关于 add-app-to-server
面向维护现有 MCP 服务的开发者,指导使用 MCP Apps SDK 为适合的工具添加内嵌交互界面,同时保留纯文本客户端的兼容性。内容涵盖工具评估、资源注册、界面构建和测试。
主要功能
- 01评估工具的界面需求
- 02将工具关联 HTML 资源
- 03为纯文本客户端保留输出
- 04构建多框架交互界面
- 05配置主机样式与安全策略
适用场景
- 01为列表或指标添加可视化界面
- 02让 MCP 工具呈现地图或 PDF
- 03为界面添加仅供应用调用的工具
MCP 相关
指导使用 MCP Apps SDK 将现有 MCP 服务工具与 HTML 资源配对;MCP 客户端调用工具后可在对话中显示界面,纯文本客户端仍可使用文本结果。
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ modelcontextprotocol/ ext-apps --skill add-app-to-server安装后在 Claude Code 中输入/add-app-to-server确认已加载。
详细介绍
为 MCP 服务添加 UI
使用 MCP Apps SDK(@modelcontextprotocol/ext-apps),为现有 MCP 服务的工具添加交互式 UI。
工作原理
现有工具会与 HTML 资源配对,并在宿主的对话中内嵌呈现。工具仍可供纯文本客户端使用——UI 是增强功能,而非替代方案。每个适合添加 UI 的工具都会通过 _meta.ui.resourceUri 关联到一个资源;调用工具时,宿主会在沙盒 iframe 中呈现该资源。
获取参考代码
克隆 SDK 仓库,获取可运行的示例和 API 文档:
git clone --branch "v$(npm view @modelcontextprotocol/ext-apps version)" --depth 1 https://github.com/modelcontextprotocol/ext-apps.git /tmp/mcp-ext-apps
API 参考(源文件)
直接阅读 /tmp/mcp-ext-apps/src/ 中的 JSDoc 文档:
| 文件 | 内容 |
|---|---|
src/app.ts | App 类、处理器(ontoolinput、ontoolresult、onhostcontextchanged、onteardown)、生命周期 |
src/server/index.ts | registerAppTool、registerAppResource、getUiCapability、工具可见性选项 |
src/spec.types.ts | 所有类型定义:McpUiHostContext、CSS 变量键、显示模式 |
src/styles.ts | applyDocumentTheme、applyHostStyleVariables、applyHostFonts |
src/react/useApp.tsx | 面向 React 应用的 useApp hook |
src/react/useHostStyles.ts | useHostStyles、useHostStyleVariables、useHostFonts hook |
关键示例(混合工具模式)
这些示例展示了同时包含 App 增强工具和普通工具的服务——这正是你要添加的模式:
| 示例 | 模式 |
|---|---|
examples/map-server/ | show-map(App 工具)+ geocode(普通工具) |
examples/pdf-server/ | display_pdf(App 工具)+ list_pdfs(普通工具)+ read_pdf_bytes(仅 App 工具) |
examples/system-monitor-server/ | get-system-info(App 工具)+ poll-system-stats(仅 App 的轮询工具) |
框架模板
从 /tmp/mcp-ext-apps/examples/basic-server-{framework}/ 学习并进行改造:
| 模板 | 关键文件 |
|---|---|
basic-server-vanillajs/ | server.ts、src/mcp-app.ts、mcp-app.html |
basic-server-react/ | server.ts、src/mcp-app.tsx(使用 useApp hook) |
basic-server-vue/ | server.ts、src/App.vue |
basic-server-svelte/ | server.ts、src/App.svelte |
basic-server-preact/ | server.ts、src/mcp-app.tsx |
basic-server-solid/ | server.ts、src/mcp-app.tsx |
步骤 1:分析现有工具
编写任何代码之前,先分析服务中现有的工具,并确定哪些工具适合添加 UI。
- 阅读服务源代码,列出所有已注册的工具
- 对每个工具评估是否适合添加 UI(返回的数据是否可视化、是否涉及用户交互等),或是否只用纯文本就够了(简单查询、实用函数)
- 找出可作为仅供 App 使用的辅助工具的工具(UI 需要轮询或获取的数据,但模型无需直接调用)
- 向用户展示分析结果,并确认要为哪些工具添加增强功能
决策框架
| 工具输出类型 | UI 优势 | 示例 |
|---|---|---|
| 结构化数据 / 列表 / 表格 | 高——交互式表格、搜索、筛选 | 项目列表、搜索结果 |
| 指标 / 随时间变化的数值 | 高——图表、仪表盘、仪表 | 系统统计数据、分析数据 |
| 媒体 / 丰富内容 | 高——查看器、播放器、渲染器 | 地图、PDF、图像、视频 |
| 简单文本 / 确认信息 | 低——文本即可 | “文件已创建”“设置已更新” |
| 供其他工具使用的数据 | 可考虑仅供 App 使用 | 轮询端点、分块加载器 |
步骤 2:添加依赖
npm install @modelcontextprotocol/ext-apps @modelcontextprotocol/client@^2.0.0 @modelcontextprotocol/server@^2.0.0 zod@^4.2.0
npm install -D vite vite-plugin-singlefile
此外,还需添加框架专用依赖(例如,React 项目需要 react、react-dom、@vitejs/plugin-react),如果服务使用 HTTP 传输,还需添加 @modelcontextprotocol/node / @modelcontextprotocol/express。
ext-apps 2.x 要求使用版本为 ^2.0.0 的拆分式基础 MCP SDK 包
(@modelcontextprotocol/core 会作为传递依赖引入)。仍导入 @modelcontextprotocol/sdk v1 的现有服务,在添加 App 集成之前,必须迁移这些导入和处理程序架构:
SDK v1(@modelcontextprotocol/sdk) | SDK v2 |
|---|---|
sdk/server/mcp.js(McpServer) | @modelcontextprotocol/server |
sdk/server/streamableHttp.js(StreamableHTTPServerTransport) | 来自 @modelcontextprotocol/node 的 NodeStreamableHTTPServerTransport |
| 手动配置 Express | 来自 @modelcontextprotocol/express 的 createMcpExpressApp |
sdk/server/stdio.js | @modelcontextprotocol/server/stdio |
sdk/types.js 类型(CallToolResult,…) | @modelcontextprotocol/client 或 @modelcontextprotocol/server |
sdk/types.js zod 架构(CallToolResultSchema,…) | @modelcontextprotocol/core |
原始 zod 形状:inputSchema: { q: z.string() } | inputSchema: z.object({ q: z.string() }) |
工具回调中的 extra.signal | extra.mcpReq.signal |
setRequestHandler(SomeRequestSchema, handler) | setRequestHandler("some/method", { params: ParamsSchema }, handler)(仅规范方法(如 "tools/call")可使用双参数形式) |
步骤 3:设置构建流水线
Vite 配置
创建 vite.config.ts,使用 vite-plugin-singlefile 将 UI 打包为单个 HTML 文件:
import { defineConfig } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";
export default defineConfig({
plugins: [viteSingleFile()],
build: {
outDir: "dist",
rollupOptions: {
input: "mcp-app.html", // one per UI, or one shared entry
},
},
});
HTML 入口点
创建 mcp-app.html(如果工具需要不同视图,也可以为每个视图各建一个):
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MCP App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./src/mcp-app.ts"></script>
</body>
</html>
构建脚本
在 package.json 中添加构建脚本。打包服务代码之前,必须先构建 UI:
{
"scripts": {
"build:ui": "vite build",
"build:server": "tsc",
"build": "npm run build:ui && npm run build:server",
"serve": "tsx server.ts"
}
}
步骤 4:将工具转换为 App 工具
将普通 MCP 工具转换为带 UI 的 App 工具。
之前(普通 MCP 工具):
server.registerTool(
"my-tool",
{ inputSchema: z.object({ param: z.string() }) },
async ({ param }) => {
const data = await fetchData(param);
return { content: [{ type: "text", text: JSON.stringify(data) }] };
},
);
之后(带 UI 的 App 工具):
import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";
const resourceUri = "ui://my-tool/mcp-app.html";
registerAppTool(
server,
"my-tool",
{
description: "Shows data with an interactive UI",
inputSchema: z.object({ param: z.string() }),
_meta: { ui: { resourceUri } },
},
async ({ param }) => {
const data = await fetchData(param);
return {
content: [{ type: "text", text: JSON.stringify(data) }], // text fallback for non-UI hosts
structuredContent: { data }, // structured data for the UI
};
},
);
要点:
- 始终保留
content数组,其中包含面向纯文本客户端的文本回退内容 - 为 UI 需要渲染的数据添加
structuredContent - 通过
_meta.ui.resourceUri将工具关联到其资源 - 对不适合使用 UI 的工具保持不变——它们仍是普通工具
步骤 5:注册资源
注册 HTML 资源,以便宿主获取它:
import fs from "node:fs/promises";
import path from "node:path";
const resourceUri = "ui://my-tool/mcp-app.html";
registerAppResource(
server,
"My Tool UI",
resourceUri,
{},
async () => {
const html = await fs.readFile(
path.resolve(import.meta.dirname, "dist", "mcp-app.html"),
"utf-8",
);
return {
contents: [{ uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html }],
};
},
);
如果多个工具共用同一 UI,它们可以引用相同的 resourceUri,并共用同一资源注册项。
步骤 6:构建 UI
处理程序注册
调用 app.connect() 之前,先注册所有处理程序:
import { App, PostMessageTransport, applyDocumentTheme, applyHostStyleVariables, applyHostFonts } from "@modelcontextprotocol/ext-apps";
const app = new App({ name: "My App", version: "1.0.0" });
app.ontoolinput = (params) => {
// Render the UI using params.arguments and/or params.structuredContent
};
app.ontoolresult = (result) => {
// Update UI with final tool result
};
app.onhostcontextchanged = (ctx) => {
if (ctx.theme) applyDocumentTheme(ctx.theme);
if (ctx.styles?.variables) applyHostStyleVariables(ctx.styles.variables);
if (ctx.styles?.css?.fonts) applyHostFonts(ctx.styles.css.fonts);
if (ctx.safeAreaInsets) {
const { top, right, bottom, left } = ctx.safeAreaInsets;
document.body.style.padding = `${top}px ${right}px ${bottom}px ${left}px`;
}
};
app.onteardown = async () => {
return {};
};
await app.connect(new PostMessageTransport());
宿主样式
使用宿主 CSS 变量集成主题:
.container {
background: var(--color-background-secondary);
color: var(--color-text-primary);
font-family: var(--font-sans);
border-radius: var(--border-radius-md);
}
主要变量组:--color-background-*、--color-text-*、--color-border-*、--font-sans、--font-mono、--font-text-*-size、--font-heading-*-size、--border-radius-*。完整列表请参阅 src/spec.types.ts。
对于 React 应用,请改用 useApp 和 useHostStyles 钩子——模式请参阅 basic-server-react/。
可选增强功能
仅供 App 使用的辅助工具
UI 会调用、但模型无需直接调用的工具(轮询、分页、分块加载):
registerAppTool(server, "poll-data", {
description: "Polls latest data for the UI",
_meta: { ui: { resourceUri, visibility: ["app"] } },
}, async () => {
const data = await getLatestData();
return { content: [{ type: "text", text: JSON.stringify(data) }] };
});
UI 通过
app.callServerTool({ name: "poll-data", arguments: {} }) 调用这些工具。
CSP 配置
如果 UI 需要加载外部资源(字体、API、CDN),请声明相应域名:
registerAppResource(server, "My Tool UI", resourceUri, {}, async () => ({
contents: [
{
uri: resourceUri,
mimeType: RESOURCE_MIME_TYPE,
text: html,
_meta: {
ui: {
csp: {
connectDomains: ["https://api.example.com"], // fetch/XHR targets
resourceDomains: ["https://cdn.example.com"], // scripts, styles, images
frameDomains: ["https://embed.example.com"], // nested iframes
},
},
},
},
],
}));
流式部分输入
对于较大的工具输入,可在大模型生成期间显示进度:
app.ontoolinputpartial = (params) => {
const args = params.arguments; // Healed partial JSON - always valid
// Render preview with partial data
};
app.ontoolinput = (params) => {
// Final complete input - switch to full render
};
使用 getUiCapability() 实现优雅降级
仅当客户端支持 UI 时才有条件地注册 App 工具,否则回退到纯文本工具:
import { getUiCapability, registerAppTool, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";
server.server.oninitialized = () => {
const clientCapabilities = server.server.getClientCapabilities();
const uiCap = getUiCapability(clientCapabilities);
if (uiCap?.mimeTypes?.includes(RESOURCE_MIME_TYPE)) {
// Client supports UI — register App tool
registerAppTool(server, "my-tool", {
description: "Shows data with interactive UI",
_meta: { ui: { resourceUri } },
}, appToolHandler);
} else {
// Text-only client — register plain tool
server.registerTool(
"my-tool",
{
description: "Shows data",
inputSchema: z.object({ param: z.string() }),
},
plainToolHandler,
);
}
};
全屏模式
允许 UI 扩展至全屏:
app.onhostcontextchanged = (ctx) => {
if (ctx.availableDisplayModes?.includes("fullscreen")) {
fullscreenBtn.style.display = "block";
}
if (ctx.displayMode) {
container.classList.toggle("fullscreen", ctx.displayMode === "fullscreen");
}
};
async function toggleFullscreen() {
const newMode = currentMode === "fullscreen" ? "inline" : "fullscreen";
const result = await app.requestDisplayMode({ mode: newMode });
currentMode = result.mode;
}
常见错误
- 忘记文本
content回退 — 始终包含带有文本的content数组,以供非 UI 宿主使用 - 在
connect()之后注册处理程序 — 调用app.connect()之前注册所有处理程序 - 缺少
vite-plugin-singlefile— 如果没有它,资源将无法在沙盒化 iframe 中加载 - 忘记注册资源 — 工具引用了一个
resourceUri,必须有匹配的资源 - 将样式硬编码 — 使用宿主 CSS 变量(
var(--color-*))来集成主题 - 未处理安全区域内边距 — 在
onhostcontextchanged中始终应用ctx.safeAreaInsets
测试
使用 basic-host
使用 basic-host 示例测试增强型服务:
# Terminal 1: Build and run your server
npm run build && npm run serve
# Terminal 2: Run basic-host (from cloned repo)
cd /tmp/mcp-ext-apps/examples/basic-host
npm install
SERVERS='["http://localhost:3001/mcp"]' npm run start
# Open http://localhost:8080
使用服务器 URL 的 JSON 数组配置 SERVERS(默认值:http://localhost:3001/mcp)。
验证
- 普通工具仍可正常工作并返回文本输出
- App 工具会在 iframe 中呈现其 UI
ontoolinput处理程序会使用工具参数触发ontoolresult处理程序会使用工具结果触发- 宿主样式(主题、字体、颜色)会正确应用