Skip to content

Commit 5aed607

Browse files
committed
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
1 parent 0e010f5 commit 5aed607

6 files changed

Lines changed: 569 additions & 311 deletions

File tree

packages/loopover-contract/src/tools/index.ts

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,24 @@ import { predictGateTool } from "./predict-gate.js";
1010
import { preflightPrTool } from "./preflight-pr.js";
1111
import { localStatusStructuredTool } from "./local-status.js";
1212
import { adminGetConfigTool } from "./admin-config.js";
13+
import {
14+
minerPingTool,
15+
minerPortfolioDashboardTool,
16+
minerManageStatusTool,
17+
minerListClaimsTool,
18+
minerAuditFeedTool,
19+
minerGetRunStateTool,
20+
minerListPlansTool,
21+
minerGetPlanTool,
22+
minerGovernorDecisionsTool,
23+
minerStatusTool,
24+
minerCalibrationReportTool,
25+
} from "./miner.js";
1326

1427
/**
15-
* Pilot batch (#9517). The remaining ~110 remote / 96 stdio / 11 miner tools migrate in #9518's
16-
* category batches; this set was chosen to exercise every axis of the model at least once --
17-
* remote and local-git locality, cloud/selfhost/both availability, and the token/session/
18-
* maintainer/mcp-admin auth levels.
28+
* Pilot batch (#9517) plus the full AMS miner server (#9536, all 11 tools -- the first server
29+
* migrated to completion). The remaining ~110 remote / ~91 stdio tools migrate in #9518/#9537's
30+
* category batches.
1931
*/
2032
export const TOOL_CONTRACTS: readonly ToolContract[] = [
2133
getRepoContextTool,
@@ -24,6 +36,17 @@ export const TOOL_CONTRACTS: readonly ToolContract[] = [
2436
preflightPrTool,
2537
localStatusStructuredTool,
2638
adminGetConfigTool,
39+
minerPingTool,
40+
minerPortfolioDashboardTool,
41+
minerManageStatusTool,
42+
minerListClaimsTool,
43+
minerAuditFeedTool,
44+
minerGetRunStateTool,
45+
minerListPlansTool,
46+
minerGetPlanTool,
47+
minerGovernorDecisionsTool,
48+
minerStatusTool,
49+
minerCalibrationReportTool,
2750
];
2851

2952
const CONTRACTS_BY_NAME: ReadonlyMap<string, ToolContract> = new Map(
@@ -49,3 +72,4 @@ export * from "./predict-gate.js";
4972
export * from "./preflight-pr.js";
5073
export * from "./local-status.js";
5174
export * from "./admin-config.js";
75+
export * from "./miner.js";

packages/loopover-contract/src/tools/miner.ts

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,3 +162,263 @@ export const minerListClaimsTool = defineTool({
162162
input: MinerListClaimsInput,
163163
output: MinerListClaimsOutput,
164164
});
165+
166+
// ── audit feed ──────────────────────────────────────────────────────────────────────────────────
167+
168+
export const MinerAuditFeedInput = z.object({
169+
repoFullName: z.string().min(1).optional(),
170+
since: z.number().int().nonnegative().optional(),
171+
type: z.string().min(1).optional(),
172+
});
173+
174+
/** `{ repoFullName?, events }` -- `collectEventLedgerAuditFeed`'s real return shape. Each event is
175+
* metadata only: `payload_json` and other raw ledger columns are never included by construction
176+
* (an explicit named-column read, not a redaction step). */
177+
export const MinerAuditFeedOutput = z.looseObject({
178+
repoFullName: z.string().optional(),
179+
events: z.array(
180+
z.looseObject({
181+
eventType: z.string(),
182+
repoFullName: z.string().nullable(),
183+
outcome: z.string().nullable(),
184+
actor: z.string().nullable(),
185+
detail: z.string().nullable(),
186+
createdAt: z.string(),
187+
}),
188+
),
189+
});
190+
191+
export const minerAuditFeedTool = defineTool({
192+
name: "loopover_miner_get_audit_feed",
193+
title: "Miner audit feed",
194+
description:
195+
"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.",
196+
category: "agent",
197+
auth: "public",
198+
locality: "miner",
199+
availability: "selfhost",
200+
input: MinerAuditFeedInput,
201+
output: MinerAuditFeedOutput,
202+
});
203+
204+
// ── run state ───────────────────────────────────────────────────────────────────────────────────
205+
206+
/** `RunState` (packages/loopover-miner/lib/run-state.ts). */
207+
export const MINER_RUN_STATES = ["idle", "discovering", "planning", "preparing"] as const;
208+
209+
export const MinerGetRunStateInput = z.object({
210+
repoFullName: z.string().min(1).optional(),
211+
});
212+
213+
/** Two distinct shapes depending on whether `repoFullName` was supplied -- a single-repo lookup
214+
* (whose `state` is null when nothing has been recorded yet) versus the full listing. Both fields
215+
* optional here so one schema describes both without a discriminated union, which the MCP output
216+
* schema does not need to enforce mutual exclusivity to be useful. */
217+
export const MinerGetRunStateOutput = z.looseObject({
218+
repoFullName: z.string().optional(),
219+
state: z.enum(MINER_RUN_STATES).nullable().optional(),
220+
states: z.array(z.looseObject({ repoFullName: z.string(), state: z.enum(MINER_RUN_STATES).nullable() })).optional(),
221+
});
222+
223+
export const minerGetRunStateTool = defineTool({
224+
name: "loopover_miner_get_run_state",
225+
title: "Miner run state",
226+
description:
227+
"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.",
228+
category: "agent",
229+
auth: "public",
230+
locality: "miner",
231+
availability: "selfhost",
232+
input: MinerGetRunStateInput,
233+
output: MinerGetRunStateOutput,
234+
});
235+
236+
// ── plan store ──────────────────────────────────────────────────────────────────────────────────
237+
238+
/** `PlanStatus` (packages/loopover-miner/lib/plan-store.ts). */
239+
export const MINER_PLAN_STATUSES = ["pending", "running", "completed", "failed"] as const;
240+
241+
/** `PlanStepStatus`, same file. */
242+
export const MINER_PLAN_STEP_STATUSES = ["pending", "running", "completed", "failed", "skipped"] as const;
243+
244+
/** `PlanStep`. `lastError` is nullish (both unset and explicit null appear -- the store's own type
245+
* spells it `string | null | undefined`). */
246+
export const minerPlanStepSchema = z.looseObject({
247+
id: z.string(),
248+
title: z.string(),
249+
actionClass: z.string().optional(),
250+
dependsOn: z.array(z.string()),
251+
status: z.enum(MINER_PLAN_STEP_STATUSES),
252+
attempts: z.number(),
253+
maxAttempts: z.number(),
254+
lastError: z.string().nullish(),
255+
});
256+
257+
/** `PlanDag`. */
258+
export const minerPlanDagSchema = z.looseObject({
259+
steps: z.array(minerPlanStepSchema),
260+
});
261+
262+
/** `PlanRecord`. */
263+
export const minerPlanRecordSchema = z.looseObject({
264+
planId: z.string(),
265+
plan: minerPlanDagSchema,
266+
status: z.enum(MINER_PLAN_STATUSES),
267+
updatedAt: z.string(),
268+
});
269+
270+
export const MinerListPlansInput = z.object({
271+
status: z.enum(MINER_PLAN_STATUSES).optional(),
272+
});
273+
274+
export const MinerListPlansOutput = z.looseObject({
275+
plans: z.array(minerPlanRecordSchema),
276+
});
277+
278+
export const minerListPlansTool = defineTool({
279+
name: "loopover_miner_list_plans",
280+
title: "List miner plans",
281+
description:
282+
"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.",
283+
category: "agent",
284+
auth: "public",
285+
locality: "miner",
286+
availability: "selfhost",
287+
input: MinerListPlansInput,
288+
output: MinerListPlansOutput,
289+
});
290+
291+
export const MinerGetPlanInput = z.object({
292+
planId: z.string().min(1),
293+
});
294+
295+
/** `{ planId, found: false }` for an unknown id, or `{ found: true, plan }` -- deliberately not a
296+
* discriminated union in the schema for the same reason as get_run_state above. */
297+
export const MinerGetPlanOutput = z.looseObject({
298+
planId: z.string().optional(),
299+
found: z.boolean(),
300+
plan: minerPlanRecordSchema.optional(),
301+
});
302+
303+
export const minerGetPlanTool = defineTool({
304+
name: "loopover_miner_get_plan",
305+
title: "Get miner plan",
306+
description:
307+
"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.",
308+
category: "agent",
309+
auth: "public",
310+
locality: "miner",
311+
availability: "selfhost",
312+
input: MinerGetPlanInput,
313+
output: MinerGetPlanOutput,
314+
});
315+
316+
// ── governor decisions ──────────────────────────────────────────────────────────────────────────
317+
318+
export const MinerGovernorDecisionsInput = z.object({
319+
repoFullName: z.string().optional(),
320+
});
321+
322+
/** `GovernorDecisionEntry` = `Omit<GovernorLedgerEntry, "payload">` -- the raw `payload` column is
323+
* excluded by an explicit named-column SELECT, not filtered after the fact. */
324+
export const MinerGovernorDecisionsOutput = z.looseObject({
325+
decisions: z.array(
326+
z.looseObject({
327+
id: z.number(),
328+
ts: z.string(),
329+
eventType: z.string(),
330+
repoFullName: z.string().nullable(),
331+
actionClass: z.string(),
332+
decision: z.string(),
333+
reason: z.string(),
334+
}),
335+
),
336+
});
337+
338+
export const minerGovernorDecisionsTool = defineTool({
339+
name: "loopover_miner_get_governor_decisions",
340+
title: "Miner governor decisions",
341+
description:
342+
"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.",
343+
category: "agent",
344+
auth: "public",
345+
locality: "miner",
346+
availability: "selfhost",
347+
input: MinerGovernorDecisionsInput,
348+
output: MinerGovernorDecisionsOutput,
349+
});
350+
351+
// ── status + doctor ─────────────────────────────────────────────────────────────────────────────
352+
353+
export const MinerStatusInput = z.object({});
354+
355+
/** `{ status: MinerStatus, doctor: DoctorCheck[] }`. `MinerStatus`/`DoctorCheck`/`MinerDriverStatus`
356+
* are all in packages/loopover-miner/lib/status.ts. Deliberately names/booleans/paths only, never
357+
* an env-var VALUE, token, key, or credential -- `modelEnvVar` is the variable's NAME. */
358+
export const MinerStatusOutput = z.looseObject({
359+
status: z.looseObject({
360+
package: z.looseObject({ name: z.string(), version: z.string().nullable() }),
361+
engine: z.looseObject({ name: z.string(), version: z.string().nullable() }),
362+
node: z.string(),
363+
stateDir: z.string(),
364+
configFile: z.string().nullable(),
365+
driver: z.looseObject({
366+
provider: z.string().nullable(),
367+
modelEnvVar: z.string().nullable(),
368+
cliPresent: z.boolean().nullable(),
369+
}),
370+
}),
371+
doctor: z.array(z.looseObject({ name: z.string(), ok: z.boolean(), detail: z.string() })),
372+
});
373+
374+
export const minerStatusTool = defineTool({
375+
name: "loopover_miner_status",
376+
title: "Miner status and doctor",
377+
description:
378+
"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.",
379+
category: "utility",
380+
auth: "public",
381+
locality: "miner",
382+
availability: "selfhost",
383+
input: MinerStatusInput,
384+
output: MinerStatusOutput,
385+
});
386+
387+
// ── calibration report ──────────────────────────────────────────────────────────────────────────
388+
389+
export const MinerCalibrationReportInput = z.object({});
390+
391+
/** `CalibrationReport` (packages/loopover-miner/lib/calibration-types.ts). `hasSignal` is true once
392+
* at least one project has enough decided samples to read meaningfully; the two precision fields
393+
* are null until then, not zero. */
394+
export const MinerCalibrationReportOutput = z.looseObject({
395+
rows: z.array(
396+
z.looseObject({
397+
project: z.string(),
398+
wouldMerge: z.number(),
399+
mergeConfirmed: z.number(),
400+
mergeFalse: z.number(),
401+
wouldClose: z.number(),
402+
closeConfirmed: z.number(),
403+
closeFalse: z.number(),
404+
hold: z.number(),
405+
decided: z.number(),
406+
mergePrecision: z.number().nullable(),
407+
closePrecision: z.number().nullable(),
408+
}),
409+
),
410+
hasSignal: z.boolean(),
411+
});
412+
413+
export const minerCalibrationReportTool = defineTool({
414+
name: "loopover_miner_get_calibration_report",
415+
title: "Miner calibration report",
416+
description:
417+
"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.",
418+
category: "agent",
419+
auth: "public",
420+
locality: "miner",
421+
availability: "selfhost",
422+
input: MinerCalibrationReportInput,
423+
output: MinerCalibrationReportOutput,
424+
});

0 commit comments

Comments
 (0)