From b30530889f836b11658b49ad0bcd792f568d69d4 Mon Sep 17 00:00:00 2001 From: jessegmeyerlab Date: Sun, 12 Jul 2026 21:02:24 -0700 Subject: [PATCH] Consolidate MaxQuant wrapper into run_maxquant.py + .gitignore - Merge server.py logic into run_maxquant.py (parameter-file generation, FAIMS handling, version selection) and remove the redundant server.py. - Build/instance script updates. - Add .gitignore excluding Python artifacts, built .sif images, and the license-restricted MaxQuant and .NET binaries (must not be redistributed). Co-Authored-By: Claude Opus 4.8 --- .gitignore | 22 ++ pyproject.toml | 2 +- src/odda_maxquant/run_maxquant.py | 412 +++++++++++++++++++++-- src/odda_maxquant/server.py | 523 ------------------------------ start_maxquant_instances.sh | 2 +- static/apptainer/build_images.sh | 10 +- 6 files changed, 419 insertions(+), 552 deletions(-) create mode 100644 .gitignore delete mode 100644 src/odda_maxquant/server.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3f889b9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +build/ +dist/ +.venv/ +venv/ + +# Built containers (do NOT distribute) +*.sif + +# License-restricted tool binaries / downloads (do NOT distribute) +static/apptainer/MaxQuant_v*/ +static/apptainer/MaxQuant*.zip +static/apptainer/dotnet/ + +# Local data / secrets +*.sqlite +*.sqlite-journal +.env +*.key diff --git a/pyproject.toml b/pyproject.toml index 8d162d2..d4e3d2a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,4 +20,4 @@ where = ["src"] version = {attr = "odda_maxquant.__version__"} [project.scripts] -maxquant-server = "odda_maxquant.server:main" +maxquant-server = "odda_maxquant.run_maxquant:main" diff --git a/src/odda_maxquant/run_maxquant.py b/src/odda_maxquant/run_maxquant.py index e988464..141cb87 100644 --- a/src/odda_maxquant/run_maxquant.py +++ b/src/odda_maxquant/run_maxquant.py @@ -1,11 +1,15 @@ # MCP server implementation for running MaxQuant via Apptainer instances. # # Provides MCP tools for running MaxQuant proteomics software via Apptainer -# containers and listing available MaxQuant versions. MaxQuant 2.x versions -# are executed using dotnet (.NET Core) rather than mono (.NET Framework). +# containers, listing available MaxQuant versions, and creating MaxQuant +# parameter files (mqpar.xml) via MaxQuant's own --create mechanism. MaxQuant +# 2.x versions are executed using dotnet (.NET Core) rather than mono +# (.NET Framework). This is the single authoritative MaxQuant MCP module and is +# the one launched by .mcp.json. from odda_utils.async_exec import execute_process from odda_maxquant import maxquant_instance_format +from pathlib import Path from typing import Optional, List, Dict, Any import asyncio import contextlib @@ -14,14 +18,166 @@ from mcp.server.fastmcp import FastMCP apptainer_maxquant_exec_base = "apptainer exec" +# In-container path to MaxQuantCmd.dll, matching the container layout defined in +# static/apptainer/maxquant.def (dotnet at /dotnet, MaxQuant at /MaxQuant). maxquant_dotnet_cmd = "/dotnet/dotnet /MaxQuant/bin/MaxQuantCmd.dll" app = FastMCP("maxquant_multiversion") +# Valid LCMS types for MaxQuant --create command +# Short codes and their full names are both accepted +VALID_LCMS_TYPES = { + "ST": "Standard", + "Standard": "Standard", + "R2": "ReporterMS2", + "ReporterMS2": "ReporterMS2", + "R3": "ReporterMS3", + "ReporterMS3": "ReporterMS3", + "NC": "NeuCode", + "NeuCode": "NeuCode", + "BC": "BoxCar", + "BoxCar": "BoxCar", + "TD": "TIMSDDA", + "TIMSDDA": "TIMSDDA", + "DIA": "DIA", + "TDIA": "TDIA", + "BDIA": "BDIA", + "DDIA": "DDIA", + "F": "FAIMS", + "FAIMS": "FAIMS", + "F2": "FAIMSReporterMS2", + "FAIMSReporterMS2": "FAIMSReporterMS2", + "F3": "FAIMSReporterMS3", + "FAIMSReporterMS3": "FAIMSReporterMS3", +} + +# Valid instrument types for MaxQuant --create command +# Short codes and their full names are both accepted +VALID_INSTRUMENT_TYPES = { + "TO": "thermoOrbi", + "thermoOrbi": "thermoOrbi", + "BQ": "BrukerTOF", + "BrukerTOF": "BrukerTOF", + "SC": "SciexTOF", + "SciexTOF": "SciexTOF", + "AQ": "AgilentTOF", + "AgilentTOF": "AgilentTOF", + "BT": "BrukerTIMS", + "BrukerTIMS": "BrukerTIMS", + "TA": "thermoAstral", + "thermoAstral": "thermoAstral", + "WQ": "WatersTOF", + "WatersTOF": "WatersTOF", +} + + +def _version_sort_key(version: str): + """ + Build a sort key for a dotted numeric version string. + + Parameters + ---------- + version : str + Version string such as "2.8.1.0". + + Returns + ------- + tuple + Tuple of integer components; unparseable versions sort lowest. + """ + try: + return tuple(int(part) for part in version.split(".")) + except ValueError: + return (-1,) + + +async def _resolve_version(version: Optional[str]) -> Dict[str, Any]: + """ + Resolve the MaxQuant version to run, discovering it dynamically if not given. + + Parameters + ---------- + version : Optional[str] + Explicit bare version string (e.g. "2.8.1.0"), or None to auto-discover + the newest running instance via ``list_maxquant_versions``. + + Returns + ------- + Dict[str, Any] + ``{"ok": True, "version": }`` on success, otherwise + ``{"ok": False, "error": }``. + """ + if version: + return {"ok": True, "version": version} + listing = await list_maxquant_versions() + versions = listing.get("versions") or [] + if not versions: + return { + "ok": False, + "error": ( + "No MaxQuant version specified and none could be discovered from " + "running Apptainer instances. Start an instance (e.g. " + "maxquant_v2.8.1.0) or pass an explicit version string." + ), + } + return {"ok": True, "version": max(versions, key=_version_sort_key)} + + +async def _exec_maxquant( + version: Optional[str], + args: Optional[List[str]] = None, + env: Optional[dict] = None, + timeout_sec: Optional[float] = None, +) -> Dict[str, Any]: + """ + Build and execute a MaxQuantCmd invocation inside the Apptainer instance. + + The instance URI is built exactly once from the bare version string, and the + command explicitly uses dotnet (.NET Core) for MaxQuant 2.x compatibility + instead of relying on the container's runscript. + + Parameters + ---------- + version : Optional[str] + Bare MaxQuant version (e.g. "2.8.1.0"), or None to auto-discover. + args : list, optional + Arguments to pass to MaxQuantCmd.dll. + env : dict, optional + Environment variables to set; overrides current environment. + timeout_sec : float, optional + Time in seconds before killing the process. + + Returns + ------- + Dict[str, Any] + On a version-resolution failure, ``{"ok": False, "error": }``. + Otherwise the dictionary returned by ``execute_process`` (with keys + ``exit_code``, ``stdout``, ``stderr``, ``cmd``, ``timed_out``). + """ + exec_env = os.environ.copy() + if env: + exec_env.update({str(k): str(v) for k, v in env.items()}) + + resolved = await _resolve_version(version) + if not resolved["ok"]: + return resolved + + # Build command: apptainer exec /dotnet/dotnet /MaxQuant/bin/MaxQuantCmd.dll [args...] + cmd = [ + "apptainer", "exec", + maxquant_instance_format.format(version=resolved["version"]), + *maxquant_dotnet_cmd.split(), + ] + if args: + cmd.extend(args) + + return await execute_process(cmd, env=exec_env, timeout_sec=timeout_sec) + + @app.tool() async def run_maxquant(env: Optional[dict] = None, - version: Optional[str] = maxquant_instance_format.format(version="2.6.3.0"), + version: Optional[str] = None, timeout_sec: Optional[int] = None, args: Optional[List[str]] = None): """ @@ -36,7 +192,9 @@ async def run_maxquant(env: Optional[dict] = None, env : dict, optional Environment variables to set; overrides current environment. version : str, optional - Version of MaxQuant to run. Defaults to instance://maxquant_v2.6.3.0. + Bare version of MaxQuant to run (e.g. "2.8.1.0"). If omitted (None), the + newest version among running Apptainer instances is auto-discovered via + ``list_maxquant_versions``. timeout_sec : int, optional Time in seconds before killing the process. args : list, optional @@ -51,25 +209,9 @@ async def run_maxquant(env: Optional[dict] = None, - stderr: Standard error from MaxQuant - cmd: The command that was executed - timed_out: Whether the process timed out + On a version-resolution failure, ``{"ok": False, "error": }``. """ - exec_env = os.environ.copy() - if env: - exec_env.update({str(k): str(v) for k, v in env.items()}) - - # Build command: apptainer exec /dotnet/dotnet /MaxQuant/bin/MaxQuantCmd.dll [args...] - # This explicitly uses dotnet for MaxQuant 2.x compatibility instead of relying - # on the container's runscript which may incorrectly try to use mono. - cmd = [ - "apptainer", "exec", - maxquant_instance_format.format(version=version), - "/dotnet/dotnet", "/MaxQuant/bin/MaxQuantCmd.dll" - ] - if args: - cmd.extend(args) - - return await execute_process(cmd, - env=exec_env, - timeout_sec=timeout_sec) + return await _exec_maxquant(version, args, env=env, timeout_sec=timeout_sec) @app.tool() @@ -81,7 +223,7 @@ async def list_maxquant_versions( Queries the list of running Apptainer/Singularity instances and extracts version information from instances with names matching the pattern "maxquant_v*". - For example, an instance named "maxquant_v2.6.3.0" would return version "2.6.3.0". + For example, an instance named "maxquant_v2.8.1.0" would return version "2.8.1.0". Parameters ---------- @@ -164,6 +306,230 @@ async def list_maxquant_versions( } +@app.tool() +async def create_parameter_file( + output_path: str, + lcms_type: str, + instrument_type: str, + fasta_paths: List[str], + raw_file_folder: str, + version: Optional[str] = None, + use_lfq: bool = False, + lfq_min_ratio_count_dia: Optional[int] = None, + use_mbr: bool = False, + split_by_taxonomy: bool = False, + num_threads: Optional[int] = None, + library_paths: Optional[List[str]] = None, + timeout_sec: Optional[float] = 300.0, +) -> Dict[str, Any]: + """ + Create a MaxQuant parameter file (mqpar.xml) using MaxQuant's --create option. + + This tool invokes MaxQuant's command-line interface with the --create flag to + generate a new parameter file. It uses a running MaxQuant Apptainer instance to + execute the command, ensuring compatibility with the specified MaxQuant version. + + Parameters + ---------- + output_path : str + The absolute path where the mqpar.xml file will be created. + lcms_type : str + The LC-MS acquisition type. Valid options (short code or full name): + - ST / Standard: Standard DDA acquisition + - R2 / ReporterMS2: Reporter ion MS2 (e.g., TMT, iTRAQ) + - R3 / ReporterMS3: Reporter ion MS3 + - NC / NeuCode: NeuCode labeling + - BC / BoxCar: BoxCar acquisition + - TD / TIMSDDA: TIMS DDA (Bruker timsTOF) + - DIA: Data-Independent Acquisition + - TDIA: TIMS DIA + - BDIA: BoxCar DIA + - DDIA: diaPASEF + - F / FAIMS: FAIMS DDA + - F2 / FAIMSReporterMS2: FAIMS with reporter MS2 + - F3 / FAIMSReporterMS3: FAIMS with reporter MS3 + instrument_type : str + The mass spectrometer instrument type. Valid options (short code or full name): + - TO / thermoOrbi: Thermo Orbitrap instruments + - BQ / BrukerTOF: Bruker TOF instruments + - SC / SciexTOF: SCIEX TOF instruments + - AQ / AgilentTOF: Agilent TOF instruments + - BT / BrukerTIMS: Bruker timsTOF instruments + - TA / thermoAstral: Thermo Astral instruments + - WQ / WatersTOF: Waters TOF instruments + fasta_paths : List[str] + List of absolute paths to FASTA files for the protein database search. + raw_file_folder : str + Absolute path to the folder containing raw mass spectrometry files. + version : str, optional + The MaxQuant version to use (e.g., "2.8.1.0"). Must match a running + instance. If omitted (None), the newest version among running Apptainer + instances is auto-discovered via ``list_maxquant_versions``. + use_lfq : bool, optional + Enable Label-Free Quantification. Default is False. + lfq_min_ratio_count_dia : Optional[int], optional + Minimum ratio count for LFQ in DIA mode. Only relevant when use_lfq is True. + use_mbr : bool, optional + Enable Match Between Runs feature. Default is False. + split_by_taxonomy : bool, optional + Split results by taxonomy. Default is False. + num_threads : Optional[int], optional + Number of threads to use for processing. If not specified, MaxQuant will + use its default. + library_paths : Optional[List[str]], optional + List of paths to spectral library files (e.g., msms.txt, evidence.txt). + timeout_sec : Optional[float], optional + Timeout in seconds for the command. Default is 300.0 (5 minutes). + + Returns + ------- + Dict[str, Any] + A dictionary containing: + - ok (bool): Whether the parameter file was created successfully. + - output_path (str): The path to the created parameter file. + - stdout (str): Standard output from MaxQuant. + - stderr (str): Standard error from MaxQuant. + - exit_code (int): The MaxQuant process exit code. + - error (str, optional): Error message if the operation failed. + + Examples + -------- + Create a basic parameter file for Thermo Orbitrap DDA data: + + >>> await create_parameter_file( + ... output_path="/data/quantified/PXD000001/v0/mqpar.xml", + ... lcms_type="Standard", + ... instrument_type="thermoOrbi", + ... fasta_paths=["/data/supporting/human.fasta"], + ... raw_file_folder="/data/datasets/PXD000001/raw", + ... ) + + Create a parameter file for Bruker timsTOF with LFQ and MBR enabled: + + >>> await create_parameter_file( + ... output_path="/data/quantified/PXD000002/v0/mqpar.xml", + ... lcms_type="TIMSDDA", + ... instrument_type="BrukerTIMS", + ... fasta_paths=["/data/supporting/human.fasta", "/data/supporting/ecoli.fasta"], + ... raw_file_folder="/data/datasets/PXD000002/raw", + ... use_lfq=True, + ... use_mbr=True, + ... num_threads=10, + ... ) + """ + # Validate required inputs + if not output_path: + return { + "ok": False, + "output_path": output_path, + "stdout": "", + "stderr": "", + "error": "output_path is required.", + } + + if not fasta_paths: + return { + "ok": False, + "output_path": output_path, + "stdout": "", + "stderr": "", + "error": "At least one FASTA path is required.", + } + + if not raw_file_folder: + return { + "ok": False, + "output_path": output_path, + "stdout": "", + "stderr": "", + "error": "raw_file_folder is required.", + } + + # Validate lcms_type + if lcms_type not in VALID_LCMS_TYPES: + valid_options = sorted(set(VALID_LCMS_TYPES.keys())) + return { + "ok": False, + "output_path": output_path, + "stdout": "", + "stderr": "", + "error": ( + f"Invalid lcms_type '{lcms_type}'. " + f"Valid options are: {', '.join(valid_options)}" + ), + } + + # Validate instrument_type + if instrument_type not in VALID_INSTRUMENT_TYPES: + valid_options = sorted(set(VALID_INSTRUMENT_TYPES.keys())) + return { + "ok": False, + "output_path": output_path, + "stdout": "", + "stderr": "", + "error": ( + f"Invalid instrument_type '{instrument_type}'. " + f"Valid options are: {', '.join(valid_options)}" + ), + } + + # Build the command arguments + args: List[str] = [ + "--create", + "--newMqpar", output_path, + "--LCMSType", lcms_type, + "--instrumentType", instrument_type, + "--pathRawFileFolder", raw_file_folder, + ] + + # Add FASTA paths (--pathFasta can take multiple paths) + args.append("--pathFasta") + args.extend(fasta_paths) + + # Add optional parameters + if use_lfq: + args.append("--useLFQ") + + if lfq_min_ratio_count_dia is not None: + args.extend(["--lfqMinRatioCountDia", str(lfq_min_ratio_count_dia)]) + + if use_mbr: + args.append("--useMBR") + + if split_by_taxonomy: + args.append("--splitByTaxonomy") + + if num_threads is not None: + args.extend(["--numThreads", str(num_threads)]) + + if library_paths: + args.append("--pathLibrary") + args.extend(library_paths) + + # Ensure the output directory exists + Path(output_path).parent.mkdir(parents=True, exist_ok=True) + + # Execute the MaxQuant command inside the Apptainer instance. + result = await _exec_maxquant(version, args, timeout_sec=timeout_sec) + result["output_path"] = output_path + + # A version-resolution failure short-circuits with ok=False and an error. + if result.get("ok") is False: + return result + + # execute_process results expose exit_code rather than an "ok" flag; the + # command must both exit 0 and produce the expected file. + created = Path(output_path).exists() + result["ok"] = result.get("exit_code") == 0 and created + if result.get("exit_code") == 0 and not created: + result["error"] = ( + "MaxQuant command completed but the parameter file was not created. " + f"Check stderr for details: {result.get('stderr', '')}" + ) + + return result + + def main(): app.run() diff --git a/src/odda_maxquant/server.py b/src/odda_maxquant/server.py deleted file mode 100644 index 53e5efb..0000000 --- a/src/odda_maxquant/server.py +++ /dev/null @@ -1,523 +0,0 @@ -# MaxQuant MCP Server -# -# This module provides an MCP (Model Context Protocol) server for running MaxQuant -# via Apptainer/Singularity containers. It exposes tools for: -# - Listing available MaxQuant versions from running Apptainer instances -# - Creating MaxQuant parameter files (mqpar.xml) using the -c/--create option -# -# MaxQuant instances are expected to follow the naming convention: maxquant_v{version} -# For example: maxquant_v2.7.5.0, maxquant_v2.6.0.0 - -from __future__ import annotations - -import asyncio -import contextlib -import re -from enum import Enum -from pathlib import Path -from typing import Any, Dict, List, Literal, Optional - -from mcp.server.fastmcp import FastMCP - -from maxquant import maxquant_instance_format - -app = FastMCP("maxquant") - - -# Valid LCMS types for MaxQuant --create command -# Short codes and their full names are both accepted -VALID_LCMS_TYPES = { - "ST": "Standard", - "Standard": "Standard", - "R2": "ReporterMS2", - "ReporterMS2": "ReporterMS2", - "R3": "ReporterMS3", - "ReporterMS3": "ReporterMS3", - "NC": "NeuCode", - "NeuCode": "NeuCode", - "BC": "BoxCar", - "BoxCar": "BoxCar", - "TD": "TIMSDDA", - "TIMSDDA": "TIMSDDA", - "DIA": "DIA", - "TDIA": "TDIA", - "BDIA": "BDIA", - "DDIA": "DDIA", - "F": "FAIMS", - "FAIMS": "FAIMS", - "F2": "FAIMSReporterMS2", - "FAIMSReporterMS2": "FAIMSReporterMS2", - "F3": "FAIMSReporterMS3", - "FAIMSReporterMS3": "FAIMSReporterMS3", -} - -# Valid instrument types for MaxQuant --create command -# Short codes and their full names are both accepted -VALID_INSTRUMENT_TYPES = { - "TO": "thermoOrbi", - "thermoOrbi": "thermoOrbi", - "BQ": "BrukerTOF", - "BrukerTOF": "BrukerTOF", - "SC": "SciexTOF", - "SciexTOF": "SciexTOF", - "AQ": "AgilentTOF", - "AgilentTOF": "AgilentTOF", - "BT": "BrukerTIMS", - "BrukerTIMS": "BrukerTIMS", - "TA": "thermoAstral", - "thermoAstral": "thermoAstral", - "WQ": "WatersTOF", - "WatersTOF": "WatersTOF", -} - - -async def _get_container_runtime() -> Optional[str]: - """ - Detect the available container runtime (apptainer or singularity). - - Returns - ------- - Optional[str] - The name of the available container runtime, or None if neither is available. - """ - for cmd in ["apptainer", "singularity"]: - try: - proc = await asyncio.create_subprocess_exec( - cmd, "--version", - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - await proc.communicate() - if proc.returncode == 0: - return cmd - except FileNotFoundError: - continue - return None - - -async def _run_maxquant_command( - version: str, - args: List[str], - timeout_sec: Optional[float] = None, -) -> Dict[str, Any]: - """ - Execute a MaxQuant command inside the Apptainer instance. - - Parameters - ---------- - version : str - The MaxQuant version to use (e.g., "2.7.5.0"). - args : List[str] - Arguments to pass to MaxQuantCmd.dll. - timeout_sec : Optional[float] - Timeout in seconds for the command. None means no timeout. - - Returns - ------- - Dict[str, Any] - A dictionary containing: - - ok (bool): Whether the command succeeded. - - stdout (str): Standard output from the command. - - stderr (str): Standard error from the command. - - return_code (int): The return code of the command. - - error (str, optional): Error message if the operation failed. - """ - container_runtime = await _get_container_runtime() - if container_runtime is None: - return { - "ok": False, - "stdout": "", - "stderr": "", - "return_code": -1, - "error": ( - "No container runtime found. " - "Ensure 'apptainer' or 'singularity' is installed and accessible in PATH." - ), - } - - instance_uri = maxquant_instance_format.format(version=version) - - # MaxQuantCmd.dll is located at /opt/maxquant/bin/MaxQuantCmd.dll inside the container - cmd = [ - container_runtime, - "exec", - instance_uri, - "dotnet", - "/opt/maxquant/bin/MaxQuantCmd.dll", - ] + args - - try: - proc = await asyncio.create_subprocess_exec( - *cmd, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - - try: - stdout_b, stderr_b = ( - await asyncio.wait_for(proc.communicate(), timeout=timeout_sec) - if timeout_sec - else await proc.communicate() - ) - except asyncio.TimeoutError: - with contextlib.suppress(ProcessLookupError): - proc.kill() - return { - "ok": False, - "stdout": "", - "stderr": "", - "return_code": -1, - "error": f"Command timed out after {timeout_sec} seconds.", - } - - stdout_str = stdout_b.decode(errors="replace") - stderr_str = stderr_b.decode(errors="replace") - - return { - "ok": proc.returncode == 0, - "stdout": stdout_str, - "stderr": stderr_str, - "return_code": proc.returncode, - } - - except FileNotFoundError as e: - return { - "ok": False, - "stdout": "", - "stderr": "", - "return_code": -1, - "error": f"Failed to execute command: {e}", - } - - -@app.tool() -async def list_maxquant_versions( - timeout_sec: Optional[float] = 10.0, -) -> Dict[str, Any]: - """ - List available MaxQuant versions by examining running Apptainer/Singularity instances. - - This tool queries the list of running Apptainer/Singularity instances and extracts - version information from instances with names matching the pattern "maxquant_v*". - For example, an instance named "maxquant_v2.7.5.0" would return version "2.7.5.0". - - Parameters - ---------- - timeout_sec : Optional[float] - Timeout in seconds for the instance list command. Default is 10.0. - - Returns - ------- - Dict[str, Any] - A dictionary containing: - - ok (bool): Whether the operation succeeded. - - versions (List[str]): List of available MaxQuant version strings - (e.g., ["2.6.0.0", "2.7.5.0"]). - - instance_names (List[str]): Full instance names matching the maxquant_v* pattern. - - container_runtime (str): The container runtime that was used - ("apptainer" or "singularity"). - - error (str, optional): Error message if the operation failed. - """ - # Pattern to match MaxQuant instance names and extract version - # Matches "maxquant_v" followed by a version string (e.g., "2.7.5.0", "2.6.0.0") - maxquant_pattern = re.compile(r"^maxquant_v(.+)$") - - # Try apptainer first, fall back to singularity - for container_cmd in ["apptainer", "singularity"]: - cmd = [container_cmd, "instance", "list"] - - try: - proc = await asyncio.create_subprocess_exec( - *cmd, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - - try: - stdout_b, stderr_b = ( - await asyncio.wait_for(proc.communicate(), timeout=timeout_sec) - if timeout_sec - else await proc.communicate() - ) - except asyncio.TimeoutError: - with contextlib.suppress(ProcessLookupError): - proc.kill() - continue # Try next container command - - if proc.returncode != 0: - continue # Try next container command - - # Parse the output to find MaxQuant instances - stdout_str = stdout_b.decode(errors="replace") - versions: List[str] = [] - instance_names: List[str] = [] - - # The output format is typically: - # INSTANCE NAME PID IP IMAGE - # maxquant_v2.7.5.0 12345 /path/to/image.sif - for line in stdout_str.strip().split("\n"): - # Skip header line and empty lines - if not line.strip() or line.startswith("INSTANCE NAME"): - continue - - # Extract the instance name (first column) - parts = line.split() - if not parts: - continue - - instance_name = parts[0] - match = maxquant_pattern.match(instance_name) - if match: - version = match.group(1) - versions.append(version) - instance_names.append(instance_name) - - return { - "ok": True, - "versions": versions, - "instance_names": instance_names, - "container_runtime": container_cmd, - } - - except FileNotFoundError: - continue # Try next container command - - # If we get here, neither apptainer nor singularity worked - return { - "ok": False, - "versions": [], - "instance_names": [], - "error": ( - "Failed to list container instances. " - "Ensure 'apptainer' or 'singularity' is installed and accessible in PATH." - ), - } - - -@app.tool() -async def create_parameter_file( - version: str, - output_path: str, - lcms_type: str, - instrument_type: str, - fasta_paths: List[str], - raw_file_folder: str, - use_lfq: bool = False, - lfq_min_ratio_count_dia: Optional[int] = None, - use_mbr: bool = False, - split_by_taxonomy: bool = False, - num_threads: Optional[int] = None, - library_paths: Optional[List[str]] = None, - timeout_sec: Optional[float] = 300.0, -) -> Dict[str, Any]: - """ - Create a MaxQuant parameter file (mqpar.xml) using MaxQuant's --create option. - - This tool invokes MaxQuant's command-line interface with the --create flag to generate - a new parameter file. It uses a running MaxQuant Apptainer instance to execute the - command, ensuring compatibility with the specified MaxQuant version. - - Parameters - ---------- - version : str - The MaxQuant version to use (e.g., "2.7.5.0"). Must match a running instance. - output_path : str - The absolute path where the mqpar.xml file will be created. - lcms_type : str - The LC-MS acquisition type. Valid options (short code or full name): - - ST / Standard: Standard DDA acquisition - - R2 / ReporterMS2: Reporter ion MS2 (e.g., TMT, iTRAQ) - - R3 / ReporterMS3: Reporter ion MS3 - - NC / NeuCode: NeuCode labeling - - BC / BoxCar: BoxCar acquisition - - TD / TIMSDDA: TIMS DDA (Bruker timsTOF) - - DIA: Data-Independent Acquisition - - TDIA: TIMS DIA - - BDIA: BoxCar DIA - - DDIA: diaPASEF - - F / FAIMS: FAIMS DDA - - F2 / FAIMSReporterMS2: FAIMS with reporter MS2 - - F3 / FAIMSReporterMS3: FAIMS with reporter MS3 - instrument_type : str - The mass spectrometer instrument type. Valid options (short code or full name): - - TO / thermoOrbi: Thermo Orbitrap instruments - - BQ / BrukerTOF: Bruker TOF instruments - - SC / SciexTOF: SCIEX TOF instruments - - AQ / AgilentTOF: Agilent TOF instruments - - BT / BrukerTIMS: Bruker timsTOF instruments - - TA / thermoAstral: Thermo Astral instruments - - WQ / WatersTOF: Waters TOF instruments - fasta_paths : List[str] - List of absolute paths to FASTA files for the protein database search. - raw_file_folder : str - Absolute path to the folder containing raw mass spectrometry files. - use_lfq : bool, optional - Enable Label-Free Quantification. Default is False. - lfq_min_ratio_count_dia : Optional[int], optional - Minimum ratio count for LFQ in DIA mode. Only relevant when use_lfq is True. - use_mbr : bool, optional - Enable Match Between Runs feature. Default is False. - split_by_taxonomy : bool, optional - Split results by taxonomy. Default is False. - num_threads : Optional[int], optional - Number of threads to use for processing. If not specified, MaxQuant will - use its default. - library_paths : Optional[List[str]], optional - List of paths to spectral library files (e.g., msms.txt, evidence.txt). - timeout_sec : Optional[float], optional - Timeout in seconds for the command. Default is 300.0 (5 minutes). - - Returns - ------- - Dict[str, Any] - A dictionary containing: - - ok (bool): Whether the parameter file was created successfully. - - output_path (str): The path to the created parameter file. - - stdout (str): Standard output from MaxQuant. - - stderr (str): Standard error from MaxQuant. - - error (str, optional): Error message if the operation failed. - - Examples - -------- - Create a basic parameter file for Thermo Orbitrap DDA data: - - >>> await create_parameter_file( - ... version="2.7.5.0", - ... output_path="/data/quantified/PXD000001/v0/mqpar.xml", - ... lcms_type="Standard", - ... instrument_type="thermoOrbi", - ... fasta_paths=["/data/supporting/human.fasta"], - ... raw_file_folder="/data/datasets/PXD000001/raw", - ... ) - - Create a parameter file for Bruker timsTOF with LFQ and MBR enabled: - - >>> await create_parameter_file( - ... version="2.7.5.0", - ... output_path="/data/quantified/PXD000002/v0/mqpar.xml", - ... lcms_type="TIMSDDA", - ... instrument_type="BrukerTIMS", - ... fasta_paths=["/data/supporting/human.fasta", "/data/supporting/ecoli.fasta"], - ... raw_file_folder="/data/datasets/PXD000002/raw", - ... use_lfq=True, - ... use_mbr=True, - ... num_threads=10, - ... ) - """ - # Validate required inputs - if not output_path: - return { - "ok": False, - "output_path": output_path, - "stdout": "", - "stderr": "", - "error": "output_path is required.", - } - - if not fasta_paths: - return { - "ok": False, - "output_path": output_path, - "stdout": "", - "stderr": "", - "error": "At least one FASTA path is required.", - } - - if not raw_file_folder: - return { - "ok": False, - "output_path": output_path, - "stdout": "", - "stderr": "", - "error": "raw_file_folder is required.", - } - - # Validate lcms_type - if lcms_type not in VALID_LCMS_TYPES: - valid_options = sorted(set(VALID_LCMS_TYPES.keys())) - return { - "ok": False, - "output_path": output_path, - "stdout": "", - "stderr": "", - "error": ( - f"Invalid lcms_type '{lcms_type}'. " - f"Valid options are: {', '.join(valid_options)}" - ), - } - - # Validate instrument_type - if instrument_type not in VALID_INSTRUMENT_TYPES: - valid_options = sorted(set(VALID_INSTRUMENT_TYPES.keys())) - return { - "ok": False, - "output_path": output_path, - "stdout": "", - "stderr": "", - "error": ( - f"Invalid instrument_type '{instrument_type}'. " - f"Valid options are: {', '.join(valid_options)}" - ), - } - - # Build the command arguments - args: List[str] = [ - "--create", - "--newMqpar", output_path, - "--LCMSType", lcms_type, - "--instrumentType", instrument_type, - "--pathRawFileFolder", raw_file_folder, - ] - - # Add FASTA paths (--pathFasta can take multiple paths) - args.append("--pathFasta") - args.extend(fasta_paths) - - # Add optional parameters - if use_lfq: - args.append("--useLFQ") - - if lfq_min_ratio_count_dia is not None: - args.extend(["--lfqMinRatioCountDia", str(lfq_min_ratio_count_dia)]) - - if use_mbr: - args.append("--useMBR") - - if split_by_taxonomy: - args.append("--splitByTaxonomy") - - if num_threads is not None: - args.extend(["--numThreads", str(num_threads)]) - - if library_paths: - args.append("--pathLibrary") - args.extend(library_paths) - - # Ensure the output directory exists - output_dir = Path(output_path).parent - output_dir.mkdir(parents=True, exist_ok=True) - - # Execute the MaxQuant command - result = await _run_maxquant_command(version, args, timeout_sec) - - # Add the output path to the result - result["output_path"] = output_path - - # Check if the file was actually created - if result["ok"] and not Path(output_path).exists(): - result["ok"] = False - result["error"] = ( - "MaxQuant command completed but the parameter file was not created. " - f"Check stderr for details: {result.get('stderr', '')}" - ) - - return result - - -def main() -> None: - """Run the MaxQuant MCP server over stdio.""" - app.run() - - -if __name__ == "__main__": - main() diff --git a/start_maxquant_instances.sh b/start_maxquant_instances.sh index 2e4d512..e872ce9 100755 --- a/start_maxquant_instances.sh +++ b/start_maxquant_instances.sh @@ -7,6 +7,6 @@ for i in `ls ${apptainer_path}/*.sif`; do echo "Starting ${i}..." im_name=`basename ${i}`; im_name="${im_name%.*}"; - apptainer instance start -B /data/articles/:/data/articles/:ro -B /data/datasets/:/data/datasets/:ro -B /data/multistudy/:/data/multistudy/:ro -B /data/quantified/:/data/quantified/ ${i} ${im_name}; + apptainer instance start -B /data/articles/:/data/articles/:ro -B /data/datasets/:/data/datasets/:ro -B /data/supporting/:/data/supporting/:ro -B /data/quantified/:/data/quantified/ ${i} ${im_name}; done diff --git a/static/apptainer/build_images.sh b/static/apptainer/build_images.sh index c006c29..34948a3 100755 --- a/static/apptainer/build_images.sh +++ b/static/apptainer/build_images.sh @@ -42,12 +42,14 @@ for version in "${!versions[@]}"; do fi echo "Building image for MaxQuant ${version}..." - if apptainer build \ + # Run from SCRIPT_DIR and pass relative %files paths so that spaces in an + # absolute SCRIPT_DIR do not break Apptainer's whitespace-split %files section. + if ( cd "$SCRIPT_DIR" && apptainer build \ --build-arg "MAXQUANT_VERSION=${version}" \ - --build-arg "MAXQUANT_PATH=${MAXQUANT_PATH}" \ - --build-arg "DOTNET_PATH=${MAXQUANT_PATH}/dotnet" \ + --build-arg "MAXQUANT_PATH=." \ + --build-arg "DOTNET_PATH=./dotnet" \ "$output" \ - "$DEF_FILE" > /dev/null; then + "$DEF_FILE" > /dev/null ); then echo "Built: ${output}" else echo "Error: Build failed for MaxQuant ${version}" >&2