diff --git a/packages/loopover-mcp/bin/loopover-mcp.ts b/packages/loopover-mcp/bin/loopover-mcp.ts index 3bad6ac30a..a6ca8da413 100644 --- a/packages/loopover-mcp/bin/loopover-mcp.ts +++ b/packages/loopover-mcp/bin/loopover-mcp.ts @@ -364,6 +364,16 @@ const prAiReviewFindingsShape = { login: z.string().min(1).optional(), }; +// #7763: the remote loopover_watch_issues tool's shape (src/mcp/server.ts's watchIssuesShape), with `login` +// made optional -- same login-resolution convention as prAiReviewFindingsShape above and the `watch` CLI +// command (watchCli), since a local stdio operator is already authenticated. +const watchIssuesShape = { + login: z.string().min(1).optional(), + action: z.enum(["watch", "unwatch", "list"]).default("list"), + repoFullName: z.string().min(3).max(200).optional(), + labels: z.array(z.string().min(1).max(100)).max(50).optional(), +}; + // #6149 write-tool input shapes -- mirror src/mcp/server.ts's remote shapes (same bounds) so the local // server validates identically. The builders (buildOpenPrSpec, ...) are the same @loopover/engine functions. const WRITE_TOOL_REPO_FULL_NAME_MAX = 200; @@ -1302,6 +1312,12 @@ const STDIO_TOOL_DESCRIPTORS = [ category: "maintainer", description: "Return per-gate-type false-positive precision for a repo's recorded gate blocks — blocked / blocked-then-merged counts and false-positive rates with low-sample guards. Optionally bounded by windowDays. Maintainer-authenticated; measurement only.", }, + { + name: "loopover_watch_issues", + category: "utility", + description: + "Watch repos for NEW grabbable, high-multiplier issues (maintainer-created, not WIP) — mirrors the `watch` CLI command and the remote loopover_watch_issues tool. action=watch subscribes a repo (optional label filter), unwatch removes it, list (default) returns your current watches. Self-scoped; login resolves from the argument, the active session, LOOPOVER_LOGIN, then GITHUB_LOGIN.", + }, { name: "loopover_open_pr", category: "agent", @@ -2623,6 +2639,38 @@ registerStdioTool( return toolResult(`Gate precision for ${owner}/${repo}.`, payload); }, ); + +// #7763: loopover_watch_issues was already a remote MCP tool (src/mcp/server.ts) and had a CLI mirror +// (`watch`, watchCli below, #6746), but never got the matching local stdio registration -- the same gap +// class the five maintain-surface tools above filled for #6152/#6382. Calls the exact +// /v1/contributors/:login/watches endpoint watchCli already calls, through the same apiGet/apiPost/ +// apiDelete client (list=GET, watch=POST, unwatch=DELETE) -- no duplicated HTTP logic. +registerStdioTool( + "loopover_watch_issues", + { + description: stdioToolDescription("loopover_watch_issues"), + inputSchema: watchIssuesShape, + }, + async ({ login, action, repoFullName, labels }: any) => { + const contributorLogin = login ?? activeProfile.session?.login ?? process.env.LOOPOVER_LOGIN ?? process.env.GITHUB_LOGIN; + if (!contributorLogin) throw new Error("No GitHub login: pass `login`, log in with `loopover-mcp login`, or set LOOPOVER_LOGIN."); + const base = `/v1/contributors/${encodeURIComponent(contributorLogin)}/watches`; + let payload: any; + if (action === "watch" || action === "unwatch") { + if (!repoFullName) throw new Error(`${action} requires repoFullName.`); + payload = + action === "watch" + ? await apiPost(base, { repoFullName, ...(labels && labels.length > 0 ? { labels } : {}) }) + : await apiDelete(base, { repoFullName }); + } else { + payload = await apiGet(base); + } + return toolResult( + `Watching ${(payload.watching ?? []).length} repo(s) for ${contributorLogin}${payload.changed ? ` (${payload.changed})` : ""}.`, + payload, + ); + }, +); // ── Write-tools (#6149): pure LOCAL-execution spec builders. loopover NEVER performs the write -- each tool // returns a spec the caller runs with its OWN gh creds. Brings the local stdio server to parity with the // miner-auto-dev profile's recommendedTools, using the same @loopover/engine builders as the remote server. diff --git a/test/unit/mcp-cli-watch-issues-tool.test.ts b/test/unit/mcp-cli-watch-issues-tool.test.ts new file mode 100644 index 0000000000..7a5c24390f --- /dev/null +++ b/test/unit/mcp-cli-watch-issues-tool.test.ts @@ -0,0 +1,161 @@ +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, describe, expect, it } from "vitest"; +import { closeFixtureServer, startFixtureServer } from "./support/mcp-cli-harness"; + +// #7763: the local stdio mirror of the remote loopover_watch_issues tool and the `watch` CLI command +// (test/unit/mcp-cli-watch.test.ts, #6746). The tool only resolves the login and proxies to +// /v1/contributors/:login/watches (list=GET, watch=POST, unwatch=DELETE) -- the route stays the single +// source of truth, so these tests assert the request the tool composes and its login-resolution fallback +// chain, not the watch-subscription logic itself. +const bin = join(process.cwd(), "packages/loopover-mcp/bin/loopover-mcp.js"); + +let client: Client; +let transport: StdioClientTransport; +let configDir: string; +// GET (list) never reaches onWatchRequest (the fixture short-circuits before it, mirroring +// mcp-cli-watch.test.ts's own list test), so requests are tracked at the raw HTTP level here instead. +let apiRequests: string[]; +let capturedRequests: Array<{ method: string; body: { repoFullName?: string; labels?: string[] } }>; + +/** Connect a stdio client with `env` overlaid, so each test drives its own login-resolution scenario. */ +async function connect(env: Record = {}) { + configDir = mkdtempSync(join(tmpdir(), "loopover-watch-issues-tool-")); + apiRequests = []; + capturedRequests = []; + const apiUrl = await startFixtureServer({ + onApiRequest: (request) => apiRequests.push(`${request.method} ${request.url}`), + onWatchRequest: (request) => capturedRequests.push(request), + }); + const childEnv: Record = {}; + for (const [key, value] of Object.entries(process.env)) if (value !== undefined) childEnv[key] = value; + // Dropped unless a test opts back in, so the RUNNER's own env can't satisfy the login fallback by accident. + delete childEnv.LOOPOVER_LOGIN; + delete childEnv.GITHUB_LOGIN; + transport = new StdioClientTransport({ + command: "node", + args: [bin, "--stdio"], + env: { + ...childEnv, + LOOPOVER_CONFIG_DIR: configDir, + LOOPOVER_API_URL: apiUrl, + LOOPOVER_TOKEN: "session-token", + LOOPOVER_API_TIMEOUT_MS: "5000", + ...env, + }, + }); + client = new Client({ name: "watch-issues-tool-test", version: "0.0.1" }); + await client.connect(transport); +} + +afterEach(async () => { + await client?.close().catch(() => undefined); + await closeFixtureServer(); + if (configDir) rmSync(configDir, { recursive: true, force: true }); +}); + +describe("loopover_watch_issues stdio mirror (#7763)", () => { + it("registers the tool in the stdio server tool list with a non-empty description", async () => { + await connect({ LOOPOVER_LOGIN: "octocat" }); + const { tools } = await client.listTools(); + const tool = tools.find((entry) => entry.name === "loopover_watch_issues"); + expect(tool, "loopover_watch_issues is not registered").toBeTruthy(); + expect(tool!.description?.trim().length).toBeGreaterThan(0); + }); + + it("defaults action to list and GETs the watches, reporting the count", async () => { + await connect({ LOOPOVER_LOGIN: "octocat" }); + const result = await client.callTool({ name: "loopover_watch_issues", arguments: {} }); + expect(result.isError).toBeFalsy(); + expect(apiRequests).toEqual(["GET /v1/contributors/octocat/watches"]); + expect(capturedRequests).toEqual([]); + const data = result.structuredContent as { watching: Array<{ repoFullName: string }> }; + expect(data.watching).toHaveLength(2); + expect(JSON.stringify(result.content)).toContain("Watching 2 repo(s) for octocat"); + }); + + it("watch POSTs {repoFullName,labels} and reports the change", async () => { + await connect(); + const result = await client.callTool({ + name: "loopover_watch_issues", + arguments: { login: "octocat", action: "watch", repoFullName: "acme/widgets", labels: ["bug", "feature"] }, + }); + expect(result.isError).toBeFalsy(); + expect(capturedRequests).toEqual([{ method: "POST", body: { repoFullName: "acme/widgets", labels: ["bug", "feature"] } }]); + expect(JSON.stringify(result.content)).toContain("watching acme/widgets (labels: bug, feature)"); + }); + + it("watch without labels sends no labels field", async () => { + await connect(); + await client.callTool({ name: "loopover_watch_issues", arguments: { login: "octocat", action: "watch", repoFullName: "acme/widgets" } }); + expect(capturedRequests[0]!.body).toEqual({ repoFullName: "acme/widgets" }); + }); + + it("unwatch DELETEs and reports it was unwatched", async () => { + await connect(); + const result = await client.callTool({ + name: "loopover_watch_issues", + arguments: { login: "octocat", action: "unwatch", repoFullName: "acme/widgets" }, + }); + expect(result.isError).toBeFalsy(); + expect(capturedRequests).toEqual([{ method: "DELETE", body: { repoFullName: "acme/widgets" } }]); + expect(JSON.stringify(result.content)).toContain("unwatched acme/widgets"); + }); + + it("falls back to LOOPOVER_LOGIN when no login argument is given", async () => { + await connect({ LOOPOVER_LOGIN: "env-login" }); + const result = await client.callTool({ name: "loopover_watch_issues", arguments: {} }); + expect(result.isError).toBeFalsy(); + expect(JSON.stringify(result.content)).toContain("for env-login"); + }); + + it("falls back to GITHUB_LOGIN when LOOPOVER_LOGIN is unset", async () => { + await connect({ GITHUB_LOGIN: "gh-login" }); + const result = await client.callTool({ name: "loopover_watch_issues", arguments: {} }); + expect(result.isError).toBeFalsy(); + expect(JSON.stringify(result.content)).toContain("for gh-login"); + }); + + it("prefers an explicit login argument over the environment fallbacks", async () => { + await connect({ LOOPOVER_LOGIN: "env-login", GITHUB_LOGIN: "gh-login" }); + const result = await client.callTool({ name: "loopover_watch_issues", arguments: { login: "explicit" } }); + expect(JSON.stringify(result.content)).toContain("for explicit"); + }); + + it("errors with actionable guidance -- and never calls the API -- when no login resolves anywhere", async () => { + await connect(); + const outcome = await client + .callTool({ name: "loopover_watch_issues", arguments: {} }) + .then((r) => ({ isError: Boolean(r.isError), text: JSON.stringify(r) }), (e: unknown) => ({ isError: true, text: String(e) })); + expect(outcome.isError).toBe(true); + expect(outcome.text).toMatch(/LOOPOVER_LOGIN|loopover-mcp login/); + expect(capturedRequests).toHaveLength(0); + }); + + it("errors when watch/unwatch is missing repoFullName, without calling the API", async () => { + await connect({ LOOPOVER_LOGIN: "octocat" }); + for (const action of ["watch", "unwatch"] as const) { + const outcome = await client + .callTool({ name: "loopover_watch_issues", arguments: { action } }) + .then((r) => ({ isError: Boolean(r.isError), text: JSON.stringify(r) }), (e: unknown) => ({ isError: true, text: String(e) })); + expect(outcome.isError).toBe(true); + expect(outcome.text).toMatch(new RegExp(`${action} requires repoFullName`)); + } + expect(capturedRequests).toHaveLength(0); + }); + + it("rejects invalid input (zod): a blank login and an unknown action, without calling the API", async () => { + await connect({ LOOPOVER_LOGIN: "octocat" }); + for (const args of [{ login: "" }, { action: "bogus" }]) { + const outcome = await client.callTool({ name: "loopover_watch_issues", arguments: args }).then( + (r) => Boolean(r.isError), + () => true, + ); + expect(outcome, `${JSON.stringify(args)} should be rejected`).toBe(true); + } + expect(capturedRequests).toHaveLength(0); + }); +}); diff --git a/test/unit/mcp-tool-rename-aliases.test.ts b/test/unit/mcp-tool-rename-aliases.test.ts index 5088d983c7..a1849f9d24 100644 --- a/test/unit/mcp-tool-rename-aliases.test.ts +++ b/test/unit/mcp-tool-rename-aliases.test.ts @@ -22,6 +22,7 @@ // (#6747 registered the loopover_pr_outcome CLI mirror, taking the count from 77 to 78.) // (#6980 registered the loopover_explain_review_risk CLI mirror, taking the count from 78 to 79.) // (#7758 registered the loopover_get_outcome_calibration stdio tool, taking the count from 79 to 80.) +// (#7763 registered the loopover_watch_issues stdio mirror, taking the count from 80 to 81.) import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { mkdtempSync, rmSync } from "node:fs"; @@ -69,14 +70,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { }); afterEach(disconnect); - it("lists exactly 80 loopover_ tools and zero gittensory_-prefixed aliases", async () => { + it("lists exactly 81 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(80); + expect(primary.length).toBe(81); expect(legacy.length).toBe(0); - expect(names.length).toBe(80); + expect(names.length).toBe(81); }); it("no loopover_ tool's description carries a stale deprecation notice", async () => { @@ -88,14 +89,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { } }); - it("`loopover-mcp tools --json` reports the same 80-tool count the live server registers", async () => { + it("`loopover-mcp tools --json` reports the same 81-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(80); + expect(payload.count).toBe(81); expect([...payload.tools.map((t) => t.name)].sort()).toEqual( [...tools.map((t) => t.name)].sort(), );