Improve Python First Server

This is a first pass as improving the Python First Server
documentation. There is still much left and I haven't
tested it yet.
This commit is contained in:
David Soria Parra 2024-11-21 00:37:13 +00:00
commit ddc20437e0
No known key found for this signature in database

View file

@ -33,6 +33,7 @@ Let's build your first MCP server in Python! We'll create a weather server that
mkdir weather-server mkdir weather-server
cd weather-server cd weather-server
uv init --package --app --name weather-server uv init --package --app --name weather-server
source .venv/bin/activate # activate our virtual environment
``` ```
</Step> </Step>
@ -61,113 +62,110 @@ Let's build your first MCP server in Python! We'll create a weather server that
</Step> </Step>
<Step title="Add the base code"> <Step title="Add the base code">
In `weather_server.py`: In `weather_server.py`
```python
import os
import json
import logging
from datetime import datetime
from typing import Any
```python import requests
import os from dotenv import load_dotenv
import json from mcp.server import Server
from datetime import datetime from mcp.server.models import InitializationOptions
from typing import Any, Dict, List, Optional from mcp.types import (
ServerCapabilities,
ListResourcesResult,
ReadResourceResult,
ListToolsResult,
CallToolResult,
McpError,
)
import requests load_dotenv()
from dotenv import load_dotenv
from modelcontextprotocol import Server, StdioServerTransport
from modelcontextprotocol.types import (
ListResourcesRequest,
ReadResourceRequest,
ListToolsRequest,
CallToolRequest,
ErrorCode,
McpError
)
load_dotenv() # Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("weather-server")
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 is required") raise ValueError("OPENWEATHER_API_KEY environment variable required")
API_CONFIG = { API_BASE_URL = "http://api.openweathermap.org/data/2.5"
"BASE_URL": "http://api.openweathermap.org/data/2.5", DEFAULT_CITY = "San Francisco"
"DEFAULT_CITY": "San Francisco", CURRENT_WEATHER_ENDPOINT = "weather"
"ENDPOINTS": { FORECAST_ENDPOINT = "forecast"
"CURRENT": "weather",
"FORECAST": "forecast"
}
}
class WeatherServer: class WeatherServer(Server):
def __init__(self): def __init__(self):
self.server = Server( super().__init__("example-weather-server")
name="example-weather-server",
version="0.1.0"
)
self.session = requests.Session()
self.session.params = {
"appid": API_KEY,
"units": "metric"
}
self.setup_handlers() # Set up HTTP session
self.setup_error_handling() self.http = requests.Session()
self.http.params = {
"appid": API_KEY,
"units": "metric"
}
def setup_error_handling(self) -> None: @self.list_resources()
def on_error(error: Exception) -> None: async def list_resources(self) -> ListResourcesResult:
print("[MCP Error]", error, flush=True) # Implementation will come later
pass
self.server.onerror = on_error @self.read_resource()
async def read_resource(self, uri: str) -> ReadResourceResult:
# Implementation will come later
pass
def setup_handlers(self) -> None: @self.list_tools()
self.server.set_request_handler( async def list_tools(self) -> ListToolsResult:
ListResourcesRequest, self.handle_list_resources) # Implementation will come later
self.server.set_request_handler( pass
ReadResourceRequest, self.handle_read_resource)
self.server.set_request_handler(
ListToolsRequest, self.handle_list_tools)
self.server.set_request_handler(
CallToolRequest, self.handle_call_tool)
async def run(self) -> None: @self.call_tool()
transport = StdioServerTransport() async def call_tool(self, name: str, args: Any) -> CallToolResult:
await self.server.connect(transport) # Implementation will come later
print("Weather MCP server running on stdio", flush=True) pass
# Handler implementations will continue in next sections
``` ```
</Step> </Step>
<Step title="Add resource handlers"> <Step title="Add resource handlers">
Add these methods to the `WeatherServer` class: Add these methods to the `WeatherServer` class:
```python ```python
async def handle_list_resources( @self.list_resources()
self, async def list_resources(self) -> ListResourcesResult:
request: ListResourcesRequest """List available weather resources."""
) -> Dict[str, Any]: return ListResourcesResult(
return { resources=[
"resources": [{ Resource(
"uri": f"weather://{API_CONFIG['DEFAULT_CITY']}/current", uri=f"weather://{DEFAULT_CITY}/current",
"name": f"Current weather in {API_CONFIG['DEFAULT_CITY']}", name=f"Current weather in {DEFAULT_CITY}",
"mimeType": "application/json", mimeType="application/json",
"description": "Real-time weather data including temperature, conditions, humidity, and wind speed" description="Real-time weather data"
}] )
} ]
)
async def handle_read_resource( @self.read_resource()
self, async def read_resource(self, uri: AnyUrl) -> ReadResourceResult:
request: ReadResourceRequest """Read current weather data for a city."""
) -> Dict[str, Any]: city = DEFAULT_CITY
city = API_CONFIG["DEFAULT_CITY"] if str(uri) != f"weather://{city}/current":
if request.params.uri != f"weather://{city}/current":
raise McpError( raise McpError(
ErrorCode.INVALID_REQUEST, ErrorData(
f"Unknown resource: {request.params.uri}" code=INVALID_REQUEST,
message=f"Unknown resource: {uri}"
)
) )
try: try:
response = self.session.get( response = self.http.get(
f"{API_CONFIG['BASE_URL']}/{API_CONFIG['ENDPOINTS']['CURRENT']}", f"{API_BASE_URL}/{CURRENT_WEATHER_ENDPOINT}",
params={"q": city} params={"q": city}
) )
response.raise_for_status() response.raise_for_status()
@ -181,17 +179,21 @@ Let's build your first MCP server in Python! We'll create a weather server that
"timestamp": datetime.now().isoformat() "timestamp": datetime.now().isoformat()
} }
return { return ReadResourceResult(
"contents": [{ contents=[
"uri": request.params.uri, TextResourceContents(
"mimeType": "application/json", uri=uri,
"text": json.dumps(weather_data, indent=2) mimeType="application/json",
}] text=json.dumps(weather_data, indent=2)
} )
]
)
except requests.RequestException as e: except requests.RequestException as e:
raise McpError( raise McpError(
ErrorCode.INTERNAL_ERROR, ErrorData(
f"Weather API error: {str(e)}" code=INTERNAL_ERROR,
message=f"Weather API error: {str(e)}"
)
) )
``` ```
</Step> </Step>
@ -200,56 +202,59 @@ Let's build your first MCP server in Python! We'll create a weather server that
Add these methods to the `WeatherServer` class: Add these methods to the `WeatherServer` class:
```python ```python
async def handle_list_tools( @self.list_tools()
self, async def list_tools(self) -> ListToolsResult:
request: ListToolsRequest """List available weather tools."""
) -> Dict[str, Any]: return ListToolsResult(
return { tools=[
"tools": [{ Tool(
"name": "get_forecast", name="get_forecast",
"description": "Get weather forecast for a city", description="Get weather forecast for a city",
"inputSchema": { inputSchema={
"type": "object", "type": "object",
"properties": { "properties": {
"city": { "city": {
"type": "string", "type": "string",
"description": "City name" "description": "City name"
},
"days": {
"type": "number",
"description": "Number of days (1-5)",
"minimum": 1,
"maximum": 5
}
}, },
"days": { "required": ["city"]
"type": "number", }
"description": "Number of days (1-5)", )
"minimum": 1, ]
"maximum": 5 )
}
},
"required": ["city"]
}
}]
}
async def handle_call_tool( @self.call_tool()
self, async def call_tool(self, name: str, arguments: Any) -> CallToolResult:
request: CallToolRequest """Handle tool calls for weather forecasts."""
) -> Dict[str, Any]: if name != "get_forecast":
if request.params.name != "get_forecast":
raise McpError( raise McpError(
ErrorCode.METHOD_NOT_FOUND, ErrorData(
f"Unknown tool: {request.params.name}" code=METHOD_NOT_FOUND,
message=f"Unknown tool: {name}"
)
) )
args = request.params.arguments if not isinstance(arguments, dict) or "city" not in arguments:
if not isinstance(args, dict) or "city" not in args:
raise McpError( raise McpError(
ErrorCode.INVALID_PARAMS, ErrorData(
"Invalid forecast arguments" code=INVALID_PARAMS,
message="Invalid forecast arguments"
)
) )
city = args["city"] city = arguments["city"]
days = min(int(args.get("days", 3)), 5) days = min(int(arguments.get("days", 3)), 5)
try: try:
response = self.session.get( response = self.session.get(
f"{API_CONFIG['BASE_URL']}/{API_CONFIG['ENDPOINTS']['FORECAST']}", f"{API_BASE_URL}/{FORECAST_ENDPOINT}",
params={ params={
"q": city, "q": city,
"cnt": days * 8 # API returns 3-hour intervals "cnt": days * 8 # API returns 3-hour intervals
@ -267,11 +272,21 @@ 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 {"toolResult": forecasts} return CallToolResult(
content=[
TextContent(
type="text",
text=json.dumps(forecasts, indent=2)
)
],
isError=False
)
except requests.RequestException as e: except requests.RequestException as e:
raise McpError( raise McpError(
ErrorCode.INTERNAL_ERROR, ErrorData(
f"Weather API error: {str(e)}" code=INTERNAL_ERROR,
message=f"Weather API error: {str(e)}"
)
) )
``` ```
</Step> </Step>
@ -280,52 +295,25 @@ Let's build your first MCP server in Python! We'll create a weather server that
Add this to the end of `weather_server.py`: Add this to the end of `weather_server.py`:
```python ```python
if __name__ == "__main__": async def main():
import asyncio server = WeatherServer()
import signal
async def main(): # Import here to avoid issues with event loops
server = WeatherServer() from mcp.server.stdio import stdio_server
def signal_handler(sig, frame):
print("Shutting down...", flush=True)
asyncio.create_task(server.server.close())
signal.signal(signal.SIGINT, signal_handler)
await server.run()
asyncio.run(main()) async with stdio_server() as (read_stream, write_stream):
await server.run(
read_stream,
write_stream,
server.create_initialization_options()
)
if __name__ == "__main__":
import anyio
anyio.run(main)
``` ```
</Step> </Step>
<Step title="Make the script executable">
```bash
chmod +x weather_server.py
pip install -e .
```
Create `setup.py`:
```python
from setuptools import setup
setup(
name="weather-server",
version="0.1.0",
py_modules=["weather_server"],
entry_points={
"console_scripts": [
"weather-server=weather_server:main",
],
},
install_requires=[
"modelcontextprotocol",
"requests",
"python-dotenv"
],
)
```
</Step>
</Steps> </Steps>
## Understanding the code ## Understanding the code
@ -333,10 +321,7 @@ Let's build your first MCP server in Python! We'll create a weather server that
<Tabs> <Tabs>
<Tab title="Type Hints"> <Tab title="Type Hints">
```python ```python
async def handle_read_resource( async def read_resource(self, uri: str) -> ReadResourceResult:
self,
request: ReadResourceRequest
) -> Dict[str, Any]:
# ... # ...
``` ```
@ -345,17 +330,18 @@ Let's build your first MCP server in Python! We'll create a weather server that
<Tab title="Resources"> <Tab title="Resources">
```python ```python
async def handle_list_resources( @self.list_resources()
self, async def list_resources(self) -> ListResourcesResult:
request: ListResourcesRequest return ListResourcesResult(
) -> Dict[str, Any]: resources=[
return { Resource(
"resources": [{ uri=f"weather://{DEFAULT_CITY}/current",
"uri": f"weather://{DEFAULT_CITY}/current", name=f"Current weather in {DEFAULT_CITY}",
"name": f"Current weather in {DEFAULT_CITY}", mimeType="application/json",
"mimeType": "application/json" description="Real-time weather data"
}] )
} ]
)
``` ```
Resources provide data that Claude can access as context. Resources provide data that Claude can access as context.
@ -363,17 +349,26 @@ Let's build your first MCP server in Python! We'll create a weather server that
<Tab title="Tools"> <Tab title="Tools">
```python ```python
{ Tool(
"name": "get_forecast", name="get_forecast",
"description": "Get weather forecast for a city", description="Get weather forecast for a city",
"inputSchema": { inputSchema={
"type": "object", "type": "object",
"properties": { "properties": {
"city": { "type": "string" }, "city": {
"days": { "type": "number" } "type": "string",
} "description": "City name"
},
"days": {
"type": "number",
"description": "Number of days (1-5)",
"minimum": 1,
"maximum": 5
}
},
"required": ["city"]
} }
} )
``` ```
Tools let Claude take actions through your server with validated inputs. Tools let Claude take actions through your server with validated inputs.
@ -386,7 +381,7 @@ Let's build your first MCP server in Python! We'll create a weather server that
<Card title="Error Handling" icon="shield"> <Card title="Error Handling" icon="shield">
```python ```python
try: try:
response = self.session.get(...) response = self.http.get(...)
response.raise_for_status() response.raise_for_status()
except requests.RequestException as e: except requests.RequestException as e:
raise McpError( raise McpError(
@ -413,29 +408,11 @@ Let's build your first MCP server in Python! We'll create a weather server that
``` ```
</Card> </Card>
<Card title="Constants" icon="box">
```python
API_CONFIG = {
"BASE_URL": "...",
"ENDPOINTS": {
"CURRENT": "weather",
"FORECAST": "forecast"
}
}
```
</Card>
</CardGroup> </CardGroup>
## Available transports ## Available transports
While this guide uses stdio transport, MCP supports multiple transport options: While this guide uses stdio transport, MCP supports additonal transport options:
### WebSocket
```python
from modelcontextprotocol import WebSocketServerTransport
transport = WebSocketServerTransport()
```
### SSE (Server-Sent Events) ### SSE (Server-Sent Events)
@ -453,8 +430,10 @@ transport = SSEServerTransport("/events", response)
python --version python --version
# Reinstall dependencies # Reinstall dependencies
pip uninstall -r requirements.txt uv sync --reinstall
pip install -r requirements.txt
# Ensure we are using the right virtual environment
source .venv/bin/activate
``` ```
### Runtime errors ### Runtime errors
@ -469,10 +448,10 @@ python weather_server.py
```bash ```bash
# Install mypy # Install mypy
pip install mypy uv add --dev pyright
# Run type checker # Run type checker
mypy weather_server.py pyright src
``` ```
## Connect to Claude Desktop ## Connect to Claude Desktop
@ -485,7 +464,8 @@ mypy weather_server.py
{ {
"mcpServers": { "mcpServers": {
"weather": { "weather": {
"command": "weather-server", "command": "uv",
"args": "--directory path/to/your/project run weather-service",
"env": { "env": {
"OPENWEATHER_API_KEY": "your-api-key", "OPENWEATHER_API_KEY": "your-api-key",
} }
@ -550,45 +530,39 @@ mypy weather_server.py
async def _fetch_weather(self, city: str) -> Dict[str, Any]: async def _fetch_weather(self, city: str) -> Dict[str, Any]:
# Cache expires after 15 minutes # Cache expires after 15 minutes
now = datetime.now() now = datetime.now()
if (self._cached_weather is None or if (self._cached_weather is None or
self._last_cache_time is None or self._last_cache_time is None or
now - self._last_cache_time > self._cache_timeout): now - self._last_cache_time > self._cache_timeout):
response = self.session.get(...) response = self.session.get(...)
self._cached_weather = response.json() self._cached_weather = response.json()
self._last_cache_time = now self._last_cache_time = now
return self._cached_weather return self._cached_weather
``` ```
</Step> </Step>
<Step title="Add progress notifications"> <Step title="Add progress notifications">
```python ```python
async def handle_call_tool( @self.call_tool()
self, async def call_tool(self, name: str, arguments: Any) -> CallToolResult:
request: CallToolRequest if progress_token := self.request_context.meta.progressToken:
) -> Dict[str, Any]:
if progress_token := request.params._meta.get("progressToken"):
# Send progress notifications # Send progress notifications
await self.server.notification({ await self.request_context.session.send_progress_notification(
"method": "notifications/progress", progress_token=progress_token,
"params": { progress=1,
"progress": 1, total=2
"total": 2, )
"progressToken": progress_token
}
})
# Fetch data... # Fetch data...
await self.server.notification({ await self.request_context.session.send_progress_notification(
"method": "notifications/progress", progress_token=progress_token,
"params": { progress=2,
"progress": 2, total=2
"total": 2, )
"progressToken": progress_token
} # Rest of the method implementation...
})
``` ```
</Step> </Step>
@ -596,50 +570,43 @@ mypy weather_server.py
```python ```python
import logging import logging
class WeatherServer: class WeatherServer(Server):
def __init__(self): def __init__(self):
super().__init__("example-weather-server")
self.logger = logging.getLogger("weather-server") self.logger = logging.getLogger("weather-server")
self.logger.setLevel(logging.INFO) self.logger.setLevel(logging.INFO)
# Set up logging handler
self.server.set_request_handler(
SetLevelRequest,
self.handle_set_log_level
)
async def handle_set_log_level( @self.set_logging_level()
self, async def set_logging_level(self, level: LoggingLevel) -> EmptyResult:
request: SetLevelRequest self.logger.setLevel(level.upper())
) -> Dict[str, Any]: await self.request_context.session.send_log_message(
level = request.params.level.upper() level="info",
self.logger.setLevel(getattr(logging, level)) data=f"Log level set to {level}",
logger="weather-server"
await self.server.notification({ )
"method": "notifications/message", return EmptyResult()
"params": {
"level": "info", # Use self.logger for logging throughout the class methods
"logger": "weather-server", # For example:
"data": f"Log level set to {level}" # self.logger.info("Weather data fetched successfully")
} # self.logger.error(f"Error fetching weather data: {str(e)}")
})
return {}
``` ```
</Step> </Step>
<Step title="Add resource templates"> <Step title="Add resource templates">
```python ```python
async def handle_list_resources( @self.list_resources()
self, async def list_resources(self) -> ListResourcesResult:
request: ListResourcesRequest return ListResourcesResult(
) -> Dict[str, Any]: resources=[...],
return { resourceTemplates=[
"resources": [...], ResourceTemplate(
"resourceTemplates": [{ uriTemplate="weather://{city}/current",
"uriTemplate": "weather://{city}/current", name="Current weather for any city",
"name": "Current weather for any city", mimeType="application/json"
"mimeType": "application/json" )
}] ]
} )
``` ```
</Step> </Step>
</Steps> </Steps>
@ -648,90 +615,50 @@ mypy weather_server.py
<Steps> <Steps>
<Step title="Create test file"> <Step title="Create test file">
Create `test_weather_server.py`: Create `src/weather_service/tests/test_weather_server.py`:
```python ```python
import pytest import pytest
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
from weather_server import WeatherServer from weather_service.weather_server import WeatherServer
@pytest.fixture @pytest.fixture
async def server(): async def server():
server = WeatherServer() server = WeatherServer()
yield server yield server
await server.server.close()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_resources(server): async def test_list_resources(server):
request = Mock() result = await server.list_resources()
result = await server.handle_list_resources(request) assert len(result.resources) > 0
assert len(result["resources"]) > 0 assert result.resources[0].mimeType == "application/json"
assert result["resources"][0]["mimeType"] == "application/json"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_forecast(): async def test_get_forecast():
with patch("requests.Session") as mock_session: with patch("requests.Session") as mock_session:
mock_session.return_value.get.return_value.json.return_value = { mock_session.return_value.get.return_value.json.return_value = {
"list": [{ "list": [{
"dt_txt": "2024-01-01 12:00:00", "dt_txt": "2024-01-01 12:00:00",
"main": {"temp": 20}, "main": {"temp": 20},
"weather": [{"description": "sunny"}] "weather": [{"description": "sunny"}]
}] }]
} }
server = WeatherServer() server = WeatherServer()
request = Mock() result = await server.call_tool("get_forecast", {"city": "Tokyo", "days": 1})
request.params.name = "get_forecast" assert len(result.content) == 1
request.params.arguments = {"city": "Tokyo", "days": 1} assert "temperature" in result.content[0].text
assert "20" in result.content[0].text
result = await server.handle_call_tool(request) ```
assert len(result["toolResult"]) == 1
assert result["toolResult"][0]["temperature"] == 20
```
</Step> </Step>
<Step title="Run tests"> <Step title="Run tests">
```bash ```bash
pip install pytest pytest-asyncio uv add --dev pytest pytest-asyncio
pytest test_weather_server.py -v uv run pytest src/weather_service/
``` ```
</Step> </Step>
</Steps> </Steps>
## Deployment
<CardGroup cols={1}>
<Card title="Docker" icon="docker">
```dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
CMD ["python", "weather_server.py"]
```
</Card>
<Card title="Systemd Service" icon="server">
```ini
[Unit]
Description=Weather MCP Server
After=network.target
[Service]
ExecStart=/path/to/venv/bin/python /path/to/weather_server.py
Environment=OPENWEATHER_API_KEY=your-key-here
Restart=always
[Install]
WantedBy=multi-user.target
```
</Card>
</CardGroup>
## Next steps ## Next steps
<CardGroup cols={2}> <CardGroup cols={2}>