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
This commit is contained in:
opencode committed 2026-03-21 19:01:58 +01:00
1 parent 55a31278a8
commit 753c7d491e
5 files changed
+380 -11

No files matched your search

+37 -2
View File
@@ -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()
+38 -9
View File
@@ -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]:
@@ -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?
+61
View File
@@ -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
+194
View File
@@ -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