diff --git a/packages/loopover-contract/src/tools/index.ts b/packages/loopover-contract/src/tools/index.ts index 2cd00698b0..73e22a1a58 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 new file mode 100644 index 0000000000..8c9eff6469 --- /dev/null +++ b/packages/loopover-contract/src/tools/miner.ts @@ -0,0 +1,424 @@ +// 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, +}); + +// ── 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 57bede90b4..8f8369bc55 100755 --- a/packages/loopover-miner/bin/loopover-miner-mcp.ts +++ b/packages/loopover-miner/bin/loopover-miner-mcp.ts @@ -3,19 +3,55 @@ 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"; +// #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 { CLAIM_STATUSES, openClaimLedger } from "../lib/claim-ledger.js"; 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 { + 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"; 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 +63,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 +136,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 +206,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: MinerPingInput.shape, outputSchema: MinerPingOutput.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: MinerPortfolioDashboardInput.shape, + outputSchema: MinerPortfolioDashboardOutput.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: MinerManageStatusInput.shape, + outputSchema: MinerManageStatusOutput.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: MinerListClaimsInput.shape, outputSchema: MinerListClaimsOutput.shape }, + ({ repoFullName, status }: z.infer) => + 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: MinerAuditFeedInput.shape, outputSchema: MinerAuditFeedOutput.shape }, + (input: z.infer) => + 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: MinerGetRunStateInput.shape, + outputSchema: MinerGetRunStateOutput.shape, }, + ({ repoFullName }: z.infer) => + 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: MinerListPlansInput.shape, outputSchema: MinerListPlansOutput.shape }, + ({ status }: z.infer) => + 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: MinerGetPlanInput.shape, outputSchema: MinerGetPlanOutput.shape }, + ({ planId }: z.infer) => + 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: MinerGovernorDecisionsInput.shape, + outputSchema: MinerGovernorDecisionsOutput.shape, }, + ({ repoFullName }: z.infer) => + 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: MinerStatusInput.shape, outputSchema: MinerStatusOutput.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: MinerCalibrationReportInput.shape, + outputSchema: MinerCalibrationReportOutput.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 efc7b2209f..0c66f6471c 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 7613255cc1..b7ccd2b035 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 c281d8c011..6aa155a6c2 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; }