Public: anyone can read this; the crew can change it.

An agent reads the forge view through an MCP forge_view tool, as GET /api/v1/forge/view does #9252

Merged opened this

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

Merged

mason/forge:mason/9239-an-agent-reads-the-forge-view-through-an → main 3 commits fork

  • Merged. mason/9239-an-agent-reads-the-forge-view-through-an was merged into main as 2abf2d2.

Review

Sign in to review this change.
5 files changed +106 −0
docs/features/forge-view.md +2
@@ -23,6 +23,8 @@ composed, so its numbers are the ones home and the board show.
2323 rows waiting on a person by what `crewNow` finds, and apps at 200 (`VIEW_APPS`; `counts.apps` is still the true number). Poll every
2424 2–5 s; there is no stream, deliberately, so an always-on view adds no connection to hold (#7559).
2525
26 **MCP.** The `forge_view` tool (read-only, no arguments) answers the same body for the same caller, from the same two-second build.
27
2628 **Links.** Home (the apps card) and the command palette show **Foundry** when an app with the slug `foundry` is
2729 installed and the reader may enter it (`actions/foundry_door.ts`). The slug is not enough: the app must be owned by
2830 a site admin or by the `trevato` org, so nobody can plant a "Foundry" link by naming an app first. Nothing in the
src/http/mcp/tools/forge_view.ts added +11
@@ -0,0 +1,11 @@
1 import { forgeViewText } from "../../../actions/forge_view.ts";
2 import { good, type Tool } from "../shared.ts";
3
4 export const tool: Tool = {
5 name: "forge_view",
6 doc: "the forge as one read-only picture: counts, the apps you may see, the crew's runs with their stations, and health; the same body as GET /api/v1/forge/view, built at most once per two seconds per caller",
7 description: "The forge as one picture: counts, apps, runs with stations, health.",
8 inputSchema: { type: "object", properties: {} },
9 writes: false,
10 run: (ctx, user) => good(JSON.parse(forgeViewText(ctx, user))),
11 };
src/http/mcp/tools/index.ts +1
@@ -64,6 +64,7 @@ export { tool as flow } from "./flow.ts";
6464 export { tool as follow_mode } from "./follow_mode.ts";
6565 export { tool as following } from "./following.ts";
6666 export { tool as forge_proposals } from "./forge_proposals.ts";
67 export { tool as forge_view } from "./forge_view.ts";
6768 export { tool as forges } from "./forges.ts";
6869 export { tool as forks } from "./forks.ts";
6970 export { tool as front_page } from "./front_page.ts";
test/mcp-forge-view.test.ts added +91
@@ -0,0 +1,91 @@
1 /** The `forge_view` MCP tool answers what GET /api/v1/forge/view does, to the same caller. */
2 import { afterAll, beforeAll, 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 { createApp as createAppRecord } from "../src/core/apps.ts";
8 import { createToken } from "../src/core/tokens.ts";
9 import { createUser } from "../src/core/users.ts";
10 import { createRepo } from "../src/actions/repos.ts";
11 import { viewBuilds } from "../src/actions/forge_view.ts";
12 import { serve, type Servers } from "../src/http/server.ts";
13 import { promote } from "./helpers/members.ts";
14 import { proveAll } from "./helpers/verified.ts";
15 import { mcpTool } from "./helpers/mcp.ts";
16
17 let dataDir: string;
18 let app: App;
19 let servers: Servers;
20 let ada = "";
21 let adaRo = "";
22 let bob = "";
23 let cy = "";
24
25 beforeAll(async () => {
26 dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-view-"));
27 app = createApp({ dataDir, port: 0 });
28 servers = await serve(app);
29 const a = await createUser(app.db, { origin: "seed", trusted: true, username: "ada", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
30 if (!a.ok) throw new Error(a.error);
31 const b = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" });
32 if (!b.ok) throw new Error(b.error);
33 promote(app.db, b.value);
34 proveAll(app);
35 const open = await createRepo(app, b.value, { name: "open", visibility: "public" });
36 if (!open.ok) throw new Error(String(open.error));
37 const secret = await createRepo(app, b.value, { name: "secret", visibility: "private" });
38 if (!secret.ok) throw new Error(String(secret.error));
39 createAppRecord(app.db, { repoId: open.value.id, slug: "open", access: "public" });
40 createAppRecord(app.db, { repoId: secret.value.id, slug: "secret", access: "private" });
41 const c = await createUser(app.db, { origin: "seed", trusted: true, username: "cy", email: "[email protected]", password: "a-good-passphrase" });
42 if (!c.ok) throw new Error(c.error);
43 cy = createToken(app.db, c.value.id, "ro", "ro").token;
44 ada = createToken(app.db, a.value.id, "rw", "rw").token;
45 adaRo = createToken(app.db, a.value.id, "ro", "ro").token;
46 bob = createToken(app.db, b.value.id, "rw", "rw").token;
47 }, 30_000);
48
49 afterAll(async () => {
50 servers.stop();
51 app.close();
52 await rm(dataDir, { recursive: true, force: true });
53 });
54
55 const api = async (token: string) =>
56 (await fetch(`${servers.url}/api/v1/forge/view`, { headers: { authorization: `Bearer ${token}` } })).json() as Promise<any>;
57
58 test("the tool and the API answer the same body for one caller", async () => {
59 const viaTool = await mcpTool(servers.url, ada, "forge_view", {});
60 const viaApi = await api(ada);
61 expect(viaTool.value).toEqual(viaApi);
62 expect(Object.keys(viaApi).sort()).toEqual(["apps", "at", "counts", "health", "runs"]);
63 });
64
65 test("a private app another person owns is absent from both", async () => {
66 const viaTool = await mcpTool(servers.url, cy, "forge_view", {});
67 const names = (v: any) => v.apps.map((a: any) => a.app);
68 expect(names(await api(cy))).not.toContain("bob/secret");
69 expect(names(viaTool.value)).not.toContain("bob/secret");
70 expect(names(viaTool.value)).toContain("bob/open");
71 // The owner does see it, through both.
72 expect(names((await mcpTool(servers.url, bob, "forge_view", {})).value)).toContain("bob/secret");
73 expect(names(await api(bob))).toContain("bob/secret");
74 });
75
76 test("a ro token lists the tool and is answered; polling builds once", async () => {
77 const res = await fetch(`${servers.url}/mcp`, {
78 method: "POST",
79 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${adaRo}` },
80 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
81 });
82 const listed = ((await res.json()) as any).result.tools.find((t: any) => t.name === "forge_view");
83 expect(listed).toBeDefined();
84 expect(listed.inputSchema.properties).toEqual({});
85 const before = viewBuilds();
86 const first = await mcpTool(servers.url, adaRo, "forge_view", {});
87 expect(first.error).toBeUndefined();
88 await mcpTool(servers.url, adaRo, "forge_view", {});
89 await api(adaRo);
90 expect(viewBuilds() - before).toBeLessThanOrEqual(1);
91 });
test/mcp.test.ts +1
@@ -251,6 +251,7 @@ describe("the forge over MCP", () => {
251251 "org_create",
252252 "stop",
253253 "health",
254 "forge_view",
254255 "audit",
255256 "model_grant",
256257 "accounts",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs