Skip to content

Commit 2525459

Browse files
feat(mcp): register loopover_list_notifications as a local stdio tool
Closes #7761 loopover_list_notifications was already registered as a remote MCP tool (src/mcp/server.ts) with a CLI mirror (`notifications`, #6745), but had no local stdio MCP tool registration -- the same gap class PR #6382 fixed for the 5 maintain-surface tools. Mirrors that pattern exactly: a registerStdioTool block using stdioToolDescription for the centralized description and toolResult for the response shape, placed alongside the other loginShape-based contributor tools (loopover_get_decision_pack / loopover_explain_repo_decision / loopover_monitor_open_prs / loopover_pr_outcome). The handler reuses getNotifications(login), the exact apiGet call the existing `notifications` CLI command already makes, so there is one HTTP call site for this route, not two. Adds a stdio-proxy test to test/unit/mcp-cli-notifications.test.ts following the same StdioClientTransport + fixture-server shape as the sibling loginShape tools' own suites (mcp-cli-pr-outcomes.test.ts, mcp-cli-monitor-open-prs.test.ts): registration, the one apiGet call, and tool/CLI mirror parity. Bumps the pinned stdio tool count in mcp-tool-rename-aliases.test.ts from 79 to 80.
1 parent e7e10e7 commit 2525459

3 files changed

Lines changed: 108 additions & 5 deletions

File tree

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1158,6 +1158,16 @@ const STDIO_TOOL_DESCRIPTORS = [
11581158
description:
11591159
"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.",
11601160
},
1161+
// #7761: local stdio counterpart of the remote loopover_list_notifications tool (src/mcp/server.ts) and the
1162+
// existing `notifications` CLI mirror (#6745) -- reuses getNotifications, the same apiGet call the CLI already
1163+
// makes, so there is exactly one HTTP call site for this route. Category matches the remote server's own
1164+
// MCP_TOOL_CATEGORIES entry for this tool name, same reasoning as the #6152 maintain-surface tools above.
1165+
{
1166+
name: "loopover_list_notifications",
1167+
category: "utility",
1168+
description:
1169+
"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.",
1170+
},
11611171
{
11621172
name: "loopover_compare_pr_variants",
11631173
category: "branch",
@@ -2131,6 +2141,20 @@ registerStdioTool(
21312141
},
21322142
);
21332143

2144+
// #7761: reuses getNotifications(login) -- the exact same apiGet call notificationsCli already makes -- so
2145+
// the stdio tool and the CLI's `notifications` command share one HTTP call site, no duplicated REST logic.
2146+
registerStdioTool(
2147+
"loopover_list_notifications",
2148+
{
2149+
description: stdioToolDescription("loopover_list_notifications"),
2150+
inputSchema: loginShape,
2151+
},
2152+
async ({ login }: any) => {
2153+
const payload = await getNotifications(login);
2154+
return toolResult(`LoopOver notifications for ${login}: ${payload.unreadCount} unread.`, payload);
2155+
},
2156+
);
2157+
21342158
registerStdioTool(
21352159
"loopover_compare_pr_variants",
21362160
{

test/unit/mcp-cli-notifications.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,24 @@
33
// stdio/CLI surface was missing. These pin: `notifications --json` stays byte-identical to the route, the
44
// plain-text path lists the feed, `notifications-read` forwards --id (or marks all), and login resolution matches
55
// the sibling contributor commands.
6+
//
7+
// #7761: the CLI mirror above already existed, but loopover_list_notifications itself was never registered as a
8+
// local STDIO tool (an agent on the stdio server had to shell out to the CLI to reach it). The first describe
9+
// block below pins that proxy, same shape as the sibling loginShape tools' own stdio-proxy suites
10+
// (mcp-cli-pr-outcomes.test.ts, mcp-cli-monitor-open-prs.test.ts): registration, the one apiGet call, and
11+
// tool/CLI mirror parity for the same login.
12+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
13+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
14+
import { mkdtempSync, rmSync } from "node:fs";
15+
import { tmpdir } from "node:os";
16+
import { join } from "node:path";
617
import { afterEach, beforeEach, describe, expect, it } from "vitest";
718
// Any CLI command that calls the API must go through runAsync: the fixture server lives in this process,
819
// so run()'s execFileSync would block the event loop and the child's fetch would abort before a response.
920
import { closeFixtureServer, notificationsFixture, notificationsReadFixture, run, runAsync, runExpectingFailure, startFixtureServer } from "./support/mcp-cli-harness";
1021

22+
const bin = join(process.cwd(), "packages/loopover-mcp/bin/loopover-mcp.js");
23+
1124
let apiUrl: string;
1225
let markReadBodies: unknown[];
1326

@@ -20,6 +33,71 @@ async function disconnect() {
2033
await closeFixtureServer();
2134
}
2235

36+
describe("loopover_list_notifications stdio proxy (#7761)", () => {
37+
let client: Client;
38+
let transport: StdioClientTransport;
39+
let configDir: string;
40+
let capturedRequests: Array<{ url: string; method: string }>;
41+
42+
beforeEach(async () => {
43+
configDir = mkdtempSync(join(tmpdir(), "loopover-list-notifications-"));
44+
capturedRequests = [];
45+
apiUrl = await startFixtureServer({
46+
onApiRequest: (request) => {
47+
if (request.url && request.url.includes("/notifications") && !request.url.includes("/notifications/read")) {
48+
capturedRequests.push({ url: request.url ?? "", method: request.method ?? "GET" });
49+
}
50+
},
51+
});
52+
transport = new StdioClientTransport({
53+
command: "node",
54+
args: [bin, "--stdio"],
55+
env: {
56+
...process.env,
57+
LOOPOVER_CONFIG_DIR: configDir,
58+
LOOPOVER_API_URL: apiUrl,
59+
LOOPOVER_TOKEN: "session-token",
60+
LOOPOVER_API_TIMEOUT_MS: "5000",
61+
},
62+
});
63+
client = new Client({ name: "list-notifications-test", version: "0.0.1" });
64+
await client.connect(transport);
65+
});
66+
67+
afterEach(async () => {
68+
await client.close().catch(() => undefined);
69+
await closeFixtureServer();
70+
if (configDir) rmSync(configDir, { recursive: true, force: true });
71+
});
72+
73+
it("registers the tool in the stdio server tool list", async () => {
74+
const { tools } = await client.listTools();
75+
expect(tools.map((t) => t.name)).toContain("loopover_list_notifications");
76+
});
77+
78+
it("proxies login to GET /v1/contributors/:login/notifications via the same apiGet the CLI uses", async () => {
79+
const result = await client.callTool({ name: "loopover_list_notifications", arguments: { login: "JSONbored" } });
80+
expect(capturedRequests.length).toBe(1);
81+
const captured = capturedRequests[0]!;
82+
expect(captured.url).toContain("/v1/contributors/JSONbored/notifications");
83+
expect(captured.method).toBe("GET");
84+
expect(result.isError).toBeFalsy();
85+
const text = JSON.stringify(result);
86+
expect(text).toContain("1 unread");
87+
expect(text).toContain("JSONbored/loopover#42");
88+
});
89+
90+
it("--json emits exactly the payload the MCP tool surfaces for the same login (mirror parity)", async () => {
91+
const viaTool = await client.callTool({ name: "loopover_list_notifications", arguments: { login: "JSONbored" } });
92+
const toolData = (viaTool as { structuredContent?: unknown }).structuredContent;
93+
const viaCli = JSON.parse(
94+
await runAsync(["notifications", "--login", "JSONbored", "--json"], { LOOPOVER_API_URL: apiUrl, LOOPOVER_TOKEN: "session-token" }),
95+
);
96+
expect(viaCli).toEqual(notificationsFixture());
97+
if (toolData !== undefined) expect(viaCli).toEqual(toolData);
98+
});
99+
});
100+
23101
describe("loopover-mcp notifications CLI", () => {
24102
beforeEach(connect);
25103
afterEach(disconnect);

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

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
// (#6747 registered the loopover_pr_outcome CLI mirror, taking the count from 77 to 78.)
2323
// (#6980 registered the loopover_explain_review_risk CLI mirror, taking the count from 78 to 79.)
2424
// (#7758 registered the loopover_get_outcome_calibration stdio tool, taking the count from 79 to 80.)
25+
// (#7761 registered the loopover_list_notifications stdio tool, taking the count from 80 to 81.)
2526
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2627
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
2728
import { mkdtempSync, rmSync } from "node:fs";
@@ -69,14 +70,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
6970
});
7071
afterEach(disconnect);
7172

72-
it("lists exactly 80 loopover_ tools and zero gittensory_-prefixed aliases", async () => {
73+
it("lists exactly 81 loopover_ tools and zero gittensory_-prefixed aliases", async () => {
7374
const { tools } = await client.listTools();
7475
const names = tools.map((t) => t.name);
7576
const primary = names.filter((n) => n.startsWith("loopover_"));
7677
const legacy = names.filter((n) => n.startsWith("gittensory_"));
77-
expect(primary.length).toBe(80);
78+
expect(primary.length).toBe(81);
7879
expect(legacy.length).toBe(0);
79-
expect(names.length).toBe(80);
80+
expect(names.length).toBe(81);
8081
});
8182

8283
it("no loopover_ tool's description carries a stale deprecation notice", async () => {
@@ -88,14 +89,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
8889
}
8990
});
9091

91-
it("`loopover-mcp tools --json` reports the same 80-tool count the live server registers", async () => {
92+
it("`loopover-mcp tools --json` reports the same 81-tool count the live server registers", async () => {
9293
const { tools } = await client.listTools();
9394
const payload = JSON.parse(run(["tools", "--json"])) as {
9495
count: number;
9596
tools: Array<{ name: string }>;
9697
};
9798
expect(payload.count).toBe(tools.length);
98-
expect(payload.count).toBe(80);
99+
expect(payload.count).toBe(81);
99100
expect([...payload.tools.map((t) => t.name)].sort()).toEqual(
100101
[...tools.map((t) => t.name)].sort(),
101102
);

0 commit comments

Comments
 (0)