Source code for tooluniverse.oncotree_tool
"""OncoTree API tool for ToolUniverse.
OncoTree is a cancer type ontology developed at Memorial Sloan Kettering
Cancer Center (MSK). It provides a hierarchical classification of cancers
with standardized codes, cross-references to UMLS and NCI thesaurus, and
tissue-based organization.
API: https://oncotree.mskcc.org/api/
No authentication required. Free public access.
"""
from typing import Dict, Any, Optional
import requests
from .base_tool import BaseTool
from .tool_registry import register_tool
ONCOTREE_BASE_URL = "https://oncotree.mskcc.org/api"
[docs]
class OncoTreeBaseTool(BaseTool):
"""Base class for OncoTree API tools."""
[docs]
def __init__(self, tool_config: Dict[str, Any]):
super().__init__(tool_config)
self.timeout = tool_config.get("timeout", 30)
[docs]
def _get(self, endpoint: str, params: Optional[Dict] = None) -> Any:
url = f"{ONCOTREE_BASE_URL}{endpoint}"
resp = requests.get(url, params=params, timeout=self.timeout)
resp.raise_for_status()
return resp.json()
[docs]
@staticmethod
def _string_argument(
arguments: Dict[str, Any], name: str, default: Optional[str] = None
) -> tuple[Optional[str], Optional[Dict[str, Any]]]:
value = arguments.get(name, default)
if not isinstance(value, str) or not value.strip():
return None, {
"status": "error",
"error": f"{name} must be a non-empty string",
"retryable": False,
}
return value.strip(), None
[docs]
@staticmethod
def _normalize_tumor_type(record: Any) -> Dict[str, Any]:
if not isinstance(record, dict) or not isinstance(record.get("code"), str):
raise ValueError("OncoTree returned a malformed tumor-type record")
structured_fields = {
"externalReferences": dict,
"history": list,
"children": (dict, list),
"precursors": list,
"revocations": list,
}
for field, expected_type in structured_fields.items():
default = {} if field in ("externalReferences", "children") else []
if not isinstance(record.get(field, default), expected_type):
raise ValueError(
f"OncoTree returned malformed {field} for code {record['code']}"
)
return {
"code": record.get("code"),
"name": record.get("name"),
"main_type": record.get("mainType"),
"tissue": record.get("tissue"),
"color": record.get("color"),
"parent": record.get("parent"),
"level": record.get("level"),
"history": record.get("history", []),
"children": record.get("children", {}),
"precursors": record.get("precursors", []),
"revocations": record.get("revocations", []),
"external_references": record.get("externalReferences", {}),
}
[docs]
def _handle_request(self, fn, *args, **kwargs) -> Dict[str, Any]:
try:
return fn(*args, **kwargs)
except requests.exceptions.Timeout:
return {
"status": "error",
"error": "OncoTree API request timed out",
"retryable": True,
}
except requests.exceptions.ConnectionError:
return {
"status": "error",
"error": "Failed to connect to OncoTree API",
"retryable": True,
}
except requests.exceptions.HTTPError as e:
code = e.response.status_code if e.response is not None else "unknown"
return {
"status": "error",
"error": f"OncoTree API HTTP {code}",
"retryable": code in (429, 502, 503),
}
except Exception as e:
return {
"status": "error",
"error": f"OncoTree error: {e}",
"retryable": False,
}
[docs]
@register_tool("OncoTreeSearchTool")
class OncoTreeSearchTool(OncoTreeBaseTool):
"""Search OncoTree cancer types by name, code, main type, or tissue."""
[docs]
def run(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
return self._handle_request(self._run, arguments)
[docs]
def _run(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
query, error = self._string_argument(arguments, "query")
if error:
return error
field, error = self._string_argument(arguments, "field", "name")
if error:
return error
version, error = self._string_argument(
arguments, "version", "oncotree_latest_stable"
)
if error:
return error
assert query is not None and field is not None and version is not None
field = field.lower()
exact_match = arguments.get("exact_match", False)
if not isinstance(exact_match, bool):
return {
"status": "error",
"error": "exact_match must be a boolean",
"retryable": False,
}
valid_fields = ("name", "code", "main_type", "tissue")
if field not in valid_fields:
return {
"status": "error",
"error": f"field must be one of: {', '.join(valid_fields)}",
"retryable": False,
}
if field in ("tissue", "main_type"):
all_types = self._get("/tumorTypes", {"version": version})
if not isinstance(all_types, list):
return {
"status": "error",
"error": "Unexpected response from OncoTree API",
"retryable": False,
}
attr = "tissue" if field == "tissue" else "mainType"
q_lower = query.lower()
if exact_match:
raw = [t for t in all_types if (t.get(attr) or "").lower() == q_lower]
else:
raw = [t for t in all_types if q_lower in (t.get(attr) or "").lower()]
else:
params = {"exactMatch": str(exact_match).lower(), "version": version}
try:
raw = self._get(
f"/tumorTypes/search/{field}/{requests.utils.quote(query)}", params
)
except requests.exceptions.HTTPError as exc:
if exc.response is not None and exc.response.status_code == 404:
raw = []
else:
raise
if not isinstance(raw, list):
return {
"status": "error",
"error": "Unexpected response from OncoTree API",
"retryable": False,
}
items = [self._normalize_tumor_type(t) for t in raw]
if exact_match and field in ("name", "code"):
key = "name" if field == "name" else "code"
mismatches = [
item.get(key)
for item in items
if (item.get(key) or "").lower() != query.lower()
]
if mismatches:
return {
"status": "error",
"error": "OncoTree returned non-matching records for an exact search",
"mismatched_values": mismatches,
"retryable": False,
}
return {
"status": "success",
"data": items,
"metadata": {
"query": query,
"field": field,
"exact_match": exact_match,
"count": len(items),
"version": version,
"source_records_complete": True,
"preserved_fields": [
"code",
"name",
"mainType",
"tissue",
"color",
"parent",
"level",
"history",
"children",
"precursors",
"revocations",
"externalReferences",
],
},
}
[docs]
@register_tool("OncoTreeGetTypeTool")
class OncoTreeGetTypeTool(OncoTreeBaseTool):
"""Get a specific OncoTree cancer type by its unique code."""
[docs]
def run(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
return self._handle_request(self._run, arguments)
[docs]
def _run(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
code, error = self._string_argument(arguments, "code")
if error:
return error
version, error = self._string_argument(
arguments, "version", "oncotree_latest_stable"
)
if error:
return error
assert code is not None and version is not None
code = code.upper()
try:
results = self._get(
f"/tumorTypes/search/code/{requests.utils.quote(code)}",
{"exactMatch": "true", "version": version},
)
except requests.exceptions.HTTPError as exc:
if exc.response is not None and exc.response.status_code == 404:
results = []
else:
raise
if not isinstance(results, list) or not results:
return {
"status": "error",
"error": f"OncoTree code '{code}' not found",
"retryable": False,
}
if len(results) != 1 or not isinstance(results[0], dict):
return {
"status": "error",
"error": f"OncoTree exact code lookup for '{code}' was not unique",
"returned": len(results),
"retryable": False,
}
if (results[0].get("code") or "").upper() != code:
return {
"status": "error",
"error": f"OncoTree returned a different code for exact lookup '{code}'",
"returned_code": results[0].get("code"),
"retryable": False,
}
item = self._normalize_tumor_type(results[0])
return {
"status": "success",
"data": item,
"metadata": {
"source": "OncoTree MSK",
"version": version,
"exact_code_verified": True,
"source_record_complete": True,
},
}
[docs]
@register_tool("OncoTreeListTissuesTool")
class OncoTreeListTissuesTool(OncoTreeBaseTool):
"""List all top-level tissue categories in the OncoTree hierarchy."""
[docs]
def run(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
return self._handle_request(self._run, arguments)
[docs]
def _run(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
version, error = self._string_argument(
arguments, "version", "oncotree_latest_stable"
)
if error:
return error
assert version is not None
all_types = self._get("/tumorTypes", {"version": version})
if not isinstance(all_types, list):
return {
"status": "error",
"error": "Unexpected response from OncoTree API",
"retryable": False,
}
normalized = [self._normalize_tumor_type(t) for t in all_types]
tissues = sorted({t["tissue"] for t in normalized if t["tissue"]})
return {
"status": "success",
"data": tissues,
"metadata": {
"count": len(tissues),
"version": version,
"tumor_types_scanned": len(normalized),
"complete_source_scan": True,
},
}