---
title: "MCP Apps Internals"
description: "How the toolkit bundles, serves, and connects MCP Apps — and the patterns you can build on top."
canonical_url: "https://mcp-toolkit.nuxt.dev/advanced/mcp-apps-internals"
---
# 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](https://mcp-toolkit.nuxt.dev/apps/overview): 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:

```bash
.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`](https://github.com/richardtallent/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.

<callout color="info" icon="i-lucide-info">

Output lives under `<buildDir>/mcp-apps/`. It's regenerated on every build, and the dev server watches `app/mcp/**` so changes hot-reload.

</callout>

The bundle is an isolated Vue app, not a Nuxt page. To add Vue-only integrations such as
Nuxt UI, customize that build without sharing the host Nuxt graph:

```ts [nuxt.config.ts]
import ui from '@nuxt/ui/vite'

export default defineNuxtConfig({
  mcp: {
    apps: {
      css: ['~/mcp/app.css'],
      vitePlugins: [ui({ router: false, colorMode: false })],
      vuePlugins: ['@nuxt/ui/vue-plugin'],
    },
  },
})
```

Use the Vue-only Nuxt UI install (`@nuxt/ui/vite` and `@nuxt/ui/vue-plugin`), not the Nuxt
module. The iframe has no Nuxt runtime, auto-imports, cookies, or shared module graph.
`css` imports are inlined into the HTML bundle. Relative paths resolve from the project root,
and Nuxt's path aliases into the project (`~`, `~~`, `#shared`, …) resolve as in the app. Additional `vitePlugins` run between the toolkit's required Vue and single-file
plugins, which cannot be removed. Default exports listed in `vuePlugins` are installed on
the generated Vue app before it mounts. The mount node includes `isolate` by default.

For integrations that need more than Vue plugin installation, `entry` replaces the generated
mount entry. It cannot be combined with `vuePlugins`; import and install those plugins in the
custom entry instead.

### What Gets Injected Into The HTML

When the host reads the `ui://` resource, the toolkit serves the bundled HTML with a CSP tag:

```html
<meta http-equiv="Content-Security-Policy" content="…">
```

The tool result never carries the HTML. Hosts fetch the view via `resources/read`, so HTML in the result's `content` would only reach the model's context.

## 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 → Server** — `tools/call color-picker { base }`.
2. **Server** — runs `handler()` and produces `structuredContent`.
3. **Server → Host** — returns `structuredContent` and `content`; the tool's `_meta.ui.resourceUri` points at the view.
4. **Host → Iframe** — reads the `ui://` resource and mounts it inline, sandboxed.
5. **Iframe → Host** — sends `ui/initialize` to negotiate capabilities.
6. **Host → Iframe** — replies with `HostContext` (`theme`, `displayMode`, `containerDimensions`, …).
7. **Host → Iframe** — pushes the result as `ui/notifications/tool-result`; `useMcpApp()` exposes its `structuredContent` as `data`.
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:

```ts
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.

<callout color="warning" icon="i-lucide-shield">

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.

</callout>

## Custom `_meta`

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

```ts
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:

```bash
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:

```bash
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 override** — `attachTo` (plus `group` / `tags`) on `defineMcpApp` win over the folder default:

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

Then add the matching handler index file:

```ts [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](https://mcp-toolkit.nuxt.dev/handlers/overview).

<callout color="info" icon="i-lucide-info">

`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.

</callout>

### Per-host adaptation

Use `hostContext` to opt into host-specific affordances:

```ts
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.

<callout color="info" icon="i-lucide-info">

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.

</callout>

## 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.
- **Import only from your own source and from packages.** Relative imports and Nuxt's path aliases into the project (`~`, `~~`, `#shared`, …) resolve, and every SFC the app imports from the project gets the same auto-imports as the app. Aliases into Nuxt's runtime or build output (`#app`, `#build`, `#imports`) don't, and anything that pulls in the Nuxt runtime won't bundle.
- **Keep payloads small.** When the handler returns no `content`, the toolkit copies `structuredContent` into it as JSON, so the model reads the whole payload. Return a short `content` text alongside large payloads.
- **Style with scoped.** Global styles leak across apps because every app loads its own copy of Vue's style runtime.


## Sitemap

See the full [sitemap](https://mcp-toolkit.nuxt.dev/sitemap.md) for all pages.
