---
title: "MCP server"
description: "The OAuth-protected MCP endpoint the module mounts — what an agent connects to, the two discovery documents, and the options that move or remove it."
canonical_url: "https://nuxt-backend.dev/docs/agents/mcp"
---
# MCP server

> The OAuth-protected MCP endpoint the module mounts — what an agent connects to, the two discovery documents, and the options that move or remove it.

Every app built on `nuxt-backend` exposes an **MCP endpoint** at `/mcp`, served by Nitro through [`@nuxtjs/mcp-toolkit`](https://mcp-toolkit.nuxt.dev) (a declared module dependency — you never install or configure it yourself). An agent that connects there signs in through OAuth as one of **your users** and can call the [built-in tools](https://nuxt-backend.dev/docs/agents/mcp/built-in-tools) — and [your own](https://nuxt-backend.dev/docs/agents/mcp/custom-tools) — with the scopes that user consented to.

It is always there, in dev and in production: everything the built-in tools expose is already reachable through the product UI under the same user auth, and OAuth adds per-client consent on top.

## What an agent connects to

<table>
<thead>
  <tr>
    <th>
      URL
    </th>
    
    <th>
      Serves
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        https://your-app.com/mcp
      </code>
    </td>
    
    <td>
      The MCP endpoint (streamable HTTP transport). Requires a Bearer token; answers <code>
        401
      </code>
      
       with a discovery challenge otherwise
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        https://your-app.com/.well-known/oauth-protected-resource
      </code>
    </td>
    
    <td>
      RFC 9728 protected-resource metadata — points the client at the authorization server
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        https://your-app.com/.well-known/oauth-authorization-server
      </code>
    </td>
    
    <td>
      RFC 8414 authorization-server metadata — the Better Auth OAuth provider's own document
    </td>
  </tr>
</tbody>
</table>

That is the entire handshake: a client hits `/mcp`, gets the challenge, follows the two documents, registers itself, runs the authorization-code flow against your app's login page, and comes back with a token. See [OAuth & consent](https://nuxt-backend.dev/docs/agents/mcp/oauth-and-consent) for the flow end to end, and [Connect a client](https://nuxt-backend.dev/docs/agents/mcp/connect-a-client) for the commands.

<note>

The endpoint lives on your **app origin**, not on the Convex site URL. Discovery documents, checkout links, and OAuth redirects are all built from that origin, which is why `SITE_URL` must be the public app URL in production.

</note>

## What the module mounts

Three server middleware, registered globally and matched in-handler (each one early-returns on every other path, so the global hop is one string compare):

<table>
<thead>
  <tr>
    <th>
      Middleware
    </th>
    
    <th>
      Owns
    </th>
    
    <th>
      Behaviour
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        mcp/gate
      </code>
    </td>
    
    <td>
      <code>
        mcp.route
      </code>
      
       and everything under it
    </td>
    
    <td>
      Reads the <code>
        Authorization: Bearer …
      </code>
      
       header, exchanges the token for a Convex-authenticated session, and attaches it to <code>
        event.context.backendMcp
      </code>
      
      . No/invalid Bearer → <code>
        401
      </code>
      
       with <code>
        WWW-Authenticate: Bearer resource_metadata="…"
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        mcp/protected-resource
      </code>
    </td>
    
    <td>
      <code>
        /.well-known/oauth-protected-resource
      </code>
      
       (+ the path-suffix form)
    </td>
    
    <td>
      Serves the RFC 9728 document locally, with <code>
        resource
      </code>
      
       set to the app origin, <code>
        authorization_servers
      </code>
      
       pointing at the auth proxy base, <code>
        jwks_uri
      </code>
      
      , <code>
        scopes_supported
      </code>
      
      , <code>
        bearer_methods_supported: ["header"]
      </code>
      
      , <code>
        RS256
      </code>
      
      . Cached 5 minutes, CORS-open
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        mcp/authorization-server
      </code>
    </td>
    
    <td>
      <code>
        /.well-known/oauth-authorization-server
      </code>
      
       (+ the path-suffix form)
    </td>
    
    <td>
      Proxies Better Auth's own RFC 8414 document, fetched straight from the Convex site URL and cached 5 minutes. A stale copy is served over an upstream outage; <code>
        503
      </code>
      
       when no Convex site URL is configured, <code>
        502
      </code>
      
       when it is unreachable and nothing is cached
    </td>
  </tr>
</tbody>
</table>

The gate deliberately lets four things through unchallenged, so agents and humans both get sensible behaviour:

- `OPTIONS` preflights.
- A browser `GET` that asks for `text/html` — a human visiting `/mcp` gets the toolkit's page, and because no session is attached, **every tool stays hidden**.
- `{route}/deeplink` and `{route}/badge.svg` — the toolkit's IDE install deeplink and badge.
- Sibling routes: the gate re-checks the full path, so a route like `/mcp-status` is never gated.

The module also enables Nitro's `asyncContext` (the toolkit's helpers, and `useBackendMcp()` inside a tool handler, read the current request from it) and publishes a **server-only** runtime config:

```ts
runtimeConfig.backendMcp = {
  route: '/mcp',                    // the route the gate owns
  authBase: '/api/auth',            // backend.authRoute — used by the discovery documents
  exchangePath: '/mcp/exchange',    // the deployment route Bearers are exchanged at
  scopes: [                         // advertised by the protected-resource document
    'openid', 'profile', 'email',
    'profile:write', 'billing:read', 'billing:checkout', 'workspace:read',
  ],
  functions: {},                    // Convex function overrides from `mcp.functions`
}
```

Nothing of it reaches the client bundle.

### nuxt-security relaxations

`nuxt-security` chains in through the base module. Left alone it would throttle long agent sessions with its IP rate limiter, reject tool arguments that look like markup, and take over CORS on the discovery documents — so the module adds route rules that turn exactly those three off for the agent surface, which has its own guards (OAuth, plus the deployment's `mcp` rate limit):

<table>
<thead>
  <tr>
    <th>
      Route rule
    </th>
    
    <th>
      Relaxes
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        mcp.route
      </code>
      
      , <code>
        {mcp.route}/**
      </code>
    </td>
    
    <td>
      <code>
        security.rateLimiter
      </code>
      
      , <code>
        security.xssValidator
      </code>
      
      , <code>
        security.corsHandler
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        /.well-known/oauth-protected-resource(/**)
      </code>
      
      , <code>
        /.well-known/oauth-authorization-server(/**)
      </code>
    </td>
    
    <td>
      <code>
        security.corsHandler
      </code>
    </td>
  </tr>
</tbody>
</table>

They are `defu`-merged, so your own route rules win, and they are inert when `nuxt-security` isn't installed.

## The Convex side

The Nuxt endpoint is only half of it. The OAuth provider and the token mint live in your deployment:

```ts [backend/auth.ts]
export const { authComponent, createAuth, getAuthUser, mcp /* … */ } = setupAuth(components, query, {
  email,
  rateLimiter,   // seeds the `mcp` limit that guards the exchange
})
```

```ts [backend/http.ts]
registerBackendRoutes(http, {
  auth: { authComponent, createAuth, mcp },   // mcp mounts POST /mcp/exchange
  billing,
  email,
  ai,
})
```

Both are in the scaffolded files already — see [auth setup](https://nuxt-backend.dev/docs/auth/setup) and [webhooks & routes](https://nuxt-backend.dev/docs/ship/production/webhooks). Without the exchange route, the gate answers `502`.

## Configuration

```ts [nuxt.config.ts]
export default defineNuxtConfig({
  modules: ['nuxt-backend'],
  backend: {
    mcp: {
      route: '/mcp',                       // default; trailing slashes are stripped
      name: 'Acme',                        // server name/description/instructions,
      description: 'Acme account agent',   //   forwarded to the MCP toolkit
      instructions: 'Prefer billing-plans before creating a checkout link.',
      sessions: false,                     // stateful transport sessions, default off
      functions: { getCredits: 'myBilling:getCredits' },
    },
  },
})
```

`name`, `description`, `instructions`, `sessions` and `route` are forwarded to `@nuxtjs/mcp-toolkit` as **defaults** — your own top-level `mcp` config in `nuxt.config.ts` wins over them, the same contract as the `convex.*` forwarding. `functions` is read by this package: see [Built-in tools](https://nuxt-backend.dev/docs/agents/mcp/built-in-tools). The full option types are in [Module options](https://nuxt-backend.dev/docs/reference/configuration/module-options).

`name` and `description` are what the server reports in the MCP `initialize` handshake (a client shows them in its server list); `instructions` is the guidance a client typically injects into the model's system prompt. `sessions` turns on the toolkit's **stateful transport** — session ids, SSE streaming and server-to-client notifications; off, every request stands alone and the OAuth gate is the only state. `true` takes the defaults, an object sizes it:

<table>
<thead>
  <tr>
    <th>
      Key
    </th>
    
    <th>
      Default
    </th>
    
    <th>
      Meaning
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        enabled
      </code>
    </td>
    
    <td>
      <code>
        false
      </code>
    </td>
    
    <td>
      Stateful transport on or off
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        maxDuration
      </code>
    </td>
    
    <td>
      <code>
        1_800_000
      </code>
      
       — 30 minutes, in milliseconds
    </td>
    
    <td>
      A session inactive for longer is cleaned up
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        maxSessions
      </code>
    </td>
    
    <td>
      <code>
        1000
      </code>
    </td>
    
    <td>
      Concurrent sessions; past it the endpoint answers <code>
        503
      </code>
    </td>
  </tr>
</tbody>
</table>

Sessions are kept in Nitro storage (`mcp:sessions` and `mcp:sessions-meta`, the in-memory driver unless you mount another), so they are per instance and gone on restart — for more than one instance, mount a shared driver on both keys.

### Changing the route

```ts
backend: { mcp: { route: '/agent' } }
```

The gate follows the route, and so do the toolkit's conveniences (`/agent/deeplink`, `/agent/badge.svg`). The discovery documents stay on their `/.well-known/…` paths — `resource` is the **origin**, not the endpoint URL, so a client requesting any path under it (the moved endpoint included) passes its sub-path resource check.

<note>

The agent surface is **experimental**: the MCP authorization profile it implements (OAuth 2.1 + protected-resource metadata) and `@nuxtjs/mcp-toolkit` are both still moving, so `nuxt-backend/mcp` may change shape in a minor release. The rest of the package holds the stability promise.

</note>

<page-keys>

- Try it live in the playground: [/playground/platform/agents](https://nuxt-backend.dev/playground/platform/agents)
- API reference: [/docs/reference/api/mcp](https://nuxt-backend.dev/docs/reference/api/mcp)
- Source: [https://github.com/qruto/nuxt-backend/blob/main/src/convex/integrations/mcp.ts](https://github.com/qruto/nuxt-backend/blob/main/src/convex/integrations/mcp.ts)

</page-keys>


## Sitemap

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