From 90da38cf4752f17e48f93ccd1ed546429879ad2b Mon Sep 17 00:00:00 2001 From: Kyle Gray Date: Tue, 10 Dec 2024 14:54:29 -0800 Subject: [PATCH] docs: Add a building your first server section (#4) Add the weather service example --- README.md | 27 ++- docs/first-server.md | 390 ++++++++++++++++++++++++++++++++++ examples/weather/fetch.go | 113 ++++++++++ examples/weather/main.go | 43 ++++ examples/weather/resources.go | 55 +++++ examples/weather/tools.go | 68 ++++++ 6 files changed, 689 insertions(+), 7 deletions(-) create mode 100644 docs/first-server.md create mode 100644 examples/weather/fetch.go create mode 100644 examples/weather/main.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.md b/docs/first-server.md new file mode 100644 index 0000000..5d9960a --- /dev/null +++ b/docs/first-server.md @@ -0,0 +1,390 @@ +# 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 + +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`. + +```bash +mkdir mcp-go-weather +cd mcp-go-weather +go mod init github.com/example/mcp-go-weather +``` + +Add your API key to the environment. + +```bash +export OPENWEATHER_API_KEY=your-api-key-here +``` + +## Create your server + +### Add the base imports and setup + +In `main.go` add the entry point and base server implemenation. + +```go +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{ + 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 + +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. + +```go +package main + +import ( + "encoding/json" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +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 +} +``` + +### Implement resource handlers + +Add resource-related handlers to a new `reosurces.go` file. + +```go +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 +} +``` + +### Implement tool handlers + +Add these tool-related handlers to `tools.go`. + +```go +package main + +import ( + "context" + "encoding/json" + + "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 + } + + 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 +} +``` + +The server is now complete! Build it using `go build` + +```bash +go build -o mcp-weather ./... +``` + +## Connect to Claude Desktop + +### Update Claude config + +Add to `claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "weather": { + "command": "/path/to/mcp-weather", + "env": { + "OPENWEATHER_API_KEY": "your-api-key" + } + } + } +} +``` + +### Restart Claude + +1. Quit Claude completely +2. Start Claude again +3. Look for your weather server in the 🔌 menu + +## Try it out! + +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? + +## 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 + +- [Architecture overview](https://docs.riza.io/concepts/architecture) +- [MCP Go SDK](https://github.com/riza-io/mcp-go) \ No newline at end of file diff --git a/examples/weather/fetch.go b/examples/weather/fetch.go new file mode 100644 index 0000000..8a75094 --- /dev/null +++ b/examples/weather/fetch.go @@ -0,0 +1,113 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +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 +} diff --git a/examples/weather/main.go b/examples/weather/main.go new file mode 100644 index 0000000..7da0bf9 --- /dev/null +++ b/examples/weather/main.go @@ -0,0 +1,43 @@ +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{ + defaultCity: "London", + 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..d7d24ed --- /dev/null +++ b/examples/weather/tools.go @@ -0,0 +1,68 @@ +package main + +import ( + "context" + "encoding/json" + + "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 + } + + 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 +}