lepanto1571 commited on
Commit
a1e520f
·
verified ·
1 Parent(s): bcccfa8

Upload 17 files

Browse files
.gitignore ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ env/
8
+ build/
9
+ develop-eggs/
10
+ dist/
11
+ downloads/
12
+ eggs/
13
+ .eggs/
14
+ lib/
15
+ lib64/
16
+ parts/
17
+ sdist/
18
+ var/
19
+ wheels/
20
+ *.egg-info/
21
+ .installed.cfg
22
+ *.egg
23
+
24
+ # Unit test / coverage reports
25
+ htmlcov/
26
+ .tox/
27
+ .coverage
28
+ .coverage.*
29
+ .cache
30
+ nosetests.xml
31
+ coverage.xml
32
+ *.cover
33
+ .hypothesis/
34
+ .pytest_cache/
35
+
36
+ # Jupyter Notebook
37
+ .ipynb_checkpoints
38
+
39
+ # VS Code
40
+ .vscode/
41
+
42
+ # Environment
43
+ .env
44
+ .venv
45
+ venv/
46
+ ENV/
47
+
48
+ # mypy
49
+ .mypy_cache/
50
+
51
+ # Logs
52
+ *.log
Dockerfile CHANGED
@@ -1,32 +1,32 @@
1
- FROM python:3.10-slim
2
-
3
- ENV PYTHONUNBUFFERED=1
4
- ENV PIP_NO_CACHE_DIR=1
5
- ENV DEBIAN_FRONTEND=noninteractive
6
- ENV MCP_LOCAL=2
7
-
8
- RUN apt-get update && apt-get install -y \
9
- build-essential \
10
- git \
11
- curl \
12
- wget \
13
- ca-certificates \
14
- libglib2.0-0 \
15
- libsm6 \
16
- libxext6 \
17
- libxrender-dev \
18
- libopenbabel-dev \
19
- python3-dev \
20
- libboost-all-dev \
21
- && rm -rf /var/lib/apt/lists/*
22
-
23
- WORKDIR /app
24
-
25
- COPY . /app
26
-
27
- RUN pip install --upgrade pip setuptools wheel build && \
28
- pip install -r requirements.txt
29
-
30
- EXPOSE 7860
31
-
32
- CMD ["python", "app.py"]
 
1
+ FROM python:3.10-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # Install system dependencies
6
+ RUN apt-get update && apt-get install -y \
7
+ libmagic1 \
8
+ && rm -rf /var/lib/apt/lists/*
9
+
10
+ # Copy requirements first for better caching
11
+ COPY requirements.txt .
12
+ RUN pip install -r requirements.txt
13
+
14
+ # Copy application files
15
+ COPY main.py .
16
+ COPY app.py .
17
+ COPY utils.py .
18
+ COPY schema.py .
19
+ COPY mcp_context.json .
20
+
21
+ # Copy test files
22
+ COPY tests/ tests/
23
+
24
+ # Create a test script
25
+ RUN echo '#!/bin/bash\npytest -v "$@"' > /usr/local/bin/run-tests && \
26
+ chmod +x /usr/local/bin/run-tests
27
+
28
+ # Expose port
29
+ EXPOSE 7860
30
+
31
+ # Run the integrated server
32
+ CMD ["python", "app.py"]
LICENSE CHANGED
@@ -1,27 +1,27 @@
1
- BSD 3-Clause License
2
-
3
- Copyright (c) 2023, Martin Buttenschoen
4
- All rights reserved.
5
-
6
- Redistribution and use in source and binary forms, with or without
7
- modification, are permitted provided that the following conditions are met:
8
-
9
- 1. Redistributions of source code must retain the above copyright notice, this
10
- list of conditions and the following disclaimer.
11
- 2. Redistributions in binary form must reproduce the above copyright notice,
12
- this list of conditions and the following disclaimer in the documentation
13
- and/or other materials provided with the distribution.
14
- 3. Neither the name of the copyright holder nor the names of its
15
- contributors may be used to endorse or promote products derived from
16
- this software without specific prior written permission.
17
-
18
- THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
19
- AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
20
- IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
21
- DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
22
- FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
23
- DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
24
- SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
25
- CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
26
- OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
27
- OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2023, Martin Buttenschoen
4
+ All rights reserved.
5
+
6
+ Redistribution and use in source and binary forms, with or without
7
+ modification, are permitted provided that the following conditions are met:
8
+
9
+ 1. Redistributions of source code must retain the above copyright notice, this
10
+ list of conditions and the following disclaimer.
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+ 3. Neither the name of the copyright holder nor the names of its
15
+ contributors may be used to endorse or promote products derived from
16
+ this software without specific prior written permission.
17
+
18
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
19
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
20
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
21
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
22
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
23
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
24
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
25
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
26
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
27
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
README.md CHANGED
@@ -1,151 +1,252 @@
1
- ---
2
- title: Posebusters Wrapper MCP
3
- emoji: 😻
4
- colorFrom: gray
5
- colorTo: pink
6
- sdk: gradio
7
- sdk_version: 5.36.2
8
- app_file: app.py
9
- pinned: false
10
- license: bsd-3-clause
11
- short_description: 'Unofficial MCP for PoseBusters: validates ligand–protein'
12
- ---
13
-
14
-
15
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
16
-
17
-
18
- # 🧪 PoseBusters Wrapper MCP
19
-
20
- This Hugging Face Space provides an **MCP-compatible API wrapper** around [PoseBusters](https://github.com/maabuu/posebusters), a command-line tool for validating the **physical and chemical plausibility** of molecular docking poses.
21
-
22
- > **⚠️ Disclaimer**
23
- > This project is unofficial and not affiliated with or endorsed by the original author of PoseBusters.
24
-
25
- ---
26
-
27
- ## ✅ Features
28
-
29
- **Supports molecular file uploads**
30
- Accepts ligand files (`.sdf`) and protein structures (`.pdb`) via either:
31
- - a simple web interface (Gradio tab UI), or
32
- - HTTP POST requests using `multipart/form-data`.
33
-
34
- 🔁 **Redocking validation (optional)**
35
- If a crystal ligand (`.sdf`) is provided, the API performs redocking validation by comparing it to the predicted ligand pose.
36
-
37
- ⚙️ **Leverages the `bust` CLI from PoseBusters**
38
- Internally, this wrapper uses the PoseBusters command-line tool to evaluate:
39
- - pose plausibility
40
- - chemical validity
41
- - geometry checks
42
-
43
- 📊 **Structured JSON responses**
44
- The output follows the [Model Context Protocol (MCP)](https://modelcontextprotocol.io), making it easy to use results in:
45
- - UI panels
46
- - workflows
47
- - logic pipelines
48
-
49
- 🤖 **MCP-Compatible API (for use in AI workflows)**
50
-
51
- This project is fully **MCP-compliant**, meaning it follows the [Model Context Protocol (MCP)](https://modelcontextprotocol.io), the standard for exposing tools in AI-driven workflows and UIs.
52
-
53
- - ✅ Exposes a valid `GET /mcp/context` for tool discovery and UI generation.
54
- - ✅ Accepts `POST /mcp/predict` with `multipart/form-data` for structured tool execution.
55
- - ✅ Returns results in structured JSON, ready for use in agents, chatbots, or pipelines.
56
- - ✅ When deployed on your own **Hugging Face Spaces**, it works as an **MCP server** that can be added to your toolset from the MCP badge.
57
-
58
- *🧠 What does this mean for you?*
59
-
60
- If you're using **VSCode with Hugging Face MCP**, **Claude**, or **any other MCP-compatible client**, you can:
61
-
62
- - 🔹 Add this tool directly from its Space card using the MCP badge.
63
- - 🔹 Interact with it using standard UI panels or programmatic workflows.
64
- - 🔹 Submit files like `.sdf` and `.pdb` and receive validated pose results.
65
-
66
- *Notes*
67
-
68
- - Although this app **does not use Gradio for serving**, it runs perfectly inside a **Gradio Space** or a Docker container, using `FastAPI` as its backend.
69
- - **You must set the environment variable `MCP_LOCAL=2`** to enable full MCP server behavior. This activates the `/mcp/context` and `/mcp/predict` endpoints.
70
- - It is **fully MCP-compatible** and **discoverable** once deployed with this variable.
71
- - You can host it on your own infrastructure, or push it to Spaces for instant integration into MCP-enabled environments.
72
- ---
73
-
74
- ## 🤗 How to Use (in Hugging Face Space)
75
-
76
- 👉 **Space UI**: [https://huggingface.co/spaces/lepanto1571/posebusters-wrapper-MCP](https://huggingface.co/spaces/lepanto1571/posebusters-wrapper-MCP)
77
-
78
- - Upload your `.sdf` ligand and `.pdb` protein files
79
- - (Optional) Add a `.sdf` with the ''true'' crystal ligand
80
- - Click on **Submit**
81
- - Results will appear in the interactive table
82
-
83
- ---
84
-
85
- ## 🐳 Run Locally with Docker
86
- ### 1. Clone the repository
87
-
88
- ```bash
89
- git clone https://github.com/lepanto1571/posebusters-wrapper-MCP.git
90
- cd posebusters-wrapper-MCP
91
- ```
92
-
93
- ### 2. Build the Docker image
94
-
95
- ```bash
96
- docker buildx build --load -t posebusters-wrapper-mcp .
97
- ```
98
- ### 3. Run the container
99
-
100
- ```bash
101
- docker run -p 7860:7860 -e MCP_LOCAL=2 posebusters-wrapper-mcp
102
- ```
103
- The server will start on http://localhost:7860.
104
-
105
- ---
106
-
107
- ## ⚙️ How to Use the API (MCP-compatible)
108
-
109
- ### 🔎 1. Discover API via MCP Context
110
-
111
- ```bash
112
- curl -X GET http://localhost:7860/mcp/context
113
- ```
114
-
115
- ### 2. Run validation (ligand + protein)
116
-
117
- ```bash
118
- curl -X POST http://localhost:7860/mcp/predict \
119
- -F action=validate_pose \
120
- -F ligand_path=@ligand.sdf \
121
- -F protein_path=@protein.pdb
122
- ```
123
- ### 3. Run redocking validation (ligand + crystal + protein)
124
-
125
- ```bash
126
- curl -X POST http://localhost:7860/mcp/predict \
127
- -F action=redocking_validation \
128
- -F ligand_path=@ligand.sdf \
129
- -F protein_path=@protein.pdb \
130
- -F crystal_path=@crystal.sdf
131
- ```
132
-
133
- ---
134
-
135
- ## 📚 Documentation & Citation
136
- ### 📖 PoseBusters documentation:
137
- Full usage and command-line reference available at
138
- https://posebusters.readthedocs.io/en/latest
139
-
140
- ### 🧾 Scientific paper:
141
- Martin Buttenschoen, Andreas Bender (2023). "PoseBusters: a consistency check for 3D protein–ligand binding poses".
142
- Read it on arXiv: https://arxiv.org/abs/2308.05777
143
-
144
- ### 💡 If you use this wrapper or PoseBusters in your work, consider citing the original paper.
145
-
146
- ---
147
-
148
- ## 📄 License and Credits
149
- This project uses [PoseBusters](https://github.com/maabuu/posebusters) by Martin Buttenschoen (© 2023) under the BSD 3-Clause License.
150
- This service is **not affiliated with or endorsed by** the original author.
151
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Posebusters Wrapper MCP
3
+ emoji: 😻
4
+ colorFrom: gray
5
+ colorTo: pink
6
+ sdk: gradio
7
+ sdk_version: 5.36.2
8
+ app_file: app.py
9
+ pinned: false
10
+ license: bsd-3-clause
11
+ short_description: 'MCP wrapper for PoseBusters: validates ligand–protein struct'
12
+ ---
13
+
14
+ Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
15
+
16
+
17
+ # 🧪 PoseBusters MCP Wrapper
18
+
19
+ This Hugging Face Space provides an **MCP-compatible API wrapper** around [PoseBusters](https://github.com/maabuu/posebusters), a command-line tool for validating the **physical and chemical plausibility** of molecular docking poses.
20
+
21
+ > **⚠️ Disclaimer**
22
+ > This project is unofficial and not affiliated with or endorsed by the original author of PoseBusters.
23
+
24
+ ---
25
+
26
+ ## ✅ Features
27
+
28
+ ✅ **Supports molecular file uploads**
29
+ Accepts ligand files (`.sdf`) and protein structures (`.pdb`) via either:
30
+ - a simple web interface (Gradio tab UI), or
31
+ - HTTP POST requests using `multipart/form-data`.
32
+
33
+ 🔁 **Redocking validation (optional)**
34
+ If a crystal ligand (`.sdf`) is provided, the API performs redocking validation by comparing it to the predicted ligand pose.
35
+
36
+ ⚙️ **Leverages the `bust` CLI from PoseBusters**
37
+ Internally, this wrapper uses the PoseBusters command-line tool to evaluate:
38
+ - pose plausibility
39
+ - chemical validity
40
+ - geometry checks
41
+
42
+ 📊 **Structured JSON responses**
43
+ The output follows the [Model Context Protocol (MCP)](https://modelcontextprotocol.io), making it easy to use results in:
44
+ - UI panels
45
+ - workflows
46
+ - logic pipelines
47
+
48
+ 🤖 **MCP-Compatible API (for use in AI workflows)**
49
+
50
+ This project is fully **MCP-compliant**, meaning it follows the [Model Context Protocol (MCP)](https://modelcontextprotocol.io), the standard for exposing tools in AI-driven workflows and UIs.
51
+
52
+ - ✅ Exposes a valid `GET /mcp/context` for tool discovery and UI generation.
53
+ - ✅ Accepts `POST /mcp/predict` with `multipart/form-data` for structured tool execution.
54
+ - ✅ Returns results in structured JSON, ready for use in agents, chatbots, or pipelines.
55
+ - ✅ When deployed on your own **Hugging Face Spaces**, it works as an **MCP server** that can be added to your toolset from the MCP badge.
56
+
57
+ *🧠 What does this mean for you?*
58
+
59
+ If you're using **VSCode with Hugging Face MCP**, **Claude**, or **any other MCP-compatible client**, you can:
60
+
61
+ - 🔹 Add this tool directly from its Space card using the MCP badge.
62
+ - 🔹 Interact with it using standard UI panels or programmatic workflows.
63
+ - 🔹 Submit files like `.sdf` and `.pdb` and receive validated pose results.
64
+
65
+ *Notes*
66
+
67
+ - The app runs perfectly inside a **Gradio Space** or a Docker container, using `FastAPI` as its backend.
68
+ - The app is **fully MCP-compatible** and **discoverable** once deployed.
69
+ - You can host it on your own infrastructure, or push it to Spaces for instant integration into MCP-enabled environments.
70
+
71
+ ---
72
+
73
+ ## 🤗 How to Use (in Hugging Face Space)
74
+
75
+ 👉 **Space UI**: [https://huggingface.co/spaces/lepanto1571/posebusters-wrapper-MCP](https://huggingface.co/spaces/lepanto1571/posebusters-wrapper-MCP)
76
+
77
+ - Upload your `.sdf` ligand and `.pdb` protein files
78
+ - (Optional) Add a `.sdf` with the ''true'' crystal ligand
79
+ - Click on **Submit**
80
+ - Results will appear in the interactive table
81
+
82
+ ---
83
+
84
+ ## 🐳 Run Locally with Docker
85
+ ### 1. Clone the repository
86
+
87
+ ```bash
88
+ git clone https://github.com/lepanto1571/posebusters-wrapper-MCP.git
89
+ cd posebusters-wrapper-MCP
90
+ ```
91
+
92
+ ### 2. Build the Docker image
93
+
94
+ ```bash
95
+ docker buildx build --load -t posebusters-wrapper-mcp .
96
+ ```
97
+ ### 3. Run the container
98
+
99
+ ```bash
100
+ docker run -p 7860:7860 posebusters-wrapper-mcp
101
+ ```
102
+ The server will start on http://localhost:7860.
103
+
104
+ ---
105
+
106
+ ## ⚙️ How to Use the API (MCP-compatible)
107
+
108
+ ### 🔎 1. Discover API via MCP Context
109
+
110
+ ```bash
111
+ # Using curl
112
+ curl -X GET http://localhost:7860/mcp/context
113
+
114
+ # Using Python
115
+ import requests
116
+ response = requests.get("http://localhost:7860/mcp/context")
117
+ context = response.json()
118
+ ```
119
+
120
+ ### 2. Run validation (ligand + protein)
121
+
122
+ ```bash
123
+ # Using curl
124
+ curl -X POST http://localhost:7860/mcp/predict \
125
+ -F action=validate_pose \
126
+ -F ligand_input=@ligand.sdf \
127
+ -F protein_input=@protein.pdb
128
+
129
+ # Using Python
130
+ import requests
131
+
132
+ files = {
133
+ 'ligand_input': ('ligand.sdf', open('ligand.sdf', 'rb')),
134
+ 'protein_input': ('protein.pdb', open('protein.pdb', 'rb'))
135
+ }
136
+ data = {'action': 'validate_pose'}
137
+
138
+ response = requests.post(
139
+ "http://localhost:7860/mcp/predict",
140
+ files=files,
141
+ data=data
142
+ )
143
+ results = response.json()
144
+ ```
145
+
146
+ ### 3. Run redocking validation (ligand + crystal + protein)
147
+
148
+ ```bash
149
+ # Using curl
150
+ curl -X POST http://localhost:7860/mcp/predict \
151
+ -F action=redocking_validation \
152
+ -F ligand_input=@ligand.sdf \
153
+ -F protein_input=@protein.pdb \
154
+ -F crystal_input=@crystal.sdf
155
+
156
+ # Using Python
157
+ import requests
158
+
159
+ files = {
160
+ 'ligand_input': ('ligand.sdf', open('ligand.sdf', 'rb')),
161
+ 'protein_input': ('protein.pdb', open('protein.pdb', 'rb')),
162
+ 'crystal_input': ('crystal.sdf', open('crystal.sdf', 'rb'))
163
+ }
164
+ data = {'action': 'redocking_validation'}
165
+
166
+ response = requests.post(
167
+ "http://localhost:7860/mcp/predict",
168
+ files=files,
169
+ data=data
170
+ )
171
+ results = response.json()
172
+ ```
173
+
174
+ ### Response Format
175
+
176
+ All responses follow the MCP standard format:
177
+
178
+ ```python
179
+ {
180
+ "object_id": "validation_results",
181
+ "data": {
182
+ "columns": ["ligand_id", "status", "passed/total", "details"],
183
+ "rows": [
184
+ ["mol1", "✅", "8/8", "All tests passed"],
185
+ # ... more results
186
+ ]
187
+ }
188
+ }
189
+ ```
190
+
191
+ ### Error Handling
192
+
193
+ The API uses standard HTTP status codes:
194
+ - 200: Success
195
+ - 400: Invalid request (wrong file type, missing required files)
196
+ - 500: Server error (validation failed, internal error)
197
+
198
+ Error responses include detailed messages:
199
+ ```python
200
+ {
201
+ "object_id": "validation_results",
202
+ "data": {
203
+ "columns": ["ligand_id", "status", "passed/total", "details"],
204
+ "rows": [
205
+ ["unknown", "❌", "0/0", "Detailed error message"]
206
+ ]
207
+ }
208
+ }
209
+ ```
210
+
211
+ ## 🧪 Development and Testing
212
+
213
+ ### Running Tests
214
+
215
+ Tests can be run directly using Docker:
216
+
217
+ ```bash
218
+ # Run tests with verbose output
219
+ docker run posebusters-wrapper-mcp pytest -v
220
+
221
+ # Run tests with coverage report
222
+ docker run posebusters-wrapper-mcp pytest --cov=. --cov-report=term-missing
223
+ ```
224
+
225
+ ### Validation
226
+
227
+ The API uses JSON Schema validation for:
228
+ - MCP Context (`/mcp/context`)
229
+ - Prediction Responses (`/mcp/predict`)
230
+ - File Types (MIME validation)
231
+
232
+ Schema definitions are in `schema.py`.
233
+
234
+ ---
235
+
236
+ ## 📚 Documentation & Citation
237
+ ### 📖 PoseBusters documentation:
238
+ Full usage and command-line reference available at
239
+ https://posebusters.readthedocs.io/en/latest
240
+
241
+ ### 🧾 Scientific paper:
242
+ Martin Buttenschoen, Andreas Bender (2023). "PoseBusters: a consistency check for 3D protein–ligand binding poses".
243
+ Read it on arXiv: https://arxiv.org/abs/2308.05777
244
+
245
+ ### 💡 If you use this wrapper or PoseBusters in your work, consider citing the original paper.
246
+
247
+ ---
248
+
249
+ ## 📄 License and Credits
250
+ This project uses [PoseBusters](https://github.com/maabuu/posebusters) by Martin Buttenschoen (© 2023) under the BSD 3-Clause License.
251
+ This service is **not affiliated with or endorsed by** the original author.
252
+
app.py CHANGED
@@ -1,242 +1,182 @@
1
- import gradio as gr
2
- from fastapi import FastAPI, Request, UploadFile, File, Form
3
- from fastapi.responses import JSONResponse
4
- import pandas as pd
5
- import traceback
6
- import tempfile
7
- import subprocess
8
- import os
9
- import csv
10
- import base64
11
- import uvicorn
12
-
13
- app = FastAPI()
14
- latest_results = []
15
-
16
-
17
- def validate_files(ligand_file, protein_file, crystal_file=None):
18
- if not ligand_file or not protein_file:
19
- raise ValueError("Both ligand (.sdf) and protein (.pdb) files must be uploaded.")
20
- ligand_path = ligand_file.name
21
- protein_path = protein_file.name
22
- crystal_path = crystal_file.name if crystal_file else None
23
- return ligand_path, protein_path, crystal_path
24
-
25
-
26
- def format_results_to_mcp(csv_text: str, stderr_text: str = "", ligand_path: str = None) -> list:
27
- rows = []
28
- if not csv_text.strip():
29
- ligand_id = os.path.basename(ligand_path) if ligand_path else "unknown"
30
- if stderr_text.strip():
31
- rows.append([
32
- ligand_id,
33
- "❌",
34
- "0/0",
35
- stderr_text.strip().splitlines()[0]
36
- ])
37
- else:
38
- rows.append([
39
- ligand_id,
40
- "❌",
41
- "0/0",
42
- "PoseBusters output is empty."
43
- ])
44
- return rows
45
-
46
- reader = csv.DictReader(csv_text.strip().splitlines())
47
- for row in reader:
48
- ligand_id = row.get("molecule", "?")
49
- failed_tests = []
50
- passed_n = 0
51
- total_n = 0
52
-
53
- for k, v in row.items():
54
- if k in {"file", "molecule"}:
55
- continue
56
- if v == "True":
57
- passed_n += 1
58
- total_n += 1
59
- elif v == "False":
60
- failed_tests.append(k)
61
- total_n += 1
62
-
63
- rows.append([
64
- ligand_id,
65
- "✅" if not failed_tests else "❌",
66
- f"{passed_n}/{total_n}",
67
- ", ".join(failed_tests) if failed_tests else "All tests passed"
68
- ])
69
-
70
- return rows
71
-
72
-
73
- from fastapi import UploadFile, File, Form, Request
74
- from fastapi.responses import JSONResponse
75
-
76
- @app.post("/mcp/predict")
77
- async def predict(
78
- action: str = Form(...),
79
- ligand_path: UploadFile = File(None),
80
- protein_path: UploadFile = File(None),
81
- crystal_path: UploadFile = File(None),
82
- ):
83
- try:
84
- if not ligand_path or not protein_path:
85
- return JSONResponse(status_code=400, content={"error": "Missing ligand or protein file"})
86
-
87
- async def save(upload, suffix):
88
- with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
89
- tmp.write(await upload.read())
90
- return tmp.name
91
-
92
- ligand_file_path = await save(ligand_path, ".sdf")
93
- protein_file_path = await save(protein_path, ".pdb")
94
- crystal_file_path = await save(crystal_path, ".sdf") if crystal_path else None
95
-
96
- if action == "validate_pose":
97
- cmd = ["bust", ligand_file_path, "-p", protein_file_path, "--outfmt", "csv"]
98
-
99
- elif action == "redocking_validation":
100
- if not crystal_file_path:
101
- return JSONResponse(status_code=400, content={"error": "Missing crystal ligand file"})
102
- cmd = ["bust", ligand_file_path, "-l", crystal_file_path, "-p", protein_file_path, "--outfmt", "csv"]
103
-
104
- else:
105
- return JSONResponse(status_code=400, content={"error": f"Unknown action: {action}"})
106
-
107
- result = subprocess.run(cmd, capture_output=True, text=True)
108
- rows = format_results_to_mcp(result.stdout, result.stderr, ligand_file_path)
109
-
110
- return {
111
- "columns": ["ligand_id", "status", "passed/total", "details"],
112
- "rows": rows
113
- }
114
-
115
- except Exception as e:
116
- return {
117
- "error": str(e),
118
- "traceback": traceback.format_exc()
119
- }
120
-
121
-
122
-
123
- def gradio_ui(ligand_file, protein_file, crystal_file=None):
124
- try:
125
- ligand_path, protein_path, crystal_path = validate_files(ligand_file, protein_file, crystal_file)
126
-
127
- if crystal_path:
128
- cmd = ["bust", ligand_path, "-l", crystal_path, "-p", protein_path, "--outfmt", "csv"]
129
- else:
130
- cmd = ["bust", ligand_path, "-p", protein_path, "--outfmt", "csv"]
131
-
132
- result = subprocess.run(cmd, capture_output=True, text=True)
133
-
134
- rows = format_results_to_mcp(result.stdout, result.stderr, ligand_path)
135
-
136
- global latest_results
137
- latest_results = rows
138
-
139
- return {
140
- "columns": ["ligand_id", "status", "passed/total", "details"],
141
- "rows": rows
142
- }
143
-
144
- except Exception as e:
145
- return {
146
- "columns": ["ligand_id", "status", "passed/total", "details"],
147
- "rows": [[os.path.basename(ligand_file.name), "❌", "0/0", str(e)]],
148
- "error": str(e),
149
- "traceback": traceback.format_exc()
150
- }
151
-
152
- @app.get("/mcp/context")
153
- def get_mcp_context():
154
- return {
155
- "app": {
156
- "id": "lepanto1571.wrapper.posebusters",
157
- "name": "PoseBusters MCP wrapper",
158
- "version": "0.5"
159
- },
160
- "windows": [
161
- {
162
- "id": "posebusters_view",
163
- "title": "Validation Panel",
164
- "view": {
165
- "type": "form",
166
- "objects": ["ligand_input", "protein_input", "crystal_input", "validation_results"]
167
- }
168
- }
169
- ],
170
- "objects": [
171
- {"type": "file", "id": "ligand_input", "name": "Ligand file", "format": "sdf"},
172
- {"type": "file", "id": "protein_input", "name": "Protein file", "format": "pdb"},
173
- {"type": "file", "id": "crystal_input", "name": "Crystal ligand file (optional)", "format": "sdf", "optional": True},
174
- {
175
- "type": "table",
176
- "id": "validation_results",
177
- "columns": ["ligand_id", "status", "passed/total", "details"],
178
- "rows": latest_results
179
- }
180
- ],
181
- "actions": [
182
- {
183
- "name": "validate_pose",
184
- "description": "Validate ligand–protein structure",
185
- "parameters": {
186
- "ligand_path": "ligand_input",
187
- "protein_path": "protein_input"
188
- }
189
- },
190
- {
191
- "name": "redocking_validation",
192
- "description": "Validate redocking using a reference ligand",
193
- "parameters": {
194
- "ligand_path": "ligand_input",
195
- "crystal_path": "crystal_input",
196
- "protein_path": "protein_input"
197
- }
198
- }
199
- ]
200
- }
201
-
202
- import gradio.themes.base
203
-
204
- theme = gradio.themes.Base()
205
-
206
- iface = gr.Interface(
207
- fn=gradio_ui,
208
- inputs=[
209
- gr.File(label="Ligand (.sdf)", type="filepath"),
210
- gr.File(label="Protein (.pdb)", type="filepath"),
211
- gr.File(label="Crystal Ligand (.sdf, optional)", type="filepath")
212
- ],
213
- outputs="json",
214
- title="PoseBusters Wrapper MCP",
215
- description="Free MCP-compatible API for validating ligand–protein structures using PoseBusters.",
216
- theme=theme
217
- )
218
-
219
- context_button = gr.Interface(
220
- fn=get_mcp_context,
221
- inputs=[],
222
- outputs="json",
223
- title="MCP Context",
224
- description="Returns the MCP context metadata for the app.",
225
- theme=theme
226
- )
227
-
228
- demo = gr.TabbedInterface(
229
- interface_list=[iface, context_button],
230
- tab_names=["Validation", "MCP Context"],
231
- theme=theme
232
- )
233
-
234
- def main():
235
- if os.environ.get("MCP_LOCAL") == "2":
236
- print("Running MCP server on 0.0.0.0:7860")
237
- uvicorn.run(app, host="0.0.0.0", port=7860)
238
- else:
239
- demo.launch(server_name="0.0.0.0")
240
-
241
- if __name__ == "__main__":
242
- main()
 
1
+ # Standard library imports
2
+ import os
3
+ import io
4
+ import asyncio
5
+ from pathlib import Path
6
+ from typing import Any, Dict, Optional
7
+ from contextlib import contextmanager
8
+
9
+ # Third-party imports
10
+ import gradio as gr
11
+ from fastapi import FastAPI, File, Form, UploadFile
12
+ import uvicorn
13
+ import json
14
+
15
+ # Local imports
16
+ from utils import validate_mime_type, format_results_to_mcp
17
+ from main import predict, app as fastapi_app
18
+
19
+ # Check if we're running in Hugging Face Spaces
20
+ IS_HF_SPACE = os.getenv("SPACE_ID") is not None
21
+
22
+ # Store the original working directory
23
+ ORIGINAL_CWD = os.getcwd()
24
+
25
+ @contextmanager
26
+ def stable_cwd():
27
+ """Context manager to maintain a stable working directory."""
28
+ current_dir = os.getcwd()
29
+ try:
30
+ os.chdir(ORIGINAL_CWD)
31
+ yield
32
+ finally:
33
+ os.chdir(current_dir)
34
+
35
+ # Get MCP context with proper path handling
36
+ def get_mcp_context_wrapper() -> Dict[str, Any]:
37
+ """Wrapper around get_mcp_context that handles Spaces paths."""
38
+ try:
39
+ with stable_cwd():
40
+ # In Spaces, we know the file is in the root directory
41
+ if IS_HF_SPACE:
42
+ context_path = Path(ORIGINAL_CWD) / 'mcp_context.json'
43
+ if not context_path.exists():
44
+ raise FileNotFoundError(f"MCP context file not found at {context_path}")
45
+ with open(context_path, 'r') as f:
46
+ return json.load(f)
47
+ # Otherwise use the utility function that searches multiple locations
48
+ from utils import get_mcp_context
49
+ return get_mcp_context()
50
+ except Exception as e:
51
+ return {
52
+ "error": f"Failed to load MCP context: {str(e)}",
53
+ "app": {
54
+ "id": "lepanto1571.wrapper.posebusters",
55
+ "name": "PoseBusters MCP wrapper",
56
+ "version": "0.5"
57
+ }
58
+ }
59
+
60
+ async def process_files(ligand_file, protein_file, crystal_file=None):
61
+ """Process uploaded files using the robust predict function from main.py."""
62
+ try:
63
+ with stable_cwd():
64
+ # Handle file uploads in a way that works in both environments
65
+ async def create_upload_file(file_obj) -> Optional[UploadFile]:
66
+ if file_obj is None:
67
+ return None
68
+
69
+ try:
70
+ # Get the filename, handling both string paths and file-like objects
71
+ if hasattr(file_obj, 'orig_name'):
72
+ filename = file_obj.orig_name
73
+ elif hasattr(file_obj, 'name'):
74
+ filename = file_obj.name
75
+ else:
76
+ filename = str(file_obj)
77
+
78
+ # Create a UploadFile object that works in both environments
79
+ if hasattr(file_obj, 'read'):
80
+ # File-like object (e.g. in Spaces or already open file)
81
+ content = await file_obj.read() if asyncio.iscoroutinefunction(file_obj.read) else file_obj.read()
82
+ if hasattr(file_obj, 'seek'):
83
+ file_obj.seek(0) # Reset file pointer for future reads
84
+ file_like = io.BytesIO(content if isinstance(content, bytes) else content.encode('utf-8'))
85
+ return UploadFile(
86
+ file=file_like,
87
+ filename=Path(filename).name
88
+ )
89
+ elif hasattr(file_obj, 'name') and os.path.exists(file_obj.name):
90
+ # Local file path that exists
91
+ return UploadFile(
92
+ file=open(file_obj.name, "rb"),
93
+ filename=Path(filename).name
94
+ )
95
+ else:
96
+ raise ValueError(f"Unable to read file: {filename}")
97
+
98
+ except Exception as e:
99
+ raise ValueError(f"Failed to process file {getattr(file_obj, 'name', str(file_obj))}: {str(e)}")
100
+
101
+ # Convert files to UploadFile objects
102
+ ligand_input = await create_upload_file(ligand_file)
103
+ protein_input = await create_upload_file(protein_file)
104
+ crystal_input = await create_upload_file(crystal_file)
105
+
106
+ if not ligand_input or not protein_input:
107
+ raise ValueError("Both ligand and protein files are required")
108
+
109
+ # Use the action based on whether crystal file is provided
110
+ action = "redocking_validation" if crystal_input else "validate_pose"
111
+
112
+ # Call the robust predict function
113
+ return await predict(
114
+ action=action,
115
+ ligand_input=ligand_input,
116
+ protein_input=protein_input,
117
+ crystal_input=crystal_input
118
+ )
119
+
120
+ except Exception as e:
121
+ return {
122
+ "object_id": "validation_results",
123
+ "data": {
124
+ "columns": ["ligand_id", "status", "passed/total", "details"],
125
+ "rows": [[
126
+ Path(getattr(ligand_file, 'orig_name',
127
+ getattr(ligand_file, 'name', 'unknown'))).stem,
128
+ "",
129
+ "0/0",
130
+ str(e)
131
+ ]]
132
+ }
133
+ }
134
+
135
+ theme = gr.themes.Base()
136
+
137
+ # Main validation interface
138
+ validation_ui = gr.Interface(
139
+ fn=process_files,
140
+ inputs=[
141
+ gr.File(label="Ligand (.sdf)", type="filepath", file_count="single"),
142
+ gr.File(label="Protein (.pdb)", type="filepath", file_count="single"),
143
+ gr.File(label="Crystal Ligand (.sdf, optional)", type="filepath", file_count="single")
144
+ ],
145
+ outputs="json",
146
+ title="PoseBusters Validation",
147
+ description="Upload files to validate ligand–protein structures using PoseBusters.",
148
+ theme=theme,
149
+ cache_examples=False # Disable caching to prevent file handle issues
150
+ )
151
+
152
+ # MCP Context viewer
153
+ context_ui = gr.Interface(
154
+ fn=get_mcp_context_wrapper,
155
+ inputs=None,
156
+ outputs=gr.JSON(),
157
+ title="MCP Context",
158
+ description="View the MCP context metadata that defines this tool's capabilities.",
159
+ theme=theme,
160
+ cache_examples=False # Disable caching to prevent file handle issues
161
+ )
162
+
163
+ # Combined interface
164
+ demo = gr.TabbedInterface(
165
+ interface_list=[validation_ui, context_ui],
166
+ tab_names=["💊 Validation", "🔍 MCP Context"],
167
+ title="PoseBusters MCP Wrapper",
168
+ theme=theme
169
+ )
170
+
171
+ def main():
172
+ """Run the server based on environment."""
173
+ if IS_HF_SPACE:
174
+ # In Hugging Face Spaces, just run the Gradio app
175
+ demo.launch(server_name="0.0.0.0")
176
+ else:
177
+ # For local/Docker, mount Gradio into FastAPI and run the server
178
+ app = gr.mount_gradio_app(fastapi_app, demo, path="/")
179
+ uvicorn.run(app, host="0.0.0.0", port=7860)
180
+
181
+ if __name__ == "__main__":
182
+ main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
data/1of6_ligand.sdf ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ DTY
2
+ RDKit 3D
3
+
4
+ 13 13 0 0 1 0 0 0 0 0999 V2000
5
+ 30.6770 -3.3700 50.5160 N 0 0 0 0 0 0 0 0 0 0 0 0
6
+ 31.3780 -3.1310 49.2550 C 0 0 2 0 0 0 0 0 0 0 0 0
7
+ 31.9670 -4.4050 48.6940 C 0 0 0 0 0 0 0 0 0 0 0 0
8
+ 32.0170 -5.4920 49.3380 O 0 0 0 0 0 0 0 0 0 0 0 0
9
+ 32.4890 -2.1160 49.5260 C 0 0 0 0 0 0 0 0 0 0 0 0
10
+ 32.0300 -0.7150 49.8830 C 0 0 0 0 0 0 0 0 0 0 0 0
11
+ 32.2930 -0.2240 51.1250 C 0 0 0 0 0 0 0 0 0 0 0 0
12
+ 31.3170 0.0570 48.9730 C 0 0 0 0 0 0 0 0 0 0 0 0
13
+ 31.8570 1.0320 51.4460 C 0 0 0 0 0 0 0 0 0 0 0 0
14
+ 30.9070 1.3420 49.2730 C 0 0 0 0 0 0 0 0 0 0 0 0
15
+ 31.2050 1.8270 50.5410 C 0 0 0 0 0 0 0 0 0 0 0 0
16
+ 30.8150 2.9790 50.9180 O 0 0 0 0 0 0 0 0 0 0 0 0
17
+ 32.3720 -4.3160 47.5370 O 0 0 0 0 0 0 0 0 0 0 0 0
18
+ 2 1 1 1
19
+ 2 3 1 0
20
+ 2 5 1 0
21
+ 3 4 2 0
22
+ 3 13 1 0
23
+ 5 6 1 0
24
+ 6 7 2 0
25
+ 6 8 1 0
26
+ 7 9 1 0
27
+ 8 10 2 0
28
+ 9 11 2 0
29
+ 10 11 1 0
30
+ 11 12 1 0
31
+ M END
data/1of6_ligands.sdf ADDED
@@ -0,0 +1,256 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ DTY
2
+ RDKit 3D
3
+
4
+ 13 13 0 0 1 0 0 0 0 0999 V2000
5
+ 30.2540 -2.5680 74.6470 N 0 0 0 0 0 0 0 0 0 0 0 0
6
+ 29.6440 -2.9290 75.9150 C 0 0 2 0 0 0 0 0 0 0 0 0
7
+ 30.4450 -3.9540 76.6840 C 0 0 0 0 0 0 0 0 0 0 0 0
8
+ 31.3690 -4.4990 76.1010 O 0 0 0 0 0 0 0 0 0 0 0 0
9
+ 28.2660 -3.5000 75.5090 C 0 0 0 0 0 0 0 0 0 0 0 0
10
+ 27.2580 -2.4780 75.0340 C 0 0 0 0 0 0 0 0 0 0 0 0
11
+ 26.8210 -1.4240 75.8630 C 0 0 0 0 0 0 0 0 0 0 0 0
12
+ 26.7940 -2.4670 73.7230 C 0 0 0 0 0 0 0 0 0 0 0 0
13
+ 25.8850 -0.4270 75.4190 C 0 0 0 0 0 0 0 0 0 0 0 0
14
+ 25.9000 -1.4590 73.2560 C 0 0 0 0 0 0 0 0 0 0 0 0
15
+ 25.4270 -0.3980 74.0850 C 0 0 0 0 0 0 0 0 0 0 0 0
16
+ 24.6220 0.5320 73.6460 O 0 0 0 0 0 0 0 0 0 0 0 0
17
+ 30.1990 -4.2930 77.8630 O 0 0 0 0 0 0 0 0 0 0 0 0
18
+ 2 1 1 6
19
+ 2 3 1 0
20
+ 2 5 1 0
21
+ 3 4 2 0
22
+ 3 13 1 0
23
+ 5 6 1 0
24
+ 6 7 2 0
25
+ 6 8 1 0
26
+ 7 9 1 0
27
+ 8 10 2 0
28
+ 9 11 2 0
29
+ 10 11 1 0
30
+ 11 12 1 0
31
+ M END
32
+ $$$$
33
+ DTY
34
+ RDKit 3D
35
+
36
+ 13 13 0 0 1 0 0 0 0 0999 V2000
37
+ 9.9250 13.9750 69.7540 N 0 0 0 0 0 0 0 0 0 0 0 0
38
+ 9.6350 13.8330 71.1740 C 0 0 2 0 0 0 0 0 0 0 0 0
39
+ 8.4120 14.6560 71.5400 C 0 0 0 0 0 0 0 0 0 0 0 0
40
+ 7.9590 15.4550 70.7310 O 0 0 0 0 0 0 0 0 0 0 0 0
41
+ 10.8440 14.2980 71.9980 C 0 0 0 0 0 0 0 0 0 0 0 0
42
+ 11.9810 13.3300 72.0410 C 0 0 0 0 0 0 0 0 0 0 0 0
43
+ 13.2260 13.7020 71.5180 C 0 0 0 0 0 0 0 0 0 0 0 0
44
+ 11.8380 12.0740 72.5910 C 0 0 0 0 0 0 0 0 0 0 0 0
45
+ 14.2940 12.8090 71.5560 C 0 0 0 0 0 0 0 0 0 0 0 0
46
+ 12.8680 11.1570 72.5970 C 0 0 0 0 0 0 0 0 0 0 0 0
47
+ 14.1380 11.5440 72.1180 C 0 0 0 0 0 0 0 0 0 0 0 0
48
+ 15.1170 10.6980 72.1020 O 0 0 0 0 0 0 0 0 0 0 0 0
49
+ 7.8390 14.5290 72.6020 O 0 0 0 0 0 0 0 0 0 0 0 0
50
+ 2 1 1 6
51
+ 2 3 1 0
52
+ 2 5 1 0
53
+ 3 4 2 0
54
+ 3 13 1 0
55
+ 5 6 1 0
56
+ 6 7 2 0
57
+ 6 8 1 0
58
+ 7 9 1 0
59
+ 8 10 2 0
60
+ 9 11 2 0
61
+ 10 11 1 0
62
+ 11 12 1 0
63
+ M END
64
+ $$$$
65
+ DTY
66
+ RDKit 3D
67
+
68
+ 13 13 0 0 1 0 0 0 0 0999 V2000
69
+ 26.1300 22.5850 52.9470 N 0 0 0 0 0 0 0 0 0 0 0 0
70
+ 26.5090 22.8290 51.5510 C 0 0 2 0 0 0 0 0 0 0 0 0
71
+ 26.3970 24.3040 51.1390 C 0 0 0 0 0 0 0 0 0 0 0 0
72
+ 25.9330 25.2450 51.8910 O 0 0 0 0 0 0 0 0 0 0 0 0
73
+ 25.5510 21.9320 50.7760 C 0 0 0 0 0 0 0 0 0 0 0 0
74
+ 25.7750 20.4360 50.9080 C 0 0 0 0 0 0 0 0 0 0 0 0
75
+ 27.0050 19.8350 50.4840 C 0 0 0 0 0 0 0 0 0 0 0 0
76
+ 24.8040 19.6220 51.4360 C 0 0 0 0 0 0 0 0 0 0 0 0
77
+ 27.2260 18.4680 50.6160 C 0 0 0 0 0 0 0 0 0 0 0 0
78
+ 25.0330 18.2430 51.5690 C 0 0 0 0 0 0 0 0 0 0 0 0
79
+ 26.1980 17.6590 51.1350 C 0 0 0 0 0 0 0 0 0 0 0 0
80
+ 26.3460 16.4460 51.3140 O 0 0 0 0 0 0 0 0 0 0 0 0
81
+ 26.8520 24.5980 50.0070 O 0 0 0 0 0 0 0 0 0 0 0 0
82
+ 2 1 1 1
83
+ 2 3 1 0
84
+ 2 5 1 0
85
+ 3 4 2 0
86
+ 3 13 1 0
87
+ 5 6 1 0
88
+ 6 7 2 0
89
+ 6 8 1 0
90
+ 7 9 1 0
91
+ 8 10 2 0
92
+ 9 11 2 0
93
+ 10 11 1 0
94
+ 11 12 1 0
95
+ M END
96
+ $$$$
97
+ DTY
98
+ RDKit 3D
99
+
100
+ 13 13 0 0 1 0 0 0 0 0999 V2000
101
+ 84.1470 43.7570 49.3420 N 0 0 0 0 0 0 0 0 0 0 0 0
102
+ 84.7810 44.0620 48.0210 C 0 0 2 0 0 0 0 0 0 0 0 0
103
+ 85.3370 42.8890 47.2840 C 0 0 0 0 0 0 0 0 0 0 0 0
104
+ 85.8700 43.0440 46.2130 O 0 0 0 0 0 0 0 0 0 0 0 0
105
+ 85.9370 45.0260 48.2620 C 0 0 0 0 0 0 0 0 0 0 0 0
106
+ 85.3930 46.3460 48.7450 C 0 0 0 0 0 0 0 0 0 0 0 0
107
+ 85.6440 46.7700 50.0290 C 0 0 0 0 0 0 0 0 0 0 0 0
108
+ 84.6580 47.1680 47.9060 C 0 0 0 0 0 0 0 0 0 0 0 0
109
+ 85.1490 47.9950 50.4910 C 0 0 0 0 0 0 0 0 0 0 0 0
110
+ 84.1750 48.3890 48.3590 C 0 0 0 0 0 0 0 0 0 0 0 0
111
+ 84.4030 48.8190 49.6570 C 0 0 0 0 0 0 0 0 0 0 0 0
112
+ 83.9390 49.9130 50.0990 O 0 0 0 0 0 0 0 0 0 0 0 0
113
+ 85.2190 41.7490 47.7280 O 0 0 0 0 0 0 0 0 0 0 0 0
114
+ 2 1 1 1
115
+ 2 3 1 0
116
+ 2 5 1 0
117
+ 3 4 2 0
118
+ 3 13 1 0
119
+ 5 6 1 0
120
+ 6 7 2 0
121
+ 6 8 1 0
122
+ 7 9 1 0
123
+ 8 10 2 0
124
+ 9 11 2 0
125
+ 10 11 1 0
126
+ 11 12 1 0
127
+ M END
128
+ $$$$
129
+ DTY
130
+ RDKit 3D
131
+
132
+ 13 13 0 0 1 0 0 0 0 0999 V2000
133
+ 64.2120 59.0260 71.4630 N 0 0 0 0 0 0 0 0 0 0 0 0
134
+ 63.9150 58.6880 72.8890 C 0 0 2 0 0 0 0 0 0 0 0 0
135
+ 62.7790 59.4940 73.4060 C 0 0 0 0 0 0 0 0 0 0 0 0
136
+ 62.2610 59.3030 74.5120 O 0 0 0 0 0 0 0 0 0 0 0 0
137
+ 65.1260 59.0470 73.7540 C 0 0 0 0 0 0 0 0 0 0 0 0
138
+ 66.3630 58.2190 73.5400 C 0 0 0 0 0 0 0 0 0 0 0 0
139
+ 67.4950 58.7590 72.8930 C 0 0 0 0 0 0 0 0 0 0 0 0
140
+ 66.3690 56.9090 74.0520 C 0 0 0 0 0 0 0 0 0 0 0 0
141
+ 68.6130 57.9270 72.7050 C 0 0 0 0 0 0 0 0 0 0 0 0
142
+ 67.5080 56.1000 73.8880 C 0 0 0 0 0 0 0 0 0 0 0 0
143
+ 68.6070 56.5980 73.2120 C 0 0 0 0 0 0 0 0 0 0 0 0
144
+ 69.6140 55.8620 73.0710 O 0 0 0 0 0 0 0 0 0 0 0 0
145
+ 62.3520 60.3180 72.6160 O 0 0 0 0 0 0 0 0 0 0 0 0
146
+ 2 1 1 6
147
+ 2 3 1 0
148
+ 2 5 1 0
149
+ 3 4 2 0
150
+ 3 13 1 0
151
+ 5 6 1 0
152
+ 6 7 2 0
153
+ 6 8 1 0
154
+ 7 9 1 0
155
+ 8 10 2 0
156
+ 9 11 2 0
157
+ 10 11 1 0
158
+ 11 12 1 0
159
+ M END
160
+ $$$$
161
+ DTY
162
+ RDKit 3D
163
+
164
+ 13 13 0 0 1 0 0 0 0 0999 V2000
165
+ 85.3020 43.0320 73.7720 N 0 0 0 0 0 0 0 0 0 0 0 0
166
+ 84.8700 42.5910 75.0950 C 0 0 2 0 0 0 0 0 0 0 0 0
167
+ 85.7370 41.5470 75.7070 C 0 0 0 0 0 0 0 0 0 0 0 0
168
+ 86.6680 41.0290 75.0590 O 0 0 0 0 0 0 0 0 0 0 0 0
169
+ 83.5050 42.0000 74.8990 C 0 0 0 0 0 0 0 0 0 0 0 0
170
+ 82.4380 43.0170 74.5710 C 0 0 0 0 0 0 0 0 0 0 0 0
171
+ 81.9960 43.9160 75.4990 C 0 0 0 0 0 0 0 0 0 0 0 0
172
+ 81.8350 43.0290 73.3200 C 0 0 0 0 0 0 0 0 0 0 0 0
173
+ 81.0170 44.8750 75.1840 C 0 0 0 0 0 0 0 0 0 0 0 0
174
+ 80.8710 43.9700 72.9940 C 0 0 0 0 0 0 0 0 0 0 0 0
175
+ 80.4220 44.9020 73.9230 C 0 0 0 0 0 0 0 0 0 0 0 0
176
+ 79.4820 45.7400 73.6110 O 0 0 0 0 0 0 0 0 0 0 0 0
177
+ 85.4920 41.1990 76.8520 O 0 0 0 0 0 0 0 0 0 0 0 0
178
+ 2 1 1 6
179
+ 2 3 1 0
180
+ 2 5 1 0
181
+ 3 4 2 0
182
+ 3 13 1 0
183
+ 5 6 1 0
184
+ 6 7 2 0
185
+ 6 8 1 0
186
+ 7 9 1 0
187
+ 8 10 2 0
188
+ 9 11 2 0
189
+ 10 11 1 0
190
+ 11 12 1 0
191
+ M END
192
+ $$$$
193
+ DTY
194
+ RDKit 3D
195
+
196
+ 13 13 0 0 1 0 0 0 0 0999 V2000
197
+ 30.6770 -3.3700 50.5160 N 0 0 0 0 0 0 0 0 0 0 0 0
198
+ 31.3780 -3.1310 49.2550 C 0 0 2 0 0 0 0 0 0 0 0 0
199
+ 31.9670 -4.4050 48.6940 C 0 0 0 0 0 0 0 0 0 0 0 0
200
+ 32.0170 -5.4920 49.3380 O 0 0 0 0 0 0 0 0 0 0 0 0
201
+ 32.4890 -2.1160 49.5260 C 0 0 0 0 0 0 0 0 0 0 0 0
202
+ 32.0300 -0.7150 49.8830 C 0 0 0 0 0 0 0 0 0 0 0 0
203
+ 32.2930 -0.2240 51.1250 C 0 0 0 0 0 0 0 0 0 0 0 0
204
+ 31.3170 0.0570 48.9730 C 0 0 0 0 0 0 0 0 0 0 0 0
205
+ 31.8570 1.0320 51.4460 C 0 0 0 0 0 0 0 0 0 0 0 0
206
+ 30.9070 1.3420 49.2730 C 0 0 0 0 0 0 0 0 0 0 0 0
207
+ 31.2050 1.8270 50.5410 C 0 0 0 0 0 0 0 0 0 0 0 0
208
+ 30.8150 2.9790 50.9180 O 0 0 0 0 0 0 0 0 0 0 0 0
209
+ 32.3720 -4.3160 47.5370 O 0 0 0 0 0 0 0 0 0 0 0 0
210
+ 2 1 1 1
211
+ 2 3 1 0
212
+ 2 5 1 0
213
+ 3 4 2 0
214
+ 3 13 1 0
215
+ 5 6 1 0
216
+ 6 7 2 0
217
+ 6 8 1 0
218
+ 7 9 1 0
219
+ 8 10 2 0
220
+ 9 11 2 0
221
+ 10 11 1 0
222
+ 11 12 1 0
223
+ M END
224
+ $$$$
225
+ DTY
226
+ RDKit 3D
227
+
228
+ 13 13 0 0 1 0 0 0 0 0999 V2000
229
+ 78.1870 69.1110 54.6720 N 0 0 0 0 0 0 0 0 0 0 0 0
230
+ 78.4910 69.2750 53.2300 C 0 0 2 0 0 0 0 0 0 0 0 0
231
+ 78.2650 70.7210 52.9630 C 0 0 0 0 0 0 0 0 0 0 0 0
232
+ 77.6840 71.4160 53.7710 O 0 0 0 0 0 0 0 0 0 0 0 0
233
+ 77.6090 68.4400 52.2900 C 0 0 0 0 0 0 0 0 0 0 0 0
234
+ 77.8710 66.9670 52.3560 C 0 0 0 0 0 0 0 0 0 0 0 0
235
+ 79.0090 66.4720 51.7490 C 0 0 0 0 0 0 0 0 0 0 0 0
236
+ 77.0040 66.0680 53.0050 C 0 0 0 0 0 0 0 0 0 0 0 0
237
+ 79.3270 65.1250 51.8140 C 0 0 0 0 0 0 0 0 0 0 0 0
238
+ 77.3050 64.7110 53.0120 C 0 0 0 0 0 0 0 0 0 0 0 0
239
+ 78.4750 64.2220 52.4400 C 0 0 0 0 0 0 0 0 0 0 0 0
240
+ 78.7940 62.9710 52.5270 O 0 0 0 0 0 0 0 0 0 0 0 0
241
+ 78.5830 71.2520 51.9460 O 0 0 0 0 0 0 0 0 0 0 0 0
242
+ 2 1 1 1
243
+ 2 3 1 0
244
+ 2 5 1 0
245
+ 3 4 2 0
246
+ 3 13 1 0
247
+ 5 6 1 0
248
+ 6 7 2 0
249
+ 6 8 1 0
250
+ 7 9 1 0
251
+ 8 10 2 0
252
+ 9 11 2 0
253
+ 10 11 1 0
254
+ 11 12 1 0
255
+ M END
256
+ $$$$
data/1of6_protein_one_lig_removed.pdb ADDED
The diff for this file is too large to render. See raw diff
 
main.py ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Standard library imports
2
+ import os
3
+ import subprocess
4
+ import tempfile
5
+ import traceback
6
+ from pathlib import Path
7
+ from typing import Dict, Any, Optional
8
+
9
+ # Third-party imports
10
+ import gradio as gr
11
+ from fastapi import FastAPI, File, Form, UploadFile, HTTPException
12
+ from fastapi.responses import JSONResponse
13
+ from jsonschema import validate, ValidationError
14
+ from schema import MCP_CONTEXT_SCHEMA, MCP_PREDICT_RESPONSE_SCHEMA
15
+
16
+ # Local imports
17
+ from utils import validate_mime_type, format_results_to_mcp, get_mcp_context, ALLOWED_MIME_TYPES
18
+
19
+ app = FastAPI(title="PoseBusters MCP API")
20
+
21
+ @app.post("/mcp/predict")
22
+ async def predict(
23
+ action: str = Form(...),
24
+ ligand_input: UploadFile = File(...),
25
+ protein_input: UploadFile = File(...),
26
+ crystal_input: Optional[UploadFile] = File(None),
27
+ ) -> Dict[str, Any]:
28
+ """
29
+ MCP-compliant prediction endpoint.
30
+ Validates file types and runs PoseBusters validation.
31
+ Response is validated against MCP_PREDICT_RESPONSE_SCHEMA.
32
+ """
33
+ try:
34
+ # Validate MIME types
35
+ validate_mime_type(ligand_input, ".sdf")
36
+ validate_mime_type(protein_input, ".pdb")
37
+ if crystal_input:
38
+ validate_mime_type(crystal_input, ".sdf")
39
+
40
+ async def save_upload(upload: UploadFile, suffix: str) -> str:
41
+ """Safely save uploaded file with secure naming."""
42
+ with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
43
+ content = await upload.read()
44
+ tmp.write(content)
45
+ return tmp.name
46
+
47
+ # Create a temporary directory for isolated execution
48
+ with tempfile.TemporaryDirectory() as tmpdir:
49
+ try:
50
+ # Save files in the temporary directory
51
+ ligand_path = await save_upload(ligand_input, ".sdf")
52
+ protein_path = await save_upload(protein_input, ".pdb")
53
+ crystal_path = await save_upload(crystal_input, ".sdf") if crystal_input else None
54
+
55
+ # Move to temporary directory
56
+ os.chdir(tmpdir)
57
+
58
+ if action == "validate_pose":
59
+ cmd = ["bust", ligand_path, "-p", protein_path, "--outfmt", "csv"]
60
+ elif action == "redocking_validation":
61
+ if not crystal_path:
62
+ raise HTTPException(status_code=400, detail="Missing crystal ligand file")
63
+ cmd = ["bust", ligand_path, "-l", crystal_path, "-p", protein_path, "--outfmt", "csv"]
64
+ else:
65
+ raise HTTPException(status_code=400, detail=f"Unknown action: {action}")
66
+
67
+ # Use list form of subprocess.run to avoid shell injection
68
+ # Add timeout of 5 minutes
69
+ result = subprocess.run(
70
+ cmd,
71
+ capture_output=True,
72
+ text=True,
73
+ check=False, # Don't raise on non-zero exit
74
+ timeout=300 # 5 minutes timeout
75
+ )
76
+
77
+ response = format_results_to_mcp(
78
+ result.stdout,
79
+ result.stderr,
80
+ Path(ligand_input.filename).stem
81
+ )
82
+ try:
83
+ validate(instance=response, schema=MCP_PREDICT_RESPONSE_SCHEMA)
84
+ return response
85
+ except ValidationError as e:
86
+ return JSONResponse(
87
+ status_code=500,
88
+ content={
89
+ "object_id": "validation_results",
90
+ "data": {
91
+ "columns": ["ligand_id", "status", "passed/total", "details"],
92
+ "rows": [[
93
+ Path(ligand_input.filename).stem,
94
+ "❌",
95
+ "0/0",
96
+ f"Schema validation error: {str(e)}"
97
+ ]]
98
+ }
99
+ }
100
+ )
101
+
102
+ finally:
103
+ # Clean up temporary files
104
+ for path in [ligand_path, protein_path, crystal_path]:
105
+ if path and os.path.exists(path):
106
+ try:
107
+ os.unlink(path)
108
+ except OSError:
109
+ pass
110
+
111
+ except subprocess.TimeoutExpired as e:
112
+ return JSONResponse(
113
+ status_code=500,
114
+ content={
115
+ "object_id": "validation_results",
116
+ "data": {
117
+ "columns": ["ligand_id", "status", "passed/total", "details"],
118
+ "rows": [[
119
+ Path(ligand_input.filename).stem,
120
+ "❌",
121
+ "0/0",
122
+ f"Process timed out after {e.timeout} seconds"
123
+ ]]
124
+ }
125
+ }
126
+ )
127
+ except HTTPException:
128
+ raise
129
+ except Exception as e:
130
+ return JSONResponse(
131
+ status_code=500,
132
+ content={
133
+ "object_id": "validation_results",
134
+ "data": {
135
+ "columns": ["ligand_id", "status", "passed/total", "details"],
136
+ "rows": [[
137
+ Path(ligand_input.filename).stem,
138
+ "❌",
139
+ "0/0",
140
+ str(e)
141
+ ]]
142
+ },
143
+ "error": str(e),
144
+ "traceback": traceback.format_exc()
145
+ }
146
+ )
147
+
148
+ @app.get("/mcp/context")
149
+ def context() -> Dict[str, Any]:
150
+ """
151
+ Return MCP context with empty initial validation results.
152
+ Response is validated against MCP_CONTEXT_SCHEMA.
153
+ """
154
+ try:
155
+ context = get_mcp_context()
156
+ validate(instance=context, schema=MCP_CONTEXT_SCHEMA)
157
+ return context
158
+ except ValidationError as e:
159
+ return JSONResponse(
160
+ status_code=500,
161
+ content={"error": f"Schema validation error: {str(e)}"}
162
+ )
163
+
164
+ def main():
165
+ """Run the FastAPI server."""
166
+ import uvicorn
167
+ uvicorn.run(app, host="0.0.0.0", port=7860)
168
+
169
+ if __name__ == "__main__":
170
+ main()
mcp_context.json CHANGED
@@ -1,63 +1,99 @@
1
- {
2
- "app": {
3
- "id": "lepanto1571.wrapper.posebusters",
4
- "name": "PoseBusters MCP wrapper",
5
- "version": "0.5"
6
- },
7
- "windows": [
8
- {
9
- "id": "posebusters_view",
10
- "title": "Validation Panel",
11
- "view": {
12
- "type": "form",
13
- "objects": ["ligand_input", "protein_input", "crystal_input", "validation_results"]
14
- }
15
- }
16
- ],
17
- "objects": [
18
- {
19
- "type": "file",
20
- "id": "ligand_input",
21
- "name": "Ligand file",
22
- "format": "sdf"
23
- },
24
- {
25
- "type": "file",
26
- "id": "protein_input",
27
- "name": "Protein file",
28
- "format": "pdb"
29
- },
30
- {
31
- "type": "file",
32
- "id": "crystal_input",
33
- "name": "Crystal ligand file (optional)",
34
- "format": "sdf",
35
- "optional": true
36
- },
37
- {
38
- "type": "table",
39
- "id": "validation_results",
40
- "columns": ["ligand_id", "status", "passed/total", "details"],
41
- "rows": []
42
- }
43
- ],
44
- "actions": [
45
- {
46
- "name": "validate_pose",
47
- "description": "Validate ligand–protein structure",
48
- "parameters": {
49
- "ligand_path": "ligand_input",
50
- "protein_path": "protein_input"
51
- }
52
- },
53
- {
54
- "name": "redocking_validation",
55
- "description": "Validate redocking using a reference ligand",
56
- "parameters": {
57
- "ligand_path": "ligand_input",
58
- "protein_path": "protein_input",
59
- "crystal_path": "crystal_input"
60
- }
61
- }
62
- ]
63
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "app": {
3
+ "id": "lepanto1571.wrapper.posebusters",
4
+ "name": "PoseBusters MCP wrapper",
5
+ "version": "0.5"
6
+ },
7
+ "windows": [
8
+ {
9
+ "id": "posebusters_view",
10
+ "title": "Validation Panel",
11
+ "view": {
12
+ "type": "form",
13
+ "objects": ["ligand_input", "protein_input", "crystal_input", "validation_results"]
14
+ }
15
+ }
16
+ ],
17
+ "objects": [
18
+ {
19
+ "type": "file",
20
+ "id": "ligand_input",
21
+ "name": "Ligand file",
22
+ "format": "sdf"
23
+ },
24
+ {
25
+ "type": "file",
26
+ "id": "protein_input",
27
+ "name": "Protein file",
28
+ "format": "pdb"
29
+ },
30
+ {
31
+ "type": "file",
32
+ "id": "crystal_input",
33
+ "name": "Crystal ligand file (optional)",
34
+ "format": "sdf",
35
+ "optional": true
36
+ },
37
+ {
38
+ "type": "table",
39
+ "id": "validation_results",
40
+ "name": "Validation Results",
41
+ "description": "Results of PoseBusters validation checks",
42
+ "columns": [
43
+ {
44
+ "id": "ligand_id",
45
+ "name": "Ligand ID",
46
+ "type": "string",
47
+ "description": "Identifier of the validated ligand"
48
+ },
49
+ {
50
+ "id": "status",
51
+ "name": "Status",
52
+ "type": "string",
53
+ "description": "Validation status (✅ for pass, ❌ for fail)",
54
+ "enum": ["", "❌"]
55
+ },
56
+ {
57
+ "id": "passed/total",
58
+ "name": "Tests Passed",
59
+ "type": "string",
60
+ "description": "Number of passed tests vs total tests (format: X/Y)"
61
+ },
62
+ {
63
+ "id": "details",
64
+ "name": "Details",
65
+ "type": "string",
66
+ "description": "List of failed tests or 'All tests passed'"
67
+ }
68
+ ],
69
+ "rows": []
70
+ }
71
+ ],
72
+ "actions": [
73
+ {
74
+ "name": "validate_pose",
75
+ "description": "Validate ligand–protein structure",
76
+ "parameters": {
77
+ "ligand_input": "ligand_input",
78
+ "protein_input": "protein_input"
79
+ },
80
+ "output": {
81
+ "object_id": "validation_results",
82
+ "type": "table"
83
+ }
84
+ },
85
+ {
86
+ "name": "redocking_validation",
87
+ "description": "Validate redocking using a reference ligand",
88
+ "parameters": {
89
+ "ligand_input": "ligand_input",
90
+ "crystal_input": "crystal_input",
91
+ "protein_input": "protein_input"
92
+ },
93
+ "output": {
94
+ "object_id": "validation_results",
95
+ "type": "table"
96
+ }
97
+ }
98
+ ]
99
+ }
pytest.ini ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ [pytest]
2
+ testpaths = tests
3
+ python_files = test_*.py
4
+ addopts = -v --tb=short
requirements.txt CHANGED
@@ -1,6 +1,15 @@
1
- fastapi
2
- uvicorn
3
- gradio
4
- pandas
5
- rdkit-pypi
6
- posebusters
 
 
 
 
 
 
 
 
 
 
1
+ fastapi>=0.109.0
2
+ uvicorn>=0.27.0
3
+ python-multipart>=0.0.6
4
+ gradio>=4.8.0
5
+ requests>=2.31.0
6
+ pandas>=2.1.3
7
+ rdkit-pypi
8
+ posebusters
9
+ python-magic>=0.4.27 # For MIME type validation
10
+ jsonschema>=4.21.1
11
+
12
+ # Testing dependencies
13
+ pytest>=7.4.0
14
+ pytest-cov>=4.1.0
15
+ pytest-asyncio>=0.21.0 # For async test support
schema.py ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """JSON Schema definitions for MCP context and responses."""
2
+
3
+ MCP_CONTEXT_SCHEMA = {
4
+ "type": "object",
5
+ "required": ["app", "windows", "objects", "actions"],
6
+ "properties": {
7
+ "app": {
8
+ "type": "object",
9
+ "required": ["id", "name", "version"],
10
+ "properties": {
11
+ "id": {"type": "string"},
12
+ "name": {"type": "string"},
13
+ "version": {"type": "string"}
14
+ }
15
+ },
16
+ "windows": {
17
+ "type": "array",
18
+ "items": {
19
+ "type": "object",
20
+ "required": ["id", "title", "view"],
21
+ "properties": {
22
+ "id": {"type": "string"},
23
+ "title": {"type": "string"},
24
+ "view": {
25
+ "type": "object",
26
+ "required": ["type", "objects"],
27
+ "properties": {
28
+ "type": {"type": "string", "enum": ["form"]},
29
+ "objects": {
30
+ "type": "array",
31
+ "items": {"type": "string"}
32
+ }
33
+ }
34
+ }
35
+ }
36
+ }
37
+ },
38
+ "objects": {
39
+ "type": "array",
40
+ "items": {
41
+ "type": "object",
42
+ "required": ["type", "id", "name"],
43
+ "properties": {
44
+ "type": {"type": "string", "enum": ["file", "table"]},
45
+ "id": {"type": "string"},
46
+ "name": {"type": "string"},
47
+ "format": {"type": "string"},
48
+ "optional": {"type": "boolean"},
49
+ "description": {"type": "string"},
50
+ "columns": {
51
+ "type": "array",
52
+ "items": {
53
+ "type": "object",
54
+ "required": ["id", "name", "type", "description"],
55
+ "properties": {
56
+ "id": {"type": "string"},
57
+ "name": {"type": "string"},
58
+ "type": {"type": "string"},
59
+ "description": {"type": "string"},
60
+ "enum": {
61
+ "type": "array",
62
+ "items": {"type": "string"}
63
+ }
64
+ }
65
+ }
66
+ },
67
+ "rows": {
68
+ "type": "array",
69
+ "items": {
70
+ "type": "array",
71
+ "items": {"type": "string"}
72
+ }
73
+ }
74
+ }
75
+ }
76
+ },
77
+ "actions": {
78
+ "type": "array",
79
+ "items": {
80
+ "type": "object",
81
+ "required": ["name", "description", "parameters", "output"],
82
+ "properties": {
83
+ "name": {"type": "string"},
84
+ "description": {"type": "string"},
85
+ "parameters": {
86
+ "type": "object",
87
+ "additionalProperties": {"type": "string"}
88
+ },
89
+ "output": {
90
+ "type": "object",
91
+ "required": ["object_id", "type"],
92
+ "properties": {
93
+ "object_id": {"type": "string"},
94
+ "type": {"type": "string"}
95
+ }
96
+ }
97
+ }
98
+ }
99
+ }
100
+ }
101
+ }
102
+
103
+ MCP_PREDICT_RESPONSE_SCHEMA = {
104
+ "type": "object",
105
+ "required": ["object_id", "data"],
106
+ "properties": {
107
+ "object_id": {"type": "string"},
108
+ "data": {
109
+ "type": "object",
110
+ "required": ["columns", "rows"],
111
+ "properties": {
112
+ "columns": {
113
+ "type": "array",
114
+ "items": {"type": "string"}
115
+ },
116
+ "rows": {
117
+ "type": "array",
118
+ "items": {
119
+ "type": "array",
120
+ "items": {"type": "string"}
121
+ }
122
+ }
123
+ }
124
+ },
125
+ "error": {"type": "string"},
126
+ "traceback": {"type": "string"}
127
+ }
128
+ }
tests/__pycache__/test_mcp.cpython-312-pytest-8.3.5.pyc ADDED
Binary file (14.8 kB). View file
 
tests/conftest.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+
4
+ # Add the parent directory to Python path so we can import app modules
5
+ app_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
6
+ sys.path.insert(0, app_dir)
tests/test_mcp.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for MCP context and response validation."""
2
+ import json
3
+ import pytest
4
+ from pathlib import Path
5
+ from jsonschema import validate, ValidationError
6
+
7
+ from schema import MCP_CONTEXT_SCHEMA, MCP_PREDICT_RESPONSE_SCHEMA
8
+ from utils import format_results_to_mcp, get_mcp_context
9
+
10
+ def test_mcp_context_schema():
11
+ """Test that the MCP context follows the schema."""
12
+ context = get_mcp_context()
13
+ validate(instance=context, schema=MCP_CONTEXT_SCHEMA)
14
+
15
+ # Test required fields
16
+ assert "app" in context
17
+ assert "id" in context["app"]
18
+ assert "name" in context["app"]
19
+ assert "version" in context["app"]
20
+
21
+ # Test windows configuration
22
+ assert "windows" in context
23
+ assert len(context["windows"]) > 0
24
+ assert "objects" in context["windows"][0]["view"]
25
+
26
+ # Test objects configuration
27
+ assert "objects" in context
28
+ file_objects = [obj for obj in context["objects"] if obj["type"] == "file"]
29
+ assert len(file_objects) >= 2 # At least ligand and protein
30
+
31
+ # Test actions configuration
32
+ assert "actions" in context
33
+ assert len(context["actions"]) >= 1
34
+ for action in context["actions"]:
35
+ assert "parameters" in action
36
+ assert "output" in action
37
+
38
+ def test_mcp_response_format():
39
+ """Test that the MCP response formatter produces valid output."""
40
+ # Test successful case
41
+ csv_text = "molecule,test1,test2\nmol1,True,False\n"
42
+ response = format_results_to_mcp(csv_text, "", "mol1")
43
+ validate(instance=response, schema=MCP_PREDICT_RESPONSE_SCHEMA)
44
+
45
+ assert response["object_id"] == "validation_results"
46
+ assert "columns" in response["data"]
47
+ assert "rows" in response["data"]
48
+ assert len(response["data"]["rows"]) == 1
49
+
50
+ # Test error case
51
+ response = format_results_to_mcp("", "Error occurred", "mol1")
52
+ validate(instance=response, schema=MCP_PREDICT_RESPONSE_SCHEMA)
53
+ assert response["data"]["rows"][0][1] == "❌" # Status should be fail
54
+
55
+ def test_mcp_response_content():
56
+ """Test the content of MCP responses for different scenarios."""
57
+ # Test all tests passing
58
+ csv_text = "molecule,test1,test2\nmol1,True,True\n"
59
+ response = format_results_to_mcp(csv_text, "", "mol1")
60
+ row = response["data"]["rows"][0]
61
+ assert row[1] == "✅" # Status
62
+ assert row[2] == "2/2" # Passed/Total
63
+ assert row[3] == "All tests passed" # Details
64
+
65
+ # Test some tests failing
66
+ csv_text = "molecule,test1,test2,test3\nmol1,True,False,False\n"
67
+ response = format_results_to_mcp(csv_text, "", "mol1")
68
+ row = response["data"]["rows"][0]
69
+ assert row[1] == "❌" # Status
70
+ assert row[2] == "1/3" # Passed/Total
71
+ assert "test2" in row[3] and "test3" in row[3] # Failed tests in details
72
+
73
+ def test_invalid_mcp_context():
74
+ """Test that invalid MCP context raises ValidationError."""
75
+ invalid_context = {
76
+ "app": {"name": "Test"} # Missing required fields
77
+ }
78
+ with pytest.raises(ValidationError):
79
+ validate(instance=invalid_context, schema=MCP_CONTEXT_SCHEMA)
80
+
81
+ def test_invalid_mcp_response():
82
+ """Test that invalid MCP response raises ValidationError."""
83
+ invalid_response = {
84
+ "object_id": "validation_results",
85
+ "data": {
86
+ "columns": ["col1"],
87
+ "rows": [["too", "many", "columns"]] # Inconsistent columns
88
+ }
89
+ }
90
+
91
+ with pytest.raises(ValidationError) as exc_info:
92
+ # First validate that rows match column count
93
+ columns_count = len(invalid_response["data"]["columns"])
94
+ for row in invalid_response["data"]["rows"]:
95
+ if len(row) != columns_count:
96
+ raise ValidationError(f"Row length {len(row)} does not match columns length {columns_count}")
97
+
98
+ # Then validate schema
99
+ validate(instance=invalid_response, schema=MCP_PREDICT_RESPONSE_SCHEMA)
100
+
101
+ assert "Row length" in str(exc_info.value)
utils.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared utilities for the PoseBusters MCP wrapper."""
2
+
3
+ # Standard library imports
4
+ import csv
5
+ import mimetypes
6
+ from pathlib import Path
7
+ from typing import Any, Dict, List
8
+ import json
9
+
10
+ # Third-party imports
11
+ from fastapi import UploadFile, HTTPException
12
+
13
+ # Constants
14
+ ALLOWED_MIME_TYPES = {
15
+ ".sdf": ["chemical/x-mdl-sdfile", "application/octet-stream", "text/plain"],
16
+ ".pdb": ["chemical/x-pdb", "application/octet-stream", "text/plain"]
17
+ }
18
+
19
+ def validate_mime_type(file: UploadFile, expected_ext: str) -> None:
20
+ """Validate file MIME type and extension."""
21
+ if not file.filename:
22
+ raise HTTPException(status_code=400, detail=f"Missing filename for {expected_ext} file")
23
+
24
+ file_ext = Path(file.filename).suffix.lower()
25
+ if file_ext != expected_ext:
26
+ raise HTTPException(
27
+ status_code=400,
28
+ detail=f"Invalid file extension. Expected {expected_ext}, got {file_ext}"
29
+ )
30
+
31
+ content_type = file.content_type or mimetypes.guess_type(file.filename)[0] or "application/octet-stream"
32
+ if not content_type or content_type not in ALLOWED_MIME_TYPES[expected_ext]:
33
+ raise HTTPException(
34
+ status_code=400,
35
+ detail=f"Invalid MIME type for {expected_ext} file"
36
+ )
37
+
38
+ def format_results_to_mcp(csv_text: str, stderr_text: str = "", ligand_id: str = "unknown") -> Dict[str, Any]:
39
+ """Format PoseBusters results to MCP standard output format."""
40
+ rows: List[List[str]] = []
41
+
42
+ if not csv_text.strip():
43
+ error_msg = stderr_text.strip().splitlines()[0] if stderr_text.strip() else "PoseBusters output is empty."
44
+ rows.append([ligand_id, "❌", "0/0", error_msg])
45
+ else:
46
+ reader = csv.DictReader(csv_text.strip().splitlines())
47
+ for row in reader:
48
+ ligand_id = row.get("molecule", "?")
49
+ failed_tests = []
50
+ passed_n = total_n = 0
51
+
52
+ for k, v in row.items():
53
+ if k in {"file", "molecule"}:
54
+ continue
55
+ if v == "True":
56
+ passed_n += 1
57
+ total_n += 1
58
+ elif v == "False":
59
+ failed_tests.append(k)
60
+ total_n += 1
61
+
62
+ rows.append([
63
+ ligand_id,
64
+ "✅" if not failed_tests else "❌",
65
+ f"{passed_n}/{total_n}",
66
+ ", ".join(failed_tests) if failed_tests else "All tests passed"
67
+ ])
68
+
69
+ # Create response
70
+ response = {
71
+ "object_id": "validation_results",
72
+ "data": {
73
+ "columns": ["ligand_id", "status", "passed/total", "details"],
74
+ "rows": rows
75
+ }
76
+ }
77
+
78
+ # Validate row lengths
79
+ expected_cols = len(response["data"]["columns"])
80
+ for row in response["data"]["rows"]:
81
+ if len(row) != expected_cols:
82
+ raise ValueError(f"Row length {len(row)} does not match columns length {expected_cols}")
83
+
84
+ return response
85
+
86
+ def get_mcp_context() -> Dict[str, Any]:
87
+ """Return MCP context from the JSON file."""
88
+ try:
89
+ # Get the directory where the current file (utils.py) is located
90
+ current_dir = Path(__file__).parent
91
+ context_path = current_dir / 'mcp_context.json'
92
+
93
+ # If not found in current directory, try the app root (for Spaces)
94
+ if not context_path.exists():
95
+ context_path = current_dir / '..' / 'mcp_context.json'
96
+ context_path = context_path.resolve()
97
+
98
+ if not context_path.exists():
99
+ raise FileNotFoundError(f"MCP context file not found at {context_path}")
100
+
101
+ with open(context_path, 'r') as f:
102
+ return json.load(f)
103
+ except FileNotFoundError as e:
104
+ raise HTTPException(
105
+ status_code=500,
106
+ detail=str(e)
107
+ )
108
+ except json.JSONDecodeError:
109
+ raise HTTPException(
110
+ status_code=500,
111
+ detail="Invalid MCP context JSON format"
112
+ )