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
51 changes: 51 additions & 0 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ import {
countOpenPullRequests,
createPendingAgentActionIfAbsent,
getBounty,
listBounties,
listBountiesByRepo,
listBountyLifecycleEvents,
getContributorEvidence,
getLatestRepoGithubTotalsSnapshot,
getInstallation,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -2046,6 +2055,8 @@ export const MCP_TOOL_CATEGORIES: Record<string, McpToolCategory> = {
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",
Expand Down Expand Up @@ -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",
{
Expand Down Expand Up @@ -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<ToolPayload> {
const bounties = await listBounties(this.env);
return {
summary: `LoopOver bounties: ${bounties.length} cached.`,
data: { bounties } as unknown as Record<string, unknown>,
};
}

// #9296 — mirror GET /v1/bounties/:id/lifecycle: the bounty's event history, 404 when the id is unknown.
private async getBountyLifecycle(id: string): Promise<ToolPayload> {
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<string, unknown>,
};
}

private async loadContributorFastContext(login: string) {
const [github, contributorPullRequests, contributorIssues, repositories, syncStates, cachedRepoStats, gittensorSnapshot] = await Promise.all([
fetchPublicContributorProfile(login, this.env),
Expand Down
88 changes: 88 additions & 0 deletions test/unit/mcp-bounty-tools.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});