Skip to content

Commit 7ffaae8

Browse files
committed
Cache compiled output-schema validators on ClientSession
`validate_tool_result` called one-shot `jsonschema.validate()` on every tool result, which rebuilt the validator class, re-ran `check_schema`, and re-crawled `$ref`s each time. Compile the validator once per schema instead, keyed on the identity of the cached schema object so a relisted tool can never reuse a validator built from its previous schema. Refs #3133.
1 parent 3a6f299 commit 7ffaae8

2 files changed

Lines changed: 96 additions & 8 deletions

File tree

src/mcp/client/session.py

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from functools import reduce
77
from operator import or_
88
from types import TracebackType
9-
from typing import Annotated, Any, Final, Literal, Protocol, cast, overload
9+
from typing import TYPE_CHECKING, Annotated, Any, Final, Literal, Protocol, cast, overload
1010

1111
import anyio
1212
import anyio.abc
@@ -56,6 +56,11 @@
5656
from mcp.shared.subscriptions import SUBSCRIPTION_ID_META_KEY, event_from_wire
5757
from mcp.shared.transport_context import TransportContext
5858

59+
if TYPE_CHECKING:
60+
# `jsonschema` is imported lazily inside `validate_tool_result`: pulling it (and its
61+
# `attrs`/`referencing` tree) in at module scope costs every client that never validates.
62+
from jsonschema.protocols import Validator
63+
5964
DEFAULT_CLIENT_INFO = types.Implementation(name="mcp", version="0.1.0")
6065
DISCOVER_TIMEOUT_SECONDS = 10.0
6166
_NOTIFICATION_QUEUE_SIZE: Final = 256
@@ -357,6 +362,9 @@ def __init__(
357362
self._logging_callback = logging_callback or _default_logging_callback
358363
self._message_handler = message_handler or _default_message_handler
359364
self._tool_output_schemas: dict[str, dict[str, Any] | None] = {}
365+
# Compiled output-schema validators, each paired with the schema object it was built
366+
# from so a re-listed tool can't be served a validator for its previous schema.
367+
self._tool_output_validators: dict[str, tuple[dict[str, Any], Validator]] = {}
360368
self._x_mcp_header_maps: dict[str, dict[tuple[str, ...], str]] = {}
361369
self._initialize_result: types.InitializeResult | None = None
362370
self._discover_result: types.DiscoverResult | None = None
@@ -1032,16 +1040,51 @@ async def validate_tool_result(self, name: str, result: types.CallToolResult) ->
10321040
logger.warning(f"Tool {name} not listed by server, cannot validate any structured content")
10331041

10341042
if output_schema is not None:
1035-
from jsonschema import SchemaError, ValidationError, validate
1043+
from jsonschema import exceptions as jsonschema_exceptions
10361044

10371045
if result.structured_content is None:
10381046
raise RuntimeError(f"Tool {name} has an output schema but did not return structured content")
1039-
try:
1040-
validate(result.structured_content, output_schema)
1041-
except ValidationError as e:
1042-
raise RuntimeError(f"Invalid structured content returned by tool {name}: {e}")
1043-
except SchemaError as e: # pragma: no cover
1044-
raise RuntimeError(f"Invalid schema for tool {name}: {e}") # pragma: no cover
1047+
validator = self._output_schema_validator(name, output_schema)
1048+
# `best_match` picks the same error the previous `jsonschema.validate()` call raised,
1049+
# so the message a caller sees is unchanged. It is untyped upstream.
1050+
errors = validator.iter_errors(result.structured_content)
1051+
error = cast(
1052+
"Exception | None",
1053+
jsonschema_exceptions.best_match(errors), # pyright: ignore[reportUnknownMemberType]
1054+
)
1055+
if error is not None:
1056+
raise RuntimeError(f"Invalid structured content returned by tool {name}: {error}")
1057+
1058+
def _output_schema_validator(self, name: str, output_schema: dict[str, Any]) -> Validator:
1059+
"""Compiled validator for `output_schema`, built once and reused across calls.
1060+
1061+
Compiling is ~60x the cost of validating, so a one-shot `jsonschema.validate()` per
1062+
result dominates `call_tool`. The cache holds the schema object it compiled alongside
1063+
the validator and only reuses it on an identity match: `_absorb_tool_listing` assigns a
1064+
freshly parsed schema object on every listing, so a server that changes a tool's schema
1065+
forces a rebuild rather than reusing the stale validator. Holding the reference also
1066+
keeps the allocator from recycling that identity behind our back.
1067+
1068+
Raises:
1069+
RuntimeError: The schema is not a valid JSON Schema. Raised on every call, since a
1070+
failed compile is never cached.
1071+
"""
1072+
from jsonschema import SchemaError
1073+
from jsonschema.validators import validator_for
1074+
1075+
cached = self._tool_output_validators.get(name)
1076+
if cached is not None and cached[0] is output_schema:
1077+
return cached[1]
1078+
1079+
validator_cls = validator_for(output_schema)
1080+
try:
1081+
validator_cls.check_schema(output_schema)
1082+
except SchemaError as e:
1083+
raise RuntimeError(f"Invalid schema for tool {name}: {e}")
1084+
# The `Validator` protocol declares `registry` without a default; concrete classes have one.
1085+
validator = cast("Callable[[dict[str, Any]], Validator]", validator_cls)(output_schema)
1086+
self._tool_output_validators[name] = (output_schema, validator)
1087+
return validator
10451088

10461089
async def list_prompts(self, *, params: types.PaginatedRequestParams | None = None) -> types.ListPromptsResult:
10471090
"""Send a prompts/list request.

tests/client/test_session_promotions.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,48 @@ 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+
with pytest.raises(RuntimeError, match="Invalid schema for tool t"):
78+
await client.session.validate_tool_result("t", result)
79+
80+
81+
@pytest.mark.anyio
82+
async def test_validate_tool_result_compiles_the_output_schema_once_per_tool() -> None:
83+
"""Regression guard: compiling dominates validating, so the validator must outlive one call."""
84+
server = _make_server({"type": "object", "properties": {"x": {"type": "integer"}}, "required": ["x"]})
85+
async with Client(server) as client:
86+
result = CallToolResult(content=[], structured_content={"x": 1})
87+
await client.session.validate_tool_result("t", result)
88+
compiled = client.session._tool_output_validators["t"]
89+
await client.session.validate_tool_result("t", result)
90+
assert client.session._tool_output_validators["t"] is compiled
91+
92+
93+
@pytest.mark.anyio
94+
async def test_validate_tool_result_recompiles_when_the_server_changes_the_schema() -> None:
95+
"""A relisted tool must not be validated against the schema it used to declare."""
96+
schemas = [
97+
{"type": "object", "properties": {"x": {"type": "integer"}}, "required": ["x"]},
98+
{"type": "object", "properties": {"x": {"type": "string"}}, "required": ["x"]},
99+
]
100+
101+
async def on_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
102+
return ListToolsResult(tools=[Tool(name="t", input_schema={"type": "object"}, output_schema=schemas.pop(0))])
103+
104+
server = Server("test-server", on_list_tools=on_list_tools)
105+
async with Client(server) as client:
106+
integer_result = CallToolResult(content=[], structured_content={"x": 1})
107+
await client.session.validate_tool_result("t", integer_result)
108+
109+
await client.session.list_tools()
110+
with pytest.raises(RuntimeError, match="Invalid structured content returned by tool t"):
111+
await client.session.validate_tool_result("t", integer_result)

0 commit comments

Comments
 (0)