diff --git a/packages/loopover-mcp/bin/loopover-mcp.js b/packages/loopover-mcp/bin/loopover-mcp.js old mode 100755 new mode 100644 index 51f1154d40..16802bbe49 --- a/packages/loopover-mcp/bin/loopover-mcp.js +++ b/packages/loopover-mcp/bin/loopover-mcp.js @@ -1034,6 +1034,12 @@ const STDIO_TOOL_DESCRIPTORS = [ 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_maintainer_lane", + category: "maintainer", + description: + "Return the repo's maintainer-lane triage report (the lane recommendation alongside the configured maintainer cut, queue health, config quality, and contributor-intake health) from the private LoopOver API. Advisory only.", + }, { name: "loopover_get_burden_forecast", category: "maintainer", @@ -1764,6 +1770,26 @@ registerStdioTool( }, ); +// #6739: CLI mirror of the remote server's loopover_get_maintainer_lane. maintainerLane ships in the same +// buildRepoIntelligenceResponse payload the sibling loopover_get_label_audit already GETs, so this is a thin +// extraction over that identical route rather than a new fetch shape. +registerStdioTool( + "loopover_get_maintainer_lane", + { + description: stdioToolDescription("loopover_get_maintainer_lane"), + inputSchema: ownerRepoShape, + }, + async ({ owner, repo }) => { + const prefix = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; + const intelligence = await apiGet(`${prefix}/intelligence`); + return toolResult("LoopOver maintainer lane.", { + repoFullName: intelligence?.repoFullName ?? `${owner}/${repo}`, + generatedAt: intelligence?.generatedAt, + maintainerLane: intelligence?.maintainerLane ?? null, + }); + }, +); + registerStdioTool( "loopover_get_burden_forecast", { diff --git a/test/unit/mcp-cli-maintainer-lane.test.ts b/test/unit/mcp-cli-maintainer-lane.test.ts new file mode 100644 index 0000000000..69b8ac4e8e --- /dev/null +++ b/test/unit/mcp-cli-maintainer-lane.test.ts @@ -0,0 +1,91 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { closeFixtureServer, startFixtureServer } from "./support/mcp-cli-harness"; + +const bin = join(process.cwd(), "packages/loopover-mcp/bin/loopover-mcp.js"); +const FORBIDDEN_PUBLIC_TERMS = /wallet\s*[:=]\s*\S+|hotkey\s*[:=]\s*\S+|coldkey\s*[:=]\s*\S+|raw trust score is|your trust score|reward estimate is|estimated reward/i; + +let client: Client; +let transport: StdioClientTransport; +let configDir: string; +let apiUrl: string; +let capturedRequests: Array<{ url: string; method: string }>; + +async function connect() { + configDir = mkdtempSync(join(tmpdir(), "gittensory-maintainer-lane-")); + capturedRequests = []; + apiUrl = await startFixtureServer({ + onApiRequest: (request) => { + if (request.url && request.url.includes("/intelligence")) { + capturedRequests.push({ url: request.url ?? "", method: request.method ?? "GET" }); + } + }, + }); + transport = new StdioClientTransport({ + command: "node", + args: [bin, "--stdio"], + env: { + ...process.env, + LOOPOVER_CONFIG_DIR: configDir, + LOOPOVER_API_URL: apiUrl, + LOOPOVER_TOKEN: "session-token", + LOOPOVER_API_TIMEOUT_MS: "5000", + }, + }); + client = new Client({ name: "maintainer-lane-test", version: "0.0.1" }); + await client.connect(transport); +} + +async function disconnect() { + await client.close().catch(() => undefined); + await closeFixtureServer(); + if (configDir) rmSync(configDir, { recursive: true, force: true }); +} + +describe("loopover_get_maintainer_lane stdio proxy", () => { + beforeEach(connect); + afterEach(disconnect); + + it("registers the tool in the stdio server tool list", async () => { + const { tools } = await client.listTools(); + expect(tools.map((t) => t.name)).toContain("loopover_get_maintainer_lane"); + }); + + it("proxies owner/repo to /v1/repos/:owner/:repo/intelligence via apiGet and returns the maintainer lane", async () => { + const result = await client.callTool({ name: "loopover_get_maintainer_lane", arguments: { owner: "owner", repo: "repo" } }); + expect(capturedRequests.length).toBe(1); + const captured = capturedRequests[0]!; + expect(captured.url).toContain("/v1/repos/owner/repo/intelligence"); + expect(captured.method).toBe("GET"); + expect(result.isError).toBeFalsy(); + const text = JSON.stringify(result); + expect(text).not.toMatch(FORBIDDEN_PUBLIC_TERMS); + expect(text).toContain("maintainerLane"); + expect(text).toContain("owner/repo"); + }); + + it("hits the SAME /intelligence route its sibling loopover_get_label_audit uses, extracting only its own field", async () => { + // maintainerLane sits beside labelAudit in one buildRepoIntelligenceResponse payload; both tools are thin + // extractions over the identical GET, so the mirrored surfaces must agree on the route and repo identity. + const lane = await client.callTool({ name: "loopover_get_maintainer_lane", arguments: { owner: "owner", repo: "repo" } }); + const audit = await client.callTool({ name: "loopover_get_label_audit", arguments: { owner: "owner", repo: "repo" } }); + + expect(capturedRequests.map((request) => request.url)).toEqual([ + expect.stringContaining("/v1/repos/owner/repo/intelligence"), + expect.stringContaining("/v1/repos/owner/repo/intelligence"), + ]); + const laneContent = lane.structuredContent as Record | undefined; + const auditContent = audit.structuredContent as Record | undefined; + expect(laneContent?.repoFullName).toEqual(auditContent?.repoFullName); + expect(laneContent?.generatedAt).toEqual(auditContent?.generatedAt); + // Each tool surfaces only its own slice of the shared response. + expect(laneContent).toHaveProperty("maintainerLane"); + expect(laneContent).not.toHaveProperty("labelAudit"); + expect(auditContent).toHaveProperty("labelAudit"); + expect(auditContent).not.toHaveProperty("maintainerLane"); + }); +}); diff --git a/test/unit/mcp-tool-rename-aliases.test.ts b/test/unit/mcp-tool-rename-aliases.test.ts index 3f7a6fa988..23d0205ab6 100644 --- a/test/unit/mcp-tool-rename-aliases.test.ts +++ b/test/unit/mcp-tool-rename-aliases.test.ts @@ -56,14 +56,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { }); afterEach(disconnect); - it("lists exactly 70 loopover_ tools and zero gittensory_-prefixed aliases", async () => { + it("lists exactly 71 loopover_ tools and zero gittensory_-prefixed aliases", async () => { const { tools } = await client.listTools(); const names = tools.map((t) => t.name); const primary = names.filter((n) => n.startsWith("loopover_")); const legacy = names.filter((n) => n.startsWith("gittensory_")); - expect(primary.length).toBe(70); + expect(primary.length).toBe(71); expect(legacy.length).toBe(0); - expect(names.length).toBe(70); + expect(names.length).toBe(71); }); it("no loopover_ tool's description carries a stale deprecation notice", async () => { @@ -73,11 +73,11 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { } }); - it("`loopover-mcp tools --json` reports the same 70-tool count the live server registers", async () => { + it("`loopover-mcp tools --json` reports the same 71-tool count the live server registers", async () => { const { tools } = await client.listTools(); const payload = JSON.parse(run(["tools", "--json"])) as { count: number; tools: Array<{ name: string }> }; expect(payload.count).toBe(tools.length); - expect(payload.count).toBe(70); + expect(payload.count).toBe(71); expect([...payload.tools.map((t) => t.name)].sort()).toEqual([...tools.map((t) => t.name)].sort()); }); });