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
48 changes: 48 additions & 0 deletions .changeset/nitro-runtime-hooks.md
Original file line number Diff line number Diff line change
@@ -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).
200 changes: 168 additions & 32 deletions apps/docs/content/7.advanced/4.hooks.md
Original file line number Diff line number Diff line change
@@ -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: {
Expand All @@ -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')
Expand All @@ -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.
54 changes: 53 additions & 1 deletion apps/docs/skills/manage-mcp/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -929,6 +973,14 @@ export default defineNuxtConfig({
| `useMcpApp<T>()` (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

Expand All @@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading