dh-cli 0.14.0__tar.gz → 0.15.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {dh_cli-0.14.0 → dh_cli-0.15.0}/PKG-INFO +1 -1
- {dh_cli-0.14.0 → dh_cli-0.15.0}/pyproject.toml +1 -1
- {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/hz/mcp_server.py +121 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/hz/live_smoke.py +5 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/hz/test_mcp_server.py +216 -3
- {dh_cli-0.14.0 → dh_cli-0.15.0}/.gitignore +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/LICENSE +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/README.md +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/__init__.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/_identity.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/batch/__init__.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/batch/aws_batch.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/batch/commands/__init__.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/batch/commands/boltz.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/batch/commands/cancel.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/batch/commands/clean.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/batch/commands/cost.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/batch/commands/disco.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/batch/commands/embed_t5.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/batch/commands/equilibrator.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/batch/commands/esmfold2.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/batch/commands/finalize.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/batch/commands/list_jobs.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/batch/commands/local.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/batch/commands/logs.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/batch/commands/orca.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/batch/commands/protmpnn.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/batch/commands/protmpnn_to_boltz.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/batch/commands/retry.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/batch/commands/rfd3.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/batch/commands/status.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/batch/commands/submit.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/batch/commands/wait_for.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/batch/cost_report.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/batch/fasta_utils.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/batch/h5_utils.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/batch/job_id.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/batch/manifest.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/batch/s3_transport.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/bedrock/__init__.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/bedrock/commands.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/bedrock/cost_report.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/bedrock/pricing.yaml +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/cloud_commands.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/codeartifact.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/engines_studios/__init__.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/engines_studios/api_client.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/engines_studios/auth.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/engines_studios/engine_commands.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/engines_studios/progress.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/engines_studios/retry.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/engines_studios/ssh_config.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/engines_studios/studio_commands.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/engines_studios/studio_resolve.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/github_commands.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/hz/__init__.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/hz/auth.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/hz/deploy.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/hz/local.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/hz/test.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/hz/tf.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/hz/users.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/main.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/sweep.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/warehouse.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/batch/__init__.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/batch/cur_fixtures.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/batch/test_aws_batch_resources.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/batch/test_batch_cost_command.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/batch/test_batch_cost_filters.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/batch/test_batch_cost_reader.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/batch/test_batch_cost_reconcile.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/batch/test_batch_cost_report.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/batch/test_image_override.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/batch/test_no_train_command.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/batch/test_submit_cpu_only.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/batch/test_submit_image_validation.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/batch/test_submit_job_tags.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/batch/test_submit_merge.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/batch/test_submit_mlflow_env.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/bedrock/conftest.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/bedrock/fixtures/A_cache_write.json +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/bedrock/fixtures/B_cache_read.json +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/bedrock/fixtures/C_plain.json +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/bedrock/fixtures/D_cursor_user.json +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/bedrock/fixtures/E_service_role.json +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/bedrock/fixtures/F_legacy_shared.json +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/bedrock/fixtures/G_unknown_model.json +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/bedrock/test_build_report.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/bedrock/test_classify_arn.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/bedrock/test_cli_exit_codes.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/bedrock/test_cost_calc.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/bedrock/test_cost_command.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/bedrock/test_cur_reconciliation.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/bedrock/test_key_command.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/bedrock/test_render_formats.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/bedrock/test_resolve_base_model.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/bedrock/test_rotate.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/bedrock/test_s3_walker.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/engines_studios/__init__.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/engines_studios/test_engine_retry_commands.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/engines_studios/test_retry.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/engines_studios/test_ssh_config.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/engines_studios/test_studio_resolution_commands.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/engines_studios/test_studio_resolve.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/github/__init__.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/github/conftest.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/github/test_engine_role_cannot_read_github_pat.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/github/test_identity.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/github/test_login.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/github/test_login_error_paths.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/github/test_login_security.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/github/test_logout.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/github/test_rotate.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/github/test_status.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/hz/test_auth.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/hz/test_init.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/hz/test_suites.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/hz/test_users.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/test_aws_status.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/test_cloud_gcp.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/test_disco_command.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/test_disco_jobdef.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/test_equilibrator_command.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/test_esmfold2_cli.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/test_finalize_boltz_tar.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/test_finalize_disco.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/test_finalize_disco_dispatch.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/test_finalize_equilibrator.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/test_finalize_esmfold2.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/test_finalize_protmpnn.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/test_finalize_rfd3.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/test_rfd3_command.py +0 -0
- {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/test_sweep.py +0 -0
|
@@ -6,6 +6,7 @@ land in that user's Horizyn history. Requires the ``mcp`` extra:
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
from importlib.metadata import version
|
|
9
|
+
from pathlib import Path
|
|
9
10
|
|
|
10
11
|
import httpx
|
|
11
12
|
from mcp.server.mcpserver import MCPServer
|
|
@@ -17,6 +18,8 @@ from dh_cli.hz.auth import NotLoggedIn, get_id_token
|
|
|
17
18
|
|
|
18
19
|
CLIENT_HEADER = f"mcp/{version('dh-cli')}"
|
|
19
20
|
_READ_ONLY = ToolAnnotations(read_only_hint=True)
|
|
21
|
+
_SUBMIT = ToolAnnotations(read_only_hint=False, destructive_hint=False, open_world_hint=True)
|
|
22
|
+
_WRITES_FILES = ToolAnnotations(read_only_hint=False, destructive_hint=False, idempotent_hint=True)
|
|
20
23
|
|
|
21
24
|
server = MCPServer(
|
|
22
25
|
"horizyn",
|
|
@@ -198,3 +201,121 @@ def horizyn_protein_details(protein_id: str, screening_set: str) -> dict:
|
|
|
198
201
|
score, full sequence, length, AlphaFold id and any extra metadata.
|
|
199
202
|
"""
|
|
200
203
|
return _request("GET", f"/protein/{protein_id}/details", params={"screening_set": screening_set})
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@server.tool(annotations=_SUBMIT)
|
|
207
|
+
def horizyn_dock_submit(
|
|
208
|
+
protein_sequence: str = "",
|
|
209
|
+
reaction_smiles: str | None = None,
|
|
210
|
+
dock_products: bool = False,
|
|
211
|
+
protein_id: str | None = None,
|
|
212
|
+
label: str | None = None,
|
|
213
|
+
predict_waters: bool = False,
|
|
214
|
+
waters_selection: str | None = None,
|
|
215
|
+
boltz_yaml: str | None = None,
|
|
216
|
+
) -> dict:
|
|
217
|
+
"""Submit a Boltz fold+dock job. Costs GPU time; ask the user before calling.
|
|
218
|
+
|
|
219
|
+
Folds `protein_sequence` and docks the ligands parsed from `reaction_smiles`
|
|
220
|
+
("substrates>>products"; the substrate side by default, the product side
|
|
221
|
+
when `dock_products` is true). A job typically takes 30-120 s of GPU plus
|
|
222
|
+
about a minute for the MSA lookup, times out after 10 min, and the queue
|
|
223
|
+
holds 20 jobs. `protein_id` (UniProt) is an MSA hint; `label` names the job
|
|
224
|
+
in the user's history. `predict_waters` chains an ordered-water prediction
|
|
225
|
+
on the folded structure (`waters_selection` "confidence" or "density");
|
|
226
|
+
its job id appears as `child_job_id` in horizyn_dock_status. `boltz_yaml`
|
|
227
|
+
is advanced mode: a raw Boltz config replaces `reaction_smiles`.
|
|
228
|
+
|
|
229
|
+
Returns `job_id` and `status` ("queued"). Poll horizyn_dock_status until
|
|
230
|
+
`status` is "complete", then call horizyn_dock_result.
|
|
231
|
+
"""
|
|
232
|
+
body = {
|
|
233
|
+
"protein_sequence": protein_sequence,
|
|
234
|
+
"reaction_smiles": reaction_smiles,
|
|
235
|
+
"dock_products": dock_products,
|
|
236
|
+
"protein_id": protein_id,
|
|
237
|
+
"label": label,
|
|
238
|
+
"predict_waters": predict_waters,
|
|
239
|
+
"waters_selection": waters_selection,
|
|
240
|
+
"boltz_yaml": boltz_yaml,
|
|
241
|
+
}
|
|
242
|
+
return _request("POST", "/dock/submit", json={k: v for k, v in body.items() if v is not None})
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@server.tool(annotations=_SUBMIT)
|
|
246
|
+
def horizyn_dock_waters_submit(
|
|
247
|
+
structure_path: str,
|
|
248
|
+
selection: str | None = None,
|
|
249
|
+
label: str | None = None,
|
|
250
|
+
) -> dict:
|
|
251
|
+
"""Predict ordered water positions on an existing structure. Costs GPU time.
|
|
252
|
+
|
|
253
|
+
`structure_path` is a local `.pdb` or `.cif` file (format taken from the
|
|
254
|
+
suffix, 8 MB max); the server reads it, so never paste structure text into
|
|
255
|
+
this call. `selection` is "confidence" or "density". Returns `job_id` and
|
|
256
|
+
`status`; poll horizyn_dock_status, then horizyn_dock_result, whose
|
|
257
|
+
`waters` list and `n_waters` carry the prediction.
|
|
258
|
+
"""
|
|
259
|
+
path = Path(structure_path).expanduser()
|
|
260
|
+
try:
|
|
261
|
+
structure_data = path.read_text()
|
|
262
|
+
except OSError as exc:
|
|
263
|
+
raise ToolError(f"Cannot read structure file {path}: {exc.strerror or type(exc).__name__}") from None
|
|
264
|
+
|
|
265
|
+
body = {"structure_data": structure_data, "structure_format": path.suffix, "selection": selection, "label": label}
|
|
266
|
+
return _request("POST", "/dock/waters/submit", json={k: v for k, v in body.items() if v is not None})
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@server.tool(annotations=_READ_ONLY)
|
|
270
|
+
def horizyn_dock_status(job_id: str) -> dict:
|
|
271
|
+
"""Poll a docking job. Wait at least 15 s between polls.
|
|
272
|
+
|
|
273
|
+
`status` is "queued", "running", "complete" or "failed"; `error` explains a
|
|
274
|
+
failure. `elapsed_seconds` and `queue_position` track progress. `msa_used`
|
|
275
|
+
false means Boltz fell back to single-sequence mode, so confidence is lower.
|
|
276
|
+
`job_type` is "fold" or "waters"; `child_job_id` names a chained waters job
|
|
277
|
+
and `parent_job_id` its fold.
|
|
278
|
+
"""
|
|
279
|
+
return _request("GET", f"/dock/{job_id}/status")
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
@server.tool(annotations=_WRITES_FILES)
|
|
283
|
+
def horizyn_dock_result(job_id: str, output_dir: str | None = None) -> dict:
|
|
284
|
+
"""Fetch a completed docking job's structure and confidence.
|
|
285
|
+
|
|
286
|
+
Returns 409 until horizyn_dock_status reports "complete", so poll first.
|
|
287
|
+
With `output_dir`, writes `{job_id}.pdb` (and `{job_id}.cif` when present)
|
|
288
|
+
there, creating the directory, and returns `pdb_path`/`cif_path` instead of
|
|
289
|
+
the raw text. Without it, `pdb_data`/`cif_data` are returned inline (large).
|
|
290
|
+
Always includes `confidence_data`, `msa_used`, `job_type`, `waters`,
|
|
291
|
+
`n_waters`, `parent_job_id` and `child_job_id`.
|
|
292
|
+
"""
|
|
293
|
+
data = _request("GET", f"/dock/{job_id}/result")
|
|
294
|
+
if output_dir is None:
|
|
295
|
+
return data
|
|
296
|
+
|
|
297
|
+
out = Path(output_dir).expanduser()
|
|
298
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
299
|
+
pdb_path = out / f"{job_id}.pdb"
|
|
300
|
+
pdb_path.write_text(data.pop("pdb_data", ""))
|
|
301
|
+
data["pdb_path"] = str(pdb_path)
|
|
302
|
+
|
|
303
|
+
cif_data = data.pop("cif_data", "")
|
|
304
|
+
cif_path = out / f"{job_id}.cif"
|
|
305
|
+
if cif_data:
|
|
306
|
+
cif_path.write_text(cif_data)
|
|
307
|
+
data["cif_path"] = str(cif_path) if cif_data else None
|
|
308
|
+
return data
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
@server.tool(annotations=_READ_ONLY)
|
|
312
|
+
def horizyn_list_docking_jobs(limit: int = 20) -> dict:
|
|
313
|
+
"""List the logged-in user's recent docking jobs.
|
|
314
|
+
|
|
315
|
+
`limit` is 1-100. Items in `jobs` are ordered by `job_id`, not by time;
|
|
316
|
+
sort on `submitted_at` yourself. Each carries `job_id`, `status`,
|
|
317
|
+
`job_type`, `submitted_at`, `label`, `protein_sequence`, `reaction_smiles`
|
|
318
|
+
and `client_source`. Status here is only updated when a job is polled or
|
|
319
|
+
its result fetched; use horizyn_dock_status for the live value.
|
|
320
|
+
"""
|
|
321
|
+
return _request("GET", "/me/docking-jobs", params={"limit": limit})
|
|
@@ -21,6 +21,11 @@ EXPECTED = {
|
|
|
21
21
|
"horizyn_query_reaction",
|
|
22
22
|
"horizyn_query_protein",
|
|
23
23
|
"horizyn_protein_details",
|
|
24
|
+
"horizyn_dock_submit",
|
|
25
|
+
"horizyn_dock_waters_submit",
|
|
26
|
+
"horizyn_dock_status",
|
|
27
|
+
"horizyn_dock_result",
|
|
28
|
+
"horizyn_list_docking_jobs",
|
|
24
29
|
}
|
|
25
30
|
|
|
26
31
|
|
|
@@ -13,13 +13,17 @@ from dh_cli.hz import mcp_server
|
|
|
13
13
|
from dh_cli.hz.auth import NotLoggedIn
|
|
14
14
|
|
|
15
15
|
TOKEN = "eyJ-test-token"
|
|
16
|
-
|
|
16
|
+
READ_ONLY_TOOLS = {
|
|
17
17
|
"horizyn_whoami",
|
|
18
18
|
"horizyn_list_screening_sets",
|
|
19
19
|
"horizyn_query_reaction",
|
|
20
20
|
"horizyn_query_protein",
|
|
21
21
|
"horizyn_protein_details",
|
|
22
|
+
"horizyn_dock_status",
|
|
23
|
+
"horizyn_list_docking_jobs",
|
|
22
24
|
}
|
|
25
|
+
WRITE_TOOLS = {"horizyn_dock_submit", "horizyn_dock_waters_submit", "horizyn_dock_result"}
|
|
26
|
+
EXPECTED_TOOLS = READ_ONLY_TOOLS | WRITE_TOOLS
|
|
23
27
|
|
|
24
28
|
|
|
25
29
|
class Recorder:
|
|
@@ -54,13 +58,35 @@ def api(monkeypatch):
|
|
|
54
58
|
|
|
55
59
|
|
|
56
60
|
class TestRegistration:
|
|
57
|
-
def
|
|
61
|
+
def test_lists_ten_tools_with_descriptions(self):
|
|
58
62
|
tools = anyio.run(mcp_server.server.list_tools)
|
|
59
63
|
|
|
60
64
|
assert {t.name for t in tools} == EXPECTED_TOOLS
|
|
61
|
-
assert all(t.annotations and t.annotations.read_only_hint for t in tools)
|
|
62
65
|
assert all(t.description for t in tools)
|
|
63
66
|
|
|
67
|
+
def test_read_only_annotations(self):
|
|
68
|
+
by_name = {t.name: t for t in anyio.run(mcp_server.server.list_tools)}
|
|
69
|
+
|
|
70
|
+
for name in READ_ONLY_TOOLS:
|
|
71
|
+
assert by_name[name].annotations.read_only_hint is True, name
|
|
72
|
+
for name in WRITE_TOOLS:
|
|
73
|
+
assert by_name[name].annotations.read_only_hint is False, name
|
|
74
|
+
|
|
75
|
+
def test_submit_tools_are_non_destructive_open_world(self):
|
|
76
|
+
by_name = {t.name: t for t in anyio.run(mcp_server.server.list_tools)}
|
|
77
|
+
|
|
78
|
+
for name in ("horizyn_dock_submit", "horizyn_dock_waters_submit"):
|
|
79
|
+
ann = by_name[name].annotations
|
|
80
|
+
assert ann.destructive_hint is False, name
|
|
81
|
+
assert ann.open_world_hint is True, name
|
|
82
|
+
|
|
83
|
+
def test_result_tool_is_idempotent_non_destructive(self):
|
|
84
|
+
by_name = {t.name: t for t in anyio.run(mcp_server.server.list_tools)}
|
|
85
|
+
|
|
86
|
+
ann = by_name["horizyn_dock_result"].annotations
|
|
87
|
+
assert ann.destructive_hint is False
|
|
88
|
+
assert ann.idempotent_hint is True
|
|
89
|
+
|
|
64
90
|
|
|
65
91
|
class TestHeaders:
|
|
66
92
|
def test_every_request_carries_auth_and_client_headers(self, api):
|
|
@@ -146,6 +172,193 @@ class TestPaths:
|
|
|
146
172
|
mcp_server.horizyn_protein_details(protein_id="P81747")
|
|
147
173
|
|
|
148
174
|
|
|
175
|
+
SEQ = "MKVLAAGIVGLLLAGQPALA"
|
|
176
|
+
RXN = "CC=O.O>>CC(O)O"
|
|
177
|
+
PDB = "ATOM 1 N MET A 1 11.104 6.134 -6.504 1.00 0.00 N\nEND\n"
|
|
178
|
+
CIF = "data_x\n_atom_site.id 1\n"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class TestDockingPaths:
|
|
182
|
+
def test_dock_submit_minimal_omits_none_fields(self, api):
|
|
183
|
+
api.body = {"job_id": "j1", "status": "queued"}
|
|
184
|
+
|
|
185
|
+
result = mcp_server.horizyn_dock_submit(protein_sequence=SEQ, reaction_smiles=RXN)
|
|
186
|
+
|
|
187
|
+
assert api.request.method == "POST"
|
|
188
|
+
assert api.request.url.path == "/dock/submit"
|
|
189
|
+
assert api.sent_json == {
|
|
190
|
+
"protein_sequence": SEQ,
|
|
191
|
+
"reaction_smiles": RXN,
|
|
192
|
+
"dock_products": False,
|
|
193
|
+
"predict_waters": False,
|
|
194
|
+
}
|
|
195
|
+
assert result == {"job_id": "j1", "status": "queued"}
|
|
196
|
+
|
|
197
|
+
def test_dock_submit_optional_fields_sent_when_given(self, api):
|
|
198
|
+
mcp_server.horizyn_dock_submit(
|
|
199
|
+
protein_sequence=SEQ,
|
|
200
|
+
reaction_smiles=RXN,
|
|
201
|
+
dock_products=True,
|
|
202
|
+
protein_id="P81747",
|
|
203
|
+
label="hzmcp2-live",
|
|
204
|
+
predict_waters=True,
|
|
205
|
+
waters_selection="density",
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
body = api.sent_json
|
|
209
|
+
assert body["dock_products"] is True
|
|
210
|
+
assert body["protein_id"] == "P81747"
|
|
211
|
+
assert body["label"] == "hzmcp2-live"
|
|
212
|
+
assert body["predict_waters"] is True
|
|
213
|
+
assert body["waters_selection"] == "density"
|
|
214
|
+
assert "boltz_yaml" not in body
|
|
215
|
+
|
|
216
|
+
def test_dock_submit_boltz_yaml_without_sequence(self, api):
|
|
217
|
+
mcp_server.horizyn_dock_submit(boltz_yaml="version: 1\n")
|
|
218
|
+
|
|
219
|
+
body = api.sent_json
|
|
220
|
+
assert body["boltz_yaml"] == "version: 1\n"
|
|
221
|
+
assert body["protein_sequence"] == ""
|
|
222
|
+
assert "reaction_smiles" not in body
|
|
223
|
+
|
|
224
|
+
def test_waters_submit_reads_pdb_file(self, api, tmp_path):
|
|
225
|
+
path = tmp_path / "fold.pdb"
|
|
226
|
+
path.write_text(PDB)
|
|
227
|
+
|
|
228
|
+
mcp_server.horizyn_dock_waters_submit(structure_path=str(path))
|
|
229
|
+
|
|
230
|
+
assert api.request.method == "POST"
|
|
231
|
+
assert api.request.url.path == "/dock/waters/submit"
|
|
232
|
+
assert api.sent_json == {"structure_data": PDB, "structure_format": ".pdb"}
|
|
233
|
+
|
|
234
|
+
def test_waters_submit_cif_suffix_and_options(self, api, tmp_path):
|
|
235
|
+
path = tmp_path / "fold.cif"
|
|
236
|
+
path.write_text(CIF)
|
|
237
|
+
|
|
238
|
+
mcp_server.horizyn_dock_waters_submit(structure_path=str(path), selection="confidence", label="w1")
|
|
239
|
+
|
|
240
|
+
assert api.sent_json == {
|
|
241
|
+
"structure_data": CIF,
|
|
242
|
+
"structure_format": ".cif",
|
|
243
|
+
"selection": "confidence",
|
|
244
|
+
"label": "w1",
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
def test_waters_submit_missing_file_names_path_and_sends_nothing(self, api, tmp_path):
|
|
248
|
+
path = tmp_path / "nope.pdb"
|
|
249
|
+
|
|
250
|
+
with pytest.raises(ToolError, match=str(path)):
|
|
251
|
+
mcp_server.horizyn_dock_waters_submit(structure_path=str(path))
|
|
252
|
+
assert api.request is None
|
|
253
|
+
|
|
254
|
+
def test_dock_status_passes_response_through(self, api):
|
|
255
|
+
api.body = {
|
|
256
|
+
"job_id": "j1",
|
|
257
|
+
"status": "running",
|
|
258
|
+
"elapsed_seconds": 12.5,
|
|
259
|
+
"error": None,
|
|
260
|
+
"queue_position": None,
|
|
261
|
+
"msa_used": True,
|
|
262
|
+
"job_type": "fold",
|
|
263
|
+
"parent_job_id": None,
|
|
264
|
+
"child_job_id": None,
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
result = mcp_server.horizyn_dock_status(job_id="j1")
|
|
268
|
+
|
|
269
|
+
assert api.request.method == "GET"
|
|
270
|
+
assert api.request.url.path == "/dock/j1/status"
|
|
271
|
+
assert result == api.body
|
|
272
|
+
|
|
273
|
+
def test_list_docking_jobs_default_limit(self, api):
|
|
274
|
+
api.body = {"jobs": []}
|
|
275
|
+
|
|
276
|
+
result = mcp_server.horizyn_list_docking_jobs()
|
|
277
|
+
|
|
278
|
+
assert api.request.method == "GET"
|
|
279
|
+
assert api.request.url.path == "/me/docking-jobs"
|
|
280
|
+
assert api.request.url.params["limit"] == "20"
|
|
281
|
+
assert result == {"jobs": []}
|
|
282
|
+
|
|
283
|
+
def test_list_docking_jobs_custom_limit(self, api):
|
|
284
|
+
mcp_server.horizyn_list_docking_jobs(limit=3)
|
|
285
|
+
|
|
286
|
+
assert api.request.url.params["limit"] == "3"
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
RESULT_BODY = {
|
|
290
|
+
"job_id": "j1",
|
|
291
|
+
"pdb_data": PDB,
|
|
292
|
+
"cif_data": CIF,
|
|
293
|
+
"confidence_data": {"confidence_score": 0.9},
|
|
294
|
+
"msa_used": True,
|
|
295
|
+
"job_type": "fold",
|
|
296
|
+
"waters": None,
|
|
297
|
+
"n_waters": None,
|
|
298
|
+
"parent_job_id": None,
|
|
299
|
+
"child_job_id": None,
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
class TestDockResult:
|
|
304
|
+
def test_inline_when_no_output_dir(self, api):
|
|
305
|
+
api.body = RESULT_BODY
|
|
306
|
+
|
|
307
|
+
result = mcp_server.horizyn_dock_result(job_id="j1")
|
|
308
|
+
|
|
309
|
+
assert api.request.method == "GET"
|
|
310
|
+
assert api.request.url.path == "/dock/j1/result"
|
|
311
|
+
assert result == RESULT_BODY
|
|
312
|
+
|
|
313
|
+
def test_output_dir_writes_both_files_and_returns_paths(self, api, tmp_path):
|
|
314
|
+
api.body = RESULT_BODY
|
|
315
|
+
out = tmp_path / "new" / "dir"
|
|
316
|
+
|
|
317
|
+
result = mcp_server.horizyn_dock_result(job_id="j1", output_dir=str(out))
|
|
318
|
+
|
|
319
|
+
assert (out / "j1.pdb").read_text() == PDB
|
|
320
|
+
assert (out / "j1.cif").read_text() == CIF
|
|
321
|
+
assert result["pdb_path"] == str(out / "j1.pdb")
|
|
322
|
+
assert result["cif_path"] == str(out / "j1.cif")
|
|
323
|
+
assert "pdb_data" not in result
|
|
324
|
+
assert "cif_data" not in result
|
|
325
|
+
for key in (
|
|
326
|
+
"job_id",
|
|
327
|
+
"confidence_data",
|
|
328
|
+
"msa_used",
|
|
329
|
+
"job_type",
|
|
330
|
+
"waters",
|
|
331
|
+
"n_waters",
|
|
332
|
+
"parent_job_id",
|
|
333
|
+
"child_job_id",
|
|
334
|
+
):
|
|
335
|
+
assert result[key] == RESULT_BODY[key], key
|
|
336
|
+
|
|
337
|
+
def test_output_dir_skips_empty_cif(self, api, tmp_path):
|
|
338
|
+
api.body = {**RESULT_BODY, "cif_data": ""}
|
|
339
|
+
|
|
340
|
+
result = mcp_server.horizyn_dock_result(job_id="j1", output_dir=str(tmp_path))
|
|
341
|
+
|
|
342
|
+
assert (tmp_path / "j1.pdb").exists()
|
|
343
|
+
assert not (tmp_path / "j1.cif").exists()
|
|
344
|
+
assert result["cif_path"] is None
|
|
345
|
+
assert "cif_data" not in result
|
|
346
|
+
|
|
347
|
+
def test_409_not_complete(self, api):
|
|
348
|
+
api.status = 409
|
|
349
|
+
api.body = {"detail": "Job not complete yet"}
|
|
350
|
+
|
|
351
|
+
with pytest.raises(ToolError, match="Horizyn returned 409: Job not complete yet"):
|
|
352
|
+
mcp_server.horizyn_dock_result(job_id="j1")
|
|
353
|
+
|
|
354
|
+
def test_404_unknown_job(self, api):
|
|
355
|
+
api.status = 404
|
|
356
|
+
api.body = {"detail": "Unknown job"}
|
|
357
|
+
|
|
358
|
+
with pytest.raises(ToolError, match="Horizyn returned 404: Unknown job"):
|
|
359
|
+
mcp_server.horizyn_dock_status(job_id="nope")
|
|
360
|
+
|
|
361
|
+
|
|
149
362
|
class TestTrimming:
|
|
150
363
|
def test_query_response_drops_timings_and_tsne(self, api):
|
|
151
364
|
api.body = {
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|