Skip to content

Commit 6cb0bed

Browse files
committed
feat(mcp): register loopover_watch_issues as a local stdio tool
loopover_watch_issues has a remote MCP tool (src/mcp/server.ts) and a `watch` CLI command, but no local stdio MCP tool registration. #6746 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. Extracts the /v1/contributors/:login/watches dispatch (list=GET, watch=POST, unwatch=DELETE) into a shared watchIssuesRequest helper reused by BOTH the `watch` CLI and the new stdio tool -- no duplicated HTTP logic. login resolves from arg / active session / LOOPOVER_LOGIN like the CLI; action defaults to list. test/unit/mcp-cli-watch-issues.test.ts drives it in-process (#7764 entrypoint guard) so the registration + helper get real Codecov coverage, including all three actions, the with/without-labels POST bodies, and both throw branches. Existing mcp-cli-watch.test.ts still passes against the refactored CLI. Count 89 -> 90. Closes #7763
1 parent fa3581d commit 6cb0bed

3 files changed

Lines changed: 216 additions & 10 deletions

File tree

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

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -462,6 +462,16 @@ const markNotificationsReadShape = {
462462
ids: z.array(z.string().min(1)).optional(),
463463
};
464464

465+
// #7763: stdio mirror of the remote loopover_watch_issues shape (src/mcp/server.ts). login is optional here,
466+
// resolved from `login` / the active session / LOOPOVER_LOGIN like the `watch` CLI; action defaults to `list`,
467+
// and watch/unwatch need repoFullName. labels filter which issues a watch surfaces.
468+
const watchIssuesShape = {
469+
login: z.string().min(1).optional(),
470+
action: z.enum(["watch", "unwatch", "list"]).default("list"),
471+
repoFullName: z.string().min(3).max(200).optional(),
472+
labels: z.array(z.string().min(1).max(100)).max(50).optional(),
473+
};
474+
465475
const loginRepoShape = {
466476
login: z.string().min(1),
467477
owner: z.string().min(1),
@@ -1255,6 +1265,12 @@ const STDIO_TOOL_DESCRIPTORS = [
12551265
description:
12561266
"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.",
12571267
},
1268+
{
1269+
name: "loopover_watch_issues",
1270+
category: "utility",
1271+
description:
1272+
"Watch repos for NEW grabbable, high-multiplier issues (maintainer-created, not WIP). action=watch subscribes a repo (optional label filter), unwatch removes it, list (default) returns your watches. When a matching issue opens you're notified via loopover_list_notifications. Self-scoped to the authenticated login.",
1273+
},
12581274
{
12591275
name: "loopover_compare_pr_variants",
12601276
category: "branch",
@@ -2348,6 +2364,23 @@ registerStdioTool(
23482364
},
23492365
);
23502366

2367+
// #7763: stdio mirror of the remote loopover_watch_issues + the `watch` CLI. Reuses the shared
2368+
// watchIssuesRequest helper (same /v1/contributors/:login/watches routes the CLI calls); login resolves the
2369+
// same way (arg / active session / LOOPOVER_LOGIN), action defaults to list, watch/unwatch need repoFullName.
2370+
registerStdioTool(
2371+
"loopover_watch_issues",
2372+
{
2373+
description: stdioToolDescription("loopover_watch_issues"),
2374+
inputSchema: watchIssuesShape,
2375+
},
2376+
async ({ login, action, repoFullName, labels }: any) => {
2377+
const contributorLogin = login ?? activeProfile.session?.login ?? process.env.LOOPOVER_LOGIN ?? process.env.GITHUB_LOGIN;
2378+
if (!contributorLogin) throw new Error("No GitHub login: pass `login`, log in with `loopover-mcp login`, or set LOOPOVER_LOGIN.");
2379+
if ((action === "watch" || action === "unwatch") && !repoFullName) throw new Error(`action "${action}" requires repoFullName.`);
2380+
return toolResult(`Issue-watch subscriptions for ${contributorLogin}.`, await watchIssuesRequest(contributorLogin, action, repoFullName, labels));
2381+
},
2382+
);
2383+
23512384
registerStdioTool(
23522385
"loopover_compare_pr_variants",
23532386
{
@@ -4368,16 +4401,27 @@ async function notificationsCli(options: any) {
43684401
}
43694402
}
43704403

4404+
// #7763: shared REST dispatch for a contributor's issue-watch subscriptions, reused by the `watch` CLI and the
4405+
// loopover_watch_issues stdio tool so there is no duplicated HTTP logic. action maps list=GET, watch=POST,
4406+
// unwatch=DELETE on the /v1/contributors/:login/watches route family (the same routes the CLI already hit).
4407+
function watchIssuesRequest(login: any, action: any, repoFullName?: any, labels?: any) {
4408+
const base = `/v1/contributors/${encodeURIComponent(login)}/watches`;
4409+
if (action === "watch") return apiPost(base, { repoFullName, ...(labels && labels.length > 0 ? { labels } : {}) });
4410+
if (action === "unwatch") return apiDelete(base, { repoFullName });
4411+
return apiGet(base);
4412+
}
4413+
43714414
// #6746: contributor-scoped mirror of the loopover_watch_issues MCP tool and the /v1/contributors/{login}/watches
43724415
// route family. The MCP tool's action enum maps to subcommands here: list=GET, add=POST, remove=DELETE.
4373-
async function watchCli(args: any) {
4416+
// Exported (like maintainCli, #7764) so an in-process test can cover the shared watchIssuesRequest call sites
4417+
// that a subprocess spawn can't instrument (#7763).
4418+
export async function watchCli(args: any) {
43744419
const subcommand = args[0];
43754420
if (!subcommand || subcommand === "--help" || subcommand === "help") return printWatchHelp();
43764421
const positional = args[1] && !args[1].startsWith("--") ? args[1] : undefined;
43774422
const options = parseOptions(args.slice(1));
43784423
const login = options.login ?? activeProfile.session?.login ?? process.env.LOOPOVER_LOGIN ?? process.env.GITHUB_LOGIN;
43794424
if (!login) throw new Error("Pass --login <github-login>, log in with `loopover-mcp login`, or set LOOPOVER_LOGIN.");
4380-
const base = `/v1/contributors/${encodeURIComponent(login)}/watches`;
43814425
// The API chooses `changed` / repo / label text, so the plain-text path is sanitized (#6261); `login` is the
43824426
// user's own value.
43834427
const render = (payload: any) =>
@@ -4394,7 +4438,7 @@ async function watchCli(args: any) {
43944438
};
43954439

43964440
if (subcommand === "list") {
4397-
emit(await apiGet(base));
4441+
emit(await watchIssuesRequest(login, "list"));
43984442
return;
43994443
}
44004444
if (subcommand === "add" || subcommand === "remove") {
@@ -4404,9 +4448,9 @@ async function watchCli(args: any) {
44044448
if (subcommand === "add") {
44054449
const labels =
44064450
typeof options.labels === "string" ? options.labels.split(",").map((label: any) => label.trim()).filter(Boolean) : [];
4407-
emit(await apiPost(base, { repoFullName: positional, ...(labels.length > 0 ? { labels } : {}) }));
4451+
emit(await watchIssuesRequest(login, "watch", positional, labels));
44084452
} else {
4409-
emit(await apiDelete(base, { repoFullName: positional }));
4453+
emit(await watchIssuesRequest(login, "unwatch", positional));
44104454
}
44114455
return;
44124456
}
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
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, vi } from "vitest";
7+
import { closeFixtureServer, startFixtureServer } from "./support/mcp-cli-harness";
8+
9+
// #7763: in-process coverage for the loopover_watch_issues stdio tool. Same #7764 entrypoint-guard pattern as
10+
// mcp-cli-repo-focus-manifest -- import the .ts, hold the exported `server`, connect an InMemoryTransport so
11+
// v8/Codecov attributes the registerStdioTool block + the shared watchIssuesRequest helper (a subprocess spawn
12+
// can't be instrumented). Drives all three actions (list=GET, watch=POST, unwatch=DELETE) 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+
watchCli: (args: string[]) => Promise<void>;
18+
};
19+
20+
async function captureStdout(fn: () => Promise<void>): Promise<string> {
21+
const chunks: string[] = [];
22+
const spy = vi.spyOn(process.stdout, "write").mockImplementation((chunk: string | Uint8Array): boolean => {
23+
chunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"));
24+
return true;
25+
});
26+
try {
27+
await fn();
28+
} finally {
29+
spy.mockRestore();
30+
}
31+
return chunks.join("");
32+
}
33+
34+
let tempDir = "";
35+
const watchGets: Array<{ method: string; url: string }> = [];
36+
const watchWrites: Array<{ method: string; body: { repoFullName?: string; labels?: string[] } }> = [];
37+
const loaded = new Map<string, BinModule>();
38+
39+
beforeAll(async () => {
40+
tempDir = mkdtempSync(join(tmpdir(), "loopover-watch-issues-"));
41+
const apiUrl = await startFixtureServer({
42+
onApiRequest: (r) => {
43+
if (r.method === "GET" && r.url && r.url.includes("/watches")) watchGets.push({ method: r.method ?? "", url: r.url ?? "" });
44+
},
45+
onWatchRequest: (req) => watchWrites.push(req),
46+
});
47+
process.env.LOOPOVER_API_URL = apiUrl;
48+
process.env.LOOPOVER_API_TOKEN = "in-process-token";
49+
process.env.LOOPOVER_API_TIMEOUT_MS = "2000";
50+
process.env.LOOPOVER_CONFIG_DIR = tempDir;
51+
process.env.LOOPOVER_SKIP_NPM_VERSION_CHECK = "1";
52+
for (const specifier of MODULES) {
53+
loaded.set(specifier, (await import(specifier)) as unknown as BinModule);
54+
}
55+
}, 120_000);
56+
57+
afterAll(async () => {
58+
await closeFixtureServer();
59+
if (tempDir) rmSync(tempDir, { recursive: true, force: true });
60+
delete process.env.LOOPOVER_API_URL;
61+
delete process.env.LOOPOVER_API_TOKEN;
62+
delete process.env.LOOPOVER_CONFIG_DIR;
63+
delete process.env.LOOPOVER_SKIP_NPM_VERSION_CHECK;
64+
});
65+
66+
async function connectClient(specifier: (typeof MODULES)[number], name: string) {
67+
const mod = loaded.get(specifier)!;
68+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
69+
await mod.server.connect(serverTransport);
70+
const client = new Client({ name, version: "0.1.0" }, { capabilities: {} });
71+
await client.connect(clientTransport);
72+
return client;
73+
}
74+
75+
describe("bin loopover_watch_issues stdio tool (in-process, #7763)", () => {
76+
it.each(MODULES)("proxies list=GET, watch=POST (with/without labels), unwatch=DELETE — %s", async (specifier) => {
77+
watchGets.length = 0;
78+
watchWrites.length = 0;
79+
const client = await connectClient(specifier, "watch-issues-test");
80+
try {
81+
const tool = (await client.listTools()).tools.find((entry) => entry.name === "loopover_watch_issues");
82+
expect(tool).toBeDefined();
83+
expect(tool?.description).toMatch(/watch repos|grabbable/i);
84+
85+
const list = await client.callTool({ name: "loopover_watch_issues", arguments: { login: "octocat", action: "list" } });
86+
expect(list.isError).toBeFalsy();
87+
expect(watchGets.at(-1)).toEqual({ method: "GET", url: "/v1/contributors/octocat/watches" });
88+
expect(JSON.stringify(list)).toContain("watching");
89+
90+
const watch = await client.callTool({
91+
name: "loopover_watch_issues",
92+
arguments: { login: "octocat", action: "watch", repoFullName: "acme/widgets", labels: ["bug"] },
93+
});
94+
expect(watch.isError).toBeFalsy();
95+
expect(watchWrites.at(-1)).toEqual({ method: "POST", body: { repoFullName: "acme/widgets", labels: ["bug"] } });
96+
97+
// No labels -> the shared helper omits the labels key entirely.
98+
await client.callTool({ name: "loopover_watch_issues", arguments: { login: "octocat", action: "watch", repoFullName: "acme/gadgets" } });
99+
expect(watchWrites.at(-1)).toEqual({ method: "POST", body: { repoFullName: "acme/gadgets" } });
100+
101+
const unwatch = await client.callTool({
102+
name: "loopover_watch_issues",
103+
arguments: { login: "octocat", action: "unwatch", repoFullName: "acme/widgets" },
104+
});
105+
expect(unwatch.isError).toBeFalsy();
106+
expect(watchWrites.at(-1)).toEqual({ method: "DELETE", body: { repoFullName: "acme/widgets" } });
107+
} finally {
108+
await client.close().catch(() => undefined);
109+
}
110+
});
111+
112+
it.each(MODULES)("errors (no request) when watch/unwatch is missing repoFullName — %s", async (specifier) => {
113+
watchWrites.length = 0;
114+
const client = await connectClient(specifier, "watch-issues-guard");
115+
try {
116+
const result = await client.callTool({ name: "loopover_watch_issues", arguments: { login: "octocat", action: "watch" } });
117+
expect(result.isError).toBe(true);
118+
expect(JSON.stringify(result.content)).toMatch(/requires repoFullName/i);
119+
expect(watchWrites).toEqual([]);
120+
} finally {
121+
await client.close().catch(() => undefined);
122+
}
123+
});
124+
125+
it.each(MODULES)("errors when no login can be resolved from arg/session/env — %s", async (specifier) => {
126+
const savedLogin = process.env.LOOPOVER_LOGIN;
127+
const savedGh = process.env.GITHUB_LOGIN;
128+
delete process.env.LOOPOVER_LOGIN;
129+
delete process.env.GITHUB_LOGIN;
130+
const client = await connectClient(specifier, "watch-issues-nologin");
131+
try {
132+
const result = await client.callTool({ name: "loopover_watch_issues", arguments: { action: "list" } });
133+
expect(result.isError).toBe(true);
134+
expect(JSON.stringify(result.content)).toMatch(/No GitHub login|LOOPOVER_LOGIN/i);
135+
} finally {
136+
await client.close().catch(() => undefined);
137+
if (savedLogin !== undefined) process.env.LOOPOVER_LOGIN = savedLogin;
138+
if (savedGh !== undefined) process.env.GITHUB_LOGIN = savedGh;
139+
}
140+
});
141+
});
142+
143+
// The `watch` CLI now routes through the same watchIssuesRequest helper. Drive it in-process (a subprocess
144+
// spawn -- mcp-cli-watch.test.ts -- can't be v8-instrumented) so those shared call sites get real coverage.
145+
describe("bin watch CLI reuses watchIssuesRequest (in-process, #7763)", () => {
146+
it.each(MODULES)("list=GET, add=POST {repoFullName,labels}, remove=DELETE via the shared helper — %s", async (specifier) => {
147+
watchGets.length = 0;
148+
watchWrites.length = 0;
149+
const mod = loaded.get(specifier)!;
150+
151+
const listOut = await captureStdout(() => mod.watchCli(["list", "--login", "octocat"]));
152+
expect(listOut).toMatch(/Watching \d+ repo\(s\) for octocat/);
153+
expect(watchGets.at(-1)).toEqual({ method: "GET", url: "/v1/contributors/octocat/watches" });
154+
155+
await captureStdout(() => mod.watchCli(["add", "acme/widgets", "--labels", "bug,feature", "--login", "octocat"]));
156+
expect(watchWrites.at(-1)).toEqual({ method: "POST", body: { repoFullName: "acme/widgets", labels: ["bug", "feature"] } });
157+
158+
await captureStdout(() => mod.watchCli(["remove", "acme/widgets", "--login", "octocat"]));
159+
expect(watchWrites.at(-1)).toEqual({ method: "DELETE", body: { repoFullName: "acme/widgets" } });
160+
});
161+
});

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

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
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.)
3434
// (#7762 registered the loopover_mark_notifications_read stdio tool, taking the count from 89 to 90.)
35+
// (#7763 registered the loopover_watch_issues stdio tool, taking the count from 90 to 91.)
3536
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3637
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
3738
import { mkdtempSync, rmSync } from "node:fs";
@@ -78,14 +79,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
7879
});
7980
afterEach(disconnect);
8081

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

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

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

0 commit comments

Comments
 (0)