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
33 changes: 33 additions & 0 deletions packages/loopover-mcp/bin/loopover-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
{
Expand Down
81 changes: 81 additions & 0 deletions test/unit/mcp-cli-agent-audit-feed.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";

// #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<void> };
};

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

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);
}
});
});
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 @@ -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";
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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(),
);
Expand Down