Skip to content

Commit b3e1bc3

Browse files
authored
feat(mcp): add REST route, CLI command, and stdio tool for loopover_plan_repo_issues (#7890)
loopover_plan_repo_issues (generateIssuePlanDrafts) shipped only as a remote MCP tool and never got the REST + CLI + local-stdio mirror surfaces its repo-scoped, requireRepoManageAccess-gated siblings all have. Mirrors loopover_generate_contributor_issue_drafts exactly: a POST /v1/repos/:owner/:repo/issue-plan-drafts/generate route (same gate), a maintain plan-issues CLI command calling it, and a loopover_plan_repo_issues stdio tool. generateIssuePlanDrafts and the remote tool are unchanged. Closes #7764
1 parent e8e5d89 commit b3e1bc3

10 files changed

Lines changed: 665 additions & 13 deletions

File tree

apps/loopover-ui/public/openapi.json

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19030,6 +19030,58 @@
1903019030
}
1903119031
]
1903219032
}
19033+
},
19034+
"/v1/repos/{owner}/{repo}/issue-plan-drafts/generate": {
19035+
"post": {
19036+
"summary": "AI-plan repo issue drafts from a maintainer goal",
19037+
"parameters": [
19038+
{
19039+
"schema": {
19040+
"type": "string"
19041+
},
19042+
"required": true,
19043+
"name": "owner",
19044+
"in": "path"
19045+
},
19046+
{
19047+
"schema": {
19048+
"type": "string"
19049+
},
19050+
"required": true,
19051+
"name": "repo",
19052+
"in": "path"
19053+
}
19054+
],
19055+
"responses": {
19056+
"200": {
19057+
"description": "AI-plan a small set of GitHub issue drafts from a maintainer-supplied planning goal (dry-run by default)",
19058+
"content": {
19059+
"application/json": {
19060+
"schema": {
19061+
"type": "object",
19062+
"additionalProperties": {
19063+
"nullable": true
19064+
}
19065+
}
19066+
}
19067+
}
19068+
},
19069+
"400": {
19070+
"description": "Invalid request or explicit create without dryRun false"
19071+
},
19072+
"403": {
19073+
"description": "Insufficient role"
19074+
}
19075+
},
19076+
"security": [
19077+
{
19078+
"LoopOverBearer": []
19079+
},
19080+
{
19081+
"LoopOverSessionCookie": []
19082+
}
19083+
]
19084+
}
1903319085
}
1903419086
},
1903519087
"servers": [

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

Lines changed: 100 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22
// Phase 3 of #7291 / #7330 — design decision: single-file 1:1 JS→TS conversion (not a split).
33
// Seams exist, but splitting would be a separate refactor; this PR only routes the CLI through tsc.
44
import { createHash } from "node:crypto";
5-
import { closeSync, constants as fsConstants, existsSync, fstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, rmSync, statSync, writeFileSync } from "node:fs";
5+
import { closeSync, constants as fsConstants, existsSync, fstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
66
import { homedir } from "node:os";
77
import { delimiter, dirname, join } from "node:path";
8+
import { fileURLToPath } from "node:url";
89
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
910
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
1011
import { buildFeasibilityVerdict, buildPrTextLint, buildGateDispositions, buildPublicPrBodyDraft } from "@loopover/engine";
@@ -72,6 +73,27 @@ const decisionPackCacheMaxBytes = 512 * 1024;
7273
const cliTextFileMaxBytes = 1024 * 1024;
7374
const changelogPath = new URL("../CHANGELOG.md", import.meta.url);
7475
const cliArgs = process.argv.slice(2);
76+
77+
// #7764: true only when this file is the process entrypoint (`node .../loopover-mcp.js`, incl. via the npm
78+
// `.bin` symlink -- realpathSync resolves it), false when it is imported in-process (e.g. by a vitest unit
79+
// test that exercises the CLI dispatcher + stdio tools directly). The two top-level side effects below -- the
80+
// CLI dispatch and the stdio `server.connect()` -- are gated on it so an in-process importer neither hijacks
81+
// the test runner's argv into runCli() nor binds a StdioServerTransport to the shared stdin. This is the same
82+
// "testable-export refactor" path bin/loopover-miner-mcp.ts's createMinerMcpServer already took (see
83+
// codecov.yml's CLI-dispatcher note); subprocess invocation is unchanged (argv[1] is this file, so it stays
84+
// true) and every mcp-cli-*.test.ts harness run continues to hit the real dispatcher.
85+
function isProcessEntrypoint() {
86+
const entry = process.argv[1];
87+
/* v8 ignore next -- argv[1] is always populated for a spawned Node process; the guard is belt-and-suspenders */
88+
if (!entry) return false;
89+
try {
90+
return realpathSync(entry) === realpathSync(fileURLToPath(import.meta.url));
91+
} catch {
92+
/* v8 ignore next -- defensive: a realpath failure (renamed/removed entry) just means "not the launched CLI" */
93+
return false;
94+
}
95+
}
96+
const runAsCliEntrypoint = isProcessEntrypoint();
7597
const defaultProfileName = "default";
7698
// Single source of truth for shell-completion: top-level command -> its subcommands (if any).
7799
const CLI_COMMAND_SPEC = {
@@ -107,7 +129,7 @@ const CLI_COMMAND_SPEC = {
107129
profile: ["list", "create", "switch", "remove"],
108130
cache: ["status", "clear", "list"],
109131
agent: ["plan", "status", "explain", "packet"],
110-
maintain: ["status", "queue", "propose", "approve", "reject", "pause", "resume", "set-level", "precision", "outcome-calibration", "onboarding-pack", "audit-feed", "automation-state", "refresh-docs", "generate-issue-drafts"],
132+
maintain: ["status", "queue", "propose", "approve", "reject", "pause", "resume", "set-level", "precision", "outcome-calibration", "onboarding-pack", "audit-feed", "automation-state", "refresh-docs", "generate-issue-drafts", "plan-issues"],
111133
};
112134
const COMPLETION_SHELLS = ["bash", "zsh", "fish", "powershell"];
113135
const AGENT_PROFILE_IDS = ["miner-planner", "miner-auto-dev", "maintainer-triage", "repo-owner-intake"];
@@ -921,6 +943,20 @@ const gatePrecisionShape = {
921943
windowDays: z.number().int().positive().optional(),
922944
};
923945

946+
// #7764: mirrors the remote loopover_plan_repo_issues tool's input (src/mcp/server.ts's planRepoIssuesShape),
947+
// minus the create-only `milestone` which this proxy (and the `maintain plan-issues` CLI) does not expose --
948+
// forwarded to POST /v1/repos/:owner/:repo/issue-plan-drafts/generate. `goal` is the required maintainer
949+
// planning goal; dryRun/create carry the route's create-safety (create alone is rejected there). `limit` is
950+
// capped at 10, matching the route, because every draft costs real LLM spend.
951+
const planRepoIssuesShape = {
952+
owner: z.string().min(1),
953+
repo: z.string().min(1),
954+
goal: z.string().min(1).max(2000),
955+
dryRun: z.boolean().optional().default(true),
956+
create: z.boolean().optional().default(false),
957+
limit: z.number().int().min(1).max(10).optional().default(5),
958+
};
959+
924960
// Single source of truth for stdio tool name + one-line description (#2233).
925961
// Registration and `loopover-mcp tools` both read this list.
926962
const STDIO_TOOL_DESCRIPTORS = [
@@ -1307,6 +1343,12 @@ const STDIO_TOOL_DESCRIPTORS = [
13071343
category: "maintainer",
13081344
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.",
13091345
},
1346+
{
1347+
name: "loopover_plan_repo_issues",
1348+
category: "maintainer",
1349+
description:
1350+
"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.",
1351+
},
13101352
{
13111353
name: "loopover_open_pr",
13121354
category: "agent",
@@ -1378,7 +1420,9 @@ function stdioToolDescription(name: any) {
13781420
return tool.description;
13791421
}
13801422

1381-
if (cliArgs[0] && cliArgs[0] !== "--stdio") {
1423+
/* v8 ignore next 8 -- the CLI dispatch runs only in the launched process (runAsCliEntrypoint); an in-process
1424+
unit importer keeps it false and drives runCli/maintainCli directly instead (mcp-cli-plan-issues.test.ts). */
1425+
if (runAsCliEntrypoint && cliArgs[0] && cliArgs[0] !== "--stdio") {
13821426
try {
13831427
const exitCode = await runCli(cliArgs);
13841428
process.exit(typeof exitCode === "number" ? exitCode : 0);
@@ -1387,7 +1431,7 @@ if (cliArgs[0] && cliArgs[0] !== "--stdio") {
13871431
}
13881432
}
13891433

1390-
const server = new McpServer({
1434+
export const server = new McpServer({
13911435
name: "loopover-local",
13921436
version: packageVersion,
13931437
});
@@ -2643,6 +2687,25 @@ registerStdioTool(
26432687
return toolResult(`Gate precision for ${owner}/${repo}.`, payload);
26442688
},
26452689
);
2690+
2691+
registerStdioTool(
2692+
"loopover_plan_repo_issues",
2693+
{
2694+
description: stdioToolDescription("loopover_plan_repo_issues"),
2695+
inputSchema: planRepoIssuesShape,
2696+
},
2697+
async ({ owner, repo, goal, dryRun, create, limit }: any) => {
2698+
// #7764: proxies POST {repoBase}/issue-plan-drafts/generate (the REST mirror of this same tool id). The
2699+
// route re-applies its own explicit_create_requires_dry_run_false guard, so forwarding the schema-defaulted
2700+
// dryRun/create verbatim keeps the create-safety exact: `create` alone (dryRun still true) is rejected;
2701+
// only an explicit {create:true, dryRun:false} reaches the write path.
2702+
const payload = await apiPost(`${toolRepoBase(owner, repo)}/issue-plan-drafts/generate`, { goal, dryRun, create, limit });
2703+
return toolResult(
2704+
`Issue plan for ${owner}/${repo} (status=${payload.status}, dryRun=${payload.dryRun}): ${payload.proposed ?? 0} proposed, ${payload.created ?? 0} created.`,
2705+
payload,
2706+
);
2707+
},
2708+
);
26462709
// ── Write-tools (#6149): pure LOCAL-execution spec builders. loopover NEVER performs the write -- each tool
26472710
// returns a spec the caller runs with its OWN gh creds. Brings the local stdio server to parity with the
26482711
// miner-auto-dev profile's recommendedTools, using the same @loopover/engine builders as the remote server.
@@ -3187,7 +3250,10 @@ server.registerPrompt(
31873250
}),
31883251
);
31893252

3190-
await server.connect(new StdioServerTransport());
3253+
// #7764: only bind the shared stdin/stdout transport when actually launched as the CLI/stdio process. An
3254+
// in-process unit-test importer holds the exported `server` and connects it to an in-memory transport instead.
3255+
/* v8 ignore next -- only the launched stdio process binds the real transport; unit tests connect in-memory. */
3256+
if (runAsCliEntrypoint) await server.connect(new StdioServerTransport());
31913257

31923258
async function withClientWorkspaceRoots(input: any) {
31933259
return withWorkspaceRoots(input, await clientWorkspaceRoots());
@@ -3248,6 +3314,9 @@ function printMaintainHelp() {
32483314
" generate-issue-drafts Preview contributor issue drafts (dry-run). Never creates without --create.",
32493315
" [--create] Actually open the drafted issues (requires repo write access).",
32503316
" [--limit N] Cap the drafts generated (1-20, default 5).",
3317+
' plan-issues --goal "..." AI-plan issue drafts from a free-form goal (dry-run). Never creates without --create.',
3318+
" [--create] Actually open the drafted issues (requires repo write access).",
3319+
" [--limit N] Cap the drafts generated (1-10, default 5).",
32513320
"",
32523321
"Pass --json for machine-readable output.",
32533322
].join("\n") + "\n",
@@ -3256,7 +3325,7 @@ function printMaintainHelp() {
32563325

32573326
// #784 maintainer CLI controls — thin proxies over the agent approval-queue API (#779) and the maintainer
32583327
// settings kill-switch (#130). The API enforces maintainer authorization; the CLI never decides locally.
3259-
async function maintainCli(args: any) {
3328+
export async function maintainCli(args: any) {
32603329
const subcommand = args[0];
32613330
if (!subcommand || subcommand === "--help" || subcommand === "help") return printMaintainHelp();
32623331
const positional = args[1] && !args[1].startsWith("--") ? args[1] : undefined;
@@ -3485,8 +3554,32 @@ async function maintainCli(args: any) {
34853554
emit(payload, lines.join("\n"));
34863555
return;
34873556
}
3557+
if (subcommand === "plan-issues") {
3558+
// #7764: session-authenticated mirror of POST {repoBase}/issue-plan-drafts/generate (and the remote
3559+
// loopover_plan_repo_issues tool). Requires --goal (the maintainer's free-form planning goal). Dry-run BY
3560+
// DEFAULT — only a bare `--create` opts into the write path, forwarded as {create:true, dryRun:false}, the
3561+
// exact shape the route's explicit_create_requires_dry_run_false guard demands. A plain `plan-issues` can
3562+
// never create.
3563+
const goal = typeof options.goal === "string" ? options.goal.trim() : "";
3564+
if (!goal) throw new Error('Pass the planning goal: loopover-mcp maintain plan-issues --repo owner/repo --goal "...".');
3565+
const create = options.create === true;
3566+
const parsedLimit = Number(options.limit);
3567+
const body = { goal, create, dryRun: !create, ...(Number.isFinite(parsedLimit) ? { limit: parsedLimit } : {}) };
3568+
const payload = await apiPost(`${repoBase}/issue-plan-drafts/generate`, body);
3569+
const mode = payload.dryRun ? "dry-run" : "create";
3570+
const lines = [
3571+
`Issue plan for ${repoFullName} (${mode}, status=${sanitizePlainTextTerminalOutput(payload.status)}): ${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.`,
3572+
// draft.title/body are AI-generated free text, so the plain-text path is sanitized (#6261).
3573+
...(payload.drafts ?? []).map((draft: any) => {
3574+
const ref = draft.issue ? ` -> #${draft.issue.number} ${draft.issue.url}` : "";
3575+
return `- [${sanitizePlainTextTerminalOutput(draft.status)}] ${sanitizePlainTextTerminalOutput(draft.title)}${sanitizePlainTextTerminalOutput(ref)}`;
3576+
}),
3577+
];
3578+
emit(payload, lines.join("\n"));
3579+
return;
3580+
}
34883581
throw new Error(
3489-
`Unknown maintain subcommand: ${subcommand}. Use status | queue | propose <action-class> <pull-number> | approve <id> | reject <id> | pause | resume | set-level <action> <level> | precision | outcome-calibration | onboarding-pack | audit-feed | automation-state | refresh-docs | generate-issue-drafts.`,
3582+
`Unknown maintain subcommand: ${subcommand}. Use status | queue | propose <action-class> <pull-number> | approve <id> | reject <id> | pause | resume | set-level <action> <level> | precision | outcome-calibration | onboarding-pack | audit-feed | automation-state | refresh-docs | generate-issue-drafts | plan-issues.`,
34903583
);
34913584
}
34923585

src/api/routes.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,7 @@ import { resolveRepositorySettings } from "../settings/repository-settings";
320320
import { loadPublicRepoFocusManifest, loadRepoFocusManifest, upsertRepoFocusManifest } from "../signals/focus-manifest-loader";
321321
import { buildRepoOnboardingPackPreviewForRepo } from "../services/repo-onboarding-pack";
322322
import { generateContributorIssueDrafts } from "../services/contributor-issue-draft";
323+
import { generateIssuePlanDrafts } from "../services/issue-plan-draft";
323324
import { buildRepoSettingsPreview, PUBLIC_SURFACE_SKIP_REASONS, skippedPrAuditRemediation } from "../signals/settings-preview";
324325
import {
325326
buildGittensorConfigRecommendation,
@@ -954,6 +955,17 @@ const contributorIssueDraftGenerateSchema = z.object({
954955
limit: z.number().int().min(1).max(20).optional().default(5),
955956
});
956957

958+
// #7764: REST mirror of the loopover_plan_repo_issues MCP tool (src/mcp/server.ts's planRepoIssuesShape).
959+
// Unlike the contributor-issue-draft schema above, `goal` is a REQUIRED maintainer-supplied free-form string
960+
// and `limit` is capped lower (10, not 20): every draft here costs real LLM spend, unlike that tool's zero-cost
961+
// static signals. dryRun/create keep the same create-safety contract (create alone is rejected below).
962+
const issuePlanDraftGenerateSchema = z.object({
963+
goal: z.string().trim().min(1).max(2000),
964+
dryRun: z.boolean().optional().default(true),
965+
create: z.boolean().optional().default(false),
966+
limit: z.number().int().min(1).max(10).optional().default(5),
967+
});
968+
957969
const settingsPreviewSchema = z.object({
958970
sample: z
959971
.object({
@@ -2793,6 +2805,43 @@ export function createApp() {
27932805
);
27942806
});
27952807

2808+
// #7764: REST mirror of the loopover_plan_repo_issues MCP tool (src/mcp/server.ts) and the
2809+
// `maintain plan-issues` CLI. Gated EXACTLY like the sibling contributor-issue-drafts route above
2810+
// (requireAppRole maintainer/owner/operator, then per-repo requireSessionRepoAccess for sessions), and it
2811+
// preserves the same create-safety: dry-run by default, and the write path is entered only when the caller
2812+
// passes BOTH create:true and dryRun:false -- which additionally requires live repo write access. The
2813+
// required `goal` is a maintainer-supplied free-form planning goal the service turns into issue drafts.
2814+
app.post("/v1/repos/:owner/:repo/issue-plan-drafts/generate", async (c) => {
2815+
const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`;
2816+
const forbidden = await requireAppRole(c, ["maintainer", "owner", "operator"]);
2817+
if (forbidden) return forbidden;
2818+
const identity = await authenticateRequestIdentity(c);
2819+
const repo = await getRepository(c.env, fullName);
2820+
if (identity?.kind === "session") {
2821+
const repoForbidden = await requireSessionRepoAccess(c, identity, fullName, repo);
2822+
if (repoForbidden) return repoForbidden;
2823+
}
2824+
const body = await c.req.json().catch(() => null);
2825+
if (body === null) return c.json({ error: "invalid_json" }, 400);
2826+
const parsed = issuePlanDraftGenerateSchema.safeParse(body);
2827+
if (!parsed.success) return c.json({ error: "invalid_issue_plan_draft_request", issues: parsed.error.issues }, 400);
2828+
if (parsed.data.create && parsed.data.dryRun !== false) {
2829+
return c.json({ error: "explicit_create_requires_dry_run_false" }, 400);
2830+
}
2831+
if (parsed.data.create && parsed.data.dryRun === false) {
2832+
const writeForbidden = await requireRepoWriteAccess(c, fullName);
2833+
if (writeForbidden instanceof Response) return writeForbidden;
2834+
}
2835+
return c.json(
2836+
await generateIssuePlanDrafts(c.env, fullName, parsed.data.goal, {
2837+
dryRun: parsed.data.dryRun,
2838+
create: parsed.data.create,
2839+
limit: parsed.data.limit,
2840+
requestedBy: identity?.kind === "session" ? identity.actor : "api",
2841+
}),
2842+
);
2843+
});
2844+
27962845
// Repo loopover settings (gate config, AI-review mode/provider/model — NON-secret; the BYOK key is
27972846
// never here). Maintainer DATA: session callers must be a verified maintainer of THIS repo (per-repo
27982847
// scope), so a maintainer of repo A cannot read repo B's config. Server-to-server tokens are exempt.
@@ -6393,6 +6442,7 @@ function canSessionAccessPath(env: Env, identity: Extract<AuthIdentity, { kind:
63936442
if (isRepoAgentPendingActionsPath(path)) return true; // list (GET, requireRepoMaintainer) + propose (POST, requireRepoWriteAccess); decision POSTs on /:id/:decision require server tokens
63946443
if (isRepoIncidentReportsPath(path)) return true; // #5672: route's requireRepoMaintainer enforces per-repo authority (contributors → 403)
63956444
if (isRepoContributorIssueDraftGeneratePath(path)) return true;
6445+
if (isRepoIssuePlanDraftGeneratePath(path)) return true;
63966446
if (path === OPPORTUNITIES_FIND_PATH) return true;
63976447
if (path === ISSUE_RAG_RETRIEVE_PATH) return true;
63986448
if (path === LINT_PR_TEXT_PATH || path === VALIDATE_FOCUS_MANIFEST_PATH || path === LINT_SLOP_RISK_PATH || path === LINT_ISSUE_SLOP_PATH) return true;
@@ -6442,6 +6492,12 @@ function isRepoContributorIssueDraftGeneratePath(path: string): boolean {
64426492
return /^\/v1\/repos\/[^/]+\/[^/]+\/contributor-issue-drafts\/generate$/.test(path);
64436493
}
64446494

6495+
// #7764: coarse path admission for the issue-plan-drafts generate route -- the route's own requireAppRole +
6496+
// requireSessionRepoAccess gate enforces real per-repo maintainer authority, exactly like the sibling above.
6497+
function isRepoIssuePlanDraftGeneratePath(path: string): boolean {
6498+
return /^\/v1\/repos\/[^/]+\/[^/]+\/issue-plan-drafts\/generate$/.test(path);
6499+
}
6500+
64456501
function isRepoCheckBeforeStartPath(path: string): boolean {
64466502
return /^\/v1\/repos\/[^/]+\/[^/]+\/check-before-start$/.test(path);
64476503
}

0 commit comments

Comments
 (0)