From 59c2fb0cdcf557205217dcc5f3bcb8c091bffde6 Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Wed, 22 Jul 2026 01:28:57 +0800 Subject: [PATCH] feat(mcp): add remote + stdio surfaces for loopover_get_registry_snapshot Mirror loopover_get_registry_changes on both the remote MCP server and local stdio wrapper. Cover found/not-found remotely and instrument the bin via the #7764 in-process exported-server pattern so codecov/patch sees the new lines (unlike bin-only #7903). Co-authored-by: Cursor --- packages/loopover-mcp/bin/loopover-mcp.ts | 15 ++++ src/mcp/server.ts | 41 ++++++++++- test/integration/api.test.ts | 2 + test/unit/mcp-cli-registry-snapshot.test.ts | 77 +++++++++++++++++++++ test/unit/mcp-output-schemas.test.ts | 28 +++++++- test/unit/mcp-tool-rename-aliases.test.ts | 15 ++-- test/unit/support/mcp-cli-harness.ts | 16 +++++ 7 files changed, 185 insertions(+), 9 deletions(-) create mode 100644 test/unit/mcp-cli-registry-snapshot.test.ts diff --git a/packages/loopover-mcp/bin/loopover-mcp.ts b/packages/loopover-mcp/bin/loopover-mcp.ts index 9c6a26fd35..f1e82b821f 100644 --- a/packages/loopover-mcp/bin/loopover-mcp.ts +++ b/packages/loopover-mcp/bin/loopover-mcp.ts @@ -1127,6 +1127,12 @@ const STDIO_TOOL_DESCRIPTORS = [ 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_registry_snapshot", + category: "utility", + description: + "Return the latest cached Gittensor registry snapshot (the raw current snapshot — repositories, emission shares, and warnings — not a diff). Read-only; takes no parameters. Public/unauthenticated, same as GET /v1/registry/snapshot.", + }, { name: "loopover_get_upstream_drift", category: "utility", @@ -1976,6 +1982,15 @@ registerStdioTool( async () => toolResult("LoopOver registry changes.", await apiGet("/v1/registry/changes")), ); +registerStdioTool( + "loopover_get_registry_snapshot", + { + description: stdioToolDescription("loopover_get_registry_snapshot"), + inputSchema: {}, + }, + async () => toolResult("LoopOver registry snapshot.", await apiGet("/v1/registry/snapshot")), +); + // #6736: CLI mirror of the public loopover_get_bounty_advisory tool. Proxies the same unauthenticated // GET /v1/bounties/:id/advisory the remote tool wraps -- no owner/repo, just the cached-bounty id. registerStdioTool( diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 0d178b1b3f..5cf3c09e46 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -87,7 +87,7 @@ import { generateContributorIssueDrafts } from "../services/contributor-issue-dr import { generateIssuePlanDrafts } from "../services/issue-plan-draft"; import { sanitizePublicComment } from "../github/commands"; import { fetchPublicContributorProfile } from "../github/public"; -import { listLatestRegistrySnapshots } from "../registry/sync"; +import { listLatestRegistrySnapshots, getLatestRegistrySnapshot } from "../registry/sync"; import { getOrCreateScoringModelSnapshot, isTimeDecayEnabled } from "../scoring/model"; import { buildScorePreview, makeScorePreviewRecord } from "../scoring/preview"; import { @@ -1413,6 +1413,18 @@ const registryChangesOutputSchema = { summary: z.string().optional(), }; +const registrySnapshotOutputSchema = { + id: z.string().optional(), + generatedAt: z.string().optional(), + fetchedAt: z.string().optional(), + source: z.unknown().optional(), + repoCount: z.number().optional(), + totalEmissionShare: z.number().optional(), + warnings: z.unknown().optional(), + repositories: z.unknown().optional(), + error: z.string().optional(), +}; + const upstreamDriftOutputSchema = { generatedAt: z.string().optional(), status: z.string().optional(), @@ -1853,6 +1865,7 @@ export const MCP_TOOL_CATEGORIES: Record = { loopover_preflight_pr: "discovery", loopover_get_bounty_advisory: "discovery", loopover_get_registry_changes: "utility", + loopover_get_registry_snapshot: "utility", loopover_get_upstream_drift: "utility", loopover_get_issue_quality: "maintainer", loopover_get_pr_reviewability: "review", @@ -2355,6 +2368,16 @@ export class LoopoverMcp { async () => this.toolResult(await this.getRegistryChanges()), ); + register( + "loopover_get_registry_snapshot", + { + description: "Return the latest cached Gittensor registry snapshot (the raw current snapshot, not a diff).", + inputSchema: {}, + outputSchema: registrySnapshotOutputSchema, + }, + async () => this.toolResult(await this.getRegistrySnapshot()), + ); + register( "loopover_get_upstream_drift", { @@ -4046,6 +4069,22 @@ export class LoopoverMcp { }; } + private async getRegistrySnapshot(): Promise { + // Mirrors GET /v1/registry/snapshot: return the raw latest snapshot, or a normal not-found result + // (never throw) when nothing has been synced yet — same error code the REST route uses. + const snapshot = await getLatestRegistrySnapshot(this.env); + if (!snapshot) { + return { + summary: "No registry snapshot has been synced yet.", + data: { error: "registry_snapshot_not_found" }, + }; + } + return { + summary: `Latest registry snapshot (${snapshot.repoCount} repos).`, + data: snapshot as unknown as Record, + }; + } + private async getUpstreamDrift(): Promise { const status = await loadUpstreamStatus(this.env); const detail = diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index d4a285a669..af19a9da02 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -5525,6 +5525,7 @@ describe("api routes", () => { expect(toolNames).toContain("loopover_explain_score_breakdown"); expect(toolNames).toContain("loopover_get_outcome_calibration"); expect(toolNames).toContain("loopover_get_registry_changes"); + expect(toolNames).toContain("loopover_get_registry_snapshot"); expect(toolNames).toContain("loopover_get_upstream_drift"); expect(toolNames).toContain("loopover_explain_review_risk"); expect(toolNames).toContain("loopover_compare_pr_variants"); @@ -5797,6 +5798,7 @@ describe("api routes", () => { }, ], ["loopover_get_registry_changes", {}], + ["loopover_get_registry_snapshot", {}], ["loopover_get_upstream_drift", {}], [ "loopover_preview_local_pr_score", diff --git a/test/unit/mcp-cli-registry-snapshot.test.ts b/test/unit/mcp-cli-registry-snapshot.test.ts new file mode 100644 index 0000000000..d36a244c8e --- /dev/null +++ b/test/unit/mcp-cli-registry-snapshot.test.ts @@ -0,0 +1,77 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { closeFixtureServer, startFixtureServer } from "./support/mcp-cli-harness"; + +// #7803: in-process coverage for the loopover_get_registry_snapshot stdio tool in +// packages/loopover-mcp/bin/loopover-mcp.ts. Same #7764 entrypoint-guard pattern as +// mcp-cli-plan-issues.test.ts — import the .ts source (not a gitignored .js build artifact), hold the +// exported `server`, and connect an in-memory transport so v8/Codecov attributes the new registerStdioTool +// lines. Subprocess spawn alone does not instrument the bin (that is why #7903's bin-only PR got 0% patch). +const MODULES = ["../../packages/loopover-mcp/bin/loopover-mcp.ts"] as const; + +type BinModule = { + server: { connect: (transport: unknown) => Promise }; +}; + +let tempDir = ""; +const capturedRequests: Array<{ url: string; method: string }> = []; +const loaded = new Map(); + +beforeAll(async () => { + tempDir = mkdtempSync(join(tmpdir(), "loopover-registry-snapshot-")); + const apiUrl = await startFixtureServer({ + onApiRequest: (request) => { + if (request.url === "/v1/registry/snapshot") { + capturedRequests.push({ url: request.url ?? "", method: request.method ?? "GET" }); + } + }, + }); + process.env.LOOPOVER_API_URL = apiUrl; + process.env.LOOPOVER_API_TOKEN = "in-process-token"; + process.env.LOOPOVER_API_TIMEOUT_MS = "2000"; + process.env.LOOPOVER_CONFIG_DIR = tempDir; + process.env.LOOPOVER_SKIP_NPM_VERSION_CHECK = "1"; + for (const specifier of MODULES) { + loaded.set(specifier, (await import(specifier)) as unknown as BinModule); + } +}, 120_000); + +afterAll(async () => { + await closeFixtureServer(); + if (tempDir) rmSync(tempDir, { recursive: true, force: true }); + delete process.env.LOOPOVER_API_URL; + delete process.env.LOOPOVER_API_TOKEN; + delete process.env.LOOPOVER_CONFIG_DIR; + delete process.env.LOOPOVER_SKIP_NPM_VERSION_CHECK; +}); + +describe("bin loopover_get_registry_snapshot stdio tool (in-process, #7803)", () => { + it.each(MODULES)("registers and proxies GET /v1/registry/snapshot — %s", async (specifier) => { + capturedRequests.length = 0; + const mod = loaded.get(specifier)!; + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await mod.server.connect(serverTransport); + const client = new Client({ name: "registry-snapshot-test", version: "0.1.0" }, { capabilities: {} }); + await client.connect(clientTransport); + try { + const { tools } = await client.listTools(); + const tool = tools.find((entry) => entry.name === "loopover_get_registry_snapshot"); + expect(tool).toBeDefined(); + expect(tool?.description).toMatch(/registry snapshot/i); + + const result = await client.callTool({ name: "loopover_get_registry_snapshot", arguments: {} }); + expect(result.isError).toBeFalsy(); + expect(capturedRequests).toEqual([{ url: "/v1/registry/snapshot", method: "GET" }]); + expect(result.structuredContent).toMatchObject({ + id: "fixture-snapshot", + repoCount: 1, + }); + } finally { + await client.close().catch(() => undefined); + } + }); +}); diff --git a/test/unit/mcp-output-schemas.test.ts b/test/unit/mcp-output-schemas.test.ts index 21caa3a624..96841ac335 100644 --- a/test/unit/mcp-output-schemas.test.ts +++ b/test/unit/mcp-output-schemas.test.ts @@ -34,6 +34,7 @@ const TOOLS_WITH_OUTPUT_SCHEMA = [ "loopover_lint_pr_text", "loopover_validate_config", "loopover_get_registry_changes", + "loopover_get_registry_snapshot", "loopover_get_upstream_drift", "loopover_local_status", "loopover_remediation_plan", @@ -137,6 +138,10 @@ describe("MCP output schema discovery", () => { const registryChangesProps = Object.keys((registryChanges?.outputSchema?.properties ?? {}) as Record); expect(registryChangesProps).toEqual(expect.arrayContaining(["currentSnapshotId", "previousSnapshotId", "addedRepos", "removedRepos", "changedRepos", "summary"])); expect(registryChangesProps).not.toEqual(expect.arrayContaining(["previous", "current", "added", "removed", "changed", "warnings"])); + + const registrySnapshot = byName.get("loopover_get_registry_snapshot"); + const registrySnapshotProps = Object.keys((registrySnapshot?.outputSchema?.properties ?? {}) as Record); + expect(registrySnapshotProps).toEqual(expect.arrayContaining(["id", "repoCount", "repositories", "error"])); }); it("preserves the full tool inventory while adding output schemas", async () => { @@ -191,6 +196,27 @@ describe("MCP tool calls return schema-valid structured content", () => { ]); }); + it("loopover_get_registry_snapshot returns the latest snapshot when one exists (#7803)", async () => { + const env = createTestEnv(); + await seedRegistryChangeSnapshots(env); + const { client } = await connectTestClient(env); + const result = await client.callTool({ name: "loopover_get_registry_snapshot", arguments: {} }); + expect(result.isError).toBeFalsy(); + expect(result.structuredContent).toMatchObject({ + repoCount: 3, + source: { kind: "raw-github", url: "fixture://current-registry" }, + }); + expect((result.structuredContent as { repositories: unknown[] }).repositories).toHaveLength(3); + expect(JSON.stringify(result.structuredContent)).not.toContain("registry_snapshot_not_found"); + }); + + it("loopover_get_registry_snapshot returns a normal not-found result when empty (#7803)", async () => { + const { client } = await connectTestClient(createTestEnv()); + const result = await client.callTool({ name: "loopover_get_registry_snapshot", arguments: {} }); + expect(result.isError).toBeFalsy(); + expect(result.structuredContent).toEqual({ error: "registry_snapshot_not_found" }); + }); + it("loopover_get_repo_context returns validated structured content", async () => { const { client } = await connectTestClient(); const result = await client.callTool({ name: "loopover_get_repo_context", arguments: { owner: "octo", repo: "demo" } }); @@ -656,7 +682,7 @@ describe("MCP output schemas do not declare private financial fields", () => { it("structured content from public-safe tools never includes redacted financial keys", async () => { const { client } = await connectTestClient(); - for (const name of ["loopover_local_status", "loopover_get_upstream_drift", "loopover_get_registry_changes"]) { + for (const name of ["loopover_local_status", "loopover_get_upstream_drift", "loopover_get_registry_changes", "loopover_get_registry_snapshot"]) { const result = await client.callTool({ name, arguments: {} }); const serialized = JSON.stringify(result.structuredContent ?? {}); expect(serialized, `tool "${name}" structured content must not leak financial fields`).not.toMatch( diff --git a/test/unit/mcp-tool-rename-aliases.test.ts b/test/unit/mcp-tool-rename-aliases.test.ts index b1195bc93d..5a660cb98f 100644 --- a/test/unit/mcp-tool-rename-aliases.test.ts +++ b/test/unit/mcp-tool-rename-aliases.test.ts @@ -23,6 +23,8 @@ // (#6980 registered the loopover_explain_review_risk CLI mirror, taking the count from 78 to 79.) // (#7758 registered the loopover_get_outcome_calibration stdio tool, taking the count from 79 to 80.) // (#7764 registered the loopover_plan_repo_issues stdio + CLI + REST tool, taking the count from 80 to 81.) +// (#7887 registered loopover_get_activation_preview without bumping this pin — live count became 82.) +// (#7803 registered the loopover_get_registry_snapshot remote+stdio tool, taking the count from 82 to 83.) import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { mkdtempSync, rmSync } from "node:fs"; @@ -30,13 +32,12 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { + bin, closeFixtureServer, run, startFixtureServer, } from "./support/mcp-cli-harness"; -const bin = join(process.cwd(), "packages/loopover-mcp/bin/loopover-mcp.js"); - let client: Client; let transport: StdioClientTransport; let configDir: string; @@ -70,14 +71,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { }); afterEach(disconnect); - it("lists exactly 81 loopover_ tools and zero gittensory_-prefixed aliases", async () => { + it("lists exactly 83 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(81); + expect(primary.length).toBe(83); expect(legacy.length).toBe(0); - expect(names.length).toBe(81); + expect(names.length).toBe(83); }); it("no loopover_ tool's description carries a stale deprecation notice", async () => { @@ -89,14 +90,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { } }); - it("`loopover-mcp tools --json` reports the same 81-tool count the live server registers", async () => { + it("`loopover-mcp tools --json` reports the same 83-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(81); + expect(payload.count).toBe(83); expect([...payload.tools.map((t) => t.name)].sort()).toEqual( [...tools.map((t) => t.name)].sort(), ); diff --git a/test/unit/support/mcp-cli-harness.ts b/test/unit/support/mcp-cli-harness.ts index 4578e7db4c..78f2280a1d 100644 --- a/test/unit/support/mcp-cli-harness.ts +++ b/test/unit/support/mcp-cli-harness.ts @@ -779,6 +779,22 @@ export async function startFixtureServer( ); return; } + // #7803: public registry snapshot (raw current snapshot, not a diff). No auth. + if (request.url === "/v1/registry/snapshot" && request.method === "GET") { + response.end( + JSON.stringify({ + id: "fixture-snapshot", + generatedAt: "2026-05-30T00:00:00.000Z", + fetchedAt: "2026-05-30T00:00:00.000Z", + source: { kind: "raw-github", url: "fixture://registry" }, + repoCount: 1, + totalEmissionShare: 0.01, + warnings: [], + repositories: [{ fullName: "owner/repo", emissionShare: 0.01 }], + }), + ); + return; + } if (request.url === "/v1/repos/owner/repo/intelligence" && request.method === "GET") { response.end( JSON.stringify({