MCP 实战手记系列(五)· 代码实操
系列(三)把服务改成无状态之后,工具返回的还是一串 JSON------给模型看刚好,给人看不行。用户在对话里想看一眼设备状态、想点一下开关,只能盯着文本数字符。MCP Apps 补的就是这一公里。这篇在 Spring AI 上把它跑通,代码在 tag
v05。
引子:JSON 是给模型看的,不是给人看的
以机房面板为例:模型读得懂 {"ac-01":true,"fan-02":false},用户看到的是一行天书------他想要的是三张卡片,点一下把风扇打开。
以前只有两条路:要么让模型吐一段 HTML(不安全、没法复用),要么自己再写一个前端(跟 MCP 就没关系了)。2026 年 1 月,官方把第三条路收成了扩展:MCP Apps(SEP-1865),现在是稳定版规范。
一、MCP Apps 是什么
拆开只有三个角色:界面资源 (一份 HTML,注册在 ui:// 协议下)、工具 (声明"我的结果有配套界面")、宿主(取资源、塞进沙箱 iframe、代理界面的调用)。前两个在服务端,第三个在聊天客户端。
关键是模板与数据分离 :ui:// 那份 HTML 是模板,动态数据走工具结果灌进去。它不是"让模型临时写一段 HTML",所以能缓存、能预加载、能复用。
界面和宿主之间走 postMessage 上的 MCP 风格 JSON-RPC :界面能拿到工具结果,也能反过来请求宿主调工具,但放不放行由宿主决定。
二、绑定写在工具元数据里,不在结果里
这是最容易理解错的一处。绑定是静态声明 ,宿主在 tools/list 阶段就能读到,不用等调用完再猜:
json
{
"name": "get_room_dashboard",
"_meta": { "ui": { "resourceUri": "ui://room-dashboard", "visibility": ["model"] } }
}
visibility 是两个可见范围:model 表示模型能当普通工具调,app 表示只留在宿主侧给界面用。我给控制设备的工具设的是 ["app"]------点按钮能关设备,但模型不能直接关。
三、动手:三段代码
代码在 com.ethanliang.mcp.apps,tag v05。沿用系列(三)的无状态配置。
① 界面资源:一个方法返回 HTML,mimeType 是宿主识别「这是个 MCP App」的依据。
java
@McpResource(uri = "ui://room-dashboard", name = "room-dashboard",
mimeType = "text/html;profile=mcp-app")
public String roomDashboardUi() { return DashboardHtml.TEMPLATE; }
② 工具绑定:
java
@McpTool(name = "get_room_dashboard", description = "获取机房仪表盘数据...",
generateOutputSchema = true,
metaProvider = DashboardUiMetaProvider.class, // ← 界面绑定,关键就这一行
annotations = @McpTool.McpAnnotations(readOnlyHint = true, destructiveHint = false))
public RoomDashboard getRoomDashboard() { ... }
顺手提醒:MCP 默认 destructiveHint=true,只读查询不改掉的话,宿主会把它当破坏性操作。
③ MetaProvider (实现 MetaProvider,注册成 @Component 即可):
java
@Override
public Map<String, Object> getMeta() {
return Map.of("ui", Map.of("resourceUri", "ui://room-dashboard",
"visibility", List.of("model")));
}
四、怎么验证真的生效
起服务(端口 8085)后三条 curl 就能验完,不需要真实宿主:
bash
curl -sS localhost:8085/mcp -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
tools/list→ 工具带_meta.ui.resourceUriresources/list→ 返回{"uri":"ui://room-dashboard","mimeType":"text/html;profile=mcp-app"}tools/call→ 同时给structuredContent(界面用)和content[0].text(不支持的宿主用)
第二条最关键:mimeType 写错,宿主就不认这是界面,只会当普通文本资源,界面永远出不来。
五、踩坑记录
① @Tool 挂不上 _meta。 Spring AI 的 ToolDefinition 只有 name / description / inputSchema,没有 meta ,@Tool + MethodToolCallbackProvider 这条路声明不了界面,必须走 @McpTool(metaProvider = ...)。
② 启动日志里那句 WARN 是误导。 会打 No resource methods found in the provided resource objects,但 resources/list 照样把资源返回了。以协议返回为准,别被日志骗去改代码。
③ 界面必须自包含。 宿主在 deny-by-default 的 CSP 下渲染,外链 CDN 脚本一律被拦,界面直接白板。CSS/JS 全内联,要用第三方库就打进同一份 HTML;确实要连外部域,得在资源的 _meta.ui.csp 里显式声明------宿主只会收紧,不会放宽。
④ 文本回退不能省。 structuredContent 喂界面,content[0].text 喂不支持的宿主。关键结论不能只藏在界面里------可访问性、日志审计、自动化测试都还指着文本这条链。
⑤ visibility 只是声明。 visibility: ["app"] 说的是"模型别调",宿主不执行就等于没设。渲染权限和业务权限得分开管,不能因为界面上多了个按钮就自动放行高风险操作。
⑥ Windows 下 curl 传 JSON 会被引号吃掉 ,服务端只报 Failed to deserialize message: Failed to read value。把 body 写进文件、用 --data-binary @file 更稳。
六、还没解决什么
Java 侧只负责声明资源和返回 HTML,渲染完全在宿主------Claude、VS Code Copilot、Microsoft 365 Copilot、Postman 这些都已支持。没有支持 MCP Apps 的宿主,这篇的代码跑得起来,但界面看不见------协议层能验,视觉效果得靠宿主。
信任边界也要想清楚:自有可控的工具适合 MCP Apps;不可信的远程智能体更适合 A2UI 那种不执行外部代码的路线,选错后面补安全成本高得多。
小结
- 绑定在工具元数据 :
_meta.ui.resourceUri指向ui://资源,宿主发现工具时就知道有界面 - Spring AI 要走
@McpTool+metaProvider,@Tool那条路没有 meta 入口 - 界面自包含、文本留回退,漏一条界面就是白板或黑盒
完整可运行代码:github.com/ethanliang2016/mcp-in-action(tag:
v05),目录05-mcp-apps。
MCP 实战手记系列路线图
| # | 篇目 | 状态 |
|---|---|---|
| 1 | 总纲篇:MCP 到哪一步了 | ✅ |
| 2 | 跑通第一个 MCP Server | ✅ |
| 3 | 把 MCP Server 改成无状态 | ✅ |
| 4 | CIMD 授权实战 | ✅ |
| 5 | 让工具返回一个能点的界面(本篇) | ✅ |
| 6 | 自建 MCP 网关 | 规划 |
| 7 | MCP 安全接入检查清单 | 规划 |
关注我,更新第一时间看到。你在 MCP 上最想让哪类工具长出界面?评论区见,有价值的我整理进后续篇目。
参考: