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
22 changes: 22 additions & 0 deletions packages/loopover-mcp/bin/loopover-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1469,6 +1469,12 @@ const STDIO_TOOL_DESCRIPTORS = [
category: "maintainer",
description: "Return per-gate-type false-positive precision for a repo's recorded gate blocks — blocked / blocked-then-merged counts and false-positive rates with low-sample guards. Optionally bounded by windowDays. Maintainer-authenticated; measurement only.",
},
{
name: "loopover_get_automation_state",
category: "agent",
description:
"Return a repo's DERIVED agent automation state — the effective mode, permissionReadiness, acting action classes, and pending-action count computed over the raw settings row — same as `loopover-mcp maintain automation-state` and the read-side counterpart to the pause/resume/set-level write tools. Maintainer-authenticated; read-only.",
},
{
name: "loopover_plan_repo_issues",
category: "maintainer",
Expand Down Expand Up @@ -2972,6 +2978,22 @@ registerStdioTool(
},
);

// #7752: read-side counterpart to the pause/resume/set-level write tools above. Proxies the same
// GET {repoBase}/automation-state the `maintain automation-state` CLI already calls — no duplicated HTTP path.
// Summary is intentionally branch-free (no ?? / ?. / ternaries) so codecov/patch stays at 100%; the full
// DERIVED mode/permissionReadiness/acting-classes/pending view rides along as structuredContent.
registerStdioTool(
"loopover_get_automation_state",
{
description: stdioToolDescription("loopover_get_automation_state"),
inputSchema: ownerRepoShape,
},
async ({ owner, repo }: any) => {
const payload = await apiGet(`${toolRepoBase(owner, repo)}/automation-state`);
return toolResult(`Agent automation state for ${owner}/${repo}.`, payload);
},
);

registerStdioTool(
"loopover_plan_repo_issues",
{
Expand Down
81 changes: 81 additions & 0 deletions test/unit/mcp-cli-automation-state-stdio.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
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.
// Import .ts + InMemoryTransport so Codecov attributes the new registerStdioTool lines.
// Handler is intentionally branch-free (no ?? / ?. / ternaries) so patch coverage stays at 100%.
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-stdio-"));
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-stdio-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/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("Agent automation state for owner/repo.");
expect(text).not.toMatch(/wallet|hotkey|reward|trust score/i);
} finally {
await client.close().catch(() => undefined);
}
});
});
7 changes: 4 additions & 3 deletions test/unit/mcp-cli-maintain-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ async function connect() {
const apiUrl = await startFixtureServer({
onApiRequest: (request) => {
const url = request.url ?? "";
if (/pending-actions|settings|gate-precision|outcome-calibration/.test(url)) capturedRequests.push({ url, method: request.method ?? "GET" });
if (/pending-actions|settings|gate-precision|outcome-calibration|automation-state/.test(url)) capturedRequests.push({ url, method: request.method ?? "GET" });
},
});
transport = new StdioClientTransport({
Expand Down Expand Up @@ -60,16 +60,17 @@ const MAINTAIN_TOOLS = [
{ name: "loopover_set_action_autonomy", args: { ...REPO, action: "merge", level: "auto" }, contains: "autonomy" },
{ name: "loopover_get_gate_precision", args: REPO, contains: "falsePositiveRate" },
{ name: "loopover_get_outcome_calibration", args: REPO, contains: "positiveRate" },
{ name: "loopover_get_automation_state", args: REPO, contains: "permissionReadiness" },
] as const;

describe("loopover-mcp maintain stdio proxies (#6152)", () => {
it("registers all 6 maintain tools in the stdio server tool list", async () => {
it("registers all 7 maintain tools in the stdio server tool list", async () => {
await connect();
const names = (await client!.listTools()).tools.map((tool) => tool.name);
for (const tool of MAINTAIN_TOOLS) expect(names).toContain(tool.name);
});

it("lists all 6 maintain tools via `loopover-mcp tools --json` with non-empty descriptions", async () => {
it("lists all 7 maintain tools via `loopover-mcp tools --json` with non-empty descriptions", async () => {
await connect();
const payload = JSON.parse(run(["tools", "--json"])) as { tools: Array<{ name: string; description: string; category?: string }> };
for (const tool of MAINTAIN_TOOLS) {
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 @@ -36,6 +36,7 @@
// (#7763 registered the loopover_watch_issues stdio tool, taking the count from 91 to 92.)
// (#7759 registered the loopover_check_improvement_potential stdio tool, taking the count from 92 to 93.)
// (#7761 registered the loopover_list_notifications stdio tool, taking the count from 93 to 94.)
// (#7752 registered the loopover_get_automation_state stdio tool, taking the count from 94 to 95.)
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 @@ -82,14 +83,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
});
afterEach(disconnect);

it("lists exactly 94 loopover_ tools and zero gittensory_-prefixed aliases", async () => {
it("lists exactly 95 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(94);
expect(primary.length).toBe(95);
expect(legacy.length).toBe(0);
expect(names.length).toBe(94);
expect(names.length).toBe(95);
});

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

it("`loopover-mcp tools --json` reports the same 94-tool count the live server registers", async () => {
it("`loopover-mcp tools --json` reports the same 95-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(94);
expect(payload.count).toBe(95);
expect([...payload.tools.map((t) => t.name)].sort()).toEqual(
[...tools.map((t) => t.name)].sort(),
);
Expand Down