diff --git a/packages/loopover-mcp/bin/loopover-mcp.ts b/packages/loopover-mcp/bin/loopover-mcp.ts index 4d2d28e96e..b0054bc50a 100644 --- a/packages/loopover-mcp/bin/loopover-mcp.ts +++ b/packages/loopover-mcp/bin/loopover-mcp.ts @@ -454,6 +454,14 @@ const loginShape = { login: z.string().min(1), }; +// #7762: stdio mirror of the remote loopover_mark_notifications_read shape (src/mcp/server.ts). login is +// optional here, resolved from `login` / the active session / LOOPOVER_LOGIN like the notifications-read CLI; +// ids is optional -- omit to mark every delivered notification read. +const markNotificationsReadShape = { + login: z.string().min(1).optional(), + ids: z.array(z.string().min(1)).optional(), +}; + const loginRepoShape = { login: z.string().min(1), owner: z.string().min(1), @@ -1241,6 +1249,12 @@ const STDIO_TOOL_DESCRIPTORS = [ description: "Return a contributor's own post-merge outcome records — for each merged PR, a public-safe attribution of what it did for their standing on the repo. Self-scoped: only the authenticated login's outcomes.", }, + { + name: "loopover_mark_notifications_read", + category: "utility", + description: + "Mark a contributor's own delivered notifications as read (clears the badge). Self-scoped; pass `ids` to clear specific notifications or omit to clear all.", + }, { name: "loopover_compare_pr_variants", category: "branch", @@ -2318,6 +2332,22 @@ registerStdioTool( }, ); +// #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). +registerStdioTool( + "loopover_mark_notifications_read", + { + description: stdioToolDescription("loopover_mark_notifications_read"), + inputSchema: markNotificationsReadShape, + }, + async ({ login, ids }: 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."); + return toolResult(`Marked LoopOver notifications read for ${contributorLogin}.`, await postMarkNotificationsRead(contributorLogin, ids)); + }, +); + registerStdioTool( "loopover_compare_pr_variants", { diff --git a/test/unit/mcp-cli-mark-notifications-read.test.ts b/test/unit/mcp-cli-mark-notifications-read.test.ts new file mode 100644 index 0000000000..70f5551be5 --- /dev/null +++ b/test/unit/mcp-cli-mark-notifications-read.test.ts @@ -0,0 +1,103 @@ +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"; + +// #7762: in-process coverage for the loopover_mark_notifications_read stdio tool. Same #7764 entrypoint-guard +// pattern as mcp-cli-repo-focus-manifest -- import the .ts, hold the exported `server`, connect an +// InMemoryTransport so v8/Codecov attributes the registerStdioTool block (a subprocess spawn cannot be +// instrumented). The bin reuses postMarkNotificationsRead, so this drives the POST proxy end to end. +const MODULES = ["../../packages/loopover-mcp/bin/loopover-mcp.ts"] as const; + +type BinModule = { + server: { connect: (transport: unknown) => Promise }; +}; + +let tempDir = ""; +const markReadBodies: unknown[] = []; +const loaded = new Map(); + +beforeAll(async () => { + tempDir = mkdtempSync(join(tmpdir(), "loopover-mark-notifications-read-")); + const apiUrl = await startFixtureServer({ onMarkNotificationsRead: (body) => markReadBodies.push(body) }); + 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_mark_notifications_read stdio tool (in-process, #7762)", () => { + it.each(MODULES)("registers and proxies POST .../notifications/read, marking all read — %s", async (specifier) => { + markReadBodies.length = 0; + const mod = loaded.get(specifier)!; + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await mod.server.connect(serverTransport); + const client = new Client({ name: "mark-notifications-read-test", version: "0.1.0" }, { capabilities: {} }); + await client.connect(clientTransport); + try { + const { tools } = await client.listTools(); + const tool = tools.find((entry) => entry.name === "loopover_mark_notifications_read"); + expect(tool).toBeDefined(); + expect(tool?.description).toMatch(/notifications as read|clears the badge/i); + + // No ids -> mark every delivered notification read (empty POST body). + const all = await client.callTool({ + name: "loopover_mark_notifications_read", + arguments: { login: "JSONbored" }, + }); + expect(all.isError).toBeFalsy(); + expect(JSON.stringify(all)).toContain("marked"); + expect(markReadBodies).toEqual([{}]); + + // Explicit ids -> forwarded as { ids } in the POST body. + const some = await client.callTool({ + name: "loopover_mark_notifications_read", + arguments: { login: "JSONbored", ids: ["d1", "d2"] }, + }); + expect(some.isError).toBeFalsy(); + expect(markReadBodies[1]).toEqual({ ids: ["d1", "d2"] }); + } finally { + await client.close().catch(() => undefined); + } + }); + + it.each(MODULES)("errors (no request) when no login can be resolved from arg/session/env — %s", async (specifier) => { + markReadBodies.length = 0; + // Exercise the login ?? session ?? env fallback chain bottoming out, and the resulting throw. + const savedLogin = process.env.LOOPOVER_LOGIN; + const savedGh = process.env.GITHUB_LOGIN; + delete process.env.LOOPOVER_LOGIN; + delete process.env.GITHUB_LOGIN; + const mod = loaded.get(specifier)!; + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await mod.server.connect(serverTransport); + const client = new Client({ name: "mark-notifications-read-nologin", version: "0.1.0" }, { capabilities: {} }); + await client.connect(clientTransport); + try { + const result = await client.callTool({ name: "loopover_mark_notifications_read", arguments: {} }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result.content)).toMatch(/No GitHub login|LOOPOVER_LOGIN/i); + expect(markReadBodies).toEqual([]); + } finally { + await client.close().catch(() => undefined); + if (savedLogin !== undefined) process.env.LOOPOVER_LOGIN = savedLogin; + if (savedGh !== undefined) process.env.GITHUB_LOGIN = savedGh; + } + }); +}); diff --git a/test/unit/mcp-tool-rename-aliases.test.ts b/test/unit/mcp-tool-rename-aliases.test.ts index cc89b6f112..7ec08b47c5 100644 --- a/test/unit/mcp-tool-rename-aliases.test.ts +++ b/test/unit/mcp-tool-rename-aliases.test.ts @@ -31,6 +31,7 @@ // (#7800 registered the loopover_get_gate_config_effective remote+stdio tool, taking the count from 86 to 87.) // (#7797 registered the loopover_get_ams_miner_cohort remote+stdio tool, taking the count from 87 to 88.) // (#7808 registered the loopover_get_repo_focus_manifest remote+stdio tool, taking the count from 88 to 89.) +// (#7762 registered the loopover_mark_notifications_read stdio tool, taking the count from 89 to 90.) import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { mkdtempSync, rmSync } from "node:fs"; @@ -77,14 +78,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { }); afterEach(disconnect); - it("lists exactly 89 loopover_ tools and zero gittensory_-prefixed aliases", async () => { + it("lists exactly 90 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(89); + expect(primary.length).toBe(90); expect(legacy.length).toBe(0); - expect(names.length).toBe(89); + expect(names.length).toBe(90); }); it("no loopover_ tool's description carries a stale deprecation notice", async () => { @@ -96,14 +97,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { } }); - it("`loopover-mcp tools --json` reports the same 89-tool count the live server registers", async () => { + it("`loopover-mcp tools --json` reports the same 90-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(89); + expect(payload.count).toBe(90); expect([...payload.tools.map((t) => t.name)].sort()).toEqual( [...tools.map((t) => t.name)].sort(), );