Skip to content

Commit 2983290

Browse files
committed
Merge remote-tracking branch 'origin/main' into same-origin-redirects
# Conflicts: # src/mcp/client/auth/oauth2.py
2 parents 22d3735 + 979208c commit 2983290

26 files changed

Lines changed: 1774 additions & 376 deletions

docs/client/oauth-clients.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ Look at `main()`. The provider goes on the **httpx2 client**, the httpx2 client
7676

7777
The first time `Client` sends a request, the server answers `401`. The provider takes over:
7878

79-
1. **Discovery.** It reads the `WWW-Authenticate` header, fetches the server's Protected Resource Metadata from `/.well-known/oauth-protected-resource`, learns which authorization server protects this resource, and fetches *that* server's metadata.
79+
1. **Discovery.** It reads the `WWW-Authenticate` header, fetches the server's Protected Resource Metadata from `/.well-known/oauth-protected-resource`, learns which authorization server protects this resource, and fetches *that* server's metadata. (An older server that publishes no resource metadata is asked for authorization server metadata at its own origin instead.) Either way the metadata must name, as its `issuer`, the server it was fetched for; anything else is refused.
8080
2. **Registration.** Nothing in storage? It registers you dynamically with your `OAuthClientMetadata` and stores the result.
8181
3. **Authorization.** It generates the PKCE pair and a `state`, builds the authorization URL, awaits your `redirect_handler`, then awaits your `callback_handler` for the code.
8282
4. **Exchange.** It trades the code for an `OAuthToken`, stores it, and replays your original request with `Authorization: Bearer ...`.
@@ -107,13 +107,14 @@ A nightly job, a CI step, another service. There is no browser and nobody to cli
107107

108108
`ClientCredentialsOAuthProvider` is the same `httpx2.Auth`, minus the human:
109109

110-
```python title="client.py" hl_lines="4 27-33"
110+
```python title="client.py" hl_lines="4 27-34"
111111
--8<-- "docs_src/oauth_clients/tutorial002.py"
112112
```
113113

114114
What changed:
115115

116116
* No `OAuthClientMetadata`, no handlers. You pass `client_id` and `client_secret`; the provider builds a minimal `client_credentials` registration around them and skips dynamic registration entirely.
117+
* `issuer` names the authorization server that issued those credentials; use the `issuer` value its `/.well-known/oauth-authorization-server` document returns. Discovery still runs as above, but token requests are only ever built from metadata for *that* issuer; if the MCP server points anywhere else, the flow stops with an `OAuthFlowError` instead. Leaving it out is deprecated and it becomes required in 3.0 (see **[Deprecated features](../deprecated.md#deprecated-sdk-helpers)**); until then the provider warns and uses whichever authorization server discovery finds.
117118
* `scope` is a space-separated string, the OAuth wire format.
118119
* Everything downstream is identical: the same `TokenStorage`, the same `httpx2.AsyncClient(auth=...)`, the same `streamable_http_client`.
119120

@@ -126,7 +127,7 @@ By default the secret travels as HTTP Basic auth on the token request (`client_s
126127
One more provider lives in `mcp.client.auth.extensions.client_credentials`:
127128
**`PrivateKeyJWTOAuthProvider`**, for clients that authenticate with a JWT instead of a
128129
shared secret (`private_key_jwt`, the key-pair and workload-identity flavour). It follows
129-
the same pattern: construct one, put it on `auth=`. The same module ships
130+
the same pattern: construct one (it takes the same optional `issuer`), put it on `auth=`. The same module ships
130131
`SignedJWTParameters` and `static_assertion_provider`, two helpers that build its assertion.
131132

132133
There is one more no-human situation: the client belongs to an enterprise whose identity provider, not the user, decides which MCP servers it may reach. That is a different grant with its own trust model and its own page, **[Identity assertion](identity-assertion.md)**.

docs/deprecated.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Deprecated features
22

3-
The 2026-07-28 spec retires five things. The SDK still implements every one of them, and every one of them now carries a **deprecation warning**. One SDK helper is deprecated on its own account and is listed [at the end](#deprecated-sdk-helpers).
3+
The 2026-07-28 spec retires five things. The SDK still implements every one of them, and every one of them now carries a **deprecation warning**. A few SDK-level deprecations stand on their own account and are listed [at the end](#deprecated-sdk-helpers).
44

55
The table below names each deprecated feature, why it is going away, and the replacement to build on.
66

@@ -131,11 +131,12 @@ That is the whole API. There is no per-method switch, and you don't want one: th
131131

132132
## Deprecated SDK helpers
133133

134-
These are not spec changes, only SDK internals with a better replacement. They warn with the same `MCPDeprecationWarning` and will be removed in 3.0.
134+
These are not spec changes, only SDK usage with a better replacement. They warn with the same `MCPDeprecationWarning`, and 3.0 removes the old form.
135135

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+
| `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. |
139140

140141
## Recap
141142

@@ -144,7 +145,7 @@ These are not spec changes, only SDK internals with a better replacement. They w
144145
* Deprecated is advisory: no wire changes, everything keeps working against pre-2026 sessions, and you get a visible `MCPDeprecationWarning` (a `UserWarning`, so it is on by default).
145146
* Sampling and roots additionally need a back-channel that a 2026-07-28 session does not have. On a modern connection they warn and then they raise.
146147
* `warnings.filterwarnings("ignore", category=MCPDeprecationWarning)` silences the whole category; `"error::mcp.MCPDeprecationWarning"` in pytest turns it into a test failure.
147-
* One SDK helper, `FuncMetadata.call_fn_with_arg_validation()`, is deprecated separately for removal in 3.0.
148+
* The [SDK-level deprecations](#deprecated-sdk-helpers) follow the same rule: they warn now, and 3.0 drops the old form.
148149
* New code should not be built on any of these.
149150

150151
Every other page in these docs teaches the current API.

docs/migration.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2495,7 +2495,9 @@ metadata's `issuer` exactly matches the authorization server URL advertised in t
24952495
resource metadata, as required by [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414)
24962496
section 3.3 ([SEP-2468](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2468)).
24972497
The comparison is a simple string comparison ([RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986)
2498-
section 6.2.1), so even a trailing-slash disagreement counts as a mismatch. v1 accepted the
2498+
section 6.2.1), so even a trailing-slash disagreement counts as a mismatch. (For an older server
2499+
that publishes no protected resource metadata the expected value is the MCP server's own origin,
2500+
and there a root issuer with a trailing slash is accepted too.) v1 accepted the
24992501
metadata without checking, so a server pairing whose two values disagree authenticated fine
25002502
under v1 and now fails the entire flow. For example, when the MCP server's protected resource
25012503
metadata advertises
@@ -2513,7 +2515,7 @@ OAuthFlowError: Authorization server metadata issuer mismatch: https://as.exampl
25132515

25142516
There is no client-side override. Fix the deployment instead: make the authorization server's
25152517
`issuer` string-equal the URL in the protected resource metadata's `authorization_servers`
2516-
list. See [OAuth metadata URLs no longer gain a trailing slash](#oauth-metadata-urls-no-longer-gain-a-trailing-slash)
2518+
list (or the MCP server's origin, without protected resource metadata). See [OAuth metadata URLs no longer gain a trailing slash](#oauth-metadata-urls-no-longer-gain-a-trailing-slash)
25172519
for how v2 preserves the exact string form of these URLs.
25182520

25192521
### OAuth client requests `offline_access` and adds `prompt=consent` when the authorization server supports it ([SEP-2207](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2207))

docs/run/index.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,11 @@ Each transport has its own keyword arguments, all on `run()`:
7070
* `max_request_body_size`: largest accepted request body in bytes. Defaults to 4 MiB; larger requests
7171
receive HTTP 413 before parsing or session creation. Raise it only when legitimate MCP messages
7272
exceed that size.
73+
* `session_idle_timeout`: seconds a legacy session may sit with nothing in flight before the
74+
server closes it. Default 1800. `None` disables it. See
75+
[Session lifetime and limits](legacy-clients.md#session-lifetime-and-limits).
76+
* `max_sessions`: how many legacy sessions one process holds at once. Default 10 000. `None`
77+
removes the limit. Covered in the same section.
7378
* `event_store`, `retry_interval`, `transport_security`: resumability and DNS-rebinding protection. They can wait, until you deploy somewhere other than localhost; **[Deploy & scale](deploy.md)** covers `transport_security`.
7479

7580
!!! warning

docs/run/legacy-clients.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,40 @@ On one worker that is invisible. On two, it is the whole problem: a request that
5656
events to a client reconnecting to the *same* session), not a session store. It never makes a
5757
session reachable from another process.
5858

59+
## Session lifetime and limits
60+
61+
A legacy session does not live forever, and one process does not hold an unlimited number of
62+
them. Two settings control this. Both are keyword arguments on `run()`, `streamable_http_app()`
63+
and `Server.streamable_http_app()`. Modern (`2026-07-28`) connections and `stateless_http=True`
64+
have no sessions, so neither setting applies to them.
65+
66+
| Setting | Default | What it does | What the client sees | Turn it off |
67+
|---|---|---|---|---|
68+
| `session_idle_timeout` | `1800` (30 min) | Closes a session that has had nothing in flight for that long. | `404 Session not found`. It has to `initialize` again. | `None` |
69+
| `max_sessions` | `10_000` | Refuses to open a session beyond that many. Existing sessions are untouched and nothing is evicted. | `503 Too many open sessions` with JSON-RPC code `-32603`. | `None` |
70+
71+
What counts as "in flight":
72+
73+
* An open `GET` stream. The SDK clients keep one open, so a connected client's session never
74+
expires.
75+
* A request that is still being answered. A tool call that runs longer than the timeout is not
76+
interrupted, and the countdown only starts once it finishes.
77+
* Nothing else. Between requests the clock runs. Any request on the session restarts it,
78+
`ping` included. Once a session has expired, nothing revives it.
79+
80+
A client that ends its session with `DELETE` frees it immediately. So does a client whose
81+
opening request was refused.
82+
83+
```python
84+
mcp.run(transport="streamable-http", session_idle_timeout=None, max_sessions=50_000)
85+
```
86+
87+
Both events show up in the server log. An expiry is `Session <id> idle timeout` at `INFO`. A
88+
refused open is `Refusing to open a new session: <n> sessions are already open` at `WARNING`.
89+
90+
The limits are per process. With four workers the ceiling is four times `max_sessions`, and each
91+
worker expires its own sessions.
92+
5993
## The one knob: `stateless_http`
6094

6195
If stickiness is a cost you refuse to pay, there is exactly one thing you can change.

docs/troubleshooting.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,7 @@ app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())], lifespan=lif
246246

247247
## `MCPError: Session not found`
248248

249-
The server does not recognise the `Mcp-Session-Id` your client sent, almost always because the server **restarted** (or you were routed to a different instance). Sessions live in that one process's memory.
249+
The server does not recognise the `Mcp-Session-Id` your client sent. Either the server **restarted** (or you were routed to a different instance), or the session **expired** because nothing was in flight for `session_idle_timeout`, which is 30 minutes by default. See [Session lifetime and limits](run/legacy-clients.md#session-lifetime-and-limits). Sessions live in that one process's memory.
250250

251251
There is no server bug to find. The HTTP response is a `404` whose body *is* JSON-RPC, so, unlike the `421` above, the python `Client` shows you this one verbatim:
252252

@@ -256,9 +256,9 @@ There is no server bug to find. The HTTP response is a `404` whose body *is* JSO
256256

257257
The fix is to reconnect: leave the `async with Client(...)` block and enter a new one, which negotiates a fresh session. For a long-lived client, that means catching `MCPError` around your calls and reconnecting on this message rather than retrying inside a dead session.
258258

259-
If it happens *without* a restart, you are running more than one worker without sticky sessions: each worker holds its own session table, so a request routed to the wrong one lands here. **[Deploy & scale](run/deploy.md)** and **[Serving legacy clients](run/legacy-clients.md)** own that story and its two fixes (sticky routing, or `stateless_http=True`).
259+
If it happens *without* a restart and without the client having gone quiet that long, you are running more than one worker without sticky sessions: each worker holds its own session table, so a request routed to the wrong one lands here. **[Deploy & scale](run/deploy.md)** and **[Serving legacy clients](run/legacy-clients.md)** own that story and its two fixes (sticky routing, or `stateless_http=True`).
260260

261-
For the server operator, the matching log line is `Rejected request with unknown or expired session ID: <id>`. It is logged at `INFO`, so it is invisible at the usual `WARNING` threshold. Seeing it in bursts right after a deploy is normal; every connected client is reconnecting.
261+
For the server operator, the matching log line is `Rejected request with unknown or expired session ID: <id>`. It is logged at `INFO`, so it is invisible at the usual `WARNING` threshold. Seeing it in bursts right after a deploy is normal; every connected client is reconnecting. When the session expired instead, that line is preceded by `Session <id> idle timeout`, also at `INFO`.
262262

263263
## `MCPError: Method not found`
264264

@@ -411,7 +411,7 @@ mcp = MCPServer("Weather", request_state_security=RequestStateSecurity(keys=[key
411411
* `Tool already exists:` in the server log is the only sign that two same-named tools collapsed into one.
412412
* One 421, three spellings: `Server returned an error response` (the python `Client`), `421 Misdirected Request` / `Invalid Host header` (everything else), `Invalid Host header: <host>` (the server log). Fix: `transport_security=TransportSecuritySettings(allowed_hosts=[...])`.
413413
* `Task group is not initialized` -> a mounted app whose host lifespan never entered `mcp.session_manager.run()`.
414-
* `Session not found` -> the server restarted; reconnect.
414+
* `Session not found` -> the server restarted or the session expired (`session_idle_timeout`); reconnect.
415415
* `Cannot send 'elicitation/create': ... no back-channel ...` -> `ctx.elicit()` needs a server-to-client channel: a `2026-07-28` connection never has one, `stateless_http=True` takes away the legacy one, and `json_response=True` takes away the request-scoped one. Use a resolver (a legacy client also needs a server that keeps the channel). Its neighbour `Method not found` is a request for a method the other side's protocol revision doesn't have.
416416
* `Client did not declare the form elicitation capability ...` and `Elicitation not supported` -> the client is missing `elicitation_callback=`.
417417
* `Invalid or expired requestState` never says why on the wire. The server log does; `unknown key` means share `RequestStateSecurity(keys=[...])` across workers.

docs_src/oauth_clients/tutorial002.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ async def set_client_info(self, client_info: OAuthClientInformationFull) -> None
3030
client_id="reporting-agent",
3131
client_secret="...",
3232
scope="user",
33+
issuer="http://localhost:9000",
3334
)
3435

3536

examples/stories/oauth_client_credentials/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@ the client and server side.
3333
- `client.py` `main` — opens with `async with Client(target, mode=mode) as
3434
client:` and that's the whole program. `target` is a transport that already
3535
carries the OAuth `httpx2.Auth`; the body never touches a token.
36-
- `client.py` `build_auth`five lines of `ClientCredentialsOAuthProvider`
37-
config is all the caller writes; the SDK does RFC 9728 PRM →
36+
- `client.py` `build_auth`a few lines of `ClientCredentialsOAuthProvider`
37+
config (credentials plus `issuer=`) is all the caller writes; the SDK does RFC 9728 PRM →
3838
RFC 8414 AS-metadata discovery and token exchange on the first 401.
3939
- `server.py` `token_endpoint` — the *entire* AS for this grant: validate
4040
HTTP-Basic `client_id:client_secret`, mint a token, return RFC 6749 JSON.

examples/stories/oauth_client_credentials/client.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,13 @@
88

99
# MCP_URL pins the resource to :8000, and the server side builds its PRM/AS metadata from
1010
# the same constant — run the server on 8000 or the discovery chain points at the wrong origin.
11-
from stories._shared.auth import MCP_URL, InMemoryTokenStorage
11+
from stories._shared.auth import BASE_URL, MCP_URL, InMemoryTokenStorage
1212

1313
from .server import DEMO_CLIENT_ID, DEMO_CLIENT_SECRET, DEMO_SCOPE
1414

1515

1616
def build_auth(_http: httpx2.AsyncClient) -> httpx2.Auth:
17-
"""The ``httpx2.Auth`` for the ``client_credentials`` grant — five lines of provider config.
17+
"""The ``httpx2.Auth`` for the ``client_credentials`` grant — a few lines of provider config.
1818
1919
The SDK then handles 401 → RFC 9728 PRM → RFC 8414 AS-metadata discovery → token POST →
2020
Bearer attachment automatically. ``Client(url)`` has no ``auth=`` passthrough yet, so the
@@ -27,6 +27,7 @@ def build_auth(_http: httpx2.AsyncClient) -> httpx2.Auth:
2727
client_id=DEMO_CLIENT_ID,
2828
client_secret=DEMO_CLIENT_SECRET,
2929
scope=DEMO_SCOPE,
30+
issuer=BASE_URL,
3031
)
3132

3233

0 commit comments

Comments
 (0)