Skip to content

feat(cli): serve a built MCP App standalone — agent-bundle serve-app and serveApp in agent-bundle/api (#514) - #537

Merged
ScriptedAlchemy merged 8 commits into
mainfrom
feat/514-serve-app
Sep 4, 2026
Merged

feat(cli): serve a built MCP App standalone — agent-bundle serve-app and serveApp in agent-bundle/api (#514)#537
ScriptedAlchemy merged 8 commits into
mainfrom
feat/514-serve-app

Conversation

@ScriptedAlchemy

@ScriptedAlchemy ScriptedAlchemy commented Sep 4, 2026

Copy link
Copy Markdown
Owner

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:

  • CLI: agent-bundle serve-app <server>/<app> [--artifact] [--target] [--tool] [--input|--input-file] [--port] [--profile] [--allow <capability>]... [--open] [--env-file]... [--no-env] [--plugin-root]. Prints MCP 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 (one AB5000 diagnostic, exit code 1).
  • Runtime helper: serveApp(options) in agent-bundle/api returning { url, close, closed, server, tool, resourceUri, sandboxOrigin }, so a plugin's own routed CLI can offer a hauler 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:

  • McpAppBindingServiceMcpAppPreviewServiceMcpAppRoutes host the App over the same authenticated /api/mcp/... routes the Workbench relay speaks; createMcpAppSandboxProxy isolates the App document on its own loopback origin; McpAppBridge enforces the MCP Apps protocol, resource policy, and consent exactly as in the Workbench. The host callbacks (onDisplayMode, onDownload, onOpenLink) and host identity moved into src/dev/mcp-apps/mcp-app-preview-host.ts and are shared by workbench-server.ts and serve-app.
  • The only host-specific pieces are (a) a session authority that is one stdio connection to the packed server, launched through the launcher mcp run uses (resolveMcpLaunchEnvironment, factored out of runMcpForeground so artifact resolution, .env layering, and the plugin-data root are byte-identical), and (b) a small host document whose inline relay mirrors packages/workbench/src/mcp/mcp-app-frame.tsx over those routes (proxy-ready → resource-ready with the server-issued policy; every App frame through POST .../messages; consent challenges surfaced with Allow/Deny; document-policy revisions remount the frame; force-close on unload).
  • The App's tool is called once before the page is served so the App opens populated (the Workbench binds a tool run the same way).
  • Resources are Effect.acquireReleased into one scope owned by makeScopedEffectRuntime (the events/ipc.ts precedent); close() finalizes once, newest first: routes → preview bindings → sandbox proxy → HTTP server → MCP session. A throwaway artifact (no --artifact) is a scoped mkdtemp + rm whose ownership transfers to the served App, per docs/effect-conventions.md.
  • Security: 127.0.0.1 only; one document at /, no directory listing, no static artifact hosting (the App HTML comes from the server's resources/read, as in the Workbench); /api/mcp/... requires a per-launch token embedded only in the served document plus same-origin and loopback Host checks (AB8003/AB8004); the bridge exposes only the selected server. The host page carries its own CSP (frame-src <sandbox origin>, connect-src 'self').
  • Consent: same authority as the Workbench. --allow <capability> / autoApprove lets the host page approve named capabilities on the operator's behalf as challenges arrive (a polling dashboard needs call-tool); everything else waits for a decision in the page.

serveApp belongs 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 and docs/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 (from workbench-server.ts).
  • packages/agent-bundle/src/services/mcp-run.tsresolveMcpLaunchEnvironment extracted; runMcpForeground unchanged in behavior.
  • packages/agent-bundle/src/api.tsserveApp, ServeAppOptions, ServedApp.
  • packages/agent-bundle/src/cli.tsserve-app command (lazy import('./api.ts') like every other action); closeForegroundOnSignal gains an until so the server exiting releases the signal listeners.

Tests

  • tests/serve-app.test.ts (integration): builds examples/mcp-app into a private copy, runs serveApp, fetches the host page, checks the 404/405/403 surface (missing token → AB8004, cross-origin → AB8003, foreign Host → 403), then drives the Workbench routes by hand: create binding → sandbox frame on the second origin → ui/initializeinitialized (tool-input + tool-result delivered) → tools/call held for consent → approve → structuredContent.status === 'degraded'; closes and verifies host, sandbox proxy, and server are gone and close() is idempotent. Plus selector/tool error messages.
  • tests/cli.test.ts: argv → serveApp option mapping and URL display; termination signal closes once and releases listeners; server exit → one AB5000 diagnostic + close; argv errors (missing app, non-object --input, --no-env + --env-file, bad --profile, bad --allow, bad --port) before anything launches.
  • Browser acceptance (manual, Chrome via DevTools MCP at 1440×900): the example App renders populated (payments-api degraded) through the sandbox; the App's Read readiness policy / Refresh status buttons 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:unit 3245/3245 ✓ · pnpm docs:site:build ✓ (parity + dead-link/anchor).

Docs

en + zh: reference/cli.mdx (serve-app section, command table, artifact option group), guide/authoring/mcp.mdx (“Serving an App standalone” with the hauler dashboard route 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 e3f9e6f and opened two threads; both are addressed in the follow-up commit (no PR comments are posted from this side — notes live here):

@changeset-bot

changeset-bot Bot commented Sep 4, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 26f1d21

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
agent-bundle Patch

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

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 4, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-04T06:37:52.350388Z e3f9e6f PR opened
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

ScriptedAlchemy added a commit that referenced this pull request Sep 4, 2026
@pkg-pr-new

pkg-pr-new Bot commented Sep 4, 2026

Copy link
Copy Markdown
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle@537
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle/create-agent-bundle@537
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle/@agent-bundle/runtime@537

commit: 26f1d21

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +543 to +545
const contentSecurityPolicy = [
"default-src 'none'",
"base-uri 'none'",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment on lines +430 to +432
signal.addEventListener('abort', () => { void served.close(); }, { once: true });
await served.closed;
return { url: served.url };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@ScriptedAlchemy
ScriptedAlchemy merged commit 178237c into main Sep 4, 2026
14 checks passed
@ScriptedAlchemy
ScriptedAlchemy deleted the feat/514-serve-app branch September 4, 2026 11:44
ScriptedAlchemy added a commit that referenced this pull request Sep 4, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Serve a built MCP App standalone in a browser (agent-bundle serve-app / runtime helper) so plugins can offer "open the dashboard" commands

1 participant