Skip to content

Commit a8e5d9f

Browse files
committed
feat(mcp): add the AMS management tool family behind the governor gate
The miner MCP exposed 11 read-only tools while its entire mutating ops surface was CLI-only. This adds 10 tools: a dedicated doctor (split out of status so status stays cheap), a structured metrics snapshot, and eight mutations. Every mutation dispatches through the miner's existing governor-gated chat-action chokepoint -- the same boundary the dashboard's own actions use. The MCP layer never touches a store: it dispatches an action NAME, and the registry structurally refuses any handler not produced by governorGatedHandler(), whose brand is a private symbol a raw function cannot forge. That is what makes "an MCP caller cannot reach a write path the dashboard could not" a property of the code rather than of review discipline, and the structural test asserts it from both ends. Three claims I had to correct against the real code rather than ship as written. The migrate CLI has no dry-run, because applying a migration IS opening the store -- so the tool has no apply flag either, instead of advertising a safety mode that does not exist. The deny-hook store keys proposals by (repo, id), so the decide tool takes the repo rather than scanning every repo's proposals to resolve an id. And purgeRepoAcrossStores is extracted from runPurge so the tool runs the CLI's own purge over the CLI's own target list, rather than a second implementation free to miss a store. collectMinerPredictionMetrics is likewise extracted in the engine: the Prometheus text renderer now formats those families, so the scrape and the JSON snapshot share one aggregation and cannot disagree about what a counter means. validate:mcp earned its keep twice here. It caught the two AMS tenant tools declared but never registered, and it caught loopover_miner_run_migrations/_purge_repo returning fields their output schemas did not declare -- the .shape re-wrap that drops looseObject's catchall, the same -32602 class this epic already fixed once. Both outputs now declare every field their handlers return. AMS tenant create/list/destroy are deliberately absent: #9522's loopover_tenant_* tools are product-parameterized and already serve product "ams", because the control plane's routes are. Only health and wake -- the pair with no ORB counterpart -- are added. The catalog's recorded exclusions (calibration floors, raw run-state set, the one-way kill switch) stay CLI-only, pinned by a test so reversing that decision has to be deliberate.
1 parent 59b778b commit a8e5d9f

13 files changed

Lines changed: 1035 additions & 30 deletions

File tree

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
// The hosted AMS tenant surface (#9523, #9199).
2+
//
3+
// CATALOG AMENDMENT, recorded here rather than improvised: #9523 listed
4+
// `loopover_ams_tenant_create` / `_list` / `_destroy`, and they are NOT here. #9522 landed
5+
// `loopover_tenant_create` / `_list` / `_destroy` taking a `product` of "ams" or "orb", because the control
6+
// plane's own `/v1/tenants` routes are product-parameterized and key their registry by `${product}:${name}`.
7+
// A second, AMS-only spelling of those three would be two names for one capability -- the same rot that kept
8+
// `loopover_fleet_get_analytics` out of #9522 -- and would leave an agent guessing which to call. Use the
9+
// product-parameterized tools with `product: "ams"`.
10+
//
11+
// What IS genuinely AMS-specific, and therefore here, is the pair that has no ORB counterpart: a tenant's
12+
// wake schedule and cycle outcomes, and the ability to trigger a cycle now.
13+
import { z } from "zod";
14+
import { defineTool } from "../tool-definition.js";
15+
16+
const TenantName = z.string().min(1).max(200);
17+
18+
export const AmsTenantHealthInput = z.object({
19+
name: TenantName.describe("The tenant's name, as reported by loopover_tenant_list with product=ams."),
20+
});
21+
22+
export const AmsTenantHealthOutput = z.looseObject({
23+
configured: z.boolean().describe("False when this deployment administers no hosted tenants."),
24+
name: z.string().optional(),
25+
state: z.string().optional().describe("The control plane's own lifecycle vocabulary, passed through verbatim."),
26+
schedule: z.string().nullable().optional().describe("The cron-wake cadence, or null when the tenant wakes only on demand."),
27+
lastWakeAt: z.string().nullable().optional(),
28+
lastCycleOutcome: z.string().nullable().optional(),
29+
containerHealthy: z.boolean().nullable().optional(),
30+
error: z.string().optional(),
31+
});
32+
33+
export const amsTenantHealthTool = defineTool({
34+
name: "loopover_ams_tenant_health",
35+
title: "Read a hosted AMS tenant's health",
36+
description:
37+
"Operator only. One hosted AMS tenant's health: lifecycle state, its cron-wake cadence, when it last woke, that cycle's outcome, and container health. Read-only, and scoped server-side to the authenticated tenant — a name outside that scope is refused rather than answered.",
38+
category: "tenant",
39+
auth: "internal",
40+
locality: "remote",
41+
availability: "cloud",
42+
input: AmsTenantHealthInput,
43+
output: AmsTenantHealthOutput,
44+
});
45+
46+
export const AmsTenantWakeInput = z.object({
47+
name: TenantName,
48+
});
49+
50+
export const AmsTenantWakeOutput = z.looseObject({
51+
configured: z.boolean(),
52+
name: z.string().optional(),
53+
woken: z.boolean().optional(),
54+
throttled: z.boolean().optional().describe("True when the tenant's own schedule guard refused a wake this soon after the last one."),
55+
error: z.string().optional(),
56+
});
57+
58+
export const amsTenantWakeTool = defineTool({
59+
name: "loopover_ams_tenant_wake",
60+
title: "Wake a hosted AMS tenant now",
61+
description:
62+
"Operator only. Trigger an immediate cycle for one hosted AMS tenant, instead of waiting for its next scheduled wake. Bounded by the SAME per-tenant schedule guards the cron path obeys — a wake too soon after the last one is reported as throttled rather than forced through.",
63+
category: "tenant",
64+
auth: "internal",
65+
locality: "remote",
66+
availability: "cloud",
67+
annotations: { readOnlyHint: false, destructiveHint: false },
68+
input: AmsTenantWakeInput,
69+
output: AmsTenantWakeOutput,
70+
});
71+
72+
export const AMS_TENANT_TOOLS = [amsTenantHealthTool, amsTenantWakeTool] as const;

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,8 @@ import { OPS_TOOLS } from "./ops.js";
147147
import { FLEET_TOOLS } from "./fleet.js";
148148
import { TENANT_TOOLS } from "./tenant.js";
149149
import { INSTANCE_OPS_TOOLS } from "./instance-ops.js";
150+
import { MINER_OPS_TOOLS } from "./miner-ops.js";
151+
import { AMS_TENANT_TOOLS } from "./ams-tenant.js";
150152
import { adminRotateSecretTool } from "./admin-config.js";
151153

152154
export const TOOL_CONTRACTS: readonly ToolContract[] = [
@@ -282,6 +284,8 @@ export const TOOL_CONTRACTS: readonly ToolContract[] = [
282284
...OPS_TOOLS,
283285
...FLEET_TOOLS,
284286
...TENANT_TOOLS,
287+
...MINER_OPS_TOOLS,
288+
...AMS_TENANT_TOOLS,
285289
];
286290

287291
const CONTRACTS_BY_NAME: ReadonlyMap<string, ToolContract> = new Map(
@@ -318,3 +322,5 @@ export * from "./ops.js";
318322
export * from "./fleet.js";
319323
export * from "./tenant.js";
320324
export * from "./instance-ops.js";
325+
export * from "./miner-ops.js";
326+
export * from "./ams-tenant.js";
Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
// The AMS miner's management surface (#9523).
2+
//
3+
// The miner MCP exposed 11 read-only tools while its entire MUTATING ops surface was CLI-only. These add
4+
// the mutating family, plus the two reads the catalog called for.
5+
//
6+
// Every mutating tool dispatches through the miner's existing governor-gated chat-action chokepoint
7+
// (packages/loopover-miner/lib/chat-action-registry.ts), which structurally refuses any handler not produced
8+
// by `governorGatedHandler()`. That is the same boundary the dashboard's own actions go through, so an MCP
9+
// caller cannot reach a write path the dashboard could not -- and the structural rule is enforced by the
10+
// registry's own brand, not by review discipline.
11+
//
12+
// locality "miner": these run inside the AMS bin against its local stores. availability "selfhost" for the
13+
// same reason the ORB config-admin tools are: the stores are on that host's disk.
14+
//
15+
// DELIBERATELY ABSENT, recorded here so the omissions are visible rather than forgotten:
16+
// * calibration apply-min-rank / revert-min-rank -- stays CLI-only behind its existing double gate. An
17+
// agent-reachable path to loosen calibration floors is not wanted.
18+
// * run-state `set` -- raw run-state mutation is a repair tool, not an operation.
19+
// * kill-switch trip/untrip -- one-way breaker semantics stay out of agent reach; pause/resume is the
20+
// agent-safe control and is here instead.
21+
import { z } from "zod";
22+
import { defineTool } from "../tool-definition.js";
23+
import { INSTANCE_CHECK_STATUSES } from "../enums.js";
24+
25+
const RepoFullName = z.string().min(3).max(200).describe("owner/repo.");
26+
27+
/** Mutating tools take this so an omitted field cannot read as false and proceed. */
28+
const DestructiveConfirm = z.literal(true).describe("Must be exactly true. Confirms an irreversible action.");
29+
30+
export const MinerDoctorInput = z.object({});
31+
32+
export const MinerDoctorOutput = z.looseObject({
33+
ok: z.boolean().describe("True when no check reported fail. Warnings do not clear it to false."),
34+
checks: z.array(z.looseObject({ name: z.string(), status: z.enum(INSTANCE_CHECK_STATUSES), detail: z.string().optional() })),
35+
});
36+
37+
export const minerDoctorTool = defineTool({
38+
name: "loopover_miner_doctor",
39+
title: "Diagnose this miner",
40+
description:
41+
"Read-only diagnostic checks for this AMS miner: state directory, engine version match, store reachability, credentials, and configuration. Split out of loopover_miner_status (#9523) so status stays cheap and doctor can grow checks. Every check runs and reports its own pass/warn/fail — nothing is mutated and nothing stops at the first failure.",
42+
category: "ops",
43+
auth: "token",
44+
locality: "miner",
45+
availability: "selfhost",
46+
input: MinerDoctorInput,
47+
output: MinerDoctorOutput,
48+
});
49+
50+
export const MinerMetricsSnapshotInput = z.object({});
51+
52+
export const MinerMetricsSnapshotOutput = z.looseObject({
53+
generatedAt: z.string(),
54+
families: z.array(
55+
z.looseObject({
56+
name: z.string(),
57+
type: z.string(),
58+
help: z.string().optional(),
59+
samples: z.array(z.looseObject({ value: z.number(), labels: z.record(z.string(), z.string()).optional() })),
60+
}),
61+
),
62+
});
63+
64+
export const minerMetricsSnapshotTool = defineTool({
65+
name: "loopover_miner_get_metrics_snapshot",
66+
title: "Read the miner's metrics snapshot",
67+
description:
68+
"The same Prometheus metric families the `metrics` CLI exports, as structured JSON — so an agent can read them without parsing the text exposition format. Read-only.",
69+
category: "ops",
70+
auth: "token",
71+
locality: "miner",
72+
availability: "selfhost",
73+
input: MinerMetricsSnapshotInput,
74+
output: MinerMetricsSnapshotOutput,
75+
});
76+
77+
/**
78+
* Shared output for every governor-gated mutation: what happened, and what the chokepoint decided. A refusal
79+
* is reported as `blocked` with a `reason` rather than thrown -- the governor saying no is an ANSWER the
80+
* caller needs to see, and a thrown error would flatten it into a generic tool failure.
81+
*/
82+
export const MinerGovernorActionOutput = z.looseObject({
83+
ok: z.boolean().optional(),
84+
action: z.string().optional(),
85+
declined: z.boolean().optional().describe("True when the caller declined an elicited confirmation."),
86+
blocked: z.boolean().optional().describe("True when the governor chokepoint refused the action."),
87+
reason: z.string().optional(),
88+
result: z.unknown().optional(),
89+
error: z.string().optional(),
90+
});
91+
92+
export const MinerGovernorPauseInput = z.object({
93+
reason: z.string().max(500).optional().describe("Recorded on the pause so the audit feed says why."),
94+
});
95+
96+
export const minerGovernorPauseTool = defineTool({
97+
name: "loopover_miner_governor_pause",
98+
title: "Pause the miner governor",
99+
description:
100+
"Pause this miner's governor: no new work is admitted until it resumes. Administrative control, not a content write — the same action the dashboard's pause button dispatches, through the same governor-gated chokepoint, firing the same notification side-channel. Recorded in the event ledger with source=mcp.",
101+
category: "ops",
102+
auth: "token",
103+
locality: "miner",
104+
availability: "selfhost",
105+
annotations: { readOnlyHint: false, destructiveHint: false },
106+
input: MinerGovernorPauseInput,
107+
output: MinerGovernorActionOutput,
108+
});
109+
110+
export const MinerGovernorResumeInput = z.object({});
111+
112+
export const minerGovernorResumeTool = defineTool({
113+
name: "loopover_miner_governor_resume",
114+
title: "Resume the miner governor",
115+
description:
116+
"Resume this miner's governor after a pause, re-admitting work. The same action the dashboard's resume button dispatches, through the same governor-gated chokepoint. Recorded in the event ledger with source=mcp.",
117+
category: "ops",
118+
auth: "token",
119+
locality: "miner",
120+
availability: "selfhost",
121+
annotations: { readOnlyHint: false, destructiveHint: false },
122+
input: MinerGovernorResumeInput,
123+
output: MinerGovernorActionOutput,
124+
});
125+
126+
const QueueItemTarget = z.object({
127+
repoFullName: RepoFullName,
128+
issueNumber: z.number().int().positive(),
129+
});
130+
131+
export const MinerQueueReleaseInput = QueueItemTarget;
132+
133+
export const minerQueueReleaseTool = defineTool({
134+
name: "loopover_miner_queue_release",
135+
title: "Release a portfolio queue item",
136+
description:
137+
"Release one claimed portfolio-queue item back to unclaimed, so another cycle can pick it up. Mirrors the dashboard's release action and dispatches through the same governor-gated chokepoint. Recorded in the event ledger with source=mcp.",
138+
category: "ops",
139+
auth: "token",
140+
locality: "miner",
141+
availability: "selfhost",
142+
annotations: { readOnlyHint: false, destructiveHint: false },
143+
input: MinerQueueReleaseInput,
144+
output: MinerGovernorActionOutput,
145+
});
146+
147+
export const MinerQueueRequeueInput = QueueItemTarget;
148+
149+
export const minerQueueRequeueTool = defineTool({
150+
name: "loopover_miner_queue_requeue",
151+
title: "Requeue a portfolio queue item",
152+
description:
153+
"Return one portfolio-queue item to the pending pool for another attempt. Mirrors the dashboard's requeue action and dispatches through the same governor-gated chokepoint. Recorded in the event ledger with source=mcp.",
154+
category: "ops",
155+
auth: "token",
156+
locality: "miner",
157+
availability: "selfhost",
158+
annotations: { readOnlyHint: false, destructiveHint: false },
159+
input: MinerQueueRequeueInput,
160+
output: MinerGovernorActionOutput,
161+
});
162+
163+
export const MinerClaimReleaseInput = QueueItemTarget;
164+
165+
export const minerClaimReleaseTool = defineTool({
166+
name: "loopover_miner_claim_release",
167+
title: "Release a claim",
168+
description:
169+
"Release this miner's claim on one issue, so the claim ledger no longer reserves it. Dispatches through the governor-gated chokepoint and is recorded in the event ledger with source=mcp.",
170+
category: "ops",
171+
auth: "token",
172+
locality: "miner",
173+
availability: "selfhost",
174+
annotations: { readOnlyHint: false, destructiveHint: false },
175+
input: MinerClaimReleaseInput,
176+
output: MinerGovernorActionOutput,
177+
});
178+
179+
export const MinerDenyHooksDecideInput = z.object({
180+
// The store keys proposals by (repo, proposalId), so the repo is required rather than guessed -- a
181+
// proposal id alone would force a scan across every repo's proposals to resolve one.
182+
repoFullName: RepoFullName,
183+
hookId: z.string().min(1).max(200).describe("The proposal id, from the deny-hook proposals list."),
184+
decision: z.enum(["approve", "reject"]),
185+
});
186+
187+
export const minerDenyHooksDecideTool = defineTool({
188+
name: "loopover_miner_deny_hooks_decide",
189+
title: "Decide a pending deny-hook",
190+
description:
191+
"Approve or reject one synthesized deny-hook awaiting review. Approving puts it into force for future runs; rejecting discards it. Dispatches through the governor-gated chokepoint and is recorded in the event ledger with source=mcp.",
192+
category: "ops",
193+
auth: "token",
194+
locality: "miner",
195+
availability: "selfhost",
196+
annotations: { readOnlyHint: false, destructiveHint: false },
197+
input: MinerDenyHooksDecideInput,
198+
output: MinerGovernorActionOutput,
199+
});
200+
201+
/**
202+
* No dry-run flag, deliberately: the miner's `migrate` CLI has none either. Applying a migration IS opening
203+
* the store, so a "preview" would have to be a second implementation of the migration walk -- and a preview
204+
* that drifts from the real thing is worse than no preview. Reported as applied/up-to-date per store.
205+
*/
206+
export const MinerRunMigrationsInput = z.object({});
207+
208+
/**
209+
* Every field the handler returns is DECLARED here rather than left to `looseObject`'s catchall: the miner
210+
* server registers output schemas as `.shape`, which the MCP SDK re-wraps in a plain `z.object` -- dropping
211+
* the catchall and rejecting any undeclared key with -32602. Same reason the sibling outputs are explicit.
212+
*/
213+
export const MinerRunMigrationsOutput = z.looseObject({
214+
ok: z.boolean().optional(),
215+
action: z.string().optional(),
216+
result: z.unknown().optional(),
217+
blocked: z.boolean().optional(),
218+
reason: z.string().optional(),
219+
error: z.string().optional(),
220+
});
221+
222+
export const minerRunMigrationsTool = defineTool({
223+
name: "loopover_miner_run_migrations",
224+
title: "Run miner store migrations",
225+
description:
226+
"Apply pending schema migrations to this miner's EXISTING local stores — it never creates a store that is not already there. Reports each store as migrated, up-to-date, or failed. There is no dry-run mode: applying a migration is opening the store, and the CLI has none either. Dispatches through the governor-gated chokepoint.",
227+
category: "ops",
228+
auth: "token",
229+
locality: "miner",
230+
availability: "selfhost",
231+
annotations: { readOnlyHint: false, destructiveHint: false },
232+
input: MinerRunMigrationsInput,
233+
output: MinerRunMigrationsOutput,
234+
});
235+
236+
export const MinerPurgeRepoInput = z.object({
237+
repoFullName: RepoFullName,
238+
confirm: DestructiveConfirm,
239+
});
240+
241+
/** Same `.shape` re-wrap constraint as above: declare everything the handler returns. */
242+
export const MinerPurgeRepoOutput = z.looseObject({
243+
ok: z.boolean().optional(),
244+
action: z.string().optional(),
245+
declined: z.boolean().optional(),
246+
repoFullName: z.string().optional(),
247+
// The purge summary verbatim from the CLI's own core -- store list, counts, and timestamp.
248+
result: z.unknown().optional(),
249+
blocked: z.boolean().optional(),
250+
reason: z.string().optional(),
251+
error: z.string().optional(),
252+
});
253+
254+
export const minerPurgeRepoTool = defineTool({
255+
name: "loopover_miner_purge_repo",
256+
title: "Purge a repo from every miner store",
257+
description:
258+
"Right-to-be-forgotten: delete every trace of one repo from this miner's local stores, returning the same per-store report as the CLI. IRREVERSIBLE — the rows are gone, not archived. Requires confirm=true, elicits confirmation where the client supports it, and dispatches through the governor-gated chokepoint.",
259+
category: "ops",
260+
auth: "token",
261+
locality: "miner",
262+
availability: "selfhost",
263+
annotations: { readOnlyHint: false, destructiveHint: true },
264+
input: MinerPurgeRepoInput,
265+
output: MinerPurgeRepoOutput,
266+
});
267+
268+
export const MINER_OPS_TOOLS = [
269+
minerDoctorTool,
270+
minerMetricsSnapshotTool,
271+
minerGovernorPauseTool,
272+
minerGovernorResumeTool,
273+
minerQueueReleaseTool,
274+
minerQueueRequeueTool,
275+
minerClaimReleaseTool,
276+
minerDenyHooksDecideTool,
277+
minerRunMigrationsTool,
278+
minerPurgeRepoTool,
279+
] as const;

packages/loopover-engine/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,9 @@ export {
203203
MINER_PREDICTIONS_TOTAL,
204204
MINER_PREDICTION_CORRECT_TOTAL,
205205
MINER_PREDICTION_INCORRECT_TOTAL,
206+
collectMinerPredictionMetrics,
206207
renderMinerPredictionMetrics,
208+
type MinerPredictionMetricFamily,
207209
type MinerPredictionMetricRow,
208210
} from "./miner-prediction-metrics.js";
209211
export {

0 commit comments

Comments
 (0)