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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -986,6 +986,10 @@ clawcodex/

## 🤝 Contributing

For an opt-in web search integration, see the runnable
[Parallel Search MCP example](demos/parallel_search/README.md). It uses the existing
HTTP MCP client without a Parallel API key.

**We welcome contributions!**

```bash
Expand Down
1 change: 1 addition & 0 deletions demos/parallel_search/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.claude-migration.json
88 changes: 88 additions & 0 deletions demos/parallel_search/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# Parallel Search MCP example

Run free, keyless web search and page extraction through ClawCodex's existing
Streamable HTTP MCP client. The [Parallel Search MCP documentation](https://docs.parallel.ai/integrations/mcp/search-mcp)
describes the anonymous tier: it uses `fast` search mode, has rate limits, and
caps excerpts to roughly 25,000 characters per call. Model inference is separate.

## Run the example

From a fresh checkout of ClawCodex, install its declared dependencies:

```bash
uv venv
uv pip install --python .venv/bin/python -e .
```

On macOS/Linux, run from the repository root:

```bash
CLAWCODEX_CONFIG_DIR="$PWD/demos/parallel_search" .venv/bin/clawcodex mcp list
CLAWCODEX_CONFIG_DIR="$PWD/demos/parallel_search" .venv/bin/python -m demos.parallel_search
```

On Windows PowerShell:

```powershell
$env:CLAWCODEX_CONFIG_DIR = "$PWD/demos/parallel_search"
.venv\Scripts\clawcodex.exe mcp list
.venv\Scripts\python.exe -m demos.parallel_search
Remove-Item Env:CLAWCODEX_CONFIG_DIR
```

The list command should include `parallel-search`. The Python example loads
`config.json` through ClawCodex's normal MCP configuration loader, discovers
`web_search` and `web_fetch`, then prints search results for the Python asyncio
documentation and excerpts from the first URL returned by search. It reuses one session ID for
both calls and closes the connection on success or failure. It calls tools
directly without a model or API key; it does not run an agent conversation.
Network or rate-limit errors are reported as failures. The configuration sends a
project User-Agent and no Authorization header.

The environment override selects this demo's configuration directory only for
these commands. To add the server to your usual configuration, merge the
`parallel-search` entry from this demo's `mcpServers` object into your existing
`~/.clawcodex/config.json` (or your configured directory). Keep your existing
provider settings and other MCP entries. Do not overwrite the whole file.
This example leaves the built-in WebSearch tool and default model provider
unchanged.

## Use in an agent conversation

Merge the `parallel-search` server into your usual user configuration as above,
then launch `clawcodex` with your existing model provider. Ask it to use
`mcp__parallel-search__web_search`, fetch a URL from those results with
`mcp__parallel-search__web_fetch`, and explain the fetched page. Model inference
needs its own configured provider; anonymous Parallel access does not supply one.
The agent discovers deferred MCP schemas through `ToolSearch` before calling
them. Keep the normal permission prompts and approve each requested tool call only
when appropriate. Interactive project `.mcp.json` servers still use the existing approval rules;
the demo uses an explicit user-scope server. Invalid entries and policy notices
are shown during agent startup, and valid entries can still connect.

## Test the native path

Install development dependencies with `uv pip install --python .venv/bin/python -e ".[dev]"`.
The native regression uses the actual TUI/agent-server backend (`make_spawn_agent`),
MCP loader, discovery, HTTP SDK transport, tool registry, permission round trips,
and query loop. Its result-dependent controlled model fixture selects a URL from
search output and uses fetched content in its next response. It tests wiring,
including native `ToolSearch` discovery and denial before execution; it does not measure production model quality.
The default test uses a scripted HTTP peer and needs no network:

```bash
.venv/bin/python -m pytest tests/server/test_parallel_search_mcp.py -q
```

To exercise the same path against real anonymous Parallel search and fetch:

```bash
CLAWCODEX_TEST_PARALLEL_LIVE=1 .venv/bin/python -m pytest tests/server/test_parallel_search_mcp.py -q
```

The live run isolates user configuration and saved MCP tokens and removes API-key
environment variables. Only model inference is controlled; MCP calls use the real
endpoint. It checks outgoing URL/User-Agent, absence of Authorization, discovery,
explicit tool approvals, a result-selected fetch URL, and fetched content reaching
the subsequent provider response. Rate limits or network failures fail the live
check; the denial case is covered offline. No credentials are needed for this test.
81 changes: 81 additions & 0 deletions demos/parallel_search/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""Search and fetch anonymously through ClawCodex's configured HTTP MCP client."""

from __future__ import annotations

import asyncio
import re
import uuid

from src.services.mcp.client import McpClient
from src.services.mcp.config import get_all_mcp_configs
from src.services.mcp.types import ConnectedMCPServer, McpToolResult


def print_result(name: str, result: McpToolResult) -> str:
"""Print text content, failing visibly on a server-reported tool error."""
if result.is_error:
raise RuntimeError(f"{name} failed: {result.content}")
text = "\n".join(
block.get("text", "") for block in result.content if block.get("type") == "text"
)
if not text.strip():
raise RuntimeError(f"{name} returned no text content")
print(f"\n{name}:\n{text}")
return text


def select_url(search_text: str) -> str:
"""Select the first HTTP(S) URL in the returned search text."""
match = re.search(r'https?://[^\s"<>]+', search_text)
if match is None:
raise RuntimeError("web_search returned no HTTP(S) URL to fetch")
return match.group(0).rstrip("),]")


async def main() -> None:
"""Load the opt-in server and run one search and one fetch without an LLM."""
configs, errors = get_all_mcp_configs()
if "parallel-search" not in configs:
raise RuntimeError(
"Configure parallel-search first (see demos/parallel_search/README.md). "
f"Configuration notices: {errors}"
)
client = McpClient()
try:
connection = await client.connect("parallel-search", configs["parallel-search"])
if not isinstance(connection, ConnectedMCPServer):
raise RuntimeError(f"MCP connection failed: {connection}")
tools = {tool.name for tool in await client.list_tools()}
if not {"web_search", "web_fetch"}.issubset(tools):
raise RuntimeError(
f"Expected web_search and web_fetch, got {sorted(tools)}"
)
session_id = str(uuid.uuid4())
search_text = print_result(
"web_search",
await client.call_tool(
"web_search",
{
"objective": "Find the official Python asyncio documentation",
"search_queries": ["Python asyncio official documentation"],
"session_id": session_id,
},
),
)
print_result(
"web_fetch",
await client.call_tool(
"web_fetch",
{
"urls": [select_url(search_text)],
"objective": "Explain what asyncio is used for",
"session_id": session_id,
},
),
)
finally:
await client.close()


if __name__ == "__main__":
asyncio.run(main())
11 changes: 11 additions & 0 deletions demos/parallel_search/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"mcpServers": {
"parallel-search": {
"type": "http",
"url": "https://search.parallel.ai/mcp",
"headers": {
"User-Agent": "clawcodex-parallel-search-example/1.0 (https://github.com/agentforce314/clawcodex)"
}
}
}
}
15 changes: 14 additions & 1 deletion src/server/agent_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -6444,7 +6444,20 @@ def _build_runtime(sess: _AgentSession, perm_mode: str | None) -> None:
# Nano has no MCP surface (pi parity; docs/nano.md) — never
# even construct the runtime, so no servers get spawned.
mcp_rt = None if cfg.nano else McpRuntime()
if mcp_rt is not None and mcp_rt.start():
mcp_started = mcp_rt.start() if mcp_rt is not None else False
if mcp_rt is not None:
for notice in mcp_rt.config_errors:
source = (
notice.server_name or notice.path or notice.scope or "settings"
)
sess._emit(
_system_message(
sess.session_id,
f"MCP configuration ({source}): " f"{notice.message}",
level="warning",
)
)
if mcp_rt is not None and mcp_started:
for mtool in mcp_rt.tools:
try:
registry.register(mtool)
Expand Down
4 changes: 3 additions & 1 deletion src/server/mcp_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ def __init__(self) -> None:
self._thread: threading.Thread | None = None
self.clients: dict[str, Any] = {}
self.servers: dict[str, list[str]] = {} # server name -> tool names
# Loader validation/policy notices are surfaced by the agent-server.
self.config_errors: list[Any] = []
self.tools: list[Any] = [] # wrapped sync Tool objects (mcp__server__tool)
# ConnectedMCPServer objects (name + server-authored ``instructions``
# from the InitializeResult handshake). The connect() return used to
Expand Down Expand Up @@ -96,7 +98,7 @@ def start(self) -> bool:
logger.debug("[mcp] config module unavailable", exc_info=True)
return False
try:
configs = get_all_mcp_configs()
configs, self.config_errors = get_all_mcp_configs()
except Exception: # noqa: BLE001
logger.debug("[mcp] reading configs failed", exc_info=True)
return False
Expand Down
35 changes: 31 additions & 4 deletions tests/server/test_mcp_runtime.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
"""McpRuntime: connect a stdio MCP server on a dedicated loop and call a tool
through the loop-correct sync wrapper (the path the agent-server uses)."""

from __future__ import annotations

import sys
from pathlib import Path

from tests.integration.test_mcp_integration import MOCK_MCP_SERVER_SCRIPT
from src.services.mcp.types import McpStdioServerConfig, ScopedMcpServerConfig
from src.server.mcp_runtime import McpRuntime
from src.services.mcp.types import McpStdioServerConfig, ScopedMcpServerConfig
from src.tool_system.context import ToolContext
from tests.integration.test_mcp_integration import MOCK_MCP_SERVER_SCRIPT


def _configure(monkeypatch, tmp_path: Path) -> None:
Expand All @@ -22,7 +23,7 @@ def _configure(monkeypatch, tmp_path: Path) -> None:
}
import src.services.mcp.config as mcpconfig

monkeypatch.setattr(mcpconfig, "get_all_mcp_configs", lambda: cfg)
monkeypatch.setattr(mcpconfig, "get_all_mcp_configs", lambda: (cfg, []))


def test_runtime_connects_lists_and_calls(monkeypatch, tmp_path: Path) -> None:
Expand Down Expand Up @@ -79,8 +80,34 @@ class _StubProvider:
def test_runtime_no_servers_is_noop(monkeypatch) -> None:
import src.services.mcp.config as mcpconfig

monkeypatch.setattr(mcpconfig, "get_all_mcp_configs", lambda: {})
monkeypatch.setattr(mcpconfig, "get_all_mcp_configs", lambda: ({}, []))
rt = McpRuntime()
assert rt.start() is False
assert rt.tools == []
rt.shutdown() # idempotent / safe when never started


def test_runtime_retains_config_errors_without_starting(monkeypatch) -> None:
"""Loader diagnostics survive even when every configured server is invalid."""
from src.services.mcp.config import ValidationError

errors = [ValidationError(path="mcpServers.bad", message="Invalid server")]
monkeypatch.setattr(
"src.services.mcp.config.get_all_mcp_configs", lambda: ({}, errors)
)
rt = McpRuntime()
assert rt.start() is False
assert rt.config_errors == errors
assert rt._loop is None


def test_runtime_config_read_failure_is_guarded(monkeypatch) -> None:
"""A loader exception still leaves startup safe and does not create a loop."""

def fail():
raise OSError("unreadable config")

monkeypatch.setattr("src.services.mcp.config.get_all_mcp_configs", fail)
rt = McpRuntime()
assert rt.start() is False
assert rt._loop is None
Loading