Skip to content

Commit 6f23f79

Browse files
committed
feat(mcp): add REST route, CLI command, and stdio tool for loopover_plan_repo_issues
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 e7e10e7 commit 6f23f79

9 files changed

Lines changed: 651 additions & 8 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 = [
@@ -1302,6 +1338,12 @@ const STDIO_TOOL_DESCRIPTORS = [
13021338
category: "maintainer",
13031339
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.",
13041340
},
1341+
{
1342+
name: "loopover_plan_repo_issues",
1343+
category: "maintainer",
1344+
description:
1345+
"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.",
1346+
},
13051347
{
13061348
name: "loopover_open_pr",
13071349
category: "agent",
@@ -1373,7 +1415,9 @@ function stdioToolDescription(name: any) {
13731415
return tool.description;
13741416
}
13751417

1376-
if (cliArgs[0] && cliArgs[0] !== "--stdio") {
1418+
/* v8 ignore next 8 -- the CLI dispatch runs only in the launched process (runAsCliEntrypoint); an in-process
1419+
unit importer keeps it false and drives runCli/maintainCli directly instead (mcp-cli-plan-issues.test.ts). */
1420+
if (runAsCliEntrypoint && cliArgs[0] && cliArgs[0] !== "--stdio") {
13771421
try {
13781422
const exitCode = await runCli(cliArgs);
13791423
process.exit(typeof exitCode === "number" ? exitCode : 0);
@@ -1382,7 +1426,7 @@ if (cliArgs[0] && cliArgs[0] !== "--stdio") {
13821426
}
13831427
}
13841428

1385-
const server = new McpServer({
1429+
export const server = new McpServer({
13861430
name: "loopover-local",
13871431
version: packageVersion,
13881432
});
@@ -2623,6 +2667,25 @@ registerStdioTool(
26232667
return toolResult(`Gate precision for ${owner}/${repo}.`, payload);
26242668
},
26252669
);
2670+
2671+
registerStdioTool(
2672+
"loopover_plan_repo_issues",
2673+
{
2674+
description: stdioToolDescription("loopover_plan_repo_issues"),
2675+
inputSchema: planRepoIssuesShape,
2676+
},
2677+
async ({ owner, repo, goal, dryRun, create, limit }: any) => {
2678+
// #7764: proxies POST {repoBase}/issue-plan-drafts/generate (the REST mirror of this same tool id). The
2679+
// route re-applies its own explicit_create_requires_dry_run_false guard, so forwarding the schema-defaulted
2680+
// dryRun/create verbatim keeps the create-safety exact: `create` alone (dryRun still true) is rejected;
2681+
// only an explicit {create:true, dryRun:false} reaches the write path.
2682+
const payload = await apiPost(`${toolRepoBase(owner, repo)}/issue-plan-drafts/generate`, { goal, dryRun, create, limit });
2683+
return toolResult(
2684+
`Issue plan for ${owner}/${repo} (status=${payload.status}, dryRun=${payload.dryRun}): ${payload.proposed ?? 0} proposed, ${payload.created ?? 0} created.`,
2685+
payload,
2686+
);
2687+
},
2688+
);
26262689
// ── Write-tools (#6149): pure LOCAL-execution spec builders. loopover NEVER performs the write -- each tool
26272690
// returns a spec the caller runs with its OWN gh creds. Brings the local stdio server to parity with the
26282691
// miner-auto-dev profile's recommendedTools, using the same @loopover/engine builders as the remote server.
@@ -3167,7 +3230,10 @@ server.registerPrompt(
31673230
}),
31683231
);
31693232

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

31723238
async function withClientWorkspaceRoots(input: any) {
31733239
return withWorkspaceRoots(input, await clientWorkspaceRoots());
@@ -3228,6 +3294,9 @@ function printMaintainHelp() {
32283294
" generate-issue-drafts Preview contributor issue drafts (dry-run). Never creates without --create.",
32293295
" [--create] Actually open the drafted issues (requires repo write access).",
32303296
" [--limit N] Cap the drafts generated (1-20, default 5).",
3297+
' plan-issues --goal "..." AI-plan issue drafts from a free-form goal (dry-run). Never creates without --create.',
3298+
" [--create] Actually open the drafted issues (requires repo write access).",
3299+
" [--limit N] Cap the drafts generated (1-10, default 5).",
32313300
"",
32323301
"Pass --json for machine-readable output.",
32333302
].join("\n") + "\n",
@@ -3236,7 +3305,7 @@ function printMaintainHelp() {
32363305

32373306
// #784 maintainer CLI controls — thin proxies over the agent approval-queue API (#779) and the maintainer
32383307
// settings kill-switch (#130). The API enforces maintainer authorization; the CLI never decides locally.
3239-
async function maintainCli(args: any) {
3308+
export async function maintainCli(args: any) {
32403309
const subcommand = args[0];
32413310
if (!subcommand || subcommand === "--help" || subcommand === "help") return printMaintainHelp();
32423311
const positional = args[1] && !args[1].startsWith("--") ? args[1] : undefined;
@@ -3465,8 +3534,32 @@ async function maintainCli(args: any) {
34653534
emit(payload, lines.join("\n"));
34663535
return;
34673536
}
3537+
if (subcommand === "plan-issues") {
3538+
// #7764: session-authenticated mirror of POST {repoBase}/issue-plan-drafts/generate (and the remote
3539+
// loopover_plan_repo_issues tool). Requires --goal (the maintainer's free-form planning goal). Dry-run BY
3540+
// DEFAULT — only a bare `--create` opts into the write path, forwarded as {create:true, dryRun:false}, the
3541+
// exact shape the route's explicit_create_requires_dry_run_false guard demands. A plain `plan-issues` can
3542+
// never create.
3543+
const goal = typeof options.goal === "string" ? options.goal.trim() : "";
3544+
if (!goal) throw new Error('Pass the planning goal: loopover-mcp maintain plan-issues --repo owner/repo --goal "...".');
3545+
const create = options.create === true;
3546+
const parsedLimit = Number(options.limit);
3547+
const body = { goal, create, dryRun: !create, ...(Number.isFinite(parsedLimit) ? { limit: parsedLimit } : {}) };
3548+
const payload = await apiPost(`${repoBase}/issue-plan-drafts/generate`, body);
3549+
const mode = payload.dryRun ? "dry-run" : "create";
3550+
const lines = [
3551+
`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.`,
3552+
// draft.title/body are AI-generated free text, so the plain-text path is sanitized (#6261).
3553+
...(payload.drafts ?? []).map((draft: any) => {
3554+
const ref = draft.issue ? ` -> #${draft.issue.number} ${draft.issue.url}` : "";
3555+
return `- [${sanitizePlainTextTerminalOutput(draft.status)}] ${sanitizePlainTextTerminalOutput(draft.title)}${sanitizePlainTextTerminalOutput(ref)}`;
3556+
}),
3557+
];
3558+
emit(payload, lines.join("\n"));
3559+
return;
3560+
}
34683561
throw new Error(
3469-
`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.`,
3562+
`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.`,
34703563
);
34713564
}
34723565

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)