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.
Files changed (134) hide show
  1. {dh_cli-0.14.0 → dh_cli-0.15.0}/PKG-INFO +1 -1
  2. {dh_cli-0.14.0 → dh_cli-0.15.0}/pyproject.toml +1 -1
  3. {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/hz/mcp_server.py +121 -0
  4. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/hz/live_smoke.py +5 -0
  5. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/hz/test_mcp_server.py +216 -3
  6. {dh_cli-0.14.0 → dh_cli-0.15.0}/.gitignore +0 -0
  7. {dh_cli-0.14.0 → dh_cli-0.15.0}/LICENSE +0 -0
  8. {dh_cli-0.14.0 → dh_cli-0.15.0}/README.md +0 -0
  9. {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/__init__.py +0 -0
  10. {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/_identity.py +0 -0
  11. {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/batch/__init__.py +0 -0
  12. {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/batch/aws_batch.py +0 -0
  13. {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/batch/commands/__init__.py +0 -0
  14. {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/batch/commands/boltz.py +0 -0
  15. {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/batch/commands/cancel.py +0 -0
  16. {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/batch/commands/clean.py +0 -0
  17. {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/batch/commands/cost.py +0 -0
  18. {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/batch/commands/disco.py +0 -0
  19. {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/batch/commands/embed_t5.py +0 -0
  20. {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/batch/commands/equilibrator.py +0 -0
  21. {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/batch/commands/esmfold2.py +0 -0
  22. {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/batch/commands/finalize.py +0 -0
  23. {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/batch/commands/list_jobs.py +0 -0
  24. {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/batch/commands/local.py +0 -0
  25. {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/batch/commands/logs.py +0 -0
  26. {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/batch/commands/orca.py +0 -0
  27. {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/batch/commands/protmpnn.py +0 -0
  28. {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/batch/commands/protmpnn_to_boltz.py +0 -0
  29. {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/batch/commands/retry.py +0 -0
  30. {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/batch/commands/rfd3.py +0 -0
  31. {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/batch/commands/status.py +0 -0
  32. {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/batch/commands/submit.py +0 -0
  33. {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/batch/commands/wait_for.py +0 -0
  34. {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/batch/cost_report.py +0 -0
  35. {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/batch/fasta_utils.py +0 -0
  36. {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/batch/h5_utils.py +0 -0
  37. {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/batch/job_id.py +0 -0
  38. {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/batch/manifest.py +0 -0
  39. {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/batch/s3_transport.py +0 -0
  40. {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/bedrock/__init__.py +0 -0
  41. {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/bedrock/commands.py +0 -0
  42. {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/bedrock/cost_report.py +0 -0
  43. {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/bedrock/pricing.yaml +0 -0
  44. {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/cloud_commands.py +0 -0
  45. {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/codeartifact.py +0 -0
  46. {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/engines_studios/__init__.py +0 -0
  47. {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/engines_studios/api_client.py +0 -0
  48. {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/engines_studios/auth.py +0 -0
  49. {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/engines_studios/engine_commands.py +0 -0
  50. {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/engines_studios/progress.py +0 -0
  51. {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/engines_studios/retry.py +0 -0
  52. {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/engines_studios/ssh_config.py +0 -0
  53. {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/engines_studios/studio_commands.py +0 -0
  54. {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/engines_studios/studio_resolve.py +0 -0
  55. {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/github_commands.py +0 -0
  56. {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/hz/__init__.py +0 -0
  57. {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/hz/auth.py +0 -0
  58. {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/hz/deploy.py +0 -0
  59. {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/hz/local.py +0 -0
  60. {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/hz/test.py +0 -0
  61. {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/hz/tf.py +0 -0
  62. {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/hz/users.py +0 -0
  63. {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/main.py +0 -0
  64. {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/sweep.py +0 -0
  65. {dh_cli-0.14.0 → dh_cli-0.15.0}/src/dh_cli/warehouse.py +0 -0
  66. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/batch/__init__.py +0 -0
  67. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/batch/cur_fixtures.py +0 -0
  68. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/batch/test_aws_batch_resources.py +0 -0
  69. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/batch/test_batch_cost_command.py +0 -0
  70. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/batch/test_batch_cost_filters.py +0 -0
  71. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/batch/test_batch_cost_reader.py +0 -0
  72. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/batch/test_batch_cost_reconcile.py +0 -0
  73. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/batch/test_batch_cost_report.py +0 -0
  74. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/batch/test_image_override.py +0 -0
  75. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/batch/test_no_train_command.py +0 -0
  76. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/batch/test_submit_cpu_only.py +0 -0
  77. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/batch/test_submit_image_validation.py +0 -0
  78. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/batch/test_submit_job_tags.py +0 -0
  79. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/batch/test_submit_merge.py +0 -0
  80. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/batch/test_submit_mlflow_env.py +0 -0
  81. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/bedrock/conftest.py +0 -0
  82. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/bedrock/fixtures/A_cache_write.json +0 -0
  83. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/bedrock/fixtures/B_cache_read.json +0 -0
  84. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/bedrock/fixtures/C_plain.json +0 -0
  85. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/bedrock/fixtures/D_cursor_user.json +0 -0
  86. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/bedrock/fixtures/E_service_role.json +0 -0
  87. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/bedrock/fixtures/F_legacy_shared.json +0 -0
  88. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/bedrock/fixtures/G_unknown_model.json +0 -0
  89. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/bedrock/test_build_report.py +0 -0
  90. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/bedrock/test_classify_arn.py +0 -0
  91. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/bedrock/test_cli_exit_codes.py +0 -0
  92. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/bedrock/test_cost_calc.py +0 -0
  93. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/bedrock/test_cost_command.py +0 -0
  94. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/bedrock/test_cur_reconciliation.py +0 -0
  95. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/bedrock/test_key_command.py +0 -0
  96. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/bedrock/test_render_formats.py +0 -0
  97. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/bedrock/test_resolve_base_model.py +0 -0
  98. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/bedrock/test_rotate.py +0 -0
  99. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/bedrock/test_s3_walker.py +0 -0
  100. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/engines_studios/__init__.py +0 -0
  101. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/engines_studios/test_engine_retry_commands.py +0 -0
  102. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/engines_studios/test_retry.py +0 -0
  103. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/engines_studios/test_ssh_config.py +0 -0
  104. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/engines_studios/test_studio_resolution_commands.py +0 -0
  105. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/engines_studios/test_studio_resolve.py +0 -0
  106. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/github/__init__.py +0 -0
  107. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/github/conftest.py +0 -0
  108. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/github/test_engine_role_cannot_read_github_pat.py +0 -0
  109. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/github/test_identity.py +0 -0
  110. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/github/test_login.py +0 -0
  111. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/github/test_login_error_paths.py +0 -0
  112. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/github/test_login_security.py +0 -0
  113. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/github/test_logout.py +0 -0
  114. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/github/test_rotate.py +0 -0
  115. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/github/test_status.py +0 -0
  116. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/hz/test_auth.py +0 -0
  117. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/hz/test_init.py +0 -0
  118. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/hz/test_suites.py +0 -0
  119. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/hz/test_users.py +0 -0
  120. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/test_aws_status.py +0 -0
  121. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/test_cloud_gcp.py +0 -0
  122. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/test_disco_command.py +0 -0
  123. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/test_disco_jobdef.py +0 -0
  124. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/test_equilibrator_command.py +0 -0
  125. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/test_esmfold2_cli.py +0 -0
  126. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/test_finalize_boltz_tar.py +0 -0
  127. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/test_finalize_disco.py +0 -0
  128. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/test_finalize_disco_dispatch.py +0 -0
  129. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/test_finalize_equilibrator.py +0 -0
  130. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/test_finalize_esmfold2.py +0 -0
  131. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/test_finalize_protmpnn.py +0 -0
  132. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/test_finalize_rfd3.py +0 -0
  133. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/test_rfd3_command.py +0 -0
  134. {dh_cli-0.14.0 → dh_cli-0.15.0}/tests/test_sweep.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: dh-cli
3
- Version: 0.14.0
3
+ Version: 0.15.0
4
4
  Summary: Dayhoff Labs developer CLI
5
5
  Author-email: Dayhoff Labs <dev@dayhofflabs.com>
6
6
  License: # PolyForm Noncommercial License 1.0.0
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "dh-cli"
7
- version = "0.14.0"
7
+ version = "0.15.0"
8
8
  description = "Dayhoff Labs developer CLI"
9
9
  requires-python = ">=3.11"
10
10
  readme = "README.md"
@@ -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
- EXPECTED_TOOLS = {
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 test_lists_five_read_only_tools(self):
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