Skip to content

Commit bd4c172

Browse files
feat(mcp): mirror contributor-issue-draft generation to an MCP tool + maintain CLI (#7019)
The POST /v1/repos/:owner/:repo/contributor-issue-drafts/generate route was web-dashboard-only: no MCP tool and no CLI could reach it. Add loopover_generate_contributor_issue_drafts to src/mcp/server.ts (requireRepoManageAccess-gated) and a `maintain generate-issue-drafts` CLI subcommand. Both preserve the route's create-safety EXACTLY: dry-run by default, and the write path is entered only when the caller passes BOTH create:true and dryRun:false, so neither surface can silently open issues. The MCP tool re-applies the route's explicit_create_requires_dry_run_false guard and returns only the counts + posture, never the per-draft title/body text. Closes #6757 Co-authored-by: reyanthony062001-ops <reyanthony062001-ops@users.noreply.github.com>
1 parent 447ee82 commit bd4c172

6 files changed

Lines changed: 253 additions & 3 deletions

File tree

packages/loopover-mcp/bin/loopover-mcp.js

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ const CLI_COMMAND_SPEC = {
103103
profile: ["list", "create", "switch", "remove"],
104104
cache: ["status", "clear", "list"],
105105
agent: ["plan", "status", "explain", "packet"],
106-
maintain: ["status", "queue", "approve", "reject", "pause", "resume", "set-level", "precision", "outcome-calibration", "onboarding-pack", "audit-feed", "automation-state", "refresh-docs"],
106+
maintain: ["status", "queue", "approve", "reject", "pause", "resume", "set-level", "precision", "outcome-calibration", "onboarding-pack", "audit-feed", "automation-state", "refresh-docs", "generate-issue-drafts"],
107107
};
108108
const COMPLETION_SHELLS = ["bash", "zsh", "fish", "powershell"];
109109
const AGENT_PROFILE_IDS = ["miner-planner", "miner-auto-dev", "maintainer-triage", "repo-owner-intake"];
@@ -3170,6 +3170,9 @@ function printMaintainHelp() {
31703170
" [--pull N] Scope the feed to one pull request.",
31713171
" automation-state Show the derived agent automation state (mode, readiness, pending).",
31723172
" refresh-docs Open (or find the already-open) the AGENTS.md/CLAUDE.md generation PR.",
3173+
" generate-issue-drafts Preview contributor issue drafts (dry-run). Never creates without --create.",
3174+
" [--create] Actually open the drafted issues (requires repo write access).",
3175+
" [--limit N] Cap the drafts generated (1-20, default 5).",
31733176
"",
31743177
"Pass --json for machine-readable output.",
31753178
].join("\n") + "\n",
@@ -3366,8 +3369,29 @@ async function maintainCli(args) {
33663369
emit(payload, line);
33673370
return;
33683371
}
3372+
if (subcommand === "generate-issue-drafts") {
3373+
// #6757: session-authenticated mirror of POST {repoBase}/contributor-issue-drafts/generate (and the remote
3374+
// loopover_generate_contributor_issue_drafts tool). Dry-run BY DEFAULT — only a bare `--create` opts into
3375+
// the write path, and it is forwarded as {create:true, dryRun:false}, the exact shape the route's
3376+
// explicit_create_requires_dry_run_false guard demands. A plain `generate-issue-drafts` can never create.
3377+
const create = options.create === true;
3378+
const parsedLimit = Number(options.limit);
3379+
const body = { create, dryRun: !create, ...(Number.isFinite(parsedLimit) ? { limit: parsedLimit } : {}) };
3380+
const payload = await apiPost(`${repoBase}/contributor-issue-drafts/generate`, body);
3381+
const mode = payload.dryRun ? "dry-run" : "create";
3382+
const lines = [
3383+
`Contributor issue drafts for ${repoFullName} (${mode}): ${payload.proposed ?? 0} proposed, ${payload.created ?? 0} created, ${payload.skippedDuplicate ?? 0} duplicate, ${payload.skippedDeclined ?? 0} declined, ${payload.skippedUnsafe ?? 0} unsafe, ${payload.skippedCreateFailed ?? 0} create-failed.`,
3384+
// draft.title/body are generated from untrusted repo issue data, so the plain-text path is sanitized (#6261).
3385+
...(payload.drafts ?? []).map((draft) => {
3386+
const ref = draft.issue ? ` -> #${draft.issue.number} ${draft.issue.url}` : "";
3387+
return `- [${sanitizePlainTextTerminalOutput(draft.status)}] ${sanitizePlainTextTerminalOutput(draft.title)}${sanitizePlainTextTerminalOutput(ref)}`;
3388+
}),
3389+
];
3390+
emit(payload, lines.join("\n"));
3391+
return;
3392+
}
33693393
throw new Error(
3370-
`Unknown maintain subcommand: ${subcommand}. Use status | queue | approve <id> | reject <id> | pause | resume | set-level <action> <level> | precision | outcome-calibration | onboarding-pack | audit-feed | automation-state | refresh-docs.`,
3394+
`Unknown maintain subcommand: ${subcommand}. Use status | queue | approve <id> | reject <id> | pause | resume | set-level <action> <level> | precision | outcome-calibration | onboarding-pack | audit-feed | automation-state | refresh-docs | generate-issue-drafts.`,
33713395
);
33723396
}
33733397

src/mcp/server.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ import { buildNotificationFeed } from "../notifications/service";
8383
import { contributorRepoStatsFromGittensor, fetchGittensorContributorSnapshot } from "../gittensor/api";
8484
import { getRepositoryCollaboratorPermission } from "../github/app";
8585
import { performRepoDocRefresh } from "../github/repo-doc-refresh-runner";
86+
import { generateContributorIssueDrafts } from "../services/contributor-issue-draft";
8687
import { sanitizePublicComment } from "../github/commands";
8788
import { fetchPublicContributorProfile } from "../github/public";
8889
import { listLatestRegistrySnapshots } from "../registry/sync";
@@ -636,6 +637,31 @@ const refreshRepoDocsOutputSchema = {
636637
reason: z.string().optional(),
637638
};
638639

640+
// #6757: dryRun/create/limit mirror the REST route's contributorIssueDraftGenerateSchema EXACTLY (same
641+
// defaults, same bounds) so the two surfaces cannot drift. `create` alone does not open issues — the handler
642+
// re-applies the route's explicit_create_requires_dry_run_false guard, so a caller must pass BOTH create:true
643+
// and dryRun:false, and can never silently create.
644+
const generateContributorIssueDraftsShape = {
645+
owner: z.string().min(1),
646+
repo: z.string().min(1),
647+
dryRun: z.boolean().optional().default(true),
648+
create: z.boolean().optional().default(false),
649+
limit: z.number().int().min(1).max(20).optional().default(5),
650+
};
651+
652+
const generateContributorIssueDraftsOutputSchema = {
653+
repoFullName: z.string(),
654+
generatedAt: z.string(),
655+
dryRun: z.boolean(),
656+
createRequested: z.boolean(),
657+
proposed: z.number(),
658+
skippedDuplicate: z.number(),
659+
skippedDeclined: z.number(),
660+
skippedUnsafe: z.number(),
661+
created: z.number(),
662+
skippedCreateFailed: z.number(),
663+
};
664+
639665
// #784 (MCP slice) — the agent audit feed: executed actions + approval decisions for a repo.
640666
const auditFeedShape = {
641667
owner: z.string().min(1),
@@ -1780,6 +1806,7 @@ export const MCP_TOOL_CATEGORIES: Record<string, McpToolCategory> = {
17801806
loopover_list_pending_actions: "agent",
17811807
loopover_decide_pending_action: "agent",
17821808
loopover_refresh_repo_docs: "maintainer",
1809+
loopover_generate_contributor_issue_drafts: "maintainer",
17831810
loopover_get_agent_audit_feed: "agent",
17841811
loopover_explain_score_breakdown: "review",
17851812
loopover_explain_review_risk: "review",
@@ -2533,6 +2560,17 @@ export class LoopoverMcp {
25332560
async (input) => this.toolResult(await this.refreshRepoDocs(input)),
25342561
);
25352562

2563+
register(
2564+
"loopover_generate_contributor_issue_drafts",
2565+
{
2566+
description:
2567+
"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.",
2568+
inputSchema: generateContributorIssueDraftsShape,
2569+
outputSchema: generateContributorIssueDraftsOutputSchema,
2570+
},
2571+
async (input) => this.toolResult(await this.generateContributorIssueDrafts(input)),
2572+
);
2573+
25362574
register(
25372575
"loopover_get_agent_audit_feed",
25382576
{
@@ -4162,6 +4200,43 @@ export class LoopoverMcp {
41624200
};
41634201
}
41644202

4203+
// #6757: MCP mirror of POST /v1/repos/:owner/:repo/contributor-issue-drafts/generate. requireRepoManageAccess
4204+
// is checked FIRST (before touching anything), then the route's own explicit_create_requires_dry_run_false
4205+
// guard is re-applied here so this surface has IDENTICAL create-safety: `create` alone is rejected; only an
4206+
// explicit {create:true, dryRun:false} reaches the service, which itself still overlays the global agent
4207+
// kill-switch. The result strips the per-draft `drafts[]` (title/body text) from the public-safe tool data,
4208+
// surfacing only the counts + posture, like getAgentAuditFeed's scrub.
4209+
private async generateContributorIssueDrafts(
4210+
input: z.infer<z.ZodObject<typeof generateContributorIssueDraftsShape>>,
4211+
): Promise<ToolPayload> {
4212+
const fullName = `${input.owner}/${input.repo}`;
4213+
await this.requireRepoManageAccess(fullName);
4214+
if (input.create && input.dryRun !== false) {
4215+
throw new Error("explicit_create_requires_dry_run_false: pass create:true together with dryRun:false to open issues.");
4216+
}
4217+
const result = await generateContributorIssueDrafts(this.env, fullName, {
4218+
dryRun: input.dryRun,
4219+
create: input.create,
4220+
limit: input.limit,
4221+
requestedBy: this.identity.kind === "session" ? this.identity.actor : "mcp",
4222+
});
4223+
return {
4224+
summary: `Contributor issue drafts for ${fullName} (dryRun=${result.dryRun}): ${result.proposed} proposed, ${result.created} created, ${result.skippedDuplicate} duplicate, ${result.skippedDeclined} declined, ${result.skippedUnsafe} unsafe.`,
4225+
data: {
4226+
repoFullName: result.repoFullName,
4227+
generatedAt: result.generatedAt,
4228+
dryRun: result.dryRun,
4229+
createRequested: result.createRequested,
4230+
proposed: result.proposed,
4231+
skippedDuplicate: result.skippedDuplicate,
4232+
skippedDeclined: result.skippedDeclined,
4233+
skippedUnsafe: result.skippedUnsafe,
4234+
created: result.created,
4235+
skippedCreateFailed: result.skippedCreateFailed,
4236+
},
4237+
};
4238+
}
4239+
41654240
// #784 — the agent audit feed: executed actions + approval decisions for a repo, newest first.
41664241
// Maintainer-manage scoped; read-only and public-safe (action posture only — no trust/score metadata).
41674242
private async getAgentAuditFeed(input: z.infer<z.ZodObject<typeof auditFeedShape>>): Promise<ToolPayload> {

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,7 @@ describe("loopover-mcp CLI — basics", () => {
221221
expect(ps).toContain("[System.Management.Automation.CompletionResult]::new");
222222
expect(ps).toContain("$commands = @('login', 'logout'");
223223
expect(ps).toContain(
224-
"'maintain' = @('status', 'queue', 'approve', 'reject', 'pause', 'resume', 'set-level', 'precision', 'outcome-calibration', 'onboarding-pack', 'audit-feed', 'automation-state', 'refresh-docs')",
224+
"'maintain' = @('status', 'queue', 'approve', 'reject', 'pause', 'resume', 'set-level', 'precision', 'outcome-calibration', 'onboarding-pack', 'audit-feed', 'automation-state', 'refresh-docs', 'generate-issue-drafts')",
225225
);
226226
});
227227

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,34 @@ describe("loopover-mcp CLI — maintain (#784)", () => {
9595
expect(scoped).toMatch(/Gate precision for owner\/repo \(last 30d\)/);
9696
});
9797

98+
it("generate-issue-drafts dry-runs by default and never forwards create (#6757)", async () => {
99+
const bodies: Array<{ dryRun?: boolean; create?: boolean; limit?: number }> = [];
100+
const e = await env({ onIssueDraftRequest: (b) => bodies.push(b) });
101+
const out = await runAsync(["maintain", "generate-issue-drafts", "--repo", "owner/repo"], e);
102+
// A bare invocation must send {create:false, dryRun:true} — the tool can never silently create.
103+
expect(bodies[0]).toMatchObject({ create: false, dryRun: true });
104+
expect(out).toMatch(/Contributor issue drafts for owner\/repo \(dry-run\): 1 proposed, 0 created/);
105+
// The generated draft title carries an ANSI escape; the plain-text path must strip it (#6261).
106+
expect(out).toContain("Add cursor pagination");
107+
expect(out).not.toContain("");
108+
});
109+
110+
it("generate-issue-drafts --create forwards {create:true, dryRun:false} and reports created issues (#6757)", async () => {
111+
const bodies: Array<{ dryRun?: boolean; create?: boolean; limit?: number }> = [];
112+
const e = await env({ onIssueDraftRequest: (b) => bodies.push(b) });
113+
const out = await runAsync(["maintain", "generate-issue-drafts", "--repo", "owner/repo", "--create", "--limit", "3"], e);
114+
// --create maps to the exact {create:true, dryRun:false} shape the route's create-safety guard demands,
115+
// and --limit is forwarded as a number.
116+
expect(bodies[0]).toMatchObject({ create: true, dryRun: false, limit: 3 });
117+
expect(out).toMatch(/\(create\): 1 proposed, 1 created/);
118+
expect(out).toMatch(/#42 https:\/\/github\.com\/owner\/repo\/issues\/42/);
119+
const json = JSON.parse(await runAsync(["maintain", "generate-issue-drafts", "--repo", "owner/repo", "--json"], e)) as {
120+
dryRun: boolean;
121+
createRequested: boolean;
122+
};
123+
expect(json).toMatchObject({ dryRun: true, createRequested: false });
124+
});
125+
98126
it("outcome-calibration reports slop-band merge rates + recommendation outcomes (plain + json), passing the window through (#6735)", async () => {
99127
const e = await env();
100128
const out = await runAsync(["maintain", "outcome-calibration", "--repo", "owner/repo"], e);
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
3+
import { describe, expect, it } from "vitest";
4+
import { LoopoverMcp } from "../../src/mcp/server";
5+
import { upsertRepositoryFromGitHub } from "../../src/db/repositories";
6+
import { generateContributorIssueDrafts } from "../../src/services/contributor-issue-draft";
7+
import type { AuthIdentity } from "../../src/auth/security";
8+
import { createTestEnv } from "../helpers/d1";
9+
10+
const REPO = "owner/widgets";
11+
12+
async function connect(env: Env, identity?: AuthIdentity) {
13+
const server = (identity ? new LoopoverMcp(env, identity) : new LoopoverMcp(env)).createServer();
14+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
15+
await server.connect(serverTransport);
16+
const client = new Client({ name: "gittensory-issue-drafts-test", version: "0.1.0" }, { capabilities: {} });
17+
await client.connect(clientTransport);
18+
return client;
19+
}
20+
21+
async function seedRepo(env: ReturnType<typeof createTestEnv>): Promise<void> {
22+
await upsertRepositoryFromGitHub(env, { name: "widgets", full_name: REPO, private: false, owner: { login: "owner" }, default_branch: "main" }, 555);
23+
}
24+
25+
// The api static identity is unconditionally trusted (like the refresh-repo-docs test), so it exercises the
26+
// happy path without needing an actuation allowlist.
27+
const API_IDENTITY = { kind: "static", actor: "api" } as AuthIdentity;
28+
29+
describe("MCP loopover_generate_contributor_issue_drafts (#6757)", () => {
30+
it("previews drafts on a dry run and returns only counts + posture (no draft bodies)", async () => {
31+
const env = createTestEnv();
32+
await seedRepo(env);
33+
const client = await connect(env, API_IDENTITY);
34+
const result = await client.callTool({ name: "loopover_generate_contributor_issue_drafts", arguments: { owner: "owner", repo: "widgets" } });
35+
expect(result.isError).toBeFalsy();
36+
const data = result.structuredContent as Record<string, unknown>;
37+
expect(data).toMatchObject({ repoFullName: REPO, dryRun: true, createRequested: false, created: 0 });
38+
// Public-safe: the free-form drafts[] (title/body) never leaves on the tool result — only the counts do.
39+
expect(data.drafts).toBeUndefined();
40+
expect(typeof data.proposed).toBe("number");
41+
});
42+
43+
it("REJECTS create without an explicit dryRun:false — the tool can never silently create (#6757)", async () => {
44+
const env = createTestEnv();
45+
await seedRepo(env);
46+
const client = await connect(env, API_IDENTITY);
47+
const result = await client.callTool({ name: "loopover_generate_contributor_issue_drafts", arguments: { owner: "owner", repo: "widgets", create: true } });
48+
expect(result.isError).toBe(true);
49+
expect(JSON.stringify(result)).toMatch(/explicit_create_requires_dry_run_false/);
50+
});
51+
52+
it("denies a static MCP-token caller when the repo is not in MCP_ACTUATION_REPO_ALLOWLIST", async () => {
53+
const env = createTestEnv({ MCP_ACTUATION_REPO_ALLOWLIST: "" });
54+
await seedRepo(env);
55+
const client = await connect(env); // default identity: { kind: "static", actor: "mcp" }
56+
const result = await client.callTool({ name: "loopover_generate_contributor_issue_drafts", arguments: { owner: "owner", repo: "widgets" } });
57+
expect(result.isError).toBe(true);
58+
expect(JSON.stringify(result)).toMatch(/MCP_ACTUATION_REPO_ALLOWLIST/);
59+
});
60+
61+
it("allows an operator session and attributes the request to that actor", async () => {
62+
// ADMIN_GITHUB_LOGINS grants operator scope, so requireRepoManageAccess admits this session actor and the
63+
// handler takes its `this.identity.actor` requestedBy branch (the primary real caller is a session, not a token).
64+
const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "maintainer-login" });
65+
await seedRepo(env);
66+
const client = await connect(env, { kind: "session", actor: "maintainer-login" } as AuthIdentity);
67+
const result = await client.callTool({ name: "loopover_generate_contributor_issue_drafts", arguments: { owner: "owner", repo: "widgets" } });
68+
expect(result.isError).toBeFalsy();
69+
expect(result.structuredContent).toMatchObject({ repoFullName: REPO, dryRun: true, createRequested: false });
70+
});
71+
72+
it("the MCP tool's counts mirror the underlying service for identical input (surface parity)", async () => {
73+
const env = createTestEnv();
74+
await seedRepo(env);
75+
// The service is the single source of truth both the REST route and this MCP tool delegate to; asserting
76+
// the tool's structuredContent equals a direct service call for the same input pins that the MCP surface
77+
// reshapes without altering the numbers.
78+
const direct = await generateContributorIssueDrafts(env, REPO, { dryRun: true, limit: 5, requestedBy: "api" });
79+
const client = await connect(env, API_IDENTITY);
80+
const result = await client.callTool({ name: "loopover_generate_contributor_issue_drafts", arguments: { owner: "owner", repo: "widgets", limit: 5 } });
81+
const data = result.structuredContent as Record<string, unknown>;
82+
expect(data).toMatchObject({
83+
repoFullName: direct.repoFullName,
84+
dryRun: direct.dryRun,
85+
createRequested: direct.createRequested,
86+
proposed: direct.proposed,
87+
skippedDuplicate: direct.skippedDuplicate,
88+
skippedDeclined: direct.skippedDeclined,
89+
skippedUnsafe: direct.skippedUnsafe,
90+
created: direct.created,
91+
skippedCreateFailed: direct.skippedCreateFailed,
92+
});
93+
});
94+
});

test/unit/support/mcp-cli-harness.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@ export async function startFixtureServer(
173173
slopRiskStatus?: number;
174174
prTextLintStatus?: number;
175175
onPacketRequest?: (body: unknown) => void;
176+
onIssueDraftRequest?: (body: { dryRun?: boolean; create?: boolean; limit?: number }) => void;
176177
onApiRequest?: (request: IncomingMessage) => void;
177178
validateConfigWarnings?: string[];
178179
openPrMonitor?: Record<string, unknown>;
@@ -621,6 +622,34 @@ export async function startFixtureServer(
621622
);
622623
return;
623624
}
625+
if (request.url === "/v1/repos/owner/repo/contributor-issue-drafts/generate" && request.method === "POST") {
626+
// Reflect the forwarded {dryRun, create, limit} back so the CLI test can assert the exact body it sent.
627+
// The draft title carries an ANSI escape to prove the plain-text path is sanitized (#6261).
628+
const requestBody = (await readJsonRequest(request)) as { dryRun?: boolean; create?: boolean; limit?: number };
629+
options.onIssueDraftRequest?.(requestBody);
630+
response.end(
631+
JSON.stringify({
632+
repoFullName: "owner/repo",
633+
generatedAt: "2026-05-30T00:00:00.000Z",
634+
dryRun: requestBody.dryRun ?? true,
635+
createRequested: requestBody.create ?? false,
636+
proposed: 1,
637+
skippedDuplicate: 0,
638+
skippedDeclined: 0,
639+
skippedUnsafe: 0,
640+
created: requestBody.create ? 1 : 0,
641+
skippedCreateFailed: 0,
642+
drafts: [
643+
{
644+
status: "proposed",
645+
title: "Add cursor pagination",
646+
...(requestBody.create ? { issue: { number: 42, url: "https://github.com/owner/repo/issues/42" } } : {}),
647+
},
648+
],
649+
}),
650+
);
651+
return;
652+
}
624653
const onboardingPackUrl = new URL(request.url ?? "/", "http://localhost");
625654
if (onboardingPackUrl.pathname === "/v1/repos/owner/repo/onboarding-pack/preview" && request.method === "GET") {
626655
const refresh = onboardingPackUrl.searchParams.get("refresh");

0 commit comments

Comments
 (0)