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
21 changes: 21 additions & 0 deletions packages/loopover-mcp/bin/loopover-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
{
Expand Down
80 changes: 80 additions & 0 deletions test/unit/mcp-cli-automation-state.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> };
};

let tempDir = "";
const capturedRequests: Array<{ url: string; method: string }> = [];
const loaded = new Map<string, BinModule>();

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);
}
});
});
8 changes: 8 additions & 0 deletions test/unit/mcp-output-schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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<string, unknown>);
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<string, unknown>);
expect(automationStateProps).toEqual(
expect.arrayContaining(["repoFullName", "autonomy", "mode", "permissionReadiness", "actingActionClasses", "pendingActionCount"]),
);
});

it("preserves the full tool inventory while adding output schemas", async () => {
Expand Down
11 changes: 6 additions & 5 deletions test/unit/mcp-tool-rename-aliases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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(),
);
Expand Down