dh-cli 0.17.3__tar.gz → 0.17.4__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 (141) hide show
  1. {dh_cli-0.17.3 → dh_cli-0.17.4}/PKG-INFO +1 -1
  2. {dh_cli-0.17.3 → dh_cli-0.17.4}/pyproject.toml +1 -1
  3. dh_cli-0.17.4/src/dh_cli/hz/mcp_catalogue.py +117 -0
  4. dh_cli-0.17.4/src/dh_cli/hz/mcp_server.py +70 -0
  5. dh_cli-0.17.4/src/dh_cli/hz/mcp_tools.py +327 -0
  6. dh_cli-0.17.4/tests/hz/test_mcp_catalogue.py +180 -0
  7. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/hz/test_mcp_server.py +55 -38
  8. dh_cli-0.17.3/src/dh_cli/hz/mcp_server.py +0 -321
  9. {dh_cli-0.17.3 → dh_cli-0.17.4}/.gitignore +0 -0
  10. {dh_cli-0.17.3 → dh_cli-0.17.4}/LICENSE +0 -0
  11. {dh_cli-0.17.3 → dh_cli-0.17.4}/README.md +0 -0
  12. {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/__init__.py +0 -0
  13. {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/_identity.py +0 -0
  14. {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/batch/__init__.py +0 -0
  15. {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/batch/aws_batch.py +0 -0
  16. {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/batch/commands/__init__.py +0 -0
  17. {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/batch/commands/boltz.py +0 -0
  18. {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/batch/commands/cancel.py +0 -0
  19. {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/batch/commands/clean.py +0 -0
  20. {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/batch/commands/cost.py +0 -0
  21. {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/batch/commands/disco.py +0 -0
  22. {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/batch/commands/embed_esmc.py +0 -0
  23. {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/batch/commands/embed_t5.py +0 -0
  24. {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/batch/commands/equilibrator.py +0 -0
  25. {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/batch/commands/esmfold2.py +0 -0
  26. {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/batch/commands/finalize.py +0 -0
  27. {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/batch/commands/list_jobs.py +0 -0
  28. {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/batch/commands/local.py +0 -0
  29. {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/batch/commands/logs.py +0 -0
  30. {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/batch/commands/orca.py +0 -0
  31. {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/batch/commands/protmpnn.py +0 -0
  32. {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/batch/commands/protmpnn_to_boltz.py +0 -0
  33. {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/batch/commands/retry.py +0 -0
  34. {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/batch/commands/rfd3.py +0 -0
  35. {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/batch/commands/status.py +0 -0
  36. {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/batch/commands/submit.py +0 -0
  37. {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/batch/commands/wait_for.py +0 -0
  38. {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/batch/cost_report.py +0 -0
  39. {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/batch/fasta_utils.py +0 -0
  40. {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/batch/h5_utils.py +0 -0
  41. {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/batch/job_id.py +0 -0
  42. {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/batch/manifest.py +0 -0
  43. {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/batch/s3_transport.py +0 -0
  44. {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/bedrock/__init__.py +0 -0
  45. {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/bedrock/commands.py +0 -0
  46. {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/bedrock/cost_report.py +0 -0
  47. {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/bedrock/pricing.yaml +0 -0
  48. {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/cloud_commands.py +0 -0
  49. {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/codeartifact.py +0 -0
  50. {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/engines_studios/__init__.py +0 -0
  51. {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/engines_studios/api_client.py +0 -0
  52. {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/engines_studios/auth.py +0 -0
  53. {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/engines_studios/engine_commands.py +0 -0
  54. {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/engines_studios/progress.py +0 -0
  55. {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/engines_studios/retry.py +0 -0
  56. {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/engines_studios/ssh_config.py +0 -0
  57. {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/engines_studios/studio_commands.py +0 -0
  58. {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/engines_studios/studio_resolve.py +0 -0
  59. {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/github_commands.py +0 -0
  60. {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/hz/__init__.py +0 -0
  61. {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/hz/auth.py +0 -0
  62. {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/hz/deploy.py +0 -0
  63. {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/hz/local.py +0 -0
  64. {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/hz/test.py +0 -0
  65. {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/hz/tf.py +0 -0
  66. {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/hz/users.py +0 -0
  67. {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/main.py +0 -0
  68. {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/sweep.py +0 -0
  69. {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/warehouse.py +0 -0
  70. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/batch/__init__.py +0 -0
  71. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/batch/cur_fixtures.py +0 -0
  72. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/batch/test_aws_batch_resources.py +0 -0
  73. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/batch/test_batch_cost_command.py +0 -0
  74. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/batch/test_batch_cost_filters.py +0 -0
  75. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/batch/test_batch_cost_reader.py +0 -0
  76. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/batch/test_batch_cost_reconcile.py +0 -0
  77. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/batch/test_batch_cost_report.py +0 -0
  78. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/batch/test_image_override.py +0 -0
  79. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/batch/test_import_without_pandas.py +0 -0
  80. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/batch/test_no_train_command.py +0 -0
  81. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/batch/test_submit_cpu_only.py +0 -0
  82. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/batch/test_submit_image_validation.py +0 -0
  83. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/batch/test_submit_job_tags.py +0 -0
  84. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/batch/test_submit_merge.py +0 -0
  85. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/batch/test_submit_mlflow_env.py +0 -0
  86. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/bedrock/conftest.py +0 -0
  87. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/bedrock/fixtures/A_cache_write.json +0 -0
  88. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/bedrock/fixtures/B_cache_read.json +0 -0
  89. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/bedrock/fixtures/C_plain.json +0 -0
  90. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/bedrock/fixtures/D_cursor_user.json +0 -0
  91. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/bedrock/fixtures/E_service_role.json +0 -0
  92. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/bedrock/fixtures/F_legacy_shared.json +0 -0
  93. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/bedrock/fixtures/G_unknown_model.json +0 -0
  94. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/bedrock/test_build_report.py +0 -0
  95. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/bedrock/test_classify_arn.py +0 -0
  96. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/bedrock/test_cli_exit_codes.py +0 -0
  97. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/bedrock/test_cost_calc.py +0 -0
  98. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/bedrock/test_cost_command.py +0 -0
  99. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/bedrock/test_cur_reconciliation.py +0 -0
  100. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/bedrock/test_key_command.py +0 -0
  101. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/bedrock/test_render_formats.py +0 -0
  102. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/bedrock/test_resolve_base_model.py +0 -0
  103. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/bedrock/test_rotate.py +0 -0
  104. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/bedrock/test_s3_walker.py +0 -0
  105. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/engines_studios/__init__.py +0 -0
  106. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/engines_studios/test_engine_retry_commands.py +0 -0
  107. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/engines_studios/test_retry.py +0 -0
  108. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/engines_studios/test_ssh_config.py +0 -0
  109. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/engines_studios/test_studio_resolution_commands.py +0 -0
  110. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/engines_studios/test_studio_resolve.py +0 -0
  111. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/github/__init__.py +0 -0
  112. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/github/conftest.py +0 -0
  113. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/github/test_engine_role_cannot_read_github_pat.py +0 -0
  114. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/github/test_identity.py +0 -0
  115. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/github/test_login.py +0 -0
  116. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/github/test_login_error_paths.py +0 -0
  117. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/github/test_login_security.py +0 -0
  118. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/github/test_logout.py +0 -0
  119. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/github/test_rotate.py +0 -0
  120. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/github/test_status.py +0 -0
  121. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/hz/live_smoke.py +0 -0
  122. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/hz/test_auth.py +0 -0
  123. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/hz/test_init.py +0 -0
  124. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/hz/test_suites.py +0 -0
  125. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/hz/test_users.py +0 -0
  126. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/test_aws_status.py +0 -0
  127. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/test_cloud_gcp.py +0 -0
  128. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/test_disco_command.py +0 -0
  129. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/test_disco_jobdef.py +0 -0
  130. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/test_embed_esmc_cli.py +0 -0
  131. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/test_equilibrator_command.py +0 -0
  132. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/test_esmfold2_cli.py +0 -0
  133. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/test_finalize_boltz_tar.py +0 -0
  134. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/test_finalize_disco.py +0 -0
  135. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/test_finalize_disco_dispatch.py +0 -0
  136. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/test_finalize_equilibrator.py +0 -0
  137. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/test_finalize_esmfold2.py +0 -0
  138. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/test_finalize_protmpnn.py +0 -0
  139. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/test_finalize_rfd3.py +0 -0
  140. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/test_rfd3_command.py +0 -0
  141. {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/test_sweep.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: dh-cli
3
- Version: 0.17.3
3
+ Version: 0.17.4
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.17.3"
7
+ version = "0.17.4"
8
8
  description = "Dayhoff Labs developer CLI"
9
9
  requires-python = ">=3.11"
10
10
  readme = "README.md"
@@ -0,0 +1,117 @@
1
+ """One Horizyn MCP catalogue for stdio and authenticated HTTP adapters."""
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ from mcp.server.auth.provider import TokenVerifier
7
+ from mcp.server.auth.settings import AuthSettings
8
+ from mcp.server.mcpserver import MCPServer
9
+ from mcp.types import ToolAnnotations
10
+
11
+ from dh_cli.hz.mcp_tools import HorizynTools
12
+
13
+ READ_ONLY = ToolAnnotations(read_only_hint=True, destructive_hint=False, idempotent_hint=True, open_world_hint=True)
14
+ SUBMIT = ToolAnnotations(read_only_hint=False, destructive_hint=False, idempotent_hint=False, open_world_hint=True)
15
+ LOCAL_FILES = ToolAnnotations(read_only_hint=False, destructive_hint=False, idempotent_hint=True)
16
+
17
+
18
+ def create_server(
19
+ tools: HorizynTools,
20
+ *,
21
+ local_files: bool = False,
22
+ token_verifier: TokenVerifier | None = None,
23
+ auth: AuthSettings | None = None,
24
+ ) -> MCPServer:
25
+ """Bind a caller's request implementation; never load credentials here.
26
+
27
+ Remote adapters omit filesystem tools/arguments. Only the local CLI opts in.
28
+ AuthSettings and TokenVerifier belong to the HTTP adapter, not the tool layer.
29
+ """
30
+ server = MCPServer(
31
+ "horizyn",
32
+ instructions=(
33
+ "Horizyn: Dayhoff Labs' enzyme and reaction similarity search. Tools query "
34
+ "production as the authenticated user. Similarity scores are cosine "
35
+ "similarities in [0, 1]; higher means more similar. Ask before GPU submissions."
36
+ ),
37
+ token_verifier=token_verifier,
38
+ auth=auth,
39
+ )
40
+ for name in (
41
+ "horizyn_whoami",
42
+ "horizyn_list_screening_sets",
43
+ "horizyn_query_reaction",
44
+ "horizyn_query_protein",
45
+ "horizyn_protein_details",
46
+ "horizyn_dock_status",
47
+ "horizyn_list_docking_jobs",
48
+ "horizyn_health",
49
+ "horizyn_detailed_health",
50
+ "horizyn_validate_reaction",
51
+ "horizyn_validate_compound",
52
+ "horizyn_list_cofactors",
53
+ "horizyn_get_query_history",
54
+ "horizyn_get_protein_references",
55
+ ):
56
+ server.add_tool(getattr(tools, name), annotations=READ_ONLY, structured_output=True)
57
+ server.add_tool(tools.horizyn_dock_submit, annotations=SUBMIT, structured_output=True)
58
+ server.add_tool(tools.horizyn_submit_ordered_waters, annotations=SUBMIT, structured_output=True)
59
+
60
+ if local_files:
61
+ server.add_tool(tools.horizyn_dock_waters_submit, annotations=SUBMIT, structured_output=True)
62
+ server.add_tool(tools.horizyn_dock_result, annotations=LOCAL_FILES, structured_output=True)
63
+ else:
64
+
65
+ @server.tool(annotations=READ_ONLY, structured_output=True)
66
+ async def horizyn_dock_result(job_id: str) -> dict[str, Any]:
67
+ """Fetch a completed job's full result without writing server files.
68
+
69
+ Prefer horizyn_get_job_result for a compact summary with resource URIs.
70
+ """
71
+ return await tools.horizyn_dock_result(job_id)
72
+
73
+ @server.tool(annotations=READ_ONLY, structured_output=True)
74
+ async def horizyn_get_job_result(job_id: str) -> dict[str, Any]:
75
+ """Summarize a completed job and return resource URIs for large artifacts."""
76
+ result = await tools.horizyn_dock_result(job_id)
77
+ root = f"horizyn://prod/jobs/{job_id}"
78
+ return {
79
+ "job_id": job_id,
80
+ "job_type": result.get("job_type", "fold"),
81
+ "msa_used": result.get("msa_used"),
82
+ "n_waters": result.get("n_waters"),
83
+ "has_pdb": bool(result.get("pdb_data")),
84
+ "pdb_bytes": len(result.get("pdb_data", "").encode()),
85
+ "has_cif": bool(result.get("cif_data")),
86
+ "cif_bytes": len(result.get("cif_data", "").encode()),
87
+ "has_confidence": bool(result.get("confidence_data")),
88
+ "resources": {
89
+ "result": f"{root}/result",
90
+ "pdb": f"{root}/structure.pdb",
91
+ "cif": f"{root}/structure.cif",
92
+ "confidence": f"{root}/confidence.json",
93
+ },
94
+ }
95
+
96
+ @server.resource("horizyn://prod/jobs/{job_id}/result")
97
+ async def job_result(job_id: str) -> str:
98
+ """Full JSON result for a completed Horizyn job."""
99
+ return json.dumps(await tools.horizyn_dock_result(job_id), indent=2, sort_keys=True)
100
+
101
+ @server.resource("horizyn://prod/jobs/{job_id}/structure.pdb")
102
+ async def job_pdb(job_id: str) -> str:
103
+ """PDB structure produced by a completed Horizyn job."""
104
+ return str((await tools.horizyn_dock_result(job_id)).get("pdb_data", ""))
105
+
106
+ @server.resource("horizyn://prod/jobs/{job_id}/structure.cif")
107
+ async def job_cif(job_id: str) -> str:
108
+ """mmCIF structure produced by a completed Horizyn job."""
109
+ return str((await tools.horizyn_dock_result(job_id)).get("cif_data", ""))
110
+
111
+ @server.resource("horizyn://prod/jobs/{job_id}/confidence.json")
112
+ async def job_confidence(job_id: str) -> str:
113
+ """Confidence metrics produced by a completed Horizyn job."""
114
+ result = await tools.horizyn_dock_result(job_id)
115
+ return json.dumps(result.get("confidence_data", {}), indent=2, sort_keys=True)
116
+
117
+ return server
@@ -0,0 +1,70 @@
1
+ """Horizyn MCP server over stdio (`dh hz mcp serve`). Prod only.
2
+
3
+ Every request carries the logged-in user's own Cognito ID token, so queries
4
+ land in that user's Horizyn history. Requires the ``mcp`` extra:
5
+ ``pip install 'dh-cli[mcp]'``.
6
+ """
7
+
8
+ from functools import partial
9
+ from importlib.metadata import version
10
+
11
+ import anyio
12
+ import httpx
13
+ from mcp.server.mcpserver.exceptions import ToolError
14
+
15
+ from dh_cli.hz import API_URLS
16
+ from dh_cli.hz.auth import NotLoggedIn, get_id_token
17
+ from dh_cli.hz.mcp_catalogue import create_server
18
+ from dh_cli.hz.mcp_tools import HorizynTools
19
+
20
+ CLIENT_HEADER = f"mcp/{version('dh-cli')}"
21
+ _client = httpx.Client(base_url=API_URLS["prod"], timeout=120)
22
+
23
+
24
+ def _request(method: str, path: str, **kwargs) -> dict:
25
+ try:
26
+ token = get_id_token()
27
+ except NotLoggedIn as exc:
28
+ raise ToolError(str(exc)) from None
29
+
30
+ headers = {"Authorization": f"Bearer {token}", "X-Horizyn-Client": CLIENT_HEADER}
31
+ try:
32
+ resp = _client.request(method, path, headers=headers, **kwargs)
33
+ except httpx.HTTPError as exc:
34
+ raise ToolError(f"Horizyn request failed: {type(exc).__name__}: {exc}") from None
35
+
36
+ if resp.status_code == 503:
37
+ raise ToolError("Horizyn is starting or unavailable, retry shortly")
38
+ if resp.status_code >= 400:
39
+ raise ToolError(_error_text(resp))
40
+ return resp.json()
41
+
42
+
43
+ def _error_text(resp: httpx.Response) -> str:
44
+ try:
45
+ body = resp.json()
46
+ except ValueError:
47
+ body = {}
48
+ if not isinstance(body, dict):
49
+ body = {}
50
+ parts = [p for p in (body.get("error"), body.get("message")) if p]
51
+ detail = body.get("detail")
52
+ if isinstance(detail, str):
53
+ parts.append(detail)
54
+ elif isinstance(detail, list):
55
+ # FastAPI validation errors; take loc + msg only, never the echoed `input`.
56
+ for err in detail:
57
+ if isinstance(err, dict) and err.get("msg"):
58
+ loc = ".".join(str(x) for x in err.get("loc", []) if x != "body")
59
+ parts.append(f"{loc}: {err['msg']}" if loc else err["msg"])
60
+ text = "; ".join(parts) if parts else resp.reason_phrase
61
+ return f"Horizyn returned {resp.status_code}: {text}"
62
+
63
+
64
+ async def _async_request(method: str, path: str, **kwargs) -> dict:
65
+ # Credential refresh and local HTTP are blocking; keep the MCP event loop free.
66
+ return await anyio.to_thread.run_sync(partial(_request, method, path, **kwargs))
67
+
68
+
69
+ tools = HorizynTools(_async_request)
70
+ server = create_server(tools, local_files=True)
@@ -0,0 +1,327 @@
1
+ """Transport-neutral Horizyn operations. Requests are supplied by each caller."""
2
+
3
+ from collections.abc import Awaitable, Callable
4
+ from pathlib import Path
5
+ from typing import Any, Literal
6
+ from urllib.parse import quote
7
+
8
+ from mcp.server.mcpserver.exceptions import ToolError
9
+
10
+ Request = Callable[..., Awaitable[dict[str, Any]]]
11
+
12
+
13
+ def _path_identifier(value: str) -> str:
14
+ if value in ("", ".", ".."):
15
+ raise ToolError("Horizyn identifier must not be empty, '.' or '..'")
16
+ return quote(value, safe="")
17
+
18
+
19
+ class HorizynTools:
20
+ def __init__(self, request: Request):
21
+ self.request = request
22
+
23
+ async def _query(self, path: str, body: dict, filters: dict) -> dict[str, Any]:
24
+ body.update({k: v for k, v in filters.items() if v is not None})
25
+ data = await self.request("POST", path, json=body)
26
+ data.pop("timings_ms", None)
27
+ data.pop("tsne", None)
28
+ return data
29
+
30
+ async def horizyn_whoami(
31
+ self,
32
+ ) -> dict[str, Any]:
33
+ """Identify the logged-in Horizyn user.
34
+
35
+ Returns email, tier (admin, alpha, or beta) and the feature flags that tier
36
+ grants. Use this first to confirm the server is authenticated.
37
+ """
38
+ return await self.request("GET", "/me")
39
+
40
+ async def horizyn_list_screening_sets(self, screening_set_type: str | None = None) -> dict[str, Any]:
41
+ """List the screening sets available for similarity queries.
42
+
43
+ Each entry gives the set's id, type (protein or reaction), size, model
44
+ version and whether it is the current default (`is_default`).
45
+ `screening_set_type` optionally filters to "protein" or "reaction".
46
+ """
47
+ params = {"screening_set_type": screening_set_type} if screening_set_type else None
48
+ return await self.request("GET", "/screening_sets", params=params)
49
+
50
+ async def horizyn_query_reaction(
51
+ self,
52
+ smiles: str,
53
+ screening_set: str | None = None,
54
+ top_k: int = 50,
55
+ page: int = 1,
56
+ page_size: int = 20,
57
+ ec_include: list[str] | None = None,
58
+ ec_exclude: list[str] | None = None,
59
+ tc_include: list[str] | None = None,
60
+ tc_exclude: list[str] | None = None,
61
+ cofactor_include: list[str] | None = None,
62
+ cofactor_exclude: list[str] | None = None,
63
+ min_length: int | None = None,
64
+ max_length: int | None = None,
65
+ cluster_by: Literal["ec3", "ec4"] | None = None,
66
+ cluster_size: int | None = None,
67
+ fetch_metadata: bool | None = None,
68
+ ) -> dict[str, Any]:
69
+ """Find proteins (or reactions) similar to a reaction SMILES.
70
+
71
+ `smiles` is a reaction SMILES such as "CC=O.O>>CC(O)O". When
72
+ `screening_set` is omitted Horizyn searches its current default protein set
73
+ (the response's `screening_set` names it; see horizyn_list_screening_sets).
74
+ Pass a reaction set such as horizyn_v1_1_rxns to find similar reactions
75
+ instead. `score` on each result is the cosine similarity between the query
76
+ and the hit in Horizyn's embedding space (0-1, higher is more similar).
77
+ `top_k` (max 1000) bounds the similarity search; `page`/`page_size`
78
+ paginate the result list.
79
+
80
+ All filters are optional. `ec_include`/`ec_exclude` and
81
+ `tc_include`/`tc_exclude` take hierarchical prefixes ("1", "1.1", "1.1.1",
82
+ "1.1.1.1"; "__NONE__" matches unannotated). `cofactor_include`/
83
+ `cofactor_exclude` take cofactor names ("NAD", "Zn"; "__ANY__"/"__NONE__").
84
+ `min_length`/`max_length` bound sequence length. `filter_stats` in the
85
+ response reports how many hits the filters kept.
86
+ """
87
+ body = {"smiles": smiles, "top_k": top_k, "page": page, "page_size": page_size}
88
+ filters = {
89
+ "screening_set": screening_set,
90
+ "ec_include": ec_include,
91
+ "ec_exclude": ec_exclude,
92
+ "tc_include": tc_include,
93
+ "tc_exclude": tc_exclude,
94
+ "cofactor_include": cofactor_include,
95
+ "cofactor_exclude": cofactor_exclude,
96
+ "min_length": min_length,
97
+ "max_length": max_length,
98
+ "cluster_by": cluster_by,
99
+ "cluster_size": cluster_size,
100
+ "fetch_metadata": fetch_metadata,
101
+ }
102
+ return await self._query("/query/reaction", body, filters)
103
+
104
+ async def horizyn_query_protein(
105
+ self,
106
+ sequence: str,
107
+ screening_set: str | None = None,
108
+ top_k: int = 50,
109
+ page: int = 1,
110
+ page_size: int = 20,
111
+ ec_include: list[str] | None = None,
112
+ ec_exclude: list[str] | None = None,
113
+ tc_include: list[str] | None = None,
114
+ tc_exclude: list[str] | None = None,
115
+ cofactor_include: list[str] | None = None,
116
+ cofactor_exclude: list[str] | None = None,
117
+ min_length: int | None = None,
118
+ max_length: int | None = None,
119
+ cluster_by: Literal["ec3", "ec4"] | None = None,
120
+ cluster_size: int | None = None,
121
+ fetch_metadata: bool | None = None,
122
+ ) -> dict[str, Any]:
123
+ """Find proteins (or reactions) similar to a protein sequence.
124
+
125
+ `sequence` is a one-letter amino-acid string. Same screening sets, scoring,
126
+ pagination and optional filters as horizyn_query_reaction: `score` is the
127
+ cosine similarity (0-1) in Horizyn's shared reaction/protein embedding
128
+ space, omitting `screening_set` searches Horizyn's current default protein
129
+ set, and every filter argument may be omitted.
130
+ """
131
+ body = {"sequence": sequence, "top_k": top_k, "page": page, "page_size": page_size}
132
+ filters = {
133
+ "screening_set": screening_set,
134
+ "ec_include": ec_include,
135
+ "ec_exclude": ec_exclude,
136
+ "tc_include": tc_include,
137
+ "tc_exclude": tc_exclude,
138
+ "cofactor_include": cofactor_include,
139
+ "cofactor_exclude": cofactor_exclude,
140
+ "min_length": min_length,
141
+ "max_length": max_length,
142
+ "cluster_by": cluster_by,
143
+ "cluster_size": cluster_size,
144
+ "fetch_metadata": fetch_metadata,
145
+ }
146
+ return await self._query("/query/protein", body, filters)
147
+
148
+ async def horizyn_protein_details(self, protein_id: str, screening_set: str) -> dict[str, Any]:
149
+ """Get full details for one protein from a screening set.
150
+
151
+ `protein_id` is the `id` returned by a query and `screening_set` is the
152
+ `screening_set` echoed in that query's response (the id is only meaningful
153
+ within its set). Returns name, organism, EC numbers, cofactors, expression
154
+ score, full sequence, length, AlphaFold id and any extra metadata.
155
+ """
156
+ return await self.request(
157
+ "GET", f"/protein/{_path_identifier(protein_id)}/details", params={"screening_set": screening_set}
158
+ )
159
+
160
+ async def horizyn_dock_submit(
161
+ self,
162
+ protein_sequence: str = "",
163
+ reaction_smiles: str | None = None,
164
+ dock_products: bool = False,
165
+ protein_id: str | None = None,
166
+ label: str | None = None,
167
+ predict_waters: bool = False,
168
+ waters_selection: str | None = None,
169
+ boltz_yaml: str | None = None,
170
+ screening_set: str | None = None,
171
+ ) -> dict[str, Any]:
172
+ """Submit a Boltz fold+dock job. Costs GPU time; ask the user before calling.
173
+
174
+ Folds `protein_sequence` and docks the ligands parsed from `reaction_smiles`
175
+ ("substrates>>products"; the substrate side by default, the product side
176
+ when `dock_products` is true). A job typically takes 30-120 s of GPU plus
177
+ about a minute for the MSA lookup, times out after 10 min, and the queue
178
+ holds 20 jobs. `protein_id` (UniProt) is an MSA hint; `label` names the job
179
+ in the user's history. `predict_waters` chains an ordered-water prediction
180
+ on the folded structure (`waters_selection` "confidence" or "density");
181
+ its job id appears as `child_job_id` in horizyn_dock_status. `boltz_yaml`
182
+ is advanced mode: a raw Boltz config replaces `reaction_smiles`.
183
+
184
+ Returns `job_id` and `status` ("queued"). Poll horizyn_dock_status until
185
+ `status` is "complete", then call horizyn_dock_result.
186
+ """
187
+ if bool(protein_sequence) == bool(boltz_yaml):
188
+ raise ToolError("Provide exactly one of protein_sequence or boltz_yaml")
189
+
190
+ body = {
191
+ "protein_sequence": protein_sequence,
192
+ "reaction_smiles": reaction_smiles,
193
+ "dock_products": dock_products,
194
+ "protein_id": protein_id,
195
+ "label": label,
196
+ "predict_waters": predict_waters,
197
+ "waters_selection": waters_selection,
198
+ "boltz_yaml": boltz_yaml,
199
+ "screening_set": screening_set,
200
+ }
201
+ return await self.request("POST", "/dock/submit", json={k: v for k, v in body.items() if v is not None})
202
+
203
+ async def horizyn_dock_waters_submit(
204
+ self,
205
+ structure_path: str,
206
+ selection: str | None = None,
207
+ label: str | None = None,
208
+ ) -> dict[str, Any]:
209
+ """Predict ordered water positions on an existing structure. Costs GPU time.
210
+
211
+ `structure_path` is a local `.pdb` or `.cif` file (format taken from the
212
+ suffix, 8 MB max); the server reads it, so never paste structure text into
213
+ this call. `selection` is "confidence" or "density". Returns `job_id` and
214
+ `status`; poll horizyn_dock_status, then horizyn_dock_result, whose
215
+ `waters` list and `n_waters` carry the prediction.
216
+ """
217
+ path = Path(structure_path).expanduser()
218
+ try:
219
+ structure_data = path.read_text()
220
+ except OSError as exc:
221
+ raise ToolError(f"Cannot read structure file {path}: {exc.strerror or type(exc).__name__}") from None
222
+
223
+ body = {
224
+ "structure_data": structure_data,
225
+ "structure_format": path.suffix,
226
+ "selection": selection,
227
+ "label": label,
228
+ }
229
+ return await self.request("POST", "/dock/waters/submit", json={k: v for k, v in body.items() if v is not None})
230
+
231
+ async def horizyn_dock_status(self, job_id: str) -> dict[str, Any]:
232
+ """Poll a docking job. Wait at least 15 s between polls.
233
+
234
+ `status` is "queued", "running", "complete" or "failed"; `error` explains a
235
+ failure. `elapsed_seconds` and `queue_position` track progress. `msa_used`
236
+ false means Boltz fell back to single-sequence mode, so confidence is lower.
237
+ `job_type` is "fold" or "waters"; `child_job_id` names a chained waters job
238
+ and `parent_job_id` its fold.
239
+ """
240
+ return await self.request("GET", f"/dock/{_path_identifier(job_id)}/status")
241
+
242
+ async def horizyn_dock_result(self, job_id: str, output_dir: str | None = None) -> dict[str, Any]:
243
+ """Fetch a completed docking job's structure and confidence.
244
+
245
+ Returns 409 until horizyn_dock_status reports "complete", so poll first.
246
+ With `output_dir`, writes `{job_id}.pdb` (and `{job_id}.cif` when present)
247
+ there, creating the directory, and returns `pdb_path`/`cif_path` instead of
248
+ the raw text. Without it, `pdb_data`/`cif_data` are returned inline (large).
249
+ Always includes `confidence_data`, `msa_used`, `job_type`, `waters`,
250
+ `n_waters`, `parent_job_id` and `child_job_id`.
251
+ """
252
+ data = await self.request("GET", f"/dock/{_path_identifier(job_id)}/result")
253
+ if output_dir is None:
254
+ return data
255
+
256
+ out = Path(output_dir).expanduser()
257
+ out.mkdir(parents=True, exist_ok=True)
258
+ pdb_path = out / f"{job_id}.pdb"
259
+ pdb_path.write_text(data.pop("pdb_data", ""))
260
+ data["pdb_path"] = str(pdb_path)
261
+
262
+ cif_data = data.pop("cif_data", "")
263
+ cif_path = out / f"{job_id}.cif"
264
+ if cif_data:
265
+ cif_path.write_text(cif_data)
266
+ data["cif_path"] = str(cif_path) if cif_data else None
267
+ return data
268
+
269
+ async def horizyn_list_docking_jobs(self, limit: int = 20) -> dict[str, Any]:
270
+ """List the logged-in user's recent docking jobs.
271
+
272
+ `limit` is 1-100. Items in `jobs` are ordered by `job_id`, not by time;
273
+ sort on `submitted_at` yourself. Each carries `job_id`, `status`,
274
+ `job_type`, `submitted_at`, `label`, `protein_sequence`, `reaction_smiles`
275
+ and `client_source`. Status here is only updated when a job is polled or
276
+ its result fetched; use horizyn_dock_status for the live value.
277
+ """
278
+ return await self.request("GET", "/me/docking-jobs", params={"limit": limit})
279
+
280
+ async def horizyn_health(self) -> dict[str, Any]:
281
+ """Check upstream readiness. Remote MCP still authenticates the caller."""
282
+ return await self.request("GET", "/healthz")
283
+
284
+ async def horizyn_detailed_health(self) -> dict[str, Any]:
285
+ """Inspect Horizyn GPU, registry, EFS and default-set readiness."""
286
+ return await self.request("GET", "/healthz/detailed")
287
+
288
+ async def horizyn_validate_reaction(self, smiles: str) -> dict[str, Any]:
289
+ """Validate atom/charge balance and parsing of reaction SMILES."""
290
+ return await self.request("POST", "/validate/reaction", json={"smiles": smiles})
291
+
292
+ async def horizyn_validate_compound(self, smiles: str) -> dict[str, Any]:
293
+ """Validate and canonicalize a compound SMILES."""
294
+ return await self.request("POST", "/validate/compound", json={"smiles": smiles})
295
+
296
+ async def horizyn_list_cofactors(self) -> dict[str, Any]:
297
+ """List annotated cofactors and protein counts available for filtering."""
298
+ return await self.request("GET", "/cofactors")
299
+
300
+ async def horizyn_get_query_history(self, limit: int = 20) -> dict[str, Any]:
301
+ """Return recent queries for the caller, with limit between 1 and 1000."""
302
+ return await self.request("GET", "/me/query-history", params={"limit": limit})
303
+
304
+ async def horizyn_get_protein_references(self, protein_id: str, screening_set: str) -> dict[str, Any]:
305
+ """Fetch UniProt literature and structure references for a protein hit."""
306
+ return await self.request(
307
+ "GET", f"/protein/{_path_identifier(protein_id)}/references", params={"screening_set": screening_set}
308
+ )
309
+
310
+ async def horizyn_submit_ordered_waters(
311
+ self,
312
+ structure_data: str,
313
+ structure_format: Literal[".pdb", ".cif", "pdb", "cif"],
314
+ selection: Literal["confidence", "density"] = "confidence",
315
+ label: str | None = None,
316
+ ) -> dict[str, Any]:
317
+ """Submit ordered-water placement from inline PDB/mmCIF. Costs GPU time; ask first.
318
+
319
+ Remote callers must send the structure itself, never a server filesystem path.
320
+ """
321
+ body = {
322
+ "structure_data": structure_data,
323
+ "structure_format": structure_format,
324
+ "selection": selection,
325
+ "label": label,
326
+ }
327
+ return await self.request("POST", "/dock/waters/submit", json={k: v for k, v in body.items() if v is not None})