Skip to main content

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

LayerOwns
Official MCP SDKProtocol parsing, method dispatch, schemas, capabilities, and wire responses.
@lupinum/better-convex-mcpBounded HTTP transport, bearer challenge, exact resource/issuer binding, verifier normalization, and a safe access context.
Token verifierSignature or introspection, token class, issuer, subject, client, expiry, scopes, and exact resource.
ApplicationTool/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:

bash
pnpm add @lupinum/better-convex-mcp@0.1.0-beta.28 @modelcontextprotocol/server@2.0.0 zod@4.4.3

The 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:

convex/mcp/scopes.ts
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)
}
convex/mcp.ts
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:

convex/http.ts
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 http

Do 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:

ts
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-Authenticate challenge;
  • 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 only Tool execution failed and 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 operationMCP projectionCanonical owner
Routine, bounded, reversible writeOrdinary explicit toolApplication mutation
High-impact action the initiating person must complete on the webCapability-negotiated same-user external interactionApplication interaction row
Existing reviewer or editorial queueOrdinary tool result with an inert queue locator or receiptApplication review row
Long-running external workApplication job/outbox and truthful statusApplication 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.