Auth & OAuth Internals (Maintainers)
This guide is for contributors who maintain or extend MXCP authentication. For user-facing setup, see Authentication.
Goals of this guide
Section titled “Goals of this guide”It focuses on:
- Architecture: which components own which responsibilities
- Security invariants: what must not change without careful review
- Extension points: how to add a provider or a storage backend safely
- Debugging: how to diagnose common failure modes quickly
OAuth flow overview
Section titled “OAuth flow overview”These are the calls involved in the OAuth flow:
/register: the client registers itself with aclient_idin an IdP. It’s optional since the client can be pre-registered in the IdP, and configured in the client. (For example our MXCP server config implies we’ve registered it as an app in the IdP.)/authorize: the client initiates the flow. That call contains itsclient_id, aredirect_uriand optionally astateand acode_challengeif using PKCE.stateis eventually returned by the IdP as it redirects the browser toredirect_uri. Thestatelets the client confirm the data belongs to the correct request- For PKCE, the client generates a random
code_verifierand derives acode_challengefrom it. The IdP stores the challenge and later verifies it when the client sends thecode_verifierat/token, proving possession of the original verifier. The IdP redirects the browser to the client’sredirect_uriwith acode(nothing to do withcode_challenge) that is meant to be exchanged for the final access token by/token.
/tokenis called with the authorization code (thecodereturned by the redirection that occurred during/authorize). The client also sends thecode_verifierif using PKCE, which is used by the token handler to validate the call against the initialcode_challenge. It then returns the access token.
MXCP issuer implementation
Section titled “MXCP issuer implementation”Scope domains (critical distinction)
Section titled “Scope domains (critical distinction)”MXCP issuer-mode tracks two different scope sets. Do not mix them:
- Provider scopes (MXCP ↔ upstream IdP)
- What MXCP requests from / receives from the upstream provider token endpoint.
- Used for provider
/tokenexchange and provider refresh operations. - Persisted on state as
StateRecord.provider_scopes_requestedso we can pass it intoProviderAdapter.exchange_code(scopes=...)and apply correct OAuth scope fallback when the provider omitsscopein its token response.
- MXCP scopes (MXCP ↔ OAuth/MCP client)
- What MXCP returns to the client in token responses (
scopefield) and uses for MXCP authorization. - Currently hardcoded to
[](empty). Persisted on auth codes asAuthCodeRecord.mxcp_scopes.
- What MXCP returns to the client in token responses (
- Capabilities (policy attributes)
- Per-request policy attributes derived by
CapabilityMapperfrom the user’s IdP claims. - NOT stored at auth time — computed on each request in
require_user_info.
- Per-request policy attributes derived by
How CapabilityMapper works
Section titled “How CapabilityMapper works”CapabilityMapper translates IdP claims into MXCP capabilities using claim_mappings
configured on the provider in config.yml. On each authenticated request,
require_user_info calls mapper.derive(raw_profile) where raw_profile is the
user’s IdP claims dict (stored on the session at auth time).
Where raw_profile comes from
Section titled “Where raw_profile comes from”raw_profile is the merged dict that CapabilityMapper resolves claim paths against.
Its content depends on the auth mode and provider type:
-
Verifier mode (any provider):
raw_profileis the decoded JWT claims from the access token presented by the client. This typically contains all custom claims (roles, groups, etc.) because the access token JWT is the primary artifact. -
Issuer mode, OIDC providers (Keycloak, Auth0, Google, generic OIDC):
raw_profileis assembled from up to three sources, merged in this order (later sources win):- Access token JWT — decoded if the token is a JWT (Keycloak puts
realm_access.roleshere). Opaque tokens are gracefully skipped. - id_token JWT — decoded if present in the token response (Auth0 puts custom
namespace claims like
https://mycompany.com/roleshere). /userinfoendpoint response — always fetched. Containssub,email,name,preferred_username. Some IdPs can be configured to return custom claims here too (e.g. Keycloak “mappers”), but many don’t by default.
- Access token JWT — decoded if the token is a JWT (Keycloak puts
-
Issuer mode, non-OIDC providers (GitHub, Salesforce, Atlassian):
raw_profileis the JSON response from the provider’s user API (e.g. GitHub’s/user). These providers use opaque access tokens (no JWTs), soraw_profilecontains whatever the API returns:email,login,company,bio, etc. All of these fields are mappable.
In all cases, email is universally available and always mappable. Custom claims like
roles or groups depend on the provider and its configuration.
The mapper resolves each configured claim path against the profile (supporting top-level
keys like "https://mycompany.com/roles", dot-separated nested paths like
"realm_access.roles", and space-separated strings like OAuth scope values), then
maps matched values to capability strings. The result is a deduplicated set[str].
Capabilities are exposed as user.capabilities in CEL policy expressions:
policy: input: - condition: '!("admin" in user.capabilities)' action: deny reason: "Admin capability required"See mxcp.server.core.auth.capability_mapper.CapabilityMapper.
- When the client registers dynamically (DCR, Dynamic Client Registration), it calls
/registerand sends aclient_idit generated along with a list of itsredirect_uris. That step binds the client (identified byclient_id) to allowed redirect URIs.
- During the flow, a single
redirect_uriis used. It’s chosen by the client when it calls/authorizeand has to match one of the registered URIs. It is where the MXCP server will eventually send the MXCP authorization code (auth_code). That authorization code is eventually turned into the MXCP access token (the one appearing in the HTTP Authorization header in the end), by a call to/token.
- The client calls
/authorizewith itsclient_idand aredirect_uri. MXCP validatesredirect_uriagainst the stored client record. From then on, the goal of MXCP is to redirect the client’s browser to the IdP’s/authorize.
- The
/authorizestep optionally involves a safety check using a state. The MCP client can add astateto its/authorizecall. That state is eventually returned to the client who can use it to verify the message belongs to the particular request it initiated. It is a string it generates. MXCP stores it asclient_state, and eventually returns it to the client like the protocol expects. - MXCP itself generates a
state, another OAuthstatestring but for the MXCP/IdP side. It’s stored asstatein the code. That state is one-time and consumed on callback (see below). - In a regular OAuth flow involving a client and an IdP, the client communicates its
redirect_urito the IdP. The IdP eventually redirects the browser to thatredirect_uri, passing the client’sstateand the IdP’s generatedcode. codeis an IdP-generated short-lived code that can eventually be exchanged for an access token.- With MXCP in the picture, the client’s call to MXCP’s
/authorizeinstead redirects the browser to the IdP’s/authorize, but with MXCP’s details (itsclient_id, itsstate, itscode_challengeif the IdP supports PKCE, and with its MXCPcallback_url, the one configured with thecallback_pathconfig knob). Meaning the IdP’s answer to the/authorizeredirects the user’s browser to the MXCP callback, with the MXCP suppliedstateand the IdP’scode.
- Upon getting its own callback called, MXCP will redirect the browser to the
original client’s callback:
- Validates the
state(its own, now sent by the IdP). It consumes it and deletes it. - Calls the IdP to exchange the
codefor an access token using the IdP’s/tokencall. - Fetches user info from the provider.
- Decodes JWT claims from the access token and id_token (if present), and merges
them into
raw_profile(access_token < id_token < userinfo — later wins). No signature verification is needed since tokens were received over TLS from the token endpoint. This givesCapabilityMapperaccess to custom claims likerealm_access.roles(Keycloak, in the access token JWT) or custom namespace claims (Auth0, in the id_token). Non-JWT tokens are gracefully skipped. - Issues and persists an MXCP session (it contains the MXCP
access_tokenand refresh token, the IdP’s tokens, and provider granted scopes). - Creates and persists an MXCP
auth_code, which is meant to play the role of the OAuthcodesent to the MCP client. MXCP scopes on the auth code are currently hardcoded to[]. - Redirects the browser to the client’s
redirect_uriwith thecode(auth_code) and the original client’sstate(client_state).
- Validates the
- The client’s callback is called with the client’s original
state(if it was present) and MXCP’scode.
- The client calls MXCP’s
/tokenwith MXCP’s auth code, itsclient_id, andredirect_uri(used to validate the call on the server/MXCP side) plus its PKCEcode_verifier. MCP’s token handler validates the verifier against the storedcode_challenge, then MXCP returns theaccess_tokenit generated earlier, and arefresh_token.
Mental model
Section titled “Mental model”MXCP runs OAuth in issuer-mode:
- MCP clients authenticate to MXCP using OAuth.
- MXCP can authenticate users against an upstream IdP (via
ProviderAdapter— built-in adapters for GitHub, Atlassian, Salesforce, Google, Keycloak, and a generic OIDC adapter for any OIDC-compliant IdP).
The key idea is that the IdP callback always returns to MXCP, and then MXCP redirects to the MCP client.
Core components (new stack)
Section titled “Core components (new stack)”- Contracts:
mxcp.sdk.auth.contracts- Defines
ProviderAdapter,GrantResult,UserInfo,ProviderError.
- Defines
- Orchestration:
mxcp.sdk.auth.auth_service.AuthService- Drives
/authorize→ callback → code issuance → token exchange.
- Drives
- Lifecycle:
mxcp.sdk.auth.session_manager.SessionManager- Creates/consumes state, issues sessions, creates auth codes.
- Persistence:
mxcp.sdk.auth.storage.TokenStore+SqliteTokenStore- Source of truth for expiry + one-time use semantics and persistence across restarts.
- Server bridge:
mxcp.server.core.auth.issuer_provider.IssuerOAuthAuthorizationServer- Adapts MXCP’s auth stack to the MCP OAuth provider interface.
- Request auth:
mxcp.sdk.auth.middleware.AuthenticationMiddleware- Loads sessions by access token and sets user context.
Legacy stack
Section titled “Legacy stack”The legacy handler-based stack has been removed. Only the ProviderAdapter-based issuer-mode stack is supported.
OAuth flows (issuer-mode)
Section titled “OAuth flows (issuer-mode)”1) /authorize (client → MXCP)
Section titled “1) /authorize (client → MXCP)”- Input:
client_id,redirect_uri, optionalstate, optionalcode_challenge. - MXCP validates the client and redirect URI against persisted client registration.
- MXCP creates a StateRecord (one-time, expiring) to bind:
- client_id
- client redirect_uri
- downstream PKCE fields (client ↔ MXCP)
- upstream PKCE verifier (MXCP ↔ IdP), if used
- the original client
state(returned back to the client)
- MXCP stores the provider scopes requested in the StateRecord as
provider_scopes_requested(provider scopes are derived from server/provider configuration; client-supplied OAuth scopes are ignored for upstream IdP authorization). - The downstream
code_challengeis stored so the MCP token handler can verify the clientcode_verifierduring the/tokenexchange. - MXCP redirects the browser to the IdP
/authorize, using MXCP callback URL.
2) Callback (IdP → MXCP callback)
Section titled “2) Callback (IdP → MXCP callback)”- Input:
codeandstate(orerrorandstate). - MXCP consumes state (one-time) and exchanges provider code for provider tokens.
- Provider token exchange uses
StateRecord.provider_scopes_requestedas the requested provider scopes (this is important for correct OAuth behavior when the provider token response omitsscope). - MXCP issues:
- an MXCP session (opaque MXCP access token + refresh token)
- an MXCP authorization code bound to the session
- MXCP scopes are currently hardcoded to
[](stored on the auth code asAuthCodeRecord.mxcp_scopes) - MXCP redirects the browser to the client redirect_uri with the MXCP auth code and the original client state.
3) /token exchange (client → MXCP)
Section titled “3) /token exchange (client → MXCP)”- Input: MXCP auth code + downstream PKCE verifier.
- Token endpoint verifies PKCE (per MCP framework) and then MXCP:
- validates code binding (client_id / redirect_uri)
- ensures one-time use of the auth code
- returns MXCP access token (and refresh token)
- The token response
scopefield reflects MXCP scopes (not provider scopes).
Security invariants (“do not break”)
Section titled “Security invariants (“do not break”)”If you change code touching these rules, require a careful review.
- State is one-time use
- State must be consumed (deleted) on first use.
- Expired state must be rejected.
- Auth codes are one-time use
- Auth codes must be deleted on redemption (when the
auth_codeis exchanged for anaccess_tokenduring the call to/token). - Expired auth codes must be rejected.
- Auth codes must be deleted on redemption (when the
- Redirect URI binding is strict
redirect_urimust be validated against persisted client registration.- Never redirect to a URI that wasn’t safely derived from stored state/client metadata.
- Issuer-mode scopes policy
- OAuth client-requested scopes must not influence upstream IdP scopes.
- Upstream IdP scopes come from server/provider configuration and are persisted on state as
StateRecord.provider_scopes_requested. - When provider scope config is omitted or empty, MXCP requests no scopes upstream.
- When a provider token response omits
scope(allowed by OAuth), provider adapters treat the granted scopes as the requested provider scopes (do not interpret omission as “no scopes”). - Client-facing scopes returned by MXCP are MXCP scopes, currently hardcoded to
[], stored on auth codes (AuthCodeRecord.mxcp_scopes). - Refresh requests that include
scopemust follow OAuth semantics:- allowed: omitted (same scopes) or subset of previously-issued MXCP scopes
- forbidden: scope expansion
- PKCE boundaries are explicit
- Downstream PKCE: client ↔ MXCP token endpoint.
- Upstream PKCE: MXCP ↔ IdP token exchange (provider capability).
- No sensitive logging
- Never log tokens, secrets, emails, or user identifiers.
- Avoid logging raw exception messages if they may contain sensitive data.
- Session-first request auth
- Middleware must treat provider user-info refresh as best-effort.
- Provider failures must never block session-based authentication.
- Token persistence policy
- MXCP access tokens should be stored hashed.
- Provider tokens should be encrypted at rest when persistence is enabled.
Extension guide
Section titled “Extension guide”Add a new provider (IdP)
Section titled “Add a new provider (IdP)”If the IdP is OIDC-compliant, users can use the generic oidc provider (mxcp.sdk.auth.providers.oidc.OIDCProviderAdapter) instead of writing a dedicated adapter. The generic adapter auto-discovers endpoints from the IdP’s .well-known/openid-configuration document at startup via ensure_ready(). The server awaits this during startup (before transports start), so OAuth flows cannot begin until discovery completes.
For IdPs that require non-standard behavior (custom token exchange, non-OIDC user endpoints, etc.), implement ProviderAdapter under mxcp.sdk.auth.providers:
- Raise
ProviderError(error, description, status_code)for expected failures. - Normalize transport/network failures into
ProviderError(do not leak HTTP client exceptions). - Never log response bodies, tokens, secrets, or PII.
Coverage expectations:
tests/sdk/auth/test_<provider>_provider_adapter.py- authorize URL parameter correctness
- token error parsing (non-200, invalid JSON, OAuth error objects)
- scope semantics (omitted/empty provider scope → no scopes requested)
Add a new storage backend
Section titled “Add a new storage backend”Implement the TokenStore protocol:
- Enforce one-time state consumption and auth code one-time use.
- Honor TTL on reads and delete expired records.
- Ensure async safety (thread-safe if wrapping sync I/O).
Coverage expectations:
- Extend
tests/sdk/auth/test_token_store.pyfor backend parity.
Where to look
Section titled “Where to look”- State handling:
mxcp.sdk.auth.session_manager.SessionManagerandmxcp.sdk.auth.storage.TokenStore - Auth code redemption:
mxcp.sdk.auth.auth_service.AuthService.exchange_token - Server bridge validation:
mxcp.server.core.auth.issuer_provider.IssuerOAuthAuthorizationServer - Callback route behavior:
mxcp.server.interfaces.server.mcp.RAWMCP._register_oauth_routes - Capability mapping:
mxcp.server.core.auth.capability_mapper.CapabilityMapper