From c4fe4d254aeb7d1a502e9a805a900869ab73219a Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Fri, 22 May 2026 23:36:50 +0000 Subject: [PATCH 1/4] Add subject and claims to AccessToken Adds optional `subject` and `claims` fields to `AccessToken` so token verifiers can surface the resource owner (`sub`) and any additional claims to request handlers. `subject` is also added to `AuthorizationCode` and `RefreshToken` so the value can be carried through code-for-token exchange and token refresh. `Context.subject` exposes the value to tool and resource handlers. The simple-auth example is updated to thread the subject from login through introspection. Closes #1038. Reported-by: Thomas Steinacher <@thomasst> Reported-by: Yukuan Jia <@yukuanj> Reported-by: Shivam Aggarwal <@shivama205> --- .../mcp_simple_auth/auth_server.py | 1 + .../mcp_simple_auth/simple_auth_provider.py | 2 + .../mcp_simple_auth/token_verifier.py | 2 + src/mcp/server/auth/provider.py | 12 +++++- src/mcp/server/mcpserver/context.py | 12 ++++++ tests/server/auth/test_provider.py | 42 ++++++++++++++++++- .../mcpserver/auth/test_context_subject.py | 42 +++++++++++++++++++ 7 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 tests/server/mcpserver/auth/test_context_subject.py diff --git a/examples/servers/simple-auth/mcp_simple_auth/auth_server.py b/examples/servers/simple-auth/mcp_simple_auth/auth_server.py index 9d13fffe42..b06e4aa923 100644 --- a/examples/servers/simple-auth/mcp_simple_auth/auth_server.py +++ b/examples/servers/simple-auth/mcp_simple_auth/auth_server.py @@ -120,6 +120,7 @@ async def introspect_handler(request: Request) -> Response: "iat": int(time.time()), "token_type": "Bearer", "aud": access_token.resource, # RFC 8707 audience claim + "sub": access_token.subject, # RFC 7662 subject } ) diff --git a/examples/servers/simple-auth/mcp_simple_auth/simple_auth_provider.py b/examples/servers/simple-auth/mcp_simple_auth/simple_auth_provider.py index 3a3895cc57..48eb9a8414 100644 --- a/examples/servers/simple-auth/mcp_simple_auth/simple_auth_provider.py +++ b/examples/servers/simple-auth/mcp_simple_auth/simple_auth_provider.py @@ -181,6 +181,7 @@ async def handle_simple_callback(self, username: str, password: str, state: str) scopes=[self.settings.mcp_scope], code_challenge=code_challenge, resource=resource, # RFC 8707 + subject=username, ) self.auth_codes[new_code] = auth_code @@ -219,6 +220,7 @@ async def exchange_authorization_code( scopes=authorization_code.scopes, expires_at=int(time.time()) + 3600, resource=authorization_code.resource, # RFC 8707 + subject=authorization_code.subject, ) # Store user data mapping for this token 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 5228d034e4..641095a125 100644 --- a/examples/servers/simple-auth/mcp_simple_auth/token_verifier.py +++ b/examples/servers/simple-auth/mcp_simple_auth/token_verifier.py @@ -75,6 +75,8 @@ async def verify_token(self, token: str) -> AccessToken | None: scopes=data.get("scope", "").split() if data.get("scope") else [], expires_at=data.get("exp"), resource=data.get("aud"), # Include resource in token + subject=data.get("sub"), # RFC 7662 subject (resource owner) + claims=data, ) except Exception as e: logger.warning(f"Token introspection failed: {e}") diff --git a/src/mcp/server/auth/provider.py b/src/mcp/server/auth/provider.py index 957082a854..b7fef1d909 100644 --- a/src/mcp/server/auth/provider.py +++ b/src/mcp/server/auth/provider.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Generic, Literal, Protocol, TypeVar +from typing import Any, Generic, Literal, Protocol, TypeVar from urllib.parse import parse_qs, urlencode, urlparse, urlunparse from pydantic import AnyUrl, BaseModel @@ -25,6 +25,7 @@ class AuthorizationCode(BaseModel): redirect_uri: AnyUrl redirect_uri_provided_explicitly: bool resource: str | None = None # RFC 8707 resource indicator + subject: str | None = None # resource owner; propagate to the issued AccessToken class RefreshToken(BaseModel): @@ -32,6 +33,7 @@ class RefreshToken(BaseModel): client_id: str scopes: list[str] expires_at: int | None = None + subject: str | None = None # resource owner; propagate to refreshed AccessTokens class AccessToken(BaseModel): @@ -40,6 +42,14 @@ class AccessToken(BaseModel): scopes: list[str] expires_at: int | None = None resource: str | None = None # RFC 8707 resource indicator + subject: str | None = None + """The resource owner this token was issued on behalf of — typically the + `sub` from a JWT (RFC 9068) or introspection response (RFC 7662). Token + verifiers should populate this whenever an end-user is involved so request + handlers and transports can distinguish users that share an OAuth client. + Conventionally unset for `client_credentials` tokens.""" + claims: dict[str, Any] | None = None + """Additional verified claims (e.g. `iss`, `act`) for request handlers.""" RegistrationErrorCode = Literal[ diff --git a/src/mcp/server/mcpserver/context.py b/src/mcp/server/mcpserver/context.py index e87388eee9..1036bbf7da 100644 --- a/src/mcp/server/mcpserver/context.py +++ b/src/mcp/server/mcpserver/context.py @@ -5,6 +5,7 @@ from pydantic import AnyUrl, BaseModel +from mcp.server.auth.middleware.auth_context import get_access_token from mcp.server.context import LifespanContextT, RequestT, ServerRequestContext from mcp.server.elicitation import ( ElicitationResult, @@ -213,6 +214,17 @@ def client_id(self) -> str | None: """Get the client ID if available.""" return self.request_context.meta.get("client_id") if self.request_context.meta else None # pragma: no cover + @property + def subject(self) -> str | None: + """The authenticated resource owner (`sub`) for this request, if any. + + Returns `AccessToken.subject` from the bearer token that authenticated + the current request, or `None` when the request is unauthenticated or + the token verifier did not populate a subject. + """ + token = get_access_token() + return token.subject if token is not None else None + @property def request_id(self) -> str: """Get the unique ID for this request.""" diff --git a/tests/server/auth/test_provider.py b/tests/server/auth/test_provider.py index aaaeb413a4..b67a68231e 100644 --- a/tests/server/auth/test_provider.py +++ b/tests/server/auth/test_provider.py @@ -1,6 +1,46 @@ """Tests for mcp.server.auth.provider module.""" -from mcp.server.auth.provider import construct_redirect_uri +from pydantic import AnyUrl + +from mcp.server.auth.provider import AccessToken, AuthorizationCode, RefreshToken, construct_redirect_uri + + +def test_access_token_subject_and_claims_default_to_none(): + token = AccessToken(token="t", client_id="c", scopes=["read"]) + assert token.subject is None + assert token.claims is None + + +def test_access_token_carries_subject_and_claims(): + token = AccessToken( + token="t", + client_id="c", + scopes=["read"], + subject="user-123", + claims={"iss": "https://auth.example.com", "act": {"sub": "gateway"}}, + ) + assert token.subject == "user-123" + assert token.claims is not None + assert token.claims["iss"] == "https://auth.example.com" + + +def test_authorization_code_carries_subject(): + code = AuthorizationCode( + code="x", + scopes=["read"], + expires_at=0.0, + client_id="c", + code_challenge="cc", + redirect_uri=AnyUrl("https://example.com/cb"), + redirect_uri_provided_explicitly=True, + subject="user-123", + ) + assert code.subject == "user-123" + + +def test_refresh_token_carries_subject(): + refresh = RefreshToken(token="r", client_id="c", scopes=["read"], subject="user-123") + assert refresh.subject == "user-123" def test_construct_redirect_uri_no_existing_params(): diff --git a/tests/server/mcpserver/auth/test_context_subject.py b/tests/server/mcpserver/auth/test_context_subject.py new file mode 100644 index 0000000000..86595edaa7 --- /dev/null +++ b/tests/server/mcpserver/auth/test_context_subject.py @@ -0,0 +1,42 @@ +"""Context.subject reads the resource owner from the request's access token.""" + +from mcp.server.auth.middleware.auth_context import auth_context_var +from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser +from mcp.server.auth.provider import AccessToken +from mcp.server.mcpserver import Context + + +def test_subject_is_none_when_unauthenticated(): + assert Context().subject is None + + +def test_subject_is_none_when_token_has_no_subject(): + user = AuthenticatedUser(AccessToken(token="t", client_id="c", scopes=[])) + cv_token = auth_context_var.set(user) + try: + assert Context().subject is None + finally: + auth_context_var.reset(cv_token) + + +def test_subject_reads_from_access_token(): + user = AuthenticatedUser(AccessToken(token="t", client_id="c", scopes=[], subject="user-123")) + cv_token = auth_context_var.set(user) + try: + assert Context().subject == "user-123" + finally: + auth_context_var.reset(cv_token) + + +def test_subject_tracks_current_auth_context(): + ctx = Context() + assert ctx.subject is None + + alice = AuthenticatedUser(AccessToken(token="a", client_id="c", scopes=[], subject="alice")) + cv_token = auth_context_var.set(alice) + try: + assert ctx.subject == "alice" + finally: + auth_context_var.reset(cv_token) + + assert ctx.subject is None From 7e542e9c64a4bd03e99e2ac19c6f02159900ab3c Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Tue, 26 May 2026 10:00:40 +0000 Subject: [PATCH 2/4] Clarify subject docstrings and add Context.claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reword AccessToken.subject's client_credentials note to match RFC 9068 §2.2 (sub may identify the client itself rather than being unset). - Add Context.claims so handlers can read iss/act/etc without importing get_access_token and handling the raw token model. - Document that Context.client_id reads MCP request _meta, not the OAuth client_id, so it isn't mistaken for the pair to Context.subject. --- src/mcp/server/auth/provider.py | 4 ++- src/mcp/server/mcpserver/context.py | 22 +++++++++++++++- .../mcpserver/auth/test_context_subject.py | 26 ++++++++++++++++++- 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/src/mcp/server/auth/provider.py b/src/mcp/server/auth/provider.py index b7fef1d909..163e162c38 100644 --- a/src/mcp/server/auth/provider.py +++ b/src/mcp/server/auth/provider.py @@ -47,7 +47,9 @@ class AccessToken(BaseModel): `sub` from a JWT (RFC 9068) or introspection response (RFC 7662). Token verifiers should populate this whenever an end-user is involved so request handlers and transports can distinguish users that share an OAuth client. - Conventionally unset for `client_credentials` tokens.""" + For `client_credentials` grants there is no end-user; `sub` may then + identify the client itself (RFC 9068 §2.2) or be absent, depending on the + authorization server.""" claims: dict[str, Any] | None = None """Additional verified claims (e.g. `iss`, `act`) for request handlers.""" diff --git a/src/mcp/server/mcpserver/context.py b/src/mcp/server/mcpserver/context.py index 1036bbf7da..3f735d4ff8 100644 --- a/src/mcp/server/mcpserver/context.py +++ b/src/mcp/server/mcpserver/context.py @@ -211,7 +211,15 @@ async def log( @property def client_id(self) -> str | None: - """Get the client ID if available.""" + """Get the client ID if available. + + Note: this reads from the MCP request's `_meta` params, not from the + OAuth bearer token. It is unrelated to `subject` and `claims` below, + which come from the authenticated `AccessToken`. For the OAuth + `client_id`, use `get_access_token().client_id`. + + TODO(maxisbey): see if this is needed otherwise remove + """ return self.request_context.meta.get("client_id") if self.request_context.meta else None # pragma: no cover @property @@ -225,6 +233,18 @@ def subject(self) -> str | None: token = get_access_token() return token.subject if token is not None else None + @property + def claims(self) -> dict[str, Any] | None: + """Additional verified claims from the bearer token, if any. + + Returns `AccessToken.claims` for the current request so handlers can + read values like `iss` or `act` without importing `get_access_token` + and handling the raw token directly. `None` when unauthenticated or + when the token verifier did not populate claims. + """ + token = get_access_token() + return token.claims if token is not None else None + @property def request_id(self) -> str: """Get the unique ID for this request.""" diff --git a/tests/server/mcpserver/auth/test_context_subject.py b/tests/server/mcpserver/auth/test_context_subject.py index 86595edaa7..2dc0fc73d7 100644 --- a/tests/server/mcpserver/auth/test_context_subject.py +++ b/tests/server/mcpserver/auth/test_context_subject.py @@ -1,4 +1,4 @@ -"""Context.subject reads the resource owner from the request's access token.""" +"""Context.subject and Context.claims read from the request's access token.""" from mcp.server.auth.middleware.auth_context import auth_context_var from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser @@ -40,3 +40,27 @@ def test_subject_tracks_current_auth_context(): auth_context_var.reset(cv_token) assert ctx.subject is None + + +def test_claims_is_none_when_unauthenticated(): + assert Context().claims is None + + +def test_claims_is_none_when_token_has_no_claims(): + user = AuthenticatedUser(AccessToken(token="t", client_id="c", scopes=[])) + cv_token = auth_context_var.set(user) + try: + assert Context().claims is None + finally: + auth_context_var.reset(cv_token) + + +def test_claims_reads_from_access_token(): + user = AuthenticatedUser( + AccessToken(token="t", client_id="c", scopes=[], claims={"iss": "https://auth.example.com"}) + ) + cv_token = auth_context_var.set(user) + try: + assert Context().claims == {"iss": "https://auth.example.com"} + finally: + auth_context_var.reset(cv_token) From 4185d1c3b8fadc1895679463d4693fce24025565 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Tue, 26 May 2026 10:28:11 +0000 Subject: [PATCH 3/4] Scope down to model fields and add subject to auth integration test - Drop Context.subject/Context.claims; handlers use get_access_token() directly for now. A designed Context auth surface can follow separately. - Collapse AccessToken.subject/claims docstrings to one-line comments to match surrounding fields. - Move the client_id TODO to a # comment and tighten its docstring note. - Replace the trivial model and contextvar unit tests with subject propagation through MockOAuthProvider in test_auth_integration.py, so test_authorization_get now asserts subject survives the full /authorize -> /token -> refresh flow over HTTP. --- src/mcp/server/auth/provider.py | 12 +--- src/mcp/server/mcpserver/context.py | 33 +--------- tests/server/auth/test_provider.py | 42 +----------- .../mcpserver/auth/test_auth_integration.py | 10 +++ .../mcpserver/auth/test_context_subject.py | 66 ------------------- 5 files changed, 16 insertions(+), 147 deletions(-) delete mode 100644 tests/server/mcpserver/auth/test_context_subject.py diff --git a/src/mcp/server/auth/provider.py b/src/mcp/server/auth/provider.py index 163e162c38..d06cea158f 100644 --- a/src/mcp/server/auth/provider.py +++ b/src/mcp/server/auth/provider.py @@ -42,16 +42,8 @@ class AccessToken(BaseModel): scopes: list[str] expires_at: int | None = None resource: str | None = None # RFC 8707 resource indicator - subject: str | None = None - """The resource owner this token was issued on behalf of — typically the - `sub` from a JWT (RFC 9068) or introspection response (RFC 7662). Token - verifiers should populate this whenever an end-user is involved so request - handlers and transports can distinguish users that share an OAuth client. - For `client_credentials` grants there is no end-user; `sub` may then - identify the client itself (RFC 9068 §2.2) or be absent, depending on the - authorization server.""" - claims: dict[str, Any] | None = None - """Additional verified claims (e.g. `iss`, `act`) for request handlers.""" + subject: str | None = None # RFC 7662/9068 `sub`: resource owner the token was issued for + claims: dict[str, Any] | None = None # additional verified claims (e.g. `iss`, `act`) RegistrationErrorCode = Literal[ diff --git a/src/mcp/server/mcpserver/context.py b/src/mcp/server/mcpserver/context.py index 3f735d4ff8..39bba839bd 100644 --- a/src/mcp/server/mcpserver/context.py +++ b/src/mcp/server/mcpserver/context.py @@ -5,7 +5,6 @@ from pydantic import AnyUrl, BaseModel -from mcp.server.auth.middleware.auth_context import get_access_token from mcp.server.context import LifespanContextT, RequestT, ServerRequestContext from mcp.server.elicitation import ( ElicitationResult, @@ -209,42 +208,16 @@ async def log( related_request_id=self.request_id, ) + # TODO(maxisbey): see if this is needed otherwise remove @property def client_id(self) -> str | None: """Get the client ID if available. - Note: this reads from the MCP request's `_meta` params, not from the - OAuth bearer token. It is unrelated to `subject` and `claims` below, - which come from the authenticated `AccessToken`. For the OAuth - `client_id`, use `get_access_token().client_id`. - - TODO(maxisbey): see if this is needed otherwise remove + Note: this reads from the MCP request's `_meta` params, not the OAuth + bearer token. For that, use `get_access_token().client_id`. """ return self.request_context.meta.get("client_id") if self.request_context.meta else None # pragma: no cover - @property - def subject(self) -> str | None: - """The authenticated resource owner (`sub`) for this request, if any. - - Returns `AccessToken.subject` from the bearer token that authenticated - the current request, or `None` when the request is unauthenticated or - the token verifier did not populate a subject. - """ - token = get_access_token() - return token.subject if token is not None else None - - @property - def claims(self) -> dict[str, Any] | None: - """Additional verified claims from the bearer token, if any. - - Returns `AccessToken.claims` for the current request so handlers can - read values like `iss` or `act` without importing `get_access_token` - and handling the raw token directly. `None` when unauthenticated or - when the token verifier did not populate claims. - """ - token = get_access_token() - return token.claims if token is not None else None - @property def request_id(self) -> str: """Get the unique ID for this request.""" diff --git a/tests/server/auth/test_provider.py b/tests/server/auth/test_provider.py index b67a68231e..aaaeb413a4 100644 --- a/tests/server/auth/test_provider.py +++ b/tests/server/auth/test_provider.py @@ -1,46 +1,6 @@ """Tests for mcp.server.auth.provider module.""" -from pydantic import AnyUrl - -from mcp.server.auth.provider import AccessToken, AuthorizationCode, RefreshToken, construct_redirect_uri - - -def test_access_token_subject_and_claims_default_to_none(): - token = AccessToken(token="t", client_id="c", scopes=["read"]) - assert token.subject is None - assert token.claims is None - - -def test_access_token_carries_subject_and_claims(): - token = AccessToken( - token="t", - client_id="c", - scopes=["read"], - subject="user-123", - claims={"iss": "https://auth.example.com", "act": {"sub": "gateway"}}, - ) - assert token.subject == "user-123" - assert token.claims is not None - assert token.claims["iss"] == "https://auth.example.com" - - -def test_authorization_code_carries_subject(): - code = AuthorizationCode( - code="x", - scopes=["read"], - expires_at=0.0, - client_id="c", - code_challenge="cc", - redirect_uri=AnyUrl("https://example.com/cb"), - redirect_uri_provided_explicitly=True, - subject="user-123", - ) - assert code.subject == "user-123" - - -def test_refresh_token_carries_subject(): - refresh = RefreshToken(token="r", client_id="c", scopes=["read"], subject="user-123") - assert refresh.subject == "user-123" +from mcp.server.auth.provider import construct_redirect_uri def test_construct_redirect_uri_no_existing_params(): diff --git a/tests/server/mcpserver/auth/test_auth_integration.py b/tests/server/mcpserver/auth/test_auth_integration.py index 602f5cc752..35fec1c57e 100644 --- a/tests/server/mcpserver/auth/test_auth_integration.py +++ b/tests/server/mcpserver/auth/test_auth_integration.py @@ -53,6 +53,7 @@ async def authorize(self, client: OAuthClientInformationFull, params: Authorizat redirect_uri_provided_explicitly=params.redirect_uri_provided_explicitly, expires_at=time.time() + 300, scopes=params.scopes or ["read", "write"], + subject="test-user", ) self.auth_codes[code.code] = code @@ -79,6 +80,7 @@ async def exchange_authorization_code( client_id=client.client_id, scopes=authorization_code.scopes, expires_at=int(time.time()) + 3600, + subject=authorization_code.subject, ) self.refresh_tokens[refresh_token] = access_token @@ -108,6 +110,7 @@ async def load_refresh_token(self, client: OAuthClientInformationFull, refresh_t client_id=token_info.client_id, scopes=token_info.scopes, expires_at=token_info.expires_at, + subject=token_info.subject, ) return refresh_obj @@ -141,6 +144,7 @@ async def exchange_refresh_token( client_id=client.client_id, scopes=scopes or token_info.scopes, expires_at=int(time.time()) + 3600, + subject=refresh_token.subject, ) self.refresh_tokens[new_refresh_token] = new_access_token @@ -169,6 +173,7 @@ async def load_access_token(self, token: str) -> AccessToken | None: client_id=token_info.client_id, scopes=token_info.scopes, expires_at=token_info.expires_at, + subject=token_info.subject, ) async def revoke_token(self, token: AccessToken | RefreshToken) -> None: @@ -832,6 +837,7 @@ async def test_authorization_get( assert auth_info.client_id == client_info["client_id"] assert "read" in auth_info.scopes assert "write" in auth_info.scopes + assert auth_info.subject == "test-user" # 6. Refresh the token response = await test_client.post( @@ -852,6 +858,10 @@ async def test_authorization_get( assert new_token_response["access_token"] != access_token assert new_token_response["refresh_token"] != refresh_token + refreshed_auth_info = await mock_oauth_provider.load_access_token(new_token_response["access_token"]) + assert refreshed_auth_info + assert refreshed_auth_info.subject == "test-user" + # 7. Revoke the token response = await test_client.post( "/revoke", diff --git a/tests/server/mcpserver/auth/test_context_subject.py b/tests/server/mcpserver/auth/test_context_subject.py deleted file mode 100644 index 2dc0fc73d7..0000000000 --- a/tests/server/mcpserver/auth/test_context_subject.py +++ /dev/null @@ -1,66 +0,0 @@ -"""Context.subject and Context.claims read from the request's access token.""" - -from mcp.server.auth.middleware.auth_context import auth_context_var -from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser -from mcp.server.auth.provider import AccessToken -from mcp.server.mcpserver import Context - - -def test_subject_is_none_when_unauthenticated(): - assert Context().subject is None - - -def test_subject_is_none_when_token_has_no_subject(): - user = AuthenticatedUser(AccessToken(token="t", client_id="c", scopes=[])) - cv_token = auth_context_var.set(user) - try: - assert Context().subject is None - finally: - auth_context_var.reset(cv_token) - - -def test_subject_reads_from_access_token(): - user = AuthenticatedUser(AccessToken(token="t", client_id="c", scopes=[], subject="user-123")) - cv_token = auth_context_var.set(user) - try: - assert Context().subject == "user-123" - finally: - auth_context_var.reset(cv_token) - - -def test_subject_tracks_current_auth_context(): - ctx = Context() - assert ctx.subject is None - - alice = AuthenticatedUser(AccessToken(token="a", client_id="c", scopes=[], subject="alice")) - cv_token = auth_context_var.set(alice) - try: - assert ctx.subject == "alice" - finally: - auth_context_var.reset(cv_token) - - assert ctx.subject is None - - -def test_claims_is_none_when_unauthenticated(): - assert Context().claims is None - - -def test_claims_is_none_when_token_has_no_claims(): - user = AuthenticatedUser(AccessToken(token="t", client_id="c", scopes=[])) - cv_token = auth_context_var.set(user) - try: - assert Context().claims is None - finally: - auth_context_var.reset(cv_token) - - -def test_claims_reads_from_access_token(): - user = AuthenticatedUser( - AccessToken(token="t", client_id="c", scopes=[], claims={"iss": "https://auth.example.com"}) - ) - cv_token = auth_context_var.set(user) - try: - assert Context().claims == {"iss": "https://auth.example.com"} - finally: - auth_context_var.reset(cv_token) From a857cfc25ff0eae9d92bc45c7d76af371a325c48 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Tue, 26 May 2026 13:09:44 +0000 Subject: [PATCH 4/4] Address review: iss in introspection, tighten field comments - Example introspection response now includes iss so resource servers can key identity on (iss, sub). - subject comment notes uniqueness is per-issuer; drop "verified" from the claims comment since the SDK only stores what the verifier returns. - Keep subject=username in the example: the nearby user_id is regenerated per login and would not be a stable subject. --- examples/servers/simple-auth/mcp_simple_auth/auth_server.py | 1 + src/mcp/server/auth/provider.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/examples/servers/simple-auth/mcp_simple_auth/auth_server.py b/examples/servers/simple-auth/mcp_simple_auth/auth_server.py index b06e4aa923..26c87c5ef2 100644 --- a/examples/servers/simple-auth/mcp_simple_auth/auth_server.py +++ b/examples/servers/simple-auth/mcp_simple_auth/auth_server.py @@ -121,6 +121,7 @@ async def introspect_handler(request: Request) -> Response: "token_type": "Bearer", "aud": access_token.resource, # RFC 8707 audience claim "sub": access_token.subject, # RFC 7662 subject + "iss": str(server_settings.server_url), } ) diff --git a/src/mcp/server/auth/provider.py b/src/mcp/server/auth/provider.py index d06cea158f..4ce1137575 100644 --- a/src/mcp/server/auth/provider.py +++ b/src/mcp/server/auth/provider.py @@ -42,8 +42,8 @@ class AccessToken(BaseModel): scopes: list[str] expires_at: int | None = None resource: str | None = None # RFC 8707 resource indicator - subject: str | None = None # RFC 7662/9068 `sub`: resource owner the token was issued for - claims: dict[str, Any] | None = None # additional verified claims (e.g. `iss`, `act`) + subject: str | None = None # RFC 7662/9068 `sub`: resource owner; unique only per issuer + claims: dict[str, Any] | None = None # additional claims (e.g. `iss`, `act`) RegistrationErrorCode = Literal[