☰
MCP实战手记系列(五):让工具返回一个能点的界面(文末附github源码链接)
2026/10/1 17:07:01 网站建设 项目流程

MCP 实战手记系列(五)· 代码实操
系列(三)把服务改成无状态之后,工具返回的还是一串 JSON——给模型看刚好,给人看不行。用户在对话里想看一眼设备状态、想点一下开关,只能盯着文本数字符。MCP Apps 补的就是这一公里。这篇在 Spring AI 上把它跑通,代码在 tagv05。

引子: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阶段就能读到,不用等调用完再猜:

{"name":"get_room_dashboard","_meta":{"ui":{"resourceUri":"ui://room-dashboard","visibility":["model"]}}}

visibility是两个可见范围:model表示模型能当普通工具调,app表示只留在宿主侧给界面用。我给控制设备的工具设的是["app"]——点按钮能关设备,但模型不能直接关。

三、动手:三段代码

代码在com.ethanliang.mcp.apps,tagv05。沿用系列(三)的无状态配置。

① 界面资源:一个方法返回 HTML,mimeType 是宿主识别「这是个 MCP App」的依据。

@McpResource(uri="ui://room-dashboard",name="room-dashboard",mimeType="text/html;profile=mcp-app")publicStringroomDashboardUi(){returnDashboardHtml.TEMPLATE;}

② 工具绑定:

@McpTool(name="get_room_dashboard",description="获取机房仪表盘数据…",generateOutputSchema=true,metaProvider=DashboardUiMetaProvider.class,// ← 界面绑定,关键就这一行annotations=@McpTool.McpAnnotations(readOnlyHint=true,destructiveHint=false))publicRoomDashboardgetRoomDashboard(){...}

顺手提醒:MCP 默认destructiveHint=true,只读查询不改掉的话,宿主会把它当破坏性操作。

③ MetaProvider(实现MetaProvider,注册成@Component即可):

@OverridepublicMap<String,Object>getMeta(){returnMap.of("ui",Map.of("resourceUri","ui://room-dashboard","visibility",List.of("model")));}

四、怎么验证真的生效

起服务(端口 8085)后三条 curl 就能验完,不需要真实宿主:

curl-sSlocalhost: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.resourceUri
  • resources/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 那种不执行外部代码的路线,选错后面补安全成本高得多。

小结

  1. 绑定在工具元数据:_meta.ui.resourceUri指向ui://资源,宿主发现工具时就知道有界面
  2. Spring AI 要走@McpTool+metaProvider,@Tool那条路没有 meta 入口
  3. 界面自包含、文本留回退,漏一条界面就是白板或黑盒

完整可运行代码:Gitee 仓库 | GitHub 镜像(tag:v05),目录05-mcp-apps。

MCP 实战手记系列路线图

#篇目状态
1总纲篇:MCP 到哪一步了✅
2跑通第一个 MCP Server✅
3把 MCP Server 改成无状态✅
4CIMD 授权实战✅
5让工具返回一个能点的界面(本篇)✅
6自建 MCP 网关规划
7MCP 安全接入检查清单规划

关注我,更新第一时间看到。你在 MCP 上最想让哪类工具长出界面?评论区见,有价值的我整理进后续篇目。


参考:

  • MCP Apps 官方规范(SEP-1865)
  • MCP Apps 官方文档

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询