skillZs
★ LIVE SKILL TAGS ★
>>> LIVE SKILLS INDEX <<<
* OPEN SOURCE *
NO LOGIN, NO TRACKING
※ REAL INSTALL DATA ※
← back to all skills
mcp-toolkit.nuxt.dev135 installs

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-mcp
view source ↗

Is 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

  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

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:

ReturnWrapped as
string / number / boolean{ content: [{ type: 'text', text: String(v) }] }
Plain object / arrayJSON-stringified into a text content item
imageResult(base64, mime){ content: [{ type: 'image', data, mimeType }] }
audioResult(base64, mime){ content: [{ type: 'audio', data, mimeType }] }
Full CallToolResultPassed through (use for structuredContent, embedded resources, multi-content)
Thrown errorCaught 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.

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.

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 with elicit.supports('url').
  • Confirm — await 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

::

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

  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
{
  "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)

HelperPurpose
defineMcpTool / defineMcpResource / defineMcpPromptDeclare a definition (auto-discovered).
defineMcpHandlerCustom handler with middleware / dynamic tools.
defineMcpApp (in app/mcp/*.vue)Interactive Vue widget.
imageResult / audioResultWrap binary content in a tool response.
completableArgument autocomplete on prompts.
extractToolNamesParse 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 / listMcpDefinitionsJSON-friendly summaries (catalog endpoints).
getMcpTools / getMcpResources / getMcpPromptsRaw definitions (feed back into a handler).
getSdkServerReach the low-level SDK Server from an McpServer (advanced).

Nitro Hooks

HookFires
mcp:config:resolvedPer request, after dynamic resolvers — mutate config.tools / resources / prompts / instructions / icons / name. X-MCP-Tools is applied after this hook.
mcp:server:createdPer 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

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>