Align with spec #3002: optional clientInfo, serverInfo in result _meta (#3143)

This commit is contained in:
Max 2026-07-23 12:00:36 +01:00 • committed by GitHub
commit 837ef904f8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1757 additions and 567 deletions

View file

@ -12,6 +12,7 @@ from mcp_types._types import (
DEFAULT_NEGOTIATED_VERSION,
LOG_LEVEL_META_KEY,
PROTOCOL_VERSION_META_KEY,
SERVER_INFO_META_KEY,
Annotations,
AudioContent,
BaseMetadata,
@ -231,6 +232,8 @@ __all__ = [
"CLIENT_INFO_META_KEY",
"CLIENT_CAPABILITIES_META_KEY",
"LOG_LEVEL_META_KEY",
# Reserved result _meta keys
"SERVER_INFO_META_KEY",
# Type aliases and variables
"CORE_RESULT_TYPES",
"ContentBlock",

View file

@ -69,6 +69,13 @@ Deprecated (with the rest of logging) by SEP-2577 in the same revision that
introduces it. If absent, the server must not send log notifications.
"""
SERVER_INFO_META_KEY = "io.modelcontextprotocol/serverInfo"
"""Reserved result `_meta` key: the server `Implementation` (2026-07-28). SDK-managed.
Servers SHOULD stamp it on every result. The value is self-reported and
unverified - display, logging, and debugging only; never behavior or security.
"""
class RequestParamsMeta(TypedDict, extra_items=Any):
"""The `_meta` object on request params (schema name: `RequestMetaObject`).
@ -591,8 +598,6 @@ class DiscoverResult(CacheableResult):
capabilities: ServerCapabilities
server_info: Implementation
instructions: str | None = None
"""Natural-language guidance describing the server and its features, e.g. for
a system prompt. Should not duplicate information already in tool descriptions."""

View file

@ -1,7 +1,7 @@
"""Internal wire-shape models for protocol 2026-07-28. Generated; do not edit.
Regenerate with `scripts/gen_surface_types.py` from `schema/2026-07-28.json`
(sha256 `e00f675287e8cf078688c26c8a89d283ff2613da3b76d5cd15aff9d189df639c`)."""
(sha256 `6293cdfe015c14bd36eda4b1331ce37bda377609e58ed6d09d16f28e7d3c7ad4`)."""
# pyright: reportIncompatibleVariableOverride=false, reportGeneralTypeIssues=false
from __future__ import annotations
@ -602,28 +602,6 @@ class NumberSchema(WireModel):
type: Literal["integer", "number"]
class PaginatedResult(WireModel):
model_config = ConfigDict(
extra="ignore",
)
meta: Annotated[MetaObject | None, Field(alias="_meta")] = None
next_cursor: Annotated[str | None, Field(alias="nextCursor")] = None
"""
An opaque token representing the pagination position after the last returned result.
If present, there may be more results available.
"""
result_type: Annotated[str, Field(alias="resultType")]
"""
Indicates the type of the result, which allows the client to determine
how to parse the result object.
Servers implementing this protocol version MUST include this field.
For backward compatibility, when a client receives a result from a
server implementing an earlier protocol version (which does not include
`resultType`), the client MUST treat the absent field as `"complete"`.
"""
class ParseError(WireModel):
"""
A JSON-RPC error indicating that invalid JSON was received by the server. This error is returned when the server cannot parse the JSON text of a message.
@ -757,24 +735,27 @@ class ResourceTemplateReference(WireModel):
"""
class Result(WireModel):
class ResultMetaObject(WireModel):
"""
Common result fields.
Extends {@link MetaObject} with additional result-specific fields. All key naming rules from `MetaObject` apply.
"""
model_config = ConfigDict(
extra="allow",
)
meta: Annotated[MetaObject | None, Field(alias="_meta")] = None
result_type: Annotated[str, Field(alias="resultType")]
io_modelcontextprotocol_server_info: Annotated[Any | None, Field(alias="io.modelcontextprotocol/serverInfo")] = None
"""
Indicates the type of the result, which allows the client to determine
how to parse the result object.
Identifies the server software producing the response. Servers SHOULD
include this field on every response unless specifically configured not
to do so.
Servers implementing this protocol version MUST include this field.
For backward compatibility, when a client receives a result from a
server implementing an earlier protocol version (which does not include
`resultType`), the client MUST treat the absent field as `"complete"`.
The {@link Implementation} schema requires `name` and `version`; other
fields are optional.
The value is self-reported by the server and is not verified by the
protocol. It is intended for display, logging, and debugging. Clients
SHOULD NOT use it to change their behavior, and SHOULD NOT rely on it for
security decisions.
"""
@ -911,13 +892,27 @@ class SubscriptionFilter(WireModel):
class SubscriptionsListenResultMeta(WireModel):
"""
Extends {@link MetaObject} with the subscription-stream identifier carried by a
Extends {@link ResultMetaObject} with the subscription-stream identifier carried by a
{@link SubscriptionsListenResult}. All key naming rules from `MetaObject` apply.
"""
model_config = ConfigDict(
extra="allow",
)
io_modelcontextprotocol_server_info: Annotated[Any | None, Field(alias="io.modelcontextprotocol/serverInfo")] = None
"""
Identifies the server software producing the response. Servers SHOULD
include this field on every response unless specifically configured not
to do so.
The {@link Implementation} schema requires `name` and `version`; other
fields are optional.
The value is self-reported by the server and is not verified by the
protocol. It is intended for display, logging, and debugging. Clients
SHOULD NOT use it to change their behavior, and SHOULD NOT rely on it for
security decisions.
"""
io_modelcontextprotocol_subscription_id: Annotated[RequestId, Field(alias="io.modelcontextprotocol/subscriptionId")]
"""
Identifies the subscription stream this response closes, so the client can
@ -1401,7 +1396,7 @@ class CacheableResult(WireModel):
model_config = ConfigDict(
extra="ignore",
)
meta: Annotated[MetaObject | None, Field(alias="_meta")] = None
meta: Annotated[ResultMetaObject | None, Field(alias="_meta")] = None
cache_scope: Annotated[Literal["private", "public"], Field(alias="cacheScope")]
"""
Indicates the intended scope of the cached response, analogous to HTTP
@ -1438,13 +1433,6 @@ class CacheableResult(WireModel):
"""
class ClientResult(RootModel[Result]):
root: Result
"""
Common result fields.
"""
class CompleteResult(WireModel):
"""
The result returned by the server for a {@link CompleteRequestcompletion/complete} request.
@ -1453,7 +1441,7 @@ class CompleteResult(WireModel):
model_config = ConfigDict(
extra="ignore",
)
meta: Annotated[MetaObject | None, Field(alias="_meta")] = None
meta: Annotated[ResultMetaObject | None, Field(alias="_meta")] = None
completion: Completion
result_type: Annotated[str, Field(alias="resultType")]
"""
@ -1507,13 +1495,6 @@ class EmbeddedResource(WireModel):
type: Literal["resource"]
class EmptyResult(RootModel[Result]):
root: Result
"""
Common result fields.
"""
class EnumSchema(
RootModel[
UntitledSingleSelectEnumSchema
@ -1599,19 +1580,6 @@ class JSONRPCRequest(WireModel):
params: dict[str, Any] | None = None
class JSONRPCResultResponse(WireModel):
"""
A successful (non-error) response to a request.
"""
model_config = ConfigDict(
extra="ignore",
)
id: RequestId
jsonrpc: Literal["2.0"]
result: Result
class Params(WireModel):
model_config = ConfigDict(
extra="ignore",
@ -1690,6 +1658,28 @@ class NotificationParams(WireModel):
meta: Annotated[NotificationMetaObject | None, Field(alias="_meta")] = None
class PaginatedResult(WireModel):
model_config = ConfigDict(
extra="ignore",
)
meta: Annotated[ResultMetaObject | None, Field(alias="_meta")] = None
next_cursor: Annotated[str | None, Field(alias="nextCursor")] = None
"""
An opaque token representing the pagination position after the last returned result.
If present, there may be more results available.
"""
result_type: Annotated[str, Field(alias="resultType")]
"""
Indicates the type of the result, which allows the client to determine
how to parse the result object.
Servers implementing this protocol version MUST include this field.
For backward compatibility, when a client receives a result from a
server implementing an earlier protocol version (which does not include
`resultType`), the client MUST treat the absent field as `"complete"`.
"""
class PrimitiveSchemaDefinition(
RootModel[
StringSchema
@ -1810,7 +1800,7 @@ class ReadResourceResult(WireModel):
model_config = ConfigDict(
extra="ignore",
)
meta: Annotated[MetaObject | None, Field(alias="_meta")] = None
meta: Annotated[ResultMetaObject | None, Field(alias="_meta")] = None
cache_scope: Annotated[Literal["private", "public"], Field(alias="cacheScope")]
"""
Indicates the intended scope of the cached response, analogous to HTTP
@ -2053,6 +2043,27 @@ class ResourceUpdatedNotificationParams(WireModel):
"""
class Result(WireModel):
"""
Common result fields.
"""
model_config = ConfigDict(
extra="allow",
)
meta: Annotated[ResultMetaObject | None, Field(alias="_meta")] = None
result_type: Annotated[str, Field(alias="resultType")]
"""
Indicates the type of the result, which allows the client to determine
how to parse the result object.
Servers implementing this protocol version MUST include this field.
For backward compatibility, when a client receives a result from a
server implementing an earlier protocol version (which does not include
`resultType`), the client MUST treat the absent field as `"complete"`.
"""
class SingleSelectEnumSchema(RootModel[UntitledSingleSelectEnumSchema | TitledSingleSelectEnumSchema]):
root: UntitledSingleSelectEnumSchema | TitledSingleSelectEnumSchema
@ -2245,6 +2256,13 @@ class ClientNotification(WireModel):
params: CancelledNotificationParams
class ClientResult(RootModel[Result]):
root: Result
"""
Common result fields.
"""
class ContentBlock(RootModel[TextContent | ImageContent | AudioContent | ResourceLink | EmbeddedResource]):
root: TextContent | ImageContent | AudioContent | ResourceLink | EmbeddedResource
@ -2261,19 +2279,25 @@ class ElicitRequest(WireModel):
params: ElicitRequestParams
class JSONRPCMessage(RootModel[JSONRPCRequest | JSONRPCNotification | JSONRPCResultResponse | JSONRPCErrorResponse]):
root: JSONRPCRequest | JSONRPCNotification | JSONRPCResultResponse | JSONRPCErrorResponse
class EmptyResult(RootModel[Result]):
root: Result
"""
Refers to any valid JSON-RPC object that can be decoded off the wire, or encoded to be sent.
Common result fields.
"""
class JSONRPCResponse(RootModel[JSONRPCResultResponse | JSONRPCErrorResponse]):
root: JSONRPCResultResponse | JSONRPCErrorResponse
class JSONRPCResultResponse(WireModel):
"""
A response to a request, containing either the result or error.
A successful (non-error) response to a request.
"""
model_config = ConfigDict(
extra="ignore",
)
id: RequestId
jsonrpc: Literal["2.0"]
result: Result
class ListPromptsResult(WireModel):
"""
@ -2283,7 +2307,7 @@ class ListPromptsResult(WireModel):
model_config = ConfigDict(
extra="ignore",
)
meta: Annotated[MetaObject | None, Field(alias="_meta")] = None
meta: Annotated[ResultMetaObject | None, Field(alias="_meta")] = None
cache_scope: Annotated[Literal["private", "public"], Field(alias="cacheScope")]
"""
Indicates the intended scope of the cached response, analogous to HTTP
@ -2347,7 +2371,7 @@ class ListResourceTemplatesResult(WireModel):
model_config = ConfigDict(
extra="ignore",
)
meta: Annotated[MetaObject | None, Field(alias="_meta")] = None
meta: Annotated[ResultMetaObject | None, Field(alias="_meta")] = None
cache_scope: Annotated[Literal["private", "public"], Field(alias="cacheScope")]
"""
Indicates the intended scope of the cached response, analogous to HTTP
@ -2411,7 +2435,7 @@ class ListResourcesResult(WireModel):
model_config = ConfigDict(
extra="ignore",
)
meta: Annotated[MetaObject | None, Field(alias="_meta")] = None
meta: Annotated[ResultMetaObject | None, Field(alias="_meta")] = None
cache_scope: Annotated[Literal["private", "public"], Field(alias="cacheScope")]
"""
Indicates the intended scope of the cached response, analogous to HTTP
@ -2475,7 +2499,7 @@ class ListToolsResult(WireModel):
model_config = ConfigDict(
extra="ignore",
)
meta: Annotated[MetaObject | None, Field(alias="_meta")] = None
meta: Annotated[ResultMetaObject | None, Field(alias="_meta")] = None
cache_scope: Annotated[Literal["private", "public"], Field(alias="cacheScope")]
"""
Indicates the intended scope of the cached response, analogous to HTTP
@ -2597,10 +2621,16 @@ class ResourceUpdatedNotification(WireModel):
class SubscriptionsAcknowledgedNotification(WireModel):
"""
Sent by the server as the first message on a
{@link SubscriptionsListenRequestsubscriptions/listen} stream to acknowledge
that the subscription has been established and to report which notification
types it agreed to honor.
Sent by the server to acknowledge that a
{@link SubscriptionsListenRequestsubscriptions/listen} subscription has been
established and to report which notification types it agreed to honor.
This notification MUST be the first message the server sends carrying the
subscription's ID in `io.modelcontextprotocol/subscriptionId`. The server MUST
NOT send any notification on the subscription before acknowledging it. On
stdio, where every subscription shares one channel, this ordering is defined
per subscription ID and not per channel: messages belonging to other
subscriptions MAY be interleaved before it.
"""
model_config = ConfigDict(
@ -2662,7 +2692,7 @@ class CallToolResult(WireModel):
model_config = ConfigDict(
extra="ignore",
)
meta: Annotated[MetaObject | None, Field(alias="_meta")] = None
meta: Annotated[ResultMetaObject | None, Field(alias="_meta")] = None
content: list[ContentBlock]
"""
A list of content objects that represent the unstructured result of the tool call.
@ -2728,7 +2758,7 @@ class GetPromptResult(WireModel):
model_config = ConfigDict(
extra="ignore",
)
meta: Annotated[MetaObject | None, Field(alias="_meta")] = None
meta: Annotated[ResultMetaObject | None, Field(alias="_meta")] = None
description: str | None = None
"""
An optional description for the prompt.
@ -2746,6 +2776,20 @@ class GetPromptResult(WireModel):
"""
class JSONRPCMessage(RootModel[JSONRPCRequest | JSONRPCNotification | JSONRPCResultResponse | JSONRPCErrorResponse]):
root: JSONRPCRequest | JSONRPCNotification | JSONRPCResultResponse | JSONRPCErrorResponse
"""
Refers to any valid JSON-RPC object that can be decoded off the wire, or encoded to be sent.
"""
class JSONRPCResponse(RootModel[JSONRPCResultResponse | JSONRPCErrorResponse]):
root: JSONRPCResultResponse | JSONRPCErrorResponse
"""
A response to a request, containing either the result or error.
"""
class LoggingMessageNotification(WireModel):
"""
JSONRPCNotification of a log message passed from server to client. The client opts in by setting `"io.modelcontextprotocol/logLevel"` in a request's `_meta`.
@ -3100,7 +3144,7 @@ class DiscoverResult(WireModel):
model_config = ConfigDict(
extra="ignore",
)
meta: Annotated[MetaObject | None, Field(alias="_meta")] = None
meta: Annotated[ResultMetaObject | None, Field(alias="_meta")] = None
cache_scope: Annotated[Literal["private", "public"], Field(alias="cacheScope")]
"""
Indicates the intended scope of the cached response, analogous to HTTP
@ -3137,10 +3181,6 @@ class DiscoverResult(WireModel):
server implementing an earlier protocol version (which does not include
`resultType`), the client MUST treat the absent field as `"complete"`.
"""
server_info: Annotated[Implementation, Field(alias="serverInfo")]
"""
Information about the server software implementation.
"""
supported_versions: Annotated[list[str], Field(alias="supportedVersions")]
"""
MCP Protocol Versions this server supports. The client should choose a
@ -3231,7 +3271,7 @@ class InputRequiredResult(WireModel):
model_config = ConfigDict(
extra="ignore",
)
meta: Annotated[MetaObject | None, Field(alias="_meta")] = None
meta: Annotated[ResultMetaObject | None, Field(alias="_meta")] = None
input_requests: Annotated[InputRequests | None, Field(alias="inputRequests")] = None
request_state: Annotated[str | None, Field(alias="requestState")] = None
result_type: Annotated[str, Field(alias="resultType")]
@ -3442,12 +3482,21 @@ class RequestMetaObject(WireModel):
an empty object means the client supports no optional capabilities.
Servers MUST NOT infer capabilities from prior requests.
"""
io_modelcontextprotocol_client_info: Annotated[Implementation, Field(alias="io.modelcontextprotocol/clientInfo")]
io_modelcontextprotocol_client_info: Annotated[
Implementation | None, Field(alias="io.modelcontextprotocol/clientInfo")
] = None
"""
Identifies the client software making the request. Required.
Identifies the client software making the request. Clients SHOULD
include this field on every request unless specifically configured not
to do so.
The {@link Implementation} schema requires `name` and `version`; other
fields are optional.
The value is self-reported by the client and is not verified by the
protocol. It is intended for display, logging, and debugging. Servers
SHOULD NOT use it to change their behavior, and SHOULD NOT rely on it for
security decisions.
"""
io_modelcontextprotocol_log_level: Annotated[
LoggingLevel | None, Field(alias="io.modelcontextprotocol/logLevel")

View file

@ -9,8 +9,13 @@ Every ``MCPError`` falls back except ``-32022`` with a disjoint modern-only
``supported`` list. The streamable-HTTP transport already maps HTTP-layer
4xx rejections (no JSON-RPC body) into ``MCPError`` codes, so those reach
the same path. Any non-``MCPError`` exception (network/connection errors,
anyio cancellation, the ``RuntimeError`` from ``adopt()`` on no-mutual)
propagates to the caller; an outage or in-process bug is never an era verdict.
anyio cancellation) propagates to the caller; an outage or in-process bug
is never an era verdict.
A successful ``DiscoverResult`` whose ``supportedVersions`` shares no modern
version with this client is treated the same way: the server speaks discover
but advertises only handshake-era versions, which is a legacy advertisement,
not an incompatibility.
The fallback handshake itself can be answered with ``-32022`` — e.g. a probe
that timed out client-side but succeeded on a slow-starting server locked the
@ -89,13 +94,21 @@ async def negotiate_auto(session: ClientSession) -> None:
version = mutual[-1]
continue
return
# any other exception (httpx2.TransportError, ConnectionError, anyio errors,
# RuntimeError from adopt) → propagate
# any other exception (httpx2.TransportError, ConnectionError,
# anyio errors) → propagate
try:
result = types.DiscoverResult.model_validate(raw)
except ValidationError:
await session.initialize() # unparseable result → not modern evidence
return
if not any(v in result.supported_versions for v in MODERN_PROTOCOL_VERSIONS):
# A discover-answering server that advertises no modern version
# (go-sdk's stateful streamable default does this) is an explicit
# legacy advertisement: fall back like the -32022 branch above
# instead of letting `adopt()` raise. The ts and go clients fall
# back here too.
await session.initialize()
return
session.adopt(result)
return
raise AssertionError("unreachable") # pragma: no cover — loop body always returns or raises

View file

@ -176,7 +176,6 @@ def _synthesize_discover(protocol_version: str) -> types.DiscoverResult:
return types.DiscoverResult(
supported_versions=[protocol_version],
capabilities=types.ServerCapabilities(),
server_info=types.Implementation(name="", version=""),
result_type="complete",
ttl_ms=0,
cache_scope="public",
@ -453,7 +452,8 @@ class Client:
session.adopt(self.prior_discover or _synthesize_discover(self.mode))
# Only publish the session after the handshake succeeds, so `_session is not None`
# implies the protocol_version/server_info/server_capabilities are populated. If the
# implies the protocol_version/server_capabilities are populated (server_info
# stays optional: 2026-era servers may not identify themselves). If the
# handshake raised above, the local exit_stack unwinds the transport for us.
self._session = session
self._exit_stack = exit_stack.pop_all()
@ -479,18 +479,24 @@ class Client:
return self._session
# TODO(maxisbey): the by-construction shape is for __aenter__ to return a connected-view
# type whose protocol_version/server_info/server_capabilities are non-Optional fields,
# type whose protocol_version/server_capabilities are non-Optional fields,
# eliminating these guards (and the one in .session). Same family as resolving the
# transport/connector at __post_init__ so the Optional internal fields disappear.
# (server_info stays Optional even connected: the 2026-era stamp is optional.)
@property
def protocol_version(self) -> str:
"""Negotiated protocol version (set by initialize/discover/adopt during ``__aenter__``)."""
return _connected(self.session.protocol_version)
@property
def server_info(self) -> Implementation:
"""Server name/version (set by initialize/discover/adopt during ``__aenter__``)."""
return _connected(self.session.server_info)
def server_info(self) -> Implementation | None:
"""Server name/version, or `None` when the server did not identify itself.
Legacy connections always carry it (`InitializeResult.serverInfo` is
required); on 2026-era connections the `_meta` `serverInfo` stamp is
optional, so an anonymous server reads as `None`.
"""
return self.session.server_info
@property
def server_capabilities(self) -> ServerCapabilities:

View file

@ -20,6 +20,7 @@ from mcp_types import (
INTERNAL_ERROR,
METHOD_NOT_FOUND,
PROTOCOL_VERSION_META_KEY,
SERVER_INFO_META_KEY,
UNSUPPORTED_PROTOCOL_VERSION,
RequestId,
RequestParamsMeta,
@ -77,6 +78,21 @@ def _preconnect_stamp(data: dict[str, Any], opts: CallOptions) -> None:
opts["cancel_on_abandon"] = False
def _parse_server_info_stamp(result: types.DiscoverResult) -> types.Implementation | None:
"""The typed identity from a discover result's `_meta` serverInfo stamp.
The stamp is display-only per the spec, so absent and malformed both read
as `None` rather than failing the connection.
"""
raw = (result.meta or {}).get(SERVER_INFO_META_KEY)
if raw is None:
return None
try:
return types.Implementation.model_validate(raw)
except ValidationError:
return None
def _make_handshake_stamp(protocol_version: str) -> Callable[[dict[str, Any], CallOptions], None]:
def stamp(data: dict[str, Any], opts: CallOptions) -> None:
opts.setdefault("headers", {})[MCP_PROTOCOL_VERSION_HEADER] = protocol_version
@ -360,6 +376,7 @@ class ClientSession:
self._x_mcp_header_maps: dict[str, dict[tuple[str, ...], str]] = {}
self._initialize_result: types.InitializeResult | None = None
self._discover_result: types.DiscoverResult | None = None
self._discover_server_info: types.Implementation | None = None
self._negotiated_version: str | None = None
self._stamp: Callable[[dict[str, Any], CallOptions], None] = _preconnect_stamp
self._task_group: anyio.abc.TaskGroup | None = None
@ -605,12 +622,14 @@ class ClientSession:
capabilities = self._build_capabilities(version).model_dump(by_alias=True, mode="json", exclude_none=True)
self._stamp = _make_modern_stamp(version, client_info, capabilities, self._resolve_param_headers)
self._discover_result = result
self._discover_server_info = _parse_server_info_stamp(result)
self._initialize_result = None
else:
version = result.protocol_version
self._stamp = _make_handshake_stamp(version)
self._initialize_result = result
self._discover_result = None
self._discover_server_info = None
self._negotiated_version = version
# Both arms reach here, so re-adoption resets cleanly; legacy versions activate no claims.
# Core-vocabulary tags are unconstructible (ResultClaim.__post_init__), so no exclusion needed.
@ -719,9 +738,15 @@ class ClientSession:
@property
def server_info(self) -> types.Implementation | None:
"""Server name/version. None until `initialize()`, `discover()`, or `adopt()`."""
"""Server name/version. None until `initialize()`, `discover()`, or `adopt()`.
On 2026-era connections this is the discover result's optional `_meta`
`serverInfo` stamp, parsed once at adopt time; `None` when the server
did not identify itself. The stamp is display-only per the spec, so a
malformed value reads as absent rather than failing the connection.
"""
if self._discover_result is not None:
return self._discover_result.server_info
return self._discover_server_info
if self._initialize_result is not None:
return self._initialize_result.server_info
return None

View file

@ -218,9 +218,11 @@ async def _tool_input_schema(
"""
meta = {
PROTOCOL_VERSION_META_KEY: verdict.protocol_version,
CLIENT_INFO_META_KEY: verdict.client_info,
CLIENT_CAPABILITIES_META_KEY: verdict.client_capabilities,
}
if verdict.client_info is not None:
# Optional key: a conforming pair-only caller omits it rather than sending null.
meta[CLIENT_INFO_META_KEY] = verdict.client_info
list_params: dict[str, Any] = {"_meta": meta}
try:
_methods.validate_client_request("tools/list", verdict.protocol_version, list_params)

View file

@ -233,8 +233,7 @@ def client_supports_apps(ctx: Context[Any] | ServerRequestContext[Any, Any]) ->
def _client_capabilities(ctx: Context[Any] | ServerRequestContext[Any, Any]) -> Any:
if isinstance(ctx, Context):
return ctx.client_capabilities
client_params = ctx.session.client_params
return client_params.capabilities if client_params else None
return ctx.session.client_capabilities
def _require_ui_scheme(uri: str) -> None:

View file

@ -147,9 +147,13 @@ class Connection:
session_id: str | None
client_params: InitializeRequestParams | None
"""The full `initialize` request params, or the equivalent built from the
2026-era envelope. `None` when no client info was supplied."""
client_capabilities: ClientCapabilities | None
"""The capabilities the peer declared: the handshake's on the loop path,
the request envelope's on the modern path. `None` when none were declared.
Kept in lockstep with `client_params` by its setter, and settable on its
own for the modern envelope, where capabilities are required but client
info is optional (spec PR #3002) - capability checks must not depend on the
peer having identified itself."""
protocol_version: str
"""The protocol version this connection speaks. Populated at construction
@ -180,11 +184,29 @@ class Connection:
self.outbound = outbound
self.protocol_version = protocol_version
self.session_id = session_id
self.client_capabilities = None
self.client_params = client_params
self.initialized = anyio.Event()
self.state = {}
self.exit_stack = AsyncExitStack()
@property
def client_params(self) -> InitializeRequestParams | None:
"""The full `initialize` request params, or the equivalent built from the
2026-era envelope. `None` when no client info was supplied."""
return self._client_params
@client_params.setter
def client_params(self, value: InitializeRequestParams | None) -> None:
# Assignment is the sync point: recording full client params (the
# handshake commit, or a modern envelope carrying client info) also
# records the capabilities fact, so the two can never drift. Clearing
# to `None` leaves `client_capabilities` alone - the modern envelope
# declares capabilities without client info.
self._client_params = value
if value is not None:
self.client_capabilities = value.capabilities
@classmethod
def from_envelope(
cls,
@ -201,13 +223,15 @@ class Connection:
values. `client_info` and `client_capabilities` are the raw envelope
values: this constructor owns turning them into connection identity,
identically on every modern entry, so a mis-shaped value degrades to
not-supplied rather than failing the request. `initialized`
is set and the info/capabilities (when both supplied and well-formed)
are recorded as `client_params` so capability checks work. `outbound`
defaults to the no-channel sentinel for the single-exchange HTTP path;
duplex modern transports (e.g. stdio) pass a notify-only wrapper
around the dispatcher so server notifications ride the pipe while
server-initiated requests stay refused.
not-supplied rather than failing the request. `initialized` is set,
well-formed capabilities are recorded as `client_capabilities` (client
info is optional per spec PR #3002, so capability checks never depend on
it), and the full `client_params` is additionally synthesized when
client info was supplied too. `outbound` defaults to the no-channel
sentinel for the single-exchange HTTP path; duplex modern transports
(e.g. stdio) pass a notify-only wrapper around the dispatcher so
server notifications ride the pipe while server-initiated requests
stay refused.
"""
info = _typed(Implementation, client_info)
capabilities = _typed(ClientCapabilities, client_capabilities)
@ -219,6 +243,7 @@ class Connection:
client_info=info,
)
connection = cls(outbound, protocol_version=protocol_version, client_params=client_params)
connection.client_capabilities = capabilities
connection.initialized.set()
return connection
@ -369,13 +394,13 @@ class Connection:
def check_capability(self, capability: ClientCapabilities) -> bool:
"""Return whether the connected client declared the given capability.
Returns `False` when no client info has been recorded.
Returns `False` when no capabilities have been recorded.
"""
# TODO(L53): redesign - mirrors v1 ServerSession.check_client_capability
# verbatim for parity.
if self.client_params is None:
if self.client_capabilities is None:
return False
have = self.client_params.capabilities
have = self.client_capabilities
if capability.roots is not None:
if have.roots is None:
return False

View file

@ -116,8 +116,11 @@ HandlerResult = BaseModel | dict[str, Any] | None
all three to a result dict."""
CallNext = Callable[["ServerRequestContext[Any, Any]"], Awaitable[HandlerResult]]
"""Invokes the rest of the chain. Pass the `ctx` through; rewrite `method` or
`params` with `dataclasses.replace(ctx, ...)` to alter what the handler sees."""
"""Invokes the rest of the chain with the given context. What a context
rewrite (`dataclasses.replace(ctx, ...)`) can alter depends on the tier:
`ServerMiddleware` runs before params validation, so its rewrites change what
the handler is invoked with; an `Extension` interceptor runs after, so its
rewrites change only what the handler observes on `ctx`."""
_MwLifespanT = TypeVar("_MwLifespanT")

View file

@ -27,7 +27,7 @@ from mcp_types import CallToolRequestParams
from mcp_types.methods import SPEC_CLIENT_METHODS
from pydantic import BaseModel
from mcp.server.context import CallNext, HandlerResult, ServerMiddleware, ServerRequestContext
from mcp.server.context import CallNext, HandlerResult, ServerRequestContext
# Re-exported from `mcp.shared.extension` (shared with the client surface) for existing importers.
from mcp.shared.extension import validate_extension_identifier as validate_extension_identifier
@ -144,30 +144,38 @@ class Extension:
Override to short-circuit (return a result without calling `call_next`)
or to observe the call. `params` is the validated `tools/call` params;
`call_next(ctx)` runs the rest of the chain and the real handler.
`call_next(ctx)` runs the rest of the chain and the real handler, and
returns the handler's domain result. Interceptors run at the handler
layer: whatever they return is serialized like any handler result,
including the 2026-era `serverInfo` `_meta` stamp. The `params` this
interceptor received is what the wrapped handler is invoked with -
passing a rewritten context through `call_next` adjusts what the
handler observes on `ctx`, not the tool invocation. Wire-level request
rewriting belongs to `Server.middleware`, above params validation.
"""
return await call_next(ctx)
def compose_tool_call_interceptor(extensions: Sequence[Extension]) -> ServerMiddleware[Any]:
"""Fold every extension's `intercept_tool_call` into one `ServerMiddleware`.
def compose_tool_call_handler(extensions: Sequence[Extension], handler: RequestHandler) -> RequestHandler:
"""Fold every extension's `intercept_tool_call` around the `tools/call` handler.
The returned middleware nests the interceptors (first extension outermost)
and is a no-op for any method other than `tools/call`. It validates the
`tools/call` params once and threads them to each interceptor.
The returned handler nests the interceptors (first extension outermost) and
replaces the plain `tools/call` registration. Interception happens at the
handler layer, below the runner's outbound envelope pass, so a
short-circuiting interceptor's result is sieved and stamped exactly like
the wrapped handler's would be.
"""
async def middleware(ctx: ServerRequestContext[Any, Any], call_next: CallNext) -> HandlerResult:
if ctx.method != "tools/call":
return await call_next(ctx)
params = CallToolRequestParams.model_validate({} if ctx.params is None else ctx.params, by_name=False)
async def wrapped(ctx: ServerRequestContext[Any, Any], params: CallToolRequestParams) -> HandlerResult:
async def innermost(inner_ctx: ServerRequestContext[Any, Any]) -> HandlerResult:
return await handler(inner_ctx, params)
chain = call_next
chain: CallNext = innermost
for extension in reversed(extensions):
chain = _bind_interceptor(extension, params, chain)
return await chain(ctx)
return middleware
return wrapped
def _bind_interceptor(extension: Extension, params: CallToolRequestParams, call_next: CallNext) -> CallNext:

View file

@ -36,12 +36,13 @@ handler callables by method string.
from __future__ import annotations
import copy
import logging
import warnings
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping
from contextlib import AbstractAsyncContextManager, asynccontextmanager
from dataclasses import dataclass
from importlib.metadata import version as importlib_version
from functools import cached_property
from typing import Any, Generic, overload
import mcp_types as types
@ -124,22 +125,13 @@ async def _ping_handler(ctx: ServerRequestContext[Any], params: types.RequestPar
return types.EmptyResult()
def _package_version(package: str) -> str:
try:
return importlib_version(package)
except Exception: # pragma: no cover
pass
return "unknown" # pragma: no cover
class Server(Generic[LifespanResultT]):
@overload
def __init__(
self,
name: str,
*,
version: str | None = None,
version: str = "",
title: str | None = None,
description: str | None = None,
instructions: str | None = None,
@ -222,7 +214,7 @@ class Server(Generic[LifespanResultT]):
self,
name: str,
*,
version: str | None = None,
version: str = "",
title: str | None = None,
description: str | None = None,
instructions: str | None = None,
@ -314,7 +306,7 @@ class Server(Generic[LifespanResultT]):
self,
name: str,
*,
version: str | None = None,
version: str = "",
title: str | None = None,
description: str | None = None,
instructions: str | None = None,
@ -547,7 +539,7 @@ class Server(Generic[LifespanResultT]):
"""
return InitializationOptions(
server_name=self.name,
server_version=self.version if self.version else _package_version("mcp"),
server_version=self.version,
title=self.title,
description=self.description,
capabilities=self.get_capabilities(
@ -636,18 +628,35 @@ class Server(Generic[LifespanResultT]):
def server_info(self) -> types.Implementation:
"""The `serverInfo` block describing this implementation.
Derived from the constructor's identity fields. `version` falls back to
the installed `mcp` package version when not supplied explicitly.
Derived from the constructor's identity fields. An unversioned server
reports an empty `version`; the SDK never substitutes its own.
"""
return types.Implementation(
name=self.name,
version=self.version if self.version else _package_version("mcp"),
version=self.version,
title=self.title,
description=self.description,
website_url=self.website_url,
icons=self.icons,
)
@cached_property
def _server_info_stamp_source(self) -> dict[str, Any]:
# Identity is fixed at construction, so the dump is computed once per
# server instead of per request. Never handed out directly: nested
# values (`icons`) would alias the cache into stamped responses.
return self.server_info.model_dump(by_alias=True, mode="json", exclude_none=True)
@property
def server_info_stamp(self) -> dict[str, Any]:
"""A fresh wire dump of `server_info`; callers own the returned dict.
Each access materializes a deep copy of the once-per-server dump, so
a caller mutating a stamped response can never corrupt the identity
stamped into later responses.
"""
return copy.deepcopy(self._server_info_stamp_source)
async def _handle_discover(
self, ctx: ServerRequestContext[LifespanResultT], params: types.RequestParams | None
) -> types.DiscoverResult:
@ -662,7 +671,6 @@ class Server(Generic[LifespanResultT]):
return types.DiscoverResult(
supported_versions=list(MODERN_PROTOCOL_VERSIONS),
capabilities=self.get_capabilities(protocol_version=ctx.protocol_version),
server_info=self.server_info,
instructions=self.instructions,
)

View file

@ -326,11 +326,11 @@ class Context(BaseModel, Generic[LifespanContextT, RequestT]):
def client_capabilities(self) -> ClientCapabilities | None:
"""The client's declared capabilities for this connection.
`None` when the client supplied no client info (e.g. an anonymous
stateless request without the reserved `_meta` keys).
`None` when the client declared none (e.g. an anonymous stateless
request without the reserved `_meta` keys). Client info is not
required for capabilities to be recorded.
"""
client_params = self.request_context.session.client_params
return client_params.capabilities if client_params else None
return self.request_context.session.client_capabilities
@property
def session(self):

View file

@ -64,7 +64,7 @@ from mcp.server.extension import (
Extension,
MethodBinding,
RequestHandler,
compose_tool_call_interceptor,
compose_tool_call_handler,
validate_extension_identifier,
)
from mcp.server.lowlevel.helper_types import ReadResourceContents
@ -165,7 +165,7 @@ class MCPServer(Generic[LifespanResultT]):
instructions: str | None = None,
website_url: str | None = None,
icons: list[Icon] | None = None,
version: str | None = None,
version: str = "",
auth_server_provider: OAuthAuthorizationServerProvider[Any, Any, Any] | None = None,
token_verifier: TokenVerifier | None = None,
*,
@ -228,8 +228,9 @@ class MCPServer(Generic[LifespanResultT]):
# We need to create a Lifespan type that is a generic on the server type, like Starlette does.
lifespan=(lifespan_wrapper(self, self.settings.lifespan) if self.settings.lifespan else default_lifespan), # type: ignore
)
# Ordering: inside OpenTelemetry (spans record the sealed wire form),
# outside extension interceptors (extensions see plaintext).
# Ordering: inside OpenTelemetry (spans record the sealed wire form).
# Extension interceptors run at the handler layer, inside this
# boundary, so they see plaintext.
if request_state_security is None:
security = RequestStateSecurity.ephemeral()
else:
@ -289,7 +290,7 @@ class MCPServer(Generic[LifespanResultT]):
return self._lowlevel_server.icons
@property
def version(self) -> str | None:
def version(self) -> str:
return self._lowlevel_server.version
@property
@ -334,13 +335,20 @@ class MCPServer(Generic[LifespanResultT]):
self._lowlevel_server.extensions[extension.identifier] = extension.settings()
def _install_extension_interceptor(self) -> None:
"""Compose every extension's `tools/call` interceptor into one middleware.
"""Wrap the `tools/call` handler with every extension's interceptor.
Installed only when at least one extension overrides `intercept_tool_call`,
so a server with purely additive extensions adds no middleware.
so a server with purely additive extensions keeps the bare handler. The
chain wraps the handler itself, below the runner's outbound envelope
pass, so a short-circuiting interceptor's result is sieved and stamped
exactly like a handler result.
"""
if any(type(e).intercept_tool_call is not Extension.intercept_tool_call for e in self._extensions):
self._lowlevel_server.middleware.append(compose_tool_call_interceptor(self._extensions))
self._lowlevel_server.add_request_handler(
"tools/call",
CallToolRequestParams,
compose_tool_call_handler(self._extensions, self._handle_call_tool),
)
@overload
def run(self, transport: Literal["stdio"] = ...) -> None: ...
@ -1320,8 +1328,8 @@ def require_client_extension(ctx: ServerRequestContext[Any, Any], identifier: st
MCPError: With code `MISSING_REQUIRED_CLIENT_CAPABILITY` if the client
did not advertise `identifier`.
"""
client_params = ctx.session.client_params
declared = client_params.capabilities.extensions if client_params else None
capabilities = ctx.session.client_capabilities
declared = capabilities.extensions if capabilities else None
if not declared or identifier not in declared:
data = MissingRequiredClientCapabilityErrorData(
required_capabilities=ClientCapabilities(extensions={identifier: {}})

View file

@ -24,11 +24,13 @@ import anyio.abc
from mcp_types import (
CLIENT_CAPABILITIES_META_KEY,
CLIENT_INFO_META_KEY,
CORE_RESULT_TYPES,
INTERNAL_ERROR,
INVALID_PARAMS,
INVALID_REQUEST,
METHOD_NOT_FOUND,
PROTOCOL_VERSION_META_KEY,
SERVER_INFO_META_KEY,
UNSUPPORTED_PROTOCOL_VERSION,
CacheableResult,
ErrorData,
@ -111,7 +113,10 @@ def _dump_result(result: Any) -> dict[str, Any]:
if isinstance(result, BaseModel):
return result.model_dump(by_alias=True, mode="json", exclude_none=True)
if isinstance(result, dict):
return cast(dict[str, Any], result)
# Copied so callers own the returned dict: handlers and middleware may
# retain the object they returned, and the outbound pipeline shapes the
# wire form without reaching into anything the handler still holds.
return dict(cast(dict[str, Any], result))
raise TypeError(f"handler returned {type(result).__name__}; expected BaseModel, dict, or None")
@ -209,22 +214,15 @@ class ServerRunner(Generic[LifespanT]):
if isinstance(result, ErrorData):
# Raise inside the chain so middleware observes the failure.
raise MCPError.from_error_data(result)
# Fill cache hints on the handler result, before the serialize sieve
# decides whether the negotiated version carries the fields at all.
# MRTR carve-out: `input_required` interim results, typed or mapping, never get hints.
if (hint := self.server.cache_hints.get(method)) is not None:
if isinstance(result, CacheableResult):
result = apply_cache_hint(result, hint)
elif isinstance(result, Mapping) and not _methods.is_input_required(result):
# Hint keys first so wire keys the handler set win, matching `apply_cache_hint` precedence.
result = {"ttlMs": hint.ttl_ms, "cacheScope": hint.scope, **result}
# Dump and serialize inside the chain so the OpenTelemetry span (the
# Shape for the wire inside the chain so the OpenTelemetry span (the
# outermost middleware) records a failing handler return shape too.
return self._serialize(method, version, result)
call = self._compose_server_middleware(_inner)
# `_inner` already produced the wire dict; a middleware that short-circuited
# without `call_next` is trusted to return its own well-formed result.
# without `call_next` is trusted to return its own well-formed result -
# including its response envelope. The pipeline never patches it up after
# the fact.
result = _dump_result(await call(ctx))
if method == "initialize":
# Commit only on chain success, so a middleware veto leaves no state.
@ -333,27 +331,84 @@ class ServerRunner(Generic[LifespanT]):
close_standalone_sse_stream=close_standalone_sse_stream,
)
@staticmethod
def _serialize(method: str, version: str, result: HandlerResult) -> dict[str, Any]:
"""Dump a handler result to the wire dict, serializing spec methods.
def _serialize(self, method: str, version: str, result: HandlerResult) -> dict[str, Any]:
"""Shape a handler result into its wire form: the outbound counterpart
of the inbound classification ladder.
Runs inside the middleware chain so the OpenTelemetry span observes a
failing return shape (unsupported type, malformed spec result) as an
error rather than closing on a request that the client sees fail.
One pass owns the whole response envelope, in order: cache hints fill
`ttlMs`/`cacheScope` the handler left unset, core-vocabulary spec-method
results are validated and sieved by the per-version surface (a claimed
extension `resultType` shape is the extension's to own), and 2026-era
results get the `serverInfo` `_meta` stamp (spec #3002). Runs inside the
middleware chain so the OpenTelemetry span observes a failing return
shape (unsupported type, malformed spec result) as an error rather
than closing on a request that the client sees fail - and so a
middleware that short-circuits without `call_next` owns its result,
envelope included.
"""
# MRTR carve-out: `input_required` interim results, typed or mapping, never get hints.
if (hint := self.server.cache_hints.get(method)) is not None:
if isinstance(result, CacheableResult):
result = apply_cache_hint(result, hint)
elif isinstance(result, Mapping) and not _methods.is_input_required(result):
# Hint keys first so wire keys the handler set win, matching `apply_cache_hint` precedence.
result = {"ttlMs": hint.ttl_ms, "cacheScope": hint.scope, **result}
dumped = _dump_result(result)
# TODO(L56): reject resultType values outside {"complete", "input_required"} unless the
# corresponding extension is in this request's _meta clientCapabilities.extensions; the
# A modern-era extension `resultType` (outside the core vocabulary) marks
# a claimed shape owned by the extension that defined it: the per-version
# surface doesn't describe it, so the sieve applies to core results only.
# Legacy connections sieve everything - claimed shapes are 2026-era
# vocabulary and cannot be delivered on a legacy wire (mirrors the
# client-side ResultClaim rule).
# TODO(L56): reject extension resultType values unless the corresponding
# extension is in this request's _meta clientCapabilities.extensions; the
# explicit MUST-reject is client-side (basic/index.mdx ResultType), this enforces it proactively.
if method not in _methods.SPEC_CLIENT_METHODS:
return dumped
try:
return _methods.serialize_server_result(method, version, dumped)
except ValidationError:
# Server bug, not client fault. Detail stays in the server log:
# pydantic messages echo the result body.
logger.exception("handler for %r returned an invalid result", method)
raise MCPError(code=INTERNAL_ERROR, message="Handler returned an invalid result") from None
result_type = dumped.get("resultType")
core_shape = (
version not in MODERN_PROTOCOL_VERSIONS
or not isinstance(result_type, str)
or result_type in CORE_RESULT_TYPES
)
if method in _methods.SPEC_CLIENT_METHODS and core_shape:
try:
dumped = _methods.serialize_server_result(method, version, dumped)
except ValidationError:
# Server bug, not client fault. Detail stays in the server log:
# pydantic messages echo the result body.
logger.exception("handler for %r returned an invalid result", method)
raise MCPError(code=INTERNAL_ERROR, message="Handler returned an invalid result") from None
if version in MODERN_PROTOCOL_VERSIONS and dumped.get("resultType") is None:
# Spec 2026-07-28: `Result.resultType` is required - servers MUST
# include it (the absent-means-complete bridge is for clients of
# older servers only). The sieve guarantees it for core methods;
# this covers everything else: custom methods, extension methods,
# and empty results.
dumped["resultType"] = "complete"
return self._stamp_server_info(version, dumped)
def _stamp_server_info(self, version: str, result: dict[str, Any]) -> dict[str, Any]:
"""Fill the `serverInfo` `_meta` stamp on a 2026-era result (spec #3002).
A handler-authored value wins; an explicit `null` reads as absent and
is stamped over, mirroring the request-side `clientInfo` posture (a
`null` is not a valid `Implementation`, so presence means a value). A
non-mapping `_meta` is the handler's to own, and handshake-era results
are never stamped. `result` is
pipeline-owned (`_dump_result` copies dicts; the spec-method sieve
re-dumps), but `_meta` may still be the handler's object, so the stamp
replaces it rather than writing into it. `server_info_stamp` is a
fresh dict per access, so the response never aliases server state.
"""
if version not in MODERN_PROTOCOL_VERSIONS:
return result
raw_meta = result.get("_meta")
if raw_meta is None:
result["_meta"] = {SERVER_INFO_META_KEY: self.server.server_info_stamp}
elif isinstance(raw_meta, dict):
meta = cast("dict[str, Any]", raw_meta)
if meta.get(SERVER_INFO_META_KEY) is None:
result["_meta"] = {**meta, SERVER_INFO_META_KEY: self.server.server_info_stamp}
return result
@staticmethod
def _negotiate_initialize(params: Mapping[str, Any] | None) -> tuple[InitializeRequestParams, str]:
@ -439,19 +494,23 @@ async def serve_loop(
)
_MODERN_ENVELOPE_KEYS = (PROTOCOL_VERSION_META_KEY, CLIENT_INFO_META_KEY, CLIENT_CAPABILITIES_META_KEY)
def _has_modern_envelope(params: Mapping[str, Any] | None) -> bool:
"""Whether `params._meta` carries every reserved modern-envelope key.
"""Whether `params._meta` carries the reserved protocol-version key.
Era evidence is the FULL key triple - bare `_meta` is not (legacy traffic
carries `progressToken` there).
Era evidence is the client's explicit version declaration: the
`io.modelcontextprotocol/protocolVersion` key exists only in 2026-07-28+
envelopes, and the `io.modelcontextprotocol/` prefix is spec-reserved, so
legacy traffic never mints it (bare `_meta` is NOT evidence - legacy
requests carry `progressToken` there). Presence of the version key alone
is the rule, not the full required pair, so a half-built envelope
(version present, capabilities missing) still routes modern and gets the
classifier's INVALID_PARAMS naming the missing key instead of the legacy
path's generic one - and, like every failed classification, locks no era.
"""
if not params:
return False
meta = params.get("_meta")
return isinstance(meta, Mapping) and all(key in meta for key in _MODERN_ENVELOPE_KEYS)
return isinstance(meta, Mapping) and PROTOCOL_VERSION_META_KEY in meta
def _initialize_after_modern_data(params: Mapping[str, Any] | None) -> dict[str, Any]:
@ -557,8 +616,8 @@ async def serve_dual_era_loop(
like `serve_loop` for its lifetime, and modern envelope traffic is then
rejected with INVALID_REQUEST. `initialize` never routes modern - the
method is legacy-distinctive by definition - even when a confused
client stamps the envelope triple on it.
- A request carrying the modern `_meta` envelope triple - or
client stamps the envelope keys on it.
- A request whose `_meta` declares the modern protocol version - or
`server/discover`, a modern-only method - is classified
(`classify_inbound_request`) and served single-exchange via `serve_one`
with a born-ready per-request `Connection`, the same dispatch model as
@ -676,7 +735,7 @@ async def serve_dual_era_loop(
return await serve_modern(dctx, method, params)
# Unlocked. `initialize` is legacy-distinctive by definition (the
# method does not exist at modern versions), so it takes the handshake
# path even when the envelope triple is stamped on it.
# path even when the envelope keys are stamped on it.
if method != "initialize" and (method == "server/discover" or _has_modern_envelope(params)):
return await serve_modern(dctx, method, params)
result = await loop_runner.on_request(dctx, method, params)

View file

@ -45,6 +45,16 @@ class ServerSession:
"""The client's `initialize` request params; `None` when no client info was supplied."""
return self._connection.client_params
@property
def client_capabilities(self) -> types.ClientCapabilities | None:
"""The capabilities the client declared; `None` when none were declared.
Prefer this over `client_params.capabilities`: on 2026-07-28+ the
request envelope declares capabilities while client info stays
optional, so capabilities can be present without `client_params`.
"""
return self._connection.client_capabilities
@property
def can_send_request(self) -> bool:
"""Whether this request's channel can currently deliver a server-initiated request."""
@ -236,8 +246,7 @@ class ServerSession:
NoBackChannelError: The connection has no back-channel for
server-initiated requests.
"""
client_caps = self.client_params.capabilities if self.client_params else None
validate_sampling_tools(client_caps, tools, tool_choice)
validate_sampling_tools(self.client_capabilities, tools, tool_choice)
validate_tool_use_result_messages(messages)
request = types.CreateMessageRequest(

View file

@ -327,9 +327,10 @@ codes fall back to the caller's default (typically 200).
class InboundModernRoute:
"""A modern-protocol request whose envelope passed every ladder rung.
`client_info` and `client_capabilities` are the raw envelope values;
the classifier checks presence only, not shape. Method existence is not a
ladder rung — kernel dispatch is the single source of truth for that.
`client_info` and `client_capabilities` are the raw envelope values; the
classifier checks presence only, not shape, and `client_info` is `None`
when the (optional, SHOULD-include) key is absent. Method existence is not
a ladder rung — kernel dispatch is the single source of truth for that.
"""
protocol_version: str
@ -376,9 +377,11 @@ def classify_inbound_request(
Rungs, in order — first failure wins:
1. `params._meta` is a mapping carrying every reserved envelope key
(protocol version, client info, client capabilities) → else
:data:`~mcp_types.jsonrpc.INVALID_PARAMS`.
1. `params._meta` is a mapping carrying the required envelope pair
(protocol version, client capabilities) → else
:data:`~mcp_types.jsonrpc.INVALID_PARAMS` naming the missing key(s)
(basic/index.mdx "Per-request protocol fields"). Client info is
optional (SHOULD-include, spec PR #3002); absent reads as `None`.
2. When `headers` is given, `MCP-Protocol-Version` equals the envelope's
protocol version, `Mcp-Method` equals `body.method`, and — for the
methods in :data:`NAME_BEARING_METHODS` — `Mcp-Name` equals the named
@ -404,16 +407,24 @@ def classify_inbound_request(
accepts on the per-request-envelope path.
"""
try:
meta = body["params"]["_meta"]
protocol_version = meta[PROTOCOL_VERSION_META_KEY]
client_info = meta[CLIENT_INFO_META_KEY]
client_capabilities = meta[CLIENT_CAPABILITIES_META_KEY]
meta_value = body["params"]["_meta"]
except (KeyError, TypeError):
meta_value = None
if not isinstance(meta_value, Mapping):
return InboundLadderRejection(
code=INVALID_PARAMS,
message="params._meta must carry the reserved protocol-version, client-info and "
"client-capabilities envelope keys",
message="params._meta must be an object carrying the required "
f"{PROTOCOL_VERSION_META_KEY!r} and {CLIENT_CAPABILITIES_META_KEY!r} envelope keys",
)
meta = cast("Mapping[str, Any]", meta_value)
if missing := [key for key in (PROTOCOL_VERSION_META_KEY, CLIENT_CAPABILITIES_META_KEY) if key not in meta]:
return InboundLadderRejection(
code=INVALID_PARAMS,
message=f"params._meta is missing the required envelope key(s): {', '.join(missing)}",
)
protocol_version: Any = meta[PROTOCOL_VERSION_META_KEY]
client_info: Any = meta.get(CLIENT_INFO_META_KEY)
client_capabilities: Any = meta[CLIENT_CAPABILITIES_META_KEY]
if headers is not None:
version_header = headers.get(MCP_PROTOCOL_VERSION_HEADER)
# Presence is checked explicitly: a null body version would otherwise
@ -431,8 +442,8 @@ def classify_inbound_request(
)
name_key = NAME_BEARING_METHODS.get(method)
if name_key is not None:
# Rung 1 already proved body["params"] is a mapping.
body_value = body["params"].get(name_key)
# Rung 1 already proved body["params"] is a mapping (its `_meta` is one).
body_value = cast("Mapping[str, Any]", body["params"]).get(name_key)
if body_value is not None and decode_header_value(headers.get(MCP_NAME_HEADER)) != body_value:
return InboundLadderRejection(
code=HEADER_MISMATCH,