# For developers

> Protocol versions, how the MCP server is built, how to add a tool, operations and known limits.

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

| 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

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

| 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 (`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:

```sh
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:

```sh
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](https://github.com/modelcontextprotocol/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.
