"""End-to-end smoke tests for the MCP Streamable-HTTP endpoint. These exist to plug a CI gap that let a real production bug ship: the PAT lifecycle tests proved tokens worked, the auth path proved tokens unlocked the API, but nothing exercised the MCP router itself. As a result, FastMCP's default ``streamable_http_path="/mcp"`` (combined with ``app.mount("/api/v1/mcp", …)``) made the actual MCP route live at ``/api/v1/mcp/mcp`` and ``POST /api/v1/mcp/`` returned a plain-text ``404 Not Found`` for every client. Codex caught this from the outside on 2026-04-27. The tests below assert on the *external* contract a third-party MCP client sees: * Unauthenticated calls return 401 + JSON envelope (not text/plain). * Wrong credential type (JWT instead of PAT) returns 401. * A valid PAT reaches the JSON-RPC dispatcher. * ``initialize`` returns a well-formed JSON-RPC response with ``serverInfo.name == "helix"``. * ``tools/list`` returns a non-trivial number of tools, including the documented ``whoami`` smoke entry. * ``tools/call whoami`` returns the calling user's profile. Streamable HTTP responses are framed as Server-Sent Events (``text/event-stream``); we extract the JSON payload from the first ``data:`` line. """ from __future__ import annotations import json import re import uuid import pytest from sqlalchemy import text from app.auth.models import AuthUserRole from app.core.security import create_access_token from app.identity.person_models import IdentityPerson async def _make_employee(db_session): user_id = uuid.uuid4() user = IdentityPerson( id=user_id, work_email="mcp-user@example.com", display_name="MCP User", employment_status="active", source_system="manual", hris_person_id=f"manual-{user_id}", ) db_session.add(user) await db_session.flush() role_id_row = await db_session.execute( text("SELECT id FROM auth_roles WHERE name = 'employee'") ) role_id = role_id_row.scalar_one() if isinstance(role_id, str): role_id = uuid.UUID(role_id) db_session.add(AuthUserRole(user_id=user_id, role_id=role_id)) await db_session.flush() return user async def _mint_pat(client, user_id) -> str: jwt = create_access_token(str(user_id), ["employee"]) r = await client.post( "/api/v1/auth/me/tokens", json={"name": "mcp-test", "expires_in_days": 30}, headers={"Authorization": f"Bearer {jwt}"}, ) assert r.status_code == 200, r.text return r.json()["data"]["plaintext"] def _parse_sse(body: str) -> dict: """Extract the JSON payload from a Streamable-HTTP SSE response. Streamable HTTP returns ``event: message\\ndata: \\n\\n`` — we split on the first ``data:`` line. """ m = re.search(r"^data:\s*(\{.*\})\s*$", body, re.MULTILINE) assert m, f"no SSE data line found in body: {body!r}" return json.loads(m.group(1)) _MCP_HEADERS = { "Content-Type": "application/json", "Accept": "application/json, text/event-stream", } @pytest.mark.asyncio async def test_mcp_unauthenticated_returns_json_401(client): r = await client.post("/api/v1/mcp/", headers=_MCP_HEADERS, json={}) assert r.status_code == 401, r.text body = r.json() assert body["success"] is False assert body["errors"][0]["code"] == "UNAUTHORIZED" @pytest.mark.asyncio async def test_mcp_jwt_rejected(client, db_session): """Web-session JWTs are NOT accepted on the MCP endpoint. The endpoint accepts exactly two credential kinds: ``helix_pat_…`` PATs and purpose-minted MCP OAuth access tokens (``type=mcp_access``, audience-bound). A regular web-session access JWT (``type=access``, no audience) is neither — it must still be rejected with 401, or a stolen browser JWT could silently spawn MCP traffic. The 401 also carries the ``WWW-Authenticate`` discovery challenge. """ user = await _make_employee(db_session) jwt = create_access_token(str(user.id), ["employee"]) r = await client.post( "/api/v1/mcp/", headers={**_MCP_HEADERS, "Authorization": f"Bearer {jwt}"}, json={}, ) assert r.status_code == 401, r.text assert r.json()["errors"][0]["code"] == "UNAUTHORIZED" assert "resource_metadata=" in r.headers.get("www-authenticate", "") @pytest.mark.asyncio async def test_mcp_initialize_returns_server_info(client, db_session): """``POST /api/v1/mcp/`` with a valid PAT and an ``initialize`` JSON-RPC request returns serverInfo. Regression gate for Codex's 2026-04-27 finding that the route was missing entirely (404). """ user = await _make_employee(db_session) pat = await _mint_pat(client, user.id) r = await client.post( "/api/v1/mcp/", headers={**_MCP_HEADERS, "Authorization": f"Bearer {pat}"}, json={ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "smoke", "version": "0"}, }, }, ) assert r.status_code == 200, r.text payload = _parse_sse(r.text) assert payload["jsonrpc"] == "2.0" assert payload["id"] == 1 assert payload["result"]["serverInfo"]["name"] == "helix" # tools capability is required for any tool call to work assert "tools" in payload["result"]["capabilities"] @pytest.mark.asyncio async def test_mcp_tools_list_includes_whoami(client, db_session): """``tools/list`` returns a non-trivial number of tools and the documented ``whoami`` smoke entry. We don't assert an exact count so adding tools doesn't churn this test, but a regression that drops half the registrations should still fail. """ user = await _make_employee(db_session) pat = await _mint_pat(client, user.id) r = await client.post( "/api/v1/mcp/", headers={**_MCP_HEADERS, "Authorization": f"Bearer {pat}"}, json={"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}, ) assert r.status_code == 200, r.text payload = _parse_sse(r.text) tools = payload["result"]["tools"] assert len(tools) >= 30, f"expected ≥30 tools, got {len(tools)}" names = [t["name"] for t in tools] assert "whoami" in names assert "list_my_assets" in names assert "list_cloud_servers" in names @pytest.mark.asyncio async def test_mcp_whoami_returns_calling_user(client, db_session): user = await _make_employee(db_session) pat = await _mint_pat(client, user.id) r = await client.post( "/api/v1/mcp/", headers={**_MCP_HEADERS, "Authorization": f"Bearer {pat}"}, json={ "jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "whoami", "arguments": {}}, }, ) assert r.status_code == 200, r.text payload = _parse_sse(r.text) assert payload["result"]["isError"] is False text_block = payload["result"]["content"][0]["text"] body = json.loads(text_block) assert body["email"] == user.work_email assert body["id"] == str(user.id) # Production: asked 「我的 github id 是什么」 the assistant answered "not in # the identity fields" and pointed at a settings page, while ``github_id`` # was populated on the person row — ``whoami`` simply never returned it. # A populated column that no tool exposes is indistinguishable from missing # data, so the self-service fields employees ask about are pinned here. for field in ( "github_id", "employee_no", "hire_date", "work_location_name", "company_name", "cost_center", "email_aliases", ): assert field in body, f"whoami no longer exposes {field}" # Personal contact details stay out on purpose: every answer lands in a # stored, administrator-readable transcript. assert "mobile_phone" not in body assert "personal_email" not in body