Rename the import package app to fluksio
A wheel whose top-level module is `app` collides with anything else in a user's venv, so the package that is about to be published takes the name it is published under. Only the Python package moves; the repo, the Docker WORKDIR and the compose project keep theirs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
"""The MCP endpoint: the same flow API, in the shape an agent can drive.
|
||||
|
||||
``server`` holds the tools, ``http`` wires them to the streamable-HTTP
|
||||
transport and the OAuth resource-server checks. Both are imported lazily, only
|
||||
when ``MCP_ENABLED`` is set, so an installation that does not want an agent
|
||||
endpoint does not carry one.
|
||||
"""
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Mounting the MCP endpoint, and deciding whose tokens it accepts.
|
||||
|
||||
The transport is streamable HTTP, stateless, one JSON response per call: no
|
||||
sticky sessions to route and nothing for a proxy to buffer. The SDK serves both
|
||||
``/mcp`` and the protected-resource metadata beside it, which is why the sub-app
|
||||
is mounted at the root rather than under ``/mcp``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
from mcp.server.auth.provider import AccessToken
|
||||
from mcp.server.auth.settings import AuthSettings
|
||||
from mcp.server.transport_security import TransportSecuritySettings
|
||||
from pydantic import AnyHttpUrl
|
||||
from starlette.applications import Starlette
|
||||
|
||||
from fluksio.core import security
|
||||
from fluksio.core.config import settings
|
||||
from fluksio.mcp import server
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: The tool calls never leave the process, so the host is a label, not a route.
|
||||
_INTERNAL_BASE = "http://fluksio-mcp.internal"
|
||||
|
||||
|
||||
class _JWTVerifier:
|
||||
"""Accept only tokens minted for the MCP channel.
|
||||
|
||||
A perfectly valid browser token is refused: it was issued for a person's
|
||||
session, and honouring it here would make agent traffic indistinguishable
|
||||
from theirs.
|
||||
"""
|
||||
|
||||
async def verify_token(self, token: str) -> AccessToken | None:
|
||||
try:
|
||||
payload = security.decode_oauth_token(token)
|
||||
except jwt.InvalidTokenError as exc:
|
||||
logger.debug("MCP token rejected: %s", exc)
|
||||
return None
|
||||
if payload.get("mcp") is not True:
|
||||
logger.debug("MCP token rejected: not an MCP-channel token")
|
||||
return None
|
||||
return AccessToken(
|
||||
token=token,
|
||||
client_id=str(payload.get("client_id", "")),
|
||||
scopes=[security.MCP_SCOPE],
|
||||
subject=str(payload.get("sub", "")),
|
||||
)
|
||||
|
||||
|
||||
def _transport_security() -> TransportSecuritySettings:
|
||||
"""Which Host headers to trust.
|
||||
|
||||
The SDK defaults to localhost only and answers 421 to anything arriving
|
||||
through a reverse proxy under the real hostname, so the deployment's own
|
||||
host is named here rather than the protection being switched off.
|
||||
"""
|
||||
host = urlparse(settings.oauth_issuer).netloc
|
||||
allowed_hosts = [host, f"{host}:*"]
|
||||
allowed_origins = [f"https://{host}", f"http://{host}"]
|
||||
if settings.ENVIRONMENT == "local":
|
||||
# The test client and a direct uvicorn run do not go through Traefik.
|
||||
allowed_hosts += [
|
||||
"localhost",
|
||||
"localhost:*",
|
||||
"127.0.0.1",
|
||||
"127.0.0.1:*",
|
||||
"testserver",
|
||||
]
|
||||
allowed_origins += ["http://localhost", "http://127.0.0.1"]
|
||||
return TransportSecuritySettings(
|
||||
enable_dns_rebinding_protection=True,
|
||||
allowed_hosts=allowed_hosts,
|
||||
allowed_origins=allowed_origins,
|
||||
)
|
||||
|
||||
|
||||
def build_http_app(app: object) -> Starlette:
|
||||
"""The MCP sub-app, wired to this app's API and its OAuth server."""
|
||||
server.mcp.settings.stateless_http = True
|
||||
server.mcp.settings.json_response = True
|
||||
server.mcp.settings.auth = AuthSettings(
|
||||
issuer_url=AnyHttpUrl(settings.oauth_issuer),
|
||||
resource_server_url=AnyHttpUrl(settings.mcp_resource),
|
||||
required_scopes=None,
|
||||
)
|
||||
server.mcp.settings.transport_security = _transport_security()
|
||||
# The SDK only takes a verifier through its constructor, and the instance is
|
||||
# module-level so the tools can be declared at import time.
|
||||
server.mcp._token_verifier = _JWTVerifier() # noqa: SLF001
|
||||
|
||||
server.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": [server.forward_caller_auth]},
|
||||
)
|
||||
)
|
||||
return server.mcp.streamable_http_app()
|
||||
|
||||
|
||||
async def aclose() -> None:
|
||||
if server._client is not None: # noqa: SLF001
|
||||
await server._client.aclose() # noqa: SLF001
|
||||
server.set_client(None)
|
||||
@@ -0,0 +1,323 @@
|
||||
"""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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from mcp.server.lowlevel.server import request_ctx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
mcp = FastMCP("fluksio")
|
||||
|
||||
#: Set by ``http.build_http_app``; the ASGI client that carries tool calls into
|
||||
#: the REST API without a network hop.
|
||||
_client: httpx.AsyncClient | None = None
|
||||
|
||||
|
||||
def set_client(client: httpx.AsyncClient | None) -> None:
|
||||
global _client
|
||||
_client = client
|
||||
|
||||
|
||||
async def forward_caller_auth(request: httpx.Request) -> None:
|
||||
"""Carry the calling agent's bearer token onto the REST call.
|
||||
|
||||
The transport attaches the originating HTTP request to every JSON-RPC
|
||||
message, and a tool handler runs inside that request's context, so the
|
||||
token is read per message rather than per connection.
|
||||
"""
|
||||
try:
|
||||
source = request_ctx.get().request
|
||||
except LookupError:
|
||||
return
|
||||
authorization = getattr(source, "headers", {}).get("authorization")
|
||||
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()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 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(name: str) -> 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.
|
||||
"""
|
||||
return await _call("GET", f"/flows/{name}")
|
||||
|
||||
|
||||
@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(name: str, node_id: str) -> Any:
|
||||
"""The Python source of one node."""
|
||||
return await _call("GET", f"/flows/{name}/nodes/{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")
|
||||
|
||||
|
||||
@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}")
|
||||
|
||||
|
||||
@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 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 save_flow(name: str, definition: dict[str, Any]) -> Any:
|
||||
"""Save a 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.
|
||||
|
||||
Nothing here reaches the engine until `publish_flow`.
|
||||
"""
|
||||
return await _call("PUT", f"/flows/{name}", json=definition)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def save_node_source(name: str, 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.
|
||||
"""
|
||||
return await _call(
|
||||
"PUT", f"/flows/{name}/nodes/{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})
|
||||
|
||||
|
||||
@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")
|
||||
|
||||
|
||||
@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}")
|
||||
|
||||
|
||||
@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")
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Running
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def run_flow(name: str, 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 _call("POST", f"/flows/{name}/run", json={"inputs": inputs or {}})
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def trigger_node(
|
||||
name: str, node_id: str, values: dict[str, Any] | None = None
|
||||
) -> 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 {}}
|
||||
)
|
||||
|
||||
|
||||
@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")
|
||||
|
||||
|
||||
@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")
|
||||
|
||||
|
||||
@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")
|
||||
|
||||
|
||||
@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")
|
||||
|
||||
|
||||
@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")
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 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")
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def get_metrics(
|
||||
flow: str | None = None, node: str | None = None, hours: int = 24
|
||||
) -> Any:
|
||||
"""Executions, errors and timings per minute over the last few hours."""
|
||||
params: dict[str, Any] = {"hours": hours}
|
||||
if flow:
|
||||
params["flow"] = flow
|
||||
if node:
|
||||
params["node"] = node
|
||||
return await _call("GET", "/observability/timeseries", params=params)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def list_failures(flow: str | None = None, limit: int = 50) -> Any:
|
||||
"""Recent failures with their tracebacks, newest first."""
|
||||
params: dict[str, Any] = {"kind": "failure", "limit": limit}
|
||||
if flow:
|
||||
params["flow"] = flow
|
||||
return await _call("GET", "/observability/events", params=params)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def list_runs(flow: str | None = 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}
|
||||
if flow:
|
||||
params["flow"] = flow
|
||||
return await _call("GET", "/observability/runs", params=params)
|
||||
Reference in New Issue
Block a user