Skip to content

Commit 88f41aa

Browse files
feat(miner): expose the persisted plan store via read-only MCP tools
Add gittensory_miner_list_plans and gittensory_miner_get_plan to the gittensory-miner MCP server (scaffold #5153): list_plans wraps plan-store.js's listPlans (optional status filter); get_plan wraps loadPlan by planId, returning the full record or an explicit { planId, found:false } for an unknown id. Both are strictly read-only (never savePlan) and are documented as the store-backed AMS plan store, distinct from ORB's stateless gittensory_plan_status. The opener is injectable for tests. Closes #5161
1 parent e8e068d commit 88f41aa

4 files changed

Lines changed: 145 additions & 6 deletions

File tree

packages/gittensory-miner/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,9 @@ It exposes these read-only tools:
143143

144144
- `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.
145145

146-
Further AMS-state-reading tools (status/doctor diagnostics, governor ledger, plan store) land as follow-up PRs on top of this server.
146+
- `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.
147+
148+
Further AMS-state-reading tools (status/doctor diagnostics, governor ledger) land as follow-up PRs on top of this server.
147149

148150
## Version check
149151

packages/gittensory-miner/bin/gittensory-miner-mcp.d.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,21 @@ export interface MinerMcpServerOptions {
3131
listRunStates(): unknown[];
3232
close(): void;
3333
};
34+
/**
35+
* Override the plan-store opener (defaults to the real on-disk store); injection seam for tests. Typed to the
36+
* minimal read surface the plan tools use (never savePlan).
37+
*/
38+
openPlanStore?: () => {
39+
loadPlan(planId: string): unknown;
40+
listPlans(filter?: { status?: string | null }): unknown[];
41+
close(): void;
42+
};
3443
}
3544

3645
/**
3746
* Build the miner MCP server with its tools registered (gittensory_miner_ping,
3847
* gittensory_miner_get_portfolio_dashboard, gittensory_miner_list_claims, gittensory_miner_get_audit_feed,
39-
* gittensory_miner_get_run_state). `options` supplies test injection seams; production callers pass nothing.
48+
* gittensory_miner_get_run_state, gittensory_miner_list_plans, gittensory_miner_get_plan). `options` supplies
49+
* test injection seams; production callers pass nothing.
4050
*/
4151
export function createMinerMcpServer(options?: MinerMcpServerOptions): McpServer;

packages/gittensory-miner/bin/gittensory-miner-mcp.js

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { initEventLedger } from "../lib/event-ledger.js";
1313
import { collectPortfolioDashboard } from "../lib/portfolio-dashboard.js";
1414
import { initPortfolioQueueStore } from "../lib/portfolio-queue.js";
1515
import { initRunStateStore } from "../lib/run-state.js";
16+
import { PLAN_STATUSES, openPlanStore } from "../lib/plan-store.js";
1617

1718
// MCP stdio server for @jsonbored/gittensory-miner (scaffold #5153). Mirrors the packages/gittensory-mcp
1819
// harness (MCP SDK server + stdio transport). Tools:
@@ -25,7 +26,9 @@ import { initRunStateStore } from "../lib/run-state.js";
2526
// collectEventLedgerAuditFeed() (same filters as `ledger list`; never returns payload_json).
2627
// - gittensory_miner_get_run_state (#5160): read-only per-repo run-state via run-state.js's getRunState/
2728
// listRunStates (read-only analog of ORB's gittensory_get_automation_state; no state-set mutation).
28-
// Remaining AMS-state-reading tools (status/doctor, governor ledger, plan store, etc.) land as follow-ups.
29+
// - gittensory_miner_list_plans / gittensory_miner_get_plan (#5161): read-only access to the persisted
30+
// plan store via plan-store.js's listPlans/loadPlan (distinct from ORB's stateless gittensory_plan_status).
31+
// Remaining AMS-state-reading tools (status/doctor, governor ledger, etc.) land as follow-ups.
2932

3033
// Read the version from this package's own package.json (always shipped) rather than a hand-synced
3134
// literal, so a release bump never has a second place to forget -- same approach as the mcp harness.
@@ -43,9 +46,9 @@ export const MINER_PING_STATUS = { status: "ok", tool: "gittensory_miner_ping" }
4346

4447
/**
4548
* Build the miner MCP server with its tools registered. `options.initPortfolioQueue`, `options.openClaimLedger`,
46-
* `options.initEventLedger`, `options.initRunStateStore`, and `options.nowMs` are injection seams for tests
47-
* (default to the real stores and the wall clock); the ping tool needs none. Each store-backed tool opens its
48-
* store only when invoked and closes any store it opened.
49+
* `options.initEventLedger`, `options.initRunStateStore`, `options.openPlanStore`, and `options.nowMs` are
50+
* injection seams for tests (default to the real stores and the wall clock); the ping tool needs none. Each
51+
* store-backed tool opens its store only when invoked and closes any store it opened.
4952
*/
5053
export function createMinerMcpServer(options = {}) {
5154
const server = new McpServer({ name: "gittensory-miner", version: ownPackageJson.version });
@@ -163,6 +166,54 @@ export function createMinerMcpServer(options = {}) {
163166
}
164167
},
165168
);
169+
server.registerTool(
170+
"gittensory_miner_list_plans",
171+
{
172+
description:
173+
"Read-only list of the miner's PERSISTED plan store (planId, plan DAG, status, updatedAt), optionally " +
174+
"filtered by status. Wraps plan-store.js's existing listPlans query -- no new logic, no mutation. NOTE: " +
175+
"this is the store-backed AMS plan store; it is distinct from ORB's stateless gittensory_plan_status " +
176+
"tool, which reads the caller's in-memory plan object rather than any persisted store.",
177+
inputSchema: {
178+
status: z.enum(PLAN_STATUSES).optional(),
179+
},
180+
},
181+
async ({ status }) => {
182+
const ownsStore = options.openPlanStore === undefined;
183+
const store = (options.openPlanStore ?? openPlanStore)();
184+
try {
185+
const filter = {};
186+
if (status !== undefined) filter.status = status;
187+
return { content: [{ type: "text", text: JSON.stringify(store.listPlans(filter)) }] };
188+
} finally {
189+
if (ownsStore) store.close();
190+
}
191+
},
192+
);
193+
server.registerTool(
194+
"gittensory_miner_get_plan",
195+
{
196+
description:
197+
"Read-only fetch of one persisted plan record by planId (the full plan DAG, status, updatedAt), or an " +
198+
"explicit { planId, found: false } for an unknown id. Wraps plan-store.js's existing loadPlan lookup -- " +
199+
"no mutation, no DAG/planning logic. Store-backed AMS plan store; distinct from ORB's stateless " +
200+
"gittensory_plan_status tool.",
201+
inputSchema: {
202+
planId: z.string().min(1),
203+
},
204+
},
205+
async ({ planId }) => {
206+
const ownsStore = options.openPlanStore === undefined;
207+
const store = (options.openPlanStore ?? openPlanStore)();
208+
try {
209+
const plan = store.loadPlan(planId);
210+
const result = plan === null ? { planId, found: false } : { found: true, plan };
211+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
212+
} finally {
213+
if (ownsStore) store.close();
214+
}
215+
},
216+
);
166217
return server;
167218
}
168219

test/unit/miner-mcp-scaffold.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,9 +92,11 @@ describe("gittensory-miner MCP server (#5153 scaffold)", () => {
9292
const { tools } = await client.listTools();
9393
expect(tools.map((tool) => tool.name).sort()).toEqual([
9494
"gittensory_miner_get_audit_feed",
95+
"gittensory_miner_get_plan",
9596
"gittensory_miner_get_portfolio_dashboard",
9697
"gittensory_miner_get_run_state",
9798
"gittensory_miner_list_claims",
99+
"gittensory_miner_list_plans",
98100
"gittensory_miner_ping",
99101
]);
100102
});
@@ -262,3 +264,77 @@ describe("gittensory_miner_get_run_state (#5160)", () => {
262264
expect(store.calls).not.toContain("setRunState");
263265
});
264266
});
267+
268+
const PLAN_RECORDS = [
269+
{ planId: "p1", plan: { steps: [] }, status: "running", updatedAt: "2026-01-01T00:00:00Z" },
270+
{ planId: "p2", plan: { steps: [] }, status: "completed", updatedAt: "2026-01-02T00:00:00Z" },
271+
];
272+
273+
// Fake plan store that records calls and throws from the mutator, so a test can assert the plan tools reach
274+
// only loadPlan/listPlans and never savePlan. listPlans applies the same optional status filter the real one does.
275+
function fakePlanStore(records: Array<{ planId: string; status: string }>) {
276+
const calls: string[] = [];
277+
return {
278+
calls,
279+
loadPlan(planId: string): unknown {
280+
calls.push("loadPlan");
281+
return records.find((record) => record.planId === planId) ?? null;
282+
},
283+
listPlans(filter: { status?: string | null } = {}): unknown[] {
284+
calls.push("listPlans");
285+
return records.filter((record) => filter.status == null || record.status === filter.status);
286+
},
287+
savePlan(): never {
288+
calls.push("savePlan");
289+
throw new Error("savePlan must not be reachable via a read tool");
290+
},
291+
close(): void {
292+
calls.push("close");
293+
},
294+
};
295+
}
296+
297+
describe("gittensory_miner_list_plans / get_plan (#5161)", () => {
298+
function planClient(store: ReturnType<typeof fakePlanStore>): Promise<Client> {
299+
return connectedClient({ openPlanStore: () => store });
300+
}
301+
async function callTool(client: Client, name: string, args: Record<string, unknown>): Promise<unknown> {
302+
const result = (await client.callTool({ name, arguments: args })) as Content;
303+
return JSON.parse(toolText(result));
304+
}
305+
306+
it("list_plans returns every plan when no status filter is given", async () => {
307+
const out = await callTool(await planClient(fakePlanStore(PLAN_RECORDS)), "gittensory_miner_list_plans", {});
308+
expect(out).toEqual(PLAN_RECORDS);
309+
});
310+
311+
it("list_plans passes an optional status filter through to listPlans", async () => {
312+
const out = await callTool(await planClient(fakePlanStore(PLAN_RECORDS)), "gittensory_miner_list_plans", {
313+
status: "running",
314+
});
315+
expect(out).toEqual([PLAN_RECORDS[0]]);
316+
});
317+
318+
it("get_plan returns the full record for an existing planId", async () => {
319+
const out = await callTool(await planClient(fakePlanStore(PLAN_RECORDS)), "gittensory_miner_get_plan", {
320+
planId: "p2",
321+
});
322+
expect(out).toEqual({ found: true, plan: PLAN_RECORDS[1] });
323+
});
324+
325+
it("get_plan returns an explicit not-found result for an unknown planId (no throw)", async () => {
326+
const out = await callTool(await planClient(fakePlanStore(PLAN_RECORDS)), "gittensory_miner_get_plan", {
327+
planId: "nope",
328+
});
329+
expect(out).toEqual({ planId: "nope", found: false });
330+
});
331+
332+
it("only reads — neither tool reaches savePlan (invariant)", async () => {
333+
const store = fakePlanStore(PLAN_RECORDS);
334+
const client = await planClient(store);
335+
await callTool(client, "gittensory_miner_list_plans", {});
336+
await callTool(client, "gittensory_miner_get_plan", { planId: "p1" });
337+
expect(store.calls).toEqual(["listPlans", "loadPlan"]);
338+
expect(store.calls).not.toContain("savePlan");
339+
});
340+
});

0 commit comments

Comments
 (0)