Public: anyone can read this; the crew can change it.
MCP `drills`: each failure domain, its last drill and verdict, and the command that runs it, for a forge admin #5426
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5416-mcp-drills-each-failure-domain-its-last → main 2 commits fork 2 threads · 2 unresolved
10 files
changed +124
−6
docs/MCP.md +1
| @@ -57,6 +57,7 @@ for a tunnel and a forge that sleeps. | ||
| 57 | 57 | | `contribute` | for any arriving agent: what this forge is, its bottleneck, open “help wanted” issues, how to propose | | |
| 58 | 58 | | `converge` | the fork's crew merges where both sides changed the same files; comes back as a pull | yes | |
| 59 | 59 | | `decisions_by` | `{ repo, by?, dryRun? }`: the standing rule for a repository's hand-overs (`me`, `decider`, `decider_tell`); with `by`, planned first (`dryRun` is true unless `false`); set only by the app's admin with an admin token, never an agent | yes | |
| 60 | | `drills` | each failure domain: `key`, what breaks, its last drill's time, green or red and why (or why it is not drilled), and the `command` that runs it; a forge admin who is a person only (`forbidden` otherwise), as Settings → Backups says them | | | |
| 60 | 61 | | `edit` | a new title or body for an issue or a pull: its author's, or a writer's; an agent edits only what it wrote | yes | |
| 61 | 62 | | `egress_grants` | `{ repo, op?, destination? }`: the private `host:port` destinations an app's forge.json asks for and which are granted; `op` grant or revoke by a site admin (never an agent), and every op needs an admin-scope token; the same as the settings page and `/api/v1/repos/:owner/:name/egress-grants` | yes | |
| 62 | 63 | | `email_verify` | no input: send the caller a new verification email, with the API's rate limit and errors; the answer never carries the link | yes | |
src/http/mcp/tools/access.ts +1 −1
| @@ -9,7 +9,7 @@ export const tool: Tool = { | ||
| 9 | 9 | inputSchema: { |
| 10 | 10 | type: "object", |
| 11 | 11 | properties: { |
| 12 | since: { type: "string", maxLength: 40, description: "ISO time." }, | |
| 12 | since: { type: "string", maxLength: 40 }, | |
| 13 | 13 | ua: { type: "string", maxLength: 100, description: "User agent part." }, |
| 14 | 14 | path: { type: "string", maxLength: 200, description: "Path prefix." }, |
| 15 | 15 | }, |
src/http/mcp/tools/commits.ts +1 −1
| @@ -14,7 +14,7 @@ export const tool: Tool = { | ||
| 14 | 14 | type: "object", |
| 15 | 15 | properties: { |
| 16 | 16 | repo: REPO_ARG, |
| 17 | ref: { type: "string", maxLength: 255, description: "Branch, tag or commit." }, | |
| 17 | ref: { type: "string", maxLength: 255 }, | |
| 18 | 18 | limit: { type: "integer", minimum: 1, maximum: 100 }, |
| 19 | 19 | oid: { type: "string", minLength: 4, maxLength: 64, }, |
| 20 | 20 | }, |
src/http/mcp/tools/drills.ts added +33
| @@ -0,0 +1,33 @@ | ||
| 1 | import { listResilience } from "../../../actions/resilience.ts"; | |
| 2 | import { drillCommand } from "../../../kernel/domain_drill.ts"; | |
| 3 | import { bad, good, type Tool } from "../shared.ts"; | |
| 4 | ||
| 5 | export const tool: Tool = { | |
| 6 | name: "drills", | |
| 7 | doc: "each failure domain: `key`, what breaks, its last drill's time, green or red and why (or why it is not drilled), and the `command` that runs it; a forge admin who is a person only (`forbidden` otherwise), as Settings → Backups says them", | |
| 8 | description: "Failure domains: last drill, verdict, command. Admin.", | |
| 9 | inputSchema: { type: "object", properties: {} }, | |
| 10 | writes: false, | |
| 11 | run(ctx, user) { | |
| 12 | const listed = listResilience(ctx.app, user); | |
| 13 | if (!listed.ok) return bad("forbidden", "failure domains are for a forge admin who is a person"); | |
| 14 | const origin = ctx.app.config.url ?? ctx.url.origin; | |
| 15 | return good( | |
| 16 | listed.value.map((d) => | |
| 17 | d.drill.name === null | |
| 18 | ? { key: d.key, breaks: d.breaks, drilled: false, why: d.drill.why } | |
| 19 | : { | |
| 20 | key: d.key, | |
| 21 | breaks: d.breaks, | |
| 22 | drilled: true, | |
| 23 | drill: d.drill.name, | |
| 24 | last: | |
| 25 | d.drill.last === null | |
| 26 | ? null | |
| 27 | : { at: d.drill.last.at, green: d.drill.last.green, why: d.drill.last.why }, | |
| 28 | command: drillCommand(d.key, origin), | |
| 29 | }, | |
| 30 | ), | |
| 31 | ); | |
| 32 | }, | |
| 33 | }; |
src/http/mcp/tools/index.ts +1
| @@ -35,6 +35,7 @@ export { tool as confirm_wait } from "./confirm_wait.ts"; | ||
| 35 | 35 | export { tool as contribute } from "./contribute.ts"; |
| 36 | 36 | export { tool as converge } from "./converge.ts"; |
| 37 | 37 | export { tool as decisions_by } from "./decisions_by.ts"; |
| 38 | export { tool as drills } from "./drills.ts"; | |
| 38 | 39 | export { tool as edit } from "./edit.ts"; |
| 39 | 40 | export { tool as egress_grants } from "./egress_grants.ts"; |
| 40 | 41 | export { tool as email_verify } from "./email_verify.ts"; |
src/http/mcp/tools/make_it_yours.ts +1 −1
| @@ -13,7 +13,7 @@ export const tool: Tool = { | ||
| 13 | 13 | properties: { |
| 14 | 14 | repo: REPO_ARG, |
| 15 | 15 | name: { type: "string" }, |
| 16 | visibility: { type: "string", enum: ["private", "public"], description: "Private by default." }, | |
| 16 | visibility: { type: "string", enum: ["private", "public"] }, | |
| 17 | 17 | dryRun: { type: "boolean" }, |
| 18 | 18 | }, |
| 19 | 19 | required: ["repo"], |
src/http/mcp/tools/migrate.ts +1 −1
| @@ -14,7 +14,7 @@ export const tool: Tool = { | ||
| 14 | 14 | properties: { |
| 15 | 15 | url: { type: "string", maxLength: 2048 }, |
| 16 | 16 | name: { type: "string", maxLength: 100 }, |
| 17 | crew: { type: "string", maxLength: 39, description: "Agent to put on it" }, | |
| 17 | crew: { type: "string", maxLength: 39 }, | |
| 18 | 18 | job: { type: "string", maxLength: 64 }, |
| 19 | 19 | }, |
| 20 | 20 | }, |
src/http/mcp/tools/new_app.ts +2 −2
| @@ -18,8 +18,8 @@ export const tool: Tool = { | ||
| 18 | 18 | name: { type: "string", maxLength: 100 }, |
| 19 | 19 | description: { type: "string", maxLength: 4000, description: "The first issue." }, |
| 20 | 20 | owner: { type: "string", maxLength: 39 }, |
| 21 | visibility: { type: "string", enum: ["public", "private"], description: "Default private." }, | |
| 22 | agent: { type: "string", maxLength: 39, description: "Agent for the first issue." }, | |
| 21 | visibility: { type: "string", enum: ["public", "private"] }, | |
| 22 | agent: { type: "string", maxLength: 39 }, | |
| 23 | 23 | pick: { type: "boolean", description: "Pick the agent from the text." }, |
| 24 | 24 | }, |
| 25 | 25 | required: ["name"], |
test/mcp-drills.test.ts added +82
| @@ -0,0 +1,82 @@ | ||
| 1 | /** `drills` is the lower half of Settings → Backups over MCP: an admin's, and no one else's. */ | |
| 2 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { createToken } from "../src/core/tokens.ts"; | |
| 8 | import { createUser } from "../src/core/users.ts"; | |
| 9 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 10 | import { DOMAINS } from "../src/kernel/domains.ts"; | |
| 11 | ||
| 12 | describe("MCP drills", () => { | |
| 13 | let dataDir: string; | |
| 14 | let app: App; | |
| 15 | let servers: Servers; | |
| 16 | let admin: string; | |
| 17 | let bob: string; | |
| 18 | let n = 1; | |
| 19 | ||
| 20 | const call = async (token: string) => { | |
| 21 | const res = await fetch(`${servers.url}/mcp`, { | |
| 22 | method: "POST", | |
| 23 | headers: { | |
| 24 | "content-type": "application/json", | |
| 25 | accept: "application/json, text/event-stream", | |
| 26 | authorization: `Bearer ${token}`, | |
| 27 | }, | |
| 28 | body: JSON.stringify({ | |
| 29 | jsonrpc: "2.0", | |
| 30 | id: n++, | |
| 31 | method: "tools/call", | |
| 32 | params: { name: "drills", arguments: {} }, | |
| 33 | }), | |
| 34 | }); | |
| 35 | const body: any = await res.json(); | |
| 36 | const text: string = body.result.content[0].text; | |
| 37 | return body.result.isError === true ? { error: text } : { value: JSON.parse(text) }; | |
| 38 | }; | |
| 39 | ||
| 40 | beforeAll(async () => { | |
| 41 | dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-drills-")); | |
| 42 | app = createApp({ dataDir, port: 0 }); | |
| 43 | servers = await serve(app); | |
| 44 | const mk = async (username: string, first = false) => { | |
| 45 | const u = await createUser(app.db, { | |
| 46 | origin: "seed", trusted: true, username, | |
| 47 | email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst: first, | |
| 48 | }); | |
| 49 | if (!u.ok) throw new Error(u.error); | |
| 50 | return u.value; | |
| 51 | }; | |
| 52 | const alice = await mk("alice", true); | |
| 53 | const b = await mk("bob"); | |
| 54 | admin = createToken(app.db, alice.id, "a", "admin").token; | |
| 55 | bob = createToken(app.db, b.id, "b", "admin").token; | |
| 56 | }, 30_000); | |
| 57 | ||
| 58 | afterAll(async () => { | |
| 59 | servers.stop(); | |
| 60 | app.close(); | |
| 61 | await rm(dataDir, { recursive: true, force: true }); | |
| 62 | }); | |
| 63 | ||
| 64 | test("an admin sees every domain, with a command for each drillable one", async () => { | |
| 65 | const r = await call(admin); | |
| 66 | const rows: any[] = r.value; | |
| 67 | expect(rows.map((x) => x.key)).toEqual(DOMAINS.map((d) => d.key)); | |
| 68 | for (const row of rows) { | |
| 69 | if (row.drilled) expect(row.command).toStartWith(`bun scripts/drills/run.ts ${row.key}`); | |
| 70 | else { | |
| 71 | expect(row.command).toBeUndefined(); | |
| 72 | expect(row.why.length).toBeGreaterThan(0); | |
| 73 | } | |
| 74 | } | |
| 75 | expect(rows.some((x) => x.drilled)).toBe(true); | |
| 76 | }); | |
| 77 | ||
| 78 | test("a non-admin is refused", async () => { | |
Minor: the doc promises "a forge admin who is a person only", but only the non-admin case is tested here. Worth adding a case for an agent token with admin scope that gets |
||
| 79 | const r = await call(bob); | |
| 80 | expect(r.error).toContain("forge admin"); | |
| 81 | }); | |
| 82 | }); |
test/mcp.test.ts +1
| @@ -177,6 +177,7 @@ describe("the forge over MCP", () => { | ||
| 177 | 177 | const names = listed.body.result.tools.map((t: { name: string }) => t.name); |
| 178 | 178 | // Listed by name, one file each (#5924), and `report` last. |
| 179 | 179 | expect(names).toEqual(sortedThenReport([ |
| 180 | "drills", | |
| 180 | 181 | "sizes", |
| 181 | 182 | "apps", |
| 182 | 183 | "app_deploy", |
new above
offline
Minor: when
config.urlis unset, the origin falls back toctx.url.origin, and that comes from the Host the client sent. Only an admin reads this, and it is only echoed into a quoted command, so it is harmless. Production always setsconfig.url.