From 0e5bebf483967a1ecd5d85a66acf1870ed6b7574 Mon Sep 17 00:00:00 2001 From: georgeatparallel Date: Thu, 8 Oct 2026 09:59:54 +0000 Subject: [PATCH 1/4] feat(mcp): add keyless Parallel search example --- README.md | 4 ++ demos/parallel_search/.gitignore | 1 + demos/parallel_search/README.md | 48 ++++++++++++++ demos/parallel_search/__main__.py | 71 +++++++++++++++++++++ demos/parallel_search/config.json | 11 ++++ tests/test_parallel_search_example.py | 90 +++++++++++++++++++++++++++ 6 files changed, 225 insertions(+) create mode 100644 demos/parallel_search/.gitignore create mode 100644 demos/parallel_search/README.md create mode 100644 demos/parallel_search/__main__.py create mode 100644 demos/parallel_search/config.json create mode 100644 tests/test_parallel_search_example.py diff --git a/README.md b/README.md index ec850f094..aa5893b29 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/demos/parallel_search/.gitignore b/demos/parallel_search/.gitignore new file mode 100644 index 000000000..897275b70 --- /dev/null +++ b/demos/parallel_search/.gitignore @@ -0,0 +1 @@ +.claude-migration.json diff --git a/demos/parallel_search/README.md b/demos/parallel_search/README.md new file mode 100644 index 000000000..d57015169 --- /dev/null +++ b/demos/parallel_search/README.md @@ -0,0 +1,48 @@ +# 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 its official page. 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. diff --git a/demos/parallel_search/__main__.py b/demos/parallel_search/__main__.py new file mode 100644 index 000000000..c434f2634 --- /dev/null +++ b/demos/parallel_search/__main__.py @@ -0,0 +1,71 @@ +"""Search and fetch anonymously through ClawCodex's configured HTTP MCP client.""" + +from __future__ import annotations + +import asyncio +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) -> None: + """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}") + + +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()) + 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": ["https://docs.python.org/3/library/asyncio.html"], + "objective": "Explain what asyncio is used for", + "session_id": session_id, + }, + ), + ) + finally: + await client.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/demos/parallel_search/config.json b/demos/parallel_search/config.json new file mode 100644 index 000000000..d94806ed2 --- /dev/null +++ b/demos/parallel_search/config.json @@ -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)" + } + } + } +} diff --git a/tests/test_parallel_search_example.py b/tests/test_parallel_search_example.py new file mode 100644 index 000000000..433ade81e --- /dev/null +++ b/tests/test_parallel_search_example.py @@ -0,0 +1,90 @@ +"""Exercise the opt-in example with the real loader and a scripted MCP client.""" + +from pathlib import Path + +import pytest + +from demos.parallel_search import __main__ as example +from src.services.mcp.config import get_all_mcp_configs +from src.services.mcp.types import ( + ConnectedMCPServer, + McpToolResult, + McpToolSchema, + ServerCapabilities, + ServerInfo, +) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("tool_error", [False, True]) +async def test_example_uses_loaded_server_and_closes( + monkeypatch, tmp_path, capsys, tool_error +) -> None: + """Load the shipped config, reuse the session, and close even on tool errors.""" + demo = Path(__file__).resolve().parents[1] / "demos" / "parallel_search" + monkeypatch.setenv("CLAWCODEX_CONFIG_DIR", str(demo)) + monkeypatch.setenv("CLAWCODEX_MANAGED_CONFIG_DIR", str(tmp_path / "managed")) + monkeypatch.chdir(tmp_path) + configs, errors = get_all_mcp_configs() + assert not errors + assert set(configs) == {"parallel-search"} + config = configs["parallel-search"] + assert config.scope == "user" + assert config.config.type == "http" + assert config.config.url == "https://search.parallel.ai/mcp" + assert config.config.headers == { + "User-Agent": "clawcodex-parallel-search-example/1.0 " + "(https://github.com/agentforce314/clawcodex)" + } + + calls = [] + closed = [] + + class Client: + async def connect(self, name, selected): + assert name == "parallel-search" + assert selected == config + return ConnectedMCPServer( + name=name, + config=selected, + capabilities=ServerCapabilities(tools=True), + server_info=ServerInfo(name="parallel", version="test"), + ) + + async def list_tools(self): + return [ + McpToolSchema(name="web_search", input_schema={}), + McpToolSchema(name="web_fetch", input_schema={}), + ] + + async def call_tool(self, name, arguments): + calls.append((name, arguments)) + return McpToolResult( + content=[{"type": "text", "text": "Python asyncio documentation"}], + is_error=tool_error, + ) + + async def close(self): + closed.append(True) + + monkeypatch.setattr(example, "McpClient", Client) + if tool_error: + with pytest.raises(RuntimeError, match="web_search failed"): + await example.main() + assert len(calls) == 1 + else: + await example.main() + assert [name for name, _ in calls] == ["web_search", "web_fetch"] + assert calls[0][1]["search_queries"] == [ + "Python asyncio official documentation" + ] + assert calls[1][1]["urls"] == ["https://docs.python.org/3/library/asyncio.html"] + assert calls[0][1]["session_id"] == calls[1][1]["session_id"] + assert "Python asyncio documentation" in capsys.readouterr().out + assert closed == [True] + + +def test_example_rejects_empty_content() -> None: + """An empty response must not look like a successful search.""" + with pytest.raises(RuntimeError, match="returned no text content"): + example.print_result("web_search", McpToolResult()) From ac2fee4b7a5af9b11515bd5dbc6f11f393220d79 Mon Sep 17 00:00:00 2001 From: georgeatparallel Date: Thu, 8 Oct 2026 10:19:56 +0000 Subject: [PATCH 2/4] fix(mcp): load Parallel tools in native agent conversations --- demos/parallel_search/README.md | 42 ++- demos/parallel_search/__main__.py | 16 +- src/server/agent_server.py | 15 +- src/server/mcp_runtime.py | 4 +- tests/server/test_mcp_runtime.py | 35 +- tests/server/test_parallel_search_mcp.py | 357 +++++++++++++++++++++ tests/test_mcp_auth_flow.py | 4 +- tests/test_mcp_instructions_live_wiring.py | 4 +- tests/test_parallel_search_example.py | 15 +- 9 files changed, 476 insertions(+), 16 deletions(-) create mode 100644 tests/server/test_parallel_search_mcp.py diff --git a/demos/parallel_search/README.md b/demos/parallel_search/README.md index d57015169..b778b45d7 100644 --- a/demos/parallel_search/README.md +++ b/demos/parallel_search/README.md @@ -33,7 +33,7 @@ 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 its official page. It reuses one session ID for +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 @@ -46,3 +46,43 @@ these commands. To add the server to your usual configuration, merge the 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. diff --git a/demos/parallel_search/__main__.py b/demos/parallel_search/__main__.py index c434f2634..d74968081 100644 --- a/demos/parallel_search/__main__.py +++ b/demos/parallel_search/__main__.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import re import uuid from src.services.mcp.client import McpClient @@ -10,7 +11,7 @@ from src.services.mcp.types import ConnectedMCPServer, McpToolResult -def print_result(name: str, result: McpToolResult) -> None: +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}") @@ -20,6 +21,15 @@ def print_result(name: str, result: McpToolResult) -> None: 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: @@ -41,7 +51,7 @@ async def main() -> None: f"Expected web_search and web_fetch, got {sorted(tools)}" ) session_id = str(uuid.uuid4()) - print_result( + search_text = print_result( "web_search", await client.call_tool( "web_search", @@ -57,7 +67,7 @@ async def main() -> None: await client.call_tool( "web_fetch", { - "urls": ["https://docs.python.org/3/library/asyncio.html"], + "urls": [select_url(search_text)], "objective": "Explain what asyncio is used for", "session_id": session_id, }, diff --git a/src/server/agent_server.py b/src/server/agent_server.py index 93eeb3a47..d336bc7a8 100644 --- a/src/server/agent_server.py +++ b/src/server/agent_server.py @@ -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) diff --git a/src/server/mcp_runtime.py b/src/server/mcp_runtime.py index 8972d7aa7..09b9dc769 100644 --- a/src/server/mcp_runtime.py +++ b/src/server/mcp_runtime.py @@ -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 @@ -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 diff --git a/tests/server/test_mcp_runtime.py b/tests/server/test_mcp_runtime.py index 6251aa1c3..e60da0ca1 100644 --- a/tests/server/test_mcp_runtime.py +++ b/tests/server/test_mcp_runtime.py @@ -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: @@ -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: @@ -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 diff --git a/tests/server/test_parallel_search_mcp.py b/tests/server/test_parallel_search_mcp.py new file mode 100644 index 000000000..dd9665e7a --- /dev/null +++ b/tests/server/test_parallel_search_mcp.py @@ -0,0 +1,357 @@ +"""Native agent-server MCP wiring, with only model inference controlled. + +The default run uses a scripted HTTP peer. CLAWCODEX_TEST_PARALLEL_LIVE=1 uses +real anonymous Parallel search/fetch through the same loader, SDK transport, +registry, permissions and query loop. This proves wiring, not model quality. +""" + +from __future__ import annotations + +import asyncio +import json +import os +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import httpx +import pytest + +from demos.parallel_search.__main__ import select_url +from src.providers.base import ChatResponse +from src.server.agent_server import AgentServerConfig, make_spawn_agent + +SEARCH = "mcp__parallel-search__web_search" +FETCH = "mcp__parallel-search__web_fetch" + + +def _result_text(messages: list[Any]) -> str: + for message in reversed(messages): + message = message if isinstance(message, dict) else message.to_dict() + content = message.get("content") + if isinstance(content, list): + for block in reversed(content): + if isinstance(block, dict) and block.get("type") == "tool_result": + assert not block.get("is_error"), content + value = block.get("content", "") + if isinstance(value, list): + return "\n".join(b.get("text", "") for b in value) + return str(value) + raise AssertionError("Native loop did not feed tool output to the provider") + + +@pytest.mark.asyncio +@pytest.mark.parametrize("allow", [True, False]) +async def test_native_parallel_conversation(monkeypatch, tmp_path, allow) -> None: + """Discover, ask permission, search, choose a result URL, fetch and answer.""" + live = os.environ.get("CLAWCODEX_TEST_PARALLEL_LIVE") == "1" + if live and not allow: + pytest.skip("The offline run covers denial without spending a live request") + demo = Path(__file__).resolve().parents[2] / "demos" / "parallel_search" + config_dir = tmp_path / "config" + config_dir.mkdir() + config_bytes = (demo / "config.json").read_bytes() + config = json.loads(config_bytes) + config["mcpServers"]["invalid-server"] = {"type": "http"} + (config_dir / "config.json").write_text(json.dumps(config)) + (tmp_path / ".mcp.json").write_text( + json.dumps( + { + "mcpServers": { + "unapproved-project": config["mcpServers"]["parallel-search"], + } + } + ) + ) + monkeypatch.setenv("CLAWCODEX_CONFIG_DIR", str(config_dir)) + monkeypatch.setenv("CLAWCODEX_MANAGED_CONFIG_DIR", str(tmp_path / "managed")) + for name in list(os.environ): + if any(part in name for part in ("API_KEY", "AUTH_TOKEN", "ACCESS_TOKEN")): + monkeypatch.delenv(name) + monkeypatch.chdir(tmp_path) + from src.bootstrap.state import _STATE + + # Exercise interactive project approval, as on the TUI backend. + monkeypatch.setattr(_STATE, "is_interactive", True) + requests: list[dict[str, Any]] = [] + state: dict[str, Any] = {"turns": 0, "permissions": [], "selected_url": None} + + def peer(request: httpx.Request) -> httpx.Response: + if request.method != "POST": + return httpx.Response(200 if request.method == "DELETE" else 405) + rpc = json.loads(request.content) + method = rpc["method"] + if "id" not in rpc: + return httpx.Response(202) + if method == "initialize": + result = { + "protocolVersion": "2024-11-05", + "capabilities": {"tools": {}}, + "serverInfo": {"name": "controlled-http-peer", "version": "1"}, + } + elif method == "tools/list": + result = { + "tools": [ + { + "name": name, + "description": name, + "inputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": True, + }, + } + for name in ("web_search", "web_fetch") + ] + } + elif method == "tools/call": + params = rpc["params"] + if params["name"] == "web_search": + text = json.dumps( + {"results": [{"url": "https://example.org/selected-page"}]} + ) + else: + assert params["arguments"]["urls"] == [ + "https://example.org/selected-page" + ] + text = "Fetched fixture: asyncio provides concurrent code using async/await." + result = {"content": [{"type": "text", "text": text}], "isError": False} + else: + raise AssertionError(method) + return httpx.Response( + 200, json={"jsonrpc": "2.0", "id": rpc["id"], "result": result} + ) + + original_send = httpx.AsyncClient.send + + async def observed_send(client, request, **kwargs): + rpc = json.loads(request.content) if request.method == "POST" else {} + response = await original_send(client, request, **kwargs) + requests.append( + { + "at": datetime.now(timezone.utc).isoformat(), + "url": str(request.url), + "method": request.method, + "user_agent": request.headers.get("user-agent"), + "authorization_present": "authorization" in request.headers, + "rpc_method": rpc.get("method"), + "params": rpc.get("params"), + "status": response.status_code, + "retry_after": response.headers.get("retry-after"), + } + ) + assert response.status_code != 429, requests[-1] + return response + + monkeypatch.setattr(httpx.AsyncClient, "send", observed_send) + if not live: + from src.services.mcp.fetch_wrappers import build_mcp_timeout + + def client_factory(*, headers=None): + # Only the HTTP peer is controlled; the SDK and native caller stay real. + return httpx.AsyncClient( + headers=headers, + timeout=build_mcp_timeout(), + transport=httpx.MockTransport(peer), + ) + + monkeypatch.setattr( + "src.services.mcp.transport.build_mcp_http_client", client_factory + ) + + class ResultDependentProvider: + """Controlled model fixture: each decision depends on native tool output.""" + + def __init__(self, api_key=None, base_url=None, model=None): + self.model = "controlled-wiring-fixture" + + def chat_stream_response(self, *args, **kwargs): + raise NotImplementedError + + def chat(self, messages, tools=None, **kwargs): + state["turns"] += 1 + if state["turns"] == 1: + assert "ToolSearch" in json.dumps(tools) + name = "ToolSearch" + args = {"query": "mcp__parallel-search__", "max_results": 2} + elif state["turns"] == 2: + assert SEARCH in json.dumps(tools) and FETCH in json.dumps(tools) + args = { + "objective": "Find official Python asyncio documentation", + "search_queries": ["Python asyncio official documentation"], + "session_id": "native-agent-wiring-test", + } + name = SEARCH + elif not allow: + return ChatResponse( + content="Permission denied; no search executed.", + model=self.model, + usage={}, + finish_reason="stop", + ) + elif state["turns"] == 3: + state["search_text"] = _result_text(messages) + state["selected_url"] = select_url(state["search_text"]) + args = { + "urls": [state["selected_url"]], + "objective": "Explain what asyncio is used for", + "session_id": "native-agent-wiring-test", + } + name = FETCH + else: + state["fetch_text"] = _result_text(messages) + assert "asyncio" in state["fetch_text"].lower() + return ChatResponse( + content="From fetched content: " + state["fetch_text"][:500], + model=self.model, + usage={}, + finish_reason="stop", + ) + return ChatResponse( + content="", + model=self.model, + usage={}, + finish_reason="tool_use", + tool_uses=[ + {"id": f"call{state['turns']}", "name": name, "input": args} + ], + ) + + # Explicit local-provider selection uses the native no-key credential rule. + # No production defaults, registry, loader, tool wrappers or loop are patched. + monkeypatch.setattr( + "src.providers.get_provider_class", lambda name: ResultDependentProvider + ) + spawn = make_spawn_agent( + AgentServerConfig(provider_name="ollama", permission_mode="default") + ) + handle = await spawn("parallel_native_test", str(tmp_path), None) + gen = handle.messages_from_agent() + frames = [] + try: + for _ in range(30): + frame = await asyncio.wait_for(gen.__anext__(), 40) + frames.append(frame) + if frame.get("subtype") == "init": + names = {tool["name"] for tool in frame["tools"]} + assert {SEARCH, FETCH}.issubset(names), frame + assert not any("unapproved-project" in n for n in names) + notices = [ + f.get("message", "") for f in frames if f.get("subtype") == "status" + ] + assert any("invalid-server" in n for n in notices), notices + assert any("unapproved-project" in n for n in notices), notices + assert frame["permission_mode"] == "default", frame + break + else: + raise AssertionError(frames) + await handle.send_to_agent( + { + "type": "user", + "message": { + "role": "user", + "content": "Search for Python asyncio, fetch a returned URL, and explain it.", + }, + } + ) + for _ in range(80): + frame = await asyncio.wait_for(gen.__anext__(), 80) + frames.append(frame) + if frame.get("type") == "control_request": + request = frame["request"] + assert request["subtype"] == "can_use_tool", frame + state["permissions"].append(request["tool_name"]) + # Prove the call has not run before the explicit approval. + assert ( + len([r for r in requests if r["rpc_method"] == "tools/call"]) + == len(state["permissions"]) - 1 + ) + await handle.send_to_agent( + { + "type": "control_response", + "response": { + "subtype": "success", + "request_id": frame["request_id"], + "response": {"behavior": "allow" if allow else "deny"}, + }, + } + ) + if frame.get("type") == "result": + assert not frame.get("is_error"), frame + break + else: + raise AssertionError("No final native agent result") + calls = [r for r in requests if r["rpc_method"] == "tools/call"] + if allow: + assert state["permissions"] == [SEARCH, FETCH] + assert state["turns"] == 4 + assert [r["params"]["name"] for r in calls] == ["web_search", "web_fetch"] + assert calls[1]["params"]["arguments"]["urls"] == [state["selected_url"]] + assert "From fetched content:" in json.dumps(frames) + assert state["fetch_text"][:200] in str(frame["result"]) + else: + assert state["permissions"] == [SEARCH] + assert calls == [] + expected_ua = json.loads(config_bytes)["mcpServers"]["parallel-search"][ + "headers" + ]["User-Agent"] + assert requests and all(r["user_agent"] == expected_ua for r in requests) + assert all(r["url"] == "https://search.parallel.ai/mcp" for r in requests) + assert not any(r["authorization_present"] for r in requests) + assert not list(config_dir.glob("*token*")) + finally: + await handle.shutdown() + await gen.aclose() + evidence = os.environ.get("CLAWCODEX_PARALLEL_EVIDENCE") + if evidence: + Path(evidence).write_text( + json.dumps( + { + "live": live, + "configuration": json.loads(config_bytes), + "caller": "make_spawn_agent -> McpRuntime -> loader/SDK -> registry/permissions/query loop", + "fixture": "ResultDependentProvider; proves wiring, not production model quality", + "state": state, + "requests": requests, + "frames": frames, + }, + indent=2, + default=str, + ) + ) + + +@pytest.mark.asyncio +async def test_native_config_errors_without_tools(monkeypatch, tmp_path) -> None: + """Surface invalid-only configuration even when MCP startup returns False.""" + from tests.server.test_agent_server_e2e import _TextProvider + + (tmp_path / "config.json").write_text( + json.dumps( + { + "mcpServers": {"invalid-only": {"type": "http"}}, + } + ) + ) + monkeypatch.setenv("CLAWCODEX_CONFIG_DIR", str(tmp_path)) + monkeypatch.setenv("CLAWCODEX_MANAGED_CONFIG_DIR", str(tmp_path / "managed")) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr("src.providers.get_provider_class", lambda name: _TextProvider) + handle = await make_spawn_agent(AgentServerConfig(provider_name="ollama"))( + "invalid_mcp_test", str(tmp_path), None + ) + gen = handle.messages_from_agent() + frames = [] + try: + for _ in range(30): + frame = await asyncio.wait_for(gen.__anext__(), 10) + frames.append(frame) + if frame.get("subtype") == "init": + assert not any(t["name"].startswith("mcp__") for t in frame["tools"]) + assert any("invalid-only" in f.get("message", "") for f in frames) + break + else: + raise AssertionError(frames) + finally: + await handle.shutdown() + await gen.aclose() diff --git a/tests/test_mcp_auth_flow.py b/tests/test_mcp_auth_flow.py index a3d0949c6..c207045d8 100644 --- a/tests/test_mcp_auth_flow.py +++ b/tests/test_mcp_auth_flow.py @@ -58,7 +58,7 @@ async def list_tools(self): return [_tool()] async def close(self): pass monkeypatch.setattr("src.services.mcp.config.get_all_mcp_configs", - lambda: {"gh": _scoped()}) + lambda: ({"gh": _scoped()}, [])) monkeypatch.setattr("src.services.mcp.client.McpClient", _FakeClient) if auth is not None: monkeypatch.setattr(mod, "McpAuthProvider", lambda: auth, raising=False) @@ -104,7 +104,7 @@ async def list_tools(self): return [_tool("do_thing")] async def close(self): pass monkeypatch.setattr("src.services.mcp.config.get_all_mcp_configs", - lambda: {"gh": _scoped()}) + lambda: ({"gh": _scoped()}, [])) monkeypatch.setattr("src.services.mcp.client.McpClient", _FakeClient) monkeypatch.setattr("src.services.mcp.auth_provider.McpAuthProvider", lambda: auth) diff --git a/tests/test_mcp_instructions_live_wiring.py b/tests/test_mcp_instructions_live_wiring.py index ce4464944..75f15c7f0 100644 --- a/tests/test_mcp_instructions_live_wiring.py +++ b/tests/test_mcp_instructions_live_wiring.py @@ -113,7 +113,7 @@ async def list_tools(self): scoped = SimpleNamespace(config=SimpleNamespace(enabled=True)) monkeypatch.setattr( - "src.services.mcp.config.get_all_mcp_configs", lambda: {"srv": scoped} + "src.services.mcp.config.get_all_mcp_configs", lambda: ({"srv": scoped}, []) ) monkeypatch.setattr("src.services.mcp.client.McpClient", _FakeClient) # C4: start() now constructs a real McpAuthProvider (which touches the @@ -151,7 +151,7 @@ async def list_tools(self): scoped = SimpleNamespace(config=SimpleNamespace(enabled=True)) monkeypatch.setattr( - "src.services.mcp.config.get_all_mcp_configs", lambda: {"srv": scoped} + "src.services.mcp.config.get_all_mcp_configs", lambda: ({"srv": scoped}, []) ) monkeypatch.setattr("src.services.mcp.client.McpClient", _FakeClient) # C4: start() now constructs a real McpAuthProvider (which touches the diff --git a/tests/test_parallel_search_example.py b/tests/test_parallel_search_example.py index 433ade81e..bae6d7f2f 100644 --- a/tests/test_parallel_search_example.py +++ b/tests/test_parallel_search_example.py @@ -60,7 +60,12 @@ async def list_tools(self): async def call_tool(self, name, arguments): calls.append((name, arguments)) return McpToolResult( - content=[{"type": "text", "text": "Python asyncio documentation"}], + content=[ + { + "type": "text", + "text": "Python asyncio documentation https://example.org/result-selected", + } + ], is_error=tool_error, ) @@ -78,7 +83,7 @@ async def close(self): assert calls[0][1]["search_queries"] == [ "Python asyncio official documentation" ] - assert calls[1][1]["urls"] == ["https://docs.python.org/3/library/asyncio.html"] + assert calls[1][1]["urls"] == ["https://example.org/result-selected"] assert calls[0][1]["session_id"] == calls[1][1]["session_id"] assert "Python asyncio documentation" in capsys.readouterr().out assert closed == [True] @@ -88,3 +93,9 @@ def test_example_rejects_empty_content() -> None: """An empty response must not look like a successful search.""" with pytest.raises(RuntimeError, match="returned no text content"): example.print_result("web_search", McpToolResult()) + + +def test_example_rejects_search_without_url() -> None: + """Never fall back to a hardcoded page when search has no result URL.""" + with pytest.raises(RuntimeError, match="no HTTP"): + example.select_url("No search results") From 38e7e6a7c13e7e34bdc88f71191a61103575d4bc Mon Sep 17 00:00:00 2001 From: georgeatparallel Date: Thu, 8 Oct 2026 10:23:47 +0000 Subject: [PATCH 3/4] style(mcp): keep example imports compatible with quality checks --- tests/test_parallel_search_example.py | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/tests/test_parallel_search_example.py b/tests/test_parallel_search_example.py index bae6d7f2f..8798eb6b4 100644 --- a/tests/test_parallel_search_example.py +++ b/tests/test_parallel_search_example.py @@ -4,15 +4,9 @@ import pytest +import src.services.mcp.types as mcp_types from demos.parallel_search import __main__ as example from src.services.mcp.config import get_all_mcp_configs -from src.services.mcp.types import ( - ConnectedMCPServer, - McpToolResult, - McpToolSchema, - ServerCapabilities, - ServerInfo, -) @pytest.mark.asyncio @@ -44,22 +38,22 @@ class Client: async def connect(self, name, selected): assert name == "parallel-search" assert selected == config - return ConnectedMCPServer( + return mcp_types.ConnectedMCPServer( name=name, config=selected, - capabilities=ServerCapabilities(tools=True), - server_info=ServerInfo(name="parallel", version="test"), + capabilities=mcp_types.ServerCapabilities(tools=True), + server_info=mcp_types.ServerInfo(name="parallel", version="test"), ) async def list_tools(self): return [ - McpToolSchema(name="web_search", input_schema={}), - McpToolSchema(name="web_fetch", input_schema={}), + mcp_types.McpToolSchema(name="web_search", input_schema={}), + mcp_types.McpToolSchema(name="web_fetch", input_schema={}), ] async def call_tool(self, name, arguments): calls.append((name, arguments)) - return McpToolResult( + return mcp_types.McpToolResult( content=[ { "type": "text", @@ -92,7 +86,7 @@ async def close(self): def test_example_rejects_empty_content() -> None: """An empty response must not look like a successful search.""" with pytest.raises(RuntimeError, match="returned no text content"): - example.print_result("web_search", McpToolResult()) + example.print_result("web_search", mcp_types.McpToolResult()) def test_example_rejects_search_without_url() -> None: From 04b077729084641a4f4e5fc81cc87318edab4aeb Mon Sep 17 00:00:00 2001 From: georgeatparallel Date: Thu, 8 Oct 2026 10:24:19 +0000 Subject: [PATCH 4/4] test(mcp): retain runtime diagnostics in init-order fixture --- tests/test_mcp_instructions_init_ordering.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_mcp_instructions_init_ordering.py b/tests/test_mcp_instructions_init_ordering.py index e7a6a6d33..985057bdb 100644 --- a/tests/test_mcp_instructions_init_ordering.py +++ b/tests/test_mcp_instructions_init_ordering.py @@ -41,6 +41,7 @@ def _capture_prompt(*a, **k): class _FakeRuntime: def __init__(self): + self.config_errors = [] self.server_infos = [_srv("enabled"), _srv("disabled")] self.tools = [SimpleNamespace(name="mcp__enabled__t")] self.clients = {"enabled": object(), "disabled": object()}