diff --git a/package-lock.json b/package-lock.json index 25119913e..8a0df1122 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15675,7 +15675,8 @@ "license": "AGPL-3.0-only", "dependencies": { "@jsonbored/gittensory-engine": "*", - "@modelcontextprotocol/sdk": "1.29.0" + "@modelcontextprotocol/sdk": "1.29.0", + "zod": "^4.4.3" }, "bin": { "gittensory-miner": "bin/gittensory-miner.js", diff --git a/packages/gittensory-miner/README.md b/packages/gittensory-miner/README.md index 52292cff4..1528c4e34 100644 --- a/packages/gittensory-miner/README.md +++ b/packages/gittensory-miner/README.md @@ -138,8 +138,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. -Further AMS-state-reading tools (status/doctor diagnostics, claim-ledger listing, 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, event/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 576a6affb..25b99e091 100644 --- a/packages/gittensory-miner/bin/gittensory-miner-mcp.d.ts +++ b/packages/gittensory-miner/bin/gittensory-miner-mcp.d.ts @@ -9,6 +9,14 @@ export interface MinerMcpServerOptions { * Typed to the minimal read surface the dashboard tool uses, mirroring runPortfolioDashboard's own seam. */ initPortfolioQueue?: () => { listQueue(repoFullName?: string | null): unknown[]; close(): void }; + /** + * Override the claim-ledger opener (defaults to the real on-disk ledger); injection seam for tests. Typed to + * the minimal read surface the list-claims tool uses. + */ + openClaimLedger?: () => { + listClaims(filter?: { repoFullName?: string | null; status?: string | null }): unknown[]; + close(): void; + }; /** Override the clock used for the oldest-queued age (defaults to Date.now()); injection seam for tests. */ nowMs?: number; } diff --git a/packages/gittensory-miner/bin/gittensory-miner-mcp.js b/packages/gittensory-miner/bin/gittensory-miner-mcp.js index 944d3232f..bb1b249e8 100755 --- a/packages/gittensory-miner/bin/gittensory-miner-mcp.js +++ b/packages/gittensory-miner/bin/gittensory-miner-mcp.js @@ -3,15 +3,19 @@ import { readFileSync, realpathSync } from "node:fs"; 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 { 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: // - gittensory_miner_ping (#5153): trivial static health check, reads no AMS state. // - gittensory_miner_get_portfolio_dashboard (#5155): read-only per-repo backlog dashboard, wrapping the // existing collectPortfolioDashboard aggregator (no new logic; same data as `queue dashboard --json`). -// Remaining AMS-state-reading tools (status/doctor, claim-ledger listing, run-state, etc.) land as follow-ups. +// - 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. // 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. @@ -21,9 +25,9 @@ const ownPackageJson = JSON.parse(readFileSync(new URL("../package.json", import export const MINER_PING_STATUS = { status: "ok", tool: "gittensory_miner_ping" }; /** - * Build the miner MCP server with its tools registered. `options.initPortfolioQueue` / `options.nowMs` are - * injection seams for tests (default to the real portfolio-queue store and the wall clock); the ping tool needs - * neither. The portfolio-dashboard tool opens the queue only when invoked and closes any store it opened. + * 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. */ export function createMinerMcpServer(options = {}) { const server = new McpServer({ name: "gittensory-miner", version: ownPackageJson.version }); @@ -57,6 +61,31 @@ export function createMinerMcpServer(options = {}) { } }, ); + server.registerTool( + "gittensory_miner_list_claims", + { + description: + "Read-only listing of the local claim ledger: which issues this miner has claimed (repo, issue number, " + + "status, claimed-at, note). Optional repoFullName/status filters pass through to the existing listClaims " + + "query. Exposes no claim/release mutation and no conflict-resolution logic.", + inputSchema: { + repoFullName: z.string().optional(), + status: z.enum(CLAIM_STATUSES).optional(), + }, + }, + async ({ repoFullName, status }) => { + const ownsLedger = options.openClaimLedger === undefined; + const ledger = (options.openClaimLedger ?? openClaimLedger)(); + try { + const filter = {}; + if (repoFullName !== undefined) filter.repoFullName = repoFullName; + if (status !== undefined) filter.status = status; + return { content: [{ type: "text", text: JSON.stringify(ledger.listClaims(filter)) }] }; + } finally { + if (ownsLedger) ledger.close(); + } + }, + ); return server; } diff --git a/packages/gittensory-miner/package.json b/packages/gittensory-miner/package.json index a8e572005..9c2f30b32 100644 --- a/packages/gittensory-miner/package.json +++ b/packages/gittensory-miner/package.json @@ -37,7 +37,8 @@ }, "dependencies": { "@jsonbored/gittensory-engine": "*", - "@modelcontextprotocol/sdk": "1.29.0" + "@modelcontextprotocol/sdk": "1.29.0", + "zod": "^4.4.3" }, "engines": { "node": ">=22.13.0" diff --git a/test/unit/miner-mcp-scaffold.test.ts b/test/unit/miner-mcp-scaffold.test.ts index 7b360f1e3..4a99f38f9 100644 --- a/test/unit/miner-mcp-scaffold.test.ts +++ b/test/unit/miner-mcp-scaffold.test.ts @@ -41,12 +41,58 @@ function fakeQueue(rows: unknown[]): { listQueue(): unknown[]; close(): void } { return { listQueue: () => rows, close: () => {} }; } +const CLAIM_ROWS = [ + { id: 1, repoFullName: "acme/api", issueNumber: 10, claimedAt: "2026-01-01T00:00:00Z", status: "active", note: null }, + { id: 2, repoFullName: "acme/api", issueNumber: 11, claimedAt: "2026-01-02T00:00:00Z", status: "released", note: "done" }, + { id: 3, repoFullName: "acme/web", issueNumber: 12, claimedAt: "2026-01-03T00:00:00Z", status: "active", note: null }, +]; + +type ClaimFilter = { repoFullName?: string | null; status?: string | null }; +type FakeLedger = { + calls: string[]; + listClaims(filter?: ClaimFilter): unknown[]; + close(): void; + recordClaim(): never; + claimIssue(): never; + releaseClaim(): never; + expireClaim(): never; +}; + +// Fake claim ledger that records every method call and throws from any mutator, so a test can assert the +// list-claims tool reaches only read methods. listClaims applies the same repo/status filter the real one does. +function fakeLedger(rows: Array<{ repoFullName: string; status: string }>): FakeLedger { + const calls: string[] = []; + const mutation = (name: string) => (): never => { + calls.push(name); + throw new Error(`mutation ${name} must not be reachable via the read tool`); + }; + return { + calls, + listClaims(filter: ClaimFilter = {}) { + calls.push("listClaims"); + return rows.filter( + (row) => + (filter.repoFullName == null || row.repoFullName === filter.repoFullName) && + (filter.status == null || row.status === filter.status), + ); + }, + close() { + calls.push("close"); + }, + recordClaim: mutation("recordClaim"), + claimIssue: mutation("claimIssue"), + releaseClaim: mutation("releaseClaim"), + expireClaim: mutation("expireClaim"), + }; +} + describe("gittensory-miner MCP server (#5153 scaffold)", () => { - it("exposes the ping and portfolio-dashboard tools", async () => { + it("exposes the ping, portfolio-dashboard, and list-claims tools", async () => { const client = await connectedClient(); const { tools } = await client.listTools(); expect(tools.map((tool) => tool.name).sort()).toEqual([ "gittensory_miner_get_portfolio_dashboard", + "gittensory_miner_list_claims", "gittensory_miner_ping", ]); }); @@ -114,3 +160,42 @@ describe("gittensory_miner_get_portfolio_dashboard (#5155)", () => { expect(JSON.parse(toolText(result))).toEqual(direct); }); }); + +describe("gittensory_miner_list_claims (#5156)", () => { + function claimsClient(ledger: FakeLedger): Promise { + return connectedClient({ openClaimLedger: () => ledger }); + } + async function callList(client: Client, args: Record = {}): Promise { + const result = (await client.callTool({ name: "gittensory_miner_list_claims", arguments: args })) as Content; + return JSON.parse(toolText(result)); + } + + it("lists every claim (all statuses) when no filter is given", async () => { + const claims = await callList(await claimsClient(fakeLedger(CLAIM_ROWS))); + expect(claims).toEqual(CLAIM_ROWS); + }); + + it("passes an optional repoFullName filter through to listClaims", async () => { + const claims = await callList(await claimsClient(fakeLedger(CLAIM_ROWS)), { repoFullName: "acme/web" }); + expect(claims).toEqual([CLAIM_ROWS[2]]); + }); + + it("passes an optional status filter through to listClaims", async () => { + const claims = await callList(await claimsClient(fakeLedger(CLAIM_ROWS)), { status: "active" }); + expect((claims as Array<{ issueNumber: number }>).map((claim) => claim.issueNumber)).toEqual([10, 12]); + }); + + it("returns an empty list for an empty ledger", async () => { + const claims = await callList(await claimsClient(fakeLedger([]))); + expect(claims).toEqual([]); + }); + + it("only reads — never reaches a mutating claim-ledger method (invariant)", async () => { + const ledger = fakeLedger(CLAIM_ROWS); + await callList(await claimsClient(ledger), { status: "active" }); + expect(ledger.calls).toEqual(["listClaims"]); + for (const mutator of ["recordClaim", "claimIssue", "releaseClaim", "expireClaim"]) { + expect(ledger.calls).not.toContain(mutator); + } + }); +});