mirror of
https://github.com/httprunner/httprunner.git
synced 2026-05-07 04:52:47 +08:00
refactor: move mcp to pkg/mcphost
This commit is contained in:
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
94
internal/mcp/testdata/demo_weather.py
vendored
94
internal/mcp/testdata/demo_weather.py
vendored
@@ -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')
|
||||
27
internal/mcp/testdata/test.mcp.json
vendored
27
internal/mcp/testdata/test.mcp.json
vendored
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
v5.0.0-beta-2505161406
|
||||
v5.0.0-beta-2505161414
|
||||
|
||||
18
runner.go
18
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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user