| import os |
| import argparse |
| import subprocess |
| import json |
| from pathlib import Path |
| from typing import Dict, List, Optional |
|
|
| from bioinfomcp_converter import BioinfoMCP |
|
|
|
|
| def generate_requirements_with_pipreqs(tool_name, server_path): |
| """ |
| Use pipreqs to generate requirements.txt (fallback to minimal deps when pipreqs fails). |
| """ |
| _ = tool_name |
| app_path = Path(server_path) / "app" |
| target_path = app_path if app_path.exists() else Path(server_path) |
| try: |
| subprocess.run( |
| ["pipreqs", str(target_path), "--force"], |
| capture_output=True, |
| text=True, |
| check=True, |
| ) |
| print(f"Generated requirements.txt using pipreqs from: {target_path}") |
| except FileNotFoundError: |
| req_path = Path(server_path) / "requirements.txt" |
| req_path.write_text("fastmcp\n", encoding="utf-8") |
| print("pipreqs not found, generated fallback requirements.txt") |
| except subprocess.CalledProcessError as exc: |
| req_path = Path(server_path) / "requirements.txt" |
| req_path.write_text("fastmcp\n", encoding="utf-8") |
| stderr = (exc.stderr or "").strip() |
| stdout = (exc.stdout or "").strip() |
| detail = stderr or stdout or "no stderr/stdout" |
| print(f"pipreqs failed ({detail}), generated fallback requirements.txt") |
| except Exception as exc: |
| req_path = Path(server_path) / "requirements.txt" |
| req_path.write_text("fastmcp\n", encoding="utf-8") |
| print(f"pipreqs failed ({exc}), generated fallback requirements.txt") |
| return Path(server_path) / "requirements.txt" |
|
|
| def generate_environment_yaml(tool_name, server_path): |
| """ |
| generate environment.yaml |
| """ |
| result = f""" |
| name: mcp-tool |
| channels: |
| - bioconda |
| - conda-forge |
| - defaults |
| dependencies: |
| - {tool_name} |
| - python=3.10 |
| """ |
| with open(server_path / "environment.yaml", "w") as f: |
| f.write(result) |
| print("Generated environment.yaml") |
| return Path(server_path) / "environment.yaml" |
|
|
| def generate_environment_yml(tool_name, server_path): |
| """ |
| Generate requirements.yml |
| """ |
| _ = server_path |
| result = f"""name: {tool_name}_env |
| channels: |
| - bioconda |
| - conda-forge |
| dependencies: |
| - python=3.10 |
| - {tool_name} |
| - pip |
| """ |
| return result |
|
|
| def parse_bool(value) -> bool: |
| if isinstance(value, bool): |
| return value |
| if value is None: |
| return False |
| return str(value).strip().lower() in ("1", "true", "yes", "y", "on") |
|
|
|
|
| def has_existing_server(output_root: Path, tool_name: str) -> Optional[Path]: |
| server_file = output_root / f"mcp_{tool_name}" / "app" / f"{tool_name}_server.py" |
| if server_file.exists() and server_file.is_file() and server_file.stat().st_size > 0: |
| return server_file |
| return None |
|
|
|
|
| def convert_mcptool(tool_name, manual, run_help_command, server_path, converter=None): |
| converter = converter or BioinfoMCP() |
| app_path = Path(server_path) / "app" |
| app_path.mkdir(parents=True, exist_ok=True) |
|
|
| conversion_log: Dict = { |
| "tool_name": tool_name, |
| "manual": manual, |
| "run_help_command": bool(run_help_command), |
| "status": "failed", |
| "error": "", |
| "output_file": "", |
| } |
|
|
| try: |
| conv_result = converter.autogenerate_mcp_tool(tool_name, manual, run_help_command) |
| except Exception as exc: |
| conversion_log["error"] = f"autogenerate_mcp_tool failed: {exc}" |
| return conversion_log |
| retry = 0 |
| while not conv_result[0] and retry < 6: |
| retry += 1 |
| if conv_result[2] is None: |
| try: |
| conv_result = converter.autogenerate_mcp_tool(tool_name, manual, run_help_command) |
| print(f"{tool_name}: regenerate ({retry})") |
| except Exception as exc: |
| conversion_log["error"] = f"regenerate failed (retry={retry}): {exc}" |
| return conversion_log |
| else: |
| try: |
| conv_result = converter.refine_after_feedback(tool_name, code=conv_result[2], error_message=conv_result[1]) |
| print(f"{tool_name}: refine ({retry})") |
| except Exception as exc: |
| conversion_log["error"] = f"refine failed (retry={retry}): {exc}" |
| return conversion_log |
|
|
| if not conv_result[0] or conv_result[2] is None: |
| conversion_log["error"] = conv_result[1] or "Unknown conversion failure" |
| return conversion_log |
|
|
| output_path = Path(server_path) / "app" / f"{tool_name}_server.py" |
| output_path.write_text(conv_result[2], encoding="utf-8") |
| conversion_log["status"] = "success" |
| conversion_log["output_file"] = str(output_path) |
| return conversion_log |
|
|
|
|
| def load_tool_list_from_json(json_path: Path, top_k: int = 0, domains: Optional[List[str]] = None) -> List[str]: |
| with open(json_path, "r", encoding="utf-8") as f: |
| rows = json.load(f) |
| if not isinstance(rows, list): |
| raise ValueError(f"Expected list JSON: {json_path}") |
|
|
| domains = [d.strip().lower() for d in (domains or []) if d.strip()] |
| tools: List[str] = [] |
| for row in rows: |
| if not isinstance(row, dict): |
| continue |
| if domains: |
| domain = str(row.get("domain", "")).strip().lower() |
| if domain not in domains: |
| continue |
| package_name = str(row.get("package_name", "")).strip() |
| software_name = str(row.get("software_name", "")).strip() |
| tool_name = package_name or software_name |
| if tool_name: |
| tools.append(tool_name) |
|
|
| deduped = list(dict.fromkeys(tools)) |
| if top_k > 0: |
| deduped = deduped[:top_k] |
| return deduped |
|
|
|
|
| def load_jobs_from_json(json_path: Path, top_k: int = 0, domains: Optional[List[str]] = None) -> List[Dict]: |
| with open(json_path, "r", encoding="utf-8") as f: |
| rows = json.load(f) |
| if not isinstance(rows, list): |
| raise ValueError(f"Expected list JSON: {json_path}") |
|
|
| domains = [d.strip().lower() for d in (domains or []) if d.strip()] |
| jobs: List[Dict] = [] |
| for row in rows: |
| if not isinstance(row, dict): |
| continue |
| name = ( |
| str(row.get("name", "")).strip() |
| or str(row.get("package_name", "")).strip() |
| or str(row.get("software_name", "")).strip() |
| ) |
| if not name: |
| continue |
| domain = str(row.get("domain", "")).strip().lower() |
| if domains and domain not in domains: |
| continue |
| jobs.append( |
| { |
| "name": name, |
| "manual": str(row.get("manual", "--help")), |
| "run_help_command": parse_bool(row.get("run_help_command", True)), |
| "domain": domain, |
| "tier": str(row.get("tier", "")), |
| } |
| ) |
| if top_k > 0: |
| jobs = jobs[:top_k] |
| return jobs |
|
|
|
|
| def load_jobs_from_manual_dir(manual_dir: Path, top_k: int = 0) -> List[Dict]: |
| """ |
| Load conversion jobs from a directory containing *.help.txt or *.manual_bundle.txt. |
| """ |
| if not manual_dir.exists(): |
| raise FileNotFoundError(f"Manual directory not found: {manual_dir}") |
|
|
| candidates = sorted( |
| list(manual_dir.glob("*.help.txt")) + list(manual_dir.glob("*.manual_bundle.txt")) |
| ) |
| |
| by_tool: Dict[str, Path] = {} |
| for p in candidates: |
| name = p.name |
| if name.endswith(".manual_bundle.txt"): |
| tool_name = name[: -len(".manual_bundle.txt")] |
| by_tool[tool_name] = p |
| elif name.endswith(".help.txt"): |
| tool_name = name[: -len(".help.txt")] |
| by_tool.setdefault(tool_name, p) |
|
|
| jobs: List[Dict] = [] |
| for tool_name, manual_path in sorted(by_tool.items()): |
| jobs.append( |
| { |
| "name": tool_name, |
| "manual": str(manual_path), |
| "run_help_command": False, |
| "domain": "", |
| "tier": "", |
| } |
| ) |
| if top_k > 0: |
| jobs = jobs[:top_k] |
| return jobs |
|
|
| def select_jobs_for_shard(jobs: List[Dict], shard_count: int, shard_index: int) -> List[Dict]: |
| """ |
| Deterministically split jobs for multi-terminal parallel execution. |
| """ |
| if shard_count <= 1: |
| return jobs |
| if shard_index < 0 or shard_index >= shard_count: |
| raise ValueError(f"Invalid shard_index={shard_index}. Expected 0 <= shard_index < {shard_count}") |
| selected: List[Dict] = [] |
| for idx, job in enumerate(jobs): |
| if idx % shard_count == shard_index: |
| selected.append(job) |
| return selected |
|
|
| def dockerfile_content(tool_name): |
| return f""" |
| FROM python:3.10-slim |
| |
| # Install system dependencies |
| RUN apt-get update && \ |
| apt-get install -y \ |
| default-jre \ |
| wget \ |
| curl \ |
| && apt-get clean \ |
| && rm -rf /var/lib/apt/lists/* |
| |
| # Install Miniconda |
| RUN wget https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh -O /tmp/miniconda.sh && \ |
| bash /tmp/miniconda.sh -b -p /opt/conda && \ |
| rm /tmp/miniconda.sh |
| |
| # Add conda to PATH |
| ENV PATH="/opt/conda/bin:$PATH" |
| |
| # Install {tool_name} via conda (e.g., from bioconda) |
| RUN conda install -c bioconda {tool_name} -y && \ |
| conda clean -a |
| |
| # Install Python dependencies |
| RUN pip install uv |
| RUN uv pip install --system fastmcp |
| |
| # Create app directory |
| WORKDIR /app |
| |
| # Copy your MCP server |
| COPY app/{tool_name}_server.py /app/ |
| |
| # Create workspace and output directories |
| RUN mkdir -p /app/workspace /app/output |
| |
| # Make sure the server script is executable |
| RUN chmod +x /app/{tool_name}_server.py |
| |
| # Expose port for MCP over HTTP (optional) |
| EXPOSE 8000 |
| |
| # Health check |
| HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ |
| CMD python -c "import sys; sys.exit(0)" |
| |
| # Default command runs the MCP server via stdio |
| CMD ["python", "/app/{tool_name}_server.py"] |
| """ |
|
|
| def dockercompose_content(tool_name): |
| """Generate docker-compose.yml for easy deployment""" |
| |
| compose = f"""version: '3.8' |
| |
| services: |
| mcp-{tool_name}: |
| build: . |
| image: mcp-{tool_name}:latest |
| container_name: mcp-{tool_name} |
| ports: |
| - "8000:8000" |
| environment: |
| - MCP_SERVER_NAME={tool_name} |
| volumes: |
| - ./workspace:/app/workspace |
| - ./output:/app/output |
| restart: unless-stopped |
| healthcheck: |
| test: ["CMD", "python", "-c", "import sys; sys.exit(0)"] |
| interval: 30s |
| timeout: 10s |
| retries: 3 |
| start_period: 5s |
| """ |
|
|
| return compose |
|
|
| def image_name_for_tool(tool_name: str) -> str: |
| return f"mcp-{tool_name}:latest" |
|
|
|
|
| def build_docker_image(tool_name, server_path, output_path, is_pipeline): |
|
|
| |
| try: |
| _ = output_path |
| df_content = dockerfile_content(tool_name) |
| with open(server_path / "Dockerfile", "w") as f: |
| f.write(df_content) |
| |
| |
| if not is_pipeline: |
| dc_content = dockercompose_content(tool_name) |
| with open(server_path/"docker-compose.yml", "w") as f: |
| f.write(dc_content) |
| |
| |
|
|
| return 1 |
| except: |
| return 0 |
|
|
|
|
| def claude_addition(tool_name, server_path): |
| image_name = image_name_for_tool(tool_name) |
| workspace_path = str((Path(server_path) / "workspace").resolve()) |
| config = { |
| "mcpServers": { |
| tool_name: { |
| "command": "docker", |
| "args": [ |
| "run", |
| "--rm", |
| "-i", |
| "-v", |
| f"{workspace_path}:/app/workspace", |
| image_name |
| ] |
| } |
| } |
| } |
| return json.dumps(config, indent=2) |
|
|
|
|
| if __name__ == '__main__': |
| parser = argparse.ArgumentParser("Accept the Bioinformatic tool name and the help document") |
| parser.add_argument('--model', type=str, default='openai', choices=['openai', 'azure', 'gemini'], help="LLM backend") |
| parser.add_argument('--name', type=str) |
| parser.add_argument('--manual', type=str, default='--help', help="The file path to the help document or help flag") |
| parser.add_argument('--run_help_command', type=str, default='True') |
| parser.add_argument('--output_location', type=str, required=True) |
| parser.add_argument('--is_pipeline', action='store_true', default=False) |
| parser.add_argument('--tools_json', type=str, default="", help="JSON list file from crawler output") |
| parser.add_argument('--jobs_json', type=str, default="", help="Per-tool jobs JSON with name/manual/run_help_command") |
| parser.add_argument('--manual_dir', type=str, default="", help="Directory containing *.help.txt or *.manual_bundle.txt") |
| parser.add_argument('--top_k', type=int, default=0, help="Only convert first K tools from tools_json/jobs_json") |
| parser.add_argument('--domains', nargs='*', default=[], help="Domain filter for tools_json/jobs_json") |
| parser.add_argument('--skip_existing', type=str, default='True', help="Skip tool if MCP server file already exists") |
| parser.add_argument('--shard_count', type=int, default=1, help="Total shards for multi-terminal execution") |
| parser.add_argument('--shard_index', type=int, default=0, help="Current shard index in [0, shard_count)") |
| parser.add_argument('--report_suffix', type=str, default="", help="Optional suffix for report filename") |
| args = parser.parse_args() |
|
|
| output_root = Path(args.output_location) |
| output_root.mkdir(parents=True, exist_ok=True) |
| run_help_command = parse_bool(args.run_help_command) |
| skip_existing = parse_bool(args.skip_existing) |
| converter = BioinfoMCP(model=args.model) |
|
|
| jobs: List[Dict] = [] |
| if args.jobs_json: |
| jobs = load_jobs_from_json(Path(args.jobs_json), top_k=args.top_k, domains=args.domains) |
| elif args.manual_dir: |
| jobs = load_jobs_from_manual_dir(Path(args.manual_dir), top_k=args.top_k) |
| elif args.tools_json: |
| tools = load_tool_list_from_json(Path(args.tools_json), top_k=args.top_k, domains=args.domains) |
| jobs = [{"name": t, "manual": args.manual, "run_help_command": run_help_command} for t in tools] |
| elif args.name: |
| jobs = [{"name": args.name, "manual": args.manual, "run_help_command": run_help_command}] |
| else: |
| raise ValueError("Either --name, --tools_json, --jobs_json or --manual_dir must be provided") |
|
|
| if args.shard_count < 1: |
| raise ValueError(f"--shard_count must be >= 1, got: {args.shard_count}") |
| jobs = select_jobs_for_shard(jobs, args.shard_count, args.shard_index) |
| print(f"Shard selection: shard_index={args.shard_index}/{args.shard_count}, jobs={len(jobs)}") |
|
|
| run_log: List[Dict] = [] |
| for job in jobs: |
| tool_name = job["name"] |
| manual = job.get("manual", "--help") |
| job_run_help = parse_bool(job.get("run_help_command", True)) |
|
|
| existing_server = has_existing_server(output_root, tool_name) if skip_existing else None |
| if existing_server: |
| print(f"Skip existing MCP server: {existing_server}") |
| run_log.append( |
| { |
| "tool_name": tool_name, |
| "manual": manual, |
| "run_help_command": bool(job_run_help), |
| "status": "skipped_existing", |
| "error": "", |
| "output_file": str(existing_server), |
| "dockerfile_ready": True, |
| } |
| ) |
| continue |
|
|
| server_path = output_root / f"mcp_{tool_name}" |
| os.makedirs(server_path, exist_ok=True) |
| print("Server path", server_path) |
| app_path = Path(server_path) / "app" |
| os.makedirs(app_path, exist_ok=True) |
|
|
| result = convert_mcptool(tool_name, manual, job_run_help, server_path, converter=converter) |
| if result.get("status") == "success": |
| generate_requirements_with_pipreqs(tool_name, server_path) |
| generate_environment_yaml(tool_name, server_path) |
|
|
| status = build_docker_image(tool_name, server_path, output_root, args.is_pipeline) |
| result["dockerfile_ready"] = bool(status) |
|
|
| if status: |
| add = claude_addition(tool_name, server_path) |
| print(f"Add the following onto your Claude Configuration json file to run the MCP server:\n{'=='*10}\n{add}\n{'=='*10}") |
| else: |
| print("Failed to build Docker Image") |
| else: |
| result["dockerfile_ready"] = False |
| print(f"Skip packaging for {tool_name}: conversion failed -> {result.get('error', '')}") |
|
|
| run_log.append(result) |
|
|
| report_name = "conversion_report.json" |
| if args.shard_count > 1: |
| report_name = f"conversion_report.shard_{args.shard_index}_of_{args.shard_count}.json" |
| if args.report_suffix: |
| report_name = report_name.replace(".json", f".{args.report_suffix}.json") |
| report_path = output_root / report_name |
| report_path.write_text(json.dumps(run_log, ensure_ascii=False, indent=2), encoding="utf-8") |
| print(f"Saved conversion report to: {report_path}") |
|
|
| |
|
|