MCP servers and MCP Apps
An MCP server is the executable half of a plugin. agent-bundle offers two ways to author one: generated route modules, where a file path is a tool's identity, and handwritten stdio entries, where you construct the server yourself and the framework owns only its process lifecycle.
Inferred tool definitions
defineTool is the canonical tool authoring API. Use it from agent-bundle/routes to define metadata, schemas, and one handler together.
The file determines the route identity. Callers supply schema input; the handler receives parsed
schema output, including defaults and transformations. The existing renderer validates
Agent.Result values against resultSchema across MCP, CLI, and browser projections.
Metadata remains statically extractable: import the helper directly, pass an object literal,
and keep schemas at their explicit schema properties. Inspection never imports the handler.
Ordinary imported handler functions are supported; the compiler does not inspect their bodies.
The default export must be the direct helper call with an inline object literal. Aliasing a
constructed definition, passing computed options, or wrapping the call is rejected with
AB4810; the compiler does not evaluate composition. Named import aliases are supported.
The tool routes under examples/*/src/mcp/**/tools/ and the mcp-server scaffold use this
shape directly. The examples keep named schema exports beside the helper so colocated CLI
projections and route tests can import their types.
Client-specific tool inventory
Set a tool's static excludeClients to negotiated MCP client-name prefixes where the tool
should be unavailable, for example excludeClients: ['codex'] for a tool that messages
Codex. Matching is case-insensitive and happens after MCP initialization, separately for
each server session. An excluded tool disappears from tools/list and rejects direct
tools/call requests. Tools without this option and clients with unmatched names retain
the normal inventory. Prefixes must be non-empty strings of at most 128 characters.
Use the client's actual identity. A client identifying itself as Cursor cannot be
distinguished from another Cursor client; do not assume it is Grok Bot. Client names are
self-reported, so this feature selects the interface and does not replace authorization.
CLI and browser projections are unaffected.
Generated route servers
Put one module per route under src/mcp/<server>/:
The path supplies identity: src/mcp/curator/tools/status.tsx is the status tool of the
curator server. No declaration is needed for the server to exist.
The compiler statically reads the defineTool options, imports schemas and implementations only into generated
entries, installs runAgentRequest, and derives the real MCP server from the route graph. Each
call renders through a warm internal Flight dispatcher and lowers the final Agent Document to
legal MCP output. Flight is an implementation transport inside the generated runtime, never a
public host wire protocol, and raw Flight bytes never cross the MCP wire.
The lowered value is an McpCallToolResult (exported from @agent-bundle/runtime, with
McpContentBlock for its content blocks): the MCP SDK's content blocks, with _meta and
structuredContent as finite JSON objects. It is assignable to the CallToolResult of both
@modelcontextprotocol/server 2.x and @modelcontextprotocol/sdk 1.x, so a handler typed
against either SDK accepts it as is; attachMcpStructuredContent returns whatever result type
it was given.
The schemas reach the host as JSON Schema: tools/list advertises inputSchema and, for a
resultSchema that describes an object, outputSchema in the 2020-12 dialect, projected so that
2020-12 validators and non-strict draft-07 validators both accept every valid value. A zod tuple
(z.tuple([...])) is emitted as prefixItems plus items set to the union of the positional
schemas, with minItems (and, for a closed tuple, maxItems) bounding the length, never
items: false, so a host that checks structuredContent with draft-07 keyword semantics
(Cursor, which otherwise rejects a valid tuple result with
MCP error -32602 … boolean schema is false) accepts everything a 2020-12 validator accepts.
inputSchema goes through the same projection, so a host that checks arguments the same way is
covered too. A .rest() tuple is the one place 2020-12 precision is loosened: its rest positions
may also match a prefix schema. Everything else zod emits passes through untouched: keywords
both dialects share ($ref, propertyNames, const, numeric exclusiveMinimum) validate the
same way under both, $defs is reached through JSON-pointer $refs under either, and a
2020-12-only keyword such as unevaluatedProperties or dependentRequired is ignored by a lax
draft-07 validator rather than misapplied.
Runtime schemas may be imported or built by factories. The optional literal inputJsonSchema
property in defineTool provides execution-free forms and CLI flag metadata; tools/list and
runtime validation use the actual schema. See
input metadata.
A generated tool can also expose an idiomatic CLI command without a second route: a colocated
<tool>.cli.ts projection module is a CLI surface projection of the same operation
(tool:<server>/<tool>), never a route. A route observes kind: 'cli' whenever it runs from
the generated CLI executable, whichever projection mechanism produced the command; the
generated MCP server still passes kind: 'tool'. See
Project one tool as an idiomatic command.
ToolConfig describes the metadata and execution options accepted by defineTool;
ToolRouteProps describes the component that defineTool produces. Both are public types.
Call await agent() only when the route needs context. The handle exposes the invocation plus
the host, session, actor, workspace, and plugin identity axes. Each axis is observed: a
transport publishes an available value and its source when it knows one, or unavailable with
a typed reason when it does not. Bare stdio supplies neither a session id nor HTTP actor
authentication, so those axes stay honestly unavailable rather than being fabricated.
The handle also carries lineage, the one answer to "who is my parent, what is the root
conversation, and are we a subagent?", { conversation, root, parent?, depth, generation?, subagent?, resolution }, resolved by the warm runtime's registry from the host's subagent
start/stop and pre-tool hooks, or unavailable with a typed reason (id-not-resolvable,
no-subagent-events, no-shared-runtime, cloud-agent-no-user-hooks, unsupported-surface,
not-provided). resolution is the trust level of parent/root/depth: native (named on
the payload, a Claude or Codex root, a Codex _meta), registry (the registry matched the
subagent's start to the newest unclaimed spawn call), confirmed (the host afterwards named
that edge itself, Claude's Agent PostToolUse carries the spawn tool_use_id and
tool_response.agentId, the child, for every edge up to the root), transcript (read from
the host-written rollout the payload names, Codex records a subagent's parent thread and
depth there, which its hook payloads omit), or inferred (Cursor binds a child conversation
to the single pending subagentStart). A confirmation also fills in subagent.toolCallId for
siblings claimed blind, places a start no spawn window could, and moves a child the window
filed under the wrong parent. There is deliberately no operator or user identity axis: the
framework never reads or surfaces who the human behind a host session is.
When the registry placed the request, the lineage also carries tree, who else is alive, read
from the same registry and never invented:
Each peer is { conversation, depth, parent?, startedAt, subagent?, resolution }, the
registry's facts about that node, with the trust level of its placement, judged exactly as on
a request that node made itself. Stopped conversations are not listed; a Cursor child whose
conversation has not spoken yet is listed under its subagent_id. The tree is absent when the
registry did not place the request (a standalone hook, or a Codex _meta naming a thread it
never saw start): the axis then still says who you are, not who else is here. Route-unit tests
inject it through the same context.lineage seam as the rest of the axis.
The terminal capability
The handle's last axis is terminal, an Observed<AgentTerminal>: what the process's output
streams are, so a route that paints its own stderr or sizes its own table never probes
process.stdout.isTTY or reads FORCE_COLOR itself. The framework shell computes it once per
invocation with the same rules that select the CLI output mode, so plugin output and framework
output agree. It is information only, never a writer, and it changes nothing about what
Agent.* components render.
Color follows the informal standards in their usual precedence: FORCE_COLOR decides outright
when set (0/false off; empty, 1, or true basic; 2 256; 3 truecolor), CLICOLOR_FORCE
forces color on even for a pipe, NO_COLOR (any non-empty value) and CLICOLOR=0 force it off,
TERM=dumb renders none, and otherwise a terminal renders at the depth COLORTERM/TERM
advertise while a pipe renders none. COLUMNS/LINES override the reported size.
A plain main-exporting script or bin has no request scope; its envelope passes the same value
as the second argument of main (see Package entries).
In tests, the tty knob of invokeCli and runScript shapes a deterministic synthetic value,
and context.terminal injects any other one through the same seam as every identity axis.
Request context providers
A conventional src/providers/<name>.ts module default-exports a factory whose value is
resolved through await (await agent()).provider('<camelCaseName>') on every generated surface. Beyond the
surface-specific invocation and the request signal, the factory receives the request as the
route will read it, host, session, workspace, plugin, and lineage (tree included), the
same Observed values with the same provenance, plus read-only views of the mounted handles:
state (read only) when the project declares src/state.ts, notices (inbox and
published only) when the scope mounts the notice ledger. A provider can therefore derive a view
of shared state, a topology, a peers list, what became of the notices this agent published, but
cannot dispatch a state event or publish a notice; those stay in routes.
Providers load and resolve on first access. Concurrent readers share the request's promise,
including a failure; unused modules are not evaluated. Factories run outside the request's async
context (agent() throws outside-invocation there) and receive the read-only view above. The
source harness uses the same semantics; explicit test initialization providers values override discovery. The public request context exposes provider(key).
Rich Markdown blocks as JSX
Agent.Markdown carries one Markdown string, and hand-concatenating that string, one
- **${label}:** ${value} per row, is where tables, nested lists, and escaping go wrong. The
async MarkdownContent component from @agent-bundle/runtime renders its JSX children to one
GitHub Flavored Markdown string and lowers it into Agent.Markdown, so a route authors the block
as markup:
Headings h1–h6, paragraphs, strong/em/del, code and pre, links and images,
blockquotes, ul/ol (nested, and start on ol), GFM task lists (<input type="checkbox">
inside li), table with align on its cells, dt/dd, br, and hr serialize to their
Markdown forms; unknown elements pass their children through. Sync and async components anywhere
in the children, including ones that call React.use, resolve before the block is emitted.
Text is escaped, so literal *stars*, [x], <tag>, &, and a line-leading -, #,
1., or > survive as text when the document is parsed again. The rendered block carries no
trailing newline: Agent Document projections own the blank-line joining between sibling blocks,
so MarkdownContent sits beside Agent.Text and other nodes exactly like a handwritten
Agent.Markdown.
components maps extra host tag names to serializers, (props, { inline, blocks }) => string,
when a custom element needs its own Markdown, inline() and blocks() render the element's
children as inline text or as blank-line-joined blocks. The renderer is exported too:
renderToMarkdown(children, options?) resolves to the string and
renderToMarkdownStream(children, options?) yields a ReadableStream<Uint8Array> of UTF-8
blocks in document order. Its output is pinned against micromark with the GFM extension, the
parser under the Workbench's react-markdown/remark-gfm, so what a route authors is the
structure the consumer parses.
Streaming and progress
A route streams by rendering React Suspense: the shell goes out first with the fallback in
place, and each resolved boundary replaces it. Agent.Progress is the fallback to use. It is the
framework's streaming progress surface on MCP, in the rendered CLI, and in the Workbench:
On MCP, the projector reads the Agent.Progress node out of each streamed shell and replace
document and sends one notifications/progress, progress from completed, message and
total when present, to a client whose request carried _meta.progressToken. A request without
a token gets no notification. The fallback alone is enough: there is no need for an announce()
helper that repeats the fallback's message through (await agent()).progress.report(), and doing
both does not duplicate the notification. progress.report() remains the way to announce steps
that have no boundary of their own (a loop over files, for example).
Both sources obey the MCP rule that progress increases with every notification: a fallback
re-streamed on a later chunk, or one whose completed an earlier report already reached, is not
re-sent, and a later fallback that advances completed is. Give nested boundaries increasing
completed values when each stage should reach the client. A progress node never becomes a
content block, and one left in the final document is content only.
The same route exposed as a rendered CLI command (routes.mcpCommands) or written as a rendered
src/cli/** command shows the fallback on an interactive terminal too: the in-place progress line
is drawn from the streamed Agent.Progress node exactly as from an explicit report, redrawn only
when the fallback changes, and never printed to piped Markdown, --json, or --ndjson output
(where the node stays a document event). The Workbench renders the node as document content.
Hooks have no progress channel.
The route-unit and mcp-in-memory test levels prove this without a host:
projectTargetCapabilities(await renderRouteEvents('tool:curator/search', { input }), fixture)
returns the notifications the fixture's token produced, and an openInMemoryMcpServer() client
that sets _meta.progressToken on callTool receives them over the real protocol.
Render budget
Every call renders inside one render session bounded by the runtime's default limits, and the
wall-clock one is 60 seconds (DEFAULT_AGENT_RENDER_LIMITS.maxElapsedMs). A route whose
legitimate work outlives that, awaiting a build, long-polling a daemon, declares its own budget
in defineTool's render option instead of clamping its waits and asking the caller to poll:
maxElapsedMs is a positive integer of milliseconds up to the framework ceiling of 24 hours
(MAX_ROUTE_RENDER_ELAPSED_MS, exported from agent-bundle); anything else is AB4835 at build
time. The compiler reads the value statically with the rest of the defineTool options, so the generated MCP
server applies it to that route's render session on every tools/call, resources/read, and
prompts/get; a routes.mcpCommands projection of the tool and a rendered src/cli/** command
carry the same key on the compiled command (config.render on CliRouteConfig; a plain .ts
command has no render session and rejects it). renderRoute and openInMemoryMcpServer apply it
too, layered over the limits a test passes as the dispatcher's base. Omitting render keeps
the default, and the other limits (document size, event count, event rate) are unchanged.
The budget bounds the framework's session only; the host's own tool-call deadline still applies,
so keep progress flowing for the whole wait, every progress.report() and streamed
Agent.Progress fallback is forwarded as notifications/progress while the call runs. Claude
Code's default per-call wall clock is about 28 hours (MCP_TOOL_TIMEOUT, or a per-server
timeout), and its idle timer (30 minutes for stdio servers) is reset by exactly those
notifications; Codex bounds a call at tool_timeout_sec, 60 seconds unless the operator raises
it in config.toml. agent-bundle inspect --routes shows the budget with the rest of each
route's compiled config.
Long-running tools: tasks
The render budget bounds how long a render may run; it does not change that an ordinary
tools/call blocks the client until the render ends. The MCP 2025-11-25 Tasks utility is the
complementary mechanism: a client that asks for task-augmented execution gets a task handle at
once and collects the result later, so its own request deadline stops mattering for the render. A
tool route opts in with defineTool's execution.taskSupport, the tool's execution block as
tools/list advertises it:
taskSupport is 'forbidden' (the wire default when the key is absent: every call is an
ordinary request), 'optional' (a client may ask for a task; one that does not gets the ordinary
result), or 'required' (an ordinary call is refused with JSON-RPC -32601); anything else, an
unknown key under execution, or the key on a resource or prompt route is AB4836. The route
module itself does not change: it renders exactly as for an ordinary call, and signal,
progress.report(), and streamed Agent.Progress fallbacks mean what they always did.
When at least one tool opted in, the generated server declares
capabilities.tasks: { list, cancel, requests: { tools: { call } } } and serves the lifecycle on
a 2025-11-25 session:
An unknown taskId is -32602; a task-augmented call to a tool that declared no support (or
none) is -32601. Progress notifications for a task still flow only when the request carried
_meta.progressToken, now stamped with the related-task key; the task observes progress either
way. A client that never sends params.task sees no change at all, and a server none of whose
tools opted in advertises no tasks capability and processes a task-augmented request as an
ordinary one, as the utility requires of a receiver without the capability. Task records belong
to the session that created them (the spawned stdio process, one client), stay for ttl after
the task settles, at most 256 per server, and every task still working is cancelled when the
session closes.
The render budget applies to the task's render, not to the client's request: a task's
tools/call returns immediately whatever the budget, and the render behind it is still bounded
by the route's config.render (or the 60-second default). Raise the budget only when the render
itself needs longer, not to outlast a host's request deadline, that is what the task is for. The
2026-07-28 protocol revision moves tasks out of the core into the io.modelcontextprotocol/tasks
extension with a different shape (resultType: "task", tasks/update, no tasks/result); the
generated server's SDK release does not serve that extension, so a client on that revision keeps
the ordinary contract and no task capability is advertised to it. Whether a pinned host issues
task-augmented calls at all is recorded per host in the host capability
matrix; at the time of writing none does, so the lifecycle is proven with
the SDK client and Advanced → Protocol in the Workbench, where a tool that advertises task
support offers Run as task and the Tasks panel polls tasks/get, fetches tasks/result, and
cancels.
The mcp-in-memory level proves the whole contract with the real SDK client:
Shared layouts
src/layout.tsx is the composition point around every rendered route, the layout.tsx idea from
page frameworks applied to Agent Documents. It default-exports one component receiving
{ children, route, signal } and renders Agent.Result around children, the route's rendered
element. src/mcp/<server>/layout.tsx nests inside it for one generated server; composition order
is root layout, server layout, route. Generated MCP tools, resources, and prompts, rendered
src/cli/** commands, projected MCP commands, and rendered src/scripts/*.tsx are all wrapped.
Event routes are host protocol responses and browser App routes are browser builds, so neither is.
An Agent.Result without a value is a container: the runtime merges it with the route's own
<Agent.Result value={...}> while decoding, so the route keeps its result value,
structuredContent, and content, and the layout adds only the shared shell, a heading, a trailing
Agent.Context note, document metadata. metadata objects merge key by key with the container
winning; because the MCP projector exposes root metadata as the result's _meta, a layout that
declares metadata changes _meta, and one that does not leaves it untouched. route is the
compile-time identity (id, kind, name, serverId for MCP kinds), signal the request abort
signal, and await agent() works inside a layout exactly as in a route.
The route's element resolves before the layout chain renders, so a throwing route still fails
the whole render (CLI exit 1; on MCP the SDK's default isError result or a JSON-RPC error with
no layout _meta, see What happens when a route throws)
rather than being downgraded to a boundary error beneath the layout's shell; the trade-off is that
a layout cannot stream a Suspense fallback around children. A layout whose default export is not a function, or that exports the
route-only config/inputSchema/resultSchema, is AB4830; .ts and .tsx siblings for one
scope are AB4831; a server layout whose server declares no tool, resource, or prompt routes is
AB4832, while a server pinned to custom, command, or remote skips its layout entirely. The
route-unit and projection test levels compose the same chain, so renderRoute('tool:...') and
invokeMcpTool(...) prove the composed document; a module passed directly to renderRoute()
composes no layout.
What happens when a route throws
Agent.Error is the supported error path. The error is data inside the document, so the layout
shell, _meta, structuredContent, and the [code] message text all survive, and the wire
carries isError: true:
A route that throws instead is not projected by agent-bundle at all. There is no document to project, and each surface's own default applies. The layout shell never wraps it. Per surface:
The projector deliberately does not wrap a root throw into the Agent.Error shape: the layout
shell is a property of a document, and a root throw has none. A route that wants a code, a shell,
or structured content on failure renders Agent.Error; a route that wants a nested failure to
keep the rest of the document places it behind Suspense.
Handwritten stdio entries
A server declared in config with no entry, command, or url picks up the conventional
src/mcp/<server-id>.ts module. Every local entry default-exports a server factory and is
served under the lifecycle shell:
The generated shell provides, in order: console-to-stderr redirection before the consumer
module evaluates, the factory call, raw process.stdout.write restored for protocol frames,
transport construction and connect, SIGINT to exit 130, SIGTERM to exit 143, stdin EOF to exit 0
so the client can respawn, transport-close to exit 0, a five-second bounded shutdown race
against wedged transports, and heartbeat and activity logging on stderr (five-minute interval,
sixty-second activity throttle, labeled with the server name).
That guard matters because stdout carries JSON-RPC framing: one stray console.log from any
imported module would corrupt the protocol stream.
A CommonJS entry assigns the same factory to module.exports, which the bundler exposes as the
module's default export:
Only the whole-object assignment counts. exports.curator = … and module.exports.curator = …
are named exports, and the shell reads neither.
A module that constructs and connects a transport at top level without a default export cannot be
built: source validation reports AB4730 as an error. Declare a server you do not want
compiled with command or url, or as a { prebuilt: ... } entry your own build produced.
The same lifecycle is public API for hand-rolled entries, and so is the operator .env layer the
generated shell imports ahead of the server module, so that even a process.env read at the
module's top level sees the composed environment (see
Installation).
Both imports are inlined into the emitted entry, which stays self-contained;
agent-bundle/launch-env is aliased into every stdio entry. A hand-rolled entry outside this
build, a prebuilt server for instance, applies the layer first thing, anchored
the way the generated shell is (the artifact root is the parent of mcp/), and may pass its own
declared env block as manifestEnv so that a manifest default the host passed through yields to
the file the way it does in the generated shell:
operatorEnvPluginRoot prefers an AGENT_BUNDLE_PLUGIN_ROOT the host set over the directory you
pass, so the entry reads the same files as the pack's hook wrappers and CLI. Without manifestEnv
every variable the host set is kept, manifest defaults included.
Declaring servers in config
Declare a server when you need something the convention cannot express, a different entry path, a target restriction, extra environment, or a command or remote server you do not compile:
Amp skill-scoped MCP
Amp reads MCP from a registered skill, not one plugin-root document. With exactly one Amp skill,
canonical remote servers become { url, headers? } entries and external stdio commands become
{ command, args?, env? } entries in that skill's flat mcp.json map, never under an
mcpServers wrapper. Native mcpServers frontmatter wins over the sibling file, and a
same-named CLI, workspace, or user server wins over the skill.
Compiler-owned local entries (entry, generated routes, or prebuilt payload paths), cwd, and
Agent Bundle path tokens are rejected for Amp. The pinned Amp docs publish no plugin-root token or
skill-server execution cwd, so emitting a relative generated entry would not be relocatable.
Use a globally resolvable command, a remote URL, or native Amp frontmatter instead. Amp connects
skill servers during discovery while hiding their tools until activation; hidden tools do not
prove that no server process started.
The plugin-root environment anchor
Every plugin-root stdio MCP server entry on the four root-document targets carries an
AGENT_BUNDLE_PLUGIN_ROOT environment variable
holding the plugin install root in the target's native spelling: ${CLAUDE_PLUGIN_ROOT} on
Claude Code, ${PLUGIN_ROOT} on portable, ${CURSOR_PLUGIN_ROOT} on Cursor, and ./ on Codex,
resolved against the entry's plugin-root cwd. Codex has no path-token interpolation, so a Codex
stdio server without a plugin-root working directory omits the anchor; source-built (entry:)
servers always have one on every target.
Resolve persistent state and bundled assets against this anchor, not the process working
directory. Claude Code currently launches stdio servers from the host's own working directory
and ignores any stdio cwd field (its placeholder table excludes cwd). The Claude adapter
therefore omits cwd when the working directory is the canonical plugin root, the standard
source-built entry: case, and instead makes the entry path absolute by prefixing the first
argument with ${CLAUDE_PLUGIN_ROOT}/, alongside the environment anchor. That canonical
plugin-root cwd is the one token-bearing value Claude accepts; any other cwd that carries a
path token is rejected, and only a non-token, explicitly authored cwd is passed through verbatim.
A server's own env entries win over the injected value, so declaring
env: { AGENT_BUNDLE_PLUGIN_ROOT: ... } replaces the anchor. The variable name is exported so
consumer code never has to hardcode it:
Inside a route there is no need to read the variable at all. Every generated shell, the MCP
entry and its Flight worker, the routed CLI executable and its render worker, the hook wrappers,
resolves its code and framework state roots once at startup with resolvePluginRoot from
@agent-bundle/runtime and publishes them on every request it opens as
(await agent()).plugin:
source: 'native' means the host supplied an expanded AGENT_BUNDLE_PLUGIN_ROOT; 'derived'
means the shell fell back, to the artifact root (the parent of mcp/, bin/, or hooks/) inside
a built artifact, or to $PWD/.agent-bundle for the npm package bin. A value still carrying an
unexpanded ${…} token (a host that passed its manifest through literally) is treated as unset,
reported once on stderr, and never joined into a path. Artifact-hosted shells default
plugin.value.stateRoot to ~/.agent-bundle/state/<plugin>-<digest> (or
$XDG_STATE_HOME/agent-bundle/<plugin>-<digest>), derived from the code root's safe basename and
the first 16 hexadecimal characters of the SHA-256 digest of its real path.
AGENT_BUNDLE_STATE_ROOT overrides that location; the npm package bin keeps
$PWD/.agent-bundle/state. Conventional providers receive the same observed value as plugin in
their factory context, beside invocation and signal, so a provider that keeps files beside the
framework's state derives nothing itself. The route-unit and mcp-in-memory harnesses resolve it
the same way (falling back to <project root>/.agent-bundle) and accept context.plugin as an
override like every other axis; outside a generated scope the axis is
unavailable('not-provided').
MCP Apps
An MCP App is a browser surface compiled to self-contained HTML and registered as a resource on
the generated server. The conventional location is src/mcp/<server>/apps/*.{ts,tsx}, where a
static config.resourceUri is required. Two App routes of one server that declare the same
config.resourceUri fail with AB4829 naming both files; the same URI on App routes of different
servers is fine, because each generated server registers only its own Apps. Prefix the file with
_ to opt out.
Declaring an App in config gives it an explicit HTML template and target restriction:
Compiled Apps are available to server code through agent-bundle/mcp-apps, which the compiler
replaces for local MCP servers. Importing it outside an agent-bundle compilation throws rather
than returning an empty registry, an unsupported boundary made explicit instead of failing
silently at run time.
An App declared on a prebuilt server stays a development surface: the Workbench compiles it live, and the build assumes the payload already serves the resource.
The compiled document
Every App compiles to exactly one self-contained HTML file, mcp-apps/<name>.html in each
selected target, with its scripts and styles inlined. An App without a template gets the
framework shell: <html lang="en">, a <title> equal to the App name, the charset and viewport
<meta> tags, and the mount point <div id="root"></div>. The entry renders into that node:
A template replaces that shell entirely, so the template owns the mount id, the title, and the
lang attribute. The framework only fills in lang="en" on <html> when the template declares
no lang, and a <title> equal to the App name when it has no <title>; a template that sets
them is left alone.
JSX compiles in every App build regardless of the entry's extension: @rsbuild/plugin-react with
the automatic runtime (react/jsx-runtime, fast refresh off) is registered on every App
environment, so the <StatusPanel /> that StatusPanel.tsx renders compiles the same way behind
a .ts entry as behind a .tsx one, never to a React.createElement call that would need a
React import in scope. JSX syntax itself still belongs in .tsx/.jsx files, because the
bundler detects a module's syntax from its extension.
The typed App client
A view talks to its host through agent-bundle/app, a browser-safe leaf of the package that
imports no Zod, Node built-in, Effect, or compiler code. createAppClient() owns the MCP Apps
handshake, request ids, pending requests, timeouts and cancellation, JSON-RPC and result-envelope
decoding, and the parent-window transport, so a view never hand-writes a postMessage frame or a
structuredContent reader:
Route ids and types. A tool is addressed by its route id, tool:<server>/<name>. The client
derives the wire tool name from the final segment, exactly as the generated server does, and any
other shape is a TypeError. tools/call carries the final segment as its standard name and
the canonical route id in framework-private _meta; createMcpAppBridge rejects a route whose
server segment or name does not match the bound server before dispatch. Other MCP App hosts may
ignore that private metadata, so address the tools of the server that registered the App. The
types come from the compiler: when the route
graph has at least one tool route, the generated .agent-bundle/routes.d.ts adds exactly one
augmentation on agent-bundle/app,
whose members are the project's generated MCP tool routes (AppToolRouteId in the same
file), input inferred from each route's inputSchema input type (what a caller sends: a
.default()ed field is optional, a .transform()ed field is spelled as the wire carries it; the
route component receives the parsed output), result from its resultSchema output. Once
that file is in the App's TypeScript program (the templates list ".agent-bundle/routes.d.ts"
in tsconfig.json include; existing projects must include it and run their own TypeScript check), a literal route id is checked against the compiled tools, input is typed from
that route, and the result is that route's structured object. onToolInput delivers the host's
opening payload with the same input type: it is the message before the server parses it, so a
default the schema would fill is not promised to be present. The declaration imports the route modules type-only, so no route
module, schema, or server code enters the App document. Without the augmentation, a handwritten
server, or a program that omits the file, call() still works with unknown input and result,
and a view may declare its own contract map through the same AppRegister seam. The
MCP App example relies on the generated file: its App calls
tool:status/show-status with the input and result the route's schemas declare.
AppRouteId, AppRouteInput<Id>, and AppRouteResult<Id> name the same surface for wrappers.
Calls and results. call() returns the structured result directly; the MCP content,
_meta, and isError envelope is transport data. It rejects with an AppClientError when the
tool result carries isError: true (code: 'rpc', the whole result on error.data), has no
object structuredContent, or is not a well-formed result (invalid-message). Give a tool an App
calls an object-rooted resultSchema: the generated MCP projection emits structuredContent for
object-valued results only, while the contract map types every tool route whatever its root, so a
tool whose resultSchema is a scalar or an array is typed but never resolves, call() on it
rejects with invalid-message every time. request(method, params?) is the lower-level escape
hatch for resources/read and the ui/* methods the host supports, resolving to the JSON-RPC
result as is. Both connect first when connect() has not been awaited.
Timeouts and cancellation. Every request takes { signal, timeoutMs }. signal is an
AbortSignal, the client reads aborted and listens for abort, nothing more, and the
deadline defaults to the client's timeoutMs (15 seconds). A request that times out or is
aborted rejects at once with timeout or aborted and, once the client is connected, sends
notifications/cancelled naming the request id and the reason. The client does not wait to learn
whether the tool stopped; that is the host's decision. The framework's host bridge
(createMcpAppBridge, which the Workbench, agent-bundle serve-app, and the browser-app test
harness run) honors it for a request that App started and that is still in flight: it aborts the
tools/call or resources/read so the abort reaches the bound MCP session's request, denies a
consent challenge still pending for that request so a later approval cannot run it, and sends no
late response. A cancellation for an unknown or finished id is a no-op, and one sent before the
handshake completes is ignored. Other hosts apply their own policy.
Opening notifications. onToolInput, onToolResult, and onToolError observe the tool call
that opened the App (ui/notifications/tool-input and ui/notifications/tool-result). Those
notifications name no tool, so the client learns which tool opened it from the handshake:
hostContext.toolInfo.tool.name in the initialize result. A listener fires only when the tool
name of the route id it registered with, the final segment, is that opening tool's; listeners
registered for other tools stay silent, and when the initialize result names no tool, tool-input
and tool-result reach no listener at all. onToolInput receives the notification's arguments,
onToolResult the decoded structuredContent of a successful result, not the envelope.
onToolError receives the same notification's failures as an AppClientError: a result with
isError: true is rpc with the whole result on data; a malformed envelope, or one without an
object structuredContent, is invalid-message. A failed opening result never reaches
onToolResult. onToolCancelled reports the terminal ui/notifications/tool-cancelled with its
optional reason. Each registration returns its unsubscribe function; listeners run
asynchronously, and an exception a listener throws is dropped.
Errors. Two kinds. Everything that happens on the wire or in the lifecycle rejects with one
class, AppClientError, with a discriminated code: timeout, aborted, disposed,
connection-rebound, invalid-message, rpc, capability-unavailable, or consent-required.
A JSON-RPC error from the host maps by its code, -32601 to capability-unavailable, -32001
to consent-required, anything else to rpc, with rpcCode and data preserved. Misusing the
API is not an AppClientError: createAppClient() throws a TypeError synchronously for a
targetOrigin that is not an exact http:/https: origin, for appInfo or appCapabilities
that are not finite JSON objects, and when there is no window and none is injected, and a
RangeError for a timeoutMs that is not an integer from 1 to 2³¹ − 1 milliseconds; the on*
registrations throw a TypeError for a route id that is not tool:<server>/<name> or a listener
that is not a function; call() rejects with that TypeError for a malformed route id,
request() for an empty method, and any request with a RangeError for an out-of-range
per-request timeoutMs. None of this is an AB diagnostic: App-side failures are browser
errors, not build output.
Lifecycle and transport authentication. Every inbound frame must come from the exact
configured parent (event.source === parent) and pass strict JSON-RPC validation. When
targetOrigin is omitted, connect() sends the framework-owned ui/initialize to that parent
with '*' and accepts only the matching request id with a valid initialize result (protocol
version 2026-01-26, hostInfo, hostCapabilities, hostContext). Every later outgoing frame
also uses '*'; incoming event.origin values are not pinned or allowlisted. The exact parent
WindowProxy identity is the load-bearing authentication control, so a foreign window is ignored
even when it reports the same origin.
A host that can supply a trusted origin passes targetOrigin, an exact http: or https:
origin; '*', "null", custom schemes, and non-origin strings are rejected. The client then uses
that exact origin for every outgoing frame and requires every incoming event.origin to match it.
The Workbench, serve-app, and their separate loopback HTTP sandbox proxy keep validating sources
and origins on the host side before relaying.
connect() is idempotent: a connected client resolves the cached result, a connecting one returns
the in-flight handshake. rebind({ parent?, targetOrigin?, window? }) rejects the previous
generation's pending requests with connection-rebound, a connect() still in flight included,
which never becomes the live connection, keeps the configured targetOrigin unless the call
names one, adopts the new parent, and performs the handshake again. dispose(), also idempotent,
removes the listener, rejects pending requests with disposed, and drops every registration. A
host's ui/resource-teardown request must come from the exact current parent, is acknowledged,
and then disposes the client; with configured targetOrigin, its origin must match too.
Cancellation and teardown replies use the current parent and configured exact origin or '*'
before connection state is cleared. The transport is DOM-shaped rather than bound to the global
window: window and parent are injectable, which is how tests and non-DOM hosts drive the
same client.
The host-side boundary. The client is the App-inside-the-iframe half of the bridge. The other
half, the frame relay, sandbox proxy, host page, /api/mcp/... routes, and consent authority,
belongs to the host. The Workbench MCP page and agent-bundle serve-app run the framework's host
bridge (createMcpAppBridge); an MCP host embedding the App runs its own. All of them speak the
standard MCP Apps wire the client implements, and the standalone production host
#564 proposes is specified against
that same wire; the host, never the App, decides which server a call reaches and which
capabilities need consent. An App reaches no foreground server or MCP backend except through its
relay.
agent-bundle/app is bundle-safe: it is not one of the compiler-carrying entries AB4837
rejects, and the compiler inlines it into the App document from the installed package like any
other dependency.
Reserved specifiers win
Your tsconfig.json paths work inside a view (@lib/*, @/components/*, and so on):
Rsbuild hands them to the bundler as usual, where they resolve ahead of every alias. The
specifiers that escape them are agent-bundle/meta and agent-bundle/app: the compiler rewrites
those exact imports before resolution starts, so a paths mapping cannot replace the generated
identity or the framework's browser runtime. The resolved-config assertion refuses a build where
a tools hatch removed either rewrite.
Compile errors and size
The bundler's own log output is silenced while views compile; the framework reads the Rspack
stats instead and reports each error as one AB4770 diagnostic carrying the failing file, its
line:column, and the bundler's message (sourcePath is the failing file):
agent-bundle build exits 1 and writes those diagnostics to stderr, never the AB5000
catch-all. Warnings that did not fail the build are AB4771 in the same shape; they print as
AB4771 (warning): … lines before Built … and ride --json as diagnostics and
build.diagnostics. The
Diagnostics reference
lists every shape, including the location-less form for a tsconfig.json problem.
A successful build prints one line per App after Built …, sorted by target and App name, with
the emitted size and its gzip size (build.compiledMcpApps[].size.bytes and .gzipBytes in
--json):
The figures are that build's own measurement, not a contract. A view that reaches its host
through agent-bundle/app carries the framework's client and nothing else from the protocol
stack; one that imports @modelcontextprotocol/ext-apps 2.x instead carries zod 4,
@modelcontextprotocol/client, @modelcontextprotocol/core, and ext-apps itself, about
249 kB (65 kB gzip) before the first line of your own code. An ext-apps 1.x view also carries
zod 3, @modelcontextprotocol/sdk, and zod-to-json-schema, about 445 kB (106 kB gzip), and
even that view sits well under 1 MiB. A
production build that emits 1 MiB or more reports the AB4772
advisory naming the five largest modules; any build over 2 MiB reports it too, because 2 MiB is
the bound above which the Workbench and serve-app hosts refuse the resource and the view stops
rendering there. The thresholds are fixed; no config key moves them.
Development vs production builds
agent-bundle dev (the Workbench) compiles views unminified, so the preview's source is
readable in the browser devtools: real identifiers, and one
// CONCATENATED MODULE: ./src/mcp/status/apps/StatusPanel.tsx marker per module. The output is
still exactly one self-contained HTML document per App, about 2.7× the production size (a
617 KiB ext-apps view becomes 1.6 MiB), which is why the 1 MiB advisory does not apply to
development builds while the 2 MiB host bound still does. A view whose readable document would
cross that bound is recompiled with the production profile so the preview keeps rendering it,
and AB4772 says so. agent-bundle build ships the production profile: minified, no source
maps.
Neither profile emits a source map by default: a map that carries the original sources is
another ~7× on top (that same view would reach 4.2 MiB), past what the hosts accept. For a
small view, opt in through the escape hatch,
tools.rsbuild = { output: { sourceMap: { js: 'inline-source-map' } } }, which, like every
tools fragment, reaches every synthesized config. Only inline maps work: an external .map
file beside the HTML fails the build with an error naming the stray file, because the artifact
must stay one self-contained document per App. agent-bundle inspect --bundler prints the
resolved App configuration when a hatch value does not land where you expected.
Exposing an App in the browser
Outside an MCP host, an App is normally reached by selecting its leaf under
Application → MCP → <server> → Apps in the Workbench. An App already declared under
mcp.servers.<id>.apps can also be opened in a plain browser tab from the installed plugin, no framework on the machine, by listing it on the
web config key:
apps[] selects among those declared Apps (<server>/<app>). tool is the opening tool
(default: the only tool whose _meta.ui.resourceUri is the App's; required when several).
input is that opening call's arguments (default {}). allow pre-approves App-initiated
consent capabilities: call-tool, download-file, open-external-link,
request-display-mode. Browser hardware and clipboard permissions always ask in the host page.
Invalid web is AB4341 (error); the message shapes are in the
diagnostics reference.
When web is configured, the composite artifact's agent-bundle.manifest.json gains a web
section and the build emits bin/<plugin>.mjs even without src/cli/** commands. The host page
lives inside that bin. There is no web/ directory in the artifact. Its compilation contributes
the same external-module evidence as every other executable, so AB6005 rejects any external
other than a Node built-in, pnpapi, or an emitted sibling.
From the built artifact or the installed plugin root:
The default App is the only exposed one; several without a selector is a usage error (exit 2)
that lists them. Missing agent-bundle.manifest.json beside bin/ exits 1. The command
launches the plugin's own MCP server from its executables.mcpServers[] launch record
(node <root>/<launch.entry> <args>, artifact arguments resolve under <root>, literals have
their path tokens expanded, with AGENT_BUNDLE_PLUGIN_ROOT=<root> and the declared static env,
path tokens expanded; the plugin-data token resolves outside the artifact, under
~/.agent-bundle/web-data/<plugin>-<digest>/<server>, so a read-only install still launches).
The spawned server's own durable framework state likewise lives outside the artifact under
~/.agent-bundle/state/<plugin>-<digest> (or AGENT_BUNDLE_STATE_ROOT). It calls the opening
tool once and serves the App at http://127.0.0.1:<port>/ on a loopback origin with a
second loopback sandbox origin for the App document (the same host stack and consent behavior as
agent-bundle serve-app and the Workbench), prints
MCP App <server>/<app> at <url> (tool <tool>; Ctrl-C stops the server) (or one JSON line with
--json: { app, server, tool, url, port, resourceUri, sandboxOrigin }), and runs until
Ctrl-C / SIGTERM. web is listed in the bin's --help. An authored src/cli/web.ts is refused
(AB4341) when web is configured. Every flag is in the
CLI reference.
agent-bundle dev serves the same page, relay, routes, sandbox proxy, and consent behavior at
GET /web/<server>/<app> on the foreground server, bound to the current epoch's server session.
Apps not listed in web.apps are 404. The server's launch comes from the projections the
artifact manifest declares, never from the browser presentation profile: an explicit
?target=<projection> is validated against the declared projections that launch the server
(invalid is an error, never a fallback), projections sharing one normalized launch descriptor
(command, arguments, cwd, declared env, runtime binding) open unprompted, and materially
different launches answer 409 naming the choices, a Claude- or Codex-only build opens its App
without a portable projection or mcp.json. Web sessions are cached by epoch, server, and
resolved launch identity; a successful rebuild retires unused sessions of older epochs (pages
still leasing one keep it until their last lease releases), and a failed rebuild retires
nothing. An opening tool annotated
readOnlyHint: true runs on every page load; any other opening tool runs once per session,
tool, App, and input. Concurrent first loads share the same in-flight call, and refreshes rebind
its retained result without gaining authority to repeat the operation. Results are bounded and
may be evicted independently of the execution record; after eviction, after a failed call whose
side effects are unknown, or when the bounded execution ledger is full, the page receives an
error result directing the user to run the tool explicitly from the App instead of retrying it
automatically.
agent-bundle serve-app is unchanged: it is the checkout-time form that builds or points at an
artifact and needs the framework installed. Every option is in the
CLI reference.
The same host is available programmatically as serveApp in agent-bundle/api. It returns
{ url, close, closed }: close() tears down the host and the server, closed settles when the
server connection ends for any reason, always close() when you are done, because closed
settling means the server connection ended, not that the host was torn down. autoApprove grants
the listed consent capabilities on the operator's behalf as the App requests them (call-tool
lets a polling dashboard refresh without a prompt); anything else waits for an Allow/Deny decision
in the host page, as in the Workbench.
serveApp is a host-process API. It belongs to processes the framework does not compile,
the first-party CLI, the Workbench, tests, a plugin's own package.json scripts or a hand-written
.mjs run from the checkout, and it needs agent-bundle resolvable where that process runs.
Never call it from the MCP server shell. A routed CLI command cannot import it either: that
value import is AB4837 (see the diagnostics reference).
Routed commands no longer spawn agent-bundle serve-app; the artifact's own
<plugin> web command is the supported way from an
installed root. AB4837 recovery text names that command.
Server modes
routes.servers.<server> opts a server out of route generation when the directory convention
should not apply, custom for a handwritten server, command for an external process, and
remote for a URL-reached server. The full mode contract, including collision recovery, is in
Entry conventions.
Run and inspect
mcp run executes one built stdio server in the foreground: it resolves the compiled entry
(named with a digest of the server name) from the artifact manifest's executables.mcpServers[]
row, expands path tokens through the host adapter, loads the project-root .env set, and forwards
the child's exit code. --target is optional, omit it and the manifest's projections[] must
name exactly one MCP-capable host; several are an error naming the choices. Without
--artifact, a temporary artifact is built first.
Live host MCP proxy
During development, a host can keep one stdio MCP process connected while agent-bundle dev
rebuilds the generated server behind it. Configure the host's MCP server command as:
The proxy discovers the loopback server through the project's development lock and connects to
the stable Streamable HTTP endpoint at /mcp/host/<serverName>. --target defaults to
portable, and --url overrides discovery. Successful rebuilds keep the stdio connection open,
route new calls to the active epoch, let admitted calls finish against their original epoch, and
forward MCP catalog change notifications. Dev MCP sessions and Workbench invocations share the
project's <project>/.agent-bundle/state root, outside the retired epoch directories. If the
epoch or development server disappears, the proxy fails closed with an MCP error and an AB8024
or AB8025 diagnostic.
The endpoint is intentionally unauthenticated because the development server binds only to loopback and is never exposed beyond the local machine.
Next
- Scripts and assets: the non-protocol executables in an artifact.
- Package entries:
bin,lib, and the routed CLI. - Compiler architecture: how a generated route sits in the application graph, a host projection, and the artifact.