From a16a6d337cfcda073344d48d2ae07ad35d898bca Mon Sep 17 00:00:00 2001 From: Govindh Kishore Date: Sat, 7 Mar 2026 19:35:31 +0530 Subject: [PATCH 1/4] feat(mcp): add minimal Python MCP client for reactome-mcp integration --- src/mcp/__init__.py | 0 src/mcp/mcp_client.py | 95 ++++++++++++++++++++++++++++++++++ src/mcp/mcp_process_manager.py | 66 +++++++++++++++++++++++ 3 files changed, 161 insertions(+) create mode 100644 src/mcp/__init__.py create mode 100644 src/mcp/mcp_client.py create mode 100644 src/mcp/mcp_process_manager.py diff --git a/src/mcp/__init__.py b/src/mcp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/mcp/mcp_client.py b/src/mcp/mcp_client.py new file mode 100644 index 0000000..be3f7d8 --- /dev/null +++ b/src/mcp/mcp_client.py @@ -0,0 +1,95 @@ +import asyncio +import json + + +class MCPToolError(Exception): + """Raised when MCP server returns a JSON-RPC error response.""" + pass + + +class MCPClient: + """ + Minimal JSON-RPC client for communicating with the Reactome MCP server + over stdin/stdout. + """ + + def __init__(self, process: asyncio.subprocess.Process, timeout: float = 30.0): + self.process = process + self.timeout = timeout + self.request_id = 0 + + async def call(self, method: str, params: dict | None = None) -> dict: + """ + Send a JSON-RPC request and return the result. + + Raises + ------ + MCPToolError + If the server returns a JSON-RPC error response. + asyncio.TimeoutError + If the server does not respond within timeout seconds. + RuntimeError + If the server closes the connection unexpectedly. + """ + if params is None: + params = {} + + self.request_id += 1 + + request = { + "jsonrpc": "2.0", + "id": self.request_id, + "method": method, + "params": params, + } + + message = json.dumps(request) + "\n" + self.process.stdin.write(message.encode("utf-8")) + await self.process.stdin.drain() + + # Wait for response with timeout so chatbot never hangs indefinitely + response_line = await asyncio.wait_for( + self.process.stdout.readline(), + timeout=self.timeout, + ) + + if not response_line: + raise RuntimeError("MCP server closed the connection.") + + try: + response = json.loads(response_line.decode("utf-8").strip()) + except json.JSONDecodeError as e: + raise RuntimeError(f"MCP server returned invalid JSON: {e}") + + # JSON-RPC error response — server understood request but returned an error + if "error" in response: + error = response["error"] + raise MCPToolError( + f"MCP error {error.get('code')}: {error.get('message')}" + ) + + return response.get("result", {}) + + async def call_tool(self, tool_name: str, arguments: dict | None = None) -> str: + """ + Call a specific MCP tool and return the text result. + + Parameters + ---------- + tool_name : str + Name of the tool (e.g. "reactome_search"). + arguments : dict | None + Tool arguments. + """ + if arguments is None: + arguments = {} + + result = await self.call( + "tools/call", + {"name": tool_name, "arguments": arguments}, + ) + + # MCP returns content as list of typed blocks — extract text blocks + content = result.get("content", []) + text_parts = [block["text"] for block in content if block.get("type") == "text"] + return "\n".join(text_parts) \ No newline at end of file diff --git a/src/mcp/mcp_process_manager.py b/src/mcp/mcp_process_manager.py new file mode 100644 index 0000000..f472d1b --- /dev/null +++ b/src/mcp/mcp_process_manager.py @@ -0,0 +1,66 @@ +import asyncio +from pathlib import Path + + +class MCPConnectionError(Exception): + """Raised when MCP server fails to start or crashes.""" + pass + + +class MCPProcessManager: + """Manages lifecycle of the Reactome MCP server process.""" + + def __init__(self, mcp_server_path: str): + self.mcp_server_path = Path(mcp_server_path) + if not self.mcp_server_path.exists(): + raise FileNotFoundError( + f"MCP server not found at: {self.mcp_server_path}\n" + f"Make sure reactome-mcp is cloned and built with 'npm run build'" + ) + self.process = None + + async def start(self) -> asyncio.subprocess.Process: + """Start the MCP server process.""" + self.process = await asyncio.create_subprocess_exec( + "node", + str(self.mcp_server_path), + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + + # Allow server time to initialize before checking if it survived + await asyncio.sleep(1) + + if self.process.returncode is not None: + # Process already exited — read stderr to find out why + stderr_output = await self.process.stderr.read() + raise MCPConnectionError( + f"MCP server failed to start:\n{stderr_output.decode('utf-8')}" + ) + + return self.process + + async def stop(self) -> None: + """Stop the MCP server — graceful terminate, falls back to kill.""" + if not self.process: + return + + try: + self.process.terminate() + await asyncio.wait_for(self.process.wait(), timeout=5.0) + + except asyncio.TimeoutError: + self.process.kill() + await self.process.wait() + + finally: + self.process = None + + async def __aenter__(self): + await self.start() + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + await self.stop() + return False \ No newline at end of file From 6d1573552ed93e2d05f003b605fd33cdcf016ced Mon Sep 17 00:00:00 2001 From: Govindh Kishore Date: Tue, 10 Mar 2026 21:12:55 +0530 Subject: [PATCH 2/4] feat: integrate Reactome MCP tools into LangGraph agent via LangChain tool wrappers --- src/agent/graph.py | 32 ++++++++-- src/agent/profiles/react_to_me.py | 97 ++++++++++++++++++++++++++----- src/mcp/mcp_tools.py | 47 +++++++++++++++ 3 files changed, 156 insertions(+), 20 deletions(-) create mode 100644 src/mcp/mcp_tools.py diff --git a/src/agent/graph.py b/src/agent/graph.py index 012df27..fade459 100644 --- a/src/agent/graph.py +++ b/src/agent/graph.py @@ -14,8 +14,11 @@ from psycopg_pool import AsyncConnectionPool from agent.models import get_embedding, get_llm -from agent.profiles import ProfileName, create_profile_graphs +from agent.profiles import ProfileName from agent.profiles.base import InputState, OutputState +from agent.profiles.cross_database import create_cross_database_graph +from agent.profiles.react_to_me import create_reactome_graph +from mcp.mcp_tools import create_mcp_tools from util.logging import logging LANGGRAPH_DB_URI = f"postgresql://{os.getenv('POSTGRES_USER')}:{os.getenv('POSTGRES_PASSWORD')}@postgres:5432/{os.getenv('POSTGRES_LANGGRAPH_DB')}?sslmode=disable" @@ -33,9 +36,9 @@ def __init__( llm: BaseChatModel = get_llm("openai", "gpt-4o-mini") embedding: Embeddings = get_embedding("openai", "text-embedding-3-large") - self.uncompiled_graph: dict[str, StateGraph] = create_profile_graphs( - profiles, llm, embedding - ) + self.llm = llm + self.embedding = embedding + self.profiles = profiles # The following are set asynchronously by calling initialize() self.graph: dict[str, CompiledStateGraph] | None = None @@ -46,10 +49,27 @@ def __del__(self) -> None: asyncio.run(self.close_pool()) async def initialize(self) -> dict[str, CompiledStateGraph]: + + mcp_tools, self.mcp_manager = await create_mcp_tools( + os.getenv("MCP_SERVER_PATH") + ) + + uncompiled_graphs: dict[str, StateGraph] = {} + for profile in map(str.lower, self.profiles): + if profile == ProfileName.React_to_Me.lower(): + uncompiled_graphs[profile] = create_reactome_graph( + self.llm, self.embedding, mcp_tools + ) + elif profile == ProfileName.Cross_Database_Prototype.lower(): + uncompiled_graphs[profile] = create_cross_database_graph( + self.llm, self.embedding + ) + checkpointer: BaseCheckpointSaver[str] = await self.create_checkpointer() + return { profile: graph.compile(checkpointer=checkpointer) - for profile, graph in self.uncompiled_graph.items() + for profile, graph in uncompiled_graphs.items() } async def create_checkpointer(self) -> BaseCheckpointSaver[str]: @@ -73,6 +93,8 @@ async def create_checkpointer(self) -> BaseCheckpointSaver[str]: async def close_pool(self) -> None: if self.pool: await self.pool.close() + if self.mcp_manager: + await self.mcp_manager.stop() async def ainvoke( self, diff --git a/src/agent/profiles/react_to_me.py b/src/agent/profiles/react_to_me.py index dab20f0..60f4261 100644 --- a/src/agent/profiles/react_to_me.py +++ b/src/agent/profiles/react_to_me.py @@ -2,7 +2,7 @@ from langchain_core.embeddings import Embeddings from langchain_core.language_models.chat_models import BaseChatModel -from langchain_core.messages import AIMessage, HumanMessage +from langchain_core.messages import AIMessage, HumanMessage, ToolMessage from langchain_core.runnables import Runnable, RunnableConfig from langgraph.graph.state import StateGraph @@ -20,9 +20,18 @@ def __init__( self, llm: BaseChatModel, embedding: Embeddings, + mcp_tools: list | None = None, ) -> None: super().__init__(llm, embedding) + self.llm = llm + + # optional MCP tools - if provided the LLM can call them instead of using RAG + self.mcp_tools = mcp_tools or [] + + # bind tools to LLM once at init time, reused on every call_model invocation + self.llm_with_tools = self.llm.bind_tools(self.mcp_tools) if self.mcp_tools else None + # Create runnables (tasks & tools) self.unsafe_answer_generator: Runnable = create_unsafe_answer_generator( llm, streaming=True @@ -73,28 +82,86 @@ async def generate_unsafe_response( async def call_model( self, state: ReactToMeState, config: RunnableConfig ) -> ReactToMeState: - result: dict[str, Any] = await self.reactome_rag.ainvoke( - { - "input": state["rephrased_input"], - "chat_history": ( - state["chat_history"] - if state["chat_history"] - else [HumanMessage(state["user_input"])] - ), - }, - config, - ) + # no MCP tools - fall back to existing RAG behaviour unchanged + if not self.mcp_tools: + result: dict[str, Any] = await self.reactome_rag.ainvoke( + { + "input": state["rephrased_input"], + "chat_history": ( + state["chat_history"] + if state["chat_history"] + else [HumanMessage(state["user_input"])] + ), + }, + config, + ) + return ReactToMeState( + chat_history=[ + HumanMessage(state["user_input"]), + AIMessage(result["answer"]), + ], + answer=result["answer"], + ) + + llm_with_tools =self.llm_with_tools + + messages = list(state["chat_history"] or []) + [ + HumanMessage(state["rephrased_input"]) + ] + + response = await llm_with_tools.ainvoke(messages, config) + + # tool calling loop - max 10 iterations to prevent infinite loop + max_iterations = 15 + iteration = 0 + while response.tool_calls and iteration < max_iterations: + iteration += 1 + tool_results = [] + + for tool_call in response.tool_calls: + # find matching tool and execute it - triggers MCP client to Reactome API + tool = next( + t for t in self.mcp_tools if t.name == tool_call["name"] + ) + + result = await tool.ainvoke(tool_call["args"]) + + # tool_call_id links this result back to the specific request the LLM made + tool_results.append( + ToolMessage( + content=str(result), + tool_call_id=tool_call["id"], + ) + ) + + # send tool results back to LLM for final answer + messages = messages + [response] + tool_results + response = await llm_with_tools.ainvoke(messages, config) + + # loop hit max iterations - LLM never gave direct answer + if response.tool_calls: + answer = "I was unable to complete the research in time. Please try rephrasing your question." + return ReactToMeState( + chat_history=[ + HumanMessage(state["user_input"]), + AIMessage(answer), + ], + answer=answer, + ) + + # LLM gave direct answer return ReactToMeState( chat_history=[ HumanMessage(state["user_input"]), - AIMessage(result["answer"]), + AIMessage(response.content), ], - answer=result["answer"], + answer=response.content, ) def create_reactome_graph( llm: BaseChatModel, embedding: Embeddings, + mcp_tools: list | None = None ) -> StateGraph: - return ReactToMeGraphBuilder(llm, embedding).uncompiled_graph + return ReactToMeGraphBuilder(llm, embedding, mcp_tools).uncompiled_graph diff --git a/src/mcp/mcp_tools.py b/src/mcp/mcp_tools.py new file mode 100644 index 0000000..f2203da --- /dev/null +++ b/src/mcp/mcp_tools.py @@ -0,0 +1,47 @@ +from langchain_core.tools import tool +from mcp.mcp_process_manager import MCPProcessManager +from mcp.mcp_client import MCPClient + + +async def create_mcp_tools(mcp_server_path: str | None): + """ + Start the MCP server and return LangChain tool wrappers + manager. + Returns ([], None) if no server path provided. + """ + if not mcp_server_path: + return [], None + + manager = MCPProcessManager(mcp_server_path) + process = await manager.start() + client = MCPClient(process) + + @tool + async def search_reactome(query: str) -> str: + """Search Reactome for pathways, proteins, genes, and biological entities.""" + return await client.call_tool("reactome_search", {"query": query}) + + @tool + async def get_pathway(id: str) -> str: + """Get detailed information about a specific Reactome pathway using its stable ID (e.g. R-HSA-109582).""" + return await client.call_tool("reactome_get_pathway", {"id": id}) + + @tool + async def analyze_identifiers(identifiers: list[str]) -> str: + """Run pathway enrichment analysis on a list of gene or protein identifiers (e.g. TP53, BRCA1).""" + return await client.call_tool( + "reactome_analyze_identifiers", {"identifiers": identifiers} + ) + + @tool + async def get_database_info() -> str: + """Get current Reactome database version and release information.""" + return await client.call_tool("reactome_database_info", {}) + + @tool + async def get_species() -> str: + """Get the list of all species available in the Reactome database.""" + return await client.call_tool("reactome_species", {}) + + tools = [search_reactome, get_pathway, analyze_identifiers, get_database_info, get_species] + + return tools, manager \ No newline at end of file From 3213965d081556b59f3b9343902b425b439966da Mon Sep 17 00:00:00 2001 From: Govindh Kishore Date: Thu, 12 Mar 2026 21:21:31 +0530 Subject: [PATCH 3/4] feat(mcp): add LLM-based query routing to direct questions to RAG or MCP tool subsets --- src/agent/profiles/react_to_me.py | 50 +++++++++++++++++++++++--- src/mcp/query_router.py | 58 +++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 4 deletions(-) create mode 100644 src/mcp/query_router.py diff --git a/src/agent/profiles/react_to_me.py b/src/agent/profiles/react_to_me.py index 60f4261..99773f8 100644 --- a/src/agent/profiles/react_to_me.py +++ b/src/agent/profiles/react_to_me.py @@ -9,6 +9,8 @@ from agent.profiles.base import BaseGraphBuilder, BaseState from agent.tasks.unsafe_question import create_unsafe_answer_generator from retrievers.reactome.rag import create_reactome_rag +from agent.models import get_llm +from mcp.query_router import create_query_router, ROUTE_RAG, ROUTE_MCP_SEARCH, ROUTE_MCP_ANALYSIS class ReactToMeState(BaseState): @@ -29,8 +31,19 @@ def __init__( # optional MCP tools - if provided the LLM can call them instead of using RAG self.mcp_tools = mcp_tools or [] - # bind tools to LLM once at init time, reused on every call_model invocation - self.llm_with_tools = self.llm.bind_tools(self.mcp_tools) if self.mcp_tools else None + # pre-bind two route-specific tool subsets to LLM once at init time - search tools for + # lookup/retrieval routes and analysis tool for enrichment routes - avoids rebinding on every message + # produces two separate LLM instances: llm_with_search_tools and llm_with_analysis_tools + search_tools = [t for t in self.mcp_tools if t.name in ( + "search_reactome", "get_pathway", "get_database_info", "get_species" + )] + analysis_tools = [t for t in self.mcp_tools if t.name == "analyze_identifiers"] + + self.llm_with_search_tools = self.llm.bind_tools(search_tools) if search_tools else None + self.llm_with_analysis_tools = self.llm.bind_tools(analysis_tools) if analysis_tools else None + + # create router with cheap model - only used when mcp_tools available + self.query_router = create_query_router(get_llm("openai", "gpt-4o-mini")) if self.mcp_tools else None # Create runnables (tasks & tools) self.unsafe_answer_generator: Runnable = create_unsafe_answer_generator( @@ -103,7 +116,36 @@ async def call_model( answer=result["answer"], ) - llm_with_tools =self.llm_with_tools + # route question to correct path + route = await self.query_router(state["rephrased_input"]) + + if route == ROUTE_RAG: + # question is general knowledge, use RAG directly, no tools needed + result: dict[str, Any] = await self.reactome_rag.ainvoke( + { + "input": state["rephrased_input"], + "chat_history": ( + state["chat_history"] + if state["chat_history"] + else [HumanMessage(state["user_input"])] + ), + }, + config, + ) + return ReactToMeState( + chat_history=[ + HumanMessage(state["user_input"]), + AIMessage(result["answer"]), + ], + answer=result["answer"], + ) + + if route == ROUTE_MCP_SEARCH: + llm_with_tools = self.llm_with_search_tools + elif route == ROUTE_MCP_ANALYSIS: + llm_with_tools = self.llm_with_analysis_tools + else: + llm_with_tools = self.llm_with_search_tools messages = list(state["chat_history"] or []) + [ HumanMessage(state["rephrased_input"]) @@ -111,7 +153,7 @@ async def call_model( response = await llm_with_tools.ainvoke(messages, config) - # tool calling loop - max 10 iterations to prevent infinite loop + # tool calling loop - max 15 iterations to prevent infinite loop max_iterations = 15 iteration = 0 while response.tool_calls and iteration < max_iterations: diff --git a/src/mcp/query_router.py b/src/mcp/query_router.py new file mode 100644 index 0000000..a0a6662 --- /dev/null +++ b/src/mcp/query_router.py @@ -0,0 +1,58 @@ +from langchain_core.language_models.chat_models import BaseChatModel +from langchain_core.output_parsers import StrOutputParser +from langchain_core.prompts import PromptTemplate + +ROUTE_RAG = "rag" +ROUTE_MCP_SEARCH = "mcp_search" +ROUTE_MCP_ANALYSIS = "mcp_analysis" + +ROUTER_PROMPT = PromptTemplate( + input_variables=["question"], + template="""You are a query router for a Reactome biological pathway chatbot. + +Classify the question into exactly one of these categories: + +rag - General knowledge question about biological pathways, proteins, + or genes answerable from a static knowledge base. + Example: "What does TP53 do?", "Explain MAPK signalling" + +mcp_search - Requires live Reactome database lookup by specific identifier, + name, species list, or database metadata. + Example: "Show pathway R-HSA-109582", "What species does Reactome cover?" + +mcp_analysis - Involves a specific list of gene or protein identifiers + needing pathway enrichment analysis. + Example: "Analyze these genes: TP53, BRCA1, EGFR" + +Rules: +- mcp_analysis requires both: (1) explicit analysis/enrichment intent AND + (2) a list of identifiers to analyze. A list alone is not enough. +- mcp_search is for retrieving specific known entities by ID or name, + or querying database metadata like species or version. +- If a question combines multiple intents, identify the primary action + the user wants performed and route based on that. +- If genuinely unclear, return rag - it is always the safest fallback. + +Return only the category name. Nothing else. + +Question: {question}""", +) + + +def create_query_router(llm: BaseChatModel): + """ + Returns an async routing function that classifies a user question into + one of three routes: rag, mcp_search, or mcp_analysis. + Intended to be used with a lightweight model like gpt-4o-mini. + """ + llm_chain = ROUTER_PROMPT | llm | StrOutputParser() + + async def route(question: str) -> str: + result = await llm_chain.ainvoke({"question": question}) + result = result.strip().lower() + # fall back to rag if LLM returns unexpected output + if result not in (ROUTE_RAG, ROUTE_MCP_SEARCH, ROUTE_MCP_ANALYSIS): + return ROUTE_RAG + return result + + return route \ No newline at end of file From 275bcc8caa980268676e42a00d58b300e60a5cb0 Mon Sep 17 00:00:00 2001 From: Govindh Kishore Date: Sat, 21 Mar 2026 01:38:22 +0530 Subject: [PATCH 4/4] docs(mcp): enhance and reformat docstrings across process manager, client, mcp_tools, and query router --- src/mcp/mcp_client.py | 32 ++++++++++++++------------------ src/mcp/mcp_process_manager.py | 31 ++++++++++++++++++++++++++++--- src/mcp/mcp_tools.py | 11 +++++++++-- src/mcp/query_router.py | 11 ++++++++--- 4 files changed, 59 insertions(+), 26 deletions(-) diff --git a/src/mcp/mcp_client.py b/src/mcp/mcp_client.py index be3f7d8..2407fcd 100644 --- a/src/mcp/mcp_client.py +++ b/src/mcp/mcp_client.py @@ -9,8 +9,11 @@ class MCPToolError(Exception): class MCPClient: """ - Minimal JSON-RPC client for communicating with the Reactome MCP server - over stdin/stdout. + JSON-RPC client for communicating with the Reactome MCP server over stdin/stdout. + + Args: + process: Running MCP server subprocess from MCPProcessManager. + timeout: Seconds to wait for a response before raising TimeoutError. """ def __init__(self, process: asyncio.subprocess.Process, timeout: float = 30.0): @@ -22,14 +25,10 @@ async def call(self, method: str, params: dict | None = None) -> dict: """ Send a JSON-RPC request and return the result. - Raises - ------ - MCPToolError - If the server returns a JSON-RPC error response. - asyncio.TimeoutError - If the server does not respond within timeout seconds. - RuntimeError - If the server closes the connection unexpectedly. + Raises: + MCPToolError: If the server returns an error response. + asyncio.TimeoutError: If no response within timeout seconds. + RuntimeError: If the server closes the connection or returns invalid JSON. """ if params is None: params = {} @@ -72,14 +71,11 @@ async def call(self, method: str, params: dict | None = None) -> dict: async def call_tool(self, tool_name: str, arguments: dict | None = None) -> str: """ - Call a specific MCP tool and return the text result. - - Parameters - ---------- - tool_name : str - Name of the tool (e.g. "reactome_search"). - arguments : dict | None - Tool arguments. + Call a specific MCP tool and return its text output. + + Args: + tool_name: Name of the tool e.g. 'reactome_search'. + arguments: Tool arguments as key-value pairs. """ if arguments is None: arguments = {} diff --git a/src/mcp/mcp_process_manager.py b/src/mcp/mcp_process_manager.py index f472d1b..fd94ae8 100644 --- a/src/mcp/mcp_process_manager.py +++ b/src/mcp/mcp_process_manager.py @@ -8,7 +8,19 @@ class MCPConnectionError(Exception): class MCPProcessManager: - """Manages lifecycle of the Reactome MCP server process.""" + """ + Manages the lifecycle of the Reactome MCP server subprocess. + + Spawns a Node.js process communicating over stdio via JSON-RPC. + Supports async context manager for automatic cleanup. + + Args: + mcp_server_path: Path to compiled server entry point (reactome-mcp/dist/index.js). + Run 'npm run build' in reactome-mcp repo first. + + Raises: + FileNotFoundError: If the server path does not exist. + """ def __init__(self, mcp_server_path: str): self.mcp_server_path = Path(mcp_server_path) @@ -20,7 +32,15 @@ def __init__(self, mcp_server_path: str): self.process = None async def start(self) -> asyncio.subprocess.Process: - """Start the MCP server process.""" + """ + Spawn the MCP server and verify it started successfully. + + Returns: + The running asyncio subprocess instance. + + Raises: + MCPConnectionError: If the process exits immediately after launch. + """ self.process = await asyncio.create_subprocess_exec( "node", str(self.mcp_server_path), @@ -42,7 +62,12 @@ async def start(self) -> asyncio.subprocess.Process: return self.process async def stop(self) -> None: - """Stop the MCP server — graceful terminate, falls back to kill.""" + """ + Shut down the server gracefully. + + Tries terminate first, falls back to force kill after 5 seconds. + Safe to call if process was never started. + """ if not self.process: return diff --git a/src/mcp/mcp_tools.py b/src/mcp/mcp_tools.py index f2203da..76abe98 100644 --- a/src/mcp/mcp_tools.py +++ b/src/mcp/mcp_tools.py @@ -5,8 +5,15 @@ async def create_mcp_tools(mcp_server_path: str | None): """ - Start the MCP server and return LangChain tool wrappers + manager. - Returns ([], None) if no server path provided. + Start the MCP server and return LangChain tool wrappers and the process manager. + + Args: + mcp_server_path: Path to compiled MCP server (reactome-mcp/dist/index.js). + Pass None to disable MCP tools entirely. + + Returns: + Tuple of (list of LangChain tools, MCPProcessManager). + Returns ([], None) if no server path provided. """ if not mcp_server_path: return [], None diff --git a/src/mcp/query_router.py b/src/mcp/query_router.py index a0a6662..9b7b8f9 100644 --- a/src/mcp/query_router.py +++ b/src/mcp/query_router.py @@ -41,9 +41,14 @@ def create_query_router(llm: BaseChatModel): """ - Returns an async routing function that classifies a user question into - one of three routes: rag, mcp_search, or mcp_analysis. - Intended to be used with a lightweight model like gpt-4o-mini. + Build an async function that classifies a question into rag, mcp_search, or mcp_analysis. + + Args: + llm: Chat model used for classification. A lightweight model like gpt-4o-mini is sufficient. + + Returns: + Async function that takes a question string and returns a route string. + Falls back to 'rag' if the model returns unexpected output. """ llm_chain = ROUTER_PROMPT | llm | StrOutputParser()