Apps

Authoring & defineMcpApp

SFC location, quick start, the defineMcpApp macro, server handler, and shared types.

Quick Start

A complete app — schema, server handler, UI — in one file:

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

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

defineMcpApp({
  description: 'Pick a colour and preview a 5-tone palette.',
  inputSchema: {
    base: z.string().describe('Hex colour to anchor the palette, e.g. #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">
      Mixing colours…
    </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(`Use ${s.name} (${s.hex}) as the primary colour.`)"
        >
          {{ 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>

That's it. The toolkit:

  1. Detects defineMcpApp and registers an MCP tool named color-picker (from the filename).
  2. Generates a UI resource at ui://mcp-app/color-picker exposing text/html;profile=mcp-app.
  3. Bundles the SFC + assets into a single HTML file with vite-plugin-singlefile.
  4. Wires the handler's structuredContent into the iframe so the UI hydrates without a second round-trip.

File Convention

MCP Apps live in app/mcp/ by default (not server/mcp/). Change the app-side directory with mcp.appsDir in nuxt.config.ts. They sit on the client side of Nuxt because they author Vue components — but the handler you declare runs server-side, just like a tool.

app/
└── mcp/
    ├── color-picker.vue    # → tool: color-picker, resource: ui://mcp-app/color-picker
    └── admin/
        └── audit-log.vue   # → tool: audit-log
Co-locate helpers next to the SFC (e.g. format.ts) — the bundler inlines them. Keep data generation in server/api/ and call it via $fetch from the handler.

Auto-Generated Name & Title

Like tools and resources, name and title are inferred from the filename:

FileNameTitle
color-picker.vuecolor-pickerColor Picker
weather-card.vueweather-cardWeather Card
admin/audit-log.vueaudit-logAudit Log

Override either by passing name / title to defineMcpApp.

Routing Apps to a Specific Handler

By default, every app is attached to the implicit apps handler and only surfaces on /mcp/apps. Two ways to route an app to a different named handler:

1. Sub-folder convention — the first sub-directory under app/mcp/ becomes the handler attribution:

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

Pair each handler folder with server/mcp/handlers/<name>/index.ts:

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

export default defineMcpHandler({})

2. Explicit attachTo override — overrides the sub-folder default if both are present:

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

The generated tool and resource carry _meta.handler = 'finder', top-level group = 'stays', and tags = ['searchable']. Filter on them with getMcpTools({ handler: 'finder' }), getMcpTools({ tags: ['searchable'] }), etc.

defineMcpApp

A macro — like definePageMeta — extracted at build time and stripped from the browser bundle. The fields it accepts:

defineMcpApp({
  name?: string                          // Override auto-derived name
  title?: string                         // Override auto-derived title
  description?: string                   // Shown to the LLM to help it pick this app
  inputSchema?: ZodRawShape              // Validates tool input on the server
  handler?: (args, extra) => Result      // Runs server-side; defaults to (args) => ({ structuredContent: args })
  csp?: McpAppCsp | false                // Tighten or disable iframe CSP
  attachTo?: string                      // Named MCP handler this app routes to (default: 'apps' or sub-folder)
  group?: string                         // Top-level group label (default: same as attachTo)
  tags?: string[]                        // Top-level tags forwarded to the generated tool
  _meta?: Record<string, unknown>        // Extra _meta fields surfaced to the host
})
attachTo, group, and tags must be literals ('finder', ['a', 'b']) — the toolkit reads them statically at build time to route the generated tool and resource. A dynamic expression (attachTo: someVar) fails the build with a clear error.

Server Handler

The handler runs in your Nitro server, not in the iframe. It receives validated input and returns structuredContent that the UI hydrates from. Treat it like a tool handler — call APIs, query a database, hit $fetch:

defineMcpApp({
  description: 'Pick a colour and preview a 5-tone palette.',
  inputSchema: {
    base: z.string().describe('Hex colour to anchor the palette, e.g. #2563eb'),
  },
  handler: async ({ base }) => {
    const swatches = await $fetch('/api/palette', { query: { base } })
    return { structuredContent: { base, swatches } }
  },
})
Returning structuredContent from the handler inlines the data into the HTML as a <script type="application/json">. The iframe boots with full data already present — no extra fetch, no flicker.

If you omit handler, the toolkit defaults to (args) => ({ structuredContent: args }). Useful for stateless apps that only need the input echoed back.

Sharing Types Between Server & UI

Place shared types in Nuxt's shared/types/ directory — they're auto-imported globally in both the SFC and your API endpoints, no import statement required:

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>

Type-only references are stripped from the browser bundle by esbuild — nothing has to resolve inside the iframe at runtime.

Copyright © 2026