Source code for tooluniverse.marrvel_tool
"""
MARRVEL tools for ToolUniverse — aggregated human gene & disease data.
MARRVEL (Model organism Aggregated Resources for Rare Variant ExpLoration, BCM)
aggregates human gene/disease annotation from many sources (OMIM, HGNC, Ensembl,
Entrez, UniProt, Pharos) behind one API. These tools expose the human gene-level
endpoints used in rare-disease / Mendelian variant triage.
API: http://api.marrvel.org/data (public, no authentication, JSON)
"""
import re
from typing import Any, Dict, List, Optional, Tuple
import requests
from .base_tool import BaseTool
from .tool_registry import register_tool
MARRVEL_BASE = "http://api.marrvel.org/data"
HUMAN_TAXON = "9606"
# Fix-R27A-3: MARRVEL indexes only *current* approved HGNC symbols. Asking it
# for a retired symbol or an alias returns HTTP 200 with an empty body -- `{}`
# from the gene endpoint and `null` from the omim endpoint (both confirmed
# live for 'RP20') -- which is byte-for-byte the same answer it gives for a
# symbol that does not exist at all. That collapses "you used the old name"
# into "no such gene", and old locus names (RP20 / LCA2 for RPE65) are exactly
# what turns up in the rare-disease triage workflows these tools advertise.
# HGNC's public keyless REST search resolves previous symbols and aliases to
# the current approved symbol, so an empty MARRVEL answer is re-checked there
# once and the lookup retried with the approved symbol.
HGNC_SEARCH_BASE = "https://rest.genenames.org/search"
# Deliberately short and independent of the tool's own timeout: this is a
# fallback on an already-empty answer and must not double worst-case latency.
HGNC_TIMEOUT = 8
# Only feed simple symbol-shaped tokens into the HGNC Solr query -- spaces,
# quotes or boolean operators from a caller would change the query's meaning.
_SYMBOL_SAFE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._@-]{0,30}$")
def _hgnc_lookup(symbol: str) -> Tuple[str, Optional[str], List[str]]:
"""Resolve a retired symbol/alias to its current approved HGNC symbol.
Returns ``(outcome, resolved_symbol, candidates)`` where *outcome* is one
of ``"resolved"`` (exactly one current symbol, different from *symbol*),
``"ambiguous"`` (the token is an alias of several genes -- never guess
which one the caller meant), ``"none"`` (HGNC knows no such prev/alias
symbol) or ``"unavailable"`` (HGNC could not be reached / parsed).
Never raises: any HGNC problem degrades to ``"unavailable"`` so a working
MARRVEL call is never turned into an error by this fallback.
"""
if not _SYMBOL_SAFE.match(symbol):
return "none", None, []
url = f"{HGNC_SEARCH_BASE}/prev_symbol:{symbol}+OR+alias_symbol:{symbol}"
try:
resp = requests.get(
url, headers={"Accept": "application/json"}, timeout=HGNC_TIMEOUT
)
if resp.status_code != 200:
return "unavailable", None, []
docs = (resp.json() or {}).get("response", {}).get("docs", []) or []
except (requests.exceptions.RequestException, ValueError, AttributeError):
return "unavailable", None, []
candidates: List[str] = []
for doc in docs:
if not isinstance(doc, dict):
continue
cand = (doc.get("symbol") or "").strip()
if cand and cand not in candidates:
candidates.append(cand)
if not candidates:
return "none", None, []
if len(candidates) > 1:
return "ambiguous", None, candidates
if candidates[0].upper() == symbol.upper():
# Same symbol back -- retrying would just repeat the empty answer.
return "none", None, candidates
return "resolved", candidates[0], candidates
def _not_found_note(symbol: str, outcome: str, candidates: List[str]) -> str:
"""Actionable note for an empty MARRVEL answer that stayed empty."""
base = (
f"No MARRVEL record for '{symbol}'. MARRVEL indexes only current "
f"approved HGNC symbols."
)
if outcome == "ambiguous":
return (
f"{base} '{symbol}' is a previous symbol or alias of several genes "
f"({', '.join(candidates)}); re-run with the intended one."
)
if outcome == "unavailable":
return (
f"{base} The HGNC previous-symbol/alias lookup used to check for a "
f"renamed gene was unavailable, so '{symbol}' could not be checked "
f"against retired symbols -- retry, or supply the current approved "
f"symbol."
)
return (
f"{base} HGNC's previous-symbol/alias search did not resolve '{symbol}' "
f"to a current approved symbol -- check the spelling or supply the "
f"current approved symbol."
)
def _substitution_note(symbol: str, resolved: str) -> str:
return (
f"'{symbol}' has no MARRVEL record because it is not a current approved "
f"HGNC symbol; HGNC resolves it to '{resolved}' (previous symbol or "
f"alias). The data below is for '{resolved}', not for '{symbol}' as "
f"typed."
)
def _resolve_symbol(arguments: Dict[str, Any]) -> str:
# Fix-R32B-4: unlike most other gene-input tools in this codebase
# (DGIdb, OpenTargets, ensembl_lookup_gene, ...), these tools only
# accepted the bare "symbol" param with no gene/gene_symbol alias --
# confirmed live that the natural-language guess {"gene_symbol":
# "PTPN22"} failed schema validation entirely.
return (
arguments.get("symbol")
or arguments.get("gene_symbol")
or arguments.get("gene")
or ""
).strip()
[docs]
@register_tool("MARRVELGeneTool")
class MARRVELGeneTool(BaseTool):
"""Aggregated identity/annotation for a human gene by symbol."""
[docs]
def __init__(self, tool_config: Dict[str, Any]):
super().__init__(tool_config)
self.timeout = tool_config.get("fields", {}).get("timeout", 30)
[docs]
def _fetch(
self, symbol: str
) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
"""Return ``(record_or_None, error_response_or_None)``.
A 404 and an HTTP 200 with an empty body are the same user-visible
situation ("MARRVEL has nothing under this symbol"), so both come back
as ``(None, None)`` and share one not-found path.
"""
url = f"{MARRVEL_BASE}/gene/taxonId/{HUMAN_TAXON}/symbol/{symbol}"
try:
resp = requests.get(
url, headers={"Accept": "application/json"}, timeout=self.timeout
)
if resp.status_code == 404:
return None, None
resp.raise_for_status()
rec = resp.json()
except requests.exceptions.Timeout:
return None, {
"status": "error",
"error": f"MARRVEL request timed out after {self.timeout}s",
}
except requests.exceptions.RequestException as e:
return None, {"status": "error", "error": f"MARRVEL request failed: {e}"}
except ValueError:
return None, {
"status": "error",
"error": "MARRVEL returned a non-JSON response",
}
if not isinstance(rec, dict) or not rec:
return None, None
return rec, None
[docs]
def run(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
symbol = _resolve_symbol(arguments)
if not symbol:
return {"status": "error", "error": "'symbol' (e.g. 'CFTR') is required"}
rec, err = self._fetch(symbol)
if err:
return err
resolved: Optional[str] = None
if rec is None:
# Fix-R27A-3: empty answer -- ask HGNC once whether this is a
# retired symbol/alias, and if so retry once with the current
# approved symbol. At most one resolution, no recursion.
outcome, candidate, candidates = _hgnc_lookup(symbol)
if outcome == "resolved" and candidate:
retried, retry_err = self._fetch(candidate)
if retried is not None and not retry_err:
rec, resolved = retried, candidate
if rec is None:
return {
"status": "success",
"data": {},
"metadata": {
"total_results": 0,
"query_symbol": symbol,
"note": _not_found_note(symbol, outcome, candidates),
},
}
xref = rec.get("xref", {}) or {}
metadata: Dict[str, Any] = {
"total_results": 1,
"query_symbol": symbol,
"source": "MARRVEL (aggregated)",
}
if resolved:
# Never silently answer about a different gene than the caller
# typed -- name both symbols in the metadata.
metadata["resolved_symbol"] = resolved
metadata["resolved_note"] = _substitution_note(symbol, resolved)
return {
"status": "success",
"data": {
"symbol": rec.get("symbol"),
"name": rec.get("name"),
"entrez_id": rec.get("entrezId"),
"hgnc_id": xref.get("hgncId"),
# Fix-R78A-1: MARRVEL's own /gene/taxonId/.../symbol/... endpoint
# returns a bogus small placeholder integer in xref.omimId (e.g.
# "1" for BRCA1/EGFR/APOE/MYH7/TP53, "6" for CFTR/PTEN) instead
# of the real 6-digit OMIM MIM number -- confirmed live across
# multiple genes, so this isn't a per-gene data gap. The correct
# gene_mim_number is only available from MARRVEL's dedicated OMIM
# endpoint, exposed here as MARRVEL_get_omim_phenotypes -- point
# callers there instead of surfacing this misleading value.
"ensembl_id": xref.get("ensemblId"),
"uniprot_id": rec.get("uniprotKBId"),
"chromosome": rec.get("chr"),
"location": rec.get("location"),
"type": rec.get("type"),
"aliases": rec.get("alias", []),
"prev_symbols": rec.get("prevSymbols", []),
"summary": rec.get("entrezSummary"),
},
"metadata": metadata,
}
[docs]
@register_tool("MARRVELOmimTool")
class MARRVELOmimTool(BaseTool):
"""OMIM phenotype/disease associations for a human gene by symbol."""
[docs]
def __init__(self, tool_config: Dict[str, Any]):
super().__init__(tool_config)
self.timeout = tool_config.get("fields", {}).get("timeout", 30)
[docs]
def _fetch(
self, symbol: str
) -> Tuple[Optional[List[Dict[str, Any]]], Optional[Dict[str, Any]]]:
"""Return ``(phenotype_rows_or_None, error_response_or_None)``.
This endpoint answers an unknown/retired symbol with HTTP 200 and a
body of literal ``null`` (confirmed live for 'RP20'), i.e. the same
user-visible situation as the gene endpoint's ``{}`` and as a 404 --
all three come back as ``(None, None)``.
"""
url = f"{MARRVEL_BASE}/omim/gene/symbol/{symbol}"
try:
resp = requests.get(
url, headers={"Accept": "application/json"}, timeout=self.timeout
)
if resp.status_code == 404:
return None, None
resp.raise_for_status()
payload = resp.json()
except requests.exceptions.Timeout:
return None, {
"status": "error",
"error": f"MARRVEL request timed out after {self.timeout}s",
}
except requests.exceptions.RequestException as e:
return None, {"status": "error", "error": f"MARRVEL request failed: {e}"}
except ValueError:
return None, {
"status": "error",
"error": "MARRVEL returned a non-JSON response",
}
if not payload:
return None, None
phenos = []
if isinstance(payload, dict):
phenos = payload.get("phenotypes", []) or []
elif isinstance(payload, list):
phenos = payload
return [
{
"gene_mim_number": p.get("mimNumber"),
"phenotype": p.get("phenotype"),
"phenotype_mim_number": p.get("phenotypeMimNumber"),
"inheritance": p.get("phenotypeInheritance"),
"phenotypic_series": p.get("phenotypicSeriesNumber"),
}
for p in phenos
if isinstance(p, dict)
], None
[docs]
def run(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
symbol = _resolve_symbol(arguments)
if not symbol:
return {"status": "error", "error": "'symbol' (e.g. 'CFTR') is required"}
results, err = self._fetch(symbol)
if err:
return err
resolved: Optional[str] = None
if results is None:
# Fix-R27A-3: same retired-symbol/alias resolution as the gene
# tool -- shared helper, one attempt, no recursion.
outcome, candidate, candidates = _hgnc_lookup(symbol)
if outcome == "resolved" and candidate:
retried, retry_err = self._fetch(candidate)
if retried is not None and not retry_err:
results, resolved = retried, candidate
if results is None:
return {
"status": "success",
"data": [],
"metadata": {
"total_results": 0,
"query_symbol": symbol,
"note": _not_found_note(symbol, outcome, candidates),
},
}
metadata: Dict[str, Any] = {
"total_results": len(results),
"query_symbol": symbol,
"source": "MARRVEL / OMIM",
}
if resolved:
metadata["resolved_symbol"] = resolved
metadata["resolved_note"] = _substitution_note(symbol, resolved)
return {
"status": "success",
"data": results,
"metadata": metadata,
}