Advanced Topics

MCP Apps Internals

How the toolkit bundles, serves, and connects MCP Apps — and the patterns you can build on top.

This page covers the moving parts behind MCP Apps: the build pipeline, the host bridge, the security model, and patterns you can compose on top.

Build Pipeline

For each app/mcp/*.vue file, the Nuxt module emits three artifacts at build time and registers them on the configured handler:

.nuxt/mcp-apps/
├── color-picker.app.ts       # McpAppDefinition (the parsed defineMcpApp call)
├── color-picker.tool.ts      # McpToolDefinition wrapping the app
├── color-picker.resource.ts  # McpResourceDefinition serving the HTML
└── color-picker.html         # Single-file Vue bundle (vite-plugin-singlefile)

The pipeline runs in three phases:

  1. Parse — extract the defineMcpApp({ … }) call from <script setup> and pull out only the imports that are referenced inside the macro arguments. The macro is then stripped from the browser bundle.
  2. Bundle — call Vite programmatically with vite-plugin-singlefile to produce one self-contained HTML file (Vue runtime, your code, scoped CSS, assets) per SFC.
  3. Emit — write the three TypeScript files plus the HTML, then add them to Nuxt's auto-import + handler registration so they behave like any other tool / resource.
Output lives under <buildDir>/mcp-apps/. It's regenerated on every build, and the dev server watches app/mcp/** so changes hot-reload.

What Gets Inlined Into The HTML

When the LLM calls the tool, the toolkit takes the bundled HTML and injects:

<meta http-equiv="Content-Security-Policy" content="">
<script type="application/json" id="__mcp_app_data__">
  { "base": "#2563eb", "swatches": [ … ] }
</script>

The first useMcpApp() call reads #__mcp_app_data__ synchronously, so data.value is already populated on the first paint — no fetch, no waterfall.

The Host Bridge

The iframe and the host communicate over postMessage using a JSON-RPC 2.0 envelope. The toolkit ships a singleton useHostBridge() (internal) that:

  1. Performs the ui/initialize handshake to negotiate capabilities and receive HostContext.
  2. Routes incoming tool-result messages back into data.
  3. Dispatches outbound callTool, prompt, openLink requests to the host.
  4. Falls back to the legacy mcp-ui envelope ({ type, payload }) when talking to older hosts.
  5. Detects the ChatGPT Apps SDK (window.openai) and uses its native APIs when available.

You don't talk to it directly — useMcpApp() composes the public surface.

The full round-trip when the LLM calls color-picker:

  1. Host → Servertools/call color-picker { base }.
  2. Server — runs handler() and produces structuredContent.
  3. Server → Host — returns the bundled HTML with the data inlined and a ui:// resource reference.
  4. Host → Iframe — mounts the iframe inline, sandboxed.
  5. IframeuseMcpApp() reads the inline <script id="__mcp_app_data__"> synchronously, so data is populated on first paint.
  6. Iframe → Host — sends ui/initialize to negotiate capabilities.
  7. Host → Iframe — replies with HostContext (theme, displayMode, containerDimensions, …).
  8. Iframe → Host (optional)ui/callTool { name, params } for in-place refreshes.
  9. Host → Server — forwards the call as tools/call name { params }.
  10. Server → Host → Iframe — new structuredContent flows back as a tool-result and replaces data.

Security Model

MCP Apps run in a sandboxed iframe loaded from the same origin as your MCP endpoint. The toolkit hardens the surface in three layers.

1. Default CSP

Every app HTML gets a CSP <meta> that:

  • Blocks all third-party scripts. Only the inline bundle script may execute.
  • Blocks <form> action targets.
  • Disallows connect-src, img-src, style-src, font-src external origins until you explicitly allow them.

You opt into external resources per app:

defineMcpApp({
  csp: {
    resourceDomains: ['https://images.example.com'], // img / style / font / link
    connectDomains: ['https://api.example.com'],     // fetch / XHR / WebSocket
  },
  // …
})

The same allow-list is mirrored into _meta.ui.csp and _meta['openai/widgetCSP'] for hosts that enforce CSP themselves.

2. Domain Validation

CSP origins are validated at build time. The toolkit rejects:

  • Non-string or empty values.
  • URL schemes other than http(s):// or ws(s)://.
  • Strings that contain quotes, semicolons, or whitespace.

If a domain looks suspicious, the build fails — you can't accidentally ship an injection vector via misconfiguration.

3. Iframe Isolation

The iframe runs as if it were a third-party page on your origin: no cookies, no localStorage from the parent app, no shared module graph. This is by design — apps must declare what they need, and they cannot reach into the parent Nuxt runtime.

Pass csp: false only when you fully control every byte the iframe loads. Stripping the CSP turns off the only line of defense against compromised dependencies.

Custom _meta

The handler returns a regular MCP CallToolResult, so you can attach any host-specific metadata via _meta:

defineMcpApp({
  _meta: {
    'openai/widgetAccessible': true,
    'openai/toolInvocation/invoking': 'Loading stays…',
    'openai/toolInvocation/invoked': 'Stays loaded',
  },
  handler: async () => ({ structuredContent: {} }),
})

The toolkit auto-fills _meta.ui.resourceUri (so hosts can re-fetch the HTML on demand) and _meta.ui.csp. Anything you put in _meta is merged on top.

Advanced Patterns

Re-using server logic

Apps share server/api/, server/utils/, and shared/ with the rest of your Nuxt app. A typical layout:

app/mcp/color-picker.vue          # UI + handler that calls $fetch('/api/palette')
server/api/palette.get.ts         # The actual data endpoint (callable by humans + tools)
server/utils/palette.ts           # Shared generators / helpers
shared/types/palette.ts           # Types auto-imported by both the SFC and the endpoint

A regular Nuxt page, an external client, or the MCP App handler all hit /api/palette with the exact same contract — and types under shared/types/ resolve globally without an import statement.

Multiple handlers

Apps are attributed to a named MCP handler at build time, exactly like tools and resources under server/mcp/handlers/<name>/. Two ways to control attribution:

Sub-folder convention — drop the SFC under a sub-directory matching the handler name:

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

Explicit overrideattachTo (plus group / tags) on defineMcpApp win over the folder default:

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

Then add the matching handler index file:

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

export default defineMcpHandler({})

With defaultHandlerStrategy: 'orphans' (the default) the app no longer leaks into /mcp — it only shows up on /mcp/finder. Need a manual cross-cut? getMcpTools({ handler: 'finder' }) and getMcpResources({ handler: 'finder' }) return the raw definitions for further filtering. See Handlers.

attachTo, group, and tags must be literals. The toolkit reads them statically at build time so the routing decision is deterministic across dev, build, and deploy. Dynamic expressions fail the build with a clear error.

Per-host adaptation

Use hostContext to opt into host-specific affordances:

const isChatGpt = computed(() => typeof window !== 'undefined' && 'openai' in window)
const supportsFullscreen = computed(() => hostContext.value?.displayMode !== undefined)

Avoid hard-coding behaviours per host whenever you can — the bridge already smooths over the major differences.

Testing apps

Server-side: the handler is a plain async function. Import the parsed app definition from .nuxt/mcp-apps/<name>.app.ts (or import the SFC's defineMcpApp arguments via the parser) and call the handler directly with mock input.

Iframe-side: render the SFC with @vue/test-utils and stub the host bridge by injecting window.parent.postMessage listeners. The toolkit's own test suite (packages/nuxt-mcp-toolkit/test/apps-handshake.test.ts) shows the pattern.

Most regressions in MCP Apps come from forgetting that handler runs server-side and the template runs client-side. Treat them as two halves of an API: one produces a contract (structuredContent), the other consumes it.

Limits & Footguns

  • One handler per app. If you need a second tool from the same UI, declare it elsewhere and call it via callTool('other-tool', …).
  • No top-level await in <script setup> of an app — the macro must be statically analysable.
  • Only relative imports + auto-imports + the #shared alias in the SFC. Anything that pulls in the Nuxt runtime won't bundle.
  • Keep payloads small. The data is inlined into the HTML; large payloads (>1 MB) noticeably slow first paint.
  • Style with scoped. Global styles leak across apps because every app loads its own copy of Vue's style runtime.
Copyright © 2026