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..b778b45d7 --- /dev/null +++ b/demos/parallel_search/README.md @@ -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. diff --git a/demos/parallel_search/__main__.py b/demos/parallel_search/__main__.py new file mode 100644 index 000000000..d74968081 --- /dev/null +++ b/demos/parallel_search/__main__.py @@ -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()) 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/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_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()} 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 new file mode 100644 index 000000000..8798eb6b4 --- /dev/null +++ b/tests/test_parallel_search_example.py @@ -0,0 +1,95 @@ +"""Exercise the opt-in example with the real loader and a scripted MCP client.""" + +from pathlib import Path + +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 + + +@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 mcp_types.ConnectedMCPServer( + name=name, + config=selected, + capabilities=mcp_types.ServerCapabilities(tools=True), + server_info=mcp_types.ServerInfo(name="parallel", version="test"), + ) + + async def list_tools(self): + return [ + 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 mcp_types.McpToolResult( + content=[ + { + "type": "text", + "text": "Python asyncio documentation https://example.org/result-selected", + } + ], + 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://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] + + +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", mcp_types.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")