Repository navigation
Issuers are compared after URL normalisation; partial CIMD client_id URL validation #3768
Description
What happened?
Title: Auth spec conformance (minor): issuer string normalisation, CIMD client_id URL validation
Environment: mcp 2.3.0 · Python 3.12.3 · spec revision 2026-07-28
Two small differences from the spec text. Each only shows up in a corner case.
1. Issuers are compared after URL normalisation
OAuthMetadata.issuer is a pydantic AnyHttpUrl, so the host is lower-cased and the default port dropped when it is parsed. validate_metadata_issuer and validate_authorization_response_iss then compare against that normalised string.
Output: accepted · accepted · rejected (OAuthFlowError)
It goes wrong in both directions: strings that aren't identical are accepted, and an authorization server whose issuer genuinely contains upper-case letters can't be used. The normalised forms all point to the same origin, so I don't see an impersonation path; the problem is inconsistency with resource servers, which compare iss exactly (RFC 7519 §2).
2. CIMD client_id URL validation is partial
draft-ietf-oauth-client-id-metadata-document §3: the URL MUST use https, MUST have a path, MUST NOT contain a username or password, MUST NOT contain ./.. segments, and MUST NOT contain a fragment. is_valid_client_metadata_url checks only the first two.
Output: True for all four ·
The authorization server is the binding check here. This only makes the client's pre-flight check match the rules it already partly enforces.
What did you expect?
1. Issuers are compared after URL normalisation
Spec, Authorization Response Validation: clients "MUST NOT apply scheme or host case folding, default-port elision, trailing-slash, or percent-encoding normalization … before comparison". RFC 8414 §3.3 requires the metadata issuer to be identical.
Expected: rejected · rejected · accepted
2. CIMD client_id URL validation is partial
draft-ietf-oauth-client-id-metadata-document §3: the URL MUST use https, MUST have a path, MUST NOT contain a username or password, MUST NOT contain ./.. segments, and MUST NOT contain a fragment. is_valid_client_metadata_url checks only the first two.
Expected: False
Code to reproduce
### 1. Issuers are compared after URL normalisation
from mcp.client.auth.utils import validate_authorization_response_iss, validate_metadata_issuer
from mcp.shared.auth import OAuthMetadata
def md(issuer):
return OAuthMetadata.model_validate({"issuer": issuer, "authorization_endpoint": "https://as.example/a",
"token_endpoint": "https://as.example/t", "authorization_response_iss_parameter_supported": True})
def run(f, *a):
try: f(*a); return "accepted"
except Exception as e: return f"rejected ({type(e).__name__})"
print(run(validate_metadata_issuer, md("https://AS.example/t"), "https://as.example/t"))
print(run(validate_metadata_issuer, md("https://as.example:443/t"), "https://as.example/t"))
print(run(validate_authorization_response_iss, "https://AS.example/t", md("https://AS.example/t")))
### 2. CIMD `client_id` URL validation is partial
from mcp.client.auth.utils import is_valid_client_metadata_url
for u in ("https://user@client.example/cimd.json", "https://client.example/a/../cimd.json",
"https://client.example/./cimd.json", "https://client.example/cimd.json#x"):
print(u, is_valid_client_metadata_url(u))SDK version
2.3.0
Area
Client
Activity
[-][v2][/-][+] Issuers are compared after URL normalisation; partial CIMD client_id URL validation[/+]kcncb commentedon Oct 27, 2026
I hit this while reading through
src/mcp/client/auth/utils.pyand can confirm item 2 onmain(a525a3d).is_valid_client_metadata_urlonly checksscheme == "https"andpath not in ("", "/"), so the remaining three rules from draft-ietf-oauth-client-id-metadata-document section 3 are unenforced in the client's pre-flight check.Approach I'd take: keep the function's shape, add the three checks, and percent-decode path segments before comparing them to
./..(RFC 3986 treats%2Eas a dot for dot-segment removal). Query strings and ports stay allowed since the draft does not forbid them. The parametrised test intests/client/test_auth.pyalready has the right shape, so the regression rows slot straight in.I have that change on a branch with the tests above (7 new failing rows on
main, 19 passing after) — linked as #3774. Happy to fold it into whatever direction you prefer, or to split item 1 (issuer normalisation) out and leave it for a separate issue if you'd rather keep this one scoped. Not asking for an assignment beyond that — your call on capacity.yedlurisrinu commentedon Oct 27, 2026
Production impact evidence on issue #1 (Issuers are compared after URL normalisation).
Production impact: the normalised client check and an exact resource-server check disagree
A follow-up on the issuer comparison. In a deployment where the issuer string differs only by the default port (or
host case) between the authorization server's metadata and the configured value, the client and the resource server
reach opposite verdicts:
validate_metadata_issuer) normalises both strings and accepts the authorization server;issclaim as an exact string (RFC 7519 §2: StringOrURI values are compared"as case-sensitive strings with no transformations or canonicalizations applied") and rejects every token.
So the login completes and then every MCP request returns 401, with no error on the client side that points at the
issuer. Local setups with an explicit port (e.g.
http://localhost:8180) do not hit this; HTTPS deployments with thedefault port can, when
:443appears in one config source and not another.Output with
mcp2.3.0 (joserfc stands in for any resource server that validatesissexactly):An exact string comparison on the client, as the spec requires, would surface the mismatch at discovery time instead
of as a 401 on every call.