From 73a62adbed58187adadfe470348406eb90949fd5 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:21:03 +0000 Subject: [PATCH 1/6] Vendor the 2026-07-28 schema at spec commit 71e30695 and regenerate Spec PR #3002 (merged upstream 07-16) made io.modelcontextprotocol/clientInfo optional on request _meta, removed the top-level serverInfo field from DiscoverResult, and introduced ResultMetaObject: every result's _meta now carries an optional io.modelcontextprotocol/serverInfo Implementation. ResultMetaObject joins OPEN_CLASSES: the spec defines it as an open key-value bag (all MetaObject key rules apply), so it must keep extra="allow" or the serialize sieve would silently drop custom result _meta keys. --- schema/2026-07-28.json | 52 ++-- schema/PINNED.json | 4 +- scripts/gen_surface_types.py | 1 + .../mcp_types/v2026_07_28/__init__.py | 229 +++++++++++------- 4 files changed, 174 insertions(+), 112 deletions(-) diff --git a/schema/2026-07-28.json b/schema/2026-07-28.json index 87116a420..7b0d05712 100644 --- a/schema/2026-07-28.json +++ b/schema/2026-07-28.json @@ -123,7 +123,7 @@ "description": "A result that supports a time-to-live (TTL) hint for client-side caching.", "properties": { "_meta": { - "$ref": "#/$defs/MetaObject" + "$ref": "#/$defs/ResultMetaObject" }, "cacheScope": { "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: The response does not contain user-specific data. Any\n client or intermediary (e.g., shared gateway, caching proxy) MAY cache\n the response and serve it across authorization contexts.\n- `\"private\"`: The response MAY be cached and reused only within the\n same authorization context. Caches MUST NOT be shared across\n authorization contexts (e.g., a different access token requires a\n different cache).", @@ -208,7 +208,7 @@ "description": "The result returned by the server for a {@link CallToolRequesttools/call} request.", "properties": { "_meta": { - "$ref": "#/$defs/MetaObject" + "$ref": "#/$defs/ResultMetaObject" }, "content": { "description": "A list of content objects that represent the unstructured result of the tool call.", @@ -501,7 +501,7 @@ "description": "The result returned by the server for a {@link CompleteRequestcompletion/complete} request.", "properties": { "_meta": { - "$ref": "#/$defs/MetaObject" + "$ref": "#/$defs/ResultMetaObject" }, "completion": { "properties": { @@ -740,7 +740,7 @@ "description": "The result returned by the server for a {@link DiscoverRequestserver/discover} request.", "properties": { "_meta": { - "$ref": "#/$defs/MetaObject" + "$ref": "#/$defs/ResultMetaObject" }, "cacheScope": { "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: The response does not contain user-specific data. Any\n client or intermediary (e.g., shared gateway, caching proxy) MAY cache\n the response and serve it across authorization contexts.\n- `\"private\"`: The response MAY be cached and reused only within the\n same authorization context. Caches MUST NOT be shared across\n authorization contexts (e.g., a different access token requires a\n different cache).", @@ -762,10 +762,6 @@ "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", "type": "string" }, - "serverInfo": { - "$ref": "#/$defs/Implementation", - "description": "Information about the server software implementation." - }, "supportedVersions": { "description": "MCP Protocol Versions this server supports. The client should choose a\nversion from this list for use in subsequent requests.", "items": { @@ -783,7 +779,6 @@ "cacheScope", "capabilities", "resultType", - "serverInfo", "supportedVersions", "ttlMs" ], @@ -1084,7 +1079,7 @@ "description": "The result returned by the server for a {@link GetPromptRequestprompts/get} request.", "properties": { "_meta": { - "$ref": "#/$defs/MetaObject" + "$ref": "#/$defs/ResultMetaObject" }, "description": { "description": "An optional description for the prompt.", @@ -1310,7 +1305,7 @@ "description": "An InputRequiredResult sent by the server to indicate that additional input is needed\nbefore the request can be completed.\n\nAt least one of `inputRequests` or `requestState` MUST be present.", "properties": { "_meta": { - "$ref": "#/$defs/MetaObject" + "$ref": "#/$defs/ResultMetaObject" }, "inputRequests": { "$ref": "#/$defs/InputRequests" @@ -1644,7 +1639,7 @@ "description": "The result returned by the server for a {@link ListPromptsRequestprompts/list} request.", "properties": { "_meta": { - "$ref": "#/$defs/MetaObject" + "$ref": "#/$defs/ResultMetaObject" }, "cacheScope": { "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: The response does not contain user-specific data. Any\n client or intermediary (e.g., shared gateway, caching proxy) MAY cache\n the response and serve it across authorization contexts.\n- `\"private\"`: The response MAY be cached and reused only within the\n same authorization context. Caches MUST NOT be shared across\n authorization contexts (e.g., a different access token requires a\n different cache).", @@ -1733,7 +1728,7 @@ "description": "The result returned by the server for a {@link ListResourceTemplatesRequestresources/templates/list} request.", "properties": { "_meta": { - "$ref": "#/$defs/MetaObject" + "$ref": "#/$defs/ResultMetaObject" }, "cacheScope": { "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: The response does not contain user-specific data. Any\n client or intermediary (e.g., shared gateway, caching proxy) MAY cache\n the response and serve it across authorization contexts.\n- `\"private\"`: The response MAY be cached and reused only within the\n same authorization context. Caches MUST NOT be shared across\n authorization contexts (e.g., a different access token requires a\n different cache).", @@ -1822,7 +1817,7 @@ "description": "The result returned by the server for a {@link ListResourcesRequestresources/list} request.", "properties": { "_meta": { - "$ref": "#/$defs/MetaObject" + "$ref": "#/$defs/ResultMetaObject" }, "cacheScope": { "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: The response does not contain user-specific data. Any\n client or intermediary (e.g., shared gateway, caching proxy) MAY cache\n the response and serve it across authorization contexts.\n- `\"private\"`: The response MAY be cached and reused only within the\n same authorization context. Caches MUST NOT be shared across\n authorization contexts (e.g., a different access token requires a\n different cache).", @@ -1947,7 +1942,7 @@ "description": "The result returned by the server for a {@link ListToolsRequesttools/list} request.", "properties": { "_meta": { - "$ref": "#/$defs/MetaObject" + "$ref": "#/$defs/ResultMetaObject" }, "cacheScope": { "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: The response does not contain user-specific data. Any\n client or intermediary (e.g., shared gateway, caching proxy) MAY cache\n the response and serve it across authorization contexts.\n- `\"private\"`: The response MAY be cached and reused only within the\n same authorization context. Caches MUST NOT be shared across\n authorization contexts (e.g., a different access token requires a\n different cache).", @@ -2299,7 +2294,7 @@ "PaginatedResult": { "properties": { "_meta": { - "$ref": "#/$defs/MetaObject" + "$ref": "#/$defs/ResultMetaObject" }, "nextCursor": { "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", @@ -2600,7 +2595,7 @@ "description": "The result returned by the server for a {@link ReadResourceRequestresources/read} request.", "properties": { "_meta": { - "$ref": "#/$defs/MetaObject" + "$ref": "#/$defs/ResultMetaObject" }, "cacheScope": { "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: The response does not contain user-specific data. Any\n client or intermediary (e.g., shared gateway, caching proxy) MAY cache\n the response and serve it across authorization contexts.\n- `\"private\"`: The response MAY be cached and reused only within the\n same authorization context. Caches MUST NOT be shared across\n authorization contexts (e.g., a different access token requires a\n different cache).", @@ -2700,7 +2695,7 @@ }, "io.modelcontextprotocol/clientInfo": { "$ref": "#/$defs/Implementation", - "description": "Identifies the client software making the request. Required.\n\nThe {@link Implementation} schema requires `name` and `version`; other\nfields are optional." + "description": "Identifies the client software making the request. Clients SHOULD\ninclude this field on every request unless specifically configured not\nto do so.\n\nThe {@link Implementation} schema requires `name` and `version`; other\nfields are optional.\n\nThe value is self-reported by the client and is not verified by the\nprotocol. It is intended for display, logging, and debugging. Servers\nSHOULD NOT use it to change their behavior, and SHOULD NOT rely on it for\nsecurity decisions." }, "io.modelcontextprotocol/logLevel": { "$ref": "#/$defs/LoggingLevel", @@ -2717,7 +2712,6 @@ }, "required": [ "io.modelcontextprotocol/clientCapabilities", - "io.modelcontextprotocol/clientInfo", "io.modelcontextprotocol/protocolVersion" ], "type": "object" @@ -3005,7 +2999,7 @@ "description": "Common result fields.", "properties": { "_meta": { - "$ref": "#/$defs/MetaObject" + "$ref": "#/$defs/ResultMetaObject" }, "resultType": { "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", @@ -3017,6 +3011,16 @@ ], "type": "object" }, + "ResultMetaObject": { + "description": "Extends {@link MetaObject} with additional result-specific fields. All key naming rules from `MetaObject` apply.", + "properties": { + "io.modelcontextprotocol/serverInfo": { + "$ref": "#/$defs/Implementation", + "description": "Identifies the server software producing the response. Servers SHOULD\ninclude this field on every response unless specifically configured not\nto do so.\n\nThe {@link Implementation} schema requires `name` and `version`; other\nfields are optional.\n\nThe value is self-reported by the server and is not verified by the\nprotocol. It is intended for display, logging, and debugging. Clients\nSHOULD NOT use it to change their behavior, and SHOULD NOT rely on it for\nsecurity decisions." + } + }, + "type": "object" + }, "ResultType": { "description": "Indicates the type of a {@link Result} object, allowing the client to\ndetermine how to parse the response.\n\ncomplete - the request completed successfully and the result contains the final content.\ninput_required - the request requires additional input and the result contains an {@link InputRequiredResult} object with instructions for the client to provide additional input before retrying the original request.", "type": "string" @@ -3312,7 +3316,7 @@ "type": "object" }, "SubscriptionsAcknowledgedNotification": { - "description": "Sent by the server as the first message on a\n{@link SubscriptionsListenRequestsubscriptions/listen} stream to acknowledge\nthat the subscription has been established and to report which notification\ntypes it agreed to honor.", + "description": "Sent by the server to acknowledge that a\n{@link SubscriptionsListenRequestsubscriptions/listen} subscription has been\nestablished and to report which notification types it agreed to honor.\n\nThis notification MUST be the first message the server sends carrying the\nsubscription's ID in `io.modelcontextprotocol/subscriptionId`. The server MUST\nNOT send any notification on the subscription before acknowledging it. On\nstdio, where every subscription shares one channel, this ordering is defined\nper subscription ID and not per channel: messages belonging to other\nsubscriptions MAY be interleaved before it.", "properties": { "jsonrpc": { "const": "2.0", @@ -3410,8 +3414,12 @@ "type": "object" }, "SubscriptionsListenResultMeta": { - "description": "Extends {@link MetaObject} with the subscription-stream identifier carried by a\n{@link SubscriptionsListenResult}. All key naming rules from `MetaObject` apply.", + "description": "Extends {@link ResultMetaObject} with the subscription-stream identifier carried by a\n{@link SubscriptionsListenResult}. All key naming rules from `MetaObject` apply.", "properties": { + "io.modelcontextprotocol/serverInfo": { + "$ref": "#/$defs/Implementation", + "description": "Identifies the server software producing the response. Servers SHOULD\ninclude this field on every response unless specifically configured not\nto do so.\n\nThe {@link Implementation} schema requires `name` and `version`; other\nfields are optional.\n\nThe value is self-reported by the server and is not verified by the\nprotocol. It is intended for display, logging, and debugging. Clients\nSHOULD NOT use it to change their behavior, and SHOULD NOT rely on it for\nsecurity decisions." + }, "io.modelcontextprotocol/subscriptionId": { "$ref": "#/$defs/RequestId", "description": "Identifies the subscription stream this response closes, so the client can\ncorrelate it with the originating subscription — mirroring the same key on\nthe stream's notifications. The value is the JSON-RPC ID of the\n`subscriptions/listen` request that opened the stream (and equals this\nresponse's `id`)." diff --git a/schema/PINNED.json b/schema/PINNED.json index 9b1739d29..1c3e2c235 100644 --- a/schema/PINNED.json +++ b/schema/PINNED.json @@ -8,7 +8,7 @@ { "protocol_version": "2026-07-28", "source_path_in_spec_repo": "schema/draft/schema.json", - "spec_commit": "ead35b59b4fda8b32e276810025d8f92bdcec1b6", - "sha256": "e00f675287e8cf078688c26c8a89d283ff2613da3b76d5cd15aff9d189df639c" + "spec_commit": "71e306956a4959c9655e5036be215d41986596e6", + "sha256": "6293cdfe015c14bd36eda4b1331ce37bda377609e58ed6d09d16f28e7d3c7ad4" } ] diff --git a/scripts/gen_surface_types.py b/scripts/gen_surface_types.py index f33862909..cc857405e 100644 --- a/scripts/gen_surface_types.py +++ b/scripts/gen_surface_types.py @@ -89,6 +89,7 @@ "MetaObject", "NotificationMetaObject", "RequestMetaObject", + "ResultMetaObject", "SubscriptionsListenResultMeta", "InputSchema", "OutputSchema", diff --git a/src/mcp-types/mcp_types/v2026_07_28/__init__.py b/src/mcp-types/mcp_types/v2026_07_28/__init__.py index 2963c1323..561225273 100644 --- a/src/mcp-types/mcp_types/v2026_07_28/__init__.py +++ b/src/mcp-types/mcp_types/v2026_07_28/__init__.py @@ -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,29 @@ 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[ + Implementation | 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 +894,29 @@ 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[ + Implementation | 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 +1400,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 +1437,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 +1445,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 +1499,6 @@ class EmbeddedResource(WireModel): type: Literal["resource"] -class EmptyResult(RootModel[Result]): - root: Result - """ - Common result fields. - """ - - class EnumSchema( RootModel[ UntitledSingleSelectEnumSchema @@ -1599,19 +1584,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 +1662,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 +1804,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 +2047,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 +2260,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 +2283,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 +2311,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 +2375,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 +2439,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 +2503,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 +2625,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 +2696,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 +2762,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 +2780,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 +3148,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 +3185,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 +3275,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 +3486,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") From 46c200d1a052e31fa997b97da94e6e785cfe15c6 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:12:56 +0000 Subject: [PATCH 2/6] Accept modern envelope without the optional clientInfo key (spec PR #3002) Spec PR #3002 made io.modelcontextprotocol/clientInfo optional (SHOULD-include): the required per-request envelope is now the protocolVersion + clientCapabilities pair. - classify_inbound_request rung 1 demands the pair and reads absent clientInfo as None; a missing required key rejects -32602 with a message naming the key(s), per basic/index.mdx. - Era evidence on the stdio dual-era loop is now presence of the reserved protocolVersion _meta key alone: the io.modelcontextprotocol/ prefix is spec-reserved, so legacy traffic never mints it, and a half-built envelope (version without capabilities) routes modern to get the classifier's named rejection instead of the legacy path's generic one. Failed classification still locks no era. - The regenerated surface types (previous commit) already carry the optional clientInfo on RequestMetaObject. - Connection records client_capabilities as its own fact so capability checks (check_capability, sampling tools validation, extension and apps gates) work for pair-only requests; the client_params setter keeps the two in lockstep on the handshake path. ServerSession exposes client_capabilities and all capability consumers read it. - The Mcp-Param schema-resolving tools/list walk omits the clientInfo key from its synthetic envelope when the caller sent none. --- src/mcp/server/_streamable_http_modern.py | 4 +- src/mcp/server/apps.py | 3 +- src/mcp/server/connection.py | 51 ++++++++++++++------ src/mcp/server/mcpserver/context.py | 8 ++-- src/mcp/server/mcpserver/server.py | 4 +- src/mcp/server/runner.py | 26 ++++++----- src/mcp/server/session.py | 13 +++++- src/mcp/shared/inbound.py | 39 ++++++++++------ tests/server/test_connection.py | 32 ++++++++++++- tests/server/test_runner.py | 50 ++++++++++++++++++-- tests/server/test_streamable_http_modern.py | 49 +++++++++++++++++++ tests/shared/test_inbound.py | 52 +++++++++++++++++---- tests/types/test_methods.py | 16 +++++-- 13 files changed, 282 insertions(+), 65 deletions(-) diff --git a/src/mcp/server/_streamable_http_modern.py b/src/mcp/server/_streamable_http_modern.py index f61251156..1db6b35f8 100644 --- a/src/mcp/server/_streamable_http_modern.py +++ b/src/mcp/server/_streamable_http_modern.py @@ -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) diff --git a/src/mcp/server/apps.py b/src/mcp/server/apps.py index d5b9d9ed8..583e203ac 100644 --- a/src/mcp/server/apps.py +++ b/src/mcp/server/apps.py @@ -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: diff --git a/src/mcp/server/connection.py b/src/mcp/server/connection.py index 8cb7dc421..ca05928df 100644 --- a/src/mcp/server/connection.py +++ b/src/mcp/server/connection.py @@ -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 @@ def __init__( 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 @@ def from_envelope( 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 @@ def from_envelope( 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 @@ async def send_resource_updated(self, uri: str, *, meta: Meta | None = None) -> 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 diff --git a/src/mcp/server/mcpserver/context.py b/src/mcp/server/mcpserver/context.py index 2b7fdf35e..97ac905bd 100644 --- a/src/mcp/server/mcpserver/context.py +++ b/src/mcp/server/mcpserver/context.py @@ -326,11 +326,11 @@ def request_state(self) -> str | None: 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): diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index 7fc1cd194..8877c3cdf 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -1320,8 +1320,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: {}}) diff --git a/src/mcp/server/runner.py b/src/mcp/server/runner.py index 3b53335ae..297bbcde6 100644 --- a/src/mcp/server/runner.py +++ b/src/mcp/server/runner.py @@ -439,19 +439,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. - - Era evidence is the FULL key triple - bare `_meta` is not (legacy traffic - carries `progressToken` there). + """Whether `params._meta` carries the reserved protocol-version key. + + 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 +561,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 +680,7 @@ async def on_request( 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) diff --git a/src/mcp/server/session.py b/src/mcp/server/session.py index 0a61689eb..69ad5ecad 100644 --- a/src/mcp/server/session.py +++ b/src/mcp/server/session.py @@ -45,6 +45,16 @@ def client_params(self) -> types.InitializeRequestParams | None: """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 @@ async def create_message( 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( diff --git a/src/mcp/shared/inbound.py b/src/mcp/shared/inbound.py index c3e0ea338..c28aa7fb7 100644 --- a/src/mcp/shared/inbound.py +++ b/src/mcp/shared/inbound.py @@ -327,9 +327,10 @@ def _value_at_path(arguments: Mapping[str, Any], path: tuple[str, ...]) -> Any: 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, diff --git a/tests/server/test_connection.py b/tests/server/test_connection.py index d448905a9..31caf87bf 100644 --- a/tests/server/test_connection.py +++ b/tests/server/test_connection.py @@ -22,6 +22,7 @@ ElicitationCapability, EmptyResult, Implementation, + InitializeRequestParams, ListRootsRequest, ListRootsResult, PingRequest, @@ -321,8 +322,8 @@ async def test_connection_send_tool_list_changed_with_meta_includes_meta_only_pa # --- check_capability ---------------------------------------------------------- -def test_connection_check_capability_false_when_no_client_params_recorded(): - """SDK-defined: `check_capability` returns False when no `client_params` +def test_connection_check_capability_false_when_no_capabilities_recorded(): + """SDK-defined: `check_capability` returns False when no capabilities were recorded, regardless of which factory built the connection.""" conn = Connection.for_loop(StubOutbound()) assert conn.check_capability(ClientCapabilities(sampling=SamplingCapability())) is False @@ -330,6 +331,33 @@ def test_connection_check_capability_false_when_no_client_params_recorded(): assert Connection.from_envelope(LATEST_MODERN_VERSION, None, None).check_capability(ClientCapabilities()) is False +def test_from_envelope_records_capabilities_without_client_info(): + """Spec-mandated (spec PR #3002): the envelope requires capabilities but not + client info, so a pair-only request still gets working capability checks - + `client_capabilities` is recorded on its own while `client_params` stays + `None`.""" + caps = ClientCapabilities(sampling=SamplingCapability()) + conn = Connection.from_envelope(LATEST_MODERN_VERSION, None, caps) + assert conn.client_params is None + assert conn.client_capabilities == caps + assert conn.check_capability(ClientCapabilities(sampling=SamplingCapability())) is True + + +def test_client_params_assignment_keeps_capabilities_in_lockstep(): + """SDK-defined: recording `client_params` (the loop path's handshake + commit) is the sync point that also records `client_capabilities`, so the + two facts cannot drift.""" + conn = Connection.for_loop(StubOutbound()) + assert conn.client_capabilities is None + conn.client_params = InitializeRequestParams( + protocol_version=LATEST_HANDSHAKE_VERSION, + capabilities=ClientCapabilities(roots=RootsCapability()), + client_info=Implementation(name="c", version="0"), + ) + assert conn.client_capabilities == ClientCapabilities(roots=RootsCapability()) + assert conn.check_capability(ClientCapabilities(roots=RootsCapability())) is True + + @pytest.mark.parametrize( ("have", "want", "expected"), [ diff --git a/tests/server/test_runner.py b/tests/server/test_runner.py index 29d3f07fa..45877ff9a 100644 --- a/tests/server/test_runner.py +++ b/tests/server/test_runner.py @@ -1447,6 +1447,44 @@ async def test_dual_era_loop_malformed_envelope_content_never_locks(server: SrvT assert exc_info.value.error.code != UNSUPPORTED_PROTOCOL_VERSION +@pytest.mark.anyio +async def test_dual_era_loop_pair_only_envelope_serves_modern_and_locks(server: SrvT): + """Spec-mandated (spec PR #3002): the required envelope pair (protocol version + + client capabilities) without the optional clientInfo is a complete + modern request - it is served, records the declared capabilities without + client params, locks the era modern, and a later legacy `initialize` is + rejected with -32022.""" + params = _modern_params() + del params["_meta"][CLIENT_INFO_META_KEY] + async with dual_era_client(server) as (client, _): + result = await client.send_raw_request("tools/list", params) + assert result["tools"][0]["name"] == "t" + with pytest.raises(MCPError) as exc_info: + await client.send_raw_request("initialize", _initialize_params()) + assert exc_info.value.error.code == UNSUPPORTED_PROTOCOL_VERSION + ctx = _seen_ctx[-1] + assert ctx.session.client_params is None + assert ctx.session.client_capabilities == ClientCapabilities() + + +@pytest.mark.anyio +async def test_dual_era_loop_version_without_capabilities_rejects_naming_the_key_and_never_locks(server: SrvT): + """A `_meta` declaring the protocol version but missing the required + client-capabilities key routes modern - never the legacy path with its + generic 'Invalid request parameters' - and is rejected INVALID_PARAMS + naming the missing key; like every failed classification it locks no era, + so the legacy handshake stays available.""" + params = _modern_params() + del params["_meta"][CLIENT_CAPABILITIES_META_KEY] + async with dual_era_client(server) as (client, _): + with pytest.raises(MCPError) as exc_info: + await client.send_raw_request("tools/list", params) + init = await client.send_raw_request("initialize", _initialize_params()) + assert init["protocolVersion"] == LATEST_HANDSHAKE_VERSION + assert exc_info.value.error.code == INVALID_PARAMS + assert CLIENT_CAPABILITIES_META_KEY in exc_info.value.error.message + + @pytest.mark.anyio async def test_dual_era_loop_failed_modern_request_never_locks(server: SrvT): """A well-formed modern request for an unknown method fails without @@ -1692,13 +1730,19 @@ async def greet(ctx: Ctx, params: RequestParams | None) -> dict[str, Any]: assert seen == [None] -def test_has_modern_envelope_requires_the_full_key_triple(): +def test_has_modern_envelope_keys_on_the_protocol_version_key(): + """Era evidence is the reserved protocol-version `_meta` key: legacy traffic + never mints it (bare `_meta` / `progressToken` is not evidence), and a + half-built envelope still routes modern so the classifier - not the legacy + path - names its missing required key.""" assert not _has_modern_envelope(None) assert not _has_modern_envelope({}) assert not _has_modern_envelope({"_meta": None}) assert not _has_modern_envelope({"_meta": {"progressToken": 1}}) - partial_meta = {k: v for k, v in _modern_envelope().items() if k != CLIENT_CAPABILITIES_META_KEY} - assert not _has_modern_envelope({"_meta": partial_meta}) + version_only = {PROTOCOL_VERSION_META_KEY: LATEST_MODERN_VERSION} + assert _has_modern_envelope({"_meta": version_only}) + pair_only = {k: v for k, v in _modern_envelope().items() if k != CLIENT_INFO_META_KEY} + assert _has_modern_envelope({"_meta": pair_only}) assert _has_modern_envelope(_modern_params()) diff --git a/tests/server/test_streamable_http_modern.py b/tests/server/test_streamable_http_modern.py index 45a1e7e1c..21cc98b8c 100644 --- a/tests/server/test_streamable_http_modern.py +++ b/tests/server/test_streamable_http_modern.py @@ -26,6 +26,7 @@ PROTOCOL_VERSION_META_KEY, CallToolRequestParams, CallToolResult, + ClientCapabilities, ErrorData, JSONRPCError, JSONRPCResponse, @@ -158,6 +159,40 @@ def _list_tools_body() -> dict[str, Any]: return {"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {"_meta": meta}} +async def test_handle_modern_request_serves_pair_only_envelope_without_client_info() -> None: + """Spec-mandated (spec PR #3002): `clientInfo` is optional - a request whose + `_meta` carries only the protocol-version + client-capabilities pair is + served, with the declared capabilities recorded and `client_params` None.""" + seen: list[tuple[object, object]] = [] + + async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + seen.append((ctx.session.client_params, ctx.session.client_capabilities)) + return ListToolsResult(tools=[], ttl_ms=0, cache_scope="public") + + server: Server[Any] = Server("test", on_list_tools=list_tools) + body = _list_tools_body() + del body["params"]["_meta"][CLIENT_INFO_META_KEY] + async with _asgi_client(server) as http: + response = await http.post("/mcp", json=body, headers={MCP_METHOD_HEADER: "tools/list"}) + assert response.status_code == 200 + assert response.json()["result"]["tools"] == [] + assert seen == [(None, ClientCapabilities())] + + +async def test_handle_modern_request_missing_capabilities_rejects_naming_the_key() -> None: + """Spec-mandated (basic/index.mdx): the protocol version without the + required client-capabilities key is malformed - INVALID_PARAMS (HTTP 400) + with a message naming the missing key.""" + body = _list_tools_body() + del body["params"]["_meta"][CLIENT_CAPABILITIES_META_KEY] + async with _asgi_client(Server("test")) as http: + response = await http.post("/mcp", json=body, headers={MCP_METHOD_HEADER: "tools/list"}) + assert response.status_code == 400 + error = response.json()["error"] + assert error["code"] == INVALID_PARAMS + assert CLIENT_CAPABILITIES_META_KEY in error["message"] + + async def test_handle_modern_request_routes_with_mis_shaped_envelope_client_info() -> None: """SDK-defined: a mis-shaped ``clientInfo`` envelope value is treated as not supplied — the request still routes (200 + result) and the handler observes ``client_params is None`` @@ -709,6 +744,20 @@ async def test_modern_tools_call_accepts_matching_mcp_param_header() -> None: assert response.json()["result"]["content"] == [] +async def test_modern_tools_call_validates_mcp_param_headers_for_a_pair_only_envelope() -> None: + """The schema-resolving `tools/list` walk builds its synthetic envelope from the caller's: + a pair-only caller (spec PR #3002, no clientInfo) omits the optional key rather than sending + null, so header validation still runs and a mismatched header is still rejected.""" + body = _tool_call_body({"region": "east"}) + del body["params"]["_meta"][CLIENT_INFO_META_KEY] + async with _asgi_client(_x_mcp_server()) as http: + matched = await http.post("/mcp", json=body, headers=_TOOL_CALL_HEADERS | {"mcp-param-region": "east"}) + mismatched = await http.post("/mcp", json=body, headers=_TOOL_CALL_HEADERS | {"mcp-param-region": "west"}) + assert matched.status_code == 200 + assert mismatched.status_code == 400 + assert mismatched.json()["error"]["code"] == HEADER_MISMATCH + + @pytest.mark.parametrize("json_response", [True, False]) async def test_modern_tools_call_rejects_mcp_param_mismatch_with_400_and_header_mismatch( json_response: bool, diff --git a/tests/shared/test_inbound.py b/tests/shared/test_inbound.py index 2bf9c3641..7712e73e5 100644 --- a/tests/shared/test_inbound.py +++ b/tests/shared/test_inbound.py @@ -98,19 +98,55 @@ def assert_rejected(result: object, code: int) -> InboundLadderRejection: @pytest.mark.parametrize( - "body", + ("body", "named"), [ - pytest.param({"jsonrpc": "2.0", "id": 1, "method": "tools/list"}, id="no-params"), - pytest.param({"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}, id="no-meta"), - pytest.param(envelope(drop=frozenset({PROTOCOL_VERSION_META_KEY})), id="meta-missing-version"), - pytest.param(envelope(drop=frozenset({CLIENT_INFO_META_KEY})), id="meta-missing-client-info"), - pytest.param(envelope(drop=frozenset({CLIENT_CAPABILITIES_META_KEY})), id="meta-missing-client-caps"), + pytest.param( + {"jsonrpc": "2.0", "id": 1, "method": "tools/list"}, + [PROTOCOL_VERSION_META_KEY, CLIENT_CAPABILITIES_META_KEY], + id="no-params", + ), + pytest.param( + {"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}, + [PROTOCOL_VERSION_META_KEY, CLIENT_CAPABILITIES_META_KEY], + id="no-meta", + ), + pytest.param( + envelope(drop=frozenset({PROTOCOL_VERSION_META_KEY})), + [PROTOCOL_VERSION_META_KEY], + id="meta-missing-version", + ), + pytest.param( + envelope(drop=frozenset({CLIENT_CAPABILITIES_META_KEY})), + [CLIENT_CAPABILITIES_META_KEY], + id="meta-missing-client-caps", + ), + pytest.param( + envelope(drop=frozenset({PROTOCOL_VERSION_META_KEY, CLIENT_CAPABILITIES_META_KEY})), + [PROTOCOL_VERSION_META_KEY, CLIENT_CAPABILITIES_META_KEY], + id="meta-missing-both", + ), ], ) -def test_envelope_rung_rejects_missing_keys(body: dict[str, Any]) -> None: - """Spec-mandated: a modern request lacking any of the three reserved `_meta` keys is rejected INVALID_PARAMS.""" +def test_envelope_rung_rejects_missing_required_keys(body: dict[str, Any], named: list[str]) -> None: + """Spec-mandated (basic/index.mdx per-request protocol fields): a modern + request lacking a required `_meta` envelope key (protocol version or + client capabilities) is rejected INVALID_PARAMS with a message naming the + missing key(s).""" rejection = assert_rejected(classify_inbound_request(body), INVALID_PARAMS) assert rejection.data is None + for key in named: + assert key in rejection.message + + +def test_envelope_rung_accepts_pair_only_envelope_without_client_info() -> None: + """Spec-mandated (spec PR #3002): `clientInfo` is optional - a request whose + `_meta` carries only the protocol-version + client-capabilities pair + routes, with `client_info` read as `None`.""" + result = classify_inbound_request(envelope(drop=frozenset({CLIENT_INFO_META_KEY}))) + assert isinstance(result, InboundModernRoute) + assert result.protocol_version == LATEST_MODERN_VERSION + assert result.client_info is None + assert result.client_capabilities == CLIENT_CAPS @pytest.mark.parametrize( diff --git a/tests/types/test_methods.py b/tests/types/test_methods.py index 126e06c29..c34d79e37 100644 --- a/tests/types/test_methods.py +++ b/tests/types/test_methods.py @@ -302,12 +302,14 @@ "2026-07-28": "mcp_types.v2026_07_28", } -# The three reserved `params._meta` entries the 2026 surface requires on every request. +# The reserved `params._meta` entries the 2026 surface accepts on every request. +# `clientInfo` is optional (SHOULD-include, spec PR #3002); the other two are required. META_TRIPLE: dict[str, Any] = { "io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientInfo": {"name": "client", "version": "1.0"}, "io.modelcontextprotocol/clientCapabilities": {}, } +META_REQUIRED_KEYS = ("io.modelcontextprotocol/protocolVersion", "io.modelcontextprotocol/clientCapabilities") # One minimal valid params mapping per surface request class. REQUEST_PARAMS_FIXTURES: dict[type[BaseModel], dict[str, Any] | None] = { @@ -651,8 +653,8 @@ def test_unknown_version_strings_raise_value_error_on_every_parse_function(): assert "2099-01-01" in str(excinfo.value) -def test_2026_07_28_requests_missing_a_reserved_meta_entry_reject_as_missing(): - for absent_key in META_TRIPLE: +def test_2026_07_28_requests_missing_a_required_meta_entry_reject_as_missing(): + for absent_key in META_REQUIRED_KEYS: partial_meta = {key: value for key, value in META_TRIPLE.items() if key != absent_key} with pytest.raises(pydantic.ValidationError) as excinfo: methods.parse_client_request("tools/list", "2026-07-28", {"_meta": partial_meta}) @@ -661,6 +663,14 @@ def test_2026_07_28_requests_missing_a_reserved_meta_entry_reject_as_missing(): ] +def test_2026_07_28_requests_accept_meta_without_the_optional_client_info(): + """spec PR #3002: `clientInfo` is optional on the 2026 surface - the required + pair alone validates.""" + pair_meta = {key: value for key, value in META_TRIPLE.items() if key != "io.modelcontextprotocol/clientInfo"} + parsed = methods.parse_client_request("tools/list", "2026-07-28", {"_meta": pair_meta}) + assert isinstance(parsed, types.ListToolsRequest) + + def test_2026_07_28_results_require_result_type(): with pytest.raises(pydantic.ValidationError): methods.parse_server_result("tools/call", "2026-07-28", {"content": []}) From a51c9f570677dbb46cb0fc05c758c9250d5c66ab Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Tue, 21 Jul 2026 20:12:23 +0000 Subject: [PATCH 3/6] Move server identity to result _meta and stamp every 2026-era result (spec #3002) The 2026-07-28 draft removed the serverInfo body field from DiscoverResult: servers now report identity by stamping io.modelcontextprotocol/serverInfo into every result's _meta (new ResultMetaObject type), and clients treat it as optional, display-only metadata. Server side: - DiscoverResult loses server_info (monolith and generated surface); the default discover handler no longer passes identity. - Every 2026-era result is stamped at the runner's single exit point, after the middleware chain, so custom methods, empty results, and middleware short-circuits are covered by construction. Stamping builds a shallow copy rather than mutating a dict the handler may retain, a handler-authored value wins, and the dumped stamp is cached per server. Notifications, error responses, and handshake-era results are never stamped. - Opt-out per the spec's 'unless specifically configured not to do so': Server(include_server_info=False), also exposed on MCPServer. Client side: - ClientSession.server_info reads the discover result's _meta stamp, parsed once at adopt time; absent or malformed reads as None (the spec forbids acting on the value, so a bad stamp must not fail the connection). The wire surface keeps the field untyped for the same reason, via a shape-driven transform in the generator that stays lenient for future result-meta definitions. - Client.server_info is now Implementation | None. This also fixes a live interop break: servers that already shipped the new shape omit the body field, which previously failed our discover validation and silently downgraded auto mode to the legacy handshake. Tests: the interaction matrix runs with stamping off (the stamp would bake the commit-dependent package version into every snapshot); stamping has dedicated unit and wire-level coverage in tests/server/, and the tests that exercise identity assert the new _meta form. --- docs_src/protocol_versions/tutorial004.py | 3 +- examples/stories/_harness.py | 2 +- examples/stories/dual_era/README.md | 4 +- examples/stories/dual_era/client.py | 10 +- examples/stories/reconnect/README.md | 2 +- examples/stories/reconnect/client.py | 14 ++- scripts/gen_surface_types.py | 31 +++++- src/mcp-types/mcp_types/__init__.py | 3 + src/mcp-types/mcp_types/_types.py | 9 +- .../mcp_types/v2026_07_28/__init__.py | 8 +- src/mcp/client/client.py | 18 ++-- src/mcp/client/session.py | 29 +++++- src/mcp/server/lowlevel/server.py | 18 +++- src/mcp/server/mcpserver/server.py | 2 + src/mcp/server/runner.py | 16 ++++ tests/client/test_client.py | 15 +-- tests/client/test_client_caching.py | 2 - tests/client/test_probe.py | 10 +- tests/client/test_send_request_mcp_name.py | 1 - tests/client/test_session.py | 42 +++++++- tests/client/test_session_claims.py | 1 - tests/client/test_session_concurrency.py | 2 +- .../test_session_notification_bindings.py | 3 +- tests/client/test_subscriptions.py | 1 - tests/docs_src/_helpers.py | 24 +++++ tests/docs_src/test_client.py | 2 + tests/docs_src/test_client_transports.py | 1 + tests/docs_src/test_index.py | 2 + tests/docs_src/test_logging.py | 2 + tests/docs_src/test_lowlevel.py | 14 ++- tests/docs_src/test_media.py | 1 + tests/docs_src/test_mrtr.py | 7 ++ tests/docs_src/test_prompts.py | 2 + tests/docs_src/test_protocol_versions.py | 20 ++-- tests/docs_src/test_run.py | 2 + tests/docs_src/test_session_groups.py | 26 +++-- tests/docs_src/test_testing.py | 2 + tests/interaction/_requirements.py | 13 ++- tests/interaction/conftest.py | 19 +++- .../lowlevel/test_client_connect.py | 13 ++- .../transports/test_hosting_http_modern.py | 50 ++++++---- tests/interaction/transports/test_stdio.py | 1 + .../transports/test_streamable_http.py | 1 + tests/server/lowlevel/test_server_discover.py | 71 ++++++++++++-- tests/server/mcpserver/test_extension.py | 14 +-- tests/server/mcpserver/test_server.py | 16 ++-- tests/server/mcpserver/test_title.py | 10 +- tests/server/test_apps.py | 2 +- tests/server/test_caching.py | 6 +- tests/server/test_runner.py | 95 ++++++++++++++++++- tests/server/test_stdio.py | 8 +- tests/server/test_streamable_http_modern.py | 12 ++- tests/test_examples.py | 24 ++++- tests/test_types.py | 2 - tests/types/test_methods.py | 20 +++- tests/types/test_parity.py | 1 + 56 files changed, 586 insertions(+), 143 deletions(-) create mode 100644 tests/docs_src/_helpers.py diff --git a/docs_src/protocol_versions/tutorial004.py b/docs_src/protocol_versions/tutorial004.py index c1b8fc6b5..dd0443b97 100644 --- a/docs_src/protocol_versions/tutorial004.py +++ b/docs_src/protocol_versions/tutorial004.py @@ -16,4 +16,5 @@ async def main() -> None: async with Client(mcp, mode="2026-07-28", prior_discover=saved) as client: print(client.protocol_version) - print(client.server_info.name) + if client.server_info is not None: + print(client.server_info.name) diff --git a/examples/stories/_harness.py b/examples/stories/_harness.py index 3ef52b323..62e02e70a 100644 --- a/examples/stories/_harness.py +++ b/examples/stories/_harness.py @@ -162,7 +162,7 @@ def run_client(main: Callable[..., Awaitable[None]]) -> None: if cfg["era"] == "dual-in-body": # The story pins its connection modes inside ``main`` itself, so hand it "auto" # (the ``Client`` default) and let those in-body pins decide. A hard version pin - # here would skip the discover probe and leave ``server_info`` blank. + # here would skip the discover probe and leave `server_info` None. era = "in-body" mode = {"modern": LATEST_MODERN_VERSION, "legacy": "legacy", "in-body": "auto"}[era] diff --git a/examples/stories/dual_era/README.md b/examples/stories/dual_era/README.md index f14f16402..6eca87693 100644 --- a/examples/stories/dual_era/README.md +++ b/examples/stories/dual_era/README.md @@ -30,7 +30,9 @@ leg fails there today — run over `--http`. at construction; no date strings appear in the body. - `client.py` — `client.protocol_version` / `client.server_info` / `client.server_capabilities` are era-neutral: populated by `initialize` *or* - `server/discover`, whichever ran. + `server/discover`, whichever ran. On the 2026 era `server_info` comes from + the optional `serverInfo` `_meta` stamp (`None` for a server that does not + identify itself); `initialize` always carries it. - `server.py` — `ctx.request_context.protocol_version` is the era branch key (lowlevel: `ctx.protocol_version` directly). Compare against `MODERN_PROTOCOL_VERSIONS`, never a date literal. diff --git a/examples/stories/dual_era/client.py b/examples/stories/dual_era/client.py index ba9acf5d9..b884c7060 100644 --- a/examples/stories/dual_era/client.py +++ b/examples/stories/dual_era/client.py @@ -13,7 +13,11 @@ async def main(targets: TargetFactory, *, mode: str = "auto") -> None: # The version/info/capabilities accessors are era-neutral. async with Client(targets(), mode=mode) as modern: assert modern.protocol_version == LATEST_MODERN_VERSION - assert modern.server_info.name == "dual-era-example" + # On the 2026 era, server identity is an optional serverInfo stamp in the + # result _meta (None for an anonymous server); this server stamps it. + info = modern.server_info + assert info is not None, "the server stamps serverInfo into its results" + assert info.name == "dual-era-example" assert modern.server_capabilities.tools is not None listed = await modern.list_tools() @@ -28,7 +32,9 @@ async def main(targets: TargetFactory, *, mode: str = "auto") -> None: # The same accessors are populated identically — here by ``initialize``. async with Client(targets(), mode="legacy") as legacy: assert legacy.protocol_version == LATEST_HANDSHAKE_VERSION - assert legacy.server_info.name == "dual-era-example" + info = legacy.server_info + assert info is not None, "initialize always carries serverInfo" + assert info.name == "dual-era-example" assert legacy.server_capabilities.tools is not None result = await legacy.call_tool("greet", {"name": "2025 client"}) diff --git a/examples/stories/reconnect/README.md b/examples/stories/reconnect/README.md index 78d281e7a..a5d3d8f59 100644 --- a/examples/stories/reconnect/README.md +++ b/examples/stories/reconnect/README.md @@ -35,7 +35,7 @@ uv run python -m stories.reconnect.client --http --server server_lowlevel ## Caveats - `mode=` *without* `prior_discover=` synthesizes a placeholder - whose `server_info` is `Implementation(name="", version="")`. Pass the cached + with no `serverInfo` stamp, so `server_info` reads `None`. Pass the cached result to get real identity on reconnect. Whether `Client` should expose a public synthesizer (or refuse the bare pin) is open. - `client.session.discover_result` is a one-hop reach into the mechanics layer; diff --git a/examples/stories/reconnect/client.py b/examples/stories/reconnect/client.py index aab2312dc..0bf3c7af9 100644 --- a/examples/stories/reconnect/client.py +++ b/examples/stories/reconnect/client.py @@ -15,7 +15,11 @@ async def main(targets: TargetFactory, *, mode: str = "auto") -> None: discovered = client.session.discover_result assert discovered is not None, "mode='auto' against a modern server populates discover_result" assert client.protocol_version == LATEST_MODERN_VERSION - assert client.server_info.name == "reconnect-example" + # On the 2026 era, server identity is an optional serverInfo stamp in the + # result _meta; an anonymous server reads as None. This one stamps it. + info = client.server_info + assert info is not None, "the server stamps serverInfo into its results" + assert info.name == "reconnect-example" assert LATEST_MODERN_VERSION in discovered.supported_versions result = await client.call_tool("add", {"a": 2, "b": 3}) @@ -28,11 +32,13 @@ async def main(targets: TargetFactory, *, mode: str = "auto") -> None: # Reconnect: a version pin plus the cached DiscoverResult adopts the prior state with # zero round-trips on entry. A Client cannot be re-entered after exit, so targets() - # yields a fresh one. Without prior_discover= a bare pin would synthesize a blank - # server_info — the cache is what makes the era-neutral accessors useful here. + # yields a fresh one. Without prior_discover= a bare pin would leave server_info + # None — the cache is what carries the server's identity stamp across reconnects. async with Client(targets(), mode=LATEST_MODERN_VERSION, prior_discover=rehydrated) as second: assert second.protocol_version == LATEST_MODERN_VERSION - assert second.server_info.name == "reconnect-example" + info = second.server_info + assert info is not None, "the cached DiscoverResult carries the serverInfo stamp" + assert info.name == "reconnect-example" assert second.server_capabilities.tools is not None assert second.session.discover_result == rehydrated diff --git a/scripts/gen_surface_types.py b/scripts/gen_surface_types.py index cc857405e..0c7b85289 100644 --- a/scripts/gen_surface_types.py +++ b/scripts/gen_surface_types.py @@ -25,6 +25,10 @@ SCHEMA_DIR = REPO_ROOT / "schema" TYPES_DIR = REPO_ROOT / "src" / "mcp-types" / "mcp_types" +# The result-meta serverInfo stamp: every `$defs` entry carrying this property +# gets its typed `$ref` stripped by `make_server_info_opaque` below. +SERVER_INFO_META_PROPERTY = "io.modelcontextprotocol/serverInfo" + # schema.ts -> schema.json renders TypeScript `number` as JSON Schema # `integer` at these sites; patch the JSON before codegen so floats validate. # Patched to `["integer", "number"]` (not bare `"number"`) so codegen emits @@ -129,9 +133,14 @@ def load_pinned() -> list[dict[str, str]]: def patch_schema(schema: dict[str, Any], patches: list[tuple[str, Any, Any]]) -> None: - """Apply `(path, old, new)` JSON-pointer-ish patches in place, asserting the old value.""" + """Apply `(path, old, new)` JSON-pointer-ish patches in place, asserting the old value. + + Path segments use JSON-pointer escaping (`~1` for `/`, `~0` for `~`) so keys + that themselves contain a slash (the reserved `io.modelcontextprotocol/*` + `_meta` keys) are addressable. + """ for path, old, new in patches: - *parts, leaf = path.split("/") + *parts, leaf = (part.replace("~1", "/").replace("~0", "~") for part in path.split("/")) node: Any = schema for part in parts: node = node[int(part) if part.isdigit() else part] @@ -140,6 +149,23 @@ def patch_schema(schema: dict[str, Any], patches: list[tuple[str, Any, Any]]) -> node[leaf] = new +def make_server_info_opaque(schema: dict[str, Any]) -> None: + """Strip the typed `$ref` from every result-meta serverInfo property. + + The stamp is display-only: the spec forbids acting on it, so a malformed + value must never fail a whole response (clients validate every inbound + result against this surface). Walking every `$defs` entry keeps future + result-meta definitions lenient by construction instead of relying on an + enumerated list; the typed, lenient parse happens at the read edge + (`ClientSession.server_info`). typescript-sdk does the same with a + schema-level catch-to-undefined. + """ + for definition in schema.get("$defs", {}).values(): + prop = definition.get("properties", {}).get(SERVER_INFO_META_PROPERTY) + if prop is not None and "$ref" in prop: + del prop["$ref"] + + def run_codegen(schema_path: Path, output_path: Path) -> None: """Run datamodel-code-generator at the version pinned in the `codegen` dependency group.""" # fmt: off @@ -197,6 +223,7 @@ def build(entry: dict[str, str]) -> str: version = entry["protocol_version"] schema = json.loads((SCHEMA_DIR / f"{version}.json").read_text()) patch_schema(schema, SCHEMA_PATCHES.get(version, [])) + make_server_info_opaque(schema) with tempfile.TemporaryDirectory() as tmp: patched = Path(tmp) / "schema.json" diff --git a/src/mcp-types/mcp_types/__init__.py b/src/mcp-types/mcp_types/__init__.py index 87c0c5d59..41cec2edf 100644 --- a/src/mcp-types/mcp_types/__init__.py +++ b/src/mcp-types/mcp_types/__init__.py @@ -12,6 +12,7 @@ DEFAULT_NEGOTIATED_VERSION, LOG_LEVEL_META_KEY, PROTOCOL_VERSION_META_KEY, + SERVER_INFO_META_KEY, Annotations, AudioContent, BaseMetadata, @@ -231,6 +232,8 @@ "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", diff --git a/src/mcp-types/mcp_types/_types.py b/src/mcp-types/mcp_types/_types.py index 9c0516836..e19ebe0b9 100644 --- a/src/mcp-types/mcp_types/_types.py +++ b/src/mcp-types/mcp_types/_types.py @@ -69,6 +69,13 @@ class MCPModel(BaseModel): 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.""" diff --git a/src/mcp-types/mcp_types/v2026_07_28/__init__.py b/src/mcp-types/mcp_types/v2026_07_28/__init__.py index 561225273..fb168b305 100644 --- a/src/mcp-types/mcp_types/v2026_07_28/__init__.py +++ b/src/mcp-types/mcp_types/v2026_07_28/__init__.py @@ -743,9 +743,7 @@ class ResultMetaObject(WireModel): model_config = ConfigDict( extra="allow", ) - io_modelcontextprotocol_server_info: Annotated[ - Implementation | None, Field(alias="io.modelcontextprotocol/serverInfo") - ] = None + 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 @@ -901,9 +899,7 @@ class SubscriptionsListenResultMeta(WireModel): model_config = ConfigDict( extra="allow", ) - io_modelcontextprotocol_server_info: Annotated[ - Implementation | None, Field(alias="io.modelcontextprotocol/serverInfo") - ] = None + 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 diff --git a/src/mcp/client/client.py b/src/mcp/client/client.py index d519106a6..e84067501 100644 --- a/src/mcp/client/client.py +++ b/src/mcp/client/client.py @@ -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 @@ async def __aenter__(self) -> 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 @@ def session(self) -> ClientSession: 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: diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index 097ade1c9..36cb78df5 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -20,6 +20,7 @@ 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 @@ def __init__( 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 @@ def adopt(self, result: types.InitializeResult | types.DiscoverResult) -> None: 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 @@ def protocol_version(self) -> str | None: @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 diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index abf98c6fd..dfe6f9e6b 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -41,6 +41,7 @@ async def main(): from collections.abc import AsyncIterator, Awaitable, Callable, Mapping from contextlib import AbstractAsyncContextManager, asynccontextmanager from dataclasses import dataclass +from functools import cached_property from importlib.metadata import version as importlib_version from typing import Any, Generic, overload @@ -146,6 +147,7 @@ def __init__( website_url: str | None = None, icons: list[types.Icon] | None = None, cache_hints: Mapping[CacheableMethod, CacheHint] | None = None, + include_server_info: bool = True, lifespan: Callable[ [Server[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT], @@ -229,6 +231,7 @@ def __init__( website_url: str | None = None, icons: list[types.Icon] | None = None, cache_hints: Mapping[CacheableMethod, CacheHint] | None = None, + include_server_info: bool = True, lifespan: Callable[ [Server[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT], @@ -321,6 +324,7 @@ def __init__( website_url: str | None = None, icons: list[types.Icon] | None = None, cache_hints: Mapping[CacheableMethod, CacheHint] | None = None, + include_server_info: bool = True, lifespan: Callable[ [Server[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT], @@ -428,6 +432,10 @@ def __init__( self.instructions = instructions self.website_url = website_url self.icons = icons + # Spec 2026-07-28: servers SHOULD stamp `serverInfo` into every result's + # `_meta` "unless specifically configured not to do so" - this is that + # configuration. Consumed at the single stamping site in `ServerRunner`. + self.include_server_info = include_server_info # Per-method `ttl_ms`/`cache_scope` fills, applied by `ServerRunner` # after the handler returns; fields the handler set explicitly win. self.cache_hints: dict[str, CacheHint] = validate_cache_hints(cache_hints) @@ -648,6 +656,15 @@ def server_info(self) -> types.Implementation: icons=self.icons, ) + @cached_property + def server_info_stamp(self) -> dict[str, Any]: + """The wire dump of `server_info`, computed once per server. + + Identity is fixed at construction, so the per-result `_meta` stamping + site (`ServerRunner`) reads this instead of re-dumping per request. + """ + return self.server_info.model_dump(by_alias=True, mode="json", exclude_none=True) + async def _handle_discover( self, ctx: ServerRequestContext[LifespanResultT], params: types.RequestParams | None ) -> types.DiscoverResult: @@ -662,7 +679,6 @@ async def _handle_discover( 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, ) diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index 8877c3cdf..b2b8a42ee 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -183,6 +183,7 @@ def __init__( resource_security: ResourceSecurity = DEFAULT_RESOURCE_SECURITY, request_state_security: RequestStateSecurity | None = None, cache_hints: Mapping[CacheableMethod, CacheHint] | None = None, + include_server_info: bool = True, subscriptions: SubscriptionBus | None = None, ): self._resource_security = resource_security @@ -216,6 +217,7 @@ def __init__( icons=icons, version=version, cache_hints=cache_hints, + include_server_info=include_server_info, on_list_tools=self._handle_list_tools, on_call_tool=self._handle_call_tool, on_list_resources=self._handle_list_resources, diff --git a/src/mcp/server/runner.py b/src/mcp/server/runner.py index 297bbcde6..bd1e9fe3c 100644 --- a/src/mcp/server/runner.py +++ b/src/mcp/server/runner.py @@ -29,6 +29,7 @@ INVALID_REQUEST, METHOD_NOT_FOUND, PROTOCOL_VERSION_META_KEY, + SERVER_INFO_META_KEY, UNSUPPORTED_PROTOCOL_VERSION, CacheableResult, ErrorData, @@ -226,6 +227,21 @@ async def _inner(ctx: ServerRequestContext[LifespanT, Any]) -> HandlerResult: # `_inner` already produced the wire dict; a middleware that short-circuited # without `call_next` is trusted to return its own well-formed result. result = _dump_result(await call(ctx)) + # Spec 2026-07-28 (#3002): stamp `serverInfo` into every modern result's + # `_meta`. This runs after the middleware chain - the one exit every + # result takes, including middleware short-circuits - so coverage holds + # by construction. Custom-method and short-circuit results pass through + # by reference, so stamping builds a shallow copy rather than mutating a + # dict the handler may retain. A handler-authored value wins; a + # non-mapping `_meta` is the handler's to own, not ours to clobber. + if self.server.include_server_info and version in MODERN_PROTOCOL_VERSIONS: + raw_meta = result.get("_meta") + if raw_meta is None: + result = {**result, "_meta": {SERVER_INFO_META_KEY: dict(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 = {**result, "_meta": {**meta, SERVER_INFO_META_KEY: dict(self.server.server_info_stamp)}} if method == "initialize": # Commit only on chain success, so a middleware veto leaves no state. # Race-free: the read loop is parked until this call returns. diff --git a/tests/client/test_client.py b/tests/client/test_client.py index 6c78503b9..419ba102e 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -82,13 +82,14 @@ async def handle_completion(ctx: ServerRequestContext, params: types.CompleteReq on_unsubscribe_resource=handle_unsubscribe_resource, on_set_logging_level=handle_set_logging_level, on_completion=handle_completion, + include_server_info=False, ) @pytest.fixture def app() -> MCPServer: """Create an MCPServer server for testing.""" - server = MCPServer("test") + server = MCPServer("test", include_server_info=False) @server.tool() def greet(name: str) -> str: @@ -119,6 +120,7 @@ async def test_client_is_initialized(app: MCPServer): tools=ToolsCapability(list_changed=False), ) ) + assert client.server_info is not None assert client.server_info.name == "test" @@ -573,6 +575,7 @@ async def scripted_transport() -> AsyncIterator[TransportStreams]: with anyio.fail_after(5): async with Client(scripted_transport(), mode="auto") as client: assert client.protocol_version == LATEST_HANDSHAKE_VERSION + assert client.server_info is not None assert client.server_info.name == "legacy-only" assert methods_seen == ["server/discover", "initialize", "notifications/initialized"] @@ -728,7 +731,7 @@ async def test_call_tool_auto_loop_dispatches_elicitation_then_returns_final_res """When the server returns `InputRequiredResult` carrying an elicitation, `Client.call_tool` routes it to `elicitation_callback` and retries automatically — the caller sees only the terminal `CallToolResult`.""" - server = MCPServer("test") + server = MCPServer("test", include_server_info=False) @server.tool() async def greet(ctx: Context) -> str | types.InputRequiredResult: @@ -765,7 +768,7 @@ async def elicitation_callback( async def test_call_tool_auto_loop_dispatches_sampling_then_returns_final_result() -> None: """`InputRequiredResult` with an embedded `CreateMessageRequest` is routed to `sampling_callback` and the call retried with the model's reply.""" - server = MCPServer("test") + server = MCPServer("test", include_server_info=False) @server.tool() async def ask(ctx: Context) -> str | types.InputRequiredResult: @@ -810,7 +813,7 @@ async def sampling_callback( async def test_call_tool_auto_loop_dispatches_list_roots_then_returns_final_result() -> None: """`InputRequiredResult` with an embedded `ListRootsRequest` is routed to `list_roots_callback` and the call retried with the returned roots.""" - server = MCPServer("test") + server = MCPServer("test", include_server_info=False) @server.tool() async def count_roots(ctx: Context) -> str | types.InputRequiredResult: @@ -906,7 +909,7 @@ async def handler( return GetPromptResult(messages=[PromptMessage(role="user", content=TextContent(text="ok"))]) return types.InputRequiredResult(input_requests={"ask": _name_elicitation()}) - server = Server("test") + server = Server("test", include_server_info=False) server.add_request_handler("prompts/get", types.GetPromptRequestParams, handler) async def elicitation_callback( @@ -932,7 +935,7 @@ async def handler( return ReadResourceResult(contents=[TextResourceContents(uri="memory://gated", text="unlocked")]) return types.InputRequiredResult(input_requests={"ask": _name_elicitation()}) - server = Server("test") + server = Server("test", include_server_info=False) server.add_request_handler("resources/read", types.ReadResourceRequestParams, handler) async def elicitation_callback( diff --git a/tests/client/test_client_caching.py b/tests/client/test_client_caching.py index 953ea005a..4ebf6483e 100644 --- a/tests/client/test_client_caching.py +++ b/tests/client/test_client_caching.py @@ -24,7 +24,6 @@ ElicitRequest, ElicitRequestFormParams, ElicitResult, - Implementation, InputRequiredResult, ListPromptsResult, ListResourcesResult, @@ -845,7 +844,6 @@ async def on_request(request: httpx2.Request) -> None: discover = DiscoverResult( supported_versions=[LATEST_MODERN_VERSION], capabilities=ServerCapabilities(), - server_info=Implementation(name="srv", version="0"), ) with anyio.fail_after(5): diff --git a/tests/client/test_probe.py b/tests/client/test_probe.py index e7fe49dc4..d4436a7b7 100644 --- a/tests/client/test_probe.py +++ b/tests/client/test_probe.py @@ -25,8 +25,8 @@ METHOD_NOT_FOUND, PARSE_ERROR, REQUEST_TIMEOUT, + SERVER_INFO_META_KEY, UNSUPPORTED_PROTOCOL_VERSION, - Implementation, ServerCapabilities, ) from mcp_types.version import ( @@ -85,7 +85,7 @@ def _discover_dict(versions: list[str] | None = None) -> dict[str, Any]: return types.DiscoverResult( supported_versions=versions or list(MODERN_PROTOCOL_VERSIONS), capabilities=ServerCapabilities(), - server_info=Implementation(name="stub", version="0"), + _meta={SERVER_INFO_META_KEY: {"name": "stub", "version": "0"}}, ).model_dump(by_alias=True, mode="json", exclude_none=True) @@ -101,11 +101,13 @@ def _err_32022(supported: Any) -> MCPError: async def test_a_valid_discover_result_is_adopted_without_initializing() -> None: - """A parseable `DiscoverResult` from the probe is adopted; `initialize()` is never called.""" + """A parseable `DiscoverResult` from the probe is adopted intact — including the + `_meta` serverInfo stamp — and `initialize()` is never called.""" session = _StubSession(_discover_dict()) await _negotiate(session) assert session.adopted is not None - assert session.adopted.server_info.name == "stub" + assert session.adopted.meta is not None + assert session.adopted.meta[SERVER_INFO_META_KEY] == {"name": "stub", "version": "0"} assert not session.initialized assert session.probed_at == [LATEST_MODERN_VERSION] diff --git a/tests/client/test_send_request_mcp_name.py b/tests/client/test_send_request_mcp_name.py index e22ec4015..27c41e661 100644 --- a/tests/client/test_send_request_mcp_name.py +++ b/tests/client/test_send_request_mcp_name.py @@ -101,7 +101,6 @@ def _adopt_modern(session: ClientSession) -> None: types.DiscoverResult( supported_versions=[LATEST_MODERN_VERSION], capabilities=ServerCapabilities(), - server_info=Implementation(name="stub", version="0"), ) ) diff --git a/tests/client/test_session.py b/tests/client/test_session.py index 507c8f69e..9c935ef18 100644 --- a/tests/client/test_session.py +++ b/tests/client/test_session.py @@ -16,6 +16,7 @@ METHOD_NOT_FOUND, PROTOCOL_VERSION_META_KEY, REQUEST_TIMEOUT, + SERVER_INFO_META_KEY, UNSUPPORTED_PROTOCOL_VERSION, CallToolResult, Implementation, @@ -1323,7 +1324,6 @@ def test_adopt_raises_when_no_mutual_modern_version_is_supported() -> None: types.DiscoverResult( supported_versions=["1999-01-01"], capabilities=types.ServerCapabilities(), - server_info=types.Implementation(name="s", version="0"), result_type="complete", ttl_ms=0, cache_scope="public", @@ -1514,7 +1514,6 @@ def _discover_result_dict() -> dict[str, Any]: return types.DiscoverResult( supported_versions=["2026-07-28"], capabilities=ServerCapabilities(), - server_info=Implementation(name="stub", version="0"), ).model_dump(by_alias=True, mode="json", exclude_none=True) @@ -1654,12 +1653,13 @@ def test_era_neutral_properties_are_none_before_any_handshake() -> None: @pytest.mark.anyio async def test_era_neutral_properties_after_discover() -> None: """SDK-defined: after `discover()` the era-neutral accessors read from the - DiscoverResult; `initialize_result` stays None.""" + DiscoverResult; `server_info` comes from the `_meta` serverInfo stamp and + `initialize_result` stays None.""" raw = types.DiscoverResult( supported_versions=["2026-07-28"], capabilities=ServerCapabilities(tools=types.ToolsCapability(list_changed=True)), - server_info=Implementation(name="discovered", version="2.0"), instructions="hello", + _meta={SERVER_INFO_META_KEY: {"name": "discovered", "version": "2.0"}}, ).model_dump(by_alias=True, mode="json", exclude_none=True) dispatcher = _ScriptedDispatcher(raw) with anyio.fail_after(5): @@ -1673,6 +1673,40 @@ async def test_era_neutral_properties_after_discover() -> None: assert isinstance(session.discover_result, types.DiscoverResult) +@pytest.mark.anyio +async def test_server_info_is_none_when_the_discover_result_carries_no_stamp() -> None: + """Spec-mandated (2026-07-28, #3002): the serverInfo result-`_meta` stamp is + optional, so a server that does not identify itself reads as `None` rather + than failing the connection.""" + raw = types.DiscoverResult( + supported_versions=["2026-07-28"], + capabilities=ServerCapabilities(), + ).model_dump(by_alias=True, mode="json", exclude_none=True) + dispatcher = _ScriptedDispatcher(raw) + with anyio.fail_after(5): + async with ClientSession(dispatcher=dispatcher) as session: + await session.discover() + assert session.protocol_version == "2026-07-28" + assert session.server_info is None + + +@pytest.mark.anyio +async def test_a_malformed_server_info_stamp_reads_as_absent() -> None: + """Spec-mandated (2026-07-28, #3002): the stamp is self-reported and + display-only, so a value that is not an `Implementation` must not fail the + call; it reads as if the server sent none.""" + raw = types.DiscoverResult( + supported_versions=["2026-07-28"], + capabilities=ServerCapabilities(), + _meta={SERVER_INFO_META_KEY: {"version": "no name makes this invalid"}}, + ).model_dump(by_alias=True, mode="json", exclude_none=True) + dispatcher = _ScriptedDispatcher(raw) + with anyio.fail_after(5): + async with ClientSession(dispatcher=dispatcher) as session: + await session.discover() + assert session.server_info is None + + @pytest.mark.anyio async def test_discover_reraises_unsupported_version_with_malformed_error_data() -> None: """SDK-defined: a -32022 reply whose `data` is not a valid diff --git a/tests/client/test_session_claims.py b/tests/client/test_session_claims.py index 94ebd7946..f61fc3710 100644 --- a/tests/client/test_session_claims.py +++ b/tests/client/test_session_claims.py @@ -107,7 +107,6 @@ def _adopt_modern(session: ClientSession) -> None: types.DiscoverResult( supported_versions=[LATEST_MODERN_VERSION], capabilities=ServerCapabilities(), - server_info=Implementation(name="stub", version="0"), ) ) diff --git a/tests/client/test_session_concurrency.py b/tests/client/test_session_concurrency.py index 0a0ae62dd..bd34b9f40 100644 --- a/tests/client/test_session_concurrency.py +++ b/tests/client/test_session_concurrency.py @@ -34,7 +34,7 @@ async def test_concurrent_tool_calls_resolve_out_of_order_to_their_own_callers() completion_order: list[str] = [] results: dict[str, CallToolResult] = {} - server = MCPServer("parking") + server = MCPServer("parking", include_server_info=False) @server.tool() async def park(tag: str) -> str: diff --git a/tests/client/test_session_notification_bindings.py b/tests/client/test_session_notification_bindings.py index 2bed2bd64..45e09998b 100644 --- a/tests/client/test_session_notification_bindings.py +++ b/tests/client/test_session_notification_bindings.py @@ -7,7 +7,7 @@ import anyio import mcp_types as types import pytest -from mcp_types import EmptyResult, Implementation, ServerCapabilities +from mcp_types import EmptyResult, ServerCapabilities from mcp_types.version import LATEST_MODERN_VERSION from pydantic import BaseModel @@ -42,7 +42,6 @@ def _adopt_modern(session: ClientSession) -> None: types.DiscoverResult( supported_versions=[LATEST_MODERN_VERSION], capabilities=ServerCapabilities(), - server_info=Implementation(name="stub", version="0"), ) ) diff --git a/tests/client/test_subscriptions.py b/tests/client/test_subscriptions.py index 0cc4f133e..698e1eb01 100644 --- a/tests/client/test_subscriptions.py +++ b/tests/client/test_subscriptions.py @@ -408,7 +408,6 @@ async def test_listen_on_a_never_entered_session_raises_runtime_error(): types.DiscoverResult( supported_versions=["2026-07-28"], capabilities=types.ServerCapabilities(), - server_info=types.Implementation(name="stub", version="0"), ) ) with pytest.raises(RuntimeError, match="entered session"): diff --git a/tests/docs_src/_helpers.py b/tests/docs_src/_helpers.py new file mode 100644 index 000000000..eefc57a8d --- /dev/null +++ b/tests/docs_src/_helpers.py @@ -0,0 +1,24 @@ +"""Shared helpers for the docs_src tests.""" + +from importlib.metadata import version +from typing import TypeVar + +from mcp_types import SERVER_INFO_META_KEY, Result + +from mcp.server import Server +from mcp.server.mcpserver import MCPServer + +R = TypeVar("R", bound=Result) + + +def strip_server_info(result: R, server: Server | MCPServer) -> R: + """Assert the 2026-era serverInfo stamp, then drop it so snapshots stay stable. + + The doc snippets set no explicit version, so the stamp's version is the + installed mcp package version; keeping it in the inline snapshots would + make them commit-dependent. + """ + assert result.meta is not None + assert result.meta[SERVER_INFO_META_KEY] == {"name": server.name, "version": version("mcp")} + result.meta = None + return result diff --git a/tests/docs_src/test_client.py b/tests/docs_src/test_client.py index 3d70371f5..c8292d989 100644 --- a/tests/docs_src/test_client.py +++ b/tests/docs_src/test_client.py @@ -27,6 +27,7 @@ async def test_every_client_program_on_the_page_runs(capsys: pytest.CaptureFixtu async def test_connected_properties_are_populated_inside_the_block() -> None: """tutorial001: server_info, server_capabilities, protocol_version and instructions are just there.""" async with Client(tutorial001.mcp) as client: + assert client.server_info is not None assert client.server_info.name == "Bookshop" assert client.protocol_version == "2026-07-28" assert client.instructions == "Search the catalog before recommending a book." @@ -38,6 +39,7 @@ async def test_a_client_is_not_reusable_after_the_block_ends() -> None: """tutorial001: `async with` is the whole lifecycle. Construct a new Client per connection.""" client = Client(tutorial001.mcp) async with client: + assert client.server_info is not None assert client.server_info.name == "Bookshop" with pytest.raises(RuntimeError, match="cannot reenter"): await client.__aenter__() diff --git a/tests/docs_src/test_client_transports.py b/tests/docs_src/test_client_transports.py index 848eddd52..4da0da1f4 100644 --- a/tests/docs_src/test_client_transports.py +++ b/tests/docs_src/test_client_transports.py @@ -22,6 +22,7 @@ async def test_the_in_memory_program_on_the_page_runs(capsys: pytest.CaptureFixt async def test_in_memory_client_talks_to_the_server_object() -> None: """tutorial001: passing the server object connects in-process. No subprocess, no port.""" async with Client(tutorial001.mcp) as client: + assert client.server_info is not None assert client.server_info.name == "Bookshop" assert client.protocol_version == "2026-07-28" result = await client.call_tool("search_books", {"query": "dune"}) diff --git a/tests/docs_src/test_index.py b/tests/docs_src/test_index.py index 3012ae1a0..332184971 100644 --- a/tests/docs_src/test_index.py +++ b/tests/docs_src/test_index.py @@ -6,6 +6,7 @@ from docs_src.index.tutorial001 import mcp from mcp import Client +from tests.docs_src._helpers import strip_server_info # `pyproject.toml` globally downgrades `mcp.MCPDeprecationWarning` to *ignore* because the # SDK still calls those methods internally. A documentation example must never lean on @@ -18,6 +19,7 @@ async def test_add_tool() -> None: async with Client(mcp) as client: result = await client.call_tool("add", {"a": 1, "b": 2}) + result = strip_server_info(result, mcp) assert result == snapshot( CallToolResult(content=[TextContent(type="text", text="3")], structured_content={"result": 3}) ) diff --git a/tests/docs_src/test_logging.py b/tests/docs_src/test_logging.py index bed5c234b..457198332 100644 --- a/tests/docs_src/test_logging.py +++ b/tests/docs_src/test_logging.py @@ -9,6 +9,7 @@ from docs_src.logging import tutorial001 from mcp import Client from mcp.server import MCPServer +from tests.docs_src._helpers import strip_server_info # See test_index.py for why this is a per-module mark and not a conftest hook. pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] @@ -28,6 +29,7 @@ async def test_the_log_line_never_reaches_the_client() -> None: """tutorial001: the result is only the return value. Log output is invisible to the model.""" async with Client(tutorial001.mcp) as client: result = await client.call_tool("search_books", {"query": "dune"}) + result = strip_server_info(result, tutorial001.mcp) assert result == snapshot( CallToolResult( content=[TextContent(type="text", text="Found 3 books matching 'dune'.")], diff --git a/tests/docs_src/test_lowlevel.py b/tests/docs_src/test_lowlevel.py index 34746dd0b..afdd6f432 100644 --- a/tests/docs_src/test_lowlevel.py +++ b/tests/docs_src/test_lowlevel.py @@ -2,7 +2,15 @@ import pytest from inline_snapshot import snapshot -from mcp_types import INTERNAL_ERROR, CallToolRequestParams, CallToolResult, ErrorData, RequestParams, TextContent +from mcp_types import ( + INTERNAL_ERROR, + SERVER_INFO_META_KEY, + CallToolRequestParams, + CallToolResult, + ErrorData, + RequestParams, + TextContent, +) from docs_src.lowlevel import tutorial001, tutorial002, tutorial003, tutorial004, tutorial005, tutorial006 from mcp import Client, MCPError @@ -99,6 +107,10 @@ async def test_meta_reaches_the_client_application() -> None: """tutorial004: `_meta=` on the result comes back as `result.meta` and serialises under `_meta`.""" async with Client(tutorial004.server) as client: result = await client.call_tool("search_books", {"query": "dune", "limit": 5}) + assert result.meta is not None + # The server identity stamp shares `_meta` with the handler's keys without clobbering + # them. Remove it before the exact compares: its `version` tracks the installed package. + del result.meta[SERVER_INFO_META_KEY] assert result.meta == {"bookshop/record_ids": ["bk_17", "bk_42", "bk_99"]} assert result.model_dump(by_alias=True, exclude_none=True) == snapshot( { diff --git a/tests/docs_src/test_media.py b/tests/docs_src/test_media.py index 7ea89eb79..7ccb4c8a2 100644 --- a/tests/docs_src/test_media.py +++ b/tests/docs_src/test_media.py @@ -93,6 +93,7 @@ def test_raw_data_without_a_format_falls_back_to_a_default_mime_type() -> None: async def test_icons_are_visible_where_they_were_declared() -> None: """tutorial004: server icons land on `server_info`, tool icons on the `Tool`, resource icons on the `Resource`.""" async with Client(tutorial004.mcp) as client: + assert client.server_info is not None assert client.server_info.icons == [ Icon(src="https://example.com/brand-kit.png", mime_type="image/png", sizes=["48x48"]) ] diff --git a/tests/docs_src/test_mrtr.py b/tests/docs_src/test_mrtr.py index 50a9e53d9..ddebd8009 100644 --- a/tests/docs_src/test_mrtr.py +++ b/tests/docs_src/test_mrtr.py @@ -22,6 +22,7 @@ from mcp import Client, MCPError from mcp.client import ClientRequestContext from mcp.server.mcpserver import InvalidRequestState +from tests.docs_src._helpers import strip_server_info # See test_index.py for why this is a per-module mark and not a conftest hook. pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] @@ -31,6 +32,7 @@ async def test_first_call_returns_an_input_required_result() -> None: """tutorial001: a tool that is missing input returns `InputRequiredResult` instead of calling back.""" async with Client(tutorial001.server) as client: result = await client.session.call_tool("provision", {"name": "orders"}, allow_input_required=True) + result = strip_server_info(result, tutorial001.server) assert result == snapshot( InputRequiredResult( result_type="input_required", @@ -57,6 +59,7 @@ async def test_the_auto_loop_drives_the_call_to_completion() -> None: """tutorial003: register `elicitation_callback`, call the tool, get a plain `CallToolResult` back.""" async with Client(tutorial001.server, elicitation_callback=tutorial003.handle_elicitation) as client: result = await client.call_tool("provision", {"name": "orders"}) + result = strip_server_info(result, tutorial001.server) assert result == snapshot( CallToolResult(content=[TextContent(type="text", text="Provisioned 'orders' in eu-west-1.")]) ) @@ -80,6 +83,7 @@ async def test_retry_with_input_responses_and_request_state_completes_the_call() input_responses={"region": ElicitResult(action="accept", content={"region": "eu-west-1"})}, request_state="provision-v1", ) + result = strip_server_info(result, tutorial001.server) assert result == snapshot( CallToolResult(content=[TextContent(type="text", text="Provisioned 'orders' in eu-west-1.")]) ) @@ -89,6 +93,7 @@ async def test_the_manual_loop_drives_the_call_to_completion() -> None: """tutorial002: `client.session.call_tool(..., allow_input_required=True)` for callers who own the loop.""" async with Client(tutorial001.server) as client: result = await tutorial002.provision(client, "billing") + result = strip_server_info(result, tutorial001.server) assert result == snapshot( CallToolResult(content=[TextContent(type="text", text="Provisioned 'billing' in eu-west-1.")]) ) @@ -121,6 +126,7 @@ async def test_a_prompt_returns_an_input_required_result_on_the_first_round() -> returns the `InputRequiredResult` itself.""" async with Client(tutorial004.mcp) as client: result = await client.session.get_prompt("briefing", allow_input_required=True) + result = strip_server_info(result, tutorial004.mcp) assert result == snapshot( InputRequiredResult( result_type="input_required", @@ -151,6 +157,7 @@ async def test_the_prompt_auto_loop_returns_the_final_messages() -> None: caller sees only the complete `GetPromptResult`.""" async with Client(tutorial004.mcp, elicitation_callback=_answer_audience) as client: result = await client.get_prompt("briefing") + result = strip_server_info(result, tutorial004.mcp) assert result == snapshot( GetPromptResult( description="Draft a briefing tuned to its audience.", diff --git a/tests/docs_src/test_prompts.py b/tests/docs_src/test_prompts.py index 3b0ad571a..c375ab614 100644 --- a/tests/docs_src/test_prompts.py +++ b/tests/docs_src/test_prompts.py @@ -8,6 +8,7 @@ from docs_src.prompts import tutorial001, tutorial002, tutorial003 from mcp import Client, MCPError +from tests.docs_src._helpers import strip_server_info # See test_index.py for why this is a per-module mark and not a conftest hook. pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] @@ -30,6 +31,7 @@ async def test_returned_string_becomes_one_user_message() -> None: """tutorial001: a `str` return value is rendered as a single `user` message.""" async with Client(tutorial001.mcp) as client: result = await client.get_prompt("review_code", {"code": "def add(a, b): return a + b"}) + result = strip_server_info(result, tutorial001.mcp) assert result.model_dump(mode="json", by_alias=True, exclude_none=True) == snapshot( { "description": "Review a piece of code.", diff --git a/tests/docs_src/test_protocol_versions.py b/tests/docs_src/test_protocol_versions.py index 73366a984..06b8a3a0c 100644 --- a/tests/docs_src/test_protocol_versions.py +++ b/tests/docs_src/test_protocol_versions.py @@ -3,7 +3,7 @@ import re import pytest -from mcp_types import DiscoverResult, Implementation, ServerCapabilities +from mcp_types import SERVER_INFO_META_KEY, DiscoverResult, Implementation, ServerCapabilities from docs_src.protocol_versions import tutorial001, tutorial002, tutorial003, tutorial004 from mcp import Client @@ -16,6 +16,7 @@ async def test_auto_lands_on_the_modern_version() -> None: """tutorial001: the default `mode="auto"` probes `server/discover` and adopts the result.""" async with Client(tutorial001.mcp) as client: assert client.protocol_version == "2026-07-28" + assert client.server_info is not None assert client.server_info.name == "Bookshop" assert client.session.discover_result is not None assert client.session.initialize_result is None @@ -25,18 +26,18 @@ async def test_legacy_forces_the_initialize_handshake() -> None: """tutorial002: `mode="legacy"` runs `initialize` against the very same server.""" async with Client(tutorial002.mcp, mode="legacy") as client: assert client.protocol_version == "2025-11-25" + assert client.server_info is not None assert client.server_info.name == "Bookshop" assert client.session.initialize_result is not None assert client.session.discover_result is None async def test_version_pin_sends_nothing_and_knows_nothing() -> None: - """tutorial003: a pin adopts the version locally; `server_info` and capabilities are blank.""" + """tutorial003: a pin adopts the version locally; `server_info` is None and capabilities are blank.""" async with Client(tutorial003.mcp, mode="2026-07-28") as client: assert client.protocol_version == "2026-07-28" - assert client.server_info == Implementation(name="", version="") - # The `!!! check` fence is the literal `print(client.server_info)` output. - assert str(client.server_info) == "name='' title=None version='' description=None website_url=None icons=None" + # The `!!! check` fence is the literal `print(client.server_info)` output: None. + assert client.server_info is None assert client.server_capabilities == ServerCapabilities() result = await client.call_tool("search_books", {"query": "dune"}) assert result.structured_content == {"result": "Found 3 books matching 'dune'."} @@ -63,6 +64,7 @@ async def test_prior_discover_round_trips() -> None: async with Client(tutorial004.mcp, mode="2026-07-28", prior_discover=saved) as client: assert client.protocol_version == "2026-07-28" + assert client.server_info is not None assert client.server_info.name == "Bookshop" assert client.server_capabilities.tools is not None @@ -77,6 +79,7 @@ async def test_discover_result_survives_json() -> None: assert restored == saved async with Client(tutorial004.mcp, mode="2026-07-28", prior_discover=restored) as client: + assert client.server_info is not None assert client.server_info.name == "Bookshop" @@ -85,9 +88,14 @@ async def test_prior_discover_is_ignored_unless_mode_is_a_pin() -> None: stale = DiscoverResult( supported_versions=["2026-07-28"], capabilities=ServerCapabilities(), - server_info=Implementation(name="Stale", version="0.0.0"), + _meta={ + SERVER_INFO_META_KEY: Implementation(name="Stale", version="0.0.0").model_dump( + by_alias=True, mode="json", exclude_none=True + ) + }, ) async with Client(tutorial004.mcp, prior_discover=stale) as client: + assert client.server_info is not None assert client.server_info.name == "Bookshop" async with Client(tutorial004.mcp, mode="legacy", prior_discover=stale) as client: assert client.session.discover_result is None diff --git a/tests/docs_src/test_run.py b/tests/docs_src/test_run.py index 4b9a8926a..fb9ab908e 100644 --- a/tests/docs_src/test_run.py +++ b/tests/docs_src/test_run.py @@ -9,6 +9,7 @@ from docs_src.run import tutorial001, tutorial002, tutorial003 from mcp import Client from mcp.server import MCPServer +from tests.docs_src._helpers import strip_server_info # See test_index.py for why this is a per-module mark and not a conftest hook. pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] @@ -18,6 +19,7 @@ async def test_the_run_call_is_guarded_so_importing_does_not_start_a_server() -> """tutorial001: `run()` sits under `__main__`, so the module imports cleanly and serves in-memory.""" async with Client(tutorial001.mcp) as client: result = await client.call_tool("search_books", {"query": "dune"}) + result = strip_server_info(result, tutorial001.mcp) assert result == snapshot( CallToolResult( content=[TextContent(type="text", text="Found 3 books matching 'dune'.")], diff --git a/tests/docs_src/test_session_groups.py b/tests/docs_src/test_session_groups.py index 79721c613..b8d54db17 100644 --- a/tests/docs_src/test_session_groups.py +++ b/tests/docs_src/test_session_groups.py @@ -16,6 +16,12 @@ pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] +def _server_info(client: Client) -> Implementation: + """Narrow `client.server_info` for `connect_with_session`: these servers all identify themselves.""" + assert client.server_info is not None + return client.server_info + + async def test_both_servers_call_their_tool_search() -> None: """tutorial001 + tutorial002: two unrelated servers, one colliding tool name.""" async with Client(tutorial001.mcp) as library, Client(tutorial002.mcp) as web: @@ -29,7 +35,7 @@ async def test_a_connected_server_is_aggregated_into_the_group() -> None: """tutorial003: the group exposes every component of every connected server as a dict.""" async with Client(tutorial001.mcp) as library: group = ClientSessionGroup() - await group.connect_with_session(library.server_info, library.session) + await group.connect_with_session(_server_info(library), library.session) assert sorted(group.tools) == ["search"] assert sorted(group.resources) == ["hours"] assert group.prompts == {} @@ -40,9 +46,9 @@ async def test_colliding_names_are_rejected() -> None: """tutorial003: without a hook the second `search` raises, and nothing from `Web` is kept.""" async with Client(tutorial001.mcp) as library, Client(tutorial002.mcp) as web: group = ClientSessionGroup() - await group.connect_with_session(library.server_info, library.session) + await group.connect_with_session(_server_info(library), library.session) with pytest.raises(MCPError) as exc_info: - await group.connect_with_session(web.server_info, web.session) + await group.connect_with_session(_server_info(web), web.session) assert str(exc_info.value) == "{'search'} already exist in group tools." assert exc_info.value.error.code == INVALID_PARAMS assert sorted(group.tools) == ["search"] @@ -56,8 +62,8 @@ async def test_component_name_hook_prefixes_every_name() -> None: """tutorial004: the hook rewrites every registered name, so both servers coexist.""" async with Client(tutorial001.mcp) as library, Client(tutorial002.mcp) as web: group = ClientSessionGroup(component_name_hook=tutorial004.by_server) - await group.connect_with_session(library.server_info, library.session) - await group.connect_with_session(web.server_info, web.session) + await group.connect_with_session(_server_info(library), library.session) + await group.connect_with_session(_server_info(web), web.session) assert sorted(group.tools) == ["Library.search", "Web.search"] assert sorted(group.resources) == ["Library.hours"] @@ -71,7 +77,7 @@ async def test_the_key_is_prefixed_but_the_wire_name_is_not() -> None: """tutorial004: the dict key is yours; the `Tool` inside keeps the name the server declared.""" async with Client(tutorial002.mcp) as web: group = ClientSessionGroup(component_name_hook=tutorial004.by_server) - await group.connect_with_session(web.server_info, web.session) + await group.connect_with_session(_server_info(web), web.session) assert group.tools["Web.search"].name == "search" @@ -79,8 +85,8 @@ async def test_call_tool_routes_to_the_owning_server() -> None: """tutorial004: `group.call_tool` resolves the prefixed name to the session that owns it.""" async with Client(tutorial001.mcp) as library, Client(tutorial002.mcp) as web: group = ClientSessionGroup(component_name_hook=tutorial004.by_server) - await group.connect_with_session(library.server_info, library.session) - await group.connect_with_session(web.server_info, web.session) + await group.connect_with_session(_server_info(library), library.session) + await group.connect_with_session(_server_info(web), web.session) web_result = await group.call_tool("Web.search", {"query": "model context protocol"}) assert web_result.structured_content == {"result": "12 pages match 'model context protocol'."} library_result = await group.call_tool("Library.search", {"query": "dune"}) @@ -91,8 +97,8 @@ async def test_disconnect_removes_every_component_of_that_server() -> None: """tutorial004: `disconnect_from_server` takes the session back out of all three dicts.""" async with Client(tutorial001.mcp) as library, Client(tutorial002.mcp) as web: group = ClientSessionGroup(component_name_hook=tutorial004.by_server) - await group.connect_with_session(library.server_info, library.session) - web_session = await group.connect_with_session(web.server_info, web.session) + await group.connect_with_session(_server_info(library), library.session) + web_session = await group.connect_with_session(_server_info(web), web.session) await group.disconnect_from_server(web_session) assert sorted(group.tools) == ["Library.search"] assert sorted(group.resources) == ["Library.hours"] diff --git a/tests/docs_src/test_testing.py b/tests/docs_src/test_testing.py index 5ab73e2e9..a6104840f 100644 --- a/tests/docs_src/test_testing.py +++ b/tests/docs_src/test_testing.py @@ -10,6 +10,7 @@ from docs_src.testing.tutorial001 import mcp from mcp import Client +from tests.docs_src._helpers import strip_server_info # See test_index.py for why this is a per-module mark and not a conftest hook. pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] @@ -18,6 +19,7 @@ async def test_call_add_tool() -> None: async with Client(mcp, raise_exceptions=True) as client: result = await client.call_tool("add", {"a": 1, "b": 2}) + result = strip_server_info(result, mcp) assert result == snapshot( CallToolResult(content=[TextContent(type="text", text="3")], structured_content={"result": 3}) ) diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index 752c17aa4..a9fa7ea87 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -354,8 +354,8 @@ def __post_init__(self) -> None: "lifecycle:stateless:request-envelope": Requirement( source=f"{SPEC_2026_BASE_URL}/basic/lifecycle#stateless-operation", behavior=( - "At protocol_version 2026-07-28, every request carries io.modelcontextprotocol/protocolVersion, " - "/clientInfo, and /clientCapabilities in params._meta; no initialize handshake occurs." + "At protocol_version 2026-07-28, every request carries io.modelcontextprotocol/protocolVersion " + "and /clientCapabilities in params._meta (/clientInfo is optional); no initialize handshake occurs." ), added_in="2026-07-28", ), @@ -408,7 +408,8 @@ def __post_init__(self) -> None: source=f"{SPEC_2026_BASE_URL}/basic/lifecycle#discover", behavior=( "Calling discover() sends server/discover with no params and returns a typed DiscoverResult " - "carrying protocolVersion, capabilities, serverInfo and the cache hint fields." + "carrying supportedVersions, capabilities and the cache hint fields; the server's identity " + "travels as the io.modelcontextprotocol/serverInfo stamp in the result _meta." ), added_in="2026-07-28", ), @@ -3261,8 +3262,10 @@ def __post_init__(self) -> None: "hosting:http:modern:discover-response-shape": Requirement( source=f"{SPEC_2026_BASE_URL}/basic/index", behavior=( - "A 2026-07-28 server/discover response carries supportedVersions, capabilities, and " - "serverInfo, with supportedVersions naming the modern protocol revisions the server accepts." + "A 2026-07-28 server/discover response carries supportedVersions and capabilities in the " + "result body, with supportedVersions naming the modern protocol revisions the server " + "accepts; serverInfo is not a body field and travels as the io.modelcontextprotocol/serverInfo " + "result _meta stamp." ), added_in="2026-07-28", transports=("streamable-http",), diff --git a/tests/interaction/conftest.py b/tests/interaction/conftest.py index b918daf00..d46ff26a8 100644 --- a/tests/interaction/conftest.py +++ b/tests/interaction/conftest.py @@ -6,10 +6,14 @@ bounds, and known-failure xfails declaratively. """ -from functools import partial +from contextlib import AbstractAsyncContextManager +from typing import Any import pytest +from mcp.client.client import Client +from mcp.server import Server +from mcp.server.mcpserver import MCPServer from tests.interaction._connect import ( Connect, connect_in_memory, @@ -45,4 +49,15 @@ def connect(request: pytest.FixtureRequest) -> Connect: transport, spec_version = request.param assert isinstance(transport, str) assert isinstance(spec_version, str) - return partial(_FACTORIES[transport], spec_version=spec_version) + factory = _FACTORIES[transport] + + def _connect(server: Server | MCPServer, **kwargs: Any) -> AbstractAsyncContextManager[Client]: + # The matrix compares exact result payloads, and the (default-on) 2026-era + # serverInfo `_meta` stamp would bake the commit-dependent package version + # into every snapshot. The matrix therefore runs with stamping off; + # stamping itself has dedicated coverage in tests/server/. + lowlevel = server._lowlevel_server if isinstance(server, MCPServer) else server + lowlevel.include_server_info = False + return factory(server, spec_version=spec_version, **kwargs) + + return _connect diff --git a/tests/interaction/lowlevel/test_client_connect.py b/tests/interaction/lowlevel/test_client_connect.py index eda1b8423..a992027a2 100644 --- a/tests/interaction/lowlevel/test_client_connect.py +++ b/tests/interaction/lowlevel/test_client_connect.py @@ -25,6 +25,7 @@ INVALID_REQUEST, METHOD_NOT_FOUND, PROTOCOL_VERSION_META_KEY, + SERVER_INFO_META_KEY, UNSUPPORTED_PROTOCOL_VERSION, DiscoverResult, Implementation, @@ -123,13 +124,13 @@ async def test_prior_discover_populates_state_with_zero_connect_time_traffic() - """`Client(..., mode=, prior_discover=...)` sends nothing on entry and exposes the prior server_info. Requirement `lifecycle:mode:prior-discover-zero-rtt` (sdk-defined): a previously-obtained - DiscoverResult is installed via `adopt()` so server_info and capabilities are available - immediately with zero round trips. + DiscoverResult is installed via `adopt()` so server_info (read from the result's `_meta` + serverInfo stamp) and capabilities are available immediately with zero round trips. """ prior = DiscoverResult( supported_versions=[LATEST_MODERN_VERSION], capabilities=ServerCapabilities(tools=ToolsCapability(list_changed=False)), - server_info=Implementation(name="cached-server", version="9.9.9"), + _meta={SERVER_INFO_META_KEY: {"name": "cached-server", "version": "9.9.9"}}, ) requests, on_request = _request_recorder() @@ -156,7 +157,8 @@ async def test_auto_mode_probes_server_discover_and_adopts_the_result() -> None: Requirement `lifecycle:discover:basic` (spec basic/lifecycle#discover): the probe is a single `server/discover` request whose result carries supported versions, capabilities, - server_info and the cache-hint fields, after which the session is modern-negotiated. + the cache-hint fields, and the `_meta` serverInfo stamp, after which the session is + modern-negotiated. """ requests, on_request = _request_recorder() server = _tools_server("discoverable") @@ -167,6 +169,7 @@ async def test_auto_mode_probes_server_discover_and_adopts_the_result() -> None: Client(streamable_http_client(f"{BASE_URL}/mcp", http_client=http), mode="auto") as client, ): assert client.protocol_version == LATEST_MODERN_VERSION + assert client.server_info is not None assert client.server_info.name == "discoverable" await client.list_tools() @@ -198,7 +201,6 @@ async def discover(ctx: ServerRequestContext, params: types.RequestParams | None return DiscoverResult( supported_versions=list(MODERN_PROTOCOL_VERSIONS), capabilities=ServerCapabilities(), - server_info=Implementation(name="picky", version="1.0.0"), ) server = _tools_server("picky") @@ -309,6 +311,7 @@ async def scripted_transport() -> AsyncIterator[TransportStreams]: with anyio.fail_after(5): async with Client(scripted_transport(), mode="auto") as client: assert client.protocol_version == LATEST_HANDSHAKE_VERSION + assert client.server_info is not None assert client.server_info.name == "legacy-only" assert methods_seen == ["server/discover", "initialize", "notifications/initialized"] diff --git a/tests/interaction/transports/test_hosting_http_modern.py b/tests/interaction/transports/test_hosting_http_modern.py index 301af558c..c60437edd 100644 --- a/tests/interaction/transports/test_hosting_http_modern.py +++ b/tests/interaction/transports/test_hosting_http_modern.py @@ -22,6 +22,7 @@ INVALID_PARAMS, METHOD_NOT_FOUND, MISSING_REQUIRED_CLIENT_CAPABILITY, + SERVER_INFO_META_KEY, CallToolRequestParams, CallToolResult, DiscoverResult, @@ -77,7 +78,12 @@ def _meta_envelope() -> dict[str, object]: def _server(*, on_meta: Callable[[dict[str, Any]], None] | None = None) -> Server: - """A low-level server with one ``add`` tool for the raw-httpx2 tests below.""" + """A low-level server with one `add` tool for the raw-httpx2 tests below. + + The explicit version pins the `_meta` serverInfo stamp every 2026 result carries, so + wire-level snapshots stay deterministic (the default falls back to the installed package + version). + """ async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: tool = Tool(name="add", input_schema={"type": "object"}) @@ -91,7 +97,7 @@ async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> on_meta(dict(ctx.meta)) return CallToolResult(content=[TextContent(text=str(params.arguments["a"] + params.arguments["b"]))]) - return Server("modern", on_list_tools=list_tools, on_call_tool=call_tool) + return Server("modern", version="1.0.0", on_list_tools=list_tools, on_call_tool=call_tool) @requirement("hosting:http:modern:tools-call-stateless") @@ -99,9 +105,10 @@ async def test_modern_tools_call_returns_result_type_complete_without_initialize """A 2026-07-28 tools/call is served without an initialize handshake and returns resultType: complete. Spec-mandated under the draft transport: the per-request ``_meta`` envelope replaces initialize, - and ``resultType`` is the 2026 result-envelope discriminator (``complete`` for the monolith - result). Asserted at the wire because the SDK client never surfaces ``resultType`` and because - the absence of any prior request on the connection is the assertion. + `resultType` is the 2026 result-envelope discriminator (`complete` for the monolith + result), and the server identifies itself via the result `_meta` serverInfo stamp. Asserted at + the wire because the SDK client never surfaces `resultType` and because the absence of any + prior request on the connection is the assertion. """ body = { "jsonrpc": "2.0", @@ -117,7 +124,12 @@ async def test_modern_tools_call_returns_result_type_complete_without_initialize parsed = JSONRPCResponse.model_validate(response.json()) assert parsed.id == 1 assert parsed.result == snapshot( - {"content": [{"text": "5", "type": "text"}], "isError": False, "resultType": "complete"} + { + "content": [{"text": "5", "type": "text"}], + "isError": False, + "resultType": "complete", + "_meta": {"io.modelcontextprotocol/serverInfo": {"name": "modern", "version": "1.0.0"}}, + } ) @@ -213,12 +225,13 @@ async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> @requirement("hosting:http:modern:discover-response-shape") async def test_modern_server_discover_returns_capabilities_and_supported_versions() -> None: - """A 2026-07-28 server/discover POST returns capabilities, serverInfo, and supportedVersions. + """A 2026-07-28 server/discover POST returns capabilities and supportedVersions, with serverInfo in `_meta`. Spec-mandated under the draft: server/discover is the 2026 advertisement method that replaces the initialize-response payload, and ``supportedVersions`` is the field a client picks its - per-request envelope version from. Asserted at the wire because the SDK client never exposes - the raw result body. + per-request envelope version from. The server's identity is no longer a result-body field: it + travels as the io.modelcontextprotocol/serverInfo result `_meta` stamp. Asserted at the wire + because the SDK client never exposes the raw result body. """ body = {"jsonrpc": "2.0", "id": 1, "method": "server/discover", "params": {"_meta": _meta_envelope()}} async with mounted_app(_server()) as (http, _): @@ -227,7 +240,8 @@ async def test_modern_server_discover_returns_capabilities_and_supported_version assert response.status_code == 200 result = JSONRPCResponse.model_validate(response.json()).result assert result["supportedVersions"] == snapshot(["2026-07-28"]) - assert result["serverInfo"]["name"] == "modern" + assert "serverInfo" not in result + assert result["_meta"][SERVER_INFO_META_KEY] == {"name": "modern", "version": "1.0.0"} assert "capabilities" in result @@ -282,7 +296,7 @@ async def cap_check(ctx: ServerRequestContext, params: RequestParams) -> EmptyRe raise MCPError( code=MISSING_REQUIRED_CLIENT_CAPABILITY, message="sampling required", - data={"requiredCapabilities": ["sampling"]}, + data={"requiredCapabilities": {"sampling": {}}}, ) server = _server() @@ -294,7 +308,7 @@ async def cap_check(ctx: ServerRequestContext, params: RequestParams) -> EmptyRe assert response.status_code == 400 error = JSONRPCError.model_validate(response.json()).error assert error.code == MISSING_REQUIRED_CLIENT_CAPABILITY - assert error.data == {"requiredCapabilities": ["sampling"]} + assert error.data == {"requiredCapabilities": {"sampling": {}}} @requirement("hosting:http:modern:tools-call-stateless") @@ -340,7 +354,6 @@ async def on_response(response: httpx2.Response) -> None: DiscoverResult( supported_versions=[LATEST_MODERN_VERSION], capabilities=ServerCapabilities(), - server_info=Implementation(name="srv", version="0"), ) ) result = await session.call_tool( @@ -350,7 +363,12 @@ async def on_response(response: httpx2.Response) -> None: ) assert result.model_dump(by_alias=True, mode="json", exclude_none=True) == snapshot( - {"content": [{"type": "text", "text": "5"}], "isError": False, "resultType": "complete"} + { + "_meta": {"io.modelcontextprotocol/serverInfo": {"name": "modern", "version": "1.0.0"}}, + "content": [{"type": "text", "text": "5"}], + "isError": False, + "resultType": "complete", + } ) # Exactly the tools/call POST and the implicit tools/list POST -- no initialize, no @@ -440,7 +458,6 @@ async def on_request(request: httpx2.Request) -> None: discover = DiscoverResult( supported_versions=[LATEST_MODERN_VERSION], capabilities=ServerCapabilities(), - server_info=Implementation(name="srv", version="0"), ) with anyio.fail_after(5): async with ( @@ -487,7 +504,6 @@ async def on_request(request: httpx2.Request) -> None: discover = DiscoverResult( supported_versions=[LATEST_MODERN_VERSION], capabilities=ServerCapabilities(), - server_info=Implementation(name="srv", version="0"), ) with anyio.fail_after(5): async with ( @@ -541,7 +557,6 @@ async def on_request(request: httpx2.Request) -> None: discover = DiscoverResult( supported_versions=[LATEST_MODERN_VERSION], capabilities=ServerCapabilities(), - server_info=Implementation(name="srv", version="0"), ) with anyio.fail_after(5): async with ( @@ -596,7 +611,6 @@ async def on_request(request: httpx2.Request) -> None: discover = DiscoverResult( supported_versions=[LATEST_MODERN_VERSION], capabilities=ServerCapabilities(), - server_info=Implementation(name="srv", version="0"), ) with anyio.fail_after(5): async with ( diff --git a/tests/interaction/transports/test_stdio.py b/tests/interaction/transports/test_stdio.py index dbb7de345..7577b0c40 100644 --- a/tests/interaction/transports/test_stdio.py +++ b/tests/interaction/transports/test_stdio.py @@ -90,6 +90,7 @@ async def collect(params: LoggingMessageNotificationParams) -> None: # Must exceed session time plus the patched PROCESS_TERMINATION_TIMEOUT (20s). with anyio.fail_after(30): async with Client(transport, mode="legacy", logging_callback=collect) as client: + assert client.server_info is not None assert client.server_info.name == "stdio-echo" result = await client.call_tool("echo", {"text": "across\nprocesses"}) diff --git a/tests/interaction/transports/test_streamable_http.py b/tests/interaction/transports/test_streamable_http.py index 779a46054..bb6dec569 100644 --- a/tests/interaction/transports/test_streamable_http.py +++ b/tests/interaction/transports/test_streamable_http.py @@ -69,6 +69,7 @@ async def announce(ctx: Context) -> str: async def test_tool_call_over_streamable_http_with_json_responses() -> None: """The round trip works when the server answers with a single JSON body instead of an SSE stream.""" async with connect_over_streamable_http(_smoke_server(), json_response=True) as client: + assert client.server_info is not None assert client.server_info.name == "smoke" result = await client.call_tool("echo", {"text": "as json"}) diff --git a/tests/server/lowlevel/test_server_discover.py b/tests/server/lowlevel/test_server_discover.py index 05d57d846..78c40a14d 100644 --- a/tests/server/lowlevel/test_server_discover.py +++ b/tests/server/lowlevel/test_server_discover.py @@ -2,17 +2,28 @@ These call the registered handler via the public `Server.get_request_handler` accessor without spinning up a `ServerRunner` or any transport, so they verify -the handler's contract in isolation from the dispatch pipeline. +the handler's contract in isolation from the dispatch pipeline. The exception +is the server-identity pair: the serverInfo `_meta` stamp is applied by the +runner (spec 2026-07-28, #3002), not the handler, so those two drive one +request through `serve_one` to observe it. """ import importlib.metadata +from collections.abc import Mapping +from dataclasses import dataclass, field from typing import Any, cast +import anyio import mcp_types as types import pytest from mcp_types.version import MODERN_PROTOCOL_VERSIONS from mcp.server import NotificationOptions, Server, ServerRequestContext +from mcp.server.connection import Connection +from mcp.server.runner import serve_one +from mcp.shared.dispatcher import CallOptions +from mcp.shared.message import MessageMetadata +from mcp.shared.transport_context import TransportContext # `Server._handle_discover` reads only `ctx.protocol_version` (capabilities are @@ -36,6 +47,47 @@ async def _discover(server: Server[Any], protocol_version: str = MODERN_PROTOCOL return result +@dataclass +class _StubDispatchContext: + """Minimal `DispatchContext` for the `serve_one`-driven identity tests. + + Satisfies the protocol structurally; the discover handler never touches + the back-channel. + """ + + request_id: int | str | None = 1 + transport: TransportContext = field(default_factory=lambda: TransportContext(kind="direct", can_send_request=False)) + message_metadata: MessageMetadata = None + cancel_requested: anyio.Event = field(default_factory=anyio.Event) + can_send_request: bool = False + + async def send_raw_request( + self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None + ) -> dict[str, Any]: + raise NotImplementedError + + async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None: + raise NotImplementedError + + async def progress(self, progress: float, total: float | None = None, message: str | None = None) -> None: + raise NotImplementedError + + +async def _discover_over_runner(server: Server[Any]) -> dict[str, Any]: + """Serve one `server/discover` through the runner - the layer that stamps + server identity into the result `_meta`.""" + connection = Connection.from_envelope(MODERN_PROTOCOL_VERSIONS[0], None, None) + params: dict[str, Any] = { + "_meta": { + types.PROTOCOL_VERSION_META_KEY: MODERN_PROTOCOL_VERSIONS[0], + types.CLIENT_CAPABILITIES_META_KEY: {}, + } + } + return await serve_one( + server, _StubDispatchContext(), "server/discover", params, connection=connection, lifespan_state={} + ) + + def test_registered_by_default() -> None: """SDK-defined: a bare `Server` registers a `server/discover` handler out of the box, typed for the base `RequestParams`.""" @@ -55,7 +107,8 @@ async def test_supported_versions_is_modern_set() -> None: @pytest.mark.anyio async def test_server_info_reflects_constructor_fields() -> None: - """SDK-defined: `serverInfo` is built field-for-field from the `Server` + """Server identity travels as the discover result's `_meta` serverInfo + stamp (spec 2026-07-28, #3002), built field-for-field from the `Server` constructor arguments.""" icons = [types.Icon(src="https://example.test/icon.png")] server = Server( @@ -66,8 +119,9 @@ async def test_server_info_reflects_constructor_fields() -> None: website_url="https://example.test", icons=icons, ) - result = await _discover(server) - assert result.server_info == types.Implementation( + result = await _discover_over_runner(server) + stamp = result["_meta"][types.SERVER_INFO_META_KEY] + assert types.Implementation.model_validate(stamp) == types.Implementation( name="info-server", version="9.9.9", title="Info Server", @@ -79,10 +133,10 @@ async def test_server_info_reflects_constructor_fields() -> None: @pytest.mark.anyio async def test_server_info_version_falls_back_to_package() -> None: - """SDK-defined: when no explicit version is supplied, `serverInfo.version` - falls back to the installed `mcp` package version.""" - result = await _discover(Server("unversioned")) - assert result.server_info.version == importlib.metadata.version("mcp") + """SDK-defined: when no explicit version is supplied, the stamped + `serverInfo` version falls back to the installed `mcp` package version.""" + result = await _discover_over_runner(Server("unversioned")) + assert result["_meta"][types.SERVER_INFO_META_KEY]["version"] == importlib.metadata.version("mcp") @pytest.mark.anyio @@ -143,7 +197,6 @@ async def test_overridable_via_add_request_handler() -> None: custom = types.DiscoverResult( supported_versions=list(MODERN_PROTOCOL_VERSIONS), capabilities=types.ServerCapabilities(), - server_info=types.Implementation(name="custom-server", version="1.0.0"), instructions="overridden", ttl_ms=60_000, cache_scope="public", diff --git a/tests/server/mcpserver/test_extension.py b/tests/server/mcpserver/test_extension.py index b6ff0283d..bd0ff690b 100644 --- a/tests/server/mcpserver/test_extension.py +++ b/tests/server/mcpserver/test_extension.py @@ -141,7 +141,7 @@ async def test_additive_extension_registers_its_tool_and_resource() -> None: through `MCPServer`'s normal `list_tools`/`list_resources`, and the tool's `_meta` round-trips equal to the exact dict the binding carried (identity can't hold - the value is JSON-serialized over the transport).""" - server = MCPServer("test", extensions=[_AdditiveExt()]) + server = MCPServer("test", extensions=[_AdditiveExt()], include_server_info=False) async with Client(server) as client: tools = await client.list_tools() @@ -189,7 +189,7 @@ def test_duplicate_extension_identifier_raises() -> None: async def test_extension_method_reachable_via_session_send_request() -> None: """SDK-defined: an `Extension` overriding `methods()` wires a new request verb onto the low-level server, reachable through `client.session.send_request`.""" - server = MCPServer("test", extensions=[_MethodExt()]) + server = MCPServer("test", extensions=[_MethodExt()], include_server_info=False) async with Client(server) as client: request = _PingRequest(params=_PingParams()) @@ -201,7 +201,7 @@ async def test_extension_method_reachable_via_session_send_request() -> None: async def test_pass_through_interceptor_leaves_tool_result_unchanged() -> None: """SDK-defined: an extension whose `intercept_tool_call` delegates to `call_next` does not alter the underlying tool's `CallToolResult`.""" - server = MCPServer("test", extensions=[_PassThroughExt()]) + server = MCPServer("test", extensions=[_PassThroughExt()], include_server_info=False) server.tool(name="echo")(_echo) async with Client(server) as client: @@ -213,7 +213,7 @@ async def test_pass_through_interceptor_leaves_tool_result_unchanged() -> None: async def test_short_circuiting_interceptor_replaces_tool_result() -> None: """SDK-defined: an extension that returns from `intercept_tool_call` without calling `call_next` replaces the tool's result wholesale (the tool never runs).""" - server = MCPServer("test", extensions=[_ReplacingExt()]) + server = MCPServer("test", extensions=[_ReplacingExt()], include_server_info=False) server.tool(name="echo", structured_output=False)(_echo) async with Client(server) as client: @@ -245,7 +245,7 @@ async def test_default_interceptor_passes_through_alongside_an_overriding_one() """SDK-defined: an extension that does not override `intercept_tool_call` runs the base-class default (pass through) when another extension forces the composed middleware to exist, leaving the tool result untouched.""" - server = MCPServer("test", extensions=[_DefaultExt(), _PassThroughExt()]) + server = MCPServer("test", extensions=[_DefaultExt(), _PassThroughExt()], include_server_info=False) server.tool(name="echo")(_echo) async with Client(server) as client: @@ -339,7 +339,7 @@ async def handler(ctx: ServerRequestContext[Any, Any], params: _VersionPinnedPar async def test_version_pinned_method_is_served_at_an_allowed_version() -> None: """SDK-defined: a `MethodBinding` with `protocol_versions` serves the method at a version in the set.""" - server = MCPServer("test", extensions=[_VersionPinnedExt()]) + server = MCPServer("test", extensions=[_VersionPinnedExt()], include_server_info=False) async with Client(server, mode="2026-07-28") as client: request = _VersionPinnedRequest(params=_VersionPinnedParams()) @@ -420,7 +420,7 @@ def guarded(ctx: Context) -> str: async def test_require_client_extension_passes_when_client_declared_it() -> None: """SDK-defined: `require_client_extension` is a no-op when the client advertised the id.""" - server = MCPServer("test", extensions=[_RequiresExt()]) + server = MCPServer("test", extensions=[_RequiresExt()], include_server_info=False) async with Client(server, extensions=[advertise(_NEEDS_EXT)]) as client: result = await client.call_tool("guarded", {}) diff --git a/tests/server/mcpserver/test_server.py b/tests/server/mcpserver/test_server.py index 3103a50f3..96a7fc531 100644 --- a/tests/server/mcpserver/test_server.py +++ b/tests/server/mcpserver/test_server.py @@ -979,7 +979,7 @@ def get_data(name: str) -> str: async def test_resource_template_includes_mime_type(self): """Test that list resource templates includes the correct mimeType.""" - mcp = MCPServer() + mcp = MCPServer(include_server_info=False) @mcp.resource("resource://{user}/csv", mime_type="text/csv") def get_csv(user: str) -> str: @@ -1055,7 +1055,7 @@ def get_weather(city: str) -> str: ... # pragma: no branch async def test_read_resource_returns_meta(self): """Test that read_resource includes meta in response.""" # Tests end-to-end: Resource.meta -> ReadResourceContents.meta -> protocol _meta (lowlevel/server.py:341,371) - mcp = MCPServer() + mcp = MCPServer(include_server_info=False) @mcp.resource("resource://data", meta={"version": "1.0", "category": "config"}) def get_data() -> str: @@ -1217,7 +1217,7 @@ def resource_with_context(name: str, ctx: Context) -> str: async def test_resource_without_context(self): """Test that resources without context work normally.""" - mcp = MCPServer() + mcp = MCPServer(include_server_info=False) @mcp.resource("resource://nocontext/{name}") def resource_no_context(name: str) -> str: @@ -1244,7 +1244,7 @@ def resource_no_context(name: str) -> str: async def test_resource_context_custom_name(self): """Test resource context with custom parameter name.""" - mcp = MCPServer() + mcp = MCPServer(include_server_info=False) @mcp.resource("resource://custom/{id}") def resource_custom_ctx(id: str, my_ctx: Context) -> str: @@ -1385,7 +1385,7 @@ def fn() -> str: ... # pragma: no branch async def test_list_prompts(self): """Test listing prompts through MCP protocol.""" - mcp = MCPServer() + mcp = MCPServer(include_server_info=False) @mcp.prompt() def fn(name: str, optional: str = "default") -> str: ... # pragma: no branch @@ -1409,7 +1409,7 @@ def fn(name: str, optional: str = "default") -> str: ... # pragma: no branch async def test_get_prompt(self): """Test getting a prompt through MCP protocol.""" - mcp = MCPServer() + mcp = MCPServer(include_server_info=False) @mcp.prompt() def fn(name: str) -> str: @@ -1438,7 +1438,7 @@ def fn(name: str) -> str: async def test_get_prompt_with_docstring_description(self): """Test prompt uses docstring as description when not explicitly provided.""" - mcp = MCPServer() + mcp = MCPServer(include_server_info=False) @mcp.prompt() def fn(name: str) -> str: @@ -1456,7 +1456,7 @@ def fn(name: str) -> str: async def test_get_prompt_with_resource(self): """Test getting a prompt that returns resource content.""" - mcp = MCPServer() + mcp = MCPServer(include_server_info=False) @mcp.prompt() def fn() -> Message: diff --git a/tests/server/mcpserver/test_title.py b/tests/server/mcpserver/test_title.py index 3e36f2257..ff76bdc0a 100644 --- a/tests/server/mcpserver/test_title.py +++ b/tests/server/mcpserver/test_title.py @@ -25,10 +25,12 @@ async def test_server_name_title_description_version(): # Start server and connect client async with Client(mcp) as client: - assert client.server_info.name == "TestServer" - assert client.server_info.title == "Test Server Title" - assert client.server_info.description == "This is a test server description." - assert client.server_info.version == "1.0" + server_info = client.server_info + assert server_info is not None + assert server_info.name == "TestServer" + assert server_info.title == "Test Server Title" + assert server_info.description == "This is a test server description." + assert server_info.version == "1.0" @pytest.mark.anyio diff --git a/tests/server/test_apps.py b/tests/server/test_apps.py index 262bdfe7a..c1a088007 100644 --- a/tests/server/test_apps.py +++ b/tests/server/test_apps.py @@ -42,7 +42,7 @@ def get_time(ctx: Context) -> str: return "2026-06-26T00:00:00Z" apps.add_html_resource("ui://clock/app.html", "Clock", title="Clock") - return MCPServer("clock", extensions=[apps]) + return MCPServer("clock", extensions=[apps], include_server_info=False) async def test_apps_tool_stamps_ui_resource_uri_on_tool_meta() -> None: diff --git a/tests/server/test_caching.py b/tests/server/test_caching.py index 0a6adc2aa..0b5257365 100644 --- a/tests/server/test_caching.py +++ b/tests/server/test_caching.py @@ -139,7 +139,11 @@ async def test_an_input_required_shaped_dict_is_never_stamped() -> None: async def read_resource(ctx: ServerRequestContext[Any], params: ReadResourceRequestParams) -> dict[str, Any]: return {"resultType": "input_required", "requestState": "s1"} - server = Server("srv", cache_hints={"resources/read": CacheHint(ttl_ms=60_000, scope="public")}) + server = Server( + "srv", + cache_hints={"resources/read": CacheHint(ttl_ms=60_000, scope="public")}, + include_server_info=False, + ) server.add_request_handler("resources/read", ReadResourceRequestParams, read_resource) async with Client(server) as client: result = await client.session.read_resource("res://x", allow_input_required=True) diff --git a/tests/server/test_runner.py b/tests/server/test_runner.py index 45877ff9a..c68b0e744 100644 --- a/tests/server/test_runner.py +++ b/tests/server/test_runner.py @@ -25,8 +25,10 @@ LATEST_PROTOCOL_VERSION, METHOD_NOT_FOUND, PROTOCOL_VERSION_META_KEY, + SERVER_INFO_META_KEY, UNSUPPORTED_PROTOCOL_VERSION, ClientCapabilities, + EmptyResult, ErrorData, Implementation, InitializeRequestParams, @@ -953,7 +955,12 @@ async def echo(ctx: Ctx, params: RequestParams) -> dict[str, Any]: born_ready = Connection.from_envelope(LATEST_MODERN_VERSION, None, None) async with connected_runner(server, initialized=False, connection=born_ready) as (client, _): result = await client.send_raw_request("myorg/echo", None) - assert result == {"echoed": True} + # Custom-method results served at a modern version carry the serverInfo + # `_meta` stamp like any other result (spec 2026-07-28, #3002). + assert result == { + "echoed": True, + "_meta": {SERVER_INFO_META_KEY: {"name": "test-server", "version": "0.0.1"}}, + } @pytest.mark.anyio @@ -987,6 +994,90 @@ async def custom(ctx: Ctx, params: RequestParams) -> dict[str, Any]: assert result == {"anything": "goes"} +@pytest.mark.anyio +async def test_modern_short_circuit_middleware_result_still_carries_the_server_info_stamp(server: SrvT): + """SDK-defined: the serverInfo stamp is applied at the runner's single exit, + after the middleware chain, so even a middleware that answers without + calling `call_next` produces an identified result (spec 2026-07-28, #3002).""" + + async def short_circuit(ctx: Ctx, call_next: Any) -> Any: + return {"ok": True} + + server.middleware.append(short_circuit) + born_ready = Connection.from_envelope(LATEST_MODERN_VERSION, None, None) + async with connected_runner(server, initialized=False, connection=born_ready) as (client, _): + result = await client.send_raw_request("myorg/anything", None) + assert result == { + "ok": True, + "_meta": {SERVER_INFO_META_KEY: {"name": "test-server", "version": "0.0.1"}}, + } + + +@pytest.mark.anyio +async def test_a_handler_authored_server_info_stamp_is_not_overwritten(server: SrvT): + """SDK-defined: a handler that stamps its own serverInfo `_meta` value owns + it; the runner fills the key only when it is absent (spec 2026-07-28, #3002).""" + + async def custom(ctx: Ctx, params: RequestParams) -> dict[str, Any]: + return {"ok": True, "_meta": {SERVER_INFO_META_KEY: {"name": "authored", "version": "9"}}} + + server.add_request_handler("myorg/authored", RequestParams, custom) + born_ready = Connection.from_envelope(LATEST_MODERN_VERSION, None, None) + async with connected_runner(server, initialized=False, connection=born_ready) as (client, _): + result = await client.send_raw_request("myorg/authored", None) + assert result["_meta"][SERVER_INFO_META_KEY] == {"name": "authored", "version": "9"} + + +@pytest.mark.anyio +async def test_a_non_mapping_custom_result_meta_is_left_alone(server: SrvT): + """SDK-defined: a custom-method handler that returns a non-mapping `_meta` + owns that shape; the stamp neither clobbers it nor fails the request.""" + + async def custom(ctx: Ctx, params: RequestParams) -> dict[str, Any]: + return {"_meta": "not-a-mapping"} + + server.add_request_handler("myorg/odd-meta", RequestParams, custom) + born_ready = Connection.from_envelope(LATEST_MODERN_VERSION, None, None) + async with connected_runner(server, initialized=False, connection=born_ready) as (client, _): + result = await client.send_raw_request("myorg/odd-meta", None) + assert result == {"_meta": "not-a-mapping"} + + +@pytest.mark.anyio +async def test_stamping_never_mutates_a_handler_retained_result_dict(server: SrvT): + """SDK-defined: the stamp lands on a shallow copy at the runner's exit, so + a dict the handler retains (module-level, cached, shared) is never mutated + underneath it.""" + + retained: dict[str, Any] = {"ok": True} + + async def custom(ctx: Ctx, params: RequestParams) -> dict[str, Any]: + return retained + + server.add_request_handler("myorg/cached", RequestParams, custom) + born_ready = Connection.from_envelope(LATEST_MODERN_VERSION, None, None) + async with connected_runner(server, initialized=False, connection=born_ready) as (client, _): + result = await client.send_raw_request("myorg/cached", None) + assert result["_meta"][SERVER_INFO_META_KEY] == {"name": "test-server", "version": "0.0.1"} + assert retained == {"ok": True} + + +@pytest.mark.anyio +async def test_an_empty_result_on_the_modern_path_carries_only_the_stamp(server: SrvT): + """Spec-mandated (2026-07-28, #3002): serverInfo is stamped into every + result, including empty ones — a result that dumps as `{}` goes on the + modern wire as just the identity `_meta`.""" + + async def custom(ctx: Ctx, params: RequestParams) -> EmptyResult: + return EmptyResult() + + server.add_request_handler("myorg/empty", RequestParams, custom) + born_ready = Connection.from_envelope(LATEST_MODERN_VERSION, None, None) + async with connected_runner(server, initialized=False, connection=born_ready) as (client, _): + result = await client.send_raw_request("myorg/empty", None) + assert result == {"_meta": {SERVER_INFO_META_KEY: {"name": "test-server", "version": "0.0.1"}}} + + @pytest.mark.anyio async def test_runner_initialize_result_reflects_init_options(): async def list_tools(ctx: Ctx, params: PaginatedRequestParams | None) -> ListToolsResult: @@ -1720,7 +1811,7 @@ async def greet(ctx: Ctx, params: RequestParams | None) -> dict[str, Any]: seen.append(ctx.session.client_params) return {"ok": True} - greeter = Server(name="greeter-server", version="0.0.1") + greeter = Server(name="greeter-server", version="0.0.1", include_server_info=False) greeter.add_request_handler("custom/greet", RequestParams, greet) params = _modern_params() params["_meta"][CLIENT_INFO_META_KEY] = "not-an-object" diff --git a/tests/server/test_stdio.py b/tests/server/test_stdio.py index 218e34d5a..24d9dcf75 100644 --- a/tests/server/test_stdio.py +++ b/tests/server/test_stdio.py @@ -11,6 +11,7 @@ CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY, + SERVER_INFO_META_KEY, JSONRPCMessage, JSONRPCRequest, JSONRPCResponse, @@ -264,11 +265,14 @@ def test_mcpserver_run_stdio_serves_a_modern_connection(monkeypatch: pytest.Monk discover = JSONRPCRequest(jsonrpc="2.0", id=1, method="server/discover", params={"_meta": envelope}) tools = JSONRPCRequest(jsonrpc="2.0", id=2, method="tools/list", params={"_meta": envelope}) - responses = _serve_stdio_and_collect(monkeypatch, MCPServer(name="ModernStdioServer"), [discover, tools], 2) + server = MCPServer(name="ModernStdioServer", version="1.2.3") + responses = _serve_stdio_and_collect(monkeypatch, server, [discover, tools], 2) assert isinstance(responses[0], JSONRPCResponse) and responses[0].id == 1 assert "2026-07-28" in responses[0].result["supportedVersions"] - assert responses[0].result["serverInfo"]["name"] == "ModernStdioServer" + # Server identity travels as the result `_meta` stamp, not a DiscoverResult + # body field (spec 2026-07-28, #3002). + assert responses[0].result["_meta"][SERVER_INFO_META_KEY] == {"name": "ModernStdioServer", "version": "1.2.3"} assert isinstance(responses[1], JSONRPCResponse) and responses[1].id == 2 # `resultType` is the modern-only wire field: its presence proves the # request was served at the discovered version, not the handshake era. diff --git a/tests/server/test_streamable_http_modern.py b/tests/server/test_streamable_http_modern.py index 21cc98b8c..ead7a20e9 100644 --- a/tests/server/test_streamable_http_modern.py +++ b/tests/server/test_streamable_http_modern.py @@ -24,6 +24,7 @@ METHOD_NOT_FOUND, PARSE_ERROR, PROTOCOL_VERSION_META_KEY, + SERVER_INFO_META_KEY, CallToolRequestParams, CallToolResult, ClientCapabilities, @@ -204,7 +205,7 @@ async def greet(ctx: ServerRequestContext, params: PaginatedRequestParams) -> di seen.append(ctx.session.client_params) return {"ok": True} - server: Server[Any] = Server("test") + server: Server[Any] = Server("test", include_server_info=False) server.add_request_handler("custom/greet", PaginatedRequestParams, greet) body = _list_tools_body() @@ -1112,7 +1113,7 @@ async def test_json_response_mode_still_streams_subscriptions_listen() -> None: the SSE path, acks first, and ends with the stamped result on close().""" bus = _OpenSignalBus() handler = ListenHandler(bus) - server = Server("test", on_subscriptions_listen=handler) + server = Server("test", version="1.2.3", on_subscriptions_listen=handler) body = _listen_body() responses: list[httpx2.Response] = [] @@ -1135,4 +1136,9 @@ async def post() -> None: events = _sse_payloads(response.text) assert events[0]["method"] == "notifications/subscriptions/acknowledged" assert events[1]["id"] == 9 - assert events[1]["result"]["_meta"] == {"io.modelcontextprotocol/subscriptionId": 9} + # The terminal listen result is a modern-era result like any other, so it + # carries the serverInfo stamp alongside the subscription id. + assert events[1]["result"]["_meta"] == { + "io.modelcontextprotocol/subscriptionId": 9, + SERVER_INFO_META_KEY: {"name": "test", "version": "1.2.3"}, + } diff --git a/tests/test_examples.py b/tests/test_examples.py index 0104d5398..9c39ef456 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -5,23 +5,37 @@ # pyright: reportUnknownArgumentType=false # pyright: reportUnknownMemberType=false +from importlib.metadata import version from pathlib import Path import pytest from inline_snapshot import snapshot -from mcp_types import CallToolResult, TextContent, TextResourceContents +from mcp_types import SERVER_INFO_META_KEY, CallToolResult, TextContent, TextResourceContents from pytest_examples import CodeExample, EvalExample, find_examples from mcp import Client +def strip_server_info(result: CallToolResult, server_name: str) -> CallToolResult: + """Assert the 2026-era serverInfo stamp, then drop it from the result's meta. + + The example servers set no explicit version, so the stamp's version is the + installed mcp package version; keeping it in the inline snapshots would make + them commit-dependent. + """ + assert result.meta is not None + assert result.meta[SERVER_INFO_META_KEY] == {"name": server_name, "version": version("mcp")} + remaining = {k: v for k, v in result.meta.items() if k != SERVER_INFO_META_KEY} + return result.model_copy(update={"meta": remaining or None}) + + @pytest.mark.anyio async def test_simple_echo(): """Test the simple echo server""" from examples.mcpserver.simple_echo import mcp async with Client(mcp) as client: - result = await client.call_tool("echo", {"text": "hello"}) + result = strip_server_info(await client.call_tool("echo", {"text": "hello"}), "Echo Server") assert result == snapshot( CallToolResult(content=[TextContent(text="hello")], structured_content={"result": "hello"}) ) @@ -35,6 +49,7 @@ async def test_complex_inputs(): async with Client(mcp) as client: tank = {"shrimp": [{"name": "bob"}, {"name": "alice"}]} result = await client.call_tool("name_shrimp", {"tank": tank, "extra_names": ["charlie"]}) + result = strip_server_info(result, "Shrimp Tank") assert result == snapshot( CallToolResult( content=[ @@ -53,7 +68,8 @@ async def test_direct_call_tool_result_return(): from examples.mcpserver.direct_call_tool_result_return import mcp async with Client(mcp) as client: - result = await client.call_tool("echo", {"text": "hello"}) + # The serverInfo stamp merges alongside the handler-authored meta. + result = strip_server_info(await client.call_tool("echo", {"text": "hello"}), "Echo Server") assert result == snapshot( CallToolResult( meta={"some": "metadata"}, # type: ignore[reportUnknownMemberType] @@ -80,7 +96,7 @@ async def test_desktop(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): async with Client(mcp) as client: # Test the sum function - result = await client.call_tool("sum", {"a": 1, "b": 2}) + result = strip_server_info(await client.call_tool("sum", {"a": 1, "b": 2}), "Demo") assert result == snapshot(CallToolResult(content=[TextContent(text="3")], structured_content={"result": 3})) # Test the desktop resource diff --git a/tests/test_types.py b/tests/test_types.py index 308377420..ad481d2c0 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -394,7 +394,6 @@ def test_concrete_wire_results_always_dump_result_type_complete(): DiscoverResult( supported_versions=["2026-07-28"], capabilities=ServerCapabilities(), - server_info=Implementation(name="server", version="1.0"), ), ] for result in carriers: @@ -414,7 +413,6 @@ def test_cacheable_results_default_to_immediately_stale_private(): DiscoverResult( supported_versions=["2026-07-28"], capabilities=ServerCapabilities(), - server_info=Implementation(name="server", version="1.0"), ), ] for result in cacheable: diff --git a/tests/types/test_methods.py b/tests/types/test_methods.py index c34d79e37..959743e59 100644 --- a/tests/types/test_methods.py +++ b/tests/types/test_methods.py @@ -399,7 +399,6 @@ v2026.DiscoverResult: { "supportedVersions": ["2026-07-28"], "capabilities": {}, - "serverInfo": {"name": "server", "version": "1.0"}, "resultType": "complete", "ttlMs": 0, "cacheScope": "private", @@ -871,7 +870,6 @@ def test_validate_functions_accept_reject_and_gate_like_their_parse_siblings(): "server/discover": types.DiscoverResult( supported_versions=["2026-07-28"], capabilities=types.ServerCapabilities(), - server_info=types.Implementation(name="server", version="1.0"), ttl_ms=0, cache_scope="private", ), @@ -937,6 +935,24 @@ def test_serialize_server_result_preserves_open_type_extras(): assert sieved["tools"][0]["_meta"] == nested_meta +def test_serialize_server_result_drops_top_level_server_info_on_discover_but_keeps_the_meta_stamp(): + """Server identity moved from the discover body to result `_meta` (spec PR #3002): + the sieve drops the removed body key and preserves the `_meta` stamp.""" + stamp = {"name": "server", "version": "1.0"} + dumped: dict[str, Any] = { + "supportedVersions": ["2026-07-28"], + "capabilities": {}, + "serverInfo": stamp, + "_meta": {types.SERVER_INFO_META_KEY: stamp}, + "resultType": "complete", + "ttlMs": 0, + "cacheScope": "private", + } + sieved = methods.serialize_server_result("server/discover", "2026-07-28", dumped) + assert "serverInfo" not in sieved + assert sieved["_meta"] == {types.SERVER_INFO_META_KEY: stamp} + + def test_serialize_server_result_drops_an_unknown_nested_tool_field(): tool = {"name": "echo", "inputSchema": {"type": "object"}, "unknownField": 1} sieved = methods.serialize_server_result("tools/list", "2025-11-25", {"tools": [tool], "resultType": "complete"}) diff --git a/tests/types/test_parity.py b/tests/types/test_parity.py index 080f343c3..8c7520563 100644 --- a/tests/types/test_parity.py +++ b/tests/types/test_parity.py @@ -121,6 +121,7 @@ "v2026_07_28.RequestMetaObject", "v2026_07_28.RequestedSchema", "v2026_07_28.ResourceRequestParams", + "v2026_07_28.ResultMetaObject", "v2026_07_28.StringSchema", "v2026_07_28.SubscriptionsListenResultMeta", "v2026_07_28.TitledMultiSelectEnumSchema", From f18da958900aaed1ff31db6ab5c79c91bab9c675 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:00:15 +0000 Subject: [PATCH 4/6] docs: server identity moved to result _meta; clientInfo optional Migration guide gets a grouped section for the spec #3002 reshape (field removal, stamping default and opt-out, Optional server_info, pair-only requests and the client_capabilities accessor). Whats-new, protocol-versions, client, session-groups, media, testing, and low-level pages updated to the new shapes. --- docs/advanced/low-level-server.md | 5 ++++- docs/client/index.md | 4 ++-- docs/client/session-groups.md | 2 +- docs/get-started/testing.md | 2 ++ docs/migration.md | 34 ++++++++++++++++++++++++++++++- docs/protocol-versions.md | 10 ++++----- docs/servers/media.md | 3 ++- docs/whats-new.md | 1 + 8 files changed, 50 insertions(+), 11 deletions(-) diff --git a/docs/advanced/low-level-server.md b/docs/advanced/low-level-server.md index 26df8f612..da0b016ff 100644 --- a/docs/advanced/low-level-server.md +++ b/docs/advanced/low-level-server.md @@ -102,10 +102,13 @@ Call it and the result carries both representations: "content": [{"type": "text", "text": "Found 3 books matching 'dune'."}], "structuredContent": {"matches": 3, "query": "dune"}, "isError": false, - "resultType": "complete" + "resultType": "complete", + "_meta": {"io.modelcontextprotocol/serverInfo": {"name": "Bookshop", "version": "2.0.0"}} } ``` +The `_meta` block is the server's identity stamp: the SDK adds it to every 2026-era result, with the `version` taken from the constructor or the installed package. Pass `include_server_info=False` to the server to turn it off. + The server never compares the two fields. This SDK's `Client` does: return `structured_content` that doesn't satisfy the `output_schema` you declared and `call_tool` raises a `RuntimeError` that starts with `Invalid structured content returned by tool search_books` and goes on to quote the `jsonschema` failure. Promising a schema is cheap; keeping it is on you. The whole ladder of return types and schemas is in **[Structured Output](../servers/structured-output.md)**. ## `_meta`: for the application, not the model diff --git a/docs/client/index.md b/docs/client/index.md index ae4750835..f70966e1a 100644 --- a/docs/client/index.md +++ b/docs/client/index.md @@ -30,7 +30,7 @@ Everything else on this page is identical across all three. Headers, subprocesse Four read-only properties, populated the moment you enter the block: -* `client.server_info`: the server's identity. `server_info.name` here is `"Bookshop"`, `server_info.version` is whatever the server reports. +* `client.server_info`: the server's identity, or `None` for a 2026-era server that does not report one (python-sdk servers do by default). `server_info.name` here is `"Bookshop"`, `server_info.version` is whatever the server reports. * `client.server_capabilities`: what the server can do (`tools`, `resources`, `prompts`, `completions`, ...). A capability the server doesn't have is `None`. * `client.protocol_version`: the protocol version the two sides agreed on. Here it is `"2026-07-28"`. * `client.instructions`: the server's `instructions=` string, or `None` if it didn't set one. @@ -202,7 +202,7 @@ There is one constructor flag built for that: `Client(mcp, raise_exceptions=True ## Recap * `Client(x)` connects in-memory to a server object, over Streamable HTTP to a URL string, and over anything else via a transport. -* `async with` is the whole lifecycle. Inside it, `server_info`, `server_capabilities`, `protocol_version` and `instructions` are already populated. +* `async with` is the whole lifecycle. Inside it, `server_capabilities` and `protocol_version` are already populated; `server_info` and `instructions` are too when the server provides them. * `list_tools()` gives you each tool's `name`, `title`, `description` and `input_schema`. * `call_tool()` returns `content` for the model, `structured_content` for your code, and `is_error`. A raising tool is a result, not an exception. * `content` is a union of block types; narrow with `isinstance` before reading. diff --git a/docs/client/session-groups.md b/docs/client/session-groups.md index c7a1434fb..70ad86833 100644 --- a/docs/client/session-groups.md +++ b/docs/client/session-groups.md @@ -64,7 +64,7 @@ Run it again. `print(sorted(group.tools))` now shows both: `connect_to_server` returns the `ClientSession` it opened. Keep it if you ever want that server gone: `await group.disconnect_from_server(session)` removes its tools, resources, and prompts from the group. -If you already hold a connected `ClientSession` (`Client.session` is one), hand it to `await group.connect_with_session(server_info, session)` instead of opening a new transport. It aggregates the same way. The group never closes a session it didn't open. +If you already hold a connected `ClientSession` (`Client.session` is one), hand it to `await group.connect_with_session(server_info, session)` instead of opening a new transport. It aggregates the same way. The group never closes a session it didn't open. `server_info` names the server for component prefixes; on a 2026-era connection `client.server_info` can be `None` (identity is optional), so pass your own `Implementation(name=..., version=...)` in that case. ## The classic handshake diff --git a/docs/get-started/testing.md b/docs/get-started/testing.md index 82a113cfb..0143e12a0 100644 --- a/docs/get-started/testing.md +++ b/docs/get-started/testing.md @@ -59,6 +59,8 @@ async def client(): # (2)! @pytest.mark.anyio async def test_call_add_tool(client: Client): result = await client.call_tool("add", {"a": 1, "b": 2}) + # Ignore the server identity stamp in `_meta`; its `version` tracks the installed package. + result.meta = None assert result == snapshot( CallToolResult( content=[TextContent(type="text", text="3")], diff --git a/docs/migration.md b/docs/migration.md index 876608db7..73104a660 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -468,6 +468,38 @@ now a plain `dict[str, Any]`: pass a dict when constructing params and read extras with dictionary access (`params.meta["traceparent"]`) instead of attribute access. The JSON wire format is unchanged. +### Server identity moved from the `server/discover` result body to result `_meta` + +The 2026-07-28 draft changed how identity travels +([spec #3002](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3002)): +`serverInfo` is no longer a field of the `server/discover` result. Servers now +report who they are by stamping `io.modelcontextprotocol/serverInfo` into the +`_meta` of every 2026-era result, and the request-side `clientInfo` `_meta` key +became optional (`protocolVersion` and `clientCapabilities` remain required). + +What changed in the SDK: + +- `DiscoverResult` no longer has a `server_info` field. Read or write identity + through result `_meta`; the key is exported as `mcp_types.SERVER_INFO_META_KEY`. +- Servers stamp `serverInfo` into every 2026-era result's `_meta` by default, + built from the constructor identity fields (`name`, `version`, `title`, and + so on). Pass `include_server_info=False` to `Server(...)` or `MCPServer(...)` + to turn it off. A `serverInfo` value your handler already set in `_meta` is + never overwritten. Handshake-era responses are unchanged, and notifications + and error responses are never stamped. +- `client.server_info` and `session.server_info` are `Implementation | None` + on 2026-era connections: identity is optional on the wire, so a server that + does not stamp it reads as `None`. Handshake-era connections still always + have it. A pinned `Client(target, mode="2026-07-28")` without + `prior_discover=` now reports `None` instead of an `Implementation` with + empty strings. The stamp is display-only in the spec, so a malformed value + from a misbehaving server also reads as `None` rather than failing the call. +- Requests that omit `clientInfo` are now accepted. On the server, + `ctx.session.client_params` is `None` for such requests; capability checks + should use `ctx.session.client_capabilities` (or `ctx.client_capabilities` + in `MCPServer` handlers), which is recorded whether or not the client + identified itself. + ### `SUPPORTED_PROTOCOL_VERSIONS` deprecated; `LATEST_PROTOCOL_VERSION` changed meaning `SUPPORTED_PROTOCOL_VERSIONS` is deprecated — it's now the union of `HANDSHAKE_PROTOCOL_VERSIONS` (initialize-handshake versions) and `MODERN_PROTOCOL_VERSIONS` (per-request-envelope versions). If you were using it to mean "versions the initialize handshake accepts", switch to `HANDSHAKE_PROTOCOL_VERSIONS`. Named scalars derived from these tuples are now exported alongside them — `LATEST_HANDSHAKE_VERSION`, `LATEST_MODERN_VERSION`, `OLDEST_SUPPORTED_VERSION` — so prefer those over indexing the tuples directly. All of these live in `mcp_types.version` (previously `mcp.shared.version`): `from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS`. @@ -1498,7 +1530,7 @@ version = session.protocol_version The raw handshake result is also retained: `session.initialize_result` is set after `initialize()` (≤2025-11-25 servers — including `stateless_http=True` servers, which still answer `initialize`); `session.discover_result` is set after `discover()` (2026-07-28+ servers). At most one is non-`None`. -On the high-level `Client`, `client.server_capabilities`, `client.server_info`, and `client.protocol_version` are non-nullable inside the context manager. `client.instructions` remains `str | None` since the server may omit it. (The lowlevel `ClientSession` still lets you call methods before any handshake, as in v1; `Client` always connects on enter — by default it probes `server/discover` and falls back to the initialize handshake.) +On the high-level `Client`, `client.server_capabilities` and `client.protocol_version` are non-nullable inside the context manager. `client.instructions` remains `str | None` since the server may omit it, and `client.server_info` is `Implementation | None`: on 2026-era connections identity is optional wire metadata, so a server that does not report it reads as `None` (see [Server identity moved to result `_meta`](#server-identity-moved-from-the-serverdiscover-result-body-to-result-_meta)). (The lowlevel `ClientSession` still lets you call methods before any handshake, as in v1; `Client` always connects on enter — by default it probes `server/discover` and falls back to the initialize handshake.) ### `cursor` parameter removed from `ClientSession` list methods diff --git a/docs/protocol-versions.md b/docs/protocol-versions.md index 221a87dc4..9ef19a7cf 100644 --- a/docs/protocol-versions.md +++ b/docs/protocol-versions.md @@ -68,10 +68,10 @@ A pin is a promise *you* make: you already know the server speaks that version. A pin is not a discovery. Print `client.server_info` and the price is right there: ```text - name='' title=None version='' description=None website_url=None icons=None + None ``` - The client never asked the server who it is, so `server_info` is a blank. `client.server_capabilities` + The client never asked the server who it is, so `server_info` is `None`. `client.server_capabilities` is the same story: every capability is `None`. Tool calls still work (the protocol needs none of it); code that reads `server_capabilities` to decide what to offer does not. @@ -87,7 +87,7 @@ ValueError: mode must be 'legacy', 'auto', or one of ['2026-07-28']; got '2025-0 The probe is cheap, but it is still a round trip you pay on every reconnect, and the answer almost never changes. -So keep it. After an `auto` connection, `client.session.discover_result` holds the exact `DiscoverResult` the server sent: its `supported_versions`, its `capabilities`, its `server_info`, its `instructions`. Hand it back as `prior_discover=` the next time: +So keep it. After an `auto` connection, `client.session.discover_result` holds the exact `DiscoverResult` the server sent: its `supported_versions`, its `capabilities`, its `instructions`, and the identity the server stamped into the result's `_meta`. Hand it back as `prior_discover=` the next time: ```python title="client.py" hl_lines="15 17" --8<-- "docs_src/protocol_versions/tutorial004.py" @@ -112,7 +112,7 @@ The second connection made **zero** negotiation round trips and still knows exac | --- | --- | --- | | `Client(target)` | one `server/discover` probe; the `initialize` handshake if it fails | the newest version both sides speak, whichever era | | `Client(target, mode="legacy")` | the `initialize` handshake | a handshake-era version; server-initiated requests work | -| `Client(target, mode="2026-07-28")` | none | that version, pinned, with a blank `server_info` | +| `Client(target, mode="2026-07-28")` | none | that version, pinned, with `server_info` as `None` | | `Client(target, mode="2026-07-28", prior_discover=saved)` | none | that version, pinned, *and* the identity you saved last time | ## Recap @@ -121,7 +121,7 @@ The second connection made **zero** negotiation round trips and still knows exac * `mode="auto"` is the default: probe, fall back. Leave it alone unless one of the other three rows describes you. * `client.protocol_version` is always the answer to "what did I get?". * `mode="legacy"` forces the handshake. It is what you need for server-initiated requests: sampling, push elicitation, `message_handler`. -* A version pin (`mode="2026-07-28"`) sends no negotiation traffic at all, at the cost of a blank `server_info`. +* A version pin (`mode="2026-07-28"`) sends no negotiation traffic at all, at the cost of `client.server_info` being `None`. * `prior_discover=` pays that cost back: save `client.session.discover_result`, reconnect with it, get both. A modern connection has no push channel, so how does a 2026 server ask you a question mid-call? It returns it: **[Multi-round-trip requests](handlers/multi-round-trip.md)**. diff --git a/docs/servers/media.md b/docs/servers/media.md index df2396607..e8042b83a 100644 --- a/docs/servers/media.md +++ b/docs/servers/media.md @@ -97,9 +97,10 @@ The same `icons=[...]` keyword is accepted by `MCPServer(...)`, `@mcp.tool()`, ` ### Where a client sees them -Icons travel with whatever they decorate. The server's arrive when the client connects, on `client.server_info`: +Icons travel with whatever they decorate. The server's arrive when the client connects, on `client.server_info` (optional on 2026-era connections, so narrow it first): ```python +assert client.server_info is not None # python-sdk servers identify themselves by default client.server_info.icons # [Icon(src="https://example.com/brand-kit.png", mime_type="image/png", sizes=["48x48"])] ``` diff --git a/docs/whats-new.md b/docs/whats-new.md index de29f0aef..f9aa98c5a 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -197,6 +197,7 @@ At 2026-07-28 the standalone HTTP GET stream and `resources/subscribe` are repla ### The rest, quickly +* **Identity is optional, per-message metadata.** The request-side `clientInfo` `_meta` key is optional (the required pair is `protocolVersion` + `clientCapabilities`), and `serverInfo` moved out of the `server/discover` result body: servers stamp it into every 2026-era result's `_meta` instead (since `2.0.0b3`; [spec #3002](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3002)). The SDK stamps by default and `include_server_info=False` turns it off; `client.server_info` is `None` when a server chooses not to identify itself. The **[Migration Guide](migration.md#server-identity-moved-from-the-serverdiscover-result-body-to-result-_meta)** has the details. * **Requests are routable without parsing bodies.** Modern HTTP requests carry `Mcp-Method` (and, for the three tool-ish calls, `Mcp-Name`); a tool input-schema property annotated with `x-mcp-header` is mirrored into an `Mcp-Param-*` header and cross-checked by the server ([SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243)). Gateways and rate limiters can route on headers alone; the **[Migration Guide](migration.md#servers-validate-mcp-param-headers-against-the-request-body-sep-2243)** has the rules. * **Results carry cache hints.** List and read results declare `ttlMs` and `cacheScope` ([SEP-2549](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549)); you set them per method with `cache_hints=`, and `Client` honors them with a built-in response cache. A server that sends no hints (every pre-2026 server) sees identical, uncached traffic. **[Caching hints](client/caching.md)**. * **Extensions are first class.** Servers and clients declare optional capability bundles under reverse-DNS identifiers ([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)); the built-in `Apps` extension (MCP Apps) is the reference. **[Extensions](advanced/extensions.md)** and **[MCP Apps](advanced/apps.md)**. From 8eb8d70f870dbb1bccdacfdec0d16657855ed370 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:00:16 +0000 Subject: [PATCH 5/6] Bump conformance referee to da56f663 and reconcile baselines The last published referee (0.2.0-alpha.9) still enforces the pre-#3002 shape, so implementing the reshape at the old pin fails three checks. Conformance main@da56f663 includes #403 (checks flipped to the final revision) and #406 (per-check expected-failures granularity); the sha256- verified tarball pin mechanism is unchanged. All server scenarios pass with zero baseline entries. The bump window adds client auth scenarios for features the OAuth client does not implement (DPoP per SEP-1932, and the jwt-bearer grant); those are baselined per-check so their passing checks stay live. Riding the same window: requiredCapabilities error data moves from the old array shape to the schema's object shape (referee #376), and the everything-server capability gate reads client_capabilities so it works for clients that omit the now-optional clientInfo. --- .../actions/conformance/expected-failures.yml | 19 ++++++++++++++++++- .github/workflows/conformance.yml | 16 +++++++++------- .../mcp_everything_server/server.py | 6 +++--- 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/.github/actions/conformance/expected-failures.yml b/.github/actions/conformance/expected-failures.yml index aa31cb757..2c1717833 100644 --- a/.github/actions/conformance/expected-failures.yml +++ b/.github/actions/conformance/expected-failures.yml @@ -10,7 +10,24 @@ # scenarios start passing and MUST be removed from this list (the runner fails # on stale entries), so the baseline burns down per milestone. -client: [] +client: + # SEP-1932 (DPoP): the SDK's OAuth client does not implement DPoP proofs. + # These scenarios are new in the da56f663 referee pin. The entries are + # per-check (conformance #406) because both scenarios pass their + # non-DPoP checks (discovery, token acquisition, request flow) live. + - auth/dpop:sep-1932-client-token-request-proof + - auth/dpop:sep-1932-client-dpop-auth-scheme + - auth/dpop:sep-1932-client-fresh-proof + - auth/dpop-nonce:sep-1932-client-token-request-proof + - auth/dpop-nonce:sep-1932-client-dpop-auth-scheme + - auth/dpop-nonce:sep-1932-client-fresh-proof + - auth/dpop-nonce:sep-1932-client-as-nonce + - auth/dpop-nonce:sep-1932-client-rs-nonce + # Workload identity federation: the OAuth client does not implement the + # urn:ietf:params:oauth:grant-type:jwt-bearer grant (it answers with + # authorization_code). Also new in the da56f663 referee pin; per-check for + # the same reason. + - auth/wif-jwt-bearer:wif-grant-type server: # SEP-2663 (io.modelcontextprotocol/tasks): the SDK does not implement the diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index dd132698d..9dd50debe 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -19,17 +19,19 @@ env: # Bump deliberately and reconcile both # .github/actions/conformance/expected-failures*.yml files in the same change. # - # Temporarily pinned to the pkg.pr.new build of conformance main@4944b268 - # (0.2.0-alpha.8, which includes #372: fail checks whose prerequisite is - # missing instead of skipping them) — alpha.8 is not published to npm yet. - # Pinned by commit SHA so the tarball cannot move under us; + # Temporarily pinned to the pkg.pr.new build of conformance main@da56f663, + # which includes #403 (sep-2575 checks flipped to the post-spec-#3002 shape: + # clientInfo optional on requests, serverInfo in result _meta instead of the + # discover body) and #406 (per-check expected-failures granularity) — the + # last published npm release (0.2.0-alpha.9) still enforces the pre-#3002 + # shape. Pinned by commit SHA so the tarball cannot move under us; # CONFORMANCE_PKG_SHA256 pins the bytes and the fetch-and-verify step below # downloads, checks the digest, and repoints CONFORMANCE_PKG at the # verified local copy. Repin to the next published @modelcontextprotocol/ - # conformance release (>=0.2.0-alpha.8) once it ships, then drop + # conformance release (>=0.2.0-alpha.10) once it ships, then drop # CONFORMANCE_PKG_SHA256 and the fetch-and-verify steps. - CONFORMANCE_PKG: "https://pkg.pr.new/@modelcontextprotocol/conformance@4944b268" - CONFORMANCE_PKG_SHA256: "0f70c035782d319d72ab427653c5275db5c50429d59fae0241a645b33aeda1a7" + CONFORMANCE_PKG: "https://pkg.pr.new/@modelcontextprotocol/conformance@da56f663" + CONFORMANCE_PKG_SHA256: "bbc94678033071df4bc9851ce2054f9dff918f4501661d1d60e2d09d8c515e6d" jobs: server-conformance: diff --git a/examples/servers/everything-server/mcp_everything_server/server.py b/examples/servers/everything-server/mcp_everything_server/server.py index 4b56a671c..9ac53c1fd 100644 --- a/examples/servers/everything-server/mcp_everything_server/server.py +++ b/examples/servers/everything-server/mcp_everything_server/server.py @@ -357,13 +357,13 @@ async def test_missing_capability(ctx: Context) -> str: ``CallToolResult.isError``) so the conformance harness observes a protocol-level error response with ``data.requiredCapabilities``. """ - client_params = ctx.session.client_params - sampling_declared = client_params is not None and client_params.capabilities.sampling is not None + capabilities = ctx.session.client_capabilities + sampling_declared = capabilities is not None and capabilities.sampling is not None if not sampling_declared: raise MCPError( code=MISSING_REQUIRED_CLIENT_CAPABILITY, message="This tool requires the client 'sampling' capability", - data={"requiredCapabilities": ["sampling"]}, + data={"requiredCapabilities": {"sampling": {}}}, ) return "Client declared sampling capability; proceeding." From 1bbc469c82f4076d7d87b54244e0609588ca1dd9 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:29:42 +0000 Subject: [PATCH 6/6] Own the response envelope in one outbound pass Cache-hint filling and the serverInfo stamp were two separate if-blocks in ServerRunner._on_request, one of them applied after the middleware chain by patching the returned dict. _serialize now owns the whole outbound envelope (hints, dump, per-version sieve, serverInfo stamp) as the counterpart of the inbound classification ladder, and _dump_result copies dict results so a handler-retained object can never be mutated by wire shaping. Behavior change: a middleware that short-circuits without call_next now owns its result envelope entirely, per the existing trust contract; it is no longer stamped after the fact. --- src/mcp/server/runner.py | 99 +++++++++++++++++++++---------------- tests/server/test_runner.py | 13 ++--- 2 files changed, 61 insertions(+), 51 deletions(-) diff --git a/src/mcp/server/runner.py b/src/mcp/server/runner.py index bd1e9fe3c..7fc1fada2 100644 --- a/src/mcp/server/runner.py +++ b/src/mcp/server/runner.py @@ -112,7 +112,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") @@ -210,38 +213,16 @@ async def _inner(ctx: ServerRequestContext[LifespanT, Any]) -> HandlerResult: 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)) - # Spec 2026-07-28 (#3002): stamp `serverInfo` into every modern result's - # `_meta`. This runs after the middleware chain - the one exit every - # result takes, including middleware short-circuits - so coverage holds - # by construction. Custom-method and short-circuit results pass through - # by reference, so stamping builds a shallow copy rather than mutating a - # dict the handler may retain. A handler-authored value wins; a - # non-mapping `_meta` is the handler's to own, not ours to clobber. - if self.server.include_server_info and version in MODERN_PROTOCOL_VERSIONS: - raw_meta = result.get("_meta") - if raw_meta is None: - result = {**result, "_meta": {SERVER_INFO_META_KEY: dict(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 = {**result, "_meta": {**meta, SERVER_INFO_META_KEY: dict(self.server.server_info_stamp)}} if method == "initialize": # Commit only on chain success, so a middleware veto leaves no state. # Race-free: the read loop is parked until this call returns. @@ -349,27 +330,59 @@ def _make_context( 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. - - 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. + 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. + + One pass owns the whole response envelope, in order: cache hints fill + `ttlMs`/`cacheScope` the handler left unset, spec-method results are + validated and sieved by the per-version surface, 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 # 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 + if method in _methods.SPEC_CLIENT_METHODS: + 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 + 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, a non-mapping `_meta` is the handler's + to own, and handshake-era results are never stamped. `result` is + pipeline-owned (`_dump_result` copies), but `_meta` may still be the + handler's object, so the stamp replaces it rather than writing into it. + """ + if not self.server.include_server_info or version not in MODERN_PROTOCOL_VERSIONS: + return result + raw_meta = result.get("_meta") + if raw_meta is None: + result["_meta"] = {SERVER_INFO_META_KEY: dict(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: dict(self.server.server_info_stamp)} + return result @staticmethod def _negotiate_initialize(params: Mapping[str, Any] | None) -> tuple[InitializeRequestParams, str]: diff --git a/tests/server/test_runner.py b/tests/server/test_runner.py index c68b0e744..b241359b4 100644 --- a/tests/server/test_runner.py +++ b/tests/server/test_runner.py @@ -995,10 +995,10 @@ async def custom(ctx: Ctx, params: RequestParams) -> dict[str, Any]: @pytest.mark.anyio -async def test_modern_short_circuit_middleware_result_still_carries_the_server_info_stamp(server: SrvT): - """SDK-defined: the serverInfo stamp is applied at the runner's single exit, - after the middleware chain, so even a middleware that answers without - calling `call_next` produces an identified result (spec 2026-07-28, #3002).""" +async def test_modern_short_circuit_middleware_owns_its_result_envelope(server: SrvT): + """SDK-defined: a middleware that answers without calling `call_next` is + trusted to return its own well-formed result, response envelope included - + the outbound pipeline (and its serverInfo stamp) never patches it up.""" async def short_circuit(ctx: Ctx, call_next: Any) -> Any: return {"ok": True} @@ -1007,10 +1007,7 @@ async def short_circuit(ctx: Ctx, call_next: Any) -> Any: born_ready = Connection.from_envelope(LATEST_MODERN_VERSION, None, None) async with connected_runner(server, initialized=False, connection=born_ready) as (client, _): result = await client.send_raw_request("myorg/anything", None) - assert result == { - "ok": True, - "_meta": {SERVER_INFO_META_KEY: {"name": "test-server", "version": "0.0.1"}}, - } + assert result == {"ok": True} @pytest.mark.anyio