Manage MCP servers in Nuxt with @nuxtjs/mcp-toolkit — setup, create tools/resources/prompts, organize with handlers, build interactive MCP Apps, add elicitation/logging/sessions, review, and troubleshoot.
Manage MCP servers in Nuxt with @nuxtjs/mcp-toolkit — setup, create tools/resources/prompts, organize with handlers, build interactive MCP Apps, add elicitation/logging/sessions, review, and troubleshoot.
Manage MCP
Complete skill for managing Model Context Protocol (MCP) servers in Nuxt with @nuxtjs/mcp-toolkit. Setup, create tools/resources/prompts, organize with multi-handler folder convention, build interactive UI widgets (MCP Apps), wire up sessions and observability, review, and troubleshoot.
When to Use
Setup: "Setup an MCP server in my Nuxt app", "Add MCP to Nuxt"
Create: "Add a tool that calculates BMI", "Expose a README as a resource", "Create a code-review prompt"
Organize: "Split my MCP into admin + public endpoints", "Filter tools per user"
Customize: "Add authentication to my MCP", "Log every tool call to Axiom", "Stream progress to the client"
Build apps: "Add an interactive Vue widget callable from ChatGPT/Cursor"
Persist state: "Remember the user across tool calls in one session"
Review: "Review my MCP implementation", "Check for best practices"
Test: "Eval which tools the model picks for these prompts"
Setup MCP Server
Installation
::code-group
pnpm add @nuxtjs/mcp-toolkit zod
npm install @nuxtjs/mcp-toolkit zod
yarn add @nuxtjs/mcp-toolkit zod
bun add @nuxtjs/mcp-toolkit zod
::
Or in one go via the Nuxt CLI:
npx nuxt add mcp
Add to nuxt.config.ts:
export default defineNuxtConfig({
modules: ['@nuxtjs/mcp-toolkit'],
mcp: {
name: 'My MCP Server',
description: 'Read and update todos for the current user.',
},
nitro: {
experimental: { asyncContext: true }, // required for useEvent / useMcpServer / useMcpLogger
},
})
Directory Structure
server/mcp/
├── tools/ # Actions the AI can perform
│ └── admin/ # Subdirectory → group: 'admin'
├── resources/ # Data the AI can read
├── prompts/ # Reusable message templates
└── handlers/ # Optional: named handlers mounted at /mcp/<name>
└── admin/
├── index.ts # defineMcpHandler({ middleware: requireAdmin })
├── tools/ # auto-attached to /mcp/admin
└── resources/
app/mcp/ # Optional: MCP Apps (interactive Vue widgets)
└── palette.vue # → app `palette`, callable from ChatGPT / Cursor
Verification
Start the dev server: pnpm dev
Hit the endpoint: curl http://localhost:3000/mcp (responds to MCP JSON-RPC)
Open Nuxt DevTools (Shift+Alt+D) → MCP Inspector tab → Launch Inspector (opens in a new browser tab).
Create Tools
Tools are functions AI assistants can call. Auto-discovered from any .ts/.js file under server/mcp/tools/.
Basic Tool
import { z } from 'zod'
export default defineMcpTool({
description: 'Echo a message back to the user',
inputSchema: {
message: z.string().describe('Text to echo'),
},
handler: async ({ message }) => {
return `Echo: ${message}` // string is auto-wrapped into a text content item
},
})
name and title are auto-derived from the filename (echo.ts → name: 'echo', title: 'Echo'). Override either by setting them explicitly.
Return Values
Handlers can return any of these — the toolkit normalizes them:
Return
Wrapped as
string / number / boolean
{ content: [{ type: 'text', text: String(v) }] }
Plain object / array
JSON-stringified into a text content item
imageResult(base64, mime)
{ content: [{ type: 'image', data, mimeType }] }
audioResult(base64, mime)
{ content: [{ type: 'audio', data, mimeType }] }
Full CallToolResult
Passed through (use for structuredContent, embedded resources, multi-content)
Thrown error
Caught and converted to isError: true. createError({ statusCode, message }) from h3 includes the status code in the response.
import { z } from 'zod'
export default defineMcpTool({
description: 'Get a user by ID',
inputSchema: { id: z.string().describe('User ID') },
handler: async ({ id }) => {
const user = await getUser(id)
if (!user) throw createError({ statusCode: 404, message: 'User not found' })
return user // plain object — auto-stringified
},
})
For typed structured output, pair outputSchema with a structuredContent return:
group (single) and tags (free-form) help organize tools and feed listMcpTools({ group, tags }) filters. group is auto-inferred from the parent folder — server/mcp/tools/admin/delete-user.ts → group: 'admin'. Explicit group wins.
export default defineMcpTool({
group: 'admin',
tags: ['destructive', 'user-management'],
description: 'Delete a user account',
// ...
})
Caching
export default defineMcpTool({
cache: '5m', // also accepts `Number` of ms or full Nitro cache options
description: 'Fetch weather (cached)',
inputSchema: { city: z.string() },
handler: async ({ city }) => $fetch(`/api/weather?city=${city}`),
})
Conditional Visibility (enabled)
Hide a tool per request — runs after middleware, so event.context is populated:
export default defineMcpTool({
enabled: event => Boolean(event.context.user),
description: 'List the current user’s todos',
handler: async () => listTodos(useEvent().context.user.id),
})
Prompts are reusable message templates. Auto-discovered from server/mcp/prompts/.
Simple String Prompt
export default defineMcpPrompt({
description: 'Code-review assistant',
handler: async () => 'You are a senior reviewer. Be concise and actionable.',
})
The string is wrapped into a single user message. Set role: 'assistant' to wrap as an assistant message instead.
Parameterized Prompt
import { z } from 'zod'
export default defineMcpPrompt({
description: 'Generate a focused code review',
inputSchema: {
language: z.string().describe('Programming language'),
focus: z.enum(['performance', 'security', 'maintainability']).describe('Review focus'),
},
handler: async ({ language, focus }) =>
`Review my ${language} code. Focus on ${focus}.`,
})
Argument Autocomplete with completable()
Surface dynamic suggestions in clients that support prompts/complete:
import { z } from 'zod'
export default defineMcpPrompt({
description: 'Open an issue for a project',
inputSchema: {
project: completable(z.string(), async (value) => {
const projects = await listProjects()
return projects.filter(p => p.startsWith(value)).slice(0, 5)
}).describe('Project name'),
},
handler: async ({ project }) => `Open an issue for ${project}.`,
})
::callout{icon="i-lucide-info" color="info"}
The MCP spec only allows user and assistant roles. Put system instructions inside the user message text.
::
By default everything under server/mcp/{tools,resources,prompts}/ is exposed at /mcp. To split into multiple endpoints with different middleware, use the folder convention:
export default defineMcpHandler({
description: 'Admin tools — destructive operations gated by Bearer auth.',
middleware: async (event) => {
const apiKey = getHeader(event, 'authorization')?.replace('Bearer ', '')
const user = apiKey ? await verifyAdmin(apiKey) : null
if (user) event.context.user = user
// No throw — let `enabled` guards on individual tools hide them when there's no user
},
// tools / resources / prompts omitted → folder convention auto-attaches them
})
mcp.defaultHandlerStrategy controls what /mcp exposes when named handlers exist:
'orphans' (default) — only definitions not attached to a named handler.
'all' — every discovered definition (the kitchen-sink route).
Cross-cutting filters use the function form:
import { defineMcpHandler, getMcpTools } from '@nuxtjs/mcp-toolkit/server'
export default defineMcpHandler({
// Every tool tagged 'searchable', regardless of folder
tools: event => getMcpTools({ event, tags: ['searchable'] }),
})
Clients can further subset whatever this endpoint would have served by sending X-MCP-Tools (comma-separated names matching tools/list). Unknown names return HTTP 400. Applied automatically after enabled() and mcp:config:resolved — no custom handler needed.
Middleware lives on defineMcpHandler (no separate defineMcpMiddleware exists). It runs before/after the MCP request, can populate event.context, and supports auto-next():
// Override the default `/mcp` handler with custom middleware
export default defineMcpHandler({
middleware: async (event) => {
// Soft auth: set context on success, never throw 401 from middleware
// (throwing 401 triggers OAuth discovery in MCP clients)
const apiKey = getHeader(event, 'authorization')?.replace('Bearer ', '')
if (apiKey) {
const user = await verifyApiKey(apiKey).catch(() => null)
if (user) event.context.user = user
}
},
})
::callout{icon="i-lucide-triangle-alert" color="warning"}
Don't throw createError({ statusCode: 401 }) from middleware — most clients interpret a 401 on the MCP route as "this server requires OAuth" and stop the regular flow. Either return 403 for hard rejections, or keep auth soft and let per-tool enabled guards hide what the user can't access.
::
For structured wide events on every MCP request, use useMcpLogger() instead of console.log — it tags mcp.tool, mcp.session_id, user.id, etc. automatically.
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.
mcp:config:resolved — mutate tools/resources/prompts per request
Fires after dynamic resolvers and enabled(event) guards, before the X-MCP-Tools allowlist and the per-request McpServer is built. Mutate ctx.config in place.
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.
Author Vue Single-File Components in app/mcp/ — they ship to MCP-Apps-compatible hosts (ChatGPT, Cursor) as interactive widgets backed by your MCP tool handler.
<script setup lang="ts">
import { z } from 'zod'
defineMcpApp({
description: 'Pick a colour and preview a palette.',
inputSchema: { base: z.string().describe('Hex colour, e.g. #2563eb') },
handler: async ({ base }) => ({
structuredContent: await $fetch('/api/palette', { query: { base } }),
}),
})
const { data, sendPrompt } = useMcpApp<{ swatches: { name: string, hex: string }[] }>()
</script>
<template>
<div class="grid grid-cols-3 gap-2">
<button
v-for="s in data?.swatches"
:key="s.hex"
class="rounded-md p-3 text-white"
:style="{ background: s.hex }"
@click="sendPrompt(`Use ${s.name}`)"
>
{{ s.name }}
</button>
</div>
</template>
Each SFC becomes a tool, a UI resource at ui://mcp-app/<name>, and a single-file HTML bundle. The handler runs server-side; structuredContent is inlined into the HTML so the iframe boots with full data on the first paint.
Identify the server in client UIs and steer the LLM with operational instructions:
export default defineNuxtConfig({
mcp: {
name: 'Todos MCP',
description: 'Read and update todos for the current user.', // shown in client UIs
instructions: 'Always call list-todos before create-todo. Group results by status.', // injected into the LLM system prompt
icons: [
{ src: 'https://example.com/icon.png', mimeType: 'image/png', sizes: ['64x64'] },
],
},
})
Override per-handler when an endpoint needs a different identity (e.g. /mcp/admin with its own description and icons).
Listing Definitions (read your catalog)
Use listMcp* to expose summaries (catalog endpoints) and getMcp* for raw definitions to feed into a handler:
Filters: event (apply enabled guards), group, tags, handler, orphansOnly. Pass event to match exactly what the request would see.
Code Mode (experimental)
Wrap every tool exposed by a handler into a single code tool. The LLM writes JavaScript that calls tools via codemode.*, executed in a secure V8 isolate via secure-exec.
When reviewing or modernizing server/mcp/**, walk this checklist:
Tools
✅ Direct returns (string, object, array) instead of full CallToolResult
✅ throw createError({ statusCode, message }) for failures (caught and returned as isError)
✅ .describe() on every Zod field
✅ Honest annotations (readOnlyHint, destructiveHint, idempotentHint, openWorldHint)
✅ inputExamples for non-trivial schemas
✅ enabled: event => Boolean(event.context.user) to hide tools per request
✅ cache: '5m' (or full Nitro options) for expensive idempotent ops
✅ nitro.experimental.asyncContext: true when calling useEvent() / useMcpServer() / useMcpSession() / useMcpLogger() / useMcpElicitation()
❌ textResult / jsonResult / errorResult (deprecated — use direct returns or throw)
❌ defineMcpMiddleware (does not exist — middleware is a field on defineMcpHandler)
❌ Throwing 401 from middleware (triggers OAuth discovery in clients — return 403, or use soft auth + enabled guards)
❌ Generic descriptions, missing await, unvalidated input, exposed secrets
Resources
✅ mimeType under metadata (not at top level)
✅ file: 'README.md' shorthand for static files (URI, MIME and handler auto-generated)
✅ ResourceTemplate for parameterized URIs
✅ metadata.annotations for audience / priority / lastModified
❌ Returning huge payloads — paginate via templates
❌ Skipping the MIME type — clients use it to render correctly
Prompts
✅ Plain string returns when one user message is enough (uses role field, default 'user')
✅ completable(z.string(), async value => suggestions) for argument autocomplete
✅ Single, focused purpose per prompt
❌ Mixing system instructions outside the user message (the spec rejects system role)
Handlers
✅ Folder convention (server/mcp/handlers/<name>/) over manual tools: [...] arrays
✅ One responsibility per handler (admin / public / apps), pick the right defaultHandlerStrategy
✅ Per-handler description / instructions / icons when an endpoint has its own identity
Troubleshooting
Tool / Resource / Prompt not discovered
File is under server/mcp/{tools,resources,prompts}/ (or under a handlers/<name>/ subfolder)?
export default (not a named export)?
Restart dev server and run pnpm nuxt prepare.
Auto-imports not working
'@nuxtjs/mcp-toolkit' listed in modules in nuxt.config.ts?
Run pnpm dev:prepare (or pnpm nuxt prepare) to regenerate type stubs.
Restart the TypeScript server in your IDE.
If you've set mcp.autoImports: false, import explicitly from @nuxtjs/mcp-toolkit/server.
Should return a JSON-RPC response. If 404, check mcp.enabled !== false and mcp.route.
useEvent() / useMcpServer() / useMcpSession() throws "no async context"
Add nitro.experimental.asyncContext: true to nuxt.config.ts. These composables read the current event from Nitro's async-local storage, which only works when asyncContext is on (default since Nuxt 3.8+).
If your IDE / vue-tsc infers Promise<…> for one of these (a known auto-import quirk), await the call defensively — runtime works either way: const mcp = await useMcpServer().
Client triggers OAuth flow on every request
You're throwing 401 from middleware. Switch to soft auth: set event.context.user on success, return 403 for hard rejections, and gate per-tool with enabled: event => Boolean(event.context.user).
Origin checks rejecting browser clients
Set mcp.security.allowedOrigins:
mcp: {
security: {
allowedOrigins: ['https://my-app.vercel.app'],
// allowedOrigins: '*' // explicit opt-out — use with care
},
}
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. X-MCP-Tools is applied after this hook.
mcp:server:created
Per request, after every definition is registered — call server.registerTool(...), getSdkServer(server).setRequestHandler(...), etc.
Debug
DevTools: Shift+Alt+D → MCP Inspector tab → Launch Inspector (opens in a new browser tab).