将 Web 应用改造为混合 MCP App:tldraw 仓库中的完整落地指南
【免费下载链接】tldrawBuild infinite canvas apps in React with the tldraw SDK. World's best, top-most agent recommended #1 five star SDK.项目地址: https://gitcode.com/GitHub_Trending/tl/tldraw
本指南以 apps/mcp-app/.claude/skills/convert-web-app/SKILL.md 为核心骨架,结合 tldraw 仓库内真实的 MCP App 实现(
apps/mcp-app)展开,讲解如何在不破坏现有独立 Web 应用的前提下,用同一份代码库同时支持浏览器独立运行与 MCP 宿主内联渲染(如 Claude Desktop、ChatGPT、Cursor)两种模式。读完你将掌握:混合架构的核心原理、MCP 服务器的工具与资源注册、单文件构建改造、运行时环境探测与参数桥接、宿主样式集成、CSP 声明与完整测试方法,并能对照 tldraw 仓库的真实代码验证每一个步骤。
一、核心概念:什么是"混合 MCP App"
MCP App(Model Context Protocol App)是 MCP 生态中一种特殊的应用形态:宿主(Host)调用 MCP 服务器上的一个工具,服务器把应用打包后的 HTML 作为资源返回,宿主再将该 HTML 渲染进一个沙箱化的 iframe 中,从而让模型与用户可以直接在一个内联界面里交互。
"混合"(hybrid)的含义是:同一个代码库、同一套渲染逻辑,既能在浏览器里作为普通网页独立运行,也能作为 MCP App 在支持 MCP App 规范的客户端(Claude Desktop、ChatGPT、Cursor 等)中内联渲染。其核心原则是:
- 现有 Web 应用保持原样,不替换其数据源;
- 新增一层极薄的初始化层,在启动时探测运行环境,从对应来源获取参数;
- 新增一个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应用自身的渲染逻辑在两种模式下完全共享,只有数据来源不同。
在 tldraw 仓库中的真实形态
tldraw 仓库中的 apps/mcp-app 正是这一模式的完整落地:它由两部分组成——运行在 Cloudflare Workers 上的 MCP服务器(src/worker.ts+ src/register-tools.ts),以及一个在宿主 iframe 中渲染完整 tldraw 画布的 Reactwidget(src/widget/mcp-app.tsx)。模型通过exec工具把 JavaScript 代码发送到 widget,widget 在活跃的Editor实例上执行并把画布状态回传,形成"模型写代码、画布实时渲染"的闭环。后续章节会反复以这份真实代码作为参考实现来印证 SKILL.md 中的每一步。
二、获取参考代码与 API 文档
动手之前,先克隆 MCP 官方扩展仓库获取可运行的示例和 API 文档。SKILL.md 给出了克隆命令:
git clone --branch "v$(npm view @modelcontextprotocol/ext-apps version)" --depth 1 https://github.com/modelcontextprotocol/ext-apps.git /tmp/mcp-ext-appsAPI 参考(源码文件)
克隆完成后,直接阅读/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 应用的useAppHook |
src/react/useHostStyles.ts | useHostStyles、useHostStyleVariables、useHostFontsHook |
框架模板
从/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(使用useAppHook) |
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/ | 加载外部资源的库(如 soundfonts) |
examples/pdf-server/ | 二进制内容处理 + 仅 App 使用的辅助工具 |
在 tldraw 仓库中,可以直接对照 apps/mcp-app/package.json 查看真实依赖:@modelcontextprotocol/ext-apps(^1.0.0)、@modelcontextprotocol/sdk(1.29.0)、zod(^4.1.8)为运行时依赖,vite、vite-plugin-singlefile、tsx为构建依赖,与 SKILL.md 推荐的依赖组合完全一致。
三、第一步:分析现有 Web 应用
在写任何代码之前,先审视现有应用,规划需要改动的内容。
需要调查的五个方面
- 数据来源—— 应用如何获取数据?(URL 参数、API 调用、props、硬编码、localStorage)
- 外部依赖—— CDN 脚本、字体、API 端点、iframe 嵌入、WebSocket 连接
- 构建系统—— 当前打包器(Webpack、Vite、Rollup 或没有)、框架(React、Vue、原生)、入口点
- 用户交互—— 应用是否有应映射为工具参数的输入框/表单?
- 运行时探测—— 如何判断应用运行在 MCP 宿主内(例如检查当前 origin、查询参数,或
window.parent !== window)
把调查结果呈现给用户并确认改造方案。
数据源映射表
混合模式下,应用保留独立运行时的数据源,同时新增 MCP 等价物:
| 独立数据源 | MCP App 等价方案 |
|---|---|
| URL 查询参数 | ontoolinput/ontoolresult的arguments或structuredContent |
| REST API 调用 | 通过app.callServerTool()调用服务端工具,或保留直接 API 调用并用 CSPconnectDomains放行 |
| Props / 组件输入 | ontoolinput的arguments |
| localStorage / sessionStorage | 沙箱 iframe 中不可用 —— 通过structuredContent或服务端状态传入 |
| WebSocket 连接 | 用 CSPconnectDomains保留,或通过仅 App 使用的工具转为轮询 |
| 硬编码数据 | 迁移到工具的structuredContent使其动态化 |
tldraw 的 widget 是这一映射的极佳范本:独立模式不适用(它本身就是为 MCP 宿主设计的),但它展示了"服务端数据经由structuredContent送达 widget"的完整链路。例如 persistence.ts 中的parseCheckpointFromToolResult从工具结果的structuredContent中解析出tldrawRecords、assets、bindings等画布数据,再通过applySnapshot应用到编辑器。
四、第二步:调查 CSP 要求
MCP App 的 HTML 运行在沙箱 iframe中,没有同源服务器。每一个外部 origin 都必须在 CSP 中声明——缺失的 origin 会静默失败(请求被拦截但没有任何报错提示)。
在写任何代码之前,先构建应用并调查它引用的所有 origin:
- 用现有构建命令构建应用;
- 在产物 HTML、CSS、JS 中搜索每一个origin(不只是"外部"origin——每个网络请求都需要 CSP 批准);
- 追溯每个 origin 的来源:
- 来自常量 → 通用(dev 与 prod 相同);
- 来自环境变量或条件逻辑 → 记录机制并确认 dev 与 prod 各自的值;
- 检查可能自行发起请求的第三方库(分析、错误上报等)。
将调查结果整理为三份清单,并标注每个 origin 是通用、仅 dev 还是仅 prod:
- resourceDomains:提供图片、字体、样式、脚本的 origin;
- connectDomains:API / fetch 请求的 origin;
- frameDomains:嵌套 iframe 的 origin。
如果没有发现任何 origin,应用可能不需要自定义 CSP 域。
tldraw 的真实 CSP 配置
tldraw 的服务器在 register-tools.ts 的readCanvasResource中为 widget 资源注入 CSP 元数据:
_meta: { ui: { csp: { resourceDomains: [ 'https://cdn.tldraw.com', 'https://fonts.googleapis.com', 'https://fonts.gstatic.com', ...(opts.extraResourceDomains ?? []), 'blob:', ], connectDomains: ['https://cdn.tldraw.com', ...(opts.extraConnectDomains ?? [])], }, permissions: { clipboardWrite: {} }, ...(domain ? { domain } : {}), }, },注意这里同时声明了资源域(CDN、Google Fonts)与连接域,且保留了extraResourceDomains/extraConnectDomains扩展点,供部署方按需追加(例如 R2 图片 URL)。这正是 SKILL.md"把每个 origin 追溯到源头"的落地形态——tldraw 通过字体与 CDN 资源在沙箱内正常加载,而blob:域用于图片等二进制内容。此外还针对不同宿主计算 widget 域(getWidgetDomain):ChatGPT 使用https://tldraw.com,Claude 使用基于/mcpURL 哈希派生的*.claudemcpcontent.com域。
五、第三步:搭建 MCP 服务器
创建一个新的 MCP 服务器,注册工具与资源,把现有 Web 应用包装起来供 MCP 宿主使用。
依赖安装
npm install @modelcontextprotocol/ext-apps @modelcontextprotocol/sdk zod npm install -D tsx vite vite-plugin-singlefileSKILL.md 明确建议:使用npm install添加依赖而不是手写版本号,让 npm 解析最新的兼容版本,绝不要凭记忆填写版本号。对照 tldraw 仓库的 package.json,其生产依赖@modelcontextprotocol/sdk固定为 1.29.0,zod为 ^4.1.8,vite-plugin-singlefile为 ^2.3.2,构建工具使用tsx、vite与@vitejs/plugin-react。
服务器代码
创建server.ts:
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' 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: { 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, { uri: resourceUri, name: 'My App UI', mimeType: RESOURCE_MIME_TYPE, // Add CSP domains from Step 2 if needed: // _meta: { ui: { connectDomains: ["api.example.com"], resourceDomains: ["cdn.example.com"] } }, }, 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 }] } } ) // 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" } }tldraw 的真实脚本(apps/mcp-app/package.json)展示了同样的结构但更完整:build:widget先生成 Editor API 文档、拷贝 CSS,再执行vite build并把产物重命名为dist/mcp-app.html;dev构建后启动本地 Cloudflare worker(HTTP MCP,localhost:8787);dev:tunnel通过 cloudflared 建立稳定命名隧道以便 ChatGPT 等要求 HTTPS 的宿主联调;deploy构建后发布到生产。
六、第四步:改造构建管线
MCP App 的构建必须用vite-plugin-singlefile产出单个 HTML 文件;独立 Web 应用的构建保持不变。
Vite 配置
创建或更新vite.config.ts。如果应用已使用 Vite,为 MCP App 构建添加vite-plugin-singlefile与独立入口;如果使用其他打包器,则并排新增一个仅用于 MCP App 构建的 Vite 配置。
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)。
tldraw 的 vite.config.ts 是带 React 插件的真实版本:
import react from '@vitejs/plugin-react' import { defineConfig } from 'vite' import { viteSingleFile } from 'vite-plugin-singlefile' export default defineConfig({ plugins: [react(), viteSingleFile()], root: 'src/widget', envDir: '../..', build: { outDir: '../../dist', emptyOutDir: false, }, })注意两点差异:root指向src/widget(入口在该目录),emptyOutDir: false避免清空同级的其他构建产物。
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>tldraw 的 src/widget/index.html 遵循同一模式(额外在<head>中内联了基础样式与 Inter 字体)。
两阶段构建
- Vite 打包 UI →
dist/mcp-app.html(所有资源内联进单个文件); - 服务器单独编译(TypeScript → JavaScript)。
独立 Web 应用继续按原有方式构建与部署,完全不受影响。
七、第五步:在现有逻辑旁加入 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 logicAPI 调用(混合模式)
// 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('fetch-data', {}) return result.structuredContent } return fetch('/api/data').then((r) => r.json()) }或者两种模式都保留直接 API 调用,只需在 CSP 中声明域名:
// API calls can stay unchanged if the API is external and the CSP declares the domain // Declare connectDomains: ["api.example.com"] in the resource registrationtldraw 的 widget 大量使用了app.callServerTool()这一桥接通道,例如在 mcp-app.tsx 中通过app.callServerTool({ name: '_get_canvas_state', arguments: { canvasId } })从服务器拉取画布历史状态,并在 persistence.ts 中调用save_checkpoint把用户编辑持久化到服务器。
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)tldraw 的运行时探测与处理器注册
tldraw 的 React widget 用useAppHook 代替手动new App(),并在 mcp-app.tsx 中声明宿主能力:
const { app, isConnected, error } = useApp({ appInfo: { name: MCP_SERVER_NAME, version: MCP_SERVER_VERSION, title: MCP_SERVER_TITLE, description: MCP_SERVER_DESCRIPTION, websiteUrl: MCP_SERVER_WEBSITE_URL, }, capabilities: { availableDisplayModes: ['fullscreen', 'inline'], }, })随后在TldrawCanvas的useEffect中按 SKILL.md 的"先注册再连接"原则挂载全部生命周期处理器(mcp-app.tsx):
app.ontoolinput:接收exec工具传入的code与canvasId,经 500ms 防抖后执行代码(防止宿主重复投递);app.ontoolinputpartial:接收 LLM 生成过程中的部分参数(已被"修复"为始终合法的 JSON),经 1000ms 防抖后提前预览渲染——这正是 SKILL.md 中"流式部分输入"增强的实战用法;app.ontoolresult:解析工具结果中的 checkpoint(画布快照),应用快照、缩放定位新图形、持久化到 localStorage 与服务器,并调用_exec_callback回传执行结果;app.ontoolcancelled:取消防抖定时器并重置执行守卫;app.onhostcontextchanged:同步宿主主题到编辑器(editor.user.updateUserPreferences({ colorScheme: theme }))、处理显示模式切换与容器尺寸变化;app.onteardown:返回空对象,配合teardownEditor清理编辑器与定时器。
这份真实代码完整印证了 SKILL.md 的每一条规范:处理器先注册后connect()、防抖避免重复执行、onteardown释放资源、safeAreaInsets与容器尺寸处理等。
八、第六步:接入宿主样式(仅 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—— 使用 Hook:
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。
tldraw 的 widget 对宿主主题的处理更进一步:它不仅把主题应用于编辑器(mcp-app.tsx 的applyHostThemeToEditor),还在onhostcontextchanged中读取ctx.containerDimensions设置容器高度、根据ctx.displayMode切换内联/全屏布局、并在全屏模式下同步documentElement与body的高度。
九、可选增强
仅 App 使用的辅助工具(App-Only Helper Tools)
对于 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("refresh-data", {})调用。visibility: ['app']表示该工具只对 App 可见,不会暴露给模型。
tldraw 服务器定义了三个这样的仅 App 工具(register-tools.ts):
_exec_callback:widget 执行完代码后回调,用于解决挂起的exec请求(_meta: { ui: { visibility: ['app'] } });_get_canvas_state:widget 按canvasId拉取画布最新 checkpoint;read_checkpoint/save_checkpoint:widget 按 checkpoint ID 读写画布形状,实现持久化。
这组工具正是"数据经structuredContent在 UI 与服务端之间流动"的实战样例:save_checkpoint的入参shapesJson要求为 JSON 数组字符串(解析校验),返回值同时携带content文本与structuredContent结构化数据。
流式部分输入(Streaming Partial Input)
对于大型工具输入,使用ontoolinputpartial在 LLM 生成期间展示进度:
app.ontoolinputpartial = (params) => { const args = params.arguments // Healed partial JSON - always valid renderPreview(args) } app.ontoolinput = (params) => { renderFull(params.arguments) }tldraw widget 的ontoolinputpartial用 1000ms 防抖执行代码预览(比完整输入的 500ms 更长,避免频繁触发),从而在模型还在"打字"时就能看到画布逐步成形(mcp-app.tsx)。
全屏模式(Fullscreen Mode)
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 }tldraw widget 完整实现了全屏切换(mcp-app.tsx):切换前先syncEditorState把当前画布提交为 checkpoint;从全屏切回内联时也做同样同步;移动端宿主(ctx.platform === 'mobile')则禁用全屏,并在进入全屏后强制回退到内联。
文本兜底(Text Fallback)
始终为非 UI 宿主提供content数组:
return { content: [{ type: 'text', text: 'Fallback description of the result' }], structuredContent: { /* data for the UI */ }, }tldraw 的exec工具返回正是这种双通道结构(tools/exec.ts):content中的文本向模型说明"画布正在渲染、后续会把状态附加到对话",structuredContent中的canvasId则通过宿主的工具结果事件可靠地送达 widget,让 widget 获知服务器分配的画布 ID——即使宿主把 widget 调用路由到了不同的 MCP 会话。
十、需要避免的常见错误
- 忘记为外部 origin 声明 CSP—— 在沙箱 iframe 中会静默失败;
- 在 MCP 模式下使用
localStorage/sessionStorage—— 沙箱 iframe 中不可用;改用兜底值或经structuredContent传入; - 缺少
vite-plugin-singlefile—— 外部资源在 iframe 中无法加载; - 在
connect()之后才注册处理器—— 必须在调用app.connect()之前注册全部处理器; - 硬编码样式无兜底—— 使用带
var(..., fallback)的宿主 CSS 变量,保证两种模式都正确; - 不处理安全区 insets—— 始终在
onhostcontextchanged中应用ctx.safeAreaInsets; - 忘记文本
content兜底—— 始终为非 UI 宿主提供content数组; - 忘记注册资源—— 工具引用的
resourceUri必须存在对应的资源注册; - 替换独立逻辑而不是分支—— 保留原始数据源,在其旁新增 MCP 路径。
tldraw 的实现还额外处理了两个易错点:一是在 register-tools.ts 中注册了ui://show-canvas/{version}/mcp-app.html的兼容资源模板——宿主会持久缓存 widget 资源 URI,即使服务器不再公布某个 URI 变体,仍能正常渲染;二是exec结果采用有界等待(tools/exec.ts,常规宿主 4s、代码编辑器宿主 8s),超时后返回非错误文本而非挂起工具,因为 widget 会独立把画布状态推送给模型。
十一、测试
使用 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用 JSON 数组配置SERVERS(默认:http://localhost:3001/mcp)。
验证清单
- MCP 模式:应用在 basic-host 中加载且无控制台报错;
ontoolinput处理器以工具参数触发;ontoolresult处理器以工具结果触发;- 宿主样式(主题、字体、颜色)正确应用;
- 外部资源正常加载(若配置了 CSP 域);
- 独立模式:直接在浏览器打开应用仍正常工作。
tldraw 仓库对 MCP 模式的测试覆盖可以从 apps/mcp-app/src 下的测试文件窥见:agents-canary.test.ts、large-payload-smoke.test.ts、idle-expiry.test.ts等(经 vitest.config.ts 运行),配合 apps/mcp-app/README.md 中针对 Cursor、Claude Desktop、ChatGPT 的详细接入步骤(本地 HTTP 服务器、云开发隧道、自定义连接器流程),构成了从单元测试到真实宿主联调的完整验证体系。
十二、总结:改造路线图
把现有 Web 应用转换为混合 MCP App 的完整路径:
- 分析现有应用的数据源、外部依赖、构建系统与交互入口;
- 调查构建产物中的全部 origin,整理为
resourceDomains/connectDomains/frameDomains三份清单; - 搭建MCP 服务器:
registerAppTool注册展示工具,registerAppResource把打包 HTML 注册为资源; - 改造构建:
vite-plugin-singlefile产出单文件 HTML,服务器单独编译; - 初始化:在现有逻辑旁加入环境探测与 MCP 参数桥接,渲染逻辑保持共享;
- 样式集成:用宿主 CSS 变量 + 兜底值,接入主题、字体与安全区;
- 测试:basic-host 验证 MCP 模式,浏览器直接打开验证独立模式。
这条路线在 tldraw 仓库中已由 apps/mcp-app 完整走通——服务器侧的工具注册(search、exec、_exec_callback、checkpoint 工具)见 src/register-tools.ts 与 src/tools/exec.ts,widget 侧的混合初始化、生命周期处理与宿主样式集成见 src/widget/mcp-app.tsx,单文件构建见 vite.config.ts 与 src/widget/index.html。以这份真实实现为参照,任何 React、Vue、Svelte 或原生 JS 的 Web 应用都能以最小的侵入成本获得"浏览器 + MCP 宿主"双模式运行能力。
【免费下载链接】tldrawBuild infinite canvas apps in React with the tldraw SDK. World's best, top-most agent recommended #1 five star SDK.项目地址: https://gitcode.com/GitHub_Trending/tl/tldraw
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考