Skip to content

Commit 1bf3d96

Browse files
feat(mcp): add remote + stdio mirror for loopover_get_upstream_ruleset
Expose GET /v1/upstream/ruleset through remote MCP and local stdio using the same public, no-argument shape as loopover_get_upstream_drift, including the upstream_ruleset_not_found result when no snapshot exists. Closes #7807 Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 299c842 commit 1bf3d96

8 files changed

Lines changed: 234 additions & 6 deletions

File tree

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1085,6 +1085,11 @@ const STDIO_TOOL_DESCRIPTORS = [
10851085
category: "utility",
10861086
description: "Return the latest cached Gittensor upstream ruleset drift status (stale/drift warnings) for MCP planning.",
10871087
},
1088+
{
1089+
name: "loopover_get_upstream_ruleset",
1090+
category: "utility",
1091+
description: "Return the latest cached upstream Gittensor ruleset snapshot (public static discovery data). Read-only; takes no parameters.",
1092+
},
10881093
{
10891094
name: "loopover_get_bounty_advisory",
10901095
category: "discovery",
@@ -1921,6 +1926,15 @@ registerStdioTool(
19211926
async () => toolResult("LoopOver upstream drift status.", await apiGet("/v1/upstream/drift")),
19221927
);
19231928

1929+
registerStdioTool(
1930+
"loopover_get_upstream_ruleset",
1931+
{
1932+
description: stdioToolDescription("loopover_get_upstream_ruleset"),
1933+
inputSchema: {},
1934+
},
1935+
async () => toolResult("LoopOver upstream ruleset snapshot.", await apiGet("/v1/upstream/ruleset")),
1936+
);
1937+
19241938
registerStdioTool(
19251939
"loopover_get_label_audit",
19261940
{

src/mcp/server.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
listBountiesByRepo,
4141
getContributorEvidence,
4242
getLatestRepoGithubTotalsSnapshot,
43+
getLatestUpstreamRulesetSnapshot,
4344
getInstallation,
4445
getIssue,
4546
getPendingAgentAction,
@@ -1407,6 +1408,23 @@ const upstreamDriftOutputSchema = {
14071408
reports: z.unknown().optional(),
14081409
};
14091410

1411+
// Public upstream ruleset snapshot (#7807) — same shape the REST route returns (or the not-found error body).
1412+
const upstreamRulesetOutputSchema = {
1413+
id: z.string().optional(),
1414+
sourceRepo: z.string().optional(),
1415+
sourceRef: z.string().optional(),
1416+
commitSha: z.string().nullable().optional(),
1417+
sourceSnapshotIds: z.unknown().optional(),
1418+
activeModel: z.string().optional(),
1419+
registryRepoCount: z.number().optional(),
1420+
totalEmissionShare: z.number().optional(),
1421+
semanticHash: z.string().optional(),
1422+
payload: z.unknown().optional(),
1423+
warnings: z.unknown().optional(),
1424+
generatedAt: z.string().optional(),
1425+
error: z.string().optional(),
1426+
};
1427+
14101428
const localStatusOutputSchema = {
14111429
apiAvailable: z.boolean().optional(),
14121430
sourceUploadDefault: z.boolean().optional(),
@@ -1836,6 +1854,7 @@ export const MCP_TOOL_CATEGORIES: Record<string, McpToolCategory> = {
18361854
loopover_get_bounty_advisory: "discovery",
18371855
loopover_get_registry_changes: "utility",
18381856
loopover_get_upstream_drift: "utility",
1857+
loopover_get_upstream_ruleset: "utility",
18391858
loopover_get_issue_quality: "maintainer",
18401859
loopover_get_pr_reviewability: "review",
18411860
loopover_validate_linked_issue: "discovery",
@@ -2336,6 +2355,17 @@ export class LoopoverMcp {
23362355
async () => this.toolResult(await this.getUpstreamDrift()),
23372356
);
23382357

2358+
register(
2359+
"loopover_get_upstream_ruleset",
2360+
{
2361+
description:
2362+
"Return the latest cached upstream Gittensor ruleset snapshot (public static discovery data). No input; returns not-found when no snapshot exists yet.",
2363+
inputSchema: {},
2364+
outputSchema: upstreamRulesetOutputSchema,
2365+
},
2366+
async () => this.toolResult(await this.getUpstreamRuleset()),
2367+
);
2368+
23392369
register(
23402370
"loopover_get_issue_quality",
23412371
{
@@ -4008,6 +4038,23 @@ export class LoopoverMcp {
40084038
};
40094039
}
40104040

4041+
// #7807 — public raw ruleset snapshot (distinct from getUpstreamDrift's status+reports payload).
4042+
// Mirrors GET /v1/upstream/ruleset: return the snapshot when present; otherwise a normal not-found
4043+
// result (never throw), matching the REST route's upstream_ruleset_not_found body.
4044+
private async getUpstreamRuleset(): Promise<ToolPayload> {
4045+
const ruleset = await getLatestUpstreamRulesetSnapshot(this.env);
4046+
if (!ruleset) {
4047+
return {
4048+
summary: "LoopOver has no upstream ruleset snapshot yet.",
4049+
data: { error: "upstream_ruleset_not_found" },
4050+
};
4051+
}
4052+
return {
4053+
summary: `LoopOver upstream ruleset snapshot ${ruleset.id} (${ruleset.activeModel}).`,
4054+
data: ruleset as unknown as Record<string, unknown>,
4055+
};
4056+
}
4057+
40114058
private async preflightPr(input: z.infer<z.ZodObject<typeof preflightShape>>): Promise<ToolPayload> {
40124059
await this.requireRepoAccess(input.repoFullName);
40134060
const [repo, issues, pullRequests, bounties, issueQuality] = await Promise.all([

test/integration/api.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5526,6 +5526,7 @@ describe("api routes", () => {
55265526
expect(toolNames).toContain("loopover_get_outcome_calibration");
55275527
expect(toolNames).toContain("loopover_get_registry_changes");
55285528
expect(toolNames).toContain("loopover_get_upstream_drift");
5529+
expect(toolNames).toContain("loopover_get_upstream_ruleset");
55295530
expect(toolNames).toContain("loopover_explain_review_risk");
55305531
expect(toolNames).toContain("loopover_compare_pr_variants");
55315532
expect(toolNames).toContain("loopover_local_status");
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
3+
import { mkdtempSync, rmSync } from "node:fs";
4+
import { tmpdir } from "node:os";
5+
import { join } from "node:path";
6+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
7+
import { closeFixtureServer, startFixtureServer } from "./support/mcp-cli-harness";
8+
9+
const bin = join(process.cwd(), "packages/loopover-mcp/bin/loopover-mcp.js");
10+
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;
11+
12+
let client: Client;
13+
let transport: StdioClientTransport;
14+
let configDir: string;
15+
let apiUrl: string;
16+
let capturedRequests: Array<{ url: string; method: string }>;
17+
18+
async function connect() {
19+
configDir = mkdtempSync(join(tmpdir(), "loopover-upstream-ruleset-"));
20+
capturedRequests = [];
21+
apiUrl = await startFixtureServer({
22+
onApiRequest: (request) => {
23+
if (request.url && request.url.includes("/v1/upstream/ruleset")) {
24+
capturedRequests.push({ url: request.url ?? "", method: request.method ?? "GET" });
25+
}
26+
},
27+
});
28+
transport = new StdioClientTransport({
29+
command: "node",
30+
args: [bin, "--stdio"],
31+
env: {
32+
...process.env,
33+
LOOPOVER_CONFIG_DIR: configDir,
34+
LOOPOVER_API_URL: apiUrl,
35+
LOOPOVER_TOKEN: "session-token",
36+
LOOPOVER_API_TIMEOUT_MS: "5000",
37+
},
38+
});
39+
client = new Client({ name: "upstream-ruleset-test", version: "0.0.1" });
40+
await client.connect(transport);
41+
}
42+
43+
async function disconnect() {
44+
await client.close().catch(() => undefined);
45+
await closeFixtureServer();
46+
if (configDir) rmSync(configDir, { recursive: true, force: true });
47+
}
48+
49+
describe("loopover_get_upstream_ruleset stdio proxy (#7807)", () => {
50+
beforeEach(connect);
51+
afterEach(disconnect);
52+
53+
it("registers the tool in the stdio server tool list", async () => {
54+
const { tools } = await client.listTools();
55+
expect(tools.map((t) => t.name)).toContain("loopover_get_upstream_ruleset");
56+
});
57+
58+
it("proxies the call to /v1/upstream/ruleset via apiGet and returns the payload", async () => {
59+
const result = await client.callTool({ name: "loopover_get_upstream_ruleset", arguments: {} });
60+
expect(capturedRequests.length).toBe(1);
61+
const captured = capturedRequests[0]!;
62+
expect(captured.url).toContain("/v1/upstream/ruleset");
63+
expect(captured.method).toBe("GET");
64+
expect(result.isError).toBeFalsy();
65+
const text = JSON.stringify(result);
66+
expect(text).not.toMatch(FORBIDDEN_PUBLIC_TERMS);
67+
expect(text).toContain("ruleset-1");
68+
expect(text).toContain("pending_saturation_model");
69+
});
70+
});

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ const TOOLS_WITH_OUTPUT_SCHEMA = [
3434
"loopover_validate_config",
3535
"loopover_get_registry_changes",
3636
"loopover_get_upstream_drift",
37+
"loopover_get_upstream_ruleset",
3738
"loopover_local_status",
3839
"loopover_remediation_plan",
3940
"loopover_explain_score_breakdown",
@@ -171,6 +172,14 @@ describe("MCP tool calls return schema-valid structured content", () => {
171172
expect(["current", "drift_detected", "stale", "unavailable"]).toContain(data.status);
172173
});
173174

175+
it("loopover_get_upstream_ruleset returns validated structured content (not-found is normal)", async () => {
176+
const { client } = await connectTestClient();
177+
const result = await client.callTool({ name: "loopover_get_upstream_ruleset", arguments: {} });
178+
expect(result.isError).toBeFalsy();
179+
const data = result.structuredContent as Record<string, unknown>;
180+
expect(data.error).toBe("upstream_ruleset_not_found");
181+
});
182+
174183
it("loopover_get_registry_changes returns validated structured content", async () => {
175184
const env = createTestEnv();
176185
await seedRegistryChangeSnapshots(env);
@@ -614,7 +623,7 @@ describe("MCP output schemas do not declare private financial fields", () => {
614623
it("structured content from public-safe tools never includes redacted financial keys", async () => {
615624
const { client } = await connectTestClient();
616625

617-
for (const name of ["loopover_local_status", "loopover_get_upstream_drift", "loopover_get_registry_changes"]) {
626+
for (const name of ["loopover_local_status", "loopover_get_upstream_drift", "loopover_get_upstream_ruleset", "loopover_get_registry_changes"]) {
618627
const result = await client.callTool({ name, arguments: {} });
619628
const serialized = JSON.stringify(result.structuredContent ?? {});
620629
expect(serialized, `tool "${name}" structured content must not leak financial fields`).not.toMatch(

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

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
// (#6741 registered the loopover_draft_pr_body CLI mirror, taking the count from 76 to 77.)
2222
// (#6747 registered the loopover_pr_outcome CLI mirror, taking the count from 77 to 78.)
2323
// (#6980 registered the loopover_explain_review_risk CLI mirror, taking the count from 78 to 79.)
24+
// (#7807 registered the loopover_get_upstream_ruleset CLI mirror, taking the count from 79 to 80.)
2425
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2526
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
2627
import { mkdtempSync, rmSync } from "node:fs";
@@ -68,14 +69,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
6869
});
6970
afterEach(disconnect);
7071

71-
it("lists exactly 79 loopover_ tools and zero gittensory_-prefixed aliases", async () => {
72+
it("lists exactly 80 loopover_ tools and zero gittensory_-prefixed aliases", async () => {
7273
const { tools } = await client.listTools();
7374
const names = tools.map((t) => t.name);
7475
const primary = names.filter((n) => n.startsWith("loopover_"));
7576
const legacy = names.filter((n) => n.startsWith("gittensory_"));
76-
expect(primary.length).toBe(79);
77+
expect(primary.length).toBe(80);
7778
expect(legacy.length).toBe(0);
78-
expect(names.length).toBe(79);
79+
expect(names.length).toBe(80);
7980
});
8081

8182
it("no loopover_ tool's description carries a stale deprecation notice", async () => {
@@ -87,14 +88,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
8788
}
8889
});
8990

90-
it("`loopover-mcp tools --json` reports the same 79-tool count the live server registers", async () => {
91+
it("`loopover-mcp tools --json` reports the same 80-tool count the live server registers", async () => {
9192
const { tools } = await client.listTools();
9293
const payload = JSON.parse(run(["tools", "--json"])) as {
9394
count: number;
9495
tools: Array<{ name: string }>;
9596
};
9697
expect(payload.count).toBe(tools.length);
97-
expect(payload.count).toBe(79);
98+
expect(payload.count).toBe(80);
9899
expect([...payload.tools.map((t) => t.name)].sort()).toEqual(
99100
[...tools.map((t) => t.name)].sort(),
100101
);
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
3+
import { describe, expect, it } from "vitest";
4+
import { persistUpstreamRulesetSnapshot } from "../../src/db/repositories";
5+
import { LoopoverMcp } from "../../src/mcp/server";
6+
import type { UpstreamRulesetSnapshotRecord } from "../../src/types";
7+
import { createTestEnv } from "../helpers/d1";
8+
9+
async function connect(env: Env) {
10+
const server = new LoopoverMcp(env).createServer();
11+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
12+
await server.connect(serverTransport);
13+
const client = new Client({ name: "loopover-upstream-ruleset-test", version: "0.1.0" }, { capabilities: {} });
14+
await client.connect(clientTransport);
15+
return client;
16+
}
17+
18+
function ruleset(id: string, generatedAt: string): UpstreamRulesetSnapshotRecord {
19+
return {
20+
id,
21+
sourceRepo: "entrius/gittensor",
22+
sourceRef: "test",
23+
commitSha: `${id}-commit`,
24+
sourceSnapshotIds: [],
25+
activeModel: "pending_saturation_model",
26+
registryRepoCount: 1,
27+
totalEmissionShare: 0.01,
28+
semanticHash: `${id}-hash`,
29+
payload: {
30+
registry: { repoCount: 1, totalEmissionShare: 0.01, repositories: [] },
31+
scoring: { activeModel: "pending_saturation_model", constants: {}, semanticFlags: {} },
32+
issueDiscovery: { branchEligibilityRequired: false },
33+
mirrorLinkage: { solvedByPrRequired: false },
34+
languageWeights: { count: 0, weights: {} },
35+
sourceSnapshots: [],
36+
},
37+
warnings: [],
38+
generatedAt,
39+
};
40+
}
41+
42+
describe("MCP loopover_get_upstream_ruleset (#7807)", () => {
43+
it("registers as a utility-category no-argument tool", async () => {
44+
const client = await connect(createTestEnv());
45+
const { tools } = await client.listTools();
46+
const tool = tools.find((entry) => entry.name === "loopover_get_upstream_ruleset");
47+
expect(tool).toBeDefined();
48+
expect((tool as { _meta?: { category?: string } })._meta?.category).toBe("utility");
49+
expect(tool?.inputSchema).toMatchObject({ type: "object" });
50+
});
51+
52+
it("returns not_found as a normal result when no snapshot exists", async () => {
53+
const client = await connect(createTestEnv());
54+
const result = await client.callTool({ name: "loopover_get_upstream_ruleset", arguments: {} });
55+
expect(result.isError).toBeFalsy();
56+
expect(result.structuredContent).toEqual({ error: "upstream_ruleset_not_found" });
57+
});
58+
59+
it("returns the latest persisted ruleset snapshot", async () => {
60+
const env = createTestEnv();
61+
await persistUpstreamRulesetSnapshot(env, ruleset("ruleset-live", "2026-05-30T00:00:00.000Z"));
62+
const client = await connect(env);
63+
const result = await client.callTool({ name: "loopover_get_upstream_ruleset", arguments: {} });
64+
expect(result.isError).toBeFalsy();
65+
const payload = result.structuredContent as UpstreamRulesetSnapshotRecord;
66+
expect(payload.id).toBe("ruleset-live");
67+
expect(payload.activeModel).toBe("pending_saturation_model");
68+
expect(payload.semanticHash).toBe("ruleset-live-hash");
69+
});
70+
});

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -722,6 +722,22 @@ export async function startFixtureServer(
722722
);
723723
return;
724724
}
725+
if (request.url === "/v1/upstream/ruleset" && request.method === "GET") {
726+
response.end(
727+
JSON.stringify({
728+
id: "ruleset-1",
729+
sourceRepo: "entrius/gittensor",
730+
sourceRef: "main",
731+
commitSha: "abc123",
732+
activeModel: "pending_saturation_model",
733+
registryRepoCount: 1,
734+
semanticHash: "hash-1",
735+
generatedAt: "2026-05-30T00:00:00.000Z",
736+
warnings: [],
737+
}),
738+
);
739+
return;
740+
}
725741
if (request.url === "/v1/repos/owner/repo/intelligence" && request.method === "GET") {
726742
response.end(
727743
JSON.stringify({

0 commit comments

Comments
 (0)