117 lines
4.0 KiB
Python
117 lines
4.0 KiB
Python
"""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
|