Skip to content

Commit c8bbfc0

Browse files
authored
Add schema validation to lowlevel server (#1005)
1 parent 0b11bdb commit c8bbfc0

File tree

8 files changed

+1155
-8
lines changed

8 files changed

+1155
-8
lines changed

README.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -829,6 +829,67 @@ if __name__ == "__main__":
829829

830830
Caution: The `mcp run` and `mcp dev` tool doesn't support low-level server.
831831

832+
#### Structured Output Support
833+
834+
The low-level server supports structured output for tools, allowing you to return both human-readable content and machine-readable structured data. Tools can define an `outputSchema` to validate their structured output:
835+
836+
```python
837+
from types import Any
838+
839+
import mcp.types as types
840+
from mcp.server.lowlevel import Server
841+
842+
server = Server("example-server")
843+
844+
845+
@server.list_tools()
846+
async def list_tools() -> list[types.Tool]:
847+
return [
848+
types.Tool(
849+
name="calculate",
850+
description="Perform mathematical calculations",
851+
inputSchema={
852+
"type": "object",
853+
"properties": {
854+
"expression": {"type": "string", "description": "Math expression"}
855+
},
856+
"required": ["expression"],
857+
},
858+
outputSchema={
859+
"type": "object",
860+
"properties": {
861+
"result": {"type": "number"},
862+
"expression": {"type": "string"},
863+
},
864+
"required": ["result", "expression"],
865+
},
866+
)
867+
]
868+
869+
870+
@server.call_tool()
871+
async def call_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
872+
if name == "calculate":
873+
expression = arguments["expression"]
874+
try:
875+
result = eval(expression) # Use a safe math parser
876+
structured = {"result": result, "expression": expression}
877+
878+
# low-level server will validate structured output against the tool's
879+
# output schema, and automatically serialize it into a TextContent block
880+
# for backwards compatibility with pre-2025-06-18 clients.
881+
return structured
882+
except Exception as e:
883+
raise ValueError(f"Calculation error: {str(e)}")
884+
```
885+
886+
Tools can return data in three ways:
887+
1. **Content only**: Return a list of content blocks (default behavior before spec revision 2025-06-18)
888+
2. **Structured data only**: Return a dictionary that will be serialized to JSON (Introduced in spec revision 2025-06-18)
889+
3. **Both**: Return a tuple of (content, structured_data) preferred option to use for backwards compatibility
890+
891+
When an `outputSchema` is defined, the server automatically validates the structured output against the schema. This ensures type safety and helps catch errors early.
892+
832893
### Writing MCP Clients
833894

834895
The SDK provides a high-level client interface for connecting to MCP servers using various [transports](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports):
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Example low-level MCP server demonstrating structured output support.
4+
5+
This example shows how to use the low-level server API to return
6+
structured data from tools, with automatic validation against output
7+
schemas.
8+
"""
9+
10+
import asyncio
11+
from datetime import datetime
12+
from typing import Any
13+
14+
import mcp.server.stdio
15+
import mcp.types as types
16+
from mcp.server.lowlevel import NotificationOptions, Server
17+
from mcp.server.models import InitializationOptions
18+
19+
# Create low-level server instance
20+
server = Server("structured-output-lowlevel-example")
21+
22+
23+
@server.list_tools()
24+
async def list_tools() -> list[types.Tool]:
25+
"""List available tools with their schemas."""
26+
return [
27+
types.Tool(
28+
name="get_weather",
29+
description="Get weather information (simulated)",
30+
inputSchema={
31+
"type": "object",
32+
"properties": {"city": {"type": "string", "description": "City name"}},
33+
"required": ["city"],
34+
},
35+
outputSchema={
36+
"type": "object",
37+
"properties": {
38+
"temperature": {"type": "number"},
39+
"conditions": {"type": "string"},
40+
"humidity": {"type": "integer", "minimum": 0, "maximum": 100},
41+
"wind_speed": {"type": "number"},
42+
"timestamp": {"type": "string", "format": "date-time"},
43+
},
44+
"required": ["temperature", "conditions", "humidity", "wind_speed", "timestamp"],
45+
},
46+
),
47+
]
48+
49+
50+
@server.call_tool()
51+
async def call_tool(name: str, arguments: dict[str, Any]) -> Any:
52+
"""
53+
Handle tool call with structured output.
54+
"""
55+
56+
if name == "get_weather":
57+
# city = arguments["city"] # Would be used with real weather API
58+
59+
# Simulate weather data (in production, call a real weather API)
60+
import random
61+
62+
weather_conditions = ["sunny", "cloudy", "rainy", "partly cloudy", "foggy"]
63+
64+
weather_data = {
65+
"temperature": round(random.uniform(0, 35), 1),
66+
"conditions": random.choice(weather_conditions),
67+
"humidity": random.randint(30, 90),
68+
"wind_speed": round(random.uniform(0, 30), 1),
69+
"timestamp": datetime.now().isoformat(),
70+
}
71+
72+
# Return structured data only
73+
# The low-level server will serialize this to JSON content automatically
74+
return weather_data
75+
76+
else:
77+
raise ValueError(f"Unknown tool: {name}")
78+
79+
80+
async def run():
81+
"""Run the low-level server using stdio transport."""
82+
async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
83+
await server.run(
84+
read_stream,
85+
write_stream,
86+
InitializationOptions(
87+
server_name="structured-output-lowlevel-example",
88+
server_version="0.1.0",
89+
capabilities=server.get_capabilities(
90+
notification_options=NotificationOptions(),
91+
experimental_capabilities={},
92+
),
93+
),
94+
)
95+
96+
97+
if __name__ == "__main__":
98+
asyncio.run(run())

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ dependencies = [
3131
"sse-starlette>=1.6.1",
3232
"pydantic-settings>=2.5.2",
3333
"uvicorn>=0.23.1; sys_platform != 'emscripten'",
34+
"jsonschema==4.20.0",
3435
]
3536

3637
[project.optional-dependencies]

src/mcp/server/lowlevel/server.py

Lines changed: 103 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -68,13 +68,15 @@ 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, TypeAlias, cast
7677

7778
import anyio
79+
import jsonschema
7880
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
7981
from pydantic import AnyUrl
8082
from typing_extensions import TypeVar
@@ -94,6 +96,11 @@ async def main():
9496
LifespanResultT = TypeVar("LifespanResultT")
9597
RequestT = TypeVar("RequestT", default=Any)
9698

99+
# type aliases for tool call results
100+
StructuredContent: TypeAlias = dict[str, Any]
101+
UnstructuredContent: TypeAlias = Iterable[types.ContentBlock]
102+
CombinationContent: TypeAlias = tuple[UnstructuredContent, StructuredContent]
103+
97104
# This will be properly typed in each Server instance's context
98105
request_ctx: contextvars.ContextVar[RequestContext[ServerSession, Any, Any]] = contextvars.ContextVar("request_ctx")
99106

@@ -143,6 +150,7 @@ def __init__(
143150
}
144151
self.notification_handlers: dict[type, Callable[..., Awaitable[None]]] = {}
145152
self.notification_options = NotificationOptions()
153+
self._tool_cache: dict[str, types.Tool] = {}
146154
logger.debug("Initializing server %r", name)
147155

148156
def create_initialization_options(
@@ -373,33 +381,120 @@ def decorator(func: Callable[[], Awaitable[list[types.Tool]]]):
373381

374382
async def handler(_: Any):
375383
tools = await func()
384+
# Refresh the tool cache
385+
self._tool_cache.clear()
386+
for tool in tools:
387+
self._tool_cache[tool.name] = tool
376388
return types.ServerResult(types.ListToolsResult(tools=tools))
377389

378390
self.request_handlers[types.ListToolsRequest] = handler
379391
return func
380392

381393
return decorator
382394

383-
def call_tool(self):
395+
def _make_error_result(self, error_message: str) -> types.ServerResult:
396+
"""Create a ServerResult with an error CallToolResult."""
397+
return types.ServerResult(
398+
types.CallToolResult(
399+
content=[types.TextContent(type="text", text=error_message)],
400+
isError=True,
401+
)
402+
)
403+
404+
async def _get_cached_tool_definition(self, tool_name: str) -> types.Tool | None:
405+
"""Get tool definition from cache, refreshing if necessary.
406+
407+
Returns the Tool object if found, None otherwise.
408+
"""
409+
if tool_name not in self._tool_cache:
410+
if types.ListToolsRequest in self.request_handlers:
411+
logger.debug("Tool cache miss for %s, refreshing cache", tool_name)
412+
await self.request_handlers[types.ListToolsRequest](None)
413+
414+
tool = self._tool_cache.get(tool_name)
415+
if tool is None:
416+
logger.warning("Tool '%s' not listed, no validation will be performed", tool_name)
417+
418+
return tool
419+
420+
def call_tool(self, *, validate_input: bool = True):
421+
"""Register a tool call handler.
422+
423+
Args:
424+
validate_input: If True, validates input against inputSchema. Default is True.
425+
426+
The handler validates input against inputSchema (if validate_input=True), calls the tool function,
427+
and builds a CallToolResult with the results:
428+
- Unstructured content (iterable of ContentBlock): returned in content
429+
- Structured content (dict): returned in structuredContent, serialized JSON text returned in content
430+
- Both: returned in content and structuredContent
431+
432+
If outputSchema is defined, validates structuredContent or errors if missing.
433+
"""
434+
384435
def decorator(
385436
func: Callable[
386437
...,
387-
Awaitable[Iterable[types.ContentBlock]],
438+
Awaitable[UnstructuredContent | StructuredContent | CombinationContent],
388439
],
389440
):
390441
logger.debug("Registering handler for CallToolRequest")
391442

392443
async def handler(req: types.CallToolRequest):
393444
try:
394-
results = await func(req.params.name, (req.params.arguments or {}))
395-
return types.ServerResult(types.CallToolResult(content=list(results), isError=False))
396-
except Exception as e:
445+
tool_name = req.params.name
446+
arguments = req.params.arguments or {}
447+
tool = await self._get_cached_tool_definition(tool_name)
448+
449+
# input validation
450+
if validate_input and tool:
451+
try:
452+
jsonschema.validate(instance=arguments, schema=tool.inputSchema)
453+
except jsonschema.ValidationError as e:
454+
return self._make_error_result(f"Input validation error: {e.message}")
455+
456+
# tool call
457+
results = await func(tool_name, arguments)
458+
459+
# output normalization
460+
unstructured_content: UnstructuredContent
461+
maybe_structured_content: StructuredContent | None
462+
if isinstance(results, tuple) and len(results) == 2:
463+
# tool returned both structured and unstructured content
464+
unstructured_content, maybe_structured_content = cast(CombinationContent, results)
465+
elif isinstance(results, dict):
466+
# tool returned structured content only
467+
maybe_structured_content = cast(StructuredContent, results)
468+
unstructured_content = [types.TextContent(type="text", text=json.dumps(results, indent=2))]
469+
elif hasattr(results, "__iter__"):
470+
# tool returned unstructured content only
471+
unstructured_content = cast(UnstructuredContent, results)
472+
maybe_structured_content = None
473+
else:
474+
return self._make_error_result(f"Unexpected return type from tool: {type(results).__name__}")
475+
476+
# output validation
477+
if tool and tool.outputSchema is not None:
478+
if maybe_structured_content is None:
479+
return self._make_error_result(
480+
"Output validation error: outputSchema defined but no structured output returned"
481+
)
482+
else:
483+
try:
484+
jsonschema.validate(instance=maybe_structured_content, schema=tool.outputSchema)
485+
except jsonschema.ValidationError as e:
486+
return self._make_error_result(f"Output validation error: {e.message}")
487+
488+
# result
397489
return types.ServerResult(
398490
types.CallToolResult(
399-
content=[types.TextContent(type="text", text=str(e))],
400-
isError=True,
491+
content=list(unstructured_content),
492+
structuredContent=maybe_structured_content,
493+
isError=False,
401494
)
402495
)
496+
except Exception as e:
497+
return self._make_error_result(str(e))
403498

404499
self.request_handlers[types.CallToolRequest] = handler
405500
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

0 commit comments

Comments
 (0)