:让工具返回一个能点的界面(文末附github源码链接))
MCP 实战手记系列五· 代码实操系列三把服务改成无状态之后工具返回的还是一串 JSON——给模型看刚好给人看不行。用户在对话里想看一眼设备状态、想点一下开关只能盯着文本数字符。MCP Apps 补的就是这一公里。这篇在 Spring AI 上把它跑通代码在 tagv05。引子JSON 是给模型看的不是给人看的以机房面板为例模型读得懂{ac-01:true,fan-02:false}用户看到的是一行天书——他想要的是三张卡片点一下把风扇打开。以前只有两条路要么让模型吐一段 HTML不安全、没法复用要么自己再写一个前端跟 MCP 就没关系了。2026 年 1 月官方把第三条路收成了扩展MCP AppsSEP-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.appstagv05。沿用系列三的无状态配置。① 界面资源一个方法返回 HTMLmimeType 是宿主识别「这是个 MCP App」的依据。McpResource(uriui://room-dashboard,nameroom-dashboard,mimeTypetext/html;profilemcp-app)publicStringroomDashboardUi(){returnDashboardHtml.TEMPLATE;}② 工具绑定McpTool(nameget_room_dashboard,description获取机房仪表盘数据…,generateOutputSchematrue,metaProviderDashboardUiMetaProvider.class,// ← 界面绑定关键就这一行annotationsMcpTool.McpAnnotations(readOnlyHinttrue,destructiveHintfalse))publicRoomDashboardgetRoomDashboard(){...}顺手提醒MCP 默认destructiveHinttrue只读查询不改掉的话宿主会把它当破坏性操作。③ MetaProvider实现MetaProvider注册成Component即可OverridepublicMapString,ObjectgetMeta(){returnMap.of(ui,Map.of(resourceUri,ui://room-dashboard,visibility,List.of(model)));}四、怎么验证真的生效起服务端口 8085后三条 curl 就能验完不需要真实宿主curl-sSlocalhost:8085/mcp-HContent-Type: application/json-HAccept: 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;profilemcp-app}tools/call→ 同时给structuredContent界面用和content[0].text不支持的宿主用第二条最关键mimeType 写错宿主就不认这是界面只会当普通文本资源界面永远出不来。五、踩坑记录①Tool挂不上_meta。Spring AI 的ToolDefinition只有 name / description / inputSchema没有 metaToolMethodToolCallbackProvider这条路声明不了界面必须走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 要走McpToolmetaProviderTool那条路没有 meta 入口界面自包含、文本留回退漏一条界面就是白板或黑盒完整可运行代码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-1865MCP Apps 官方文档