├── .github ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── config.yml │ └── feature_request.yml ├── SECURITY.md ├── dependabot.yml └── workflows │ ├── go.yml │ └── test.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── assets ├── weather.gif └── weather.svg ├── cmd └── weather-mcp-server │ └── main.go ├── go.mod ├── go.sum ├── internal └── server │ ├── config.go │ ├── handlers │ ├── weather.go │ └── weather_test.go │ ├── server.go │ ├── services │ ├── core │ │ ├── core.go │ │ ├── weather.go │ │ └── weather_test.go │ ├── external.go │ ├── mock │ │ └── .gitignore │ └── services.go │ ├── tools │ ├── tools.go │ ├── weather.go │ └── weather_test.go │ └── view │ └── weather.html └── pkg └── weatherapi ├── mock └── current.json ├── models ├── current.go └── other.go ├── weatherapi.go └── weatherapi_test.go /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | - Fork the project on GitHub. 4 | - Make your feature addition or bug fix in a branch. Example branches: (`feature/name`) / (`fix/name`). 5 | - Push your branch to GitHub. 6 | - Send a Pull Request. Include a description of your changes. 7 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug report 2 | description: Create a bug report 3 | labels: ["bug"] 4 | body: 5 | - type: textarea 6 | id: what-happened 7 | attributes: 8 | label: What happened? 9 | validations: 10 | required: true 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | contact_links: 2 | - name: Telegram 3 | url: https://t.me/TuanKiri 4 | about: For questions, feedback and support! -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature request 2 | description: Suggest an idea for this project 3 | labels: ["enhancement"] 4 | body: 5 | - type: textarea 6 | id: problem 7 | attributes: 8 | label: The problem 9 | description: Describe what problem you would like to see solved. 10 | validations: 11 | required: true 12 | -------------------------------------------------------------------------------- /.github/SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | If you discover a security issue, please bring it to our attention right away! 4 | 5 | ## Reporting a Vulnerability 6 | 7 | Please **DO NOT** file a public issue to report a security vulberability, instead send your report privately to **ejow-artem@yandex.ru**. 8 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gomod 4 | directory: / 5 | schedule: 6 | interval: monthly 7 | - package-ecosystem: github-actions 8 | directory: / 9 | schedule: 10 | interval: monthly 11 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | name: Build 3 | 4 | jobs: 5 | build: 6 | strategy: 7 | matrix: 8 | platform: [ubuntu-latest, windows-latest, macos-latest] 9 | runs-on: ${{ matrix.platform }} 10 | steps: 11 | - name: Check out code 12 | uses: actions/checkout@v4 13 | 14 | - name: Set up Go 15 | uses: actions/setup-go@v5 16 | with: 17 | go-version-file: "go.mod" 18 | 19 | - name: Download dependencies 20 | run: go mod download 21 | 22 | - name: Build 23 | run: go build -v ./cmd/weather-mcp-server 24 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | name: Test 3 | 4 | jobs: 5 | test: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - name: Check out code 9 | uses: actions/checkout@v4 10 | 11 | - name: Set up Go 12 | uses: actions/setup-go@v5 13 | with: 14 | go-version-file: "go.mod" 15 | 16 | - name: Download dependencies 17 | run: go mod download 18 | 19 | - name: Install mockgen 20 | run: go install go.uber.org/mock/mockgen@latest 21 | 22 | - name: Generate mocks 23 | run: make generate-mocks 24 | 25 | - name: Run tests 26 | run: make run-tests 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vendor -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.24-alpine3.21 AS builder 2 | 3 | WORKDIR /app 4 | 5 | COPY go.mod go.sum ./ 6 | 7 | RUN go mod download 8 | 9 | COPY . . 10 | 11 | RUN go build -o weather-mcp-server ./cmd/weather-mcp-server 12 | 13 | FROM gcr.io/distroless/base-debian12 14 | 15 | WORKDIR /app 16 | 17 | COPY --from=builder /app/weather-mcp-server . 18 | 19 | USER nonroot:nonroot 20 | 21 | EXPOSE 8000 22 | 23 | ENTRYPOINT ["./weather-mcp-server", "--address", "0.0.0.0:8000"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Ezhov Artem 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: help 2 | help: 3 | @awk 'BEGIN {FS = ":.*##"; printf "Usage: make \033[36m\033[0m\n"} /^[a-zA-Z_-]+:.*?##/ { printf " \033[36m%-10s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) 4 | 5 | .DEFAULT_GOAL := help 6 | 7 | ##@ Tests 8 | generate-mocks: ## generate mock files using mockgen 9 | go generate ./internal/server/services 10 | 11 | run-tests: ## run unit tests 12 | go test -v -race ./... -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | example output template 3 | 4 |

Weather API MCP Server

5 | 6 | [![License](https://img.shields.io/badge/license-MIT-red.svg)](LICENSE) 7 | [![Go Version](https://img.shields.io/github/go-mod/go-version/TuanKiri/weather-mcp-server)](go.mod) 8 | [![Go Report Card](https://goreportcard.com/badge/github.com/TuanKiri/weather-mcp-server?cache)](https://goreportcard.com/report/github.com/TuanKiri/weather-mcp-server) 9 | [![Build](https://github.com/TuanKiri/weather-mcp-server/actions/workflows/go.yml/badge.svg?branch=master)](https://github.com/TuanKiri/weather-mcp-server/actions?workflow=Build) 10 | [![Tests](https://github.com/TuanKiri/weather-mcp-server/actions/workflows/test.yml/badge.svg?branch=master)](https://github.com/TuanKiri/weather-mcp-server/actions?workflow=Test) 11 | 12 | [Report Bug](https://github.com/TuanKiri/weather-mcp-server/issues/new?assignees=&labels=bug&projects=&template=bug_report.yml) | [Request Feature](https://github.com/TuanKiri/weather-mcp-server/issues/new?assignees=&labels=enhancement&projects=&template=feature_request.yml) 13 | 14 |
15 | 16 | A lightweight Model Context Protocol (MCP) server that enables AI assistants like Claude to retrieve and interpret real-time weather data. 17 | 18 |
19 | demo example 20 |
21 | 22 | ## Installing on Claude Desktop 23 | 24 | To use your MCP server with Claude Desktop, add it to your Claude configuration: 25 | 26 | #### 1. Local mode 27 | 28 | ```json 29 | { 30 | "mcpServers": { 31 | "weather-mcp-server": { 32 | "command": "/path/to/weather-mcp-server", 33 | "env": { 34 | "WEATHER_API_KEY": "your-api-key" 35 | } 36 | } 37 | } 38 | } 39 | ``` 40 | 41 | You can get an API key from your personal account on [WeatherAPI](https://www.weatherapi.com/my/). 42 | 43 | #### 2. Remote mode 44 | 45 | ```json 46 | { 47 | "mcpServers": { 48 | "weather-mcp-server": { 49 | "url": "http://host:port/sse" 50 | } 51 | } 52 | } 53 | ``` 54 | 55 | ## Build from source 56 | 57 | You can use `go` to build the binary in the `cmd/github-mcp-server` directory. 58 | 59 | ```shell 60 | go build -o weather-mcp-server ./cmd/weather-mcp-server 61 | ``` 62 | 63 | ## Using MCP with Docker Containers 64 | 65 | #### 1. Build the Docker Image: 66 | 67 | ```shell 68 | docker build -t weather-mcp-server . 69 | ``` 70 | 71 | #### 2. Run the Docker Container: 72 | 73 | ```shell 74 | docker run -e WEATHER_API_KEY=your-api-key -d --name weather-mcp-server -p 8000:8000 weather-mcp-server 75 | ``` 76 | 77 | Replace `your-api-key` with your actual [WeatherAPI](https://www.weatherapi.com/my/) API key. 78 | 79 | ## Tools 80 | 81 | - **current_weather** - Gets the current weather for a city 82 | 83 | - `city`: The name of the city (string, required) 84 | 85 | ## Project Structure 86 | 87 | The project is organized into several key directories: 88 | 89 | ```shell 90 | ├── cmd 91 | │ └── weather-mcp-server 92 | ├── internal 93 | │ └── server 94 | │ ├── handlers # MCP handlers 95 | │ ├── services # Business logic layer 96 | │ │ ├── core # Core application logic 97 | │ │ └── mock # Mock services for testing 98 | │ ├── tools # MCP tools 99 | │ └── view # Templates for displaying messages 100 | └── pkg 101 | ``` 102 | 103 | ## Testing 104 | 105 | If you're adding new features, please make sure to include tests for them. 106 | 107 | #### 1. Install the mockgen tool: 108 | 109 | ```shell 110 | go install go.uber.org/mock/mockgen@latest 111 | ``` 112 | 113 | See the installation guide on [go.uber.org/mock](https://github.com/uber-go/mock?tab=readme-ov-file#installation). 114 | 115 | #### 2. Use the following command to generate mock files: 116 | 117 | ```shell 118 | make generate-mocks 119 | ``` 120 | 121 | #### 3. To run unit tests: 122 | 123 | ```shell 124 | make run-tests 125 | ``` 126 | 127 | ## Contributing 128 | 129 | Feel free to open tickets or send pull requests with improvements. Thanks in advance for your help! 130 | 131 | Please follow the [contribution guidelines](.github/CONTRIBUTING.md). 132 | 133 | ## License 134 | 135 | This MCP server is licensed under the [MIT License](LICENSE). 136 | -------------------------------------------------------------------------------- /assets/weather.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TuanKiri/weather-mcp-server/8c08282231c0c68e25f80fef2143e20d11b7db6d/assets/weather.gif -------------------------------------------------------------------------------- /assets/weather.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 41 | 42 |
43 |

London, United Kingdom

44 | Sunny 45 |
    46 |
  • Temperature: 15°C
  • 47 |
  • Condition: Sunny
  • 48 |
  • Humidity: 36%
  • 49 |
  • Wind Speed: 15 km/h
  • 50 |
51 |
52 |
53 |
54 |
-------------------------------------------------------------------------------- /cmd/weather-mcp-server/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "log" 6 | "os" 7 | "time" 8 | 9 | "github.com/TuanKiri/weather-mcp-server/internal/server" 10 | ) 11 | 12 | func main() { 13 | addr := flag.String("address", "", "The host and port to start the sse server") 14 | flag.Parse() 15 | 16 | cfg := &server.Config{ 17 | ListenAddr: *addr, 18 | WeatherAPIKey: os.Getenv("WEATHER_API_KEY"), 19 | WeatherAPITimeout: 1 * time.Second, 20 | } 21 | 22 | if err := cfg.Validate(); err != nil { 23 | log.Fatal(err) 24 | } 25 | 26 | if err := server.Run(cfg); err != nil { 27 | log.Fatal(err) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/TuanKiri/weather-mcp-server 2 | 3 | go 1.24.1 4 | 5 | require ( 6 | github.com/mark3labs/mcp-go v0.18.0 7 | github.com/stretchr/testify v1.9.0 8 | go.uber.org/mock v0.5.1 9 | ) 10 | 11 | require ( 12 | github.com/davecgh/go-spew v1.1.1 // indirect 13 | github.com/google/uuid v1.6.0 // indirect 14 | github.com/pmezard/go-difflib v1.0.0 // indirect 15 | github.com/yosida95/uritemplate/v3 v3.0.2 // indirect 16 | gopkg.in/yaml.v3 v3.0.1 // indirect 17 | ) 18 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 4 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 5 | github.com/mark3labs/mcp-go v0.18.0 h1:YuhgIVjNlTG2ZOwmrkORWyPTp0dz1opPEqvsPtySXao= 6 | github.com/mark3labs/mcp-go v0.18.0/go.mod h1:KmJndYv7GIgcPVwEKJjNcbhVQ+hJGJhrCCB/9xITzpE= 7 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 8 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 9 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 10 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 11 | github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= 12 | github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= 13 | go.uber.org/mock v0.5.1 h1:ASgazW/qBmR+A32MYFDB6E2POoTgOwT509VP0CT/fjs= 14 | go.uber.org/mock v0.5.1/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= 15 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 16 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 17 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 18 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 19 | -------------------------------------------------------------------------------- /internal/server/config.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "errors" 5 | "time" 6 | ) 7 | 8 | type Config struct { 9 | ListenAddr string 10 | WeatherAPIKey string 11 | WeatherAPITimeout time.Duration 12 | } 13 | 14 | func (c *Config) Validate() error { 15 | if c.WeatherAPIKey != "" { 16 | return nil 17 | } 18 | 19 | return errors.New("WeatherAPIKey is required") 20 | } 21 | -------------------------------------------------------------------------------- /internal/server/handlers/weather.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/mark3labs/mcp-go/mcp" 7 | "github.com/mark3labs/mcp-go/server" 8 | 9 | "github.com/TuanKiri/weather-mcp-server/internal/server/services" 10 | ) 11 | 12 | func CurrentWeather(svc services.Services) server.ToolHandlerFunc { 13 | return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { 14 | city, ok := request.Params.Arguments["city"].(string) 15 | if !ok { 16 | return mcp.NewToolResultError("city must be a string"), nil 17 | } 18 | 19 | data, err := svc.Weather().Current(ctx, city) 20 | if err != nil { 21 | return nil, err 22 | } 23 | 24 | return mcp.NewToolResultText(data), nil 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /internal/server/handlers/weather_test.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "testing" 7 | 8 | "github.com/mark3labs/mcp-go/mcp" 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | "go.uber.org/mock/gomock" 12 | 13 | "github.com/TuanKiri/weather-mcp-server/internal/server/services/mock" 14 | ) 15 | 16 | func TestCurrentWeather(t *testing.T) { 17 | testCases := map[string]struct { 18 | arguments map[string]any 19 | errString string 20 | wait string 21 | setupWeatherService func(mocksWeather *mock.MockWeatherService) 22 | }{ 23 | "empty_city": { 24 | wait: "city must be a string", 25 | }, 26 | "city_not_found": { 27 | arguments: map[string]any{ 28 | "city": "Tokyo", 29 | }, 30 | errString: "weather API not available. Code: 400", 31 | setupWeatherService: func(mocksWeather *mock.MockWeatherService) { 32 | mocksWeather.EXPECT(). 33 | Current(context.Background(), "Tokyo"). 34 | Return("", errors.New("weather API not available. Code: 400")) 35 | }, 36 | }, 37 | "successful_request": { 38 | arguments: map[string]any{ 39 | "city": "London", 40 | }, 41 | wait: "

London weather data

", 42 | setupWeatherService: func(mocksWeather *mock.MockWeatherService) { 43 | mocksWeather.EXPECT(). 44 | Current(context.Background(), "London"). 45 | Return("

London weather data

", nil) 46 | }, 47 | }, 48 | } 49 | 50 | ctrl := gomock.NewController(t) 51 | defer ctrl.Finish() 52 | 53 | mocksWeather := mock.NewMockWeatherService(ctrl) 54 | 55 | svc := mock.NewMockServices(ctrl) 56 | svc.EXPECT().Weather().Return(mocksWeather).AnyTimes() 57 | 58 | handler := CurrentWeather(svc) 59 | 60 | for name, tc := range testCases { 61 | t.Run(name, func(t *testing.T) { 62 | if tc.setupWeatherService != nil { 63 | tc.setupWeatherService(mocksWeather) 64 | } 65 | 66 | var request mcp.CallToolRequest 67 | request.Params.Arguments = tc.arguments 68 | 69 | result, err := handler(context.Background(), request) 70 | if err != nil { 71 | assert.EqualError(t, err, tc.errString) 72 | return 73 | } 74 | 75 | require.Len(t, result.Content, 1) 76 | content, ok := result.Content[0].(mcp.TextContent) 77 | require.True(t, ok) 78 | 79 | assert.Equal(t, tc.wait, content.Text) 80 | }) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /internal/server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "embed" 6 | "html/template" 7 | "log" 8 | "os/signal" 9 | "syscall" 10 | 11 | "github.com/mark3labs/mcp-go/server" 12 | 13 | "github.com/TuanKiri/weather-mcp-server/internal/server/services/core" 14 | "github.com/TuanKiri/weather-mcp-server/internal/server/tools" 15 | "github.com/TuanKiri/weather-mcp-server/pkg/weatherapi" 16 | ) 17 | 18 | //go:embed view 19 | var templates embed.FS 20 | 21 | func Run(cfg *Config) error { 22 | tmpl, err := template.ParseFS(templates, "view/*.html") 23 | if err != nil { 24 | return err 25 | } 26 | 27 | wApi := weatherapi.New(cfg.WeatherAPIKey, cfg.WeatherAPITimeout) 28 | 29 | svc := core.New(tmpl, wApi) 30 | 31 | s := server.NewMCPServer( 32 | "Weather Server", 33 | "1.0.0", 34 | server.WithLogging(), 35 | ) 36 | 37 | toolFuncs := []tools.ToolFunc{ 38 | tools.CurrentWeather, 39 | } 40 | 41 | for _, tool := range toolFuncs { 42 | s.AddTool(tool(svc)) 43 | } 44 | 45 | if cfg.ListenAddr != "" { 46 | return serveSSE(s, cfg.ListenAddr) 47 | } 48 | 49 | return server.ServeStdio(s) 50 | } 51 | 52 | func serveSSE(s *server.MCPServer, addr string) error { 53 | ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) 54 | defer stop() 55 | 56 | srv := server.NewSSEServer(s) 57 | 58 | go func() { 59 | if err := srv.Start(addr); err != nil { 60 | log.Fatal(err) 61 | } 62 | }() 63 | 64 | <-ctx.Done() 65 | 66 | return srv.Shutdown(context.TODO()) 67 | } 68 | -------------------------------------------------------------------------------- /internal/server/services/core/core.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "html/template" 5 | 6 | "github.com/TuanKiri/weather-mcp-server/internal/server/services" 7 | ) 8 | 9 | type CoreServices struct { 10 | renderer *template.Template 11 | weatherAPI services.WeatherAPIProvider 12 | 13 | weatherService *WeatherService 14 | } 15 | 16 | func New(renderer *template.Template, weatherAPI services.WeatherAPIProvider) *CoreServices { 17 | return &CoreServices{ 18 | renderer: renderer, 19 | weatherAPI: weatherAPI, 20 | } 21 | } 22 | 23 | func (cs *CoreServices) Weather() services.WeatherService { 24 | if cs.weatherService == nil { 25 | cs.weatherService = &WeatherService{CoreServices: cs} 26 | } 27 | 28 | return cs.weatherService 29 | } 30 | -------------------------------------------------------------------------------- /internal/server/services/core/weather.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | ) 8 | 9 | type WeatherService struct { 10 | *CoreServices 11 | } 12 | 13 | func (ws *WeatherService) Current(ctx context.Context, city string) (string, error) { 14 | data, err := ws.weatherAPI.Current(ctx, city) 15 | if err != nil { 16 | return "", err 17 | } 18 | 19 | var buf bytes.Buffer 20 | 21 | if err := ws.renderer.ExecuteTemplate(&buf, "weather.html", map[string]string{ 22 | "Location": fmt.Sprintf("%s, %s", data.Location.Name, data.Location.Country), 23 | "Icon": "https:" + data.Current.Condition.Icon, 24 | "Condition": data.Current.Condition.Text, 25 | "Temperature": fmt.Sprintf("%.0f", data.Current.TempC), 26 | "Humidity": fmt.Sprintf("%d", data.Current.Humidity), 27 | "WindSpeed": fmt.Sprintf("%.0f", data.Current.WindKph), 28 | }); err != nil { 29 | return "", err 30 | } 31 | 32 | return buf.String(), nil 33 | } 34 | -------------------------------------------------------------------------------- /internal/server/services/core/weather_test.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "html/template" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | "go.uber.org/mock/gomock" 12 | 13 | "github.com/TuanKiri/weather-mcp-server/internal/server/services/mock" 14 | "github.com/TuanKiri/weather-mcp-server/pkg/weatherapi/models" 15 | ) 16 | 17 | func TestCurrentWeather(t *testing.T) { 18 | testCases := map[string]struct { 19 | city string 20 | errString string 21 | wait string 22 | setupWeatherAPI func(weatherAPI *mock.MockWeatherAPIProvider) 23 | }{ 24 | "city_not_found": { 25 | city: "Tokyo", 26 | errString: "weather API not available. Code: 400", 27 | setupWeatherAPI: func(weatherAPI *mock.MockWeatherAPIProvider) { 28 | weatherAPI.EXPECT(). 29 | Current(context.Background(), "Tokyo"). 30 | Return(nil, errors.New("weather API not available. Code: 400")) 31 | }, 32 | }, 33 | "successful_result": { 34 | city: "London", 35 | wait: "London, United Kingdom Sunny 18 45 4 " + 36 | "https://cdn.weatherapi.com/weather/64x64/day/113.png", 37 | setupWeatherAPI: func(weatherAPI *mock.MockWeatherAPIProvider) { 38 | weatherAPI.EXPECT(). 39 | Current(context.Background(), "London"). 40 | Return(&models.CurrentResponse{ 41 | Location: models.Location{ 42 | Name: "London", 43 | Country: "United Kingdom", 44 | }, 45 | Current: models.Current{ 46 | TempC: 18.4, 47 | WindKph: 4.2, 48 | Humidity: 45, 49 | Condition: models.Condition{ 50 | Text: "Sunny", 51 | Icon: "//cdn.weatherapi.com/weather/64x64/day/113.png", 52 | }, 53 | }, 54 | }, nil) 55 | }, 56 | }, 57 | } 58 | 59 | renderer, err := template.New("weather.html").Parse( 60 | "{{ .Location }} {{ .Condition }} {{ .Temperature }} " + 61 | "{{ .Humidity }} {{ .WindSpeed }} {{ .Icon }}") 62 | require.NoError(t, err) 63 | 64 | ctrl := gomock.NewController(t) 65 | defer ctrl.Finish() 66 | 67 | weatherAPI := mock.NewMockWeatherAPIProvider(ctrl) 68 | 69 | svc := New(renderer, weatherAPI) 70 | 71 | for name, tc := range testCases { 72 | t.Run(name, func(t *testing.T) { 73 | if tc.setupWeatherAPI != nil { 74 | tc.setupWeatherAPI(weatherAPI) 75 | } 76 | 77 | data, err := svc.Weather().Current(context.Background(), tc.city) 78 | if err != nil { 79 | assert.EqualError(t, err, tc.errString) 80 | } 81 | 82 | assert.Equal(t, tc.wait, data) 83 | }) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /internal/server/services/external.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/TuanKiri/weather-mcp-server/pkg/weatherapi/models" 7 | ) 8 | 9 | //go:generate mockgen --source external.go --destination mock/external_mock.go --package mock 10 | 11 | type WeatherAPIProvider interface { 12 | Current(ctx context.Context, city string) (*models.CurrentResponse, error) 13 | } 14 | -------------------------------------------------------------------------------- /internal/server/services/mock/.gitignore: -------------------------------------------------------------------------------- 1 | *.go 2 | !.gitignore -------------------------------------------------------------------------------- /internal/server/services/services.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import "context" 4 | 5 | //go:generate mockgen --source services.go --destination mock/mock.go --package mock 6 | 7 | type Services interface { 8 | Weather() WeatherService 9 | } 10 | 11 | type WeatherService interface { 12 | Current(ctx context.Context, city string) (string, error) 13 | } 14 | -------------------------------------------------------------------------------- /internal/server/tools/tools.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "github.com/mark3labs/mcp-go/mcp" 5 | "github.com/mark3labs/mcp-go/server" 6 | 7 | "github.com/TuanKiri/weather-mcp-server/internal/server/services" 8 | ) 9 | 10 | type ToolFunc func(svc services.Services) (mcp.Tool, server.ToolHandlerFunc) 11 | -------------------------------------------------------------------------------- /internal/server/tools/weather.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "github.com/mark3labs/mcp-go/mcp" 5 | "github.com/mark3labs/mcp-go/server" 6 | 7 | "github.com/TuanKiri/weather-mcp-server/internal/server/handlers" 8 | "github.com/TuanKiri/weather-mcp-server/internal/server/services" 9 | ) 10 | 11 | func CurrentWeather(svc services.Services) (mcp.Tool, server.ToolHandlerFunc) { 12 | tool := mcp.NewTool("current_weather", 13 | mcp.WithDescription(` 14 | The service retrieves the current weather for a specified city and returns only HTML and CSS code designed 15 | for displaying weather information directly on a canvas. No additional comments should be included after the response. 16 | It processes the city's name, gathers weather data such as temperature, humidity, wind speed, and general weather conditions, 17 | and then generates a structured HTML layout along with appropriate CSS styles. This HTML must be rendered visually on the canvas. 18 | After receiving the response, the text inside the HTML should be translated into the language of the request. 19 | `), 20 | mcp.WithString("city", 21 | mcp.Required(), 22 | mcp.Description(` 23 | The name of the city. This field is required and must be provided in English. 24 | Only one city is allowed, and it must be the last one provided by the user. 25 | `), 26 | ), 27 | ) 28 | 29 | handler := handlers.CurrentWeather(svc) 30 | 31 | return tool, handler 32 | } 33 | -------------------------------------------------------------------------------- /internal/server/tools/weather_test.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestCurrentWeather(t *testing.T) { 10 | tool, handler := CurrentWeather(nil) 11 | 12 | assert.Equal(t, "current_weather", tool.Name) 13 | assert.NotEmpty(t, tool.Description) 14 | assert.Contains(t, tool.InputSchema.Properties, "city") 15 | assert.ElementsMatch(t, tool.InputSchema.Required, []string{"city"}) 16 | 17 | assert.NotNil(t, handler) 18 | } 19 | -------------------------------------------------------------------------------- /internal/server/view/weather.html: -------------------------------------------------------------------------------- 1 | 39 | 40 |
41 |

{{ .Location }}

42 | {{ .Condition }} 43 | 49 |
-------------------------------------------------------------------------------- /pkg/weatherapi/mock/current.json: -------------------------------------------------------------------------------- 1 | { 2 | "location": { 3 | "name": "London", 4 | "region": "City of London, Greater London", 5 | "country": "United Kingdom", 6 | "lat": 51.5171, 7 | "lon": -0.1062, 8 | "tz_id": "Europe/London", 9 | "localtime_epoch": 1744373247, 10 | "localtime": "2025-04-11 13:07" 11 | }, 12 | "current": { 13 | "last_updated_epoch": 1744372800, 14 | "last_updated": "2025-04-11 13:00", 15 | "temp_c": 18.4, 16 | "temp_f": 65.1, 17 | "is_day": 1, 18 | "condition": { 19 | "text": "Sunny", 20 | "icon": "//cdn.weatherapi.com/weather/64x64/day/113.png", 21 | "code": 1000 22 | }, 23 | "wind_mph": 2.5, 24 | "wind_kph": 4.0, 25 | "wind_degree": 255, 26 | "wind_dir": "WSW", 27 | "pressure_mb": 1022.0, 28 | "pressure_in": 30.18, 29 | "precip_mm": 0.0, 30 | "precip_in": 0.0, 31 | "humidity": 45, 32 | "cloud": 0, 33 | "feelslike_c": 18.4, 34 | "feelslike_f": 65.1, 35 | "windchill_c": 19.8, 36 | "windchill_f": 67.6, 37 | "heatindex_c": 19.8, 38 | "heatindex_f": 67.6, 39 | "dewpoint_c": 1.4, 40 | "dewpoint_f": 34.5, 41 | "vis_km": 10.0, 42 | "vis_miles": 6.0, 43 | "uv": 4.2, 44 | "gust_mph": 2.8, 45 | "gust_kph": 4.6 46 | } 47 | } -------------------------------------------------------------------------------- /pkg/weatherapi/models/current.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type Current struct { 4 | TempC float64 `json:"temp_c"` 5 | WindKph float64 `json:"wind_kph"` 6 | Humidity int64 `json:"humidity"` 7 | Condition Condition `json:"condition"` 8 | } 9 | 10 | type CurrentResponse struct { 11 | Location Location `json:"location"` 12 | Current Current `json:"current"` 13 | } 14 | -------------------------------------------------------------------------------- /pkg/weatherapi/models/other.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type Location struct { 4 | Name string `json:"name"` 5 | Country string `json:"country"` 6 | } 7 | 8 | type Condition struct { 9 | Text string `json:"text"` 10 | Icon string `json:"icon"` 11 | } 12 | -------------------------------------------------------------------------------- /pkg/weatherapi/weatherapi.go: -------------------------------------------------------------------------------- 1 | package weatherapi 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "net/url" 10 | "time" 11 | 12 | "github.com/TuanKiri/weather-mcp-server/pkg/weatherapi/models" 13 | ) 14 | 15 | const baseURL = "http://api.weatherapi.com" 16 | 17 | type WeatherAPI struct { 18 | key string 19 | baseURL string 20 | client *http.Client 21 | } 22 | 23 | func New(key string, timeout time.Duration) *WeatherAPI { 24 | return &WeatherAPI{ 25 | key: key, 26 | baseURL: baseURL, 27 | client: &http.Client{ 28 | Timeout: timeout, 29 | }, 30 | } 31 | } 32 | 33 | func (w *WeatherAPI) Current(ctx context.Context, city string) (*models.CurrentResponse, error) { 34 | query := url.Values{ 35 | "key": {w.key}, 36 | "q": {city}, 37 | } 38 | 39 | request, err := http.NewRequestWithContext(ctx, 40 | http.MethodGet, 41 | w.baseURL+"/v1/current.json?"+query.Encode(), 42 | nil, 43 | ) 44 | if err != nil { 45 | return nil, err 46 | } 47 | 48 | response, err := w.client.Do(request) 49 | if err != nil { 50 | return nil, err 51 | } 52 | defer response.Body.Close() 53 | 54 | if response.StatusCode != http.StatusOK { 55 | return nil, fmt.Errorf("weather API not available. Code: %d", response.StatusCode) 56 | } 57 | 58 | body, err := io.ReadAll(response.Body) 59 | if err != nil { 60 | return nil, err 61 | } 62 | 63 | var data models.CurrentResponse 64 | 65 | if err = json.Unmarshal(body, &data); err != nil { 66 | return nil, err 67 | } 68 | 69 | return &data, nil 70 | } 71 | -------------------------------------------------------------------------------- /pkg/weatherapi/weatherapi_test.go: -------------------------------------------------------------------------------- 1 | package weatherapi 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/http/httptest" 7 | "os" 8 | "path/filepath" 9 | "testing" 10 | 11 | "github.com/stretchr/testify/assert" 12 | 13 | "github.com/TuanKiri/weather-mcp-server/pkg/weatherapi/models" 14 | ) 15 | 16 | func TestCurrentWeather(t *testing.T) { 17 | t.Parallel() 18 | 19 | testCases := map[string]struct { 20 | city string 21 | errString string 22 | wait *models.CurrentResponse 23 | }{ 24 | "successful_request": { 25 | city: "London", 26 | wait: &models.CurrentResponse{ 27 | Location: models.Location{ 28 | Name: "London", 29 | Country: "United Kingdom", 30 | }, 31 | Current: models.Current{ 32 | TempC: 18.4, 33 | WindKph: 4, 34 | Humidity: 45, 35 | Condition: models.Condition{ 36 | Text: "Sunny", 37 | Icon: "//cdn.weatherapi.com/weather/64x64/day/113.png", 38 | }, 39 | }, 40 | }, 41 | }, 42 | "bad_request": { 43 | errString: "weather API not available. Code: 400", 44 | }, 45 | } 46 | 47 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 48 | q := r.URL.Query().Get("q") 49 | if q == "" { 50 | http.Error(w, "", http.StatusBadRequest) 51 | return 52 | } 53 | 54 | path := filepath.Join("mock", "current.json") 55 | 56 | data, err := os.ReadFile(path) 57 | if err != nil { 58 | http.Error(w, "", http.StatusInternalServerError) 59 | return 60 | } 61 | 62 | w.Header().Set("Content-Type", "application/json") 63 | w.Write(data) 64 | })) 65 | defer server.Close() 66 | 67 | weatherAPI := &WeatherAPI{ 68 | key: "test-key", 69 | baseURL: server.URL, 70 | client: server.Client(), 71 | } 72 | 73 | for name, tc := range testCases { 74 | t.Run(name, func(t *testing.T) { 75 | result, err := weatherAPI.Current(context.Background(), tc.city) 76 | if err != nil { 77 | assert.EqualError(t, err, tc.errString) 78 | } 79 | 80 | assert.Equal(t, tc.wait, result) 81 | }) 82 | } 83 | } 84 | --------------------------------------------------------------------------------