feat(cli): serve a built MCP App standalone — agent-bundle serve-app and serveApp in agent-bundle/api (#514) - #537
Conversation
🦋 Changeset detectedLatest commit: 26f1d21 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
commit: |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e3f9e6f676
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const contentSecurityPolicy = [ | ||
| "default-src 'none'", | ||
| "base-uri 'none'", |
There was a problem hiding this comment.
Block framing of the consent-bearing host page
When serve-app runs on a known or fixed --port, another origin can iframe this page and clickjack its Allow/Deny consent controls: frame-ancestors does not inherit from default-src, and the response has no equivalent X-Frame-Options. Requests initiated by the framed host document still have its embedded token and same-origin context, so the existing authorization checks do not prevent this UI-redress path; add frame-ancestors 'none' to the CSP.
Useful? React with 👍 / 👎.
| signal.addEventListener('abort', () => { void served.close(); }, { once: true }); | ||
| await served.closed; | ||
| return { url: served.url }; |
There was a problem hiding this comment.
Close the served host after
closed settles
If the packed MCP server exits on its own rather than through the abort signal, served.closed resolves and this example returns without calling served.close(). Because closed only tracks the MCP session, the host HTTP server, sandbox proxy, and any throwaway artifact remain alive and can keep the routed CLI process from exiting; the real serve-app CLI explicitly calls served.close() in this branch. Wrap this lifetime in try/finally or close immediately after the await.
AGENTS.md reference: AGENTS.md:L67-L70
Useful? React with 👍 / 👎.
2441651 to
54aa641
Compare
…pp and serveApp in agent-bundle/api (#514)
…he served App in the docs example (Codex review)
…so emitted root d.ts stays consumer-resolvable
54aa641 to
6c14c18
Compare
…s agent-bundle serve-app (#558) (#560) * docs(mcp): serveApp is a host-process API; a routed CLI command spawns agent-bundle serve-app (#558) The #537 docs showed a plugin route doing await import('agent-bundle/api'). That does not build: routed CLI bins are self-contained, so the bundler inlines the compiler and fails on its runtime-relative module references, and the external or non-literal escapes are AB6005. State the real audience of serveApp (CLI, Workbench, tests, a plugin's own scripts), name the constraint, and show the child-process spawn pattern cargo-hauler shipped, with #558 linked. * docs(mcp): parse the serve-app URL from whole stdout lines in the dashboard sample A child stdout write can arrive split across data events; buffer to newlines before matching, as cargo-hauler's route does. Addresses the Codex P2 thread on #560.
Fixes #514.
What
A built MCP App can now be served standalone in a plain browser tab, outside any MCP host and without the Workbench, bound to the plugin's own packed MCP server:
agent-bundle serve-app <server>/<app> [--artifact] [--target] [--tool] [--input|--input-file] [--port] [--profile] [--allow <capability>]... [--open] [--env-file]... [--no-env] [--plugin-root]. PrintsMCP App <app> at http://127.0.0.1:<port>/ (tool <tool>; Ctrl-C stops the server)through the Effect Terminal path (feat(cli): route first-party CLI terminal I/O through Effect Terminal/Stdio; spell routed-CLI input errors in CLI terms (#465) #505) and runs in the foreground until SIGINT/SIGTERM, or until the bound server exits on its own (oneAB5000diagnostic, exit code 1).serveApp(options)inagent-bundle/apireturning{ url, close, closed, server, tool, resourceUri, sandboxOrigin }, so a plugin's own routed CLI can offer ahauler dashboard-style command.Design
This is the Workbench's MCP App preview stack without the Workbench. Nothing new is built and no second bridge exists:
McpAppBindingService→McpAppPreviewService→McpAppRouteshost the App over the same authenticated/api/mcp/...routes the Workbench relay speaks;createMcpAppSandboxProxyisolates the App document on its own loopback origin;McpAppBridgeenforces the MCP Apps protocol, resource policy, and consent exactly as in the Workbench. The host callbacks (onDisplayMode,onDownload,onOpenLink) and host identity moved intosrc/dev/mcp-apps/mcp-app-preview-host.tsand are shared byworkbench-server.tsandserve-app.mcp runuses (resolveMcpLaunchEnvironment, factored out ofrunMcpForegroundso artifact resolution,.envlayering, and the plugin-data root are byte-identical), and (b) a small host document whose inline relay mirrorspackages/workbench/src/mcp/mcp-app-frame.tsxover those routes (proxy-ready → resource-ready with the server-issued policy; every App frame throughPOST .../messages; consent challenges surfaced with Allow/Deny; document-policy revisions remount the frame; force-close on unload).Effect.acquireReleased into one scope owned bymakeScopedEffectRuntime(theevents/ipc.tsprecedent);close()finalizes once, newest first: routes → preview bindings → sandbox proxy → HTTP server → MCP session. A throwaway artifact (no--artifact) is a scopedmkdtemp+rmwhose ownership transfers to the served App, perdocs/effect-conventions.md.127.0.0.1only; one document at/, no directory listing, no static artifact hosting (the App HTML comes from the server'sresources/read, as in the Workbench);/api/mcp/...requires a per-launch token embedded only in the served document plus same-origin and loopbackHostchecks (AB8003/AB8004); the bridge exposes only the selected server. The host page carries its own CSP (frame-src <sandbox origin>,connect-src 'self').--allow <capability>/autoApprovelets the host page approve named capabilities on the operator's behalf as challenges arrive (a polling dashboard needscall-tool); everything else waits for a decision in the page.serveAppbelongs to the plugin's dev-time / CLI process — imported lazily from the route that needs it — never to the MCP server shell, so emitted artifacts stay free of the host runtime (documented in the MCP Apps guide anddocs/entry-conventions.md).Files
packages/agent-bundle/src/serve-app/serve-mcp-app.ts— the host (session, selection, routes, scope).packages/agent-bundle/src/serve-app/serve-app-page.ts— the host document + inline relay.packages/agent-bundle/src/dev/mcp-apps/mcp-app-preview-host.ts— shared host callbacks /openInBrowser(fromworkbench-server.ts).packages/agent-bundle/src/services/mcp-run.ts—resolveMcpLaunchEnvironmentextracted;runMcpForegroundunchanged in behavior.packages/agent-bundle/src/api.ts—serveApp,ServeAppOptions,ServedApp.packages/agent-bundle/src/cli.ts—serve-appcommand (lazyimport('./api.ts')like every other action);closeForegroundOnSignalgains anuntilso the server exiting releases the signal listeners.Tests
tests/serve-app.test.ts(integration): buildsexamples/mcp-appinto a private copy, runsserveApp, fetches the host page, checks the 404/405/403 surface (missing token →AB8004, cross-origin →AB8003, foreignHost→ 403), then drives the Workbench routes by hand: create binding → sandbox frame on the second origin →ui/initialize→initialized(tool-input + tool-result delivered) →tools/callheld for consent → approve →structuredContent.status === 'degraded'; closes and verifies host, sandbox proxy, and server are gone andclose()is idempotent. Plus selector/tool error messages.tests/cli.test.ts: argv →serveAppoption mapping and URL display; termination signal closes once and releases listeners; server exit → oneAB5000diagnostic + close; argv errors (missing app, non-object--input,--no-env+--env-file, bad--profile, bad--allow, bad--port) before anything launches.payments-apidegraded) through the sandbox; the App'sRead readiness policy/Refresh statusbuttons round-trip through the bridge (the example server deliberately serves neither, so both show their fallback text); Ctrl-C exits, closes the port, and removes the throwaway artifact.pnpm typecheck✓ ·pnpm lint✓ ·pnpm test:unit3245/3245 ✓ ·pnpm docs:site:build✓ (parity + dead-link/anchor).Docs
en + zh:
reference/cli.mdx(serve-appsection, command table, artifact option group),guide/authoring/mcp.mdx(“Serving an App standalone” with thehauler dashboardroute shape),guide/development/workbench.mdx,reference/api.mdx,reference/security.mdx;docs/entry-conventions.md(agent-bundle serve-app),docs/framework-mode.md. Changeset.changeset/514-serve-app.md(patch).Review status
Codex reviewed
e3f9e6fand opened two threads; both are addressed in the follow-up commit (no PR comments are posted from this side — notes live here):P1 — frame the consent-bearing host page (
serve-mcp-app.ts): fixed. The host document's CSP now carriesframe-ancestors 'none';tests/serve-app.test.tsasserts the header.P2 — docs example returns without
close()when the server exits on its own (guide/authoring/mcp.mdx, en + zh): fixed. Thehauler dashboardroute example now awaitsclosedinsidetry/finallyand callsserved.close(), and the prose states thatclosedtracks only the server connection.Last-reviewed head SHA:
e3f9e6f676e60fbf3d0c55a14f9ee653f025e056Unreviewed head SHAs:
59bcc6f7e64dcd01b1b62cf6b1977fe9738527ed(frame-ancestors+ docs fix) and2441651091de33f26ce187527265cfc0542382fa(publicserveApptypes moved tosrc/serve-app/types.tssodist/index.d.ts/dist/api.d.tsnever referenceeffect; fixes thepublic-api.test.tsconsumer-declaration check that failed on CI)Rebased onto
main(feat(test): mountTestState/withTestState — one mounted state across several renderRoute calls #525, fix(skills): serve agent-bundle/meta to rendered skills and compile their JSX without the consumer react runtime #527, fix(host-contracts): guard Claude's .claude.json by its user-scope mcpServers only in the native smoke #529, docs(evaluations): show a typed outcome grader (EvalGraderFunction / EvalGraderContext / EvalScriptOutcome) #535) without conflicts; final head54aa641df562001e640ab4c9711af34515e42672, CI green, merged on green per the reviewer-fallback rule (no@codex reviewre-request is posted from this side).