Skip to content

Commit b9cd587

Browse files
committed
update README.md and add example
1 parent fff4cf9 commit b9cd587

File tree

2 files changed

+299
-0
lines changed

2 files changed

+299
-0
lines changed

README.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -829,6 +829,61 @@ 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+
@server.list_tools()
838+
async def list_tools() -> list[types.Tool]:
839+
return [
840+
types.Tool(
841+
name="calculate",
842+
description="Perform mathematical calculations",
843+
inputSchema={
844+
"type": "object",
845+
"properties": {
846+
"expression": {"type": "string", "description": "Math expression"}
847+
},
848+
"required": ["expression"]
849+
},
850+
outputSchema={
851+
"type": "object",
852+
"properties": {
853+
"result": {"type": "number"},
854+
"expression": {"type": "string"}
855+
},
856+
"required": ["result", "expression"]
857+
}
858+
)
859+
]
860+
861+
@server.call_tool()
862+
async def call_tool(name: str, arguments: dict) -> tuple[list[types.TextContent], dict]:
863+
if name == "calculate":
864+
expression = arguments["expression"]
865+
try:
866+
result = eval(expression) # Note: Use a safe math parser in production
867+
868+
# Return both human-readable content and structured data
869+
content = [types.TextContent(
870+
type="text",
871+
text=f"The result of {expression} is {result}"
872+
)]
873+
structured = {"result": result, "expression": expression}
874+
875+
return (content, structured)
876+
except Exception as e:
877+
raise ValueError(f"Calculation error: {str(e)}")
878+
```
879+
880+
Tools can return data in three ways:
881+
1. **Content only**: Return a list of content blocks (default behavior)
882+
2. **Structured data only**: Return a dictionary that will be serialized to JSON
883+
3. **Both**: Return a tuple of (content, structured_data)
884+
885+
When an `outputSchema` is defined, the server automatically validates the structured output against the schema. This ensures type safety and helps catch errors early.
886+
832887
### Writing MCP Clients
833888

834889
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: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
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 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)
14+
"""
15+
16+
import asyncio
17+
from datetime import datetime
18+
from typing import Any
19+
20+
import mcp.server.stdio
21+
import mcp.types as types
22+
from mcp.server.lowlevel import NotificationOptions, Server
23+
from mcp.server.models import InitializationOptions
24+
25+
# Create low-level server instance
26+
server = Server("structured-output-lowlevel-example")
27+
28+
29+
@server.list_tools()
30+
async def list_tools() -> list[types.Tool]:
31+
"""List available tools with their schemas."""
32+
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+
),
59+
types.Tool(
60+
name="get_weather",
61+
description="Get weather information (simulated)",
62+
inputSchema={
63+
"type": "object",
64+
"properties": {"city": {"type": "string", "description": "City name"}},
65+
"required": ["city"],
66+
},
67+
outputSchema={
68+
"type": "object",
69+
"properties": {
70+
"temperature": {"type": "number"},
71+
"conditions": {"type": "string"},
72+
"humidity": {"type": "integer", "minimum": 0, "maximum": 100},
73+
"wind_speed": {"type": "number"},
74+
"timestamp": {"type": "string", "format": "date-time"},
75+
},
76+
"required": ["temperature", "conditions", "humidity", "wind_speed", "timestamp"],
77+
},
78+
),
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+
),
106+
]
107+
108+
109+
@server.call_tool()
110+
async def call_tool(name: str, arguments: dict[str, Any]) -> Any:
111+
"""
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
118+
"""
119+
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":
164+
# city = arguments["city"] # Would be used with real weather API
165+
166+
# Simulate weather data (in production, call a real weather API)
167+
import random
168+
169+
weather_conditions = ["sunny", "cloudy", "rainy", "partly cloudy", "foggy"]
170+
171+
weather_data = {
172+
"temperature": round(random.uniform(0, 35), 1),
173+
"conditions": random.choice(weather_conditions),
174+
"humidity": random.randint(30, 90),
175+
"wind_speed": round(random.uniform(0, 30), 1),
176+
"timestamp": datetime.now().isoformat(),
177+
}
178+
179+
# Example 2: Return structured data only
180+
# The low-level server will serialize this to JSON content automatically
181+
return weather_data
182+
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+
222+
else:
223+
raise ValueError(f"Unknown tool: {name}")
224+
225+
226+
async def run():
227+
"""Run the low-level server using stdio transport."""
228+
async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
229+
await server.run(
230+
read_stream,
231+
write_stream,
232+
InitializationOptions(
233+
server_name="structured-output-lowlevel-example",
234+
server_version="0.1.0",
235+
capabilities=server.get_capabilities(
236+
notification_options=NotificationOptions(),
237+
experimental_capabilities={},
238+
),
239+
),
240+
)
241+
242+
243+
if __name__ == "__main__":
244+
asyncio.run(run())

0 commit comments

Comments
 (0)