应用

编写与 defineMcpApp

SFC 位置、快速开始、defineMcpApp 宏、服务器处理器,以及共享类型。

快速开始

一个完整的应用——schema、服务器处理器、UI——全部写在一个文件里:

app/mcp/color-picker.vue
<script setup lang="ts">
import { z } from 'zod'

interface PalettePayload {
  base: string
  swatches: { name: string, hex: string }[]
}

defineMcpApp({
  description: '选择一种颜色并预览一个 5 色调色板。',
  inputSchema: {
    base: z.string().describe('用于锚定调色板的十六进制颜色,例如 #2563eb'),
  },
  handler: async ({ base }): Promise<{ structuredContent: PalettePayload }> => {
    const swatches = await $fetch<{ name: string, hex: string }[]>('/api/palette', {
      query: { base },
    })
    return { structuredContent: { base, swatches } }
  },
})

const { data, loading, sendPrompt } = useMcpApp<PalettePayload>()
</script>

<template>
  <main class="picker">
    <p v-if="loading">
      正在混合颜色…
    </p>
    <ul v-else-if="data" class="swatches">
      <li v-for="s in data.swatches" :key="s.hex">
        <button
          type="button"
          :style="{ background: s.hex }"
          @click="sendPrompt(`将 ${s.name}(${s.hex})用作主颜色。`)"
        >
          {{ s.name }}
        </button>
      </li>
    </ul>
  </main>
</template>

<style scoped>
.picker { padding: 16px; font-family: system-ui, sans-serif; }
.swatches { display: grid; grid-template-columns: repeat(5, 1fr); gap: 8px; padding: 0; list-style: none; }
.swatches button { width: 100%; aspect-ratio: 1; border-radius: 8px; border: 0; cursor: pointer; }
</style>

就是这样。该工具包会:

  1. 检测 defineMcpApp 并根据文件名注册一个 MCP 工具,命名为 color-picker
  2. 生成一个 UI 资源 ui://mcp-app/color-picker,暴露 text/html;profile=mcp-app
  3. 使用 vite-plugin-singlefile 将 SFC + 资源打包为一个 HTML 文件。
  4. 将处理器返回的 structuredContent 注入 iframe,从而使 UI 无需第二次往返请求即可完成水合。

文件约定

MCP Apps 默认位于 app/mcp/(而不是 server/mcp/)。你可以在 nuxt.config.ts 中通过 mcp.appsDir 更改应用侧目录。它们位于 Nuxt 的客户端侧,因为它们要编写 Vue 组件——但你声明的 handler 会像工具一样在服务端运行。

app/
└── mcp/
    ├── color-picker.vue    # → 工具:color-picker,资源:ui://mcp-app/color-picker
    └── admin/
        └── audit-log.vue   # → 工具:audit-log
将辅助函数与 SFC 放在一起(例如 format.ts)——打包器会将它们内联。把数据生成逻辑保留在 server/api/ 中,并在处理器里通过 $fetch 调用它。

自动生成的名称与标题

和工具与资源一样,nametitle 会从文件名推断出来:

文件名称标题
color-picker.vuecolor-pickerColor Picker
weather-card.vueweather-cardWeather Card
admin/audit-log.vueaudit-logAudit Log

你也可以通过向 defineMcpApp 传入 name / title 来覆盖它们。

将应用路由到特定处理器

默认情况下,每个应用都会附加到隐式的 apps 处理器,并且只会在 /mcp/apps 上展示。将应用路由到其他命名处理器有两种方式:

1. 子文件夹约定 —— app/mcp/ 下的第一级子目录会成为处理器归属:

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

将每个处理器文件夹与 server/mcp/handlers/<name>/index.ts 配对:

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

export default defineMcpHandler({})

2. 显式 attachTo 覆盖 —— 如果两者都存在,则会覆盖子文件夹默认值:

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

生成的工具和资源会携带 _meta.handler = 'finder'、顶层 group = 'stays',以及 tags = ['searchable']。你可以使用 getMcpTools({ handler: 'finder' })getMcpTools({ tags: ['searchable'] }) 等对它们进行过滤。

defineMcpApp

一个宏——类似 definePageMeta——会在构建时提取,并且从浏览器打包产物中移除。它接受的字段如下:

defineMcpApp({
  name?: string                          // 覆盖自动推断的名称
  title?: string                         // 覆盖自动推断的标题
  description?: string                   // 显示给 LLM,以帮助它选择这个应用
  inputSchema?: ZodRawShape              // 在服务器上验证工具输入
  handler?: (args, extra) => Result      // 在服务端运行;默认是 (args) => ({ structuredContent: args })
  csp?: McpAppCsp | false                // 收紧或禁用 iframe CSP
  attachTo?: string                      // 该应用路由到的命名 MCP 处理器(默认:'apps' 或子文件夹)
  group?: string                         // 顶层分组标签(默认:与 attachTo 相同)
  tags?: string[]                        // 转发到生成工具的顶层标签
  _meta?: Record<string, unknown>        // 额外的 _meta 字段,向宿主暴露
})
attachTogrouptags 必须是字面量'finder'['a', 'b'])——工具包会在构建时静态读取它们,以便路由生成的工具和资源。动态表达式(attachTo: someVar)会以清晰的错误使构建失败。

服务器处理器

handler 运行在你的 Nitro 服务器中,而不是在 iframe 里。它接收已验证的输入,并返回 UI 用于水合的 structuredContent把它当作一个工具处理器来使用——调用 API、查询数据库、使用 $fetch

defineMcpApp({
  description: '选择一种颜色并预览一个 5 色调色板。',
  inputSchema: {
    base: z.string().describe('用于锚定调色板的十六进制颜色,例如 #2563eb'),
  },
  handler: async ({ base }) => {
    const swatches = await $fetch('/api/palette', { query: { base } })
    return { structuredContent: { base, swatches } }
  },
})
从处理器返回 structuredContent 会将数据以内联方式写入 HTML,形式为 <script type="application/json">。iframe 启动时已经拥有完整数据——无需额外 fetch,也不会闪烁。

如果你省略 handler,工具包会默认使用 (args) => ({ structuredContent: args })。这适用于只需要回显输入的无状态应用。

在服务器与 UI 之间共享类型

将共享类型放在 Nuxt 的 shared/types/ 目录中——它们会在 SFC 和 API 端点中自动全局导入,无需 import 语句:

shared/types/palette.ts
export interface Swatch { name: string, hex: string }
export interface PalettePayload { base: string, swatches: Swatch[] }
server/api/palette.get.ts
export default defineEventHandler(async (event): Promise<PalettePayload> => {
  const { base } = getQuery(event)
  return { base: String(base), swatches: buildPalette(String(base)) }
})
app/mcp/color-picker.vue
<script setup lang="ts">
defineMcpApp({
  inputSchema: { base: z.string() },
  handler: async ({ base }): Promise<{ structuredContent: PalettePayload }> => ({
    structuredContent: await $fetch('/api/palette', { query: { base } }),
  }),
})

const { data } = useMcpApp<PalettePayload>()
</script>

仅类型引用会被 esbuild 从浏览器打包产物中移除——运行时无需在 iframe 内解析任何内容。