diff --git a/packages/gittensory-mcp/bin/gittensory-mcp.js b/packages/gittensory-mcp/bin/gittensory-mcp.js index 280189ec7c..4799493806 100755 --- a/packages/gittensory-mcp/bin/gittensory-mcp.js +++ b/packages/gittensory-mcp/bin/gittensory-mcp.js @@ -183,6 +183,26 @@ const checkBeforeStartShape = { plannedPaths: z.array(z.string()).optional(), }; +const findOpportunitiesShape = { + targets: z + .array( + z.object({ + owner: z.string().min(1), + repo: z.string().min(1), + }), + ) + .optional(), + searchQuery: z.string().min(1).max(500).optional(), + goalSpec: z + .object({ + lane: z.string().min(1).optional(), + minRankScore: z.number().min(0).max(100).optional(), + languages: z.array(z.string()).optional(), + }) + .optional(), + limit: z.number().int().min(1).max(50).optional(), +}; + const lintPrTextShape = { commitMessages: z.array(z.string()).max(50).optional(), prBody: z.string().optional(), @@ -379,6 +399,24 @@ server.registerTool( }, ); +server.registerTool( + "gittensory_find_opportunities", + { + description: + "Cross-repo discovery: find high-fit contribution opportunities across registered Gittensor repos. Returns a ranked, public-safe list filtered by your MinerGoalSpec (lane, min rank score, languages). Metadata-only, no GitHub writes.", + inputSchema: findOpportunitiesShape, + }, + async ({ targets, searchQuery, goalSpec, limit }) => { + const body = { + ...(targets && targets.length > 0 ? { targets } : {}), + ...(searchQuery ? { searchQuery } : {}), + ...(goalSpec ? { goalSpec } : {}), + ...(limit != null ? { limit } : {}), + }; + return toolResult("Gittensory cross-repo opportunities.", await apiPost("/v1/opportunities/find", body)); + }, +); + server.registerTool( "gittensory_lint_pr_text", { diff --git a/test/unit/mcp-cli-find-opportunities.test.ts b/test/unit/mcp-cli-find-opportunities.test.ts new file mode 100644 index 0000000000..8a9f1d0dad --- /dev/null +++ b/test/unit/mcp-cli-find-opportunities.test.ts @@ -0,0 +1,106 @@ +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/gittensory-mcp/bin/gittensory-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; body: string }>; + +async function connect() { + configDir = mkdtempSync(join(tmpdir(), "gittensory-find-opp-")); + capturedRequests = []; + apiUrl = await startFixtureServer({ + onApiRequest: (request) => { + if (request.url && request.url.includes("/v1/opportunities/find")) { + const chunks: Buffer[] = []; + request.on("data", (chunk: Buffer) => chunks.push(chunk)); + request.on("end", () => { + capturedRequests.push({ + url: request.url ?? "", + method: request.method ?? "GET", + body: Buffer.concat(chunks).toString("utf8"), + }); + }); + } + }, + }); + transport = new StdioClientTransport({ + command: "node", + args: [bin, "--stdio"], + env: { + ...process.env, + GITTENSORY_CONFIG_DIR: configDir, + GITTENSORY_API_URL: apiUrl, + GITTENSORY_TOKEN: "session-token", + GITTENSORY_API_TIMEOUT_MS: "5000", + }, + }); + client = new Client({ name: "find-opportunities-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("gittensory_find_opportunities stdio proxy", () => { + beforeEach(connect); + afterEach(disconnect); + + it("registers the tool in the stdio server's tool list", async () => { + const { tools } = await client.listTools(); + const names = tools.map((t) => t.name); + expect(names).toContain("gittensory_find_opportunities"); + }); + + it("proxies the call to /v1/opportunities/find via apiPost", async () => { + await client.callTool({ + name: "gittensory_find_opportunities", + arguments: { searchQuery: "test coverage", limit: 3 }, + }); + expect(capturedRequests.length).toBe(1); + const captured = capturedRequests[0]!; + expect(captured.url).toContain("/v1/opportunities/find"); + expect(captured.method).toBe("POST"); + const parsedBody = JSON.parse(captured.body) as { searchQuery?: string; limit?: number }; + expect(parsedBody.searchQuery).toBe("test coverage"); + expect(parsedBody.limit).toBe(3); + }); + + it("returns a ranked, public-safe list of opportunities", async () => { + const result = await client.callTool({ + name: "gittensory_find_opportunities", + arguments: { searchQuery: "scoring", limit: 2 }, + }); + expect(result.isError).toBeFalsy(); + const text = JSON.stringify(result); + expect(text).not.toMatch(FORBIDDEN_PUBLIC_TERMS); + expect(text).toContain("rankScore"); + expect(text).toContain("laneFit"); + expect(text).toContain("aiPolicyAllowed"); + }); + + it("strips undefined optional fields from the proxied body", async () => { + await client.callTool({ + name: "gittensory_find_opportunities", + arguments: { searchQuery: "minimum" }, + }); + expect(capturedRequests.length).toBe(1); + const parsedBody = JSON.parse(capturedRequests[0]!.body) as Record; + expect(parsedBody.searchQuery).toBe("minimum"); + expect("targets" in parsedBody).toBe(false); + expect("goalSpec" in parsedBody).toBe(false); + expect("limit" in parsedBody).toBe(false); + }); +}); \ No newline at end of file diff --git a/test/unit/support/mcp-cli-harness.ts b/test/unit/support/mcp-cli-harness.ts index 1d686e9b98..064246dced 100644 --- a/test/unit/support/mcp-cli-harness.ts +++ b/test/unit/support/mcp-cli-harness.ts @@ -234,6 +234,27 @@ export async function startFixtureServer( response.end(JSON.stringify(lintPrTextFixture(body))); return; } + if (request.url === "/v1/opportunities/find" && request.method === "POST") { + const body = (await readJsonRequest(request)) as { + targets?: Array<{ owner: string; repo: string }>; + searchQuery?: string; + goalSpec?: { lane?: string; minRankScore?: number; languages?: string[] }; + limit?: number; + }; + const limit = body.limit ?? 5; + const lane = body.goalSpec?.lane ?? "default"; + const minRank = body.goalSpec?.minRankScore ?? 0; + const candidates = [ + { owner: "JSONbored", repo: "gittensory", issueNumber: 100, title: "Improve REES test retry", rankScore: 85, laneFit: lane, freshness: 0.9, dupRisk: 0.1, aiPolicyAllowed: true }, + { owner: "JSONbored", repo: "gittensory", issueNumber: 101, title: "Add label-audit coverage", rankScore: 72, laneFit: lane, freshness: 0.7, dupRisk: 0.2, aiPolicyAllowed: true }, + { owner: "JSONbored", repo: "gittensory", issueNumber: 102, title: "Fix flaky buildBrief test", rankScore: 68, laneFit: lane, freshness: 0.5, dupRisk: 0.3, aiPolicyAllowed: true }, + { owner: "JSONbored", repo: "gittensory", issueNumber: 103, title: "Normalize path matchers", rankScore: 55, laneFit: lane, freshness: 0.4, dupRisk: 0.1, aiPolicyAllowed: true }, + { owner: "JSONbored", repo: "gittensory", issueNumber: 104, title: "Document score breakdown", rankScore: 45, laneFit: lane, freshness: 0.3, dupRisk: 0.1, aiPolicyAllowed: true }, + ]; + const ranked = candidates.filter((c) => c.rankScore >= minRank).slice(0, limit); + response.end(JSON.stringify({ ranked, totalCandidates: candidates.length, appliedLane: lane, appliedMinRankScore: minRank })); + return; + } // #784 maintainer controls (agent approval queue + kill-switch). if (request.url === "/v1/repos/owner/repo/agent/pending-actions" && request.method === "GET") { response.end(JSON.stringify({ repoFullName: "owner/repo", pendingActions: [{ id: "pa-1", actionClass: "merge", pullNumber: 7, reason: "clean", status: "pending" }] }));