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
64 changes: 64 additions & 0 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,13 @@ import {
listBountiesByRepo,
getContributorEvidence,
getLatestRepoGithubTotalsSnapshot,
getInstallation,
getIssue,
getRepository,
getRepositorySettings,
getRepoQueueTrendSnapshot,
listCheckSummaries,
listPendingAgentActions,
listContributorRepoStats,
listContributorIssues,
listContributorPullRequests,
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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",
{
Expand Down Expand Up @@ -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<ToolPayload> {
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<z.ZodObject<typeof scorePreviewShape>>): Promise<ToolPayload> {
if (!input.contributorLogin) throw new Error("contributorLogin is required for score breakdown.");
this.requireContributorAccess(input.contributorLogin);
Expand Down
64 changes: 64 additions & 0 deletions test/unit/mcp-automation-state.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
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
});
});
Loading