For AI agents: the complete documentation index is available at https://scriptedalchemy.github.io/agent-bundle/llms.txt, the full documentation bundle is available at https://scriptedalchemy.github.io/agent-bundle/llms-full.txt, and this page is available as Markdown at https://scriptedalchemy.github.io/agent-bundle/guide/authoring/mcp.md.
Skip to main content
  • English
  • 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.

    // src/mcp/runtime/tools/status.tsx
    import { defineTool } from 'agent-bundle/routes';
    import { Agent } from '@agent-bundle/runtime';
    import { z } from 'zod';
    
    export default defineTool({
      description: 'Report status.',
      annotations: { readOnlyHint: true },
      inputSchema: z.object({ verbose: z.boolean().default(false) }),
      resultSchema: z.object({ status: z.literal('ready') }),
    }, async (input, context) => (
      <Agent.Result value={{ status: 'ready' }}>
        <Agent.Text>{input.verbose ? 'Runtime is ready.' : 'Ready.'}</Agent.Text>
      </Agent.Result>
    ));

    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>/:

    src/mcp/curator/
    ├── tools/status.tsx
    ├── resources/catalog.tsx
    ├── prompts/triage.tsx
    └── apps/status-panel.tsx

    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:

    const { lineage } = await agent();
    if (lineage.state === 'available' && lineage.value.tree !== undefined) {
      lineage.value.tree.siblings; // every other live conversation under the same root
                                   // (any depth, the root itself included for a subagent),
                                   // oldest first
      lineage.value.tree.children; // live conversations whose parent is this one
      lineage.value.tree.roots;    // other live root conversations
                                   // (Cursor: same workspace_roots)
    }

    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.

    interface AgentTerminal {
      hostSurface: 'cli' | 'mcp' | 'hook' | 'script' | 'workbench';
      stdout: {
        kind: 'tty' | 'pipe' | 'none';
        color: 'none' | 'basic' | '256' | 'truecolor';
        columns?: number;
        rows?: number;
      };
      stderr: {
        kind: 'tty' | 'pipe' | 'none';
        color: 'none' | 'basic' | '256' | 'truecolor';
        columns?: number;
        rows?: number;
      };
      sharesTarget: boolean; // fd 1 and fd 2 name one open file (`2>&1`, one shared terminal)
    }

    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.

    Where the route runshostSurfacestdout / stderr
    Routed CLI executable, plain, rendered, or projected MCP commandcliProbed from the executable's own process. Machine output owns stdout, so stdout says where the document lands and stderr is the channel a route may write to.
    Rendered script (src/scripts/<name>.tsx)scriptProbed, as above.
    Generated MCP servermcpnone on both, stdout is the protocol wire, stderr the host's log. Never probed, never guessed.
    Event route (shared runtime or standalone hook)hooknone on both, stdout is the host's hook envelope.
    Workbench lifecycle replayworkbenchnone on both.
    A custom host that mounts nonenot applicableunavailable (not-provided).

    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.

    // src/providers/agent-topology.ts
    import type { AgentProviderContext } from 'agent-bundle';
    
    export default async function agentTopology({ lineage, state }: AgentProviderContext) {
      const snapshot = state === undefined ? undefined : await state.read();
      return {
        peers: lineage.state === 'available' ? lineage.value.tree?.siblings ?? [] : [],
        revision: snapshot?.revision,
      };
    }

    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:

    import React from 'react';
    import { MarkdownContent } from '@agent-bundle/runtime';
    
    interface MeasuredFile {
      readonly path: string;
      readonly bytes: number;
    }
    
    export const MeasuredFiles = ({ files }: { readonly files: readonly MeasuredFile[] }) => (
      <MarkdownContent>
        <table>
          <thead>
            <tr><th>File</th><th align="right">Bytes</th></tr>
          </thead>
          <tbody>
            {files.map((file) => (
              <tr key={file.path}><td>{file.path}</td><td>{file.bytes}</td></tr>
            ))}
          </tbody>
        </table>
      </MarkdownContent>
    );

    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>, &amp;, 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:

    // src/mcp/curator/tools/search.tsx
    import React, { Suspense } from 'react';
    import { Agent } from '@agent-bundle/runtime';
    import { defineTool } from 'agent-bundle/routes';
    
    export default defineTool({
      inputSchema,
      resultSchema,
    }, async (input, { signal }) => (
        <Suspense
          fallback={
            <Agent.Progress completed={0} message={`Searching for ${input.query}`} />
          }
        >
          <Results input={input} signal={signal} />
        </Suspense>
    ));

    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:

    import { defineTool } from 'agent-bundle/routes';
    
    export default defineTool({
      annotations: { readOnlyHint: true },
      description: 'Wait for a ticket to settle.',
      inputSchema,
      render: { maxElapsedMs: 7_200_000 },
      resultSchema,
    }, handler);

    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:

    import { defineTool } from 'agent-bundle/routes';
    
    export default defineTool({
      annotations: { readOnlyHint: true },
      description: 'Convert the selected audiobook.',
      execution: { taskSupport: 'optional' },
      inputSchema,
      resultSchema,
    }, handler);

    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:

    RequestAnswer
    tools/call with params.task ({ ttl?, pollInterval? }) on an opted-in toolCreateTaskResult at once: task.taskId, status: "working", createdAt, lastUpdatedAt, the honoured ttl (the client's request, capped at 24 hours; five minutes when omitted) and pollInterval (at least 100 ms; 1 s when omitted), plus _meta["io.modelcontextprotocol/model-immediate-response"], a sentence a host may hand its model while the task runs. The render starts under the task.
    tasks/getThe task: working with statusMessage set to the latest progress message and _meta["agent-bundle/progress"] carrying { progress, total?, message? }, the same Agent.Progress projection that feeds notifications/progress; completed; failed with the tool error's text as statusMessage (a result with isError: true is a failed task, as the spec requires); or cancelled. The last progress stays readable on a settled task.
    tasks/resultBlocks until the task settles, then returns exactly what the ordinary call would have: the same content, structuredContent, and layout _meta, plus _meta["io.modelcontextprotocol/related-task"]; a render that ended in a JSON-RPC error returns that error.
    tasks/cancelTransitions the task to cancelled before answering and aborts the render through the same AbortSignal a cancelled request uses; the interrupted render settles as the SDK's tool error, which tasks/result then returns. Cancelling a settled task is -32602.
    tasks/listEvery task the session still retains, oldest first, paged by an opaque cursor.

    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:

    import { specTypeSchemas } from '@modelcontextprotocol/client';
    import { openInMemoryMcpServer } from 'agent-bundle/test';
    
    await using session = await openInMemoryMcpServer();
    const created = await session.client.request({
      method: 'tools/call',
      params: { arguments: { holdMs: 2000 }, name: 'wait', task: { ttl: 60_000 } },
    }, specTypeSchemas.CreateTaskResult);
    const result = await session.client.request({
      method: 'tasks/result',
      params: { taskId: created.task.taskId },
    }, specTypeSchemas.CallToolResult);

    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.

    // src/layout.tsx — the whole layout a consumer writes
    import { Agent, type AgentLayoutProps } from '@agent-bundle/runtime';
    import React from 'react';
    
    export default function Layout({ children, route }: AgentLayoutProps) {
      return (
        <Agent.Result metadata={{ route: route.id }}>
          {children}
        </Agent.Result>
      );
    }

    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:

    import { defineTool } from 'agent-bundle/routes';
    
    export default defineTool({ inputSchema, resultSchema }, async (input) => {
      const hit = await catalog.find(input.id);
      if (hit === undefined) {
        return (
          <Agent.Result value={{ found: false }}>
            <Agent.Error code="E_NOT_FOUND">{`No entry is named ${input.id}.`}</Agent.Error>
          </Agent.Result>
        );
      }
      // …
    });

    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:

    SurfaceThe route's default export throwsA nested Suspense boundary rejects
    MCP tools/callThe MCP SDK's default tool error: { content: [{ type: 'text', text: <message> }], isError: true }, no _meta, no structuredContent, no [code] prefix. The session stays usable.A represented error: the reconciler folds the rejected boundary into the document as an error node with code boundary, so the result is what <Agent.Error code="boundary"> would produce, layout _meta kept, structuredContent from the route's value, isError: true, and a [boundary] <message> text block beside the content that had already rendered.
    MCP prompts/get, resources/readA JSON-RPC error response carrying the message; the client call rejects. These surfaces have no isError channel.The generated server returns the route's resultSchema-parsed value from the same represented document.
    Rendered CLI command / rendered scriptOne [render-failed] <message> line on stderr; under --json, one canonical {"error":{"code":"render-failed","message":"..."}} line on stderr. Exit 1; nothing on stdout; no React Flight stack.[boundary] message on stderr as the event happens; then the document prints as usual, Markdown with **[boundary]** message, or under --json the route's value alone (the boundary message is not in the JSON; --ndjson carries the error event). Exit 1, as for any non-success document.
    Plain CLI command / plain scriptRouted command: message on stderr, exit 1 (usage and input errors exit 2). Plain script: Node's top-level failure path, stack on stderr, exit 1.n/a
    Event route (hook)The generated wrapper writes to stderr, nothing to stdout, and exits 1. A runtime: 'standalone' route or a config-declared handler writes the thrown message; a route in the shared runtime writes Event route rendering failed., the runtime answers with the generic runtime-failed error and the original message stays inside the runtime process. Every supported host documents exit 1 as a non-blocking error, so the pending action proceeds exactly as a pass-through would, a thrown tool/before does not deny. Return outcome: 'deny' to block.The hook projection reads Agent.Context and the result value only; the error node contributes nothing.
    renderRoute / renderRouteEventsAgentTestError('render-failed') naming the route and the cause; no document, no events.Resolves with document.status === 'represented-error', an error node with code boundary, and events shell → error → complete.

    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:

    // src/mcp/curator.ts — the whole stdio entry a consumer writes
    import { McpServer } from '@modelcontextprotocol/server';
    
    export default () => new McpServer({ name: 'curator', version: '1.0.0' });

    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:

    // src/mcp/curator.cjs
    const { McpServer } = require('@modelcontextprotocol/server');
    
    module.exports = () => new McpServer({ name: 'curator', version: '1.0.0' });

    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:

    import { fileURLToPath } from 'node:url';
    import { applyOperatorEnv, operatorEnvPluginRoot } from 'agent-bundle/launch-env';
    import { redirectConsoleToStderr, runStdioServer } from 'agent-bundle/mcp-entry';
    
    applyOperatorEnv({
      manifestEnv: { LIBRARY_URL: 'https://library.example' },
      pluginRoot: operatorEnvPluginRoot(fileURLToPath(new URL('..', import.meta.url))),
    });

    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:

    import { 
    const defineConfig: (config: AgentBundleConfig | ConfigFactory) => AgentBundleConfig | ConfigFactory
    defineConfig
    } from 'agent-bundle/config';
    export default
    function defineConfig(config: AgentBundleConfig | ConfigFactory): AgentBundleConfig | ConfigFactory
    defineConfig
    ({
    AgentBundleConfig.mcp?: AgentBundleMcpConfig | undefined
    mcp
    : {
    AgentBundleMcpConfig.servers: Readonly<Record<string, AgentBundleMcpServer>>
    servers
    : {
    curator: {
        entry: string;
        transport: "stdio";
    }
    curator
    : {
    AgentBundleMcpServer.entry?: string | AgentBundlePrebuiltEntry | undefined
    entry
    : './src/mcp/curator.ts',
    AgentBundleMcpServer.transport?: McpTransport | undefined
    transport
    : 'stdio' },
    remote: {
        transport: "streamable-http";
        url: string;
    }
    remote
    : {
    AgentBundleMcpServer.transport?: McpTransport | undefined
    transport
    : 'streamable-http',
    AgentBundleMcpServer.url?: string | undefined
    url
    : 'https://example.com/mcp' },
    }, },
    AgentBundleConfig.plugin: AgentBundlePluginConfig
    plugin
    : {
    AgentBundlePluginConfig.description?: string | undefined
    description
    : 'Library curation tools.',
    AgentBundlePluginConfig.name: string
    name
    : 'curator' },
    AgentBundleConfig.targets?: string[] | undefined
    targets
    : ['portable', 'codex', 'claude'],
    });
    FieldMeaning
    entryA source module to compile, or a prebuilt marker naming an already-built file inside a declared payload.
    command / args / cwdAn external process to launch instead of a compiled entry.
    url / headersA remote server reached over streamable-http.
    transportstdio or streamable-http.
    envExtra environment for stdio servers.
    targetsRestrict the server to specific targets.
    appsBrowser MCP Apps registered on this server.

    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:

    import { 
    const pluginRootEnvAnchor: "AGENT_BUNDLE_PLUGIN_ROOT"

    Well-known environment variable every adapter injects into emitted stdio MCP server entries, holding the plugin install root in the target's native representation (${CLAUDE_PLUGIN_ROOT}, ${PLUGIN_ROOT}, ${CURSOR_PLUGIN_ROOT}, or Codex's ./ resolved against the entry's plugin-root cwd). Server runtime code should resolve bundled assets against it instead of the process working directory, which not every host anchors to the plugin root. A user-declared env entry with this key always wins over the injected value.

    pluginRootEnvAnchor
    } from 'agent-bundle';
    const
    const readPluginRoot: (env: Record<string, string | undefined>) => string | undefined
    readPluginRoot
    = (
    env: Record<string, string | undefined>
    env
    :
    type Record<K extends keyof any, T> = { [P in K]: T; }

    Construct a type with a set of properties K of type T

    Record
    <string, string | undefined>): string | undefined =>
    env: Record<string, string | undefined>
    env
    [
    const pluginRootEnvAnchor: "AGENT_BUNDLE_PLUGIN_ROOT"

    Well-known environment variable every adapter injects into emitted stdio MCP server entries, holding the plugin install root in the target's native representation (${CLAUDE_PLUGIN_ROOT}, ${PLUGIN_ROOT}, ${CURSOR_PLUGIN_ROOT}, or Codex's ./ resolved against the entry's plugin-root cwd). Server runtime code should resolve bundled assets against it instead of the process working directory, which not every host anchors to the plugin root. A user-declared env entry with this key always wins over the injected value.

    pluginRootEnvAnchor
    ];

    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:

    const { plugin } = await agent();
    if (plugin.state === 'available') {
      plugin.value.root;      // the code root: AGENT_BUNDLE_PLUGIN_ROOT or the shell's fallback
      plugin.value.stateRoot; // the framework state root: AGENT_BUNDLE_STATE_ROOT or the default
      plugin.source;          // the code root is 'native' from the variable or 'derived'
    }

    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:

    import { 
    const defineConfig: (config: AgentBundleConfig | ConfigFactory) => AgentBundleConfig | ConfigFactory
    defineConfig
    } from 'agent-bundle/config';
    export default
    function defineConfig(config: AgentBundleConfig | ConfigFactory): AgentBundleConfig | ConfigFactory
    defineConfig
    ({
    AgentBundleConfig.mcp?: AgentBundleMcpConfig | undefined
    mcp
    : {
    AgentBundleMcpConfig.servers: Readonly<Record<string, AgentBundleMcpServer>>
    servers
    : {
    status: {
        apps: {
            status: {
                entry: string;
                resourceUri: string;
                targets: string[];
                template: string;
            };
        };
    }
    status
    : {
    AgentBundleMcpServer.apps?: Readonly<Record<string, AgentBundleMcpApp>> | undefined
    apps
    : {
    status: {
        entry: string;
        resourceUri: string;
        targets: string[];
        template: string;
    }
    status
    : {
    AgentBundleMcpApp.entry: string
    entry
    : './views/status-panel.ts',
    AgentBundleMcpApp.resourceUri: string
    resourceUri
    : 'ui://mcp-app-example/status.html',
    AgentBundleMcpApp.targets?: readonly string[] | undefined
    targets
    : ['portable'],
    AgentBundleMcpApp.template?: string | undefined
    template
    : './views/status-panel.html',
    }, }, }, }, },
    AgentBundleConfig.plugin: AgentBundlePluginConfig
    plugin
    : {
    AgentBundlePluginConfig.description?: string | undefined
    description
    : 'A service-readiness assistant.',
    AgentBundlePluginConfig.name: string
    name
    : 'mcp-app-example' },
    AgentBundleConfig.targets?: string[] | undefined
    targets
    : ['portable', 'codex', 'claude'],
    });

    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:

    // src/mcp/status/apps/status.ts — a .ts entry; the JSX lives in the .tsx it imports.
    import type { AppRouteConfig } from 'agent-bundle';
    
    import { mountStatusPanel } from './StatusPanel.tsx';
    
    export const config = {
      resourceUri: 'ui://mcp-app-example/status.html',
    } satisfies AppRouteConfig;
    
    const root = document.getElementById('root');
    if (root === null) throw new Error('The App document has no #root mount point.');
    mountStatusPanel(root);

    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:

    // src/mcp/hauler/apps/dashboard.ts
    import { createAppClient } from 'agent-bundle/app';
    
    const client = createAppClient({
      appInfo: { name: 'hauler-dashboard', version: '1' },
      timeoutMs: 15_000,
    });
    
    await client.connect();
    // The route's structured result object, typed from its `resultSchema`.
    const status = await client.call('tool:hauler/hauler_status', { limit: 40 });
    
    const stop = client.onToolResult('tool:hauler/hauler_status', (opening) => { /* typed */ });
    client.onToolInput('tool:hauler/hauler_status', (input) => { /* typed */ });
    client.onToolError('tool:hauler/hauler_status', (error) => { /* the opening call failed */ });
    client.onToolCancelled(({ reason }) => { /* the opening call ended without a result */ });
    
    stop();
    client.dispose();

    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,

    declare module 'agent-bundle/app' {
      interface AppRegister {
        readonly routes: AgentBundleAppRouteContracts;
      }
    }

    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):

    MCP App "status" failed to compile: views/status.ts:1:10: Module build failed
      (from builtin:swc-loader): Syntax Error: Expression expected

    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):

    Built <plugin> to …/<plugin>/artifact
    MCP App <name> (<target>): mcp-apps/<name>.html <size> (<gzip size> gzip)

    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:

    web: {
      apps: [
        'status/status',
        { app: 'status/status', tool: 'show-status', input: {}, allow: ['call-tool'] },
      ],
      open: 'never', // 'browser' | 'never' (default 'never')
    }

    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:

    node <root>/bin/<plugin>.mjs web [<server>/<app>] [--port N] [--open|--no-open] \
      [--tool T] [--input JSON] [--allow <cap>]... [--profile portable|claude|chatgpt] [--json]

    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.

    npx agent-bundle build
    npx agent-bundle serve-app status/status --artifact artifact \
      --input '{"service":"compiler"}' --open

    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

    npx agent-bundle inspect --root . --routes
    npx agent-bundle mcp list --artifact artifact --target claude --server curator
    npx agent-bundle mcp invoke --artifact artifact --target claude --server curator \
      --tool status --input '{"verbose":true}'
    npx agent-bundle mcp run --artifact artifact --target claude --server curator

    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:

    {
      "command": "agent-bundle",
      "args": [
        "dev",
        "proxy",
        "--root",
        "/absolute/path/to/plugin",
        "--server",
        "tools"
      ]
    }

    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