From 925c5db70fa010b88fbf57bf10376ddb39548026 Mon Sep 17 00:00:00 2001 From: KQDtianxiaK <1720573356@qq.com> Date: Wed, 25 Feb 2026 01:24:11 +0800 Subject: [PATCH 01/13] initialize files, commit to branch Test --- README.md | 528 ++++++--- SKILL.md | 245 ++++ __pycache__/server.cpython-312.pyc | Bin 0 -> 31226 bytes __pycache__/server.cpython-313.pyc | Bin 0 -> 36166 bytes config.json | 17 + geomcp_sra/__init__.py | 10 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 467 bytes geomcp_sra/__pycache__/config.cpython-312.pyc | Bin 0 -> 5719 bytes geomcp_sra/__pycache__/config.cpython-313.pyc | Bin 0 -> 5846 bytes .../__pycache__/geo_download.cpython-312.pyc | Bin 0 -> 20369 bytes .../__pycache__/geo_download.cpython-313.pyc | Bin 0 -> 20411 bytes .../__pycache__/geo_search.cpython-312.pyc | Bin 0 -> 15626 bytes .../__pycache__/geo_search.cpython-313.pyc | Bin 0 -> 15327 bytes .../__pycache__/sra_handler.cpython-312.pyc | Bin 0 -> 23239 bytes .../__pycache__/sra_handler.cpython-313.pyc | Bin 0 -> 39122 bytes geomcp_sra/config.py | 159 +++ geomcp_sra/geo_download.py | 463 ++++++++ geomcp_sra/geo_search.py | 384 ++++++ geomcp_sra/sra_handler.py | 1040 +++++++++++++++++ pyproject.toml | 37 +- requirements.txt | 4 + server.py | 1031 ++++++++++++++++ 22 files changed, 3715 insertions(+), 203 deletions(-) create mode 100644 SKILL.md create mode 100644 __pycache__/server.cpython-312.pyc create mode 100644 __pycache__/server.cpython-313.pyc create mode 100644 config.json create mode 100644 geomcp_sra/__init__.py create mode 100644 geomcp_sra/__pycache__/__init__.cpython-313.pyc create mode 100644 geomcp_sra/__pycache__/config.cpython-312.pyc create mode 100644 geomcp_sra/__pycache__/config.cpython-313.pyc create mode 100644 geomcp_sra/__pycache__/geo_download.cpython-312.pyc create mode 100644 geomcp_sra/__pycache__/geo_download.cpython-313.pyc create mode 100644 geomcp_sra/__pycache__/geo_search.cpython-312.pyc create mode 100644 geomcp_sra/__pycache__/geo_search.cpython-313.pyc create mode 100644 geomcp_sra/__pycache__/sra_handler.cpython-312.pyc create mode 100644 geomcp_sra/__pycache__/sra_handler.cpython-313.pyc create mode 100644 geomcp_sra/config.py create mode 100644 geomcp_sra/geo_download.py create mode 100644 geomcp_sra/geo_search.py create mode 100644 geomcp_sra/sra_handler.py create mode 100644 requirements.txt create mode 100644 server.py diff --git a/README.md b/README.md index 81f946e..647a67a 100644 --- a/README.md +++ b/README.md @@ -1,247 +1,411 @@ -# Gene Expression Omnibus (GEO) MCP - -
- GEO Logo -
- Gene Expression Omnibus (GEO) - A public functional genomics data repository -
- -[![PyPI version](https://badge.fury.io/py/geo-mcp.svg)](https://pypi.org/project/geo-mcp/) -[![Python](https://img.shields.io/badge/Python-3.10+-blue.svg)](https://www.python.org/downloads/) -[![License](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) -[![MCP](https://img.shields.io/badge/MCP-Protocol-blue.svg)](https://modelcontextprotocol.io/) -[![GEO](https://img.shields.io/badge/GEO-NCBI-orange.svg)](https://www.ncbi.nlm.nih.gov/geo/) - -> ⚠️ **Development Warning** ⚠️ -> -> **This project is currently in active development and is not yet production-ready.** -> -> - The MCP stdio server is functional but may have bugs or incomplete features -> - API endpoints and functionality may change without notice -> - Use at your own risk and report any issues you encounter -> -> We recommend testing thoroughly in a development environment before using in production. - -A Model Context Protocol (MCP) server for accessing [GEO (Gene Expression Omnibus)](https://www.ncbi.nlm.nih.gov/geo/) data through NCBI E-Utils API. -The tool will enable you to search for GEO datasets, series, samples, platforms, and profiles for your LLM. -Tested with Claude Desktop, chatGPT has no out of the box support for this tool yet. -Claude will automatically use the tools if it fits the context. - -## Quick Install (pip) - -install from pip -```bash -pip install geo-mcp -``` -install from source +# GEO MCP Server with SRA Support + +An enhanced [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server for accessing **GEO (Gene Expression Omnibus)** data through NCBI E-Utils API, with comprehensive support for **SRA (Sequence Read Archive)** raw sequencing data downloads and conversion. + +## Features + +### GEO Data Access +- 🔍 **Search GEO databases**: Series (GSE), Samples (GSM), Platforms (GPL), Datasets (GDS), and Profiles +- 📥 **Download metadata**: SOFT format, Series Matrix, MINiML XML +- 📦 **Download supplementary files**: Processed data tables, raw array data + +### SRA Raw Sequencing Data (NEW) +- 🔗 **Query SRA from GEO**: Map GEO Samples (GSM) to SRA Runs (SRR) +- 📊 **Size estimation**: Dry-run mode to check file sizes before downloading +- 💾 **Download & convert**: Integrated prefetch + fastq-dump workflow +- 🛡️ **Safety constraints**: Size warnings and confirmation requirements +- ✅ **Check sra-toolkit**: Verify installation and get setup instructions + +## Safety Features + +Following [maintainer recommendations](https://github.com/MCPmed/GEOmcp/issues/1), this server implements safety constraints for large file downloads: + +| Constraint | Implementation | +|------------|----------------| +| **Safe by Default** | `dry_run=True` by default - only estimates sizes | +| **Dry-Run Mode** | `sra_estimate_size()` tool for size checking | +| **Explicit Output** | Required `output_dir` for files >1GB | +| **Confirmation** | `confirm_large=True` required for files >5GB | + +## Installation + +### Prerequisites +- Python 3.10 or higher +- (Optional) [SRA Toolkit](https://github.com/ncbi/sra-tools) for downloading raw FASTQ files + +### Install from Source ```bash -# Clone the repo (if not already) -git clone https://github.com/MCPmed/GEOmcp -cd GEO_MCP +# Clone the repository +git clone https://github.com/yourusername/geo-mcp-server.git +cd geo-mcp-server + +# Install dependencies pip install -e . ``` -## Configuration +### Configuration -Run init to create a config file +1. **Initialize configuration:** ```bash -geo-mcp --init +python server.py --init ``` -This will create a config file at `~/.geo-mcp/config.json` (auto-created on first run if missing). -This file will contain the following: +2. **Edit the config file** at `~/.geo-mcp/config.json`: ```json { - "base_url": "https://eutils.ncbi.nlm.nih.gov/entrez/eutils", "email": "your_email@example.com", - "api_key": "YOUR_API_KEY" + "api_key": "YOUR_NCBI_API_KEY (optional)", + "download_dir": "~/geo_downloads", + "sra_toolkit_path": "/path/to/sra-toolkit/bin (optional)" +} +``` + +> **Note:** NCBI requires an email address for E-Utils access. An API key is optional but recommended for higher rate limits (10 req/s vs 3 req/s). Get one at [NCBI](https://ncbiinsights.ncbi.nlm.nih.gov/2017/11/02/new-api-keys-for-the-e-utilities/). + +## Usage + +### Running the Server + +**MCP stdio mode** (for Claude Desktop): +```bash +python server.py +``` + +**HTTP mode**: +```bash +python server.py --http --port 8000 +``` + +### Claude Desktop Integration + +Add to your Claude Desktop configuration (`~/.config/claude-desktop/config.json`): + +```json +{ + "mcpServers": { + "geo_mcp": { + "command": "python", + "args": ["/path/to/geo_mcp_server/server.py"], + "env": { + "CONFIG_PATH": "/home/yourusername/.geo-mcp/config.json" + } + } + } } ``` -It will also print out a configuration template for your claude desktop configuration file. +## Available Tools (18 Total) -- `email` is required by NCBI. -- `api_key` is optional but recommended for higher rate limits ([get one here](https://ncbiinsights.ncbi.nlm.nih.gov/2017/11/02/new-api-keys-for-the-e-utilities/)). +### GEO Search Tools -## Running the Server +| Tool | Description | +|------|-------------| +| `geo_search` | Search all GEO record types with natural language (e.g., "Human RNA-seq") | +| `geo_search_series` | Search GEO Series (GSE) - complete experiments | +| `geo_search_samples` | Search GEO Samples (GSM) - individual samples | +| `geo_search_platforms` | Search GEO Platforms (GPL) - array/sequencing platforms | +| `geo_search_datasets` | Search GEO Datasets (GDS) - curated gene expression | +| `geo_search_profiles` | Search GEO Profiles - gene expression profiles | -- **MCP stdio mode:** - ```bash - geo-mcp - ``` -- **HTTP mode:** - ```bash - geo-mcp --http --port 8001 - ``` +### GEO Download Tools -## Claude Desktop Integration +| Tool | Description | +|------|-------------| +| `geo_download_series` | Download GSE data files (SOFT, matrix, supplementary) | +| `geo_download_sample` | Download GSM supplementary files | +| `geo_get_download_status` | Check if a GEO dataset has been downloaded | +| `geo_list_downloads` | List all downloaded datasets | +| `geo_cleanup_downloads` | Clean up downloaded files | -### Common Issue: `spawn geo-mcp ENOENT` +### SRA Tools -This error means Claude Desktop cannot find the `geo-mcp` command. This is usually a PATH issue. +| Tool | Description | +|------|-------------| +| `sra_query_from_geo` | Query SRA Run information from a GEO Series | +| `sra_get_metadata` | Get SRA run metadata | +| `sra_estimate_size` | **NEW** Estimate download sizes before downloading (dry-run) | +| `sra_generate_download_commands` | Generate download commands for various methods | +| `sra_check_toolkit` | Check sra-toolkit installation | +| `sra_download` | Download SRA directly via HTTP (small files only) | +| `sra_download_and_convert` | **NEW** Download with prefetch and convert to FASTQ | -### Solution -1. **Find the full path to the executable:** - ```bash - which geo-mcp - ``` - Example output: `/Users/youruser/miniforge3/bin/geo-mcp` +## Example Workflows -2. **Update your Claude config:** - Instead of just `"geo-bio-mcp"`, use the full path: - ```json - { - "mcpServers": { - "geo-mcp": { - "command": "/Users/youruser/miniforge3/bin/geo-mcp", - "env": { - "CONFIG_PATH": "/Users/youruser/.geo-mcp/config.json" - } - } - } - } - ``` +### 1. Search with Natural Language -3. **(Optional) Use a Conda Environment:** - - Activate your conda env and run `which geo-mcp` to get the correct path. - - Use that path in your Claude config as above. +``` +User: Find Human RNA-seq datasets -4. **Restart Claude Desktop** after updating the config. +AI: I'll search for Human RNA-seq datasets in GEO. +[Uses geo_search_series with term="Human RNA-seq"] ---- +Results: +- GSE319287: "TPD52 Inhibits Aldosterone Synthesis..." (6 samples) +- GSE213095: "Positive feedback between cDC1s and CD8+ T cells..." (46 samples) +- GSE320065: "Transcriptomic and Functional Responses of Human Airway Cells..." (56 samples) +``` -## Troubleshooting -- If you see `command not found: geo-mcp`, make sure you installed with the correct Python/conda environment and that its `bin` directory is in your PATH. -- If the config file is missing, it will be auto-created on first run, or you can copy the template from `geo_mcp_server/config_template.json`. +### 2. Download Processed Data ---- +``` +User: Download the metadata for GSE12345 -## Usage +AI: I'll download the SOFT file and series matrix for GSE12345. +[Uses geo_download_series with geo_id="GSE12345", file_types=["soft", "matrix"]] + +Results: +✓ Downloaded GSE12345_family.soft.gz (1.2 MB) +✓ Downloaded GSE12345_series_matrix.txt.gz (5.6 MB) +Location: ~/geo_downloads/gse/GSE12345/ +``` -### MCP Server (stdio mode) -For use with MCP clients like Claude Desktop: +### 3. Find and Download Raw Sequencing Data (Safe Workflow) -```bash -cd geo_mcp_server -python main.py --mode stdio +**Step 1: Get SRA accessions** ``` +User: Get the SRA run accessions for GSE272467 -### HTTP Server (localhost:8000) -For HTTP API access: +AI: I'll query the SRA information from this GEO Series. +[Uses sra_query_from_geo with gse_id="GSE272467"] -```bash -# Option 1: Using the main script -cd geo_mcp_server -python main.py --mode http --host localhost --port 8000 +Results: +- Total samples: 4 +- Samples with SRA data: 4 +- Total SRA runs: 4 +- All SRA accessions: ["SRR29864801", "SRR29864802", "SRR29864803", "SRR29864804"] +``` -# Option 2: Using the convenience script -python run_server.py +**Step 2: Estimate size (recommended)** ``` +User: How large are these files? -### HTTP API Endpoints +AI: Let me estimate the download sizes. +[Uses sra_estimate_size with sra_ids=["SRR29864801"]] -Once the HTTP server is running on localhost:8000, you can access: +Results: +- SRA size: ~76 MB +- Estimated FASTQ size: ~190 MB +- Read count: 7,178,576 +- Warning: Small file (< 100 MB) +``` -- **Root**: `GET /` - Server status -- **Health Check**: `GET /health` - Health check endpoint -- **List Tools**: `GET /tools` - List all available tools -- **Call Tool**: `POST /tools/call` - Execute a tool with arguments -- **API Documentation**: `GET /docs` - Interactive API documentation (Swagger UI) +**Step 3: Download and convert** +``` +User: Download and convert SRR29864801 to FASTQ -### Example HTTP API Usage +AI: I'll download the SRA file and convert it to FASTQ format. +[Uses sra_download_and_convert with sra_id="SRR29864801", dry_run=False] + +Results: +✓ Downloaded SRR29864801.sra (76 MB) +✓ Converted to FASTQ + - SRR29864801_1.fastq (660 KB, 5000 reads) + - SRR29864801_2.fastq (660 KB, 5000 reads) +Location: ~/geo_downloads/sra/SRR29864801/ +``` +### 4. Safety Constraints in Action + +``` +User: Download SRR1234567 + +AI: First, let me estimate the size before downloading. +[Uses sra_estimate_size with sra_ids=["SRR1234567"]] + +Results: +- SRA size: ~2.5 GB +- Estimated FASTQ size: ~6.25 GB +- Warning: LARGE FILE (1-5 GB) + +⚠️ This is a large download that requires explicit output directory. +Please provide output_dir parameter to proceed. + +User: Download to /data/sra + +AI: +[Uses sra_download_and_convert with + sra_id="SRR1234567", + dry_run=False, + output_dir="/data/sra"] + +✓ Download complete! +``` + +### 5. Check SRA Toolkit + +``` +User: Do I have sra-toolkit installed? + +AI: Let me check your sra-toolkit installation. +[Uses sra_check_toolkit] + +Results: +✓ prefetch: available (version 3.3.0) +✓ fastq-dump: available (version 3.3.0) +✓ fasterq-dump: available (version 3.3.0) +✓ vdb-validate: available (version 3.3.0) + +All tools are installed and ready to use! +``` + +## SRA Download Methods + +### Method 1: Using sra_download_and_convert (Recommended) + +Integrated workflow with safety features: +```python +# Step 1: Estimate size (dry-run) +sra_estimate_size(sra_ids=["SRR1234567"]) + +# Step 2: Download and convert based on size +# Small file (<1GB) +sra_download_and_convert(sra_id="SRR1234567", dry_run=False) + +# Medium file (1-5GB) - requires output_dir +sra_download_and_convert( + sra_id="SRR1234567", + dry_run=False, + output_dir="/path/to/output" +) + +# Large file (>5GB) - requires confirmation +sra_download_and_convert( + sra_id="SRR1234567", + dry_run=False, + output_dir="/path/to/output", + confirm_large=True +) +``` + +### Method 2: Using SRA Toolkit Manually + +1. **Install sra-toolkit**: Follow instructions at https://github.com/ncbi/sra-tools + +2. **Download and convert**: ```bash -# List available tools -curl http://localhost:8000/tools - -# Search GEO Profiles -curl -X POST http://localhost:8000/tools/call \ - -H "Content-Type: application/json" \ - -d '{ - "name": "search_geo_profiles", - "arguments": { - "term": "cancer", - "retmax": 5 - } - }' - -# Search GEO Datasets -curl -X POST http://localhost:8000/tools/call \ - -H "Content-Type: application/json" \ - -d '{ - "name": "search_geo_datasets", - "arguments": { - "term": "breast cancer", - "retmax": 10 - } - }' +# Download SRA file +prefetch SRR1234567 + +# Convert to FASTQ (3-way split for paired-end) +fastq-dump --split-3 SRR1234567 ``` -## Available Tools +### Method 3: Direct HTTP Download (Small Files Only) + +For small files or when sra-toolkit is not available: +```bash +# Using wget +wget https://sra-downloadb.be-md.ncbi.nlm.nih.gov/sos2/sra-pub-run-11/SRR1234567/SRR1234567.1 -This MCP server provides access to all major GEO databases through the following tools: +# Using curl +curl -o SRR1234567.sra https://sra-downloadb.be-md.ncbi.nlm.nih.gov/sos2/sra-pub-run-11/SRR1234567/SRR1234567.1 +``` -### Search Tools +## Project Structure -- **`search_geo_profiles`** - Search gene expression profiles across different biological contexts - - *Example searches*: "cancer", "breast cancer", "p53", "apoptosis" - -- **`search_geo_datasets`** - Search curated gene expression datasets - - *Example searches*: "diabetes", "Alzheimer's disease", "drug response", "tissue specific" - -- **`search_geo_series`** - Search original submitter-supplied gene expression series - - *Example searches*: "GSE12345", "microarray", "RNA-seq", "time course" - -- **`search_geo_samples`** - Search individual gene expression samples - - *Example searches*: "GSM123456", "human", "mouse", "tumor", "normal" - -- **`search_geo_platforms`** - Search microarray platform definitions - - *Example searches*: "Affymetrix", "Illumina", "Agilent", "GPL96" +``` +geo_mcp_server/ +├── geomcp_sra/ +│ ├── __init__.py +│ ├── config.py # Configuration management +│ ├── geo_search.py # GEO search functionality (E-Utilities) +│ ├── geo_download.py # GEO data download +│ └── sra_handler.py # SRA query, size estimation, download & convert +├── server.py # Main MCP server with 18 tools +├── config.json # Configuration template +├── pyproject.toml # Project dependencies +├── requirements.txt # Python dependencies +├── README.md # This file +└── SKILL.md # Skill documentation +``` -### Search Parameters +## Comparison with Original GEOmcp -Each tool accepts: -- **`term`** (required): Search term or query string -- **`retmax`** (optional, default: 20): Maximum number of results to return +| Feature | GEOmcp (Original) | This Project (geo-mcp-server) | +|---------|-------------------|---------------------------| +| GEO Search | ✓ | ✓ (Enhanced with natural language) | +| SOFT Download | ✓ | ✓ | +| Matrix Download | ✓ | ✓ | +| Supplementary Files | ✓ | ✓ | +| **SRA Query** | ✗ | **✓** | +| **SRR Mapping** | ✗ | **✓** | +| **Size Estimation** | ✗ | **✓ (Dry-run mode)** | +| **Download & Convert** | ✗ | **✓ (Integrated workflow)** | +| **Safety Constraints** | ✗ | **✓ (>1GB, >5GB checks)** | +| Tool Count | 11 | **18** | -### Example usage -For now just chat with claude desktop and it will use the tools automatically if it fits the context. +## Configuration Options -**User:** find a small brain dataset +### Full Config File Reference + +>>>>>>> ad562a4 (initialize files, commit to branch Test) +```json +{ + "base_url": "https://eutils.ncbi.nlm.nih.gov/entrez/eutils", + "email": "your_email@example.com", + "api_key": "YOUR_NCBI_API_KEY (optional but recommended)", + "download_dir": "~/geo_downloads", + "max_file_size_mb": 5000, + "max_total_downloads_mb": 10000, + "max_concurrent_downloads": 3, + "download_timeout_seconds": 300, + "allowed_download_paths": ["~/geo_downloads", "/tmp/geo_downloads"], + "sra_toolkit_path": "/path/to/sra-toolkit/bin (optional)" +} +``` -**Assistant:** I'll help you find a small brain dataset from the GEO database. Let me search for brain-related datasets with a maximum of one result. +## Troubleshooting -**Assistant (system):** *search_geo_datasets* +### Email not configured +``` +Error: Email is required for NCBI E-utilities. +``` +**Solution**: Edit `~/.geo-mcp/config.json` and add your email address. -**Assistant:** I found a brain-related dataset for you: +### SRA toolkit not found +``` +fasterq-dump not found. Please install sra-toolkit. +``` +**Solution**: Install sra-toolkit from https://github.com/ncbi/sra-tools and set `sra_toolkit_path` in config. -- **Dataset:** GSE290848 -- **Title:** Maternal Stress Triggers Early-Life Eczema via Fetal Mast Cell Reprogramming [10x Genomics Multiome ATAC + Gene Expression] -- **Species:** Mouse (*Mus musculus*) -- **Samples:** 8 -- **Data type:** ATAC-seq & gene expression (10x Genomics Multiome) -- **Publication date:** 21 Jun 2025 +### Rate limiting +``` +Error: Rate limit exceeded +``` +**Solution**: Add an NCBI API key to your config for higher rate limits (10 req/s vs 3 req/s). -This small dataset explores how prenatal stress affects neuro-immune circuits and mast-cell development. +### Large file safety error +``` +SAFETY CHECK: File size is ~2.5 GB. Large downloads require an explicit output directory. +``` +**Solution**: Provide `output_dir` parameter for files >1GB, or both `output_dir` and `confirm_large=True` for files >5GB. -Would you like me to download this dataset or search for others? +## References ---- +- [GEO Home](https://www.ncbi.nlm.nih.gov/geo/) +- [SRA Home](https://www.ncbi.nlm.nih.gov/sra) +- [NCBI E-Utilities](https://www.ncbi.nlm.nih.gov/books/NBK25501/) +- [SRA Toolkit Documentation](https://github.com/ncbi/sra-tools/wiki) +- [MCP Documentation](https://modelcontextprotocol.io/) -**User:** download GSE290848 +## License -**Assistant:** I'll download the GSE290848 dataset for you. +MIT License - See LICENSE file for details. -**Assistant (system):** *download_geo_data* +## Contributing -**Assistant:** Download complete. +Contributions are welcome! Please feel free to submit issues or pull requests. -**Downloaded files** +### Contributing SRA Support Back to Original GEOmcp -- `GSE290848_family.soft.gz` — series, platform & sample info -- `GSE290848_metadata.xml` — detailed study metadata +This project implements the [enhancement request](https://github.com/MCPmed/GEOmcp/issues/1) for SRA raw data support with the following safety features as recommended by maintainers: +- Safe by default (dry-run mode) +- Size estimation before download +- Explicit output directory requirement for large files +- Confirmation for very large files (>5GB) -**Location:** `/Users/matthiasflo/Documents/2025/GEO_MCP/geo_mcp_server/downloads/gse/GSE290848` -**Total size:** 0.01 MB +## Acknowledgments -The dataset is ready for analysis. Let me know if you’d like to explore it further. +- Original [GEOmcp](https://github.com/MCPmed/GEOmcp) project for the foundation +- NCBI for providing the GEO and SRA databases +- MCP team for the Model Context Protocol diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..f1e08d8 --- /dev/null +++ b/SKILL.md @@ -0,0 +1,245 @@ +--- +name: geo-mcp-sra +description: MCP server for accessing GEO (Gene Expression Omnibus) data with comprehensive SRA (Sequence Read Archive) raw sequencing support. Enables natural language search, metadata download, size estimation with dry-run mode, and safe FASTQ downloads with prefetch/fastq-dump integration. +--- + +# GEO MCP Server with SRA Support + +This skill provides a Model Context Protocol (MCP) server for accessing NCBI's GEO and SRA databases programmatically with enhanced safety features for large file downloads. + +## Capabilities + +### GEO Data Access +- Search GEO databases (GSE, GSM, GPL, GDS, Profiles) using natural language +- Download SOFT format metadata files +- Download series matrix files +- Download supplementary processed data + +### SRA Raw Sequencing Data (with Safety Features) +- Query SRA Run accessions from GEO Series +- Map GSM samples to SRR run accessions +- **Estimate download sizes** (dry-run mode) +- **Download & convert** with integrated prefetch + fastq-dump workflow +- **Safety constraints**: Size warnings, explicit output directory, confirmation for large files + +## Installation + +```bash +cd /path/to/geo_mcp_server +pip install -e . +``` + +## Configuration + +1. Create config file: +```bash +python3 server.py --init +``` + +2. Edit `~/.geo-mcp/config.json`: +```json +{ + "email": "your_email@example.com", + "api_key": "YOUR_NCBI_API_KEY (optional but recommended)", + "download_dir": "~/geo_downloads", + "sra_toolkit_path": "/path/to/sra-toolkit/bin (optional)" +} +``` + +> **Note:** NCBI requires an email address. API key provides higher rate limits (10 req/s vs 3 req/s). + +## Usage with Claude Desktop + +Add to `~/.config/claude-desktop/config.json`: + +```json +{ + "mcpServers": { + "geo_mcp": { + "command": "python3", + "args": ["/path/to/geo_mcp_server/server.py"], + "env": { + "CONFIG_PATH": "/home/username/.geo-mcp/config.json" + } + } + } +} +``` + +## Available Tools (18 Total) + +### Search Tools (Natural Language Support) + +All search tools support natural language queries like "Human RNA-seq", "mouse brain single cell", "breast cancer transcriptome". + +| Tool | Description | Example Query | +|------|-------------|---------------| +| `geo_search` | Universal GEO search | "cancer RNA-seq" | +| `geo_search_series` | Search GSE records | "Human RNA-seq" | +| `geo_search_samples` | Search GSM records | "HeLa cell line" | +| `geo_search_platforms` | Search GPL records | "Illumina HiSeq" | +| `geo_search_datasets` | Search GDS records | "breast cancer" | +| `geo_search_profiles` | Search GEO Profiles | "p53 expression" | + +### GEO Download Tools + +| Tool | Description | +|------|-------------| +| `geo_download_series` | Download GSE data (SOFT, matrix, supplementary) | +| `geo_download_sample` | Download GSM supplementary files | +| `geo_get_download_status` | Check download status | +| `geo_list_downloads` | List downloaded datasets | +| `geo_cleanup_downloads` | Clean up files | + +### SRA Tools + +| Tool | Description | Safety Features | +|------|-------------|-----------------| +| `sra_query_from_geo` | Get SRA accessions from GEO Series | - | +| `sra_get_metadata` | Get SRA run metadata | - | +| `sra_estimate_size` | **Estimate sizes (dry-run)** | Shows warnings for >1GB, >5GB | +| `sra_generate_download_commands` | Generate download commands | - | +| `sra_check_toolkit` | Check sra-toolkit installation | - | +| `sra_download` | Direct HTTP download | Small files only | +| `sra_download_and_convert` | **Download & convert to FASTQ** | dry_run=True default, size checks | + +## Safety Features + +### Default Safe Behavior + +All SRA downloads default to **dry-run mode** (`dry_run=True`): + +```python +# This only estimates size, does NOT download +sra_download_and_convert(sra_id="SRR1234567") +``` + +### Size-Based Safety Constraints + +| File Size | Required Parameters | +|-----------|---------------------| +| < 1 GB | `dry_run=False` | +| 1-5 GB | `dry_run=False` + `output_dir="/path"` | +| > 5 GB | `dry_run=False` + `output_dir="/path"` + `confirm_large=True` | + +### Safety Check Examples + +**Error for >1GB without output_dir:** +``` +SAFETY CHECK: File size is ~2.5 GB. Large downloads require an explicit +output directory. Please provide output_dir parameter. +Tip: Run with dry_run=True first to see size estimates. +``` + +**Error for >5GB without confirmation:** +``` +SAFETY CHECK: File size is ~6.2 GB (>5GB). This is a VERY LARGE download +that will consume significant disk space and time. +To proceed, set confirm_large=True. +``` + +## Example Workflows + +### Workflow 1: Search with Natural Language + +```python +# Search for Human RNA-seq datasets +geo_search_series(term="Human RNA-seq", retmax=10) + +# Search for specific tissue + disease +geo_search_series(term="mouse brain Alzheimer's", retmax=5) +``` + +### Workflow 2: Safe SRA Download + +**Step 1: Always estimate first** +```python +sra_estimate_size(sra_ids=["SRR1234567"]) +# Returns: SRA size, FASTQ estimate, read count, safety warnings +``` + +**Step 2: Download based on size** + +Small file (<1GB): +```python +sra_download_and_convert( + sra_id="SRR1234567", + dry_run=False, + split_3=True, # Properly handle paired-end + check_refseq=False # Skip refseq to save space +) +``` + +Medium file (1-5GB): +```python +sra_download_and_convert( + sra_id="SRR1234567", + dry_run=False, + output_dir="/data/sra", # Required! + split_3=True +) +``` + +Large file (>5GB): +```python +sra_download_and_convert( + sra_id="SRR1234567", + dry_run=False, + output_dir="/data/sra", # Required + confirm_large=True, # Required + split_3=True +) +``` + +### Workflow 3: Complete Analysis Pipeline + +```python +# 1. Search for datasets +results = geo_search_series(term="GLOR2 m6A", retmax=5) + +# 2. Get SRA accessions for a dataset +sra_info = sra_query_from_geo(gse_id="GSE272467") +# Returns: 4 SRR accessions + +# 3. Estimate sizes +sizes = sra_estimate_size(sra_ids=sra_info["all_sra_accessions"]) +# Shows: ~76 MB each, total ~304 MB + +# 4. Download and convert +for sra_id in sra_info["all_sra_accessions"]: + sra_download_and_convert( + sra_id=sra_id, + dry_run=False, + split_3=True, + check_refseq=False + ) +``` + +## Architecture + +``` +geo_mcp_server/ +├── geomcp_sra/ +│ ├── config.py # Configuration management +│ ├── geo_search.py # NCBI E-Utilities search +│ ├── geo_download.py # FTP/HTTP downloads +│ └── sra_handler.py # SRA query, size estimation, download & convert +├── server.py # MCP server with 18 tools (FastMCP) +├── config.json # Config template +└── pyproject.toml # Project metadata +``` + +## Dependencies + +- `mcp>=1.9.0` - MCP Python SDK +- `httpx>=0.27.0` - Async HTTP client +- `aiofiles>=23.0.0` - Async file operations +- `pydantic>=2.0.0` - Input validation + +## References + +- [GEO Documentation](https://www.ncbi.nlm.nih.gov/geo/info/) +- [SRA Documentation](https://www.ncbi.nlm.nih.gov/sra/docs/) +- [SRA Toolkit](https://github.com/ncbi/sra-tools) +- [MCP Specification](https://modelcontextprotocol.io/) +- [Original GEOmcp Issue #1 - SRA Support](https://github.com/MCPmed/GEOmcp/issues/1) diff --git a/__pycache__/server.cpython-312.pyc b/__pycache__/server.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8646bebd9f56dfb62d7d69c08bd032461f7ff7d1 GIT binary patch literal 31226 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 literal 0 HcmV?d00001 diff --git a/__pycache__/server.cpython-313.pyc b/__pycache__/server.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ef9e707a3c2df96757109357dcead75a4ca91285 GIT binary patch literal 36166 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 K)v<-CJ^nB0d_F$_ literal 0 HcmV?d00001 diff --git a/config.json b/config.json new file mode 100644 index 0000000..3251bd4 --- /dev/null +++ b/config.json @@ -0,0 +1,17 @@ +{ + "base_url": "https://eutils.ncbi.nlm.nih.gov/entrez/eutils", + "sra_base_url": "https://trace.ncbi.nlm.nih.gov/Traces/sra", + "email": "your_email@example.com", + "api_key": "", + "retmax": 20, + "download_dir": "./downloads", + "max_file_size_mb": 5000, + "max_total_downloads_mb": 50000, + "max_concurrent_downloads": 3, + "download_timeout_seconds": 300, + "sra_toolkit_path": "", + "allowed_download_paths": [ + "./downloads", + "/tmp/geo_downloads" + ] +} diff --git a/geomcp_sra/__init__.py b/geomcp_sra/__init__.py new file mode 100644 index 0000000..1597189 --- /dev/null +++ b/geomcp_sra/__init__.py @@ -0,0 +1,10 @@ +""" +GEO MCP Server with SRA Support + +An enhanced MCP server for accessing GEO (Gene Expression Omnibus) data +through NCBI E-Utils API, with additional support for SRA (Sequence Read Archive) +raw data download capabilities. +""" + +__version__ = "0.2.0" +__author__ = "GEO MCP Contributors" diff --git a/geomcp_sra/__pycache__/__init__.cpython-313.pyc b/geomcp_sra/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cb57390858e61bc76a73f45eb3886afe9ff63662 GIT binary patch literal 467 zcmXw0!Ab)$5Z!HSm6T#Z@hAf#SkP|qstC1|(t}#Mcn;|%b_3gFon%|>Nk77yKj0rp z@#YUG{Q;8}=aBF+Gw;2btm<_VxH#T>mxo~xe0%U84zFX?aMxQ92N=X5hH(i?lXA1N z_#*w@9o%$>5DT0Mgt@dS#G?+xMV@Q4gme@Ll`@qGzAiMI!Uv7Ol0+CI)dW1tNna=d zy{8=gqgHS?RdQUICU9mMu_@|elEQ8GdH}t%dn+>oo#Ehgqscgz)^)KA%tji#gtu}M zi^oDZ127VdLkE*o&P0$%FbOC>C4W0^T3VOnId)oz`)+Kc`F8M|6pbe60} zbK5L0w<+a1p>$cJ)D7=VJ3uI9g-tb5`Vp-B`j!{bRN06}H_TXpk@FKd&S)YuohCWm zti;1LLKaafm9&($^5^BwJO)Jj_3dY literal 0 HcmV?d00001 diff --git a/geomcp_sra/__pycache__/config.cpython-312.pyc b/geomcp_sra/__pycache__/config.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0941b064bea2614f60934e8d5d67be00ae67a336 GIT binary patch literal 5719 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 literal 0 HcmV?d00001 diff --git a/geomcp_sra/__pycache__/config.cpython-313.pyc b/geomcp_sra/__pycache__/config.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a906d0d092fed1f8ddf4d8934e4966cafda7cb43 GIT binary patch literal 5846 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 T4=A`j)Lc@~n)?)nq}~4yy~ai| literal 0 HcmV?d00001 diff --git a/geomcp_sra/__pycache__/geo_download.cpython-313.pyc b/geomcp_sra/__pycache__/geo_download.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..255e72249b505426cdc0805d103505147b306da0 GIT binary patch literal 20411 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 OJFB8~pHS$Ne*ZrJY#hh{ literal 0 HcmV?d00001 diff --git a/geomcp_sra/__pycache__/geo_search.cpython-312.pyc b/geomcp_sra/__pycache__/geo_search.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b60d5d8b8e7fb75a6c14073ec5f103d971ce747b GIT binary patch literal 15626 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 ZHMDW^A~!PL{{cU2{Wbsq literal 0 HcmV?d00001 diff --git a/geomcp_sra/__pycache__/geo_search.cpython-313.pyc b/geomcp_sra/__pycache__/geo_search.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..879474c3cd475db2423665713124ceb6ddff64cd GIT binary patch literal 15327 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 C#1c#Z literal 0 HcmV?d00001 diff --git a/geomcp_sra/__pycache__/sra_handler.cpython-312.pyc b/geomcp_sra/__pycache__/sra_handler.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ceecbf8ce63cb5e4a8e24528cd0d652b82b61802 GIT binary patch literal 23239 zcmd6Pc~l&Co?lhp7j$!94HP#>(C9=69Twt{7w0AZCus@qlSVUnlh5~8 zUDXY=uiflR3i?;S`kmk3ud2THxBh)bhK0lN*Ew%{@15qj|4J9ip-*Ji7m>NdiCiBi z@}g#p@8j87)2Cr)ZJ(B%b$vQ^*7xbz*)VP#)bQN6sn5()Pwkjx+}dYldEJ<8JfkmT zJhLxz+}>v&cl0^Nv-+}Fon|b%FI&`$hOwOS+`e3%({RT)(fB$inml>$Yh|eV@>!`F zrIzSbHe1g|EG$D<15;X_EWu22}9m7Ks4p2rTZm!Q($6Z?2I>H`HXh2r-6^@o^%IB8uT$; zr*|+A)3*DjW4gxxPt2qY8q@bqO^$g!!wz%kU>WuV`UfX`L*C&*`4-42RnxKVKyr!m zaCl-kdRqFlxI@~Q2>^FV(u5SO?>ax|VGs+F+w1p;Z0V?Ol00Kruqb$_o!`~dpo^LM z`+e?lPk(>R(%&!NC**DY{Vzt{FL zBe^E0B^&BlQhP-9Q2+1xV|^$yXlj+}r9JsqgYO4JYxS-8$Nb7oE~6>}5_#9Z{u^W@0zB-Wvf zVG{FS*Z1YJHu5?OFh(95ql}GQ=*efj3sQC=*}Dk63&mnj0ooK`Yz20MSb}kiHqE9H zohU7y&Tc4=S@4V;rx)2H1-BQPf?}%%7;%sv-75nK~2{tKBJyQYin15?qk1rs3g3WUE!y`&_dbyMrb-*>? zX&M)|_VrKrchM|OlT!mt(v+`h=gvXpy`hIphacg(^Ieg@+b9`<|2hWxbsf<)jG;`92vf&TtrPV%vL zDTUQ&;(ry%E8LxY;d^7Vx(n85UeTpzFFw0a5Xo!6wIy0ma_Pm3FJ5~2;>!!uD+N1I zW{V2dKeWDU{h|F`dw9pu6`^BR_lc?Wj#F63{b9+wC5z8TDh{qV53M@8BF?TA=doKg z5$6-srYLH%t(uA=rlM6-dBjw{s9OqzP31o^9b||zX7ilK%Y{9Y+~fY3LGlEqB;TO= z5CT$KJ$tyngA7;;9ybrU&4-r{@qfV2XwpA+l0AKSNF!^PYdnz!3+vgo*1l+}L7Q0Uknv}d&qFt71k;Dx0!u+%VK}g)9 zjQ#0L-xV7w}`8`+KF1LlVS{9v1W(`rR{mp~Faggqe?Ooe! z_uT%7tu$)So6V4S)-de%^n1k(dr2?AYxu4IPdqW-;&|+5h%soz+*_QeeMd8+5p^LA z^7;^u)WA~XJH{Dp5~-v^AIht?hkFgSiatepk-e%-o!zf$xPu>T^2mJ>`*i9(y+fxy`nn#S!)CP+Lh#6g%h0{8mSj2*;~G8 zh$JjPg#o-~g4-|n0}@zJwPVj`|NB?J`c+Kp^ZA1H3JDzwOl~EaVC9H!QV<7s#yat)uY%s_41j+fBwcS7M8|)bK5~;61r8&pXL;<=2S;=s#kJq z?{gXELvczLYon!ASNbmZEozr~=lfPl+g1zOW{hLLRR2rA7hCdbQ^#KZkNA#4!;%)ce^t;SXs=oLj$*?# z>t5uqb9{%>aGh`0Baz&;2$h<6`Q)=}<7; z7vXAAWzeL0VAyms>E2J2ih5#PM!#$p?J($VNK170!eb$=pTF<~D2*;1YpFnX7I^K# zfM}f22UJWw%G41XmlD#4bP5gyzQ!yJnsiv(pg;`#ZVKrzgI+YVGk@XIg?Sxd{4%Dp zlY7(nrfE>??L0kQ^r@A_y zJ9+Xu;`WAMuFwgU!~k>{Oln7KYLi2HicN(LrkLJ82^9-YpbbEuK&Dc_PyAU-KQ%e& zkzyLj6EhBZeWH5|SQ2E-;}c_gZ@@F|Z!pUU^(X;i8TOBZYbSX>W*wZ8h&As=Sxg6& zj|bSBF{T0Vf4-OzN}ItEk3YtriScLsv=9ZqCb+tC;^(jc>g85dH~n-cWrVve72$q) z+a3(>{|pJkzWIf-79h1*>sqd`np+#mtz8JLpvdu-aoza|vOKlh1zISl8Gj8A< zxo^Jw#>?}ePwb7+vdSx?mq!m zPtCdKvo4C^yhmm&k-SIN>_uTy(a+cQT>cT>Pa2jR8cRok;YNlY=?1Jv+x;4T>z@Lz zD12)=Jfz@@bod9{RRTx(GXp9|Dr$(weMiNVukvPyis^7sNbL`9Rl9*(C8>j8+;oK0 zQGubkQDBM9$Cr#rd~+JSrK}Jb{ZIny5NE0nY0q*}b3he?L)s9-#LAk%hZ@ia(j(!h zknt=JJY`Z^rQ=XTCZ#XWT`1LY7xsq?)Slt(M}Qx-Uk=}!`Zo=O!1ch7z|;&s%8(jF z-P@XrnhQ2yNAnEIl29wFo3UW@G~&2YkCmVn>$Z&MZQfIGYTBrjBN?jDRm*MvLES3Ime|)D^~Ykf}nnd^4y&w%cP-=vX0&p7@2@- znA8k|P?tl3SE<>6CveW=@iD6AcaK9`?H5P|NZNO(*^;!3kz?waqOMmdYnqwAzJMgu zdtAe=MxhRp)6QMH_w21}u&9LPn^0(`pa6v-z*Ef(=|}52&z8GUhsC~{RAixNwjNC*9_#7_8!&GZkdT-+6pC=(gA44 zq4AKReVpxPOB!(H#%R8gQqzQ$xbt$qeU*(u@~XA+xwNp%Y$@H?E?FPbVgrMY6TY*s zAc;&)KGE4G^fYKrST@+6O%Z%f~yTBE$_QjGU7 zRz`Wq=qxK{v&qOXom?ga@nC5ROSrY#kAeLbpg~y--^Drd-weGG`puVC?e!6R{bJrH z_U1d;#o^MX#i7M-hf7*kviF5;`{KH0^KpKyqGq*XZ=_=H($Gr9q1BQ@vyVlKN>_^x zMT!nBkF6AaYxYraaMkszRR<$g2bZ5-sp?!U?VRmFVa@K<>efhg>q>RoJ((IRf*w(XjGbpGt^+}d~+2966{!|v7k)<}KpN`2dXt3k-TYvJ;0 z=DO#P$BkThO|;gvuGeN|$F-;m+njefoIlUz3W~qyfl%+PU3E4^oJ}jv=6f8U(J*I< zItngjUd)_7{}V@jT#F*iSyFMO@p9u^t~(Xg;hMcG6)ktF>%y3^djFlunuX!tYrIq2 z_(AVd-TTk2)Edf%*~8*#*EDPBb@W_&oM+<7yoeGiMbzq-pD!fR_)ahd-Xj| zYkr!K+8u8O-w3YSYa;fV#hlyr#yk0C3nhzBFCGu?`L%HQw^#C?2|J!)_vyfL`@enk z&mLVpcrtSEWca|Ta7ph<_UW+g^e^t%3$X&+HrDRYj|Z<-{Ke2R?rHZ*_OY<-*e|dc z)c(>h;hA{Vb!-R!quj1~?wX$OD%D*xG*G^o?-F#^cI-v|dhJf6Hwx@sm4+L&?Jc3B2vF6{x)gP@HQ7hWDJ&vSO5QJF#+hn3on_4IYwnvr;TaEftU{3iE&a(2jJx3 zd6?1c@q66xUU3Z#4txK*{2U)X@}5R&KwjF3B$knA0=4nf7^tfk9xkATc$vBPw9*tc z(RfAa95N3xG2RrsN2F;sAoS5hVWk#Iwoy`nB$f%d`@2r}o_Oq3|LK#DNv%{v)JyUr zQFJbhn=#0*#3>nshA&ik}Xk8fm{iOSe8L@1zMt<-{G8BoXY4k7BuVchuc?v;I z$5mWKBmbLF11Os9?A%#HT(`}f^=W}~wV*yyP`^^J?LJqL(HN&>acE7bUKMslgk90% z@@RR@{K&#k*x7{KU88PK_ByAl$eGKC+qlYlQcc5lx>~R;Qm`$YzkTsgICp=vq(15t zK+Gx|(Y7-CKBseL&zYcW&MI1UG(;Q?pE$O!mDOGuzdZid1T-_nHCLK1H!n6X?^-E7 zyjpxTQhfAk>#dV3#ZS%||4Ttxw4~yG9ROn>-eqt$a+S5K6}ux9y9vbNiiL{H+hq9m z5`2|8bD8PkI~>k!i(a@RHl8 z^!ml;X3kLrcEOyrW^=5Ou`+JP#V`HWabsT9c4hM4E$lqZ{SMz*X1Jn7?spG$)@!eo z^PPg>n$T{>#q|ao%CGO`J1Y&>TXd9fC~X*%`%?y0#9u!S-zU(YgxYD(0Rak)5Ul0wB2krQoe;Cxw)V9J;3sZ&0SXQ zheiVULkmxNE9JqBf=S$DbXDo)ORDnf%PA#H+vY_@z zqm)u;+6)w30hQ*B5>LMAP+Bsh?pB4Ds%lDhgISg*Z@d)RqlFqhjrkL_ha9Ii9iY`l z&>q<|jNPI`RkkU#niOMcls*cypw+rjRiz`3L8($dL#E|45Q&x)TAi-mnnJ5#3icOZ ztu`{_zO4n#*3zFs>tUANuwMz9oqop*qMgxfz352UH{&21RoZP)E16$1 zpW`=h19`!JLEON&C*URm!@R)UJ|@XR>F~9f0Qn7vWhIqXt7@F20^u7ea%eD8+$eyl zq(5kTK4~baPAU+n15+T#GAJXqgfpb`luT0+q~t|PLP)xURU08rO8s6R^MWHwOM_PW zE*i)5OdkM0C-SIdtbsH`BfX5IH_=F%rIscIt*d3xVW+1 zh6?Z^U2Wh+bd*2Hqw|eJ2T^--Ki_rGaI>ue`46k8<%f{n4;wz*K`lSrLoGkt%TvCU zr#5XYj}eeRO!<#ELwC0EBLm-UwtZwYQa+PF`6!F}ew5AfdFJjm?MGz=-Fvkk?bTDh zO^ezMvb^d}a1aY5XRt3bs?ESbX-31`!zU&lS(_oPT=$_(J=_(89MDPloL~!loU}x35R~r|23p z07=2N5wpN)X>uw+oomT47!w{3un~dblJEm_r+Y`^Ls$sZB|I#6HRLf7GN2sR zOH$K>j3D89*e7-A7+3PC5TPVNOIWf*bCOwteX>pm+nP@ROtOby=hueG%3!k0m{VXf zV-DNbhAD%=l$j1D-zp%ovt4RqPHzh~zOU@BMRX))w_rzfUt>qIw#?q5<9zd& zJ)6ybl+BK}96SD%v$MH#*s5|dyH(5!S=s&hy3pirf#xuvc?!@JFlY`lXrLyTF^Yv? zTZ_bE=7EYikuOp5m{WnmJYy4`Asd4N&&t=@lhQ3vv;m6Dly{(vkXfqjKg5&IP)LV8 zN~7*e+VjQoxAj1Jg8NC_GBx5x4YYxKjDijrpGZu{H()OPZ34y82w&`6Lp+Q8_O}V0 zN}P{eAk^2vnUFd3)xoZK4(Nx@@NXHz85W*8w;x_I?oIq@5{I;@aY2Gym_X_i(zJ>$ zX@2$Ms}x<#G^y@~x=Cbd_4=TBnqqE7No#8`r-ve-h{5$M!Bt|+q^v5&1su6bqq>>1gMJCQ5~-CxxIj0TdO)baXoyxp}B_ zfl&>bs6c3%NVcN_n7-J(3LbX*5IPl@Jd)1YwT)s!SbPX=r`s23mJr0|Z$3JGa>~c* zdOjn}{uv?Z;1h2D;H1#>RIku9ChXp^V;s=!#(AgEb_S|YKjIx8X+qv3HUx`K`_B0Ad?sH9<*9AZ zGa@E^4=_pJM-nqBPex1+*~H^-Fv_9rd5Lm5ijnuWSRPBdTSWe!oY0a87*LT}mzE)DcTCyWjvg3A1%hJ)=$9}rEeZFzN zY+*W5u=8pVj<&@W_c%jF&YU4yR55Q^s9p#zj4f3xbuYDs^AEzg)=_xLe$gH+tB#ge zMr&M)XBS7pEl-83PJ+>{$o^YSmz8r*j{)KiE;D!TrP~=bTc$j8Vt#D?`Ncz#lKr<{ zntd#F%9{Dih0&$rCEIfQGFi6rySGf)w%oE@f3+c8^;mMoyU7{P-p(jxBO2Kv@{3pV znLG@*XUWbW_To3qLQ0 zJ;ULl;qdb#;pe2v}Nh+ z(nz@Ek(KNtVcQV^URWN@cSeh=u@49!TFu)Y$=klzy^^>4uGQeM#5sf6!g$s6;szNI zD3(Vg#TkryP#Nk;v`8m@K<%UkV1eeEf(UU9kkbOZbtw4Ny&1WgF?|em5_a%6xKwKDXB0?|gFQFmMcl+76h##OBMf*HgpZh0 z1>Yx-U@F0I1*g-&kvc~T@nBB1a`Y_}G7~-zf*(ASF5z+av=DHg@d#rRaQ~Ntfhhzg zNr*f~=%L^pANPn}I71+Wcgjz$8gl5aD>##oc9`Raf{|RpuT4m2L!IZVqI`9?| zgLf$T9ZD7`xr78_U^d2Gh{b&Yh&%|f-^OT-o zX**Ib>uEl2;Inq!t;)@^%&Sa^`i<%N{Dtfldv(}UEr)bD5_|?ZEz@?tutD4- zTkdQ4t^Xy6{nv4losk7U8#Lx=V?yDys!eN`haTVqjiT#jy;Um&=1&|+*H*IEGY3Ri z@+lH?Mi0w_YMDsq#;@q=ndL#Z!PWK)g0LV$>R6;E)=o!CPsMIPfmmrY@XA~ee^sr2 zNH3a{_3b5xS>u;EqweB%L$qa38Wa8m=8!3!jUwq_3X!!0`fx9PJ@^&3P$;#I{*-xn z&SxWQcxphGFQFUX*b>$T-9~*n`sO7p>2i&p;M#0S|BBJnkg_eKrPbRjqrrBiT3|| zQ|d%}%D&i?ec32}in7TbhW&(}>joqgB=A`JL? zguw~jK#4LnA^8W$)syte^akquh=nCC7VdX4;zxrGR6`M~Hb_cLtb+)W2EMJHv9g+e zlRZV1Es4qAjP=@tUmJnu3$zflLlM1HI$TfDyPIkMMyJ{YXOCU@za;A_61eQ#kd$P5 zrBd8}=?W6~a2)IE9cE9%Rq2Sd1G$*y+yFcz9NcGM`nm1~oh*?sPeUel82#R$r+<7P zmYrCpM^tgY^v5)UmC=EI#-{|c)Q4mUj+-7a>2^tPoJ0>Ylbn)H957hpvjpyOW?_lx zNT!Qf-3XPD`Uj>#I%E2C5?zrD7Sl`QB#Mx8xK_fv0+&rd2Ptgx{b61o6nFz226=1x z5KeQ3$#M+|`HTcF=?^I($&blf(!Zn}xrInaki__*n0|0%3LXCuT`~dfh5m&12(#aO zfgF~MyeGN3AcZa~Zvy#XUMG_LS4d<>kXkOOgN>4?wP#_^YSo@d)gDM$rtDQyVZ>Cp zkh`P{n+iWM?On^vUd^nFWY#SjRx+Dswb7i?g_ec-#p(rnB&T`S6xWrS_iwc77&5kl zDbCowI0P4tD~B#0TFhJ0E}x!1v{KS>pVMcd7RlA~(Ol66^%Pm4x8LXZB6LEsP!n~QUO9I8*h1GkJ@E`o@*u9kU=P4% z6m5SHL0c`l-={I|Qu6S=5!GzgWU@~4KfJF+_xl8BGFhj~hY*uWD(*PT7ff$Gey2oO zDEwjRyQRNZzFN^5sc2n35vl0D#jlh+8m--a*R0RaobA5r;2c7YB8mOm9Pe`?MaZQea+2p4RRI2xn%d*%#tGZ9D4T29gI;}1S1Ur3#KKO#dZmgy6_ zkPuBjaa1rnjq3yd($4ouJT2L`lHD4%wf-V*Mu%Ve@8eNV1tLHni z4c8j9ly58|?-4!Uk!!kc&{E!9+R5v$*JgE88L#itbrhPeAK*LkZPyQKDgQ_g$`Qbc zzBh8oKjcO}Vn0nc3VA9ohSf-aqe|b=XuVNyqgO;lnG(0mO%{*$^!nJv83#&8n%ScEV~Y2TQf38L}ny*fFpvekg{5} z$E&KB1$f!ZEVv21&vER-13AIJ?MUcHNvTVu>Pw|&mG-Nd^h^T{)F#k(sAxE4Xtfh@ z4jTwPGm<5B*-UpJ%iD~4Zs@39q}6RJ%6UcOC(0^cSyEQJG7(aG8(Sxlfcene)C46l zebB4-Gul&laAx9*Vff&Qr(Xt?yg3o4w3E6JM-bedDpV)qG+e@yV;*>GFg^=`2dNyH zD|q^wcWM*9QZ*7ZDa!9477`d?D&`o!r)Lm=st3LC^p35Qg69YPrLXV_e}hy&Py&6R zRqzH`l6!i+4mkEoWOPS-jKx1S02Y-#h8BZ=st;264^hKDdh-g?y*@M@9CMEki0;Ed zR|>t~!kD-Yj!g`@$NYzpk=xf}oBbrNJa~os{-c+AF7_@F6^5Qf-_d^b+bC= zpu0U%usv#X{LFS5(IthJ`6fFW$v?zWU7Xa_Wdrr>L^m9LnuCEn$9^ijxZ>N*HmeUT8RsMQ0-?>M3y`;Sn z7dMUBoh`bXMOw-`^E!8GZ|>AlzD198!-vF-7>tPh`oF=AgvY>_GYkAM*o5DOO9;Y0 z;QUA)0U%b>q~Hnhk{olYBfI}QS|1-mobOk%68Ih-SP6`^ft4V|O?pNA)Jest zB{pc&kuDNjVH4n=U=A5X%iCauL@i5Tg=$pQ#8+*UNT+yv3yRd z4jQt8uXl`i2G0oIp;Y=H;JcN~OgIJskwv?zQGRK}^|a(q4z0r|(5EK8W$7N+q)y&Q z1))Jz6|F(rS#h9=g+W4Z-OG%i(ucT1(npjK(r3i42OobltFXn43ECOc(f8H5@x`qe zwY-Bw$-jYo(0Xc`u%hr}d++gJ`&W{+=5yXN-sT;9FK3W!7~HKu3gf|w(*lk9t7WE@1S!$$Nk{Re7J2pbzD9mjS#WOpcx{U9T-W4s^IBFkYN_SQ89Ri1sPHge6n)`*4|Rv zLiDd3FFQ2Tw#^tj<~TU3Uqgh1F`8RAYhKHRvs|9Ja<=oQuC}@F%=Ii(EOaikM6$PC z)y;OqH8~lTcZ#a6lwB^1<`vFw59iiKtLhMQ78>AJcySAr1%c5tS;)TzwO+=_;@tW85IU0gpwIbZ?-)6+|K3JAm|{YGV0pB zd@3Te%L7K86~AS^ljBsXb-jnSeT`WxI6}7CptGDAg zDF}?$;@KlM=FrON6~8!eh)N5z`IOGehWHxEYkVybt;TM%72U3ALkeSagJq{0?Qp&bC!?)=%sbJAl|3hr_W!? ziE}tD=PnOk-E&KOZU3?_vj6dQDvL|}5nkWPFP@5XEVIO|O{RY5 z;_f(y)6(vBI>l?bf{JK${ay2Sc)evUD{pS_Vo_XA7ubQU+_;ftO|yp^l0g9_xXd7pm&njzmfH^W%D)>3$T&jg&L7mTwfTo9XH-U$3_;>{-+m-6kUo7zyw9zH2TciE35rkU_FXKaa>)L7p6@i7;^H|g=Xy2d=v^ags7hwC>L z#jL4inyy~PGLDRS2js6(8UoYwT_E|1We+QRMA?%_gjd!y3X~&}M@D-@5!v&$=|Kb= zFMp}`u=Kys1kWMoCyWVhiRbTXjJ$3=o8#?2cH}yL0D)>Ch+e8U&Feh(^ZF@eY2w65 zr|neWJTJcV`?Dv%$#H)}FY=>JdG3Yb`8wz3 zM9$5-RY!P{XTK^@#eUVIn*C}-4g1xKTK20utPiLJ?yy135h$nni1DyVG%>&Ci1~1? zn0q)+%sXrmEr+e5^>DtJ&*D@^3d91p)~!2YJ8T#20$0LSb8dYZ=QccDxI-Bj``GP0(vvKFh7reNJ*(B zl-#)8B^*C1b!qNmI`MG7d;EkjJv)aMEe5FT0nytQTv(WUel}?QfJSe$iBD>t@CMH` zX_K14*~ws1-S5Ac)Eq^7k_NfZq;`Dq?40idj4+1`#u;C5VsgPhJv%cgO@ZW6G(Go9 z;C!8Xn!}3WXtiLasIW@ZqIO1uiVSG@kSH#Q9~2{DsB!4Rq>nXU5WTYj-xM1Mq-hg< zbLjn);G@I=*IQ_=E@_yU@O$Td6B9|}#Dp|$@S7(lURdX4q)kqEgF$ij^kUE#n3xdrsM+}Al6~#-{$Tr5`!R1I=o8zqnA$P1 z?K8fGiTTO169J!i4#9Y3Z=$yyV>-dsmQQq@y(pSd2y>`!0Rx;baX-;)OKJ>ap&Yeo z)bbHZ&%J|K&)G-Ktr2x@t*CeF$~e*B)<2!|wDD=vb`^c9_&Vs#D7|ghxbbZ~rxDF= zgdxs|5EBc@b(>iTrOtcWNO_e!xh#dnjd0|VV>NCI3%91q%BU5kT3K9vMx30FwF@co zS&9O8fm~v$t*MqHg^i`Kx$RHeQ*A9|A%*TD79y9ZaTl}jB6o?>hjb~WEUq{eM?EfM zAtjYuCFgF*sNaFy6;GE+z2DLrgS(P)J;#e>Zb~POos*SR&Pr-!H3&~TSQ!-=qc69` z;I2xQQR%LJx&rAcP>$RSox6sW=A_c3f9^I`CbdZ_uNI}(;jbQl4fvC0S1v!#-Iyw0 z*b<|1H?gv-+|6S3OnH+lX~Y+FkoG*E_<;kH9coFv$)6Y7I|S^NY|BDQW#I0Xl2@rT;vq`t-A>|Ef%v_5}O^o81` z=#Tfy$!Rl^H@4HR)4sO(sR#2076RL-mbSBtr`yCue_Lngq`c3dQyfhKoelS%Lg?4I zQ%c+pipAsM=9DlGH1(*JKz? zYJKzG*}0_NdvdEdpPm3F?=X)5D{s4Ce=7q#0vPtN^)pYlX}q?SeV0x>!dgB{PQT6PON}$ZaP2% zFNj4HgEL{)KO4mUZcDFvmmFA&B!QRUe1W@NBz$#lN%Oy$5{0F&p1O2uwK!VXgm+`2 zxct@6T>8wbFJ5|a^NrkmN)rZi+)x@dl*SFtsKFW5tOX+m=Z_70*cp&C`!3+K z!kCHP`9M-9`htt1e^Oa~XesrcE$eA`0EF?FHu$za9lVDh;{B@Z%cL=t+=~U0J%vZ* zRy81%M^(!;sY7iC7H8+C1d#x!Ku}=q^WhW>2WUzwCM$R1G*u&aza(?UGGkhiDbNn*3*4Q8(v|IR^uE>`$=?}v zz**8IOqN&nyt0Sp$b8wnG`!psHCH4og-f~880%&NzKPkXO`}R5!58s&@9(g7YB&KS z>tR5xhRfsJ>MoUE<<@vq@M}FhTpe@uU3$Oz6tYQAwk@QZlRr3~JA@L?pu^|*ud7G3 zX3nqiXvUOJTP_7-u67&TIX$XFnB7h3wI1!5E}c&HxsAOiInIm4oTZH>YU4kno*cRK z^sn3#0Oe*ke?;rod30mShLlc+Rua^-<&iffzuu!ClR=w|H11q?p4-x+3J?;3e=-n% z2=AGrAaJ#ufg4@L-|yn@B>qfQTr0=YY2?v0aAraZ&=xGvIn42mRtqpx21CD@g097hwHR8lD#p z4YEk%82g4(Nz-LxgA3x55xfB*0C3YkV;ue9AOGo}{wb;U`~9It`6!$Yo_+A>1B}@Y za1;1Mdy;Pt>DmJe)4}$TWn$VpKRb8PMX#=z(2O*?qx;|DlltK7yl-JK_`%mO7$Jk4 zM5L`Hq+JY7w{_E=dUg(nH}+AT1XwraB(>C%3rSOd;G%zWU~ZO<>^fvhnh0863;^7p z@+GYdKA!-Z!hq<-KvFXknmwB|PWgbCoW*7xNa|BvPU zuqe*q0AN)m4aj;H*pW}%hh$YgFI_iX>Adpfm4i2jBkoT}#-55iH5%7kqUO3==7xJZF1I96RQBq^rG?cq@%o+7`kk?&T}vYgTiL69m-<#4)|!_4BDOsr zm2>t6v@2$-|4DK0T_fk%_ff7CgwTks{x^YH^!p{(Ko`Hp59I5&S285q`apxy3w%l_-k7aUjl5sEwX{W#J*qx+J9n~%PRnB_X!n*&pTfs=>?u}5 zmR;VhlWW+NUc@f0_iIln`}~+;OE>Xa&7B;V_ea0(WDRQ}z`3oDN2x>Lj=jNgCQiVK zo^8*T$Iiw*sejTCPB@PhOZ%9V?K^^sD@Ozky%{BJc#% zeuG~>4bU+3XHR&=fNu*)!@oFxnm`9e8+l+y+_bf3E}&lFfwWn6SRz#r2?z8TkXp_X zZG*@yhX#pQlcJ_9OmA)x$TIG+li2IeVpE;RS_Ls?G|3x7#%JCA$DTMkBFR7oN^-p={u+!m)@%g8c+S#CQKG2jSZHk9b704-pdBD*V0GE@d$wiTXmO_WG#3el_^My>)3YVJm*M_fqdy_bd%==s2tW zl^0)rapkjdOKa59ny9RPT1{{m=G4M;rO zKA~c;CSXvvhKQgpWoN=Zr_P3YqMR&y2B21bm&&7Iu%$XHY?);Tk@p4c8`?+h8w?iA zh6WouIC-ba)>gNvU5O{iP}yaGc6uHQ7WC+C+z5Yg8_os8Lr2hnu`|1r{okMSTaH{V zMlR=HY2=I^W7d($OOKr2}xswcw1&LN@SNw_Fq+!m2BR76kuaJn?iA5H*rVsX*rWUfPPEov&|ffmMEafPr@0LL#~xai zSq{Xxz%e-1#Lh}~HV-KWB0C@{X7lOHHb$JwI6RdTMQHT7W?Zd8146g$=-Rohvuj5~ zQDr%3YMu)$_&=CJM3W||nmV1-;Lw{Vj3o#{zmHK$ z=Dim_&TaPvd|ohiT+@>?v;UP11=qL#EtTkkU)%*JnVU)i%wlmanAFXJ`4b4fr53#u ztD2n>C*k=x4@E74ry^ctWlbgvd zG0X3iT%2c3!8jx}1hj|?Qh}&b213+C22`K(&Lw$YfVKh!7D;;~)GZ%KTM#gbm;yw^ z3rR;N9I`dmkHP|7fJsy6o4shsu%X$LlYhI|5if51adBh3xcOFbb9nlWP#YJvMTKpN zGH1eBw{m86F5>8dFHu=_H%Gg@;2x)~vMuLstV) zDr*xpO?UO0o}zo4roMQ2bi>Tmx4!w*<);$m&Um>iTJDOJwTC|+Dd|f%+d$$%lP;M*)CUHpeU4q7`l7GwZ5Y#ctH? zta~H$S}0t&=^+jG27l0@TUEpNt=$Kk)mJO|gHGL5XMY}EuCcb|4 zn&5o~kp904K$;zymI1nK2rY~V> zXOiz2Nt#-_Db(~y0N#3!4k2wR5HK4_(jfQjf%fEp9BJ9o z7`%FNs?cz$=}~W6J0S03|jTSqV;Zd8df! z#2x^*7%6%_Axr^rPhp&-C%s0}0I*z|C(o^Q=~AHJD-8TQ4Dj#QDKd)5ubt)q)(LW1 zW7V=R{R>bJPyzsH>sW0%RQ8S4%f9rlTgMm%SoIm-ww|GxhAst3^6?q5Igej@9+$a8 zxYd4Zzjc~FC2uJl+6%Ux-np%+cpXL8nCYva?xpD@Vw9GXJn%QBlQFX;(?R64nciJ1{+h347nbKG_?spei&y! zfsw&7=-k$|Jv)G$LCRuWHh@LcfK2%!BY{)Kp$n{;5;x*AER4ddrRuADg=)E;>eh5@ za3ScOn@IH#i8?znB2&Q=R54ME5Z|2{4KR_$0>P)05~_Z*n$-qUlwQ02k#@h2qNCMJb(k*Jt7`^16j6+!}& zk$NyHC3My+G!-Sai)TU26py0?lH}0{`EUerqMMvAkwZ9zc%GaKaFX^EEbf(HaY-yA zsRJ362$VVuCb+*z-Qrnb0luVq&gTchupmMZi(xfM3$@$L*g~v@4AT(zQu*h|S%lM+ zFX1UP+TtN{#^Gd;Iz{n!DF7(U1qp?@NQoF)qXC)_NalH`rXYPsVtO?2l+w#mro;#^ zSPX>}|5qwUFU^lA{tn&(bX?0TPWhD(kphx;xyYuHkwn2uN>*fGA`eFY8I|}9HlPnU zkU_U`)}mKF`|@YM{Q0=0F=}ZH7sf5^QA_*ng0cue?&1d*~B3`;TTDo_AE>?PE=`djSGRM;4M6D}cyFFUF{r%dV zQLC`5OO#hE8-7|z*aH=^D_XT{T^Fm`7cbwpJeaUMUOj&4`0C*M_NKd9&PilcyJNZg zc2nDTCf6FjJs)e@vwS#aufLni)$IZYthO;;vnN`!XZ^`o&0xG@aA_3v)^){eyQ8(; z@7MN9RoY5pwuYZOt5TCeYP+s|0<*tOIKI(AA9Xq0q9cz>CJ9??WTkv^#3AT^)Yt+>k})%^mlxa_MwaJ(G#aYtL!(e}Qhec6z(7QdQzDR1S%`_{(0YObOK z^_5q>(fV5J>#o~XwUN3Vv8tW7Ya1es-Lcx9+tqceGv8{xUElhh@wJ9;KNG9pohWa) zo2$3yZE*VBybs+x=ji*;$~pG)cgmW6b6Fd;)ZXLNIZyHli}jVz z%b~cXE^4U@+hUg1+eMYD<>4p82P4}*6>*-76@5Bl{WP2S-Ru4T=ixs)9N#k*-7^;1 z?T(a>#|k_Vv**{hEhYCj9_xUm+xvsbD^-6ny^cjP94i=!m`8rSp{Cg11ip-A_EOiO zZT$akA8FvO8u^h@%~eys9xvAl_z{QUnyr%jJ6v$D*YG1w&Gp(Y_-~YNhx@iAf22zH zw$R^=mmik!DCmc!Joz2Wbuza~MKXCTk)Z0^}Eel^0%vz zc@x?}pg%)9zJt#|qJtAJ?_Zw|ocoMg`Zq4L$#MRPdZM^)(y1NG*Bu-p@J&3 zYJi$Rhe`5-;x~bgd7+nO%BMoIlZBy&+B}d~WD?$w1oQS z)iYCpknU9fz`*D;jPREtM~bI0cZ?n>`p7p;&I~zc$eASvUmhnuN6s7^NL3IDxP`(f z1}Q8}R;1%tW;L>tm`HkzSSW_bM%lg;F^)4$5pmgcaCR zfaLPnQCYwh*q3x0nr0AZKACDa9dWdQFa@d@k?b^#WM}@jBiU7b)F4roKp5O;<*MuB zRbA1lE+Q(HRjpRN)-1Jc2eqx*wwwowT^4d;U&P*-#w}#p{mQdWYoQHgXu%>l$Y0CH0UURIed5s9uAW zpnAdgt=_>#_0_Qr>W=kn%<)Pkw=?G}X*s%l!4s->4hn^fz4mV5j*;yP5pEsUhw{~L=Td{;w({i9C%^bvbYs)uL!4T6_>Ai0!^gwRXyz0sCTW@9G-b=j z6QkCx@~d%3(Wk?nF>DiCpDm($o2ghD4-dAZ>Yy5!EQ#gOLAd3SsR({8#PYOYziPYG zejO-gnz3y3uar#f1N12pmwN>Dhp19xG!k2$Y&0#oMOnq~6bhN&kfywNlu&n0?+-XI zth_IPR%LKAN&c+m7-jq&Ojd(*_+CW_iVt0H(+MDEgF=k|-|J2DrpKl1@6K&zyfkDV6hg_yWi$f!(Yte znA-*lDul6AV+su`y|_HuQ&sXCa^=V)mJw=8hkEiJYdq*%O^=5{Yda3@GSKoKLGe>Y zX>$n*6^@m_jn&94PXDIqOzsj_hL{xCynl}oe@NfmrTACoE>8<#2-pRVVgE4!%y6se zpFAQENZqt&2qf4m&2XWo5aUgAUF5FJ_}Ua2ut$|Ko`vc0%m^)dNGO}rbR6jOP3d0C zK2M>%F5FI+p3xswqjR7?sz!T3Z^TNL9sp2aocisL91q8-206XQE|*KxyUpVSR=T7?|@S4`a1K~lYFG6q7D^yR37fCav znsf-M3~x|Ts-Xf23M78&X{hxvS>se~Mp;>cRVj(sLv$AqV-SjL8G3v~7V`R`*M~wj z0jfk10nFAb90dCQe_3ie4-ARr0YslOU}HTWLrkcnYrsGVjC! z6_3FAfR6kmpTlJNk~vCd@hpX#G73%_)8!@kj-)YN<_8>SMIpN%ouxP|;|BBt_bEbn2A4}-n#6krPJN%MrxabMSh=YpiP&&+Uajq*g#)XxT{ zW@jL0sGju)@vd@pB>4*g>ZU9mowH{K>Y^9UiharU3^-k$T1N7s$#(6TTbT6D1^Qe{ zhR>phfhv?maC@ZlnQJZUC*mibik^5Xe&XprssDb>n)grZ*Xpme{3>8Ts+USS_-__; z@H5|djh>(EBUA;CX8#8q#s@^!&*6;(7rhfywWU<_iDTuGrw5B4&KSomVyOds^ zT6anPqW^h6G+slk0}G4LM{&W(y7N6O{-E`IBb0-?U3V~g=#Q9%?Y_Hfn9mP#Rg zEJ)J?lGaqmsUY@QN?0YEcRlui2}t)d^fIX>Ay%jboWZ9KabWRlRE~xTn0y;v@jK)w zi-66Z{DHD5Nt!aoELo5qN@)=1PcxEl(jcY6N=x}>P6r6eRLH-ZJt?UaxisbD*d&$m zK532CvTvRQOY-n~BmM5CpzgzZJ+1kjJ@o-tB>2SQ} z@QNW(BE(BNqa~fUO1kbqv?BL%Zd~Y!3SBW_M_kw)6?U%=#)SSA?d|f)mBrV4!}-6z z?@oP7xc~B*H~p9WYvZx{-j$I=U30ke^2s-!z5MLjV63iZW%%bc4c}3RM`De8Vl{hL zNR+p!?YoAz3~S}J_!AniB2ZiQR*V zmQO?Kh@!O>&bwZoL|oJwrW$MyCNHyuW636XQ$I7Q(cO+_C zZdcW>URcYIRdr?f+P1BAuTOrj?@DK^Z7AWai95Ta&hGV+nDfA0i@|E#;0!s&509%* z4=CHUNBCdf|92pB*WKfESVJYwH}YP~OVqa}TDB*4?N2nNsn89>Xl!E{8e3WpIjPce zkfV|15)C$pYVsBy6>yu`_qae)#|H-w!?LrtBw5OYQxp0;oW$-S*u3G&4wcK@72Qp zuF8b;@8W>uikuyDWbNmPg-ROSRYOPqf;v){VcWi6H;ejt3=jr(uX=|!AQV@ylx~z zAcSVH^H0&>Ppurb6b8l#=txKTU~a<)6C$)0T=FDeTv&vK6oRj^&9+PYDf%87c}Ph# z-!6gKQ8_=06&dtCPiuF^KZ}Lq#kwK2L?&Iq){bkIB+nL!EzMTYr~1c64~>oh_Bv5J73MAU%7x8!R_plBn%;FSYrDsf`Vag$-9_Hpfd`23;-di8pZVr88^h}YY zpF+iaP|^?5gjwinG7jU1B;YAXbV*M(dgLRisidY3n%LF{bW?1VP;In3`bW);y4uO9kOu)MS%}lj9lPH%nXGnJO>Y;xkoVphB(8 z0H9Lz7t~C5o=Xii=ue;~)spOG_EFQhEg;NuuRstb2O~9a511 zeLxZ|r$1*4y{IsBj`rYrU(ib!46~%-^)nI)@Wm{|8Pb}_U;-)^CZc_FlB;2M6VypM;s2-Y;I9nP-)h$(5bL{GE`2>M3`Xt zgA6cjVL^_k5{dse>Ps5rF$p;&)OyAj4AKTowaIZ1S;}L&4lw0GdW=cqp^?$!V?zV| z?jZ^1y+mzKBfjnQ$`Icm`3UBa6eiMwMn(yKpyp=dY%+cl<7$d8Qj*_+6QC_DjSot= zpcM^fXrSEKWI39iGMVGr8u>#y6>h>IOb~)A)`C|q{O*MnOStI!1%F!cy^6SHU(~WM zi%`&={Nh&~mmFWMB+lrzoI(E4tWihx|1_THo$AI!Rehpr7%W*wQ6_tI`@cGS^p3OP zjnBRIxz}G@HUmF`#85?5LTE)pN(w%L01xAwLi;0wUAmtqerf*}wB=bzJ%GnJv$^Q` zLCw1NFY4DzBIO5S1p^WD07F{_fwue*YvQGT<46vFHMewF;I4v|xm$O&K~4VVUBf!{ zwJLt7Pj{`lzW^`Sx0?}hgX4!e-3?wtel3s8H*`ISy|IrUR_Wg6n&5xCiBi73gCExD z-rh+m-|nT9Z|~;G-^U}(J19rs-cd2XhWT~6;R^jbW`5XWeka#V{!(hnI}qo|;oqrX ze!)0`yv@ZUy!wZ{7XBaV)QH^#R)A@-8Dn`AkkV4L zV3QLGoK!ThYP23TT%AXcBUKB1-R!JzJpco+y^#{)=Ag0n;*NixvaH$4{Po5J7}wgjV#CmtQIU&j0u;GwVzfX@f+RNR2z&K zG0kI)SpEZT*d;Lyj(-zvC}3?EOtoR}BfDR%U$(HN#&n`%$ypmK?v??eLgcTd=MkYkVpmXbXS}VyYa^ z%lM`$X{XCnWAN}E71-n&%vdhI%Pmh@c?s-PEp|k~YXAtKSm{)ZbHW6LyvH#3=eOkC zNo+f5-{=(vW!s-pfY8_gLp#jaO#{?#&tuP`U)YV9!!Lx*8@vICgnLtPWqQ}?RcwR` zQebK)Ul1=o2AfdnkJ3p}mYDTJ`E-$);s_YUFvcrskXy%iZ6D{f%kLqL&~_XKHiS@) z(xp)OgN8}LXq37q1EH`e#EhOMO)$(l2`d{U75=fA=>IDpYEt7J~qF&L;-Z2;#*KlZDizh6XWYAXjL6LI{~sjiP|0hDMehHy>+U;An*m z6d<%Mq|;FVU!)WyO(L1_BgT{Xca%Y5J4ppjNLg~zY$Z`CS^N#9HfI_Y z6#s!rVduJfa(*f)&-(<;yNEpwjKD0QFC_5gjuX*eG~3DhqmMk(3Vg-uw= zt}d9!l3m?o-6o*_VaZAfDELoszy=bwvVjVn@xq=z>{<=R8@i(n-LV?jA4y20qx|Kk zVkYMuQu6JHmUqOfBn@ z+VEO;q-YP!NLfo>wOq202C=g`QRfPu3!jPXJQ1lG18`hb@C#0pZ-Y>NdG&^s%d;Q?erpIa+iGq3lrlYn~B@B_8=uJ2rLywVh@ zIhwBQ!*pfmV!0JkVMS%}qV{M}`H!Zmc~YsX35Q zu%3ERK)rZ)!Bs7+SZH0WjYlebp_`4W%2#v4C&GcSFH*Gq@v6$9s_azNg;=f=`pxCl zm;mQ9k!PnOzM07MOyt=!k!NNjv(H7Ie<30UBY|M#`Nha{=OX9MM=pFma{k2)j@z&5 zC+6J&)j|5{R~@3CL#ku+Q#qk5uejJGXWwQob)%us>qnPh2EtqR5dbtHlt&xMjSsC0f`L9*z}u-8JcmMWo9y zGLSSoc0i&NWM|S%1S!b^jg~NhY}f*bI(k|B38$bT#0*-q&sxF+w(cRW(3cce05(9` z1T{9Gl#y9THk?^J?ZQ{sPL^ib4oRCppvk42e*t!sO4h8;pP7X9+e7>MA)0ZY+hYBYS?yvpZ5G; zkn<5azar->9AJAy4U(}vc5eJ50yZIf;(tfTgRG;>)8{=ERn#lgmsGXTmFMy(G?A8Yqa&=ZSQb6F{Dze#&eEO}q2>4b|LCwid zfYdNJ#UvE9pzKq7*)yil_~j7dHW1294>NJ9*`)3N5EG$51U1ZtusTZ~l6cNS*OqGm zQmat0JzBRx-f&c807{?D@Q|F-$`wEnW>fnhqdZn9wLa6`r2X&UNIhi+f5-7xZX{(I z{FCbi>X1*8i!u_t+1%*y6y14q9>|7jHcL68l(Llk0f!I$1rAFA9oI|r}XU%ZWBy$Y5YcyO75Yz2J4W^CUtz$Esr+1I9tXC2r2I| zx^o*);mJRD=h3gnSj%PV&4be455&@pR-=xskKAgd9afZ}9it6p%Oj`68l-C2j9FOfrKdM82AOF0H4A4WH(Sq%IaHoe0F&Q@E8F1F!?GB zW5blr8bX4w_4-lHqA z25rU(h!aD`tCZ8XQIwhg8E2YgAnKoulggNIN(#+5E>>Y~#9zXJa@EMt_zat>E8>2U z;F+ZA{AqEGQoIFcI3*w`X%f?U!-Ni!1*xX{NIFm2#l>9Am@I|HPNq=4GJ(_3-}X&` zm0_3U@)*8D%Y>miY32o#reXHpJD-x-BP1+o^1_mmC`rsEwdX~8TcoV-!husJZk2t=xWwkEhsCZ-KwUO1KZ$h*S%!Qwn58O2%CV~`duFMrLZF|48 z?c^fB<3@~Ybo=c?iLW4FtN)si48uIS=@|Ugs4SGA&9rERgB@``c7c2^V@_Z zmhXxcbVtnHzuw3}p5FuruzIOKZ={#MnqNBH3LXbPP@ucoq9(tqXs}a#O~Vh^4cD}4 z@*64!wc2ZS`2*GZYdbXqC5CIg{6LZU+HN)Z`|Jq6Uce*obvx9))Ypr6gkLX#v0(l6 z(j4TyUTqxcFu`On`MXS%rjH-wb=UXl;J=~O4LWjenE1iGyc=dS`AhT^?lhsy8_oP+ zo90G~j{KdvA%p$~l+=37H@eO6zpd#nM#MYi{BWh_9fyVdEj&8*POEWbm-?NZdq%dY zf4EHx{||Sm5zfAdNx)>vV}S3A_`CN_tlno$DOfG5$^8dN<7CiSF$T;?<3#*cF=DgY zulZ#CGr&lib``kzkb%H`3mPVIKm?#IkK53D9@0p}7Eoj~paFtUjUHyD33t6@+#R7< za3X1gjBo`8Y}d`3KFP$Qd=@g(pEjwkOxFz^s_i_xDMo?OaC+x$g1;VS(q8HjIQ1Dl zvf@m(XVxJ3H=neSQz?2djb9J;2B1TXKa*;}Z$X!8JDE5JJV2Ui*|dy+#{dJ$#(*ki;|MY}eq+Y?D!Os7bp#lLas*U<306@cYrrZDzR&2nlFz4@ zZv-T|8c<33rzL#=Tj+lqND(oKw@$~?BXQnL8}`_raWT>yKn}X|iL%K>uoHgM!OYlI zX&N!Z>?LfNoro;MW^HI$4MireO-b}s3AZyc1Wx>Km^G2myN`3)+CYh=OR>g3piy~C zoy^0nTeu3tHz74de47&QB8O0*P*)~Ko3^mx5}ugzk#Y^AsY3iL1K@ClJimRWUf~nz z5+5660&@uc2%ceN+$6t!bP^3!9zA^Y$=urpr;o?=G)h!9q^_D9CkhM@IJde-Q_H=j z86BUsVs(iEKJvFzur9rL8qgozC6-h}KIAQ41UANz#x?f^iMDCjGv}Q@J>~67x-t&v z2Oxb_!84&td-|S+@ z@5jcJ2e%SH(4!KCi4AC**a(LdZP+y@P?BOGOEP~LD-i(jFli<*p%DfP!IleMLNL3K z%me*>0y-ZshdsBja5iZ{mvPU_gztiHaxu6d5`=}qL;{>(pOWMWaUsFn!s#ZnL|kT& z4^gbee6sRGy?Rl1FXB6*bJhwpc<**qGwup{HCElrqKep7xEDcK4V zg=LlhU|Q+?ntHka_jH#{Fd7~)wEpt`Ud~!Y@J${7nGF@6zx|$CZG#aPb#C5<8o?jm z70}*aedyqigUAa-*3@o_8bdv_e|?>h%vEz5WC2bVtiD7^QQc&42TKz9-N+84VQa( zMu#GjGa#I;52Pg_FPWli5q`4eQJ|C@81QCNkusLgECeVs2EA^4n)E=7g3ES+O-5_+ zB}4QfAG8ajTpEM#zz`?60-_H#SRs5c&d%ZrL5JWoXjlp~<{|o(R%%72>n=*0hiT{B z#V)Ns7to^ubAeq>A~~h$(ZyJrNKZLiE$y25YL7;4_Y_JQug*pTbK^FDHy`hMO@*$OG3PEh8Ry)(fVQ|D5PkfWJ?in;nqi9OUl?m)9z#yMn0AAQR_Fe{8NAy zrH6%9rQ#GVfOfSXCeHzzf{Nyc-u`2?1#9vceTfBrD`<%-28}*mFE&elS9<_hWQsnd zDP0(qCb{M5UwKr=5EZ?xX)Pm zO>FF%H?4fLyCtI(Wd*mZvF_9O805vXuV{Cxyb};*_aSN8(rL1%$Y_Dmk93|#u7B4f z*T3Ckf9&<&jCC{4iadqc_6Cn?tSw!h?DG`KJ(Pdb zn343AJlOei z==KXvZuIkV$SHz9*#o>D^Y5>6_sR8U^sq|lVXk{0wISma+n+7n=5H9nxmEpDTlfq8 z-v6k{;4ctD&vX+cCa^3_#k8+dxJov1fh0!-PJp|Acxe37!thZ4xMyt0Ei`&z`T;b4 z-2Lef;tyVh*TC+93^er`+k`>M2nMr((JKte7B!d(H<;5~EodMI(`S?wvwDSo(Bo5A zU}tcIg~q2=%&Zb|Ax+un6^<=} zB@GTbaBI5#TPh}_6{$PIVeW%8?Ue2#;dyZHr{@;VGwO(38>u~H$Pvg|5Hu_79&t|q zf&$9KfCwXJRH!kKj6eZ&Z+Av3&_Y5|*$v{X2!?bs`8SBWG4!RaD?2YnF72!roTRBX zu!*RznAW+AOLgY zMonT0rO>Zl0bR@J4oL`fwVj8^1xvtGO2Ar5GrK4O8E z;U+;t+_p6dURmnScuDj!W&fd9I5aJci;F(URr(>%A=?9xY(=m;QPF*+OEk~vjt;}b zhA-8eOzbhe+NfljE~Pn)uEv(S(3n#5WId5e*-UH8usq1jxG06B7aoz;uvnSF1|}Jr zGE}4VLpBdXm#)he+F%re97zocAxR3* zj?_jraByhgNUuP&CN^&ZzT(e^)UM9y(5p;s^(d1+k?SQ+DpOj<7o1jG-nwNdB*)a4 zq*gpFYJpvOOSk4iBX;&I_9p0YZR!S_cXmBJ&3?d_YL~pa0x4zNAE0&E{%GS%>DDrH zLmBGUa+@WaLrn7)a{!YcRAO5BHm$VHb|uoqMS;q=x1tG(Vzxx#*h@`({N_(V9S*|_ zL+i2`A(zCNP3B?_C%lTq>th*|*+6&a~t;eb}Lbl$?{iZ@XNskg=@|3t$G{ zSRhou2*S|tXp^}05UZwm_5ruSp0&!q@IR6^EY^XJA=Z=A1Sf+8EH>jk*_lE8mDkAx z_6C^}{2&3?uLhx-5c*$!f%|>q8+qTzOW2DOH4U&<4SSX!=4eX`Br0&eJOqMF0cIR{ zNhODA7t%(So?pQ`jjuu3=6Z!RPhK3Lwi@IHf(W-bNz`rn$Z0Z&!b{iBN<8d8r&+eK z+1b`h>fj{F%_gVf5g?7rC03&rqSL3&$WX5AWjpS_;k1auXvt*^v^WBfG@Q2dN=*-F zD@C`#N%A5yV)X%FJQ8S_45`}YU^JnM61!yH#aW5zUUcixQ>^W#+D6D3+7>6F*_~kzFNx%`kqJR85~v@)Jq3bl9f?Q+AV> z`6bD~((h2SI2048fsX*CM?VtJBWagE&&>T}lipmm1RBgv;2Lm7i4TH>pOyq6oz9wg z<@RXh_BGg$iBE69w6|?PGGWFqRN#hIRcId zS#sBc31H>Sb~44V5{f!I!6Zl&xWX6KHE(@x-5qiDgHdpRKg{DN{|J9ittHk$X0HFZ zH+z;*mgRV6mOQRVh!^f#9gNrRh}P~{*F|glvWl;uUoh=o;)<5IVkI5ukj1ew5x2Bu z;b0ga;0tx`U7lNhcC{yJYrE3BWFVyg^YZ6cPeltmuY7jNh_Z_-ZkJW8JokD#F4Rgm zTjS1-sIw!|dEm-{m~$jiSPVgL#=x--t8#|;pPTa|_Qr5g*bwPB5GfeAWgff(qeD+e zTK3(rU3u!dGg5G3X#fLkhtP=aRpTY&%F%Fbq`du>wd0Q2^2*_t4=R%hJO6ty&kPsA)P;!{;^ zTiX}!Iuz|X6xnt-;ye;7IvTOkHDx8$Ftxm<3tw0Zh9{!MdzKEuCeo|>F71=n08$h> zSG&X8SDjINN5Wnbx7S7Obzxo9?jm)F_3dkj*nitzvNF8Vz1q3@6UEL~U%d1pu8oToHzsOYRt!HWskp0099UhrMM0^OHFxmJ$t!5* z@mSFl5$h8_FM|vZS=joPB`)lW3cJ>OqQcOX7h+|HmX6#p7cB33`FPx19W_@cN~#lO zH7S{>Hk7wKnfQmYfB=x7-5UIUd%nUWl|GjT9WaWgfj_t5^wrrz;%#_P$8L?xlgh zwG_#-u=}s94R=yAvX8EwlP2S9ftBvBe(sjJPPtJcV+yKi3LJS$M`;QiaZ7#FQXj60 zSz1`1wy!-M-*GUy0~Z$@iZ~C)ijG9AM=-$U6-!5;UyeKTW?)HcZRc9!dUK>>C{{j9 zm+h9vt<_O0Zi0Ao-{pO4yJ9uF-?#1o-_9|@M5N23)|$AbC2DEG3XfXaaHSn&wHhMA zHquO3e-3rUoQIc35_YF_TX5JIZQLDi=#Mt^UulRn9Dd(^1f>Z_AZjh$e^Ff%F*|=+ zC@k~0G3@-Cy34w68R9}mROnbc6czfe@UgN3)Uuk@^SA;yVy!{WO6Rh1LuJWtxeXyN z@2c(h9pQqXR@JQZ;}(f<)oaILmduIO_y;fkM1XN|uDX^)y6W$nxmwqoegB~k(9KHl z58dJXZ#@|)X-zb=K-C-y7qvypNADZCn)=nbczsW_z9(M4FIvAZR^NZ6Dq24ns~%cD zlBjB3pZKPU5Zhe0tf}*?oD-a(m zQK7V$gD1a}xz$E@M^3C&k3_L9jk`x1+oh%0J;w1J>RYeP=b!v%ZcZq*4WzvE&HSY#n3Y0zrF07{-Vt&;84r)bv9(s!5oIPcMt zt?y5=Fz(U0wUF>4OD~dy7jbj5OL!eb4G)Vu3C;UnrZVjooXQ-u!RS(- z+`%ZH537NAYXCIeCcoy`7r<$HDMfV!C;0)sXddhw!p?2yp`7LReW7<752;L+mw>ui6l+Rj14k`TUu)*!yuAFoe%sWzR)jmKhf+UsB=I61t@Z^08+}O__;fmsWd_4+l0Nv;faqaDCv4ZT;Z)K7U^d*zl`%X^Ylu8ytSt?dy|Qw%=4= z?OFFndyd_wunmzv&1;R};M%}j7dJTaXSSK%Hx8;z+HpQyxWUoyTGxH{yHRG+mV`YN zSVHxF?;?elY?STiwS#=vy}>ch+W30SdSJcrdl&&0ura90({_ftHaPrHVffjo=ZdR< zHQdd48WrBjFI=9yRJx(17Yug3eM8T@2Ha=7;=TlVHTG)cjCo6w%Uw%nUatIaCKmKr zRk5}%tlr@8v!=dJKO5a#bpxd4@8;~%_5ftw(Bc=RmOD4})*x}hhZfu(%8^nMP#!8&$*bE3pSMuF8gNLm&(360zD)p7q)>w>MeB^5Cp)!s4bsb(y#~g z`o%e4pZFPkjrh+60OfGF_>AZ8s`R|(UIE8je#)7C%H^>C`G3t-N4V<$i!1qSuIsP3 guAg$||A(u*uT$|kOIV5c`N$Zs@Wu~0a@m;t|I6?~Bme*a literal 0 HcmV?d00001 diff --git a/geomcp_sra/config.py b/geomcp_sra/config.py new file mode 100644 index 0000000..498fba8 --- /dev/null +++ b/geomcp_sra/config.py @@ -0,0 +1,159 @@ +"""Configuration management for GEO MCP Server.""" + +import json +import os +import sys +from pathlib import Path +from typing import Dict, Any, Optional + + +# Default configuration values +DEFAULT_CONFIG = { + "base_url": "https://eutils.ncbi.nlm.nih.gov/entrez/eutils", + "sra_base_url": "https://trace.ncbi.nlm.nih.gov/Traces/sra", + "email": None, + "api_key": None, + "retmax": 20, + "download_dir": "./downloads", + "max_file_size_mb": 5000, + "max_total_downloads_mb": 50000, + "max_concurrent_downloads": 3, + "download_timeout_seconds": 300, + "sra_toolkit_path": None, # Path to sra-toolkit (prefetch/fasterq-dump) + "allowed_download_paths": ["./downloads", "/tmp/geo_downloads"], +} + + +def find_config_file() -> Optional[Path]: + """Find configuration file in standard locations.""" + # Check environment variable first + env_config = os.getenv("CONFIG_PATH") + if env_config: + path = Path(env_config).expanduser() + if path.exists(): + return path + + # Check current directory + current_dir = Path.cwd() / "config.json" + if current_dir.exists(): + return current_dir + + # Check module directory + module_dir = Path(__file__).parent / "config.json" + if module_dir.exists(): + return module_dir + + # Check user home directory + home_config = Path.home() / ".geo-mcp" / "config.json" + if home_config.exists(): + return home_config + + return None + + +def load_config(config_path: Optional[Path] = None) -> Dict[str, Any]: + """Load configuration from file or use defaults. + + Args: + config_path: Optional explicit path to config file + + Returns: + Configuration dictionary + """ + config = DEFAULT_CONFIG.copy() + + # Find config file + if config_path is None: + config_path = find_config_file() + + if config_path and config_path.exists(): + try: + with open(config_path, 'r') as f: + user_config = json.load(f) + config.update(user_config) + except (json.JSONDecodeError, IOError) as e: + print(f"Warning: Error loading config from {config_path}: {e}", file=sys.stderr) + + # Override with environment variables + if os.getenv("NCBI_EMAIL"): + config["email"] = os.getenv("NCBI_EMAIL") + if os.getenv("NCBI_API_KEY"): + config["api_key"] = os.getenv("NCBI_API_KEY") + if os.getenv("GEO_DOWNLOAD_DIR"): + config["download_dir"] = os.getenv("GEO_DOWNLOAD_DIR") + if os.getenv("SRA_TOOLKIT_PATH"): + config["sra_toolkit_path"] = os.getenv("SRA_TOOLKIT_PATH") + + return config + + +def validate_config(config: Dict[str, Any]) -> bool: + """Validate configuration values. + + Args: + config: Configuration dictionary + + Returns: + True if valid, raises ValueError otherwise + """ + if not config.get("email"): + raise ValueError( + "Email is required for NCBI E-utilities. " + "Set it in config.json or via NCBI_EMAIL environment variable." + ) + + # Validate download directory + download_dir = Path(config.get("download_dir", "./downloads")) + try: + download_dir.mkdir(parents=True, exist_ok=True) + except OSError as e: + raise ValueError(f"Cannot create download directory: {e}") + + return True + + +def create_config_template(path: Path) -> None: + """Create a configuration file template. + + Args: + path: Path where to create the config file + """ + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + + template = { + "base_url": "https://eutils.ncbi.nlm.nih.gov/entrez/eutils", + "sra_base_url": "https://trace.ncbi.nlm.nih.gov/Traces/sra", + "email": "your_email@example.com", + "api_key": "YOUR_NCBI_API_KEY (optional)", + "retmax": 20, + "download_dir": "./downloads", + "max_file_size_mb": 5000, + "max_total_downloads_mb": 50000, + "max_concurrent_downloads": 3, + "download_timeout_seconds": 300, + "sra_toolkit_path": "/path/to/sra-toolkit/bin (optional, for fasterq-dump)", + "allowed_download_paths": ["./downloads", "/tmp/geo_downloads"] + } + + with open(path, 'w') as f: + json.dump(template, f, indent=4) + + print(f"Configuration template created at: {path}") + + +# Global config instance (lazy loading) +_config: Optional[Dict[str, Any]] = None + + +def get_config() -> Dict[str, Any]: + """Get the global configuration instance. + + Returns: + Configuration dictionary + """ + global _config + if _config is None: + _config = load_config() + validate_config(_config) + return _config diff --git a/geomcp_sra/geo_download.py b/geomcp_sra/geo_download.py new file mode 100644 index 0000000..8db3f8a --- /dev/null +++ b/geomcp_sra/geo_download.py @@ -0,0 +1,463 @@ +"""GEO data download functionality.""" + +import asyncio +import gzip +import json +import os +import re +import shutil +import tarfile +from pathlib import Path +from typing import Dict, Any, List, Optional +import httpx +import aiofiles + +from .config import get_config + + +class GEODownloadError(Exception): + """Exception raised for GEO download errors.""" + pass + + +class GEODownloadClient: + """Client for downloading GEO data files.""" + + # GEO FTP base URL (using HTTPS) + GEO_FTP_BASE = "https://ftp.ncbi.nlm.nih.gov/geo" + + def __init__(self): + self.config = get_config() + self.download_dir = Path(self.config["download_dir"]).resolve() + self.max_file_bytes = self.config.get("max_file_size_mb", 5000) * 1024 * 1024 + self.max_total_bytes = self.config.get("max_total_downloads_mb", 50000) * 1024 * 1024 + self.timeout = self.config.get("download_timeout_seconds", 300) + self.allowed_paths = self.config.get("allowed_download_paths", ["./downloads"]) + + # Create download directory + self.download_dir.mkdir(parents=True, exist_ok=True) + + def _is_allowed_path(self, path: Path) -> bool: + """Check if path is within allowed download directories.""" + path = path.resolve() + for allowed in self.allowed_paths: + allowed_path = Path(allowed).resolve() + try: + path.relative_to(allowed_path) + return True + except ValueError: + continue + return False + + def _get_dir_size(self, path: Path) -> int: + """Calculate total size of files in directory.""" + total = 0 + for f in path.rglob("*"): + if f.is_file(): + total += f.stat().st_size + return total + + def _get_range_dir(self, accession: str) -> str: + """Get range directory for GEO accession. + + GEO uses range directories to avoid too many files in one directory. + E.g., GSE15701 -> GSE15nnn + """ + match = re.match(r'(GSE|GSM|GPL|GDS)(\d+)', accession, re.IGNORECASE) + if match: + prefix = match.group(1).upper() + number = match.group(2) + return f"{prefix}{number[:-3]}nnn" + return accession + + def _build_geo_urls(self, accession: str) -> Dict[str, str]: + """Build download URLs for a GEO accession. + + Args: + accession: GEO accession ID + + Returns: + Dictionary of file types to URLs + """ + urls = {} + prefix = accession[:3].upper() + range_dir = self._get_range_dir(accession) + base_url = f"{self.GEO_FTP_BASE}" + + if prefix == "GSE": + # Series files + base = f"{base_url}/series/{range_dir}/{accession}" + urls["series_matrix"] = f"{base}/matrix/{accession}_series_matrix.txt.gz" + urls["soft"] = f"{base}/soft/{accession}_family.soft.gz" + urls["miniml"] = f"{base}/miniml/{accession}_family.xml.tgz" + urls["supplementary"] = f"{base}/suppl/{accession}_RAW.tar" + elif prefix == "GDS": + # Dataset files + base = f"{base_url}/datasets/{range_dir}/{accession}" + urls["soft"] = f"{base}/soft/{accession}.soft.gz" + urls["soft_full"] = f"{base}/soft/{accession}_full.soft.gz" + elif prefix == "GPL": + # Platform files + base = f"{base_url}/platforms/{range_dir}/{accession}" + urls["annot"] = f"{base}/annot/{accession}.annot.gz" + urls["soft"] = f"{base}/soft/{accession}_family.soft.gz" + urls["supplementary"] = f"{base}/suppl/" + elif prefix == "GSM": + # Sample files + base = f"{base_url}/samples/{range_dir}/{accession}" + urls["supplementary"] = f"{base}/suppl/" + + return urls + + async def download_file( + self, + url: str, + dest_path: Path, + progress_callback: Optional[callable] = None + ) -> Path: + """Download a single file. + + Args: + url: URL to download + dest_path: Destination path + progress_callback: Optional callback for progress updates + + Returns: + Path to downloaded file + """ + if not self._is_allowed_path(dest_path): + raise GEODownloadError(f"Destination path not allowed: {dest_path}") + + # Check total download limit + current_total = self._get_dir_size(self.download_dir) + if current_total >= self.max_total_bytes: + raise GEODownloadError("Total download limit reached") + + # Check disk space + free_space = shutil.disk_usage(dest_path.parent).free + if free_space < self.max_file_bytes: + raise GEODownloadError("Insufficient disk space") + + async with httpx.AsyncClient() as client: + async with client.stream( + "GET", + url, + timeout=self.timeout, + follow_redirects=True + ) as response: + response.raise_for_status() + + # Check content length + content_length = response.headers.get('content-length') + if content_length: + size = int(content_length) + if size > self.max_file_bytes: + raise GEODownloadError( + f"File size ({size} bytes) exceeds maximum allowed" + ) + + # Download file + downloaded = 0 + async with aiofiles.open(dest_path, 'wb') as f: + async for chunk in response.aiter_bytes(chunk_size=8192): + downloaded += len(chunk) + if downloaded > self.max_file_bytes: + dest_path.unlink() + raise GEODownloadError("File size exceeded limit during download") + await f.write(chunk) + + if progress_callback: + progress_callback(downloaded) + + return dest_path + + async def download_geo_series( + self, + gse_id: str, + file_types: Optional[List[str]] = None, + output_dir: Optional[Path] = None + ) -> Dict[str, Any]: + """Download files for a GEO Series. + + Args: + gse_id: GSE accession ID + file_types: List of file types to download (soft, matrix, miniml, supplementary) + output_dir: Optional custom output directory + + Returns: + Download results dictionary + """ + if not gse_id.upper().startswith("GSE"): + raise GEODownloadError(f"Invalid GSE ID: {gse_id}") + + file_types = file_types or ["soft"] + output_dir = output_dir or self.download_dir / "series" / gse_id + output_dir.mkdir(parents=True, exist_ok=True) + + urls = self._build_geo_urls(gse_id) + downloaded = [] + errors = [] + + for file_type in file_types: + if file_type not in urls: + errors.append(f"Unknown file type: {file_type}") + continue + + url = urls[file_type] + filename = url.split("/")[-1] + dest_path = output_dir / filename + + # Skip if already exists + if dest_path.exists(): + downloaded.append({ + "type": file_type, + "path": str(dest_path), + "status": "already_exists", + "size_mb": round(dest_path.stat().st_size / (1024*1024), 2) + }) + continue + + try: + await self.download_file(url, dest_path) + downloaded.append({ + "type": file_type, + "path": str(dest_path), + "status": "downloaded", + "size_mb": round(dest_path.stat().st_size / (1024*1024), 2) + }) + except httpx.HTTPStatusError as e: + if e.response.status_code == 404: + errors.append(f"{file_type}: File not found on server") + else: + errors.append(f"{file_type}: HTTP {e.response.status_code}") + except Exception as e: + errors.append(f"{file_type}: {str(e)}") + + return { + "accession": gse_id, + "output_dir": str(output_dir), + "downloaded": downloaded, + "errors": errors, + "total_downloaded": len([d for d in downloaded if d["status"] == "downloaded"]) + } + + async def download_geo_sample( + self, + gsm_id: str, + output_dir: Optional[Path] = None + ) -> Dict[str, Any]: + """Download supplementary files for a GEO Sample. + + Args: + gsm_id: GSM accession ID + output_dir: Optional custom output directory + + Returns: + Download results dictionary + """ + if not gsm_id.upper().startswith("GSM"): + raise GEODownloadError(f"Invalid GSM ID: {gsm_id}") + + output_dir = output_dir or self.download_dir / "samples" / gsm_id + output_dir.mkdir(parents=True, exist_ok=True) + + # Get the supplementary file listing + urls = self._build_geo_urls(gsm_id) + suppl_url = urls.get("supplementary") + + if not suppl_url: + return { + "accession": gsm_id, + "output_dir": str(output_dir), + "downloaded": [], + "errors": ["No supplementary files URL available"] + } + + # For samples, we need to list and download files + # This requires parsing the directory listing + downloaded = [] + errors = [] + + try: + # Try to get directory listing + async with httpx.AsyncClient() as client: + response = await client.get(suppl_url, timeout=30.0) + if response.status_code == 200: + # Parse HTML directory listing + files = self._parse_directory_listing(response.text) + + for filename in files: + if filename.endswith('/'): + continue + + file_url = suppl_url + filename + dest_path = output_dir / filename + + try: + await self.download_file(file_url, dest_path) + downloaded.append({ + "filename": filename, + "path": str(dest_path), + "size_mb": round(dest_path.stat().st_size / (1024*1024), 2) + }) + except Exception as e: + errors.append(f"{filename}: {str(e)}") + else: + errors.append(f"Could not list supplementary files: HTTP {response.status_code}") + except Exception as e: + errors.append(f"Error accessing supplementary files: {str(e)}") + + return { + "accession": gsm_id, + "output_dir": str(output_dir), + "downloaded": downloaded, + "errors": errors, + "total_downloaded": len(downloaded) + } + + def _parse_directory_listing(self, html: str) -> List[str]: + """Parse HTML directory listing for file names.""" + # Simple regex to extract href values + files = [] + for match in re.finditer(r'href=["\']([^"\']+)["\']', html): + filename = match.group(1) + if filename not in ['../', './']: + files.append(filename) + return files + + def get_download_status(self, accession: str, db_type: str = "gse") -> Dict[str, Any]: + """Check download status of a GEO dataset. + + Args: + accession: GEO accession ID + db_type: Database type (gse, gsm, gpl, gds) + + Returns: + Status dictionary + """ + dataset_path = self.download_dir / db_type / accession + + if not dataset_path.exists(): + return { + "accession": accession, + "db_type": db_type, + "downloaded": False, + "path": str(dataset_path) + } + + files = [] + total_size = 0 + for f in dataset_path.rglob("*"): + if f.is_file(): + size = f.stat().st_size + files.append({ + "name": f.name, + "path": str(f), + "size_mb": round(size / (1024*1024), 2) + }) + total_size += size + + return { + "accession": accession, + "db_type": db_type, + "downloaded": True, + "path": str(dataset_path), + "files": files, + "total_size_mb": round(total_size / (1024*1024), 2), + "file_count": len(files) + } + + def list_downloaded_datasets(self, db_type: Optional[str] = None) -> Dict[str, Any]: + """List all downloaded datasets. + + Args: + db_type: Optional filter by database type + + Returns: + List of downloaded datasets + """ + datasets = [] + + if db_type: + db_path = self.download_dir / db_type + if db_path.exists(): + for dataset_dir in db_path.iterdir(): + if dataset_dir.is_dir(): + total_size = sum( + f.stat().st_size for f in dataset_dir.rglob("*") if f.is_file() + ) + datasets.append({ + "accession": dataset_dir.name, + "db_type": db_type, + "path": str(dataset_dir), + "size_mb": round(total_size / (1024*1024), 2) + }) + else: + for db_dir in self.download_dir.iterdir(): + if db_dir.is_dir(): + for dataset_dir in db_dir.iterdir(): + if dataset_dir.is_dir(): + total_size = sum( + f.stat().st_size for f in dataset_dir.rglob("*") if f.is_file() + ) + datasets.append({ + "accession": dataset_dir.name, + "db_type": db_dir.name, + "path": str(dataset_dir), + "size_mb": round(total_size / (1024*1024), 2) + }) + + return { + "datasets": datasets, + "count": len(datasets) + } + + def cleanup_downloads( + self, + accession: Optional[str] = None, + db_type: Optional[str] = None + ) -> Dict[str, Any]: + """Clean up downloaded files. + + Args: + accession: Optional specific accession to remove + db_type: Optional database type filter + + Returns: + Cleanup results + """ + removed = [] + + if accession and db_type: + # Remove specific dataset + dataset_path = self.download_dir / db_type / accession + if dataset_path.exists(): + shutil.rmtree(dataset_path) + removed.append(str(dataset_path)) + elif db_type: + # Remove all datasets of a specific type + db_path = self.download_dir / db_type + if db_path.exists(): + for dataset_dir in db_path.iterdir(): + if dataset_dir.is_dir(): + shutil.rmtree(dataset_dir) + removed.append(str(dataset_dir)) + elif accession: + # Remove all matching accessions across types + for db_dir in self.download_dir.iterdir(): + if db_dir.is_dir(): + dataset_path = db_dir / accession + if dataset_path.exists(): + shutil.rmtree(dataset_path) + removed.append(str(dataset_path)) + else: + # Remove all downloads + for db_dir in self.download_dir.iterdir(): + if db_dir.is_dir(): + shutil.rmtree(db_dir) + removed.append(str(db_dir)) + + return { + "removed": removed, + "count": len(removed) + } diff --git a/geomcp_sra/geo_search.py b/geomcp_sra/geo_search.py new file mode 100644 index 0000000..a9be1aa --- /dev/null +++ b/geomcp_sra/geo_search.py @@ -0,0 +1,384 @@ +"""GEO search functionality using NCBI E-Utilities.""" + +import json +import time +from typing import Dict, Any, List, Optional +import httpx + +from .config import get_config + + +class GEOSearchError(Exception): + """Exception raised for GEO search errors.""" + pass + + +class GEOSearchClient: + """Client for searching GEO database using NCBI E-Utilities.""" + + def __init__(self): + self.config = get_config() + self.base_url = self.config["base_url"] + self.email = self.config["email"] + self.api_key = self.config.get("api_key") + self.retmax = self.config.get("retmax", 20) + self._last_request_time = 0 + + def _rate_limit(self): + """Apply rate limiting to be respectful to NCBI servers.""" + # Without API key: 3 requests per second + # With API key: 10 requests per second + min_interval = 0.1 if self.api_key else 0.34 + + elapsed = time.time() - self._last_request_time + if elapsed < min_interval: + time.sleep(min_interval - elapsed) + self._last_request_time = time.time() + + def _build_params(self, extra_params: Dict[str, Any]) -> Dict[str, str]: + """Build request parameters with authentication.""" + params = {"email": self.email, **extra_params} + if self.api_key: + params["api_key"] = self.api_key + return params + + async def _esearch(self, db: str, term: str, retmax: int = 20) -> Dict[str, Any]: + """Perform ESearch query. + + Args: + db: Database to search (gds, geoprofiles, etc.) + term: Search term + retmax: Maximum results to return + + Returns: + ESearch response dictionary + """ + self._rate_limit() + + params = self._build_params({ + "db": db, + "term": term, + "retmax": retmax, + "retmode": "json", + }) + + async with httpx.AsyncClient() as client: + response = await client.get( + f"{self.base_url}/esearch.fcgi", + params=params, + timeout=30.0 + ) + response.raise_for_status() + return response.json() + + async def _esummary(self, db: str, ids: List[str]) -> Dict[str, Any]: + """Fetch summaries for a list of IDs. + + Args: + db: Database + ids: List of IDs to summarize + + Returns: + ESummary response dictionary + """ + if not ids: + return {"result": {}} + + self._rate_limit() + + params = self._build_params({ + "db": db, + "id": ",".join(map(str, ids)), + "retmode": "json", + }) + + async with httpx.AsyncClient() as client: + response = await client.get( + f"{self.base_url}/esummary.fcgi", + params=params, + timeout=30.0 + ) + response.raise_for_status() + return response.json() + + async def _efetch(self, db: str, id: str, retmode: str = "xml") -> str: + """Fetch full records. + + Args: + db: Database + id: Record ID + retmode: Return mode (xml, json, etc.) + + Returns: + EFetch response text + """ + self._rate_limit() + + params = self._build_params({ + "db": db, + "id": id, + "retmode": retmode, + }) + + async with httpx.AsyncClient() as client: + response = await client.get( + f"{self.base_url}/efetch.fcgi", + params=params, + timeout=30.0 + ) + response.raise_for_status() + return response.text + + async def search_geo( + self, + term: str, + retmax: Optional[int] = None, + record_types: Optional[List[str]] = None + ) -> Dict[str, Any]: + """Search GEO for all types of records. + + Args: + term: Search term (e.g., 'breast cancer', 'GSE12345', 'RNA-seq') + retmax: Maximum number of results to return + record_types: Optional filter for specific types ["GSE", "GSM", "GPL", "GDS"] + + Returns: + Dictionary with categorized results by record type + """ + try: + retmax = retmax or self.retmax + + # Search the gds database + data = await self._esearch('gds', term, retmax) + ids = data.get('esearchresult', {}).get('idlist', []) + + if not ids: + return { + "total_count": 0, + "results": [], + "series": [], + "samples": [], + "platforms": [], + "datasets": [] + } + + # Get detailed summaries + summaries = await self._esummary('gds', ids) + results = summaries.get('result', {}) + + # Categorize results by accession type + categorized = { + "total_count": len(ids), + "results": [], + "series": [], # GSE records + "samples": [], # GSM records + "platforms": [], # GPL records + "datasets": [] # GDS records + } + + for uid in ids: + if uid in results: + record = results[uid] + accession = record.get('accession', '') + + # Add to main results + categorized["results"].append(record) + + # Categorize by type + if accession.startswith('GSE'): + categorized["series"].append(record) + elif accession.startswith('GSM'): + categorized["samples"].append(record) + elif accession.startswith('GPL'): + categorized["platforms"].append(record) + elif accession.startswith('GDS'): + categorized["datasets"].append(record) + + # Filter by record types if specified + if record_types: + record_types = [rt.upper() for rt in record_types] + filtered_results = [] + + if "GSE" in record_types: + filtered_results.extend(categorized["series"]) + if "GSM" in record_types: + filtered_results.extend(categorized["samples"]) + if "GPL" in record_types: + filtered_results.extend(categorized["platforms"]) + if "GDS" in record_types: + filtered_results.extend(categorized["datasets"]) + + categorized["results"] = filtered_results + categorized["total_count"] = len(filtered_results) + + return categorized + + except httpx.HTTPStatusError as e: + raise GEOSearchError(f"HTTP error: {e.response.status_code} - {e.response.text}") + except Exception as e: + raise GEOSearchError(f"Search error: {str(e)}") + + async def search_geo_profiles(self, term: str, retmax: Optional[int] = None) -> Dict[str, Any]: + """Search GEO Profiles database. + + Args: + term: Search term + retmax: Maximum results to return + + Returns: + GEO Profiles search results + """ + try: + retmax = retmax or self.retmax + data = await self._esearch('geoprofiles', term, retmax) + ids = data.get('esearchresult', {}).get('idlist', []) + + if not ids: + return {"esummaryresult": ["Empty id list - nothing to do"]} + + summary = await self._esummary('geoprofiles', ids) + return summary + + except Exception as e: + raise GEOSearchError(f"GEO Profiles search error: {str(e)}") + + async def search_geo_datasets(self, term: str, retmax: Optional[int] = None) -> Dict[str, Any]: + """Search GEO Datasets (GDS) specifically. + + Args: + term: Search term + retmax: Maximum results to return + + Returns: + GDS search results + """ + result = await self.search_geo(term, retmax, record_types=["GDS"]) + + if result["datasets"]: + formatted_result = { + "header": {"type": "esummary", "version": "0.3"}, + "result": { + "uids": [r.get("uid") for r in result["datasets"]] + } + } + for record in result["datasets"]: + uid = record.get("uid") + if uid: + formatted_result["result"][uid] = record + + return formatted_result + else: + return {"esummaryresult": ["Empty id list - nothing to do"]} + + async def search_geo_series(self, term: str, retmax: Optional[int] = None) -> Dict[str, Any]: + """Search GEO Series (GSE) specifically. + + Args: + term: Search term + retmax: Maximum results to return + + Returns: + GSE search results + """ + result = await self.search_geo(term, retmax, record_types=["GSE"]) + + if result["series"]: + formatted_result = { + "header": {"type": "esummary", "version": "0.3"}, + "result": { + "uids": [r.get("uid") for r in result["series"]] + } + } + for record in result["series"]: + uid = record.get("uid") + if uid: + formatted_result["result"][uid] = record + + return formatted_result + else: + return {"esummaryresult": ["Empty id list - nothing to do"]} + + async def search_geo_samples(self, term: str, retmax: Optional[int] = None) -> Dict[str, Any]: + """Search GEO Samples (GSM) specifically. + + Args: + term: Search term + retmax: Maximum results to return + + Returns: + GSM search results + """ + result = await self.search_geo(term, retmax, record_types=["GSM"]) + + if result["samples"]: + formatted_result = { + "header": {"type": "esummary", "version": "0.3"}, + "result": { + "uids": [r.get("uid") for r in result["samples"]] + } + } + for record in result["samples"]: + uid = record.get("uid") + if uid: + formatted_result["result"][uid] = record + + return formatted_result + else: + return {"esummaryresult": ["Empty id list - nothing to do"]} + + async def search_geo_platforms(self, term: str, retmax: Optional[int] = None) -> Dict[str, Any]: + """Search GEO Platforms (GPL) specifically. + + Args: + term: Search term + retmax: Maximum results to return + + Returns: + GPL search results + """ + result = await self.search_geo(term, retmax, record_types=["GPL"]) + + if result["platforms"]: + formatted_result = { + "header": {"type": "esummary", "version": "0.3"}, + "result": { + "uids": [r.get("uid") for r in result["platforms"]] + } + } + for record in result["platforms"]: + uid = record.get("uid") + if uid: + formatted_result["result"][uid] = record + + return formatted_result + else: + return {"esummaryresult": ["Empty id list - nothing to do"]} + + async def get_series_info(self, gse_id: str) -> Dict[str, Any]: + """Get detailed information about a GEO Series. + + Args: + gse_id: GSE accession ID (e.g., 'GSE12345') + + Returns: + Series information dictionary + """ + try: + # Search for the specific GSE + data = await self._esearch('gds', f"{gse_id}[ACCN]", 1) + ids = data.get('esearchresult', {}).get('idlist', []) + + if not ids: + raise GEOSearchError(f"Series {gse_id} not found") + + # Get summary + summaries = await self._esummary('gds', ids) + result = summaries.get('result', {}) + + if ids[0] in result: + return result[ids[0]] + else: + raise GEOSearchError(f"No summary available for {gse_id}") + + except Exception as e: + raise GEOSearchError(f"Error getting series info: {str(e)}") diff --git a/geomcp_sra/sra_handler.py b/geomcp_sra/sra_handler.py new file mode 100644 index 0000000..d059f94 --- /dev/null +++ b/geomcp_sra/sra_handler.py @@ -0,0 +1,1040 @@ +"""SRA (Sequence Read Archive) handling functionality. + +This module provides capabilities to: +1. Query SRA Run information from GEO Series (map GSM to SRR accessions) +2. Get SRA accession lists for datasets +3. Generate download commands for SRA data +4. Optionally download FASTQ files using sra-toolkit +""" + +import asyncio +import json +import os +import re +import subprocess +import xml.etree.ElementTree as ET +from pathlib import Path +from typing import Dict, Any, List, Optional, Tuple +import httpx + +from .config import get_config + + +class SRAError(Exception): + """Exception raised for SRA-related errors.""" + pass + + +class SRAHandler: + """Handler for SRA data queries and downloads.""" + + # SRA endpoints + SRA_TRACE_URL = "https://trace.ncbi.nlm.nih.gov/Traces/sra" + SRA_EUTILS_URL = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils" + SRA_FETCH_URL = "https://sra-downloadb.be-md.ncbi.nlm.nih.gov/sos2/sra-pub-run-11" + + def __init__(self): + self.config = get_config() + self.email = self.config["email"] + self.api_key = self.config.get("api_key") + self.sra_toolkit_path = self.config.get("sra_toolkit_path") + self.download_dir = Path(self.config["download_dir"]).resolve() / "sra" + self.download_dir.mkdir(parents=True, exist_ok=True) + + def _build_params(self, extra_params: Dict[str, Any]) -> Dict[str, str]: + """Build request parameters with authentication.""" + params = {"email": self.email, **extra_params} + if self.api_key: + params["api_key"] = self.api_key + return params + + async def _fetch_geo_soft(self, gse_id: str) -> str: + """Fetch GEO Series SOFT file to extract SRA information. + + Args: + gse_id: GSE accession ID + + Returns: + SOFT file content as string + """ + range_dir = f"{gse_id[:-3]}nnn" + url = f"https://ftp.ncbi.nlm.nih.gov/geo/series/{range_dir}/{gse_id}/soft/{gse_id}_family.soft.gz" + + async with httpx.AsyncClient() as client: + response = await client.get(url, timeout=60.0) + + if response.status_code == 404: + # Try without _family suffix + url = f"https://ftp.ncbi.nlm.nih.gov/geo/series/{range_dir}/{gse_id}/soft/{gse_id}.soft.gz" + response = await client.get(url, timeout=60.0) + + response.raise_for_status() + + # Decompress gzip content + import gzip + content = gzip.decompress(response.content) + return content.decode('utf-8', errors='replace') + + def _parse_sra_accessions_from_soft(self, soft_content: str) -> Dict[str, List[str]]: + """Parse SRA accession numbers from SOFT file content. + + Args: + soft_content: SOFT file content + + Returns: + Dictionary mapping GSM IDs to lists of SRA accessions (SRX experiments) + """ + gsm_to_sra = {} + current_gsm = None + + for line in soft_content.split('\n'): + line = line.strip() + + # Track current sample + if line.startswith('^SAMPLE = '): + current_gsm = line.split('=')[1].strip() + gsm_to_sra[current_gsm] = [] + + # Look for SRA relation links (contain SRX experiment IDs) + elif current_gsm and line.startswith('!Sample_relation = SRA:'): + # Extract SRX ID from URL like "https://www.ncbi.nlm.nih.gov/sra?term=SRX25362135" + srx_pattern = r'([SED]RX\d+)' + matches = re.findall(srx_pattern, line) + gsm_to_sra[current_gsm].extend(matches) + + # Remove empty entries + gsm_to_sra = {k: v for k, v in gsm_to_sra.items() if v} + + return gsm_to_sra + + async def _get_srr_from_srx(self, srx_id: str) -> List[str]: + """Get SRA Run (SRR) IDs from Experiment (SRX) ID. + + Args: + srx_id: SRX accession (e.g., 'SRX25362135') + + Returns: + List of SRR accessions + """ + srr_list = [] + + try: + async with httpx.AsyncClient() as client: + # Use E-Utilities to search for runs linked to this experiment + search_params = self._build_params({ + "db": "sra", + "term": f"{srx_id}[Experiment]", + "retmode": "json", + "retmax": 100 + }) + + response = await client.get( + f"{self.SRA_EUTILS_URL}/esearch.fcgi", + params=search_params, + timeout=30.0 + ) + response.raise_for_status() + + search_data = response.json() + sra_ids = search_data.get('esearchresult', {}).get('idlist', []) + + if not sra_ids: + return srr_list + + # Get summary for each SRA entry to find SRR IDs + for sra_id in sra_ids: + summary_params = self._build_params({ + "db": "sra", + "id": sra_id, + "retmode": "json" + }) + + response = await client.get( + f"{self.SRA_EUTILS_URL}/esummary.fcgi", + params=summary_params, + timeout=30.0 + ) + response.raise_for_status() + + summary_data = response.json() + + # Parse the result to find SRR IDs + result = summary_data.get('result', {}) + for uid in result.get('uids', []): + item = result.get(uid, {}) + # Look for run accessions in the summary + runs = item.get('runs', '') + if runs: + # Parse SRR from runs field + srr_matches = re.findall(r'([SED]RR\d+)', runs) + srr_list.extend(srr_matches) + + # Also check other fields + for key in ['accession', 'runlist', 'experiment']: + val = item.get(key, '') + if val: + srr_matches = re.findall(r'([SED]RR\d+)', str(val)) + srr_list.extend(srr_matches) + + except Exception as e: + # Don't fail if we can't get SRR info + pass + + return list(set(srr_list)) # Remove duplicates + + async def query_sra_from_geo(self, gse_id: str) -> Dict[str, Any]: + """Query SRA Run information from a GEO Series. + + This method extracts the mapping between GSM samples and SRR runs + from the GEO Series SOFT file. + + Args: + gse_id: GSE accession ID (e.g., 'GSE12345') + + Returns: + Dictionary with SRA run information + + Example: + { + "gse_id": "GSE12345", + "total_samples": 10, + "samples_with_sra": 8, + "sra_runs": [ + { + "gsm_id": "GSM123456", + "sra_accessions": ["SRR1234567", "SRR1234568"] + } + ] + } + """ + if not gse_id.upper().startswith("GSE"): + raise SRAError(f"Invalid GSE ID: {gse_id}") + + try: + # Fetch and parse SOFT file + soft_content = await self._fetch_geo_soft(gse_id) + gsm_to_sra = self._parse_sra_accessions_from_soft(soft_content) + + # Convert SRX (Experiment) IDs to SRR (Run) IDs + gsm_to_srr = {} + for gsm_id, srx_list in gsm_to_sra.items(): + srr_list = [] + for srx_id in srx_list: + srrs = await self._get_srr_from_srx(srx_id) + srr_list.extend(srrs) + if srr_list: + gsm_to_srr[gsm_id] = list(set(srr_list)) # Remove duplicates + + # Also try to get from E-Utilities link + additional_sra = await self._get_sra_from_eutils(gse_id) + + # Merge results + for gsm_id, sra_list in additional_sra.items(): + if gsm_id in gsm_to_srr: + # Merge without duplicates + existing = set(gsm_to_srr[gsm_id]) + for sra in sra_list: + if sra not in existing: + gsm_to_srr[gsm_id].append(sra) + else: + gsm_to_srr[gsm_id] = sra_list + + # Build response + samples_with_sra = [ + { + "gsm_id": gsm_id, + "sra_accessions": sra_list + } + for gsm_id, sra_list in gsm_to_srr.items() + ] + + # Get all unique SRR accessions + all_srr = set() + for sra_list in gsm_to_srr.values(): + all_srr.update(sra_list) + + return { + "gse_id": gse_id.upper(), + "total_samples": len(samples_with_sra), + "samples_with_sra": len(samples_with_sra), + "total_sra_runs": len(all_srr), + "all_sra_accessions": sorted(list(all_srr)), + "samples": samples_with_sra + } + + except httpx.HTTPStatusError as e: + if e.response.status_code == 404: + raise SRAError(f"GEO Series {gse_id} not found or SOFT file unavailable") + raise SRAError(f"HTTP error querying SRA info: {e.response.status_code}") + except Exception as e: + raise SRAError(f"Error querying SRA information: {str(e)}") + + async def _get_sra_from_eutils(self, gse_id: str) -> Dict[str, List[str]]: + """Get SRA accessions using E-Utilities. + + Args: + gse_id: GSE accession ID + + Returns: + Dictionary mapping GSM IDs to SRA accessions + """ + result = {} + + try: + # Search for samples in this series + async with httpx.AsyncClient() as client: + # First, get series UID + search_params = self._build_params({ + "db": "gds", + "term": f"{gse_id}[ACCN]", + "retmode": "json" + }) + + response = await client.get( + f"{self.SRA_EUTILS_URL}/esearch.fcgi", + params=search_params, + timeout=30.0 + ) + response.raise_for_status() + + search_data = response.json() + gds_ids = search_data.get('esearchresult', {}).get('idlist', []) + + if not gds_ids: + return result + + # Fetch summary to get sample information + summary_params = self._build_params({ + "db": "gds", + "id": gds_ids[0], + "retmode": "json" + }) + + response = await client.get( + f"{self.SRA_EUTILS_URL}/esummary.fcgi", + params=summary_params, + timeout=30.0 + ) + response.raise_for_status() + + # Note: E-utilities doesn't always have SRA links + # This is a fallback method + + except Exception: + # Don't fail if eutils doesn't return data + pass + + return result + + async def estimate_sra_size(self, sra_ids: List[str]) -> Dict[str, Any]: + """Estimate the size of SRA files before downloading. + + This is a "dry-run" mode that queries the SRA database to estimate + download sizes without actually downloading any data. + + Args: + sra_ids: List of SRA accession IDs + + Returns: + Dictionary with size estimates and warnings + """ + if not sra_ids: + raise SRAError("No SRA IDs provided") + + results = [] + total_size_bytes = 0 + + try: + async with httpx.AsyncClient() as client: + for sra_id in sra_ids: + sra_id = sra_id.upper() + + # Use EBI ENA API to get file size (more reliable than NCBI for size info) + ena_url = f"https://www.ebi.ac.uk/ena/portal/api/filereport?accession={sra_id}&result=read_run&fields=run_accession,fastq_bytes,sra_bytes,read_count,base_count" + + try: + response = await client.get(ena_url, timeout=30.0) + response.raise_for_status() + + lines = response.text.strip().split('\n') + if len(lines) >= 2: + # Parse TSV response + headers = lines[0].split('\t') + values = lines[1].split('\t') + + data = dict(zip(headers, values)) + + # Get SRA size if available, otherwise estimate from FASTQ + sra_bytes = data.get('sra_bytes', '') + fastq_bytes = data.get('fastq_bytes', '') + read_count = data.get('read_count', '0') + base_count = data.get('base_count', '0') + + # Calculate sizes + sra_size_mb = 0 + if sra_bytes and sra_bytes.isdigit(): + sra_size_mb = int(sra_bytes) / (1024 * 1024) + elif fastq_bytes: + # Estimate SRA size as ~40% of FASTQ (compressed) + fastq_sizes = fastq_bytes.split(';') + total_fastq = sum(int(x) for x in fastq_sizes if x.isdigit()) + sra_size_mb = (total_fastq * 0.4) / (1024 * 1024) + + # Estimate FASTQ size (SRA * 2.5 for decompressed) + estimated_fastq_mb = sra_size_mb * 2.5 + + total_size_bytes += sra_size_mb * 1024 * 1024 + + results.append({ + "sra_id": sra_id, + "sra_size_mb": round(sra_size_mb, 2), + "estimated_fastq_size_mb": round(estimated_fastq_mb, 2), + "read_count": int(read_count) if read_count.isdigit() else 0, + "base_count": int(base_count) if base_count.isdigit() else 0, + "warning": self._get_size_warning(sra_size_mb) + }) + else: + results.append({ + "sra_id": sra_id, + "sra_size_mb": "unknown", + "estimated_fastq_size_mb": "unknown", + "warning": "Could not retrieve size information from ENA" + }) + + except Exception as e: + results.append({ + "sra_id": sra_id, + "sra_size_mb": "unknown", + "estimated_fastq_size_mb": "unknown", + "warning": f"Error querying ENA: {str(e)}" + }) + + except Exception as e: + raise SRAError(f"Error estimating sizes: {str(e)}") + + # Calculate total + total_mb = total_size_bytes / (1024 * 1024) + total_gb = total_mb / 1024 + + return { + "dry_run": True, + "sra_count": len(sra_ids), + "individual_estimates": results, + "total_sra_size_mb": round(total_mb, 2), + "total_sra_size_gb": round(total_gb, 2), + "estimated_total_fastq_size_gb": round(total_gb * 2.5, 2), + "safety_warnings": self._get_safety_warnings(total_mb) + } + + def _get_size_warning(self, size_mb: float) -> str: + """Get warning message based on file size.""" + if size_mb == 0: + return "Size unknown" + elif size_mb < 100: + return "Small file (< 100 MB)" + elif size_mb < 1024: + return "Medium file (100 MB - 1 GB)" + elif size_mb < 5120: # 5 GB + return "⚠️ LARGE FILE (1-5 GB) - Ensure sufficient disk space" + else: + return "🚨 VERY LARGE FILE (> 5 GB) - Requires explicit confirmation" + + def _get_safety_warnings(self, total_mb: float) -> List[str]: + """Get safety warnings for the total download size.""" + warnings = [] + + if total_mb > 1024: # > 1 GB + warnings.append("Total download exceeds 1 GB. Ensure you have sufficient disk space.") + if total_mb > 5120: # > 5 GB + warnings.append("Total download exceeds 5 GB. This will take significant time and space.") + if total_mb > 10240: # > 10 GB + warnings.append("🚨 WARNING: Total download exceeds 10 GB! Consider downloading individual files.") + + return warnings + + async def get_sra_metadata(self, sra_id: str) -> Dict[str, Any]: + """Get metadata for an SRA run. + + Args: + sra_id: SRA accession (e.g., 'SRR1234567') + + Returns: + SRA run metadata + """ + if not re.match(r'^[SED]RR\d+$', sra_id, re.IGNORECASE): + raise SRAError(f"Invalid SRA ID: {sra_id}") + + sra_id = sra_id.upper() + + try: + # Use E-Utilities to get SRA metadata + async with httpx.AsyncClient() as client: + # Search in SRA database + search_params = self._build_params({ + "db": "sra", + "term": sra_id, + "retmode": "json" + }) + + response = await client.get( + f"{self.SRA_EUTILS_URL}/esearch.fcgi", + params=search_params, + timeout=30.0 + ) + response.raise_for_status() + + search_data = response.json() + sra_ids = search_data.get('esearchresult', {}).get('idlist', []) + + if not sra_ids: + return { + "sra_id": sra_id, + "found": False, + "error": "SRA accession not found in database" + } + + # Get summary + summary_params = self._build_params({ + "db": "sra", + "id": sra_ids[0], + "retmode": "json" + }) + + response = await client.get( + f"{self.SRA_EUTILS_URL}/esummary.fcgi", + params=summary_params, + timeout=30.0 + ) + response.raise_for_status() + + summary_data = response.json() + + return { + "sra_id": sra_id, + "found": True, + "metadata": summary_data + } + + except Exception as e: + raise SRAError(f"Error getting SRA metadata: {str(e)}") + + def get_sra_download_url(self, sra_id: str) -> str: + """Get direct download URL for an SRA run. + + Args: + sra_id: SRA accession (e.g., 'SRR1234567') + + Returns: + Direct download URL + """ + sra_id = sra_id.upper() + + # Construct SRA download URL + # Format: https://sra-downloadb.be-md.ncbi.nlm.nih.gov/sos2/sra-pub-run-11/{SRRxxxxxxx}/{SRRxxxxxxx}.1 + base_url = "https://sra-downloadb.be-md.ncbi.nlm.nih.gov/sos2/sra-pub-run-11" + return f"{base_url}/{sra_id}/{sra_id}.1" + + def generate_download_commands( + self, + sra_ids: List[str], + method: str = "prefetch", + output_dir: Optional[str] = None + ) -> Dict[str, Any]: + """Generate commands to download SRA data. + + Args: + sra_ids: List of SRA accessions + method: Download method ('prefetch', 'wget', 'curl', or 'aspera') + output_dir: Optional output directory + + Returns: + Dictionary with download commands and instructions + """ + if not sra_ids: + raise SRAError("No SRA IDs provided") + + output_dir = output_dir or str(self.download_dir) + commands = [] + + if method == "prefetch": + # sra-toolkit prefetch command + toolkit_path = self.sra_toolkit_path or "" + prefetch = f"{toolkit_path}/prefetch" if toolkit_path else "prefetch" + + for sra_id in sra_ids: + cmd = f"{prefetch} -O {output_dir} {sra_id}" + commands.append({ + "sra_id": sra_id, + "command": cmd, + "description": f"Download {sra_id} using sra-toolkit prefetch" + }) + + elif method == "fasterq-dump": + # Directly download and convert to FASTQ + toolkit_path = self.sra_toolkit_path or "" + fasterq_dump = f"{toolkit_path}/fasterq-dump" if toolkit_path else "fasterq-dump" + + for sra_id in sra_ids: + cmd = f"{fasterq_dump} --outdir {output_dir} {sra_id}" + commands.append({ + "sra_id": sra_id, + "command": cmd, + "description": f"Download and convert {sra_id} to FASTQ" + }) + + elif method == "wget": + # Direct HTTP download + for sra_id in sra_ids: + url = self.get_sra_download_url(sra_id) + cmd = f"wget -P {output_dir} {url}" + commands.append({ + "sra_id": sra_id, + "command": cmd, + "description": f"Download {sra_id} using wget" + }) + + elif method == "curl": + # Direct HTTP download with curl + for sra_id in sra_ids: + url = self.get_sra_download_url(sra_id) + output_file = f"{output_dir}/{sra_id}.sra" + cmd = f"curl -o {output_file} {url}" + commands.append({ + "sra_id": sra_id, + "command": cmd, + "description": f"Download {sra_id} using curl" + }) + + elif method == "aspera": + # Aspera high-speed download + # Requires aspera-cli to be installed + for sra_id in sra_ids: + aspera_url = f"anonftp@ftp.ncbi.nlm.nih.gov:/sra/sra-instant/reads/ByRun/sra/{sra_id[:3]}/{sra_id[:6]}/{sra_id}/{sra_id}.sra" + cmd = f"ascp -QT -l 300m -P33001 -i $HOME/.aspera/connect/etc/asperaweb_id_dsa.openssh {aspera_url} {output_dir}" + commands.append({ + "sra_id": sra_id, + "command": cmd, + "description": f"Download {sra_id} using Aspera (high-speed)" + }) + + else: + raise SRAError(f"Unknown download method: {method}") + + return { + "sra_ids": sra_ids, + "method": method, + "output_dir": output_dir, + "commands": commands, + "notes": self._get_method_notes(method) + } + + def _get_method_notes(self, method: str) -> str: + """Get notes for a download method.""" + notes = { + "prefetch": ( + "Requires sra-toolkit (https://github.com/ncbi/sra-tools). " + "Downloads SRA files which can then be converted to FASTQ using fasterq-dump." + ), + "fasterq-dump": ( + "Requires sra-toolkit. Downloads and converts to FASTQ in one step. " + "May take longer but produces immediately usable files." + ), + "wget": ( + "Direct HTTP download. Works without sra-toolkit but downloads SRA format files " + "which need to be converted using fasterq-dump." + ), + "curl": ( + "Direct HTTP download using curl. Similar to wget but more portable." + ), + "aspera": ( + "High-speed download using Aspera protocol. Requires aspera-cli. " + "Fastest method for large files." + ) + } + return notes.get(method, "") + + async def download_sra( + self, + sra_id: str, + convert_to_fastq: bool = False, + output_dir: Optional[Path] = None + ) -> Dict[str, Any]: + """Download an SRA file. + + Note: This method downloads SRA files directly. For production use, + it's recommended to use sra-toolkit prefetch/fasterq-dump instead. + + Args: + sra_id: SRA accession + convert_to_fastq: Whether to convert to FASTQ (requires sra-toolkit) + output_dir: Optional output directory + + Returns: + Download results + """ + sra_id = sra_id.upper() + output_dir = output_dir or self.download_dir + output_dir.mkdir(parents=True, exist_ok=True) + + url = self.get_sra_download_url(sra_id) + sra_file = output_dir / f"{sra_id}.sra" + + try: + # Download the SRA file + async with httpx.AsyncClient() as client: + async with client.stream("GET", url, timeout=300.0) as response: + response.raise_for_status() + + with open(sra_file, 'wb') as f: + async for chunk in response.aiter_bytes(): + f.write(chunk) + + result = { + "sra_id": sra_id, + "sra_file": str(sra_file), + "size_mb": round(sra_file.stat().st_size / (1024*1024), 2), + "converted_to_fastq": False + } + + # Convert to FASTQ if requested + if convert_to_fastq: + fastq_result = await self._convert_to_fastq(sra_file, output_dir) + result["converted_to_fastq"] = True + result["fastq_files"] = fastq_result + + return result + + except Exception as e: + # Clean up partial download + if sra_file.exists(): + sra_file.unlink() + raise SRAError(f"Download failed: {str(e)}") + + async def _convert_to_fastq( + self, + sra_file: Path, + output_dir: Path + ) -> List[str]: + """Convert SRA file to FASTQ using fasterq-dump. + + Args: + sra_file: Path to SRA file + output_dir: Output directory for FASTQ files + + Returns: + List of generated FASTQ files + """ + toolkit_path = self.sra_toolkit_path or "" + fasterq_dump = f"{toolkit_path}/fasterq-dump" if toolkit_path else "fasterq-dump" + + # Check if fasterq-dump is available + try: + result = subprocess.run( + [fasterq_dump, "--version"], + capture_output=True, + text=True, + timeout=10 + ) + if result.returncode != 0: + raise SRAError("fasterq-dump not found. Please install sra-toolkit.") + except FileNotFoundError: + raise SRAError( + "fasterq-dump not found. Please install sra-toolkit: " + "https://github.com/ncbi/sra-tools" + ) + + # Run fasterq-dump + cmd = [ + fasterq_dump, + "--outdir", str(output_dir), + "--threads", "4", + str(sra_file) + ] + + # Run in thread pool to avoid blocking + loop = asyncio.get_event_loop() + result = await loop.run_in_executor( + None, + lambda: subprocess.run(cmd, capture_output=True, text=True, timeout=3600) + ) + + if result.returncode != 0: + raise SRAError(f"fasterq-dump failed: {result.stderr}") + + # Find generated FASTQ files + sra_id = sra_file.stem + fastq_files = [] + for pattern in [f"{sra_id}*.fastq", f"{sra_id}*.fastq.gz"]: + fastq_files.extend(output_dir.glob(pattern)) + + return [str(f) for f in fastq_files] + + async def download_and_convert( + self, + sra_id: str, + output_dir: Optional[Path] = None, + split_3: bool = True, + check_refseq: bool = True, + dry_run: bool = False, + confirm_large: bool = False + ) -> Dict[str, Any]: + """Download SRA data using prefetch and convert to FASTQ using fastq-dump. + + SAFETY FEATURES (as per maintainer recommendations): + - Dry-run mode: Estimate sizes before downloading + - Size warnings: Alerts for large files (>1GB, >5GB) + - Explicit confirmation: Required for very large downloads + - Explicit output directory: Must be provided for large files + + This is the recommended workflow for downloading and converting SRA data: + 1. Uses prefetch to download SRA file (handles large files better) + 2. Uses fastq-dump --split-3 to convert to FASTQ (handles paired-end properly) + + Args: + sra_id: SRA accession ID (e.g., 'SRR1234567') + output_dir: Optional output directory (default: download_dir/sra_id) + split_3: Use --split-3 for 3-way splitting (recommended for mate-pairs) + check_refseq: Whether to check/download reference sequences + dry_run: If True, only estimate sizes without downloading (default: False) + confirm_large: Must be True to download files >5GB (safety check) + + Returns: + Dictionary with download and conversion results, or dry-run estimates + + Raises: + SRAError: If output_dir not provided for large files, or if confirm_large=False for >5GB files + """ + sra_id = sra_id.upper() + + # SAFETY CHECK 1: Estimate size before downloading + size_estimate = await self.estimate_sra_size([sra_id]) + total_mb = size_estimate.get("total_sra_size_mb", 0) + + # SAFETY CHECK 2: Dry-run mode - return estimates without downloading + if dry_run: + return { + "mode": "dry_run", + "sra_id": sra_id, + "size_estimate": size_estimate, + "note": "To proceed with download, call with dry_run=False" + } + + # SAFETY CHECK 3: Explicit output directory required for large files + if total_mb > 1024 and output_dir is None: # > 1GB + raise SRAError( + f"SAFETY CHECK: File size is ~{total_mb/1024:.1f} GB. " + f"Large downloads require an explicit output directory. " + f"Please provide output_dir parameter. " + f"Tip: Run with dry_run=True first to see size estimates." + ) + + # SAFETY CHECK 4: Confirmation required for very large files + if total_mb > 5120 and not confirm_large: # > 5GB + raise SRAError( + f"SAFETY CHECK: File size is ~{total_mb/1024:.1f} GB (>5GB). " + f"This is a VERY LARGE download that will consume significant " + f"disk space and time. To proceed, set confirm_large=True. " + f"Tip: Run with dry_run=True first to see detailed estimates." + ) + + output_dir = output_dir or (self.download_dir / sra_id) + output_dir.mkdir(parents=True, exist_ok=True) + + # Check for sra-toolkit + toolkit_check = self.check_sra_toolkit() + if not toolkit_check["all_available"]: + raise SRAError( + "sra-toolkit not found. Please install from: " + "https://github.com/ncbi/sra-tools" + ) + + toolkit_path = self.sra_toolkit_path or "" + prefetch = f"{toolkit_path}/prefetch" if toolkit_path else "prefetch" + fastq_dump = f"{toolkit_path}/fastq-dump" if toolkit_path else "fastq-dump" + + result = { + "sra_id": sra_id, + "output_dir": str(output_dir), + "size_estimate_mb": total_mb, + "safety_warnings": size_estimate.get("safety_warnings", []), + "steps": [] + } + + try: + # Step 1: Download using prefetch + import logging + logger = logging.getLogger(__name__) + + cmd = [prefetch, "--progress", "--output-directory", str(output_dir)] + + # Handle refseq checking + if not check_refseq: + cmd.extend(["--check-rs", "no"]) + + cmd.append(sra_id) + + loop = asyncio.get_event_loop() + prefetch_result = await loop.run_in_executor( + None, + lambda: subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=3600 # 1 hour timeout for large files + ) + ) + + if prefetch_result.returncode != 0: + raise SRAError(f"prefetch failed: {prefetch_result.stderr}") + + # Find the downloaded SRA file + sra_file = output_dir / sra_id / f"{sra_id}.sra" + if not sra_file.exists(): + # Try alternative locations + alt_paths = [ + output_dir / f"{sra_id}.sra", + self.download_dir / sra_id / f"{sra_id}.sra", + Path(f"{sra_id}/{sra_id}.sra"), + ] + for alt_path in alt_paths: + if alt_path.exists(): + sra_file = alt_path + break + + if not sra_file.exists(): + raise SRAError(f"SRA file not found after download: {sra_file}") + + sra_size_mb = round(sra_file.stat().st_size / (1024*1024), 2) + + result["steps"].append({ + "step": "download", + "status": "success", + "sra_file": str(sra_file), + "sra_size_mb": sra_size_mb + }) + + # Step 2: Convert to FASTQ using fastq-dump + print(f"Step 2: Converting {sra_id} to FASTQ using fastq-dump...") + + cmd = [fastq_dump, "--outdir", str(output_dir)] + + if split_3: + cmd.append("--split-3") + else: + cmd.append("--split-files") + + cmd.append(str(sra_file)) + + fastq_result = await loop.run_in_executor( + None, + lambda: subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=3600 # 1 hour timeout + ) + ) + + if fastq_result.returncode != 0: + raise SRAError(f"fastq-dump failed: {fastq_result.stderr}") + + # Find generated FASTQ files + fastq_files = [] + for pattern in [f"{sra_id}*.fastq", f"{sra_id}*.fastq.gz"]: + fastq_files.extend(output_dir.glob(pattern)) + + # Also check in sra_id subdirectory + if (output_dir / sra_id).exists(): + for pattern in [f"{sra_id}*.fastq", f"{sra_id}*.fastq.gz"]: + fastq_files.extend((output_dir / sra_id).glob(pattern)) + + fastq_info = [] + total_fastq_size_mb = 0 + for f in fastq_files: + size_mb = round(f.stat().st_size / (1024*1024), 2) + total_fastq_size_mb += size_mb + + # Count reads (each read = 4 lines) + line_count = 0 + try: + with open(f, 'r') as fp: + for _ in fp: + line_count += 1 + if line_count >= 4: + break + # Get total lines + result_count = subprocess.run( + ["wc", "-l", str(f)], + capture_output=True, + text=True + ) + total_lines = int(result_count.stdout.split()[0]) + read_count = total_lines // 4 + except: + read_count = "unknown" + + fastq_info.append({ + "file": str(f.name), + "path": str(f), + "size_mb": size_mb, + "reads": read_count + }) + + result["steps"].append({ + "step": "convert", + "status": "success", + "fastq_files": fastq_info, + "total_fastq_size_mb": total_fastq_size_mb + }) + + result["status"] = "success" + result["total_size_mb"] = sra_size_mb + total_fastq_size_mb + + return result + + except Exception as e: + result["status"] = "failed" + result["error"] = str(e) + raise SRAError(f"Download and convert failed: {str(e)}") + + def check_sra_toolkit(self) -> Dict[str, Any]: + """Check if sra-toolkit is installed and available. + + Returns: + Status information about sra-toolkit + """ + toolkit_path = self.sra_toolkit_path or "" + tools = ["prefetch", "fastq-dump", "fasterq-dump", "vdb-validate"] + + results = {} + all_found = True + + for tool in tools: + cmd = f"{toolkit_path}/{tool}" if toolkit_path else tool + try: + result = subprocess.run( + [cmd, "--version"], + capture_output=True, + text=True, + timeout=10 + ) + results[tool] = { + "available": result.returncode == 0, + "version": result.stdout.strip() if result.returncode == 0 else None, + "path": cmd + } + if result.returncode != 0: + all_found = False + except FileNotFoundError: + results[tool] = {"available": False, "path": cmd} + all_found = False + except Exception as e: + results[tool] = {"available": False, "error": str(e)} + all_found = False + + return { + "all_available": all_found, + "toolkit_path": toolkit_path or "System PATH", + "tools": results, + "installation_url": "https://github.com/ncbi/sra-tools/wiki/02.-Installing-SRA-Toolkit" + } diff --git a/pyproject.toml b/pyproject.toml index a2608b1..d6c9ae2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,16 +5,16 @@ build-backend = "hatchling.build" [project] name = "geo-mcp" dynamic = ["version"] -description = "A Model Context Protocol (MCP) server for accessing GEO (Gene Expression Omnibus) data through NCBI E-Utils API" +description = "An enhanced MCP server for accessing GEO data with SRA raw sequencing support" readme = "README.md" license = {text = "MIT"} requires-python = ">=3.10" -keywords = ["mcp", "geo", "bioinformatics", "gene-expression", "ncbi", "e-utils"] +keywords = ["mcp", "geo", "sra", "bioinformatics", "gene-expression", "ncbi", "rna-seq", "fastq"] authors = [ - {name ="MCPmed Contributors", email = "matthias.flotho@ccb.uni-saarland.de"} + {name = "GEO MCP Contributors"} ] classifiers = [ - "Development Status :: 3 - Alpha", + "Development Status :: 4 - Beta", "Intended Audience :: Science/Research", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", @@ -22,26 +22,25 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Topic :: Scientific/Engineering :: Bio-Informatics", "Topic :: Software Development :: Libraries :: Python Modules", ] dependencies = [ - "mcp[cli]>=1.9.4", - "requests>=2.31.0", - "aiohttp>=3.8.0", + "mcp>=1.9.0", + "httpx>=0.27.0", "aiofiles>=23.0.0", - "fastapi>=0.100.0", - "uvicorn>=0.20.0", + "pydantic>=2.0.0", ] [project.urls] -Homepage = "https://github.com/MCPmed/geomcp" -Repository = "https://github.com/MCPmed/geomcp" -Documentation = "https://github.com/MCPmed/geomcp#readme" -"Bug Tracker" = "https://github.com/MCPmed/geomcp/issues" +Homepage = "https://github.com/yourusername/geo-mcp-sra" +Repository = "https://github.com/yourusername/geo-mcp-sra" +Documentation = "https://github.com/yourusername/geo-mcp-sra#readme" +"Bug Tracker" = "https://github.com/yourusername/geo-mcp-sra/issues" [project.scripts] -geo-mcp = "geomcp.main:main" +geo-mcp-sra = "server:main" [project.optional-dependencies] dev = [ @@ -51,23 +50,19 @@ dev = [ "ruff>=0.1.0", "mypy>=1.0.0", ] -test = [ - "pytest>=7.0.0", - "pytest-asyncio>=0.21.0", -] [tool.hatch.version] source = "vcs" [tool.hatch.build.targets.wheel] -packages = ["geomcp"] +packages = ["geomcp_sra"] [tool.black] -line-length = 88 +line-length = 100 target-version = ['py310'] [tool.ruff] -line-length = 88 +line-length = 100 target-version = "py310" [tool.mypy] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ad97d61 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +mcp>=1.9.0 +httpx>=0.27.0 +aiofiles>=23.0.0 +pydantic>=2.0.0 diff --git a/server.py b/server.py new file mode 100644 index 0000000..f921c08 --- /dev/null +++ b/server.py @@ -0,0 +1,1031 @@ +#!/usr/bin/env python3 +""" +GEO MCP Server with SRA Support + +An enhanced Model Context Protocol (MCP) server for accessing +GEO (Gene Expression Omnibus) data through NCBI E-Utils API, +with additional support for SRA (Sequence Read Archive) raw data queries. + +Usage: + python server.py # Run MCP stdio server + python server.py --http # Run HTTP server + python server.py --http --port 8080 # Run HTTP server on custom port + python server.py --init # Initialize configuration +""" + +import argparse +import asyncio +import json +import sys +from pathlib import Path +from typing import Optional, List +from enum import Enum + +from pydantic import BaseModel, Field, field_validator, ConfigDict +from mcp.server.fastmcp import FastMCP + +# Import our modules +from geomcp_sra.config import load_config, validate_config, create_config_template, get_config +from geomcp_sra.geo_search import GEOSearchClient, GEOSearchError +from geomcp_sra.geo_download import GEODownloadClient, GEODownloadError +from geomcp_sra.sra_handler import SRAHandler, SRAError + + +# Initialize MCP server +mcp = FastMCP("geo_mcp") + + +# ============================================================================ +# Enums and Response Formats +# ============================================================================ + +class ResponseFormat(str, Enum): + """Output format for tool responses.""" + MARKDOWN = "markdown" + JSON = "json" + + +class DownloadMethod(str, Enum): + """SRA download methods.""" + PREFETCH = "prefetch" + FASTERQ_DUMP = "fasterq-dump" + WGET = "wget" + CURL = "curl" + ASPERA = "aspera" + + +# ============================================================================ +# Pydantic Models for Input Validation +# ============================================================================ + +class SearchInput(BaseModel): + """Base input for search operations.""" + model_config = ConfigDict(str_strip_whitespace=True) + + term: str = Field( + ..., + description="Search term (e.g., 'breast cancer', 'GSE12345', 'RNA-seq')", + min_length=1, + max_length=500 + ) + retmax: int = Field( + default=20, + description="Maximum number of results to return", + ge=1, + le=1000 + ) + response_format: ResponseFormat = Field( + default=ResponseFormat.JSON, + description="Output format: 'json' for structured data or 'markdown' for readable text" + ) + + +class SearchWithTypesInput(SearchInput): + """Input for search with record type filtering.""" + record_types: Optional[List[str]] = Field( + default=None, + description="Filter for specific record types: GSE, GSM, GPL, GDS" + ) + + +class GeoIdInput(BaseModel): + """Input for GEO ID operations.""" + model_config = ConfigDict(str_strip_whitespace=True) + + geo_id: str = Field( + ..., + description="GEO accession ID (e.g., GSE12345, GSM789, GPL456, GDS123)", + pattern=r'^(GSE|GSM|GPL|GDS)\d+$' + ) + + +class DownloadInput(GeoIdInput): + """Input for download operations.""" + db_type: str = Field( + default="gse", + description="Database type: gse, gsm, gpl, or gds" + ) + output_dir: Optional[str] = Field( + default=None, + description="Optional custom output directory" + ) + file_types: Optional[List[str]] = Field( + default=None, + description="File types to download (for series: soft, matrix, miniml, supplementary)" + ) + + +class SRASearchInput(BaseModel): + """Input for SRA search operations.""" + model_config = ConfigDict(str_strip_whitespace=True) + + gse_id: str = Field( + ..., + description="GEO Series accession ID (e.g., GSE12345)", + pattern=r'^GSE\d+$' + ) + + +class SRADownloadInput(BaseModel): + """Input for SRA download command generation.""" + model_config = ConfigDict(str_strip_whitespace=True) + + sra_ids: List[str] = Field( + ..., + description="List of SRA accession IDs (e.g., ['SRR1234567', 'SRR1234568'])", + min_length=1 + ) + method: DownloadMethod = Field( + default=DownloadMethod.PREFETCH, + description="Download method: prefetch, fasterq-dump, wget, curl, or aspera" + ) + output_dir: Optional[str] = Field( + default=None, + description="Optional output directory for downloads" + ) + + +class SRADirectDownloadInput(BaseModel): + """Input for direct SRA download.""" + model_config = ConfigDict(str_strip_whitespace=True) + + sra_id: str = Field( + ..., + description="SRA accession ID (e.g., SRR1234567)", + pattern=r'^[SED]RR\d+$' + ) + convert_to_fastq: bool = Field( + default=False, + description="Whether to convert SRA to FASTQ format (requires sra-toolkit)" + ) + output_dir: Optional[str] = Field( + default=None, + description="Optional output directory" + ) + + +class CleanupInput(BaseModel): + """Input for cleanup operations.""" + model_config = ConfigDict(str_strip_whitespace=True) + + geo_id: Optional[str] = Field( + default=None, + description="Optional specific GEO ID to remove" + ) + db_type: Optional[str] = Field( + default=None, + description="Optional database type filter for cleanup (gse, gsm, gpl, gds, sra)" + ) + + +# ============================================================================ +# Helper Functions +# ============================================================================ + +def format_as_markdown(data: dict, title: str = "Results") -> str: + """Format results as markdown for human readability.""" + lines = [f"# {title}", ""] + + def format_value(value, indent=0): + prefix = " " * indent + if isinstance(value, dict): + for k, v in value.items(): + if isinstance(v, (dict, list)): + lines.append(f"{prefix}- **{k}:**") + format_value(v, indent + 1) + else: + lines.append(f"{prefix}- **{k}:** {v}") + elif isinstance(value, list): + for item in value: + if isinstance(item, dict): + lines.append(f"{prefix}-") + format_value(item, indent + 1) + else: + lines.append(f"{prefix}- {item}") + else: + lines.append(f"{prefix}{value}") + + format_value(data) + return "\n".join(lines) + + +def handle_error(error: Exception) -> str: + """Format error messages consistently.""" + if isinstance(error, GEOSearchError): + return f"GEO Search Error: {str(error)}" + elif isinstance(error, GEODownloadError): + return f"GEO Download Error: {str(error)}" + elif isinstance(error, SRAError): + return f"SRA Error: {str(error)}" + elif isinstance(error, ValueError): + return f"Validation Error: {str(error)}" + else: + return f"Error: {type(error).__name__}: {str(error)}" + + +# ============================================================================ +# GEO Search Tools +# ============================================================================ + +@mcp.tool( + name="geo_search", + annotations={ + "title": "Search GEO Database", + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": True + } +) +async def geo_search(params: SearchWithTypesInput) -> str: + '''Search GEO for all types of records (GSE, GSM, GPL, GDS). + + This tool searches across all GEO databases and returns categorized results + by record type (Series, Samples, Platforms, Datasets). + + Args: + params: Search parameters including term, retmax, record_types, and response_format + + Returns: + JSON or Markdown formatted search results + + Examples: + - Search for cancer studies: term="breast cancer" + - Find specific series: term="GSE12345" + - Search for RNA-seq data: term="RNA-seq" + - Filter to only series: record_types=["GSE"] + ''' + try: + client = GEOSearchClient() + result = await client.search_geo( + term=params.term, + retmax=params.retmax, + record_types=params.record_types + ) + + if params.response_format == ResponseFormat.MARKDOWN: + return format_as_markdown(result, f"GEO Search Results: '{params.term}'") + return json.dumps(result, indent=2) + + except Exception as e: + return handle_error(e) + + +@mcp.tool( + name="geo_search_series", + annotations={ + "title": "Search GEO Series", + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": True + } +) +async def geo_search_series(params: SearchInput) -> str: + '''Search for GEO Series (GSE) - complete experiments. + + Args: + params: Search parameters including term and retmax + + Returns: + JSON or Markdown formatted GSE search results + ''' + try: + client = GEOSearchClient() + result = await client.search_geo_series(params.term, params.retmax) + + if params.response_format == ResponseFormat.MARKDOWN: + return format_as_markdown(result, f"GEO Series Search: '{params.term}'") + return json.dumps(result, indent=2) + + except Exception as e: + return handle_error(e) + + +@mcp.tool( + name="geo_search_samples", + annotations={ + "title": "Search GEO Samples", + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": True + } +) +async def geo_search_samples(params: SearchInput) -> str: + '''Search for GEO Samples (GSM) - individual samples. + + Args: + params: Search parameters including term and retmax + + Returns: + JSON or Markdown formatted GSM search results + ''' + try: + client = GEOSearchClient() + result = await client.search_geo_samples(params.term, params.retmax) + + if params.response_format == ResponseFormat.MARKDOWN: + return format_as_markdown(result, f"GEO Sample Search: '{params.term}'") + return json.dumps(result, indent=2) + + except Exception as e: + return handle_error(e) + + +@mcp.tool( + name="geo_search_platforms", + annotations={ + "title": "Search GEO Platforms", + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": True + } +) +async def geo_search_platforms(params: SearchInput) -> str: + '''Search for GEO Platforms (GPL) - array/sequencing platforms. + + Args: + params: Search parameters including term and retmax + + Returns: + JSON or Markdown formatted GPL search results + ''' + try: + client = GEOSearchClient() + result = await client.search_geo_platforms(params.term, params.retmax) + + if params.response_format == ResponseFormat.MARKDOWN: + return format_as_markdown(result, f"GEO Platform Search: '{params.term}'") + return json.dumps(result, indent=2) + + except Exception as e: + return handle_error(e) + + +@mcp.tool( + name="geo_search_datasets", + annotations={ + "title": "Search GEO Datasets", + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": True + } +) +async def geo_search_datasets(params: SearchInput) -> str: + '''Search for GEO Datasets (GDS) - curated gene expression datasets. + + Args: + params: Search parameters including term and retmax + + Returns: + JSON or Markdown formatted GDS search results + ''' + try: + client = GEOSearchClient() + result = await client.search_geo_datasets(params.term, params.retmax) + + if params.response_format == ResponseFormat.MARKDOWN: + return format_as_markdown(result, f"GEO Dataset Search: '{params.term}'") + return json.dumps(result, indent=2) + + except Exception as e: + return handle_error(e) + + +@mcp.tool( + name="geo_search_profiles", + annotations={ + "title": "Search GEO Profiles", + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": True + } +) +async def geo_search_profiles(params: SearchInput) -> str: + '''Search GEO Profiles database for gene expression profiles. + + Args: + params: Search parameters including term and retmax + + Returns: + JSON or Markdown formatted GEO Profiles search results + ''' + try: + client = GEOSearchClient() + result = await client.search_geo_profiles(params.term, params.retmax) + + if params.response_format == ResponseFormat.MARKDOWN: + return format_as_markdown(result, f"GEO Profiles Search: '{params.term}'") + return json.dumps(result, indent=2) + + except Exception as e: + return handle_error(e) + + +# ============================================================================ +# GEO Download Tools +# ============================================================================ + +@mcp.tool( + name="geo_download_series", + annotations={ + "title": "Download GEO Series Data", + "readOnlyHint": False, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": True + } +) +async def geo_download_series(params: DownloadInput) -> str: + '''Download GEO Series data files (SOFT, matrix, supplementary). + + Downloads data files for a GEO Series including: + - SOFT format metadata (soft) + - Series matrix file (matrix) + - MINiML XML (miniml) + - Supplementary files (supplementary) + + Args: + params: Download parameters including geo_id, file_types, and output_dir + + Returns: + JSON formatted download results + + Examples: + - Download SOFT file: geo_id="GSE12345", file_types=["soft"] + - Download all: geo_id="GSE12345", file_types=["soft", "matrix", "supplementary"] + ''' + try: + client = GEODownloadClient() + output_dir = Path(params.output_dir) if params.output_dir else None + + result = await client.download_geo_series( + gse_id=params.geo_id.upper(), + file_types=params.file_types, + output_dir=output_dir + ) + + return json.dumps(result, indent=2) + + except Exception as e: + return handle_error(e) + + +@mcp.tool( + name="geo_download_sample", + annotations={ + "title": "Download GEO Sample Data", + "readOnlyHint": False, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": True + } +) +async def geo_download_sample(params: DownloadInput) -> str: + '''Download GEO Sample supplementary files. + + Downloads supplementary data files for a specific GEO Sample (GSM). + + Args: + params: Download parameters including geo_id (GSM) and output_dir + + Returns: + JSON formatted download results + ''' + try: + client = GEODownloadClient() + output_dir = Path(params.output_dir) if params.output_dir else None + + result = await client.download_geo_sample( + gsm_id=params.geo_id.upper(), + output_dir=output_dir + ) + + return json.dumps(result, indent=2) + + except Exception as e: + return handle_error(e) + + +@mcp.tool( + name="geo_get_download_status", + annotations={ + "title": "Get GEO Download Status", + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": False + } +) +async def geo_get_download_status(params: GeoIdInput) -> str: + '''Check if a GEO dataset has been downloaded. + + Args: + params: Parameters including geo_id and optional db_type + + Returns: + JSON formatted status information + ''' + try: + client = GEODownloadClient() + + # Determine db_type from geo_id prefix + db_type = params.geo_id[:3].lower() + + result = client.get_download_status( + accession=params.geo_id.upper(), + db_type=db_type + ) + + return json.dumps(result, indent=2) + + except Exception as e: + return handle_error(e) + + +@mcp.tool( + name="geo_list_downloads", + annotations={ + "title": "List Downloaded GEO Datasets", + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": False + } +) +async def geo_list_downloads(db_type: Optional[str] = None) -> str: + '''List all downloaded GEO datasets. + + Args: + db_type: Optional filter by database type (gse, gsm, gpl, gds, sra) + + Returns: + JSON formatted list of downloaded datasets + ''' + try: + client = GEODownloadClient() + result = client.list_downloaded_datasets(db_type) + return json.dumps(result, indent=2) + + except Exception as e: + return handle_error(e) + + +@mcp.tool( + name="geo_cleanup_downloads", + annotations={ + "title": "Clean Up GEO Downloads", + "readOnlyHint": False, + "destructiveHint": True, + "idempotentHint": False, + "openWorldHint": False + } +) +async def geo_cleanup_downloads(params: CleanupInput) -> str: + '''Clean up downloaded GEO files. + + Args: + params: Parameters including optional geo_id and db_type to remove + + Returns: + JSON formatted cleanup results + + Warning: + This is a destructive operation that deletes downloaded files. + ''' + try: + client = GEODownloadClient() + result = client.cleanup_downloads( + accession=params.geo_id, + db_type=params.db_type + ) + return json.dumps(result, indent=2) + + except Exception as e: + return handle_error(e) + + +# ============================================================================ +# SRA Tools (NEW - for raw sequencing data) +# ============================================================================ + +@mcp.tool( + name="sra_query_from_geo", + annotations={ + "title": "Query SRA Accessions from GEO Series", + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": True + } +) +async def sra_query_from_geo(params: SRASearchInput) -> str: + '''Query SRA Run information from a GEO Series. + + This tool extracts the mapping between GEO Samples (GSM) and + SRA Run accessions (SRR) from a GEO Series. This allows you to + identify which SRA files contain the raw sequencing data for a dataset. + + Args: + params: Parameters including gse_id + + Returns: + JSON formatted SRA accession information + + Example: + Input: gse_id="GSE12345" + Output: { + "gse_id": "GSE12345", + "total_samples": 10, + "samples_with_sra": 8, + "all_sra_accessions": ["SRR1234567", "SRR1234568", ...], + "samples": [ + { + "gsm_id": "GSM123456", + "sra_accessions": ["SRR1234567"] + } + ] + } + ''' + try: + handler = SRAHandler() + result = await handler.query_sra_from_geo(params.gse_id.upper()) + return json.dumps(result, indent=2) + + except Exception as e: + return handle_error(e) + + +@mcp.tool( + name="sra_get_metadata", + annotations={ + "title": "Get SRA Run Metadata", + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": True + } +) +async def sra_get_metadata(sra_id: str) -> str: + '''Get metadata for an SRA Run accession. + + Args: + sra_id: SRA accession ID (e.g., SRR1234567) + + Returns: + JSON formatted SRA metadata + ''' + try: + handler = SRAHandler() + result = await handler.get_sra_metadata(sra_id) + return json.dumps(result, indent=2) + + except Exception as e: + return handle_error(e) + + +@mcp.tool( + name="sra_generate_download_commands", + annotations={ + "title": "Generate SRA Download Commands", + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": False + } +) +async def sra_generate_download_commands(params: SRADownloadInput) -> str: + '''Generate commands to download SRA data. + + Generates download commands for SRA accessions using various methods: + - prefetch: Using sra-toolkit prefetch (recommended) + - fasterq-dump: Download and convert to FASTQ in one step + - wget: Direct HTTP download + - curl: Direct HTTP download using curl + - aspera: High-speed Aspera download + + Args: + params: Parameters including sra_ids list, method, and output_dir + + Returns: + JSON formatted commands and instructions + + Note: + This tool generates commands but does not execute them. + Run the commands in your terminal or use the shell tool. + ''' + try: + handler = SRAHandler() + result = handler.generate_download_commands( + sra_ids=[s.upper() for s in params.sra_ids], + method=params.method.value, + output_dir=params.output_dir + ) + return json.dumps(result, indent=2) + + except Exception as e: + return handle_error(e) + + +@mcp.tool( + name="sra_check_toolkit", + annotations={ + "title": "Check SRA Toolkit Installation", + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": False + } +) +async def sra_check_toolkit() -> str: + '''Check if SRA Toolkit is installed and available. + + Returns: + JSON formatted toolkit status and installation information + ''' + try: + handler = SRAHandler() + result = handler.check_sra_toolkit() + return json.dumps(result, indent=2) + + except Exception as e: + return handle_error(e) + + +@mcp.tool( + name="sra_download", + annotations={ + "title": "Download SRA Data Directly", + "readOnlyHint": False, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": True + } +) +async def sra_download(params: SRADirectDownloadInput) -> str: + '''Download SRA data directly (without sra-toolkit). + + Note: For large files or multiple downloads, using sra-toolkit + prefetch is recommended instead. This method is suitable for + small files or when sra-toolkit is not available. + + Args: + params: Parameters including sra_id, convert_to_fastq, and output_dir + + Returns: + JSON formatted download results + ''' + try: + handler = SRAHandler() + output_dir = Path(params.output_dir) if params.output_dir else None + + result = await handler.download_sra( + sra_id=params.sra_id.upper(), + convert_to_fastq=params.convert_to_fastq, + output_dir=output_dir + ) + return json.dumps(result, indent=2) + + except Exception as e: + return handle_error(e) + + +class SRADownloadAndConvertInput(BaseModel): + """Input for SRA download and convert operations.""" + model_config = ConfigDict(str_strip_whitespace=True) + + sra_id: str = Field( + ..., + description="SRA accession ID (e.g., SRR1234567)", + pattern=r'^[SED]RR\d+$' + ) + output_dir: Optional[str] = Field( + default=None, + description="Optional output directory (default: ~/geo_downloads/sra/). REQUIRED for files >1GB" + ) + split_3: bool = Field( + default=True, + description="Use --split-3 for 3-way splitting (recommended for mate-pairs)" + ) + check_refseq: bool = Field( + default=True, + description="Whether to check/download reference sequences (disable to save space)" + ) + dry_run: bool = Field( + default=True, + description="SAFETY: If True (default), only estimates size without downloading. Set to False to actually download." + ) + confirm_large: bool = Field( + default=False, + description="SAFETY: Must be True to download files >5GB. Use dry_run=True first to check size." + ) + + +class SRASizeEstimateInput(BaseModel): + """Input for SRA size estimation (dry-run).""" + model_config = ConfigDict(str_strip_whitespace=True) + + sra_ids: List[str] = Field( + ..., + description="List of SRA accession IDs to estimate (e.g., ['SRR1234567', 'SRR1234568'])", + min_length=1 + ) + + +@mcp.tool( + name="sra_download_and_convert", + annotations={ + "title": "Download SRA and Convert to FASTQ", + "readOnlyHint": False, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": True + } +) +async def sra_download_and_convert(params: SRADownloadAndConvertInput) -> str: + '''Download SRA data using prefetch and convert to FASTQ using fastq-dump. + + SAFETY FEATURES (following maintainer recommendations): + - DRY-RUN BY DEFAULT: dry_run=True (default) only estimates size without downloading + - SIZE WARNINGS: Alerts for files >1GB, requires confirmation for >5GB + - EXPLICIT OUTPUT: Required output_dir for files >1GB + - CONFIRMATION: confirm_large=True required for files >5GB + + RECOMMENDED WORKFLOW: + 1. First, run with dry_run=True to see size estimates + 2. If size is acceptable, run with dry_run=False and output_dir="/path" + 3. For large files (>5GB), also set confirm_large=True + + Args: + params: Parameters including sra_id, output_dir, split_3, check_refseq, dry_run, confirm_large + + Returns: + JSON formatted results with download/conversion details OR dry-run estimates + + Examples: + - Step 1: Check size first + sra_id="SRR1234567", dry_run=true + + - Step 2a: Download small file (<1GB) + sra_id="SRR1234567", dry_run=false + + - Step 2b: Download medium file (1-5GB) + sra_id="SRR1234567", dry_run=false, output_dir="/path/to/output" + + - Step 2c: Download large file (>5GB) + sra_id="SRR1234567", dry_run=false, output_dir="/path/to/output", confirm_large=true + ''' + try: + handler = SRAHandler() + output_dir = Path(params.output_dir) if params.output_dir else None + + result = await handler.download_and_convert( + sra_id=params.sra_id.upper(), + output_dir=output_dir, + split_3=params.split_3, + check_refseq=params.check_refseq, + dry_run=params.dry_run, + confirm_large=params.confirm_large + ) + return json.dumps(result, indent=2) + + except Exception as e: + return handle_error(e) + + +@mcp.tool( + name="sra_estimate_size", + annotations={ + "title": "Estimate SRA Download Size (Dry-Run)", + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": True + } +) +async def sra_estimate_size(params: SRASizeEstimateInput) -> str: + '''Estimate the size of SRA files before downloading (dry-run mode). + + This tool queries the EBI ENA database to get file size estimates without + actually downloading any data. Use this before sra_download_and_convert + to check if you have sufficient disk space. + + Args: + params: Parameters including list of sra_ids to estimate + + Returns: + JSON formatted size estimates and safety warnings: + - Individual file sizes (SRA and estimated FASTQ) + - Total download size + - Safety warnings for large files (>1GB, >5GB) + + Examples: + - Estimate single file: sra_ids=["SRR1234567"] + - Estimate multiple: sra_ids=["SRR1234567", "SRR1234568"] + ''' + try: + handler = SRAHandler() + + result = await handler.estimate_sra_size( + sra_ids=[s.upper() for s in params.sra_ids] + ) + return json.dumps(result, indent=2) + + except Exception as e: + return handle_error(e) + + +# ============================================================================ +# Main Entry Point +# ============================================================================ + +def init_config(): + """Initialize configuration file.""" + config_path = Path.home() / ".geo-mcp" / "config.json" + create_config_template(config_path) + print(f"\nConfiguration template created at: {config_path}") + print("\nPlease edit the file and add your email address (required by NCBI).") + print("Optionally, add your NCBI API key for higher rate limits.") + print(f"\nTo use with Claude Desktop, add this to your config:") + print(json.dumps({ + "mcpServers": { + "geo_mcp": { + "command": "python", + "args": [str(Path(__file__).resolve())], + "env": { + "CONFIG_PATH": str(config_path) + } + } + } + }, indent=2)) + + +def main(): + """Main entry point.""" + parser = argparse.ArgumentParser( + description="GEO MCP Server with SRA Support", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + python server.py --init # Initialize configuration + python server.py # Run MCP stdio server + python server.py --http # Run HTTP server on localhost:8000 + python server.py --http --port 8080 # Run HTTP server on custom port + """ + ) + + parser.add_argument( + "--init", + action="store_true", + help="Initialize configuration file" + ) + + parser.add_argument( + "--http", + action="store_true", + help="Run HTTP server instead of MCP stdio" + ) + + parser.add_argument( + "--host", + default="localhost", + help="Host for HTTP server (default: localhost)" + ) + + parser.add_argument( + "--port", + type=int, + default=8000, + help="Port for HTTP server (default: 8000)" + ) + + args = parser.parse_args() + + if args.init: + init_config() + return + + # Validate config before starting + try: + config = get_config() + validate_config(config) + except Exception as e: + print(f"Configuration error: {e}", file=sys.stderr) + print("Run with --init to create a configuration template.", file=sys.stderr) + sys.exit(1) + + if args.http: + # Run HTTP server + print(f"Starting HTTP server on http://{args.host}:{args.port}") + mcp.run(transport="streamable_http", host=args.host, port=args.port) + else: + # Run MCP stdio server + mcp.run() + + +if __name__ == "__main__": + main() From d7585c9870ada93102464cb368563eb39dc35c4f Mon Sep 17 00:00:00 2001 From: KQDtianxiaK <1720573356@qq.com> Date: Tue, 24 Feb 2026 01:58:04 +0800 Subject: [PATCH 02/13] Add new function --- GEO/geo_mcp_server/README.md | 410 +++++++ GEO/geo_mcp_server/SKILL.md | 245 ++++ GEO/geo_mcp_server/config.json | 17 + GEO/geo_mcp_server/geomcp_sra/__init__.py | 10 + GEO/geo_mcp_server/geomcp_sra/config.py | 159 +++ GEO/geo_mcp_server/geomcp_sra/geo_download.py | 463 ++++++++ GEO/geo_mcp_server/geomcp_sra/geo_search.py | 384 ++++++ GEO/geo_mcp_server/geomcp_sra/sra_handler.py | 1040 +++++++++++++++++ GEO/geo_mcp_server/pyproject.toml | 72 ++ GEO/geo_mcp_server/requirements.txt | 4 + GEO/geo_mcp_server/server.py | 1031 ++++++++++++++++ 11 files changed, 3835 insertions(+) create mode 100644 GEO/geo_mcp_server/README.md create mode 100644 GEO/geo_mcp_server/SKILL.md create mode 100644 GEO/geo_mcp_server/config.json create mode 100644 GEO/geo_mcp_server/geomcp_sra/__init__.py create mode 100644 GEO/geo_mcp_server/geomcp_sra/config.py create mode 100644 GEO/geo_mcp_server/geomcp_sra/geo_download.py create mode 100644 GEO/geo_mcp_server/geomcp_sra/geo_search.py create mode 100644 GEO/geo_mcp_server/geomcp_sra/sra_handler.py create mode 100644 GEO/geo_mcp_server/pyproject.toml create mode 100644 GEO/geo_mcp_server/requirements.txt create mode 100644 GEO/geo_mcp_server/server.py diff --git a/GEO/geo_mcp_server/README.md b/GEO/geo_mcp_server/README.md new file mode 100644 index 0000000..925980f --- /dev/null +++ b/GEO/geo_mcp_server/README.md @@ -0,0 +1,410 @@ +# GEO MCP Server with SRA Support + +An enhanced [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server for accessing **GEO (Gene Expression Omnibus)** data through NCBI E-Utils API, with comprehensive support for **SRA (Sequence Read Archive)** raw sequencing data downloads and conversion. + +## Features + +### GEO Data Access +- 🔍 **Search GEO databases**: Series (GSE), Samples (GSM), Platforms (GPL), Datasets (GDS), and Profiles +- 📥 **Download metadata**: SOFT format, Series Matrix, MINiML XML +- 📦 **Download supplementary files**: Processed data tables, raw array data + +### SRA Raw Sequencing Data (NEW) +- 🔗 **Query SRA from GEO**: Map GEO Samples (GSM) to SRA Runs (SRR) +- 📊 **Size estimation**: Dry-run mode to check file sizes before downloading +- 💾 **Download & convert**: Integrated prefetch + fastq-dump workflow +- 🛡️ **Safety constraints**: Size warnings and confirmation requirements +- ✅ **Check sra-toolkit**: Verify installation and get setup instructions + +## Safety Features + +Following [maintainer recommendations](https://github.com/MCPmed/GEOmcp/issues/1), this server implements safety constraints for large file downloads: + +| Constraint | Implementation | +|------------|----------------| +| **Safe by Default** | `dry_run=True` by default - only estimates sizes | +| **Dry-Run Mode** | `sra_estimate_size()` tool for size checking | +| **Explicit Output** | Required `output_dir` for files >1GB | +| **Confirmation** | `confirm_large=True` required for files >5GB | + +## Installation + +### Prerequisites +- Python 3.10 or higher +- (Optional) [SRA Toolkit](https://github.com/ncbi/sra-tools) for downloading raw FASTQ files + +### Install from Source +```bash +# Clone the repository +git clone https://github.com/yourusername/geo-mcp-server.git +cd geo-mcp-server + +# Install dependencies +pip install -e . +``` + +### Configuration + +1. **Initialize configuration:** +```bash +python server.py --init +``` + +2. **Edit the config file** at `~/.geo-mcp/config.json`: +```json +{ + "email": "your_email@example.com", + "api_key": "YOUR_NCBI_API_KEY (optional)", + "download_dir": "~/geo_downloads", + "sra_toolkit_path": "/path/to/sra-toolkit/bin (optional)" +} +``` + +> **Note:** NCBI requires an email address for E-Utils access. An API key is optional but recommended for higher rate limits (10 req/s vs 3 req/s). Get one at [NCBI](https://ncbiinsights.ncbi.nlm.nih.gov/2017/11/02/new-api-keys-for-the-e-utilities/). + +## Usage + +### Running the Server + +**MCP stdio mode** (for Claude Desktop): +```bash +python server.py +``` + +**HTTP mode**: +```bash +python server.py --http --port 8000 +``` + +### Claude Desktop Integration + +Add to your Claude Desktop configuration (`~/.config/claude-desktop/config.json`): + +```json +{ + "mcpServers": { + "geo_mcp": { + "command": "python", + "args": ["/path/to/geo_mcp_server/server.py"], + "env": { + "CONFIG_PATH": "/home/yourusername/.geo-mcp/config.json" + } + } + } +} +``` + +## Available Tools (18 Total) + +### GEO Search Tools + +| Tool | Description | +|------|-------------| +| `geo_search` | Search all GEO record types with natural language (e.g., "Human RNA-seq") | +| `geo_search_series` | Search GEO Series (GSE) - complete experiments | +| `geo_search_samples` | Search GEO Samples (GSM) - individual samples | +| `geo_search_platforms` | Search GEO Platforms (GPL) - array/sequencing platforms | +| `geo_search_datasets` | Search GEO Datasets (GDS) - curated gene expression | +| `geo_search_profiles` | Search GEO Profiles - gene expression profiles | + +### GEO Download Tools + +| Tool | Description | +|------|-------------| +| `geo_download_series` | Download GSE data files (SOFT, matrix, supplementary) | +| `geo_download_sample` | Download GSM supplementary files | +| `geo_get_download_status` | Check if a GEO dataset has been downloaded | +| `geo_list_downloads` | List all downloaded datasets | +| `geo_cleanup_downloads` | Clean up downloaded files | + +### SRA Tools + +| Tool | Description | +|------|-------------| +| `sra_query_from_geo` | Query SRA Run information from a GEO Series | +| `sra_get_metadata` | Get SRA run metadata | +| `sra_estimate_size` | **NEW** Estimate download sizes before downloading (dry-run) | +| `sra_generate_download_commands` | Generate download commands for various methods | +| `sra_check_toolkit` | Check sra-toolkit installation | +| `sra_download` | Download SRA directly via HTTP (small files only) | +| `sra_download_and_convert` | **NEW** Download with prefetch and convert to FASTQ | + +## Example Workflows + +### 1. Search with Natural Language + +``` +User: Find Human RNA-seq datasets + +AI: I'll search for Human RNA-seq datasets in GEO. +[Uses geo_search_series with term="Human RNA-seq"] + +Results: +- GSE319287: "TPD52 Inhibits Aldosterone Synthesis..." (6 samples) +- GSE213095: "Positive feedback between cDC1s and CD8+ T cells..." (46 samples) +- GSE320065: "Transcriptomic and Functional Responses of Human Airway Cells..." (56 samples) +``` + +### 2. Download Processed Data + +``` +User: Download the metadata for GSE12345 + +AI: I'll download the SOFT file and series matrix for GSE12345. +[Uses geo_download_series with geo_id="GSE12345", file_types=["soft", "matrix"]] + +Results: +✓ Downloaded GSE12345_family.soft.gz (1.2 MB) +✓ Downloaded GSE12345_series_matrix.txt.gz (5.6 MB) +Location: ~/geo_downloads/gse/GSE12345/ +``` + +### 3. Find and Download Raw Sequencing Data (Safe Workflow) + +**Step 1: Get SRA accessions** +``` +User: Get the SRA run accessions for GSE272467 + +AI: I'll query the SRA information from this GEO Series. +[Uses sra_query_from_geo with gse_id="GSE272467"] + +Results: +- Total samples: 4 +- Samples with SRA data: 4 +- Total SRA runs: 4 +- All SRA accessions: ["SRR29864801", "SRR29864802", "SRR29864803", "SRR29864804"] +``` + +**Step 2: Estimate size (recommended)** +``` +User: How large are these files? + +AI: Let me estimate the download sizes. +[Uses sra_estimate_size with sra_ids=["SRR29864801"]] + +Results: +- SRA size: ~76 MB +- Estimated FASTQ size: ~190 MB +- Read count: 7,178,576 +- Warning: Small file (< 100 MB) +``` + +**Step 3: Download and convert** +``` +User: Download and convert SRR29864801 to FASTQ + +AI: I'll download the SRA file and convert it to FASTQ format. +[Uses sra_download_and_convert with sra_id="SRR29864801", dry_run=False] + +Results: +✓ Downloaded SRR29864801.sra (76 MB) +✓ Converted to FASTQ + - SRR29864801_1.fastq (660 KB, 5000 reads) + - SRR29864801_2.fastq (660 KB, 5000 reads) +Location: ~/geo_downloads/sra/SRR29864801/ +``` + +### 4. Safety Constraints in Action + +``` +User: Download SRR1234567 + +AI: First, let me estimate the size before downloading. +[Uses sra_estimate_size with sra_ids=["SRR1234567"]] + +Results: +- SRA size: ~2.5 GB +- Estimated FASTQ size: ~6.25 GB +- Warning: LARGE FILE (1-5 GB) + +⚠️ This is a large download that requires explicit output directory. +Please provide output_dir parameter to proceed. + +User: Download to /data/sra + +AI: +[Uses sra_download_and_convert with + sra_id="SRR1234567", + dry_run=False, + output_dir="/data/sra"] + +✓ Download complete! +``` + +### 5. Check SRA Toolkit + +``` +User: Do I have sra-toolkit installed? + +AI: Let me check your sra-toolkit installation. +[Uses sra_check_toolkit] + +Results: +✓ prefetch: available (version 3.3.0) +✓ fastq-dump: available (version 3.3.0) +✓ fasterq-dump: available (version 3.3.0) +✓ vdb-validate: available (version 3.3.0) + +All tools are installed and ready to use! +``` + +## SRA Download Methods + +### Method 1: Using sra_download_and_convert (Recommended) + +Integrated workflow with safety features: +```python +# Step 1: Estimate size (dry-run) +sra_estimate_size(sra_ids=["SRR1234567"]) + +# Step 2: Download and convert based on size +# Small file (<1GB) +sra_download_and_convert(sra_id="SRR1234567", dry_run=False) + +# Medium file (1-5GB) - requires output_dir +sra_download_and_convert( + sra_id="SRR1234567", + dry_run=False, + output_dir="/path/to/output" +) + +# Large file (>5GB) - requires confirmation +sra_download_and_convert( + sra_id="SRR1234567", + dry_run=False, + output_dir="/path/to/output", + confirm_large=True +) +``` + +### Method 2: Using SRA Toolkit Manually + +1. **Install sra-toolkit**: Follow instructions at https://github.com/ncbi/sra-tools + +2. **Download and convert**: +```bash +# Download SRA file +prefetch SRR1234567 + +# Convert to FASTQ (3-way split for paired-end) +fastq-dump --split-3 SRR1234567 +``` + +### Method 3: Direct HTTP Download (Small Files Only) + +For small files or when sra-toolkit is not available: +```bash +# Using wget +wget https://sra-downloadb.be-md.ncbi.nlm.nih.gov/sos2/sra-pub-run-11/SRR1234567/SRR1234567.1 + +# Using curl +curl -o SRR1234567.sra https://sra-downloadb.be-md.ncbi.nlm.nih.gov/sos2/sra-pub-run-11/SRR1234567/SRR1234567.1 +``` + +## Project Structure + +``` +geo_mcp_server/ +├── geomcp_sra/ +│ ├── __init__.py +│ ├── config.py # Configuration management +│ ├── geo_search.py # GEO search functionality (E-Utilities) +│ ├── geo_download.py # GEO data download +│ └── sra_handler.py # SRA query, size estimation, download & convert +├── server.py # Main MCP server with 18 tools +├── config.json # Configuration template +├── pyproject.toml # Project dependencies +├── requirements.txt # Python dependencies +├── README.md # This file +└── SKILL.md # Skill documentation +``` + +## Comparison with Original GEOmcp + +| Feature | GEOmcp (Original) | This Project (geo-mcp-server) | +|---------|-------------------|---------------------------| +| GEO Search | ✓ | ✓ (Enhanced with natural language) | +| SOFT Download | ✓ | ✓ | +| Matrix Download | ✓ | ✓ | +| Supplementary Files | ✓ | ✓ | +| **SRA Query** | ✗ | **✓** | +| **SRR Mapping** | ✗ | **✓** | +| **Size Estimation** | ✗ | **✓ (Dry-run mode)** | +| **Download & Convert** | ✗ | **✓ (Integrated workflow)** | +| **Safety Constraints** | ✗ | **✓ (>1GB, >5GB checks)** | +| Tool Count | 11 | **18** | + +## Configuration Options + +### Full Config File Reference + +```json +{ + "base_url": "https://eutils.ncbi.nlm.nih.gov/entrez/eutils", + "email": "your_email@example.com", + "api_key": "YOUR_NCBI_API_KEY (optional but recommended)", + "download_dir": "~/geo_downloads", + "max_file_size_mb": 5000, + "max_total_downloads_mb": 10000, + "max_concurrent_downloads": 3, + "download_timeout_seconds": 300, + "allowed_download_paths": ["~/geo_downloads", "/tmp/geo_downloads"], + "sra_toolkit_path": "/path/to/sra-toolkit/bin (optional)" +} +``` + +## Troubleshooting + +### Email not configured +``` +Error: Email is required for NCBI E-utilities. +``` +**Solution**: Edit `~/.geo-mcp/config.json` and add your email address. + +### SRA toolkit not found +``` +fasterq-dump not found. Please install sra-toolkit. +``` +**Solution**: Install sra-toolkit from https://github.com/ncbi/sra-tools and set `sra_toolkit_path` in config. + +### Rate limiting +``` +Error: Rate limit exceeded +``` +**Solution**: Add an NCBI API key to your config for higher rate limits (10 req/s vs 3 req/s). + +### Large file safety error +``` +SAFETY CHECK: File size is ~2.5 GB. Large downloads require an explicit output directory. +``` +**Solution**: Provide `output_dir` parameter for files >1GB, or both `output_dir` and `confirm_large=True` for files >5GB. + +## References + +- [GEO Home](https://www.ncbi.nlm.nih.gov/geo/) +- [SRA Home](https://www.ncbi.nlm.nih.gov/sra) +- [NCBI E-Utilities](https://www.ncbi.nlm.nih.gov/books/NBK25501/) +- [SRA Toolkit Documentation](https://github.com/ncbi/sra-tools/wiki) +- [MCP Documentation](https://modelcontextprotocol.io/) + +## License + +MIT License - See LICENSE file for details. + +## Contributing + +Contributions are welcome! Please feel free to submit issues or pull requests. + +### Contributing SRA Support Back to Original GEOmcp + +This project implements the [enhancement request](https://github.com/MCPmed/GEOmcp/issues/1) for SRA raw data support with the following safety features as recommended by maintainers: +- Safe by default (dry-run mode) +- Size estimation before download +- Explicit output directory requirement for large files +- Confirmation for very large files (>5GB) + +## Acknowledgments + +- Original [GEOmcp](https://github.com/MCPmed/GEOmcp) project for the foundation +- NCBI for providing the GEO and SRA databases +- MCP team for the Model Context Protocol diff --git a/GEO/geo_mcp_server/SKILL.md b/GEO/geo_mcp_server/SKILL.md new file mode 100644 index 0000000..f1e08d8 --- /dev/null +++ b/GEO/geo_mcp_server/SKILL.md @@ -0,0 +1,245 @@ +--- +name: geo-mcp-sra +description: MCP server for accessing GEO (Gene Expression Omnibus) data with comprehensive SRA (Sequence Read Archive) raw sequencing support. Enables natural language search, metadata download, size estimation with dry-run mode, and safe FASTQ downloads with prefetch/fastq-dump integration. +--- + +# GEO MCP Server with SRA Support + +This skill provides a Model Context Protocol (MCP) server for accessing NCBI's GEO and SRA databases programmatically with enhanced safety features for large file downloads. + +## Capabilities + +### GEO Data Access +- Search GEO databases (GSE, GSM, GPL, GDS, Profiles) using natural language +- Download SOFT format metadata files +- Download series matrix files +- Download supplementary processed data + +### SRA Raw Sequencing Data (with Safety Features) +- Query SRA Run accessions from GEO Series +- Map GSM samples to SRR run accessions +- **Estimate download sizes** (dry-run mode) +- **Download & convert** with integrated prefetch + fastq-dump workflow +- **Safety constraints**: Size warnings, explicit output directory, confirmation for large files + +## Installation + +```bash +cd /path/to/geo_mcp_server +pip install -e . +``` + +## Configuration + +1. Create config file: +```bash +python3 server.py --init +``` + +2. Edit `~/.geo-mcp/config.json`: +```json +{ + "email": "your_email@example.com", + "api_key": "YOUR_NCBI_API_KEY (optional but recommended)", + "download_dir": "~/geo_downloads", + "sra_toolkit_path": "/path/to/sra-toolkit/bin (optional)" +} +``` + +> **Note:** NCBI requires an email address. API key provides higher rate limits (10 req/s vs 3 req/s). + +## Usage with Claude Desktop + +Add to `~/.config/claude-desktop/config.json`: + +```json +{ + "mcpServers": { + "geo_mcp": { + "command": "python3", + "args": ["/path/to/geo_mcp_server/server.py"], + "env": { + "CONFIG_PATH": "/home/username/.geo-mcp/config.json" + } + } + } +} +``` + +## Available Tools (18 Total) + +### Search Tools (Natural Language Support) + +All search tools support natural language queries like "Human RNA-seq", "mouse brain single cell", "breast cancer transcriptome". + +| Tool | Description | Example Query | +|------|-------------|---------------| +| `geo_search` | Universal GEO search | "cancer RNA-seq" | +| `geo_search_series` | Search GSE records | "Human RNA-seq" | +| `geo_search_samples` | Search GSM records | "HeLa cell line" | +| `geo_search_platforms` | Search GPL records | "Illumina HiSeq" | +| `geo_search_datasets` | Search GDS records | "breast cancer" | +| `geo_search_profiles` | Search GEO Profiles | "p53 expression" | + +### GEO Download Tools + +| Tool | Description | +|------|-------------| +| `geo_download_series` | Download GSE data (SOFT, matrix, supplementary) | +| `geo_download_sample` | Download GSM supplementary files | +| `geo_get_download_status` | Check download status | +| `geo_list_downloads` | List downloaded datasets | +| `geo_cleanup_downloads` | Clean up files | + +### SRA Tools + +| Tool | Description | Safety Features | +|------|-------------|-----------------| +| `sra_query_from_geo` | Get SRA accessions from GEO Series | - | +| `sra_get_metadata` | Get SRA run metadata | - | +| `sra_estimate_size` | **Estimate sizes (dry-run)** | Shows warnings for >1GB, >5GB | +| `sra_generate_download_commands` | Generate download commands | - | +| `sra_check_toolkit` | Check sra-toolkit installation | - | +| `sra_download` | Direct HTTP download | Small files only | +| `sra_download_and_convert` | **Download & convert to FASTQ** | dry_run=True default, size checks | + +## Safety Features + +### Default Safe Behavior + +All SRA downloads default to **dry-run mode** (`dry_run=True`): + +```python +# This only estimates size, does NOT download +sra_download_and_convert(sra_id="SRR1234567") +``` + +### Size-Based Safety Constraints + +| File Size | Required Parameters | +|-----------|---------------------| +| < 1 GB | `dry_run=False` | +| 1-5 GB | `dry_run=False` + `output_dir="/path"` | +| > 5 GB | `dry_run=False` + `output_dir="/path"` + `confirm_large=True` | + +### Safety Check Examples + +**Error for >1GB without output_dir:** +``` +SAFETY CHECK: File size is ~2.5 GB. Large downloads require an explicit +output directory. Please provide output_dir parameter. +Tip: Run with dry_run=True first to see size estimates. +``` + +**Error for >5GB without confirmation:** +``` +SAFETY CHECK: File size is ~6.2 GB (>5GB). This is a VERY LARGE download +that will consume significant disk space and time. +To proceed, set confirm_large=True. +``` + +## Example Workflows + +### Workflow 1: Search with Natural Language + +```python +# Search for Human RNA-seq datasets +geo_search_series(term="Human RNA-seq", retmax=10) + +# Search for specific tissue + disease +geo_search_series(term="mouse brain Alzheimer's", retmax=5) +``` + +### Workflow 2: Safe SRA Download + +**Step 1: Always estimate first** +```python +sra_estimate_size(sra_ids=["SRR1234567"]) +# Returns: SRA size, FASTQ estimate, read count, safety warnings +``` + +**Step 2: Download based on size** + +Small file (<1GB): +```python +sra_download_and_convert( + sra_id="SRR1234567", + dry_run=False, + split_3=True, # Properly handle paired-end + check_refseq=False # Skip refseq to save space +) +``` + +Medium file (1-5GB): +```python +sra_download_and_convert( + sra_id="SRR1234567", + dry_run=False, + output_dir="/data/sra", # Required! + split_3=True +) +``` + +Large file (>5GB): +```python +sra_download_and_convert( + sra_id="SRR1234567", + dry_run=False, + output_dir="/data/sra", # Required + confirm_large=True, # Required + split_3=True +) +``` + +### Workflow 3: Complete Analysis Pipeline + +```python +# 1. Search for datasets +results = geo_search_series(term="GLOR2 m6A", retmax=5) + +# 2. Get SRA accessions for a dataset +sra_info = sra_query_from_geo(gse_id="GSE272467") +# Returns: 4 SRR accessions + +# 3. Estimate sizes +sizes = sra_estimate_size(sra_ids=sra_info["all_sra_accessions"]) +# Shows: ~76 MB each, total ~304 MB + +# 4. Download and convert +for sra_id in sra_info["all_sra_accessions"]: + sra_download_and_convert( + sra_id=sra_id, + dry_run=False, + split_3=True, + check_refseq=False + ) +``` + +## Architecture + +``` +geo_mcp_server/ +├── geomcp_sra/ +│ ├── config.py # Configuration management +│ ├── geo_search.py # NCBI E-Utilities search +│ ├── geo_download.py # FTP/HTTP downloads +│ └── sra_handler.py # SRA query, size estimation, download & convert +├── server.py # MCP server with 18 tools (FastMCP) +├── config.json # Config template +└── pyproject.toml # Project metadata +``` + +## Dependencies + +- `mcp>=1.9.0` - MCP Python SDK +- `httpx>=0.27.0` - Async HTTP client +- `aiofiles>=23.0.0` - Async file operations +- `pydantic>=2.0.0` - Input validation + +## References + +- [GEO Documentation](https://www.ncbi.nlm.nih.gov/geo/info/) +- [SRA Documentation](https://www.ncbi.nlm.nih.gov/sra/docs/) +- [SRA Toolkit](https://github.com/ncbi/sra-tools) +- [MCP Specification](https://modelcontextprotocol.io/) +- [Original GEOmcp Issue #1 - SRA Support](https://github.com/MCPmed/GEOmcp/issues/1) diff --git a/GEO/geo_mcp_server/config.json b/GEO/geo_mcp_server/config.json new file mode 100644 index 0000000..3251bd4 --- /dev/null +++ b/GEO/geo_mcp_server/config.json @@ -0,0 +1,17 @@ +{ + "base_url": "https://eutils.ncbi.nlm.nih.gov/entrez/eutils", + "sra_base_url": "https://trace.ncbi.nlm.nih.gov/Traces/sra", + "email": "your_email@example.com", + "api_key": "", + "retmax": 20, + "download_dir": "./downloads", + "max_file_size_mb": 5000, + "max_total_downloads_mb": 50000, + "max_concurrent_downloads": 3, + "download_timeout_seconds": 300, + "sra_toolkit_path": "", + "allowed_download_paths": [ + "./downloads", + "/tmp/geo_downloads" + ] +} diff --git a/GEO/geo_mcp_server/geomcp_sra/__init__.py b/GEO/geo_mcp_server/geomcp_sra/__init__.py new file mode 100644 index 0000000..1597189 --- /dev/null +++ b/GEO/geo_mcp_server/geomcp_sra/__init__.py @@ -0,0 +1,10 @@ +""" +GEO MCP Server with SRA Support + +An enhanced MCP server for accessing GEO (Gene Expression Omnibus) data +through NCBI E-Utils API, with additional support for SRA (Sequence Read Archive) +raw data download capabilities. +""" + +__version__ = "0.2.0" +__author__ = "GEO MCP Contributors" diff --git a/GEO/geo_mcp_server/geomcp_sra/config.py b/GEO/geo_mcp_server/geomcp_sra/config.py new file mode 100644 index 0000000..498fba8 --- /dev/null +++ b/GEO/geo_mcp_server/geomcp_sra/config.py @@ -0,0 +1,159 @@ +"""Configuration management for GEO MCP Server.""" + +import json +import os +import sys +from pathlib import Path +from typing import Dict, Any, Optional + + +# Default configuration values +DEFAULT_CONFIG = { + "base_url": "https://eutils.ncbi.nlm.nih.gov/entrez/eutils", + "sra_base_url": "https://trace.ncbi.nlm.nih.gov/Traces/sra", + "email": None, + "api_key": None, + "retmax": 20, + "download_dir": "./downloads", + "max_file_size_mb": 5000, + "max_total_downloads_mb": 50000, + "max_concurrent_downloads": 3, + "download_timeout_seconds": 300, + "sra_toolkit_path": None, # Path to sra-toolkit (prefetch/fasterq-dump) + "allowed_download_paths": ["./downloads", "/tmp/geo_downloads"], +} + + +def find_config_file() -> Optional[Path]: + """Find configuration file in standard locations.""" + # Check environment variable first + env_config = os.getenv("CONFIG_PATH") + if env_config: + path = Path(env_config).expanduser() + if path.exists(): + return path + + # Check current directory + current_dir = Path.cwd() / "config.json" + if current_dir.exists(): + return current_dir + + # Check module directory + module_dir = Path(__file__).parent / "config.json" + if module_dir.exists(): + return module_dir + + # Check user home directory + home_config = Path.home() / ".geo-mcp" / "config.json" + if home_config.exists(): + return home_config + + return None + + +def load_config(config_path: Optional[Path] = None) -> Dict[str, Any]: + """Load configuration from file or use defaults. + + Args: + config_path: Optional explicit path to config file + + Returns: + Configuration dictionary + """ + config = DEFAULT_CONFIG.copy() + + # Find config file + if config_path is None: + config_path = find_config_file() + + if config_path and config_path.exists(): + try: + with open(config_path, 'r') as f: + user_config = json.load(f) + config.update(user_config) + except (json.JSONDecodeError, IOError) as e: + print(f"Warning: Error loading config from {config_path}: {e}", file=sys.stderr) + + # Override with environment variables + if os.getenv("NCBI_EMAIL"): + config["email"] = os.getenv("NCBI_EMAIL") + if os.getenv("NCBI_API_KEY"): + config["api_key"] = os.getenv("NCBI_API_KEY") + if os.getenv("GEO_DOWNLOAD_DIR"): + config["download_dir"] = os.getenv("GEO_DOWNLOAD_DIR") + if os.getenv("SRA_TOOLKIT_PATH"): + config["sra_toolkit_path"] = os.getenv("SRA_TOOLKIT_PATH") + + return config + + +def validate_config(config: Dict[str, Any]) -> bool: + """Validate configuration values. + + Args: + config: Configuration dictionary + + Returns: + True if valid, raises ValueError otherwise + """ + if not config.get("email"): + raise ValueError( + "Email is required for NCBI E-utilities. " + "Set it in config.json or via NCBI_EMAIL environment variable." + ) + + # Validate download directory + download_dir = Path(config.get("download_dir", "./downloads")) + try: + download_dir.mkdir(parents=True, exist_ok=True) + except OSError as e: + raise ValueError(f"Cannot create download directory: {e}") + + return True + + +def create_config_template(path: Path) -> None: + """Create a configuration file template. + + Args: + path: Path where to create the config file + """ + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + + template = { + "base_url": "https://eutils.ncbi.nlm.nih.gov/entrez/eutils", + "sra_base_url": "https://trace.ncbi.nlm.nih.gov/Traces/sra", + "email": "your_email@example.com", + "api_key": "YOUR_NCBI_API_KEY (optional)", + "retmax": 20, + "download_dir": "./downloads", + "max_file_size_mb": 5000, + "max_total_downloads_mb": 50000, + "max_concurrent_downloads": 3, + "download_timeout_seconds": 300, + "sra_toolkit_path": "/path/to/sra-toolkit/bin (optional, for fasterq-dump)", + "allowed_download_paths": ["./downloads", "/tmp/geo_downloads"] + } + + with open(path, 'w') as f: + json.dump(template, f, indent=4) + + print(f"Configuration template created at: {path}") + + +# Global config instance (lazy loading) +_config: Optional[Dict[str, Any]] = None + + +def get_config() -> Dict[str, Any]: + """Get the global configuration instance. + + Returns: + Configuration dictionary + """ + global _config + if _config is None: + _config = load_config() + validate_config(_config) + return _config diff --git a/GEO/geo_mcp_server/geomcp_sra/geo_download.py b/GEO/geo_mcp_server/geomcp_sra/geo_download.py new file mode 100644 index 0000000..8db3f8a --- /dev/null +++ b/GEO/geo_mcp_server/geomcp_sra/geo_download.py @@ -0,0 +1,463 @@ +"""GEO data download functionality.""" + +import asyncio +import gzip +import json +import os +import re +import shutil +import tarfile +from pathlib import Path +from typing import Dict, Any, List, Optional +import httpx +import aiofiles + +from .config import get_config + + +class GEODownloadError(Exception): + """Exception raised for GEO download errors.""" + pass + + +class GEODownloadClient: + """Client for downloading GEO data files.""" + + # GEO FTP base URL (using HTTPS) + GEO_FTP_BASE = "https://ftp.ncbi.nlm.nih.gov/geo" + + def __init__(self): + self.config = get_config() + self.download_dir = Path(self.config["download_dir"]).resolve() + self.max_file_bytes = self.config.get("max_file_size_mb", 5000) * 1024 * 1024 + self.max_total_bytes = self.config.get("max_total_downloads_mb", 50000) * 1024 * 1024 + self.timeout = self.config.get("download_timeout_seconds", 300) + self.allowed_paths = self.config.get("allowed_download_paths", ["./downloads"]) + + # Create download directory + self.download_dir.mkdir(parents=True, exist_ok=True) + + def _is_allowed_path(self, path: Path) -> bool: + """Check if path is within allowed download directories.""" + path = path.resolve() + for allowed in self.allowed_paths: + allowed_path = Path(allowed).resolve() + try: + path.relative_to(allowed_path) + return True + except ValueError: + continue + return False + + def _get_dir_size(self, path: Path) -> int: + """Calculate total size of files in directory.""" + total = 0 + for f in path.rglob("*"): + if f.is_file(): + total += f.stat().st_size + return total + + def _get_range_dir(self, accession: str) -> str: + """Get range directory for GEO accession. + + GEO uses range directories to avoid too many files in one directory. + E.g., GSE15701 -> GSE15nnn + """ + match = re.match(r'(GSE|GSM|GPL|GDS)(\d+)', accession, re.IGNORECASE) + if match: + prefix = match.group(1).upper() + number = match.group(2) + return f"{prefix}{number[:-3]}nnn" + return accession + + def _build_geo_urls(self, accession: str) -> Dict[str, str]: + """Build download URLs for a GEO accession. + + Args: + accession: GEO accession ID + + Returns: + Dictionary of file types to URLs + """ + urls = {} + prefix = accession[:3].upper() + range_dir = self._get_range_dir(accession) + base_url = f"{self.GEO_FTP_BASE}" + + if prefix == "GSE": + # Series files + base = f"{base_url}/series/{range_dir}/{accession}" + urls["series_matrix"] = f"{base}/matrix/{accession}_series_matrix.txt.gz" + urls["soft"] = f"{base}/soft/{accession}_family.soft.gz" + urls["miniml"] = f"{base}/miniml/{accession}_family.xml.tgz" + urls["supplementary"] = f"{base}/suppl/{accession}_RAW.tar" + elif prefix == "GDS": + # Dataset files + base = f"{base_url}/datasets/{range_dir}/{accession}" + urls["soft"] = f"{base}/soft/{accession}.soft.gz" + urls["soft_full"] = f"{base}/soft/{accession}_full.soft.gz" + elif prefix == "GPL": + # Platform files + base = f"{base_url}/platforms/{range_dir}/{accession}" + urls["annot"] = f"{base}/annot/{accession}.annot.gz" + urls["soft"] = f"{base}/soft/{accession}_family.soft.gz" + urls["supplementary"] = f"{base}/suppl/" + elif prefix == "GSM": + # Sample files + base = f"{base_url}/samples/{range_dir}/{accession}" + urls["supplementary"] = f"{base}/suppl/" + + return urls + + async def download_file( + self, + url: str, + dest_path: Path, + progress_callback: Optional[callable] = None + ) -> Path: + """Download a single file. + + Args: + url: URL to download + dest_path: Destination path + progress_callback: Optional callback for progress updates + + Returns: + Path to downloaded file + """ + if not self._is_allowed_path(dest_path): + raise GEODownloadError(f"Destination path not allowed: {dest_path}") + + # Check total download limit + current_total = self._get_dir_size(self.download_dir) + if current_total >= self.max_total_bytes: + raise GEODownloadError("Total download limit reached") + + # Check disk space + free_space = shutil.disk_usage(dest_path.parent).free + if free_space < self.max_file_bytes: + raise GEODownloadError("Insufficient disk space") + + async with httpx.AsyncClient() as client: + async with client.stream( + "GET", + url, + timeout=self.timeout, + follow_redirects=True + ) as response: + response.raise_for_status() + + # Check content length + content_length = response.headers.get('content-length') + if content_length: + size = int(content_length) + if size > self.max_file_bytes: + raise GEODownloadError( + f"File size ({size} bytes) exceeds maximum allowed" + ) + + # Download file + downloaded = 0 + async with aiofiles.open(dest_path, 'wb') as f: + async for chunk in response.aiter_bytes(chunk_size=8192): + downloaded += len(chunk) + if downloaded > self.max_file_bytes: + dest_path.unlink() + raise GEODownloadError("File size exceeded limit during download") + await f.write(chunk) + + if progress_callback: + progress_callback(downloaded) + + return dest_path + + async def download_geo_series( + self, + gse_id: str, + file_types: Optional[List[str]] = None, + output_dir: Optional[Path] = None + ) -> Dict[str, Any]: + """Download files for a GEO Series. + + Args: + gse_id: GSE accession ID + file_types: List of file types to download (soft, matrix, miniml, supplementary) + output_dir: Optional custom output directory + + Returns: + Download results dictionary + """ + if not gse_id.upper().startswith("GSE"): + raise GEODownloadError(f"Invalid GSE ID: {gse_id}") + + file_types = file_types or ["soft"] + output_dir = output_dir or self.download_dir / "series" / gse_id + output_dir.mkdir(parents=True, exist_ok=True) + + urls = self._build_geo_urls(gse_id) + downloaded = [] + errors = [] + + for file_type in file_types: + if file_type not in urls: + errors.append(f"Unknown file type: {file_type}") + continue + + url = urls[file_type] + filename = url.split("/")[-1] + dest_path = output_dir / filename + + # Skip if already exists + if dest_path.exists(): + downloaded.append({ + "type": file_type, + "path": str(dest_path), + "status": "already_exists", + "size_mb": round(dest_path.stat().st_size / (1024*1024), 2) + }) + continue + + try: + await self.download_file(url, dest_path) + downloaded.append({ + "type": file_type, + "path": str(dest_path), + "status": "downloaded", + "size_mb": round(dest_path.stat().st_size / (1024*1024), 2) + }) + except httpx.HTTPStatusError as e: + if e.response.status_code == 404: + errors.append(f"{file_type}: File not found on server") + else: + errors.append(f"{file_type}: HTTP {e.response.status_code}") + except Exception as e: + errors.append(f"{file_type}: {str(e)}") + + return { + "accession": gse_id, + "output_dir": str(output_dir), + "downloaded": downloaded, + "errors": errors, + "total_downloaded": len([d for d in downloaded if d["status"] == "downloaded"]) + } + + async def download_geo_sample( + self, + gsm_id: str, + output_dir: Optional[Path] = None + ) -> Dict[str, Any]: + """Download supplementary files for a GEO Sample. + + Args: + gsm_id: GSM accession ID + output_dir: Optional custom output directory + + Returns: + Download results dictionary + """ + if not gsm_id.upper().startswith("GSM"): + raise GEODownloadError(f"Invalid GSM ID: {gsm_id}") + + output_dir = output_dir or self.download_dir / "samples" / gsm_id + output_dir.mkdir(parents=True, exist_ok=True) + + # Get the supplementary file listing + urls = self._build_geo_urls(gsm_id) + suppl_url = urls.get("supplementary") + + if not suppl_url: + return { + "accession": gsm_id, + "output_dir": str(output_dir), + "downloaded": [], + "errors": ["No supplementary files URL available"] + } + + # For samples, we need to list and download files + # This requires parsing the directory listing + downloaded = [] + errors = [] + + try: + # Try to get directory listing + async with httpx.AsyncClient() as client: + response = await client.get(suppl_url, timeout=30.0) + if response.status_code == 200: + # Parse HTML directory listing + files = self._parse_directory_listing(response.text) + + for filename in files: + if filename.endswith('/'): + continue + + file_url = suppl_url + filename + dest_path = output_dir / filename + + try: + await self.download_file(file_url, dest_path) + downloaded.append({ + "filename": filename, + "path": str(dest_path), + "size_mb": round(dest_path.stat().st_size / (1024*1024), 2) + }) + except Exception as e: + errors.append(f"{filename}: {str(e)}") + else: + errors.append(f"Could not list supplementary files: HTTP {response.status_code}") + except Exception as e: + errors.append(f"Error accessing supplementary files: {str(e)}") + + return { + "accession": gsm_id, + "output_dir": str(output_dir), + "downloaded": downloaded, + "errors": errors, + "total_downloaded": len(downloaded) + } + + def _parse_directory_listing(self, html: str) -> List[str]: + """Parse HTML directory listing for file names.""" + # Simple regex to extract href values + files = [] + for match in re.finditer(r'href=["\']([^"\']+)["\']', html): + filename = match.group(1) + if filename not in ['../', './']: + files.append(filename) + return files + + def get_download_status(self, accession: str, db_type: str = "gse") -> Dict[str, Any]: + """Check download status of a GEO dataset. + + Args: + accession: GEO accession ID + db_type: Database type (gse, gsm, gpl, gds) + + Returns: + Status dictionary + """ + dataset_path = self.download_dir / db_type / accession + + if not dataset_path.exists(): + return { + "accession": accession, + "db_type": db_type, + "downloaded": False, + "path": str(dataset_path) + } + + files = [] + total_size = 0 + for f in dataset_path.rglob("*"): + if f.is_file(): + size = f.stat().st_size + files.append({ + "name": f.name, + "path": str(f), + "size_mb": round(size / (1024*1024), 2) + }) + total_size += size + + return { + "accession": accession, + "db_type": db_type, + "downloaded": True, + "path": str(dataset_path), + "files": files, + "total_size_mb": round(total_size / (1024*1024), 2), + "file_count": len(files) + } + + def list_downloaded_datasets(self, db_type: Optional[str] = None) -> Dict[str, Any]: + """List all downloaded datasets. + + Args: + db_type: Optional filter by database type + + Returns: + List of downloaded datasets + """ + datasets = [] + + if db_type: + db_path = self.download_dir / db_type + if db_path.exists(): + for dataset_dir in db_path.iterdir(): + if dataset_dir.is_dir(): + total_size = sum( + f.stat().st_size for f in dataset_dir.rglob("*") if f.is_file() + ) + datasets.append({ + "accession": dataset_dir.name, + "db_type": db_type, + "path": str(dataset_dir), + "size_mb": round(total_size / (1024*1024), 2) + }) + else: + for db_dir in self.download_dir.iterdir(): + if db_dir.is_dir(): + for dataset_dir in db_dir.iterdir(): + if dataset_dir.is_dir(): + total_size = sum( + f.stat().st_size for f in dataset_dir.rglob("*") if f.is_file() + ) + datasets.append({ + "accession": dataset_dir.name, + "db_type": db_dir.name, + "path": str(dataset_dir), + "size_mb": round(total_size / (1024*1024), 2) + }) + + return { + "datasets": datasets, + "count": len(datasets) + } + + def cleanup_downloads( + self, + accession: Optional[str] = None, + db_type: Optional[str] = None + ) -> Dict[str, Any]: + """Clean up downloaded files. + + Args: + accession: Optional specific accession to remove + db_type: Optional database type filter + + Returns: + Cleanup results + """ + removed = [] + + if accession and db_type: + # Remove specific dataset + dataset_path = self.download_dir / db_type / accession + if dataset_path.exists(): + shutil.rmtree(dataset_path) + removed.append(str(dataset_path)) + elif db_type: + # Remove all datasets of a specific type + db_path = self.download_dir / db_type + if db_path.exists(): + for dataset_dir in db_path.iterdir(): + if dataset_dir.is_dir(): + shutil.rmtree(dataset_dir) + removed.append(str(dataset_dir)) + elif accession: + # Remove all matching accessions across types + for db_dir in self.download_dir.iterdir(): + if db_dir.is_dir(): + dataset_path = db_dir / accession + if dataset_path.exists(): + shutil.rmtree(dataset_path) + removed.append(str(dataset_path)) + else: + # Remove all downloads + for db_dir in self.download_dir.iterdir(): + if db_dir.is_dir(): + shutil.rmtree(db_dir) + removed.append(str(db_dir)) + + return { + "removed": removed, + "count": len(removed) + } diff --git a/GEO/geo_mcp_server/geomcp_sra/geo_search.py b/GEO/geo_mcp_server/geomcp_sra/geo_search.py new file mode 100644 index 0000000..a9be1aa --- /dev/null +++ b/GEO/geo_mcp_server/geomcp_sra/geo_search.py @@ -0,0 +1,384 @@ +"""GEO search functionality using NCBI E-Utilities.""" + +import json +import time +from typing import Dict, Any, List, Optional +import httpx + +from .config import get_config + + +class GEOSearchError(Exception): + """Exception raised for GEO search errors.""" + pass + + +class GEOSearchClient: + """Client for searching GEO database using NCBI E-Utilities.""" + + def __init__(self): + self.config = get_config() + self.base_url = self.config["base_url"] + self.email = self.config["email"] + self.api_key = self.config.get("api_key") + self.retmax = self.config.get("retmax", 20) + self._last_request_time = 0 + + def _rate_limit(self): + """Apply rate limiting to be respectful to NCBI servers.""" + # Without API key: 3 requests per second + # With API key: 10 requests per second + min_interval = 0.1 if self.api_key else 0.34 + + elapsed = time.time() - self._last_request_time + if elapsed < min_interval: + time.sleep(min_interval - elapsed) + self._last_request_time = time.time() + + def _build_params(self, extra_params: Dict[str, Any]) -> Dict[str, str]: + """Build request parameters with authentication.""" + params = {"email": self.email, **extra_params} + if self.api_key: + params["api_key"] = self.api_key + return params + + async def _esearch(self, db: str, term: str, retmax: int = 20) -> Dict[str, Any]: + """Perform ESearch query. + + Args: + db: Database to search (gds, geoprofiles, etc.) + term: Search term + retmax: Maximum results to return + + Returns: + ESearch response dictionary + """ + self._rate_limit() + + params = self._build_params({ + "db": db, + "term": term, + "retmax": retmax, + "retmode": "json", + }) + + async with httpx.AsyncClient() as client: + response = await client.get( + f"{self.base_url}/esearch.fcgi", + params=params, + timeout=30.0 + ) + response.raise_for_status() + return response.json() + + async def _esummary(self, db: str, ids: List[str]) -> Dict[str, Any]: + """Fetch summaries for a list of IDs. + + Args: + db: Database + ids: List of IDs to summarize + + Returns: + ESummary response dictionary + """ + if not ids: + return {"result": {}} + + self._rate_limit() + + params = self._build_params({ + "db": db, + "id": ",".join(map(str, ids)), + "retmode": "json", + }) + + async with httpx.AsyncClient() as client: + response = await client.get( + f"{self.base_url}/esummary.fcgi", + params=params, + timeout=30.0 + ) + response.raise_for_status() + return response.json() + + async def _efetch(self, db: str, id: str, retmode: str = "xml") -> str: + """Fetch full records. + + Args: + db: Database + id: Record ID + retmode: Return mode (xml, json, etc.) + + Returns: + EFetch response text + """ + self._rate_limit() + + params = self._build_params({ + "db": db, + "id": id, + "retmode": retmode, + }) + + async with httpx.AsyncClient() as client: + response = await client.get( + f"{self.base_url}/efetch.fcgi", + params=params, + timeout=30.0 + ) + response.raise_for_status() + return response.text + + async def search_geo( + self, + term: str, + retmax: Optional[int] = None, + record_types: Optional[List[str]] = None + ) -> Dict[str, Any]: + """Search GEO for all types of records. + + Args: + term: Search term (e.g., 'breast cancer', 'GSE12345', 'RNA-seq') + retmax: Maximum number of results to return + record_types: Optional filter for specific types ["GSE", "GSM", "GPL", "GDS"] + + Returns: + Dictionary with categorized results by record type + """ + try: + retmax = retmax or self.retmax + + # Search the gds database + data = await self._esearch('gds', term, retmax) + ids = data.get('esearchresult', {}).get('idlist', []) + + if not ids: + return { + "total_count": 0, + "results": [], + "series": [], + "samples": [], + "platforms": [], + "datasets": [] + } + + # Get detailed summaries + summaries = await self._esummary('gds', ids) + results = summaries.get('result', {}) + + # Categorize results by accession type + categorized = { + "total_count": len(ids), + "results": [], + "series": [], # GSE records + "samples": [], # GSM records + "platforms": [], # GPL records + "datasets": [] # GDS records + } + + for uid in ids: + if uid in results: + record = results[uid] + accession = record.get('accession', '') + + # Add to main results + categorized["results"].append(record) + + # Categorize by type + if accession.startswith('GSE'): + categorized["series"].append(record) + elif accession.startswith('GSM'): + categorized["samples"].append(record) + elif accession.startswith('GPL'): + categorized["platforms"].append(record) + elif accession.startswith('GDS'): + categorized["datasets"].append(record) + + # Filter by record types if specified + if record_types: + record_types = [rt.upper() for rt in record_types] + filtered_results = [] + + if "GSE" in record_types: + filtered_results.extend(categorized["series"]) + if "GSM" in record_types: + filtered_results.extend(categorized["samples"]) + if "GPL" in record_types: + filtered_results.extend(categorized["platforms"]) + if "GDS" in record_types: + filtered_results.extend(categorized["datasets"]) + + categorized["results"] = filtered_results + categorized["total_count"] = len(filtered_results) + + return categorized + + except httpx.HTTPStatusError as e: + raise GEOSearchError(f"HTTP error: {e.response.status_code} - {e.response.text}") + except Exception as e: + raise GEOSearchError(f"Search error: {str(e)}") + + async def search_geo_profiles(self, term: str, retmax: Optional[int] = None) -> Dict[str, Any]: + """Search GEO Profiles database. + + Args: + term: Search term + retmax: Maximum results to return + + Returns: + GEO Profiles search results + """ + try: + retmax = retmax or self.retmax + data = await self._esearch('geoprofiles', term, retmax) + ids = data.get('esearchresult', {}).get('idlist', []) + + if not ids: + return {"esummaryresult": ["Empty id list - nothing to do"]} + + summary = await self._esummary('geoprofiles', ids) + return summary + + except Exception as e: + raise GEOSearchError(f"GEO Profiles search error: {str(e)}") + + async def search_geo_datasets(self, term: str, retmax: Optional[int] = None) -> Dict[str, Any]: + """Search GEO Datasets (GDS) specifically. + + Args: + term: Search term + retmax: Maximum results to return + + Returns: + GDS search results + """ + result = await self.search_geo(term, retmax, record_types=["GDS"]) + + if result["datasets"]: + formatted_result = { + "header": {"type": "esummary", "version": "0.3"}, + "result": { + "uids": [r.get("uid") for r in result["datasets"]] + } + } + for record in result["datasets"]: + uid = record.get("uid") + if uid: + formatted_result["result"][uid] = record + + return formatted_result + else: + return {"esummaryresult": ["Empty id list - nothing to do"]} + + async def search_geo_series(self, term: str, retmax: Optional[int] = None) -> Dict[str, Any]: + """Search GEO Series (GSE) specifically. + + Args: + term: Search term + retmax: Maximum results to return + + Returns: + GSE search results + """ + result = await self.search_geo(term, retmax, record_types=["GSE"]) + + if result["series"]: + formatted_result = { + "header": {"type": "esummary", "version": "0.3"}, + "result": { + "uids": [r.get("uid") for r in result["series"]] + } + } + for record in result["series"]: + uid = record.get("uid") + if uid: + formatted_result["result"][uid] = record + + return formatted_result + else: + return {"esummaryresult": ["Empty id list - nothing to do"]} + + async def search_geo_samples(self, term: str, retmax: Optional[int] = None) -> Dict[str, Any]: + """Search GEO Samples (GSM) specifically. + + Args: + term: Search term + retmax: Maximum results to return + + Returns: + GSM search results + """ + result = await self.search_geo(term, retmax, record_types=["GSM"]) + + if result["samples"]: + formatted_result = { + "header": {"type": "esummary", "version": "0.3"}, + "result": { + "uids": [r.get("uid") for r in result["samples"]] + } + } + for record in result["samples"]: + uid = record.get("uid") + if uid: + formatted_result["result"][uid] = record + + return formatted_result + else: + return {"esummaryresult": ["Empty id list - nothing to do"]} + + async def search_geo_platforms(self, term: str, retmax: Optional[int] = None) -> Dict[str, Any]: + """Search GEO Platforms (GPL) specifically. + + Args: + term: Search term + retmax: Maximum results to return + + Returns: + GPL search results + """ + result = await self.search_geo(term, retmax, record_types=["GPL"]) + + if result["platforms"]: + formatted_result = { + "header": {"type": "esummary", "version": "0.3"}, + "result": { + "uids": [r.get("uid") for r in result["platforms"]] + } + } + for record in result["platforms"]: + uid = record.get("uid") + if uid: + formatted_result["result"][uid] = record + + return formatted_result + else: + return {"esummaryresult": ["Empty id list - nothing to do"]} + + async def get_series_info(self, gse_id: str) -> Dict[str, Any]: + """Get detailed information about a GEO Series. + + Args: + gse_id: GSE accession ID (e.g., 'GSE12345') + + Returns: + Series information dictionary + """ + try: + # Search for the specific GSE + data = await self._esearch('gds', f"{gse_id}[ACCN]", 1) + ids = data.get('esearchresult', {}).get('idlist', []) + + if not ids: + raise GEOSearchError(f"Series {gse_id} not found") + + # Get summary + summaries = await self._esummary('gds', ids) + result = summaries.get('result', {}) + + if ids[0] in result: + return result[ids[0]] + else: + raise GEOSearchError(f"No summary available for {gse_id}") + + except Exception as e: + raise GEOSearchError(f"Error getting series info: {str(e)}") diff --git a/GEO/geo_mcp_server/geomcp_sra/sra_handler.py b/GEO/geo_mcp_server/geomcp_sra/sra_handler.py new file mode 100644 index 0000000..d059f94 --- /dev/null +++ b/GEO/geo_mcp_server/geomcp_sra/sra_handler.py @@ -0,0 +1,1040 @@ +"""SRA (Sequence Read Archive) handling functionality. + +This module provides capabilities to: +1. Query SRA Run information from GEO Series (map GSM to SRR accessions) +2. Get SRA accession lists for datasets +3. Generate download commands for SRA data +4. Optionally download FASTQ files using sra-toolkit +""" + +import asyncio +import json +import os +import re +import subprocess +import xml.etree.ElementTree as ET +from pathlib import Path +from typing import Dict, Any, List, Optional, Tuple +import httpx + +from .config import get_config + + +class SRAError(Exception): + """Exception raised for SRA-related errors.""" + pass + + +class SRAHandler: + """Handler for SRA data queries and downloads.""" + + # SRA endpoints + SRA_TRACE_URL = "https://trace.ncbi.nlm.nih.gov/Traces/sra" + SRA_EUTILS_URL = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils" + SRA_FETCH_URL = "https://sra-downloadb.be-md.ncbi.nlm.nih.gov/sos2/sra-pub-run-11" + + def __init__(self): + self.config = get_config() + self.email = self.config["email"] + self.api_key = self.config.get("api_key") + self.sra_toolkit_path = self.config.get("sra_toolkit_path") + self.download_dir = Path(self.config["download_dir"]).resolve() / "sra" + self.download_dir.mkdir(parents=True, exist_ok=True) + + def _build_params(self, extra_params: Dict[str, Any]) -> Dict[str, str]: + """Build request parameters with authentication.""" + params = {"email": self.email, **extra_params} + if self.api_key: + params["api_key"] = self.api_key + return params + + async def _fetch_geo_soft(self, gse_id: str) -> str: + """Fetch GEO Series SOFT file to extract SRA information. + + Args: + gse_id: GSE accession ID + + Returns: + SOFT file content as string + """ + range_dir = f"{gse_id[:-3]}nnn" + url = f"https://ftp.ncbi.nlm.nih.gov/geo/series/{range_dir}/{gse_id}/soft/{gse_id}_family.soft.gz" + + async with httpx.AsyncClient() as client: + response = await client.get(url, timeout=60.0) + + if response.status_code == 404: + # Try without _family suffix + url = f"https://ftp.ncbi.nlm.nih.gov/geo/series/{range_dir}/{gse_id}/soft/{gse_id}.soft.gz" + response = await client.get(url, timeout=60.0) + + response.raise_for_status() + + # Decompress gzip content + import gzip + content = gzip.decompress(response.content) + return content.decode('utf-8', errors='replace') + + def _parse_sra_accessions_from_soft(self, soft_content: str) -> Dict[str, List[str]]: + """Parse SRA accession numbers from SOFT file content. + + Args: + soft_content: SOFT file content + + Returns: + Dictionary mapping GSM IDs to lists of SRA accessions (SRX experiments) + """ + gsm_to_sra = {} + current_gsm = None + + for line in soft_content.split('\n'): + line = line.strip() + + # Track current sample + if line.startswith('^SAMPLE = '): + current_gsm = line.split('=')[1].strip() + gsm_to_sra[current_gsm] = [] + + # Look for SRA relation links (contain SRX experiment IDs) + elif current_gsm and line.startswith('!Sample_relation = SRA:'): + # Extract SRX ID from URL like "https://www.ncbi.nlm.nih.gov/sra?term=SRX25362135" + srx_pattern = r'([SED]RX\d+)' + matches = re.findall(srx_pattern, line) + gsm_to_sra[current_gsm].extend(matches) + + # Remove empty entries + gsm_to_sra = {k: v for k, v in gsm_to_sra.items() if v} + + return gsm_to_sra + + async def _get_srr_from_srx(self, srx_id: str) -> List[str]: + """Get SRA Run (SRR) IDs from Experiment (SRX) ID. + + Args: + srx_id: SRX accession (e.g., 'SRX25362135') + + Returns: + List of SRR accessions + """ + srr_list = [] + + try: + async with httpx.AsyncClient() as client: + # Use E-Utilities to search for runs linked to this experiment + search_params = self._build_params({ + "db": "sra", + "term": f"{srx_id}[Experiment]", + "retmode": "json", + "retmax": 100 + }) + + response = await client.get( + f"{self.SRA_EUTILS_URL}/esearch.fcgi", + params=search_params, + timeout=30.0 + ) + response.raise_for_status() + + search_data = response.json() + sra_ids = search_data.get('esearchresult', {}).get('idlist', []) + + if not sra_ids: + return srr_list + + # Get summary for each SRA entry to find SRR IDs + for sra_id in sra_ids: + summary_params = self._build_params({ + "db": "sra", + "id": sra_id, + "retmode": "json" + }) + + response = await client.get( + f"{self.SRA_EUTILS_URL}/esummary.fcgi", + params=summary_params, + timeout=30.0 + ) + response.raise_for_status() + + summary_data = response.json() + + # Parse the result to find SRR IDs + result = summary_data.get('result', {}) + for uid in result.get('uids', []): + item = result.get(uid, {}) + # Look for run accessions in the summary + runs = item.get('runs', '') + if runs: + # Parse SRR from runs field + srr_matches = re.findall(r'([SED]RR\d+)', runs) + srr_list.extend(srr_matches) + + # Also check other fields + for key in ['accession', 'runlist', 'experiment']: + val = item.get(key, '') + if val: + srr_matches = re.findall(r'([SED]RR\d+)', str(val)) + srr_list.extend(srr_matches) + + except Exception as e: + # Don't fail if we can't get SRR info + pass + + return list(set(srr_list)) # Remove duplicates + + async def query_sra_from_geo(self, gse_id: str) -> Dict[str, Any]: + """Query SRA Run information from a GEO Series. + + This method extracts the mapping between GSM samples and SRR runs + from the GEO Series SOFT file. + + Args: + gse_id: GSE accession ID (e.g., 'GSE12345') + + Returns: + Dictionary with SRA run information + + Example: + { + "gse_id": "GSE12345", + "total_samples": 10, + "samples_with_sra": 8, + "sra_runs": [ + { + "gsm_id": "GSM123456", + "sra_accessions": ["SRR1234567", "SRR1234568"] + } + ] + } + """ + if not gse_id.upper().startswith("GSE"): + raise SRAError(f"Invalid GSE ID: {gse_id}") + + try: + # Fetch and parse SOFT file + soft_content = await self._fetch_geo_soft(gse_id) + gsm_to_sra = self._parse_sra_accessions_from_soft(soft_content) + + # Convert SRX (Experiment) IDs to SRR (Run) IDs + gsm_to_srr = {} + for gsm_id, srx_list in gsm_to_sra.items(): + srr_list = [] + for srx_id in srx_list: + srrs = await self._get_srr_from_srx(srx_id) + srr_list.extend(srrs) + if srr_list: + gsm_to_srr[gsm_id] = list(set(srr_list)) # Remove duplicates + + # Also try to get from E-Utilities link + additional_sra = await self._get_sra_from_eutils(gse_id) + + # Merge results + for gsm_id, sra_list in additional_sra.items(): + if gsm_id in gsm_to_srr: + # Merge without duplicates + existing = set(gsm_to_srr[gsm_id]) + for sra in sra_list: + if sra not in existing: + gsm_to_srr[gsm_id].append(sra) + else: + gsm_to_srr[gsm_id] = sra_list + + # Build response + samples_with_sra = [ + { + "gsm_id": gsm_id, + "sra_accessions": sra_list + } + for gsm_id, sra_list in gsm_to_srr.items() + ] + + # Get all unique SRR accessions + all_srr = set() + for sra_list in gsm_to_srr.values(): + all_srr.update(sra_list) + + return { + "gse_id": gse_id.upper(), + "total_samples": len(samples_with_sra), + "samples_with_sra": len(samples_with_sra), + "total_sra_runs": len(all_srr), + "all_sra_accessions": sorted(list(all_srr)), + "samples": samples_with_sra + } + + except httpx.HTTPStatusError as e: + if e.response.status_code == 404: + raise SRAError(f"GEO Series {gse_id} not found or SOFT file unavailable") + raise SRAError(f"HTTP error querying SRA info: {e.response.status_code}") + except Exception as e: + raise SRAError(f"Error querying SRA information: {str(e)}") + + async def _get_sra_from_eutils(self, gse_id: str) -> Dict[str, List[str]]: + """Get SRA accessions using E-Utilities. + + Args: + gse_id: GSE accession ID + + Returns: + Dictionary mapping GSM IDs to SRA accessions + """ + result = {} + + try: + # Search for samples in this series + async with httpx.AsyncClient() as client: + # First, get series UID + search_params = self._build_params({ + "db": "gds", + "term": f"{gse_id}[ACCN]", + "retmode": "json" + }) + + response = await client.get( + f"{self.SRA_EUTILS_URL}/esearch.fcgi", + params=search_params, + timeout=30.0 + ) + response.raise_for_status() + + search_data = response.json() + gds_ids = search_data.get('esearchresult', {}).get('idlist', []) + + if not gds_ids: + return result + + # Fetch summary to get sample information + summary_params = self._build_params({ + "db": "gds", + "id": gds_ids[0], + "retmode": "json" + }) + + response = await client.get( + f"{self.SRA_EUTILS_URL}/esummary.fcgi", + params=summary_params, + timeout=30.0 + ) + response.raise_for_status() + + # Note: E-utilities doesn't always have SRA links + # This is a fallback method + + except Exception: + # Don't fail if eutils doesn't return data + pass + + return result + + async def estimate_sra_size(self, sra_ids: List[str]) -> Dict[str, Any]: + """Estimate the size of SRA files before downloading. + + This is a "dry-run" mode that queries the SRA database to estimate + download sizes without actually downloading any data. + + Args: + sra_ids: List of SRA accession IDs + + Returns: + Dictionary with size estimates and warnings + """ + if not sra_ids: + raise SRAError("No SRA IDs provided") + + results = [] + total_size_bytes = 0 + + try: + async with httpx.AsyncClient() as client: + for sra_id in sra_ids: + sra_id = sra_id.upper() + + # Use EBI ENA API to get file size (more reliable than NCBI for size info) + ena_url = f"https://www.ebi.ac.uk/ena/portal/api/filereport?accession={sra_id}&result=read_run&fields=run_accession,fastq_bytes,sra_bytes,read_count,base_count" + + try: + response = await client.get(ena_url, timeout=30.0) + response.raise_for_status() + + lines = response.text.strip().split('\n') + if len(lines) >= 2: + # Parse TSV response + headers = lines[0].split('\t') + values = lines[1].split('\t') + + data = dict(zip(headers, values)) + + # Get SRA size if available, otherwise estimate from FASTQ + sra_bytes = data.get('sra_bytes', '') + fastq_bytes = data.get('fastq_bytes', '') + read_count = data.get('read_count', '0') + base_count = data.get('base_count', '0') + + # Calculate sizes + sra_size_mb = 0 + if sra_bytes and sra_bytes.isdigit(): + sra_size_mb = int(sra_bytes) / (1024 * 1024) + elif fastq_bytes: + # Estimate SRA size as ~40% of FASTQ (compressed) + fastq_sizes = fastq_bytes.split(';') + total_fastq = sum(int(x) for x in fastq_sizes if x.isdigit()) + sra_size_mb = (total_fastq * 0.4) / (1024 * 1024) + + # Estimate FASTQ size (SRA * 2.5 for decompressed) + estimated_fastq_mb = sra_size_mb * 2.5 + + total_size_bytes += sra_size_mb * 1024 * 1024 + + results.append({ + "sra_id": sra_id, + "sra_size_mb": round(sra_size_mb, 2), + "estimated_fastq_size_mb": round(estimated_fastq_mb, 2), + "read_count": int(read_count) if read_count.isdigit() else 0, + "base_count": int(base_count) if base_count.isdigit() else 0, + "warning": self._get_size_warning(sra_size_mb) + }) + else: + results.append({ + "sra_id": sra_id, + "sra_size_mb": "unknown", + "estimated_fastq_size_mb": "unknown", + "warning": "Could not retrieve size information from ENA" + }) + + except Exception as e: + results.append({ + "sra_id": sra_id, + "sra_size_mb": "unknown", + "estimated_fastq_size_mb": "unknown", + "warning": f"Error querying ENA: {str(e)}" + }) + + except Exception as e: + raise SRAError(f"Error estimating sizes: {str(e)}") + + # Calculate total + total_mb = total_size_bytes / (1024 * 1024) + total_gb = total_mb / 1024 + + return { + "dry_run": True, + "sra_count": len(sra_ids), + "individual_estimates": results, + "total_sra_size_mb": round(total_mb, 2), + "total_sra_size_gb": round(total_gb, 2), + "estimated_total_fastq_size_gb": round(total_gb * 2.5, 2), + "safety_warnings": self._get_safety_warnings(total_mb) + } + + def _get_size_warning(self, size_mb: float) -> str: + """Get warning message based on file size.""" + if size_mb == 0: + return "Size unknown" + elif size_mb < 100: + return "Small file (< 100 MB)" + elif size_mb < 1024: + return "Medium file (100 MB - 1 GB)" + elif size_mb < 5120: # 5 GB + return "⚠️ LARGE FILE (1-5 GB) - Ensure sufficient disk space" + else: + return "🚨 VERY LARGE FILE (> 5 GB) - Requires explicit confirmation" + + def _get_safety_warnings(self, total_mb: float) -> List[str]: + """Get safety warnings for the total download size.""" + warnings = [] + + if total_mb > 1024: # > 1 GB + warnings.append("Total download exceeds 1 GB. Ensure you have sufficient disk space.") + if total_mb > 5120: # > 5 GB + warnings.append("Total download exceeds 5 GB. This will take significant time and space.") + if total_mb > 10240: # > 10 GB + warnings.append("🚨 WARNING: Total download exceeds 10 GB! Consider downloading individual files.") + + return warnings + + async def get_sra_metadata(self, sra_id: str) -> Dict[str, Any]: + """Get metadata for an SRA run. + + Args: + sra_id: SRA accession (e.g., 'SRR1234567') + + Returns: + SRA run metadata + """ + if not re.match(r'^[SED]RR\d+$', sra_id, re.IGNORECASE): + raise SRAError(f"Invalid SRA ID: {sra_id}") + + sra_id = sra_id.upper() + + try: + # Use E-Utilities to get SRA metadata + async with httpx.AsyncClient() as client: + # Search in SRA database + search_params = self._build_params({ + "db": "sra", + "term": sra_id, + "retmode": "json" + }) + + response = await client.get( + f"{self.SRA_EUTILS_URL}/esearch.fcgi", + params=search_params, + timeout=30.0 + ) + response.raise_for_status() + + search_data = response.json() + sra_ids = search_data.get('esearchresult', {}).get('idlist', []) + + if not sra_ids: + return { + "sra_id": sra_id, + "found": False, + "error": "SRA accession not found in database" + } + + # Get summary + summary_params = self._build_params({ + "db": "sra", + "id": sra_ids[0], + "retmode": "json" + }) + + response = await client.get( + f"{self.SRA_EUTILS_URL}/esummary.fcgi", + params=summary_params, + timeout=30.0 + ) + response.raise_for_status() + + summary_data = response.json() + + return { + "sra_id": sra_id, + "found": True, + "metadata": summary_data + } + + except Exception as e: + raise SRAError(f"Error getting SRA metadata: {str(e)}") + + def get_sra_download_url(self, sra_id: str) -> str: + """Get direct download URL for an SRA run. + + Args: + sra_id: SRA accession (e.g., 'SRR1234567') + + Returns: + Direct download URL + """ + sra_id = sra_id.upper() + + # Construct SRA download URL + # Format: https://sra-downloadb.be-md.ncbi.nlm.nih.gov/sos2/sra-pub-run-11/{SRRxxxxxxx}/{SRRxxxxxxx}.1 + base_url = "https://sra-downloadb.be-md.ncbi.nlm.nih.gov/sos2/sra-pub-run-11" + return f"{base_url}/{sra_id}/{sra_id}.1" + + def generate_download_commands( + self, + sra_ids: List[str], + method: str = "prefetch", + output_dir: Optional[str] = None + ) -> Dict[str, Any]: + """Generate commands to download SRA data. + + Args: + sra_ids: List of SRA accessions + method: Download method ('prefetch', 'wget', 'curl', or 'aspera') + output_dir: Optional output directory + + Returns: + Dictionary with download commands and instructions + """ + if not sra_ids: + raise SRAError("No SRA IDs provided") + + output_dir = output_dir or str(self.download_dir) + commands = [] + + if method == "prefetch": + # sra-toolkit prefetch command + toolkit_path = self.sra_toolkit_path or "" + prefetch = f"{toolkit_path}/prefetch" if toolkit_path else "prefetch" + + for sra_id in sra_ids: + cmd = f"{prefetch} -O {output_dir} {sra_id}" + commands.append({ + "sra_id": sra_id, + "command": cmd, + "description": f"Download {sra_id} using sra-toolkit prefetch" + }) + + elif method == "fasterq-dump": + # Directly download and convert to FASTQ + toolkit_path = self.sra_toolkit_path or "" + fasterq_dump = f"{toolkit_path}/fasterq-dump" if toolkit_path else "fasterq-dump" + + for sra_id in sra_ids: + cmd = f"{fasterq_dump} --outdir {output_dir} {sra_id}" + commands.append({ + "sra_id": sra_id, + "command": cmd, + "description": f"Download and convert {sra_id} to FASTQ" + }) + + elif method == "wget": + # Direct HTTP download + for sra_id in sra_ids: + url = self.get_sra_download_url(sra_id) + cmd = f"wget -P {output_dir} {url}" + commands.append({ + "sra_id": sra_id, + "command": cmd, + "description": f"Download {sra_id} using wget" + }) + + elif method == "curl": + # Direct HTTP download with curl + for sra_id in sra_ids: + url = self.get_sra_download_url(sra_id) + output_file = f"{output_dir}/{sra_id}.sra" + cmd = f"curl -o {output_file} {url}" + commands.append({ + "sra_id": sra_id, + "command": cmd, + "description": f"Download {sra_id} using curl" + }) + + elif method == "aspera": + # Aspera high-speed download + # Requires aspera-cli to be installed + for sra_id in sra_ids: + aspera_url = f"anonftp@ftp.ncbi.nlm.nih.gov:/sra/sra-instant/reads/ByRun/sra/{sra_id[:3]}/{sra_id[:6]}/{sra_id}/{sra_id}.sra" + cmd = f"ascp -QT -l 300m -P33001 -i $HOME/.aspera/connect/etc/asperaweb_id_dsa.openssh {aspera_url} {output_dir}" + commands.append({ + "sra_id": sra_id, + "command": cmd, + "description": f"Download {sra_id} using Aspera (high-speed)" + }) + + else: + raise SRAError(f"Unknown download method: {method}") + + return { + "sra_ids": sra_ids, + "method": method, + "output_dir": output_dir, + "commands": commands, + "notes": self._get_method_notes(method) + } + + def _get_method_notes(self, method: str) -> str: + """Get notes for a download method.""" + notes = { + "prefetch": ( + "Requires sra-toolkit (https://github.com/ncbi/sra-tools). " + "Downloads SRA files which can then be converted to FASTQ using fasterq-dump." + ), + "fasterq-dump": ( + "Requires sra-toolkit. Downloads and converts to FASTQ in one step. " + "May take longer but produces immediately usable files." + ), + "wget": ( + "Direct HTTP download. Works without sra-toolkit but downloads SRA format files " + "which need to be converted using fasterq-dump." + ), + "curl": ( + "Direct HTTP download using curl. Similar to wget but more portable." + ), + "aspera": ( + "High-speed download using Aspera protocol. Requires aspera-cli. " + "Fastest method for large files." + ) + } + return notes.get(method, "") + + async def download_sra( + self, + sra_id: str, + convert_to_fastq: bool = False, + output_dir: Optional[Path] = None + ) -> Dict[str, Any]: + """Download an SRA file. + + Note: This method downloads SRA files directly. For production use, + it's recommended to use sra-toolkit prefetch/fasterq-dump instead. + + Args: + sra_id: SRA accession + convert_to_fastq: Whether to convert to FASTQ (requires sra-toolkit) + output_dir: Optional output directory + + Returns: + Download results + """ + sra_id = sra_id.upper() + output_dir = output_dir or self.download_dir + output_dir.mkdir(parents=True, exist_ok=True) + + url = self.get_sra_download_url(sra_id) + sra_file = output_dir / f"{sra_id}.sra" + + try: + # Download the SRA file + async with httpx.AsyncClient() as client: + async with client.stream("GET", url, timeout=300.0) as response: + response.raise_for_status() + + with open(sra_file, 'wb') as f: + async for chunk in response.aiter_bytes(): + f.write(chunk) + + result = { + "sra_id": sra_id, + "sra_file": str(sra_file), + "size_mb": round(sra_file.stat().st_size / (1024*1024), 2), + "converted_to_fastq": False + } + + # Convert to FASTQ if requested + if convert_to_fastq: + fastq_result = await self._convert_to_fastq(sra_file, output_dir) + result["converted_to_fastq"] = True + result["fastq_files"] = fastq_result + + return result + + except Exception as e: + # Clean up partial download + if sra_file.exists(): + sra_file.unlink() + raise SRAError(f"Download failed: {str(e)}") + + async def _convert_to_fastq( + self, + sra_file: Path, + output_dir: Path + ) -> List[str]: + """Convert SRA file to FASTQ using fasterq-dump. + + Args: + sra_file: Path to SRA file + output_dir: Output directory for FASTQ files + + Returns: + List of generated FASTQ files + """ + toolkit_path = self.sra_toolkit_path or "" + fasterq_dump = f"{toolkit_path}/fasterq-dump" if toolkit_path else "fasterq-dump" + + # Check if fasterq-dump is available + try: + result = subprocess.run( + [fasterq_dump, "--version"], + capture_output=True, + text=True, + timeout=10 + ) + if result.returncode != 0: + raise SRAError("fasterq-dump not found. Please install sra-toolkit.") + except FileNotFoundError: + raise SRAError( + "fasterq-dump not found. Please install sra-toolkit: " + "https://github.com/ncbi/sra-tools" + ) + + # Run fasterq-dump + cmd = [ + fasterq_dump, + "--outdir", str(output_dir), + "--threads", "4", + str(sra_file) + ] + + # Run in thread pool to avoid blocking + loop = asyncio.get_event_loop() + result = await loop.run_in_executor( + None, + lambda: subprocess.run(cmd, capture_output=True, text=True, timeout=3600) + ) + + if result.returncode != 0: + raise SRAError(f"fasterq-dump failed: {result.stderr}") + + # Find generated FASTQ files + sra_id = sra_file.stem + fastq_files = [] + for pattern in [f"{sra_id}*.fastq", f"{sra_id}*.fastq.gz"]: + fastq_files.extend(output_dir.glob(pattern)) + + return [str(f) for f in fastq_files] + + async def download_and_convert( + self, + sra_id: str, + output_dir: Optional[Path] = None, + split_3: bool = True, + check_refseq: bool = True, + dry_run: bool = False, + confirm_large: bool = False + ) -> Dict[str, Any]: + """Download SRA data using prefetch and convert to FASTQ using fastq-dump. + + SAFETY FEATURES (as per maintainer recommendations): + - Dry-run mode: Estimate sizes before downloading + - Size warnings: Alerts for large files (>1GB, >5GB) + - Explicit confirmation: Required for very large downloads + - Explicit output directory: Must be provided for large files + + This is the recommended workflow for downloading and converting SRA data: + 1. Uses prefetch to download SRA file (handles large files better) + 2. Uses fastq-dump --split-3 to convert to FASTQ (handles paired-end properly) + + Args: + sra_id: SRA accession ID (e.g., 'SRR1234567') + output_dir: Optional output directory (default: download_dir/sra_id) + split_3: Use --split-3 for 3-way splitting (recommended for mate-pairs) + check_refseq: Whether to check/download reference sequences + dry_run: If True, only estimate sizes without downloading (default: False) + confirm_large: Must be True to download files >5GB (safety check) + + Returns: + Dictionary with download and conversion results, or dry-run estimates + + Raises: + SRAError: If output_dir not provided for large files, or if confirm_large=False for >5GB files + """ + sra_id = sra_id.upper() + + # SAFETY CHECK 1: Estimate size before downloading + size_estimate = await self.estimate_sra_size([sra_id]) + total_mb = size_estimate.get("total_sra_size_mb", 0) + + # SAFETY CHECK 2: Dry-run mode - return estimates without downloading + if dry_run: + return { + "mode": "dry_run", + "sra_id": sra_id, + "size_estimate": size_estimate, + "note": "To proceed with download, call with dry_run=False" + } + + # SAFETY CHECK 3: Explicit output directory required for large files + if total_mb > 1024 and output_dir is None: # > 1GB + raise SRAError( + f"SAFETY CHECK: File size is ~{total_mb/1024:.1f} GB. " + f"Large downloads require an explicit output directory. " + f"Please provide output_dir parameter. " + f"Tip: Run with dry_run=True first to see size estimates." + ) + + # SAFETY CHECK 4: Confirmation required for very large files + if total_mb > 5120 and not confirm_large: # > 5GB + raise SRAError( + f"SAFETY CHECK: File size is ~{total_mb/1024:.1f} GB (>5GB). " + f"This is a VERY LARGE download that will consume significant " + f"disk space and time. To proceed, set confirm_large=True. " + f"Tip: Run with dry_run=True first to see detailed estimates." + ) + + output_dir = output_dir or (self.download_dir / sra_id) + output_dir.mkdir(parents=True, exist_ok=True) + + # Check for sra-toolkit + toolkit_check = self.check_sra_toolkit() + if not toolkit_check["all_available"]: + raise SRAError( + "sra-toolkit not found. Please install from: " + "https://github.com/ncbi/sra-tools" + ) + + toolkit_path = self.sra_toolkit_path or "" + prefetch = f"{toolkit_path}/prefetch" if toolkit_path else "prefetch" + fastq_dump = f"{toolkit_path}/fastq-dump" if toolkit_path else "fastq-dump" + + result = { + "sra_id": sra_id, + "output_dir": str(output_dir), + "size_estimate_mb": total_mb, + "safety_warnings": size_estimate.get("safety_warnings", []), + "steps": [] + } + + try: + # Step 1: Download using prefetch + import logging + logger = logging.getLogger(__name__) + + cmd = [prefetch, "--progress", "--output-directory", str(output_dir)] + + # Handle refseq checking + if not check_refseq: + cmd.extend(["--check-rs", "no"]) + + cmd.append(sra_id) + + loop = asyncio.get_event_loop() + prefetch_result = await loop.run_in_executor( + None, + lambda: subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=3600 # 1 hour timeout for large files + ) + ) + + if prefetch_result.returncode != 0: + raise SRAError(f"prefetch failed: {prefetch_result.stderr}") + + # Find the downloaded SRA file + sra_file = output_dir / sra_id / f"{sra_id}.sra" + if not sra_file.exists(): + # Try alternative locations + alt_paths = [ + output_dir / f"{sra_id}.sra", + self.download_dir / sra_id / f"{sra_id}.sra", + Path(f"{sra_id}/{sra_id}.sra"), + ] + for alt_path in alt_paths: + if alt_path.exists(): + sra_file = alt_path + break + + if not sra_file.exists(): + raise SRAError(f"SRA file not found after download: {sra_file}") + + sra_size_mb = round(sra_file.stat().st_size / (1024*1024), 2) + + result["steps"].append({ + "step": "download", + "status": "success", + "sra_file": str(sra_file), + "sra_size_mb": sra_size_mb + }) + + # Step 2: Convert to FASTQ using fastq-dump + print(f"Step 2: Converting {sra_id} to FASTQ using fastq-dump...") + + cmd = [fastq_dump, "--outdir", str(output_dir)] + + if split_3: + cmd.append("--split-3") + else: + cmd.append("--split-files") + + cmd.append(str(sra_file)) + + fastq_result = await loop.run_in_executor( + None, + lambda: subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=3600 # 1 hour timeout + ) + ) + + if fastq_result.returncode != 0: + raise SRAError(f"fastq-dump failed: {fastq_result.stderr}") + + # Find generated FASTQ files + fastq_files = [] + for pattern in [f"{sra_id}*.fastq", f"{sra_id}*.fastq.gz"]: + fastq_files.extend(output_dir.glob(pattern)) + + # Also check in sra_id subdirectory + if (output_dir / sra_id).exists(): + for pattern in [f"{sra_id}*.fastq", f"{sra_id}*.fastq.gz"]: + fastq_files.extend((output_dir / sra_id).glob(pattern)) + + fastq_info = [] + total_fastq_size_mb = 0 + for f in fastq_files: + size_mb = round(f.stat().st_size / (1024*1024), 2) + total_fastq_size_mb += size_mb + + # Count reads (each read = 4 lines) + line_count = 0 + try: + with open(f, 'r') as fp: + for _ in fp: + line_count += 1 + if line_count >= 4: + break + # Get total lines + result_count = subprocess.run( + ["wc", "-l", str(f)], + capture_output=True, + text=True + ) + total_lines = int(result_count.stdout.split()[0]) + read_count = total_lines // 4 + except: + read_count = "unknown" + + fastq_info.append({ + "file": str(f.name), + "path": str(f), + "size_mb": size_mb, + "reads": read_count + }) + + result["steps"].append({ + "step": "convert", + "status": "success", + "fastq_files": fastq_info, + "total_fastq_size_mb": total_fastq_size_mb + }) + + result["status"] = "success" + result["total_size_mb"] = sra_size_mb + total_fastq_size_mb + + return result + + except Exception as e: + result["status"] = "failed" + result["error"] = str(e) + raise SRAError(f"Download and convert failed: {str(e)}") + + def check_sra_toolkit(self) -> Dict[str, Any]: + """Check if sra-toolkit is installed and available. + + Returns: + Status information about sra-toolkit + """ + toolkit_path = self.sra_toolkit_path or "" + tools = ["prefetch", "fastq-dump", "fasterq-dump", "vdb-validate"] + + results = {} + all_found = True + + for tool in tools: + cmd = f"{toolkit_path}/{tool}" if toolkit_path else tool + try: + result = subprocess.run( + [cmd, "--version"], + capture_output=True, + text=True, + timeout=10 + ) + results[tool] = { + "available": result.returncode == 0, + "version": result.stdout.strip() if result.returncode == 0 else None, + "path": cmd + } + if result.returncode != 0: + all_found = False + except FileNotFoundError: + results[tool] = {"available": False, "path": cmd} + all_found = False + except Exception as e: + results[tool] = {"available": False, "error": str(e)} + all_found = False + + return { + "all_available": all_found, + "toolkit_path": toolkit_path or "System PATH", + "tools": results, + "installation_url": "https://github.com/ncbi/sra-tools/wiki/02.-Installing-SRA-Toolkit" + } diff --git a/GEO/geo_mcp_server/pyproject.toml b/GEO/geo_mcp_server/pyproject.toml new file mode 100644 index 0000000..0fe5662 --- /dev/null +++ b/GEO/geo_mcp_server/pyproject.toml @@ -0,0 +1,72 @@ +[build-system] +requires = ["hatchling", "hatch-vcs"] +build-backend = "hatchling.build" + +[project] +name = "geo-mcp-sra" +dynamic = ["version"] +description = "An enhanced MCP server for accessing GEO data with SRA raw sequencing support" +readme = "README.md" +license = {text = "MIT"} +requires-python = ">=3.10" +keywords = ["mcp", "geo", "sra", "bioinformatics", "gene-expression", "ncbi", "rna-seq", "fastq"] +authors = [ + {name = "GEO MCP Contributors"} +] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Scientific/Engineering :: Bio-Informatics", + "Topic :: Software Development :: Libraries :: Python Modules", +] +dependencies = [ + "mcp>=1.9.0", + "httpx>=0.27.0", + "aiofiles>=23.0.0", + "pydantic>=2.0.0", +] + +[project.urls] +Homepage = "https://github.com/yourusername/geo-mcp-sra" +Repository = "https://github.com/yourusername/geo-mcp-sra" +Documentation = "https://github.com/yourusername/geo-mcp-sra#readme" +"Bug Tracker" = "https://github.com/yourusername/geo-mcp-sra/issues" + +[project.scripts] +geo-mcp-sra = "server:main" + +[project.optional-dependencies] +dev = [ + "pytest>=7.0.0", + "pytest-asyncio>=0.21.0", + "black>=23.0.0", + "ruff>=0.1.0", + "mypy>=1.0.0", +] + +[tool.hatch.version] +source = "vcs" + +[tool.hatch.build.targets.wheel] +packages = ["geomcp_sra"] + +[tool.black] +line-length = 100 +target-version = ['py310'] + +[tool.ruff] +line-length = 100 +target-version = "py310" + +[tool.mypy] +python_version = "3.10" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true diff --git a/GEO/geo_mcp_server/requirements.txt b/GEO/geo_mcp_server/requirements.txt new file mode 100644 index 0000000..ad97d61 --- /dev/null +++ b/GEO/geo_mcp_server/requirements.txt @@ -0,0 +1,4 @@ +mcp>=1.9.0 +httpx>=0.27.0 +aiofiles>=23.0.0 +pydantic>=2.0.0 diff --git a/GEO/geo_mcp_server/server.py b/GEO/geo_mcp_server/server.py new file mode 100644 index 0000000..f921c08 --- /dev/null +++ b/GEO/geo_mcp_server/server.py @@ -0,0 +1,1031 @@ +#!/usr/bin/env python3 +""" +GEO MCP Server with SRA Support + +An enhanced Model Context Protocol (MCP) server for accessing +GEO (Gene Expression Omnibus) data through NCBI E-Utils API, +with additional support for SRA (Sequence Read Archive) raw data queries. + +Usage: + python server.py # Run MCP stdio server + python server.py --http # Run HTTP server + python server.py --http --port 8080 # Run HTTP server on custom port + python server.py --init # Initialize configuration +""" + +import argparse +import asyncio +import json +import sys +from pathlib import Path +from typing import Optional, List +from enum import Enum + +from pydantic import BaseModel, Field, field_validator, ConfigDict +from mcp.server.fastmcp import FastMCP + +# Import our modules +from geomcp_sra.config import load_config, validate_config, create_config_template, get_config +from geomcp_sra.geo_search import GEOSearchClient, GEOSearchError +from geomcp_sra.geo_download import GEODownloadClient, GEODownloadError +from geomcp_sra.sra_handler import SRAHandler, SRAError + + +# Initialize MCP server +mcp = FastMCP("geo_mcp") + + +# ============================================================================ +# Enums and Response Formats +# ============================================================================ + +class ResponseFormat(str, Enum): + """Output format for tool responses.""" + MARKDOWN = "markdown" + JSON = "json" + + +class DownloadMethod(str, Enum): + """SRA download methods.""" + PREFETCH = "prefetch" + FASTERQ_DUMP = "fasterq-dump" + WGET = "wget" + CURL = "curl" + ASPERA = "aspera" + + +# ============================================================================ +# Pydantic Models for Input Validation +# ============================================================================ + +class SearchInput(BaseModel): + """Base input for search operations.""" + model_config = ConfigDict(str_strip_whitespace=True) + + term: str = Field( + ..., + description="Search term (e.g., 'breast cancer', 'GSE12345', 'RNA-seq')", + min_length=1, + max_length=500 + ) + retmax: int = Field( + default=20, + description="Maximum number of results to return", + ge=1, + le=1000 + ) + response_format: ResponseFormat = Field( + default=ResponseFormat.JSON, + description="Output format: 'json' for structured data or 'markdown' for readable text" + ) + + +class SearchWithTypesInput(SearchInput): + """Input for search with record type filtering.""" + record_types: Optional[List[str]] = Field( + default=None, + description="Filter for specific record types: GSE, GSM, GPL, GDS" + ) + + +class GeoIdInput(BaseModel): + """Input for GEO ID operations.""" + model_config = ConfigDict(str_strip_whitespace=True) + + geo_id: str = Field( + ..., + description="GEO accession ID (e.g., GSE12345, GSM789, GPL456, GDS123)", + pattern=r'^(GSE|GSM|GPL|GDS)\d+$' + ) + + +class DownloadInput(GeoIdInput): + """Input for download operations.""" + db_type: str = Field( + default="gse", + description="Database type: gse, gsm, gpl, or gds" + ) + output_dir: Optional[str] = Field( + default=None, + description="Optional custom output directory" + ) + file_types: Optional[List[str]] = Field( + default=None, + description="File types to download (for series: soft, matrix, miniml, supplementary)" + ) + + +class SRASearchInput(BaseModel): + """Input for SRA search operations.""" + model_config = ConfigDict(str_strip_whitespace=True) + + gse_id: str = Field( + ..., + description="GEO Series accession ID (e.g., GSE12345)", + pattern=r'^GSE\d+$' + ) + + +class SRADownloadInput(BaseModel): + """Input for SRA download command generation.""" + model_config = ConfigDict(str_strip_whitespace=True) + + sra_ids: List[str] = Field( + ..., + description="List of SRA accession IDs (e.g., ['SRR1234567', 'SRR1234568'])", + min_length=1 + ) + method: DownloadMethod = Field( + default=DownloadMethod.PREFETCH, + description="Download method: prefetch, fasterq-dump, wget, curl, or aspera" + ) + output_dir: Optional[str] = Field( + default=None, + description="Optional output directory for downloads" + ) + + +class SRADirectDownloadInput(BaseModel): + """Input for direct SRA download.""" + model_config = ConfigDict(str_strip_whitespace=True) + + sra_id: str = Field( + ..., + description="SRA accession ID (e.g., SRR1234567)", + pattern=r'^[SED]RR\d+$' + ) + convert_to_fastq: bool = Field( + default=False, + description="Whether to convert SRA to FASTQ format (requires sra-toolkit)" + ) + output_dir: Optional[str] = Field( + default=None, + description="Optional output directory" + ) + + +class CleanupInput(BaseModel): + """Input for cleanup operations.""" + model_config = ConfigDict(str_strip_whitespace=True) + + geo_id: Optional[str] = Field( + default=None, + description="Optional specific GEO ID to remove" + ) + db_type: Optional[str] = Field( + default=None, + description="Optional database type filter for cleanup (gse, gsm, gpl, gds, sra)" + ) + + +# ============================================================================ +# Helper Functions +# ============================================================================ + +def format_as_markdown(data: dict, title: str = "Results") -> str: + """Format results as markdown for human readability.""" + lines = [f"# {title}", ""] + + def format_value(value, indent=0): + prefix = " " * indent + if isinstance(value, dict): + for k, v in value.items(): + if isinstance(v, (dict, list)): + lines.append(f"{prefix}- **{k}:**") + format_value(v, indent + 1) + else: + lines.append(f"{prefix}- **{k}:** {v}") + elif isinstance(value, list): + for item in value: + if isinstance(item, dict): + lines.append(f"{prefix}-") + format_value(item, indent + 1) + else: + lines.append(f"{prefix}- {item}") + else: + lines.append(f"{prefix}{value}") + + format_value(data) + return "\n".join(lines) + + +def handle_error(error: Exception) -> str: + """Format error messages consistently.""" + if isinstance(error, GEOSearchError): + return f"GEO Search Error: {str(error)}" + elif isinstance(error, GEODownloadError): + return f"GEO Download Error: {str(error)}" + elif isinstance(error, SRAError): + return f"SRA Error: {str(error)}" + elif isinstance(error, ValueError): + return f"Validation Error: {str(error)}" + else: + return f"Error: {type(error).__name__}: {str(error)}" + + +# ============================================================================ +# GEO Search Tools +# ============================================================================ + +@mcp.tool( + name="geo_search", + annotations={ + "title": "Search GEO Database", + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": True + } +) +async def geo_search(params: SearchWithTypesInput) -> str: + '''Search GEO for all types of records (GSE, GSM, GPL, GDS). + + This tool searches across all GEO databases and returns categorized results + by record type (Series, Samples, Platforms, Datasets). + + Args: + params: Search parameters including term, retmax, record_types, and response_format + + Returns: + JSON or Markdown formatted search results + + Examples: + - Search for cancer studies: term="breast cancer" + - Find specific series: term="GSE12345" + - Search for RNA-seq data: term="RNA-seq" + - Filter to only series: record_types=["GSE"] + ''' + try: + client = GEOSearchClient() + result = await client.search_geo( + term=params.term, + retmax=params.retmax, + record_types=params.record_types + ) + + if params.response_format == ResponseFormat.MARKDOWN: + return format_as_markdown(result, f"GEO Search Results: '{params.term}'") + return json.dumps(result, indent=2) + + except Exception as e: + return handle_error(e) + + +@mcp.tool( + name="geo_search_series", + annotations={ + "title": "Search GEO Series", + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": True + } +) +async def geo_search_series(params: SearchInput) -> str: + '''Search for GEO Series (GSE) - complete experiments. + + Args: + params: Search parameters including term and retmax + + Returns: + JSON or Markdown formatted GSE search results + ''' + try: + client = GEOSearchClient() + result = await client.search_geo_series(params.term, params.retmax) + + if params.response_format == ResponseFormat.MARKDOWN: + return format_as_markdown(result, f"GEO Series Search: '{params.term}'") + return json.dumps(result, indent=2) + + except Exception as e: + return handle_error(e) + + +@mcp.tool( + name="geo_search_samples", + annotations={ + "title": "Search GEO Samples", + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": True + } +) +async def geo_search_samples(params: SearchInput) -> str: + '''Search for GEO Samples (GSM) - individual samples. + + Args: + params: Search parameters including term and retmax + + Returns: + JSON or Markdown formatted GSM search results + ''' + try: + client = GEOSearchClient() + result = await client.search_geo_samples(params.term, params.retmax) + + if params.response_format == ResponseFormat.MARKDOWN: + return format_as_markdown(result, f"GEO Sample Search: '{params.term}'") + return json.dumps(result, indent=2) + + except Exception as e: + return handle_error(e) + + +@mcp.tool( + name="geo_search_platforms", + annotations={ + "title": "Search GEO Platforms", + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": True + } +) +async def geo_search_platforms(params: SearchInput) -> str: + '''Search for GEO Platforms (GPL) - array/sequencing platforms. + + Args: + params: Search parameters including term and retmax + + Returns: + JSON or Markdown formatted GPL search results + ''' + try: + client = GEOSearchClient() + result = await client.search_geo_platforms(params.term, params.retmax) + + if params.response_format == ResponseFormat.MARKDOWN: + return format_as_markdown(result, f"GEO Platform Search: '{params.term}'") + return json.dumps(result, indent=2) + + except Exception as e: + return handle_error(e) + + +@mcp.tool( + name="geo_search_datasets", + annotations={ + "title": "Search GEO Datasets", + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": True + } +) +async def geo_search_datasets(params: SearchInput) -> str: + '''Search for GEO Datasets (GDS) - curated gene expression datasets. + + Args: + params: Search parameters including term and retmax + + Returns: + JSON or Markdown formatted GDS search results + ''' + try: + client = GEOSearchClient() + result = await client.search_geo_datasets(params.term, params.retmax) + + if params.response_format == ResponseFormat.MARKDOWN: + return format_as_markdown(result, f"GEO Dataset Search: '{params.term}'") + return json.dumps(result, indent=2) + + except Exception as e: + return handle_error(e) + + +@mcp.tool( + name="geo_search_profiles", + annotations={ + "title": "Search GEO Profiles", + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": True + } +) +async def geo_search_profiles(params: SearchInput) -> str: + '''Search GEO Profiles database for gene expression profiles. + + Args: + params: Search parameters including term and retmax + + Returns: + JSON or Markdown formatted GEO Profiles search results + ''' + try: + client = GEOSearchClient() + result = await client.search_geo_profiles(params.term, params.retmax) + + if params.response_format == ResponseFormat.MARKDOWN: + return format_as_markdown(result, f"GEO Profiles Search: '{params.term}'") + return json.dumps(result, indent=2) + + except Exception as e: + return handle_error(e) + + +# ============================================================================ +# GEO Download Tools +# ============================================================================ + +@mcp.tool( + name="geo_download_series", + annotations={ + "title": "Download GEO Series Data", + "readOnlyHint": False, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": True + } +) +async def geo_download_series(params: DownloadInput) -> str: + '''Download GEO Series data files (SOFT, matrix, supplementary). + + Downloads data files for a GEO Series including: + - SOFT format metadata (soft) + - Series matrix file (matrix) + - MINiML XML (miniml) + - Supplementary files (supplementary) + + Args: + params: Download parameters including geo_id, file_types, and output_dir + + Returns: + JSON formatted download results + + Examples: + - Download SOFT file: geo_id="GSE12345", file_types=["soft"] + - Download all: geo_id="GSE12345", file_types=["soft", "matrix", "supplementary"] + ''' + try: + client = GEODownloadClient() + output_dir = Path(params.output_dir) if params.output_dir else None + + result = await client.download_geo_series( + gse_id=params.geo_id.upper(), + file_types=params.file_types, + output_dir=output_dir + ) + + return json.dumps(result, indent=2) + + except Exception as e: + return handle_error(e) + + +@mcp.tool( + name="geo_download_sample", + annotations={ + "title": "Download GEO Sample Data", + "readOnlyHint": False, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": True + } +) +async def geo_download_sample(params: DownloadInput) -> str: + '''Download GEO Sample supplementary files. + + Downloads supplementary data files for a specific GEO Sample (GSM). + + Args: + params: Download parameters including geo_id (GSM) and output_dir + + Returns: + JSON formatted download results + ''' + try: + client = GEODownloadClient() + output_dir = Path(params.output_dir) if params.output_dir else None + + result = await client.download_geo_sample( + gsm_id=params.geo_id.upper(), + output_dir=output_dir + ) + + return json.dumps(result, indent=2) + + except Exception as e: + return handle_error(e) + + +@mcp.tool( + name="geo_get_download_status", + annotations={ + "title": "Get GEO Download Status", + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": False + } +) +async def geo_get_download_status(params: GeoIdInput) -> str: + '''Check if a GEO dataset has been downloaded. + + Args: + params: Parameters including geo_id and optional db_type + + Returns: + JSON formatted status information + ''' + try: + client = GEODownloadClient() + + # Determine db_type from geo_id prefix + db_type = params.geo_id[:3].lower() + + result = client.get_download_status( + accession=params.geo_id.upper(), + db_type=db_type + ) + + return json.dumps(result, indent=2) + + except Exception as e: + return handle_error(e) + + +@mcp.tool( + name="geo_list_downloads", + annotations={ + "title": "List Downloaded GEO Datasets", + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": False + } +) +async def geo_list_downloads(db_type: Optional[str] = None) -> str: + '''List all downloaded GEO datasets. + + Args: + db_type: Optional filter by database type (gse, gsm, gpl, gds, sra) + + Returns: + JSON formatted list of downloaded datasets + ''' + try: + client = GEODownloadClient() + result = client.list_downloaded_datasets(db_type) + return json.dumps(result, indent=2) + + except Exception as e: + return handle_error(e) + + +@mcp.tool( + name="geo_cleanup_downloads", + annotations={ + "title": "Clean Up GEO Downloads", + "readOnlyHint": False, + "destructiveHint": True, + "idempotentHint": False, + "openWorldHint": False + } +) +async def geo_cleanup_downloads(params: CleanupInput) -> str: + '''Clean up downloaded GEO files. + + Args: + params: Parameters including optional geo_id and db_type to remove + + Returns: + JSON formatted cleanup results + + Warning: + This is a destructive operation that deletes downloaded files. + ''' + try: + client = GEODownloadClient() + result = client.cleanup_downloads( + accession=params.geo_id, + db_type=params.db_type + ) + return json.dumps(result, indent=2) + + except Exception as e: + return handle_error(e) + + +# ============================================================================ +# SRA Tools (NEW - for raw sequencing data) +# ============================================================================ + +@mcp.tool( + name="sra_query_from_geo", + annotations={ + "title": "Query SRA Accessions from GEO Series", + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": True + } +) +async def sra_query_from_geo(params: SRASearchInput) -> str: + '''Query SRA Run information from a GEO Series. + + This tool extracts the mapping between GEO Samples (GSM) and + SRA Run accessions (SRR) from a GEO Series. This allows you to + identify which SRA files contain the raw sequencing data for a dataset. + + Args: + params: Parameters including gse_id + + Returns: + JSON formatted SRA accession information + + Example: + Input: gse_id="GSE12345" + Output: { + "gse_id": "GSE12345", + "total_samples": 10, + "samples_with_sra": 8, + "all_sra_accessions": ["SRR1234567", "SRR1234568", ...], + "samples": [ + { + "gsm_id": "GSM123456", + "sra_accessions": ["SRR1234567"] + } + ] + } + ''' + try: + handler = SRAHandler() + result = await handler.query_sra_from_geo(params.gse_id.upper()) + return json.dumps(result, indent=2) + + except Exception as e: + return handle_error(e) + + +@mcp.tool( + name="sra_get_metadata", + annotations={ + "title": "Get SRA Run Metadata", + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": True + } +) +async def sra_get_metadata(sra_id: str) -> str: + '''Get metadata for an SRA Run accession. + + Args: + sra_id: SRA accession ID (e.g., SRR1234567) + + Returns: + JSON formatted SRA metadata + ''' + try: + handler = SRAHandler() + result = await handler.get_sra_metadata(sra_id) + return json.dumps(result, indent=2) + + except Exception as e: + return handle_error(e) + + +@mcp.tool( + name="sra_generate_download_commands", + annotations={ + "title": "Generate SRA Download Commands", + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": False + } +) +async def sra_generate_download_commands(params: SRADownloadInput) -> str: + '''Generate commands to download SRA data. + + Generates download commands for SRA accessions using various methods: + - prefetch: Using sra-toolkit prefetch (recommended) + - fasterq-dump: Download and convert to FASTQ in one step + - wget: Direct HTTP download + - curl: Direct HTTP download using curl + - aspera: High-speed Aspera download + + Args: + params: Parameters including sra_ids list, method, and output_dir + + Returns: + JSON formatted commands and instructions + + Note: + This tool generates commands but does not execute them. + Run the commands in your terminal or use the shell tool. + ''' + try: + handler = SRAHandler() + result = handler.generate_download_commands( + sra_ids=[s.upper() for s in params.sra_ids], + method=params.method.value, + output_dir=params.output_dir + ) + return json.dumps(result, indent=2) + + except Exception as e: + return handle_error(e) + + +@mcp.tool( + name="sra_check_toolkit", + annotations={ + "title": "Check SRA Toolkit Installation", + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": False + } +) +async def sra_check_toolkit() -> str: + '''Check if SRA Toolkit is installed and available. + + Returns: + JSON formatted toolkit status and installation information + ''' + try: + handler = SRAHandler() + result = handler.check_sra_toolkit() + return json.dumps(result, indent=2) + + except Exception as e: + return handle_error(e) + + +@mcp.tool( + name="sra_download", + annotations={ + "title": "Download SRA Data Directly", + "readOnlyHint": False, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": True + } +) +async def sra_download(params: SRADirectDownloadInput) -> str: + '''Download SRA data directly (without sra-toolkit). + + Note: For large files or multiple downloads, using sra-toolkit + prefetch is recommended instead. This method is suitable for + small files or when sra-toolkit is not available. + + Args: + params: Parameters including sra_id, convert_to_fastq, and output_dir + + Returns: + JSON formatted download results + ''' + try: + handler = SRAHandler() + output_dir = Path(params.output_dir) if params.output_dir else None + + result = await handler.download_sra( + sra_id=params.sra_id.upper(), + convert_to_fastq=params.convert_to_fastq, + output_dir=output_dir + ) + return json.dumps(result, indent=2) + + except Exception as e: + return handle_error(e) + + +class SRADownloadAndConvertInput(BaseModel): + """Input for SRA download and convert operations.""" + model_config = ConfigDict(str_strip_whitespace=True) + + sra_id: str = Field( + ..., + description="SRA accession ID (e.g., SRR1234567)", + pattern=r'^[SED]RR\d+$' + ) + output_dir: Optional[str] = Field( + default=None, + description="Optional output directory (default: ~/geo_downloads/sra/). REQUIRED for files >1GB" + ) + split_3: bool = Field( + default=True, + description="Use --split-3 for 3-way splitting (recommended for mate-pairs)" + ) + check_refseq: bool = Field( + default=True, + description="Whether to check/download reference sequences (disable to save space)" + ) + dry_run: bool = Field( + default=True, + description="SAFETY: If True (default), only estimates size without downloading. Set to False to actually download." + ) + confirm_large: bool = Field( + default=False, + description="SAFETY: Must be True to download files >5GB. Use dry_run=True first to check size." + ) + + +class SRASizeEstimateInput(BaseModel): + """Input for SRA size estimation (dry-run).""" + model_config = ConfigDict(str_strip_whitespace=True) + + sra_ids: List[str] = Field( + ..., + description="List of SRA accession IDs to estimate (e.g., ['SRR1234567', 'SRR1234568'])", + min_length=1 + ) + + +@mcp.tool( + name="sra_download_and_convert", + annotations={ + "title": "Download SRA and Convert to FASTQ", + "readOnlyHint": False, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": True + } +) +async def sra_download_and_convert(params: SRADownloadAndConvertInput) -> str: + '''Download SRA data using prefetch and convert to FASTQ using fastq-dump. + + SAFETY FEATURES (following maintainer recommendations): + - DRY-RUN BY DEFAULT: dry_run=True (default) only estimates size without downloading + - SIZE WARNINGS: Alerts for files >1GB, requires confirmation for >5GB + - EXPLICIT OUTPUT: Required output_dir for files >1GB + - CONFIRMATION: confirm_large=True required for files >5GB + + RECOMMENDED WORKFLOW: + 1. First, run with dry_run=True to see size estimates + 2. If size is acceptable, run with dry_run=False and output_dir="/path" + 3. For large files (>5GB), also set confirm_large=True + + Args: + params: Parameters including sra_id, output_dir, split_3, check_refseq, dry_run, confirm_large + + Returns: + JSON formatted results with download/conversion details OR dry-run estimates + + Examples: + - Step 1: Check size first + sra_id="SRR1234567", dry_run=true + + - Step 2a: Download small file (<1GB) + sra_id="SRR1234567", dry_run=false + + - Step 2b: Download medium file (1-5GB) + sra_id="SRR1234567", dry_run=false, output_dir="/path/to/output" + + - Step 2c: Download large file (>5GB) + sra_id="SRR1234567", dry_run=false, output_dir="/path/to/output", confirm_large=true + ''' + try: + handler = SRAHandler() + output_dir = Path(params.output_dir) if params.output_dir else None + + result = await handler.download_and_convert( + sra_id=params.sra_id.upper(), + output_dir=output_dir, + split_3=params.split_3, + check_refseq=params.check_refseq, + dry_run=params.dry_run, + confirm_large=params.confirm_large + ) + return json.dumps(result, indent=2) + + except Exception as e: + return handle_error(e) + + +@mcp.tool( + name="sra_estimate_size", + annotations={ + "title": "Estimate SRA Download Size (Dry-Run)", + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": True + } +) +async def sra_estimate_size(params: SRASizeEstimateInput) -> str: + '''Estimate the size of SRA files before downloading (dry-run mode). + + This tool queries the EBI ENA database to get file size estimates without + actually downloading any data. Use this before sra_download_and_convert + to check if you have sufficient disk space. + + Args: + params: Parameters including list of sra_ids to estimate + + Returns: + JSON formatted size estimates and safety warnings: + - Individual file sizes (SRA and estimated FASTQ) + - Total download size + - Safety warnings for large files (>1GB, >5GB) + + Examples: + - Estimate single file: sra_ids=["SRR1234567"] + - Estimate multiple: sra_ids=["SRR1234567", "SRR1234568"] + ''' + try: + handler = SRAHandler() + + result = await handler.estimate_sra_size( + sra_ids=[s.upper() for s in params.sra_ids] + ) + return json.dumps(result, indent=2) + + except Exception as e: + return handle_error(e) + + +# ============================================================================ +# Main Entry Point +# ============================================================================ + +def init_config(): + """Initialize configuration file.""" + config_path = Path.home() / ".geo-mcp" / "config.json" + create_config_template(config_path) + print(f"\nConfiguration template created at: {config_path}") + print("\nPlease edit the file and add your email address (required by NCBI).") + print("Optionally, add your NCBI API key for higher rate limits.") + print(f"\nTo use with Claude Desktop, add this to your config:") + print(json.dumps({ + "mcpServers": { + "geo_mcp": { + "command": "python", + "args": [str(Path(__file__).resolve())], + "env": { + "CONFIG_PATH": str(config_path) + } + } + } + }, indent=2)) + + +def main(): + """Main entry point.""" + parser = argparse.ArgumentParser( + description="GEO MCP Server with SRA Support", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + python server.py --init # Initialize configuration + python server.py # Run MCP stdio server + python server.py --http # Run HTTP server on localhost:8000 + python server.py --http --port 8080 # Run HTTP server on custom port + """ + ) + + parser.add_argument( + "--init", + action="store_true", + help="Initialize configuration file" + ) + + parser.add_argument( + "--http", + action="store_true", + help="Run HTTP server instead of MCP stdio" + ) + + parser.add_argument( + "--host", + default="localhost", + help="Host for HTTP server (default: localhost)" + ) + + parser.add_argument( + "--port", + type=int, + default=8000, + help="Port for HTTP server (default: 8000)" + ) + + args = parser.parse_args() + + if args.init: + init_config() + return + + # Validate config before starting + try: + config = get_config() + validate_config(config) + except Exception as e: + print(f"Configuration error: {e}", file=sys.stderr) + print("Run with --init to create a configuration template.", file=sys.stderr) + sys.exit(1) + + if args.http: + # Run HTTP server + print(f"Starting HTTP server on http://{args.host}:{args.port}") + mcp.run(transport="streamable_http", host=args.host, port=args.port) + else: + # Run MCP stdio server + mcp.run() + + +if __name__ == "__main__": + main() From 5e679f4b5d07f20c9e8aa4563e76681a6709a0da Mon Sep 17 00:00:00 2001 From: DualK <92998962+KQDtianxiaK@users.noreply.github.com> Date: Tue, 24 Feb 2026 02:10:31 +0800 Subject: [PATCH 03/13] Delete geomcp directory --- geomcp/__init__.py | 22 -- geomcp/geo_downloader.py | 375 -------------------------- geomcp/geo_profiles.py | 431 ------------------------------ geomcp/main.py | 300 --------------------- geomcp/mcp_http_server.py | 189 ------------- geomcp/mcp_server.py | 544 -------------------------------------- 6 files changed, 1861 deletions(-) delete mode 100644 geomcp/__init__.py delete mode 100644 geomcp/geo_downloader.py delete mode 100644 geomcp/geo_profiles.py delete mode 100644 geomcp/main.py delete mode 100755 geomcp/mcp_http_server.py delete mode 100644 geomcp/mcp_server.py diff --git a/geomcp/__init__.py b/geomcp/__init__.py deleted file mode 100644 index 77a9d12..0000000 --- a/geomcp/__init__.py +++ /dev/null @@ -1,22 +0,0 @@ -""" -GEO MCP Server - A Model Context Protocol (MCP) server for accessing GEO data. - -This package provides tools to search and download data from the Gene Expression Omnibus (GEO) -through NCBI E-Utils API using the Model Context Protocol. -""" - -__version__ = "0.1.1" -__author__ = "MCPmed Contributors" -__email__ = "matthias.flotho@ccb.uni-saarland.de" - -from .main import main -from .geo_profiles import search_geo, search_geo_profiles, search_geo_datasets -from .geo_downloader import download_geo - -__all__ = [ - "main", - "search_geo", - "search_geo_profiles", - "search_geo_datasets", - "download_geo" -] \ No newline at end of file diff --git a/geomcp/geo_downloader.py b/geomcp/geo_downloader.py deleted file mode 100644 index 9dd7073..0000000 --- a/geomcp/geo_downloader.py +++ /dev/null @@ -1,375 +0,0 @@ -import json -import os -import sys -import re -import requests -import asyncio -import aiohttp -import aiofiles -import shutil -from pathlib import Path -from typing import Dict, Any, Optional, List - -CONFIG_PATH = os.getenv("CONFIG_PATH") # optional override - -# ------------------------------------------------------------------ -# Configuration loading -# ------------------------------------------------------------------ - -def _load_config() -> Dict[str, Any]: - """Return the first config.json we can find. - - Search order: - 1. $CONFIG_PATH (env‑var) if it points to a real file - 2. ./config.json next to this script - 3. ~/.geo-mcp/config.json (user default) - """ - candidates: List[Path] = [] - if CONFIG_PATH: - candidates.append(Path(os.path.expanduser(CONFIG_PATH))) - candidates.append(Path(__file__).parent / "config.json") - candidates.append(Path.home() / ".geo-mcp" / "config.json") - - for p in candidates: - if p.exists(): - with open(p) as f: - return json.load(f) - - raise FileNotFoundError("No config.json found. Looked in: " + ", ".join(str(p) for p in candidates)) - - -config = _load_config() - -# ------------------------------------------------------------------ -# Global constants (with sane defaults) -# ------------------------------------------------------------------ -BASE_URL = config.get("base_url", "https://eutils.ncbi.nlm.nih.gov/entrez/eutils") -EMAIL = config.get("email") # this *must* be set – NCBI requirement -API_KEY = config.get("api_key", "") -DOWNLOAD_DIR = config.get("download_dir", "./downloads") -MAX_FILE_MB = config.get("max_file_size_mb", 5000) -MAX_TOTAL_MB = config.get("max_total_downloads_mb", 10000) -MAX_CONCURRENT = config.get("max_concurrent_downloads", 3) -TIMEOUT = config.get("download_timeout_seconds", 300) -ALLOWED_PATHS = config.get("allowed_download_paths", ["./downloads", "/tmp/geo_downloads"]) - -if not EMAIL: - print("✘ 'email' is missing in config.json (required by NCBI)", file=sys.stderr) - sys.exit(1) - -# byte helpers -BYTES_IN_MB = 1024 * 1024 -MAX_FILE_BYTES = MAX_FILE_MB * BYTES_IN_MB -MAX_TOTAL_BYTES = MAX_TOTAL_MB * BYTES_IN_MB - -root_path = Path(DOWNLOAD_DIR) -if not root_path.is_absolute(): - root_path = Path(__file__).parent / root_path - -# ------------------------------------------------------------------ -# Utility functions -# ------------------------------------------------------------------ - -def _is_child(path: Path, parent: Path) -> bool: - try: - path.relative_to(parent) - return True - except ValueError: - return False - - -def _allowed(path: Path) -> bool: - path = path.resolve() - for ap in ALLOWED_PATHS: - p = Path(ap) - if not p.is_absolute(): - p = Path(__file__).parent / p - p = p.resolve() - if path == p or _is_child(path, p): - return True - return False - - -def _dir_size(p: Path) -> int: - return sum(f.stat().st_size for f in p.rglob("*") if f.is_file()) - - -def _disk_free(p: Path) -> int: - try: - return shutil.disk_usage(p).free - except Exception: - return 0 - -# ------------------------------------------------------------------ -# E‑utilities wrappers -# ------------------------------------------------------------------ - -def _request(path: str, params: Dict[str, str]) -> requests.Response: - prm = params.copy() - prm["email"] = EMAIL - if API_KEY: - prm["api_key"] = API_KEY - resp = requests.get(f"{BASE_URL}/{path}", params=prm) - resp.raise_for_status() - return resp - - -def _esearch_uid(acc: str) -> Optional[str]: - r = _request("esearch.fcgi", { - "db": "gds", - "term": f"{acc}[ACCN]", - "retmode": "json", - "retmax": "1", - }) - ids = r.json().get("esearchresult", {}).get("idlist", []) - return ids[0] if ids else None - - -def _efetch_gds(uid: str) -> str: - return _request("efetch.fcgi", {"db": "gds", "id": uid, "retmode": "xml"}).text - - -# ------------------------------------------------------------------ -# FTP/HTTP link extraction -# ------------------------------------------------------------------ - -def _extract_ftp_links(xml_text: str) -> List[str]: - """Return *https* direct links to SOFT archives. - - The XML from GDS often includes only an FTP directory. This helper: - • converts any `ftp://` prefix to `https://` (to avoid FTP handling) - • if the link ends with a GEO accession directory, appends the - standard SOFT location (`soft/[_family].soft.gz`). - """ - raw_links = re.findall(r"ftp://[\w./-]+", xml_text, re.I) - cleaned: List[str] = [] - - for link in raw_links: - base = re.sub(r"^ftp://", "https://", link.rstrip("/")) - - # if it's already a file we can use it straight away - if base.endswith(".soft.gz"): - cleaned.append(base) - continue - - # otherwise build the conventional SOFT file path - m = re.search(r"/(GSE\d+|GSM\d+|GPL\d+|GDS\d+)$", base, re.I) - if not m: - continue - acc = m.group(1) - soft = f"{base}/soft/{acc}_family.soft.gz" if acc.startswith("GSE") else f"{base}/soft/{acc}.soft.gz" - cleaned.append(soft) - - return cleaned - -# ------------------------------------------------------------------ -# Download logic -# ------------------------------------------------------------------ -sem = asyncio.Semaphore(MAX_CONCURRENT) - - -async def download_geo(acc: str, db_type: str, out_dir: Optional[str] = None) -> Dict[str, Any]: - async with sem: - dest = Path(out_dir or root_path / db_type / acc) - if not _allowed(dest): - raise ValueError("output dir violates ALLOWED_DOWNLOAD_PATHS") - dest.mkdir(parents=True, exist_ok=True) - - if _dir_size(root_path) >= MAX_TOTAL_BYTES: - raise ValueError("total download limit reached") - - uid = _esearch_uid(acc) - if not uid: - raise ValueError(f"{acc} not found in GDS database") - xml = _efetch_gds(uid) - urls = _extract_ftp_links(xml) - if not urls: - raise ValueError("no downloadable SOFT file exposed by E‑utilities") - - downloaded: List[str] = [] - total_bytes = 0 - timeout = aiohttp.ClientTimeout(total=TIMEOUT) - - async with aiohttp.ClientSession(timeout=timeout) as session: - for url in urls: - filename = url.split("/")[-1] - filepath = dest / filename - - if _disk_free(dest) < MAX_FILE_BYTES * 2: - raise ValueError("insufficient disk space to continue") - - async with session.get(url) as resp: - if resp.status != 200: - raise ValueError(f"download failed with HTTP {resp.status}: {url}") - clen = int(resp.headers.get("content-length", "0")) - if clen and clen > MAX_FILE_BYTES: - raise ValueError("SOFT archive exceeds MAX_FILE_SIZE_MB") - - sz = 0 - async with aiofiles.open(filepath, "wb") as fh: - async for chunk in resp.content.iter_chunked(8192): - sz += len(chunk) - if sz > MAX_FILE_BYTES: - raise ValueError("file size grew beyond limit during transfer") - await fh.write(chunk) - total_bytes += sz - downloaded.append(str(filepath)) - - # save XML metadata next to archives - meta_path = dest / f"{acc}_metadata.xml" - meta_path.write_text(xml) - downloaded.append(str(meta_path)) - - return { - "acc": acc, - "db_type": db_type, - "output_dir": str(dest), - "files": downloaded, - "total_size_mb": round(total_bytes / BYTES_IN_MB, 2), - } - -# ------------------------------------------------------------------ -# Status and management functions -# ------------------------------------------------------------------ - -def get_download_status(geo_id: str, db_type: str) -> Dict[str, Any]: - """Check if a GEO dataset has been downloaded.""" - try: - dataset_path = root_path / db_type / geo_id - if dataset_path.exists(): - files = list(dataset_path.glob("*")) - total_size = sum(f.stat().st_size for f in files if f.is_file()) - return { - "geo_id": geo_id, - "db_type": db_type, - "downloaded": True, - "path": str(dataset_path), - "files": [f.name for f in files], - "total_size_mb": round(total_size / BYTES_IN_MB, 2) - } - else: - return { - "geo_id": geo_id, - "db_type": db_type, - "downloaded": False, - "path": str(dataset_path) - } - except Exception as e: - return { - "geo_id": geo_id, - "db_type": db_type, - "downloaded": False, - "error": str(e) - } - -def list_downloaded_datasets(db_type: str = None) -> Dict[str, Any]: - """List all downloaded datasets, optionally filtered by database type.""" - try: - datasets = [] - if db_type: - db_path = root_path / db_type - if db_path.exists(): - for dataset_dir in db_path.iterdir(): - if dataset_dir.is_dir(): - datasets.append({ - "geo_id": dataset_dir.name, - "db_type": db_type, - "path": str(dataset_dir) - }) - else: - for db_dir in root_path.iterdir(): - if db_dir.is_dir(): - for dataset_dir in db_dir.iterdir(): - if dataset_dir.is_dir(): - datasets.append({ - "geo_id": dataset_dir.name, - "db_type": db_dir.name, - "path": str(dataset_dir) - }) - - return { - "datasets": datasets, - "count": len(datasets) - } - except Exception as e: - return { - "error": str(e), - "datasets": [], - "count": 0 - } - -def get_download_stats() -> Dict[str, Any]: - """Get overall download statistics and limits.""" - try: - total_size = _dir_size(root_path) - total_size_mb = round(total_size / BYTES_IN_MB, 2) - - return { - "download_dir": str(root_path), - "total_downloaded_mb": total_size_mb, - "max_total_mb": MAX_TOTAL_MB, - "max_file_mb": MAX_FILE_MB, - "max_concurrent": MAX_CONCURRENT, - "timeout_seconds": TIMEOUT, - "allowed_paths": ALLOWED_PATHS, - "disk_free_mb": round(_disk_free(root_path) / BYTES_IN_MB, 2) - } - except Exception as e: - return { - "error": str(e), - "download_dir": str(root_path) - } - -def cleanup_downloads(geo_id: str = None, db_type: str = None) -> Dict[str, Any]: - """Clean up downloaded files.""" - try: - removed = [] - - if geo_id and db_type: - # Remove specific dataset - dataset_path = root_path / db_type / geo_id - if dataset_path.exists(): - shutil.rmtree(dataset_path) - removed.append(str(dataset_path)) - elif db_type: - # Remove all datasets of a specific type - db_path = root_path / db_type - if db_path.exists(): - for dataset_dir in db_path.iterdir(): - if dataset_dir.is_dir(): - shutil.rmtree(dataset_dir) - removed.append(str(dataset_dir)) - else: - # Remove all downloads - if root_path.exists(): - shutil.rmtree(root_path) - removed.append(str(root_path)) - - return { - "removed": removed, - "count": len(removed) - } - except Exception as e: - return { - "error": str(e), - "removed": [], - "count": 0 - } - -# ------------------------------------------------------------------ -# Minimal CLI example -# ------------------------------------------------------------------ -if __name__ == "__main__": - import argparse, json as _json - - parser = argparse.ArgumentParser(description="Download GEO SOFT archives using E‑utilities only") - parser.add_argument("acc", nargs="?", default="GSE10072", help="GEO accession (e.g. GSE10072)") - parser.add_argument("--db", dest="db", default="gse", help="Database type: gse/gsm/gpl/gds") - args = parser.parse_args() - - try: - result = asyncio.run(download_geo(args.acc, args.db)) - print(_json.dumps(result, indent=2)) - except Exception as exc: - print(f"✘ {exc}", file=sys.stderr) - sys.exit(1) diff --git a/geomcp/geo_profiles.py b/geomcp/geo_profiles.py deleted file mode 100644 index a7a6a76..0000000 --- a/geomcp/geo_profiles.py +++ /dev/null @@ -1,431 +0,0 @@ -import json -import os -import requests -from pathlib import Path -import sys -import time - -# Load configuration from JSON file -CONFIG_PATH = os.getenv("CONFIG_PATH", "config.json") - -def load_config(): - """Load configuration from JSON file with fallback to defaults.""" - try: - # Try to load config from the specified path - config_file = Path(CONFIG_PATH) - if not config_file.is_absolute(): - # If relative path, make it relative to the directory containing this script - script_dir = Path(__file__).parent - config_file = script_dir / config_file - - if not config_file.exists(): - print(f"Config file not found: {config_file}", file=sys.stderr) - raise FileNotFoundError(f"Config file not found: {config_file}") - - with open(config_file, 'r') as cfg_file: - return json.load(cfg_file) - except Exception as e: - print(f"Error loading config from {CONFIG_PATH}: {e}", file=sys.stderr) - print("Please run `geo-mcp --init` to create a config file.", file=sys.stderr) - raise e - -def _get_config(): - """Get configuration, loading it when needed.""" - try: - return load_config() - except Exception: - # Return default config for basic functionality - return { - "base_url": "https://eutils.ncbi.nlm.nih.gov/entrez/eutils", - "email": None, - "api_key": None - } - -def _esearch(db: str, term: str, retmax: int = 20) -> dict: - """Perform an ESearch query and return JSON results.""" - config = _get_config() - email = config.get("email") - - # Make email optional with warning - if not email: - print("Warning: No email configured for NCBI E-Utils. Consider adding one for better compliance.", file=sys.stderr) - - params = { - 'db': db, - 'term': term, - 'retmax': retmax, - 'retmode': 'json', - } - - # Only add email if configured - if email: - params['email'] = email - - api_key = config.get("api_key") - if api_key: - params['api_key'] = api_key - - # Add rate limiting to be respectful to NCBI servers - time.sleep(0.1) - - try: - resp = requests.get(f"{config.get('base_url', 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils')}/esearch.fcgi", params=params, timeout=30) - resp.raise_for_status() - return resp.json() - except requests.exceptions.RequestException as e: - print(f"Error during esearch: {e}", file=sys.stderr) - raise - -def _esummary(db: str, ids: list) -> dict: - """Fetch summaries for a list of IDs.""" - if not ids: - return {"result": {}} - - config = _get_config() - email = config.get("email") - - params = { - 'db': db, - 'id': ','.join(map(str, ids)), - 'retmode': 'json', - } - - # Only add email if configured - if email: - params['email'] = email - - api_key = config.get("api_key") - if api_key: - params['api_key'] = api_key - - # Add rate limiting - time.sleep(0.1) - - try: - resp = requests.get(f"{config.get('base_url', 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils')}/esummary.fcgi", params=params, timeout=30) - resp.raise_for_status() - return resp.json() - except requests.exceptions.RequestException as e: - print(f"Error during esummary: {e}", file=sys.stderr) - raise - -def search_geo(term: str, retmax: int = 20, record_types: list = None) -> dict: - """ - Universal GEO search function that finds all relevant records. - - Args: - term: Search term (e.g., "breast cancer", "GSE12345", "RNA-seq") - retmax: Maximum number of results to return - record_types: Optional filter for specific types ["GSE", "GSM", "GPL", "GDS"] - - Returns: - Dict with categorized results by record type - """ - try: - # Search the gds database which contains most GEO records - data = _esearch('gds', term, retmax) - ids = data.get('esearchresult', {}).get('idlist', []) - - if not ids: - return { - "total_count": 0, - "results": [], - "series": [], - "samples": [], - "platforms": [], - "datasets": [] - } - - # Get detailed summaries - summaries = _esummary('gds', ids) - results = summaries.get('result', {}) - - # Categorize results by accession type - categorized = { - "total_count": len(ids), - "results": [], - "series": [], # GSE records - "samples": [], # GSM records - "platforms": [], # GPL records - "datasets": [] # GDS records - } - - for uid in ids: - if uid in results: - record = results[uid] - accession = record.get('accession', '') - - # Add to main results - categorized["results"].append(record) - - # Categorize by type - if accession.startswith('GSE'): - categorized["series"].append(record) - elif accession.startswith('GSM'): - categorized["samples"].append(record) - elif accession.startswith('GPL'): - categorized["platforms"].append(record) - elif accession.startswith('GDS'): - categorized["datasets"].append(record) - - # Filter by record types if specified - if record_types: - record_types = [rt.upper() for rt in record_types] - filtered_results = [] - - if "GSE" in record_types: - filtered_results.extend(categorized["series"]) - if "GSM" in record_types: - filtered_results.extend(categorized["samples"]) - if "GPL" in record_types: - filtered_results.extend(categorized["platforms"]) - if "GDS" in record_types: - filtered_results.extend(categorized["datasets"]) - - categorized["results"] = filtered_results - categorized["total_count"] = len(filtered_results) - - return categorized - - except Exception as e: - print(f"Error in search_geo: {e}", file=sys.stderr) - return { - "total_count": 0, - "results": [], - "series": [], - "samples": [], - "platforms": [], - "datasets": [], - "error": str(e) - } - -def search_geo_profiles(term: str, retmax: int = 20) -> dict: - """Search GEO Profiles - keeping original functionality.""" - try: - data = _esearch('geoprofiles', term, retmax) - ids = data.get('esearchresult', {}).get('idlist', []) - if not ids: - return {"esummaryresult": ["Empty id list - nothing todo"]} - summary = _esummary('geoprofiles', ids) - return summary - except Exception as e: - print(f"Error in search_geo_profiles: {e}", file=sys.stderr) - return {"esummaryresult": ["Empty id list - nothing todo"], "error": str(e)} - -def search_geo_datasets(term: str, retmax: int = 20) -> dict: - """Search for GEO Dataset (GDS) records only.""" - try: - result = search_geo(term, retmax, record_types=["GDS"]) - - # Format to match expected output structure - if result["datasets"]: - formatted_result = { - "header": {"type": "esummary", "version": "0.3"}, - "result": { - "uids": [r.get("uid") for r in result["datasets"]] - } - } - # Add each record to the result dict - for record in result["datasets"]: - uid = record.get("uid") - if uid: - formatted_result["result"][uid] = record - - return formatted_result - else: - return {"esummaryresult": ["Empty id list - nothing todo"]} - - except Exception as e: - print(f"Error in search_geo_datasets: {e}", file=sys.stderr) - return {"esummaryresult": ["Empty id list - nothing todo"], "error": str(e)} - -def search_geo_series(term: str, retmax: int = 20) -> dict: - """Search for GEO Series (GSE) records only.""" - try: - result = search_geo(term, retmax, record_types=["GSE"]) - - # Format to match expected output structure - if result["series"]: - formatted_result = { - "header": {"type": "esummary", "version": "0.3"}, - "result": { - "uids": [r.get("uid") for r in result["series"]] - } - } - # Add each record to the result dict - for record in result["series"]: - uid = record.get("uid") - if uid: - formatted_result["result"][uid] = record - - return formatted_result - else: - return {"esummaryresult": ["Empty id list - nothing todo"]} - - except Exception as e: - print(f"Error in search_geo_series: {e}", file=sys.stderr) - return {"esummaryresult": ["Empty id list - nothing todo"], "error": str(e)} - -def search_geo_samples(term: str, retmax: int = 20) -> dict: - """Search for GEO Sample (GSM) records only.""" - try: - result = search_geo(term, retmax, record_types=["GSM"]) - - # Format to match expected output structure - if result["samples"]: - formatted_result = { - "header": {"type": "esummary", "version": "0.3"}, - "result": { - "uids": [r.get("uid") for r in result["samples"]] - } - } - # Add each record to the result dict - for record in result["samples"]: - uid = record.get("uid") - if uid: - formatted_result["result"][uid] = record - - return formatted_result - else: - return {"esummaryresult": ["Empty id list - nothing todo"]} - - except Exception as e: - print(f"Error in search_geo_samples: {e}", file=sys.stderr) - return {"esummaryresult": ["Empty id list - nothing todo"], "error": str(e)} - -def search_geo_platforms(term: str, retmax: int = 20) -> dict: - """Search for GEO Platform (GPL) records only.""" - try: - result = search_geo(term, retmax, record_types=["GPL"]) - - # Format to match expected output structure - if result["platforms"]: - formatted_result = { - "header": {"type": "esummary", "version": "0.3"}, - "result": { - "uids": [r.get("uid") for r in result["platforms"]] - } - } - # Add each record to the result dict - for record in result["platforms"]: - uid = record.get("uid") - if uid: - formatted_result["result"][uid] = record - - return formatted_result - else: - return {"esummaryresult": ["Empty id list - nothing todo"]} - - except Exception as e: - print(f"Error in search_geo_platforms: {e}", file=sys.stderr) - return {"esummaryresult": ["Empty id list - nothing todo"], "error": str(e)} - -def download_geo_data(geo_id: str, output_dir: str = "downloads") -> str: - """Download GEO data file.""" - try: - # Create output directory if it doesn't exist - Path(output_dir).mkdir(parents=True, exist_ok=True) - - # Determine the correct URL based on GEO ID type - if geo_id.startswith('GSE'): - # Series data - url = f"https://ftp.ncbi.nlm.nih.gov/geo/series/{geo_id[:-3]}nnn/{geo_id}/matrix/{geo_id}_series_matrix.txt.gz" - elif geo_id.startswith('GDS'): - # Dataset data - url = f"https://ftp.ncbi.nlm.nih.gov/geo/datasets/{geo_id[:-3]}nnn/{geo_id}/soft/{geo_id}.soft.gz" - elif geo_id.startswith('GPL'): - # Platform data - url = f"https://ftp.ncbi.nlm.nih.gov/geo/platforms/{geo_id[:-3]}nnn/{geo_id}/annot/{geo_id}.annot.gz" - else: - raise ValueError(f"Unsupported GEO ID format: {geo_id}") - - filename = os.path.join(output_dir, url.split('/')[-1]) - - print(f"Downloading {geo_id} from {url}...", file=sys.stderr) - - response = requests.get(url, stream=True, timeout=300) - response.raise_for_status() - - with open(filename, 'wb') as f: - for chunk in response.iter_content(chunk_size=8192): - f.write(chunk) - - print(f"Downloaded: {filename}", file=sys.stderr) - return filename - - except Exception as e: - print(f"Error downloading {geo_id}: {e}", file=sys.stderr) - raise - -def list_downloaded_datasets(output_dir: str = "downloads") -> list: - """List all downloaded datasets.""" - try: - download_path = Path(output_dir) - if not download_path.exists(): - return [] - - files = list(download_path.glob("*")) - return [str(f) for f in files if f.is_file()] - except Exception as e: - print(f"Error listing downloads: {e}", file=sys.stderr) - return [] - -def get_download_stats(output_dir: str = "downloads") -> dict: - """Get statistics about downloaded files.""" - try: - files = list_downloaded_datasets(output_dir) - total_size = 0 - - for file_path in files: - try: - total_size += Path(file_path).stat().st_size - except OSError: - continue - - return { - "total_files": len(files), - "total_size_bytes": total_size, - "total_size_mb": round(total_size / (1024 * 1024), 2), - "files": files - } - except Exception as e: - print(f"Error getting download stats: {e}", file=sys.stderr) - return {"total_files": 0, "total_size_bytes": 0, "total_size_mb": 0, "files": []} - -if __name__ == '__main__': - # Example usage - try: - term = 'cancer' - print(f"Searching for: {term}") - - # Test the new universal search function - print("\n=== Testing universal search function ===") - all_results = search_geo(term, 10) - print(f"Total found: {all_results['total_count']}") - print(f"- Series (GSE): {len(all_results['series'])}") - print(f"- Samples (GSM): {len(all_results['samples'])}") - print(f"- Platforms (GPL): {len(all_results['platforms'])}") - print(f"- Datasets (GDS): {len(all_results['datasets'])}") - - # Test individual functions - print("\n=== Testing individual search functions ===") - - datasets = search_geo_datasets(term, 5) - dataset_count = len(datasets.get('result', {}).get('uids', [])) - print(f"DataSets found: {dataset_count}") - - series = search_geo_series(term, 5) - series_count = len(series.get('result', {}).get('uids', [])) - print(f"Series found: {series_count}") - - samples = search_geo_samples(term, 5) - samples_count = len(samples.get('result', {}).get('uids', [])) - print(f"Samples found: {samples_count}") - - platforms = search_geo_platforms("Illumina", 5) - platforms_count = len(platforms.get('result', {}).get('uids', [])) - print(f"Illumina platforms found: {platforms_count}") - - except Exception as e: - print(f"Error in main: {e}", file=sys.stderr) - sys.exit(1) \ No newline at end of file diff --git a/geomcp/main.py b/geomcp/main.py deleted file mode 100644 index e65d03d..0000000 --- a/geomcp/main.py +++ /dev/null @@ -1,300 +0,0 @@ -import asyncio -import argparse -import os -import sys -import json -import shutil -from pathlib import Path - -def setup_environment(): - """Set up the environment for the MCP server.""" - # Set the working directory to the script's directory - script_dir = Path(__file__).parent.absolute() - os.chdir(script_dir) - - # Add the script directory to Python path to ensure local imports work - if str(script_dir) not in sys.path: - sys.path.insert(0, str(script_dir)) - - # Set CONFIG_PATH environment variable if not already set - if not os.getenv("CONFIG_PATH"): - config_dir = Path.home() / ".geo-mcp" - config_path = config_dir / "config.json" - os.environ["CONFIG_PATH"] = str(config_path) - - # Get the config path - config_path = Path(os.getenv("CONFIG_PATH", str(Path.home() / ".geo-mcp" / "config.json"))) - - # If config file doesn't exist, create it from template - if not config_path.exists(): - template_path = script_dir / "config_template.json" - if template_path.exists(): - # Create parent directories if they don't exist - config_path.parent.mkdir(parents=True, exist_ok=True) - # Copy template to config location - shutil.copy2(template_path, config_path) - print(f"Created default configuration file at: {config_path}", file=sys.stderr) - else: - print(f"Config file not found: {config_path}", file=sys.stderr) - print(f"Template file not found: {template_path}", file=sys.stderr) - print(f"Current working directory: {os.getcwd()}", file=sys.stderr) - print(f"Available files in current directory: {list(Path('.').glob('*'))}", file=sys.stderr) - sys.exit(1) - - # point any child-spawns at the venv python - venv_bin = os.path.join(os.path.dirname(__file__), ".venv", "bin") - os.environ["PATH"] = venv_bin + os.pathsep + os.environ.get("PATH", "") - -def run_http_server(host: str = "localhost", port: int = 8001): - """Run the HTTP server.""" - import uvicorn - - # Check if we're running as a package or as a script - try: - from .mcp_http_server import app - except ImportError: - # Running as script, use absolute import - from mcp_http_server import app - - print(f"Starting HTTP server on http://{host}:{port}") - uvicorn.run(app, host=host, port=port) - -async def run_mcp_server(): - """Run the MCP stdio server.""" - import mcp.server.stdio - - # Check if we're running as a package or as a script - try: - from .mcp_server import server - except ImportError: - # Running as script, use absolute import - from mcp_server import server - - try: - async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): - await server.run( - read_stream, - write_stream, - server.create_initialization_options() - ) - except Exception as e: - print(f"Error starting MCP server: {e}", file=sys.stderr) - sys.exit(1) - -def init_config(config_path: Path = None): - """Initialize a new configuration file with user input.""" - if config_path is None: - # Use the same logic as setup_environment for consistency - if not os.getenv("CONFIG_PATH"): - config_dir = Path.home() / ".geo-mcp" - config_path = config_dir / "config.json" - else: - config_path = Path(os.getenv("CONFIG_PATH")) - - # Create parent directories if they don't exist - config_path.parent.mkdir(parents=True, exist_ok=True) - - print("GEO MCP Server Configuration Initialization") - print("=" * 50) - - # Get user input - email = input("Enter your email address (required for NCBI E-utilities): ").strip() - if not email: - print("Error: Email address is required!") - sys.exit(1) - - api_key = input("Enter your NCBI API key (optional, press Enter to skip): ").strip() - if not api_key: - api_key = "" - print("Note: Without an API key, you'll be limited to 3 requests/second") - else: - print("Note: With an API key, you'll have 10 requests/second limit") - - # Create config with user input - config = { - "base_url": "https://eutils.ncbi.nlm.nih.gov/entrez/eutils", - "email": email, - "api_key": api_key, - "retmax": 20, - "download_dir": "./downloads", - "max_file_size_mb": 5000, - "max_total_downloads_mb": 10000, - "max_concurrent_downloads": 3, - "download_timeout_seconds": 300, - "allowed_download_paths": ["./downloads", "/tmp/geo_downloads"] - } - - # Find absolute path to geo-mcp executable - geo_mcp_path = shutil.which("geo-mcp") or "geo-mcp" - if geo_mcp_path == "geo-mcp": - print("WARNING: Could not find absolute path to geo-mcp executable. Falling back to 'geo-mcp'.", file=sys.stderr) - - # Write config file - try: - print(f"Creating config file at: {config_path}") - print(f"config: {config}") - with open(config_path, 'w') as f: - json.dump(config, f, indent=4) - print(f"""\ - - Configuration file created successfully at: {config_path} - - You can now run the server with: - {geo_mcp_path} --http - {geo_mcp_path} - - ================================================== - CLAUDE DESKTOP CONFIGURATION - ================================================== - Add the following to your Claude Desktop configuration file: - (Usually located at ~/.config/claude-desktop/config.json) - - WARNING: INSERT CORRECT PATH TO CONFIG FILE BELOW - - {{ - "mcpServers": {{ - "geo-mcp": {{ - "command": "{geo_mcp_path}", - "env": {{ - "CONFIG_PATH": "{config_path}" - }} - }} - }} - }} - - After adding this configuration: - 1. Restart Claude Desktop - 2. You should see GEO tools available in Claude - """) - return True - except Exception as e: - print(f"Error creating config file: {e}") - return False - -def main(): - """Main entry point for the GEO MCP server.""" - parser = argparse.ArgumentParser( - description="GEO MCP Server - Access GEO data through Model Context Protocol", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=""" -Examples: - geo-mcp --init # Initialize configuration file - geo-mcp # Run MCP stdio server - geo-mcp --http # Run HTTP server on localhost:8001 - geo-mcp --http --port 8080 # Run HTTP server on port 8080 - geo-mcp --http --host 0.0.0.0 --port 8080 # Run HTTP server on all interfaces - """ - ) - - parser.add_argument( - "--init", - action="store_true", - help="Initialize configuration file with interactive prompts" - ) - - parser.add_argument( - "--http", - action="store_true", - help="Run HTTP server instead of MCP stdio server" - ) - - parser.add_argument( - "--host", - default="localhost", - help="Host for HTTP server (default: localhost)" - ) - - parser.add_argument( - "--port", - type=int, - default=8001, - help="Port for HTTP server (default: 8001)" - ) - - # Configuration options - parser.add_argument( - "--email", - help="Email address for NCBI E-utilities (required)" - ) - - parser.add_argument( - "--api-key", - help="NCBI API key for higher rate limits (optional)" - ) - - parser.add_argument( - "--retmax", - type=int, - default=20, - help="Maximum number of search results to return (default: 20)" - ) - - parser.add_argument( - "--download-dir", - default="./downloads", - help="Directory where downloads will be stored (default: ./downloads)" - ) - - parser.add_argument( - "--max-file-size-mb", - type=int, - default=5000, - help="Maximum size of individual files to download in MB (default: 5000)" - ) - - parser.add_argument( - "--max-total-downloads-mb", - type=int, - default=10000, - help="Maximum total size of all downloads in MB (default: 10000)" - ) - - parser.add_argument( - "--max-concurrent-downloads", - type=int, - default=3, - help="Maximum number of concurrent downloads (default: 3)" - ) - - parser.add_argument( - "--download-timeout-seconds", - type=int, - default=300, - help="Timeout for download requests in seconds (default: 300)" - ) - - parser.add_argument( - "--allowed-download-paths", - nargs="+", - default=["./downloads", "/tmp/geo_downloads"], - help="List of allowed download paths for security (default: ./downloads /tmp/geo_downloads)" - ) - - parser.add_argument( - "--version", - action="version", - version="geo-mcp 0.1.1" - ) - - args = parser.parse_args() - - # Handle init command first, before any environment setup - if args.init: - success = init_config() - if success: - sys.exit(0) - else: - sys.exit(1) - - # Set up environment only for non-init commands - setup_environment() - - if args.http: - # Run HTTP server - run_http_server(args.host, args.port) - else: - # Run MCP stdio server - asyncio.run(run_mcp_server()) - -if __name__ == "__main__": - main() diff --git a/geomcp/mcp_http_server.py b/geomcp/mcp_http_server.py deleted file mode 100755 index 7b9129b..0000000 --- a/geomcp/mcp_http_server.py +++ /dev/null @@ -1,189 +0,0 @@ -#!/usr/bin/env python3 -"""HTTP gateway for GEO-MCP on http://localhost:8001""" -import asyncio, os, sys -from pathlib import Path -from typing import Dict, Any, List -import json -from collections import deque - -from fastapi import FastAPI, HTTPException, Request -from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import StreamingResponse -from pydantic import BaseModel -import uvicorn -from .mcp_server import server - -# Global event queue for SSE -event_queue = deque(maxlen=100) # Keep last 100 events -connected_clients = set() - -def publish_event(event_type: str, data: Any): - """Publish an event to all connected SSE clients.""" - event = { - "type": event_type, - "data": data, - "timestamp": asyncio.get_event_loop().time() - } - event_queue.append(event) - # Notify all connected clients - for client in connected_clients.copy(): - if not client.is_disconnected(): - client.put_nowait(event) - - -async def _get_tools() -> List[Any]: - """Return the MCP tool-model list or raise.""" - try: - # Import the handle_list_tools function directly - from .mcp_server import handle_list_tools - - # Call the async function directly - tools = await handle_list_tools() - if isinstance(tools, list): - return tools - except Exception as e: - print(f"Error getting tools: {e}", file=sys.stderr) - - # Fallback: try to access tools from server object - for attr in ("tools", "_tools"): - if hasattr(server, attr): - tools = getattr(server, attr) - if isinstance(tools, list): - return tools - - try: - maybe = server.list_tools() - if isinstance(maybe, list): - return maybe - except TypeError: - pass - - raise RuntimeError("Could not locate tool registry in mcp_server.server") - - -# Create the FastAPI app at module level -app = FastAPI(title="GEO MCP Server", version="1.0.0") -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], allow_methods=["*"], allow_headers=["*"] -) - -class ToolCallRequest(BaseModel): - name: str - arguments: Dict[str, Any] - -class ToolCallResponse(BaseModel): - result: List[Dict[str, Any]] - -@app.get("/") -async def root(): - return {"status": "healthy"} - -@app.get("/tools", response_model=List[Dict[str, Any]]) -async def list_tools(): - try: - return [t.model_dump() for t in await _get_tools()] - except Exception as e: - raise HTTPException(500, f"Error listing tools: {e}") - -@app.post("/tools/call", response_model=ToolCallResponse) -async def call_tool(req: ToolCallRequest): - try: - # Publish tool call start event - publish_event("tool_call_start", { - "tool": req.name, - "arguments": req.arguments - }) - - # Import the handle_call_tool function directly - from .mcp_server import handle_call_tool - - # Call the async function - out = await handle_call_tool(req.name, req.arguments) - result = [o.model_dump() for o in out] - - # Publish tool call completion event - publish_event("tool_call_complete", { - "tool": req.name, - "arguments": req.arguments, - "result": result - }) - - return ToolCallResponse(result=result) - except ValueError as e: - # Publish error event - publish_event("tool_call_error", { - "tool": req.name, - "arguments": req.arguments, - "error": str(e) - }) - raise HTTPException(400, f"Invalid tool call: {e}") - except Exception as e: - # Publish error event - publish_event("tool_call_error", { - "tool": req.name, - "arguments": req.arguments, - "error": str(e) - }) - raise HTTPException(500, f"Error calling tool: {e}") - -@app.get("/health") -async def health(): - try: - return {"status": "healthy", "tools_available": len(await _get_tools())} - except Exception as e: - return {"status": "error", "error": str(e)} - -@app.get("/events") -async def events(request: Request): - # Create a queue for this client - client_queue = asyncio.Queue() - connected_clients.add(client_queue) - - try: - # Send initial connection event - await client_queue.put({ - "type": "connection_established", - "data": {"message": "Connected to GEO-MCP server"}, - "timestamp": asyncio.get_event_loop().time() - }) - - # Send recent events (last 10) - recent_events = list(event_queue)[-10:] - for event in recent_events: - await client_queue.put(event) - - async def event_generator(): - while True: - if await request.is_disconnected(): - break - - try: - # Wait for new events with timeout - event = await asyncio.wait_for(client_queue.get(), timeout=30.0) - yield f"data: {json.dumps(event)}\n\n" - except asyncio.TimeoutError: - # Send heartbeat to keep connection alive - yield f"data: {json.dumps({'type': 'heartbeat', 'data': {}, 'timestamp': asyncio.get_event_loop().time()})}\n\n" - except Exception as e: - # Send error event - yield f"data: {json.dumps({'type': 'error', 'data': {'error': str(e)}, 'timestamp': asyncio.get_event_loop().time()})}\n\n" - break - - return StreamingResponse(event_generator(), media_type="text/event-stream") - - finally: - # Clean up when client disconnects - connected_clients.discard(client_queue) - - -async def main() -> None: - os.chdir(Path(__file__).parent) - os.environ.setdefault("CONFIG_PATH", str(Path("config.json"))) - - print("Starting GEO-MCP HTTP server on http://localhost:8001") - await uvicorn.Server(uvicorn.Config(app, host="localhost", port=8001, log_level="info")).serve() - - -if __name__ == "__main__": - asyncio.run(main()) \ No newline at end of file diff --git a/geomcp/mcp_server.py b/geomcp/mcp_server.py deleted file mode 100644 index 89c8c51..0000000 --- a/geomcp/mcp_server.py +++ /dev/null @@ -1,544 +0,0 @@ -#!/usr/bin/env python3 -""" -MCP Server for GEO (Gene Expression Omnibus) Data -Handles MCP protocol and tool definitions for accessing GEO data through NCBI E-Utils -""" - -import json -import logging -import asyncio -from typing import Any, Dict, List -from mcp.server import Server -import mcp.types as types -from . import geo_profiles, geo_downloader - -logger = logging.getLogger("geo-mcp-server") - - -class GEOMCPServer: - """ - MCP Server implementation for GEO Data Access - Handles MCP protocol and tool definitions for Gene Expression Omnibus - """ - - def __init__(self): - self.server = Server("geo-mcp") - self._setup_tools() - - def _setup_tools(self): - """Register all available GEO tools""" - - @self.server.call_tool() - async def search_geo(arguments: dict) -> list[types.TextContent]: - """Search GEO for all types of records (GSE, GSM, GPL, GDS)""" - term = arguments.get("term", "") - retmax = arguments.get("retmax", 20) - record_types = arguments.get("record_types") - - if not term: - raise ValueError("term parameter is required") - - result = geo_profiles.search_geo(term, retmax, record_types) - - return [types.TextContent( - type="text", - text=json.dumps(result, indent=2) - )] - - # GEO Profiles search - @self.server.call_tool() - async def search_geo_profiles(arguments: dict) -> list[types.TextContent]: - """Search GEO Profiles database""" - term = arguments.get("term", "") - retmax = arguments.get("retmax", 20) - - if not term: - raise ValueError("term parameter is required") - - result = geo_profiles.search_geo_profiles(term, retmax) - - return [types.TextContent( - type="text", - text=json.dumps(result, indent=2) - )] - - # GEO Datasets search - @self.server.call_tool() - async def search_geo_datasets(arguments: dict) -> list[types.TextContent]: - """Search GEO Datasets (GDS) specifically""" - term = arguments.get("term", "") - retmax = arguments.get("retmax", 20) - - if not term: - raise ValueError("term parameter is required") - - result = geo_profiles.search_geo_datasets(term, retmax) - - return [types.TextContent( - type="text", - text=json.dumps(result, indent=2) - )] - - # GEO Series search - @self.server.call_tool() - async def search_geo_series(arguments: dict) -> list[types.TextContent]: - """Search GEO Series (GSE) specifically""" - term = arguments.get("term", "") - retmax = arguments.get("retmax", 20) - - if not term: - raise ValueError("term parameter is required") - - result = geo_profiles.search_geo_series(term, retmax) - - return [types.TextContent( - type="text", - text=json.dumps(result, indent=2) - )] - - # GEO Samples search - @self.server.call_tool() - async def search_geo_samples(arguments: dict) -> list[types.TextContent]: - """Search GEO Samples (GSM) specifically""" - term = arguments.get("term", "") - retmax = arguments.get("retmax", 20) - - if not term: - raise ValueError("term parameter is required") - - result = geo_profiles.search_geo_samples(term, retmax) - - return [types.TextContent( - type="text", - text=json.dumps(result, indent=2) - )] - - # GEO Platforms search - @self.server.call_tool() - async def search_geo_platforms(arguments: dict) -> list[types.TextContent]: - """Search GEO Platforms (GPL) specifically""" - term = arguments.get("term", "") - retmax = arguments.get("retmax", 20) - - if not term: - raise ValueError("term parameter is required") - - result = geo_profiles.search_geo_platforms(term, retmax) - - return [types.TextContent( - type="text", - text=json.dumps(result, indent=2) - )] - - # Download GEO data - @self.server.call_tool() - async def download_geo_data(arguments: dict) -> list[types.TextContent]: - """Download GEO data files""" - geo_id = arguments.get("geo_id", "") - db_type = arguments.get("db_type", "gse") - output_dir = arguments.get("output_dir") - - if not geo_id: - raise ValueError("geo_id parameter is required") - - result = await geo_downloader.download_geo(geo_id, db_type, output_dir) - - return [types.TextContent( - type="text", - text=json.dumps(result, indent=2) - )] - - # Get download status - @self.server.call_tool() - async def get_download_status(arguments: dict) -> list[types.TextContent]: - """Check download status of a GEO dataset""" - geo_id = arguments.get("geo_id", "") - db_type = arguments.get("db_type", "gse") - - if not geo_id: - raise ValueError("geo_id parameter is required") - - result = geo_downloader.get_download_status(geo_id, db_type) - - return [types.TextContent( - type="text", - text=json.dumps(result, indent=2) - )] - - # List downloaded datasets - @self.server.call_tool() - async def list_downloaded_datasets(arguments: dict) -> list[types.TextContent]: - """List all downloaded GEO datasets""" - db_type = arguments.get("db_type") - - result = geo_downloader.list_downloaded_datasets(db_type) - - return [types.TextContent( - type="text", - text=json.dumps(result, indent=2) - )] - - # Get download statistics - @self.server.call_tool() - async def get_download_stats(arguments: dict) -> list[types.TextContent]: - """Get download statistics and limits""" - result = geo_downloader.get_download_stats() - - return [types.TextContent( - type="text", - text=json.dumps(result, indent=2) - )] - - # Cleanup downloads - @self.server.call_tool() - async def cleanup_downloads_tool(arguments: dict) -> list[types.TextContent]: - """Clean up downloaded files""" - geo_id = arguments.get("geo_id") - db_type = arguments.get("db_type") - - result = geo_downloader.cleanup_downloads(geo_id, db_type) - - return [types.TextContent( - type="text", - text=json.dumps(result, indent=2) - )] - - def get_server(self) -> Server: - """Get the configured MCP server""" - return self.server - - def get_tool_definitions(self) -> List[types.Tool]: - """Get all tool definitions for the GEO MCP server""" - return [ - # Universal GEO search - types.Tool( - name="search_geo", - description="Search GEO for all types of records (GSE, GSM, GPL, GDS)", - inputSchema={ - "type": "object", - "properties": { - "term": { - "type": "string", - "description": "Search term (e.g., 'breast cancer', 'GSE12345', 'RNA-seq')" - }, - "retmax": { - "type": "integer", - "description": "Maximum number of results to return (default: 20)", - "default": 20 - }, - "record_types": { - "type": "array", - "items": {"type": "string"}, - "description": "Filter for specific record types: GSE, GSM, GPL, GDS" - } - }, - "required": ["term"] - } - ), - - # GEO Profiles search - types.Tool( - name="search_geo_profiles", - description="Search GEO Profiles database for gene expression profiles", - inputSchema={ - "type": "object", - "properties": { - "term": { - "type": "string", - "description": "Search term for GEO Profiles" - }, - "retmax": { - "type": "integer", - "description": "Maximum number of results to return (default: 20)", - "default": 20 - } - }, - "required": ["term"] - } - ), - - # GEO Datasets search - types.Tool( - name="search_geo_datasets", - description="Search GEO Datasets (GDS) - curated gene expression datasets", - inputSchema={ - "type": "object", - "properties": { - "term": { - "type": "string", - "description": "Search term for GEO Datasets" - }, - "retmax": { - "type": "integer", - "description": "Maximum number of results to return (default: 20)", - "default": 20 - } - }, - "required": ["term"] - } - ), - - # GEO Series search - types.Tool( - name="search_geo_series", - description="Search GEO Series (GSE) - complete experiments", - inputSchema={ - "type": "object", - "properties": { - "term": { - "type": "string", - "description": "Search term for GEO Series" - }, - "retmax": { - "type": "integer", - "description": "Maximum number of results to return (default: 20)", - "default": 20 - } - }, - "required": ["term"] - } - ), - - # GEO Samples search - types.Tool( - name="search_geo_samples", - description="Search GEO Samples (GSM) - individual samples", - inputSchema={ - "type": "object", - "properties": { - "term": { - "type": "string", - "description": "Search term for GEO Samples" - }, - "retmax": { - "type": "integer", - "description": "Maximum number of results to return (default: 20)", - "default": 20 - } - }, - "required": ["term"] - } - ), - - # GEO Platforms search - types.Tool( - name="search_geo_platforms", - description="Search GEO Platforms (GPL) - array/sequencing platforms", - inputSchema={ - "type": "object", - "properties": { - "term": { - "type": "string", - "description": "Search term for GEO Platforms" - }, - "retmax": { - "type": "integer", - "description": "Maximum number of results to return (default: 20)", - "default": 20 - } - }, - "required": ["term"] - } - ), - - # Download tool - types.Tool( - name="download_geo_data", - description="Download GEO data files (SOFT format)", - inputSchema={ - "type": "object", - "properties": { - "geo_id": { - "type": "string", - "description": "GEO accession ID (e.g., GSE12345, GSM789, GPL456, GDS123)" - }, - "db_type": { - "type": "string", - "description": "Database type: gse, gsm, gpl, or gds (default: gse)", - "default": "gse" - }, - "output_dir": { - "type": "string", - "description": "Optional custom output directory" - } - }, - "required": ["geo_id"] - } - ), - - # Download status - types.Tool( - name="get_download_status", - description="Check if a GEO dataset has been downloaded", - inputSchema={ - "type": "object", - "properties": { - "geo_id": { - "type": "string", - "description": "GEO accession ID" - }, - "db_type": { - "type": "string", - "description": "Database type: gse, gsm, gpl, or gds (default: gse)", - "default": "gse" - } - }, - "required": ["geo_id"] - } - ), - - # List downloads - types.Tool( - name="list_downloaded_datasets", - description="List all downloaded GEO datasets", - inputSchema={ - "type": "object", - "properties": { - "db_type": { - "type": "string", - "description": "Optional filter by database type: gse, gsm, gpl, or gds" - } - } - } - ), - - # Download stats - types.Tool( - name="get_download_stats", - description="Get download statistics and limits", - inputSchema={ - "type": "object", - "properties": {} - } - ), - - # Cleanup downloads - types.Tool( - name="cleanup_downloads_tool", - description="Clean up downloaded files", - inputSchema={ - "type": "object", - "properties": { - "geo_id": { - "type": "string", - "description": "Optional specific GEO ID to remove" - }, - "db_type": { - "type": "string", - "description": "Optional database type filter for cleanup" - } - } - } - ) - ] - - -# Create the server instance -mcp_server = GEOMCPServer() -server = mcp_server.get_server() - -# List tools for Claude Desktop integration -@server.list_tools() -async def handle_list_tools() -> list[types.Tool]: - """Return the list of available tools""" - return mcp_server.get_tool_definitions() - -# Handle tool calls for HTTP server -async def handle_call_tool(name: str, arguments: dict): - """Handle tool calls from HTTP server""" - # Get the server's tool handlers - server_instance = mcp_server.get_server() - - # Call the tool through the server - from mcp.types import CallToolRequest - request = CallToolRequest( - method="tools/call", - params={ - "name": name, - "arguments": arguments - } - ) - - # Simulate calling the tool - if hasattr(server_instance, '_call_tool_handlers') and name in server_instance._call_tool_handlers: - handler = server_instance._call_tool_handlers[name] - return await handler(arguments) - else: - # Fallback: call functions directly - if name == "search_geo": - from . import geo_profiles - result = geo_profiles.search_geo( - arguments.get("term", ""), - arguments.get("retmax", 20), - arguments.get("record_types") - ) - return [types.TextContent(type="text", text=json.dumps(result, indent=2))] - elif name == "search_geo_profiles": - from . import geo_profiles - result = geo_profiles.search_geo_profiles( - arguments.get("term", ""), - arguments.get("retmax", 20) - ) - return [types.TextContent(type="text", text=json.dumps(result, indent=2))] - elif name == "search_geo_datasets": - from . import geo_profiles - result = geo_profiles.search_geo_datasets( - arguments.get("term", ""), - arguments.get("retmax", 20) - ) - return [types.TextContent(type="text", text=json.dumps(result, indent=2))] - elif name == "search_geo_series": - from . import geo_profiles - result = geo_profiles.search_geo_series( - arguments.get("term", ""), - arguments.get("retmax", 20) - ) - return [types.TextContent(type="text", text=json.dumps(result, indent=2))] - elif name == "search_geo_samples": - from . import geo_profiles - result = geo_profiles.search_geo_samples( - arguments.get("term", ""), - arguments.get("retmax", 20) - ) - return [types.TextContent(type="text", text=json.dumps(result, indent=2))] - elif name == "search_geo_platforms": - from . import geo_profiles - result = geo_profiles.search_geo_platforms( - arguments.get("term", ""), - arguments.get("retmax", 20) - ) - return [types.TextContent(type="text", text=json.dumps(result, indent=2))] - elif name == "download_geo_data": - from . import geo_downloader - result = await geo_downloader.download_geo( - arguments.get("geo_id", ""), - arguments.get("db_type", "gse"), - arguments.get("output_dir") - ) - return [types.TextContent(type="text", text=json.dumps(result, indent=2))] - elif name == "get_download_status": - from . import geo_downloader - result = geo_downloader.get_download_status( - arguments.get("geo_id", ""), - arguments.get("db_type", "gse") - ) - return [types.TextContent(type="text", text=json.dumps(result, indent=2))] - elif name == "list_downloaded_datasets": - from . import geo_downloader - result = geo_downloader.list_downloaded_datasets( - arguments.get("db_type") - ) - return [types.TextContent(type="text", text=json.dumps(result, indent=2))] - elif name == "get_download_stats": - from . import geo_downloader - result = geo_downloader.get_download_stats() - return [types.TextContent(type="text", text=json.dumps(result, indent=2))] - elif name == "cleanup_downloads_tool": - from . import geo_downloader - result = geo_downloader.cleanup_downloads( - arguments.get("geo_id"), - arguments.get("db_type") - ) - return [types.TextContent(type="text", text=json.dumps(result, indent=2))] - else: - raise ValueError(f"Unknown tool: {name}") \ No newline at end of file From f2470a3541b321a758a903288ecbbfeffec56670 Mon Sep 17 00:00:00 2001 From: DualK <92998962+KQDtianxiaK@users.noreply.github.com> Date: Tue, 24 Feb 2026 02:10:41 +0800 Subject: [PATCH 04/13] Delete .github directory --- .github/WORKFLOWS.md | 107 --------------------------- .github/workflows/dev-release.yml | 52 ------------- .github/workflows/pypi-publish.yml | 23 ------ .github/workflows/python-publish.yml | 50 ------------- .github/workflows/test.yml | 68 ----------------- 5 files changed, 300 deletions(-) delete mode 100644 .github/WORKFLOWS.md delete mode 100644 .github/workflows/dev-release.yml delete mode 100644 .github/workflows/pypi-publish.yml delete mode 100644 .github/workflows/python-publish.yml delete mode 100644 .github/workflows/test.yml diff --git a/.github/WORKFLOWS.md b/.github/WORKFLOWS.md deleted file mode 100644 index 8ce2136..0000000 --- a/.github/WORKFLOWS.md +++ /dev/null @@ -1,107 +0,0 @@ -# GitHub Actions Workflows - -This repository includes several GitHub Actions workflows for automated testing, building, and releasing the GEO MCP Server package. - -## Workflows - -### 1. Test and Build (`test.yml`) -- **Triggers**: Push to `main`/`develop` branches, Pull Requests to `main` -- **Purpose**: Runs tests across multiple Python versions and builds the package -- **Actions**: - - Tests the package on Python 3.9, 3.10, 3.11, and 3.12 - - Builds the package (only on pushes to main) - - Uploads build artifacts - -### 2. Release Package (`release.yml`) -- **Triggers**: When a GitHub release is published -- **Purpose**: Publishes stable releases to PyPI -- **Actions**: - - Builds the package - - Publishes to PyPI - - Creates GitHub release assets - -### 3. Development Release (`dev-release.yml`) -- **Triggers**: When a GitHub pre-release is published (tags containing `dev`, `alpha`, `beta`, or `rc`) -- **Purpose**: Publishes development releases to TestPyPI -- **Actions**: - - Builds the package - - Publishes to TestPyPI - - Creates GitHub pre-release assets - -## Setup Instructions - -### 1. PyPI API Token -To publish to PyPI, you need to create an API token: - -1. Go to [PyPI Account Settings](https://pypi.org/manage/account/) -2. Create a new API token with "Entire account" scope -3. Add the token to your GitHub repository secrets: - - Go to your repository → Settings → Secrets and variables → Actions - - Create a new secret named `PYPI_API_TOKEN` - - Paste your PyPI API token - -### 2. TestPyPI API Token (Optional) -For development releases, you can also set up TestPyPI: - -1. Go to [TestPyPI Account Settings](https://test.pypi.org/manage/account/) -2. Create a new API token -3. Add the token to your GitHub repository secrets as `TEST_PYPI_API_TOKEN` - -### 3. GitHub Token -The `GITHUB_TOKEN` is automatically provided by GitHub Actions, so no setup is needed. - -## Usage - -### Creating a Release -1. Create a new release on GitHub -2. Tag it with a version number (e.g., `v1.0.0`) -3. Publish the release -4. The workflow will automatically build and publish to PyPI - -### Creating a Development Release -1. Create a new release on GitHub -2. Tag it with a development version (e.g., `v1.0.0-dev.1`, `v1.0.0-alpha.1`) -3. Mark it as a pre-release -4. Publish the release -5. The workflow will automatically build and publish to TestPyPI - -### Testing -- Push to `main` or `develop` branches to trigger tests -- Create pull requests to `main` to trigger tests -- Tests run on multiple Python versions to ensure compatibility - -## Version Management - -The package uses [hatch-vcs](https://github.com/ofek/hatch-vcs) for automatic version detection from git tags. The version format follows PEP 440: - -- `0.1.dev1+g91ddaa4.d20250623` - Development version -- `0.1.0` - Stable release -- `0.1.0-dev.1` - Development release -- `0.1.0-alpha.1` - Alpha release -- `0.1.0-beta.1` - Beta release -- `0.1.0-rc.1` - Release candidate - -## Troubleshooting - -### Common Issues - -1. **Build fails**: Check that all dependencies are properly specified in `pyproject.toml` -2. **PyPI upload fails**: Verify your `PYPI_API_TOKEN` is correct and has proper permissions -3. **Tests fail**: Ensure all test dependencies are installed and tests are properly configured - -### Manual Release -If you need to release manually: - -```bash -# Build the package -python -m build - -# Check the package -twine check dist/* - -# Upload to PyPI -twine upload dist/* - -# Upload to TestPyPI (for development releases) -twine upload --repository testpypi dist/* -``` \ No newline at end of file diff --git a/.github/workflows/dev-release.yml b/.github/workflows/dev-release.yml deleted file mode 100644 index e77d7cc..0000000 --- a/.github/workflows/dev-release.yml +++ /dev/null @@ -1,52 +0,0 @@ -name: Development Release - -on: - release: - types: [published] - -jobs: - dev-release: - runs-on: ubuntu-latest - if: contains(github.event.release.tag_name, 'dev') || contains(github.event.release.tag_name, 'alpha') || contains(github.event.release.tag_name, 'beta') || contains(github.event.release.tag_name, 'rc') - - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: '3.11' - - - name: Install build dependencies - run: | - python -m pip install --upgrade pip - pip install build twine - - - name: Build package - run: | - python -m build - - - name: Check package - run: | - twine check dist/* - - - name: Publish to TestPyPI - env: - TWINE_USERNAME: __token__ - TWINE_PASSWORD: ${{ secrets.TEST_PYPI_API_TOKEN }} - run: | - twine upload --repository testpypi dist/* - - - name: Create Release Assets - uses: softprops/action-gh-release@v1 - with: - files: | - dist/*.whl - dist/*.tar.gz - draft: false - prerelease: true - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml deleted file mode 100644 index 1e12b6f..0000000 --- a/.github/workflows/pypi-publish.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: Publish to PyPI - -on: - release: - types: [published] - -jobs: - deploy: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: '3.11' - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install build - - name: Build package - run: python -m build - - name: Publish package - uses: pypa/gh-action-pypi-publish@release/v1 \ No newline at end of file diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml deleted file mode 100644 index 8983ea2..0000000 --- a/.github/workflows/python-publish.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: Publish Python Package - -on: - release: - types: [published] - -jobs: - build-and-publish: - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 # Required for version detection - - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: '3.11' - - - name: Install build dependencies - run: | - python -m pip install --upgrade pip - pip install build twine - - - name: Build package - run: | - python -m build - - - name: Check package - run: | - twine check dist/* - - - name: Publish to PyPI - env: - TWINE_USERNAME: __token__ - TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} - run: | - twine upload dist/* - - - name: Create Release Assets - uses: softprops/action-gh-release@v1 - with: - files: | - dist/*.whl - dist/*.tar.gz - draft: false - prerelease: false - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index 6c8f907..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,68 +0,0 @@ -name: Test and Build - -on: - push: - branches: [ main, develop ] - pull_request: - branches: [ main ] - -jobs: - test: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: ['3.10', '3.11', '3.12'] - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -e . - pip install pytest pytest-asyncio - - - name: Run tests - run: | - pytest geo_mcp_server/test/ -v --tb=short - - build: - runs-on: ubuntu-latest - needs: test - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: '3.11' - - - name: Install build dependencies - run: | - python -m pip install --upgrade pip - pip install build twine - - - name: Build package - run: | - python -m build - - - name: Check package - run: | - twine check dist/* - - - name: Upload build artifacts - uses: actions/upload-artifact@v4 - with: - name: dist-files - path: dist/ \ No newline at end of file From a9d394d4d88c250b5100aa1df9f33cafd41a585e Mon Sep 17 00:00:00 2001 From: DualK <92998962+KQDtianxiaK@users.noreply.github.com> Date: Tue, 24 Feb 2026 02:10:52 +0800 Subject: [PATCH 05/13] Delete .gitignore --- .gitignore | 159 ----------------------------------------------------- 1 file changed, 159 deletions(-) delete mode 100644 .gitignore diff --git a/.gitignore b/.gitignore deleted file mode 100644 index cc3c6e8..0000000 --- a/.gitignore +++ /dev/null @@ -1,159 +0,0 @@ -# Configuration files -geo_mcp_server/config.json -claude_desktop_config.json -mcp_config.json -*.json -*.txt -*.csv -*.tsv -*.fasta -*.fastq -*.fq -# Python -__pycache__/ -*.py[cod] -*$py.class -*.so -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Virtual environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# IDE -.vscode/ -.idea/ -*.swp -*.swo -*~ - -# macOS -.DS_Store -.AppleDouble -.LSOverride - -# Windows -Thumbs.db -ehthumbs.db -Desktop.ini - -# Logs -*.log -logs/ - -# Temporary files -*.tmp -*.temp -.cursor* - -# Mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Ruff -.ruff_cache/ - -# Black -.black_cache/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -.python-version - -# pipenv -Pipfile.lock - -# poetry -poetry.lock - -# uv -uv.lock - -# pdm -.pdm.toml - -# PEP 582 -__pypackages__/ - -# Celery -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ \ No newline at end of file From c3be096d17da72edc43827eb3d9655e82cc48891 Mon Sep 17 00:00:00 2001 From: DualK <92998962+KQDtianxiaK@users.noreply.github.com> Date: Tue, 24 Feb 2026 02:11:03 +0800 Subject: [PATCH 06/13] Delete pytest.ini --- pytest.ini | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100644 pytest.ini diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index d69da20..0000000 --- a/pytest.ini +++ /dev/null @@ -1,10 +0,0 @@ -[tool:pytest] -testpaths = geo_mcp_server/test -python_files = test_*.py -python_classes = Test* -python_functions = test_* -addopts = -v --tb=short -asyncio_mode = auto -filterwarnings = - ignore::DeprecationWarning - ignore::PendingDeprecationWarning \ No newline at end of file From 2ec716a310c60dd03a2e07e5e6afcc5077cb0774 Mon Sep 17 00:00:00 2001 From: DualK <92998962+KQDtianxiaK@users.noreply.github.com> Date: Tue, 24 Feb 2026 02:11:11 +0800 Subject: [PATCH 07/13] Delete pyproject.toml --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index d6c9ae2..0fe5662 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["hatchling", "hatch-vcs"] build-backend = "hatchling.build" [project] -name = "geo-mcp" +name = "geo-mcp-sra" dynamic = ["version"] description = "An enhanced MCP server for accessing GEO data with SRA raw sequencing support" readme = "README.md" From 4a0a4acce9011d0b87ce8d59e2062845fc132673 Mon Sep 17 00:00:00 2001 From: DualK <92998962+KQDtianxiaK@users.noreply.github.com> Date: Tue, 24 Feb 2026 02:11:18 +0800 Subject: [PATCH 08/13] Delete claude_desktop_config_example.json --- claude_desktop_config_example.json | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100644 claude_desktop_config_example.json diff --git a/claude_desktop_config_example.json b/claude_desktop_config_example.json deleted file mode 100644 index 2b9b50c..0000000 --- a/claude_desktop_config_example.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "mcpServers": { - "geo-bio-mcp": { - "command": "/path_to_envs/geo-mcp-server/bin/geo-bio-mcp", - "env": { - "CONFIG_PATH": "/path_to_user_home/.geo-bio-mcp/config.json" - } - } - } -} From d215e08efd33fc44e2607bc644b982850e84b133 Mon Sep 17 00:00:00 2001 From: KQDtianxiaK <1720573356@qq.com> Date: Wed, 25 Feb 2026 01:11:13 +0800 Subject: [PATCH 09/13] delete wrong path files --- GEO/geo_mcp_server/README.md | 410 ------- GEO/geo_mcp_server/SKILL.md | 245 ---- GEO/geo_mcp_server/config.json | 17 - GEO/geo_mcp_server/geomcp_sra/__init__.py | 10 - GEO/geo_mcp_server/geomcp_sra/config.py | 159 --- GEO/geo_mcp_server/geomcp_sra/geo_download.py | 463 -------- GEO/geo_mcp_server/geomcp_sra/geo_search.py | 384 ------ GEO/geo_mcp_server/geomcp_sra/sra_handler.py | 1040 ----------------- GEO/geo_mcp_server/pyproject.toml | 72 -- GEO/geo_mcp_server/requirements.txt | 4 - GEO/geo_mcp_server/server.py | 1031 ---------------- 11 files changed, 3835 deletions(-) delete mode 100644 GEO/geo_mcp_server/README.md delete mode 100644 GEO/geo_mcp_server/SKILL.md delete mode 100644 GEO/geo_mcp_server/config.json delete mode 100644 GEO/geo_mcp_server/geomcp_sra/__init__.py delete mode 100644 GEO/geo_mcp_server/geomcp_sra/config.py delete mode 100644 GEO/geo_mcp_server/geomcp_sra/geo_download.py delete mode 100644 GEO/geo_mcp_server/geomcp_sra/geo_search.py delete mode 100644 GEO/geo_mcp_server/geomcp_sra/sra_handler.py delete mode 100644 GEO/geo_mcp_server/pyproject.toml delete mode 100644 GEO/geo_mcp_server/requirements.txt delete mode 100644 GEO/geo_mcp_server/server.py diff --git a/GEO/geo_mcp_server/README.md b/GEO/geo_mcp_server/README.md deleted file mode 100644 index 925980f..0000000 --- a/GEO/geo_mcp_server/README.md +++ /dev/null @@ -1,410 +0,0 @@ -# GEO MCP Server with SRA Support - -An enhanced [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server for accessing **GEO (Gene Expression Omnibus)** data through NCBI E-Utils API, with comprehensive support for **SRA (Sequence Read Archive)** raw sequencing data downloads and conversion. - -## Features - -### GEO Data Access -- 🔍 **Search GEO databases**: Series (GSE), Samples (GSM), Platforms (GPL), Datasets (GDS), and Profiles -- 📥 **Download metadata**: SOFT format, Series Matrix, MINiML XML -- 📦 **Download supplementary files**: Processed data tables, raw array data - -### SRA Raw Sequencing Data (NEW) -- 🔗 **Query SRA from GEO**: Map GEO Samples (GSM) to SRA Runs (SRR) -- 📊 **Size estimation**: Dry-run mode to check file sizes before downloading -- 💾 **Download & convert**: Integrated prefetch + fastq-dump workflow -- 🛡️ **Safety constraints**: Size warnings and confirmation requirements -- ✅ **Check sra-toolkit**: Verify installation and get setup instructions - -## Safety Features - -Following [maintainer recommendations](https://github.com/MCPmed/GEOmcp/issues/1), this server implements safety constraints for large file downloads: - -| Constraint | Implementation | -|------------|----------------| -| **Safe by Default** | `dry_run=True` by default - only estimates sizes | -| **Dry-Run Mode** | `sra_estimate_size()` tool for size checking | -| **Explicit Output** | Required `output_dir` for files >1GB | -| **Confirmation** | `confirm_large=True` required for files >5GB | - -## Installation - -### Prerequisites -- Python 3.10 or higher -- (Optional) [SRA Toolkit](https://github.com/ncbi/sra-tools) for downloading raw FASTQ files - -### Install from Source -```bash -# Clone the repository -git clone https://github.com/yourusername/geo-mcp-server.git -cd geo-mcp-server - -# Install dependencies -pip install -e . -``` - -### Configuration - -1. **Initialize configuration:** -```bash -python server.py --init -``` - -2. **Edit the config file** at `~/.geo-mcp/config.json`: -```json -{ - "email": "your_email@example.com", - "api_key": "YOUR_NCBI_API_KEY (optional)", - "download_dir": "~/geo_downloads", - "sra_toolkit_path": "/path/to/sra-toolkit/bin (optional)" -} -``` - -> **Note:** NCBI requires an email address for E-Utils access. An API key is optional but recommended for higher rate limits (10 req/s vs 3 req/s). Get one at [NCBI](https://ncbiinsights.ncbi.nlm.nih.gov/2017/11/02/new-api-keys-for-the-e-utilities/). - -## Usage - -### Running the Server - -**MCP stdio mode** (for Claude Desktop): -```bash -python server.py -``` - -**HTTP mode**: -```bash -python server.py --http --port 8000 -``` - -### Claude Desktop Integration - -Add to your Claude Desktop configuration (`~/.config/claude-desktop/config.json`): - -```json -{ - "mcpServers": { - "geo_mcp": { - "command": "python", - "args": ["/path/to/geo_mcp_server/server.py"], - "env": { - "CONFIG_PATH": "/home/yourusername/.geo-mcp/config.json" - } - } - } -} -``` - -## Available Tools (18 Total) - -### GEO Search Tools - -| Tool | Description | -|------|-------------| -| `geo_search` | Search all GEO record types with natural language (e.g., "Human RNA-seq") | -| `geo_search_series` | Search GEO Series (GSE) - complete experiments | -| `geo_search_samples` | Search GEO Samples (GSM) - individual samples | -| `geo_search_platforms` | Search GEO Platforms (GPL) - array/sequencing platforms | -| `geo_search_datasets` | Search GEO Datasets (GDS) - curated gene expression | -| `geo_search_profiles` | Search GEO Profiles - gene expression profiles | - -### GEO Download Tools - -| Tool | Description | -|------|-------------| -| `geo_download_series` | Download GSE data files (SOFT, matrix, supplementary) | -| `geo_download_sample` | Download GSM supplementary files | -| `geo_get_download_status` | Check if a GEO dataset has been downloaded | -| `geo_list_downloads` | List all downloaded datasets | -| `geo_cleanup_downloads` | Clean up downloaded files | - -### SRA Tools - -| Tool | Description | -|------|-------------| -| `sra_query_from_geo` | Query SRA Run information from a GEO Series | -| `sra_get_metadata` | Get SRA run metadata | -| `sra_estimate_size` | **NEW** Estimate download sizes before downloading (dry-run) | -| `sra_generate_download_commands` | Generate download commands for various methods | -| `sra_check_toolkit` | Check sra-toolkit installation | -| `sra_download` | Download SRA directly via HTTP (small files only) | -| `sra_download_and_convert` | **NEW** Download with prefetch and convert to FASTQ | - -## Example Workflows - -### 1. Search with Natural Language - -``` -User: Find Human RNA-seq datasets - -AI: I'll search for Human RNA-seq datasets in GEO. -[Uses geo_search_series with term="Human RNA-seq"] - -Results: -- GSE319287: "TPD52 Inhibits Aldosterone Synthesis..." (6 samples) -- GSE213095: "Positive feedback between cDC1s and CD8+ T cells..." (46 samples) -- GSE320065: "Transcriptomic and Functional Responses of Human Airway Cells..." (56 samples) -``` - -### 2. Download Processed Data - -``` -User: Download the metadata for GSE12345 - -AI: I'll download the SOFT file and series matrix for GSE12345. -[Uses geo_download_series with geo_id="GSE12345", file_types=["soft", "matrix"]] - -Results: -✓ Downloaded GSE12345_family.soft.gz (1.2 MB) -✓ Downloaded GSE12345_series_matrix.txt.gz (5.6 MB) -Location: ~/geo_downloads/gse/GSE12345/ -``` - -### 3. Find and Download Raw Sequencing Data (Safe Workflow) - -**Step 1: Get SRA accessions** -``` -User: Get the SRA run accessions for GSE272467 - -AI: I'll query the SRA information from this GEO Series. -[Uses sra_query_from_geo with gse_id="GSE272467"] - -Results: -- Total samples: 4 -- Samples with SRA data: 4 -- Total SRA runs: 4 -- All SRA accessions: ["SRR29864801", "SRR29864802", "SRR29864803", "SRR29864804"] -``` - -**Step 2: Estimate size (recommended)** -``` -User: How large are these files? - -AI: Let me estimate the download sizes. -[Uses sra_estimate_size with sra_ids=["SRR29864801"]] - -Results: -- SRA size: ~76 MB -- Estimated FASTQ size: ~190 MB -- Read count: 7,178,576 -- Warning: Small file (< 100 MB) -``` - -**Step 3: Download and convert** -``` -User: Download and convert SRR29864801 to FASTQ - -AI: I'll download the SRA file and convert it to FASTQ format. -[Uses sra_download_and_convert with sra_id="SRR29864801", dry_run=False] - -Results: -✓ Downloaded SRR29864801.sra (76 MB) -✓ Converted to FASTQ - - SRR29864801_1.fastq (660 KB, 5000 reads) - - SRR29864801_2.fastq (660 KB, 5000 reads) -Location: ~/geo_downloads/sra/SRR29864801/ -``` - -### 4. Safety Constraints in Action - -``` -User: Download SRR1234567 - -AI: First, let me estimate the size before downloading. -[Uses sra_estimate_size with sra_ids=["SRR1234567"]] - -Results: -- SRA size: ~2.5 GB -- Estimated FASTQ size: ~6.25 GB -- Warning: LARGE FILE (1-5 GB) - -⚠️ This is a large download that requires explicit output directory. -Please provide output_dir parameter to proceed. - -User: Download to /data/sra - -AI: -[Uses sra_download_and_convert with - sra_id="SRR1234567", - dry_run=False, - output_dir="/data/sra"] - -✓ Download complete! -``` - -### 5. Check SRA Toolkit - -``` -User: Do I have sra-toolkit installed? - -AI: Let me check your sra-toolkit installation. -[Uses sra_check_toolkit] - -Results: -✓ prefetch: available (version 3.3.0) -✓ fastq-dump: available (version 3.3.0) -✓ fasterq-dump: available (version 3.3.0) -✓ vdb-validate: available (version 3.3.0) - -All tools are installed and ready to use! -``` - -## SRA Download Methods - -### Method 1: Using sra_download_and_convert (Recommended) - -Integrated workflow with safety features: -```python -# Step 1: Estimate size (dry-run) -sra_estimate_size(sra_ids=["SRR1234567"]) - -# Step 2: Download and convert based on size -# Small file (<1GB) -sra_download_and_convert(sra_id="SRR1234567", dry_run=False) - -# Medium file (1-5GB) - requires output_dir -sra_download_and_convert( - sra_id="SRR1234567", - dry_run=False, - output_dir="/path/to/output" -) - -# Large file (>5GB) - requires confirmation -sra_download_and_convert( - sra_id="SRR1234567", - dry_run=False, - output_dir="/path/to/output", - confirm_large=True -) -``` - -### Method 2: Using SRA Toolkit Manually - -1. **Install sra-toolkit**: Follow instructions at https://github.com/ncbi/sra-tools - -2. **Download and convert**: -```bash -# Download SRA file -prefetch SRR1234567 - -# Convert to FASTQ (3-way split for paired-end) -fastq-dump --split-3 SRR1234567 -``` - -### Method 3: Direct HTTP Download (Small Files Only) - -For small files or when sra-toolkit is not available: -```bash -# Using wget -wget https://sra-downloadb.be-md.ncbi.nlm.nih.gov/sos2/sra-pub-run-11/SRR1234567/SRR1234567.1 - -# Using curl -curl -o SRR1234567.sra https://sra-downloadb.be-md.ncbi.nlm.nih.gov/sos2/sra-pub-run-11/SRR1234567/SRR1234567.1 -``` - -## Project Structure - -``` -geo_mcp_server/ -├── geomcp_sra/ -│ ├── __init__.py -│ ├── config.py # Configuration management -│ ├── geo_search.py # GEO search functionality (E-Utilities) -│ ├── geo_download.py # GEO data download -│ └── sra_handler.py # SRA query, size estimation, download & convert -├── server.py # Main MCP server with 18 tools -├── config.json # Configuration template -├── pyproject.toml # Project dependencies -├── requirements.txt # Python dependencies -├── README.md # This file -└── SKILL.md # Skill documentation -``` - -## Comparison with Original GEOmcp - -| Feature | GEOmcp (Original) | This Project (geo-mcp-server) | -|---------|-------------------|---------------------------| -| GEO Search | ✓ | ✓ (Enhanced with natural language) | -| SOFT Download | ✓ | ✓ | -| Matrix Download | ✓ | ✓ | -| Supplementary Files | ✓ | ✓ | -| **SRA Query** | ✗ | **✓** | -| **SRR Mapping** | ✗ | **✓** | -| **Size Estimation** | ✗ | **✓ (Dry-run mode)** | -| **Download & Convert** | ✗ | **✓ (Integrated workflow)** | -| **Safety Constraints** | ✗ | **✓ (>1GB, >5GB checks)** | -| Tool Count | 11 | **18** | - -## Configuration Options - -### Full Config File Reference - -```json -{ - "base_url": "https://eutils.ncbi.nlm.nih.gov/entrez/eutils", - "email": "your_email@example.com", - "api_key": "YOUR_NCBI_API_KEY (optional but recommended)", - "download_dir": "~/geo_downloads", - "max_file_size_mb": 5000, - "max_total_downloads_mb": 10000, - "max_concurrent_downloads": 3, - "download_timeout_seconds": 300, - "allowed_download_paths": ["~/geo_downloads", "/tmp/geo_downloads"], - "sra_toolkit_path": "/path/to/sra-toolkit/bin (optional)" -} -``` - -## Troubleshooting - -### Email not configured -``` -Error: Email is required for NCBI E-utilities. -``` -**Solution**: Edit `~/.geo-mcp/config.json` and add your email address. - -### SRA toolkit not found -``` -fasterq-dump not found. Please install sra-toolkit. -``` -**Solution**: Install sra-toolkit from https://github.com/ncbi/sra-tools and set `sra_toolkit_path` in config. - -### Rate limiting -``` -Error: Rate limit exceeded -``` -**Solution**: Add an NCBI API key to your config for higher rate limits (10 req/s vs 3 req/s). - -### Large file safety error -``` -SAFETY CHECK: File size is ~2.5 GB. Large downloads require an explicit output directory. -``` -**Solution**: Provide `output_dir` parameter for files >1GB, or both `output_dir` and `confirm_large=True` for files >5GB. - -## References - -- [GEO Home](https://www.ncbi.nlm.nih.gov/geo/) -- [SRA Home](https://www.ncbi.nlm.nih.gov/sra) -- [NCBI E-Utilities](https://www.ncbi.nlm.nih.gov/books/NBK25501/) -- [SRA Toolkit Documentation](https://github.com/ncbi/sra-tools/wiki) -- [MCP Documentation](https://modelcontextprotocol.io/) - -## License - -MIT License - See LICENSE file for details. - -## Contributing - -Contributions are welcome! Please feel free to submit issues or pull requests. - -### Contributing SRA Support Back to Original GEOmcp - -This project implements the [enhancement request](https://github.com/MCPmed/GEOmcp/issues/1) for SRA raw data support with the following safety features as recommended by maintainers: -- Safe by default (dry-run mode) -- Size estimation before download -- Explicit output directory requirement for large files -- Confirmation for very large files (>5GB) - -## Acknowledgments - -- Original [GEOmcp](https://github.com/MCPmed/GEOmcp) project for the foundation -- NCBI for providing the GEO and SRA databases -- MCP team for the Model Context Protocol diff --git a/GEO/geo_mcp_server/SKILL.md b/GEO/geo_mcp_server/SKILL.md deleted file mode 100644 index f1e08d8..0000000 --- a/GEO/geo_mcp_server/SKILL.md +++ /dev/null @@ -1,245 +0,0 @@ ---- -name: geo-mcp-sra -description: MCP server for accessing GEO (Gene Expression Omnibus) data with comprehensive SRA (Sequence Read Archive) raw sequencing support. Enables natural language search, metadata download, size estimation with dry-run mode, and safe FASTQ downloads with prefetch/fastq-dump integration. ---- - -# GEO MCP Server with SRA Support - -This skill provides a Model Context Protocol (MCP) server for accessing NCBI's GEO and SRA databases programmatically with enhanced safety features for large file downloads. - -## Capabilities - -### GEO Data Access -- Search GEO databases (GSE, GSM, GPL, GDS, Profiles) using natural language -- Download SOFT format metadata files -- Download series matrix files -- Download supplementary processed data - -### SRA Raw Sequencing Data (with Safety Features) -- Query SRA Run accessions from GEO Series -- Map GSM samples to SRR run accessions -- **Estimate download sizes** (dry-run mode) -- **Download & convert** with integrated prefetch + fastq-dump workflow -- **Safety constraints**: Size warnings, explicit output directory, confirmation for large files - -## Installation - -```bash -cd /path/to/geo_mcp_server -pip install -e . -``` - -## Configuration - -1. Create config file: -```bash -python3 server.py --init -``` - -2. Edit `~/.geo-mcp/config.json`: -```json -{ - "email": "your_email@example.com", - "api_key": "YOUR_NCBI_API_KEY (optional but recommended)", - "download_dir": "~/geo_downloads", - "sra_toolkit_path": "/path/to/sra-toolkit/bin (optional)" -} -``` - -> **Note:** NCBI requires an email address. API key provides higher rate limits (10 req/s vs 3 req/s). - -## Usage with Claude Desktop - -Add to `~/.config/claude-desktop/config.json`: - -```json -{ - "mcpServers": { - "geo_mcp": { - "command": "python3", - "args": ["/path/to/geo_mcp_server/server.py"], - "env": { - "CONFIG_PATH": "/home/username/.geo-mcp/config.json" - } - } - } -} -``` - -## Available Tools (18 Total) - -### Search Tools (Natural Language Support) - -All search tools support natural language queries like "Human RNA-seq", "mouse brain single cell", "breast cancer transcriptome". - -| Tool | Description | Example Query | -|------|-------------|---------------| -| `geo_search` | Universal GEO search | "cancer RNA-seq" | -| `geo_search_series` | Search GSE records | "Human RNA-seq" | -| `geo_search_samples` | Search GSM records | "HeLa cell line" | -| `geo_search_platforms` | Search GPL records | "Illumina HiSeq" | -| `geo_search_datasets` | Search GDS records | "breast cancer" | -| `geo_search_profiles` | Search GEO Profiles | "p53 expression" | - -### GEO Download Tools - -| Tool | Description | -|------|-------------| -| `geo_download_series` | Download GSE data (SOFT, matrix, supplementary) | -| `geo_download_sample` | Download GSM supplementary files | -| `geo_get_download_status` | Check download status | -| `geo_list_downloads` | List downloaded datasets | -| `geo_cleanup_downloads` | Clean up files | - -### SRA Tools - -| Tool | Description | Safety Features | -|------|-------------|-----------------| -| `sra_query_from_geo` | Get SRA accessions from GEO Series | - | -| `sra_get_metadata` | Get SRA run metadata | - | -| `sra_estimate_size` | **Estimate sizes (dry-run)** | Shows warnings for >1GB, >5GB | -| `sra_generate_download_commands` | Generate download commands | - | -| `sra_check_toolkit` | Check sra-toolkit installation | - | -| `sra_download` | Direct HTTP download | Small files only | -| `sra_download_and_convert` | **Download & convert to FASTQ** | dry_run=True default, size checks | - -## Safety Features - -### Default Safe Behavior - -All SRA downloads default to **dry-run mode** (`dry_run=True`): - -```python -# This only estimates size, does NOT download -sra_download_and_convert(sra_id="SRR1234567") -``` - -### Size-Based Safety Constraints - -| File Size | Required Parameters | -|-----------|---------------------| -| < 1 GB | `dry_run=False` | -| 1-5 GB | `dry_run=False` + `output_dir="/path"` | -| > 5 GB | `dry_run=False` + `output_dir="/path"` + `confirm_large=True` | - -### Safety Check Examples - -**Error for >1GB without output_dir:** -``` -SAFETY CHECK: File size is ~2.5 GB. Large downloads require an explicit -output directory. Please provide output_dir parameter. -Tip: Run with dry_run=True first to see size estimates. -``` - -**Error for >5GB without confirmation:** -``` -SAFETY CHECK: File size is ~6.2 GB (>5GB). This is a VERY LARGE download -that will consume significant disk space and time. -To proceed, set confirm_large=True. -``` - -## Example Workflows - -### Workflow 1: Search with Natural Language - -```python -# Search for Human RNA-seq datasets -geo_search_series(term="Human RNA-seq", retmax=10) - -# Search for specific tissue + disease -geo_search_series(term="mouse brain Alzheimer's", retmax=5) -``` - -### Workflow 2: Safe SRA Download - -**Step 1: Always estimate first** -```python -sra_estimate_size(sra_ids=["SRR1234567"]) -# Returns: SRA size, FASTQ estimate, read count, safety warnings -``` - -**Step 2: Download based on size** - -Small file (<1GB): -```python -sra_download_and_convert( - sra_id="SRR1234567", - dry_run=False, - split_3=True, # Properly handle paired-end - check_refseq=False # Skip refseq to save space -) -``` - -Medium file (1-5GB): -```python -sra_download_and_convert( - sra_id="SRR1234567", - dry_run=False, - output_dir="/data/sra", # Required! - split_3=True -) -``` - -Large file (>5GB): -```python -sra_download_and_convert( - sra_id="SRR1234567", - dry_run=False, - output_dir="/data/sra", # Required - confirm_large=True, # Required - split_3=True -) -``` - -### Workflow 3: Complete Analysis Pipeline - -```python -# 1. Search for datasets -results = geo_search_series(term="GLOR2 m6A", retmax=5) - -# 2. Get SRA accessions for a dataset -sra_info = sra_query_from_geo(gse_id="GSE272467") -# Returns: 4 SRR accessions - -# 3. Estimate sizes -sizes = sra_estimate_size(sra_ids=sra_info["all_sra_accessions"]) -# Shows: ~76 MB each, total ~304 MB - -# 4. Download and convert -for sra_id in sra_info["all_sra_accessions"]: - sra_download_and_convert( - sra_id=sra_id, - dry_run=False, - split_3=True, - check_refseq=False - ) -``` - -## Architecture - -``` -geo_mcp_server/ -├── geomcp_sra/ -│ ├── config.py # Configuration management -│ ├── geo_search.py # NCBI E-Utilities search -│ ├── geo_download.py # FTP/HTTP downloads -│ └── sra_handler.py # SRA query, size estimation, download & convert -├── server.py # MCP server with 18 tools (FastMCP) -├── config.json # Config template -└── pyproject.toml # Project metadata -``` - -## Dependencies - -- `mcp>=1.9.0` - MCP Python SDK -- `httpx>=0.27.0` - Async HTTP client -- `aiofiles>=23.0.0` - Async file operations -- `pydantic>=2.0.0` - Input validation - -## References - -- [GEO Documentation](https://www.ncbi.nlm.nih.gov/geo/info/) -- [SRA Documentation](https://www.ncbi.nlm.nih.gov/sra/docs/) -- [SRA Toolkit](https://github.com/ncbi/sra-tools) -- [MCP Specification](https://modelcontextprotocol.io/) -- [Original GEOmcp Issue #1 - SRA Support](https://github.com/MCPmed/GEOmcp/issues/1) diff --git a/GEO/geo_mcp_server/config.json b/GEO/geo_mcp_server/config.json deleted file mode 100644 index 3251bd4..0000000 --- a/GEO/geo_mcp_server/config.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "base_url": "https://eutils.ncbi.nlm.nih.gov/entrez/eutils", - "sra_base_url": "https://trace.ncbi.nlm.nih.gov/Traces/sra", - "email": "your_email@example.com", - "api_key": "", - "retmax": 20, - "download_dir": "./downloads", - "max_file_size_mb": 5000, - "max_total_downloads_mb": 50000, - "max_concurrent_downloads": 3, - "download_timeout_seconds": 300, - "sra_toolkit_path": "", - "allowed_download_paths": [ - "./downloads", - "/tmp/geo_downloads" - ] -} diff --git a/GEO/geo_mcp_server/geomcp_sra/__init__.py b/GEO/geo_mcp_server/geomcp_sra/__init__.py deleted file mode 100644 index 1597189..0000000 --- a/GEO/geo_mcp_server/geomcp_sra/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -""" -GEO MCP Server with SRA Support - -An enhanced MCP server for accessing GEO (Gene Expression Omnibus) data -through NCBI E-Utils API, with additional support for SRA (Sequence Read Archive) -raw data download capabilities. -""" - -__version__ = "0.2.0" -__author__ = "GEO MCP Contributors" diff --git a/GEO/geo_mcp_server/geomcp_sra/config.py b/GEO/geo_mcp_server/geomcp_sra/config.py deleted file mode 100644 index 498fba8..0000000 --- a/GEO/geo_mcp_server/geomcp_sra/config.py +++ /dev/null @@ -1,159 +0,0 @@ -"""Configuration management for GEO MCP Server.""" - -import json -import os -import sys -from pathlib import Path -from typing import Dict, Any, Optional - - -# Default configuration values -DEFAULT_CONFIG = { - "base_url": "https://eutils.ncbi.nlm.nih.gov/entrez/eutils", - "sra_base_url": "https://trace.ncbi.nlm.nih.gov/Traces/sra", - "email": None, - "api_key": None, - "retmax": 20, - "download_dir": "./downloads", - "max_file_size_mb": 5000, - "max_total_downloads_mb": 50000, - "max_concurrent_downloads": 3, - "download_timeout_seconds": 300, - "sra_toolkit_path": None, # Path to sra-toolkit (prefetch/fasterq-dump) - "allowed_download_paths": ["./downloads", "/tmp/geo_downloads"], -} - - -def find_config_file() -> Optional[Path]: - """Find configuration file in standard locations.""" - # Check environment variable first - env_config = os.getenv("CONFIG_PATH") - if env_config: - path = Path(env_config).expanduser() - if path.exists(): - return path - - # Check current directory - current_dir = Path.cwd() / "config.json" - if current_dir.exists(): - return current_dir - - # Check module directory - module_dir = Path(__file__).parent / "config.json" - if module_dir.exists(): - return module_dir - - # Check user home directory - home_config = Path.home() / ".geo-mcp" / "config.json" - if home_config.exists(): - return home_config - - return None - - -def load_config(config_path: Optional[Path] = None) -> Dict[str, Any]: - """Load configuration from file or use defaults. - - Args: - config_path: Optional explicit path to config file - - Returns: - Configuration dictionary - """ - config = DEFAULT_CONFIG.copy() - - # Find config file - if config_path is None: - config_path = find_config_file() - - if config_path and config_path.exists(): - try: - with open(config_path, 'r') as f: - user_config = json.load(f) - config.update(user_config) - except (json.JSONDecodeError, IOError) as e: - print(f"Warning: Error loading config from {config_path}: {e}", file=sys.stderr) - - # Override with environment variables - if os.getenv("NCBI_EMAIL"): - config["email"] = os.getenv("NCBI_EMAIL") - if os.getenv("NCBI_API_KEY"): - config["api_key"] = os.getenv("NCBI_API_KEY") - if os.getenv("GEO_DOWNLOAD_DIR"): - config["download_dir"] = os.getenv("GEO_DOWNLOAD_DIR") - if os.getenv("SRA_TOOLKIT_PATH"): - config["sra_toolkit_path"] = os.getenv("SRA_TOOLKIT_PATH") - - return config - - -def validate_config(config: Dict[str, Any]) -> bool: - """Validate configuration values. - - Args: - config: Configuration dictionary - - Returns: - True if valid, raises ValueError otherwise - """ - if not config.get("email"): - raise ValueError( - "Email is required for NCBI E-utilities. " - "Set it in config.json or via NCBI_EMAIL environment variable." - ) - - # Validate download directory - download_dir = Path(config.get("download_dir", "./downloads")) - try: - download_dir.mkdir(parents=True, exist_ok=True) - except OSError as e: - raise ValueError(f"Cannot create download directory: {e}") - - return True - - -def create_config_template(path: Path) -> None: - """Create a configuration file template. - - Args: - path: Path where to create the config file - """ - path = Path(path) - path.parent.mkdir(parents=True, exist_ok=True) - - template = { - "base_url": "https://eutils.ncbi.nlm.nih.gov/entrez/eutils", - "sra_base_url": "https://trace.ncbi.nlm.nih.gov/Traces/sra", - "email": "your_email@example.com", - "api_key": "YOUR_NCBI_API_KEY (optional)", - "retmax": 20, - "download_dir": "./downloads", - "max_file_size_mb": 5000, - "max_total_downloads_mb": 50000, - "max_concurrent_downloads": 3, - "download_timeout_seconds": 300, - "sra_toolkit_path": "/path/to/sra-toolkit/bin (optional, for fasterq-dump)", - "allowed_download_paths": ["./downloads", "/tmp/geo_downloads"] - } - - with open(path, 'w') as f: - json.dump(template, f, indent=4) - - print(f"Configuration template created at: {path}") - - -# Global config instance (lazy loading) -_config: Optional[Dict[str, Any]] = None - - -def get_config() -> Dict[str, Any]: - """Get the global configuration instance. - - Returns: - Configuration dictionary - """ - global _config - if _config is None: - _config = load_config() - validate_config(_config) - return _config diff --git a/GEO/geo_mcp_server/geomcp_sra/geo_download.py b/GEO/geo_mcp_server/geomcp_sra/geo_download.py deleted file mode 100644 index 8db3f8a..0000000 --- a/GEO/geo_mcp_server/geomcp_sra/geo_download.py +++ /dev/null @@ -1,463 +0,0 @@ -"""GEO data download functionality.""" - -import asyncio -import gzip -import json -import os -import re -import shutil -import tarfile -from pathlib import Path -from typing import Dict, Any, List, Optional -import httpx -import aiofiles - -from .config import get_config - - -class GEODownloadError(Exception): - """Exception raised for GEO download errors.""" - pass - - -class GEODownloadClient: - """Client for downloading GEO data files.""" - - # GEO FTP base URL (using HTTPS) - GEO_FTP_BASE = "https://ftp.ncbi.nlm.nih.gov/geo" - - def __init__(self): - self.config = get_config() - self.download_dir = Path(self.config["download_dir"]).resolve() - self.max_file_bytes = self.config.get("max_file_size_mb", 5000) * 1024 * 1024 - self.max_total_bytes = self.config.get("max_total_downloads_mb", 50000) * 1024 * 1024 - self.timeout = self.config.get("download_timeout_seconds", 300) - self.allowed_paths = self.config.get("allowed_download_paths", ["./downloads"]) - - # Create download directory - self.download_dir.mkdir(parents=True, exist_ok=True) - - def _is_allowed_path(self, path: Path) -> bool: - """Check if path is within allowed download directories.""" - path = path.resolve() - for allowed in self.allowed_paths: - allowed_path = Path(allowed).resolve() - try: - path.relative_to(allowed_path) - return True - except ValueError: - continue - return False - - def _get_dir_size(self, path: Path) -> int: - """Calculate total size of files in directory.""" - total = 0 - for f in path.rglob("*"): - if f.is_file(): - total += f.stat().st_size - return total - - def _get_range_dir(self, accession: str) -> str: - """Get range directory for GEO accession. - - GEO uses range directories to avoid too many files in one directory. - E.g., GSE15701 -> GSE15nnn - """ - match = re.match(r'(GSE|GSM|GPL|GDS)(\d+)', accession, re.IGNORECASE) - if match: - prefix = match.group(1).upper() - number = match.group(2) - return f"{prefix}{number[:-3]}nnn" - return accession - - def _build_geo_urls(self, accession: str) -> Dict[str, str]: - """Build download URLs for a GEO accession. - - Args: - accession: GEO accession ID - - Returns: - Dictionary of file types to URLs - """ - urls = {} - prefix = accession[:3].upper() - range_dir = self._get_range_dir(accession) - base_url = f"{self.GEO_FTP_BASE}" - - if prefix == "GSE": - # Series files - base = f"{base_url}/series/{range_dir}/{accession}" - urls["series_matrix"] = f"{base}/matrix/{accession}_series_matrix.txt.gz" - urls["soft"] = f"{base}/soft/{accession}_family.soft.gz" - urls["miniml"] = f"{base}/miniml/{accession}_family.xml.tgz" - urls["supplementary"] = f"{base}/suppl/{accession}_RAW.tar" - elif prefix == "GDS": - # Dataset files - base = f"{base_url}/datasets/{range_dir}/{accession}" - urls["soft"] = f"{base}/soft/{accession}.soft.gz" - urls["soft_full"] = f"{base}/soft/{accession}_full.soft.gz" - elif prefix == "GPL": - # Platform files - base = f"{base_url}/platforms/{range_dir}/{accession}" - urls["annot"] = f"{base}/annot/{accession}.annot.gz" - urls["soft"] = f"{base}/soft/{accession}_family.soft.gz" - urls["supplementary"] = f"{base}/suppl/" - elif prefix == "GSM": - # Sample files - base = f"{base_url}/samples/{range_dir}/{accession}" - urls["supplementary"] = f"{base}/suppl/" - - return urls - - async def download_file( - self, - url: str, - dest_path: Path, - progress_callback: Optional[callable] = None - ) -> Path: - """Download a single file. - - Args: - url: URL to download - dest_path: Destination path - progress_callback: Optional callback for progress updates - - Returns: - Path to downloaded file - """ - if not self._is_allowed_path(dest_path): - raise GEODownloadError(f"Destination path not allowed: {dest_path}") - - # Check total download limit - current_total = self._get_dir_size(self.download_dir) - if current_total >= self.max_total_bytes: - raise GEODownloadError("Total download limit reached") - - # Check disk space - free_space = shutil.disk_usage(dest_path.parent).free - if free_space < self.max_file_bytes: - raise GEODownloadError("Insufficient disk space") - - async with httpx.AsyncClient() as client: - async with client.stream( - "GET", - url, - timeout=self.timeout, - follow_redirects=True - ) as response: - response.raise_for_status() - - # Check content length - content_length = response.headers.get('content-length') - if content_length: - size = int(content_length) - if size > self.max_file_bytes: - raise GEODownloadError( - f"File size ({size} bytes) exceeds maximum allowed" - ) - - # Download file - downloaded = 0 - async with aiofiles.open(dest_path, 'wb') as f: - async for chunk in response.aiter_bytes(chunk_size=8192): - downloaded += len(chunk) - if downloaded > self.max_file_bytes: - dest_path.unlink() - raise GEODownloadError("File size exceeded limit during download") - await f.write(chunk) - - if progress_callback: - progress_callback(downloaded) - - return dest_path - - async def download_geo_series( - self, - gse_id: str, - file_types: Optional[List[str]] = None, - output_dir: Optional[Path] = None - ) -> Dict[str, Any]: - """Download files for a GEO Series. - - Args: - gse_id: GSE accession ID - file_types: List of file types to download (soft, matrix, miniml, supplementary) - output_dir: Optional custom output directory - - Returns: - Download results dictionary - """ - if not gse_id.upper().startswith("GSE"): - raise GEODownloadError(f"Invalid GSE ID: {gse_id}") - - file_types = file_types or ["soft"] - output_dir = output_dir or self.download_dir / "series" / gse_id - output_dir.mkdir(parents=True, exist_ok=True) - - urls = self._build_geo_urls(gse_id) - downloaded = [] - errors = [] - - for file_type in file_types: - if file_type not in urls: - errors.append(f"Unknown file type: {file_type}") - continue - - url = urls[file_type] - filename = url.split("/")[-1] - dest_path = output_dir / filename - - # Skip if already exists - if dest_path.exists(): - downloaded.append({ - "type": file_type, - "path": str(dest_path), - "status": "already_exists", - "size_mb": round(dest_path.stat().st_size / (1024*1024), 2) - }) - continue - - try: - await self.download_file(url, dest_path) - downloaded.append({ - "type": file_type, - "path": str(dest_path), - "status": "downloaded", - "size_mb": round(dest_path.stat().st_size / (1024*1024), 2) - }) - except httpx.HTTPStatusError as e: - if e.response.status_code == 404: - errors.append(f"{file_type}: File not found on server") - else: - errors.append(f"{file_type}: HTTP {e.response.status_code}") - except Exception as e: - errors.append(f"{file_type}: {str(e)}") - - return { - "accession": gse_id, - "output_dir": str(output_dir), - "downloaded": downloaded, - "errors": errors, - "total_downloaded": len([d for d in downloaded if d["status"] == "downloaded"]) - } - - async def download_geo_sample( - self, - gsm_id: str, - output_dir: Optional[Path] = None - ) -> Dict[str, Any]: - """Download supplementary files for a GEO Sample. - - Args: - gsm_id: GSM accession ID - output_dir: Optional custom output directory - - Returns: - Download results dictionary - """ - if not gsm_id.upper().startswith("GSM"): - raise GEODownloadError(f"Invalid GSM ID: {gsm_id}") - - output_dir = output_dir or self.download_dir / "samples" / gsm_id - output_dir.mkdir(parents=True, exist_ok=True) - - # Get the supplementary file listing - urls = self._build_geo_urls(gsm_id) - suppl_url = urls.get("supplementary") - - if not suppl_url: - return { - "accession": gsm_id, - "output_dir": str(output_dir), - "downloaded": [], - "errors": ["No supplementary files URL available"] - } - - # For samples, we need to list and download files - # This requires parsing the directory listing - downloaded = [] - errors = [] - - try: - # Try to get directory listing - async with httpx.AsyncClient() as client: - response = await client.get(suppl_url, timeout=30.0) - if response.status_code == 200: - # Parse HTML directory listing - files = self._parse_directory_listing(response.text) - - for filename in files: - if filename.endswith('/'): - continue - - file_url = suppl_url + filename - dest_path = output_dir / filename - - try: - await self.download_file(file_url, dest_path) - downloaded.append({ - "filename": filename, - "path": str(dest_path), - "size_mb": round(dest_path.stat().st_size / (1024*1024), 2) - }) - except Exception as e: - errors.append(f"{filename}: {str(e)}") - else: - errors.append(f"Could not list supplementary files: HTTP {response.status_code}") - except Exception as e: - errors.append(f"Error accessing supplementary files: {str(e)}") - - return { - "accession": gsm_id, - "output_dir": str(output_dir), - "downloaded": downloaded, - "errors": errors, - "total_downloaded": len(downloaded) - } - - def _parse_directory_listing(self, html: str) -> List[str]: - """Parse HTML directory listing for file names.""" - # Simple regex to extract href values - files = [] - for match in re.finditer(r'href=["\']([^"\']+)["\']', html): - filename = match.group(1) - if filename not in ['../', './']: - files.append(filename) - return files - - def get_download_status(self, accession: str, db_type: str = "gse") -> Dict[str, Any]: - """Check download status of a GEO dataset. - - Args: - accession: GEO accession ID - db_type: Database type (gse, gsm, gpl, gds) - - Returns: - Status dictionary - """ - dataset_path = self.download_dir / db_type / accession - - if not dataset_path.exists(): - return { - "accession": accession, - "db_type": db_type, - "downloaded": False, - "path": str(dataset_path) - } - - files = [] - total_size = 0 - for f in dataset_path.rglob("*"): - if f.is_file(): - size = f.stat().st_size - files.append({ - "name": f.name, - "path": str(f), - "size_mb": round(size / (1024*1024), 2) - }) - total_size += size - - return { - "accession": accession, - "db_type": db_type, - "downloaded": True, - "path": str(dataset_path), - "files": files, - "total_size_mb": round(total_size / (1024*1024), 2), - "file_count": len(files) - } - - def list_downloaded_datasets(self, db_type: Optional[str] = None) -> Dict[str, Any]: - """List all downloaded datasets. - - Args: - db_type: Optional filter by database type - - Returns: - List of downloaded datasets - """ - datasets = [] - - if db_type: - db_path = self.download_dir / db_type - if db_path.exists(): - for dataset_dir in db_path.iterdir(): - if dataset_dir.is_dir(): - total_size = sum( - f.stat().st_size for f in dataset_dir.rglob("*") if f.is_file() - ) - datasets.append({ - "accession": dataset_dir.name, - "db_type": db_type, - "path": str(dataset_dir), - "size_mb": round(total_size / (1024*1024), 2) - }) - else: - for db_dir in self.download_dir.iterdir(): - if db_dir.is_dir(): - for dataset_dir in db_dir.iterdir(): - if dataset_dir.is_dir(): - total_size = sum( - f.stat().st_size for f in dataset_dir.rglob("*") if f.is_file() - ) - datasets.append({ - "accession": dataset_dir.name, - "db_type": db_dir.name, - "path": str(dataset_dir), - "size_mb": round(total_size / (1024*1024), 2) - }) - - return { - "datasets": datasets, - "count": len(datasets) - } - - def cleanup_downloads( - self, - accession: Optional[str] = None, - db_type: Optional[str] = None - ) -> Dict[str, Any]: - """Clean up downloaded files. - - Args: - accession: Optional specific accession to remove - db_type: Optional database type filter - - Returns: - Cleanup results - """ - removed = [] - - if accession and db_type: - # Remove specific dataset - dataset_path = self.download_dir / db_type / accession - if dataset_path.exists(): - shutil.rmtree(dataset_path) - removed.append(str(dataset_path)) - elif db_type: - # Remove all datasets of a specific type - db_path = self.download_dir / db_type - if db_path.exists(): - for dataset_dir in db_path.iterdir(): - if dataset_dir.is_dir(): - shutil.rmtree(dataset_dir) - removed.append(str(dataset_dir)) - elif accession: - # Remove all matching accessions across types - for db_dir in self.download_dir.iterdir(): - if db_dir.is_dir(): - dataset_path = db_dir / accession - if dataset_path.exists(): - shutil.rmtree(dataset_path) - removed.append(str(dataset_path)) - else: - # Remove all downloads - for db_dir in self.download_dir.iterdir(): - if db_dir.is_dir(): - shutil.rmtree(db_dir) - removed.append(str(db_dir)) - - return { - "removed": removed, - "count": len(removed) - } diff --git a/GEO/geo_mcp_server/geomcp_sra/geo_search.py b/GEO/geo_mcp_server/geomcp_sra/geo_search.py deleted file mode 100644 index a9be1aa..0000000 --- a/GEO/geo_mcp_server/geomcp_sra/geo_search.py +++ /dev/null @@ -1,384 +0,0 @@ -"""GEO search functionality using NCBI E-Utilities.""" - -import json -import time -from typing import Dict, Any, List, Optional -import httpx - -from .config import get_config - - -class GEOSearchError(Exception): - """Exception raised for GEO search errors.""" - pass - - -class GEOSearchClient: - """Client for searching GEO database using NCBI E-Utilities.""" - - def __init__(self): - self.config = get_config() - self.base_url = self.config["base_url"] - self.email = self.config["email"] - self.api_key = self.config.get("api_key") - self.retmax = self.config.get("retmax", 20) - self._last_request_time = 0 - - def _rate_limit(self): - """Apply rate limiting to be respectful to NCBI servers.""" - # Without API key: 3 requests per second - # With API key: 10 requests per second - min_interval = 0.1 if self.api_key else 0.34 - - elapsed = time.time() - self._last_request_time - if elapsed < min_interval: - time.sleep(min_interval - elapsed) - self._last_request_time = time.time() - - def _build_params(self, extra_params: Dict[str, Any]) -> Dict[str, str]: - """Build request parameters with authentication.""" - params = {"email": self.email, **extra_params} - if self.api_key: - params["api_key"] = self.api_key - return params - - async def _esearch(self, db: str, term: str, retmax: int = 20) -> Dict[str, Any]: - """Perform ESearch query. - - Args: - db: Database to search (gds, geoprofiles, etc.) - term: Search term - retmax: Maximum results to return - - Returns: - ESearch response dictionary - """ - self._rate_limit() - - params = self._build_params({ - "db": db, - "term": term, - "retmax": retmax, - "retmode": "json", - }) - - async with httpx.AsyncClient() as client: - response = await client.get( - f"{self.base_url}/esearch.fcgi", - params=params, - timeout=30.0 - ) - response.raise_for_status() - return response.json() - - async def _esummary(self, db: str, ids: List[str]) -> Dict[str, Any]: - """Fetch summaries for a list of IDs. - - Args: - db: Database - ids: List of IDs to summarize - - Returns: - ESummary response dictionary - """ - if not ids: - return {"result": {}} - - self._rate_limit() - - params = self._build_params({ - "db": db, - "id": ",".join(map(str, ids)), - "retmode": "json", - }) - - async with httpx.AsyncClient() as client: - response = await client.get( - f"{self.base_url}/esummary.fcgi", - params=params, - timeout=30.0 - ) - response.raise_for_status() - return response.json() - - async def _efetch(self, db: str, id: str, retmode: str = "xml") -> str: - """Fetch full records. - - Args: - db: Database - id: Record ID - retmode: Return mode (xml, json, etc.) - - Returns: - EFetch response text - """ - self._rate_limit() - - params = self._build_params({ - "db": db, - "id": id, - "retmode": retmode, - }) - - async with httpx.AsyncClient() as client: - response = await client.get( - f"{self.base_url}/efetch.fcgi", - params=params, - timeout=30.0 - ) - response.raise_for_status() - return response.text - - async def search_geo( - self, - term: str, - retmax: Optional[int] = None, - record_types: Optional[List[str]] = None - ) -> Dict[str, Any]: - """Search GEO for all types of records. - - Args: - term: Search term (e.g., 'breast cancer', 'GSE12345', 'RNA-seq') - retmax: Maximum number of results to return - record_types: Optional filter for specific types ["GSE", "GSM", "GPL", "GDS"] - - Returns: - Dictionary with categorized results by record type - """ - try: - retmax = retmax or self.retmax - - # Search the gds database - data = await self._esearch('gds', term, retmax) - ids = data.get('esearchresult', {}).get('idlist', []) - - if not ids: - return { - "total_count": 0, - "results": [], - "series": [], - "samples": [], - "platforms": [], - "datasets": [] - } - - # Get detailed summaries - summaries = await self._esummary('gds', ids) - results = summaries.get('result', {}) - - # Categorize results by accession type - categorized = { - "total_count": len(ids), - "results": [], - "series": [], # GSE records - "samples": [], # GSM records - "platforms": [], # GPL records - "datasets": [] # GDS records - } - - for uid in ids: - if uid in results: - record = results[uid] - accession = record.get('accession', '') - - # Add to main results - categorized["results"].append(record) - - # Categorize by type - if accession.startswith('GSE'): - categorized["series"].append(record) - elif accession.startswith('GSM'): - categorized["samples"].append(record) - elif accession.startswith('GPL'): - categorized["platforms"].append(record) - elif accession.startswith('GDS'): - categorized["datasets"].append(record) - - # Filter by record types if specified - if record_types: - record_types = [rt.upper() for rt in record_types] - filtered_results = [] - - if "GSE" in record_types: - filtered_results.extend(categorized["series"]) - if "GSM" in record_types: - filtered_results.extend(categorized["samples"]) - if "GPL" in record_types: - filtered_results.extend(categorized["platforms"]) - if "GDS" in record_types: - filtered_results.extend(categorized["datasets"]) - - categorized["results"] = filtered_results - categorized["total_count"] = len(filtered_results) - - return categorized - - except httpx.HTTPStatusError as e: - raise GEOSearchError(f"HTTP error: {e.response.status_code} - {e.response.text}") - except Exception as e: - raise GEOSearchError(f"Search error: {str(e)}") - - async def search_geo_profiles(self, term: str, retmax: Optional[int] = None) -> Dict[str, Any]: - """Search GEO Profiles database. - - Args: - term: Search term - retmax: Maximum results to return - - Returns: - GEO Profiles search results - """ - try: - retmax = retmax or self.retmax - data = await self._esearch('geoprofiles', term, retmax) - ids = data.get('esearchresult', {}).get('idlist', []) - - if not ids: - return {"esummaryresult": ["Empty id list - nothing to do"]} - - summary = await self._esummary('geoprofiles', ids) - return summary - - except Exception as e: - raise GEOSearchError(f"GEO Profiles search error: {str(e)}") - - async def search_geo_datasets(self, term: str, retmax: Optional[int] = None) -> Dict[str, Any]: - """Search GEO Datasets (GDS) specifically. - - Args: - term: Search term - retmax: Maximum results to return - - Returns: - GDS search results - """ - result = await self.search_geo(term, retmax, record_types=["GDS"]) - - if result["datasets"]: - formatted_result = { - "header": {"type": "esummary", "version": "0.3"}, - "result": { - "uids": [r.get("uid") for r in result["datasets"]] - } - } - for record in result["datasets"]: - uid = record.get("uid") - if uid: - formatted_result["result"][uid] = record - - return formatted_result - else: - return {"esummaryresult": ["Empty id list - nothing to do"]} - - async def search_geo_series(self, term: str, retmax: Optional[int] = None) -> Dict[str, Any]: - """Search GEO Series (GSE) specifically. - - Args: - term: Search term - retmax: Maximum results to return - - Returns: - GSE search results - """ - result = await self.search_geo(term, retmax, record_types=["GSE"]) - - if result["series"]: - formatted_result = { - "header": {"type": "esummary", "version": "0.3"}, - "result": { - "uids": [r.get("uid") for r in result["series"]] - } - } - for record in result["series"]: - uid = record.get("uid") - if uid: - formatted_result["result"][uid] = record - - return formatted_result - else: - return {"esummaryresult": ["Empty id list - nothing to do"]} - - async def search_geo_samples(self, term: str, retmax: Optional[int] = None) -> Dict[str, Any]: - """Search GEO Samples (GSM) specifically. - - Args: - term: Search term - retmax: Maximum results to return - - Returns: - GSM search results - """ - result = await self.search_geo(term, retmax, record_types=["GSM"]) - - if result["samples"]: - formatted_result = { - "header": {"type": "esummary", "version": "0.3"}, - "result": { - "uids": [r.get("uid") for r in result["samples"]] - } - } - for record in result["samples"]: - uid = record.get("uid") - if uid: - formatted_result["result"][uid] = record - - return formatted_result - else: - return {"esummaryresult": ["Empty id list - nothing to do"]} - - async def search_geo_platforms(self, term: str, retmax: Optional[int] = None) -> Dict[str, Any]: - """Search GEO Platforms (GPL) specifically. - - Args: - term: Search term - retmax: Maximum results to return - - Returns: - GPL search results - """ - result = await self.search_geo(term, retmax, record_types=["GPL"]) - - if result["platforms"]: - formatted_result = { - "header": {"type": "esummary", "version": "0.3"}, - "result": { - "uids": [r.get("uid") for r in result["platforms"]] - } - } - for record in result["platforms"]: - uid = record.get("uid") - if uid: - formatted_result["result"][uid] = record - - return formatted_result - else: - return {"esummaryresult": ["Empty id list - nothing to do"]} - - async def get_series_info(self, gse_id: str) -> Dict[str, Any]: - """Get detailed information about a GEO Series. - - Args: - gse_id: GSE accession ID (e.g., 'GSE12345') - - Returns: - Series information dictionary - """ - try: - # Search for the specific GSE - data = await self._esearch('gds', f"{gse_id}[ACCN]", 1) - ids = data.get('esearchresult', {}).get('idlist', []) - - if not ids: - raise GEOSearchError(f"Series {gse_id} not found") - - # Get summary - summaries = await self._esummary('gds', ids) - result = summaries.get('result', {}) - - if ids[0] in result: - return result[ids[0]] - else: - raise GEOSearchError(f"No summary available for {gse_id}") - - except Exception as e: - raise GEOSearchError(f"Error getting series info: {str(e)}") diff --git a/GEO/geo_mcp_server/geomcp_sra/sra_handler.py b/GEO/geo_mcp_server/geomcp_sra/sra_handler.py deleted file mode 100644 index d059f94..0000000 --- a/GEO/geo_mcp_server/geomcp_sra/sra_handler.py +++ /dev/null @@ -1,1040 +0,0 @@ -"""SRA (Sequence Read Archive) handling functionality. - -This module provides capabilities to: -1. Query SRA Run information from GEO Series (map GSM to SRR accessions) -2. Get SRA accession lists for datasets -3. Generate download commands for SRA data -4. Optionally download FASTQ files using sra-toolkit -""" - -import asyncio -import json -import os -import re -import subprocess -import xml.etree.ElementTree as ET -from pathlib import Path -from typing import Dict, Any, List, Optional, Tuple -import httpx - -from .config import get_config - - -class SRAError(Exception): - """Exception raised for SRA-related errors.""" - pass - - -class SRAHandler: - """Handler for SRA data queries and downloads.""" - - # SRA endpoints - SRA_TRACE_URL = "https://trace.ncbi.nlm.nih.gov/Traces/sra" - SRA_EUTILS_URL = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils" - SRA_FETCH_URL = "https://sra-downloadb.be-md.ncbi.nlm.nih.gov/sos2/sra-pub-run-11" - - def __init__(self): - self.config = get_config() - self.email = self.config["email"] - self.api_key = self.config.get("api_key") - self.sra_toolkit_path = self.config.get("sra_toolkit_path") - self.download_dir = Path(self.config["download_dir"]).resolve() / "sra" - self.download_dir.mkdir(parents=True, exist_ok=True) - - def _build_params(self, extra_params: Dict[str, Any]) -> Dict[str, str]: - """Build request parameters with authentication.""" - params = {"email": self.email, **extra_params} - if self.api_key: - params["api_key"] = self.api_key - return params - - async def _fetch_geo_soft(self, gse_id: str) -> str: - """Fetch GEO Series SOFT file to extract SRA information. - - Args: - gse_id: GSE accession ID - - Returns: - SOFT file content as string - """ - range_dir = f"{gse_id[:-3]}nnn" - url = f"https://ftp.ncbi.nlm.nih.gov/geo/series/{range_dir}/{gse_id}/soft/{gse_id}_family.soft.gz" - - async with httpx.AsyncClient() as client: - response = await client.get(url, timeout=60.0) - - if response.status_code == 404: - # Try without _family suffix - url = f"https://ftp.ncbi.nlm.nih.gov/geo/series/{range_dir}/{gse_id}/soft/{gse_id}.soft.gz" - response = await client.get(url, timeout=60.0) - - response.raise_for_status() - - # Decompress gzip content - import gzip - content = gzip.decompress(response.content) - return content.decode('utf-8', errors='replace') - - def _parse_sra_accessions_from_soft(self, soft_content: str) -> Dict[str, List[str]]: - """Parse SRA accession numbers from SOFT file content. - - Args: - soft_content: SOFT file content - - Returns: - Dictionary mapping GSM IDs to lists of SRA accessions (SRX experiments) - """ - gsm_to_sra = {} - current_gsm = None - - for line in soft_content.split('\n'): - line = line.strip() - - # Track current sample - if line.startswith('^SAMPLE = '): - current_gsm = line.split('=')[1].strip() - gsm_to_sra[current_gsm] = [] - - # Look for SRA relation links (contain SRX experiment IDs) - elif current_gsm and line.startswith('!Sample_relation = SRA:'): - # Extract SRX ID from URL like "https://www.ncbi.nlm.nih.gov/sra?term=SRX25362135" - srx_pattern = r'([SED]RX\d+)' - matches = re.findall(srx_pattern, line) - gsm_to_sra[current_gsm].extend(matches) - - # Remove empty entries - gsm_to_sra = {k: v for k, v in gsm_to_sra.items() if v} - - return gsm_to_sra - - async def _get_srr_from_srx(self, srx_id: str) -> List[str]: - """Get SRA Run (SRR) IDs from Experiment (SRX) ID. - - Args: - srx_id: SRX accession (e.g., 'SRX25362135') - - Returns: - List of SRR accessions - """ - srr_list = [] - - try: - async with httpx.AsyncClient() as client: - # Use E-Utilities to search for runs linked to this experiment - search_params = self._build_params({ - "db": "sra", - "term": f"{srx_id}[Experiment]", - "retmode": "json", - "retmax": 100 - }) - - response = await client.get( - f"{self.SRA_EUTILS_URL}/esearch.fcgi", - params=search_params, - timeout=30.0 - ) - response.raise_for_status() - - search_data = response.json() - sra_ids = search_data.get('esearchresult', {}).get('idlist', []) - - if not sra_ids: - return srr_list - - # Get summary for each SRA entry to find SRR IDs - for sra_id in sra_ids: - summary_params = self._build_params({ - "db": "sra", - "id": sra_id, - "retmode": "json" - }) - - response = await client.get( - f"{self.SRA_EUTILS_URL}/esummary.fcgi", - params=summary_params, - timeout=30.0 - ) - response.raise_for_status() - - summary_data = response.json() - - # Parse the result to find SRR IDs - result = summary_data.get('result', {}) - for uid in result.get('uids', []): - item = result.get(uid, {}) - # Look for run accessions in the summary - runs = item.get('runs', '') - if runs: - # Parse SRR from runs field - srr_matches = re.findall(r'([SED]RR\d+)', runs) - srr_list.extend(srr_matches) - - # Also check other fields - for key in ['accession', 'runlist', 'experiment']: - val = item.get(key, '') - if val: - srr_matches = re.findall(r'([SED]RR\d+)', str(val)) - srr_list.extend(srr_matches) - - except Exception as e: - # Don't fail if we can't get SRR info - pass - - return list(set(srr_list)) # Remove duplicates - - async def query_sra_from_geo(self, gse_id: str) -> Dict[str, Any]: - """Query SRA Run information from a GEO Series. - - This method extracts the mapping between GSM samples and SRR runs - from the GEO Series SOFT file. - - Args: - gse_id: GSE accession ID (e.g., 'GSE12345') - - Returns: - Dictionary with SRA run information - - Example: - { - "gse_id": "GSE12345", - "total_samples": 10, - "samples_with_sra": 8, - "sra_runs": [ - { - "gsm_id": "GSM123456", - "sra_accessions": ["SRR1234567", "SRR1234568"] - } - ] - } - """ - if not gse_id.upper().startswith("GSE"): - raise SRAError(f"Invalid GSE ID: {gse_id}") - - try: - # Fetch and parse SOFT file - soft_content = await self._fetch_geo_soft(gse_id) - gsm_to_sra = self._parse_sra_accessions_from_soft(soft_content) - - # Convert SRX (Experiment) IDs to SRR (Run) IDs - gsm_to_srr = {} - for gsm_id, srx_list in gsm_to_sra.items(): - srr_list = [] - for srx_id in srx_list: - srrs = await self._get_srr_from_srx(srx_id) - srr_list.extend(srrs) - if srr_list: - gsm_to_srr[gsm_id] = list(set(srr_list)) # Remove duplicates - - # Also try to get from E-Utilities link - additional_sra = await self._get_sra_from_eutils(gse_id) - - # Merge results - for gsm_id, sra_list in additional_sra.items(): - if gsm_id in gsm_to_srr: - # Merge without duplicates - existing = set(gsm_to_srr[gsm_id]) - for sra in sra_list: - if sra not in existing: - gsm_to_srr[gsm_id].append(sra) - else: - gsm_to_srr[gsm_id] = sra_list - - # Build response - samples_with_sra = [ - { - "gsm_id": gsm_id, - "sra_accessions": sra_list - } - for gsm_id, sra_list in gsm_to_srr.items() - ] - - # Get all unique SRR accessions - all_srr = set() - for sra_list in gsm_to_srr.values(): - all_srr.update(sra_list) - - return { - "gse_id": gse_id.upper(), - "total_samples": len(samples_with_sra), - "samples_with_sra": len(samples_with_sra), - "total_sra_runs": len(all_srr), - "all_sra_accessions": sorted(list(all_srr)), - "samples": samples_with_sra - } - - except httpx.HTTPStatusError as e: - if e.response.status_code == 404: - raise SRAError(f"GEO Series {gse_id} not found or SOFT file unavailable") - raise SRAError(f"HTTP error querying SRA info: {e.response.status_code}") - except Exception as e: - raise SRAError(f"Error querying SRA information: {str(e)}") - - async def _get_sra_from_eutils(self, gse_id: str) -> Dict[str, List[str]]: - """Get SRA accessions using E-Utilities. - - Args: - gse_id: GSE accession ID - - Returns: - Dictionary mapping GSM IDs to SRA accessions - """ - result = {} - - try: - # Search for samples in this series - async with httpx.AsyncClient() as client: - # First, get series UID - search_params = self._build_params({ - "db": "gds", - "term": f"{gse_id}[ACCN]", - "retmode": "json" - }) - - response = await client.get( - f"{self.SRA_EUTILS_URL}/esearch.fcgi", - params=search_params, - timeout=30.0 - ) - response.raise_for_status() - - search_data = response.json() - gds_ids = search_data.get('esearchresult', {}).get('idlist', []) - - if not gds_ids: - return result - - # Fetch summary to get sample information - summary_params = self._build_params({ - "db": "gds", - "id": gds_ids[0], - "retmode": "json" - }) - - response = await client.get( - f"{self.SRA_EUTILS_URL}/esummary.fcgi", - params=summary_params, - timeout=30.0 - ) - response.raise_for_status() - - # Note: E-utilities doesn't always have SRA links - # This is a fallback method - - except Exception: - # Don't fail if eutils doesn't return data - pass - - return result - - async def estimate_sra_size(self, sra_ids: List[str]) -> Dict[str, Any]: - """Estimate the size of SRA files before downloading. - - This is a "dry-run" mode that queries the SRA database to estimate - download sizes without actually downloading any data. - - Args: - sra_ids: List of SRA accession IDs - - Returns: - Dictionary with size estimates and warnings - """ - if not sra_ids: - raise SRAError("No SRA IDs provided") - - results = [] - total_size_bytes = 0 - - try: - async with httpx.AsyncClient() as client: - for sra_id in sra_ids: - sra_id = sra_id.upper() - - # Use EBI ENA API to get file size (more reliable than NCBI for size info) - ena_url = f"https://www.ebi.ac.uk/ena/portal/api/filereport?accession={sra_id}&result=read_run&fields=run_accession,fastq_bytes,sra_bytes,read_count,base_count" - - try: - response = await client.get(ena_url, timeout=30.0) - response.raise_for_status() - - lines = response.text.strip().split('\n') - if len(lines) >= 2: - # Parse TSV response - headers = lines[0].split('\t') - values = lines[1].split('\t') - - data = dict(zip(headers, values)) - - # Get SRA size if available, otherwise estimate from FASTQ - sra_bytes = data.get('sra_bytes', '') - fastq_bytes = data.get('fastq_bytes', '') - read_count = data.get('read_count', '0') - base_count = data.get('base_count', '0') - - # Calculate sizes - sra_size_mb = 0 - if sra_bytes and sra_bytes.isdigit(): - sra_size_mb = int(sra_bytes) / (1024 * 1024) - elif fastq_bytes: - # Estimate SRA size as ~40% of FASTQ (compressed) - fastq_sizes = fastq_bytes.split(';') - total_fastq = sum(int(x) for x in fastq_sizes if x.isdigit()) - sra_size_mb = (total_fastq * 0.4) / (1024 * 1024) - - # Estimate FASTQ size (SRA * 2.5 for decompressed) - estimated_fastq_mb = sra_size_mb * 2.5 - - total_size_bytes += sra_size_mb * 1024 * 1024 - - results.append({ - "sra_id": sra_id, - "sra_size_mb": round(sra_size_mb, 2), - "estimated_fastq_size_mb": round(estimated_fastq_mb, 2), - "read_count": int(read_count) if read_count.isdigit() else 0, - "base_count": int(base_count) if base_count.isdigit() else 0, - "warning": self._get_size_warning(sra_size_mb) - }) - else: - results.append({ - "sra_id": sra_id, - "sra_size_mb": "unknown", - "estimated_fastq_size_mb": "unknown", - "warning": "Could not retrieve size information from ENA" - }) - - except Exception as e: - results.append({ - "sra_id": sra_id, - "sra_size_mb": "unknown", - "estimated_fastq_size_mb": "unknown", - "warning": f"Error querying ENA: {str(e)}" - }) - - except Exception as e: - raise SRAError(f"Error estimating sizes: {str(e)}") - - # Calculate total - total_mb = total_size_bytes / (1024 * 1024) - total_gb = total_mb / 1024 - - return { - "dry_run": True, - "sra_count": len(sra_ids), - "individual_estimates": results, - "total_sra_size_mb": round(total_mb, 2), - "total_sra_size_gb": round(total_gb, 2), - "estimated_total_fastq_size_gb": round(total_gb * 2.5, 2), - "safety_warnings": self._get_safety_warnings(total_mb) - } - - def _get_size_warning(self, size_mb: float) -> str: - """Get warning message based on file size.""" - if size_mb == 0: - return "Size unknown" - elif size_mb < 100: - return "Small file (< 100 MB)" - elif size_mb < 1024: - return "Medium file (100 MB - 1 GB)" - elif size_mb < 5120: # 5 GB - return "⚠️ LARGE FILE (1-5 GB) - Ensure sufficient disk space" - else: - return "🚨 VERY LARGE FILE (> 5 GB) - Requires explicit confirmation" - - def _get_safety_warnings(self, total_mb: float) -> List[str]: - """Get safety warnings for the total download size.""" - warnings = [] - - if total_mb > 1024: # > 1 GB - warnings.append("Total download exceeds 1 GB. Ensure you have sufficient disk space.") - if total_mb > 5120: # > 5 GB - warnings.append("Total download exceeds 5 GB. This will take significant time and space.") - if total_mb > 10240: # > 10 GB - warnings.append("🚨 WARNING: Total download exceeds 10 GB! Consider downloading individual files.") - - return warnings - - async def get_sra_metadata(self, sra_id: str) -> Dict[str, Any]: - """Get metadata for an SRA run. - - Args: - sra_id: SRA accession (e.g., 'SRR1234567') - - Returns: - SRA run metadata - """ - if not re.match(r'^[SED]RR\d+$', sra_id, re.IGNORECASE): - raise SRAError(f"Invalid SRA ID: {sra_id}") - - sra_id = sra_id.upper() - - try: - # Use E-Utilities to get SRA metadata - async with httpx.AsyncClient() as client: - # Search in SRA database - search_params = self._build_params({ - "db": "sra", - "term": sra_id, - "retmode": "json" - }) - - response = await client.get( - f"{self.SRA_EUTILS_URL}/esearch.fcgi", - params=search_params, - timeout=30.0 - ) - response.raise_for_status() - - search_data = response.json() - sra_ids = search_data.get('esearchresult', {}).get('idlist', []) - - if not sra_ids: - return { - "sra_id": sra_id, - "found": False, - "error": "SRA accession not found in database" - } - - # Get summary - summary_params = self._build_params({ - "db": "sra", - "id": sra_ids[0], - "retmode": "json" - }) - - response = await client.get( - f"{self.SRA_EUTILS_URL}/esummary.fcgi", - params=summary_params, - timeout=30.0 - ) - response.raise_for_status() - - summary_data = response.json() - - return { - "sra_id": sra_id, - "found": True, - "metadata": summary_data - } - - except Exception as e: - raise SRAError(f"Error getting SRA metadata: {str(e)}") - - def get_sra_download_url(self, sra_id: str) -> str: - """Get direct download URL for an SRA run. - - Args: - sra_id: SRA accession (e.g., 'SRR1234567') - - Returns: - Direct download URL - """ - sra_id = sra_id.upper() - - # Construct SRA download URL - # Format: https://sra-downloadb.be-md.ncbi.nlm.nih.gov/sos2/sra-pub-run-11/{SRRxxxxxxx}/{SRRxxxxxxx}.1 - base_url = "https://sra-downloadb.be-md.ncbi.nlm.nih.gov/sos2/sra-pub-run-11" - return f"{base_url}/{sra_id}/{sra_id}.1" - - def generate_download_commands( - self, - sra_ids: List[str], - method: str = "prefetch", - output_dir: Optional[str] = None - ) -> Dict[str, Any]: - """Generate commands to download SRA data. - - Args: - sra_ids: List of SRA accessions - method: Download method ('prefetch', 'wget', 'curl', or 'aspera') - output_dir: Optional output directory - - Returns: - Dictionary with download commands and instructions - """ - if not sra_ids: - raise SRAError("No SRA IDs provided") - - output_dir = output_dir or str(self.download_dir) - commands = [] - - if method == "prefetch": - # sra-toolkit prefetch command - toolkit_path = self.sra_toolkit_path or "" - prefetch = f"{toolkit_path}/prefetch" if toolkit_path else "prefetch" - - for sra_id in sra_ids: - cmd = f"{prefetch} -O {output_dir} {sra_id}" - commands.append({ - "sra_id": sra_id, - "command": cmd, - "description": f"Download {sra_id} using sra-toolkit prefetch" - }) - - elif method == "fasterq-dump": - # Directly download and convert to FASTQ - toolkit_path = self.sra_toolkit_path or "" - fasterq_dump = f"{toolkit_path}/fasterq-dump" if toolkit_path else "fasterq-dump" - - for sra_id in sra_ids: - cmd = f"{fasterq_dump} --outdir {output_dir} {sra_id}" - commands.append({ - "sra_id": sra_id, - "command": cmd, - "description": f"Download and convert {sra_id} to FASTQ" - }) - - elif method == "wget": - # Direct HTTP download - for sra_id in sra_ids: - url = self.get_sra_download_url(sra_id) - cmd = f"wget -P {output_dir} {url}" - commands.append({ - "sra_id": sra_id, - "command": cmd, - "description": f"Download {sra_id} using wget" - }) - - elif method == "curl": - # Direct HTTP download with curl - for sra_id in sra_ids: - url = self.get_sra_download_url(sra_id) - output_file = f"{output_dir}/{sra_id}.sra" - cmd = f"curl -o {output_file} {url}" - commands.append({ - "sra_id": sra_id, - "command": cmd, - "description": f"Download {sra_id} using curl" - }) - - elif method == "aspera": - # Aspera high-speed download - # Requires aspera-cli to be installed - for sra_id in sra_ids: - aspera_url = f"anonftp@ftp.ncbi.nlm.nih.gov:/sra/sra-instant/reads/ByRun/sra/{sra_id[:3]}/{sra_id[:6]}/{sra_id}/{sra_id}.sra" - cmd = f"ascp -QT -l 300m -P33001 -i $HOME/.aspera/connect/etc/asperaweb_id_dsa.openssh {aspera_url} {output_dir}" - commands.append({ - "sra_id": sra_id, - "command": cmd, - "description": f"Download {sra_id} using Aspera (high-speed)" - }) - - else: - raise SRAError(f"Unknown download method: {method}") - - return { - "sra_ids": sra_ids, - "method": method, - "output_dir": output_dir, - "commands": commands, - "notes": self._get_method_notes(method) - } - - def _get_method_notes(self, method: str) -> str: - """Get notes for a download method.""" - notes = { - "prefetch": ( - "Requires sra-toolkit (https://github.com/ncbi/sra-tools). " - "Downloads SRA files which can then be converted to FASTQ using fasterq-dump." - ), - "fasterq-dump": ( - "Requires sra-toolkit. Downloads and converts to FASTQ in one step. " - "May take longer but produces immediately usable files." - ), - "wget": ( - "Direct HTTP download. Works without sra-toolkit but downloads SRA format files " - "which need to be converted using fasterq-dump." - ), - "curl": ( - "Direct HTTP download using curl. Similar to wget but more portable." - ), - "aspera": ( - "High-speed download using Aspera protocol. Requires aspera-cli. " - "Fastest method for large files." - ) - } - return notes.get(method, "") - - async def download_sra( - self, - sra_id: str, - convert_to_fastq: bool = False, - output_dir: Optional[Path] = None - ) -> Dict[str, Any]: - """Download an SRA file. - - Note: This method downloads SRA files directly. For production use, - it's recommended to use sra-toolkit prefetch/fasterq-dump instead. - - Args: - sra_id: SRA accession - convert_to_fastq: Whether to convert to FASTQ (requires sra-toolkit) - output_dir: Optional output directory - - Returns: - Download results - """ - sra_id = sra_id.upper() - output_dir = output_dir or self.download_dir - output_dir.mkdir(parents=True, exist_ok=True) - - url = self.get_sra_download_url(sra_id) - sra_file = output_dir / f"{sra_id}.sra" - - try: - # Download the SRA file - async with httpx.AsyncClient() as client: - async with client.stream("GET", url, timeout=300.0) as response: - response.raise_for_status() - - with open(sra_file, 'wb') as f: - async for chunk in response.aiter_bytes(): - f.write(chunk) - - result = { - "sra_id": sra_id, - "sra_file": str(sra_file), - "size_mb": round(sra_file.stat().st_size / (1024*1024), 2), - "converted_to_fastq": False - } - - # Convert to FASTQ if requested - if convert_to_fastq: - fastq_result = await self._convert_to_fastq(sra_file, output_dir) - result["converted_to_fastq"] = True - result["fastq_files"] = fastq_result - - return result - - except Exception as e: - # Clean up partial download - if sra_file.exists(): - sra_file.unlink() - raise SRAError(f"Download failed: {str(e)}") - - async def _convert_to_fastq( - self, - sra_file: Path, - output_dir: Path - ) -> List[str]: - """Convert SRA file to FASTQ using fasterq-dump. - - Args: - sra_file: Path to SRA file - output_dir: Output directory for FASTQ files - - Returns: - List of generated FASTQ files - """ - toolkit_path = self.sra_toolkit_path or "" - fasterq_dump = f"{toolkit_path}/fasterq-dump" if toolkit_path else "fasterq-dump" - - # Check if fasterq-dump is available - try: - result = subprocess.run( - [fasterq_dump, "--version"], - capture_output=True, - text=True, - timeout=10 - ) - if result.returncode != 0: - raise SRAError("fasterq-dump not found. Please install sra-toolkit.") - except FileNotFoundError: - raise SRAError( - "fasterq-dump not found. Please install sra-toolkit: " - "https://github.com/ncbi/sra-tools" - ) - - # Run fasterq-dump - cmd = [ - fasterq_dump, - "--outdir", str(output_dir), - "--threads", "4", - str(sra_file) - ] - - # Run in thread pool to avoid blocking - loop = asyncio.get_event_loop() - result = await loop.run_in_executor( - None, - lambda: subprocess.run(cmd, capture_output=True, text=True, timeout=3600) - ) - - if result.returncode != 0: - raise SRAError(f"fasterq-dump failed: {result.stderr}") - - # Find generated FASTQ files - sra_id = sra_file.stem - fastq_files = [] - for pattern in [f"{sra_id}*.fastq", f"{sra_id}*.fastq.gz"]: - fastq_files.extend(output_dir.glob(pattern)) - - return [str(f) for f in fastq_files] - - async def download_and_convert( - self, - sra_id: str, - output_dir: Optional[Path] = None, - split_3: bool = True, - check_refseq: bool = True, - dry_run: bool = False, - confirm_large: bool = False - ) -> Dict[str, Any]: - """Download SRA data using prefetch and convert to FASTQ using fastq-dump. - - SAFETY FEATURES (as per maintainer recommendations): - - Dry-run mode: Estimate sizes before downloading - - Size warnings: Alerts for large files (>1GB, >5GB) - - Explicit confirmation: Required for very large downloads - - Explicit output directory: Must be provided for large files - - This is the recommended workflow for downloading and converting SRA data: - 1. Uses prefetch to download SRA file (handles large files better) - 2. Uses fastq-dump --split-3 to convert to FASTQ (handles paired-end properly) - - Args: - sra_id: SRA accession ID (e.g., 'SRR1234567') - output_dir: Optional output directory (default: download_dir/sra_id) - split_3: Use --split-3 for 3-way splitting (recommended for mate-pairs) - check_refseq: Whether to check/download reference sequences - dry_run: If True, only estimate sizes without downloading (default: False) - confirm_large: Must be True to download files >5GB (safety check) - - Returns: - Dictionary with download and conversion results, or dry-run estimates - - Raises: - SRAError: If output_dir not provided for large files, or if confirm_large=False for >5GB files - """ - sra_id = sra_id.upper() - - # SAFETY CHECK 1: Estimate size before downloading - size_estimate = await self.estimate_sra_size([sra_id]) - total_mb = size_estimate.get("total_sra_size_mb", 0) - - # SAFETY CHECK 2: Dry-run mode - return estimates without downloading - if dry_run: - return { - "mode": "dry_run", - "sra_id": sra_id, - "size_estimate": size_estimate, - "note": "To proceed with download, call with dry_run=False" - } - - # SAFETY CHECK 3: Explicit output directory required for large files - if total_mb > 1024 and output_dir is None: # > 1GB - raise SRAError( - f"SAFETY CHECK: File size is ~{total_mb/1024:.1f} GB. " - f"Large downloads require an explicit output directory. " - f"Please provide output_dir parameter. " - f"Tip: Run with dry_run=True first to see size estimates." - ) - - # SAFETY CHECK 4: Confirmation required for very large files - if total_mb > 5120 and not confirm_large: # > 5GB - raise SRAError( - f"SAFETY CHECK: File size is ~{total_mb/1024:.1f} GB (>5GB). " - f"This is a VERY LARGE download that will consume significant " - f"disk space and time. To proceed, set confirm_large=True. " - f"Tip: Run with dry_run=True first to see detailed estimates." - ) - - output_dir = output_dir or (self.download_dir / sra_id) - output_dir.mkdir(parents=True, exist_ok=True) - - # Check for sra-toolkit - toolkit_check = self.check_sra_toolkit() - if not toolkit_check["all_available"]: - raise SRAError( - "sra-toolkit not found. Please install from: " - "https://github.com/ncbi/sra-tools" - ) - - toolkit_path = self.sra_toolkit_path or "" - prefetch = f"{toolkit_path}/prefetch" if toolkit_path else "prefetch" - fastq_dump = f"{toolkit_path}/fastq-dump" if toolkit_path else "fastq-dump" - - result = { - "sra_id": sra_id, - "output_dir": str(output_dir), - "size_estimate_mb": total_mb, - "safety_warnings": size_estimate.get("safety_warnings", []), - "steps": [] - } - - try: - # Step 1: Download using prefetch - import logging - logger = logging.getLogger(__name__) - - cmd = [prefetch, "--progress", "--output-directory", str(output_dir)] - - # Handle refseq checking - if not check_refseq: - cmd.extend(["--check-rs", "no"]) - - cmd.append(sra_id) - - loop = asyncio.get_event_loop() - prefetch_result = await loop.run_in_executor( - None, - lambda: subprocess.run( - cmd, - capture_output=True, - text=True, - timeout=3600 # 1 hour timeout for large files - ) - ) - - if prefetch_result.returncode != 0: - raise SRAError(f"prefetch failed: {prefetch_result.stderr}") - - # Find the downloaded SRA file - sra_file = output_dir / sra_id / f"{sra_id}.sra" - if not sra_file.exists(): - # Try alternative locations - alt_paths = [ - output_dir / f"{sra_id}.sra", - self.download_dir / sra_id / f"{sra_id}.sra", - Path(f"{sra_id}/{sra_id}.sra"), - ] - for alt_path in alt_paths: - if alt_path.exists(): - sra_file = alt_path - break - - if not sra_file.exists(): - raise SRAError(f"SRA file not found after download: {sra_file}") - - sra_size_mb = round(sra_file.stat().st_size / (1024*1024), 2) - - result["steps"].append({ - "step": "download", - "status": "success", - "sra_file": str(sra_file), - "sra_size_mb": sra_size_mb - }) - - # Step 2: Convert to FASTQ using fastq-dump - print(f"Step 2: Converting {sra_id} to FASTQ using fastq-dump...") - - cmd = [fastq_dump, "--outdir", str(output_dir)] - - if split_3: - cmd.append("--split-3") - else: - cmd.append("--split-files") - - cmd.append(str(sra_file)) - - fastq_result = await loop.run_in_executor( - None, - lambda: subprocess.run( - cmd, - capture_output=True, - text=True, - timeout=3600 # 1 hour timeout - ) - ) - - if fastq_result.returncode != 0: - raise SRAError(f"fastq-dump failed: {fastq_result.stderr}") - - # Find generated FASTQ files - fastq_files = [] - for pattern in [f"{sra_id}*.fastq", f"{sra_id}*.fastq.gz"]: - fastq_files.extend(output_dir.glob(pattern)) - - # Also check in sra_id subdirectory - if (output_dir / sra_id).exists(): - for pattern in [f"{sra_id}*.fastq", f"{sra_id}*.fastq.gz"]: - fastq_files.extend((output_dir / sra_id).glob(pattern)) - - fastq_info = [] - total_fastq_size_mb = 0 - for f in fastq_files: - size_mb = round(f.stat().st_size / (1024*1024), 2) - total_fastq_size_mb += size_mb - - # Count reads (each read = 4 lines) - line_count = 0 - try: - with open(f, 'r') as fp: - for _ in fp: - line_count += 1 - if line_count >= 4: - break - # Get total lines - result_count = subprocess.run( - ["wc", "-l", str(f)], - capture_output=True, - text=True - ) - total_lines = int(result_count.stdout.split()[0]) - read_count = total_lines // 4 - except: - read_count = "unknown" - - fastq_info.append({ - "file": str(f.name), - "path": str(f), - "size_mb": size_mb, - "reads": read_count - }) - - result["steps"].append({ - "step": "convert", - "status": "success", - "fastq_files": fastq_info, - "total_fastq_size_mb": total_fastq_size_mb - }) - - result["status"] = "success" - result["total_size_mb"] = sra_size_mb + total_fastq_size_mb - - return result - - except Exception as e: - result["status"] = "failed" - result["error"] = str(e) - raise SRAError(f"Download and convert failed: {str(e)}") - - def check_sra_toolkit(self) -> Dict[str, Any]: - """Check if sra-toolkit is installed and available. - - Returns: - Status information about sra-toolkit - """ - toolkit_path = self.sra_toolkit_path or "" - tools = ["prefetch", "fastq-dump", "fasterq-dump", "vdb-validate"] - - results = {} - all_found = True - - for tool in tools: - cmd = f"{toolkit_path}/{tool}" if toolkit_path else tool - try: - result = subprocess.run( - [cmd, "--version"], - capture_output=True, - text=True, - timeout=10 - ) - results[tool] = { - "available": result.returncode == 0, - "version": result.stdout.strip() if result.returncode == 0 else None, - "path": cmd - } - if result.returncode != 0: - all_found = False - except FileNotFoundError: - results[tool] = {"available": False, "path": cmd} - all_found = False - except Exception as e: - results[tool] = {"available": False, "error": str(e)} - all_found = False - - return { - "all_available": all_found, - "toolkit_path": toolkit_path or "System PATH", - "tools": results, - "installation_url": "https://github.com/ncbi/sra-tools/wiki/02.-Installing-SRA-Toolkit" - } diff --git a/GEO/geo_mcp_server/pyproject.toml b/GEO/geo_mcp_server/pyproject.toml deleted file mode 100644 index 0fe5662..0000000 --- a/GEO/geo_mcp_server/pyproject.toml +++ /dev/null @@ -1,72 +0,0 @@ -[build-system] -requires = ["hatchling", "hatch-vcs"] -build-backend = "hatchling.build" - -[project] -name = "geo-mcp-sra" -dynamic = ["version"] -description = "An enhanced MCP server for accessing GEO data with SRA raw sequencing support" -readme = "README.md" -license = {text = "MIT"} -requires-python = ">=3.10" -keywords = ["mcp", "geo", "sra", "bioinformatics", "gene-expression", "ncbi", "rna-seq", "fastq"] -authors = [ - {name = "GEO MCP Contributors"} -] -classifiers = [ - "Development Status :: 4 - Beta", - "Intended Audience :: Science/Research", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13", - "Topic :: Scientific/Engineering :: Bio-Informatics", - "Topic :: Software Development :: Libraries :: Python Modules", -] -dependencies = [ - "mcp>=1.9.0", - "httpx>=0.27.0", - "aiofiles>=23.0.0", - "pydantic>=2.0.0", -] - -[project.urls] -Homepage = "https://github.com/yourusername/geo-mcp-sra" -Repository = "https://github.com/yourusername/geo-mcp-sra" -Documentation = "https://github.com/yourusername/geo-mcp-sra#readme" -"Bug Tracker" = "https://github.com/yourusername/geo-mcp-sra/issues" - -[project.scripts] -geo-mcp-sra = "server:main" - -[project.optional-dependencies] -dev = [ - "pytest>=7.0.0", - "pytest-asyncio>=0.21.0", - "black>=23.0.0", - "ruff>=0.1.0", - "mypy>=1.0.0", -] - -[tool.hatch.version] -source = "vcs" - -[tool.hatch.build.targets.wheel] -packages = ["geomcp_sra"] - -[tool.black] -line-length = 100 -target-version = ['py310'] - -[tool.ruff] -line-length = 100 -target-version = "py310" - -[tool.mypy] -python_version = "3.10" -warn_return_any = true -warn_unused_configs = true -disallow_untyped_defs = true diff --git a/GEO/geo_mcp_server/requirements.txt b/GEO/geo_mcp_server/requirements.txt deleted file mode 100644 index ad97d61..0000000 --- a/GEO/geo_mcp_server/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -mcp>=1.9.0 -httpx>=0.27.0 -aiofiles>=23.0.0 -pydantic>=2.0.0 diff --git a/GEO/geo_mcp_server/server.py b/GEO/geo_mcp_server/server.py deleted file mode 100644 index f921c08..0000000 --- a/GEO/geo_mcp_server/server.py +++ /dev/null @@ -1,1031 +0,0 @@ -#!/usr/bin/env python3 -""" -GEO MCP Server with SRA Support - -An enhanced Model Context Protocol (MCP) server for accessing -GEO (Gene Expression Omnibus) data through NCBI E-Utils API, -with additional support for SRA (Sequence Read Archive) raw data queries. - -Usage: - python server.py # Run MCP stdio server - python server.py --http # Run HTTP server - python server.py --http --port 8080 # Run HTTP server on custom port - python server.py --init # Initialize configuration -""" - -import argparse -import asyncio -import json -import sys -from pathlib import Path -from typing import Optional, List -from enum import Enum - -from pydantic import BaseModel, Field, field_validator, ConfigDict -from mcp.server.fastmcp import FastMCP - -# Import our modules -from geomcp_sra.config import load_config, validate_config, create_config_template, get_config -from geomcp_sra.geo_search import GEOSearchClient, GEOSearchError -from geomcp_sra.geo_download import GEODownloadClient, GEODownloadError -from geomcp_sra.sra_handler import SRAHandler, SRAError - - -# Initialize MCP server -mcp = FastMCP("geo_mcp") - - -# ============================================================================ -# Enums and Response Formats -# ============================================================================ - -class ResponseFormat(str, Enum): - """Output format for tool responses.""" - MARKDOWN = "markdown" - JSON = "json" - - -class DownloadMethod(str, Enum): - """SRA download methods.""" - PREFETCH = "prefetch" - FASTERQ_DUMP = "fasterq-dump" - WGET = "wget" - CURL = "curl" - ASPERA = "aspera" - - -# ============================================================================ -# Pydantic Models for Input Validation -# ============================================================================ - -class SearchInput(BaseModel): - """Base input for search operations.""" - model_config = ConfigDict(str_strip_whitespace=True) - - term: str = Field( - ..., - description="Search term (e.g., 'breast cancer', 'GSE12345', 'RNA-seq')", - min_length=1, - max_length=500 - ) - retmax: int = Field( - default=20, - description="Maximum number of results to return", - ge=1, - le=1000 - ) - response_format: ResponseFormat = Field( - default=ResponseFormat.JSON, - description="Output format: 'json' for structured data or 'markdown' for readable text" - ) - - -class SearchWithTypesInput(SearchInput): - """Input for search with record type filtering.""" - record_types: Optional[List[str]] = Field( - default=None, - description="Filter for specific record types: GSE, GSM, GPL, GDS" - ) - - -class GeoIdInput(BaseModel): - """Input for GEO ID operations.""" - model_config = ConfigDict(str_strip_whitespace=True) - - geo_id: str = Field( - ..., - description="GEO accession ID (e.g., GSE12345, GSM789, GPL456, GDS123)", - pattern=r'^(GSE|GSM|GPL|GDS)\d+$' - ) - - -class DownloadInput(GeoIdInput): - """Input for download operations.""" - db_type: str = Field( - default="gse", - description="Database type: gse, gsm, gpl, or gds" - ) - output_dir: Optional[str] = Field( - default=None, - description="Optional custom output directory" - ) - file_types: Optional[List[str]] = Field( - default=None, - description="File types to download (for series: soft, matrix, miniml, supplementary)" - ) - - -class SRASearchInput(BaseModel): - """Input for SRA search operations.""" - model_config = ConfigDict(str_strip_whitespace=True) - - gse_id: str = Field( - ..., - description="GEO Series accession ID (e.g., GSE12345)", - pattern=r'^GSE\d+$' - ) - - -class SRADownloadInput(BaseModel): - """Input for SRA download command generation.""" - model_config = ConfigDict(str_strip_whitespace=True) - - sra_ids: List[str] = Field( - ..., - description="List of SRA accession IDs (e.g., ['SRR1234567', 'SRR1234568'])", - min_length=1 - ) - method: DownloadMethod = Field( - default=DownloadMethod.PREFETCH, - description="Download method: prefetch, fasterq-dump, wget, curl, or aspera" - ) - output_dir: Optional[str] = Field( - default=None, - description="Optional output directory for downloads" - ) - - -class SRADirectDownloadInput(BaseModel): - """Input for direct SRA download.""" - model_config = ConfigDict(str_strip_whitespace=True) - - sra_id: str = Field( - ..., - description="SRA accession ID (e.g., SRR1234567)", - pattern=r'^[SED]RR\d+$' - ) - convert_to_fastq: bool = Field( - default=False, - description="Whether to convert SRA to FASTQ format (requires sra-toolkit)" - ) - output_dir: Optional[str] = Field( - default=None, - description="Optional output directory" - ) - - -class CleanupInput(BaseModel): - """Input for cleanup operations.""" - model_config = ConfigDict(str_strip_whitespace=True) - - geo_id: Optional[str] = Field( - default=None, - description="Optional specific GEO ID to remove" - ) - db_type: Optional[str] = Field( - default=None, - description="Optional database type filter for cleanup (gse, gsm, gpl, gds, sra)" - ) - - -# ============================================================================ -# Helper Functions -# ============================================================================ - -def format_as_markdown(data: dict, title: str = "Results") -> str: - """Format results as markdown for human readability.""" - lines = [f"# {title}", ""] - - def format_value(value, indent=0): - prefix = " " * indent - if isinstance(value, dict): - for k, v in value.items(): - if isinstance(v, (dict, list)): - lines.append(f"{prefix}- **{k}:**") - format_value(v, indent + 1) - else: - lines.append(f"{prefix}- **{k}:** {v}") - elif isinstance(value, list): - for item in value: - if isinstance(item, dict): - lines.append(f"{prefix}-") - format_value(item, indent + 1) - else: - lines.append(f"{prefix}- {item}") - else: - lines.append(f"{prefix}{value}") - - format_value(data) - return "\n".join(lines) - - -def handle_error(error: Exception) -> str: - """Format error messages consistently.""" - if isinstance(error, GEOSearchError): - return f"GEO Search Error: {str(error)}" - elif isinstance(error, GEODownloadError): - return f"GEO Download Error: {str(error)}" - elif isinstance(error, SRAError): - return f"SRA Error: {str(error)}" - elif isinstance(error, ValueError): - return f"Validation Error: {str(error)}" - else: - return f"Error: {type(error).__name__}: {str(error)}" - - -# ============================================================================ -# GEO Search Tools -# ============================================================================ - -@mcp.tool( - name="geo_search", - annotations={ - "title": "Search GEO Database", - "readOnlyHint": True, - "destructiveHint": False, - "idempotentHint": True, - "openWorldHint": True - } -) -async def geo_search(params: SearchWithTypesInput) -> str: - '''Search GEO for all types of records (GSE, GSM, GPL, GDS). - - This tool searches across all GEO databases and returns categorized results - by record type (Series, Samples, Platforms, Datasets). - - Args: - params: Search parameters including term, retmax, record_types, and response_format - - Returns: - JSON or Markdown formatted search results - - Examples: - - Search for cancer studies: term="breast cancer" - - Find specific series: term="GSE12345" - - Search for RNA-seq data: term="RNA-seq" - - Filter to only series: record_types=["GSE"] - ''' - try: - client = GEOSearchClient() - result = await client.search_geo( - term=params.term, - retmax=params.retmax, - record_types=params.record_types - ) - - if params.response_format == ResponseFormat.MARKDOWN: - return format_as_markdown(result, f"GEO Search Results: '{params.term}'") - return json.dumps(result, indent=2) - - except Exception as e: - return handle_error(e) - - -@mcp.tool( - name="geo_search_series", - annotations={ - "title": "Search GEO Series", - "readOnlyHint": True, - "destructiveHint": False, - "idempotentHint": True, - "openWorldHint": True - } -) -async def geo_search_series(params: SearchInput) -> str: - '''Search for GEO Series (GSE) - complete experiments. - - Args: - params: Search parameters including term and retmax - - Returns: - JSON or Markdown formatted GSE search results - ''' - try: - client = GEOSearchClient() - result = await client.search_geo_series(params.term, params.retmax) - - if params.response_format == ResponseFormat.MARKDOWN: - return format_as_markdown(result, f"GEO Series Search: '{params.term}'") - return json.dumps(result, indent=2) - - except Exception as e: - return handle_error(e) - - -@mcp.tool( - name="geo_search_samples", - annotations={ - "title": "Search GEO Samples", - "readOnlyHint": True, - "destructiveHint": False, - "idempotentHint": True, - "openWorldHint": True - } -) -async def geo_search_samples(params: SearchInput) -> str: - '''Search for GEO Samples (GSM) - individual samples. - - Args: - params: Search parameters including term and retmax - - Returns: - JSON or Markdown formatted GSM search results - ''' - try: - client = GEOSearchClient() - result = await client.search_geo_samples(params.term, params.retmax) - - if params.response_format == ResponseFormat.MARKDOWN: - return format_as_markdown(result, f"GEO Sample Search: '{params.term}'") - return json.dumps(result, indent=2) - - except Exception as e: - return handle_error(e) - - -@mcp.tool( - name="geo_search_platforms", - annotations={ - "title": "Search GEO Platforms", - "readOnlyHint": True, - "destructiveHint": False, - "idempotentHint": True, - "openWorldHint": True - } -) -async def geo_search_platforms(params: SearchInput) -> str: - '''Search for GEO Platforms (GPL) - array/sequencing platforms. - - Args: - params: Search parameters including term and retmax - - Returns: - JSON or Markdown formatted GPL search results - ''' - try: - client = GEOSearchClient() - result = await client.search_geo_platforms(params.term, params.retmax) - - if params.response_format == ResponseFormat.MARKDOWN: - return format_as_markdown(result, f"GEO Platform Search: '{params.term}'") - return json.dumps(result, indent=2) - - except Exception as e: - return handle_error(e) - - -@mcp.tool( - name="geo_search_datasets", - annotations={ - "title": "Search GEO Datasets", - "readOnlyHint": True, - "destructiveHint": False, - "idempotentHint": True, - "openWorldHint": True - } -) -async def geo_search_datasets(params: SearchInput) -> str: - '''Search for GEO Datasets (GDS) - curated gene expression datasets. - - Args: - params: Search parameters including term and retmax - - Returns: - JSON or Markdown formatted GDS search results - ''' - try: - client = GEOSearchClient() - result = await client.search_geo_datasets(params.term, params.retmax) - - if params.response_format == ResponseFormat.MARKDOWN: - return format_as_markdown(result, f"GEO Dataset Search: '{params.term}'") - return json.dumps(result, indent=2) - - except Exception as e: - return handle_error(e) - - -@mcp.tool( - name="geo_search_profiles", - annotations={ - "title": "Search GEO Profiles", - "readOnlyHint": True, - "destructiveHint": False, - "idempotentHint": True, - "openWorldHint": True - } -) -async def geo_search_profiles(params: SearchInput) -> str: - '''Search GEO Profiles database for gene expression profiles. - - Args: - params: Search parameters including term and retmax - - Returns: - JSON or Markdown formatted GEO Profiles search results - ''' - try: - client = GEOSearchClient() - result = await client.search_geo_profiles(params.term, params.retmax) - - if params.response_format == ResponseFormat.MARKDOWN: - return format_as_markdown(result, f"GEO Profiles Search: '{params.term}'") - return json.dumps(result, indent=2) - - except Exception as e: - return handle_error(e) - - -# ============================================================================ -# GEO Download Tools -# ============================================================================ - -@mcp.tool( - name="geo_download_series", - annotations={ - "title": "Download GEO Series Data", - "readOnlyHint": False, - "destructiveHint": False, - "idempotentHint": True, - "openWorldHint": True - } -) -async def geo_download_series(params: DownloadInput) -> str: - '''Download GEO Series data files (SOFT, matrix, supplementary). - - Downloads data files for a GEO Series including: - - SOFT format metadata (soft) - - Series matrix file (matrix) - - MINiML XML (miniml) - - Supplementary files (supplementary) - - Args: - params: Download parameters including geo_id, file_types, and output_dir - - Returns: - JSON formatted download results - - Examples: - - Download SOFT file: geo_id="GSE12345", file_types=["soft"] - - Download all: geo_id="GSE12345", file_types=["soft", "matrix", "supplementary"] - ''' - try: - client = GEODownloadClient() - output_dir = Path(params.output_dir) if params.output_dir else None - - result = await client.download_geo_series( - gse_id=params.geo_id.upper(), - file_types=params.file_types, - output_dir=output_dir - ) - - return json.dumps(result, indent=2) - - except Exception as e: - return handle_error(e) - - -@mcp.tool( - name="geo_download_sample", - annotations={ - "title": "Download GEO Sample Data", - "readOnlyHint": False, - "destructiveHint": False, - "idempotentHint": True, - "openWorldHint": True - } -) -async def geo_download_sample(params: DownloadInput) -> str: - '''Download GEO Sample supplementary files. - - Downloads supplementary data files for a specific GEO Sample (GSM). - - Args: - params: Download parameters including geo_id (GSM) and output_dir - - Returns: - JSON formatted download results - ''' - try: - client = GEODownloadClient() - output_dir = Path(params.output_dir) if params.output_dir else None - - result = await client.download_geo_sample( - gsm_id=params.geo_id.upper(), - output_dir=output_dir - ) - - return json.dumps(result, indent=2) - - except Exception as e: - return handle_error(e) - - -@mcp.tool( - name="geo_get_download_status", - annotations={ - "title": "Get GEO Download Status", - "readOnlyHint": True, - "destructiveHint": False, - "idempotentHint": True, - "openWorldHint": False - } -) -async def geo_get_download_status(params: GeoIdInput) -> str: - '''Check if a GEO dataset has been downloaded. - - Args: - params: Parameters including geo_id and optional db_type - - Returns: - JSON formatted status information - ''' - try: - client = GEODownloadClient() - - # Determine db_type from geo_id prefix - db_type = params.geo_id[:3].lower() - - result = client.get_download_status( - accession=params.geo_id.upper(), - db_type=db_type - ) - - return json.dumps(result, indent=2) - - except Exception as e: - return handle_error(e) - - -@mcp.tool( - name="geo_list_downloads", - annotations={ - "title": "List Downloaded GEO Datasets", - "readOnlyHint": True, - "destructiveHint": False, - "idempotentHint": True, - "openWorldHint": False - } -) -async def geo_list_downloads(db_type: Optional[str] = None) -> str: - '''List all downloaded GEO datasets. - - Args: - db_type: Optional filter by database type (gse, gsm, gpl, gds, sra) - - Returns: - JSON formatted list of downloaded datasets - ''' - try: - client = GEODownloadClient() - result = client.list_downloaded_datasets(db_type) - return json.dumps(result, indent=2) - - except Exception as e: - return handle_error(e) - - -@mcp.tool( - name="geo_cleanup_downloads", - annotations={ - "title": "Clean Up GEO Downloads", - "readOnlyHint": False, - "destructiveHint": True, - "idempotentHint": False, - "openWorldHint": False - } -) -async def geo_cleanup_downloads(params: CleanupInput) -> str: - '''Clean up downloaded GEO files. - - Args: - params: Parameters including optional geo_id and db_type to remove - - Returns: - JSON formatted cleanup results - - Warning: - This is a destructive operation that deletes downloaded files. - ''' - try: - client = GEODownloadClient() - result = client.cleanup_downloads( - accession=params.geo_id, - db_type=params.db_type - ) - return json.dumps(result, indent=2) - - except Exception as e: - return handle_error(e) - - -# ============================================================================ -# SRA Tools (NEW - for raw sequencing data) -# ============================================================================ - -@mcp.tool( - name="sra_query_from_geo", - annotations={ - "title": "Query SRA Accessions from GEO Series", - "readOnlyHint": True, - "destructiveHint": False, - "idempotentHint": True, - "openWorldHint": True - } -) -async def sra_query_from_geo(params: SRASearchInput) -> str: - '''Query SRA Run information from a GEO Series. - - This tool extracts the mapping between GEO Samples (GSM) and - SRA Run accessions (SRR) from a GEO Series. This allows you to - identify which SRA files contain the raw sequencing data for a dataset. - - Args: - params: Parameters including gse_id - - Returns: - JSON formatted SRA accession information - - Example: - Input: gse_id="GSE12345" - Output: { - "gse_id": "GSE12345", - "total_samples": 10, - "samples_with_sra": 8, - "all_sra_accessions": ["SRR1234567", "SRR1234568", ...], - "samples": [ - { - "gsm_id": "GSM123456", - "sra_accessions": ["SRR1234567"] - } - ] - } - ''' - try: - handler = SRAHandler() - result = await handler.query_sra_from_geo(params.gse_id.upper()) - return json.dumps(result, indent=2) - - except Exception as e: - return handle_error(e) - - -@mcp.tool( - name="sra_get_metadata", - annotations={ - "title": "Get SRA Run Metadata", - "readOnlyHint": True, - "destructiveHint": False, - "idempotentHint": True, - "openWorldHint": True - } -) -async def sra_get_metadata(sra_id: str) -> str: - '''Get metadata for an SRA Run accession. - - Args: - sra_id: SRA accession ID (e.g., SRR1234567) - - Returns: - JSON formatted SRA metadata - ''' - try: - handler = SRAHandler() - result = await handler.get_sra_metadata(sra_id) - return json.dumps(result, indent=2) - - except Exception as e: - return handle_error(e) - - -@mcp.tool( - name="sra_generate_download_commands", - annotations={ - "title": "Generate SRA Download Commands", - "readOnlyHint": True, - "destructiveHint": False, - "idempotentHint": True, - "openWorldHint": False - } -) -async def sra_generate_download_commands(params: SRADownloadInput) -> str: - '''Generate commands to download SRA data. - - Generates download commands for SRA accessions using various methods: - - prefetch: Using sra-toolkit prefetch (recommended) - - fasterq-dump: Download and convert to FASTQ in one step - - wget: Direct HTTP download - - curl: Direct HTTP download using curl - - aspera: High-speed Aspera download - - Args: - params: Parameters including sra_ids list, method, and output_dir - - Returns: - JSON formatted commands and instructions - - Note: - This tool generates commands but does not execute them. - Run the commands in your terminal or use the shell tool. - ''' - try: - handler = SRAHandler() - result = handler.generate_download_commands( - sra_ids=[s.upper() for s in params.sra_ids], - method=params.method.value, - output_dir=params.output_dir - ) - return json.dumps(result, indent=2) - - except Exception as e: - return handle_error(e) - - -@mcp.tool( - name="sra_check_toolkit", - annotations={ - "title": "Check SRA Toolkit Installation", - "readOnlyHint": True, - "destructiveHint": False, - "idempotentHint": True, - "openWorldHint": False - } -) -async def sra_check_toolkit() -> str: - '''Check if SRA Toolkit is installed and available. - - Returns: - JSON formatted toolkit status and installation information - ''' - try: - handler = SRAHandler() - result = handler.check_sra_toolkit() - return json.dumps(result, indent=2) - - except Exception as e: - return handle_error(e) - - -@mcp.tool( - name="sra_download", - annotations={ - "title": "Download SRA Data Directly", - "readOnlyHint": False, - "destructiveHint": False, - "idempotentHint": True, - "openWorldHint": True - } -) -async def sra_download(params: SRADirectDownloadInput) -> str: - '''Download SRA data directly (without sra-toolkit). - - Note: For large files or multiple downloads, using sra-toolkit - prefetch is recommended instead. This method is suitable for - small files or when sra-toolkit is not available. - - Args: - params: Parameters including sra_id, convert_to_fastq, and output_dir - - Returns: - JSON formatted download results - ''' - try: - handler = SRAHandler() - output_dir = Path(params.output_dir) if params.output_dir else None - - result = await handler.download_sra( - sra_id=params.sra_id.upper(), - convert_to_fastq=params.convert_to_fastq, - output_dir=output_dir - ) - return json.dumps(result, indent=2) - - except Exception as e: - return handle_error(e) - - -class SRADownloadAndConvertInput(BaseModel): - """Input for SRA download and convert operations.""" - model_config = ConfigDict(str_strip_whitespace=True) - - sra_id: str = Field( - ..., - description="SRA accession ID (e.g., SRR1234567)", - pattern=r'^[SED]RR\d+$' - ) - output_dir: Optional[str] = Field( - default=None, - description="Optional output directory (default: ~/geo_downloads/sra/). REQUIRED for files >1GB" - ) - split_3: bool = Field( - default=True, - description="Use --split-3 for 3-way splitting (recommended for mate-pairs)" - ) - check_refseq: bool = Field( - default=True, - description="Whether to check/download reference sequences (disable to save space)" - ) - dry_run: bool = Field( - default=True, - description="SAFETY: If True (default), only estimates size without downloading. Set to False to actually download." - ) - confirm_large: bool = Field( - default=False, - description="SAFETY: Must be True to download files >5GB. Use dry_run=True first to check size." - ) - - -class SRASizeEstimateInput(BaseModel): - """Input for SRA size estimation (dry-run).""" - model_config = ConfigDict(str_strip_whitespace=True) - - sra_ids: List[str] = Field( - ..., - description="List of SRA accession IDs to estimate (e.g., ['SRR1234567', 'SRR1234568'])", - min_length=1 - ) - - -@mcp.tool( - name="sra_download_and_convert", - annotations={ - "title": "Download SRA and Convert to FASTQ", - "readOnlyHint": False, - "destructiveHint": False, - "idempotentHint": True, - "openWorldHint": True - } -) -async def sra_download_and_convert(params: SRADownloadAndConvertInput) -> str: - '''Download SRA data using prefetch and convert to FASTQ using fastq-dump. - - SAFETY FEATURES (following maintainer recommendations): - - DRY-RUN BY DEFAULT: dry_run=True (default) only estimates size without downloading - - SIZE WARNINGS: Alerts for files >1GB, requires confirmation for >5GB - - EXPLICIT OUTPUT: Required output_dir for files >1GB - - CONFIRMATION: confirm_large=True required for files >5GB - - RECOMMENDED WORKFLOW: - 1. First, run with dry_run=True to see size estimates - 2. If size is acceptable, run with dry_run=False and output_dir="/path" - 3. For large files (>5GB), also set confirm_large=True - - Args: - params: Parameters including sra_id, output_dir, split_3, check_refseq, dry_run, confirm_large - - Returns: - JSON formatted results with download/conversion details OR dry-run estimates - - Examples: - - Step 1: Check size first - sra_id="SRR1234567", dry_run=true - - - Step 2a: Download small file (<1GB) - sra_id="SRR1234567", dry_run=false - - - Step 2b: Download medium file (1-5GB) - sra_id="SRR1234567", dry_run=false, output_dir="/path/to/output" - - - Step 2c: Download large file (>5GB) - sra_id="SRR1234567", dry_run=false, output_dir="/path/to/output", confirm_large=true - ''' - try: - handler = SRAHandler() - output_dir = Path(params.output_dir) if params.output_dir else None - - result = await handler.download_and_convert( - sra_id=params.sra_id.upper(), - output_dir=output_dir, - split_3=params.split_3, - check_refseq=params.check_refseq, - dry_run=params.dry_run, - confirm_large=params.confirm_large - ) - return json.dumps(result, indent=2) - - except Exception as e: - return handle_error(e) - - -@mcp.tool( - name="sra_estimate_size", - annotations={ - "title": "Estimate SRA Download Size (Dry-Run)", - "readOnlyHint": True, - "destructiveHint": False, - "idempotentHint": True, - "openWorldHint": True - } -) -async def sra_estimate_size(params: SRASizeEstimateInput) -> str: - '''Estimate the size of SRA files before downloading (dry-run mode). - - This tool queries the EBI ENA database to get file size estimates without - actually downloading any data. Use this before sra_download_and_convert - to check if you have sufficient disk space. - - Args: - params: Parameters including list of sra_ids to estimate - - Returns: - JSON formatted size estimates and safety warnings: - - Individual file sizes (SRA and estimated FASTQ) - - Total download size - - Safety warnings for large files (>1GB, >5GB) - - Examples: - - Estimate single file: sra_ids=["SRR1234567"] - - Estimate multiple: sra_ids=["SRR1234567", "SRR1234568"] - ''' - try: - handler = SRAHandler() - - result = await handler.estimate_sra_size( - sra_ids=[s.upper() for s in params.sra_ids] - ) - return json.dumps(result, indent=2) - - except Exception as e: - return handle_error(e) - - -# ============================================================================ -# Main Entry Point -# ============================================================================ - -def init_config(): - """Initialize configuration file.""" - config_path = Path.home() / ".geo-mcp" / "config.json" - create_config_template(config_path) - print(f"\nConfiguration template created at: {config_path}") - print("\nPlease edit the file and add your email address (required by NCBI).") - print("Optionally, add your NCBI API key for higher rate limits.") - print(f"\nTo use with Claude Desktop, add this to your config:") - print(json.dumps({ - "mcpServers": { - "geo_mcp": { - "command": "python", - "args": [str(Path(__file__).resolve())], - "env": { - "CONFIG_PATH": str(config_path) - } - } - } - }, indent=2)) - - -def main(): - """Main entry point.""" - parser = argparse.ArgumentParser( - description="GEO MCP Server with SRA Support", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=""" -Examples: - python server.py --init # Initialize configuration - python server.py # Run MCP stdio server - python server.py --http # Run HTTP server on localhost:8000 - python server.py --http --port 8080 # Run HTTP server on custom port - """ - ) - - parser.add_argument( - "--init", - action="store_true", - help="Initialize configuration file" - ) - - parser.add_argument( - "--http", - action="store_true", - help="Run HTTP server instead of MCP stdio" - ) - - parser.add_argument( - "--host", - default="localhost", - help="Host for HTTP server (default: localhost)" - ) - - parser.add_argument( - "--port", - type=int, - default=8000, - help="Port for HTTP server (default: 8000)" - ) - - args = parser.parse_args() - - if args.init: - init_config() - return - - # Validate config before starting - try: - config = get_config() - validate_config(config) - except Exception as e: - print(f"Configuration error: {e}", file=sys.stderr) - print("Run with --init to create a configuration template.", file=sys.stderr) - sys.exit(1) - - if args.http: - # Run HTTP server - print(f"Starting HTTP server on http://{args.host}:{args.port}") - mcp.run(transport="streamable_http", host=args.host, port=args.port) - else: - # Run MCP stdio server - mcp.run() - - -if __name__ == "__main__": - main() From 85e904d1c538b4e4c6e6f5b1d5ea8873bc390015 Mon Sep 17 00:00:00 2001 From: DualK <92998962+KQDtianxiaK@users.noreply.github.com> Date: Wed, 25 Feb 2026 01:36:10 +0800 Subject: [PATCH 10/13] Delete __pycache__ directory --- __pycache__/server.cpython-312.pyc | Bin 31226 -> 0 bytes __pycache__/server.cpython-313.pyc | Bin 36166 -> 0 bytes 2 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 __pycache__/server.cpython-312.pyc delete mode 100644 __pycache__/server.cpython-313.pyc diff --git a/__pycache__/server.cpython-312.pyc b/__pycache__/server.cpython-312.pyc deleted file mode 100644 index 8646bebd9f56dfb62d7d69c08bd032461f7ff7d1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 31226 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 diff --git a/__pycache__/server.cpython-313.pyc b/__pycache__/server.cpython-313.pyc deleted file mode 100644 index ef9e707a3c2df96757109357dcead75a4ca91285..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 36166 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 K)v<-CJ^nB0d_F$_ From 309d58621b48a374e321cacc5dad773ca6634f61 Mon Sep 17 00:00:00 2001 From: DualK <92998962+KQDtianxiaK@users.noreply.github.com> Date: Wed, 25 Feb 2026 01:36:23 +0800 Subject: [PATCH 11/13] Delete geomcp_sra/__pycache__ directory --- geomcp_sra/__pycache__/__init__.cpython-313.pyc | Bin 467 -> 0 bytes geomcp_sra/__pycache__/config.cpython-312.pyc | Bin 5719 -> 0 bytes geomcp_sra/__pycache__/config.cpython-313.pyc | Bin 5846 -> 0 bytes .../__pycache__/geo_download.cpython-312.pyc | Bin 20369 -> 0 bytes .../__pycache__/geo_download.cpython-313.pyc | Bin 20411 -> 0 bytes .../__pycache__/geo_search.cpython-312.pyc | Bin 15626 -> 0 bytes .../__pycache__/geo_search.cpython-313.pyc | Bin 15327 -> 0 bytes .../__pycache__/sra_handler.cpython-312.pyc | Bin 23239 -> 0 bytes .../__pycache__/sra_handler.cpython-313.pyc | Bin 39122 -> 0 bytes 9 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 geomcp_sra/__pycache__/__init__.cpython-313.pyc delete mode 100644 geomcp_sra/__pycache__/config.cpython-312.pyc delete mode 100644 geomcp_sra/__pycache__/config.cpython-313.pyc delete mode 100644 geomcp_sra/__pycache__/geo_download.cpython-312.pyc delete mode 100644 geomcp_sra/__pycache__/geo_download.cpython-313.pyc delete mode 100644 geomcp_sra/__pycache__/geo_search.cpython-312.pyc delete mode 100644 geomcp_sra/__pycache__/geo_search.cpython-313.pyc delete mode 100644 geomcp_sra/__pycache__/sra_handler.cpython-312.pyc delete mode 100644 geomcp_sra/__pycache__/sra_handler.cpython-313.pyc diff --git a/geomcp_sra/__pycache__/__init__.cpython-313.pyc b/geomcp_sra/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index cb57390858e61bc76a73f45eb3886afe9ff63662..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 467 zcmXw0!Ab)$5Z!HSm6T#Z@hAf#SkP|qstC1|(t}#Mcn;|%b_3gFon%|>Nk77yKj0rp z@#YUG{Q;8}=aBF+Gw;2btm<_VxH#T>mxo~xe0%U84zFX?aMxQ92N=X5hH(i?lXA1N z_#*w@9o%$>5DT0Mgt@dS#G?+xMV@Q4gme@Ll`@qGzAiMI!Uv7Ol0+CI)dW1tNna=d zy{8=gqgHS?RdQUICU9mMu_@|elEQ8GdH}t%dn+>oo#Ehgqscgz)^)KA%tji#gtu}M zi^oDZ127VdLkE*o&P0$%FbOC>C4W0^T3VOnId)oz`)+Kc`F8M|6pbe60} zbK5L0w<+a1p>$cJ)D7=VJ3uI9g-tb5`Vp-B`j!{bRN06}H_TXpk@FKd&S)YuohCWm zti;1LLKaafm9&($^5^BwJO)Jj_3dY diff --git a/geomcp_sra/__pycache__/config.cpython-312.pyc b/geomcp_sra/__pycache__/config.cpython-312.pyc deleted file mode 100644 index 0941b064bea2614f60934e8d5d67be00ae67a336..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5719 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 diff --git a/geomcp_sra/__pycache__/config.cpython-313.pyc b/geomcp_sra/__pycache__/config.cpython-313.pyc deleted file mode 100644 index a906d0d092fed1f8ddf4d8934e4966cafda7cb43..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5846 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 T4=A`j)Lc@~n)?)nq}~4yy~ai| diff --git a/geomcp_sra/__pycache__/geo_download.cpython-313.pyc b/geomcp_sra/__pycache__/geo_download.cpython-313.pyc deleted file mode 100644 index 255e72249b505426cdc0805d103505147b306da0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 20411 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 OJFB8~pHS$Ne*ZrJY#hh{ diff --git a/geomcp_sra/__pycache__/geo_search.cpython-312.pyc b/geomcp_sra/__pycache__/geo_search.cpython-312.pyc deleted file mode 100644 index b60d5d8b8e7fb75a6c14073ec5f103d971ce747b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15626 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 ZHMDW^A~!PL{{cU2{Wbsq diff --git a/geomcp_sra/__pycache__/geo_search.cpython-313.pyc b/geomcp_sra/__pycache__/geo_search.cpython-313.pyc deleted file mode 100644 index 879474c3cd475db2423665713124ceb6ddff64cd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15327 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 C#1c#Z diff --git a/geomcp_sra/__pycache__/sra_handler.cpython-312.pyc b/geomcp_sra/__pycache__/sra_handler.cpython-312.pyc deleted file mode 100644 index ceecbf8ce63cb5e4a8e24528cd0d652b82b61802..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 23239 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 From 5870364db8822b1ff2899be9b0538cbc2fe58317 Mon Sep 17 00:00:00 2001 From: DualK <92998962+KQDtianxiaK@users.noreply.github.com> Date: Wed, 25 Feb 2026 01:36:50 +0800 Subject: [PATCH 12/13] Delete LICENSE --- LICENSE | 28 ---------------------------- 1 file changed, 28 deletions(-) delete mode 100644 LICENSE diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 93e1f2d..0000000 --- a/LICENSE +++ /dev/null @@ -1,28 +0,0 @@ -BSD 3-Clause License - -Copyright (c) 2025, Matthias Flotho - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - -1. Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. - -2. Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - -3. Neither the name of the copyright holder nor the names of its - contributors may be used to endorse or promote products derived from - this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. From 3db63ac3f6699ba3c057280a166533be9f32583b Mon Sep 17 00:00:00 2001 From: KQDtianxiaK <1720573356@qq.com> Date: Wed, 25 Feb 2026 01:44:28 +0800 Subject: [PATCH 13/13] modify SKILL.md --- SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SKILL.md b/SKILL.md index f1e08d8..8c1de1a 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,5 +1,5 @@ --- -name: geo-mcp-sra +name: geo-mcp description: MCP server for accessing GEO (Gene Expression Omnibus) data with comprehensive SRA (Sequence Read Archive) raw sequencing support. Enables natural language search, metadata download, size estimation with dry-run mode, and safe FASTQ downloads with prefetch/fastq-dump integration. ---