Quote names put into SDK client URL paths; share the helper with MCP
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013GAeg4Jo6xjZzjwy9QDWMQ
This commit is contained in:
@@ -15,12 +15,13 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
from contextvars import ContextVar
|
from contextvars import ContextVar
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import quote
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from mcp.server.fastmcp import FastMCP
|
from mcp.server.fastmcp import FastMCP
|
||||||
from mcp.server.lowlevel.server import request_ctx
|
from mcp.server.lowlevel.server import request_ctx
|
||||||
|
|
||||||
|
from fluksio.sdk.client import seg as _seg
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
mcp = FastMCP("fluksio")
|
mcp = FastMCP("fluksio")
|
||||||
@@ -86,17 +87,6 @@ async def forward_caller_auth(request: httpx.Request) -> None:
|
|||||||
request.headers["authorization"] = authorization
|
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:
|
async def _call(method: str, path: str, **kwargs: Any) -> Any:
|
||||||
"""One REST call, with failures handed back as data rather than raised.
|
"""One REST call, with failures handed back as data rather than raised.
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import uuid
|
|||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
from fluksio.sdk import MARKER, Flow, SyncError
|
from fluksio.sdk import MARKER, Flow, SyncError
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ __all__ = [
|
|||||||
"find_data_dir",
|
"find_data_dir",
|
||||||
"ignore_self",
|
"ignore_self",
|
||||||
"login",
|
"login",
|
||||||
|
"seg",
|
||||||
"sync",
|
"sync",
|
||||||
"write_config",
|
"write_config",
|
||||||
]
|
]
|
||||||
@@ -126,6 +128,17 @@ def _stored() -> dict[str, str]:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
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: ``get_flow("../ai/config")``
|
||||||
|
was ``GET /ai/config``. ``quote`` covers the slashes but leaves dots alone,
|
||||||
|
hence encoding those too; the server decodes both.
|
||||||
|
"""
|
||||||
|
return quote(value, safe="").replace(".", "%2E")
|
||||||
|
|
||||||
|
|
||||||
class ApiError(Exception):
|
class ApiError(Exception):
|
||||||
"""The engine refused, with what it said."""
|
"""The engine refused, with what it said."""
|
||||||
|
|
||||||
@@ -226,7 +239,7 @@ class Client:
|
|||||||
def get_flow(self, name: str) -> dict[str, Any] | None:
|
def get_flow(self, name: str) -> dict[str, Any] | None:
|
||||||
"""The stored flow, draft included, or ``None`` if there is none."""
|
"""The stored flow, draft included, or ``None`` if there is none."""
|
||||||
try:
|
try:
|
||||||
result: dict[str, Any] = self._call("GET", f"/flows/{name}")
|
result: dict[str, Any] = self._call("GET", f"/flows/{seg(name)}")
|
||||||
except ApiError as exc:
|
except ApiError as exc:
|
||||||
if exc.status == 404:
|
if exc.status == 404:
|
||||||
return None
|
return None
|
||||||
@@ -235,7 +248,7 @@ class Client:
|
|||||||
|
|
||||||
def put_flow(self, document: dict[str, Any]) -> dict[str, Any]:
|
def put_flow(self, document: dict[str, Any]) -> dict[str, Any]:
|
||||||
result: dict[str, Any] = self._call(
|
result: dict[str, Any] = self._call(
|
||||||
"PUT", f"/flows/{document['name']}", json=document
|
"PUT", f"/flows/{seg(document['name'])}", json=document
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -249,18 +262,20 @@ class Client:
|
|||||||
somebody edited: the engine answers a new-node template for both, and
|
somebody edited: the engine answers a new-node template for both, and
|
||||||
only one of them is work worth refusing to overwrite.
|
only one of them is work worth refusing to overwrite.
|
||||||
"""
|
"""
|
||||||
result: dict[str, Any] = self._call("GET", f"/flows/{flow}/nodes/{node}/source")
|
result: dict[str, Any] = self._call(
|
||||||
|
"GET", f"/flows/{seg(flow)}/nodes/{seg(node)}/source"
|
||||||
|
)
|
||||||
return result or {}
|
return result or {}
|
||||||
|
|
||||||
def put_source(self, flow: str, node: str, code: str) -> dict[str, Any]:
|
def put_source(self, flow: str, node: str, code: str) -> dict[str, Any]:
|
||||||
result: dict[str, Any] = self._call(
|
result: dict[str, Any] = self._call(
|
||||||
"PUT", f"/flows/{flow}/nodes/{node}/source", json={"code": code}
|
"PUT", f"/flows/{seg(flow)}/nodes/{seg(node)}/source", json={"code": code}
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def publish(self, name: str, version: int) -> dict[str, Any]:
|
def publish(self, name: str, version: int) -> dict[str, Any]:
|
||||||
result: dict[str, Any] = self._call(
|
result: dict[str, Any] = self._call(
|
||||||
"POST", f"/flows/{name}/publish", json={"version": version}
|
"POST", f"/flows/{seg(name)}/publish", json={"version": version}
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -345,7 +360,7 @@ class Client:
|
|||||||
"""
|
"""
|
||||||
row = self._call(
|
row = self._call(
|
||||||
"POST",
|
"POST",
|
||||||
f"/runs/flows/{flow}",
|
f"/runs/flows/{seg(flow)}",
|
||||||
idempotent=True,
|
idempotent=True,
|
||||||
json={
|
json={
|
||||||
"params": params or {},
|
"params": params or {},
|
||||||
@@ -371,7 +386,7 @@ class Client:
|
|||||||
keyed = [{**entry, "idempotency_key": uuid.uuid4().hex} for entry in entries]
|
keyed = [{**entry, "idempotency_key": uuid.uuid4().hex} for entry in entries]
|
||||||
rows = self._call(
|
rows = self._call(
|
||||||
"POST",
|
"POST",
|
||||||
f"/runs/flows/{flow}/sweep",
|
f"/runs/flows/{seg(flow)}/sweep",
|
||||||
idempotent=True,
|
idempotent=True,
|
||||||
json={"runs": keyed, "no_cache": no_cache},
|
json={"runs": keyed, "no_cache": no_cache},
|
||||||
)
|
)
|
||||||
@@ -397,7 +412,7 @@ class Client:
|
|||||||
return answers
|
return answers
|
||||||
|
|
||||||
def run(self, run_id: str) -> dict[str, Any]:
|
def run(self, run_id: str) -> dict[str, Any]:
|
||||||
result: dict[str, Any] = self._call("GET", f"/runs/{run_id}")
|
result: dict[str, Any] = self._call("GET", f"/runs/{seg(run_id)}")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def runs(self, flow: str = "", limit: int = 20, **filters: Any) -> Any:
|
def runs(self, flow: str = "", limit: int = 20, **filters: Any) -> Any:
|
||||||
@@ -410,7 +425,7 @@ class Client:
|
|||||||
query: dict[str, Any] = {"stride": stride}
|
query: dict[str, Any] = {"stride": stride}
|
||||||
if name:
|
if name:
|
||||||
query["name"] = name
|
query["name"] = name
|
||||||
return self._call("GET", f"/runs/{run_id}/metrics", params=query)
|
return self._call("GET", f"/runs/{seg(run_id)}/metrics", params=query)
|
||||||
|
|
||||||
def metric_names(
|
def metric_names(
|
||||||
self, ids: Iterable[str] = (), flow: str = "", **filters: Any
|
self, ids: Iterable[str] = (), flow: str = "", **filters: Any
|
||||||
@@ -512,14 +527,14 @@ class Client:
|
|||||||
|
|
||||||
def cancel(self, run_id: str) -> Any:
|
def cancel(self, run_id: str) -> Any:
|
||||||
# Cancelling a cancelled run is cancelled.
|
# Cancelling a cancelled run is cancelled.
|
||||||
return self._call("POST", f"/runs/{run_id}/cancel", idempotent=True)
|
return self._call("POST", f"/runs/{seg(run_id)}/cancel", idempotent=True)
|
||||||
|
|
||||||
def retry(self, run_id: str) -> Any:
|
def retry(self, run_id: str) -> Any:
|
||||||
"""Submit the same run again. Not idempotent: it makes a run."""
|
"""Submit the same run again. Not idempotent: it makes a run."""
|
||||||
return self._call("POST", f"/runs/{run_id}/retry")
|
return self._call("POST", f"/runs/{seg(run_id)}/retry")
|
||||||
|
|
||||||
def download(self, digest: str) -> bytes:
|
def download(self, digest: str) -> bytes:
|
||||||
response = self._request("GET", f"/artifacts/{digest}", idempotent=True)
|
response = self._request("GET", f"/artifacts/{seg(digest)}", idempotent=True)
|
||||||
if response.status_code >= 400:
|
if response.status_code >= 400:
|
||||||
raise ApiError(response.status_code, _detail(response))
|
raise ApiError(response.status_code, _detail(response))
|
||||||
return bytes(response.content)
|
return bytes(response.content)
|
||||||
|
|||||||
@@ -181,3 +181,23 @@ def test_a_history_is_imported_a_page_at_a_time(monkeypatch):
|
|||||||
assert [one["external_id"] for one in answers] == [
|
assert [one["external_id"] for one in answers] == [
|
||||||
e["external_id"] for e in entries
|
e["external_id"] for e in entries
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_name_cannot_climb_out_of_its_route(monkeypatch):
|
||||||
|
"""httpx resolves ``..``, so ``get_flow("../x")`` was ``GET /api/v1/x``."""
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
seen.append(request.url.raw_path.decode())
|
||||||
|
return httpx.Response(200, json={})
|
||||||
|
|
||||||
|
client = a_client(handler, monkeypatch)
|
||||||
|
client.get_flow("../x")
|
||||||
|
client.put_source("..", "../x", "code")
|
||||||
|
client.run("..")
|
||||||
|
|
||||||
|
assert seen == [
|
||||||
|
"/api/v1/flows/%2E%2E%2Fx",
|
||||||
|
"/api/v1/flows/%2E%2E/nodes/%2E%2E%2Fx/source",
|
||||||
|
"/api/v1/runs/%2E%2E",
|
||||||
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user