diff --git a/packages/loopover-mcp/bin/loopover-mcp.js b/packages/loopover-mcp/bin/loopover-mcp.js index 7928263cc6..0dce0c153d 100644 --- a/packages/loopover-mcp/bin/loopover-mcp.js +++ b/packages/loopover-mcp/bin/loopover-mcp.js @@ -338,6 +338,12 @@ const ownerRepoPullShape = { number: z.number().int().positive(), }; +// #6736: the remote loopover_get_bounty_advisory tool's input shape (src/mcp/server.ts's bountyShape) -- +// a single cached-bounty id, GET /v1/bounties/:id/advisory. +const bountyAdvisoryShape = { + id: z.string().min(1), +}; + // #6619: same PR coordinates plus the OPTIONAL author login. Omitted, it resolves from the local session / // LOOPOVER_LOGIN / GITHUB_LOGIN, so an already-logged-in contributor never has to retype their own login. const prAiReviewFindingsShape = { @@ -1055,6 +1061,12 @@ 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_bounty_advisory", + category: "discovery", + description: + "Return the lifecycle, funding, and consensus-risk context for a cached Gittensor bounty by id, from the public LoopOver API.", + }, { name: "loopover_get_label_audit", category: "maintainer", @@ -1805,6 +1817,17 @@ registerStdioTool( async () => toolResult("LoopOver registry changes.", await apiGet("/v1/registry/changes")), ); +// #6736: CLI mirror of the public loopover_get_bounty_advisory tool. Proxies the same unauthenticated +// GET /v1/bounties/:id/advisory the remote tool wraps -- no owner/repo, just the cached-bounty id. +registerStdioTool( + "loopover_get_bounty_advisory", + { + description: stdioToolDescription("loopover_get_bounty_advisory"), + inputSchema: bountyAdvisoryShape, + }, + async ({ id }) => toolResult("LoopOver bounty advisory.", await apiGet(`/v1/bounties/${encodeURIComponent(id)}/advisory`)), +); + registerStdioTool( "loopover_get_upstream_drift", { diff --git a/test/unit/mcp-cli-bounty-advisory.test.ts b/test/unit/mcp-cli-bounty-advisory.test.ts new file mode 100644 index 0000000000..820d7b3d6d --- /dev/null +++ b/test/unit/mcp-cli-bounty-advisory.test.ts @@ -0,0 +1,82 @@ +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(), "gittensory-bounty-advisory-")); + capturedRequests = []; + apiUrl = await startFixtureServer({ + onApiRequest: (request) => { + if (request.url && request.url.includes("/v1/bounties/")) { + 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: "bounty-advisory-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_bounty_advisory stdio proxy", () => { + 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_bounty_advisory"); + }); + + it("proxies the bounty id to the public GET /v1/bounties/:id/advisory route via apiGet", async () => { + const result = await client.callTool({ name: "loopover_get_bounty_advisory", arguments: { id: "bounty-42" } }); + // The fixture server has no bounties route, so it 404s -- the point of THIS test is the proxy contract + // (the exact path + verb the remote tool wraps), which the tool computes before any response comes back. + expect(capturedRequests.length).toBe(1); + const captured = capturedRequests[0]!; + expect(captured.url).toContain("/v1/bounties/bounty-42/advisory"); + expect(captured.method).toBe("GET"); + // Never leaks a private/reward term regardless of success or error surfacing. + expect(JSON.stringify(result)).not.toMatch(FORBIDDEN_PUBLIC_TERMS); + }); + + it("url-encodes an id with reserved characters before hitting the route", async () => { + // The remote tool's bountyShape allows any non-empty string; a slash or space in an id must not break out + // of the /v1/bounties/:id/advisory path segment. + await client.callTool({ name: "loopover_get_bounty_advisory", arguments: { id: "acme/bounty 7" } }); + expect(capturedRequests[0]!.url).toContain("/v1/bounties/acme%2Fbounty%207/advisory"); + }); + + it("rejects a missing/empty id at the input-schema boundary, never issuing a request", async () => { + const result = await client.callTool({ name: "loopover_get_bounty_advisory", arguments: { id: "" } }); + expect(result.isError).toBe(true); + expect(capturedRequests.length).toBe(0); + }); +}); diff --git a/test/unit/mcp-tool-rename-aliases.test.ts b/test/unit/mcp-tool-rename-aliases.test.ts index e1f4a361cb..1efc0a3c27 100644 --- a/test/unit/mcp-tool-rename-aliases.test.ts +++ b/test/unit/mcp-tool-rename-aliases.test.ts @@ -59,14 +59,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { }); afterEach(disconnect); - it("lists exactly 73 loopover_ tools and zero gittensory_-prefixed aliases", async () => { + it("lists exactly 74 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(73); + expect(primary.length).toBe(74); expect(legacy.length).toBe(0); - expect(names.length).toBe(73); + expect(names.length).toBe(74); }); it("no loopover_ tool's description carries a stale deprecation notice", async () => { @@ -76,11 +76,11 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { } }); - it("`loopover-mcp tools --json` reports the same 73-tool count the live server registers", async () => { + it("`loopover-mcp tools --json` reports the same 74-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(73); + expect(payload.count).toBe(74); expect([...payload.tools.map((t) => t.name)].sort()).toEqual([...tools.map((t) => t.name)].sort()); }); });