Skip to content

Commit 8ddd540

Browse files
Aland BabanAland Baban
authored andcommitted
Cache compiled tool validators on v1.x lowlevel server
One-shot jsonschema.validate() re-selects the validator class, checks the schema against the meta-schema, and resolves $refs on every tools/call, then discards the result. Compile per-tool input/output validators once per tool listing and reuse them across calls, mirroring the client-side fix from #3134 (explicit empty referencing registry, SchemaError surfaced with tool context). Fixes #3676
1 parent 52e64dd commit 8ddd540

2 files changed

Lines changed: 250 additions & 2 deletions

File tree

‎src/mcp/server/lowlevel/server.py‎

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,10 @@ async def main():
7979
import anyio
8080
import jsonschema
8181
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
82+
from jsonschema.protocols import Validator
83+
from jsonschema.validators import validator_for
8284
from pydantic import AnyUrl
85+
from referencing import Registry
8386
from typing_extensions import TypeVar, deprecated
8487

8588
import mcp.types as types
@@ -158,6 +161,12 @@ def __init__(
158161
}
159162
self.notification_handlers: dict[type, Callable[..., Awaitable[None]]] = {}
160163
self._tool_cache: dict[str, types.Tool] = {}
164+
# Compiled input/output validators per tool name, built lazily once
165+
# per tool listing and reused across calls. Entries are identity-bound
166+
# to the cached Tool so re-listed schemas are recompiled; both caches
167+
# are cleared with the tool cache.
168+
self._tool_input_validators: dict[str, tuple[types.Tool, Validator]] = {}
169+
self._tool_output_validators: dict[str, tuple[types.Tool, Validator]] = {}
161170
self._experimental_handlers: ExperimentalHandlers | None = None
162171
logger.debug("Initializing server %r", name)
163172

@@ -460,6 +469,8 @@ async def handler(req: types.ListToolsRequest):
460469
# Old style returns list[Tool]
461470
# Clear and refresh the entire tool cache
462471
self._tool_cache.clear()
472+
self._tool_input_validators.clear()
473+
self._tool_output_validators.clear()
463474
for tool in result:
464475
validate_and_warn_tool_name(tool.name)
465476
self._tool_cache[tool.name] = tool
@@ -495,6 +506,47 @@ async def _get_cached_tool_definition(self, tool_name: str) -> types.Tool | None
495506

496507
return tool
497508

509+
@staticmethod
510+
def _compile_schema_validator(tool_name: str, schema: dict[str, Any]) -> Validator:
511+
"""Compile one JSON Schema, mirroring one-shot `jsonschema.validate`.
512+
513+
Selects the validator class, checks the schema against the
514+
meta-schema, and resolves `$ref`s a single time. Same construction as
515+
the client-side cached validators from #3134: an explicit empty
516+
registry keeps `$ref` resolution within the schema document and the
517+
bundled meta-schemas.
518+
"""
519+
validator_cls = validator_for(schema)
520+
try:
521+
validator_cls.check_schema(schema)
522+
except jsonschema.SchemaError as e:
523+
raise RuntimeError(f"Invalid schema for tool {tool_name}: {e}")
524+
return validator_cls(schema, registry=Registry())
525+
526+
def _get_compiled_input_validator(self, tool_name: str, tool: types.Tool) -> Validator:
527+
"""Compiled input validator for a cached tool, built once per listing.
528+
529+
The entry is identity-bound to the cached Tool: a re-listed tool
530+
object triggers a recompile.
531+
"""
532+
cached = self._tool_input_validators.get(tool_name)
533+
if cached is not None and cached[0] is tool:
534+
return cached[1]
535+
compiled = self._compile_schema_validator(tool_name, tool.inputSchema)
536+
self._tool_input_validators[tool_name] = (tool, compiled)
537+
return compiled
538+
539+
def _get_compiled_output_validator(self, tool_name: str, tool: types.Tool) -> Validator | None:
540+
"""Compiled output validator, or None when the tool has no outputSchema."""
541+
if tool.outputSchema is None:
542+
return None
543+
cached = self._tool_output_validators.get(tool_name)
544+
if cached is not None and cached[0] is tool:
545+
return cached[1]
546+
compiled = self._compile_schema_validator(tool_name, tool.outputSchema)
547+
self._tool_output_validators[tool_name] = (tool, compiled)
548+
return compiled
549+
498550
def call_tool(self, *, validate_input: bool = True):
499551
"""Register a tool call handler.
500552
@@ -533,7 +585,7 @@ async def handler(req: types.CallToolRequest):
533585
# input validation
534586
if validate_input and tool:
535587
try:
536-
jsonschema.validate(instance=arguments, schema=tool.inputSchema)
588+
self._get_compiled_input_validator(tool_name, tool).validate(arguments)
537589
except jsonschema.ValidationError as e:
538590
return self._make_error_result(f"Input validation error: {e.message}")
539591

@@ -569,8 +621,13 @@ async def handler(req: types.CallToolRequest):
569621
"Output validation error: outputSchema defined but no structured output returned"
570622
)
571623
else:
624+
output_validator = self._get_compiled_output_validator(tool_name, tool)
625+
if output_validator is None: # pragma: no cover - excluded by outputSchema check
626+
return self._make_error_result(
627+
"Output validation error: outputSchema defined but no validator available"
628+
)
572629
try:
573-
jsonschema.validate(instance=maybe_structured_content, schema=tool.outputSchema)
630+
output_validator.validate(maybe_structured_content)
574631
except jsonschema.ValidationError as e:
575632
return self._make_error_result(f"Output validation error: {e.message}")
576633

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
"""Compiled tool validators are built once per listing and reused (issue #3676).
2+
3+
One-shot `jsonschema.validate` re-selects the validator class, checks the
4+
schema against the meta-schema, and resolves `$ref`s on every `tools/call`.
5+
The server now compiles per-tool validators once and reuses them, mirroring
6+
the client-side fix from #3134.
7+
"""
8+
9+
from collections.abc import Awaitable, Callable
10+
from typing import Any
11+
12+
import pytest
13+
14+
import mcp.server.lowlevel.server as lowlevel_server
15+
from mcp.server import Server
16+
from mcp.types import CallToolRequest, CallToolRequestParams, CallToolResult, ListToolsRequest, TextContent, Tool
17+
18+
19+
def make_tool(extra_required: bool = False) -> Tool:
20+
required = ["name"] + (["nickname"] if extra_required else [])
21+
return Tool(
22+
name="greet",
23+
description="Greet someone",
24+
inputSchema={
25+
"type": "object",
26+
"properties": {
27+
"name": {"type": "string"},
28+
"nickname": {"type": "string"},
29+
},
30+
"required": required,
31+
"additionalProperties": False,
32+
},
33+
outputSchema={
34+
"type": "object",
35+
"properties": {"greeting": {"type": "string"}},
36+
"required": ["greeting"],
37+
"additionalProperties": False,
38+
},
39+
)
40+
41+
42+
def make_server(
43+
tools: Callable[[], list[Tool]],
44+
handler: Callable[[str, dict[str, Any]], Awaitable[Any]],
45+
) -> Server:
46+
server = Server("test")
47+
48+
@server.list_tools()
49+
async def list_tools() -> list[Tool]:
50+
return tools()
51+
52+
@server.call_tool()
53+
async def call_tool(name: str, arguments: dict[str, Any]) -> Any:
54+
return await handler(name, arguments)
55+
56+
return server
57+
58+
59+
def call_request(name: str, arguments: dict[str, Any]) -> CallToolRequest:
60+
return CallToolRequest(
61+
method="tools/call",
62+
params=CallToolRequestParams(name=name, arguments=arguments),
63+
)
64+
65+
66+
async def greet_handler(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
67+
assert name == "greet"
68+
return {"greeting": f"Hello, {arguments['name']}!"}
69+
70+
71+
async def call(server: Server, arguments: dict[str, Any]) -> CallToolResult:
72+
handler = server.request_handlers[CallToolRequest]
73+
result = await handler(call_request("greet", arguments))
74+
assert result is not None
75+
assert isinstance(result.root, CallToolResult)
76+
return result.root
77+
78+
79+
@pytest.mark.anyio
80+
async def test_validators_compiled_once_and_reused(monkeypatch: pytest.MonkeyPatch) -> None:
81+
"""N calls compile exactly one input and one output validator."""
82+
calls = 0
83+
real_validator_for = lowlevel_server.validator_for
84+
85+
def counting_validator_for(schema: Any, *args: Any, **kwargs: Any) -> Any:
86+
nonlocal calls
87+
calls += 1
88+
return real_validator_for(schema, *args, **kwargs)
89+
90+
monkeypatch.setattr(lowlevel_server, "validator_for", counting_validator_for)
91+
92+
server = make_server(lambda: [make_tool()], greet_handler)
93+
for _ in range(3):
94+
res = await call(server, {"name": "Ada"})
95+
assert not res.isError
96+
assert calls == 2 # one input-schema compile + one output-schema compile
97+
98+
99+
@pytest.mark.anyio
100+
async def test_invalid_input_message_preserved() -> None:
101+
"""Same error contract as one-shot validation: isError + prefix."""
102+
server = make_server(lambda: [make_tool()], greet_handler)
103+
res = await call(server, {})
104+
assert res.isError
105+
first = res.content[0]
106+
assert isinstance(first, TextContent)
107+
assert first.text.startswith("Input validation error: ")
108+
assert "'name' is a required property" in first.text
109+
110+
111+
@pytest.mark.anyio
112+
async def test_invalid_output_message_preserved() -> None:
113+
"""Structured output violating outputSchema keeps the error contract."""
114+
115+
async def bad_handler(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
116+
return {"wrong": "shape"}
117+
118+
server = make_server(lambda: [make_tool()], bad_handler)
119+
res = await call(server, {"name": "Ada"})
120+
assert res.isError
121+
first = res.content[0]
122+
assert isinstance(first, TextContent)
123+
assert first.text.startswith("Output validation error: ")
124+
125+
126+
@pytest.mark.anyio
127+
async def test_relisted_schema_is_recompiled(monkeypatch: pytest.MonkeyPatch) -> None:
128+
"""A re-listed tool object triggers exactly one recompile and new rules apply."""
129+
calls = 0
130+
real_validator_for = lowlevel_server.validator_for
131+
132+
def counting_validator_for(schema: Any, *args: Any, **kwargs: Any) -> Any:
133+
nonlocal calls
134+
calls += 1
135+
return real_validator_for(schema, *args, **kwargs)
136+
137+
monkeypatch.setattr(lowlevel_server, "validator_for", counting_validator_for)
138+
139+
current = {"tool": make_tool()}
140+
server = make_server(lambda: [current["tool"]], greet_handler)
141+
142+
res = await call(server, {"name": "Ada"})
143+
assert not res.isError
144+
assert calls == 2
145+
146+
# Re-list with a stricter schema: refreshed tool objects must recompile.
147+
# The failing call recompiles only the input validator (lazy output
148+
# compile is skipped on the error path).
149+
current["tool"] = make_tool(extra_required=True)
150+
await server.request_handlers[ListToolsRequest](None)
151+
res = await call(server, {"name": "Ada"})
152+
assert res.isError
153+
first = res.content[0]
154+
assert isinstance(first, TextContent)
155+
assert "'nickname' is a required property" in first.text
156+
assert calls == 3
157+
158+
# A valid call under the new schema compiles the output validator too.
159+
res = await call(server, {"name": "Ada", "nickname": "A"})
160+
assert not res.isError
161+
assert calls == 4
162+
163+
164+
@pytest.mark.anyio
165+
async def test_validate_input_false_skips_input_compile(monkeypatch: pytest.MonkeyPatch) -> None:
166+
"""validate_input=False keeps skipping input validation entirely."""
167+
calls = 0
168+
real_validator_for = lowlevel_server.validator_for
169+
170+
def counting_validator_for(schema: Any, *args: Any, **kwargs: Any) -> Any:
171+
nonlocal calls
172+
calls += 1
173+
return real_validator_for(schema, *args, **kwargs)
174+
175+
monkeypatch.setattr(lowlevel_server, "validator_for", counting_validator_for)
176+
177+
server = Server("test")
178+
179+
@server.list_tools()
180+
async def list_tools() -> list[Tool]:
181+
return [make_tool()]
182+
183+
@server.call_tool(validate_input=False)
184+
async def call_tool(name: str, arguments: dict[str, Any]) -> Any:
185+
assert name == "greet"
186+
return {"greeting": "Hello!"}
187+
188+
# Invalid input passes through; only the output schema compiles.
189+
res = await call(server, {})
190+
assert not res.isError
191+
assert calls == 1

0 commit comments

Comments
 (0)