Skip to content

Commit a97dd18

Browse files
feat(mcp): add remote + stdio surfaces for loopover_get_upstream_ruleset (#7921)
Mirror loopover_get_upstream_drift 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 #7855). Closes #7807 Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent f27b9f0 commit a97dd18

7 files changed

Lines changed: 215 additions & 7 deletions

File tree

packages/loopover-mcp/bin/loopover-mcp.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1138,6 +1138,12 @@ const STDIO_TOOL_DESCRIPTORS = [
11381138
category: "utility",
11391139
description: "Return the latest cached Gittensor upstream ruleset drift status (stale/drift warnings) for MCP planning.",
11401140
},
1141+
{
1142+
name: "loopover_get_upstream_ruleset",
1143+
category: "utility",
1144+
description:
1145+
"Return the latest cached upstream Gittensor ruleset snapshot (the raw current ruleset — active model, registry counts, and payload — not the drift report). Read-only; takes no parameters. Public/unauthenticated, same as GET /v1/upstream/ruleset.",
1146+
},
11411147
{
11421148
name: "loopover_get_bounty_advisory",
11431149
category: "discovery",
@@ -2011,6 +2017,15 @@ registerStdioTool(
20112017
async () => toolResult("LoopOver upstream drift status.", await apiGet("/v1/upstream/drift")),
20122018
);
20132019

2020+
registerStdioTool(
2021+
"loopover_get_upstream_ruleset",
2022+
{
2023+
description: stdioToolDescription("loopover_get_upstream_ruleset"),
2024+
inputSchema: {},
2025+
},
2026+
async () => toolResult("LoopOver upstream ruleset snapshot.", await apiGet("/v1/upstream/ruleset")),
2027+
);
2028+
20142029
registerStdioTool(
20152030
"loopover_get_label_audit",
20162031
{

src/mcp/server.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import {
4646
getPullRequest,
4747
getRepository,
4848
getRepositorySettings,
49+
getLatestUpstreamRulesetSnapshot,
4950
isGlobalAgentFrozen,
5051
getRepoQueueTrendSnapshot,
5152
listAgentAuditEvents,
@@ -1436,6 +1437,22 @@ const upstreamDriftOutputSchema = {
14361437
reports: z.unknown().optional(),
14371438
};
14381439

1440+
const upstreamRulesetOutputSchema = {
1441+
id: z.string().optional(),
1442+
sourceRepo: z.string().optional(),
1443+
sourceRef: z.string().optional(),
1444+
commitSha: z.string().optional(),
1445+
sourceSnapshotIds: z.unknown().optional(),
1446+
activeModel: z.string().optional(),
1447+
registryRepoCount: z.number().optional(),
1448+
totalEmissionShare: z.number().optional(),
1449+
semanticHash: z.string().optional(),
1450+
payload: z.unknown().optional(),
1451+
warnings: z.unknown().optional(),
1452+
generatedAt: z.string().optional(),
1453+
error: z.string().optional(),
1454+
};
1455+
14391456
const localStatusOutputSchema = {
14401457
apiAvailable: z.boolean().optional(),
14411458
sourceUploadDefault: z.boolean().optional(),
@@ -1867,6 +1884,7 @@ export const MCP_TOOL_CATEGORIES: Record<string, McpToolCategory> = {
18671884
loopover_get_registry_changes: "utility",
18681885
loopover_get_registry_snapshot: "utility",
18691886
loopover_get_upstream_drift: "utility",
1887+
loopover_get_upstream_ruleset: "utility",
18701888
loopover_get_issue_quality: "maintainer",
18711889
loopover_get_pr_reviewability: "review",
18721890
loopover_validate_linked_issue: "discovery",
@@ -2388,6 +2406,17 @@ export class LoopoverMcp {
23882406
async () => this.toolResult(await this.getUpstreamDrift()),
23892407
);
23902408

2409+
register(
2410+
"loopover_get_upstream_ruleset",
2411+
{
2412+
description:
2413+
"Return the latest cached upstream Gittensor ruleset snapshot (the raw current ruleset — active model, registry counts, and payload — not the drift report). Read-only; takes no parameters. Public/unauthenticated, same as GET /v1/upstream/ruleset.",
2414+
inputSchema: {},
2415+
outputSchema: upstreamRulesetOutputSchema,
2416+
},
2417+
async () => this.toolResult(await this.getUpstreamRuleset()),
2418+
);
2419+
23912420
register(
23922421
"loopover_get_issue_quality",
23932422
{
@@ -4101,6 +4130,22 @@ export class LoopoverMcp {
41014130
};
41024131
}
41034132

4133+
private async getUpstreamRuleset(): Promise<ToolPayload> {
4134+
// Mirrors GET /v1/upstream/ruleset: return the raw latest ruleset snapshot, or a normal not-found
4135+
// result (never throw) when nothing has been synced yet — same error code the REST route uses.
4136+
const ruleset = await getLatestUpstreamRulesetSnapshot(this.env);
4137+
if (!ruleset) {
4138+
return {
4139+
summary: "No upstream ruleset snapshot has been synced yet.",
4140+
data: { error: "upstream_ruleset_not_found" },
4141+
};
4142+
}
4143+
return {
4144+
summary: `Latest upstream ruleset snapshot (${ruleset.activeModel}, ${ruleset.registryRepoCount} repos).`,
4145+
data: ruleset as unknown as Record<string, unknown>,
4146+
};
4147+
}
4148+
41044149
private async preflightPr(input: z.infer<z.ZodObject<typeof preflightShape>>): Promise<ToolPayload> {
41054150
await this.requireRepoAccess(input.repoFullName);
41064151
const [repo, issues, pullRequests, bounties, issueQuality] = await Promise.all([

test/integration/api.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5527,6 +5527,7 @@ describe("api routes", () => {
55275527
expect(toolNames).toContain("loopover_get_registry_changes");
55285528
expect(toolNames).toContain("loopover_get_registry_snapshot");
55295529
expect(toolNames).toContain("loopover_get_upstream_drift");
5530+
expect(toolNames).toContain("loopover_get_upstream_ruleset");
55305531
expect(toolNames).toContain("loopover_explain_review_risk");
55315532
expect(toolNames).toContain("loopover_compare_pr_variants");
55325533
expect(toolNames).toContain("loopover_local_status");
@@ -5800,6 +5801,7 @@ describe("api routes", () => {
58005801
["loopover_get_registry_changes", {}],
58015802
["loopover_get_registry_snapshot", {}],
58025803
["loopover_get_upstream_drift", {}],
5804+
["loopover_get_upstream_ruleset", {}],
58035805
[
58045806
"loopover_preview_local_pr_score",
58055807
{
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
3+
import { mkdtempSync, rmSync } from "node:fs";
4+
import { tmpdir } from "node:os";
5+
import { join } from "node:path";
6+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
7+
import { closeFixtureServer, startFixtureServer } from "./support/mcp-cli-harness";
8+
9+
// #7807: in-process coverage for the loopover_get_upstream_ruleset stdio tool in
10+
// packages/loopover-mcp/bin/loopover-mcp.ts. Same #7764 entrypoint-guard pattern as
11+
// mcp-cli-registry-snapshot.test.ts — import the .ts source, hold the exported `server`, and connect
12+
// an in-memory transport so v8/Codecov attributes the new registerStdioTool lines. Subprocess spawn
13+
// alone does not instrument the bin (that is why #7855's bin lines got 0% patch).
14+
const MODULES = ["../../packages/loopover-mcp/bin/loopover-mcp.ts"] as const;
15+
16+
type BinModule = {
17+
server: { connect: (transport: unknown) => Promise<void> };
18+
};
19+
20+
let tempDir = "";
21+
const capturedRequests: Array<{ url: string; method: string }> = [];
22+
const loaded = new Map<string, BinModule>();
23+
24+
beforeAll(async () => {
25+
tempDir = mkdtempSync(join(tmpdir(), "loopover-upstream-ruleset-"));
26+
const apiUrl = await startFixtureServer({
27+
onApiRequest: (request) => {
28+
if (request.url === "/v1/upstream/ruleset") {
29+
capturedRequests.push({ url: request.url ?? "", method: request.method ?? "GET" });
30+
}
31+
},
32+
});
33+
process.env.LOOPOVER_API_URL = apiUrl;
34+
process.env.LOOPOVER_API_TOKEN = "in-process-token";
35+
process.env.LOOPOVER_API_TIMEOUT_MS = "2000";
36+
process.env.LOOPOVER_CONFIG_DIR = tempDir;
37+
process.env.LOOPOVER_SKIP_NPM_VERSION_CHECK = "1";
38+
for (const specifier of MODULES) {
39+
loaded.set(specifier, (await import(specifier)) as unknown as BinModule);
40+
}
41+
}, 120_000);
42+
43+
afterAll(async () => {
44+
await closeFixtureServer();
45+
if (tempDir) rmSync(tempDir, { recursive: true, force: true });
46+
delete process.env.LOOPOVER_API_URL;
47+
delete process.env.LOOPOVER_API_TOKEN;
48+
delete process.env.LOOPOVER_CONFIG_DIR;
49+
delete process.env.LOOPOVER_SKIP_NPM_VERSION_CHECK;
50+
});
51+
52+
describe("bin loopover_get_upstream_ruleset stdio tool (in-process, #7807)", () => {
53+
it.each(MODULES)("registers and proxies GET /v1/upstream/ruleset — %s", async (specifier) => {
54+
capturedRequests.length = 0;
55+
const mod = loaded.get(specifier)!;
56+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
57+
await mod.server.connect(serverTransport);
58+
const client = new Client({ name: "upstream-ruleset-test", version: "0.1.0" }, { capabilities: {} });
59+
await client.connect(clientTransport);
60+
try {
61+
const { tools } = await client.listTools();
62+
const tool = tools.find((entry) => entry.name === "loopover_get_upstream_ruleset");
63+
expect(tool).toBeDefined();
64+
expect(tool?.description).toMatch(/upstream.*ruleset|ruleset snapshot/i);
65+
66+
const result = await client.callTool({ name: "loopover_get_upstream_ruleset", arguments: {} });
67+
expect(capturedRequests).toEqual([{ url: "/v1/upstream/ruleset", method: "GET" }]);
68+
expect(result.isError).toBeFalsy();
69+
const text = JSON.stringify(result);
70+
expect(text).toContain("fixture-ruleset");
71+
expect(text).toContain("pending_saturation_model");
72+
} finally {
73+
await client.close().catch(() => undefined);
74+
}
75+
});
76+
});

test/unit/mcp-output-schemas.test.ts

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
22
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
33
import { describe, expect, it, vi } from "vitest";
4-
import { persistSignalSnapshot, upsertBounty, upsertIssueFromGitHub, upsertPullRequestFromGitHub, upsertRepositoryFromGitHub, updatePullRequestSlopAssessment } from "../../src/db/repositories";
4+
import { persistSignalSnapshot, upsertBounty, upsertIssueFromGitHub, upsertPullRequestFromGitHub, upsertRepositoryFromGitHub, updatePullRequestSlopAssessment, persistUpstreamRulesetSnapshot } from "../../src/db/repositories";
55
import type { AuthIdentity } from "../../src/auth/security";
66
import { LoopoverMcp } from "../../src/mcp/server";
77
import { normalizeRegistryPayload } from "../../src/registry/normalize";
@@ -36,6 +36,7 @@ const TOOLS_WITH_OUTPUT_SCHEMA = [
3636
"loopover_get_registry_changes",
3737
"loopover_get_registry_snapshot",
3838
"loopover_get_upstream_drift",
39+
"loopover_get_upstream_ruleset",
3940
"loopover_local_status",
4041
"loopover_remediation_plan",
4142
"loopover_explain_score_breakdown",
@@ -142,6 +143,10 @@ describe("MCP output schema discovery", () => {
142143
const registrySnapshot = byName.get("loopover_get_registry_snapshot");
143144
const registrySnapshotProps = Object.keys((registrySnapshot?.outputSchema?.properties ?? {}) as Record<string, unknown>);
144145
expect(registrySnapshotProps).toEqual(expect.arrayContaining(["id", "repoCount", "repositories", "error"]));
146+
147+
const upstreamRuleset = byName.get("loopover_get_upstream_ruleset");
148+
const upstreamRulesetProps = Object.keys((upstreamRuleset?.outputSchema?.properties ?? {}) as Record<string, unknown>);
149+
expect(upstreamRulesetProps).toEqual(expect.arrayContaining(["id", "activeModel", "registryRepoCount", "payload", "error"]));
145150
});
146151

147152
it("preserves the full tool inventory while adding output schemas", async () => {
@@ -217,6 +222,27 @@ describe("MCP tool calls return schema-valid structured content", () => {
217222
expect(result.structuredContent).toEqual({ error: "registry_snapshot_not_found" });
218223
});
219224

225+
it("loopover_get_upstream_ruleset returns the latest ruleset when one exists (#7807)", async () => {
226+
const env = createTestEnv();
227+
await seedUpstreamRulesetSnapshot(env);
228+
const { client } = await connectTestClient(env);
229+
const result = await client.callTool({ name: "loopover_get_upstream_ruleset", arguments: {} });
230+
expect(result.isError).toBeFalsy();
231+
expect(result.structuredContent).toMatchObject({
232+
id: "fixture-upstream-ruleset",
233+
activeModel: "pending_saturation_model",
234+
registryRepoCount: 1,
235+
});
236+
expect(JSON.stringify(result.structuredContent)).not.toContain("upstream_ruleset_not_found");
237+
});
238+
239+
it("loopover_get_upstream_ruleset returns a normal not-found result when empty (#7807)", async () => {
240+
const { client } = await connectTestClient(createTestEnv());
241+
const result = await client.callTool({ name: "loopover_get_upstream_ruleset", arguments: {} });
242+
expect(result.isError).toBeFalsy();
243+
expect(result.structuredContent).toEqual({ error: "upstream_ruleset_not_found" });
244+
});
245+
220246
it("loopover_get_repo_context returns validated structured content", async () => {
221247
const { client } = await connectTestClient();
222248
const result = await client.callTool({ name: "loopover_get_repo_context", arguments: { owner: "octo", repo: "demo" } });
@@ -682,7 +708,7 @@ describe("MCP output schemas do not declare private financial fields", () => {
682708
it("structured content from public-safe tools never includes redacted financial keys", async () => {
683709
const { client } = await connectTestClient();
684710

685-
for (const name of ["loopover_local_status", "loopover_get_upstream_drift", "loopover_get_registry_changes", "loopover_get_registry_snapshot"]) {
711+
for (const name of ["loopover_local_status", "loopover_get_upstream_drift", "loopover_get_upstream_ruleset", "loopover_get_registry_changes", "loopover_get_registry_snapshot"]) {
686712
const result = await client.callTool({ name, arguments: {} });
687713
const serialized = JSON.stringify(result.structuredContent ?? {});
688714
expect(serialized, `tool "${name}" structured content must not leak financial fields`).not.toMatch(
@@ -719,6 +745,29 @@ async function seedRegistryChangeSnapshots(env: Env) {
719745
);
720746
}
721747

748+
async function seedUpstreamRulesetSnapshot(env: Env) {
749+
await persistUpstreamRulesetSnapshot(env, {
750+
id: "fixture-upstream-ruleset",
751+
sourceRepo: "entrius/gittensor",
752+
sourceRef: "test",
753+
commitSha: "fixture-commit",
754+
sourceSnapshotIds: [],
755+
activeModel: "pending_saturation_model",
756+
registryRepoCount: 1,
757+
totalEmissionShare: 0.01,
758+
semanticHash: "fixture-semantic-hash",
759+
payload: {
760+
registry: {
761+
repoCount: 1,
762+
totalEmissionShare: 0.01,
763+
repositories: [],
764+
},
765+
},
766+
warnings: [],
767+
generatedAt: "2026-05-30T00:00:00.000Z",
768+
});
769+
}
770+
722771
function repoOutcomePatternsPayload(repoFullName: string, generatedAt: string) {
723772
return {
724773
repoFullName,

test/unit/mcp-tool-rename-aliases.test.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
// (#7764 registered the loopover_plan_repo_issues stdio + CLI + REST tool, taking the count from 80 to 81.)
2626
// (#7887 registered loopover_get_activation_preview without bumping this pin — live count became 82.)
2727
// (#7803 registered the loopover_get_registry_snapshot remote+stdio tool, taking the count from 82 to 83.)
28+
// (#7807 registered the loopover_get_upstream_ruleset remote+stdio tool, taking the count from 83 to 84.)
2829
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2930
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
3031
import { mkdtempSync, rmSync } from "node:fs";
@@ -71,14 +72,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
7172
});
7273
afterEach(disconnect);
7374

74-
it("lists exactly 83 loopover_ tools and zero gittensory_-prefixed aliases", async () => {
75+
it("lists exactly 84 loopover_ tools and zero gittensory_-prefixed aliases", async () => {
7576
const { tools } = await client.listTools();
7677
const names = tools.map((t) => t.name);
7778
const primary = names.filter((n) => n.startsWith("loopover_"));
7879
const legacy = names.filter((n) => n.startsWith("gittensory_"));
79-
expect(primary.length).toBe(83);
80+
expect(primary.length).toBe(84);
8081
expect(legacy.length).toBe(0);
81-
expect(names.length).toBe(83);
82+
expect(names.length).toBe(84);
8283
});
8384

8485
it("no loopover_ tool's description carries a stale deprecation notice", async () => {
@@ -90,14 +91,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
9091
}
9192
});
9293

93-
it("`loopover-mcp tools --json` reports the same 83-tool count the live server registers", async () => {
94+
it("`loopover-mcp tools --json` reports the same 84-tool count the live server registers", async () => {
9495
const { tools } = await client.listTools();
9596
const payload = JSON.parse(run(["tools", "--json"])) as {
9697
count: number;
9798
tools: Array<{ name: string }>;
9899
};
99100
expect(payload.count).toBe(tools.length);
100-
expect(payload.count).toBe(83);
101+
expect(payload.count).toBe(84);
101102
expect([...payload.tools.map((t) => t.name)].sort()).toEqual(
102103
[...tools.map((t) => t.name)].sort(),
103104
);

test/unit/support/mcp-cli-harness.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -779,6 +779,26 @@ export async function startFixtureServer(
779779
);
780780
return;
781781
}
782+
// #7807: public upstream ruleset snapshot (raw current ruleset, not the drift report). No auth.
783+
if (request.url === "/v1/upstream/ruleset" && request.method === "GET") {
784+
response.end(
785+
JSON.stringify({
786+
id: "fixture-ruleset",
787+
sourceRepo: "entrius/gittensor",
788+
sourceRef: "test",
789+
commitSha: "fixture-commit",
790+
sourceSnapshotIds: [],
791+
activeModel: "pending_saturation_model",
792+
registryRepoCount: 1,
793+
totalEmissionShare: 0.01,
794+
semanticHash: "fixture-semantic-hash",
795+
payload: { registry: { repoCount: 1 } },
796+
warnings: [],
797+
generatedAt: "2026-05-30T00:00:00.000Z",
798+
}),
799+
);
800+
return;
801+
}
782802
// #7803: public registry snapshot (raw current snapshot, not a diff). No auth.
783803
if (request.url === "/v1/registry/snapshot" && request.method === "GET") {
784804
response.end(

0 commit comments

Comments
 (0)