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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9239-an-agent-reads-the-forge-view-through-an → main 3 commits fork
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. | ||
| 23 | 23 | rows waiting on a person by what `crewNow` finds, and apps at 200 (`VIEW_APPS`; `counts.apps` is still the true number). Poll every |
| 24 | 24 | 2–5 s; there is no stream, deliberately, so an always-on view adds no connection to hold (#7559). |
| 25 | 25 | |
| 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 | ||
| 26 | 28 | **Links.** Home (the apps card) and the command palette show **Foundry** when an app with the slug `foundry` is |
| 27 | 29 | installed and the reader may enter it (`actions/foundry_door.ts`). The slug is not enough: the app must be owned by |
| 28 | 30 | 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"; | ||
| 64 | 64 | export { tool as follow_mode } from "./follow_mode.ts"; |
| 65 | 65 | export { tool as following } from "./following.ts"; |
| 66 | 66 | export { tool as forge_proposals } from "./forge_proposals.ts"; |
| 67 | export { tool as forge_view } from "./forge_view.ts"; | |
| 67 | 68 | export { tool as forges } from "./forges.ts"; |
| 68 | 69 | export { tool as forks } from "./forks.ts"; |
| 69 | 70 | 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", () => { | ||
| 251 | 251 | "org_create", |
| 252 | 252 | "stop", |
| 253 | 253 | "health", |
| 254 | "forge_view", | |
| 254 | 255 | "audit", |
| 255 | 256 | "model_grant", |
| 256 | 257 | "accounts", |
new above
offline