diff --git a/packages/loopover-mcp/README.md b/packages/loopover-mcp/README.md index e0240089f2..b71f936c53 100644 --- a/packages/loopover-mcp/README.md +++ b/packages/loopover-mcp/README.md @@ -88,7 +88,7 @@ Add `--json` for machine-readable output: } ``` -`loopover-mcp tools` lists every stdio MCP tool the local wrapper registers, with its one-line description. Add `--json` for `{ "count": N, "tools": [{ "name", "description" }, ...] }`. +`loopover-mcp tools` lists every stdio MCP tool the local wrapper registers, grouped under category headers (Discovery & planning, Local branch & PR prep, Review & gate prediction, Agent automation, Maintainer & repo owner, Registry, config & status), each tool with its one-line description. Add `--json` for `{ "count": N, "categories": [{ "id", "label", "count" }, ...], "tools": [{ "name", "category", "description" }, ...] }`. ### Shell completion diff --git a/packages/loopover-mcp/bin/loopover-mcp.js b/packages/loopover-mcp/bin/loopover-mcp.js index aca8c4d67a..d18cbb45d8 100755 --- a/packages/loopover-mcp/bin/loopover-mcp.js +++ b/packages/loopover-mcp/bin/loopover-mcp.js @@ -444,176 +444,230 @@ const agentRunIdShape = { const STDIO_TOOL_DESCRIPTORS = [ { name: "loopover_get_repo_context", + category: "maintainer", description: "Return the LoopOver repo-context bundle for a repo — registration state, recommended contribution lane, queue health, duplicate-PR collisions, and config quality — from the private LoopOver API. Takes owner and repo.", }, { name: "loopover_get_pr_reviewability", + category: "review", description: "Return the reviewability report for an open PR: how ready it is to review/merge, the blocking or advisory signals against it, and its lane/duplicate/linked-issue context. Metadata-only, no GitHub writes.", }, { name: "loopover_get_maintainer_noise", + category: "maintainer", description: "Return the maintainer queue-noise triage report for a repo: a noise score/level, the specific noise sources to clear first, and recommended maintainer actions. Maintainer-authenticated; advisory only.", }, { name: "loopover_preflight_pr", + category: "discovery", description: "Preflight planned PR metadata against lane, duplicate, linked issue, test, and queue signals.", }, { name: "loopover_validate_linked_issue", + category: "discovery", description: "Report whether linking an issue will actually earn the standard linked-issue scoring multiplier for a planned PR — open, valid, single-owner, solvable by this PR — with the blocking reason if not. The raw multiplier value stays private.", }, { name: "loopover_check_before_start", + category: "discovery", description: "Before writing any code, check whether an issue is already claimed or solved, whether a duplicate cluster is forming, and whether it is a valid target. Returns a go/raise/avoid recommendation with public-safe reasons from cached metadata.", }, { name: "loopover_find_opportunities", + category: "discovery", description: "Cross-repo discovery: find high-fit contribution opportunities across registered Gittensor repos. Returns a ranked, public-safe list filtered by your MinerGoalSpec (lane, min rank score, languages). Metadata-only, no GitHub writes.", }, { name: "loopover_retrieve_issue_context", + category: "discovery", description: "Repo-scoped issue-centric RAG retrieval for the miner analyze phase. Returns related file paths and retrieval scores from issue title/body/labels — metadata only, never source text.", }, { name: "loopover_lint_pr_text", + category: "review", description: "Lint a commit message + PR body against the gittensor traceability/no-issue-rationale and Conventional Commit rubric before submitting. Returns a deterministic verdict (strong/adequate/weak) plus specific public-safe fixes. Computed in-process; no source upload and no API round-trip.", }, { name: "loopover_validate_config", + category: "utility", description: "Parse and validate a .loopover.yml manifest string using the same focus-manifest parser as the server. Returns normalized config fields, parse warnings, and an ok/warn/error status. Metadata-only, no GitHub writes.", }, { name: "loopover_check_slop_risk", + category: "review", description: "Assess the deterministic slop risk of a planned change from local diff metadata (paths + line counts) + the PR description — an agent-native, source-free quality self-check. Returns slopRisk (0-100), band, findings, and the rubric. No repo data needed.", }, { name: "loopover_check_issue_slop", + category: "review", description: "Assess the deterministic slop risk of an issue from its title + body alone (no repo data) — flags clearly low-effort issues (empty body, an unfilled template) for triage. Returns slopRisk (0-100), band, findings, and the rubric. Advisory-only.", }, { name: "loopover_preflight_local_diff", + category: "branch", description: "Inspect local git diff metadata and run LoopOver preflight without uploading source contents.", }, { name: "loopover_get_registry_changes", + category: "utility", description: "Return the latest cached report of changes to the Gittensor repo registry — repositories added, removed, or re-registered upstream. Read-only; takes no parameters.", }, { name: "loopover_get_upstream_drift", + category: "utility", description: "Return the latest cached Gittensor upstream ruleset drift status (stale/drift warnings) for MCP planning.", }, { name: "loopover_get_label_audit", + category: "maintainer", description: "Return the repo's label-policy audit (configured-vs-live labels, missing configured labels, suspicious status/source-style labels, and trusted-label-pipeline readiness) from the private LoopOver API.", }, { name: "loopover_get_burden_forecast", + category: "maintainer", description: "Return the repo's cached maintainer burden forecast (projected review load, queue-growth risk, and stale-PR signals) with a freshness marker, from the private LoopOver API.", }, { name: "loopover_preview_local_pr_score", + category: "branch", description: "Inspect local diff metadata and request a private LoopOver scoring preview. No source contents are uploaded.", }, { name: "loopover_explain_score_breakdown", + category: "review", description: "Explain a private score preview multiplier-by-multiplier with plain-English levers and the highest-impact improvement.", }, { name: "loopover_get_decision_pack", + category: "discovery", description: "Return the private decision pack for a contributor: the ranked repos and issues to work on next, with per-repo go/raise/avoid guidance. Takes login (the contributor's GitHub username).", }, { name: "loopover_explain_repo_decision", + category: "discovery", description: "Return the go/raise/avoid decision for one specific contributor-and-repo pair, drawn from that contributor's decision pack — narrower than loopover_get_decision_pack, which returns the whole pack. Takes login (GitHub username), owner, and repo.", }, { name: "loopover_compare_pr_variants", + category: "branch", description: "Compare private LoopOver scoring previews across local/metadata variants.", }, { name: "loopover_local_status", + category: "utility", description: "Return local LoopOver MCP status, inferred git repo metadata, and privacy defaults.", }, { name: "loopover_preflight_current_branch", + category: "branch", description: "Analyze the current git branch and return PR readiness. Sends metadata only.", }, { name: "loopover_review_pr_before_push", + category: "branch", description: "Run a single composed pre-PR review of the current branch: preflight (lane/duplicate/linked-issue/test/queue fit), slop-risk, and PR-text lint, merged into one report with an overall pass/warn/fail status. Thin composition of the existing checks — does not reimplement any of them. Sends metadata only, no source upload.", }, { name: "loopover_preview_current_branch_score", + category: "branch", description: "Analyze the current git branch and return private scoreability context. Sends metadata only.", }, { name: "loopover_rank_local_next_actions", + category: "branch", description: "Analyze the current git branch and rank local next actions by private reward/risk and review friction.", }, { name: "loopover_explain_local_blockers", + category: "branch", description: "Analyze the current git branch and explain private scoreability, lane, and review blockers.", }, { name: "loopover_remediation_plan", + category: "branch", description: "Analyze the current git branch and return an ordered public-safe remediation checklist with rerun conditions.", }, { name: "loopover_prepare_pr_packet", + category: "branch", description: "Analyze the current git branch and return a public-safe PR packet. Sends metadata only.", }, { name: "loopover_compare_local_variants", + category: "branch", description: "Compare current-branch metadata variants without uploading source contents.", }, { name: "loopover_agent_plan_next_work", + category: "agent", description: "Run the deterministic LoopOver planner for a contributor and return the single recommended next unit of work (repo, issue, and action). Planning only — does not queue or start a run. Takes login (GitHub username); optional objective and repoFullName narrow the result.", }, { name: "loopover_agent_start_run", + category: "agent", description: "Queue a new LoopOver automated-agent run for a contributor. Copilot mode only: it proposes and records work but takes no GitHub actions on its own. Takes objective (what to accomplish) and actorLogin (the contributor's GitHub username); returns the new run's id and status.", }, { name: "loopover_agent_get_run", + category: "agent", description: "Fetch a previously queued LoopOver agent run by its id, including current status and planned actions. Takes runId (the id returned by loopover_agent_start_run).", }, { name: "loopover_agent_explain_next_action", + category: "agent", description: "Explain the next deterministic action and blocker context for a GitHub login.", }, { name: "loopover_agent_prepare_pr_packet", + category: "branch", description: "Prepare a public-safe PR packet from current branch metadata. Sends metadata only.", }, { name: "loopover_local_status_structured", + category: "utility", description: "Return local LoopOver MCP status with a validated structured output schema.", }, { name: "loopover_feasibility_gate", + category: "discovery", description: "Pure local go/raise/avoid feasibility verdict from claim status, duplicate-cluster risk, and issue quality/lifecycle status — the same discriminants the analyze-phase feasibility gate branches on. When repoFullName/issueNumber are supplied and a local loopover-miner install's claim ledger is present, claimStatus is read from that ledger instead of the caller-supplied value; otherwise falls back to the caller-supplied claimStatus unchanged. Advisory-only — never blocks, cancels, or overrides a claim or attempt; real claim-conflict resolution authority stays with the maintainer-only path. No API round-trip.", }, { name: "loopover_get_issue_quality", + category: "maintainer", description: "Return the cached or freshly-computed issue-quality report for a repo, ranking which open issues are actionable, need proof, are stale/duplicate-prone, or already solved.", }, { name: "loopover_get_registration_readiness", + category: "maintainer", description: "Preview-only registration-readiness report for a repository: what's missing/present before/after registering with LoopOver (direct-PR and issue-discovery lane readiness, label policy, maintainer-cut readiness, queue health, docs, and the GitHub App install state). Advisory only, not a registration action.", }, { name: "loopover_get_config_recommendation", + category: "maintainer", description: "Return recommended .loopover.yml additions for a repository, derived from the repo's live, currently-active configured behavior (the raw dashboard/API-configured settings, not a yml-merged view — so the recommendation never compares itself against an override that already exists). Advisory only, not a write action.", }, { name: "loopover_get_skipped_pr_audit", + category: "maintainer", description: "Return the skipped-PR audit trail: pull requests LoopOver's automated reviewer intentionally stayed quiet on, each with a reason code and a remediation hint. Optionally filter by repoFullName, reason, or since. Maintainer-authenticated; read-only measurement, not a moderation or override action.", }, ]; +// #6301 — coarse tool categories for grouping `loopover-mcp tools` output. Ordered +// contributor-facing surfaces first, operator ones last; the `label` is the human-readable header. +// Every STDIO_TOOL_DESCRIPTORS entry carries a `category` id drawn from this list (asserted in tests). +const STDIO_TOOL_CATEGORIES = [ + { id: "discovery", label: "Discovery & planning" }, + { id: "branch", label: "Local branch & PR prep" }, + { id: "review", label: "Review & gate prediction" }, + { id: "agent", label: "Agent automation" }, + { id: "maintainer", label: "Maintainer & repo owner" }, + { id: "utility", label: "Registry, config & status" }, +]; + function stdioToolDescription(name) { const tool = STDIO_TOOL_DESCRIPTORS.find((entry) => entry.name === name); if (!tool) throw new Error(`Unknown stdio tool descriptor: ${name}`); @@ -2523,13 +2577,31 @@ function toolsCommand(args) { const subcommand = args[0]; if (subcommand === "search") return toolsSearchCommand(args.slice(1)); const options = parseOptions(args); - const tools = STDIO_TOOL_DESCRIPTORS.map(({ name, description }) => ({ name, description })); - const payload = { count: tools.length, tools }; + const tools = STDIO_TOOL_DESCRIPTORS.map(({ name, category, description }) => ({ name, category, description })); + // Group tools by category in the canonical order; any category with no tools is omitted, and a tool + // whose category is unknown falls into a trailing "Other" bucket so nothing is silently dropped. + const knownIds = new Set(STDIO_TOOL_CATEGORIES.map((entry) => entry.id)); + const groups = [ + ...STDIO_TOOL_CATEGORIES.map((entry) => ({ ...entry, tools: tools.filter((tool) => tool.category === entry.id) })), + { id: "other", label: "Other", tools: tools.filter((tool) => !knownIds.has(tool.category)) }, + ].filter((group) => group.tools.length > 0); if (options.json) { + const payload = { + count: tools.length, + categories: groups.map((group) => ({ id: group.id, label: group.label, count: group.tools.length })), + tools, + }; process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); return; } - printToolRows(tools); + const nameWidth = tools.reduce((width, tool) => Math.max(width, tool.name.length), 0); + groups.forEach((group, index) => { + if (index > 0) process.stdout.write("\n"); + process.stdout.write(`${group.label} (${group.tools.length})\n`); + for (const tool of group.tools) { + process.stdout.write(` ${tool.name.padEnd(nameWidth)} ${tool.description}\n`); + } + }); } // `tools search ` — fuzzy discovery across the ~150-tool combined surface (#6300). Matches the diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 3d2ce03a8e..b03b03d302 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1677,6 +1677,111 @@ async function describeMcpUsageRequest(request: Request, telemetryMetadata: Reco }; } +// #6301 — coarse tool categories so tools/list clients and the `loopover-mcp tools` CLI can group +// this server's tool surface by the repo's own conceptual groupings instead of reading one flat +// list. The ids mirror the issue's suggested surfaces: contributor discovery/planning, local-branch +// & PR prep, review/gate prediction, agent automation, maintainer/repo-owner, and registry/config +// utility. Attached to each tool as MCP `_meta.category` at registration (see createServer). +export type McpToolCategory = "discovery" | "branch" | "review" | "agent" | "maintainer" | "utility"; + +// Canonical category order for grouped rendering (contributor-facing surfaces first, operator ones +// last). Kept as a single source of truth so a display/grouping consumer never invents its own order. +export const MCP_TOOL_CATEGORY_IDS: readonly McpToolCategory[] = ["discovery", "branch", "review", "agent", "maintainer", "utility"]; + +// Every registered tool maps to exactly one category. Listed in registration order (matching +// createServer) so a new tool without a category entry is easy to spot in review; the +// every-tool-has-a-category test fails loudly if one is ever missed. +export const MCP_TOOL_CATEGORIES: Record = { + loopover_get_repo_context: "maintainer", + loopover_get_maintainer_noise: "maintainer", + loopover_get_label_audit: "maintainer", + loopover_get_maintainer_lane: "maintainer", + loopover_get_repo_onboarding_pack: "maintainer", + loopover_get_registration_readiness: "maintainer", + loopover_get_config_recommendation: "maintainer", + loopover_get_burden_forecast: "maintainer", + loopover_get_repo_outcome_patterns: "maintainer", + loopover_get_outcome_calibration: "maintainer", + loopover_get_gate_precision: "maintainer", + loopover_get_skipped_pr_audit: "maintainer", + loopover_get_fleet_analytics: "maintainer", + loopover_get_recommendation_quality: "maintainer", + loopover_simulate_open_pr_pressure: "discovery", + loopover_get_contributor_profile: "discovery", + loopover_get_decision_pack: "discovery", + loopover_monitor_open_prs: "discovery", + loopover_predict_gate: "review", + loopover_explain_gate_disposition: "review", + loopover_intake_idea: "agent", + loopover_plan_idea_claims: "agent", + loopover_build_results_payload: "agent", + loopover_build_progress_snapshot: "agent", + loopover_evaluate_escalation: "agent", + loopover_check_slop_risk: "review", + loopover_check_improvement_potential: "review", + loopover_check_test_evidence: "review", + loopover_check_issue_slop: "review", + loopover_suggest_boundary_tests: "review", + loopover_pr_outcome: "review", + loopover_get_pr_ai_review_findings: "review", + loopover_list_notifications: "utility", + loopover_mark_notifications_read: "utility", + loopover_watch_issues: "utility", + loopover_explain_repo_decision: "discovery", + loopover_preflight_pr: "discovery", + loopover_get_bounty_advisory: "discovery", + loopover_get_registry_changes: "utility", + loopover_get_upstream_drift: "utility", + loopover_get_issue_quality: "maintainer", + loopover_get_pr_reviewability: "review", + loopover_validate_linked_issue: "discovery", + loopover_check_before_start: "discovery", + loopover_find_opportunities: "discovery", + loopover_retrieve_issue_context: "discovery", + loopover_lint_pr_text: "review", + loopover_validate_config: "utility", + loopover_preflight_local_diff: "branch", + loopover_preview_local_pr_score: "branch", + loopover_get_eligibility_plan: "discovery", + loopover_run_local_scorer: "branch", + loopover_open_pr: "agent", + loopover_file_issue: "agent", + loopover_apply_labels: "agent", + loopover_post_eligibility_comment: "agent", + loopover_create_branch: "agent", + loopover_delete_branch: "agent", + loopover_generate_tests: "agent", + loopover_file_follow_up_issue: "agent", + loopover_build_plan: "agent", + loopover_plan_status: "agent", + loopover_record_step_result: "agent", + loopover_get_automation_state: "agent", + loopover_set_agent_paused: "agent", + loopover_set_action_autonomy: "agent", + loopover_propose_action: "agent", + loopover_list_pending_actions: "agent", + loopover_decide_pending_action: "agent", + loopover_refresh_repo_docs: "maintainer", + loopover_get_agent_audit_feed: "agent", + loopover_explain_score_breakdown: "review", + loopover_explain_review_risk: "review", + loopover_compare_pr_variants: "branch", + loopover_local_status: "utility", + loopover_preflight_current_branch: "branch", + loopover_preview_current_branch_score: "branch", + loopover_rank_local_next_actions: "branch", + loopover_explain_local_blockers: "branch", + loopover_remediation_plan: "branch", + loopover_prepare_pr_packet: "branch", + loopover_draft_pr_body: "branch", + loopover_compare_local_variants: "branch", + loopover_agent_plan_next_work: "agent", + loopover_agent_start_run: "agent", + loopover_agent_get_run: "agent", + loopover_agent_explain_next_action: "agent", + loopover_agent_prepare_pr_packet: "branch", +}; + export class LoopoverMcp { private accessScopePromise: Promise | null = null; @@ -1691,7 +1796,13 @@ export class LoopoverMcp { version: "0.1.0", }); - server.registerTool( + // #6301 — register every tool through this thin wrapper so its category rides along as MCP + // `_meta.category`, exposed in tools/list for clients (and mirrored by the CLI `tools` command). + const baseRegister = server.registerTool.bind(server); + const register: McpServer["registerTool"] = (name, config, cb) => + baseRegister(name, { ...config, _meta: { category: MCP_TOOL_CATEGORIES[name] } }, cb); + + register( "loopover_get_repo_context", { description: "Return LoopOver repo context: registration, lane, queue health, collisions, and config quality.", @@ -1701,7 +1812,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.getRepoContext(input)), ); - server.registerTool( + register( "loopover_get_maintainer_noise", { description: "Return the maintainer queue-noise triage report for a repo: a noise score/level, the specific noise sources to clear first, and recommended maintainer actions. Maintainer-authenticated; advisory only.", @@ -1711,7 +1822,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.getMaintainerNoise(input)), ); - server.registerTool( + register( "loopover_get_label_audit", { description: "Return the repo's label-policy audit: configured-vs-live labels, missing configured labels, suspicious status/source-style labels, and trusted-label-pipeline readiness for label-multiplier scoring. Maintainer-authenticated; advisory only.", @@ -1721,7 +1832,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.getLabelAudit(input)), ); - server.registerTool( + register( "loopover_get_maintainer_lane", { description: "Return the maintainer-lane triage report for a repo: the lane recommendation alongside the configured maintainer cut, queue health, config quality, and contributor-intake health. Maintainer-authenticated; advisory only.", @@ -1731,7 +1842,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.getMaintainerLane(input)), ); - server.registerTool( + register( "loopover_get_repo_onboarding_pack", { description: @@ -1742,7 +1853,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.getRepoOnboardingPack(input)), ); - server.registerTool( + register( "loopover_get_registration_readiness", { description: @@ -1753,7 +1864,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.getRegistrationReadiness(input)), ); - server.registerTool( + register( "loopover_get_config_recommendation", { description: @@ -1764,7 +1875,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.getConfigRecommendation(input)), ); - server.registerTool( + register( "loopover_get_burden_forecast", { description: "Return the cached maintainer burden forecast for a repo, including projected review load, queue growth risk, stale PR signals, and a freshness marker.", @@ -1774,7 +1885,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.getBurdenForecast(input)), ); - server.registerTool( + register( "loopover_get_repo_outcome_patterns", { description: "Return cached or freshly-computed per-repo accepted/rejected PR outcome patterns: what maintainers actually merge or close, separated from maintainer-lane activity, with a freshness marker and explicit evidence-completeness.", @@ -1784,7 +1895,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.getRepoOutcomePatterns(input)), ); - server.registerTool( + register( "loopover_get_outcome_calibration", { description: @@ -1795,7 +1906,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.getOutcomeCalibration(input)), ); - server.registerTool( + register( "loopover_get_gate_precision", { description: @@ -1806,7 +1917,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.getGatePrecision(input)), ); - server.registerTool( + register( "loopover_get_skipped_pr_audit", { description: @@ -1817,7 +1928,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.getSkippedPrAudit(input)), ); - server.registerTool( + register( "loopover_get_fleet_analytics", { description: @@ -1828,7 +1939,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.getFleetAnalytics(input)), ); - server.registerTool( + register( "loopover_get_recommendation_quality", { description: @@ -1839,7 +1950,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.getRecommendationQuality(input)), ); - server.registerTool( + register( "loopover_simulate_open_pr_pressure", { description: @@ -1850,7 +1961,7 @@ export class LoopoverMcp { async (input) => this.toolResult(this.simulateOpenPrPressureTool(input)), ); - server.registerTool( + register( "loopover_get_contributor_profile", { description: "Return an evidence-backed LoopOver contributor profile for a GitHub login.", @@ -1860,7 +1971,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.getContributorProfile(input.login)), ); - server.registerTool( + register( "loopover_get_decision_pack", { description: "Return the canonical private contributor decision pack for a GitHub login.", @@ -1870,7 +1981,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.getDecisionPack(input.login)), ); - server.registerTool( + register( "loopover_monitor_open_prs", { description: @@ -1881,7 +1992,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.monitorOpenPullRequests(input.login)), ); - server.registerTool( + register( "loopover_predict_gate", { description: @@ -1892,7 +2003,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.predictGate(input)), ); - server.registerTool( + register( "loopover_explain_gate_disposition", { description: @@ -1903,7 +2014,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.explainGateDisposition(input)), ); - server.registerTool( + register( "loopover_intake_idea", { description: @@ -1914,7 +2025,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.intakeIdea(input)), ); - server.registerTool( + register( "loopover_plan_idea_claims", { description: @@ -1925,7 +2036,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.planIdeaClaims(input)), ); - server.registerTool( + register( "loopover_build_results_payload", { description: @@ -1936,7 +2047,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.buildLoopResults(input)), ); - server.registerTool( + register( "loopover_build_progress_snapshot", { description: @@ -1947,7 +2058,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.buildLoopProgress(input)), ); - server.registerTool( + register( "loopover_evaluate_escalation", { description: @@ -1958,7 +2069,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.evalEscalation(input)), ); - server.registerTool( + register( "loopover_check_slop_risk", { description: @@ -1969,7 +2080,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.checkSlopRisk(input)), ); - server.registerTool( + register( "loopover_check_improvement_potential", { description: @@ -1980,7 +2091,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.checkImprovementPotential(input)), ); - server.registerTool( + register( "loopover_check_test_evidence", { description: @@ -1991,7 +2102,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.checkTestEvidence(input)), ); - server.registerTool( + register( "loopover_check_issue_slop", { description: @@ -2002,7 +2113,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.checkIssueSlop(input)), ); - server.registerTool( + register( "loopover_suggest_boundary_tests", { description: @@ -2013,7 +2124,7 @@ export class LoopoverMcp { async (input) => this.toolResult(this.suggestBoundaryTests(input)), ); - server.registerTool( + register( "loopover_pr_outcome", { description: @@ -2024,7 +2135,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.prOutcomes(input.login, input.limit)), ); - server.registerTool( + register( "loopover_get_pr_ai_review_findings", { description: @@ -2035,7 +2146,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.getPrAiReviewFindings(input)), ); - server.registerTool( + register( "loopover_list_notifications", { description: @@ -2046,7 +2157,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.listNotifications(input.login)), ); - server.registerTool( + register( "loopover_mark_notifications_read", { description: @@ -2057,7 +2168,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.markNotificationsRead(input.login, input.ids)), ); - server.registerTool( + register( "loopover_watch_issues", { description: @@ -2068,7 +2179,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.watchIssues(input)), ); - server.registerTool( + register( "loopover_explain_repo_decision", { description: "Return the contributor/repo decision from the canonical decision pack.", @@ -2078,7 +2189,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.explainRepoDecision(input)), ); - server.registerTool( + register( "loopover_preflight_pr", { description: "Preflight a planned PR for lane correctness, duplicate risk, linked issues, and review burden.", @@ -2088,7 +2199,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.preflightPr(input)), ); - server.registerTool( + register( "loopover_get_bounty_advisory", { description: "Return lifecycle, funding, and consensus-risk context for a cached Gittensor bounty.", @@ -2098,7 +2209,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.getBountyAdvisory(input.id)), ); - server.registerTool( + register( "loopover_get_registry_changes", { description: "Return the diff between the latest cached Gittensor registry snapshots.", @@ -2108,7 +2219,7 @@ export class LoopoverMcp { async () => this.toolResult(await this.getRegistryChanges()), ); - server.registerTool( + register( "loopover_get_upstream_drift", { description: "Return private upstream Gittensor ruleset drift status, including stale/drift warnings for MCP planning.", @@ -2118,7 +2229,7 @@ export class LoopoverMcp { async () => this.toolResult(await this.getUpstreamDrift()), ); - server.registerTool( + register( "loopover_get_issue_quality", { description: "Return the cached or freshly-computed issue-quality report for a repo, ranking which open issues are actionable, need proof, are stale/duplicate-prone, or already solved.", @@ -2128,7 +2239,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.getIssueQuality(input)), ); - server.registerTool( + register( "loopover_get_pr_reviewability", { description: @@ -2139,7 +2250,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.getPrReviewability(input)), ); - server.registerTool( + register( "loopover_validate_linked_issue", { description: @@ -2150,7 +2261,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.validateLinkedIssue(input)), ); - server.registerTool( + register( "loopover_check_before_start", { description: @@ -2161,7 +2272,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.checkBeforeStart(input)), ); - server.registerTool( + register( "loopover_find_opportunities", { description: @@ -2172,7 +2283,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.findOpportunities(input)), ); - server.registerTool( + register( "loopover_retrieve_issue_context", { description: @@ -2183,7 +2294,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.retrieveIssueContext(input)), ); - server.registerTool( + register( "loopover_lint_pr_text", { description: @@ -2194,7 +2305,7 @@ export class LoopoverMcp { async (input) => this.toolResult(this.lintPrText(input)), ); - server.registerTool( + register( "loopover_validate_config", { description: @@ -2205,7 +2316,7 @@ export class LoopoverMcp { async (input) => this.toolResult(this.validateConfig(input)), ); - server.registerTool( + register( "loopover_preflight_local_diff", { description: "Preflight local git-diff metadata without uploading code content.", @@ -2215,7 +2326,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.preflightLocalDiff(input)), ); - server.registerTool( + register( "loopover_preview_local_pr_score", { description: "Return a private scoring preview from local diff metrics or supplied metadata. Source contents are not required.", @@ -2225,7 +2336,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.previewScore(input)), ); - server.registerTool( + register( "loopover_get_eligibility_plan", { description: @@ -2236,7 +2347,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.getEligibilityPlan(input)), ); - server.registerTool( + register( "loopover_run_local_scorer", { description: @@ -2248,37 +2359,37 @@ export class LoopoverMcp { ); // #780 miner write-tools — each returns a LOCAL-execution action spec; loopover never performs the write. - server.registerTool( + register( "loopover_open_pr", { description: "Build a LOCAL-execution spec to open a pull request from your branch (run it with your own gh creds; loopover never performs the write).", inputSchema: openPrShape, outputSchema: localWriteActionOutputSchema }, async (input) => this.toolResult(this.localWriteSpec(buildOpenPrSpec(input))), ); - server.registerTool( + register( "loopover_file_issue", { description: "Build a LOCAL-execution spec to file an issue (run it with your own gh creds; loopover never performs the write).", inputSchema: fileIssueShape, outputSchema: localWriteActionOutputSchema }, async (input) => this.toolResult(this.localWriteSpec(buildFileIssueSpec(input))), ); - server.registerTool( + register( "loopover_apply_labels", { description: "Build a LOCAL-execution spec to add labels to an issue or PR (run it with your own gh creds; loopover never performs the write).", inputSchema: applyLabelsShape, outputSchema: localWriteActionOutputSchema }, async (input) => this.toolResult(this.localWriteSpec(buildApplyLabelsSpec(input))), ); - server.registerTool( + register( "loopover_post_eligibility_comment", { description: "Build a LOCAL-execution spec to post an eligibility/context comment on an issue or PR (run it with your own gh creds; loopover never performs the write).", inputSchema: postEligibilityCommentShape, outputSchema: localWriteActionOutputSchema }, async (input) => this.toolResult(this.localWriteSpec(buildPostEligibilityCommentSpec(input))), ); - server.registerTool( + register( "loopover_create_branch", { description: "Build a LOCAL-execution spec to create a branch (run it locally; loopover never performs the write).", inputSchema: createBranchShape, outputSchema: localWriteActionOutputSchema }, async (input) => this.toolResult(this.localWriteSpec(buildCreateBranchSpec(input))), ); - server.registerTool( + register( "loopover_delete_branch", { description: "Build a LOCAL-execution spec to delete a branch (run it locally; loopover never performs the write).", inputSchema: deleteBranchShape, outputSchema: localWriteActionOutputSchema }, async (input) => this.toolResult(this.localWriteSpec(buildDeleteBranchSpec(input))), ); - server.registerTool( + register( "loopover_generate_tests", { description: @@ -2288,7 +2399,7 @@ export class LoopoverMcp { }, async (input) => this.toolResult(this.localWriteSpec(buildTestGenSpec(input))), ); - server.registerTool( + register( "loopover_file_follow_up_issue", { description: @@ -2300,17 +2411,17 @@ export class LoopoverMcp { ); // #783 multi-step plan DAG — stateless: pass the plan back each call. - server.registerTool( + register( "loopover_build_plan", { description: "Normalize raw steps into a validated multi-step plan DAG (per-step state + retries). Returns the plan to hold and pass back to the other plan tools.", inputSchema: buildPlanShape, outputSchema: planViewOutputSchema }, async (input) => this.toolResult(this.buildPlan(input)), ); - server.registerTool( + register( "loopover_plan_status", { description: "Return a plan's progress, validation, and the steps ready to run now (all dependencies met).", inputSchema: planStatusShape, outputSchema: planViewOutputSchema }, async (input) => this.toolResult(this.planStatusTool(input)), ); - server.registerTool( + register( "loopover_record_step_result", { description: "Record a step's outcome (completed / failed / skipped). A failure retries until maxAttempts is exhausted. Returns the advanced plan + the next ready steps.", inputSchema: recordStepResultShape, outputSchema: planViewOutputSchema }, async (input) => this.toolResult(this.recordStepResult(input)), @@ -2318,7 +2429,7 @@ export class LoopoverMcp { // #784 (MCP control surface, read side): a repo's agent automation posture — autonomy dial, kill-switch / // dry-run mode, write-permission readiness, and the pending-approval count. Repo-access scoped. - server.registerTool( + register( "loopover_get_automation_state", { description: @@ -2331,7 +2442,7 @@ export class LoopoverMcp { // #6087 (MCP control surface, write side): the missing MCP counterpart to `maintain pause`/`resume` // (loopover-mcp.js:1783). Maintainer-manage access required, same as loopover_propose_action. - server.registerTool( + register( "loopover_set_agent_paused", { description: @@ -2344,7 +2455,7 @@ export class LoopoverMcp { // #6087 (MCP control surface, write side): the missing MCP counterpart to `maintain set-level` // (loopover-mcp.js:1789). Maintainer-manage access required, same as loopover_propose_action. - server.registerTool( + register( "loopover_set_action_autonomy", { description: @@ -2355,7 +2466,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.setActionAutonomy(input)), ); - server.registerTool( + register( "loopover_propose_action", { description: @@ -2366,7 +2477,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.proposeAction(input)), ); - server.registerTool( + register( "loopover_list_pending_actions", { description: @@ -2377,7 +2488,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.listPendingActions(input)), ); - server.registerTool( + register( "loopover_decide_pending_action", { description: @@ -2388,7 +2499,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.decidePendingAction(input)), ); - server.registerTool( + register( "loopover_refresh_repo_docs", { description: @@ -2399,7 +2510,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.refreshRepoDocs(input)), ); - server.registerTool( + register( "loopover_get_agent_audit_feed", { description: @@ -2410,7 +2521,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.getAgentAuditFeed(input)), ); - server.registerTool( + register( "loopover_explain_score_breakdown", { description: @@ -2421,7 +2532,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.explainScoreBreakdown(input)), ); - server.registerTool( + register( "loopover_explain_review_risk", { description: "Explain review risk for a planned PR using preflight, lane, duplicate, and role context.", @@ -2431,7 +2542,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.explainReviewRisk(input)), ); - server.registerTool( + register( "loopover_compare_pr_variants", { description: "Compare private scoring previews for multiple PR variants.", @@ -2441,7 +2552,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.comparePrVariants(input.variants)), ); - server.registerTool( + register( "loopover_local_status", { description: "Return LoopOver local-MCP contract status and privacy defaults.", @@ -2470,7 +2581,7 @@ export class LoopoverMcp { }), ); - server.registerTool( + register( "loopover_preflight_current_branch", { description: "Analyze current-branch metadata supplied by a local MCP wrapper and return PR readiness.", @@ -2480,7 +2591,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.localBranchSlice(input, "preflight")), ); - server.registerTool( + register( "loopover_preview_current_branch_score", { description: "Analyze current-branch metadata and return private scoreability context.", @@ -2490,7 +2601,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.localBranchSlice(input, "scorePreview")), ); - server.registerTool( + register( "loopover_rank_local_next_actions", { description: "Analyze current-branch metadata and rank local next actions by private reward/risk signals.", @@ -2500,7 +2611,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.localBranchSlice(input, "nextActions")), ); - server.registerTool( + register( "loopover_explain_local_blockers", { description: "Analyze current-branch metadata and explain private scoreability and review blockers.", @@ -2510,7 +2621,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.localBranchSlice(input, "scoreBlockers")), ); - server.registerTool( + register( "loopover_remediation_plan", { description: @@ -2521,7 +2632,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.remediationPlan(input)), ); - server.registerTool( + register( "loopover_prepare_pr_packet", { description: "Analyze current-branch metadata and return a public-safe PR packet for coding agents.", @@ -2531,7 +2642,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.localBranchSlice(input, "prPacket")), ); - server.registerTool( + register( "loopover_draft_pr_body", { description: "Draft a public-safe, copy/paste PR body from local branch metadata (changed files, tests run, linked issue, duplicate/WIP caution, branch freshness, next steps). Private scoreability/reward/trust context is excluded; source contents are not uploaded.", @@ -2541,7 +2652,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.draftPrBody(input)), ); - server.registerTool( + register( "loopover_compare_local_variants", { description: "Compare private local-branch analysis variants without source uploads.", @@ -2551,7 +2662,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.compareLocalVariants(input.variants)), ); - server.registerTool( + register( "loopover_agent_plan_next_work", { description: "Run the deterministic LoopOver base-agent planner and rank the next Gittensor OSS contribution actions.", @@ -2561,7 +2672,7 @@ export class LoopoverMcp { async (input, extra) => this.toolResult(await this.agentPlanNextWork(input, extra, server)), ); - server.registerTool( + register( "loopover_agent_start_run", { description: "Create a queued copilot-only LoopOver agent run. The agent plans and explains; it does not edit code or open PRs.", @@ -2571,7 +2682,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.agentStartRun(input)), ); - server.registerTool( + register( "loopover_agent_get_run", { description: "Fetch a persisted LoopOver agent run with ranked actions and context snapshots.", @@ -2581,7 +2692,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.agentGetRun(input.runId)), ); - server.registerTool( + register( "loopover_agent_explain_next_action", { description: "Explain the top deterministic next action and its scoreability/risk/maintainer impact.", @@ -2591,7 +2702,7 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.agentExplainNextAction(input)), ); - server.registerTool( + register( "loopover_agent_prepare_pr_packet", { description: "Prepare a public-safe PR packet from local branch metadata. Source contents are not uploaded.", diff --git a/test/unit/mcp-cli-tools.test.ts b/test/unit/mcp-cli-tools.test.ts index cf253f5132..ff4c881f64 100644 --- a/test/unit/mcp-cli-tools.test.ts +++ b/test/unit/mcp-cli-tools.test.ts @@ -75,6 +75,51 @@ describe("loopover-mcp CLI — tools", () => { } }); + it("annotates every tool with exactly one known category and groups the output by it (#6301)", () => { + const categories = [ + { id: "discovery", label: "Discovery & planning" }, + { id: "branch", label: "Local branch & PR prep" }, + { id: "review", label: "Review & gate prediction" }, + { id: "agent", label: "Agent automation" }, + { id: "maintainer", label: "Maintainer & repo owner" }, + { id: "utility", label: "Registry, config & status" }, + ]; + const validIds = new Set(categories.map((category) => category.id)); + + const payload = JSON.parse(run(["tools", "--json"])) as { + count: number; + categories: Array<{ id: string; label: string; count: number }>; + tools: Array<{ name: string; category: string; description: string }>; + }; + + // Every tool has exactly one category, and it is one of the known ids. + for (const tool of payload.tools) { + expect(typeof tool.category, `missing category for ${tool.name}`).toBe("string"); + expect(validIds.has(tool.category), `unknown category ${tool.category} for ${tool.name}`).toBe(true); + } + + // The category summary partitions the tools exactly: counts sum to the total, and each label + // matches the canonical one for its id. + const summedCount = payload.categories.reduce((total, category) => total + category.count, 0); + expect(summedCount).toBe(payload.count); + const labelById = new Map(categories.map((category) => [category.id, category.label])); + for (const category of payload.categories) { + expect(category.label).toBe(labelById.get(category.id)); + expect(category.count).toBe(payload.tools.filter((tool) => tool.category === category.id).length); + } + + // Human output groups tools under their category headers, in the canonical order, with every + // tool listed exactly once under a header that matches its own category. + const plain = run(["tools"]); + const emittedLabels = payload.categories.map((category) => category.label); + const headerOrder = emittedLabels.map((label) => plain.indexOf(`${label} (`)); + expect(headerOrder.every((index) => index >= 0)).toBe(true); + expect([...headerOrder]).toEqual([...headerOrder].sort((a, b) => a - b)); + for (const category of payload.categories) { + expect(plain).toContain(`${category.label} (${category.count})`); + } + }); + it("documents LOOPOVER_LOGIN / GITHUB_LOGIN in the --help Environment block (#5930)", () => { const help = run(["--help"]); expect(help).toContain("Environment:"); diff --git a/test/unit/mcp-tool-categories.test.ts b/test/unit/mcp-tool-categories.test.ts new file mode 100644 index 0000000000..e571fa16e5 --- /dev/null +++ b/test/unit/mcp-tool-categories.test.ts @@ -0,0 +1,66 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { describe, expect, it } from "vitest"; +import { LoopoverMcp, MCP_TOOL_CATEGORIES, MCP_TOOL_CATEGORY_IDS } from "../../src/mcp/server"; +import { createTestEnv } from "../helpers/d1"; + +async function listRegisteredTools() { + const mcpServer = new LoopoverMcp(createTestEnv()).createServer(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await mcpServer.connect(serverTransport); + const client = new Client({ name: "tool-category-test", version: "0.1.0" }, { capabilities: {} }); + await client.connect(clientTransport); + const { tools } = await client.listTools(); + return { client, tools }; +} + +// #6301 — every registered tool carries exactly one category, surfaced as MCP `_meta.category` so +// tools/list clients (and the CLI `tools` command) can group the surface instead of reading a flat list. +describe("MCP remote server tool categorization (#6301)", () => { + it("exposes exactly one known category on every registered tool via _meta", async () => { + const { client, tools } = await listRegisteredTools(); + const validIds = new Set(MCP_TOOL_CATEGORY_IDS); + expect(tools.length).toBeGreaterThan(0); + + const uncategorized: string[] = []; + const unknown: string[] = []; + for (const tool of tools) { + const category = (tool._meta as { category?: unknown } | undefined)?.category; + if (typeof category !== "string" || category.length === 0) { + uncategorized.push(tool.name); + continue; + } + if (!validIds.has(category)) unknown.push(`${tool.name}:${category}`); + } + expect(uncategorized, `tools missing a category: ${uncategorized.join(", ")}`).toEqual([]); + expect(unknown, `tools with an unknown category: ${unknown.join(", ")}`).toEqual([]); + await client.close(); + }); + + it("keeps the MCP_TOOL_CATEGORIES map in exact sync with the registered tool set", async () => { + const { client, tools } = await listRegisteredTools(); + const registered = new Set(tools.map((tool) => tool.name)); + const mapped = new Set(Object.keys(MCP_TOOL_CATEGORIES)); + + const missingFromMap = [...registered].filter((name) => !mapped.has(name)).sort(); + const staleInMap = [...mapped].filter((name) => !registered.has(name)).sort(); + expect(missingFromMap, `registered tools with no category entry: ${missingFromMap.join(", ")}`).toEqual([]); + expect(staleInMap, `category entries for tools that are no longer registered: ${staleInMap.join(", ")}`).toEqual([]); + + // The category surfaced over the wire matches the source-of-truth map for every tool. + for (const tool of tools) { + const category = (tool._meta as { category?: unknown } | undefined)?.category; + expect(category, `wire category mismatch for ${tool.name}`).toBe(MCP_TOOL_CATEGORIES[tool.name]); + } + await client.close(); + }); + + it("only uses category ids drawn from the canonical id list", () => { + const validIds = new Set(MCP_TOOL_CATEGORY_IDS); + for (const [name, category] of Object.entries(MCP_TOOL_CATEGORIES)) { + expect(validIds.has(category), `${name} maps to unknown category ${category}`).toBe(true); + } + // The canonical id list has no duplicates. + expect(new Set(MCP_TOOL_CATEGORY_IDS).size).toBe(MCP_TOOL_CATEGORY_IDS.length); + }); +});