Skip to content
Merged
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
35 changes: 35 additions & 0 deletions packages/loopover-mcp/bin/loopover-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1030,6 +1030,17 @@ const planRepoIssuesShape = {
limit: z.number().int().min(1).max(10).optional().default(5),
};

// #7755: mirrors the remote loopover_generate_contributor_issue_drafts input (src/mcp/server.ts's
// generateContributorIssueDraftsShape) -- dryRun/create carry the route's create-safety (create alone is
// rejected there); `limit` is capped at 20, matching the route.
const generateContributorIssueDraftsShape = {
owner: z.string().min(1),
repo: z.string().min(1),
dryRun: z.boolean().optional().default(true),
create: z.boolean().optional().default(false),
limit: z.number().int().min(1).max(20).optional().default(5),
};

// Single source of truth for stdio tool name + one-line description (#2233).
// Registration and `loopover-mcp tools` both read this list.
const STDIO_TOOL_DESCRIPTORS = [
Expand Down Expand Up @@ -1518,6 +1529,12 @@ const STDIO_TOOL_DESCRIPTORS = [
description:
"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.",
},
{
name: "loopover_generate_contributor_issue_drafts",
category: "maintainer",
description:
"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.",
},
{
name: "loopover_open_pr",
category: "agent",
Expand Down Expand Up @@ -3102,6 +3119,24 @@ registerStdioTool(
);
},
);

// #7755: stdio mirror of the remote loopover_generate_contributor_issue_drafts + the `maintain
// generate-issue-drafts` CLI. Proxies POST {repoBase}/contributor-issue-drafts/generate (the same route the
// CLI hits). The route re-applies its own explicit_create_requires_dry_run_false guard, so forwarding the
// schema-defaulted dryRun/create verbatim keeps create-safety exact: `create` alone (dryRun still true) is
// rejected; only an explicit {create:true, dryRun:false} reaches the write path.
registerStdioTool(
"loopover_generate_contributor_issue_drafts",
{
description: stdioToolDescription("loopover_generate_contributor_issue_drafts"),
inputSchema: generateContributorIssueDraftsShape,
},
async ({ owner, repo, dryRun, create, limit }: any) => {
const payload = await apiPost(`${toolRepoBase(owner, repo)}/contributor-issue-drafts/generate`, { dryRun, create, limit });
return toolResult(`Contributor issue drafts for ${owner}/${repo}.`, 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
75 changes: 75 additions & 0 deletions test/unit/mcp-cli-generate-contributor-issue-drafts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { closeFixtureServer, startFixtureServer } from "./support/mcp-cli-harness";

// #7755: in-process coverage for the loopover_generate_contributor_issue_drafts stdio tool. Same #7764
// entrypoint-guard pattern as mcp-cli-repo-focus-manifest -- import the .ts, hold the exported `server`,
// connect an InMemoryTransport so v8/Codecov attributes the registerStdioTool block (a subprocess spawn can't
// be instrumented). Verifies the create-safety forwarding: dry-run by default, explicit create only on request.
const MODULES = ["../../packages/loopover-mcp/bin/loopover-mcp.ts"] as const;

type BinModule = {
server: { connect: (transport: unknown) => Promise<void> };
};

let tempDir = "";
const draftBodies: Array<{ dryRun?: boolean; create?: boolean; limit?: number }> = [];
const loaded = new Map<string, BinModule>();

beforeAll(async () => {
tempDir = mkdtempSync(join(tmpdir(), "loopover-generate-issue-drafts-"));
const apiUrl = await startFixtureServer({ onIssueDraftRequest: (body) => draftBodies.push(body) });
process.env.LOOPOVER_API_URL = apiUrl;
process.env.LOOPOVER_API_TOKEN = "in-process-token";
process.env.LOOPOVER_API_TIMEOUT_MS = "2000";
process.env.LOOPOVER_CONFIG_DIR = tempDir;
process.env.LOOPOVER_SKIP_NPM_VERSION_CHECK = "1";
for (const specifier of MODULES) {
loaded.set(specifier, (await import(specifier)) as unknown as BinModule);
}
}, 120_000);

afterAll(async () => {
await closeFixtureServer();
if (tempDir) rmSync(tempDir, { recursive: true, force: true });
delete process.env.LOOPOVER_API_URL;
delete process.env.LOOPOVER_API_TOKEN;
delete process.env.LOOPOVER_CONFIG_DIR;
delete process.env.LOOPOVER_SKIP_NPM_VERSION_CHECK;
});

describe("bin loopover_generate_contributor_issue_drafts stdio tool (in-process, #7755)", () => {
it.each(MODULES)("dry-runs by default and only writes on explicit create+dryRun=false — %s", async (specifier) => {
draftBodies.length = 0;
const mod = loaded.get(specifier)!;
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await mod.server.connect(serverTransport);
const client = new Client({ name: "generate-issue-drafts-test", version: "0.1.0" }, { capabilities: {} });
await client.connect(clientTransport);
try {
const tool = (await client.listTools()).tools.find((entry) => entry.name === "loopover_generate_contributor_issue_drafts");
expect(tool).toBeDefined();
expect(tool?.description).toMatch(/issue drafts|dry-run/i);

// Defaults: schema fills dryRun=true, create=false, limit=5 -> a safe preview.
const preview = await client.callTool({ name: "loopover_generate_contributor_issue_drafts", arguments: { owner: "owner", repo: "repo" } });
expect(preview.isError).toBeFalsy();
expect(draftBodies.at(-1)).toEqual({ dryRun: true, create: false, limit: 5 });
expect(JSON.stringify(preview)).toContain("Contributor issue drafts for owner/repo.");

// Explicit write: only {create:true, dryRun:false} reaches the write path.
const write = await client.callTool({
name: "loopover_generate_contributor_issue_drafts",
arguments: { owner: "owner", repo: "repo", create: true, dryRun: false, limit: 3 },
});
expect(write.isError).toBeFalsy();
expect(draftBodies.at(-1)).toEqual({ dryRun: false, create: true, limit: 3 });
} finally {
await client.close().catch(() => undefined);
}
});
});
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 @@ -40,6 +40,7 @@
// (#7757 registered the loopover_get_agent_audit_feed stdio tool, taking the count from 95 to 96.)
// (#7754 registered the loopover_refresh_repo_docs stdio tool, taking the count from 96 to 97.)
// (#7756 registered the loopover_get_repo_onboarding_pack stdio tool, taking the count from 97 to 98.)
// (#7755 registered the loopover_generate_contributor_issue_drafts stdio tool, taking the count from 98 to 99.)
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 @@ -86,14 +87,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
});
afterEach(disconnect);

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

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

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