Skip to content
Merged
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
10 changes: 6 additions & 4 deletions packages/gittensory-miner/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 13 additions & 3 deletions packages/gittensory-miner/bin/gittensory-miner-mcp.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
41 changes: 32 additions & 9 deletions packages/gittensory-miner/bin/gittensory-miner-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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",
Expand All @@ -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;
}

Expand Down
106 changes: 84 additions & 22 deletions test/unit/miner-mcp-scaffold.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,51 +4,113 @@ 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<Client> {
type Content = { content: Array<{ type: string; text?: string }> };

async function connectedClient(options: MinerMcpServerOptions = {}): Promise<Client> {
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");
}
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<Client> {
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);
});
});