manage-mcp
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.
How do I install this agent skill?
npx skills add https://mcp-toolkit.nuxt.dev --skill manage-mcpIs this agent skill safe to install?
No partner audit is available yet. Read the source before installing.
What does this agent skill do?
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"
- Troubleshoot: "My tool isn't discovered", "Auto-imports don't work", "OAuth flow keeps triggering"
- 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:
import { z } from 'zod'
export default defineMcpTool({
description: 'Calculate Body Mass Index',
inputSchema: {
height: z.number().describe('Height in meters'),
weight: z.number().describe('Weight in kilograms'),
},
outputSchema: {
bmi: z.number(),
category: z.string(),
},
handler: async ({ height, weight }) => {
const bmi = weight / (height * height)
const category = bmi < 18.5 ? 'underweight' : bmi < 25 ? 'normal' : bmi < 30 ? 'overweight' : 'obese'
return { structuredContent: { bmi, category } }
},
})
Annotations & Input Examples
Behavioral hints that help clients decide whether to prompt for confirmation:
export default defineMcpTool({
description: 'Delete a user account',
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: true,
openWorldHint: false,
},
inputSchema: { id: z.string() },
inputExamples: [{ id: 'usr_42' }, { id: 'admin' }],
handler: async ({ id }) => {
await deleteUser(id)
return `Deleted ${id}.`
},
})
Common patterns: read-only → readOnlyHint: true; create → idempotentHint: false; update → idempotentHint: true; delete → destructiveHint: true, idempotentHint: true.
Groups, Tags & Folder Inference
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),
})
See tool examples →.
Create Resources
Resources expose read-only data addressable by URI. Auto-discovered from server/mcp/resources/.
File Shorthand (zero handler)
export default defineMcpResource({
description: 'Project README file',
file: 'README.md', // URI, MIME type, and handler auto-generated
})
Standard Resource (custom handler)
export default defineMcpResource({
description: 'Application config',
uri: 'config:///app',
metadata: { mimeType: 'application/json' },
handler: async (uri) => ({
contents: [{
uri: uri.toString(),
mimeType: 'application/json',
text: JSON.stringify({ env: process.env.NODE_ENV }, null, 2),
}],
}),
})
Template Resource (URI variables)
Pass a ResourceTemplate from the SDK as uri:
import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'
export default defineMcpResource({
description: 'Fetch a user by ID',
uri: new ResourceTemplate('user:///{id}', { list: undefined }),
handler: async (uri, { id }) => {
const user = await getUser(String(id))
return {
contents: [{
uri: uri.toString(),
mimeType: 'application/json',
text: JSON.stringify(user, null, 2),
}],
}
},
})
Metadata & Annotations
Use the top-level metadata field for MIME type and annotations (audience, priority, lastModified):
export default defineMcpResource({
description: 'Project README',
file: 'README.md',
metadata: {
mimeType: 'text/markdown',
annotations: {
audience: ['user', 'assistant'],
priority: 0.8,
lastModified: new Date().toISOString(),
},
},
})
See resource examples →.
Create Prompts
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}.`,
})
Full GetPromptResult (multi-message)
export default defineMcpPrompt({
description: 'Structured debugging session',
inputSchema: { issue: z.string(), env: z.enum(['dev', 'staging', 'prod']) },
handler: async ({ issue, env }) => ({
messages: [
{ role: 'user', content: { type: 'text', text: `Debug in ${env}:\n${issue}` } },
{ role: 'assistant', content: { type: 'text', text: 'Let me analyze step by step:' } },
],
}),
})
::callout{icon="i-lucide-info" color="info"}
The MCP spec only allows user and assistant roles. Put system instructions inside the user message text.
::
See prompt examples →.
Multi-Handler Organization
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:
server/mcp/
├── tools/ # → /mcp (default handler)
└── handlers/
├── admin/
│ ├── index.ts # defineMcpHandler({ middleware: requireAdmin })
│ ├── tools/ # → /mcp/admin (auto)
│ └── prompts/
└── public/
├── index.ts
└── tools/ # → /mcp/public (auto)
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.
See handlers reference →.
Middleware
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.
::
Logging Middleware (with next() for timing)
import { extractToolNames } from '@nuxtjs/mcp-toolkit/server'
export default defineMcpHandler({
middleware: async (event, next) => {
const start = Date.now()
const response = await next()
const tools = await extractToolNames(event)
console.log(`[mcp] ${tools.join(',') || 'rpc'} took ${Date.now() - start}ms`)
return response
},
})
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.
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 → X-MCP-Tools allowlist → createMcpServer → mcp:server:created → transport
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.
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.
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 →.
Sessions
Stateful MCP — server assigns an Mcp-Session-Id and remembers data across tool calls in the same session.
export default defineNuxtConfig({
mcp: {
sessions: true, // or { maxDuration: 30 * 60_000, maxSessions: 1000 }
},
})
import { z } from 'zod'
export default defineMcpTool({
description: 'Remember a fact for this session',
inputSchema: { key: z.string(), value: z.string() },
handler: async ({ key, value }) => {
const session = useMcpSession<{ facts: Record<string, string> }>()
const facts = (await session.get('facts')) ?? {}
facts[key] = value
await session.set('facts', facts)
return `Remembered ${key}.`
},
})
Sessions enable SSE streaming, server-to-client notifications, and elicitation. See sessions reference →.
Elicitation
Ask the connected client for structured input mid-request, or send the user to a URL (MCP spec 2025-11-25).
import { z } from 'zod'
export default defineMcpTool({
description: 'Create a release after asking for the channel',
inputSchema: { name: z.string() },
handler: async ({ name }) => {
const elicit = useMcpElicitation()
if (!elicit.supports('form')) {
return `Pass --channel via your client; "${name}" needs a release channel.`
}
const result = await elicit.form({
message: `Pick a channel for "${name}"`,
schema: {
channel: z.enum(['stable', 'beta']).describe('Release channel'),
notify: z.boolean().default(true),
},
})
if (result.action !== 'accept') return `Cancelled (${result.action}).`
return `Released ${name} on ${result.content.channel}.`
},
})
- Form mode — Zod raw shape, validated and typed. Schema must be a flat object of primitives, enums, or string-enum arrays.
- URL mode —
elicit.url({ message, url }), opt-in per spec; gate withelicit.supports('url'). - Confirm —
await elicit.confirm('Continue?')returnsboolean. - Errors — catch
McpElicitationError(code: 'unsupported' | 'invalid-schema' | 'invalid-response') to fall back gracefully.
Requires nitro.experimental.asyncContext: true and a client that declared the elicitation capability.
See elicitation docs →.
Observability — useMcpLogger()
Split-channel logger. notify goes to the connected client; set / event / evlog feed the request's evlog wide event when observability is on.
import { z } from 'zod'
export default defineMcpTool({
description: 'Charge a payment method',
inputSchema: { userId: z.string(), amount: z.number().int().positive() },
annotations: { destructiveHint: true, idempotentHint: false },
handler: async ({ userId, amount }) => {
const log = useMcpLogger('billing')
log.set({ billing: { amount } })
await log.notify.info({ msg: 'starting charge', amount })
try {
const receipt = await chargeCard(userId, amount)
log.event('charge_completed', { receiptId: receipt.id })
return `Charged ${amount}. Receipt: ${receipt.id}`
}
catch (err) {
log.evlog.error('charge failed', err as Error)
throw err
}
},
})
- Client channel (
log.notify):notify(level, data, logger?)+.debug/.info/.warning/.error. Always resolves, never throws. - Server channel:
set(fields)/event(name, fields?)/setUser({ id, email, name })/setSession({ id })/evlog. ThrowsMcpObservabilityNotEnabledErrorwhen off. - Wide events are auto-tagged:
mcp.method,mcp.tool,mcp.session_id,mcp.request_id,service: '<env.service>/mcp'.user.*/session.*flow through automatically when middleware setsevent.context.user/event.context.session.
Setup
::code-group
pnpm add evlog
npm install evlog
::
export default defineNuxtConfig({
modules: ['evlog/nuxt', '@nuxtjs/mcp-toolkit'],
evlog: { env: { service: 'my-app' } },
})
mcp.logging: omit (auto-detect), true (assert evlog/nuxt is registered), false (opt out).
Drains
Ship every MCP wide event to Axiom, Sentry, OTLP, HyperDX, Datadog, Better Stack, or PostHog with one Nitro plugin:
import { createAxiomDrain } from 'evlog/adapters/axiom'
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('evlog:drain', createAxiomDrain())
})
See logging docs → and evlog.dev →.
MCP Apps (interactive UI widgets)
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; the host renders the HTML from the ui:// resource and pushes structuredContent into the iframe.
useMcpApp<T>() exposes data, meta, loading, error, hostContext, hostCapabilities, callTool(name, params), sendPrompt(prompt), openLink(url), requestDisplayMode(mode), updateModelContext(params), and downloadFile(contents).
CSP is strict by default — opt extra origins in:
defineMcpApp({
csp: {
resourceDomains: ['https://images.example.com'], // <img>, <style>, fonts
connectDomains: ['https://api.example.com'], // fetch / XHR / WebSocket
},
// ...
})
See MCP Apps reference →.
Server Metadata
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).
Agent Discovery (llms.txt)
When nuxt-llms is registered alongside this module, the toolkit appends an ## MCP Server section to /llms.txt pointing at the endpoint, so agents that discover the site through llms.txt can connect without a hand-configured URL. Nothing to set up:
export default defineNuxtConfig({
modules: ['@nuxtjs/mcp-toolkit', 'nuxt-llms'],
llms: { domain: 'https://example.com', title: 'Example' },
mcp: { name: 'Example MCP', description: 'Query Example data from your agent.' },
})
The section uses llms.domain + mcp.route for the URL, mcp.name as the label, mcp.description as the section description, and adds mcp.browserRedirect as a documentation link when set. Set mcp.llms: false to opt out; a section you title MCP Server yourself always wins. No-op when nuxt-llms isn't installed.
This is a discoverability convention, not part of the MCP specification — spec'd discovery (AI Catalog / Server Cards) is still a draft.
Listing Definitions (read your catalog)
Use listMcp* to expose summaries (catalog endpoints) and getMcp* for raw definitions to feed into a handler:
import { listMcpDefinitions } from '@nuxtjs/mcp-toolkit/server'
export default defineEventHandler(async (event) => {
const { tools, resources, prompts } = await listMcpDefinitions({ event })
return {
name: 'My MCP Server',
tools: tools.map(t => ({ name: t.name, description: t.description })),
resources: resources.map(r => ({ name: r.name, uri: r.uri })),
prompts: prompts.map(p => ({ name: p.name, description: p.description })),
}
})
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.
export default defineMcpHandler({
experimental_codeMode: {
progressive: true,
memoryLimit: 128, // MB
cpuTimeLimitMs: 5000,
maxToolCalls: 20,
},
})
npm install secure-exec
Useful for letting the LLM orchestrate many tool calls in one round-trip. Not supported on Cloudflare Workers (returns a clear runtime error).
See Code Mode docs →.
Review & Best Practices
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 ahandlers/<name>/subfolder)? export default(not a named export)?- Restart dev server and run
pnpm nuxt prepare.
Auto-imports not working
'@nuxtjs/mcp-toolkit'listed inmodulesinnuxt.config.ts?- Run
pnpm dev:prepare(orpnpm 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.
Endpoint not accessible
curl -X POST http://localhost:3000/mcp \
-H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
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
},
}
See troubleshooting reference →.
Testing with Evals
Eval the right-tool-for-the-prompt selection with Evalite:
pnpm add -D evalite vitest @ai-sdk/mcp ai
{
"scripts": {
"eval": "evalite",
"eval:ui": "evalite watch"
}
}
import { experimental_createMCPClient as createMCPClient } from '@ai-sdk/mcp'
import { generateText } from 'ai'
import { evalite } from 'evalite'
import { toolCallAccuracy } from 'evalite/scorers'
evalite('MCP Tool Selection', {
data: async () => [
{
input: 'Calculate BMI for 70kg 1.75m',
expected: [{ toolName: 'bmi-calculator', input: { weight: 70, height: 1.75 } }],
},
],
task: async (input) => {
const mcp = await createMCPClient({
transport: { type: 'http', url: 'http://localhost:3000/mcp' },
})
try {
const result = await generateText({
model: 'openai/gpt-4o',
prompt: input,
tools: await mcp.tools(),
})
return result.toolCalls ?? []
}
finally {
await mcp.close()
}
},
scorers: [
({ output, expected }) => toolCallAccuracy({ actualCalls: output, expectedCalls: expected }),
],
})
Run it:
pnpm dev # in one terminal
pnpm eval # in another, or `pnpm eval:ui` for the watcher UI
See testing reference →.
Quick Reference
Configuration
export default defineNuxtConfig({
modules: ['@nuxtjs/mcp-toolkit'],
mcp: {
name: 'My Server',
description: 'What this server does',
instructions: 'How the LLM should use it',
route: '/mcp',
sessions: true,
defaultHandlerStrategy: 'orphans', // or 'all'
security: { allowedOrigins: ['https://my-app.vercel.app'] },
logging: true, // requires evlog/nuxt
llms: true, // advertise in /llms.txt when nuxt-llms is registered
},
nitro: { experimental: { asyncContext: true } },
})
Server-Side API (auto-imported)
| Helper | Purpose |
|---|---|
defineMcpTool / defineMcpResource / defineMcpPrompt | Declare a definition (auto-discovered). |
defineMcpHandler | Custom handler with middleware / dynamic tools. |
defineMcpApp (in app/mcp/*.vue) | Interactive Vue widget. |
imageResult / audioResult | Wrap binary content in a tool response. |
completable | Argument autocomplete on prompts. |
extractToolNames | Parse tool names from the JSON-RPC body in middleware. |
useMcpServer() | Mid-session register/unregister tools. |
useMcpSession<T>() | Per-session storage (sessions must be enabled). |
useMcpLogger(name?) | Client notifications + server-side wide events. |
useMcpElicitation() | Form / URL / confirm prompts to the client. |
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. 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).
- CLI Inspector:
npx @modelcontextprotocol/inspector http://localhost:3000/mcp - curl smoke test:
curl -X POST … -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
For an authenticated endpoint, pass headers to the DevTools launcher:
const token = process.env.MCP_TOKEN
export default defineNuxtConfig({
mcp: {
inspector: {
headers: { Authorization: token ? `Bearer ${token}` : undefined },
},
},
})
Learn More
How can the creator link this skill?
Add the canonical catalog link to the repository README so users can inspect current installs and available audits. The publishing guide covers the complete discovery path.
<a href="https://skillzs.dev/skills/mcp-toolkit.nuxt.dev/manage-mcp">View manage-mcp on skillZs</a>