From a430d67e41a655cad8ba623b131ef34636fdd773 Mon Sep 17 00:00:00 2001 From: nghetienhiep <13849419+nghetienhiep@users.noreply.github.com> Date: Wed, 15 Jul 2026 09:18:59 +0000 Subject: [PATCH] feat(mcp): add loopover_set_agent_paused and loopover_set_action_autonomy tools Closes #6087 --- src/mcp/server.ts | 96 +++++++++++++++++++++++++- test/unit/mcp-automation-state.test.ts | 88 ++++++++++++++++++++++- 2 files changed, 182 insertions(+), 2 deletions(-) diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 3776fbda13..086a0c2873 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -44,6 +44,7 @@ import { getPendingAgentAction, getPullRequest, getRepository, + getRepositorySettings, isGlobalAgentFrozen, getRepoQueueTrendSnapshot, listAgentAuditEvents, @@ -59,6 +60,7 @@ import { listIssueWatchSubscriptionsForLogin, listNotificationDeliveriesForRecipient, upsertIssueWatchSubscription, + upsertRepositorySettings, listOpenPullRequests, listPullRequests, listRecentMergedPullRequests, @@ -152,7 +154,7 @@ import { classifyTestCoverage, isCodeFile, isTestPath, TEST_FRAMEWORKS } from ". import { applyStepResult, buildPlanDag, nextReadySteps, planProgress, validatePlanDag, type PlanDag } from "../services/plan-dag"; import { buildFocusManifestValidation } from "../services/focus-manifest-validation"; import { isGlobalAgentPause, resolveAgentActionMode, resolveAgentPermissionReadiness } from "../settings/agent-execution"; -import { AGENT_ACTION_CLASSES, isActingAutonomyLevel, resolveAutonomy } from "../settings/autonomy"; +import { AGENT_ACTION_CLASSES, AUTONOMY_LEVELS, isActingAutonomyLevel, resolveAutonomy } from "../settings/autonomy"; import { resolveRepositorySettings } from "../settings/repository-settings"; import { MAX_FOCUS_MANIFEST_BYTES } from "../signals/focus-manifest"; import { loadPublicRepoFocusManifest, loadRepoFocusManifest } from "../signals/focus-manifest-loader"; @@ -523,6 +525,42 @@ const automationStateOutputSchema = { pendingActionCount: z.number().optional(), }; +// #6087 (MCP slice) — the write side of the automation control surface: pause/resume and per-action autonomy, +// the two `maintain` CLI operations (loopover-mcp.js:1783-1800) that had no MCP tool yet. Both read-merge-write +// over the same repo `settings` row loopover_get_automation_state reads, so unrelated settings groups (and, +// for autonomy, other action classes) are preserved. +const setAgentPausedShape = { + owner: z.string().min(1), + repo: z.string().min(1), + paused: z.boolean(), +}; + +const setAgentPausedOutputSchema = { + repoFullName: z.string().optional(), + agentPaused: z.boolean().optional(), +}; + +// `action` mirrors the CLI's MAINTAIN_ACTION_CLASSES exactly (loopover-mcp.js:65). `level` intentionally +// validates against the LIVE AUTONOMY_LEVELS (src/settings/autonomy.ts), not the CLI's own stale +// MAINTAIN_AUTONOMY_LEVELS -- that list still carries "suggest"/"propose", both removed server-side by #4620 +// and silently dropped by normalizeAutonomyPolicy on persist, so accepting them here would report success on a +// write that never actually took effect. +const MAINTAIN_AUTONOMY_ACTION_CLASSES = ["review", "request_changes", "approve", "merge", "close", "label"] as const; + +const setActionAutonomyShape = { + owner: z.string().min(1), + repo: z.string().min(1), + action: z.enum(MAINTAIN_AUTONOMY_ACTION_CLASSES), + level: z.enum(AUTONOMY_LEVELS), +}; + +const setActionAutonomyOutputSchema = { + repoFullName: z.string().optional(), + action: z.string().optional(), + level: z.string().optional(), + autonomy: z.record(z.string(), z.string()).optional(), +}; + // #784 (MCP slice) — surface + decide the approval queue, so an MCP client can do the full loop it can // already propose into: list staged actions, then accept (execute) or reject one. const listPendingActionsShape = { @@ -2270,6 +2308,32 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.getAutomationState(input)), ); + // #6087 (MCP control surface, write side): the missing MCP counterpart to `maintain pause`/`resume` + // (loopover-mcp.js:1783). Maintainer-manage access required, same as loopover_propose_action. + server.registerTool( + "loopover_set_agent_paused", + { + description: + "Pause or resume ALL agent actions on a repo (the kill-switch toggle) -- the write-side counterpart to loopover_get_automation_state's agentPaused/mode fields, same as `loopover-mcp maintain pause|resume`. Maintainer access required.", + inputSchema: setAgentPausedShape, + outputSchema: setAgentPausedOutputSchema, + }, + async (input) => this.toolResult(await this.setAgentPaused(input)), + ); + + // #6087 (MCP control surface, write side): the missing MCP counterpart to `maintain set-level` + // (loopover-mcp.js:1789). Maintainer-manage access required, same as loopover_propose_action. + server.registerTool( + "loopover_set_action_autonomy", + { + description: + "Set the autonomy level for one action class via a read-merge-write so other classes are left untouched -- the write-side counterpart to loopover_get_automation_state's autonomy map, same as `loopover-mcp maintain set-level `. Maintainer access required.", + inputSchema: setActionAutonomyShape, + outputSchema: setActionAutonomyOutputSchema, + }, + async (input) => this.toolResult(await this.setActionAutonomy(input)), + ); + server.registerTool( "loopover_propose_action", { @@ -3767,6 +3831,36 @@ export class LoopoverMcp { }; } + // #6087 — pause/resume: the write-side kill-switch counterpart to loopover_get_automation_state's read-only + // mode/agentPaused fields. Reads the RAW settings row (not resolveRepositorySettings's yaml-merged view -- + // writing back a yaml-only override would wrongly persist it into the DB row) and writes the whole row back, + // mirroring the PUT /settings route's own read-merge-write so unrelated settings groups are preserved. + private async setAgentPaused(input: z.infer>): Promise { + const fullName = `${input.owner}/${input.repo}`; + await this.requireRepoManageAccess(fullName); + const current = await getRepositorySettings(this.env, fullName); + const updated = await upsertRepositorySettings(this.env, { ...current, agentPaused: input.paused }); + return { + summary: `Agent actions ${input.paused ? "paused" : "resumed"} for ${fullName}.`, + data: { repoFullName: fullName, agentPaused: updated.agentPaused }, + }; + } + + // #6087 — set-level: the write-side per-action-class autonomy dial. Read-merge-write over the autonomy map + // (mirrors the CLI's own read-merge-write, loopover-mcp.js:1789-1796) so setting one action class's level + // never clobbers the others. + private async setActionAutonomy(input: z.infer>): Promise { + const fullName = `${input.owner}/${input.repo}`; + await this.requireRepoManageAccess(fullName); + const current = await getRepositorySettings(this.env, fullName); + const autonomy = { ...current.autonomy, [input.action]: input.level }; + const updated = await upsertRepositorySettings(this.env, { ...current, autonomy }); + return { + summary: `Set ${input.action} autonomy to ${input.level} for ${fullName}.`, + data: { repoFullName: fullName, action: input.action, level: input.level, autonomy: updated.autonomy }, + }; + } + // #784 — stage a proposed PR action into the approval queue (#779) for a maintainer to accept/reject. The // action is auto_with_approval (never auto-executes); maintainer-manage access required. private async proposeAction(input: z.infer>): Promise { diff --git a/test/unit/mcp-automation-state.test.ts b/test/unit/mcp-automation-state.test.ts index 08cc2b4b15..40d8ec4cd3 100644 --- a/test/unit/mcp-automation-state.test.ts +++ b/test/unit/mcp-automation-state.test.ts @@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { LoopoverMcp } from "../../src/mcp/server"; import { getRepositoryCollaboratorPermission } from "../../src/github/app"; import { mergePullRequest } from "../../src/github/pr-actions"; -import { createPendingAgentActionIfAbsent, getPendingAgentAction, listPendingAgentActions, recordAuditEvent, upsertInstallation, upsertOfficialMinerDetection, upsertPullRequestFromGitHub, upsertRepositoryFromGitHub, upsertRepositorySettings } from "../../src/db/repositories"; +import { createPendingAgentActionIfAbsent, getPendingAgentAction, getRepositorySettings, listPendingAgentActions, recordAuditEvent, upsertInstallation, upsertOfficialMinerDetection, upsertPullRequestFromGitHub, upsertRepositoryFromGitHub, upsertRepositorySettings } from "../../src/db/repositories"; import type { AuthIdentity } from "../../src/auth/security"; import { createTestEnv } from "../helpers/d1"; @@ -152,6 +152,92 @@ describe("MCP loopover_get_automation_state (#784)", () => { }); }); +describe("MCP loopover_set_agent_paused (#6087)", () => { + it("pauses and resumes agent actions for a repo, preserving unrelated settings (read-merge-write)", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "repo", full_name: "owner/repo", private: false, owner: { login: "owner" } }, 5); + await upsertRepositorySettings(env, { repoFullName: "owner/repo", autonomy: { merge: "auto" }, agentDryRun: true }); + + const client = await connect(env); + const paused = await client.callTool({ name: "loopover_set_agent_paused", arguments: { owner: "owner", repo: "repo", paused: true } }); + expect(paused.isError).toBeFalsy(); + expect((paused.structuredContent as { repoFullName: string; agentPaused: boolean }).agentPaused).toBe(true); + + const afterPause = await getRepositorySettings(env, "owner/repo"); + expect(afterPause.agentPaused).toBe(true); + expect(afterPause.agentDryRun).toBe(true); // unrelated setting preserved by the read-merge-write + expect(afterPause.autonomy).toMatchObject({ merge: "auto" }); // unrelated setting preserved + + const resumed = await client.callTool({ name: "loopover_set_agent_paused", arguments: { owner: "owner", repo: "repo", paused: false } }); + expect(resumed.isError).toBeFalsy(); + expect((resumed.structuredContent as { agentPaused: boolean }).agentPaused).toBe(false); + expect((await getRepositorySettings(env, "owner/repo")).agentPaused).toBe(false); + }); + + it("forbids a session without live GitHub write access to the repo, leaving agentPaused untouched", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "repo", full_name: "owner/repo", private: false, owner: { login: "owner" } }, 5); + mockedPermission.mockResolvedValue("read"); + const client = await connect(env, { kind: "session", actor: "rando" } as AuthIdentity); + const result = await client.callTool({ name: "loopover_set_agent_paused", arguments: { owner: "owner", repo: "repo", paused: true } }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result)).toMatch(/write access/i); + expect((await getRepositorySettings(env, "owner/repo")).agentPaused).toBe(false); + }); + + it("denies a static MCP-token caller when the repo is not in MCP_ACTUATION_REPO_ALLOWLIST (#2253)", async () => { + const env = createTestEnv({ MCP_ACTUATION_REPO_ALLOWLIST: "" }); + await upsertRepositoryFromGitHub(env, { name: "repo", full_name: "owner/repo", private: false, owner: { login: "owner" } }, 5); + const client = await connect(env); // default identity: { kind: "static", actor: "mcp" } + const result = await client.callTool({ name: "loopover_set_agent_paused", arguments: { owner: "owner", repo: "repo", paused: true } }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result)).toMatch(/MCP_ACTUATION_REPO_ALLOWLIST/); + }); +}); + +describe("MCP loopover_set_action_autonomy (#6087)", () => { + it("sets one action class's autonomy level without clobbering the others (read-merge-write)", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "repo", full_name: "owner/repo", private: false, owner: { login: "owner" } }, 5); + await upsertRepositorySettings(env, { repoFullName: "owner/repo", autonomy: { merge: "auto_with_approval", label: "auto" } }); + + const client = await connect(env); + const result = await client.callTool({ name: "loopover_set_action_autonomy", arguments: { owner: "owner", repo: "repo", action: "review", level: "observe" } }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as { repoFullName: string; action: string; level: string; autonomy: Record }; + expect(data.action).toBe("review"); + expect(data.level).toBe("observe"); + expect(data.autonomy).toMatchObject({ review: "observe", merge: "auto_with_approval", label: "auto" }); + + const persisted = await getRepositorySettings(env, "owner/repo"); + expect(persisted.autonomy).toMatchObject({ review: "observe", merge: "auto_with_approval", label: "auto" }); + }); + + it("rejects an action class outside the CLI's write surface and a level removed by #4620 via schema validation", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "repo", full_name: "owner/repo", private: false, owner: { login: "owner" } }, 5); + const client = await connect(env); + // "assign" is a valid AgentActionClass but outside MAINTAIN_ACTION_CLASSES (the CLI's set-level surface). + const badAction = await client.callTool({ name: "loopover_set_action_autonomy", arguments: { owner: "owner", repo: "repo", action: "assign", level: "auto" } }); + expect(badAction.isError).toBe(true); + // "suggest" is still in the CLI's own (stale) MAINTAIN_AUTONOMY_LEVELS but was removed server-side by #4620. + const badLevel = await client.callTool({ name: "loopover_set_action_autonomy", arguments: { owner: "owner", repo: "repo", action: "merge", level: "suggest" } }); + expect(badLevel.isError).toBe(true); + expect((await getRepositorySettings(env, "owner/repo")).autonomy).toEqual({}); + }); + + it("forbids a session without live GitHub write access to the repo, leaving autonomy untouched", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "repo", full_name: "owner/repo", private: false, owner: { login: "owner" } }, 5); + mockedPermission.mockResolvedValue("read"); + const client = await connect(env, { kind: "session", actor: "rando" } as AuthIdentity); + const result = await client.callTool({ name: "loopover_set_action_autonomy", arguments: { owner: "owner", repo: "repo", action: "merge", level: "auto" } }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result)).toMatch(/write access/i); + expect((await getRepositorySettings(env, "owner/repo")).autonomy).toEqual({}); + }); +}); + describe("MCP loopover_propose_action (#784)", () => { it("stages a proposed action into the approval queue (idempotent)", async () => { const env = createTestEnv();