For developers
Protocol versions, how the MCP server is built, how to add a tool, operations and known limits.
Agents5 min read
Protocol versions
The gateway serves both protocol eras on the same endpoint.
- 2026-07-28 (stateless). No handshake and no sessions: each request carries its
protocol version and client capabilities in
_meta, plusMcp-Method/Mcp-Nameheaders. Nothing is stored between requests. - 2025-03-26 / 06-18 / 11-25 (sessions).
initializecreates a session row. Each later request costs one session read and one small write.
Captured from real traffic on 2026-09-13:
| Client | First request | Version |
|---|---|---|
| Claude Code 2.1.270 | server/discover | 2026-07-28 |
| Codex CLI 0.154.0 | initialize | 2025-06-18 |
Dropping the session path would disconnect Codex, so it stays. Idle sessions are pruned
after 24 hours. Once Codex sends 2026-07-28, the session path can be retired by refusing
initialize in convex/mcp.ts.
Implementation
All code lives in packages/backend/convex except the two web pages.
| File | Role |
|---|---|
mcp.ts | Tool and resource registration, server instructions, token resolution, the authorize callback, request handling |
mcpPrompts.ts | The prompt catalog, and the prompts/list / prompts/get handling the gateway does not provide |
agentApi.ts | Read tools (internal queries and the search action) |
agentWrite.ts | Write tools (internal mutations) |
lib/agentCaller.ts | agentAccess (caller → access, scope check) and agentGuard (access errors → ConvexError) |
agentTokens.ts | Personal tokens, token resolution, expiry purge, audit and session pruning |
oauth.ts | The authorization server |
lib/tokens.ts | Token minting, hashing, PKCE |
http.ts | Routes for /mcp, /.well-known/*, /oauth/* |
apps/web/app/app/settings/agents/page.tsx | Settings → Agents: setup snippets, token creation, connection list |
apps/web/app/oauth/consent/page.tsx | The consent screen |
Request flow
client ──POST /mcp──▶ http.ts ─▶ mcp.handleMcp
│ resolveIdentity: sha256(token) → agentTokens.resolve
│ authorize: hide/deny write tools for read scope or viewers
▼
convex-mcp-gateway (component)
│ injects { subject, claims } as the tool's `caller` arg
▼
internal.agentApi.* / internal.agentWrite.*
│ agentAccess(caller) → documentFor / folderFor
▼
the same *As helpers the web app's mutations call
Prompts
convex-mcp-gateway 2.0 doesn't handle prompts. It answers tools/* and resources/*
and advertises only those. So mcpPrompts.ts serves prompts/list and prompts/get
ahead of the gateway and patches prompts: {} into the capabilities of the initialize
and server/discover responses on the way back out. Everything else is delegated
untouched.
Three things it has to keep doing, because a prompt response that differs from every other response on the endpoint is a bug that shows up on one client only:
- Resolve the bearer token itself, through the same resolver
handleMcpgives the gateway. No token gets the same 401 with aWWW-Authenticatechallenge, so OAuth discovery still works when a prompt call is a client's first request. - Mirror the framing: SSE when the client asked for it, JSON otherwise, and the
stateless era's
resultType/_meta/ttlMsenvelope fields when the request declares 2026-07-28. - Buffer the gateway's response to patch it. That is safe because the gateway's SSE body is a single frame, already closed. It's never a live stream.
Adding a prompt is an entry in the PROMPTS array: a name, a title, a description, its
arguments, and a build that returns the user-role text. Write it as a sequence of tool
calls with a stopping condition, not as a description of the feature.
Writing a new tool
- Write an internal function in
agentApi.ts(read) oragentWrite.ts(write). Give it acaller: mcpCallerValidatorargument. Do not usegetAuthUserId,requireAccessorrequireDocumentthere: the gateway stripsctx.auth, so they always fail. CallagentAccess(ctx, caller, capability), thendocumentFor/folderForfor entity-level checks. - Reuse the shared helpers. For writes, call the
*Ashelper the web mutation uses (createMarkdownAs,saveContentAs,moveFolderAs, …) rather than copying the mutation body. If none exists, extract one from the mutation first. - Enforce the token's workspace. Before touching any id argument, check that it
belongs to
access.workspaceId. The helpers check the entity's own workspace, which is not the same thing when a person belongs to several. - Throw
ConvexErrorfor messages the agent should read. The gateway replaces any other error with "Tool execution failed". Wrap the body inagentGuardso access errors come through. - Register it in
mcp.tswithidentityArg: "caller", annotations, and a short description written for a model. Addmetadata: WRITESif it changes anything. - Shape the output for a context window. Put ids next to names, use paths instead
of parent-id chains, page long text, and leave optional fields undefined rather than
null.
Operations
Crons in convex/crons.ts:
| Job | Interval | What |
|---|---|---|
| purge expired agent tokens | 1 h | Expired personal tokens, OAuth grants past refresh expiry, unused codes |
| prune MCP audit log | 6 h | Gateway audit rows older than 30 days |
| prune idle MCP sessions | 6 h | Legacy sessions idle for 24 hours |
- Audit: the gateway writes one row per tool call (
mcpGatewaycomponent,audittable), recording tool, caller, outcome, and duration. Browse it in the Convex dashboard under the component's data. - OAuth discovery config is written to the gateway's tables on the first request each
process serves, from
CONVEX_SITE_URL. Dev, preview, and prod need no setup step. - Env:
SITE_URL(web app origin) is required for OAuth. Semantic search needs an embedding provider key (see README → Configuration); without one,searchfalls back to keyword and reportsmode: "keyword".
Testing by hand
Stateless call with a personal token:
URL=https://<deployment>.convex.site/mcp
TOKEN=ctx_pat_...
META='"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{},"io.modelcontextprotocol/clientInfo":{"name":"curl","version":"1"}}'
curl -s -X POST $URL \
-H 'content-type: application/json' \
-H 'accept: application/json, text/event-stream' \
-H 'mcp-protocol-version: 2026-07-28' \
-H 'mcp-method: tools/call' -H 'mcp-name: workspace_overview' \
-H "authorization: Bearer $TOKEN" \
-d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"workspace_overview\",\"arguments\":{},$META}}"
Check discovery:
curl -s -i -X POST $URL -H 'content-type: application/json' -d '{}' | grep -i www-authenticate
curl -s https://<deployment>.convex.site/.well-known/oauth-authorization-server
The MCP Inspector also works: point it at the URL and choose OAuth or a bearer header.
Rate limits
Requests over a limit get HTTP 429 with a Retry-After header in seconds. The limits
sit well above what a person or a well-behaved agent does:
| Endpoint | Limit | Counted per |
|---|---|---|
/mcp (every request, tool calls included) | 120 a minute, bursts of 60 | bearer token, or address before one is sent |
/oauth/token | 60 a minute, bursts of 20 | client |
/oauth/register | 20 an hour, bursts of 10 | address |
Semantic search (in the web app, in the assistant, and through the search tool here)
and voice transcription share a separate limit of 60 a minute per person, because each
call is a paid model request. It is per person rather than per token, so a second token
does not buy a second budget; a search past it still runs on literal matches and says
mode: "keyword". The limits are defined in
convex/lib/rateLimits.ts. If the limiter itself is overloaded, requests are let through
rather than refused.
Known limits
- Client metadata fetches need a signed-in person and reject IP literals and
localhost, but a hostname that resolves to a private address is not checked. - Tool results are pretty-printed JSON. The gateway formats them with two-space indentation, which costs some tokens.
- Error text includes a stack trace on dev deployments. Expected to be absent on prod, not yet confirmed.
- No Tasks. No current tool runs long enough to need them. Prompts are served by
convex/mcpPrompts.ts, since the gateway has no prompts primitive.