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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions