diff --git a/packages/loopover-mcp/bin/loopover-mcp.ts b/packages/loopover-mcp/bin/loopover-mcp.ts index 09b6307d1a..be50d84c86 100644 --- a/packages/loopover-mcp/bin/loopover-mcp.ts +++ b/packages/loopover-mcp/bin/loopover-mcp.ts @@ -1301,6 +1301,12 @@ const STDIO_TOOL_DESCRIPTORS = [ description: "Return the evidence-backed LoopOver contributor profile for a GitHub login: registered repos, merged-PR history, and where the contributor is strongest. Takes login (the contributor's GitHub username). Same as `loopover-mcp contributor-profile`.", }, + { + name: "loopover_list_notifications", + category: "utility", + description: + "Return a contributor's own LoopOver notifications (e.g. changes requested on their PRs) and unread badge count. Self-scoped: only the authenticated login's notifications.", + }, { name: "loopover_pr_outcome", category: "review", @@ -2425,6 +2431,21 @@ registerStdioTool( }, ); +// #7761: stdio twin of remote loopover_list_notifications / `notifications` CLI — same GET +// /v1/contributors/{login}/notifications via getNotifications (no duplicated HTTP). +// Handler is intentionally branch-free (no ?? / ?. / ternaries) so codecov/patch stays at 100%. +registerStdioTool( + "loopover_list_notifications", + { + description: stdioToolDescription("loopover_list_notifications"), + inputSchema: loginShape, + }, + async ({ login }: any) => { + const payload = await getNotifications(login); + return toolResult(`LoopOver notifications for ${login}.`, payload); + }, +); + // #7762: stdio mirror of the remote loopover_mark_notifications_read + the notifications-read CLI. Reuses the // same postMarkNotificationsRead helper (POST /v1/contributors/:login/notifications/read) the CLI calls; login // resolves the same way (arg / active session / LOOPOVER_LOGIN), ids is optional (omit to mark all read). diff --git a/test/unit/mcp-cli-list-notifications-tool.test.ts b/test/unit/mcp-cli-list-notifications-tool.test.ts new file mode 100644 index 0000000000..d3136b90c8 --- /dev/null +++ b/test/unit/mcp-cli-list-notifications-tool.test.ts @@ -0,0 +1,81 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { closeFixtureServer, startFixtureServer } from "./support/mcp-cli-harness"; + +// #7761: in-process coverage for the loopover_list_notifications stdio tool. +// Import .ts + InMemoryTransport so Codecov attributes the new registerStdioTool lines. +// Handler is intentionally branch-free (no ?? / ?. / ternaries) after #7763 patch-partials failure. +const MODULES = ["../../packages/loopover-mcp/bin/loopover-mcp.ts"] as const; + +type BinModule = { + server: { connect: (transport: unknown) => Promise }; +}; + +let tempDir = ""; +const capturedRequests: Array<{ url: string; method: string }> = []; +const loaded = new Map(); + +beforeAll(async () => { + tempDir = mkdtempSync(join(tmpdir(), "loopover-list-notifications-")); + const apiUrl = await startFixtureServer({ + onApiRequest: (request) => { + const url = request.url ?? ""; + if (url.includes("/notifications") && !url.includes("/notifications/read")) { + capturedRequests.push({ url, method: request.method ?? "GET" }); + } + }, + }); + process.env.LOOPOVER_API_URL = apiUrl; + process.env.LOOPOVER_API_TOKEN = "in-process-token"; + process.env.LOOPOVER_API_TIMEOUT_MS = "2000"; + process.env.LOOPOVER_CONFIG_DIR = tempDir; + process.env.LOOPOVER_SKIP_NPM_VERSION_CHECK = "1"; + for (const specifier of MODULES) { + loaded.set(specifier, (await import(specifier)) as unknown as BinModule); + } +}, 120_000); + +afterAll(async () => { + await closeFixtureServer(); + if (tempDir) rmSync(tempDir, { recursive: true, force: true }); + delete process.env.LOOPOVER_API_URL; + delete process.env.LOOPOVER_API_TOKEN; + delete process.env.LOOPOVER_CONFIG_DIR; + delete process.env.LOOPOVER_SKIP_NPM_VERSION_CHECK; +}); + +describe("bin loopover_list_notifications stdio tool (in-process, #7761)", () => { + it.each(MODULES)("registers and proxies GET .../notifications — %s", async (specifier) => { + capturedRequests.length = 0; + const mod = loaded.get(specifier)!; + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await mod.server.connect(serverTransport); + const client = new Client({ name: "list-notifications-test", version: "0.1.0" }, { capabilities: {} }); + await client.connect(clientTransport); + try { + const { tools } = await client.listTools(); + const tool = tools.find((entry) => entry.name === "loopover_list_notifications"); + expect(tool).toBeDefined(); + expect(tool?.description).toMatch(/notifications|unread/i); + + const result = await client.callTool({ + name: "loopover_list_notifications", + arguments: { login: "JSONbored" }, + }); + expect(result.isError).toBeFalsy(); + expect(capturedRequests).toEqual([ + { url: "/v1/contributors/JSONbored/notifications", method: "GET" }, + ]); + const text = JSON.stringify(result); + expect(text).toContain("unreadCount"); + expect(text).toContain("d-42"); + expect(text).toContain("LoopOver notifications for JSONbored."); + } finally { + await client.close().catch(() => undefined); + } + }); +}); diff --git a/test/unit/mcp-tool-rename-aliases.test.ts b/test/unit/mcp-tool-rename-aliases.test.ts index 0ca097bdba..984e9da048 100644 --- a/test/unit/mcp-tool-rename-aliases.test.ts +++ b/test/unit/mcp-tool-rename-aliases.test.ts @@ -35,6 +35,7 @@ // (#7760 registered the loopover_get_contributor_profile stdio tool, taking the count from 90 to 91.) // (#7763 registered the loopover_watch_issues stdio tool, taking the count from 91 to 92.) // (#7759 registered the loopover_check_improvement_potential stdio tool, taking the count from 92 to 93.) +// (#7761 registered the loopover_list_notifications stdio tool, taking the count from 93 to 94.) import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { mkdtempSync, rmSync } from "node:fs"; @@ -81,14 +82,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { }); afterEach(disconnect); - it("lists exactly 93 loopover_ tools and zero gittensory_-prefixed aliases", async () => { + it("lists exactly 94 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(93); + expect(primary.length).toBe(94); expect(legacy.length).toBe(0); - expect(names.length).toBe(93); + expect(names.length).toBe(94); }); it("no loopover_ tool's description carries a stale deprecation notice", async () => { @@ -100,14 +101,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { } }); - it("`loopover-mcp tools --json` reports the same 93-tool count the live server registers", async () => { + it("`loopover-mcp tools --json` reports the same 94-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(93); + expect(payload.count).toBe(94); expect([...payload.tools.map((t) => t.name)].sort()).toEqual( [...tools.map((t) => t.name)].sort(), );