Skip to main content

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:

  1. Register. The user creates a client record at Settings → Connected MCP clients in the Evenhand web app and supplies a redirect URI.
  2. 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.
  3. 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

MethodURLPurpose
GEThttps://mcp.evenhandhq.com/api/mcp/authorizeRender the consent screen.
POSThttps://mcp.evenhandhq.com/api/mcp/authorizeRecord the user's decision; redirect with the code.
POSThttps://mcp.evenhandhq.com/api/mcp/tokenExchange 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:

ParameterRequiredNotes
client_idYesThe id returned at registration.
redirect_uriYesMust match the URI registered for the client exactly.
response_typeYesAlways code.
code_challengeYesPKCE challenge derived from a per-flow verifier.
code_challenge_methodYesAlways S256. plain is not accepted.
scopeYesSpace-separated. mcp:read is the minimum; mcp:write requires consent.
stateYesOpaque 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"
}
FieldNotes
access_tokenBearer credential for the MCP server. Treat as opaque.
token_typeAlways Bearer.
expires_inSeconds until the access token expires. Currently 3600 (1 hour).
refresh_tokenLong-lived credential for refresh. Treat as opaque.
scopeScopes actually granted. May be a subset of what was requested.

Token lifetimes

TokenTTLRotation
Access token1 hourNot rotated; obtain a new one.
Refresh token30 daysRotated 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

ScopeGrants
mcp:readCalling any read tool.
mcp:writeCalling 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.