Skip to content

Commit fff4cf9

Browse files
committed
Add outputSchema validation to lowlevel server
- Refactor code to extract _get_tool_definition helper and simplify validation - Update call_tool to support three return types: content only, dict only, or both - Add outputSchema validation that checks structured content matches the schema - Serialize dict-only results to JSON text content - Factor error result construction into _make_error_result helper - Add comprehensive tests for all output validation scenarios The server now validates tool outputs against their defined schemas, providing better error messages and ensuring tool responses match their contracts.
1 parent 4e9f1e2 commit fff4cf9

File tree

4 files changed

+515
-36
lines changed

4 files changed

+515
-36
lines changed

src/mcp/server/lowlevel/server.py

Lines changed: 74 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -68,11 +68,12 @@ async def main():
6868
from __future__ import annotations as _annotations
6969

7070
import contextvars
71+
import json
7172
import logging
7273
import warnings
7374
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable
7475
from contextlib import AbstractAsyncContextManager, AsyncExitStack, asynccontextmanager
75-
from typing import Any, Generic
76+
from typing import Any, Generic, cast
7677

7778
import anyio
7879
import jsonschema
@@ -386,36 +387,48 @@ async def handler(_: Any):
386387

387388
return decorator
388389

389-
async def _validate_tool_arguments(self, tool_name: str, arguments: dict[str, Any]) -> str | None:
390-
"""Validate tool arguments against inputSchema.
390+
def _make_error_result(self, error_message: str) -> types.ServerResult:
391+
"""Create a ServerResult with an error CallToolResult."""
392+
return types.ServerResult(
393+
types.CallToolResult(
394+
content=[types.TextContent(type="text", text=error_message)],
395+
isError=True,
396+
)
397+
)
398+
399+
async def _get_cached_tool_definition(self, tool_name: str) -> types.Tool | None:
400+
"""Get tool definition from cache, refreshing if necessary.
391401
392-
Returns None if validation passes, or an error message if validation fails.
402+
Returns the Tool object if found, None otherwise.
393403
"""
394-
# Check if tool is in cache
395404
if tool_name not in self._tool_cache:
396-
# Try to refresh the cache by calling list_tools
397405
if types.ListToolsRequest in self.request_handlers:
398406
logger.debug("Tool cache miss for %s, refreshing cache", tool_name)
399407
await self.request_handlers[types.ListToolsRequest](None)
400408

401-
# Check again after potential refresh
402-
if tool_name in self._tool_cache:
403-
tool = self._tool_cache[tool_name]
404-
try:
405-
# Validate arguments against inputSchema
406-
jsonschema.validate(instance=arguments, schema=tool.inputSchema)
407-
return None
408-
except jsonschema.ValidationError as e:
409-
return f"Input validation error: {e.message}"
410-
else:
411-
logger.warning("Tool '%s' not found in cache, validation will not be performed", tool_name)
412-
return None
409+
tool = self._tool_cache.get(tool_name)
410+
if tool is None:
411+
logger.warning("Tool '%s' not listed, no validation will be performed", tool_name)
412+
413+
return tool
413414

414415
def call_tool(self):
416+
"""Register a tool call handler.
417+
418+
The handler validates input against inputSchema, calls the tool function, and processes results:
419+
- Content only: returns as-is
420+
- Dict only: serializes to JSON text and returns as content with structuredContent
421+
- Both: returns content and structuredContent
422+
423+
If outputSchema is defined, validates structuredContent or errors if missing.
424+
"""
425+
415426
def decorator(
416427
func: Callable[
417428
...,
418-
Awaitable[Iterable[types.ContentBlock]],
429+
Awaitable[
430+
Iterable[types.ContentBlock] | dict[str, Any] | tuple[Iterable[types.ContentBlock], dict[str, Any]]
431+
],
419432
],
420433
):
421434
logger.debug("Registering handler for CallToolRequest")
@@ -424,26 +437,53 @@ async def handler(req: types.CallToolRequest):
424437
try:
425438
tool_name = req.params.name
426439
arguments = req.params.arguments or {}
440+
tool = await self._get_cached_tool_definition(tool_name)
427441

428-
# Validate arguments
429-
validation_error = await self._validate_tool_arguments(tool_name, arguments)
430-
if validation_error:
431-
return types.ServerResult(
432-
types.CallToolResult(
433-
content=[types.TextContent(type="text", text=validation_error)],
434-
isError=True,
435-
)
436-
)
442+
# input validation
443+
if tool:
444+
try:
445+
jsonschema.validate(instance=arguments, schema=tool.inputSchema)
446+
except jsonschema.ValidationError as e:
447+
return self._make_error_result(f"Input validation error: {e.message}")
437448

449+
# tool call
438450
results = await func(tool_name, arguments)
439-
return types.ServerResult(types.CallToolResult(content=list(results), isError=False))
440-
except Exception as e:
451+
452+
# output normalization
453+
content: list[types.ContentBlock]
454+
structured_content: dict[str, Any] | None
455+
456+
if isinstance(results, tuple) and len(results) == 2:
457+
# tool returned both content and structured content
458+
structured_content = cast(dict[str, Any], results[1])
459+
content = list(cast(Iterable[types.ContentBlock], results[0]))
460+
elif isinstance(results, dict):
461+
# tool returned structured content only
462+
structured_content = cast(dict[str, Any], results)
463+
content = [types.TextContent(type="text", text=json.dumps(results, indent=2))]
464+
else:
465+
# tool returned content only
466+
structured_content = None
467+
content = list(cast(Iterable[types.ContentBlock], results))
468+
469+
# output validation
470+
if tool and tool.outputSchema is not None:
471+
if structured_content is None:
472+
return self._make_error_result(
473+
"Output validation error: outputSchema defined but no structured output returned"
474+
)
475+
else:
476+
try:
477+
jsonschema.validate(instance=structured_content, schema=tool.outputSchema)
478+
except jsonschema.ValidationError as e:
479+
return self._make_error_result(f"Output validation error: {e.message}")
480+
481+
# result
441482
return types.ServerResult(
442-
types.CallToolResult(
443-
content=[types.TextContent(type="text", text=str(e))],
444-
isError=True,
445-
)
483+
types.CallToolResult(content=content, structuredContent=structured_content, isError=False)
446484
)
485+
except Exception as e:
486+
return self._make_error_result(str(e))
447487

448488
self.request_handlers[types.CallToolRequest] = handler
449489
return func

src/mcp/types.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -839,6 +839,11 @@ class Tool(BaseMetadata):
839839
"""A human-readable description of the tool."""
840840
inputSchema: dict[str, Any]
841841
"""A JSON Schema object defining the expected parameters for the tool."""
842+
outputSchema: dict[str, Any] | None = None
843+
"""
844+
An optional JSON Schema object defining the structure of the tool's output
845+
returned in the structuredContent field of a CallToolResult.
846+
"""
842847
annotations: ToolAnnotations | None = None
843848
"""Optional additional tool information."""
844849
meta: dict[str, Any] | None = Field(alias="_meta", default=None)
@@ -874,6 +879,8 @@ class CallToolResult(Result):
874879
"""The server's response to a tool call."""
875880

876881
content: list[ContentBlock]
882+
structuredContent: dict[str, Any] | None = None
883+
"""An optional JSON object that represents the structured result of the tool call."""
877884
isError: bool = False
878885

879886

tests/server/test_lowlevel_input_validation.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,5 @@ async def test_callback(client_session: ClientSession) -> CallToolResult:
306306

307307
# Verify warning was logged
308308
assert any(
309-
"Tool 'unknown_tool' not found in cache, validation will not be performed" in record.message
310-
for record in caplog.records
309+
"Tool 'unknown_tool' not listed, no validation will be performed" in record.message for record in caplog.records
311310
)

0 commit comments

Comments
 (0)