Skip to content

Commit 3fe4960

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 17b4358 commit 3fe4960

2 files changed

Lines changed: 140 additions & 140 deletions

File tree

Lines changed: 70 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,70 +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-
});
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+
});
Lines changed: 70 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,70 +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-
});
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+
});

0 commit comments

Comments
 (0)