diff --git a/packages/gittensory-miner/README.md b/packages/gittensory-miner/README.md index a0ca58d01..816aa0192 100644 --- a/packages/gittensory-miner/README.md +++ b/packages/gittensory-miner/README.md @@ -131,10 +131,12 @@ The package ships a second bin entry, `gittensory-miner-mcp`, a minimal [Model C gittensory-miner-mcp ``` -It currently exposes a single tool, `gittensory_miner_ping` — a health check that returns a static -`{ "status": "ok", "tool": "gittensory_miner_ping" }` object, reads no AMS state, and takes no arguments. This is a -scaffold (#5153): real AMS-state-reading tools (status/doctor diagnostics, portfolio dashboard, claim-ledger -listing) land as follow-up PRs on top of it. +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. + +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. ## 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 0a2c5d90b..576a6affb 100644 --- a/packages/gittensory-miner/bin/gittensory-miner-mcp.d.ts +++ b/packages/gittensory-miner/bin/gittensory-miner-mcp.d.ts @@ -3,8 +3,18 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.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" }; +export interface MinerMcpServerOptions { + /** + * Override the portfolio-queue store opener (defaults to the real on-disk store); injection seam for tests. + * 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 clock used for the oldest-queued age (defaults to Date.now()); injection seam for tests. */ + nowMs?: number; +} + /** - * Build the miner MCP server with its single gittensory_miner_ping health-check tool registered. No I/O - * and no AMS-state reads, so a test can drive it over an in-memory transport. + * Build the miner MCP server with its tools registered (gittensory_miner_ping, gittensory_miner_get_portfolio_dashboard). + * `options` supplies test injection seams; production callers pass nothing. */ -export function createMinerMcpServer(): McpServer; +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 e5ca311b5..944d3232f 100755 --- a/packages/gittensory-miner/bin/gittensory-miner-mcp.js +++ b/packages/gittensory-miner/bin/gittensory-miner-mcp.js @@ -3,12 +3,15 @@ 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 { collectPortfolioDashboard } from "../lib/portfolio-dashboard.js"; +import { initPortfolioQueueStore } from "../lib/portfolio-queue.js"; -// Minimal MCP stdio-server scaffold for @jsonbored/gittensory-miner (#5153). Mirrors the -// packages/gittensory-mcp harness (MCP SDK server + stdio transport) but ships exactly ONE trivial -// health-check tool -- gittensory_miner_ping -- returning a static status object. It reads NO AMS -// state and takes no arguments. Future AMS-state-reading tools (status/doctor, portfolio dashboard, -// claim-ledger listing) land as follow-up PRs on top of this scaffold. +// 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. // 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. @@ -18,11 +21,11 @@ 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 single health-check tool registered. No I/O and no AMS-state - * reads, so a test can drive it over an in-memory transport without spawning a process or requiring - * any on-disk state to exist. + * 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. */ -export function createMinerMcpServer() { +export function createMinerMcpServer(options = {}) { const server = new McpServer({ name: "gittensory-miner", version: ownPackageJson.version }); server.registerTool( "gittensory_miner_ping", @@ -34,6 +37,26 @@ export function createMinerMcpServer() { }, async () => ({ content: [{ type: "text", text: JSON.stringify(MINER_PING_STATUS) }] }), ); + server.registerTool( + "gittensory_miner_get_portfolio_dashboard", + { + description: + "Read-only per-repo portfolio-queue backlog dashboard: status counts (queued/in_progress/done), totals, " + + "and the oldest-queued age in ms. Wraps the existing collectPortfolioDashboard aggregator (no new logic) " + + "-- the same data `gittensory-miner queue dashboard --json` prints locally. Takes no arguments; mutates nothing.", + inputSchema: {}, + }, + async () => { + const ownsQueue = options.initPortfolioQueue === undefined; + const portfolioQueue = (options.initPortfolioQueue ?? initPortfolioQueueStore)(); + try { + const summary = collectPortfolioDashboard({ portfolioQueue }, { nowMs: options.nowMs ?? Date.now() }); + return { content: [{ type: "text", text: JSON.stringify(summary) }] }; + } finally { + if (ownsQueue) portfolioQueue.close(); + } + }, + ); return server; } diff --git a/test/unit/miner-mcp-scaffold.test.ts b/test/unit/miner-mcp-scaffold.test.ts index 11d208fa2..7b360f1e3 100644 --- a/test/unit/miner-mcp-scaffold.test.ts +++ b/test/unit/miner-mcp-scaffold.test.ts @@ -4,19 +4,24 @@ import { describe, expect, it } from "vitest"; import { createMinerMcpServer, MINER_PING_STATUS, + type MinerMcpServerOptions, } from "../../packages/gittensory-miner/bin/gittensory-miner-mcp.js"; +import { collectPortfolioDashboard } from "../../packages/gittensory-miner/lib/portfolio-dashboard.js"; -// Smoke test for the gittensory-miner MCP scaffold (#5153). Drives the real server over an in-memory -// transport (no child process, no AMS state on disk) and exercises the single gittensory_miner_ping tool. +// Tests for the gittensory-miner MCP server: the #5153 ping scaffold and the #5155 read-only +// portfolio-dashboard tool. Drives the real server over an in-memory transport (no child process); the +// dashboard tool's store opener and clock are injected so no on-disk AMS state is required. -async function connectedClient(): Promise { +type Content = { content: Array<{ type: string; text?: string }> }; + +async function connectedClient(options: MinerMcpServerOptions = {}): Promise { const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); const client = new Client({ name: "miner-mcp-test", version: "0.0.0" }); - await Promise.all([createMinerMcpServer().connect(serverTransport), client.connect(clientTransport)]); + await Promise.all([createMinerMcpServer(options).connect(serverTransport), client.connect(clientTransport)]); return client; } -function pingText(result: { content: Array<{ type: string; text?: string }> }): string { +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"); @@ -24,31 +29,88 @@ function pingText(result: { content: Array<{ type: string; text?: string }> }): return first.text; } -describe("gittensory-miner MCP scaffold (#5153)", () => { - it("exposes exactly the gittensory_miner_ping tool", async () => { +const NOW_MS = Date.parse("2026-02-01T00:00:00Z"); +const QUEUE_ROWS = [ + { repoFullName: "acme/api", identifier: "pr:1", priority: 0, status: "queued", enqueuedAt: "2026-01-01T00:00:00Z" }, + { repoFullName: "acme/api", identifier: "pr:2", priority: 0, status: "in_progress", enqueuedAt: "2026-01-02T00:00:00Z" }, + { repoFullName: "acme/web", identifier: "pr:3", priority: 0, status: "done", enqueuedAt: "2026-01-03T00:00:00Z" }, + { repoFullName: "acme/web", identifier: "pr:4", priority: 0, status: "queued", enqueuedAt: "2026-01-04T00:00:00Z" }, +]; + +function fakeQueue(rows: unknown[]): { listQueue(): unknown[]; close(): void } { + return { listQueue: () => rows, close: () => {} }; +} + +describe("gittensory-miner MCP server (#5153 scaffold)", () => { + it("exposes the ping and portfolio-dashboard tools", async () => { const client = await connectedClient(); const { tools } = await client.listTools(); - expect(tools.map((tool) => tool.name)).toEqual(["gittensory_miner_ping"]); + expect(tools.map((tool) => tool.name).sort()).toEqual([ + "gittensory_miner_get_portfolio_dashboard", + "gittensory_miner_ping", + ]); }); it("gittensory_miner_ping returns the static, non-secret status object", async () => { const client = await connectedClient(); - const result = (await client.callTool({ name: "gittensory_miner_ping", arguments: {} })) as { - content: Array<{ type: string; text?: string }>; - }; - expect(JSON.parse(pingText(result))).toEqual({ status: "ok", tool: "gittensory_miner_ping" }); - expect(JSON.parse(pingText(result))).toEqual(MINER_PING_STATUS); + const result = (await client.callTool({ name: "gittensory_miner_ping", arguments: {} })) as Content; + expect(JSON.parse(toolText(result))).toEqual({ status: "ok", tool: "gittensory_miner_ping" }); + expect(JSON.parse(toolText(result))).toEqual(MINER_PING_STATUS); }); - it("returns the same object on every call, with no AMS state required on disk (invariant)", async () => { + it("gittensory_miner_ping returns the same object on every call, no AMS state required (invariant)", async () => { const client = await connectedClient(); - const first = (await client.callTool({ name: "gittensory_miner_ping", arguments: {} })) as { - content: Array<{ type: string; text?: string }>; - }; - const second = (await client.callTool({ name: "gittensory_miner_ping", arguments: {} })) as { - content: Array<{ type: string; text?: string }>; - }; - expect(pingText(first)).toBe(pingText(second)); - expect(JSON.parse(pingText(first))).toEqual(MINER_PING_STATUS); + const a = (await client.callTool({ name: "gittensory_miner_ping", arguments: {} })) as Content; + const b = (await client.callTool({ name: "gittensory_miner_ping", arguments: {} })) as Content; + expect(toolText(a)).toBe(toolText(b)); + expect(JSON.parse(toolText(a))).toEqual(MINER_PING_STATUS); + }); +}); + +describe("gittensory_miner_get_portfolio_dashboard (#5155)", () => { + function dashboardClient(rows: unknown[]): Promise { + return connectedClient({ initPortfolioQueue: () => fakeQueue(rows), nowMs: NOW_MS }); + } + + it("returns per-repo status counts, totals, and oldest-queued age over a multi-repo backlog", async () => { + const client = await dashboardClient(QUEUE_ROWS); + const result = (await client.callTool({ + name: "gittensory_miner_get_portfolio_dashboard", + arguments: {}, + })) as Content; + const summary = JSON.parse(toolText(result)); + expect(summary.total).toBe(4); + expect(summary.byStatus).toEqual({ queued: 2, in_progress: 1, done: 1 }); + expect(summary.repos.map((repo: { repoFullName: string }) => repo.repoFullName)).toEqual(["acme/api", "acme/web"]); + expect(summary.repos[0]).toEqual({ + repoFullName: "acme/api", + byStatus: { queued: 1, in_progress: 1, done: 0 }, + total: 2, + }); + expect(summary.oldestQueuedAgeMs).toBe(NOW_MS - Date.parse("2026-01-01T00:00:00Z")); + }); + + it("handles an empty queue without a clock error (single-repo/empty edge)", async () => { + const client = await dashboardClient([]); + const result = (await client.callTool({ + name: "gittensory_miner_get_portfolio_dashboard", + arguments: {}, + })) as Content; + expect(JSON.parse(toolText(result))).toEqual({ + total: 0, + byStatus: { queued: 0, in_progress: 0, done: 0 }, + repos: [], + oldestQueuedAgeMs: null, + }); + }); + + it("is structurally identical to collectPortfolioDashboard() — the wrapper adds no drift (invariant)", async () => { + const client = await dashboardClient(QUEUE_ROWS); + const result = (await client.callTool({ + name: "gittensory_miner_get_portfolio_dashboard", + arguments: {}, + })) as Content; + const direct = collectPortfolioDashboard({ portfolioQueue: fakeQueue(QUEUE_ROWS) }, { nowMs: NOW_MS }); + expect(JSON.parse(toolText(result))).toEqual(direct); }); });