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

Absorb: serve a feature's MCP tools inside /mcp #9902

Merged opened this

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

Merged

mason/forge:mason/9885-absorb-serve-a-feature-s-mcp-tools-insid → main 3 commits fork 1 thread · 1 unresolved

  • Merged. mason/9885-absorb-serve-a-feature-s-mcp-tools-insid was merged into main as bee3feb.

Review

Sign in to review this change.
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
3232
3333 - **Settings > Features** (`/settings/features`).
3434 - 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.
3636
3737 Each act records an event: `app.absorbed`, `app.unabsorbed` or `app.feature_settings`. The settings event names the keys that changed, never the values.
3838
@@ -76,7 +76,11 @@ Settings stay behind on a fork. A fork of another forge's app carries its `featu
7676
7777 ## Not done yet
7878
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`.
8084
8185 ## Findings
8286
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 @@
2020 import type { App } from "../app.ts";
2121 import { readFileAt } from "../apps/checkout.ts";
2222 import { checkSetting, featureEnv, parseFeature, settingEnvName, type FeatureSpec } from "../apps/feature.ts";
23 import type { ToolSpec } from "../apps/feature_tools.ts";
2324 import { appsRuntime } from "../apps/runtime.ts";
2425 import { getAppByRepo, getAppById, getAppBySlug, listApps, type AppRecord } from "../core/apps.ts";
2526 import {
@@ -81,6 +82,8 @@ export interface FeatureView {
8182 /** Why the declaration is not usable, when it is not; `settings` is then empty. */
8283 readonly problem: string | null;
8384 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[];
8487 /** Where the app comes from: the upstream only when the reader may read it. */
8588 readonly provenance: Provenance;
8689 }
@@ -230,6 +233,7 @@ function viewOf(
230233 absorbedAt: absorbed?.absorbedAt ?? null,
231234 problem,
232235 provenance,
236 tools: spec?.tools ?? [],
233237 settings: (spec?.settings ?? []).map((s) => {
234238 const name = settingEnvName(s.key);
235239 return {
@@ -250,7 +254,7 @@ function viewOf(
250254 }
251255
252256 /** 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 {
254258 const record = getAppBySlug(app.db, slug);
255259 if (record === undefined) return undefined;
256260 const repo = getRepoById(app.db, record.repoId);
src/apps/feature.ts +7 −2
@@ -23,6 +23,7 @@
2323 */
2424
2525 import { err, ok, type Result } from "../kernel/result.ts";
26 import { parseTools, type ToolSpec } from "./feature_tools.ts";
2627
2728 export type SettingType = "bool" | "int" | "string" | "choice" | "days" | "list";
2829
@@ -65,6 +66,8 @@ export interface FeatureSpec {
6566 /** The app path the forge's nav opens. */
6667 readonly page: string;
6768 readonly settings: readonly SettingSpec[];
69 /** MCP tools the app serves through the forge's `features` tool (`feature_tools.ts`). */
70 readonly tools: readonly ToolSpec[];
6871 }
6972
7073 const KEY = /^[a-z][a-zA-Z0-9]{0,39}$/;
@@ -114,8 +117,10 @@ export function parseFeature(forgeJson: Raw): Result<FeatureSpec | undefined, st
114117 const envs = new Set(settings.map((s) => settingEnvName(s.key)));
115118 if (envs.size !== settings.length) return bad("two settings would share one FORGE_SETTING_ name");
116119 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 });
119124 }
120125
121126 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 @@
11 import { absorbApp, changeFeatureSettings, getFeature, listFeatures, unabsorbApp } from "../../../actions/features.ts";
2 import { callFeatureTool } from "../../../actions/feature_tools.ts";
23 import { scopeAllows } from "../../../core/tokens.ts";
34 import { good, bad, type Tool } from "../shared.ts";
45
56 export const tool: Tool = {
67 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",
89 description: "Apps absorbed as forge features: list, read, absorb or un-absorb, change typed settings.",
910 inputSchema: {
1011 type: "object",
@@ -12,6 +13,8 @@ export const tool: Tool = {
1213 slug: { type: "string", maxLength: 100 },
1314 absorb: { type: "boolean" },
1415 settings: { type: "object" },
16 tool: { type: "string", maxLength: 40 },
17 args: { type: "object" },
1518 dryRun: { type: "boolean" },
1619 },
1720 },
@@ -25,6 +28,15 @@ export const tool: Tool = {
2528 }
2629 const found = await getFeature(ctx.app, user, slug);
2730 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>) : {};

Plan is answered before canWrite, absorbed and running are checked; move it after those checks.

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 }
2840 const absorb = typeof args["absorb"] === "boolean" ? args["absorb"] : undefined;
2941 const settings = typeof args["settings"] === "object" && args["settings"] !== null && !Array.isArray(args["settings"])
3042 ? (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";
2929 import { absorb, featureSettings, getAbsorbed } from "../src/core/features.ts";
3030 import type { Repo } from "../src/core/repos.ts";
3131 import { createToken } from "../src/core/tokens.ts";
32 import { AppsRuntime } from "../src/apps/runtime.ts";
3233 import { createUser, type User } from "../src/core/users.ts";
3334 import { eventVerb } from "../src/design/activity.ts";
3435 import { provenanceRows } from "../src/web/pages/features.ts";
@@ -49,6 +50,7 @@ const FEATURE = {
4950 claudeCode: { type: "choice", choices: ["stable", "latest"], default: "latest", label: "Claude Code follows" },
5051 scout: { type: "bool", default: true, label: "Search the web for ideas" },
5152 },
53 tools: { scan: { description: "Run a scan now" }, latest: { description: "The last findings", writes: false } },
5254 };
5355 const FORGE_JSON = JSON.stringify({ app: { run: "bun run src/server.ts" }, feature: FEATURE });
5456
@@ -582,6 +584,17 @@ describe("absorbing an app", () => {
582584 expect(stranger.error).toBeDefined();
583585 });
584586
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
585598 test("the declaration is cached by tip: the same tip is one read, a moved tip reads again", async () => {
586599 const first = await declaredFeature(app, repo);
587600 expect(await declaredFeature(app, repo)).toBe(first); // the very same parsed result
@@ -636,6 +649,70 @@ describe("absorbing an app", () => {
636649 expect(long.endsWith("word…")).toBe(true);
637650 });
638651
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
639716 test("deleting the app takes its feature row and settings with it", () => {
640717 const rows = (table: string) =>
641718 app.db.query(`SELECT 1 FROM ${table} WHERE app_id = ?1`).all(appId).length;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs