Skip to content

MCP proxy OAuth - client registration and authorization

An MCP client (an IDE, another agent runtime) that connects to the MCP proxy endpoint identifies itself with a Client ID Metadata Document (CIMD, draft-ietf-oauth-client-id-metadata-document, MCP authorization spec 2025-11-25). The client_id is an https:// URL; the gateway fetches the JSON document at that URL and uses it as the client's registration. A client that has no metadata document (for example Cursor) can register itself with Dynamic Client Registration (POST /oauth/register, RFC 7591); such a client is unverified. There is no client secret: every client is a public client.

This page describes the client resolver, the authorization endpoint, the consent API, the client-to-agent mapping, the registration endpoint, token endpoint, grant revocation, the authorization-server metadata and the protected-resource metadata that points a client at this server.

The resolver turns an untrusted client_id into a validated client record or one fixed error code. It fails closed: a missing field and a malformed field (including an explicit JSON null) are both refused.

The client_id URL

The URL is refused with invalid_client_id unless it:

  • is ASCII, at most 2048 bytes, with no whitespace, control characters, backslash, fragment or query;
  • uses https (plain http only for hosts on the gateway's egress allowlist);
  • has no userinfo, a valid host and port (1 to 65535, no leading zero), and a path other than / with no . or .. segments (also not percent-encoded).

Fetching

The host is resolved once and the connection is pinned to that address, so DNS rebinding cannot swap it. Private, loopback, link-local and metadata addresses are refused (fetch_blocked). The fetch follows no redirects, has a 5 second timeout and a 6 second total budget, and reads at most 5120 bytes. The response must be 200 with a application/json (or +json) Content-Type; a response carrying the Content-Type header more than once is refused. The request sends Accept: application/json.

Results are cached per worker: a valid client for 300 seconds, a failure for 30 seconds. These are fixed values; the upstream Cache-Control and Expires headers are ignored.

The document

Field Rule
client_id Must equal the fetch URL byte for byte.
redirect_uris Required array of 1 to 20 entries; duplicates are collapsed. Each is https, http on a loopback host (localhost, 127.0.0.1, [::1]), or a private-use scheme such as cursor:// (RFC 8252) with something after the separator (myapp:/ and myapp:// are refused). No fragment. ASCII only (send IDN hosts as punycode), at most 2048 bytes, no whitespace or control characters. The redirect URI presented at authorize time, including its port, must also be at most 2048 bytes. javascript:, data:, file: and similar are refused.
token_endpoint_auth_method Must be exactly none (public client, PKCE).
grant_types Default authorization_code; must contain it. Only refresh_token is kept beyond it.
response_types If present, must contain code.
client_name Optional; at most 200 bytes of valid UTF-8, no control characters (C0 incl. tab/newline/CR, DEL, C1 U+0080–U+009F), line/paragraph separators U+2028/U+2029, interlinear annotation controls U+FFF9–U+FFFB, braille blank U+2800, or Unicode default-ignorable code points (zero-width and bidi controls, soft hyphen, byte-order mark, word joiner / invisible operators, Hangul fillers, Unicode tag characters …) except a variation selector (U+180B–U+180D, U+180F, U+FE00–U+FE0F, U+E0100–U+E01EF) that directly follows a base character of its own kind (a symbol or emoji, a CJK ideograph, a Mongolian letter), at most one per base; any other variation selector is refused, so emoji (❤️), CJK variants and Mongolian text stay accepted; not blank.
client_uri, logo_uri, tos_uri, policy_uri Optional; must be https, ASCII only, at most 2048 bytes, no whitespace or control characters. A fragment (#...) is allowed.
client_secret, client_secret_expires_at Must not be present.

At authorization time a presented redirect_uri must match a registered one exactly. The only exception is a registered loopback IP literal (127.0.0.1 or [::1]), where the port may differ (OAuth 2.1 section 8.4.2).

Error codes

invalid_client_id, fetch_blocked, fetch_failed, fetch_timeout, too_large, bad_status, bad_content_type, invalid_json, client_id_mismatch, invalid_redirect_uris, unsupported_auth_method, invalid_metadata.

Client-to-agent mapping

A client can only act through an external agent that names it. Set oauth_client_id (the client's CIMD URL, byte for byte) on the external agent, see Agents. The mapping is per gateway: one external agent per (gateway, client_id). The URL is compared case-sensitively. A client with no mapped agent can start an authorization, but the request can only be denied.

A registered (unverified) client (dcr_ id) needs no mapping: while the workspace switch mcp_dcr_enabled is on, an approved authorization is bound to the gateway's unverified-clients agent (see Unverified clients). A named mapping always wins: once an administrator sets the registered client's dcr_ id as oauth_client_id on an external agent (promotion), new and existing grants of that client act through the named agent and lose the unverified-clients agent.

Authorization endpoint

GET /oauth/authorize on the gateway API host (the same host as the MCP endpoint). It is public (no session), answers 405 with Allow: GET to other methods, and never renders a page: it validates the request, stores a pending authorization request for 300 seconds and redirects the browser to the web app's consent route /oauth/consent?request=<handle>. The handle (mcr_ plus 64 hex characters) is a bearer secret; only its hash is stored. Every answer carries Cache-Control: no-store and Referrer-Policy: no-referrer.

Parameter Rule
client_id Required. The CIMD URL, see above.
redirect_uri Required. Must be a valid redirect URI and match one registered in the client's document (loopback IP literals may differ in port).
response_type Required, exactly code.
code_challenge Required, 43 to 128 characters of A-Z a-z 0-9 . _ ~ -.
code_challenge_method Required, exactly S256. plain is refused.
state Optional, 1 to 500 visible ASCII characters. Returned unchanged. A malformed value is an error and is not echoed.
scope Optional, at most 1024 visible ASCII characters and spaces. Validated and ignored: the agent's contract is the scope.
resource Required (RFC 8707). Must equal <issuer>/v1/{tenant}/{gateway}/mcp of an existing gateway that requires authentication.

A parameter given twice, or without a value (?state), is malformed, never absent. More than 64 query parameters is refused. Absent and malformed values never share a permissive outcome.

Exact-origin rule

The resource origin must equal the origin the client connected to, byte for byte: the Host header including any port, with the scheme https (http only on test environments). https://ai-api.myra.eu/v1/acme/support/mcp is accepted; https://ai-api.myra.eu:443/v1/acme/support/mcp is invalid_target. Copy the resource URL verbatim. The issuer (iss) is the same origin. A missing or malformed Host answers 400 {"error":"invalid_request"} without a redirect.

Two error channels

Until the redirect_uri is validated against the client's document, the gateway never redirects to it (RFC 6749 section 4.1.2.1).

E1: no trusted redirect. 302 to the web app: /oauth/consent?error=<code>. Nothing else from the request is echoed.

Code Cause
invalid_request client_id or redirect_uri absent or malformed (repeated, valueless); more than 64 parameters
invalid_client client_id is neither a valid CIMD URL nor a registered dcr_ id (including an http:// client_id outside the operator loopback allowlist), or its document is invalid
client_unavailable The document could not be fetched (blocked, timeout, too large, bad status, content type or JSON)
invalid_redirect_uri redirect_uri is invalid, or is not registered in the client's document
rate_limited Pre-authentication throttle: 30 requests per minute per IP (IPv6: per /64 — see How the public rate limits behave), 300 per minute globally
server_error Reserved set member for the web app screen

If the web app origin is not configured, every outcome is 503 {"error":"server_error"}.

E2: redirect validated. 302 to the redirect_uri with error, state (only when well-formed) and iss (RFC 9207), percent-encoded; an existing query string is extended with &.

Code Cause
invalid_request response_type, code_challenge, code_challenge_method or state absent or malformed
unsupported_response_type response_type is present and not code
invalid_scope scope malformed
invalid_target resource absent, wrong origin, not an MCP proxy path (query, fragment and trailing slash are refused), unknown gateway, or a gateway without authentication
temporarily_unavailable The gateway already has 100 pending requests, or the authorize throttle is hit (see Throttle)
unauthorized_client client_id is a registered (dcr_) client and dynamic client registration is switched off for the workspace
server_error Storage or gateway lookup fault

Session-gated (the web app's signed-in user), admin host. Mirrors the device approval gates. {request} is the handle from the consent route. A malformed, unknown, expired or already-decided handle, and a request for a gateway the user cannot reach, all answer the same 404 {"error":"not found"}. Each user may make 30 consent calls per 5 minutes (429).

Route Result
GET /admin/v1/mcp-oauth/requests/{request} 200 {client: {client_id, client_name, client_uri, logo_uri, host, verified}, agent: {id, name, purpose, external_platform, system_role} or null, gateway: {id, name}, resource, expires_at, approvable, refusal}. verified is true for a client identified by a metadata document URL and false for a client that registered itself through POST /oauth/register; client_name is the client's own unverified claim; client_name, client_uri, logo_uri and host are JSON null when absent (host is the host of the client_id, null for a non-https test client and for a registered (dcr_…) client); agent and refusal are always present, as an explicit JSON null when unset. refusal is null or the first failing reason: client_not_registered, dcr_disabled, no_tenant_admin, outside_tenant, role, routing (the user's department may not route via this gateway). If the department policy cannot be read (transient), the GET answers 503 department_policy_unavailable instead of a refusal, as approve does
POST /admin/v1/mcp-oauth/requests/{request}/approve 200 {redirect_to} with code (mca_ plus 64 hex, single use, valid 60 seconds), state and iss. Gates in order: not impersonating, author role, throttle, reachable (404), same tenant (GATEWAY_OUTSIDE_OWNER_TENANT), the user's department routing gate, for an unverified client the switch on (409 mcp_oauth_dcr_disabled, checked before the mapping), agent mapped and still matching (409 MCP_OAUTH_CLIENT_NOT_REGISTERED), and for an unverified client an active tenant admin (409 mcp_oauth_no_tenant_admin) and a free agent slug (409 mcp_oauth_slug_exhausted). A second approve is 404
POST /admin/v1/mcp-oauth/requests/{request}/deny 200 {redirect_to} with error=access_denied, state and iss. Any signed-in user who can reach the gateway; refused while impersonating (same guard as the GET). A decided request is 404

The user who approves is the grant user: the tokens redeemed from the code act as that user. Every decision writes one audit event (mcp_oauth_consent, action approve or deny) with the actor, gateway, agent and the client host; the handle and the code are never logged. Pending and decided requests are deleted an hour after they expire.

Token endpoint

POST /oauth/token on the gateway API host. Public client (token_endpoint_auth_method none), body application/x-www-form-urlencoded (anything else is 400 invalid_request), other methods 405 with Allow: POST. Every answer, success or error, carries Cache-Control: no-store and Pragma: no-cache. Each IP may make 60 requests per minute, 600 per minute globally (429 invalid_request with Retry-After: 60).

A client credential of any kind (Authorization header, client_secret, client_assertion, client_assertion_type) is refused with 401 invalid_client; it is never silently ignored. A parameter given twice, or without a value, is malformed, never absent.

Parameter authorization_code refresh_token
grant_type authorization_code refresh_token
client_id required, the CIMD URL or the registered dcr_ id required, must be the grant's client
code required, mca_ plus 64 hex -
redirect_uri required, at most 2048 bytes, byte-identical to the authorization request -
code_verifier required, 43 to 128 characters of A-Z a-z 0-9 . _ ~ -; PKCE S256 -
resource required, 1 to 2048 bytes, must equal the authorization request's optional; if present (1 to 2048 bytes) it must equal the grant's
refresh_token - required, mor_ plus 64 hex

A success is 200 {"access_token","token_type":"Bearer","expires_in":600,"refresh_token"}. The access token (myra_...) is valid for 600 seconds and carries the single scope mcp_proxy: it opens the gateway's MCP endpoint and nothing else (for example /v1/.../chat/completions answers 403). It acts as the agent mapped to the client, for the user who approved (the grant user, who can be any active member of the agent's tenant with the author role or higher, see MCP proxy endpoint). A token for another gateway's MCP path is 401.

Single attempt and replay

An authorization code is single use and gets exactly one attempt: a request that fails any check (wrong client_id, redirect_uri, resource or code_verifier, expired, or not approved) burns the code, and the correct values sent afterwards fail too. Presenting a code that was already redeemed revokes the grant and its tokens. This is intended behaviour (RFC 6749 section 4.1.2: when a code is used more than once the server SHOULD revoke the tokens issued from it).

The refresh token rotates on every use. Presenting an already-rotated refresh token revokes the whole grant, so a stolen token is useless after the legitimate client's next refresh (and the legitimate client has to authorize again). A grant also ends 30 days after it was issued (sooner when the tenant's token_max_lifetime_days is lower), when the client-to-agent mapping is removed or changed, when the agent is deleted, and when the approving user's account is disabled or deleted (a consent approved just before that can no longer be redeemed). A wrong client_id or a wrong resource is rejected without revoking. Grant tokens are managed by the gateway: they do not appear in the agent token list, are not touched by token rotation, and the generic gateway token routes ignore them: GET /admin/v1/gateways/{gateway}/tokens does not list them, and DELETE and PATCH /admin/v1/gateways/{gateway}/tokens/{id} answer the same 404 token not found as for an unknown id, without changing anything. A grant ends only through its own lifecycle (refresh-token reuse, the 30-day limit, unmapping or deleting the agent, closing the approving user's account).

Token errors

Errors are {"error", "error_description"} with these codes. The description is fixed text; storage error text is logged and never returned.

Status error Cause
400 invalid_request Content type is not form-urlencoded, unreadable or truncated body, a missing or malformed parameter
400 unsupported_grant_type grant_type is neither authorization_code nor refresh_token
400 invalid_grant Code or refresh token malformed, unknown, expired, already used, revoked, or bound to other values; also a kill (replay, reuse, unmapped client, 30 day expiry)
400 invalid_target resource differs from the grant's (refresh)
400 unauthorized_client The client is a registered (dcr_) client and dynamic client registration is switched off for the workspace (the grant is refused, not revoked; on a code redemption the code is spent)
401 invalid_client client_id is neither a valid CIMD URL nor a dcr_ id, or a client credential was sent (with an Authorization header the answer also carries WWW-Authenticate: Basic)
405 invalid_request Not POST
429 invalid_request Throttled
503 temporarily_unavailable Storage deadlock or timeout; retry the identical request (a refresh that was not committed has not rotated)
500 server_error Storage fault

Revoking a grant

A grant (one client's access for one user and agent) ends in three ways you control, besides the automatic ends described above. Revoking kills the whole grant: the current access token stops working at once (its next MCP call answers 401), and the refresh token answers invalid_grant.

By the user or an administrator

Session-gated, admin host.

Route Result
GET /admin/v1/mcp-oauth/grants 200 array of the caller's grants, newest first, revoked ones included: {id, agent_id, agent_name, client_id, client_host, gateway_id, created_at, last_rotated_at, absolute_expires_at, status, revoked_at, revoked_reason}. No token or token hash is ever returned. ?user_id= lists another user's grants and needs the USERS_MANAGE permission (403 without it) and access to that user; a malformed or repeated user_id is 400 invalid_user_id
DELETE /admin/v1/mcp-oauth/grants/{id} 200 {"ok":true}. Idempotent: revoking a revoked grant is 200 again

Both routes are refused (403) while an administrator impersonates another user.

{id} is the grant id from the list. The owner may revoke their own grant (revoked_reason user). Anyone else needs USERS_MANAGE, access to the grant's gateway and the right to act on the owner, as for the owner's tokens (revoked_reason admin). Every refusal, an unknown id, another user's grant without that authority and the id of a device session, is the same audited 404 {"error":"not found"}. A successful, changing revocation writes one audit event (mcp_oauth_grant, action revoke, entity the grant id). The device session routes (/admin/v1/device/families, see Device-code authentication) share this implementation and authority rule but only handle device sessions: a grant id on a device route is 404, and vice versa.

The automatic ends (refresh-token reuse, replayed code, unmapped client, 30-day limit) are not audited; they are only written to the server log as warnings. The same holds for a revocation by the client through POST /oauth/revoke (see below): there is no signed-in actor to attribute it to, so it writes no audit event and is logged as a warning.

By the client: POST /oauth/revoke

RFC 7009 token revocation on the gateway API host. Public client, form-urlencoded body, 405 with Allow: POST to other methods, the same per-IP and global throttle and Cache-Control: no-store as the token endpoint. A client credential of any kind (Authorization header, client_secret, client_assertion, client_assertion_type) is 401 invalid_client.

Parameter Rule
client_id Required, the CIMD URL or the registered dcr_ id
token Required, the refresh token (mor_...) or the access token (myra_...) of the grant
token_type_hint Optional, must not be repeated; ignored (the gateway looks the token up either way)

The answer is 200 {} in every case where the request is well formed, whether or not anything was revoked: an unknown, malformed, expired or already revoked token, a token of another kind, and a token that belongs to a different client are all the same empty 200, so the endpoint tells nobody whether a token is valid. A well-formed request with a valid token of this client revokes the whole grant. A refresh token that was already rotated still revokes the grant, so a client that suspects a leaked token can always stop it.

Deviation from RFC 7009 section 2.1: the RFC asks for an error when the token was issued to another client. This server answers 200 without revoking instead, so a stolen token cannot be probed for its owner. A client that revokes its own token sees no difference.

Status error Cause
400 invalid_request Content type is not form-urlencoded, unreadable body, client_id or token missing, repeated or valueless
401 invalid_client client_id is neither a valid CIMD URL nor a dcr_ id, or a client credential was sent
405 invalid_request Not POST
429 invalid_request Throttled
503 temporarily_unavailable Storage deadlock or timeout; retry the identical request
500 server_error Storage fault

Unverified clients

Dynamic client registration is governed per workspace by the tenant switch mcp_dcr_enabled (default on; set it with PATCH /admin/v1/tenants/{id}, see Unverified MCP clients).

  • Switch off. POST /oauth/register still answers, but authorize for a dcr_ client answers unauthorized_client (one tenant-level audit event), the consent screen is not approvable (refusal: dcr_disabled, approve 409 mcp_oauth_dcr_disabled), and existing grants of unverified clients stop working: the proxy answers 401 and refresh answers unauthorized_client. The grants are not deleted; switching on again resumes them. Verified (CIMD) clients are unaffected. A switch read fault fails closed.
  • One agent per gateway. The first approval on a gateway creates the unverified-clients agent (external, system_role = unverified_mcp_clients, one per gateway, never per client). It is owned by the oldest active tenant admin and re-assigned to another active admin when that owner is gone; with no active tenant admin the approve answers 409 mcp_oauth_no_tenant_admin and creates nothing. Its tool consent is the workspace's tenant-shared connectors only (bound to the gateway, or unbound = every gateway of the workspace); the agent cannot be created, edited into, or given system_role through the agent API.
  • Reads run, writes are held. Every write or destructive tool call of this agent is held for approval (-32003, approval_required), regardless of interactive use or earlier consent. Once the hold is approved, the retry runs once; a second retry is held again.
  • Promotion. Setting the dcr_ id as oauth_client_id on an external agent (it must be a registered client, else 400 unknown_oauth_client) maps the client to that agent. The unverified-clients agent itself cannot be mapped or re-mapped.
  • Connected clients. The unverified-clients agent carries connected_clients on its agent read: the number of distinct registered clients with a live grant acting through it, a count only. It is 0 while unverified clients are switched off. A promoted client is not counted.
  • Throttle. Authorize attempts are counted per hour: at most 20 per (tenant, client IP) and 600 per tenant; beyond that authorize answers temporarily_unavailable. Deciding a request frees nothing.

The consent GET returns client.redirect_host (the redirect URI's host; localhost for a loopback http redirect, scheme:// for a private-use scheme, null otherwise) and, before the first approval, a placeholder agent with id: null. The consent audit events (approve/deny) carry verified.

Dynamic client registration

POST /oauth/register (RFC 7591) registers a public client that has no Client ID Metadata Document. It is unauthenticated and sends no CORS headers (as the token and revoke endpoints), so a browser-only client cannot register; native and server-side clients can. Responses carry Cache-Control: no-store.

Request: Content-Type: application/json (parameters such as charset are allowed), a JSON object of at most 5120 bytes (5121 bytes to 16 KB is answered 400 invalid_client_metadata; above 16 KB the edge answers 413) that is validated by the same rules as a metadata document: redirect_uris (required; 1 to 20 entries, https, or http to 127.0.0.1, [::1] and localhost only, or a private-use scheme such as cursor://; no fragment), client_name, client_uri, logo_uri, tos_uri, policy_uri, grant_types (default ["authorization_code"], must include it; refresh_token is kept), response_types (must include code) and token_endpoint_auth_method. An absent token_endpoint_auth_method is accepted and answered as none; a present value other than none is refused. A JSON null for any of these members is refused; other members are ignored. Also refused are a client_id, a client_secret member or a software_statement (not supported). An Authorization header is refused.

{ "client_name": "Example", "redirect_uris": ["http://127.0.0.1:33418/callback"] }

Success: 201

{
  "client_id": "dcr_0123456789abcdef0123456789abcdef",
  "client_id_issued_at": 1790000000,
  "client_name": "Example",
  "redirect_uris": ["http://127.0.0.1:33418/callback"],
  "grant_types": ["authorization_code"],
  "response_types": ["code"],
  "token_endpoint_auth_method": "none"
}

The response also carries client_uri, logo_uri, tos_uri and policy_uri when they were registered. The client_id is opaque (dcr_ plus 32 hex). No client_secret and no registration_access_token is issued; RFC 7592 client configuration management is not offered, so a registration cannot be read, changed or deleted by the client.

Status error Cause
400 invalid_client_metadata Not application/json, a body above 5120 bytes, a repeated key, a number that is not finite (such as 1e400), a body with more than 2048 JSON values and keys in total, not a JSON object, or any metadata rule above violated
400 invalid_redirect_uri redirect_uris missing, empty, too many, or an entry violates the redirect rules
400 invalid_software_statement software_statement present
401 invalid_client An Authorization header was sent
405 invalid_request Not POST
413 invalid_request Body above 16 KB (a body of 5121 bytes to 16 KB is the 400 above), answered by the edge (the shared OAuth 413 responder, so the code is invalid_request)
429 invalid_request Throttled; Retry-After: 3600
503 temporarily_unavailable The registry is full (soft cap of 10000 clients; answered with Retry-After: 3600) or storage hit a retryable fault (deadlock, lock timeout, no connection), or the request body could not be read (that case answers Retry-After: 5, a transient fault); retry
500 server_error Non-retryable storage fault

Limits: 10 registrations per hour per IP (IPv6: per /64 — see How the public rate limits behave) and 200 per hour for the whole gateway. The global limit means one actor can exhaust it and delay registrations for everyone for up to an hour; this is an accepted trade-off of an unauthenticated endpoint.

Unverified status. A registered client is verified = false everywhere the server resolves clients: the authorization request stores it, and the consent API returns client.verified: false so the consent screen can show it. A registered client acts through the gateway's unverified-clients agent (see Unverified clients) or, after promotion, through a named agent. A client without an active grant is deleted 24 hours after registration; a registration is kept while a live grant exists for it. A registered client that has been deleted (never granted within 24 hours, or after its grants were revoked or expired) must register again; authorize answers invalid_client for a deleted id. Each registration writes an audit event (mcp_oauth_dcr_client / register) with the client name and redirect hosts, without IP address or full URIs.

Authorization server metadata

GET /.well-known/oauth-authorization-server (RFC 8414; HEAD too, other methods 405 with Allow: GET, HEAD) is public and cacheable for 300 seconds. The issuer is the origin the client connected to, derived exactly like the authorization endpoint's iss.

{
  "issuer": "https://ai-api.myra.eu",
  "authorization_endpoint": "https://ai-api.myra.eu/oauth/authorize",
  "token_endpoint": "https://ai-api.myra.eu/oauth/token",
  "registration_endpoint": "https://ai-api.myra.eu/oauth/register",
  "revocation_endpoint": "https://ai-api.myra.eu/oauth/revoke",
  "revocation_endpoint_auth_methods_supported": ["none"],
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "code_challenge_methods_supported": ["S256"],
  "token_endpoint_auth_methods_supported": ["none"],
  "client_id_metadata_document_supported": true,
  "authorization_response_iss_parameter_supported": true
}

A missing or malformed Host answers 400 invalid_request. Clients identify themselves by CIMD URL or register at registration_endpoint.

Protected resource metadata

GET /.well-known/oauth-protected-resource/v1/{tenant}/{gateway}/mcp (RFC 9728; HEAD too, other methods 405 with Allow: GET, HEAD) names the authorization server of one gateway's MCP endpoint. It is public and cacheable for 300 seconds, and limited to 60 requests per minute per IP (429). For IPv6 clients the per-IP limit counts per /64 — see How the public rate limits behave.

{
  "resource": "https://ai-api.myra.eu/v1/acme/main/mcp",
  "authorization_servers": ["https://ai-api.myra.eu"],
  "bearer_methods_supported": ["header"]
}

resource is the exact value an MCP client sends as the resource parameter; a grant is only valid for that resource (scheme and host compared case-insensitively, a default port ignored, the path exactly). An unknown gateway, or one that does not require authentication, answers 404; the PRM answers 200 only for an existing gateway that requires authentication, the same existence signal the MCP route itself gives (401 versus 404), so it reveals nothing new; a missing or malformed Host answers 400.

The MCP endpoint's 401 points here: WWW-Authenticate: Bearer realm="mcp", resource_metadata="https://ai-api.myra.eu/.well-known/oauth-protected-resource/v1/acme/main/mcp". A client that connects without a token learns the authorization server from that URL.

Client compatibility

The flow on this page is verified end to end with the official MCP TypeScript SDK (@modelcontextprotocol/sdk 1.32.1) over Streamable HTTP, using the SDK's own Client, StreamableHTTPClientTransport and OAuth code; no gateway-specific client code is needed.

  • Discovery. The SDK follows the 401 resource_metadata URL to the protected resource metadata (RFC 9728), then reads the authorization server metadata (RFC 8414), which advertises S256 PKCE and client ID metadata documents.
  • Client identity. The verified path is a client ID metadata document (CIMD) URL supplied to the SDK as a pre-registered client_id: the SDK skips registration and the gateway resolves the URL as a verified CIMD client. The SDK's own clientMetadataUrl branch (taken on https deployments; SDK 1.32.1 rejects a non-https clientMetadataUrl) is not covered by this verification.
  • Authorization. The SDK sends PKCE S256, the resource parameter and a state, and checks the returned state. The authorization response carries iss (RFC 9207).
  • Refresh. When the gateway answers 401 to an expired or invalid access token, the SDK refreshes with the rotated refresh token and retries the request without surfacing an error.
  • Revocation. After a grant is revoked, the refresh is refused with invalid_grant and the SDK starts a new authorization instead of retrying.
  • Held writes. A write that needs approval returns JSON-RPC error -32003 with data.reason set to approval_required. After approval, the SDK's identical retry runs once.

Token lifetime policy. The tenant token_max_lifetime_days of the token lifetime policy applies to grants: the grant's 30-day absolute lifetime is shortened to the cap if the cap is lower (a cap never lengthens it), and each access token expires no later than the grant, with expires_in reporting the clamped value. Existing grants are not shortened retroactively. self_mint_tokens = 0 does not block MCP OAuth consent; it gates the device approve route only. A malformed tenant policy row answers 500 server_error on the token endpoint. Third-party credentials stored through the MCP callback (mcp_callback) are not gateway-minted tokens and are not covered.

Numbers in the OAuth endpoints' JSON are written exactly. If a response, or the client metadata of a dynamic registration, cannot be encoded, the endpoint answers 500 with { "error": "server_error" } and nothing is stored; see Error codes.