Skip to content

Trailing slash in OAuthMetadata's issuer causes issues with clients #1919

Description

@joar

Initial Checks

Description

In the .well-known/oauth-authorization-server endpoint and , the issuer is forced to always contain a trailing slash e.g.,

  • https://your-mcp.com/ instead of
  • https://your-mcp.com
    as a byproduct of using pydantic's AnyHttpUrl type.

This causes issues in both Google's ADK and IBM's MCP Context Forge because:

  • when building the .well-known URL, they expect a discovery issuer URL that does not contain a trailing slash; and
  • then they MUST verify that the returned metadata issuer URL is identical to the discovery issuer URL ("authorization server's issuer identifier value" in the spec) according to RFC 8414 Section 3.2; so
  • when OAuthMetadata.issuer contains the trailing slash, the discovery process is aborted.

OAuth 2.0 Authorization Server Metadata spec says that the client MUST remove trailing paths from when the issuer contains a path component:

If the issuer identifier value contains a path component, any
terminating "/" MUST be removed before inserting "/.well-known/" and
the well-known URI suffix between the host component and the path
component.
-- https://datatracker.ietf.org/doc/html/rfc8414#section-3.1

if the trailing / in https://example.com/ is a "path component", and should thus be stripped by the client, so I think the spec is ambiguous about the responsibilities of the client in the case where there the issuer identifier value contains a lone trailing slash.

I did note that the examples of issuer identifiers in the spec do not contain a lone trailing slash, i.e. they are https://example.com rather than https://example.com/.

For these reasons, and

  • while it's listed as the client's responsibility to remove trailing slashes from the issuer identifier,
  • I don't believe it's the server implementation's responsibility to intentionally make it harder for clients by returning a URL that do not follow the assumptions in the spec.

I think it's worth it to consider interpreting the spec as "the issuer field should not contain a trailing slash".

I also believe this issue could be similar in mechanism, but different in scope, to what is described in #1265

Example Code

# A demonstration on how AnyHttpUrl adds a trailing slash.

>>> from pydantic.networks import AnyHttpUrl
>>> x = AnyHttpUrl("http://localhost:8000")
>>> x
AnyHttpUrl('http://localhost:8000/')
>>> str(x)
'http://localhost:8000/'
>>>

Python & MCP Python SDK

Python 3.14
mcp==1.25.0

Activity

  1. added
    bugSomething isn't working
    authIssues and PRs related to Authentication / OAuth
    ready for workEnough information for someone to start working on
    P1Significant bug affecting many users, highly requested feature
    on Jan 22, 2026
  2. maxisbey commented on Jan 22, 2026

    @maxisbey
  3. claude commented on Jan 22, 2026

    @claude
  4. added a commit that references this issue on Jan 23, 2026
    96c7a43
  5. added a commit that references this issue on Feb 8, 2026
    b288e05
  6. added a commit that references this issue on Feb 22, 2026
    dbd2d8d
  7. added a commit that references this issue on Mar 9, 2026
    fd389bc
  8. piyushbag commented on Jun 28, 2026

    @piyushbag

    Planning to open a PR for this. The fix strips trailing slashes when constructing OAuth and protected-resource metadata in routes.py, passing canonical URL strings so url_preserve_empty_path on the metadata models keeps RFC 8414/9728 wire forms intact (e.g. https://example.com not https://example.com/).

    This should also address #1265. Regression tests cover AnyHttpUrl-typed issuer inputs and root-level PRM responses.

  9. added 2 commits that reference this issue on Jul 11, 2026
    84d8208
    9da3005
  10. peterdalinis-scout commented on Aug 27, 2026

    @peterdalinis-scout

    Adding a client-side data point, since this issue and the linked PRs focus on the server side (build_metadata() / routes.py emitting the slash).

    The same ambiguity bites in the client validator, against a third-party AS that nobody here controls. validate_metadata_issuer() in mcp/client/auth/utils.py (mcp 2.0.0) does an exact string compare:

    if str(oauth_metadata.issuer) != expected_issuer:
        raise OAuthFlowError(
            f"Authorization server metadata issuer mismatch: {oauth_metadata.issuer} != {expected_issuer}"
        )

    Real-world repro (Indeed's MCP server):

    1. GET https://mcp.indeed.com/.well-known/oauth-protected-resource/claude/mcp →
      {"authorization_servers":["https://secure.indeed.com/"]} ← trailing slash
    2. GET https://secure.indeed.com/.well-known/oauth-authorization-server →
      {"issuer":"https://secure.indeed.com"} ← no trailing slash
    3. Client aborts:
      Authorization server metadata issuer mismatch: https://secure.indeed.com != https://secure.indeed.com/

    The expected_issuer is derived from the PRM's authorization_servers entry, so a provider that is internally inconsistent between its two documents makes the flow unreachable. Per RFC 3986 §6.2.1 these are the same URI; the two documents just disagree on the character.

    Note the asymmetry: PRM authorization_servers is a list of AnyHttpUrl, so pydantic normalizes a bare origin to a trailing slash on the client side too — meaning a server that correctly emits "issuer": "https://example.com" (exactly what the PRs here aim to produce) can still fail client validation once its origin is round-tripped through AnyHttpUrl. Fixing only the server side may not close this.

    Workaround in use locally — compare with .rstrip("/") on both sides, which keeps host/scheme/path mismatches raising:

    if str(oauth_metadata.issuer).rstrip("/") != expected_issuer.rstrip("/"):

    Happy to open a separate issue/PR for the client-side validator if you'd prefer to keep this one scoped to metadata construction.

  11. keurcien commented on Sep 14, 2026

    @keurcien
    Contributor

    Hitting this issue with Google official MCP servers (Calendar, Gmail, Slides, etc)

    Example for Calendar https://calendarmcp.googleapis.com/.well-known/oauth-protected-resource/mcp/v1 returns

    {
    	"resource": "https://calendarmcp.googleapis.com/mcp/v1",
    	"authorization_servers": ["https://accounts.google.com/"]
    }

    while https://accounts.google.com/.well-known/oauth-authorization-server returns "issuer": "https://accounts.google.com".

    If you need a reproducible example, I can provide one.

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

    P1Significant bug affecting many users, highly requested featureauthIssues and PRs related to Authentication / OAuthbugSomething isn't workingready for workEnough information for someone to start working on

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions