mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-06-15 18:47:59 +00:00
bdb60de7ae
- 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
296 lines
8.5 KiB
Go
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)
|
|
}
|
|
}
|