Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
33 changes: 29 additions & 4 deletions libs/kotaemon/kotaemon/agents/tools/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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"],
Expand Down Expand Up @@ -311,6 +323,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))
Expand Down
128 changes: 128 additions & 0 deletions libs/kotaemon/tests/test_mcp_input.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
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()


@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
3 changes: 2 additions & 1 deletion libs/kotaemon/tests/test_mcp_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
2 changes: 1 addition & 1 deletion libs/ktem/ktem/reasoning/react.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion libs/ktem/ktem/reasoning/rewoo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
58 changes: 58 additions & 0 deletions libs/ktem/ktem_tests/test_mcp_selection.py
Original file line number Diff line number Diff line change
@@ -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