Skip to content

Commit ee6cba5

Browse files
committed
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.
1 parent 0f89376 commit ee6cba5

14 files changed

Lines changed: 83 additions & 22 deletions

File tree

docs/deprecated.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ These are not spec changes, only SDK usage with a better replacement. They warn
136136
| Deprecated | What you do instead |
137137
|---|---|
138138
| `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. |
139+
| `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. |
139140
| `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. |
140141

141142
## Recap

docs/run/authorization.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,20 +18,20 @@ That's the whole triangle. Everything on this page is the middle bullet.
1818

1919
The SDK has no opinion about what a valid token looks like. You tell it, by implementing **`TokenVerifier`**:
2020

21-
```python title="server.py" hl_lines="12-14 19-24"
21+
```python title="server.py" hl_lines="14-16 21-27"
2222
--8<-- "docs_src/authorization/tutorial001.py"
2323
```
2424

2525
* `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.
26-
* 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.
26+
* 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.
2727
* `token_verifier=` and `auth=` always travel together. Pass one without the other and `MCPServer(...)` raises a `ValueError` before it ever serves a request.
2828

2929
`AuthSettings` is the public face of your resource server:
3030

3131
* `issuer_url`: the authorization server that issues your tokens.
3232
* `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.
3333
* `required_scopes`: every token must carry all of them.
34-
* `validate_token_resource`: refuse any token whose `AccessToken.resource` is not `resource_server_url`. Off by default.
34+
* `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.
3535
* 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.
3636
* 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.
3737
* 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
9090

9191
Inside any handler, **`get_access_token()`** is the `AccessToken` your verifier returned for the current request:
9292

93-
```python title="server.py" hl_lines="4 32-35"
93+
```python title="server.py" hl_lines="4 35-38"
9494
--8<-- "docs_src/authorization/tutorial002.py"
9595
```
9696

docs_src/authorization/tutorial001.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44
from mcp.server.auth.provider import AccessToken, TokenVerifier
55
from mcp.server.auth.settings import AuthSettings
66

7+
RESOURCE = "http://127.0.0.1:8000/mcp"
8+
79
KNOWN_TOKENS = {
8-
"alice-token": AccessToken(token="alice-token", client_id="alice", scopes=["notes:read"]),
10+
"alice-token": AccessToken(token="alice-token", client_id="alice", scopes=["notes:read"], resource=RESOURCE),
911
}
1012

1113

@@ -19,8 +21,9 @@ async def verify_token(self, token: str) -> AccessToken | None:
1921
token_verifier=StaticTokenVerifier(),
2022
auth=AuthSettings(
2123
issuer_url=AnyHttpUrl("https://auth.example.com"),
22-
resource_server_url=AnyHttpUrl("http://127.0.0.1:8000/mcp"),
24+
resource_server_url=AnyHttpUrl(RESOURCE),
2325
required_scopes=["notes:read"],
26+
validate_token_resource=True,
2427
),
2528
)
2629

docs_src/authorization/tutorial002.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
from mcp.server.auth.provider import AccessToken, TokenVerifier
66
from mcp.server.auth.settings import AuthSettings
77

8+
RESOURCE = "http://127.0.0.1:8000/mcp"
9+
810
KNOWN_TOKENS = {
9-
"alice-token": AccessToken(token="alice-token", client_id="alice", scopes=["notes:read"]),
11+
"alice-token": AccessToken(token="alice-token", client_id="alice", scopes=["notes:read"], resource=RESOURCE),
1012
}
1113

1214

@@ -20,8 +22,9 @@ async def verify_token(self, token: str) -> AccessToken | None:
2022
token_verifier=StaticTokenVerifier(),
2123
auth=AuthSettings(
2224
issuer_url=AnyHttpUrl("https://auth.example.com"),
23-
resource_server_url=AnyHttpUrl("http://127.0.0.1:8000/mcp"),
25+
resource_server_url=AnyHttpUrl(RESOURCE),
2426
required_scopes=["notes:read"],
27+
validate_token_resource=True,
2528
),
2629
)
2730

examples/snippets/servers/oauth_server.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,9 @@ async def verify_token(self, token: str) -> AccessToken | None:
2424
# Auth settings for RFC 9728 Protected Resource Metadata
2525
auth=AuthSettings(
2626
issuer_url=AnyHttpUrl("https://auth.example.com"), # Authorization Server URL
27-
resource_server_url=AnyHttpUrl("http://localhost:3001"), # This server's URL
27+
resource_server_url=AnyHttpUrl("http://localhost:3001/mcp"), # This server's URL
2828
required_scopes=["user"],
29+
validate_token_resource=True,
2930
),
3031
)
3132

examples/stories/_shared/auth.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,4 +169,5 @@ def auth_settings(
169169
required_scopes=scopes,
170170
client_registration_options=ClientRegistrationOptions(enabled=True, valid_scopes=scopes, default_scopes=scopes),
171171
identity_assertion_enabled=identity_assertion_enabled,
172+
validate_token_resource=True,
172173
)

examples/stories/bearer_auth/server.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ async def verify_token(self, token: str) -> AccessToken | None:
2828
client_id="demo-client",
2929
scopes=[REQUIRED_SCOPE],
3030
expires_at=int(time.time()) + 3600,
31+
resource=RESOURCE_URL,
3132
subject="demo-user",
3233
)
3334

@@ -40,6 +41,7 @@ def build_app() -> Starlette:
4041
issuer_url=AnyHttpUrl(ISSUER),
4142
resource_server_url=AnyHttpUrl(RESOURCE_URL),
4243
required_scopes=[REQUIRED_SCOPE],
44+
validate_token_resource=True,
4345
),
4446
)
4547

examples/stories/bearer_auth/server_lowlevel.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolReques
4646
issuer_url=AnyHttpUrl(ISSUER),
4747
resource_server_url=AnyHttpUrl(RESOURCE_URL),
4848
required_scopes=[REQUIRED_SCOPE],
49+
validate_token_resource=True,
4950
),
5051
token_verifier=StaticTokenVerifier(),
5152
transport_security=NO_DNS_REBIND,

examples/stories/oauth_client_credentials/server.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,14 @@ async def token_endpoint(request: Request) -> JSONResponse:
6666
if creds != f"{DEMO_CLIENT_ID}:{DEMO_CLIENT_SECRET}":
6767
return JSONResponse({"error": "invalid_client"}, status_code=401)
6868
access = f"access_{secrets.token_hex(16)}"
69-
issued[access] = AccessToken(token=access, client_id=DEMO_CLIENT_ID, scopes=[DEMO_SCOPE], expires_at=None)
69+
resource = form.get("resource") # RFC 8707: bind the token to the resource the client asked for
70+
issued[access] = AccessToken(
71+
token=access,
72+
client_id=DEMO_CLIENT_ID,
73+
scopes=[DEMO_SCOPE],
74+
expires_at=None,
75+
resource=resource if isinstance(resource, str) else None,
76+
)
7077
body = OAuthToken(access_token=access, token_type="Bearer", expires_in=3600, scope=DEMO_SCOPE)
7178
return JSONResponse(body.model_dump(exclude_none=True), headers={"cache-control": "no-store"})
7279

examples/stories/oauth_client_credentials/server_lowlevel.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,14 @@ async def token_endpoint(request: Request) -> JSONResponse:
6363
if creds != f"{DEMO_CLIENT_ID}:{DEMO_CLIENT_SECRET}":
6464
return JSONResponse({"error": "invalid_client"}, status_code=401)
6565
access = f"access_{secrets.token_hex(16)}"
66-
issued[access] = AccessToken(token=access, client_id=DEMO_CLIENT_ID, scopes=[DEMO_SCOPE], expires_at=None)
66+
resource = form.get("resource") # RFC 8707: bind the token to the resource the client asked for
67+
issued[access] = AccessToken(
68+
token=access,
69+
client_id=DEMO_CLIENT_ID,
70+
scopes=[DEMO_SCOPE],
71+
expires_at=None,
72+
resource=resource if isinstance(resource, str) else None,
73+
)
6774
body = OAuthToken(access_token=access, token_type="Bearer", expires_in=3600, scope=DEMO_SCOPE)
6875
return JSONResponse(body.model_dump(exclude_none=True), headers={"cache-control": "no-store"})
6976

0 commit comments

Comments
 (0)