Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions packages/loopover-mcp/bin/loopover-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,16 @@ const prAiReviewFindingsShape = {
login: z.string().min(1).optional(),
};

// #7763: the remote loopover_watch_issues tool's shape (src/mcp/server.ts's watchIssuesShape), with `login`
// made optional -- same login-resolution convention as prAiReviewFindingsShape above and the `watch` CLI
// command (watchCli), since a local stdio operator is already authenticated.
const watchIssuesShape = {
login: z.string().min(1).optional(),
action: z.enum(["watch", "unwatch", "list"]).default("list"),
repoFullName: z.string().min(3).max(200).optional(),
labels: z.array(z.string().min(1).max(100)).max(50).optional(),
};

// #6149 write-tool input shapes -- mirror src/mcp/server.ts's remote shapes (same bounds) so the local
// server validates identically. The builders (buildOpenPrSpec, ...) are the same @loopover/engine functions.
const WRITE_TOOL_REPO_FULL_NAME_MAX = 200;
Expand Down Expand Up @@ -1302,6 +1312,12 @@ const STDIO_TOOL_DESCRIPTORS = [
category: "maintainer",
description: "Return per-gate-type false-positive precision for a repo's recorded gate blocks — blocked / blocked-then-merged counts and false-positive rates with low-sample guards. Optionally bounded by windowDays. Maintainer-authenticated; measurement only.",
},
{
name: "loopover_watch_issues",
category: "utility",
description:
"Watch repos for NEW grabbable, high-multiplier issues (maintainer-created, not WIP) — mirrors the `watch` CLI command and the remote loopover_watch_issues tool. action=watch subscribes a repo (optional label filter), unwatch removes it, list (default) returns your current watches. Self-scoped; login resolves from the argument, the active session, LOOPOVER_LOGIN, then GITHUB_LOGIN.",
},
{
name: "loopover_open_pr",
category: "agent",
Expand Down Expand Up @@ -2623,6 +2639,38 @@ registerStdioTool(
return toolResult(`Gate precision for ${owner}/${repo}.`, payload);
},
);

// #7763: loopover_watch_issues was already a remote MCP tool (src/mcp/server.ts) and had a CLI mirror
// (`watch`, watchCli below, #6746), but never got the matching local stdio registration -- the same gap
// class the five maintain-surface tools above filled for #6152/#6382. Calls the exact
// /v1/contributors/:login/watches endpoint watchCli already calls, through the same apiGet/apiPost/
// apiDelete client (list=GET, watch=POST, unwatch=DELETE) -- no duplicated HTTP logic.
registerStdioTool(
"loopover_watch_issues",
{
description: stdioToolDescription("loopover_watch_issues"),
inputSchema: watchIssuesShape,
},
async ({ login, action, repoFullName, labels }: 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.");
const base = `/v1/contributors/${encodeURIComponent(contributorLogin)}/watches`;
let payload: any;
if (action === "watch" || action === "unwatch") {
if (!repoFullName) throw new Error(`${action} requires repoFullName.`);
payload =
action === "watch"
? await apiPost(base, { repoFullName, ...(labels && labels.length > 0 ? { labels } : {}) })
: await apiDelete(base, { repoFullName });
} else {
payload = await apiGet(base);
}
return toolResult(
`Watching ${(payload.watching ?? []).length} repo(s) for ${contributorLogin}${payload.changed ? ` (${payload.changed})` : ""}.`,
payload,
);
},
);
// ── Write-tools (#6149): pure LOCAL-execution spec builders. loopover NEVER performs the write -- each tool
// returns a spec the caller runs with its OWN gh creds. Brings the local stdio server to parity with the
// miner-auto-dev profile's recommendedTools, using the same @loopover/engine builders as the remote server.
Expand Down
161 changes: 161 additions & 0 deletions test/unit/mcp-cli-watch-issues-tool.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { closeFixtureServer, startFixtureServer } from "./support/mcp-cli-harness";

// #7763: the local stdio mirror of the remote loopover_watch_issues tool and the `watch` CLI command
// (test/unit/mcp-cli-watch.test.ts, #6746). The tool only resolves the login and proxies to
// /v1/contributors/:login/watches (list=GET, watch=POST, unwatch=DELETE) -- the route stays the single
// source of truth, so these tests assert the request the tool composes and its login-resolution fallback
// chain, not the watch-subscription logic itself.
const bin = join(process.cwd(), "packages/loopover-mcp/bin/loopover-mcp.js");

let client: Client;
let transport: StdioClientTransport;
let configDir: string;
// GET (list) never reaches onWatchRequest (the fixture short-circuits before it, mirroring
// mcp-cli-watch.test.ts's own list test), so requests are tracked at the raw HTTP level here instead.
let apiRequests: string[];
let capturedRequests: Array<{ method: string; body: { repoFullName?: string; labels?: string[] } }>;

/** Connect a stdio client with `env` overlaid, so each test drives its own login-resolution scenario. */
async function connect(env: Record<string, string> = {}) {
configDir = mkdtempSync(join(tmpdir(), "loopover-watch-issues-tool-"));
apiRequests = [];
capturedRequests = [];
const apiUrl = await startFixtureServer({
onApiRequest: (request) => apiRequests.push(`${request.method} ${request.url}`),
onWatchRequest: (request) => capturedRequests.push(request),
});
const childEnv: Record<string, string> = {};
for (const [key, value] of Object.entries(process.env)) if (value !== undefined) childEnv[key] = value;
// Dropped unless a test opts back in, so the RUNNER's own env can't satisfy the login fallback by accident.
delete childEnv.LOOPOVER_LOGIN;
delete childEnv.GITHUB_LOGIN;
transport = new StdioClientTransport({
command: "node",
args: [bin, "--stdio"],
env: {
...childEnv,
LOOPOVER_CONFIG_DIR: configDir,
LOOPOVER_API_URL: apiUrl,
LOOPOVER_TOKEN: "session-token",
LOOPOVER_API_TIMEOUT_MS: "5000",
...env,
},
});
client = new Client({ name: "watch-issues-tool-test", version: "0.0.1" });
await client.connect(transport);
}

afterEach(async () => {
await client?.close().catch(() => undefined);
await closeFixtureServer();
if (configDir) rmSync(configDir, { recursive: true, force: true });
});

describe("loopover_watch_issues stdio mirror (#7763)", () => {
it("registers the tool in the stdio server tool list with a non-empty description", async () => {
await connect({ LOOPOVER_LOGIN: "octocat" });
const { tools } = await client.listTools();
const tool = tools.find((entry) => entry.name === "loopover_watch_issues");
expect(tool, "loopover_watch_issues is not registered").toBeTruthy();
expect(tool!.description?.trim().length).toBeGreaterThan(0);
});

it("defaults action to list and GETs the watches, reporting the count", async () => {
await connect({ LOOPOVER_LOGIN: "octocat" });
const result = await client.callTool({ name: "loopover_watch_issues", arguments: {} });
expect(result.isError).toBeFalsy();
expect(apiRequests).toEqual(["GET /v1/contributors/octocat/watches"]);
expect(capturedRequests).toEqual([]);
const data = result.structuredContent as { watching: Array<{ repoFullName: string }> };
expect(data.watching).toHaveLength(2);
expect(JSON.stringify(result.content)).toContain("Watching 2 repo(s) for octocat");
});

it("watch POSTs {repoFullName,labels} and reports the change", async () => {
await connect();
const result = await client.callTool({
name: "loopover_watch_issues",
arguments: { login: "octocat", action: "watch", repoFullName: "acme/widgets", labels: ["bug", "feature"] },
});
expect(result.isError).toBeFalsy();
expect(capturedRequests).toEqual([{ method: "POST", body: { repoFullName: "acme/widgets", labels: ["bug", "feature"] } }]);
expect(JSON.stringify(result.content)).toContain("watching acme/widgets (labels: bug, feature)");
});

it("watch without labels sends no labels field", async () => {
await connect();
await client.callTool({ name: "loopover_watch_issues", arguments: { login: "octocat", action: "watch", repoFullName: "acme/widgets" } });
expect(capturedRequests[0]!.body).toEqual({ repoFullName: "acme/widgets" });
});

it("unwatch DELETEs and reports it was unwatched", async () => {
await connect();
const result = await client.callTool({
name: "loopover_watch_issues",
arguments: { login: "octocat", action: "unwatch", repoFullName: "acme/widgets" },
});
expect(result.isError).toBeFalsy();
expect(capturedRequests).toEqual([{ method: "DELETE", body: { repoFullName: "acme/widgets" } }]);
expect(JSON.stringify(result.content)).toContain("unwatched acme/widgets");
});

it("falls back to LOOPOVER_LOGIN when no login argument is given", async () => {
await connect({ LOOPOVER_LOGIN: "env-login" });
const result = await client.callTool({ name: "loopover_watch_issues", arguments: {} });
expect(result.isError).toBeFalsy();
expect(JSON.stringify(result.content)).toContain("for env-login");
});

it("falls back to GITHUB_LOGIN when LOOPOVER_LOGIN is unset", async () => {
await connect({ GITHUB_LOGIN: "gh-login" });
const result = await client.callTool({ name: "loopover_watch_issues", arguments: {} });
expect(result.isError).toBeFalsy();
expect(JSON.stringify(result.content)).toContain("for gh-login");
});

it("prefers an explicit login argument over the environment fallbacks", async () => {
await connect({ LOOPOVER_LOGIN: "env-login", GITHUB_LOGIN: "gh-login" });
const result = await client.callTool({ name: "loopover_watch_issues", arguments: { login: "explicit" } });
expect(JSON.stringify(result.content)).toContain("for explicit");
});

it("errors with actionable guidance -- and never calls the API -- when no login resolves anywhere", async () => {
await connect();
const outcome = await client
.callTool({ name: "loopover_watch_issues", arguments: {} })
.then((r) => ({ isError: Boolean(r.isError), text: JSON.stringify(r) }), (e: unknown) => ({ isError: true, text: String(e) }));
expect(outcome.isError).toBe(true);
expect(outcome.text).toMatch(/LOOPOVER_LOGIN|loopover-mcp login/);
expect(capturedRequests).toHaveLength(0);
});

it("errors when watch/unwatch is missing repoFullName, without calling the API", async () => {
await connect({ LOOPOVER_LOGIN: "octocat" });
for (const action of ["watch", "unwatch"] as const) {
const outcome = await client
.callTool({ name: "loopover_watch_issues", arguments: { action } })
.then((r) => ({ isError: Boolean(r.isError), text: JSON.stringify(r) }), (e: unknown) => ({ isError: true, text: String(e) }));
expect(outcome.isError).toBe(true);
expect(outcome.text).toMatch(new RegExp(`${action} requires repoFullName`));
}
expect(capturedRequests).toHaveLength(0);
});

it("rejects invalid input (zod): a blank login and an unknown action, without calling the API", async () => {
await connect({ LOOPOVER_LOGIN: "octocat" });
for (const args of [{ login: "" }, { action: "bogus" }]) {
const outcome = await client.callTool({ name: "loopover_watch_issues", arguments: args }).then(
(r) => Boolean(r.isError),
() => true,
);
expect(outcome, `${JSON.stringify(args)} should be rejected`).toBe(true);
}
expect(capturedRequests).toHaveLength(0);
});
});
11 changes: 6 additions & 5 deletions test/unit/mcp-tool-rename-aliases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
// (#6747 registered the loopover_pr_outcome CLI mirror, taking the count from 77 to 78.)
// (#6980 registered the loopover_explain_review_risk CLI mirror, taking the count from 78 to 79.)
// (#7758 registered the loopover_get_outcome_calibration stdio tool, taking the count from 79 to 80.)
// (#7763 registered the loopover_watch_issues stdio mirror, taking the count from 80 to 81.)
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { mkdtempSync, rmSync } from "node:fs";
Expand Down Expand Up @@ -69,14 +70,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
});
afterEach(disconnect);

it("lists exactly 80 loopover_ tools and zero gittensory_-prefixed aliases", async () => {
it("lists exactly 81 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(80);
expect(primary.length).toBe(81);
expect(legacy.length).toBe(0);
expect(names.length).toBe(80);
expect(names.length).toBe(81);
});

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

it("`loopover-mcp tools --json` reports the same 80-tool count the live server registers", async () => {
it("`loopover-mcp tools --json` reports the same 81-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(80);
expect(payload.count).toBe(81);
expect([...payload.tools.map((t) => t.name)].sort()).toEqual(
[...tools.map((t) => t.name)].sort(),
);
Expand Down