From a71cd200a72323755f89e0cb27b1d8f82be16e29 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:48:48 -0700 Subject: [PATCH 1/3] wip(contract): first four AMS miner tool contracts Refs #9536 --- packages/loopover-contract/src/tools/miner.ts | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 packages/loopover-contract/src/tools/miner.ts diff --git a/packages/loopover-contract/src/tools/miner.ts b/packages/loopover-contract/src/tools/miner.ts new file mode 100644 index 000000000..330836c23 --- /dev/null +++ b/packages/loopover-contract/src/tools/miner.ts @@ -0,0 +1,164 @@ +// AMS miner tool contracts (#9536). +// +// Every tool here is `locality: "miner"`: it reads the miner box's own local SQLite stores (plan, +// event ledger, governor ledger, run state, portfolio queue, claim ledger, prediction ledger). +// That is not a deployment preference -- no hosted Worker can reach those files, which is exactly +// why LoopOver runs a separate MCP server for AMS rather than collapsing to one process. +// +// Before this migration not one of these tools declared an output schema or returned +// structuredContent; every handler stringified JSON into a text block. The schemas below are +// modelled from the aggregators the handlers actually call, so the structured payload each tool +// gains is a description of what it already returns, not a new shape. +import { z } from "zod"; +import { defineTool } from "../tool-definition.js"; + +/** Statuses a portfolio-queue entry can hold. */ +export const QUEUE_STATUSES = ["queued", "in_progress", "done"] as const; + +/** Statuses a local claim-ledger row can hold. */ +export const CLAIM_STATUSES = ["active", "released", "expired"] as const; + +/** Per-status counts, repeated at both the global and per-repo level of the dashboard. */ +const queueStatusCounts = z.looseObject({ + queued: z.number(), + in_progress: z.number(), + done: z.number(), +}); + +// ── ping ──────────────────────────────────────────────────────────────────────────────────────── + +export const MinerPingInput = z.object({}); + +/** Static, and deliberately so: this tool exists to prove the server is reachable without touching + * any store, so its output is a fixed literal rather than anything derived. */ +export const MinerPingOutput = z.looseObject({ + status: z.literal("ok"), + tool: z.literal("loopover_miner_ping"), +}); + +export const minerPingTool = defineTool({ + name: "loopover_miner_ping", + title: "Miner health check", + description: + "Health check for the loopover-miner MCP server. Returns a static status object confirming the server is reachable. Reads no AMS state and takes no arguments.", + category: "utility", + auth: "public", + locality: "miner", + availability: "selfhost", + input: MinerPingInput, + output: MinerPingOutput, +}); + +// ── portfolio dashboard ───────────────────────────────────────────────────────────────────────── + +export const MinerPortfolioDashboardInput = z.object({}); + +/** `PortfolioDashboardSummary` (packages/loopover-miner/lib/portfolio-dashboard.ts). + * `oldestQueuedAgeMs` is null when no clock was supplied or nothing is queued -- a real absence, + * not a zero. */ +export const MinerPortfolioDashboardOutput = z.looseObject({ + total: z.number(), + byStatus: queueStatusCounts, + repos: z.array( + z.looseObject({ + apiBaseUrl: z.string(), + repoFullName: z.string(), + byStatus: queueStatusCounts, + total: z.number(), + }), + ), + oldestQueuedAgeMs: z.number().nullable(), +}); + +export const minerPortfolioDashboardTool = defineTool({ + name: "loopover_miner_get_portfolio_dashboard", + title: "Miner 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 `loopover-miner queue dashboard --json` prints locally. Takes no arguments; mutates nothing.", + category: "agent", + auth: "public", + locality: "miner", + availability: "selfhost", + input: MinerPortfolioDashboardInput, + output: MinerPortfolioDashboardOutput, +}); + +// ── manage status ─────────────────────────────────────────────────────────────────────────────── + +/** `ManageStatusRow`. Every field but the identifiers is nullable: a PR can be tracked before CI + * has reported, before the gate has run, and before it has an outcome. */ +export const manageStatusRowSchema = z.looseObject({ + repoFullName: z.string(), + prNumber: z.number(), + branch: z.string().nullable(), + ciState: z.string().nullable(), + gateVerdict: z.string().nullable(), + outcome: z.string().nullable(), + lastPolledAt: z.string().nullable(), + queueStatus: z.enum(QUEUE_STATUSES).nullable(), + priority: z.number().nullable(), +}); + +export const MinerManageStatusInput = z.object({}); + +/** `{ rows, runPortfolio }` -- the same pair `manage status --json` prints. */ +export const MinerManageStatusOutput = z.looseObject({ + rows: z.array(manageStatusRowSchema), + runPortfolio: z.array( + z.looseObject({ + repoFullName: z.string(), + runState: z.string().nullable(), + runStateUpdatedAt: z.string().nullable(), + prCount: z.number(), + prs: z.array(manageStatusRowSchema), + }), + ), +}); + +export const minerManageStatusTool = defineTool({ + name: "loopover_miner_get_manage_status", + title: "Miner manage-phase 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. Read-only: never calls GitHub, never mutates local stores. Takes no arguments.", + category: "agent", + auth: "public", + locality: "miner", + availability: "selfhost", + input: MinerManageStatusInput, + output: MinerManageStatusOutput, +}); + +// ── claims ────────────────────────────────────────────────────────────────────────────────────── + +export const MinerListClaimsInput = z.object({ + repoFullName: z.string().optional(), + status: z.enum(CLAIM_STATUSES).optional(), +}); + +/** The ledger's own row shape. Left open below the named fields because `listClaims` returns rows + * straight from SQLite, and the store has added columns over time without the MCP surface + * changing -- pinning it closed would make the next column a breaking change. */ +export const MinerListClaimsOutput = z.looseObject({ + claims: z.array( + z.looseObject({ + repoFullName: z.string(), + issueNumber: z.number(), + status: z.string(), + claimedAt: z.string().nullish(), + note: z.string().nullish(), + }), + ), +}); + +export const minerListClaimsTool = defineTool({ + name: "loopover_miner_list_claims", + title: "List miner 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.", + category: "agent", + auth: "public", + locality: "miner", + availability: "selfhost", + input: MinerListClaimsInput, + output: MinerListClaimsOutput, +}); From a43a4d04d6419d5f1802dee5f2b8868b89b461f8 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:20:59 -0700 Subject: [PATCH 2/3] feat(ams): migrate the AMS miner MCP server to @loopover/contract All 11 loopover_miner_* tools now register from the shared contract instead of declaring their own zod shapes -- the last of LoopOver's three MCP servers to gain a source of truth, and the only one with zero output schemas before this (every handler returned unschematized text). Every tool now emits structuredContent alongside its text block. Two real defects surfaced during the migration, not before it: - The output schema for loopover_miner_get_audit_feed initially wrapped collectEventLedgerAuditFeed's return value in a second { events: ... } layer, double-nesting a payload that already has that shape ({ repoFullName?, events }). The MCP SDK's own structured-output validation caught it immediately -- "expected array, received object" -- confirming the schema is doing its job as a runtime contract, not just documentation. - list_claims, list_plans, and governor_decisions previously returned BARE ARRAYS as their text payload (JSON.stringify(ledger.listClaims(...)), etc.), but an MCP structuredContent value must be a JSON object per spec. Wrapping structuredContent in { claims }/{ plans }/{ decisions } while leaving the text block as the original array (via a small structured/text split in the result helper) is what keeps every existing text-parsing consumer working unchanged -- confirmed by running the real handler before committing to the shape, not by inspecting the aggregator's declared TS return type alone. Also: a unified error envelope across all 11 tools. Before, only loopover_miner_get_audit_feed caught a store failure and returned isError: true; the other ten threw, so the same failure surfaced as a clean result from one tool and a raw protocol error from the rest. test/unit/miner-mcp-scaffold.test.ts's hardcoded 11-name array and miner-mcp-tool-docs-parity.test.ts's regex-scrape of the COMPILED dist bundle (a pattern my own earlier investigation flagged as fragile -- a reformatted registration call would silently break the guard, not the code) are both replaced by a direct read of listToolDefinitions({ locality: ["miner"] }): one list every server and every test derives from, no build step, no regex. Full unit suite: 23041 tests, 1208 files, all green. build:miner (135 files, check-syntax), test:miner-pack, and test:miner-deployment-docs-audit all pass unchanged. Closes #9536 --- packages/loopover-contract/src/tools/index.ts | 32 +- packages/loopover-contract/src/tools/miner.ts | 260 +++++++++ .../loopover-miner/bin/loopover-miner-mcp.ts | 543 +++++++++--------- packages/loopover-miner/package.json | 1 + test/unit/miner-mcp-scaffold.test.ts | 24 +- test/unit/miner-mcp-tool-docs-parity.test.ts | 20 +- 6 files changed, 569 insertions(+), 311 deletions(-) diff --git a/packages/loopover-contract/src/tools/index.ts b/packages/loopover-contract/src/tools/index.ts index 2cd00698b..73e22a1a5 100644 --- a/packages/loopover-contract/src/tools/index.ts +++ b/packages/loopover-contract/src/tools/index.ts @@ -10,12 +10,24 @@ import { predictGateTool } from "./predict-gate.js"; import { preflightPrTool } from "./preflight-pr.js"; import { localStatusStructuredTool } from "./local-status.js"; import { adminGetConfigTool } from "./admin-config.js"; +import { + minerPingTool, + minerPortfolioDashboardTool, + minerManageStatusTool, + minerListClaimsTool, + minerAuditFeedTool, + minerGetRunStateTool, + minerListPlansTool, + minerGetPlanTool, + minerGovernorDecisionsTool, + minerStatusTool, + minerCalibrationReportTool, +} from "./miner.js"; /** - * Pilot batch (#9517). The remaining ~110 remote / 96 stdio / 11 miner tools migrate in #9518's - * category batches; this set was chosen to exercise every axis of the model at least once -- - * remote and local-git locality, cloud/selfhost/both availability, and the token/session/ - * maintainer/mcp-admin auth levels. + * Pilot batch (#9517) plus the full AMS miner server (#9536, all 11 tools -- the first server + * migrated to completion). The remaining ~110 remote / ~91 stdio tools migrate in #9518/#9537's + * category batches. */ export const TOOL_CONTRACTS: readonly ToolContract[] = [ getRepoContextTool, @@ -24,6 +36,17 @@ export const TOOL_CONTRACTS: readonly ToolContract[] = [ preflightPrTool, localStatusStructuredTool, adminGetConfigTool, + minerPingTool, + minerPortfolioDashboardTool, + minerManageStatusTool, + minerListClaimsTool, + minerAuditFeedTool, + minerGetRunStateTool, + minerListPlansTool, + minerGetPlanTool, + minerGovernorDecisionsTool, + minerStatusTool, + minerCalibrationReportTool, ]; const CONTRACTS_BY_NAME: ReadonlyMap = new Map( @@ -49,3 +72,4 @@ export * from "./predict-gate.js"; export * from "./preflight-pr.js"; export * from "./local-status.js"; export * from "./admin-config.js"; +export * from "./miner.js"; diff --git a/packages/loopover-contract/src/tools/miner.ts b/packages/loopover-contract/src/tools/miner.ts index 330836c23..8c9eff646 100644 --- a/packages/loopover-contract/src/tools/miner.ts +++ b/packages/loopover-contract/src/tools/miner.ts @@ -162,3 +162,263 @@ export const minerListClaimsTool = defineTool({ input: MinerListClaimsInput, output: MinerListClaimsOutput, }); + +// ── audit feed ────────────────────────────────────────────────────────────────────────────────── + +export const MinerAuditFeedInput = z.object({ + repoFullName: z.string().min(1).optional(), + since: z.number().int().nonnegative().optional(), + type: z.string().min(1).optional(), +}); + +/** `{ repoFullName?, events }` -- `collectEventLedgerAuditFeed`'s real return shape. Each event is + * metadata only: `payload_json` and other raw ledger columns are never included by construction + * (an explicit named-column read, not a redaction step). */ +export const MinerAuditFeedOutput = z.looseObject({ + repoFullName: z.string().optional(), + events: z.array( + z.looseObject({ + eventType: z.string(), + repoFullName: z.string().nullable(), + outcome: z.string().nullable(), + actor: z.string().nullable(), + detail: z.string().nullable(), + createdAt: z.string(), + }), + ), +}); + +export const minerAuditFeedTool = defineTool({ + name: "loopover_miner_get_audit_feed", + title: "Miner audit feed", + description: + "Read-only, metadata-only audit feed from the local append-only event ledger: eventType, repoFullName, outcome, actor, detail, and createdAt per row. Wraps collectEventLedgerAuditFeed() (no new query logic) -- the same read filters as `loopover-miner ledger list` (--repo, --since, --type). Never returns payload_json or other raw ledger columns; never writes to the ledger.", + category: "agent", + auth: "public", + locality: "miner", + availability: "selfhost", + input: MinerAuditFeedInput, + output: MinerAuditFeedOutput, +}); + +// ── run state ─────────────────────────────────────────────────────────────────────────────────── + +/** `RunState` (packages/loopover-miner/lib/run-state.ts). */ +export const MINER_RUN_STATES = ["idle", "discovering", "planning", "preparing"] as const; + +export const MinerGetRunStateInput = z.object({ + repoFullName: z.string().min(1).optional(), +}); + +/** Two distinct shapes depending on whether `repoFullName` was supplied -- a single-repo lookup + * (whose `state` is null when nothing has been recorded yet) versus the full listing. Both fields + * optional here so one schema describes both without a discriminated union, which the MCP output + * schema does not need to enforce mutual exclusivity to be useful. */ +export const MinerGetRunStateOutput = z.looseObject({ + repoFullName: z.string().optional(), + state: z.enum(MINER_RUN_STATES).nullable().optional(), + states: z.array(z.looseObject({ repoFullName: z.string(), state: z.enum(MINER_RUN_STATES).nullable() })).optional(), +}); + +export const minerGetRunStateTool = defineTool({ + name: "loopover_miner_get_run_state", + title: "Miner run state", + description: + "Read-only per-repo miner run-state (idle/discovering/planning/preparing). Pass repoFullName for a single repo (a null state means none has been recorded for it yet), or omit it to list every repo's state. The read-only analog of ORB's loopover_get_automation_state; adds no state-set or mutation capability.", + category: "agent", + auth: "public", + locality: "miner", + availability: "selfhost", + input: MinerGetRunStateInput, + output: MinerGetRunStateOutput, +}); + +// ── plan store ────────────────────────────────────────────────────────────────────────────────── + +/** `PlanStatus` (packages/loopover-miner/lib/plan-store.ts). */ +export const MINER_PLAN_STATUSES = ["pending", "running", "completed", "failed"] as const; + +/** `PlanStepStatus`, same file. */ +export const MINER_PLAN_STEP_STATUSES = ["pending", "running", "completed", "failed", "skipped"] as const; + +/** `PlanStep`. `lastError` is nullish (both unset and explicit null appear -- the store's own type + * spells it `string | null | undefined`). */ +export const minerPlanStepSchema = z.looseObject({ + id: z.string(), + title: z.string(), + actionClass: z.string().optional(), + dependsOn: z.array(z.string()), + status: z.enum(MINER_PLAN_STEP_STATUSES), + attempts: z.number(), + maxAttempts: z.number(), + lastError: z.string().nullish(), +}); + +/** `PlanDag`. */ +export const minerPlanDagSchema = z.looseObject({ + steps: z.array(minerPlanStepSchema), +}); + +/** `PlanRecord`. */ +export const minerPlanRecordSchema = z.looseObject({ + planId: z.string(), + plan: minerPlanDagSchema, + status: z.enum(MINER_PLAN_STATUSES), + updatedAt: z.string(), +}); + +export const MinerListPlansInput = z.object({ + status: z.enum(MINER_PLAN_STATUSES).optional(), +}); + +export const MinerListPlansOutput = z.looseObject({ + plans: z.array(minerPlanRecordSchema), +}); + +export const minerListPlansTool = defineTool({ + name: "loopover_miner_list_plans", + title: "List miner 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 loopover_plan_status tool, which reads the caller's in-memory plan object rather than any persisted store.", + category: "agent", + auth: "public", + locality: "miner", + availability: "selfhost", + input: MinerListPlansInput, + output: MinerListPlansOutput, +}); + +export const MinerGetPlanInput = z.object({ + planId: z.string().min(1), +}); + +/** `{ planId, found: false }` for an unknown id, or `{ found: true, plan }` -- deliberately not a + * discriminated union in the schema for the same reason as get_run_state above. */ +export const MinerGetPlanOutput = z.looseObject({ + planId: z.string().optional(), + found: z.boolean(), + plan: minerPlanRecordSchema.optional(), +}); + +export const minerGetPlanTool = defineTool({ + name: "loopover_miner_get_plan", + title: "Get miner 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 loopover_plan_status tool.", + category: "agent", + auth: "public", + locality: "miner", + availability: "selfhost", + input: MinerGetPlanInput, + output: MinerGetPlanOutput, +}); + +// ── governor decisions ────────────────────────────────────────────────────────────────────────── + +export const MinerGovernorDecisionsInput = z.object({ + repoFullName: z.string().optional(), +}); + +/** `GovernorDecisionEntry` = `Omit` -- the raw `payload` column is + * excluded by an explicit named-column SELECT, not filtered after the fact. */ +export const MinerGovernorDecisionsOutput = z.looseObject({ + decisions: z.array( + z.looseObject({ + id: z.number(), + ts: z.string(), + eventType: z.string(), + repoFullName: z.string().nullable(), + actionClass: z.string(), + decision: z.string(), + reason: z.string(), + }), + ), +}); + +export const minerGovernorDecisionsTool = defineTool({ + name: "loopover_miner_get_governor_decisions", + title: "Miner governor decisions", + description: + "Read-only governor decision log: every accept/deny decision the local governor recorded, with its reason -- an explicit named-column SELECT, never SELECT *. Optional repoFullName filter (the only filter the ledger's readGovernorDecisions accepts). Excludes the raw payload column by construction; adds no decision-making, override, or write capability.", + category: "agent", + auth: "public", + locality: "miner", + availability: "selfhost", + input: MinerGovernorDecisionsInput, + output: MinerGovernorDecisionsOutput, +}); + +// ── status + doctor ───────────────────────────────────────────────────────────────────────────── + +export const MinerStatusInput = z.object({}); + +/** `{ status: MinerStatus, doctor: DoctorCheck[] }`. `MinerStatus`/`DoctorCheck`/`MinerDriverStatus` + * are all in packages/loopover-miner/lib/status.ts. Deliberately names/booleans/paths only, never + * an env-var VALUE, token, key, or credential -- `modelEnvVar` is the variable's NAME. */ +export const MinerStatusOutput = z.looseObject({ + status: z.looseObject({ + package: z.looseObject({ name: z.string(), version: z.string().nullable() }), + engine: z.looseObject({ name: z.string(), version: z.string().nullable() }), + node: z.string(), + stateDir: z.string(), + configFile: z.string().nullable(), + driver: z.looseObject({ + provider: z.string().nullable(), + modelEnvVar: z.string().nullable(), + cliPresent: z.boolean().nullable(), + }), + }), + doctor: z.array(z.looseObject({ name: z.string(), ok: z.boolean(), detail: z.string() })), +}); + +export const minerStatusTool = defineTool({ + name: "loopover_miner_status", + title: "Miner status and doctor", + description: + "Read-only miner status + doctor diagnostics. Returns { status, doctor }: status = package/engine versions (+ skew), node version, state-dir path, config-file path, and the resolved coding-agent driver (provider name, the model ENV-VAR NAME -- never its value -- and a CLI-present boolean); doctor = the same checks `loopover-miner doctor` runs (Docker/CLI presence, config validity, ...) as { name, ok, detail }. Reuses collectStatus/runDoctorChecks so it can never drift from the CLI. Only names / booleans / paths -- never any env-var value, token, key, or credential. Read-only; no writes or state changes.", + category: "utility", + auth: "public", + locality: "miner", + availability: "selfhost", + input: MinerStatusInput, + output: MinerStatusOutput, +}); + +// ── calibration report ────────────────────────────────────────────────────────────────────────── + +export const MinerCalibrationReportInput = z.object({}); + +/** `CalibrationReport` (packages/loopover-miner/lib/calibration-types.ts). `hasSignal` is true once + * at least one project has enough decided samples to read meaningfully; the two precision fields + * are null until then, not zero. */ +export const MinerCalibrationReportOutput = z.looseObject({ + rows: z.array( + z.looseObject({ + project: z.string(), + wouldMerge: z.number(), + mergeConfirmed: z.number(), + mergeFalse: z.number(), + wouldClose: z.number(), + closeConfirmed: z.number(), + closeFalse: z.number(), + hold: z.number(), + decided: z.number(), + mergePrecision: z.number().nullable(), + closePrecision: z.number().nullable(), + }), + ), + hasSignal: z.boolean(), +}); + +export const minerCalibrationReportTool = defineTool({ + name: "loopover_miner_get_calibration_report", + title: "Miner calibration report", + description: + "Read-only miner-local prediction-accuracy report: per-project merge/close precision, joining this miner's own recorded gate predictions (prediction ledger) with the realized PR outcomes it later observed (pr_outcome events). Wraps calibration-cli.js's existing toPredictionRecords/toOutcomeRecords mappers and calibration.js's buildCalibrationReport composer -- no new join/scoring logic, no mutation. Strictly local and offline; distinct from ORB's hosted, maintainer-authenticated loopover_get_outcome_calibration tool, which reads a different (D1) data source. Takes no arguments.", + category: "agent", + auth: "public", + locality: "miner", + availability: "selfhost", + input: MinerCalibrationReportInput, + output: MinerCalibrationReportOutput, +}); diff --git a/packages/loopover-miner/bin/loopover-miner-mcp.ts b/packages/loopover-miner/bin/loopover-miner-mcp.ts index 57bede90b..0cf3a1ccf 100755 --- a/packages/loopover-miner/bin/loopover-miner-mcp.ts +++ b/packages/loopover-miner/bin/loopover-miner-mcp.ts @@ -3,19 +3,30 @@ 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 { CLAIM_STATUSES, openClaimLedger } from "../lib/claim-ledger.js"; +// #9536: every tool's schemas come from the shared contract instead of being declared here. The +// remote and stdio servers already register from the same package (#9517/#9518) -- this closes the +// gap that made AMS the one server with no structured output and no shared source of truth. import { - type AuditFeedMcpFilterInput, - collectEventLedgerAuditFeed, - normalizeAuditFeedMcpFilter, -} from "../lib/event-ledger-cli.js"; + minerPingTool, + minerPortfolioDashboardTool, + minerManageStatusTool, + minerListClaimsTool, + minerAuditFeedTool, + minerGetRunStateTool, + minerListPlansTool, + minerGetPlanTool, + minerGovernorDecisionsTool, + minerStatusTool, + minerCalibrationReportTool, +} from "@loopover/contract/tools"; +import { openClaimLedger } from "../lib/claim-ledger.js"; +import { type AuditFeedMcpFilterInput, collectEventLedgerAuditFeed, normalizeAuditFeedMcpFilter } from "../lib/event-ledger-cli.js"; import { initEventLedger, type EventLedger } from "../lib/event-ledger.js"; import { collectManageStatus, collectRunPortfolio } from "../lib/manage-status.js"; import { collectPortfolioDashboard } from "../lib/portfolio-dashboard.js"; import { initPortfolioQueueStore, type PortfolioQueueStore } from "../lib/portfolio-queue.js"; import { initRunStateStore, type RunStateStore } from "../lib/run-state.js"; -import { PLAN_STATUSES, openPlanStore } from "../lib/plan-store.js"; +import { openPlanStore } from "../lib/plan-store.js"; import { initGovernorLedger } from "../lib/governor-ledger.js"; import { collectStatus, runDoctorChecks } from "../lib/status.js"; import { buildCalibrationReport } from "../lib/calibration.js"; @@ -27,29 +38,64 @@ import { captureMinerErrorAndFlush, initMinerSentry } from "../lib/sentry.js"; import { captureMinerPostHogErrorAndFlush, initMinerPostHog } from "../lib/posthog.js"; // MCP stdio server for @loopover/miner (scaffold #5153). Mirrors the packages/loopover-mcp -// harness (MCP SDK server + stdio transport). Tools: -// - 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 -// collectEventLedgerAuditFeed() (same filters as `ledger list`; never returns payload_json). -// - loopover_miner_get_run_state (#5160): read-only per-repo run-state via run-state.js's getRunState/ -// listRunStates (read-only analog of ORB's loopover_get_automation_state; no state-set mutation). -// - loopover_miner_list_plans / loopover_miner_get_plan (#5161): read-only access to the persisted -// plan store via plan-store.js's listPlans/loadPlan (distinct from ORB's stateless loopover_plan_status). -// - loopover_miner_get_governor_decisions (#5159): read-only governor decision-log projection via -// governor-ledger.js's readGovernorDecisions -- an explicit named-column read that excludes payload_json. -// - loopover_miner_status (#5154): read-only status + doctor diagnostics via status.js's collectStatus/ -// runDoctorChecks (names/booleans/paths only -- never any env-var value, token, key, or credential). -// - loopover_miner_get_calibration_report (#5821): read-only miner-local prediction-accuracy report, joining -// the prediction ledger with observed pr_outcome events via calibration-cli.js's existing toPredictionRecords/ -// toOutcomeRecords mappers and calibration.js's buildCalibrationReport composer (no new join logic). Distinct -// from ORB's hosted, maintainer-authenticated loopover_get_outcome_calibration tool. +// harness (MCP SDK server + stdio transport). All 11 tools are read-only over local state; none +// call GitHub or mutate a store. Name, description, category, and both schemas for each one now +// come from @loopover/contract/tools (#9536) rather than being declared here -- see miner.ts in +// that package for what each tool actually reads (portfolio queue, event ledger, run-state store, +// plan store, governor ledger, claim ledger, prediction ledger). + +/** + * Every tool's declared error code, closed by construction: `getToolErrorCode` narrows unknown + * thrown values to this set rather than passing a caller/store-derived string through, matching the + * `code`/`message` shape @loopover/contract's shared `toolErrorFields` describes. + */ +type MinerToolErrorCode = "store_unavailable" | "unknown_error"; + +function toolErrorCode(error: unknown): MinerToolErrorCode { + // A local SQLite store failing to open (missing file, corrupted file, permissions) is the one + // failure mode every store-backed tool below can actually hit; anything else is unclassified. + return error instanceof Error && /not found|not a database|permission|ENOENT/i.test(error.message) ? "store_unavailable" : "unknown_error"; +} + +/** + * Build a tool result carrying BOTH the text block and `structuredContent` (#9536: none of these + * tools returned structuredContent before this migration). `structuredContent` must be a JSON + * object per the MCP spec, so a handler whose real return value is a bare array supplies `text` + * separately to keep the text block byte-identical to what every current consumer already parses -- + * only `structuredContent` gains the object wrapper an output schema requires. + */ +function minerToolResult( + structuredContent: object, + text: unknown = structuredContent, +): { content: [{ type: "text"; text: string }]; structuredContent: Record } { + return { content: [{ type: "text", text: JSON.stringify(text) }], structuredContent: structuredContent as Record }; +} + +/** A handler's real result, plus the original (possibly array-shaped) text payload when it differs + * from the object `structuredContent` requires. */ +type MinerToolRun = T | { structured: T; text: unknown }; + +function isTextOverride(value: MinerToolRun): value is { structured: T; text: unknown } { + return "structured" in value && "text" in value; +} + +/** + * The unified error envelope (#9536). Before this, only loopover_miner_get_audit_feed caught a + * store failure and returned `isError: true`; the other ten threw, so the same class of failure + * surfaced as a clean structured result from one tool and a raw protocol error from the rest. Every + * tool below routes its store access through this wrapper instead. + */ +async function withMinerToolErrorHandling( + run: () => Promise> | MinerToolRun, +): Promise<{ content: [{ type: "text"; text: string }]; structuredContent: Record; isError?: true }> { + try { + const result = await run(); + return isTextOverride(result) ? minerToolResult(result.structured, result.text) : minerToolResult(result); + } catch (error) { + const data = { error: { code: toolErrorCode(error), message: error instanceof Error ? error.message : String(error) } }; + return { ...minerToolResult(data), isError: true }; + } +} // 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. Self-referencing package import @@ -65,13 +111,6 @@ import { captureMinerPostHogErrorAndFlush, initMinerPostHog } from "../lib/posth const packageJsonPath = fileURLToPath(import.meta.resolve("@loopover/miner/package.json")); const ownPackageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")); -/** Optional filters accepted by loopover_miner_get_audit_feed (#5158). */ -const auditFeedInputSchema = { - repoFullName: z.string().min(1).optional(), - since: z.number().int().nonnegative().optional(), - type: z.string().min(1).optional(), -}; - /** The static, non-secret payload the ping tool always returns, independent of any input or AMS state. */ export const MINER_PING_STATUS = { status: "ok", tool: "loopover_miner_ping" }; @@ -142,287 +181,219 @@ export interface MinerMcpServerOptions { */ export function createMinerMcpServer(options: MinerMcpServerOptions = {}) { const server = new McpServer({ name: "loopover-miner", version: ownPackageJson.version }); + server.registerTool( - "loopover_miner_ping", - { - description: - "Health check for the loopover-miner MCP server. Returns a static status object confirming the " + - "server is reachable. Reads no AMS state and takes no arguments.", - inputSchema: {}, - }, - async () => ({ content: [{ type: "text", text: JSON.stringify(MINER_PING_STATUS) }] }), + minerPingTool.name, + { description: minerPingTool.description, inputSchema: minerPingTool.input.shape, outputSchema: minerPingTool.output.shape }, + async () => minerToolResult(MINER_PING_STATUS), ); + server.registerTool( - "loopover_miner_get_portfolio_dashboard", + minerPortfolioDashboardTool.name, { - 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 `loopover-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(); - } + description: minerPortfolioDashboardTool.description, + inputSchema: minerPortfolioDashboardTool.input.shape, + outputSchema: minerPortfolioDashboardTool.output.shape, }, + () => + withMinerToolErrorHandling(() => { + const ownsQueue = options.initPortfolioQueue === undefined; + const portfolioQueue = (options.initPortfolioQueue ?? initPortfolioQueueStore)(); + try { + return collectPortfolioDashboard({ portfolioQueue }, { nowMs: options.nowMs ?? Date.now() }); + } finally { + if (ownsQueue) portfolioQueue.close(); + } + }), ); + server.registerTool( - "loopover_miner_get_manage_status", + minerManageStatusTool.name, { - 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 { - // The injection seams above are typed to the minimal read surface each tool touches (mirroring the - // dashboard tool's `{ listQueue }` seam), but collectManageStatus/collectRunPortfolio's declared source - // types name the full stores. Both aggregators only ever read (listQueue/getRunState/listRunStates) at - // runtime -- exactly what the seam guarantees -- so widening the resolved stores back to the store types - // the signatures ask for is sound; it never reaches a write/lifecycle method the minimal seam omits. - const rows = collectManageStatus({ - portfolioQueue: portfolioQueue as PortfolioQueueStore, - eventLedger, - }); - const runPortfolio = collectRunPortfolio({ - portfolioQueue: portfolioQueue as PortfolioQueueStore, - eventLedger, - runStateStore: runStateStore as RunStateStore, - }); - return { content: [{ type: "text", text: JSON.stringify({ rows, runPortfolio }) }] }; - } finally { - if (ownsPortfolioQueue) portfolioQueue.close(); - if (ownsEventLedger) eventLedger.close(); - if (ownsRunStateStore) runStateStore.close(); - } + description: minerManageStatusTool.description, + inputSchema: minerManageStatusTool.input.shape, + outputSchema: minerManageStatusTool.output.shape, }, + () => + withMinerToolErrorHandling(() => { + 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 { + // The injection seams above are typed to the minimal read surface each tool touches (mirroring the + // dashboard tool's `{ listQueue }` seam), but collectManageStatus/collectRunPortfolio's declared source + // types name the full stores. Both aggregators only ever read (listQueue/getRunState/listRunStates) at + // runtime -- exactly what the seam guarantees -- so widening the resolved stores back to the store types + // the signatures ask for is sound; it never reaches a write/lifecycle method the minimal seam omits. + const rows = collectManageStatus({ portfolioQueue: portfolioQueue as PortfolioQueueStore, eventLedger }); + const runPortfolio = collectRunPortfolio({ + portfolioQueue: portfolioQueue as PortfolioQueueStore, + eventLedger, + runStateStore: runStateStore as RunStateStore, + }); + return { rows, runPortfolio }; + } finally { + if (ownsPortfolioQueue) portfolioQueue.close(); + if (ownsEventLedger) eventLedger.close(); + if (ownsRunStateStore) runStateStore.close(); + } + }), ); + server.registerTool( - "loopover_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: { repoFullName?: string; status?: string } = {}; - 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(); - } - }, + minerListClaimsTool.name, + { description: minerListClaimsTool.description, inputSchema: minerListClaimsTool.input.shape, outputSchema: minerListClaimsTool.output.shape }, + ({ repoFullName, status }) => + withMinerToolErrorHandling(() => { + const ownsLedger = options.openClaimLedger === undefined; + const ledger = (options.openClaimLedger ?? openClaimLedger)(); + try { + const filter: { repoFullName?: string; status?: string } = {}; + if (repoFullName !== undefined) filter.repoFullName = repoFullName; + if (status !== undefined) filter.status = status; + const claims = ledger.listClaims(filter); + // The text block stays the bare array every current consumer already parses; + // structuredContent gets the object wrapper the MCP spec (and the output schema) requires. + return { structured: { claims }, text: claims }; + } finally { + if (ownsLedger) ledger.close(); + } + }), ); + server.registerTool( - "loopover_miner_get_audit_feed", - { - description: - "Read-only, metadata-only audit feed from the local append-only event ledger: eventType, repoFullName, " + - "outcome, actor, detail, and createdAt per row. Wraps collectEventLedgerAuditFeed() (no new query logic) — " + - "the same read filters as `loopover-miner ledger list` (--repo, --since, --type). Never returns " + - "payload_json or other raw ledger columns; never writes to the ledger.", - inputSchema: auditFeedInputSchema, - }, - async (input) => { - const ownsLedger = options.initEventLedger === undefined; - const eventLedger = (options.initEventLedger ?? initEventLedger)(); - try { - // zod's `.optional()` widens each field to `string | undefined`, whereas normalizeAuditFeedMcpFilter's - // input type spells the same absent-field slot as `string | null`; the normalizer treats missing and - // null identically, so narrowing the parsed input to that shape is exact, not a behavior change. - const filter = normalizeAuditFeedMcpFilter((input ?? {}) as AuditFeedMcpFilterInput); - const feed = collectEventLedgerAuditFeed(eventLedger, filter); - return { content: [{ type: "text", text: JSON.stringify(feed) }] }; - } catch (error) { - return { - content: [ - { - type: "text", - text: JSON.stringify({ - error: error instanceof Error ? error.message : String(error), - }), - }, - ], - isError: true, - }; - } finally { - if (ownsLedger) eventLedger.close(); - } - }, + minerAuditFeedTool.name, + { description: minerAuditFeedTool.description, inputSchema: minerAuditFeedTool.input.shape, outputSchema: minerAuditFeedTool.output.shape }, + (input) => + withMinerToolErrorHandling(() => { + const ownsLedger = options.initEventLedger === undefined; + const eventLedger = (options.initEventLedger ?? initEventLedger)(); + try { + // zod's `.optional()` widens each field to `string | undefined`, whereas normalizeAuditFeedMcpFilter's + // input type spells the same absent-field slot as `string | null`; the normalizer treats missing and + // null identically, so narrowing the parsed input to that shape is exact, not a behavior change. + const filter = normalizeAuditFeedMcpFilter((input ?? {}) as AuditFeedMcpFilterInput); + // collectEventLedgerAuditFeed already returns { repoFullName?, events } -- return it directly + // rather than re-wrapping it in another `events` key. + return collectEventLedgerAuditFeed(eventLedger, filter); + } finally { + if (ownsLedger) eventLedger.close(); + } + }), ); + server.registerTool( - "loopover_miner_get_run_state", + minerGetRunStateTool.name, { - description: - "Read-only per-repo miner run-state (idle/discovering/planning/preparing). Pass repoFullName for a single " + - "repo (a null state means none has been recorded for it yet), or omit it to list every repo's state. The " + - "read-only analog of ORB's loopover_get_automation_state; adds no state-set or mutation capability.", - inputSchema: { - repoFullName: z.string().min(1).optional(), - }, - }, - async ({ repoFullName }) => { - const ownsStore = options.initRunStateStore === undefined; - const store = (options.initRunStateStore ?? initRunStateStore)(); - try { - const result = - repoFullName === undefined - ? { states: store.listRunStates() } - : { repoFullName, state: store.getRunState(repoFullName) }; - return { content: [{ type: "text", text: JSON.stringify(result) }] }; - } finally { - if (ownsStore) store.close(); - } + description: minerGetRunStateTool.description, + inputSchema: minerGetRunStateTool.input.shape, + outputSchema: minerGetRunStateTool.output.shape, }, + ({ repoFullName }) => + withMinerToolErrorHandling(() => { + const ownsStore = options.initRunStateStore === undefined; + const store = (options.initRunStateStore ?? initRunStateStore)(); + try { + return repoFullName === undefined ? { states: store.listRunStates() } : { repoFullName, state: store.getRunState(repoFullName) }; + } finally { + if (ownsStore) store.close(); + } + }), ); + server.registerTool( - "loopover_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 loopover_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: { status?: string } = {}; - if (status !== undefined) filter.status = status; - return { content: [{ type: "text", text: JSON.stringify(store.listPlans(filter)) }] }; - } finally { - if (ownsStore) store.close(); - } - }, + minerListPlansTool.name, + { description: minerListPlansTool.description, inputSchema: minerListPlansTool.input.shape, outputSchema: minerListPlansTool.output.shape }, + ({ status }) => + withMinerToolErrorHandling(() => { + const ownsStore = options.openPlanStore === undefined; + const store = (options.openPlanStore ?? openPlanStore)(); + try { + const filter: { status?: string } = {}; + if (status !== undefined) filter.status = status; + const plans = store.listPlans(filter); + return { structured: { plans }, text: plans }; + } finally { + if (ownsStore) store.close(); + } + }), ); + server.registerTool( - "loopover_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 " + - "loopover_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(); - } - }, + minerGetPlanTool.name, + { description: minerGetPlanTool.description, inputSchema: minerGetPlanTool.input.shape, outputSchema: minerGetPlanTool.output.shape }, + ({ planId }) => + withMinerToolErrorHandling(() => { + const ownsStore = options.openPlanStore === undefined; + const store = (options.openPlanStore ?? openPlanStore)(); + try { + const plan = store.loadPlan(planId); + return plan === null ? { planId, found: false } : { found: true, plan }; + } finally { + if (ownsStore) store.close(); + } + }), ); + server.registerTool( - "loopover_miner_get_governor_decisions", + minerGovernorDecisionsTool.name, { - description: - "Read-only projection of the governor decision log: id, ts, eventType, repoFullName, actionClass, " + - "decision, reason per row. This projection INTENTIONALLY EXCLUDES the internal/sensitive payload column " + - "(reputation / self-plagiarism / budget state) by construction -- governor-ledger.js reads it with an " + - "explicit named-column SELECT, never SELECT *. Optional repoFullName filter (the only filter the ledger " + - "supports natively). Read-only; never writes to the ledger.", - inputSchema: { - repoFullName: z.string().min(1).optional(), - }, - }, - async ({ repoFullName }) => { - const ownsLedger = options.initGovernorLedger === undefined; - const ledger = (options.initGovernorLedger ?? initGovernorLedger)(); - try { - const filter: { repoFullName?: string } = {}; - if (repoFullName !== undefined) filter.repoFullName = repoFullName; - return { content: [{ type: "text", text: JSON.stringify(ledger.readGovernorDecisions(filter)) }] }; - } finally { - if (ownsLedger) ledger.close(); - } + description: minerGovernorDecisionsTool.description, + inputSchema: minerGovernorDecisionsTool.input.shape, + outputSchema: minerGovernorDecisionsTool.output.shape, }, + ({ repoFullName }) => + withMinerToolErrorHandling(() => { + const ownsLedger = options.initGovernorLedger === undefined; + const ledger = (options.initGovernorLedger ?? initGovernorLedger)(); + try { + const filter: { repoFullName?: string } = {}; + if (repoFullName !== undefined) filter.repoFullName = repoFullName; + const decisions = ledger.readGovernorDecisions(filter); + return { structured: { decisions }, text: decisions }; + } finally { + if (ownsLedger) ledger.close(); + } + }), ); + server.registerTool( - "loopover_miner_status", - { - description: - "Read-only miner status + doctor diagnostics. Returns { status, doctor }: status = package/engine versions " + - "(+ skew), node version, state-dir path, config-file path, and the resolved coding-agent driver (provider " + - "name, the model ENV-VAR NAME -- never its value -- and a CLI-present boolean); doctor = the same checks " + - "`loopover-miner doctor` runs (Docker/CLI presence, config validity, ...) as { name, ok, detail }. Reuses " + - "collectStatus/runDoctorChecks so it can never drift from the CLI. Only names / booleans / paths -- never " + - "any env-var value, token, key, or credential. Read-only; no writes or state changes.", - inputSchema: {}, - }, - async () => { - const status = (options.collectStatus ?? collectStatus)(); - const doctor = (options.runDoctorChecks ?? runDoctorChecks)(); - return { content: [{ type: "text", text: JSON.stringify({ status, doctor }) }] }; - }, + minerStatusTool.name, + { description: minerStatusTool.description, inputSchema: minerStatusTool.input.shape, outputSchema: minerStatusTool.output.shape }, + () => + withMinerToolErrorHandling(() => ({ + status: (options.collectStatus ?? collectStatus)(), + doctor: (options.runDoctorChecks ?? runDoctorChecks)(), + })), ); + server.registerTool( - "loopover_miner_get_calibration_report", + minerCalibrationReportTool.name, { - description: - "Read-only miner-local prediction-accuracy report: per-project merge/close precision, joining this " + - "miner's own recorded gate predictions (prediction ledger) with the realized PR outcomes it later " + - "observed (pr_outcome events). Wraps calibration-cli.js's existing toPredictionRecords/toOutcomeRecords " + - "mappers and calibration.js's buildCalibrationReport composer -- no new join/scoring logic, no mutation. " + - "Strictly local and offline; distinct from ORB's hosted, maintainer-authenticated " + - "loopover_get_outcome_calibration tool, which reads a different (D1) data source. Takes no arguments.", - inputSchema: {}, - }, - async () => { - const ownsPredictionLedger = options.initPredictionLedger === undefined; - const ownsEventLedger = options.initEventLedger === undefined; - let predictionLedger; - let eventLedger; - try { - predictionLedger = (options.initPredictionLedger ?? initPredictionLedger)(); - eventLedger = (options.initEventLedger ?? initEventLedger)(); - const report = buildCalibrationReport( - toPredictionRecords(predictionLedger.readPredictions()), - toOutcomeRecords(eventLedger.readEvents()), - ); - return { content: [{ type: "text", text: JSON.stringify(report) }] }; - } finally { - if (ownsPredictionLedger) predictionLedger?.close(); - if (ownsEventLedger) eventLedger?.close(); - } + description: minerCalibrationReportTool.description, + inputSchema: minerCalibrationReportTool.input.shape, + outputSchema: minerCalibrationReportTool.output.shape, }, + () => + withMinerToolErrorHandling(() => { + const ownsPredictionLedger = options.initPredictionLedger === undefined; + const ownsEventLedger = options.initEventLedger === undefined; + let predictionLedger; + let eventLedger; + try { + predictionLedger = (options.initPredictionLedger ?? initPredictionLedger)(); + eventLedger = (options.initEventLedger ?? initEventLedger)(); + return buildCalibrationReport(toPredictionRecords(predictionLedger.readPredictions()), toOutcomeRecords(eventLedger.readEvents())); + } finally { + if (ownsPredictionLedger) predictionLedger?.close(); + if (ownsEventLedger) eventLedger?.close(); + } + }), ); + return server; } diff --git a/packages/loopover-miner/package.json b/packages/loopover-miner/package.json index efc7b2209..0c66f6471 100644 --- a/packages/loopover-miner/package.json +++ b/packages/loopover-miner/package.json @@ -47,6 +47,7 @@ "build:verify": "node scripts/check-syntax.mjs" }, "dependencies": { + "@loopover/contract": "^0.1.0", "@loopover/engine": "^3.15.2", "@modelcontextprotocol/sdk": "1.29.0", "@sentry/node": "^10.67.0", diff --git a/test/unit/miner-mcp-scaffold.test.ts b/test/unit/miner-mcp-scaffold.test.ts index 7613255cc..b7ccd2b03 100644 --- a/test/unit/miner-mcp-scaffold.test.ts +++ b/test/unit/miner-mcp-scaffold.test.ts @@ -7,6 +7,7 @@ import { type MinerMcpServerOptions, } from "../../packages/loopover-miner/bin/loopover-miner-mcp"; import { collectPortfolioDashboard } from "../../packages/loopover-miner/lib/portfolio-dashboard"; +import { listToolDefinitions } from "@loopover/contract/tools"; // Tests for the loopover-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 @@ -87,22 +88,17 @@ function fakeLedger(rows: Array<{ repoFullName: string; status: string }>): Fake } describe("loopover-miner MCP server (#5153 scaffold)", () => { - it("exposes the ping, portfolio-dashboard, and list-claims tools", async () => { + it("exposes exactly the miner-locality tools the contract registry declares (#9536)", async () => { + // Asserted against the registry rather than a hand-copied name list: a tool added to + // @loopover/contract/tools with locality "miner" is picked up here automatically, so adding one + // can no longer silently miss this test the way a hardcoded array would let it. const client = await connectedClient(); const { tools } = await client.listTools(); - expect(tools.map((tool) => tool.name).sort()).toEqual([ - "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", - "loopover_miner_list_claims", - "loopover_miner_list_plans", - "loopover_miner_ping", - "loopover_miner_status", - ]); + const expectedNames = listToolDefinitions({ locality: ["miner"] }) + .map((tool) => tool.name) + .sort(); + expect(expectedNames.length).toBeGreaterThan(0); + expect(tools.map((tool) => tool.name).sort()).toEqual(expectedNames); }); it("loopover_miner_ping returns the static, non-secret status object", async () => { diff --git a/test/unit/miner-mcp-tool-docs-parity.test.ts b/test/unit/miner-mcp-tool-docs-parity.test.ts index c281d8c01..6aa155a6c 100644 --- a/test/unit/miner-mcp-tool-docs-parity.test.ts +++ b/test/unit/miner-mcp-tool-docs-parity.test.ts @@ -1,18 +1,24 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; +import { listToolDefinitions } from "@loopover/contract/tools"; -const MCP_BIN_PATH = join(process.cwd(), "packages/loopover-miner/dist/bin/loopover-miner-mcp.js"); const README_PATH = join(process.cwd(), "packages/loopover-miner/README.md"); const CODING_AGENT_DRIVER_DOC_PATH = join(process.cwd(), "packages/loopover-miner/docs/coding-agent-driver.md"); -/** Every `server.registerTool("loopover_miner_...", ...)` name in the real MCP bin -- the source of truth - * this test pins the README's "MCP server" section against, so the two can never silently drift (#5162). */ +/** + * Every miner-locality tool name from `@loopover/contract` -- the source of truth this test pins + * the README's "MCP server" section against (#5162, #9536). + * + * Previously regex-scraped `server.registerTool("loopover_miner_...", ...)` calls out of the + * *compiled* dist bundle, which meant (a) it needed a fresh `npm run build:miner` to see a real + * change and (b) any reformatting of the registration call (the exact pattern #9517's own + * investigation flagged as fragile in the sibling stdio-completion test) silently broke the guard + * instead of the code. The registry removes both failure modes: it is the same list every server + * registers from, read directly, no build step and no regex between this test and reality. + */ function registeredMinerMcpToolNames(): string[] { - const source = readFileSync(MCP_BIN_PATH, "utf8"); - const names = [...source.matchAll(/server\.registerTool\(\s*\n?\s*"(loopover_miner_\w+)"/g)] - .map((m) => m[1]) - .filter((name): name is string => name !== undefined); + const names = listToolDefinitions({ locality: ["miner"] }).map((tool) => tool.name); expect(names.length).toBeGreaterThan(0); return names; } From b77e15448840990411f432668fa653e0cf5b2d1a Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:01:13 -0700 Subject: [PATCH 3/3] fix(ams): use the contract's named schema exports, not the ToolContract.input accessor Registering with minerXTool.input.shape/output.shape compiled fine standalone but broke once each handler declared an explicit z.infer parameter type: ToolContract.input is typed as the general z.ZodObject class (the type every contract entry shares), so z.infer collapses to a generic/incorrect shape instead of the tool's real fields -- and passing that same widened .shape to registerTool's inputSchema, while the handler declares the SPECIFIC narrower type, is a real contravariant mismatch the MCP SDK's overload correctly rejected (planId missing in type ShapeOutput<...>). Every registration and handler now references the tool's own named MinerXInput/MinerXOutput export from @loopover/contract/tools instead of the ToolContract.input/.output accessor -- the schema passed to registerTool and the type the handler destructures against are now the same concrete object. build:miner and the full repo typecheck are clean; 106 tests green across miner-mcp-scaffold/contract/audit-feed/manage-status/calibration-report/ governor-decisions/tool-docs-parity and contract-registry. Refs #9536 --- .../loopover-miner/bin/loopover-miner-mcp.ts | 69 +++++++++++++------ 1 file changed, 47 insertions(+), 22 deletions(-) diff --git a/packages/loopover-miner/bin/loopover-miner-mcp.ts b/packages/loopover-miner/bin/loopover-miner-mcp.ts index 0cf3a1ccf..8f8369bc5 100755 --- a/packages/loopover-miner/bin/loopover-miner-mcp.ts +++ b/packages/loopover-miner/bin/loopover-miner-mcp.ts @@ -6,6 +6,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" // #9536: every tool's schemas come from the shared contract instead of being declared here. The // remote and stdio servers already register from the same package (#9517/#9518) -- this closes the // gap that made AMS the one server with no structured output and no shared source of truth. +import { z } from "zod"; import { minerPingTool, minerPortfolioDashboardTool, @@ -19,6 +20,30 @@ import { minerStatusTool, minerCalibrationReportTool, } from "@loopover/contract/tools"; +import { + MinerPingInput, + MinerPingOutput, + MinerPortfolioDashboardInput, + MinerPortfolioDashboardOutput, + MinerManageStatusInput, + MinerManageStatusOutput, + MinerListClaimsInput, + MinerListClaimsOutput, + MinerAuditFeedInput, + MinerAuditFeedOutput, + MinerGetRunStateInput, + MinerGetRunStateOutput, + MinerListPlansInput, + MinerListPlansOutput, + MinerGetPlanInput, + MinerGetPlanOutput, + MinerGovernorDecisionsInput, + MinerGovernorDecisionsOutput, + MinerStatusInput, + MinerStatusOutput, + MinerCalibrationReportInput, + MinerCalibrationReportOutput, +} from "@loopover/contract/tools"; import { openClaimLedger } from "../lib/claim-ledger.js"; import { type AuditFeedMcpFilterInput, collectEventLedgerAuditFeed, normalizeAuditFeedMcpFilter } from "../lib/event-ledger-cli.js"; import { initEventLedger, type EventLedger } from "../lib/event-ledger.js"; @@ -184,7 +209,7 @@ export function createMinerMcpServer(options: MinerMcpServerOptions = {}) { server.registerTool( minerPingTool.name, - { description: minerPingTool.description, inputSchema: minerPingTool.input.shape, outputSchema: minerPingTool.output.shape }, + { description: minerPingTool.description, inputSchema: MinerPingInput.shape, outputSchema: MinerPingOutput.shape }, async () => minerToolResult(MINER_PING_STATUS), ); @@ -192,8 +217,8 @@ export function createMinerMcpServer(options: MinerMcpServerOptions = {}) { minerPortfolioDashboardTool.name, { description: minerPortfolioDashboardTool.description, - inputSchema: minerPortfolioDashboardTool.input.shape, - outputSchema: minerPortfolioDashboardTool.output.shape, + inputSchema: MinerPortfolioDashboardInput.shape, + outputSchema: MinerPortfolioDashboardOutput.shape, }, () => withMinerToolErrorHandling(() => { @@ -211,8 +236,8 @@ export function createMinerMcpServer(options: MinerMcpServerOptions = {}) { minerManageStatusTool.name, { description: minerManageStatusTool.description, - inputSchema: minerManageStatusTool.input.shape, - outputSchema: minerManageStatusTool.output.shape, + inputSchema: MinerManageStatusInput.shape, + outputSchema: MinerManageStatusOutput.shape, }, () => withMinerToolErrorHandling(() => { @@ -245,8 +270,8 @@ export function createMinerMcpServer(options: MinerMcpServerOptions = {}) { server.registerTool( minerListClaimsTool.name, - { description: minerListClaimsTool.description, inputSchema: minerListClaimsTool.input.shape, outputSchema: minerListClaimsTool.output.shape }, - ({ repoFullName, status }) => + { description: minerListClaimsTool.description, inputSchema: MinerListClaimsInput.shape, outputSchema: MinerListClaimsOutput.shape }, + ({ repoFullName, status }: z.infer) => withMinerToolErrorHandling(() => { const ownsLedger = options.openClaimLedger === undefined; const ledger = (options.openClaimLedger ?? openClaimLedger)(); @@ -266,8 +291,8 @@ export function createMinerMcpServer(options: MinerMcpServerOptions = {}) { server.registerTool( minerAuditFeedTool.name, - { description: minerAuditFeedTool.description, inputSchema: minerAuditFeedTool.input.shape, outputSchema: minerAuditFeedTool.output.shape }, - (input) => + { description: minerAuditFeedTool.description, inputSchema: MinerAuditFeedInput.shape, outputSchema: MinerAuditFeedOutput.shape }, + (input: z.infer) => withMinerToolErrorHandling(() => { const ownsLedger = options.initEventLedger === undefined; const eventLedger = (options.initEventLedger ?? initEventLedger)(); @@ -289,10 +314,10 @@ export function createMinerMcpServer(options: MinerMcpServerOptions = {}) { minerGetRunStateTool.name, { description: minerGetRunStateTool.description, - inputSchema: minerGetRunStateTool.input.shape, - outputSchema: minerGetRunStateTool.output.shape, + inputSchema: MinerGetRunStateInput.shape, + outputSchema: MinerGetRunStateOutput.shape, }, - ({ repoFullName }) => + ({ repoFullName }: z.infer) => withMinerToolErrorHandling(() => { const ownsStore = options.initRunStateStore === undefined; const store = (options.initRunStateStore ?? initRunStateStore)(); @@ -306,8 +331,8 @@ export function createMinerMcpServer(options: MinerMcpServerOptions = {}) { server.registerTool( minerListPlansTool.name, - { description: minerListPlansTool.description, inputSchema: minerListPlansTool.input.shape, outputSchema: minerListPlansTool.output.shape }, - ({ status }) => + { description: minerListPlansTool.description, inputSchema: MinerListPlansInput.shape, outputSchema: MinerListPlansOutput.shape }, + ({ status }: z.infer) => withMinerToolErrorHandling(() => { const ownsStore = options.openPlanStore === undefined; const store = (options.openPlanStore ?? openPlanStore)(); @@ -324,8 +349,8 @@ export function createMinerMcpServer(options: MinerMcpServerOptions = {}) { server.registerTool( minerGetPlanTool.name, - { description: minerGetPlanTool.description, inputSchema: minerGetPlanTool.input.shape, outputSchema: minerGetPlanTool.output.shape }, - ({ planId }) => + { description: minerGetPlanTool.description, inputSchema: MinerGetPlanInput.shape, outputSchema: MinerGetPlanOutput.shape }, + ({ planId }: z.infer) => withMinerToolErrorHandling(() => { const ownsStore = options.openPlanStore === undefined; const store = (options.openPlanStore ?? openPlanStore)(); @@ -342,10 +367,10 @@ export function createMinerMcpServer(options: MinerMcpServerOptions = {}) { minerGovernorDecisionsTool.name, { description: minerGovernorDecisionsTool.description, - inputSchema: minerGovernorDecisionsTool.input.shape, - outputSchema: minerGovernorDecisionsTool.output.shape, + inputSchema: MinerGovernorDecisionsInput.shape, + outputSchema: MinerGovernorDecisionsOutput.shape, }, - ({ repoFullName }) => + ({ repoFullName }: z.infer) => withMinerToolErrorHandling(() => { const ownsLedger = options.initGovernorLedger === undefined; const ledger = (options.initGovernorLedger ?? initGovernorLedger)(); @@ -362,7 +387,7 @@ export function createMinerMcpServer(options: MinerMcpServerOptions = {}) { server.registerTool( minerStatusTool.name, - { description: minerStatusTool.description, inputSchema: minerStatusTool.input.shape, outputSchema: minerStatusTool.output.shape }, + { description: minerStatusTool.description, inputSchema: MinerStatusInput.shape, outputSchema: MinerStatusOutput.shape }, () => withMinerToolErrorHandling(() => ({ status: (options.collectStatus ?? collectStatus)(), @@ -374,8 +399,8 @@ export function createMinerMcpServer(options: MinerMcpServerOptions = {}) { minerCalibrationReportTool.name, { description: minerCalibrationReportTool.description, - inputSchema: minerCalibrationReportTool.input.shape, - outputSchema: minerCalibrationReportTool.output.shape, + inputSchema: MinerCalibrationReportInput.shape, + outputSchema: MinerCalibrationReportOutput.shape, }, () => withMinerToolErrorHandling(() => {