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
96 changes: 95 additions & 1 deletion src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
getPendingAgentAction,
getPullRequest,
getRepository,
getRepositorySettings,
isGlobalAgentFrozen,
getRepoQueueTrendSnapshot,
listAgentAuditEvents,
Expand All @@ -59,6 +60,7 @@ import {
listIssueWatchSubscriptionsForLogin,
listNotificationDeliveriesForRecipient,
upsertIssueWatchSubscription,
upsertRepositorySettings,
listOpenPullRequests,
listPullRequests,
listRecentMergedPullRequests,
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 <action> <level>`. Maintainer access required.",
inputSchema: setActionAutonomyShape,
outputSchema: setActionAutonomyOutputSchema,
},
async (input) => this.toolResult(await this.setActionAutonomy(input)),
);

server.registerTool(
"loopover_propose_action",
{
Expand Down Expand Up @@ -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<z.ZodObject<typeof setAgentPausedShape>>): Promise<ToolPayload> {
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<z.ZodObject<typeof setActionAutonomyShape>>): Promise<ToolPayload> {
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<z.ZodObject<typeof proposeActionShape>>): Promise<ToolPayload> {
Expand Down
88 changes: 87 additions & 1 deletion test/unit/mcp-automation-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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<string, string> };
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();
Expand Down