Skip to content

Commit f4cd962

Browse files
committed
feat(server): surface structured validation errors in CallToolResult._meta
Fixes #3351. When a tool call fails schema validation, the low-level Server previously returned only the interpolated free-text message, forcing clients to regex-match brittle wording to classify failures. This change forwards jsonschema.ValidationError's stable machine-readable fields (validator, validator_value, schema_path, json_path, message) into CallToolResult._meta under the MCP-namespaced key 'io.modelcontextprotocol/schema-validation-error', for both input- and output-schema failures. The human-readable message and isError=True stay unchanged, so existing clients keep working. - _make_error_result now accepts optional structured_data - new _validation_error_data helper extracts jsonschema fields - new _jsonable helper coerces non-JSON schema fragments (deques, sets, callables) to JSON-safe values before they cross the transport - added tests covering required/type/enum classification via _meta
1 parent b222713 commit f4cd962

2 files changed

Lines changed: 142 additions & 4 deletions

File tree

src/mcp/server/lowlevel/server.py

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,23 @@ async def main():
109109
request_ctx: contextvars.ContextVar[RequestContext[ServerSession, Any, Any]] = contextvars.ContextVar("request_ctx")
110110

111111

112+
def _jsonable(value: Any) -> Any:
113+
"""Best-effort coerce a `jsonschema` schema fragment to JSON-serializable form.
114+
115+
`ValidationError.validator_value` and `schema_path` elements can be
116+
arbitrary Python objects (deques, sets, custom validators). We keep
117+
JSON-native values as-is and fall back to `str(...)` for anything else
118+
so the result can safely cross the JSON-RPC transport in `_meta`.
119+
"""
120+
if isinstance(value, (str, int, float, bool)) or value is None:
121+
return value
122+
if isinstance(value, dict):
123+
return {str(k): _jsonable(v) for k, v in value.items()}
124+
if isinstance(value, (list, tuple, set, frozenset)):
125+
return [_jsonable(v) for v in value]
126+
return str(value)
127+
128+
112129
class NotificationOptions:
113130
def __init__(
114131
self,
@@ -470,15 +487,49 @@ async def handler(req: types.ListToolsRequest):
470487

471488
return decorator
472489

473-
def _make_error_result(self, error_message: str) -> types.ServerResult:
474-
"""Create a ServerResult with an error CallToolResult."""
490+
def _make_error_result(
491+
self,
492+
error_message: str,
493+
*,
494+
structured_data: dict[str, Any] | None = None,
495+
) -> types.ServerResult:
496+
"""Create a ServerResult with an error CallToolResult.
497+
498+
When `structured_data` is provided, it is exposed on the result's `_meta`
499+
field under `io.modelcontextprotocol/schema-validation-error`, so clients
500+
can programmatically distinguish failure kinds (e.g. `required` vs
501+
`type` vs `enum`) without regex-matching the free-text message.
502+
"""
503+
meta: dict[str, Any] | None = None
504+
if structured_data is not None:
505+
meta = {"io.modelcontextprotocol/schema-validation-error": structured_data}
475506
return types.ServerResult(
476507
types.CallToolResult(
477508
content=[types.TextContent(type="text", text=error_message)],
478509
isError=True,
510+
**({"_meta": meta} if meta is not None else {}),
479511
)
480512
)
481513

514+
@staticmethod
515+
def _validation_error_data(
516+
e: jsonschema.ValidationError, *, kind: str
517+
) -> dict[str, Any]:
518+
"""Extract machine-readable fields from a `jsonschema.ValidationError`.
519+
520+
`kind` distinguishes input-schema vs output-schema failures, and the
521+
remaining fields mirror `jsonschema.ValidationError`'s stable
522+
attributes so a client can classify failures without parsing prose.
523+
"""
524+
return {
525+
"kind": kind,
526+
"validator": e.validator,
527+
"validator_value": _jsonable(e.validator_value),
528+
"schema_path": [_jsonable(p) for p in e.schema_path],
529+
"json_path": e.json_path,
530+
"message": e.message,
531+
}
532+
482533
async def _get_cached_tool_definition(self, tool_name: str) -> types.Tool | None:
483534
"""Get tool definition from cache, refreshing if necessary.
484535
@@ -535,7 +586,10 @@ async def handler(req: types.CallToolRequest):
535586
try:
536587
jsonschema.validate(instance=arguments, schema=tool.inputSchema)
537588
except jsonschema.ValidationError as e:
538-
return self._make_error_result(f"Input validation error: {e.message}")
589+
return self._make_error_result(
590+
f"Input validation error: {e.message}",
591+
structured_data=self._validation_error_data(e, kind="input"),
592+
)
539593

540594
# tool call
541595
results = await func(tool_name, arguments)
@@ -572,7 +626,10 @@ async def handler(req: types.CallToolRequest):
572626
try:
573627
jsonschema.validate(instance=maybe_structured_content, schema=tool.outputSchema)
574628
except jsonschema.ValidationError as e:
575-
return self._make_error_result(f"Output validation error: {e.message}")
629+
return self._make_error_result(
630+
f"Output validation error: {e.message}",
631+
structured_data=self._validation_error_data(e, kind="output"),
632+
)
576633

577634
# result
578635
return types.ServerResult(

tests/server/test_lowlevel_input_validation.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,3 +309,84 @@ async def test_callback(client_session: ClientSession) -> CallToolResult:
309309
assert any(
310310
"Tool 'unknown_tool' not listed, no validation will be performed" in record.message for record in caplog.records
311311
)
312+
313+
314+
_META_KEY = "io.modelcontextprotocol/schema-validation-error"
315+
316+
317+
@pytest.mark.anyio
318+
async def test_input_validation_error_carries_structured_meta():
319+
"""Missing-required and type-mismatch failures both attach structured
320+
`_meta["io.modelcontextprotocol/schema-validation-error"]` with the
321+
`jsonschema` validator name and JSON path, so clients can classify
322+
them without regexing the free-text message."""
323+
324+
async def call_tool_handler(name: str, arguments: dict[str, Any]) -> list[TextContent]: # pragma: no cover
325+
raise RuntimeError("Should not reach here")
326+
327+
async def missing_required(client_session: ClientSession) -> CallToolResult:
328+
return await client_session.call_tool("add", {"a": 5}) # missing 'b'
329+
330+
result_missing = await run_tool_test([create_add_tool()], call_tool_handler, missing_required)
331+
332+
assert result_missing is not None
333+
assert result_missing.isError
334+
assert result_missing.meta is not None
335+
payload = result_missing.meta[_META_KEY]
336+
assert payload["kind"] == "input"
337+
assert payload["validator"] == "required"
338+
assert payload["json_path"] == "$"
339+
assert "b" in payload["message"]
340+
341+
async def wrong_type(client_session: ClientSession) -> CallToolResult:
342+
return await client_session.call_tool("add", {"a": "five", "b": 3}) # 'a' should be number
343+
344+
result_wrong = await run_tool_test([create_add_tool()], call_tool_handler, wrong_type)
345+
346+
assert result_wrong is not None
347+
assert result_wrong.isError
348+
assert result_wrong.meta is not None
349+
payload = result_wrong.meta[_META_KEY]
350+
assert payload["kind"] == "input"
351+
assert payload["validator"] == "type"
352+
assert payload["validator_value"] == "number"
353+
assert payload["json_path"] == "$.a"
354+
355+
356+
@pytest.mark.anyio
357+
async def test_enum_validation_error_carries_structured_meta():
358+
"""Enum mismatches surface `validator="enum"` with the allowed values
359+
intact in `validator_value`, so a client can render the choice list
360+
without re-fetching the tool schema."""
361+
tools = [
362+
Tool(
363+
name="greet",
364+
description="Greet someone",
365+
inputSchema={
366+
"type": "object",
367+
"properties": {
368+
"name": {"type": "string"},
369+
"title": {"type": "string", "enum": ["Mr", "Ms", "Dr"]},
370+
},
371+
"required": ["name"],
372+
},
373+
)
374+
]
375+
376+
async def call_tool_handler(name: str, arguments: dict[str, Any]) -> list[TextContent]: # pragma: no cover
377+
raise RuntimeError("Should not reach here")
378+
379+
async def test_callback(client_session: ClientSession) -> CallToolResult:
380+
return await client_session.call_tool("greet", {"name": "Smith", "title": "Prof"})
381+
382+
result = await run_tool_test(tools, call_tool_handler, test_callback)
383+
384+
assert result is not None
385+
assert result.isError
386+
assert result.meta is not None
387+
payload = result.meta[_META_KEY]
388+
assert payload["kind"] == "input"
389+
assert payload["validator"] == "enum"
390+
assert payload["validator_value"] == ["Mr", "Ms", "Dr"]
391+
assert payload["json_path"] == "$.title"
392+

0 commit comments

Comments
 (0)