Let agents drive the flow API over MCP
Playwright Tests / test-playwright (1, 2) (push) Canceled after 0s
Playwright Tests / test-playwright (2, 2) (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Test Backend / test-backend (push) Canceled after 0s
Compose Smoke Test / test-compose (push) Canceled after 0s
Playwright Tests / merge-reports (push) Canceled after 0s

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.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Melvin Strobl
2026-08-16 00:22:41 +02:00
co-authored by Claude Fable 5
parent 3724b68f23
commit 8d82d6c4ec
28 changed files with 2459 additions and 555 deletions
+6 -3
View File
@@ -20,7 +20,7 @@ import {
import { Input } from "@/components/ui/input"
import { LoadingButton } from "@/components/ui/loading-button"
import { PasswordInput } from "@/components/ui/password-input"
import useAuth, { isLoggedIn } from "@/hooks/useAuth"
import useAuth, { isLoggedIn, safeRedirect } from "@/hooks/useAuth"
const formSchema = z.object({
username: z.email(),
@@ -34,10 +34,13 @@ type FormData = z.infer<typeof formSchema>
export const Route = createFileRoute("/login")({
component: Login,
beforeLoad: async () => {
// Optional, so every other `navigate({ to: "/login" })` stays as it was.
validateSearch: (search: Record<string, unknown>): { redirect?: string } =>
typeof search.redirect === "string" ? { redirect: search.redirect } : {},
beforeLoad: async ({ search }) => {
if (isLoggedIn()) {
throw redirect({
to: "/",
to: safeRedirect(search.redirect),
})
}
},
+147
View File
@@ -0,0 +1,147 @@
import { useMutation, useQuery } from "@tanstack/react-query"
import { createFileRoute, redirect } from "@tanstack/react-router"
import { AlertTriangle } from "lucide-react"
import { OauthService } from "@/client"
import { AuthLayout } from "@/components/Common/AuthLayout"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { Button } from "@/components/ui/button"
import { LoadingButton } from "@/components/ui/loading-button"
import { Skeleton } from "@/components/ui/skeleton"
import { isLoggedIn } from "@/hooks/useAuth"
/**
* Where an agent sends someone to be let in.
*
* Everything shown here is checked by the server first, and the URL the browser
* is finally sent to is the one the server hands back — never the one in the
* address bar — so a doctored link cannot redirect an approval somewhere else.
*/
export const Route = createFileRoute("/oauth/authorize")({
component: Authorize,
validateSearch: (search: Record<string, unknown>) => ({
client_id: String(search.client_id ?? ""),
redirect_uri: String(search.redirect_uri ?? ""),
code_challenge: String(search.code_challenge ?? ""),
code_challenge_method: String(search.code_challenge_method ?? "S256"),
state: search.state ? String(search.state) : undefined,
resource: search.resource ? String(search.resource) : undefined,
}),
beforeLoad: ({ location }) => {
if (!isLoggedIn()) {
throw redirect({
to: "/login",
search: { redirect: location.href },
})
}
},
head: () => ({
meta: [{ title: "Authorize - Fluksio" }],
}),
})
function Authorize() {
const search = Route.useSearch()
const {
data: info,
isPending,
error,
} = useQuery({
queryKey: ["oauth", "authorize", search.client_id, search.redirect_uri],
queryFn: () =>
OauthService.authorizeValidate({
clientId: search.client_id,
redirectUri: search.redirect_uri,
}),
retry: false,
})
const approve = useMutation({
mutationFn: () =>
OauthService.authorize({
requestBody: {
client_id: search.client_id,
redirect_uri: search.redirect_uri,
code_challenge: search.code_challenge,
code_challenge_method: search.code_challenge_method,
state: search.state,
resource: search.resource,
},
}),
onSuccess: (response) => {
// The server's URL, built from the registered redirect.
window.location.assign(response.redirect_url)
},
})
const deny = () => {
if (!info) return
const url = new URL(info.redirect_uri)
url.searchParams.set("error", "access_denied")
if (search.state) url.searchParams.set("state", search.state)
window.location.assign(url.toString())
}
return (
<AuthLayout>
<div className="flex flex-col gap-6">
<div className="flex flex-col items-center gap-2 text-center">
<h1 className="text-2xl font-bold">Authorize access</h1>
</div>
{isPending ? (
<div className="grid gap-3">
<Skeleton className="h-5 w-56" />
<Skeleton className="h-5 w-40" />
</div>
) : error || !info ? (
<Alert variant="destructive">
<AlertTriangle />
<AlertDescription>
This authorization request is not valid. Start it again from the
application that sent you here.
</AlertDescription>
</Alert>
) : (
<>
<p className="text-center text-sm text-muted-foreground">
<span className="font-medium text-foreground">
{info.client_name}
</span>{" "}
wants to read and change your flows, and to run them. It will act
as you. Only allow this if you started it.
</p>
<div className="grid gap-2">
<LoadingButton
loading={approve.isPending}
onClick={() => approve.mutate()}
data-testid="approve-client"
>
Allow
</LoadingButton>
<Button
variant="ghost"
onClick={deny}
disabled={approve.isPending}
data-testid="deny-client"
>
Cancel
</Button>
</div>
{approve.isError ? (
<Alert variant="destructive">
<AlertTriangle />
<AlertDescription>
That did not work. Start again from the application.
</AlertDescription>
</Alert>
) : null}
</>
)}
</div>
</AuthLayout>
)
}