diff --git a/packages/gittensory-miner/README.md b/packages/gittensory-miner/README.md index 1528c4e340..a03ced60c7 100644 --- a/packages/gittensory-miner/README.md +++ b/packages/gittensory-miner/README.md @@ -139,8 +139,9 @@ It exposes these read-only tools: - `gittensory_miner_ping` (#5153) — a health check returning a static `{ "status": "ok", "tool": "gittensory_miner_ping" }` object. Reads no AMS state, takes no arguments. - `gittensory_miner_get_portfolio_dashboard` (#5155) — the per-repo portfolio-queue backlog dashboard: status counts (queued / in_progress / done), totals, and the oldest-queued age. Wraps `collectPortfolioDashboard()` (no new logic) — the same data `gittensory-miner queue dashboard --json` prints locally. Read-only, takes no arguments. - `gittensory_miner_list_claims` (#5156) — lists the local claim ledger (repo, issue number, status, claimed-at, note) via `listClaims()`. Optional `repoFullName` / `status` filters pass through to the query. Read-only — exposes no claim/release mutation. +- `gittensory_miner_get_audit_feed` (#5158) — read-only, metadata-only event-ledger audit feed (`eventType`, `repoFullName`, `outcome`, `actor`, `detail`, `createdAt`). Wraps `collectEventLedgerAuditFeed()` with the same filters as `gittensory-miner ledger list` (`--repo`, `--since`, `--type`). Never returns `payload_json` or other raw ledger columns. -Further AMS-state-reading tools (status/doctor diagnostics, run-state, event/governor ledgers) land as follow-up PRs on top of this server. +Further AMS-state-reading tools (status/doctor diagnostics, run-state, governor ledgers) land as follow-up PRs on top of this server. ## Version check diff --git a/packages/gittensory-miner/bin/gittensory-miner-mcp.d.ts b/packages/gittensory-miner/bin/gittensory-miner-mcp.d.ts index 25b99e0916..9bb05a2f04 100644 --- a/packages/gittensory-miner/bin/gittensory-miner-mcp.d.ts +++ b/packages/gittensory-miner/bin/gittensory-miner-mcp.d.ts @@ -1,4 +1,5 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { EventLedger } from "../lib/event-ledger.js"; /** The static, non-secret payload the gittensory_miner_ping tool always returns, independent of input. */ export const MINER_PING_STATUS: { status: "ok"; tool: "gittensory_miner_ping" }; @@ -19,10 +20,13 @@ export interface MinerMcpServerOptions { }; /** Override the clock used for the oldest-queued age (defaults to Date.now()); injection seam for tests. */ nowMs?: number; + /** Override the event-ledger opener (defaults to initEventLedger); injection seam for tests. */ + initEventLedger?: () => EventLedger; } /** - * Build the miner MCP server with its tools registered (gittensory_miner_ping, gittensory_miner_get_portfolio_dashboard). + * Build the miner MCP server with its tools registered (gittensory_miner_ping, + * gittensory_miner_get_portfolio_dashboard, gittensory_miner_list_claims, gittensory_miner_get_audit_feed). * `options` supplies test injection seams; production callers pass nothing. */ export function createMinerMcpServer(options?: MinerMcpServerOptions): McpServer; diff --git a/packages/gittensory-miner/bin/gittensory-miner-mcp.js b/packages/gittensory-miner/bin/gittensory-miner-mcp.js index bb1b249e84..8fce54d1c9 100755 --- a/packages/gittensory-miner/bin/gittensory-miner-mcp.js +++ b/packages/gittensory-miner/bin/gittensory-miner-mcp.js @@ -4,9 +4,14 @@ import { fileURLToPath } from "node:url"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; +import { CLAIM_STATUSES, openClaimLedger } from "../lib/claim-ledger.js"; +import { + collectEventLedgerAuditFeed, + normalizeAuditFeedMcpFilter, +} from "../lib/event-ledger-cli.js"; +import { initEventLedger } from "../lib/event-ledger.js"; import { collectPortfolioDashboard } from "../lib/portfolio-dashboard.js"; import { initPortfolioQueueStore } from "../lib/portfolio-queue.js"; -import { CLAIM_STATUSES, openClaimLedger } from "../lib/claim-ledger.js"; // MCP stdio server for @jsonbored/gittensory-miner (scaffold #5153). Mirrors the packages/gittensory-mcp // harness (MCP SDK server + stdio transport). Tools: @@ -15,19 +20,29 @@ import { CLAIM_STATUSES, openClaimLedger } from "../lib/claim-ledger.js"; // existing collectPortfolioDashboard aggregator (no new logic; same data as `queue dashboard --json`). // - gittensory_miner_list_claims (#5156): read-only listing of the local claim ledger (optional repo/status // filter passed through to listClaims); exposes no claim/release mutation. -// Remaining AMS-state-reading tools (status/doctor, run-state, event/governor ledgers, etc.) land as follow-ups. +// - gittensory_miner_get_audit_feed (#5158): read-only metadata-only event-ledger audit feed via +// collectEventLedgerAuditFeed() (same filters as `ledger list`; never returns payload_json). +// Remaining AMS-state-reading tools (status/doctor, run-state, governor ledgers, etc.) land as follow-ups. // Read the version from this package's own package.json (always shipped) rather than a hand-synced // literal, so a release bump never has a second place to forget -- same approach as the mcp harness. const ownPackageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")); +/** Optional filters accepted by gittensory_miner_get_audit_feed (#5158). */ +const auditFeedInputSchema = { + repoFullName: z.string().min(1).optional(), + since: z.number().int().nonnegative().optional(), + type: z.string().min(1).optional(), +}; + /** The static, non-secret payload the ping tool always returns, independent of any input or AMS state. */ export const MINER_PING_STATUS = { status: "ok", tool: "gittensory_miner_ping" }; /** * Build the miner MCP server with its tools registered. `options.initPortfolioQueue`, `options.openClaimLedger`, - * and `options.nowMs` are injection seams for tests (default to the real stores and the wall clock); the ping tool - * needs none. Each store-backed tool opens its store only when invoked and closes any store it opened. + * `options.initEventLedger`, and `options.nowMs` are injection seams for tests (default to the real stores and the + * wall clock); the ping tool needs none. Each store-backed tool opens its store only when invoked and closes any + * store it opened. */ export function createMinerMcpServer(options = {}) { const server = new McpServer({ name: "gittensory-miner", version: ownPackageJson.version }); @@ -86,6 +101,40 @@ export function createMinerMcpServer(options = {}) { } }, ); + server.registerTool( + "gittensory_miner_get_audit_feed", + { + description: + "Read-only, metadata-only audit feed from the local append-only event ledger: eventType, repoFullName, " + + "outcome, actor, detail, and createdAt per row. Wraps collectEventLedgerAuditFeed() (no new query logic) — " + + "the same read filters as `gittensory-miner ledger list` (--repo, --since, --type). Never returns " + + "payload_json or other raw ledger columns; never writes to the ledger.", + inputSchema: auditFeedInputSchema, + }, + async (input) => { + const ownsLedger = options.initEventLedger === undefined; + const eventLedger = (options.initEventLedger ?? initEventLedger)(); + try { + const filter = normalizeAuditFeedMcpFilter(input ?? {}); + const feed = collectEventLedgerAuditFeed(eventLedger, filter); + return { content: [{ type: "text", text: JSON.stringify(feed) }] }; + } catch (error) { + return { + content: [ + { + type: "text", + text: JSON.stringify({ + error: error instanceof Error ? error.message : String(error), + }), + }, + ], + isError: true, + }; + } finally { + if (ownsLedger) eventLedger.close(); + } + }, + ); return server; } diff --git a/packages/gittensory-miner/lib/event-ledger-cli.d.ts b/packages/gittensory-miner/lib/event-ledger-cli.d.ts index ce6c8af0b6..1482434989 100644 --- a/packages/gittensory-miner/lib/event-ledger-cli.d.ts +++ b/packages/gittensory-miner/lib/event-ledger-cli.d.ts @@ -16,6 +16,51 @@ export function filterLedgerEvents( options?: { type?: string | null }, ): LedgerEntry[]; +export const AUDIT_FEED_ENTRY_FIELDS: readonly [ + "eventType", + "repoFullName", + "outcome", + "actor", + "detail", + "createdAt", +]; + +export function projectLedgerEventToAuditFeedEntry(entry: LedgerEntry): { + eventType: string; + repoFullName: string | null; + outcome: string | null; + actor: string | null; + detail: string | null; + createdAt: string; +}; + +export type AuditFeedMcpFilterInput = { + repoFullName?: string | null; + since?: number | null; + type?: string | null; +}; + +export function normalizeAuditFeedMcpFilter(input?: AuditFeedMcpFilterInput): { + repoFullName: string | null; + since: number | null; + type: string | null; +}; + +export function collectEventLedgerAuditFeed( + eventLedger: EventLedger, + filter?: { repoFullName?: string | null; since?: number | null; type?: string | null }, +): { + repoFullName?: string; + events: Array<{ + eventType: string; + repoFullName: string | null; + outcome: string | null; + actor: string | null; + detail: string | null; + createdAt: string; + }>; +}; + export function renderLedgerTable(events: LedgerEntry[]): string; export function runLedgerList( diff --git a/packages/gittensory-miner/lib/event-ledger-cli.js b/packages/gittensory-miner/lib/event-ledger-cli.js index 23f943497b..1f913ce668 100644 --- a/packages/gittensory-miner/lib/event-ledger-cli.js +++ b/packages/gittensory-miner/lib/event-ledger-cli.js @@ -71,6 +71,75 @@ export function filterLedgerEvents(events, options = {}) { return events.filter((entry) => entry.type === type); } +/** Metadata-only audit-feed columns exposed by the MCP tool (#5158). */ +export const AUDIT_FEED_ENTRY_FIELDS = Object.freeze([ + "eventType", + "repoFullName", + "outcome", + "actor", + "detail", + "createdAt", +]); + +function optionalMetadataString(value) { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed || null; +} + +/** Project one ledger row to the public, metadata-only audit-feed shape — never returns payload_json. */ +export function projectLedgerEventToAuditFeedEntry(entry) { + const payload = + entry?.payload && typeof entry.payload === "object" && !Array.isArray(entry.payload) ? entry.payload : {}; + return { + eventType: entry.type, + repoFullName: entry.repoFullName, + outcome: optionalMetadataString(payload.outcome), + actor: optionalMetadataString(payload.actor), + detail: optionalMetadataString(payload.detail), + createdAt: entry.createdAt, + }; +} + +/** Normalize optional MCP/JSON filter args into the shape `ledger list` already uses (#5158). */ +export function normalizeAuditFeedMcpFilter(input = {}) { + if (input === null || typeof input !== "object" || Array.isArray(input)) { + throw new Error("filter must be an object"); + } + const filter = { repoFullName: null, since: null, type: null }; + if (input.repoFullName !== undefined && input.repoFullName !== null) { + const repo = parseRepoArg(String(input.repoFullName), "repoFullName must be in owner/repo form."); + if ("error" in repo) throw new Error(repo.error); + filter.repoFullName = repo.repoFullName; + } + if (input.since !== undefined && input.since !== null) { + const parsedSince = parseSinceArg(String(input.since)); + if ("error" in parsedSince) throw new Error(parsedSince.error); + filter.since = parsedSince.since; + } + if (input.type !== undefined && input.type !== null) { + const trimmed = String(input.type).trim(); + if (!trimmed) throw new Error("type must be a non-empty string."); + filter.type = trimmed; + } + return filter; +} + +/** Read-only audit feed shared by the MCP audit-feed tool (#5158). */ +export function collectEventLedgerAuditFeed(eventLedger, filter = {}) { + const events = filterLedgerEvents( + eventLedger.readEvents({ + repoFullName: filter.repoFullName, + since: filter.since, + }), + { type: filter.type }, + ); + return { + ...(filter.repoFullName ? { repoFullName: filter.repoFullName } : {}), + events: events.map(projectLedgerEventToAuditFeedEntry), + }; +} + function display(value) { if (value === null || value === undefined) return "-"; return String(value); diff --git a/test/unit/miner-mcp-audit-feed.test.ts b/test/unit/miner-mcp-audit-feed.test.ts new file mode 100644 index 0000000000..c6c6745d8d --- /dev/null +++ b/test/unit/miner-mcp-audit-feed.test.ts @@ -0,0 +1,258 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createMinerMcpServer } from "../../packages/gittensory-miner/bin/gittensory-miner-mcp.js"; +import { + AUDIT_FEED_ENTRY_FIELDS, + collectEventLedgerAuditFeed, + normalizeAuditFeedMcpFilter, + projectLedgerEventToAuditFeedEntry, +} from "../../packages/gittensory-miner/lib/event-ledger-cli.js"; +import { + closeDefaultEventLedger, + initEventLedger, +} from "../../packages/gittensory-miner/lib/event-ledger.js"; + +type Content = { content: Array<{ type: string; text?: string }>; isError?: boolean }; + +const roots: string[] = []; +const ledgers: Array<{ close(): void }> = []; + +function tempLedger() { + const root = mkdtempSync(join(tmpdir(), "gittensory-miner-mcp-audit-feed-")); + roots.push(root); + const ledger = initEventLedger(join(root, "event-ledger.sqlite3")); + ledgers.push(ledger); + return ledger; +} + +afterEach(() => { + for (const ledger of ledgers.splice(0)) ledger.close(); + closeDefaultEventLedger(); + vi.restoreAllMocks(); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +async function connectedClient(eventLedger: ReturnType): Promise { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "miner-mcp-audit-feed-test", version: "0.0.0" }); + await Promise.all([ + createMinerMcpServer({ initEventLedger: () => eventLedger }).connect(serverTransport), + client.connect(clientTransport), + ]); + return client; +} + +function toolText(result: Content): string { + const first = result.content[0]; + if (!first || first.type !== "text" || typeof first.text !== "string") { + throw new Error("expected a single text content block"); + } + return first.text; +} + +function seedEvents(eventLedger: ReturnType) { + eventLedger.appendEvent({ + type: "discovered_issue", + repoFullName: "acme/widgets", + payload: { issueNumber: 1, secretBlob: "must-not-leak" }, + }); + eventLedger.appendEvent({ + type: "manage_pr_update", + repoFullName: "acme/widgets", + payload: { + prNumber: 7, + outcome: "ready", + actor: "miner-bot", + detail: "gate passed", + }, + }); + eventLedger.appendEvent({ + type: "manage_pr_update", + repoFullName: "acme/other", + payload: { prNumber: 3, outcome: "needs-work" }, + }); +} + +describe("gittensory_miner_get_audit_feed (#5158)", () => { + it("is registered on the miner MCP server", async () => { + const ledger = tempLedger(); + const client = await connectedClient(ledger); + const { tools } = await client.listTools(); + expect(tools.map((tool) => tool.name)).toContain("gittensory_miner_get_audit_feed"); + }); + + it("returns metadata-only audit rows with repo, since, and type filters", async () => { + const ledger = tempLedger(); + seedEvents(ledger); + const client = await connectedClient(ledger); + const result = (await client.callTool({ + name: "gittensory_miner_get_audit_feed", + arguments: { repoFullName: "acme/widgets", since: 1, type: "manage_pr_update" }, + })) as Content; + const payload = JSON.parse(toolText(result)); + expect(payload.repoFullName).toBe("acme/widgets"); + expect(payload.events).toEqual([ + { + eventType: "manage_pr_update", + repoFullName: "acme/widgets", + outcome: "ready", + actor: "miner-bot", + detail: "gate passed", + createdAt: expect.any(String), + }, + ]); + }); + + it("returns an empty events array for an empty ledger", async () => { + const ledger = tempLedger(); + const client = await connectedClient(ledger); + const result = (await client.callTool({ + name: "gittensory_miner_get_audit_feed", + arguments: {}, + })) as Content; + expect(JSON.parse(toolText(result))).toEqual({ events: [] }); + }); + + it("is structurally identical to collectEventLedgerAuditFeed() — the wrapper adds no drift (invariant)", async () => { + const ledger = tempLedger(); + seedEvents(ledger); + const filter = normalizeAuditFeedMcpFilter({ repoFullName: "acme/widgets" }); + const client = await connectedClient(ledger); + const result = (await client.callTool({ + name: "gittensory_miner_get_audit_feed", + arguments: { repoFullName: "acme/widgets" }, + })) as Content; + expect(JSON.parse(toolText(result))).toEqual(collectEventLedgerAuditFeed(ledger, filter)); + }); + + it("never exposes fields beyond the metadata-only audit-feed columns (invariant)", async () => { + const ledger = tempLedger(); + seedEvents(ledger); + const client = await connectedClient(ledger); + const result = (await client.callTool({ + name: "gittensory_miner_get_audit_feed", + arguments: {}, + })) as Content; + const payload = JSON.parse(toolText(result)); + for (const event of payload.events) { + expect(Object.keys(event).sort()).toEqual([...AUDIT_FEED_ENTRY_FIELDS].sort()); + expect(JSON.stringify(event)).not.toContain("must-not-leak"); + expect(JSON.stringify(event)).not.toContain("payload"); + expect(JSON.stringify(event)).not.toContain("secretBlob"); + } + }); + + it("never calls mutating event-ledger methods — only readEvents (invariant)", async () => { + const ledger = tempLedger(); + seedEvents(ledger); + const appendEvent = vi.spyOn(ledger, "appendEvent"); + const readEvents = vi.spyOn(ledger, "readEvents"); + const client = await connectedClient(ledger); + await client.callTool({ + name: "gittensory_miner_get_audit_feed", + arguments: { type: "manage_pr_update" }, + }); + expect(readEvents).toHaveBeenCalled(); + expect(appendEvent).not.toHaveBeenCalled(); + }); + + it("returns an MCP error for invalid since cursors", async () => { + const ledger = tempLedger(); + const client = await connectedClient(ledger); + const result = (await client.callTool({ + name: "gittensory_miner_get_audit_feed", + arguments: { since: -1 }, + })) as Content; + expect(result.isError).toBe(true); + expect(toolText(result)).toMatch(/Invalid arguments|since|invalid/i); + }); +}); + +describe("event-ledger audit-feed projection (#5158)", () => { + it("projectLedgerEventToAuditFeedEntry strips payload columns and keeps declared metadata strings", () => { + const projected = projectLedgerEventToAuditFeedEntry({ + id: 1, + seq: 2, + type: "manage_pr_update", + repoFullName: "acme/widgets", + payload: { + prNumber: 7, + outcome: "ready", + actor: "miner-bot", + detail: "gate passed", + secretBlob: "must-not-leak", + }, + createdAt: "2026-07-04T12:00:00.000Z", + }); + expect(projected).toEqual({ + eventType: "manage_pr_update", + repoFullName: "acme/widgets", + outcome: "ready", + actor: "miner-bot", + detail: "gate passed", + createdAt: "2026-07-04T12:00:00.000Z", + }); + }); + + it("normalizeAuditFeedMcpFilter mirrors ledger list filter semantics", () => { + expect( + normalizeAuditFeedMcpFilter({ + repoFullName: "acme/widgets", + since: 3, + type: "manage_pr_update", + }), + ).toEqual({ + repoFullName: "acme/widgets", + since: 3, + type: "manage_pr_update", + }); + expect(() => normalizeAuditFeedMcpFilter({ repoFullName: "bad" })).toThrow( + "Repository must be in owner/repo form.", + ); + expect(() => normalizeAuditFeedMcpFilter(null as unknown as Parameters[0])).toThrow( + "filter must be an object", + ); + expect(() => normalizeAuditFeedMcpFilter({ type: " " })).toThrow("type must be a non-empty string."); + }); + + it("projectLedgerEventToAuditFeedEntry nulls blank metadata strings and ignores non-object payloads", () => { + expect( + projectLedgerEventToAuditFeedEntry({ + id: 1, + seq: 2, + type: "manage_pr_update", + repoFullName: "acme/widgets", + payload: { outcome: " ", actor: 42, detail: null }, + createdAt: "2026-07-04T12:00:00.000Z", + }), + ).toEqual({ + eventType: "manage_pr_update", + repoFullName: "acme/widgets", + outcome: null, + actor: null, + detail: null, + createdAt: "2026-07-04T12:00:00.000Z", + }); + expect( + projectLedgerEventToAuditFeedEntry({ + id: 3, + seq: 4, + type: "discovered_issue", + repoFullName: "acme/widgets", + payload: ["not-an-object"] as unknown as Record, + createdAt: "2026-07-04T12:00:00.000Z", + }), + ).toEqual({ + eventType: "discovered_issue", + repoFullName: "acme/widgets", + outcome: null, + actor: null, + detail: null, + createdAt: "2026-07-04T12:00:00.000Z", + }); + }); +}); diff --git a/test/unit/miner-mcp-scaffold.test.ts b/test/unit/miner-mcp-scaffold.test.ts index 4a99f38f93..87b26ac86e 100644 --- a/test/unit/miner-mcp-scaffold.test.ts +++ b/test/unit/miner-mcp-scaffold.test.ts @@ -91,6 +91,7 @@ describe("gittensory-miner MCP server (#5153 scaffold)", () => { const client = await connectedClient(); const { tools } = await client.listTools(); expect(tools.map((tool) => tool.name).sort()).toEqual([ + "gittensory_miner_get_audit_feed", "gittensory_miner_get_portfolio_dashboard", "gittensory_miner_list_claims", "gittensory_miner_ping",