From e57f23a4a8163ede8a25e41d8fe11345fdb72b72 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:21:04 +0000 Subject: [PATCH 1/5] Add AuthSettings.validate_token_resource to check a bearer token's resource BearerAuthBackend takes an optional resource_server_url; when it is set, only a token the verifier reports as issued for that URL (AccessToken.resource, the RFC 8707 resource indicator, compared as a URL with a trailing slash tolerated) is accepted, and anything else is answered 401 like an unrecognized token. AuthSettings.validate_token_resource (default False) turns this on for the Streamable HTTP and SSE apps; it requires resource_server_url. TokenVerifier.verify_token's docstring and docs/run/authorization.md say where the token's audience goes and when to enable the option versus checking the audience in the verifier. The simple-auth example enables it. The interaction suite enables it for the bearer tests and records that it is off by default on hosting:auth:aud-validation. --- docs/run/authorization.md | 3 +- .../simple-auth/mcp_simple_auth/server.py | 1 + src/mcp/server/auth/middleware/bearer_auth.py | 28 +++++++++- src/mcp/server/auth/provider.py | 10 +++- src/mcp/server/auth/settings.py | 15 ++++- src/mcp/server/lowlevel/server.py | 5 +- src/mcp/server/mcpserver/server.py | 7 ++- tests/interaction/_requirements.py | 6 +- tests/interaction/auth/_harness.py | 5 +- tests/interaction/auth/test_bearer.py | 56 +++++++++++-------- .../auth/middleware/test_bearer_auth.py | 51 +++++++++++++++++ tests/server/auth/test_settings.py | 17 ++++++ 12 files changed, 169 insertions(+), 35 deletions(-) create mode 100644 tests/server/auth/test_settings.py diff --git a/docs/run/authorization.md b/docs/run/authorization.md index b7d731b1e2..6ab3f5e5bc 100644 --- a/docs/run/authorization.md +++ b/docs/run/authorization.md @@ -23,7 +23,7 @@ The SDK has no opinion about what a valid token looks like. You tell it, by impl ``` * `TokenVerifier` is a protocol with one async method. `verify_token` gets the raw token from the `Authorization` header and returns an **`AccessToken`** if it's valid, `None` if it isn't. There is nothing else to implement. -* This one looks the token up in a table. A real one verifies a JWT signature or calls the authorization server's token-introspection endpoint. That code is yours; the SDK only calls it. +* This one looks the token up in a table that belongs to this server. A real one verifies a JWT signature or calls the authorization server's token-introspection endpoint, and puts the token's audience (`aud`) in `AccessToken.resource` so the SDK can check the token was issued for this server (see `validate_token_resource` below); if `aud` is a list, use the entry that equals your `resource_server_url`. That code is yours; the SDK only calls it. * `token_verifier=` and `auth=` always travel together. Pass one without the other and `MCPServer(...)` raises a `ValueError` before it ever serves a request. `AuthSettings` is the public face of your resource server: @@ -31,6 +31,7 @@ The SDK has no opinion about what a valid token looks like. You tell it, by impl * `issuer_url`: the authorization server that issues your tokens. * `resource_server_url`: the public URL of this MCP endpoint. It names *which* resource a token is for, and it's where the discovery document lives. * `required_scopes`: every token must carry all of them. +* `validate_token_resource`: refuse any token your verifier does not report as issued for `resource_server_url` (its `AccessToken.resource`, compared as a URL, a trailing slash aside). Turn it on when your authorization server binds tokens to the `resource` a client asks for, which is what MCP clients send. If it issues its own audience identifiers instead (an Auth0 API identifier, an Entra application ID), leave it off and check `aud` against that identifier in your verifier: a token you cannot tie to this server should come back as `None`. !!! tip `examples/servers/simple-auth/` in the SDK repository has an `IntrospectionTokenVerifier` that calls diff --git a/examples/servers/simple-auth/mcp_simple_auth/server.py b/examples/servers/simple-auth/mcp_simple_auth/server.py index 0320871b12..c53df6a550 100644 --- a/examples/servers/simple-auth/mcp_simple_auth/server.py +++ b/examples/servers/simple-auth/mcp_simple_auth/server.py @@ -71,6 +71,7 @@ def create_resource_server(settings: ResourceServerSettings) -> MCPServer: issuer_url=settings.auth_server_url, required_scopes=[settings.mcp_scope], resource_server_url=settings.server_url, + validate_token_resource=True, # tokens must be reported as issued for server_url ), ) # Store settings for later use in run() diff --git a/src/mcp/server/auth/middleware/bearer_auth.py b/src/mcp/server/auth/middleware/bearer_auth.py index 29413abf2b..541894b652 100644 --- a/src/mcp/server/auth/middleware/bearer_auth.py +++ b/src/mcp/server/auth/middleware/bearer_auth.py @@ -1,14 +1,17 @@ import json +import logging import time from typing import Any, TypedDict -from pydantic import AnyHttpUrl +from pydantic import AnyHttpUrl, ValidationError from starlette.authentication import AuthCredentials, AuthenticationBackend, SimpleUser from starlette.requests import HTTPConnection from starlette.types import Receive, Scope, Send from mcp.server.auth.provider import AccessToken, TokenVerifier, principal_components +logger = logging.getLogger(__name__) + class AuthenticatedUser(SimpleUser): """User with authentication info.""" @@ -39,10 +42,15 @@ def authorization_context(user: AuthenticatedUser) -> AuthorizationContext: class BearerAuthBackend(AuthenticationBackend): - """Authentication backend that validates Bearer tokens using a TokenVerifier.""" + """Authentication backend that validates Bearer tokens using a TokenVerifier. + + When `resource_server_url` is given, only a token whose `AccessToken.resource` + (its RFC 8707 resource indicator / audience) is that URL is accepted. + """ - def __init__(self, token_verifier: TokenVerifier): + def __init__(self, token_verifier: TokenVerifier, *, resource_server_url: AnyHttpUrl | None = None): self.token_verifier = token_verifier + self.resource_server_url = resource_server_url async def authenticate(self, conn: HTTPConnection): auth_header = next( @@ -63,8 +71,22 @@ async def authenticate(self, conn: HTTPConnection): if auth_info.expires_at and auth_info.expires_at < int(time.time()): return None + if self.resource_server_url and not self._issued_for_this_resource(auth_info.resource): + logger.warning( + "Bearer token resource %s is not resource_server_url %s", auth_info.resource, self.resource_server_url + ) + return None + return AuthCredentials(auth_info.scopes), AuthenticatedUser(auth_info) + def _issued_for_this_resource(self, resource: str | None) -> bool: + """Compare as URLs (so case and default-port spelling do not matter), a trailing slash aside.""" + try: + token_resource = str(AnyHttpUrl(resource or "")) + except ValidationError: + return False + return token_resource.removesuffix("/") == str(self.resource_server_url).removesuffix("/") + class RequireAuthMiddleware: """Middleware that requires a valid Bearer token in the Authorization header. diff --git a/src/mcp/server/auth/provider.py b/src/mcp/server/auth/provider.py index 644868f3e5..bfb68d4b98 100644 --- a/src/mcp/server/auth/provider.py +++ b/src/mcp/server/auth/provider.py @@ -124,7 +124,15 @@ class TokenVerifier(Protocol): """Protocol for verifying bearer tokens.""" async def verify_token(self, token: str) -> AccessToken | None: - """Verify a bearer token and return access info if valid.""" + """Verify a bearer token and return access info if valid. + + Set `AccessToken.resource` to the resource the token was issued for (its RFC 8707 + resource indicator / `aud`; for a list, the entry equal to the server's + `AuthSettings.resource_server_url`). With `AuthSettings.validate_token_resource` the + bearer middleware then refuses any token whose resource is not `resource_server_url`; + without it, confirming the token was issued for this server (for example by passing the + expected audience to your JWT library) is up to the verifier. + """ # NOTE: MCPServer doesn't render any of these types in the user response, so it's diff --git a/src/mcp/server/auth/settings.py b/src/mcp/server/auth/settings.py index ae2083a38b..52e5c9780e 100644 --- a/src/mcp/server/auth/settings.py +++ b/src/mcp/server/auth/settings.py @@ -1,4 +1,5 @@ -from pydantic import AnyHttpUrl, BaseModel, ConfigDict, Field +from pydantic import AnyHttpUrl, BaseModel, ConfigDict, Field, model_validator +from typing_extensions import Self class ClientRegistrationOptions(BaseModel): @@ -40,3 +41,15 @@ class AuthSettings(BaseModel): description="The URL of the MCP server to be used as the resource identifier " "and base route to look up OAuth Protected Resource Metadata.", ) + validate_token_resource: bool = Field( + default=False, + description="Only accept tokens the token verifier reports as issued for `resource_server_url` " + "(`AccessToken.resource`, the RFC 8707 resource indicator). Enable it when your authorization " + "server binds tokens to the `resource` the client requested.", + ) + + @model_validator(mode="after") + def _validate_token_resource_needs_a_resource(self) -> Self: + if self.validate_token_resource and self.resource_server_url is None: + raise ValueError("validate_token_resource requires resource_server_url") + return self diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index 6df5341e41..8a886dcc24 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -776,7 +776,10 @@ def streamable_http_app( middleware = [ Middleware( AuthenticationMiddleware, - backend=BearerAuthBackend(token_verifier), + backend=BearerAuthBackend( + token_verifier, + resource_server_url=auth.resource_server_url if auth.validate_token_resource else None, + ), ), Middleware(AuthContextMiddleware), ] diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index 1f3e863dbd..fbd2c26dd8 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -1187,7 +1187,12 @@ async def handle_sse(scope: Scope, receive: Receive, send: Send): # pragma: no # extract auth info from request (but do not require it) Middleware( AuthenticationMiddleware, - backend=BearerAuthBackend(self._token_verifier), + backend=BearerAuthBackend( + self._token_verifier, + resource_server_url=self.settings.auth.resource_server_url + if self.settings.auth.validate_token_resource + else None, + ), ), # Add the auth context middleware to store # authenticated user in a contextvar diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index 11bffba7b5..16c4036420 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -2848,11 +2848,11 @@ def __post_init__(self) -> None: source=f"{SPEC_BASE_URL}/basic/authorization#access-token-usage", behavior="The resource server validates that the token audience matches its resource identifier.", transports=("streamable-http",), - note="Auth is enforced at the HTTP layer.", + note="Auth is enforced at the HTTP layer; the tests enable AuthSettings.validate_token_resource.", divergence=Divergence( note=( - "BearerAuthBackend never inspects AccessToken.resource; a token issued for a different " - "resource is accepted. Spec MUST." + "Off by default: without AuthSettings.validate_token_resource the bearer gate does not compare " + "AccessToken.resource with resource_server_url and the check is the token verifier's. Spec MUST." ), ), ), diff --git a/tests/interaction/auth/_harness.py b/tests/interaction/auth/_harness.py index 856a1fe9a8..16b84b1de5 100644 --- a/tests/interaction/auth/_harness.py +++ b/tests/interaction/auth/_harness.py @@ -182,6 +182,7 @@ def auth_settings( required_scopes: Sequence[str] = ("mcp",), valid_scopes: Sequence[str] | None = None, identity_assertion_enabled: bool = False, + validate_token_resource: bool = False, ) -> AuthSettings: """Build `AuthSettings` for the co-hosted authorization + resource server. @@ -194,7 +195,8 @@ def auth_settings( `identity_assertion_enabled` advertises and accepts the SEP-990 ID-JAG grant (RFC 7523 jwt-bearer); the provider must implement `exchange_identity_assertion` for the endpoint to - issue tokens. + issue tokens. `validate_token_resource` makes the bearer gate refuse tokens whose verifier + does not report them as issued for the resource URL. """ required = list(required_scopes) valid = list(valid_scopes) if valid_scopes is not None else required @@ -202,6 +204,7 @@ def auth_settings( issuer_url=AnyHttpUrl(BASE_URL), resource_server_url=AnyHttpUrl(f"{BASE_URL}/mcp"), required_scopes=required, + validate_token_resource=validate_token_resource, client_registration_options=ClientRegistrationOptions( enabled=True, valid_scopes=valid, default_scopes=required ), diff --git a/tests/interaction/auth/test_bearer.py b/tests/interaction/auth/test_bearer.py index c70a27c52e..0cc1f20bfc 100644 --- a/tests/interaction/auth/test_bearer.py +++ b/tests/interaction/auth/test_bearer.py @@ -2,9 +2,9 @@ These tests mount only the resource-server side of the auth wiring (a `StaticTokenVerifier` seeded with hand-built tokens, no authorization-server provider) and speak raw HTTP, since -every assertion is about HTTP semantics the SDK `Client` cannot observe: the 401/403 status, -the `WWW-Authenticate` header structure, and that a wrong-audience token reaches the MCP -endpoint behind the gate. The flow side of the same 401 is `test_flow.py`'s flagship test. +every assertion is about HTTP semantics the SDK `Client` cannot observe: the 401/403 status +and the `WWW-Authenticate` header structure. The flow side of the same 401 is `test_flow.py`'s +flagship test. """ import time @@ -24,22 +24,25 @@ pytestmark = pytest.mark.anyio REQUIRED_SCOPE = "mcp:read" +RESOURCE = "http://127.0.0.1:8000/mcp" RESOURCE_METADATA_URL = "http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp" _FUTURE = int(time.time()) + 3600 _PAST = int(time.time()) - 3600 + +def tok(name: str, *, scopes: list[str], expires_at: int, resource: str | None = RESOURCE) -> AccessToken: + return AccessToken(token=name, client_id="c", scopes=scopes, expires_at=expires_at, resource=resource) + + TOKENS = { - "tok-valid": AccessToken(token="tok-valid", client_id="c", scopes=[REQUIRED_SCOPE], expires_at=_FUTURE), - "tok-expired": AccessToken(token="tok-expired", client_id="c", scopes=[REQUIRED_SCOPE], expires_at=_PAST), - "tok-noscope": AccessToken(token="tok-noscope", client_id="c", scopes=["other:thing"], expires_at=_FUTURE), - "tok-wrong-aud": AccessToken( - token="tok-wrong-aud", - client_id="c", - scopes=[REQUIRED_SCOPE], - expires_at=_FUTURE, - resource="https://other.example/mcp", + "tok-valid": tok("tok-valid", scopes=[REQUIRED_SCOPE], expires_at=_FUTURE), + "tok-expired": tok("tok-expired", scopes=[REQUIRED_SCOPE], expires_at=_PAST), + "tok-noscope": tok("tok-noscope", scopes=["other:thing"], expires_at=_FUTURE), + "tok-wrong-aud": tok( + "tok-wrong-aud", scopes=[REQUIRED_SCOPE], expires_at=_FUTURE, resource="https://other.example/mcp" ), + "tok-no-aud": tok("tok-no-aud", scopes=[REQUIRED_SCOPE], expires_at=_FUTURE, resource=None), } @@ -47,7 +50,7 @@ async def protected() -> AsyncIterator[httpx2.AsyncClient]: """A bearer-gated streamable-HTTP app (resource server only) on the in-process bridge.""" server = Server("rs") - settings = auth_settings(required_scopes=[REQUIRED_SCOPE]) + settings = auth_settings(required_scopes=[REQUIRED_SCOPE], validate_token_resource=True) async with mounted_app(server, auth=settings, token_verifier=StaticTokenVerifier(TOKENS)) as (http, _): yield http @@ -157,19 +160,26 @@ async def test_a_token_missing_a_required_scope_is_answered_403_insufficient_sco @requirement("hosting:auth:aud-validation") -async def test_a_token_with_a_mismatched_audience_is_accepted(protected: httpx2.AsyncClient) -> None: - """A token whose `resource` does not match the server's resource identifier is accepted. - - The spec mandates the resource server validate the token's audience; the bearer backend - never inspects `AccessToken.resource`, so the request passes the gate and the MCP endpoint - serves it. This pins current behaviour with the divergence recorded on the requirement. +@pytest.mark.parametrize("bearer", ["tok-wrong-aud", "tok-no-aud"]) +async def test_a_token_not_issued_for_this_resource_is_answered_401(protected: httpx2.AsyncClient, bearer: str) -> None: + """Spec-mandated audience check, which the SDK performs when `AuthSettings.validate_token_resource` + is set (off by default, the recorded divergence): a token whose verifier-reported `resource` + (RFC 8707) is another URL, or absent, is answered 401 `invalid_token` like an unrecognized token. """ - response = await post_mcp(protected, bearer="tok-wrong-aud") + response = await post_mcp(protected, bearer=bearer) + + assert response.status_code == 401 + assert parse_www_authenticate(response.headers["www-authenticate"])["error"] == "invalid_token" + + +@requirement("hosting:auth:aud-validation") +async def test_a_token_issued_for_this_resource_is_served(protected: httpx2.AsyncClient) -> None: + """The other half: a token the verifier reports as issued for `resource_server_url` passes the + gate and the MCP endpoint answers the initialize request.""" + response = await post_mcp(protected, bearer="tok-valid") assert response.status_code == 200 - assert response.headers["content-type"].startswith("text/event-stream") - # The body is finite SSE: a result event followed by stream close. Pull the JSON-RPC response - # out of the buffered text to prove the MCP endpoint actually answered the initialize request. + # Finite SSE body: pull out the JSON-RPC result to prove the endpoint actually answered. [data] = [line.removeprefix("data: ") for line in response.text.splitlines() if line.startswith("data: ")] assert "protocolVersion" in JSONRPCResponse.model_validate_json(data).result diff --git a/tests/server/auth/middleware/test_bearer_auth.py b/tests/server/auth/middleware/test_bearer_auth.py index 6ab3436771..b7efb937c2 100644 --- a/tests/server/auth/middleware/test_bearer_auth.py +++ b/tests/server/auth/middleware/test_bearer_auth.py @@ -4,6 +4,7 @@ from typing import Any, cast import pytest +from pydantic import AnyHttpUrl from starlette.authentication import AuthCredentials from starlette.datastructures import Headers from starlette.requests import Request @@ -273,6 +274,56 @@ async def test_mixed_case_authorization_header( assert user.access_token == valid_access_token +class SingleTokenVerifier: + """A `TokenVerifier` that knows exactly one token.""" + + def __init__(self, access_token: AccessToken) -> None: + self.access_token = access_token + + async def verify_token(self, token: str) -> AccessToken | None: + return self.access_token if token == self.access_token.token else None + + +RS = "https://api.example.com/mcp" + + +@pytest.mark.anyio +@pytest.mark.parametrize( + ("resource_server_url", "token_resource", "accepted"), + [ + (None, "https://other.example.com/mcp", True), # nothing configured to compare against + (None, None, True), + (RS, None, False), # the verifier did not report what the token was issued for + (RS, RS, True), + (RS, RS + "/", True), + (RS, "https://API.EXAMPLE.COM:443/mcp", True), # same URL, different spelling + (RS, "https://api.example.com", False), + (RS, RS + "/child", False), + (RS, "https://api.example.com/other", False), + (RS, "https://other.example.com/mcp", False), + (RS, "api.example.com", False), # not a URL + ], +) +async def test_backend_accepts_only_tokens_issued_for_its_resource( + resource_server_url: str | None, token_resource: str | None, accepted: bool +): + """With `resource_server_url` set, only a token whose `resource` (RFC 8707) is that URL is + accepted and anything else is treated like an unrecognized token (spec-mandated audience + check); without it the verifier's answer stands (SDK-defined, the default wiring).""" + token = AccessToken(token="t", client_id="c", scopes=["read"], resource=token_resource) + backend = BearerAuthBackend( + SingleTokenVerifier(token), + resource_server_url=AnyHttpUrl(resource_server_url) if resource_server_url else None, + ) + + result = await backend.authenticate(Request({"type": "http", "headers": [(b"authorization", b"Bearer t")]})) + + if accepted: + assert result is not None and result[1].access_token == token + else: + assert result is None + + @pytest.mark.anyio class TestRequireAuthMiddleware: """Tests for the RequireAuthMiddleware class.""" diff --git a/tests/server/auth/test_settings.py b/tests/server/auth/test_settings.py new file mode 100644 index 0000000000..84967ebb72 --- /dev/null +++ b/tests/server/auth/test_settings.py @@ -0,0 +1,17 @@ +import pytest +from pydantic import AnyHttpUrl, ValidationError + +from mcp.server.auth.settings import AuthSettings + + +def test_validate_token_resource_requires_a_resource_server_url(): + """SDK-defined: asking the bearer gate to compare tokens against `resource_server_url` without + configuring one is refused at construction time rather than silently comparing nothing.""" + issuer_url = AnyHttpUrl("https://auth.example.com") + AuthSettings( + issuer_url=issuer_url, + resource_server_url=AnyHttpUrl("https://mcp.example.com/mcp"), + validate_token_resource=True, + ) + with pytest.raises(ValidationError, match="validate_token_resource requires resource_server_url"): + AuthSettings(issuer_url=issuer_url, resource_server_url=None, validate_token_resource=True) From 84c7c13f73fcaaf4e086f9c287043e7e9e931345 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Fri, 4 Sep 2026 23:42:01 +0000 Subject: [PATCH 2/5] Carry the token resource through refresh; review follow-ups RefreshToken gains an optional resource (RFC 8707 resource indicator) that a provider propagates to refreshed access tokens, as it does subject, so a server with validate_token_resource keeps accepting tokens after a refresh. The interaction suite's provider does so and a lifecycle test covers it. Also: log the reported resource with %r; note in the docs that resource_server_url should be the exact endpoint URL when the option is on; pin the default-off behaviour with an interaction test; have the simple-auth verifier pick the aud entry for this server when aud is a list. --- docs/run/authorization.md | 2 +- .../mcp_simple_auth/token_verifier.py | 9 ++++++- src/mcp/server/auth/middleware/bearer_auth.py | 2 +- src/mcp/server/auth/provider.py | 1 + tests/interaction/_requirements.py | 2 +- tests/interaction/auth/_provider.py | 7 ++++-- tests/interaction/auth/test_bearer.py | 12 +++++++++ tests/interaction/auth/test_lifecycle.py | 25 +++++++++++++++++++ 8 files changed, 54 insertions(+), 6 deletions(-) diff --git a/docs/run/authorization.md b/docs/run/authorization.md index 6ab3f5e5bc..3087de2ed5 100644 --- a/docs/run/authorization.md +++ b/docs/run/authorization.md @@ -31,7 +31,7 @@ The SDK has no opinion about what a valid token looks like. You tell it, by impl * `issuer_url`: the authorization server that issues your tokens. * `resource_server_url`: the public URL of this MCP endpoint. It names *which* resource a token is for, and it's where the discovery document lives. * `required_scopes`: every token must carry all of them. -* `validate_token_resource`: refuse any token your verifier does not report as issued for `resource_server_url` (its `AccessToken.resource`, compared as a URL, a trailing slash aside). Turn it on when your authorization server binds tokens to the `resource` a client asks for, which is what MCP clients send. If it issues its own audience identifiers instead (an Auth0 API identifier, an Entra application ID), leave it off and check `aud` against that identifier in your verifier: a token you cannot tie to this server should come back as `None`. +* `validate_token_resource`: refuse any token your verifier does not report as issued for `resource_server_url` (its `AccessToken.resource`, compared as a URL, a trailing slash aside). Turn it on when your authorization server binds tokens to the `resource` a client asks for, which is what MCP clients send, and keep `resource_server_url` the exact endpoint URL clients connect to: a client that skips the discovery document binds its token to that URL, and a parent such as the bare origin will not match. If your authorization server issues its own audience identifiers instead (an Auth0 API identifier, an Entra application ID), leave it off and check `aud` against that identifier in your verifier: a token you cannot tie to this server should come back as `None`. !!! tip `examples/servers/simple-auth/` in the SDK repository has an `IntrospectionTokenVerifier` that calls diff --git a/examples/servers/simple-auth/mcp_simple_auth/token_verifier.py b/examples/servers/simple-auth/mcp_simple_auth/token_verifier.py index 933935d6a6..687196d18c 100644 --- a/examples/servers/simple-auth/mcp_simple_auth/token_verifier.py +++ b/examples/servers/simple-auth/mcp_simple_auth/token_verifier.py @@ -69,12 +69,19 @@ async def verify_token(self, token: str) -> AccessToken | None: logger.warning(f"Token resource validation failed. Expected: {self.resource_url}") return None + # `aud` may be a string or a list; report the entry naming this server when there is + # one, otherwise what the token was issued for, so the server can compare it. + aud: str | list[str] | None = data.get("aud") + audiences = aud if isinstance(aud, list) else [aud] if aud else [] + own = self.resource_url.rstrip("/") + resource = next((a for a in audiences if a.rstrip("/") == own), audiences[0] if audiences else None) + return AccessToken( token=token, client_id=data.get("client_id", "unknown"), scopes=data.get("scope", "").split() if data.get("scope") else [], expires_at=data.get("exp"), - resource=data.get("aud"), # Include resource in token + resource=resource, subject=data.get("sub"), # RFC 7662 subject (resource owner) claims=data, ) diff --git a/src/mcp/server/auth/middleware/bearer_auth.py b/src/mcp/server/auth/middleware/bearer_auth.py index 541894b652..1a070cd29d 100644 --- a/src/mcp/server/auth/middleware/bearer_auth.py +++ b/src/mcp/server/auth/middleware/bearer_auth.py @@ -73,7 +73,7 @@ async def authenticate(self, conn: HTTPConnection): if self.resource_server_url and not self._issued_for_this_resource(auth_info.resource): logger.warning( - "Bearer token resource %s is not resource_server_url %s", auth_info.resource, self.resource_server_url + "Bearer token resource %r is not resource_server_url %s", auth_info.resource, self.resource_server_url ) return None diff --git a/src/mcp/server/auth/provider.py b/src/mcp/server/auth/provider.py index bfb68d4b98..bc8ba3e518 100644 --- a/src/mcp/server/auth/provider.py +++ b/src/mcp/server/auth/provider.py @@ -46,6 +46,7 @@ class RefreshToken(BaseModel): client_id: str scopes: list[str] expires_at: int | None = None + resource: str | None = None # RFC 8707 resource indicator; propagate to refreshed AccessTokens subject: str | None = None # resource owner; propagate to refreshed AccessTokens diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index 16c4036420..235fb65cd4 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -2848,7 +2848,7 @@ def __post_init__(self) -> None: source=f"{SPEC_BASE_URL}/basic/authorization#access-token-usage", behavior="The resource server validates that the token audience matches its resource identifier.", transports=("streamable-http",), - note="Auth is enforced at the HTTP layer; the tests enable AuthSettings.validate_token_resource.", + note="Auth is enforced at the HTTP layer; the conformant tests enable AuthSettings.validate_token_resource.", divergence=Divergence( note=( "Off by default: without AuthSettings.validate_token_resource the bearer gate does not compare " diff --git a/tests/interaction/auth/_provider.py b/tests/interaction/auth/_provider.py index 0c54d4fd37..4e422947b4 100644 --- a/tests/interaction/auth/_provider.py +++ b/tests/interaction/auth/_provider.py @@ -157,6 +157,7 @@ async def exchange_authorization_code( token=refresh, client_id=client.client_id, scopes=authorization_code.scopes, + resource=authorization_code.resource, ) del self.codes[authorization_code.code] return OAuthToken( @@ -183,9 +184,11 @@ async def exchange_refresh_token( if self._fail_next_refresh: self._fail_next_refresh = False raise TokenError(error="invalid_grant", error_description="refresh denied by harness") - access = self.mint_access_token(client_id=client.client_id, scopes=scopes) + access = self.mint_access_token(client_id=client.client_id, scopes=scopes, resource=refresh_token.resource) new_refresh = f"refresh_{secrets.token_hex(16)}" - self.refresh_tokens[new_refresh] = RefreshToken(token=new_refresh, client_id=client.client_id, scopes=scopes) + self.refresh_tokens[new_refresh] = RefreshToken( + token=new_refresh, client_id=client.client_id, scopes=scopes, resource=refresh_token.resource + ) del self.refresh_tokens[refresh_token.token] return OAuthToken( access_token=access, diff --git a/tests/interaction/auth/test_bearer.py b/tests/interaction/auth/test_bearer.py index 0cc1f20bfc..40b5010c15 100644 --- a/tests/interaction/auth/test_bearer.py +++ b/tests/interaction/auth/test_bearer.py @@ -172,6 +172,18 @@ async def test_a_token_not_issued_for_this_resource_is_answered_401(protected: h assert parse_www_authenticate(response.headers["www-authenticate"])["error"] == "invalid_token" +@requirement("hosting:auth:aud-validation") +async def test_a_token_for_another_resource_is_served_when_validate_token_resource_is_off() -> None: + """The recorded divergence: with `AuthSettings` at their defaults the gate does not compare + `AccessToken.resource` with `resource_server_url`, so a token the verifier reports as issued + for another resource still reaches the MCP endpoint (SDK default; the check is the verifier's).""" + settings = auth_settings(required_scopes=[REQUIRED_SCOPE]) + async with mounted_app(Server("rs"), auth=settings, token_verifier=StaticTokenVerifier(TOKENS)) as (http, _): + response = await post_mcp(http, bearer="tok-wrong-aud") + + assert response.status_code == 200 + + @requirement("hosting:auth:aud-validation") async def test_a_token_issued_for_this_resource_is_served(protected: httpx2.AsyncClient) -> None: """The other half: a token the verifier reports as issued for `resource_server_url` passes the diff --git a/tests/interaction/auth/test_lifecycle.py b/tests/interaction/auth/test_lifecycle.py index 610db62e27..32f96bc34b 100644 --- a/tests/interaction/auth/test_lifecycle.py +++ b/tests/interaction/auth/test_lifecycle.py @@ -135,6 +135,31 @@ async def test_an_expired_access_token_is_transparently_refreshed_before_the_nex assert storage.tokens.expires_in == 3600 +@requirement("client-auth:refresh:transparent") +async def test_a_refreshed_access_token_is_still_accepted_when_the_server_validates_token_resource() -> None: + """With `AuthSettings.validate_token_resource` on, the access token minted by the refresh grant + still passes the gate: the provider carries the original grant's resource on `RefreshToken` and + onto the refreshed `AccessToken` (SDK-defined propagation), so the request after the refresh is + served rather than answered 401.""" + recorded, on_request = record_requests() + provider = InMemoryAuthorizationServerProvider(issue_expired_first=True) + server = Server("guarded", on_list_tools=list_tools) + settings = auth_settings(validate_token_resource=True) + + with anyio.fail_after(5): + async with connect_with_oauth(server, provider=provider, settings=settings, on_request=on_request) as ( + client, + _, + ): + result = await client.list_tools() + + assert result.tools[0].name == "echo" + assert [form_body(r)["grant_type"] for r in find(recorded, "POST", "/token")] == snapshot( + ["authorization_code", "refresh_token"] + ) + assert {t.resource for t in provider.access_tokens.values()} == {f"{BASE_URL}/mcp"} + + @requirement("client-auth:403-scope-upgrade") async def test_a_403_insufficient_scope_triggers_one_reauthorize_with_the_challenged_scope() -> None: """A 403 `insufficient_scope` challenge is answered by one re-authorize with the challenge's scope. From 0f89376f7e6fb8c8a818ae57db66d82af17ff126 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Sat, 5 Sep 2026 01:13:10 +0000 Subject: [PATCH 3/5] Docs: shorter validate_token_resource guidance as a list; spell out test tokens --- docs/run/authorization.md | 7 +++++-- tests/interaction/auth/test_bearer.py | 26 ++++++++++++++++---------- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/docs/run/authorization.md b/docs/run/authorization.md index 3087de2ed5..5e0f3800b6 100644 --- a/docs/run/authorization.md +++ b/docs/run/authorization.md @@ -23,7 +23,7 @@ The SDK has no opinion about what a valid token looks like. You tell it, by impl ``` * `TokenVerifier` is a protocol with one async method. `verify_token` gets the raw token from the `Authorization` header and returns an **`AccessToken`** if it's valid, `None` if it isn't. There is nothing else to implement. -* This one looks the token up in a table that belongs to this server. A real one verifies a JWT signature or calls the authorization server's token-introspection endpoint, and puts the token's audience (`aud`) in `AccessToken.resource` so the SDK can check the token was issued for this server (see `validate_token_resource` below); if `aud` is a list, use the entry that equals your `resource_server_url`. That code is yours; the SDK only calls it. +* This one looks the token up in a table. A real one verifies a JWT signature or calls the authorization server's token-introspection endpoint, and reports who the token was issued for (its `aud`) in `AccessToken.resource`. That code is yours; the SDK only calls it. * `token_verifier=` and `auth=` always travel together. Pass one without the other and `MCPServer(...)` raises a `ValueError` before it ever serves a request. `AuthSettings` is the public face of your resource server: @@ -31,7 +31,10 @@ The SDK has no opinion about what a valid token looks like. You tell it, by impl * `issuer_url`: the authorization server that issues your tokens. * `resource_server_url`: the public URL of this MCP endpoint. It names *which* resource a token is for, and it's where the discovery document lives. * `required_scopes`: every token must carry all of them. -* `validate_token_resource`: refuse any token your verifier does not report as issued for `resource_server_url` (its `AccessToken.resource`, compared as a URL, a trailing slash aside). Turn it on when your authorization server binds tokens to the `resource` a client asks for, which is what MCP clients send, and keep `resource_server_url` the exact endpoint URL clients connect to: a client that skips the discovery document binds its token to that URL, and a parent such as the bare origin will not match. If your authorization server issues its own audience identifiers instead (an Auth0 API identifier, an Entra application ID), leave it off and check `aud` against that identifier in your verifier: a token you cannot tie to this server should come back as `None`. +* `validate_token_resource`: refuse any token whose `AccessToken.resource` is not `resource_server_url`. Off by default. + * Turn it on when your authorization server binds tokens to the `resource` the client requested, which MCP clients always send. Keep `resource_server_url` the exact URL clients connect to. + * Leave it off when your authorization server uses its own audience identifiers (an Auth0 API identifier, an Entra application ID) and check `aud` in your verifier instead, returning `None` for a token that isn't for this server. + * If `aud` is a list, put the entry that equals `resource_server_url` in `resource`. !!! tip `examples/servers/simple-auth/` in the SDK repository has an `IntrospectionTokenVerifier` that calls diff --git a/tests/interaction/auth/test_bearer.py b/tests/interaction/auth/test_bearer.py index 40b5010c15..aa42c50dfb 100644 --- a/tests/interaction/auth/test_bearer.py +++ b/tests/interaction/auth/test_bearer.py @@ -31,18 +31,24 @@ _PAST = int(time.time()) - 3600 -def tok(name: str, *, scopes: list[str], expires_at: int, resource: str | None = RESOURCE) -> AccessToken: - return AccessToken(token=name, client_id="c", scopes=scopes, expires_at=expires_at, resource=resource) - - TOKENS = { - "tok-valid": tok("tok-valid", scopes=[REQUIRED_SCOPE], expires_at=_FUTURE), - "tok-expired": tok("tok-expired", scopes=[REQUIRED_SCOPE], expires_at=_PAST), - "tok-noscope": tok("tok-noscope", scopes=["other:thing"], expires_at=_FUTURE), - "tok-wrong-aud": tok( - "tok-wrong-aud", scopes=[REQUIRED_SCOPE], expires_at=_FUTURE, resource="https://other.example/mcp" + "tok-valid": AccessToken( + token="tok-valid", client_id="c", scopes=[REQUIRED_SCOPE], expires_at=_FUTURE, resource=RESOURCE + ), + "tok-expired": AccessToken( + token="tok-expired", client_id="c", scopes=[REQUIRED_SCOPE], expires_at=_PAST, resource=RESOURCE + ), + "tok-noscope": AccessToken( + token="tok-noscope", client_id="c", scopes=["other:thing"], expires_at=_FUTURE, resource=RESOURCE + ), + "tok-wrong-aud": AccessToken( + token="tok-wrong-aud", + client_id="c", + scopes=[REQUIRED_SCOPE], + expires_at=_FUTURE, + resource="https://other.example/mcp", ), - "tok-no-aud": tok("tok-no-aud", scopes=[REQUIRED_SCOPE], expires_at=_FUTURE, resource=None), + "tok-no-aud": AccessToken(token="tok-no-aud", client_id="c", scopes=[REQUIRED_SCOPE], expires_at=_FUTURE), } From ee6cba57d9b7dddceadd947a0646f63aba92b221 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Sat, 5 Sep 2026 02:06:39 +0000 Subject: [PATCH 4/5] Warn when AuthSettings.validate_token_resource is left unset validate_token_resource becomes bool | None (default None). With a resource_server_url configured and no explicit choice, AuthSettings emits an MCPDeprecationWarning and behaves as False, so existing deployments keep working but are asked to decide; 3.0 makes True the default. An explicit False (the verifier checks the audience itself) is silent. The docs tutorials, the bearer_auth and oauth_client_credentials stories, and the oauth_server snippet now set it to True and issue tokens bound to their resource URL; docs/deprecated.md lists the new warning. --- docs/deprecated.md | 1 + docs/run/authorization.md | 8 ++--- docs_src/authorization/tutorial001.py | 7 +++-- docs_src/authorization/tutorial002.py | 7 +++-- examples/snippets/servers/oauth_server.py | 3 +- examples/stories/_shared/auth.py | 1 + examples/stories/bearer_auth/server.py | 2 ++ .../stories/bearer_auth/server_lowlevel.py | 1 + .../oauth_client_credentials/server.py | 9 +++++- .../server_lowlevel.py | 9 +++++- src/mcp/server/auth/settings.py | 23 +++++++++++--- tests/docs_src/test_identity_assertion.py | 1 + tests/server/auth/test_routes.py | 2 ++ tests/server/auth/test_settings.py | 31 ++++++++++++++----- 14 files changed, 83 insertions(+), 22 deletions(-) diff --git a/docs/deprecated.md b/docs/deprecated.md index 40de550f9d..b919075e9f 100644 --- a/docs/deprecated.md +++ b/docs/deprecated.md @@ -136,6 +136,7 @@ These are not spec changes, only SDK usage with a better replacement. They warn | Deprecated | What you do instead | |---|---| | `FuncMetadata.call_fn_with_arg_validation()` | `FuncMetadata.validate_arguments()` and then `FuncMetadata.call_fn()`. Only code that drives `FuncMetadata` directly (a custom `Tool` subclass, say) ever called it. | +| `AuthSettings(resource_server_url=...)` without `validate_token_resource=` | Set it: `True` has the server refuse bearer tokens your verifier does not report as issued for `resource_server_url`, `False` says your verifier checks the token's audience itself (see **[Authorization](run/authorization.md#a-token-verifier)**). Unset behaves as `False`; 3.0 makes `True` the default. | | `ClientCredentialsOAuthProvider(...)` or `PrivateKeyJWTOAuthProvider(...)` without `issuer=` | Pass `issuer=` naming the authorization server that issued the credentials (see **[Writing OAuth clients](client/oauth-clients.md#machine-to-machine)**). Without it the MCP server decides which authorization server receives them; 3.0 makes the keyword required. | ## Recap diff --git a/docs/run/authorization.md b/docs/run/authorization.md index 5e0f3800b6..17c348b6a7 100644 --- a/docs/run/authorization.md +++ b/docs/run/authorization.md @@ -18,12 +18,12 @@ That's the whole triangle. Everything on this page is the middle bullet. The SDK has no opinion about what a valid token looks like. You tell it, by implementing **`TokenVerifier`**: -```python title="server.py" hl_lines="12-14 19-24" +```python title="server.py" hl_lines="14-16 21-27" --8<-- "docs_src/authorization/tutorial001.py" ``` * `TokenVerifier` is a protocol with one async method. `verify_token` gets the raw token from the `Authorization` header and returns an **`AccessToken`** if it's valid, `None` if it isn't. There is nothing else to implement. -* This one looks the token up in a table. A real one verifies a JWT signature or calls the authorization server's token-introspection endpoint, and reports who the token was issued for (its `aud`) in `AccessToken.resource`. That code is yours; the SDK only calls it. +* This one looks the token up in a table; each entry records the resource it was issued for. A real one verifies a JWT signature or calls the authorization server's token-introspection endpoint, and reports who the token was issued for (its `aud`) in `AccessToken.resource`. That code is yours; the SDK only calls it. * `token_verifier=` and `auth=` always travel together. Pass one without the other and `MCPServer(...)` raises a `ValueError` before it ever serves a request. `AuthSettings` is the public face of your resource server: @@ -31,7 +31,7 @@ The SDK has no opinion about what a valid token looks like. You tell it, by impl * `issuer_url`: the authorization server that issues your tokens. * `resource_server_url`: the public URL of this MCP endpoint. It names *which* resource a token is for, and it's where the discovery document lives. * `required_scopes`: every token must carry all of them. -* `validate_token_resource`: refuse any token whose `AccessToken.resource` is not `resource_server_url`. Off by default. +* `validate_token_resource`: refuse any token whose `AccessToken.resource` is not `resource_server_url`. Leaving it unset warns (`MCPDeprecationWarning`) and behaves as `False`; 3.0 makes `True` the default. * Turn it on when your authorization server binds tokens to the `resource` the client requested, which MCP clients always send. Keep `resource_server_url` the exact URL clients connect to. * Leave it off when your authorization server uses its own audience identifiers (an Auth0 API identifier, an Entra application ID) and check `aud` in your verifier instead, returning `None` for a token that isn't for this server. * If `aud` is a list, put the entry that equals `resource_server_url` in `resource`. @@ -90,7 +90,7 @@ This document is how a client that has never heard of your server finds its way Inside any handler, **`get_access_token()`** is the `AccessToken` your verifier returned for the current request: -```python title="server.py" hl_lines="4 32-35" +```python title="server.py" hl_lines="4 35-38" --8<-- "docs_src/authorization/tutorial002.py" ``` diff --git a/docs_src/authorization/tutorial001.py b/docs_src/authorization/tutorial001.py index f15f54fd79..d1a9db874f 100644 --- a/docs_src/authorization/tutorial001.py +++ b/docs_src/authorization/tutorial001.py @@ -4,8 +4,10 @@ from mcp.server.auth.provider import AccessToken, TokenVerifier from mcp.server.auth.settings import AuthSettings +RESOURCE = "http://127.0.0.1:8000/mcp" + KNOWN_TOKENS = { - "alice-token": AccessToken(token="alice-token", client_id="alice", scopes=["notes:read"]), + "alice-token": AccessToken(token="alice-token", client_id="alice", scopes=["notes:read"], resource=RESOURCE), } @@ -19,8 +21,9 @@ async def verify_token(self, token: str) -> AccessToken | None: token_verifier=StaticTokenVerifier(), auth=AuthSettings( issuer_url=AnyHttpUrl("https://auth.example.com"), - resource_server_url=AnyHttpUrl("http://127.0.0.1:8000/mcp"), + resource_server_url=AnyHttpUrl(RESOURCE), required_scopes=["notes:read"], + validate_token_resource=True, ), ) diff --git a/docs_src/authorization/tutorial002.py b/docs_src/authorization/tutorial002.py index 55b024f2cc..c088442a6c 100644 --- a/docs_src/authorization/tutorial002.py +++ b/docs_src/authorization/tutorial002.py @@ -5,8 +5,10 @@ from mcp.server.auth.provider import AccessToken, TokenVerifier from mcp.server.auth.settings import AuthSettings +RESOURCE = "http://127.0.0.1:8000/mcp" + KNOWN_TOKENS = { - "alice-token": AccessToken(token="alice-token", client_id="alice", scopes=["notes:read"]), + "alice-token": AccessToken(token="alice-token", client_id="alice", scopes=["notes:read"], resource=RESOURCE), } @@ -20,8 +22,9 @@ async def verify_token(self, token: str) -> AccessToken | None: token_verifier=StaticTokenVerifier(), auth=AuthSettings( issuer_url=AnyHttpUrl("https://auth.example.com"), - resource_server_url=AnyHttpUrl("http://127.0.0.1:8000/mcp"), + resource_server_url=AnyHttpUrl(RESOURCE), required_scopes=["notes:read"], + validate_token_resource=True, ), ) diff --git a/examples/snippets/servers/oauth_server.py b/examples/snippets/servers/oauth_server.py index 962ef0615e..c55dd94a00 100644 --- a/examples/snippets/servers/oauth_server.py +++ b/examples/snippets/servers/oauth_server.py @@ -24,8 +24,9 @@ async def verify_token(self, token: str) -> AccessToken | None: # Auth settings for RFC 9728 Protected Resource Metadata auth=AuthSettings( issuer_url=AnyHttpUrl("https://auth.example.com"), # Authorization Server URL - resource_server_url=AnyHttpUrl("http://localhost:3001"), # This server's URL + resource_server_url=AnyHttpUrl("http://localhost:3001/mcp"), # This server's URL required_scopes=["user"], + validate_token_resource=True, ), ) diff --git a/examples/stories/_shared/auth.py b/examples/stories/_shared/auth.py index 35e997242f..cdbf3fd6f7 100644 --- a/examples/stories/_shared/auth.py +++ b/examples/stories/_shared/auth.py @@ -169,4 +169,5 @@ def auth_settings( required_scopes=scopes, client_registration_options=ClientRegistrationOptions(enabled=True, valid_scopes=scopes, default_scopes=scopes), identity_assertion_enabled=identity_assertion_enabled, + validate_token_resource=True, ) diff --git a/examples/stories/bearer_auth/server.py b/examples/stories/bearer_auth/server.py index 45c9872c3a..31a0f77bfa 100644 --- a/examples/stories/bearer_auth/server.py +++ b/examples/stories/bearer_auth/server.py @@ -28,6 +28,7 @@ async def verify_token(self, token: str) -> AccessToken | None: client_id="demo-client", scopes=[REQUIRED_SCOPE], expires_at=int(time.time()) + 3600, + resource=RESOURCE_URL, subject="demo-user", ) @@ -40,6 +41,7 @@ def build_app() -> Starlette: issuer_url=AnyHttpUrl(ISSUER), resource_server_url=AnyHttpUrl(RESOURCE_URL), required_scopes=[REQUIRED_SCOPE], + validate_token_resource=True, ), ) diff --git a/examples/stories/bearer_auth/server_lowlevel.py b/examples/stories/bearer_auth/server_lowlevel.py index e03cb26d03..dd15fa8865 100644 --- a/examples/stories/bearer_auth/server_lowlevel.py +++ b/examples/stories/bearer_auth/server_lowlevel.py @@ -46,6 +46,7 @@ async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolReques issuer_url=AnyHttpUrl(ISSUER), resource_server_url=AnyHttpUrl(RESOURCE_URL), required_scopes=[REQUIRED_SCOPE], + validate_token_resource=True, ), token_verifier=StaticTokenVerifier(), transport_security=NO_DNS_REBIND, diff --git a/examples/stories/oauth_client_credentials/server.py b/examples/stories/oauth_client_credentials/server.py index 7e3d910e8f..0ccc3e32f1 100644 --- a/examples/stories/oauth_client_credentials/server.py +++ b/examples/stories/oauth_client_credentials/server.py @@ -66,7 +66,14 @@ async def token_endpoint(request: Request) -> JSONResponse: if creds != f"{DEMO_CLIENT_ID}:{DEMO_CLIENT_SECRET}": return JSONResponse({"error": "invalid_client"}, status_code=401) access = f"access_{secrets.token_hex(16)}" - issued[access] = AccessToken(token=access, client_id=DEMO_CLIENT_ID, scopes=[DEMO_SCOPE], expires_at=None) + resource = form.get("resource") # RFC 8707: bind the token to the resource the client asked for + issued[access] = AccessToken( + token=access, + client_id=DEMO_CLIENT_ID, + scopes=[DEMO_SCOPE], + expires_at=None, + resource=resource if isinstance(resource, str) else None, + ) body = OAuthToken(access_token=access, token_type="Bearer", expires_in=3600, scope=DEMO_SCOPE) return JSONResponse(body.model_dump(exclude_none=True), headers={"cache-control": "no-store"}) diff --git a/examples/stories/oauth_client_credentials/server_lowlevel.py b/examples/stories/oauth_client_credentials/server_lowlevel.py index cde947e9ed..5879bb8208 100644 --- a/examples/stories/oauth_client_credentials/server_lowlevel.py +++ b/examples/stories/oauth_client_credentials/server_lowlevel.py @@ -63,7 +63,14 @@ async def token_endpoint(request: Request) -> JSONResponse: if creds != f"{DEMO_CLIENT_ID}:{DEMO_CLIENT_SECRET}": return JSONResponse({"error": "invalid_client"}, status_code=401) access = f"access_{secrets.token_hex(16)}" - issued[access] = AccessToken(token=access, client_id=DEMO_CLIENT_ID, scopes=[DEMO_SCOPE], expires_at=None) + resource = form.get("resource") # RFC 8707: bind the token to the resource the client asked for + issued[access] = AccessToken( + token=access, + client_id=DEMO_CLIENT_ID, + scopes=[DEMO_SCOPE], + expires_at=None, + resource=resource if isinstance(resource, str) else None, + ) body = OAuthToken(access_token=access, token_type="Bearer", expires_in=3600, scope=DEMO_SCOPE) return JSONResponse(body.model_dump(exclude_none=True), headers={"cache-control": "no-store"}) diff --git a/src/mcp/server/auth/settings.py b/src/mcp/server/auth/settings.py index 52e5c9780e..52ac23903e 100644 --- a/src/mcp/server/auth/settings.py +++ b/src/mcp/server/auth/settings.py @@ -1,6 +1,10 @@ +import warnings + from pydantic import AnyHttpUrl, BaseModel, ConfigDict, Field, model_validator from typing_extensions import Self +from mcp.shared.exceptions import MCPDeprecationWarning + class ClientRegistrationOptions(BaseModel): enabled: bool = False @@ -41,15 +45,26 @@ class AuthSettings(BaseModel): description="The URL of the MCP server to be used as the resource identifier " "and base route to look up OAuth Protected Resource Metadata.", ) - validate_token_resource: bool = Field( - default=False, + validate_token_resource: bool | None = Field( + default=None, description="Only accept tokens the token verifier reports as issued for `resource_server_url` " "(`AccessToken.resource`, the RFC 8707 resource indicator). Enable it when your authorization " - "server binds tokens to the `resource` the client requested.", + "server binds tokens to the `resource` the client requested; set it to False when your token " + "verifier checks the token's audience itself. Leaving it unset warns and behaves as False; it " + "defaults to True in 3.0.", ) @model_validator(mode="after") - def _validate_token_resource_needs_a_resource(self) -> Self: + def _check_validate_token_resource(self) -> Self: if self.validate_token_resource and self.resource_server_url is None: raise ValueError("validate_token_resource requires resource_server_url") + if self.validate_token_resource is None and self.resource_server_url is not None: + warnings.warn( + "`AuthSettings.validate_token_resource` is not set, so bearer tokens are not checked " + "against `resource_server_url`; it will default to True in 3.0. Set it to True to have " + "the server refuse tokens issued for another resource, or to False if your TokenVerifier " + "validates the token's audience itself.", + MCPDeprecationWarning, + stacklevel=3, + ) return self diff --git a/tests/docs_src/test_identity_assertion.py b/tests/docs_src/test_identity_assertion.py index 1d69c5ebde..3a15ef94ec 100644 --- a/tests/docs_src/test_identity_assertion.py +++ b/tests/docs_src/test_identity_assertion.py @@ -151,6 +151,7 @@ async def test_the_whole_grant_is_one_token_request() -> None: issuer_url=AnyHttpUrl(tutorial002.ISSUER), resource_server_url=AnyHttpUrl(MCP_SERVER_URL), required_scopes=["notes:read"], + validate_token_resource=True, ), ) diff --git a/tests/server/auth/test_routes.py b/tests/server/auth/test_routes.py index 58685c64c7..64c2c02460 100644 --- a/tests/server/auth/test_routes.py +++ b/tests/server/auth/test_routes.py @@ -53,6 +53,7 @@ def test_auth_settings_preserves_path_less_issuer(): settings = AuthSettings( issuer_url="https://as.example.com", # type: ignore[arg-type] resource_server_url="https://rs.example.com", # type: ignore[arg-type] + validate_token_resource=True, ) assert str(settings.issuer_url) == "https://as.example.com" assert str(settings.resource_server_url) == "https://rs.example.com" @@ -63,6 +64,7 @@ def test_build_metadata_serves_issuer_without_trailing_slash(): settings = AuthSettings( issuer_url="https://as.example.com", # type: ignore[arg-type] resource_server_url="https://rs.example.com", # type: ignore[arg-type] + validate_token_resource=True, ) metadata = build_metadata(settings.issuer_url, None, ClientRegistrationOptions(), RevocationOptions()) diff --git a/tests/server/auth/test_settings.py b/tests/server/auth/test_settings.py index 84967ebb72..db7dd26f7d 100644 --- a/tests/server/auth/test_settings.py +++ b/tests/server/auth/test_settings.py @@ -1,17 +1,34 @@ +import warnings + import pytest from pydantic import AnyHttpUrl, ValidationError from mcp.server.auth.settings import AuthSettings +from mcp.shared.exceptions import MCPDeprecationWarning + +ISSUER = AnyHttpUrl("https://auth.example.com") +RESOURCE = AnyHttpUrl("https://mcp.example.com/mcp") def test_validate_token_resource_requires_a_resource_server_url(): """SDK-defined: asking the bearer gate to compare tokens against `resource_server_url` without configuring one is refused at construction time rather than silently comparing nothing.""" - issuer_url = AnyHttpUrl("https://auth.example.com") - AuthSettings( - issuer_url=issuer_url, - resource_server_url=AnyHttpUrl("https://mcp.example.com/mcp"), - validate_token_resource=True, - ) + AuthSettings(issuer_url=ISSUER, resource_server_url=RESOURCE, validate_token_resource=True) with pytest.raises(ValidationError, match="validate_token_resource requires resource_server_url"): - AuthSettings(issuer_url=issuer_url, resource_server_url=None, validate_token_resource=True) + AuthSettings(issuer_url=ISSUER, resource_server_url=None, validate_token_resource=True) + + +def test_leaving_validate_token_resource_unset_warns_when_a_resource_server_url_is_configured(): + """Unset behaves as False but says so: a resource server that has not chosen gets an + `MCPDeprecationWarning` pointing at its own `AuthSettings(...)` call (3.0 flips the default).""" + with pytest.warns(MCPDeprecationWarning, match="validate_token_resource") as record: + settings = AuthSettings(issuer_url=ISSUER, resource_server_url=RESOURCE) + assert settings.validate_token_resource is None + assert record[0].filename == __file__ + + +@pytest.mark.parametrize("kwargs", [{"validate_token_resource": False}, {"resource_server_url": None}]) +def test_an_explicit_choice_or_no_resource_server_url_does_not_warn(kwargs: dict[str, object]): + with warnings.catch_warnings(): + warnings.simplefilter("error") + AuthSettings.model_validate({"issuer_url": ISSUER, "resource_server_url": RESOURCE, **kwargs}) From 5e8ba84e6df1c45e04f57b11981d9a25759195d2 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Sat, 5 Sep 2026 02:20:44 +0000 Subject: [PATCH 5/5] Scope the validate_token_resource default note to resource servers; snippet URL The 3.0 default applies when resource_server_url is set (the field description, warning text, docs and deprecated.md now say so). The oauth_server snippet's resource_server_url matches the address mcp.run() serves on. --- docs/deprecated.md | 2 +- docs/run/authorization.md | 2 +- examples/snippets/servers/oauth_server.py | 2 +- src/mcp/server/auth/settings.py | 10 +++++----- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/deprecated.md b/docs/deprecated.md index b919075e9f..f48f07ac11 100644 --- a/docs/deprecated.md +++ b/docs/deprecated.md @@ -136,7 +136,7 @@ These are not spec changes, only SDK usage with a better replacement. They warn | Deprecated | What you do instead | |---|---| | `FuncMetadata.call_fn_with_arg_validation()` | `FuncMetadata.validate_arguments()` and then `FuncMetadata.call_fn()`. Only code that drives `FuncMetadata` directly (a custom `Tool` subclass, say) ever called it. | -| `AuthSettings(resource_server_url=...)` without `validate_token_resource=` | Set it: `True` has the server refuse bearer tokens your verifier does not report as issued for `resource_server_url`, `False` says your verifier checks the token's audience itself (see **[Authorization](run/authorization.md#a-token-verifier)**). Unset behaves as `False`; 3.0 makes `True` the default. | +| `AuthSettings(resource_server_url=...)` without `validate_token_resource=` | Set it: `True` has the server refuse bearer tokens your verifier does not report as issued for `resource_server_url`, `False` says your verifier checks the token's audience itself (see **[Authorization](run/authorization.md#a-token-verifier)**). Unset behaves as `False`; 3.0 makes `True` the default whenever `resource_server_url` is set. | | `ClientCredentialsOAuthProvider(...)` or `PrivateKeyJWTOAuthProvider(...)` without `issuer=` | Pass `issuer=` naming the authorization server that issued the credentials (see **[Writing OAuth clients](client/oauth-clients.md#machine-to-machine)**). Without it the MCP server decides which authorization server receives them; 3.0 makes the keyword required. | ## Recap diff --git a/docs/run/authorization.md b/docs/run/authorization.md index 17c348b6a7..dfcbbc9a20 100644 --- a/docs/run/authorization.md +++ b/docs/run/authorization.md @@ -31,7 +31,7 @@ The SDK has no opinion about what a valid token looks like. You tell it, by impl * `issuer_url`: the authorization server that issues your tokens. * `resource_server_url`: the public URL of this MCP endpoint. It names *which* resource a token is for, and it's where the discovery document lives. * `required_scopes`: every token must carry all of them. -* `validate_token_resource`: refuse any token whose `AccessToken.resource` is not `resource_server_url`. Leaving it unset warns (`MCPDeprecationWarning`) and behaves as `False`; 3.0 makes `True` the default. +* `validate_token_resource`: refuse any token whose `AccessToken.resource` is not `resource_server_url`. Leaving it unset while `resource_server_url` is set warns (`MCPDeprecationWarning`) and behaves as `False`; 3.0 makes `True` the default for resource servers. * Turn it on when your authorization server binds tokens to the `resource` the client requested, which MCP clients always send. Keep `resource_server_url` the exact URL clients connect to. * Leave it off when your authorization server uses its own audience identifiers (an Auth0 API identifier, an Entra application ID) and check `aud` in your verifier instead, returning `None` for a token that isn't for this server. * If `aud` is a list, put the entry that equals `resource_server_url` in `resource`. diff --git a/examples/snippets/servers/oauth_server.py b/examples/snippets/servers/oauth_server.py index c55dd94a00..204ede4b26 100644 --- a/examples/snippets/servers/oauth_server.py +++ b/examples/snippets/servers/oauth_server.py @@ -24,7 +24,7 @@ async def verify_token(self, token: str) -> AccessToken | None: # Auth settings for RFC 9728 Protected Resource Metadata auth=AuthSettings( issuer_url=AnyHttpUrl("https://auth.example.com"), # Authorization Server URL - resource_server_url=AnyHttpUrl("http://localhost:3001/mcp"), # This server's URL + resource_server_url=AnyHttpUrl("http://127.0.0.1:8000/mcp"), # This server's URL (mcp.run() default) required_scopes=["user"], validate_token_resource=True, ), diff --git a/src/mcp/server/auth/settings.py b/src/mcp/server/auth/settings.py index 52ac23903e..4d0f7f1342 100644 --- a/src/mcp/server/auth/settings.py +++ b/src/mcp/server/auth/settings.py @@ -50,8 +50,8 @@ class AuthSettings(BaseModel): description="Only accept tokens the token verifier reports as issued for `resource_server_url` " "(`AccessToken.resource`, the RFC 8707 resource indicator). Enable it when your authorization " "server binds tokens to the `resource` the client requested; set it to False when your token " - "verifier checks the token's audience itself. Leaving it unset warns and behaves as False; it " - "defaults to True in 3.0.", + "verifier checks the token's audience itself. With `resource_server_url` set, leaving it unset warns " + "and behaves as False; 3.0 makes True the default there.", ) @model_validator(mode="after") @@ -61,9 +61,9 @@ def _check_validate_token_resource(self) -> Self: if self.validate_token_resource is None and self.resource_server_url is not None: warnings.warn( "`AuthSettings.validate_token_resource` is not set, so bearer tokens are not checked " - "against `resource_server_url`; it will default to True in 3.0. Set it to True to have " - "the server refuse tokens issued for another resource, or to False if your TokenVerifier " - "validates the token's audience itself.", + "against `resource_server_url`; it will default to True in 3.0 when `resource_server_url` is " + "set. Set it to True to have the server refuse tokens issued for another resource, or to " + "False if your TokenVerifier validates the token's audience itself.", MCPDeprecationWarning, stacklevel=3, )