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
28 changes: 26 additions & 2 deletions packages/loopover-mcp/bin/loopover-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"];
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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 <id> | reject <id> | pause | resume | set-level <action> <level> | precision | outcome-calibration | onboarding-pack | audit-feed | automation-state | refresh-docs.`,
`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.`,
);
}

Expand Down
75 changes: 75 additions & 0 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -1780,6 +1806,7 @@ export const MCP_TOOL_CATEGORIES: Record<string, McpToolCategory> = {
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",
Expand Down Expand Up @@ -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",
{
Expand Down Expand Up @@ -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<z.ZodObject<typeof generateContributorIssueDraftsShape>>,
): Promise<ToolPayload> {
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<z.ZodObject<typeof auditFeedShape>>): Promise<ToolPayload> {
Expand Down
2 changes: 1 addition & 1 deletion test/unit/mcp-cli-basics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')",
);
});

Expand Down
28 changes: 28 additions & 0 deletions test/unit/mcp-cli-maintain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
94 changes: 94 additions & 0 deletions test/unit/mcp-generate-contributor-issue-drafts.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createTestEnv>): Promise<void> {
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<string, unknown>;
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<string, unknown>;
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,
});
});
});
29 changes: 29 additions & 0 deletions test/unit/support/mcp-cli-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
Expand Down Expand Up @@ -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");
Expand Down