Apps
What are MCP Apps?
MCP Apps are interactive HTML widgets returned by an MCP tool and rendered inline by compatible hosts. Instead of streaming back text, your tool ships a small UI that the user can read, scroll, filter, and click — connected back to your server through a typed message bridge.
They follow the MCP UI proposal (SEP-1865): a tool returns a text/html;profile=mcp-app resource referenced by ui://, the host loads it inside a sandboxed iframe, and the iframe talks back over postMessage.
@nuxtjs/mcp-toolkit makes that authoring experience feel like writing a regular Nuxt page:
- One Vue SFC per app in
app/mcp/. - A
defineMcpAppmacro inside<script setup>declares the tool, schema, and server handler. - A
useMcpApp()composable gives you reactive data, host context, and a typed bridge to the host. - The toolkit bundles the SFC into a single self-contained HTML file at build time and serves it from your MCP endpoint.
Create a new MCP App in my Nuxt app using @nuxtjs/mcp-toolkit.
- Create a Vue SFC in app/mcp/ (e.g. app/mcp/color-picker.vue)
- Use defineMcpApp({ description, inputSchema, handler }) inside <script setup lang="ts">
- inputSchema uses Zod; handler runs server-side and returns { structuredContent }
- handler can call any Nuxt API route via $fetch — keep heavy data work on the server
- In the template, call useMcpApp<TPayload>() to get { data, loading, hostContext, sendPrompt, callTool, openLink }
- data is hydrated from structuredContent on first render — no extra request
- Use sendPrompt(text) to push a follow-up into the chat that triggers another tool/app
- Use callTool(name, params) to re-invoke an MCP tool and refresh data in place
- Use openLink(url) to ask the host to open a URL outside the iframe
- Add CSP allow-lists with csp: { resourceDomains, connectDomains } if you load images or call external APIs
- Make the layout fluid (no fixed heights); hosts often render the iframe inline at variable widths
Docs: https://mcp-toolkit.nuxt.dev/core-concepts/apps
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.
useMcpApp()
The single client-side composable, auto-imported into every MCP App SFC. It returns everything the iframe needs to talk to the host:
const {
data, // Ref<T | null> — hydrated from structuredContent, refreshed by callTool
loading, // Ref<boolean> — true until first payload arrives
error, // Ref<Error | null> — bridge / transport / payload errors
pending, // Ref<boolean> — true while a callTool() is in flight
hostContext, // Ref<HostContext | null> — theme, displayMode, locale, …
callTool, // (name, params?) => Promise<T | null> — re-invoke any MCP tool
sendPrompt, // (prompt: string) => void — push a message into the chat
openLink, // (url: string) => void — ask the host to open a URL
} = useMcpApp<MyPayload>()
Pass your payload type as the generic to get full inference downstream.
data & loading
data is already populated on first render when the handler returns structuredContent. loading starts as true and becomes false after the first payload arrives. Use pending for in-flight callTool() refreshes:
<template>
<section v-if="loading" class="skeleton" />
<section v-else-if="data" class="content">
{{ data.swatches.length }} swatches from {{ data.base }}
</section>
</template>
hostContext
The host hands the iframe a context object during the ui/initialize handshake. Use it to adapt to dark mode, fullscreen, or a fixed iframe size:
interface HostContext {
theme?: 'light' | 'dark'
displayMode?: 'inline' | 'fullscreen' | 'pip'
containerDimensions?: { width?: number, height?: number, maxWidth?: number, maxHeight?: number }
locale?: string
timeZone?: string
platform?: 'web' | 'desktop' | 'mobile'
}
<script setup lang="ts">
const { hostContext } = useMcpApp()
const isDark = computed(() => hostContext.value?.theme === 'dark')
const isFullscreen = computed(() => hostContext.value?.displayMode === 'fullscreen')
</script>
<template>
<main :data-theme="isDark ? 'dark' : 'light'" :data-mode="isFullscreen ? 'fullscreen' : 'inline'">
…
</main>
</template>
hostContext is null on the very first paint and populates after the handshake (typically <50 ms). Always use a fallback in your template.sendPrompt(prompt) — Follow-Ups
Push a message into the chat as if the user had typed it. The LLM then routes it like any other request — including invoking another MCP App:
<button @click="sendPrompt(`Use ${swatch.name} (${swatch.hex}) as the brand colour.`)">
Use this colour
</button>
The host receives the prompt as if the user had typed it. The LLM may reply, call another tool, or open a different MCP App in response — app-to-app workflows fall out of this primitive.
ui/message forward the prompt cleanly. ChatGPT acknowledges the request but doesn't always re-render the next tool inline (an upstream limitation).callTool(name, params) — In-Place Refresh
Re-invoke any MCP tool from the iframe. The result replaces data automatically:
<script setup lang="ts">
const { data, pending, callTool } = useMcpApp<PalettePayload>()
async function refresh(base: string) {
await callTool('color-picker', { base })
}
</script>
Use this for filters, pagination, refresh buttons — anything that changes the query without a full chat round-trip.
openLink(url)
Sandbox iframes can't open windows. openLink asks the host to do it for you (e.g. open a booking confirmation in a new browser tab):
<button @click="openLink(`https://example.com/colors/${swatch.hex.slice(1)}`)">
View on the web
</button>
csp.connectDomains if you also need to fetch() it from the iframe.CSP & Resource Allow-Lists
The toolkit injects a conservative Content Security Policy into every app HTML. By default the iframe can:
- Run only its own inline script.
- Render images and styles only from the same response.
- Talk over
postMessageto its parent host.
If your UI needs external assets or APIs, allow them explicitly:
<script setup lang="ts">
defineMcpApp({
description: 'Pick a colour and preview a palette.',
csp: {
resourceDomains: ['https://images.unsplash.com'],
connectDomains: ['https://api.example.com'],
},
handler: async ({ base }) => ({ structuredContent: await $fetch('/api/palette', { query: { base } }) }),
})
</script>
The CSP is mirrored into _meta.ui.csp (and openai/widgetCSP for ChatGPT) so hosts that enforce CSP at the iframe level pick up the same rules.
csp: false only as a last resort — and only if you fully control the assets the iframe loads. The default policy is what makes apps safe to render across hosts.| Field | What it allows |
|---|---|
resourceDomains | <img>, <style>, <link>, fonts loaded from these origins |
connectDomains | fetch(), XHR, WebSocket, EventSource to these origins |
Origins should use http(s):// or ws(s)://. The toolkit rejects empty values, unsupported URL schemes, whitespace, quotes, and semicolons before the app response is served.
How It's Wired
Behind the scenes, each .vue file under the configured apps directory becomes three artifacts at build time:
- A tool definition registered on your MCP handler (input schema, description, server handler).
- A UI resource at
ui://mcp-app/<name>returningtext/html;profile=mcp-app. - A single-file HTML bundle of the Vue SFC (Vue runtime, your code, scoped CSS, assets) inlined into the resource response.
When the LLM calls the tool:
- Your
handlerruns on the server and producesstructuredContent. - The toolkit injects that data into the bundled HTML as
<script type="application/json" id="__mcp_app_data__">…</script>. - The host returns the resource to the user; its iframe boots and
useMcpApp()reads the inline data intodatasynchronously. - From there, the iframe and the host exchange messages over a JSON-RPC bridge for
callTool,sendPrompt,openLink, and theme/size updates.
Host Compatibility
| Host | Render | sendPrompt | callTool | openLink | Notes |
|---|---|---|---|---|---|
| Cursor | ✅ | ✅ | ✅ | ✅ | Tested with the JSON-RPC bridge and legacy ready/resize messages. |
| ChatGPT (Apps SDK) | ✅ | ⚠️ | ✅ | ✅ | Uses window.openai when available. Follow-ups are sent, but the next tool is not always rendered inline. |
| MCP UI / Inspector-style hosts | ✅ | varies | varies | varies | The bridge emits spec JSON-RPC messages plus legacy mcp-ui envelopes where useful. Verify each host before documenting support. |
The bridge auto-detects the host on handshake and adapts to its protocol — modern JSON-RPC for MCP UI hosts, the legacy mcp-ui envelope for older clients, the ChatGPT Apps SDK globals when present. Your code just calls useMcpApp().
Patterns
Skeletons during callTool
pending flips to true only during in-flight callTool calls — perfect for partial UI updates without losing the previous data:
<button :disabled="pending" @click="callTool('color-picker', { base: nextBase })">
{{ pending ? 'Mixing…' : 'Try this colour' }}
</button>
Adapting to fullscreen vs inline
Hosts can mount your app inline (small) or expand it fullscreen. Switch layout primitives:
<section :class="['rail', isFullscreen && 'rail-grid']">
<!-- horizontal scroll inline, CSS grid in fullscreen -->
</section>
App-to-app workflows
Use sendPrompt from app A and let the LLM dispatch to app B. The user perceives a smooth transition; the LLM stays in the loop and can carry parameters through:
<button @click="sendPrompt(`Generate a typography scale for ${swatch.hex}.`)">
Pair with type
</button>
The LLM picks the right tool from the prompt — keep names predictable.
Co-locating CSS
Use scoped styles or any CSS-in-JS solution that emits inline styles. The bundler inlines all styles into the HTML; no external CSS request fires from the iframe.
What You Cannot Do (Yet)
MCP Apps run inside an isolated iframe with no access to your Nuxt runtime context. That means:
- ❌ Nuxt UI / Nuxt Image / NuxtLink — they need the parent Nuxt app's runtime.
- ❌ Auto-imported Nuxt composables (
useFetch,useState,useRoute,useNuxtApp, …). - ❌ Pinia stores or app-level plugins — different module graph.
- ❌ Cookies, headers, session from the parent page — sandboxed origin.
What you can use:
- ✅ Vue 3 + Composition API (auto-imported:
ref,computed,watch,onMounted, …). - ✅
useMcpApp()— the only toolkit composable in the iframe. - ✅
$fetchinsidehandler(server side) — call any Nuxt API. - ✅ Co-located
.ts/.csshelpers next to the SFC (the bundler inlines them). - ✅ Headless UI libraries (Reka UI, Headless UI Vue, Floating UI) — they're framework-agnostic.
app/mcp/_components/ — it gets bundled into every app that imports it. Or pull in a headless library that doesn't depend on the host runtime.Reference
| API | Where | Purpose |
|---|---|---|
defineMcpApp() | <script setup> | Declare the tool, schema, handler, CSP. Stripped from the browser bundle. |
useMcpApp<T>() | SFC body | Reactive data / loading / hostContext + callTool, sendPrompt, openLink. |
csp.resourceDomains | defineMcpApp | Allow-list image / font / style origins. |
csp.connectDomains | defineMcpApp | Allow-list fetch / XHR / WebSocket origins. |
_meta | defineMcpApp | Extra _meta fields surfaced to the host alongside ui.resourceUri and ui.csp. |
Looking for the underlying tool / resource APIs? See Tools and Resources.