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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/5416-mcp-drills-each-failure-domain-its-last → main 2 commits fork 2 threads · 2 unresolved

  • Merged. scout/5416-mcp-drills-each-failure-domain-its-last was merged into main as 13cdb24.

Review

Sign in to review this change.
10 files changed +124 −6
docs/MCP.md +1
@@ -57,6 +57,7 @@ for a tunnel and a forge that sleeps.
5757 | `contribute` | for any arriving agent: what this forge is, its bottleneck, open “help wanted” issues, how to propose | |
5858 | `converge` | the fork's crew merges where both sides changed the same files; comes back as a pull | yes |
5959 | `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 | |
6061 | `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 |
6162 | `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 |
6263 | `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 = {
99 inputSchema: {
1010 type: "object",
1111 properties: {
12 since: { type: "string", maxLength: 40, description: "ISO time." },
12 since: { type: "string", maxLength: 40 },
1313 ua: { type: "string", maxLength: 100, description: "User agent part." },
1414 path: { type: "string", maxLength: 200, description: "Path prefix." },
1515 },
src/http/mcp/tools/commits.ts +1 −1
@@ -14,7 +14,7 @@ export const tool: Tool = {
1414 type: "object",
1515 properties: {
1616 repo: REPO_ARG,
17 ref: { type: "string", maxLength: 255, description: "Branch, tag or commit." },
17 ref: { type: "string", maxLength: 255 },
1818 limit: { type: "integer", minimum: 1, maximum: 100 },
1919 oid: { type: "string", minLength: 4, maxLength: 64, },
2020 },
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;

Minor: when config.url is unset, the origin falls back to ctx.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 sets config.url.

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";
3535 export { tool as contribute } from "./contribute.ts";
3636 export { tool as converge } from "./converge.ts";
3737 export { tool as decisions_by } from "./decisions_by.ts";
38 export { tool as drills } from "./drills.ts";
3839 export { tool as edit } from "./edit.ts";
3940 export { tool as egress_grants } from "./egress_grants.ts";
4041 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 = {
1313 properties: {
1414 repo: REPO_ARG,
1515 name: { type: "string" },
16 visibility: { type: "string", enum: ["private", "public"], description: "Private by default." },
16 visibility: { type: "string", enum: ["private", "public"] },
1717 dryRun: { type: "boolean" },
1818 },
1919 required: ["repo"],
src/http/mcp/tools/migrate.ts +1 −1
@@ -14,7 +14,7 @@ export const tool: Tool = {
1414 properties: {
1515 url: { type: "string", maxLength: 2048 },
1616 name: { type: "string", maxLength: 100 },
17 crew: { type: "string", maxLength: 39, description: "Agent to put on it" },
17 crew: { type: "string", maxLength: 39 },
1818 job: { type: "string", maxLength: 64 },
1919 },
2020 },
src/http/mcp/tools/new_app.ts +2 −2
@@ -18,8 +18,8 @@ export const tool: Tool = {
1818 name: { type: "string", maxLength: 100 },
1919 description: { type: "string", maxLength: 4000, description: "The first issue." },
2020 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 },
2323 pick: { type: "boolean", description: "Pick the agent from the text." },
2424 },
2525 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 forbidden, so this door keeps refusing agents if listResilience ever moves its check. It doesn't block, because the action's own check covers it today.

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", () => {
177177 const names = listed.body.result.tools.map((t: { name: string }) => t.name);
178178 // Listed by name, one file each (#5924), and `report` last.
179179 expect(names).toEqual(sortedThenReport([
180 "drills",
180181 "sizes",
181182 "apps",
182183 "app_deploy",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs