From d95efd8a34586eb81b7fe5619469a564e6dec6bc Mon Sep 17 00:00:00 2001 From: Ravindra S Date: Fri, 27 Mar 2026 20:45:32 +0530 Subject: [PATCH 1/2] MCP prototype enhancement --- README.md | 52 +++++ .../__pycache__/executor.cpython-313.pyc | Bin 0 -> 6196 bytes .../__pycache__/mock_adapter.cpython-313.pyc | Bin 0 -> 4532 bytes .../__pycache__/planner.cpython-313.pyc | Bin 0 -> 5685 bytes orchestrator/demo.py | 141 ++++++++++++++ orchestrator/executor.py | 180 ++++++++++++++++++ orchestrator/mock_adapter.py | 104 ++++++++++ orchestrator/planner.py | 171 +++++++++++++++++ package-lock.json | 12 +- 9 files changed, 659 insertions(+), 1 deletion(-) create mode 100644 orchestrator/__pycache__/executor.cpython-313.pyc create mode 100644 orchestrator/__pycache__/mock_adapter.cpython-313.pyc create mode 100644 orchestrator/__pycache__/planner.cpython-313.pyc create mode 100644 orchestrator/demo.py create mode 100644 orchestrator/executor.py create mode 100644 orchestrator/mock_adapter.py create mode 100644 orchestrator/planner.py 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 0000000000000000000000000000000000000000..b0e22d940891b55f158a5bbf8b3b5dd2f1cdaaba GIT binary patch literal 6196 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 literal 0 HcmV?d00001 diff --git a/orchestrator/__pycache__/mock_adapter.cpython-313.pyc b/orchestrator/__pycache__/mock_adapter.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f5e2356249db699c77bec2635644a8447f751c1a GIT binary patch literal 4532 zcmbtYO>7&-72YM6lC_jX(T)>GPV9lBIAUOuwtg(jj*}>&EXj_3%#{)+qRXz9Lu#Yt z?s|4tS)MhC zN*GlQQlYkCHS5%LVV3TYx>acylx$cHqe50G2-$|J(Fz%~7*QKFl%$mmuBGA(Q`QVU zoUOXVp=QM)SCxhNY;kcCCf{mM#|4Eft`lpRrf$}_N)BbWDI*qEj$6^KyrN9y{-bcv%(ds=9%f=(y@C-1lrj{wMlzMhudwX<9Bf zi=&Cy8I?ou8wU_P{stQNg=L|*do&Unjed(Dxxci=OcCLs#1$zQSHSh77P!9B#rtH0 z3~zMEXC?7-x)<#E;ein10O9aKWS|G_gO(CrvjVQHgZ~t#lqE8;IGdA;)NL@+flF%Jmkc`YEC5F} z0avP{YordBP*Gi#;C14Du4R)o-Ob5Qdy3f^Wghu+aVmL&yh(AE(P1QPN=Ah}JO z?(BJCbbMsUJ3dn=f>z9G)Yiz{+|*Svs2Xl|erD=w=4p4R-??#+JnQwoH~Ib$S<#xV zJpmGwK9m zB^(!w9V8mIO<52JjA18m8*2ss4Q{DG$Iyu681O-pYv;|HibvMLU;P! zrMs8Dy!?Ig#8&de!z9@({JlTpk9_t>5T;{`u^$ED@b%a>c3t;ZDqCr~-9PA$6#Nq1 zN>_P%y45}~?Z;$Cx?^(|w>f(6^4-f1Uid2IPb&V>N51m0|M7}HQSz(S)=B$e@|G{% zdJIYld#IxWIyiOw0UG}obqINgt=C1UW4w;@TH7wQclx3{B5t7N@aV z*C9%(WN`r)Bnp-XRz>DuA+g99HnR|$0oK6hVGM(I!+>Q5?d)yU08czat`v%AfL!rHoRLCnrb>@~2ccWlP0TYw9z;lLW2RbSOjuB|8U?Bu<8MKv|c)X9WN z>pGaBMN{6%SredVB3(wEoGo`0J_^99F|}R?+T$x0C`|T_0_3wpq^j0+NE_^sj~vjd z7qohMcyV_4T7Kf%5@}PC*F86}c#TY|j;2;95X6MCGnk390X)qS+yOY4!A%3s}IJo^`EtYGz@FS71tTD2m-q zkl+KLG2)!Pj8#8MBn)eVvX*=@k}rdwvC}Z#q91kwgf+HQ9*MJK8j^F$I?&l9&|Md8` zZ~3p!JWS1Q&TPMM?AuG6%l?@WJuc`NyWFMYrWY_W$Dx}bwof(?!PJYmL2 zuOxC3WCWe)5*7Q6+yjA5o0bUHTGbm}m)G?St7`u4|kmc;H82SH|4NgHFV2Nyu#uoQ~# zS9E$PVu1(*lWi6V70o300l5l%!*D^qR*FMp2i+NwdwC|t4-&{N_pYoun9xDG%>DX> zwqNhccUcZ3F2m=10~Pn@*M|H{21GzloQc6tF}8w%u)h@BK6TE&GzVRuinBPP#7cbR zCXAf*FReUxWd7MBuo~`jU^Rft;f~F#7&%|O_x9bl9}M_oGybyX*Kco;jfcrQzI2CU z0|1Rdonw+8#5+>aHFpJ4f-^kz#5Db8(6h9#@P4^CH9e~=6+hCVBo`(Mf&?E2R1q5Y zg%#}MCc) zjlZgPo|TI#FfoNEnX~J0Bx8dy+-*Pj||ou4!p#oCD&;aUvhhnn2~kUZn$M0 z(Jqojrq@*lZ%zG707HNiL>WcZ498 zH^P)KCkF{G}z&Ao&Sd7@#IaRr7c#trzK zF{n0$C;fu_()V5cTV4GRMjm#N&G)uDyMF0y_r3Jsov%i|I{kame|gAH4f}_N|Ca3e zV*Sf^e5s$i&OE^WIII!G!C8WikZJ-64+SF_R@iy0a36L7suoS`>+RqN2xW(#F{>-+ zESD>mRxSr}xm<0)6Ni?|417-5=eyi0L8@Hd^^^ojJn`28*Exjop|`?+H@wXdw&;+~ zyHGufi=z0iSW1+BIwgp&J?Tz~OnfrXCw?eCIo%^R#Yb-nua9gGyt_Sc;m1ztbZoQ7 SPn~)!NMh`#-ahd--{Ic}zyhWK literal 0 HcmV?d00001 diff --git a/orchestrator/__pycache__/planner.cpython-313.pyc b/orchestrator/__pycache__/planner.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..585c766a4f93dc6d79216f6c550d9febacafceec GIT binary patch literal 5685 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 literal 0 HcmV?d00001 diff --git a/orchestrator/demo.py b/orchestrator/demo.py new file mode 100644 index 0000000..d82abec --- /dev/null +++ b/orchestrator/demo.py @@ -0,0 +1,141 @@ +""" +demo.py +------- +End-to-end demonstration of the Reactome MCP Orchestration Layer. + +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. + +No network access, API keys, or running MCP server is required – the +mock_adapter.py module simulates all Reactome tool responses locally. +""" + +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 entirely offline.\n" + " Tool calls are handled by mock_adapter.py which mirrors the real\n" + " MCP tool names so the orchestration layer is drop-in replaceable.\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..6002444 --- /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 mock_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/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", From b4a37c714be9869d5399418e16069f33dd978be6 Mon Sep 17 00:00:00 2001 From: Ravindra S Date: Tue, 31 Mar 2026 12:40:00 +0530 Subject: [PATCH 2/2] feat(orchestrator): transition from mock to live Reactome API execution Replaces the simulated response layer with a functional adapter that interacts directly with the Reactome Content and Analysis REST APIs. Key improvements: - Implemented eal_adapter.py using Python's standard urllib.request. - Added production networking standards: User-Agent headers, 15s timeouts, and comprehensive error handling for API request failures. - Updated executor.py to wire the planning logic into live services. - Refined demo.py to provide a complete trace of real-world biological tasks (search, enrichment, and pathway lookup). - Maintained a zero-dependency architecture for low-friction setup. This change moves the orchestration layer from a conceptual prototype to a functional tool for multi-step biological query execution. --- orchestrator/demo.py | 14 ++-- orchestrator/executor.py | 2 +- orchestrator/real_adapter.py | 121 +++++++++++++++++++++++++++++++++++ 3 files changed, 128 insertions(+), 9 deletions(-) create mode 100644 orchestrator/real_adapter.py diff --git a/orchestrator/demo.py b/orchestrator/demo.py index d82abec..4c8f264 100644 --- a/orchestrator/demo.py +++ b/orchestrator/demo.py @@ -1,7 +1,5 @@ """ -demo.py -------- -End-to-end demonstration of the Reactome MCP Orchestration Layer. +Real demonstration of the Reactome MCP Orchestration Layer calls. Run from inside the orchestrator/ directory: @@ -17,8 +15,8 @@ 2. Runs four representative queries that cover every execution mode. 3. Prints a formatted summary of plans and results to stdout. -No network access, API keys, or running MCP server is required – the -mock_adapter.py module simulates all Reactome tool responses locally. +Network access is required – the real_adapter.py module calls the Reactome +REST APIs (Content and Analysis services) directly. """ from __future__ import annotations @@ -107,9 +105,9 @@ def main() -> None: print(c("bold", " Reactome MCP Orchestration Layer -- Demo")) print(c("bold", DIVIDER)) print( - " This demo runs the Planner -> Executor pipeline entirely offline.\n" - " Tool calls are handled by mock_adapter.py which mirrors the real\n" - " MCP tool names so the orchestration layer is drop-in replaceable.\n" + " 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() diff --git a/orchestrator/executor.py b/orchestrator/executor.py index 6002444..9f838ea 100644 --- a/orchestrator/executor.py +++ b/orchestrator/executor.py @@ -27,7 +27,7 @@ from concurrent.futures import ThreadPoolExecutor, as_completed from typing import Any -from mock_adapter import call_tool +from real_adapter import call_tool class Executor: 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)