czty's picture
Add files using upload-large-folder tool
a9e46a4 verified
Raw
History Blame Contribute Delete
4.49 kB
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:
# @mcp.tool / @mcp.tool()
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:
# 将常见 pipeline 逻辑名映射到具体 MCP 工具函数
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