506 lines
17 KiB
Python
506 lines
17 KiB
Python
"""The tools an agent can call, each one a request to the flow API.
|
|
|
|
Tools do not reach into the engine: they call the same REST endpoints the
|
|
dashboard calls, over an in-process ASGI transport. That keeps one description
|
|
of what a flow is and how it may be changed — validation, the draft/publish
|
|
split, the version check that stops two clients overwriting each other — and it
|
|
means an agent cannot do anything a person could not do in the browser.
|
|
|
|
The caller's token rides along on every hop, so the API sees the agent's own
|
|
identity rather than some service account.
|
|
|
|
A tool takes a flow by its id or its key, resolved by `fluksio.sdk.resolve`
|
|
from the flows the caller can list, and calls the API with the id.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from contextvars import ContextVar
|
|
from typing import Annotated, Any
|
|
|
|
import httpx
|
|
from mcp.server.context import CallNext, HandlerResult, ServerRequestContext
|
|
from mcp.server.mcpserver import MCPServer
|
|
from pydantic import Field
|
|
|
|
from fluksio.sdk.client import seg as _seg
|
|
from fluksio.sdk.resolve import Resolved, Unresolved, resolve
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
#: The ``Authorization`` value tool calls carry onto the REST API. The endpoint's
|
|
#: middleware sets it from each MCP request; the assistant sets it to the token of
|
|
#: the person chatting, for the length of their turn, since no MCP request is
|
|
#: involved there.
|
|
caller_authorization: ContextVar[str | None] = ContextVar(
|
|
"caller_authorization", default=None
|
|
)
|
|
|
|
|
|
async def _carry_authorization(
|
|
ctx: ServerRequestContext[Any, Any], call_next: CallNext
|
|
) -> HandlerResult:
|
|
"""Make the bearer token of an MCP request the caller's for its tool calls.
|
|
|
|
The transport attaches the originating HTTP request to every JSON-RPC
|
|
message, so the token is read per message rather than per connection.
|
|
"""
|
|
headers = getattr(ctx.request, "headers", None)
|
|
authorization = headers.get("authorization") if headers else None
|
|
if not authorization:
|
|
return await call_next(ctx)
|
|
token = caller_authorization.set(authorization)
|
|
try:
|
|
return await call_next(ctx)
|
|
finally:
|
|
caller_authorization.reset(token)
|
|
|
|
|
|
mcp = MCPServer("fluksio", middleware=[_carry_authorization])
|
|
|
|
#: The tool calls never leave the process, so the host is a label, not a route.
|
|
_INTERNAL_BASE = "http://fluksio-mcp.internal"
|
|
|
|
#: The ASGI client that carries tool calls into the REST API without a network
|
|
#: hop. Built once by :func:`connect`, for the endpoint and the assistant alike.
|
|
_client: httpx.AsyncClient | None = None
|
|
|
|
|
|
def set_client(client: httpx.AsyncClient | None) -> None:
|
|
global _client
|
|
_client = client
|
|
|
|
|
|
def connect(app: object) -> None:
|
|
"""Wire the tools to *app*'s API, once."""
|
|
if _client is not None:
|
|
return
|
|
set_client(
|
|
httpx.AsyncClient(
|
|
transport=httpx.ASGITransport(app=app), # type: ignore[arg-type]
|
|
base_url=_INTERNAL_BASE,
|
|
# Running a flow is synchronous work on a threadpool, so a slow
|
|
# flow must not look like a dead endpoint.
|
|
timeout=120.0,
|
|
event_hooks={"request": [forward_caller_auth]},
|
|
)
|
|
)
|
|
|
|
|
|
async def aclose() -> None:
|
|
if _client is not None:
|
|
await _client.aclose()
|
|
set_client(None)
|
|
|
|
|
|
async def forward_caller_auth(request: httpx.Request) -> None:
|
|
"""Carry the caller's bearer token onto the REST call.
|
|
|
|
A tool handler runs inside the context :data:`caller_authorization` was set
|
|
in, by the endpoint's middleware or by the assistant.
|
|
"""
|
|
authorization = caller_authorization.get()
|
|
if authorization:
|
|
request.headers["authorization"] = authorization
|
|
|
|
|
|
async def _call(method: str, path: str, **kwargs: Any) -> Any:
|
|
"""One REST call, with failures handed back as data rather than raised.
|
|
|
|
An agent can read an error and try something else; a transport fault just
|
|
ends the conversation.
|
|
"""
|
|
if _client is None:
|
|
return {"error": "The MCP endpoint is not running."}
|
|
try:
|
|
response = await _client.request(method, f"/api/v1{path}", **kwargs)
|
|
except httpx.HTTPError as exc:
|
|
logger.exception("MCP call to %s failed", path)
|
|
return {"error": f"Could not reach the flow API: {exc}"}
|
|
|
|
if response.status_code >= 400:
|
|
detail: Any = response.text
|
|
try:
|
|
detail = response.json().get("detail", detail)
|
|
except ValueError:
|
|
pass
|
|
return {"error": detail, "status": response.status_code}
|
|
if not response.content:
|
|
return {"ok": True}
|
|
return response.json()
|
|
|
|
|
|
async def _resolve(
|
|
flow: str, *, mutation: bool = False, history: bool = False
|
|
) -> Resolved | dict[str, Any]:
|
|
"""The flow *flow* names, or the error to hand back.
|
|
|
|
Read from what the caller may list, with the caller's token, so nothing
|
|
is resolved that the API would not show them. ``history`` counts deleted
|
|
flows too, under the key their runs last had.
|
|
"""
|
|
listing = await _call("GET", "/flows/")
|
|
if "error" in listing:
|
|
return dict(listing)
|
|
flows = [(str(row["id"]), str(row["name"])) for row in listing.get("data") or []]
|
|
if history:
|
|
overview = await _call("GET", "/runs/overview")
|
|
if isinstance(overview, dict):
|
|
return overview
|
|
flows += [
|
|
(str(row["flow"]), str(row.get("flow_name") or row["flow"]))
|
|
for row in overview
|
|
]
|
|
try:
|
|
return resolve(flow, flows, mutation=mutation)
|
|
except Unresolved as exc:
|
|
return {"error": str(exc)}
|
|
|
|
|
|
def _warned(answer: Any, found: Resolved) -> Any:
|
|
"""*answer*, saying which flow was taken when the name could mean two."""
|
|
if not found.warning:
|
|
return answer
|
|
if isinstance(answer, dict):
|
|
return {**answer, "warning": found.warning}
|
|
return {"result": answer, "warning": found.warning}
|
|
|
|
|
|
async def _on_flow(
|
|
flow: str, method: str, tail: str = "", *, mutation: bool, **kwargs: Any
|
|
) -> Any:
|
|
"""One call to ``/flows/<id><tail>`` for the flow *flow* names.
|
|
|
|
``mutation`` is whether the call changes anything, which a name two flows
|
|
answer to is refused for rather than guessed.
|
|
"""
|
|
found = await _resolve(flow, mutation=mutation)
|
|
if not isinstance(found, Resolved):
|
|
return found
|
|
answer = await _call(method, f"/flows/{_seg(found.id)}{tail}", **kwargs)
|
|
return _warned(answer, found)
|
|
|
|
|
|
async def _narrowed(flow: str | None, path: str, params: dict[str, Any]) -> Any:
|
|
"""A history read at *path*, narrowed to the flow *flow* names when it
|
|
names one, a deleted one included."""
|
|
if not flow:
|
|
return await _call("GET", path, params=params)
|
|
found = await _resolve(flow, history=True)
|
|
if not isinstance(found, Resolved):
|
|
return found
|
|
params["flow"] = found.id
|
|
return _warned(await _call("GET", path, params=params), found)
|
|
|
|
|
|
#: Every tool's *flow* argument, described where an agent reads the schema.
|
|
FlowArg = Annotated[
|
|
str,
|
|
Field(
|
|
description=(
|
|
"The flow's id or its key. Write id:<x> or key:<x> when one flow's "
|
|
"id is another's key."
|
|
)
|
|
),
|
|
]
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Reading
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
@mcp.tool()
|
|
async def list_flows() -> Any:
|
|
"""Every flow, with its node count, errors, and whether it is running."""
|
|
return await _call("GET", "/flows/")
|
|
|
|
|
|
@mcp.tool()
|
|
async def get_flow(flow: FlowArg) -> Any:
|
|
"""One flow: its definition, the state of its nodes, and its problems.
|
|
|
|
The definition is the working copy — unpublished edits included — which is
|
|
what to base a change on. It carries the flow's ``id`` and its key,
|
|
``name``.
|
|
"""
|
|
return await _on_flow(flow, "GET", mutation=False)
|
|
|
|
|
|
@mcp.tool()
|
|
async def list_node_types() -> Any:
|
|
"""The node types that can be placed, with their parameter schemas."""
|
|
return await _call("GET", "/flows/node-types")
|
|
|
|
|
|
@mcp.tool()
|
|
async def get_node_source(flow: FlowArg, node_id: str) -> Any:
|
|
"""The Python source of one node."""
|
|
return await _on_flow(flow, "GET", f"/nodes/{_seg(node_id)}/source", mutation=False)
|
|
|
|
|
|
@mcp.tool()
|
|
async def get_flow_state(flow: FlowArg) -> Any:
|
|
"""The last value seen on every message of a flow."""
|
|
return await _on_flow(flow, "GET", "/state", mutation=False)
|
|
|
|
|
|
@mcp.tool()
|
|
async def get_message_history(flow: FlowArg, message: str) -> Any:
|
|
"""Recent numeric values of one message, oldest first."""
|
|
return await _on_flow(flow, "GET", f"/history/{_seg(message)}", mutation=False)
|
|
|
|
|
|
@mcp.tool()
|
|
async def list_secrets() -> Any:
|
|
"""The names of stored secrets, for pointing a node parameter at one.
|
|
|
|
Values are never returned. Reference one from a node parameter as
|
|
``{"$secret": "<name>"}``.
|
|
"""
|
|
return await _call("GET", "/secrets/")
|
|
|
|
|
|
@mcp.tool()
|
|
async def list_shared_nodes() -> Any:
|
|
"""Node sources shared across flows, and which nodes use each."""
|
|
return await _call("GET", "/flows/library")
|
|
|
|
|
|
@mcp.tool()
|
|
async def list_devices() -> Any:
|
|
"""Every paired screen or board, the dashboards it shows, whether it is
|
|
connected and what it reports having."""
|
|
return await _call("GET", "/devices/")
|
|
|
|
|
|
@mcp.tool()
|
|
async def get_device(device_id: str) -> Any:
|
|
"""One device: its dashboards in rail order, touch, and its own I/O ports."""
|
|
return await _call("GET", f"/devices/{_seg(device_id)}")
|
|
|
|
|
|
@mcp.tool()
|
|
async def get_graph() -> Any:
|
|
"""Every flow as one graph, with nodes talking to the same thing merged.
|
|
|
|
Nodes on the same broker topic, URL or bucket come back as a single entry,
|
|
which is how wiring that runs between flows shows up.
|
|
"""
|
|
return await _call("GET", "/flows/graph")
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Building
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
@mcp.tool()
|
|
async def create_flow(definition: dict[str, Any]) -> Any:
|
|
"""Create a new flow, as an unpublished draft.
|
|
|
|
``definition`` is a flow document like the one `get_flow` returns under
|
|
``definition``. Its ``name`` is the new flow's key, which no other flow
|
|
may hold. The server gives the flow its id, so an ``id`` or ``version`` in
|
|
the document is ignored; the answer carries both. Change an existing flow
|
|
with `save_flow` instead.
|
|
"""
|
|
document = {k: v for k, v in definition.items() if k not in ("id", "version")}
|
|
return await _call("POST", "/flows/", json=document)
|
|
|
|
|
|
@mcp.tool()
|
|
async def save_flow(flow: FlowArg, definition: dict[str, Any]) -> Any:
|
|
"""Save changes to an existing flow as an unpublished draft.
|
|
|
|
``definition`` is a whole flow document — the shape `get_flow` returns
|
|
under ``definition``. It must carry the ``version`` that was read, so an
|
|
edit someone else made in between is refused rather than overwritten; on a
|
|
conflict, read the flow again and reapply the change. Its ``name``, the
|
|
key, cannot change here. A new flow is `create_flow`.
|
|
|
|
Nothing here reaches the engine until `publish_flow`.
|
|
"""
|
|
return await _on_flow(flow, "PUT", mutation=True, json=definition)
|
|
|
|
|
|
@mcp.tool()
|
|
async def save_node_source(flow: FlowArg, node_id: str, code: str) -> Any:
|
|
"""Save a node's Python source and report whether it compiles.
|
|
|
|
A node defines ``process(...)``, taking one argument per input port and
|
|
one per setting, and returns a dict keyed by output port. A parameter added
|
|
or dropped there adds or drops the input port or setting in the draft,
|
|
which moves its version on: read the flow again before saving it.
|
|
"""
|
|
return await _on_flow(
|
|
flow,
|
|
"PUT",
|
|
f"/nodes/{_seg(node_id)}/source",
|
|
mutation=True,
|
|
json={"code": code},
|
|
)
|
|
|
|
|
|
@mcp.tool()
|
|
async def publish_flow(flow: FlowArg, version: int) -> Any:
|
|
"""Deploy a flow's unpublished changes: this is what puts them live."""
|
|
return await _on_flow(
|
|
flow, "POST", "/publish", mutation=True, json={"version": version}
|
|
)
|
|
|
|
|
|
@mcp.tool()
|
|
async def discard_draft(flow: FlowArg) -> Any:
|
|
"""Throw unpublished changes away and go back to what is running."""
|
|
return await _on_flow(flow, "POST", "/discard-draft", mutation=True)
|
|
|
|
|
|
@mcp.tool()
|
|
async def delete_flow(flow: FlowArg) -> Any:
|
|
"""Delete a flow and the code of its nodes."""
|
|
return await _on_flow(flow, "DELETE", mutation=True)
|
|
|
|
|
|
@mcp.tool()
|
|
async def validate_flow(flow: FlowArg) -> Any:
|
|
"""What would keep this flow from running: loops, unconnected inputs."""
|
|
return await _on_flow(flow, "POST", "/validate", mutation=False)
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Running
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
@mcp.tool()
|
|
async def run_flow(flow: FlowArg, inputs: dict[str, Any] | None = None) -> Any:
|
|
"""Run every node of a flow once and return the resulting state.
|
|
|
|
With unpublished changes this runs the draft, so a change can be tried
|
|
before it is published.
|
|
"""
|
|
return await _on_flow(
|
|
flow, "POST", "/run", mutation=True, json={"inputs": inputs or {}}
|
|
)
|
|
|
|
|
|
@mcp.tool()
|
|
async def trigger_node(
|
|
flow: FlowArg, node_id: str, values: dict[str, Any] | None = None
|
|
) -> Any:
|
|
"""Feed values into one node and run everything downstream of it."""
|
|
return await _on_flow(
|
|
flow,
|
|
"POST",
|
|
f"/nodes/{_seg(node_id)}/trigger",
|
|
mutation=True,
|
|
json={"values": values or {}},
|
|
)
|
|
|
|
|
|
@mcp.tool()
|
|
async def start_flow(flow: FlowArg) -> Any:
|
|
"""Let the engine run this flow: subscriptions, schedules and webhooks."""
|
|
return await _on_flow(flow, "POST", "/start", mutation=True)
|
|
|
|
|
|
@mcp.tool()
|
|
async def stop_flow(flow: FlowArg) -> Any:
|
|
"""Take a flow off the engine. Nothing of it stays subscribed or scheduled."""
|
|
return await _on_flow(flow, "POST", "/stop", mutation=True)
|
|
|
|
|
|
@mcp.tool()
|
|
async def pause_flow(flow: FlowArg) -> Any:
|
|
"""Hold a flow's nodes while its incoming values keep arriving."""
|
|
return await _on_flow(flow, "POST", "/pause", mutation=True)
|
|
|
|
|
|
@mcp.tool()
|
|
async def resume_flow(flow: FlowArg) -> Any:
|
|
"""Let a paused flow carry on, running whatever was held back."""
|
|
return await _on_flow(flow, "POST", "/resume", mutation=True)
|
|
|
|
|
|
@mcp.tool()
|
|
async def cancel_node(flow: FlowArg, node_id: str) -> Any:
|
|
"""Stop a node's code while it is running. Nothing to stop is not an error."""
|
|
return await _on_flow(flow, "POST", f"/nodes/{_seg(node_id)}/cancel", mutation=True)
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Modules
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
@mcp.tool()
|
|
async def get_modules() -> Any:
|
|
"""The python packages node code can import, and the manifest asking for them."""
|
|
return await _call("GET", "/modules/")
|
|
|
|
|
|
@mcp.tool()
|
|
async def apply_modules(requirements: str) -> Any:
|
|
"""Install exactly these requirements, one pip line each.
|
|
|
|
This replaces the whole manifest: a package left out is uninstalled. A
|
|
manifest that does not resolve changes nothing.
|
|
"""
|
|
return await _call("POST", "/modules/apply", json={"requirements": requirements})
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Observability
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
@mcp.tool()
|
|
async def get_health() -> Any:
|
|
"""How the engine is doing right now: flows, nodes, queue and loop lag."""
|
|
return await _call("GET", "/observability/summary")
|
|
|
|
|
|
#: The optional flow a history question is narrowed to.
|
|
FlowFilter = Annotated[
|
|
str | None,
|
|
Field(
|
|
description=(
|
|
"Only this flow: its id or its key, or the key it had before it was "
|
|
"deleted. Write id:<x> or key:<x> when the name could mean two."
|
|
)
|
|
),
|
|
]
|
|
|
|
|
|
@mcp.tool()
|
|
async def get_metrics(
|
|
flow: FlowFilter = None, node: str | None = None, hours: int = 24
|
|
) -> Any:
|
|
"""Executions, errors and timings per minute over the last few hours.
|
|
|
|
``node`` is ``<flow key>.<node id>``.
|
|
"""
|
|
params: dict[str, Any] = {"hours": hours}
|
|
if node:
|
|
params["node"] = node
|
|
return await _narrowed(flow, "/observability/timeseries", params)
|
|
|
|
|
|
@mcp.tool()
|
|
async def list_failures(flow: FlowFilter = None, limit: int = 50) -> Any:
|
|
"""Recent failures with their tracebacks, newest first."""
|
|
params: dict[str, Any] = {"kind": "failure", "limit": limit}
|
|
return await _narrowed(flow, "/observability/events", params)
|
|
|
|
|
|
@mcp.tool()
|
|
async def list_runs(flow: FlowFilter = None, limit: int = 50) -> Any:
|
|
"""Recent cascades: what triggered them, how long they took, how they ended.
|
|
|
|
A page of rows plus the total number matching, which says whether the limit
|
|
cut anything off.
|
|
"""
|
|
params: dict[str, Any] = {"limit": limit}
|
|
return await _narrowed(flow, "/observability/runs", params)
|