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.

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
  _meta?: Record<string, unknown>        // Extra _meta fields surfaced to the host
})

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