MCP 工具只能返回文字?现在能直接弹出交互式 UI 了,手把手写一个

前两天在折腾 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 项目部署一下不就行了,何必塞到聊天框里?

想了想还真有几个场景是网页做不到的:

  1. 上下文不丢:Dashboard 就嵌在对话里,用户不用切标签页。"帮我看一下销售数据" → 图表直接出现在对话下方,接着问 "按地区拆分呢" → 图表原地刷新
  2. 双向数据流白嫖 MCP:你的 UI 可以直接调 MCP Server 上注册的任何工具,不用自己搞 API、鉴权、状态管理。MCP 帮你全干了
  3. 安全隔离:跑在沙箱 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------等它铺开的时候,你已经是老手了。

官方仓库和文档:

相关推荐
丁劲犇5 小时前
在Trae Solo模式下用Qt HttpServer和Concurrent升级MCP服务器绘制6G互联网覆盖区域
服务器·开发语言·qt·ai·6g·mcp·trae
安逸sgr5 小时前
MCP 协议深度解析(八):Prompts 提示模板与 Sampling 采样机制!
人工智能·分布式·学习·语言模型·协议·mcp
码路飞21 小时前
Claude Code 装了 10 个 MCP Server 直接卡死?一个隐藏功能帮你省 95% 上下文
ai编程·claude·mcp
-许平安-1 天前
MCP项目笔记三(server)
网络·c++·笔记·mcp
阿捏利1 天前
vscode+ida-mcp-server配置及使用
vscode·ida·逆向·mcp
深念Y1 天前
Chrome MCP Server 配置失败全记录:一场历时数小时的“fetch failed”排查之旅
前端·自动化测试·chrome·http·ai·agent·mcp
x-cmd1 天前
[x-cmd] Chrome DevTools MCP 更新:支持 coding agent 直接接管当前的浏览器窗口
前端·chrome·ai·agent·chrome devtools·x-cmd·mcp
阿捏利1 天前
vscode+jadx-mcp-server配置及使用
android·apk·逆向·mcp·jadx
Shawn_Shawn2 天前
mcp学习笔记(三)-Mcp传输协议代码示例
llm·agent·mcp