diff --git a/.changeset/nitro-runtime-hooks.md b/.changeset/nitro-runtime-hooks.md new file mode 100644 index 0000000..33252c4 --- /dev/null +++ b/.changeset/nitro-runtime-hooks.md @@ -0,0 +1,48 @@ +--- +"@nuxtjs/mcp-toolkit": minor +--- + +Expose two Nitro runtime hooks for the MCP request lifecycle. Subscribe from a `server/plugins/*.ts` plugin to inject custom logic without owning a `defineMcpHandler` — listeners that throw are logged via consola and the request continues. + +``` +defineMcpHandler middleware → mcp:config:resolved → createMcpServer → mcp:server:created → transport +``` + +### `mcp:config:resolved` + +Fires per request after dynamic `tools` / `resources` / `prompts` resolvers and `enabled(event)` guards have run, **before** the per-request `McpServer` is built. Mutate `ctx.config` in place to add, remove or transform definitions for this request only. + +```ts [server/plugins/mcp-filter.ts] +export default defineNitroPlugin((nitroApp) => { + nitroApp.hooks.hook('mcp:config:resolved', ({ config, event }) => { + if (!event.context.user) { + config.tools = config.tools.filter(t => !t.tags?.includes('admin')) + } + }) +}) +``` + +### `mcp:server:created` + +Fires per request after every tool / resource / prompt has been registered, **before** the server is connected to the transport. Receives the SDK `McpServer` instance — call `server.registerTool(...)` to add definitions late, or use `getSdkServer(server)` to reach the low-level `Server` and `setRequestHandler(...)` for custom JSON-RPC methods. + +```ts [server/plugins/mcp-whoami.ts] +export default defineNitroPlugin((nitroApp) => { + nitroApp.hooks.hook('mcp:server:created', ({ server, event }) => { + server.registerTool( + 'whoami', + { description: 'Return the current user id' }, + async () => ({ + content: [{ type: 'text', text: String(event.context.userId ?? 'anonymous') }], + }), + ) + }) +}) +``` + +### Public API additions + +- `McpResolvedConfig` — type of the resolved per-request server config. +- `getSdkServer(server)` — reach the underlying SDK `Server` instance from an `McpServer`. + +Both are exported from `@nuxtjs/mcp-toolkit/server`. See [Hooks · Runtime hooks](https://mcp-toolkit.nuxt.dev/advanced/hooks#runtime-hooks). diff --git a/apps/docs/content/7.advanced/4.hooks.md b/apps/docs/content/7.advanced/4.hooks.md index 82980df..b717277 100644 --- a/apps/docs/content/7.advanced/4.hooks.md +++ b/apps/docs/content/7.advanced/4.hooks.md @@ -1,23 +1,28 @@ --- title: Extend the module with hooks -description: Use Nuxt hooks to extend and customize the MCP module. +description: Use Nuxt and Nitro hooks to extend and customize the MCP module. navigation: title: Hooks icon: i-lucide-webhook seo: - title: Extend the toolkit with Nuxt hooks - description: Use the mcp:definitions:paths hook to add additional directories scanned for tools, resources, prompts, and handlers — share definitions across modules and Nuxt layers. + title: Extend the toolkit with Nuxt and Nitro hooks + description: Use build-time Nuxt hooks and runtime Nitro hooks to scan additional directories, mutate the resolved server config per request, and reach the underlying MCP SDK server. --- -## Available Hooks +The toolkit exposes two flavours of hooks: -The Nuxt MCP module provides hooks for extending and customizing behavior. +- **Build-time hooks** on `NuxtHooks` — fire during `nuxt build` / `nuxt prepare`, useful to register additional definition directories. +- **Runtime hooks** on `NitroRuntimeHooks` — fire **per request**, inside Nitro plugins, to mutate the resolved config or reach the SDK `McpServer` instance. -## `mcp:definitions:paths` +User listeners are best-effort: a hook that throws is logged via [consola](https://github.com/unjs/consola) and the MCP request continues. -This hook allows you to add additional directories to scan for MCP definitions. +## Build-time hooks -### Hook Signature +### `mcp:definitions:paths` + +Add additional directories to scan for tool / resource / prompt / handler definitions. Useful for sharing definitions across Nuxt layers or shipping them from a custom module. + +#### Hook Signature ```typescript nuxt.hook('mcp:definitions:paths', (paths: { @@ -26,41 +31,33 @@ nuxt.hook('mcp:definitions:paths', (paths: { prompts: string[] handlers: string[] }) => { - // Modify paths + // Mutate paths in place }) ``` -### Usage in nuxt.config.ts +#### Usage in `nuxt.config.ts` ```typescript [nuxt.config.ts] export default defineNuxtConfig({ modules: ['@nuxtjs/mcp-toolkit'], hooks: { 'mcp:definitions:paths'(paths) { - // Add additional tool directories paths.tools.push('shared/tools') paths.tools.push('legacy/tools') - - // Add additional resource directories paths.resources.push('shared/resources') - - // Add additional prompt directories paths.prompts.push('shared/prompts') - - // Add additional handler directories paths.handlers.push('custom/handlers') }, }, }) ``` -### Usage in a Custom Module +#### Usage in a Custom Module ```typescript [my-module.ts] export default defineNuxtModule({ setup(options, nuxt) { nuxt.hook('mcp:definitions:paths', (paths) => { - // Add paths from this module paths.tools.push('my-module/tools') paths.resources.push('my-module/resources') paths.prompts.push('my-module/prompts') @@ -69,31 +66,170 @@ export default defineNuxtModule({ }) ``` -## Path Structure - -The `paths` object contains arrays of directory paths: +#### Path Structure ```typescript { tools: string[] // Directories to scan for tools resources: string[] // Directories to scan for resources prompts: string[] // Directories to scan for prompts - handlers: string[] // Directories to scan for handlers + handlers: string[] // Directories to scan for handlers } ``` -All paths are relative to the `server/` directory of each Nuxt layer. +All paths are relative to the `server/` directory of each Nuxt layer: + +1. **Relative paths** like `'tools'` resolve to `server/tools/`. +2. **Absolute paths** starting with `/` resolve from project root. +3. **Layer-specific** — each Nuxt layer resolves paths relative to its own `server/` directory. + +## Runtime hooks + +Runtime hooks fire **per MCP request**, from inside the Nitro server. Subscribe to them from a [Nitro plugin](https://nuxt.com/docs/guide/directory-structure/server#server-plugins) at `server/plugins/`. + +```typescript [server/plugins/mcp.ts] +export default defineNitroPlugin((nitroApp) => { + nitroApp.hooks.hook('mcp:server:created', ({ server, event }) => { + // ... + }) +}) +``` + +The hooks run in this order: -## Path Resolution +``` +H3 Event + │ + ▼ +defineMcpHandler middleware (if any) + │ + ▼ +resolveDynamicDefinitions ──► mcp:config:resolved + │ + ▼ +createMcpServer ──► mcp:server:created + │ + ▼ +transport.handleRequest +``` + +### `mcp:config:resolved` + +Fired after dynamic `tools` / `resources` / `prompts` resolvers and `enabled(event)` guards have run, **before** the per-request `McpServer` is built. Mutate `ctx.config` in place to add, remove or transform definitions for this request only. + +#### Hook signature + +```typescript +import type { McpResolvedConfig } from '@nuxtjs/mcp-toolkit/server' +import type { H3Event } from 'h3' + +nitroApp.hooks.hook('mcp:config:resolved', (ctx: { + config: McpResolvedConfig + event: H3Event +}) => { + // Mutate ctx.config in place +}) +``` + +#### Example: hide admin tools from anonymous clients + +```typescript [server/plugins/mcp-auth.ts] +export default defineNitroPlugin((nitroApp) => { + nitroApp.hooks.hook('mcp:config:resolved', ({ config, event }) => { + if (!event.context.user) { + config.tools = config.tools.filter( + tool => !tool.tags?.includes('admin'), + ) + } + }) +}) +``` -Paths are resolved in the following order: +#### Example: rebrand the server per request + +```typescript [server/plugins/mcp-rebrand.ts] +export default defineNitroPlugin((nitroApp) => { + nitroApp.hooks.hook('mcp:config:resolved', ({ config, event }) => { + const tenant = event.context.tenant?.name + if (tenant) { + config.name = `${tenant} MCP` + config.instructions = `You are connected to ${tenant}'s MCP server.` + } + }) +}) +``` + +### `mcp:server:created` + +Fired after `createMcpServer` has registered every tool / resource / prompt and **before** the server is connected to the transport. Receives the SDK [`McpServer`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/src/server/mcp.ts) instance. + +Use it to register definitions late or reach the underlying low-level [`Server`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/src/server/index.ts) via `getSdkServer(ctx.server)` to install custom JSON-RPC handlers. + +#### Hook signature + +```typescript +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import type { H3Event } from 'h3' + +nitroApp.hooks.hook('mcp:server:created', (ctx: { + server: McpServer + event: H3Event +}) => { + // Reach into the SDK +}) +``` + +#### Example: register a tool dynamically + +```typescript [server/plugins/mcp-whoami.ts] +export default defineNitroPlugin((nitroApp) => { + nitroApp.hooks.hook('mcp:server:created', ({ server, event }) => { + server.registerTool( + 'whoami', + { description: 'Return the current user id' }, + async () => ({ + content: [{ + type: 'text', + text: String(event.context.userId ?? 'anonymous'), + }], + }), + ) + }) +}) +``` + +#### Example: instrument the low-level SDK server + +```typescript [server/plugins/mcp-instrument.ts] +import { getSdkServer } from '@nuxtjs/mcp-toolkit/server' + +export default defineNitroPlugin((nitroApp) => { + nitroApp.hooks.hook('mcp:server:created', ({ server }) => { + const sdkServer = getSdkServer(server) + sdkServer.oninitialized = () => { + console.log('Client initialized') + } + }) +}) +``` + +::tip +Need request- or session-scoped state? Pull it from `event.context` inside the hook — middleware (and any auth layer that runs before the MCP handler) has already populated it. +:: + +## Error handling + +Listeners that throw are caught and reported via consola (tag `mcp-toolkit`): + +``` +[error] [mcp-toolkit] Hook "mcp:server:created" threw — request continues +``` -1. **Relative to `server/`**: Paths like `'tools'` resolve to `server/tools/` -2. **Absolute paths**: Paths starting with `/` resolve from project root -3. **Layer-specific**: Each Nuxt layer resolves paths relative to its own `server/` directory +The MCP request always proceeds. If you need an error to surface to the client, throw inside the corresponding tool / resource / prompt handler instead — the toolkit converts thrown errors (including [H3 errors](https://h3.unjs.io/utils/response#createerror)) into MCP-compliant `isError` results. ## Next Steps -- [Custom Paths](/advanced/custom-paths) - Learn more about customizing paths -- [Handlers](/handlers/overview) - Create multiple MCP endpoints -- [Configuration](/getting-started/configuration) - Configure the module +- [Middleware](/advanced/middleware) - Run code per MCP request, including `next()` interception. +- [Dynamic Definitions](/advanced/dynamic-definitions) - Pick tools / resources / prompts dynamically from `defineMcpHandler`. +- [Custom Paths](/advanced/custom-paths) - Customize where definitions are scanned from. +- [Handlers](/handlers/overview) - Create multiple MCP endpoints. diff --git a/apps/docs/skills/manage-mcp/SKILL.md b/apps/docs/skills/manage-mcp/SKILL.md index 50d1a28..db1e349 100644 --- a/apps/docs/skills/manage-mcp/SKILL.md +++ b/apps/docs/skills/manage-mcp/SKILL.md @@ -467,6 +467,50 @@ See [middleware patterns →](./references/middleware.md). --- +## Nitro Runtime Hooks + +Two per-request Nitro hooks fire during the MCP request lifecycle. Subscribe from a `server/plugins/*.ts` plugin to mutate the resolved config or reach the SDK `McpServer` instance from anywhere — no need to own a `defineMcpHandler`. Listeners that throw are logged and the request continues. + +``` +defineMcpHandler middleware → mcp:config:resolved → createMcpServer → mcp:server:created → transport +``` + +### `mcp:config:resolved` — mutate tools/resources/prompts per request + +Fires after dynamic resolvers and `enabled(event)` guards, before the per-request `McpServer` is built. Mutate `ctx.config` in place. + +```typescript [server/plugins/mcp-filter.ts] +export default defineNitroPlugin((nitroApp) => { + nitroApp.hooks.hook('mcp:config:resolved', ({ config, event }) => { + if (!event.context.user) { + config.tools = config.tools.filter(t => !t.tags?.includes('admin')) + } + }) +}) +``` + +### `mcp:server:created` — reach the SDK server + +Fires after every tool/resource/prompt has been registered, before the server is connected to the transport. Use the SDK API to register definitions late or call `getSdkServer(server)` for low-level handlers. + +```typescript [server/plugins/mcp-whoami.ts] +export default defineNitroPlugin((nitroApp) => { + nitroApp.hooks.hook('mcp:server:created', ({ server, event }) => { + server.registerTool( + 'whoami', + { description: 'Return the current user id' }, + async () => ({ + content: [{ type: 'text', text: String(event.context.userId ?? 'anonymous') }], + }), + ) + }) +}) +``` + +See the [hooks reference →](https://mcp-toolkit.nuxt.dev/advanced/hooks). + +--- + ## Sessions Stateful MCP — server assigns an `Mcp-Session-Id` and remembers data across tool calls in the same session. @@ -929,6 +973,14 @@ export default defineNuxtConfig({ | `useMcpApp()` (in MCP App SFCs) | Reactive `data` + `callTool` / `sendPrompt` bridge. | | `listMcpTools` / `listMcpResources` / `listMcpPrompts` / `listMcpDefinitions` | JSON-friendly summaries (catalog endpoints). | | `getMcpTools` / `getMcpResources` / `getMcpPrompts` | Raw definitions (feed back into a handler). | +| `getSdkServer` | Reach the low-level SDK `Server` from an `McpServer` (advanced). | + +### Nitro Hooks + +| Hook | Fires | +| --- | --- | +| `mcp:config:resolved` | Per request, after dynamic resolvers — mutate `config.tools / resources / prompts / instructions / icons / name`. | +| `mcp:server:created` | Per request, after every definition is registered — call `server.registerTool(...)`, `getSdkServer(server).setRequestHandler(...)`, etc. | ### Debug @@ -941,5 +993,5 @@ export default defineNuxtConfig({ - [Documentation](https://mcp-toolkit.nuxt.dev) - [Tools](https://mcp-toolkit.nuxt.dev/tools/overview) · [Resources](https://mcp-toolkit.nuxt.dev/resources/overview) · [Prompts](https://mcp-toolkit.nuxt.dev/prompts/overview) - [Handlers](https://mcp-toolkit.nuxt.dev/handlers/overview) · [Apps](https://mcp-toolkit.nuxt.dev/apps/overview) -- [Sessions](https://mcp-toolkit.nuxt.dev/advanced/sessions) · [Logging](https://mcp-toolkit.nuxt.dev/advanced/logging) · [Elicitation](https://mcp-toolkit.nuxt.dev/advanced/elicitation) +- [Sessions](https://mcp-toolkit.nuxt.dev/advanced/sessions) · [Logging](https://mcp-toolkit.nuxt.dev/advanced/logging) · [Elicitation](https://mcp-toolkit.nuxt.dev/advanced/elicitation) · [Hooks](https://mcp-toolkit.nuxt.dev/advanced/hooks) - [MCP Specification](https://modelcontextprotocol.io) diff --git a/packages/nuxt-mcp-toolkit/src/runtime/server/mcp/definitions/index.ts b/packages/nuxt-mcp-toolkit/src/runtime/server/mcp/definitions/index.ts index 9477909..063adc1 100644 --- a/packages/nuxt-mcp-toolkit/src/runtime/server/mcp/definitions/index.ts +++ b/packages/nuxt-mcp-toolkit/src/runtime/server/mcp/definitions/index.ts @@ -29,6 +29,9 @@ export type { export { useMcpLogger, McpObservabilityNotEnabledError } from '../logger' export type { McpClientNotifier, McpLogger, McpRequestLogger, McpUserFields, McpSessionFields } from '../logger' +export { getSdkServer } from '../internals' +export type { McpResolvedConfig } from '../utils' + /** Commonly used MCP protocol types from `@modelcontextprotocol/sdk` (single import path with the toolkit). */ export type { Annotations, diff --git a/packages/nuxt-mcp-toolkit/src/runtime/server/mcp/utils.ts b/packages/nuxt-mcp-toolkit/src/runtime/server/mcp/utils.ts index 4e4dc82..db27971 100644 --- a/packages/nuxt-mcp-toolkit/src/runtime/server/mcp/utils.ts +++ b/packages/nuxt-mcp-toolkit/src/runtime/server/mcp/utils.ts @@ -1,6 +1,8 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { eventHandler, readBody, sendRedirect } from 'h3' import type { H3Event } from 'h3' +import { useNitroApp } from 'nitropack/runtime' +import { consola } from 'consola' import type { McpMiddleware, McpIcon } from './definitions/handlers' import type { McpPromptDefinition } from './definitions/prompts' import { registerPromptFromDefinition } from './definitions/prompts' @@ -34,7 +36,14 @@ export interface ResolvedMcpConfig { experimental_codeMode?: boolean | CodeModeOptions } -interface StaticMcpConfig { +/** + * Fully-resolved MCP server config — `tools` / `resources` / `prompts` are + * concrete arrays (no functions), filtered by `enabled(event)` guards. + * + * This is the shape passed to `mcp:config:resolved` Nitro hook listeners. + * Mutating it in place affects what the per-request `McpServer` registers. + */ +export interface McpResolvedConfig { name: string version: string description?: string @@ -68,7 +77,7 @@ async function filterByEnabled boolean async function resolveDynamicDefinitions( config: ResolvedMcpConfig, event: H3Event, -): Promise { +): Promise { const tools = typeof config.tools === 'function' ? await config.tools(event) : (config.tools || []) @@ -92,7 +101,7 @@ async function resolveDynamicDefinitions( } } -function registerEmptyDefinitionFallbacks(server: McpServer, config: StaticMcpConfig) { +function registerEmptyDefinitionFallbacks(server: McpServer, config: McpResolvedConfig) { if (!config.tools.length) { server.registerTool('__init__', {}, async () => ({ content: [] })).remove() } @@ -106,7 +115,7 @@ function registerEmptyDefinitionFallbacks(server: McpServer, config: StaticMcpCo } } -export async function createMcpServer(config: StaticMcpConfig): Promise { +export async function createMcpServer(config: McpResolvedConfig): Promise { const server = new McpServer({ name: config.name, version: config.version, @@ -246,6 +255,27 @@ function asString(value: unknown): string | undefined { return undefined } +const hookLog = consola.withTag('mcp-toolkit') + +/** Fire a Nitro runtime hook, swallowing listener errors so the request continues. */ +async function callMcpHook( + name: 'mcp:config:resolved', + ctx: { config: McpResolvedConfig, event: H3Event }, +): Promise +async function callMcpHook( + name: 'mcp:server:created', + ctx: { server: McpServer, event: H3Event }, +): Promise +async function callMcpHook(name: string, ctx: unknown): Promise { + try { + const hooks = useNitroApp().hooks as { callHook: (name: string, ctx: unknown) => Promise } + await hooks.callHook(name, ctx) + } + catch (error) { + hookLog.error(`Hook "${name}" threw — request continues`, error) + } +} + /** Tag `user` / `session` from `event.context` (whatever auth middleware set). */ function tagAuthContext(event: H3Event) { const log = getEvlogLogger(event) @@ -282,7 +312,9 @@ export function createMcpHandler(config: CreateMcpHandlerConfig) { const handler = async () => { tagAuthContext(event) const staticConfig = await resolveDynamicDefinitions(resolvedConfig, event) + await callMcpHook('mcp:config:resolved', { config: staticConfig, event }) const server = await createMcpServer(staticConfig) + await callMcpHook('mcp:server:created', { server, event }) return handleMcpRequest(() => server, event) } diff --git a/packages/nuxt-mcp-toolkit/src/runtime/server/types/hooks.ts b/packages/nuxt-mcp-toolkit/src/runtime/server/types/hooks.ts index 8dc2667..8ed6ac9 100644 --- a/packages/nuxt-mcp-toolkit/src/runtime/server/types/hooks.ts +++ b/packages/nuxt-mcp-toolkit/src/runtime/server/types/hooks.ts @@ -1,3 +1,7 @@ +import type { H3Event } from 'h3' +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import type { McpResolvedConfig } from '../mcp/utils' + declare module '@nuxt/schema' { interface NuxtHooks { /** @@ -17,3 +21,39 @@ declare module '@nuxt/schema' { }) => void | Promise } } + +/** + * Per-request MCP runtime hooks. Listener errors are caught and logged — + * the request always proceeds. + * @see https://mcp-toolkit.nuxt.dev/advanced/hooks#runtime-hooks + */ +declare module 'nitropack/types' { + interface NitroRuntimeHooks { + /** + * Fires once the per-request config is resolved, before the `McpServer` is built. Mutate `ctx.config` to add or filter definitions for this request. + * @example + * ```ts + * nitroApp.hooks.hook('mcp:config:resolved', ({ config, event }) => { + * if (!event.context.user) config.tools = config.tools.filter(t => !t.tags?.includes('admin')) + * }) + * ``` + */ + 'mcp:config:resolved': (ctx: { + config: McpResolvedConfig + event: H3Event + }) => void | Promise + /** + * Fires after the per-request `McpServer` is built, before transport. Register late definitions or reach the SDK via `getSdkServer(ctx.server)`. + * @example + * ```ts + * nitroApp.hooks.hook('mcp:server:created', ({ server }) => { + * server.registerTool('whoami', { description: '...' }, async () => 'me') + * }) + * ``` + */ + 'mcp:server:created': (ctx: { + server: McpServer + event: H3Event + }) => void | Promise + } +} diff --git a/packages/nuxt-mcp-toolkit/test/fixtures/hooks/app.vue b/packages/nuxt-mcp-toolkit/test/fixtures/hooks/app.vue new file mode 100644 index 0000000..9206c7f --- /dev/null +++ b/packages/nuxt-mcp-toolkit/test/fixtures/hooks/app.vue @@ -0,0 +1,3 @@ + diff --git a/packages/nuxt-mcp-toolkit/test/fixtures/hooks/nuxt.config.ts b/packages/nuxt-mcp-toolkit/test/fixtures/hooks/nuxt.config.ts new file mode 100644 index 0000000..f2330a0 --- /dev/null +++ b/packages/nuxt-mcp-toolkit/test/fixtures/hooks/nuxt.config.ts @@ -0,0 +1,6 @@ +import { defineNuxtConfig } from 'nuxt/config' +import MyModule from '../../../src/module' + +export default defineNuxtConfig({ + modules: [MyModule], +}) diff --git a/packages/nuxt-mcp-toolkit/test/fixtures/hooks/package.json b/packages/nuxt-mcp-toolkit/test/fixtures/hooks/package.json new file mode 100644 index 0000000..edbbbba --- /dev/null +++ b/packages/nuxt-mcp-toolkit/test/fixtures/hooks/package.json @@ -0,0 +1,4 @@ +{ + "name": "mcp-toolkit-hooks-fixture", + "private": true +} diff --git a/packages/nuxt-mcp-toolkit/test/fixtures/hooks/server/mcp/tools/admin-tool.ts b/packages/nuxt-mcp-toolkit/test/fixtures/hooks/server/mcp/tools/admin-tool.ts new file mode 100644 index 0000000..946178c --- /dev/null +++ b/packages/nuxt-mcp-toolkit/test/fixtures/hooks/server/mcp/tools/admin-tool.ts @@ -0,0 +1,9 @@ +import { defineMcpTool } from '../../../../../../src/runtime/server/types' + +export default defineMcpTool({ + name: 'admin_tool', + description: 'An admin tool filtered out by mcp:config:resolved', + tags: ['admin'], + inputSchema: {}, + handler: async () => 'admin-result', +}) diff --git a/packages/nuxt-mcp-toolkit/test/fixtures/hooks/server/mcp/tools/public-tool.ts b/packages/nuxt-mcp-toolkit/test/fixtures/hooks/server/mcp/tools/public-tool.ts new file mode 100644 index 0000000..00d96ee --- /dev/null +++ b/packages/nuxt-mcp-toolkit/test/fixtures/hooks/server/mcp/tools/public-tool.ts @@ -0,0 +1,8 @@ +import { defineMcpTool } from '../../../../../../src/runtime/server/types' + +export default defineMcpTool({ + name: 'public_tool', + description: 'A public tool always exposed by the server', + inputSchema: {}, + handler: async () => 'public-result', +}) diff --git a/packages/nuxt-mcp-toolkit/test/fixtures/hooks/server/plugins/mcp-hooks.ts b/packages/nuxt-mcp-toolkit/test/fixtures/hooks/server/plugins/mcp-hooks.ts new file mode 100644 index 0000000..c836eb2 --- /dev/null +++ b/packages/nuxt-mcp-toolkit/test/fixtures/hooks/server/plugins/mcp-hooks.ts @@ -0,0 +1,29 @@ +import { defineNitroPlugin } from 'nitropack/runtime' + +export default defineNitroPlugin((nitroApp) => { + nitroApp.hooks.hook('mcp:config:resolved', ({ config }) => { + config.tools = config.tools.filter(tool => !tool.tags?.includes('admin')) + for (const tool of config.tools) { + if (tool.name === 'public_tool' && tool.description) { + tool.description = `[mutated] ${tool.description}` + } + } + }) + + nitroApp.hooks.hook('mcp:server:created', ({ server }) => { + server.registerTool( + 'late_tool', + { + description: 'Tool registered via mcp:server:created hook', + inputSchema: {}, + }, + async () => ({ + content: [{ type: 'text', text: 'late-result' }], + }), + ) + }) + + nitroApp.hooks.hook('mcp:server:created', () => { + throw new Error('intentional hook failure — should be swallowed') + }) +}) diff --git a/packages/nuxt-mcp-toolkit/test/hooks.test.ts b/packages/nuxt-mcp-toolkit/test/hooks.test.ts new file mode 100644 index 0000000..d37afe4 --- /dev/null +++ b/packages/nuxt-mcp-toolkit/test/hooks.test.ts @@ -0,0 +1,74 @@ +import { fileURLToPath } from 'node:url' +import { describe, it, expect, afterAll } from 'vitest' +import { setup, $fetch } from '@nuxt/test-utils/e2e' +import type { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { cleanupMcpTests, createMcpClient } from './helpers/mcp-setup.js' + +describe('MCP Nitro Hooks', async () => { + await setup({ + rootDir: fileURLToPath(new URL('./fixtures/hooks', import.meta.url)), + }) + + afterAll(async () => { + await cleanupMcpTests() + }) + + it('should render the page', async () => { + const html = await $fetch('/') + expect(html).toContain('
hooks fixture
') + }) + + it('mcp:server:created can register tools dynamically', async () => { + const client: Client = await createMcpClient('/mcp', 'hooks-late-register') + try { + const { tools } = await client.listTools() + const names = tools.map(t => t.name) + expect(names).toContain('late_tool') + + const result = await client.callTool({ name: 'late_tool', arguments: {} }) + const content = result.content as Array<{ type: string, text?: string }> + const text = content.find(c => c.type === 'text')?.text + expect(text).toBe('late-result') + } + finally { + await client.close() + } + }) + + it('mcp:config:resolved can mutate the resolved config to filter definitions', async () => { + const client: Client = await createMcpClient('/mcp', 'hooks-config-filter') + try { + const { tools } = await client.listTools() + const names = tools.map(t => t.name) + expect(names).toContain('public_tool') + expect(names).not.toContain('admin_tool') + } + finally { + await client.close() + } + }) + + it('mcp:config:resolved can mutate non-tools fields on a definition', async () => { + const client: Client = await createMcpClient('/mcp', 'hooks-config-mutate') + try { + const { tools } = await client.listTools() + const publicTool = tools.find(t => t.name === 'public_tool') + expect(publicTool?.description).toMatch(/^\[mutated\] /) + } + finally { + await client.close() + } + }) + + it('throwing inside a hook listener does not break the request', async () => { + const client: Client = await createMcpClient('/mcp', 'hooks-throwing') + try { + const result = await client.callTool({ name: 'public_tool', arguments: {} }) + expect(result).toBeDefined() + expect(result.isError).not.toBe(true) + } + finally { + await client.close() + } + }) +})