On this page

    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, plus Mcp-Method / Mcp-Name headers. Nothing is stored between requests.
    • 2025-03-26 / 06-18 / 11-25 (sessions). initialize creates a session row. Each later request costs one session read and one small write.

    Captured from real traffic on 2026-09-13:

    ClientFirst requestVersion
    Claude Code 2.1.270server/discover2026-07-28
    Codex CLI 0.154.0initialize2025-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.

    FileRole
    mcp.tsTool and resource registration, server instructions, token resolution, the authorize callback, request handling
    mcpPrompts.tsThe prompt catalog, and the prompts/list / prompts/get handling the gateway does not provide
    agentApi.tsRead tools (internal queries and the search action)
    agentWrite.tsWrite tools (internal mutations)
    lib/agentCaller.tsagentAccess (caller → access, scope check) and agentGuard (access errors → ConvexError)
    agentTokens.tsPersonal tokens, token resolution, expiry purge, audit and session pruning
    oauth.tsThe authorization server
    lib/tokens.tsToken minting, hashing, PKCE
    http.tsRoutes for /mcp, /.well-known/*, /oauth/*
    apps/web/app/app/settings/agents/page.tsxSettings → Agents: setup snippets, token creation, connection list
    apps/web/app/oauth/consent/page.tsxThe consent screen

    Request flow

    text
    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 handleMcp gives the gateway. No token gets the same 401 with a WWW-Authenticate challenge, 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 / ttlMs envelope 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

    1. Write an internal function in agentApi.ts (read) or agentWrite.ts (write). Give it a caller: mcpCallerValidator argument. Do not use getAuthUserId, requireAccess or requireDocument there: the gateway strips ctx.auth, so they always fail. Call agentAccess(ctx, caller, capability), then documentFor/folderFor for entity-level checks.
    2. Reuse the shared helpers. For writes, call the *As helper the web mutation uses (createMarkdownAs, saveContentAs, moveFolderAs, …) rather than copying the mutation body. If none exists, extract one from the mutation first.
    3. 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.
    4. Throw ConvexError for messages the agent should read. The gateway replaces any other error with "Tool execution failed". Wrap the body in agentGuard so access errors come through.
    5. Register it in mcp.ts with identityArg: "caller", annotations, and a short description written for a model. Add metadata: WRITES if it changes anything.
    6. 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:

    JobIntervalWhat
    purge expired agent tokens1 hExpired personal tokens, OAuth grants past refresh expiry, unused codes
    prune MCP audit log6 hGateway audit rows older than 30 days
    prune idle MCP sessions6 hLegacy sessions idle for 24 hours
    • Audit: the gateway writes one row per tool call (mcpGateway component, audit table), 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, search falls back to keyword and reports mode: "keyword".

    Testing by hand

    Stateless call with a personal token:

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

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

    EndpointLimitCounted per
    /mcp (every request, tool calls included)120 a minute, bursts of 60bearer token, or address before one is sent
    /oauth/token60 a minute, bursts of 20client
    /oauth/register20 an hour, bursts of 10address

    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.
    MoveOpen