Let agents drive the flow API over MCP

The engine now speaks MCP at /mcp, with a built-in OAuth 2.1 authorization
server in front of it: an agent registers itself, sends a human to the browser
to approve it, and exchanges the resulting code for a token. PKCE is required,
codes are single-use and stored only as hashes, the browser is redirected to
the URI that was registered rather than the one asked for, and refresh tokens
rotate so that replaying a spent one revokes the whole line.

Twenty tools cover reading, building, publishing and running flows, and each
one calls the same REST endpoint the dashboard calls, in-process, carrying the
caller's own token. That keeps one description of what a flow is and what may
be done to it — validation, the draft/publish split, the version check — and
means an agent can do nothing a person could not do in the browser.

Agent tokens are RS256 with a keypair of their own rather than the secret that
signs browser sessions, so deleting the key withdraws every agent without
logging anyone out, and deps.decode_token grew the branch that trusting a
second issuer will need when the hosted login arrives.
This commit is contained in:
Melvin Strobl
2026-08-16 00:22:41 +02:00
parent cb5e8f0ecc
commit 6d2e25c2ca
28 changed files with 2459 additions and 555 deletions
+28
View File
@@ -41,6 +41,16 @@ class Settings(BaseSettings):
# Flows live on disk as a git repository; secrets stay outside it.
FLOWS_DIR: Path = Path("flow-data/flows")
SECRETS_FILE: Path = Path("flow-data/secrets.enc")
# The MCP endpoint, and the OAuth server agents authenticate against. Off
# until someone asks for it: it opens client registration to the network.
MCP_ENABLED: bool = False
DOMAIN: str = "localhost"
OAUTH_PRIVATE_KEY_FILE: Path = Path("flow-data/oauth-key.pem")
OAUTH_CODE_EXPIRE_SECONDS: int = 60
# Short, because an agent's token is a bearer secret held by a program
# rather than a person, and it can refresh unattended.
MCP_TOKEN_EXPIRE_MINUTES: int = 60
MCP_REFRESH_EXPIRE_DAYS: int = 30
FLOW_MAX_WORKERS: int = 4
# Without a Redis host the engine keeps its state in memory.
REDIS_HOST: str | None = None
@@ -50,6 +60,24 @@ class Settings(BaseSettings):
list[AnyUrl] | str, BeforeValidator(parse_cors)
] = []
@computed_field # type: ignore[prop-decorator]
@property
def oauth_issuer(self) -> str:
"""Who issues MCP tokens — this app, on its API host.
Kept separate from the app's own URL because a hosted deployment can
later point agents at a different issuer without the resource server
changing: it validates whatever issuer it is configured to trust.
"""
scheme = "http" if self.ENVIRONMENT == "local" else "https"
return f"{scheme}://api.{self.DOMAIN}"
@computed_field # type: ignore[prop-decorator]
@property
def mcp_resource(self) -> str:
"""The resource an MCP token is issued for (RFC 8707)."""
return f"{self.oauth_issuer}/mcp"
@computed_field # type: ignore[prop-decorator]
@property
def all_cors_origins(self) -> list[str]:
+110
View File
@@ -1,7 +1,12 @@
import base64
import uuid
from datetime import datetime, timedelta, timezone
from functools import lru_cache
from typing import Any
import jwt
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from pwdlib import PasswordHash
from pwdlib.hashers.argon2 import Argon2Hasher
from pwdlib.hashers.bcrypt import BcryptHasher
@@ -17,6 +22,13 @@ password_hash = PasswordHash(
ALGORITHM = "HS256"
#: MCP tokens are signed with a keypair of their own, so the public half can be
#: published and the whole set revoked by rotating it — without logging anyone
#: out of the browser, and without the resource server needing the secret that
#: signs browser sessions.
OAUTH_ALGORITHM = "RS256"
#: The one scope an MCP token carries.
MCP_SCOPE = "mcp"
def create_access_token(subject: str | Any, expires_delta: timedelta) -> str:
@@ -26,6 +38,104 @@ def create_access_token(subject: str | Any, expires_delta: timedelta) -> str:
return encoded_jwt
# -----------------------------------------------------------------------------
# OAuth signing key
# -----------------------------------------------------------------------------
def _load_or_create_key() -> rsa.RSAPrivateKey:
"""The RSA key MCP tokens are signed with, generated on first use."""
path = settings.OAUTH_PRIVATE_KEY_FILE
if path.exists():
loaded = serialization.load_pem_private_key(path.read_bytes(), password=None)
if not isinstance(loaded, rsa.RSAPrivateKey):
raise TypeError(f"{path} is not an RSA private key")
return loaded
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(
key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
)
path.chmod(0o600)
return key
@lru_cache(maxsize=1)
def oauth_key() -> rsa.RSAPrivateKey:
return _load_or_create_key()
def public_jwks() -> dict[str, Any]:
"""The public half, for anything that wants to check a token itself."""
numbers = oauth_key().public_key().public_numbers()
def b64(value: int) -> str:
raw = value.to_bytes((value.bit_length() + 7) // 8, "big")
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
return {
"keys": [
{
"kty": "RSA",
"use": "sig",
"alg": OAUTH_ALGORITHM,
"kid": "fluksio-oauth",
"n": b64(numbers.n),
"e": b64(numbers.e),
}
]
}
def create_oauth_access_token(
user_id: uuid.UUID, client_id: uuid.UUID, expires_delta: timedelta
) -> str:
"""An access token for the MCP channel, told apart from a browser session.
The ``mcp`` claim is what the MCP endpoint checks: a perfectly valid token
from someone's browser is refused there, so agent traffic never arrives
looking like a person's.
"""
now = datetime.now(timezone.utc)
payload = {
"sub": str(user_id),
"iss": settings.oauth_issuer,
"aud": settings.mcp_resource,
"iat": now,
"exp": now + expires_delta,
"mcp": True,
"client_id": str(client_id),
"scope": MCP_SCOPE,
}
return jwt.encode(
payload,
oauth_key().private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
),
algorithm=OAUTH_ALGORITHM,
headers={"kid": "fluksio-oauth"},
)
def decode_oauth_token(token: str) -> dict[str, Any]:
"""Validate an MCP token. Raises ``InvalidTokenError`` if it does not hold."""
payload: dict[str, Any] = jwt.decode(
token,
oauth_key().public_key(),
algorithms=[OAUTH_ALGORITHM],
audience=settings.mcp_resource,
issuer=settings.oauth_issuer,
)
return payload
def verify_password(
plain_password: str, hashed_password: str
) -> tuple[bool, str | None]: