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
3 changes: 2 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion packages/gittensory-miner/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 8 additions & 0 deletions packages/gittensory-miner/bin/gittensory-miner-mcp.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
37 changes: 33 additions & 4 deletions packages/gittensory-miner/bin/gittensory-miner-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 });
Expand Down Expand Up @@ -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;
}

Expand Down
3 changes: 2 additions & 1 deletion packages/gittensory-miner/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
87 changes: 86 additions & 1 deletion test/unit/miner-mcp-scaffold.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]);
});
Expand Down Expand Up @@ -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<Client> {
return connectedClient({ openClaimLedger: () => ledger });
}
async function callList(client: Client, args: Record<string, unknown> = {}): Promise<unknown[]> {
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);
}
});
});
Loading