Skip to content

Commit fa3581d

Browse files
authored
feat(mcp): register loopover_mark_notifications_read as a local stdio tool (#7950)
loopover_mark_notifications_read has a remote MCP tool (src/mcp/server.ts) and a notifications-read CLI command, but no local stdio MCP tool registration. #6745 added the REST route + CLI but never the matching stdio tool, so a self-host operator using the local MCP server couldn't call it. Adds the registerStdioTool block following the existing sibling pattern, reusing the same postMarkNotificationsRead helper (POST /v1/contributors/:login/ notifications/read) the notifications-read CLI already calls — no duplicated HTTP logic. login resolves from arg / active session / LOOPOVER_LOGIN like the CLI; ids is optional (omit to mark every delivered notification read). test/unit/mcp-cli-mark-notifications-read.test.ts drives it in-process (the #7764 entrypoint-guard pattern) so the registration + handler get real Codecov coverage, including the no-login throw branch. Tool-count invariant bumped 89 -> 90. Closes #7762
1 parent 002a864 commit fa3581d

3 files changed

Lines changed: 139 additions & 5 deletions

File tree

packages/loopover-mcp/bin/loopover-mcp.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,14 @@ const loginShape = {
454454
login: z.string().min(1),
455455
};
456456

457+
// #7762: stdio mirror of the remote loopover_mark_notifications_read shape (src/mcp/server.ts). login is
458+
// optional here, resolved from `login` / the active session / LOOPOVER_LOGIN like the notifications-read CLI;
459+
// ids is optional -- omit to mark every delivered notification read.
460+
const markNotificationsReadShape = {
461+
login: z.string().min(1).optional(),
462+
ids: z.array(z.string().min(1)).optional(),
463+
};
464+
457465
const loginRepoShape = {
458466
login: z.string().min(1),
459467
owner: z.string().min(1),
@@ -1241,6 +1249,12 @@ const STDIO_TOOL_DESCRIPTORS = [
12411249
description:
12421250
"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.",
12431251
},
1252+
{
1253+
name: "loopover_mark_notifications_read",
1254+
category: "utility",
1255+
description:
1256+
"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.",
1257+
},
12441258
{
12451259
name: "loopover_compare_pr_variants",
12461260
category: "branch",
@@ -2318,6 +2332,22 @@ registerStdioTool(
23182332
},
23192333
);
23202334

2335+
// #7762: stdio mirror of the remote loopover_mark_notifications_read + the notifications-read CLI. Reuses the
2336+
// same postMarkNotificationsRead helper (POST /v1/contributors/:login/notifications/read) the CLI calls; login
2337+
// resolves the same way (arg / active session / LOOPOVER_LOGIN), ids is optional (omit to mark all read).
2338+
registerStdioTool(
2339+
"loopover_mark_notifications_read",
2340+
{
2341+
description: stdioToolDescription("loopover_mark_notifications_read"),
2342+
inputSchema: markNotificationsReadShape,
2343+
},
2344+
async ({ login, ids }: any) => {
2345+
const contributorLogin = login ?? activeProfile.session?.login ?? process.env.LOOPOVER_LOGIN ?? process.env.GITHUB_LOGIN;
2346+
if (!contributorLogin) throw new Error("No GitHub login: pass `login`, log in with `loopover-mcp login`, or set LOOPOVER_LOGIN.");
2347+
return toolResult(`Marked LoopOver notifications read for ${contributorLogin}.`, await postMarkNotificationsRead(contributorLogin, ids));
2348+
},
2349+
);
2350+
23212351
registerStdioTool(
23222352
"loopover_compare_pr_variants",
23232353
{
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
3+
import { mkdtempSync, rmSync } from "node:fs";
4+
import { tmpdir } from "node:os";
5+
import { join } from "node:path";
6+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
7+
import { closeFixtureServer, startFixtureServer } from "./support/mcp-cli-harness";
8+
9+
// #7762: in-process coverage for the loopover_mark_notifications_read stdio tool. Same #7764 entrypoint-guard
10+
// pattern as mcp-cli-repo-focus-manifest -- import the .ts, hold the exported `server`, connect an
11+
// InMemoryTransport so v8/Codecov attributes the registerStdioTool block (a subprocess spawn cannot be
12+
// instrumented). The bin reuses postMarkNotificationsRead, so this drives the POST proxy end to end.
13+
const MODULES = ["../../packages/loopover-mcp/bin/loopover-mcp.ts"] as const;
14+
15+
type BinModule = {
16+
server: { connect: (transport: unknown) => Promise<void> };
17+
};
18+
19+
let tempDir = "";
20+
const markReadBodies: unknown[] = [];
21+
const loaded = new Map<string, BinModule>();
22+
23+
beforeAll(async () => {
24+
tempDir = mkdtempSync(join(tmpdir(), "loopover-mark-notifications-read-"));
25+
const apiUrl = await startFixtureServer({ onMarkNotificationsRead: (body) => markReadBodies.push(body) });
26+
process.env.LOOPOVER_API_URL = apiUrl;
27+
process.env.LOOPOVER_API_TOKEN = "in-process-token";
28+
process.env.LOOPOVER_API_TIMEOUT_MS = "2000";
29+
process.env.LOOPOVER_CONFIG_DIR = tempDir;
30+
process.env.LOOPOVER_SKIP_NPM_VERSION_CHECK = "1";
31+
for (const specifier of MODULES) {
32+
loaded.set(specifier, (await import(specifier)) as unknown as BinModule);
33+
}
34+
}, 120_000);
35+
36+
afterAll(async () => {
37+
await closeFixtureServer();
38+
if (tempDir) rmSync(tempDir, { recursive: true, force: true });
39+
delete process.env.LOOPOVER_API_URL;
40+
delete process.env.LOOPOVER_API_TOKEN;
41+
delete process.env.LOOPOVER_CONFIG_DIR;
42+
delete process.env.LOOPOVER_SKIP_NPM_VERSION_CHECK;
43+
});
44+
45+
describe("bin loopover_mark_notifications_read stdio tool (in-process, #7762)", () => {
46+
it.each(MODULES)("registers and proxies POST .../notifications/read, marking all read — %s", async (specifier) => {
47+
markReadBodies.length = 0;
48+
const mod = loaded.get(specifier)!;
49+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
50+
await mod.server.connect(serverTransport);
51+
const client = new Client({ name: "mark-notifications-read-test", version: "0.1.0" }, { capabilities: {} });
52+
await client.connect(clientTransport);
53+
try {
54+
const { tools } = await client.listTools();
55+
const tool = tools.find((entry) => entry.name === "loopover_mark_notifications_read");
56+
expect(tool).toBeDefined();
57+
expect(tool?.description).toMatch(/notifications as read|clears the badge/i);
58+
59+
// No ids -> mark every delivered notification read (empty POST body).
60+
const all = await client.callTool({
61+
name: "loopover_mark_notifications_read",
62+
arguments: { login: "JSONbored" },
63+
});
64+
expect(all.isError).toBeFalsy();
65+
expect(JSON.stringify(all)).toContain("marked");
66+
expect(markReadBodies).toEqual([{}]);
67+
68+
// Explicit ids -> forwarded as { ids } in the POST body.
69+
const some = await client.callTool({
70+
name: "loopover_mark_notifications_read",
71+
arguments: { login: "JSONbored", ids: ["d1", "d2"] },
72+
});
73+
expect(some.isError).toBeFalsy();
74+
expect(markReadBodies[1]).toEqual({ ids: ["d1", "d2"] });
75+
} finally {
76+
await client.close().catch(() => undefined);
77+
}
78+
});
79+
80+
it.each(MODULES)("errors (no request) when no login can be resolved from arg/session/env — %s", async (specifier) => {
81+
markReadBodies.length = 0;
82+
// Exercise the login ?? session ?? env fallback chain bottoming out, and the resulting throw.
83+
const savedLogin = process.env.LOOPOVER_LOGIN;
84+
const savedGh = process.env.GITHUB_LOGIN;
85+
delete process.env.LOOPOVER_LOGIN;
86+
delete process.env.GITHUB_LOGIN;
87+
const mod = loaded.get(specifier)!;
88+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
89+
await mod.server.connect(serverTransport);
90+
const client = new Client({ name: "mark-notifications-read-nologin", version: "0.1.0" }, { capabilities: {} });
91+
await client.connect(clientTransport);
92+
try {
93+
const result = await client.callTool({ name: "loopover_mark_notifications_read", arguments: {} });
94+
expect(result.isError).toBe(true);
95+
expect(JSON.stringify(result.content)).toMatch(/No GitHub login|LOOPOVER_LOGIN/i);
96+
expect(markReadBodies).toEqual([]);
97+
} finally {
98+
await client.close().catch(() => undefined);
99+
if (savedLogin !== undefined) process.env.LOOPOVER_LOGIN = savedLogin;
100+
if (savedGh !== undefined) process.env.GITHUB_LOGIN = savedGh;
101+
}
102+
});
103+
});

test/unit/mcp-tool-rename-aliases.test.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
// (#7800 registered the loopover_get_gate_config_effective remote+stdio tool, taking the count from 86 to 87.)
3232
// (#7797 registered the loopover_get_ams_miner_cohort remote+stdio tool, taking the count from 87 to 88.)
3333
// (#7808 registered the loopover_get_repo_focus_manifest remote+stdio tool, taking the count from 88 to 89.)
34+
// (#7762 registered the loopover_mark_notifications_read stdio tool, taking the count from 89 to 90.)
3435
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3536
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
3637
import { mkdtempSync, rmSync } from "node:fs";
@@ -77,14 +78,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
7778
});
7879
afterEach(disconnect);
7980

80-
it("lists exactly 89 loopover_ tools and zero gittensory_-prefixed aliases", async () => {
81+
it("lists exactly 90 loopover_ tools and zero gittensory_-prefixed aliases", async () => {
8182
const { tools } = await client.listTools();
8283
const names = tools.map((t) => t.name);
8384
const primary = names.filter((n) => n.startsWith("loopover_"));
8485
const legacy = names.filter((n) => n.startsWith("gittensory_"));
85-
expect(primary.length).toBe(89);
86+
expect(primary.length).toBe(90);
8687
expect(legacy.length).toBe(0);
87-
expect(names.length).toBe(89);
88+
expect(names.length).toBe(90);
8889
});
8990

9091
it("no loopover_ tool's description carries a stale deprecation notice", async () => {
@@ -96,14 +97,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
9697
}
9798
});
9899

99-
it("`loopover-mcp tools --json` reports the same 89-tool count the live server registers", async () => {
100+
it("`loopover-mcp tools --json` reports the same 90-tool count the live server registers", async () => {
100101
const { tools } = await client.listTools();
101102
const payload = JSON.parse(run(["tools", "--json"])) as {
102103
count: number;
103104
tools: Array<{ name: string }>;
104105
};
105106
expect(payload.count).toBe(tools.length);
106-
expect(payload.count).toBe(89);
107+
expect(payload.count).toBe(90);
107108
expect([...payload.tools.map((t) => t.name)].sort()).toEqual(
108109
[...tools.map((t) => t.name)].sort(),
109110
);

0 commit comments

Comments
 (0)