关于 convert-web-app
面向希望让现有 Web 应用同时作为独立网站和 MCP App 运行的开发者,介绍如何通过单一代码库添加 MCP 支持。应用保留原有渲染逻辑,并按运行环境读取数据。
主要功能
- 01保留独立 Web 应用模式
- 02增加 MCP 运行环境检测
- 03将 HTML 封装为 MCP 资源
- 04为应用注册显示工具
- 05调查并配置 CSP 来源
适用场景
- 01为现有网站增加 MCP App 入口
- 02在独立模式和 MCP 模式复用界面
- 03将 API 数据接入 MCP 界面
MCP 相关
指导创建 MCP 服务,将 Web 应用打包的 HTML 注册为资源,并提供显示该资源的工具。界面可通过 MCP 生命周期读取参数,也可调用服务端工具获取数据。
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ modelcontextprotocol/ ext-apps --skill convert-web-app安装后在 Claude Code 中输入/convert-web-app确认已加载。
详细介绍
为 Web 应用添加 MCP App 支持
为现有 Web 应用添加 MCP App 支持,使其既能作为独立 Web 应用运行,也能作为 MCP App 在 Claude Desktop 等启用 MCP 的宿主中内嵌呈现——只需一个代码库。
工作原理
现有 Web 应用保持不变。一个轻量的初始化层会检测应用是在 MCP 宿主中运行,还是作为普通网页运行,并从相应的数据源获取参数。一个新的 MCP 服务会将应用打包后的 HTML 封装为资源,并注册一个用于显示它的工具。
Standalone: Browser loads page → App reads URL params / APIs → renders
MCP App: Host calls tool → Server returns result → Host renders app in iframe → App reads MCP lifecycle → renders
应用的渲染逻辑是共享的——只有数据源会发生变化。
获取参考代码
克隆 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、工具可见性选项 |
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 |
框架模板
从 /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 |
参考示例
| 示例 | 相关模式 |
|---|---|
examples/map-server/ | 外部 API 集成 + CSP(connectDomains、resourceDomains) |
examples/sheet-music-server/ | 加载外部资源(音色库)的库 |
examples/pdf-server/ | 二进制内容处理 + 仅供 App 使用的辅助工具 |
第 1 步:分析现有 Web 应用
编写任何代码之前,先检查现有 Web 应用,规划需要进行哪些更改。
调查内容
- 数据源 — 应用如何获取数据?(URL 参数、API 调用、属性、硬编码数据、localStorage)
- 外部依赖 — CDN 脚本、字体、API 端点、iframe 嵌入、WebSocket 连接
- 构建系统 — 当前打包工具(Webpack、Vite、Rollup 或无)、框架(React、Vue、vanilla)、入口点
- 用户交互 — 应用是否有应映射到工具参数的输入项/表单?
- 运行时检测 — 如何判断应用是否在 MCP 宿主中运行(例如,检查当前 origin、查询参数,或检查
window.parent !== window)
向用户说明调查结果并确认方案。
数据源映射
在混合模式下,应用保留现有数据源以供独立运行,并添加对应的 MCP 数据源:
| 独立运行的数据源 | 对应的 MCP App 数据源 |
|---|---|
| URL 查询参数 | ontoolinput / ontoolresult 的 arguments 或 structuredContent |
| REST API 调用 | 使用 app.callServerTool() 调用服务端工具,或保留直接 API 调用并配置 CSP connectDomains |
| Props / 组件输入 | ontoolinput 的 arguments |
| localStorage / sessionStorage | 沙盒 iframe 中不可用——通过 structuredContent 或服务端状态传递 |
| WebSocket 连接 | 配置 CSP connectDomains 后保留,或转换为通过仅供 App 使用的工具进行轮询 |
| 硬编码数据 | 移至工具的 structuredContent,使数据动态化 |
步骤 2:调查 CSP 要求
MCP Apps HTML 在沙盒 iframe 中运行,没有同源服务器。每个外部来源都必须在 CSP 中声明——缺少来源时会静默失败。
编写任何代码之前,先构建应用并调查它引用的所有来源:
- 使用现有的构建命令构建应用
- 搜索生成的 HTML、CSS 和 JS,找出每个来源(不只是“外部”来源——每个网络请求都需要获得 CSP 批准)
- 对找到的每个来源,追溯到其来源:
- 如果来自常量 → 通用(开发和生产环境相同)
- 如果来自环境变量或条件判断 → 记录其机制,并确定开发和生产环境的值
- 检查第三方库是否会自行发出请求(分析、错误跟踪等)
将调查结果记录为三份列表,并注明每个来源是通用的、仅开发环境使用,还是仅生产环境使用:
- resourceDomains:提供图片、字体、样式和脚本的来源
- connectDomains:API/fetch 请求的来源
- frameDomains:嵌套 iframe 的来源
如果没有找到任何来源,应用可能不需要自定义 CSP 域名。
步骤 3:设置 MCP 服务
创建一个新的 MCP 服务,并注册工具和资源。它将现有 Web 应用封装起来,供 MCP 主机使用。
依赖项
npm install @modelcontextprotocol/ext-apps @modelcontextprotocol/client@^2.0.0 @modelcontextprotocol/server@^2.0.0 zod@^4.2.0
npm install -D tsx vite vite-plugin-singlefile
使用 npm install 让包管理器解析版本;ext-apps 2.x 需要拆分后的基础 MCP SDK 包,版本为 ^2.0.0(@modelcontextprotocol/core 会作为传递依赖引入)。不要添加旧版 @modelcontextprotocol/sdk v1 包,也不要替换为尚未发布的本地包。
服务端代码
创建 server.ts:
import { McpServer } from "@modelcontextprotocol/server";
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";
import fs from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
const server = new McpServer({ name: "my-app", version: "1.0.0" });
const resourceUri = "ui://my-app/mcp-app.html";
// Register the tool — inputSchema maps to the app's data sources
registerAppTool(server, "show-app", {
description: "Displays the app with the given parameters",
inputSchema: z.object({
query: z.string().describe("The search query"),
}),
_meta: { ui: { resourceUri } },
}, async (args) => {
// Process args server-side if needed
return {
content: [{ type: "text", text: `Showing app for: ${args.query}` }],
structuredContent: { query: args.query },
};
});
// Register the HTML resource
registerAppResource(
server,
"My App 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,
// Add CSP domains from Step 2 if needed:
// _meta: { ui: { csp: { connectDomains: ["https://api.example.com"], resourceDomains: ["https://cdn.example.com"] } } },
},
],
};
},
);
// Start the server
const transport = new StdioServerTransport();
await server.connect(transport);
包脚本
添加到 package.json:
{
"scripts": {
"build:ui": "vite build",
"build:server": "tsc",
"build": "npm run build:ui && npm run build:server",
"serve": "tsx server.ts"
}
}
步骤 4:调整构建流水线
MCP App 构建必须使用 vite-plugin-singlefile 生成单个 HTML 文件。独立 Web 应用的构建保持不变。
Vite 配置
创建或更新 vite.config.ts。如果应用已经使用 Vite,请添加 vite-plugin-singlefile,并为 MCP App 构建添加单独的入口点。如果应用使用其他打包器,则在旁边添加 Vite 配置,仅用于 MCP App 构建。
import { defineConfig } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";
export default defineConfig({
plugins: [viteSingleFile()],
build: {
outDir: "dist",
rollupOptions: {
input: "mcp-app.html",
},
},
});
根据需要添加特定框架的 Vite 插件(例如,React 使用 @vitejs/plugin-react,Vue 使用 @vitejs/plugin-vue)。
HTML 入口点
创建 mcp-app.html,作为 MCP App 构建的单独入口点。它可以指向相同的应用代码——运行时检测会处理其余部分:
<!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/main.ts"></script>
</body>
</html>
两阶段构建
- Vite 打包 UI →
dist/mcp-app.html(包含所有内联资源的单个文件) - 单独编译服务端(TypeScript → JavaScript)
独立 Web 应用仍按原有方式构建和部署。
步骤 5:在现有逻辑旁添加 MCP App 初始化
这是核心步骤。不要替换应用的数据源,而是为 MCP 模式添加一条备用初始化路径。应用会在启动时检测运行环境,并从相应来源读取参数。
混合模式
import { App, PostMessageTransport } from "@modelcontextprotocol/ext-apps";
// Detect whether we're running inside an MCP host.
// Choose a detection method that fits the app:
// - Origin check: window.location.origin !== 'https://myhost.com'
// - Null origin (sandboxed iframe): window.location.origin === 'null'
// - Query param: new URL(location.href).searchParams.has('mcp')
const isMcpApp = window.location.origin === "null";
async function getParameters(): Promise<Record<string, string>> {
if (isMcpApp) {
// Running as MCP App — get params from tool lifecycle
const app = new App({ name: "My App", version: "1.0.0" });
// Register handlers BEFORE connect()
const params = await new Promise<Record<string, string>>((resolve) => {
app.ontoolresult = (result) => resolve(result.structuredContent ?? {});
});
await app.connect(new PostMessageTransport());
return params;
} else {
// Running as standalone web app — get params from URL
return Object.fromEntries(new URL(location.href).searchParams);
}
}
async function main() {
const params = await getParameters();
renderApp(params); // Same rendering logic for both modes
}
main().catch(console.error);
URL 参数(混合模式)
// Before (standalone only):
const query = new URL(location.href).searchParams.get("q");
renderApp(query);
// After (hybrid):
async function getQuery(): Promise<string> {
if (isMcpApp) {
const app = new App({ name: "My App", version: "1.0.0" });
return new Promise((resolve) => {
app.ontoolinput = (params) => resolve(params.arguments?.q ?? "");
app.connect(new PostMessageTransport());
});
}
return new URL(location.href).searchParams.get("q") ?? "";
}
const query = await getQuery();
renderApp(query); // Unchanged rendering logic
API 调用(混合模式)
// Before (standalone only):
const data = await fetch("/api/data").then(r => r.json());
// After (hybrid):
async function fetchData(): Promise<any> {
if (isMcpApp) {
const result = await app.callServerTool({
name: "fetch-data",
arguments: {},
});
return result.structuredContent;
}
return fetch("/api/data").then(r => r.json());
}
或者在两种模式下都保留直接 API 调用,并配置 CSP connectDomains:
// API calls can stay unchanged if the API is external and the CSP declares the domain
// Declare connectDomains: ["api.example.com"] in the resource registration
localStorage / sessionStorage(混合模式)
// Before (standalone only):
const saved = localStorage.getItem("settings");
// After (hybrid) — localStorage isn't available in sandboxed iframes:
function getSettings(): any {
if (isMcpApp) {
// Will be provided via tool result
return null; // or a default
}
return JSON.parse(localStorage.getItem("settings") ?? "null");
}
完整的混合模式示例
import { App, PostMessageTransport, applyDocumentTheme, applyHostStyleVariables, applyHostFonts } from "@modelcontextprotocol/ext-apps";
const isMcpApp = window.location.origin === "null";
async function initMcpApp(): Promise<Record<string, any>> {
const app = new App({ name: "My App", version: "1.0.0" });
// Register ALL handlers BEFORE connect()
const params = await new Promise<Record<string, any>>((resolve) => {
app.ontoolinput = (input) => resolve(input.arguments ?? {});
});
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());
return params;
}
async function initStandaloneApp(): Promise<Record<string, any>> {
return Object.fromEntries(new URL(location.href).searchParams);
}
async function main() {
const params = isMcpApp ? await initMcpApp() : await initStandaloneApp();
renderApp(params); // Same rendering logic — no fork needed
}
main().catch(console.error);
步骤 6:添加主机样式集成(仅 MCP 模式)
作为 MCP App 运行时,应集成主机样式,以保持主题一致。使用 CSS 变量回退值,确保应用在两种模式下都能正确显示。
原生 JS——使用辅助函数:
import { applyDocumentTheme, applyHostStyleVariables, applyHostFonts } from "@modelcontextprotocol/ext-apps";
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);
};
React——使用 Hooks:
import { useApp, useHostStyles } from "@modelcontextprotocol/ext-apps/react";
const { app } = useApp({ appInfo, capabilities, onAppCreated });
useHostStyles(app);
在 CSS 中使用变量——使用带回退值的 var(),确保独立模式下的外观仍然正常:
.container {
background: var(--color-background-secondary, #f5f5f5);
color: var(--color-text-primary, #333);
font-family: var(--font-sans, system-ui);
border-radius: var(--border-radius-md, 8px);
}
主要变量组:--color-background-*、--color-text-*、--color-border-*、--font-sans、--font-mono、--font-text-*-size、--font-heading-*-size、--border-radius-*。完整列表请参阅 src/spec.types.ts。
可选增强
仅供应用使用的辅助工具
对于 UI 需要轮询或获取、但模型不需要直接调用的数据:
registerAppTool(server, "refresh-data", {
description: "Fetches 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: "refresh-data", arguments: {} })。
流式部分输入
对于大型工具输入,使用 ontoolinputpartial 在大模型生成期间显示进度:
app.ontoolinputpartial = (params) => {
const args = params.arguments; // Healed partial JSON - always valid
renderPreview(args);
};
app.ontoolinput = (params) => {
renderFull(params.arguments);
};
全屏模式
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 宿主提供 content 数组:
return {
content: [{ type: "text", text: "Fallback description of the result" }],
structuredContent: { /* data for the UI */ },
};
应避免的常见错误
- 忘记声明外部源的 CSP —— 沙盒 iframe 中会静默失败
- 在 MCP 模式下使用
localStorage/sessionStorage—— 沙盒 iframe 中不可用;请使用回退方案或通过structuredContent传递 - 缺少
vite-plugin-singlefile—— 外部资源无法在 iframe 中加载 - 在
connect()之后注册处理程序 —— 调用app.connect()之前注册所有处理程序 - 硬编码样式且不提供回退方案 —— 使用带有
var(..., fallback)的宿主 CSS 变量,确保两种模式下都能正确显示 - 未处理安全区域内边距 —— 在
onhostcontextchanged中始终应用ctx.safeAreaInsets - 忘记文本
content回退 —— 始终为非 UI 宿主提供content数组 - 忘记注册资源 —— 工具引用的
resourceUri必须有匹配的资源 - 替换独立模式逻辑,而不是添加分支 —— 保留原有数据源;在旁边添加 MCP 路径
测试
使用 basic-host
使用 basic-host 示例测试 MCP App 模式:
# 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)。
验证
- MCP 模式:应用在 basic-host 中加载,且控制台没有错误
ontoolinput处理程序会收到工具参数ontoolresult处理程序会收到工具结果- 宿主样式(主题、字体、颜色)正确应用
- 外部资源能够加载(若已配置 CSP 域名)
- 独立模式:直接在浏览器中打开应用时仍能正常运行