A2UI in MCP Apps:Angular 宿主容器的双 iframe 安全隔离架构与实现解析
【免费下载链接】a2ui项目地址: https://gitcode.com/GitHub_Trending/a2/a2ui
本文围绕 A2UI 项目中a2ui-in-mcpapps示例的 MCP Host 应用组件展开,深入解析其如何以双 iframe 代理模式安全托管由 MCP 服务器提供的不受信任第三方 Angular 微应用(MCP Apps),实现工具调用转发、布局消息透传与 UI 无关的隔离沙箱基础设施。读完本文,你将掌握该宿主的完整通信协议(ui/initialize、tools/call中继、ui://资源加载)、工具可见性白名单机制,以及从构建沙箱桥接到启动服务端与客户端的完整实操流程。
一、关联文档与示例定位
本篇文章的核心文档位于 samples/community/mcp/a2ui-in-mcpapps/client/src/app/README.md,它描述的是 MCP Host Application 的核心应用组件。该示例所在的完整目录为 samples/community/mcp/a2ui-in-mcpapps,整体架构由三部分构成:
- Client(宿主应用):Angular 编写的宿主容器,持有外层安全 iframe,是本文的主角;
- Server(MCP 服务器):基于 Python/uv 的 MCP 服务器,提供工具(tools)与微应用资源(resources),实现位于 server.py;
- Isolated Micro-Apps(隔离微应用):由服务器提供、在宿主内渲染的不受信任组件,包括 Basic 计数器应用与 Editor 生成式编辑器应用。
该宿主应用的核心设计原则是:对 MCP 服务器下发的不受信任第三方组件进行安全隔离与托管,同时自身不持有任何 A2UI 或应用特定的渲染知识——它只管理隔离沙箱所需的基础设施框架,实现"UI 无关的隔离基础设施"(UI-Agnostic Isolation Infrastructure)。这意味着宿主可以托管任何符合 MCP Apps 规范的微应用,而无需了解其内部 UI 逻辑。
二、核心特性:四层职责拆解
根据文档,该宿主应用具备四项关键能力:
| 特性 | 说明 |
|---|---|
| 安全隔离(Security Isolation) | 实现安全的双 iframe 代理模式(double-iframe proxy pattern),对不受信任组件进行隔离测试,在维持功能的同时防止安全泄露 |
| 宿主容器(Host Container) | 作为外层安全 iframe 的主容器 |
| 工具与消息透传(Tool & Message Passthrough) | 作为通信桥梁,在隔离 iframe 与数据库/MCP 服务器之间路由自定义工具调用与布局消息 |
| UI 无关隔离基础设施 | 不持有任何 A2UI 或应用特定渲染知识,仅管理隔离沙箱所需的基础设施框架 |
其中"工具与消息透传"提到的"数据库/MCP 服务器",在源码层面体现为宿主通过@modelcontextprotocol/sdk的Client与 Python MCP 服务器建立 SSE 连接,并将微应用发出的tools/call请求转发给服务器,服务器返回的 A2UI JSON 载荷再回传至微应用进行渲染。
三、双 iframe 代理模式:源码级深入
3.1 沙箱代理的初始化与安全自检
双 iframe 模式的中间层——沙箱代理——位于 samples/client/shared/mcp_apps_inner_iframe/sandbox.ts。它通过yarn build:sandbox构建为独立 bundle,输出到宿主客户端的public/sandbox_iframe/sandbox.{js,html},随后由宿主在connectAndLoadApp()中设置 iframe 的src指向该沙箱页面:
this.appIframe.nativeElement.src = '/sandbox_iframe/sandbox.html?disable_security_self_test=true';沙箱代理加载后依次执行以下安全检查:
- iframe 使用校验:
window.self === window.top时直接抛错,确保该文件只在 iframe 沙箱内运行; - referrer 校验:无
document.referrer则拒绝加载;默认只允许http://localhost或127.0.0.1开头的嵌入来源,生产环境可通过VITE_ALLOWED_HOST_ORIGIN环境变量配置允许的宿主来源; - 安全自检(security self-test):默认调用
window.top.alert(...),若能成功弹出说明隔离失效,直接抛错拒绝运行;仅在 URL 带disable_security_self_test=true时跳过(供测试使用)。
3.2 内层 iframe 的沙箱属性
代理随后创建一个内层 iframe 承载不受信任的 HTML 内容,并刻意省略allow-top-navigation与allow-top-navigation-by-user-activation,防止嵌入脚本劫持顶层窗口导航(frame-busting 防护):
const inner = document.createElement('iframe'); inner.style.cssText = 'width:100%; height:100%; border:none;'; inner.setAttribute('sandbox', 'allow-scripts allow-forms allow-popups allow-modals'); document.body.appendChild(inner);结合外层宿主 iframe,形成三层结构:Angular 宿主页面 → 沙箱代理 iframe(独立安全边界)→ 内层 iframe(运行不受信任微应用)。宿主只与代理通信,代理与微应用通信,微应用永远无法直接触达顶层窗口。
3.3 资源投递握手
代理与宿主之间通过两条专用通知完成 HTML 资源投递:
- 代理就绪后向宿主发送
ui/notifications/sandbox-proxy-ready; - 宿主收到后回发
ui/notifications/sandbox-resource-ready,并在params.html中携带微应用的完整 HTML 内容。
宿主侧对应处理逻辑位于 app.ts 的ngAfterViewInit()消息监听中。这一设计保证了:只有代理确认就绪后,宿主才会把不受信任的 HTML 注入沙箱,避免注入时机竞态带来的安全风险。
四、宿主容器的消息协议与中继实现
宿主在ngAfterViewInit()中注册统一的window.addEventListener('message', ...),并执行两道来源校验:event.origin必须等于window.location.origin,且event.source必须是appIframe的contentWindow。只有通过校验的消息才会进入协议分发,防止第三方页面伪造消息。宿主支持的消息方法如下:
| 方法 | 方向 | 宿主行为 |
|---|---|---|
ui/notifications/sandbox-proxy-ready | 代理 → 宿主 | 回发ui/notifications/sandbox-resource-ready,携带html内容 |
ping | 代理 → 宿主 | 原样回包{jsonrpc:'2.0', id, result:{}},用于探测连通性 |
ui/initialize | 代理 → 宿主 | 返回protocolVersion: '2026-01-26'、hostInfo、hostCapabilities、hostContext(displayMode: 'inline') |
ui/notifications/initialized | 代理 → 宿主 | 微应用已初始化,宿主随后投递ui/notifications/tool-input(实例化工具调用的入参)与ui/notifications/tool-result(调用结果) |
ui/notifications/size-changed | 代理 → 宿主 | 根据params.height动态调整 iframe 高度,实现微应用自适布局 |
tools/call | 代理 → 宿主 | 校验工具名是否在白名单内,通过后经 MCP Client 转发至服务器,结果回传代理 |
4.1 布局消息透传
微应用通过ui/notifications/size-changed通知宿主其内容高度,宿主据此实时调整 iframe 的style.height。这正是文档所说"路由布局消息"的具体落地,让宿主无需理解微应用内部 UI 即可完成自适应布局。
4.2 工具调用中继与白名单拦截
当微应用发起tools/call时,宿主先检查this.allowedTools.has(toolName):
- 不在白名单内:打印
[Host] Blocked unauthorized tool call: ...,并以 JSON-RPC 错误码-32000返回Tool '...' is not whitelisted.,拒绝执行; - 在白名单内:调用
this.mcpClient.callTool({name, arguments})转发至服务器,成功与失败均以 JSON-RPC 格式回传代理。
该白名单的构建逻辑在下节详述。从实现可见,宿主在安全上采取默认拒绝策略:只放行显式声明可被应用调用(visibility包含"app")的工具。
五、工具可见性白名单:_meta.ui.visibility与ui://资源
5.1 白名单构建规则
宿主的connectAndLoadApp()在连接 MCP 服务器后先调用client.listTools(),遍历所有工具并按_meta.ui.visibility构建allowedTools:
this.allowedTools = new Set( tools .filter(tool => { const visibility = (tool._meta as any)?.ui?.visibility; return Array.isArray(visibility) ? visibility.includes('app') : APP_CALLABLE_WHEN_VISIBILITY_UNDECLARED; }) .map(tool => tool.name), );规则如下:
- 显式声明:
visibility为数组且包含'app'时放行(模型与应用均可调用); - 未声明:按 MCP Apps 规范默认视为
["model", "app"],即默认可被应用调用。源码中通过常量APP_CALLABLE_WHEN_VISIBILITY_UNDECLARED = true显式表达这一宽松默认值,并注释说明"更严格的宿主可以选择拒绝未显式声明 app 可见性的工具"。
5.2 服务器端的工具声明
对应地,服务器在 server.py 中通过_meta.ui声明两类工具:
- 入口工具(仅模型可调,
visibility: ["model"],声明resourceUri):get_basic_app指向ui://basic/app,get_editor_app指向ui://editor/app; - 应用可调工具(
visibility: ["app"]):fetch_counter_a2ui、increase_counter、smart_editor_get_controls、smart_editor_apply。
入口工具通过_meta.ui.resourceUri预先声明 UI 模板(ui://协议),宿主据此调用resources/read拉取微应用 HTML,而不会在工具结果中内嵌资源——这与旧式的"结果内嵌 UI"方案形成关键差异。
5.3ui://资源加载链路
宿主加载微应用的完整链路为:
client.listTools()发现入口工具及其_meta.ui.resourceUri;- 校验
resourceUri为ui://开头,否则抛错; client.callTool({name: entryTool})触发工具,结果暂存为toolCallResult;client.readResource({uri: resourceUri})读取微应用 HTML;- 校验资源
mimeType === 'text/html;profile=mcp-app'或包含text字段,将text存入this.htmlContent; - 设置 iframe
src指向沙箱页面,待sandbox-proxy-ready后投递 HTML。
服务器端read_resource严格按 MCP Apps 要求,在resources/read返回的内容上携带text/html;profile=mcp-appMIME 类型(而不仅是resources/list中声明),并映射ui://basic/app→apps/public/app.html、ui://editor/app→apps/public/editor.html。
5.4 工具结果与输入投递时序
微应用完成ui/initialize后发送ui/notifications/initialized,宿主在此之前不得向微应用发送消息;收到该通知后,宿主按序投递:
ui/notifications/tool-input(params.arguments)——实例化该 View 的工具调用入参;ui/notifications/tool-result(params)——调用结果,其中携带application/a2ui+jsonMIME 类型的嵌入式资源(如simple_counter_a2ui.json中的初始计数器载荷、dataModelUpdate增量更新)。
例如点击计数器按钮后,微应用经代理向宿主发送tools/call(increase_counter),服务器返回dataModelUpdate载荷更新counter数值,再沿链路回传微应用并刷新 A2UI 渲染。
六、端到端通信时序
以下序列图完整描述了从宿主加载到 A2UI 组件交互的十一条消息链路(源自示例根文档 README.md 的架构图):
该链路揭示了两个关键事实:
- 微应用自身负责 A2UI 渲染:步骤 7~9 说明 A2UI Surface 由微应用内部渲染,宿主不参与;A2UI 按钮触发
UserAction后由微应用映射为tools/call请求; - 宿主只做中继:无论工具调用还是 A2UI 载荷,宿主仅校验、转发、回传,不解析内容——这正是"UI 无关隔离基础设施"的直接体现。
七、宿主 UI 与交互模板
宿主模板 app.html 提供极简交互界面:
- 状态栏:
Status: {{ status() }},反映Not connected、Connecting to MCP Server...、Initializing MCP Client...、Listing tools...、Calling MCP App tool...、Reading resource: ...、App loaded successfully!、Error: ...等完整生命周期; - 应用选择器:
editor(Generative Editor App)与basic(Basic Counter App)两个选项,通过onAppChange()校验并更新selectedApp; - 加载按钮:触发
connectAndLoadApp(),加载期间禁用选择器与按钮; - 内容区:宽度 100%、高度 600px 的 iframe(
#appIframe),由宿主在加载完成后设置其src指向沙箱页面。
其中onAppChange只接受'editor' | 'basic'两个枚举值,其他输入记录错误并忽略,防止非法应用注入。测试用例 app.spec.ts 验证了组件可创建且渲染Simple MCP Apps Host标题。
八、构建与运行:从零到完整链路
8.1 前置依赖
- Node.js(推荐 LTS);
- uv(Python 版本由
server/.python-version自动管理); - 仓库根目录执行
yarn install链接 workspace 包。
客户端依赖栈(见 client/package.json)包括@angular/core21.x、@modelcontextprotocol/sdk^1.29.0、@modelcontextprotocol/ext-apps^1.7.4 等。
8.2 第一步:构建客户端沙箱桥接
在client/目录执行:
cd client yarn install yarn build:sandbox该命令通过esbuild将 sandbox.ts 打包为client/public/sandbox_iframe/sandbox.js,并复制sandbox.html到同目录——这是沙箱代理的运行时资产,宿主页面依赖此产物才能加载。
8.3 第二步:构建微应用
服务器从server/apps/public/目录伺服单文件 HTML 产物。这些产物被 git 忽略、不随仓库分发,因此必须至少构建一个应用后才能加载其界面(服务器本身即使没有产物也能启动)。两种选择:
选项 A:Editor 应用
cd server/apps/editor yarn install yarn build:all生成server/apps/public/editor.html。
选项 B:Basic 应用
cd server/apps/src yarn install yarn build:all生成server/apps/public/app.html。
构建流程(详见 server/apps/README.md):先由 Angular 编译原始产物到dist/raw,再由inline.js将全部 JavaScript 与 CSS动态内联进index.html,输出单一自包含文件。这是 MCP App 安全隔离要求的必然结果——沙箱 iframe 内的应用必须自包含,不能依赖额外的网络资源加载。
8.4 第三步:启动 MCP 服务器
在server/目录执行:
cd server uv sync uv run python server.py --transport sse --port 8000服务器参数:--transport可选stdio或sse(默认sse),--port默认8000。SSE 模式下基于 Starlette 暴露/sse端点与/messages/消息挂载点,并启用 CORS(示例中allow_origins=["*"],源码注释明确警告生产环境必须限制为客户端来源,例如http://localhost:4200)。
8.5 第四步:启动宿主客户端
在client/目录执行:
cd client yarn start浏览器访问http://localhost:4200。宿主通过SSEClientTransport连接http://127.0.0.1:8000/sse(该地址硬编码于 app.ts 的connectAndLoadApp()),随后即可选择应用并加载。
九、安全设计要点总结
| 层次 | 防护措施 | 源码依据 |
|---|---|---|
| 沙箱结构 | 双 iframe 代理 + 内层 iframe 的sandbox属性仅开放allow-scripts allow-forms allow-popups allow-modals,禁止顶层导航 | sandbox.ts |
| 来源校验 | 宿主消息监听校验event.origin与event.source;代理校验 referrer 与 iframe 上下文 | app.ts、sandbox.ts |
| 隔离自检 | 默认执行window.top.alert安全自检,隔离失效则拒绝加载 | sandbox.ts |
| 工具白名单 | 仅放行visibility含"app"的工具,其余以 JSON-RPC 错误-32000拒绝 | app.ts |
| 内容自包含 | 微应用构建为单文件 HTML,不依赖外部资源 | server/apps/README.md |
十、适用边界与延伸阅读
本文所述宿主是 MCP Apps 规范的参考级实现:它严格遵循标准隔离沙箱容器预期,未引入任何额外自定义托管机制,因而可作为自建 MCP Apps 宿主的可对照范本。需要特别说明的边界包括:工具调用白名单的"未声明即放行"是面向示例的宽松默认(APP_CALLABLE_WHEN_VISIBILITY_UNDECLARED = true),生产宿主可按需收紧为默认拒绝;CORS 全放开与 SSE 地址硬编码同样仅适用于本地开发。
如需继续深入,建议阅读同仓库内的关联材料:
- 示例整体说明与通信时序:samples/community/mcp/a2ui-in-mcpapps/README.md;
- 宿主核心实现:app.ts;
- 服务器工具与资源定义:server.py;
- 沙箱代理实现:sandbox.ts;
- 微应用内联构建流程:server/apps/README.md。
【免费下载链接】a2ui项目地址: https://gitcode.com/GitHub_Trending/a2/a2ui
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考