MCP Apps Internals
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:
- 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. - Bundle — call Vite programmatically with
vite-plugin-singlefileto produce one self-contained HTML file (Vue runtime, your code, scoped CSS, assets) per SFC. - 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.
<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:
- Performs the
ui/initializehandshake to negotiate capabilities and receiveHostContext. - Routes incoming
tool-resultmessages back intodata. - Dispatches outbound
callTool,prompt,openLinkrequests to the host. - Falls back to the legacy
mcp-uienvelope ({ type, payload }) when talking to older hosts. - 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:
- Host → Server —
tools/call color-picker { base }. - Server — runs
handler()and producesstructuredContent. - Server → Host — returns the bundled HTML with the data inlined and a
ui://resource reference. - Host → Iframe — mounts the iframe inline, sandboxed.
- Iframe —
useMcpApp()reads the inline<script id="__mcp_app_data__">synchronously, sodatais populated on first paint. - Iframe → Host — sends
ui/initializeto negotiate capabilities. - Host → Iframe — replies with
HostContext(theme,displayMode,containerDimensions, …). - Iframe → Host (optional) —
ui/callTool { name, params }for in-place refreshes. - Host → Server — forwards the call as
tools/call name { params }. - Server → Host → Iframe — new
structuredContentflows back as atool-resultand replacesdata.
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-srcexternal 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)://orws(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.
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:
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.
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
#sharedalias 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.