convert-web-app

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Add MCP App Support to a Web App

为Web应用添加MCP App支持

Add MCP App support to an existing web application so it works both as a standalone web app and as an MCP App that renders inline in MCP-enabled hosts like Claude Desktop — from a single codebase.
为现有Web应用添加MCP App支持,使其既能作为独立Web应用运行,又能作为MCP App在支持MCP的宿主环境(如Claude Desktop)中内嵌渲染——所有功能均来自单一代码库。

How It Works

工作原理

The existing web app stays intact. A thin initialization layer detects whether the app is running inside an MCP host or as a regular web page, and fetches parameters from the appropriate source. A new MCP server wraps the app's bundled HTML as a resource and registers a tool to display it.
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
The app's rendering logic is shared — only the data source changes.
现有Web应用保持完整不变。一个轻量的初始化层会检测应用是运行在MCP宿主环境中还是作为常规网页,并从相应来源获取参数。一个新的MCP服务器会将应用打包后的HTML作为资源进行包装,并注册一个用于显示该应用的工具。
独立模式: 浏览器加载页面 → 应用读取URL参数/API → 渲染
MCP App模式:宿主调用工具 → 服务器返回结果 → 宿主在iframe中渲染应用 → 应用读取MCP生命周期 → 渲染
应用的渲染逻辑是共享的——仅数据源会发生变化。

Getting 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-apps

API Reference (Source Files)

API参考(源文件)

Read JSDoc documentation directly from
/tmp/mcp-ext-apps/src/
:
FileContents
src/app.ts
App
class, handlers (
ontoolinput
,
ontoolresult
,
onhostcontextchanged
,
onteardown
), lifecycle
src/server/index.ts
registerAppTool
,
registerAppResource
, 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
直接从
/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
钩子
src/react/useHostStyles.ts
useHostStyles
useHostStyleVariables
useHostFonts
钩子

Framework Templates

框架模板

Learn and adapt from
/tmp/mcp-ext-apps/examples/basic-server-{framework}/
:
TemplateKey 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/
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
/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

Reference Examples

参考示例

ExampleRelevant Pattern
examples/map-server/
External API integration + CSP (
connectDomains
,
resourceDomains
)
examples/sheet-music-server/
Library that loads external assets (soundfonts)
examples/pdf-server/
Binary content handling + app-only helper tools
示例相关模式
examples/map-server/
外部API集成 + CSP(
connectDomains
resourceDomains
examples/sheet-music-server/
加载外部资源的库(soundfonts)
examples/pdf-server/
二进制内容处理 + 应用专属辅助工具

Step 1: Analyze the Existing Web App

步骤1:分析现有Web应用

Before writing any code, examine the existing web app to plan what needs to change.
在编写任何代码之前,先检查现有Web应用,规划需要修改的内容。

What to Investigate

调研内容

  1. Data sources — How does the app get its data? (URL params, API calls, props, hardcoded, localStorage)
  2. External dependencies — CDN scripts, fonts, API endpoints, iframe embeds, WebSocket connections
  3. Build system — Current bundler (Webpack, Vite, Rollup, none), framework (React, Vue, vanilla), entry points
  4. User interactions — Does the app have inputs/forms that should map to tool parameters?
  5. Runtime detection — How to tell if the app is running inside an MCP host (e.g., check the current origin, a query param, or whether
    window.parent !== window
    )
Present findings to the user and confirm the approach.
  1. 数据源 — 应用如何获取数据?(URL参数、API调用、属性、硬编码、localStorage)
  2. 外部依赖 — CDN脚本、字体、API端点、iframe嵌入、WebSocket连接
  3. 构建系统 — 当前使用的打包工具(Webpack、Vite、Rollup,或无)、框架(React、Vue、原生JS)、入口点
  4. 用户交互 — 应用是否有需要映射到工具参数的输入/表单?
  5. 运行时检测 — 如何判断应用是否运行在MCP宿主环境中(例如,检查当前源、查询参数,或
    window.parent !== window
将调研结果呈现给用户并确认实施方案。

Data Source Mapping

数据源映射

In hybrid mode, the app keeps its existing data sources for standalone use and adds MCP equivalents:
Standalone data sourceMCP App equivalent
URL query parameters
ontoolinput
/
ontoolresult
arguments
or
structuredContent
REST API calls
app.callServerTool()
to server-side tools, or keep direct API calls with CSP
connectDomains
Props / component inputs
ontoolinput
arguments
localStorage / sessionStorageNot available in sandboxed iframe — pass via
structuredContent
or server-side state
WebSocket connectionsKeep with CSP
connectDomains
, or convert to polling via app-only tools
Hardcoded dataMove to tool
structuredContent
to make it dynamic
在混合模式下,应用保留独立运行时的现有数据源,并添加对应的MCP替代方案:
独立模式数据源MCP App等效方案
URL查询参数
ontoolinput
/
ontoolresult
arguments
structuredContent
REST API调用
app.callServerTool()
调用服务器端工具,或通过CSP
connectDomains
保留直接API调用
属性/组件输入
ontoolinput
arguments
localStorage / sessionStorage在沙箱iframe中不可用 — 通过
structuredContent
或服务器端状态传递
WebSocket连接通过CSP
connectDomains
保留,或转换为通过应用专属工具进行轮询
硬编码数据迁移到工具的
structuredContent
以实现动态化

Step 2: Investigate CSP Requirements

步骤2:调研CSP要求

MCP Apps HTML runs in a sandboxed iframe with no same-origin server. Every external origin must be declared in CSP — missing origins fail silently.
Before writing any code, build the app and investigate all origins it references:
  1. Build the app using the existing build command
  2. Search the resulting HTML, CSS, and JS for every origin (not just "external" origins — every network request will need CSP approval)
  3. For each origin found, trace back to source:
    • If it comes from a constant → universal (same in dev and prod)
    • If it comes from an env var or conditional → note the mechanism and identify both dev and prod values
  4. Check for third-party libraries that may make their own requests (analytics, error tracking, etc.)
Document your findings as three lists, and note for each origin whether it's universal, dev-only, or prod-only:
  • resourceDomains: origins serving images, fonts, styles, scripts
  • connectDomains: origins for API/fetch requests
  • frameDomains: origins for nested iframes
If no origins are found, the app may not need custom CSP domains.
MCP App的HTML在沙箱iframe中运行,无同源服务器。所有外部源必须在CSP中声明 — 缺失的源会静默加载失败。
在编写任何代码之前,构建应用并调研其引用的所有源:
  1. 使用现有构建命令构建应用
  2. 在生成的HTML、CSS和JS中搜索所有源(不仅是“外部”源 — 每个网络请求都需要CSP批准)
  3. 对于找到的每个源,追溯其来源:
    • 如果来自常量 → 通用(开发和生产环境相同)
    • 如果来自环境变量或条件判断 → 记录机制并确定开发和生产环境的值
  4. 检查可能发起自身请求的第三方库(分析、错误跟踪等)
记录调研结果为三个列表,并注明每个源是通用、仅开发环境还是仅生产环境:
  • resourceDomains:提供图片、字体、样式、脚本的源
  • connectDomains:用于API/fetch请求的源
  • frameDomains:用于嵌套iframe的源
如果未找到任何源,应用可能不需要自定义CSP域。

Step 3: Set Up the MCP Server

步骤3:搭建MCP服务器

Create a new MCP server with tool and resource registration. This wraps the existing web app for MCP hosts.
创建一个包含工具和资源注册的新MCP服务器,为MCP宿主环境包装现有Web应用。

Dependencies

依赖安装

bash
npm install @modelcontextprotocol/ext-apps @modelcontextprotocol/sdk zod
npm install -D tsx vite vite-plugin-singlefile
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.
bash
npm install @modelcontextprotocol/ext-apps @modelcontextprotocol/sdk zod
npm install -D tsx vite vite-plugin-singlefile
使用
npm install
添加依赖,而非手动编写版本号。这让npm可以解析最新的兼容版本。切勿凭记忆指定版本号。

Server Code

服务器代码

Create
server.ts
:
typescript
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);
创建
server.ts
typescript
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";

// 注册工具 — inputSchema映射到应用的数据源
registerAppTool(server, "show-app", {
  description: "使用给定参数显示应用",
  inputSchema: { query: z.string().describe("搜索查询词") },
  _meta: { ui: { resourceUri } },
}, async (args) => {
  // 如有需要,在服务器端处理参数
  return {
    content: [{ type: "text", text: `正在为以下内容显示应用:${args.query}` }],
    structuredContent: { query: args.query },
  };
});

// 注册HTML资源
registerAppResource(server, {
  uri: resourceUri,
  name: "My App UI",
  mimeType: RESOURCE_MIME_TYPE,
  // 如有需要,添加步骤2中的CSP域:
  // _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 }] };
});

// 启动服务器
const transport = new StdioServerTransport();
await server.connect(transport);

Package Scripts

包脚本

Add to
package.json
:
json
{
  "scripts": {
    "build:ui": "vite build",
    "build:server": "tsc",
    "build": "npm run build:ui && npm run build:server",
    "serve": "tsx server.ts"
  }
}
添加到
package.json
json
{
  "scripts": {
    "build:ui": "vite build",
    "build:server": "tsc",
    "build": "npm run build:ui && npm run build:server",
    "serve": "tsx server.ts"
  }
}

Step 4: Adapt the Build Pipeline

步骤4:适配构建流水线

The MCP App build must produce a single HTML file using
vite-plugin-singlefile
. The standalone web app build stays unchanged.
MCP App构建必须使用
vite-plugin-singlefile
生成单个HTML文件。独立Web应用的构建保持不变。

Vite Configuration

Vite配置

Create or update
vite.config.ts
. If the app already uses Vite, add
vite-plugin-singlefile
and a separate entry point for the MCP App build. If it uses another bundler, add a Vite config alongside for the MCP App build only.
typescript
import { defineConfig } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";

export default defineConfig({
  plugins: [viteSingleFile()],
  build: {
    outDir: "dist",
    rollupOptions: {
      input: "mcp-app.html",
    },
  },
});
Add framework-specific Vite plugins as needed (e.g.,
@vitejs/plugin-react
for React,
@vitejs/plugin-vue
for Vue).
创建或更新
vite.config.ts
。如果应用已使用Vite,添加
vite-plugin-singlefile
并为MCP App构建设置单独的入口点。如果使用其他打包工具,单独添加一个Vite配置用于MCP App构建。
typescript
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 Entry Point

HTML入口点

Create
mcp-app.html
as a separate entry point for the MCP App build. This can point to the same app code — the runtime detection handles the rest:
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/main.ts"></script>
  </body>
</html>
创建
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/main.ts"></script>
  </body>
</html>

Two-Phase Build

两阶段构建

  1. Vite bundles the UI →
    dist/mcp-app.html
    (single file with all assets inlined)
  2. Server is compiled separately (TypeScript → JavaScript)
The standalone web app continues to build and deploy as before.
  1. Vite打包UI →
    dist/mcp-app.html
    (包含所有内嵌资源的单个文件)
  2. 单独编译服务器代码(TypeScript → JavaScript)
独立Web应用继续按原有方式构建和部署。

Step 5: Add MCP App Initialization Alongside Existing Logic

步骤5:添加MCP App初始化逻辑(与现有逻辑共存)

This is the core step. Instead of replacing the app's data sources, add an alternative initialization path for MCP mode. The app detects its environment at startup and reads parameters from the right source.
这是核心步骤。无需替换应用的数据源,而是为MCP模式添加一个备选初始化路径。应用在启动时检测环境,并从正确的源读取参数。

The Hybrid Pattern

混合模式示例

typescript
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);
typescript
import { App, PostMessageTransport } from "@modelcontextprotocol/ext-apps";

// 检测是否运行在MCP宿主环境中。
// 选择适合应用的检测方法:
//   - 源检查:window.location.origin !== 'https://myhost.com'
//   - 空源(沙箱iframe):window.location.origin === 'null'
//   - 查询参数:new URL(location.href).searchParams.has('mcp')
const isMcpApp = window.location.origin === "null";

async function getParameters(): Promise<Record<string, string>> {
  if (isMcpApp) {
    // 以MCP App运行 — 从工具生命周期获取参数
    const app = new App({ name: "My App", version: "1.0.0" });

    // 在connect()之前注册处理器
    const params = await new Promise<Record<string, string>>((resolve) => {
      app.ontoolresult = (result) => resolve(result.structuredContent ?? {});
    });

    await app.connect(new PostMessageTransport());
    return params;
  } else {
    // 作为独立Web应用运行 — 从URL获取参数
    return Object.fromEntries(new URL(location.href).searchParams);
  }
}

async function main() {
  const params = await getParameters();
  renderApp(params); // 两种模式使用相同的渲染逻辑
}

main().catch(console.error);

URL Parameters (Hybrid)

URL参数(混合模式)

typescript
// 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
typescript
// 之前(仅独立模式):
const query = new URL(location.href).searchParams.get("q");
renderApp(query);

// 之后(混合模式):
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); // 渲染逻辑保持不变

API Calls (Hybrid)

API调用(混合模式)

typescript
// 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());
}
Or keep direct API calls in both modes with CSP
connectDomains
:
typescript
// 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
typescript
// 之前(仅独立模式):
const data = await fetch("/api/data").then(r => r.json());

// 之后(混合模式):
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());
}
或者通过CSP
connectDomains
在两种模式下都保留直接API调用:
typescript
// 如果API是外部的且CSP已声明该域,API调用可以保持不变
// 在资源注册中声明connectDomains: ["api.example.com"]

localStorage / sessionStorage (Hybrid)

localStorage / sessionStorage(混合模式)

typescript
// 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");
}
typescript
// 之前(仅独立模式):
const saved = localStorage.getItem("settings");

// 之后(混合模式)—— localStorage在沙箱iframe中不可用:
function getSettings(): any {
  if (isMcpApp) {
    // 将通过工具结果提供
    return null; // 或默认值
  }
  return JSON.parse(localStorage.getItem("settings") ?? "null");
}

Complete Hybrid Example

完整混合模式示例

typescript
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);
typescript
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" });

  // 在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); // 相同的渲染逻辑 — 无需分支
}

main().catch(console.error);

Step 6: Add Host Styling Integration (MCP Mode Only)

步骤6:添加宿主样式集成(仅MCP模式)

When running as an MCP App, integrate with host styling for theme consistency. Use CSS variable fallbacks so the app looks correct in both modes.
Vanilla JS — use helper functions:
typescript
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 — use hooks:
typescript
import { useApp, useHostStyles } from "@modelcontextprotocol/ext-apps/react";

const { app } = useApp({ appInfo, capabilities, onAppCreated });
useHostStyles(app);
Using variables in CSS — use
var()
with fallbacks so standalone mode still looks right:
css
.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);
}
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.
当以MCP App运行时,与宿主样式集成以保持主题一致性。使用CSS变量回退,确保应用在两种模式下都能正确显示。
原生JS — 使用辅助函数:
typescript
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 — 使用钩子:
typescript
import { useApp, useHostStyles } from "@modelcontextprotocol/ext-apps/react";

const { app } = useApp({ appInfo, capabilities, onAppCreated });
useHostStyles(app);
在CSS中使用变量 — 使用
var()
并添加回退值,确保独立模式下显示正常:
css
.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

Optional Enhancements

可选增强功能

App-Only Helper Tools

应用专属辅助工具

For data the UI needs to poll or fetch that the model doesn't need to call directly:
typescript
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) }] };
});
The UI calls these via
app.callServerTool("refresh-data", {})
.
针对UI需要轮询或获取但模型无需直接调用的数据:
typescript
registerAppTool(server, "refresh-data", {
  description: "为UI获取最新数据",
  _meta: { ui: { resourceUri, visibility: ["app"] } },
}, async () => {
  const data = await getLatestData();
  return { content: [{ type: "text", text: JSON.stringify(data) }] };
});
UI通过
app.callServerTool("refresh-data", {})
调用这些工具。

Streaming Partial Input

流式部分输入

For large tool inputs, use
ontoolinputpartial
to show progress during LLM generation:
typescript
app.ontoolinputpartial = (params) => {
  const args = params.arguments; // Healed partial JSON - always valid
  renderPreview(args);
};

app.ontoolinput = (params) => {
  renderFull(params.arguments);
};
对于大型工具输入,使用
ontoolinputpartial
在LLM生成过程中显示进度:
typescript
app.ontoolinputpartial = (params) => {
  const args = params.arguments; // 修复后的部分JSON - 始终有效
  renderPreview(args);
};

app.ontoolinput = (params) => {
  renderFull(params.arguments);
};

Fullscreen Mode

全屏模式

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;
}
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;
}

Text Fallback

文本回退

Always provide a
content
array for non-UI hosts:
typescript
return {
  content: [{ type: "text", text: "Fallback description of the result" }],
  structuredContent: { /* data for the UI */ },
};
始终为非UI宿主提供
content
数组:
typescript
return {
  content: [{ type: "text", text: "结果的回退描述" }],
  structuredContent: { /* UI所需数据 */ },
};

Common Mistakes to Avoid

需避免的常见错误

  1. Forgetting CSP declarations for external origins — fails silently in the sandboxed iframe
  2. Using
    localStorage
    /
    sessionStorage
    in MCP mode
    — not available in sandboxed iframe; use fallbacks or pass via
    structuredContent
  3. Missing
    vite-plugin-singlefile
    — external assets won't load in the iframe
  4. Registering handlers after
    connect()
    — register ALL handlers BEFORE calling
    app.connect()
  5. Hardcoding styles without fallbacks — use host CSS variables with
    var(..., fallback)
    so both modes look correct
  6. Not handling safe area insets — always apply
    ctx.safeAreaInsets
    in
    onhostcontextchanged
  7. Forgetting text
    content
    fallback
    — always provide
    content
    array for non-UI hosts
  8. Forgetting resource registration — the tool references a
    resourceUri
    that must have a matching resource
  9. Replacing standalone logic instead of branching — keep the original data sources intact; add the MCP path alongside them
  1. 忘记为外部源声明CSP — 在沙箱iframe中会静默加载失败
  2. 在MCP模式下使用
    localStorage
    /
    sessionStorage
    — 在沙箱iframe中不可用;使用回退方案或通过
    structuredContent
    传递
  3. 未使用
    vite-plugin-singlefile
    — 外部资源无法在iframe中加载
  4. connect()
    之后注册处理器
    — 在调用
    app.connect()
    之前注册所有处理器
  5. 硬编码样式且无回退值 — 使用宿主CSS变量并添加
    var(..., 回退值)
    ,确保两种模式显示正常
  6. 未处理安全区域内边距 — 始终在
    onhostcontextchanged
    中应用
    ctx.safeAreaInsets
  7. 忘记文本
    content
    回退
    — 始终为非UI宿主提供
    content
    数组
  8. 忘记注册资源 — 工具引用的
    resourceUri
    必须有对应的已注册资源
  9. 替换独立模式逻辑而非添加分支 — 保留原始数据源不变;在其旁边添加MCP路径

Testing

测试

Using basic-host

使用basic-host

Test the MCP App mode with the basic-host example:
bash
undefined
使用basic-host示例测试MCP App模式:
bash
undefined

Terminal 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`).

使用服务器URL的JSON数组配置`SERVERS`(默认:`http://localhost:3001/mcp`)。

Verify

验证内容

  1. MCP mode: App loads in basic-host without console errors
  2. ontoolinput
    handler fires with tool arguments
  3. ontoolresult
    handler fires with tool result
  4. Host styling (theme, fonts, colors) applies correctly
  5. External resources load (if CSP domains are configured)
  6. Standalone mode: App still works when opened directly in a browser
  1. MCP模式:应用在basic-host中加载且无控制台错误
  2. ontoolinput
    处理器接收到工具参数
  3. ontoolresult
    处理器接收到工具结果
  4. 宿主样式(主题、字体、颜色)正确应用
  5. 外部资源加载成功(如果已配置CSP域)
  6. 独立模式:直接在浏览器中打开时应用仍能正常运行