Skip to content

Commit 93bd16f

Browse files
committed
address comments
1 parent 727660f commit 93bd16f

File tree

3 files changed

+47
-187
lines changed

3 files changed

+47
-187
lines changed

README.md

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -834,6 +834,8 @@ Caution: The `mcp run` and `mcp dev` tool doesn't support low-level server.
834834
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:
835835

836836
```python
837+
from types import Any
838+
837839
import mcp.types as types
838840
from mcp.server.lowlevel import Server
839841

@@ -866,29 +868,25 @@ async def list_tools() -> list[types.Tool]:
866868

867869

868870
@server.call_tool()
869-
async def call_tool(name: str, arguments: dict) -> tuple[list[types.TextContent], dict]:
871+
async def call_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
870872
if name == "calculate":
871873
expression = arguments["expression"]
872874
try:
873-
result = eval(expression) # Note: Use a safe math parser in production
874-
875-
# Return both human-readable content and structured data
876-
content = [
877-
types.TextContent(
878-
type="text", text=f"The result of {expression} is {result}"
879-
)
880-
]
875+
result = eval(expression) # Use a safe math parser
881876
structured = {"result": result, "expression": expression}
882877

883-
return (content, structured)
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
884882
except Exception as e:
885883
raise ValueError(f"Calculation error: {str(e)}")
886884
```
887885

888886
Tools can return data in three ways:
889-
1. **Content only**: Return a list of content blocks (default behavior)
890-
2. **Structured data only**: Return a dictionary that will be serialized to JSON
891-
3. **Both**: Return a tuple of (content, structured_data)
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
892890

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

examples/servers/structured_output_lowlevel.py

Lines changed: 6 additions & 152 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,9 @@
22
"""
33
Example low-level MCP server demonstrating structured output support.
44
5-
This example shows how to use the low-level server API to return both
6-
human-readable content and machine-readable structured data from tools,
7-
with automatic validation against output schemas.
8-
9-
The low-level API provides direct control over request handling and
10-
allows tools to return different types of responses:
11-
1. Content only (list of content blocks)
12-
2. Structured data only (dict that gets serialized to JSON)
13-
3. Both content and structured data (tuple)
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.
148
"""
159

1610
import asyncio
@@ -30,32 +24,6 @@
3024
async def list_tools() -> list[types.Tool]:
3125
"""List available tools with their schemas."""
3226
return [
33-
types.Tool(
34-
name="analyze_text",
35-
description="Analyze text and return structured insights",
36-
inputSchema={
37-
"type": "object",
38-
"properties": {"text": {"type": "string", "description": "Text to analyze"}},
39-
"required": ["text"],
40-
},
41-
outputSchema={
42-
"type": "object",
43-
"properties": {
44-
"word_count": {"type": "integer"},
45-
"char_count": {"type": "integer"},
46-
"sentence_count": {"type": "integer"},
47-
"most_common_words": {
48-
"type": "array",
49-
"items": {
50-
"type": "object",
51-
"properties": {"word": {"type": "string"}, "count": {"type": "integer"}},
52-
"required": ["word", "count"],
53-
},
54-
},
55-
},
56-
"required": ["word_count", "char_count", "sentence_count", "most_common_words"],
57-
},
58-
),
5927
types.Tool(
6028
name="get_weather",
6129
description="Get weather information (simulated)",
@@ -76,91 +44,16 @@ async def list_tools() -> list[types.Tool]:
7644
"required": ["temperature", "conditions", "humidity", "wind_speed", "timestamp"],
7745
},
7846
),
79-
types.Tool(
80-
name="calculate_statistics",
81-
description="Calculate statistics for a list of numbers",
82-
inputSchema={
83-
"type": "object",
84-
"properties": {
85-
"numbers": {
86-
"type": "array",
87-
"items": {"type": "number"},
88-
"description": "List of numbers to analyze",
89-
}
90-
},
91-
"required": ["numbers"],
92-
},
93-
outputSchema={
94-
"type": "object",
95-
"properties": {
96-
"mean": {"type": "number"},
97-
"median": {"type": "number"},
98-
"min": {"type": "number"},
99-
"max": {"type": "number"},
100-
"sum": {"type": "number"},
101-
"count": {"type": "integer"},
102-
},
103-
"required": ["mean", "median", "min", "max", "sum", "count"],
104-
},
105-
),
10647
]
10748

10849

10950
@server.call_tool()
11051
async def call_tool(name: str, arguments: dict[str, Any]) -> Any:
11152
"""
112-
Handle tool calls with structured output.
113-
114-
This low-level handler demonstrates the three ways to return data:
115-
1. Return a list of content blocks (traditional approach)
116-
2. Return a dict (gets serialized to JSON and included as structuredContent)
117-
3. Return a tuple of (content, structured_data) for both
53+
Handle tool call with structured output.
11854
"""
11955

120-
if name == "analyze_text":
121-
text = arguments["text"]
122-
123-
# Analyze the text
124-
words = text.split()
125-
word_count = len(words)
126-
char_count = len(text)
127-
sentences = text.replace("?", ".").replace("!", ".").split(".")
128-
sentence_count = len([s for s in sentences if s.strip()])
129-
130-
# Count word frequencies
131-
word_freq = {}
132-
for word in words:
133-
word_lower = word.lower().strip('.,!?;:"')
134-
if word_lower:
135-
word_freq[word_lower] = word_freq.get(word_lower, 0) + 1
136-
137-
# Get top 5 most common words
138-
most_common = sorted(word_freq.items(), key=lambda x: x[1], reverse=True)[:5]
139-
most_common_words = [{"word": word, "count": count} for word, count in most_common]
140-
141-
# Example 3: Return both content and structured data
142-
# The low-level server will validate the structured data against outputSchema
143-
content = [
144-
types.TextContent(
145-
type="text",
146-
text=f"Text analysis complete:\n"
147-
f"- {word_count} words\n"
148-
f"- {char_count} characters\n"
149-
f"- {sentence_count} sentences\n"
150-
f"- Most common words: {', '.join(w['word'] for w in most_common_words)}",
151-
)
152-
]
153-
154-
structured = {
155-
"word_count": word_count,
156-
"char_count": char_count,
157-
"sentence_count": sentence_count,
158-
"most_common_words": most_common_words,
159-
}
160-
161-
return (content, structured)
162-
163-
elif name == "get_weather":
56+
if name == "get_weather":
16457
# city = arguments["city"] # Would be used with real weather API
16558

16659
# Simulate weather data (in production, call a real weather API)
@@ -176,49 +69,10 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> Any:
17669
"timestamp": datetime.now().isoformat(),
17770
}
17871

179-
# Example 2: Return structured data only
72+
# Return structured data only
18073
# The low-level server will serialize this to JSON content automatically
18174
return weather_data
18275

183-
elif name == "calculate_statistics":
184-
numbers = arguments["numbers"]
185-
186-
if not numbers:
187-
raise ValueError("Cannot calculate statistics for empty list")
188-
189-
sorted_nums = sorted(numbers)
190-
count = len(numbers)
191-
192-
# Calculate statistics
193-
mean = sum(numbers) / count
194-
195-
if count % 2 == 0:
196-
median = (sorted_nums[count // 2 - 1] + sorted_nums[count // 2]) / 2
197-
else:
198-
median = sorted_nums[count // 2]
199-
200-
stats = {
201-
"mean": mean,
202-
"median": median,
203-
"min": sorted_nums[0],
204-
"max": sorted_nums[-1],
205-
"sum": sum(numbers),
206-
"count": count,
207-
}
208-
209-
# Example 3: Return both content and structured data
210-
content = [
211-
types.TextContent(
212-
type="text",
213-
text=f"Statistics for {count} numbers:\n"
214-
f"Mean: {stats['mean']:.2f}, Median: {stats['median']:.2f}\n"
215-
f"Range: {stats['min']} to {stats['max']}\n"
216-
f"Sum: {stats['sum']}",
217-
)
218-
]
219-
220-
return (content, stats)
221-
22276
else:
22377
raise ValueError(f"Unknown tool: {name}")
22478

src/mcp/server/lowlevel/server.py

Lines changed: 30 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ async def main():
7373
import warnings
7474
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable
7575
from contextlib import AbstractAsyncContextManager, AsyncExitStack, asynccontextmanager
76-
from typing import Any, Generic, cast
76+
from typing import Any, Generic, TypeAlias, cast
7777

7878
import anyio
7979
import jsonschema
@@ -96,6 +96,11 @@ async def main():
9696
LifespanResultT = TypeVar("LifespanResultT")
9797
RequestT = TypeVar("RequestT", default=Any)
9898

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+
99104
# This will be properly typed in each Server instance's context
100105
request_ctx: contextvars.ContextVar[RequestContext[ServerSession, Any, Any]] = contextvars.ContextVar("request_ctx")
101106

@@ -415,20 +420,19 @@ async def _get_cached_tool_definition(self, tool_name: str) -> types.Tool | None
415420
def call_tool(self):
416421
"""Register a tool call handler.
417422
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
423+
The handler validates input against inputSchema, calls the tool function,
424+
and builds a CallToolResult with the results:
425+
- Unstructured content (iterable of ContentBlock): returned in content
426+
- Structured content (dict): returned in structuredContent, serialized JSON text returned in content
427+
- Both: returned in content and structuredContent
422428
423429
If outputSchema is defined, validates structuredContent or errors if missing.
424430
"""
425431

426432
def decorator(
427433
func: Callable[
428434
...,
429-
Awaitable[
430-
Iterable[types.ContentBlock] | dict[str, Any] | tuple[Iterable[types.ContentBlock], dict[str, Any]]
431-
],
435+
Awaitable[UnstructuredContent | StructuredContent | CombinationContent],
432436
],
433437
):
434438
logger.debug("Registering handler for CallToolRequest")
@@ -450,37 +454,41 @@ async def handler(req: types.CallToolRequest):
450454
results = await func(tool_name, arguments)
451455

452456
# output normalization
453-
content: list[types.ContentBlock]
454-
structured_content: dict[str, Any] | None
455-
457+
unstructured_content: UnstructuredContent
458+
maybe_structured_content: StructuredContent | None
456459
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+
# tool returned both structured and unstructured content
461+
unstructured_content, maybe_structured_content = cast(CombinationContent, results)
460462
elif isinstance(results, dict):
461463
# 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+
maybe_structured_content = cast(StructuredContent, results)
465+
unstructured_content = [types.TextContent(type="text", text=json.dumps(results, indent=2))]
466+
elif hasattr(results, "__iter__"):
467+
# tool returned unstructured content only
468+
unstructured_content = cast(UnstructuredContent, results)
469+
maybe_structured_content = None
464470
else:
465-
# tool returned content only
466-
structured_content = None
467-
content = list(cast(Iterable[types.ContentBlock], results))
471+
return self._make_error_result(f"Unexpected return type from tool: {type(results).__name__}")
468472

469473
# output validation
470474
if tool and tool.outputSchema is not None:
471-
if structured_content is None:
475+
if maybe_structured_content is None:
472476
return self._make_error_result(
473477
"Output validation error: outputSchema defined but no structured output returned"
474478
)
475479
else:
476480
try:
477-
jsonschema.validate(instance=structured_content, schema=tool.outputSchema)
481+
jsonschema.validate(instance=maybe_structured_content, schema=tool.outputSchema)
478482
except jsonschema.ValidationError as e:
479483
return self._make_error_result(f"Output validation error: {e.message}")
480484

481485
# result
482486
return types.ServerResult(
483-
types.CallToolResult(content=content, structuredContent=structured_content, isError=False)
487+
types.CallToolResult(
488+
content=list(unstructured_content),
489+
structuredContent=maybe_structured_content,
490+
isError=False,
491+
)
484492
)
485493
except Exception as e:
486494
return self._make_error_result(str(e))

0 commit comments

Comments
 (0)