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.
ElicitRequestedSchema was a TypeAlias for dict[str, Any]; it is now a
Pydantic model of the spec's restricted requested-schema subset, backed
by a new PrimitiveSchemaDefinition union (StringSchema, NumberSchema,
BooleanSchema, and the enum schemas). ServerSession.elicit_form (and the
deprecated elicit alias) and ClientPeer.elicit_form accept only this
model, so a nested-object property, an array-of-objects property, or an
anyOf union is unconstructible at the only place a server author
supplies a schema, rather than silently forwarded to the client.
The spec restricts form-mode requested schemas to flat objects with
primitive-typed properties only ("complex nested structures, arrays of
objects ... are intentionally not supported"). The high-level
Context.elicit / elicit_with_validation path is unchanged in behaviour:
it converts the rendered JSON Schema into the typed model, keeping its
existing per-field TypeError contract and producing value-identical wire
output.
Inbound is deliberately untouched. The wire field
ElicitRequestFormParams.requested_schema stays a plain dict[str, Any],
so older servers that emit anyOf for Optional form fields still reach
the client's elicitation callback. The typed-model-to-wire-dict
conversion lives in one place, ElicitRequestedSchema.to_wire(), which
both send sites call.
The schema family is extra="allow": keys schema.ts does not name (a
top-level title, pattern, exclusiveMinimum, json_schema_extra keys)
still round-trip, because the primitives-only restriction is carried by
the union members' required type literals, not by extra-key rejection.
`ServerSession.send_progress_notification` takes an explicit progress token
decoupled from the request it belongs to, so it can keep emitting progress
for a request that has already completed -- which the spec forbids
("Progress notifications MUST stop after completion"). The request-scoped
`report_progress` (and `Context.report_progress`) is the supported path: it
reports against the inbound request's own token, no-ops when the caller did
not ask for progress, and stops when the request completes. The deprecated
method keeps working and emits `MCPDeprecationWarning`.
The warning message deliberately departs from the "<X> is deprecated as of
<version>" pattern used by the spec-driven deprecations: this one is an SDK
API decision, not a spec retirement (2026-07-28 does not retire
server-to-client progress).
For "stops when the request completes" to hold on every dispatcher,
`_DirectDispatchContext` now closes with its request the way
`_JSONRPCDispatchContext` already did: `close()` runs in the dispatch
handler's `finally`, after which `progress`/`notify` deliver nothing,
`can_send_request` is False, and `send_raw_request` raises
`NoBackChannelError` -- the closed state the `DispatchContext` protocol
documents. Two pre-existing tests that asserted `can_send_request` on a
context captured after its handler returned now sample it in-handler, and
the closed-state contract tests are parametrized over both dispatchers.
The interaction test that covered both the server and client side of late
progress is split in two. The server-side property is proved positively on
the wire through `report_progress`; it no longer relies on a session-bound
standalone stream, so it also runs on the stateless streamable-http arm.
The client-side late-drop test keeps using the deprecated explicit-token
method -- the only API that can still produce a late notification -- under
`pytest.warns`, and its arms are unchanged. The migration guide stops
recommending the deprecated method anywhere and documents the replacement.
Client(..., strict_capabilities=True) rejects, before any request reaches
the transport, a call to a method whose required server capability the
connected server did not advertise -- for example list_resources() against
a server that only advertised tools, or subscribe_resource() when the
server's resources capability does not set subscribe. The rejection is an
MCPError with code -32601 (METHOD_NOT_FOUND) and data set to the method,
the same shape a compliant server returns for an unadvertised capability,
so opting in changes where the rejection happens, not what callers catch.
The default is False and unchanged: every request is sent and the server's
answer is surfaced. This mirrors the TypeScript SDK's
enforceStrictCapabilities option (also default-off). The same keyword-only
parameter exists on ClientSession for low-level users; Client forwards it.
The method-to-capability table lives in
mcp_types.methods.SERVER_CAPABILITY_REQUIREMENTS next to the other
per-method maps, with missing_server_capability() as its only evaluator,
so the check in ClientSession.send_request is a single data-driven gate
rather than a per-method condition, and the relationship is the same at
every protocol version. Because the gate reads server_capabilities, a bare
version pin (mode="2026-07-28" with no prior_discover=) would reject every
gated method; that combination is refused at Client construction with a
ValueError that names the fix.
The interaction-requirements entry for the lifecycle capability rule is no
longer marked untested: the new tests pin both the opt-in pre-wire
rejection and the default send-and-surface behaviour.
A server that had already completed the initialize handshake on a
connection answered a repeated initialize request on that same
connection as a fresh handshake, silently overwriting the session's
recorded client_params and negotiated protocol version. A
check_capability call made after that point then answered against the
second client's declared capabilities.
The handshake now commits at most once per connection: a repeated
initialize is answered with JSON-RPC error -32600 (INVALID_REQUEST,
"Session already initialized") and the established session keeps
serving. The check lives at the runner's request-dispatch boundary,
right next to its mirror (the request-before-initialize gate), so every
transport gets it without any transport-level body sniffing.
The discriminator is client_params rather than initialize_accepted: the
legacy stateless path builds a per-request connection that is born past
the gate but has no peer info yet, and its one initialize must still be
accepted.
No compliant client is affected. The spec makes initialization the
first interaction of a session, and ClientSession.initialize() is
already idempotent (a repeat call returns the first result without
sending anything). This only applies to the legacy (2025-11-25 and
earlier) handshake; the 2026-07-28 protocol removes initialize
entirely.
Closes#2605
Two changes to how the OAuth client uses discovered authorization-server
metadata, both required by the MCP authorization specification.
Verify PKCE support before the authorization-code grant. The spec's
Authorization Code Protection section requires clients to verify PKCE
support from the authorization server's metadata and to refuse to
proceed when code_challenge_methods_supported is absent. The new
validate_pkce_support() also refuses a method list that omits S256,
since that is the only method this client sends. The check sits at the
top of _perform_authorization_code_grant, so it covers both the initial
401 flow and the 403 insufficient_scope step-up, while grants that never
issue an authorization code (client credentials, private key JWT) are
unaffected. When no metadata document was discovered at all the flow
proceeds as before: absence of a document is not evidence of
non-support.
Stop reading scopes_supported from authorization-server metadata when
selecting a scope. The spec's scope-selection chain is the
WWW-Authenticate scope parameter, then the protected-resource metadata's
scopes_supported, otherwise omit the scope parameter. The SDK inserted
an extra fallback to the authorization server'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 causes
access_denied failures against servers that reject unknown scopes. With
the fallback removed, clients that relied on it should pass an explicit
scope on their OAuthClientMetadata.
Closes#1307
Two fixes to the optional bundled OAuth authorization server (the
`auth_server_provider=` path).
The registration endpoint accepted any well-formed URL as a
`redirect_uris` entry: cleartext `http://` on a non-loopback host,
`javascript:`, `data:`, and URIs carrying a fragment all registered
successfully. The MCP authorization specification's Communication
Security section requires every redirect URI to be either localhost or
HTTPS, and OAuth 2.1 section 2.3 forbids a fragment component. Such an
entry is now rejected with `400 invalid_client_metadata`. Loopback is
exactly the three forms OAuth 2.1 section 8.4.2 names (`localhost`,
`127.0.0.1`, `[::1]`), on any port; query strings remain permitted.
This also rejects RFC 8252 private-use schemes such as
`com.example.app:/callback`: MCP restricts redirect URIs to HTTPS or
loopback, with no carve-out for native apps.
The rule lives on the request model: `RegistrationRequest`, until now a
dead alias of `OAuthClientMetadata`, becomes a real subclass with a
`redirect_uris` field validator, so a forbidden URI fails parsing and
takes the handler's existing `invalid_client_metadata` arm rather than
needing a post-parse check. `OAuthClientMetadata` itself is unchanged:
the client also serializes it when registering against third-party
authorization servers whose redirect-URI policies the SDK does not own.
The loopback host set moves to a single `LOOPBACK_HOSTS` constant in
`mcp.server.auth.provider`, shared with `validate_issuer_url`, which
previously inlined the same tuple.
Separately, the token endpoint now answers an authorization-code
exchange whose `redirect_uri` does not match the one used at
`/authorize` with `error=invalid_grant` instead of `invalid_request`.
RFC 6749 section 5.2 assigns this case to `invalid_grant` ("does not
match the redirection URI used in the authorization request"), and the
handler's other authorization-code failures already use it. The
exchange was already rejected with HTTP 400; only the `error` field
changes.
Update the affected tests and the interaction-requirement entries, and
add a migration note.
Closes#2629
A client constructed without an elicitation_callback or
list_roots_callback still answers the server's request, via a default
callback that returns a JSON-RPC error. Both defaults used -32600
(invalid request). The spec assigns a specific code to each case:
- elicitation/create: -32602 (invalid params). A client with no
callback declares no elicitation modes, so every incoming request
names an undeclared mode, which clients MUST answer with -32602.
- roots/list: -32601 (method not found), the code clients SHOULD use
when they do not support roots.
The default sampling callback keeps -32600: the spec assigns no code
to a client that does not support sampling. Error messages are
unchanged.
Update the affected tests and the interaction-requirement entries that
recorded the old codes, fix the code named in the client callbacks doc
page, and add a migration note.
When `AuthSettings.resource_server_url` is configured, `BearerAuthBackend`
previously ran the RFC 8707 audience comparison only for tokens whose
verifier populated `AccessToken.resource`: a token carrying no resource
indicator at all was accepted. The MCP authorization spec requires a
resource server to only accept tokens issued specifically for it, so the
gate now fails closed: a verified token with no `resource` is answered
`401 invalid_token` ("The access token carries no audience claim").
`resource_server_url=None` still means there is no audience to enforce.
For verifiers that validate the audience themselves and cannot surface
the claim (for example a JWT decoder configured with the expected
audience), the new `AuthSettings.verifier_validates_audience=True` opts
the gate out. The `AuthSettings.enforced_audience` property derives the
single value both server wirings pass to `BearerAuthBackend`, whose
signature is unchanged.
`RefreshToken` gains an optional `resource` field so an authorization
server provider can carry the original grant's audience binding through
`exchange_refresh_token`; without it every refreshed access token would
be audience-unbound and rejected by the hardened gate.
The docs tutorials and example servers now populate
`AccessToken.resource` (and the client-credentials demo token endpoint
honors the RFC 8707 `resource` parameter) so they pass the check they
teach. The migration guide entry for audience validation is rewritten
for the fail-closed behavior.
A request cancelled via notifications/cancelled now gets no response: when
the handler scope's cancel is caught, JSONRPCDispatcher returns instead of
writing an error. The sender retired its own waiter when it cancelled, so
no reply is needed to unblock it.
An unhandled exception in a request handler now produces JSON-RPC error
-32603 (INTERNAL_ERROR) with the opaque message "Internal server error"
instead of code 0 carrying str(exc). The exception is still logged
server-side. To send a specific code/message, raise MCPError; pydantic
ValidationError still maps to INVALID_PARAMS.
handler_exception_to_error_data is now total: it returns
MappedError(error: ErrorData, unexpected: bool) for every Exception.
Callers gate logger.exception / raise_handler_exceptions on the
unexpected flag rather than re-deriving the rung set, so a handler that
deliberately raises MCPError(code=INTERNAL_ERROR) is not treated as a
crash. JSONRPCDispatcher, DirectDispatcher, and the modern HTTP entry's
_to_jsonrpc_response all call the one helper; DirectDispatcher no longer
hand-rolls its own ladder.
The interaction suite's protocol:cancel:in-flight and
protocol:error:internal-error requirements drop their divergence entries
and modern-error-surface arm exclusions; the two prompt-validation
divergence notes are reworded for the new -32603 surface.
docs/migration.md gains a section covering both behaviour changes.
BearerAuthBackend / RequireAuthMiddleware now produce spec-conformant
challenges and reject tokens issued for a different resource server.
- A request with no credentials gets a bare `Bearer` challenge (with
scope/resource_metadata only), not error="invalid_token" -- RFC 6750
Section 3.1 says the error attribute SHOULD NOT appear when no
authentication information was presented.
- A malformed/unknown token, an expired token, or a token whose audience
does not match the configured resource_server_url is answered 401
invalid_token with a specific error_description, carried via a new
InvalidTokenUser marker so the middleware can distinguish it from
no-credentials.
- All challenges (401 and the 403 insufficient_scope path) now advertise
the required scopes in a `scope=` parameter, which the SDK client
already reads to drive step-up.
- New check_token_audience() helper canonicalises default ports before
comparing, and is wired through both the lowlevel and MCPServer
Starlette stacks via the auth settings' resource_server_url.
Docs and migration guide updated; the corresponding interaction-suite
divergence entries are now closed.