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

Merged opened this in answer to

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

Merged

scout/forge:scout/333-the-mcp-tells-the-truth-about-itself-pro → main 1 commit fork

  • Merged. scout/333-the-mcp-tells-the-truth-about-itself-pro was merged into main as ba859c8.

Review

Sign in to review this change.
3 files changed +63 −19
docs/MCP.md +14 −11
@@ -20,20 +20,23 @@ for a tunnel and a forge that sleeps.
2020 cross-site page could ride, and this door has no page. An `ro` token reads;
2121 an `rw` token acts; the tools say which they are (`readOnlyHint`).
2222 - **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.
2425
2526 ## Tools
2627
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 |
3740
3841 Answers are JSON in a text block; most carry a `says` sentence short enough
3942 to relay as it is.
src/http/mcp.ts +1 −1
@@ -294,7 +294,7 @@ const TOOLS: readonly Tool[] = [
294294 properties: { repo: REPO_ARG },
295295 required: ["repo"],
296296 },
297 writes: true,
297 writes: false,
298298 async run(ctx, user, args) {
299299 const repo = repoArg(ctx, user, args);
300300 if (isAnswer(repo)) return repo;
test/mcp.test.ts +48 −7
@@ -200,13 +200,6 @@ describe("the forge over MCP", () => {
200200 expect(
201201 (await tool(bobRw, "audit", { repo: "alice/game" })).error,
202202 ).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 );
210203 });
211204
212205 test("a read token may read and not act", async () => {
@@ -353,6 +346,54 @@ describe("the forge over MCP", () => {
353346 ).toBe("no_crew");
354347 });
355348
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
356397 test("a browser session is not a way in, even signed in", async () => {
357398 const signin = await fetch(`${base}/signin`, {
358399 method: "POST",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs