diff --git a/packages/loopover-mcp/bin/loopover-mcp.ts b/packages/loopover-mcp/bin/loopover-mcp.ts index 4d2d28e96e..068e7268ef 100644 --- a/packages/loopover-mcp/bin/loopover-mcp.ts +++ b/packages/loopover-mcp/bin/loopover-mcp.ts @@ -1109,6 +1109,12 @@ const STDIO_TOOL_DESCRIPTORS = [ description: "Route a freeform idea through the intake bridge into a claim/code/submit-loop plan (#4799): validates the submission, builds the scored task-graph, and returns which constituent issues the loop can claim now vs. defer vs. skip — dependency-ordered so a prerequisite is always claimed before its dependents. Deterministic and source-free; it decides what to claim, it does not claim or run anything. Computed in-process; no API round-trip.", }, + { + name: "loopover_get_automation_state", + category: "agent", + description: + "Return a repo's derived agent automation state: the per-action autonomy levels, kill-switch / dry-run mode, GitHub write-permission readiness, and how many approval-gated actions are awaiting a maintainer decision. Metadata-only; takes owner and repo.", + }, { name: "loopover_check_issue_slop", category: "review", @@ -1647,6 +1653,21 @@ registerStdioTool( }, ); +// #7752: local stdio mirror of the remote loopover_get_automation_state tool and the `maintain automation-state` +// CLI. All three proxy the same GET {repoBase}/automation-state route so the derived mode / permission-readiness / +// acting-classes / pending-count view stays computed one way and cannot drift across surfaces. +registerStdioTool( + "loopover_get_automation_state", + { + description: stdioToolDescription("loopover_get_automation_state"), + inputSchema: ownerRepoShape, + }, + async ({ owner, repo }: any) => { + const prefix = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; + return toolResult("LoopOver agent automation state.", await apiGet(`${prefix}/automation-state`)); + }, +); + registerStdioTool( "loopover_get_issue_quality", { diff --git a/test/unit/mcp-cli-automation-state.test.ts b/test/unit/mcp-cli-automation-state.test.ts new file mode 100644 index 0000000000..c5bfe1db1e --- /dev/null +++ b/test/unit/mcp-cli-automation-state.test.ts @@ -0,0 +1,80 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { closeFixtureServer, startFixtureServer } from "./support/mcp-cli-harness"; + +// #7752: in-process coverage for the loopover_get_automation_state stdio tool. +// Same #7764 entrypoint-guard pattern as mcp-cli-gate-config-effective — import .ts, hold exported `server`, +// connect InMemoryTransport so v8/Codecov attributes registerStdioTool. +const MODULES = ["../../packages/loopover-mcp/bin/loopover-mcp.ts"] as const; + +type BinModule = { + server: { connect: (transport: unknown) => Promise }; +}; + +let tempDir = ""; +const capturedRequests: Array<{ url: string; method: string }> = []; +const loaded = new Map(); + +beforeAll(async () => { + tempDir = mkdtempSync(join(tmpdir(), "loopover-automation-state-")); + const apiUrl = await startFixtureServer({ + onApiRequest: (request) => { + if (request.url && request.url.includes("/automation-state")) { + capturedRequests.push({ url: request.url ?? "", method: request.method ?? "GET" }); + } + }, + }); + process.env.LOOPOVER_API_URL = apiUrl; + process.env.LOOPOVER_API_TOKEN = "in-process-token"; + process.env.LOOPOVER_API_TIMEOUT_MS = "2000"; + process.env.LOOPOVER_CONFIG_DIR = tempDir; + process.env.LOOPOVER_SKIP_NPM_VERSION_CHECK = "1"; + for (const specifier of MODULES) { + loaded.set(specifier, (await import(specifier)) as unknown as BinModule); + } +}, 120_000); + +afterAll(async () => { + await closeFixtureServer(); + if (tempDir) rmSync(tempDir, { recursive: true, force: true }); + delete process.env.LOOPOVER_API_URL; + delete process.env.LOOPOVER_API_TOKEN; + delete process.env.LOOPOVER_CONFIG_DIR; + delete process.env.LOOPOVER_SKIP_NPM_VERSION_CHECK; +}); + +describe("bin loopover_get_automation_state stdio tool (in-process, #7752)", () => { + it.each(MODULES)("registers and proxies GET .../automation-state — %s", async (specifier) => { + capturedRequests.length = 0; + const mod = loaded.get(specifier)!; + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await mod.server.connect(serverTransport); + const client = new Client({ name: "automation-state-test", version: "0.1.0" }, { capabilities: {} }); + await client.connect(clientTransport); + try { + const { tools } = await client.listTools(); + const tool = tools.find((entry) => entry.name === "loopover_get_automation_state"); + expect(tool).toBeDefined(); + expect(tool?.description).toMatch(/automation state/i); + + 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).toContain("permissionReadiness"); + expect(text).toContain("actingActionClasses"); + } finally { + await client.close().catch(() => undefined); + } + }); +}); diff --git a/test/unit/mcp-output-schemas.test.ts b/test/unit/mcp-output-schemas.test.ts index c7aaf7cd9b..bc3f775b10 100644 --- a/test/unit/mcp-output-schemas.test.ts +++ b/test/unit/mcp-output-schemas.test.ts @@ -18,6 +18,7 @@ const TOOLS_WITH_OUTPUT_SCHEMA = [ "loopover_get_activation_preview", "loopover_get_live_gate_thresholds", "loopover_get_gate_config_effective", + "loopover_get_automation_state", "loopover_get_repo_focus_manifest", "loopover_get_label_audit", "loopover_get_maintainer_lane", @@ -164,6 +165,13 @@ describe("MCP output schema discovery", () => { const focusManifest = byName.get("loopover_get_repo_focus_manifest"); const focusManifestProps = Object.keys((focusManifest?.outputSchema?.properties ?? {}) as Record); expect(focusManifestProps).toEqual(expect.arrayContaining(["repoFullName", "manifest", "policy"])); + + // #7752 — the derived agent automation-state read surface (mode / readiness / acting-classes / pending count). + const automationState = byName.get("loopover_get_automation_state"); + const automationStateProps = Object.keys((automationState?.outputSchema?.properties ?? {}) as Record); + expect(automationStateProps).toEqual( + expect.arrayContaining(["repoFullName", "autonomy", "mode", "permissionReadiness", "actingActionClasses", "pendingActionCount"]), + ); }); it("preserves the full tool inventory while adding output schemas", async () => { diff --git a/test/unit/mcp-tool-rename-aliases.test.ts b/test/unit/mcp-tool-rename-aliases.test.ts index cc89b6f112..d110b62754 100644 --- a/test/unit/mcp-tool-rename-aliases.test.ts +++ b/test/unit/mcp-tool-rename-aliases.test.ts @@ -31,6 +31,7 @@ // (#7800 registered the loopover_get_gate_config_effective remote+stdio tool, taking the count from 86 to 87.) // (#7797 registered the loopover_get_ams_miner_cohort remote+stdio tool, taking the count from 87 to 88.) // (#7808 registered the loopover_get_repo_focus_manifest remote+stdio tool, taking the count from 88 to 89.) +// (#7752 registered the loopover_get_automation_state stdio tool, taking the count from 89 to 90.) import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { mkdtempSync, rmSync } from "node:fs"; @@ -77,14 +78,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { }); afterEach(disconnect); - it("lists exactly 89 loopover_ tools and zero gittensory_-prefixed aliases", async () => { + it("lists exactly 90 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(89); + expect(primary.length).toBe(90); expect(legacy.length).toBe(0); - expect(names.length).toBe(89); + expect(names.length).toBe(90); }); it("no loopover_ tool's description carries a stale deprecation notice", async () => { @@ -96,14 +97,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { } }); - it("`loopover-mcp tools --json` reports the same 89-tool count the live server registers", async () => { + it("`loopover-mcp tools --json` reports the same 90-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(89); + expect(payload.count).toBe(90); expect([...payload.tools.map((t) => t.name)].sort()).toEqual( [...tools.map((t) => t.name)].sort(), );