OAuth Support
The client supports the MCP OAuth authorization flow for servers that require authentication.
Flow
- Metadata discovery: RFC 9728 protected-resource metadata (
/.well-known/oauth-protected-resource) first — the authorization server is located viaauthorization_servers[0]before its RFC 8414 metadata is fetched (a missingresourceis carried over from the protected-resource document); each failed stage logs a warning and falls back — first to direct RFC 8414 discovery, finally to hardcodedserver_urldefaults (issuer=server_url,authorize/tokensuffixes) — never failing hard - Authorization Code + PKCE with S256 code challenge
- Client identity: when
client_idis anhttp(s)://URL, the Client ID Metadata Document (CIMD) is fetched and parsed; Dynamic Client Registration (DCR, HTTP POST to the registration endpoint) runs only when noclient_idis configured - Resource indicator: the authorization request and all token requests carry a
resourceparameter per RFC 8707 - Token refresh via preemptive expiry check (not 401-driven)
- Token revocation via manual
Revoke()call (best-effort call to the RFC 7009 revocation endpoint; failure does not affect local token clearing)
OAuthClientOptions
| Field | Type | Description |
|---|---|---|
server_url | string | Authorization server base URL |
redirect_uri | string | OAuth redirect URI |
client_id | optional<string> | Client identifier (auto-registered if absent) |
client_secret | optional<string> | Client secret (optional) |
scopes | vector<string> | Requested OAuth scopes |
scope_filter | function<vector<string>(const vector<string>&)> | Pre-send scope hook (empty by default, scopes sent as-is): the return value replaces scope in the authorization URL and the client_credentials token request; refresh requests carry no scope and a missing persisted scope falls back to the previous value, unaffected by this hook |
token_cache | shared_ptr<ITokenCache> | Token persistence (default: InMemoryTokenCache) |
resource | optional<string> | RFC 8707 resource indicator sent with the authorization request and token requests; falls back to the discovered metadata resource, then to server_url; when the resolved value is empty the parameter is omitted entirely; persisted as the token's audience — cached tokens are treated as invalid once the indicator changes |
application_type | string | application_type sent during dynamic registration (default "native") |
private_key_pem | optional<string> | PKCS#8 EC private key PEM (P-256) used for private_key_jwt client authentication; mutually exclusive with client_secret — once configured, client_secret is no longer sent |
signing_algorithm | string | Client assertion signing algorithm (default "ES256"; it is the only supported value, anything else throws McpError) |
client_assertion_clock_seconds | function<int64_t()> | Clock hook providing the client assertion iat/exp (defaults to the system clock; lets tests inject a fixed time) |
authorization_redirect_handler | function<void(string_view url)> | Callback to open the authorization URL |
authorization_code_callback | function<optional<AuthorizationCodeResult>()> | Callback returning the authorization code, the echoed state, and the optional iss (AuthorizationCodeResult{code, state, iss}), nullopt on failure |
enable_dpop | bool | DPoP switch (default false): proofs are minted and server DPoP-Nonce values observed only when on (requires OpenSSL) |
token_update_handler | function<void(const TokenContainer&)> | Notification callback fired after tokens are stored/refreshed |
Setup
OAuthClientOptions oauth_opts;
oauth_opts.server_url = "https://auth.server.com";
oauth_opts.redirect_uri = "http://localhost:3000/callback";
oauth_opts.client_id = "my-client";
oauth_opts.scopes = {"profile", "email"};
oauth_opts.authorization_redirect_handler =
[](std::string_view url) {
// Open URL in browser for user to authorize
};
oauth_opts.authorization_code_callback =
[]() -> std::optional<AuthorizationCodeResult> {
// Return the authorization code, the `state` echoed back
// by the authorization server (CSRF protection), and the `iss`
// carried by the authorization response (RFC 9207; nullopt when absent)
return AuthorizationCodeResult{"auth-code", "state", std::nullopt};
};
auto auth = std::make_shared<OAuthClientProvider>(oauth_opts);
auth->Authenticate();
auto token = auth->GetAccessToken();OAuthClientProvider is a standalone class — it is not passed to McpClient::Create.
API
| Method | Description |
|---|---|
Authenticate() | Full OAuth flow: discover → register → authorize → token exchange (returns bool) |
GetAccessToken() | Returns valid access token, auto-refreshes if expiring soon; throws McpError (InternalError) on refresh failure; concurrent refreshes coalesce into one flight, owner re-entry short-circuits |
RefreshTokens() | Force-refresh using stored refresh token (returns bool); concurrent refreshes coalesce into one flight, owner re-entry short-circuits |
IsAuthenticated() | Check if stored token is present and not expired |
HasToken() | Check if any token exists (may be expired) |
GetAuthorizationHeader() | Returns "Bearer {token}" string |
StepUpAuthorization(scopes) | Re-authorize with additional scopes (returns bool) |
AuthenticateClientCredentials() | Client credentials grant (RFC 6749 §4.4) for service-to-service scenarios without user interaction (returns bool) |
HandleAuthChallenge(www_authenticate) | Handles a 401/403 authentication challenge header (RFC 9728); retries the original request on success (returns bool) |
Revoke() | Best-effort call to the RFC 7009 revocation endpoint (when revocation_endpoint is configured); clears local tokens regardless of outcome |
Resource Indicator and Issuer Validation
- RFC 8707
resource: resolved as "explicitoptions.resource→ discovered metadataresource→server_url". When the resolved value is non-empty it is injected into the authorization URL and all three token requests (client_credentials, authorization-code exchange,refresh_token). - Audience binding: token responses are persisted together with the current resource indicator (
TokenContainer.resource, also persisted byFileTokenCache); once the indicator changes,Authenticateclears the mismatched cache first, andGetAccessToken/IsAuthenticated/HasToken/GetAuthorizationHeaderall behave as if no valid token exists. - RFC 9207
iss: on the authorization-response side the comparison runs after thestatecheck and before the code is exchanged — a non-emptyAuthorizationCodeResult::issthat differs from the discovered metadata issuer is rejected, while an absentissis not validated. On the token/refresh response side theissis "validated when present": a stringissthat differs from the issuer is rejected, a non-stringissis likewise rejected, and a missingisspasses. This follows RFC 9207 §2/§2.4 (and the MCP specification's Authorization Response Validation table), which bind obligations only to theissof the authorization response — §2.4 explicitly allows clients to accept responses withoutissper local policy; a token response that does carryissis still validated strictly to prevent mix-up attacks. - Issuer binding:
issueris a required field — authorization server implementations missing it should add it; the current implementation logs a warning on a missing issuer and continues down the fallback chain (direct RFC 8414 discovery first, then hardcodedserver_urldefaults), to be tightened into a hard failure in a future version. The first discovery binds the issuer, and a later issuer change clears dynamic registration info and local tokens before rebinding. - expectedResource comparison: the client-side
VerifyResourceMatchrequires the discovered metadataresourceto equalserver_urlexactly or match its scheme+host prefix (a missing/empty field passes); the server-side audience gate (active whenvalidate_token_resourceis true andexpected_resourceis non-empty) requires the tokenresourceto be non-empty and equal toexpected_resource, otherwise 401invalid_token. Both sides share the same normalization (strip#fragment, strip trailing slash, seeNormalizeResourceUrl), but the direction is asymmetric — the server rejects a missing audience while the client passes a missingresourcefield. - Client authentication methods: the sending side supports
none,client_secret_post(client_secretsent with the form) andprivate_key_jwt— whenprivate_key_pemis configured and the discovered metadata'stoken_endpoint_auth_methods_supportedcontainsprivate_key_jwt, all three token requests (client_credentials, authorization-code exchange,refresh_token) sendclient_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearerplus an ES256-signedclient_assertioninstead ofclient_secret(and no longer fail merely becauseclient_secretis absent). The assertion JWT header is{alg:ES256,typ:JWT}and its payload is{iss,sub,aud,jti,iat,exp}(iss/subare theclient_id,audprefers the metadataissuerand falls back totoken_endpointonly when the issuer is absent — RFC 7523 §3 defines the exactaudstring as agreed out of band by the authorization server and the assertion issuer, with the token endpoint URL only a MAY,exp=iat+60).jwt-bearer(resource-owner assertion) remains unsupported, andtoken_endpoint_auth_methodis still only adopted informationally (stored in the registration info). - CIMD (Client ID Metadata Document): only an
http(s)://-shapedclient_idis treated as a document URL — the client metadata document at that URL is fetched and parsed (adopting itstoken_endpoint_auth_method; documents with more than 128grant_types/redirect_urisentries, aclient_idfield that does not match the requested URL, or a configuredredirect_urimissing from the document'sredirect_urisare refused). Failures are best-effort (a warning is logged and the configured values are used) and do not abort the authorization flow; dynamic registration sendsapplication_type(default"native"). - Challenge hardening:
HandleAuthChallengereturns false on oversized/illegal input (never throws); illegal URLs and mismatched resources are both refused.
Integration with Server-Side Bearer Auth
The SDK ships built-in RFC 6750/9728 bearer auth on the server side (StreamableHttpServerOptions::bearer_auth, see Transports): failed verification returns a 401/403 challenge, the WWW-Authenticate header references the protected-resource metadata URL, and the metadata document is optionally served at /.well-known/oauth-protected-resource. When expected_resource is non-empty the token audience is additionally validated (mismatch → 401 invalid_token, split from the scope-gap 403).
The client has two integration points:
HttpClientTransportOptions::auth_challenge_handler(see Transports): always invoked on 401, invoked on 403 only whenWWW-Authenticatecontainsinsufficient_scope; returning a non-emptyAuthorizationheader retries the request exactly once. Inside the callback you can delegate toOAuthClientProvider::HandleAuthChallenge(www_authenticate)to parse the challenge and obtain a fresh token.HandleAuthChallenge(www_authenticate)(returnsbool): parses the metadata URL from the challenge per RFC 9728 to discover the authorization server and re-runs the authorization flow when needed.
DPoP Sending-Side Wiring
With OAuthClientOptions::enable_dpop on, bind ApplyDPoPProofHeader/ObserveDPoPNonce to the two HttpClientTransportOptions callbacks to drive a DPoP session:
http_opts.apply_per_request_headers =
[auth](const HttpRequestHeaderContext& ctx,
std::map<std::string, std::string>& headers) {
auth->ApplyDPoPProofHeader(ctx, headers);
};
http_opts.observe_response_challenge =
[auth](const HttpResponseChallengeContext& ctx) {
auth->ObserveDPoPNonce(ctx.dpop_nonce);
};htuis derived fromcontext.urlwith query and fragment stripped before the proof is minted,htmcomes fromcontext.http_method; each call mints a fresh proof, sojti/iatare new per request.- On 401/400 with
use_dpop_nonceinWWW-Authenticateor the first 4KB of the response body, the request is retried exactly once (single budget shared with theauth_challenge_handlerretry).
PKCE Helpers
PKCE helpers reside in the pkce namespace within <mcp/client/auth/OAuthClientProvider.hpp>.
auto verifier = pkce::GenerateCodeVerifier();
auto challenge = pkce::ComputeCodeChallenge(verifier);
auto encoded = pkce::Base64UrlEncode(input);Token Cache
The SDK provides two implementations of ITokenCache (defined in <mcp/client/auth/TokenCache.hpp>):
| Implementation | Persistence | Protection |
|---|---|---|
InMemoryTokenCache | Runtime only | None |
FileTokenCache (<mcp/storage/FileTokenCache.hpp>) | JSON file (DPAPI encrypted on Windows) | chmod 0600 on POSIX, CryptProtectData (DPAPI) on Windows |
#include <mcp/storage/FileTokenCache.hpp>
auto token_cache = std::make_shared<FileTokenCache>("./tokens.json");
OAuthClientOptions oauth_opts;
oauth_opts.token_cache = token_cache;Requirements
OpenSSL development headers are an optional dependency, enabled by the CMake switch MCP_WITH_OPENSSL (AUTO/ON/OFF, default AUTO) together with the find_package(OpenSSL) result: under AUTO it is enabled when a local installation is found, ON force-enables it (configure fails when not found), and OFF force-disables it (no OpenSSL even when installed). When enabled, the single INTERFACE target mcp-openssl defines MCP_HAVE_OPENSSL and links OpenSSL::Crypto/OpenSSL::SSL for mcp-transport/mcp-client/mcp-ext; the build system no longer sniffs headers via __has_include to infer OpenSSL availability (a visible header does not mean the macro is defined, let alone that OpenSSL is linked). PKCE code verifier generation uses RAND_bytes when built with OpenSSL, falling back to BCryptGenRandom (Windows) or std::random_device (POSIX), with SHA-256 from the built-in implementation. Without OpenSSL, TLS is disabled (TlsSocket::Connect throws TlsHandshakeFailed). Install OpenSSL (vcpkg install openssl / apt install libssl-dev / brew install openssl) to enable TLS (handled by the self-hosted network stack), cryptographic-grade randomness, and the ES256 ECDSA signing used by private_key_jwt client assertions and DPoP proofs; without OpenSSL EC support both the private_key_jwt assertion (when private_key_pem is configured) and DPoP proofs throw McpError (InternalError).