MCP Apps (ext-apps) — 架构与原理
30 秒导读: MCP 工具默认只能返回文本和结构化数据。MCP Apps 这个扩展让一个工具能额外声明一份 HTML UI 资源;当模型调用该工具时,聊天客户端(Claude、ChatGPT、VS Code…)把这份 HTML 渲染成一个沙箱 iframe 内联在对话里——图表、表单、画布、播放器都行。这个仓库(
@modelcontextprotocol/ext-apps)是实现这套协议的官方 TypeScript SDK。
1. 这是什么(零基础也能懂)
一句话定义: MCP Apps 是 MCP(Model Context Protocol)的一个扩展,在“工具返回文本”之上加了一条“工具可以返回一块会动的网页 UI”。
它解决谁的什么问题。 假设你写了一个 MCP 服务器,有个 get-weather 工具。在纯 MCP 里,工具调用完只能往对话里塞一段文字(“东京,晴,24°C”)。但天气更适合用一张带图标、可切换城市的卡片来展示。MCP Apps 让你给这个工具配一份 HTML,客户端就把这张卡片直接画在对话气泡里,用户还能点它、它还能反过来再调工具拿新数据。
三个角色,先记住名字(后面每一章都在讲它们):
| 角色 | 中文 | 它是谁 | 在 SDK 里用哪个类 |
|---|---|---|---|
| View | 视图 | 跑在 iframe 里的那份 UI(你的 HTML+JS) | App(src/app.ts) |
| Host | 宿主 | 嵌着 iframe 的聊天客户端 | AppBridge(src/app-bridge.ts) |
| Server | 服务器 | 提供工具和 UI 资源的 MCP 后端 | registerAppTool 等(src/server/index.ts) |
一句话直觉/类比: 把 View 想成一个没有网络、被关在玻璃罩里的网页。它什么外部请求都发不出去,只能隔着玻璃跟 Host 喊话(postMessage);需要数据时,喊“帮我调一下服务器的工具”,由 Host 代它去真正的 MCP 服务器跑一趟,再把结果递进来。玻璃罩 = 沙箱 iframe;喊话 = JSON-RPC over postMessage;代跑 = Host 的请求转发。
用起来什么样(最小真实示例)。 服务器端把工具和 UI 资源绑在一起(examples/quickstart/server.ts):
// 示意,改写自 examples/quickstart/server.ts
const resourceUri = "ui://get-time/mcp-app.html"; // UI 资源的地址
registerAppTool(server, "get-time", {
description: "Returns the current server time.",
_meta: { ui: { resourceUri } }, // 把工具链到这份 UI
}, async () => {
return { content: [{ type: "text", text: new Date().toISOString() }] };
});
registerAppResource(server, resourceUri, resourceUri, {}, async () => ({
contents: [{ uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html }],
})); // 返回那份 HTML
UI 端(跑在 iframe 里,examples/quickstart/src/mcp-app.ts):
// 示意,改写自 examples/quickstart/src/mcp-app.ts
const app = new App({ name: "Get Time App", version: "1.0.0" });
app.ontoolresult = (result) => { // 收到工具结果就刷新界面
const time = result.content?.find((c) => c.type === "text")?.text;
document.getElementById("server-time")!.textContent = time ?? "[ERROR]";
};
app.connect(); // 与 Host 握手
本节到此不碰底层。你只要记住:一个工具 + 一份 HTML,客户端就把 UI 内联渲染出来,并保持 UI 和服务器双向通信。
2. 顶层全景(它大概怎么转)
数据流主线
核心是一条三跳链路:View ↔ Host ↔ MCP Server。View 永远不直接连服务器,所有跨边界请求都被 Host 代理。
postMessage(JSON-RPC) MCP 客户端连接
┌────────────┐ ───────────────▶ ┌──────────┐ ──────────▶ ┌────────────┐
│ View │ ui/initialize │ Host │ tools/call │ MCP Server │
│ (iframe) │ tools/call(代调) │AppBridge │ resources/ │ 你的后端 │
│ App 类 │ ◀─────────────── │ │ ◀────────── │ │
└────────────┘ tool-input/result └──────────┘ 结果 └────────────┘
玻璃罩(沙箱) 代理人 真正干活的
怎么读:左边是关在沙箱里的 UI,中间是聊天客户端兼“翻译/守门人”,右边是真正的服务器。 箭头向右是 View 发起的请求(被 Host 转发),箭头向左是 Host 推给 View 的通知(工具输入、结果、主题变化)。