For AI agents: the complete documentation index is available at https://scriptedalchemy.github.io/agent-bundle/zh/llms.txt, the full documentation bundle is available at https://scriptedalchemy.github.io/agent-bundle/zh/llms-full.txt, and this page is available as Markdown at https://scriptedalchemy.github.io/agent-bundle/zh/guide/authoring/mcp.md.
跳至主要内容
  • 简体中文
  • MCP 服务器与 MCP App

    MCP 服务器是插件中可执行的那一半。agent-bundle 提供两种编写方式:生成式路由模块,其中文件路径 就是工具的身份;以及手写 stdio 入口,由你自己构造服务器,框架只负责它的进程生命周期。

    推断工具定义

    使用 agent-bundle/routes 的 defineTool 一起声明元数据、schema 和唯一处理器。文件决定路由身份。 调用方提供 schema 输入,处理器接收经过默认值和转换处理的输出。MCP、CLI 与浏览器投影继续使用 同一个渲染器,按 resultSchema 验证 Agent.Result 的值。

    // 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>
    ));

    元数据仍是静态声明:直接导入辅助函数,传入对象字面量,并在明确的 schema 属性中声明输入和结果。 检查过程不会加载处理器,允许使用导入的普通处理函数,也不会分析其函数体。

    默认导出必须是直接的辅助函数调用,第一个参数必须是内联对象字面量。通过变量间接导出构造结果、 传入计算生成的配置或包装调用都会触发 AB4810;编译器不会求值这些组合。允许命名导入别名。 examples/*/src/mcp/**/tools/ 下的工具路由与 mcp-server 脚手架都直接采用这种形态。 示例会在辅助函数旁保留命名 schema 导出,供同目录 CLI 投影与路由测试导入类型。

    按客户端选择工具列表

    在工具的静态配置中设置 excludeClients,填写不应提供该工具的 MCP 客户端名称前缀。 例如,向 Codex 发送消息的工具可以使用 excludeClients: ['codex']。匹配不区分大小写, 在 MCP 初始化后按服务器会话分别进行。排除的工具不会出现在 tools/list 中,直接调用 tools/call 也会被拒绝。未设置此选项的工具以及名称不匹配的客户端保留正常工具列表。 前缀必须是非空字符串,长度最多为 128 个字符。

    请使用客户端实际提供的身份。自称 Cursor 的客户端无法与其他 Cursor 客户端区分, 不能据此认定它是 Grok Bot。客户端名称由客户端自行报告,因此该功能只选择接口, 不能代替授权检查。CLI 和浏览器投影不受影响。

    生成式路由服务器

    在 src/mcp/<server>/ 下每个路由放一个模块:

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

    路径提供身份:src/mcp/curator/tools/status.tsx 就是 curator 服务器的 status 工具。服务器的存在 不需要任何声明。

    编译器静态读取 defineTool 选项,只把 schema 与实现导入生成的入口,装配 runAgentRequest,并从路由图推导出 真实的 MCP 服务器。每次调用都经由一个常驻的内部 Flight 分发器渲染,并把最终的 Agent Document 降级为 合法的 MCP 输出。Flight 是生成运行时内部的实现传输层,绝不是面向宿主的公开线上协议,原始 Flight 字节也永远不会跨越 MCP 线路。

    降级后的值是一个 McpCallToolResult(由 @agent-bundle/runtime 导出,其内容块类型为 McpContentBlock):即 MCP SDK 的内容块,加上作为有限 JSON 对象的 _meta 与 structuredContent。它可以赋值给 @modelcontextprotocol/server 2.x 与 @modelcontextprotocol/sdk 1.x 两者的 CallToolResult,因此按任一 SDK 类型声明的处理函数都能 原样接受它;attachMcpStructuredContent 返回的类型与传入的结果类型一致。

    这些 schema 以 JSON Schema 的形式到达宿主:tools/list 以 2020-12 方言公布 inputSchema,以及(当 resultSchema 描述一个对象时)outputSchema,并经过一次投影,使 2020-12 校验器与非严格模式的 draft-07 校验器都接受每一个合法取值。zod 元组(z.tuple([...]))被发出为 prefixItems,加上取各 位置 schema 并集的 items,并以 minItems(闭合元组还有 maxItems)限定长度,绝不会是 items: false,因此用 draft-07 关键字语义校验 structuredContent 的宿主(Cursor,否则它会以 MCP error -32602 … boolean schema is false 拒绝一个合法的元组结果)能接受 2020-12 校验器接受的 一切。inputSchema 经过同一投影,因此以同样方式校验参数的宿主也一并覆盖。.rest() 元组是唯一放宽 2020-12 精度的地方:其 rest 位置也可以匹配某个前缀 schema。zod 发出的其他一切都原样通过:两种方言 共有的关键字($ref、propertyNames、const、数值型 exclusiveMinimum)在两者下的校验方式相同, $defs 在任一方言下都通过 JSON 指针形式的 $ref 到达,而像 unevaluatedProperties、 dependentRequired 这样仅属于 2020-12 的关键字会被宽松模式的 draft-07 校验器忽略,而不会被错误地 套用。

    运行时 schema 可以导入或由工厂构建。defineTool 中可选的字面量 inputJsonSchema 属性提供无需执行 模块的表单和 CLI flag 元数据;tools/list 和运行时校验仍使用真实 schema。参见 输入元数据。

    ToolConfig 描述 defineTool 接受的元数据与执行选项;ToolRouteProps 描述 defineTool 生成的组件。 两者都是公开类型。

    只有在路由确实需要上下文时才调用 await agent()。该句柄暴露本次调用,以及 host、session、 actor、workspace、plugin 五个身份轴。每个轴都是被观察到的:传输层知道时会发布一个 available 取值及 其来源,不知道时则发布带有类型化原因的 unavailable。裸 stdio 既不提供 session id 也不提供 HTTP actor 认证,因此这两个轴保持诚实的不可用,而不是被伪造出来。

    句柄还带有 lineage,它是"我的父会话是谁、根会话是什么、我们是否是子代理"这一问题的唯一答案, { conversation, root, parent?, depth, generation?, subagent?, resolution },由常驻运行时的注册表根据 宿主的子代理 start/stop 与 pre-tool 钩子解析得出;无法解析时则给出带类型化原因的 unavailable (id-not-resolvable、no-subagent-events、no-shared-runtime、cloud-agent-no-user-hooks、 unsupported-surface、not-provided)。resolution 表示 parent/root/depth 的可信级别: native(载荷本身给出,Claude 或 Codex 的根会话、Codex 的 _meta)、registry(注册表把子代理的 start 匹配到最新一个未被认领的 spawn 调用)、confirmed(宿主随后自己说出了这条边,Claude 的 Agent PostToolUse 同时带有 spawn 的 tool_use_id 与作为子代理的 tool_response.agentId,并且 直到根会话的每一条边都如此)、transcript(从载荷所指向的宿主自写 rollout 文件中读取,Codex 会在 其中记录子代理的父线程与深度,而其钩子载荷并不携带这些字段)、或 inferred(Cursor 把子会话绑定到 唯一一个待定的 subagentStart)。一次确认还会为盲选认领的同胞补上 subagent.toolCallId,安放 spawn 窗口无法安放的 start,并把窗口挂错父节点的子代理挪到正确的父节点下。框架刻意不提供操作者或用户身份轴:它永远不会读取或暴露宿主 会话背后的人是谁。

    当注册表安放了这次请求时,lineage 还会带有 tree,还有谁在线,读自同一个注册表,绝不凭空编造:

    const { lineage } = await agent();
    if (lineage.state === 'available' && lineage.value.tree !== undefined) {
      lineage.value.tree.siblings; // 同一根会话之下所有其他在线会话(任意深度,
                                   // 对子代理而言包含根会话本身),按启动时间从早到晚
      lineage.value.tree.children; // 以本会话为父节点的在线会话
      lineage.value.tree.roots;    // 其他在线的根会话(Cursor:同一 workspace_roots)
    }

    每个对等节点是 { conversation, depth, parent?, startedAt, subagent?, resolution },注册表关于该 节点的事实,以及它自己安放的可信级别,与该节点自己发出请求时得到的判断完全一致。已停止的会话不会 列出;尚未开口的 Cursor 子会话以其 subagent_id 列出。注册表没有安放这次请求时(独立钩子进程,或 Codex _meta 指向一个注册表从未见过其启动的线程),tree 不存在:此时这一轴仍然回答"我是谁",但不 回答"还有谁在这里"。路由单元测试通过与该轴其余部分相同的 context.lineage 接缝注入它。

    终端能力

    句柄的最后一个轴是 terminal,一个 Observed<AgentTerminal>:描述进程的输出流是什么,因此需要自己给 stderr 上色或给表格定宽的路由,再也不必自行探测 process.stdout.isTTY 或读取 FORCE_COLOR。框架外壳 在每次调用时只计算一次,所用规则与选择 CLI 输出模式的规则完全相同,所以插件输出与框架输出总是一致。 它只是信息,从来不是写入器,也不会改变 Agent.* 组件渲染的任何内容。

    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 与 fd 2 指向同一个打开的文件(`2>&1`,或同一个终端)
    }

    颜色遵循非正式标准的惯常优先级:设置了 FORCE_COLOR 时由它直接决定(0/false 关闭;空值、1 或 true 为 basic;2 为 256;3 为 truecolor),CLICOLOR_FORCE 即使对管道也强制开启颜色, NO_COLOR(任何非空值)与 CLICOLOR=0 强制关闭,TERM=dumb 不渲染颜色,其余情况下终端按 COLORTERM/TERM 声明的深度渲染,管道则不渲染。COLUMNS/LINES 会覆盖上报的尺寸。

    路由运行的位置hostSurfacestdout / stderr
    路由式 CLI 可执行文件,普通、渲染式或投影的 MCP 命令cli从可执行文件自身进程探测。机器输出独占 stdout,因此 stdout 说明文档落在哪里,stderr 则是路由可以自行写入的通道。
    渲染式脚本(src/scripts/<name>.tsx)script同上,探测得出。
    生成的 MCP 服务器mcp两者均为 none,stdout 是协议线路,stderr 是宿主的日志。从不探测,也从不猜测。
    事件路由(共享运行时或独立钩子进程)hook两者均为 none,stdout 是宿主的钩子信封。
    Workbench 生命周期回放workbench两者均为 none。
    未挂载该轴的自定义宿主不适用unavailable(not-provided)。

    导出 main 的普通脚本或 bin 没有请求作用域;其封套会把同一个值作为 main 的第二个参数传入(见 包入口)。在测试中,invokeCli 与 runScript 的 tty 开关会塑造 一个确定性的合成值,而 context.terminal 则可以像注入任何身份轴一样注入其他取值。

    请求上下文 provider

    约定式的 src/providers/<name>.ts 模块默认导出一个工厂函数,其返回值在每个生成表面上挂载到 await (await agent()).provider('<camelCaseName>')。除了表面特定的 invocation 和请求 signal 之外,工厂 还会收到路由将要读到的请求本身,host、session、workspace、plugin 与 lineage(含 tree), 即同样的 Observed 值、同样的来源,以及已挂载句柄的只读视图:项目声明了 src/state.ts 时的 state(仅 read),作用域挂载了通知账本时的 notices(仅 inbox 与 published)。因此 provider 可以派生共享状态的一个视图,拓扑、对等节点列表、本代理发布的通知的去向,但不能派发状态事件或发布通知; 这些仍然只属于路由。

    // 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,
      };
    }

    provider 在第一次读取时加载并执行;同一请求内的并发调用共享 Promise,包括失败结果。未使用的模块 不会被执行。工厂在请求的异步上下文之外运行(调用 agent() 会抛出 outside-invocation),并接收 上述只读视图。源代码测试装置使用相同语义;显式测试初始化 providers 值覆盖约定式发现。公共请求上下文通过 provider(key) 访问 provider。

    用 JSX 编写富 Markdown 块

    Agent.Markdown 承载一个 Markdown 字符串,而手工拼接这个字符串,每行一个 - **${label}:** ${value},正是表格、嵌套列表与转义出错的地方。@agent-bundle/runtime 导出的异步 MarkdownContent 组件把它的 JSX 子节点渲染为一个 GitHub Flavored Markdown 字符串,并降低为 Agent.Markdown,因此路由可以直接用标记语言编写这个块:

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

    标题 h1–h6、段落、strong/em/del、code 与 pre、链接与图片、引用块、ul/ol(可嵌套, ol 支持 start)、GFM 任务列表(li 内的 <input type="checkbox">)、单元格带 align 的 table、dt/dd、br 与 hr 都会序列化为对应的 Markdown 形式;未知元素直接透传其子节点。子节点中 任何位置的同步与异步组件,包括调用 React.use 的组件,都会在该块发出前解析完成。文本会被转义,因此 字面上的 *stars*、[x]、<tag>、&amp;,以及行首的 -、#、1. 或 > 在文档被再次解析时仍是 文本。渲染出的块不带末尾换行:Agent Document 投影负责兄弟块之间的空行连接,所以 MarkdownContent 与 Agent.Text 及其他节点并列时,行为与手写的 Agent.Markdown 完全一致。

    当自定义元素需要自己的 Markdown 时,components 把额外的宿主标签名映射到序列化器 (props, { inline, blocks }) => string,inline() 与 blocks() 分别把该元素的子节点渲染为行内文本, 或以空行连接的块。渲染器本身也被导出:renderToMarkdown(children, options?) 解析为字符串, renderToMarkdownStream(children, options?) 则按文档顺序产出一个由 UTF-8 块组成的 ReadableStream<Uint8Array>。其输出以带 GFM 扩展的 micromark,Workbench 的 react-markdown/remark-gfm 底层所用的解析器,为基准固定下来,因此路由编写的结构就是消费方解析出的 结构。

    流式输出与进度

    路由通过渲染 React Suspense 实现流式输出:外壳(shell)先带着回退内容发出,之后每个解析完成的 boundary 替换掉对应的回退。回退内容应使用 Agent.Progress,它是框架在 MCP、渲染式 CLI 与 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>
    ));

    在 MCP 上,投影器会从每个流式传输的 shell 与 replace 文档中读取 Agent.Progress 节点,并向请求携带了 _meta.progressToken 的客户端发送一条 notifications/progress,progress 取自 completed,存在时附带 message 与 total。没有 token 的请求不会收到任何通知。仅有回退内容就足够了:不需要再写一个 announce() 之类的辅助函数,把回退的消息通过 (await agent()).progress.report() 重复一遍;即使两者都做,通知也不会重复。 progress.report() 仍然用于宣告那些没有自己 boundary 的步骤(例如对一批文件的循环)。

    两种来源都遵守 MCP 的规则:每条通知的 progress 必须递增。因此在后续 chunk 中再次流出的同一回退、或其 completed 已被先前报告到达的回退,不会被重发;而推进了 completed 的后续回退会被发送。当嵌套的每一级 boundary 都应到达客户端时,请给它们递增的 completed 值。进度节点永远不会成为 content 块;留在最终 文档中的进度节点只是内容。

    同一条路由以渲染式 CLI 命令形式暴露(routes.mcpCommands)或作为渲染式 src/cli/** 命令编写时,交互式 终端同样会显示这个回退:就地更新的进度行会像来自显式报告一样从流式传输的 Agent.Progress 节点绘制, 仅在回退变化时重绘,并且永远不会打印到管道 Markdown、--json 或 --ndjson 输出中(在那里该节点只是一个 文档事件)。Workbench 把该节点渲染为文档内容。钩子没有进度通道。

    route-unit 与 mcp-in-memory 两个测试层级无需宿主即可证明这一点: projectTargetCapabilities(await renderRouteEvents('tool:curator/search', { input }), fixture) 返回 fixture 的 token 产生的通知;而在 callTool 上设置了 _meta.progressToken 的 openInMemoryMcpServer() 客户端会通过真实协议收到它们。

    渲染预算

    每次调用都在一个受运行时默认上限约束的渲染会话中进行,其中挂钟上限是 60 秒 (DEFAULT_AGENT_RENDER_LIMITS.maxElapsedMs)。一条合理工作时间超过这一上限的路由,等待一次构建、 对守护进程长轮询,应在 defineTool 的 render 选项中声明自己的预算,而不是截短自己的等待再让 调用方反复轮询:

    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 是一个以毫秒计的正整数,上限为框架天花板 24 小时(从 agent-bundle 导出的 MAX_ROUTE_RENDER_ELAPSED_MS);其他任何值都会在构建时报 AB4835。编译器随 defineTool 的其余选项静态读取 该值,因此生成式 MCP 服务器会在该路由的每次 tools/call、resources/read 与 prompts/get 上把它应用到 渲染会话;该工具的 routes.mcpCommands 投影以及渲染式 src/cli/** 命令在编译后的命令上携带同一个键 (CliRouteConfig 上的 config.render;纯 .ts 命令没有渲染会话,会拒绝它)。renderRoute 与 openInMemoryMcpServer 同样应用它,叠加在测试作为分发器基线传入的 limits 之上。省略 render 即保留 默认值,其他上限(文档大小、事件数量、事件速率)保持不变。

    预算只约束框架自身的会话;宿主自己的工具调用期限仍然生效,因此整个等待期间都要持续汇报进度,调用运行 期间,每次 progress.report() 与流式传输的 Agent.Progress 回退都会作为 notifications/progress 转发。 Claude Code 默认的单次调用挂钟约为 28 小时(MCP_TOOL_TIMEOUT,或按服务器配置的 timeout),其空闲 计时器(stdio 服务器为 30 分钟)正是由这些通知重置;Codex 以 tool_timeout_sec 约束一次调用,除非操作者 在 config.toml 中提高,否则为 60 秒。agent-bundle inspect --routes 会随每条路由编译后的 config 一并显示该预算。

    长时运行的工具:任务

    渲染预算约束的是一次渲染最多能跑多久;它并不改变普通 tools/call 会一直阻塞客户端直到渲染结束这一点。 MCP 2025-11-25 的 Tasks 工具是与之互补的机制:请求任务增强执行的客户端会立刻拿到一个任务句柄,稍后再 取结果,于是它自身的请求期限不再影响渲染。工具路由通过 defineTool 的 execution.taskSupport 选择加入,这正是 tools/list 宣告的工具 execution 块:

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

    taskSupport 取 'forbidden'(省略该键时的线上默认值:每次调用都是普通请求)、'optional'(客户端 可以请求任务;不请求的客户端得到普通结果)或 'required'(普通调用会被以 JSON-RPC -32601 拒绝); 其他任何值、execution 下的未知键,或把该键放在资源或提示路由上,都会报 AB4836。路由模块本身无需 改动:它与普通调用完全一样地渲染,signal、progress.report() 与流式 Agent.Progress 回退的含义也 一如从前。

    只要至少有一个工具选择加入,生成的服务器就会声明 capabilities.tasks: { list, cancel, requests: { tools: { call } } },并在 2025-11-25 会话上提供整个 生命周期:

    请求应答
    对已加入的工具发出带 params.task({ ttl?, pollInterval? })的 tools/call立即返回 CreateTaskResult:task.taskId、status: "working"、createdAt、lastUpdatedAt、实际采用的 ttl(客户端请求的值,上限 24 小时;省略时为五分钟)与 pollInterval(至少 100 毫秒;省略时为 1 秒),以及 _meta["io.modelcontextprotocol/model-immediate-response"],宿主可在任务运行期间交给模型的一句话。渲染在任务之下开始。
    tasks/get任务本身:working 时 statusMessage 为最新的进度消息,_meta["agent-bundle/progress"] 携带 { progress, total?, message? },与馈送 notifications/progress 的同一份 Agent.Progress 投影;completed;failed 时以工具错误文本作为 statusMessage(按规范要求,isError: true 的结果即失败任务);或 cancelled。已结束的任务上仍可读到最后一次进度。
    tasks/result阻塞到任务结束,然后返回与普通调用完全相同的内容:同样的 content、structuredContent 与布局 _meta,另加 _meta["io.modelcontextprotocol/related-task"];以 JSON-RPC 错误结束的渲染返回该错误。
    tasks/cancel在应答前把任务转为 cancelled,并通过与取消请求相同的 AbortSignal 中止渲染;被中断的渲染以 SDK 的工具错误结束,随后 tasks/result 返回的就是它。取消已结束的任务报 -32602。
    tasks/list会话仍保留的所有任务,按创建先后排列,以不透明的 cursor 分页。

    未知 taskId 报 -32602;对未声明支持(或声明为 forbidden)的工具发出任务增强调用报 -32601。任务的 进度通知仍只在请求携带 _meta.progressToken 时发送,并加盖 related-task 键;无论是否携带令牌,任务都会 记录进度。从不发送 params.task 的客户端看不到任何变化;没有任何工具加入的服务器不会宣告 tasks 能力, 并把任务增强请求当作普通请求处理,这正是该工具对不具备能力的接收方的要求。任务记录归创建它的会话(被 拉起的 stdio 进程,一个客户端)所有,在任务结束后保留 ttl 时长,每台服务器最多 256 条,会话关闭时仍在 运行的任务全部被取消。

    渲染预算作用于任务的渲染,而不是客户端的请求:无论预算多大,任务的 tools/call 都会立即返回,其背后的 渲染仍受路由 config.render(或 60 秒默认值)约束。只在渲染本身确实需要更久时才提高预算,不要为了熬过 宿主的请求期限而提高,那正是任务的用途。2026-07-28 协议修订把任务从核心移入 io.modelcontextprotocol/tasks 扩展,形态也不同(resultType: "task"、tasks/update、没有 tasks/result);生成服务器所用的 SDK 版本不提供该扩展,因此该修订上的客户端保持普通契约,也不会被宣告 任务能力。固定版本的宿主是否会发出任务增强调用,按宿主记录在宿主能力矩阵中; 撰写本文时没有任何宿主这样做,因此这一生命周期由 SDK 客户端与 Workbench 的 Advanced → Protocol 证明:宣告了任务支持的工具会提供Run as task,Tasks 面板会轮询 tasks/get、获取 tasks/result 并取消任务。

    mcp-in-memory 层用真实的 SDK 客户端证明整套契约:

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

    共享布局

    src/layout.tsx 是每个渲染式路由外层的组合点,页面框架中 layout.tsx 的思路应用到 Agent Document 上。 它默认导出一个接收 { children, route, signal } 的组件,并在 children(路由渲染出的元素)外层渲染 Agent.Result。src/mcp/<server>/layout.tsx 嵌套在它之内,只作用于一个生成式服务器;组合顺序是根布局、 服务器布局、路由。生成式 MCP 工具、资源与提示、渲染式 src/cli/** 命令、投影的 MCP 命令以及渲染式 src/scripts/*.tsx 都会被包裹。事件路由是宿主协议响应,浏览器 App 路由是浏览器构建,二者都不会被包裹。

    // src/layout.tsx —— 使用者需要编写的全部布局
    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>
      );
    }

    没有 value 的 Agent.Result 是一个容器:运行时在解码时把它与路由自己的 <Agent.Result value={...}> 合并,因此路由保留其结果值、structuredContent 与内容,布局只添加共享的外壳,一个标题、末尾的 Agent.Context 说明、文档元数据。metadata 对象按键合并,容器一方优先;由于 MCP 投影器把根元数据暴露为 结果的 _meta,声明了元数据的布局会改变 _meta,没有声明的则保持原样。route 是编译期身份(id、 kind、name,MCP 类型还有 serverId),signal 是请求中止信号,await agent() 在布局中的用法与在 路由中完全一致。

    路由的元素在布局链渲染之前解析,因此抛错的路由仍然让整次渲染失败(CLI 退出码 1;在 MCP 上是 SDK 默认的 isError 结果或一个 JSON-RPC 错误,且没有布局 _meta,见 路由抛错时会发生什么),而不是被降级为布局外壳之下的 boundary 错误;代价是 布局无法在 children 周围流式输出 Suspense 回退。 默认导出不是函数、或导出了仅属于路由的 config/inputSchema/resultSchema 的布局是 AB4830;同一作用域 下 .ts 与 .tsx 并存是 AB4831;其服务器没有声明任何工具、资源或提示路由的服务器布局是 AB4832,而固定为 custom、command 或 remote 的服务器会完全跳过其布局。route-unit 与 projection 两个测试层级组合同一条 布局链,因此 renderRoute('tool:...') 与 invokeMcpTool(...) 证明的是组合后的文档;直接传给 renderRoute() 的模块不会组合任何布局。

    路由抛错时会发生什么

    Agent.Error 是受支持的错误路径。错误是文档内部的数据,因此布局外壳、_meta、structuredContent 与 [code] message 文本都得以保留,线上结果携带 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>
        );
      }
      // …
    });

    改为抛出异常的路由完全不会被 agent-bundle 投影,没有文档可投影,各个表面自己的默认行为生效。布局外壳 永远不会包裹它。按表面分列:

    表面路由的默认导出抛错嵌套的 Suspense boundary 被拒绝
    MCP tools/callMCP SDK 的默认工具错误:{ content: [{ type: 'text', text: <message> }], isError: true },没有 _meta、没有 structuredContent、没有 [code] 前缀。会话仍然可用。一个已表示的错误:reconciler 把被拒绝的 boundary 折叠进文档,成为 code 为 boundary 的错误节点,因此结果与 <Agent.Error code="boundary"> 所产生的一致,保留布局 _meta,structuredContent 来自路由的值,isError: true,并在已渲染的内容旁多一个 [boundary] <message> 文本块。
    MCP prompts/get、resources/read一个携带该消息的 JSON-RPC 错误响应;客户端调用被拒绝。这些表面没有 isError 通道。生成的服务器从同一份已表示文档中返回路由经 resultSchema 解析后的值。
    渲染式 CLI 命令 / 渲染式脚本stderr 输出一行 [render-failed] <message>;在 --json 下,stderr 改为一行规范化的 {"error":{"code":"render-failed","message":"..."}}。退出码 1;stdout 无输出;不会回显 React Flight 堆栈。事件发生时 [boundary] message 写到 stderr;随后文档照常打印,Markdown 带有 **[boundary]** message,而在 --json 下只输出路由的值(boundary 消息不在 JSON 中;--ndjson 携带 error 事件本身)。退出码 1,与任何非 success 文档相同。
    普通 CLI 命令 / 普通脚本路由式命令:消息写到 stderr,退出码 1(用法与输入错误退出码为 2)。普通脚本:走 Node 的顶层失败路径,堆栈写到 stderr,退出码 1。不适用
    事件路由(钩子)生成的包装器写到 stderr,stdout 无输出,退出码 1。runtime: 'standalone' 的路由或配置声明的处理器写出抛出的消息;共享运行时中的路由写出 Event route rendering failed.,运行时以通用的 runtime-failed 错误应答,原始消息留在运行时进程内部。所有受支持的宿主都把退出码 1 记作非阻塞错误,因此待执行的动作会像 pass-through 一样继续进行,抛错的 tool/before 不会拒绝。要阻止,请返回 outcome: 'deny'。钩子投影只读取 Agent.Context 与结果值;错误节点不贡献任何内容。
    renderRoute / renderRouteEventsAgentTestError('render-failed'),指明路由与原因;没有文档,也没有事件。正常返回:document.status === 'represented-error',一个 code 为 boundary 的 error 节点,事件序列为 shell → error → complete。

    投影器有意不把根级抛错包装成 Agent.Error 的形状:布局外壳是文档的属性,而根级抛错没有文档。希望在失败时 带有 code、外壳或结构化内容的路由应渲染 Agent.Error;希望嵌套失败不影响文档其余部分的路由应把它放到 Suspense 之后。

    手写 stdio 入口

    在配置中声明、但未指定 entry、command 或 url 的服务器,会识别约定的 src/mcp/<server-id>.ts 模块。每一个本地入口都默认导出一个服务器工厂函数,并在生命周期外壳之下运行:

    // 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' });

    生成的外壳按顺序提供:在消费者模块求值之前把 console 重定向到 stderr、调用工厂函数、为协议帧恢复 原始的 process.stdout.write、构造并连接传输层、SIGINT 退出码 130、SIGTERM 退出码 143、stdin EOF 退出码 0(以便客户端重新拉起)、传输层关闭退出码 0、对卡死传输层的五秒有界关停竞态,以及 stderr 上 的心跳与活动日志(五分钟间隔、六十秒活动节流,并以服务器名标注)。

    这层保护之所以重要,是因为 stdout 承载着 JSON-RPC 帧:任何被导入模块中一次走神的 console.log 都会 破坏协议流。

    CommonJS 入口把同一个工厂函数赋给 module.exports,打包器会把它暴露为该模块的默认导出:

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

    只有整体赋值才算数。exports.curator = … 与 module.exports.curator = … 都是具名导出,外壳两者都 不读取。

    在顶层构造并连接传输层、且没有默认导出的模块无法构建:源码校验会把 AB4730 报告为错误。不希望 被编译的服务器请用 command 或 url 声明,或声明为由你自己的构建产出的 { prebuilt: ... } 入口。

    同一套生命周期对手写入口也是公开 API,生成外壳先于服务器模块导入的操作者 .env 层同样如此,因此即使 在模块顶层读取 process.env 也能看到合成后的环境(见安装)。 这两个导入都会被内联进产出的入口,入口保持自包含;agent-bundle/launch-env 会被别名到每一个 stdio 入口。本次构建之外的手写入口(例如预构建服务器)应在第一行自行应用它,并像生成外壳一样 锚定(产物根目录是 mcp/ 的上一级);还可以把自己声明的 env 块作为 manifestEnv 传入,让宿主透传的 清单默认值像在生成外壳中那样让位于文件:

    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 会优先采用宿主设置的 AGENT_BUNDLE_PLUGIN_ROOT,否则回退到你传入的目录, 因此该入口读取的文件与同一个包的钩子包装器和 CLI 完全一致。不传 manifestEnv 时,宿主设置的每个变量 都会被保留,清单默认值也在其中。

    在配置中声明服务器

    当你需要约定无法表达的东西时再声明服务器,不同的入口路径、target 限制、额外环境变量,或者一个你 并不编译的命令式或远程服务器:

    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'],
    });
    字段含义
    entry要编译的源码模块,或一个 prebuilt 标记,指向已声明 payload 中某个已构建好的文件。
    command / args / cwd要启动的外部进程,用以替代编译入口。
    url / headers通过 streamable-http 访问的远程服务器。
    transportstdio 或 streamable-http。
    envstdio 服务器的额外环境变量。
    targets把服务器限制到特定 target。
    apps注册到该服务器上的浏览器 MCP App。

    Amp 的 Skill 范围 MCP

    Amp 从已注册 Skill 而非插件根文档读取 MCP。恰有一个 Amp Skill 时,规范远程服务器会成为 { url, headers? },外部 stdio 命令会成为该 Skill 扁平 mcp.json 映射中的 { command, args?, env? },外层绝不再包 mcpServers。原生 mcpServers frontmatter 优先于同级文件; 同名的 CLI、工作区或用户服务器又优先于 Skill。

    Amp target 会拒绝编译器拥有的本地入口(entry、生成路由或预构建 payload 路径)、cwd 与 Agent Bundle 路径 token。固定版本的 Amp 文档没有发布插件根 token 或 Skill 服务器执行 cwd,因此输出相对的生成入口 并不可搬迁。请改用全局可解析命令、远程 URL 或原生 Amp frontmatter。Amp 在发现 Skill 时连接服务器,但 在激活前隐藏工具;看不见工具并不能证明服务器进程尚未启动。

    插件根目录环境锚点

    四个插件根文档 target 的每个 stdio MCP 服务器入口都带有 AGENT_BUNDLE_PLUGIN_ROOT 环境变量,其值是插件安装根目录在 该 target 上的原生写法:Claude Code 上是 ${CLAUDE_PLUGIN_ROOT}、可移植格式上是 ${PLUGIN_ROOT}、 Cursor 上是 ${CURSOR_PLUGIN_ROOT}、Codex 上是 ./,并相对入口的插件根 cwd 解析。Codex 没有路径 token 插值,因此没有插件根工作目录的 Codex stdio 服务器会省略该锚点;由源码构建(entry:)的服务器 在每个 target 上都一定有它。

    请以这个锚点、而不是进程工作目录来解析持久化状态与随包资源。 Claude Code 目前从宿主自身的工作 目录启动 stdio 服务器,并忽略任何 stdio cwd 字段(其占位符表不包含 cwd)。因此当工作目录是规范 插件根,也就是标准的由源码构建(entry:)场景,Claude 适配器会省略 cwd,改为在第一个参数前 加上 ${CLAUDE_PLUGIN_ROOT}/ 使入口路径变为绝对路径,并同时注入环境锚点。这个规范的插件根 cwd 是 Claude 唯一接受的带 token 取值;其他任何携带路径 token 的 cwd 都会被拒绝,只有显式编写、且不含 token 的 cwd 才会原样透传。

    服务器自身的 env 条目优先于注入值,因此声明 env: { AGENT_BUNDLE_PLUGIN_ROOT: ... } 会替换该锚点。 变量名有对应导出,消费者代码永远不必硬编码它:

    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
    ];

    在路由内部完全不需要读取这个变量。每个生成的外壳,MCP 入口及其 Flight worker、路由式 CLI 可执行文件 及其渲染 worker、hook 包装器,都在启动时用 @agent-bundle/runtime 的 resolvePluginRoot 解析一次 代码根目录与框架状态根目录,并在它打开的每个请求上以 (await agent()).plugin 发布这两个值:

    const { plugin } = await agent();
    if (plugin.state === 'available') {
      plugin.value.root;      // 代码根目录:AGENT_BUNDLE_PLUGIN_ROOT,或外壳的回退值
      plugin.value.stateRoot; // 框架状态根目录:AGENT_BUNDLE_STATE_ROOT,或默认位置
      plugin.source;          // 代码根目录来自变量为 'native',来自回退为 'derived'
    }

    source: 'native' 表示宿主提供了已展开的 AGENT_BUNDLE_PLUGIN_ROOT;'derived' 表示外壳使用了回退, 在构建产物内是产物根目录(mcp/、bin/ 或 hooks/ 的父目录),对 npm 包 bin 则是 $PWD/.agent-bundle。 仍带有未展开 ${…} token 的值(宿主把清单原样透传)会被视为未设置,在 stderr 上报告一次,且绝不会被 拼接进路径。产物宿主外壳把 plugin.value.stateRoot 默认为 ~/.agent-bundle/state/<plugin>-<digest>(或 $XDG_STATE_HOME/agent-bundle/<plugin>-<digest>),由 代码根目录的安全 basename 及其真实路径 SHA-256 摘要的前 16 位十六进制字符推导而来。 AGENT_BUNDLE_STATE_ROOT 覆盖该位置;npm 包 bin 仍使用 $PWD/.agent-bundle/state。约定式 provider 在其工厂上下文中收到同样的被观察值 plugin,与 invocation、signal 并列, 因此把文件放在框架状态旁边的 provider 自己不必再推导任何东西。route-unit 与 mcp-in-memory 测试层级 以同样方式解析它(回退到 <项目根目录>/.agent-bundle),并像其他每个轴一样接受 context.plugin 覆盖; 在生成的作用域之外,该轴为 unavailable('not-provided')。

    MCP App

    MCP App 是一个浏览器表面,编译为自包含 HTML 并作为资源注册到生成的服务器上。约定位置是 src/mcp/<server>/apps/*.{ts,tsx},其中必须提供静态 config.resourceUri。同一服务器的两个 App 路由 声明相同的 config.resourceUri 会以 AB4829 失败并点名两个文件;不同服务器的 App 路由使用相同 URI 则没有问题,因为每个生成的服务器只注册自己的 App。给文件名加 _ 前缀即可退出。

    在配置中声明 App 可以为它指定显式的 HTML 模板与 target 限制:

    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'],
    });

    已编译的 App 通过 agent-bundle/mcp-apps 提供给服务器代码,编译器会为本地 MCP 服务器替换该子路径。 在 agent-bundle 编译之外导入它会抛出错误,而不是返回一个空注册表,把不受支持的边界显式化,而不是在 运行时悄悄失败。

    声明在预构建服务器上的 App 仍属于开发期表面:Workbench 会实时编译它,而构建假定 payload 已经在 提供该资源。

    编译出的文档

    每个 App 都编译为恰好一个自包含的 HTML 文件,在每个被选中的 target 下为 mcp-apps/<name>.html, 脚本与样式全部内联。没有 template 的 App 会得到框架外壳:<html lang="en">、等于 App 名称的 <title>、charset 与 viewport 的 <meta> 标签,以及挂载点 <div id="root"></div>。入口渲染到该 节点上:

    // src/mcp/status/apps/status.ts —— 一个 .ts 入口;JSX 位于它导入的 .tsx 中。
    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);

    template 会完全替换这个外壳,因此模板拥有挂载 id、标题与 lang 属性。框架只在模板未声明 lang 时为 <html> 补上 lang="en",并在模板没有 <title> 时插入一个等于 App 名称的 <title>;自己 设置了这两项的模板不会被改动。

    无论入口的扩展名是什么,每次 App 构建都会编译 JSX:带自动运行时(react/jsx-runtime,关闭 fast refresh)的 @rsbuild/plugin-react 注册在每个 App 环境上,因此 StatusPanel.tsx 渲染的 <StatusPanel /> 在 .ts 入口之后与在 .tsx 入口之后的编译方式完全相同,绝不会变成需要作用域内 有 React 导入的 React.createElement 调用。JSX 语法本身仍然只能写在 .tsx/.jsx 文件里,因为 打包器按扩展名识别模块的语法。

    带类型的 App 客户端

    agent-bundle/app 是只面向浏览器的入口,不导入 Zod、Node built-in、Effect 或编译器代码。 createAppClient() 负责 MCP Apps 握手、request id、pending request、超时与取消、JSON-RPC 与结果 envelope 解码,以及 parent-window transport,因此视图不需要手写 postMessage frame 或 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();
    
    // 直接得到由 resultSchema 定型的结构化结果对象。
    const status = await client.call('tool:hauler/hauler_status', { limit: 40 });
    
    const stop = client.onToolResult('tool:hauler/hauler_status', (opening) => { /* 已定型 */ });
    client.onToolInput('tool:hauler/hauler_status', (input) => { /* 已定型 */ });
    client.onToolError('tool:hauler/hauler_status', (error) => { /* opening tool 失败 */ });
    client.onToolCancelled(({ reason }) => { /* opening call 未产生结果便结束 */ });
    
    stop();
    client.dispose();

    路由 id 与类型。 工具以 tool:<server>/<name> 路由 id 寻址。客户端与生成的服务器一样从最后一段 推导 wire tool name;其他形状属于调用方误用,会抛出 TypeError。tools/call 以最后一段作为标准 name,并在框架私有 _meta 中携带规范路由 id;createMcpAppBridge 会在分派之前拒绝 server 段或 工具名与当前绑定服务器不符的路由。其他 MCP App 宿主可以忽略这项私有 metadata,因此仍应只寻址注册该 App 的服务器工具。当路由图至少包含一条工具路由时, 生成的 .agent-bundle/routes.d.ts 会为 agent-bundle/app 恰好添加一次 augmentation:

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

    成员就是项目生成的 MCP 工具路由:input 来自各路由 inputSchema 的输入类型(调用方发送的 内容:带 .default() 的字段是可选的,经 .transform() 的字段按 wire 上的写法拼写;路由组件收到的则是 解析后的输出),result 来自 resultSchema 的输出。该声明以 type-only 方式导入路由模块,所以不会把路由 模块、schema 或服务器代码带入 App 文档。模板已经在 tsconfig.json 的 include 中列出 ".agent-bundle/routes.d.ts";现有项目必须自行包含该文件,并运行 TypeScript 检查。 onToolInput 以同一输入类型交付宿主的 opening 载荷:那是服务器解析之前的消息,因此 schema 会填入的默认值 并不保证已经存在。没有 augmentation 时,例如手写服务器,call() 仍能以 unknown 输入与结果工作,视图也可以 通过同一个结构化 AppRegister seam 声明自己的本地契约 map。MCP App 示例 依赖的就是生成文件:它的 App 以路由 schema 声明的输入与结果调用 tool:status/show-status。AppRouteId、 AppRouteInput<Id> 与 AppRouteResult<Id> 为 wrapper 命名同一套类型表面。

    调用与结果。 call() 直接返回结构化结果对象;MCP 的 content、_meta 与 isError envelope 只是 transport 数据。工具结果带有 isError: true 时,它会以 code: 'rpc' 拒绝,并把完整结果保存在 error.data;没有 object structuredContent 或结果格式不合法时则以 invalid-message 拒绝。只有根为 对象的 resultSchema 才会产生 structuredContent;契约表仍会定型 scalar 或 array 路由,但对它们的 call() 每次都会以 invalid-message 拒绝。 request(method, params?) 是读取 resources/read 及宿主支持的 ui/* method 的底层逃生舱,按原样返回 JSON-RPC result。两者都会在尚未 connect() 时先连接;每个请求都接受 { signal?: AbortSignal, timeoutMs?: number }。客户端默认超时为 15 秒,也可在 createAppClient({ timeoutMs }) 或单次请求上覆盖。超时或中止分别以 timeout、aborted 拒绝,并向 宿主发送带 request id 与原因的 notifications/cancelled。

    打开时的 notification。 onToolInput(routeId, listener) 与 onToolResult(routeId, listener) 观察打开 App 的工具调用;宿主所带的工具名会按注册路由 id 的最后一段 筛选,因此其他工具的 opening notification 不会送到该 listener。onToolResult 交付解码后的 structuredContent,而不是 envelope。onToolCancelled(listener) 接收终止性的 ui/notifications/tool-cancelled 及可选 reason。onToolError(routeId, listener) 以同样的路由名 筛选 opening tool failure:isError: true 交付 code: 'rpc' 的 AppClientError,无效 envelope 或 缺少 object structuredContent 则交付 invalid-message;错误不会同时送到 onToolResult。每次注册 都返回 unsubscribe 函数。

    完整公开表面。 该入口导出 createAppClient、AppClient、AppRegister、 AppRouteContract、AppRoutes、AppRouteId、AppRouteInput、AppRouteResult、 AppClientError、AppClientErrorCode、AppConnectOptions、CreateAppClientOptions、 AppRequestOptions、AppAbortSignal、AppImplementation、AppInitializeResult、AppMessageEvent、 AppToolInputListener、AppToolResultListener、AppToolErrorListener、 AppToolCancelledListener、可注入的 AppWindow / AppMessageTarget port,以及 APP_PROTOCOL_VERSION。AppClient 提供只读 connected / disposed 状态与 connect、 request、call、onToolInput、onToolResult、onToolError、onToolCancelled、rebind、 dispose。

    错误与调用方误用。 运行时、连接与能力失败统一使用 AppClientError,其 code 恰好是八种: timeout、aborted、disposed、connection-rebound、invalid-message、rpc、 capability-unavailable、consent-required。宿主 JSON-RPC 的 -32601 映射为 capability-unavailable,-32001 映射为 consent-required,其他 code 映射为 rpc,同时保留 rpcCode 与 data。参数种类或契约形状的同步误用,例如错误的 route id、非函数 listener、非精确 targetOrigin、缺少浏览器 window,或非法的 appInfo / appCapabilities,抛出 TypeError; 超时不是 1 到 2,147,483,647 毫秒之间的安全整数则抛出 RangeError。这些都不是 AB 诊断。

    生命周期与 transport 认证。 每一帧入站消息都必须来自完全相同的 configured parent (event.source === parent),并通过严格的 JSON-RPC 校验。省略 targetOrigin 时,connect() 以 '*' 向该 parent 发送框架拥有的 ui/initialize,并且只接受同一 request id 对应的有效 initialize result(协议版本 2026-01-26,以及 hostInfo、hostCapabilities、hostContext)。之后的每一帧 出站消息也使用 '*';入站 event.origin 不会被固定或加入 allowlist。完全相同的 parent WindowProxy 身份是承担安全性的认证控制,因此即使报告相同 origin,其他 window 仍会被忽略。

    能够给出可信 origin 的宿主可以传入 targetOrigin,精确的 http: 或 https: origin;'*'、 "null"、自定义 scheme 与非 origin 字符串都会被拒绝。此时客户端会对每一帧出站消息使用该精确 origin, 并要求每一帧入站消息的 event.origin 与之匹配。Workbench、serve-app 及其独立的 loopback HTTP sandbox proxy 会在 relay 之前继续于宿主侧校验 source 与 origin。

    connect() 幂等:已连接客户端返回缓存结果,连接中的客户端返回进行中的握手。 rebind({ parent?, targetOrigin?, window? }) 以 connection-rebound 拒绝旧 generation 的 pending request,进行中的 connect() 也包括在内,且永远不会成为 live connection,除非调用显式指定新的 targetOrigin,否则保留已配置的值;然后采用新 parent 并重新握手。dispose() 同样幂等:移除 listener, 以 disposed 拒绝 pending request,并丢弃所有注册。宿主的 ui/resource-teardown request 必须来自完全 相同的当前 parent,先得到确认,再 dispose 客户端;配置 targetOrigin 时,其 origin 也必须匹配。取消与 teardown 回复会在连接状态清除之前,沿当前 parent 使用已配置的精确 origin 或 '*' 发送。可注入的 window 与 parent 让测试及非 DOM 宿主能驱动同一个客户端。

    宿主侧边界。 客户端只是 iframe 内的半座桥。frame relay、sandbox proxy、宿主页面、 /api/mcp/... 路由与 consent authority 都属于宿主;Workbench MCP 页面、agent-bundle serve-app、 嵌入 App 的 MCP 宿主,以及 #564 计划的独立生产宿主,都会通过同一份 wire contract(McpAppBridge)服务这个客户端。由宿主而不是 App 决定 call 到达哪台服务器、哪些能力需要同意;App 不能绕过 relay 访问前台服务器或 MCP backend。

    agent-bundle/app 不携带编译器,编译器会像其他依赖一样从已安装的包把它内联进 App 文档,因此 App 中的 值导入不会触发保护自包含可执行文件的 AB4837。

    保留标识符优先

    你的 tsconfig.json paths 在视图内照常生效(@lib/*、@/components/* 等):Rsbuild 会像往常 一样把它们交给打包器,并在所有别名之前解析。不受它们影响的标识符是 agent-bundle/meta 与 agent-bundle/app:编译器会在解析开始之前改写这两个精确导入,因此 paths 映射既不能替换生成的 身份模块,也不能替换框架的浏览器运行时。若某个 tools 逃生舱移除了任一改写,已解析配置断言会拒绝 该构建。

    编译错误与体积

    视图编译期间,打包器自己的日志输出被静音;框架改为读取 Rspack 的 stats,并把每个错误报告为一条 AB4770 诊断,携带出错文件、其 line:column 以及打包器的消息(sourcePath 即出错文件):

    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 以退出码 1 结束并把这些诊断写到 stderr,绝不会是 AB5000 兜底码。未导致 构建失败的警告是同样形状的 AB4771;它们以 AB4771 (warning): … 行打印在 Built … 之前,并在 --json 中同时出现在 diagnostics 与 build.diagnostics 里。 诊断参考列出了每种形状, 包括 tsconfig.json 问题那种不带位置的形式。

    成功的构建会在 Built … 之后为每个 App 打印一行,按 target 与 App 名称排序,给出输出体积及其 gzip 体积(--json 中为 build.compiledMcpApps[].size.bytes 与 .gzipBytes):

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

    数字来自当次实际 输出;不要把某次示例构建的体积当作固定契约。

    任何导入 @modelcontextprotocol/ext-apps 2.x 的视图都会带上 zod 4、@modelcontextprotocol/client、 @modelcontextprotocol/core 以及 ext-apps 本身,在你写下第一行自己的代码之前就约有 249 kB (gzip 后 65 kB)。ext-apps 1.x 视图还会带上 zod 3、@modelcontextprotocol/sdk 与 zod-to-json-schema,约 445 kB(gzip 后 106 kB),即便如此也远在 1 MiB 以下。生产构建的输出达到或超过 1 MiB 时会报告 AB4772 建议性诊断并点名最大的五个模块;任何超过 2 MiB 的构建也会报告它,因为 2 MiB 是 Workbench 与 serve-app 宿主拒绝该资源的上限,超过后视图在那里将不再渲染。阈值是固定的; 没有配置键可以改变它们。

    开发构建与生产构建

    agent-bundle dev(Workbench)以未压缩的方式编译视图,因此预览的源码在浏览器开发者工具中可读: 真实的标识符,以及每个模块一条 // CONCATENATED MODULE: ./src/mcp/status/apps/StatusPanel.tsx 标记。输出仍然是每个 App 恰好一份自包含 HTML 文档,约为生产体积的 2.7 倍(一个 617 KiB 的 ext-apps 视图会变成 1.6 MiB),这正是 1 MiB 建议性诊断不适用于开发构建、而 2 MiB 宿主上限仍然 适用的原因。可读文档会越过该上限的视图会改用生产配置重新编译,以便预览继续渲染它,并由 AB4772 说明这一点。agent-bundle build 交付生产配置:已压缩、无 source map。

    两种配置默认都不输出 source map:携带原始源码的 map 会再增加约 7 倍(同一视图会达到 4.2 MiB), 超出宿主所能接受的范围。对于小视图,可通过逃生舱选择加入, tools.rsbuild = { output: { sourceMap: { js: 'inline-source-map' } } },它与其他每个 tools 片段一样,会到达每一份合成配置。只有内联 map 可用:HTML 旁边的外部 .map 文件会让构建失败,并以一条 点名该多余文件的错误结束,因为产物必须保持每个 App 一份自包含文档。当某个逃生舱取值没有落在你预期的 位置时,agent-bundle inspect --bundler 会打印解析后的 App 配置。

    在浏览器中暴露 App

    在 MCP 宿主之外,App 通常通过在 Workbench 中选中 Application → MCP → <server> → Apps 下的 叶子来访问。已经声明在 mcp.servers.<id>.apps 下的 App,也可以通过 web 配置键在普通浏览器标签页里从已安装插件打开,机器上不需要框架:

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

    apps[] 从那些已声明的 App 中选择(<server>/<app>)。tool 是开场工具(默认:唯一一个 _meta.ui.resourceUri 指向该 App 的工具;有多个时必填)。input 是那次开场调用的参数(默认 {})。allow 预先批准由 App 发起的同意能力:call-tool、download-file、open-external-link、 request-display-mode。浏览器硬件与剪贴板权限始终在宿主页面里询问。无效的 web 是 AB4341 (error);各消息形状见诊断参考。

    配置了 web 时,组合产物的 agent-bundle.manifest.json 会增加 web 节,即使没有 src/cli/** 命令也会输出 bin/<plugin>.mjs。宿主页面装在该 bin 里,产物中没有 web/ 目录。它的编译与其他 每个可执行文件一样提供 external-module 证据,因此 AB6005 会拒绝 Node 内建模块、pnpapi 或已输出 同级文件之外的任何 external。

    从已构建产物或已安装插件根目录运行:

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

    默认 App 是唯一被暴露的那个;暴露了多个却未选择时是用法错误(退出码 2)并列出它们。bin/ 旁边 缺少 agent-bundle.manifest.json 则以 1 退出。该命令按 executables.mcpServers[] 的 launch 记录启动插件自己的 MCP 服务器 (node <root>/<launch.entry> <args>,artifact 参数解析到 <root> 之下,literal 参数展开路径 令牌,并带上 AGENT_BUNDLE_PLUGIN_ROOT=<root> 与已声明的静态 env,路径令牌同样展开;plugin-data 令牌解析到产物之外的 ~/.agent-bundle/web-data/<plugin>-<digest>/<server>, 因此只读安装也能启动)。被拉起的服务器自己的持久框架状态同样落在产物之外的 ~/.agent-bundle/state/<plugin>-<digest>(或 AGENT_BUNDLE_STATE_ROOT)。随后先调用一次开场工具,然后在 http://127.0.0.1:<port>/ 上用 loopback origin 提供 App,并为 App 文档再开一个 loopback 沙箱 origin(与 agent-bundle serve-app 以及 Workbench 同一套宿主栈与 同意行为),打印 MCP App <server>/<app> at <url> (tool <tool>; Ctrl-C stops the server)(或带 --json 时打印一行 { app, server, tool, url, port, resourceUri, sandboxOrigin }),并一直运行 到 Ctrl-C / SIGTERM。web 出现在该 bin 的 --help 里。配置了 web 时,手写的 src/cli/web.ts 会被 AB4341 拒绝。全部标志见命令行参考。

    agent-bundle dev 在前台服务器的 GET /web/<server>/<app> 上提供同一页面、中继、路由、沙箱代理 与同意行为,绑定到当前 epoch 的服务器会话。未列入 web.apps 的 App 返回 404。服务器的启动方式来自 产物清单声明的 projection,而绝不来自浏览器展示 profile:显式的 ?target=<projection> 会对照声明 了该服务器启动方式的 projection 校验(无效即报错,绝不回退);共享同一个规范化启动描述符(command、 args、cwd、声明的 env、运行时绑定)的 projection 无需询问即可打开;实质不同的启动方式则以 409 应答 并列出可选项,只有 Claude 或 Codex projection 的构建不需要 portable projection 或 mcp.json 就能 打开它的 App。Web 会话按 epoch、服务器与解析后的启动身份缓存;一次成功的重建会退役旧 epoch 中未被 使用的会话(仍被页面租用的保持有效,直到最后一个租约释放时关闭),失败的重建则不退役任何会话。标注了 readOnlyHint: true 的 开场工具在每次页面加载时运行;其他开场工具在每个会话、工具、App 与输入组合下只运行一次。并发的首次加载 共享同一个进行中的调用,刷新只重新绑定保留的结果,并不会重新获得执行操作的权限。结果按独立上限保留; 结果被淘汰、调用失败而副作用未知,或有界执行记录已满时,页面会收到一份错误结果,要求用户从 App 显式运行该工具,而不会自动重试。

    agent-bundle serve-app 保持不变:它是 checkout 时的形态,会构建或指向一份产物,并需要已安装的 框架。全部选项见命令行参考。

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

    同一套宿主以 agent-bundle/api 中的 serveApp 提供编程形式。它返回 { url, close, closed }: close() 拆除宿主与服务器,closed 在服务器连接因任何原因结束时落定,用完后务必 close(),因为 closed 落定只表示服务器连接结束,并不表示宿主已被拆除。autoApprove 会在 App 请求时代操作者批准 列出的同意能力(call-tool 让轮询型仪表盘无需弹窗即可刷新);其余能力与 Workbench 一样,在宿主页面里 等待 Allow/Deny 决定。

    serveApp 是一个宿主进程 API。它属于框架不编译的那些进程,第一方 CLI、Workbench、测试、插件自己的 package.json 脚本或从 checkout 里运行的手写 .mjs,并且要求 agent-bundle 在该进程运行之处可被 解析。绝不要在 MCP 服务器外壳里调用它。路由式 CLI 命令也不能导入它:那种值导入是 AB4837(见 诊断参考)。路由式命令不再启动 agent-bundle serve-app;从已安装 根目录出发,受支持的方式是产物自己的 <plugin> web 命令。 AB4837 的恢复文本点名该命令。

    服务器模式

    当目录约定不该生效时,routes.servers.<server> 可以让某个服务器退出路由生成,手写服务器用 custom、外部进程用 command、通过 URL 访问的服务器用 remote。包含冲突恢复在内的完整模式契约见 Entry conventions。

    运行与检查

    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 在前台执行一个已构建的 stdio 服务器:它从产物清单的 executables.mcpServers[] 行中解析出编译后 的入口(其文件名带有服务器名称的摘要),通过宿主适配器展开路径 token,加载项目根目录的 .env 集合,并转发 子进程的退出码。--target 是可选的,省略时,清单的 projections[] 必须恰好指出一个具备 MCP 能力的宿主; 有多个时是一条点名候选宿主的错误。不带 --artifact 时会先构建一个临时产物。

    实时宿主 MCP 代理

    在开发期间,宿主可以在 agent-bundle dev 重建其背后的生成服务器时,始终保持一个 stdio MCP 进程连接 不断。把宿主的 MCP 服务器命令配置为:

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

    该代理通过项目的开发锁发现 loopback 服务器,并连接到位于 /mcp/host/<serverName> 的稳定 Streamable HTTP 端点。--target 默认为 portable,--url 可覆盖发现过程。重建成功时会保持 stdio 连接不断、 把新调用路由到当前 epoch、让已受理的调用在其原始 epoch 上完成,并转发 MCP 目录变更通知。开发期 MCP 会话与 Workbench 调用共享项目的 <project>/.agent-bundle/state 根目录,它不在会被退役的 epoch 目录之内。若 epoch 或开发服务器消失,代理会以 MCP 错误以及 AB8024 或 AB8025 诊断失败关闭。

    该端点刻意不做认证,因为开发服务器只绑定 loopback,绝不会暴露到本机之外。

    下一步