Files
goclaw/internal/mcp/manager_connect.go
T
viettranx bdb60de7ae chore: upgrade Go 1.25 → 1.26 and apply go fix modernizations
- Update go.mod and Dockerfile to Go 1.26
- Apply `go fix ./...` stdlib modernizations across 170+ files
- Add `go fix` to post-implementation checklist in CLAUDE.md
- Fix go fix misapplied rewrite in loop_history.go
2026-03-10 00:09:15 +07:00

296 lines
8.5 KiB
Go

package mcp
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
mcpclient "github.com/mark3labs/mcp-go/client"
"github.com/mark3labs/mcp-go/client/transport"
mcpgo "github.com/mark3labs/mcp-go/mcp"
"github.com/nextlevelbuilder/goclaw/internal/tools"
)
// connectAndDiscover creates a client, initializes the MCP handshake, and
// discovers tools. Returns a connected serverState with discovered tool
// definitions. The caller is responsible for registering tools and starting
// the health loop. This function is shared by both Manager and Pool.
func connectAndDiscover(ctx context.Context, name, transportType, command string, args []string, env map[string]string, url string, headers map[string]string, timeoutSec int) (*serverState, []mcpgo.Tool, error) {
client, err := createClient(transportType, command, args, env, url, headers)
if err != nil {
return nil, nil, fmt.Errorf("create client: %w", err)
}
if transportType != "stdio" {
if err := client.Start(ctx); err != nil {
_ = client.Close()
return nil, nil, fmt.Errorf("start transport: %w", err)
}
}
initReq := mcpgo.InitializeRequest{}
initReq.Params.ProtocolVersion = mcpgo.LATEST_PROTOCOL_VERSION
initReq.Params.ClientInfo = mcpgo.Implementation{
Name: "goclaw",
Version: "1.0.0",
}
if _, err := client.Initialize(ctx, initReq); err != nil {
_ = client.Close()
return nil, nil, fmt.Errorf("initialize: %w", err)
}
toolsResult, err := client.ListTools(ctx, mcpgo.ListToolsRequest{})
if err != nil {
_ = client.Close()
return nil, nil, fmt.Errorf("list tools: %w", err)
}
if timeoutSec <= 0 {
timeoutSec = 60
}
ss := &serverState{
name: name,
transport: transportType,
client: client,
timeoutSec: timeoutSec,
}
ss.connected.Store(true)
return ss, toolsResult.Tools, nil
}
// connectServer creates a client, initializes the connection, discovers tools, and registers them.
func (m *Manager) connectServer(ctx context.Context, name, transportType, command string, args []string, env map[string]string, url string, headers map[string]string, toolPrefix string, timeoutSec int) error {
ss, mcpTools, err := connectAndDiscover(ctx, name, transportType, command, args, env, url, headers, timeoutSec)
if err != nil {
return err
}
// Register tools
registeredNames := m.registerBridgeTools(ss, mcpTools, name, toolPrefix, timeoutSec)
ss.toolNames = registeredNames
// Create health monitoring context
hctx, hcancel := context.WithCancel(context.Background())
ss.cancel = hcancel
// Store server state BEFORE updating MCP group
m.mu.Lock()
m.servers[name] = ss
m.mu.Unlock()
if len(registeredNames) > 0 {
tools.RegisterToolGroup("mcp:"+name, registeredNames)
m.updateMCPGroup()
}
go m.healthLoop(hctx, ss)
slog.Info("mcp.server.connected",
"server", name,
"transport", transportType,
"tools", len(registeredNames),
)
return nil
}
// registerBridgeTools creates BridgeTools from MCP tool definitions and
// registers them in the Manager's registry. Returns registered tool names.
func (m *Manager) registerBridgeTools(ss *serverState, mcpTools []mcpgo.Tool, serverName, toolPrefix string, timeoutSec int) []string {
var registeredNames []string
for _, mcpTool := range mcpTools {
bt := NewBridgeTool(serverName, mcpTool, ss.client, toolPrefix, timeoutSec, &ss.connected)
if _, exists := m.registry.Get(bt.Name()); exists {
slog.Warn("mcp.tool.name_collision",
"server", serverName,
"tool", bt.Name(),
"action", "skipped",
)
continue
}
m.registry.Register(bt)
registeredNames = append(registeredNames, bt.Name())
}
return registeredNames
}
// connectViaPool acquires a shared connection from the pool and creates
// per-agent BridgeTools pointing to the shared client/connected pointers.
func (m *Manager) connectViaPool(ctx context.Context, name, transportType, command string, args []string, env map[string]string, url string, headers map[string]string, toolPrefix string, timeoutSec int) error {
entry, err := m.pool.Acquire(ctx, name, transportType, command, args, env, url, headers, timeoutSec)
if err != nil {
return err
}
// Create per-agent BridgeTools from the pool's shared connection
registeredNames := m.registerPoolBridgeTools(entry, name, toolPrefix, timeoutSec)
// Track server state and per-agent tool names
m.mu.Lock()
m.servers[name] = entry.state
if m.poolServers == nil {
m.poolServers = make(map[string]struct{})
}
m.poolServers[name] = struct{}{}
if m.poolToolNames == nil {
m.poolToolNames = make(map[string][]string)
}
m.poolToolNames[name] = registeredNames
m.mu.Unlock()
if len(registeredNames) > 0 {
tools.RegisterToolGroup("mcp:"+name, registeredNames)
m.updateMCPGroup()
}
slog.Info("mcp.server.connected_via_pool",
"server", name,
"transport", transportType,
"tools", len(registeredNames),
)
return nil
}
// registerPoolBridgeTools creates BridgeTools from pool entry's discovered tools,
// pointing to the shared client/connected pointers. Returns registered tool names.
func (m *Manager) registerPoolBridgeTools(entry *poolEntry, serverName, toolPrefix string, timeoutSec int) []string {
var registeredNames []string
for _, mcpTool := range entry.tools {
bt := NewBridgeTool(serverName, mcpTool, entry.state.client, toolPrefix, timeoutSec, &entry.state.connected)
if _, exists := m.registry.Get(bt.Name()); exists {
slog.Warn("mcp.tool.name_collision",
"server", serverName,
"tool", bt.Name(),
"action", "skipped",
)
continue
}
m.registry.Register(bt)
registeredNames = append(registeredNames, bt.Name())
}
return registeredNames
}
// createClient creates the appropriate MCP client based on transport type.
func createClient(transportType, command string, args []string, env map[string]string, url string, headers map[string]string) (*mcpclient.Client, error) {
switch transportType {
case "stdio":
envSlice := mapToEnvSlice(env)
return mcpclient.NewStdioMCPClient(command, envSlice, args...)
case "sse":
var opts []transport.ClientOption
if len(headers) > 0 {
opts = append(opts, mcpclient.WithHeaders(headers))
}
return mcpclient.NewSSEMCPClient(url, opts...)
case "streamable-http":
var opts []transport.StreamableHTTPCOption
if len(headers) > 0 {
opts = append(opts, transport.WithHTTPHeaders(headers))
}
return mcpclient.NewStreamableHttpClient(url, opts...)
default:
return nil, fmt.Errorf("unsupported transport: %q", transportType)
}
}
// newHealthTicker creates a ticker for health check intervals.
func newHealthTicker() *time.Ticker {
return time.NewTicker(healthCheckInterval)
}
// isMethodNotFound returns true if the error indicates the server
// doesn't implement the "ping" method (still considered healthy).
func isMethodNotFound(err error) bool {
return strings.Contains(strings.ToLower(err.Error()), "method not found")
}
// healthLoop periodically pings the MCP server and attempts reconnection on failure.
func (m *Manager) healthLoop(ctx context.Context, ss *serverState) {
ticker := newHealthTicker()
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := ss.client.Ping(ctx); err != nil {
if isMethodNotFound(err) {
ss.connected.Store(true)
ss.mu.Lock()
ss.reconnAttempts = 0
ss.lastErr = ""
ss.mu.Unlock()
continue
}
ss.connected.Store(false)
ss.mu.Lock()
ss.lastErr = err.Error()
ss.mu.Unlock()
slog.Warn("mcp.server.health_failed", "server", ss.name, "error", err)
m.tryReconnect(ctx, ss)
} else {
ss.connected.Store(true)
ss.mu.Lock()
ss.reconnAttempts = 0
ss.lastErr = ""
ss.mu.Unlock()
}
}
}
}
// tryReconnect attempts to reconnect with exponential backoff.
func (m *Manager) tryReconnect(ctx context.Context, ss *serverState) {
ss.mu.Lock()
if ss.reconnAttempts >= maxReconnectAttempts {
ss.lastErr = fmt.Sprintf("max reconnect attempts (%d) reached", maxReconnectAttempts)
ss.mu.Unlock()
slog.Error("mcp.server.reconnect_exhausted", "server", ss.name)
return
}
ss.reconnAttempts++
attempt := ss.reconnAttempts
ss.mu.Unlock()
backoff := min(initialBackoff*time.Duration(1<<(attempt-1)), maxBackoff)
slog.Info("mcp.server.reconnecting",
"server", ss.name,
"attempt", attempt,
"backoff", backoff,
)
select {
case <-ctx.Done():
return
case <-time.After(backoff):
}
// Try to ping again — transport may have auto-reconnected
if err := ss.client.Ping(ctx); err == nil {
ss.connected.Store(true)
ss.mu.Lock()
ss.reconnAttempts = 0
ss.lastErr = ""
ss.mu.Unlock()
slog.Info("mcp.server.reconnected", "server", ss.name)
}
}