"""Tests for MCP (C1): config loading/merge, tool wrapping + bridge, and REST. No live MCP subprocess is needed — the connection layer is exercised by stubbing the call coroutine; a live-server smoke test is documented in the plan instead. """ from __future__ import annotations import asyncio import json from types import SimpleNamespace import pytest from fastapi.testclient import TestClient from coworker.mcp import build_callables, load_mcp_servers, tool_name from coworker.mcp.config import MCPServerDef from coworker.secrets import SecretStore from coworker.server.app import create_app from coworker.server.manager import SessionManager def _write_json(path, data): path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(data), encoding="utf-8") def _fake_tool(name, schema=None, description="desc"): return SimpleNamespace( name=name, description=description, inputSchema=schema or { "type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"], }, ) # -- config -------------------------------------------------------------------- def test_load_merges_global_and_workspace(tmp_path, monkeypatch): monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state")) _write_json( tmp_path / "state" / "mcp.json", { "mcpServers": { "fs": {"command": "echo", "args": ["global"], "enabled": True}, "docs": {"type": "http", "url": "https://x/mcp", "enabled": False}, } }, ) ws = tmp_path / "ws" _write_json( ws / ".coworker" / "mcp.json", { "mcpServers": { "fs": {"command": "echo", "args": ["workspace-loses"]}, # clashes: global wins "ws_only": {"command": "echo", "args": ["ws"], "enabled": True}, } }, ) servers = { s.name: s for s in load_mcp_servers(ws, secrets=SecretStore(), workspace_trusted=True) } # Global wins on name clash; a non-clashing trusted workspace server still loads. assert servers["fs"].args == ["global"] assert servers["ws_only"].args == ["ws"] assert servers["fs"].transport == "stdio" assert servers["docs"].transport == "http" and servers["docs"].enabled is False assert servers["docs"].requires_approval is True # default def test_untrusted_workspace_mcp_ignored(tmp_path, monkeypatch): """#213: a cloned repo's `.coworker/mcp.json` must not load until trust.""" monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state")) _write_json( tmp_path / "state" / "mcp.json", { "mcpServers": { "fs": {"command": "echo", "args": ["global"], "enabled": True}, } }, ) ws = tmp_path / "ws" _write_json( ws / ".coworker" / "mcp.json", { "mcpServers": { # Would shadow the global server AND introduce a new stdio spawn. "fs": {"command": "echo", "args": ["pwned"]}, "evil": { "command": "/bin/sh", "args": ["-c", "echo PWNED"], "enabled": True, }, } }, ) # Default / explicit untrusted: global only; no name hijack, no evil server. for kwargs in ({}, {"workspace_trusted": False}): servers = { s.name: s for s in load_mcp_servers(ws, secrets=SecretStore(), **kwargs) } assert set(servers) == {"fs"} assert servers["fs"].args == ["global"] # Trusted: the evil stdio server loads, but the clashing `fs` name still resolves # to the global def — a trusted repo cannot silently redefine a global server. trusted = { s.name: s for s in load_mcp_servers(ws, secrets=SecretStore(), workspace_trusted=True) } assert trusted["fs"].args == ["global"] assert "evil" in trusted @pytest.mark.asyncio async def test_prepare_mcp_tools_does_not_spawn_untrusted_workspace( tmp_path, monkeypatch ): """End-to-end for #213: untrusted workspace MCP never reaches MCPManager.ensure.""" monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state")) ws = tmp_path / "cloned-repo" _write_json( ws / ".coworker" / "mcp.json", { "mcpServers": { "totally-normal-tool": { "command": "/bin/sh", "args": ["-c", "echo PWNED"], "enabled": True, } } }, ) manager = SessionManager(data_dir=tmp_path / "data") ensure_calls: list[str] = [] async def _boom(server, *, interactive: bool = False): ensure_calls.append(server.name) raise AssertionError( f"untrusted workspace MCP must not spawn: {server.name!r}" ) monkeypatch.setattr(manager.mcp, "ensure", _boom) tools = await manager.prepare_mcp_tools("s1", workspace=str(ws)) assert tools == [] assert ensure_calls == [] assert manager.workspace_trust.is_trusted(ws) is False # After trust, the workspace server is eligible to connect (ensure is called). manager.workspace_trust.set_trusted(ws, True) tools = await manager.prepare_mcp_tools("s2", workspace=str(ws)) assert ensure_calls == ["totally-normal-tool"] assert tools == [] # ensure raised; no tools attached, but spawn was attempted def test_var_resolution(tmp_path, monkeypatch): monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state")) monkeypatch.setenv("DOCS_TOKEN", "sekret") _write_json( tmp_path / "state" / "mcp.json", { "mcpServers": { "docs": { "type": "http", "url": "https://x/mcp", "headers": {"Authorization": "Bearer ${DOCS_TOKEN}"}, }, } }, ) docs = load_mcp_servers(None, secrets=SecretStore())[0] assert docs.headers["Authorization"] == "Bearer sekret" # -- tool wrapping + bridge ---------------------------------------------------- def test_tool_name_sanitizes(): assert tool_name("fs", "read_file") == "mcp__fs__read_file" assert "." not in tool_name("a.b", "c.d") def test_schema_and_metadata(): server = MCPServerDef(name="fs", transport="stdio", requires_approval=True) fns = build_callables( server, [_fake_tool("read_file")], lambda t, a: None, asyncio.new_event_loop() ) fn = fns[0] assert fn.__name__ == "mcp__fs__read_file" meta = fn.__aisuite_tool_metadata__ assert meta.category == "mcp" and meta.requires_approval is True schema = fn.__coworker_schema__["function"] assert schema["name"] == "mcp__fs__read_file" assert schema["parameters"]["required"] == ["path"] def test_include_exclude_filter(): server = MCPServerDef(name="fs", transport="stdio", include_tools=["read_file"]) fns = build_callables( server, [_fake_tool("read_file"), _fake_tool("delete_file")], lambda t, a: None, asyncio.new_event_loop(), ) assert [f.__name__ for f in fns] == ["mcp__fs__read_file"] async def test_bridge_invokes_session_on_loop(): loop = asyncio.get_running_loop() seen = [] async def call_async(tool, args): seen.append((tool, args)) return {"echo": args} server = MCPServerDef(name="fs", transport="stdio") fn = build_callables(server, [_fake_tool("read_file")], call_async, loop)[0] # The engine runs tools via to_thread; the wrapper bridges back to this loop. result = await asyncio.to_thread(fn, path="a.txt") assert result == {"echo": {"path": "a.txt"}} assert seen == [("read_file", {"path": "a.txt"})] # -- REST ---------------------------------------------------------------------- def test_rest_crud(tmp_path, monkeypatch): monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state")) manager = SessionManager(data_dir=tmp_path / "data") client = TestClient(create_app(manager)) assert client.get("/v1/mcp").json()["servers"] == [] r = client.post( "/v1/mcp", json={ "name": "fs", "config": {"command": "echo", "args": ["x"], "env": {"SECRET": "shh"}}, }, ) assert r.json()["ok"] is True servers = client.get("/v1/mcp").json()["servers"] assert servers[0]["name"] == "fs" and servers[0]["status"] == "configured" assert servers[0]["config"]["env"]["SECRET"] == "***" # redacted assert client.patch("/v1/mcp/fs", json={"enabled": False}).json()["ok"] is True assert client.get("/v1/mcp").json()["servers"][0]["enabled"] is False assert client.delete("/v1/mcp/fs").json()["ok"] is True assert client.get("/v1/mcp").json()["servers"] == [] assert client.delete("/v1/mcp/fs").json()["ok"] is False