diff --git a/README.md b/README.md index a97e2d1..c1ce351 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,55 @@ +## πŸš€ Experimental Extension: Multi-Step Query Orchestration + +This fork extends the original Reactome MCP server by introducing a lightweight orchestration layer for multi-step biological query execution. + +> **Note:** The core MCP implementation remains unchanged. All extensions are implemented as an external orchestration layer within the `orchestrator/` directory of this repository. + +### What the Orchestration Layer Adds + +| Module | Role | +|---|---| +| `orchestrator/planner.py` | Converts a natural-language query β†’ structured JSON execution plan | +| `orchestrator/executor.py` | Executes plan steps sequentially or in parallel; resolves `$step.field` references between steps | +| `orchestrator/mock_adapter.py` | Simulates Reactome MCP tools by name (swap in the real client with zero executor changes) | +| `orchestrator/demo.py` | Runs a full end-to-end demonstration β€” no API key or running server required | + +### Supported Query Patterns + +``` +Compare TP53 and BRCA1 β†’ parallel enrichment analysis of both genes +Find apoptosis pathways for BCL2 β†’ 3-step sequential chain (search β†’ analyse β†’ pathway detail) +Analyse EGFR β†’ single-step pathway enrichment +Search PTEN signaling β†’ single-step full-text search +``` + +### Quick Start + +```bash +# No extra dependencies β€” uses Python standard library only +cd orchestrator +python demo.py +``` + +### Architecture + +``` +User Query + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” structured plan (JSON) β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ planner.py β”‚ ──────────────────────────────► β”‚ executor.py β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ tool call (name + input) + β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ mock_adapter.py β”‚ + β”‚ (β†’ real MCP clientβ”‚ + β”‚ in production) β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +--- + # reactome-mcp An [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) server that exposes the [Reactome](https://reactome.org/) pathway knowledgebase to AI assistants. It wraps Reactome's Content Service and Analysis Service REST APIs, giving LLMs the ability to search, browse, analyse, and export biological pathway data through natural language. diff --git a/orchestrator/__pycache__/executor.cpython-313.pyc b/orchestrator/__pycache__/executor.cpython-313.pyc new file mode 100644 index 0000000..b0e22d9 Binary files /dev/null and b/orchestrator/__pycache__/executor.cpython-313.pyc differ diff --git a/orchestrator/__pycache__/mock_adapter.cpython-313.pyc b/orchestrator/__pycache__/mock_adapter.cpython-313.pyc new file mode 100644 index 0000000..f5e2356 Binary files /dev/null and b/orchestrator/__pycache__/mock_adapter.cpython-313.pyc differ diff --git a/orchestrator/__pycache__/planner.cpython-313.pyc b/orchestrator/__pycache__/planner.cpython-313.pyc new file mode 100644 index 0000000..585c766 Binary files /dev/null and b/orchestrator/__pycache__/planner.cpython-313.pyc differ diff --git a/orchestrator/demo.py b/orchestrator/demo.py new file mode 100644 index 0000000..4c8f264 --- /dev/null +++ b/orchestrator/demo.py @@ -0,0 +1,139 @@ +""" +Real demonstration of the Reactome MCP Orchestration Layer calls. + +Run from inside the orchestrator/ directory: + + python demo.py + +Or from the repo root: + + python orchestrator/demo.py + +What it does +------------ +1. Creates a Planner and an Executor. +2. Runs four representative queries that cover every execution mode. +3. Prints a formatted summary of plans and results to stdout. + +Network access is required – the real_adapter.py module calls the Reactome +REST APIs (Content and Analysis services) directly. +""" + +from __future__ import annotations +import json +import sys +import os +import time + +# --------------------------------------------------------------------------- +# Allow running from repo root without modifying PYTHONPATH +# --------------------------------------------------------------------------- +sys.path.insert(0, os.path.dirname(__file__)) + +from planner import Planner +from executor import Executor + + +# --------------------------------------------------------------------------- +# Formatting helpers +# --------------------------------------------------------------------------- + +DIVIDER = "=" * 70 +THIN_DIVIDER = "-" * 70 + +ANSI = { + "reset": "\033[0m", + "bold": "\033[1m", + "cyan": "\033[96m", + "green": "\033[92m", + "yellow": "\033[93m", + "red": "\033[91m", + "grey": "\033[90m", +} + +def c(color: str, text: str) -> str: + """Wrap *text* in an ANSI colour code (skipped on non-TTY outputs).""" + if not sys.stdout.isatty(): + return text + return f"{ANSI.get(color, '')}{text}{ANSI['reset']}" + + +def print_plan(plan: dict) -> None: + print(c("cyan", f" Execution mode : {plan['execution']}")) + if plan.get("error"): + print(c("red", f" Planner error : {plan['error']}")) + return + for step in plan.get("steps", []): + print(c("grey", f" [{step['id']}] {step['tool']}({step['input']!r})")) + + +def print_result(result: dict) -> None: + if not result.get("steps"): + print(c("red", " No steps executed.")) + return + for step in result["steps"]: + status = c("green", "OK") if "error" not in step["result"] else c("red", "ERR") + print(f" [{status}] {step['id']} – {step['tool']}({step['input']!r}) " + f"{c('grey', str(step['duration_ms']) + ' ms')}") + print(f" {json.dumps(step['result'])}") + print(c("yellow", f" Total : {result['total_ms']} ms")) + + +# --------------------------------------------------------------------------- +# Demo queries +# --------------------------------------------------------------------------- + +DEMO_QUERIES = [ + # (label, query) + ("Parallel β€” compare two genes", "Compare TP53 and BRCA1"), + ("Sequential β€” 3-step pathway chain", "Find apoptosis pathways for BCL2"), + ("Single step β€” enrichment analysis", "Analyse EGFR"), + ("Single step β€” free-text search", "Search PTEN signaling"), + ("Error handling β€” unrecognised query", "Do something completely unknown"), +] + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main() -> None: + planner = Planner() + executor = Executor() + + print(f"\n{c('bold', DIVIDER)}") + print(c("bold", " Reactome MCP Orchestration Layer -- Demo")) + print(c("bold", DIVIDER)) + print( + " This demo runs the Planner -> Executor pipeline with REAL API calls.\n" + " Tool calls are handled by real_adapter.py which calls the Reactome\n" + " REST APIs directly, mirroring the production MCP server behavior.\n" + ) + + overall_start = time.perf_counter() + + for label, query in DEMO_QUERIES: + print(f"\n{THIN_DIVIDER}") + print(c("bold", f" {label}")) + print(f" Query : {c('cyan', query)}") + print() + + # --- Plan --- + print(c("bold", " [PLAN]")) + plan = planner.generate_plan(query) + print_plan(plan) + print() + + # --- Execute --- + print(c("bold", " [RESULT]")) + result = executor.run(plan) + print_result(result) + + overall_ms = round((time.perf_counter() - overall_start) * 1000, 2) + print(f"\n{DIVIDER}") + print(c("green", f" [OK] All demo queries completed in {overall_ms} ms")) + print(f"{DIVIDER}\n") + + +if __name__ == "__main__": + main() diff --git a/orchestrator/executor.py b/orchestrator/executor.py new file mode 100644 index 0000000..9f838ea --- /dev/null +++ b/orchestrator/executor.py @@ -0,0 +1,180 @@ +""" +executor.py +----------- +Executes the structured plan produced by the Planner. + +Execution modes +--------------- +sequential + Steps run one after another. A step's input may reference the output of + a previous step using the notation "$.". + Example: "$step1.stId" resolves to the "stId" key of step1's result. + +parallel + All steps are dispatched concurrently using a thread pool, then results + are collected in order. + +single + Convenience alias for a plan with exactly one step (runs sequentially). + +none + The plan contains no steps (usually an error from the planner). +""" + +from __future__ import annotations +import re +import time +from concurrent.futures import ThreadPoolExecutor, as_completed +from typing import Any + +from real_adapter import call_tool + + +class Executor: + """ + Runs a plan dict produced by Planner.generate_plan(). + + Usage + ----- + >>> executor = Executor() + >>> result = executor.run(plan) + """ + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def run(self, plan: dict) -> dict: + """ + Execute *plan* and return a result dict. + + Parameters + ---------- + plan : dict + Output of Planner.generate_plan(). + + Returns + ------- + dict with keys: + query – original query string + execution – execution mode used + steps – list of {id, tool, input, result, duration_ms} + total_ms – total wall-clock time in milliseconds + """ + mode = plan.get("execution", "none") + steps = plan.get("steps", []) + query = plan.get("query", "") + + start = time.perf_counter() + if mode in ("sequential", "single"): + executed = self._run_sequential(steps) + elif mode == "parallel": + executed = self._run_parallel(steps) + else: + executed = [] + + total_ms = round((time.perf_counter() - start) * 1000, 2) + + return { + "query": query, + "execution": mode, + "steps": executed, + "total_ms": total_ms, + "error": plan.get("error"), + } + + # ------------------------------------------------------------------ + # Execution strategies + # ------------------------------------------------------------------ + + def _run_sequential(self, steps: list[dict]) -> list[dict]: + """Run steps one at a time; later steps may reference earlier results.""" + results: dict[str, dict] = {} # step_id β†’ result dict + executed: list[dict] = [] + + for step in steps: + resolved_input = self._resolve_input(step["input"], results) + step_result = self._execute_step(step, resolved_input) + results[step["id"]] = step_result["result"] + executed.append(step_result) + + return executed + + def _run_parallel(self, steps: list[dict]) -> list[dict]: + """Dispatch all steps concurrently; collect results in original order.""" + futures = {} + executed_map: dict[str, dict] = {} + + with ThreadPoolExecutor(max_workers=min(len(steps), 8)) as pool: + for step in steps: + future = pool.submit(self._execute_step, step, step["input"]) + futures[future] = step["id"] + + for future in as_completed(futures): + step_id = futures[future] + executed_map[step_id] = future.result() + + # Return in original plan order + return [executed_map[s["id"]] for s in steps] + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _execute_step(self, step: dict, resolved_input: str) -> dict: + """Call the tool and capture timing.""" + t0 = time.perf_counter() + result = call_tool(step["tool"], resolved_input) + ms = round((time.perf_counter() - t0) * 1000, 2) + return { + "id": step["id"], + "tool": step["tool"], + "input": resolved_input, + "result": result, + "duration_ms": ms, + } + + def _resolve_input(self, input_value: str, results: dict[str, dict]) -> str: + """ + Resolve step-reference tokens of the form $.. + + Example + ------- + input_value = "$step1.stId" + results = {"step1": {"stId": "R-HSA-199420", ...}} + returns "R-HSA-199420" + + If the reference cannot be resolved, the original token is returned + unchanged so the error is visible in the output. + """ + m = re.match(r"^\$(\w+)\.(\w+)$", str(input_value)) + if not m: + return input_value # plain string, no substitution needed + + step_id, field = m.groups() + step_result = results.get(step_id, {}) + return str(step_result.get(field, input_value)) # fallback to token + + +# --------------------------------------------------------------------------- +# Quick smoke-test +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + import json + from planner import Planner + + planner = Planner() + executor = Executor() + + for query in [ + "Compare TP53 and BRCA1", + "Find apoptosis pathways for BCL2", + "Analyse EGFR", + "Search PTEN", + ]: + plan = planner.generate_plan(query) + result = executor.run(plan) + print(f"\n{'='*60}") + print(f"Query : {query}") + print(json.dumps(result, indent=2)) diff --git a/orchestrator/mock_adapter.py b/orchestrator/mock_adapter.py new file mode 100644 index 0000000..e904674 --- /dev/null +++ b/orchestrator/mock_adapter.py @@ -0,0 +1,104 @@ +""" +mock_adapter.py +--------------- +Simulates the Reactome MCP tools by name. + +In a real deployment this module would be replaced (or augmented) by a thin +client that sends JSON-RPC requests to the running MCP server over stdio/SSE. +Tool names here intentionally mirror those registered in src/tools/ so the +executor can call them without modification once the real adapter is wired in. + +Tools simulated +--------------- + reactome_search – full-text search + reactome_analyze_identifiers – pathway enrichment for a gene list + reactome_get_pathway – pathway/event detail by stable ID +""" + +from __future__ import annotations + +# --------------------------------------------------------------------------- +# Simulated tool implementations +# --------------------------------------------------------------------------- + +def reactome_search(query: str) -> dict: + """ + Simulate the Reactome full-text search tool. + + In the real MCP this calls the Reactome Search Service REST API. + Returns a stable ID (stId) and basic metadata for the top hit. + """ + gene = query.strip().upper() + catalogue = { + "TP53": {"stId": "R-HSA-5633007", "name": "TP53 Regulates Transcription of Genes Involved in G1 Cell Cycle Arrest", "type": "Pathway"}, + "BRCA1": {"stId": "R-HSA-5685942", "name": "HDR through MMEJ (alt-NHEJ)", "type": "Pathway"}, + "BCL2": {"stId": "R-HSA-199420", "name": "BCL2 [cytosol]", "type": "Protein"}, + "EGFR": {"stId": "R-HSA-177929", "name": "Signaling by EGFR", "type": "Pathway"}, + "PTEN": {"stId": "R-HSA-6796648", "name": "TP53 Regulates Transcription of Genes Involved in G2 Cell Cycle Arrest", "type": "Pathway"}, + } + return catalogue.get(gene, {"stId": "R-HSA-UNKNOWN", "name": f"Unknown Entity ({query})", "type": "Unknown"}) + + +def reactome_analyze_identifiers(gene: str) -> dict: + """ + Simulate the Reactome Analysis Service identifier-mapping tool. + + In the real MCP this submits a POST to the Analysis Service and returns + over-representation results (p-values, FDR, found/not-found identifiers). + """ + gene = gene.strip().upper() + pathway_map = { + "TP53": ["Apoptosis", "Cell Cycle Arrest", "DNA Repair", "p53-Dependent G1/S DNA damage checkpoint"], + "BRCA1": ["DNA Repair", "Homologous Recombination", "Cell Cycle", "Fanconi Anemia Pathway"], + "BCL2": ["Intrinsic Pathway for Apoptosis", "Programmed Cell Death", "BCL-2 family proteins"], + "EGFR": ["Signaling by EGFR", "PI3K/AKT Signaling", "MAPK Cascade", "RAS Signaling"], + "PTEN": ["PI3K/AKT Signaling", "Cellular Senescence", "DNA Damage Response"], + } + pathways = pathway_map.get(gene, ["General Signaling", "Metabolism"]) + return {"gene": gene, "pathways": pathways, "token": f"mock-token-{gene.lower()}"} + + +def reactome_get_pathway(stId: str) -> dict: + """ + Simulate the Reactome Content Service pathway-detail tool. + + In the real MCP this calls /data/query/{id} and returns full event metadata. + """ + pathway_db = { + "R-HSA-5633007": {"stId": "R-HSA-5633007", "name": "TP53 Regulates Transcription of G1 Arrest Genes", "species": "Homo sapiens", "type": "Pathway"}, + "R-HSA-5685942": {"stId": "R-HSA-5685942", "name": "HDR through MMEJ", "species": "Homo sapiens", "type": "Pathway"}, + "R-HSA-199420": {"stId": "R-HSA-199420", "name": "Intrinsic Pathway of Apoptosis", "species": "Homo sapiens", "type": "Pathway"}, + "R-HSA-177929": {"stId": "R-HSA-177929", "name": "Signaling by EGFR", "species": "Homo sapiens", "type": "Pathway"}, + "R-HSA-6796648": {"stId": "R-HSA-6796648", "name": "TP53 Regulates G2/S DNA Damage Checkpoint Genes", "species": "Homo sapiens", "type": "Pathway"}, + } + return pathway_db.get(stId, {"stId": stId, "name": "Generic Pathway", "species": "Homo sapiens", "type": "Pathway"}) + + +# --------------------------------------------------------------------------- +# Registry – maps tool name (string) β†’ callable +# --------------------------------------------------------------------------- + +TOOL_REGISTRY: dict[str, callable] = { + "reactome_search": reactome_search, + "reactome_analyze_identifiers": reactome_analyze_identifiers, + "reactome_get_pathway": reactome_get_pathway, +} + + +def call_tool(name: str, input_value: str) -> dict: + """ + Dispatch a tool call by name. + + Parameters + ---------- + name: MCP tool name (must match a key in TOOL_REGISTRY) + input_value: Primary argument for the tool + + Returns + ------- + dict with the tool result, or an error dict if the tool is unknown. + """ + fn = TOOL_REGISTRY.get(name) + if fn is None: + return {"error": f"Unknown tool: '{name}'"} + return fn(input_value) diff --git a/orchestrator/planner.py b/orchestrator/planner.py new file mode 100644 index 0000000..c8f39cc --- /dev/null +++ b/orchestrator/planner.py @@ -0,0 +1,171 @@ +""" +planner.py +---------- +Converts a natural-language biological query into a structured JSON execution +plan that the Executor understands. + +In a real system the generate_plan() method would be implemented by an LLM +(e.g. GPT-4 / Claude 3.5 Sonnet) that has been given the list of available +MCP tool descriptions. Here we use deterministic regex-based pattern matching +so the demo runs fully offline without any API keys. + +Supported query patterns +------------------------ +1. "Compare and " + β†’ parallel analysis of both genes with reactome_analyze_identifiers + +2. "Find pathways for " + β†’ sequential chain: + step1 – reactome_search (find the entity stId) + step2 – reactome_analyze_identifiers (enrich the gene) + step3 – reactome_get_pathway (detail on the stId from step1) + +3. "Analyse " / "Analyze " + β†’ single-step enrichment analysis + +4. "Search " + β†’ single-step full-text search + +Any unrecognised query returns an empty plan with a descriptive error. +""" + +from __future__ import annotations +import json +import re + + +class Planner: + """ + Rule-based query planner. + + Produces a plan dict with two keys: + steps – list of step dicts (id, tool, input) + execution – "parallel" | "sequential" | "single" | "none" + """ + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def generate_plan(self, query: str) -> dict: + """ + Convert *query* into a structured execution plan. + + Parameters + ---------- + query : str + Free-text biological query from a user or upstream LLM. + + Returns + ------- + dict + { + "query": , + "steps": [ {id, tool, input}, … ], + "execution": "parallel" | "sequential" | "single" | "none" + } + """ + query = query.strip() + + plan = ( + self._plan_compare(query) + or self._plan_find_pathways(query) + or self._plan_analyse(query) + or self._plan_search(query) + or self._plan_fallback(query) + ) + + plan["query"] = query + return plan + + # ------------------------------------------------------------------ + # Pattern handlers (return None if the pattern does not match) + # ------------------------------------------------------------------ + + def _plan_compare(self, query: str) -> dict | None: + """'Compare GENE_A and GENE_B' β†’ parallel enrichment analysis.""" + m = re.match(r"compare\s+(\w+)\s+and\s+(\w+)", query, re.I) + if not m: + return None + gene1, gene2 = m.groups() + return { + "steps": [ + {"id": "step1", "tool": "reactome_analyze_identifiers", "input": gene1}, + {"id": "step2", "tool": "reactome_analyze_identifiers", "input": gene2}, + ], + "execution": "parallel", + } + + def _plan_find_pathways(self, query: str) -> dict | None: + """'Find pathways for ' β†’ 3-step sequential chain.""" + m = re.match(r"find\s+(\w+)\s+pathways?\s+for\s+(\w+)", query, re.I) + if not m: + return None + _, gene = m.groups() + return { + "steps": [ + # Step 1: locate entity, capture stId for step 3 + {"id": "step1", "tool": "reactome_search", "input": gene}, + # Step 2: full pathway enrichment for the gene + {"id": "step2", "tool": "reactome_analyze_identifiers", "input": gene}, + # Step 3: detailed metadata for the stId returned by step 1 + {"id": "step3", "tool": "reactome_get_pathway", "input": "$step1.stId"}, + ], + "execution": "sequential", + } + + def _plan_analyse(self, query: str) -> dict | None: + """'Analyse/Analyze ' β†’ single enrichment step.""" + m = re.match(r"analy[sz]e\s+(\w+)", query, re.I) + if not m: + return None + (gene,) = m.groups() + return { + "steps": [ + {"id": "step1", "tool": "reactome_analyze_identifiers", "input": gene}, + ], + "execution": "single", + } + + def _plan_search(self, query: str) -> dict | None: + """'Search ' β†’ single search step.""" + m = re.match(r"search\s+(.+)", query, re.I) + if not m: + return None + (search_query,) = m.groups() + return { + "steps": [ + {"id": "step1", "tool": "reactome_search", "input": search_query}, + ], + "execution": "single", + } + + def _plan_fallback(self, query: str) -> dict: + return { + "steps": [], + "execution": "none", + "error": ( + "No plan could be generated for this query. " + "Try: 'Compare GENE_A and GENE_B', " + "'Find pathways for GENE', " + "'Analyse GENE', or 'Search '." + ), + } + + +# --------------------------------------------------------------------------- +# Quick smoke-test +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + planner = Planner() + test_queries = [ + "Compare TP53 and BRCA1", + "Find apoptosis pathways for BCL2", + "Analyse EGFR", + "Search PTEN signaling", + "Do something weird", + ] + for q in test_queries: + print(f"\nQuery: {q}") + print(json.dumps(planner.generate_plan(q), indent=2)) diff --git a/orchestrator/real_adapter.py b/orchestrator/real_adapter.py new file mode 100644 index 0000000..0254d21 --- /dev/null +++ b/orchestrator/real_adapter.py @@ -0,0 +1,121 @@ +""" +real_adapter.py +--------------- +Real implementation of Reactome MCP tools by calling Reactome REST APIs. +Uses only the Python standard library (urllib.request). + +Tools implemented: + reactome_search – full-text search + reactome_analyze_identifiers – pathway enrichment for a gene list + reactome_get_pathway – pathway/event detail by stable ID +""" + +import json +import urllib.request +import urllib.parse +from typing import Any + +CONTENT_SERVICE_BASE = "https://reactome.org/ContentService" +ANALYSIS_SERVICE_BASE = "https://reactome.org/AnalysisService" +USER_AGENT = "reactome-mcp-orchestrator/1.0" +TIMEOUT_SECONDS = 15 + +def _make_request(url: str, data: bytes | None = None, method: str = 'GET') -> dict[str, Any]: + """Helper to perform requests with standard headers and timeout.""" + req = urllib.request.Request(url, data=data, method=method) + req.add_header('User-Agent', USER_AGENT) + req.add_header('Accept', 'application/json') + if data and method == 'POST': + req.add_header('Content-Type', 'text/plain') + + try: + with urllib.request.urlopen(req, timeout=TIMEOUT_SECONDS) as response: + return json.loads(response.read().decode()) + except Exception as e: + return {"error": f"Request failed: {str(e)}"} + +def reactome_search(query: str) -> dict[str, Any]: + """ + Search Reactome for a query string. + Returns the top hit's stId, name, and type. + """ + params = { + "query": query, + "cluster": "true", + "rows": 10 + } + url = f"{CONTENT_SERVICE_BASE}/search/query?{urllib.parse.urlencode(params)}" + + data = _make_request(url) + if "error" in data: return data + + results = data.get("results", []) + if not results: + return {"stId": "UNKNOWN", "name": f"No results for {query}", "type": "Unknown"} + + # Find the first entry in the first cluster + for group in results: + if group.get("entries"): + top = group["entries"][0] + return { + "stId": top.get("stId"), + "name": top.get("name"), + "type": top.get("exactType", "Unknown") + } + + return {"stId": "UNKNOWN", "name": f"No entries for {query}", "type": "Unknown"} + +def reactome_analyze_identifiers(gene: str) -> dict[str, Any]: + """ + Perform pathway enrichment analysis for a single gene (or comma-separated list). + Returns basic result summary and a token. + """ + url = f"{ANALYSIS_SERVICE_BASE}/identifiers/projection?interactors=false&pageSize=20&sortBy=ENTITIES_PVALUE&order=ASC&resource=TOTAL" + + # POST body is text/plain with identifiers separated by newlines + body = gene.replace(",", "\n").encode('utf-8') + result = _make_request(url, data=body, method='POST') + if "error" in result: return result + + pathways = [p["name"] for p in result.get("pathways", [])[:5]] + return { + "gene": gene, + "pathways": pathways, + "token": result.get("summary", {}).get("token"), + "pathwaysFound": result.get("pathwaysFound", 0) + } + +def reactome_get_pathway(stId: str) -> dict[str, Any]: + """ + Get details of a pathway or reaction by stable ID. + """ + url = f"{CONTENT_SERVICE_BASE}/data/query/{stId}" + + data = _make_request(url) + if "error" in data: return data + + return { + "stId": data.get("stId"), + "name": data.get("displayName"), + "species": data.get("speciesName", "Homo sapiens"), + "type": data.get("schemaClass", "Pathway") + } + +# --------------------------------------------------------------------------- +# Registry – maps tool name (string) β†’ callable +# --------------------------------------------------------------------------- + +TOOL_REGISTRY: dict[str, callable] = { + "reactome_search": reactome_search, + "reactome_analyze_identifiers": reactome_analyze_identifiers, + "reactome_get_pathway": reactome_get_pathway, +} + +def call_tool(name: str, input_value: str) -> dict[str, Any]: + """ + Dispatch a tool call to the real API. + """ + fn = TOOL_REGISTRY.get(name) + if fn is None: + return {"error": f"Unknown tool: '{name}'"} + return fn(input_value) diff --git a/package-lock.json b/package-lock.json index 9d63d8e..bca00d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,7 @@ "": { "name": "reactome-mcp", "version": "1.0.0", - "license": "MIT", + "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.12.0", "zod": "^3.25.0" @@ -585,6 +585,16 @@ "node": ">= 0.4" } }, + "node_modules/hono": { + "version": "4.12.9", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz", + "integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",