From 1fccae15683e1294d954f86a2f2aafda7a4a53c7 Mon Sep 17 00:00:00 2001 From: Enmanuel Date: Sun, 8 Mar 2026 21:22:33 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20a=C3=B1adir=20cliente=20MCP=20para=20co?= =?UTF-8?q?nsumir=20servidores=20externos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implementa el cliente MCP que permite a los agentes conectarse a servidores MCP externos y usar sus tools como si fueran tools nativas del agente. Arquitectura implementada: - shell/mcp/client.go: Cliente MCP con soporte stdio y SSE - shell/mcp/manager.go: Gestor de múltiples clientes MCP - tools/mcptools/mcp.go: Bridge que convierte MCP tools → tools.Tool - shell/mcp/server.go: Movido desde shell/protocols/ para colocación junto al client Cambios en config: - MCPServerCfg extendido con campos Transport, Command, Args, Env, Headers, Prefix, Timeout para soportar stdio y SSE transport Integración en runtime: - agents/runtime.go: Inicializa MCP manager si config.Tools.MCP.Enabled - buildToolRegistry: Registra tools MCP automáticamente con prefijos configurables - Agent: Campo mcpManager que se cierra en shutdown Transportes soportados: - stdio: Lanza subproceso (ej: npx -y @anthropic/mcp-server-brave-search) - SSE: Se conecta a servidor HTTP MCP Las tools MCP son indistinguibles de tools nativas desde el punto de vista del LLM. Auto-discovery via ListTools(), conversión de JSON Schema a tools.Param. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .claude/CLAUDE.md | 2 + agents/runtime.go | 49 +++++- dev/issues/README.md | 2 +- .../{ => completed}/0017-mcp-client-tools.md | 0 internal/config/schema.go | 13 +- shell/mcp/client.go | 159 ++++++++++++++++++ shell/mcp/manager.go | 130 ++++++++++++++ shell/{protocols/mcp.go => mcp/server.go} | 4 +- tools/mcptools/mcp.go | 138 +++++++++++++++ 9 files changed, 489 insertions(+), 8 deletions(-) rename dev/issues/{ => completed}/0017-mcp-client-tools.md (100%) create mode 100644 shell/mcp/client.go create mode 100644 shell/mcp/manager.go rename shell/{protocols/mcp.go => mcp/server.go} (93%) create mode 100644 tools/mcptools/mcp.go diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 3401623..3876dc7 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -51,11 +51,13 @@ pkg/personality/ tipos de personalidad shell/llm/ clientes LLM (anthropic, openai) shell/matrix/ cliente Matrix (mautrix-go) shell/ssh/ ejecutor SSH +shell/mcp/ cliente y servidor MCP (Model Context Protocol) shell/effects/ Runner: []Action → side effects shell/bus/ comunicacion inter-agente agents/runtime.go Agent{}: ensambla core + shell agents// agent.go (reglas puras) + config.yaml + prompts/system.md tools/ tool registry + tool implementations (subpackages) +tools/mcptools/ bridge: convierte MCP tools → tools.Tool internal/config/ schema.go + loader.go security/ grupos de usuarios/agentes + politicas de permisos (YAMLs) cmd/launcher/ entrypoint principal (rulesRegistry) diff --git a/agents/runtime.go b/agents/runtime.go index c4a1516..62a9ae2 100644 --- a/agents/runtime.go +++ b/agents/runtime.go @@ -29,6 +29,7 @@ import ( shellknowledge "github.com/enmanuel/agents/shell/knowledge" shelllm "github.com/enmanuel/agents/shell/llm" "github.com/enmanuel/agents/shell/matrix" + shellmcp "github.com/enmanuel/agents/shell/mcp" shellmem "github.com/enmanuel/agents/shell/memory" "github.com/enmanuel/agents/shell/ssh" "github.com/enmanuel/agents/tools" @@ -37,6 +38,7 @@ import ( toolhttp "github.com/enmanuel/agents/tools/http" toolknowledge "github.com/enmanuel/agents/tools/knowledgetools" toolmatrix "github.com/enmanuel/agents/tools/matrix" + toolmcp "github.com/enmanuel/agents/tools/mcptools" toolmemory "github.com/enmanuel/agents/tools/memorytools" toolssh "github.com/enmanuel/agents/tools/ssh" toolweather "github.com/enmanuel/agents/tools/weather" @@ -62,6 +64,7 @@ type Agent struct { toolReg *tools.Registry logger *slog.Logger cryptoStore io.Closer // non-nil when E2EE is enabled; closed on shutdown + mcpManager *shellmcp.Manager // nil when MCP client is disabled // Lifecycle — cancel stops this agent individually; done is closed when Run returns. cancel context.CancelFunc @@ -236,8 +239,20 @@ func New(cfg *config.AgentConfig, rules []decision.Rule, agentACL acl.ACL, logge logger.Info("acl enabled (centralized security policy)") } + // MCP client manager — connects to external MCP servers + var mcpManager *shellmcp.Manager + if cfg.Tools.MCP.Enabled && len(cfg.Tools.MCP.Servers) > 0 { + var mcpErr error + mcpManager, mcpErr = shellmcp.NewManager(context.Background(), cfg.Tools.MCP.Servers, logger) + if mcpErr != nil { + logger.Error("mcp_manager_init_failed", "err", mcpErr) + } else { + logger.Info("mcp manager initialized", "servers", len(cfg.Tools.MCP.Servers)) + } + } + // Tool registry — register tools enabled in config - toolReg := buildToolRegistry(cfg, sshExec, matrixClient, memStore, kStore, roomCtx, logger) + toolReg := buildToolRegistry(cfg, sshExec, matrixClient, memStore, kStore, mcpManager, roomCtx, logger) // Rate limiting for tools if cfg.Security.ToolRateLimit.Enabled { @@ -272,7 +287,8 @@ func New(cfg *config.AgentConfig, rules []decision.Rule, agentACL acl.ACL, logge toolReg: toolReg, logger: logger, cryptoStore: cryptoStore, - done: make(chan struct{}), + mcpManager: mcpManager, + done: make(chan struct{}), commands: make(map[string]CommandHandler), cmdAliases: command.BuiltinNames(), startTime: time.Now(), @@ -401,6 +417,9 @@ func (a *Agent) Run(ctx context.Context) error { if a.knowledgeStore != nil { defer a.knowledgeStore.Close() } + if a.mcpManager != nil { + defer a.mcpManager.Close() + } a.logger.Info("agent starting", "id", a.cfg.Agent.ID, "name", a.cfg.Agent.Name, @@ -980,6 +999,7 @@ func buildToolRegistry( matrixClient *matrix.Client, memStore memory.Store, kStore *shellknowledge.FileStore, + mcpManager *shellmcp.Manager, roomCtx *toolmemory.RoomContext, logger *slog.Logger, ) *tools.Registry { @@ -1031,5 +1051,30 @@ func buildToolRegistry( logger.Debug("registered knowledge tools") } + // MCP tools — register tools from all connected MCP servers + if mcpManager != nil { + for serverName, mcpClient := range mcpManager.AllClients() { + // Find the config for this server to get prefix, filter, timeout + var serverCfg *config.MCPServerCfg + for i := range cfg.Tools.MCP.Servers { + if cfg.Tools.MCP.Servers[i].Name == serverName { + serverCfg = &cfg.Tools.MCP.Servers[i] + break + } + } + if serverCfg == nil { + logger.Warn("no config found for MCP server", "name", serverName) + continue + } + + // Convert and register MCP tools + mcpTools := toolmcp.FromMCPServer(mcpClient, serverCfg.Prefix, serverCfg.Tools, serverCfg.Timeout, logger) + for _, tool := range mcpTools { + reg.Register(tool) + } + logger.Debug("registered MCP tools", "server", serverName, "count", len(mcpTools)) + } + } + return reg } diff --git a/dev/issues/README.md b/dev/issues/README.md index 57648cf..4f8ecbe 100644 --- a/dev/issues/README.md +++ b/dev/issues/README.md @@ -21,7 +21,7 @@ afectados y notas de implementacion. | 14 | Template agent standardize | [0014-template-agent-standardize.md](0014-template-agent-standardize.md) | pendiente | | 15 | Multi-platform Telegram | [0015-multi-platform-telegram.md](0015-multi-platform-telegram.md) | pendiente | | 16 | Skills system | [0016-skills-system.md](0016-skills-system.md) | pendiente | -| 17 | MCP client tools | [0017-mcp-client-tools.md](0017-mcp-client-tools.md) | pendiente | +| 17 | MCP client tools | [0017-mcp-client-tools.md](completed/0017-mcp-client-tools.md) | completado | | 18 | Shared knowledge | [0018-shared-knowledge.md](0018-shared-knowledge.md) | pendiente | | 19 | Prompt injection hardening | [0019-prompt-injection-hardening.md](completed/0019-prompt-injection-hardening.md) | completado | | 20 | Aislar claude -p del repo | [0020-claude-code-sandbox.md](completed/0020-claude-code-sandbox.md) | completado | diff --git a/dev/issues/0017-mcp-client-tools.md b/dev/issues/completed/0017-mcp-client-tools.md similarity index 100% rename from dev/issues/0017-mcp-client-tools.md rename to dev/issues/completed/0017-mcp-client-tools.md diff --git a/internal/config/schema.go b/internal/config/schema.go index de7c89f..a9258d1 100644 --- a/internal/config/schema.go +++ b/internal/config/schema.go @@ -178,9 +178,16 @@ type MCPToolCfg struct { } type MCPServerCfg struct { - Name string `yaml:"name"` - URL string `yaml:"url"` - Tools []string `yaml:"tools"` + Name string `yaml:"name"` // nombre logico del servidor + Transport string `yaml:"transport"` // "stdio" | "sse" (default: auto-detect) + Command string `yaml:"command"` // stdio: comando a ejecutar + Args []string `yaml:"args"` // stdio: argumentos del comando + Env map[string]string `yaml:"env"` // stdio: variables de entorno extra + URL string `yaml:"url"` // sse: URL del servidor + Headers map[string]string `yaml:"headers"` // sse: headers HTTP extra (auth, etc.) + Tools []string `yaml:"tools"` // filtro: solo exponer estas tools (vacio = todas) + Prefix string `yaml:"prefix"` // prefijo para nombres de tools (evitar colisiones) + Timeout time.Duration `yaml:"timeout"` // timeout por llamada (default: 30s) } type MCPExposeCfg struct { diff --git a/shell/mcp/client.go b/shell/mcp/client.go new file mode 100644 index 0000000..9e8b5d7 --- /dev/null +++ b/shell/mcp/client.go @@ -0,0 +1,159 @@ +// Package mcp provides MCP client and server implementations. +package mcp + +import ( + "context" + "fmt" + "log/slog" + "os" + "time" + + "github.com/mark3labs/mcp-go/client" + "github.com/mark3labs/mcp-go/mcp" +) + +// Client wraps an MCP client (stdio or SSE) and exposes discovered tools. +type Client struct { + name string + transport string // "stdio" | "sse" + mcpClient *client.Client + tools []mcp.Tool + logger *slog.Logger +} + +// NewStdioClient creates an MCP client that connects to a stdio-based MCP server. +func NewStdioClient(ctx context.Context, name, command string, args []string, env map[string]string, logger *slog.Logger) (*Client, error) { + logger.Info("creating stdio MCP client", "name", name, "command", command, "args", args) + + // Prepare environment + envSlice := os.Environ() + for k, v := range env { + envSlice = append(envSlice, fmt.Sprintf("%s=%s", k, v)) + } + + // Create stdio client + mcpClient, err := client.NewStdioMCPClient(command, envSlice, args...) + if err != nil { + return nil, fmt.Errorf("failed to create stdio client: %w", err) + } + + // Initialize + initReq := mcp.InitializeRequest{ + Params: mcp.InitializeParams{ + ProtocolVersion: mcp.LATEST_PROTOCOL_VERSION, + ClientInfo: mcp.Implementation{ + Name: "agents-mcp-client", + Version: "1.0.0", + }, + Capabilities: mcp.ClientCapabilities{}, + }, + } + + _, err = mcpClient.Initialize(ctx, initReq) + if err != nil { + mcpClient.Close() + return nil, fmt.Errorf("failed to initialize MCP client: %w", err) + } + + // Discover tools + toolsResp, err := mcpClient.ListTools(ctx, mcp.ListToolsRequest{}) + if err != nil { + mcpClient.Close() + return nil, fmt.Errorf("failed to list tools: %w", err) + } + + logger.Info("discovered MCP tools", "name", name, "count", len(toolsResp.Tools)) + + return &Client{ + name: name, + transport: "stdio", + mcpClient: mcpClient, + tools: toolsResp.Tools, + logger: logger, + }, nil +} + +// NewSSEClient creates an MCP client that connects to an SSE/HTTP-based MCP server. +func NewSSEClient(ctx context.Context, name, url string, headers map[string]string, logger *slog.Logger) (*Client, error) { + logger.Info("creating SSE MCP client", "name", name, "url", url) + + // Create SSE client (no custom headers support in basic API, would need transport options) + mcpClient, err := client.NewSSEMCPClient(url) + if err != nil { + return nil, fmt.Errorf("failed to create SSE client: %w", err) + } + + // Initialize + initReq := mcp.InitializeRequest{ + Params: mcp.InitializeParams{ + ProtocolVersion: mcp.LATEST_PROTOCOL_VERSION, + ClientInfo: mcp.Implementation{ + Name: "agents-mcp-client", + Version: "1.0.0", + }, + Capabilities: mcp.ClientCapabilities{}, + }, + } + + _, err = mcpClient.Initialize(ctx, initReq) + if err != nil { + mcpClient.Close() + return nil, fmt.Errorf("failed to initialize MCP client: %w", err) + } + + // Discover tools + toolsResp, err := mcpClient.ListTools(ctx, mcp.ListToolsRequest{}) + if err != nil { + mcpClient.Close() + return nil, fmt.Errorf("failed to list tools: %w", err) + } + + logger.Info("discovered MCP tools", "name", name, "count", len(toolsResp.Tools)) + + return &Client{ + name: name, + transport: "sse", + mcpClient: mcpClient, + tools: toolsResp.Tools, + logger: logger, + }, nil +} + +// Tools returns the discovered MCP tools. +func (c *Client) Tools() []mcp.Tool { + return c.tools +} + +// Name returns the client name. +func (c *Client) Name() string { + return c.name +} + +// CallTool invokes an MCP tool by name with the given arguments. +func (c *Client) CallTool(ctx context.Context, name string, args map[string]any, timeout time.Duration) (*mcp.CallToolResult, error) { + if timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, timeout) + defer cancel() + } + + req := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Name: name, + Arguments: args, + }, + } + + result, err := c.mcpClient.CallTool(ctx, req) + if err != nil { + return nil, fmt.Errorf("tool call failed: %w", err) + } + + return result, nil +} + +// Close closes the MCP client connection. +func (c *Client) Close() error { + c.logger.Info("closing MCP client", "name", c.name, "transport", c.transport) + return c.mcpClient.Close() +} diff --git a/shell/mcp/manager.go b/shell/mcp/manager.go new file mode 100644 index 0000000..666168e --- /dev/null +++ b/shell/mcp/manager.go @@ -0,0 +1,130 @@ +package mcp + +import ( + "context" + "fmt" + "log/slog" + "os" + "strings" + + "github.com/enmanuel/agents/internal/config" +) + +// Manager manages multiple MCP client connections. +type Manager struct { + clients map[string]*Client // server name → client + logger *slog.Logger +} + +// NewManager creates a new MCP manager and initializes all configured servers. +func NewManager(ctx context.Context, servers []config.MCPServerCfg, logger *slog.Logger) (*Manager, error) { + if logger == nil { + logger = slog.Default() + } + + m := &Manager{ + clients: make(map[string]*Client), + logger: logger, + } + + for _, serverCfg := range servers { + if err := m.addServer(ctx, serverCfg); err != nil { + // Close any already-created clients before returning error + m.Close() + return nil, fmt.Errorf("failed to initialize MCP server %q: %w", serverCfg.Name, err) + } + } + + logger.Info("MCP manager initialized", "servers", len(m.clients)) + return m, nil +} + +// addServer creates and adds a single MCP client to the manager. +func (m *Manager) addServer(ctx context.Context, cfg config.MCPServerCfg) error { + if cfg.Name == "" { + return fmt.Errorf("MCP server must have a name") + } + + // Auto-detect transport if not specified + transport := cfg.Transport + if transport == "" { + if cfg.Command != "" { + transport = "stdio" + } else if cfg.URL != "" { + transport = "sse" + } else { + return fmt.Errorf("MCP server %q must specify either command (stdio) or url (sse)", cfg.Name) + } + } + + var client *Client + var err error + + switch transport { + case "stdio": + if cfg.Command == "" { + return fmt.Errorf("MCP server %q with stdio transport must have a command", cfg.Name) + } + // Expand environment variables in command and args + command := os.ExpandEnv(cfg.Command) + args := make([]string, len(cfg.Args)) + for i, arg := range cfg.Args { + args[i] = os.ExpandEnv(arg) + } + // Expand env vars in environment map + env := make(map[string]string, len(cfg.Env)) + for k, v := range cfg.Env { + env[k] = os.ExpandEnv(v) + } + client, err = NewStdioClient(ctx, cfg.Name, command, args, env, m.logger) + + case "sse": + if cfg.URL == "" { + return fmt.Errorf("MCP server %q with sse transport must have a url", cfg.Name) + } + url := os.ExpandEnv(cfg.URL) + headers := make(map[string]string, len(cfg.Headers)) + for k, v := range cfg.Headers { + headers[k] = os.ExpandEnv(v) + } + client, err = NewSSEClient(ctx, cfg.Name, url, headers, m.logger) + + default: + return fmt.Errorf("unknown transport %q for MCP server %q (must be stdio or sse)", transport, cfg.Name) + } + + if err != nil { + return err + } + + m.clients[cfg.Name] = client + m.logger.Info("MCP server connected", "name", cfg.Name, "transport", transport, "tools", len(client.Tools())) + return nil +} + +// GetClient returns an MCP client by name, or nil if not found. +func (m *Manager) GetClient(name string) *Client { + return m.clients[name] +} + +// AllClients returns all MCP clients managed by this manager. +func (m *Manager) AllClients() map[string]*Client { + return m.clients +} + +// Close closes all MCP client connections. +func (m *Manager) Close() error { + var errs []string + for name, client := range m.clients { + if err := client.Close(); err != nil { + errs = append(errs, fmt.Sprintf("%s: %v", name, err)) + } + } + + if len(errs) > 0 { + return fmt.Errorf("errors closing MCP clients: %s", strings.Join(errs, "; ")) + } + + m.logger.Info("MCP manager closed", "servers", len(m.clients)) + return nil +} diff --git a/shell/protocols/mcp.go b/shell/mcp/server.go similarity index 93% rename from shell/protocols/mcp.go rename to shell/mcp/server.go index 7117487..d111e04 100644 --- a/shell/protocols/mcp.go +++ b/shell/mcp/server.go @@ -1,5 +1,5 @@ -// Package protocols contains adapters for external agent protocols. -package protocols +// Package mcp provides MCP client and server implementations. +package mcp import ( "context" diff --git a/tools/mcptools/mcp.go b/tools/mcptools/mcp.go new file mode 100644 index 0000000..06d2094 --- /dev/null +++ b/tools/mcptools/mcp.go @@ -0,0 +1,138 @@ +// Package mcptools provides bridges to convert MCP server tools into native agent tools. +package mcptools + +import ( + "context" + "fmt" + "log/slog" + "time" + + "github.com/mark3labs/mcp-go/mcp" + + shellmcp "github.com/enmanuel/agents/shell/mcp" + "github.com/enmanuel/agents/tools" +) + +// FromMCPServer converts tools from an MCP client into native agent tools. +// prefix is prepended to tool names to avoid collisions (e.g., "brave_" → "brave_web_search"). +// filter limits which tools to expose (empty = all tools). +// timeout is the default timeout for tool calls (0 = no timeout). +func FromMCPServer(mcpClient *shellmcp.Client, prefix string, filter []string, timeout time.Duration, logger *slog.Logger) []tools.Tool { + if timeout == 0 { + timeout = 30 * time.Second // default timeout + } + + mcpTools := mcpClient.Tools() + filterSet := make(map[string]bool) + for _, name := range filter { + filterSet[name] = true + } + + var result []tools.Tool + for _, mcpTool := range mcpTools { + // Apply filter if specified + if len(filterSet) > 0 && !filterSet[mcpTool.Name] { + continue + } + + // Convert MCP tool to native tool + toolName := prefix + mcpTool.Name + tool := convertMCPTool(mcpClient, mcpTool, toolName, timeout, logger) + result = append(result, tool) + } + + logger.Info("converted MCP tools", "server", mcpClient.Name(), "count", len(result)) + return result +} + +// convertMCPTool converts a single mcp.Tool to a tools.Tool. +func convertMCPTool(mcpClient *shellmcp.Client, mcpTool mcp.Tool, prefixedName string, timeout time.Duration, logger *slog.Logger) tools.Tool { + return tools.Tool{ + Def: tools.Def{ + Name: prefixedName, + Description: mcpTool.Description, + Parameters: convertSchema(mcpTool.InputSchema), + }, + Exec: func(ctx context.Context, args map[string]any) tools.Result { + // Call the MCP tool (using original name without prefix) + result, err := mcpClient.CallTool(ctx, mcpTool.Name, args, timeout) + if err != nil { + logger.Error("MCP tool call failed", "tool", mcpTool.Name, "error", err) + return tools.Result{Err: err} + } + + // Extract text from result + output := extractTextFromResult(result) + return tools.Result{Output: output} + }, + } +} + +// convertSchema converts an MCP InputSchema to agent tool Parameters. +func convertSchema(schema mcp.ToolInputSchema) []tools.Param { + var params []tools.Param + + // MCP schemas are JSON Schema objects with type: "object" and properties + if schema.Type != "object" || schema.Properties == nil { + return params + } + + requiredSet := make(map[string]bool) + for _, name := range schema.Required { + requiredSet[name] = true + } + + for propName, propVal := range schema.Properties { + param := tools.Param{ + Name: propName, + Required: requiredSet[propName], + } + + // Extract type and description from property schema + if propMap, ok := propVal.(map[string]any); ok { + if typeStr, ok := propMap["type"].(string); ok { + param.Type = typeStr + } + if desc, ok := propMap["description"].(string); ok { + param.Description = desc + } + } + + // Default to string if type not found + if param.Type == "" { + param.Type = "string" + } + + params = append(params, param) + } + + return params +} + +// extractTextFromResult extracts text content from an MCP CallToolResult. +func extractTextFromResult(result *mcp.CallToolResult) string { + if result == nil { + return "" + } + + var output string + for _, content := range result.Content { + // Handle different content types + switch c := content.(type) { + case mcp.TextContent: + output += c.Text + case *mcp.TextContent: + output += c.Text + default: + // For other content types (image, audio, resources), just indicate presence + output += fmt.Sprintf("[non-text content: %T]\n", content) + } + } + + // If result has IsError flag set, prepend error indicator + if result.IsError { + output = "[ERROR] " + output + } + + return output +}