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)
该流水线分三个阶段运行:
- 解析 — 从
<script setup>中提取defineMcpApp({ … })调用,并仅保留在宏参数内部被引用的导入。随后该宏会从浏览器包中剥离。 - 打包 — 通过编程方式调用 Vite,并使用
vite-plugin-singlefile为每个 SFC 生成一个自包含的 HTML 文件(Vue 运行时、你的代码、scoped CSS、资源)。 - 输出 — 写入这三个 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()(内部实现),它会:
- 执行
ui/initialize握手,以协商能力并接收HostContext。 - 将传入的
tool-result消息路由回data。 - 将发出的
callTool、prompt、openLink请求分发给宿主。 - 在与旧宿主通信时,回退到旧版
mcp-ui信封({ type, payload })。 - 检测 ChatGPT Apps SDK(
window.openai),并在可用时使用其原生 API。
你不会直接与它交互——useMcpApp() 组合了公开接口。
当 LLM 调用 color-picker 时的完整往返流程:
- 宿主 → 服务器 —
tools/call color-picker { base }。 - 服务器 — 运行
handler()并生成structuredContent。 - 服务器 → 宿主 — 返回包含内联数据和
ui://资源引用的打包 HTML。 - 宿主 → iframe — 以内联方式挂载 iframe,并进行沙箱隔离。
- iframe —
useMcpApp()同步读取内联的<script id="__mcp_app_data__">,因此data在首次渲染时就已填充。 - iframe → 宿主 — 发送
ui/initialize以协商能力。 - 宿主 → iframe — 回复
HostContext(theme、displayMode、containerDimensions、…)。 - iframe → 宿主 (可选) —
ui/callTool { name, params },用于就地刷新。 - 宿主 → 服务器 — 将调用转发为
tools/call name { params }。 - 服务器 → 宿主 → 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 运行时。
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)会覆盖文件夹默认值:
<script setup lang="ts">
defineMcpApp({
attachTo: 'finder',
group: 'stays',
tags: ['searchable'],
// ...
})
</script>
然后添加匹配的处理器索引文件:
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)展示了这种模式。
handler 在服务端运行,而模板在客户端运行**。把它们视为 API 的两半:一半生成契约(structuredContent),另一半消费它。限制与易踩坑
- 每个应用只能有一个 handler。 如果你需要从同一个 UI 中再暴露一个工具,请在别处声明它,并通过
callTool('other-tool', …)调用。 - 应用的
<script setup>中不能使用顶层 await——宏必须能够被静态分析。 - SFC 中只允许相对导入 + 自动导入 +
#shared别名。任何引入 Nuxt 运行时的内容都无法打包。 - 保持载荷尽量小。 数据会内联到 HTML 中;较大的载荷(>1 MB)会明显拖慢首次渲染。
- 使用
scoped进行样式隔离。 全局样式会泄漏到各个应用,因为每个应用都会加载 Vue 的样式运行时副本。