Lessons from building 20 MCP Apps in 2 days
Lessons from building 20 MCP Apps in 2 days
两天内构建 20 个 MCP 应用的经验总结
A few weeks back, my team sat down for two days and built around twenty MCP Apps. I came out with a much better idea of what they are, what they aren’t (yet), and where the duct tape is currently holding things together. Here’s the brain dump. 几周前,我的团队花了整整两天时间构建了大约 20 个 MCP 应用。通过这次实践,我对自己所做的事情有了更深刻的理解:它们是什么、目前还不是什么,以及哪些地方目前还处于“临时拼凑”的状态。以下是我的心得总结。
If you haven’t run into them yet: MCP Apps is the first official extension to the MCP spec. It lets a tool return a UI resource alongside its result. The host renders that UI inline as a sandboxed iframe. Tables, charts, forms, branded layouts, little interactive bits that can call back into other tools. Real, actual UI in the middle of a chat-based experience. Very cool. 如果你还没接触过它们:MCP Apps 是 MCP 规范的第一个官方扩展。它允许工具在返回结果的同时返回一个 UI 资源。宿主程序会将该 UI 作为沙盒 iframe 内嵌渲染。表格、图表、表单、品牌化布局,以及可以回调其他工具的小型交互组件——在基于聊天的体验中实现真正的 UI,这非常酷。
They matter because some information is just better visually. A neatly grouped list of pull requests is much easier to scan than a wall of bulleted text. A chart beats a CSV. And as more of our day-to-day work shifts into chat, “bring your brand and your product surface into the conversation” stops being a nice-to-have. 它们之所以重要,是因为有些信息通过视觉呈现效果更好。相比于一大堆项目符号文本,整齐分组的 Pull Request 列表更容易浏览;图表也远胜于 CSV 文件。随着我们越来越多的日常工作转向聊天界面,“将品牌和产品界面带入对话中”已不再是锦上添花,而是必需品。
OK. Lessons.
好了,谈谈经验。
The call is coming from inside the house
MCP Apps live inside the server. This was the first thing that surprised me. An MCP App isn’t a hosted URL you point your tool at or some third-party iframe you embed. It is fetched via MCP, not HTTP, so the UI code ships with the MCP server and is served via the ui:// resource scheme.
“呼叫来自内部”
MCP 应用驻留在服务器内部。这是让我感到惊讶的第一点。MCP 应用不是你指向某个 URL 的托管链接,也不是你嵌入的第三方 iframe。它是通过 MCP 而非 HTTP 获取的,因此 UI 代码随 MCP 服务器一起发布,并通过 ui:// 资源方案进行服务。
There are a number of different ways you could go about organizing this. For example, you could co-locate the files with the tools themselves… Or co-locate them in a single place… We were using React because we wanted to leverage the existing internal design system components. So we landed on: A single Vite project at the root of /ui, configured to output an HTML file per TSX file at build time.
你可以通过多种方式组织这些文件。例如,你可以将文件与工具本身放在一起……或者将它们集中存放在一个地方……我们使用了 React,因为我们想利用现有的内部设计系统组件。因此,我们最终采取的方案是:在 /ui 根目录下建立一个单一的 Vite 项目,配置为在构建时为每个 TSX 文件输出一个 HTML 文件。
MCP Apps are enrichment-only
If a host supports MCP Apps, your user sees the rich UI. If it doesn’t, the _meta.ui property is silently ignored and your user just gets the text response. So the text response is still the contract. Your MCP App is enrichment on top. If you stuff the actual answer into the UI and leave your text response empty, congratulations: you’ve shipped a tool that works in some clients and silently breaks in others. Always design as if half your users will never see the app.
MCP 应用仅作为增强功能
如果宿主支持 MCP 应用,用户就能看到丰富的 UI。如果不支持,_meta.ui 属性会被静默忽略,用户只会收到文本回复。因此,文本回复依然是核心契约,MCP 应用只是锦上添花。如果你把实际答案塞进 UI 而让文本回复留空,恭喜你:你发布了一个在某些客户端能用,但在其他客户端却会静默失效的工具。设计时请始终假设有一半用户永远看不到这个应用。
Keep these things STUPID simple I am going to be the first one to tell you: keep your MCP App components dumb. Pure. Boring. All data passed in as props from the tool result. No fetches from inside the app, no state machines, no calls back to your API. The tool runs, computes its answer, hands the data to the UI as props, and the UI is just a deterministic render of that. This made our apps fast to build, easy to reason about, and very simple to test in isolation. 保持极度简单 我要第一个告诉你:让你的 MCP 应用组件保持“愚蠢”。纯粹、无聊。所有数据都通过工具结果作为 props 传入。应用内部不要有 fetch 请求,不要有状态机,不要回调你的 API。工具运行并计算出答案,将数据作为 props 传给 UI,UI 只是对这些数据的确定性渲染。这使得我们的应用构建速度快、逻辑易于理解,且非常容易进行独立测试。
Host quirks are real There’s a spec, but hosts implement it with their own opinions. Container width, padding, default typography, dark/light handling, the whole vibe varies. There’s no standardized testing harness yet, so our iteration loop was: build, install in client A, eyeball it, install in client B, eyeball it, adjust, repeat. Compared to ordinary frontend dev, it felt slow. Like, painfully slow. 宿主差异是真实存在的 虽然有规范,但不同宿主的实现方式各有偏好。容器宽度、内边距、默认字体、深色/浅色模式处理,整体风格各异。目前还没有标准化的测试工具,所以我们的迭代循环是:构建、在客户端 A 安装、目测、在客户端 B 安装、目测、调整、重复。与普通前端开发相比,这感觉很慢,慢得令人痛苦。
The host can see everything MCP Apps run in a sandboxed iframe, but the content of that iframe is visible to the host. This has a real implication: don’t use MCP Apps to collect secrets. No API keys in form fields. No OAuth tokens. Nothing you wouldn’t want logged. If you need to collect secrets, use URL elicitation or a separate secure form outside the MCP App. 宿主可以看到一切 MCP 应用运行在沙盒 iframe 中,但该 iframe 的内容对宿主是可见的。这有一个实际含义:不要使用 MCP 应用来收集敏感信息。表单字段中不要包含 API 密钥,不要包含 OAuth 令牌,不要包含任何你不希望被记录的内容。如果你需要收集敏感信息,请使用 URL 跳转或在 MCP 应用之外使用单独的安全表单。
TL;DR
总结
- Bundle your UI inside your server. (Multi-page Vite, one HTML per surface). 将 UI 打包在服务器内。(多页面 Vite,每个界面对应一个 HTML)。
- Always make the text response stand on its own. 确保文本回复始终能独立存在。
- Pure components, props in, no client-side state. 纯组件,仅通过 props 传入数据,无客户端状态。
- Test on every host you care about, by hand, until tooling catches up. 在所有你关心的宿主上手动测试,直到配套工具完善。
- Don’t put secrets in the app. 不要在应用中放入敏感信息。