mcpField
C
convert-web-app

convert-web-app

github.com/modelcontextprotocol开发3.0k976 次安装1 天前更新
代码仓库

关于 convert-web-app

面向希望让现有 Web 应用同时作为独立网站和 MCP App 运行的开发者,介绍如何通过单一代码库添加 MCP 支持。应用保留原有渲染逻辑,并按运行环境读取数据。

主要功能

  1. 01保留独立 Web 应用模式
  2. 02增加 MCP 运行环境检测
  3. 03将 HTML 封装为 MCP 资源
  4. 04为应用注册显示工具
  5. 05调查并配置 CSP 来源

适用场景

  1. 01为现有网站增加 MCP App 入口
  2. 02在独立模式和 MCP 模式复用界面
  3. 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.tsApp 类、处理程序(ontoolinput、ontoolresult、onhostcontextchanged、onteardown)、生命周期
src/server/index.tsregisterAppTool、registerAppResource、工具可见性选项
src/spec.types.ts所有类型定义:McpUiHostContext、CSS 变量键、显示模式
src/styles.tsapplyDocumentTheme、applyHostStyleVariables、applyHostFonts
src/react/useApp.tsx面向 React 应用的 useApp Hook
src/react/useHostStyles.tsuseHostStyles、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 应用,规划需要进行哪些更改。

调查内容
  1. 数据源 — 应用如何获取数据?(URL 参数、API 调用、属性、硬编码数据、localStorage)
  2. 外部依赖 — CDN 脚本、字体、API 端点、iframe 嵌入、WebSocket 连接
  3. 构建系统 — 当前打包工具(Webpack、Vite、Rollup 或无)、框架(React、Vue、vanilla)、入口点
  4. 用户交互 — 应用是否有应映射到工具参数的输入项/表单?
  5. 运行时检测 — 如何判断应用是否在 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 中声明——缺少来源时会静默失败。

编写任何代码之前,先构建应用并调查它引用的所有来源:

  1. 使用现有的构建命令构建应用
  2. 搜索生成的 HTML、CSS 和 JS,找出每个来源(不只是“外部”来源——每个网络请求都需要获得 CSP 批准)
  3. 对找到的每个来源,追溯到其来源:
    • 如果来自常量 → 通用(开发和生产环境相同)
    • 如果来自环境变量或条件判断 → 记录其机制,并确定开发和生产环境的值
  4. 检查第三方库是否会自行发出请求(分析、错误跟踪等)

将调查结果记录为三份列表,并注明每个来源是通用的、仅开发环境使用,还是仅生产环境使用:

  • 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>
两阶段构建
  1. Vite 打包 UI → dist/mcp-app.html(包含所有内联资源的单个文件)
  2. 单独编译服务端(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 */ },
};

应避免的常见错误

  1. 忘记声明外部源的 CSP —— 沙盒 iframe 中会静默失败
  2. 在 MCP 模式下使用 localStorage / sessionStorage —— 沙盒 iframe 中不可用;请使用回退方案或通过 structuredContent 传递
  3. 缺少 vite-plugin-singlefile —— 外部资源无法在 iframe 中加载
  4. 在 connect() 之后注册处理程序 —— 调用 app.connect() 之前注册所有处理程序
  5. 硬编码样式且不提供回退方案 —— 使用带有 var(..., fallback) 的宿主 CSS 变量,确保两种模式下都能正确显示
  6. 未处理安全区域内边距 —— 在 onhostcontextchanged 中始终应用 ctx.safeAreaInsets
  7. 忘记文本 content 回退 —— 始终为非 UI 宿主提供 content 数组
  8. 忘记注册资源 —— 工具引用的 resourceUri 必须有匹配的资源
  9. 替换独立模式逻辑,而不是添加分支 —— 保留原有数据源;在旁边添加 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)。

验证
  1. MCP 模式:应用在 basic-host 中加载,且控制台没有错误
  2. ontoolinput 处理程序会收到工具参数
  3. ontoolresult 处理程序会收到工具结果
  4. 宿主样式(主题、字体、颜色)正确应用
  5. 外部资源能够加载(若已配置 CSP 域名)
  6. 独立模式:直接在浏览器中打开应用时仍能正常运行

评论

支持 Markdown