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 01/15] 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 87116a420e..7b0d05712d 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 9b1739d29d..1c3e2c235a 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 f338629095..cc857405e9 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 2963c13232..5612252739 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 02/15] 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 f612511568..1db6b35f8a 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 d5b9d9ed85..583e203ac0 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 8cb7dc4213..ca05928dff 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 2b7fdf35ee..97ac905bdb 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 7fc1cd1948..8877c3cdfd 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 3b53335ae4..297bbcde67 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 0a61689eb5..69ad5ecadb 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 c3e0ea338f..c28aa7fb71 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 d448905a96..31caf87bf2 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 29d3f07fa6..45877ff9a0 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 45a1e7e1c4..21cc98b8c8 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 2bf9c36411..7712e73e5e 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 126e06c291..c34d79e37e 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 03/15] 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 c1b8fc6b5b..dd0443b972 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 3ef52b3239..62e02e70ab 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 f14f164027..6eca876934 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 ba9acf5d99..b884c70609 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 78d281e7a9..a5d3d8f595 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 aab2312dc9..0bf3c7af95 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 cc857405e9..0c7b85289b 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 87c0c5d594..41cec2edf3 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 9c0516836a..e19ebe0b94 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 5612252739..fb168b3059 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 d519106a63..e840675011 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 097ade1c91..36cb78df5d 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 abf98c6fdb..dfe6f9e6b2 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 8877c3cdfd..b2b8a42eee 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 297bbcde67..bd1e9fe3ca 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 6c78503b97..419ba102ee 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 953ea005ad..4ebf6483ed 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 e7fe49dc43..d4436a7b71 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 e22ec4015b..27c41e6614 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 507c8f69e3..9c935ef18a 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 94ebd7946e..f61fc3710e 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 0a0ae62dde..bd34b9f401 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 2bed2bd64c..45e09998b8 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 0cc4f133e4..698e1eb013 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 0000000000..eefc57a8d6 --- /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 3d70371f53..c8292d989b 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 848eddd52e..4da0da1f42 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 3012ae1a08..332184971c 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 bed5c234b6..4571983329 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 34746dd0b3..afdd6f4324 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 7ea89eb790..7ccb4c8a24 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 50a9e53d9d..ddebd80090 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 3b0ad571a0..c375ab6149 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 73366a9840..06b8a3a0c0 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 4b9a8926ad..fb9ab908ed 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 79721c6138..b8d54db176 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 5ab73e2e94..a6104840fc 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 752c17aa4f..a9fa7ea87f 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 b918daf008..d46ff26a86 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 eda1b8423c..a992027a23 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 301af558c1..c60437edd1 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 dbb7de3459..7577b0c408 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 779a46054a..bb6dec5695 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 05d57d846a..78c40a14d3 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 b6ff0283d6..bd0ff690b6 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 3103a50f38..96a7fc531f 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 3e36f22579..ff76bdc0af 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 262bdfe7a1..c1a0880070 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 0a6adc2aa1..0b52573650 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 45877ff9a0..c68b0e7449 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 218e34d5ac..24d9dcf75c 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 21cc98b8c8..ead7a20e90 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 0104d5398b..9c39ef456b 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 3083774200..ad481d2c0a 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 c34d79e37e..959743e596 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 080f343c3d..8c75205636 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 04/15] 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 26df8f6123..da0b016ffd 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 ae47508359..f70966e1ab 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 c7a1434fb1..70ad86833a 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 82a113cfb1..0143e12a01 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 876608db70..73104a6601 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 221a87dc41..9ef19a7cf7 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 df23966078..e8042b83ab 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 de29f0aefc..f9aa98c5a4 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 05/15] 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 aa31cb757b..2c17178330 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 dd132698dd..9dd50debea 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 4b56a671c7..9ac53c1fd9 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 06/15] 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 bd1e9fe3ca..7fc1fada23 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 c68b0e7449..b241359b47 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 From debec20fae94c8287de4e370c3d56698a7d1ba5e Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:57:20 +0000 Subject: [PATCH 07/15] Extensions intercept tools/call at the handler layer A short-circuiting Extension.intercept_tool_call previously returned from the middleware chain, above the runner's outbound envelope pass, so its result skipped the per-version result sieve and the 2026-era serverInfo _meta stamp - and extension authors had no way to add the stamp themselves. Compose the interceptor chain around the tools/call handler instead of appending a middleware: a short-circuited result is now serialized exactly like a handler result, and call_next hands interceptors the handler's domain result rather than the wire dict. Also: pin version="2.0.0" on the lowlevel structured-output tutorial server so the page's fenced result is reproducible (and prove the fence verbatim in its test), document middleware envelope ownership in the middleware and migration pages, and update two stale docstrings from the envelope refactor. --- docs/advanced/middleware.md | 4 ++ docs/migration.md | 3 +- docs_src/lowlevel/tutorial003.py | 2 +- src/mcp/server/extension.py | 30 ++++++----- src/mcp/server/mcpserver/server.py | 20 +++++--- src/mcp/server/runner.py | 5 +- tests/docs_src/test_lowlevel.py | 13 ++++- tests/server/mcpserver/test_extension.py | 63 +++++++++++------------- tests/server/test_runner.py | 6 +-- 9 files changed, 85 insertions(+), 61 deletions(-) diff --git a/docs/advanced/middleware.md b/docs/advanced/middleware.md index 4e57bae82d..3d5808550a 100644 --- a/docs/advanced/middleware.md +++ b/docs/advanced/middleware.md @@ -63,6 +63,10 @@ In increasing order of how much you should hesitate: `initialize`: the result the client gets back is built from your rewritten params, but the server commits its connection state from the original wire params. The two sides can finish the handshake disagreeing about what they negotiated. +* **Answer.** Return a result without calling `call_next(ctx)` and it goes to the client as + your response. `call_next` hands you the finished wire form, and the pipeline never patches + what you return, so the whole envelope is yours: on a 2026-era connection that includes the + `serverInfo` `_meta` stamp, which the SDK adds to handler results but not to yours. !!! check `initialize` is one of the things middleware wraps, and it is the *only* hook you get diff --git a/docs/migration.md b/docs/migration.md index 73104a6601..3ba7286768 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -486,7 +486,8 @@ What changed in the SDK: 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. + and error responses are never stamped. A middleware that answers a request + itself (without `call_next`) owns its result envelope, stamp included. - `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 diff --git a/docs_src/lowlevel/tutorial003.py b/docs_src/lowlevel/tutorial003.py index f350397006..65d89f198c 100644 --- a/docs_src/lowlevel/tutorial003.py +++ b/docs_src/lowlevel/tutorial003.py @@ -38,4 +38,4 @@ async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> ) -server = Server("Bookshop", on_list_tools=list_tools, on_call_tool=call_tool) +server = Server("Bookshop", version="2.0.0", on_list_tools=list_tools, on_call_tool=call_tool) diff --git a/src/mcp/server/extension.py b/src/mcp/server/extension.py index c87d93b006..c7c9e3e0dd 100644 --- a/src/mcp/server/extension.py +++ b/src/mcp/server/extension.py @@ -27,7 +27,7 @@ from mcp_types.methods import SPEC_CLIENT_METHODS from pydantic import BaseModel -from mcp.server.context import CallNext, HandlerResult, ServerMiddleware, ServerRequestContext +from mcp.server.context import CallNext, HandlerResult, ServerRequestContext # Re-exported from `mcp.shared.extension` (shared with the client surface) for existing importers. from mcp.shared.extension import validate_extension_identifier as validate_extension_identifier @@ -144,30 +144,34 @@ async def intercept_tool_call( Override to short-circuit (return a result without calling `call_next`) or to observe the call. `params` is the validated `tools/call` params; - `call_next(ctx)` runs the rest of the chain and the real handler. + `call_next(ctx)` runs the rest of the chain and the real handler, and + returns the handler's domain result. Interceptors run at the handler + layer: whatever they return is serialized like any handler result, + including the 2026-era `serverInfo` `_meta` stamp. """ return await call_next(ctx) -def compose_tool_call_interceptor(extensions: Sequence[Extension]) -> ServerMiddleware[Any]: - """Fold every extension's `intercept_tool_call` into one `ServerMiddleware`. +def compose_tool_call_handler(extensions: Sequence[Extension], handler: RequestHandler) -> RequestHandler: + """Fold every extension's `intercept_tool_call` around the `tools/call` handler. - The returned middleware nests the interceptors (first extension outermost) - and is a no-op for any method other than `tools/call`. It validates the - `tools/call` params once and threads them to each interceptor. + The returned handler nests the interceptors (first extension outermost) and + replaces the plain `tools/call` registration. Interception happens at the + handler layer, below the runner's outbound envelope pass, so a + short-circuiting interceptor's result is sieved and stamped exactly like + the wrapped handler's would be. """ - async def middleware(ctx: ServerRequestContext[Any, Any], call_next: CallNext) -> HandlerResult: - if ctx.method != "tools/call": - return await call_next(ctx) - params = CallToolRequestParams.model_validate({} if ctx.params is None else ctx.params, by_name=False) + async def wrapped(ctx: ServerRequestContext[Any, Any], params: CallToolRequestParams) -> HandlerResult: + async def innermost(inner_ctx: ServerRequestContext[Any, Any]) -> HandlerResult: + return await handler(inner_ctx, params) - chain = call_next + chain: CallNext = innermost for extension in reversed(extensions): chain = _bind_interceptor(extension, params, chain) return await chain(ctx) - return middleware + return wrapped def _bind_interceptor(extension: Extension, params: CallToolRequestParams, call_next: CallNext) -> CallNext: diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index b2b8a42eee..18edecda0b 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -64,7 +64,7 @@ Extension, MethodBinding, RequestHandler, - compose_tool_call_interceptor, + compose_tool_call_handler, validate_extension_identifier, ) from mcp.server.lowlevel.helper_types import ReadResourceContents @@ -230,8 +230,9 @@ def __init__( # We need to create a Lifespan type that is a generic on the server type, like Starlette does. lifespan=(lifespan_wrapper(self, self.settings.lifespan) if self.settings.lifespan else default_lifespan), # type: ignore ) - # Ordering: inside OpenTelemetry (spans record the sealed wire form), - # outside extension interceptors (extensions see plaintext). + # Ordering: inside OpenTelemetry (spans record the sealed wire form). + # Extension interceptors run at the handler layer, inside this + # boundary, so they see plaintext. if request_state_security is None: security = RequestStateSecurity.ephemeral() else: @@ -336,13 +337,20 @@ def _apply_extension(self, extension: Extension) -> None: self._lowlevel_server.extensions[extension.identifier] = extension.settings() def _install_extension_interceptor(self) -> None: - """Compose every extension's `tools/call` interceptor into one middleware. + """Wrap the `tools/call` handler with every extension's interceptor. Installed only when at least one extension overrides `intercept_tool_call`, - so a server with purely additive extensions adds no middleware. + so a server with purely additive extensions keeps the bare handler. The + chain wraps the handler itself, below the runner's outbound envelope + pass, so a short-circuiting interceptor's result is sieved and stamped + exactly like a handler result. """ if any(type(e).intercept_tool_call is not Extension.intercept_tool_call for e in self._extensions): - self._lowlevel_server.middleware.append(compose_tool_call_interceptor(self._extensions)) + self._lowlevel_server.add_request_handler( + "tools/call", + CallToolRequestParams, + compose_tool_call_handler(self._extensions, self._handle_call_tool), + ) @overload def run(self, transport: Literal["stdio"] = ...) -> None: ... diff --git a/src/mcp/server/runner.py b/src/mcp/server/runner.py index 7fc1fada23..b23e694a89 100644 --- a/src/mcp/server/runner.py +++ b/src/mcp/server/runner.py @@ -370,8 +370,9 @@ def _stamp_server_info(self, version: str, result: dict[str, Any]) -> dict[str, 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. + pipeline-owned (`_dump_result` copies dicts; the spec-method sieve + re-dumps), but `_meta` may still be the handler's object, so the stamp + replaces it rather than writing into it. """ if not self.server.include_server_info or version not in MODERN_PROTOCOL_VERSIONS: return result diff --git a/tests/docs_src/test_lowlevel.py b/tests/docs_src/test_lowlevel.py index afdd6f4324..6277a72131 100644 --- a/tests/docs_src/test_lowlevel.py +++ b/tests/docs_src/test_lowlevel.py @@ -87,8 +87,17 @@ async def test_output_schema_and_structured_content_are_both_yours_to_build() -> } ) result = await client.call_tool("search_books", {"query": "dune", "limit": 5}) - assert result.content == [TextContent(type="text", text="Found 3 books matching 'dune'.")] - assert result.structured_content == {"matches": 3, "query": "dune"} + # The page shows this exact payload; tutorial003 pins `version="2.0.0"` so + # the identity stamp is deterministic and the fence is proved verbatim. + assert result.model_dump(by_alias=True, exclude_none=True) == snapshot( + { + "_meta": {"io.modelcontextprotocol/serverInfo": {"name": "Bookshop", "version": "2.0.0"}}, + "content": [{"type": "text", "text": "Found 3 books matching 'dune'."}], + "structuredContent": {"matches": 3, "query": "dune"}, + "isError": False, + "resultType": "complete", + } + ) async def test_the_client_checks_the_schema_you_promised() -> None: diff --git a/tests/server/mcpserver/test_extension.py b/tests/server/mcpserver/test_extension.py index bd0ff690b6..ea00ecb18e 100644 --- a/tests/server/mcpserver/test_extension.py +++ b/tests/server/mcpserver/test_extension.py @@ -2,11 +2,11 @@ These exercise the closed set of extension contribution kinds - tools, resources, request methods, and the single `tools/call` interceptor - through -the highest-level public surface (in-memory `Client`), plus the -`compose_tool_call_interceptor` helper directly. +the highest-level public surface (in-memory `Client`). """ -from typing import Any, Literal, cast +from importlib.metadata import version +from typing import Any, Literal import mcp_types as types import pytest @@ -14,6 +14,7 @@ from mcp_types import ( METHOD_NOT_FOUND, MISSING_REQUIRED_CLIENT_CAPABILITY, + SERVER_INFO_META_KEY, CallToolResult, TextContent, ) @@ -26,7 +27,6 @@ MethodBinding, ResourceBinding, ToolBinding, - compose_tool_call_interceptor, ) from mcp.server.mcpserver import Context, MCPServer, require_client_extension from mcp.server.mcpserver.resources import TextResource @@ -222,23 +222,28 @@ async def test_short_circuiting_interceptor_replaces_tool_result() -> None: assert result == snapshot(CallToolResult(content=[TextContent(text="intercepted")])) -def test_plain_extension_installs_no_tool_call_interceptor() -> None: - """SDK-defined: an extension that does not override `intercept_tool_call` adds no - middleware - the composed interceptor exists only when at least one extension - overrides it.""" - baseline = len(MCPServer("test")._lowlevel_server.middleware) +def test_plain_extension_leaves_the_tool_call_handler_bare() -> None: + """SDK-defined: an extension that does not override `intercept_tool_call` leaves + `tools/call` registered as the server's own handler - the interceptor chain is + composed only when at least one extension overrides it.""" server = MCPServer("test", extensions=[_AdditiveExt()]) - assert len(server._lowlevel_server.middleware) == baseline + entry = server._lowlevel_server.get_request_handler("tools/call") + assert entry is not None + assert entry.handler == server._handle_call_tool -def test_overriding_extension_installs_one_tool_call_interceptor() -> None: - """SDK-defined: an extension that overrides `intercept_tool_call` composes exactly - one additional `tools/call` middleware.""" +def test_overriding_extension_wraps_the_tool_call_handler() -> None: + """SDK-defined: an extension that overrides `intercept_tool_call` re-registers + `tools/call` with the interceptor chain wrapped around the server's own handler, + and installs no middleware.""" baseline = len(MCPServer("test")._lowlevel_server.middleware) server = MCPServer("test", extensions=[_ReplacingExt()]) - assert len(server._lowlevel_server.middleware) == baseline + 1 + entry = server._lowlevel_server.get_request_handler("tools/call") + assert entry is not None + assert entry.handler != server._handle_call_tool + assert len(server._lowlevel_server.middleware) == baseline async def test_default_interceptor_passes_through_alongside_an_overriding_one() -> None: @@ -255,7 +260,7 @@ async def test_default_interceptor_passes_through_alongside_an_overriding_one() async def test_interceptors_run_in_registration_order_with_threaded_params() -> None: - """SDK-defined: `compose_tool_call_interceptor` nests extensions first-outermost, so + """SDK-defined: `compose_tool_call_handler` nests extensions first-outermost, so two passing-through interceptors record in registration order, each seeing the validated `tools/call` params (the real tool name).""" log: list[tuple[str, str]] = [] @@ -271,26 +276,18 @@ async def test_interceptors_run_in_registration_order_with_threaded_params() -> assert log == [("com.example/first", "echo"), ("com.example/second", "echo")] -async def test_compose_tool_call_interceptor_passes_through_non_tools_call() -> None: - """SDK-defined: the composed middleware is a no-op for any method other than - `tools/call` - it forwards to `call_next` without touching the interceptors.""" - sentinel = types.EmptyResult() - - async def call_next(ctx: ServerRequestContext[Any, Any]) -> HandlerResult: - return sentinel - - middleware = compose_tool_call_interceptor([_ReplacingExt()]) - ctx = ServerRequestContext( - session=cast("Any", None), - lifespan_context={}, - protocol_version="2026-07-28", - method="tasks/get", - params={"taskId": "t-1"}, - ) +async def test_short_circuited_interceptor_result_carries_the_server_info_stamp() -> None: + """Spec-mandated (2026-07-28, #3002): an interceptor that answers without running + the tool still produces a stamped result - interception happens at the handler + layer, below the runner's outbound envelope pass.""" + server = MCPServer("test", extensions=[_ReplacingExt()]) + server.tool(name="echo", structured_output=False)(_echo) - result = await middleware(ctx, call_next) + async with Client(server) as client: + result = await client.call_tool("echo", {"value": "hi"}) - assert result is sentinel + assert result.content == [TextContent(text="intercepted")] + assert result.meta == {SERVER_INFO_META_KEY: {"name": "test", "version": version("mcp")}} def test_extension_subclass_without_prefixed_identifier_is_rejected_at_definition() -> None: diff --git a/tests/server/test_runner.py b/tests/server/test_runner.py index b241359b47..dd97b26fd1 100644 --- a/tests/server/test_runner.py +++ b/tests/server/test_runner.py @@ -1042,9 +1042,9 @@ async def custom(ctx: Ctx, params: RequestParams) -> dict[str, Any]: @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.""" + """SDK-defined: the outbound pass dumps the handler's dict to a copy + (`_dump_result`) and the stamp writes into that copy, so a dict the handler + retains (module-level, cached, shared) is never mutated underneath it.""" retained: dict[str, Any] = {"ok": True} From 9f54d31431cb746a2885374136e800caa918debc Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:11:10 +0000 Subject: [PATCH 08/15] Sieve only core-vocabulary results; claimed shapes pass through Moving extension interception to the handler layer put interceptor results through the per-version result sieve, which strips fields the surface doesn't know - breaking claimed extension shapes (a resultType outside the core vocabulary carrying vendor fields). The sieve's job is per-version surface enforcement for core results; an extension resultType marks a shape the extension owns, so it now applies to core-vocabulary results only. Claimed shapes still get the 2026-era serverInfo stamp: they are results like any other. --- src/mcp/server/runner.py | 19 +++++++++++++------ tests/server/test_runner.py | 22 ++++++++++++++++++++++ 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/src/mcp/server/runner.py b/src/mcp/server/runner.py index b23e694a89..9dd23ae911 100644 --- a/src/mcp/server/runner.py +++ b/src/mcp/server/runner.py @@ -24,6 +24,7 @@ from mcp_types import ( CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, + CORE_RESULT_TYPES, INTERNAL_ERROR, INVALID_PARAMS, INVALID_REQUEST, @@ -335,9 +336,10 @@ def _serialize(self, method: str, version: str, result: HandlerResult) -> dict[s 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 + `ttlMs`/`cacheScope` the handler left unset, core-vocabulary spec-method + results are validated and sieved by the per-version surface (a claimed + extension `resultType` shape is the extension's to own), and 2026-era + results get the `serverInfo` `_meta` stamp (spec #3002). Runs inside the middleware chain so the OpenTelemetry span observes a failing return shape (unsupported type, malformed spec result) as an error rather than closing on a request that the client sees fail - and so a @@ -352,10 +354,15 @@ def _serialize(self, method: str, version: str, result: HandlerResult) -> dict[s # 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 + # An extension `resultType` (outside the core vocabulary) marks a claimed + # shape owned by the extension that defined it: the per-version surface + # doesn't describe it, so the sieve applies to core results only. + # TODO(L56): reject extension resultType values unless the corresponding + # extension is in this request's _meta clientCapabilities.extensions; the # explicit MUST-reject is client-side (basic/index.mdx ResultType), this enforces it proactively. - if method in _methods.SPEC_CLIENT_METHODS: + result_type = dumped.get("resultType") + core_shape = not isinstance(result_type, str) or result_type in CORE_RESULT_TYPES + if method in _methods.SPEC_CLIENT_METHODS and core_shape: try: dumped = _methods.serialize_server_result(method, version, dumped) except ValidationError: diff --git a/tests/server/test_runner.py b/tests/server/test_runner.py index dd97b26fd1..b103b75ab0 100644 --- a/tests/server/test_runner.py +++ b/tests/server/test_runner.py @@ -27,6 +27,7 @@ PROTOCOL_VERSION_META_KEY, SERVER_INFO_META_KEY, UNSUPPORTED_PROTOCOL_VERSION, + CallToolRequestParams, ClientCapabilities, EmptyResult, ErrorData, @@ -1059,6 +1060,27 @@ async def custom(ctx: Ctx, params: RequestParams) -> dict[str, Any]: assert retained == {"ok": True} +@pytest.mark.anyio +async def test_a_claimed_extension_result_type_bypasses_the_sieve_and_is_stamped(server: SrvT): + """SDK-defined: a spec-method result carrying an extension `resultType` is a + claimed shape the extension owns - the per-version sieve would strip its + vendor fields, so it applies to core-vocabulary results only. The identity + stamp still lands: claimed shapes are results like any other.""" + + async def custom(ctx: Ctx, params: CallToolRequestParams) -> dict[str, Any]: + return {"resultType": "voucher", "voucherCode": "v-42"} + + server.add_request_handler("tools/call", CallToolRequestParams, 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("tools/call", _modern_params(name="issue")) + assert result == { + "resultType": "voucher", + "voucherCode": "v-42", + "_meta": {SERVER_INFO_META_KEY: {"name": "test-server", "version": "0.0.1"}}, + } + + @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 From fb9b64cf42dde4e736eaf800e13cc1aa79575f22 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:29:55 +0000 Subject: [PATCH 09/15] Always stamp serverInfo; drop the include_server_info opt-out The spec's opt-out language ("unless specifically configured not to do so") is already expressible at the layer that owns response envelopes: a middleware that strips the key from the results it passes along. A dedicated constructor flag duplicated that mechanism, so it is removed; neither the go nor the typescript SDK shipped one. The interaction matrix now pins a server version and strips the stamp at comparison sites (tests/_stamp.unstamped) instead of running a non-default config. Also from review: - server_info_stamp returns a deep copy per access: the cached dump's nested values (icons) previously aliased into every stamped response, so mutating one response's stamp corrupted all later ones. - The claimed-shape sieve bypass is now modern-only: a resultType outside the core vocabulary on a legacy session fails the per-version surface (INTERNAL_ERROR) instead of leaking a shape legacy clients cannot resolve. - The validated params argument of Extension.intercept_tool_call is documented as authoritative: a context rewrite through call_next adjusts what the handler observes, not which tool call runs (pinned by test). --- docs/advanced/low-level-server.md | 2 +- docs/migration.md | 15 +++-- docs/whats-new.md | 2 +- src/mcp/server/context.py | 7 +- src/mcp/server/extension.py | 6 +- src/mcp/server/lowlevel/server.py | 24 +++---- src/mcp/server/mcpserver/server.py | 2 - src/mcp/server/runner.py | 24 ++++--- tests/_stamp.py | 32 +++++++++ tests/client/test_client.py | 44 +++++++------ tests/client/test_session_concurrency.py | 5 +- tests/interaction/conftest.py | 11 ++-- .../interaction/lowlevel/test_cancellation.py | 9 ++- tests/interaction/lowlevel/test_completion.py | 9 ++- tests/interaction/lowlevel/test_flows.py | 7 +- tests/interaction/lowlevel/test_logging.py | 3 +- tests/interaction/lowlevel/test_meta.py | 6 +- tests/interaction/lowlevel/test_pagination.py | 5 +- tests/interaction/lowlevel/test_progress.py | 5 +- tests/interaction/lowlevel/test_prompts.py | 11 ++-- tests/interaction/lowlevel/test_resources.py | 9 +-- tests/interaction/lowlevel/test_tools.py | 27 ++++---- tests/interaction/mcpserver/test_context.py | 7 +- .../interaction/mcpserver/test_extensions.py | 3 +- tests/interaction/mcpserver/test_prompts.py | 9 +-- tests/interaction/mcpserver/test_resources.py | 11 ++-- tests/interaction/mcpserver/test_tools.py | 25 ++++--- tests/server/mcpserver/test_extension.py | 66 ++++++++++++++----- tests/server/mcpserver/test_server.py | 33 +++++----- tests/server/test_apps.py | 5 +- tests/server/test_caching.py | 6 +- tests/server/test_runner.py | 47 ++++++++++++- tests/server/test_streamable_http_modern.py | 7 +- 33 files changed, 327 insertions(+), 157 deletions(-) create mode 100644 tests/_stamp.py diff --git a/docs/advanced/low-level-server.md b/docs/advanced/low-level-server.md index da0b016ffd..bb8600c307 100644 --- a/docs/advanced/low-level-server.md +++ b/docs/advanced/low-level-server.md @@ -107,7 +107,7 @@ Call it and the result carries both representations: } ``` -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 `_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. A server that must not identify itself can strip the key with a middleware, which owns the results it returns. 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)**. diff --git a/docs/migration.md b/docs/migration.md index 3ba7286768..b6aecb7d8c 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -481,13 +481,14 @@ 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. A middleware that answers a request - itself (without `call_next`) owns its result envelope, stamp included. +- Servers stamp `serverInfo` into every 2026-era result's `_meta`, built from + the constructor identity fields (`name`, `version`, `title`, and so on). 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. A middleware that answers a request itself (without + `call_next`) owns its result envelope, stamp included; a server that must + not identify itself can strip the key the same way, with a middleware that + removes it from the results it passes along. - `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 diff --git a/docs/whats-new.md b/docs/whats-new.md index f9aa98c5a4..fddaaa00bf 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -197,7 +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. +* **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 always stamps; `client.server_info` is `None` when a server does not identify itself (for example, a middleware stripped the key). 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)**. diff --git a/src/mcp/server/context.py b/src/mcp/server/context.py index b5c356075a..903b7ef6f8 100644 --- a/src/mcp/server/context.py +++ b/src/mcp/server/context.py @@ -116,8 +116,11 @@ async def log(self, level: LoggingLevel, data: Any, logger: str | None = None, * all three to a result dict.""" CallNext = Callable[["ServerRequestContext[Any, Any]"], Awaitable[HandlerResult]] -"""Invokes the rest of the chain. Pass the `ctx` through; rewrite `method` or -`params` with `dataclasses.replace(ctx, ...)` to alter what the handler sees.""" +"""Invokes the rest of the chain with the given context. What a context +rewrite (`dataclasses.replace(ctx, ...)`) can alter depends on the tier: +`ServerMiddleware` runs before params validation, so its rewrites change what +the handler is invoked with; an `Extension` interceptor runs after, so its +rewrites change only what the handler observes on `ctx`.""" _MwLifespanT = TypeVar("_MwLifespanT") diff --git a/src/mcp/server/extension.py b/src/mcp/server/extension.py index c7c9e3e0dd..6205a7033d 100644 --- a/src/mcp/server/extension.py +++ b/src/mcp/server/extension.py @@ -147,7 +147,11 @@ async def intercept_tool_call( `call_next(ctx)` runs the rest of the chain and the real handler, and returns the handler's domain result. Interceptors run at the handler layer: whatever they return is serialized like any handler result, - including the 2026-era `serverInfo` `_meta` stamp. + including the 2026-era `serverInfo` `_meta` stamp. The `params` this + interceptor received is what the wrapped handler is invoked with - + passing a rewritten context through `call_next` adjusts what the + handler observes on `ctx`, not the tool invocation. Wire-level request + rewriting belongs to `Server.middleware`, above params validation. """ return await call_next(ctx) diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index dfe6f9e6b2..ce2f2b67bc 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -36,6 +36,7 @@ async def main(): from __future__ import annotations +import copy import logging import warnings from collections.abc import AsyncIterator, Awaitable, Callable, Mapping @@ -147,7 +148,6 @@ 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], @@ -231,7 +231,6 @@ 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], @@ -324,7 +323,6 @@ 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], @@ -432,10 +430,6 @@ 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) @@ -657,13 +651,21 @@ def server_info(self) -> types.Implementation: ) @cached_property + def _server_info_stamp_source(self) -> dict[str, Any]: + # Identity is fixed at construction, so the dump is computed once per + # server instead of per request. Never handed out directly: nested + # values (`icons`) would alias the cache into stamped responses. + return self.server_info.model_dump(by_alias=True, mode="json", exclude_none=True) + + @property def server_info_stamp(self) -> dict[str, Any]: - """The wire dump of `server_info`, computed once per server. + """A fresh wire dump of `server_info`; callers own the returned dict. - Identity is fixed at construction, so the per-result `_meta` stamping - site (`ServerRunner`) reads this instead of re-dumping per request. + Each access materializes a deep copy of the once-per-server dump, so + a caller mutating a stamped response can never corrupt the identity + stamped into later responses. """ - return self.server_info.model_dump(by_alias=True, mode="json", exclude_none=True) + return copy.deepcopy(self._server_info_stamp_source) async def _handle_discover( self, ctx: ServerRequestContext[LifespanResultT], params: types.RequestParams | None diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index 18edecda0b..ddd6c1fe65 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -183,7 +183,6 @@ 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 @@ -217,7 +216,6 @@ 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 9dd23ae911..53b76839b3 100644 --- a/src/mcp/server/runner.py +++ b/src/mcp/server/runner.py @@ -354,14 +354,21 @@ def _serialize(self, method: str, version: str, result: HandlerResult) -> dict[s # 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) - # An extension `resultType` (outside the core vocabulary) marks a claimed - # shape owned by the extension that defined it: the per-version surface - # doesn't describe it, so the sieve applies to core results only. + # A modern-era extension `resultType` (outside the core vocabulary) marks + # a claimed shape owned by the extension that defined it: the per-version + # surface doesn't describe it, so the sieve applies to core results only. + # Legacy connections sieve everything - claimed shapes are 2026-era + # vocabulary and cannot be delivered on a legacy wire (mirrors the + # client-side ResultClaim rule). # TODO(L56): reject extension resultType values unless the corresponding # extension is in this request's _meta clientCapabilities.extensions; the # explicit MUST-reject is client-side (basic/index.mdx ResultType), this enforces it proactively. result_type = dumped.get("resultType") - core_shape = not isinstance(result_type, str) or result_type in CORE_RESULT_TYPES + core_shape = ( + version not in MODERN_PROTOCOL_VERSIONS + or not isinstance(result_type, str) + or result_type in CORE_RESULT_TYPES + ) if method in _methods.SPEC_CLIENT_METHODS and core_shape: try: dumped = _methods.serialize_server_result(method, version, dumped) @@ -379,17 +386,18 @@ def _stamp_server_info(self, version: str, result: dict[str, Any]) -> dict[str, to own, and handshake-era results are never stamped. `result` is pipeline-owned (`_dump_result` copies dicts; the spec-method sieve re-dumps), but `_meta` may still be the handler's object, so the stamp - replaces it rather than writing into it. + replaces it rather than writing into it. `server_info_stamp` is a + fresh dict per access, so the response never aliases server state. """ - if not self.server.include_server_info or version not in MODERN_PROTOCOL_VERSIONS: + if version not in MODERN_PROTOCOL_VERSIONS: return result raw_meta = result.get("_meta") if raw_meta is None: - result["_meta"] = {SERVER_INFO_META_KEY: dict(self.server.server_info_stamp)} + result["_meta"] = {SERVER_INFO_META_KEY: self.server.server_info_stamp} elif isinstance(raw_meta, dict): meta = cast("dict[str, Any]", raw_meta) if meta.get(SERVER_INFO_META_KEY) is None: - result["_meta"] = {**meta, SERVER_INFO_META_KEY: dict(self.server.server_info_stamp)} + result["_meta"] = {**meta, SERVER_INFO_META_KEY: self.server.server_info_stamp} return result @staticmethod diff --git a/tests/_stamp.py b/tests/_stamp.py new file mode 100644 index 0000000000..8cf1c4cd49 --- /dev/null +++ b/tests/_stamp.py @@ -0,0 +1,32 @@ +"""Shared helper: drop the 2026-era serverInfo `_meta` stamp from a result. + +Servers stamp `io.modelcontextprotocol/serverInfo` into every 2026-era +result's `_meta` and never into handshake-era ones, and the stamp's `version` +defaults to the installed package version. Suites that share one expected +payload across eras (the interaction matrix) or that construct servers +without a pinned version strip the stamp before exact comparison. Stamp +semantics themselves have dedicated coverage in tests/server/test_runner.py. +""" + +from typing import Any, TypeVar + +from mcp_types import SERVER_INFO_META_KEY, Result + +R = TypeVar("R", bound=Result) + + +def unstamped(result: R) -> R: + """Remove the serverInfo stamp in place if present, asserting its shape. + + Present-versus-absent is era-dependent and belongs to the runner tests; + this only guarantees that when a stamp exists it is a well-formed + identity object, then returns the result for inline use in comparisons. + """ + meta = result.meta + if meta is not None and SERVER_INFO_META_KEY in meta: + stamp: Any = meta.pop(SERVER_INFO_META_KEY) + assert isinstance(stamp, dict) + assert "name" in stamp and "version" in stamp + if not meta: + result.meta = None + return result diff --git a/tests/client/test_client.py b/tests/client/test_client.py index 419ba102ee..0c1f354a2d 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -45,6 +45,7 @@ from mcp.server.mcpserver import Context, MCPServer from mcp.shared.memory import MessageStream, create_client_server_memory_streams from mcp.shared.message import SessionMessage +from tests._stamp import unstamped from tests.interaction._connect import BASE_URL, mounted_app pytestmark = pytest.mark.anyio @@ -82,14 +83,13 @@ 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", include_server_info=False) + server = MCPServer("test") @server.tool() def greet(name: str) -> str: @@ -134,7 +134,7 @@ async def test_client_with_simple_server(simple_server: Server): """Test that from_server works with a basic Server instance.""" async with Client(simple_server) as client: resources = await client.list_resources() - assert resources == snapshot( + assert unstamped(resources) == snapshot( ListResourcesResult( resources=[Resource(name="Test Resource", uri="memory://test", description="A test resource")] ) @@ -150,7 +150,7 @@ async def test_client_send_ping(app: MCPServer): async def test_client_list_tools(app: MCPServer): async with Client(app) as client: result = await client.list_tools() - assert result == snapshot( + assert unstamped(result) == snapshot( ListToolsResult( tools=[ Tool( @@ -177,7 +177,7 @@ async def test_client_list_tools(app: MCPServer): async def test_client_call_tool(app: MCPServer): async with Client(app) as client: result = await client.call_tool("greet", {"name": "World"}) - assert result == snapshot( + assert unstamped(result) == snapshot( CallToolResult( content=[TextContent(text="Hello, World!")], structured_content={"result": "Hello, World!"}, @@ -189,7 +189,7 @@ async def test_read_resource(app: MCPServer): """Test reading a resource.""" async with Client(app) as client: result = await client.read_resource("test://resource") - assert result == snapshot( + assert unstamped(result) == snapshot( ReadResourceResult( contents=[TextResourceContents(uri="test://resource", mime_type="text/plain", text="Test content")] ) @@ -269,7 +269,7 @@ async def test_get_prompt(app: MCPServer): """Test getting a prompt.""" async with Client(app) as client: result = await client.get_prompt("greeting_prompt", {"name": "Alice"}) - assert result == snapshot( + assert unstamped(result) == snapshot( GetPromptResult( description="A greeting prompt.", messages=[PromptMessage(role="user", content=TextContent(text="Please greet Alice warmly."))], @@ -335,7 +335,7 @@ async def test_client_list_resources_with_params(app: MCPServer): """Test listing resources with params parameter.""" async with Client(app) as client: result = await client.list_resources() - assert result == snapshot( + assert unstamped(result) == snapshot( ListResourcesResult( resources=[ Resource( @@ -353,14 +353,14 @@ async def test_client_list_resource_templates(app: MCPServer): """Test listing resource templates with params parameter.""" async with Client(app) as client: result = await client.list_resource_templates() - assert result == snapshot(ListResourceTemplatesResult(resource_templates=[])) + assert unstamped(result) == snapshot(ListResourceTemplatesResult(resource_templates=[])) async def test_list_prompts(app: MCPServer): """Test listing prompts with params parameter.""" async with Client(app) as client: result = await client.list_prompts() - assert result == snapshot( + assert unstamped(result) == snapshot( ListPromptsResult( prompts=[ Prompt( @@ -378,7 +378,7 @@ async def test_complete_with_prompt_reference(simple_server: Server): async with Client(simple_server) as client: ref = types.PromptReference(type="ref/prompt", name="test_prompt") result = await client.complete(ref=ref, argument={"name": "arg", "value": "test"}) - assert result == snapshot(types.CompleteResult(completion=types.Completion(values=[]))) + assert unstamped(result) == snapshot(types.CompleteResult(completion=types.Completion(values=[]))) def test_client_with_url_initializes_streamable_http_transport(): @@ -731,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", include_server_info=False) + server = MCPServer("test") @server.tool() async def greet(ctx: Context) -> str | types.InputRequiredResult: @@ -756,7 +756,7 @@ async def elicitation_callback( async with Client(server, elicitation_callback=elicitation_callback) as client: result = await client.call_tool("greet") - assert result == snapshot( + assert unstamped(result) == snapshot( CallToolResult(content=[TextContent(text="Hello, Ada!")], structured_content={"result": "Hello, Ada!"}) ) assert len(callback_params) == 1 @@ -768,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", include_server_info=False) + server = MCPServer("test") @server.tool() async def ask(ctx: Context) -> str | types.InputRequiredResult: @@ -801,7 +801,7 @@ async def sampling_callback( async with Client(server, sampling_callback=sampling_callback) as client: result = await client.call_tool("ask") - assert result == snapshot( + assert unstamped(result) == snapshot( CallToolResult( content=[TextContent(text="Model said: Paris")], structured_content={"result": "Model said: Paris"} ) @@ -813,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", include_server_info=False) + server = MCPServer("test") @server.tool() async def count_roots(ctx: Context) -> str | types.InputRequiredResult: @@ -834,7 +834,7 @@ async def list_roots_callback(context: ClientRequestContext) -> types.ListRootsR async with Client(server, list_roots_callback=list_roots_callback) as client: result = await client.call_tool("count_roots") - assert result == snapshot( + assert unstamped(result) == snapshot( CallToolResult( content=[TextContent(text="Client exposed 1 root(s).")], structured_content={"result": "Client exposed 1 root(s)."}, @@ -909,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", include_server_info=False) + server = Server("test") server.add_request_handler("prompts/get", types.GetPromptRequestParams, handler) async def elicitation_callback( @@ -920,7 +920,9 @@ async def elicitation_callback( with anyio.fail_after(5): async with Client(server, mode="2026-07-28", elicitation_callback=elicitation_callback) as client: result = await client.get_prompt("summary") - assert result == snapshot(GetPromptResult(messages=[PromptMessage(role="user", content=TextContent(text="ok"))])) + assert unstamped(result) == snapshot( + GetPromptResult(messages=[PromptMessage(role="user", content=TextContent(text="ok"))]) + ) async def test_read_resource_auto_loop_resolves_input_required_via_callbacks() -> None: @@ -935,7 +937,7 @@ async def handler( return ReadResourceResult(contents=[TextResourceContents(uri="memory://gated", text="unlocked")]) return types.InputRequiredResult(input_requests={"ask": _name_elicitation()}) - server = Server("test", include_server_info=False) + server = Server("test") server.add_request_handler("resources/read", types.ReadResourceRequestParams, handler) async def elicitation_callback( @@ -946,6 +948,6 @@ async def elicitation_callback( with anyio.fail_after(5): async with Client(server, mode="2026-07-28", elicitation_callback=elicitation_callback) as client: result = await client.read_resource("memory://gated") - assert result == snapshot( + assert unstamped(result) == snapshot( ReadResourceResult(contents=[TextResourceContents(uri="memory://gated", text="unlocked")]) ) diff --git a/tests/client/test_session_concurrency.py b/tests/client/test_session_concurrency.py index bd34b9f401..abfbdbba88 100644 --- a/tests/client/test_session_concurrency.py +++ b/tests/client/test_session_concurrency.py @@ -14,6 +14,7 @@ from mcp import Client from mcp.client import ClientRequestContext from mcp.server.mcpserver import Context, MCPServer +from tests._stamp import unstamped pytestmark = pytest.mark.anyio @@ -34,7 +35,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", include_server_info=False) + server = MCPServer("parking") @server.tool() async def park(tag: str) -> str: @@ -66,7 +67,7 @@ async def call_and_record(tag: str) -> None: await done[tag].wait() assert completion_order == ["c", "b", "a"] - assert results == snapshot( + assert {tag: unstamped(result) for tag, result in results.items()} == snapshot( { "c": CallToolResult(content=[TextContent(text="result:c")], structured_content={"result": "result:c"}), "b": CallToolResult(content=[TextContent(text="result:b")], structured_content={"result": "result:b"}), diff --git a/tests/interaction/conftest.py b/tests/interaction/conftest.py index d46ff26a86..6252558bd8 100644 --- a/tests/interaction/conftest.py +++ b/tests/interaction/conftest.py @@ -52,12 +52,13 @@ def connect(request: pytest.FixtureRequest) -> Connect: 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/. + # The matrix compares exact result payloads, and the 2026-era serverInfo + # `_meta` stamp carries the server version, which defaults to the + # commit-dependent installed package version. Pin it so expected + # payloads stay deterministic across commits. lowlevel = server._lowlevel_server if isinstance(server, MCPServer) else server - lowlevel.include_server_info = False + if lowlevel.version is None: + lowlevel.version = "1.0.0" return factory(server, spec_version=spec_version, **kwargs) return _connect diff --git a/tests/interaction/lowlevel/test_cancellation.py b/tests/interaction/lowlevel/test_cancellation.py index 0e9d81afbc..83b5749ffd 100644 --- a/tests/interaction/lowlevel/test_cancellation.py +++ b/tests/interaction/lowlevel/test_cancellation.py @@ -33,6 +33,7 @@ from mcp.server import Server, ServerRequestContext from mcp.shared.memory import MessageStream, create_client_server_memory_streams from mcp.shared.message import SessionMessage +from tests._stamp import unstamped from tests.interaction._connect import Connect from tests.interaction._helpers import IncomingMessage from tests.interaction._requirements import requirement @@ -157,7 +158,7 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara ) result = await client.call_tool("echo", {}) - assert result == snapshot(CallToolResult(content=[TextContent(text="unbothered")])) + assert unstamped(result) == snapshot(CallToolResult(content=[TextContent(text="unbothered")])) @requirement("protocol:cancel:server-to-client") @@ -397,7 +398,7 @@ async def call_and_abandon() -> None: # dropped while the client is still open, so teardown never races its delivery. await anyio.wait_all_tasks_blocked() result = await client.call_tool("echo", {}) - assert result == snapshot(CallToolResult(content=[TextContent(text="ok")])) + assert unstamped(result) == snapshot(CallToolResult(content=[TextContent(text="ok")])) @requirement("protocol:cancel:abort-scoped") @@ -464,4 +465,6 @@ async def survivor_call() -> None: # dropped while the client is still open, so teardown never races its delivery. await anyio.wait_all_tasks_blocked() - assert results == snapshot([CallToolResult(content=[TextContent(text="survived")])]) + assert [unstamped(result) for result in results] == snapshot( + [CallToolResult(content=[TextContent(text="survived")])] + ) diff --git a/tests/interaction/lowlevel/test_completion.py b/tests/interaction/lowlevel/test_completion.py index d75865a2f0..4705154bd1 100644 --- a/tests/interaction/lowlevel/test_completion.py +++ b/tests/interaction/lowlevel/test_completion.py @@ -15,6 +15,7 @@ from mcp import MCPError from mcp.server import Server, ServerRequestContext +from tests._stamp import unstamped from tests.interaction._connect import Connect from tests.interaction._requirements import requirement @@ -44,7 +45,7 @@ async def completion(ctx: ServerRequestContext, params: types.CompleteRequestPar PromptReference(name="code_review"), argument={"name": "language", "value": "py"} ) - assert result == snapshot( + assert unstamped(result) == snapshot( CompleteResult(completion=Completion(values=["python", "pytorch"], total=2, has_more=False)) ) @@ -67,7 +68,7 @@ async def completion(ctx: ServerRequestContext, params: types.CompleteRequestPar argument={"name": "owner", "value": "model"}, ) - assert result == snapshot(CompleteResult(completion=Completion(values=["modelcontextprotocol"]))) + assert unstamped(result) == snapshot(CompleteResult(completion=Completion(values=["modelcontextprotocol"]))) @requirement("completion:context-arguments") @@ -92,7 +93,9 @@ async def completion(ctx: ServerRequestContext, params: types.CompleteRequestPar context_arguments={"owner": "modelcontextprotocol"}, ) - assert result == snapshot(CompleteResult(completion=Completion(values=["modelcontextprotocol/python-sdk"]))) + assert unstamped(result) == snapshot( + CompleteResult(completion=Completion(values=["modelcontextprotocol/python-sdk"])) + ) @requirement("completion:error:invalid-ref") diff --git a/tests/interaction/lowlevel/test_flows.py b/tests/interaction/lowlevel/test_flows.py index 19788db4a1..e29e1a1430 100644 --- a/tests/interaction/lowlevel/test_flows.py +++ b/tests/interaction/lowlevel/test_flows.py @@ -32,6 +32,7 @@ from mcp.client import ClientRequestContext from mcp.server import Server, ServerRequestContext from mcp.server.session import ServerSession +from tests._stamp import unstamped from tests.interaction._connect import Connect from tests.interaction._helpers import IncomingMessage from tests.interaction._requirements import requirement @@ -78,8 +79,10 @@ async def read_resource(ctx: ServerRequestContext, params: types.ReadResourceReq assert isinstance(link, ResourceLink) read = await client.read_resource(link.uri) - assert called == snapshot(CallToolResult(content=[ResourceLink(name="report", uri="file:///report.txt")])) - assert read == snapshot( + assert unstamped(called) == snapshot( + CallToolResult(content=[ResourceLink(name="report", uri="file:///report.txt")]) + ) + assert unstamped(read) == snapshot( ReadResourceResult(contents=[TextResourceContents(uri="file:///report.txt", text="generated")]) ) diff --git a/tests/interaction/lowlevel/test_logging.py b/tests/interaction/lowlevel/test_logging.py index bfc86509d2..571526af44 100644 --- a/tests/interaction/lowlevel/test_logging.py +++ b/tests/interaction/lowlevel/test_logging.py @@ -11,6 +11,7 @@ from mcp_types import CallToolResult, EmptyResult, LoggingMessageNotificationParams, TextContent from mcp.server import Server, ServerRequestContext +from tests._stamp import unstamped from tests.interaction._connect import Connect from tests.interaction._requirements import requirement @@ -83,7 +84,7 @@ async def set_logging_level(ctx: ServerRequestContext, params: types.SetLevelReq async with connect(server, logging_callback=collect) as client: result = await client.call_tool("chatty", {}) - assert result == snapshot(CallToolResult(content=[TextContent(text="done")])) + assert unstamped(result) == snapshot(CallToolResult(content=[TextContent(text="done")])) assert received == snapshot( [ LoggingMessageNotificationParams(level="info", logger="app.lifecycle", data="starting up"), diff --git a/tests/interaction/lowlevel/test_meta.py b/tests/interaction/lowlevel/test_meta.py index 27cf25e30c..3a549a5c2a 100644 --- a/tests/interaction/lowlevel/test_meta.py +++ b/tests/interaction/lowlevel/test_meta.py @@ -2,7 +2,8 @@ Meta is opaque pass-through data, so these tests assert identity against the value that was sent rather than snapshotting a literal: the expected value and the sent value are the same variable, -which also proves the SDK injected nothing alongside it. +which also proves the SDK injected nothing alongside it beyond the 2026-era serverInfo stamp, +stripped via `unstamped` before comparison. """ import mcp_types as types @@ -10,6 +11,7 @@ from mcp_types import CallToolResult, RequestParamsMeta, TextContent from mcp.server import Server, ServerRequestContext +from tests._stamp import unstamped from tests.interaction._connect import Connect from tests.interaction._requirements import requirement @@ -60,4 +62,4 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara async with connect(server) as client: result = await client.call_tool("metered", {}) - assert result == CallToolResult(content=[TextContent(text="done")], _meta=result_meta) + assert unstamped(result) == CallToolResult(content=[TextContent(text="done")], _meta=result_meta) diff --git a/tests/interaction/lowlevel/test_pagination.py b/tests/interaction/lowlevel/test_pagination.py index 01bc0a99bd..2dcb380c65 100644 --- a/tests/interaction/lowlevel/test_pagination.py +++ b/tests/interaction/lowlevel/test_pagination.py @@ -22,6 +22,7 @@ from mcp import MCPError from mcp.server import Server, ServerRequestContext +from tests._stamp import unstamped from tests.interaction._connect import Connect from tests.interaction._requirements import requirement @@ -55,7 +56,9 @@ async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestPa assert first_page.next_cursor == cursor assert seen_cursors == [None, cursor] assert [tool.name for tool in first_page.tools] == ["alpha"] - assert second_page == snapshot(ListToolsResult(tools=[Tool(name="beta", input_schema={"type": "object"})])) + assert unstamped(second_page) == snapshot( + ListToolsResult(tools=[Tool(name="beta", input_schema={"type": "object"})]) + ) @requirement("pagination:exhaustion") diff --git a/tests/interaction/lowlevel/test_progress.py b/tests/interaction/lowlevel/test_progress.py index 7f75e18eeb..c958432525 100644 --- a/tests/interaction/lowlevel/test_progress.py +++ b/tests/interaction/lowlevel/test_progress.py @@ -18,6 +18,7 @@ from mcp.server import Server, ServerRequestContext from mcp.server.session import ServerSession from mcp.shared.session import ProgressFnT +from tests._stamp import unstamped from tests.interaction._connect import Connect from tests.interaction._helpers import IncomingMessage from tests.interaction._requirements import requirement @@ -51,7 +52,7 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara async with connect(server) as client: result = await client.call_tool("download", {}, progress_callback=collect) - assert result == snapshot(CallToolResult(content=[TextContent(text="downloaded")])) + assert unstamped(result) == snapshot(CallToolResult(content=[TextContent(text="downloaded")])) assert received == snapshot([(1.0, 3.0, "first chunk"), (2.0, 3.0, "second chunk"), (3.0, 3.0, "done")]) @@ -105,7 +106,7 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara async with connect(server) as client: result = await client.call_tool("inspect", {}) - assert result == snapshot(CallToolResult(content=[TextContent(text="None")])) + assert unstamped(result) == snapshot(CallToolResult(content=[TextContent(text="None")])) @requirement("protocol:progress:client-to-server") diff --git a/tests/interaction/lowlevel/test_prompts.py b/tests/interaction/lowlevel/test_prompts.py index eb19d4d60d..775f0b44e0 100644 --- a/tests/interaction/lowlevel/test_prompts.py +++ b/tests/interaction/lowlevel/test_prompts.py @@ -21,6 +21,7 @@ from mcp import MCPError from mcp.server import Server, ServerRequestContext +from tests._stamp import unstamped from tests.interaction._connect import Connect from tests.interaction._requirements import requirement @@ -52,7 +53,7 @@ async def list_prompts(ctx: ServerRequestContext, params: types.PaginatedRequest async with connect(server) as client: result = await client.list_prompts() - assert result == snapshot( + assert unstamped(result) == snapshot( ListPromptsResult( prompts=[ Prompt( @@ -87,7 +88,7 @@ async def get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestPa async with connect(server) as client: result = await client.get_prompt("greet", {"name": "Ada"}) - assert result == snapshot( + assert unstamped(result) == snapshot( GetPromptResult( description="A personalised greeting.", messages=[PromptMessage(role="user", content=TextContent(text="Hello, Ada!"))], @@ -114,7 +115,7 @@ async def get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestPa async with connect(server) as client: result = await client.get_prompt("geography_quiz") - assert result == snapshot( + assert unstamped(result) == snapshot( GetPromptResult( messages=[ PromptMessage(role="user", content=TextContent(text="What is the capital of France?")), @@ -139,7 +140,7 @@ async def get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestPa async with connect(server) as client: result = await client.get_prompt("static") - assert result == snapshot( + assert unstamped(result) == snapshot( GetPromptResult(messages=[PromptMessage(role="user", content=TextContent(text="Say hello."))]) ) @@ -175,7 +176,7 @@ async def get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestPa async with connect(server) as client: result = await client.get_prompt("media", {}) - assert result == snapshot( + assert unstamped(result) == snapshot( GetPromptResult( messages=[ PromptMessage(role="user", content=ImageContent(data="aW1n", mime_type="image/png")), diff --git a/tests/interaction/lowlevel/test_resources.py b/tests/interaction/lowlevel/test_resources.py index db7d4dfe60..ab64a41d7b 100644 --- a/tests/interaction/lowlevel/test_resources.py +++ b/tests/interaction/lowlevel/test_resources.py @@ -27,6 +27,7 @@ from mcp import MCPError from mcp.server import Server, ServerRequestContext +from tests._stamp import unstamped from tests.interaction._connect import Connect from tests.interaction._helpers import IncomingMessage from tests.interaction._requirements import requirement @@ -71,7 +72,7 @@ async def list_resources( async with connect(server) as client: result = await client.list_resources() - assert result == snapshot( + assert unstamped(result) == snapshot( ListResourcesResult( resources=[ Resource(uri="memo://minimal", name="minimal"), @@ -106,7 +107,7 @@ async def read_resource(ctx: ServerRequestContext, params: types.ReadResourceReq async with connect(server) as client: result = await client.read_resource("file:///greeting.txt") - assert result == snapshot( + assert unstamped(result) == snapshot( ReadResourceResult( contents=[TextResourceContents(uri="file:///greeting.txt", mime_type="text/plain", text="Hello, world!")] ) @@ -133,7 +134,7 @@ async def read_resource(ctx: ServerRequestContext, params: types.ReadResourceReq async with connect(server) as client: result = await client.read_resource("file:///pixel.png") - assert result == snapshot( + assert unstamped(result) == snapshot( ReadResourceResult( contents=[BlobResourceContents(uri="file:///pixel.png", mime_type="image/png", blob="iVBORw==")] ) @@ -186,7 +187,7 @@ async def list_resource_templates( async with connect(server) as client: result = await client.list_resource_templates() - assert result == snapshot( + assert unstamped(result) == snapshot( ListResourceTemplatesResult( resource_templates=[ ResourceTemplate(uri_template="users://{user_id}", name="user"), diff --git a/tests/interaction/lowlevel/test_tools.py b/tests/interaction/lowlevel/test_tools.py index 861dd75e44..be56f89813 100644 --- a/tests/interaction/lowlevel/test_tools.py +++ b/tests/interaction/lowlevel/test_tools.py @@ -22,6 +22,7 @@ from mcp import MCPError from mcp.server import Server, ServerRequestContext +from tests._stamp import unstamped from tests.interaction._connect import Connect from tests.interaction._requirements import requirement @@ -49,7 +50,7 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara async with connect(server) as client: result = await client.call_tool("add", {"a": 2, "b": 3}) - assert result == snapshot(CallToolResult(content=[TextContent(text="5")])) + assert unstamped(result) == snapshot(CallToolResult(content=[TextContent(text="5")])) @requirement("tools:call:is-error") @@ -69,7 +70,7 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara async with connect(server) as client: result = await client.call_tool("flux", {}) - assert result == snapshot( + assert unstamped(result) == snapshot( CallToolResult(content=[TextContent(text="the flux capacitor is offline")], is_error=True) ) @@ -141,7 +142,7 @@ async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestPa async with connect(server) as client: result = await client.list_tools() - assert result == snapshot( + assert unstamped(result) == snapshot( ListToolsResult( tools=[ Tool( @@ -202,7 +203,7 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara called = await client.call_tool("typed", {"count": 3, "options": {"verbose": True}}) assert listed.tools[0].input_schema == schema - assert called == snapshot(CallToolResult(content=[TextContent(text="ok")])) + assert unstamped(called) == snapshot(CallToolResult(content=[TextContent(text="ok")])) @requirement("tools:list:metadata") @@ -228,7 +229,7 @@ async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestPa async with connect(server) as client: result = await client.list_tools() - assert result == snapshot( + assert unstamped(result) == snapshot( ListToolsResult( tools=[ Tool( @@ -280,7 +281,7 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara async with connect(server) as client: result = await client.call_tool("render", {}) - assert result == snapshot( + assert unstamped(result) == snapshot( CallToolResult( content=[ TextContent(text="all five content block types"), @@ -311,7 +312,9 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara async with connect(server) as client: result = await client.call_tool("sum", {}) - assert result == snapshot(CallToolResult(content=[TextContent(text="the sum is 5")], structured_content={"sum": 5})) + assert unstamped(result) == snapshot( + CallToolResult(content=[TextContent(text="the sum is 5")], structured_content={"sum": 5}) + ) @requirement("tools:call:concurrent") @@ -347,7 +350,7 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara async with anyio.create_task_group() as task_group: # pragma: no branch async def call_and_record(tag: str) -> None: - results[tag] = await client.call_tool("echo", {"tag": tag}) + results[tag] = unstamped(await client.call_tool("echo", {"tag": tag})) task_group.start_soon(call_and_record, "first") task_group.start_soon(call_and_record, "second") @@ -437,7 +440,7 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara await client.list_tools() result = await client.call_tool("forecast", {}) - assert result == snapshot( + assert unstamped(result) == snapshot( CallToolResult(content=[TextContent(text="boom")], structured_content={"temperature": "warm"}, is_error=True) ) @@ -509,5 +512,7 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara second = await client.call_tool("forecast", {}) assert list_calls == ["called"] - assert first == snapshot(CallToolResult(content=[TextContent(text="21 C")], structured_content={"temperature": 21})) - assert second == first + assert unstamped(first) == snapshot( + CallToolResult(content=[TextContent(text="21 C")], structured_content={"temperature": 21}) + ) + assert unstamped(second) == first diff --git a/tests/interaction/mcpserver/test_context.py b/tests/interaction/mcpserver/test_context.py index 27c0c70cc1..24fc344ebf 100644 --- a/tests/interaction/mcpserver/test_context.py +++ b/tests/interaction/mcpserver/test_context.py @@ -20,6 +20,7 @@ from mcp.client import ClientRequestContext from mcp.server.elicitation import AcceptedElicitation from mcp.server.mcpserver import Context, MCPServer +from tests._stamp import unstamped from tests.interaction._connect import Connect from tests.interaction._helpers import IncomingMessage from tests.interaction._requirements import requirement @@ -88,7 +89,7 @@ async def on_progress(progress: float, total: float | None, message: str | None) async with connect(mcp) as client: result = await client.call_tool("crunch", {}, progress_callback=on_progress) - assert result == snapshot( + assert unstamped(result) == snapshot( CallToolResult(content=[TextContent(text="crunched")], structured_content={"result": "crunched"}) ) assert received == snapshot([(1.0, 3.0, None), (2.0, 3.0, "halfway there")]) @@ -145,7 +146,7 @@ async def collect(message: IncomingMessage) -> None: async with connect(mcp, message_handler=collect) as client: result = await client.call_tool("mill", {}) - assert result == snapshot( + assert unstamped(result) == snapshot( CallToolResult(content=[TextContent(text="milled")], structured_content={"result": "milled"}) ) assert received == snapshot( @@ -228,7 +229,7 @@ async def show_config(ctx: Context) -> str: async with connect(mcp) as client: result = await client.call_tool("show_config", {}) - assert result == snapshot( + assert unstamped(result) == snapshot( CallToolResult( content=[TextContent(text="text/plain: 'theme = dark'")], structured_content={"result": "text/plain: 'theme = dark'"}, diff --git a/tests/interaction/mcpserver/test_extensions.py b/tests/interaction/mcpserver/test_extensions.py index 205a7fd6ea..2284ce73b8 100644 --- a/tests/interaction/mcpserver/test_extensions.py +++ b/tests/interaction/mcpserver/test_extensions.py @@ -15,6 +15,7 @@ from mcp.server.context import CallNext, HandlerResult, ServerRequestContext from mcp.server.extension import Extension from mcp.server.mcpserver import Context, MCPServer, require_client_extension +from tests._stamp import unstamped from tests.interaction._connect import Connect from tests.interaction._requirements import requirement @@ -92,7 +93,7 @@ async def redeem_receipt(claimed: ReceiptResult, ctx: ClaimContext) -> CallToolR result = await client.call_tool("buy", {"item": "lamp"}) assert [claimed.receipt_token for claimed in received] == ["r-117"] - assert result == snapshot( + assert unstamped(result) == snapshot( CallToolResult(content=[TextContent(text="goods for r-117")], structured_content={"result": "goods for r-117"}) ) diff --git a/tests/interaction/mcpserver/test_prompts.py b/tests/interaction/mcpserver/test_prompts.py index 58c8b48c7f..2084cbdb34 100644 --- a/tests/interaction/mcpserver/test_prompts.py +++ b/tests/interaction/mcpserver/test_prompts.py @@ -14,6 +14,7 @@ from mcp import MCPError from mcp.server.mcpserver import MCPServer +from tests._stamp import unstamped from tests.interaction._connect import Connect from tests.interaction._requirements import requirement @@ -36,7 +37,7 @@ def code_review(code: str, style_guide: str = "pep8") -> str: async with connect(mcp) as client: result = await client.list_prompts() - assert result == snapshot( + assert unstamped(result) == snapshot( ListPromptsResult( prompts=[ Prompt( @@ -65,7 +66,7 @@ def greet(name: str) -> str: async with connect(mcp) as client: result = await client.get_prompt("greet", {"name": "Ada"}) - assert result == snapshot( + assert unstamped(result) == snapshot( GetPromptResult( description="A personalised greeting.", messages=[PromptMessage(role="user", content=TextContent(text="Say hello to Ada."))], @@ -153,7 +154,7 @@ def review(code: str, style: str = "pep8") -> str: async with connect(mcp) as client: result = await client.get_prompt("review", {"code": "x = 1"}) - assert result == snapshot( + assert unstamped(result) == snapshot( GetPromptResult( description="Review a snippet of code against a style guide.", messages=[PromptMessage(role="user", content=TextContent(text="Review x = 1 per pep8."))], @@ -187,7 +188,7 @@ def greet_second() -> str: result = await client.get_prompt("greet") assert [prompt.name for prompt in listed.prompts] == ["greet"] - assert result == snapshot( + assert unstamped(result) == snapshot( GetPromptResult( description="The first registration; this is the one that wins.", messages=[PromptMessage(role="user", content=TextContent(text="first"))], diff --git a/tests/interaction/mcpserver/test_resources.py b/tests/interaction/mcpserver/test_resources.py index d7fd996051..0a0fd34bd8 100644 --- a/tests/interaction/mcpserver/test_resources.py +++ b/tests/interaction/mcpserver/test_resources.py @@ -14,6 +14,7 @@ from mcp import MCPError from mcp.server.mcpserver import MCPServer +from tests._stamp import unstamped from tests.interaction._connect import Connect from tests.interaction._requirements import requirement @@ -33,7 +34,7 @@ def app_config() -> str: async with connect(mcp) as client: result = await client.read_resource("config://app") - assert result == snapshot( + assert unstamped(result) == snapshot( ReadResourceResult( contents=[TextResourceContents(uri="config://app", mime_type="text/plain", text="theme = dark")] ) @@ -63,7 +64,7 @@ def user_profile(user_id: str) -> str: resources = await client.list_resources() templates = await client.list_resource_templates() - assert resources == snapshot( + assert unstamped(resources) == snapshot( ListResourcesResult( resources=[ Resource( @@ -75,7 +76,7 @@ def user_profile(user_id: str) -> str: ] ) ) - assert templates == snapshot( + assert unstamped(templates) == snapshot( ListResourceTemplatesResult( resource_templates=[ ResourceTemplate( @@ -103,7 +104,7 @@ def user_profile(user_id: str) -> str: async with connect(mcp) as client: result = await client.read_resource("users://42/profile") - assert result == snapshot( + assert unstamped(result) == snapshot( ReadResourceResult( contents=[TextResourceContents(uri="users://42/profile", mime_type="text/plain", text="profile for 42")] ) @@ -178,6 +179,6 @@ def config_second() -> str: assert [resource.uri for resource in listed.resources] == ["config://app"] assert listed.resources[0].name == "config_first" - assert result == snapshot( + assert unstamped(result) == snapshot( ReadResourceResult(contents=[TextResourceContents(uri="config://app", mime_type="text/plain", text="first")]) ) diff --git a/tests/interaction/mcpserver/test_tools.py b/tests/interaction/mcpserver/test_tools.py index a7791d7cda..5c59ad9117 100644 --- a/tests/interaction/mcpserver/test_tools.py +++ b/tests/interaction/mcpserver/test_tools.py @@ -20,6 +20,7 @@ from mcp.server.mcpserver import Context, MCPServer from mcp.server.mcpserver.exceptions import ToolError from mcp.shared.exceptions import UrlElicitationRequiredError +from tests._stamp import unstamped from tests.interaction._connect import Connect from tests.interaction._helpers import IncomingMessage from tests.interaction._requirements import requirement @@ -43,7 +44,9 @@ def add(a: int, b: int) -> str: async with connect(mcp) as client: result = await client.call_tool("add", {"a": 2, "b": 3}) - assert result == snapshot(CallToolResult(content=[TextContent(text="5")], structured_content={"result": "5"})) + assert unstamped(result) == snapshot( + CallToolResult(content=[TextContent(text="5")], structured_content={"result": "5"}) + ) @requirement("mcpserver:tool:schema-variants") @@ -67,7 +70,7 @@ def place(mode: Literal["fast", "slow"], point: Point, count: Annotated[int, Fie async with connect(mcp) as client: result = await client.call_tool("place", {"mode": "fast", "point": {"x": "3", "y": 4}, "count": 5}) - assert result == snapshot( + assert unstamped(result) == snapshot( CallToolResult( content=[TextContent(text="fast at (3, 4) x5")], structured_content={"result": "fast at (3, 4) x5"} ) @@ -92,7 +95,7 @@ def explode() -> str: async with connect(mcp) as client: result = await client.call_tool("explode", {}) - assert result == snapshot( + assert unstamped(result) == snapshot( CallToolResult(content=[TextContent(text="Error executing tool explode: boom")], is_error=True) ) @@ -109,7 +112,7 @@ def flux() -> str: async with connect(mcp) as client: result = await client.call_tool("flux", {}) - assert result == snapshot( + assert unstamped(result) == snapshot( CallToolResult(content=[TextContent(text="Error executing tool flux: flux capacitor offline")], is_error=True) ) @@ -130,7 +133,9 @@ def add() -> None: async with connect(mcp) as client: result = await client.call_tool("nope", {}) - assert result == snapshot(CallToolResult(content=[TextContent(text="Unknown tool: nope")], is_error=True)) + assert unstamped(result) == snapshot( + CallToolResult(content=[TextContent(text="Unknown tool: nope")], is_error=True) + ) @requirement("mcpserver:tool:output-schema:model") @@ -164,7 +169,7 @@ def get_weather() -> Weather: "type": "object", } ) - assert result == snapshot( + assert unstamped(result) == snapshot( CallToolResult( content=[ TextContent( @@ -204,7 +209,7 @@ def primes() -> list[int]: "type": "object", } ) - assert result == snapshot( + assert unstamped(result) == snapshot( CallToolResult( content=[TextContent(text="2"), TextContent(text="3"), TextContent(text="5")], structured_content={"result": [2, 3, 5]}, @@ -304,7 +309,7 @@ def echo_second() -> str: result = await client.call_tool("echo", {}) assert [tool.name for tool in listed.tools] == ["echo"] - assert result == snapshot( + assert unstamped(result) == snapshot( CallToolResult(content=[TextContent(text="first")], structured_content={"result": "first"}) ) @@ -340,7 +345,9 @@ def bad() -> str: result = await client.call_tool("bad name!", {}) assert [tool.name for tool in listed.tools] == ["bad name!"] - assert result == snapshot(CallToolResult(content=[TextContent(text="ok")], structured_content={"result": "ok"})) + assert unstamped(result) == snapshot( + CallToolResult(content=[TextContent(text="ok")], structured_content={"result": "ok"}) + ) @requirement("mcpserver:tool:url-elicitation-error") diff --git a/tests/server/mcpserver/test_extension.py b/tests/server/mcpserver/test_extension.py index ea00ecb18e..e5561a7f98 100644 --- a/tests/server/mcpserver/test_extension.py +++ b/tests/server/mcpserver/test_extension.py @@ -5,6 +5,7 @@ the highest-level public surface (in-memory `Client`). """ +from dataclasses import replace from importlib.metadata import version from typing import Any, Literal @@ -31,6 +32,7 @@ from mcp.server.mcpserver import Context, MCPServer, require_client_extension from mcp.server.mcpserver.resources import TextResource from mcp.shared.exceptions import MCPError +from tests._stamp import unstamped pytestmark = pytest.mark.anyio @@ -141,7 +143,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()], include_server_info=False) + server = MCPServer("test", extensions=[_AdditiveExt()]) async with Client(server) as client: tools = await client.list_tools() @@ -150,8 +152,10 @@ async def test_additive_extension_registers_its_tool_and_resource() -> None: assert [t.name for t in tools.tools] == ["ping"] assert tools.tools[0].meta == _TOOL_META - assert called == snapshot(CallToolResult(content=[TextContent(text="pong")], structured_content={"result": "pong"})) - assert resources == snapshot( + assert unstamped(called) == snapshot( + CallToolResult(content=[TextContent(text="pong")], structured_content={"result": "pong"}) + ) + assert unstamped(resources) == snapshot( types.ListResourcesResult( resources=[types.Resource(name="greeting", uri="ext://greeting", mime_type="text/plain")] ) @@ -189,37 +193,39 @@ 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()], include_server_info=False) + server = MCPServer("test", extensions=[_MethodExt()]) async with Client(server) as client: request = _PingRequest(params=_PingParams()) result = await client.session.send_request(request, _PingResult) - assert result == snapshot(_PingResult(pong=True)) + assert unstamped(result) == snapshot(_PingResult(pong=True)) 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()], include_server_info=False) + server = MCPServer("test", extensions=[_PassThroughExt()]) server.tool(name="echo")(_echo) async with Client(server) as client: result = await client.call_tool("echo", {"value": "hi"}) - assert result == snapshot(CallToolResult(content=[TextContent(text="hi")], structured_content={"result": "hi"})) + assert unstamped(result) == snapshot( + CallToolResult(content=[TextContent(text="hi")], structured_content={"result": "hi"}) + ) 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()], include_server_info=False) + server = MCPServer("test", extensions=[_ReplacingExt()]) server.tool(name="echo", structured_output=False)(_echo) async with Client(server) as client: result = await client.call_tool("echo", {"value": "hi"}) - assert result == snapshot(CallToolResult(content=[TextContent(text="intercepted")])) + assert unstamped(result) == snapshot(CallToolResult(content=[TextContent(text="intercepted")])) def test_plain_extension_leaves_the_tool_call_handler_bare() -> None: @@ -250,13 +256,15 @@ 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()], include_server_info=False) + server = MCPServer("test", extensions=[_DefaultExt(), _PassThroughExt()]) server.tool(name="echo")(_echo) async with Client(server) as client: result = await client.call_tool("echo", {"value": "hi"}) - assert result == snapshot(CallToolResult(content=[TextContent(text="hi")], structured_content={"result": "hi"})) + assert unstamped(result) == snapshot( + CallToolResult(content=[TextContent(text="hi")], structured_content={"result": "hi"}) + ) async def test_interceptors_run_in_registration_order_with_threaded_params() -> None: @@ -276,6 +284,32 @@ async def test_interceptors_run_in_registration_order_with_threaded_params() -> assert log == [("com.example/first", "echo"), ("com.example/second", "echo")] +async def test_an_interceptor_context_rewrite_does_not_change_the_tool_invocation() -> None: + """SDK-defined: the validated `params` argument is authoritative - an + interceptor passing a rewritten context through `call_next` adjusts what + the handler observes on `ctx`, not which tool call runs. Wire-level + request rewriting belongs to `Server.middleware`, above params validation.""" + + class _RewritingExt(Extension): + identifier = "com.example/rewriting" + + async def intercept_tool_call( + self, params: types.CallToolRequestParams, ctx: ServerRequestContext[Any, Any], call_next: CallNext + ) -> HandlerResult: + rewritten = {**(ctx.params or {}), "arguments": {"value": "rewritten"}} + return await call_next(replace(ctx, params=rewritten)) + + server = MCPServer("test", extensions=[_RewritingExt()]) + server.tool(name="echo")(_echo) + + async with Client(server) as client: + result = await client.call_tool("echo", {"value": "original"}) + + assert unstamped(result) == snapshot( + CallToolResult(content=[TextContent(text="original")], structured_content={"result": "original"}) + ) + + async def test_short_circuited_interceptor_result_carries_the_server_info_stamp() -> None: """Spec-mandated (2026-07-28, #3002): an interceptor that answers without running the tool still produces a stamped result - interception happens at the handler @@ -336,13 +370,13 @@ 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()], include_server_info=False) + server = MCPServer("test", extensions=[_VersionPinnedExt()]) async with Client(server, mode="2026-07-28") as client: request = _VersionPinnedRequest(params=_VersionPinnedParams()) result = await client.session.send_request(request, _VersionPinnedResult) - assert result == snapshot(_VersionPinnedResult(ok=True)) + assert unstamped(result) == snapshot(_VersionPinnedResult(ok=True)) async def test_version_pinned_method_is_method_not_found_at_a_disallowed_version() -> None: @@ -417,12 +451,14 @@ 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()], include_server_info=False) + server = MCPServer("test", extensions=[_RequiresExt()]) async with Client(server, extensions=[advertise(_NEEDS_EXT)]) as client: result = await client.call_tool("guarded", {}) - assert result == snapshot(CallToolResult(content=[TextContent(text="ok")], structured_content={"result": "ok"})) + assert unstamped(result) == snapshot( + CallToolResult(content=[TextContent(text="ok")], structured_content={"result": "ok"}) + ) async def test_require_client_extension_raises_minus_32021_when_client_did_not_declare_it() -> None: diff --git a/tests/server/mcpserver/test_server.py b/tests/server/mcpserver/test_server.py index 96a7fc531f..09097c03c7 100644 --- a/tests/server/mcpserver/test_server.py +++ b/tests/server/mcpserver/test_server.py @@ -62,6 +62,7 @@ from mcp.server.transport_security import TransportSecuritySettings from mcp.shared.exceptions import MCPError from mcp.shared.uri_template import InvalidUriTemplate +from tests._stamp import unstamped pytestmark = pytest.mark.anyio @@ -979,7 +980,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(include_server_info=False) + mcp = MCPServer() @mcp.resource("resource://{user}/csv", mime_type="text/csv") def get_csv(user: str) -> str: @@ -996,7 +997,7 @@ def get_csv(user: str) -> str: async with Client(mcp) as client: result = await client.read_resource("resource://bob/csv") - assert result == snapshot( + assert unstamped(result) == snapshot( ReadResourceResult( contents=[TextResourceContents(uri="resource://bob/csv", mime_type="text/csv", text="csv for bob")] ) @@ -1055,7 +1056,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(include_server_info=False) + mcp = MCPServer() @mcp.resource("resource://data", meta={"version": "1.0", "category": "config"}) def get_data() -> str: @@ -1063,7 +1064,7 @@ def get_data() -> str: async with Client(mcp) as client: result = await client.read_resource("resource://data") - assert result == snapshot( + assert unstamped(result) == snapshot( ReadResourceResult( contents=[ TextResourceContents( @@ -1217,7 +1218,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(include_server_info=False) + mcp = MCPServer() @mcp.resource("resource://nocontext/{name}") def resource_no_context(name: str) -> str: @@ -1232,7 +1233,7 @@ def resource_no_context(name: str) -> str: async with Client(mcp) as client: result = await client.read_resource("resource://nocontext/test") - assert result == snapshot( + assert unstamped(result) == snapshot( ReadResourceResult( contents=[ TextResourceContents( @@ -1244,7 +1245,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(include_server_info=False) + mcp = MCPServer() @mcp.resource("resource://custom/{id}") def resource_custom_ctx(id: str, my_ctx: Context) -> str: @@ -1260,7 +1261,7 @@ def resource_custom_ctx(id: str, my_ctx: Context) -> str: async with Client(mcp) as client: result = await client.read_resource("resource://custom/123") - assert result == snapshot( + assert unstamped(result) == snapshot( ReadResourceResult( contents=[ TextResourceContents( @@ -1385,14 +1386,14 @@ def fn() -> str: ... # pragma: no branch async def test_list_prompts(self): """Test listing prompts through MCP protocol.""" - mcp = MCPServer(include_server_info=False) + mcp = MCPServer() @mcp.prompt() def fn(name: str, optional: str = "default") -> str: ... # pragma: no branch async with Client(mcp) as client: result = await client.list_prompts() - assert result == snapshot( + assert unstamped(result) == snapshot( ListPromptsResult( prompts=[ Prompt( @@ -1409,7 +1410,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(include_server_info=False) + mcp = MCPServer() @mcp.prompt() def fn(name: str) -> str: @@ -1417,7 +1418,7 @@ def fn(name: str) -> str: async with Client(mcp) as client: result = await client.get_prompt("fn", {"name": "World"}) - assert result == snapshot( + assert unstamped(result) == snapshot( GetPromptResult( description="", messages=[PromptMessage(role="user", content=TextContent(text="Hello, World!"))], @@ -1438,7 +1439,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(include_server_info=False) + mcp = MCPServer() @mcp.prompt() def fn(name: str) -> str: @@ -1447,7 +1448,7 @@ def fn(name: str) -> str: async with Client(mcp) as client: result = await client.get_prompt("fn", {"name": "World"}) - assert result == snapshot( + assert unstamped(result) == snapshot( GetPromptResult( description="This is the function docstring.", messages=[PromptMessage(role="user", content=TextContent(text="Hello, World!"))], @@ -1456,7 +1457,7 @@ def fn(name: str) -> str: async def test_get_prompt_with_resource(self): """Test getting a prompt that returns resource content.""" - mcp = MCPServer(include_server_info=False) + mcp = MCPServer() @mcp.prompt() def fn() -> Message: @@ -1469,7 +1470,7 @@ def fn() -> Message: async with Client(mcp) as client: result = await client.get_prompt("fn") - assert result == snapshot( + assert unstamped(result) == snapshot( GetPromptResult( description="", messages=[ diff --git a/tests/server/test_apps.py b/tests/server/test_apps.py index c1a0880070..b73a70debc 100644 --- a/tests/server/test_apps.py +++ b/tests/server/test_apps.py @@ -28,6 +28,7 @@ from mcp.server.mcpserver import MCPServer from mcp.server.mcpserver.context import Context from mcp.server.mcpserver.resources import TextResource +from tests._stamp import unstamped pytestmark = pytest.mark.anyio @@ -42,7 +43,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], include_server_info=False) + return MCPServer("clock", extensions=[apps]) async def test_apps_tool_stamps_ui_resource_uri_on_tool_meta() -> None: @@ -60,7 +61,7 @@ async def test_add_html_resource_serves_ui_resource_at_app_mime_type() -> None: `text/html;profile=mcp-app`, observed through `read_resource`.""" async with Client(_clock_server()) as client: result = await client.read_resource("ui://clock/app.html") - assert result == snapshot( + assert unstamped(result) == snapshot( ReadResourceResult( contents=[ TextResourceContents( diff --git a/tests/server/test_caching.py b/tests/server/test_caching.py index 0b52573650..fa15c08c07 100644 --- a/tests/server/test_caching.py +++ b/tests/server/test_caching.py @@ -18,6 +18,7 @@ from mcp import Client from mcp.server import CacheHint, MCPServer, Server, ServerRequestContext from mcp.server.caching import apply_cache_hint +from tests._stamp import unstamped pytestmark = pytest.mark.anyio @@ -133,7 +134,7 @@ async def list_tools(ctx: ServerRequestContext[Any], params: PaginatedRequestPar assert "cache_scope" not in result.model_fields_set -async def test_an_input_required_shaped_dict_is_never_stamped() -> None: +async def test_an_input_required_shaped_dict_never_gets_cache_hints() -> None: """Spec carve-out: interim `input_required` results carry no cache hints, even on a hinted method.""" async def read_resource(ctx: ServerRequestContext[Any], params: ReadResourceRequestParams) -> dict[str, Any]: @@ -142,13 +143,12 @@ async def read_resource(ctx: ServerRequestContext[Any], params: ReadResourceRequ 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) assert isinstance(result, InputRequiredResult) - assert result.model_dump(by_alias=True, exclude_none=True) == snapshot( + assert unstamped(result).model_dump(by_alias=True, exclude_none=True) == snapshot( {"resultType": "input_required", "requestState": "s1"} ) diff --git a/tests/server/test_runner.py b/tests/server/test_runner.py index b103b75ab0..359a05a007 100644 --- a/tests/server/test_runner.py +++ b/tests/server/test_runner.py @@ -31,6 +31,7 @@ ClientCapabilities, EmptyResult, ErrorData, + Icon, Implementation, InitializeRequestParams, JSONRPCRequest, @@ -1060,6 +1061,48 @@ async def custom(ctx: Ctx, params: RequestParams) -> dict[str, Any]: assert retained == {"ok": True} +@pytest.mark.anyio +async def test_mutating_a_stamped_response_never_corrupts_later_stamps(): + """SDK-defined: every response gets a fresh stamp dict, nested values + included - a caller mutating one stamped response (a middleware, or an + application holding an in-memory result) cannot corrupt the identity + stamped into later responses.""" + + async def custom(ctx: Ctx, params: RequestParams) -> dict[str, Any]: + return {} + + server: SrvT = Server(name="test-server", version="0.0.1", icons=[Icon(src="https://example.com/icon.png")]) + 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, _): + first = await client.send_raw_request("myorg/empty", None) + first["_meta"][SERVER_INFO_META_KEY]["icons"][0]["src"] = "https://evil.example/pwned.png" + second = await client.send_raw_request("myorg/empty", None) + assert second["_meta"][SERVER_INFO_META_KEY] == { + "name": "test-server", + "version": "0.0.1", + "icons": [{"src": "https://example.com/icon.png"}], + } + + +@pytest.mark.anyio +async def test_a_claimed_result_type_on_a_legacy_session_is_sieved_not_leaked(server: SrvT): + """SDK-defined: claimed extension shapes are 2026-era vocabulary, so on a + handshake-era session the per-version sieve still applies - a claimed + shape (not a valid legacy result) surfaces as INTERNAL_ERROR instead of + leaking a shape the client cannot resolve.""" + + async def custom(ctx: Ctx, params: CallToolRequestParams) -> dict[str, Any]: + return {"resultType": "voucher", "voucherCode": "v-42"} + + server.add_request_handler("tools/call", CallToolRequestParams, custom) + async with connected_runner(server) as (client, _): + with pytest.raises(MCPError) as exc: + await client.send_raw_request("tools/call", {"name": "issue"}) + assert exc.value.error.code == INTERNAL_ERROR + assert exc.value.error.message == "Handler returned an invalid result" + + @pytest.mark.anyio async def test_a_claimed_extension_result_type_bypasses_the_sieve_and_is_stamped(server: SrvT): """SDK-defined: a spec-method result carrying an extension `resultType` is a @@ -1830,13 +1873,13 @@ 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", include_server_info=False) + greeter = Server(name="greeter-server", version="0.0.1") greeter.add_request_handler("custom/greet", RequestParams, greet) params = _modern_params() params["_meta"][CLIENT_INFO_META_KEY] = "not-an-object" async with dual_era_client(greeter) as (client, _): result = await client.send_raw_request("custom/greet", params) - assert result == {"ok": True} + assert result == {"ok": True, "_meta": {SERVER_INFO_META_KEY: {"name": "greeter-server", "version": "0.0.1"}}} assert seen == [None] diff --git a/tests/server/test_streamable_http_modern.py b/tests/server/test_streamable_http_modern.py index ead7a20e90..477fc2b07b 100644 --- a/tests/server/test_streamable_http_modern.py +++ b/tests/server/test_streamable_http_modern.py @@ -205,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", include_server_info=False) + server: Server[Any] = Server("test") server.add_request_handler("custom/greet", PaginatedRequestParams, greet) body = _list_tools_body() @@ -214,7 +214,10 @@ async def greet(ctx: ServerRequestContext, params: PaginatedRequestParams) -> di async with _asgi_client(server) as http: response = await http.post("/mcp", json=body, headers={MCP_METHOD_HEADER: "custom/greet"}) assert response.status_code == 200 - assert response.json()["result"] == {"ok": True} + result = response.json()["result"] + stamp = result["_meta"].pop(SERVER_INFO_META_KEY) + assert "name" in stamp and "version" in stamp + assert result == {"_meta": {}, "ok": True} assert seen == [None] From d1258afd8de0ad3d74f86902b632d8b8d8651562 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:52:37 +0000 Subject: [PATCH 10/15] Keep migration.md scoped to v1.x breakage The server-identity section described 2026-07-28 behavior only: server/discover does not exist in v1.x, so no v1 code path reaches it. The migration guide covers exactly what breaks when v1.x code moves to v2; the identity stamp and the Implementation|None client surface are documented on the low-level server, middleware, and client pages instead. The era-neutral accessors section keeps its corrected server_info type without the removed cross-reference. No-Verification-Needed: docs-only edit --- docs/migration.md | 36 +----------------------------------- docs/whats-new.md | 2 +- 2 files changed, 2 insertions(+), 36 deletions(-) diff --git a/docs/migration.md b/docs/migration.md index b6aecb7d8c..e81510fedc 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -468,40 +468,6 @@ 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`, built from - the constructor identity fields (`name`, `version`, `title`, and so on). 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. A middleware that answers a request itself (without - `call_next`) owns its result envelope, stamp included; a server that must - not identify itself can strip the key the same way, with a middleware that - removes it from the results it passes along. -- `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`. @@ -1532,7 +1498,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` 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.) +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`. (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/whats-new.md b/docs/whats-new.md index fddaaa00bf..cc358098f5 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -197,7 +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 always stamps; `client.server_info` is `None` when a server does not identify itself (for example, a middleware stripped the key). The **[Migration Guide](migration.md#server-identity-moved-from-the-serverdiscover-result-body-to-result-_meta)** has the details. +* **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 always stamps; `client.server_info` is `None` when a server does not identify itself (for example, a middleware stripped the key). **[The low-level Server](advanced/low-level-server.md)** shows the stamp on the wire. * **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 9a0881fc76abd0ffa7044c3fc29659d5acb48b4b Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:11:14 +0000 Subject: [PATCH 11/15] Interaction matrix: era-aware unstamped fixture The shared strip helper was if-present, so a modern cell whose result lost its serverInfo stamp would still pass. The helper is now strict (stamp must be present and well-formed before it is stripped), and the interaction matrix resolves 'unstamped' as a fixture keyed on the cell's spec_version: modern cells assert-and-strip, handshake-era cells assert the result was never stamped. Every existing comparison site now enforces the stamp contract in both directions. The connect fixture returns a CellConnect callable that names its cell's spec_version, so sibling fixtures can key on the era without re-deriving it. No-Verification-Needed: test-only change --- tests/_stamp.py | 43 +++++++++----- tests/interaction/conftest.py | 58 +++++++++++++++---- .../interaction/lowlevel/test_cancellation.py | 8 +-- tests/interaction/lowlevel/test_completion.py | 8 +-- tests/interaction/lowlevel/test_flows.py | 6 +- tests/interaction/lowlevel/test_logging.py | 4 +- tests/interaction/lowlevel/test_meta.py | 4 +- tests/interaction/lowlevel/test_pagination.py | 4 +- tests/interaction/lowlevel/test_progress.py | 6 +- tests/interaction/lowlevel/test_prompts.py | 12 ++-- tests/interaction/lowlevel/test_resources.py | 10 ++-- tests/interaction/lowlevel/test_tools.py | 24 ++++---- tests/interaction/mcpserver/test_context.py | 8 +-- .../interaction/mcpserver/test_extensions.py | 6 +- tests/interaction/mcpserver/test_prompts.py | 14 +++-- tests/interaction/mcpserver/test_resources.py | 12 ++-- tests/interaction/mcpserver/test_tools.py | 24 ++++---- 17 files changed, 156 insertions(+), 95 deletions(-) diff --git a/tests/_stamp.py b/tests/_stamp.py index 8cf1c4cd49..20238ecd10 100644 --- a/tests/_stamp.py +++ b/tests/_stamp.py @@ -1,32 +1,43 @@ -"""Shared helper: drop the 2026-era serverInfo `_meta` stamp from a result. +"""Shared helper: strip the 2026-era serverInfo `_meta` stamp from a result. Servers stamp `io.modelcontextprotocol/serverInfo` into every 2026-era result's `_meta` and never into handshake-era ones, and the stamp's `version` -defaults to the installed package version. Suites that share one expected -payload across eras (the interaction matrix) or that construct servers -without a pinned version strip the stamp before exact comparison. Stamp -semantics themselves have dedicated coverage in tests/server/test_runner.py. +defaults to the installed package version. Suites whose expected payloads +should stay identity-free strip the stamp before exact comparison - and the +strip is strict, so a modern result that lost its stamp fails the test +instead of passing silently. + +The interaction matrix does not use this function directly: its `unstamped` +fixture (tests/interaction/conftest.py) resolves per cell to this strict +strip on modern cells and to a must-not-be-stamped assertion on +handshake-era cells, so one comparison line enforces both eras. """ -from typing import Any, TypeVar +from typing import Any, Protocol, TypeVar from mcp_types import SERVER_INFO_META_KEY, Result R = TypeVar("R", bound=Result) +class Unstamp(Protocol): + """An era-appropriate stamp normalizer: strips or forbids the stamp.""" + + def __call__(self, result: R) -> R: ... + + def unstamped(result: R) -> R: - """Remove the serverInfo stamp in place if present, asserting its shape. + """Assert the result carries a well-formed serverInfo stamp, then remove it. - Present-versus-absent is era-dependent and belongs to the runner tests; - this only guarantees that when a stamp exists it is a well-formed - identity object, then returns the result for inline use in comparisons. + Returns the result for inline use in comparisons. Use only where a stamp + is required (a 2026-era result); the interaction matrix's `unstamped` + fixture handles the era split. """ meta = result.meta - if meta is not None and SERVER_INFO_META_KEY in meta: - stamp: Any = meta.pop(SERVER_INFO_META_KEY) - assert isinstance(stamp, dict) - assert "name" in stamp and "version" in stamp - if not meta: - result.meta = None + assert meta is not None and SERVER_INFO_META_KEY in meta, "expected a serverInfo stamp on this result" + stamp: Any = meta.pop(SERVER_INFO_META_KEY) + assert isinstance(stamp, dict) + assert "name" in stamp and "version" in stamp + if not meta: + result.meta = None return result diff --git a/tests/interaction/conftest.py b/tests/interaction/conftest.py index 6252558bd8..a6188fe095 100644 --- a/tests/interaction/conftest.py +++ b/tests/interaction/conftest.py @@ -10,10 +10,14 @@ from typing import Any import pytest +from mcp_types import SERVER_INFO_META_KEY +from mcp_types.version import MODERN_PROTOCOL_VERSIONS from mcp.client.client import Client from mcp.server import Server from mcp.server.mcpserver import MCPServer +from tests._stamp import R, Unstamp +from tests._stamp import unstamped as _strip_required_stamp from tests.interaction._connect import ( Connect, connect_in_memory, @@ -39,19 +43,18 @@ def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: metafunc.parametrize("connect", compute_cells(requirements), indirect=True) -@pytest.fixture -def connect(request: pytest.FixtureRequest) -> Connect: - """The transport-parametrized connection factory: a test using it runs once per matrix cell. +class CellConnect: + """The cell's connection factory, also naming the cell's `spec_version`. - Tests that are tied to one transport (the wire-recording tests, the bare-ClientSession tests, - the transport-specific tests under transports/) do not use this fixture and connect directly. + Callable exactly like the `Connect` factories it wraps; the attribute lets + sibling fixtures (`unstamped`) key on the cell's era without re-deriving it. """ - transport, spec_version = request.param - assert isinstance(transport, str) - assert isinstance(spec_version, str) - factory = _FACTORIES[transport] - def _connect(server: Server | MCPServer, **kwargs: Any) -> AbstractAsyncContextManager[Client]: + def __init__(self, factory: Connect, spec_version: str) -> None: + self._factory = factory + self.spec_version = spec_version + + def __call__(self, server: Server | MCPServer, **kwargs: Any) -> AbstractAsyncContextManager[Client]: # The matrix compares exact result payloads, and the 2026-era serverInfo # `_meta` stamp carries the server version, which defaults to the # commit-dependent installed package version. Pin it so expected @@ -59,6 +62,37 @@ def _connect(server: Server | MCPServer, **kwargs: Any) -> AbstractAsyncContextM lowlevel = server._lowlevel_server if isinstance(server, MCPServer) else server if lowlevel.version is None: lowlevel.version = "1.0.0" - return factory(server, spec_version=spec_version, **kwargs) + return self._factory(server, spec_version=self.spec_version, **kwargs) + + +@pytest.fixture +def connect(request: pytest.FixtureRequest) -> CellConnect: + """The transport-parametrized connection factory: a test using it runs once per matrix cell. + + Tests that are tied to one transport (the wire-recording tests, the bare-ClientSession tests, + the transport-specific tests under transports/) do not use this fixture and connect directly. + """ + transport, spec_version = request.param + assert isinstance(transport, str) + assert isinstance(spec_version, str) + return CellConnect(_FACTORIES[transport], spec_version) + + +@pytest.fixture +def unstamped(connect: CellConnect) -> Unstamp: + """The cell's era-aware serverInfo-stamp normalizer, for full-result comparisons. + + On a modern cell the stamp MUST be present (asserted) and is stripped so + one expected payload stays valid across eras; on a handshake-era cell the + result must not be stamped at all. Either direction failing is a runner + regression, so the same comparison line enforces both. + """ + if connect.spec_version in MODERN_PROTOCOL_VERSIONS: + return _strip_required_stamp + + def _assert_never_stamped(result: R) -> R: + meta = result.meta + assert meta is None or SERVER_INFO_META_KEY not in meta, "handshake-era results are never stamped" + return result - return _connect + return _assert_never_stamped diff --git a/tests/interaction/lowlevel/test_cancellation.py b/tests/interaction/lowlevel/test_cancellation.py index 83b5749ffd..8361865db0 100644 --- a/tests/interaction/lowlevel/test_cancellation.py +++ b/tests/interaction/lowlevel/test_cancellation.py @@ -33,7 +33,7 @@ from mcp.server import Server, ServerRequestContext from mcp.shared.memory import MessageStream, create_client_server_memory_streams from mcp.shared.message import SessionMessage -from tests._stamp import unstamped +from tests._stamp import Unstamp from tests.interaction._connect import Connect from tests.interaction._helpers import IncomingMessage from tests.interaction._requirements import requirement @@ -138,7 +138,7 @@ async def call_and_swallow_cancellation_error() -> None: @requirement("protocol:cancel:unknown-id-ignored") -async def test_cancellation_for_unknown_request_is_ignored(connect: Connect) -> None: +async def test_cancellation_for_unknown_request_is_ignored(connect: Connect, unstamped: Unstamp) -> None: """A cancellation referencing a request id that is not in flight is ignored without error.""" async def list_tools( @@ -351,7 +351,7 @@ async def scripted_server(streams: MessageStream) -> None: @requirement("protocol:cancel:abort-signal") -async def test_abandoning_a_call_stops_the_server_handler(connect: Connect) -> None: +async def test_abandoning_a_call_stops_the_server_handler(connect: Connect, unstamped: Unstamp) -> None: """Cancelling the task that awaits a call cancels the request itself, not just the local wait: the server-side handler is interrupted, and the session serves later requests normally. @@ -402,7 +402,7 @@ async def call_and_abandon() -> None: @requirement("protocol:cancel:abort-scoped") -async def test_abandoning_one_call_leaves_a_concurrent_call_running(connect: Connect) -> None: +async def test_abandoning_one_call_leaves_a_concurrent_call_running(connect: Connect, unstamped: Unstamp) -> None: """Cancellation is scoped to the request it names: with two calls genuinely in flight, abandoning the first interrupts only its handler and the second returns its result. diff --git a/tests/interaction/lowlevel/test_completion.py b/tests/interaction/lowlevel/test_completion.py index 4705154bd1..1ed5542734 100644 --- a/tests/interaction/lowlevel/test_completion.py +++ b/tests/interaction/lowlevel/test_completion.py @@ -15,7 +15,7 @@ from mcp import MCPError from mcp.server import Server, ServerRequestContext -from tests._stamp import unstamped +from tests._stamp import Unstamp from tests.interaction._connect import Connect from tests.interaction._requirements import requirement @@ -24,7 +24,7 @@ @requirement("completion:prompt-arg") @requirement("completion:result-shape") -async def test_complete_prompt_argument(connect: Connect) -> None: +async def test_complete_prompt_argument(connect: Connect, unstamped: Unstamp) -> None: """Completing a prompt argument delivers the ref, argument name, and current value to the handler. The returned values are filtered by the argument's value, proving the value reached the handler. @@ -51,7 +51,7 @@ async def completion(ctx: ServerRequestContext, params: types.CompleteRequestPar @requirement("completion:resource-template-arg") -async def test_complete_resource_template_variable(connect: Connect) -> None: +async def test_complete_resource_template_variable(connect: Connect, unstamped: Unstamp) -> None: """Completing a URI template variable delivers the template URI and variable name to the handler.""" async def completion(ctx: ServerRequestContext, params: types.CompleteRequestParams) -> CompleteResult: @@ -72,7 +72,7 @@ async def completion(ctx: ServerRequestContext, params: types.CompleteRequestPar @requirement("completion:context-arguments") -async def test_complete_receives_context_arguments(connect: Connect) -> None: +async def test_complete_receives_context_arguments(connect: Connect, unstamped: Unstamp) -> None: """Previously-resolved arguments passed as completion context reach the handler. The returned value is derived from the context, proving it arrived. diff --git a/tests/interaction/lowlevel/test_flows.py b/tests/interaction/lowlevel/test_flows.py index e29e1a1430..9a6db1a45a 100644 --- a/tests/interaction/lowlevel/test_flows.py +++ b/tests/interaction/lowlevel/test_flows.py @@ -32,7 +32,7 @@ from mcp.client import ClientRequestContext from mcp.server import Server, ServerRequestContext from mcp.server.session import ServerSession -from tests._stamp import unstamped +from tests._stamp import Unstamp from tests.interaction._connect import Connect from tests.interaction._helpers import IncomingMessage from tests.interaction._requirements import requirement @@ -54,7 +54,9 @@ async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestPa @requirement("flow:tool-result:resource-link-follow") -async def test_a_resource_link_returned_by_a_tool_can_be_followed_with_read(connect: Connect) -> None: +async def test_a_resource_link_returned_by_a_tool_can_be_followed_with_read( + connect: Connect, unstamped: Unstamp +) -> None: """A tool returns a resource_link; reading that link's URI returns the referenced contents. Steps: (1) call the tool, (2) extract the link from its content, (3) read_resource on the diff --git a/tests/interaction/lowlevel/test_logging.py b/tests/interaction/lowlevel/test_logging.py index 571526af44..7f2f89fd1a 100644 --- a/tests/interaction/lowlevel/test_logging.py +++ b/tests/interaction/lowlevel/test_logging.py @@ -11,7 +11,7 @@ from mcp_types import CallToolResult, EmptyResult, LoggingMessageNotificationParams, TextContent from mcp.server import Server, ServerRequestContext -from tests._stamp import unstamped +from tests._stamp import Unstamp from tests.interaction._connect import Connect from tests.interaction._requirements import requirement @@ -47,7 +47,7 @@ async def set_logging_level(ctx: ServerRequestContext, params: types.SetLevelReq @requirement("logging:message:fields") @requirement("tools:call:logging-mid-execution") -async def test_log_messages_reach_logging_callback_in_order(connect: Connect) -> None: +async def test_log_messages_reach_logging_callback_in_order(connect: Connect, unstamped: Unstamp) -> None: """Log messages sent during a tool call arrive at the logging callback, in order, before the call returns. The two messages pin the full notification shape: severity, optional logger name, and both diff --git a/tests/interaction/lowlevel/test_meta.py b/tests/interaction/lowlevel/test_meta.py index 3a549a5c2a..6e1403fea4 100644 --- a/tests/interaction/lowlevel/test_meta.py +++ b/tests/interaction/lowlevel/test_meta.py @@ -11,7 +11,7 @@ from mcp_types import CallToolResult, RequestParamsMeta, TextContent from mcp.server import Server, ServerRequestContext -from tests._stamp import unstamped +from tests._stamp import Unstamp from tests.interaction._connect import Connect from tests.interaction._requirements import requirement @@ -44,7 +44,7 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara @requirement("meta:result-to-client") -async def test_result_meta_reaches_client(connect: Connect) -> None: +async def test_result_meta_reaches_client(connect: Connect, unstamped: Unstamp) -> None: """The _meta object a handler attaches to its result is delivered to the client unchanged.""" result_meta = {"example.com/cost": 3} diff --git a/tests/interaction/lowlevel/test_pagination.py b/tests/interaction/lowlevel/test_pagination.py index 2dcb380c65..e59d99c083 100644 --- a/tests/interaction/lowlevel/test_pagination.py +++ b/tests/interaction/lowlevel/test_pagination.py @@ -22,7 +22,7 @@ from mcp import MCPError from mcp.server import Server, ServerRequestContext -from tests._stamp import unstamped +from tests._stamp import Unstamp from tests.interaction._connect import Connect from tests.interaction._requirements import requirement @@ -30,7 +30,7 @@ @requirement("tools:list:pagination") -async def test_next_cursor_round_trips_through_the_client(connect: Connect) -> None: +async def test_next_cursor_round_trips_through_the_client(connect: Connect, unstamped: Unstamp) -> None: """The next_cursor a list handler returns reaches the client, and the cursor the client sends back on the following call reaches the handler verbatim. """ diff --git a/tests/interaction/lowlevel/test_progress.py b/tests/interaction/lowlevel/test_progress.py index c958432525..025dded156 100644 --- a/tests/interaction/lowlevel/test_progress.py +++ b/tests/interaction/lowlevel/test_progress.py @@ -18,7 +18,7 @@ from mcp.server import Server, ServerRequestContext from mcp.server.session import ServerSession from mcp.shared.session import ProgressFnT -from tests._stamp import unstamped +from tests._stamp import Unstamp from tests.interaction._connect import Connect from tests.interaction._helpers import IncomingMessage from tests.interaction._requirements import requirement @@ -28,7 +28,7 @@ @requirement("protocol:progress:callback") @requirement("tools:call:progress") -async def test_progress_during_tool_call_reaches_callback_in_order(connect: Connect) -> None: +async def test_progress_during_tool_call_reaches_callback_in_order(connect: Connect, unstamped: Unstamp) -> None: """Progress notifications emitted by a tool handler reach the caller's progress callback in order.""" received: list[tuple[float, float | None, str | None]] = [] @@ -84,7 +84,7 @@ async def ignore(progress: float, total: float | None, message: str | None) -> N @requirement("protocol:progress:no-token") -async def test_no_progress_callback_means_no_token(connect: Connect) -> None: +async def test_no_progress_callback_means_no_token(connect: Connect, unstamped: Unstamp) -> None: """Without a progress callback the request carries no progress token. The low-level API has no way to report request-scoped progress without a token, so a handler diff --git a/tests/interaction/lowlevel/test_prompts.py b/tests/interaction/lowlevel/test_prompts.py index 775f0b44e0..6048c8b24d 100644 --- a/tests/interaction/lowlevel/test_prompts.py +++ b/tests/interaction/lowlevel/test_prompts.py @@ -21,7 +21,7 @@ from mcp import MCPError from mcp.server import Server, ServerRequestContext -from tests._stamp import unstamped +from tests._stamp import Unstamp from tests.interaction._connect import Connect from tests.interaction._requirements import requirement @@ -29,7 +29,7 @@ @requirement("prompts:list:basic") -async def test_list_prompts_returns_registered_prompts(connect: Connect) -> None: +async def test_list_prompts_returns_registered_prompts(connect: Connect, unstamped: Unstamp) -> None: """The prompts returned by the handler reach the client with their argument declarations intact.""" async def list_prompts(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListPromptsResult: @@ -72,7 +72,7 @@ async def list_prompts(ctx: ServerRequestContext, params: types.PaginatedRequest @requirement("prompts:get:with-args") -async def test_get_prompt_substitutes_arguments(connect: Connect) -> None: +async def test_get_prompt_substitutes_arguments(connect: Connect, unstamped: Unstamp) -> None: """Arguments supplied by the client reach the prompt handler; the templated message comes back.""" async def get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestParams) -> GetPromptResult: @@ -97,7 +97,7 @@ async def get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestPa @requirement("prompts:get:multi-message") -async def test_get_prompt_multiple_messages_preserve_roles_and_order(connect: Connect) -> None: +async def test_get_prompt_multiple_messages_preserve_roles_and_order(connect: Connect, unstamped: Unstamp) -> None: """A prompt returning a user/assistant conversation reaches the client with roles and order intact.""" async def get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestParams) -> GetPromptResult: @@ -127,7 +127,7 @@ async def get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestPa @requirement("prompts:get:no-args") -async def test_get_prompt_without_arguments_returns_the_messages(connect: Connect) -> None: +async def test_get_prompt_without_arguments_returns_the_messages(connect: Connect, unstamped: Unstamp) -> None: """A prompt fetched with no arguments delivers None as the handler's arguments and returns its messages.""" async def get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestParams) -> GetPromptResult: @@ -148,7 +148,7 @@ async def get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestPa @requirement("prompts:get:content:image") @requirement("prompts:get:content:audio") @requirement("prompts:get:content:embedded-resource") -async def test_get_prompt_with_non_text_content_round_trips(connect: Connect) -> None: +async def test_get_prompt_with_non_text_content_round_trips(connect: Connect, unstamped: Unstamp) -> None: """Prompt messages can carry image, audio, and embedded-resource content; all reach the client. A single full-result snapshot proves all three content types round-trip: each block in the result diff --git a/tests/interaction/lowlevel/test_resources.py b/tests/interaction/lowlevel/test_resources.py index ab64a41d7b..e911c65876 100644 --- a/tests/interaction/lowlevel/test_resources.py +++ b/tests/interaction/lowlevel/test_resources.py @@ -27,7 +27,7 @@ from mcp import MCPError from mcp.server import Server, ServerRequestContext -from tests._stamp import unstamped +from tests._stamp import Unstamp from tests.interaction._connect import Connect from tests.interaction._helpers import IncomingMessage from tests.interaction._requirements import requirement @@ -37,7 +37,7 @@ @requirement("resources:list:basic") @requirement("resources:annotations") -async def test_list_resources_returns_registered_resources(connect: Connect) -> None: +async def test_list_resources_returns_registered_resources(connect: Connect, unstamped: Unstamp) -> None: """Listed resources reach the client with their URIs, names, and optional descriptive fields intact. The fully-populated entry includes annotations, so the snapshot also proves they round-trip. @@ -94,7 +94,7 @@ async def list_resources( @requirement("resources:read:text") -async def test_read_resource_text(connect: Connect) -> None: +async def test_read_resource_text(connect: Connect, unstamped: Unstamp) -> None: """Reading a text resource returns its contents with the URI, MIME type, and text supplied by the handler.""" async def read_resource(ctx: ServerRequestContext, params: types.ReadResourceRequestParams) -> ReadResourceResult: @@ -115,7 +115,7 @@ async def read_resource(ctx: ServerRequestContext, params: types.ReadResourceReq @requirement("resources:read:blob") -async def test_read_resource_binary(connect: Connect) -> None: +async def test_read_resource_binary(connect: Connect, unstamped: Unstamp) -> None: """Reading a binary resource returns its contents base64-encoded in the blob field.""" async def read_resource(ctx: ServerRequestContext, params: types.ReadResourceRequestParams) -> ReadResourceResult: @@ -162,7 +162,7 @@ async def read_resource(ctx: ServerRequestContext, params: types.ReadResourceReq @requirement("resources:templates:list") -async def test_list_resource_templates_returns_registered_templates(connect: Connect) -> None: +async def test_list_resource_templates_returns_registered_templates(connect: Connect, unstamped: Unstamp) -> None: """Listed resource templates reach the client with their URI templates and descriptive fields intact.""" async def list_resource_templates( diff --git a/tests/interaction/lowlevel/test_tools.py b/tests/interaction/lowlevel/test_tools.py index be56f89813..86fec356bc 100644 --- a/tests/interaction/lowlevel/test_tools.py +++ b/tests/interaction/lowlevel/test_tools.py @@ -22,7 +22,7 @@ from mcp import MCPError from mcp.server import Server, ServerRequestContext -from tests._stamp import unstamped +from tests._stamp import Unstamp from tests.interaction._connect import Connect from tests.interaction._requirements import requirement @@ -30,7 +30,7 @@ @requirement("tools:call:content:text") -async def test_call_tool_returns_text_content(connect: Connect) -> None: +async def test_call_tool_returns_text_content(connect: Connect, unstamped: Unstamp) -> None: """Arguments reach the tool handler; its content comes back as the call result.""" async def list_tools( @@ -54,7 +54,7 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara @requirement("tools:call:is-error") -async def test_call_tool_execution_error_is_returned_as_result(connect: Connect) -> None: +async def test_call_tool_execution_error_is_returned_as_result(connect: Connect, unstamped: Unstamp) -> None: """A tool reporting its own failure with is_error=True reaches the client as a result, not an exception. Tool execution errors are part of the result so the caller (typically a model) can see @@ -118,7 +118,7 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara @requirement("tools:list:basic") -async def test_list_tools_returns_registered_tools(connect: Connect) -> None: +async def test_list_tools_returns_registered_tools(connect: Connect, unstamped: Unstamp) -> None: """The tools advertised by the server's list handler arrive at the client unchanged.""" async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult: @@ -164,7 +164,7 @@ async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestPa @requirement("tools:input-schema:preserve-additional-properties") @requirement("tools:input-schema:preserve-defs") @requirement("tools:input-schema:preserve-schema-dialect") -async def test_tools_list_preserves_arbitrary_input_schema_keywords(connect: Connect) -> None: +async def test_tools_list_preserves_arbitrary_input_schema_keywords(connect: Connect, unstamped: Unstamp) -> None: """A rich JSON Schema 2020-12 inputSchema reaches the client unchanged and the tool is callable. The single identity assertion below proves all four pass-through behaviours at once: the same @@ -207,7 +207,7 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara @requirement("tools:list:metadata") -async def test_list_tools_optional_fields_round_trip(connect: Connect) -> None: +async def test_list_tools_optional_fields_round_trip(connect: Connect, unstamped: Unstamp) -> None: """Every optional Tool field the server supplies reaches the client unchanged.""" tool = Tool( @@ -252,7 +252,7 @@ async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestPa @requirement("tools:call:content:audio") @requirement("tools:call:content:resource-link") @requirement("tools:call:content:embedded-resource") -async def test_call_tool_multiple_content_block_types(connect: Connect) -> None: +async def test_call_tool_multiple_content_block_types(connect: Connect, unstamped: Unstamp) -> None: """A tool result can mix every content block type; all of them arrive in order. The payloads are tiny fixed base64 strings ("aW1n" is b"img", "YXVk" is b"aud") so the @@ -297,7 +297,7 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara @requirement("tools:call:structured-content") -async def test_call_tool_structured_content(connect: Connect) -> None: +async def test_call_tool_structured_content(connect: Connect, unstamped: Unstamp) -> None: """A tool result carrying structured content alongside content delivers both to the client.""" async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult: @@ -318,7 +318,7 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara @requirement("tools:call:concurrent") -async def test_concurrent_tool_calls_complete_independently(connect: Connect) -> None: +async def test_concurrent_tool_calls_complete_independently(connect: Connect, unstamped: Unstamp) -> None: """Two tool calls in flight at once run concurrently and each caller gets its own answer. Both handlers are held on a shared event after signalling that they have started, and the test @@ -406,7 +406,7 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara @requirement("client:output-schema:skip-on-error") -async def test_is_error_result_bypasses_client_output_schema_validation(connect: Connect) -> None: +async def test_is_error_result_bypasses_client_output_schema_validation(connect: Connect, unstamped: Unstamp) -> None: """A tool result with isError true is returned as-is even when its structured content violates the schema. The schema is cached up front so the client could validate, proving the bypass is specifically the @@ -478,7 +478,9 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara @requirement("client:output-schema:auto-list") -async def test_call_tool_populates_the_output_schema_cache_via_an_implicit_tools_list(connect: Connect) -> None: +async def test_call_tool_populates_the_output_schema_cache_via_an_implicit_tools_list( + connect: Connect, unstamped: Unstamp +) -> None: """Calling a tool whose schema is not cached issues exactly one implicit tools/list to populate it. The first call_tool of an uncached tool triggers a tools/list the caller never asked for; the diff --git a/tests/interaction/mcpserver/test_context.py b/tests/interaction/mcpserver/test_context.py index 24fc344ebf..24728e2da8 100644 --- a/tests/interaction/mcpserver/test_context.py +++ b/tests/interaction/mcpserver/test_context.py @@ -20,7 +20,7 @@ from mcp.client import ClientRequestContext from mcp.server.elicitation import AcceptedElicitation from mcp.server.mcpserver import Context, MCPServer -from tests._stamp import unstamped +from tests._stamp import Unstamp from tests.interaction._connect import Connect from tests.interaction._helpers import IncomingMessage from tests.interaction._requirements import requirement @@ -69,7 +69,7 @@ async def collect(params: LoggingMessageNotificationParams) -> None: @requirement("mcpserver:context:progress") -async def test_context_report_progress_sends_progress_notifications(connect: Connect) -> None: +async def test_context_report_progress_sends_progress_notifications(connect: Connect, unstamped: Unstamp) -> None: """Context.report_progress sends progress notifications correlated to the calling request. The caller's progress callback receives each report, in order, before the tool call returns. @@ -124,7 +124,7 @@ async def whoami(ctx: Context) -> str: @requirement("mcpserver:context:logging") @requirement("protocol:progress:no-token") -async def test_report_progress_without_a_progress_token_sends_nothing(connect: Connect) -> None: +async def test_report_progress_without_a_progress_token_sends_nothing(connect: Connect, unstamped: Unstamp) -> None: """When the caller supplied no progress callback, Context.report_progress is a silent no-op. The tool also emits one log message as a sentinel: the message handler receives only that, @@ -208,7 +208,7 @@ async def answer_form(context: ClientRequestContext, params: ElicitRequestParams @requirement("mcpserver:context:read-resource") -async def test_context_read_resource_reads_registered_resource(connect: Connect) -> None: +async def test_context_read_resource_reads_registered_resource(connect: Connect, unstamped: Unstamp) -> None: """Context.read_resource lets a tool read a resource registered on the same server. The tool reports the MIME type and content it read, proving the resource function ran and its diff --git a/tests/interaction/mcpserver/test_extensions.py b/tests/interaction/mcpserver/test_extensions.py index 2284ce73b8..129324538f 100644 --- a/tests/interaction/mcpserver/test_extensions.py +++ b/tests/interaction/mcpserver/test_extensions.py @@ -15,7 +15,7 @@ from mcp.server.context import CallNext, HandlerResult, ServerRequestContext from mcp.server.extension import Extension from mcp.server.mcpserver import Context, MCPServer, require_client_extension -from tests._stamp import unstamped +from tests._stamp import Unstamp from tests.interaction._connect import Connect from tests.interaction._requirements import requirement @@ -80,7 +80,9 @@ def redeem(token: str) -> str: @requirement("extensions:client:claimed-result-resolved") -async def test_claimed_result_is_finished_by_the_owning_extensions_resolver(connect: Connect) -> None: +async def test_claimed_result_is_finished_by_the_owning_extensions_resolver( + connect: Connect, unstamped: Unstamp +) -> None: """The owning extension's claim resolver redeems the substituted `receipt` through `ctx.session`, and `call_tool` returns the resolver's plain `CallToolResult`.""" received: list[ReceiptResult] = [] diff --git a/tests/interaction/mcpserver/test_prompts.py b/tests/interaction/mcpserver/test_prompts.py index 2084cbdb34..8409e50207 100644 --- a/tests/interaction/mcpserver/test_prompts.py +++ b/tests/interaction/mcpserver/test_prompts.py @@ -14,7 +14,7 @@ from mcp import MCPError from mcp.server.mcpserver import MCPServer -from tests._stamp import unstamped +from tests._stamp import Unstamp from tests.interaction._connect import Connect from tests.interaction._requirements import requirement @@ -22,7 +22,7 @@ @requirement("mcpserver:prompt:decorated") -async def test_list_prompts_derives_arguments_from_signature(connect: Connect) -> None: +async def test_list_prompts_derives_arguments_from_signature(connect: Connect, unstamped: Unstamp) -> None: """A decorated prompt is listed with arguments derived from the function signature. Parameters without a default are required; the description comes from the docstring. @@ -54,7 +54,7 @@ def code_review(code: str, style_guide: str = "pep8") -> str: @requirement("mcpserver:prompt:decorated") -async def test_get_prompt_renders_function_return(connect: Connect) -> None: +async def test_get_prompt_renders_function_return(connect: Connect, unstamped: Unstamp) -> None: """The decorated function's string return value is rendered as a single user message.""" mcp = MCPServer("prompter") @@ -142,7 +142,9 @@ def repeat(phrase: str, count: int) -> str: @requirement("mcpserver:prompt:optional-args") -async def test_get_prompt_with_an_optional_argument_omitted_uses_the_default(connect: Connect) -> None: +async def test_get_prompt_with_an_optional_argument_omitted_uses_the_default( + connect: Connect, unstamped: Unstamp +) -> None: """A prompt rendered without one of its optional arguments uses that parameter's default value.""" mcp = MCPServer("prompter") @@ -163,7 +165,9 @@ def review(code: str, style: str = "pep8") -> str: @requirement("mcpserver:prompt:duplicate-name") -async def test_registering_a_duplicate_prompt_name_warns_and_keeps_the_first(connect: Connect) -> None: +async def test_registering_a_duplicate_prompt_name_warns_and_keeps_the_first( + connect: Connect, unstamped: Unstamp +) -> None: """Registering a second prompt with an already-used name keeps the first registration. The intended behaviour is rejection at registration time; MCPServer instead logs a warning diff --git a/tests/interaction/mcpserver/test_resources.py b/tests/interaction/mcpserver/test_resources.py index 0a0fd34bd8..eadf4794e6 100644 --- a/tests/interaction/mcpserver/test_resources.py +++ b/tests/interaction/mcpserver/test_resources.py @@ -14,7 +14,7 @@ from mcp import MCPError from mcp.server.mcpserver import MCPServer -from tests._stamp import unstamped +from tests._stamp import Unstamp from tests.interaction._connect import Connect from tests.interaction._requirements import requirement @@ -22,7 +22,7 @@ @requirement("mcpserver:resource:static") -async def test_read_static_resource(connect: Connect) -> None: +async def test_read_static_resource(connect: Connect, unstamped: Unstamp) -> None: """A function registered for a fixed URI is served at that URI with its return value as text.""" mcp = MCPServer("library") @@ -42,7 +42,7 @@ def app_config() -> str: @requirement("mcpserver:resource:static") -async def test_list_static_and_templated_resources(connect: Connect) -> None: +async def test_list_static_and_templated_resources(connect: Connect, unstamped: Unstamp) -> None: """Statically-registered resources appear in resources/list; templated ones only in templates/list. The name and description are derived from the function name and docstring; the MIME type @@ -92,7 +92,7 @@ def user_profile(user_id: str) -> str: @requirement("mcpserver:resource:template") @requirement("resources:read:template-vars") -async def test_read_templated_resource(connect: Connect) -> None: +async def test_read_templated_resource(connect: Connect, unstamped: Unstamp) -> None: """Reading a URI that matches a registered template invokes the function with the extracted parameters.""" mcp = MCPServer("library") @@ -153,7 +153,9 @@ def boom() -> str: @requirement("mcpserver:resource:duplicate-name") -async def test_registering_a_duplicate_resource_uri_warns_and_keeps_the_first(connect: Connect) -> None: +async def test_registering_a_duplicate_resource_uri_warns_and_keeps_the_first( + connect: Connect, unstamped: Unstamp +) -> None: """Registering a second static resource at an already-used URI keeps the first registration. The intended behaviour is rejection at registration time; MCPServer instead logs a warning diff --git a/tests/interaction/mcpserver/test_tools.py b/tests/interaction/mcpserver/test_tools.py index 5c59ad9117..ad5db520b9 100644 --- a/tests/interaction/mcpserver/test_tools.py +++ b/tests/interaction/mcpserver/test_tools.py @@ -20,7 +20,7 @@ from mcp.server.mcpserver import Context, MCPServer from mcp.server.mcpserver.exceptions import ToolError from mcp.shared.exceptions import UrlElicitationRequiredError -from tests._stamp import unstamped +from tests._stamp import Unstamp from tests.interaction._connect import Connect from tests.interaction._helpers import IncomingMessage from tests.interaction._requirements import requirement @@ -29,7 +29,7 @@ @requirement("tools:call:content:text") -async def test_call_tool_returns_text_content(connect: Connect) -> None: +async def test_call_tool_returns_text_content(connect: Connect, unstamped: Unstamp) -> None: """Arguments reach the tool function; its return value comes back as text content. MCPServer also derives an output schema from the return annotation and attaches the @@ -50,7 +50,9 @@ def add(a: int, b: int) -> str: @requirement("mcpserver:tool:schema-variants") -async def test_complex_parameter_types_are_validated_and_coerced_before_the_tool_runs(connect: Connect) -> None: +async def test_complex_parameter_types_are_validated_and_coerced_before_the_tool_runs( + connect: Connect, unstamped: Unstamp +) -> None: """Literal, nested-model, and constrained parameters are validated and coerced from the wire arguments. The string "3" is coerced to `int` and the `point` dict to a `Point` instance before the function @@ -79,7 +81,7 @@ def place(mode: Literal["fast", "slow"], point: Point, count: Annotated[int, Fie @requirement("mcpserver:tool:handler-throws") @requirement("mcpserver:output-schema:skip-on-error") -async def test_call_tool_function_exception_becomes_error_result(connect: Connect) -> None: +async def test_call_tool_function_exception_becomes_error_result(connect: Connect, unstamped: Unstamp) -> None: """An exception raised by a tool function is returned as an is_error result, not a JSON-RPC error. The function's `-> str` annotation gives the tool a derived output schema, but the error @@ -101,7 +103,7 @@ def explode() -> str: @requirement("mcpserver:tool:handler-throws") -async def test_call_tool_tool_error_becomes_error_result(connect: Connect) -> None: +async def test_call_tool_tool_error_becomes_error_result(connect: Connect, unstamped: Unstamp) -> None: """A ToolError raised by a tool function is returned as an is_error result, not a JSON-RPC error.""" mcp = MCPServer("errors") @@ -118,7 +120,7 @@ def flux() -> str: @requirement("mcpserver:tool:unknown-name") -async def test_call_tool_unknown_name_returns_error_result(connect: Connect) -> None: +async def test_call_tool_unknown_name_returns_error_result(connect: Connect, unstamped: Unstamp) -> None: """Calling a tool name that was never registered is reported as an is_error result. The spec classifies unknown tools as a protocol error; see the divergence note on the @@ -140,7 +142,7 @@ def add() -> None: @requirement("mcpserver:tool:output-schema:model") @requirement("tools:call:structured-content:text-mirror") -async def test_call_tool_model_return_becomes_structured_content(connect: Connect) -> None: +async def test_call_tool_model_return_becomes_structured_content(connect: Connect, unstamped: Unstamp) -> None: """A tool returning a pydantic model advertises the model's schema as the tool's output schema and returns the model's fields as structured content alongside a serialised text block. """ @@ -187,7 +189,7 @@ def get_weather() -> Weather: @requirement("mcpserver:tool:output-schema:wrapped") -async def test_call_tool_list_return_is_wrapped_in_result_key(connect: Connect) -> None: +async def test_call_tool_list_return_is_wrapped_in_result_key(connect: Connect, unstamped: Unstamp) -> None: """A tool returning a list wraps the value under a "result" key in both the generated output schema and the structured content. """ @@ -284,7 +286,9 @@ def missing() -> Annotated[CallToolResult, Weather]: @requirement("mcpserver:tool:duplicate-name") -async def test_registering_a_duplicate_tool_name_warns_and_keeps_the_first(connect: Connect) -> None: +async def test_registering_a_duplicate_tool_name_warns_and_keeps_the_first( + connect: Connect, unstamped: Unstamp +) -> None: """Registering a second tool with an already-used name keeps the first registration. The intended behaviour is rejection at registration time; MCPServer instead logs a warning @@ -316,7 +320,7 @@ def echo_second() -> str: @requirement("mcpserver:tool:naming-validation") async def test_registering_a_tool_with_a_spec_invalid_name_warns_but_does_not_reject( - connect: Connect, caplog: pytest.LogCaptureFixture + connect: Connect, caplog: pytest.LogCaptureFixture, unstamped: Unstamp ) -> None: """A tool name that violates the SEP-986 rules logs a warning at registration but is still registered. From 476d758cfafd369f88464f86524be420fb3df33b Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:37:42 +0000 Subject: [PATCH 12/15] Unversioned servers report an empty version The version fallback to the installed mcp package dated to the SDK's first initialization-options helper and reported the SDK's version as the server's own. No spec text supports that: the schema requires the field but says only 'the version of this implementation', and the ecosystem expectation (spec discussion #1176) is the server's distributed version. typescript and kotlin require an explicit identity; go serializes an empty string for an unset version. Align with go: version='' unless the constructor sets one, and delete the _package_version helper. The stamp is now fully deterministic for unversioned servers, so the unit suites carry it literally in their expectations instead of stripping it (tests._stamp stays for the dual-era interaction matrix, whose era split is unrelated), and the matrix conftest's version pin is gone. Small migration note added: this is a v1-visible behavior change for servers that never set a version. --- docs/advanced/low-level-server.md | 2 +- docs/get-started/testing.md | 2 +- docs/migration.md | 8 ++ src/mcp/server/lowlevel/server.py | 14 +--- tests/_stamp.py | 9 +-- tests/client/test_client.py | 76 +++++++++++++------ tests/client/test_session_concurrency.py | 21 +++-- tests/docs_src/_helpers.py | 11 ++- tests/docs_src/test_lowlevel.py | 2 +- tests/interaction/conftest.py | 7 -- tests/server/lowlevel/test_server_discover.py | 8 +- tests/server/mcpserver/test_extension.py | 64 +++++++++++----- tests/server/mcpserver/test_server.py | 35 +++++---- tests/server/test_apps.py | 6 +- tests/server/test_caching.py | 9 ++- tests/server/test_streamable_http_modern.py | 4 +- tests/test_examples.py | 8 +- 17 files changed, 174 insertions(+), 112 deletions(-) diff --git a/docs/advanced/low-level-server.md b/docs/advanced/low-level-server.md index bb8600c307..dd5fb427a2 100644 --- a/docs/advanced/low-level-server.md +++ b/docs/advanced/low-level-server.md @@ -107,7 +107,7 @@ Call it and the result carries both representations: } ``` -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. A server that must not identify itself can strip the key with a middleware, which owns the results it returns. +The `_meta` block is the server's identity stamp: the SDK adds it to every 2026-era result, with the `version` from the constructor (a server that sets none reports an empty string). A server that must not identify itself can strip the key with a middleware, which owns the results it returns. 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)**. diff --git a/docs/get-started/testing.md b/docs/get-started/testing.md index 0143e12a01..2c3f9fca74 100644 --- a/docs/get-started/testing.md +++ b/docs/get-started/testing.md @@ -59,7 +59,7 @@ 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. + # Drop the server identity stamp in `_meta`; it is not what this test is about. result.meta = None assert result == snapshot( CallToolResult( diff --git a/docs/migration.md b/docs/migration.md index e81510fedc..2f2594c4c9 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -606,6 +606,14 @@ mcp = MCPServer("Demo", instructions="You answer questions about the weather.") Keep `name` positional and pass everything else by keyword. +### Unversioned servers report an empty version + +In v1, a server constructed without a `version` reported the installed `mcp` +package's version as its own in the `initialize` result's `serverInfo`. In v2 +it reports an empty string instead: the SDK's version is not your server's +version. Pass `version="..."` to `Server(...)` or `MCPServer(...)` to identify +your server properly. The field is display-only; nothing breaks either way. + ### `mount_path` parameter removed from MCPServer The `mount_path` parameter has been removed from `MCPServer.__init__()`, `MCPServer.run()`, `MCPServer.run_sse_async()`, and `MCPServer.sse_app()`. It was also removed from the `Settings` class. diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index ce2f2b67bc..0eaf358f32 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -43,7 +43,6 @@ async def main(): 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 import mcp_types as types @@ -126,15 +125,6 @@ async def _ping_handler(ctx: ServerRequestContext[Any], params: types.RequestPar return types.EmptyResult() -def _package_version(package: str) -> str: - try: - return importlib_version(package) - except Exception: # pragma: no cover - pass - - return "unknown" # pragma: no cover - - class Server(Generic[LifespanResultT]): @overload def __init__( @@ -549,7 +539,7 @@ def create_initialization_options( """ return InitializationOptions( server_name=self.name, - server_version=self.version if self.version else _package_version("mcp"), + server_version=self.version or "", title=self.title, description=self.description, capabilities=self.get_capabilities( @@ -643,7 +633,7 @@ def server_info(self) -> types.Implementation: """ return types.Implementation( name=self.name, - version=self.version if self.version else _package_version("mcp"), + version=self.version or "", title=self.title, description=self.description, website_url=self.website_url, diff --git a/tests/_stamp.py b/tests/_stamp.py index 20238ecd10..2dc380a36c 100644 --- a/tests/_stamp.py +++ b/tests/_stamp.py @@ -1,11 +1,10 @@ """Shared helper: strip the 2026-era serverInfo `_meta` stamp from a result. Servers stamp `io.modelcontextprotocol/serverInfo` into every 2026-era -result's `_meta` and never into handshake-era ones, and the stamp's `version` -defaults to the installed package version. Suites whose expected payloads -should stay identity-free strip the stamp before exact comparison - and the -strip is strict, so a modern result that lost its stamp fails the test -instead of passing silently. +result's `_meta` and never into handshake-era ones. Suites whose expected +payloads should stay identity-free strip the stamp before exact comparison - +and the strip is strict, so a modern result that lost its stamp fails the +test instead of passing silently. The interaction matrix does not use this function directly: its `unstamped` fixture (tests/interaction/conftest.py) resolves per cell to this strict diff --git a/tests/client/test_client.py b/tests/client/test_client.py index 0c1f354a2d..088c714001 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -45,7 +45,6 @@ from mcp.server.mcpserver import Context, MCPServer from mcp.shared.memory import MessageStream, create_client_server_memory_streams from mcp.shared.message import SessionMessage -from tests._stamp import unstamped from tests.interaction._connect import BASE_URL, mounted_app pytestmark = pytest.mark.anyio @@ -134,9 +133,10 @@ async def test_client_with_simple_server(simple_server: Server): """Test that from_server works with a basic Server instance.""" async with Client(simple_server) as client: resources = await client.list_resources() - assert unstamped(resources) == snapshot( + assert resources == snapshot( ListResourcesResult( - resources=[Resource(name="Test Resource", uri="memory://test", description="A test resource")] + _meta={"io.modelcontextprotocol/serverInfo": {"name": "test_server", "version": ""}}, + resources=[Resource(name="Test Resource", uri="memory://test", description="A test resource")], ) ) @@ -150,8 +150,9 @@ async def test_client_send_ping(app: MCPServer): async def test_client_list_tools(app: MCPServer): async with Client(app) as client: result = await client.list_tools() - assert unstamped(result) == snapshot( + assert result == snapshot( ListToolsResult( + _meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}}, tools=[ Tool( name="greet", @@ -169,7 +170,7 @@ async def test_client_list_tools(app: MCPServer): "type": "object", }, ) - ] + ], ) ) @@ -177,8 +178,9 @@ async def test_client_list_tools(app: MCPServer): async def test_client_call_tool(app: MCPServer): async with Client(app) as client: result = await client.call_tool("greet", {"name": "World"}) - assert unstamped(result) == snapshot( + assert result == snapshot( CallToolResult( + _meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}}, content=[TextContent(text="Hello, World!")], structured_content={"result": "Hello, World!"}, ) @@ -189,9 +191,10 @@ async def test_read_resource(app: MCPServer): """Test reading a resource.""" async with Client(app) as client: result = await client.read_resource("test://resource") - assert unstamped(result) == snapshot( + assert result == snapshot( ReadResourceResult( - contents=[TextResourceContents(uri="test://resource", mime_type="text/plain", text="Test content")] + _meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}}, + contents=[TextResourceContents(uri="test://resource", mime_type="text/plain", text="Test content")], ) ) @@ -269,8 +272,9 @@ async def test_get_prompt(app: MCPServer): """Test getting a prompt.""" async with Client(app) as client: result = await client.get_prompt("greeting_prompt", {"name": "Alice"}) - assert unstamped(result) == snapshot( + assert result == snapshot( GetPromptResult( + _meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}}, description="A greeting prompt.", messages=[PromptMessage(role="user", content=TextContent(text="Please greet Alice warmly."))], ) @@ -335,8 +339,9 @@ async def test_client_list_resources_with_params(app: MCPServer): """Test listing resources with params parameter.""" async with Client(app) as client: result = await client.list_resources() - assert unstamped(result) == snapshot( + assert result == snapshot( ListResourcesResult( + _meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}}, resources=[ Resource( name="test_resource", @@ -344,7 +349,7 @@ async def test_client_list_resources_with_params(app: MCPServer): description="A test resource.", mime_type="text/plain", ) - ] + ], ) ) @@ -353,22 +358,27 @@ async def test_client_list_resource_templates(app: MCPServer): """Test listing resource templates with params parameter.""" async with Client(app) as client: result = await client.list_resource_templates() - assert unstamped(result) == snapshot(ListResourceTemplatesResult(resource_templates=[])) + assert result == snapshot( + ListResourceTemplatesResult( + _meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}}, resource_templates=[] + ) + ) async def test_list_prompts(app: MCPServer): """Test listing prompts with params parameter.""" async with Client(app) as client: result = await client.list_prompts() - assert unstamped(result) == snapshot( + assert result == snapshot( ListPromptsResult( + _meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}}, prompts=[ Prompt( name="greeting_prompt", description="A greeting prompt.", arguments=[PromptArgument(name="name", required=True)], ) - ] + ], ) ) @@ -378,7 +388,12 @@ async def test_complete_with_prompt_reference(simple_server: Server): async with Client(simple_server) as client: ref = types.PromptReference(type="ref/prompt", name="test_prompt") result = await client.complete(ref=ref, argument={"name": "arg", "value": "test"}) - assert unstamped(result) == snapshot(types.CompleteResult(completion=types.Completion(values=[]))) + assert result == snapshot( + types.CompleteResult( + _meta={"io.modelcontextprotocol/serverInfo": {"name": "test_server", "version": ""}}, + completion=types.Completion(values=[]), + ) + ) def test_client_with_url_initializes_streamable_http_transport(): @@ -756,8 +771,12 @@ async def elicitation_callback( async with Client(server, elicitation_callback=elicitation_callback) as client: result = await client.call_tool("greet") - assert unstamped(result) == snapshot( - CallToolResult(content=[TextContent(text="Hello, Ada!")], structured_content={"result": "Hello, Ada!"}) + assert result == snapshot( + CallToolResult( + _meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}}, + content=[TextContent(text="Hello, Ada!")], + structured_content={"result": "Hello, Ada!"}, + ) ) assert len(callback_params) == 1 assert isinstance(callback_params[0], types.ElicitRequestFormParams) @@ -801,9 +820,11 @@ async def sampling_callback( async with Client(server, sampling_callback=sampling_callback) as client: result = await client.call_tool("ask") - assert unstamped(result) == snapshot( + assert result == snapshot( CallToolResult( - content=[TextContent(text="Model said: Paris")], structured_content={"result": "Model said: Paris"} + _meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}}, + content=[TextContent(text="Model said: Paris")], + structured_content={"result": "Model said: Paris"}, ) ) assert len(callback_params) == 1 @@ -834,8 +855,9 @@ async def list_roots_callback(context: ClientRequestContext) -> types.ListRootsR async with Client(server, list_roots_callback=list_roots_callback) as client: result = await client.call_tool("count_roots") - assert unstamped(result) == snapshot( + assert result == snapshot( CallToolResult( + _meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}}, content=[TextContent(text="Client exposed 1 root(s).")], structured_content={"result": "Client exposed 1 root(s)."}, ) @@ -920,8 +942,11 @@ async def elicitation_callback( with anyio.fail_after(5): async with Client(server, mode="2026-07-28", elicitation_callback=elicitation_callback) as client: result = await client.get_prompt("summary") - assert unstamped(result) == snapshot( - GetPromptResult(messages=[PromptMessage(role="user", content=TextContent(text="ok"))]) + assert result == snapshot( + GetPromptResult( + _meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}}, + messages=[PromptMessage(role="user", content=TextContent(text="ok"))], + ) ) @@ -948,6 +973,9 @@ async def elicitation_callback( with anyio.fail_after(5): async with Client(server, mode="2026-07-28", elicitation_callback=elicitation_callback) as client: result = await client.read_resource("memory://gated") - assert unstamped(result) == snapshot( - ReadResourceResult(contents=[TextResourceContents(uri="memory://gated", text="unlocked")]) + assert result == snapshot( + ReadResourceResult( + _meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}}, + contents=[TextResourceContents(uri="memory://gated", text="unlocked")], + ) ) diff --git a/tests/client/test_session_concurrency.py b/tests/client/test_session_concurrency.py index abfbdbba88..beb1d6d4d5 100644 --- a/tests/client/test_session_concurrency.py +++ b/tests/client/test_session_concurrency.py @@ -14,7 +14,6 @@ from mcp import Client from mcp.client import ClientRequestContext from mcp.server.mcpserver import Context, MCPServer -from tests._stamp import unstamped pytestmark = pytest.mark.anyio @@ -67,11 +66,23 @@ async def call_and_record(tag: str) -> None: await done[tag].wait() assert completion_order == ["c", "b", "a"] - assert {tag: unstamped(result) for tag, result in results.items()} == snapshot( + assert results == snapshot( { - "c": CallToolResult(content=[TextContent(text="result:c")], structured_content={"result": "result:c"}), - "b": CallToolResult(content=[TextContent(text="result:b")], structured_content={"result": "result:b"}), - "a": CallToolResult(content=[TextContent(text="result:a")], structured_content={"result": "result:a"}), + "c": CallToolResult( + _meta={"io.modelcontextprotocol/serverInfo": {"name": "parking", "version": ""}}, + content=[TextContent(text="result:c")], + structured_content={"result": "result:c"}, + ), + "b": CallToolResult( + _meta={"io.modelcontextprotocol/serverInfo": {"name": "parking", "version": ""}}, + content=[TextContent(text="result:b")], + structured_content={"result": "result:b"}, + ), + "a": CallToolResult( + _meta={"io.modelcontextprotocol/serverInfo": {"name": "parking", "version": ""}}, + content=[TextContent(text="result:a")], + structured_content={"result": "result:a"}, + ), } ) diff --git a/tests/docs_src/_helpers.py b/tests/docs_src/_helpers.py index eefc57a8d6..0905c6f00a 100644 --- a/tests/docs_src/_helpers.py +++ b/tests/docs_src/_helpers.py @@ -1,6 +1,5 @@ """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 @@ -12,13 +11,13 @@ def strip_server_info(result: R, server: Server | MCPServer) -> R: - """Assert the 2026-era serverInfo stamp, then drop it so snapshots stay stable. + """Assert the 2026-era serverInfo stamp, then drop it so snapshots stay focused. - 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. + The doc snippets set no explicit version, so the stamp's version is empty; + the fenced outputs in the docs pages leave the stamp out, and the tests + mirror the fences. """ assert result.meta is not None - assert result.meta[SERVER_INFO_META_KEY] == {"name": server.name, "version": version("mcp")} + assert result.meta[SERVER_INFO_META_KEY] == {"name": server.name, "version": ""} result.meta = None return result diff --git a/tests/docs_src/test_lowlevel.py b/tests/docs_src/test_lowlevel.py index 6277a72131..7d58e941d2 100644 --- a/tests/docs_src/test_lowlevel.py +++ b/tests/docs_src/test_lowlevel.py @@ -118,7 +118,7 @@ async def test_meta_reaches_the_client_application() -> None: 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. + # them. Remove it before the exact compares: the page's fence leaves the stamp out. 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/interaction/conftest.py b/tests/interaction/conftest.py index a6188fe095..1e0e879bbb 100644 --- a/tests/interaction/conftest.py +++ b/tests/interaction/conftest.py @@ -55,13 +55,6 @@ def __init__(self, factory: Connect, spec_version: str) -> None: self.spec_version = spec_version def __call__(self, server: Server | MCPServer, **kwargs: Any) -> AbstractAsyncContextManager[Client]: - # The matrix compares exact result payloads, and the 2026-era serverInfo - # `_meta` stamp carries the server version, which defaults to the - # commit-dependent installed package version. Pin it so expected - # payloads stay deterministic across commits. - lowlevel = server._lowlevel_server if isinstance(server, MCPServer) else server - if lowlevel.version is None: - lowlevel.version = "1.0.0" return self._factory(server, spec_version=self.spec_version, **kwargs) diff --git a/tests/server/lowlevel/test_server_discover.py b/tests/server/lowlevel/test_server_discover.py index 78c40a14d3..23a29327ee 100644 --- a/tests/server/lowlevel/test_server_discover.py +++ b/tests/server/lowlevel/test_server_discover.py @@ -8,7 +8,6 @@ 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 @@ -132,11 +131,12 @@ async def test_server_info_reflects_constructor_fields() -> None: @pytest.mark.anyio -async def test_server_info_version_falls_back_to_package() -> None: +async def test_an_unversioned_server_reports_an_empty_version() -> None: """SDK-defined: when no explicit version is supplied, the stamped - `serverInfo` version falls back to the installed `mcp` package version.""" + `serverInfo` version is an empty string - the SDK never substitutes its + own package version for the server's.""" result = await _discover_over_runner(Server("unversioned")) - assert result["_meta"][types.SERVER_INFO_META_KEY]["version"] == importlib.metadata.version("mcp") + assert result["_meta"][types.SERVER_INFO_META_KEY] == {"name": "unversioned", "version": ""} @pytest.mark.anyio diff --git a/tests/server/mcpserver/test_extension.py b/tests/server/mcpserver/test_extension.py index e5561a7f98..5aa05b1e3d 100644 --- a/tests/server/mcpserver/test_extension.py +++ b/tests/server/mcpserver/test_extension.py @@ -6,7 +6,6 @@ """ from dataclasses import replace -from importlib.metadata import version from typing import Any, Literal import mcp_types as types @@ -32,7 +31,6 @@ from mcp.server.mcpserver import Context, MCPServer, require_client_extension from mcp.server.mcpserver.resources import TextResource from mcp.shared.exceptions import MCPError -from tests._stamp import unstamped pytestmark = pytest.mark.anyio @@ -152,12 +150,17 @@ async def test_additive_extension_registers_its_tool_and_resource() -> None: assert [t.name for t in tools.tools] == ["ping"] assert tools.tools[0].meta == _TOOL_META - assert unstamped(called) == snapshot( - CallToolResult(content=[TextContent(text="pong")], structured_content={"result": "pong"}) + assert called == snapshot( + CallToolResult( + _meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}}, + content=[TextContent(text="pong")], + structured_content={"result": "pong"}, + ) ) - assert unstamped(resources) == snapshot( + assert resources == snapshot( types.ListResourcesResult( - resources=[types.Resource(name="greeting", uri="ext://greeting", mime_type="text/plain")] + _meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}}, + resources=[types.Resource(name="greeting", uri="ext://greeting", mime_type="text/plain")], ) ) @@ -199,7 +202,9 @@ async def test_extension_method_reachable_via_session_send_request() -> None: request = _PingRequest(params=_PingParams()) result = await client.session.send_request(request, _PingResult) - assert unstamped(result) == snapshot(_PingResult(pong=True)) + assert result == snapshot( + _PingResult(_meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}}, pong=True) + ) async def test_pass_through_interceptor_leaves_tool_result_unchanged() -> None: @@ -211,8 +216,12 @@ async def test_pass_through_interceptor_leaves_tool_result_unchanged() -> None: async with Client(server) as client: result = await client.call_tool("echo", {"value": "hi"}) - assert unstamped(result) == snapshot( - CallToolResult(content=[TextContent(text="hi")], structured_content={"result": "hi"}) + assert result == snapshot( + CallToolResult( + _meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}}, + content=[TextContent(text="hi")], + structured_content={"result": "hi"}, + ) ) @@ -225,7 +234,12 @@ async def test_short_circuiting_interceptor_replaces_tool_result() -> None: async with Client(server) as client: result = await client.call_tool("echo", {"value": "hi"}) - assert unstamped(result) == snapshot(CallToolResult(content=[TextContent(text="intercepted")])) + assert result == snapshot( + CallToolResult( + _meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}}, + content=[TextContent(text="intercepted")], + ) + ) def test_plain_extension_leaves_the_tool_call_handler_bare() -> None: @@ -262,8 +276,12 @@ async def test_default_interceptor_passes_through_alongside_an_overriding_one() async with Client(server) as client: result = await client.call_tool("echo", {"value": "hi"}) - assert unstamped(result) == snapshot( - CallToolResult(content=[TextContent(text="hi")], structured_content={"result": "hi"}) + assert result == snapshot( + CallToolResult( + _meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}}, + content=[TextContent(text="hi")], + structured_content={"result": "hi"}, + ) ) @@ -305,8 +323,12 @@ async def intercept_tool_call( async with Client(server) as client: result = await client.call_tool("echo", {"value": "original"}) - assert unstamped(result) == snapshot( - CallToolResult(content=[TextContent(text="original")], structured_content={"result": "original"}) + assert result == snapshot( + CallToolResult( + _meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}}, + content=[TextContent(text="original")], + structured_content={"result": "original"}, + ) ) @@ -321,7 +343,7 @@ async def test_short_circuited_interceptor_result_carries_the_server_info_stamp( result = await client.call_tool("echo", {"value": "hi"}) assert result.content == [TextContent(text="intercepted")] - assert result.meta == {SERVER_INFO_META_KEY: {"name": "test", "version": version("mcp")}} + assert result.meta == {SERVER_INFO_META_KEY: {"name": "test", "version": ""}} def test_extension_subclass_without_prefixed_identifier_is_rejected_at_definition() -> None: @@ -376,7 +398,9 @@ async def test_version_pinned_method_is_served_at_an_allowed_version() -> None: request = _VersionPinnedRequest(params=_VersionPinnedParams()) result = await client.session.send_request(request, _VersionPinnedResult) - assert unstamped(result) == snapshot(_VersionPinnedResult(ok=True)) + assert result == snapshot( + _VersionPinnedResult(_meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}}, ok=True) + ) async def test_version_pinned_method_is_method_not_found_at_a_disallowed_version() -> None: @@ -456,8 +480,12 @@ async def test_require_client_extension_passes_when_client_declared_it() -> None async with Client(server, extensions=[advertise(_NEEDS_EXT)]) as client: result = await client.call_tool("guarded", {}) - assert unstamped(result) == snapshot( - CallToolResult(content=[TextContent(text="ok")], structured_content={"result": "ok"}) + assert result == snapshot( + CallToolResult( + _meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}}, + content=[TextContent(text="ok")], + structured_content={"result": "ok"}, + ) ) diff --git a/tests/server/mcpserver/test_server.py b/tests/server/mcpserver/test_server.py index 09097c03c7..98d59e98cb 100644 --- a/tests/server/mcpserver/test_server.py +++ b/tests/server/mcpserver/test_server.py @@ -62,7 +62,6 @@ from mcp.server.transport_security import TransportSecuritySettings from mcp.shared.exceptions import MCPError from mcp.shared.uri_template import InvalidUriTemplate -from tests._stamp import unstamped pytestmark = pytest.mark.anyio @@ -997,9 +996,10 @@ def get_csv(user: str) -> str: async with Client(mcp) as client: result = await client.read_resource("resource://bob/csv") - assert unstamped(result) == snapshot( + assert result == snapshot( ReadResourceResult( - contents=[TextResourceContents(uri="resource://bob/csv", mime_type="text/csv", text="csv for bob")] + _meta={"io.modelcontextprotocol/serverInfo": {"name": "mcp-server", "version": ""}}, + contents=[TextResourceContents(uri="resource://bob/csv", mime_type="text/csv", text="csv for bob")], ) ) @@ -1064,8 +1064,9 @@ def get_data() -> str: async with Client(mcp) as client: result = await client.read_resource("resource://data") - assert unstamped(result) == snapshot( + assert result == snapshot( ReadResourceResult( + _meta={"io.modelcontextprotocol/serverInfo": {"name": "mcp-server", "version": ""}}, contents=[ TextResourceContents( uri="resource://data", @@ -1073,7 +1074,7 @@ def get_data() -> str: meta={"version": "1.0", "category": "config"}, # type: ignore[reportUnknownMemberType] text="test data", ) - ] + ], ) ) @@ -1233,13 +1234,14 @@ def resource_no_context(name: str) -> str: async with Client(mcp) as client: result = await client.read_resource("resource://nocontext/test") - assert unstamped(result) == snapshot( + assert result == snapshot( ReadResourceResult( + _meta={"io.modelcontextprotocol/serverInfo": {"name": "mcp-server", "version": ""}}, contents=[ TextResourceContents( uri="resource://nocontext/test", mime_type="text/plain", text="Resource test works" ) - ] + ], ) ) @@ -1261,13 +1263,14 @@ def resource_custom_ctx(id: str, my_ctx: Context) -> str: async with Client(mcp) as client: result = await client.read_resource("resource://custom/123") - assert unstamped(result) == snapshot( + assert result == snapshot( ReadResourceResult( + _meta={"io.modelcontextprotocol/serverInfo": {"name": "mcp-server", "version": ""}}, contents=[ TextResourceContents( uri="resource://custom/123", mime_type="text/plain", text="Resource 123 with context" ) - ] + ], ) ) @@ -1393,8 +1396,9 @@ def fn(name: str, optional: str = "default") -> str: ... # pragma: no branch async with Client(mcp) as client: result = await client.list_prompts() - assert unstamped(result) == snapshot( + assert result == snapshot( ListPromptsResult( + _meta={"io.modelcontextprotocol/serverInfo": {"name": "mcp-server", "version": ""}}, prompts=[ Prompt( name="fn", @@ -1404,7 +1408,7 @@ def fn(name: str, optional: str = "default") -> str: ... # pragma: no branch PromptArgument(name="optional", required=False), ], ) - ] + ], ) ) @@ -1418,8 +1422,9 @@ def fn(name: str) -> str: async with Client(mcp) as client: result = await client.get_prompt("fn", {"name": "World"}) - assert unstamped(result) == snapshot( + assert result == snapshot( GetPromptResult( + _meta={"io.modelcontextprotocol/serverInfo": {"name": "mcp-server", "version": ""}}, description="", messages=[PromptMessage(role="user", content=TextContent(text="Hello, World!"))], ) @@ -1448,8 +1453,9 @@ def fn(name: str) -> str: async with Client(mcp) as client: result = await client.get_prompt("fn", {"name": "World"}) - assert unstamped(result) == snapshot( + assert result == snapshot( GetPromptResult( + _meta={"io.modelcontextprotocol/serverInfo": {"name": "mcp-server", "version": ""}}, description="This is the function docstring.", messages=[PromptMessage(role="user", content=TextContent(text="Hello, World!"))], ) @@ -1470,8 +1476,9 @@ def fn() -> Message: async with Client(mcp) as client: result = await client.get_prompt("fn") - assert unstamped(result) == snapshot( + assert result == snapshot( GetPromptResult( + _meta={"io.modelcontextprotocol/serverInfo": {"name": "mcp-server", "version": ""}}, description="", messages=[ PromptMessage( diff --git a/tests/server/test_apps.py b/tests/server/test_apps.py index b73a70debc..8d8bf74b74 100644 --- a/tests/server/test_apps.py +++ b/tests/server/test_apps.py @@ -28,7 +28,6 @@ from mcp.server.mcpserver import MCPServer from mcp.server.mcpserver.context import Context from mcp.server.mcpserver.resources import TextResource -from tests._stamp import unstamped pytestmark = pytest.mark.anyio @@ -61,15 +60,16 @@ async def test_add_html_resource_serves_ui_resource_at_app_mime_type() -> None: `text/html;profile=mcp-app`, observed through `read_resource`.""" async with Client(_clock_server()) as client: result = await client.read_resource("ui://clock/app.html") - assert unstamped(result) == snapshot( + assert result == snapshot( ReadResourceResult( + _meta={"io.modelcontextprotocol/serverInfo": {"name": "clock", "version": ""}}, contents=[ TextResourceContents( uri="ui://clock/app.html", mime_type="text/html;profile=mcp-app", text="Clock", ) - ] + ], ) ) assert isinstance(result.contents[0], TextResourceContents) diff --git a/tests/server/test_caching.py b/tests/server/test_caching.py index fa15c08c07..9095f6172f 100644 --- a/tests/server/test_caching.py +++ b/tests/server/test_caching.py @@ -18,7 +18,6 @@ from mcp import Client from mcp.server import CacheHint, MCPServer, Server, ServerRequestContext from mcp.server.caching import apply_cache_hint -from tests._stamp import unstamped pytestmark = pytest.mark.anyio @@ -148,8 +147,12 @@ async def read_resource(ctx: ServerRequestContext[Any], params: ReadResourceRequ async with Client(server) as client: result = await client.session.read_resource("res://x", allow_input_required=True) assert isinstance(result, InputRequiredResult) - assert unstamped(result).model_dump(by_alias=True, exclude_none=True) == snapshot( - {"resultType": "input_required", "requestState": "s1"} + assert result.model_dump(by_alias=True, exclude_none=True) == snapshot( + { + "_meta": {"io.modelcontextprotocol/serverInfo": {"name": "srv", "version": ""}}, + "resultType": "input_required", + "requestState": "s1", + } ) diff --git a/tests/server/test_streamable_http_modern.py b/tests/server/test_streamable_http_modern.py index 477fc2b07b..845c3f6436 100644 --- a/tests/server/test_streamable_http_modern.py +++ b/tests/server/test_streamable_http_modern.py @@ -215,9 +215,7 @@ async def greet(ctx: ServerRequestContext, params: PaginatedRequestParams) -> di response = await http.post("/mcp", json=body, headers={MCP_METHOD_HEADER: "custom/greet"}) assert response.status_code == 200 result = response.json()["result"] - stamp = result["_meta"].pop(SERVER_INFO_META_KEY) - assert "name" in stamp and "version" in stamp - assert result == {"_meta": {}, "ok": True} + assert result == {"_meta": {SERVER_INFO_META_KEY: {"name": "test", "version": ""}}, "ok": True} assert seen == [None] diff --git a/tests/test_examples.py b/tests/test_examples.py index 9c39ef456b..5b2997fdf1 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -5,7 +5,6 @@ # pyright: reportUnknownArgumentType=false # pyright: reportUnknownMemberType=false -from importlib.metadata import version from pathlib import Path import pytest @@ -19,12 +18,11 @@ 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. + The example servers set no explicit version, so the stamp's version is + empty; the snapshots stay about the behavior under test, not identity. """ assert result.meta is not None - assert result.meta[SERVER_INFO_META_KEY] == {"name": server_name, "version": version("mcp")} + assert result.meta[SERVER_INFO_META_KEY] == {"name": server_name, "version": ""} 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}) From d8b7d0c00d552905cb2313109f18cde8ca0f27f8 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:44:39 +0000 Subject: [PATCH 13/15] Server.version is a plain str defaulting to empty None and the empty string were never distinguished (the old fallback used a falsy check, and version, unlike title or description, can never be omitted from the wire), so the str|None type encoded the same state twice. The constructor takes version: str = "" on both server tiers, MCPServer.version returns str, and the two 'or empty' patches at the identity sites disappear. No-Verification-Needed: type-narrowing refactor, zero behavior change, full suite green --- src/mcp/server/lowlevel/server.py | 10 +++++----- src/mcp/server/mcpserver/server.py | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index 0eaf358f32..7bbdda1b17 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -131,7 +131,7 @@ def __init__( self, name: str, *, - version: str | None = None, + version: str = "", title: str | None = None, description: str | None = None, instructions: str | None = None, @@ -214,7 +214,7 @@ def __init__( self, name: str, *, - version: str | None = None, + version: str = "", title: str | None = None, description: str | None = None, instructions: str | None = None, @@ -306,7 +306,7 @@ def __init__( self, name: str, *, - version: str | None = None, + version: str = "", title: str | None = None, description: str | None = None, instructions: str | None = None, @@ -539,7 +539,7 @@ def create_initialization_options( """ return InitializationOptions( server_name=self.name, - server_version=self.version or "", + server_version=self.version, title=self.title, description=self.description, capabilities=self.get_capabilities( @@ -633,7 +633,7 @@ def server_info(self) -> types.Implementation: """ return types.Implementation( name=self.name, - version=self.version or "", + version=self.version, title=self.title, description=self.description, website_url=self.website_url, diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index ddd6c1fe65..c6a75ec925 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -165,7 +165,7 @@ def __init__( instructions: str | None = None, website_url: str | None = None, icons: list[Icon] | None = None, - version: str | None = None, + version: str = "", auth_server_provider: OAuthAuthorizationServerProvider[Any, Any, Any] | None = None, token_verifier: TokenVerifier | None = None, *, @@ -290,7 +290,7 @@ def icons(self) -> list[Icon] | None: return self._lowlevel_server.icons @property - def version(self) -> str | None: + def version(self) -> str: return self._lowlevel_server.version @property From b608b2337f4c23f89f6dd5bcb92b960f3861f310 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:52:12 +0000 Subject: [PATCH 14/15] everything-server: identify with an explicit version Unversioned servers now report an empty version string, and the conformance referee's identity check (sep-2575-server-identifies-in- result-meta) treats an empty version as an unidentified server. A conformance fixture should fully identify itself; use the example package's own version. No-Verification-Needed: one-line example fixture config; conformance CI is the verification --- .../servers/everything-server/mcp_everything_server/server.py | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/servers/everything-server/mcp_everything_server/server.py b/examples/servers/everything-server/mcp_everything_server/server.py index 9ac53c1fd9..4b03421f44 100644 --- a/examples/servers/everything-server/mcp_everything_server/server.py +++ b/examples/servers/everything-server/mcp_everything_server/server.py @@ -102,6 +102,7 @@ async def replay_events_after(self, last_event_id: EventId, send_callback: Event mcp = MCPServer( name="mcp-conformance-test-server", + version="0.1.0", request_state_security=RequestStateSecurity(keys=[_REQUEST_STATE_KEY]), ) From 3d5f6730033ba95f983ffa474d6e0a1b8270834b Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:59:03 +0000 Subject: [PATCH 15/15] Docs and docstrings catch up with recent contract changes - extensions.md interception section: call_next returns the handler's result, short-circuits are serialized and stamped like handler results, the validated params argument decides the tool invocation, and no middleware is involved either way. - whats-new client recap: server_info is Implementation | None. - Server.server_info and a transport-test helper docstring still described the removed package-version fallback. - _stamp_server_info docstring states the explicit-null posture (null reads as absent, mirroring clientInfo). No-Verification-Needed: docs and docstring-only edits --- docs/advanced/extensions.md | 17 ++++++++++++----- docs/whats-new.md | 2 +- src/mcp/server/lowlevel/server.py | 4 ++-- src/mcp/server/runner.py | 7 +++++-- .../transports/test_hosting_http_modern.py | 5 ++--- 5 files changed, 22 insertions(+), 13 deletions(-) diff --git a/docs/advanced/extensions.md b/docs/advanced/extensions.md index 0358ba5a83..715c5bbdc8 100644 --- a/docs/advanced/extensions.md +++ b/docs/advanced/extensions.md @@ -133,14 +133,21 @@ or veto a tool call: ``` * `params` is the validated `CallToolRequestParams`: you get `params.name` and - `params.arguments` without touching raw JSON. -* `call_next(ctx)` runs the rest of the chain. Return its result unchanged (observe), - return something else (replace), or raise an `MCPError` (refuse). + `params.arguments` without touching raw JSON. It is also what decides which + tool call runs: passing a rewritten context through `call_next` changes what + the handler observes on `ctx`, not the tool invocation. Wire-level request + rewriting belongs to [Middleware](middleware.md). +* `call_next(ctx)` runs the rest of the chain and returns the handler's result. + Return it unchanged (observe), return something else (replace), or raise an + `MCPError` (refuse). Whatever you return is serialized like any handler + result, including the 2026-era `serverInfo` identity stamp, so a + short-circuiting interceptor never produces an anonymous or off-schema + response. * With several extensions, interceptors nest in registration order: the first extension in `extensions=[...]` is outermost. * The default implementation is a pass-through, and a server whose extensions never - override this hook installs **no** middleware at all. You don't pay for what - you don't use. + override this hook keeps the bare `tools/call` handler untouched. You don't + pay for what you don't use. The hook wraps `tools/call` and nothing else. For every-message concerns, use [Middleware](middleware.md). That is what it is for. diff --git a/docs/whats-new.md b/docs/whats-new.md index cc358098f5..9c7a9be911 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -44,7 +44,7 @@ v1 handed you three nested layers: a transport context manager yielding raw stre --8<-- "docs_src/client/tutorial001.py" ``` -`Client` takes a server object (in memory, no transport: the testing story), a URL (Streamable HTTP), or any transport context manager such as `stdio_client(...)`. Entering `async with` connects and negotiates the protocol version, whichever era the server speaks; `client.server_info`, `client.server_capabilities`, and `client.protocol_version` are simply there afterwards. The sampling and elicitation callbacks you registered in v1 still work (their bodies see the same snake_case attribute rename as everything else on this page), they now also answer the 2026-style requests-inside-results (below), and they run concurrently instead of one at a time. `ClientSession` is still underneath for anyone who wants the low-level surface, and `client.session` hands it to you; it moved too (it runs on the new dispatcher engine, and some of its own signatures changed), so read the **[Migration Guide](migration.md#clientsession-now-runs-on-jsonrpcdispatcher-basesession-removed)** before you drop down. +`Client` takes a server object (in memory, no transport: the testing story), a URL (Streamable HTTP), or any transport context manager such as `stdio_client(...)`. Entering `async with` connects and negotiates the protocol version, whichever era the server speaks; `client.server_capabilities` and `client.protocol_version` are simply there afterwards, and `client.server_info` is too when the server identifies itself (it is `Implementation | None` now, since 2026-era identity is optional). The sampling and elicitation callbacks you registered in v1 still work (their bodies see the same snake_case attribute rename as everything else on this page), they now also answer the 2026-style requests-inside-results (below), and they run concurrently instead of one at a time. `ClientSession` is still underneath for anyone who wants the low-level surface, and `client.session` hands it to you; it moved too (it runs on the new dispatcher engine, and some of its own signatures changed), so read the **[Migration Guide](migration.md#clientsession-now-runs-on-jsonrpcdispatcher-basesession-removed)** before you drop down. **[The Client](client/index.md)** introduces it, **[Client transports](client/transports.md)** covers the three connection forms, **[Client callbacks](client/callbacks.md)** covers the callbacks themselves, and **[Testing](get-started/testing.md)** shows the in-memory pattern that replaces v1's `create_connected_server_and_client_session()` helper. diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index 7bbdda1b17..5088c92788 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -628,8 +628,8 @@ def get_capabilities( def server_info(self) -> types.Implementation: """The `serverInfo` block describing this implementation. - Derived from the constructor's identity fields. `version` falls back to - the installed `mcp` package version when not supplied explicitly. + Derived from the constructor's identity fields. An unversioned server + reports an empty `version`; the SDK never substitutes its own. """ return types.Implementation( name=self.name, diff --git a/src/mcp/server/runner.py b/src/mcp/server/runner.py index 53b76839b3..5045c8bf1c 100644 --- a/src/mcp/server/runner.py +++ b/src/mcp/server/runner.py @@ -382,8 +382,11 @@ def _serialize(self, method: str, version: str, result: HandlerResult) -> dict[s 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 + A handler-authored value wins; an explicit `null` reads as absent and + is stamped over, mirroring the request-side `clientInfo` posture (a + `null` is not a valid `Implementation`, so presence means a value). A + non-mapping `_meta` is the handler's to own, and handshake-era results + are never stamped. `result` is pipeline-owned (`_dump_result` copies dicts; the spec-method sieve re-dumps), but `_meta` may still be the handler's object, so the stamp replaces it rather than writing into it. `server_info_stamp` is a diff --git a/tests/interaction/transports/test_hosting_http_modern.py b/tests/interaction/transports/test_hosting_http_modern.py index c60437edd1..9ebaa71460 100644 --- a/tests/interaction/transports/test_hosting_http_modern.py +++ b/tests/interaction/transports/test_hosting_http_modern.py @@ -80,9 +80,8 @@ 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. - 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). + The explicit version gives the `_meta` serverInfo stamp every 2026 result + carries a non-empty value for the wire-level snapshots. """ async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: