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.
- {dh_cli-0.17.3 → dh_cli-0.17.4}/PKG-INFO +1 -1
- {dh_cli-0.17.3 → dh_cli-0.17.4}/pyproject.toml +1 -1
- dh_cli-0.17.4/src/dh_cli/hz/mcp_catalogue.py +117 -0
- dh_cli-0.17.4/src/dh_cli/hz/mcp_server.py +70 -0
- dh_cli-0.17.4/src/dh_cli/hz/mcp_tools.py +327 -0
- dh_cli-0.17.4/tests/hz/test_mcp_catalogue.py +180 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/hz/test_mcp_server.py +55 -38
- dh_cli-0.17.3/src/dh_cli/hz/mcp_server.py +0 -321
- {dh_cli-0.17.3 → dh_cli-0.17.4}/.gitignore +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/LICENSE +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/README.md +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/__init__.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/_identity.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/batch/__init__.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/batch/aws_batch.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/batch/commands/__init__.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/batch/commands/boltz.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/batch/commands/cancel.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/batch/commands/clean.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/batch/commands/cost.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/batch/commands/disco.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/batch/commands/embed_esmc.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/batch/commands/embed_t5.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/batch/commands/equilibrator.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/batch/commands/esmfold2.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/batch/commands/finalize.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/batch/commands/list_jobs.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/batch/commands/local.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/batch/commands/logs.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/batch/commands/orca.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/batch/commands/protmpnn.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/batch/commands/protmpnn_to_boltz.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/batch/commands/retry.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/batch/commands/rfd3.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/batch/commands/status.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/batch/commands/submit.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/batch/commands/wait_for.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/batch/cost_report.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/batch/fasta_utils.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/batch/h5_utils.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/batch/job_id.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/batch/manifest.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/batch/s3_transport.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/bedrock/__init__.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/bedrock/commands.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/bedrock/cost_report.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/bedrock/pricing.yaml +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/cloud_commands.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/codeartifact.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/engines_studios/__init__.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/engines_studios/api_client.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/engines_studios/auth.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/engines_studios/engine_commands.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/engines_studios/progress.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/engines_studios/retry.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/engines_studios/ssh_config.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/engines_studios/studio_commands.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/engines_studios/studio_resolve.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/github_commands.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/hz/__init__.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/hz/auth.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/hz/deploy.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/hz/local.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/hz/test.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/hz/tf.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/hz/users.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/main.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/sweep.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/src/dh_cli/warehouse.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/batch/__init__.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/batch/cur_fixtures.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/batch/test_aws_batch_resources.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/batch/test_batch_cost_command.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/batch/test_batch_cost_filters.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/batch/test_batch_cost_reader.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/batch/test_batch_cost_reconcile.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/batch/test_batch_cost_report.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/batch/test_image_override.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/batch/test_import_without_pandas.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/batch/test_no_train_command.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/batch/test_submit_cpu_only.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/batch/test_submit_image_validation.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/batch/test_submit_job_tags.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/batch/test_submit_merge.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/batch/test_submit_mlflow_env.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/bedrock/conftest.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/bedrock/fixtures/A_cache_write.json +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/bedrock/fixtures/B_cache_read.json +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/bedrock/fixtures/C_plain.json +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/bedrock/fixtures/D_cursor_user.json +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/bedrock/fixtures/E_service_role.json +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/bedrock/fixtures/F_legacy_shared.json +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/bedrock/fixtures/G_unknown_model.json +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/bedrock/test_build_report.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/bedrock/test_classify_arn.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/bedrock/test_cli_exit_codes.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/bedrock/test_cost_calc.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/bedrock/test_cost_command.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/bedrock/test_cur_reconciliation.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/bedrock/test_key_command.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/bedrock/test_render_formats.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/bedrock/test_resolve_base_model.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/bedrock/test_rotate.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/bedrock/test_s3_walker.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/engines_studios/__init__.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/engines_studios/test_engine_retry_commands.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/engines_studios/test_retry.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/engines_studios/test_ssh_config.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/engines_studios/test_studio_resolution_commands.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/engines_studios/test_studio_resolve.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/github/__init__.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/github/conftest.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/github/test_engine_role_cannot_read_github_pat.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/github/test_identity.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/github/test_login.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/github/test_login_error_paths.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/github/test_login_security.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/github/test_logout.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/github/test_rotate.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/github/test_status.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/hz/live_smoke.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/hz/test_auth.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/hz/test_init.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/hz/test_suites.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/hz/test_users.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/test_aws_status.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/test_cloud_gcp.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/test_disco_command.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/test_disco_jobdef.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/test_embed_esmc_cli.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/test_equilibrator_command.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/test_esmfold2_cli.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/test_finalize_boltz_tar.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/test_finalize_disco.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/test_finalize_disco_dispatch.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/test_finalize_equilibrator.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/test_finalize_esmfold2.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/test_finalize_protmpnn.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/test_finalize_rfd3.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/test_rfd3_command.py +0 -0
- {dh_cli-0.17.3 → dh_cli-0.17.4}/tests/test_sweep.py +0 -0
|
@@ -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})
|