A wheel whose top-level module is `app` collides with anything else in a user's venv, so the package that is about to be published takes the name it is published under. Only the Python package moves; the repo, the Docker WORKDIR and the compose project keep theirs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
124 lines
4.3 KiB
Python
124 lines
4.3 KiB
Python
"""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
|