Skip to content

OAuth token refresh sends RFC 8707 resource parameter that Entra ID v2.0 rejects (AADSTS9010010) #2578

Description

@ShaneFlag

Problem

The MCP Python SDK sends an RFC 8707 resource parameter on all token requests — including refresh_token grants. Microsoft Entra ID v2.0 rejects this with AADSTS9010010 (The resource parameter provided in the request doesn't match with the requested scopes).

This causes MCP servers using Entra ID OAuth to lose authentication after ~1 hour when the access token expires and the SDK attempts a silent refresh.

Root Cause

Two compounding issues:

1. Entra v2.0 does not support resource on refresh

Entra's v2.0 token endpoint expects scope, not resource. The resource parameter is a v1.0 concept. Since March 2026, Entra strictly validates and rejects resource on token refresh (previously it was silently ignored).

2. Pydantic v2 AnyHttpUrl trailing-slash normalization

ProtectedResourceMetadata.resource is typed as AnyHttpUrl (shared/auth.py:143). When str() is called on a bare-domain URL, Pydantic v2 adds a trailing slash:

>>> str(AnyHttpUrl("https://mcp-server.example.com"))
'https://mcp-server.example.com/'   # trailing slash added

In get_resource_url() (client/auth/oauth2.py:155), this trailing-slash version is used:

prm_resource = str(self.protected_resource_metadata.resource)  # adds trailing slash

But the Entra app registration has the audience as https://mcp-server.example.com (no slash), so the resource and scope audience don't match.

Affected Code

src/mcp/client/auth/oauth2.py:

async def _refresh_token(self) -> httpx.Request:
    refresh_data = {
        "grant_type": "refresh_token",
        "refresh_token": self.context.current_tokens.refresh_token,
        "client_id": self.context.client_info.client_id,
    }
    # This sends 'resource' on refresh — Entra v2.0 rejects it
    if self.context.should_include_resource_param(self.context.protocol_version):
        refresh_data["resource"] = self.context.get_resource_url()  # RFC 8707

The same issue exists in the TypeScript SDK (packages/client/src/client/auth.ts), where WHATWG URL also normalizes bare-domain URLs with a trailing slash.

Suggested Fix

Option A: Strip trailing slash in get_resource_url()

def get_resource_url(self) -> str:
    resource = resource_url_from_server_url(self.server_url)
    if self.protected_resource_metadata and self.protected_resource_metadata.resource:
        prm_resource = str(self.protected_resource_metadata.resource).rstrip('/')
        if check_resource_allowed(requested_resource=resource, configured_resource=prm_resource):
            resource = prm_resource
    return resource

Option B: Include scope alongside resource on refresh

Entra v2.0 tolerates resource if scope is also present and consistent:

refresh_data["scope"] = " ".join(self.context.scopes)

Option C: Make resource on refresh configurable

Allow servers to signal whether the resource parameter should be included on refresh grants, since not all authorization servers support RFC 8707.

Related Issues

Environment

  • MCP Python SDK: v1.27.0
  • Authorization server: Microsoft Entra ID v2.0
  • MCP server: Azure Container Apps with custom EntraTokenVerifier
  • MCP spec version: 2025-06-18 (mandates RFC 8707 resource)

Current Workaround

Server-side: set resource_server_url=None in AuthSettings and do NOT serve /.well-known/oauth-protected-resource metadata. Without PRM, should_include_resource_param() returns False and resource is omitted from refresh requests. Initial auth still works via the WWW-Authenticate header fallback.

Activity

  1. added
    triageQueued for automated analysis — bot will process and remove this label
    on May 12, 2026
  2. mcp-claude commented on May 12, 2026

    @mcp-claude

    two confirmed bugs: resource param sent on refresh_token grants (Entra v2.0 rejects with AADSTS9010010), and AnyHttpUrl adds a trailing slash to bare-domain resource URLs causing an audience mismatch.

    workaround: don't serve /.well-known/oauth-protected-resource — without PRM, should_include_resource_param() returns False and resource is omitted from all requests including refresh.

    repro script
    """
    Repro for issue #2578:
    1. AnyHttpUrl trailing slash normalization on bare-domain URLs
    2. resource param included unconditionally in refresh_token requests
    """
    import sys
    import asyncio
    import httpx
    from unittest.mock import AsyncMock, MagicMock
    
    sys.path.insert(0, "src")
    
    from pydantic import AnyHttpUrl
    from mcp.shared.auth import ProtectedResourceMetadata
    from mcp.client.auth.oauth2 import OAuthContext, OAuthClientProvider
    from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
    
    
    # ---- Bug 1: AnyHttpUrl trailing slash ----
    bare_url = "https://mcp-server.example.com"
    parsed = AnyHttpUrl(bare_url)
    serialized = str(parsed)
    print(f"[Bug 1] Input:      {bare_url!r}")
    print(f"[Bug 1] str(AnyHttpUrl): {serialized!r}")
    if serialized.endswith("/"):
        print("[Bug 1] REPRODUCED: trailing slash added by Pydantic v2")
    
    # ---- Bug 1b: trailing slash flows into get_resource_url ----
    prm = ProtectedResourceMetadata(
        resource=AnyHttpUrl(bare_url),
        authorization_servers=[AnyHttpUrl("https://login.microsoftonline.com/tenant/v2.0")],
    )
    prm_resource_str = str(prm.resource)
    print(f"[Bug 1b] ProtectedResourceMetadata.resource: {prm_resource_str!r}")
    
    # ---- Bug 2: resource param in refresh_token grant ----
    storage = MagicMock()
    storage.get_tokens = AsyncMock(return_value=None)
    storage.set_tokens = AsyncMock()
    storage.get_client_info = AsyncMock(return_value=None)
    storage.set_client_info = AsyncMock()
    
    client_info = OAuthClientInformationFull(
        client_id="test-client",
        client_secret="secret",
        redirect_uris=["http://localhost/callback"],
    )
    tokens = OAuthToken(
        access_token="old-access",
        token_type="bearer",
        refresh_token="old-refresh",
    )
    
    ctx = OAuthContext(
        server_url="https://mcp-server.example.com",
        client_metadata=MagicMock(redirect_uris=["http://localhost/callback"]),
        storage=storage,
        redirect_handler=AsyncMock(),
        callback_handler=AsyncMock(return_value=("code123", "state123")),
    )
    ctx.client_info = client_info
    ctx.current_tokens = tokens
    ctx.protected_resource_metadata = prm
    ctx.protocol_version = "2025-06-18"
    
    refresh_data = {
        "grant_type": "refresh_token",
        "refresh_token": ctx.current_tokens.refresh_token,
        "client_id": ctx.client_info.client_id,
    }
    if ctx.should_include_resource_param(ctx.protocol_version):
        refresh_data["resource"] = ctx.get_resource_url()
    
    print(f"[Bug 2] refresh_token POST body: {refresh_data}")
    if "resource" in refresh_data:
        print("[Bug 2] REPRODUCED: 'resource' param present in refresh_token grant")
        if refresh_data["resource"].endswith("/"):
            print("         (trailing slash — Entra audience mismatch)")
    command + output
    $ uv run python repro.py
    [Bug 1] Input:      'https://mcp-server.example.com'
    [Bug 1] str(AnyHttpUrl): 'https://mcp-server.example.com/'
    [Bug 1] REPRODUCED: trailing slash added by Pydantic v2
    
    [Bug 1b] ProtectedResourceMetadata.resource: 'https://mcp-server.example.com/'
    
    [Bug 2] refresh_token POST body: {'grant_type': 'refresh_token', 'refresh_token': 'old-refresh', 'client_id': 'test-client', 'resource': 'https://mcp-server.example.com/'}
    [Bug 2] REPRODUCED: 'resource' param present in refresh_token grant
             (trailing slash — Entra audience mismatch)
    

    both bugs present on main (161834d) and v1.x (77431eb).

    code path

    Bug 1 — trailing slash: shared/auth.py:155 types ProtectedResourceMetadata.resource as AnyHttpUrl. Pydantic v2 normalizes bare-domain URLs by appending /. get_resource_url() at oauth2.py:154 calls str(self.protected_resource_metadata.resource) which returns the slash-suffixed form. Fix: call .rstrip('/') there, or change the field type to str with a URL validator.

    Bug 2 — resource on refresh: _refresh_token() at oauth2.py:445-447 adds resource whenever should_include_resource_param() is true, with no exception for refresh_token grants. RFC 8707 doesn't mandate resource on refresh, and Entra v2.0 actively rejects it. Fix: remove lines 445-447 from _refresh_token, keeping resource only on the authorization redirect (oauth2.py:344-346) and initial token exchange (oauth2.py:400-402).

    suggested fix
    // src/mcp/client/auth/oauth2.py
    -            prm_resource = str(self.protected_resource_metadata.resource)
    +            prm_resource = str(self.protected_resource_metadata.resource).rstrip("/")
                 if check_resource_allowed(requested_resource=resource, configured_resource=prm_resource):
                     resource = prm_resource
    
    -        # Only include resource param if conditions are met
    -        if self.context.should_include_resource_param(self.context.protocol_version):
    -            refresh_data["resource"] = self.context.get_resource_url()  # RFC 8707
    -
             # Prepare authentication based on preferred method

    test to verify: test_get_resource_url_no_trailing_slash_for_bare_domain checks that get_resource_url() returns "https://mcp-server.example.com" (no trailing slash) when PRM resource is a bare-domain URL; test_refresh_token_excludes_resource_param confirms resource= is absent from the refresh POST body even when PRM is present and protocol version is 2025-06-18.

  3. added
    bugSomething isn't working
    ready for workEnough information for someone to start working on
    authIssues and PRs related to Authentication / OAuth
    P2Moderate issues affecting some users, edge cases, potentially valuable feature
    fix proposedBot has a verified fix diff in the comment
    and removed
    triageQueued for automated analysis — bot will process and remove this label
    on May 12, 2026
  4. added a commit that references this issue on May 12, 2026
    02da247
  5. jshaofa-ui commented on May 16, 2026

    @jshaofa-ui
  6. serejaris commented on May 27, 2026

    @serejaris

    Duplicate check update: I compared the three open linked PRs for this issue (#2590, #2645, and #2646).

    All three touch the same focused surface:

    • src/mcp/client/auth/oauth2.py
    • tests/client/test_auth.py

    And they all cover the two reported contracts:

    • refresh-token requests no longer include resource=;
    • bare-domain PRM resource URLs no longer retain Pydantic/AnyHttpUrl's trailing / in get_resource_url().

    I do not see an uncovered behavior that would justify another PR from my side. The useful next step looks like maintainer selection/consolidation of one of the existing PRs, not a new duplicate branch.

  7. Marsssssssssssdsss commented on Jun 2, 2026

    @Marsssssssssssdsss
  8. whiskeysierra commented on Jun 2, 2026

    @whiskeysierra

    I posted anthropics/claude-code#52871 (comment) which explains a workaround

  9. fede-kamel commented on Jun 12, 2026

    @fede-kamel

    I'd like to pick this up. The maintainer-endorsed approach from #2590 — dropping resource from refresh_token grants and normalizing only root-path trailing slashes per RFC 9728's exact-identity requirement — was orphaned when that PR was closed during backlog cleanup without review.

    I've opened #2853 with a fresh implementation of that approach on current main. One addition beyond #2590's scope: the newer interaction-level test tests/interaction/auth/test_lifecycle.py (added after #2590 was written) still snapshot-asserted the resource key in the refresh body, so it's updated there as well. Full local gate passes (100% coverage, strict-no-cover, ruff, pyright).

  10. added 2 commits that reference this issue on Aug 19, 2026
    e8afa1a
    1eef34d
  11. monkeydimi commented on Sep 24, 2026

    @monkeydimi

    Ciao ShaneFlag, ho letto il tuo report sul refresh OAuth MCP con Entra ID. Sto verificando come i team testano questi flussi prima del rilascio e se i test esistenti lasciano un problema pratico. Posso farti 4 domande brevi su come hai riprodotto il caso, quanto lavoro ha richiesto e se si è ripetuto? Non mi servono log, token o accesso ai sistemi; va bene anche rispondere qui, e nessun problema se non ti interessa.

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

    P2Moderate issues affecting some users, edge cases, potentially valuable featureauthIssues and PRs related to Authentication / OAuthbugSomething isn't workingfix proposedBot has a verified fix diff in the commentready 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