diff --git a/README.md b/README.md index 44cd4d1..d3c68d6 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,7 @@ biocontext cache clear ## 🔌 Available MCP Tools -When connected via MCP, BioContext exposes 8 production-ready biological tools: +When connected via MCP, BioContext exposes 10 production-ready biological & clinical tools: | MCP Tool | Signature & Parameters | Description | | :--- | :--- | :--- | @@ -148,6 +148,8 @@ When connected via MCP, BioContext exposes 8 production-ready biological tools: | `get_go_term` | `go_id: str` | Inspects a specific Gene Ontology term definition and aspect. | | `get_pathways` | `query: str`, `taxon_id: int = 9606`, `species: str = "Homo sapiens"`, `limit: int = 10` | Maps genes/proteins to biological pathways via Reactome. | | `get_pathway_details`| `st_id: str` | Retrieves descriptive summary and metadata for a Reactome pathway. | +| `resolve_disease` | `query: str`, `limit: int = 5` | Resolves disease names, synonyms, or IDs to canonical MONDO Disease Ontology entities. | +| `get_target_diseases` | `gene: str`, `limit: int = 10` | Retrieves evidence-backed therapeutic target-disease associations from Open Targets Platform. | | `get_mouse_gene` | `mgi_id: str` | Direct lookup of mouse gene models from MGI. | --- diff --git a/docs/API.md b/docs/API.md index 2d3948c..64640c0 100644 --- a/docs/API.md +++ b/docs/API.md @@ -124,6 +124,13 @@ async def main(): pathways = await resolver.get_pathways("TP53", limit=5) print("Reactome Pathways:", [p.name for p in pathways.pathways]) + # 5. Disease Ontology (MONDO) & Open Targets + diseases = await resolver.resolve_disease("Li-Fraumeni", limit=2) + print("MONDO Disease:", diseases[0].mondo_id, diseases[0].name) + + target_assocs = await resolver.get_target_diseases("TP53", limit=3) + print("Associated Diseases:", [(a.disease_name, a.score) for a in target_assocs.associations]) + asyncio.run(main()) ``` @@ -139,6 +146,9 @@ asyncio.run(main()) | `go` | `biocontext go ` | Inspect GO term metadata. | | `pathway` | `biocontext pathway [-l INT]` | Retrieve Reactome pathways. | | `pathway-info` | `biocontext pathway-info ` | Retrieve Reactome pathway summation. | +| `disease` | `biocontext disease [-l INT]` | Resolve disease name or MONDO identifier. | +| `targets` | `biocontext targets [-l INT]` | Retrieve Open Targets evidence-backed disease associations. | | `mouse` | `biocontext mouse ` | Lookup mouse gene model via MGI. | | `cache` | `biocontext cache [stats\|clear]` | Inspect or clear SQLite cache. | | `serve` | `biocontext serve` | Start stdio MCP server for AI clients. | + diff --git a/pyproject.toml b/pyproject.toml index c272827..b306666 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "biocontext-mcp" -version = "0.5.1" +version = "0.6.0" description = "Authoritative Biological Entity Resolution & Contextual Intelligence Framework" readme = "README.md" authors = [ diff --git a/src/biocontext/adapters.py b/src/biocontext/adapters.py index c38a7c4..8b90805 100644 --- a/src/biocontext/adapters.py +++ b/src/biocontext/adapters.py @@ -11,6 +11,7 @@ from biocontext.config import ClientConfig, RateLimitConfig from biocontext.logging import get_logger from biocontext.schemas import ( + DiseaseEntity, ExonEntity, FunctionalAnnotation, GOAnnotation, @@ -20,6 +21,8 @@ PathwayContext, PathwayEntity, ProteinEntity, + TargetAssociationContext, + TargetDiseaseAssociation, TranscriptEntity, ) @@ -1211,6 +1214,255 @@ async def fetch_pathway_details(self, st_id: str) -> Optional[Dict[str, Any]]: return details +class MondoAdapter(BaseBioAdapter): + """Adapter for MONDO Disease Ontology via EBI OLS4 API. + Primary authority for canonical disease identifiers (MONDO:xxxxxxx), preferred names, + synonyms, and cross-references (OMIM, Orphanet, DOID, UMLS). + """ + + BASE_URL = "https://www.ebi.ac.uk/ols4/api" + + def __init__(self, cache: Optional[SQLiteCache] = None, email: Optional[str] = None): + super().__init__(name="MONDO", cache=cache) + self.email = ClientConfig.get_email(email) + self.headers = ClientConfig.get_headers(self.email) + self.rate_limiter = AsyncRateLimiter(requests_per_second=RateLimitConfig.MONDO_RPS) + + async def resolve_gene(self, query: str, taxon_id: int = 9606) -> Optional[Dict[str, Any]]: + """MONDO resolves disease entities rather than genes.""" + return None + + async def fetch_by_id(self, mondo_id: str) -> Optional[DiseaseEntity]: + """Fetch canonical disease entity by MONDO ID (e.g. 'MONDO:0018875' or 'MONDO_0018875').""" + clean_id = mondo_id.strip() + if clean_id.startswith("MONDO:"): + iri_id = clean_id.replace(":", "_") + elif clean_id.startswith("MONDO_"): + iri_id = clean_id + clean_id = clean_id.replace("_", ":") + else: + return None + + cache_key = f"mondo:id:{clean_id.upper()}" + cached = self.cache.get("mondo", cache_key) + if cached: + return DiseaseEntity(**cached) + + iri = f"http://purl.obolibrary.org/obo/{iri_id}" + url = f"{self.BASE_URL}/ontologies/mondo/terms" + params = {"iri": iri} + + await self.rate_limiter.acquire() + try: + async with httpx.AsyncClient(timeout=RateLimitConfig.MONDO_TIMEOUT_SEC) as client: + resp = await client.get(url, params=params, headers=self.headers) + if resp.status_code != 200: + return None + data = resp.json() + except Exception as e: + logger.warning("Mondo term lookup exception | id=%s error=%s", clean_id, str(e)) + return None + + terms = data.get("_embedded", {}).get("terms", []) + if not terms: + return None + + term = terms[0] + name = term.get("label") or clean_id + descriptions = term.get("description", []) + desc = descriptions[0] if descriptions else None + synonyms = term.get("synonyms") or [] + + # Cross references / dbxrefs + xrefs = [] + annotation = term.get("annotation", {}) + dbxrefs = annotation.get("database_cross_reference", []) + if isinstance(dbxrefs, list): + xrefs = [str(x) for x in dbxrefs] + + disease = DiseaseEntity( + mondo_id=clean_id, + name=name, + description=desc, + synonyms=synonyms, + cross_references=xrefs + ) + self.cache.set("mondo", cache_key, disease.model_dump()) + return disease + + async def search_disease(self, query: str, limit: int = 5) -> List[DiseaseEntity]: + """Search diseases by name, synonym, or keyword within MONDO.""" + clean_query = query.strip() + if not clean_query: + return [] + + cache_key = f"mondo:search:{clean_query.lower()}:{limit}" + cached = self.cache.get("mondo", cache_key) + if cached and "items" in cached: + return [DiseaseEntity(**item) for item in cached["items"]] + + url = f"{self.BASE_URL}/search" + params = { + "q": clean_query, + "ontology": "mondo", + "rows": limit, + "queryFields": "label,synonym" + } + + await self.rate_limiter.acquire() + try: + async with httpx.AsyncClient(timeout=RateLimitConfig.MONDO_TIMEOUT_SEC) as client: + resp = await client.get(url, params=params, headers=self.headers) + if resp.status_code != 200: + return [] + data = resp.json() + except Exception as e: + logger.warning("Mondo search exception | query=%s error=%s", clean_query, str(e)) + return [] + + docs = data.get("response", {}).get("docs", []) + results: List[DiseaseEntity] = [] + for doc in docs: + short_form = doc.get("short_form", "") + if not short_form.startswith("MONDO_") and not short_form.startswith("MONDO:"): + continue + mondo_id = short_form.replace("_", ":") + name = doc.get("label") or mondo_id + descriptions = doc.get("description", []) + desc = descriptions[0] if descriptions else None + synonyms = doc.get("synonym", []) or [] + + results.append(DiseaseEntity( + mondo_id=mondo_id, + name=name, + description=desc, + synonyms=synonyms, + cross_references=[] + )) + + self.cache.set("mondo", cache_key, {"items": [r.model_dump() for r in results]}) + return results + + +class OpenTargetsAdapter(BaseBioAdapter): + """Adapter for Open Targets Platform GraphQL API. + Primary authority for target-disease association scores, clinical pipeline evidence, + and genetic target tractability. + """ + + GRAPHQL_URL = "https://api.platform.opentargets.org/api/v4/graphql" + + def __init__(self, cache: Optional[SQLiteCache] = None, email: Optional[str] = None): + super().__init__(name="OpenTargets", cache=cache) + self.email = ClientConfig.get_email(email) + self.headers = ClientConfig.get_headers(self.email) + self.headers["Content-Type"] = "application/json" + self.rate_limiter = AsyncRateLimiter(requests_per_second=RateLimitConfig.OPENTARGETS_RPS) + + async def resolve_gene(self, query: str, taxon_id: int = 9606) -> Optional[Dict[str, Any]]: + """OpenTargets is queried via resolved Ensembl ID rather than as a primary gene symbol resolver.""" + return None + + async def fetch_target_diseases( + self, + ensembl_gene_id: str, + symbol: Optional[str] = None, + limit: int = 10 + ) -> TargetAssociationContext: + """Fetch top disease associations for a target gene by Ensembl Gene ID.""" + clean_id = ensembl_gene_id.strip().upper() + cache_key = f"opentargets:target:{clean_id}:{limit}" + cached = self.cache.get("opentargets", cache_key) + if cached: + return TargetAssociationContext(**cached) + + query = """ + query TargetDiseases($ensemblId: String!, $size: Int!) { + target(ensemblId: $ensemblId) { + id + approvedSymbol + approvedName + associatedDiseases(page: {size: $size, index: 0}) { + count + rows { + score + datatypeScores { + id + score + } + disease { + id + name + } + } + } + } + } + """ + + payload = { + "query": query, + "variables": { + "ensemblId": clean_id, + "size": limit + } + } + + await self.rate_limiter.acquire() + try: + async with httpx.AsyncClient(timeout=RateLimitConfig.OPENTARGETS_TIMEOUT_SEC) as client: + resp = await client.post(self.GRAPHQL_URL, json=payload, headers=self.headers) + if resp.status_code != 200: + logger.warning("OpenTargets GraphQL error | status=%d body=%s", resp.status_code, resp.text[:200]) + return TargetAssociationContext(query=clean_id, ensembl_gene_id=clean_id, symbol=symbol) + res_data = resp.json() + except Exception as e: + logger.warning("OpenTargets request exception | target=%s error=%s", clean_id, str(e)) + return TargetAssociationContext(query=clean_id, ensembl_gene_id=clean_id, symbol=symbol) + + target_data = res_data.get("data", {}).get("target") + if not target_data: + return TargetAssociationContext(query=clean_id, ensembl_gene_id=clean_id, symbol=symbol) + + approved_symbol = target_data.get("approvedSymbol") or symbol + assoc_block = target_data.get("associatedDiseases", {}) + total_count = assoc_block.get("count", 0) + rows = assoc_block.get("rows", []) + + associations: List[TargetDiseaseAssociation] = [] + for r in rows: + disease_info = r.get("disease", {}) + d_id = disease_info.get("id", "") + d_name = disease_info.get("name", d_id) + score = float(r.get("score", 0.0)) + + dt_scores_dict: Dict[str, float] = {} + for dt in r.get("datatypeScores", []): + dt_id = dt.get("id") + dt_val = dt.get("score") + if dt_id and dt_val is not None: + dt_scores_dict[dt_id] = float(dt_val) + + associations.append(TargetDiseaseAssociation( + disease_id=d_id.replace("_", ":") if d_id.startswith("MONDO_") else d_id, + disease_name=d_name, + score=round(score, 4), + datatype_scores=dt_scores_dict if dt_scores_dict else None + )) + + context = TargetAssociationContext( + query=clean_id, + ensembl_gene_id=clean_id, + symbol=approved_symbol, + total_associations=total_count, + associations=associations + ) + + self.cache.set("opentargets", cache_key, context.model_dump()) + return context + + + diff --git a/src/biocontext/cli.py b/src/biocontext/cli.py index 34fbd6c..4517a56 100644 --- a/src/biocontext/cli.py +++ b/src/biocontext/cli.py @@ -209,6 +209,19 @@ async def run_cli_async(args: argparse.Namespace) -> int: print(json.dumps(details, indent=2)) return 0 + elif args.command == "disease": + diseases = await resolver.resolve_disease(query=args.query, limit=args.limit) + print(json.dumps([d.model_dump() for d in diseases], indent=2)) + return 0 + + elif args.command == "targets": + context = await resolver.get_target_diseases(gene_query=args.gene, limit=args.limit) + if not context: + print(json.dumps({"status": "not_found", "gene": args.gene}, indent=2)) + return 1 + print(context.model_dump_json(indent=2)) + return 0 + elif args.command == "cache": cache = resolver.cache if args.cache_action == "clear": @@ -243,7 +256,7 @@ def main(): sys.exit(pytest.main(["tests/", "-v"])) elif args.command in ( "resolve", "batch", "protein", "transcripts", "ortholog", "mouse", - "annotate", "go", "pathway", "pathway-info", "cache" + "annotate", "go", "pathway", "pathway-info", "disease", "targets", "cache" ): sys.exit(asyncio.run(run_cli_async(args))) else: diff --git a/src/biocontext/config.py b/src/biocontext/config.py index 13dd261..ec986b0 100644 --- a/src/biocontext/config.py +++ b/src/biocontext/config.py @@ -38,6 +38,11 @@ class RateLimitConfig: QUICKGO_TIMEOUT_SEC: float = 15.0 REACTOME_RPS: float = 5.0 REACTOME_TIMEOUT_SEC: float = 15.0 + MONDO_RPS: float = 10.0 + MONDO_TIMEOUT_SEC: float = 15.0 + OPENTARGETS_RPS: float = 10.0 + OPENTARGETS_TIMEOUT_SEC: float = 15.0 + @@ -158,6 +163,22 @@ def get_headers(cls, email: Optional[str] = None) -> Dict[str, str]: {"flags": ["st_id"], "help": "Reactome stable ID (e.g. R-HSA-5357801)"} ] }, + { + "name": "disease", + "help": "Resolve disease name, synonym, or ID against MONDO Disease Ontology", + "arguments": [ + {"flags": ["query"], "help": "Disease name (e.g. 'Li-Fraumeni syndrome') or MONDO ID ('MONDO:0018875')"}, + {"flags": ["--limit"], "type": int, "default": 5, "help": "Maximum matches to return (default: 5)"} + ] + }, + { + "name": "targets", + "help": "Fetch evidence-backed target-disease associations from Open Targets Platform", + "arguments": [ + {"flags": ["gene"], "help": "Gene symbol (e.g. TP53) or Ensembl Gene ID"}, + {"flags": ["--limit"], "type": int, "default": 10, "help": "Maximum associations to return (default: 10)"} + ] + }, { diff --git a/src/biocontext/resolver.py b/src/biocontext/resolver.py index a6758a3..b976ffa 100644 --- a/src/biocontext/resolver.py +++ b/src/biocontext/resolver.py @@ -1,10 +1,15 @@ -from typing import List, Optional +import asyncio +import time +from typing import Any, Dict, List, Optional + from biocontext.adapters import ( EnsemblAdapter, GeneOntologyAdapter, HGNCAdapter, MGIAdapter, + MondoAdapter, NCBIAdapter, + OpenTargetsAdapter, ReactomeAdapter, UniProtAdapter, ) @@ -13,6 +18,7 @@ from biocontext.logging import get_logger from biocontext.schemas import ( BatchResolutionSummary, + DiseaseEntity, FunctionalAnnotation, GOAnnotation, MatchReason, @@ -20,6 +26,8 @@ PathwayEntity, ResolutionContext, ResolutionResult, + TargetAssociationContext, + TargetDiseaseAssociation, ) logger = get_logger("resolver") @@ -43,6 +51,9 @@ def __init__( self.mgi = MGIAdapter(cache=self.cache, email=email) self.go = GeneOntologyAdapter(cache=self.cache, email=email) self.reactome = ReactomeAdapter(cache=self.cache, email=email) + self.mondo = MondoAdapter(cache=self.cache, email=email) + self.opentargets = OpenTargetsAdapter(cache=self.cache, email=email) + @@ -521,3 +532,45 @@ async def _bounded_resolve(query: str) -> ResolutionResult: results=results ) + async def resolve_disease(self, query: str, limit: int = 5) -> List[DiseaseEntity]: + """Resolve a disease name, synonym, or keyword against MONDO Disease Ontology.""" + clean_q = query.strip() + if not clean_q: + return [] + + # If query is direct MONDO identifier e.g. MONDO:0018875 + if clean_q.upper().startswith("MONDO:") or clean_q.upper().startswith("MONDO_"): + entity = await self.mondo.fetch_by_id(clean_q) + return [entity] if entity else [] + + return await self.mondo.search_disease(clean_q, limit=limit) + + async def get_target_diseases(self, gene_query: str, limit: int = 10) -> Optional[TargetAssociationContext]: + """Retrieve evidence-backed disease associations for a target gene from Open Targets.""" + clean_q = gene_query.strip() + if not clean_q: + return None + + ensembl_id = None + symbol = None + + if clean_q.upper().startswith("ENSG"): + ensembl_id = clean_q.upper() + else: + # Resolve gene entity to acquire authoritative Ensembl Gene ID + res = await self.resolve(clean_q) + if res and res.resolved_entity: + ensembl_id = res.resolved_entity.ensembl_gene_id + symbol = res.resolved_entity.symbol + + if not ensembl_id: + logger.warning("Could not map gene query '%s' to an Ensembl Gene ID for Open Targets", clean_q) + return None + + return await self.opentargets.fetch_target_diseases( + ensembl_gene_id=ensembl_id, + symbol=symbol, + limit=limit + ) + + diff --git a/src/biocontext/schemas.py b/src/biocontext/schemas.py index 94ca36d..59d40e8 100644 --- a/src/biocontext/schemas.py +++ b/src/biocontext/schemas.py @@ -159,3 +159,30 @@ class BatchResolutionSummary(BaseModel): success_rate: float = Field(..., ge=0.0, le=1.0, description="Proportion of successfully resolved queries") execution_time_seconds: float = Field(..., description="Elapsed wall-clock processing time") results: List[ResolutionResult] = Field(default_factory=list, description="List of resolution results for each query") + + +class DiseaseEntity(BaseModel): + """Standardized representation of a disease entity based on MONDO and cross-references.""" + mondo_id: str = Field(..., description="Canonical MONDO disease identifier (e.g. 'MONDO:0018875')") + name: str = Field(..., description="Standard disease preferred label / name (e.g. 'Li-Fraumeni syndrome')") + description: Optional[str] = Field(None, description="Clinical definition or description of the disease") + synonyms: List[str] = Field(default_factory=list, description="Alternative names, acronyms, and synonyms") + cross_references: List[str] = Field(default_factory=list, description="External cross-references (OMIM, DOID, Orphanet, UMLS, MeSH)") + + +class TargetDiseaseAssociation(BaseModel): + """Evidence-backed association between a therapeutic target (gene) and a disease from Open Targets.""" + disease_id: str = Field(..., description="Disease identifier (e.g. MONDO or EFO ID)") + disease_name: str = Field(..., description="Preferred name of the disease") + score: float = Field(..., ge=0.0, le=1.0, description="Overall aggregate association evidence score (0.0 - 1.0)") + datatype_scores: Optional[Dict[str, float]] = Field(default=None, description="Breakdown scores by evidence data type (genetic, somatic, literature, animal)") + + +class TargetAssociationContext(BaseModel): + """Profile of diseases associated with a target gene.""" + query: str = Field(..., description="Input gene symbol or target identifier") + ensembl_gene_id: Optional[str] = Field(None, description="Ensembl gene ID used for Open Targets association") + symbol: Optional[str] = Field(None, description="Approved gene symbol") + total_associations: int = Field(0, description="Total number of associated diseases found") + associations: List[TargetDiseaseAssociation] = Field(default_factory=list, description="Top ranked disease associations") + diff --git a/src/biocontext/server.py b/src/biocontext/server.py index cf6d6dd..7201124 100644 --- a/src/biocontext/server.py +++ b/src/biocontext/server.py @@ -239,6 +239,40 @@ async def get_pathway_details(st_id: str) -> str: return json.dumps(details, indent=2) +@mcp.tool() +async def resolve_disease(query: str, limit: int = 5) -> str: + """Resolve a disease name, synonym, or MONDO identifier against the MONDO Disease Ontology. + + Args: + query: Disease name (e.g. 'Li-Fraumeni syndrome', 'Breast cancer'), synonym, or ID ('MONDO:0018875'). + limit: Maximum number of matches to return (default: 5). + + Returns: + JSON string containing the list of resolved disease entities with MONDO IDs, definitions, and cross-references. + """ + diseases = await resolver.resolve_disease(query=query, limit=limit) + import json + return json.dumps([d.model_dump() for d in diseases], indent=2) + + +@mcp.tool() +async def get_target_diseases(gene: str, limit: int = 10) -> str: + """Fetch evidence-backed therapeutic target-disease associations from Open Targets Platform. + + Args: + gene: Gene symbol (e.g. 'TP53', 'BRAF', 'EGFR') or Ensembl Gene ID ('ENSG00000141510'). + limit: Maximum number of top disease associations to return (default: 10). + + Returns: + JSON string containing associated diseases, evidence scores (0.0-1.0), and datatype evidence breakdowns. + """ + context = await resolver.get_target_diseases(gene_query=gene, limit=limit) + if not context: + return '{"status": "not_found", "gene": "%s"}' % gene + + return context.model_dump_json(indent=2) + + def main(): """Run MCP server over stdio.""" mcp.run(transport="stdio") diff --git a/tests/test_disease_adapter.py b/tests/test_disease_adapter.py new file mode 100644 index 0000000..02f31a4 --- /dev/null +++ b/tests/test_disease_adapter.py @@ -0,0 +1,111 @@ +"""Tests for MONDO Disease Ontology and Open Targets Platform Adapters.""" + +import json +import pytest +from biocontext.adapters import MondoAdapter, OpenTargetsAdapter +from biocontext.base import SQLiteCache +from biocontext.resolver import EntityResolver +from biocontext.schemas import DiseaseEntity, TargetAssociationContext +from biocontext.server import get_target_diseases, resolve_disease + + +@pytest.fixture +def temp_cache(tmp_path): + db_file = tmp_path / "test_disease_cache.db" + return SQLiteCache(db_path=str(db_file)) + + +@pytest.fixture +def mondo_adapter(temp_cache): + return MondoAdapter(cache=temp_cache) + + +@pytest.fixture +def opentargets_adapter(temp_cache): + return OpenTargetsAdapter(cache=temp_cache) + + +@pytest.fixture +def resolver(temp_cache): + return EntityResolver(cache=temp_cache) + + +@pytest.mark.asyncio +async def test_fetch_mondo_by_id(mondo_adapter): + """Test fetching Li-Fraumeni syndrome by MONDO ID (MONDO:0018875).""" + disease = await mondo_adapter.fetch_by_id("MONDO:0018875") + assert disease is not None + assert isinstance(disease, DiseaseEntity) + assert disease.mondo_id == "MONDO:0018875" + assert "li-fraumeni" in disease.name.lower() + assert len(disease.cross_references) > 0 + + +@pytest.mark.asyncio +async def test_search_mondo_disease(mondo_adapter): + """Test searching MONDO disease ontology by keyword.""" + results = await mondo_adapter.search_disease("Li-Fraumeni", limit=3) + assert len(results) > 0 + first = results[0] + assert isinstance(first, DiseaseEntity) + assert first.mondo_id.startswith("MONDO:") + assert "li-fraumeni" in first.name.lower() + + +@pytest.mark.asyncio +async def test_opentargets_fetch_target_diseases_tp53(opentargets_adapter): + """Test fetching Open Targets associations for TP53 (ENSG00000141510).""" + context = await opentargets_adapter.fetch_target_diseases( + ensembl_gene_id="ENSG00000141510", + symbol="TP53", + limit=5 + ) + assert context is not None + assert isinstance(context, TargetAssociationContext) + assert context.ensembl_gene_id == "ENSG00000141510" + assert context.total_associations > 100 + assert len(context.associations) > 0 + + top_assoc = context.associations[0] + assert top_assoc.score > 0.0 + assert top_assoc.disease_id is not None + assert top_assoc.disease_name is not None + + +@pytest.mark.asyncio +async def test_resolver_resolve_disease(resolver): + """Test EntityResolver.resolve_disease method.""" + diseases = await resolver.resolve_disease("Li-Fraumeni", limit=2) + assert len(diseases) > 0 + assert diseases[0].mondo_id.startswith("MONDO:") + + +@pytest.mark.asyncio +async def test_resolver_get_target_diseases(resolver): + """Test EntityResolver.get_target_diseases for gene symbol TP53.""" + context = await resolver.get_target_diseases("TP53", limit=3) + assert context is not None + assert isinstance(context, TargetAssociationContext) + assert context.symbol == "TP53" + assert len(context.associations) > 0 + + +@pytest.mark.asyncio +async def test_mcp_tool_resolve_disease(): + """Test MCP resolve_disease tool serialization.""" + res_str = await resolve_disease("Li-Fraumeni", limit=2) + assert res_str is not None + data = json.loads(res_str) + assert isinstance(data, list) + assert len(data) > 0 + assert "mondo_id" in data[0] + + +@pytest.mark.asyncio +async def test_mcp_tool_get_target_diseases(): + """Test MCP get_target_diseases tool serialization.""" + res_str = await get_target_diseases("TP53", limit=3) + assert res_str is not None + data = json.loads(res_str) + assert "associations" in data + assert len(data["associations"]) > 0