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,79 +62,74 @@ 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 ```python
import os import os
import json import json
import logging
from datetime import datetime from datetime import datetime
from typing import Any, Dict, List, Optional from typing import Any
import requests import requests
from dotenv import load_dotenv from dotenv import load_dotenv
from modelcontextprotocol import Server, StdioServerTransport from mcp.server import Server
from modelcontextprotocol.types import ( from mcp.server.models import InitializationOptions
ListResourcesRequest, from mcp.types import (
ReadResourceRequest, ServerCapabilities,
ListToolsRequest, ListResourcesResult,
CallToolRequest, ReadResourceResult,
ErrorCode, ListToolsResult,
McpError CallToolResult,
McpError,
) )
load_dotenv() 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() # Set up HTTP session
self.session.params = { self.http = requests.Session()
self.http.params = {
"appid": API_KEY, "appid": API_KEY,
"units": "metric" "units": "metric"
} }
self.setup_handlers() @self.list_resources()
self.setup_error_handling() async def list_resources(self) -> ListResourcesResult:
# Implementation will come later
pass
def setup_error_handling(self) -> None: @self.read_resource()
def on_error(error: Exception) -> None: async def read_resource(self, uri: str) -> ReadResourceResult:
print("[MCP Error]", error, flush=True) # Implementation will come later
pass
self.server.onerror = on_error @self.list_tools()
async def list_tools(self) -> ListToolsResult:
# Implementation will come later
pass
def setup_handlers(self) -> None: @self.call_tool()
self.server.set_request_handler( async def call_tool(self, name: str, args: Any) -> CallToolResult:
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:
transport = StdioServerTransport()
await self.server.connect(transport)
print("Weather MCP server running on stdio", flush=True)
# Handler implementations will continue in next sections
``` ```
</Step> </Step>
@ -141,33 +137,35 @@ 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_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,15 +202,15 @@ 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": {
@ -224,32 +226,35 @@ Let's build your first MCP server in Python! We'll create a weather server that
}, },
"required": ["city"] "required": ["city"]
} }
}] )
} ]
async def handle_call_tool(
self,
request: CallToolRequest
) -> Dict[str, Any]:
if request.params.name != "get_forecast":
raise McpError(
ErrorCode.METHOD_NOT_FOUND,
f"Unknown tool: {request.params.name}"
) )
args = request.params.arguments @self.call_tool()
if not isinstance(args, dict) or "city" not in args: async def call_tool(self, name: str, arguments: Any) -> CallToolResult:
"""Handle tool calls for weather forecasts."""
if name != "get_forecast":
raise McpError( raise McpError(
ErrorCode.INVALID_PARAMS, ErrorData(
"Invalid forecast arguments" code=METHOD_NOT_FOUND,
message=f"Unknown tool: {name}"
)
) )
city = args["city"] if not isinstance(arguments, dict) or "city" not in arguments:
days = min(int(args.get("days", 3)), 5) raise McpError(
ErrorData(
code=INVALID_PARAMS,
message="Invalid forecast arguments"
)
)
city = arguments["city"]
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__":
import asyncio
import signal
async def main(): async def main():
server = WeatherServer() server = WeatherServer()
def signal_handler(sig, frame): # Import here to avoid issues with event loops
print("Shutting down...", flush=True) from mcp.server.stdio import stdio_server
asyncio.create_task(server.server.close())
signal.signal(signal.SIGINT, signal_handler) async with stdio_server() as (read_stream, write_stream):
await server.run() await server.run(
read_stream,
asyncio.run(main()) write_stream,
``` server.create_initialization_options()
</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"
],
) )
if __name__ == "__main__":
import anyio
anyio.run(main)
``` ```
</Step> </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",
} }
@ -564,31 +544,25 @@ mypy weather_server.py
<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.set_logging_level()
self.server.set_request_handler( async def set_logging_level(self, level: LoggingLevel) -> EmptyResult:
SetLevelRequest, self.logger.setLevel(level.upper())
self.handle_set_log_level await self.request_context.session.send_log_message(
level="info",
data=f"Log level set to {level}",
logger="weather-server"
) )
return EmptyResult()
async def handle_set_log_level( # Use self.logger for logging throughout the class methods
self, # For example:
request: SetLevelRequest # self.logger.info("Weather data fetched successfully")
) -> Dict[str, Any]: # self.logger.error(f"Error fetching weather data: {str(e)}")
level = request.params.level.upper()
self.logger.setLevel(getattr(logging, level))
await self.server.notification({
"method": "notifications/message",
"params": {
"level": "info",
"logger": "weather-server",
"data": f"Log level set to {level}"
}
})
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,25 +615,23 @@ 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():
@ -680,58 +645,20 @@ mypy weather_server.py
} }
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}>