This commit is contained in:
parent
3a6f2996cd
commit
837ef904f8
17 changed files with 1757 additions and 567 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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: {}})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue