"""What the MCP tools send, whatever an agent hands them.""" import asyncio import httpx from fluksio.mcp import server #: What the engine lists: the grandfathered `heating`, now keyed `warmth`, and #: the later flow that took its old key. LISTED = [{"id": "heating", "name": "warmth"}, {"id": "f_heat0000", "name": "heating"}] def _calls(*tools) -> list[str]: """What the tools sent past the flow listing, run against a recording engine.""" seen: list[str] = [] def record(request: httpx.Request) -> httpx.Response: if request.url.path == "/api/v1/flows/" and request.method == "GET": return httpx.Response(200, json={"data": LISTED, "count": len(LISTED)}) seen.append(f"{request.method} {request.url.raw_path.decode()}") return httpx.Response(200, json={}) async def calls() -> list: previous = server._client mock = httpx.AsyncClient( transport=httpx.MockTransport(record), base_url="http://test" ) server.set_client(mock) try: return [await tool() for tool in tools] finally: server.set_client(previous) await mock.aclose() answers = asyncio.run(calls()) return seen + [answer for answer in answers if answer != {}] def test_a_name_cannot_climb_out_of_its_route() -> None: """httpx resolves ``..``, so ``save_flow("../ai/config")`` was ``PUT /ai/config``. A prompt-injected assistant acting for a superuser could repoint the provider that way while the stored key stayed. A flow is now only ever a listed one, and what follows it is still one segment. """ seen = _calls( lambda: server.save_flow("../ai/config", {}), lambda: server.get_node_source("f_heat0000", "../source"), ) assert seen == [ "GET /api/v1/flows/f_heat0000/nodes/%2E%2E%2Fsource/source", {"error": "No flow with the id or key '../ai/config'."}, ] def test_a_flow_argument_goes_through_the_resolver() -> None: """Id first; a read of a name two flows answer to says so, a change of it is refused, and the explicit forms say which.""" seen = _calls( lambda: server.get_flow("heating"), lambda: server.delete_flow("heating"), lambda: server.delete_flow("key:heating"), lambda: server.get_flow("warmth"), ) warning = ( "'heating' is the id of 'warmth' and the key of 'f_heat0000'. " "Using the id. Pass key:heating for the other." ) refusal = ( "'heating' is the id of 'warmth' and the key of 'f_heat0000'. " "Pass id:heating or key:heating." ) assert seen == [ "GET /api/v1/flows/heating", "DELETE /api/v1/flows/f_heat0000", "GET /api/v1/flows/heating", {"warning": warning}, {"error": refusal}, ] def test_the_assistant_calls_a_tool_as_the_person_chatting() -> None: """The in-process path: no MCP request, so the person's token is set by hand.""" from fluksio.ai import agent seen: list[str | None] = [] def record(request: httpx.Request) -> httpx.Response: seen.append(request.headers.get("authorization")) return httpx.Response(200, json=[{"name": "home"}]) async def calls() -> tuple[list[dict], str]: previous = server._client mock = httpx.AsyncClient( transport=httpx.MockTransport(record), base_url="http://test", event_hooks={"request": [server.forward_caller_auth]}, ) server.set_client(mock) try: specs = await agent.tool_specs() answer = await agent.mcp_caller("Bearer person")("list_flows", {}) finally: server.set_client(previous) await mock.aclose() return specs, answer specs, answer = asyncio.run(calls()) assert {"list_flows", "run_flow"} <= {s["function"]["name"] for s in specs} assert all(s["function"]["parameters"]["type"] == "object" for s in specs) assert seen == ["Bearer person"] assert "home" in answer