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
- The host sends a JSON-RPC request with
Authorization: Bearer <token>to the Convex/mcproute. handleMcpRequestchecks the transport, then asks the verifier to check the token. The verifier returns the safeaccesscontext and a typedprincipal.configureServerregisters this request's tools. Each tool calls one internal Convex function and passes theprincipal.- The internal function re-checks the principal and the application's own rules in the same transaction as its read or write.
| Layer | Responsible for |
|---|---|
| Official MCP SDK | Protocol parsing, method dispatch, schemas, capabilities, and wire responses. |
@lupinum/better-convex-mcp | Bounded HTTP transport, bearer challenge, exact resource and issuer binding, verifier normalization, and tool helpers. |
| Token verifier | Signature, token class, issuer, subject, client, expiry, scopes, exact resource, and the live grant. |
| Application | Tools, 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
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.
- Configure the MCP OAuth profile. Add
oauth: { mcp: { scopes } }tocreateBetterConvexAuth. It configures PKCE public clients, consent, ten-minute access tokens bound to your/mcpresource, and renewal that ends with the Better Auth session. The auth component frombetter-convex initalso needsoauthProvider()in its schema plugins. - Mount the routes. Register
/mcpand its protected-resource metadata route next toauth.registerRoutes(http). - Handle MCP. Call
handleMcpRequestonce, withauth.createMcpAccessVerifier(ctx)as the verifier andauth.mcpfor the issuer and resource. - Define tools. Register each tool with
registerMcpTool. Its handler calls one internal Convex function with the typedprincipal. - Authorize in the function. Call
auth.requireMcpPrincipal(ctx, principal, { scope })first, then your own checks, then the effect. - Let people disconnect. Show
auth.oauthConnections.liston a settings page, and revoke withauth.oauthConnections.revoke.
The core of steps 3 to 5 looks like this:
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 }),
}),
})
},
}),
)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:
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 httpDo 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:
| Field | Contents |
|---|---|
access | The normalized issuer, subject, client ID, resource, and scopes. |
principal | The verifier's typed principal. Pass it to internal functions. |
server | The official McpServer for this request. |
tools | runTool(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 field | Effect |
|---|---|
risk | read: read-only and idempotent. write: changes state. destructive: can overwrite or delete. Hosts use these hints to ask for confirmation. |
idempotent | Overrides the idempotent hint. The default is true only for read. |
openWorld | Set it when the tool reaches systems outside the application. The default is false. |
scopes | Advertises the scopes in _meta.securitySchemes and adds the SDK step-up challenge. |
outputSchema | Lets the handler return only structuredContent. The text content is filled from its JSON. |
ui | Sets 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:
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-Authenticatechallenge; - 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.
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:
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:
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.
| Operation | How MCP exposes it | Where the state lives |
|---|---|---|
| Small write that the person can undo | An ordinary tool | Your mutation |
| High-impact action that the person must confirm on your site | An approval that the person completes in your application | Your approval table |
| Change that a reviewer must approve | A tool that returns a reference to your existing review | Your review table |
| Long-running external work | A job record and a status tool | Your 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.