Skip to content

Commit 94fa841

Browse files
committed
feat(mcp): register loopover_generate_contributor_issue_drafts as a local stdio tool
loopover_generate_contributor_issue_drafts has a remote MCP tool (src/mcp/server.ts) and a `maintain generate-issue-drafts` CLI command, but no local stdio MCP tool registration. #6757 added the REST route + CLI but never the matching stdio tool. Adds the registerStdioTool block mirroring the sibling loopover_plan_repo_issues write-tool pattern -- proxies POST {repoBase}/contributor-issue-drafts/generate (the same route the CLI hits). Dry-run BY DEFAULT: the schema defaults dryRun=true/ create=false and the route re-applies its explicit_create_requires_dry_run_false guard, so `create` alone is rejected and only an explicit {create:true,dryRun:false} reaches the write path. Input mirrors the remote generateContributorIssueDraftsShape; description via stdioToolDescription; category "maintainer". test/unit/mcp-cli-generate-contributor-issue-drafts.test.ts drives it in-process (#7764 entrypoint guard) so the registration + handler get real Codecov coverage, asserting the dry-run-default and explicit-create forwarding. Count 97 -> 98. Closes #7755
1 parent 4dccaa6 commit 94fa841

3 files changed

Lines changed: 115 additions & 5 deletions

File tree

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1020,6 +1020,17 @@ const planRepoIssuesShape = {
10201020
limit: z.number().int().min(1).max(10).optional().default(5),
10211021
};
10221022

1023+
// #7755: mirrors the remote loopover_generate_contributor_issue_drafts input (src/mcp/server.ts's
1024+
// generateContributorIssueDraftsShape) -- dryRun/create carry the route's create-safety (create alone is
1025+
// rejected there); `limit` is capped at 20, matching the route.
1026+
const generateContributorIssueDraftsShape = {
1027+
owner: z.string().min(1),
1028+
repo: z.string().min(1),
1029+
dryRun: z.boolean().optional().default(true),
1030+
create: z.boolean().optional().default(false),
1031+
limit: z.number().int().min(1).max(20).optional().default(5),
1032+
};
1033+
10231034
// Single source of truth for stdio tool name + one-line description (#2233).
10241035
// Registration and `loopover-mcp tools` both read this list.
10251036
const STDIO_TOOL_DESCRIPTORS = [
@@ -1502,6 +1513,12 @@ const STDIO_TOOL_DESCRIPTORS = [
15021513
description:
15031514
"AI-plan a small set of concrete GitHub issue drafts for a repo from a maintainer-supplied free-form goal, same as `loopover-mcp maintain plan-issues --goal ...`. Dry-run BY DEFAULT: only previews the drafted title/body/labels unless the caller passes BOTH create:true and dryRun:false, so it can never silently open issues. Maintainer access required.",
15041515
},
1516+
{
1517+
name: "loopover_generate_contributor_issue_drafts",
1518+
category: "maintainer",
1519+
description:
1520+
"Generate contributor-facing issue drafts for one repo from its lane/config/queue signals. Dry-run BY DEFAULT: it only PREVIEWS drafts unless the caller passes BOTH create:true and dryRun:false, so it can never silently open issues; the write path additionally requires repo write access and is suppressed while the agent is globally paused/frozen. Maintainer access required.",
1521+
},
15051522
{
15061523
name: "loopover_open_pr",
15071524
category: "agent",
@@ -3066,6 +3083,23 @@ registerStdioTool(
30663083
);
30673084
},
30683085
);
3086+
3087+
// #7755: stdio mirror of the remote loopover_generate_contributor_issue_drafts + the `maintain
3088+
// generate-issue-drafts` CLI. Proxies POST {repoBase}/contributor-issue-drafts/generate (the same route the
3089+
// CLI hits). The route re-applies its own explicit_create_requires_dry_run_false guard, so forwarding the
3090+
// schema-defaulted dryRun/create verbatim keeps create-safety exact: `create` alone (dryRun still true) is
3091+
// rejected; only an explicit {create:true, dryRun:false} reaches the write path.
3092+
registerStdioTool(
3093+
"loopover_generate_contributor_issue_drafts",
3094+
{
3095+
description: stdioToolDescription("loopover_generate_contributor_issue_drafts"),
3096+
inputSchema: generateContributorIssueDraftsShape,
3097+
},
3098+
async ({ owner, repo, dryRun, create, limit }: any) => {
3099+
const payload = await apiPost(`${toolRepoBase(owner, repo)}/contributor-issue-drafts/generate`, { dryRun, create, limit });
3100+
return toolResult(`Contributor issue drafts for ${owner}/${repo}.`, payload);
3101+
},
3102+
);
30693103
// ── Write-tools (#6149): pure LOCAL-execution spec builders. loopover NEVER performs the write -- each tool
30703104
// returns a spec the caller runs with its OWN gh creds. Brings the local stdio server to parity with the
30713105
// miner-auto-dev profile's recommendedTools, using the same @loopover/engine builders as the remote server.
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
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+
// #7755: in-process coverage for the loopover_generate_contributor_issue_drafts stdio tool. Same #7764
10+
// entrypoint-guard pattern as mcp-cli-repo-focus-manifest -- import the .ts, hold the exported `server`,
11+
// connect an InMemoryTransport so v8/Codecov attributes the registerStdioTool block (a subprocess spawn can't
12+
// be instrumented). Verifies the create-safety forwarding: dry-run by default, explicit create only on request.
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 draftBodies: Array<{ dryRun?: boolean; create?: boolean; limit?: number }> = [];
21+
const loaded = new Map<string, BinModule>();
22+
23+
beforeAll(async () => {
24+
tempDir = mkdtempSync(join(tmpdir(), "loopover-generate-issue-drafts-"));
25+
const apiUrl = await startFixtureServer({ onIssueDraftRequest: (body) => draftBodies.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_generate_contributor_issue_drafts stdio tool (in-process, #7755)", () => {
46+
it.each(MODULES)("dry-runs by default and only writes on explicit create+dryRun=false — %s", async (specifier) => {
47+
draftBodies.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: "generate-issue-drafts-test", version: "0.1.0" }, { capabilities: {} });
52+
await client.connect(clientTransport);
53+
try {
54+
const tool = (await client.listTools()).tools.find((entry) => entry.name === "loopover_generate_contributor_issue_drafts");
55+
expect(tool).toBeDefined();
56+
expect(tool?.description).toMatch(/issue drafts|dry-run/i);
57+
58+
// Defaults: schema fills dryRun=true, create=false, limit=5 -> a safe preview.
59+
const preview = await client.callTool({ name: "loopover_generate_contributor_issue_drafts", arguments: { owner: "owner", repo: "repo" } });
60+
expect(preview.isError).toBeFalsy();
61+
expect(draftBodies.at(-1)).toEqual({ dryRun: true, create: false, limit: 5 });
62+
expect(JSON.stringify(preview)).toContain("Contributor issue drafts for owner/repo.");
63+
64+
// Explicit write: only {create:true, dryRun:false} reaches the write path.
65+
const write = await client.callTool({
66+
name: "loopover_generate_contributor_issue_drafts",
67+
arguments: { owner: "owner", repo: "repo", create: true, dryRun: false, limit: 3 },
68+
});
69+
expect(write.isError).toBeFalsy();
70+
expect(draftBodies.at(-1)).toEqual({ dryRun: false, create: true, limit: 3 });
71+
} finally {
72+
await client.close().catch(() => undefined);
73+
}
74+
});
75+
});

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

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
// (#7752 registered the loopover_get_automation_state stdio tool, taking the count from 94 to 95.)
4040
// (#7757 registered the loopover_get_agent_audit_feed stdio tool, taking the count from 95 to 96.)
4141
// (#7754 registered the loopover_refresh_repo_docs stdio tool, taking the count from 96 to 97.)
42+
// (#7755 registered the loopover_generate_contributor_issue_drafts stdio tool, taking the count from 97 to 98.)
4243
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4344
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
4445
import { mkdtempSync, rmSync } from "node:fs";
@@ -85,14 +86,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
8586
});
8687
afterEach(disconnect);
8788

88-
it("lists exactly 97 loopover_ tools and zero gittensory_-prefixed aliases", async () => {
89+
it("lists exactly 98 loopover_ tools and zero gittensory_-prefixed aliases", async () => {
8990
const { tools } = await client.listTools();
9091
const names = tools.map((t) => t.name);
9192
const primary = names.filter((n) => n.startsWith("loopover_"));
9293
const legacy = names.filter((n) => n.startsWith("gittensory_"));
93-
expect(primary.length).toBe(97);
94+
expect(primary.length).toBe(98);
9495
expect(legacy.length).toBe(0);
95-
expect(names.length).toBe(97);
96+
expect(names.length).toBe(98);
9697
});
9798

9899
it("no loopover_ tool's description carries a stale deprecation notice", async () => {
@@ -104,14 +105,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
104105
}
105106
});
106107

107-
it("`loopover-mcp tools --json` reports the same 97-tool count the live server registers", async () => {
108+
it("`loopover-mcp tools --json` reports the same 98-tool count the live server registers", async () => {
108109
const { tools } = await client.listTools();
109110
const payload = JSON.parse(run(["tools", "--json"])) as {
110111
count: number;
111112
tools: Array<{ name: string }>;
112113
};
113114
expect(payload.count).toBe(tools.length);
114-
expect(payload.count).toBe(97);
115+
expect(payload.count).toBe(98);
115116
expect([...payload.tools.map((t) => t.name)].sort()).toEqual(
116117
[...tools.map((t) => t.name)].sort(),
117118
);

0 commit comments

Comments
 (0)