Skip to content

Issuers are compared after URL normalisation; partial CIMD client_id URL validation #3768

Open

Description

@yedlurisrinu

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

  1. changed the title [-][v2][/-] [+] Issuers are compared after URL normalisation; partial CIMD client_id URL validation[/+] on Oct 26, 2026
  2. kcncb commented on Oct 27, 2026

    @kcncb

    I hit this while reading through src/mcp/client/auth/utils.py and can confirm item 2 on main (a525a3d).

    >>> from mcp.client.auth.utils import is_valid_client_metadata_url as v
    >>> v("https://user:secret@example.com/client")   # userinfo
    True
    >>> v("https://example.com/path/../client")       # dot segment
    True
    >>> v("https://example.com/%2E%2E/client")        # encoded dot segment
    True
    >>> v("https://example.com/client#section")       # fragment
    True

    is_valid_client_metadata_url only checks scheme == "https" and path 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 %2E as a dot for dot-segment removal). Query strings and ports stay allowed since the draft does not forbid them. The parametrised test in tests/client/test_auth.py already 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.

  3. yedlurisrinu commented on Oct 27, 2026

    @yedlurisrinu
    Author

    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:

    • the client (validate_metadata_issuer) normalises both strings and accepts the authorization server;
    • the resource server checks the token's iss claim 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 the
    default port can, when :443 appears in one config source and not another.

    import time
    from joserfc import jwt
    from joserfc.jwk import RSAKey
    from joserfc.errors import JoseError
    from mcp.client.auth.utils import validate_metadata_issuer
    from mcp.shared.auth import OAuthMetadata
    
    configured = "https://idp.example.com/realms/news"
    emitted = "https://idp.example.com:443/realms/news"
    md = OAuthMetadata.model_validate({"issuer": emitted, "authorization_endpoint": "https://idp.example.com/a",
                                       "token_endpoint": "https://idp.example.com/t"})
    try:
        validate_metadata_issuer(md, configured); client = "accepted"
    except Exception as e:
        client = f"rejected ({type(e).__name__})"
    key = RSAKey.generate_key(2048)
    now = int(time.time())
    token = jwt.encode({"alg": "RS256"}, {"iss": emitted, "aud": "https://news.example.com/mcp", "exp": now + 300, "iat": now}, key)
    try:
        jwt.JWTClaimsRegistry(iss={"essential": True, "value": configured}).validate(jwt.decode(token, key).claims); rs = "accepted"
    except JoseError as e:
        rs = f"rejected ({type(e).__name__})"
    print(f"client (SDK validate_metadata_issuer): {client}")
    print(f"resource server (iss claim, exact):    {rs}")

    Output with mcp 2.3.0 (joserfc stands in for any resource server that validates iss exactly):

    client (SDK validate_metadata_issuer): accepted
    resource server (iss claim, exact):    rejected (InvalidClaimError)
    

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingv2Affects the v2 line (2.x on main)

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions