LocalAI 测试 MCP Apps(交互式工具 UI):从 Docker 测试服务器到浏览器端 iframe 桥接的完整实战指南
MCP Apps 是 Model Context Protocol 的扩展:工具通过 _meta.ui.resourceUri 声明一个交互式 HTML 界面,当 LLM 调用该工具时,宿主(Host)会在聊天中以沙箱 iframe 内联渲染这个应用。本文以 LocalAI 仓库中的测试手册 .agents/testing-mcp-apps.md 为骨架展开:你将学会用一行 Docker 命令起一个带 React 时钟 UI 的 MCP 测试服务器,用 JSON-RPC 请求验证协议正确性,再在 LocalAI 的 Chat 页面接入该服务器、观察双向通信日志,并理解 LocalAI 前端 MCPAppFrame、useMCPClient、useChat 三个核心文件的实现原理与 CORS 代理的安全边界。
一、MCP Apps 是什么:协议层的关键声明
普通 MCP 工具只返回文本或图片;MCP Apps 扩展让工具声明一个 UI 资源:
- 工具定义中携带
_meta.ui.resourceUri,指向一段 HTML 资源; - 应用通过
postMessage(JSON-RPC 格式)与宿主双向通信:可以调用服务端工具、向聊天发送消息、更新模型上下文; - 整个 MCP App 协议在浏览器端完成,LocalAI 服务端无需任何改动。
在 LocalAI 中,判定“一个工具是否带 App UI”依赖官方包 @modelcontextprotocol/ext-apps 提供的 getToolUiResourceUri 与 isToolVisibilityAppOnly 两个辅助函数,见 useMCPClient.js 第 5 行的导入。
二、快速上手:在 Docker 中运行 MCP Apps 测试服务器
文档推荐用 @modelcontextprotocol/server-basic-react 这个现成的测试包:它暴露一个 get-time 工具,带一个交互式 React 时钟 UI。由于该包要求 Node >= 20,文档直接在 Docker 中运行(映射 3001 端口):
docker run -d --name mcp-app-test -p 3001:3001 node:22-slim \
sh -c 'npx -y @modelcontextprotocol/server-basic-react'
等待约 10 秒后按顺序验证:
# 1. 确认进程启动
docker logs mcp-app-test
# 预期输出: "MCP server listening on http://localhost:3001/mcp"
# 2. 验证 MCP 协议握手(initialize)
curl -s -X POST http://localhost:3001/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0.0"}}}'
# 3. 列出工具,应看到 get-time 带 _meta.ui.resourceUri
curl -s -X POST http://localhost:3001/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'
tools/list 的响应中应包含:
{
"name": "get-time",
"_meta": {
"ui": { "resourceUri": "ui://get-time/mcp-app.html" }
}
}
这个 resourceUri 正是后续 LocalAI 前端通过 readResource 拉取 HTML 的地址。@modelcontextprotocol/ext-apps 仓库中还有更多示例服务器(均支持 stdio 与 HTTP 两种传输,不带 --stdio 参数时默认以 HTTP 模式监听 3001 端口),server-basic-react 只是最简单的时钟示例。
三、在 LocalAI UI 中接入并触发 MCP App
前置条件:LocalAI 正在运行(例如 http://localhost:8080),且 React UI 已构建:
cd core/http/react-ui && npm install && npm run build
操作步骤:
- 浏览器打开 LocalAI 的 Chat 页面;
- 点击聊天头部工具栏中的 "Client MCP";
- 新增一个客户端 MCP 服务器:
- URL:
http://localhost:3001/mcp - Use CORS proxy:启用(默认开启)——文档给出的理由是浏览器无法直接跨域访问
localhost:3001,由 LocalAI 的/api/cors-proxy代理转发;
- URL:
- 服务器连接后应发现
get-time工具; - 选择模型,发送消息 "What time is it?";
- LLM 应调用
get-time工具; - 工具结果以独立聊天消息的形式在 iframe 中渲染出交互式 React 时钟(而不是折叠进 activity 分组)。
关于 CORS 代理的重要限制:从源码看,代理端点 cors_proxy.go 内置了 SSRF 防护:
localhost、*.local、云 metadata 域名会被直接拒绝(第 49-53 行),所有解析出的 IP 必须通过utils.IsPublicIP判定为公网地址(第 59-63 行),否则返回 403。因此对于纯本地回环的测试服务器,启用 CORS proxy 实际上可能无法连通——此时应关闭 CORS proxy 让浏览器直连,或把测试服务器部署到可公网访问的地址再走代理。这一点值得在实际测试时留意。
代理路由的注册位置在 routes/localai.go 第 492-494 行,同时注册了 GET/POST 与 OPTIONS 预检,前端侧的默认代理路径配置在 config.js(corsProxy: '/api/cors-proxy')。
四、逐项验证清单(What to Verify)
文档给出了完整的验收清单,逐项对应具体的 UI 行为:
- [ ]
get-time出现在已连接工具列表中且未被过滤(它对 LLM 可调用); - [ ] iframe 以独立聊天消息形式渲染,带拼图(puzzle-piece)图标;
- [ ] 应用可加载且可交互(时钟走动、按钮可用);
- [ ] 不出现 "Reconnect to MCP server" 遮罩层(表示连接存活);
- [ ] 控制台日志能看到双向通信(见下一节);
- [ ] 应用渲染后 LLM 继续生成包含时间的文本回复;
- [ ] 非 UI 工具仍按纯文本结果正常工作;
- [ ] 刷新页面后 HTML 以静态形式展示,直到重新连接才出现重连遮罩。
其中"独立消息而非折叠分组"的行为由 Chat.jsx 中的 ActivityGroup 组件实现:它把带 appUI 字段的 tool_result 项与普通项分离(第 112-114 行),前者单独渲染 MCPAppFrame(第 166-180 行)。"重连遮罩"则来自 MCPAppFrame.jsx 第 97-101 行:当 mcpClient 为空(例如刷新页面后连接丢失)时,iframe 保留静态 HTML 并叠加提示层。
五、控制台日志模式:健康的双向通信长什么样
浏览器控制台中,一次健康的 MCP App 会话呈现如下 JSON-RPC 序列:
Parsed message { jsonrpc: "2.0", id: N, result: {...} } // Bridge 初始化
get-time result: { content: [...] } // 收到工具结果
Calling get-time tool... // 应用调用工具
Sending message { method: "tools/call", ... } // 应用 -> 宿主 -> 服务器
Parsed message { jsonrpc: "2.0", id: N, result: {...} } // 服务器响应
Sending message text to Host: ... // 应用发送消息
Sending message { method: "ui/message", ... } // 消息通知
Message accepted // 宿主确认
以下告警可安全忽略:
Source map error: ... about:srcdoc——devtools 找不到 srcdoc iframe 的 sourcemap;Ignoring message from unknown source——iframe 导航产生的重复 postMessage;notifications/cancelled——应用清理上一次请求。
六、源码级原理:LocalAI 如何渲染 MCP App
6.1 工具结果的 UI 探测:useMCPClient
useMCPClient.js 暴露了四个 App UI 专用辅助方法(第 179-212 行):
| 方法 | 作用 |
|---|---|
hasAppUI(toolName) |
用 getToolUiResourceUri 判断工具是否声明了 UI 资源 |
getAppResource(toolName) |
通过 client.readResource({ uri }) 拉取 UI HTML,返回 { html, meta },其中 meta 即工具上的 _meta.ui |
getClientForTool(toolName) |
返回该工具所属 MCP 服务器的 Client 实例,供 iframe 桥接复用 |
getToolDefinition(toolName) |
返回完整工具定义(含 _meta) |
在 Chat 页面,handleSend 把这些能力组装进 getToolAppUI 回调(Chat.jsx 第 889-901 行):先 hasAppUI 判定,再取资源,最终返回包含 html、meta、toolName、toolInput、toolDefinition 与归一化后的 toolResult 的对象。
6.2 Agentic 循环中的 appUI 挂载:useChat
useChat.js 第 766-791 行实现了客户端工具执行循环:每个 tool_call 经 options.executeTool 执行后,若提供 options.getToolAppUI 则调用它获取 UI 资源,并把 appUI 字段附加到 tool_result 消息上。注意 Chat.jsx 第 888 行将 maxToolTurns 设为 10,即单轮对话内最多 10 次客户端工具往返。
6.3 iframe 与桥接:MCPAppFrame
MCPAppFrame.jsx 是渲染层核心,关键实现:
- 传输层:
new PostMessageTransport(iframe.contentWindow, iframe.contentWindow)包裹宿主与srcdociframe 之间的window.postMessage(第 18 行); - 桥接层:
AppBridge(来自@modelcontextprotocol/ext-apps/app-bridge,第 19-24 行)自动把应用侧的tools/call、resources/read、resources/list经由宿主的 MCPClient转发到服务器,宿主自报身份为{ name: 'LocalAI', version: '1.0.0' },上下文声明displayMode: 'inline'; - 初始化时序:
bridge.oninitialized中把之前收到的toolInput与toolResult补发给应用;若工具结果晚于桥接建立到达,useEffect(第 60-64 行)会再次sendToolResult; - 沙箱安全:iframe 使用
sandbox="allow-scripts allow-forms"(第 89 行),不含allow-same-origin——应用运行在不透明源(opaque origin)下,无法访问宿主 Cookie、DOM 或 localStorage;若资源声明了permissions,还会经buildAllowAttribute生成allow属性(第 81-82 行); - 尺寸自适应:
onsizechange回调把 iframe 高度限制在 600px 以内(第 31-33 行);外链请求经onopenlink用window.open(url, '_blank', 'noopener,noreferrer')打开(第 35-38 行); - 卸载清理:组件卸载时仅
bridge.close()关闭本地传输,刻意不发送teardownResource(第 66-77 行注释)——否则会销毁服务端状态,导致流式结束、ActivityGroup接管StreamingActivity组件重挂载时出现 "Connection closed" 错误。
6.4 工具可见性过滤
标记为 _meta.ui.visibility: "app-only" 的工具会从 LLM 可见的工具列表中过滤掉,但仍可被应用 iframe 调用——对应的判定函数 isToolVisibilityAppOnly 同样导入自 @modelcontextprotocol/ext-apps/app-bridge(useMCPClient.js 第 5 行)。这与验证清单第一条相互印证:get-time 未标记 app-only,所以它既对 LLM 可见、也可被应用调用。
七、关键文件索引与清理
与 MCP Apps 实现直接相关的文件:
- core/http/react-ui/src/components/MCPAppFrame.jsx —— iframe + AppBridge 组件;
- core/http/react-ui/src/hooks/useMCPClient.js —— MCP 客户端 hook,含
hasAppUI/getAppResource/getClientForTool/getToolDefinition; - core/http/react-ui/src/hooks/useChat.js —— agentic 循环,为 tool_result 消息附加
appUI; - core/http/react-ui/src/pages/Chat.jsx —— 将 MCPAppFrame 渲染为独立聊天消息;
- core/http/endpoints/localai/cors_proxy.go —— 浏览器直连外部 MCP 服务器的 CORS 代理(含 SSRF 防护与测试 cors_proxy_test.go);
- core/http/routes/localai.go ——
/api/cors-proxy路由注册。
测试结束后的清理只需一行:
docker rm -f mcp-app-test
八、小结
LocalAI 对 MCP Apps 的支持是一套纯浏览器端方案:PostMessageTransport 提供宿主与 srcdoc iframe 间的 JSON-RPC 通道,AppBridge 自动转发工具调用与资源读取,沙箱属性保证应用隔离。测试路径非常短——Docker 一行命令起 get-time 时钟服务器、curl 验证 initialize 与 tools/list、UI 中接入 Client MCP 并发送 "What time is it?"——随后用文档给出的日志模式与验证清单逐项核对即可确认双向通信、独立消息渲染与重连行为均符合预期。需要特别留意的是 CORS 代理对私网/回环地址的拒绝策略:本地回环测试服务器应直连,代理路径面向公网服务器。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00