MCP Apps 能直接返回 HTML,为什么还需要 A2UI?

125 阅读31分钟

大模型早就能把话说明白了,但它一直不太会「画界面」。 2025 年底到 2026 年初,两个协议几乎同时给出了答案——而且答案完全不同。

一个返回 HTML,一个返回 JSON。看起来前者完胜,实际上没这么简单。


一、先说清楚问题:Agent 的「最后一公里」

我们已经把 Agent 的上游打通得差不多了:

  • MCP 解决了 Agent 怎么用工具、怎么读数据;
  • A2A 解决了 Agent 之间怎么互相调用、怎么鉴权;
  • AG-UI 解决了 Agent 后端怎么把事件流推给前端。

但还剩最后一段:Agent 想让用户「选一个日期」「填一张表」「批一个单」的时候,它到底该吐出什么?

在此之前的默认答案是 Markdown。于是我们见过太多这样的对话:

助手:好的,我为你找到了 3 家餐厅:
1. 西安名吃 ★★★★☆ 地址:...
2. 汉唐 ★★★★☆ 地址:...
请回复序号选择,并告诉我用餐时间和人数。

用户得手打「2,今晚 7 点,4 个人」。这在 2019 年可以接受,在今天不行。

MCP AppsA2UI 就是对这一公里的两种回答。它们经常被放在一起比较,也确实在争夺同一块屏幕,但它们的世界观差得非常远:

MCP Apps 说:「界面是我服务端写好的,你原样渲染就行。」 A2UI 说:「界面是我这一轮现场想出来的,你用你自己的组件画出来。」

先回答标题那个问题

既然 MCP Apps 能直接返回 HTML、能渲染任意样式,为什么还需要一个只能发 JSON、还得客户端自己实现渲染器的 A2UI?

一句话版本:因为「能画什么」和「谁说了算」是两件事。

  • HTML 的表现力更强,但界面长什么样由服务端决定。 你的产品里接五个厂商的 MCP Server,可能得到五种视觉语言。
  • HTML 是开发者预先写好的,LLM 只能往里填数据。 界面结构没法随对话变。
  • HTML 要靠 iframe 承载,出了 Web 就很尴尬。 移动端原生、可访问性、布局联动都要另想办法。

A2UI 用「表现力受限于组件目录」换回了这三样:样式主权归宿主、结构可由 LLM 现场生成、能渲染成真正的原生控件。

所以这不是谁强谁弱,而是两套不同的权衡。完整的六个分水岭在第六章,急的话可以直接跳过去。下面先把两者的机制讲清楚——不了解机制,那六条对比只是结论,记不住也用不上。


二、先建立坐标系:五个协议各在哪一层

初学者最容易犯的错,是把 MCP / A2A / AG-UI / A2UI / MCP Apps 摆在同一排比较。它们其实分属不同层次:

diag01.png

图 1:五个协议的分层坐标——只有 A2UI 与 MCP Apps 在同一层竞争

一句话记忆:

协议回答的问题层次
MCPAgent 怎么调工具、读数据能力
A2AAgent 之间怎么互相委托协作
AG-UI消息怎么流到前端传输
A2UI这条消息渲染成什么界面呈现
MCP Apps这个工具附带什么界面呈现

只有最后两个是真正的同层竞争关系。 前三个跟它们是互补的——A2UI 的官方传输方案就是 A2A 和 AG-UI,而 MCP Apps 本身就是 MCP 的一个扩展(SEP-1865)。


三、MCP Apps 机制详解

3.1 核心公式:MCP Apps = Tool + UI Resource

MCP Apps 是 MCP 的官方扩展(规范版本 2026-01-26),它的思路极其克制:不发明新的 UI 语言,直接用 HTML。

三个角色:

diag02.png

图 2:MCP Apps 的三个角色(Server / Host / View)

  • Server:标准 MCP Server,额外声明 UI 资源;
  • Host:聊天客户端,负责把 View 塞进 iframe,并在 Server 和 View 之间做代理;
  • View:跑在沙箱 iframe 里的 UI,它本身扮演一个 MCP Client 的角色。

3.2 两段式注册

关键在于工具和 UI 资源是分开注册、靠 URI 绑定的

import {
  registerAppResource,
  registerAppTool,
  RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/server";

const resourceUri = "ui://get-time/mcp-app.html";

// ① 注册工具,用 _meta.ui 指向它的 UI 资源
registerAppTool(
  server,
  "get-time",
  {
    title: "Get Time",
    description: "Returns the current server time.",
    inputSchema: {},
    _meta: { ui: { resourceUri } },   // ← 这一行是全部的魔法
  },
  async () => {
    const time = new Date().toISOString();
    return { content: [{ type: "text", text: time }] };
  },
);

// ② 注册资源,返回打包好的 HTML
registerAppResource(
  server,
  resourceUri,
  resourceUri,
  { mimeType: RESOURCE_MIME_TYPE },   // "text/html;profile=mcp-app"
  async () => {
    const html = await fs.readFile(path.join(DIST_DIR, "mcp-app.html"), "utf-8");
    return { contents: [{ uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html }] };
  },
);

View 侧同样简单:

import { App } from "@modelcontextprotocol/ext-apps";

const app = new App({ name: "Get Time App", version: "1.0.0" });

// 接收 Host 推下来的工具结果(要在 connect 之前挂,否则会漏掉首次结果)
app.ontoolresult = (result) => {
  const time = result.content?.find((c) => c.type === "text")?.text;
  document.getElementById("server-time")!.textContent = time ?? "[ERROR]";
};

// UI 里的按钮可以直接回调服务端工具
document.getElementById("get-time-btn")!.addEventListener("click", async () => {
  const result = await app.callServerTool({ name: "get-time", arguments: {} });
  // ...
});

app.connect();

注意这里的 ui:// 是自定义 URI scheme,专门用来把 UI 资源和普通 MCP 资源区分开。UI 是在工具注册时就声明的,不是运行时生成的——这一点后面会反复提到,它是两个协议最根本的分水岭。

规范里给出的理由很明确:

  1. 可预取(Prefetching)——Host 可以在工具真正被调用之前就缓存好模板;
  2. 关注点分离——模板(表现)与工具结果(数据)解耦;
  3. 可审查(Review)——Host 可以在连接建立时就检查 UI 模板。

3.3 完整生命周期

diag03.png

图 3:MCP Apps 完整生命周期

有几个设计细节值得单独拎出来:

contentstructuredContent 分离。 工具结果里,content 是给模型看的文本,structuredContent 是给 UI 用的结构化数据。这样服务端能给 UI 喂很详细的数据,而不会撑爆模型的上下文。这是个非常实用的设计。

② 工具可见性(Tool Visibility)。 工具可以声明 visibility: ["model", "app"]。设成 ["app"] 的工具模型根本看不见,只有 View 能调——刷新按钮、翻页、表单提交这类纯 UI 交互就该这么干,免得污染 Agent 的上下文。这个设计我个人非常喜欢。

③ 渐进增强(Progressive Enhancement)。 Host 在连接时声明自己支不支持 MCP Apps,Server 据此决定要不要注册带 UI 的工具。不支持的 Host 上,工具照常工作,只是退化成纯文本。 UI 是增强,不是依赖。

3.4 安全模型:沙箱 + 声明式 CSP

因为跑的是真代码,MCP Apps 的安全全押在隔离上:

  • 所有 View 跑在 sandboxed iframe 里,无法访问 Host 的 DOM、Cookie、Storage;
  • 通信只走 postMessage,因此全程可审计
  • Server 必须通过 _meta.ui.csp 声明自己需要哪些外部域名:
interface McpUiResourceCsp {
  connectDomains?: string[];    // fetch / XHR / WebSocket → CSP connect-src
  resourceDomains?: string[];   // 图片/脚本/样式/字体/媒体 → img-src, script-src, ...
  frameDomains?: string[];      // 嵌套 iframe → frame-src
}

**「默认拒绝」**是这里的关键:不声明就一个外部连接都不许发。这直接堵死了数据外泄的路径。

3.5 主题:Host 给建议,View 自愿采纳

这是理解 MCP Apps 的关键一环。Host 会在 ui/initialize 时下发上下文(主题明暗、locale、时区、显示模式、容器尺寸、平台),并提供一组 CSS 自定义属性:

.container {
  background: var(--color-background-primary, #ffffff);
  color: var(--color-text-primary, #000000);
}

注意 var(..., fallback) 这个写法——这是软约定,不是强制。View 完全可以无视所有变量,写死自己的品牌色、字体和圆角。

对「我就是要我的品牌视觉」的服务方,这是优点。 对「我要一个统一体验的产品」的宿主方,这是失控的开始

记住这个点,它是后面对比的核心。

3.6 显示模式

View 可以声明自己支持哪些模式,Host 决定给不给:

模式说明适用
inline嵌在对话流里图表、预览、表单
fullscreen接管整个窗口编辑器、游戏、复杂看板
pip画中画悬浮播放器、计时器等常驻小组件

规范里写得很直白:View 可以请求切换,但 Host 有最终决定权——毕竟那是 Host 自己的界面。


四、A2UI 机制详解

4.1 核心理念:像数据一样安全,像代码一样有表现力

A2UI(Agent-to-User Interface)的出发点完全相反:绝不让 LLM 生成可执行代码。

它的做法是:Agent 只发送一段声明式 JSON,描述「我想要一个 Card,里面放一个标题和一个提交按钮」的意图;客户端从自己维护的**可信组件目录(Catalog)**里挑出对应实现来渲染。

用官方的类比来说:

WebA2UI
HTML 规范A2UI 协议
Web ServerAgent
浏览器引擎Renderer(客户端库)
CSS / 设计系统Catalog + Theme

没有浏览器,HTML 就是一堆文本;没有 Renderer,A2UI JSON 就是死数据。

4.2 只有四条消息

v0.9 协议全部的服务端→客户端消息就这四条:

消息作用
createSurface创建一个渲染面,指定用哪个 catalog
updateComponents增加 / 更新组件
updateDataModel更新数据
deleteSurface销毁面

一个完整的最小示例(来自官方 catalog 示例库):

{"version":"v0.9","createSurface":{
  "surfaceId":"demo",
  "catalogId":"https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json"
}}

{"version":"v0.9","updateComponents":{"surfaceId":"demo","components":[
  {"id":"root","component":"Column","children":["title","action_button"],
   "justify":"center","align":"center"},
  {"id":"title","component":"Text","text":"Click the button below","variant":"body"},
  {"id":"action_button","component":"Button","child":"button_label","variant":"primary",
   "action":{"event":{"name":"button_clicked","context":{}}}},
  {"id":"button_label","component":"Text","text":"Click Me"}
]}}

4.3 关键设计一:扁平邻接表,不是嵌套树

看上面的 JSON——rootchildren: ["title","action_button"] 按 ID 引用子节点,而不是把子节点嵌套在自己内部。

这是专门为 LLM 优化的:

  • 好流式:模型可以一个组件一个组件地吐,客户端边收边渲染,用户不用等整棵树生成完;
  • 好增量:下一轮对话想改标题?只发那一个 id: "title" 的组件即可,不用重发整个界面;
  • 好生成:扁平结构比深度嵌套的 JSON 更不容易让模型写崩括号。

v0.9 里根节点是约定:必须有一个 id"root" 的组件。

4.4 关键设计二:结构与数据分离

组件里可以写数据绑定,而不是写死的值:

{"id":"party_field","component":"TextField","text":{"path":"/partySize"}}

数据走单独的消息,用 JSON Pointer 精确更新:

{"version":"v0.9","updateDataModel":{
  "surfaceId":"demo","path":"/user/email","value":"alice@newdomain.com"
}}

只改 /user/email/user/name 纹丝不动。绑定到该路径的组件自动重渲染。

4.5 关键设计三:Actions 分两类

这是 A2UI 相当聪明的一处设计:

diag04.png

图 4:A2UI 的 Action 双轨——本地执行 vs 回传 Agent

谁执行Agent 知道吗典型用途
functionCall本地渲染器打开链接、切换 tab、表单校验
event发回 Agent提交预订、确认支付

event 的载荷长这样:

{
  "id": "submit-btn", "component": "Button", "child": "btn-text",
  "action": {
    "event": {
      "name": "submit_reservation",
      "context": {
        "time": {"path": "/reservationTime"},
        "size": {"path": "/partySize"}
      }
    }
  }
}

context 是数据模型的手挑子集——官方文档形容为一个「view」。好处是 Agent 不用在一棵庞大的状态树里翻找,拿到的就是这次事件需要的几个值。

渲染器解析路径后实际发出去的是:

{
  "version": "v0.9",
  "action": {
    "name": "submit_reservation",
    "surfaceId": "booking-surface",
    "sourceComponentId": "submit-btn",
    "timestamp": "2026-02-25T10:40:00Z",
    "context": { "time": "7:00 PM", "size": 4 }
  }
}

Agent 侧的处理通常就是把它翻译成一句「隐藏的用户输入」:

if action_name == "submit_reservation":
    query = f"User submitted a reservation for {context['size']} people at {context['time']}."
    response = await llm.generate(query)

4.6 关键设计四:本地优先的读写契约

所有输入组件(TextField / CheckBox / Slider)遵循一个明确的双向契约:

  • 读(Model → View):渲染时从绑定的 path 拉值;
  • 写(View → Model):用户一敲键盘,渲染器立刻同步写回本地数据模型。

这带来两个实打实的好处:

  1. 网络完全不感知 UI 噪音。用户在输入框里敲的每一个字符都不出网,直到他点「提交」触发一个正式 event你不需要写防抖,不需要担心延迟抖动。
  2. 没有竞态。规范明确要求本地写入是同步的,保证「输入」一定先于「点击」提交——按钮解析 context 时拿到的一定是最新值。

另外还有 checks,可以在渲染端做前置校验,不满足就自动禁用按钮:

{
  "id": "submit-button", "component": "Button", "child": "submit-text",
  "checks": [{
    "condition": {"call": "required", "args": {"value": {"path": "/partySize"}}},
    "message": "Party size is required"
  }],
  "action": {"event": {"name": "submit_booking"}}
}

⚠️ 但文档反复强调:checks 只管 UX,不管数据完整性。真正的校验必须在 Agent 侧再做一遍。

4.7 关键设计五:错误反馈闭环

这一点在 Agent 系统里特别关键。如果 Agent 生成的 JSON 违反了 catalog schema,渲染器会主动回报

{
  "version": "v0.9",
  "error": {
    "code": "VALIDATION_FAILED",
    "surfaceId": "booking-surface",
    "path": "/components/0/children",
    "message": "Expected array of strings, got null."
  }
}

Agent 接住这个错误,可以内部自我纠正后重发。这是一条给 LLM 用的编译错误信息——有它和没它,生成式 UI 的可用性差一个数量级。

4.8 Catalog:真正需要你投入的地方

每个 surface 都由一个 Catalog 驱动。Catalog 本质就是一份 JSON Schema,告诉 Agent「你能用哪些组件、哪些函数、哪些主题」。

官方提供了 Basic Catalog,18 个组件:

AudioPlayer  Button   Card       CheckBox      ChoicePicker  Column
DateTimeInput Divider Icon       Image         List          Modal
Row          Slider   Tabs       Text          TextField     Video

但官方自己也说了,Basic Catalog 是刻意做得很稀疏的,只为了让不同渲染器都容易实现。生产环境应该定义自己的 catalog:

  • 设计体系对齐:Agent 只能用你 App 里真实存在的组件和视觉语言;
  • 安全与类型:catalog 就是白名单,没注册的组件根本渲染不出来;
  • 别做映射层:官方明确建议直接照你的组件库写 catalog,而不是先用 Basic Catalog 再写 adapter 转换。

协商流程是双向的:

客户端 --supportedCatalogIds--> 声明支持哪些目录
                                     ↓
Agent --createSurface.catalogId--> 挑一个用
                                     ↓
Agent --updateComponents--------> 按目录里的组件名生成

4.9 Renderer 现状

渲染器平台v0.9.1v1.0
ReactWeb✅ 稳定🚧 计划中
Lit(Web Components)Web✅ 稳定🚧
AngularWeb✅ 稳定🚧
Flutter(GenUI SDK)移动/桌面/Web✅ 稳定🚧
SwiftUIiOS/macOS🚧
Jetpack ComposeAndroid🚧

三个 Web 渲染器共用底座 @a2ui/web_core——消息处理、状态管理、数据绑定都在里面,各框架只贴一层渲染层。所以协议处理逻辑在 Web 各端是完全一致的。

客户端接入大概长这样:

import { MessageProcessor } from '@a2ui/web_core/v0_9';
import { A2uiSurface, basicCatalog } from '@a2ui/react/v0_9';

const p = new MessageProcessor([basicCatalog]);  // 注册 catalog
p.processMessages(agentMessages);                // 喂消息
// <A2uiSurface surface={s} onAction={handleAction} />

社区渲染器也有一些:Vercel 的 json-renderA2UI-Android(Jetpack Compose)、a2ui-react-nativeLynx A2UIAGenUI(iOS/Android/鸿蒙)。不过多数还停在 v0.8/v0.9。


五、关系:它们不是竞品

在讲区别之前,先泼一盆冷水:这两个协议在实际项目里经常同时出现。 A2UI 仓库里已经有三种成型的共存模式。

diag05.png

图 5:A2UI 与 MCP Apps 的三种共存形态

模式谁装谁场景
A2UI over MCPMCP 当传输,tool 返回 application/a2ui+json想复用 MCP 生态,但要动态 UI
A2UI in MCP AppsMCP App 内部嵌一块 A2UI 渲染区App 主体固定,某块面板需要 LLM 动态生成
MCP Apps in A2UIA2UI 宿主用双层 iframe 承载 MCP App主体统一设计,个别第三方要完全自定义

模式二最能说明分工。 官方那个「生成式文档编辑器」demo:编辑器主体(富文本、复杂交互)是 MCP App 的原生 HTML,而「接受 / 拒绝这段改写」这类随内容动态变化的控制面板,由 A2UI 渲染。

它的消息流是这样的:

MCP App 需要 UI
  → postMessage 发 JSON-RPC 给 Host(如 ui/fetch_counter_a2ui)
  → Sandbox Proxy 转发
  → Host 翻译成标准 MCP tools/call
  → Server 返回 application/a2ui+json 资源
  → 原路回传
  → MCP App 喂给自己本地的 A2UI MessageProcessor
  → 渲染

用户在 A2UI 区域点按钮,流程整个反过来走一遍。

分工原则:HTML 干「结构固定但交互复杂」的活,A2UI 干「交互简单但结构随对话变」的活。


六、区别:六个维度深挖

6.1 分水岭一:模板 vs 生成 ★最重要

这条比「HTML vs JSON」重要得多,但最容易被忽略。

diag06.png

图 6:最重要的分水岭——模板 vs 生成

MCP Apps 的 UI 是注册期就固定的。这是它 prefetch、可审查、可缓存的前提,也是它的天花板:同一个工具,永远长同一个样。

A2UI 的 UI 是推理期生成的。同一个 Agent,可以根据「用户在问退款」还是「用户在选座位」给出完全不同的界面。

MCP Apps 是「带 UI 的工具」,A2UI 是「会画界面的 Agent」。

6.2 分水岭二:样式主权归谁

很多人的第一反应是「MCP Apps 返回 HTML,能渲染样式,肯定更强」。这个判断只对了一半——问题不是能不能渲染样式,而是样式归谁管。

MCP AppsA2UI
谁决定视觉ServerHost
机制Host 提供 CSS 变量,View 自愿采纳Host 的 catalog 实现直接决定
强制力无(View 可写死品牌色)完全(Agent 只能说「我要个 Button」)

后果很现实:你的产品里接了五个不同厂商的 MCP Server,可能得到五种视觉语言、五套圆角、五种按钮手感。用户会觉得「东拼西凑」。

而 A2UI 里,即便是一个你完全不信任的远端 Agent 发来的界面,也长得和你自研页面一模一样

6.3 分水岭三:安全模型的哲学差异

diag07.png

图 7:两种安全哲学

MCP Apps 的沙箱不是随便配的。A2UI 文档里给出的反例很典型:

单层 iframe 只要同时带上 allow-scriptsallow-same-origin,里面的脚本就可以操作父 DOM、甚至把自己的 sandbox 属性摘掉,从而逃逸。

所以 A2UI 在承载 MCP App 时用的是 双层 iframe

  • 外层 Sandbox Proxy(同源,不加 sandbox):负责校验消息来源、维持 JSON-RPC 通道;
  • 内层 通过 srcdoc 注入,权限为 sandbox="allow-scripts allow-forms allow-popups allow-modals"MUST NOTallow-same-originallow-top-navigationallow-top-navigation-by-user-activation

各自防的是:

  • 去掉 allow-same-origin → 独立源,切断 localStorage / sessionStorage / IndexedDB / Cookie;
  • 去掉 allow-top-navigation* → 防 window.top.location = "..." 这类劫持跳转;
  • 额外收紧弹窗权限 + 拦截链接跳转 → 防通过新开窗口做数据外泄(这一条属于更严格的加固,会牺牲一部分正常的外链跳转能力,按业务权衡)。

这些全都不是 A2UI 自己需要操心的问题——因为它压根不执行代码。这就是两种安全哲学的成本差:一个要持续对抗浏览器沙箱的边界情况,一个只需要维护一份组件白名单。

6.4 分水岭四:iframe 的隐性代价

「返回 HTML」听着是纯赚,但在真实产品里有几笔账要算:

代价说明
非 Web 端要靠 WebViewFlutter / SwiftUI / Compose 里得嵌 WebView 才能跑 iframe,性能、手势、键盘、深色模式都要单独处理
布局要协商iframe 高度不随内容自适应,得显式沟通尺寸——这正是 MCP Apps 要专门定义 display modes 和 container dimensions 的原因
可访问性断裂屏幕阅读器、Ctrl+F 全文搜索、跨区域文本选择,都在 iframe 边界处断掉
零集成iframe 里的内容无法参与宿主的滚动联动、转场动画、主题过渡,视觉上永远是「贴上去的一块」
强依赖沙箱正确性见上一节

A2UI 在移动端渲染的是真正的原生控件(Flutter Widget / 未来的 SwiftUI View),这些问题天然不存在。

6.5 分水岭五:实现成本落在谁头上(生态动力学)

这是最少被讨论、但最能解释「为什么会有两个协议」的角度。

diag08.png

图 8:生态动力学——实现成本落在谁头上

成本落在收益
MCP AppsServer 开发者(写 HTML)写一次,所有 Host 渲染
A2UIHost 开发者(写 catalog)写一次,所有 Agent 可用

所以选型其实可以从「你在生态里站哪个位置」倒推:

  • 你是 Server 方,想让自家能力出现在别人的 Claude Desktop 里 → MCP Apps;
  • 你是 Host 方,在做一个自有的 Agent 产品,要接很多 Agent → A2UI。

6.6 完整对照表

维度MCP AppsA2UI
UI 载体HTML/JS,ui:// 资源声明式组件 JSON
UI 结构来源开发者预写,注册时声明LLM 运行时生成,支持流式
渲染方式沙箱 iframe宿主原生组件
样式控制权Server(Host 只给 CSS 变量建议)Host(catalog 实现说了算)
表现力上限——Three.js、shader、图表库、富文本编辑器受限于 catalog 词汇表
安全模型运行不可信代码,靠 sandbox + CSP 关住不运行代码,只接受白名单组件的数据
增量更新View 自管状态,Host 推 tool-resultupdateDataModel + JSON Pointer 精确更新
跨端Web 为主(非 Web 端需 WebView)Web / Flutter / 原生移动 / 桌面
可访问性iframe 内自成一体,a11y 树割裂复用宿主原生控件的 a11y
布局需协商(container dimensions / display modes)组件直接参与宿主布局流
多 Agent 跨信任边界多个 Server 各一个 iframe✅ 天然支持
错误自愈无协议级机制VALIDATION_FAILED 回传给 Agent 自纠
降级✅ 渐进增强,不支持则退回文本依赖 renderer 存在
成本落点Server 开发者Host 开发者
规范归属MCP 官方扩展(SEP-1865)独立开源项目(Apache 2.0)

七、被忽略的一章:Agent 停下来问人怎么办

前面六章讲的都是「Agent 主动画一个界面给你看」。但生成式 UI 真正的硬骨头是反过来的方向:Agent 执行到一半,需要人来批准、选择或补充信息,然后才能继续。

这就是 HITL(Human-in-the-Loop)。它比「渲染一张卡片」难得多,因为它涉及状态机:任务要暂停、要等待、要能被恢复,还要能跨越网络重试。

有意思的是,MCP 和 A2A 都为此做了专门设计,而且思路完全不同。

7.1 MCP 的答案:Elicitation

MCP 提供了 elicitation/create——服务端反过来向客户端征询用户输入

两种模式:

模式用途数据流向
Form结构化数据收集请求带 requestedSchema,客户端据此渲染表单并校验
URL敏感输入(凭据录入、第三方 OAuth)带外完成,数据不经过客户端,也不进 LLM 上下文;客户端只知道用户是否同意

2026-07-28 版规范里,Elicitation 走的是 MRTR(Multi Round-Trip Requests) 模式——注意这里的机制细节,它决定了 elicitation 能干什么、不能干什么:

diag09.png

图 9:MCP Elicitation 的 MRTR 流程

关键在于「重试原请求」这四个字:服务端返回 InputRequiredResult 中止掉原来那次调用,客户端收集完输入后,带着 inputResponses 和服务端给的 requestState 重新发起同一个请求

这个结构带来几条硬约束,实际选型时必须知道:

  1. 必须有一个「正在处理中」的请求可以挂靠。 Elicitation 是服务端在处理某个请求期间发出的。如果任务已经返回了句柄、在后台异步跑,此时没有请求可挂——单靠 elicitation 接不上,得配合下一节的 Tasks 扩展。
  2. 依赖客户端声明能力。 2026-07-28 版要求客户端在每个请求_meta.io.modelcontextprotocol/clientCapabilities 里声明 elicitation(并细分 form / url);客户端没声明的,服务端 MUST NOT 下发对应的 elicitation/create。不是所有宿主都声明它,服务端不能假定其存在。
  3. Form 模式的 schema 是单层的。 规范原文:schema 限定为「flat objects with primitive properties only」,属性只能是字符串 / 数值 / 布尔 / 枚举,不支持嵌套对象和对象数组——这是为了简化客户端 UX 有意为之。多步骤向导、带子项的条件表单这类结构表达不了。
  4. MRTR 是无状态设计,服务端不替你记账。 规范明确:重试请求是完全独立的,服务端处理重试时不需要任何额外信息;跨轮次的上下文全靠 requestState 这个不透明字符串由客户端原样带回。规范同时规定服务端 MUST 把回传的 requestState 当作攻击者可控输入(影响授权时 MUST 做完整性保护)、SHOULD 防重放,而「同一个 requestState 只能消费一次」这类不变量 MUST 由服务端自己实现。还有一条容易忽略:服务端 MUST NOT 假设客户端一定会填完输入并回来重试。
  5. Form 模式禁止索取敏感信息。 规范明文规定:密码、API key、access token、支付凭据不许走 form 模式,必须走 URL 模式——因为 URL 模式的数据不经过客户端和 LLM 上下文。

7.2 MCP Tasks:给长任务的中断态

上面第 1 条约束怎么破?答案是 MCP Tasks 扩展——它把 input_required 提升为任务状态机里的一等状态。

diag10.png

图 10:MCP Tasks 的任务状态机

流程变成了轮询式:

  1. 客户端在 _meta.io.modelcontextprotocol/clientCapabilities.extensions 里声明 io.modelcontextprotocol/tasks,服务端在 server/discover 里对等声明;
  2. 服务端返回 CreateTaskResult(含 taskId、初始状态、TTL、pollIntervalMs),任务在响应发出前就已持久化创建
  3. 客户端按 pollIntervalMs 轮询 tasks/get
  4. 任务转入 input_required 时,tasks/get 会带回一个 inputRequests map,客户端用 tasks/update 提交 inputResponses,任务随即回到 working
  5. 终态时 tasks/get 返回 resulterror

注意状态机里 input_required 是可以直接走向 cancelledfailed 的——「等不到人回答」是一等公民,不是异常路径。这个设计细节在做超时策略时很重要。

7.3 MCP Apps 给 HITL 提供的三个抓手,外加一个提案

MCP Apps 本身不定义 HITL 状态机,但它提供了三个直接相关的机制。这三个我在第三章只是一笔带过,这里展开——因为它们组合起来才是 MCP Apps 做 HITL 的真正形态。最后再说一个正在标准化的提案。

① 工具可见性 _meta.ui.visibility

规范原文对宿主的要求是 MUST 级别的:

  • tools/list 行为:可见性不含 "model" 的工具(如 visibility: ["app"]),宿主 MUST NOT 放进 agent 的工具列表;
  • tools/call 行为:不含 "app" 的工具,宿主 MUST 拒绝来自 App 的调用。

而规范给出的 app-only 用例里,明确列了「表单提交」

Tools with visibility: ["app"] are hidden from the agent but remain callable by apps via tools/call. This enables UI-only interactions (refresh buttons, form submissions) without exposing implementation details to the model.

② 三级受众划分

规范对工具结果三个字段的定位是:

字段受众
content面向模型上下文与纯文本宿主的文本表示
structuredContent为 UI 渲染优化的结构化数据,不加入模型上下文
_meta附加元数据,不进入模型上下文

⚠️ 这里有个跨平台的坑:字段名相同不代表受众语义相同。OpenAI Apps SDK 的文档写明 structuredContentcontent 同时提供给模型和组件,只有 _meta 对模型隐藏。所以「把数据放进 structuredContent 就等于对模型不可见」这个假设,换个宿主就不成立。做多宿主适配时这一条必须逐个验证,不能想当然。

ui/message:View 可以往对话流里写

{
  jsonrpc: "2.0", id: 2,
  method: "ui/message",
  params: { role: "user", content: { type: "text", text: string } }
}

View 可以把一条消息写进宿主的聊天界面,宿主会把它当作用户消息,触发模型新一轮推理。这是规范提供的、把 View 里发生的事情交回给模型的合法路径。用它做「用户在卡片里操作完了,请模型回来继续」的唤醒很自然。

④ 正在标准化中:用 App 渲染 Elicitation

社区提案 ext-apps #511(2026-02-27)提出在 elicitation/create 上带 _meta.ui.resourceUri,让同时支持 MCP Apps 和 elicitation 的宿主用 App 代替平面表单来渲染,不支持的宿主回落到 requestedSchema 平面表单。

SEP-3118 及 ext-apps 草案 #733(2026-07,评审中)把它规范化:服务端把 elicitation/create 装进 MRTR 的 InputRequiredResult 返回;宿主解析绑定的 App 资源、校验 App 给出的标准 ElicitResult、放进 inputResponses 并携带 requestState 重试原请求。

这里有一条值得注意的明文规定:App 不得绕过宿主直接重试服务端操作。 也就是说这个方案的结构是宿主中介——应答由宿主收集、宿主校验、宿主回传。资源加载、初始化或校验失败时,回落到宿主原生表单渲染。

7.4 A2A 的答案:INPUT_REQUIRED 是一等状态

A2A 走了另一条路。它把 TASK_STATE_INPUT_REQUIREDTASK_STATE_AUTH_REQUIRED 直接做成任务状态机里的中断态(v0.3.0 时期写作 input-required / auth-required):

  • 服务端把任务置为该状态,在状态消息里描述所需输入;
  • 流会关闭——规范明确:任务到达终态或中断态(COMPLETED / FAILED / CANCELED / REJECTED / INPUT_REQUIRED)时,服务端关闭流,不再发送更新;
  • 客户端用携带taskId / contextId 与新 messageIdMessage 续答;
  • 任务标识跨轮次不变——这是 A2A 相比 elicitation 最大的结构优势;
  • push notification 的典型触发点也包括 input-requiredauth-required

A2A 文档里给的典型场景很朴素:agent 发现信息不足或有歧义时,返回 input-required 向客户端要澄清。

但要注意 A2A 的定位:它的对端是客户端程序(另一个 agent 或应用),续答消息由客户端构造。协议不定义渲染层,也不规定续答内容由谁产生——这跟它的名字是自洽的,Agent-to-Agent 本来就不以「有人在场」为前提。

7.5 四种机制横向对照

维度MCP ElicitationMCP Tasks input_requiredMCP Apps 组合A2A INPUT_REQUIRED
中断产生时机服务端处理某个进行中请求期间长任务异步执行中同左(配合 Tasks)任务执行中任意时刻
请求方向服务端以 InputRequiredResult 结束原请求,客户端重试客户端轮询发现轮询 + View 正向调用状态变更 + 客户端正向续答
应答载体重试原请求带 inputResponsestasks/updateapp-only 工具的 tools/call带原 taskId 的新 Message
应答形状单层原始属性对象inputResponses,按 key 对应 inputRequests服务端自定义任意 Part,规范不约束
任务身份跨轮次❌ 无taskId 恒定✅ 随 TaskstaskId / contextId 恒定
前置条件客户端声明 capabilities.elicitation双方声明 tasks 扩展宿主支持 MCP Apps客户端实现 A2A
持久化 / 幂等无状态,靠 requestState 回传;一次性消费由服务端自行实现✅ 任务持久创建随实现发送消息 MAY 幂等,可用 messageId 判重
渲染客户端原生表单未定义App 自定义 UI未定义
敏感输入URL 模式带外处理AUTH_REQUIRED 独立状态

7.6 选型上的几条实践结论

综合下来,做 HITL 的路径选择其实比较清晰:

  • 同步、短、简单表单 → Elicitation form 模式。最省事,但记得它扛不住响应丢失。
  • 凭据 / OAuth 等敏感输入 → 必须 Elicitation URL 模式。这不只是建议,是规范要求。
  • 长任务、要跨轮次恢复 → MCP Tasks 的 input_requiredtaskId 恒定是刚需。
  • 需要富交互界面(多问题、条件分支、带附件的审批)→ MCP Apps + app-only 提交工具。Elicitation 的单层 schema 表达不了这类结构。
  • Agent 之间的委托 → A2A INPUT_REQUIRED,它的任务身份语义最完整。

最后一条容易被忽略的规范事实:ui/message 会触发模型新一轮推理。 它的 role 固定为 "user",宿主把它当作用户消息处理,写进去的内容会直接进入模型输入。如果只是想给模型补充背景而不触发新一轮,规范另有 ui/update-model-context——它不触发 follow-up,宿主还可以推迟到下一条用户消息时再交给模型。两者别混用。


八、怎么选:决策树

diag11.png

图 11:选型决策树

如果落到 MCP Apps 那一支,还有个后续问题值得问一句:其中某块面板是否需要动态生成? 如果是,就走第五章的「A2UI in MCP Apps」混用模式。

再补几条实战判据:

情况
需要 ECharts / Monaco / Three.js 这类特定 npm 库MCP Apps(catalog 词汇表覆盖不到)
接的是不可信第三方 Agent,还要跟自家界面无缝A2UI(这是它唯一独占的组合)
只想快速给一个 tool 加个可视化MCP Apps(成本低得多,且自动降级)
要做移动端原生体验A2UI(但 SwiftUI/Compose 官方渲染器还在路上)
Agent 需要根据用户回答动态改表单字段A2UI
服务方有强品牌视觉诉求MCP Apps
大量刷新/翻页交互,不想污染模型上下文MCP Apps(用 visibility: ["app"] 工具)

九、落地建议与坑

A2UI 侧

  1. 版本选 v0.9.1。v1.0 还是 RC,且所有官方渲染器对 v1.0 都标着 🚧 Planned。v0.8 是 legacy,消息名都不一样(beginRendering / surfaceUpdate)。
  2. 导入路径带版本号@a2ui/react/v0_9provideA2Ui from @a2ui/angular/v0_9——协议版本在编译期就分叉。
  3. LLM 输出必须 schema 校验。官方示例代码自己都写了注释:「直接 json.loads 很脆弱,LLM 经常加 Markdown 围栏」。务必用 jsonschema.validate 兜底,并接上 VALIDATION_FAILED 反馈闭环。
  4. 数据变换在 Agent 侧做完。渲染端不做日期格式化之类的计算,格式化好了再发。
  5. 别用 Basic Catalog 上生产。它是刻意做稀疏的。直接照你自己的设计系统写 catalog,不要写 adapter。

MCP Apps 侧

  1. CSP 默认拒绝,用到 CDN、外部 API 记得在 _meta.ui.csp 里显式声明,否则线上会静默失败。
  2. 善用 structuredContent。给 UI 的数据别塞进 content,会白白烧模型上下文。
  3. 纯 UI 交互用 visibility: ["app"]。刷新、分页、排序这些不该让模型看见。
  4. ontoolresult 要在 app.connect() 之前挂,否则会漏掉首次结果。
  5. 主题变量带 fallback,并监听主题切换通知——Host 会在用户切换明暗模式时下发。
  6. 别假设显示模式。你请求 fullscreen,Host 完全可能不给。

通用

  • 两者都是 2025 年底才成型的新协议,实现和规范都在快速演进,别在核心链路上做不可逆的强耦合;
  • 无论哪条路线,服务端的数据完整性校验都不能省——渲染端的校验只管 UX。

十、结语

回到标题那个问题——MCP Apps 能直接返回 HTML,为什么还需要 A2UI?

现在答案应该清楚了:因为 HTML 换来的表现力,代价是样式主权交给服务端、界面结构在注册期就被冻结、以及出了 Web 就要靠 WebView 兜底。A2UI 反过来,用组件目录的边界换回了这三样。

回到最初那句对比:

MCP Apps 让 Server 说:「这是我的界面,请原样显示。」 A2UI 让 Agent 说:「我需要一个表单和一个提交按钮,剩下的你看着办。」

前者用表现力换走了一致性和跨端能力,后者用词汇表的边界换来了统一体验、原生渲染和跨信任边界的安全。

这不是一场谁取代谁的战争。更可能的终局是:MCP Apps 成为 Agent 生态里「工具自带 UI」的事实标准,而 A2UI 成为「Agent 自己画界面」的通用语言——就像今天的网页里,既有 <iframe> 嵌入的第三方组件,也有服务端下发的 JSON 驱动的原生渲染。

它们最终会在同一个屏幕上共存,各干各擅长的活。


参考

  • MCP 规范2026-07-28 版 — Elicitation(client/elicitation)、Multi Round-Trip Requests(basic/patterns/mrtr
  • MCP Tasks 扩展modelcontextprotocol/ext-tasks,schema 2026-07-28
  • MCP Apps 规范modelcontextprotocol/ext-apps — SEP-1865,规范版本 2026-01-26
  • MCP Apps 文档docs/overview.mddocs/quickstart.mddocs/csp-cors.md
  • App 渲染 Elicitation:SEP-3118(评审中)及 ext-apps 相关 issue / PR
  • A2UI 项目a2ui.org · a2ui-project/a2ui(Apache 2.0)
  • A2UI 在线体验:Composer(可视化生成 JSON)、Theater(流式渲染演示)
  • A2A 协议a2a-protocol.org
  • AG-UIag-ui.com (CopilotKit 团队)
  • Flutter GenUI SDKdocs.flutter.dev/ai/genui

本文基于 MCP 规范 2026-07-28、MCP Apps 规范 2026-01-26、MCP Tasks 2026-07-28、A2A v1.0 与 A2UI v0.9.1 撰写。这些协议都在快速迭代,具体细节请以官方仓库为准。