---
title: "Custom tools"
description: "Write your own agent tools with defineBackendMcpTool and useBackendMcp — file location, argument schema, scopes, and calling Convex as the signed-in user."
canonical_url: "https://nuxt-backend.dev/docs/agents/mcp/custom-tools"
---
# Custom tools

> Write your own agent tools with defineBackendMcpTool and useBackendMcp — file location, argument schema, scopes, and calling Convex as the signed-in user.

Your own tools sit next to the [built-in ones](https://nuxt-backend.dev/docs/agents/mcp/built-in-tools) on the same OAuth-protected endpoint, with the same session contract: hidden from unauthenticated requests, hidden without their scope, and able to call Convex **as the signed-in user**.

Two utilities do the work. Both are auto-imported in `server/`, and both are exported from `nuxt-backend/mcp` when you prefer an explicit import:

```ts
import { defineBackendMcpTool, useBackendMcp } from 'nuxt-backend/mcp'
```

## Where tool files live

`@nuxtjs/mcp-toolkit` discovers definitions from the file tree: **server/mcp/tools/** in your app, one tool per file (`server/mcp/resources/` and `server/mcp/prompts/` cover the other MCP primitives).

```bash
server/
└── mcp/
    └── tools/
        ├── todos-list.ts
        └── todos-create.ts
```

The module contributes its own directory of built-ins to that same scan through the toolkit's `mcp:definitions:paths` hook, so the two sets merge — nothing to register by hand.

<warning>

The scan covers `server/mcp/` in **every layer** the app extends, and all of it lands in the one handler mounted at `mcp.route` — behind this package's OAuth gate. A layer that ships tools of its own (Docus adds `list-pages` and `get-page`) therefore puts them behind the sign-in challenge, where an anonymous client gets `401`, and there is no second, ungated route to move them to. To leave a layer's tools out, point discovery at a directory the layer has no copy of: the toolkit's own `mcp: { dir: 'agent' }` (top-level in `nuxt.config`, not under `backend.mcp`) scans `server/agent/` instead, and your tools then live in `server/agent/tools/`. The built-ins are unaffected — the module contributes them by absolute path.

</warning>

<tip>

`name` and `title` are derived from the filename when you omit them, so `server/mcp/tools/todos-create.ts` becomes the `todos-create` tool. A nested folder becomes the tool's `group` (`server/mcp/tools/admin/purge.ts` → group `admin`).

</tip>

## The definition

```ts [server/mcp/tools/todos-create.ts]
import { z } from 'zod'
import { api } from '#backend/api'

export default defineBackendMcpTool({
  description: 'Create a todo for the signed-in user.',
  scope: 'todos:write',
  annotations: { destructiveHint: false },
  inputSchema: {
    text: z.string().min(1).max(500).describe('What the todo says.'),
  },
  handler: async ({ text }) => {
    const { fetchMutation } = useBackendMcp()
    const id = await fetchMutation(api.todos.create, { text })
    return { id, text }
  },
})
```

<table>
<thead>
  <tr>
    <th>
      Field
    </th>
    
    <th>
      What it does
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        scope
      </code>
    </td>
    
    <td>
      The OAuth scope this tool requires. Agents without it never see the tool in <code>
        tools/list
      </code>
      
      , and a direct call is refused with <code>
        403
      </code>
      
       — the check runs again inside the handler
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        inputSchema
      </code>
    </td>
    
    <td>
      A <strong>
        Zod raw shape
      </strong>
      
       (an object of Zod validators, not <code>
        z.object(...)
      </code>
      
      ). Arguments are validated and typed for the handler; <code>
        .describe()
      </code>
      
       text reaches the model
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        description
      </code>
      
       / <code>
        title
      </code>
      
       / <code>
        group
      </code>
      
       / <code>
        tags
      </code>
    </td>
    
    <td>
      What the client shows and how it groups the tool
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        annotations
      </code>
    </td>
    
    <td>
      MCP behaviour hints — <code>
        readOnlyHint
      </code>
      
      , <code>
        destructiveHint
      </code>
      
      , <code>
        idempotentHint
      </code>
      
      , <code>
        openWorldHint
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        enabled
      </code>
    </td>
    
    <td>
      An extra visibility guard <code>
        (event) => boolean
      </code>
      
      . It runs <strong>
        after
      </strong>
      
       the session/scope gate, so use it for your own rules (a feature flag, a role)
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        outputSchema
      </code>
      
      , <code>
        cache
      </code>
      
      , <code>
        inputExamples
      </code>
      
      , <code>
        _meta
      </code>
    </td>
    
    <td>
      Passed through to the toolkit
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        handler
      </code>
    </td>
    
    <td>
      <code>
        (args, extra) => …
      </code>
      
      . Return a plain value (string, number, object, array — wrapped into MCP content for you) or a full <code>
        CallToolResult
      </code>
      
      . A thrown <code>
        createError({ statusCode })
      </code>
      
       becomes an MCP <code>
        isError
      </code>
      
       result carrying the status
    </td>
  </tr>
</tbody>
</table>

Zod ships as a dependency of `nuxt-backend`, so there is nothing extra to install.

## `useBackendMcp(event?)`

The authenticated agent behind the current request:

```ts
interface BackendMcp {
  session: {
    userId: string      // the signed-in user the agent acts as
    clientId: string    // the OAuth client (agent) id
    scopes: string[]    // what the user consented to
    convexToken: string // the short-lived Convex JWT
  }
  hasScope: (scope: string) => boolean
  fetchQuery:    (query,    args?) => Promise<…>
  fetchMutation: (mutation, args?) => Promise<…>
  fetchAction:   (action,   args?) => Promise<…>
}
```

The three fetchers are the ordinary Convex fetchers **bound to the agent's user JWT** — so deployment functions see `ctx.auth`, the active workspace and the billing entity exactly as they would for a web session. Pass the H3 event where you have it; without one the current request comes from Nitro's async context. Called outside the gate it throws a `401` — the gate answers unauthenticated requests before any tool runs, so that only trips when you call it from an ordinary route.

## Custom scopes

`scope` may be any string, but the OAuth provider has to **offer** it — otherwise no agent can ever consent to it. Add yours to the provider alongside the package's built-in catalogue:

```ts [backend/auth.ts]
export const { authComponent, createAuth, mcp /* … */ } = setupAuth(components, query, {
  email,
  mcp: {
    scopes: ['todos:read', 'todos:write'],
  },
  rateLimiter,
})
```

Extras are appended to the built-in scopes on both the consent offer and the advertised `scopes_supported` metadata. Reusing an existing scope (`profile`, `billing:read`, `workspace:read`, …) needs no configuration at all.

## A complete example

A read tool and a write tool over an app table, gated by their own scopes.

```ts [server/mcp/tools/todos-list.ts]
import { z } from 'zod'
import { api } from '#backend/api'

export default defineBackendMcpTool({
  description: 'List the signed-in user\'s todos, newest first.',
  scope: 'todos:read',
  annotations: { readOnlyHint: true },
  inputSchema: {
    limit: z.number().int().min(1).max(100).default(20)
      .describe('How many todos to return.'),
  },
  handler: async ({ limit }) => {
    const { fetchQuery } = useBackendMcp()
    const todos = await fetchQuery(api.todos.list, { limit })
    return { todos }
  },
})
```

```ts [server/mcp/tools/todos-complete.ts]
import { createError } from 'h3'
import { z } from 'zod'
import { api } from '#backend/api'

export default defineBackendMcpTool({
  description: 'Mark one of the signed-in user\'s todos as done.',
  scope: 'todos:write',
  annotations: { idempotentHint: true, destructiveHint: false },
  inputSchema: {
    id: z.string().describe('Todo id from todos-list.'),
  },
  // Extra visibility rule on top of the session/scope gate.
  enabled: event => useBackendMcp(event).hasScope('todos:read'),
  handler: async ({ id }) => {
    const { fetchMutation, session } = useBackendMcp()
    const updated = await fetchMutation(api.todos.complete, { id })
    if (!updated) throw createError({ statusCode: 404, message: 'No such todo.' })
    return { ok: true, id, by: session.userId }
  },
})
```

```ts [backend/todos.ts]
import { query, mutation } from './_generated/server'
import { v } from 'convex/values'

// Ordinary Convex functions — the agent's JWT makes ctx.auth resolve to the
// signed-in user, so the same authorization you already wrote applies.
export const list = query({
  args: { limit: v.number() },
  handler: async (ctx, { limit }) => {
    const identity = await ctx.auth.getUserIdentity()
    if (!identity) return []
    return await ctx.db
      .query('todos')
      .withIndex('by_user', q => q.eq('userId', identity.subject))
      .order('desc')
      .take(limit)
  },
})
```

<warning>

A tool is a prompt-injection surface: the model chooses its arguments from text it read somewhere. Keep write tools narrow (one action, validated arguments), prefer app-relative paths and ids over free-form URLs, and let the Convex function — not the tool — own authorization. The built-in tools follow the same rule: `profile-update` changes the display name only, and the checkout tools return links instead of taking payment.

</warning>

Definitions are collected when the Nuxt build starts, so restart the dev server after adding a tool file. A connected agent then sees the new tool on its next `tools/list` — [connect a client](https://nuxt-backend.dev/docs/agents/mcp/connect-a-client) to try it.


## Sitemap

See the full [sitemap](https://nuxt-backend.dev/sitemap.md) for all pages.
