update the python impl

This commit is contained in:
David Soria Parra 2024-11-21 14:37:46 +00:00
commit f1649a310f
No known key found for this signature in database

View file

@ -17,7 +17,6 @@ Let's build your first MCP server in Python! We'll create a weather server that
```bash ```bash
python --version # Should be 3.10 or higher python --version # Should be 3.10 or higher
pip --version
``` ```
</Step> </Step>
@ -30,16 +29,15 @@ Let's build your first MCP server in Python! We'll create a weather server that
<Step title="Create a new project"> <Step title="Create a new project">
```bash ```bash
mkdir weather-server mkdir weather-service
cd weather-server cd weather-service
uv init --package --app --name weather-server uv init --package --app --name weather-service
source .venv/bin/activate # activate our virtual environment
``` ```
</Step> </Step>
<Step title="Install dependencies"> <Step title="Install dependencies">
```bash ```bash
uv add mcp uv add mcp requests python-dotenv
``` ```
</Step> </Step>
@ -57,203 +55,174 @@ Let's build your first MCP server in Python! We'll create a weather server that
<Steps> <Steps>
<Step title="Create project structure"> <Step title="Create project structure">
```bash ```bash
touch weather_server.py mkdir -p weather_service/src/weather_service
touch weather_service/src/weather_service/server.py
``` ```
</Step> </Step>
<Step title="Add the base code"> <Step title="Add the base imports and setup">
In `weather_server.py` In `weather_service/src/weather_service/server.py`
```python ```python
import os import os
import json import json
import logging import logging
from datetime import datetime from datetime import datetime, timedelta
from typing import Any from collections.abc import Sequence
from functools import lru_cache
import requests import requests
import asyncio
from dotenv import load_dotenv from dotenv import load_dotenv
from mcp.server import Server from mcp.server import Server
from mcp.server.models import InitializationOptions
from mcp.types import ( from mcp.types import (
ServerCapabilities, Resource,
ListResourcesResult, Tool,
ReadResourceResult, TextContent,
ListToolsResult, ImageContent,
CallToolResult, EmbeddedResource,
McpError, LoggingLevel,
) )
from pydantic import AnyUrl
# Load environment variables
load_dotenv() load_dotenv()
# Configure logging # Configure logging
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("weather-server") logger = logging.getLogger("weather-server")
# API configuration
API_KEY = os.getenv("OPENWEATHER_API_KEY") API_KEY = os.getenv("OPENWEATHER_API_KEY")
if not API_KEY: if not API_KEY:
raise ValueError("OPENWEATHER_API_KEY environment variable required") raise ValueError("OPENWEATHER_API_KEY environment variable required")
API_BASE_URL = "http://api.openweathermap.org/data/2.5" API_BASE_URL = "http://api.openweathermap.org/data/2.5"
DEFAULT_CITY = "San Francisco" DEFAULT_CITY = "London"
CURRENT_WEATHER_ENDPOINT = "weather" CURRENT_WEATHER_ENDPOINT = "weather"
FORECAST_ENDPOINT = "forecast" FORECAST_ENDPOINT = "forecast"
class WeatherServer(Server):
def __init__(self):
super().__init__("example-weather-server")
# Set up HTTP session
self.http = requests.Session()
self.http.params = {
"appid": API_KEY,
"units": "metric"
}
@self.list_resources()
async def list_resources(self) -> ListResourcesResult:
# Implementation will come later
pass
@self.read_resource()
async def read_resource(self, uri: str) -> ReadResourceResult:
# Implementation will come later
pass
@self.list_tools()
async def list_tools(self) -> ListToolsResult:
# Implementation will come later
pass
@self.call_tool()
async def call_tool(self, name: str, args: Any) -> CallToolResult:
# Implementation will come later
pass
async def mcp_server():
# our server implementation will go here
``` ```
</Step> </Step>
<Step title="Add resource handlers"> <Step title="Add weather fetching functionality">
Add these methods to the `WeatherServer` class: Add this functionality:
```python ```python
@self.list_resources() # Create reusable session
async def list_resources(self) -> ListResourcesResult: http = requests.Session()
"""List available weather resources.""" http.params = {
return ListResourcesResult( "appid": API_KEY,
resources=[ "units": "metric"
Resource( }
uri=f"weather://{DEFAULT_CITY}/current",
name=f"Current weather in {DEFAULT_CITY}",
mimeType="application/json",
description="Real-time weather data"
)
]
)
@self.read_resource() async def fetch_weather(city: str) -> dict[str, Any]:
async def read_resource(self, uri: AnyUrl) -> ReadResourceResult: response = http.get(
"""Read current weather data for a city.""" f"{API_BASE_URL}/weather",
city = DEFAULT_CITY params={"q": city}
if str(uri) != f"weather://{city}/current": )
raise McpError( response.raise_for_status()
ErrorData( data = response.json()
code=INVALID_REQUEST,
message=f"Unknown resource: {uri}"
)
)
try: return {
response = self.http.get( "temperature": data["main"]["temp"],
f"{API_BASE_URL}/{CURRENT_WEATHER_ENDPOINT}", "conditions": data["weather"][0]["description"],
params={"q": city} "humidity": data["main"]["humidity"],
) "wind_speed": data["wind"]["speed"],
response.raise_for_status() "timestamp": datetime.now().isoformat()
data = response.json() }
weather_data = {
"temperature": data["main"]["temp"],
"conditions": data["weather"][0]["description"],
"humidity": data["main"]["humidity"],
"wind_speed": data["wind"]["speed"],
"timestamp": datetime.now().isoformat()
}
return ReadResourceResult( app = Server("weather-server")
contents=[
TextResourceContents(
uri=uri,
mimeType="application/json",
text=json.dumps(weather_data, indent=2)
)
]
)
except requests.RequestException as e:
raise McpError(
ErrorData(
code=INTERNAL_ERROR,
message=f"Weather API error: {str(e)}"
)
)
``` ```
</Step> </Step>
<Step title="Add tool handlers"> <Step title="Implement resource handlers">
Add these methods to the `WeatherServer` class: Add these resource-related handlers to our main function:
```python ```python
@self.list_tools() app = Server("weather-server")
async def list_tools(self) -> ListToolsResult:
"""List available weather tools."""
return ListToolsResult(
tools=[
Tool(
name="get_forecast",
description="Get weather forecast for a city",
inputSchema={
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "City name"
},
"days": {
"type": "number",
"description": "Number of days (1-5)",
"minimum": 1,
"maximum": 5
}
},
"required": ["city"]
}
)
]
)
@self.call_tool() @app.list_resources()
async def call_tool(self, name: str, arguments: Any) -> CallToolResult: async def list_resources() -> list[Resource]:
"""List available weather resources."""
uri = AnyUrl(f"weather://{DEFAULT_CITY}/current")
return [
Resource(
uri=uri,
name=f"Current weather in {DEFAULT_CITY}",
mimeType="application/json",
description="Real-time weather data"
)
]
@app.read_resource()
async def read_resource(uri: AnyUrl) -> str:
"""Read current weather data for a city."""
city = DEFAULT_CITY
if str(uri).startswith("weather://") and str(uri).endswith("/current"):
city = str(uri).split("/")[-2]
else:
raise ValueError(f"Unknown resource: {uri}")
try:
weather_data = await fetch_weather(city)
return json.dumps(weather_data, indent=2)
except requests.RequestException as e:
raise RuntimeError(f"Weather API error: {str(e)}")
```
</Step>
<Step title="Implement tool handlers">
Add these tool-related handlers:
```python
app = Server("weather-server")
# Resource implementation ...
@app.list_tools()
async def list_tools() -> list[Tool]:
"""List available weather tools."""
return [
Tool(
name="get_forecast",
description="Get weather forecast for a city",
inputSchema={
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "City name"
},
"days": {
"type": "number",
"description": "Number of days (1-5)",
"minimum": 1,
"maximum": 5
}
},
"required": ["city"]
}
)
]
@app.call_tool()
async def call_tool(name: str, arguments: Any) -> Sequence[TextContent | ImageContent | EmbeddedResource]:
"""Handle tool calls for weather forecasts.""" """Handle tool calls for weather forecasts."""
if name != "get_forecast": if name != "get_forecast":
raise McpError( raise ValueError(f"Unknown tool: {name}")
ErrorData(
code=METHOD_NOT_FOUND,
message=f"Unknown tool: {name}"
)
)
if not isinstance(arguments, dict) or "city" not in arguments: if not isinstance(arguments, dict) or "city" not in arguments:
raise McpError( raise ValueError("Invalid forecast arguments")
ErrorData(
code=INVALID_PARAMS,
message="Invalid forecast arguments"
)
)
city = arguments["city"] city = arguments["city"]
days = min(int(arguments.get("days", 3)), 5) days = min(int(arguments.get("days", 3)), 5)
try: try:
response = self.session.get( response = http.get(
f"{API_BASE_URL}/{FORECAST_ENDPOINT}", f"{API_BASE_URL}/{FORECAST_ENDPOINT}",
params={ params={
"q": city, "q": city,
@ -272,48 +241,50 @@ Let's build your first MCP server in Python! We'll create a weather server that
"conditions": day_data["weather"][0]["description"] "conditions": day_data["weather"][0]["description"]
}) })
return CallToolResult( return [
content=[ TextContent(
TextContent( type="text",
type="text", text=json.dumps(forecasts, indent=2)
text=json.dumps(forecasts, indent=2)
)
],
isError=False
)
except requests.RequestException as e:
raise McpError(
ErrorData(
code=INTERNAL_ERROR,
message=f"Weather API error: {str(e)}"
) )
) ]
except requests.RequestException as e:
logger.error(f"Weather API error: {str(e)}")
raise RuntimeError(f"Weather API error: {str(e)}")
``` ```
</Step> </Step>
<Step title="Add the main entry point"> <Step title="Add the main function">
Add this to the end of `weather_server.py`: Add this to the end of `weather_service/src/weather_service/server.py`:
```python ```python
async def main(): async def main():
server = WeatherServer() # Import here to avoid issues with event loops
from mcp.server.stdio import stdio_server
# Import here to avoid issues with event loops async with stdio_server() as (read_stream, write_stream):
from mcp.server.stdio import stdio_server await app.run(
read_stream,
async with stdio_server() as (read_stream, write_stream): write_stream,
await server.run( app.create_initialization_options()
read_stream, )
write_stream,
server.create_initialization_options()
)
if __name__ == "__main__":
import anyio
anyio.run(main)
``` ```
</Step> </Step>
<Step title="Check your entry point in __init__.py">
Add this to the end of `weather_service/src/weather_service/__init__.py`:
```python
from . import server
import asyncio
def main():
"""Main entry point for the package."""
asyncio.run(server.main())
# Optionally expose other important items at package level
__all__ = ['main', 'server']
```
</Step>
</Steps> </Steps>
## Understanding the code ## Understanding the code
@ -330,7 +301,7 @@ Let's build your first MCP server in Python! We'll create a weather server that
<Tab title="Resources"> <Tab title="Resources">
```python ```python
@self.list_resources() @app.list_resources()
async def list_resources(self) -> ListResourcesResult: async def list_resources(self) -> ListResourcesResult:
return ListResourcesResult( return ListResourcesResult(
resources=[ resources=[
@ -373,6 +344,33 @@ Let's build your first MCP server in Python! We'll create a weather server that
Tools let Claude take actions through your server with validated inputs. Tools let Claude take actions through your server with validated inputs.
</Tab> </Tab>
<Tab title="Server Structure">
```python
# Create server instance with name
app = Server("weather-server")
# Register resource handler
@app.list_resources()
async def list_resources() -> list[Resource]:
"""List available resources"""
return [...]
# Register tool handler
@app.call_tool()
async def call_tool(name: str, arguments: Any) -> Sequence[TextContent]:
"""Handle tool execution"""
return [...]
# Register additional handlers
@app.read_resource()
...
@app.list_tools()
...
```
The MCP server uses a simple app pattern - create a Server instance and register handlers with decorators. Each handler maps to a specific MCP protocol operation.
</Tab>
</Tabs> </Tabs>
## Best practices ## Best practices
@ -417,8 +415,36 @@ While this guide uses stdio transport, MCP supports additonal transport options:
### SSE (Server-Sent Events) ### SSE (Server-Sent Events)
```python ```python
from modelcontextprotocol import SSEServerTransport from mcp.server.sse import SseServerTransport
transport = SSEServerTransport("/events", response) from starlette.applications import Starlette
from starlette.routing import Route
# Create SSE transport with endpoint
sse = SseServerTransport("/messages")
# Handler for SSE connections
async def handle_sse(scope, receive, send):
async with sse.connect_sse(scope, receive, send) as streams:
await app.run(
streams[0], streams[1], app.create_initialization_options()
)
# Handler for client messages
async def handle_messages(scope, receive, send):
await sse.handle_post_message(scope, receive, send)
# Create Starlette app with routes
app = Starlette(
debug=True,
routes=[
Route("/sse", endpoint=handle_sse),
Route("/messages", endpoint=handle_messages, methods=["POST"]),
],
)
# Run with any ASGI server
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
``` ```
## Troubleshooting ## Troubleshooting
@ -431,18 +457,8 @@ python --version
# Reinstall dependencies # Reinstall dependencies
uv sync --reinstall uv sync --reinstall
# Ensure we are using the right virtual environment
source .venv/bin/activate
``` ```
### Runtime errors
```bash
# Enable debug logging
export MCP_LOG_LEVEL=DEBUG
python weather_server.py
```
### Type checking ### Type checking
@ -451,7 +467,7 @@ python weather_server.py
uv add --dev pyright uv add --dev pyright
# Run type checker # Run type checker
pyright src uv run pyright src
``` ```
## Connect to Claude Desktop ## Connect to Claude Desktop
@ -515,30 +531,74 @@ pyright src
## Advanced features ## Advanced features
<Steps> <Steps>
<Step title="Understanding Request Context">
The request context provides access to the current request's metadata and the active client session. Access it through `server.request_context`:
```python
@app.call_tool()
async def call_tool(name: str, arguments: Any) -> Sequence[TextContent]:
# Access the current request context
ctx = self.request_context
# Get request metadata like progress tokens
if progress_token := ctx.meta.progressToken:
# Send progress notifications via the session
await ctx.session.send_progress_notification(
progress_token=progress_token,
progress=0.5,
total=1.0
)
# Sample from the LLM client
result = await ctx.session.create_message(
messages=[
SamplingMessage(
role="user",
content=TextContent(
type="text",
text="Analyze this weather data: " + json.dumps(arguments)
)
)
],
max_tokens=100
)
return [TextContent(type="text", text=result.content.text)]
```
</Step>
<Step title="Add caching"> <Step title="Add caching">
```python ```python
from functools import lru_cache # Cache settings
from datetime import datetime, timedelta cache_timeout = timedelta(minutes=15)
last_cache_time = None
cached_weather = None
class WeatherServer: async def fetch_weather(city: str) -> dict[str, Any]:
def __init__(self): global cached_weather, last_cache_time
self._cache_timeout = timedelta(minutes=15)
self._last_cache_time = None
self._cached_weather = None
@lru_cache(maxsize=32) now = datetime.now()
async def _fetch_weather(self, city: str) -> Dict[str, Any]: if (cached_weather is None or
# Cache expires after 15 minutes last_cache_time is None or
now = datetime.now() now - last_cache_time > cache_timeout):
if (self._cached_weather is None or
self._last_cache_time is None or
now - self._last_cache_time > self._cache_timeout):
response = self.session.get(...) response = http.get(
self._cached_weather = response.json() f"{API_BASE_URL}/{CURRENT_WEATHER_ENDPOINT}",
self._last_cache_time = now params={"q": city}
)
response.raise_for_status()
data = response.json()
return self._cached_weather cached_weather = {
"temperature": data["main"]["temp"],
"conditions": data["weather"][0]["description"],
"humidity": data["main"]["humidity"],
"wind_speed": data["wind"]["speed"],
"timestamp": datetime.now().isoformat()
}
last_cache_time = now
return cached_weather
``` ```
</Step> </Step>
@ -615,46 +675,122 @@ pyright src
<Steps> <Steps>
<Step title="Create test file"> <Step title="Create test file">
Create `src/weather_service/tests/test_weather_server.py`: Create `tests/weather_test.py`:
```python ```python
import pytest import pytest
from unittest.mock import Mock, patch import os
from weather_service.weather_server import WeatherServer from unittest.mock import patch, Mock
from datetime import datetime
import json
from pydantic import AnyUrl
os.environ["OPENWEATHER_API_KEY"] = "TEST"
from weather_service.server import (
fetch_weather,
read_resource,
call_tool,
list_resources,
list_tools,
DEFAULT_CITY
)
@pytest.fixture @pytest.fixture
async def server(): def mock_weather_response():
server = WeatherServer() return {
yield server "main": {
"temp": 20.5,
"humidity": 65
},
"weather": [
{"description": "scattered clouds"}
],
"wind": {
"speed": 3.6
}
}
@pytest.mark.asyncio @pytest.fixture
async def test_list_resources(server): def mock_forecast_response():
result = await server.list_resources() return {
assert len(result.resources) > 0 "list": [
assert result.resources[0].mimeType == "application/json" {
@pytest.mark.asyncio
async def test_get_forecast():
with patch("requests.Session") as mock_session:
mock_session.return_value.get.return_value.json.return_value = {
"list": [{
"dt_txt": "2024-01-01 12:00:00", "dt_txt": "2024-01-01 12:00:00",
"main": {"temp": 20}, "main": {"temp": 18.5},
"weather": [{"description": "sunny"}] "weather": [{"description": "sunny"}]
}] },
{
"dt_txt": "2024-01-02 12:00:00",
"main": {"temp": 17.2},
"weather": [{"description": "cloudy"}]
}
]
}
@pytest.mark.asyncio
async def test_fetch_weather(mock_weather_response):
with patch('requests.Session.get') as mock_get:
mock_get.return_value.json.return_value = mock_weather_response
mock_get.return_value.raise_for_status = Mock()
weather = await fetch_weather("London")
assert weather["temperature"] == 20.5
assert weather["conditions"] == "scattered clouds"
assert weather["humidity"] == 65
assert weather["wind_speed"] == 3.6
assert "timestamp" in weather
@pytest.mark.asyncio
async def test_read_resource():
with patch('weather_service.server.fetch_weather') as mock_fetch:
mock_fetch.return_value = {
"temperature": 20.5,
"conditions": "clear sky",
"timestamp": datetime.now().isoformat()
} }
server = WeatherServer() uri = AnyUrl("weather://London/current")
result = await server.call_tool("get_forecast", {"city": "Tokyo", "days": 1}) result = await read_resource(uri)
assert len(result.content) == 1
assert "temperature" in result.content[0].text assert isinstance(result, str)
assert "20" in result.content[0].text assert "temperature" in result
assert "clear sky" in result
@pytest.mark.asyncio
async def test_call_tool(mock_forecast_response):
with patch('weather_service.server.http.get') as mock_get:
mock_get.return_value.json.return_value = mock_forecast_response
mock_get.return_value.raise_for_status = Mock()
result = await call_tool("get_forecast", {"city": "London", "days": 2})
assert len(result) == 1
assert result[0].type == "text"
forecast_data = json.loads(result[0].text)
assert len(forecast_data) == 1
assert forecast_data[0]["temperature"] == 18.5
assert forecast_data[0]["conditions"] == "sunny"
@pytest.mark.asyncio
async def test_list_resources():
resources = await list_resources()
assert len(resources) == 1
assert resources[0].name == f"Current weather in {DEFAULT_CITY}"
assert resources[0].mimeType == "application/json"
@pytest.mark.asyncio
async def test_list_tools():
tools = await list_tools()
assert len(tools) == 1
assert tools[0].name == "get_forecast"
assert "city" in tools[0].inputSchema["properties"]
``` ```
</Step> </Step>
<Step title="Run tests"> <Step title="Run tests">
```bash ```bash
uv add --dev pytest pytest-asyncio uv add --dev pytest pytest-asyncio
uv run pytest src/weather_service/ uv run pytest
``` ```
</Step> </Step>
</Steps> </Steps>