Repository navigation
Token handler drops RFC 8707 resource on refresh_token (and authorization_code) grants; provider can't see it #3720
Description
Initial Checks
- I confirm that I'm using the newest release of my line (checked on v2.3.0)
- I confirm that I searched for my issue in https://github.com/modelcontextprotocol/python-sdk/issues before opening this issue
Release line
2.x (current stable)
Description
The token handler parses the RFC 8707 resource parameter on a refresh_token grant but never passes it to the provider, and OAuthAuthorizationServerProvider.exchange_refresh_token has no way to receive it.
RefreshTokenRequestdeclaresresource(handlers/token.py#L36-L45).- The refresh branch calls
self.provider.exchange_refresh_token(client_info, refresh_token, scopes)and dropstoken_request.resource(L238). exchange_refresh_token(self, client, refresh_token, scopes)has no resource parameter (provider.py#L259-L264).- The JWT bearer branch does forward it (
resource=token_request.resource, L268).
So an authorization server built on the SDK can't apply RFC 8707 §2.2 on refresh ("such policy may limit the acceptable resources to those that were originally granted by the resource owner or a subset thereof") or answer a mismatched resource with invalid_target. The MCP authorization spec requires clients to send resource in token requests, so the value is always there; the provider just can't see it. #3447 added RefreshToken.resource so the granted resource can be stored, but there is still nothing to compare it with.
The same applies to the authorization_code grant: the handler doesn't compare token_request.resource with AuthorizationCode.resource and doesn't pass it to exchange_authorization_code (L200), so a mismatch is accepted silently.
For comparison, the TypeScript SDK's token handler passes resource to exchangeRefreshToken and to the authorization code exchange.
Today the only workaround is to subclass TokenHandler and hand the form value to the provider out of band.
Expected: the provider can see the requested resource on both grants. One backwards-compatible option is an optional resource: str | None = None argument on exchange_refresh_token and exchange_authorization_code (or a params object, as the JWT bearer path does). Optionally, the handler could itself return invalid_target when resource is sent and differs from refresh_token.resource / auth_code.resource.
Example Code
class Provider(OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]):
async def exchange_refresh_token(
self, client: OAuthClientInformationFull, refresh_token: RefreshToken, scopes: list[str]
) -> OAuthToken:
# POST /token
# grant_type=refresh_token&refresh_token=...&resource=https://other.example/mcp
# The requested resource is not available here, so the provider can't refuse it
# with invalid_target or check it against refresh_token.resource.
...Python & MCP Python SDK
Python 3.12.12
mcp v2.3.0
Activity
sattyamjjain commentedon Oct 17, 2026
Confirmed on main at 9f66f37: token.py L200 calls
exchange_authorization_code(client_info, auth_code)and L238 callsexchange_refresh_token(client_info, refresh_token, scopes), while the JWT-bearer branch at L268 passesresource=token_request.resource. Same gap on v1.x (52e64dd, L189 and L232).https://github.com/modelcontextprotocol/python-sdk/blob/9f66f376d486e84e9810b368cf8be9d5908661ab/src/mcp/server/auth/handlers/token.py
For reference, the TypeScript SDK v1.x passes it as an optional argument on both grants (
exchangeRefreshToken(client, refreshToken, scopes?, resource?: URL), provider.ts L51; handler token.ts L109-114 and L129-133). It only forwards the value and leaves the check to the provider.If the handler does validate, I think it should be a subset check, not equality. RFC 8707 section 2.2: "In the case of a "refresh_token" or "authorization_code" grant type request, such policy may limit the acceptable resources to those that were originally granted by the resource owner or a subset thereof."
TokenErrorCodein provider.ts already includes "invalid_target", so a provider could reject with it once it can see the value.anhdoox commentedon Oct 21, 2026
Hi, I'd like to work on this. The fix looks contained to src/mcp/server/auth/handlers/token.py (pass
resourcethrough on refresh_token and authorization_code grants) plus provider.py, with tests. Could a maintainer assign me?Ben-Home commentedon Oct 21, 2026
Read the code paths you cite, and the asymmetry is the strongest evidence in the report: the JWT bearer branch forwards
resource, the refresh branch does not. That is very likely an oversight rather than a decision, since RFC 8707 §2.2 addresses the refresh grant explicitly and the whole point of the resource indicator is that an AS can restrict audiences at every grant, not only at first issuance.Three additions that would make the fix land:
Backward-compatible signature. Adding
resourcetoexchange_refresh_tokenbreaks every out-of-tree provider implementation. The non-breaking form is keyword-only with a default:exchange_refresh_token(self, client, refresh_token, scopes, *, resource=None). SDK providers get the fix, third-party providers keep working and can opt in.Validation, not just plumbing. RFC 8707 §2.2 lets the AS limit acceptable resources to the ones originally granted, which means the handler should compare rather than forward. If the fix only passes the value through, a dropped parameter becomes an unvalidated parameter and a client can walk one refresh token across multiple audiences. Also compare as a set: clients may send more than one
resourceon refresh, and a string comparison will reject legal requests.Check the code grant while you are in there.
exchange_authorization_codedropstoken_request.resourcein the same file. That matters more than it looks, because if the authorization code grant never persists the resource audience, the refresh validation in (2) has nothing to compare against. Fixing one without the other leaves the AS unable to enforce §2.2 at all.We have hit the audience-binding half of this from the client side: a token that authenticates against one MCP endpoint and 401s against a sibling endpoint on the same host, because the resource indicator in the token did not match the URL being called. Persisting the resource at the code grant and re-checking it at refresh is what makes that failure impossible rather than intermittent.