add-app-to-server by modelcontextprotocol/ext-apps
npx skills add https://github.com/modelcontextprotocol/ext-apps --skill add-app-to-server使用 MCP Apps SDK (@modelcontextprotocol/ext-apps) 为现有的 MCP 服务器工具添加交互式用户界面。
现有的工具将与 HTML 资源配对,这些资源在宿主的对话中内联渲染。该工具在纯文本客户端上继续工作——用户界面是一种增强,而非替代。每个能从用户界面中受益的工具都通过 _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
直接从 /tmp/mcp-ext-apps/src/ 阅读 JSDoc 文档:
| 文件 | 内容 |
|---|---|
src/app.ts |
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
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 钩子 |
src/react/useHostStyles.ts | useHostStyles, useHostStyleVariables, useHostFonts 钩子 |
这些示例演示了同时包含应用增强工具和纯文本工具的服务器——这正是您要添加的模式:
| 示例 | 模式 |
|---|---|
examples/map-server/ | show-map (应用工具) + geocode (纯文本工具) |
examples/pdf-server/ | display_pdf (应用工具) + list_pdfs (纯文本工具) + read_pdf_bytes (仅应用工具) |
examples/system-monitor-server/ | get-system-info (应用工具) + poll-system-stats (仅应用轮询工具) |
从 /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 钩子) |
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 |
在编写任何代码之前,分析服务器的现有工具并确定哪些工具能从用户界面中受益。
| 工具输出类型 | 用户界面收益 | 示例 |
|---|---|---|
| 结构化数据 / 列表 / 表格 | 高 — 交互式表格、搜索、筛选 | 项目列表、搜索结果 |
| 随时间变化的指标 / 数字 | 高 — 图表、仪表盘、仪表板 | 系统统计、分析数据 |
| 媒体 / 富内容 | 高 — 查看器、播放器、渲染器 | 地图、PDF、图像、视频 |
| 简单文本 / 确认信息 | 低 — 文本即可 | "文件已创建"、"设置已更新" |
| 供其他工具使用的数据 | 考虑设为仅应用工具 | 轮询端点、分块加载器 |
npm install @modelcontextprotocol/ext-apps
npm install -D vite vite-plugin-singlefile
如果需要,还需添加框架特定的依赖项(例如,对于 React,需要 react、react-dom、@vitejs/plugin-react)。
使用 npm install 添加依赖项,而不是手动编写版本号。这可以让 npm 解析最新的兼容版本。切勿凭记忆指定版本号。
创建 vite.config.ts,使用 vite-plugin-singlefile 将用户界面打包成单个 HTML 文件:
import { defineConfig } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";
export default defineConfig({
plugins: [viteSingleFile()],
build: {
outDir: "dist",
rollupOptions: {
input: "mcp-app.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。必须在服务器代码打包之前构建用户界面:
{
"scripts": {
"build:ui": "vite build",
"build:server": "tsc",
"build": "npm run build:ui && npm run build:server",
"serve": "tsx server.ts"
}
}
将纯 MCP 工具转换为带有用户界面的应用工具。
之前(纯 MCP 工具):
server.tool("my-tool", { param: z.string() }, async (args) => {
const data = await fetchData(args.param);
return { content: [{ type: "text", text: JSON.stringify(data) }] };
});
之后(带有用户界面的应用工具):
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) }], // 为非用户界面宿主准备的文本回退方案
structuredContent: { data }, // 供用户界面使用的结构化数据
};
});
关键指导:
content 数组,为非用户界面宿主提供文本回退方案structuredContent 用于用户界面渲染所需的数据_meta.ui.resourceUri 将工具链接到其资源注册 HTML 资源,以便宿主可以获取它:
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 }] };
});
如果多个工具共享同一个用户界面,它们可以引用相同的 resourceUri 和相同的资源注册。
在调用 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) => {
// 使用 params.arguments 和/或 params.structuredContent 渲染用户界面
};
app.ontoolresult = (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/ 中的模式。
用户界面调用但模型不需要直接调用的工具(轮询、分页、分块加载):
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) }] };
});
用户界面通过 app.callServerTool("poll-data", {}) 调用这些工具。
如果用户界面需要加载外部资源(字体、API、CDN),请声明域名:
registerAppResource(server, {
uri: resourceUri,
name: "My Tool UI",
mimeType: RESOURCE_MIME_TYPE,
_meta: {
ui: {
connectDomains: ["api.example.com"], // fetch/XHR 目标
resourceDomains: ["cdn.example.com"], // 脚本、样式、图像
frameDomains: ["embed.example.com"], // 嵌套 iframe
},
},
}, async () => { /* ... */ });
对于大型工具输入,在 LLM 生成过程中显示进度:
app.ontoolinputpartial = (params) => {
const args = params.arguments; // 已修复的部分 JSON - 始终有效
// 使用部分数据渲染预览
};
app.ontoolinput = (params) => {
// 最终完整的输入 - 切换到完整渲染
};
getUiCapability() 进行优雅降级仅在客户端支持用户界面时有条件地注册应用工具,否则回退到纯文本工具:
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)) {
// 客户端支持用户界面 — 注册应用工具
registerAppTool(server, "my-tool", {
description: "Shows data with interactive UI",
_meta: { ui: { resourceUri } },
}, appToolHandler);
} else {
// 纯文本客户端 — 注册纯文本工具
server.tool("my-tool", "Shows data", { param: z.string() }, plainToolHandler);
}
};
允许用户界面扩展到全屏:
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 数组connect() 之后注册处理器 — 在调用 app.connect() 之前 注册 所有 处理器vite-plugin-singlefile — 没有它,资源将无法在沙盒化的 iframe 中加载resourceUri 必须有一个匹配的资源var(--color-*)) 进行主题集成onhostcontextchanged 中应用 ctx.safeAreaInsets使用 basic-host 示例测试增强后的服务器:
# 终端 1:构建并运行您的服务器
npm run build && npm run serve
# 终端 2:运行 basic-host(从克隆的仓库)
cd /tmp/mcp-ext-apps/examples/basic-host
npm install
SERVERS='["http://localhost:3001/mcp"]' npm run start
# 打开 http://localhost:8080
使用您的服务器 URL 的 JSON 数组配置 SERVERS(默认值:http://localhost:3001/mcp)。
ontoolinput 处理器随工具参数触发ontoolresult 处理器随工具结果触发每周安装次数
185
仓库
GitHub 星标数
1.9K
首次出现
2026 年 2 月 21 日
安全审计
安装在
cursor178
opencode177
codex177
gemini-cli176
kimi-cli175
github-copilot175
Enrich an existing MCP server's tools with interactive UIs using the MCP Apps SDK (@modelcontextprotocol/ext-apps).
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 _meta.ui.resourceUri, and the host renders that resource in a sandboxed iframe when the tool is called.
Clone the SDK repository for working examples and API documentation:
git clone --branch "v$(npm view @modelcontextprotocol/ext-apps version)" --depth 1 https://github.com/modelcontextprotocol/ext-apps.git /tmp/mcp-ext-apps
Read JSDoc documentation directly from /tmp/mcp-ext-apps/src/:
| File | Contents |
|---|---|
src/app.ts | App class, handlers (ontoolinput, ontoolresult, onhostcontextchanged, onteardown), lifecycle |
src/server/index.ts | registerAppTool, registerAppResource, getUiCapability, tool visibility options |
src/spec.types.ts | All type definitions: McpUiHostContext, CSS variable keys, display modes |
src/styles.ts | applyDocumentTheme, applyHostStyleVariables, applyHostFonts |
src/react/useApp.tsx | useApp hook for React apps |
src/react/useHostStyles.ts | useHostStyles, useHostStyleVariables, useHostFonts hooks |
These examples demonstrate servers with both App-enhanced and plain tools — the exact pattern you're adding:
| Example | Pattern |
|---|---|
examples/map-server/ | show-map (App tool) + geocode (plain tool) |
examples/pdf-server/ | display_pdf (App tool) + list_pdfs (plain tool) + read_pdf_bytes (app-only tool) |
examples/system-monitor-server/ | (App tool) + (app-only polling tool) |
Learn and adapt from /tmp/mcp-ext-apps/examples/basic-server-{framework}/:
| Template | Key Files |
|---|---|
basic-server-vanillajs/ | server.ts, src/mcp-app.ts, mcp-app.html |
basic-server-react/ | server.ts, src/mcp-app.tsx (uses useApp hook) |
basic-server-vue/ |
Before writing any code, analyze the server's existing tools and determine which ones benefit from UI.
| 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 |
npm install @modelcontextprotocol/ext-apps
npm install -D vite vite-plugin-singlefile
Plus framework-specific dependencies if needed (e.g., react, react-dom, @vitejs/plugin-react for React).
Use npm install to add dependencies rather than manually writing version numbers. This lets npm resolve the latest compatible versions. Never specify version numbers from memory.
Create vite.config.ts with vite-plugin-singlefile to bundle the UI into a single HTML file:
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
},
},
});
Create mcp-app.html (or one per distinct UI if tools need different views):
<!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>
Add build scripts to package.json. The UI must be built before the server code bundles it:
{
"scripts": {
"build:ui": "vite build",
"build:server": "tsc",
"build": "npm run build:ui && npm run build:server",
"serve": "tsx server.ts"
}
}
Transform plain MCP tools into App tools with UI.
Before (plain MCP tool):
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):
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:
content array with a text fallback for text-only clientsstructuredContent for data the UI needs to render_meta.ui.resourceUriRegister the HTML resource so the host can fetch it:
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 resourceUri and the same resource registration.
Register ALL handlers BEFORE calling 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());
Use host CSS variables for theme integration:
.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: --color-background-*, --color-text-*, --color-border-*, --font-sans, --font-mono, --font-text-*-size, --font-heading-*-size, --border-radius-*. See src/spec.types.ts for the full list.
For React apps, use the useApp and useHostStyles hooks instead — see basic-server-react/ for the pattern.
Tools the UI calls but the model doesn't need to invoke directly (polling, pagination, chunk loading):
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", {}).
If the UI needs to load external resources (fonts, APIs, CDNs), declare the domains:
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 () => { /* ... */ });
For large tool inputs, show progress during LLM generation:
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()Conditionally register App tools only when the client supports UI, falling back to text-only tools:
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);
}
};
Allow the UI to expand to fullscreen:
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 fallback — Always include content array with text for non-UI hostsconnect() — Register ALL handlers BEFORE calling app.connect()vite-plugin-singlefile — Without it, assets won't load in the sandboxed iframeresourceUri that must have a matching resourcevar(--color-*)) for theme integrationctx.safeAreaInsets in Test the enhanced server with the basic-host example:
# 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
Configure SERVERS with a JSON array of your server URLs (default: http://localhost:3001/mcp).
ontoolinput handler fires with tool argumentsontoolresult handler fires with tool resultWeekly Installs
185
Repository
GitHub Stars
1.9K
First Seen
Feb 21, 2026
Security Audits
Gen Agent Trust HubPassSocketWarnSnykPass
Installed on
cursor178
opencode177
codex177
gemini-cli176
kimi-cli175
github-copilot175
React 组合模式指南:Vercel 组件架构最佳实践,提升代码可维护性
116,600 周安装
Rust 重构助手:安全重构工具,支持重命名、提取函数、内联和移动操作
631 周安装
SwiftUI 布局与组件指南:iOS 26+ 网格、列表、滚动视图最佳实践
645 周安装
dbs-unblock执行力诊断AI:基于阿德勒心理学,帮你找出拖延不行动的真实原因
628 周安装
Google Workspace Apps Script CLI 工具 - 自动化脚本项目管理和部署
629 周安装
约定式提交规范指南:Git提交消息格式、类型与最佳实践
632 周安装
Google Drive 批量重命名文件教程 - 使用 gws CLI 自动化文件命名规范
634 周安装
get-system-infopoll-system-statsserver.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 |
onhostcontextchanged