| from __future__ import annotations |
|
|
| import ast |
| from dataclasses import dataclass |
| from pathlib import Path |
| import shutil |
|
|
|
|
| @dataclass(frozen=True) |
| class MCPToolRoute: |
| tool_name: str |
| function_name: str |
| binary_name: str |
| server_script: Path |
| server_dir: Path |
| image_name: str |
| service_name: str |
|
|
|
|
| class MCPToolRouter: |
| """Dynamic router for MCP tool servers under mcp-servers/.""" |
|
|
| def __init__(self, project_root: str | Path): |
| self.project_root = Path(project_root) |
| self.servers_root = self.project_root / "mcp-servers" |
| self.routes: dict[str, MCPToolRoute] = {} |
| self.refresh() |
|
|
| def refresh(self) -> None: |
| self.routes = {} |
| if not self.servers_root.exists(): |
| return |
|
|
| for script in sorted(self.servers_root.glob("mcp_*/app/*_server.py")): |
| server_dir = script.parent.parent |
| server_name = server_dir.name.replace("mcp_", "") |
| canonical_tool = server_name.replace("-", "_") |
| image_name = f"mcp-{server_name}:latest" |
| service_name = f"mcp-{server_name}" |
| for function_name in self._discover_mcp_functions(script): |
| route = MCPToolRoute( |
| tool_name=canonical_tool, |
| function_name=function_name, |
| binary_name=self._infer_binary_name(canonical_tool, function_name), |
| server_script=script, |
| server_dir=server_dir, |
| image_name=image_name, |
| service_name=service_name, |
| ) |
| |
| self.routes[function_name] = route |
| self.routes.setdefault(canonical_tool, route) |
| |
| self._register_aliases(route) |
|
|
| def resolve(self, tool_name: str) -> MCPToolRoute: |
| if tool_name not in self.routes: |
| raise KeyError(f"Unsupported tool route: {tool_name}") |
| return self.routes[tool_name] |
|
|
| def has_tool(self, tool_name: str) -> bool: |
| return tool_name in self.routes |
|
|
| def pick_first_available(self, candidates: list[str]) -> MCPToolRoute | None: |
| for name in candidates: |
| if name in self.routes: |
| return self.routes[name] |
| return None |
|
|
| def binary_available(self, tool_name: str) -> bool: |
| route = self.resolve(tool_name) |
| return shutil.which(route.binary_name) is not None |
|
|
| @staticmethod |
| def _discover_mcp_functions(script_path: Path) -> list[str]: |
| try: |
| tree = ast.parse(script_path.read_text(encoding="utf-8")) |
| except Exception: |
| return [] |
|
|
| functions: list[str] = [] |
| for node in tree.body: |
| if not isinstance(node, ast.FunctionDef): |
| continue |
| if any(MCPToolRouter._is_mcp_tool_decorator(d) for d in node.decorator_list): |
| functions.append(node.name) |
| return functions |
|
|
| @staticmethod |
| def _is_mcp_tool_decorator(decorator: ast.AST) -> bool: |
| |
| if isinstance(decorator, ast.Call): |
| decorator = decorator.func |
| return ( |
| isinstance(decorator, ast.Attribute) |
| and isinstance(decorator.value, ast.Name) |
| and decorator.value.id == "mcp" |
| and decorator.attr == "tool" |
| ) |
|
|
| @staticmethod |
| def _infer_binary_name(tool_name: str, function_name: str) -> str: |
| overrides = { |
| "trim_galore": "trim_galore", |
| "bowtie2_align": "bowtie2", |
| "bowtie2_build": "bowtie2-build", |
| "bowtie2_inspect": "bowtie2-inspect", |
| } |
| if function_name in overrides: |
| return overrides[function_name] |
| if tool_name in overrides: |
| return overrides[tool_name] |
| return tool_name.replace("_", "-") |
|
|
| def _register_aliases(self, route: MCPToolRoute) -> None: |
| |
| if route.function_name == "bowtie2_align": |
| self.routes["bowtie2"] = route |
| if route.function_name == "trim_galore": |
| self.routes["trim_galore"] = route |
| self.routes["trim-galore"] = route |
| if route.function_name == "fastqc": |
| self.routes["fastqc"] = route |
| if route.function_name == "multiqc": |
| self.routes["multiqc"] = route |
|
|