Skip to main content

MCP on Convex

Serve MCP tools from a Convex HTTP action, with OAuth from Better Auth and authorization inside Convex functions.

@lupinum/better-convex-mcp serves MCP from one Convex HTTP action. ChatGPT, Claude, and other MCP hosts call it with an OAuth access token. Your Convex functions stay the only place that decides what a person may read or change.

This page describes the 1.0 release candidate (the next dist-tag). It implements the final MCP 2026-07-28 contract through exact @modelcontextprotocol/server@2.1.0. The protocol is stable; this integration remains prerelease until the 1.0 release.

The package does not depend on Nuxt, Nitro, Better Auth, or your authorization rules. Better Auth is optional: createBetterConvexAuth gives you a ready token verifier, and any other OAuth provider can supply its own.

How one request flows

  1. The host sends a JSON-RPC request with Authorization: Bearer <token> to the Convex /mcp route.
  2. handleMcpRequest checks the transport, then asks the verifier to check the token. The verifier returns the safe access context and a typed principal.
  3. configureServer registers this request's tools. Each tool calls one internal Convex function and passes the principal.
  4. The internal function re-checks the principal and the application's own rules in the same transaction as its read or write.
LayerResponsible for
Official MCP SDKProtocol parsing, method dispatch, schemas, capabilities, and wire responses.
@lupinum/better-convex-mcpBounded HTTP transport, bearer challenge, exact resource and issuer binding, verifier normalization, and tool helpers.
Token verifierSignature, token class, issuer, subject, client, expiry, scopes, exact resource, and the live grant.
ApplicationTools, data, membership, roles, authorization, rate limits, idempotency, and effects, inside Convex functions.

Token scopes and OAuth consent are ceilings: they set the most that a token can do. They do not decide whether this person may change this record. Load the person's current roles and memberships in the same Convex transaction as the write: the application reloads it for every effect. Never put roles or permissions in an access token, and never pass the bearer token to a query, mutation, action, tool result, log, or iframe.

Install

bash
pnpm add @lupinum/better-convex-mcp@next @modelcontextprotocol/server@2.1.0 zod@4.6.5

@modelcontextprotocol/server is an exact peer dependency of @lupinum/better-convex-mcp. Install exactly the version above; the package and your tools then share one McpServer.

Build the server with Better Auth

This is the recommended path. Add MCP to your application has every file, including login and consent pages. starters/mcp-oauth-agent is the complete, tested example.

  1. Configure the MCP OAuth profile. Add oauth: { mcp: { scopes } } to createBetterConvexAuth. It configures PKCE public clients, consent, ten-minute access tokens bound to your /mcp resource, and renewal that ends with the Better Auth session. The auth component from better-convex init also needs oauthProvider() in its schema plugins.
  2. Mount the routes. Register /mcp and its protected-resource metadata route next to auth.registerRoutes(http).
  3. Handle MCP. Call handleMcpRequest once, with auth.createMcpAccessVerifier(ctx) as the verifier and auth.mcp for the issuer and resource.
  4. Define tools. Register each tool with registerMcpTool. Its handler calls one internal Convex function with the typed principal.
  5. Authorize in the function. Call auth.requireMcpPrincipal(ctx, principal, { scope }) first, then your own checks, then the effect.
  6. Let people disconnect. Show auth.oauthConnections.list on a settings page, and revoke with auth.oauthConnections.revoke.

The core of steps 3 to 5 looks like this:

convex/mcp.ts
export const handleMcp = httpAction((ctx, request) =>
  handleMcpRequest(request, {
    serverInfo: { name: 'personal-notes', version: '1.0.0' },
    resource: auth.mcp.resource(),
    authorization: {
      mode: 'oauth',
      issuer: auth.mcp.issuer(),
      verifier: auth.createMcpAccessVerifier(ctx),
      scopesSupported: auth.mcp.scopesSupported(),
    },
    configureServer({ principal, server, tools }) {
      registerMcpTool(server, tools, {
        name: 'list_notes',
        description: 'List your newest notes.',
        risk: 'read',
        scopes: ['notes:read'],
        inputSchema: z.object({}).strict(),
        outputSchema: z.object({ notes: z.array(z.object({ id: z.string(), title: z.string() })) }),
        handler: async () => ({
          structuredContent: await ctx.runQuery(internal.notes.list, { principal }),
        }),
      })
    },
  }),
)
convex/notes.ts
export const list = internalQuery({
  args: { principal: mcpPrincipalValidator },
  handler: async (ctx, { principal }) => {
    const { user } = await auth.requireMcpPrincipal(ctx, principal, { scope: 'notes:read' })
    const notes = await ctx.db
      .query('notes')
      .withIndex('by_owner', (q) => q.eq('ownerId', user.id))
      .take(20)
    return { notes: notes.map((note) => ({ id: note._id, title: note.title })) }
  },
})

The verifier reads signing keys from the auth component, so the Convex action never fetches JWKS over HTTP. For each token it checks the issuer ${SITE_URL}/api/auth, the audience (your resource), RS256, expiry and lifetime, the OAuth access-token class, and the scopes. It then checks the live session, client, resource link, and consent in one component query. auth.requireMcpPrincipal repeats that live check in your function's transaction and checks the scope. It throws a ConvexError with code MCP_ACCESS_DENIED or MCP_INSUFFICIENT_SCOPE.

BetterConvexMcpPrincipal says who the token belongs to. It does not decide what that person may do. It contains the user ID, client ID, scopes, session ID, grant ID, issuer, resource, and token expiry. Validate it with mcpPrincipalValidator on internal functions only. It never reaches the model.

To connect a host, create its OAuth client with auth.oauthOperator.createHostClient and follow Connect ChatGPT and Claude.

Register the routes

OAuth mode has five explicit Convex route registrations. GET and DELETE on the transport path reach the handler's deliberate 405 response instead of becoming router-level 404s. Convex dispatches HEAD through the matching metadata GET route, while browser metadata discovery needs its own OPTIONS registration:

convex/http.ts
import { httpRouter } from 'convex/server'

import { auth } from './auth'
import { handleMcp } from './mcp'

const http = httpRouter()

auth.registerRoutes(http)
for (const method of ['POST', 'GET', 'DELETE'] as const) {
  http.route({ path: '/mcp', method, handler: handleMcp })
}
for (const method of ['GET', 'OPTIONS'] as const) {
  http.route({ path: '/.well-known/oauth-protected-resource/mcp', method, handler: handleMcp })
}

export default http

Do not register OPTIONS /mcp: it would let browsers on other origins call your tools. Build the public resource and issuer from deployment configuration, never from request headers. Production resources and issuers must use HTTPS; exact localhost, 127.0.0.1, and [::1] HTTP origins are accepted only for local development.

The handler serves only the RFC 9728 protected-resource document. It does not serve the authorization-server metadata; the issuer serves that document.

Define tools

Register as tools only the application operations you have checked. The package never turns Convex functions into tools automatically. configureServer receives one object:

FieldContents
accessThe normalized issuer, subject, client ID, resource, and scopes.
principalThe verifier's typed principal. Pass it to internal functions.
serverThe official McpServer for this request.
toolsrunTool(name, operation) and requireScopes(...scopes) bound to this request.

registerMcpTool(server, tools, definition) registers one tool. defineMcpTool(tools, definition) returns { name, config, handler } for server.registerTool or for registerAppTool from the MCP Apps SDK. Both set:

Definition fieldEffect
riskread: read-only and idempotent. write: changes state. destructive: can overwrite or delete. Hosts use these hints to ask for confirmation.
idempotentOverrides the idempotent hint. The default is true only for read.
openWorldSet it when the tool reaches systems outside the application. The default is false.
scopesAdvertises the scopes in _meta.securitySchemes and adds the SDK step-up challenge.
outputSchemaLets the handler return only structuredContent. The text content is filled from its JSON.
uiSets the MCP Apps template URI. See MCP Apps.

The handler runs through tools.runTool, so a thrown error becomes a safe tool result. Annotations are hints for the host. They grant and deny nothing.

Tool errors and diagnostics

Give the model a code and a short message for failures it can act on. Throw a ConvexError with { code, message } from the Convex function and add the code to exposeErrorCodes:

ts
handleMcpRequest(request, {
  // ...
  exposeErrorCodes: ['PROJECT_NOT_FOUND', 'RATE_LIMITED'],
  onToolError: ({ name, code }) => console.error('MCP tool failed', { name, code }),
})

An allowlisted error becomes { isError: true, content: [message], structuredContent: { error: { code, message, retryable } } }. UNAUTHENTICATED, MCP_ACCESS_DENIED, and MCP_INSUFFICIENT_SCOPE are always exposed. Any other throw becomes the static result Tool execution failed. onToolError receives { kind: 'tool', name }, plus code when a code was exposed. It never receives the error, its message, or the tool arguments.

Only put text in an exposed message that the person may read. The messages of the three built-in codes contain no internal detail.

projectMcpToolError(error, { expose }) performs the same projection for your own callbacks. Standalone runMcpTool(operation, options) has the same result but does not inherit a handler's exposeErrorCodes or onToolError.

These helpers cover only the callbacks that use them. They do not change the SDK's own input and output validation errors, resources, or callbacks that do not use them. Keep schemas free of secrets.

Each kind of failure gets a different response:

  • missing, invalid, expired, wrong-resource, or insufficient-scope bearer credentials receive a standards-shaped WWW-Authenticate challenge;
  • MCP parse, schema, and unsupported-method errors remain official-SDK protocol responses;
  • expected domain outcomes are explicit, bounded tool results;
  • unexpected throws inside a tool become the static failure.

Ask for more scopes

Set scopes on a tool definition, or set scopeChallenge: tools.requireScopes('mcp:write') on a hand-registered tool or resource. If the token does not have every listed scope, the SDK returns HTTP 403 before the callback runs. The insufficient_scope challenge names the scopes in authorization.requiredScopes and the listed scopes, so a client that authorizes again keeps the access it already has. In OAuth mode, the challenge also contains resource_metadata.

In OAuth mode, advertise every challenged scope in scopesSupported. If you challenge a scope that is not advertised, requireScopes throws a TypeError while the tool is registered, and the request fails with HTTP 500. A client never receives a challenge that it cannot satisfy.

tools/list still shows tools that the current token cannot call yet. A client can discover such a tool and then ask the person for the additional scope. Discovery does not grant access: the Convex function still checks the scope.

Tool and resource callbacks also receive the verified scopes, client ID, expiry, and metadata URL in the SDK's ctx.http.authInfo. Its token field is always empty. Use principal from configureServer as the principal.

Test the catalog

listMcpCatalog from @lupinum/better-convex-mcp/test returns the exact tools/list and resources/list results a client sees. It runs your configureServer through handleMcpRequest, so names, schemas, annotations, and _meta match production. Tool callbacks do not run.

The test imports registerNoteTools, the exported tool setup from step 5 of Add MCP to your application.

convex/mcp.test.ts
import { listMcpCatalog } from '@lupinum/better-convex-mcp/test'
import type { BetterConvexMcpPrincipal } from '@lupinum/better-convex-nuxt/better-auth/server'
import { expect, test } from 'vitest'

import { registerNoteTools } from './mcp'

test('the MCP catalog does not change by accident', async () => {
  const principal: BetterConvexMcpPrincipal = {
    kind: 'oauth',
    userId: 'user-1',
    clientId: 'client-1',
    scopes: ['notes:read'],
    sessionId: 'session-1',
    grantId: 'grant-1',
    issuer: 'https://app.example.com/api/auth',
    resource: 'https://example.convex.site/mcp',
    expiresAt: 4_102_444_800,
  }
  const catalog = await listMcpCatalog({
    configureServer: (context) => registerNoteTools({} as never, context),
    access: {
      issuer: principal.issuer,
      subject: principal.userId,
      clientId: principal.clientId,
      resource: principal.resource,
      scopes: principal.scopes,
    },
    principal,
    scopesSupported: ['notes:read', 'offline_access'],
  })
  expect(catalog.tools).toMatchSnapshot()
})

Pass the production requiredScopes and scopesSupported, so an unadvertised scope fails in the test too.

Use another token provider

Any provider works when you implement McpAccessVerifier. The verifier returns the allowlisted access context, the expiry in Unix seconds, and an optional typed principal:

convex/mcp/verify.ts
import type { McpAccessVerifier } from '@lupinum/better-convex-mcp'

export const applicationTokenVerifier: McpAccessVerifier<{ accountId: string }> = {
  async verifyAccessToken(token, expected) {
    const verified = await verifyWithYourProvider(token, expected)

    return {
      access: {
        issuer: verified.issuer,
        subject: verified.subject,
        clientId: verified.clientId,
        resource: expected.resource.href,
        scopes: verified.scopes,
      },
      principal: { accountId: verified.accountId },
      expiresAt: verified.expiresAt,
    }
  },
}

The verifier must check the signature, token class, issuer, subject, client, expiry, scopes, and the exact expected resource. The package rejects extra result fields and mismatched, expired, or malformed values. It passes principal through unchanged to requestState and configureServer, so you never capture verifier state in a closure. Never put the raw token, raw claims, headers, cookies, or provider secrets in the principal.

For controlled credentials provisioned out of band, use authorization.mode: 'preconfigured-bearer'. This mode deliberately publishes no OAuth discovery metadata. Register only the three /mcp transport methods and omit both protected-resource metadata registrations. Its 401 challenge does not contain resource_metadata. Credential storage, rotation, revocation, and application authorization remain application-owned.

Check request state for multi-round tools

A tool can return the official inputRequired(...) result to ask the client for more information. The client retries with the opaque requestState that the server returned. Treat that state as untrusted input. If it affects a resource or an operation, check its signature, expiry, user, and original request before you use it.

Pass a requestState({ access, principal }) function to handleMcpRequest. It returns the official SDK's requestState.verify hook. The package calls it for each request with the verified access context and principal. The SDK runs verify before the tool or resource callback, and returns the decoded value from ctx.mcpReq.requestState(). A rejection becomes the SDK's JSON-RPC -32602 error, not a tool result with isError.

Use the official codec:

ts
import { createRequestStateCodec } from '@modelcontextprotocol/server'
import type { McpAccessContext } from '@lupinum/better-convex-mcp'

function stateCodec(access: McpAccessContext) {
  return createRequestStateCodec({
    key: configuredStateKey,
    ttlSeconds: 120,
    bind: (ctx) =>
      JSON.stringify([
        access.issuer,
        access.resource,
        access.subject,
        access.clientId,
        ctx.mcpReq.method,
        ctx.http?.req?.headers.get('mcp-name'),
      ]),
  })
}

Set requestState: ({ access }) => ({ verify: stateCodec(access).verify }) in the handler options.

configuredStateKey is a server-only key of at least 32 bytes. Every server instance that handles the flow uses the same key. In your tool callback, create state with stateCodec(access).mint(payload, ctx). Put a hash of the operation's parameters into the payload, and compare it before the tool changes anything.

The codec signs the payload but does not encrypt it. Do not put credentials or private data in it. It does not check your payload's schema, so validate the payload yourself. Keep the live permission check in the Convex function, and enforce one-time use in the backend when you need it. Without the hook, the SDK passes the raw, unchecked state to the callback. The package creates no default key and no state store.

To deny a request, verify must throw or reject. Returning false is a decoded value, not a denial. Returning undefined keeps the raw string.

When revocation takes effect

With the Better Auth verifier, revocation takes effect on the next request: deleting a session or consent, disabling or deleting a client, disabling the resource, or unlinking the resource denies the next token check and the next requireMcpPrincipal call. Every token is bound to the consent that issued it, so granting the same access again does not revive an older token. An application membership, role, or resource grant is revoked immediately because the application reloads it for every effect.

With another provider, a token that you verify only by its signature stays valid until it expires, unless you add a live check.

The package adds no token blocklist, refresh-token store, permission table, role model, or background revocation job.

Writes and human approval

Decide what the operation is first. Then decide how MCP exposes it. Do not add an approval step only because a model calls the operation.

OperationHow MCP exposes itWhere the state lives
Small write that the person can undoAn ordinary toolYour mutation
High-impact action that the person must confirm on your siteAn approval that the person completes in your applicationYour approval table
Change that a reviewer must approveA tool that returns a reference to your existing reviewYour review table
Long-running external workA job record and a status toolYour job or outbox table

Tool descriptions, annotations, host confirmation dialogs, MCP App buttons, and OAuth scopes do not authorize a write. Every path must check the caller and load the person's current permissions again at the write.

The starter shows one pattern: request_project_deletion records a pending approval, the person approves it in the application, and delete_project uses that approval once, for the same user, client, and project.

Approval in your application

Ask the person to approve on your site only when the person who started the tool call must review a high-impact operation. The tool creates an approval row and returns a link to it. That link:

  • uses your HTTPS origin and a random ID;
  • contains no token, session, user ID, tool arguments, or approval right;
  • changes nothing when opened with GET;
  • requires sign-in, as the same person who started the tool call;
  • shows the current state and effect of the operation again; and
  • is used up by the same Convex transaction that performs the operation.

Opening the link is not approval. The person reads the current summary and submits an explicit form. Expired, forwarded, wrong-user, outdated, or reused approvals fail. Two submissions at the same time produce at most one change.

The approval row belongs to your application. Better Convex has no approval table, role model, or impact calculation.

Reviewer queues

A reviewer queue is a different workflow. A reviewer signs in as themselves, and your application decides who may review. The tool may return a reference to the review row, but it must not create a second, MCP-only approval record. Opening that reference changes nothing.

Retries and external services

Do not retry a failed mutation or action blindly: the change may already have happened. To make retries safe, use an idempotency key that your application stores. JSON-RPC IDs, equal arguments, OAuth subjects, and MCP metadata are not idempotency keys.

A Convex transaction cannot include a call to an external API. For email, payments, deployments, and similar effects, write a job or outbox row in the transaction, send the request with the provider's idempotency key, and record the provider's result. Report accepted, pending, applied, or failed truthfully.

What the package supports

The package supports one stateless Convex HTTP Action, tools and resources that you register with the official SDK, OAuth or preconfigured-bearer verification, protected-resource metadata, bounded requests and responses, and safe tool errors. It accepts only the modern protocol and finite JSON responses. List-change notifications and resource subscriptions are off. Older transports, SSE, and subscription methods are rejected.

The official SDK checks the MCP-Protocol-Version header on JSON-RPC requests. A request that names the protocol version only in its body receives HTTP 400 with JSON-RPC -32020. A request without a protocol version receives HTTP 400 with JSON-RPC -32022. A notification receives HTTP 202 with an empty body, with or without the header, and never runs a tool or resource callback. A subscriptions/listen request receives HTTP 404 with JSON-RPC -32601.

The package does not include:

  • automatic Convex-function exposure or a tool registry;
  • prompts, Tasks, or a URL approval workflow;
  • a roles/permissions DSL or generic approval table;
  • token passthrough, service-proof arguments, or a second MCP server in Nitro;
  • dynamic client registration or Client Credentials;
  • a hand-written MCP parser or support for older protocol versions.

If you need prompts, streaming, subscriptions, or Tasks, this handler is not the right fit: it rejects those features instead of half-supporting them.

To show a card in hosts that support MCP Apps, see MCP Apps.