From d834b5a2e13813e99476326dab8053e477a7c4fe Mon Sep 17 00:00:00 2001 From: "lilong.129" Date: Fri, 16 May 2025 10:39:44 +0800 Subject: [PATCH] feat: load mcp config file --- internal/version/VERSION | 2 +- pkg/mcphost/README.md | 5 ++ pkg/mcphost/config.go | 124 +++++++++++++++++++++++++++ pkg/mcphost/config_test.go | 30 +++++++ pkg/mcphost/testdata/demo_weather.py | 94 ++++++++++++++++++++ pkg/mcphost/testdata/test.mcp.json | 27 ++++++ 6 files changed, 281 insertions(+), 1 deletion(-) create mode 100644 pkg/mcphost/README.md create mode 100644 pkg/mcphost/config.go create mode 100644 pkg/mcphost/config_test.go create mode 100644 pkg/mcphost/testdata/demo_weather.py create mode 100644 pkg/mcphost/testdata/test.mcp.json diff --git a/internal/version/VERSION b/internal/version/VERSION index 5d530a5f..c0405126 100644 --- a/internal/version/VERSION +++ b/internal/version/VERSION @@ -1 +1 @@ -v5.0.0-beta-2505141501 +v5.0.0-beta-2505161141 diff --git a/pkg/mcphost/README.md b/pkg/mcphost/README.md new file mode 100644 index 00000000..93a9250c --- /dev/null +++ b/pkg/mcphost/README.md @@ -0,0 +1,5 @@ +# mcphost + +This package is a fork of [mark3labs/mcphost], and it helps HttpRunner to interact with external tools through the Model Context Protocol (MCP). + +[mark3labs/mcphost]: https://github.com/mark3labs/mcphost \ No newline at end of file diff --git a/pkg/mcphost/config.go b/pkg/mcphost/config.go new file mode 100644 index 00000000..523785fb --- /dev/null +++ b/pkg/mcphost/config.go @@ -0,0 +1,124 @@ +package mcphost + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +const ( + transportStdio = "stdio" + transportSSE = "sse" +) + +// MCPConfig represents the configuration for MCP servers +type MCPConfig struct { + MCPServers map[string]ServerConfigWrapper `json:"mcpServers"` +} + +// ServerConfig is an interface for different types of server configurations +type ServerConfig interface { + GetType() string + IsDisabled() bool +} + +// STDIOServerConfig represents configuration for a STDIO-based server +type STDIOServerConfig struct { + Command string `json:"command"` + Args []string `json:"args"` + Env map[string]string `json:"env,omitempty"` + Disabled bool `json:"disabled,omitempty"` +} + +func (s STDIOServerConfig) GetType() string { + return transportStdio +} + +func (s STDIOServerConfig) IsDisabled() bool { + return s.Disabled +} + +// SSEServerConfig represents configuration for an SSE-based server +type SSEServerConfig struct { + Url string `json:"url"` + Headers []string `json:"headers,omitempty"` + Disabled bool `json:"disabled,omitempty"` +} + +func (s SSEServerConfig) GetType() string { + return transportSSE +} + +func (s SSEServerConfig) IsDisabled() bool { + return s.Disabled +} + +// ServerConfigWrapper is a wrapper for different types of server configurations +type ServerConfigWrapper struct { + Config ServerConfig +} + +func (w *ServerConfigWrapper) UnmarshalJSON(data []byte) error { + var typeField struct { + Url string `json:"url"` + } + + if err := json.Unmarshal(data, &typeField); err != nil { + return err + } + if typeField.Url != "" { + // If the URL field is present, treat it as an SSE server + var sse SSEServerConfig + if err := json.Unmarshal(data, &sse); err != nil { + return err + } + w.Config = sse + } else { + // Otherwise, treat it as a STDIOServerConfig + var stdio STDIOServerConfig + if err := json.Unmarshal(data, &stdio); err != nil { + return err + } + w.Config = stdio + } + + return nil +} + +func (w ServerConfigWrapper) MarshalJSON() ([]byte, error) { + return json.Marshal(w.Config) +} + +// LoadMCPConfig loads the MCP configuration from the specified path or default location +func LoadMCPConfig(configPath string) (*MCPConfig, error) { + if configPath == "" { + homeDir, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("error getting home directory: %w", err) + } + configPath = filepath.Join(homeDir, ".mcp.json") + } + + // Check if config file exists + if _, err := os.Stat(configPath); os.IsNotExist(err) { + return nil, fmt.Errorf("config file does not exist: %s", configPath) + } + + // Read existing config + configData, err := os.ReadFile(configPath) + if err != nil { + return nil, fmt.Errorf( + "error reading config file %s: %w", + configPath, + err, + ) + } + + var config MCPConfig + if err := json.Unmarshal(configData, &config); err != nil { + return nil, fmt.Errorf("error parsing config file: %w", err) + } + + return &config, nil +} diff --git a/pkg/mcphost/config_test.go b/pkg/mcphost/config_test.go new file mode 100644 index 00000000..df12544d --- /dev/null +++ b/pkg/mcphost/config_test.go @@ -0,0 +1,30 @@ +package mcphost + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestLoadSettings(t *testing.T) { + // Load settings from test.mcp.json + settings, err := LoadMCPConfig("testdata/test.mcp.json") + if err != nil { + t.Fatalf("Failed to load settings: %v", err) + } + + // Verify settings are loaded correctly + assert.NotNil(t, settings) + assert.Contains(t, settings.MCPServers, "filesystem") + assert.Contains(t, settings.MCPServers, "weather") + + // Verify specific server configurations + filesystemConfig := settings.MCPServers["filesystem"].Config.(STDIOServerConfig) + assert.Equal(t, "npx", filesystemConfig.Command) + assert.Equal(t, []string{"-y", "@modelcontextprotocol/server-filesystem", "./"}, filesystemConfig.Args) + + weatherConfig := settings.MCPServers["weather"].Config.(STDIOServerConfig) + assert.Equal(t, "uv", weatherConfig.Command) + assert.Equal(t, []string{"--directory", "/Users/debugtalk/MyProjects/HttpRunner-dev/httprunner/pkg/mcphost/testdata", "run", "demo_weather.py"}, weatherConfig.Args) + assert.Equal(t, map[string]string{"ABC": "123"}, weatherConfig.Env) +} diff --git a/pkg/mcphost/testdata/demo_weather.py b/pkg/mcphost/testdata/demo_weather.py new file mode 100644 index 00000000..74a3015e --- /dev/null +++ b/pkg/mcphost/testdata/demo_weather.py @@ -0,0 +1,94 @@ +from typing import Any +import httpx +from mcp.server.fastmcp import FastMCP + +# Initialize FastMCP server +mcp = FastMCP("weather") + +# Constants +NWS_API_BASE = "https://api.weather.gov" +USER_AGENT = "weather-app/1.0" + +async def make_nws_request(url: str) -> dict[str, Any] | None: + """Make a request to the NWS API with proper error handling.""" + headers = { + "User-Agent": USER_AGENT, + "Accept": "application/geo+json" + } + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers, timeout=30.0) + response.raise_for_status() + return response.json() + except Exception: + return None + +def format_alert(feature: dict) -> str: + """Format an alert feature into a readable string.""" + props = feature["properties"] + return f""" +Event: {props.get('event', 'Unknown')} +Area: {props.get('areaDesc', 'Unknown')} +Severity: {props.get('severity', 'Unknown')} +Description: {props.get('description', 'No description available')} +Instructions: {props.get('instruction', 'No specific instructions provided')} +""" + +@mcp.tool() +async def get_alerts(state: str) -> str: + """Get weather alerts for a US state. + + Args: + state: Two-letter US state code (e.g. CA, NY) + """ + url = f"{NWS_API_BASE}/alerts/active/area/{state}" + data = await make_nws_request(url) + + if not data or "features" not in data: + return "Unable to fetch alerts or no alerts found." + + if not data["features"]: + return "No active alerts for this state." + + alerts = [format_alert(feature) for feature in data["features"]] + return "\n---\n".join(alerts) + +@mcp.tool() +async def get_forecast(latitude: float, longitude: float) -> str: + """Get weather forecast for a location. + + Args: + latitude: Latitude of the location + longitude: Longitude of the location + """ + # First get the forecast grid endpoint + points_url = f"{NWS_API_BASE}/points/{latitude},{longitude}" + points_data = await make_nws_request(points_url) + + if not points_data: + return "Unable to fetch forecast data for this location." + + # Get the forecast URL from the points response + forecast_url = points_data["properties"]["forecast"] + forecast_data = await make_nws_request(forecast_url) + + if not forecast_data: + return "Unable to fetch detailed forecast." + + # Format the periods into a readable forecast + periods = forecast_data["properties"]["periods"] + forecasts = [] + for period in periods[:5]: # Only show next 5 periods + forecast = f""" +{period['name']}: +Temperature: {period['temperature']}°{period['temperatureUnit']} +Wind: {period['windSpeed']} {period['windDirection']} +Forecast: {period['detailedForecast']} +""" + forecasts.append(forecast) + + return "\n---\n".join(forecasts) + +if __name__ == "__main__": + # Initialize and run the server + mcp.run(transport='stdio') diff --git a/pkg/mcphost/testdata/test.mcp.json b/pkg/mcphost/testdata/test.mcp.json new file mode 100644 index 00000000..b6a9a947 --- /dev/null +++ b/pkg/mcphost/testdata/test.mcp.json @@ -0,0 +1,27 @@ +{ + "mcpServers": { + "filesystem": { + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-filesystem", + "./" + ] + }, + "weather": { + "args": [ + "--directory", + "/Users/debugtalk/MyProjects/HttpRunner-dev/httprunner/pkg/mcphost/testdata", + "run", + "demo_weather.py" + ], + "autoApprove": [ + "get_forecast" + ], + "command": "uv", + "env": { + "ABC": "123" + } + } + } +}