Skip to content

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

@viejunoacien

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