Skip to content

Commit c53aefd

Browse files
committed
Close cancelled HTTP exchanges and harden auth validation
Review-feedback round on the conformance burn-down: - Cancelled requests no longer leave the legacy streamable-HTTP POST hanging. The dispatcher emits a RequestSettled marker when a handler is cancelled without producing a response; the transport consumes it by closing the per-request stream, so the POST's SSE stream terminates without a response frame and JSON-response mode completes with 204 No Content (the client treats 202/204 alike). Per-request streams are released instead of leaking until session teardown, and a handler that survives the cancellation still delivers its normal response. The marker is type-visible on the dispatcher write stream and is stripped by every serializing transport, so it can never appear on a wire. - A bearer token whose audience cannot be canonicalized (out-of-range or non-numeric port) is now rejected with the standard 401 invalid_token instead of raising through the auth middleware as a 500. - The bundled authorization server's /register now accepts only https redirect URIs or http on a loopback host; other schemes on loopback hosts (ftp, ws, javascript, custom) are rejected. - OAuth client scope selection falls back to the caller-configured OAuthClientMetadata.scope when neither the WWW-Authenticate challenge nor protected-resource metadata names scopes, matching the TypeScript SDK, so the documented migration path works as written. - The cross-dispatcher contract that handler-raised MCPError subclasses surface to callers as plain MCPError is now pinned by an explicit test and documented; rehydrate with from_error when the subclass matters. - Docs: migration notes for the bearer-challenge wire-shape changes and the cancellation wire spellings; story READMEs updated to the landed error contract; strict-capabilities doc corrected to state that resources/unsubscribe is gated by the base resources capability only.
1 parent c2b3e8e commit c53aefd

46 files changed

Lines changed: 1611 additions & 305 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/client/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ The resource verbs come in pairs: two ways to list, one way to read.
145145

146146
`read_resource` returns `contents`, a list of `TextResourceContents` or `BlobResourceContents`. Same idea as tool content: narrow with `isinstance`, then read `.text` (or `.blob`).
147147

148-
A client can also **subscribe** to a resource and be told when it changes: `subscribe_resource(uri)` and `unsubscribe_resource(uri)`, same shape as everything else here. `MCPServer` doesn't implement that half. It says so up front (`server_capabilities.resources.subscribe` is `False`) and answers the request with an `MCPError`: `-32601`, *Method not found*. With `strict_capabilities=True` you get the same `-32601` without the round trip: the client sees `server_capabilities.resources.subscribe` is falsy and never sends the request. A server that does support subscriptions is built on the low-level `Server` (**The low-level Server**).
148+
A client can also **subscribe** to a resource and be told when it changes: `subscribe_resource(uri)` and `unsubscribe_resource(uri)`, same shape as everything else here. `MCPServer` doesn't implement that half. It says so up front (`server_capabilities.resources.subscribe` is `False`) and answers the request with an `MCPError`: `-32601`, *Method not found*. With `strict_capabilities=True` you get the same `-32601` for `subscribe_resource` without the round trip: the client sees `server_capabilities.resources.subscribe` is falsy and never sends the request. `unsubscribe_resource` is still sent — only the base `resources` capability gates it, matching the TypeScript SDK — so its `-32601` comes from the server. A server that does support subscriptions is built on the low-level `Server` (**The low-level Server**).
149149

150150
## Prompts
151151

docs/migration.md

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,19 @@ An unhandled exception in a request handler now produces JSON-RPC error `-32603`
4141
code `0` with `str(exc)` as the message, leaking handler internals to the peer; the
4242
exception is still logged server-side via `logger.exception`. To send a specific
4343
code and message, raise `MCPError` (unchanged); a pydantic `ValidationError` is
44-
still mapped to `INVALID_PARAMS`.
44+
still mapped to `INVALID_PARAMS`. An `MCPError` *subclass* raised by a handler
45+
(e.g. `UrlElicitationRequiredError`) reaches the caller as plain `MCPError` with
46+
the same `code`, `message`, and `data` on every dispatch path — including the
47+
in-process `Client(server)` — so catch `MCPError` and match on `error.code`
48+
rather than on the subclass type.
4549

4650
A request cancelled via `notifications/cancelled` now receives no response at all,
4751
per the spec's SHOULD. v1 answered the cancelled request with an error
4852
(`code=0, message="Request cancelled"`). The sender's awaiting call already fails
4953
with anyio cancellation when its scope is cancelled, so no reply is needed to
50-
unblock it.
54+
unblock it. On legacy streamable HTTP this means the cancelled request's POST SSE
55+
stream now terminates without a response frame, and in JSON-response mode the POST
56+
completes with `204 No Content`.
5157

5258
### A second `initialize` on an already-initialized session is rejected
5359

@@ -148,7 +154,7 @@ When no authorization-server metadata document could be discovered at all, the f
148154

149155
The specification's scope-selection chain is two steps: the `scope` parameter from the `WWW-Authenticate` challenge, then `scopes_supported` from the Protected Resource Metadata document, *otherwise the `scope` parameter is omitted*. The SDK inserted an extra fallback between those two steps — the **authorization-server** metadata's `scopes_supported` — which over-requests (an authorization server may serve many resource servers, so its list is a superset of any one resource's) and caused real `access_denied` failures ([#1307](https://github.com/modelcontextprotocol/python-sdk/issues/1307)). That fallback is removed: when neither the challenge nor the PRM names scopes, the client now omits the `scope` parameter and lets the authorization server apply its defaults.
150156

151-
This also affects the SEP-2207 `offline_access` augmentation, which only fires once a base scope was selected: if the authorization server's `scopes_supported` was your only scope source, the client now sends no `scope` at all (not even `offline_access`) and the authorization server's defaults decide whether a refresh token is issued. In either case, if you relied on the removed fallback, pass an explicit `scope` on the `OAuthClientMetadata` you give to `OAuthClientProvider`.
157+
This also affects the SEP-2207 `offline_access` augmentation, which only fires once a base scope was selected: if the authorization server's `scopes_supported` was your only scope source, the client now sends no `scope` at all (not even `offline_access`) and the authorization server's defaults decide whether a refresh token is issued. In either case, if you relied on the removed fallback, pass an explicit `scope` on the `OAuthClientMetadata` you give to `OAuthClientProvider`. That explicit scope ranks *below* the spec's two sources (matching the TypeScript SDK): a `scope` on the `WWW-Authenticate` challenge or a PRM `scopes_supported` still wins, so the explicit value only takes effect when both are silent.
152158

153159
### `get_session_id` callback removed from `streamable_http_client`
154160

@@ -1583,6 +1589,12 @@ Leaving `resource_server_url=None` continues to disable the check entirely (ther
15831589

15841590
`RefreshToken` gains an optional `resource` field so an `OAuthAuthorizationServerProvider` can propagate the original grant's audience binding through `exchange_refresh_token`; without it a refreshed access token would carry no audience and be rejected. `BearerAuthBackend.__init__` gains a keyword-only `resource_server_url: AnyHttpUrl | None = None`, wired automatically from `AuthSettings.enforced_audience`; `None` (the default, and what the SDK passes when `verifier_validates_audience` is set) means no audience is enforced.
15851591

1592+
The error responses the bearer gate sends changed shape in the same release, following RFC 6750 §3:
1593+
1594+
- A request presenting **no credentials at all** (no `Authorization: Bearer` header) is now answered with a bare challenge — `WWW-Authenticate: Bearer scope="…", resource_metadata="…"` and an empty JSON body `{}` — instead of `error="invalid_token", error_description="Authentication required"` in both the header and the body. RFC 6750 §3.1 says the `error` attribute should only appear when the request actually carried a token, so "no token" and "rejected token" are now distinguishable; anything matching on the literal `Authentication required` string or expecting a non-empty 401 body must be updated.
1595+
- Every challenge — the `401`s and the `403` — now advertises the configured `required_scopes` in an RFC 6750 `scope="…"` parameter, which spec-conformant clients (including this SDK's OAuth client) read as the first step of scope selection and step-up authorization.
1596+
- The `error_description` strings changed. A rejected token now states the failure: `The access token is malformed or unknown`, `The access token has expired`, `The access token carries no audience claim`, or `The access token was issued for a different resource` (previously all of these — where they were rejected at all — said `Authentication required`). The `403 insufficient_scope` description is now the fixed string `The access token lacks a required scope` instead of `Required scope: {scope}`; the required scope moved to the machine-readable `scope=` challenge parameter. Treat `error_description` as human-readable prose, not a contract.
1597+
15861598
### Bundled authorization server: RFC-correct redirect-URI handling
15871599

15881600
Two fixes to the optional bundled OAuth authorization server (the `auth_server_provider=` path).

examples/clients/simple-auth-client/mcp_simple_auth_client/main.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
from mcp.client.sse import sse_client
2525
from mcp.client.streamable_http import streamable_http_client
2626
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
27-
from mcp.shared.message import SessionMessage
27+
from mcp.shared.message import RequestSettled, SessionMessage
2828

2929

3030
class InMemoryTokenStorage(TokenStorage):
@@ -248,8 +248,8 @@ async def _default_redirect_handler(authorization_url: str) -> None:
248248

249249
async def _run_session(
250250
self,
251-
read_stream: ReadStream[SessionMessage | Exception],
252-
write_stream: WriteStream[SessionMessage],
251+
read_stream: ReadStream[SessionMessage | Exception | RequestSettled],
252+
write_stream: WriteStream[SessionMessage | RequestSettled],
253253
):
254254
"""Run the MCP session with the given streams."""
255255
print("🤝 Initializing MCP session...")

examples/stories/error_handling/README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,10 @@ uv run python -m stories.error_handling.client --http --server server_lowlevel
3535
## Caveats
3636

3737
- The "any other exception → `is_error` result" contract on `MCPServer` and the
38-
"uncaught exception → `code=0`" behaviour on `lowlevel.Server` are **not
39-
shown** — the contract is under design and the legacy code is a known spec
40-
divergence. This story will grow those cases once the contract lands.
38+
"uncaught exception → `-32603` `Internal server error`" behaviour on
39+
`lowlevel.Server` are **not shown** here. The lowlevel reply is deliberately
40+
opaque — handler internals never reach the peer; the exception is logged
41+
server-side.
4142
- `MCPServer` prefixes the execution-error message with
4243
`"Error executing tool {name}: "`; build a `CallToolResult` directly from a
4344
lowlevel handler if you need verbatim control.

examples/stories/streaming/README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,10 @@ uv run python -m stories.streaming.client --http --server server_lowlevel
5656
OpenTelemetry instead of `notifications/message`. It is shown here because
5757
servers still need to support 2025-era clients during that window. Progress
5858
and cancellation are **not** deprecated. TODO(maxisbey): revisit before beta.
59-
- When a request is cancelled the server currently replies with
60-
`ErrorData(code=0, message="Request cancelled")`; the spec says it should not
61-
reply at all. The client never observes it (its awaiting task is already
62-
cancelled), so this story does not assert on the reply.
59+
- When a request is cancelled the server sends no reply at all, per the spec's
60+
SHOULD. The client's awaiting task is already cancelled, so there is nothing
61+
to observe on that call; this story asserts only that the in-flight call was
62+
cancelled and that the session survives for a follow-up call.
6363

6464
## Spec
6565

src/mcp/client/__main__.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from mcp.client.session import ClientSession
1313
from mcp.client.sse import sse_client
1414
from mcp.client.stdio import StdioServerParameters, stdio_client
15-
from mcp.shared.message import SessionMessage
15+
from mcp.shared.message import RequestSettled, SessionMessage
1616
from mcp.shared.session import RequestResponder
1717

1818
if not sys.warnoptions:
@@ -33,8 +33,8 @@ async def message_handler(
3333

3434

3535
async def run_session(
36-
read_stream: ReadStream[SessionMessage | Exception],
37-
write_stream: WriteStream[SessionMessage],
36+
read_stream: ReadStream[SessionMessage | Exception | RequestSettled],
37+
write_stream: WriteStream[SessionMessage | RequestSettled],
3838
client_info: types.Implementation | None = None,
3939
):
4040
async with ClientSession(

src/mcp/client/_transport.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,14 @@
66
from typing import Protocol
77

88
from mcp.shared._stream_protocols import ReadStream, WriteStream
9-
from mcp.shared.message import SessionMessage
9+
from mcp.shared.message import RequestSettled, SessionMessage
1010

1111
__all__ = ["ReadStream", "WriteStream", "Transport", "TransportStreams"]
1212

13-
TransportStreams = tuple[ReadStream[SessionMessage | Exception], WriteStream[SessionMessage]]
13+
TransportStreams = tuple[
14+
ReadStream[SessionMessage | Exception | RequestSettled],
15+
WriteStream[SessionMessage | RequestSettled],
16+
]
1417

1518

1619
class Transport(AbstractAsyncContextManager[TransportStreams], Protocol):

src/mcp/client/auth/oauth2.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -645,12 +645,16 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.
645645
self.context.client_info = None
646646
self.context.clear_tokens()
647647

648-
# Step 3: Apply scope selection strategy
648+
# Step 3: Apply scope selection strategy. The configured client-metadata
649+
# scope is the lowest-priority fallback, and the selection is written back
650+
# so registration (Step 4) and authorization (Step 5) read it — a later
651+
# re-auth therefore falls back to this selection, not the constructor value.
649652
self.context.client_metadata.scope = get_client_metadata_scopes(
650653
extract_scope_from_www_auth(response),
651654
self.context.protected_resource_metadata,
652655
self.context.oauth_metadata,
653656
self.context.client_metadata.grant_types,
657+
self.context.client_metadata.scope,
654658
)
655659

656660
# Step 4: Register client or use URL-based client ID (CIMD)

src/mcp/client/auth/utils.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -105,18 +105,25 @@ def get_client_metadata_scopes(
105105
protected_resource_metadata: ProtectedResourceMetadata | None,
106106
authorization_server_metadata: OAuthMetadata | None = None,
107107
client_grant_types: list[str] | None = None,
108+
client_metadata_scope: str | None = None,
108109
) -> str | None:
109-
"""Select effective scopes and augment for refresh token support."""
110-
selected_scope: str | None = None
110+
"""Select effective scopes and augment for refresh token support.
111111
112-
# MCP spec scope selection priority:
112+
Follows the MCP spec's scope-selection strategy (challenge scope, then PRM
113+
`scopes_supported`), with the caller's pre-configured `OAuthClientMetadata.scope` as a
114+
final SDK-defined fallback (TypeScript-SDK parity) before omitting the parameter.
115+
"""
116+
# Scope selection priority (1-2 are the spec's chain; 3 is the SDK fallback):
113117
# 1. WWW-Authenticate header scope
114118
# 2. PRM scopes_supported
115-
# 3. Omit scope parameter
119+
# 3. Caller-supplied client metadata scope
120+
# 4. Omit scope parameter
116121
if www_authenticate_scope is not None:
117122
selected_scope = www_authenticate_scope
118123
elif protected_resource_metadata is not None and protected_resource_metadata.scopes_supported is not None:
119124
selected_scope = " ".join(protected_resource_metadata.scopes_supported)
125+
else:
126+
selected_scope = client_metadata_scope
120127

121128
# SEP-2207: append offline_access when the AS supports it and the client can use refresh tokens
122129
if (

src/mcp/client/session.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
x_mcp_header_map,
4646
)
4747
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
48-
from mcp.shared.message import ClientMessageMetadata, SessionMessage
48+
from mcp.shared.message import ClientMessageMetadata, RequestSettled, SessionMessage
4949
from mcp.shared.session import RequestResponder
5050
from mcp.shared.transport_context import TransportContext
5151

@@ -215,8 +215,8 @@ class ClientSession:
215215

216216
def __init__(
217217
self,
218-
read_stream: ReadStream[SessionMessage | Exception] | None = None,
219-
write_stream: WriteStream[SessionMessage] | None = None,
218+
read_stream: ReadStream[SessionMessage | Exception | RequestSettled] | None = None,
219+
write_stream: WriteStream[SessionMessage | RequestSettled] | None = None,
220220
read_timeout_seconds: float | None = None,
221221
sampling_callback: SamplingFnT | None = None,
222222
elicitation_callback: ElicitationFnT | None = None,

0 commit comments

Comments
 (0)