Overview
Expose Convex functions as tools that Claude and ChatGPT can call as the signed-in user, over a hosted remote MCP server with real OAuth. One server works across every MCP client. The whole thing lives inside Convex — no separate web server, no bridge, no keypair.
Built on the convex-mcp-gateway component. It hosts the MCP server as a
Convex httpAction, handles the JSON-RPC/Streamable-HTTP protocol, the OAuth
discovery doc, and an audit log. You supply three things:
- Tools —
defineMcpQuery/defineMcpMutation/defineMcpActionmap a Convex function to a tool. Each identity-scoped tool takes an injectedcallerarg (identityArg), because Convex stripsctx.authacross the component boundary — so you scope oncaller.subject, notctx.auth. - Identity — a
resolveIdentitythat validates Clerk's opaque OAuth access token at Clerk's OIDC userinfo endpoint and returns{ subject, claims }. Clerk is the OAuth authorization server (with Dynamic Client Registration); the gateway is the resource server. - Mount — one
httpActioninconvex/http.tsthat callsgateway.handleMcpRequest(...), plus the protected-resource discovery route.
Claude/ChatGPT --OAuth (DCR)--> Clerk (authorization server)
| opaque access token
v
convex.site/mcp (gateway httpAction, requireAuth)
| resolveIdentity → Clerk userinfo → { subject }
v
gateway injects `caller` → internal Convex fn scopes by caller.subject
(runs as that user; every owner-scoped function works unchanged)
When to use
- You want an AI client to do something in your app (save, fetch, recall, trigger) and have it run as the actual user.
- Your backend is Convex + Clerk — Convex is your source of truth.
- You want it addable in Claude and ChatGPT without per-client backends.
Not for: local stdio MCP servers (no OAuth needed — a token is enough); apps not on Convex+Clerk (the identity resolver assumes Clerk; the tool model assumes Convex components); read-only public data with no user identity (skip OAuth entirely).
Why not host the MCP server in a Next.js/Vercel route and bridge to Convex? You can, but for a Convex-centric app it adds a separate server, a signed bridge JWT, a JWKS endpoint, and key rotation — all to reproduce an identity Convex can already carry. The gateway keeps everything in
convex/.
Build steps
- Install + register:
npm install convex-mcp-gateway, thenapp.use(mcpGateway)inconvex/convex.config.ts(setup-and-gotchas.md§1). - Tools — copy
references/mcp.tstoconvex/mcp.ts. Replace the sample tools with onedefineMcp{Query,Mutation,Action}per function you expose. Every identity-scoped tool declarescaller: mcpCallerValidator+identityArg: "caller"and pointsfnat an internal function. Keepauthorize,resolveClerkIdentity, andinitializeInstructions. - Tool impls — copy
references/mcp-tools.tstoconvex/mcpTools.ts. These areinternalQuery/internalMutation/internalActions that owner-scope bycaller.subject(map it to your user via whatever index keys users on the Clerk id). - Mount — merge
references/http.tsintoconvex/http.ts(the/mcproutes + protected-resource discovery +requireAuth+ exposed headers). - Env — set
MCP_AUTH_SERVER_URL(your Clerk issuer) per deployment, or rely on an existingCLERK_JWT_ISSUER_DOMAIN(setup-and-gotchas.md§2). - Enable Dynamic Client Registration in the Clerk dashboard — required, one
manual toggle; leave "access tokens as JWTs" OFF (
setup-and-gotchas.md§3). - Deploy (
npx convex deploy), then add the connector athttps://<deployment>.convex.site/mcpin Claude/ChatGPT (setup-and-gotchas.md§4).
Reference files
| File | Goes to | What it is |
|---|---|---|
references/mcp.ts |
convex/mcp.ts |
gateway + tool descriptors + authorize + resolveClerkIdentity (Clerk userinfo) + initializeInstructions |
references/mcp-tools.ts |
convex/mcpTools.ts |
the internal tool impls, owner-scoped by the injected caller.subject |
references/http.ts |
convex/http.ts |
the /mcp mount + OAuth discovery route + requireAuth + exposed headers |
references/setup-and-gotchas.md |
— | convex.config, env, Clerk DCR, deploy, add-connector, gotchas, round-trip test |
Common mistakes
- Reading the user from
ctx.authin a tool → always null. The gateway stripsctx.authacross the component boundary; use the injectedcallerarg. - Exposing tool functions as
public(api.*) → any Convex client can call them with a forgedcallerand read another user's data. Make theminternal. - Skipping Dynamic Client Registration in Clerk (or leaving it unsaved) → the
client can't register, OAuth never starts.
registration_endpointmust appear in Clerk's oauth-authorization-server metadata (not openid-configuration). - Omitting
requireAuth: true→ browser clients (claude.ai) see an emptytools/list, think they're connected, and never start OAuth (they only react to a 401). - Not exposing
mcp-session-id/www-authenticateon CORS → the browser client can't read the session or the auth challenge. - Convex
subjectmismatch → tools authenticate but owner-scoped queries return nothing.caller.subject(the Clerk user id) must be what your functions key on. - Weak tool descriptions → the model defaults to web search instead of your
connector. Name the concrete data and say when to use it; set
initializeInstructions. Re-sync by starting a new chat after changing them.
See references/setup-and-gotchas.md for the full gotcha list (opaque-vs-JWT
tokens, the circular-type annotation, ChatGPT caveats, DCR verification) and the
round-trip test.
Reference implementation
Padscanner's connector (github.com/tfohlmeister/convex-mcp-gateway is the component; padscanner exposes its rental-search tools to Claude as the signed-in user) is built exactly this way and runs in production — the same pattern powers a savethis "save & recall from Claude" connector. Use it as the worked example, but keep this skill stack-generic.