From 1403282a5cd47608d7d927bb5600d864463a34ec Mon Sep 17 00:00:00 2001 From: Hugo Richard Date: Wed, 6 May 2026 11:22:55 +0100 Subject: [PATCH] feat(module): add MCP Apps to named handlers via sub-folders & attachTo --- .changeset/apps-routing.md | 55 +++++ apps/docs/content/6.apps/0.overview.md | 5 +- apps/docs/content/6.apps/1.authoring.md | 46 ++++ .../7.advanced/11.mcp-apps-internals.md | 44 +++- .../docs/skills/manage-mcp/references/apps.md | 38 ++- .../runtime/server/mcp/definitions/apps.ts | 42 +++- .../src/setup/mcp-apps/discover.ts | 40 ++- .../src/setup/mcp-apps/emit.ts | 19 +- .../src/setup/mcp-apps/index.ts | 47 +++- .../src/setup/mcp-apps/parse-sfc.ts | 229 +++++++++++++++++- .../test/apps-routing.test.ts | 117 +++++++++ .../test/discover-apps.test.ts | 34 +++ .../nuxt-mcp-toolkit/test/parse-sfc.test.ts | 126 +++++++++- 13 files changed, 803 insertions(+), 39 deletions(-) create mode 100644 .changeset/apps-routing.md create mode 100644 packages/nuxt-mcp-toolkit/test/apps-routing.test.ts create mode 100644 packages/nuxt-mcp-toolkit/test/discover-apps.test.ts diff --git a/.changeset/apps-routing.md b/.changeset/apps-routing.md new file mode 100644 index 00000000..b090acfe --- /dev/null +++ b/.changeset/apps-routing.md @@ -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//` 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] + +``` + +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). diff --git a/apps/docs/content/6.apps/0.overview.md b/apps/docs/content/6.apps/0.overview.md index df519847..3c953637 100644 --- a/apps/docs/content/6.apps/0.overview.md +++ b/apps/docs/content/6.apps/0.overview.md @@ -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//` or set `attachTo: ''` (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 @@ -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 --- diff --git a/apps/docs/content/6.apps/1.authoring.md b/apps/docs/content/6.apps/1.authoring.md index 192b4a97..b073a666 100644 --- a/apps/docs/content/6.apps/1.authoring.md +++ b/apps/docs/content/6.apps/1.authoring.md @@ -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//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] + +``` + +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: @@ -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 // 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`: diff --git a/apps/docs/content/7.advanced/11.mcp-apps-internals.md b/apps/docs/content/7.advanced/11.mcp-apps-internals.md index d72e09a3..c08f18d8 100644 --- a/apps/docs/content/7.advanced/11.mcp-apps-internals.md +++ b/apps/docs/content/7.advanced/11.mcp-apps-internals.md @@ -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//`. 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 }) => 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] + +``` + +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 diff --git a/apps/docs/skills/manage-mcp/references/apps.md b/apps/docs/skills/manage-mcp/references/apps.md index b3cfbdc0..0ee5ab69 100644 --- a/apps/docs/skills/manage-mcp/references/apps.md +++ b/apps/docs/skills/manage-mcp/references/apps.md @@ -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 @@ -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 // 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] + +``` + +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()` Bridge Auto-imported into every MCP App SFC. Returns the iframe ↔ host bridge: diff --git a/packages/nuxt-mcp-toolkit/src/runtime/server/mcp/definitions/apps.ts b/packages/nuxt-mcp-toolkit/src/runtime/server/mcp/definitions/apps.ts index 2a5ab18b..8997038f 100644 --- a/packages/nuxt-mcp-toolkit/src/runtime/server/mcp/definitions/apps.ts +++ b/packages/nuxt-mcp-toolkit/src/runtime/server/mcp/definitions/apps.ts @@ -188,6 +188,32 @@ export interface McpAppOptions< csp?: McpAppCsp | false /** Free-form `_meta`. `ui.resourceUri` and `ui.csp` are auto-injected. */ _meta?: Record + /** + * 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/` 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. */ @@ -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 @@ -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) { @@ -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 }, } } @@ -297,6 +326,7 @@ 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`, @@ -304,13 +334,15 @@ export function _createAppResource( 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 }, }], }), } diff --git a/packages/nuxt-mcp-toolkit/src/setup/mcp-apps/discover.ts b/packages/nuxt-mcp-toolkit/src/setup/mcp-apps/discover.ts index 4371b475..cdc8933f 100644 --- a/packages/nuxt-mcp-toolkit/src/setup/mcp-apps/discover.ts +++ b/packages/nuxt-mcp-toolkit/src/setup/mcp-apps/discover.ts @@ -1,5 +1,5 @@ import { existsSync } from 'node:fs' -import { resolve as resolvePath, basename, sep } from 'node:path' +import { resolve as resolvePath, basename, sep, relative as relativePath } from 'node:path' import { getLayerDirectories } from '@nuxt/kit' import { glob } from 'tinyglobby' import type { ConsolaInstance } from 'consola' @@ -9,6 +9,15 @@ export interface DiscoveredApp { name: string /** Absolute path to the source `.vue` SFC. */ sfc: string + /** + * First sub-directory between the `app/mcp/` root and the SFC, used as the + * default named-handler attribution and group when the macro doesn't + * specify `attachTo` / `group` explicitly. + * + * `undefined` when the SFC lives directly under `app/mcp/` (default + * attribution `'apps'`). + */ + inferredAttribution?: string } /** Mirrors {@link assertSafeAppName} on the runtime side — keep them in sync. */ @@ -25,6 +34,21 @@ export function sfcToAppName(sfcPath: string): string { .toLowerCase() } +/** + * Pull the first sub-directory between the apps root and the SFC, if any. + * Returns `undefined` for SFCs sitting directly under `/`. + * + * `app/mcp/finder/stay-finder.vue` → `'finder'` + * `app/mcp/finder/admin/audit-log.vue` → `'finder'` (only first level) + * `app/mcp/color-picker.vue` → `undefined` + */ +export function inferAttribution(sfcPath: string, appsRoot: string): string | undefined { + const rel = normalize(relativePath(appsRoot, sfcPath)) + if (!rel || rel.startsWith('..')) return undefined + const segments = rel.split('/') + return segments.length > 1 ? segments[0] : undefined +} + /** Cheap existence check: any layer carries `//`. */ export function probeAppsDir(appsDir: string): boolean { for (const layer of getLayerDirectories()) { @@ -40,6 +64,8 @@ export async function discoverApps(appsDir: string, log?: ConsolaInstance): Prom const skipped: string[] = [] const candidates = new Map() + const unsafeAttributions: string[] = [] + for (const layer of [...layers].reverse()) { const root = normalize(resolvePath(layer.app, appsDir)) const pattern = `${root}/**/*.vue` @@ -53,10 +79,15 @@ export async function discoverApps(appsDir: string, log?: ConsolaInstance): Prom skipped.push(`${file} → ${JSON.stringify(name)}`) continue } + const inferredAttribution = inferAttribution(normalised, root) + if (inferredAttribution !== undefined && !SAFE_APP_NAME.test(inferredAttribution)) { + unsafeAttributions.push(`${file} → directory ${JSON.stringify(inferredAttribution)}`) + continue + } const list = candidates.get(name) ?? [] list.push(file) candidates.set(name, list) - seen.set(name, { name, sfc: file }) + seen.set(name, { name, sfc: file, inferredAttribution }) } } @@ -65,6 +96,11 @@ export async function discoverApps(appsDir: string, log?: ConsolaInstance): Prom `MCP App SFCs with unsafe names (must match ${SAFE_APP_NAME}): \n - ${skipped.join('\n - ')}`, ) } + if (unsafeAttributions.length) { + throw new Error( + `MCP App sub-directories must match ${SAFE_APP_NAME} (used as the named handler attribution): \n - ${unsafeAttributions.join('\n - ')}`, + ) + } for (const [name, files] of candidates) { if (files.length < 2) continue const winner = seen.get(name)!.sfc diff --git a/packages/nuxt-mcp-toolkit/src/setup/mcp-apps/emit.ts b/packages/nuxt-mcp-toolkit/src/setup/mcp-apps/emit.ts index 0f22f90b..2bba0754 100644 --- a/packages/nuxt-mcp-toolkit/src/setup/mcp-apps/emit.ts +++ b/packages/nuxt-mcp-toolkit/src/setup/mcp-apps/emit.ts @@ -3,6 +3,13 @@ import type { Resolver } from '@nuxt/kit' import type { DiscoveredApp } from './discover' import type { ParsedSfcApp } from './parse-sfc' +export interface ResolvedAttribution { + /** Final `attachTo` value (explicit override > sub-folder > `'apps'`). */ + attachTo: string + /** Final `group` value (explicit override > resolved `attachTo`). */ + group: string +} + /** * Emit the three TS modules backing one MCP App: * `.app.ts` — `defineMcpApp({ ...args })` @@ -15,6 +22,7 @@ export function emitAppModules( app: DiscoveredApp, parsed: ParsedSfcApp, bundledHtml: string, + attribution: ResolvedAttribution, resolver: Resolver, ): { toolFile: string, resourceFile: string } { // Absolute paths sidestep the `@nuxtjs/mcp-toolkit/server` subpath import: @@ -25,16 +33,21 @@ export function emitAppModules( const html64 = JSON.stringify(Buffer.from(bundledHtml, 'utf-8').toString('base64')) const argText = stripTypeScriptFromMacroArg(parsed.argText) + // Inject resolved `attachTo` / `group` as DEFAULTS — the user's literal + // override (already statically extracted) wins via the spread that follows. + const defaultsBlock = `attachTo: ${JSON.stringify(attribution.attachTo)},\n group: ${JSON.stringify(attribution.group)},\n ` + const mergedArgs = `{\n ${defaultsBlock}...(${argText}),\n}` + const appFileBody = `import { defineMcpApp } from ${appsModule} ${importsBlock} -export default defineMcpApp(${argText}) +export default defineMcpApp(${mergedArgs}) ` const toolFileBody = `import { defineMcpApp, _createAppTool } from ${appsModule} ${importsBlock} const __HTML = Buffer.from(${html64}, 'base64').toString('utf-8') -const _app = defineMcpApp(${argText}) +const _app = defineMcpApp(${mergedArgs}) export default _createAppTool(_app, { name: ${JSON.stringify(app.name)}, html: __HTML }) ` @@ -43,7 +56,7 @@ export default _createAppTool(_app, { name: ${JSON.stringify(app.name)}, html: _ ${importsBlock} const __HTML = Buffer.from(${html64}, 'base64').toString('utf-8') -const _app = defineMcpApp(${argText}) +const _app = defineMcpApp(${mergedArgs}) export default _createAppResource(_app, { name: ${JSON.stringify(app.name)}, html: __HTML }) ` diff --git a/packages/nuxt-mcp-toolkit/src/setup/mcp-apps/index.ts b/packages/nuxt-mcp-toolkit/src/setup/mcp-apps/index.ts index 0692453a..74888e8f 100644 --- a/packages/nuxt-mcp-toolkit/src/setup/mcp-apps/index.ts +++ b/packages/nuxt-mcp-toolkit/src/setup/mcp-apps/index.ts @@ -5,9 +5,9 @@ import type { Nuxt } from '@nuxt/schema' import type { ConsolaInstance } from 'consola' import type { LoadedFile } from '../../runtime/server/mcp/loaders/utils' import { type DiscoveredApp, discoverApps } from './discover' -import { parseSfcApp } from './parse-sfc' +import { parseSfcApp, type McpAppStaticFields } from './parse-sfc' import { bundleAppHtml } from './bundle' -import { emitAppModules } from './emit' +import { emitAppModules, type ResolvedAttribution } from './emit' export type { DiscoveredApp } from './discover' export { probeAppsDir } from './discover' @@ -15,6 +15,7 @@ export { probeAppsDir } from './discover' export interface BuiltApp extends DiscoveredApp { toolFile: string resourceFile: string + attribution: ResolvedAttribution } export interface McpAppsResult { @@ -25,6 +26,25 @@ export interface McpAppsResult { const APPS_DIR_DEFAULT = 'mcp' const APPS_OUT_DIR = 'mcp-apps' +const DEFAULT_ATTRIBUTION = 'apps' + +/** + * Resolve the named-handler attribution for one app. Precedence: + * 1. `defineMcpApp({ attachTo: '...' })` — explicit literal override. + * 2. First sub-directory of the SFC under `app/mcp/` (e.g. `finder`). + * 3. The default `'apps'` handler. + * + * `group` follows the same chain, falling back to the resolved `attachTo` + * so apps surfaced on the same handler share a default group label. + */ +function resolveAttribution( + inferred: string | undefined, + staticFields: McpAppStaticFields, +): ResolvedAttribution { + const attachTo = staticFields.attachTo ?? inferred ?? DEFAULT_ATTRIBUTION + const group = staticFields.group ?? attachTo + return { attachTo, group } +} /** * Discover, bundle, and emit all SFC-based MCP Apps. Designed to run inside @@ -52,9 +72,10 @@ export async function setupMcpApps( for (const app of apps) { try { const parsed = await parseSfcApp(app.sfc) + const attribution = resolveAttribution(app.inferredAttribution, parsed.staticFields) const html = await bundleAppHtml(app, parsed.bundleSource, buildRoot, resolver, log) - const { toolFile, resourceFile } = emitAppModules(app, parsed, html, resolver) - built.push({ ...app, toolFile, resourceFile }) + const { toolFile, resourceFile } = emitAppModules(app, parsed, html, attribution, resolver) + built.push({ ...app, toolFile, resourceFile, attribution }) } catch (error) { const message = error instanceof Error ? error.message : String(error) @@ -65,11 +86,19 @@ export async function setupMcpApps( log.success(`Built ${built.length} MCP app${built.length === 1 ? '' : 's'} from .vue SFCs`) - // Attribute every app-derived tool and resource to the implicit `apps` handler - // (folder convention equivalent). With `defaultHandlerStrategy: 'orphans'` this - // automatically excludes them from `/mcp` so they only surface on `/mcp/apps`. - const toolFiles: LoadedFile[] = built.map(a => ({ path: a.toolFile, group: 'apps', handler: 'apps' })) - const resourceFiles: LoadedFile[] = built.map(a => ({ path: a.resourceFile, group: 'apps', handler: 'apps' })) + // Attribute every app-derived tool and resource to its resolved handler. + // With `defaultHandlerStrategy: 'orphans'` this automatically excludes them + // from the default `/mcp` route, so they only surface on `/mcp/`. + const toolFiles: LoadedFile[] = built.map(a => ({ + path: a.toolFile, + group: a.attribution.group, + handler: a.attribution.attachTo, + })) + const resourceFiles: LoadedFile[] = built.map(a => ({ + path: a.resourceFile, + group: a.attribution.group, + handler: a.attribution.attachTo, + })) return { apps: built, toolFiles, resourceFiles } } diff --git a/packages/nuxt-mcp-toolkit/src/setup/mcp-apps/parse-sfc.ts b/packages/nuxt-mcp-toolkit/src/setup/mcp-apps/parse-sfc.ts index 89beb56c..b2de0c0a 100644 --- a/packages/nuxt-mcp-toolkit/src/setup/mcp-apps/parse-sfc.ts +++ b/packages/nuxt-mcp-toolkit/src/setup/mcp-apps/parse-sfc.ts @@ -15,6 +15,19 @@ export interface ParsedSfcApp { imports: string[] /** SFC source with the macro call replaced by `void 0` for the browser bundle. */ bundleSource: string + /** Statically-extractable fields used by the build-time loader for routing. */ + staticFields: McpAppStaticFields +} + +/** + * Subset of {@link McpAppOptions} fields we extract statically from the SFC at + * build time. These drive named-handler attribution (`_meta.handler`), the + * `group` filter, and `tags` filters on the loader-side `LoadedFile` entry. + */ +export interface McpAppStaticFields { + attachTo?: string + group?: string + tags?: string[] } interface MacroCall { @@ -165,6 +178,214 @@ function injectBundleAutoImports(source: string, scriptOffset: number | null, ex return `${source.slice(0, scriptOffset)}\n${block}${source.slice(scriptOffset)}` } +interface TopLevelField { + key: string + valueText: string +} + +/** + * Walk a single object literal (`{ ... }`) and yield its top-level + * `key: value` pairs. Skips strings, comments, and nested + * braces/brackets/parens correctly. + * + * Returns an empty list for non-object inputs (`undefined`, `null`, + * `someExpr`, …) so callers can treat those as "no extractable fields". + * + * Exported for tests. + * + * @internal + */ +export function walkTopLevelObjectFields(argText: string): TopLevelField[] { + const text = argText.trim() + if (!text.startsWith('{') || !text.endsWith('}')) return [] + + const fields: TopLevelField[] = [] + let i = 1 + const end = text.length - 1 + + while (i < end) { + while (i < end && /[\s,;]/.test(text[i]!)) i++ + if (i >= end) break + if (text[i] === '/' && text[i + 1] === '/') { + const eol = text.indexOf('\n', i) + i = eol === -1 ? end : eol + 1 + continue + } + if (text[i] === '/' && text[i + 1] === '*') { + const close = text.indexOf('*/', i + 2) + i = close === -1 ? end : close + 2 + continue + } + + let key = '' + if (text[i] === '"' || text[i] === '\'') { + const quote = text[i]! + let j = i + 1 + while (j < end && text[j] !== quote) { + if (text[j] === '\\') j++ + j++ + } + key = text.slice(i + 1, j) + i = j + 1 + } + else if (/[a-z_$]/i.test(text[i]!)) { + let j = i + while (j < end && /[\w$]/.test(text[j]!)) j++ + key = text.slice(i, j) + i = j + } + else { + i = skipToNextTopLevelComma(text, i, end) + continue + } + + while (i < end && /\s/.test(text[i]!)) i++ + if (text[i] !== ':') { + i = skipToNextTopLevelComma(text, i, end) + continue + } + i++ + while (i < end && /\s/.test(text[i]!)) i++ + + const valueStart = i + const valueEnd = skipToNextTopLevelComma(text, i, end) + fields.push({ key, valueText: text.slice(valueStart, valueEnd).trim() }) + i = valueEnd + if (text[i] === ',') i++ + } + + return fields +} + +/** Advance past the current top-level value to the next comma or closing brace. */ +function skipToNextTopLevelComma(text: string, start: number, end: number): number { + let i = start + let depth = 0 + let str: string | null = null + while (i < end) { + const c = text[i] + if (str) { + if (c === '\\') { + i += 2 + continue + } + if (c === str) str = null + i++ + continue + } + if (c === '/' && text[i + 1] === '/') { + const eol = text.indexOf('\n', i) + i = eol === -1 ? end : eol + 1 + continue + } + if (c === '/' && text[i + 1] === '*') { + const close = text.indexOf('*/', i + 2) + i = close === -1 ? end : close + 2 + continue + } + if (c === '"' || c === '\'' || c === '`') { + str = c + i++ + continue + } + if (c === '{' || c === '[' || c === '(') depth++ + else if (c === '}' || c === ']' || c === ')') { + if (depth === 0) return i + depth-- + } + if (depth === 0 && c === ',') return i + i++ + } + return end +} + +/** Strip TypeScript trailing assertions like `'foo' as const` or `... satisfies T`. */ +function stripTsTrailingAssertions(value: string): string { + return value + .replace(/\s+as\s+\S[\w.<>[\]|&,\s]*$/, '') + .replace(/\s+satisfies\s+\S[\w.<>[\]|&,\s]*$/, '') + .trim() +} + +function readStringLiteral(value: string): string | undefined { + const trimmed = stripTsTrailingAssertions(value) + const m = /^(['"])((?:\\.|[^\\])*?)\1$/.exec(trimmed) + if (!m) return undefined + return m[2]!.replace(/\\(.)/g, '$1') +} + +function readStringArrayLiteral(value: string): string[] | undefined { + const trimmed = stripTsTrailingAssertions(value) + if (!trimmed.startsWith('[') || !trimmed.endsWith(']')) return undefined + const inner = trimmed.slice(1, -1).trim() + if (inner === '') return [] + const out: string[] = [] + let i = 0 + while (i < inner.length) { + while (i < inner.length && /[\s,]/.test(inner[i]!)) i++ + if (i >= inner.length) break + const quote = inner[i] + if (quote !== '"' && quote !== '\'') return undefined + let j = i + 1 + while (j < inner.length && inner[j] !== quote) { + if (inner[j] === '\\') j++ + j++ + } + if (j >= inner.length) return undefined + out.push(inner.slice(i + 1, j).replace(/\\(.)/g, '$1')) + i = j + 1 + while (i < inner.length && /\s/.test(inner[i]!)) i++ + if (i < inner.length && inner[i] !== ',') return undefined + } + return out +} + +/** + * Statically extract `attachTo`, `group`, and `tags` from a `defineMcpApp` + * argument object. Throws when one of these keys is present but not a + * literal — they drive build-time routing decisions and must be analysable + * without executing the SFC. + * + * Exported for tests. + * + * @internal + */ +export function extractMcpAppStaticFields(argText: string, sfcPath?: string): McpAppStaticFields { + const out: McpAppStaticFields = {} + const fields = walkTopLevelObjectFields(argText) + const where = sfcPath ? ` in ${sfcPath}` : '' + + for (const { key, valueText } of fields) { + if (key === 'attachTo' || key === 'group') { + const lit = readStringLiteral(valueText) + if (lit === undefined) { + throw new Error( + `${MACRO_NAME}({ ${key}: … })${where} must be a string literal ` + + `(got \`${truncate(valueText)}\`). Build-time routing cannot evaluate dynamic expressions.`, + ) + } + out[key] = lit + } + else if (key === 'tags') { + const arr = readStringArrayLiteral(valueText) + if (arr === undefined) { + throw new Error( + `${MACRO_NAME}({ tags: … })${where} must be an array of string literals ` + + `(got \`${truncate(valueText)}\`). Build-time routing cannot evaluate dynamic expressions.`, + ) + } + out.tags = arr + } + } + + return out +} + +function truncate(text: string, max = 60): string { + const flat = text.replace(/\s+/g, ' ').trim() + return flat.length > max ? `${flat.slice(0, max)}…` : flat +} + /** Parse a Vue SFC; extract macro args and emit a bundle source with the macro neutralised. */ export async function parseSfcApp(sfcPath: string): Promise { const { parse } = await import('@vue/compiler-sfc') @@ -175,7 +396,7 @@ export async function parseSfcApp(sfcPath: string): Promise { const scriptBlock = descriptor.scriptSetup ?? descriptor.script if (!scriptBlock) { const bundleSource = absolutiseAllRelativeImports(injectBundleAutoImports(source, null, new Set()), sfcDir) - return { argText: '{}', imports: [], bundleSource } + return { argText: '{}', imports: [], bundleSource, staticFields: {} } } const scriptContent = scriptBlock.content @@ -186,7 +407,7 @@ export async function parseSfcApp(sfcPath: string): Promise { const sourceWithImports = injectBundleAutoImports(source, scriptOffset, existingNames) if (!macro) { - return { argText: '{}', imports: [], bundleSource: absolutiseAllRelativeImports(sourceWithImports, sfcDir) } + return { argText: '{}', imports: [], bundleSource: absolutiseAllRelativeImports(sourceWithImports, sfcDir), staticFields: {} } } if (findMacroCall(scriptContent.slice(macro.end), MACRO_NAME)) { throw new Error(`Multiple ${MACRO_NAME}() calls found in ${sfcPath}. MCP App SFCs support exactly one app definition.`) @@ -197,6 +418,8 @@ export async function parseSfcApp(sfcPath: string): Promise { dirname(sfcPath), ) + const staticFields = extractMcpAppStaticFields(macro.argText, sfcPath) + const offsetShift = sourceWithImports.length - source.length const macroStart = scriptOffset + macro.start + offsetShift const macroEnd = scriptOffset + macro.end + offsetShift @@ -205,5 +428,5 @@ export async function parseSfcApp(sfcPath: string): Promise { sfcDir, ) - return { argText: macro.argText, imports, bundleSource } + return { argText: macro.argText, imports, bundleSource, staticFields } } diff --git a/packages/nuxt-mcp-toolkit/test/apps-routing.test.ts b/packages/nuxt-mcp-toolkit/test/apps-routing.test.ts new file mode 100644 index 00000000..9f07284a --- /dev/null +++ b/packages/nuxt-mcp-toolkit/test/apps-routing.test.ts @@ -0,0 +1,117 @@ +// Unit tests for the per-app routing surface (`attachTo` / `group` / `tags`). +// E2E coverage that wires this into `defineMcpHandler` lives alongside the +// other handler-organization fixtures, but the runtime contract — that the +// generated tool + resource carry the right `_meta.handler`, top-level `group`, +// and `tags` — is what callers ultimately filter on, and we lock that down +// here without paying the cost of a full Vite bundle. +import { describe, it, expect } from 'vitest' +import { + defineMcpApp, + _createAppTool, + _createAppResource, +} from '../src/runtime/server/mcp/definitions/apps' + +const ctx = { name: 'demo', html: '' } + +const callTool = (tool: ReturnType, args: unknown = {}) => + tool.handler(args as Record, {} as never) + +describe('MCP App — attachTo (named-handler attribution)', () => { + it('does not surface `_meta.handler` when no attribution is set', async () => { + const app = defineMcpApp() + const tool = _createAppTool(app, ctx) + + expect(tool._meta?.handler).toBeUndefined() + + const result = await callTool(tool) as { _meta?: Record } + expect(result._meta?.handler).toBeUndefined() + }) + + it('surfaces `attachTo` as `_meta.handler` on the tool definition + each call result', async () => { + const app = defineMcpApp({ attachTo: 'finder' }) + const tool = _createAppTool(app, ctx) + + expect(tool._meta?.handler).toBe('finder') + + const result = await callTool(tool) as { _meta?: Record } + expect(result._meta?.handler).toBe('finder') + }) + + it('surfaces `attachTo` as `_meta.handler` on the resource + its read contents', async () => { + const app = defineMcpApp({ attachTo: 'finder' }) + const resource = _createAppResource(app, ctx) + + expect(resource._meta?.handler).toBe('finder') + + const read = await resource.handler(new URL('ui://mcp-app/demo'), {} as never, {} as never) + const c = read.contents?.[0] as { _meta?: Record } + expect(c?._meta?.handler).toBe('finder') + }) + + it('preserves user `_meta` keys when attribution is added (no clobbering)', async () => { + const app = defineMcpApp({ + attachTo: 'finder', + _meta: { 'openai/widgetAccessible': false, 'custom': 'value' }, + }) + const tool = _createAppTool(app, ctx) + + expect(tool._meta?.handler).toBe('finder') + expect(tool._meta?.custom).toBe('value') + }) +}) + +describe('MCP App — group (top-level + filterable)', () => { + it('forwards `group` to the tool definition as a top-level field', () => { + const app = defineMcpApp({ group: 'stays' }) + const tool = _createAppTool(app, ctx) + expect(tool.group).toBe('stays') + }) + + it('forwards `group` to the resource definition as a top-level field', () => { + const app = defineMcpApp({ group: 'stays' }) + const resource = _createAppResource(app, ctx) + expect(resource.group).toBe('stays') + }) + + it('omits `group` entirely when not provided (keeps existing summaries clean)', () => { + const tool = _createAppTool(defineMcpApp(), ctx) + expect(tool.group).toBeUndefined() + }) +}) + +describe('MCP App — tags (top-level + filterable)', () => { + it('forwards `tags` to the tool definition as a top-level field', () => { + const app = defineMcpApp({ tags: ['searchable', 'demo'] }) + const tool = _createAppTool(app, ctx) + expect(tool.tags).toEqual(['searchable', 'demo']) + }) + + it('forwards `tags` to the resource definition as a top-level field', () => { + const app = defineMcpApp({ tags: ['searchable'] }) + const resource = _createAppResource(app, ctx) + expect(resource.tags).toEqual(['searchable']) + }) +}) + +describe('MCP App — combined routing surface', () => { + it('lets a single app declare attachTo + group + tags simultaneously', async () => { + const app = defineMcpApp({ + attachTo: 'finder', + group: 'stays', + tags: ['searchable', 'public'], + }) + const tool = _createAppTool(app, ctx) + const resource = _createAppResource(app, ctx) + + expect(tool._meta?.handler).toBe('finder') + expect(tool.group).toBe('stays') + expect(tool.tags).toEqual(['searchable', 'public']) + + expect(resource._meta?.handler).toBe('finder') + expect(resource.group).toBe('stays') + expect(resource.tags).toEqual(['searchable', 'public']) + + const result = await callTool(tool) as { _meta?: Record } + expect(result._meta?.handler).toBe('finder') + }) +}) diff --git a/packages/nuxt-mcp-toolkit/test/discover-apps.test.ts b/packages/nuxt-mcp-toolkit/test/discover-apps.test.ts new file mode 100644 index 00000000..2fcea0fd --- /dev/null +++ b/packages/nuxt-mcp-toolkit/test/discover-apps.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest' +import { inferAttribution, sfcToAppName } from '../src/setup/mcp-apps/discover' + +describe('inferAttribution', () => { + const root = '/proj/app/mcp' + + it('returns the first sub-directory between the apps root and the SFC', () => { + expect(inferAttribution('/proj/app/mcp/finder/stay-finder.vue', root)).toBe('finder') + }) + + it('keeps only the first sub-directory level (deeper sub-folders ignored)', () => { + expect(inferAttribution('/proj/app/mcp/finder/admin/audit-log.vue', root)).toBe('finder') + }) + + it('returns undefined for SFCs sitting directly under the apps root', () => { + expect(inferAttribution('/proj/app/mcp/color-picker.vue', root)).toBeUndefined() + }) + + it('returns undefined for paths outside the apps root', () => { + expect(inferAttribution('/elsewhere/foo.vue', root)).toBeUndefined() + }) +}) + +describe('sfcToAppName', () => { + it('kebab-cases the basename', () => { + expect(sfcToAppName('/proj/app/mcp/StayFinder.vue')).toBe('stay-finder') + expect(sfcToAppName('/proj/app/mcp/color_picker.vue')).toBe('color-picker') + expect(sfcToAppName('/proj/app/mcp/audit-log.vue')).toBe('audit-log') + }) + + it('only uses the basename (sub-folders do not affect the name)', () => { + expect(sfcToAppName('/proj/app/mcp/finder/stay-finder.vue')).toBe('stay-finder') + }) +}) diff --git a/packages/nuxt-mcp-toolkit/test/parse-sfc.test.ts b/packages/nuxt-mcp-toolkit/test/parse-sfc.test.ts index 50675cfa..edb012c7 100644 --- a/packages/nuxt-mcp-toolkit/test/parse-sfc.test.ts +++ b/packages/nuxt-mcp-toolkit/test/parse-sfc.test.ts @@ -2,7 +2,12 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, it, expect } from 'vitest' -import { absolutiseRelativeImports, parseSfcApp } from '../src/setup/mcp-apps/parse-sfc' +import { + absolutiseRelativeImports, + extractMcpAppStaticFields, + parseSfcApp, + walkTopLevelObjectFields, +} from '../src/setup/mcp-apps/parse-sfc' describe('absolutiseRelativeImports', () => { // Regression for "Could not resolve './stay-finder.data' from @@ -55,4 +60,123 @@ defineMcpApp({ description: 'second' }) await rm(dir, { recursive: true, force: true }) } }) + + it('extracts attachTo / group / tags from the macro', async () => { + const dir = await mkdtemp(join(tmpdir(), 'mcp-app-')) + try { + const file = join(dir, 'finder.vue') + await writeFile(file, ` + +`, 'utf-8') + + const parsed = await parseSfcApp(file) + expect(parsed.staticFields).toEqual({ + attachTo: 'finder', + group: 'stays', + tags: ['searchable', 'demo'], + }) + } + finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + it('throws when attachTo is a dynamic expression', async () => { + const dir = await mkdtemp(join(tmpdir(), 'mcp-app-')) + try { + const file = join(dir, 'dynamic.vue') + await writeFile(file, ` + +`, 'utf-8') + + await expect(parseSfcApp(file)).rejects.toThrow(/attachTo: … .* must be a string literal/) + } + finally { + await rm(dir, { recursive: true, force: true }) + } + }) +}) + +describe('walkTopLevelObjectFields', () => { + it('returns top-level entries only, skipping nested braces and strings', () => { + const fields = walkTopLevelObjectFields(`{ + name: 'foo', + _meta: { handler: 'inner' }, + tags: ['a', 'b'], + handler: () => 'noop', + }`) + expect(fields.map(f => f.key)).toEqual(['name', '_meta', 'tags', 'handler']) + expect(fields[3]?.valueText).toBe('() => \'noop\'') + }) + + it('handles single-line comments and string literals containing commas', () => { + const fields = walkTopLevelObjectFields(`{ + // a comment + desc: 'hello, world', + group: "stays", + }`) + expect(fields).toEqual([ + { key: 'desc', valueText: '\'hello, world\'' }, + { key: 'group', valueText: '"stays"' }, + ]) + }) + + it('returns [] for non-object inputs (defensive)', () => { + expect(walkTopLevelObjectFields('undefined')).toEqual([]) + expect(walkTopLevelObjectFields('someExpr()')).toEqual([]) + }) +}) + +describe('extractMcpAppStaticFields', () => { + it('returns empty object when none of the routing fields are present', () => { + expect(extractMcpAppStaticFields(`{ description: 'foo' }`)).toEqual({}) + }) + + it('extracts string literals (single + double quotes)', () => { + expect(extractMcpAppStaticFields(`{ attachTo: 'finder', group: "stays" }`)).toEqual({ + attachTo: 'finder', + group: 'stays', + }) + }) + + it('strips trailing TypeScript assertions', () => { + expect(extractMcpAppStaticFields(`{ attachTo: 'finder' as const }`)).toEqual({ + attachTo: 'finder', + }) + }) + + it('extracts string array literals for tags', () => { + expect(extractMcpAppStaticFields(`{ tags: ['searchable', "demo"] }`)).toEqual({ + tags: ['searchable', 'demo'], + }) + }) + + it('throws on dynamic attachTo (variable reference)', () => { + expect(() => extractMcpAppStaticFields(`{ attachTo: someVar }`)) + .toThrow(/attachTo: ….*must be a string literal/) + }) + + it('throws on dynamic group (template literal)', () => { + expect(() => extractMcpAppStaticFields(`{ group: \`stays-\${id}\` }`)) + .toThrow(/group: ….*must be a string literal/) + }) + + it('throws on tags that contain non-literal entries', () => { + expect(() => extractMcpAppStaticFields(`{ tags: ['ok', someTag] }`)) + .toThrow(/tags: ….*must be an array of string literals/) + }) + + it('ignores routing fields nested inside _meta (only top-level matters)', () => { + expect(extractMcpAppStaticFields(`{ _meta: { attachTo: 'finder' } }`)).toEqual({}) + }) })