mirror of
https://github.com/mims-harvard/ToolUniverse.git
synced 2026-09-19 07:31:47 +08:00
6b35f906f8
* feat: restore native remote tool sharing * fix: make remote provider startup lightweight * feat: support resilient platform remote jobs * docs: pin finalized remote companion * test: protect standalone ToolUniverse compatibility * fix: preserve standalone sdk isolation * test: harden final merge compatibility gates * fix: harden native remote release gates * fix: close native remote edge cases * Add browser-first remote tool sharing (#542) * Add browser-first remote tool sharing * Use public relay wheel for remote setup * Fix native remote server compatibility * Publish validated remote provider hardening (#543) * Clarify provider preflight diagnostics * Publish validated remote provider hardening * Fix remote provider lint errors * Fix main integration test regressions * Isolate CellTypist boundary tests
508 lines
16 KiB
Python
Executable File
508 lines
16 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Preflight one or all documented ToolUniverse remote-tool deployments.
|
|
|
|
The static checks use only the Python standard library, so an operator can run
|
|
them before installing a remote tool's large scientific dependency stack. The
|
|
optional live check imports FastMCP and verifies exact tool discovery, but it
|
|
does not invoke models or provider operations.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import asyncio
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
SKILLS_ROOT = REPO_ROOT / "skills"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Deployment:
|
|
slug: str
|
|
port: int
|
|
operations: tuple[str, ...]
|
|
required_env: tuple[str, ...] = ()
|
|
required_commands: tuple[str, ...] = ()
|
|
path_env: tuple[str, ...] = ()
|
|
secret_env: tuple[str, ...] = ()
|
|
|
|
@property
|
|
def endpoint(self) -> str:
|
|
return f"http://127.0.0.1:{self.port}/mcp"
|
|
|
|
|
|
DEPLOYMENTS = (
|
|
Deployment(
|
|
"boltz",
|
|
8080,
|
|
("boltz2_docking",),
|
|
required_commands=("boltz",),
|
|
),
|
|
Deployment("borzoi", 8012, ("run_borzoi_predict", "run_borzoi_variant_effect")),
|
|
Deployment(
|
|
"cell2location",
|
|
8019,
|
|
("run_cell2location_deconvolution",),
|
|
("TOOLUNIVERSE_REMOTE_DATA_ROOT",),
|
|
path_env=("TOOLUNIVERSE_REMOTE_DATA_ROOT",),
|
|
),
|
|
Deployment(
|
|
"cellrank",
|
|
8028,
|
|
("run_cellrank_fate",),
|
|
("TOOLUNIVERSE_REMOTE_DATA_ROOT",),
|
|
path_env=("TOOLUNIVERSE_REMOTE_DATA_ROOT",),
|
|
),
|
|
Deployment(
|
|
"celltypist",
|
|
8014,
|
|
("run_celltypist_annotate",),
|
|
("TOOLUNIVERSE_REMOTE_DATA_ROOT", "CELLTYPIST_SAFE_MODEL_DIR"),
|
|
path_env=("TOOLUNIVERSE_REMOTE_DATA_ROOT", "CELLTYPIST_SAFE_MODEL_DIR"),
|
|
),
|
|
Deployment(
|
|
"chrombpnet",
|
|
8032,
|
|
("run_chrombpnet_predict", "run_chrombpnet_variant_effect"),
|
|
("CHROMBPNET_MODEL_PATH",),
|
|
path_env=("CHROMBPNET_MODEL_PATH",),
|
|
),
|
|
Deployment(
|
|
"depmap-24q2",
|
|
7002,
|
|
("compute_depmap24q2_gene_correlations",),
|
|
("DEPMAP_DATA_PATH",),
|
|
path_env=("DEPMAP_DATA_PATH",),
|
|
),
|
|
Deployment(
|
|
"enformer", 8011, ("run_enformer_predict", "run_enformer_variant_effect")
|
|
),
|
|
Deployment("esm", 8008, ("esm_embed_sequence",)),
|
|
Deployment(
|
|
"expert-feedback",
|
|
9876,
|
|
(
|
|
"consult_human_expert",
|
|
"get_expert_response",
|
|
"list_pending_expert_requests",
|
|
"submit_expert_response",
|
|
"get_expert_status",
|
|
),
|
|
),
|
|
Deployment(
|
|
"harmony",
|
|
8026,
|
|
("run_harmony_integrate",),
|
|
("TOOLUNIVERSE_REMOTE_DATA_ROOT",),
|
|
path_env=("TOOLUNIVERSE_REMOTE_DATA_ROOT",),
|
|
),
|
|
Deployment(
|
|
"immune-compass",
|
|
7003,
|
|
("run_compass_prediction",),
|
|
("COMPASS_SAFE_MODEL_DIR",),
|
|
path_env=("COMPASS_SAFE_MODEL_DIR",),
|
|
),
|
|
Deployment(
|
|
"ldsc",
|
|
8013,
|
|
("run_ldsc_heritability", "run_ldsc_genetic_correlation"),
|
|
("TOOLUNIVERSE_REMOTE_DATA_ROOT", "LDSC_DIR", "LDSC_REF_DIR"),
|
|
path_env=("TOOLUNIVERSE_REMOTE_DATA_ROOT", "LDSC_DIR", "LDSC_REF_DIR"),
|
|
),
|
|
Deployment(
|
|
"liana",
|
|
8017,
|
|
("run_liana_cellphonedb",),
|
|
("TOOLUNIVERSE_REMOTE_DATA_ROOT",),
|
|
path_env=("TOOLUNIVERSE_REMOTE_DATA_ROOT",),
|
|
),
|
|
Deployment(
|
|
"macs3",
|
|
8021,
|
|
("run_macs3_callpeak",),
|
|
("TOOLUNIVERSE_REMOTE_DATA_ROOT",),
|
|
path_env=("TOOLUNIVERSE_REMOTE_DATA_ROOT",),
|
|
),
|
|
Deployment(
|
|
"milo",
|
|
8023,
|
|
("run_milo_differential_abundance",),
|
|
("TOOLUNIVERSE_REMOTE_DATA_ROOT",),
|
|
path_env=("TOOLUNIVERSE_REMOTE_DATA_ROOT",),
|
|
),
|
|
Deployment("mofa", 8024, ("run_mofa_factors",)),
|
|
Deployment(
|
|
"monocle3",
|
|
8031,
|
|
("run_monocle3_pseudotime",),
|
|
("TOOLUNIVERSE_REMOTE_DATA_ROOT",),
|
|
("Rscript",),
|
|
("TOOLUNIVERSE_REMOTE_DATA_ROOT",),
|
|
),
|
|
Deployment(
|
|
"paga",
|
|
8022,
|
|
("run_paga_trajectory",),
|
|
("TOOLUNIVERSE_REMOTE_DATA_ROOT",),
|
|
path_env=("TOOLUNIVERSE_REMOTE_DATA_ROOT",),
|
|
),
|
|
Deployment(
|
|
"pinnacle",
|
|
7001,
|
|
("run_pinnacle_ppi_retrieval",),
|
|
("PINNACLE_DATA_PATH",),
|
|
path_env=("PINNACLE_DATA_PATH",),
|
|
),
|
|
Deployment(
|
|
"scanvi",
|
|
8027,
|
|
("run_scanvi_annotate",),
|
|
("TOOLUNIVERSE_REMOTE_DATA_ROOT",),
|
|
path_env=("TOOLUNIVERSE_REMOTE_DATA_ROOT",),
|
|
),
|
|
Deployment(
|
|
"scrublet",
|
|
8015,
|
|
("run_scrublet_doublets",),
|
|
("TOOLUNIVERSE_REMOTE_DATA_ROOT",),
|
|
path_env=("TOOLUNIVERSE_REMOTE_DATA_ROOT",),
|
|
),
|
|
Deployment(
|
|
"scvelo",
|
|
8025,
|
|
("run_scvelo_velocity",),
|
|
("TOOLUNIVERSE_REMOTE_DATA_ROOT",),
|
|
path_env=("TOOLUNIVERSE_REMOTE_DATA_ROOT",),
|
|
),
|
|
Deployment(
|
|
"scvi",
|
|
8010,
|
|
("run_scvi_integration", "run_scvi_differential_expression"),
|
|
("TOOLUNIVERSE_REMOTE_DATA_ROOT",),
|
|
path_env=("TOOLUNIVERSE_REMOTE_DATA_ROOT",),
|
|
),
|
|
Deployment(
|
|
"singler",
|
|
8029,
|
|
("run_singler_annotate",),
|
|
("TOOLUNIVERSE_REMOTE_DATA_ROOT",),
|
|
("Rscript",),
|
|
("TOOLUNIVERSE_REMOTE_DATA_ROOT",),
|
|
),
|
|
Deployment(
|
|
"slingshot",
|
|
8030,
|
|
("run_slingshot_trajectory",),
|
|
("TOOLUNIVERSE_REMOTE_DATA_ROOT",),
|
|
("Rscript",),
|
|
("TOOLUNIVERSE_REMOTE_DATA_ROOT",),
|
|
),
|
|
Deployment(
|
|
"squidpy",
|
|
8016,
|
|
("run_squidpy_nhood_enrichment",),
|
|
("TOOLUNIVERSE_REMOTE_DATA_ROOT",),
|
|
path_env=("TOOLUNIVERSE_REMOTE_DATA_ROOT",),
|
|
),
|
|
Deployment(
|
|
"tangram",
|
|
8018,
|
|
("run_tangram_deconvolution",),
|
|
("TOOLUNIVERSE_REMOTE_DATA_ROOT",),
|
|
path_env=("TOOLUNIVERSE_REMOTE_DATA_ROOT",),
|
|
),
|
|
Deployment(
|
|
"transcriptformer",
|
|
7000,
|
|
("run_transcriptformer_embedding_retrieval",),
|
|
("TRANSCRIPTFORMER_DATA_PATH",),
|
|
path_env=("TRANSCRIPTFORMER_DATA_PATH",),
|
|
),
|
|
Deployment(
|
|
"uspto-downloader",
|
|
8081,
|
|
(
|
|
"get_abstract_from_patent_app_number",
|
|
"get_claims_from_patent_app_number",
|
|
"get_full_text_from_patent_app_number",
|
|
),
|
|
("USPTO_API_KEY",),
|
|
secret_env=("USPTO_API_KEY",),
|
|
),
|
|
)
|
|
|
|
BY_SLUG = {deployment.slug: deployment for deployment in DEPLOYMENTS}
|
|
|
|
|
|
def _skill_contract(deployment: Deployment) -> dict[str, Any]:
|
|
skill_path = SKILLS_ROOT / f"setup-{deployment.slug}-remote-tool" / "SKILL.md"
|
|
result: dict[str, Any] = {
|
|
"ok": True,
|
|
"skill": str(skill_path.relative_to(REPO_ROOT)),
|
|
}
|
|
if not skill_path.is_file():
|
|
result.update(ok=False, error="setup skill is missing")
|
|
return result
|
|
|
|
text = skill_path.read_text(encoding="utf-8")
|
|
module_match = re.search(
|
|
r"^python -m (tooluniverse\.remote\.\S+)", text, re.MULTILINE
|
|
)
|
|
endpoint_match = re.search(r"endpoint is http://127\.0\.0\.1:(\d+)/mcp", text)
|
|
if module_match is None or endpoint_match is None:
|
|
result.update(ok=False, error="documented module or endpoint is missing")
|
|
return result
|
|
|
|
module = module_match.group(1)
|
|
documented_port = int(endpoint_match.group(1))
|
|
module_path = REPO_ROOT / "src" / Path(*module.split(".")).with_suffix(".py")
|
|
representative_operations = tuple(
|
|
re.findall(r"^Operation: (\S+)", text, re.MULTILINE)
|
|
)
|
|
requirements = re.findall(r"^python -m pip install -r (\S+)", text, re.MULTILINE)
|
|
missing_requirements = [
|
|
path for path in requirements if not (REPO_ROOT / path).is_file()
|
|
]
|
|
problems = []
|
|
if documented_port != deployment.port:
|
|
problems.append(
|
|
f"documented port {documented_port} != catalog port {deployment.port}"
|
|
)
|
|
if not module_path.is_file():
|
|
problems.append(
|
|
f"module source is missing: {module_path.relative_to(REPO_ROOT)}"
|
|
)
|
|
if not representative_operations:
|
|
problems.append("representative operation is missing")
|
|
elif not set(representative_operations).issubset(deployment.operations):
|
|
problems.append("representative operation is absent from the expected tool set")
|
|
if missing_requirements:
|
|
problems.append(f"missing requirement files: {missing_requirements}")
|
|
if "setup_skill_preflight.py" not in text:
|
|
problems.append("shared preflight command is missing from the setup skill")
|
|
if "tu remote login" not in text:
|
|
problems.append("one-time remote login command is missing")
|
|
if "tu remote logout" not in text:
|
|
problems.append("remote logout recovery command is missing")
|
|
if "expired, or revoked" not in text:
|
|
problems.append("invalid-key recovery guidance is missing")
|
|
one_command = f"tu remote share {deployment.slug}"
|
|
if one_command not in text:
|
|
problems.append(f"reviewed short share command is missing: {one_command}")
|
|
|
|
result.update(
|
|
ok=not problems,
|
|
module=module,
|
|
module_source=str(module_path.relative_to(REPO_ROOT)),
|
|
endpoint=deployment.endpoint,
|
|
expected_operations=list(deployment.operations),
|
|
representative_operations=list(representative_operations),
|
|
requirements=requirements,
|
|
problems=problems,
|
|
)
|
|
return result
|
|
|
|
|
|
def _provider_environment(deployment: Deployment) -> dict[str, Any]:
|
|
variables = []
|
|
for name in deployment.required_env:
|
|
value = os.getenv(name, "")
|
|
item: dict[str, Any] = {"name": name, "set": bool(value)}
|
|
if name in deployment.path_env:
|
|
item["path_exists"] = bool(value) and Path(value).expanduser().exists()
|
|
if name in deployment.secret_env:
|
|
item["secret"] = True
|
|
variables.append(item)
|
|
|
|
commands = []
|
|
for name in deployment.required_commands:
|
|
override_name = "RSCRIPT_BIN" if name == "Rscript" else None
|
|
override = os.getenv(override_name, "") if override_name else ""
|
|
available = (
|
|
Path(override).expanduser().is_file()
|
|
if override
|
|
else shutil.which(name) is not None
|
|
)
|
|
commands.append(
|
|
{
|
|
"name": name,
|
|
"available": available,
|
|
"override_environment": override_name,
|
|
"override_set": bool(override),
|
|
}
|
|
)
|
|
ok = all(item["set"] for item in variables)
|
|
ok = ok and all(item.get("path_exists", True) for item in variables)
|
|
ok = ok and all(item["available"] for item in commands)
|
|
return {"ok": ok, "variables": variables, "commands": commands}
|
|
|
|
|
|
def _connect_prerequisites() -> dict[str, Any]:
|
|
sdk_available = importlib.util.find_spec("tuplatform_connect") is not None
|
|
key_available = bool(os.getenv("TOOLUNIVERSE_SERVICE_KEY", "").strip())
|
|
return {
|
|
"ok": sdk_available and key_available,
|
|
"sdk_available": sdk_available,
|
|
"service_key_set": key_available,
|
|
"service_key_value_disclosed": False,
|
|
"install_source": ("private pinned GitHub SSH source; not published on PyPI"),
|
|
"installation_requires_authorized_github_read_access": True,
|
|
"installation_requires_configured_ssh_key": True,
|
|
}
|
|
|
|
|
|
async def _live_discovery(
|
|
deployment: Deployment, endpoint: str, timeout: float
|
|
) -> dict[str, Any]:
|
|
try:
|
|
from fastmcp import Client
|
|
except ImportError:
|
|
return {
|
|
"ok": False,
|
|
"endpoint": endpoint,
|
|
"error": "fastmcp is not installed in this environment",
|
|
}
|
|
|
|
try:
|
|
async with asyncio.timeout(timeout):
|
|
async with Client(endpoint) as client:
|
|
discovered = sorted(tool.name for tool in await client.list_tools())
|
|
except Exception as exc: # noqa: BLE001 - preserve one failed endpoint in evidence
|
|
return {
|
|
"ok": False,
|
|
"endpoint": endpoint,
|
|
"error": f"{type(exc).__name__}: {exc}",
|
|
}
|
|
expected = sorted(deployment.operations)
|
|
return {
|
|
"ok": discovered == expected,
|
|
"endpoint": endpoint,
|
|
"expected_operations": expected,
|
|
"discovered_operations": discovered,
|
|
"discovery_only": True,
|
|
}
|
|
|
|
|
|
async def _run(args: argparse.Namespace) -> tuple[dict[str, Any], int]:
|
|
selected = list(DEPLOYMENTS) if args.all else [BY_SLUG[args.implementation]]
|
|
checks = []
|
|
python_supported = sys.version_info[:2] == (3, 12)
|
|
failed = not python_supported
|
|
for deployment in selected:
|
|
item: dict[str, Any] = {
|
|
"implementation": deployment.slug,
|
|
"contract": _skill_contract(deployment),
|
|
}
|
|
failed = failed or not item["contract"]["ok"]
|
|
if args.check_provider_env:
|
|
item["provider_environment"] = _provider_environment(deployment)
|
|
failed = failed or not item["provider_environment"]["ok"]
|
|
checks.append(item)
|
|
|
|
if args.live:
|
|
live_results = await asyncio.gather(
|
|
*(
|
|
_live_discovery(
|
|
deployment,
|
|
args.url if args.url else deployment.endpoint,
|
|
args.timeout,
|
|
)
|
|
for deployment in selected
|
|
)
|
|
)
|
|
for item, live_result in zip(checks, live_results, strict=True):
|
|
item["live"] = live_result
|
|
failed = failed or not live_result["ok"]
|
|
|
|
connect = None
|
|
if args.check_connect_prereqs:
|
|
connect = _connect_prerequisites()
|
|
failed = failed or not connect["ok"]
|
|
|
|
result = {
|
|
"ok": not failed,
|
|
"scope": "all" if args.all else args.implementation,
|
|
"python": {
|
|
"version": ".".join(str(part) for part in sys.version_info[:3]),
|
|
"supported_3_12": python_supported,
|
|
"virtual_environment_active": sys.prefix != sys.base_prefix,
|
|
},
|
|
"checks": checks,
|
|
"connect_prerequisites": connect,
|
|
"claims": {
|
|
"live_mode_invokes_models": False,
|
|
"live_mode_validates_discovery_only": bool(args.live),
|
|
"provider_env_mode_validates_artifact_contents_or_provenance": False,
|
|
"connect_prerequisite_mode_authenticates_to_platform": False,
|
|
},
|
|
}
|
|
return result, int(failed)
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(
|
|
description="Check documented remote-tool setup and optional live discovery."
|
|
)
|
|
target = parser.add_mutually_exclusive_group(required=True)
|
|
target.add_argument("--implementation", choices=sorted(BY_SLUG))
|
|
target.add_argument("--all", action="store_true")
|
|
parser.add_argument(
|
|
"--check-provider-env",
|
|
action="store_true",
|
|
help="require provider paths, credentials, and external runtimes",
|
|
)
|
|
parser.add_argument(
|
|
"--check-connect-prereqs",
|
|
action="store_true",
|
|
help="require the relay SDK and a service key without printing its value",
|
|
)
|
|
parser.add_argument(
|
|
"--live",
|
|
action="store_true",
|
|
help="verify exact MCP tool discovery without invoking operations",
|
|
)
|
|
parser.add_argument(
|
|
"--url",
|
|
help="override the MCP endpoint for one implementation",
|
|
)
|
|
parser.add_argument("--timeout", type=float, default=15.0)
|
|
parser.add_argument("--output", type=Path)
|
|
args = parser.parse_args()
|
|
if args.url and args.all:
|
|
parser.error("--url can only be used with one --implementation")
|
|
if args.url and not args.live:
|
|
parser.error("--url requires --live")
|
|
|
|
result, exit_code = asyncio.run(_run(args))
|
|
rendered = json.dumps(result, indent=2, sort_keys=True) + "\n"
|
|
if args.output:
|
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
args.output.write_text(rendered, encoding="utf-8")
|
|
print(
|
|
json.dumps(
|
|
{
|
|
"ok": result["ok"],
|
|
"output": str(args.output),
|
|
"scope": result["scope"],
|
|
},
|
|
sort_keys=True,
|
|
)
|
|
)
|
|
else:
|
|
print(rendered, end="")
|
|
raise SystemExit(exit_code)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|