Suppose you keep a todo list in a Nuxt app. You want to ask the assistant in your editor "what is still open?" and get an answer from the real data, or say "add a task to fix the footer" and have it added. For that, the assistant needs a way to call into your app. The Model Context Protocol (MCP) gives it one, and the new @nuxtjs/mcp-toolkit module turns a folder of TypeScript files into an MCP server inside your Nuxt app.
You will build two tools, try them without any assistant, and connect Cursor or VS Code to them. The code uses Nuxt 4.2 and @nuxtjs/mcp-toolkit 0.5.2.
What MCP is and what the toolkit adds
The Model Context Protocol is an open standard for connecting AI assistants to data and tools. A server offers three kinds of things:
- Tools: functions the assistant may call.
- Resources: data it may read by URI.
- Prompts: reusable message templates.
Clients that speak the protocol, such as Cursor, VS Code and the desktop chat apps, can use a server's tools without any code written for that server.
The Nuxt team runs a server of their own at https://nuxt.com/mcp and described how they built it in Building an MCP Server for Nuxt on 13 November: tools that list and fetch documentation pages, resources for the docs and blog, and prompts that find the right page for a topic.
The module packages that pattern. You add it to nuxt.config.ts and put files in server/mcp/tools/, server/mcp/resources/ and server/mcp/prompts/. The module discovers them, validates tool and prompt arguments with Zod, and serves everything at /mcp from the same Nitro server that already runs your API routes. There is no second process to deploy, but there must be a server: under nuxt generate the module switches itself off with a warning.
Install the module
One command installs the package and adds it to the modules list:
npx nuxt module add mcp-toolkit
To do it by hand, install @nuxtjs/mcp-toolkit and its peer dependency zod, which must be Zod 4, then register the module. The module's docs ask for Nuxt 3 or 4 and Node 18 or newer.
Then give the server a name in nuxt.config.ts; the module sends it to clients when they connect. DevTools stays on for the inspector you will open later:
export default defineNuxtConfig({
modules: ['@nuxtjs/mcp-toolkit'],
devtools: { enabled: true },
mcp: {
name: 'Todo app',
},
})
The mcp block has six options, and name is the only one worth setting on day one. The other five, with their defaults:
route, the endpoint path:/mcp.dir, the folder underserver/that the module scans:mcp.version, reported to clients when they connect:1.0.0.browserRedirect, where a person who opens the endpoint in a browser is sent:/.enabled, whether the server runs at all:true.
To check the route, start npm run dev and open http://localhost:3000/mcp in a browser. Being sent to the home page tells you the route is live. Until the first tool exists, the terminal also warns that no MCP definitions were found.
Keep the todos in a server utility
To keep the focus on MCP rather than databases, the todos live in memory in a server utility that your API routes can share; the list resets when the server restarts. In the Nuxt 4 layout server/ stays at the project root, next to app/:
export type Todo = { id: number; title: string; done: boolean }
const todos: Todo[] = [
{ id: 1, title: 'Write the lesson on slots', done: true },
{ id: 2, title: 'Record the quiz questions', done: false },
]
export function listTodos(filter: 'all' | 'open' | 'done' = 'all'): Todo[] {
if (filter === 'all') return todos
return todos.filter(todo => (filter === 'done' ? todo.done : !todo.done))
}
export function addTodo(title: string): Todo {
const todo = { id: Date.now(), title, done: false }
todos.push(todo)
return todo
}
In a real app these two functions would call your database. They would then return promises, so the tools would await them; nothing else in the MCP files changes.
Write a read-only tool: list-todos
Each file in server/mcp/tools/ exports one tool. defineMcpTool is auto-imported in server files, as are the result helpers used below. z is not, so import it from zod:
import { z } from 'zod'
import { listTodos } from '../../utils/todos'
export default defineMcpTool({
name: 'list-todos',
description: 'List the todos, optionally only the open or only the done ones. Use this before answering any question about what is planned, open or finished.',
inputSchema: {
filter: z.enum(['all', 'open', 'done']).default('all').describe('Which todos to return'),
},
annotations: {
readOnlyHint: true,
},
handler: async ({ filter }) => jsonResult(listTodos(filter)),
})
What each field does:
nameis the identifier the assistant calls. Leave it out and the module derives it from the file name, solist-todos.tsgetslist-todosanyway.descriptionis what the assistant reads to decide whether to call the tool. Write it for a reader who has never seen your app.inputSchemais a plain object with one Zod schema per parameter. The arguments are checked against it before the handler runs, and the handler seesfiltertyped as'all' | 'open' | 'done'. Because of.default('all'), the assistant may leave it out. The.describe()text travels with the schema to the client, so the assistant knows what each parameter means.readOnlyHint: truetells the client that calling the tool changes nothing, so it may skip asking the user for confirmation.handlerreceives the validated arguments and must return a result object with acontentarray.jsonResult()builds one from any value by serialising it as pretty-printed JSON;textResult()does the same for a string.
For a tool that reads something expensive, add cache: '1h' (or any duration ms understands) and the module caches the response through Nitro's cache, keyed by the arguments.
Write a tool that changes data: add-todo
The second tool writes. Zod refuses an empty title before the handler runs, the handler refuses a duplicate with an error result, and the annotations describe the kind of change it makes:
import { z } from 'zod'
import { addTodo, listTodos } from '../../utils/todos'
export default defineMcpTool({
name: 'add-todo',
description: 'Add a new todo with the given title. Call this only when the user asks to add, create or remember a task.',
inputSchema: {
title: z.string().trim().min(1).max(120).describe('What needs doing, in a few words'),
},
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
handler: async ({ title }) => {
const exists = listTodos().some(todo => todo.title.toLowerCase() === title.toLowerCase())
if (exists) return errorResult(`There is already a todo called "${title}".`)
const todo = addTodo(title)
return textResult(`Added todo ${todo.id}: ${todo.title}`)
},
})
errorResult() returns the message with isError: true, which tells the assistant the call ran but did not do what was asked, so it can tell the user or try something else. The toolkit docs return an error result from a catch block in the same way. If an exception escapes instead, the MCP SDK under the module still turns it into an error result, but with the raw exception message (a database error, say) rather than a sentence you chose.
The annotations are hints a client may use to decide when to ask the user first. destructiveHint: false says the tool only adds data. Leave it out and the hint counts as true, which tells the client the tool may delete or overwrite data. idempotentHint: false is the default and promises nothing about repeated calls. Here the duplicate check makes a second call with the same title harmless, so true would also be accurate.
The description is the part people get wrong. Here is the mistake and the fix, on the same tool:
description: 'Todo tool',
Nothing crashes. The assistant simply has no idea when to use the tool, so it calls it when the user asks what is open, or never calls it at all. The fix is to say what it does and when to use it:
description: 'Add a new todo with the given title. Call this only when the user asks to add, create or remember a task.',
Most of the nuxt.com tools go further and add a "WHEN NOT TO USE" line to the description. Treat the description as documentation for a colleague who will only ever see the tool list.
Test the tools in the MCP Inspector
Start (or restart) the dev server, and the terminal reports that /mcp is enabled with 2 tools. Then use the official MCP Inspector, which the module adds to Nuxt DevTools and connects to your endpoint with no setup:
- Open Nuxt DevTools in the running app, find the MCP Inspector tab in the Server section and click Launch Inspector. The first launch downloads the inspector with
npx, so give it a few seconds. - Browse the tool list, pick
list-todos, setfiltertoopenand run it. The response shows the JSON fromjsonResult(). - Run
add-todowith a title, thenlist-todosagain. The new item is there. - Run
add-todowith the same title, and you get the error result.
The inspector keeps a history of every request and response, which is the fastest way to see what an assistant will see. It uses port 6274 for its interface and 6277 for its proxy. If either is taken, start the dev server with CLIENT_PORT and SERVER_PORT set to free ports.
Connect Cursor or VS Code
Clients connect over HTTP, so the only thing they need is the URL. For Cursor, add the server to ~/.cursor/mcp.json:
{
"mcpServers": {
"todo-app": {
"url": "http://localhost:3000/mcp"
}
}
}
For VS Code the file is .vscode/mcp.json in the project, with a slightly different shape:
{
"servers": {
"todo-app": {
"type": "http",
"url": "http://localhost:3000/mcp"
}
}
}
Reload the editor and ask "what is still open on my list?" The assistant should call list-todos with filter: "open" and answer from the result.
The module also serves /mcp/deeplink, a redirect that installs the server in Cursor or VS Code in one click, and registers an InstallButton component you can put in your own docs to link to it.
Protect the endpoint before you deploy
The module has no authentication of its own; its six options do not include one. Whatever a tool can do, anyone who can reach /mcp can do. For a public site that means read-only tools over data that is public anyway, as on nuxt.com. A tool that writes, like add-todo, belongs behind your own server middleware that checks a session or a token on the /mcp route, or in an app that is only reachable on your own network.