Authoring & defineMcpApp
Quick Start
A complete app — schema, server handler, UI — in one file:
<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:
- Detects
defineMcpAppand registers an MCP tool namedcolor-picker(from the filename). - Generates a UI resource at
ui://mcp-app/color-pickerexposingtext/html;profile=mcp-app. - Bundles the SFC + assets into a single HTML file with
vite-plugin-singlefile. - Wires the
handler'sstructuredContentinto 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
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:
| File | Name | Title |
|---|---|---|
color-picker.vue | color-picker | Color Picker |
weather-card.vue | weather-card | Weather Card |
admin/audit-log.vue | audit-log | Audit 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 } }
},
})
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:
export interface Swatch { name: string, hex: string }
export interface PalettePayload { base: string, swatches: Swatch[] }
export default defineEventHandler(async (event): Promise<PalettePayload> => {
const { base } = getQuery(event)
return { base: String(base), swatches: buildPalette(String(base)) }
})
<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.