From 42f43ea90bdd3b1043c98dd23df625c961668d40 Mon Sep 17 00:00:00 2001 From: bitfathers94 <237535319+bitfathers94@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:55:30 +0000 Subject: [PATCH] feat(mcp): add loopover_list_bounties and loopover_get_bounty_lifecycle tools Mirror the two remaining public bounty REST routes (GET /v1/bounties and GET /v1/bounties/:id/lifecycle) as read-only MCP tools, matching the shape of the existing loopover_get_bounty_advisory. loopover_list_bounties takes no input and returns every cached bounty; loopover_get_bounty_lifecycle takes a bounty id and returns its { bountyId, events } history, surfacing the REST 404 as a tool error. Both are categorized under discovery and carry MCP output schemas. --- src/mcp/server.ts | 51 +++++++++++++++++ test/unit/mcp-bounty-tools.test.ts | 88 ++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 test/unit/mcp-bounty-tools.test.ts diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 635f863ad7..93e87c09a0 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -38,7 +38,9 @@ import { countOpenPullRequests, createPendingAgentActionIfAbsent, getBounty, + listBounties, listBountiesByRepo, + listBountyLifecycleEvents, getContributorEvidence, getLatestRepoGithubTotalsSnapshot, getInstallation, @@ -1760,6 +1762,13 @@ const bountyAdvisoryOutputSchema = { linkedPrs: z.unknown().optional(), findings: z.array(z.unknown()).optional(), }; +const bountyListOutputSchema = { + bounties: z.array(z.unknown()).optional(), +}; +const bountyLifecycleOutputSchema = { + bountyId: z.string().optional(), + events: z.array(z.unknown()).optional(), +}; const preflightLocalDiffOutputSchema = { ...preflightResultOutputSchema, localDiff: z.unknown().optional(), @@ -2046,6 +2055,8 @@ export const MCP_TOOL_CATEGORIES: Record = { loopover_explain_repo_decision: "discovery", loopover_preflight_pr: "discovery", loopover_get_bounty_advisory: "discovery", + loopover_list_bounties: "discovery", + loopover_get_bounty_lifecycle: "discovery", loopover_get_registry_changes: "utility", loopover_get_registry_snapshot: "utility", loopover_get_upstream_drift: "utility", @@ -2614,6 +2625,26 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.getBountyAdvisory(input.id)), ); + register( + "loopover_list_bounties", + { + description: "List all cached Gittensor bounties (mirrors the public GET /v1/bounties route; no repo/owner input).", + inputSchema: {}, + outputSchema: bountyListOutputSchema, + }, + async () => this.toolResult(await this.getBountyList()), + ); + + register( + "loopover_get_bounty_lifecycle", + { + description: "Return the lifecycle-event history for a cached Gittensor bounty by id (mirrors GET /v1/bounties/:id/lifecycle).", + inputSchema: bountyShape, + outputSchema: bountyLifecycleOutputSchema, + }, + async (input) => this.toolResult(await this.getBountyLifecycle(input.id)), + ); + register( "loopover_get_registry_changes", { @@ -5547,6 +5578,26 @@ export class LoopoverMcp { }; } + // #9296 — mirror the public GET /v1/bounties route: list every cached bounty, no repo/owner scoping. + private async getBountyList(): Promise { + const bounties = await listBounties(this.env); + return { + summary: `LoopOver bounties: ${bounties.length} cached.`, + data: { bounties } as unknown as Record, + }; + } + + // #9296 — mirror GET /v1/bounties/:id/lifecycle: the bounty's event history, 404 when the id is unknown. + private async getBountyLifecycle(id: string): Promise { + const bounty = await getBounty(this.env, id); + if (!bounty) throw new Error("Bounty not found."); + const events = await listBountyLifecycleEvents(this.env, id); + return { + summary: `LoopOver bounty lifecycle for ${id}: ${events.length} event(s).`, + data: { bountyId: id, events } as unknown as Record, + }; + } + private async loadContributorFastContext(login: string) { const [github, contributorPullRequests, contributorIssues, repositories, syncStates, cachedRepoStats, gittensorSnapshot] = await Promise.all([ fetchPublicContributorProfile(login, this.env), diff --git a/test/unit/mcp-bounty-tools.test.ts b/test/unit/mcp-bounty-tools.test.ts new file mode 100644 index 0000000000..1a15355184 --- /dev/null +++ b/test/unit/mcp-bounty-tools.test.ts @@ -0,0 +1,88 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { describe, expect, it } from "vitest"; +import { listBounties, listBountyLifecycleEvents, persistBountyLifecycleEvent, upsertBounty } from "../../src/db/repositories"; +import { LoopoverMcp } from "../../src/mcp/server"; +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: "bounty-tools-test", version: "0.0.1" }, { capabilities: {} }); + await client.connect(clientTransport); + return client; +} + +// #9296 — the two new read-only MCP bounty tools that close the bounty read-parity gap with REST. +describe("loopover_list_bounties (#9296)", () => { + it("registers the tool in tools/list under the discovery category", async () => { + const client = await connect(createTestEnv()); + const { tools } = await client.listTools(); + const tool = tools.find((t) => t.name === "loopover_list_bounties"); + expect(tool).toBeDefined(); + expect((tool?._meta as { category?: string } | undefined)?.category).toBe("discovery"); + await client.close(); + }); + + it("returns the same bounty set GET /v1/bounties serves, no repo/owner input", async () => { + const env = createTestEnv(); + await upsertBounty(env, { id: "octo/demo#1", repoFullName: "octo/demo", issueNumber: 1, status: "active", payload: {} }); + await upsertBounty(env, { id: "octo/demo#2", repoFullName: "octo/demo", issueNumber: 2, status: "resolved", payload: { note: "done" } }); + const client = await connect(env); + + const result = await client.callTool({ name: "loopover_list_bounties", arguments: {} }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as { bounties: unknown[] }; + // Regression: the tool payload must mirror the REST route's data exactly (listBounties(env)). + expect(data.bounties).toEqual(await listBounties(env)); + expect(data.bounties).toHaveLength(2); + }); + + it("returns an empty list when no bounties are cached", async () => { + const client = await connect(createTestEnv()); + const result = await client.callTool({ name: "loopover_list_bounties", arguments: {} }); + expect(result.isError).toBeFalsy(); + expect(result.structuredContent).toEqual({ bounties: [] }); + await client.close(); + }); +}); + +describe("loopover_get_bounty_lifecycle (#9296)", () => { + it("returns the { bountyId, events } shape GET /v1/bounties/:id/lifecycle serves", async () => { + const env = createTestEnv(); + await upsertBounty(env, { id: "octo/demo#1", repoFullName: "octo/demo", issueNumber: 1, status: "active", payload: {} }); + await persistBountyLifecycleEvent(env, { + id: "evt-1", + bountyId: "octo/demo#1", + repoFullName: "octo/demo", + issueNumber: 1, + status: "active", + payload: { phase: "opened" }, + generatedAt: "2026-06-01T00:00:00.000Z", + }); + const client = await connect(env); + + const result = await client.callTool({ name: "loopover_get_bounty_lifecycle", arguments: { id: "octo/demo#1" } }); + expect(result.isError).toBeFalsy(); + // Regression: mirrors the REST route's exact body -- id echoed back + the raw lifecycle events. + expect(result.structuredContent).toEqual({ bountyId: "octo/demo#1", events: await listBountyLifecycleEvents(env, "octo/demo#1") }); + expect((result.structuredContent as { events: unknown[] }).events).toHaveLength(1); + }); + + it("surfaces a tool error (not a silent empty result) when the bounty id is unknown", async () => { + const client = await connect(createTestEnv()); + const result = await client.callTool({ name: "loopover_get_bounty_lifecycle", arguments: { id: "missing#1" } }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result.content)).toMatch(/bounty not found/i); + expect(result.structuredContent).toBeUndefined(); + await client.close(); + }); + + it("rejects a missing/empty id at the input-schema boundary", async () => { + const client = await connect(createTestEnv()); + const result = await client.callTool({ name: "loopover_get_bounty_lifecycle", arguments: { id: "" } }); + expect(result.isError).toBe(true); + await client.close(); + }); +});