From 1e2a3e5234a61e53ee1cbfc3889d63360fefb645 Mon Sep 17 00:00:00 2001 From: Kyle Gray Date: Tue, 10 Dec 2024 11:39:46 -0800 Subject: [PATCH 1/4] docs: Add a building your first server section --- docs/first-server.mdx | 825 +++++++++++++++++++++++++++++++++++++++ examples/weather/main.go | 0 2 files changed, 825 insertions(+) create mode 100644 docs/first-server.mdx create mode 100644 examples/weather/main.go diff --git a/docs/first-server.mdx b/docs/first-server.mdx new file mode 100644 index 0000000..e41c917 --- /dev/null +++ b/docs/first-server.mdx @@ -0,0 +1,825 @@ +# Create a simple MCP server in Go in 15 minutes + +Let's build your first MCP server in Go! We'll create a weather server that provides current weather data as a resource and lets Claude fetch forecasts using tools. + + + This guide uses the OpenWeatherMap API. You'll need a free API key from [OpenWeatherMap](https://openweathermap.org/api) to follow along. + + +## Prerequisites + + + The following steps are for macOS. Guides for other platforms are coming soon. + + + + + You'll need Python 3.10 or higher: + + ```bash + python --version # Should be 3.10 or higher + ``` + + + + See https://docs.astral.sh/uv/ for more information. + + ```bash + brew install uv + uv --version # Should be 0.4.18 or higher + ``` + + + + ```bash + uvx create-mcp-server --path weather_service + cd weather_service + ``` + + + + ```bash + uv add httpx python-dotenv + ``` + + + + Create `.env`: + + ```bash + OPENWEATHER_API_KEY=your-api-key-here + ``` + + + +## Create your server + + + + In `weather_service/src/weather_service/server.py` + ```python + import os + import json + import logging + from datetime import datetime, timedelta + from collections.abc import Sequence + from functools import lru_cache + from typing import Any + + import httpx + import asyncio + from dotenv import load_dotenv + from mcp.server import Server + from mcp.types import ( + Resource, + Tool, + TextContent, + ImageContent, + EmbeddedResource, + LoggingLevel + ) + from pydantic import AnyUrl + + # Load environment variables + load_dotenv() + + # Configure logging + logging.basicConfig(level=logging.INFO) + logger = logging.getLogger("weather-server") + + # API configuration + API_KEY = os.getenv("OPENWEATHER_API_KEY") + if not API_KEY: + raise ValueError("OPENWEATHER_API_KEY environment variable required") + + API_BASE_URL = "http://api.openweathermap.org/data/2.5" + DEFAULT_CITY = "London" + CURRENT_WEATHER_ENDPOINT = "weather" + FORECAST_ENDPOINT = "forecast" + + # The rest of our server implementation will go here + ``` + + + + Add this functionality: + + ```python + # Create reusable params + http_params = { + "appid": API_KEY, + "units": "metric" + } + + async def fetch_weather(city: str) -> dict[str, Any]: + async with httpx.AsyncClient() as client: + response = await client.get( + f"{API_BASE_URL}/weather", + params={"q": city, **http_params} + ) + response.raise_for_status() + data = response.json() + + return { + "temperature": data["main"]["temp"], + "conditions": data["weather"][0]["description"], + "humidity": data["main"]["humidity"], + "wind_speed": data["wind"]["speed"], + "timestamp": datetime.now().isoformat() + } + + + app = Server("weather-server") + ``` + + + + Add these resource-related handlers to our main function: + + ```python + app = Server("weather-server") + + @app.list_resources() + 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 httpx.HTTPError as e: + raise RuntimeError(f"Weather API error: {str(e)}") + + ``` + + + + 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.""" + if name != "get_forecast": + raise ValueError(f"Unknown tool: {name}") + + if not isinstance(arguments, dict) or "city" not in arguments: + raise ValueError("Invalid forecast arguments") + + city = arguments["city"] + days = min(int(arguments.get("days", 3)), 5) + + try: + async with httpx.AsyncClient() as client: + response = await client.get( + f"{API_BASE_URL}/{FORECAST_ENDPOINT}", + params={ + "q": city, + "cnt": days * 8, # API returns 3-hour intervals + **http_params, + } + ) + response.raise_for_status() + data = response.json() + + forecasts = [] + for i in range(0, len(data["list"]), 8): + day_data = data["list"][i] + forecasts.append({ + "date": day_data["dt_txt"].split()[0], + "temperature": day_data["main"]["temp"], + "conditions": day_data["weather"][0]["description"] + }) + + return [ + TextContent( + type="text", + text=json.dumps(forecasts, indent=2) + ) + ] + except httpx.HTTPError as e: + logger.error(f"Weather API error: {str(e)}") + raise RuntimeError(f"Weather API error: {str(e)}") + ``` + + + + Add this to the end of `weather_service/src/weather_service/server.py`: + + ```python + async def main(): + # Import here to avoid issues with event loops + from mcp.server.stdio import stdio_server + + async with stdio_server() as (read_stream, write_stream): + await app.run( + read_stream, + write_stream, + app.create_initialization_options() + ) + ``` + + + + 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'] + ``` + + + + + + +## Connect to Claude Desktop + + + + Add to `claude_desktop_config.json`: + + ```json + { + "mcpServers": { + "weather": { + "command": "uv", + "args": [ + "--directory", + "path/to/your/project", + "run", + "weather-service" + ], + "env": { + "OPENWEATHER_API_KEY": "your-api-key" + } + } + } + } + ``` + + + + 1. Quit Claude completely + + 2. Start Claude again + + 3. Look for your weather server in the 🔌 menu + + + +## Try it out! + + + + Ask Claude: + + ``` + What's the current weather in San Francisco? Can you analyze the conditions and tell me if it's a good day for outdoor activities? + ``` + + + + Ask Claude: + + ``` + Can you get me a 5-day forecast for Tokyo and help me plan what clothes to pack for my trip? + ``` + + + + Ask Claude: + + ``` + Can you analyze the forecast for both Tokyo and San Francisco and tell me which city would be better for outdoor photography this week? + ``` + + + +## Understanding the code + + + + ```python + async def read_resource(self, uri: str) -> ReadResourceResult: + # ... + ``` + + Python type hints help catch errors early and improve code maintainability. + + + + ```python + @app.list_resources() + async def list_resources(self) -> ListResourcesResult: + return ListResourcesResult( + resources=[ + Resource( + uri=f"weather://{DEFAULT_CITY}/current", + name=f"Current weather in {DEFAULT_CITY}", + mimeType="application/json", + description="Real-time weather data" + ) + ] + ) + ``` + + Resources provide data that Claude can access as context. + + + + ```python + 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"] + } + ) + ``` + + Tools let Claude take actions through your server with validated inputs. + + + + ```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. + + + +## Best practices + + + + ```python + try: + async with httpx.AsyncClient() as client: + response = await client.get(..., params={..., **http_params}) + response.raise_for_status() + except httpx.HTTPError as e: + raise McpError( + ErrorCode.INTERNAL_ERROR, + f"API error: {str(e)}" + ) + ``` + + + + ```python + if not isinstance(args, dict) or "city" not in args: + raise McpError( + ErrorCode.INVALID_PARAMS, + "Invalid forecast arguments" + ) + ``` + + + + ```python + if not API_KEY: + raise ValueError("OPENWEATHER_API_KEY is required") + ``` + + + + +## Available transports + +While this guide uses stdio transport, MCP supports additional transport options: + +### SSE (Server-Sent Events) + +```python +from mcp.server.sse import SseServerTransport +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) +``` + +## Advanced features + + + + 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)] + ``` + + + + ```python + # Cache settings + cache_timeout = timedelta(minutes=15) + last_cache_time = None + cached_weather = None + + async def fetch_weather(city: str) -> dict[str, Any]: + global cached_weather, last_cache_time + + now = datetime.now() + if (cached_weather is None or + last_cache_time is None or + now - last_cache_time > cache_timeout): + + async with httpx.AsyncClient() as client: + response = await client.get( + f"{API_BASE_URL}/{CURRENT_WEATHER_ENDPOINT}", + params={"q": city, **http_params} + ) + response.raise_for_status() + data = response.json() + + 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 + ``` + + + + ```python + @self.call_tool() + async def call_tool(self, name: str, arguments: Any) -> CallToolResult: + if progress_token := self.request_context.meta.progressToken: + # Send progress notifications + await self.request_context.session.send_progress_notification( + progress_token=progress_token, + progress=1, + total=2 + ) + + # Fetch data... + + await self.request_context.session.send_progress_notification( + progress_token=progress_token, + progress=2, + total=2 + ) + + # Rest of the method implementation... + ``` + + + + ```python + # Set up logging + logger = logging.getLogger("weather-server") + logger.setLevel(logging.INFO) + + @app.set_logging_level() + async def set_logging_level(level: LoggingLevel) -> EmptyResult: + logger.setLevel(level.upper()) + await app.request_context.session.send_log_message( + level="info", + data=f"Log level set to {level}", + logger="weather-server" + ) + return EmptyResult() + + # Use logger throughout the code + # For example: + # logger.info("Weather data fetched successfully") + # logger.error(f"Error fetching weather data: {str(e)}") + ``` + + + + ```python + @app.list_resource_templates() + async def list_resource_templates() -> list[ResourceTemplate]: + return [ + ResourceTemplate( + uriTemplate="weather://{city}/current", + name="Current weather for any city", + mimeType="application/json" + ) + ] + ``` + + + +## Testing + + + + Create `tests/weather_test.py`: + + ```python + import pytest + import os + 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 + def anyio_backend(): + return "asyncio" + + @pytest.fixture + def mock_weather_response(): + return { + "main": { + "temp": 20.5, + "humidity": 65 + }, + "weather": [ + {"description": "scattered clouds"} + ], + "wind": { + "speed": 3.6 + } + } + + @pytest.fixture + def mock_forecast_response(): + return { + "list": [ + { + "dt_txt": "2024-01-01 12:00:00", + "main": {"temp": 18.5}, + "weather": [{"description": "sunny"}] + }, + { + "dt_txt": "2024-01-02 12:00:00", + "main": {"temp": 17.2}, + "weather": [{"description": "cloudy"}] + } + ] + } + + @pytest.mark.anyio + 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.anyio + 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() + } + + uri = AnyUrl("weather://London/current") + result = await read_resource(uri) + + assert isinstance(result, str) + assert "temperature" in result + assert "clear sky" in result + + @pytest.mark.anyio + async def test_call_tool(mock_forecast_response): + class Response(): + def raise_for_status(self): + pass + + def json(self): + return mock_forecast_response + + class AsyncClient(): + async def __aenter__(self): + return self + + async def __aexit__(self, *exc_info): + pass + + async def get(self, *args, **kwargs): + return Response() + + with patch('httpx.AsyncClient', new=AsyncClient) as mock_client: + 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.anyio + 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.anyio + 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"] + ``` + + + ```bash + uv add --dev pytest + uv run pytest + ``` + + + +## Troubleshooting + +### Installation issues + +```bash +# Check Python version +python --version + +# Reinstall dependencies +uv sync --reinstall +``` + +### Type checking + +```bash +# Install mypy +uv add --dev pyright + +# Run type checker +uv run pyright src +``` + +## Next steps + + + + Learn more about the MCP architecture + + + + Check out the Python SDK on GitHub + + \ No newline at end of file diff --git a/examples/weather/main.go b/examples/weather/main.go new file mode 100644 index 0000000..e69de29 From 44ca2c8481e4c8822bc572e865a81f38019fc786 Mon Sep 17 00:00:00 2001 From: Kyle Gray Date: Tue, 10 Dec 2024 14:16:11 -0800 Subject: [PATCH 2/4] Builds --- README.md | 27 ++++++--- docs/{first-server.mdx => first-server.md} | 62 +++++++------------- examples/weather/fetch.go | 66 ++++++++++++++++++++++ examples/weather/main.go | 42 ++++++++++++++ examples/weather/resources.go | 55 ++++++++++++++++++ examples/weather/tools.go | 64 +++++++++++++++++++++ 6 files changed, 267 insertions(+), 49 deletions(-) rename docs/{first-server.mdx => first-server.md} (95%) create mode 100644 examples/weather/fetch.go create mode 100644 examples/weather/resources.go create mode 100644 examples/weather/tools.go diff --git a/README.md b/README.md index cb94afd..5baee99 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,21 @@ -mcp-go -======= +MCP Go SDK +========== [![Build](https://github.com/riza-io/mcp-go/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/riza-io/mcp-go/actions/workflows/ci.yml) [![Report Card](https://goreportcard.com/badge/github.com/riza-io/mcp-go)](https://goreportcard.com/report/github.com/riza-io/mcp-go) [![GoDoc](https://pkg.go.dev/badge/github.com/riza-io/mcp-go.svg)](https://pkg.go.dev/github.com/riza-io/mcp-go) -mcp-go is a Go implementation of the [Model Context -Protocol](https://modelcontextprotocol.io/introduction). The client and server -support resources, prompts and tools. Support for sampling and roots in on the -roadmap. + +Go implementation of the [Model Context Protocol](https://modelcontextprotocol.io) (MCP), providing both client and server capabilities for integrating with LLM surfaces. + +## Overview + +The Model Context Protocol allows applications to provide context for LLMs in a standardized way, separating the concerns of providing context from the actual LLM interaction. This Go SDK implements the full MCP specification, making it easy to: + +- Build MCP clients that can connect to any MCP server +- Create MCP servers that expose resources, prompts and tools +- Use standard transports like stdio and SSE (coming soon) +- Handle all MCP protocol messages and lifecycle events ## A small example @@ -116,6 +123,12 @@ This example can be compiled and wired up to Claude Desktop (or any other MCP cl } ``` +## Documentation + +- [Model Context Protocol documentation](https://modelcontextprotocol.io) +- [MCP Specification](https://spec.modelcontextprotocol.io) +- [Example Servers](https://github.com/riza-io/mcp-go/tree/main/examples) + ## Roadmap The majority of the base protocol has been implemented. The following features are on our roadmap: @@ -126,4 +139,4 @@ The majority of the base protocol has been implemented. The following features a ## Legal -Offered under the [MIT license][license]. +Offered under the [MIT license][license]. \ No newline at end of file diff --git a/docs/first-server.mdx b/docs/first-server.md similarity index 95% rename from docs/first-server.mdx rename to docs/first-server.md index e41c917..b58aa99 100644 --- a/docs/first-server.mdx +++ b/docs/first-server.md @@ -2,61 +2,39 @@ Let's build your first MCP server in Go! We'll create a weather server that provides current weather data as a resource and lets Claude fetch forecasts using tools. - - This guide uses the OpenWeatherMap API. You'll need a free API key from [OpenWeatherMap](https://openweathermap.org/api) to follow along. - +> This guide uses the OpenWeatherMap API. You'll need a free API key from [OpenWeatherMap](https://openweathermap.org/api) to follow along. ## Prerequisites - - The following steps are for macOS. Guides for other platforms are coming soon. - +> The following steps are for macOS. Guides for other platforms are coming soon. - - - You'll need Python 3.10 or higher: +You'll need Go 1.22 or higher: - ```bash - python --version # Should be 3.10 or higher - ``` - +```bash +go version # Should be 1.22 or higher +``` - - See https://docs.astral.sh/uv/ for more information. - ```bash - brew install uv - uv --version # Should be 0.4.18 or higher - ``` - +Create a new module using `go mod init` - - ```bash - uvx create-mcp-server --path weather_service - cd weather_service - ``` - - - - ```bash - uv add httpx python-dotenv - ``` - +```bash +mkdir mcp-go-weather +cd mcp-go-weather +go mod init github.com/example/mcp-go-weather +``` - - Create `.env`: +Create `.env` - ```bash - OPENWEATHER_API_KEY=your-api-key-here - ``` - - +```bash +OPENWEATHER_API_KEY=your-api-key-here +``` ## Create your server - - - In `weather_service/src/weather_service/server.py` +### Add the base imports and setup + +In `weather_service/src/weather_service/server.py` + ```python import os import json diff --git a/examples/weather/fetch.go b/examples/weather/fetch.go new file mode 100644 index 0000000..19bf72b --- /dev/null +++ b/examples/weather/fetch.go @@ -0,0 +1,66 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/url" + "strconv" + "time" +) + +type WeatherData struct { + Temperature float64 `json:"temperature"` + Conditions string `json:"conditions"` + Humidity float64 `json:"humidity"` + WindSpeed float64 `json:"wind_speed"` + Timestamp time.Time `json:"timestamp"` +} + +func (s *WeatherServer) fetchWeather(city string) (*WeatherData, error) { + q := url.Values{} + q.Set("q", city) + q.Set("appid", s.key) + q.Set("units", "metric") + + uri := "http://api.openweathermap.org/data/2.5/weather?" + q.Encode() + resp, err := http.Get(uri) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var data WeatherData + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return nil, err + } + + return &data, nil +} + +type Forecast struct { + Date string `json:"date"` + Temperature float64 `json:"temperature"` + Conditions string `json:"conditions"` +} + +func (s *WeatherServer) fetchForecast(city string, days int) ([]Forecast, error) { + q := url.Values{} + q.Set("q", city) + q.Set("appid", s.key) + q.Set("units", "metric") + q.Set("cnt", strconv.Itoa(days)) + + uri := "http://api.openweathermap.org/data/2.5/weather?" + q.Encode() + resp, err := http.Get(uri) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var data WeatherData + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return nil, err + } + + return nil, nil +} diff --git a/examples/weather/main.go b/examples/weather/main.go index e69de29..bbce39b 100644 --- a/examples/weather/main.go +++ b/examples/weather/main.go @@ -0,0 +1,42 @@ +package main + +import ( + "context" + "log" + "os" + + "github.com/riza-io/mcp-go" +) + +type WeatherServer struct { + key string + defaultCity string + + mcp.UnimplementedServer +} + +func (s *WeatherServer) Initialize(ctx context.Context, req *mcp.Request[mcp.InitializeRequest]) (*mcp.Response[mcp.InitializeResponse], error) { + return mcp.NewResponse(&mcp.InitializeResponse{ + ProtocolVersion: req.Params.ProtocolVersion, + Capabilities: mcp.ServerCapabilities{ + Resources: &mcp.Resources{}, + Tools: &mcp.Tools{}, + }, + }), nil +} + +func main() { + ctx := context.Background() + + if os.Getenv("OPENWEATHER_API_KEY") == "" { + log.Fatal("OPENWEATHER_API_KEY environment variable required") + } + + server := mcp.NewStdioServer(&WeatherServer{ + key: os.Getenv("OPENWEATHER_API_KEY"), + }) + + if err := server.Listen(ctx, os.Stdin, os.Stdout); err != nil { + log.Fatal(err) + } +} diff --git a/examples/weather/resources.go b/examples/weather/resources.go new file mode 100644 index 0000000..cf2612b --- /dev/null +++ b/examples/weather/resources.go @@ -0,0 +1,55 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/riza-io/mcp-go" +) + +// List available weather resources. +func (s *WeatherServer) ListResources(ctx context.Context, req *mcp.Request[mcp.ListResourcesRequest]) (*mcp.Response[mcp.ListResourcesResponse], error) { + return mcp.NewResponse(&mcp.ListResourcesResponse{ + Resources: []mcp.Resource{ + { + URI: "weather://" + s.defaultCity + "/current", + Name: "Current weather in " + s.defaultCity, + Description: "Real-time weather data", + MimeType: "application/json", + }, + }, + }), nil +} + +func (s *WeatherServer) ReadResource(ctx context.Context, req *mcp.Request[mcp.ReadResourceRequest]) (*mcp.Response[mcp.ReadResourceResponse], error) { + city := s.defaultCity + + if strings.HasPrefix(req.Params.URI, "weather://") && strings.HasSuffix(req.Params.URI, "/current") { + city = strings.TrimPrefix(req.Params.URI, "weather://") + city = strings.TrimSuffix(city, "/current") + } else { + return nil, fmt.Errorf("unknown resource: %s", req.Params.URI) + } + + data, err := s.fetchWeather(city) + if err != nil { + return nil, err + } + + contents, err := json.Marshal(data) + if err != nil { + return nil, err + } + + return mcp.NewResponse(&mcp.ReadResourceResponse{ + Contents: []mcp.ResourceContent{ + { + URI: req.Params.URI, + MimeType: "application/json", + Text: string(contents), + }, + }, + }), nil +} diff --git a/examples/weather/tools.go b/examples/weather/tools.go new file mode 100644 index 0000000..18de9ff --- /dev/null +++ b/examples/weather/tools.go @@ -0,0 +1,64 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/riza-io/mcp-go" +) + +const getForecastSchema = `{ + "type": "object", + "properties": { + "city": { + "type": "string", + "description": "City name" + }, + "days": { + "type": "number", + "description": "Number of days (1-5)", + "minimum": 1, + "maximum": 5 + } + }, + "required": ["city"] +}` + +type GetForecastArguments struct { + City string `json:"city"` + Days int `json:"days"` +} + +func (s *WeatherServer) ListTools(ctx context.Context, req *mcp.Request[mcp.ListToolsRequest]) (*mcp.Response[mcp.ListToolsResponse], error) { + return mcp.NewResponse(&mcp.ListToolsResponse{ + Tools: []mcp.Tool{ + { + Name: "get_forecast", + Description: "Get weather forecast for a city", + InputSchema: json.RawMessage([]byte(getForecastSchema)), + }, + }, + }), nil +} + +func (s *WeatherServer) CallTool(ctx context.Context, req *mcp.Request[mcp.CallToolRequest]) (*mcp.Response[mcp.CallToolResponse], error) { + var args GetForecastArguments + if err := json.Unmarshal(req.Params.Arguments, &args); err != nil { + return nil, err + } + + forecasts, err := s.fetchForecast(args.City, args.Days) + if err != nil { + return nil, err + } + + return mcp.NewResponse(&mcp.CallToolResponse{ + Content: []mcp.Content{ + { + Type: "text", + Text: fmt.Sprintf("Forecast for %s for the next %d days:\n%v", args.City, args.Days, forecasts), + }, + }, + }), nil +} From 0ab992a2306d729cf5efd32e0d3b8d6631fc03ab Mon Sep 17 00:00:00 2001 From: Kyle Gray Date: Tue, 10 Dec 2024 14:43:43 -0800 Subject: [PATCH 3/4] Implement the Weather server --- examples/weather/fetch.go | 63 ++++++++++++++++++++++++++++++++++----- examples/weather/main.go | 3 +- examples/weather/tools.go | 8 +++-- 3 files changed, 63 insertions(+), 11 deletions(-) diff --git a/examples/weather/fetch.go b/examples/weather/fetch.go index 19bf72b..8a75094 100644 --- a/examples/weather/fetch.go +++ b/examples/weather/fetch.go @@ -5,10 +5,11 @@ import ( "net/http" "net/url" "strconv" + "strings" "time" ) -type WeatherData struct { +type Weather struct { Temperature float64 `json:"temperature"` Conditions string `json:"conditions"` Humidity float64 `json:"humidity"` @@ -16,7 +17,20 @@ type WeatherData struct { Timestamp time.Time `json:"timestamp"` } -func (s *WeatherServer) fetchWeather(city string) (*WeatherData, error) { +type weatherResponse struct { + Main struct { + Temp float64 `json:"temp"` + Humidity float64 `json:"humidity"` + } `json:"main"` + Wind struct { + Speed float64 `json:"speed"` + } `json:"wind"` + Weather []struct { + Description string `json:"description"` + } `json:"weather"` +} + +func (s *WeatherServer) fetchWeather(city string) (*Weather, error) { q := url.Values{} q.Set("q", city) q.Set("appid", s.key) @@ -29,12 +43,18 @@ func (s *WeatherServer) fetchWeather(city string) (*WeatherData, error) { } defer resp.Body.Close() - var data WeatherData + var data weatherResponse if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { return nil, err } - return &data, nil + return &Weather{ + Temperature: data.Main.Temp, + Conditions: data.Weather[0].Description, + Humidity: data.Main.Humidity, + WindSpeed: data.Wind.Speed, + Timestamp: time.Now(), + }, nil } type Forecast struct { @@ -43,24 +63,51 @@ type Forecast struct { Conditions string `json:"conditions"` } +type forecastResponse struct { + List []struct { + DatetimeText string `json:"dt_txt"` + Main struct { + Temp float64 `json:"temp"` + } `json:"main"` + Weather []struct { + Description string `json:"description"` + } `json:"weather"` + } `json:"list"` +} + func (s *WeatherServer) fetchForecast(city string, days int) ([]Forecast, error) { q := url.Values{} q.Set("q", city) + q.Set("cnt", strconv.Itoa(days*8)) q.Set("appid", s.key) q.Set("units", "metric") - q.Set("cnt", strconv.Itoa(days)) - uri := "http://api.openweathermap.org/data/2.5/weather?" + q.Encode() + uri := "http://api.openweathermap.org/data/2.5/forecast?" + q.Encode() resp, err := http.Get(uri) if err != nil { return nil, err } defer resp.Body.Close() - var data WeatherData + var data forecastResponse if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { return nil, err } - return nil, nil + forecasts := []Forecast{} + for i, day_data := range data.List { + if i%8 != 0 { + continue + } + + date, _, _ := strings.Cut(day_data.DatetimeText, " ") + + forecasts = append(forecasts, Forecast{ + Date: date, + Temperature: day_data.Main.Temp, + Conditions: day_data.Weather[0].Description, + }) + } + + return forecasts, nil } diff --git a/examples/weather/main.go b/examples/weather/main.go index bbce39b..7da0bf9 100644 --- a/examples/weather/main.go +++ b/examples/weather/main.go @@ -33,7 +33,8 @@ func main() { } server := mcp.NewStdioServer(&WeatherServer{ - key: os.Getenv("OPENWEATHER_API_KEY"), + defaultCity: "London", + key: os.Getenv("OPENWEATHER_API_KEY"), }) if err := server.Listen(ctx, os.Stdin, os.Stdout); err != nil { diff --git a/examples/weather/tools.go b/examples/weather/tools.go index 18de9ff..d7d24ed 100644 --- a/examples/weather/tools.go +++ b/examples/weather/tools.go @@ -3,7 +3,6 @@ package main import ( "context" "encoding/json" - "fmt" "github.com/riza-io/mcp-go" ) @@ -53,11 +52,16 @@ func (s *WeatherServer) CallTool(ctx context.Context, req *mcp.Request[mcp.CallT return nil, err } + text, err := json.MarshalIndent(forecasts, "", " ") + if err != nil { + return nil, err + } + return mcp.NewResponse(&mcp.CallToolResponse{ Content: []mcp.Content{ { Type: "text", - Text: fmt.Sprintf("Forecast for %s for the next %d days:\n%v", args.City, args.Days, forecasts), + Text: string(text), }, }, }), nil From 5d6772227d94a781fe7789b6071570efb3600efc Mon Sep 17 00:00:00 2001 From: Kyle Gray Date: Tue, 10 Dec 2024 14:52:58 -0800 Subject: [PATCH 4/4] Update first server docs --- docs/first-server.md | 1067 +++++++++++++----------------------------- 1 file changed, 327 insertions(+), 740 deletions(-) diff --git a/docs/first-server.md b/docs/first-server.md index b58aa99..5d9960a 100644 --- a/docs/first-server.md +++ b/docs/first-server.md @@ -6,16 +6,13 @@ Let's build your first MCP server in Go! We'll create a weather server that prov ## Prerequisites -> The following steps are for macOS. Guides for other platforms are coming soon. - -You'll need Go 1.22 or higher: +You'll need Go 1.22 or higher ```bash go version # Should be 1.22 or higher ``` - -Create a new module using `go mod init` +Create a new module using `go mod init`. ```bash mkdir mcp-go-weather @@ -23,781 +20,371 @@ cd mcp-go-weather go mod init github.com/example/mcp-go-weather ``` -Create `.env` +Add your API key to the environment. ```bash -OPENWEATHER_API_KEY=your-api-key-here +export OPENWEATHER_API_KEY=your-api-key-here ``` ## Create your server ### Add the base imports and setup -In `weather_service/src/weather_service/server.py` - - ```python - import os - import json - import logging - from datetime import datetime, timedelta - from collections.abc import Sequence - from functools import lru_cache - from typing import Any - - import httpx - import asyncio - from dotenv import load_dotenv - from mcp.server import Server - from mcp.types import ( - Resource, - Tool, - TextContent, - ImageContent, - EmbeddedResource, - LoggingLevel - ) - from pydantic import AnyUrl - - # Load environment variables - load_dotenv() - - # Configure logging - logging.basicConfig(level=logging.INFO) - logger = logging.getLogger("weather-server") - - # API configuration - API_KEY = os.getenv("OPENWEATHER_API_KEY") - if not API_KEY: - raise ValueError("OPENWEATHER_API_KEY environment variable required") - - API_BASE_URL = "http://api.openweathermap.org/data/2.5" - DEFAULT_CITY = "London" - CURRENT_WEATHER_ENDPOINT = "weather" - FORECAST_ENDPOINT = "forecast" - - # The rest of our server implementation will go here - ``` - - - - Add this functionality: - - ```python - # Create reusable params - http_params = { - "appid": API_KEY, - "units": "metric" - } - - async def fetch_weather(city: str) -> dict[str, Any]: - async with httpx.AsyncClient() as client: - response = await client.get( - f"{API_BASE_URL}/weather", - params={"q": city, **http_params} - ) - response.raise_for_status() - data = response.json() - - return { - "temperature": data["main"]["temp"], - "conditions": data["weather"][0]["description"], - "humidity": data["main"]["humidity"], - "wind_speed": data["wind"]["speed"], - "timestamp": datetime.now().isoformat() - } +In `main.go` add the entry point and base server implemenation. +```go +package main - app = Server("weather-server") - ``` - - - - Add these resource-related handlers to our main function: - - ```python - app = Server("weather-server") - - @app.list_resources() - 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 httpx.HTTPError as e: - raise RuntimeError(f"Weather API error: {str(e)}") - - ``` - - - - 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.""" - if name != "get_forecast": - raise ValueError(f"Unknown tool: {name}") - - if not isinstance(arguments, dict) or "city" not in arguments: - raise ValueError("Invalid forecast arguments") - - city = arguments["city"] - days = min(int(arguments.get("days", 3)), 5) - - try: - async with httpx.AsyncClient() as client: - response = await client.get( - f"{API_BASE_URL}/{FORECAST_ENDPOINT}", - params={ - "q": city, - "cnt": days * 8, # API returns 3-hour intervals - **http_params, - } - ) - response.raise_for_status() - data = response.json() - - forecasts = [] - for i in range(0, len(data["list"]), 8): - day_data = data["list"][i] - forecasts.append({ - "date": day_data["dt_txt"].split()[0], - "temperature": day_data["main"]["temp"], - "conditions": day_data["weather"][0]["description"] - }) - - return [ - TextContent( - type="text", - text=json.dumps(forecasts, indent=2) - ) - ] - except httpx.HTTPError as e: - logger.error(f"Weather API error: {str(e)}") - raise RuntimeError(f"Weather API error: {str(e)}") - ``` - - - - Add this to the end of `weather_service/src/weather_service/server.py`: - - ```python - async def main(): - # Import here to avoid issues with event loops - from mcp.server.stdio import stdio_server - - async with stdio_server() as (read_stream, write_stream): - await app.run( - read_stream, - write_stream, - app.create_initialization_options() - ) - ``` - - - - 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'] - ``` - - +import ( + "context" + "log" + "os" + "github.com/riza-io/mcp-go" +) +type WeatherServer struct { + key string + defaultCity string + + mcp.UnimplementedServer +} + +func (s *WeatherServer) Initialize(ctx context.Context, req *mcp.Request[mcp.InitializeRequest]) (*mcp.Response[mcp.InitializeResponse], error) { + return mcp.NewResponse(&mcp.InitializeResponse{ + ProtocolVersion: req.Params.ProtocolVersion, + Capabilities: mcp.ServerCapabilities{ + Resources: &mcp.Resources{}, + Tools: &mcp.Tools{}, + }, + }), nil +} + +func main() { + ctx := context.Background() + + if os.Getenv("OPENWEATHER_API_KEY") == "" { + log.Fatal("OPENWEATHER_API_KEY environment variable required") + } + + server := mcp.NewStdioServer(&WeatherServer{ + defaultCity: "London", + key: os.Getenv("OPENWEATHER_API_KEY"), + }) + + if err := server.Listen(ctx, os.Stdin, os.Stdout); err != nil { + log.Fatal(err) + } +} +``` +### Add weather fetching functionality -## Connect to Claude Desktop +In `fetch.go` add two functions to fetch the weather and the five-day forecast. +Note that JSON handling in Go is verbose, hence the length of this code. - - - Add to `claude_desktop_config.json`: - - ```json - { - "mcpServers": { - "weather": { - "command": "uv", - "args": [ - "--directory", - "path/to/your/project", - "run", - "weather-service" - ], - "env": { - "OPENWEATHER_API_KEY": "your-api-key" - } - } - } - } - ``` - +```go +package main - - 1. Quit Claude completely +import ( + "encoding/json" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) - 2. Start Claude again +type Weather struct { + Temperature float64 `json:"temperature"` + Conditions string `json:"conditions"` + Humidity float64 `json:"humidity"` + WindSpeed float64 `json:"wind_speed"` + Timestamp time.Time `json:"timestamp"` +} + +type weatherResponse struct { + Main struct { + Temp float64 `json:"temp"` + Humidity float64 `json:"humidity"` + } `json:"main"` + Wind struct { + Speed float64 `json:"speed"` + } `json:"wind"` + Weather []struct { + Description string `json:"description"` + } `json:"weather"` +} + +func (s *WeatherServer) fetchWeather(city string) (*Weather, error) { + q := url.Values{} + q.Set("q", city) + q.Set("appid", s.key) + q.Set("units", "metric") + + uri := "http://api.openweathermap.org/data/2.5/weather?" + q.Encode() + resp, err := http.Get(uri) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var data weatherResponse + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return nil, err + } + + return &Weather{ + Temperature: data.Main.Temp, + Conditions: data.Weather[0].Description, + Humidity: data.Main.Humidity, + WindSpeed: data.Wind.Speed, + Timestamp: time.Now(), + }, nil +} + +type Forecast struct { + Date string `json:"date"` + Temperature float64 `json:"temperature"` + Conditions string `json:"conditions"` +} + +type forecastResponse struct { + List []struct { + DatetimeText string `json:"dt_txt"` + Main struct { + Temp float64 `json:"temp"` + } `json:"main"` + Weather []struct { + Description string `json:"description"` + } `json:"weather"` + } `json:"list"` +} + +func (s *WeatherServer) fetchForecast(city string, days int) ([]Forecast, error) { + q := url.Values{} + q.Set("q", city) + q.Set("cnt", strconv.Itoa(days*8)) + q.Set("appid", s.key) + q.Set("units", "metric") + + uri := "http://api.openweathermap.org/data/2.5/forecast?" + q.Encode() + resp, err := http.Get(uri) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var data forecastResponse + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return nil, err + } + + forecasts := []Forecast{} + for i, day_data := range data.List { + if i%8 != 0 { + continue + } + + date, _, _ := strings.Cut(day_data.DatetimeText, " ") + + forecasts = append(forecasts, Forecast{ + Date: date, + Temperature: day_data.Main.Temp, + Conditions: day_data.Weather[0].Description, + }) + } + + return forecasts, nil +} +``` - 3. Look for your weather server in the 🔌 menu - - +### Implement resource handlers -## Try it out! +Add resource-related handlers to a new `reosurces.go` file. - - - Ask Claude: - - ``` - What's the current weather in San Francisco? Can you analyze the conditions and tell me if it's a good day for outdoor activities? - ``` - - - - Ask Claude: - - ``` - Can you get me a 5-day forecast for Tokyo and help me plan what clothes to pack for my trip? - ``` - - - - Ask Claude: - - ``` - Can you analyze the forecast for both Tokyo and San Francisco and tell me which city would be better for outdoor photography this week? - ``` - - - -## Understanding the code - - - - ```python - async def read_resource(self, uri: str) -> ReadResourceResult: - # ... - ``` - - Python type hints help catch errors early and improve code maintainability. - - - - ```python - @app.list_resources() - async def list_resources(self) -> ListResourcesResult: - return ListResourcesResult( - resources=[ - Resource( - uri=f"weather://{DEFAULT_CITY}/current", - name=f"Current weather in {DEFAULT_CITY}", - mimeType="application/json", - description="Real-time weather data" - ) - ] - ) - ``` - - Resources provide data that Claude can access as context. - - - - ```python - 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"] - } - ) - ``` - - Tools let Claude take actions through your server with validated inputs. - - - - ```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. - - - -## Best practices - - - - ```python - try: - async with httpx.AsyncClient() as client: - response = await client.get(..., params={..., **http_params}) - response.raise_for_status() - except httpx.HTTPError as e: - raise McpError( - ErrorCode.INTERNAL_ERROR, - f"API error: {str(e)}" - ) - ``` - - - - ```python - if not isinstance(args, dict) or "city" not in args: - raise McpError( - ErrorCode.INVALID_PARAMS, - "Invalid forecast arguments" - ) - ``` - - - - ```python - if not API_KEY: - raise ValueError("OPENWEATHER_API_KEY is required") - ``` - - - +```go +package main -## Available transports +import ( + "context" + "encoding/json" + "fmt" + "strings" -While this guide uses stdio transport, MCP supports additional transport options: + "github.com/riza-io/mcp-go" +) -### SSE (Server-Sent Events) +// List available weather resources. +func (s *WeatherServer) ListResources(ctx context.Context, req *mcp.Request[mcp.ListResourcesRequest]) (*mcp.Response[mcp.ListResourcesResponse], error) { + return mcp.NewResponse(&mcp.ListResourcesResponse{ + Resources: []mcp.Resource{ + { + URI: "weather://" + s.defaultCity + "/current", + Name: "Current weather in " + s.defaultCity, + Description: "Real-time weather data", + MimeType: "application/json", + }, + }, + }), nil +} + +func (s *WeatherServer) ReadResource(ctx context.Context, req *mcp.Request[mcp.ReadResourceRequest]) (*mcp.Response[mcp.ReadResourceResponse], error) { + city := s.defaultCity + + if strings.HasPrefix(req.Params.URI, "weather://") && strings.HasSuffix(req.Params.URI, "/current") { + city = strings.TrimPrefix(req.Params.URI, "weather://") + city = strings.TrimSuffix(city, "/current") + } else { + return nil, fmt.Errorf("unknown resource: %s", req.Params.URI) + } + + data, err := s.fetchWeather(city) + if err != nil { + return nil, err + } + + contents, err := json.Marshal(data) + if err != nil { + return nil, err + } + + return mcp.NewResponse(&mcp.ReadResourceResponse{ + Contents: []mcp.ResourceContent{ + { + URI: req.Params.URI, + MimeType: "application/json", + Text: string(contents), + }, + }, + }), nil +} +``` -```python -from mcp.server.sse import SseServerTransport -from starlette.applications import Starlette -from starlette.routing import Route +### Implement tool handlers -# Create SSE transport with endpoint -sse = SseServerTransport("/messages") +Add these tool-related handlers to `tools.go`. -# 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() - ) +```go +package main -# Handler for client messages -async def handle_messages(scope, receive, send): - await sse.handle_post_message(scope, receive, send) +import ( + "context" + "encoding/json" -# Create Starlette app with routes -app = Starlette( - debug=True, - routes=[ - Route("/sse", endpoint=handle_sse), - Route("/messages", endpoint=handle_messages, methods=["POST"]), - ], + "github.com/riza-io/mcp-go" ) -# Run with any ASGI server -import uvicorn -uvicorn.run(app, host="0.0.0.0", port=8000) +const getForecastSchema = `{ + "type": "object", + "properties": { + "city": { + "type": "string", + "description": "City name" + }, + "days": { + "type": "number", + "description": "Number of days (1-5)", + "minimum": 1, + "maximum": 5 + } + }, + "required": ["city"] +}` + +type GetForecastArguments struct { + City string `json:"city"` + Days int `json:"days"` +} + +func (s *WeatherServer) ListTools(ctx context.Context, req *mcp.Request[mcp.ListToolsRequest]) (*mcp.Response[mcp.ListToolsResponse], error) { + return mcp.NewResponse(&mcp.ListToolsResponse{ + Tools: []mcp.Tool{ + { + Name: "get_forecast", + Description: "Get weather forecast for a city", + InputSchema: json.RawMessage([]byte(getForecastSchema)), + }, + }, + }), nil +} + +func (s *WeatherServer) CallTool(ctx context.Context, req *mcp.Request[mcp.CallToolRequest]) (*mcp.Response[mcp.CallToolResponse], error) { + var args GetForecastArguments + if err := json.Unmarshal(req.Params.Arguments, &args); err != nil { + return nil, err + } + + forecasts, err := s.fetchForecast(args.City, args.Days) + if err != nil { + return nil, err + } + + text, err := json.MarshalIndent(forecasts, "", " ") + if err != nil { + return nil, err + } + + return mcp.NewResponse(&mcp.CallToolResponse{ + Content: []mcp.Content{ + { + Type: "text", + Text: string(text), + }, + }, + }), nil +} ``` -## Advanced features - - - - 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)] - ``` - - - - ```python - # Cache settings - cache_timeout = timedelta(minutes=15) - last_cache_time = None - cached_weather = None - - async def fetch_weather(city: str) -> dict[str, Any]: - global cached_weather, last_cache_time - - now = datetime.now() - if (cached_weather is None or - last_cache_time is None or - now - last_cache_time > cache_timeout): - - async with httpx.AsyncClient() as client: - response = await client.get( - f"{API_BASE_URL}/{CURRENT_WEATHER_ENDPOINT}", - params={"q": city, **http_params} - ) - response.raise_for_status() - data = response.json() - - 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 - ``` - - - - ```python - @self.call_tool() - async def call_tool(self, name: str, arguments: Any) -> CallToolResult: - if progress_token := self.request_context.meta.progressToken: - # Send progress notifications - await self.request_context.session.send_progress_notification( - progress_token=progress_token, - progress=1, - total=2 - ) - - # Fetch data... - - await self.request_context.session.send_progress_notification( - progress_token=progress_token, - progress=2, - total=2 - ) - - # Rest of the method implementation... - ``` - - - - ```python - # Set up logging - logger = logging.getLogger("weather-server") - logger.setLevel(logging.INFO) - - @app.set_logging_level() - async def set_logging_level(level: LoggingLevel) -> EmptyResult: - logger.setLevel(level.upper()) - await app.request_context.session.send_log_message( - level="info", - data=f"Log level set to {level}", - logger="weather-server" - ) - return EmptyResult() - - # Use logger throughout the code - # For example: - # logger.info("Weather data fetched successfully") - # logger.error(f"Error fetching weather data: {str(e)}") - ``` - - - - ```python - @app.list_resource_templates() - async def list_resource_templates() -> list[ResourceTemplate]: - return [ - ResourceTemplate( - uriTemplate="weather://{city}/current", - name="Current weather for any city", - mimeType="application/json" - ) - ] - ``` - - - -## Testing - - - - Create `tests/weather_test.py`: - - ```python - import pytest - import os - 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 - def anyio_backend(): - return "asyncio" - - @pytest.fixture - def mock_weather_response(): - return { - "main": { - "temp": 20.5, - "humidity": 65 - }, - "weather": [ - {"description": "scattered clouds"} - ], - "wind": { - "speed": 3.6 - } - } +The server is now complete! Build it using `go build` - @pytest.fixture - def mock_forecast_response(): - return { - "list": [ - { - "dt_txt": "2024-01-01 12:00:00", - "main": {"temp": 18.5}, - "weather": [{"description": "sunny"}] - }, - { - "dt_txt": "2024-01-02 12:00:00", - "main": {"temp": 17.2}, - "weather": [{"description": "cloudy"}] - } - ] - } +```bash +go build -o mcp-weather ./... +``` - @pytest.mark.anyio - 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.anyio - 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() - } - - uri = AnyUrl("weather://London/current") - result = await read_resource(uri) - - assert isinstance(result, str) - assert "temperature" in result - assert "clear sky" in result - - @pytest.mark.anyio - async def test_call_tool(mock_forecast_response): - class Response(): - def raise_for_status(self): - pass - - def json(self): - return mock_forecast_response - - class AsyncClient(): - async def __aenter__(self): - return self - - async def __aexit__(self, *exc_info): - pass - - async def get(self, *args, **kwargs): - return Response() - - with patch('httpx.AsyncClient', new=AsyncClient) as mock_client: - 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.anyio - 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.anyio - 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"] - ``` - - - ```bash - uv add --dev pytest - uv run pytest - ``` - - - -## Troubleshooting - -### Installation issues +## Connect to Claude Desktop -```bash -# Check Python version -python --version +### Update Claude config -# Reinstall dependencies -uv sync --reinstall +Add to `claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "weather": { + "command": "/path/to/mcp-weather", + "env": { + "OPENWEATHER_API_KEY": "your-api-key" + } + } + } +} ``` -### Type checking +### Restart Claude -```bash -# Install mypy -uv add --dev pyright +1. Quit Claude completely +2. Start Claude again +3. Look for your weather server in the 🔌 menu -# Run type checker -uv run pyright src -``` +## Try it out! -## Next steps +Ask Claude the following questions: + +> What's the current weather in San Francisco? Can you analyze the conditions and tell me if it's a good day for outdoor activities? + + +> Can you get me a 5-day forecast for Tokyo and help me plan what clothes to pack for my trip? + +> Can you analyze the forecast for both Tokyo and San Francisco and tell me which city would be better for outdoor photography this week? - - - Learn more about the MCP architecture - +## Available transports + +mcp-go currently supports the stdio transport. Follow this +[issue](https://github.com/riza-io/mcp-go/issues/5) to track progress on the SSE +transports. + +## Next steps - - Check out the Python SDK on GitHub - - \ No newline at end of file +- [Architecture overview](https://docs.riza.io/concepts/architecture) +- [MCP Go SDK](https://github.com/riza-io/mcp-go) \ No newline at end of file