diff --git a/src/mcp/server.ts b/src/mcp/server.ts index dd78ac427c..fdf5674167 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -13,10 +13,13 @@ import { listBountiesByRepo, getContributorEvidence, getLatestRepoGithubTotalsSnapshot, + getInstallation, getIssue, getRepository, + getRepositorySettings, getRepoQueueTrendSnapshot, listCheckSummaries, + listPendingAgentActions, listContributorRepoStats, listContributorIssues, listContributorPullRequests, @@ -98,6 +101,8 @@ import { type LocalWriteActionSpec, } from "./local-write-tools"; import { applyStepResult, buildPlanDag, nextReadySteps, planProgress, validatePlanDag, type PlanDag } from "../services/plan-dag"; +import { isGlobalAgentPause, resolveAgentActionMode, resolveAgentPermissionReadiness } from "../settings/agent-execution"; +import { AGENT_ACTION_CLASSES, isActingAutonomyLevel, resolveAutonomy } from "../settings/autonomy"; import { loadRepoFocusManifest } from "../signals/focus-manifest-loader"; import { buildPredictedGateVerdict } from "../rules/predicted-gate"; import { buildIssueSlopAssessment, buildSlopAssessment, ISSUE_SLOP_RUBRIC_MARKDOWN, SLOP_RUBRIC_MARKDOWN } from "../signals/slop"; @@ -318,6 +323,20 @@ const planViewOutputSchema = { validation: z.object({ valid: z.boolean(), errors: z.array(z.string()) }).optional(), }; +// #784 (MCP slice) — the read side of the agent automation control surface for a repo. +const automationStateOutputSchema = { + repoFullName: z.string().optional(), + configured: z.boolean().optional(), + autonomy: z.record(z.string(), z.string()).optional(), + autoMaintain: z.object({ requireApprovals: z.number(), mergeMethod: z.string() }).optional(), + agentPaused: z.boolean().optional(), + agentDryRun: z.boolean().optional(), + mode: z.string().optional(), + permissionReadiness: z.string().optional(), + actingActionClasses: z.array(z.string()).optional(), + pendingActionCount: z.number().optional(), +}; + const localBranchAnalysisShape = { login: z.string().min(1).max(SCENARIO_MAX_BRANCH_REF_CHARS), repoFullName: z.string().min(3).max(SCENARIO_MAX_REPO_FULL_NAME_CHARS), @@ -1166,6 +1185,19 @@ export class GittensoryMcp { async (input) => this.toolResult(this.recordStepResult(input)), ); + // #784 (MCP control surface, read side): a repo's agent automation posture — autonomy dial, kill-switch / + // dry-run mode, write-permission readiness, and the pending-approval count. Repo-access scoped. + server.registerTool( + "gittensory_get_automation_state", + { + description: + "Return a repo's agent automation state: the per-action autonomy levels, kill-switch / dry-run mode, GitHub write-permission readiness, and how many auto_with_approval actions are awaiting a maintainer decision.", + inputSchema: ownerRepoShape, + outputSchema: automationStateOutputSchema, + }, + async (input) => this.toolResult(await this.getAutomationState(input)), + ); + server.registerTool( "gittensory_explain_score_breakdown", { @@ -1953,6 +1985,38 @@ export class GittensoryMcp { return { summary: `Recorded ${input.outcome} for step ${input.stepId}; plan is now ${planProgress(plan).status}.`, data: this.planView(plan) }; } + // #784 — read the agent automation state for a repo. Repo-access scoped; surfaces the count (not the + // details) of the approval queue — the full queue + accept/reject stay behind the maintainer-authed REST API. + private async getAutomationState(input: { owner: string; repo: string }): Promise { + const fullName = `${input.owner}/${input.repo}`; + await this.requireRepoAccess(fullName); + const [repo, settings, pending] = await Promise.all([ + getRepository(this.env, fullName), + getRepositorySettings(this.env, fullName), + listPendingAgentActions(this.env, { repoFullName: fullName, status: "pending" }), + ]); + const autonomy = settings.autonomy; + const actingActionClasses = AGENT_ACTION_CLASSES.filter((actionClass) => isActingAutonomyLevel(resolveAutonomy(autonomy, actionClass))); + const installation = repo?.installationId ? await getInstallation(this.env, repo.installationId) : null; + const mode = resolveAgentActionMode({ globalPaused: isGlobalAgentPause(this.env), agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun }); + const permissionReadiness = resolveAgentPermissionReadiness({ autonomy, installationPermissions: installation?.permissions ?? null }); + return { + summary: `Agent automation for ${fullName}: mode=${mode}, ${actingActionClasses.length} acting class(es), ${pending.length} pending approval(s).`, + data: { + repoFullName: fullName, + configured: actingActionClasses.length > 0, + autonomy, + autoMaintain: settings.autoMaintain, + agentPaused: settings.agentPaused === true, + agentDryRun: settings.agentDryRun === true, + mode, + permissionReadiness, + actingActionClasses, + pendingActionCount: pending.length, + }, + }; + } + private async explainScoreBreakdown(input: z.infer>): Promise { if (!input.contributorLogin) throw new Error("contributorLogin is required for score breakdown."); this.requireContributorAccess(input.contributorLogin); diff --git a/test/unit/mcp-automation-state.test.ts b/test/unit/mcp-automation-state.test.ts new file mode 100644 index 0000000000..b8fc58e2ab --- /dev/null +++ b/test/unit/mcp-automation-state.test.ts @@ -0,0 +1,64 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { describe, expect, it } from "vitest"; +import { GittensoryMcp } from "../../src/mcp/server"; +import { createPendingAgentActionIfAbsent, upsertInstallation, upsertRepositoryFromGitHub, upsertRepositorySettings } from "../../src/db/repositories"; +import { createTestEnv } from "../helpers/d1"; + +async function connect(env: Env) { + const server = new GittensoryMcp(env).createServer(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + const client = new Client({ name: "gittensory-automation-test", version: "0.1.0" }, { capabilities: {} }); + await client.connect(clientTransport); + return client; +} + +type State = { + configured: boolean; + autonomy: Record; + agentPaused: boolean; + agentDryRun: boolean; + mode: string; + permissionReadiness: string; + actingActionClasses: string[]; + pendingActionCount: number; +}; + +describe("MCP gittensory_get_automation_state (#784)", () => { + it("surfaces a configured repo's autonomy, mode, readiness, and pending-approval count", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "repo", full_name: "owner/repo", private: false, owner: { login: "owner" } }, 5); + await upsertInstallation(env, { + installation: { id: 5, account: { login: "owner", id: 1, type: "User" }, repository_selection: "selected", permissions: { metadata: "read", pull_requests: "write", issues: "write" }, events: ["pull_request"] }, + repositories: [{ name: "repo", full_name: "owner/repo", private: false, owner: { login: "owner" } }], + }); + await upsertRepositorySettings(env, { repoFullName: "owner/repo", autonomy: { merge: "auto", label: "auto_with_approval" }, agentDryRun: true }); + await createPendingAgentActionIfAbsent(env, { repoFullName: "owner/repo", pullNumber: 7, installationId: 5, actionClass: "merge", autonomyLevel: "auto_with_approval", params: {}, reason: "x" }); + + const client = await connect(env); + const result = await client.callTool({ name: "gittensory_get_automation_state", arguments: { owner: "owner", repo: "repo" } }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as State; + expect(data.configured).toBe(true); + expect(data.mode).toBe("dry_run"); // agentDryRun → dry_run + expect(data.permissionReadiness).toBe("ready"); // pull_requests: write granted + expect(data.actingActionClasses).toEqual(expect.arrayContaining(["merge", "label"])); + expect(data.pendingActionCount).toBe(1); + // surfaces the COUNT, not the queue details — no reward/wallet leakage either + expect(JSON.stringify(data)).not.toMatch(/wallet|hotkey|reward|payout|trust score/i); + }); + + it("reports unconfigured + not_required readiness for an unknown / un-onboarded repo (no repo record)", async () => { + const env = createTestEnv(); + // no repo seeded → getRepository returns null (exercises the no-installation path) + default settings. + const client = await connect(env); + const result = await client.callTool({ name: "gittensory_get_automation_state", arguments: { owner: "owner", repo: "ghost" } }); + const data = result.structuredContent as State; + expect(data.configured).toBe(false); + expect(data.actingActionClasses).toEqual([]); + expect(data.permissionReadiness).toBe("not_required"); // no acting PR-write class + expect(data.pendingActionCount).toBe(0); + expect(data.mode).toBe("live"); // nothing paused or dry-run + }); +});