Quote every path parameter the MCP tools put into a URL

httpx resolves `..` before sending, so save_flow("../ai/config") reached
PUT /ai/config. Each segment is now percent-encoded, dots included.
This commit is contained in:
2026-10-06 12:16:48 +02:00
parent c5a8f170da
commit ae290b5421
2 changed files with 76 additions and 17 deletions
+35 -17
View File
@@ -15,6 +15,7 @@ from __future__ import annotations
import logging
from contextvars import ContextVar
from typing import Any
from urllib.parse import quote
import httpx
from mcp.server.fastmcp import FastMCP
@@ -85,6 +86,17 @@ async def forward_caller_auth(request: httpx.Request) -> None:
request.headers["authorization"] = authorization
def _seg(value: str) -> str:
"""*value* as one path segment, and nothing more.
httpx resolves ``..`` before sending, so a name put into a path as written
could climb out of the route it was meant for: ``save_flow("../ai/config")``
was ``PUT /ai/config``. ``quote`` covers the slashes but leaves dots alone,
hence encoding those too; the server decodes both.
"""
return quote(value, safe="").replace(".", "%2E")
async def _call(method: str, path: str, **kwargs: Any) -> Any:
"""One REST call, with failures handed back as data rather than raised.
@@ -129,7 +141,7 @@ async def get_flow(name: str) -> Any:
The definition is the working copy — unpublished edits included — which is
what to base a change on.
"""
return await _call("GET", f"/flows/{name}")
return await _call("GET", f"/flows/{_seg(name)}")
@mcp.tool()
@@ -141,19 +153,19 @@ async def list_node_types() -> Any:
@mcp.tool()
async def get_node_source(name: str, node_id: str) -> Any:
"""The Python source of one node."""
return await _call("GET", f"/flows/{name}/nodes/{node_id}/source")
return await _call("GET", f"/flows/{_seg(name)}/nodes/{_seg(node_id)}/source")
@mcp.tool()
async def get_flow_state(name: str) -> Any:
"""The last value seen on every message of a flow."""
return await _call("GET", f"/flows/{name}/state")
return await _call("GET", f"/flows/{_seg(name)}/state")
@mcp.tool()
async def get_message_history(name: str, message: str) -> Any:
"""Recent numeric values of one message, oldest first."""
return await _call("GET", f"/flows/{name}/history/{message}")
return await _call("GET", f"/flows/{_seg(name)}/history/{_seg(message)}")
@mcp.tool()
@@ -198,7 +210,7 @@ async def save_flow(name: str, definition: dict[str, Any]) -> Any:
Nothing here reaches the engine until `publish_flow`.
"""
return await _call("PUT", f"/flows/{name}", json=definition)
return await _call("PUT", f"/flows/{_seg(name)}", json=definition)
@mcp.tool()
@@ -209,32 +221,34 @@ async def save_node_source(name: str, node_id: str, code: str) -> Any:
one per setting, and returns a dict keyed by output port.
"""
return await _call(
"PUT", f"/flows/{name}/nodes/{node_id}/source", json={"code": code}
"PUT", f"/flows/{_seg(name)}/nodes/{_seg(node_id)}/source", json={"code": code}
)
@mcp.tool()
async def publish_flow(name: str, version: int) -> Any:
"""Deploy a flow's unpublished changes: this is what puts them live."""
return await _call("POST", f"/flows/{name}/publish", json={"version": version})
return await _call(
"POST", f"/flows/{_seg(name)}/publish", json={"version": version}
)
@mcp.tool()
async def discard_draft(name: str) -> Any:
"""Throw unpublished changes away and go back to what is running."""
return await _call("POST", f"/flows/{name}/discard-draft")
return await _call("POST", f"/flows/{_seg(name)}/discard-draft")
@mcp.tool()
async def delete_flow(name: str) -> Any:
"""Delete a flow and the code of its nodes."""
return await _call("DELETE", f"/flows/{name}")
return await _call("DELETE", f"/flows/{_seg(name)}")
@mcp.tool()
async def validate_flow(name: str) -> Any:
"""What would keep this flow from running: loops, unconnected inputs."""
return await _call("POST", f"/flows/{name}/validate")
return await _call("POST", f"/flows/{_seg(name)}/validate")
# -----------------------------------------------------------------------------
@@ -249,7 +263,9 @@ async def run_flow(name: str, inputs: dict[str, Any] | None = None) -> Any:
With unpublished changes this runs the draft, so a change can be tried
before it is published.
"""
return await _call("POST", f"/flows/{name}/run", json={"inputs": inputs or {}})
return await _call(
"POST", f"/flows/{_seg(name)}/run", json={"inputs": inputs or {}}
)
@mcp.tool()
@@ -258,38 +274,40 @@ async def trigger_node(
) -> Any:
"""Feed values into one node and run everything downstream of it."""
return await _call(
"POST", f"/flows/{name}/nodes/{node_id}/trigger", json={"values": values or {}}
"POST",
f"/flows/{_seg(name)}/nodes/{_seg(node_id)}/trigger",
json={"values": values or {}},
)
@mcp.tool()
async def start_flow(name: str) -> Any:
"""Let the engine run this flow: subscriptions, schedules and webhooks."""
return await _call("POST", f"/flows/{name}/start")
return await _call("POST", f"/flows/{_seg(name)}/start")
@mcp.tool()
async def stop_flow(name: str) -> Any:
"""Take a flow off the engine. Nothing of it stays subscribed or scheduled."""
return await _call("POST", f"/flows/{name}/stop")
return await _call("POST", f"/flows/{_seg(name)}/stop")
@mcp.tool()
async def pause_flow(name: str) -> Any:
"""Hold a flow's nodes while its incoming values keep arriving."""
return await _call("POST", f"/flows/{name}/pause")
return await _call("POST", f"/flows/{_seg(name)}/pause")
@mcp.tool()
async def resume_flow(name: str) -> Any:
"""Let a paused flow carry on, running whatever was held back."""
return await _call("POST", f"/flows/{name}/resume")
return await _call("POST", f"/flows/{_seg(name)}/resume")
@mcp.tool()
async def cancel_node(name: str, node_id: str) -> Any:
"""Stop a node's code while it is running. Nothing to stop is not an error."""
return await _call("POST", f"/flows/{name}/nodes/{node_id}/cancel")
return await _call("POST", f"/flows/{_seg(name)}/nodes/{_seg(node_id)}/cancel")
# -----------------------------------------------------------------------------
+41
View File
@@ -0,0 +1,41 @@
"""What the MCP tools send, whatever an agent hands them."""
import asyncio
import httpx
from fluksio.mcp import server
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.
"""
seen: list[str] = []
def record(request: httpx.Request) -> httpx.Response:
seen.append(f"{request.method} {request.url.raw_path.decode()}")
return httpx.Response(200, json={})
async def calls() -> None:
previous = server._client
mock = httpx.AsyncClient(
transport=httpx.MockTransport(record), base_url="http://test"
)
server.set_client(mock)
try:
await server.save_flow("../ai/config", {})
await server.delete_flow("..")
await server.get_node_source("..", "../source")
finally:
server.set_client(previous)
await mock.aclose()
asyncio.run(calls())
assert seen == [
"PUT /api/v1/flows/%2E%2E%2Fai%2Fconfig",
"DELETE /api/v1/flows/%2E%2E",
"GET /api/v1/flows/%2E%2E/nodes/%2E%2E%2Fsource/source",
]