From 3f3577288ad014f52b321f3968d5425452f2ddf3 Mon Sep 17 00:00:00 2001 From: reyanthony062001-ops Date: Fri, 17 Jul 2026 17:06:42 +0000 Subject: [PATCH] feat(mcp): mirror contributor-issue-draft generation to an MCP tool + maintain CLI 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 --- packages/loopover-mcp/bin/loopover-mcp.js | 28 +++++- src/mcp/server.ts | 75 +++++++++++++++ test/unit/mcp-cli-basics.test.ts | 2 +- test/unit/mcp-cli-maintain.test.ts | 28 ++++++ ...-generate-contributor-issue-drafts.test.ts | 94 +++++++++++++++++++ test/unit/support/mcp-cli-harness.ts | 29 ++++++ 6 files changed, 253 insertions(+), 3 deletions(-) create mode 100644 test/unit/mcp-generate-contributor-issue-drafts.test.ts diff --git a/packages/loopover-mcp/bin/loopover-mcp.js b/packages/loopover-mcp/bin/loopover-mcp.js index 4a8d130bac..71b4bfca15 100644 --- a/packages/loopover-mcp/bin/loopover-mcp.js +++ b/packages/loopover-mcp/bin/loopover-mcp.js @@ -101,7 +101,7 @@ const CLI_COMMAND_SPEC = { profile: ["list", "create", "switch", "remove"], cache: ["status", "clear", "list"], agent: ["plan", "status", "explain", "packet"], - maintain: ["status", "queue", "approve", "reject", "pause", "resume", "set-level", "precision", "outcome-calibration", "onboarding-pack", "audit-feed", "automation-state", "refresh-docs"], + maintain: ["status", "queue", "approve", "reject", "pause", "resume", "set-level", "precision", "outcome-calibration", "onboarding-pack", "audit-feed", "automation-state", "refresh-docs", "generate-issue-drafts"], }; const COMPLETION_SHELLS = ["bash", "zsh", "fish", "powershell"]; const AGENT_PROFILE_IDS = ["miner-planner", "miner-auto-dev", "maintainer-triage", "repo-owner-intake"]; @@ -3168,6 +3168,9 @@ function printMaintainHelp() { " [--pull N] Scope the feed to one pull request.", " automation-state Show the derived agent automation state (mode, readiness, pending).", " refresh-docs Open (or find the already-open) the AGENTS.md/CLAUDE.md generation PR.", + " generate-issue-drafts Preview contributor issue drafts (dry-run). Never creates without --create.", + " [--create] Actually open the drafted issues (requires repo write access).", + " [--limit N] Cap the drafts generated (1-20, default 5).", "", "Pass --json for machine-readable output.", ].join("\n") + "\n", @@ -3364,8 +3367,29 @@ async function maintainCli(args) { emit(payload, line); return; } + if (subcommand === "generate-issue-drafts") { + // #6757: session-authenticated mirror of POST {repoBase}/contributor-issue-drafts/generate (and the remote + // loopover_generate_contributor_issue_drafts tool). Dry-run BY DEFAULT — only a bare `--create` opts into + // the write path, and it is forwarded as {create:true, dryRun:false}, the exact shape the route's + // explicit_create_requires_dry_run_false guard demands. A plain `generate-issue-drafts` can never create. + const create = options.create === true; + const parsedLimit = Number(options.limit); + const body = { create, dryRun: !create, ...(Number.isFinite(parsedLimit) ? { limit: parsedLimit } : {}) }; + const payload = await apiPost(`${repoBase}/contributor-issue-drafts/generate`, body); + const mode = payload.dryRun ? "dry-run" : "create"; + const lines = [ + `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.`, + // draft.title/body are generated from untrusted repo issue data, so the plain-text path is sanitized (#6261). + ...(payload.drafts ?? []).map((draft) => { + const ref = draft.issue ? ` -> #${draft.issue.number} ${draft.issue.url}` : ""; + return `- [${sanitizePlainTextTerminalOutput(draft.status)}] ${sanitizePlainTextTerminalOutput(draft.title)}${sanitizePlainTextTerminalOutput(ref)}`; + }), + ]; + emit(payload, lines.join("\n")); + return; + } throw new Error( - `Unknown maintain subcommand: ${subcommand}. Use status | queue | approve | reject | pause | resume | set-level | precision | outcome-calibration | onboarding-pack | audit-feed | automation-state | refresh-docs.`, + `Unknown maintain subcommand: ${subcommand}. Use status | queue | approve | reject | pause | resume | set-level | precision | outcome-calibration | onboarding-pack | audit-feed | automation-state | refresh-docs | generate-issue-drafts.`, ); } diff --git a/src/mcp/server.ts b/src/mcp/server.ts index d77180447b..ba02fb78e8 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -83,6 +83,7 @@ import { buildNotificationFeed } from "../notifications/service"; import { contributorRepoStatsFromGittensor, fetchGittensorContributorSnapshot } from "../gittensor/api"; import { getRepositoryCollaboratorPermission } from "../github/app"; import { performRepoDocRefresh } from "../github/repo-doc-refresh-runner"; +import { generateContributorIssueDrafts } from "../services/contributor-issue-draft"; import { sanitizePublicComment } from "../github/commands"; import { fetchPublicContributorProfile } from "../github/public"; import { listLatestRegistrySnapshots } from "../registry/sync"; @@ -636,6 +637,31 @@ const refreshRepoDocsOutputSchema = { reason: z.string().optional(), }; +// #6757: dryRun/create/limit mirror the REST route's contributorIssueDraftGenerateSchema EXACTLY (same +// defaults, same bounds) so the two surfaces cannot drift. `create` alone does not open issues — the handler +// re-applies the route's explicit_create_requires_dry_run_false guard, so a caller must pass BOTH create:true +// and dryRun:false, and can never silently create. +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), +}; + +const generateContributorIssueDraftsOutputSchema = { + repoFullName: z.string(), + generatedAt: z.string(), + dryRun: z.boolean(), + createRequested: z.boolean(), + proposed: z.number(), + skippedDuplicate: z.number(), + skippedDeclined: z.number(), + skippedUnsafe: z.number(), + created: z.number(), + skippedCreateFailed: z.number(), +}; + // #784 (MCP slice) — the agent audit feed: executed actions + approval decisions for a repo. const auditFeedShape = { owner: z.string().min(1), @@ -1780,6 +1806,7 @@ export const MCP_TOOL_CATEGORIES: Record = { loopover_list_pending_actions: "agent", loopover_decide_pending_action: "agent", loopover_refresh_repo_docs: "maintainer", + loopover_generate_contributor_issue_drafts: "maintainer", loopover_get_agent_audit_feed: "agent", loopover_explain_score_breakdown: "review", loopover_explain_review_risk: "review", @@ -2533,6 +2560,17 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.refreshRepoDocs(input)), ); + register( + "loopover_generate_contributor_issue_drafts", + { + 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.", + inputSchema: generateContributorIssueDraftsShape, + outputSchema: generateContributorIssueDraftsOutputSchema, + }, + async (input) => this.toolResult(await this.generateContributorIssueDrafts(input)), + ); + register( "loopover_get_agent_audit_feed", { @@ -4162,6 +4200,43 @@ export class LoopoverMcp { }; } + // #6757: MCP mirror of POST /v1/repos/:owner/:repo/contributor-issue-drafts/generate. requireRepoManageAccess + // is checked FIRST (before touching anything), then the route's own explicit_create_requires_dry_run_false + // guard is re-applied here so this surface has IDENTICAL create-safety: `create` alone is rejected; only an + // explicit {create:true, dryRun:false} reaches the service, which itself still overlays the global agent + // kill-switch. The result strips the per-draft `drafts[]` (title/body text) from the public-safe tool data, + // surfacing only the counts + posture, like getAgentAuditFeed's scrub. + private async generateContributorIssueDrafts( + input: z.infer>, + ): Promise { + const fullName = `${input.owner}/${input.repo}`; + await this.requireRepoManageAccess(fullName); + if (input.create && input.dryRun !== false) { + throw new Error("explicit_create_requires_dry_run_false: pass create:true together with dryRun:false to open issues."); + } + const result = await generateContributorIssueDrafts(this.env, fullName, { + dryRun: input.dryRun, + create: input.create, + limit: input.limit, + requestedBy: this.identity.kind === "session" ? this.identity.actor : "mcp", + }); + return { + summary: `Contributor issue drafts for ${fullName} (dryRun=${result.dryRun}): ${result.proposed} proposed, ${result.created} created, ${result.skippedDuplicate} duplicate, ${result.skippedDeclined} declined, ${result.skippedUnsafe} unsafe.`, + data: { + repoFullName: result.repoFullName, + generatedAt: result.generatedAt, + dryRun: result.dryRun, + createRequested: result.createRequested, + proposed: result.proposed, + skippedDuplicate: result.skippedDuplicate, + skippedDeclined: result.skippedDeclined, + skippedUnsafe: result.skippedUnsafe, + created: result.created, + skippedCreateFailed: result.skippedCreateFailed, + }, + }; + } + // #784 — the agent audit feed: executed actions + approval decisions for a repo, newest first. // Maintainer-manage scoped; read-only and public-safe (action posture only — no trust/score metadata). private async getAgentAuditFeed(input: z.infer>): Promise { diff --git a/test/unit/mcp-cli-basics.test.ts b/test/unit/mcp-cli-basics.test.ts index 7aeb33d8aa..47134e8200 100644 --- a/test/unit/mcp-cli-basics.test.ts +++ b/test/unit/mcp-cli-basics.test.ts @@ -221,7 +221,7 @@ describe("loopover-mcp CLI — basics", () => { expect(ps).toContain("[System.Management.Automation.CompletionResult]::new"); expect(ps).toContain("$commands = @('login', 'logout'"); expect(ps).toContain( - "'maintain' = @('status', 'queue', 'approve', 'reject', 'pause', 'resume', 'set-level', 'precision', 'outcome-calibration', 'onboarding-pack', 'audit-feed', 'automation-state', 'refresh-docs')", + "'maintain' = @('status', 'queue', 'approve', 'reject', 'pause', 'resume', 'set-level', 'precision', 'outcome-calibration', 'onboarding-pack', 'audit-feed', 'automation-state', 'refresh-docs', 'generate-issue-drafts')", ); }); diff --git a/test/unit/mcp-cli-maintain.test.ts b/test/unit/mcp-cli-maintain.test.ts index 5e213d059d..b7d93d62b5 100644 --- a/test/unit/mcp-cli-maintain.test.ts +++ b/test/unit/mcp-cli-maintain.test.ts @@ -95,6 +95,34 @@ describe("loopover-mcp CLI — maintain (#784)", () => { expect(scoped).toMatch(/Gate precision for owner\/repo \(last 30d\)/); }); + it("generate-issue-drafts dry-runs by default and never forwards create (#6757)", async () => { + const bodies: Array<{ dryRun?: boolean; create?: boolean; limit?: number }> = []; + const e = await env({ onIssueDraftRequest: (b) => bodies.push(b) }); + const out = await runAsync(["maintain", "generate-issue-drafts", "--repo", "owner/repo"], e); + // A bare invocation must send {create:false, dryRun:true} — the tool can never silently create. + expect(bodies[0]).toMatchObject({ create: false, dryRun: true }); + expect(out).toMatch(/Contributor issue drafts for owner\/repo \(dry-run\): 1 proposed, 0 created/); + // The generated draft title carries an ANSI escape; the plain-text path must strip it (#6261). + expect(out).toContain("Add cursor pagination"); + expect(out).not.toContain(""); + }); + + it("generate-issue-drafts --create forwards {create:true, dryRun:false} and reports created issues (#6757)", async () => { + const bodies: Array<{ dryRun?: boolean; create?: boolean; limit?: number }> = []; + const e = await env({ onIssueDraftRequest: (b) => bodies.push(b) }); + const out = await runAsync(["maintain", "generate-issue-drafts", "--repo", "owner/repo", "--create", "--limit", "3"], e); + // --create maps to the exact {create:true, dryRun:false} shape the route's create-safety guard demands, + // and --limit is forwarded as a number. + expect(bodies[0]).toMatchObject({ create: true, dryRun: false, limit: 3 }); + expect(out).toMatch(/\(create\): 1 proposed, 1 created/); + expect(out).toMatch(/#42 https:\/\/github\.com\/owner\/repo\/issues\/42/); + const json = JSON.parse(await runAsync(["maintain", "generate-issue-drafts", "--repo", "owner/repo", "--json"], e)) as { + dryRun: boolean; + createRequested: boolean; + }; + expect(json).toMatchObject({ dryRun: true, createRequested: false }); + }); + it("outcome-calibration reports slop-band merge rates + recommendation outcomes (plain + json), passing the window through (#6735)", async () => { const e = await env(); const out = await runAsync(["maintain", "outcome-calibration", "--repo", "owner/repo"], e); diff --git a/test/unit/mcp-generate-contributor-issue-drafts.test.ts b/test/unit/mcp-generate-contributor-issue-drafts.test.ts new file mode 100644 index 0000000000..e3df2c5c86 --- /dev/null +++ b/test/unit/mcp-generate-contributor-issue-drafts.test.ts @@ -0,0 +1,94 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { describe, expect, it } from "vitest"; +import { LoopoverMcp } from "../../src/mcp/server"; +import { upsertRepositoryFromGitHub } from "../../src/db/repositories"; +import { generateContributorIssueDrafts } from "../../src/services/contributor-issue-draft"; +import type { AuthIdentity } from "../../src/auth/security"; +import { createTestEnv } from "../helpers/d1"; + +const REPO = "owner/widgets"; + +async function connect(env: Env, identity?: AuthIdentity) { + const server = (identity ? new LoopoverMcp(env, identity) : new LoopoverMcp(env)).createServer(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + const client = new Client({ name: "gittensory-issue-drafts-test", version: "0.1.0" }, { capabilities: {} }); + await client.connect(clientTransport); + return client; +} + +async function seedRepo(env: ReturnType): Promise { + await upsertRepositoryFromGitHub(env, { name: "widgets", full_name: REPO, private: false, owner: { login: "owner" }, default_branch: "main" }, 555); +} + +// The api static identity is unconditionally trusted (like the refresh-repo-docs test), so it exercises the +// happy path without needing an actuation allowlist. +const API_IDENTITY = { kind: "static", actor: "api" } as AuthIdentity; + +describe("MCP loopover_generate_contributor_issue_drafts (#6757)", () => { + it("previews drafts on a dry run and returns only counts + posture (no draft bodies)", async () => { + const env = createTestEnv(); + await seedRepo(env); + const client = await connect(env, API_IDENTITY); + const result = await client.callTool({ name: "loopover_generate_contributor_issue_drafts", arguments: { owner: "owner", repo: "widgets" } }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as Record; + expect(data).toMatchObject({ repoFullName: REPO, dryRun: true, createRequested: false, created: 0 }); + // Public-safe: the free-form drafts[] (title/body) never leaves on the tool result — only the counts do. + expect(data.drafts).toBeUndefined(); + expect(typeof data.proposed).toBe("number"); + }); + + it("REJECTS create without an explicit dryRun:false — the tool can never silently create (#6757)", async () => { + const env = createTestEnv(); + await seedRepo(env); + const client = await connect(env, API_IDENTITY); + const result = await client.callTool({ name: "loopover_generate_contributor_issue_drafts", arguments: { owner: "owner", repo: "widgets", create: true } }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result)).toMatch(/explicit_create_requires_dry_run_false/); + }); + + it("denies a static MCP-token caller when the repo is not in MCP_ACTUATION_REPO_ALLOWLIST", async () => { + const env = createTestEnv({ MCP_ACTUATION_REPO_ALLOWLIST: "" }); + await seedRepo(env); + const client = await connect(env); // default identity: { kind: "static", actor: "mcp" } + const result = await client.callTool({ name: "loopover_generate_contributor_issue_drafts", arguments: { owner: "owner", repo: "widgets" } }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result)).toMatch(/MCP_ACTUATION_REPO_ALLOWLIST/); + }); + + it("allows an operator session and attributes the request to that actor", async () => { + // ADMIN_GITHUB_LOGINS grants operator scope, so requireRepoManageAccess admits this session actor and the + // handler takes its `this.identity.actor` requestedBy branch (the primary real caller is a session, not a token). + const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "maintainer-login" }); + await seedRepo(env); + const client = await connect(env, { kind: "session", actor: "maintainer-login" } as AuthIdentity); + const result = await client.callTool({ name: "loopover_generate_contributor_issue_drafts", arguments: { owner: "owner", repo: "widgets" } }); + expect(result.isError).toBeFalsy(); + expect(result.structuredContent).toMatchObject({ repoFullName: REPO, dryRun: true, createRequested: false }); + }); + + it("the MCP tool's counts mirror the underlying service for identical input (surface parity)", async () => { + const env = createTestEnv(); + await seedRepo(env); + // The service is the single source of truth both the REST route and this MCP tool delegate to; asserting + // the tool's structuredContent equals a direct service call for the same input pins that the MCP surface + // reshapes without altering the numbers. + const direct = await generateContributorIssueDrafts(env, REPO, { dryRun: true, limit: 5, requestedBy: "api" }); + const client = await connect(env, API_IDENTITY); + const result = await client.callTool({ name: "loopover_generate_contributor_issue_drafts", arguments: { owner: "owner", repo: "widgets", limit: 5 } }); + const data = result.structuredContent as Record; + expect(data).toMatchObject({ + repoFullName: direct.repoFullName, + dryRun: direct.dryRun, + createRequested: direct.createRequested, + proposed: direct.proposed, + skippedDuplicate: direct.skippedDuplicate, + skippedDeclined: direct.skippedDeclined, + skippedUnsafe: direct.skippedUnsafe, + created: direct.created, + skippedCreateFailed: direct.skippedCreateFailed, + }); + }); +}); diff --git a/test/unit/support/mcp-cli-harness.ts b/test/unit/support/mcp-cli-harness.ts index b58c1bccf9..354bce3c7b 100644 --- a/test/unit/support/mcp-cli-harness.ts +++ b/test/unit/support/mcp-cli-harness.ts @@ -173,6 +173,7 @@ export async function startFixtureServer( slopRiskStatus?: number; prTextLintStatus?: number; onPacketRequest?: (body: unknown) => void; + onIssueDraftRequest?: (body: { dryRun?: boolean; create?: boolean; limit?: number }) => void; onApiRequest?: (request: IncomingMessage) => void; validateConfigWarnings?: string[]; openPrMonitor?: Record; @@ -608,6 +609,34 @@ export async function startFixtureServer( ); return; } + if (request.url === "/v1/repos/owner/repo/contributor-issue-drafts/generate" && request.method === "POST") { + // Reflect the forwarded {dryRun, create, limit} back so the CLI test can assert the exact body it sent. + // The draft title carries an ANSI escape to prove the plain-text path is sanitized (#6261). + const requestBody = (await readJsonRequest(request)) as { dryRun?: boolean; create?: boolean; limit?: number }; + options.onIssueDraftRequest?.(requestBody); + response.end( + JSON.stringify({ + repoFullName: "owner/repo", + generatedAt: "2026-05-30T00:00:00.000Z", + dryRun: requestBody.dryRun ?? true, + createRequested: requestBody.create ?? false, + proposed: 1, + skippedDuplicate: 0, + skippedDeclined: 0, + skippedUnsafe: 0, + created: requestBody.create ? 1 : 0, + skippedCreateFailed: 0, + drafts: [ + { + status: "proposed", + title: "Add cursor pagination", + ...(requestBody.create ? { issue: { number: 42, url: "https://github.com/owner/repo/issues/42" } } : {}), + }, + ], + }), + ); + return; + } const onboardingPackUrl = new URL(request.url ?? "/", "http://localhost"); if (onboardingPackUrl.pathname === "/v1/repos/owner/repo/onboarding-pack/preview" && request.method === "GET") { const refresh = onboardingPackUrl.searchParams.get("refresh");