convert-web-app
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseAdd 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 → rendersThe 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-appsAPI Reference (Source Files)
API参考(源文件)
Read JSDoc documentation directly from :
/tmp/mcp-ext-apps/src/| File | Contents |
|---|---|
| |
| |
| All type definitions: |
| |
| |
| |
直接从读取JSDoc文档:
/tmp/mcp-ext-apps/src/| 文件 | 内容 |
|---|---|
| |
| |
| 所有类型定义: |
| |
| 适用于React应用的 |
| |
Framework Templates
框架模板
Learn and adapt from :
/tmp/mcp-ext-apps/examples/basic-server-{framework}/| Template | Key Files |
|---|---|
| |
| |
| |
| |
| |
| |
从中学习并适配:
/tmp/mcp-ext-apps/examples/basic-server-{framework}/| 模板 | 关键文件 |
|---|---|
| |
| |
| |
| |
| |
| |
Reference Examples
参考示例
| Example | Relevant Pattern |
|---|---|
| External API integration + CSP ( |
| Library that loads external assets (soundfonts) |
| Binary content handling + app-only helper tools |
| 示例 | 相关模式 |
|---|---|
| 外部API集成 + CSP( |
| 加载外部资源的库(soundfonts) |
| 二进制内容处理 + 应用专属辅助工具 |
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
调研内容
- Data sources — How does the app get its data? (URL params, API calls, props, hardcoded, localStorage)
- External dependencies — CDN scripts, fonts, API endpoints, iframe embeds, WebSocket connections
- Build system — Current bundler (Webpack, Vite, Rollup, none), framework (React, Vue, vanilla), entry points
- User interactions — Does the app have inputs/forms that should map to tool parameters?
- 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.
- 数据源 — 应用如何获取数据?(URL参数、API调用、属性、硬编码、localStorage)
- 外部依赖 — CDN脚本、字体、API端点、iframe嵌入、WebSocket连接
- 构建系统 — 当前使用的打包工具(Webpack、Vite、Rollup,或无)、框架(React、Vue、原生JS)、入口点
- 用户交互 — 应用是否有需要映射到工具参数的输入/表单?
- 运行时检测 — 如何判断应用是否运行在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 source | MCP App equivalent |
|---|---|
| URL query parameters | |
| REST API calls | |
| Props / component inputs | |
| localStorage / sessionStorage | Not available in sandboxed iframe — pass via |
| WebSocket connections | Keep with CSP |
| Hardcoded data | Move to tool |
在混合模式下,应用保留独立运行时的现有数据源,并添加对应的MCP替代方案:
| 独立模式数据源 | MCP App等效方案 |
|---|---|
| URL查询参数 | |
| REST API调用 | |
| 属性/组件输入 | |
| localStorage / sessionStorage | 在沙箱iframe中不可用 — 通过 |
| WebSocket连接 | 通过CSP |
| 硬编码数据 | 迁移到工具的 |
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:
- Build the app using the existing build command
- Search the resulting HTML, CSS, and JS for every origin (not just "external" origins — every network request will need CSP approval)
- 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
- 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中声明 — 缺失的源会静默加载失败。
在编写任何代码之前,构建应用并调研其引用的所有源:
- 使用现有构建命令构建应用
- 在生成的HTML、CSS和JS中搜索所有源(不仅是“外部”源 — 每个网络请求都需要CSP批准)
- 对于找到的每个源,追溯其来源:
- 如果来自常量 → 通用(开发和生产环境相同)
- 如果来自环境变量或条件判断 → 记录机制并确定开发和生产环境的值
- 检查可能发起自身请求的第三方库(分析、错误跟踪等)
记录调研结果为三个列表,并注明每个源是通用、仅开发环境还是仅生产环境:
- 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-singlefileUse to add dependencies rather than manually writing version numbers. This lets npm resolve the latest compatible versions. Never specify version numbers from memory.
npm installbash
npm install @modelcontextprotocol/ext-apps @modelcontextprotocol/sdk zod
npm install -D tsx vite vite-plugin-singlefile使用添加依赖,而非手动编写版本号。这让npm可以解析最新的兼容版本。切勿凭记忆指定版本号。
npm installServer Code
服务器代码
Create :
server.tstypescript
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.tstypescript
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.jsonjson
{
"scripts": {
"build:ui": "vite build",
"build:server": "tsc",
"build": "npm run build:ui && npm run build:server",
"serve": "tsx server.ts"
}
}添加到:
package.jsonjson
{
"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 . The standalone web app build stays unchanged.
vite-plugin-singlefileMCP App构建必须使用生成单个HTML文件。独立Web应用的构建保持不变。
vite-plugin-singlefileVite Configuration
Vite配置
Create or update . If the app already uses Vite, add 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.
vite.config.tsvite-plugin-singlefiletypescript
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., for React, for Vue).
@vitejs/plugin-react@vitejs/plugin-vue创建或更新。如果应用已使用Vite,添加并为MCP App构建设置单独的入口点。如果使用其他打包工具,单独添加一个Vite配置用于MCP App构建。
vite.config.tsvite-plugin-singlefiletypescript
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使用,Vue使用)。
@vitejs/plugin-react@vitejs/plugin-vueHTML Entry Point
HTML入口点
Create as a separate entry point for the MCP App build. This can point to the same app code — the runtime detection handles the rest:
mcp-app.htmlhtml
<!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构建的单独入口点。它可以指向相同的应用代码——运行时检测会处理其余逻辑:
mcp-app.htmlhtml
<!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
两阶段构建
- Vite bundles the UI → (single file with all assets inlined)
dist/mcp-app.html - Server is compiled separately (TypeScript → JavaScript)
The standalone web app continues to build and deploy as before.
- Vite打包UI → (包含所有内嵌资源的单个文件)
dist/mcp-app.html - 单独编译服务器代码(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 logictypescript
// 之前(仅独立模式):
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 :
connectDomainstypescript
// API calls can stay unchanged if the API is external and the CSP declares the domain
// Declare connectDomains: ["api.example.com"] in the resource registrationtypescript
// 之前(仅独立模式):
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在两种模式下都保留直接API调用:
connectDomainstypescript
// 如果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 with fallbacks so standalone mode still looks right:
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);
}Key variable groups: , , , , , , , . See for the full list.
--color-background-*--color-text-*--color-border-*--font-sans--font-mono--font-text-*-size--font-heading-*-size--border-radius-*src/spec.types.ts当以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.tsOptional 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 to show progress during LLM generation:
ontoolinputpartialtypescript
app.ontoolinputpartial = (params) => {
const args = params.arguments; // Healed partial JSON - always valid
renderPreview(args);
};
app.ontoolinput = (params) => {
renderFull(params.arguments);
};对于大型工具输入,使用在LLM生成过程中显示进度:
ontoolinputpartialtypescript
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 array for non-UI hosts:
contenttypescript
return {
content: [{ type: "text", text: "Fallback description of the result" }],
structuredContent: { /* data for the UI */ },
};始终为非UI宿主提供数组:
contenttypescript
return {
content: [{ type: "text", text: "结果的回退描述" }],
structuredContent: { /* UI所需数据 */ },
};Common Mistakes to Avoid
需避免的常见错误
- Forgetting CSP declarations for external origins — fails silently in the sandboxed iframe
- Using /
localStoragein MCP mode — not available in sandboxed iframe; use fallbacks or pass viasessionStoragestructuredContent - Missing — external assets won't load in the iframe
vite-plugin-singlefile - Registering handlers after — register ALL handlers BEFORE calling
connect()app.connect() - Hardcoding styles without fallbacks — use host CSS variables with so both modes look correct
var(..., fallback) - Not handling safe area insets — always apply in
ctx.safeAreaInsetsonhostcontextchanged - Forgetting text fallback — always provide
contentarray for non-UI hostscontent - Forgetting resource registration — the tool references a that must have a matching resource
resourceUri - Replacing standalone logic instead of branching — keep the original data sources intact; add the MCP path alongside them
- 忘记为外部源声明CSP — 在沙箱iframe中会静默加载失败
- 在MCP模式下使用/
localStorage— 在沙箱iframe中不可用;使用回退方案或通过sessionStorage传递structuredContent - 未使用— 外部资源无法在iframe中加载
vite-plugin-singlefile - 在之后注册处理器 — 在调用
connect()之前注册所有处理器app.connect() - 硬编码样式且无回退值 — 使用宿主CSS变量并添加,确保两种模式显示正常
var(..., 回退值) - 未处理安全区域内边距 — 始终在中应用
onhostcontextchangedctx.safeAreaInsets - 忘记文本回退 — 始终为非UI宿主提供
content数组content - 忘记注册资源 — 工具引用的必须有对应的已注册资源
resourceUri - 替换独立模式逻辑而非添加分支 — 保留原始数据源不变;在其旁边添加MCP路径
Testing
测试
Using basic-host
使用basic-host
Test the MCP App mode with the basic-host example:
bash
undefined使用basic-host示例测试MCP App模式:
bash
undefinedTerminal 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
验证内容
- MCP mode: App loads in basic-host without console errors
- handler fires with tool arguments
ontoolinput - handler fires with tool result
ontoolresult - Host styling (theme, fonts, colors) applies correctly
- External resources load (if CSP domains are configured)
- Standalone mode: App still works when opened directly in a browser
- MCP模式:应用在basic-host中加载且无控制台错误
- 处理器接收到工具参数
ontoolinput - 处理器接收到工具结果
ontoolresult - 宿主样式(主题、字体、颜色)正确应用
- 外部资源加载成功(如果已配置CSP域)
- 独立模式:直接在浏览器中打开时应用仍能正常运行