"""MCP client tests — wire shape, headers, error mapping. We use ``respx`` (already a Helix dev dep) to mock the HTTP layer. This keeps the tests fast and lets us assert on: * Path: ``/api/v1/mcp/`` (trailing slash, NOT ``/api/v1/mcp/mcp``). * Method: POST. * Headers: ``Content-Type: application/json``, ``Accept: application/json, text/event-stream``, ``Authorization: Bearer helix_pat_...``. * JSON-RPC body shapes for ``initialize`` / ``tools/list`` / ``tools/call``. * Status-code → exit-code mapping. """ from __future__ import annotations import json import httpx import pytest import respx from helix_cli.config import PAT_PREFIX, CliConfig from helix_cli.http_client import CliRestError from helix_cli.mcp_client import McpClient, McpJsonRpcError FAKE_PAT = PAT_PREFIX + "abcdefghijklmnopqrstuvwxyz012345" BASE_URL = "https://helix.example.test" def _config() -> CliConfig: return CliConfig( base_url=BASE_URL, token=FAKE_PAT, base_url_source="test", token_source="test", ) def _sse(payload: dict) -> tuple[bytes, dict]: body = f"event: message\ndata: {json.dumps(payload)}\n\n" headers = {"content-type": "text/event-stream"} return body.encode("utf-8"), headers # ── headers + path ────────────────────────────────────────────────── @respx.mock def test_initialize_uses_canonical_path_and_headers() -> None: payload, hdrs = _sse({ "jsonrpc": "2.0", "id": "x", "result": {"serverInfo": {"name": "helix"}, "capabilities": {"tools": {}}, "protocolVersion": "2025-06-18"}, }) route = respx.post(f"{BASE_URL}/api/v1/mcp/").mock( return_value=httpx.Response(200, content=payload, headers=hdrs) ) client = McpClient(_config()) result = client.initialize() assert route.called sent = route.calls[0].request # Critical: trailing slash on the mount point assert sent.url.path == "/api/v1/mcp/" assert sent.method == "POST" assert sent.headers["authorization"] == f"Bearer {FAKE_PAT}" assert sent.headers["content-type"].startswith("application/json") accept = sent.headers["accept"] assert "application/json" in accept assert "text/event-stream" in accept sent_body = json.loads(sent.content.decode("utf-8")) assert sent_body["method"] == "initialize" assert sent_body["jsonrpc"] == "2.0" assert "id" in sent_body assert sent_body["params"]["protocolVersion"] == "2025-06-18" assert result["serverInfo"]["name"] == "helix" @respx.mock def test_tools_list_payload_shape() -> None: tools_payload = { "jsonrpc": "2.0", "id": "x", "result": {"tools": [{"name": "whoami", "description": "yo"}]}, } body, hdrs = _sse(tools_payload) route = respx.post(f"{BASE_URL}/api/v1/mcp/").mock( return_value=httpx.Response(200, content=body, headers=hdrs) ) client = McpClient(_config()) tools = client.list_tools() sent_body = json.loads(route.calls[0].request.content.decode("utf-8")) assert sent_body["method"] == "tools/list" assert tools == [{"name": "whoami", "description": "yo"}] @respx.mock def test_tools_call_unwraps_text_block_as_json() -> None: tool_payload = {"id": "u1", "email": "u@example.com"} response_payload = { "jsonrpc": "2.0", "id": "x", "result": { "isError": False, "content": [{"type": "text", "text": json.dumps(tool_payload)}], }, } body, hdrs = _sse(response_payload) route = respx.post(f"{BASE_URL}/api/v1/mcp/").mock( return_value=httpx.Response(200, content=body, headers=hdrs) ) client = McpClient(_config()) result = client.call_tool("whoami", {"foo": "bar"}) sent_body = json.loads(route.calls[0].request.content.decode("utf-8")) assert sent_body["method"] == "tools/call" assert sent_body["params"]["name"] == "whoami" assert sent_body["params"]["arguments"] == {"foo": "bar"} assert result.payload == tool_payload assert result.is_error is False # ── status-code mapping ───────────────────────────────────────────── @respx.mock def test_401_maps_to_exit_2() -> None: respx.post(f"{BASE_URL}/api/v1/mcp/").mock( return_value=httpx.Response( 401, json={ "success": False, "data": None, "errors": [{"code": "UNAUTHORIZED", "message": "invalid token"}], }, ) ) client = McpClient(_config()) with pytest.raises(CliRestError) as exc: client.initialize() assert exc.value.exit_code == 2 assert "invalid token" in str(exc.value) @respx.mock def test_403_maps_to_exit_3() -> None: respx.post(f"{BASE_URL}/api/v1/mcp/").mock( return_value=httpx.Response( 403, json={ "success": False, "data": None, "errors": [{"code": "FORBIDDEN", "message": "no scope"}], }, ) ) client = McpClient(_config()) with pytest.raises(CliRestError) as exc: client.initialize() assert exc.value.exit_code == 3 @respx.mock def test_jsonrpc_error_maps_to_exit_4() -> None: body, hdrs = _sse({ "jsonrpc": "2.0", "id": "x", "error": {"code": -32601, "message": "method not found"}, }) respx.post(f"{BASE_URL}/api/v1/mcp/").mock( return_value=httpx.Response(200, content=body, headers=hdrs) ) client = McpClient(_config()) with pytest.raises(McpJsonRpcError) as exc: client.list_tools() assert exc.value.exit_code == 4 assert exc.value.rpc_code == -32601 @respx.mock def test_tool_iserror_maps_to_exit_4() -> None: body, hdrs = _sse({ "jsonrpc": "2.0", "id": "x", "result": { "isError": True, "content": [{"type": "text", "text": "asset not found"}], }, }) respx.post(f"{BASE_URL}/api/v1/mcp/").mock( return_value=httpx.Response(200, content=body, headers=hdrs) ) client = McpClient(_config()) with pytest.raises(CliRestError) as exc: client.call_tool("get_asset", {"id": "x"}) assert exc.value.exit_code == 4 assert "asset not found" in str(exc.value) @respx.mock def test_500_maps_to_exit_5() -> None: respx.post(f"{BASE_URL}/api/v1/mcp/").mock( return_value=httpx.Response(500) ) client = McpClient(_config()) with pytest.raises(CliRestError) as exc: client.initialize() assert exc.value.exit_code == 5 @respx.mock def test_429_carries_retry_after() -> None: respx.post(f"{BASE_URL}/api/v1/mcp/").mock( return_value=httpx.Response(429, headers={"retry-after": "12"}) ) client = McpClient(_config()) with pytest.raises(CliRestError) as exc: client.initialize() assert exc.value.exit_code == 4 assert "12" in str(exc.value) @respx.mock def test_413_payload_too_large_maps_to_exit_4() -> None: respx.post(f"{BASE_URL}/api/v1/mcp/").mock( return_value=httpx.Response(413) ) client = McpClient(_config()) with pytest.raises(CliRestError) as exc: client.initialize() assert exc.value.exit_code == 4 assert exc.value.code == "PAYLOAD_TOO_LARGE" def test_no_token_is_local_usage_error() -> None: cfg = CliConfig( base_url=BASE_URL, token=None, base_url_source="default", token_source="", ) with pytest.raises(CliRestError) as exc: McpClient(cfg) assert exc.value.exit_code == 1 def test_jwt_shaped_token_rejected() -> None: """A JWT-shaped string in the token slot must be rejected client-side so we don't even attempt to send it (the server would 401 anyway, but failing locally avoids a network round-trip and an audit row). """ cfg = CliConfig( base_url=BASE_URL, token="eyJhbGciOi.eyJzdWIiOi.signature", base_url_source="env", token_source="env", ) with pytest.raises(CliRestError) as exc: McpClient(cfg) assert exc.value.exit_code == 1 assert "personal access token" in str(exc.value) @respx.mock def test_application_json_response_also_supported() -> None: """Some MCP servers / Streamable-HTTP variants reply with plain JSON (no SSE framing). The client must accept either. """ respx.post(f"{BASE_URL}/api/v1/mcp/").mock( return_value=httpx.Response( 200, json={"jsonrpc": "2.0", "id": "x", "result": {"tools": []}}, ) ) client = McpClient(_config()) assert client.list_tools() == [] @respx.mock def test_unexpected_content_type_fails_cleanly() -> None: respx.post(f"{BASE_URL}/api/v1/mcp/").mock( return_value=httpx.Response( 200, content=b"oops", headers={"content-type": "text/html"}, ) ) client = McpClient(_config()) with pytest.raises(CliRestError) as exc: client.initialize() assert exc.value.exit_code == 5 assert "content-type" in str(exc.value).lower() @respx.mock def test_call_tool_serializes_arguments_correctly() -> None: """Nested / list arguments must round-trip without coercion.""" body, hdrs = _sse({ "jsonrpc": "2.0", "id": "x", "result": {"isError": False, "content": [{"type": "text", "text": "{}"}]}, }) route = respx.post(f"{BASE_URL}/api/v1/mcp/").mock( return_value=httpx.Response(200, content=body, headers=hdrs) ) client = McpClient(_config()) args = {"page": 2, "tags": ["a", "b"], "filters": {"status": "active"}} client.call_tool("list_my_assets", args) sent = json.loads(route.calls[0].request.content.decode("utf-8")) assert sent["params"]["arguments"] == args