diff --git a/packages/loopover-mcp/bin/loopover-mcp.ts b/packages/loopover-mcp/bin/loopover-mcp.ts index aa89ef236c..f2d448b28b 100644 --- a/packages/loopover-mcp/bin/loopover-mcp.ts +++ b/packages/loopover-mcp/bin/loopover-mcp.ts @@ -367,6 +367,15 @@ const skippedPrAuditShape = { limit: z.number().int().positive().optional(), }; +// #7757: stdio mirror of the remote loopover_get_agent_audit_feed shape (src/mcp/server.ts) -- owner/repo plus +// the same optional since / limit (1..200) query filters the maintain audit-feed CLI and the route accept. +const auditFeedShape = { + owner: z.string().min(1), + repo: z.string().min(1), + since: z.string().min(1).optional(), + limit: z.number().int().positive().max(200).optional(), +}; + const ownerRepoPullShape = { owner: z.string().min(1), repo: z.string().min(1), @@ -1041,6 +1050,12 @@ const STDIO_TOOL_DESCRIPTORS = [ category: "maintainer", description: "Return the maintainer queue-noise triage report for a repo: a noise score/level, the specific noise sources to clear first, and recommended maintainer actions. Maintainer-authenticated; advisory only.", }, + { + name: "loopover_get_agent_audit_feed", + category: "agent", + description: + "Return a repo's agent audit feed: executed actions (agent.action.*) and approval-queue decisions (accepted/rejected), newest first. Read-only and public-safe (action posture only). Maintainer access required.", + }, { name: "loopover_get_ams_miner_cohort", category: "maintainer", @@ -1671,6 +1686,24 @@ registerStdioTool( }, ); +// #7757: stdio mirror of the remote loopover_get_agent_audit_feed + the `maintain audit-feed` CLI. Thin GET +// proxy of the same {repoBase}/agent/audit-feed route (optional since/limit forwarded verbatim; the route +// validates and applies defaults). Same ownerRepoShape+apiGet pattern as maintainer_noise. +registerStdioTool( + "loopover_get_agent_audit_feed", + { + description: stdioToolDescription("loopover_get_agent_audit_feed"), + inputSchema: auditFeedShape, + }, + async ({ owner, repo, since, limit }: any) => { + const query = new URLSearchParams(); + if (since !== undefined) query.set("since", String(since)); + if (limit !== undefined) query.set("limit", String(limit)); + const prefix = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; + return toolResult(`LoopOver agent audit feed for ${owner}/${repo}.`, await apiGet(`${prefix}/agent/audit-feed${query.size > 0 ? `?${query}` : ""}`)); + }, +); + registerStdioTool( "loopover_get_ams_miner_cohort", { diff --git a/test/unit/mcp-cli-agent-audit-feed.test.ts b/test/unit/mcp-cli-agent-audit-feed.test.ts new file mode 100644 index 0000000000..3c77cb7fde --- /dev/null +++ b/test/unit/mcp-cli-agent-audit-feed.test.ts @@ -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"; + +// #7757: in-process coverage for the loopover_get_agent_audit_feed stdio tool. Same #7764 entrypoint-guard +// pattern as mcp-cli-repo-focus-manifest -- import the .ts, hold the exported `server`, connect an +// InMemoryTransport so v8/Codecov attributes the registerStdioTool block (a subprocess spawn can't be +// instrumented). Exercises both the with- and without-query-filter paths. +const MODULES = ["../../packages/loopover-mcp/bin/loopover-mcp.ts"] as const; + +type BinModule = { + server: { connect: (transport: unknown) => Promise }; +}; + +let tempDir = ""; +const auditGets: Array<{ url: string; method: string }> = []; +const loaded = new Map(); + +beforeAll(async () => { + tempDir = mkdtempSync(join(tmpdir(), "loopover-agent-audit-feed-")); + const apiUrl = await startFixtureServer({ + onApiRequest: (r) => { + if (r.url && r.url.includes("/agent/audit-feed")) auditGets.push({ url: r.url ?? "", method: r.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_agent_audit_feed stdio tool (in-process, #7757)", () => { + it.each(MODULES)("proxies GET .../agent/audit-feed, forwarding since + limit — %s", async (specifier) => { + auditGets.length = 0; + const mod = loaded.get(specifier)!; + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await mod.server.connect(serverTransport); + const client = new Client({ name: "agent-audit-feed-test", version: "0.1.0" }, { capabilities: {} }); + await client.connect(clientTransport); + try { + const tool = (await client.listTools()).tools.find((entry) => entry.name === "loopover_get_agent_audit_feed"); + expect(tool).toBeDefined(); + expect(tool?.description).toMatch(/agent audit feed|approval-queue/i); + + const filtered = await client.callTool({ + name: "loopover_get_agent_audit_feed", + arguments: { owner: "owner", repo: "repo", since: "2026-05-01T00:00:00.000Z", limit: 5 }, + }); + expect(filtered.isError).toBeFalsy(); + const url = auditGets.at(-1)!.url; + expect(url).toContain("/v1/repos/owner/repo/agent/audit-feed?"); + expect(url).toContain("since=2026-05-01T00%3A00%3A00.000Z"); + expect(url).toContain("limit=5"); + expect(JSON.stringify(filtered)).toContain("events"); + + // No filters -> the query string is omitted entirely (query.size === 0 branch). + const unfiltered = await client.callTool({ name: "loopover_get_agent_audit_feed", arguments: { owner: "owner", repo: "repo" } }); + expect(unfiltered.isError).toBeFalsy(); + expect(auditGets.at(-1)!.url).toBe("/v1/repos/owner/repo/agent/audit-feed"); + } finally { + await client.close().catch(() => undefined); + } + }); +}); diff --git a/test/unit/mcp-tool-rename-aliases.test.ts b/test/unit/mcp-tool-rename-aliases.test.ts index bb1ab73809..74bc5c6660 100644 --- a/test/unit/mcp-tool-rename-aliases.test.ts +++ b/test/unit/mcp-tool-rename-aliases.test.ts @@ -37,6 +37,7 @@ // (#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.) +// (#7757 registered the loopover_get_agent_audit_feed stdio tool, taking the count from 95 to 96.) import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { mkdtempSync, rmSync } from "node:fs"; @@ -83,14 +84,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { }); afterEach(disconnect); - it("lists exactly 95 loopover_ tools and zero gittensory_-prefixed aliases", async () => { + it("lists exactly 96 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(95); + expect(primary.length).toBe(96); expect(legacy.length).toBe(0); - expect(names.length).toBe(95); + expect(names.length).toBe(96); }); it("no loopover_ tool's description carries a stale deprecation notice", async () => { @@ -102,14 +103,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { } }); - it("`loopover-mcp tools --json` reports the same 95-tool count the live server registers", async () => { + it("`loopover-mcp tools --json` reports the same 96-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(95); + expect(payload.count).toBe(96); expect([...payload.tools.map((t) => t.name)].sort()).toEqual( [...tools.map((t) => t.name)].sort(), );