When all MCP servers fail to connect (e.g., network unreachable), the agent loop would exit immediately, leaving the application in a zombie state where the gateway is running but no messages can be processed. Changes: - Downgrade MCP init failure from fatal error to warning in Run(), ProcessDirectWithChannel(), ProcessHeartbeat(), and Continue() - Add 30s timeout to HTTP client used for SSE/HTTP MCP transports to prevent indefinite blocking on unreachable servers The agent now continues operating without MCP tools when servers are unavailable, rather than becoming completely unresponsive.
693 lines
17 KiB
Go
693 lines
17 KiB
Go
package mcp
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
|
|
"github.com/sipeed/picoclaw/pkg/config"
|
|
"github.com/sipeed/picoclaw/pkg/logger"
|
|
)
|
|
|
|
// headerTransport is an http.RoundTripper that adds custom headers to requests
|
|
type headerTransport struct {
|
|
base http.RoundTripper
|
|
headers map[string]string
|
|
}
|
|
|
|
func expandHomeCommandPath(command string) string {
|
|
if command == "" || command[0] != '~' {
|
|
return command
|
|
}
|
|
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return command
|
|
}
|
|
if command == "~" {
|
|
return home
|
|
}
|
|
if strings.HasPrefix(command, "~/") || strings.HasPrefix(command, "~\\") {
|
|
return filepath.Join(home, command[2:])
|
|
}
|
|
return command
|
|
}
|
|
|
|
func (t *headerTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
// Clone the request to avoid modifying the original
|
|
req = req.Clone(req.Context())
|
|
|
|
// Add custom headers
|
|
for key, value := range t.headers {
|
|
req.Header.Set(key, value)
|
|
}
|
|
|
|
// Use the base transport
|
|
base := t.base
|
|
if base == nil {
|
|
base = http.DefaultTransport
|
|
}
|
|
return base.RoundTrip(req)
|
|
}
|
|
|
|
// loadEnvFile loads environment variables from a file in .env format
|
|
// Each line should be in the format: KEY=value
|
|
// Lines starting with # are comments
|
|
// Empty lines are ignored
|
|
func loadEnvFile(path string) (map[string]string, error) {
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to open env file: %w", err)
|
|
}
|
|
defer file.Close()
|
|
|
|
envVars := make(map[string]string)
|
|
scanner := bufio.NewScanner(file)
|
|
lineNum := 0
|
|
|
|
for scanner.Scan() {
|
|
lineNum++
|
|
line := strings.TrimSpace(scanner.Text())
|
|
|
|
// Skip empty lines and comments
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
|
|
// Parse KEY=value
|
|
parts := strings.SplitN(line, "=", 2)
|
|
if len(parts) != 2 {
|
|
return nil, fmt.Errorf("invalid format at line %d: %s", lineNum, line)
|
|
}
|
|
|
|
key := strings.TrimSpace(parts[0])
|
|
value := strings.TrimSpace(parts[1])
|
|
|
|
if key == "" {
|
|
return nil, fmt.Errorf("invalid format at line %d: empty key", lineNum)
|
|
}
|
|
|
|
// Remove surrounding quotes if present
|
|
if len(value) >= 2 {
|
|
if (value[0] == '"' && value[len(value)-1] == '"') ||
|
|
(value[0] == '\'' && value[len(value)-1] == '\'') {
|
|
value = value[1 : len(value)-1]
|
|
}
|
|
}
|
|
|
|
envVars[key] = value
|
|
}
|
|
|
|
if err := scanner.Err(); err != nil {
|
|
return nil, fmt.Errorf("error reading env file: %w", err)
|
|
}
|
|
|
|
return envVars, nil
|
|
}
|
|
|
|
// ServerConnection represents a connection to an MCP server
|
|
type ServerConnection struct {
|
|
Name string
|
|
Config config.MCPServerConfig
|
|
Client *mcp.Client
|
|
Session *mcp.ClientSession
|
|
Tools []*mcp.Tool
|
|
reconnectMu sync.Mutex
|
|
}
|
|
|
|
// Manager manages multiple MCP server connections
|
|
type Manager struct {
|
|
servers map[string]*ServerConnection
|
|
mu sync.RWMutex
|
|
closed atomic.Bool // changed from bool to atomic.Bool to avoid TOCTOU race
|
|
wg sync.WaitGroup // tracks in-flight CallTool calls
|
|
}
|
|
|
|
var connectServerFunc = connectServer
|
|
|
|
// NewManager creates a new MCP manager
|
|
func NewManager() *Manager {
|
|
return &Manager{
|
|
servers: make(map[string]*ServerConnection),
|
|
}
|
|
}
|
|
|
|
// LoadFromConfig loads MCP servers from configuration
|
|
func (m *Manager) LoadFromConfig(ctx context.Context, cfg *config.Config) error {
|
|
return m.LoadFromMCPConfig(ctx, cfg.Tools.MCP, cfg.WorkspacePath())
|
|
}
|
|
|
|
// LoadFromMCPConfig loads MCP servers from MCP configuration and workspace path.
|
|
// This is the minimal dependency version that doesn't require the full Config object.
|
|
func (m *Manager) LoadFromMCPConfig(
|
|
ctx context.Context,
|
|
mcpCfg config.MCPConfig,
|
|
workspacePath string,
|
|
) error {
|
|
if !mcpCfg.Enabled {
|
|
logger.InfoCF("mcp", "MCP integration is disabled", nil)
|
|
return nil
|
|
}
|
|
|
|
if len(mcpCfg.Servers) == 0 {
|
|
logger.InfoCF("mcp", "No MCP servers configured", nil)
|
|
return nil
|
|
}
|
|
|
|
logger.InfoCF("mcp", "Initializing MCP servers",
|
|
map[string]any{
|
|
"count": len(mcpCfg.Servers),
|
|
})
|
|
|
|
var wg sync.WaitGroup
|
|
errs := make(chan error, len(mcpCfg.Servers))
|
|
enabledCount := 0
|
|
|
|
for name, serverCfg := range mcpCfg.Servers {
|
|
if !serverCfg.Enabled {
|
|
logger.DebugCF("mcp", "Skipping disabled server",
|
|
map[string]any{
|
|
"server": name,
|
|
})
|
|
continue
|
|
}
|
|
|
|
enabledCount++
|
|
wg.Add(1)
|
|
go func(name string, serverCfg config.MCPServerConfig, workspace string) {
|
|
defer wg.Done()
|
|
|
|
// Resolve relative envFile paths relative to workspace
|
|
if serverCfg.EnvFile != "" && !filepath.IsAbs(serverCfg.EnvFile) {
|
|
if workspace == "" {
|
|
err := fmt.Errorf(
|
|
"workspace path is empty while resolving relative envFile %q for server %s",
|
|
serverCfg.EnvFile,
|
|
name,
|
|
)
|
|
logger.ErrorCF("mcp", "Invalid MCP server configuration",
|
|
map[string]any{
|
|
"server": name,
|
|
"env_file": serverCfg.EnvFile,
|
|
"error": err.Error(),
|
|
})
|
|
errs <- err
|
|
return
|
|
}
|
|
serverCfg.EnvFile = filepath.Join(workspace, serverCfg.EnvFile)
|
|
}
|
|
|
|
if err := m.ConnectServer(ctx, name, serverCfg); err != nil {
|
|
logger.ErrorCF("mcp", "Failed to connect to MCP server",
|
|
map[string]any{
|
|
"server": name,
|
|
"error": err.Error(),
|
|
})
|
|
errs <- fmt.Errorf("failed to connect to server %s: %w", name, err)
|
|
}
|
|
}(name, serverCfg, workspacePath)
|
|
}
|
|
|
|
wg.Wait()
|
|
close(errs)
|
|
|
|
// Collect errors
|
|
var allErrors []error
|
|
for err := range errs {
|
|
allErrors = append(allErrors, err)
|
|
}
|
|
|
|
connectedCount := len(m.GetServers())
|
|
|
|
// If all enabled servers failed to connect, return aggregated error
|
|
if enabledCount > 0 && connectedCount == 0 {
|
|
logger.ErrorCF("mcp", "All MCP servers failed to connect",
|
|
map[string]any{
|
|
"failed": len(allErrors),
|
|
"total": enabledCount,
|
|
})
|
|
return errors.Join(allErrors...)
|
|
}
|
|
|
|
if len(allErrors) > 0 {
|
|
logger.WarnCF("mcp", "Some MCP servers failed to connect",
|
|
map[string]any{
|
|
"failed": len(allErrors),
|
|
"connected": connectedCount,
|
|
"total": enabledCount,
|
|
})
|
|
// Don't fail completely if some servers successfully connected
|
|
}
|
|
|
|
logger.InfoCF("mcp", "MCP server initialization complete",
|
|
map[string]any{
|
|
"connected": connectedCount,
|
|
"total": enabledCount,
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
// ConnectServer connects to a single MCP server
|
|
func (m *Manager) ConnectServer(
|
|
ctx context.Context,
|
|
name string,
|
|
cfg config.MCPServerConfig,
|
|
) error {
|
|
conn, err := connectServerFunc(ctx, name, cfg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
if m.closed.Load() {
|
|
_ = conn.Session.Close()
|
|
return fmt.Errorf("manager is closed")
|
|
}
|
|
|
|
m.servers[name] = conn
|
|
return nil
|
|
}
|
|
|
|
func connectServer(
|
|
ctx context.Context,
|
|
name string,
|
|
cfg config.MCPServerConfig,
|
|
) (*ServerConnection, error) {
|
|
logger.InfoCF("mcp", "Connecting to MCP server",
|
|
map[string]any{
|
|
"server": name,
|
|
"command": cfg.Command,
|
|
"args_count": len(cfg.Args),
|
|
})
|
|
|
|
// Create client
|
|
client := mcp.NewClient(&mcp.Implementation{
|
|
Name: "picoclaw",
|
|
Version: "1.0.0",
|
|
}, nil)
|
|
|
|
// Create transport based on configuration
|
|
// Auto-detect transport type if not explicitly specified
|
|
var transport mcp.Transport
|
|
transportType := cfg.Type
|
|
|
|
// Auto-detect: if URL is provided, use SSE; if command is provided, use stdio
|
|
if transportType == "" {
|
|
if cfg.URL != "" {
|
|
transportType = "sse"
|
|
} else if cfg.Command != "" {
|
|
transportType = "stdio"
|
|
} else {
|
|
return nil, fmt.Errorf("either URL or command must be provided")
|
|
}
|
|
}
|
|
|
|
switch transportType {
|
|
case "sse", "http":
|
|
if cfg.URL == "" {
|
|
return nil, fmt.Errorf("URL is required for SSE/HTTP transport")
|
|
}
|
|
|
|
// Configure DisableStandaloneSSE based on transport type.
|
|
// - "http": Request-response only mode. Disable the standalone SSE stream
|
|
// to avoid compatibility issues with servers that don't support GET /mcp.
|
|
// - "sse": Bidirectional mode. Enable the standalone SSE stream to receive
|
|
// server-initiated notifications (e.g., ToolListChangedNotification).
|
|
// - Empty or auto-detected: Defaults to "sse" behavior (standalone SSE enabled).
|
|
disableStandaloneSSE := (cfg.Type == "http")
|
|
|
|
logger.DebugCF("mcp", "Using SSE/HTTP transport",
|
|
map[string]any{
|
|
"server": name,
|
|
"url": cfg.URL,
|
|
"disableStandaloneSSE": disableStandaloneSSE,
|
|
})
|
|
|
|
sseTransport := &mcp.StreamableClientTransport{
|
|
Endpoint: cfg.URL,
|
|
DisableStandaloneSSE: disableStandaloneSSE,
|
|
}
|
|
|
|
// Set up HTTP client with a connection timeout to avoid hanging
|
|
// indefinitely when the MCP server is unreachable.
|
|
mcpHTTPClient := &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
}
|
|
|
|
// Add custom headers if provided
|
|
if len(cfg.Headers) > 0 {
|
|
// Create a custom HTTP client with header-injecting transport
|
|
mcpHTTPClient.Transport = &headerTransport{
|
|
base: http.DefaultTransport,
|
|
headers: cfg.Headers,
|
|
}
|
|
logger.DebugCF("mcp", "Added custom HTTP headers",
|
|
map[string]any{
|
|
"server": name,
|
|
"header_count": len(cfg.Headers),
|
|
})
|
|
}
|
|
|
|
sseTransport.HTTPClient = mcpHTTPClient
|
|
|
|
transport = sseTransport
|
|
case "stdio":
|
|
if cfg.Command == "" {
|
|
return nil, fmt.Errorf("command is required for stdio transport")
|
|
}
|
|
logger.DebugCF("mcp", "Using stdio transport",
|
|
map[string]any{
|
|
"server": name,
|
|
"command": cfg.Command,
|
|
})
|
|
// Create command with context
|
|
cmd := exec.CommandContext(ctx, expandHomeCommandPath(cfg.Command), cfg.Args...)
|
|
|
|
// Build environment variables with proper override semantics
|
|
// Use a map to ensure config variables override file variables
|
|
envMap := make(map[string]string)
|
|
|
|
// Start with parent process environment
|
|
for _, e := range cmd.Environ() {
|
|
if idx := strings.Index(e, "="); idx > 0 {
|
|
envMap[e[:idx]] = e[idx+1:]
|
|
}
|
|
}
|
|
|
|
// Load environment variables from file if specified
|
|
if cfg.EnvFile != "" {
|
|
envVars, err := loadEnvFile(cfg.EnvFile)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to load env file %s: %w", cfg.EnvFile, err)
|
|
}
|
|
for k, v := range envVars {
|
|
envMap[k] = v
|
|
}
|
|
logger.DebugCF("mcp", "Loaded environment variables from file",
|
|
map[string]any{
|
|
"server": name,
|
|
"envFile": cfg.EnvFile,
|
|
"var_count": len(envVars),
|
|
})
|
|
}
|
|
|
|
// Environment variables from config override those from file
|
|
for k, v := range cfg.Env {
|
|
envMap[k] = v
|
|
}
|
|
|
|
// Convert map to slice
|
|
env := make([]string, 0, len(envMap))
|
|
for k, v := range envMap {
|
|
env = append(env, fmt.Sprintf("%s=%s", k, v))
|
|
}
|
|
cmd.Env = env
|
|
transport = &isolatedCommandTransport{Command: cmd}
|
|
default:
|
|
return nil, fmt.Errorf(
|
|
"unsupported transport type: %s (supported: stdio, sse, http)",
|
|
transportType,
|
|
)
|
|
}
|
|
|
|
// Connect to server
|
|
session, err := client.Connect(ctx, transport, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to connect: %w", err)
|
|
}
|
|
|
|
// Get server info
|
|
initResult := session.InitializeResult()
|
|
logger.InfoCF("mcp", "Connected to MCP server",
|
|
map[string]any{
|
|
"server": name,
|
|
"serverName": initResult.ServerInfo.Name,
|
|
"serverVersion": initResult.ServerInfo.Version,
|
|
"protocol": initResult.ProtocolVersion,
|
|
})
|
|
|
|
// List available tools if supported
|
|
tools, err := listServerTools(ctx, name, session, initResult)
|
|
if err != nil {
|
|
_ = session.Close()
|
|
return nil, err
|
|
}
|
|
|
|
return &ServerConnection{
|
|
Name: name,
|
|
Config: cfg,
|
|
Client: client,
|
|
Session: session,
|
|
Tools: tools,
|
|
}, nil
|
|
}
|
|
|
|
// GetServers returns all connected servers
|
|
func (m *Manager) GetServers() map[string]*ServerConnection {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
|
|
result := make(map[string]*ServerConnection, len(m.servers))
|
|
for k, v := range m.servers {
|
|
result[k] = v
|
|
}
|
|
return result
|
|
}
|
|
|
|
// GetServer returns a specific server connection
|
|
func (m *Manager) GetServer(name string) (*ServerConnection, bool) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
|
|
conn, ok := m.servers[name]
|
|
return conn, ok
|
|
}
|
|
|
|
// CallTool calls a tool on a specific server
|
|
func (m *Manager) CallTool(
|
|
ctx context.Context,
|
|
serverName, toolName string,
|
|
arguments map[string]any,
|
|
) (*mcp.CallToolResult, error) {
|
|
// Check if closed before acquiring lock (fast path)
|
|
if m.closed.Load() {
|
|
return nil, fmt.Errorf("manager is closed")
|
|
}
|
|
|
|
m.mu.RLock()
|
|
// Double-check after acquiring lock to prevent TOCTOU race
|
|
if m.closed.Load() {
|
|
m.mu.RUnlock()
|
|
return nil, fmt.Errorf("manager is closed")
|
|
}
|
|
conn, ok := m.servers[serverName]
|
|
if ok {
|
|
m.wg.Add(1) // Add to WaitGroup while holding the lock
|
|
}
|
|
m.mu.RUnlock()
|
|
|
|
if !ok {
|
|
return nil, fmt.Errorf("server %s not found", serverName)
|
|
}
|
|
defer m.wg.Done()
|
|
|
|
params := &mcp.CallToolParams{
|
|
Name: toolName,
|
|
Arguments: arguments,
|
|
}
|
|
|
|
result, err := conn.Session.CallTool(ctx, params)
|
|
if err != nil {
|
|
if shouldReconnectCallError(err) {
|
|
logger.WarnCF("mcp", "MCP server session was lost during tool call, reconnecting",
|
|
map[string]any{
|
|
"server": serverName,
|
|
"tool": toolName,
|
|
"error": err.Error(),
|
|
})
|
|
|
|
reconnectedConn, reconnectErr := m.reconnectServer(ctx, serverName, conn)
|
|
if reconnectErr != nil {
|
|
return nil, fmt.Errorf("failed to recover lost MCP session: %w", reconnectErr)
|
|
}
|
|
|
|
result, err = reconnectedConn.Session.CallTool(ctx, params)
|
|
if err == nil {
|
|
return result, nil
|
|
}
|
|
}
|
|
|
|
return nil, fmt.Errorf("failed to call tool: %w", err)
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func listServerTools(
|
|
ctx context.Context,
|
|
name string,
|
|
session *mcp.ClientSession,
|
|
initResult *mcp.InitializeResult,
|
|
) ([]*mcp.Tool, error) {
|
|
var tools []*mcp.Tool
|
|
if initResult.Capabilities.Tools == nil {
|
|
return tools, nil
|
|
}
|
|
|
|
for tool, err := range session.Tools(ctx, nil) {
|
|
if err != nil {
|
|
logger.WarnCF("mcp", "Error listing tool",
|
|
map[string]any{
|
|
"server": name,
|
|
"error": err.Error(),
|
|
})
|
|
continue
|
|
}
|
|
tools = append(tools, tool)
|
|
}
|
|
|
|
logger.InfoCF("mcp", "Listed tools from MCP server",
|
|
map[string]any{
|
|
"server": name,
|
|
"toolCount": len(tools),
|
|
})
|
|
|
|
return tools, nil
|
|
}
|
|
|
|
func shouldReconnectCallError(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
if errors.Is(err, mcp.ErrSessionMissing) {
|
|
return true
|
|
}
|
|
return strings.Contains(strings.ToLower(err.Error()), mcp.ErrSessionMissing.Error())
|
|
}
|
|
|
|
func (m *Manager) reconnectServer(
|
|
ctx context.Context,
|
|
serverName string,
|
|
staleConn *ServerConnection,
|
|
) (*ServerConnection, error) {
|
|
if staleConn == nil {
|
|
return nil, fmt.Errorf("server %s not found", serverName)
|
|
}
|
|
|
|
staleConn.reconnectMu.Lock()
|
|
defer staleConn.reconnectMu.Unlock()
|
|
|
|
if m.closed.Load() {
|
|
return nil, fmt.Errorf("manager is closed")
|
|
}
|
|
|
|
m.mu.RLock()
|
|
currentConn, ok := m.servers[serverName]
|
|
m.mu.RUnlock()
|
|
if !ok {
|
|
return nil, fmt.Errorf("server %s not found", serverName)
|
|
}
|
|
if currentConn != staleConn {
|
|
return currentConn, nil
|
|
}
|
|
|
|
freshConn, err := connectServerFunc(ctx, serverName, staleConn.Config)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
m.mu.Lock()
|
|
if m.closed.Load() {
|
|
m.mu.Unlock()
|
|
_ = freshConn.Session.Close()
|
|
return nil, fmt.Errorf("manager is closed")
|
|
}
|
|
|
|
currentConn, ok = m.servers[serverName]
|
|
if !ok {
|
|
m.mu.Unlock()
|
|
_ = freshConn.Session.Close()
|
|
return nil, fmt.Errorf("server %s not found", serverName)
|
|
}
|
|
|
|
if currentConn == staleConn {
|
|
m.servers[serverName] = freshConn
|
|
staleToClose := staleConn
|
|
m.mu.Unlock()
|
|
_ = staleToClose.Session.Close()
|
|
return freshConn, nil
|
|
}
|
|
|
|
m.mu.Unlock()
|
|
_ = freshConn.Session.Close()
|
|
return currentConn, nil
|
|
}
|
|
|
|
// Close closes all server connections
|
|
func (m *Manager) Close() error {
|
|
// Use Swap to atomically set closed=true and get the previous value
|
|
// This prevents TOCTOU race with CallTool's closed check
|
|
if m.closed.Swap(true) {
|
|
return nil // already closed
|
|
}
|
|
|
|
// Wait for all in-flight CallTool calls to finish before closing sessions
|
|
// After closed=true is set, no new CallTool can start (they check closed first)
|
|
m.wg.Wait()
|
|
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
logger.InfoCF("mcp", "Closing all MCP server connections",
|
|
map[string]any{
|
|
"count": len(m.servers),
|
|
})
|
|
|
|
var errs []error
|
|
for name, conn := range m.servers {
|
|
if err := conn.Session.Close(); err != nil {
|
|
logger.ErrorCF("mcp", "Failed to close server connection",
|
|
map[string]any{
|
|
"server": name,
|
|
"error": err.Error(),
|
|
})
|
|
errs = append(errs, fmt.Errorf("server %s: %w", name, err))
|
|
}
|
|
}
|
|
|
|
m.servers = make(map[string]*ServerConnection)
|
|
|
|
if len(errs) > 0 {
|
|
return fmt.Errorf("failed to close %d server(s): %w", len(errs), errors.Join(errs...))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetAllTools returns all tools from all connected servers
|
|
func (m *Manager) GetAllTools() map[string][]*mcp.Tool {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
|
|
result := make(map[string][]*mcp.Tool)
|
|
for name, conn := range m.servers {
|
|
if len(conn.Tools) > 0 {
|
|
result[name] = conn.Tools
|
|
}
|
|
}
|
|
return result
|
|
}
|