package tools import ( "bufio" "context" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "os" "path/filepath" "strconv" "strings" "testing" "time" "clawgo/pkg/config" ) func TestMCPToolListServers(t *testing.T) { tool := NewMCPTool("/tmp/workspace", config.MCPToolsConfig{ Enabled: true, RequestTimeoutSec: 5, Servers: map[string]config.MCPServerConfig{ "demo": { Enabled: true, Transport: "stdio", Command: "demo-server", Description: "demo", }, "disabled": { Enabled: false, Transport: "stdio", Command: "nope", }, }, }) out, err := tool.Execute(context.Background(), map[string]interface{}{"action": "list_servers"}) if err != nil { t.Fatalf("list_servers returned error: %v", err) } if !strings.Contains(out, `"name": "demo"`) { t.Fatalf("expected enabled server in output, got: %s", out) } if strings.Contains(out, "disabled") { t.Fatalf("did not expect disabled server in output, got: %s", out) } } func TestMCPToolCallTool(t *testing.T) { tool := NewMCPTool(t.TempDir(), config.MCPToolsConfig{ Enabled: true, RequestTimeoutSec: 5, Servers: map[string]config.MCPServerConfig{ "helper": { Enabled: true, Transport: "stdio", Command: os.Args[0], Args: []string{"-test.run=TestMCPHelperProcess", "--"}, Env: map[string]string{ "GO_WANT_HELPER_PROCESS": "1", }, }, }, }) out, err := tool.Execute(context.Background(), map[string]interface{}{ "action": "call_tool", "server": "helper", "tool": "echo", "arguments": map[string]interface{}{"text": "hello"}, }) if err != nil { t.Fatalf("call_tool returned error: %v", err) } if !strings.Contains(out, "echo:hello") { t.Fatalf("expected echo output, got: %s", out) } } func TestMCPToolDiscoverTools(t *testing.T) { tool := NewMCPTool(t.TempDir(), config.MCPToolsConfig{ Enabled: true, RequestTimeoutSec: 5, Servers: map[string]config.MCPServerConfig{ "helper": { Enabled: true, Transport: "stdio", Command: os.Args[0], Args: []string{"-test.run=TestMCPHelperProcess", "--"}, Env: map[string]string{ "GO_WANT_HELPER_PROCESS": "1", }, }, }, }) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() discovered := tool.DiscoverTools(ctx) if len(discovered) != 1 { t.Fatalf("expected 1 discovered tool, got %d", len(discovered)) } if got := discovered[0].Name(); got != "mcp__helper__echo" { t.Fatalf("unexpected discovered tool name: %s", got) } out, err := discovered[0].Execute(ctx, map[string]interface{}{"text": "world"}) if err != nil { t.Fatalf("discovered tool execute returned error: %v", err) } if strings.TrimSpace(out) != "echo:world" { t.Fatalf("unexpected discovered tool output: %q", out) } } func TestMCPHelperProcess(t *testing.T) { if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" { return } runMCPHelper() os.Exit(0) } func runMCPHelper() { reader := bufio.NewReader(os.Stdin) writer := bufio.NewWriter(os.Stdout) for { msg, err := readHelperFrame(reader) if err != nil { return } method, _ := msg["method"].(string) id, hasID := msg["id"] switch method { case "initialize": writeHelperFrame(writer, map[string]interface{}{ "jsonrpc": "2.0", "id": id, "result": map[string]interface{}{ "protocolVersion": mcpProtocolVersion, "capabilities": map[string]interface{}{ "tools": map[string]interface{}{}, "resources": map[string]interface{}{}, "prompts": map[string]interface{}{}, }, "serverInfo": map[string]interface{}{ "name": "helper", "version": "1.0.0", }, }, }) case "notifications/initialized": continue case "tools/list": writeHelperFrame(writer, map[string]interface{}{ "jsonrpc": "2.0", "id": id, "result": map[string]interface{}{ "tools": []map[string]interface{}{ { "name": "echo", "description": "Echo the provided text", "inputSchema": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "text": map[string]interface{}{ "type": "string", }, }, "required": []string{"text"}, }, }, }, }, }) case "tools/call": params, _ := msg["params"].(map[string]interface{}) args, _ := params["arguments"].(map[string]interface{}) text, _ := args["text"].(string) writeHelperFrame(writer, map[string]interface{}{ "jsonrpc": "2.0", "id": id, "result": map[string]interface{}{ "content": []map[string]interface{}{ { "type": "text", "text": "echo:" + text, }, }, }, }) case "resources/list": writeHelperFrame(writer, map[string]interface{}{ "jsonrpc": "2.0", "id": id, "result": map[string]interface{}{ "resources": []map[string]interface{}{ {"uri": "file:///tmp/demo.txt", "name": "demo"}, }, }, }) case "resources/read": writeHelperFrame(writer, map[string]interface{}{ "jsonrpc": "2.0", "id": id, "result": map[string]interface{}{ "contents": []map[string]interface{}{ {"uri": "file:///tmp/demo.txt", "mimeType": "text/plain", "text": "demo content"}, }, }, }) case "prompts/list": writeHelperFrame(writer, map[string]interface{}{ "jsonrpc": "2.0", "id": id, "result": map[string]interface{}{ "prompts": []map[string]interface{}{ {"name": "greeter", "description": "Greets"}, }, }, }) case "prompts/get": params, _ := msg["params"].(map[string]interface{}) args, _ := params["arguments"].(map[string]interface{}) name, _ := args["name"].(string) writeHelperFrame(writer, map[string]interface{}{ "jsonrpc": "2.0", "id": id, "result": map[string]interface{}{ "description": "Greets", "messages": []map[string]interface{}{ { "role": "user", "content": map[string]interface{}{ "type": "text", "text": "hello " + name, }, }, }, }, }) default: if hasID { writeHelperFrame(writer, map[string]interface{}{ "jsonrpc": "2.0", "id": id, "error": map[string]interface{}{ "code": -32601, "message": "method not found", }, }) } } } } func readHelperFrame(r *bufio.Reader) (map[string]interface{}, error) { length := 0 for { line, err := r.ReadString('\n') if err != nil { return nil, err } line = strings.TrimRight(line, "\r\n") if line == "" { break } parts := strings.SplitN(line, ":", 2) if len(parts) == 2 && strings.EqualFold(strings.TrimSpace(parts[0]), "Content-Length") { length, _ = strconv.Atoi(strings.TrimSpace(parts[1])) } } body := make([]byte, length) if _, err := io.ReadFull(r, body); err != nil { return nil, err } var msg map[string]interface{} if err := json.Unmarshal(body, &msg); err != nil { return nil, err } return msg, nil } func writeHelperFrame(w *bufio.Writer, payload map[string]interface{}) { data, _ := json.Marshal(payload) _, _ = fmt.Fprintf(w, "Content-Length: %d\r\n\r\n%s", len(data), data) _ = w.Flush() } func TestValidateMCPTools(t *testing.T) { cfg := config.DefaultConfig() cfg.Tools.MCP.Enabled = true cfg.Tools.MCP.RequestTimeoutSec = 0 cfg.Tools.MCP.Servers = map[string]config.MCPServerConfig{ "bad": { Enabled: true, Transport: "ws", Command: "", WorkingDir: "/outside-workspace", }, } errs := config.Validate(cfg) if len(errs) == 0 { t.Fatal("expected validation errors") } got := make([]string, 0, len(errs)) for _, err := range errs { got = append(got, err.Error()) } joined := strings.Join(got, "\n") for _, want := range []string{ "tools.mcp.request_timeout_sec must be > 0 when tools.mcp.enabled=true", "tools.mcp.servers.bad.transport must be one of: stdio, http, streamable_http, sse", } { if !strings.Contains(joined, want) { t.Fatalf("expected validation error %q in:\n%s", want, joined) } } } func TestValidateMCPToolsFullPermissionRequiresAbsolutePath(t *testing.T) { cfg := config.DefaultConfig() cfg.Tools.MCP.Enabled = true cfg.Tools.MCP.Servers = map[string]config.MCPServerConfig{ "full": { Enabled: true, Transport: "stdio", Command: "demo", Permission: "full", WorkingDir: "relative", }, } errs := config.Validate(cfg) if len(errs) == 0 { t.Fatal("expected validation errors") } joined := "" for _, err := range errs { joined += err.Error() + "\n" } if !strings.Contains(joined, "tools.mcp.servers.full.working_dir must be an absolute path when permission=full") { t.Fatalf("unexpected validation errors:\n%s", joined) } } func TestMCPToolListTools(t *testing.T) { tool := NewMCPTool(t.TempDir(), config.MCPToolsConfig{ Enabled: true, RequestTimeoutSec: 5, Servers: map[string]config.MCPServerConfig{ "helper": { Enabled: true, Transport: "stdio", Command: os.Args[0], Args: []string{"-test.run=TestMCPHelperProcess", "--"}, Env: map[string]string{ "GO_WANT_HELPER_PROCESS": "1", }, }, }, }) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() out, err := tool.Execute(ctx, map[string]interface{}{ "action": "list_tools", "server": "helper", }) if err != nil { t.Fatalf("list_tools returned error: %v", err) } if !strings.Contains(out, `"name": "echo"`) { t.Fatalf("expected tool listing, got: %s", out) } } func TestMCPToolHTTPTransport(t *testing.T) { initializedNotified := false server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() var req map[string]interface{} if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } method, _ := req["method"].(string) id := req["id"] var resp map[string]interface{} switch method { case "initialize": resp = map[string]interface{}{ "jsonrpc": "2.0", "id": id, "result": map[string]interface{}{ "protocolVersion": mcpProtocolVersion, }, } case "notifications/initialized": if _, hasID := req["id"]; hasID { http.Error(w, "notification must not include id", http.StatusBadRequest) return } initializedNotified = true w.WriteHeader(http.StatusAccepted) return case "tools/list": resp = map[string]interface{}{ "jsonrpc": "2.0", "id": id, "result": map[string]interface{}{ "tools": []map[string]interface{}{ { "name": "echo", "description": "Echo text", "inputSchema": map[string]interface{}{"type": "object"}, }, }, }, } case "tools/call": resp = map[string]interface{}{ "jsonrpc": "2.0", "id": id, "result": map[string]interface{}{ "content": []map[string]interface{}{ {"type": "text", "text": "echo:http"}, }, }, } default: resp = map[string]interface{}{ "jsonrpc": "2.0", "id": id, "error": map[string]interface{}{"code": -32601, "message": "unsupported"}, } } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(resp) })) defer server.Close() tool := NewMCPTool(t.TempDir(), config.MCPToolsConfig{ Enabled: true, RequestTimeoutSec: 5, Servers: map[string]config.MCPServerConfig{ "httpdemo": { Enabled: true, Transport: "http", URL: server.URL, }, }, }) out, err := tool.Execute(context.Background(), map[string]interface{}{ "action": "list_tools", "server": "httpdemo", }) if err != nil { t.Fatalf("http list_tools returned error: %v", err) } if !strings.Contains(out, `"name": "echo"`) { t.Fatalf("expected http tool listing, got: %s", out) } if !initializedNotified { t.Fatal("expected initialized notification to be sent") } } func TestMCPToolSSETransport(t *testing.T) { messageCh := make(chan string, 8) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodGet && r.URL.Path == "/sse": w.Header().Set("Content-Type", "text/event-stream") flusher, ok := w.(http.Flusher) if !ok { http.Error(w, "flush unsupported", http.StatusInternalServerError) return } _, _ = fmt.Fprintf(w, "event: endpoint\n") _, _ = fmt.Fprintf(w, "data: /messages\n\n") flusher.Flush() notify := r.Context().Done() for { select { case payload := <-messageCh: _, _ = fmt.Fprintf(w, "event: message\n") _, _ = fmt.Fprintf(w, "data: %s\n\n", payload) flusher.Flush() case <-notify: return } } case r.Method == http.MethodPost && r.URL.Path == "/messages": defer r.Body.Close() var req map[string]interface{} if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } method, _ := req["method"].(string) id := req["id"] switch method { case "initialize": payload, _ := json.Marshal(map[string]interface{}{ "jsonrpc": "2.0", "id": id, "result": map[string]interface{}{ "protocolVersion": mcpProtocolVersion, }, }) messageCh <- string(payload) case "tools/list": payload, _ := json.Marshal(map[string]interface{}{ "jsonrpc": "2.0", "id": id, "result": map[string]interface{}{ "tools": []map[string]interface{}{ {"name": "echo", "description": "Echo text", "inputSchema": map[string]interface{}{"type": "object"}}, }, }, }) messageCh <- string(payload) case "tools/call": payload, _ := json.Marshal(map[string]interface{}{ "jsonrpc": "2.0", "id": id, "result": map[string]interface{}{ "content": []map[string]interface{}{{"type": "text", "text": "echo:sse"}}, }, }) messageCh <- string(payload) } w.WriteHeader(http.StatusAccepted) default: http.NotFound(w, r) } })) defer server.Close() tool := NewMCPTool(t.TempDir(), config.MCPToolsConfig{ Enabled: true, RequestTimeoutSec: 5, Servers: map[string]config.MCPServerConfig{ "ssedemo": { Enabled: true, Transport: "sse", URL: server.URL + "/sse", }, }, }) out, err := tool.Execute(context.Background(), map[string]interface{}{ "action": "list_tools", "server": "ssedemo", }) if err != nil { t.Fatalf("sse list_tools returned error: %v", err) } if !strings.Contains(out, `"name": "echo"`) { t.Fatalf("expected sse tool listing, got: %s", out) } } func TestBuildMCPDynamicToolName(t *testing.T) { got := buildMCPDynamicToolName("Context7 Server", "resolve-library.id") if got != "mcp__context7_server__resolve_library_id" { t.Fatalf("unexpected tool name: %s", got) } } func TestResolveMCPWorkingDirWorkspaceScoped(t *testing.T) { workspace := t.TempDir() dir, err := resolveMCPWorkingDir(workspace, config.MCPServerConfig{WorkingDir: "tools/context7"}) if err != nil { t.Fatalf("resolveMCPWorkingDir returned error: %v", err) } want := filepath.Join(workspace, "tools", "context7") if dir != want { t.Fatalf("unexpected working dir: got %q want %q", dir, want) } } func TestResolveMCPWorkingDirRejectsOutsideWorkspaceWithoutFullPermission(t *testing.T) { workspace := t.TempDir() _, err := resolveMCPWorkingDir(workspace, config.MCPServerConfig{WorkingDir: "/"}) if err == nil { t.Fatal("expected outside-workspace path to be rejected") } } func TestResolveMCPWorkingDirAllowsAbsolutePathWithFullPermission(t *testing.T) { dir, err := resolveMCPWorkingDir(t.TempDir(), config.MCPServerConfig{Permission: "full", WorkingDir: "/"}) if err != nil { t.Fatalf("resolveMCPWorkingDir returned error: %v", err) } if dir != "/" { t.Fatalf("unexpected working dir: %q", dir) } }