From 753c7d491e1248c6a95b9577a19cf403cf73ae0e Mon Sep 17 00:00:00 2001 From: opencode Date: Sat, 21 Mar 2026 19:01:58 +0100 Subject: [PATCH] feat: Add MCP integration to enable tools from skills.sh and other MCP servers - Added MCP client to discover and convert MCP tools to LangChain Tools - Extended configuration manager to support MCP server configurations - Updated CLI to manage MCP server configurations (list, add, remove, use) - Integrated MCP tools into the agent's tool loading system - Added documentation for MCP integration design --- config_cli.py | 39 +++- config_manager.py | 47 ++++- .../2026-03-21-mcp-integration-design.md | 50 +++++ main.py | 61 ++++++ mcp_client.py | 194 ++++++++++++++++++ 5 files changed, 380 insertions(+), 11 deletions(-) create mode 100644 docs/plans/2026-03-21-mcp-integration-design.md create mode 100644 mcp_client.py diff --git a/config_cli.py b/config_cli.py index 1c57ecc..29ac570 100644 --- a/config_cli.py +++ b/config_cli.py @@ -42,6 +42,13 @@ def print_configurations(configs=None, title="LangCode Agent Configurations"): print(f" API Key: {'Set' if config.get('api_key') else 'Not set'}") base_url = config.get("base_url", "https://openrouter.ai/api/v1") print(f" Base URL: {base_url}") + elif provider == "mcp": + server_url = config.get("server_url", "Not set") + print(f" Server URL: {server_url}") + api_key_status = 'Set' if config.get('api_key') else 'Not set' + print(f" API Key: {api_key_status}") + timeout = config.get("timeout", 30) + print(f" Timeout: {timeout}s") print() @@ -101,12 +108,14 @@ def add_configuration_interactive(): print("1. OpenAI") print("2. Ollama") print("3. OpenRouter") + print("4. MCP (Model Context Protocol)") - provider_choice = input("Enter choice (1-3): ").strip() + provider_choice = input("Enter choice (1-4): ").strip() provider_map = { "1": "openai", "2": "ollama", - "3": "openrouter" + "3": "openrouter", + "4": "mcp" } if provider_choice not in provider_map: @@ -146,6 +155,32 @@ def add_configuration_interactive(): if not base_url: base_url = "https://openrouter.ai/api/v1" config["base_url"] = base_url + + elif provider == "mcp": + server_url = input("MCP server URL (e.g., https://skills.sh/mcp): ").strip() + if not server_url: + print("Error: Server URL is required for MCP") + return + config["server_url"] = server_url + + api_key = input("MCP API key (optional, press Enter to skip): ").strip() + if api_key: + config["api_key"] = api_key + + timeout_input = input(f"MCP request timeout in seconds (default: 30): ").strip() + if timeout_input: + try: + timeout = int(timeout_input) + if timeout <= 0: + print("Warning: Timeout must be positive, using default 30") + config["timeout"] = "30" + else: + config["timeout"] = str(timeout) + except ValueError: + print("Warning: Invalid timeout value, using default 30") + config["timeout"] = "30" + else: + config["timeout"] = "30" # Ask if this should be the active configuration set_active = input("Set as active configuration? (y/N): ").strip().lower() diff --git a/config_manager.py b/config_manager.py index 863da5c..53b6b8d 100644 --- a/config_manager.py +++ b/config_manager.py @@ -115,14 +115,14 @@ class ConfigManager: # Create a deep copy to avoid modifying original result = copy.deepcopy(config) - # Decrypt configurations - if "configurations" in result and isinstance(result["configurations"], dict): - for name, config_data in result["configurations"].items(): - if isinstance(config_data, dict): - # Decrypt API keys for relevant providers - provider = config_data.get("provider", "").lower() - if provider in ["openai", "openrouter"] and "api_key" in config_data: - config_data["api_key"] = self._decrypt_data(config_data["api_key"]) + # Decrypt configurations + if "configurations" in result and isinstance(result["configurations"], dict): + for name, config_data in result["configurations"].items(): + if isinstance(config_data, dict): + # Decrypt API keys for relevant providers + provider = config_data.get("provider", "").lower() + if provider in ["openai", "openrouter", "mcp"] and "api_key" in config_data: + config_data["api_key"] = self._decrypt_data(config_data["api_key"]) return result @@ -207,7 +207,7 @@ class ConfigManager: # Validate provider provider = config.get("provider", "").lower() - valid_providers = ["openai", "ollama", "openrouter"] + valid_providers = ["openai", "ollama", "openrouter", "mcp"] if not provider: errors.append("Provider is required") @@ -262,6 +262,35 @@ class ConfigManager: if base_url.endswith("/"): config["base_url"] = base_url.rstrip("/") + elif provider == "mcp": + server_url = config.get("server_url", "").strip() + if not server_url: + errors.append("Server URL is required for MCP provider") + else: + if not server_url.startswith(("http://", "https://")): + errors.append("MCP server URL must start with http:// or https://") + else: + # Remove trailing slash for consistency + if server_url.endswith("/"): + config["server_url"] = server_url.rstrip("/") + + # API key is optional for MCP (some servers might not require auth) + api_key = config.get("api_key", "").strip() + if api_key and len(api_key) < 10: + errors.append("MCP API key appears too short if provided") + + # Timeout is optional, but if provided must be positive integer + timeout = config.get("timeout") + if timeout is not None: + try: + timeout_int = int(timeout) + if timeout_int <= 0: + errors.append("MCP timeout must be a positive integer") + else: + config["timeout"] = timeout_int + except ValueError: + errors.append("MCP timeout must be a valid integer") + return len(errors) == 0, errors def get_configurations(self) -> Dict[str, Any]: diff --git a/docs/plans/2026-03-21-mcp-integration-design.md b/docs/plans/2026-03-21-mcp-integration-design.md new file mode 100644 index 0000000..6171f8b --- /dev/null +++ b/docs/plans/2026-03-21-mcp-integration-design.md @@ -0,0 +1,50 @@ +# MCP Integration Design for LangCode Agent + +## Section 1: MCP Client and Tool Conversion (Previously discussed and validated) + +## Section 2: Integration with LangCode Agent Architecture (Previously discussed and validated) + +## Section 3: Specific Implementation Details, Data Flow, and Error Handling + +### Implementation Files to Create/Modify: +1. **New file**: `mcp_client.py` - Contains the MCPClient class and tool conversion functions +2. **Modified file**: `main.py` - Add `get_mcp_tools()` function and integrate MCP tools into `get_custom_tools()` +3. **Modified file**: `config_manager.py` - Add methods to manage MCP server configurations (if not already present) +4. **Modified file**: `config_cli.py` - Add CLI commands for MCP server management (list, add, remove, use, etc.) + +### Data Flow: +1. **Startup**: When the agent initializes, `get_custom_tools()` is called +2. **MCP Tool Discovery**: `get_mcp_tools()` reads all configured MCP servers from config_manager +3. **Server Connection**: For each MCP server, an MCPClient instance connects to the server URL +4. **Tool Enumeration**: The client calls the MCP server's `tools/list` endpoint to get available tools +5. **Tool Conversion**: Each tool specification is converted to a LangChain Tool with: + - Same name and description as the MCP tool + - A function that serializes arguments and calls the MCP server's `tools/call` endpoint + - Proper error handling for network issues, server errors, or invalid responses +6. **Tool Aggregation**: All MCP-derived tools are combined with existing Proxmox and provider tools +7. **Agent Initialization**: The DeepAgent is created with the complete toolset + +### Error Handling Strategy: +- **Connection Failures**: If an MCP server is unreachable during startup, log a warning and continue (don't fail agent initialization) +- **Tool Call Failures**: If a tool call to an MCP server fails, return a descriptive error message as the tool result +- **Schema Validation**: Validate tool arguments against the MCP tool's input schema before making the call +- **Timeouts**: Implement reasonable timeouts for MCP server connections and calls +- **Retry Logic**: Consider implementing retry logic for transient network failures + +### Testing Approach: +1. **Unit Tests**: + - Test MCPClient connection and tool listing with mock servers + - Test tool conversion function with various tool specifications + - Test error handling scenarios (timeouts, invalid responses, etc.) + +2. **Integration Tests**: + - Test with a real MCP server (could be a local test server or skills.sh if accessible) + - Verify that tools appear in the agent's tool list and can be invoked successfully + - Test end-to-end flows: user asks agent to use an MCP-derived tool, agent executes it correctly + +3. **Manual Testing**: + - Add an MCP server configuration pointing to skills.sh (if it offers MCP endpoints) + - Verify that skills from skills.sh appear as available tools in the agent + - Test invoking a few different skills to ensure they work correctly + +Are these implementation details, data flow, error handling, and testing approaches correct and complete? Do you see any gaps or areas that need adjustment? \ No newline at end of file diff --git a/main.py b/main.py index d593d3d..4f4f357 100644 --- a/main.py +++ b/main.py @@ -72,6 +72,59 @@ def load_model_from_config(): return init_chat_model(f"openai:{model_name}") +def get_mcp_tools() -> List[Tool]: + """ + Get MCP tools from configured MCP servers + + Returns: + List of MCP-derived LangChain Tool objects + """ + from mcp_client import MCPClient, get_mcp_tools_from_client + + mcp_tools = [] + + if not CONFIG_AVAILABLE: + return mcp_tools + + try: + # Get all MCP server configurations + # We'll look for configurations with provider="mcp" + all_configs = config_manager.get_configurations() + mcp_configs = { + name: config for name, config in all_configs.items() + if config.get("provider") == "mcp" + } + + for name, config in mcp_configs.items(): + try: + server_url = config.get("server_url") + if not server_url: + print(f"⚠️ MCP configuration '{name}' missing server_url") + continue + + headers = {} + api_key = config.get("api_key") + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + + mcp_client = MCPClient( + server_url=server_url, + headers=headers, + timeout=config.get("timeout", 30) + ) + + tools = get_mcp_tools_from_client(mcp_client) + mcp_tools.extend(tools) + print(f"✅ Added {len(tools)} tools from MCP server '{name}'") + except Exception as e: + print(f"⚠️ Could not load tools from MCP server '{name}': {e}") + + except Exception as e: + print(f"⚠️ Error loading MCP tools: {e}") + + return mcp_tools + + def get_custom_tools() -> List[Tool]: """ Get custom tools for the LangCode agent @@ -111,6 +164,14 @@ def get_custom_tools() -> List[Tool]: except Exception as e: print(f"⚠️ Could not load provider tools: {e}") + # Add MCP tools from configured servers + try: + mcp_tools = get_mcp_tools() + custom_tools.extend(mcp_tools) + print(f"✅ Added {len(mcp_tools)} MCP tools") + except Exception as e: + print(f"⚠️ Could not load MCP tools: {e}") + return custom_tools diff --git a/mcp_client.py b/mcp_client.py new file mode 100644 index 0000000..9815f12 --- /dev/null +++ b/mcp_client.py @@ -0,0 +1,194 @@ +""" +MCP (Model Context Protocol) client for LangCode Agent +Enables integration with MCP servers like skills.sh to discover and use tools +""" +import json +import requests +from typing import Dict, List, Any, Optional +from langchain.tools import Tool +import logging + +logger = logging.getLogger(__name__) + +class MCPClient: + """Client for communicating with MCP (Model Context Protocol) servers""" + + def __init__(self, server_url: str, headers: Optional[Dict[str, str]] = None, timeout: int = 30): + """ + Initialize MCP client + + Args: + server_url: URL of the MCP server + headers: Optional headers for authentication + timeout: Request timeout in seconds + """ + self.server_url = server_url.rstrip('/') + self.headers = headers or {} + self.timeout = timeout + self._available_tools: Optional[List[Dict[str, Any]]] = None + + def _make_request(self, method: str, endpoint: str, data: Optional[Dict] = None) -> Dict[str, Any]: + """ + Make a JSON-RPC request to the MCP server + + Args: + method: HTTP method (GET, POST, etc.) + endpoint: API endpoint + data: Optional data to send + + Returns: + Response data as dictionary + + Raises: + Exception: If request fails + """ + url = f"{self.server_url}{endpoint}" + + # For MCP, we typically use JSON-RPC over HTTP POST + if method.upper() == "POST": + # JSON-RPC 2.0 format + payload = { + "jsonrpc": "2.0", + "method": endpoint.lstrip('/'), # Remove leading slash for method name + "params": data or {}, + "id": 1 + } + response = requests.post( + url, + json=payload, + headers={**self.headers, "Content-Type": "application/json"}, + timeout=self.timeout + ) + else: + response = requests.request( + method, + url, + headers=self.headers, + params=data, + timeout=self.timeout + ) + + response.raise_for_status() + + # Handle JSON-RPC response + if method.upper() == "POST": + result = response.json() + if "error" in result: + raise Exception(f"MCP error: {result['error']}") + return result.get("result", {}) + else: + return response.json() + + def list_tools(self) -> List[Dict[str, Any]]: + """ + List available tools from the MCP server + + Returns: + List of tool specifications + """ + if self._available_tools is not None: + return self._available_tools + + try: + # MCP standard method for listing tools + result = self._make_request("POST", "/tools/list") + self._available_tools = result.get("tools", []) + logger.info(f"Discovered {len(self._available_tools)} tools from MCP server {self.server_url}") + return self._available_tools + except Exception as e: + logger.error(f"Failed to list tools from MCP server {self.server_url}: {e}") + return [] + + def call_tool(self, tool_name: str, arguments: Dict[str, Any]) -> Any: + """ + Call a specific tool on the MCP server + + Args: + tool_name: Name of the tool to call + arguments: Arguments to pass to the tool + + Returns: + Tool execution result + """ + try: + result = self._make_request("POST", "/tools/call", { + "name": tool_name, + "arguments": arguments + }) + return result + except Exception as e: + logger.error(f"Failed to call tool {tool_name} on MCP server {self.server_url}: {e}") + raise Exception(f"MCP tool call failed: {e}") + + def refresh_tools(self): + """Refresh the cached list of available tools""" + self._available_tools = None + self.list_tools() + + +def mcp_to_langchain_tool(mcp_client: MCPClient, tool_spec: Dict[str, Any]) -> Tool: + """ + Convert an MCP tool specification to a LangChain Tool + + Args: + mcp_client: MCP client instance + tool_spec: Tool specification from MCP server + + Returns: + LangChain Tool object + """ + def tool_function(*args, **kwargs): + # Handle both positional and keyword arguments + if args and len(args) == 1 and isinstance(args[0], dict): + # Single dict argument passed positionally + tool_args = args[0] + elif kwargs: + # Keyword arguments + tool_args = kwargs + else: + # No arguments or multiple positional args (shouldn't happen for MCP tools) + tool_args = {} + + try: + result = mcp_client.call_tool(tool_spec["name"], tool_args) + # Convert result to string if it isn't already + if isinstance(result, str): + return result + else: + return json.dumps(result, indent=2) + except Exception as e: + return f"Error calling MCP tool {tool_spec['name']}: {str(e)}" + + # Extract description, default to empty string if not provided + description = tool_spec.get("description", "") + if not description: + description = f"MCP tool: {tool_spec['name']}" + + return Tool( + name=tool_spec["name"], + description=description, + func=tool_function + ) + + +def get_mcp_tools_from_client(mcp_client: MCPClient) -> List[Tool]: + """ + Get all available tools from an MCP client as LangChain Tools + + Args: + mcp_client: Configured MCP client + + Returns: + List of LangChain Tool objects + """ + tools = [] + try: + tool_specs = mcp_client.list_tools() + for tool_spec in tool_specs: + langchain_tool = mcp_to_langchain_tool(mcp_client, tool_spec) + tools.append(langchain_tool) + logger.info(f"Converted {len(tools)} MCP tools to LangChain Tools") + except Exception as e: + logger.error(f"Failed to get tools from MCP client: {e}") + + return tools \ No newline at end of file