You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Copy file name to clipboardExpand all lines: docs/client/oauth-clients.md
+4-3Lines changed: 4 additions & 3 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -76,7 +76,7 @@ Look at `main()`. The provider goes on the **httpx2 client**, the httpx2 client
76
76
77
77
The first time `Client` sends a request, the server answers `401`. The provider takes over:
78
78
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.
80
80
2.**Registration.** Nothing in storage? It registers you dynamically with your `OAuthClientMetadata` and stores the result.
81
81
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.
82
82
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
107
107
108
108
`ClientCredentialsOAuthProvider` is the same `httpx2.Auth`, minus the human:
109
109
110
-
```python title="client.py" hl_lines="4 27-33"
110
+
```python title="client.py" hl_lines="4 27-34"
111
111
--8<--"docs_src/oauth_clients/tutorial002.py"
112
112
```
113
113
114
114
What changed:
115
115
116
116
* 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.
117
118
*`scope` is a space-separated string, the OAuth wire format.
118
119
* Everything downstream is identical: the same `TokenStorage`, the same `httpx2.AsyncClient(auth=...)`, the same `streamable_http_client`.
119
120
@@ -126,7 +127,7 @@ By default the secret travels as HTTP Basic auth on the token request (`client_s
126
127
One more provider lives in `mcp.client.auth.extensions.client_credentials`:
127
128
**`PrivateKeyJWTOAuthProvider`**, for clients that authenticate with a JWT instead of a
128
129
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
130
131
`SignedJWTParameters` and `static_assertion_provider`, two helpers that build its assertion.
131
132
132
133
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)**.
Copy file name to clipboardExpand all lines: docs/deprecated.md
+4-3Lines changed: 4 additions & 3 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -1,6 +1,6 @@
1
1
# Deprecated features
2
2
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).
4
4
5
5
The table below names each deprecated feature, why it is going away, and the replacement to build on.
6
6
@@ -131,11 +131,12 @@ That is the whole API. There is no per-method switch, and you don't want one: th
131
131
132
132
## Deprecated SDK helpers
133
133
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.
135
135
136
136
| Deprecated | What you do instead |
137
137
|---|---|
138
138
|`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. |
139
140
140
141
## Recap
141
142
@@ -144,7 +145,7 @@ These are not spec changes, only SDK internals with a better replacement. They w
144
145
* 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).
145
146
* 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.
146
147
*`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.
148
149
* New code should not be built on any of these.
149
150
150
151
Every other page in these docs teaches the current API.
There is no client-side override. Fix the deployment instead: make the authorization server's
2515
2517
`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)
2517
2519
for how v2 preserves the exact string form of these URLs.
2518
2520
2519
2521
### OAuth client requests `offline_access` and adds `prompt=consent` when the authorization server supports it ([SEP-2207](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2207))
Copy file name to clipboardExpand all lines: docs/run/index.md
+5Lines changed: 5 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -70,6 +70,11 @@ Each transport has its own keyword arguments, all on `run()`:
70
70
*`max_request_body_size`: largest accepted request body in bytes. Defaults to 4 MiB; larger requests
71
71
receive HTTP 413 before parsing or session creation. Raise it only when legitimate MCP messages
72
72
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.
73
78
*`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`.
Copy file name to clipboardExpand all lines: docs/run/legacy-clients.md
+34Lines changed: 34 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -56,6 +56,40 @@ On one worker that is invisible. On two, it is the whole problem: a request that
56
56
events to a client reconnecting to the *same* session), not a session store. It never makes a
57
57
session reachable from another process.
58
58
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
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.
250
250
251
251
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:
252
252
@@ -256,9 +256,9 @@ There is no server bug to find. The HTTP response is a `404` whose body *is* JSO
256
256
257
257
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.
258
258
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`).
260
260
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`.
*`Tool already exists:` in the server log is the only sign that two same-named tools collapsed into one.
412
412
* 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=[...])`.
413
413
*`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.
415
415
*`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.
416
416
*`Client did not declare the form elicitation capability ...` and `Elicitation not supported` -> the client is missing `elicitation_callback=`.
417
417
*`Invalid or expired requestState` never says why on the wire. The server log does; `unknown key` means share `RequestStateSecurity(keys=[...])` across workers.
0 commit comments