Skip to content

Commit f599cdf

Browse files
jlowinmaxisbey
andauthored
Cache compiled output-schema validators on ClientSession (#3134)
Co-authored-by: Max Isbey <224885523+maxisbey@users.noreply.github.com>
1 parent 11934c9 commit f599cdf

3 files changed

Lines changed: 134 additions & 9 deletions

File tree

src/mcp/client/session.py

Lines changed: 69 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
from __future__ import annotations
22

3+
import json
34
import logging
45
from collections.abc import Callable, Mapping, Sequence
56
from dataclasses import dataclass
67
from functools import reduce
78
from operator import or_
89
from 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

1112
import anyio
1213
import anyio.abc
@@ -56,6 +57,11 @@
5657
from mcp.shared.subscriptions import SUBSCRIPTION_ID_META_KEY, event_from_wire
5758
from 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+
5965
DEFAULT_CLIENT_INFO = types.Implementation(name="mcp", version="0.1.0")
6066
DISCOVER_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+
7390
def _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

tests/client/test_client.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -657,12 +657,16 @@ async def test_a_complete_listing_prunes_per_tool_state_for_tools_it_no_longer_c
657657
with anyio.fail_after(5):
658658
async with Client(server) as client:
659659
await client.session.list_tools()
660+
# Compile the retired tool's output-schema validator so its eviction is observable.
661+
await client.session.validate_tool_result("retired", CallToolResult(content=[], structured_content={}))
660662
assert set(client.session._x_mcp_header_maps) == {"retired", "survivor"}
661663
assert set(client.session._tool_output_schemas) == {"retired", "survivor"}
664+
assert set(client.session._tool_output_validators) == {"retired"}
662665

663666
await client.session.list_tools()
664667
assert set(client.session._x_mcp_header_maps) == {"survivor"}
665668
assert set(client.session._tool_output_schemas) == {"survivor"}
669+
assert client.session._tool_output_validators == {}
666670

667671

668672
async def test_a_complete_listing_prunes_output_schemas_on_a_legacy_session_too() -> None:

tests/client/test_session_promotions.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,64 @@ async def test_validate_tool_result_raises_on_schema_mismatch() -> None:
6464
# Stable SDK prefix only: the message tail is jsonschema text that shifts with the dependency.
6565
with pytest.raises(RuntimeError, match="Invalid structured content returned by tool t"):
6666
await client.session.validate_tool_result("t", CallToolResult(content=[], structured_content={"x": "no"}))
67+
68+
69+
@pytest.mark.anyio
70+
async def test_validate_tool_result_raises_on_an_unusable_output_schema() -> None:
71+
"""A schema that isn't valid JSON Schema is reported as such, on every call."""
72+
server = _make_server({"type": "not-a-json-schema-type"})
73+
async with Client(server) as client:
74+
result = CallToolResult(content=[], structured_content={"x": 1})
75+
for _ in range(2):
76+
# Compiling is never cached on failure, so the second call raises like the first.
77+
# Stable SDK prefix only: the message tail is jsonschema text that shifts with the dependency.
78+
with pytest.raises(RuntimeError, match="Invalid schema for tool t"):
79+
await client.session.validate_tool_result("t", result)
80+
81+
82+
@pytest.mark.anyio
83+
async def test_validate_tool_result_compiles_the_output_schema_once_per_tool() -> None:
84+
"""Regression guard: compiling dominates validating, so the validator must outlive one call."""
85+
server = _make_server({"type": "object", "properties": {"x": {"type": "integer"}}, "required": ["x"]})
86+
async with Client(server) as client:
87+
result = CallToolResult(content=[], structured_content={"x": 1})
88+
await client.session.validate_tool_result("t", result)
89+
compiled = client.session._tool_output_validators["t"]
90+
await client.session.validate_tool_result("t", result)
91+
assert client.session._tool_output_validators["t"] is compiled
92+
93+
94+
@pytest.mark.anyio
95+
async def test_validate_tool_result_keeps_the_validator_across_a_relisting_of_the_same_schema() -> None:
96+
"""SDK-defined: an unchanged schema on a re-listing (or a re-absorbed cache hit) keeps its
97+
compiled validator, so relisting between calls doesn't reintroduce the per-call compile."""
98+
server = _make_server({"type": "object", "properties": {"x": {"type": "integer"}}, "required": ["x"]})
99+
async with Client(server) as client:
100+
result = CallToolResult(content=[], structured_content={"x": 1})
101+
await client.session.validate_tool_result("t", result)
102+
compiled = client.session._tool_output_validators["t"]
103+
104+
await client.session.list_tools() # a second listing carrying an equal schema
105+
await client.session.validate_tool_result("t", result)
106+
assert client.session._tool_output_validators["t"] is compiled
107+
108+
109+
@pytest.mark.anyio
110+
async def test_validate_tool_result_recompiles_when_the_server_changes_the_schema() -> None:
111+
"""A relisted tool must not be validated against the schema it used to declare."""
112+
schemas = [
113+
{"type": "object", "properties": {"x": {"type": "integer"}}, "required": ["x"]},
114+
{"type": "object", "properties": {"x": {"type": "string"}}, "required": ["x"]},
115+
]
116+
117+
async def on_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
118+
return ListToolsResult(tools=[Tool(name="t", input_schema={"type": "object"}, output_schema=schemas.pop(0))])
119+
120+
server = Server("test-server", on_list_tools=on_list_tools)
121+
async with Client(server) as client:
122+
integer_result = CallToolResult(content=[], structured_content={"x": 1})
123+
await client.session.validate_tool_result("t", integer_result)
124+
125+
await client.session.list_tools()
126+
with pytest.raises(RuntimeError, match="Invalid structured content returned by tool t"):
127+
await client.session.validate_tool_result("t", integer_result)

0 commit comments

Comments
 (0)