| name | manage-mcp |
| description | Manage MCP servers in Nuxt - setup, create, customize with middleware, review, and troubleshoot |
Manage MCP
Complete skill for managing Model Context Protocol (MCP) servers in Nuxt applications. Setup, create, customize with middleware and handlers, review, and troubleshoot.
When to Use
- Setup: "Setup an MCP server in my Nuxt app"
- Create: "Create a tool to calculate BMI" / "Add a resource to read the README"
- Customize: "Add authentication to my MCP server" / "Create middleware for rate limiting"
- Review: "Review my MCP implementation" / "Check for best practices"
- Troubleshoot: "My auto-imports aren't working" / "Cannot connect to endpoint"
- Test: "Create tests for my MCP tools"
Setup MCP Server
Installation
Automatic (recommended):
npx nuxt module add mcp-toolkit
Manual:
pnpm add -D @nuxtjs/mcp-toolkit zod
Add to nuxt.config.ts:
export default defineNuxtConfig({
modules: ['@nuxtjs/mcp-toolkit'],
mcp: {
name: 'My MCP Server',
},
})
Directory Structure
server/mcp/
├── tools/ # Actions AI can perform
│ ├── admin/ # Subdirectory → group: 'admin'
│ └── content/ # Subdirectory → group: 'content'
├── resources/ # Data AI can read
└── prompts/ # Message templates
Verification
- Start:
pnpm dev
- Check:
http://localhost:3000/mcp (should redirect)
- Open DevTools (Shift+Alt+D) → MCP tab
Create Tools
Tools are functions AI assistants can call.
Basic Structure
import { z } from 'zod'
export default defineMcpTool({
description: 'What the tool does',
inputSchema: {
param: z.string().describe('Parameter description'),
},
handler: async ({ param }) => {
return 'Result'
},
})
Input Patterns
name: z.string().describe('User name')
limit: z.number().default(10).describe('Max results')
format: z.enum(['json', 'xml']).describe('Format')
tags: z.array(z.string()).describe('Tags')
Error Handling
if (!param) {
throw createError({ statusCode: 400, message: 'Error: param required' })
}
Annotations
Behavioral hints that help MCP clients decide when to prompt for confirmation:
export default defineMcpTool({
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: false,
openWorldHint: false,
},
})
Common patterns: read-only tools → readOnlyHint: true, create → idempotentHint: false, update → idempotentHint: true, delete → destructiveHint: true, idempotentHint: true.
Input Examples
Type-safe usage examples that help AI models fill in parameters correctly:
export default defineMcpTool({
inputSchema: {
title: z.string().describe('Todo title'),
content: z.string().optional().describe('Description'),
},
inputExamples: [
{ title: 'Buy groceries', content: 'Milk, eggs, bread' },
{ title: 'Fix login bug' },
],
})
Groups and Tags
Organize tools with group and tags for filtering and progressive discovery:
export default defineMcpTool({
group: 'admin',
tags: ['destructive', 'user-management'],
description: 'Delete a user account',
})
Groups are auto-inferred from subdirectories: server/mcp/tools/admin/delete-user.ts → group: 'admin'. Explicit group takes precedence.
Caching
export default defineMcpTool({
cache: '5m',
})
See detailed examples →
Create Resources
Resources expose read-only data.
File Resource
import { readFile } from 'node:fs/promises'
export default defineMcpResource({
description: 'Read a file',
uri: 'file:///README.md',
mimeType: 'text/markdown',
handler: async (uri: URL) => {
const content = await readFile('README.md', 'utf-8')
return {
contents: [{
uri: uri.toString(),
text: content,
mimeType: 'text/markdown',
}],
}
},
})
API Resource
export default defineMcpResource({
description: 'Fetch API data',
uri: 'api:///users',
mimeType: 'application/json',
cache: '5m',
handler: async (uri: URL) => {
const data = await $fetch('https://api.example.com/users')
return {
contents: [{
uri: uri.toString(),
text: JSON.stringify(data, null, 2),
mimeType: 'application/json',
}],
}
},
})
Dynamic Resource
import { z } from 'zod'
export default defineMcpResource({
description: 'Fetch by ID',
uriTemplate: {
uriTemplate: 'user:///{id}',
arguments: {
id: z.string().describe('User ID'),
},
},
handler: async (uri: URL, args) => {
const user = await fetchUser(args.id)
return {
contents: [{
uri: uri.toString(),
text: JSON.stringify(user),
mimeType: 'application/json',
}],
}
},
})
See detailed examples →
Create Prompts
Prompts are reusable message templates.
Static Prompt
export default defineMcpPrompt({
description: 'Code review',
handler: async () => {
return {
messages: [{
role: 'user',
content: {
type: 'text',
text: 'Review this code for best practices.',
},
}],
}
},
})
Dynamic Prompt
import { z } from 'zod'
export default defineMcpPrompt({
description: 'Custom review',
inputSchema: {
language: z.string().describe('Language'),
focus: z.array(z.string()).describe('Focus areas'),
},
handler: async ({ language, focus }) => {
return {
messages: [{
role: 'user',
content: {
type: 'text',
text: `Review my ${language} code: ${focus.join(', ')}`,
},
}],
}
},
})
See detailed examples →
Middleware & Handlers
Customize MCP behavior with middleware and handlers for authentication, logging, rate limiting, and more.
Basic Middleware
export default defineMcpMiddleware({
handler: async (event, next) => {
console.log('MCP Request:', event.path)
const token = event.headers.get('authorization')
if (!token) {
return createError({ statusCode: 401, message: 'Unauthorized' })
}
return next()
},
})
Custom Handler
export default defineMcpHandler({
name: 'custom-mcp',
route: '/mcp/custom',
handler: async (event) => {
return {
tools: await loadCustomTools(),
resources: [],
prompts: [],
}
},
})
Common Use Cases
- Authentication: API keys, JWT tokens
- Rate limiting: Per IP or per user
- Logging: Request/response tracking
- CORS: Cross-origin configuration
- Multiple endpoints: Public/admin separation
See detailed middleware guide →
Interactive Composables
useMcpElicitation()
Ask the connected client for structured input mid-request, or send the user to a URL.
import { z } from 'zod'
export default defineMcpTool({
name: 'create_release',
inputSchema: { name: z.string() },
handler: async ({ name }) => {
const elicit = useMcpElicitation()
const result = await elicit.form({
message: `Pick a channel for "${name}"`,
schema: {
channel: z.enum(['stable', 'beta']).describe('Release channel'),
},
})
if (result.action !== 'accept') return 'Cancelled.'
return `Released "${name}" on ${result.content.channel}.`
},
})
- Form mode: pass a Zod raw shape, the response is validated and typed.
- URL mode:
elicit.url({ message, url }) — opt-in per spec, gate with elicit.supports('url').
- Confirm:
await elicit.confirm('Continue?') returns a boolean.
- Capability check:
elicit.supports('form' | 'url') — always false before init completes.
- Errors: catch
McpElicitationError (code: 'unsupported' | 'invalid-schema' | 'invalid-response') to fall back when the client doesn't support elicitation.
- Schema must be a flat object of primitives (string/number/boolean), enums, or string-enum arrays — nested objects are rejected by the spec.
See elicitation docs →
Observability
useMcpLogger()
Split-channel logger: notify the connected client and capture structured wide events (powered by evlog, an optional peer dependency — install with pnpm add evlog to enable wide events).
export default defineMcpTool({
name: 'charge_card',
inputSchema: { userId: z.string(), amount: z.number().int() },
handler: async ({ userId, amount }) => {
const log = useMcpLogger('billing')
log.set({ user: { id: userId }, billing: { amount } })
log.event('charge_started', { amount })
await log.notify.info({ msg: 'starting charge', amount })
const receipt = await charge(userId, amount)
return `Charged ${amount}.`
},
})
- Client channel (
log.notify): notify(level, data, logger?) plus notify.debug / notify.info / notify.warning / notify.error shortcuts. Always resolves, never throws — respects the client's logging/setLevel per session. Works with or without evlog.
- Server channel (requires
evlog installed): set(fields) accumulates context onto the request's wide event; event(name, fields?) captures a discrete event; evlog exposes the full request logger. These throw McpObservabilityNotEnabledError when observability is off.
- Wide events are auto-tagged with
mcp.transport, mcp.route, mcp.session_id, mcp.method, mcp.request_id, and mcp.tool / mcp.resource / mcp.prompt based on the JSON-RPC payload (no user code required).
mcp.logging modes: omit (auto-detect — on if evlog installed), true / object (force on, throws at build if missing), false (force off — notify keeps working).
Ship to a backend (drains)
Ship every MCP wide event to Axiom, Sentry, OTLP, HyperDX, Datadog, Better Stack, or PostHog with a single Nitro plugin. Each adapter lives under evlog/adapters/* and is registered on the evlog:drain hook:
import { createAxiomDrain } from 'evlog/adapters/axiom'
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('evlog:drain', createAxiomDrain())
})
The hook is additive — register multiple drains in parallel. Custom drains are just (ctx) => Promise<void> registered on the same hook.
See evlog.dev → for the full list of adapters, env-var conventions, sampling, and redaction.
See logging docs →
Review & Best Practices
MCP code review (agents & humans)
When reviewing or modernizing server/mcp/**, walk through this list so implementations stay aligned with current toolkit behavior and Nuxt server typing.
Tool return values
- Prefer direct returns:
string, number, boolean, plain objects, or arrays. The module wraps them into CallToolResult (JSON is pretty-printed for objects).
- Avoid deprecated helpers unless you must support very old code:
textResult, jsonResult, errorResult — migrate to direct values and throw createError({ statusCode, message }) (or throw new Error(...)) for failures.
- Reserve full
CallToolResult (content, structuredContent, embedded resources, isError) for cases that need explicit MCP shapes.
Async context & server composables
useMcpServer() needs nitro.experimental.asyncContext: true in nuxt.config. If TypeScript reports Promise<McpServerHelper> (common with server auto-imports), use const mcp = await useMcpServer() before registerTool / removeTool / etc.
useMcpSession() / useEvent(): await if the IDE or vue-tsc indicates a Promise; keep session and event usage inside tool, resource, or prompt handlers.
Hygiene
- Every
await on a Promise-backed call in handlers (DB, fetch, composables that return promises).
- Zod: required
.describe() on schema fields for good model UX; use inputExamples for non-trivial shapes.
- Annotations: set
readOnlyHint, destructiveHint, idempotentHint, openWorldHint honestly.
- Run
pnpm eslint / nuxi typecheck on the app after refactors (catch deprecated APIs and missing await early).
Tool Checklist
✅ Use kebab-case filenames
✅ Add .describe() to all Zod fields
✅ Return plain values or throw createError for failures (not deprecated errorResult)
✅ Add caching for expensive ops
✅ Clear, actionable descriptions
✅ Validate all inputs
✅ Add annotations (readOnlyHint, destructiveHint, etc.)
✅ Add inputExamples for tools with optional/complex params
✅ nitro.experimental.asyncContext: true when using useMcpServer()
❌ Generic descriptions
❌ Skip error handling
❌ Expose sensitive data
❌ No input validation
❌ textResult / jsonResult / errorResult in new code (deprecated)
Resource Checklist
✅ Descriptive URIs (config:///app)
✅ Set appropriate MIME types
✅ Enable caching when needed
✅ Handle errors gracefully
✅ Use URI templates for collections
❌ Unclear URI schemes
❌ Skip MIME types
❌ Expose sensitive data
❌ Return huge datasets without pagination
Prompt Checklist
✅ Clear descriptions
✅ Meaningful parameters
✅ Default values where appropriate
✅ Single, focused purpose
✅ Reusable design
❌ Overly complex
❌ Skip descriptions
❌ Mix multiple concerns
Troubleshooting
Auto-imports Not Working
Fix:
- Check
modules: ['@nuxtjs/mcp-toolkit'] in config
- Restart dev server
- Files in
server/mcp/ directory?
- Run
pnpm nuxt prepare
Endpoint Not Accessible
Fix:
- Dev server running?
- Test:
curl http://localhost:3000/mcp
- Check
enabled: true in config
- Review server logs
Validation Errors
Fix:
- Required fields provided?
- Types match schema?
- Use
.optional() for optional fields
- Enum values exact match?
Tool Not Discovered
Fix:
- File extension
.ts or .js?
- Using
export default?
- File in correct directory?
- Restart dev server
See detailed troubleshooting →
Testing with Evals
Setup
pnpm add -D evalite vitest @ai-sdk/mcp ai
Add to package.json:
{
"scripts": {
"eval": "evalite",
"eval:ui": "evalite watch"
}
}
Basic Test
Create test/mcp.eval.ts:
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,
}),
],
})
Running
pnpm dev
pnpm eval
pnpm eval:ui
See detailed testing guide →
Quick Reference
Common Commands
npx nuxt module add mcp-toolkit
pnpm dev
curl http://localhost:3000/mcp
pnpm nuxt prepare
pnpm eval
Configuration
export default defineNuxtConfig({
mcp: {
name: 'My Server',
route: '/mcp',
enabled: true,
dir: 'mcp',
},
})
Debug Tools
- DevTools: Shift+Alt+D → MCP tab
- Logs: Check terminal
- curl: Test endpoint
Learn More