package tools import ( "bufio" "bytes" "context" "encoding/json" "fmt" "hash/fnv" "io" "net/http" "net/url" "os" "os/exec" "path/filepath" "regexp" "sort" "strconv" "strings" "sync" "sync/atomic" "time" "github.com/YspCoder/clawgo/pkg/config" ) const mcpProtocolVersion = "2025-06-18" type MCPTool struct { workspace string cfg config.MCPToolsConfig } type MCPRemoteTool struct { bridge *MCPTool serverName string remoteName string localName string description string parameters map[string]interface{} } type mcpRPCClient interface { listAll(ctx context.Context, method, field string) (map[string]interface{}, error) request(ctx context.Context, method string, params map[string]interface{}) (map[string]interface{}, error) Close() error } func NewMCPTool(workspace string, cfg config.MCPToolsConfig) *MCPTool { if cfg.RequestTimeoutSec <= 0 { cfg.RequestTimeoutSec = 20 } if cfg.Servers == nil { cfg.Servers = map[string]config.MCPServerConfig{} } return &MCPTool{workspace: workspace, cfg: cfg} } func (t *MCPTool) Name() string { return "mcp" } func (t *MCPTool) Description() string { return "Call configured MCP servers over stdio or HTTP transports. Supports listing servers, tools, resources, prompts, and invoking remote MCP tools." } func (t *MCPTool) Parameters() map[string]interface{} { return map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "action": map[string]interface{}{ "type": "string", "description": "Operation to perform", "enum": []string{"list_servers", "list_tools", "call_tool", "list_resources", "read_resource", "list_prompts", "get_prompt"}, }, "server": map[string]interface{}{ "type": "string", "description": "Configured MCP server name", }, "tool": map[string]interface{}{ "type": "string", "description": "MCP tool name for action=call_tool", }, "arguments": map[string]interface{}{ "type": "object", "description": "Arguments for call_tool or get_prompt", }, "uri": map[string]interface{}{ "type": "string", "description": "Resource URI for action=read_resource", }, "prompt": map[string]interface{}{ "type": "string", "description": "Prompt name for action=get_prompt", }, }, "required": []string{"action"}, } } func (t *MCPTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) { action := strings.TrimSpace(mcpStringArg(args, "action")) if action == "" { return "", fmt.Errorf("action is required") } if action == "list_servers" { return t.listServers(), nil } serverName := strings.TrimSpace(mcpStringArg(args, "server")) if serverName == "" { return "", fmt.Errorf("server is required for action %q", action) } serverCfg, ok := t.cfg.Servers[serverName] if !ok || !serverCfg.Enabled { return "", fmt.Errorf("mcp server %q is not configured or not enabled", serverName) } timeout := time.Duration(t.cfg.RequestTimeoutSec) * time.Second if deadline, ok := ctx.Deadline(); ok { remaining := time.Until(deadline) if remaining > 0 && remaining < timeout { timeout = remaining } } callCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() client, err := newMCPClient(callCtx, t.workspace, serverName, serverCfg) if err != nil { return "", err } defer client.Close() switch action { case "list_tools": out, err := client.listAll(callCtx, "tools/list", "tools") if err != nil { return "", err } return prettyJSON(out) case "call_tool": toolName := strings.TrimSpace(mcpStringArg(args, "tool")) if toolName == "" { return "", fmt.Errorf("tool is required for action=call_tool") } params := map[string]interface{}{ "name": toolName, "arguments": mcpObjectArg(args, "arguments"), } out, err := client.request(callCtx, "tools/call", params) if err != nil { return "", err } return prettyJSON(out) case "list_resources": out, err := client.listAll(callCtx, "resources/list", "resources") if err != nil { return "", err } return prettyJSON(out) case "read_resource": resourceURI := strings.TrimSpace(mcpStringArg(args, "uri")) if resourceURI == "" { return "", fmt.Errorf("uri is required for action=read_resource") } out, err := client.request(callCtx, "resources/read", map[string]interface{}{"uri": resourceURI}) if err != nil { return "", err } return prettyJSON(out) case "list_prompts": out, err := client.listAll(callCtx, "prompts/list", "prompts") if err != nil { return "", err } return prettyJSON(out) case "get_prompt": promptName := strings.TrimSpace(mcpStringArg(args, "prompt")) if promptName == "" { return "", fmt.Errorf("prompt is required for action=get_prompt") } out, err := client.request(callCtx, "prompts/get", map[string]interface{}{ "name": promptName, "arguments": mcpObjectArg(args, "arguments"), }) if err != nil { return "", err } return prettyJSON(out) default: return "", fmt.Errorf("unsupported action %q", action) } } func (t *MCPTool) DiscoverTools(ctx context.Context) []Tool { if t == nil || !t.cfg.Enabled { return nil } names := make([]string, 0, len(t.cfg.Servers)) for name, server := range t.cfg.Servers { if server.Enabled { names = append(names, name) } } sort.Strings(names) tools := make([]Tool, 0) seen := map[string]int{} for _, serverName := range names { serverCfg := t.cfg.Servers[serverName] client, err := newMCPClient(ctx, t.workspace, serverName, serverCfg) if err != nil { continue } result, err := client.listAll(ctx, "tools/list", "tools") _ = client.Close() if err != nil { continue } items, _ := result["tools"].([]interface{}) for _, item := range items { toolMap, _ := item.(map[string]interface{}) remoteName := strings.TrimSpace(mcpStringArg(toolMap, "name")) if remoteName == "" { continue } localName := buildMCPDynamicToolName(serverName, remoteName) if count := seen[localName]; count > 0 { localName = fmt.Sprintf("%s_%d", localName, count+1) } seen[localName]++ tools = append(tools, &MCPRemoteTool{ bridge: t, serverName: serverName, remoteName: remoteName, localName: localName, description: buildMCPDynamicToolDescription(serverName, toolMap), parameters: normalizeMCPSchema(toolMap["inputSchema"]), }) } } return tools } func (t *MCPTool) callServerTool(ctx context.Context, serverName, remoteToolName string, arguments map[string]interface{}) (string, error) { serverCfg, ok := t.cfg.Servers[serverName] if !ok || !serverCfg.Enabled { return "", fmt.Errorf("mcp server %q is not configured or not enabled", serverName) } timeout := time.Duration(t.cfg.RequestTimeoutSec) * time.Second if deadline, ok := ctx.Deadline(); ok { remaining := time.Until(deadline) if remaining > 0 && remaining < timeout { timeout = remaining } } callCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() client, err := newMCPClient(callCtx, t.workspace, serverName, serverCfg) if err != nil { return "", err } defer client.Close() out, err := client.request(callCtx, "tools/call", map[string]interface{}{ "name": remoteToolName, "arguments": arguments, }) if err != nil { return "", err } return renderMCPToolCallResult(out) } func (t *MCPTool) listServers() string { type item struct { Name string `json:"name"` Transport string `json:"transport"` URL string `json:"url,omitempty"` Permission string `json:"permission,omitempty"` Command string `json:"command"` WorkingDir string `json:"working_dir,omitempty"` Description string `json:"description,omitempty"` } names := make([]string, 0, len(t.cfg.Servers)) for name, server := range t.cfg.Servers { if server.Enabled { names = append(names, name) } } sort.Strings(names) items := make([]item, 0, len(names)) for _, name := range names { server := t.cfg.Servers[name] transport := strings.TrimSpace(server.Transport) if transport == "" { transport = "stdio" } permission := strings.TrimSpace(server.Permission) if permission == "" { permission = "workspace" } items = append(items, item{ Name: name, Transport: transport, URL: strings.TrimSpace(server.URL), Permission: permission, Command: server.Command, WorkingDir: server.WorkingDir, Description: server.Description, }) } out, _ := json.MarshalIndent(map[string]interface{}{"servers": items}, "", " ") return string(out) } func (t *MCPRemoteTool) Name() string { return t.localName } func (t *MCPRemoteTool) Description() string { return t.description } func (t *MCPRemoteTool) Parameters() map[string]interface{} { return t.parameters } func (t *MCPRemoteTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) { return t.bridge.callServerTool(ctx, t.serverName, t.remoteName, args) } func (t *MCPRemoteTool) CatalogEntry() map[string]interface{} { return map[string]interface{}{ "source": "mcp", "mcp": map[string]interface{}{ "server": t.serverName, "remote_tool": t.remoteName, }, } } type mcpClient struct { workspace string workingDir string serverName string cmd *exec.Cmd stdin io.WriteCloser reader *bufio.Reader stderr bytes.Buffer writeMu sync.Mutex waiters sync.Map nextID atomic.Int64 } type mcpInbound struct { JSONRPC string `json:"jsonrpc"` ID interface{} `json:"id,omitempty"` Method string `json:"method,omitempty"` Params map[string]interface{} `json:"params,omitempty"` Result json.RawMessage `json:"result,omitempty"` Error *mcpResponseError `json:"error,omitempty"` } type mcpResponseError struct { Code int `json:"code"` Message string `json:"message"` } type mcpResponseWaiter struct { ch chan mcpInbound } func newMCPClient(ctx context.Context, workspace, serverName string, cfg config.MCPServerConfig) (mcpRPCClient, error) { transport := strings.ToLower(strings.TrimSpace(cfg.Transport)) if transport == "" { transport = "stdio" } switch transport { case "stdio": return newMCPStdioClient(ctx, workspace, serverName, cfg) case "sse": return newMCPSSEClient(ctx, workspace, serverName, cfg) case "http", "streamable_http": return newMCPHTTPClient(ctx, serverName, cfg) default: return nil, fmt.Errorf("unsupported mcp transport %q", transport) } } func newMCPStdioClient(ctx context.Context, workspace, serverName string, cfg config.MCPServerConfig) (*mcpClient, error) { command := strings.TrimSpace(cfg.Command) if command == "" { return nil, fmt.Errorf("mcp server %q command is empty", serverName) } workingDir, err := resolveMCPWorkingDir(workspace, cfg) if err != nil { return nil, err } cmd := exec.CommandContext(ctx, command, cfg.Args...) cmd.Env = buildMCPEnv(cfg.Env) cmd.Dir = workingDir stdin, err := cmd.StdinPipe() if err != nil { return nil, fmt.Errorf("open stdin for mcp server %q: %w", serverName, err) } stdout, err := cmd.StdoutPipe() if err != nil { return nil, fmt.Errorf("open stdout for mcp server %q: %w", serverName, err) } client := &mcpClient{ workspace: workspace, workingDir: workingDir, serverName: serverName, cmd: cmd, stdin: stdin, reader: bufio.NewReader(stdout), } cmd.Stderr = &client.stderr if err := cmd.Start(); err != nil { return nil, fmt.Errorf("start mcp server %q: %w", serverName, err) } go client.readLoop() if err := client.initialize(ctx); err != nil { client.Close() return nil, err } return client, nil } type mcpHTTPClient struct { serverName string baseURL string client *http.Client nextID atomic.Int64 } type mcpSSEClient struct { workspace string serverName string baseURL string endpointURL string client *http.Client cancel context.CancelFunc respBody io.ReadCloser writeMu sync.Mutex waiters sync.Map nextID atomic.Int64 endpointOnce sync.Once endpointCh chan string errCh chan error } func newMCPHTTPClient(ctx context.Context, serverName string, cfg config.MCPServerConfig) (*mcpHTTPClient, error) { baseURL := strings.TrimSpace(cfg.URL) if baseURL == "" { return nil, fmt.Errorf("mcp server %q url is empty", serverName) } client := &mcpHTTPClient{ serverName: serverName, baseURL: baseURL, client: &http.Client{Timeout: 30 * time.Second}, } if err := client.initialize(ctx); err != nil { return nil, err } return client, nil } func newMCPSSEClient(ctx context.Context, workspace, serverName string, cfg config.MCPServerConfig) (*mcpSSEClient, error) { baseURL := strings.TrimSpace(cfg.URL) if baseURL == "" { return nil, fmt.Errorf("mcp server %q url is empty", serverName) } streamCtx, cancel := context.WithCancel(context.Background()) client := &mcpSSEClient{ workspace: workspace, serverName: serverName, baseURL: baseURL, client: &http.Client{Timeout: 0}, cancel: cancel, endpointCh: make(chan string, 1), errCh: make(chan error, 1), } req, err := http.NewRequestWithContext(streamCtx, http.MethodGet, baseURL, nil) if err != nil { cancel() return nil, err } req.Header.Set("Accept", "text/event-stream") resp, err := client.client.Do(req) if err != nil { cancel() return nil, fmt.Errorf("connect sse for mcp server %q: %w", serverName, err) } if resp.StatusCode < 200 || resp.StatusCode >= 300 { data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) resp.Body.Close() cancel() return nil, fmt.Errorf("connect sse for mcp server %q failed: http %d %s", serverName, resp.StatusCode, strings.TrimSpace(string(data))) } client.respBody = resp.Body go client.readLoop() select { case endpoint := <-client.endpointCh: client.endpointURL = endpoint case err := <-client.errCh: client.Close() return nil, err case <-ctx.Done(): client.Close() return nil, ctx.Err() } if err := client.initialize(ctx); err != nil { client.Close() return nil, err } return client, nil } func (c *mcpSSEClient) Close() error { if c == nil { return nil } if c.cancel != nil { c.cancel() } if c.respBody != nil { _ = c.respBody.Close() } return nil } func (c *mcpSSEClient) initialize(ctx context.Context) error { result, err := c.request(ctx, "initialize", map[string]interface{}{ "protocolVersion": mcpProtocolVersion, "capabilities": map[string]interface{}{ "roots": map[string]interface{}{ "listChanged": false, }, }, "clientInfo": map[string]interface{}{ "name": "clawgo", "version": "dev", }, }) if err != nil { return err } if _, ok := result["protocolVersion"]; !ok { return fmt.Errorf("mcp server %q initialize missing protocolVersion", c.serverName) } return c.notify("notifications/initialized", map[string]interface{}{}) } func (c *mcpSSEClient) listAll(ctx context.Context, method, field string) (map[string]interface{}, error) { items := make([]interface{}, 0) cursor := "" for { params := map[string]interface{}{} if strings.TrimSpace(cursor) != "" { params["cursor"] = cursor } result, err := c.request(ctx, method, params) if err != nil { return nil, err } batch, _ := result[field].([]interface{}) items = append(items, batch...) next, _ := result["nextCursor"].(string) if strings.TrimSpace(next) == "" { return map[string]interface{}{field: items}, nil } cursor = next } } func (c *mcpSSEClient) request(ctx context.Context, method string, params map[string]interface{}) (map[string]interface{}, error) { id := strconv.FormatInt(c.nextID.Add(1), 10) waiter := &mcpResponseWaiter{ch: make(chan mcpInbound, 1)} c.waiters.Store(id, waiter) defer c.waiters.Delete(id) if err := c.postMessage(map[string]interface{}{ "jsonrpc": "2.0", "id": id, "method": method, "params": params, }); err != nil { return nil, err } select { case resp := <-waiter.ch: if resp.Error != nil { return nil, fmt.Errorf("mcp %s %s failed: %s", c.serverName, method, resp.Error.Message) } var out map[string]interface{} if len(resp.Result) == 0 { return map[string]interface{}{}, nil } if err := json.Unmarshal(resp.Result, &out); err != nil { return nil, fmt.Errorf("decode mcp %s %s result: %w", c.serverName, method, err) } return out, nil case <-ctx.Done(): return nil, ctx.Err() } } func (c *mcpSSEClient) notify(method string, params map[string]interface{}) error { return c.postMessage(map[string]interface{}{ "jsonrpc": "2.0", "method": method, "params": params, }) } func (c *mcpSSEClient) postMessage(payload map[string]interface{}) error { data, err := json.Marshal(payload) if err != nil { return err } c.writeMu.Lock() defer c.writeMu.Unlock() req, err := http.NewRequest(http.MethodPost, c.endpointURL, bytes.NewReader(data)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") resp, err := c.client.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) return fmt.Errorf("mcp %s post failed: http %d %s", c.serverName, resp.StatusCode, strings.TrimSpace(string(body))) } return nil } func (c *mcpSSEClient) readLoop() { reader := bufio.NewReader(c.respBody) var eventName string var dataLines []string emit := func() bool { if len(dataLines) == 0 && strings.TrimSpace(eventName) == "" { eventName = "" dataLines = nil return true } event := strings.TrimSpace(eventName) if event == "" { event = "message" } data := strings.Join(dataLines, "\n") if err := c.handleSSEEvent(event, data); err != nil { c.signalErr(err) return false } eventName = "" dataLines = nil return true } for { line, err := reader.ReadString('\n') if err != nil { if err != io.EOF { c.signalErr(err) } return } line = strings.TrimRight(line, "\r\n") if line == "" { if !emit() { return } continue } if strings.HasPrefix(line, ":") { continue } if strings.HasPrefix(line, "event:") { eventName = strings.TrimSpace(strings.TrimPrefix(line, "event:")) continue } if strings.HasPrefix(line, "data:") { dataLines = append(dataLines, strings.TrimSpace(strings.TrimPrefix(line, "data:"))) } } } func (c *mcpSSEClient) handleSSEEvent(eventName, data string) error { switch eventName { case "endpoint": endpoint := strings.TrimSpace(data) if endpoint == "" { return fmt.Errorf("mcp server %q sent empty endpoint event", c.serverName) } resolved, err := resolveRelativeURL(c.baseURL, endpoint) if err != nil { return err } c.endpointOnce.Do(func() { c.endpointURL = resolved c.endpointCh <- resolved }) return nil case "message": var msg mcpInbound if err := json.Unmarshal([]byte(data), &msg); err != nil { return err } if msg.Method != "" && msg.ID != nil { return c.handleServerRequest(msg) } if msg.Method != "" { return nil } if key, ok := normalizeMCPID(msg.ID); ok { if raw, ok := c.waiters.Load(key); ok { raw.(*mcpResponseWaiter).ch <- msg } } return nil default: return nil } } func (c *mcpSSEClient) handleServerRequest(msg mcpInbound) error { method := strings.TrimSpace(msg.Method) switch method { case "roots/list": root := resolveMCPDefaultRoot(c.workspace) return c.postMessage(map[string]interface{}{ "jsonrpc": "2.0", "id": msg.ID, "result": map[string]interface{}{ "roots": []map[string]interface{}{ {"uri": fileURI(root), "name": filepath.Base(root)}, }, }, }) case "ping": return c.postMessage(map[string]interface{}{ "jsonrpc": "2.0", "id": msg.ID, "result": map[string]interface{}{}, }) default: return c.postMessage(map[string]interface{}{ "jsonrpc": "2.0", "id": msg.ID, "error": map[string]interface{}{ "code": -32601, "message": "method not supported by clawgo mcp client", }, }) } } func (c *mcpSSEClient) signalErr(err error) { select { case c.errCh <- err: default: } c.waiters.Range(func(_, value interface{}) bool { value.(*mcpResponseWaiter).ch <- mcpInbound{ Error: &mcpResponseError{Message: err.Error()}, } return true }) } func resolveRelativeURL(baseURL, ref string) (string, error) { base, err := url.Parse(baseURL) if err != nil { return "", err } target, err := url.Parse(ref) if err != nil { return "", err } return base.ResolveReference(target).String(), nil } func (c *mcpHTTPClient) Close() error { return nil } func (c *mcpHTTPClient) initialize(ctx context.Context) error { result, err := c.request(ctx, "initialize", map[string]interface{}{ "protocolVersion": mcpProtocolVersion, "capabilities": map[string]interface{}{}, "clientInfo": map[string]interface{}{ "name": "clawgo", "version": "dev", }, }) if err != nil { return err } if _, ok := result["protocolVersion"]; !ok { return fmt.Errorf("mcp server %q initialize missing protocolVersion", c.serverName) } return c.notify(ctx, "notifications/initialized", map[string]interface{}{}) } func (c *mcpHTTPClient) listAll(ctx context.Context, method, field string) (map[string]interface{}, error) { items := make([]interface{}, 0) cursor := "" for { params := map[string]interface{}{} if strings.TrimSpace(cursor) != "" { params["cursor"] = cursor } result, err := c.request(ctx, method, params) if err != nil { return nil, err } batch, _ := result[field].([]interface{}) items = append(items, batch...) next, _ := result["nextCursor"].(string) if strings.TrimSpace(next) == "" { return map[string]interface{}{field: items}, nil } cursor = next } } func (c *mcpHTTPClient) request(ctx context.Context, method string, params map[string]interface{}) (map[string]interface{}, error) { id := strconv.FormatInt(c.nextID.Add(1), 10) payload := map[string]interface{}{ "jsonrpc": "2.0", "id": id, "method": method, "params": params, } body, err := json.Marshal(payload) if err != nil { return nil, err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL, bytes.NewReader(body)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") resp, err := c.client.Do(req) if err != nil { return nil, fmt.Errorf("mcp %s %s failed: %w", c.serverName, method, err) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) return nil, fmt.Errorf("mcp %s %s failed: http %d %s", c.serverName, method, resp.StatusCode, strings.TrimSpace(string(data))) } var msg mcpInbound if err := json.NewDecoder(resp.Body).Decode(&msg); err != nil { return nil, fmt.Errorf("decode mcp %s %s result: %w", c.serverName, method, err) } if msg.Error != nil { return nil, fmt.Errorf("mcp %s %s failed: %s", c.serverName, method, msg.Error.Message) } if len(msg.Result) == 0 { return map[string]interface{}{}, nil } var out map[string]interface{} if err := json.Unmarshal(msg.Result, &out); err != nil { return nil, fmt.Errorf("decode mcp %s %s result: %w", c.serverName, method, err) } return out, nil } func (c *mcpHTTPClient) notify(ctx context.Context, method string, params map[string]interface{}) error { payload := map[string]interface{}{ "jsonrpc": "2.0", "method": method, "params": params, } body, err := json.Marshal(payload) if err != nil { return err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL, bytes.NewReader(body)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") resp, err := c.client.Do(req) if err != nil { return fmt.Errorf("mcp %s %s failed: %w", c.serverName, method, err) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) return fmt.Errorf("mcp %s %s failed: http %d %s", c.serverName, method, resp.StatusCode, strings.TrimSpace(string(data))) } return nil } func (c *mcpClient) Close() error { if c == nil || c.cmd == nil { return nil } _ = c.stdin.Close() done := make(chan error, 1) go func() { done <- c.cmd.Wait() }() select { case err := <-done: return err case <-time.After(500 * time.Millisecond): if c.cmd.Process != nil { _ = c.cmd.Process.Kill() } <-done return nil } } func (c *mcpClient) initialize(ctx context.Context) error { result, err := c.request(ctx, "initialize", map[string]interface{}{ "protocolVersion": mcpProtocolVersion, "capabilities": map[string]interface{}{ "roots": map[string]interface{}{ "listChanged": false, }, }, "clientInfo": map[string]interface{}{ "name": "clawgo", "version": "dev", }, }) if err != nil { return err } if _, ok := result["protocolVersion"]; !ok { return fmt.Errorf("mcp server %q initialize missing protocolVersion", c.serverName) } return c.notify("notifications/initialized", map[string]interface{}{}) } func (c *mcpClient) listAll(ctx context.Context, method, field string) (map[string]interface{}, error) { items := make([]interface{}, 0) cursor := "" for { params := map[string]interface{}{} if strings.TrimSpace(cursor) != "" { params["cursor"] = cursor } result, err := c.request(ctx, method, params) if err != nil { return nil, err } batch, _ := result[field].([]interface{}) items = append(items, batch...) next, _ := result["nextCursor"].(string) if strings.TrimSpace(next) == "" { return map[string]interface{}{field: items}, nil } cursor = next } } func (c *mcpClient) request(ctx context.Context, method string, params map[string]interface{}) (map[string]interface{}, error) { id := strconv.FormatInt(c.nextID.Add(1), 10) waiter := &mcpResponseWaiter{ch: make(chan mcpInbound, 1)} c.waiters.Store(id, waiter) defer c.waiters.Delete(id) msg := map[string]interface{}{ "jsonrpc": "2.0", "id": id, "method": method, "params": params, } if err := c.writeMessage(msg); err != nil { return nil, err } select { case resp := <-waiter.ch: if resp.Error != nil { return nil, fmt.Errorf("mcp %s %s failed: %s", c.serverName, method, resp.Error.Message) } var out map[string]interface{} if len(resp.Result) == 0 { return map[string]interface{}{}, nil } if err := json.Unmarshal(resp.Result, &out); err != nil { return nil, fmt.Errorf("decode mcp %s %s result: %w", c.serverName, method, err) } return out, nil case <-ctx.Done(): return nil, ctx.Err() } } func (c *mcpClient) notify(method string, params map[string]interface{}) error { return c.writeMessage(map[string]interface{}{ "jsonrpc": "2.0", "method": method, "params": params, }) } func (c *mcpClient) writeMessage(payload map[string]interface{}) error { data, err := json.Marshal(payload) if err != nil { return err } frame := fmt.Sprintf("Content-Length: %d\r\n\r\n%s", len(data), data) c.writeMu.Lock() defer c.writeMu.Unlock() _, err = io.WriteString(c.stdin, frame) return err } func (c *mcpClient) readLoop() { for { msg, err := c.readMessage() if err != nil { c.failAll(err) return } if msg.Method != "" && msg.ID != nil { _ = c.handleServerRequest(msg) continue } if msg.Method != "" { continue } if key, ok := normalizeMCPID(msg.ID); ok { if raw, ok := c.waiters.Load(key); ok { raw.(*mcpResponseWaiter).ch <- msg } } } } func (c *mcpClient) handleServerRequest(msg mcpInbound) error { method := strings.TrimSpace(msg.Method) switch method { case "roots/list": rootDir := c.workingDir if strings.TrimSpace(rootDir) == "" { rootDir = resolveMCPDefaultRoot(c.workspace) } return c.reply(msg.ID, map[string]interface{}{ "roots": []map[string]interface{}{ { "uri": fileURI(rootDir), "name": filepath.Base(rootDir), }, }, }) case "ping": return c.reply(msg.ID, map[string]interface{}{}) default: return c.replyError(msg.ID, -32601, "method not supported by clawgo mcp client") } } func (c *mcpClient) reply(id interface{}, result map[string]interface{}) error { return c.writeMessage(map[string]interface{}{ "jsonrpc": "2.0", "id": id, "result": result, }) } func (c *mcpClient) replyError(id interface{}, code int, message string) error { return c.writeMessage(map[string]interface{}{ "jsonrpc": "2.0", "id": id, "error": map[string]interface{}{ "code": code, "message": message, }, }) } func (c *mcpClient) failAll(err error) { message := err.Error() if stderr := strings.TrimSpace(c.stderr.String()); stderr != "" { message += ": " + stderr } c.waiters.Range(func(_, value interface{}) bool { value.(*mcpResponseWaiter).ch <- mcpInbound{ Error: &mcpResponseError{Message: message}, } return true }) } func (c *mcpClient) readMessage() (mcpInbound, error) { length := 0 for { line, err := c.reader.ReadString('\n') if err != nil { return mcpInbound{}, err } line = strings.TrimRight(line, "\r\n") if line == "" { break } parts := strings.SplitN(line, ":", 2) if len(parts) != 2 { continue } if strings.EqualFold(strings.TrimSpace(parts[0]), "Content-Length") { length, _ = strconv.Atoi(strings.TrimSpace(parts[1])) } } if length <= 0 { return mcpInbound{}, fmt.Errorf("invalid mcp content length") } body := make([]byte, length) if _, err := io.ReadFull(c.reader, body); err != nil { return mcpInbound{}, err } var msg mcpInbound if err := json.Unmarshal(body, &msg); err != nil { return mcpInbound{}, err } return msg, nil } func buildMCPEnv(overrides map[string]string) []string { env := os.Environ() path := os.Getenv("PATH") fallback := "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/opt/homebrew/bin:/opt/homebrew/sbin" if strings.TrimSpace(path) == "" { env = append(env, "PATH="+fallback) } else { env = append(env, "PATH="+path+":"+fallback) } for key, value := range overrides { env = append(env, key+"="+value) } return env } func resolveMCPWorkingDir(workspace string, cfg config.MCPServerConfig) (string, error) { root := resolveMCPDefaultRoot(workspace) permission := strings.ToLower(strings.TrimSpace(cfg.Permission)) if permission == "" { permission = "workspace" } wd := strings.TrimSpace(cfg.WorkingDir) if wd == "" { return root, nil } if permission == "full" { if !filepath.IsAbs(wd) { return "", fmt.Errorf("mcp server %q working_dir must be absolute when permission=full", strings.TrimSpace(cfg.Command)) } return filepath.Clean(wd), nil } if filepath.IsAbs(wd) { clean := filepath.Clean(wd) rel, err := filepath.Rel(root, clean) if err != nil || strings.HasPrefix(rel, "..") { return "", fmt.Errorf("mcp working_dir %q must stay within workspace root %q unless permission=full", clean, root) } return clean, nil } return filepath.Clean(filepath.Join(root, wd)), nil } func resolveMCPDefaultRoot(workspace string) string { if abs, err := filepath.Abs(workspace); err == nil { return abs } return workspace } func fileURI(path string) string { abs, err := filepath.Abs(path) if err != nil { abs = path } return (&url.URL{Scheme: "file", Path: filepath.ToSlash(abs)}).String() } func normalizeMCPID(id interface{}) (string, bool) { switch v := id.(type) { case string: return v, v != "" case float64: return strconv.FormatInt(int64(v), 10), true case int: return strconv.Itoa(v), true case int64: return strconv.FormatInt(v, 10), true default: return "", false } } func prettyJSON(v interface{}) (string, error) { data, err := json.MarshalIndent(v, "", " ") if err != nil { return "", err } return string(data), nil } func buildMCPDynamicToolName(serverName, remoteName string) string { base := "mcp__" + sanitizeMCPToolSegment(serverName) + "__" + sanitizeMCPToolSegment(remoteName) if len(base) <= 64 { return base } hash := fnv.New32a() _, _ = hash.Write([]byte(serverName + "::" + remoteName)) suffix := fmt.Sprintf("_%x", hash.Sum32()) trimmed := base if len(trimmed)+len(suffix) > 64 { trimmed = trimmed[:64-len(suffix)] } return trimmed + suffix } func ParseMCPDynamicToolName(name string) (serverName string, remoteName string, ok bool) { const prefix = "mcp__" if !strings.HasPrefix(strings.TrimSpace(name), prefix) { return "", "", false } rest := strings.TrimPrefix(strings.TrimSpace(name), prefix) parts := strings.SplitN(rest, "__", 2) if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[1]) == "" { return "", "", false } return parts[0], parts[1], true } var mcpToolSegmentPattern = regexp.MustCompile(`[^a-zA-Z0-9_]+`) func sanitizeMCPToolSegment(in string) string { in = strings.TrimSpace(strings.ToLower(in)) in = mcpToolSegmentPattern.ReplaceAllString(in, "_") in = strings.Trim(in, "_") if in == "" { return "tool" } return in } func buildMCPDynamicToolDescription(serverName string, toolMap map[string]interface{}) string { desc := strings.TrimSpace(mcpStringArg(toolMap, "description")) remoteName := strings.TrimSpace(mcpStringArg(toolMap, "name")) if desc == "" { desc = fmt.Sprintf("Proxy to MCP tool %q on server %q.", remoteName, serverName) } else { desc = fmt.Sprintf("%s (MCP server: %s, remote tool: %s)", desc, serverName, remoteName) } return desc } func normalizeMCPSchema(raw interface{}) map[string]interface{} { schema, _ := raw.(map[string]interface{}) if schema == nil { return map[string]interface{}{ "type": "object", "properties": map[string]interface{}{}, } } out := map[string]interface{}{} for k, v := range schema { out[k] = v } if _, ok := out["type"]; !ok { out["type"] = "object" } if _, ok := out["properties"]; !ok { out["properties"] = map[string]interface{}{} } return out } func renderMCPToolCallResult(result map[string]interface{}) (string, error) { if result == nil { return "", nil } if content, ok := result["content"].([]interface{}); ok && len(content) > 0 { parts := make([]string, 0, len(content)) for _, item := range content { m, _ := item.(map[string]interface{}) if m == nil { continue } kind := strings.TrimSpace(mcpStringArg(m, "type")) switch kind { case "text": if text := mcpStringArg(m, "text"); strings.TrimSpace(text) != "" { parts = append(parts, text) } default: if text := mcpStringArg(m, "text"); strings.TrimSpace(text) != "" { parts = append(parts, text) } else { data, err := prettyJSON(m) if err == nil { parts = append(parts, data) } } } } if len(parts) > 0 { if structured, ok := result["structuredContent"]; ok { data, err := prettyJSON(structured) if err == nil && strings.TrimSpace(data) != "" && data != "{}" { parts = append(parts, data) } } return strings.Join(parts, "\n\n"), nil } } return prettyJSON(result) } func mcpStringArg(args map[string]interface{}, key string) string { v, _ := args[key].(string) return v } func mcpObjectArg(args map[string]interface{}, key string) map[string]interface{} { v, _ := args[key].(map[string]interface{}) if v == nil { return map[string]interface{}{} } return v }