Browse Source

feat: add hefeng weather mcp server (#250)

* feat: add hefeng weather mcp server

* fix: ci lint
zijiren 6 months ago
parent
commit
6ae2bcca4e

+ 17 - 0
mcp-servers/hefeng-weather/README.md

@@ -0,0 +1,17 @@
+# HeFeng Weather MCP Server
+
+> <https://github.com/shanggqm/hefeng-mcp-weather>
+
+A Model Context Protocol server that provides weather forecast data for locations in China through HeFeng Weather API.
+
+## Features
+
+- Get real-time weather data
+- Get hourly weather forecast (24h/72h/168h)
+- Get daily weather forecast (3d/7d/10d/15d/30d)
+- Support location query by longitude and latitude coordinates
+- Full Chinese weather description
+
+## Find your api key
+
+<https://console.qweather.com/project>

+ 27 - 0
mcp-servers/hefeng-weather/init.go

@@ -0,0 +1,27 @@
+package hefengweather
+
+import (
+	_ "embed"
+
+	mcpservers "github.com/labring/aiproxy/mcp-servers"
+)
+
+//go:embed README.md
+var readme string
+
+func init() {
+	mcpservers.Register(
+		mcpservers.NewMcp(
+			"hefeng-weather",
+			"HeFeng Weather",
+			mcpservers.McpTypeEmbed,
+			mcpservers.WithNewServerFunc(NewServer),
+			mcpservers.WithGitHubURL(
+				"https://github.com/shanggqm/hefeng-mcp-weather",
+			),
+			mcpservers.WithConfigTemplates(configTemplates),
+			mcpservers.WithTags([]string{"weather", "天气", "和风天气", "forecast", "china"}),
+			mcpservers.WithReadme(readme),
+		),
+	)
+}

+ 398 - 0
mcp-servers/hefeng-weather/server.go

@@ -0,0 +1,398 @@
+package hefengweather
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/bytedance/sonic"
+	mcpservers "github.com/labring/aiproxy/mcp-servers"
+	"github.com/mark3labs/mcp-go/mcp"
+	"github.com/mark3labs/mcp-go/server"
+)
+
+// Configuration templates for the weather server
+var configTemplates = mcpservers.ConfigTemplates{
+	"hefeng_api_key": {
+		Name:        "和风天气API密钥",
+		Required:    mcpservers.ConfigRequiredTypeInitOrReusingOnly,
+		Example:     "your_hefeng_api_key_here",
+		Description: "和风天气API密钥,用于获取天气数据",
+	},
+	"hefeng_api_base": {
+		Name:        "和风天气API基础URL",
+		Required:    mcpservers.ConfigRequiredTypeInitOrReusingOnly,
+		Example:     "https://devapi.qweather.com/v7",
+		Description: "和风天气API基础URL,用于获取天气数据",
+		Validator: func(value string) error {
+			_, err := url.Parse(value)
+			return err
+		},
+	},
+}
+
+// WeatherServer represents the MCP server for weather functionality
+type WeatherServer struct {
+	*server.MCPServer
+	apiKey     string
+	httpClient *http.Client
+	apiBase    string
+}
+
+// WeatherArguments represents the arguments for weather requests
+type WeatherArguments struct {
+	Location string `json:"location"`
+	Days     string `json:"days"`
+}
+
+// NowResponse represents the current weather response
+type NowResponse struct {
+	Code string `json:"code"`
+	Now  struct {
+		ObsTime   string `json:"obsTime"`
+		Temp      string `json:"temp"`
+		FeelsLike string `json:"feelsLike"`
+		Text      string `json:"text"`
+		WindDir   string `json:"windDir"`
+		WindScale string `json:"windScale"`
+	} `json:"now"`
+}
+
+// DailyResponse represents the daily weather forecast response
+type DailyResponse struct {
+	Code  string `json:"code"`
+	Daily []struct {
+		FxDate         string `json:"fxDate"`
+		TempMax        string `json:"tempMax"`
+		TempMin        string `json:"tempMin"`
+		TextDay        string `json:"textDay"`
+		TextNight      string `json:"textNight"`
+		WindDirDay     string `json:"windDirDay"`
+		WindScaleDay   string `json:"windScaleDay"`
+		WindDirNight   string `json:"windDirNight"`
+		WindScaleNight string `json:"windScaleNight"`
+	} `json:"daily"`
+}
+
+// HourlyResponse represents the hourly weather forecast response
+type HourlyResponse struct {
+	Code   string `json:"code"`
+	Hourly []struct {
+		FxTime    string `json:"fxTime"`
+		Temp      string `json:"temp"`
+		Text      string `json:"text"`
+		WindDir   string `json:"windDir"`
+		WindScale string `json:"windScale"`
+		Humidity  string `json:"humidity"`
+	} `json:"hourly"`
+}
+
+// NewServer creates a new MCP server for weather functionality
+func NewServer(config, _ map[string]string) (mcpservers.Server, error) {
+	// Get API key from config or environment
+	apiKey := config["hefeng_api_key"]
+	if apiKey == "" {
+		return nil, errors.New("api key is required")
+	}
+	apiBase := config["hefeng_api_base"]
+	if apiBase == "" {
+		return nil, errors.New("api base is required")
+	}
+
+	if !strings.HasSuffix(apiBase, "/v7") {
+		apiBase += "/v7"
+	}
+
+	// Create MCP server
+	mcpServer := server.NewMCPServer(
+		"hefeng-weather",
+		"1.0.0",
+	)
+
+	// Create HTTP client
+	httpClient := &http.Client{
+		Timeout: 30 * time.Second,
+	}
+
+	weatherServer := &WeatherServer{
+		MCPServer:  mcpServer,
+		apiKey:     apiKey,
+		apiBase:    apiBase,
+		httpClient: httpClient,
+	}
+
+	// Add weather tool
+	weatherServer.addWeatherTool()
+
+	return weatherServer, nil
+}
+
+// validateDays validates the days parameter
+func validateDays(days string) error {
+	validDays := []string{"now", "24h", "72h", "168h", "3d", "7d", "10d", "15d", "30d"}
+	for _, validDay := range validDays {
+		if days == validDay {
+			return nil
+		}
+	}
+	return fmt.Errorf("无效的预报天数: %s,有效值为: %s", days, strings.Join(validDays, ", "))
+}
+
+// makeHeFengRequest makes a request to the HeFeng API
+func (s *WeatherServer) makeHeFengRequest(ctx context.Context, endpoint string) ([]byte, error) {
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
+	if err != nil {
+		return nil, fmt.Errorf("创建请求失败: %w", err)
+	}
+
+	req.Header.Set("Accept", "application/json")
+
+	resp, err := s.httpClient.Do(req)
+	if err != nil {
+		return nil, fmt.Errorf("请求失败: %w", err)
+	}
+	defer resp.Body.Close()
+
+	body, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return nil, fmt.Errorf("读取响应失败: %w", err)
+	}
+
+	if resp.StatusCode != http.StatusOK {
+		return nil, fmt.Errorf("HTTP错误: %d, %s", resp.StatusCode, body)
+	}
+
+	return body, nil
+}
+
+// addWeatherTool adds the weather tool to the server
+func (s *WeatherServer) addWeatherTool() {
+	weatherTool := mcp.Tool{
+		Name:        "get-weather",
+		Description: "获取中国国内的天气预报",
+		InputSchema: mcp.ToolInputSchema{
+			Type: "object",
+			Properties: map[string]any{
+				"location": map[string]any{
+					"type":        "string",
+					"description": "逗号分隔的经纬度信息 (e.g., 116.40,39.90)",
+				},
+				"days": map[string]any{
+					"type": "string",
+					"enum": []string{
+						"now", "24h", "72h", "168h", "3d", "7d", "10d", "15d", "30d",
+					},
+					"description": "预报天数,now为实时天气,24h为24小时预报,72h为72小时预报,168h为168小时预报,3d为3天预报,以此类推",
+					"default":     "now",
+				},
+			},
+			Required: []string{"location"},
+		},
+	}
+
+	s.AddTool(weatherTool, s.handleWeather)
+}
+
+// handleWeather handles the weather tool
+func (s *WeatherServer) handleWeather(
+	ctx context.Context,
+	request mcp.CallToolRequest,
+) (*mcp.CallToolResult, error) {
+	args := request.GetArguments()
+
+	location, ok := args["location"].(string)
+	if !ok || location == "" {
+		return nil, errors.New("location参数是必需的")
+	}
+
+	days := "now"
+	if d, ok := args["days"].(string); ok && d != "" {
+		days = d
+	}
+
+	// Validate days parameter
+	if err := validateDays(days); err != nil {
+		return nil, err
+	}
+
+	// URL encode the location parameter
+	encodedLocation := url.QueryEscape(location)
+
+	switch days {
+	case "now":
+		return s.handleCurrentWeather(ctx, encodedLocation, location)
+	case "24h", "72h", "168h":
+		return s.handleHourlyWeather(ctx, encodedLocation, location, days)
+	default:
+		return s.handleDailyWeather(ctx, encodedLocation, location, days)
+	}
+}
+
+// handleCurrentWeather handles current weather requests
+func (s *WeatherServer) handleCurrentWeather(
+	ctx context.Context,
+	encodedLocation, location string,
+) (*mcp.CallToolResult, error) {
+	weatherURL := fmt.Sprintf(
+		"%s/weather/now?location=%s&key=%s",
+		s.apiBase,
+		encodedLocation,
+		s.apiKey,
+	)
+
+	body, err := s.makeHeFengRequest(ctx, weatherURL)
+	if err != nil {
+		return mcp.NewToolResultError(fmt.Sprintf("获取天气数据失败: %v", err)), nil
+	}
+
+	var weatherData NowResponse
+	if err := sonic.Unmarshal(body, &weatherData); err != nil {
+		return mcp.NewToolResultError(fmt.Sprintf("解析天气数据失败: %v", err)), nil
+	}
+
+	// Check API response code
+	if weatherData.Code != "200" {
+		return mcp.NewToolResultError("API返回错误代码: " + weatherData.Code), nil
+	}
+
+	now := weatherData.Now
+	weatherText := fmt.Sprintf(`地点: %s
+观测时间: %s
+天气: %s
+温度: %s°C
+体感温度: %s°C
+风向: %s
+风力: %s级`,
+		location,
+		now.ObsTime,
+		now.Text,
+		now.Temp,
+		now.FeelsLike,
+		now.WindDir,
+		now.WindScale)
+
+	return mcp.NewToolResultText(weatherText), nil
+}
+
+// handleHourlyWeather handles hourly weather forecast requests
+func (s *WeatherServer) handleHourlyWeather(
+	ctx context.Context,
+	encodedLocation, location, days string,
+) (*mcp.CallToolResult, error) {
+	weatherURL := fmt.Sprintf(
+		"%s/weather/%s?location=%s&key=%s",
+		s.apiBase,
+		days,
+		encodedLocation,
+		s.apiKey,
+	)
+
+	body, err := s.makeHeFengRequest(ctx, weatherURL)
+	if err != nil {
+		return mcp.NewToolResultError(fmt.Sprintf("获取逐小时天气预报数据失败: %v", err)), nil
+	}
+
+	var weatherData HourlyResponse
+	if err := sonic.Unmarshal(body, &weatherData); err != nil {
+		return mcp.NewToolResultError(fmt.Sprintf("解析逐小时天气预报数据失败: %v", err)), nil
+	}
+
+	// Check API response code
+	if weatherData.Code != "200" {
+		return mcp.NewToolResultError("API返回错误代码: " + weatherData.Code), nil
+	}
+
+	if len(weatherData.Hourly) == 0 {
+		return mcp.NewToolResultError(fmt.Sprintf("无法获取 %s 的逐小时天气预报数据", location)), nil
+	}
+
+	var hoursText strings.Builder
+	for _, hour := range weatherData.Hourly {
+		hoursText.WriteString(fmt.Sprintf(`时间: %s
+天气: %s
+温度: %s°C
+湿度: %s%%
+风向: %s %s级
+------------------------
+`,
+			hour.FxTime,
+			hour.Text,
+			hour.Temp,
+			hour.Humidity,
+			hour.WindDir,
+			hour.WindScale))
+	}
+
+	result := fmt.Sprintf("地点: %s\n%s小时预报:\n%s", location, days, hoursText.String())
+	return mcp.NewToolResultText(result), nil
+}
+
+// handleDailyWeather handles daily weather forecast requests
+func (s *WeatherServer) handleDailyWeather(
+	ctx context.Context,
+	encodedLocation, location, days string,
+) (*mcp.CallToolResult, error) {
+	weatherURL := fmt.Sprintf(
+		"%s/weather/%s?location=%s&key=%s",
+		s.apiBase,
+		days,
+		encodedLocation,
+		s.apiKey,
+	)
+
+	body, err := s.makeHeFengRequest(ctx, weatherURL)
+	if err != nil {
+		return mcp.NewToolResultError(fmt.Sprintf("获取天气预报数据失败: %v", err)), nil
+	}
+
+	var weatherData DailyResponse
+	if err := sonic.Unmarshal(body, &weatherData); err != nil {
+		return mcp.NewToolResultError(fmt.Sprintf("解析天气预报数据失败: %v", err)), nil
+	}
+
+	// Check API response code
+	if weatherData.Code != "200" {
+		return mcp.NewToolResultError("API返回错误代码: " + weatherData.Code), nil
+	}
+
+	if len(weatherData.Daily) == 0 {
+		return mcp.NewToolResultError(fmt.Sprintf("无法获取 %s 的天气预报数据", location)), nil
+	}
+
+	var forecastText strings.Builder
+	for _, day := range weatherData.Daily {
+		forecastText.WriteString(fmt.Sprintf(`日期: %s
+白天天气: %s
+夜间天气: %s
+最高温度: %s°C
+最低温度: %s°C
+白天风向: %s %s级
+夜间风向: %s %s级
+------------------------
+`,
+			day.FxDate,
+			day.TextDay,
+			day.TextNight,
+			day.TempMax,
+			day.TempMin,
+			day.WindDirDay,
+			day.WindScaleDay,
+			day.WindDirNight,
+			day.WindScaleNight))
+	}
+
+	// Parse days number for display
+	daysNum := days
+	if parsedDays, err := strconv.Atoi(strings.TrimSuffix(days, "d")); err == nil {
+		daysNum = strconv.Itoa(parsedDays)
+	}
+
+	result := fmt.Sprintf("地点: %s\n%s天预报:\n%s", location, daysNum, forecastText.String())
+	return mcp.NewToolResultText(result), nil
+}

+ 1 - 0
mcp-servers/mcpregister/init.go

@@ -11,6 +11,7 @@ import (
 	_ "github.com/labring/aiproxy/mcp-servers/fetch"
 	_ "github.com/labring/aiproxy/mcp-servers/firecrawl"
 	_ "github.com/labring/aiproxy/mcp-servers/flomo"
+	_ "github.com/labring/aiproxy/mcp-servers/hefeng-weather"
 	_ "github.com/labring/aiproxy/mcp-servers/jina-tools"
 	_ "github.com/labring/aiproxy/mcp-servers/time"
 	_ "github.com/labring/aiproxy/mcp-servers/web-search"