From e333ba380a2dd971aa6cea8e82aa75c5d3440469 Mon Sep 17 00:00:00 2001 From: "lilong.129" Date: Fri, 16 May 2025 14:14:56 +0800 Subject: [PATCH] refactor: move mcp to pkg/mcphost --- cmd/server.go | 2 +- config.go | 1 + internal/mcp/config.go | 92 ------ internal/mcp/config_test.go | 31 -- internal/mcp/hub.go | 432 -------------------------- internal/mcp/hub_test.go | 286 ----------------- internal/mcp/testdata/demo_weather.py | 94 ------ internal/mcp/testdata/test.mcp.json | 27 -- internal/version/VERSION | 2 +- runner.go | 18 ++ server/main.go | 14 +- server/tool.go | 6 +- server/ui_test.go | 2 +- 13 files changed, 32 insertions(+), 975 deletions(-) delete mode 100644 internal/mcp/config.go delete mode 100644 internal/mcp/config_test.go delete mode 100644 internal/mcp/hub.go delete mode 100644 internal/mcp/hub_test.go delete mode 100644 internal/mcp/testdata/demo_weather.py delete mode 100644 internal/mcp/testdata/test.mcp.json diff --git a/cmd/server.go b/cmd/server.go index 3cdcc692..2556021c 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -17,7 +17,7 @@ var CmdServer = &cobra.Command{ router := server.NewRouter() mcpConfigPath = os.ExpandEnv(mcpConfigPath) if mcpConfigPath != "" { - router.InitMCPHub(mcpConfigPath) + router.InitMCPHost(mcpConfigPath) } return router.Run(port) }, diff --git a/config.go b/config.go index d8813d90..bda473da 100644 --- a/config.go +++ b/config.go @@ -42,6 +42,7 @@ type TConfig struct { Weight int `json:"weight,omitempty" yaml:"weight,omitempty"` Path string `json:"path,omitempty" yaml:"path,omitempty"` // testcase file path PluginSetting *PluginConfig `json:"plugin,omitempty" yaml:"plugin,omitempty"` // plugin config + MCPConfigPath string `json:"mcp_config_path,omitempty" yaml:"mcp_config_path,omitempty"` IgnorePopup bool `json:"ignore_popup,omitempty" yaml:"ignore_popup,omitempty"` LLMService option.LLMServiceType `json:"llm_service,omitempty" yaml:"llm_service,omitempty"` CVService option.CVServiceType `json:"cv_service,omitempty" yaml:"cv_service,omitempty"` diff --git a/internal/mcp/config.go b/internal/mcp/config.go deleted file mode 100644 index 3377040b..00000000 --- a/internal/mcp/config.go +++ /dev/null @@ -1,92 +0,0 @@ -package mcp - -import ( - "encoding/json" - "fmt" - "os" - "time" - - "github.com/rs/zerolog/log" -) - -// MCPSettings represents the main configuration structure -type MCPSettings struct { - MCPServers map[string]ServerConfig `json:"mcpServers"` -} - -// ServerConfig represents configuration for a single MCP server -type ServerConfig struct { - TransportType string `json:"transportType,omitempty"` // "sse" or "stdio" - AutoApprove []string `json:"autoApprove,omitempty"` - Disabled bool `json:"disabled,omitempty"` - Timeout time.Duration `json:"timeout,omitempty"` - - // SSE specific config - URL string `json:"url,omitempty"` - - // Stdio specific config - Command string `json:"command"` - Args []string `json:"args"` - Env map[string]string `json:"env,omitempty"` -} - -const ( - DefaultMCPTimeoutSeconds = 30 - MinMCPTimeoutSeconds = 5 -) - -// GetTimeoutDuration converts timeout seconds to time.Duration -func (c *ServerConfig) GetTimeoutDuration() time.Duration { - if c.Timeout == 0 { - return time.Duration(DefaultMCPTimeoutSeconds) * time.Second - } - return c.Timeout -} - -// LoadSettings loads MCP settings from the config file -func LoadSettings(path string) (*MCPSettings, error) { - log.Info().Str("path", path).Msg("load MCP settings") - data, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("failed to read settings file: %w", err) - } - - var settings MCPSettings - if err := json.Unmarshal(data, &settings); err != nil { - return nil, fmt.Errorf("failed to parse settings: %w", err) - } - - if err := validateSettings(&settings); err != nil { - return nil, fmt.Errorf("invalid settings: %w", err) - } - - return &settings, nil -} - -// validateSettings validates the MCP settings -func validateSettings(settings *MCPSettings) error { - if settings == nil { - return fmt.Errorf("settings cannot be nil") - } - - for name, server := range settings.MCPServers { - if server.Timeout > 0 && server.Timeout < time.Duration(MinMCPTimeoutSeconds)*time.Second { - return fmt.Errorf("server %s: timeout must be at least %d seconds", name, MinMCPTimeoutSeconds) - } - - switch server.TransportType { - case "sse": - if server.URL == "" { - return fmt.Errorf("server %s: URL is required for SSE transport", name) - } - case "stdio", "": - if server.Command == "" { - return fmt.Errorf("server %s: command is required for stdio transport", name) - } - default: - return fmt.Errorf("server %s: unsupported transport type: %s", name, server.TransportType) - } - } - - return nil -} diff --git a/internal/mcp/config_test.go b/internal/mcp/config_test.go deleted file mode 100644 index e3942216..00000000 --- a/internal/mcp/config_test.go +++ /dev/null @@ -1,31 +0,0 @@ -package mcp - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestLoadSettings(t *testing.T) { - // Load settings from test.mcp.json - settings, err := LoadSettings("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"] - assert.Equal(t, "npx", filesystemConfig.Command) - assert.Equal(t, []string{"-y", "@modelcontextprotocol/server-filesystem", "/tmp"}, filesystemConfig.Args) - - weatherConfig := settings.MCPServers["weather"] - assert.Equal(t, "uv", weatherConfig.Command) - assert.Equal(t, []string{"--directory", "/Users/debugtalk/MyProjects/HttpRunner-dev/httprunner/internal/mcp/testdata", "run", "demo_weather.py"}, weatherConfig.Args) - assert.Equal(t, []string{"get_forecast"}, weatherConfig.AutoApprove) - assert.Equal(t, map[string]string{"ABC": "123"}, weatherConfig.Env) -} diff --git a/internal/mcp/hub.go b/internal/mcp/hub.go deleted file mode 100644 index 521af463..00000000 --- a/internal/mcp/hub.go +++ /dev/null @@ -1,432 +0,0 @@ -package mcp - -import ( - "bufio" - "context" - "fmt" - "os" - "strings" - "sync" - "time" - - "github.com/bytedance/sonic" - mcpp "github.com/cloudwego/eino-ext/components/tool/mcp" - "github.com/cloudwego/eino/components/tool" - "github.com/httprunner/httprunner/v5/internal/version" - "github.com/mark3labs/mcp-go/client" - "github.com/mark3labs/mcp-go/mcp" - "github.com/pkg/errors" - "github.com/rs/zerolog/log" -) - -type MCPTools struct { - Name string - Tools []mcp.Tool - Err error -} - -type MCPHub struct { - mu sync.RWMutex - connections map[string]*Connection - config *MCPSettings -} - -type Connection struct { - Client client.MCPClient - Config ServerConfig -} - -func NewMCPHub(configPath string) (*MCPHub, error) { - settings, err := LoadSettings(configPath) - if err != nil { - return nil, err - } - return &MCPHub{ - connections: make(map[string]*Connection), - config: settings, - }, nil -} - -// InitServers initializes all enabled MCP servers -func (h *MCPHub) InitServers(ctx context.Context) error { - for name, config := range h.config.MCPServers { - if config.Disabled { - continue - } - - if err := h.connectToServer(ctx, name, config); err != nil { - return fmt.Errorf("failed to connect to server %s: %w", name, err) - } - } - - return nil -} - -// GetClient returns the client for the specified server -func (h *MCPHub) GetClient(serverName string) (client.MCPClient, error) { - h.mu.RLock() - defer h.mu.RUnlock() - - conn, exists := h.connections[serverName] - if !exists { - return nil, fmt.Errorf("no connection found for server %s", serverName) - } - - if conn.Config.Disabled { - return nil, fmt.Errorf("server %s is disabled", serverName) - } - - return conn.Client, nil -} - -// connectToServer establishes connection to a single MCP server -func (h *MCPHub) connectToServer(ctx context.Context, serverName string, config ServerConfig) error { - h.mu.Lock() - defer h.mu.Unlock() - - log.Debug().Str("server", serverName).Msg("connecting to MCP server") - - // Close existing connection if any - if existing, exists := h.connections[serverName]; exists { - if err := existing.Client.Close(); err != nil { - return fmt.Errorf("failed to close existing connection: %w", err) - } - delete(h.connections, serverName) - } - - var mcpClient *client.Client - var err error - - // create client - switch config.TransportType { - case "sse": - mcpClient, err = client.NewSSEMCPClient(config.URL) - - case "stdio", "": // default to stdio - var env []string - for k, v := range config.Env { - env = append(env, fmt.Sprintf("%s=%s", k, v)) - } - mcpClient, err = client.NewStdioMCPClient(config.Command, - env, config.Args...) - - // print MCP Server logs for stdio transport - stderr, _ := client.GetStderr(mcpClient) - go func() { - scanner := bufio.NewScanner(stderr) - for scanner.Scan() { - fmt.Fprintf(os.Stderr, "MCP Server %s: %s\n", - serverName, scanner.Text()) - } - }() - - default: - return fmt.Errorf("unsupported transport type: %s", config.TransportType) - } - if err != nil { - return fmt.Errorf("failed to create client: %w", err) - } - - // prepare client init request - initRequest := mcp.InitializeRequest{} - initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION - initRequest.Params.Capabilities = mcp.ClientCapabilities{} - initRequest.Params.ClientInfo = mcp.Implementation{ - Name: "HttpRunner", - Version: version.VERSION, - } - - // initialize client - _, err = mcpClient.Initialize(ctx, initRequest) - if err != nil { - mcpClient.Close() - return errors.Wrapf(err, "initialize MCP client for %s failed", serverName) - } - - log.Info().Str("server", serverName).Msg("connected to MCP server") - h.connections[serverName] = &Connection{ - Client: mcpClient, - Config: config, - } - return nil -} - -// GetTools fetches available tools from all connected MCP servers -func (h *MCPHub) GetTools(ctx context.Context) map[string]MCPTools { - h.mu.RLock() - defer h.mu.RUnlock() - - results := make(map[string]MCPTools) - - for serverName, conn := range h.connections { - if conn.Config.Disabled { - continue - } - - // get tools from MCP server tools - listResults, err := conn.Client.ListTools(ctx, mcp.ListToolsRequest{}) - if err != nil { - results[serverName] = MCPTools{ - Name: serverName, - Tools: nil, - Err: fmt.Errorf("failed to get tools: %w", err), - } - continue - } - - results[serverName] = MCPTools{ - Name: serverName, - Tools: listResults.Tools, - Err: nil, - } - } - - return results -} - -func (h *MCPHub) GetTool(ctx context.Context, serverName, toolName string) (*mcp.Tool, error) { - h.mu.RLock() - defer h.mu.RUnlock() - - // filter MCP server by serverName - mcpTools, exists := h.GetTools(ctx)[serverName] - if !exists { - return nil, fmt.Errorf("no connection found for server %s", serverName) - } else if mcpTools.Err != nil { - return nil, mcpTools.Err - } - - // filter tool by toolName - for _, tool := range mcpTools.Tools { - if tool.Name == toolName { - return &tool, nil - } - } - - return nil, fmt.Errorf("tool %s not found", toolName) -} - -// InvokeTool calls a tool with the given arguments -func (h *MCPHub) InvokeTool(ctx context.Context, - serverName, toolName string, arguments map[string]interface{}, -) (*mcp.CallToolResult, error) { - log.Info().Str("tool", toolName).Interface("args", arguments). - Str("server", serverName).Msg("invoke tool") - - conn, err := h.GetClient(serverName) - if err != nil { - return nil, errors.Wrapf(err, - "get mcp client for server %s failed", serverName) - } - - mcpTool, err := h.GetTool(ctx, serverName, toolName) - if err != nil { - return nil, errors.Wrapf(err, - "get mcp tool %s/%s failed", serverName, toolName) - } - - req := mcp.CallToolRequest{} - req.Params.Name = mcpTool.Name - req.Params.Arguments = arguments - callToolResult, err := conn.CallTool(ctx, req) - if err != nil { - return nil, errors.Wrapf(err, - "call tool %s/%s failed", serverName, toolName) - } - - return callToolResult, nil -} - -// GetEinoTool returns an eino tool from the MCP server -func (h *MCPHub) GetEinoTool(ctx context.Context, serverName, toolName string) (tool.BaseTool, error) { - h.mu.RLock() - defer h.mu.RUnlock() - - // filter MCP server by serverName - conn, exists := h.connections[serverName] - if !exists { - return nil, fmt.Errorf("no connection found for server %s", serverName) - } - - if conn.Config.Disabled { - return nil, fmt.Errorf("server %s is disabled", serverName) - } - - // get tools from MCP server and convert to eino tools - tools, err := mcpp.GetTools(ctx, &mcpp.Config{ - Cli: conn.Client, - ToolNameList: []string{toolName}, - }) - if err != nil || len(tools) == 0 { - log.Error().Err(err). - Str("server", serverName).Str("tool", toolName). - Msg("get MCP tool failed") - return nil, err - } - - return tools[0], nil -} - -// CloseServers closes all connected MCP servers -func (h *MCPHub) CloseServers() error { - h.mu.Lock() - defer h.mu.Unlock() - - log.Info().Msg("Shutting down MCP servers...") - for name, client := range h.connections { - if err := client.Client.Close(); err != nil { - log.Error().Str("name", name).Err(err).Msg("Failed to close server") - } else { - delete(h.connections, name) - log.Info().Str("name", name).Msg("Server closed") - } - } - - return nil -} - -// MCPToolRecord represents a single tool record in the database -// Each record contains detailed information about a tool and its server -type MCPToolRecord struct { - ToolID string `json:"tool_id"` // Unique identifier for the tool record - ServerName string `json:"mcp_server"` // Name of the MCP server - ToolName string `json:"tool_name"` // Name of the tool - Description string `json:"description"` // Tool description - Parameters string `json:"parameters"` // Tool input parameters in JSON format - Returns string `json:"returns"` // Tool return value format in JSON format - CreatedAt time.Time `json:"created_at"` // Record creation time - LastUpdatedAt time.Time `json:"last_updated_at"` // Record last update time -} - -// DocStringInfo contains the parsed information from a Python docstring -type DocStringInfo struct { - Description string - Parameters map[string]string - Returns map[string]string -} - -// extractDocStringInfo extracts information from a Python docstring -// Example input: -// """Get weather alerts for a US state. -// -// Args: -// state: Two-letter US state code (e.g. CA, NY) -// -// Returns: -// alerts: List of active weather alerts for the specified state -// error: Error message if the request fails -// """ -func extractDocStringInfo(docstring string) DocStringInfo { - info := DocStringInfo{ - Parameters: make(map[string]string), - Returns: make(map[string]string), - } - - // Find the Args and Returns sections - argsIndex := strings.Index(docstring, "Args:") - returnsIndex := strings.Index(docstring, "Returns:") - - // Extract description (everything before Args) - if argsIndex != -1 { - info.Description = strings.TrimSpace(docstring[:argsIndex]) - } else if returnsIndex != -1 { - info.Description = strings.TrimSpace(docstring[:returnsIndex]) - } else { - info.Description = strings.TrimSpace(docstring) - return info - } - - // Helper function to extract key-value pairs from a section - extractSection := func(content string) map[string]string { - result := make(map[string]string) - lines := strings.Split(content, "\n") - - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" { - continue - } - - parts := strings.SplitN(line, ":", 2) - if len(parts) != 2 { - continue - } - - key := strings.TrimSpace(parts[0]) - value := strings.TrimSpace(parts[1]) - - if key != "" && value != "" { - result[key] = value - } - } - - return result - } - - // Extract Args section - if argsIndex != -1 { - endIndex := returnsIndex - if endIndex == -1 { - endIndex = len(docstring) - } - argsContent := docstring[argsIndex+len("Args:") : endIndex] - info.Parameters = extractSection(argsContent) - } - - // Extract Returns section - if returnsIndex != -1 { - returnsContent := docstring[returnsIndex+len("Returns:"):] - info.Returns = extractSection(returnsContent) - } - - return info -} - -// ConvertToolsToRecords converts map[string]MCPTools to a list of database records -func ConvertToolsToRecords(toolsMap map[string]MCPTools) []MCPToolRecord { - var records []MCPToolRecord - now := time.Now() - - for serverName, mcpTools := range toolsMap { - if mcpTools.Err != nil { - log.Error().Str("server", serverName).Err(mcpTools.Err).Msg("skip tools conversion due to error") - continue - } - - for _, tool := range mcpTools.Tools { - // Generate unique ID by combining server name and tool name - id := fmt.Sprintf("%s_%s", serverName, tool.Name) - - // Extract docstring information - info := extractDocStringInfo(tool.Description) - - // Convert parameters and returns to JSON - paramsJSON, err := sonic.MarshalString(info.Parameters) - if err != nil { - log.Warn().Interface("params", info.Parameters).Err(err).Msg("failed to marshal parameters to JSON") - paramsJSON = "{}" - } - - returnsJSON, err := sonic.MarshalString(info.Returns) - if err != nil { - log.Warn().Interface("returns", info.Returns).Err(err).Msg("failed to marshal returns to JSON") - returnsJSON = "{}" - } - - record := MCPToolRecord{ - ToolID: id, - ServerName: serverName, - ToolName: tool.Name, - Description: info.Description, - Parameters: paramsJSON, - Returns: returnsJSON, - CreatedAt: now, - LastUpdatedAt: now, - } - - records = append(records, record) - } - } - - return records -} diff --git a/internal/mcp/hub_test.go b/internal/mcp/hub_test.go deleted file mode 100644 index 22ffd12e..00000000 --- a/internal/mcp/hub_test.go +++ /dev/null @@ -1,286 +0,0 @@ -package mcp - -import ( - "context" - "encoding/json" - "os" - "testing" - "time" - - "github.com/cloudwego/eino/components/tool" - "github.com/mark3labs/mcp-go/mcp" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestGetTools(t *testing.T) { - hub, err := NewMCPHub("./testdata/test.mcp.json") - require.NoError(t, err) - - ctx := context.Background() - err = hub.InitServers(ctx) - require.NoError(t, err) - - tools := hub.GetTools(ctx) - assert.NoError(t, err) - assert.Equal(t, 2, len(tools)) -} - -func TestCallTool(t *testing.T) { - hub, err := NewMCPHub("./testdata/test.mcp.json") - require.NoError(t, err) - - ctx := context.Background() - err = hub.InitServers(ctx) - require.NoError(t, err) - - result, err := hub.InvokeTool(ctx, "weather", "get_alerts", - map[string]interface{}{"state": "CA"}, - ) - require.NoError(t, err) - - t.Logf("Result: %v", result) -} - -func TestCallEinoTool(t *testing.T) { - hub, err := NewMCPHub("./testdata/test.mcp.json") - require.NoError(t, err) - - ctx := context.Background() - err = hub.InitServers(ctx) - require.NoError(t, err) - - einoTool, err := hub.GetEinoTool(ctx, "weather", "get_alerts") - require.NoError(t, err) - t.Logf("Tool: %v", einoTool) - - tool := einoTool.(tool.InvokableTool) - result, err := tool.InvokableRun(ctx, `{"state": "CA"}`) - require.NoError(t, err) - t.Logf("Result: %v", result) -} - -func TestConvertToolsToRecordsFromFile(t *testing.T) { - hub, err := NewMCPHub("./testdata/test.mcp.json") - require.NoError(t, err) - - ctx := context.Background() - err = hub.InitServers(ctx) - require.NoError(t, err) - - tools := hub.GetTools(ctx) - require.NoError(t, err) - - records := ConvertToolsToRecords(tools) - - // Convert records to JSON - recordsJSON, err := json.Marshal(records) - require.NoError(t, err) - - // Write JSON to file - err = os.WriteFile("./tools_records.json", recordsJSON, 0o644) - require.NoError(t, err) - - t.Logf("Tools records written to ./tools_records.json") -} - -func TestExtractDocStringInfo(t *testing.T) { - tests := []struct { - name string - docstring string - want DocStringInfo - }{ - { - name: "complete docstring with args and returns", - docstring: `Get weather alerts for a US state. - - Args: - state: Two-letter US state code (e.g. CA, NY) - - Returns: - alerts: List of active weather alerts for the specified state - error: Error message if the request fails - `, - want: DocStringInfo{ - Description: "Get weather alerts for a US state.", - Parameters: map[string]string{ - "state": "Two-letter US state code (e.g. CA, NY)", - }, - Returns: map[string]string{ - "alerts": "List of active weather alerts for the specified state", - "error": "Error message if the request fails", - }, - }, - }, - { - name: "docstring with only args", - docstring: `Do screen swipe action. - - Args: - direction: swipe direction (up, down) - `, - want: DocStringInfo{ - Description: "Do screen swipe action.", - Parameters: map[string]string{ - "direction": "swipe direction (up, down)", - }, - Returns: map[string]string{}, - }, - }, - { - name: "docstring with only description", - docstring: "Simple tool with no parameters.", - want: DocStringInfo{ - Description: "Simple tool with no parameters.", - Parameters: map[string]string{}, - Returns: map[string]string{}, - }, - }, - { - name: "docstring with multiple parameters", - docstring: `Perform complex operation. - - Args: - param1: first parameter description - param2: second parameter description - param3: third parameter description - - Returns: - result: operation result - `, - want: DocStringInfo{ - Description: "Perform complex operation.", - Parameters: map[string]string{ - "param1": "first parameter description", - "param2": "second parameter description", - "param3": "third parameter description", - }, - Returns: map[string]string{ - "result": "operation result", - }, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := extractDocStringInfo(tt.docstring) - assert.Equal(t, tt.want.Description, got.Description) - assert.Equal(t, tt.want.Parameters, got.Parameters) - assert.Equal(t, tt.want.Returns, got.Returns) - }) - } -} - -func TestConvertToolsToRecords(t *testing.T) { - tests := []struct { - name string - toolsMap map[string]MCPTools - want []MCPToolRecord - }{ - { - name: "convert weather tool", - toolsMap: map[string]MCPTools{ - "weather": { - Name: "weather", - Tools: []mcp.Tool{ - { - Name: "get_alerts", - Description: `Get weather alerts for a US state. - - Args: - state: Two-letter US state code (e.g. CA, NY) - - Returns: - alerts: List of active weather alerts for the specified state - error: Error message if the request fails - `, - }, - }, - }, - }, - want: []MCPToolRecord{ - { - ToolID: "weather_get_alerts", - ServerName: "weather", - ToolName: "get_alerts", - Description: "Get weather alerts for a US state.", - Parameters: `{"state":"Two-letter US state code (e.g. CA, NY)"}`, - Returns: `{"alerts":"List of active weather alerts for the specified state","error":"Error message if the request fails"}`, - }, - }, - }, - { - name: "convert multiple tools", - toolsMap: map[string]MCPTools{ - "ui": { - Name: "ui", - Tools: []mcp.Tool{ - { - Name: "swipe", - Description: `Do screen swipe action. - - Args: - direction: swipe direction (up, down) - `, - }, - { - Name: "tap", - Description: "Tap on screen at specified position.", - }, - }, - }, - }, - want: []MCPToolRecord{ - { - ToolID: "ui_swipe", - ServerName: "ui", - ToolName: "swipe", - Description: "Do screen swipe action.", - Parameters: `{"direction":"swipe direction (up, down)"}`, - Returns: "{}", - }, - { - ToolID: "ui_tap", - ServerName: "ui", - ToolName: "tap", - Description: "Tap on screen at specified position.", - Parameters: "{}", - Returns: "{}", - }, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := ConvertToolsToRecords(tt.toolsMap) - - // Compare each record - require.Equal(t, len(tt.want), len(got)) - for i := range tt.want { - assert.Equal(t, tt.want[i].ToolID, got[i].ToolID) - assert.Equal(t, tt.want[i].ServerName, got[i].ServerName) - assert.Equal(t, tt.want[i].ToolName, got[i].ToolName) - assert.Equal(t, tt.want[i].Description, got[i].Description) - - // Compare JSON content (ignoring whitespace differences) - var wantParams, gotParams, wantReturns, gotReturns map[string]string - require.NoError(t, json.Unmarshal([]byte(tt.want[i].Parameters), &wantParams)) - require.NoError(t, json.Unmarshal([]byte(got[i].Parameters), &gotParams)) - require.NoError(t, json.Unmarshal([]byte(tt.want[i].Returns), &wantReturns)) - require.NoError(t, json.Unmarshal([]byte(got[i].Returns), &gotReturns)) - - assert.Equal(t, wantParams, gotParams) - assert.Equal(t, wantReturns, gotReturns) - - // Verify timestamps are recent (within last 5 seconds) - now := time.Now() - assert.True(t, now.Sub(got[i].CreatedAt) < 5*time.Second, "CreatedAt should be recent") - assert.True(t, now.Sub(got[i].LastUpdatedAt) < 5*time.Second, "LastUpdatedAt should be recent") - // CreatedAt and LastUpdatedAt should be the same - assert.Equal(t, got[i].CreatedAt, got[i].LastUpdatedAt) - } - }) - } -} diff --git a/internal/mcp/testdata/demo_weather.py b/internal/mcp/testdata/demo_weather.py deleted file mode 100644 index 74a3015e..00000000 --- a/internal/mcp/testdata/demo_weather.py +++ /dev/null @@ -1,94 +0,0 @@ -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/internal/mcp/testdata/test.mcp.json b/internal/mcp/testdata/test.mcp.json deleted file mode 100644 index 26be6fb1..00000000 --- a/internal/mcp/testdata/test.mcp.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "mcpServers": { - "filesystem": { - "command": "npx", - "args": [ - "-y", - "@modelcontextprotocol/server-filesystem", - "/tmp" - ] - }, - "weather": { - "args": [ - "--directory", - "/Users/debugtalk/MyProjects/HttpRunner-dev/httprunner/internal/mcp/testdata", - "run", - "demo_weather.py" - ], - "autoApprove": [ - "get_forecast" - ], - "command": "uv", - "env": { - "ABC": "123" - } - } - } -} diff --git a/internal/version/VERSION b/internal/version/VERSION index 58f20fd4..afbae8b4 100644 --- a/internal/version/VERSION +++ b/internal/version/VERSION @@ -1 +1 @@ -v5.0.0-beta-2505161406 +v5.0.0-beta-2505161414 diff --git a/runner.go b/runner.go index 2a6f3880..a5fd1871 100644 --- a/runner.go +++ b/runner.go @@ -1,6 +1,7 @@ package hrp import ( + "context" "crypto/tls" _ "embed" "fmt" @@ -28,6 +29,7 @@ import ( "github.com/httprunner/httprunner/v5/internal/builtin" "github.com/httprunner/httprunner/v5/internal/sdk" "github.com/httprunner/httprunner/v5/internal/version" + "github.com/httprunner/httprunner/v5/pkg/mcphost" "github.com/httprunner/httprunner/v5/uixt" "github.com/httprunner/httprunner/v5/uixt/option" ) @@ -315,6 +317,22 @@ func NewCaseRunner(testcase TestCase, hrpRunner *HRPRunner) (*CaseRunner, error) Msg("plugin info loaded") } + // init MCP servers + if config.MCPConfigPath != "" { + mcpHost, err := mcphost.NewMCPHost(config.MCPConfigPath) + if err != nil { + log.Error().Err(err).Msg("init MCP hub failed") + return nil, err + } + err = mcpHost.InitServers(context.Background()) + if err != nil { + log.Error().Err(err).Msg("init MCP servers failed") + return nil, err + } + caseRunner.parser.MCPHost = mcpHost + log.Info().Str("mcpConfigPath", config.MCPConfigPath).Msg("mcp server loaded") + } + // parse testcase config parsedConfig, err := caseRunner.parseConfig() if err != nil { diff --git a/server/main.go b/server/main.go index 9a690f17..f0ce394b 100644 --- a/server/main.go +++ b/server/main.go @@ -5,7 +5,7 @@ import ( "fmt" "time" - "github.com/httprunner/httprunner/v5/internal/mcp" + "github.com/httprunner/httprunner/v5/pkg/mcphost" "github.com/httprunner/httprunner/v5/uixt" "github.com/gin-gonic/gin" @@ -22,23 +22,23 @@ func NewRouter() *Router { type Router struct { *gin.Engine - mcpHub *mcp.MCPHub + mcpHost *mcphost.MCPHost } -func (r *Router) InitMCPHub(configPath string) error { - mcpHub, err := mcp.NewMCPHub(configPath) +func (r *Router) InitMCPHost(configPath string) error { + mcpHost, err := mcphost.NewMCPHost(configPath) if err != nil { - log.Error().Err(err).Msg("init MCP hub failed") + log.Error().Err(err).Msg("init MCP host failed") return err } - err = mcpHub.InitServers(context.Background()) + err = mcpHost.InitServers(context.Background()) if err != nil { log.Error().Err(err).Msg("init MCP servers failed") return err } - r.mcpHub = mcpHub + r.mcpHost = mcpHost return nil } diff --git a/server/tool.go b/server/tool.go index 976afafc..fe71e3c9 100644 --- a/server/tool.go +++ b/server/tool.go @@ -13,8 +13,8 @@ type ToolRequest struct { } func (r *Router) invokeToolHandler(c *gin.Context) { - if r.mcpHub == nil { - RenderError(c, errors.New("mcp hub not initialized")) + if r.mcpHost == nil { + RenderError(c, errors.New("mcp host not initialized")) return } @@ -28,7 +28,7 @@ func (r *Router) invokeToolHandler(c *gin.Context) { req.Args["platform"] = c.Param("platform") req.Args["serial"] = c.Param("serial") - result, err := r.mcpHub.InvokeTool(c.Request.Context(), + result, err := r.mcpHost.InvokeTool(c.Request.Context(), req.ServerName, req.ToolName, req.Args) if err != nil { RenderError(c, err) diff --git a/server/ui_test.go b/server/ui_test.go index f39ded86..9172ad80 100644 --- a/server/ui_test.go +++ b/server/ui_test.go @@ -74,7 +74,7 @@ func TestTapHandler(t *testing.T) { func TestInvokeToolHandler(t *testing.T) { router := NewRouter() - router.InitMCPHub("../internal/mcp/testdata/test.mcp.json") + router.InitMCPHost("../internal/mcp/testdata/test.mcp.json") tests := []struct { name string