Public: anyone can read this; the crew can change it.
Absorb: serve a feature's MCP tools inside /mcp #9902
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9885-absorb-serve-a-feature-s-mcp-tools-insid → main 3 commits fork 1 thread · 1 unresolved
8 files
changed +308
−6
docs/features/absorb.md +6 −2
| @@ -32,7 +32,7 @@ Only a site admin absorbs an app, un-absorbs it or changes its settings. A repos | ||
| 32 | 32 | |
| 33 | 33 | - **Settings > Features** (`/settings/features`). |
| 34 | 34 | - The API: `PUT /api/v1/features/:slug` to absorb and `DELETE /api/v1/features/:slug` to un-absorb. Both need an `admin`-scope token. |
| 35 | - The `features` MCP tool. It is on the ask-first list: it shows a plan, and it applies only with `dryRun: false` and an `admin`-scope token. | |
| 35 | - The `features` MCP tool. It is on the ask-first list: it shows a plan, and absorbing and settings apply only with `dryRun: false` and an `admin`-scope token; calling a declared tool is described under MCP tools below. | |
| 36 | 36 | |
| 37 | 37 | Each act records an event: `app.absorbed`, `app.unabsorbed` or `app.feature_settings`. The settings event names the keys that changed, never the values. |
| 38 | 38 | |
| @@ -76,7 +76,11 @@ Settings stay behind on a fork. A fork of another forge's app carries its `featu | ||
| 76 | 76 | |
| 77 | 77 | ## Not done yet |
| 78 | 78 | |
| 79 | Until then an absorbed app is a menu link with kept settings (Settings › Features says so). The forge does not yet fire a feature's schedule (#9883), show its feed items (#9884), serve its MCP tools inside `/mcp` (#9885), keep `secret` settings (#9886) or show its health on `/system` (#9887). | |
| 79 | Until then an absorbed app is a menu link with kept settings (Settings › Features says so). The forge does not yet fire a feature's schedule (#9883), show its feed items (#9884), keep `secret` settings (#9886) or show its health on `/system` (#9887). | |
| 80 | ||
| 81 | ## MCP tools | |
| 82 | ||
| 83 | A feature may declare `"tools": { "scan": { "description": "Run a scan now", "writes": true } }` (at most 12; `writes` is true unless it says `false`). The forge adds no tool to `/mcp` for them: the `features` tool lists them as `tools` with a `slug`, and `{ slug, tool, args }` calls one of an absorbed app. The forge POSTs `{ "args": … }` to the app's `/forge/tools/<name>` over loopback and returns its JSON (at most 64 KB, 10 seconds). A read tool answers at once for anyone who can read the app; a write tool is for someone who can write it, needs an `rw` token and plans first (`dryRun: false` does it). The plan of a write is told only once the call could be made (allowed, absorbed, running). These tools are MCP-only on purpose: they are an agent's door to the app, and the app's own page is the person's, so the terminal has no entry for them (a person reaches the app itself there). Code: `src/apps/feature_tools.ts`, `src/actions/feature_tools.ts`; tests: `test/feature-tools.test.ts`, `test/features.test.ts`. | |
| 80 | 84 | |
| 81 | 85 | ## Findings |
| 82 | 86 |
src/actions/feature_tools.ts added +42
| @@ -0,0 +1,42 @@ | ||
| 1 | /** | |
| 2 | * Calling a tool an absorbed app declares (`apps/feature_tools.ts`). The rules: | |
| 3 | * only an absorbed app's declared tools are called; a reader may call a tool | |
| 4 | * that only reads, a writer one that changes things, and a writer's call is | |
| 5 | * planned before it is made; the app is reached over | |
| 6 | * loopback at its production port and nothing else is asked of it. | |
| 7 | */ | |
| 8 | ||
| 9 | import type { App } from "../app.ts"; | |
| 10 | import { callTool } from "../apps/feature_tools.ts"; | |
| 11 | import { appsRuntime } from "../apps/runtime.ts"; | |
| 12 | import { getAbsorbed } from "../core/features.ts"; | |
| 13 | import { canWrite } from "../core/repos.ts"; | |
| 14 | import type { User } from "../core/users.ts"; | |
| 15 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 16 | import { declaredFeature, readable } from "./features.ts"; | |
| 17 | ||
| 18 | export type ToolError = "not_found" | "not_absorbed" | "no_such_tool" | "forbidden" | "not_running" | "failed"; | |
| 19 | ||
| 20 | export async function callFeatureTool( | |
| 21 | app: App, | |
| 22 | actor: User | undefined, | |
| 23 | slug: string, | |
| 24 | tool: string, | |
| 25 | args: Record<string, unknown>, | |
| 26 | dryRun: boolean, | |
| 27 | ): Promise<Result<{ planned: string } | { answer: unknown }, { error: ToolError; message: string }>> { | |
| 28 | const refuse = (error: ToolError, message: string) => err({ error, message }); | |
| 29 | const found = readable(app, actor, slug); | |
| 30 | if (found === undefined) return refuse("not_found", "No such app."); | |
| 31 | if (getAbsorbed(app.db, found.record.id) === undefined) return refuse("not_absorbed", "The app is not absorbed."); | |
| 32 | const declared = await declaredFeature(app, found.repo); | |
| 33 | const spec = declared.ok ? declared.value?.tools.find((t) => t.name === tool) : undefined; | |
| 34 | if (spec === undefined) return refuse("no_such_tool", `The app declares no tool ${tool}.`); | |
| 35 | if (spec.writes && !canWrite(app.db, found.repo, actor)) return refuse("forbidden", "A tool that changes things is for someone who can write this app."); | |
| 36 | const port = appsRuntime(app)?.envStatus(found.record, "production")?.port; | |
| 37 | if (port === undefined) return refuse("not_running", "The app is not running."); | |
| 38 | // A plan is told only once the call could be made: allowed, absorbed, running. | |
| 39 | if (spec.writes && dryRun) return ok({ planned: `Would call ${tool} on ${found.record.slug}: ${spec.description} Send dryRun: false to do it.` }); | |
| 40 | const answer = await callTool(port, tool, args); | |
| 41 | return answer.ok ? ok({ answer: answer.value }) : refuse("failed", answer.error); | |
| 42 | } |
src/actions/features.ts +5 −1
| @@ -20,6 +20,7 @@ | ||
| 20 | 20 | import type { App } from "../app.ts"; |
| 21 | 21 | import { readFileAt } from "../apps/checkout.ts"; |
| 22 | 22 | import { checkSetting, featureEnv, parseFeature, settingEnvName, type FeatureSpec } from "../apps/feature.ts"; |
| 23 | import type { ToolSpec } from "../apps/feature_tools.ts"; | |
| 23 | 24 | import { appsRuntime } from "../apps/runtime.ts"; |
| 24 | 25 | import { getAppByRepo, getAppById, getAppBySlug, listApps, type AppRecord } from "../core/apps.ts"; |
| 25 | 26 | import { |
| @@ -81,6 +82,8 @@ export interface FeatureView { | ||
| 81 | 82 | /** Why the declaration is not usable, when it is not; `settings` is then empty. */ |
| 82 | 83 | readonly problem: string | null; |
| 83 | 84 | readonly settings: readonly SettingView[]; |
| 85 | /** The MCP tools the app declares; the `features` tool calls them while it is absorbed. */ | |
| 86 | readonly tools: readonly ToolSpec[]; | |
| 84 | 87 | /** Where the app comes from: the upstream only when the reader may read it. */ |
| 85 | 88 | readonly provenance: Provenance; |
| 86 | 89 | } |
| @@ -230,6 +233,7 @@ function viewOf( | ||
| 230 | 233 | absorbedAt: absorbed?.absorbedAt ?? null, |
| 231 | 234 | problem, |
| 232 | 235 | provenance, |
| 236 | tools: spec?.tools ?? [], | |
| 233 | 237 | settings: (spec?.settings ?? []).map((s) => { |
| 234 | 238 | const name = settingEnvName(s.key); |
| 235 | 239 | return { |
| @@ -250,7 +254,7 @@ function viewOf( | ||
| 250 | 254 | } |
| 251 | 255 | |
| 252 | 256 | /** The app behind a slug, if the actor may read its repository. */ |
| 253 | function readable(app: App, actor: User | undefined, slug: string): { record: AppRecord; repo: Repo } | undefined { | |
| 257 | export function readable(app: App, actor: User | undefined, slug: string): { record: AppRecord; repo: Repo } | undefined { | |
| 254 | 258 | const record = getAppBySlug(app.db, slug); |
| 255 | 259 | if (record === undefined) return undefined; |
| 256 | 260 | const repo = getRepoById(app.db, record.repoId); |
src/apps/feature.ts +7 −2
| @@ -23,6 +23,7 @@ | ||
| 23 | 23 | */ |
| 24 | 24 | |
| 25 | 25 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 26 | import { parseTools, type ToolSpec } from "./feature_tools.ts"; | |
| 26 | 27 | |
| 27 | 28 | export type SettingType = "bool" | "int" | "string" | "choice" | "days" | "list"; |
| 28 | 29 | |
| @@ -65,6 +66,8 @@ export interface FeatureSpec { | ||
| 65 | 66 | /** The app path the forge's nav opens. */ |
| 66 | 67 | readonly page: string; |
| 67 | 68 | readonly settings: readonly SettingSpec[]; |
| 69 | /** MCP tools the app serves through the forge's `features` tool (`feature_tools.ts`). */ | |
| 70 | readonly tools: readonly ToolSpec[]; | |
| 68 | 71 | } |
| 69 | 72 | |
| 70 | 73 | const KEY = /^[a-z][a-zA-Z0-9]{0,39}$/; |
| @@ -114,8 +117,10 @@ export function parseFeature(forgeJson: Raw): Result<FeatureSpec | undefined, st | ||
| 114 | 117 | const envs = new Set(settings.map((s) => settingEnvName(s.key))); |
| 115 | 118 | if (envs.size !== settings.length) return bad("two settings would share one FORGE_SETTING_ name"); |
| 116 | 119 | for (const extra of Object.keys(block)) |
| 117 | if (!["name", "summary", "page", "settings"].includes(extra)) return bad(`unknown field ${extra}`); | |
| 118 | return ok({ name: name.trim(), summary: typeof summary === "string" && summary.trim() !== "" ? summary.trim() : null, page, settings }); | |
| 120 | if (!["name", "summary", "page", "settings", "tools"].includes(extra)) return bad(`unknown field ${extra}`); | |
| 121 | const tools = parseTools(block["tools"]); | |
| 122 | if (!tools.ok) return tools; | |
| 123 | return ok({ tools: tools.value, name: name.trim(), summary: typeof summary === "string" && summary.trim() !== "" ? summary.trim() : null, page, settings }); | |
| 119 | 124 | } |
| 120 | 125 | |
| 121 | 126 | function parseSetting(key: string, raw: unknown): Result<SettingSpec, string> { |
src/apps/feature_tools.ts added +83
| @@ -0,0 +1,83 @@ | ||
| 1 | /** | |
| 2 | * The MCP tools an app declares as part of its `feature`: | |
| 3 | * | |
| 4 | * "feature": { "name": "Radar", "tools": { | |
| 5 | * "scan": { "description": "Run a scan now", "writes": true }, | |
| 6 | * "latest": { "description": "The last findings", "writes": false } } } | |
| 7 | * | |
| 8 | * The forge lists them under the `features` MCP tool and, for an absorbed app, | |
| 9 | * calls one by POSTing `{ args }` to the app's `/forge/tools/<name>` over | |
| 10 | * loopback. The app's code stays in its cell; what it answers is data, bounded | |
| 11 | * in size and time. No tool is added to `/mcp` itself, so the manifest does not grow. | |
| 12 | */ | |
| 13 | ||
| 14 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 15 | ||
| 16 | export interface ToolSpec { | |
| 17 | readonly name: string; | |
| 18 | readonly description: string; | |
| 19 | /** Whether a call changes anything: a write plans first, like the forge's own. */ | |
| 20 | readonly writes: boolean; | |
| 21 | } | |
| 22 | ||
| 23 | const NAME = /^[a-z][a-z0-9_]{0,39}$/; | |
| 24 | export const TOOLS_MAX = 12; | |
| 25 | const DESCRIPTION_MAX = 200; | |
| 26 | export const ANSWER_MAX = 64 * 1024; | |
| 27 | const WAIT_MS = 10_000; | |
| 28 | ||
| 29 | /** The `tools` of a feature block; an error names the tool. */ | |
| 30 | export function parseTools(raw: unknown): Result<readonly ToolSpec[], string> { | |
| 31 | if (raw === undefined) return ok([]); | |
| 32 | if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return err("feature: tools must be an object of name → tool"); | |
| 33 | const names = Object.keys(raw); | |
| 34 | if (names.length > TOOLS_MAX) return err(`feature: at most ${TOOLS_MAX} tools`); | |
| 35 | const out: ToolSpec[] = []; | |
| 36 | for (const name of names) { | |
| 37 | const t = (raw as Record<string, unknown>)[name]; | |
| 38 | if (!NAME.test(name)) return err(`feature: tool ${JSON.stringify(name)}: names are lowercase words with _, at most 40 characters`); | |
| 39 | if (typeof t !== "object" || t === null || Array.isArray(t)) return err(`feature: tool ${name} must be an object`); | |
| 40 | const { description, writes, ...extra } = t as Record<string, unknown>; | |
| 41 | if (typeof description !== "string" || description.trim() === "" || description.length > DESCRIPTION_MAX) | |
| 42 | return err(`feature: tool ${name}: description is text of 1 to ${DESCRIPTION_MAX} characters`); | |
| 43 | if (writes !== undefined && typeof writes !== "boolean") return err(`feature: tool ${name}: writes is true or false`); | |
| 44 | if (Object.keys(extra).length > 0) return err(`feature: tool ${name}: unknown field ${Object.keys(extra)[0]}`); | |
| 45 | out.push({ name, description: description.trim(), writes: writes !== false }); | |
| 46 | } | |
| 47 | return ok(out); | |
| 48 | } | |
| 49 | ||
| 50 | /** Call a declared tool on the app listening on `port`: its JSON answer, or why not. */ | |
| 51 | export async function callTool(port: number, tool: string, args: Record<string, unknown>): Promise<Result<unknown, string>> { | |
| 52 | try { | |
| 53 | const res = await fetch(`http://127.0.0.1:${port}/forge/tools/${tool}`, { | |
| 54 | method: "POST", | |
| 55 | headers: { "content-type": "application/json", accept: "application/json" }, | |
| 56 | body: JSON.stringify({ args }), | |
| 57 | redirect: "manual", | |
| 58 | signal: AbortSignal.timeout(WAIT_MS), | |
| 59 | }); | |
| 60 | const reader = res.body?.getReader(); | |
| 61 | const chunks: Uint8Array[] = []; | |
| 62 | let total = 0; | |
| 63 | while (reader !== undefined) { | |
| 64 | const { done, value } = await reader.read(); | |
| 65 | if (done) break; | |
| 66 | total += value.length; | |
| 67 | if (total > ANSWER_MAX) { | |
| 68 | await reader.cancel(); | |
| 69 | return err("the app's answer is over 64 KB"); | |
| 70 | } | |
| 71 | chunks.push(value); | |
| 72 | } | |
| 73 | const text = Buffer.concat(chunks).toString("utf8"); | |
| 74 | if (res.status !== 200) return err(`the app answered ${res.status}`); | |
| 75 | try { | |
| 76 | return ok(JSON.parse(text)); | |
| 77 | } catch { | |
| 78 | return err("the app's answer is not JSON"); | |
| 79 | } | |
| 80 | } catch { | |
| 81 | return err("the app did not answer"); | |
| 82 | } | |
| 83 | } |
src/http/mcp/tools/features.ts +13 −1
| @@ -1,10 +1,11 @@ | ||
| 1 | 1 | import { absorbApp, changeFeatureSettings, getFeature, listFeatures, unabsorbApp } from "../../../actions/features.ts"; |
| 2 | import { callFeatureTool } from "../../../actions/feature_tools.ts"; | |
| 2 | 3 | import { scopeAllows } from "../../../core/tokens.ts"; |
| 3 | 4 | import { good, bad, type Tool } from "../shared.ts"; |
| 4 | 5 | |
| 5 | 6 | export const tool: Tool = { |
| 6 | 7 | name: "features", |
| 7 | doc: "`{ slug?, absorb?, settings?, dryRun? }`: apps absorbed as the forge's own features. With nothing, every app whose forge.json declares a `feature`, absorbed or not (forge admin); with `slug`, that one and its typed settings (its readers); `absorb: true|false` absorbs or un-absorbs it, `settings: { name: value }` changes an absorbed one's settings (each checked against its declaration, the app restarts with them as `FORGE_SETTING_*`); changes are planned first (`dryRun` is true unless `false`) and need an admin-scope token", | |
| 8 | doc: "`{ slug?, absorb?, settings?, tool?, args?, dryRun? }`: apps absorbed as the forge's own features. With nothing, every app whose forge.json declares a `feature`, absorbed or not (forge admin); with `slug`, that one and its typed settings (its readers); `absorb: true|false` absorbs or un-absorbs it, `settings: { name: value }` changes an absorbed one's settings (each checked against its declaration, the app restarts with them as `FORGE_SETTING_*`); `tool` calls one of the absorbed app's declared tools (listed as `tools` with `slug`) with `args`: a read tool answers at once, one that writes is for a repo writer and planned first; absorbing and settings changes are planned first (`dryRun` is true unless `false`) and need an admin-scope token", | |
| 8 | 9 | description: "Apps absorbed as forge features: list, read, absorb or un-absorb, change typed settings.", |
| 9 | 10 | inputSchema: { |
| 10 | 11 | type: "object", |
| @@ -12,6 +13,8 @@ export const tool: Tool = { | ||
| 12 | 13 | slug: { type: "string", maxLength: 100 }, |
| 13 | 14 | absorb: { type: "boolean" }, |
| 14 | 15 | settings: { type: "object" }, |
| 16 | tool: { type: "string", maxLength: 40 }, | |
| 17 | args: { type: "object" }, | |
| 15 | 18 | dryRun: { type: "boolean" }, |
| 16 | 19 | }, |
| 17 | 20 | }, |
| @@ -25,6 +28,15 @@ export const tool: Tool = { | ||
| 25 | 28 | } |
| 26 | 29 | const found = await getFeature(ctx.app, user, slug); |
| 27 | 30 | if (!found.ok) return bad(found.error, found.error === "not_a_feature" ? "that app declares no feature" : "no such app"); |
| 31 | const tool = typeof args["tool"] === "string" ? args["tool"] : undefined; | |
| 32 | if (tool !== undefined) { | |
| 33 | const spec = found.value.tools.find((t) => t.name === tool); | |
| 34 | const given = typeof args["args"] === "object" && args["args"] !== null && !Array.isArray(args["args"]) ? (args["args"] as Record<string, unknown>) : {}; | |
| 35 | if (spec?.writes === true && !scopeAllows(ctx.auth.scope ?? "ro", "rw")) return bad("forbidden", "a tool that writes needs an rw token"); | |
| 36 | const called = await callFeatureTool(ctx.app, user, slug, tool, given, args["dryRun"] !== false); | |
| 37 | if (!called.ok) return bad(called.error.error, called.error.message); | |
| 38 | return good("planned" in called.value ? { says: called.value.planned } : { tool, result: called.value.answer }); | |
| 39 | } | |
| 28 | 40 | const absorb = typeof args["absorb"] === "boolean" ? args["absorb"] : undefined; |
| 29 | 41 | const settings = typeof args["settings"] === "object" && args["settings"] !== null && !Array.isArray(args["settings"]) |
| 30 | 42 | ? (args["settings"] as Record<string, unknown>) |
test/feature-tools.test.ts added +75
| @@ -0,0 +1,75 @@ | ||
| 1 | /** | |
| 2 | * An absorbed app's declared MCP tools are served inside `/mcp` through the | |
| 3 | * `features` tool (no new tool): declared in forge.json, listed with the | |
| 4 | * feature, called over loopback, refused unless absorbed and declared. | |
| 5 | */ | |
| 6 | ||
| 7 | import { describe, expect, test } from "bun:test"; | |
| 8 | import { callTool, parseTools } from "../src/apps/feature_tools.ts"; | |
| 9 | import { parseFeature } from "../src/apps/feature.ts"; | |
| 10 | import { TOOLS } from "../src/http/mcp/tools.ts"; | |
| 11 | ||
| 12 | describe("declaring tools", () => { | |
| 13 | test("a feature carries its tools, writing unless it says otherwise", () => { | |
| 14 | const parsed = parseFeature({ feature: { name: "Radar", tools: { scan: { description: "Run a scan" }, latest: { description: "Last findings", writes: false } } } }); | |
| 15 | expect(parsed.ok && parsed.value?.tools).toEqual([ | |
| 16 | { name: "scan", description: "Run a scan", writes: true }, | |
| 17 | { name: "latest", description: "Last findings", writes: false }, | |
| 18 | ]); | |
| 19 | const none = parseFeature({ feature: { name: "Radar" } }); | |
| 20 | expect(none.ok && none.value?.tools).toEqual([]); | |
| 21 | }); | |
| 22 | ||
| 23 | test("a wrong tool is refused with its name", () => { | |
| 24 | expect(parseTools({ Scan: { description: "x" } }).ok).toBe(false); | |
| 25 | expect(parseTools({ scan: {} }).ok).toBe(false); | |
| 26 | expect(parseTools({ scan: { description: "x", writes: "no" } }).ok).toBe(false); | |
| 27 | expect(parseTools({ scan: { description: "x", url: "/x" } }).ok).toBe(false); | |
| 28 | expect(parseTools([]).ok).toBe(false); | |
| 29 | const many = Object.fromEntries(Array.from({ length: 13 }, (_, i) => [`t${i}`, { description: "x" }])); | |
| 30 | expect(parseTools(many).ok).toBe(false); | |
| 31 | const r = parseFeature({ feature: { name: "Radar", tools: { bad: 1 } } }); | |
| 32 | expect(!r.ok && r.error).toContain("tool bad"); | |
| 33 | }); | |
| 34 | ||
| 35 | test("no tool was added to /mcp for it", () => { | |
| 36 | expect(TOOLS.some((t) => t.name.startsWith("feature_"))).toBe(false); | |
| 37 | }); | |
| 38 | }); | |
| 39 | ||
| 40 | describe("calling a tool on the app", () => { | |
| 41 | const serveApp = (handler: (req: Request) => Response | Promise<Response>) => Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: handler }); | |
| 42 | ||
| 43 | test("posts the args to /forge/tools/<name> and returns the JSON", async () => { | |
| 44 | let seen = ""; | |
| 45 | const s = serveApp(async (req) => { | |
| 46 | seen = `${req.method} ${new URL(req.url).pathname} ${await req.text()}`; | |
| 47 | return Response.json({ found: 3 }); | |
| 48 | }); | |
| 49 | try { | |
| 50 | const out = await callTool(s.port!, "scan", { depth: 2 }); | |
| 51 | expect(out).toEqual({ ok: true, value: { found: 3 } }); | |
| 52 | expect(seen).toBe('POST /forge/tools/scan {"args":{"depth":2}}'); | |
| 53 | } finally { | |
| 54 | s.stop(true); | |
| 55 | } | |
| 56 | }); | |
| 57 | ||
| 58 | test("a failing, non-JSON, oversized or absent app is an error, not a crash", async () => { | |
| 59 | const bad = serveApp((req) => { | |
| 60 | const p = new URL(req.url).pathname; | |
| 61 | if (p.endsWith("/boom")) return new Response("no", { status: 500 }); | |
| 62 | if (p.endsWith("/text")) return new Response("hello"); | |
| 63 | return new Response("x".repeat(70 * 1024)); | |
| 64 | }); | |
| 65 | try { | |
| 66 | expect((await callTool(bad.port!, "boom", {})).ok).toBe(false); | |
| 67 | expect((await callTool(bad.port!, "text", {})).ok).toBe(false); | |
| 68 | const big = await callTool(bad.port!, "big", {}); | |
| 69 | expect(!big.ok && big.error).toContain("64 KB"); | |
| 70 | } finally { | |
| 71 | bad.stop(true); | |
| 72 | } | |
| 73 | expect((await callTool(1, "scan", {})).ok).toBe(false); | |
| 74 | }); | |
| 75 | }); |
test/features.test.ts +77
| @@ -29,6 +29,7 @@ import { listEvents } from "../src/core/events.ts"; | ||
| 29 | 29 | import { absorb, featureSettings, getAbsorbed } from "../src/core/features.ts"; |
| 30 | 30 | import type { Repo } from "../src/core/repos.ts"; |
| 31 | 31 | import { createToken } from "../src/core/tokens.ts"; |
| 32 | import { AppsRuntime } from "../src/apps/runtime.ts"; | |
| 32 | 33 | import { createUser, type User } from "../src/core/users.ts"; |
| 33 | 34 | import { eventVerb } from "../src/design/activity.ts"; |
| 34 | 35 | import { provenanceRows } from "../src/web/pages/features.ts"; |
| @@ -49,6 +50,7 @@ const FEATURE = { | ||
| 49 | 50 | claudeCode: { type: "choice", choices: ["stable", "latest"], default: "latest", label: "Claude Code follows" }, |
| 50 | 51 | scout: { type: "bool", default: true, label: "Search the web for ideas" }, |
| 51 | 52 | }, |
| 53 | tools: { scan: { description: "Run a scan now" }, latest: { description: "The last findings", writes: false } }, | |
| 52 | 54 | }; |
| 53 | 55 | const FORGE_JSON = JSON.stringify({ app: { run: "bun run src/server.ts" }, feature: FEATURE }); |
| 54 | 56 | |
| @@ -582,6 +584,17 @@ describe("absorbing an app", () => { | ||
| 582 | 584 | expect(stranger.error).toBeDefined(); |
| 583 | 585 | }); |
| 584 | 586 | |
| 587 | test("the MCP tool lists the app's tools, plans a write, and refuses what is not declared or not running", async () => { | |
| 588 | const listed = await mcpTool(servers.url, adaToken, "features", { slug: "radar" }); | |
| 589 | expect(listed.value?.tools.map((t: { name: string }) => t.name)).toEqual(["scan", "latest"]); | |
| 590 | const missing = await mcpTool(servers.url, adaToken, "features", { slug: "radar", tool: "nope" }); | |
| 591 | expect(missing.error).toContain("no_such_tool"); | |
| 592 | const asleep = await mcpTool(servers.url, adaToken, "features", { slug: "radar", tool: "latest" }); | |
| 593 | expect(asleep.error).toContain("not_running"); | |
| 594 | const stranger = await mcpTool(servers.url, bobToken, "features", { slug: "radar", tool: "latest" }); | |
| 595 | expect(stranger.error).toBeDefined(); | |
| 596 | }); | |
| 597 | ||
| 585 | 598 | test("the declaration is cached by tip: the same tip is one read, a moved tip reads again", async () => { |
| 586 | 599 | const first = await declaredFeature(app, repo); |
| 587 | 600 | expect(await declaredFeature(app, repo)).toBe(first); // the very same parsed result |
| @@ -636,6 +649,70 @@ describe("absorbing an app", () => { | ||
| 636 | 649 | expect(long.endsWith("word…")).toBe(true); |
| 637 | 650 | }); |
| 638 | 651 | |
| 652 | test("a declared tool is refused to who may not call it, planned only when it could run, and reaches the app", async () => { | |
| 653 | const hits: string[] = []; | |
| 654 | const stub = Bun.serve({ | |
| 655 | port: 0, | |
| 656 | hostname: "127.0.0.1", | |
| 657 | fetch: async (req) => { | |
| 658 | hits.push(`${new URL(req.url).pathname} ${await req.text()}`); | |
| 659 | return Response.json({ found: 3 }); | |
| 660 | }, | |
| 661 | }); | |
| 662 | const runtime = new AppsRuntime(app, { domain: "apps.test", scheme: "http", publicPort: servers.port, forgeUrl: servers.url }); | |
| 663 | const serving = (on: boolean) => { | |
| 664 | runtime.envStatus = (() => (on ? { port: stub.port } : undefined)) as never; | |
| 665 | }; | |
| 666 | const cyRw = createToken(app.db, cy.id, "rw", "rw").token; | |
| 667 | const cyRo = createToken(app.db, cy.id, "ro", "ro").token; | |
| 668 | try { | |
| 669 | serving(true); | |
| 670 | // A reader (no write access, rw token) is refused a writing tool, with or without a plan. | |
| 671 | app.db.run("UPDATE repo SET visibility = 'public' WHERE id = ?1", [repo.id]); | |
| 672 | const bobRw = createToken(app.db, bob.id, "rw", "rw").token; | |
| 673 | for (const dryRun of [true, false]) { | |
| 674 | const reader = await mcpTool(servers.url, bobRw, "features", { slug: "radar", tool: "scan", dryRun }); | |
| 675 | expect(reader.error).toContain("forbidden"); | |
| 676 | expect(reader.value).toBeUndefined(); | |
| 677 | } | |
| 678 | // A read-only token is refused a writing tool even for a writer. | |
| 679 | const ro = await mcpTool(servers.url, cyRo, "features", { slug: "radar", tool: "scan", dryRun: false }); | |
| 680 | expect(ro.error).toBeDefined(); | |
| 681 | // The forge lets a read-only token ask for any plan; a tool's plan is still not told to it. | |
| 682 | const roPlan = await mcpTool(servers.url, cyRo, "features", { slug: "radar", tool: "scan" }); | |
| 683 | expect(roPlan.error).toContain("forbidden"); | |
| 684 | expect(hits).toEqual([]); | |
| 685 | // A writer's plan comes back without reaching the app; dryRun:false reaches it. | |
| 686 | const plan = await mcpTool(servers.url, cyRw, "features", { slug: "radar", tool: "scan" }); | |
| 687 | expect(plan.value?.says).toContain("Would call scan"); | |
| 688 | expect(hits).toEqual([]); | |
| 689 | const done = await mcpTool(servers.url, cyRw, "features", { slug: "radar", tool: "scan", args: { depth: 2 }, dryRun: false }); | |
| 690 | expect(done.value).toEqual({ tool: "scan", result: { found: 3 } }); | |
| 691 | expect(hits).toEqual(['/forge/tools/scan {"args":{"depth":2}}']); | |
| 692 | // A reader still reads with a read tool. | |
| 693 | const read = await mcpTool(servers.url, bobRw, "features", { slug: "radar", tool: "latest" }); | |
| 694 | expect(read.value).toEqual({ tool: "latest", result: { found: 3 } }); | |
| 695 | // A stopped app is told nothing of a plan. | |
| 696 | serving(false); | |
| 697 | const stopped = await mcpTool(servers.url, cyRw, "features", { slug: "radar", tool: "scan" }); | |
| 698 | expect(stopped.error).toContain("not_running"); | |
| 699 | expect(stopped.value).toBeUndefined(); | |
| 700 | // An app not absorbed is not called, and no plan is told. | |
| 701 | serving(true); | |
| 702 | await unabsorbApp(app, ada, "radar"); | |
| 703 | const out = await mcpTool(servers.url, cyRw, "features", { slug: "radar", tool: "scan" }); | |
| 704 | expect(out.error).toContain("not_absorbed"); | |
| 705 | expect(out.value).toBeUndefined(); | |
| 706 | const outRead = await mcpTool(servers.url, cyRw, "features", { slug: "radar", tool: "latest" }); | |
| 707 | expect(outRead.error).toContain("not_absorbed"); | |
| 708 | await absorbApp(app, ada, "radar"); | |
| 709 | expect(hits).toHaveLength(2); | |
| 710 | } finally { | |
| 711 | app.db.run("UPDATE repo SET visibility = 'private' WHERE id = ?1", [repo.id]); | |
| 712 | stub.stop(true); | |
| 713 | } | |
| 714 | }); | |
| 715 | ||
| 639 | 716 | test("deleting the app takes its feature row and settings with it", () => { |
| 640 | 717 | const rows = (table: string) => |
| 641 | 718 | app.db.query(`SELECT 1 FROM ${table} WHERE app_id = ?1`).all(appId).length; |
Plan is answered before canWrite, absorbed and running are checked; move it after those checks.