11from __future__ import annotations
22
3+ import json
34import logging
45from collections .abc import Callable , Mapping , Sequence
56from dataclasses import dataclass
67from functools import reduce
78from operator import or_
89from types import TracebackType
9- from typing import Annotated , Any , Final , Literal , Protocol , TypeAlias , cast , overload
10+ from typing import TYPE_CHECKING , Annotated , Any , Final , Literal , Protocol , TypeAlias , cast , overload
1011
1112import anyio
1213import anyio .abc
5657from mcp .shared .subscriptions import SUBSCRIPTION_ID_META_KEY , event_from_wire
5758from mcp .shared .transport_context import TransportContext
5859
60+ if TYPE_CHECKING :
61+ # `jsonschema` is imported lazily inside `validate_tool_result`: pulling it (and its
62+ # `attrs`/`referencing` tree) in at module scope costs every client that never validates.
63+ from jsonschema .protocols import Validator
64+
5965DEFAULT_CLIENT_INFO = types .Implementation (name = "mcp" , version = "0.1.0" )
6066DISCOVER_TIMEOUT_SECONDS = 10.0
6167_NOTIFICATION_QUEUE_SIZE : Final = 256
@@ -70,6 +76,17 @@ def _clamp_inbound_ttl(raw: dict[str, Any]) -> None:
7076 raw ["ttlMs" ] = 0
7177
7278
79+ def _same_schema (a : dict [str , Any ] | None , b : dict [str , Any ] | None ) -> bool :
80+ """JSON equality for two output schemas.
81+
82+ Python `==` is not JSON equality: it conflates `True`/`1` and `False`/`0`, which JSON
83+ Schema keeps distinct (`const: true` vs `const: 1`). Canonical serialization compares as
84+ JSON does; where it is stricter (`1` vs `1.0`), erring toward "changed" only costs a
85+ recompile, never a stale validator.
86+ """
87+ return json .dumps (a , sort_keys = True ) == json .dumps (b , sort_keys = True )
88+
89+
7390def _preconnect_stamp (data : dict [str , Any ], opts : CallOptions ) -> None :
7491 # initialize/discover forbid cancellation; other pre-handshake requests (lowlevel
7592 # ClientSession callers may skip the handshake entirely) keep the courtesy cancel.
@@ -378,6 +395,9 @@ def __init__(
378395 self ._logging_callback = logging_callback or _default_logging_callback
379396 self ._message_handler = message_handler or _default_message_handler
380397 self ._tool_output_schemas : dict [str , dict [str , Any ] | None ] = {}
398+ # Compiled output-schema validators, derived from `_tool_output_schemas` and owned by
399+ # `_absorb_tool_listing`, which evicts a tool's entry whenever its schema changes.
400+ self ._tool_output_validators : dict [str , Validator ] = {}
381401 self ._x_mcp_header_maps : dict [str , dict [tuple [str , ...], str ]] = {}
382402 self ._initialize_result : types .InitializeResult | None = None
383403 self ._discover_result : types .DiscoverResult | None = None
@@ -1062,16 +1082,49 @@ async def validate_tool_result(self, name: str, result: types.CallToolResult) ->
10621082 logger .warning (f"Tool { name } not listed by server, cannot validate any structured content" )
10631083
10641084 if output_schema is not None :
1065- from jsonschema import SchemaError , ValidationError , validate
1085+ from jsonschema import exceptions as jsonschema_exceptions
10661086
10671087 if result .structured_content is None :
10681088 raise RuntimeError (f"Tool { name } has an output schema but did not return structured content" )
1069- try :
1070- validate (result .structured_content , output_schema )
1071- except ValidationError as e :
1072- raise RuntimeError (f"Invalid structured content returned by tool { name } : { e } " )
1073- except SchemaError as e : # pragma: no cover
1074- raise RuntimeError (f"Invalid schema for tool { name } : { e } " ) # pragma: no cover
1089+ validator = self ._output_schema_validator (name , output_schema )
1090+ # `best_match` picks the same error the previous `jsonschema.validate()` call raised,
1091+ # so the message a caller sees is unchanged. It is untyped upstream.
1092+ errors = validator .iter_errors (result .structured_content )
1093+ error = cast (
1094+ "Exception | None" ,
1095+ jsonschema_exceptions .best_match (errors ), # pyright: ignore[reportUnknownMemberType]
1096+ )
1097+ if error is not None :
1098+ raise RuntimeError (f"Invalid structured content returned by tool { name } : { error } " ) from error
1099+
1100+ def _output_schema_validator (self , name : str , output_schema : dict [str , Any ]) -> Validator :
1101+ """Compiled validator for the tool's cached output schema, built once per schema value.
1102+
1103+ Compiling is ~60x the cost of validating, so a one-shot `jsonschema.validate()` per
1104+ result dominates `call_tool`; the compiled validator is cached instead. It stays valid
1105+ because `_absorb_tool_listing` evicts a tool's validator whenever it absorbs a different
1106+ schema for that tool, so a cached entry always matches `output_schema`.
1107+
1108+ Raises:
1109+ RuntimeError: The schema is not a valid JSON Schema. Raised on every call, since a
1110+ failed compile is never cached.
1111+ """
1112+ from jsonschema import SchemaError
1113+ from jsonschema .validators import validator_for
1114+
1115+ if (validator := self ._tool_output_validators .get (name )) is not None :
1116+ return validator
1117+
1118+ validator_cls = validator_for (output_schema )
1119+ try :
1120+ validator_cls .check_schema (output_schema )
1121+ except SchemaError as e :
1122+ raise RuntimeError (f"Invalid schema for tool { name } : { e } " )
1123+ # jsonschema ships no `py.typed`, so pyright reads typeshed's stub, which declares
1124+ # `registry` as required (concrete validators default it); cast to a schema-only ctor.
1125+ validator = cast ("Callable[[dict[str, Any]], Validator]" , validator_cls )(output_schema )
1126+ self ._tool_output_validators [name ] = validator
1127+ return validator
10751128
10761129 async def list_prompts (self , * , params : types .PaginatedRequestParams | None = None ) -> types .ListPromptsResult :
10771130 """Send a prompts/list request.
@@ -1200,8 +1253,14 @@ def _absorb_tool_listing(self, result: types.ListToolsResult, *, complete: bool)
12001253 kept .append (tool )
12011254 result .tools = kept
12021255
1203- # Cache tool output schemas for future validation; cursor pages only ever add.
1256+ # Cache tool output schemas for future validation; cursor pages only ever add. A
1257+ # changed schema evicts its compiled validator; an unchanged one (a re-listing, or the
1258+ # response cache re-absorbing a served hit) keeps it. Only validated tools pay the check.
12041259 for tool in result .tools :
1260+ if tool .name in self ._tool_output_validators and not _same_schema (
1261+ self ._tool_output_schemas .get (tool .name ), tool .output_schema
1262+ ):
1263+ del self ._tool_output_validators [tool .name ]
12051264 self ._tool_output_schemas [tool .name ] = tool .output_schema
12061265
12071266 if complete :
@@ -1210,6 +1269,7 @@ def _absorb_tool_listing(self, result: types.ListToolsResult, *, complete: bool)
12101269 names = {tool .name for tool in result .tools }
12111270 self ._x_mcp_header_maps = {k : v for k , v in self ._x_mcp_header_maps .items () if k in names }
12121271 self ._tool_output_schemas = {k : v for k , v in self ._tool_output_schemas .items () if k in names }
1272+ self ._tool_output_validators = {k : v for k , v in self ._tool_output_validators .items () if k in names }
12131273
12141274 return result
12151275
0 commit comments