mcpField
A
add-app-to-server

add-app-to-server

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

关于 add-app-to-server

面向维护现有 MCP 服务的开发者,指导使用 MCP Apps SDK 为适合的工具添加内嵌交互界面,同时保留纯文本客户端的兼容性。内容涵盖工具评估、资源注册、界面构建和测试。

主要功能

  1. 01评估工具的界面需求
  2. 02将工具关联 HTML 资源
  3. 03为纯文本客户端保留输出
  4. 04构建多框架交互界面
  5. 05配置主机样式与安全策略

适用场景

  1. 01为列表或指标添加可视化界面
  2. 02让 MCP 工具呈现地图或 PDF
  3. 03为界面添加仅供应用调用的工具

MCP 相关

指导使用 MCP Apps SDK 将现有 MCP 服务工具与 HTML 资源配对;MCP 客户端调用工具后可在对话中显示界面,纯文本客户端仍可使用文本结果。

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/modelcontextprotocol/ext-apps --skill add-app-to-server

安装后在 Claude Code 中输入/add-app-to-server确认已加载。

详细介绍

为 MCP 服务添加 UI

使用 MCP Apps SDK(@modelcontextprotocol/ext-apps),为现有 MCP 服务的工具添加交互式 UI。

工作原理

现有工具会与 HTML 资源配对,并在宿主的对话中内嵌呈现。工具仍可供纯文本客户端使用——UI 是增强功能,而非替代方案。每个适合添加 UI 的工具都会通过 _meta.ui.resourceUri 关联到一个资源;调用工具时,宿主会在沙盒 iframe 中呈现该资源。

获取参考代码

克隆 SDK 仓库,获取可运行的示例和 API 文档:

git clone --branch "v$(npm view @modelcontextprotocol/ext-apps version)" --depth 1 https://github.com/modelcontextprotocol/ext-apps.git /tmp/mcp-ext-apps
API 参考(源文件)

直接阅读 /tmp/mcp-ext-apps/src/ 中的 JSDoc 文档:

文件内容
src/app.tsApp 类、处理器(ontoolinput、ontoolresult、onhostcontextchanged、onteardown)、生命周期
src/server/index.tsregisterAppTool、registerAppResource、getUiCapability、工具可见性选项
src/spec.types.ts所有类型定义:McpUiHostContext、CSS 变量键、显示模式
src/styles.tsapplyDocumentTheme、applyHostStyleVariables、applyHostFonts
src/react/useApp.tsx面向 React 应用的 useApp hook
src/react/useHostStyles.tsuseHostStyles、useHostStyleVariables、useHostFonts hook
关键示例(混合工具模式)

这些示例展示了同时包含 App 增强工具和普通工具的服务——这正是你要添加的模式:

示例模式
examples/map-server/show-map(App 工具)+ geocode(普通工具)
examples/pdf-server/display_pdf(App 工具)+ list_pdfs(普通工具)+ read_pdf_bytes(仅 App 工具)
examples/system-monitor-server/get-system-info(App 工具)+ poll-system-stats(仅 App 的轮询工具)
框架模板

从 /tmp/mcp-ext-apps/examples/basic-server-{framework}/ 学习并进行改造:

模板关键文件
basic-server-vanillajs/server.ts、src/mcp-app.ts、mcp-app.html
basic-server-react/server.ts、src/mcp-app.tsx(使用 useApp hook)
basic-server-vue/server.ts、src/App.vue
basic-server-svelte/server.ts、src/App.svelte
basic-server-preact/server.ts、src/mcp-app.tsx
basic-server-solid/server.ts、src/mcp-app.tsx

步骤 1:分析现有工具

编写任何代码之前,先分析服务中现有的工具,并确定哪些工具适合添加 UI。

  1. 阅读服务源代码,列出所有已注册的工具
  2. 对每个工具评估是否适合添加 UI(返回的数据是否可视化、是否涉及用户交互等),或是否只用纯文本就够了(简单查询、实用函数)
  3. 找出可作为仅供 App 使用的辅助工具的工具(UI 需要轮询或获取的数据,但模型无需直接调用)
  4. 向用户展示分析结果,并确认要为哪些工具添加增强功能
决策框架
工具输出类型UI 优势示例
结构化数据 / 列表 / 表格高——交互式表格、搜索、筛选项目列表、搜索结果
指标 / 随时间变化的数值高——图表、仪表盘、仪表系统统计数据、分析数据
媒体 / 丰富内容高——查看器、播放器、渲染器地图、PDF、图像、视频
简单文本 / 确认信息低——文本即可“文件已创建”“设置已更新”
供其他工具使用的数据可考虑仅供 App 使用轮询端点、分块加载器

步骤 2:添加依赖

npm install @modelcontextprotocol/ext-apps @modelcontextprotocol/client@^2.0.0 @modelcontextprotocol/server@^2.0.0 zod@^4.2.0
npm install -D vite vite-plugin-singlefile

此外,还需添加框架专用依赖(例如,React 项目需要 react、react-dom、@vitejs/plugin-react),如果服务使用 HTTP 传输,还需添加 @modelcontextprotocol/node / @modelcontextprotocol/express。

ext-apps 2.x 要求使用版本为 ^2.0.0 的拆分式基础 MCP SDK 包 (@modelcontextprotocol/core 会作为传递依赖引入)。仍导入 @modelcontextprotocol/sdk v1 的现有服务,在添加 App 集成之前,必须迁移这些导入和处理程序架构:

SDK v1(@modelcontextprotocol/sdk)SDK v2
sdk/server/mcp.js(McpServer)@modelcontextprotocol/server
sdk/server/streamableHttp.js(StreamableHTTPServerTransport)来自 @modelcontextprotocol/node 的 NodeStreamableHTTPServerTransport
手动配置 Express来自 @modelcontextprotocol/express 的 createMcpExpressApp
sdk/server/stdio.js@modelcontextprotocol/server/stdio
sdk/types.js 类型(CallToolResult,…)@modelcontextprotocol/client 或 @modelcontextprotocol/server
sdk/types.js zod 架构(CallToolResultSchema,…)@modelcontextprotocol/core
原始 zod 形状:inputSchema: { q: z.string() }inputSchema: z.object({ q: z.string() })
工具回调中的 extra.signalextra.mcpReq.signal
setRequestHandler(SomeRequestSchema, handler)setRequestHandler("some/method", { params: ParamsSchema }, handler)(仅规范方法(如 "tools/call")可使用双参数形式)

步骤 3:设置构建流水线

Vite 配置

创建 vite.config.ts,使用 vite-plugin-singlefile 将 UI 打包为单个 HTML 文件:

import { defineConfig } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";

export default defineConfig({
  plugins: [viteSingleFile()],
  build: {
    outDir: "dist",
    rollupOptions: {
      input: "mcp-app.html", // one per UI, or one shared entry
    },
  },
});
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/mcp-app.ts"></script>
  </body>
</html>
构建脚本

在 package.json 中添加构建脚本。打包服务代码之前,必须先构建 UI:

{
  "scripts": {
    "build:ui": "vite build",
    "build:server": "tsc",
    "build": "npm run build:ui && npm run build:server",
    "serve": "tsx server.ts"
  }
}

步骤 4:将工具转换为 App 工具

将普通 MCP 工具转换为带 UI 的 App 工具。

之前(普通 MCP 工具):

server.registerTool(
  "my-tool",
  { inputSchema: z.object({ param: z.string() }) },
  async ({ param }) => {
    const data = await fetchData(param);
    return { content: [{ type: "text", text: JSON.stringify(data) }] };
  },
);

之后(带 UI 的 App 工具):

import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";

const resourceUri = "ui://my-tool/mcp-app.html";

registerAppTool(
  server,
  "my-tool",
  {
    description: "Shows data with an interactive UI",
    inputSchema: z.object({ param: z.string() }),
    _meta: { ui: { resourceUri } },
  },
  async ({ param }) => {
    const data = await fetchData(param);
    return {
      content: [{ type: "text", text: JSON.stringify(data) }], // text fallback for non-UI hosts
      structuredContent: { data }, // structured data for the UI
    };
  },
);

要点:

  • 始终保留 content 数组,其中包含面向纯文本客户端的文本回退内容
  • 为 UI 需要渲染的数据添加 structuredContent
  • 通过 _meta.ui.resourceUri 将工具关联到其资源
  • 对不适合使用 UI 的工具保持不变——它们仍是普通工具

步骤 5:注册资源

注册 HTML 资源,以便宿主获取它:

import fs from "node:fs/promises";
import path from "node:path";

const resourceUri = "ui://my-tool/mcp-app.html";

registerAppResource(
  server,
  "My Tool UI",
  resourceUri,
  {},
  async () => {
    const html = await fs.readFile(
      path.resolve(import.meta.dirname, "dist", "mcp-app.html"),
      "utf-8",
    );
    return {
      contents: [{ uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html }],
    };
  },
);

如果多个工具共用同一 UI,它们可以引用相同的 resourceUri,并共用同一资源注册项。

步骤 6:构建 UI

处理程序注册

调用 app.connect() 之前,先注册所有处理程序:

import { App, PostMessageTransport, applyDocumentTheme, applyHostStyleVariables, applyHostFonts } from "@modelcontextprotocol/ext-apps";

const app = new App({ name: "My App", version: "1.0.0" });

app.ontoolinput = (params) => {
  // Render the UI using params.arguments and/or params.structuredContent
};

app.ontoolresult = (result) => {
  // Update UI with final tool result
};

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());
宿主样式

使用宿主 CSS 变量集成主题:

.container {
  background: var(--color-background-secondary);
  color: var(--color-text-primary);
  font-family: var(--font-sans);
  border-radius: var(--border-radius-md);
}

主要变量组:--color-background-*、--color-text-*、--color-border-*、--font-sans、--font-mono、--font-text-*-size、--font-heading-*-size、--border-radius-*。完整列表请参阅 src/spec.types.ts。

对于 React 应用,请改用 useApp 和 useHostStyles 钩子——模式请参阅 basic-server-react/。

可选增强功能

仅供 App 使用的辅助工具

UI 会调用、但模型无需直接调用的工具(轮询、分页、分块加载):

registerAppTool(server, "poll-data", {
  description: "Polls latest data for the UI",
  _meta: { ui: { resourceUri, visibility: ["app"] } },
}, async () => {
  const data = await getLatestData();
  return { content: [{ type: "text", text: JSON.stringify(data) }] };
});

UI 通过 app.callServerTool({ name: "poll-data", arguments: {} }) 调用这些工具。

CSP 配置

如果 UI 需要加载外部资源(字体、API、CDN),请声明相应域名:

registerAppResource(server, "My Tool UI", resourceUri, {}, async () => ({
  contents: [
    {
      uri: resourceUri,
      mimeType: RESOURCE_MIME_TYPE,
      text: html,
      _meta: {
        ui: {
          csp: {
            connectDomains: ["https://api.example.com"], // fetch/XHR targets
            resourceDomains: ["https://cdn.example.com"], // scripts, styles, images
            frameDomains: ["https://embed.example.com"], // nested iframes
          },
        },
      },
    },
  ],
}));
流式部分输入

对于较大的工具输入,可在大模型生成期间显示进度:

app.ontoolinputpartial = (params) => {
  const args = params.arguments; // Healed partial JSON - always valid
  // Render preview with partial data
};

app.ontoolinput = (params) => {
  // Final complete input - switch to full render
};
使用 getUiCapability() 实现优雅降级

仅当客户端支持 UI 时才有条件地注册 App 工具,否则回退到纯文本工具:

import { getUiCapability, registerAppTool, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";

server.server.oninitialized = () => {
  const clientCapabilities = server.server.getClientCapabilities();
  const uiCap = getUiCapability(clientCapabilities);

  if (uiCap?.mimeTypes?.includes(RESOURCE_MIME_TYPE)) {
    // Client supports UI — register App tool
    registerAppTool(server, "my-tool", {
      description: "Shows data with interactive UI",
      _meta: { ui: { resourceUri } },
    }, appToolHandler);
  } else {
    // Text-only client — register plain tool
    server.registerTool(
      "my-tool",
      {
        description: "Shows data",
        inputSchema: z.object({ param: z.string() }),
      },
      plainToolHandler,
    );
  }
};
全屏模式

允许 UI 扩展至全屏:

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

常见错误

  1. 忘记文本 content 回退 — 始终包含带有文本的 content 数组,以供非 UI 宿主使用
  2. 在 connect() 之后注册处理程序 — 调用 app.connect() 之前注册所有处理程序
  3. 缺少 vite-plugin-singlefile — 如果没有它,资源将无法在沙盒化 iframe 中加载
  4. 忘记注册资源 — 工具引用了一个 resourceUri,必须有匹配的资源
  5. 将样式硬编码 — 使用宿主 CSS 变量(var(--color-*))来集成主题
  6. 未处理安全区域内边距 — 在 onhostcontextchanged 中始终应用 ctx.safeAreaInsets

测试

使用 basic-host

使用 basic-host 示例测试增强型服务:

# Terminal 1: Build and run your server
npm run build && npm run serve

# Terminal 2: Run basic-host (from cloned repo)
cd /tmp/mcp-ext-apps/examples/basic-host
npm install
SERVERS='["http://localhost:3001/mcp"]' npm run start
# Open http://localhost:8080

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

验证
  1. 普通工具仍可正常工作并返回文本输出
  2. App 工具会在 iframe 中呈现其 UI
  3. ontoolinput 处理程序会使用工具参数触发
  4. ontoolresult 处理程序会使用工具结果触发
  5. 宿主样式(主题、字体、颜色)会正确应用

评论

支持 Markdown