add-app-to-server
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseAdd UI to MCP Server
为MCP服务器添加UI
Enrich an existing MCP server's tools with interactive UIs using the MCP Apps SDK ().
@modelcontextprotocol/ext-apps使用MCP Apps SDK()为现有MCP服务器的工具添加交互式UI,丰富其功能。
@modelcontextprotocol/ext-appsHow It Works
工作原理
Existing tools get paired with HTML resources that render inline in the host's conversation. The tool continues to work for text-only clients — UI is an enhancement, not a replacement. Each tool that benefits from UI gets linked to a resource via , and the host renders that resource in a sandboxed iframe when the tool is called.
_meta.ui.resourceUri现有工具会与在宿主对话中内嵌渲染的HTML资源配对。该工具仍可在纯文本客户端中正常工作——UI只是增强功能,而非替代方案。每个可从UI中获益的工具都会通过链接到一个资源,当工具被调用时,宿主会在沙箱化的iframe中渲染该资源。
_meta.ui.resourceUriGetting Reference Code
获取参考代码
Clone the SDK repository for working examples and API documentation:
bash
git clone --branch "v$(npm view @modelcontextprotocol/ext-apps version)" --depth 1 https://github.com/modelcontextprotocol/ext-apps.git /tmp/mcp-ext-apps克隆SDK仓库获取可用示例和API文档:
bash
git clone --branch "v$(npm view @modelcontextprotocol/ext-apps version)" --depth 1 https://github.com/modelcontextprotocol/ext-apps.git /tmp/mcp-ext-appsAPI Reference (Source Files)
API参考(源文件)
Read JSDoc documentation directly from :
/tmp/mcp-ext-apps/src/| File | Contents |
|---|---|
| |
| |
| All type definitions: |
| |
| |
| |
直接从读取JSDoc文档:
/tmp/mcp-ext-apps/src/| 文件 | 内容 |
|---|---|
| |
| |
| 所有类型定义: |
| |
| 用于React应用的 |
| |
Key Examples (Mixed Tool Patterns)
核心示例(混合工具模式)
These examples demonstrate servers with both App-enhanced and plain tools — the exact pattern you're adding:
| Example | Pattern |
|---|---|
| |
| |
| |
这些示例展示了同时包含App增强型工具和普通工具的服务器——与你要实现的扩展模式完全一致:
| 示例 | 模式 |
|---|---|
| |
| |
| |
Framework Templates
框架模板
Learn and adapt from :
/tmp/mcp-ext-apps/examples/basic-server-{framework}/| Template | Key Files |
|---|---|
| |
| |
| |
| |
| |
| |
从中学习并适配:
/tmp/mcp-ext-apps/examples/basic-server-{framework}/| 模板 | 核心文件 |
|---|---|
| |
| |
| |
| |
| |
| |
Step 1: Analyze Existing Tools
步骤1:分析现有工具
Before writing any code, analyze the server's existing tools and determine which ones benefit from UI.
- Read the server source and list all registered tools
- For each tool, assess whether it would benefit from UI (returns data that could be visualized, involves user interaction, etc.) vs. is fine as text-only (simple lookups, utility functions)
- Identify tools that could become app-only helpers (data the UI needs to poll/fetch but the model doesn't need to call directly)
- Present the analysis to the user and confirm which tools to enhance
在编写任何代码之前,先分析服务器的现有工具,确定哪些工具可从UI中获益。
- 阅读服务器源码,列出所有已注册的工具
- 针对每个工具,评估其是否能从UI中获益(返回可可视化的数据、涉及用户交互等),还是保持纯文本形式即可(简单查询、工具函数)
- 识别可成为仅App辅助工具的工具(UI需要轮询/获取但模型无需直接调用的数据)
- 向用户展示分析结果,确认要增强的工具
Decision Framework
决策框架
| Tool output type | UI benefit | Example |
|---|---|---|
| Structured data / lists / tables | High — interactive table, search, filtering | List of items, search results |
| Metrics / numbers over time | High — charts, gauges, dashboards | System stats, analytics |
| Media / rich content | High — viewer, player, renderer | Maps, PDFs, images, video |
| Simple text / confirmations | Low — text is fine | "File created", "Setting updated" |
| Data for other tools | Consider app-only | Polling endpoints, chunk loaders |
| 工具输出类型 | UI收益 | 示例 |
|---|---|---|
| 结构化数据/列表/表格 | 高——交互式表格、搜索、筛选 | 项目列表、搜索结果 |
| 时序指标/数值 | 高——图表、仪表盘、计量器 | 系统统计数据、分析数据 |
| 媒体/富内容 | 高——查看器、播放器、渲染器 | 地图、PDF、图片、视频 |
| 简单文本/确认信息 | 低——纯文本即可 | "文件已创建"、"设置已更新" |
| 供其他工具使用的数据 | 考虑设为仅App工具 | 轮询端点、分块加载器 |
Step 2: Add Dependencies
步骤2:添加依赖
bash
npm install @modelcontextprotocol/ext-apps
npm install -D vite vite-plugin-singlefilePlus framework-specific dependencies if needed (e.g., , , for React).
reactreact-dom@vitejs/plugin-reactUse to add dependencies rather than manually writing version numbers. This lets npm resolve the latest compatible versions. Never specify version numbers from memory.
npm installbash
pm install @modelcontextprotocol/ext-apps
npm install -D vite vite-plugin-singlefile如需使用特定框架,还需添加对应依赖(例如React项目需安装、、)。
reactreact-dom@vitejs/plugin-react使用命令添加依赖,而非手动编写版本号。这样npm会自动解析最新的兼容版本,切勿凭记忆指定版本号。
npm installStep 3: Set Up the Build Pipeline
步骤3:构建构建流水线
Vite Configuration
Vite配置
Create with to bundle the UI into a single HTML file:
vite.config.tsvite-plugin-singlefiletypescript
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
},
},
});创建并配置,将UI打包为单个HTML文件:
vite.config.tsvite-plugin-singlefiletypescript
import { defineConfig } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";
export default defineConfig({
plugins: [viteSingleFile()],
build: {
outDir: "dist",
rollupOptions: {
input: "mcp-app.html", // 每个UI对应一个入口,或使用共享入口
},
},
});HTML Entry Point
HTML入口文件
Create (or one per distinct UI if tools need different views):
mcp-app.htmlhtml
<!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>创建(如果不同工具需要不同视图,可为每个工具创建单独的入口):
mcp-app.htmlhtml
<!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>Build Scripts
构建脚本
Add build scripts to . The UI must be built before the server code bundles it:
package.jsonjson
{
"scripts": {
"build:ui": "vite build",
"build:server": "tsc",
"build": "npm run build:ui && npm run build:server",
"serve": "tsx server.ts"
}
}在中添加构建脚本。必须先构建UI,再让服务器代码打包UI资源:
package.jsonjson
{
"scripts": {
"build:ui": "vite build",
"build:server": "tsc",
"build": "npm run build:ui && npm run build:server",
"serve": "tsx server.ts"
}
}Step 4: Convert Tools to App Tools
步骤4:将工具转换为App工具
Transform plain MCP tools into App tools with UI.
Before (plain MCP tool):
typescript
server.tool("my-tool", { param: z.string() }, async (args) => {
const data = await fetchData(args.param);
return { content: [{ type: "text", text: JSON.stringify(data) }] };
});After (App tool with UI):
typescript
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: { param: z.string() },
_meta: { ui: { resourceUri } },
}, async (args) => {
const data = await fetchData(args.param);
return {
content: [{ type: "text", text: JSON.stringify(data) }], // text fallback for non-UI hosts
structuredContent: { data }, // structured data for the UI
};
});Key guidance:
- Always keep the array with a text fallback for text-only clients
content - Add for data the UI needs to render
structuredContent - Link the tool to its resource via
_meta.ui.resourceUri - Leave tools that don't benefit from UI unchanged — they stay as plain tools
将普通MCP工具转换为带UI的App工具。
转换前(普通MCP工具):
typescript
server.tool("my-tool", { param: z.string() }, async (args) => {
const data = await fetchData(args.param);
return { content: [{ type: "text", text: JSON.stringify(data) }] };
});转换后(带UI的App工具):
typescript
import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";
const resourceUri = "ui://my-tool/mcp-app.html";
registerAppTool(server, "my-tool", {
description: "以交互式UI展示数据",
inputSchema: { param: z.string() },
_meta: { ui: { resourceUri } },
}, async (args) => {
const data = await fetchData(args.param);
return {
content: [{ type: "text", text: JSON.stringify(data) }], // 为不支持UI的宿主提供文本回退
structuredContent: { data }, // 供UI渲染使用的结构化数据
};
});核心指导:
- 始终保留数组,为纯文本客户端提供文本回退
content - 添加字段,提供UI所需的渲染数据
structuredContent - 通过将工具与对应的资源关联
_meta.ui.resourceUri - 无需增强的工具保持不变——继续作为普通工具使用
Step 5: Register Resources
步骤5:注册资源
Register the HTML resource so the host can fetch it:
typescript
import fs from "node:fs/promises";
import path from "node:path";
const resourceUri = "ui://my-tool/mcp-app.html";
registerAppResource(server, {
uri: resourceUri,
name: "My Tool UI",
mimeType: RESOURCE_MIME_TYPE,
}, 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 }] };
});If multiple tools share the same UI, they can reference the same and the same resource registration.
resourceUri注册HTML资源,使宿主能够获取该资源:
typescript
import fs from "node:fs/promises";
import path from "node:path";
const resourceUri = "ui://my-tool/mcp-app.html";
registerAppResource(server, {
uri: resourceUri,
name: "我的工具UI",
mimeType: RESOURCE_MIME_TYPE,
}, 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,它们可以引用相同的和资源注册信息。
resourceUriStep 6: Build the UI
步骤6:构建UI
Handler Registration
处理器注册
Register ALL handlers BEFORE calling :
app.connect()typescript
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());在调用之前,必须注册所有处理器:
app.connect()typescript
import { App, PostMessageTransport, applyDocumentTheme, applyHostStyleVariables, applyHostFonts } from "@modelcontextprotocol/ext-apps";
const app = new App({ name: "My App", version: "1.0.0" });
app.ontoolinput = (params) => {
// 使用params.arguments和/或params.structuredContent渲染UI
};
app.ontoolresult = (result) => {
// 使用最终的工具结果更新UI
};
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());Host Styling
宿主样式适配
Use host CSS variables for theme integration:
css
.container {
background: var(--color-background-secondary);
color: var(--color-text-primary);
font-family: var(--font-sans);
border-radius: var(--border-radius-md);
}Key variable groups: , , , , , , , . See for the full list.
--color-background-*--color-text-*--color-border-*--font-sans--font-mono--font-text-*-size--font-heading-*-size--border-radius-*src/spec.types.tsFor React apps, use the and hooks instead — see for the pattern.
useAppuseHostStylesbasic-server-react/使用宿主CSS变量实现主题集成:
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应用,请使用和钩子替代上述方式——可参考中的实现模式。
useAppuseHostStylesbasic-server-react/Optional Enhancements
可选增强功能
App-Only Helper Tools
仅App辅助工具
Tools the UI calls but the model doesn't need to invoke directly (polling, pagination, chunk loading):
typescript
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) }] };
});The UI calls these via .
app.callServerTool("poll-data", {})供UI调用但模型无需直接触发的工具(轮询、分页、分块加载):
typescript
registerAppTool(server, "poll-data", {
description: "为UI轮询最新数据",
_meta: { ui: { resourceUri, visibility: ["app"] } },
}, async () => {
const data = await getLatestData();
return { content: [{ type: "text", text: JSON.stringify(data) }] };
});UI可通过调用这些工具。
app.callServerTool("poll-data", {})CSP Configuration
CSP配置
If the UI needs to load external resources (fonts, APIs, CDNs), declare the domains:
typescript
registerAppResource(server, {
uri: resourceUri,
name: "My Tool UI",
mimeType: RESOURCE_MIME_TYPE,
_meta: {
ui: {
connectDomains: ["api.example.com"], // fetch/XHR targets
resourceDomains: ["cdn.example.com"], // scripts, styles, images
frameDomains: ["embed.example.com"], // nested iframes
},
},
}, async () => { /* ... */ });如果UI需要加载外部资源(字体、API、CDN),请声明对应的域名:
typescript
registerAppResource(server, {
uri: resourceUri,
name: "我的工具UI",
mimeType: RESOURCE_MIME_TYPE,
_meta: {
ui: {
connectDomains: ["api.example.com"], // fetch/XHR目标域名
resourceDomains: ["cdn.example.com"], // 脚本、样式、图片资源域名
frameDomains: ["embed.example.com"], // 嵌套iframe域名
},
},
}, async () => { /* ... */ });Streaming Partial Input
流式部分输入
For large tool inputs, show progress during LLM generation:
typescript
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
};对于大型工具输入,在LLM生成过程中展示进度:
typescript
app.ontoolinputpartial = (params) => {
const args = params.arguments; // 修复后的部分JSON——始终有效
// 使用部分数据渲染预览
};
app.ontoolinput = (params) => {
// 最终完整输入——切换为完整渲染
};Graceful Degradation with getUiCapability()
getUiCapability()使用getUiCapability()
实现优雅降级
getUiCapability()Conditionally register App tools only when the client supports UI, falling back to text-only tools:
typescript
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.tool("my-tool", "Shows data", { param: z.string() }, plainToolHandler);
}
};仅当客户端支持UI时才注册App工具,否则回退到纯文本工具:
typescript
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)) {
// 客户端支持UI——注册App工具
registerAppTool(server, "my-tool", {
description: "以交互式UI展示数据",
_meta: { ui: { resourceUri } },
}, appToolHandler);
} else {
// 纯文本客户端——注册普通工具
server.tool("my-tool", "展示数据", { param: z.string() }, plainToolHandler);
}
};Fullscreen Mode
全屏模式
Allow the UI to expand to fullscreen:
typescript
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;
}允许UI切换至全屏模式:
typescript
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;
}Common Mistakes to Avoid
需避免的常见错误
- Forgetting text fallback — Always include
contentarray with text for non-UI hostscontent - Registering handlers after — Register ALL handlers BEFORE calling
connect()app.connect() - Missing — Without it, assets won't load in the sandboxed iframe
vite-plugin-singlefile - Forgetting resource registration — The tool references a that must have a matching resource
resourceUri - Hardcoding styles — Use host CSS variables () for theme integration
var(--color-*) - Not handling safe area insets — Always apply in
ctx.safeAreaInsetsonhostcontextchanged
-
遗漏文本回退——始终为不支持UI的宿主保留
content数组和文本内容content -
在之后注册处理器——必须在调用
connect()之前注册所有处理器app.connect() -
未使用——缺少该插件,资源将无法在沙箱iframe中加载
vite-plugin-singlefile -
未注册资源——工具引用的必须有对应的资源注册
resourceUri -
硬编码样式——使用宿主CSS变量()实现主题集成
var(--color-*) -
未处理安全区域内边距——必须在中应用
onhostcontextchangedctx.safeAreaInsets
Testing
测试
Using basic-host
使用basic-host测试
Test the enhanced server with the basic-host example:
bash
undefined使用basic-host示例测试增强后的服务器:
bash
undefinedTerminal 1: Build and run your server
终端1:构建并运行你的服务器
npm run build && npm run serve
npm run build && npm run serve
Terminal 2: Run basic-host (from cloned repo)
终端2:运行basic-host(从克隆的仓库中)
cd /tmp/mcp-ext-apps/examples/basic-host
npm install
SERVERS='["http://localhost:3001/mcp"]' npm run start
cd /tmp/mcp-ext-apps/examples/basic-host
npm install
SERVERS='["http://localhost:3001/mcp"]' npm run start
Configure `SERVERS` with a JSON array of your server URLs (default: `http://localhost:3001/mcp`).
使用JSON数组配置`SERVERS`,填入你的服务器URL(默认值:`http://localhost:3001/mcp`)。Verify
验证项
- Plain tools still work and return text output
- App tools render their UI in the iframe
- handler fires with tool arguments
ontoolinput - handler fires with tool result
ontoolresult - Host styling (theme, fonts, colors) applies correctly
- 普通工具仍可正常工作并返回文本输出
- App工具可在iframe中渲染对应的UI
- 处理器可接收工具参数并触发
ontoolinput - 处理器可接收工具结果并触发
ontoolresult - 宿主样式(主题、字体、颜色)可正确应用