diff --git a/config/mcp_servers.json b/config/mcp_servers.json index e79f2593..b16263e7 100644 --- a/config/mcp_servers.json +++ b/config/mcp_servers.json @@ -5,9 +5,19 @@ "enabled": true, "description": "Biomedical research: PubMed papers, bioRxiv, ClinicalTrials.gov, NCI, variants, OncoKB", "transport": "stdio", - "command": "biomcp", - "args": ["run", "--mode", "stdio"], + "command": "biomcp-cli", + "args": ["mcp"], "env": {} + }, + "modulector": { + "enabled": true, + "description": "miRNA target interactions, aliases, methylation sites, disease and drug associations", + "transport": "stdio", + "command": "modulector-mcp", + "args": [], + "env": { + "MODULECTOR_API_BASE_URL": "https://modulector.multiomix.org" + } } } } diff --git a/config/requirements.txt b/config/requirements.txt index 0cc208d0..1a3603de 100644 --- a/config/requirements.txt +++ b/config/requirements.txt @@ -38,4 +38,5 @@ langchain-mcp-adapters==0.2.2 pgvector==0.4.2 sentence-transformers==3.0.0 openai==2.38.0 -biomcp-cli==0.8.22 \ No newline at end of file +biomcp-cli==0.8.22 +modulector-sdk==2.4.0 \ No newline at end of file diff --git a/src/api_service/mongo_service.py b/src/api_service/mongo_service.py index 02cc2b13..cb8ac047 100644 --- a/src/api_service/mongo_service.py +++ b/src/api_service/mongo_service.py @@ -222,12 +222,7 @@ def __get_standard_ids(file_type: FileType, molecules: List[str]) -> Optional[Di method='post' ) elif file_type == FileType.MIRNA: - data = global_mrna_service.get_modulector_service_content( - 'mirna-codes', - request_params={'mirna_codes': molecules}, - is_paginated=False, - method='post' - ) + data = global_mrna_service.get_mirna_codes(mirna_codes=molecules) else: # In case of methylation, cBioPortal don't manage the methylation sites, so we don't need to use Modulector. # Generates a dummy dict with the same keys and values as the molecules list diff --git a/src/api_service/mrna_service.py b/src/api_service/mrna_service.py index d431f53e..04ec4910 100644 --- a/src/api_service/mrna_service.py +++ b/src/api_service/mrna_service.py @@ -1,35 +1,36 @@ import logging from json.decoder import JSONDecodeError -from typing import Any, Dict, Optional, Literal, Union +from typing import Any, Dict, List, Optional, Literal, Union import requests from django.conf import settings from django.http import QueryDict from requests.exceptions import ConnectionError +import modulector_sdk as modulector +from modulector_sdk import PaginatedResponse + class MRNAService(object): - url_modulector_prefix: str url_bioapi_prefix: str + _modulector_base_url: str def __init__(self): - modulector_settings = settings.MODULECTOR_SETTINGS - self.url_modulector_prefix = self.__build_url(modulector_settings) - bioapi_settings = settings.BIOAPI_SETTINGS self.url_bioapi_prefix = self.__build_url(bioapi_settings) + self._modulector_base_url = settings.MODULECTOR_BASE_URL @staticmethod - def __build_url(settings: Dict[str, Any]) -> str: + def __build_url(svc_settings: Dict[str, Any]) -> str: """ Constructs the URL based on the settings provided. If the port is the default for the protocol (80 for http, 443 for https), it is omitted. Otherwise, the port is included in the URL. - @param settings: Dictionary containing protocol, host, and port information. + @param svc_settings: Dictionary containing protocol, host, and port information. @return: Constructed URL as a string. """ - protocol = settings['protocol'] - host = settings['host'] - port = settings['port'] + protocol = svc_settings['protocol'] + host = svc_settings['host'] + port = svc_settings['port'] if (protocol == 'http' and port == 80) or (protocol == 'https' and port == 443): return f"{protocol}://{host}" @@ -41,43 +42,35 @@ def __generate_rest_query_params(get_request: QueryDict) -> str: """ Generates a string with all the query params from GET request. @param get_request: GET request with query params to send to DRF backend - @return: String to send to Modulector/BioAPI APIs + @return: String to send to BioAPI """ return '&'.join([f'{key}={value}' for (key, value) in get_request.items()]) - def __get_service_content( + def __get_bioapi_content( self, service_name: str, request_params: QueryDict, is_paginated: bool, - url_prefix: str, method: Literal['get', 'post'], - append_slash: bool ) -> Optional[Union[Dict, str]]: """ - Generic function to make a request to a Modulector/BioAPI service - @param service_name: Modulector/BioAPI service to consume - @param request_params: GET/POST request with query params to send to DRF backend - @param is_paginated: True if the expected response is paginated to generate a default response in case of error - @param url_prefix: URL of the Modulector or BioAPI service + Generic function to make a request to a BioAPI service. + @param service_name: BioAPI service to consume + @param request_params: GET/POST request with query params + @param is_paginated: True if the expected response is paginated @param method: Request method (GET or POST) - @param append_slash: If True appends a slash to prevent issues with Django. - @return: JSON data retrieved from the Modulector service. None if response has 404 status code + @return: JSON data retrieved from BioAPI. None if response has 404 status code """ - url = f'{url_prefix}/{service_name}' + url = f'{self.url_bioapi_prefix}/{service_name}/' data = None # Prevents Mypy warning try: if method == 'get': params = self.__generate_rest_query_params(request_params) if params: - url += f'/?{params}' + url += f'/?{params}/' data = requests.get(url) else: - # Prevents issues with Django APPEND_SLASH option - if append_slash and not url.endswith('/'): - url += '/' - data = requests.post(url, json=request_params) if data.status_code != 200: @@ -94,7 +87,7 @@ def __get_service_content( return None except (ConnectionError, JSONDecodeError) as ex: - logging.error(f'Received data from Modulector/BioAPI: {data}') + logging.error(f'Received data from BioAPI: {data}') logging.exception(ex) if is_paginated: @@ -106,24 +99,6 @@ def __get_service_content( } return None - def get_modulector_service_content( - self, - service_name: str, - request_params: QueryDict, - is_paginated: bool, - method: Literal['get', 'post'] = 'get' - ) -> Optional[Dict]: - """ - Makes a request to a Modulector service. - @param service_name: Modulector service to consume - @param request_params: GET/POST params with query params to send to DRF backend - @param is_paginated: True if the expected response is paginated to generate a default response in case of error - @param method: Request method (GET or POST) - @return: JSON data retrieved from the Modulector service. None if response has 404 status code - """ - return self.__get_service_content(service_name, request_params, is_paginated, self.url_modulector_prefix, - method, append_slash=True) - def get_bioapi_service_content( self, service_name: str, @@ -135,12 +110,144 @@ def get_bioapi_service_content( Makes a request to a BioAPI service. @param service_name: BioAPI service to consume @param request_params: GET/POST params with query params to send to DRF backend - @param is_paginated: True if the expected response is paginated to generate a default response in case of error + @param is_paginated: True if the expected response is paginated @param method: Request method (GET or POST) - @return: JSON data retrieved from the Modulector service. None if response has 404 status code + @return: JSON data retrieved from BioAPI. None if response has 404 status code """ - return self.__get_service_content(service_name, request_params, is_paginated, self.url_bioapi_prefix, method, - append_slash=False) + return self.__get_bioapi_content(service_name, request_params, is_paginated, method) + + # ------------------------------------------------------------------ # + # Modulector SDK wrappers # + # ------------------------------------------------------------------ # + + def _paginated_to_dict(self, result: Optional[PaginatedResponse]) -> Dict: + """Converts a SDK PaginatedResponse to the dict format expected by the frontend.""" + if result is None: + return {'count': 0, 'next': '', 'previous': '', 'results': []} + return { + 'count': result.count, + 'next': result.next or '', + 'previous': result.previous or '', + 'results': list(result.results or []), + } + + def get_mirna_details(self, mirna: str) -> Optional[Dict]: + """Get miRNA details from Modulector SDK.""" + try: + return modulector.get_mirna_details(mirna=mirna, base_url=self._modulector_base_url) + except Exception as ex: + logging.exception(ex) + return None + + def get_methylation_details(self, methylation_site: str) -> Optional[Dict]: + """Get methylation site details from Modulector SDK.""" + try: + return modulector.get_methylation_details( + methylation_site=methylation_site, + base_url=self._modulector_base_url + ) + except Exception as ex: + logging.exception(ex) + return None + + def get_mirna_target_interactions( + self, + mirna: Optional[str] = None, + gene: Optional[str] = None, + score: Optional[str] = None, + include_pubmeds: bool = False, + page: Optional[int] = None, + page_size: Optional[int] = None, + ) -> Dict: + """Get miRNA-target interactions from Modulector SDK.""" + try: + result = modulector.get_mirna_target_interactions( + mirna=mirna, + gene=gene, + score=float(score) if score else None, + include_pubmeds=include_pubmeds, + page=page, + page_size=page_size, + base_url=self._modulector_base_url, + ) + return self._paginated_to_dict(result) + except Exception as ex: + logging.exception(ex) + return self._paginated_to_dict(None) + + def get_diseases(self, mirna: Optional[str] = None, page: Optional[int] = None, + page_size: Optional[int] = None) -> Dict: + """Get miRNA disease associations from Modulector SDK.""" + try: + result = modulector.get_diseases( + mirna=mirna, + page=page, + page_size=page_size, + base_url=self._modulector_base_url, + ) + return self._paginated_to_dict(result) + except Exception as ex: + logging.exception(ex) + return self._paginated_to_dict(None) + + def get_drugs(self, mirna: Optional[str] = None, page: Optional[int] = None, + page_size: Optional[int] = None) -> Dict: + """Get drug/miRNA associations from Modulector SDK.""" + try: + result = modulector.get_drugs( + mirna=mirna, + page=page, + page_size=page_size, + base_url=self._modulector_base_url, + ) + return self._paginated_to_dict(result) + except Exception as ex: + logging.exception(ex) + return self._paginated_to_dict(None) + + def get_mirna_codes(self, mirna_codes: List[str]) -> Optional[Dict[str, Optional[str]]]: + """Resolve miRNA identifiers to standard codes via Modulector SDK.""" + try: + return modulector.get_mirna_codes( + mirna_codes=mirna_codes, + base_url=self._modulector_base_url, + ) + except Exception as ex: + logging.exception(ex) + return None + + def find_mirna_codes(self, query: str, limit: Optional[int] = None) -> List[str]: + """Search miRNA identifiers via Modulector SDK.""" + try: + kwargs: Dict[str, Any] = {'query': query, 'base_url': self._modulector_base_url} + if limit is not None: + kwargs['limit'] = limit + return modulector.find_mirna_codes(**kwargs) + except Exception as ex: + logging.exception(ex) + return [] + + def get_methylation_sites(self, methylation_sites: List[str]) -> Optional[Dict[str, List[str]]]: + """Resolve methylation site identifiers to EPIC 2.0 names via Modulector SDK.""" + try: + return modulector.get_methylation_sites( + methylation_sites=methylation_sites, + base_url=self._modulector_base_url, + ) + except Exception as ex: + logging.exception(ex) + return None + + def find_methylation_sites(self, query: str, limit: Optional[int] = None) -> List[str]: + """Search methylation site identifiers via Modulector SDK.""" + try: + kwargs: Dict[str, Any] = {'query': query, 'base_url': self._modulector_base_url} + if limit is not None: + kwargs['limit'] = limit + return modulector.find_methylation_sites(**kwargs) + except Exception as ex: + logging.exception(ex) + return [] -global_mrna_service = MRNAService() +global_mrna_service = MRNAService() \ No newline at end of file diff --git a/src/api_service/views.py b/src/api_service/views.py index 901da96b..0f0a3c6b 100644 --- a/src/api_service/views.py +++ b/src/api_service/views.py @@ -914,21 +914,23 @@ def get_number_samples_in_common_action_one_front(request): @login_required def mirna_data_action(request): """Gets miRNA data from Modulector""" - data = global_mrna_service.get_modulector_service_content('mirna', request.GET, is_paginated=False) + data = global_mrna_service.get_mirna_details(mirna=request.GET.get('mirna', '')) return generate_json_response_or_404(data) @login_required def methylation_data_action(request): """Gets Methylation site data from Modulector""" - data = global_mrna_service.get_modulector_service_content('methylation', request.GET, is_paginated=False) + data = global_mrna_service.get_methylation_details( + methylation_site=request.GET.get('methylation_site', '') + ) return generate_json_response_or_404(data) @login_required def get_mirna_target_interaction_action(request): """ - Searches in papers an specific miRNA interaction. + Searches in papers an specific miRNA interaction. Examples: http://127.0.0.1:8000/api-service/mirna-target-interaction?gene=BRCA1&mirna=hsa-miR-132-3p&include_pubmeds=true @@ -939,48 +941,40 @@ def get_mirna_target_interaction_action(request): """ gene = request.GET.get('gene') mirna = request.GET.get('mirna') - include_pubmeds = request.GET.get('include_pubmeds') if not gene and not mirna: return JsonResponse(data={"error": "Param 'mirna' or 'gene' are mandatory"}, status=400) - if gene and not mirna: - params = {'gene': gene} - elif not gene and mirna: - params = {'mirna': mirna} - else: - params = {'mirna': mirna, 'gene': gene} - - score = request.GET.get('score') - if score: - params['score'] = score - - if include_pubmeds: - if include_pubmeds.lower() == "true": - params['include_pubmeds'] = "true" - - # Gets include_pubmeds flag - include_pubmeds = request.GET.get('include_pubmeds') == 'true' - if include_pubmeds: - params['include_pubmeds'] = 'true' - data = global_mrna_service.get_modulector_service_content('mirna-target-interactions', - request_params=params, - is_paginated=True, - method='get') + data = global_mrna_service.get_mirna_target_interactions( + mirna=mirna, + gene=gene, + score=request.GET.get('score'), + include_pubmeds=request.GET.get('include_pubmeds', '').lower() == 'true', + page=request.GET.get('page'), + page_size=request.GET.get('page_size'), + ) return JsonResponse(data) @login_required def get_mirna_diseases_action(request): """Searches in papers miRNA associations with diseases""" - data = global_mrna_service.get_modulector_service_content('diseases', request.GET, is_paginated=True) + data = global_mrna_service.get_diseases( + mirna=request.GET.get('mirna'), + page=request.GET.get('page'), + page_size=request.GET.get('page_size'), + ) return generate_json_response_or_404(data) @login_required def get_mirna_drugs_action(request): """Searches in papers miRNA associations with drugs""" - data = global_mrna_service.get_modulector_service_content('drugs', request.GET, is_paginated=True) + data = global_mrna_service.get_drugs( + mirna=request.GET.get('mirna'), + page=request.GET.get('page'), + page_size=request.GET.get('page_size'), + ) return generate_json_response_or_404(data) diff --git a/src/assistant/services/tools.py b/src/assistant/services/tools.py index be3331d3..39b7997e 100644 --- a/src/assistant/services/tools.py +++ b/src/assistant/services/tools.py @@ -542,15 +542,9 @@ def get_mirna_modulators(gene_name: str, min_score: float = 0.0, limit: int = 20 """ from api_service.mrna_service import global_mrna_service - params: dict = {'gene': gene_name} - if min_score > 0: - params['score'] = str(min_score) - - data = global_mrna_service.get_mdulector_service_content( - 'mirna-target-interactions', - request_params=params, - is_paginated=True, - method='get' + data = global_mrna_service.get_mirna_target_interactions( + gene=gene_name, + score=str(min_score) if min_score > 0 else None, ) if not data or data.get('count', 0) == 0: diff --git a/src/biomarkers/views.py b/src/biomarkers/views.py index c67d9324..67c0ea54 100644 --- a/src/biomarkers/views.py +++ b/src/biomarkers/views.py @@ -168,6 +168,8 @@ def find_genes_from_request(request: Request) -> List[Dict]: """ genes_found = global_mrna_service.get_bioapi_service_content('gene-symbols-finder', request.GET, is_paginated=False) + if not genes_found: + return [] aliases = get_gene_aliases(genes_found) return [{'molecule': gene, 'standard': aliases.get(gene, [None])[0]} for gene in genes_found] @@ -197,17 +199,11 @@ class MiRNACodes(APIView): @staticmethod def __get_mirna_aliases(mirna_codes: List[str]) -> Optional[Dict]: """Get the aliases for a list of miRNAs through Modulector""" - return global_mrna_service.get_modulector_service_content( - 'mirna-codes', - request_params={'mirna_codes': mirna_codes}, - is_paginated=False, - method='post' - ) + return global_mrna_service.get_mirna_codes(mirna_codes=mirna_codes) def get(self, request): """Generates a query to search miRNAs through Modulector""" - mirnas_found = global_mrna_service.get_modulector_service_content('mirna-codes-finder', - request.GET, is_paginated=False) + mirnas_found = global_mrna_service.find_mirna_codes(query=request.GET.get('query', '')) # Generates the structure for the frontend aliases = self.__get_mirna_aliases(mirnas_found) @@ -235,23 +231,12 @@ class MethylationSites(APIView): @staticmethod def __get_methylation_sites_aliases(methylation_sites: List[str]) -> Optional[Dict]: """Get the aliases for a list of Methylation sites through Modulector""" - return global_mrna_service.get_modulector_service_content( - 'methylation-sites', - request_params={'methylation_sites': methylation_sites}, - is_paginated=False, - method='post' - ) + return global_mrna_service.get_methylation_sites(methylation_sites=methylation_sites) def get(self, request: Request): """Generates a query to search Methylation sites through Modulector""" - # methylation_sites = request.GET.get('methylation_sites', '') - # Gets the Methylation sites - sites_found = global_mrna_service.get_modulector_service_content( - 'methylation-sites-finder', - request_params=request.GET, - is_paginated=False - ) + sites_found = global_mrna_service.find_methylation_sites(query=request.GET.get('query', '')) # Generates the structure for the frontend aliases = self.__get_methylation_sites_aliases(sites_found) diff --git a/src/molecules_details/views.py b/src/molecules_details/views.py index 1d4c9c13..7fc61404 100644 --- a/src/molecules_details/views.py +++ b/src/molecules_details/views.py @@ -594,14 +594,7 @@ def get(request: HttpRequest): if not methylation_site: return Response(status=400, data={"error": "Param 'methylation_site' is mandatory"}) - data = global_mrna_service.get_modulector_service_content( - 'methylation', - request_params={ - 'methylation_site': methylation_site - }, - is_paginated=False, - method='get' - ) + data = global_mrna_service.get_methylation_details(methylation_site=methylation_site) return Response({ 'data': data if data else None diff --git a/src/multiomics_intermediate/settings.py b/src/multiomics_intermediate/settings.py index 52f39191..5f4737df 100644 --- a/src/multiomics_intermediate/settings.py +++ b/src/multiomics_intermediate/settings.py @@ -304,11 +304,15 @@ EMAIL_PAGE_DOMAIN = 'https://multiomix.org' # Modulector settings -MODULECTOR_SETTINGS = { - 'host': os.getenv('MODULECTOR_HOST', 'modulector.multiomix.org'), - 'port': os.getenv('MODULECTOR_PORT', 443), - 'protocol': os.getenv('BIOAPI_PROTOCOL', 'https') -} +_modulector_protocol = os.getenv('MODULECTOR_PROTOCOL', os.getenv('BIOAPI_PROTOCOL', 'https')) +_modulector_host = os.getenv('MODULECTOR_HOST', 'modulector.multiomix.org') +_modulector_port = os.getenv('MODULECTOR_PORT', '443') +# MODULECTOR_API_BASE_URL is also read directly by the modulector-sdk as env var +MODULECTOR_BASE_URL = os.getenv( + 'MODULECTOR_API_BASE_URL', + f"{_modulector_protocol}://{_modulector_host}" if _modulector_port in ('80', '443') + else f"{_modulector_protocol}://{_modulector_host}:{_modulector_port}" +) # BioAPI settings BIOAPI_SETTINGS = {