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
1 change: 1 addition & 0 deletions packages/loopover-miner/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ It exposes these read-only tools:

- `loopover_miner_ping` (#5153) — a health check returning a static `{ "status": "ok", "tool": "loopover_miner_ping" }` object. Reads no AMS state, takes no arguments.
- `loopover_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 `loopover-miner queue dashboard --json` prints locally. Read-only, takes no arguments.
- `loopover_miner_get_manage_status` (#5822) — read-only manage-phase status: the per-managed-PR rows (branch, CI state, gate verdict, outcome, last-polled-at, queue status/priority) plus the run-level portfolio view (one row per tracked repo: run state, updated-at, PR count). Joins the portfolio queue, the event ledger, and run-state by reusing `collectManageStatus()` / `collectRunPortfolio()` (no new join logic) — the same `{ rows, runPortfolio }` shape `loopover-miner manage status --json` prints. Read-only: never calls GitHub, never mutates local stores; takes no arguments.
- `loopover_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.
- `loopover_miner_get_audit_feed` (#5158) — read-only, metadata-only event-ledger audit feed (`eventType`, `repoFullName`, `outcome`, `actor`, `detail`, `createdAt`). Wraps `collectEventLedgerAuditFeed()` with the same filters as `loopover-miner ledger list` (`--repo`, `--since`, `--type`). Never returns `payload_json` or other raw ledger columns.

Expand Down
9 changes: 5 additions & 4 deletions packages/loopover-miner/bin/loopover-miner-mcp.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,10 @@ export interface MinerMcpServerOptions {

/**
* Build the miner MCP server with its tools registered (loopover_miner_ping,
* loopover_miner_get_portfolio_dashboard, loopover_miner_list_claims, loopover_miner_get_audit_feed,
* loopover_miner_get_run_state, loopover_miner_list_plans, loopover_miner_get_plan,
* loopover_miner_get_governor_decisions, loopover_miner_status, loopover_miner_get_calibration_report). `options`
* supplies test injection seams; production callers pass nothing.
* loopover_miner_get_portfolio_dashboard, loopover_miner_get_manage_status, loopover_miner_list_claims,
* loopover_miner_get_audit_feed, loopover_miner_get_run_state, loopover_miner_list_plans,
* loopover_miner_get_plan, loopover_miner_get_governor_decisions, loopover_miner_status,
* loopover_miner_get_calibration_report). `options` supplies test injection seams; production callers
* pass nothing.
*/
export function createMinerMcpServer(options?: MinerMcpServerOptions): McpServer;
34 changes: 34 additions & 0 deletions packages/loopover-miner/bin/loopover-miner-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
normalizeAuditFeedMcpFilter,
} from "../lib/event-ledger-cli.js";
import { initEventLedger } from "../lib/event-ledger.js";
import { collectManageStatus, collectRunPortfolio } from "../lib/manage-status.js";
import { collectPortfolioDashboard } from "../lib/portfolio-dashboard.js";
import { initPortfolioQueueStore } from "../lib/portfolio-queue.js";
import { initRunStateStore } from "../lib/run-state.js";
Expand All @@ -25,6 +26,9 @@ import { initPredictionLedger } from "../lib/prediction-ledger.js";
// - loopover_miner_ping (#5153): trivial static health check, reads no AMS state.
// - loopover_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`).
// - loopover_miner_get_manage_status (#5822): read-only manage-phase status joining the portfolio queue, the
// event ledger, and run-state via manage-status.js's collectManageStatus/collectRunPortfolio (no new join
// logic; same { rows, runPortfolio } shape as `manage status --json`). Never calls GitHub, never mutates.
// - loopover_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.
// - loopover_miner_get_audit_feed (#5158): read-only metadata-only event-ledger audit feed via
Expand Down Expand Up @@ -95,6 +99,36 @@ export function createMinerMcpServer(options = {}) {
}
},
);
server.registerTool(
"loopover_miner_get_manage_status",
{
description:
"Read-only manage-phase status: the per-managed-PR rows `loopover-miner manage status` reports (branch, CI " +
"state, gate verdict, outcome, last-polled-at, queue status/priority) plus the run-level portfolio view " +
"(one row per tracked repo: run state, updated-at, PR count). Joins the portfolio queue, the append-only " +
"event ledger, and run-state by reusing the existing collectManageStatus/collectRunPortfolio aggregators " +
"-- no new join logic -- returning the same { rows, runPortfolio } shape `manage status --json` prints. " +
"Read-only: never calls GitHub, never mutates local stores. Takes no arguments.",
inputSchema: {},
},
async () => {
const ownsPortfolioQueue = options.initPortfolioQueue === undefined;
const ownsEventLedger = options.initEventLedger === undefined;
const ownsRunStateStore = options.initRunStateStore === undefined;
const portfolioQueue = (options.initPortfolioQueue ?? initPortfolioQueueStore)();
const eventLedger = (options.initEventLedger ?? initEventLedger)();
const runStateStore = (options.initRunStateStore ?? initRunStateStore)();
try {
const rows = collectManageStatus({ portfolioQueue, eventLedger });
const runPortfolio = collectRunPortfolio({ portfolioQueue, eventLedger, runStateStore });
return { content: [{ type: "text", text: JSON.stringify({ rows, runPortfolio }) }] };
} finally {
if (ownsPortfolioQueue) portfolioQueue.close();
if (ownsEventLedger) eventLedger.close();
if (ownsRunStateStore) runStateStore.close();
}
},
);
server.registerTool(
"loopover_miner_list_claims",
{
Expand Down
35 changes: 35 additions & 0 deletions test/unit/miner-mcp-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,21 @@ type ToolContract = {
excluded: string[];
};

// manage-status is the one tool that opens THREE stores at once, so its rows seed all three seams and vary only the
// one under test — an un-stubbed seam would otherwise fall through to a real on-disk store.
const benignEventLedger: MinerMcpServerOptions["initEventLedger"] = () => ({
dbPath: "",
appendEvent: readThrows,
readEvents: () => [],
purgeByRepo: readThrows,
close() {},
});
const benignRunStateStore: MinerMcpServerOptions["initRunStateStore"] = () => ({
getRunState: () => null,
listRunStates: () => [],
close() {},
});

const READ_ONLY_TOOLS: ToolContract[] = [
{
tool: "loopover_miner_status",
Expand Down Expand Up @@ -132,6 +147,26 @@ const READ_ONLY_TOOLS: ToolContract[] = [
corrupt: { initPortfolioQueue: () => ({ listQueue: readThrows, close() {} }) },
excluded: [],
},
{
tool: "loopover_miner_get_manage_status",
args: {},
valid: {
initPortfolioQueue: () => ({ listQueue: () => [], close() {} }),
initEventLedger: benignEventLedger,
initRunStateStore: benignRunStateStore,
},
missing: {
initPortfolioQueue: openerThrows,
initEventLedger: benignEventLedger,
initRunStateStore: benignRunStateStore,
},
corrupt: {
initPortfolioQueue: () => ({ listQueue: readThrows, close() {} }),
initEventLedger: benignEventLedger,
initRunStateStore: benignRunStateStore,
},
excluded: [],
},
{
tool: "loopover_miner_list_claims",
args: {},
Expand Down
179 changes: 179 additions & 0 deletions test/unit/miner-mcp-manage-status.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createMinerMcpServer } from "../../packages/loopover-miner/bin/loopover-miner-mcp.js";
import {
MANAGE_PR_UPDATE_EVENT,
collectManageStatus,
collectRunPortfolio,
} from "../../packages/loopover-miner/lib/manage-status.js";
import {
closeDefaultEventLedger,
initEventLedger,
} from "../../packages/loopover-miner/lib/event-ledger.js";
import {
closeDefaultPortfolioQueueStore,
initPortfolioQueueStore,
} from "../../packages/loopover-miner/lib/portfolio-queue.js";
import {
closeDefaultRunStateStore,
initRunStateStore,
} from "../../packages/loopover-miner/lib/run-state.js";

type Content = { content: Array<{ type: string; text?: string }>; isError?: boolean };
type Stores = {
portfolioQueue: ReturnType<typeof initPortfolioQueueStore>;
eventLedger: ReturnType<typeof initEventLedger>;
runStateStore: ReturnType<typeof initRunStateStore>;
};

const roots: string[] = [];
const stores: Array<{ close(): void }> = [];

function tempStores(): Stores {
const root = mkdtempSync(join(tmpdir(), "loopover-miner-mcp-manage-status-"));
roots.push(root);
const portfolioQueue = initPortfolioQueueStore(join(root, "portfolio-queue.sqlite3"));
const eventLedger = initEventLedger(join(root, "event-ledger.sqlite3"));
const runStateStore = initRunStateStore(join(root, "run-state.sqlite3"));
stores.push(portfolioQueue, eventLedger, runStateStore);
return { portfolioQueue, eventLedger, runStateStore };
}

afterEach(() => {
for (const store of stores.splice(0)) store.close();
closeDefaultPortfolioQueueStore();
closeDefaultEventLedger();
closeDefaultRunStateStore();
vi.restoreAllMocks();
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
});

async function connectedClient(sources: Stores): Promise<Client> {
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const client = new Client({ name: "miner-mcp-manage-status-test", version: "0.0.0" });
await Promise.all([
createMinerMcpServer({
initPortfolioQueue: () => sources.portfolioQueue,
initEventLedger: () => sources.eventLedger,
initRunStateStore: () => sources.runStateStore,
}).connect(serverTransport),
client.connect(clientTransport),
]);
return client;
}

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;
}

const callManageStatus = async (sources: Stores): Promise<Content> =>
(await (await connectedClient(sources)).callTool({
name: "loopover_miner_get_manage_status",
arguments: {},
})) as Content;

/** A managed PR in the queue with a manage snapshot, plus a run-state-only repo that has no PRs yet. */
function seed(sources: Stores) {
sources.portfolioQueue.enqueue({ repoFullName: "acme/widgets", identifier: "pr:12", priority: 3 });
sources.eventLedger.appendEvent({
type: MANAGE_PR_UPDATE_EVENT,
repoFullName: "acme/widgets",
payload: {
prNumber: 12,
branch: "feat/widget-cursor",
ciState: "passing",
gateVerdict: "approve",
outcome: "merged",
lastPolledAt: "2026-07-04T12:00:00.000Z",
},
});
sources.runStateStore.setRunState("acme/widgets", "preparing");
sources.runStateStore.setRunState("acme/discovering-only", "discovering");
}

describe("loopover_miner_get_manage_status (#5822)", () => {
it("is registered on the miner MCP server", async () => {
const client = await connectedClient(tempStores());
const { tools } = await client.listTools();
expect(tools.map((tool) => tool.name)).toContain("loopover_miner_get_manage_status");
});

it("returns the per-PR rows and the run-portfolio view for a populated managed-PR set", async () => {
const sources = tempStores();
seed(sources);
const payload = JSON.parse(toolText(await callManageStatus(sources)));
expect(payload.rows).toEqual([
{
repoFullName: "acme/widgets",
prNumber: 12,
branch: "feat/widget-cursor",
ciState: "passing",
gateVerdict: "approve",
outcome: "merged",
lastPolledAt: "2026-07-04T12:00:00.000Z",
queueStatus: "queued",
priority: 3,
},
]);
// The run-state-only repo has no PRs but must still appear (#4279), proving the fold is not PR-scoped.
expect(payload.runPortfolio.map((entry: { repoFullName: string }) => entry.repoFullName)).toEqual([
"acme/discovering-only",
"acme/widgets",
]);
expect(payload.runPortfolio).toEqual([
{ repoFullName: "acme/discovering-only", runState: "discovering", runStateUpdatedAt: expect.any(String), prCount: 0, prs: [] },
{ repoFullName: "acme/widgets", runState: "preparing", runStateUpdatedAt: expect.any(String), prCount: 1, prs: payload.rows },
]);
});

it("returns empty rows and an empty run portfolio when no managed PRs are recorded yet", async () => {
const payload = JSON.parse(toolText(await callManageStatus(tempStores())));
expect(payload).toEqual({ rows: [], runPortfolio: [] });
});

it("is structurally identical to collectManageStatus/collectRunPortfolio — the wrapper adds no drift (invariant)", async () => {
const sources = tempStores();
seed(sources);
const payload = JSON.parse(toolText(await callManageStatus(sources)));
expect(payload).toEqual({
rows: collectManageStatus(sources),
runPortfolio: collectRunPortfolio(sources),
});
});

it("reads without mutating: no enqueue, no appendEvent, no setRunState (invariant)", async () => {
const sources = tempStores();
seed(sources);
const enqueue = vi.spyOn(sources.portfolioQueue, "enqueue");
const appendEvent = vi.spyOn(sources.eventLedger, "appendEvent");
const setRunState = vi.spyOn(sources.runStateStore, "setRunState");
const listQueue = vi.spyOn(sources.portfolioQueue, "listQueue");
await callManageStatus(sources);
expect(listQueue).toHaveBeenCalled();
expect(enqueue).not.toHaveBeenCalled();
expect(appendEvent).not.toHaveBeenCalled();
expect(setRunState).not.toHaveBeenCalled();
});

it("leaves injected stores open — it closes only the stores it opened itself (invariant)", async () => {
const sources = tempStores();
seed(sources);
const closes = [
vi.spyOn(sources.portfolioQueue, "close"),
vi.spyOn(sources.eventLedger, "close"),
vi.spyOn(sources.runStateStore, "close"),
];
await callManageStatus(sources);
for (const close of closes) expect(close).not.toHaveBeenCalled();
// Still usable afterward — a closed sqlite handle would throw here.
expect(() => sources.portfolioQueue.listQueue(null)).not.toThrow();
});
});
1 change: 1 addition & 0 deletions test/unit/miner-mcp-scaffold.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ describe("loopover-miner MCP server (#5153 scaffold)", () => {
"loopover_miner_get_audit_feed",
"loopover_miner_get_calibration_report",
"loopover_miner_get_governor_decisions",
"loopover_miner_get_manage_status",
"loopover_miner_get_plan",
"loopover_miner_get_portfolio_dashboard",
"loopover_miner_get_run_state",
Expand Down