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

Isolate apps from your other tools by giving them their own MCP endpoint:

server/mcp/apps.ts
import { defineMcpHandler } from '@nuxtjs/mcp-toolkit/server'
import { tools as allTools } from '#nuxt-mcp-toolkit/tools.mjs'
import { resources as allResources } from '#nuxt-mcp-toolkit/resources.mjs'

const isAppDef = (def: { _meta?: Record<string, unknown> }) => def._meta?.group === 'apps'

export default defineMcpHandler({
  route: '/mcp/apps',
  tools: allTools.filter(isAppDef),
  resources: allResources.filter(isAppDef),
})

Connect the host to https://your-app/mcp/apps to expose only the apps surface, separate from your back-office tools. See Handlers.

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