Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
on: [push, pull_request]
name: Test

jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: "go.mod"

- name: Download dependencies
run: go mod download

- name: Install mockgen
run: go install go.uber.org/mock/mockgen@latest

- name: Generate mocks
run: make generate-mocks

- name: Run tests
run: make run-tests
12 changes: 12 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
.PHONY: help
help:
@awk 'BEGIN {FS = ":.*##"; printf "Usage: make \033[36m<target>\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)

.DEFAULT_GOAL := help

##@ Tests
generate-mocks: ## generate mock files using mockgen
go generate ./internal/server/services

run-tests: ## run unit tests
go test -v -race ./...
30 changes: 27 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
[![License](https://img.shields.io/badge/license-MIT-red.svg)](LICENSE)
[![Go Version](https://img.shields.io/github/go-mod/go-version/TuanKiri/weather-mcp-server)](go.mod)
[![Go Report Card](https://goreportcard.com/badge/github.com/TuanKiri/weather-mcp-server?cache)](https://goreportcard.com/report/github.com/TuanKiri/weather-mcp-server)
[![Build](https://github.com/TuanKiri/weather-mcp-server/workflows/Build/badge.svg)](https://github.com/TuanKiri/weather-mcp-server/actions?workflow=Build)
[![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)
[![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)

<strong>[Report Bug](https://github.com/TuanKiri/weather-mcp-server/issues/new?assignees=&labels=bug&projects=&template=bug_report.yml)</strong> | <strong>[Request Feature](https://github.com/TuanKiri/weather-mcp-server/issues/new?assignees=&labels=enhancement&projects=&template=feature_request.yml)</strong>

Expand All @@ -29,7 +30,6 @@ To use your MCP server with Claude Desktop, add it to your Claude configuration:
"mcpServers": {
"weather-mcp-server": {
"command": "/path/to/weather-mcp-server",
"args": [],
"env": {
"WEATHER_API_KEY": "your-api-key"
}
Expand Down Expand Up @@ -62,7 +62,7 @@ go build -o weather-mcp-server ./cmd/weather-mcp-server

## Using MCP with Docker Containers

#### 1. Build the Docker image:
#### 1. Build the Docker Image:

```shell
docker build -t weather-mcp-server .
Expand Down Expand Up @@ -100,6 +100,30 @@ The project is organized into several key directories:
└── pkg
```

## Testing

If you're adding new features, please make sure to include tests for them.

#### 1. Install the mockgen tool:

```shell
go install go.uber.org/mock/mockgen@latest
```

See the installation guide on [go.uber.org/mock](https://github.com/uber-go/mock?tab=readme-ov-file#installation).

#### 2. Use the following command to generate mock files:

```shell
make generate-mocks
```

#### 3. To run unit tests:

```shell
make run-tests
```

## Contributing

Feel free to open tickets or send pull requests with improvements. Thanks in advance for your help!
Expand Down
9 changes: 8 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,16 @@ module github.com/TuanKiri/weather-mcp-server

go 1.24.1

require github.com/mark3labs/mcp-go v0.18.0
require (
github.com/mark3labs/mcp-go v0.18.0
github.com/stretchr/testify v1.9.0
go.uber.org/mock v0.5.1
)

require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,9 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
go.uber.org/mock v0.5.1 h1:ASgazW/qBmR+A32MYFDB6E2POoTgOwT509VP0CT/fjs=
go.uber.org/mock v0.5.1/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
82 changes: 82 additions & 0 deletions internal/server/handlers/weather_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package handlers

import (
"context"
"errors"
"testing"

"github.com/mark3labs/mcp-go/mcp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"

"github.com/TuanKiri/weather-mcp-server/internal/server/services/mock"
)

func TestCurrentWeather(t *testing.T) {
testCases := map[string]struct {
arguments map[string]any
errString string
wait string
setupWeatherService func(mocksWeather *mock.MockWeatherService)
}{
"empty_city": {
wait: "city must be a string",
},
"city_not_found": {
arguments: map[string]any{
"city": "Tokyo",
},
errString: "weather API not available. Code: 400",
setupWeatherService: func(mocksWeather *mock.MockWeatherService) {
mocksWeather.EXPECT().
Current(context.Background(), "Tokyo").
Return("", errors.New("weather API not available. Code: 400"))
},
},
"successful_request": {
arguments: map[string]any{
"city": "London",
},
wait: "<h1>London weather data</h1>",
setupWeatherService: func(mocksWeather *mock.MockWeatherService) {
mocksWeather.EXPECT().
Current(context.Background(), "London").
Return("<h1>London weather data</h1>", nil)
},
},
}

ctrl := gomock.NewController(t)
defer ctrl.Finish()

mocksWeather := mock.NewMockWeatherService(ctrl)

svc := mock.NewMockServices(ctrl)
svc.EXPECT().Weather().Return(mocksWeather).AnyTimes()

handler := CurrentWeather(svc)

for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
if tc.setupWeatherService != nil {
tc.setupWeatherService(mocksWeather)
}

var request mcp.CallToolRequest
request.Params.Arguments = tc.arguments

result, err := handler(context.Background(), request)
if err != nil {
assert.EqualError(t, err, tc.errString)
return
}

require.Len(t, result.Content, 1)
content, ok := result.Content[0].(mcp.TextContent)
require.True(t, ok)

assert.Equal(t, tc.wait, content.Text)
})
}
}
5 changes: 2 additions & 3 deletions internal/server/services/core/core.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,16 @@ import (
"html/template"

"github.com/TuanKiri/weather-mcp-server/internal/server/services"
"github.com/TuanKiri/weather-mcp-server/pkg/weatherapi"
)

type CoreServices struct {
renderer *template.Template
weatherAPI *weatherapi.WeatherAPI
weatherAPI services.WeatherAPIProvider

weatherService *WeatherService
}

func New(renderer *template.Template, weatherAPI *weatherapi.WeatherAPI) *CoreServices {
func New(renderer *template.Template, weatherAPI services.WeatherAPIProvider) *CoreServices {
return &CoreServices{
renderer: renderer,
weatherAPI: weatherAPI,
Expand Down
85 changes: 85 additions & 0 deletions internal/server/services/core/weather_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package core

import (
"context"
"errors"
"html/template"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"

"github.com/TuanKiri/weather-mcp-server/internal/server/services/mock"
"github.com/TuanKiri/weather-mcp-server/pkg/weatherapi/models"
)

func TestCurrentWeather(t *testing.T) {
testCases := map[string]struct {
city string
errString string
wait string
setupWeatherAPI func(weatherAPI *mock.MockWeatherAPIProvider)
}{
"city_not_found": {
city: "Tokyo",
errString: "weather API not available. Code: 400",
setupWeatherAPI: func(weatherAPI *mock.MockWeatherAPIProvider) {
weatherAPI.EXPECT().
Current(context.Background(), "Tokyo").
Return(nil, errors.New("weather API not available. Code: 400"))
},
},
"successful_result": {
city: "London",
wait: "London, United Kingdom Sunny 18 45 4 " +
"https://cdn.weatherapi.com/weather/64x64/day/113.png",
setupWeatherAPI: func(weatherAPI *mock.MockWeatherAPIProvider) {
weatherAPI.EXPECT().
Current(context.Background(), "London").
Return(&models.CurrentResponse{
Location: models.Location{
Name: "London",
Country: "United Kingdom",
},
Current: models.Current{
TempC: 18.4,
WindKph: 4.2,
Humidity: 45,
Condition: models.Condition{
Text: "Sunny",
Icon: "//cdn.weatherapi.com/weather/64x64/day/113.png",
},
},
}, nil)
},
},
}

renderer, err := template.New("weather.html").Parse(
"{{ .Location }} {{ .Condition }} {{ .Temperature }} " +
"{{ .Humidity }} {{ .WindSpeed }} {{ .Icon }}")
require.NoError(t, err)

ctrl := gomock.NewController(t)
defer ctrl.Finish()

weatherAPI := mock.NewMockWeatherAPIProvider(ctrl)

svc := New(renderer, weatherAPI)

for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
if tc.setupWeatherAPI != nil {
tc.setupWeatherAPI(weatherAPI)
}

data, err := svc.Weather().Current(context.Background(), tc.city)
if err != nil {
assert.EqualError(t, err, tc.errString)
}

assert.Equal(t, tc.wait, data)
})
}
}
13 changes: 13 additions & 0 deletions internal/server/services/external.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package services

import (
"context"

"github.com/TuanKiri/weather-mcp-server/pkg/weatherapi/models"
)

//go:generate mockgen --source external.go --destination mock/external_mock.go --package mock

type WeatherAPIProvider interface {
Current(ctx context.Context, city string) (*models.CurrentResponse, error)
}
2 changes: 2 additions & 0 deletions internal/server/services/mock/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
*.go
!.gitignore
1 change: 0 additions & 1 deletion internal/server/services/mock/mock.go

This file was deleted.

2 changes: 2 additions & 0 deletions internal/server/services/services.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package services

import "context"

//go:generate mockgen --source services.go --destination mock/mock.go --package mock

type Services interface {
Weather() WeatherService
}
Expand Down
18 changes: 18 additions & 0 deletions internal/server/tools/weather_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package tools

import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestCurrentWeather(t *testing.T) {
tool, handler := CurrentWeather(nil)

assert.Equal(t, "current_weather", tool.Name)
assert.NotEmpty(t, tool.Description)
assert.Contains(t, tool.InputSchema.Properties, "city")
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"city"})

assert.NotNil(t, handler)
}
Loading