前两天在折腾 MCP Server 的时候,随手刷了一下 MCP 官方博客,发现了一个让我眼前一亮的东西------MCP Apps。
简单说就是:你的 MCP 工具不再只能返回一段文字了,它可以直接在 Claude、VS Code Copilot 的对话框里渲染一个可交互的 HTML 页面。图表、表单、Dashboard、3D 模型... 都行。
说实话看到这个我第一反应是"这不就是把 iframe 塞进去了吗",但仔细看了架构设计之后发现,它解决的问题比想象中优雅得多。
先说结论
| 特性 | 传统 MCP 工具 | MCP Apps |
|---|---|---|
| 返回内容 | 纯文本/图片 | 交互式 HTML 页面 |
| 用户操作 | 只能看,想改得再发消息 | 直接在 UI 上点击、输入、交互 |
| 数据流 | 单向(工具→用户) | 双向(UI ↔ Server) |
| 上下文 | 切出去就断了 | 嵌在对话里,上下文保持 |
| 安全性 | N/A | 沙箱 iframe,隔离宿主 |
已支持的客户端:Claude Web/Desktop、VS Code GitHub Copilot、ChatGPT(灰度中)、Goose、Postman。
为什么不直接做个网页?
这是我最开始的疑问------你搞个 Dashboard 直接写个 React 项目部署一下不就行了,何必塞到聊天框里?
想了想还真有几个场景是网页做不到的:
- 上下文不丢:Dashboard 就嵌在对话里,用户不用切标签页。"帮我看一下销售数据" → 图表直接出现在对话下方,接着问 "按地区拆分呢" → 图表原地刷新
- 双向数据流白嫖 MCP:你的 UI 可以直接调 MCP Server 上注册的任何工具,不用自己搞 API、鉴权、状态管理。MCP 帮你全干了
- 安全隔离:跑在沙箱 iframe 里,读不到宿主的 Cookie、DOM、LocalStorage。第三方 MCP 服务商做的 App 也不怕它偷数据
如果你的场景不需要这些,那直接写个网页确实更简单。但如果你想让 AI 返回的东西能被"操作"而不只是"阅读",MCP Apps 比什么方案都顺手。
工作原理(30 秒版)
架构其实不复杂,核心就两个 MCP 原语的组合:
arduino
用户问:"显示服务器状态"
↓
LLM 决定调用 monitor_server 工具
↓
宿主发现工具描述里有 _meta.ui.resourceUri
↓
宿主去 MCP Server 拉取 ui:// 资源(一个打包好的 HTML)
↓
在沙箱 iframe 里渲染 HTML
↓
工具执行结果推送给 App(ontoolresult 回调)
↓
用户在 UI 上点击按钮 → App 通过 postMessage 调 MCP 工具 → 数据刷新
通信走的是 JSON-RPC over postMessage,和 MCP 核心协议是一个路子。tools/call 这些方法直接复用,新增了 ui/initialize 等 App 专属方法。
实战:写一个服务器时间面板
光说不练假把式。下面跟着我从零写一个 MCP App------功能很简单:在 Claude 对话框里渲染一个面板,显示服务器时间,点按钮可以刷新。
项目结构
perl
my-time-app/
├── package.json
├── tsconfig.json
├── vite.config.ts
├── server.ts # MCP Server
├── mcp-app.html # UI 入口
└── src/
└── mcp-app.ts # UI 逻辑
1. 初始化项目
bash
mkdir my-time-app && cd my-time-app
npm init -y
npm install @modelcontextprotocol/ext-apps @modelcontextprotocol/sdk
npm install -D typescript vite vite-plugin-singlefile express cors @types/express @types/cors tsx
2. 配置文件
package.json 加上脚本:
json
{
"type": "module",
"scripts": {
"build": "INPUT=mcp-app.html vite build",
"serve": "npx tsx server.ts"
}
}
vite.config.ts ------用 vite-plugin-singlefile 把 HTML/CSS/JS 打包成一个文件:
typescript
import { defineConfig } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";
export default defineConfig({
plugins: [viteSingleFile()],
build: {
outDir: "dist",
rollupOptions: {
input: process.env.INPUT,
},
},
});
tsconfig.json:
json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist"
},
"include": ["*.ts", "src/**/*.ts"]
}
3. 写 MCP Server
这是核心部分。关键就两步:注册一个带 UI 元数据的工具 + 注册一个返回 HTML 的资源。
typescript
// server.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import {
registerAppTool,
registerAppResource,
RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/server";
import cors from "cors";
import express from "express";
import fs from "node:fs/promises";
import path from "node:path";
const server = new McpServer({
name: "Time App Server",
version: "1.0.0",
});
// ui:// 前缀告诉宿主这是一个 MCP App 资源
const resourceUri = "ui://get-time/mcp-app.html";
// 注册工具:返回当前时间
registerAppTool(
server,
"get-time",
{
title: "Get Time",
description: "返回当前服务器时间",
inputSchema: {},
_meta: { ui: { resourceUri } }, // 关键:关联 UI 资源
},
async () => {
const now = new Date();
return {
content: [{
type: "text",
text: JSON.stringify({
iso: now.toISOString(),
local: now.toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" }),
timestamp: now.getTime(),
}),
}],
};
},
);
// 注册 UI 资源:返回打包好的 HTML
registerAppResource(
server,
resourceUri,
resourceUri,
{ mimeType: RESOURCE_MIME_TYPE },
async () => {
const html = await fs.readFile(
path.join(import.meta.dirname, "dist", "mcp-app.html"),
"utf-8",
);
return {
contents: [
{ uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html },
],
};
},
);
// 启动 HTTP 服务
const app = express();
app.use(cors());
app.use(express.json());
app.post("/mcp", async (req, res) => {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
enableJsonResponse: true,
});
res.on("close", () => transport.close());
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
app.listen(3001, () => {
console.log("MCP App Server 跑起来了: http://localhost:3001/mcp");
});
划重点:
_meta.ui.resourceUri是魔法发生的地方。宿主看到这个字段就知道要渲染 UI 而不只是显示文本registerAppResource把你打包好的 HTML 注册为ui://资源- 底层走的是
StreamableHTTPServerTransport,也就是标准的 MCP HTTP 传输
4. 写前端 UI
mcp-app.html:
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>服务器时间面板</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
padding: 20px;
background: #f8f9fa;
color: #333;
}
.card {
background: white;
border-radius: 12px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
max-width: 400px;
}
h2 { font-size: 16px; color: #666; margin-bottom: 16px; }
.time-display {
font-size: 28px;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: #1a1a1a;
margin-bottom: 8px;
}
.timestamp { font-size: 13px; color: #999; margin-bottom: 20px; }
button {
background: #0066ff;
color: white;
border: none;
padding: 10px 20px;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
transition: background 0.2s;
}
button:hover { background: #0052cc; }
button:active { background: #003d99; }
.status { font-size: 12px; color: #52c41a; margin-top: 12px; }
</style>
</head>
<body>
<div class="card">
<h2>⏰ 服务器时间</h2>
<div class="time-display" id="time">加载中...</div>
<div class="timestamp" id="ts"></div>
<button id="refresh">🔄 刷新时间</button>
<div class="status" id="status"></div>
</div>
<script type="module" src="/src/mcp-app.ts"></script>
</body>
</html>
src/mcp-app.ts------这里是 App 端逻辑:
typescript
import { App } from "@modelcontextprotocol/ext-apps";
const timeEl = document.getElementById("time")!;
const tsEl = document.getElementById("ts")!;
const statusEl = document.getElementById("status")!;
const refreshBtn = document.getElementById("refresh")!;
const app = new App({ name: "Time App", version: "1.0.0" });
// 跟宿主建立连接(只调一次)
app.connect();
function updateDisplay(raw: string) {
try {
const data = JSON.parse(raw);
timeEl.textContent = data.local;
tsEl.textContent = `Unix: ${data.timestamp}`;
statusEl.textContent = `✅ 已同步 (${new Date().toLocaleTimeString("zh-CN")})`;
} catch {
timeEl.textContent = raw;
}
}
// 宿主推送工具结果时的回调(首次渲染走这里)
app.ontoolresult = (result) => {
const text = result.content?.find((c) => c.type === "text")?.text;
if (text) updateDisplay(text);
};
// 用户点按钮 → 主动调 MCP 工具 → 拿到最新数据
refreshBtn.addEventListener("click", async () => {
refreshBtn.textContent = "⏳ 请求中...";
refreshBtn.disabled = true;
try {
const result = await app.callServerTool({
name: "get-time",
arguments: {},
});
const text = result.content?.find((c) => c.type === "text")?.text;
if (text) updateDisplay(text);
} catch (err) {
statusEl.textContent = `❌ 请求失败: ${err}`;
} finally {
refreshBtn.textContent = "🔄 刷新时间";
refreshBtn.disabled = false;
}
});
三个关键 API:
app.connect():跟宿主握手,整个生命周期只调一次app.ontoolresult:宿主把工具执行结果推过来时触发。首次渲染就是走这个回调app.callServerTool():从 UI 端主动调 Server 上注册的工具。注意有网络延迟,按钮状态记得处理
5. 构建 & 运行
bash
npm run build && npm run serve
然后你需要一个支持 MCP Apps 的客户端来看效果。最简单的是用 Claude Desktop:
bash
# 用 cloudflared 把本地服务暴露出去
npx cloudflared tunnel --url http://localhost:3001
拿到隧道 URL 后,去 Claude 设置 → Connectors → Add custom connector,填进去。
开个新对话,说 "帮我看一下服务器时间",Claude 就会调用你的 get-time 工具,然后------boom------一个交互式面板直接出现在对话里。
点击 "刷新时间" 按钮,数据实时更新,整个过程不需要再发任何消息。
踩坑记录
搞这个东西的过程中踩了几个坑,记录一下:
1. HTML 必须打包成单文件
MCP Apps 的 UI 资源默认跑在沙箱 iframe 里,CSP 策略是 deny-by-default。也就是说你的 CSS、JS 如果是外部文件,加载不了。
解决方案:用 vite-plugin-singlefile 把所有东西内联到一个 HTML 里。或者自己配 CSP,但那更麻烦。
2. app.connect() 必须在 DOM ready 之后调
这个其实文档没明说,但你如果在脚本最顶部调 app.connect(),可能因为 postMessage 通道还没建立而报错。用 type="module" 的 script 标签天然是 defer 的,基本没这个问题。但如果你用内联 script,记得包一层 DOMContentLoaded。
3. callServerTool 每次都是完整的网络往返
别指望用 callServerTool 来做实时数据流。它每次调用都是 UI → 宿主 → MCP Server → 宿主 → UI 的完整链路。做 Dashboard 的话,考虑一次性拿批量数据,前端自己做渲染逻辑。
4. 目前不支持 Server 主动推送
如果你想做"服务器每秒推一次数据"那种实时监控,MCP Apps 目前做不到。只能 UI 端轮询,或者等后续版本支持 Server Push。
更多可能性
这个时间面板只是个 Hello World。MCP Apps 官方仓库里有一堆更有意思的例子:
- CesiumJS 地球仪:在对话框里嵌入一个 3D 地球,"显示这些坐标的位置" → 地球自动定位
- QR 码生成器:输入文字直接出二维码,还能下载
- PDF 阅读器:对话框里就能翻页看 PDF
- 系统监控面板:CPU、内存、磁盘的实时图表
支持 React、Vue、Svelte、Preact、Solid、原生 JS 的 starter template 全有,直接 clone 下来改就行。
小结
MCP Apps 是 MCP 协议的第一个官方扩展,我觉得它标志着一个挺重要的方向转变:AI 工具的输出不再只是文本,而是可交互的界面。
以前我们跟 AI 说 "帮我生成一个报表",AI 返回一段文字或者一张静态图。现在它可以直接给你一个能筛选、能钻取、能导出的交互式 Dashboard。
当然现在还早期,支持的客户端不多,API 也在演进中。但如果你正在做 MCP 相关的开发,建议现在就开始玩 MCP Apps------等它铺开的时候,你已经是老手了。
官方仓库和文档: