Skip to content
Merged
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
23 changes: 23 additions & 0 deletions packages/loopover-mcp/bin/loopover-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
{
Expand Down
82 changes: 82 additions & 0 deletions test/unit/mcp-cli-bounty-advisory.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
10 changes: 5 additions & 5 deletions test/unit/mcp-tool-rename-aliases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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());
});
});
Expand Down