Repository navigation
OAuth token refresh sends RFC 8707 resource parameter that Entra ID v2.0 rejects (AADSTS9010010) #2578
Description
Activity
- addedtriageQueued for automated analysis — bot will process and remove this labelQueued for automated analysis — bot will process and remove this label
on May 12, 2026 two confirmed bugs:
resourceparam sent onrefresh_tokengrants (Entra v2.0 rejects with AADSTS9010010), andAnyHttpUrladds 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()returnsFalseandresourceis 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)code path
Bug 1 — trailing slash:
shared/auth.py:155typesProtectedResourceMetadata.resourceasAnyHttpUrl. Pydantic v2 normalizes bare-domain URLs by appending/.get_resource_url()atoauth2.py:154callsstr(self.protected_resource_metadata.resource)which returns the slash-suffixed form. Fix: call.rstrip('/')there, or change the field type tostrwith a URL validator.Bug 2 — resource on refresh:
_refresh_token()atoauth2.py:445-447addsresourcewhenevershould_include_resource_param()is true, with no exception forrefresh_tokengrants. RFC 8707 doesn't mandateresourceon refresh, and Entra v2.0 actively rejects it. Fix: remove lines 445-447 from_refresh_token, keepingresourceonly 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_domainchecks thatget_resource_url()returns"https://mcp-server.example.com"(no trailing slash) when PRM resource is a bare-domain URL;test_refresh_token_excludes_resource_paramconfirmsresource=is absent from the refresh POST body even when PRM is present and protocol version is 2025-06-18.- addedbugSomething isn't workingSomething isn't workingready for workEnough information for someone to start working onEnough information for someone to start working onauthIssues and PRs related to Authentication / OAuthIssues and PRs related to Authentication / OAuthP2Moderate issues affecting some users, edge cases, potentially valuable featureModerate issues affecting some users, edge cases, potentially valuable featurefix proposedBot has a verified fix diff in the commentBot has a verified fix diff in the commentand removedtriageQueued for automated analysis — bot will process and remove this labelQueued for automated analysis — bot will process and remove this label
on May 12, 2026 - added a commit that references this issue
on May 12, 2026 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.pytests/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
/inget_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.
I posted anthropics/claude-code#52871 (comment) which explains a workaround
I'd like to pick this up. The maintainer-endorsed approach from #2590 — dropping
resourcefromrefresh_tokengrants 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 testtests/interaction/auth/test_lifecycle.py(added after #2590 was written) still snapshot-asserted theresourcekey in the refresh body, so it's updated there as well. Full local gate passes (100% coverage, strict-no-cover, ruff, pyright).- added 2 commits that reference this issue
on Aug 19, 2026 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.
Problem
The MCP Python SDK sends an RFC 8707
resourceparameter on all token requests — includingrefresh_tokengrants. 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
resourceon refreshEntra's v2.0 token endpoint expects
scope, notresource. Theresourceparameter is a v1.0 concept. Since March 2026, Entra strictly validates and rejectsresourceon token refresh (previously it was silently ignored).2. Pydantic v2
AnyHttpUrltrailing-slash normalizationProtectedResourceMetadata.resourceis typed asAnyHttpUrl(shared/auth.py:143). Whenstr()is called on a bare-domain URL, Pydantic v2 adds a trailing slash:In
get_resource_url()(client/auth/oauth2.py:155), this trailing-slash version is used:But the Entra app registration has the audience as
https://mcp-server.example.com(no slash), so theresourceandscopeaudience don't match.Affected Code
src/mcp/client/auth/oauth2.py:The same issue exists in the TypeScript SDK (
packages/client/src/client/auth.ts), where WHATWGURLalso normalizes bare-domain URLs with a trailing slash.Suggested Fix
Option A: Strip trailing slash in
get_resource_url()Option B: Include
scopealongsideresourceon refreshEntra v2.0 tolerates
resourceifscopeis also present and consistent:Option C: Make
resourceon refresh configurableAllow servers to signal whether the
resourceparameter should be included on refresh grants, since not all authorization servers support RFC 8707.Related Issues
resourceparameter, breaking Entra ID auth (AADSTS9010010) anthropics/claude-code#52871 — same trailing-slash + AADSTS9010010 bugresourceparameter conflicts withscopeon v2.0 endpoint microsoft/powerbi-modeling-mcp#68 — same Entra v2.0 incompatibilityEnvironment
resource)Current Workaround
Server-side: set
resource_server_url=NoneinAuthSettingsand do NOT serve/.well-known/oauth-protected-resourcemetadata. Without PRM,should_include_resource_param()returnsFalseandresourceis omitted from refresh requests. Initial auth still works via theWWW-Authenticateheader fallback.