Real MCP clients (Claude Desktop, Cursor, ChatGPT connectors and similar) do not
always follow the strict shape the authorization server enforces by default.
AuthorizationServerConfig.ClientCompat relaxes individual checks, one switch
at a time. Every switch defaults to false, which is today’s strict behavior.
Flags
OptionalResource
When resource is absent at /oauth/authorize, /oauth/par, the token
endpoint (authorization code and refresh) or the jwt-bearer grant, the
request defaults to the sole configured resource. With two or more configured
resources an absent resource is still invalid_target. A resource that is
present but unknown is always invalid_target. Client credentials, token
exchange and the device flow are unchanged (device already defaulted).
The client_credentials grant also honors it: an absent resource defaults to
the sole configured resource and the token aud is the configured identifier.
Token exchange (RFC 8693) already defaulted an absent resource to the
subject token’s aud; that is unchanged.
Tradeoff: low. The audience can only be a resource you configured, and with a
single resource there is no ambiguity about the audience.
DropUnknownScopes
A requested scope is kept only if it is in the resource catalog (when the
catalog is non-empty) and in the client’s registered scope set (when the
client registered one). Everything else is dropped, and the granted scope is
returned in the token response scope field and the scope claim. If scopes
were requested and none remain, the request fails with invalid_scope. An
entirely empty scope request is rejected exactly as before. On the refresh
grant, scopes outside the original grant are dropped the same way, so a
refresh can never widen.
Tradeoff: medium. A client can no longer learn it asked for something wrong
from an error, and it may receive fewer scopes than it expected. It can never
receive more than the catalog and its registration allow. Clients must read
the returned scope rather than assume what they asked for.
LoopbackAnyPort
RFC 8252 section 7.3: when a registered redirect URI is http on 127.0.0.1
or [::1], the requested URI matches if scheme, host, path and query are
identical and only the port differs. Applied at /authorize, PAR and
/token (the token request may carry a different port than the code was
issued for, as long as both are the same loopback URI up to the port).
127.0.0.1 and [::1] do not match each other. Userinfo, fragments and
invalid ports never match. localhost is excluded unless
LoopbackAllowLocalhost is also set.
Tradeoff: higher. Any process on the user’s machine can listen on a port and
receive the redirect. PKCE (always required) is what prevents that process
from redeeming the code, so do not enable this for clients that cannot use
PKCE S256 (this server requires it for everyone). Only affects clients that
registered a loopback URI.
PathAudience
A requested resource matches a configured resource if it is equal modulo a
trailing slash, or if the configured identifier is a path prefix of the
request on a / boundary (https://api.example.com/mcp/sse matches
https://api.example.com/mcp; https://api.example.com/mcp-admin does not).
Queries, fragments, userinfo, dot segments and percent-encoded dots or slashes
are refused. When several configured resources match, the longest wins. The
issued token aud is always the configured canonical identifier, never the
requested sub-path.
It applies at every grant that takes a resource: authorization code,
refresh, client_credentials, token exchange (the resource is compared to the
subject token aud by canonical identifier, and audience may name a
sub-path) and ID-JAG redemption at the jwt-bearer grant (the resource claim
in the ID-JAG and the presented resource match when both resolve to the same
configured resource). ID-JAG issuance is unchanged: it accepts any absolute
resource URL because the target need not be one of this server’s resources.
Tradeoff: low to medium. A client can obtain a token for the whole configured
resource while naming a sub-path, so do not rely on the sub-path to narrow
authorization.
Recommended MCP profile
For a single-resource MCP server with public (CIMD or DCR) clients:
Leave LoopbackAllowLocalhost off unless a client you must support registers
http://localhost/.... Pair this with RequireState: true and CIMD
TrustPolicy: AllowHTTPSHosts(...) for the clients you intend to serve. If you
only serve clients that behave, enable none of these.
token_endpoint_auth_method in a client metadata document is checked against
an allowlist: none (the default when omitted) and private_key_jwt.
client_secret_basic, client_secret_post, client_secret_jwt and any other
value are rejected because a published document has no place for a secret.
private_key_jwt requires exactly one of jwks (a key set with at least one
key) or jwks_uri (an absolute https URL). Unknown top-level fields are
ignored, so vendor extensions do not break resolution.
CIMDConfig.HonorCacheControl (default false) lets the publisher shorten how
long a fetched document is cached with Cache-Control: max-age, no-cache or
no-store. The effective freshness is always between cimd.MinCacheTTL (10
seconds) and CIMDConfig.CacheTTL; a publisher can never extend caching past
CacheTTL.
A redirect URI with a bare custom scheme such as cursor:// is refused by
default (RFC 8252 section 7.1 requires a reverse-DNS scheme). List the scheme
to accept it:
Entries must be lowercase and may not be http, https, javascript,
data, file (or vbscript, blob, about, ftp); theauth.New fails
otherwise. Fragments and userinfo are still refused and matching stays exact.
Tradeoff: medium. Any app on the user’s device can register the same
private-use scheme and receive the code; PKCE S256 (always required here) is
what stops it redeeming the code. Prefer a narrow TrustPolicy, and note the
listed scheme applies to every document the policy admits.
Authentication context and step-up
Independent of ClientCompat: the authorization server records how and when
the user authenticated and emits it, so a resource server using
mcpresource.RequireStepUp (RFC 9470) can be satisfied by tokens from this
server.
auth_time is the later of sign-in and the latest POST /auth/step-up of
the session that authorized. amr comes from
AuthorizationServerConfig.SessionAMR, which defaults to ["pwd"] because
sessions do not record their sign-in method. Supply it (for example from
your own login records) to emit otp, hwk or mfa.
acr maps the method set through AuthorizationServerConfig.ACRValues
(keys are the distinct amr values sorted and joined with +, such as
"otp+pwd"). Unmapped sets use the default: urn:theauth:acr:1fa for one
method, urn:theauth:acr:mfa for mfa or two or more methods. Configured
values are advertised as acr_values_supported.
- The claims appear in JWT and opaque access tokens and in introspection
responses. They are stored on the authorization code and the refresh token
family and copied on every refresh; a refresh never advances
auth_time.
Grants without a user session (client_credentials, token exchange,
jwt-bearer) carry none of them.
/oauth/authorize honors max_age (seconds; 0 always re-authenticates),
acr_values (a session satisfies it when its acr equals one listed value)
and prompt=none, also through PAR and JAR. When a requirement is unmet the
user is redirected to LoginURL with next, reauth=1 and the max_age
and acr_values hints; with prompt=none the client receives
login_required instead. A requirement with no authentication context fails
closed.
Limit: there is no in-band step-up flow at /authorize. The login page you
mount at LoginURL must create a fresh session or step the existing one up
(POST /auth/step-up) and send the user back to next; if it returns without
doing so the user is redirected again. A step-up with TOTP does not by itself
change amr unless SessionAMR reports it.
Test vectors
internal/as/compat_test.go holds table-driven vectors for resource
resolution, scope narrowing and redirect matching. They are plain data so
the TypeScript library can reuse them. Client shapes under
internal/as/testdata/clientcompat are representative of public client
documentation, not captured traffic.
The TypeScript package has the same switches under lenientClients. See MCP client compatibility for the TypeScript names and the few places the two differ. Last modified on October 13, 2026