进阶主题

MCP Apps 内部机制

该工具包如何打包、提供和连接 MCP Apps —— 以及你可以在其基础上构建的模式。

本页介绍 MCP Apps 背后的组成部分:构建流水线、主机桥接、安全模型,以及你可以在其上组合的模式。

构建流水线

对于每个 app/mcp/*.vue 文件,Nuxt 模块会在构建时生成 三个产物,并将它们注册到配置好的处理器上:

.nuxt/mcp-apps/
├── color-picker.app.ts       # McpAppDefinition(已解析的 defineMcpApp 调用)
├── color-picker.tool.ts      # 封装该应用的 McpToolDefinition
├── color-picker.resource.ts  # 提供 HTML 的 McpResourceDefinition
└── color-picker.html         # 单文件 Vue 打包结果(vite-plugin-singlefile)

该流水线分三个阶段运行:

  1. 解析 — 从 <script setup> 中提取 defineMcpApp({ … }) 调用,并仅保留在宏参数内部被引用的导入。随后该宏会从浏览器包中剥离
  2. 打包 — 通过编程方式调用 Vite,并使用 vite-plugin-singlefile 为每个 SFC 生成一个自包含的 HTML 文件(Vue 运行时、你的代码、scoped CSS、资源)。
  3. 输出 — 写入这三个 TypeScript 文件以及 HTML,然后将它们加入 Nuxt 的自动导入 + 处理器注册,使其表现得像其他工具 / 资源一样。
输出位于 <buildDir>/mcp-apps/ 下。它会在每次构建时重新生成,开发服务器会监听 app/mcp/**,因此修改会热更新。

HTML 中会内联什么内容

当 LLM 调用该工具时,工具包会取出打包后的 HTML 并注入:

<meta http-equiv="Content-Security-Policy" content="">
<script type="application/json" id="__mcp_app_data__">
  { "base": "#2563eb", "swatches": [ … ] }
</script>

第一次 useMcpApp() 调用会同步读取 #__mcp_app_data__,因此 data.value首次渲染时就已经填充——没有 fetch,没有瀑布流。

主机桥接

iframe 和宿主通过使用 JSON-RPC 2.0 信封的 postMessage 进行通信。工具包提供了一个单例的 useHostBridge()(内部实现),它会:

  1. 执行 ui/initialize 握手,以协商能力并接收 HostContext
  2. 将传入的 tool-result 消息路由回 data
  3. 将发出的 callToolpromptopenLink 请求分发给宿主。
  4. 在与旧宿主通信时,回退到旧版 mcp-ui 信封({ type, payload })。
  5. 检测 ChatGPT Apps SDKwindow.openai),并在可用时使用其原生 API。

你不会直接与它交互——useMcpApp() 组合了公开接口。

当 LLM 调用 color-picker 时的完整往返流程:

  1. 宿主 → 服务器tools/call color-picker { base }
  2. 服务器 — 运行 handler() 并生成 structuredContent
  3. 服务器 → 宿主 — 返回包含内联数据和 ui:// 资源引用的打包 HTML。
  4. 宿主 → iframe — 以内联方式挂载 iframe,并进行沙箱隔离。
  5. iframeuseMcpApp() 同步读取内联的 <script id="__mcp_app_data__">,因此 data 在首次渲染时就已填充。
  6. iframe → 宿主 — 发送 ui/initialize 以协商能力。
  7. 宿主 → iframe — 回复 HostContextthemedisplayModecontainerDimensions、…)。
  8. iframe → 宿主 (可选)ui/callTool { name, params },用于就地刷新。
  9. 宿主 → 服务器 — 将调用转发为 tools/call name { params }
  10. 服务器 → 宿主 → iframe — 新的 structuredContent 作为 tool-result 返回,并替换 data

安全模型

MCP Apps 运行在一个从你的 MCP 端点同源加载的、受沙箱限制的 iframe 中。工具包通过三层来强化该表面。

1. 默认 CSP

每个应用 HTML 都会获得一个 CSP <meta>,它会:

  • 阻止所有第三方脚本。只有内联打包脚本可以执行。
  • 阻止 <form> 的 action 目标。
  • 在你显式允许之前,禁止 connect-srcimg-srcstyle-srcfont-src 外部来源。

你可以按应用启用外部资源:

defineMcpApp({
  csp: {
    resourceDomains: ['https://images.example.com'], // 图片 / 样式 / 字体 / link
    connectDomains: ['https://api.example.com'],     // fetch / XHR / WebSocket
  },
  // …
})

同样的允许列表也会镜像到 _meta.ui.csp_meta['openai/widgetCSP'],供那些自行强制执行 CSP 的宿主使用。

2. 域名验证

CSP 来源会在构建时进行验证。工具包会拒绝:

  • 非字符串或空值。
  • http(s)://ws(s):// 之外的 URL 协议。
  • 包含引号、分号或空白字符的字符串。

如果某个域名看起来可疑,构建就会失败——你不会因为配置错误而意外发布一个注入向量。

3. iframe 隔离

该 iframe 的运行方式就像你域名上的第三方页面:没有 cookie、没有来自父应用的 localStorage、没有共享模块图。这是有意为之——应用必须声明自己需要什么,并且不能访问父 Nuxt 运行时。

只有当你完全控制 iframe 加载的每一个字节时,才传入 csp: false。移除 CSP 就等于关闭了防范依赖被攻破的唯一防线。

自定义 _meta

处理器返回的是一个标准的 MCP CallToolResult,因此你可以通过 _meta 附加任何宿主特定元数据:

defineMcpApp({
  _meta: {
    'openai/widgetAccessible': true,
    'openai/toolInvocation/invoking': '正在加载…',
    'openai/toolInvocation/invoked': '已加载',
  },
  handler: async () => ({ structuredContent: {} }),
})

工具包会自动填充 _meta.ui.resourceUri(这样宿主就可以按需重新获取 HTML)以及 _meta.ui.csp。你放入 _meta 的任何内容都会在其基础上进行合并。

高级模式

重用服务端逻辑

应用与 Nuxt 应用的其他部分共享 server/api/server/utils/shared/。一个典型的布局如下:

app/mcp/color-picker.vue          # UI + 调用 $fetch('/api/palette') 的 handler
server/api/palette.get.ts         # 实际的数据端点(人类 + 工具都可调用)
server/utils/palette.ts           # 共享生成器 / 辅助函数
shared/types/palette.ts           # SFC 和端点都会自动导入的类型

普通 Nuxt 页面、外部客户端或 MCP App 的处理器都会以完全相同的契约访问 /api/palette——而且位于 shared/types/ 下的类型无需 import 语句即可全局解析。

多个处理器

应用在构建时会被归属到一个命名的 MCP 处理器,就像 server/mcp/handlers/<name>/ 下的工具和资源一样。控制归属有两种方式:

子文件夹约定 — 将 SFC 放到与处理器名称匹配的子目录下:

app/mcp/
├── color-picker.vue          # → 处理器 'apps'(默认)
├── finder/
   └── stay-finder.vue       # → 处理器 'finder' (/mcp/finder)
└── checkout/
    └── stay-checkout.vue     # → 处理器 'checkout' (/mcp/checkout)

显式覆盖defineMcpApp 上的 attachTo(以及 group / tags)会覆盖文件夹默认值:

app/mcp/stay-finder.vue
<script setup lang="ts">
defineMcpApp({
  attachTo: 'finder',
  group: 'stays',
  tags: ['searchable'],
  // ...
})
</script>

然后添加匹配的处理器索引文件:

server/mcp/handlers/finder/index.ts
import { defineMcpHandler } from '@nuxtjs/mcp-toolkit/server'

export default defineMcpHandler({})

defaultHandlerStrategy: 'orphans'(默认值)下,该应用不再泄漏到 /mcp 中——它只会出现在 /mcp/finder。需要手动跨处理器汇总?getMcpTools({ handler: 'finder' })getMcpResources({ handler: 'finder' }) 会返回原始定义,供你进一步筛选。参见 处理器

attachTogrouptags 必须是字面量。工具包会在构建时静态读取它们,因此路由决策在开发、构建和部署之间都是确定性的。动态表达式会以明确错误导致构建失败。

按宿主适配

使用 hostContext 选择性启用宿主特定能力:

const isChatGpt = computed(() => typeof window !== 'undefined' && 'openai' in window)
const supportsFullscreen = computed(() => hostContext.value?.displayMode !== undefined)

尽可能避免按宿主硬编码行为——桥接层已经处理了主要差异。

测试应用

服务端:handler 是一个普通的异步函数。直接从 .nuxt/mcp-apps/<name>.app.ts 导入已解析的应用定义(或通过解析器导入 SFC 的 defineMcpApp 参数),并使用 mock 输入直接调用该 handler。

iframe 端:使用 @vue/test-utils 渲染 SFC,并通过注入 window.parent.postMessage 监听器来 stub 主机桥接。工具包自己的测试套件(packages/nuxt-mcp-toolkit/test/apps-handshake.test.ts)展示了这种模式。

MCP Apps 中的大多数回归都源于忘记了**handler 在服务端运行,而模板在客户端运行**。把它们视为 API 的两半:一半生成契约(structuredContent),另一半消费它。

限制与易踩坑

  • 每个应用只能有一个 handler。 如果你需要从同一个 UI 中再暴露一个工具,请在别处声明它,并通过 callTool('other-tool', …) 调用。
  • 应用的 <script setup> 中不能使用顶层 await——宏必须能够被静态分析。
  • SFC 中只允许相对导入 + 自动导入 + #shared 别名。任何引入 Nuxt 运行时的内容都无法打包。
  • 保持载荷尽量小。 数据会内联到 HTML 中;较大的载荷(>1 MB)会明显拖慢首次渲染。
  • 使用 scoped 进行样式隔离。 全局样式会泄漏到各个应用,因为每个应用都会加载 Vue 的样式运行时副本。