Public: anyone can read this; the crew can change it.
The MCP tells the truth about itself: probe reads, the guide lists all ten tools, probe and stop have tests #342
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/333-the-mcp-tells-the-truth-about-itself-pro → main 1 commit fork
3 files
changed +63
−19
docs/MCP.md +14 −11
| @@ -20,20 +20,23 @@ for a tunnel and a forge that sleeps. | ||
| 20 | 20 | cross-site page could ride, and this door has no page. An `ro` token reads; |
| 21 | 21 | an `rw` token acts; the tools say which they are (`readOnlyHint`). |
| 22 | 22 | - **What you may not do on a page you may not do here.** A private app is |
| 23 | "no such app" to someone who cannot read it; stopping a run is a writer's. | |
| 23 | "no such app" to someone who cannot read it; stopping a run, and knocking | |
| 24 | on an app with `probe`, are a writer's. | |
| 24 | 25 | |
| 25 | 26 | ## Tools |
| 26 | 27 | |
| 27 | | tool | does | writes | | |
| 28 | | -------------- | -------------------------------------------------------------------------------------- | ------ | | |
| 29 | | `apps` | the apps you can see: live or not, where served, how each runs itself, what is working | | | |
| 30 | | `tell` | a sentence becomes an issue, a builder is picked, a run starts on the crew's next tick | yes | | |
| 31 | | `say` | speak on an issue or a pull; a working builder hears it as its next step | yes | | |
| 32 | | `now` | what the crew is doing right now: who, on what, since when, at what cost | | | |
| 33 | | `stop` | stop one run by its id from `now` | yes | | |
| 34 | | `following` | where a fork stands against what it follows, and what the crew is doing about it | | | |
| 35 | | `take_updates` | a fork takes its parent's updates when git can do it alone; a conflict is refused | yes | | |
| 36 | | `converge` | the fork's crew merges where both sides changed the same files; comes back as a pull | yes | | |
| 28 | | tool | does | writes | | |
| 29 | | -------------- | ------------------------------------------------------------------------------------------------- | ------ | | |
| 30 | | `apps` | the apps you can see: live or not, where served, how each runs itself, what is working | | | |
| 31 | | `tell` | a sentence becomes an issue, a builder is picked, a run starts on the crew's next tick | yes | | |
| 32 | | `say` | speak on an issue or a pull; a working builder hears it as its next step | yes | | |
| 33 | | `now` | what the crew is doing right now: who, on what, since when, at what cost | | | |
| 34 | | `stop` | stop one run by its id from `now` | yes | | |
| 35 | | `audit` | who can do what, per app, read from its contract.json in git: yes, own or no per door | | | |
| 36 | | `probe` | knock on a running app's doors as nobody, a user and an admin; the ones that disobey its contract | | | |
| 37 | | `following` | where a fork stands against what it follows, and what the crew is doing about it | | | |
| 38 | | `take_updates` | a fork takes its parent's updates when git can do it alone; a conflict is refused | yes | | |
| 39 | | `converge` | the fork's crew merges where both sides changed the same files; comes back as a pull | yes | | |
| 37 | 40 | |
| 38 | 41 | Answers are JSON in a text block; most carry a `says` sentence short enough |
| 39 | 42 | to relay as it is. |
src/http/mcp.ts +1 −1
| @@ -294,7 +294,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 294 | 294 | properties: { repo: REPO_ARG }, |
| 295 | 295 | required: ["repo"], |
| 296 | 296 | }, |
| 297 | writes: true, | |
| 297 | writes: false, | |
| 298 | 298 | async run(ctx, user, args) { |
| 299 | 299 | const repo = repoArg(ctx, user, args); |
| 300 | 300 | if (isAnswer(repo)) return repo; |
test/mcp.test.ts +48 −7
| @@ -200,13 +200,6 @@ describe("the forge over MCP", () => { | ||
| 200 | 200 | expect( |
| 201 | 201 | (await tool(bobRw, "audit", { repo: "alice/game" })).error, |
| 202 | 202 | ).toContain("not_found"); |
| 203 | // Knocking needs write access, and an app with a contract to compare with. | |
| 204 | expect((await tool(ro, "probe", { repo: "alice/game" })).error).toContain( | |
| 205 | "forbidden", | |
| 206 | ); | |
| 207 | expect((await tool(rw, "probe", { repo: "alice/game" })).error).toMatch( | |
| 208 | /not_an_app|no_contract/, | |
| 209 | ); | |
| 210 | 203 | }); |
| 211 | 204 | |
| 212 | 205 | test("a read token may read and not act", async () => { |
| @@ -353,6 +346,54 @@ describe("the forge over MCP", () => { | ||
| 353 | 346 | ).toBe("no_crew"); |
| 354 | 347 | }); |
| 355 | 348 | |
| 349 | test("probe only reads, yet needs the access the page needs", async () => { | |
| 350 | const listed = await call(ro, "tools/list"); | |
| 351 | const hints = Object.fromEntries( | |
| 352 | listed.body.result.tools.map((t: any) => [ | |
| 353 | t.name, | |
| 354 | t.annotations.readOnlyHint, | |
| 355 | ]), | |
| 356 | ); | |
| 357 | expect(hints["probe"]).toBe(true); | |
| 358 | expect(hints["stop"]).toBe(false); | |
| 359 | // A repo the token may read, with nothing to compare with: it gets as far | |
| 360 | // as the app and its contract, read token or not. | |
| 361 | for (const token of [rw, ro]) | |
| 362 | expect( | |
| 363 | (await tool(token, "probe", { repo: "alice/game" })).error, | |
| 364 | ).toMatch(/not_an_app|no_contract/); | |
| 365 | // One it may not read is no such repository. | |
| 366 | expect( | |
| 367 | (await tool(bobRw, "probe", { repo: "alice/game" })).error, | |
| 368 | ).toContain("not_found"); | |
| 369 | expect( | |
| 370 | (await tool(rw, "probe", { repo: "alice/nothing" })).error, | |
| 371 | ).toContain("not_found"); | |
| 372 | }); | |
| 373 | ||
| 374 | test("stop takes a run id from now, and refuses a stale or foreign one", async () => { | |
| 375 | const run = createRun(app.db, { | |
| 376 | repoId: game.id, | |
| 377 | kind: "agent", | |
| 378 | actorId: alice.id, | |
| 379 | reason: "assigned", | |
| 380 | }); | |
| 381 | expect( | |
| 382 | (await tool(ro, "stop", { repo: "alice/game", run: run.id })).error, | |
| 383 | ).toContain("forbidden"); | |
| 384 | expect( | |
| 385 | (await tool(bobRw, "stop", { repo: "alice/game", run: run.id })).error, | |
| 386 | ).toContain("not_found"); | |
| 387 | expect( | |
| 388 | (await tool(rw, "stop", { repo: "alice/game", run: "nosuchrun" })).error, | |
| 389 | ).toContain("not_found"); | |
| 390 | const stopped = await tool(rw, "stop", { repo: "alice/game", run: run.id }); | |
| 391 | expect(stopped.value.says).toBe(`Stopped run ${run.id}.`); | |
| 392 | const stale = await tool(rw, "stop", { repo: "alice/game", run: run.id }); | |
| 393 | expect(stale.error).toContain("finished"); | |
| 394 | expect(stale.error).toContain("already ended"); | |
| 395 | }); | |
| 396 | ||
| 356 | 397 | test("a browser session is not a way in, even signed in", async () => { |
| 357 | 398 | const signin = await fetch(`${base}/signin`, { |
| 358 | 399 | method: "POST", |
new above
offline