MCP on Convex
Expose explicit application operations through the provider-neutral Better Convex MCP resource boundary.
@lupinum/better-convex-mcp is the provider-neutral MCP resource-server package for
Convex applications. It terminates MCP and bearer authentication in one
deployment-owned Convex HTTP Action. It does not depend on Nuxt, Nitro, Better
Auth, or an application authorization model.
The current package is an experimental 0.1.0-beta.28 candidate. It uses the
final MCP 2026-07-28 contract through exact @modelcontextprotocol/server@2.0.0.
The protocol is stable; this integration remains prerelease until its complete
compatibility and security matrices pass against the exact release.
Ownership boundary
| Layer | Owns |
|---|---|
| Official MCP SDK | Protocol parsing, method dispatch, schemas, capabilities, and wire responses. |
@lupinum/better-convex-mcp | Bounded HTTP transport, bearer challenge, exact resource/issuer binding, verifier normalization, and a safe access context. |
| Token verifier | Signature or introspection, token class, issuer, subject, client, expiry, scopes, and exact resource. |
| Application | Tool/resource registration, canonical data, membership, roles, delegation, authorization, rate limits, idempotency, and effects. |
Token scopes and OAuth consent are ceilings, not application authorization. Every effect must reload current application authority inside the same Convex transaction as the write. Never put roles or permissions in a long-lived access token and never pass the bearer token to a query, mutation, action, tool result, diagnostic, or iframe.
Install and handle each request
Install the MCP package and the schema library used by the official SDK:
pnpm add @lupinum/better-convex-mcp@0.1.0-beta.28 @modelcontextprotocol/server@2.0.0 zod@4.4.3The package creates one official server for each authenticated request. Configure only reviewed application operations; Convex functions are never exported as tools automatically.
Configure one scope authority
Keep the resource's delegated scopes in one module consumed by provider setup, metadata, verification, and application authorization:
export const MCP_SCOPES = Object.freeze(['mcp:read', 'mcp:write'] as const)
export type McpScope = (typeof MCP_SCOPES)[number]
export function isMcpScope(value: string): value is McpScope {
return (MCP_SCOPES as readonly string[]).includes(value)
}import { handleMcpRequest, runMcpTool } from '@lupinum/better-convex-mcp'
import { createBetterAuthMcpAccessVerifier } from '@lupinum/better-convex-nuxt/convex-auth'
import { z } from 'zod'
import { internal } from './_generated/api'
import { httpAction } from './_generated/server'
import { authComponent } from './auth'
import { MCP_SCOPES } from './mcp/scopes'
const resource = new URL('https://deployment.convex.site/mcp')
const issuer = 'https://accounts.example.com'
export const handleMcp = httpAction(
async (ctx, request) =>
await handleMcpRequest(request, {
resource,
authorization: {
mode: 'oauth',
issuer,
verifier: createBetterAuthMcpAccessVerifier({
allowedScopes: MCP_SCOPES,
jwksUrl: `${issuer}/jwks`,
maxLifetimeSeconds: 600,
validateLiveAccess: (access) => authComponent.validateOAuthAccess(ctx, access),
}),
resourceName: 'Example MCP',
scopesSupported: MCP_SCOPES,
},
serverInfo: { name: 'example-mcp', version: '1.0.0' },
configureServer(access, server) {
server.registerTool(
'notes.rename',
{
description: 'Rename one note after current application authorization.',
inputSchema: z
.object({ noteId: z.string(), title: z.string().min(1).max(120) })
.strict(),
},
async (args) =>
runMcpTool(async () => {
const value = await ctx.runMutation(internal.notes.renameFromMcp, {
...args,
access: {
clientId: access.clientId,
issuer: access.issuer,
subject: access.subject,
},
})
return {
content: [{ type: 'text', text: 'Note renamed' }],
structuredContent: value,
}
}),
)
},
}),
)authComponent.validateOAuthAccess owns the live Better Auth session, user, client, resource,
client-resource link, consent, and scope checks. Application mutations still own membership,
roles, delegation, resource policy, rate limits, and approvals. A disabled provider resource blocks
new token issuance; deletion—not disabling—invalidates an otherwise-current issued token under the
pinned provider semantics.
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 { handleMcp } from './mcp'
const http = httpRouter()
http.route({ handler: handleMcp, method: 'POST', path: '/mcp' })
http.route({ handler: handleMcp, method: 'GET', path: '/mcp' })
http.route({ handler: handleMcp, method: 'DELETE', path: '/mcp' })
http.route({
handler: handleMcp,
method: 'GET',
path: '/.well-known/oauth-protected-resource/mcp',
})
http.route({
handler: handleMcp,
method: 'OPTIONS',
path: '/.well-known/oauth-protected-resource/mcp',
})
export default httpDo not register OPTIONS /mcp unless the application has deliberately enabled
and reviewed cross-origin MCP transport. Construct the public resource and
issuer from trusted 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 resource handler serves only its RFC 9728 protected-resource document. It does not author or mirror authorization-server metadata; the configured issuer owns its own discovery document and endpoint capabilities.
Implement a provider-neutral verifier
The verifier returns an allowlisted access context, not a provider object or application actor:
import type { McpAccessVerifier } from '@lupinum/better-convex-mcp'
export const applicationTokenVerifier: McpAccessVerifier = {
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,
},
expiresAt: verified.expiresAt,
}
},
}The package rejects extra verifier-result fields and mismatched, expired, or noncanonical values. Provider-private grant IDs, token bytes, raw claims, roles, permissions, headers, cookies, and error objects must stay inside the verifier.
For Better Auth, use the reviewed adapter and fixed delegated profile described
in Delegated OAuth and MCP.
The adapter requires a server-only validateLiveAccess callback that checks the
current Better Auth session, user, OAuth client, consent, and resource link on
every request. Its provider-private session ID stays inside that callback and
never enters McpAccessContext. The base package has also been certified with
an independent public-JWKS verifier; Better Auth is optional.
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.
Errors and diagnostics
The layers have different error responsibilities:
- 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 should be explicit, bounded tool results;
- unexpected throws inside a tool callback may pass through the one-argument
runMcpTool(), which returns onlyTool execution failedand exposes no cause-derived diagnostics.
runMcpTool() is an opt-in callback boundary, not a universal SDK sanitizer.
It cannot sanitize input validation, output validation, resources, prompts, or
callbacks that bypass it. Keep schemas free of secrets and treat the SDK's
validation/error surface as public until an official operation-error hook is
available.
Revocation semantics
An application membership, role, delegation, or resource grant can be revoked immediately because the application reloads it for every effect. Provider grant revocation depends on the verifier:
- the maintained Better Auth example also checks current session, client, resource link, and consent before each tool effect;
- a self-contained token verified only through public JWKS may remain cryptographically valid until its bounded expiry unless the application adds a provider-specific live check.
The package does not add a JWT blacklist, refresh-token store, authorization table, role model, or background revocation job.
Writes and human interaction
Choose the application operation first, then project it through MCP. Do not introduce a review workflow merely because a model called the operation.
| Application operation | MCP projection | Canonical owner |
|---|---|---|
| Routine, bounded, reversible write | Ordinary explicit tool | Application mutation |
| High-impact action the initiating person must complete on the web | Capability-negotiated same-user external interaction | Application interaction row |
| Existing reviewer or editorial queue | Ordinary tool result with an inert queue locator or receipt | Application review row |
| Long-running external work | Application job/outbox and truthful status | Application job/outbox |
Tool descriptions, annotations, model prose, host confirmation dialogs, MCP App buttons, and OAuth scopes do not authorize a write. Every path must authenticate the caller and recheck current application authority at the protected effect. A client that does not support the required interaction capability receives an explicit unsupported result; the server must not return an unnegotiated confirmation URL as a substitute.
Same-user external interaction
Use an external interaction only when the person who initiated the tool call must personally review or complete a high-impact operation in the authoritative application. The server may create a bounded application-owned interaction and project its opaque URL through the negotiated MCP mechanism. That URL:
- is built from one configured HTTPS origin and a random opaque identifier;
- contains no bearer credential, session, identity, arguments, or approval capability;
- is inert on
GET; - requires application login and must resolve to the same initiating subject;
- reloads current authority, target state, and impact before execution; and
- is consumed with the application effect in one Convex transaction.
Opening the URL or accepting navigation is not confirmation. The person sees the application's current summary, warnings, and effects, then submits an explicit state-changing request. Expired, forwarded, wrong-user, stale, or replayed interactions fail closed. Concurrent submissions produce at most one canonical effect and return the same terminal receipt where application policy permits recovery.
The interaction record belongs to the application. Better Convex must not own a generic approval table, role policy, impact calculation, or effect. The currently tested implementation remains internal while the stable SDK is reconciled with the official final specification, changelog, and conformance suite; there is no public interaction export in this beta.
Reviewer queues are a different workflow
An existing reviewer queue is not the requester's same-user interaction. A
reviewer signs into the application under their own identity and the
application decides whether the requester may self-review, whether another
person is required, and which current role can act. The queue locator is inert:
it grants neither identity nor approval authority, and GET performs no
mutation.
Keep one canonical review row and one status transition path. MCP may return its opaque locator, receipt, or authorized status, but it must not create a parallel MCP approval record. A forwarded same-user interaction URL also never transfers the requester's authority to a reviewer.
Retry, status, and external effects
Unknown mutation or action failures must not be retried blindly: the protected effect may already have committed. Retry recovery requires an explicit application-owned interaction, operation, or idempotency key. JSON-RPC IDs, argument equality, argument digests, OAuth subjects, and MCP transport metadata are not idempotency contracts. A later intentional call with the same arguments is a new operation unless it carries the prior application key.
Status reads must authenticate and authorize the current caller and return only the canonical operation's bounded state or receipt. They must not expose a cross-user enumeration surface.
A Convex transaction can atomically consume an interaction and commit Convex
state, but it cannot make an external API call transactional. For email,
payments, deployments, third-party deletion, and similar effects, commit an
application outbox/job record transactionally, deliver with a provider
idempotency key, and reconcile provider state. Report accepted, pending,
applied, or failed truthfully. Do not promise generic exactly-once external
execution or imply that cancellation reverses an effect already accepted by
another system.
Experimental supported surface
The certified profile supports one stateless Convex HTTP Action, explicit official-SDK tool and resource registration, OAuth or preconfigured-bearer verification, protected-resource metadata, bounded transport, and safe tool diagnostics. It accepts only the strict modern protocol route and finite JSON responses. Tool/resource list-change flags and resource subscriptions are disabled; legacy transport, SSE, and subscription methods fail closed.
The base server package does not currently ship:
- automatic Convex-function exposure;
- prompts, Tasks, or a URL approval workflow;
- a roles/permissions DSL or generic approval table;
- token passthrough, service-proof arguments, or a second Nitro MCP topology;
- dynamic client registration, Client Credentials, or provider administration;
- a hand-written MCP parser or a compatibility protocol.
Applications that require prompts, streaming, subscriptions, Tasks, or another official-SDK capability need a separately proven topology; this finite Convex-native handler rejects those capabilities instead of silently overclaiming support.
The optional experimental Vue MCP Apps client
entry lives in @lupinum/better-convex-vue/mcp-app. It adds no server capability or
authority and composes with explicit resources registered on this same
official-SDK handler.
Release evidence
The candidate is tested from its exact tarball in three independent consumers:
a clean Node/type consumer, the maintained Better Auth/PKCE Convex deployment,
and a separate public-JWKS verifier Convex deployment. The suite compares
installed bytes, runs production builds, exercises two preregistered public
clients, validates OAuth discovery/challenges/revocation, and executes the
2026-07-28 stateless request envelope through the stable official client SDK.
The older official conformance package is retained only for its published
2025-11-25 initialize, ping, and tools-list scenarios because it advertises no
2026-07-28 scenarios. It is supporting evidence, not a substitute for matching
official final conformance scenarios and not an OAuth certification.