Authentication
The MCP server authenticates clients with OAuth 2.1 and PKCE. There is no static API key, no client-secret-only path, and no implicit grant.
A client moves through three steps:
- Register. The user creates a client record at Settings → Connected MCP clients in the Evenhand web app and supplies a redirect URI.
- Authorize. The client opens the authorization URL in the user's browser. The user signs in (if needed), reviews scopes on the consent screen, and clicks Allow. The server returns a single-use authorization code via the redirect URI.
- Exchange. The client posts the code and PKCE verifier to the token endpoint and receives an access token and refresh token.
Every MCP request after that carries the access token in Authorization: Bearer <access_token>. Missing, malformed, expired, or revoked tokens are rejected with HTTP 401.
Endpoints
| Method | URL | Purpose |
|---|---|---|
| GET | https://mcp.evenhandhq.com/api/mcp/authorize | Render the consent screen. |
| POST | https://mcp.evenhandhq.com/api/mcp/authorize | Record the user's decision; redirect with the code. |
| POST | https://mcp.evenhandhq.com/api/mcp/token | Exchange the code or refresh token for new tokens. |
Authorization request
The client redirects the user's browser to the authorize endpoint with these query parameters:
| Parameter | Required | Notes |
|---|---|---|
client_id | Yes | The id returned at registration. |
redirect_uri | Yes | Must match the URI registered for the client exactly. |
response_type | Yes | Always code. |
code_challenge | Yes | PKCE challenge derived from a per-flow verifier. |
code_challenge_method | Yes | Always S256. plain is not accepted. |
scope | Yes | Space-separated. mcp:read is the minimum; mcp:write requires consent. |
state | Yes | Opaque CSRF token the client echoes back from the redirect. |
Example:
GET /api/mcp/authorize
?client_id=mcc_3f9b...c1
&redirect_uri=https%3A%2F%2Fclient.example.com%2Fcallback
&response_type=code
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256
&scope=mcp%3Aread%20mcp%3Awrite
&state=8c1f7e7e-6b4a-4a1f-9d76-9d3b2f8f3a52 HTTP/1.1
Host: mcp.evenhandhq.com
If the user is not signed in to Evenhand, the server redirects to the sign-in page and back. On approval, the server 302-redirects to:
https://client.example.com/callback?code=mcac_a1b2...e9&state=8c1f7e7e-...
The code is single-use and short-lived. Bind it to the verifier you used to generate code_challenge.
Token exchange
Exchange the authorization code for tokens:
curl -X POST https://mcp.evenhandhq.com/api/mcp/token \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=authorization_code" \
--data-urlencode "code=mcac_a1b2...e9" \
--data-urlencode "code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" \
--data-urlencode "client_id=mcc_3f9b...c1" \
--data-urlencode "redirect_uri=https://client.example.com/callback"
application/json is also accepted with the same field names.
A successful response is:
{
"access_token": "mcat_4r7p...d8",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "mcrt_9k2v...11",
"scope": "mcp:read mcp:write"
}
| Field | Notes |
|---|---|
access_token | Bearer credential for the MCP server. Treat as opaque. |
token_type | Always Bearer. |
expires_in | Seconds until the access token expires. Currently 3600 (1 hour). |
refresh_token | Long-lived credential for refresh. Treat as opaque. |
scope | Scopes actually granted. May be a subset of what was requested. |
Token lifetimes
| Token | TTL | Rotation |
|---|---|---|
| Access token | 1 hour | Not rotated; obtain a new one. |
| Refresh token | 30 days | Rotated on every successful use. |
Refresh-token rotation means each successful refresh returns a new refresh token and invalidates the one you sent. Clients must persist the new refresh token atomically. If a rotated token is replayed, the entire token family is revoked — a deliberate countermeasure against stolen-refresh-token replay.
Scopes
| Scope | Grants |
|---|---|
mcp:read | Calling any read tool. |
mcp:write | Calling any write tool. Layered with per-tool and per-deal gates — see Write gating. |
Scopes are coarse on purpose: they decide what kinds of tools the access token may attempt to call. The fine-grained authorization a write tool actually requires is enforced inside the dispatcher, not at the OAuth layer.
A request that calls a write tool with a mcp:read-only token is rejected with a structured permission_denied response. Same for a missing per-tool grant or a missing per-deal opt-in — see Write gating for the response shape.
Refresh access tokens
When an access token is close to expiring — or when a tool call returns HTTP 401 with invalid_token — exchange the refresh token for a new pair:
curl -X POST https://mcp.evenhandhq.com/api/mcp/token \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=refresh_token" \
--data-urlencode "refresh_token=mcrt_9k2v...11" \
--data-urlencode "client_id=mcc_3f9b...c1"
A successful response is the same shape as the initial token exchange, with a fresh access_token and a rotated refresh_token:
{
"access_token": "mcat_8t1q...c4",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "mcrt_2x6w...3a",
"scope": "mcp:read mcp:write"
}
Discard the refresh token you sent. Persist the new one before retrying the failed tool call.
If the refresh token is expired, revoked, or has already been rotated once, the response is HTTP 400 with error: "invalid_grant". Start a new authorization flow.