Skip to content

Add schema validation to lowlevel server #1005

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Jun 25, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -829,6 +829,67 @@ if __name__ == "__main__":

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

#### Structured Output Support

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:

```python
from types import Any

import mcp.types as types
from mcp.server.lowlevel import Server

server = Server("example-server")


@server.list_tools()
async def list_tools() -> list[types.Tool]:
return [
types.Tool(
name="calculate",
description="Perform mathematical calculations",
inputSchema={
"type": "object",
"properties": {
"expression": {"type": "string", "description": "Math expression"}
},
"required": ["expression"],
},
outputSchema={
"type": "object",
"properties": {
"result": {"type": "number"},
"expression": {"type": "string"},
},
"required": ["result", "expression"],
},
)
]


@server.call_tool()
async def call_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
if name == "calculate":
expression = arguments["expression"]
try:
result = eval(expression) # Use a safe math parser
structured = {"result": result, "expression": expression}

# low-level server will validate structured output against the tool's
# output schema, and automatically serialize it into a TextContent block
# for backwards compatibility with pre-2025-06-18 clients.
return structured
except Exception as e:
raise ValueError(f"Calculation error: {str(e)}")
```

Tools can return data in three ways:
1. **Content only**: Return a list of content blocks (default behavior before spec revision 2025-06-18)
2. **Structured data only**: Return a dictionary that will be serialized to JSON (Introduced in spec revision 2025-06-18)
3. **Both**: Return a tuple of (content, structured_data) preferred option to use for backwards compatibility

When an `outputSchema` is defined, the server automatically validates the structured output against the schema. This ensures type safety and helps catch errors early.

### Writing MCP Clients

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):
Expand Down
98 changes: 98 additions & 0 deletions examples/servers/structured_output_lowlevel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
#!/usr/bin/env python3
"""
Example low-level MCP server demonstrating structured output support.
This example shows how to use the low-level server API to return
structured data from tools, with automatic validation against output
schemas.
"""

import asyncio
from datetime import datetime
from typing import Any

import mcp.server.stdio
import mcp.types as types
from mcp.server.lowlevel import NotificationOptions, Server
from mcp.server.models import InitializationOptions

# Create low-level server instance
server = Server("structured-output-lowlevel-example")


@server.list_tools()
async def list_tools() -> list[types.Tool]:
"""List available tools with their schemas."""
return [
types.Tool(
name="get_weather",
description="Get weather information (simulated)",
inputSchema={
"type": "object",
"properties": {"city": {"type": "string", "description": "City name"}},
"required": ["city"],
},
outputSchema={
"type": "object",
"properties": {
"temperature": {"type": "number"},
"conditions": {"type": "string"},
"humidity": {"type": "integer", "minimum": 0, "maximum": 100},
"wind_speed": {"type": "number"},
"timestamp": {"type": "string", "format": "date-time"},
},
"required": ["temperature", "conditions", "humidity", "wind_speed", "timestamp"],
},
),
]


@server.call_tool()
async def call_tool(name: str, arguments: dict[str, Any]) -> Any:
"""
Handle tool call with structured output.
"""

if name == "get_weather":
# city = arguments["city"] # Would be used with real weather API

# Simulate weather data (in production, call a real weather API)
import random

weather_conditions = ["sunny", "cloudy", "rainy", "partly cloudy", "foggy"]

weather_data = {
"temperature": round(random.uniform(0, 35), 1),
"conditions": random.choice(weather_conditions),
"humidity": random.randint(30, 90),
"wind_speed": round(random.uniform(0, 30), 1),
"timestamp": datetime.now().isoformat(),
}

# Return structured data only
# The low-level server will serialize this to JSON content automatically
return weather_data

else:
raise ValueError(f"Unknown tool: {name}")


async def run():
"""Run the low-level server using stdio transport."""
async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
await server.run(
read_stream,
write_stream,
InitializationOptions(
server_name="structured-output-lowlevel-example",
server_version="0.1.0",
capabilities=server.get_capabilities(
notification_options=NotificationOptions(),
experimental_capabilities={},
),
),
)


if __name__ == "__main__":
asyncio.run(run())
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ dependencies = [
"sse-starlette>=1.6.1",
"pydantic-settings>=2.5.2",
"uvicorn>=0.23.1; sys_platform != 'emscripten'",
"jsonschema==4.20.0",
]

[project.optional-dependencies]
Expand Down
111 changes: 103 additions & 8 deletions src/mcp/server/lowlevel/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,13 +68,15 @@ async def main():
from __future__ import annotations as _annotations

import contextvars
import json
import logging
import warnings
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable
from contextlib import AbstractAsyncContextManager, AsyncExitStack, asynccontextmanager
from typing import Any, Generic
from typing import Any, Generic, TypeAlias, cast

import anyio
import jsonschema
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
from pydantic import AnyUrl
from typing_extensions import TypeVar
Expand All @@ -94,6 +96,11 @@ async def main():
LifespanResultT = TypeVar("LifespanResultT")
RequestT = TypeVar("RequestT", default=Any)

# type aliases for tool call results
StructuredContent: TypeAlias = dict[str, Any]
UnstructuredContent: TypeAlias = Iterable[types.ContentBlock]
CombinationContent: TypeAlias = tuple[UnstructuredContent, StructuredContent]

# This will be properly typed in each Server instance's context
request_ctx: contextvars.ContextVar[RequestContext[ServerSession, Any, Any]] = contextvars.ContextVar("request_ctx")

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

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

async def handler(_: Any):
tools = await func()
# Refresh the tool cache
self._tool_cache.clear()
for tool in tools:
self._tool_cache[tool.name] = tool
return types.ServerResult(types.ListToolsResult(tools=tools))

self.request_handlers[types.ListToolsRequest] = handler
return func

return decorator

def call_tool(self):
def _make_error_result(self, error_message: str) -> types.ServerResult:
"""Create a ServerResult with an error CallToolResult."""
return types.ServerResult(
types.CallToolResult(
content=[types.TextContent(type="text", text=error_message)],
isError=True,
)
)

async def _get_cached_tool_definition(self, tool_name: str) -> types.Tool | None:
"""Get tool definition from cache, refreshing if necessary.
Returns the Tool object if found, None otherwise.
"""
if tool_name not in self._tool_cache:
if types.ListToolsRequest in self.request_handlers:
logger.debug("Tool cache miss for %s, refreshing cache", tool_name)
await self.request_handlers[types.ListToolsRequest](None)

tool = self._tool_cache.get(tool_name)
if tool is None:
logger.warning("Tool '%s' not listed, no validation will be performed", tool_name)

return tool

def call_tool(self, *, validate_input: bool = True):
"""Register a tool call handler.
Args:
validate_input: If True, validates input against inputSchema. Default is True.
The handler validates input against inputSchema (if validate_input=True), calls the tool function,
and builds a CallToolResult with the results:
- Unstructured content (iterable of ContentBlock): returned in content
- Structured content (dict): returned in structuredContent, serialized JSON text returned in content
- Both: returned in content and structuredContent
If outputSchema is defined, validates structuredContent or errors if missing.
"""

def decorator(
func: Callable[
...,
Awaitable[Iterable[types.ContentBlock]],
Awaitable[UnstructuredContent | StructuredContent | CombinationContent],
],
):
logger.debug("Registering handler for CallToolRequest")

async def handler(req: types.CallToolRequest):
try:
results = await func(req.params.name, (req.params.arguments or {}))
return types.ServerResult(types.CallToolResult(content=list(results), isError=False))
except Exception as e:
tool_name = req.params.name
arguments = req.params.arguments or {}
tool = await self._get_cached_tool_definition(tool_name)

# input validation
if validate_input and tool:
try:
jsonschema.validate(instance=arguments, schema=tool.inputSchema)
except jsonschema.ValidationError as e:
return self._make_error_result(f"Input validation error: {e.message}")

# tool call
results = await func(tool_name, arguments)

# output normalization
unstructured_content: UnstructuredContent
maybe_structured_content: StructuredContent | None
if isinstance(results, tuple) and len(results) == 2:
# tool returned both structured and unstructured content
unstructured_content, maybe_structured_content = cast(CombinationContent, results)
elif isinstance(results, dict):
# tool returned structured content only
maybe_structured_content = cast(StructuredContent, results)
unstructured_content = [types.TextContent(type="text", text=json.dumps(results, indent=2))]
elif hasattr(results, "__iter__"):
# tool returned unstructured content only
unstructured_content = cast(UnstructuredContent, results)
maybe_structured_content = None
else:
return self._make_error_result(f"Unexpected return type from tool: {type(results).__name__}")

# output validation
if tool and tool.outputSchema is not None:
if maybe_structured_content is None:
return self._make_error_result(
"Output validation error: outputSchema defined but no structured output returned"
)
else:
try:
jsonschema.validate(instance=maybe_structured_content, schema=tool.outputSchema)
except jsonschema.ValidationError as e:
return self._make_error_result(f"Output validation error: {e.message}")

# result
return types.ServerResult(
types.CallToolResult(
content=[types.TextContent(type="text", text=str(e))],
isError=True,
content=list(unstructured_content),
structuredContent=maybe_structured_content,
isError=False,
)
)
except Exception as e:
return self._make_error_result(str(e))

self.request_handlers[types.CallToolRequest] = handler
return func
Expand Down
7 changes: 7 additions & 0 deletions src/mcp/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -839,6 +839,11 @@ class Tool(BaseMetadata):
"""A human-readable description of the tool."""
inputSchema: dict[str, Any]
"""A JSON Schema object defining the expected parameters for the tool."""
outputSchema: dict[str, Any] | None = None
"""
An optional JSON Schema object defining the structure of the tool's output
returned in the structuredContent field of a CallToolResult.
"""
annotations: ToolAnnotations | None = None
"""Optional additional tool information."""
meta: dict[str, Any] | None = Field(alias="_meta", default=None)
Expand Down Expand Up @@ -874,6 +879,8 @@ class CallToolResult(Result):
"""The server's response to a tool call."""

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


Expand Down
Loading
Loading