"""The MCP endpoint: who it lets in, and what happens once they are in.""" import asyncio import uuid from collections.abc import Awaitable, Callable from datetime import timedelta from typing import Any import httpx import pytest from fastapi.testclient import TestClient from fluksio.core import security from fluksio.core.config import settings from fluksio.main import app MCP_HEADERS = {"Accept": "application/json, text/event-stream"} def mcp_token(user_id: uuid.UUID) -> str: return security.create_oauth_access_token( user_id, uuid.uuid4(), timedelta(minutes=5) ) @pytest.fixture def over_mcp(monkeypatch: pytest.MonkeyPatch): """Run a block of calls against a live MCP endpoint. The session manager and the requests have to share one event loop, so the whole exchange happens inside a single ``asyncio.run``. """ monkeypatch.setattr(settings, "MCP_ENABLED", True) from fluksio.mcp import http as mcp_http from fluksio.mcp import server as mcp_server # A FastMCP instance enters its session manager once, and another test in # the run may have built one already. monkeypatch.setattr(mcp_server.mcp, "_session_manager", None, raising=False) mcp_app = mcp_http.build_http_app(app) def run(block: Callable[[httpx.AsyncClient], Awaitable[Any]]) -> Any: async def main() -> Any: async with mcp_server.mcp.session_manager.run(): async with httpx.AsyncClient( transport=httpx.ASGITransport(app=mcp_app), base_url="http://api.localhost", ) as client: return await block(client) return asyncio.run(main()) return run def rpc(token: str | None, method: str, **params: Any) -> dict[str, Any]: headers = dict(MCP_HEADERS) if token: headers["Authorization"] = f"Bearer {token}" body: dict[str, Any] = {"jsonrpc": "2.0", "id": 1, "method": method} if params: body["params"] = params return {"json": body, "headers": headers} def test_an_unauthenticated_call_says_where_to_authenticate(over_mcp) -> None: async def block(client: httpx.AsyncClient) -> httpx.Response: return await client.post("/mcp", **rpc(None, "tools/list")) response = over_mcp(block) assert response.status_code == 401 assert "resource_metadata=" in response.headers.get("www-authenticate", "") def test_the_resource_metadata_names_the_authorization_server(over_mcp) -> None: async def block(client: httpx.AsyncClient) -> httpx.Response: return await client.get("/.well-known/oauth-protected-resource/mcp") document = over_mcp(block).json() # The SDK normalises the URL, so compare without the trailing slash. listed = [url.rstrip("/") for url in document["authorization_servers"]] assert settings.oauth_issuer in listed def test_a_browser_token_is_not_an_agent_token( over_mcp, superuser_token_headers: dict[str, str] ) -> None: session_token = superuser_token_headers["Authorization"].removeprefix("Bearer ") async def block(client: httpx.AsyncClient) -> httpx.Response: return await client.post("/mcp", **rpc(session_token, "tools/list")) # It validates perfectly well against the API; it is refused here because # it was issued for a person's session, not for an agent. assert over_mcp(block).status_code == 401 def test_an_agent_can_list_and_call_tools( over_mcp, client: TestClient, superuser_token_headers: dict[str, str] ) -> None: me = client.get( f"{settings.API_V1_STR}/users/me", headers=superuser_token_headers ).json() token = mcp_token(uuid.UUID(me["id"])) async def block(http: httpx.AsyncClient) -> tuple[Any, Any]: listed = await http.post("/mcp", **rpc(token, "tools/list")) called = await http.post( "/mcp", **rpc(token, "tools/call", name="list_flows", arguments={}), ) return listed, called listed, called = over_mcp(block) assert listed.status_code == 200 names = {tool["name"] for tool in listed.json()["result"]["tools"]} assert {"list_flows", "save_flow", "publish_flow", "run_flow"} <= names # The call reached the real API, carrying the agent's own token. assert called.status_code == 200 assert "error" not in called.json() assert called.json()["result"]["isError"] is False