进阶主题

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/**,因此修改会热更新。

定制 Vue 构建

打包结果是一个隔离的 Vue 应用,而不是 Nuxt 页面。如果要添加 Nuxt UI 等仅依赖 Vue 的集成,可以定制此构建,而不与宿主 Nuxt 模块图共享内容:

nuxt.config.ts
import ui from '@nuxt/ui/vite'

export default defineNuxtConfig({
  mcp: {
    apps: {
      css: ['~/app/mcp/app.css'],
      vitePlugins: [ui({ router: false, colorMode: false })],
      vuePlugins: ['@nuxt/ui/vue-plugin'],
    },
  },
})

请使用仅适用于 Vue 的 Nuxt UI 集成(@nuxt/ui/vite 和 @nuxt/ui/vue-plugin),不要使用 Nuxt 模块。iframe 没有 Nuxt 运行时、自动导入、cookies 或共享模块图。css 导入会内联到 HTML 包中,~/@ 会相对于 Nuxt 源码目录解析。额外的 vitePlugins 会在工具包必需的 Vue 插件和单文件插件之间运行,这两个必需插件不能移除。vuePlugins 中列出的默认导出会在挂载前安装到生成的 Vue 应用中。挂载节点默认包含 isolate。

对于需要执行 Vue 插件安装以外操作的集成,entry 可以替换生成的挂载入口。它不能与 vuePlugins 同时使用;请改为在自定义入口中导入并安装这些插件。

HTML 中会注入什么内容

当主机读取 ui:// 资源时,工具包会提供带有 CSP 标签的打包 HTML:

<meta http-equiv="Content-Security-Policy" content="…">

工具结果本身不包含 HTML。主机会通过 resources/read 获取视图,因此如果将 HTML 放入结果的 content,它只会进入模型上下文。

主机桥接

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

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

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

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

  1. 主机 → 服务器 — tools/call color-picker { base }。
  2. 服务器 — 运行 handler() 并生成 structuredContent。
  3. 服务器 → 主机 — 返回 structuredContent 和 content;工具的 _meta.ui.resourceUri 指向视图。
  4. 主机 → iframe — 读取 ui:// 资源,并在沙盒中以内联方式挂载。
  5. iframe → 主机 — 发送 ui/initialize 以协商能力。
  6. 主机 → iframe — 回复 HostContext(theme、displayMode、containerDimensions 等)。
  7. 主机 → iframe — 通过 ui/notifications/tool-result 推送结果;useMcpApp() 会将其中的 structuredContent 作为 data 暴露。
  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-src、img-src、style-src、font-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' }) 会返回原始定义,供你进一步筛选。参见 处理器。

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

按宿主适配

使用 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 运行时的内容都无法打包。
  • 保持载荷尽量小。 如果 handler 未返回 content,工具包会将 structuredContent 作为 JSON 复制到 content 中,因此模型会读取整个载荷。对于较大的载荷,请同时返回简短的 content 文本。
  • 使用 scoped 隔离样式。 全局样式会泄漏到各个应用,因为每个应用都会加载自己的 Vue 样式运行时副本。