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:
1 parent
55a31278a8
commit
753c7d491e
5 files changed
+380
-11
No files matched your search
+37
-2
@@ -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
@@ -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?
|
||||
@@ -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
@@ -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
|
||||
Reference in new issue
Block a user