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(plainhttponly 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 |
Consent API
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/registerstill answers, but authorize for adcr_client answersunauthorized_client(one tenant-level audit event), the consent screen is not approvable (refusal: dcr_disabled, approve409 mcp_oauth_dcr_disabled), and existing grants of unverified clients stop working: the proxy answers401and refresh answersunauthorized_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 answers409 mcp_oauth_no_tenant_adminand 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 givensystem_rolethrough 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 asoauth_client_idon an external agent (it must be a registered client, else400 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_clientson its agent read: the number of distinct registered clients with a live grant acting through it, a count only. It is0while 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.
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
401resource_metadataURL to the protected resource metadata (RFC 9728), then reads the authorization server metadata (RFC 8414), which advertisesS256PKCE 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 ownclientMetadataUrlbranch (taken onhttpsdeployments; SDK 1.32.1 rejects a non-httpsclientMetadataUrl) is not covered by this verification. - Authorization. The SDK sends PKCE
S256, theresourceparameter and astate, and checks the returnedstate. The authorization response carriesiss(RFC 9207). - Refresh. When the gateway answers
401to 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_grantand the SDK starts a new authorization instead of retrying. - Held writes. A write that needs approval returns JSON-RPC error
-32003withdata.reasonset toapproval_required. After approval, the SDK's identical retry runs once.
Token lifetime policy. The tenant
token_max_lifetime_daysof 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, withexpires_inreporting the clamped value. Existing grants are not shortened retroactively.self_mint_tokens = 0does not block MCP OAuth consent; it gates the device approve route only. A malformed tenant policy row answers500 server_erroron 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.