Skip to content

Commit f498499

Browse files
Merge branch 'modelcontextprotocol:main' into configure-input-size
2 parents a807d26 + c8bbfc0 commit f498499

File tree

13 files changed

+1276
-15
lines changed

13 files changed

+1276
-15
lines changed

.github/workflows/publish-docs-manually.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,4 @@ jobs:
3030
mkdocs-material-
3131
3232
- run: uv sync --frozen --group docs
33-
- run: uv run --no-sync mkdocs gh-deploy --force
33+
- run: uv run --frozen --no-sync mkdocs gh-deploy --force

.github/workflows/publish-pypi.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ jobs:
2222
run: uv python install 3.12
2323

2424
- name: Build
25-
run: uv build
25+
run: uv build --frozen
2626

2727
- name: Upload artifacts
2828
uses: actions/upload-artifact@v4
@@ -79,4 +79,4 @@ jobs:
7979
mkdocs-material-
8080
8181
- run: uv sync --frozen --group docs
82-
- run: uv run --no-sync mkdocs gh-deploy --force
82+
- run: uv run --frozen --no-sync mkdocs gh-deploy --force

.github/workflows/shared.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,5 +44,5 @@ jobs:
4444
run: uv sync --frozen --all-extras --python ${{ matrix.python-version }}
4545

4646
- name: Run pytest
47-
run: uv run --no-sync pytest
47+
run: uv run --frozen --no-sync pytest
4848
continue-on-error: true

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/client/auth.py

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ class OAuthContext:
9595
protected_resource_metadata: ProtectedResourceMetadata | None = None
9696
oauth_metadata: OAuthMetadata | None = None
9797
auth_server_url: str | None = None
98+
protocol_version: str | None = None
9899

99100
# Client registration
100101
client_info: OAuthClientInformationFull | None = None
@@ -154,6 +155,25 @@ def get_resource_url(self) -> str:
154155

155156
return resource
156157

158+
def should_include_resource_param(self, protocol_version: str | None = None) -> bool:
159+
"""Determine if the resource parameter should be included in OAuth requests.
160+
161+
Returns True if:
162+
- Protected resource metadata is available, OR
163+
- MCP-Protocol-Version header is 2025-06-18 or later
164+
"""
165+
# If we have protected resource metadata, include the resource param
166+
if self.protected_resource_metadata is not None:
167+
return True
168+
169+
# If no protocol version provided, don't include resource param
170+
if not protocol_version:
171+
return False
172+
173+
# Check if protocol version is 2025-06-18 or later
174+
# Version format is YYYY-MM-DD, so string comparison works
175+
return protocol_version >= "2025-06-18"
176+
157177

158178
class OAuthClientProvider(httpx.Auth):
159179
"""
@@ -320,9 +340,12 @@ async def _perform_authorization(self) -> tuple[str, str]:
320340
"state": state,
321341
"code_challenge": pkce_params.code_challenge,
322342
"code_challenge_method": "S256",
323-
"resource": self.context.get_resource_url(), # RFC 8707
324343
}
325344

345+
# Only include resource param if conditions are met
346+
if self.context.should_include_resource_param(self.context.protocol_version):
347+
auth_params["resource"] = self.context.get_resource_url() # RFC 8707
348+
326349
if self.context.client_metadata.scope:
327350
auth_params["scope"] = self.context.client_metadata.scope
328351

@@ -358,9 +381,12 @@ async def _exchange_token(self, auth_code: str, code_verifier: str) -> httpx.Req
358381
"redirect_uri": str(self.context.client_metadata.redirect_uris[0]),
359382
"client_id": self.context.client_info.client_id,
360383
"code_verifier": code_verifier,
361-
"resource": self.context.get_resource_url(), # RFC 8707
362384
}
363385

386+
# Only include resource param if conditions are met
387+
if self.context.should_include_resource_param(self.context.protocol_version):
388+
token_data["resource"] = self.context.get_resource_url() # RFC 8707
389+
364390
if self.context.client_info.client_secret:
365391
token_data["client_secret"] = self.context.client_info.client_secret
366392

@@ -409,9 +435,12 @@ async def _refresh_token(self) -> httpx.Request:
409435
"grant_type": "refresh_token",
410436
"refresh_token": self.context.current_tokens.refresh_token,
411437
"client_id": self.context.client_info.client_id,
412-
"resource": self.context.get_resource_url(), # RFC 8707
413438
}
414439

440+
# Only include resource param if conditions are met
441+
if self.context.should_include_resource_param(self.context.protocol_version):
442+
refresh_data["resource"] = self.context.get_resource_url() # RFC 8707
443+
415444
if self.context.client_info.client_secret:
416445
refresh_data["client_secret"] = self.context.client_info.client_secret
417446

@@ -457,6 +486,9 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.
457486
if not self._initialized:
458487
await self._initialize()
459488

489+
# Capture protocol version from request headers
490+
self.context.protocol_version = request.headers.get(MCP_PROTOCOL_VERSION)
491+
460492
# Perform OAuth flow if not authenticated
461493
if not self.context.is_token_valid():
462494
try:

0 commit comments

Comments
 (0)