Skip to content

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

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. 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

  2. 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