Skip to content
Closed
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
17 changes: 17 additions & 0 deletions packages/loopover-mcp/bin/loopover-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -1232,6 +1232,11 @@ const STDIO_TOOL_DESCRIPTORS = [
// #6152 — the maintain CLI's REST surface, exposed as tools so an agent can drive it without shelling out.
// Categories mirror the remote server's MCP_TOOL_CATEGORIES entries for the same names, so a caller sees one
// consistent grouping across both surfaces.
{
name: "loopover_get_automation_state",
category: "agent",
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 — the read-side counterpart to `loopover-mcp maintain pause|resume|set-level`. Maintainer access required.",
},
{
name: "loopover_list_pending_actions",
category: "agent",
Expand Down Expand Up @@ -2407,6 +2412,18 @@ function toolRepoBase(owner, repo) {
return `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
}

registerStdioTool(
"loopover_get_automation_state",
{
description: stdioToolDescription("loopover_get_automation_state"),
inputSchema: ownerRepoShape,
},
async ({ owner, repo }) => {
const payload = await apiGet(`${toolRepoBase(owner, repo)}/automation-state`);
return toolResult(`Agent automation for ${owner}/${repo}: mode=${payload.mode ?? "unknown"}.`, payload);
},
);

registerStdioTool(
"loopover_list_pending_actions",
{
Expand Down
25 changes: 25 additions & 0 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ import { buildStructuralImprovementAssessment } from "../signals/improvement";
import { evaluateEscalation } from "../loop-escalation";
import { buildResultsPayload } from "../results-payload";
import { buildProgressSnapshot } from "../loop-progress";
import { buildAutomationStateResponse } from "../automation-state";
import { validateIdeaSubmission, buildTaskGraph, buildClaimPlan } from "../idea-intake";
import { loadPrAiReviewFindings } from "../mcp/pr-ai-review-findings";
import {
Expand Down Expand Up @@ -2503,6 +2504,23 @@ export function createApp() {
return c.json(response);
});

// #6742: REST mirror of the loopover_get_automation_state MCP tool (src/mcp/server.ts). Same
// buildAutomationStateResponse the tool calls, and the same session/static-mcp repo-access gate as
// issue-quality above, so the two surfaces can never drift on either the data or who may read it.
app.get("/v1/repos/:owner/:repo/automation-state", async (c) => {
const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`;
const identity = await authenticateRequestIdentity(c);
/* v8 ignore next -- Protected middleware rejects unauthenticated private routes before route-specific repo guards. */
if (!identity) return c.json({ error: "unauthorized" }, 401);
const repo = identity.kind === "session" ? await getRepository(c.env, fullName) : null;
if (identity.kind === "session") {
const forbidden = await requireSessionRepoAccess(c, identity, fullName, repo);
if (forbidden) return forbidden;
}
if (identity.kind === "static" && identity.actor === "mcp" && !(await import("../auth/security")).isMcpReadRepoAllowed(c.env.MCP_READ_REPO_ALLOWLIST, fullName)) return c.json({ error: "forbidden_repo" }, 403);
return c.json(await buildAutomationStateResponse(c.env, fullName));
});

app.post("/v1/repos/:owner/:repo/validate-linked-issue", async (c) => {
const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`;
const identity = await authenticateRequestIdentity(c);
Expand Down Expand Up @@ -6023,6 +6041,7 @@ function canSessionAccessPath(env: Env, identity: Extract<AuthIdentity, { kind:
if (isRepoOutcomeCalibrationPath(path)) return true;
if (isRepoGatePrecisionPath(path)) return true;
if (isRepoMaintainerNoisePath(path)) return true;
if (isRepoAutomationStatePath(path)) return true; // route's own requireSessionRepoAccess enforces per-repo authority
if (isRepoSelftuneOverridesPath(path)) return true;
if (isRepoSettingsPreviewPath(path)) return true;
if (isRepoOnboardingPackPreviewPath(path)) return true;
Expand Down Expand Up @@ -6065,6 +6084,12 @@ function isRepoMaintainerNoisePath(path: string): boolean {
return /^\/v1\/repos\/[^/]+\/[^/]+\/maintainer-noise$/.test(path);
}

// #6742: mirrors isRepoMaintainerNoisePath above — the automation-state route's own requireSessionRepoAccess
// check enforces per-repo authority, so this coarse allowlist only needs to let a session reach the route.
function isRepoAutomationStatePath(path: string): boolean {
return /^\/v1\/repos\/[^/]+\/[^/]+\/automation-state$/.test(path);
}

// #6168: let a browser (session) maintainer reach the self-tune override admin routes; the route's own
// requireRepoMaintainer then enforces per-repo authority (a non-maintainer session → 403). Matches the
// gate-precision allowlist entry above. Covers both the audit read and the live-override delete.
Expand Down
53 changes: 53 additions & 0 deletions src/automation-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { countPendingAgentActions, getInstallation, getRepository, isGlobalAgentFrozen } from "./db/repositories";
import { isGlobalAgentPause, resolveAgentActionMode, resolveAgentPermissionReadiness } from "./settings/agent-execution";
import { AGENT_ACTION_CLASSES, isActingAutonomyLevel, resolveAutonomy } from "./settings/autonomy";
import { resolveRepositorySettings } from "./settings/repository-settings";
import type { AgentActionClass, AutoMaintainPolicy, AutonomyPolicy } from "./types";

export type AutomationStateResponse = {
repoFullName: string;
configured: boolean;
autonomy: AutonomyPolicy | undefined;
autoMaintain: AutoMaintainPolicy | undefined;
agentPaused: boolean;
agentDryRun: boolean;
mode: string;
permissionReadiness: string;
actingActionClasses: AgentActionClass[];
pendingActionCount: number;
};

/**
* A repo's agent automation state: 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.
* Shared by loopover_get_automation_state (src/mcp/server.ts) and its REST mirror (src/api/routes.ts) so
* the two surfaces can never drift (#6742).
*/
export async function buildAutomationStateResponse(env: Env, repoFullName: string): Promise<AutomationStateResponse> {
const [repo, settings, pendingActionCount] = await Promise.all([
getRepository(env, repoFullName),
resolveRepositorySettings(env, repoFullName),
countPendingAgentActions(env, { repoFullName, status: "pending" }),
]);
const autonomy = settings.autonomy;
const actingActionClasses = AGENT_ACTION_CLASSES.filter((actionClass) => isActingAutonomyLevel(resolveAutonomy(autonomy, actionClass)));
const installation = repo?.installationId ? await getInstallation(env, repo.installationId) : null;
const mode = resolveAgentActionMode({
globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)),
agentPaused: settings.agentPaused,
agentDryRun: settings.agentDryRun,
});
const permissionReadiness = resolveAgentPermissionReadiness({ autonomy, installationPermissions: installation?.permissions ?? null });
return {
repoFullName,
configured: actingActionClasses.length > 0,
autonomy,
autoMaintain: settings.autoMaintain,
agentPaused: settings.agentPaused === true,
agentDryRun: settings.agentDryRun === true,
mode,
permissionReadiness,
actingActionClasses,
pendingActionCount,
};
}
34 changes: 6 additions & 28 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,12 @@ import {
import { canLoginAccessRepo, canWatchRepo, loadControlPanelAccessScope, loadControlPanelRoleSummary, type ControlPanelAccessScope } from "../services/control-panel-roles";
import {
countOpenIssues,
countPendingAgentActions,
countOpenPullRequests,
createPendingAgentActionIfAbsent,
getBounty,
listBountiesByRepo,
getContributorEvidence,
getLatestRepoGithubTotalsSnapshot,
getInstallation,
getIssue,
getPendingAgentAction,
getPullRequest,
Expand Down Expand Up @@ -159,9 +157,7 @@ import {
import { buildTestEvidenceReport, classifyTestCoverage, hasLocalTestEvidence, isCodeFile, isTestPath, TEST_FRAMEWORKS } from "../signals/test-evidence";
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, AUTONOMY_LEVELS, isActingAutonomyLevel, resolveAutonomy } from "../settings/autonomy";
import { resolveRepositorySettings } from "../settings/repository-settings";
import { AUTONOMY_LEVELS } from "../settings/autonomy";
import { MAX_FOCUS_MANIFEST_BYTES } from "../signals/focus-manifest";
import { loadPublicRepoFocusManifest, loadRepoFocusManifest } from "../signals/focus-manifest-loader";
import { buildPredictedGateVerdict, type PredictedGateVerdict } from "../rules/predicted-gate";
Expand All @@ -171,6 +167,7 @@ import { validateIdeaSubmission, buildTaskGraph, buildClaimPlan } from "../idea-
import { buildResultsPayload } from "../results-payload";
import { buildProgressSnapshot } from "../loop-progress";
import { evaluateEscalation } from "../loop-escalation";
import { buildAutomationStateResponse } from "../automation-state";
import { buildStructuralImprovementAssessment } from "../signals/improvement";
import { buildBoundaryTestGenerationFinding, buildBoundaryTestGenerationSpec } from "../signals/boundary-test-generation";
import { buildRepoDataQuality } from "../signals/data-quality";
Expand Down Expand Up @@ -4023,33 +4020,14 @@ export class LoopoverMcp {

// #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.
// The response shape is built by the shared buildAutomationStateResponse (#6742), also used by the REST mirror.
private async getAutomationState(input: { owner: string; repo: string }): Promise<ToolPayload> {
const fullName = `${input.owner}/${input.repo}`;
await this.requireRepoAccess(fullName);
const [repo, settings, pendingActionCount] = await Promise.all([
getRepository(this.env, fullName),
resolveRepositorySettings(this.env, fullName),
countPendingAgentActions(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) || (await isGlobalAgentFrozen(this.env)), agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun });
const permissionReadiness = resolveAgentPermissionReadiness({ autonomy, installationPermissions: installation?.permissions ?? null });
const data = await buildAutomationStateResponse(this.env, fullName);
return {
summary: `Agent automation for ${fullName}: mode=${mode}, ${actingActionClasses.length} acting class(es), ${pendingActionCount} 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,
},
summary: `Agent automation for ${fullName}: mode=${data.mode}, ${data.actingActionClasses.length} acting class(es), ${data.pendingActionCount} pending approval(s).`,
data,
};
}

Expand Down
94 changes: 94 additions & 0 deletions test/unit/mcp-cli-automation-state.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 { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { closeFixtureServer, run, startFixtureServer } from "./support/mcp-cli-harness";

const bin = join(process.cwd(), "packages/loopover-mcp/bin/loopover-mcp.js");
const FORBIDDEN_PUBLIC_TERMS = /wallet\s*[:=]\s*\S+|hotkey\s*[:=]\s*\S+|coldkey\s*[:=]\s*\S+|raw trust score is|your trust score|reward estimate is|estimated reward/i;

let client: Client;
let transport: StdioClientTransport;
let configDir: string;
let apiUrl: string;
let capturedRequests: Array<{ url: string; method: string }>;

async function connect() {
configDir = mkdtempSync(join(tmpdir(), "loopover-automation-state-"));
capturedRequests = [];
apiUrl = await startFixtureServer({
onApiRequest: (request) => {
if (request.url && request.url.includes("/automation-state")) {
capturedRequests.push({ url: request.url ?? "", method: request.method ?? "GET" });
}
},
});
transport = new StdioClientTransport({
command: "node",
args: [bin, "--stdio"],
env: {
...process.env,
LOOPOVER_CONFIG_DIR: configDir,
LOOPOVER_API_URL: apiUrl,
LOOPOVER_TOKEN: "session-token",
LOOPOVER_API_TIMEOUT_MS: "5000",
},
});
client = new Client({ name: "automation-state-test", version: "0.0.1" });
await client.connect(transport);
}

async function disconnect() {
await client.close().catch(() => undefined);
await closeFixtureServer();
if (configDir) rmSync(configDir, { recursive: true, force: true });
}

describe("loopover_get_automation_state stdio proxy (#6742)", () => {
beforeEach(connect);
afterEach(disconnect);

it("registers the tool in the stdio server tool list", async () => {
const { tools } = await client.listTools();
expect(tools.map((tool) => tool.name)).toContain("loopover_get_automation_state");
});

it("proxies the call to /automation-state via apiGet and returns the payload", async () => {
const result = await client.callTool({
name: "loopover_get_automation_state",
arguments: { owner: "owner", repo: "repo" },
});
expect(capturedRequests.length).toBe(1);
const captured = capturedRequests[0]!;
expect(captured.url).toContain("/v1/repos/owner/repo/automation-state");
expect(captured.method).toBe("GET");
expect(result.isError).toBeFalsy();
const text = JSON.stringify(result);
expect(text).not.toMatch(FORBIDDEN_PUBLIC_TERMS);
expect(text).toContain("owner/repo");
expect(text).toContain("permissionReadiness");
expect(text).toContain("live");
});

it("url-encodes an owner/repo with reserved characters before hitting the route", async () => {
await client.callTool({ name: "loopover_get_automation_state", arguments: { owner: "acme", repo: "widgets 7" } }).catch(() => undefined);
expect(capturedRequests[0]?.url).toContain("/v1/repos/acme/widgets%207/automation-state");
});

it("rejects a missing owner/repo at the input-schema boundary, never issuing a request", async () => {
const result = await client.callTool({ name: "loopover_get_automation_state", arguments: { owner: "", repo: "repo" } });
expect(result.isError).toBe(true);
expect(capturedRequests.length).toBe(0);
});

it("lists the tool via loopover-mcp tools", () => {
const payload = JSON.parse(run(["tools", "--json"])) as {
tools: Array<{ name: string; description: string }>;
};
const tool = payload.tools.find((entry) => entry.name === "loopover_get_automation_state");
expect(tool?.description).toMatch(/autonomy levels/i);
expect(tool?.description.trim().length).toBeGreaterThan(0);
});
});
15 changes: 7 additions & 8 deletions test/unit/mcp-tool-rename-aliases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
// (#6942 registered loopover_get_maintainer_lane without bumping this pin — live count became 72.)
// (#6756 registered the loopover_plan_idea_claims CLI mirror, taking the count from 72 to 73.)
// (#6734 registered the loopover_get_repo_outcome_patterns CLI mirror, taking the count from 74 to 75.)
// (#6742 registered the loopover_get_automation_state CLI mirror, taking the count from 75 to 76.)
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { mkdtempSync, rmSync } from "node:fs";
Expand Down Expand Up @@ -64,14 +65,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
});
afterEach(disconnect);

it("lists exactly 75 loopover_ tools and zero gittensory_-prefixed aliases", async () => {
it("lists exactly 76 loopover_ tools and zero gittensory_-prefixed aliases", async () => {
const { tools } = await client.listTools();
const names = tools.map((t) => t.name);
const primary = names.filter((n) => n.startsWith("loopover_"));
const legacy = names.filter((n) => n.startsWith("gittensory_"));
expect(primary.length).toBe(75);
expect(primary.length).toBe(76);
expect(legacy.length).toBe(0);
expect(names.length).toBe(75);
expect(names.length).toBe(76);
});

it("no loopover_ tool's description carries a stale deprecation notice", async () => {
Expand All @@ -83,17 +84,15 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
}
});

it("`loopover-mcp tools --json` reports the same 75-tool count the live server registers", async () => {
it("`loopover-mcp tools --json` reports the same 76-tool count the live server registers", async () => {
const { tools } = await client.listTools();
const payload = JSON.parse(run(["tools", "--json"])) as {
count: number;
tools: Array<{ name: string }>;
};
expect(payload.count).toBe(tools.length);
expect(payload.count).toBe(75);
expect([...payload.tools.map((t) => t.name)].sort()).toEqual(
[...tools.map((t) => t.name)].sort(),
);
expect(payload.count).toBe(76);
expect([...payload.tools.map((t) => t.name)].sort()).toEqual([...tools.map((t) => t.name)].sort());
});
});

Expand Down
Loading