Skip to content

AnyHttpUrl normalization adds trailing slash → breaks RFC 9728 canonical resource URL in ProtectedResourceMetadata #2883

Closed as not planned

Description

@viejunoacien

Repro

from pydantic import AnyHttpUrl
str(AnyHttpUrl("https://example.com"))
# -> 'https://example.com/'

Context

We use mcp.shared.auth.ProtectedResourceMetadata.resource for canonical resource URLs exposed at /.well-known/oauth-protected-resource. When AnyHttpUrl normalizes a URL whose original path is /, it serializes the value with a trailing slash. This breaks RFC 9728 §3.1 expectations and causes OAuth protected-resource validation to fail in strict clients — tokens are rejected and the client falls back into an infinite OAuth authorize/token loop.

Symptom

Any MCP server exposing a resource with streamable_http_path="/" ends up producing a resource value with a trailing slash in the protected-resource metadata. Clients that validate strict against the canonical form (e.g. Claude Desktop) silently reject the token exchange and reinitiate the OAuth flow indefinitely.

Observed server logs:

POST /         → 401 (no token, expected)
GET  /.well-known/oauth-protected-resource → 200  (resource = "https://host/")
GET  /authorize → 302
POST /token    → 200  (token issued)
POST /         → never sent with Authorization: Bearer
GET  /authorize → 302  (new flow)
POST /token    → 200
... loops forever

Suggested fixes (options)

  1. Add a custom validator on ProtectedResourceMetadata.resource that strips the trailing slash when the original path is exactly /.
  2. Change the field type from AnyHttpUrl to str with explicit canonical-form validation.
  3. Use a stricter URL normalization option in pydantic if one exists for this case.

Workaround in consumer repo

We patched it on our side by setting streamable_http_path="/mcp" so the URL has a non-trivial path, which AnyHttpUrl preserves without appending a slash. The consumer commit is 27c8e0c.

But that is a workaround for the symptom — the underlying contract violation in the protected-resource metadata is still present for any consumer that uses streamable_http_path="/".

Happy to help

Happy to open a follow-up PR if you prefer the custom-validator route — let me know which option you would prefer to merge.

Activity

  1. Ben-Home commented on Jun 19, 2026

    @Ben-Home

    Nice catch @viejunoacien — this is more impactful than it looks at first glance. The AnyHttpUrl trailing slash isn't just a cosmetic issue; it causes silent token rejection with no user-visible error, which is the worst kind of OAuth failure.

    We've hit this exact pattern across multiple business API connectors at corpusiq.io (36 APIs including Stripe, HubSpot, QuickBooks, NetSuite). When we set streamable_http_path="/" on a connector:

    1. Client fetches /.well-known/oauth-protected-resource → gets "resource": "https://host/"
    2. Client canonicalizes to https://host (no trailing slash, per RFC 9728 §3.1)
    3. Token is issued with aud: "https://host"
    4. Client sends Authorization: Bearer <token> to https://host/
    5. Server compares aud → mismatch → 401 → infinite re-auth loop

    The server logs show exactly the pattern from your reproduction: POST /token → 200, but POST / (with Bearer) never arrives because the client abandons the session after resource URL validation fails.

    One additional consideration for the fix: the resource field in ProtectedResourceMetadata is what OAuth clients use as the canonical resource identifier. If we strip the trailing slash in the metadata response, we need to ensure the server's actual endpoint handling also works without it. Otherwise we trade one mismatch for another.

    The PR #2888 approach looks clean — custom validator on the resource field preserves the canonical form while keeping AnyHttpUrl for URL validation elsewhere. Happy to help review or test against our multi-API setup if that would be useful.

  2. maxisbey commented on Aug 14, 2026

    @maxisbey
    Contributor

    This is the same root cause as #1265, so I'm going to track it there rather than in two places. For what it's worth the SDK side is fixed in v2.0.0: #2925 set url_preserve_empty_path=True on ProtectedResourceMetadata (and the other OAuth metadata models) and #2987 did the same for AuthSettings, so a resource_server_url given as a path-less string is now served without the synthetic trailing slash — essentially your option 3. On 1.x, mounting at a non-root path as you did remains the workaround. Feel free to reopen if you still see the trailing slash on 2.x with a string resource_server_url.

    AI Disclaimer

  3. slowe89 commented on Sep 10, 2026

    @slowe89

    +1 on the client pain: pathless// resources serialized with a trailing slash fail strict PRM/RFC 9728 identity checks even when Inspector looks fine. We're seeing the same "works in Inspector / breaks in real clients" gap across OAuth PRM, trailing-slash, and needs-auth discovery.

    Cataloguing those seams here if useful: https://compatlab-waitlist.vercel.app (permission-only).

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions