From ae27ad1ec98bd1ee06cfd2aee1be1db3e56ccf43 Mon Sep 17 00:00:00 2001 From: George Pickett Date: Fri, 4 Sep 2026 17:45:54 -0700 Subject: [PATCH 1/2] feat(mcp): document Parallel search and preserve structured tool inputs --- docs/usage.md | 45 +++++++++++++++++ libs/kotaemon/kotaemon/agents/tools/mcp.py | 13 +++++ libs/kotaemon/tests/test_mcp_input.py | 57 +++++++++++++++++++++ libs/ktem/ktem/reasoning/react.py | 2 +- libs/ktem/ktem/reasoning/rewoo.py | 2 +- libs/ktem/ktem_tests/test_mcp_selection.py | 58 ++++++++++++++++++++++ 6 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 libs/kotaemon/tests/test_mcp_input.py create mode 100644 libs/ktem/ktem_tests/test_mcp_selection.py diff --git a/docs/usage.md b/docs/usage.md index 5713e719e..8b681257e 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -155,3 +155,48 @@ Now navigate back to the `Chat` tab. The chat tab is divided into 3 regions: Generally, the score quality is `LLM relevant score` > `Reranking score` > `Vectorscore`. By default, overall relevance score is taken directly from LLM relevant score. Evidences are sorted based on their overall relevance score and whether they have citation or not. + +## 4. Add web search with Parallel MCP (optional) + +ReAct and ReWOO can use [Parallel Search MCP](https://docs.parallel.ai/integrations/mcp/search-mcp) +to search the public web and fetch page text without a Parallel account or API key. +Free access is rate limited. This setup uses Kotaemon's existing stdio support and +the [mcp-remote bridge](https://github.com/geelen/mcp-remote) to connect to Parallel's +Streamable HTTP endpoint. + +Install Node.js with `npx` on the machine running Kotaemon, including inside the +container if you use Docker. The first connection downloads `mcp-remote`. Your +Kotaemon environment also needs the optional `mcp` package, included in the `adv` +and `all` extras. + +1. Open **Resources > MCP Servers > Add** and paste this configuration: + + ```json + { + "mcpServers": { + "parallel-search": { + "command": "npx", + "args": ["-y", "mcp-remote", "https://search.parallel.ai/mcp"], + "enabled_tools": ["web_search", "web_fetch"] + } + } + } + ``` + +2. Click **Add MCP Servers**. Wait for the available tools to show `web_search` + and `web_fetch`. A saved configuration alone does not confirm a connection. +3. In **Settings > Reasoning settings**, choose **ReAct Agent** or **ReWOO Agent** and add + **[MCP] parallel-search** to its **Tools for knowledge retrieval** selection. Keep any existing tools + you still want, then click **Save & Close**. These pipelines must be included + in your installation's `KH_REASONINGS` configuration. +4. Use that reasoning mode in chat and ask it to search the public web. The agent + can call these tools during its work. Queries, requested URLs, and any context + the agent includes in tool arguments are sent to Parallel, even when your + language model runs locally. Avoid including private document content in + requests you want to keep local. + +To allow search without page fetching, open the server under **View**, change +`enabled_tools` to `["web_search"]`, and click **Save**. To stop using Parallel, +remove **[MCP] parallel-search** from the selected tools for each reasoning mode +where you enabled it. You can also delete the server under **Resources > MCP Servers**. +Adding this example does not change the default reasoning mode or search tools. diff --git a/libs/kotaemon/kotaemon/agents/tools/mcp.py b/libs/kotaemon/kotaemon/agents/tools/mcp.py index e0bce4cca..bb15694f5 100644 --- a/libs/kotaemon/kotaemon/agents/tools/mcp.py +++ b/libs/kotaemon/kotaemon/agents/tools/mcp.py @@ -311,6 +311,19 @@ class MCPTool(BaseTool): # The original MCP tool name (on the server) mcp_tool_name: str = "" + def _parse_input(self, tool_input: str | dict) -> str | dict: + # ReAct and ReWOO pass text, including JSON objects for structured tools. + # Decode before BaseTool validates required fields and array arguments. + if isinstance(tool_input, str): + try: + parsed = json.loads(tool_input) + except json.JSONDecodeError: + pass + else: + if isinstance(parsed, dict): + tool_input = parsed + return super()._parse_input(tool_input) + def _run_tool(self, *args: Any, **kwargs: Any) -> str: """Invoke the MCP tool by establishing a session.""" return _run_async(self._arun_tool(*args, **kwargs)) diff --git a/libs/kotaemon/tests/test_mcp_input.py b/libs/kotaemon/tests/test_mcp_input.py new file mode 100644 index 000000000..6fc8b6b57 --- /dev/null +++ b/libs/kotaemon/tests/test_mcp_input.py @@ -0,0 +1,57 @@ +import json +from unittest.mock import patch + +import pytest +from pydantic import ValidationError + +from kotaemon.agents.tools.mcp import MCPTool, build_args_model + + +def make_tool(properties, required): + return MCPTool( + name="example", + description="Example tool", + args_schema=build_args_model( + "example", {"properties": properties, "required": required} + ), + ) + + +@pytest.mark.parametrize("as_json", [False, True]) +@pytest.mark.parametrize("invoke", ["run", "__call__"]) +def test_run_structured_input(as_json, invoke): + tool = make_tool( + {"objective": {"type": "string"}, "queries": {"type": "array"}}, + ["objective", "queries"], + ) + arguments = {"objective": "Find public docs", "queries": ["MCP setup"]} + with patch.object(MCPTool, "_run_tool", return_value="result") as run: + assert ( + getattr(tool, invoke)(json.dumps(arguments) if as_json else arguments) + == "result" + ) + run.assert_called_once_with(**arguments) + + +def test_run_array_argument(): + tool = make_tool({"urls": {"type": "array"}}, ["urls"]) + with patch.object(MCPTool, "_run_tool", return_value="page") as run: + assert tool.run('{"urls": ["https://example.com"]}') == "page" + run.assert_called_once_with(urls=["https://example.com"]) + + +@pytest.mark.parametrize("text", ["plain query", "123", '"quoted"', "[1, 2]"]) +def test_run_preserves_single_string_input(text): + tool = make_tool({"query": {"type": "string"}}, ["query"]) + with patch.object(MCPTool, "_run_tool", return_value="result") as run: + assert tool.run(text) == "result" + run.assert_called_once_with(text) + + +@pytest.mark.parametrize("text", ["{}", '{"urls": "not an array"}']) +def test_run_validates_before_dispatch(text): + tool = make_tool({"urls": {"type": "array"}}, ["urls"]) + with patch.object(MCPTool, "_run_tool") as run: + with pytest.raises(ValidationError): + tool.run(text) + run.assert_not_called() diff --git a/libs/ktem/ktem/reasoning/react.py b/libs/ktem/ktem/reasoning/react.py index d53b43c10..0989107f6 100644 --- a/libs/ktem/ktem/reasoning/react.py +++ b/libs/ktem/ktem/reasoning/react.py @@ -286,7 +286,7 @@ def get_pipeline( entry = mcp_manager.get(server_name) if entry: config = entry["config"] - enabled_tools = config.pop("enabled_tools", None) + enabled_tools = config.get("enabled_tools", None) mcp_tools = create_tools_from_config(config, enabled_tools) tools.extend(mcp_tools) else: diff --git a/libs/ktem/ktem/reasoning/rewoo.py b/libs/ktem/ktem/reasoning/rewoo.py index 7bd14c898..7ff8afdd6 100644 --- a/libs/ktem/ktem/reasoning/rewoo.py +++ b/libs/ktem/ktem/reasoning/rewoo.py @@ -412,7 +412,7 @@ def get_pipeline( entry = mcp_manager.get(server_name) if entry: config = entry["config"] - enabled_tools = config.pop("enabled_tools", None) + enabled_tools = config.get("enabled_tools", None) mcp_tools = create_tools_from_config(config, enabled_tools) tools.extend(mcp_tools) else: diff --git a/libs/ktem/ktem_tests/test_mcp_selection.py b/libs/ktem/ktem_tests/test_mcp_selection.py new file mode 100644 index 000000000..7d5954764 --- /dev/null +++ b/libs/ktem/ktem_tests/test_mcp_selection.py @@ -0,0 +1,58 @@ +from copy import deepcopy +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest + + +@pytest.mark.parametrize( + "module_name,class_name", + [("react", "ReactAgentPipeline"), ("rewoo", "RewooAgentPipeline")], +) +def test_repeated_pipeline_load_preserves_enabled_tools( + module_name, class_name, mocker +): + import importlib + + module = importlib.import_module(f"ktem.reasoning.{module_name}") + pipeline_class = getattr(module, class_name) + config = { + "command": "npx", + "args": ["-y", "mcp-remote", "https://example.com/mcp"], + "enabled_tools": ["search"], + } + original = deepcopy(config) + manager = Mock() + manager.get.return_value = {"config": config} + mocker.patch.object(module, "mcp_manager", manager) + selected_tool = object() + create_tools = mocker.patch.object( + module, "create_tools_from_config", return_value=[selected_tool] + ) + mocker.patch.object(module, "llms") + pipeline = SimpleNamespace( + agent=SimpleNamespace(prompt_template={}), rewrite_pipeline=SimpleNamespace() + ) + mocker.patch.object(module, class_name, return_value=pipeline) + prefix = f"reasoning.options.{pipeline_class.get_info()['id']}" + settings = { + f"{prefix}.{key}": value + for key, value in { + "llm": "", + "planner_llm": "", + "solver_llm": "", + "max_iterations": 2, + "qa_prompt": "test", + "planner_prompt": "test", + "solver_prompt": "test", + "highlight_citation": False, + "tools": ["[MCP] example"], + }.items() + } + settings["reasoning.lang"] = "en" + + for _ in range(2): + result = pipeline_class.get_pipeline(settings, {}) + assert result.agent.plugins == [selected_tool] + assert create_tools.call_args.args[1] == ["search"] + assert config == original From 20f330f93d66b0e9dffefe8cda42c39bb1ef8f8a Mon Sep 17 00:00:00 2001 From: George Pickett Date: Fri, 4 Sep 2026 18:17:58 -0700 Subject: [PATCH 2/2] fix(mcp): expose input schemas and support nullable arguments --- libs/kotaemon/kotaemon/agents/tools/mcp.py | 20 ++++-- libs/kotaemon/tests/test_mcp_input.py | 71 ++++++++++++++++++++++ libs/kotaemon/tests/test_mcp_tools.py | 3 +- 3 files changed, 89 insertions(+), 5 deletions(-) diff --git a/libs/kotaemon/kotaemon/agents/tools/mcp.py b/libs/kotaemon/kotaemon/agents/tools/mcp.py index bb15694f5..d24feb200 100644 --- a/libs/kotaemon/kotaemon/agents/tools/mcp.py +++ b/libs/kotaemon/kotaemon/agents/tools/mcp.py @@ -46,12 +46,17 @@ def build_args_model(tool_name: str, input_schema: dict) -> Type[BaseModel]: required = set(input_schema.get("required", [])) fields: dict[str, Any] = {} for prop_name, prop_info in properties.items(): - python_type = _json_schema_type_to_python(prop_info.get("type", "string")) + # MCP schemas can express a nullable field as anyOf: [type, null]. + variants = prop_info.get("anyOf", []) + non_null = [v for v in variants if v.get("type") != "null"] + nullable = len(non_null) == 1 and len(non_null) < len(variants) + type_schema = non_null[0] if nullable else prop_info + python_type = _json_schema_type_to_python(type_schema.get("type", "string")) description = prop_info.get("description", "") - if prop_name in required: + if prop_name in required and not nullable: fields[prop_name] = (python_type, Field(..., description=description)) else: - default = prop_info.get("default", None) + default = ... if prop_name in required else prop_info.get("default", None) fields[prop_name] = ( Optional[python_type], Field(default=default, description=description), @@ -106,9 +111,16 @@ def _make_tool(parsed: dict, tool_info: Any) -> "MCPTool": build_args_model(tool_info.name, input_schema) if input_schema else None ) + description = tool_info.description or f"MCP tool: {tool_info.name}" + if input_schema: + # Text-based agents see descriptions, not args_schema. + description += "\nInput must be a JSON object with this schema: " + json.dumps( + input_schema + ) + return MCPTool( name=tool_info.name, - description=tool_info.description or f"MCP tool: {tool_info.name}", + description=description, args_schema=args_model, server_transport=parsed["transport"], server_command=parsed["command"], diff --git a/libs/kotaemon/tests/test_mcp_input.py b/libs/kotaemon/tests/test_mcp_input.py index 6fc8b6b57..ba9a42ef5 100644 --- a/libs/kotaemon/tests/test_mcp_input.py +++ b/libs/kotaemon/tests/test_mcp_input.py @@ -55,3 +55,74 @@ def test_run_validates_before_dispatch(text): with pytest.raises(ValidationError): tool.run(text) run.assert_not_called() + + +@pytest.mark.parametrize("as_json", [False, True]) +@pytest.mark.parametrize("queries", [["MCP setup"], None]) +@pytest.mark.parametrize("required", [False, True]) +def test_run_nullable_array(as_json, queries, required): + tool = make_tool( + { + "urls": {"type": "array"}, + "search_queries": { + "anyOf": [{"type": "array"}, {"type": "null"}], + "default": None, + }, + }, + ["urls", "search_queries"] if required else ["urls"], + ) + arguments = {"urls": ["https://example.com"], "search_queries": queries} + with patch.object(MCPTool, "_run_tool", return_value="page") as run: + assert tool.run(json.dumps(arguments) if as_json else arguments) == "page" + run.assert_called_once_with(**arguments) + + +def test_nullable_array_still_rejects_string(): + tool = make_tool( + {"queries": {"anyOf": [{"type": "array"}, {"type": "null"}]}}, + [], + ) + with patch.object(MCPTool, "_run_tool") as run: + with pytest.raises(ValidationError): + tool.run({"queries": "not an array"}) + run.assert_not_called() + + +@pytest.mark.parametrize("required", [False, True]) +def test_nullable_array_presence(required): + tool = make_tool( + {"queries": {"anyOf": [{"type": "array"}, {"type": "null"}]}}, + ["queries"] if required else [], + ) + with patch.object(MCPTool, "_run_tool", return_value="result") as run: + if required: + with pytest.raises(ValidationError): + tool.run({}) + run.assert_not_called() + else: + assert tool.run({}) == "result" + run.assert_called_once_with() + + +def test_discovered_tool_description_includes_input_contract(): + from types import SimpleNamespace + + from kotaemon.agents.tools.mcp import _make_tool + + schema = { + "type": "object", + "properties": { + "objective": {"type": "string", "description": "Information to find"}, + "queries": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["objective", "queries"], + } + tool = _make_tool( + {"transport": "stdio", "command": "example"}, + SimpleNamespace( + name="search", description="Search the web", inputSchema=schema + ), + ) + assert tool.description.startswith("Search the web") + assert "JSON object" in tool.description + assert json.dumps(schema) in tool.description diff --git a/libs/kotaemon/tests/test_mcp_tools.py b/libs/kotaemon/tests/test_mcp_tools.py index 3e81a1982..35ab4542b 100644 --- a/libs/kotaemon/tests/test_mcp_tools.py +++ b/libs/kotaemon/tests/test_mcp_tools.py @@ -163,7 +163,8 @@ def test_make_tool_creates_mcp_tool_with_schema() -> None: assert isinstance(tool, MCPTool) assert tool.name == "fetch" - assert tool.description == "Fetch a URL" + assert tool.description.startswith("Fetch a URL\n") + assert '"required": ["url"]' in tool.description assert tool.server_transport == "stdio" assert tool.server_command == "uvx" assert tool.server_args == ["mcp-server-fetch"]