197 lines
8.5 KiB
Python
197 lines
8.5 KiB
Python
"""Adapter layer between the CodingAgent and one or more MCP tool servers.
|
|
|
|
MCPToolAdapter reads a JSON config file that lists MCP server processes, spawns
|
|
each process via stdio, queries its available tools, and stores them in a flat
|
|
registry. At call time it re-spawns the appropriate server process, executes
|
|
the requested tool, and returns the raw MCP result object.
|
|
|
|
Design note: connections are opened per-call (not kept alive) because Streamlit
|
|
reruns make it impractical to maintain long-lived async context managers across
|
|
the synchronous/asynchronous boundary.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import sys
|
|
from typing import List, Dict, Any
|
|
from pathlib import Path
|
|
|
|
from mcp import ClientSession, StdioServerParameters
|
|
from mcp.client.stdio import stdio_client
|
|
|
|
from backend.managers.debug_logger import get_logger
|
|
logger = get_logger(__name__)
|
|
|
|
class MCPToolAdapter:
|
|
"""Discovers and dispatches MCP tools from one or more stdio-based MCP servers.
|
|
|
|
Workflow:
|
|
1. Call ``initialize_all_servers()`` once at startup to populate the
|
|
tool registry from every server listed in the config file.
|
|
2. Call ``get_all_tools()`` to retrieve the registry for building the
|
|
system-prompt tool description.
|
|
3. Call ``call_tool(name, arguments)`` whenever the agent wants to
|
|
execute a tool. The adapter resolves the owning server, opens a
|
|
fresh connection, and returns the MCP result object.
|
|
|
|
Attributes:
|
|
config_path: Path (relative to this file) of the JSON server config.
|
|
servers: Dict mapping server name → raw config params dict.
|
|
tool_registry: Flat list of registered tool dicts, each containing
|
|
"server", "tool_name", and "tool_description".
|
|
"""
|
|
|
|
def __init__(self, config_path: str = "mcp_server_config.json"):
|
|
self.config_path = config_path
|
|
self.servers: Dict[str, Dict] = {}
|
|
self.tool_registry: List[Dict[str, Any]] = []
|
|
|
|
def _load_config(self) -> Dict[str, Any]:
|
|
"""Load the MCP server configuration from the JSON file next to this module.
|
|
|
|
Returns:
|
|
Parsed config dict, or an empty dict if the file is missing or invalid.
|
|
"""
|
|
path = Path(__file__).parent / self.config_path
|
|
if not path.exists():
|
|
logger.warning("Config file not found: %s", path)
|
|
return {}
|
|
|
|
try:
|
|
with open(path, 'r') as f:
|
|
config_file = json.load(f)
|
|
logger.info("MCP-Server config loaded successfully")
|
|
return config_file
|
|
except json.JSONDecodeError as e:
|
|
logger.critical("Error decoding JSON from server config: %s", e)
|
|
return {}
|
|
|
|
async def initialize_all_servers(self):
|
|
"""Lädt die Konfiguration und fragt alle Server ab, um die Tools zu registrieren."""
|
|
config = self._load_config()
|
|
logger.info("Loaded config for servers: %s", list(config.keys()))
|
|
|
|
for server_name, params in config.items():
|
|
logger.info("Initializing connection to %s", server_name)
|
|
|
|
self.servers[server_name] = params
|
|
server_script = str(Path(__file__).parent / params["args"][0])
|
|
|
|
# Always use the current Python interpreter so the server runs in the
|
|
# same virtual environment as the adapter, regardless of the literal
|
|
# command string in the config ("py", "python", "python3").
|
|
if params.get("command") in ["py", "python", "python3"]:
|
|
server_command = sys.executable
|
|
else:
|
|
server_command = params["command"]
|
|
|
|
server_params = StdioServerParameters(
|
|
command=server_command,
|
|
args=[server_script],
|
|
)
|
|
|
|
try:
|
|
async with stdio_client(server_params) as (read_stream, write_stream):
|
|
logger.info("Connected to %s. Initializing session...", server_name)
|
|
async with ClientSession(read_stream, write_stream) as session:
|
|
await session.initialize()
|
|
logger.info("Session initialized for %s. Requesting tools...", server_name)
|
|
result = await session.list_tools()
|
|
# REVIEW: debug print — remove before shipping.
|
|
print(f"Tools received from {server_name}: {result}")
|
|
tools = result.tools
|
|
logger.info(f"Tools received from %s: %s Tools", server_name, str(len(tools)))
|
|
|
|
for tool in tools:
|
|
# Build a human-readable parameter description for the system prompt.
|
|
t_params = tool.inputSchema.get("properties", {})
|
|
if t_params:
|
|
param_lines = []
|
|
for pname, pinfo in t_params.items():
|
|
ptype = pinfo.get("type", "any")
|
|
pdesc = pinfo.get("description", "")
|
|
param_lines.append(f" - {pname} ({ptype}): {pdesc}")
|
|
param_str = "\n".join(param_lines)
|
|
else:
|
|
param_str = " (none)"
|
|
|
|
t_definition = f"- {tool.name}: {tool.description}\nParameters:\n{param_str}"
|
|
|
|
self.tool_registry.append({
|
|
"server": server_name,
|
|
"tool_name": tool.name,
|
|
"tool_description": t_definition
|
|
})
|
|
|
|
logger.info("Registered tool '%s' from %s.", tool.name, server_name)
|
|
|
|
except Exception as e:
|
|
logger.exception("Failed to initialize %s: %s", server_name, str(e))
|
|
|
|
def get_all_tools(self) -> List[Dict[str, Any]]:
|
|
"""Return the full list of registered tools across all servers.
|
|
|
|
Returns:
|
|
List of dicts, each with keys "server", "tool_name", "tool_description".
|
|
"""
|
|
return self.tool_registry
|
|
|
|
async def call_tool(self, tool_name: str, arguments: Dict[str, Any]):
|
|
"""Look up a tool in the registry, connect to its server, and execute it.
|
|
|
|
Opens a fresh stdio connection for every call. This is intentionally
|
|
stateless so that server crashes or restarts are fully transparent.
|
|
|
|
Args:
|
|
tool_name: Name of the tool to call (must be in the registry).
|
|
arguments: Key-value arguments passed verbatim to the MCP server.
|
|
|
|
Returns:
|
|
The raw MCP ``CallToolResult`` object on success, or an error string
|
|
if the tool is not found or the server raises an exception.
|
|
"""
|
|
# Look up which server owns this tool.
|
|
tool_entry = next((t for t in self.tool_registry if t["tool_name"] == tool_name), None)
|
|
|
|
if not tool_entry:
|
|
logger.warning("Tool '%s' not found in MCP adapter registry.", tool_name)
|
|
return f"Error: Tool '{tool_name}' not found in registry."
|
|
|
|
server_name = tool_entry["server"]
|
|
s_params = self.servers.get(server_name)
|
|
|
|
if s_params:
|
|
server_script = str(Path(__file__).parent / s_params["args"][0])
|
|
# Normalise the interpreter command the same way as in initialize_all_servers().
|
|
if s_params.get("command") in ["py", "python", "python3"]:
|
|
server_command = sys.executable
|
|
else:
|
|
server_command = s_params["command"]
|
|
|
|
server_params = StdioServerParameters(
|
|
command=server_command,
|
|
args=[server_script],
|
|
)
|
|
|
|
try:
|
|
async with stdio_client(server_params) as (read_stream, write_stream):
|
|
async with ClientSession(read_stream, write_stream) as session:
|
|
await session.initialize()
|
|
logger.info("Session successfully initialized, calling tool '%s' on server '%s", tool_name, server_name)
|
|
result = await session.call_tool(tool_name, arguments)
|
|
return result
|
|
except Exception as e:
|
|
logger.exception("Error calling tool '%s' on server '%s': %s", tool_name, server_name, str(e))
|
|
return f"Error calling tool '{tool_name}' on server '{server_name}': {str(e)}"
|
|
|
|
def main():
|
|
"""Debug Function for Tool-Registry"""
|
|
adapter = MCPToolAdapter()
|
|
asyncio.run(adapter.initialize_all_servers())
|
|
print("All servers initialized. Registered tools:")
|
|
for tool in adapter.get_all_tools():
|
|
print(f"- {tool['tool_name']} (from {tool['server']})")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main() |