Core Concepts

Apps

Build interactive UI widgets that AI hosts render alongside chat — written as Vue SFCs, served as MCP UI resources.

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 defineMcpApp macro 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.
Prompt
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:

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.

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.

Follow-ups are best-effort. Hosts that implement 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>
Add the target host to 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 postMessage to its parent host.

If your UI needs external assets or APIs, allow them explicitly:

app/mcp/color-picker.vue
<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.

Pass 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.
FieldWhat it allows
resourceDomains<img>, <style>, <link>, fonts loaded from these origins
connectDomainsfetch(), 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:

  1. A tool definition registered on your MCP handler (input schema, description, server handler).
  2. A UI resource at ui://mcp-app/<name> returning text/html;profile=mcp-app.
  3. 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:

  1. Your handler runs on the server and produces structuredContent.
  2. The toolkit injects that data into the bundled HTML as <script type="application/json" id="__mcp_app_data__">…</script>.
  3. The host returns the resource to the user; its iframe boots and useMcpApp() reads the inline data into data synchronously.
  4. From there, the iframe and the host exchange messages over a JSON-RPC bridge for callTool, sendPrompt, openLink, and theme/size updates.
Everything ships in one HTML response. No extra HTTP request from the iframe, no waterfall, no flicker.

Host Compatibility

HostRendersendPromptcallToolopenLinkNotes
CursorTested 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 hostsvariesvariesvariesThe 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.
  • $fetch inside handler (server side) — call any Nuxt API.
  • ✅ Co-located .ts/.css helpers next to the SFC (the bundler inlines them).
  • ✅ Headless UI libraries (Reka UI, Headless UI Vue, Floating UI) — they're framework-agnostic.
Need rich components? Build a small co-located design system in your apps directory, such as 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

APIWherePurpose
defineMcpApp()<script setup>Declare the tool, schema, handler, CSP. Stripped from the browser bundle.
useMcpApp<T>()SFC bodyReactive data / loading / hostContext + callTool, sendPrompt, openLink.
csp.resourceDomainsdefineMcpAppAllow-list image / font / style origins.
csp.connectDomainsdefineMcpAppAllow-list fetch / XHR / WebSocket origins.
_metadefineMcpAppExtra _meta fields surfaced to the host alongside ui.resourceUri and ui.csp.

Looking for the underlying tool / resource APIs? See Tools and Resources.

Copyright © 2026