convert-web-app by modelcontextprotocol/ext-apps
npx skills add https://github.com/modelcontextprotocol/ext-apps --skill convert-web-app为现有的 Web 应用程序添加 MCP App 支持,使其既能作为独立的 Web 应用运行,又能作为 MCP App 在支持 MCP 的主机(如 Claude Desktop)中内嵌渲染——所有功能均来自单一代码库。
现有的 Web 应用保持不变。一个轻量级的初始化层会检测应用是在 MCP 主机内运行还是作为常规网页运行,并从相应的来源获取参数。一个新的 MCP 服务器将应用打包后的 HTML 包装为资源,并注册一个工具来显示它。
独立运行: 浏览器加载页面 → 应用读取 URL 参数 / API → 渲染
MCP App: 主机调用工具 → 服务器返回结果 → 主机在 iframe 中渲染应用 → 应用读取 MCP 生命周期 → 渲染
应用的渲染逻辑是共享的——只有数据源会改变。
克隆 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
直接从 /tmp/mcp-ext-apps/src/ 阅读 JSDoc 文档:
| 文件 | 内容 |
|---|---|
src/app.ts |
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
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 钩子 |
从 /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 |
| 示例 | 相关模式 |
|---|---|
examples/map-server/ | 外部 API 集成 + CSP(connectDomains、resourceDomains) |
examples/sheet-music-server/ | 加载外部资源(音源字体)的库 |
examples/pdf-server/ | 二进制内容处理 + 仅限应用的辅助工具 |
在编写任何代码之前,先检查现有的 Web 应用,以规划需要更改的内容。
window.parent !== window)向用户展示调查结果并确认方法。
在混合模式下,应用保留其现有的数据源以供独立使用,并添加 MCP 等效项:
| 独立数据源 | MCP App 等效项 |
|---|---|
| URL 查询参数 | ontoolinput / ontoolresult 的 arguments 或 structuredContent |
| REST API 调用 | app.callServerTool() 调用服务器端工具,或通过 CSP connectDomains 保持直接 API 调用 |
| Props / 组件输入 | ontoolinput 的 arguments |
| localStorage / sessionStorage | 在沙盒化 iframe 中不可用 — 通过 structuredContent 或服务器端状态传递 |
| WebSocket 连接 | 通过 CSP connectDomains 保持,或通过仅限应用的工具转换为轮询 |
| 硬编码数据 | 移动到工具的 structuredContent 以使其动态化 |
MCP Apps HTML 在沙盒化的 iframe 中运行,没有同源服务器。每个外部来源都必须在 CSP 中声明——缺少的来源会静默失败。
在编写任何代码之前,构建应用并调查其引用的所有来源:
将你的发现记录为三个列表,并注明每个来源是通用的、仅限开发还是仅限生产:
如果未找到任何来源,则应用可能不需要自定义 CSP 域。
创建一个新的 MCP 服务器,并注册工具和资源。这将包装现有的 Web 应用以供 MCP 主机使用。
npm install @modelcontextprotocol/ext-apps @modelcontextprotocol/sdk zod
npm install -D tsx vite vite-plugin-singlefile
使用 npm install 添加依赖项,而不是手动编写版本号。这可以让 npm 解析最新的兼容版本。切勿凭记忆指定版本号。
创建 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";
// 注册工具 — 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: "我的应用 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.json:
{
"scripts": {
"build:ui": "vite build",
"build:server": "tsc",
"build": "npm run build:ui && npm run build:server",
"serve": "tsx server.ts"
}
}
MCP App 构建必须使用 vite-plugin-singlefile 生成单个 HTML 文件。独立的 Web 应用构建保持不变。
创建或更新 vite.config.ts。如果应用已经使用 Vite,则添加 vite-plugin-singlefile 和一个用于 MCP App 构建的单独入口点。如果它使用另一个打包工具,则仅为 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)。
创建 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>
dist/mcp-app.html(单个文件,所有资源内联)独立的 Web 应用继续像以前一样构建和部署。
这是核心步骤。不是替换应用的数据源,而是为 MCP 模式添加一个替代的初始化路径。应用在启动时检测其环境,并从正确的来源读取参数。
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);
// 之前(仅独立模式):
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); // 未更改的渲染逻辑
// 之前(仅独立模式):
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 调用:
// 如果 API 是外部的并且 CSP 声明了该域,API 调用可以保持不变
// 在资源注册中声明 connectDomains: ["api.example.com"]
// 之前(仅独立模式):
const saved = localStorage.getItem("settings");
// 之后(混合模式)— localStorage 在沙盒化 iframe 中不可用:
function getSettings(): any {
if (isMcpApp) {
// 将通过工具结果提供
return null; // 或默认值
}
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" });
// 在 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);
当作为 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 — 使用钩子:
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: "为 UI 获取最新数据",
_meta: { ui: { resourceUri, visibility: ["app"] } },
}, async () => {
const data = await getLatestData();
return { content: [{ type: "text", text: JSON.stringify(data) }] };
});
UI 通过 app.callServerTool("refresh-data", {}) 调用这些工具。
对于大型工具输入,使用 ontoolinputpartial 在 LLM 生成过程中显示进度:
app.ontoolinputpartial = (params) => {
const args = params.arguments; // 已修复的部分 JSON - 始终有效
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: "结果的回退描述" }],
structuredContent: { /* 用于 UI 的数据 */ },
};
localStorage / sessionStorage — 在沙盒化 iframe 中不可用;使用回退或通过 structuredContent 传递vite-plugin-singlefile — 外部资源无法在 iframe 中加载connect() 之后注册处理程序 — 在调用 app.connect() 之前注册所有处理程序var(..., fallback) 的主机 CSS 变量,以便两种模式看起来都正确onhostcontextchanged 中应用 ctx.safeAreaInsetscontent 回退 — 始终为非 UI 主机提供 content 数组resourceUri 必须有匹配的资源使用 basic-host 示例测试 MCP App 模式:
# 终端 1:构建并运行你的服务器
npm run build && npm run serve
# 终端 2:运行 basic-host(从克隆的仓库)
cd /tmp/mcp-ext-apps/examples/basic-host
npm install
SERVERS='["http://localhost:3001/mcp"]' npm run start
# 打开 http://localhost:8080
使用你的服务器 URL 的 JSON 数组配置 SERVERS(默认值:http://localhost:3001/mcp)。
ontoolinput 处理程序触发,带有工具参数ontoolresult 处理程序触发,带有工具结果每周安装量
163
仓库
GitHub 星标
1.9K
首次出现
2026年2月21日
安全审计
安装于
codex157
opencode157
gemini-cli156
cursor156
amp155
github-copilot155
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.
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.
Clone the SDK repository for working examples and API documentation:
git clone --branch "v$(npm view @modelcontextprotocol/ext-apps version)" --depth 1 https://github.com/modelcontextprotocol/ext-apps.git /tmp/mcp-ext-apps
Read JSDoc documentation directly from /tmp/mcp-ext-apps/src/:
| File | Contents |
|---|---|
src/app.ts | App class, handlers (ontoolinput, ontoolresult, onhostcontextchanged, onteardown), lifecycle |
src/server/index.ts | registerAppTool, registerAppResource, tool visibility options |
src/spec.types.ts |
Learn and adapt from /tmp/mcp-ext-apps/examples/basic-server-{framework}/:
| Template | Key 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/ |
| Example | Relevant 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 |
Before writing any code, examine the existing web app to plan what needs to change.
window.parent !== window)Present findings to the user and confirm the approach.
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 | 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 |
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:
Document your findings as three lists, and note for each origin whether it's universal, dev-only, or prod-only:
If no origins are found, the app may not need custom CSP domains.
Create a new MCP server with tool and resource registration. This wraps the existing web app for MCP hosts.
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.
Create 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);
Add to package.json:
{
"scripts": {
"build:ui": "vite build",
"build:server": "tsc",
"build": "npm run build:ui && npm run build:server",
"serve": "tsx server.ts"
}
}
The MCP App build must produce a single HTML file using vite-plugin-singlefile. The standalone web app build stays unchanged.
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.
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).
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:
<!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>
dist/mcp-app.html (single file with all assets inlined)The standalone web app continues to build and deploy as before.
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.
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);
// 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
// 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:
// 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
// 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);
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:
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:
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:
.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.
For data the UI needs to poll or fetch that the model doesn't need to call directly:
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", {}).
For large tool inputs, use ontoolinputpartial to show progress during LLM generation:
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;
}
Always provide a content array for non-UI hosts:
return {
content: [{ type: "text", text: "Fallback description of the result" }],
structuredContent: { /* data for the UI */ },
};
localStorage / sessionStorage in MCP mode — not available in sandboxed iframe; use fallbacks or pass via structuredContentvite-plugin-singlefile — external assets won't load in the iframeconnect() — register ALL handlers BEFORE calling app.connect()var(..., fallback) so both modes look correctctx.safeAreaInsets in Test the MCP App mode with the basic-host example:
# 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
Configure SERVERS with a JSON array of your server URLs (default: http://localhost:3001/mcp).
ontoolinput handler fires with tool argumentsontoolresult handler fires with tool resultWeekly Installs
163
Repository
GitHub Stars
1.9K
First Seen
Feb 21, 2026
Security Audits
Gen Agent Trust HubPassSocketWarnSnykPass
Installed on
codex157
opencode157
gemini-cli156
cursor156
amp155
github-copilot155
React 组合模式指南:Vercel 组件架构最佳实践,提升代码可维护性
113,700 周安装
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 |
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 |
| localStorage / sessionStorage | Not available in sandboxed iframe — pass via structuredContent or server-side state |
| WebSocket connections | Keep with CSP connectDomains, or convert to polling via app-only tools |
| Hardcoded data | Move to tool structuredContent to make it dynamic |
onhostcontextchangedcontent fallback — always provide content array for non-UI hostsresourceUri that must have a matching resource