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
4 changes: 3 additions & 1 deletion packages/gittensory-miner/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,9 @@ It exposes these read-only tools:

- `gittensory_miner_get_run_state` (#5160) — read-only per-repo run-state (`idle` / `discovering` / `planning` / `preparing`) via `getRunState` / `listRunStates`. Pass `repoFullName` for one repo (a null state means none recorded yet), or omit it to list all. The read-only analog of ORB's `gittensory_get_automation_state`; adds no state-set mutation.

Further AMS-state-reading tools (status/doctor diagnostics, governor ledger, plan store) land as follow-up PRs on top of this server.
- `gittensory_miner_list_plans` / `gittensory_miner_get_plan` (#5161) — read-only access to the persisted plan store (`planId`, plan DAG, status, `updatedAt`) via `listPlans` / `loadPlan`; `list_plans` takes an optional `status` filter, `get_plan` takes a `planId` and returns an explicit `{ planId, found: false }` for an unknown id. These read the store-backed AMS plan store — distinct from ORB's stateless `gittensory_plan_status` tool.

Further AMS-state-reading tools (status/doctor diagnostics, governor ledger) land as follow-up PRs on top of this server.

## Version check

Expand Down
12 changes: 11 additions & 1 deletion packages/gittensory-miner/bin/gittensory-miner-mcp.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,21 @@ export interface MinerMcpServerOptions {
listRunStates(): unknown[];
close(): void;
};
/**
* Override the plan-store opener (defaults to the real on-disk store); injection seam for tests. Typed to the
* minimal read surface the plan tools use (never savePlan).
*/
openPlanStore?: () => {
loadPlan(planId: string): unknown;
listPlans(filter?: { status?: string | null }): unknown[];
close(): void;
};
}

/**
* 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,
* gittensory_miner_get_run_state). `options` supplies test injection seams; production callers pass nothing.
* gittensory_miner_get_run_state, gittensory_miner_list_plans, gittensory_miner_get_plan). `options` supplies
* test injection seams; production callers pass nothing.
*/
export function createMinerMcpServer(options?: MinerMcpServerOptions): McpServer;
59 changes: 55 additions & 4 deletions packages/gittensory-miner/bin/gittensory-miner-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { initEventLedger } from "../lib/event-ledger.js";
import { collectPortfolioDashboard } from "../lib/portfolio-dashboard.js";
import { initPortfolioQueueStore } from "../lib/portfolio-queue.js";
import { initRunStateStore } from "../lib/run-state.js";
import { PLAN_STATUSES, openPlanStore } from "../lib/plan-store.js";

// MCP stdio server for @jsonbored/gittensory-miner (scaffold #5153). Mirrors the packages/gittensory-mcp
// harness (MCP SDK server + stdio transport). Tools:
Expand All @@ -25,7 +26,9 @@ import { initRunStateStore } from "../lib/run-state.js";
// collectEventLedgerAuditFeed() (same filters as `ledger list`; never returns payload_json).
// - gittensory_miner_get_run_state (#5160): read-only per-repo run-state via run-state.js's getRunState/
// listRunStates (read-only analog of ORB's gittensory_get_automation_state; no state-set mutation).
// Remaining AMS-state-reading tools (status/doctor, governor ledger, plan store, etc.) land as follow-ups.
// - gittensory_miner_list_plans / gittensory_miner_get_plan (#5161): read-only access to the persisted
// plan store via plan-store.js's listPlans/loadPlan (distinct from ORB's stateless gittensory_plan_status).
// Remaining AMS-state-reading tools (status/doctor, governor ledger, 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 @@ -43,9 +46,9 @@ export const MINER_PING_STATUS = { status: "ok", tool: "gittensory_miner_ping" }

/**
* Build the miner MCP server with its tools registered. `options.initPortfolioQueue`, `options.openClaimLedger`,
* `options.initEventLedger`, `options.initRunStateStore`, 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`, `options.initRunStateStore`, `options.openPlanStore`, 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 @@ -163,6 +166,54 @@ export function createMinerMcpServer(options = {}) {
}
},
);
server.registerTool(
"gittensory_miner_list_plans",
{
description:
"Read-only list of the miner's PERSISTED plan store (planId, plan DAG, status, updatedAt), optionally " +
"filtered by status. Wraps plan-store.js's existing listPlans query -- no new logic, no mutation. NOTE: " +
"this is the store-backed AMS plan store; it is distinct from ORB's stateless gittensory_plan_status " +
"tool, which reads the caller's in-memory plan object rather than any persisted store.",
inputSchema: {
status: z.enum(PLAN_STATUSES).optional(),
},
},
async ({ status }) => {
const ownsStore = options.openPlanStore === undefined;
const store = (options.openPlanStore ?? openPlanStore)();
try {
const filter = {};
if (status !== undefined) filter.status = status;
return { content: [{ type: "text", text: JSON.stringify(store.listPlans(filter)) }] };
} finally {
if (ownsStore) store.close();
}
},
);
server.registerTool(
"gittensory_miner_get_plan",
{
description:
"Read-only fetch of one persisted plan record by planId (the full plan DAG, status, updatedAt), or an " +
"explicit { planId, found: false } for an unknown id. Wraps plan-store.js's existing loadPlan lookup -- " +
"no mutation, no DAG/planning logic. Store-backed AMS plan store; distinct from ORB's stateless " +
"gittensory_plan_status tool.",
inputSchema: {
planId: z.string().min(1),
},
},
async ({ planId }) => {
const ownsStore = options.openPlanStore === undefined;
const store = (options.openPlanStore ?? openPlanStore)();
try {
const plan = store.loadPlan(planId);
const result = plan === null ? { planId, found: false } : { found: true, plan };
return { content: [{ type: "text", text: JSON.stringify(result) }] };
} finally {
if (ownsStore) store.close();
}
},
);
return server;
}

Expand Down
76 changes: 76 additions & 0 deletions test/unit/miner-mcp-scaffold.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,11 @@ describe("gittensory-miner MCP server (#5153 scaffold)", () => {
const { tools } = await client.listTools();
expect(tools.map((tool) => tool.name).sort()).toEqual([
"gittensory_miner_get_audit_feed",
"gittensory_miner_get_plan",
"gittensory_miner_get_portfolio_dashboard",
"gittensory_miner_get_run_state",
"gittensory_miner_list_claims",
"gittensory_miner_list_plans",
"gittensory_miner_ping",
]);
});
Expand Down Expand Up @@ -262,3 +264,77 @@ describe("gittensory_miner_get_run_state (#5160)", () => {
expect(store.calls).not.toContain("setRunState");
});
});

const PLAN_RECORDS = [
{ planId: "p1", plan: { steps: [] }, status: "running", updatedAt: "2026-01-01T00:00:00Z" },
{ planId: "p2", plan: { steps: [] }, status: "completed", updatedAt: "2026-01-02T00:00:00Z" },
];

// Fake plan store that records calls and throws from the mutator, so a test can assert the plan tools reach
// only loadPlan/listPlans and never savePlan. listPlans applies the same optional status filter the real one does.
function fakePlanStore(records: Array<{ planId: string; status: string }>) {
const calls: string[] = [];
return {
calls,
loadPlan(planId: string): unknown {
calls.push("loadPlan");
return records.find((record) => record.planId === planId) ?? null;
},
listPlans(filter: { status?: string | null } = {}): unknown[] {
calls.push("listPlans");
return records.filter((record) => filter.status == null || record.status === filter.status);
},
savePlan(): never {
calls.push("savePlan");
throw new Error("savePlan must not be reachable via a read tool");
},
close(): void {
calls.push("close");
},
};
}

describe("gittensory_miner_list_plans / get_plan (#5161)", () => {
function planClient(store: ReturnType<typeof fakePlanStore>): Promise<Client> {
return connectedClient({ openPlanStore: () => store });
}
async function callTool(client: Client, name: string, args: Record<string, unknown>): Promise<unknown> {
const result = (await client.callTool({ name, arguments: args })) as Content;
return JSON.parse(toolText(result));
}

it("list_plans returns every plan when no status filter is given", async () => {
const out = await callTool(await planClient(fakePlanStore(PLAN_RECORDS)), "gittensory_miner_list_plans", {});
expect(out).toEqual(PLAN_RECORDS);
});

it("list_plans passes an optional status filter through to listPlans", async () => {
const out = await callTool(await planClient(fakePlanStore(PLAN_RECORDS)), "gittensory_miner_list_plans", {
status: "running",
});
expect(out).toEqual([PLAN_RECORDS[0]]);
});

it("get_plan returns the full record for an existing planId", async () => {
const out = await callTool(await planClient(fakePlanStore(PLAN_RECORDS)), "gittensory_miner_get_plan", {
planId: "p2",
});
expect(out).toEqual({ found: true, plan: PLAN_RECORDS[1] });
});

it("get_plan returns an explicit not-found result for an unknown planId (no throw)", async () => {
const out = await callTool(await planClient(fakePlanStore(PLAN_RECORDS)), "gittensory_miner_get_plan", {
planId: "nope",
});
expect(out).toEqual({ planId: "nope", found: false });
});

it("only reads — neither tool reaches savePlan (invariant)", async () => {
const store = fakePlanStore(PLAN_RECORDS);
const client = await planClient(store);
await callTool(client, "gittensory_miner_list_plans", {});
await callTool(client, "gittensory_miner_get_plan", { planId: "p1" });
expect(store.calls).toEqual(["listPlans", "loadPlan"]);
expect(store.calls).not.toContain("savePlan");
});
});