diff --git a/packages/loopover-mcp/bin/loopover-mcp.ts b/packages/loopover-mcp/bin/loopover-mcp.ts index d164dddd5e..d90bed897a 100644 --- a/packages/loopover-mcp/bin/loopover-mcp.ts +++ b/packages/loopover-mcp/bin/loopover-mcp.ts @@ -1085,6 +1085,11 @@ const STDIO_TOOL_DESCRIPTORS = [ category: "utility", description: "Return the latest cached Gittensor upstream ruleset drift status (stale/drift warnings) for MCP planning.", }, + { + name: "loopover_get_upstream_ruleset", + category: "utility", + description: "Return the latest cached upstream Gittensor ruleset snapshot (public static discovery data). Read-only; takes no parameters.", + }, { name: "loopover_get_bounty_advisory", category: "discovery", @@ -1921,6 +1926,15 @@ registerStdioTool( async () => toolResult("LoopOver upstream drift status.", await apiGet("/v1/upstream/drift")), ); +registerStdioTool( + "loopover_get_upstream_ruleset", + { + description: stdioToolDescription("loopover_get_upstream_ruleset"), + inputSchema: {}, + }, + async () => toolResult("LoopOver upstream ruleset snapshot.", await apiGet("/v1/upstream/ruleset")), +); + registerStdioTool( "loopover_get_label_audit", { diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 85d59a46ee..b5c756cf66 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -40,6 +40,7 @@ import { listBountiesByRepo, getContributorEvidence, getLatestRepoGithubTotalsSnapshot, + getLatestUpstreamRulesetSnapshot, getInstallation, getIssue, getPendingAgentAction, @@ -1407,6 +1408,23 @@ const upstreamDriftOutputSchema = { reports: z.unknown().optional(), }; +// Public upstream ruleset snapshot (#7807) — same shape the REST route returns (or the not-found error body). +const upstreamRulesetOutputSchema = { + id: z.string().optional(), + sourceRepo: z.string().optional(), + sourceRef: z.string().optional(), + commitSha: z.string().nullable().optional(), + sourceSnapshotIds: z.unknown().optional(), + activeModel: z.string().optional(), + registryRepoCount: z.number().optional(), + totalEmissionShare: z.number().optional(), + semanticHash: z.string().optional(), + payload: z.unknown().optional(), + warnings: z.unknown().optional(), + generatedAt: z.string().optional(), + error: z.string().optional(), +}; + const localStatusOutputSchema = { apiAvailable: z.boolean().optional(), sourceUploadDefault: z.boolean().optional(), @@ -1836,6 +1854,7 @@ export const MCP_TOOL_CATEGORIES: Record = { loopover_get_bounty_advisory: "discovery", loopover_get_registry_changes: "utility", loopover_get_upstream_drift: "utility", + loopover_get_upstream_ruleset: "utility", loopover_get_issue_quality: "maintainer", loopover_get_pr_reviewability: "review", loopover_validate_linked_issue: "discovery", @@ -2336,6 +2355,17 @@ export class LoopoverMcp { async () => this.toolResult(await this.getUpstreamDrift()), ); + register( + "loopover_get_upstream_ruleset", + { + description: + "Return the latest cached upstream Gittensor ruleset snapshot (public static discovery data). No input; returns not-found when no snapshot exists yet.", + inputSchema: {}, + outputSchema: upstreamRulesetOutputSchema, + }, + async () => this.toolResult(await this.getUpstreamRuleset()), + ); + register( "loopover_get_issue_quality", { @@ -4008,6 +4038,23 @@ export class LoopoverMcp { }; } + // #7807 — public raw ruleset snapshot (distinct from getUpstreamDrift's status+reports payload). + // Mirrors GET /v1/upstream/ruleset: return the snapshot when present; otherwise a normal not-found + // result (never throw), matching the REST route's upstream_ruleset_not_found body. + private async getUpstreamRuleset(): Promise { + const ruleset = await getLatestUpstreamRulesetSnapshot(this.env); + if (!ruleset) { + return { + summary: "LoopOver has no upstream ruleset snapshot yet.", + data: { error: "upstream_ruleset_not_found" }, + }; + } + return { + summary: `LoopOver upstream ruleset snapshot ${ruleset.id} (${ruleset.activeModel}).`, + data: ruleset as unknown as Record, + }; + } + private async preflightPr(input: z.infer>): Promise { await this.requireRepoAccess(input.repoFullName); const [repo, issues, pullRequests, bounties, issueQuality] = await Promise.all([ diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index d4a285a669..4fdc316ec7 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -5526,6 +5526,7 @@ describe("api routes", () => { expect(toolNames).toContain("loopover_get_outcome_calibration"); expect(toolNames).toContain("loopover_get_registry_changes"); expect(toolNames).toContain("loopover_get_upstream_drift"); + expect(toolNames).toContain("loopover_get_upstream_ruleset"); expect(toolNames).toContain("loopover_explain_review_risk"); expect(toolNames).toContain("loopover_compare_pr_variants"); expect(toolNames).toContain("loopover_local_status"); diff --git a/test/unit/mcp-cli-upstream-ruleset.test.ts b/test/unit/mcp-cli-upstream-ruleset.test.ts new file mode 100644 index 0000000000..52380b78c2 --- /dev/null +++ b/test/unit/mcp-cli-upstream-ruleset.test.ts @@ -0,0 +1,70 @@ +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(), "loopover-upstream-ruleset-")); + capturedRequests = []; + apiUrl = await startFixtureServer({ + onApiRequest: (request) => { + if (request.url && request.url.includes("/v1/upstream/ruleset")) { + 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: "upstream-ruleset-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_upstream_ruleset stdio proxy (#7807)", () => { + 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_upstream_ruleset"); + }); + + it("proxies the call to /v1/upstream/ruleset via apiGet and returns the payload", async () => { + const result = await client.callTool({ name: "loopover_get_upstream_ruleset", arguments: {} }); + expect(capturedRequests.length).toBe(1); + const captured = capturedRequests[0]!; + expect(captured.url).toContain("/v1/upstream/ruleset"); + expect(captured.method).toBe("GET"); + expect(result.isError).toBeFalsy(); + const text = JSON.stringify(result); + expect(text).not.toMatch(FORBIDDEN_PUBLIC_TERMS); + expect(text).toContain("ruleset-1"); + expect(text).toContain("pending_saturation_model"); + }); +}); diff --git a/test/unit/mcp-output-schemas.test.ts b/test/unit/mcp-output-schemas.test.ts index 55e958a34f..ceafcfb3d8 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_validate_config", "loopover_get_registry_changes", "loopover_get_upstream_drift", + "loopover_get_upstream_ruleset", "loopover_local_status", "loopover_remediation_plan", "loopover_explain_score_breakdown", @@ -171,6 +172,14 @@ describe("MCP tool calls return schema-valid structured content", () => { expect(["current", "drift_detected", "stale", "unavailable"]).toContain(data.status); }); + it("loopover_get_upstream_ruleset returns validated structured content (not-found is normal)", async () => { + const { client } = await connectTestClient(); + const result = await client.callTool({ name: "loopover_get_upstream_ruleset", arguments: {} }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as Record; + expect(data.error).toBe("upstream_ruleset_not_found"); + }); + it("loopover_get_registry_changes returns validated structured content", async () => { const env = createTestEnv(); await seedRegistryChangeSnapshots(env); @@ -614,7 +623,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_upstream_ruleset", "loopover_get_registry_changes"]) { 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 6bc40a746b..ed905ed562 100644 --- a/test/unit/mcp-tool-rename-aliases.test.ts +++ b/test/unit/mcp-tool-rename-aliases.test.ts @@ -21,6 +21,7 @@ // (#6741 registered the loopover_draft_pr_body CLI mirror, taking the count from 76 to 77.) // (#6747 registered the loopover_pr_outcome CLI mirror, taking the count from 77 to 78.) // (#6980 registered the loopover_explain_review_risk CLI mirror, taking the count from 78 to 79.) +// (#7807 registered the loopover_get_upstream_ruleset CLI mirror, taking the count from 79 to 80.) import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { mkdtempSync, rmSync } from "node:fs"; @@ -68,14 +69,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { }); afterEach(disconnect); - it("lists exactly 79 loopover_ tools and zero gittensory_-prefixed aliases", async () => { + it("lists exactly 80 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(79); + expect(primary.length).toBe(80); expect(legacy.length).toBe(0); - expect(names.length).toBe(79); + expect(names.length).toBe(80); }); it("no loopover_ tool's description carries a stale deprecation notice", async () => { @@ -87,14 +88,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { } }); - it("`loopover-mcp tools --json` reports the same 79-tool count the live server registers", async () => { + it("`loopover-mcp tools --json` reports the same 80-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(79); + expect(payload.count).toBe(80); expect([...payload.tools.map((t) => t.name)].sort()).toEqual( [...tools.map((t) => t.name)].sort(), ); diff --git a/test/unit/mcp-upstream-ruleset.test.ts b/test/unit/mcp-upstream-ruleset.test.ts new file mode 100644 index 0000000000..d1ff9135f6 --- /dev/null +++ b/test/unit/mcp-upstream-ruleset.test.ts @@ -0,0 +1,70 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { describe, expect, it } from "vitest"; +import { persistUpstreamRulesetSnapshot } from "../../src/db/repositories"; +import { LoopoverMcp } from "../../src/mcp/server"; +import type { UpstreamRulesetSnapshotRecord } from "../../src/types"; +import { createTestEnv } from "../helpers/d1"; + +async function connect(env: Env) { + const server = new LoopoverMcp(env).createServer(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + const client = new Client({ name: "loopover-upstream-ruleset-test", version: "0.1.0" }, { capabilities: {} }); + await client.connect(clientTransport); + return client; +} + +function ruleset(id: string, generatedAt: string): UpstreamRulesetSnapshotRecord { + return { + id, + sourceRepo: "entrius/gittensor", + sourceRef: "test", + commitSha: `${id}-commit`, + sourceSnapshotIds: [], + activeModel: "pending_saturation_model", + registryRepoCount: 1, + totalEmissionShare: 0.01, + semanticHash: `${id}-hash`, + payload: { + registry: { repoCount: 1, totalEmissionShare: 0.01, repositories: [] }, + scoring: { activeModel: "pending_saturation_model", constants: {}, semanticFlags: {} }, + issueDiscovery: { branchEligibilityRequired: false }, + mirrorLinkage: { solvedByPrRequired: false }, + languageWeights: { count: 0, weights: {} }, + sourceSnapshots: [], + }, + warnings: [], + generatedAt, + }; +} + +describe("MCP loopover_get_upstream_ruleset (#7807)", () => { + it("registers as a utility-category no-argument tool", async () => { + const client = await connect(createTestEnv()); + const { tools } = await client.listTools(); + const tool = tools.find((entry) => entry.name === "loopover_get_upstream_ruleset"); + expect(tool).toBeDefined(); + expect((tool as { _meta?: { category?: string } })._meta?.category).toBe("utility"); + expect(tool?.inputSchema).toMatchObject({ type: "object" }); + }); + + it("returns not_found as a normal result when no snapshot exists", async () => { + const client = await connect(createTestEnv()); + const result = await client.callTool({ name: "loopover_get_upstream_ruleset", arguments: {} }); + expect(result.isError).toBeFalsy(); + expect(result.structuredContent).toEqual({ error: "upstream_ruleset_not_found" }); + }); + + it("returns the latest persisted ruleset snapshot", async () => { + const env = createTestEnv(); + await persistUpstreamRulesetSnapshot(env, ruleset("ruleset-live", "2026-05-30T00:00:00.000Z")); + const client = await connect(env); + const result = await client.callTool({ name: "loopover_get_upstream_ruleset", arguments: {} }); + expect(result.isError).toBeFalsy(); + const payload = result.structuredContent as UpstreamRulesetSnapshotRecord; + expect(payload.id).toBe("ruleset-live"); + expect(payload.activeModel).toBe("pending_saturation_model"); + expect(payload.semanticHash).toBe("ruleset-live-hash"); + }); +}); diff --git a/test/unit/support/mcp-cli-harness.ts b/test/unit/support/mcp-cli-harness.ts index 2f26c988c5..c3bf6221da 100644 --- a/test/unit/support/mcp-cli-harness.ts +++ b/test/unit/support/mcp-cli-harness.ts @@ -722,6 +722,22 @@ export async function startFixtureServer( ); return; } + if (request.url === "/v1/upstream/ruleset" && request.method === "GET") { + response.end( + JSON.stringify({ + id: "ruleset-1", + sourceRepo: "entrius/gittensor", + sourceRef: "main", + commitSha: "abc123", + activeModel: "pending_saturation_model", + registryRepoCount: 1, + semanticHash: "hash-1", + generatedAt: "2026-05-30T00:00:00.000Z", + warnings: [], + }), + ); + return; + } if (request.url === "/v1/repos/owner/repo/intelligence" && request.method === "GET") { response.end( JSON.stringify({