|
6 | 6 | from functools import reduce |
7 | 7 | from operator import or_ |
8 | 8 | 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 |
10 | 10 |
|
11 | 11 | import anyio |
12 | 12 | import anyio.abc |
|
56 | 56 | from mcp.shared.subscriptions import SUBSCRIPTION_ID_META_KEY, event_from_wire |
57 | 57 | from mcp.shared.transport_context import TransportContext |
58 | 58 |
|
| 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 | + |
59 | 64 | DEFAULT_CLIENT_INFO = types.Implementation(name="mcp", version="0.1.0") |
60 | 65 | DISCOVER_TIMEOUT_SECONDS = 10.0 |
61 | 66 | _NOTIFICATION_QUEUE_SIZE: Final = 256 |
@@ -357,6 +362,9 @@ def __init__( |
357 | 362 | self._logging_callback = logging_callback or _default_logging_callback |
358 | 363 | self._message_handler = message_handler or _default_message_handler |
359 | 364 | 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]] = {} |
360 | 368 | self._x_mcp_header_maps: dict[str, dict[tuple[str, ...], str]] = {} |
361 | 369 | self._initialize_result: types.InitializeResult | None = None |
362 | 370 | self._discover_result: types.DiscoverResult | None = None |
@@ -1032,16 +1040,51 @@ async def validate_tool_result(self, name: str, result: types.CallToolResult) -> |
1032 | 1040 | logger.warning(f"Tool {name} not listed by server, cannot validate any structured content") |
1033 | 1041 |
|
1034 | 1042 | if output_schema is not None: |
1035 | | - from jsonschema import SchemaError, ValidationError, validate |
| 1043 | + from jsonschema import exceptions as jsonschema_exceptions |
1036 | 1044 |
|
1037 | 1045 | if result.structured_content is None: |
1038 | 1046 | 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 |
1045 | 1088 |
|
1046 | 1089 | async def list_prompts(self, *, params: types.PaginatedRequestParams | None = None) -> types.ListPromptsResult: |
1047 | 1090 | """Send a prompts/list request. |
|
0 commit comments