Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .changeset/apps-routing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
---
"@nuxtjs/mcp-toolkit": minor
---

Route MCP Apps to any named handler — no manual filtering required. Until now every `defineMcpApp` SFC was hard-attributed to the implicit `apps` handler, so multiple `app/mcp/*.vue` files could only be exposed together on `/mcp/apps`. Two new mechanisms (consistent with the rest of the module) let you split apps across handlers.

### Sub-folder convention

The first sub-directory under `app/mcp/` becomes the named-handler attribution — same idea as `server/mcp/handlers/<name>/` for tools, resources, and prompts:

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

Pair each sub-folder with its handler index file (one-liner is fine):

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

export default defineMcpHandler({})
```

With `defaultHandlerStrategy: 'orphans'` (the default), each app surfaces on exactly one route.

### Explicit `attachTo` / `group` / `tags` overrides

Three new fields on `defineMcpApp` let an SFC opt out of the folder convention or add filterable metadata. They override any sub-folder default:

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

The generated tool and resource carry `_meta.handler = 'finder'`, top-level `group` and `tags`, so `getMcpTools({ handler: 'finder' })` / `getMcpTools({ tags: ['searchable'] })` filters work the same way they do for ordinary tools.

### Build-time validation

`attachTo`, `group`, and `tags` must be **string literals** (e.g. `'finder'`, `['a', 'b']`). The toolkit reads them statically from the `defineMcpApp` macro at build time so routing decisions are deterministic across dev, build, and deploy. A dynamic expression (`attachTo: someVar`) fails the build with a clear message.

### Back-compat

100% additive — apps without sub-folders or explicit overrides keep their previous behaviour (attached to `apps`, surfaced on `/mcp/apps`). The previous "manual filter inside `defineMcpHandler`" workaround documented in [MCP Apps internals](https://mcp-toolkit.nuxt.dev/advanced/mcp-apps-internals#multiple-handlers) is no longer required.

See [Apps · Authoring → Routing apps to a specific handler](https://mcp-toolkit.nuxt.dev/apps/authoring#routing-apps-to-a-specific-handler).
5 changes: 3 additions & 2 deletions apps/docs/content/6.apps/0.overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ Create a new MCP App in my Nuxt app using @nuxtjs/mcp-toolkit.
- 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
- To route the app to a dedicated handler, either drop the SFC under `app/mcp/<handler>/` or set `attachTo: '<handler>'` (string literal) on `defineMcpApp`. `group` and `tags` are also accepted as literals.
- Make the layout fluid (no fixed heights); hosts often render the iframe inline at variable widths

Docs: https://mcp-toolkit.nuxt.dev/apps/overview
Expand All @@ -87,10 +88,10 @@ Docs: https://mcp-toolkit.nuxt.dev/apps/overview
---
icon: i-lucide-network
title: Multi-handler organization
to: /handlers/organization
to: /apps/authoring#routing-apps-to-a-specific-handler
color: neutral
---
Mount apps on a dedicated MCP route (e.g. `/mcp/apps`) separate from your other tools.
Route apps to dedicated MCP endpoints (e.g. `/mcp/finder`) via sub-folders or `attachTo`.
:::
:::card
---
Expand Down
46 changes: 46 additions & 0 deletions apps/docs/content/6.apps/1.authoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,45 @@ Like tools and resources, `name` and `title` are inferred from the filename:

Override either by passing `name` / `title` to `defineMcpApp`.

### Routing Apps to a Specific Handler

By default, every app is attached to the implicit `apps` handler and only surfaces on `/mcp/apps`. Two ways to route an app to a different named handler:

**1. Sub-folder convention** — the first sub-directory under `app/mcp/` becomes the handler attribution:

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

Pair each handler folder with `server/mcp/handlers/<name>/index.ts`:

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

export default defineMcpHandler({})
```

**2. Explicit `attachTo` override** — overrides the sub-folder default if both are present:

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

The generated tool and resource carry `_meta.handler = 'finder'`, top-level `group = 'stays'`, and `tags = ['searchable']`. Filter on them with `getMcpTools({ handler: 'finder' })`, `getMcpTools({ tags: ['searchable'] })`, etc.

## `defineMcpApp`

A macro — like `definePageMeta` — extracted at build time and **stripped from the browser bundle**. The fields it accepts:
Expand All @@ -110,10 +149,17 @@ defineMcpApp({
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
attachTo?: string // Named MCP handler this app routes to (default: 'apps' or sub-folder)
group?: string // Top-level group label (default: same as attachTo)
tags?: string[] // Top-level tags forwarded to the generated tool
_meta?: Record<string, unknown> // Extra _meta fields surfaced to the host
})
```

::callout{icon="i-lucide-info" color="info"}
`attachTo`, `group`, and `tags` must be **literals** (`'finder'`, `['a', 'b']`) — the toolkit reads them statically at build time to route the generated tool and resource. A dynamic expression (`attachTo: someVar`) fails the build with a clear error.
::

### 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`:
Expand Down
44 changes: 33 additions & 11 deletions apps/docs/content/7.advanced/11.mcp-apps-internals.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,23 +148,45 @@ A regular Nuxt page, an external client, or the MCP App handler all hit `/api/pa

### Multiple handlers

Isolate apps from your other tools by giving them their own MCP endpoint:
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:

```ts [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'
**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)
```

const isAppDef = (def: { _meta?: Record<string, unknown> }) => def._meta?.group === 'apps'
**Explicit override** — `attachTo` (plus `group` / `tags`) on `defineMcpApp` win over the folder default:

export default defineMcpHandler({
route: '/mcp/apps',
tools: allTools.filter(isAppDef),
resources: allResources.filter(isAppDef),
```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({})
```

Connect the host to `https://your-app/mcp/apps` to expose **only** the apps surface, separate from your back-office tools. See [Handlers](/handlers/overview).
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](/handlers/overview).

::callout{icon="i-lucide-info" color="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.
::

### Per-host adaptation

Expand Down
38 changes: 35 additions & 3 deletions apps/docs/skills/manage-mcp/references/apps.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,15 @@ MCP Apps live in **`app/mcp/`** (not `server/mcp/`). They sit on the client side
```bash
app/
└── mcp/
├── color-picker.vue # → tool: color-picker, resource: ui://mcp-app/color-picker
└── admin/
└── audit-log.vue # → tool: audit-log
├── color-picker.vue # → tool: color-picker, mounted on /mcp/apps
├── finder/
│ └── stay-finder.vue # → tool: stay-finder, mounted on /mcp/finder
└── checkout/
└── stay-checkout.vue # → tool: stay-checkout, mounted on /mcp/checkout
```

The first sub-directory under `app/mcp/` becomes the **named-handler attribution**. SFCs sitting directly under `app/mcp/` go to the implicit `apps` handler. Override per-app with `attachTo`.

Override the directory via `mcp.appsDir` in `nuxt.config.ts`. The MCP Apps pipeline only runs when the directory exists — fully tree-shakable when unused.

## Quick Start
Expand Down Expand Up @@ -83,12 +87,40 @@ defineMcpApp({
inputSchema?: ZodRawShape // Validates tool input on the server
handler?: (args, extra) => Result // Server-side; defaults to (args) => ({ structuredContent: args })
csp?: McpAppCsp | false // Tighten or disable iframe CSP
attachTo?: string // Named MCP handler to mount on (default: 'apps' or sub-folder)
group?: string // Top-level group label (default: same as attachTo)
tags?: string[] // Top-level tags forwarded to the generated tool
_meta?: Record<string, unknown> // Extra _meta surfaced to the host
})
```

If `handler` is omitted, the toolkit defaults to `(args) => ({ structuredContent: args })` — useful for stateless apps that just echo the input.

**`attachTo`, `group`, and `tags` must be string literals** (`'finder'`, `['a', 'b']`). The toolkit reads them statically at build time to route the generated tool and resource. Dynamic expressions (`attachTo: someVar`) fail the build with a clear error.

### Routing apps to a dedicated handler

```vue [app/mcp/finder/stay-finder.vue]
<script setup lang="ts">
defineMcpApp({
attachTo: 'finder', // explicit override (sub-folder default would also be 'finder' here)
group: 'stays',
tags: ['searchable'],
// ...
})
</script>
```

Then add the handler index file:

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

export default defineMcpHandler({})
```

The app now only surfaces on `/mcp/finder` (with `defaultHandlerStrategy: 'orphans'`). Filter further with `getMcpTools({ handler: 'finder' })`, `getMcpTools({ tags: ['searchable'] })`, etc.

## `useMcpApp<T>()` Bridge

Auto-imported into every MCP App SFC. Returns the iframe ↔ host bridge:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,32 @@ export interface McpAppOptions<
csp?: McpAppCsp | false
/** Free-form `_meta`. `ui.resourceUri` and `ui.csp` are auto-injected. */
_meta?: Record<string, unknown>
/**
* Named MCP handler this app is attached to. Surfaces on the generated tool
* and resource as `_meta.handler` so they only show up on the matching
* `/mcp/<name>` route (when `defaultHandlerStrategy: 'orphans'`).
*
* Defaults to the first sub-directory of the SFC under `app/mcp/`
* (e.g. `app/mcp/finder/stay-finder.vue` → `'finder'`), or `'apps'` if the
* SFC lives directly under `app/mcp/`.
*
* Must be a string literal (statically extracted at build time).
*/
attachTo?: string
/**
* Functional group label forwarded to `getMcpTools({ group })` and surfaced
* to inspectors. Defaults to the same value as {@link attachTo}.
*
* Must be a string literal (statically extracted at build time).
*/
group?: string
/**
* Free-form tags forwarded to the generated tool, used by
* `getMcpTools({ tags })` filters and inspector UIs.
*
* Must be an array of string literals (statically extracted at build time).
*/
tags?: string[]
}

/** Output of {@link defineMcpApp}: an opaque options bag the build-time loader pairs with the bundled HTML. */
Expand Down Expand Up @@ -234,6 +260,7 @@ export function _createAppTool(
const resourceUri = buildAppResourceUri(ctx.name)
const userMeta = app._meta ?? {}
const sharedMeta = buildAppMeta(app, resourceUri)
const attributionMeta = app.attachTo ? { handler: app.attachTo } : {}

const wrapped: McpToolCallback = async (args, extra) => {
const handlerArgs = (args ?? {}) as Record<string, unknown>
Expand All @@ -257,12 +284,12 @@ export function _createAppTool(
uri: resourceUri,
mimeType: MCP_APP_MIME_TYPE,
text: html,
_meta: sharedMeta,
_meta: { ...sharedMeta, ...attributionMeta },
},
},
...(normalised.content ?? []),
],
_meta: { ...(normalised._meta ?? {}), ...sharedMeta },
_meta: { ...(normalised._meta ?? {}), ...sharedMeta, ...attributionMeta },
}
}
catch (err) {
Expand All @@ -281,7 +308,9 @@ export function _createAppTool(
inputSchema: app.inputSchema,
annotations: app.annotations,
handler: wrapped,
_meta: { ...userMeta, ...sharedMeta },
...(app.group ? { group: app.group } : {}),
...(app.tags ? { tags: app.tags } : {}),
_meta: { ...userMeta, ...sharedMeta, ...attributionMeta },
}
}

Expand All @@ -297,20 +326,23 @@ export function _createAppResource(
const resourceUri = buildAppResourceUri(ctx.name)
const html = prepareAppHtml(ctx.html, app)
const sharedMeta = buildAppMeta(app, resourceUri)
const attributionMeta = app.attachTo ? { handler: app.attachTo } : {}

return {
name: `${ctx.name}-app`,
title: app.title ?? app.name ?? ctx.name,
description: app.description,
uri: resourceUri,
metadata: { mimeType: MCP_APP_MIME_TYPE },
_meta: sharedMeta,
...(app.group ? { group: app.group } : {}),
...(app.tags ? { tags: app.tags } : {}),
_meta: { ...sharedMeta, ...attributionMeta },
handler: async (uri: URL) => ({
contents: [{
uri: uri.toString(),
mimeType: MCP_APP_MIME_TYPE,
text: html,
_meta: sharedMeta,
_meta: { ...sharedMeta, ...attributionMeta },
}],
}),
}
Expand Down
Loading
Loading