nuxt-modules

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.

nuxt-modules 170 12 Updated 3w ago

Resources

1
GitHub

Install

npx skillscat add nuxt-modules/mcp-toolkit/apps-docs-skills-manage-mcp

Install via the SkillsCat registry.

SKILL.md

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:

typescript [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

  1. Start the dev server: pnpm dev
  2. Hit the endpoint: curl http://localhost:3000/mcp (responds to MCP JSON-RPC)
  3. 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

typescript [server/mcp/tools/echo.ts]
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.tsname: '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 folderserver/mcp/tools/admin/delete-user.tsgroup: '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)

typescript [server/mcp/resources/readme.ts]
export default defineMcpResource({
  description: 'Project README file',
  file: 'README.md', // URI, MIME type, and handler auto-generated
})

Standard Resource (custom handler)

typescript [server/mcp/resources/config.ts]
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:

typescript [server/mcp/resources/user.ts]
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

typescript [server/mcp/prompts/code-review.ts]
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

typescript [server/mcp/prompts/review.ts]
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)
typescript [server/mcp/handlers/admin/index.ts]
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():

typescript [server/mcp/index.ts]
// 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.

See middleware patterns →.


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.

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 →.


Sessions

Stateful MCP — server assigns an Mcp-Session-Id and remembers data across tool calls in the same session.

typescript [nuxt.config.ts]
export default defineNuxtConfig({
  mcp: {
    sessions: true, // or { maxDuration: 30 * 60_000, maxSessions: 1000 }
  },
})
typescript [server/mcp/tools/remember.ts]
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 modeelicit.url({ message, url }), opt-in per spec; gate with elicit.supports('url').
  • Confirmawait elicit.confirm('Continue?') returns boolean.
  • 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. Throws McpObservabilityNotEnabledError when 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 sets event.context.user / event.context.session.

Setup

::code-group

pnpm add evlog
npm install evlog

::

typescript [nuxt.config.ts]
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:

typescript [server/plugins/evlog-axiom.ts]
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.

vue [app/mcp/palette.vue]
<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.

useMcpApp<T>() exposes data, loading, error, hostContext, callTool(name, params), sendPrompt(prompt), and openLink(url).

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:

typescript [nuxt.config.ts]
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:

typescript [server/routes/.well-known/mcp/server-card.json.get.ts]
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`.

typescript [server/mcp/handlers/codemode/index.ts]
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

  1. File is under server/mcp/{tools,resources,prompts}/ (or under a handlers/<name>/ subfolder)?
  2. export default (not a named export)?
  3. Restart dev server and run pnpm nuxt prepare.

Auto-imports not working

  1. '@nuxtjs/mcp-toolkit' listed in modules in nuxt.config.ts?
  2. Run pnpm dev:prepare (or pnpm nuxt prepare) to regenerate type stubs.
  3. Restart the TypeScript server in your IDE.
  4. 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
json [package.json]
{
  "scripts": {
    "eval": "evalite",
    "eval:ui": "evalite watch"
  }
}
typescript [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 }),
  ],
})

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

typescript [nuxt.config.ts]
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
  },
  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"}'

Learn More