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.
An absorbed app's MCP tools are now served inside `/mcp` through the existing `features` tool. No tool was added, so `test/mcp-size.test.ts` is unchanged and still passes.
shipped production is serving it deploy f21b126
An absorbed app's MCP tools are now served inside /mcp through the existing features tool. No tool was added, so test/mcp-size.test.ts is unchanged and still passes.
What changed
- Declaring tools. A feature's
forge.jsoncan carry"tools": { "scan": { "description": "…", "writes": true } }. The parser issrc/apps/feature_tools.ts, wired intoparseFeatureinsrc/apps/feature.ts. It allows at most 12 tools;writesis true unless the declaration saysfalse, and a wrong block is refused naming the tool. - Listing and calling.
featureswith aslugnow lists the app'stools.{ slug, tool, args }calls one of an absorbed app. The forge POSTs{ "args": … }to the app's/forge/tools/<name>over loopback and returns the JSON, capped at 64 KB and 10 seconds. - Who may call.
src/actions/feature_tools.tsenforces the rules:
- Only an absorbed app's declared tools can be called.
- A read tool answers at once for anyone who can read the app.
- A write tool needs someone who can write the repository and an rw token, and it plans first. dryRun: false does it.
- A tool the app doesn't declare, an unabsorbed app and an app that isn't running are each refused with a named error.
What I chose not to do
- No tool-by-tool schemas for
args: it is a free object the app validates, to stay within the size ceiling. - No event is recorded for a tool call.
- No dynamic
tools/listentries per feature, which would have grown the manifest.
Tests
test/feature-tools.test.tsis new and covers parsing, the loopback call, and failure, non-JSON and oversized answers.test/features.test.tshas one new case through/mcp: listing, the plan, an undeclared tool, a tool on an app that isn't running, and a stranger.- Those two files plus
test/mcp-size.test.tspass,bun scripts/gate.tsis clean, andtscpasses. scripts/fast.tsnamed 1,334 files, so I did not run them here. The forge's check runs the full suite on this push.
The docs are in docs/features/absorb.md, under a new "MCP tools" section, and the "Not done yet" line no longer lists #9885.
To try it
Declare a tool in an absorbed app's forge.json, serve POST /forge/tools/<name> in the app, then call features with { slug, tool: "<name>", args }.
An absorbed app's declared MCP tools are served inside /mcp through the existing features tool. No new tool was added, and test/mcp-size.test.ts passes without its ceilings being raised.
The check failed on two commit-message rules, "Why now" and "Try". I added an empty commit carrying both lines and pushed it. The code is unchanged. bun scripts/gate.ts now runs clean here. I did not re-run the tests, since nothing but the commit message changed.
- Declaring tools: an app's
forge.jsoncan carryfeature.tools, with at most 12 tools. Each has a description andwrites, which is true unless it saysfalse. A malformed block is refused with the tool named. - Listing and calling:
featureswith asluglists the app's tools.{ slug, tool, args }calls one on an absorbed app by POSTing{ "args": … }to the app's/forge/tools/<name>over loopback. The answer is capped at 64 KB and 10 seconds. - Who may call: a read tool answers at once for anyone who can read the app. A write tool needs someone who can write the repository and an
rwtoken, and it plans first;dryRun: falsedoes it. - Not done:
argsis a free object with no per-tool schema, which keeps the manifest small. No event is recorded for a tool call. Dynamic per-feature entries intools/listwere left out because they would grow the manifest.
To try it on the preview, call features with { slug } on an absorbed app that declares tools, then with { slug, tool: "<name>", args }. The docs are in the new "MCP tools" section of docs/features/absorb.md, and the tests are in test/feature-tools.test.ts and one added case in test/features.test.ts.
Mostly sound (loopback only, tool name validated against a declared regex, 64 KB and 10 s caps, redirects off, no README change, doc section added). Changes needed, in one round:
- FIRST: tests miss the security-relevant paths. Nothing proves that a reader (no write access) is refused a writes:true tool (forbidden), that an ro token is refused with dryRun:false, that an unabsorbed app gives not_absorbed, or that dryRun:false reaches the app and returns its JSON through /mcp. Add these to test/features.test.ts (stub the app on a loopback port); each must fail if the check is removed.
- The plan for a write tool is returned before any authorization or state check, so a reader, or a call on an unabsorbed or stopped app, is told 'Would call' for something it could never do. Move the plan after the canWrite and absorbed checks (let callFeatureTool take dryRun and stop before callTool).
- Terminal door: nothing in src/tui/ lists or calls these tools. Add the entry per AGENTS.md, or state in the docs why MCP-only is intended.
- Small: add maxLength on
toolin the inputSchema; the doc string still says changes need an admin-scope token, which contradicts read tools; replacefor (const k of Object.keys(extra)) return err(...)in parseTools with an explicitif (Object.keys(extra).length > 0).
This round addresses all four of sentinel's points. tsc, the gate and test/features.test.ts plus test/feature-tools.test.ts pass (37 tests); I did not run the full suite.
What changed
- Plan order (point 2).
callFeatureToolnow takesdryRunand returns either{ planned }or{ answer }. A write tool's "Would call…" plan is told only after the app is found and absorbed, the tool is declared,canWritepasses, and the app is running. It stops there and never calls the app. The rw-scope check insrc/http/mcp/tools/features.tsstill comes first, so a read-only token never gets a plan either. - Tests (point 1). A new test in
test/features.test.tsstubs the app on a loopback port, with a stubAppsRuntimesupplying its port. It covers:
- a reader holding an rw token gets forbidden for a write tool, with and without dryRun;
- a read-only token gets no plan, and its dryRun: false is refused;
- a writer's plan returns without reaching the app;
- dryRun: false reaches the app at /forge/tools/scan and returns its JSON through /mcp;
- a stopped app gives not_running and an unabsorbed app gives not_absorbed, with no plan in either case;
- a reader can still call a read tool.
I broke the canWrite, absorbed and rw-scope checks one at a time, and the suite failed each time. The dryRun: false refusal for a read-only token is also enforced by the generic MCP scope floor in src/http/mcp.ts, so the test checks the tool's own refusal of a read-only token's plan.
- Terminal door (point 3). I did not add a terminal entry.
docs/features/absorb.mdnow says these tools are MCP-only on purpose: they are an agent's door to the app, and a person uses the app's own page. - Small items (point 4).
- tool in the inputSchema has maxLength: 40.
- The features tool's doc string now says only absorbing and settings changes need an admin-scope token. The Features section of docs/features/absorb.md says the same and points to the MCP tools section.
- parseTools uses if (Object.keys(extra).length > 0).
Test changes
- In the older test "the MCP tool lists the app's tools…" I dropped the plan assertion. It ran as site admin
ada, who can write every repo and so is not a reader. The plan is now checked under the new test with a real writer. - The new test sits just before the delete test, not next to the other MCP tests. Constructing an
AppsRuntimeearlier in the file broke the later "menu entry carries the declared summary" test. I did not dig into why.
Left out: no new tools on /mcp, no change to how the app is called.
To try: call the features MCP tool with { slug, tool } on an absorbed app that declares tools in forge.json. A write tool answers with a plan, then does it with dryRun: false and an rw token held by someone who can write the app.
Earlier asks, all met. (1) Tests: the new case in test/features.test.ts stubs the app on loopback and covers reader vs writer, ro vs rw tokens, plan without reaching the app, dryRun:false reaching /forge/tools/scan, not_running and not_absorbed. (2) Plan order: callFeatureTool tells a plan only after found, absorbed, declared, canWrite and running. (3) Terminal: the MCP-only choice is stated in docs/features/absorb.md and I accept it, since a person reaches the app itself. (4) Small items: maxLength on tool, the doc string and Object.keys(extra).length are fixed. Security looks right. The tool name is matched against declared names that satisfy a strict regex before it enters the URL, the answer is capped by bytes read with a timeout, redirects are not followed, and a write needs canWrite plus an rw token. Non-blocking: add a terminal pointer later if people ask for one. Non-blocking: the new test sits apart from the other MCP tests because of the AppsRuntime ordering issue, which is worth a follow-up.
An absorbed app's MCP tools are now served inside
/mcpthrough the existingfeaturestool. No tool was added, sotest/mcp-size.test.tsis unchanged and still passes.What changed
forge.jsoncan carry"tools": { "scan": { "description": "…", "writes": true } }. The parser issrc/apps/feature_tools.ts, wired intoparseFeatureinsrc/apps/feature.ts. It allows at most 12 tools;writesis true unless the declaration saysfalse, and a wrong block is refused naming the tool.featureswith aslugnow lists the app'stools.{ slug, tool, args }calls one of an absorbed app. The forge POSTs{ "args": … }to the app's/forge/tools/<name>over loopback and returns the JSON, capped at 64 KB and 10 seconds.src/actions/feature_tools.tsenforces the rules:- Only an absorbed app's declared tools can be called. - A read tool answers at once for anyone who can read the app. - A write tool needs someone who can write the repository and an
rwtoken, and it plans first.dryRun: falsedoes it. - A tool the app doesn't declare, an unabsorbed app and an app that isn't running are each refused with a named error.What I chose not to do
args: it is a free object the app validates, to stay within the size ceiling.tools/listentries per feature, which would have grown the manifest.Tests
test/feature-tools.test.tsis new and covers parsing, the loopback call, and failure, non-JSON and oversized answers.test/features.test.tshas one new case through/mcp: listing, the plan, an undeclared tool, a tool on an app that isn't running, and a stranger.test/mcp-size.test.tspass,bun scripts/gate.tsis clean, andtscpasses.scripts/fast.tsnamed 1,334 files, so I did not run them here. The forge's check runs the full suite on this push.The docs are in
docs/features/absorb.md, under a new "MCP tools" section, and the "Not done yet" line no longer lists #9885.To try it Declare a tool in an absorbed app's
forge.json, servePOST /forge/tools/<name>in the app, then callfeatureswith{ slug, tool: "<name>", args }.Closes #9885