Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions packages/loopover-mcp/bin/loopover-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
{
Expand Down
47 changes: 47 additions & 0 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
listBountiesByRepo,
getContributorEvidence,
getLatestRepoGithubTotalsSnapshot,
getLatestUpstreamRulesetSnapshot,
getInstallation,
getIssue,
getPendingAgentAction,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -1836,6 +1854,7 @@ export const MCP_TOOL_CATEGORIES: Record<string, McpToolCategory> = {
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",
Expand Down Expand Up @@ -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",
{
Expand Down Expand Up @@ -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<ToolPayload> {
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<string, unknown>,
};
}

private async preflightPr(input: z.infer<z.ZodObject<typeof preflightShape>>): Promise<ToolPayload> {
await this.requireRepoAccess(input.repoFullName);
const [repo, issues, pullRequests, bounties, issueQuality] = await Promise.all([
Expand Down
1 change: 1 addition & 0 deletions test/integration/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
70 changes: 70 additions & 0 deletions test/unit/mcp-cli-upstream-ruleset.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
11 changes: 10 additions & 1 deletion test/unit/mcp-output-schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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<string, unknown>;
expect(data.error).toBe("upstream_ruleset_not_found");
});

it("loopover_get_registry_changes returns validated structured content", async () => {
const env = createTestEnv();
await seedRegistryChangeSnapshots(env);
Expand Down Expand Up @@ -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(
Expand Down
11 changes: 6 additions & 5 deletions test/unit/mcp-tool-rename-aliases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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(),
);
Expand Down
70 changes: 70 additions & 0 deletions test/unit/mcp-upstream-ruleset.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
16 changes: 16 additions & 0 deletions test/unit/support/mcp-cli-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading