This document describes the design, configuration, security model, and developer setup for the gflow-cli MCP server.
The gflow-cli MCP server acts as a type-safe JSON-RPC interface, supporting two transport mechanisms:
- stdio Subprocess Transport (
gflow mcp run): Runs over standard input/output (stdio), ideal for direct integration with local desktop agents like Claude Desktop, Cursor, or VS Code. - Streamable HTTP Transport (
gflow serve): Runs as a background web daemon over HTTP at/mcp, ideal for decoupled web UI dashboards, concurrent scripts, or external clients. The legacy HTTP+SSE transport remains available for one deprecation cycle viagflow serve --transport sse.
The server is built on the mcp>=2 Python SDK and negotiates the protocol
per connection, serving both eras from one binary:
| Era | Versions | Notes |
|---|---|---|
| Handshake (legacy) | 2024-11-05, 2025-03-26, 2025-06-18, 2025-11-25 |
initialize/initialized exchange, Mcp-Session-Id |
| Modern (stateless) | 2026-07-28 |
No handshake, no session id; protocol version and client capabilities ride in _meta on every request |
We write no protocol code for this — the SDK's low-level Server.run drives
serve_dual_era_loop, and the client's first request decides the era.
The 2026-07-28 stateless core costs us nothing structurally: the server holds no
per-connection state. Cross-call continuity lives in SQLite (gflow.db) and the
Chromium profile directory, keyed by the profile argument that every tool
already takes — which is exactly the "server-minted handle passed as an ordinary
tool argument" pattern the spec now prescribes. ProfileLease is a cross-process
file lock, not a session, so it is unaffected. We use none of the features the
spec deprecated (Roots, Sampling, Logging).
Dependency bound. pyproject.toml pins mcp>=2.0.0,<3. The upper bound is
mandatory, not cosmetic: MCP SDK majors carry breaking protocol-era changes
(2.0.0 deleted mcp.server.fastmcp outright, which an unbounded mcp>=1.0.0
happily resolved into — breaking every fresh install of this surface while CI
stayed green on the lockfile). The resolve-drift CI job installs from the
declared ranges without the lockfile and smoke-imports this surface, so the
next such break fails CI instead of reaching users.
Response caching. 2026-07-28 added ttlMs/cacheScope to list results. Our
listing surfaces are decided at import time by decorators, so tools/list,
prompts/list, and resources/list advertise a one-hour TTL. resources/read
gets five minutes because it is not static — the known-issues resource reads
KNOWN_ISSUES.md off disk. cacheScope is private throughout: gflow is a
local, single-user daemon driving one user's authenticated browser profile, so
public would authorize shared caching of user-scoped responses.
┌────────────────────────────────────────────────────────────┐
│ Client AI Agent (Claude / Cursor / Web Dashboard) │
└─────────────┬───────────────────▲──────────────────────────┘
│ JSON-RPC │ JSON-RPC
│ (stdio / HTTP) │ (stdout / SSE)
┌─────────────▼───────────────────┴──────────────────────────┐
│ MCP Server Adapter (MCPServer / FastAPI app) │
│ Exposes: Tools, Prompts, Resources │
└─────────────┬──────────────────────────────────────────────┘
│ internal calls
▼
┌────────────────────────────────────────────────────────────┐
│ gflow-cli Core │
│ - FlowApiClient (Playwright / REST requests) │
│ - SQLite operations catalog (DataStore) │
├──────────────────────────────┬─────────────────────────────┤
│ Chromium Profile Lock │ Direct SQLite Read │
│ (asyncio & file-based) │ (Fast read paths) │
└─────────────┬────────────────┴──────────────┬──────────────┘
│ writes cookies │ queries history
▼ ▼
[profile_<name>/] [gflow.db]
Both Click CLI (src/gflow_cli/cli.py) and MCP Server (src/gflow_cli/mcp/) are thin adapter layers that drive the core application services (FlowApiClient and repository.py), ensuring zero duplication of business logic.
The server registers three protocol surfaces:
gflow_generate_image(prompt, model, aspect, count, reference_images, reference_entities, reference_entity_names, tools, profile, project, project_name, instructions, ui_mode, output, wait): Triggers text-to-image / image-to-image (Imagen / Nano Banana). There is noseedparameter: Flow picks the seed and the image request cannot carry one (removed in #1013, as the CLI's--seedwas in v0.8.0). A completed result reports the catalogued seed underseed: the seed Flow chose on the classic image paths,0on the agentic / agent-only arms (which observe none),nullif none was recorded — the MCP twin ofgflow image t2i --json'simages[].seed.instructionsis an optional list of ephemeral agent-instruction strings (agentic cohort only).reference_imagesswitches to i2i and accepts either a local file path or a generated image's Flow media UUID. A UUID reference is attached by selecting the already-existing asset in Flow's reference picker — no duplicate copy is uploaded (locating the tile by the media id in its thumbnail URL, and searching the recorded display name to surface it when needed); gflow falls back to uploading the asset's on-disk local file only when it can't be located in place (e.g. it lives in a different project's picker).projectgenerates into an existing Flow project id (mirrors CLI--project; when omitted,GFLOW_CLI_PROJECTis used unlessproject_nameasks for a new project, #1039) — pass the reference's project to keep it selectable in place.ui_modeselects the Flow UI arm (auto/classic/agentic, mirroring CLI--ui-mode, matched case-insensitively). Since #595automeans "no arm was asked for" and resolves toclassic— the arm that can satisfy an image request — so an account in Flow's agentic cohort aborts pre-submit (exit-28 equivalent envelope, zero credits) instead of failing mid-run with selector drift or video bytes; the agentic arm is bound only when named. Passinginstructionsforcesagenticautomatically, soui_mode="classic"+instructionsis a hard conflict rather than a silent drop. An unknown value returns a 400 problem-details envelope. See CONFIGURATION § GFLOW_CLI_UI_MODE.modelisnano2(Nano Banana 2.1 on flow.google.com;nano2.1also accepted),nano2-lite(Nano Banana 2 Lite),nano-pro(Nano Banana Pro) orimage4(Imagen 4, no longer in Flow's menu); other aliases are accepted too. On migratedflow.google.comaccounts, T2I and local-file I2I are supported with Nano Banana 2.1 (nano2, #958) / 2 Lite / Pro, all five aspects (16:9, 4:3, 1:1, 3:4, 9:16) and count 1–4; the page owns reCAPTCHA and theogiZ0bsubmit.@Namecharacter mentions in the prompt are attached through the composer's@picker and verified as character chips before submit (#723).reference_entitiesmust be paired withreference_entity_namesthere (an id alone is refused, exit 36); areference_imagesUUID is referenced in place when the catalog records it in the sameproject(#995), and a localreference_imagesfile is uploaded into the project once and mentioned in place on later calls with the same file, like the CLI's--ref(#772); an output path gflow cannot write ends in the exit-11-equivalent envelope, with the image still in the Flow project (#1091); other UUID references, instructions and Imagen 4 remain pre-submit refusals there. An agent-only composer (see below) takes T2I only. The queued (wait=false) and blocking (wait=true) calls use the same typed payload and worker path.gflow_generate_video(prompt, mode, aspect, initial_frame, end_frame, reference_images, reference_entities, reference_entity_names, model, duration, resolution, count, tools, profile, project, project_name, ui_mode, output, wait): Triggers vertical or landscape video generation (Veo).modeist2v/i2v/r2v;model(veo_lite/veo_fast/veo_quality/omni_flash/omni_1_2_flash, aliases accepted),duration(seconds — 4/6/8 for Veo 3.1, 4/6/8/10 for omni_flash, every integer 3–15 for omni_1_2_flash, at most 10 with anend_frame; with nomodel, 4/6/8/10),resolution('360p' or '720p', supported on the Omni models; on any other model, or on the labs editor, the job fails pre-submit with the exit-11-equivalent envelope and no credits spent). Whether Flow renders a duration control at all is account/cohort-dependent: on an account that renders none, the job is accepted here and fails in the worker pre-submit (exit 23 equivalent on the labs driver, exit 11 equivalent on the migratedflow.google.comhost — now the default t2v route, #650 — no credits spent either way) rather than being rejected up front — see KNOWN_ISSUES (#451/#288/#630), andcountmirror the CLIgflow videoflags — an omittedmodellets the transport apply its i2v veo-lite default (issue #125), and every model —omni_flashincluded — accepts i2v with a start frame and with an end frame (wire-verified 2026-09-02, issue #626);i2vrequiresinitial_frame,r2vrequiresreference_imagesorreference_entities;projectgenerates into an existing Flow project id (mirrors CLI--project; when omitted,GFLOW_CLI_PROJECTis used unlessproject_nameasks for a new project, #1039); on an account Google has moved toflow.google.com(GFLOW_CLI_FLOW_HOST, read from the server/daemon environment, not per call — see CONFIGURATION § GFLOW_CLI_FLOW_HOST) omittingprojectcreates one there first (#864); there, on the classic migrated composer (an agent-only one takes text-to-video only — see below), the ported modes are text-to-video; image-to-video with a localinitial_frame, optionally with a localend_frame(start+end interpolation, #639) (the file is uploaded through the editor and bound on the Start chip by display name — it stays in the Flow project like any upload, under a run-unique name:hero.pngis listed ashero-a1b2c3d4.png, so a re-run cannot bind an earlier upload — #792); and reference-to-video with localreference_images(each file is uploaded the same way — run-unique name included — and attached as an@mention of that name in the prompt;durationthere accepts every integer from3through15with explicitmodel="omni_1_2_flash"(set through its duration slider; Google AI plans are required above10) and4/6/8/10with explicitomni_flash; Veo or an unspecified model accepts only8, because measured shorter Veo requests dropped the references and billed text-to-video, so any other value returns the exit-11-equivalent envelope. An omitted r2v duration still pins8to avoid inheriting the editor's previous setting). A Flow media UUID asinitial_frameorend_frame, a character ont2vori2v, andreference_entitieswithout pairedreference_entity_namesreturn the exit-36-equivalent envelope;r2vtakes a saved character when eachreference_entitiesid is paired with itsreference_entity_namesentry, with or without localreference_images(#723); a local-fileend_frameis ported (#639).initial_frame,end_frame, andreference_imageseach accept either a local file path or the Flow image UUID of a generated asset — pass a generated image's id straight in to chain image→video, and gflow attaches it for you. Since v0.58.0 (#529) the CLI and MCP surfaces are unified for i2v frames: the UUID keeps its identity and is enriched with the catalog's recorded display name plus an integrity-verified local fallback, so the transport prefers selecting the exact asset in the project's media picker (no duplicate upload) and re-uploads the recorded local file only when the tile is unreachable — and only if its byte count/SHA-256 still match. A UUID that isn't in your local asset catalog is rejected up front with a clear "Reference Not Found" error; a catalogued asset with neither a display name nor a verified local file gives a "Reference Not Usable" error (re-generate it or pass a local path).r2vUUID refs are resolved to the recorded local file for upload.ui_modeselects the Flow UI arm (#299 PR-A, mirroring CLI--ui-mode) and applies to every mode of this tool, includingr2v— unlike the CLI, wherevideo r2v/chainhave no flag and follow the env-only path. On labs and the classic migrated composer, video generation has only a classic driver, soauto≡classic: both verify the classic editor pre-submit and abort before spending credits if it is unreachable.ui_mode="agentic"is rejected with a 400 problem-details envelope, because no agentic video driver exists yet. (An agent-only migrated composer is routed before this check: text-to-video runs through its chat agent underui_modeautoorclassic—"agentic"is still rejected with the 400 above, before any browser work — and other video forms return the exit-36-equivalent envelope before anything is clicked.) Values are matched case-insensitively. (MCP tools return envelopes, never process exit codes — the CLI equivalents of these aborts are exit 28 and exit 2 respectively.) See CONFIGURATION § GFLOW_CLI_UI_MODE.
Attaching a saved character (the identity axis). Three routes reach Flow's
referenceEntitieswire and they dedupe against each other: an@Namemention inside the prompt,reference_entitiesby entity id, andreference_entity_namesas the display names paired with those ids (required on flow.google.com). Prefer the id when a display name could be ambiguous — Flow does not enforce unique names.reference_imagesis a different axis entirely: it buys a look, not an identity. Usegflow_character_listto discover ids. On flow.google.com an id must be paired with its name (an id alone is exit 36) andgflow_character_listcannot read the project there (exit 7, #875): take both from Flow's UI. See REFERENCE_STRATEGIES.
waitand long generations (#481, #1097). Both generation tools takewait(defaulttrue), and its schema description intools/listsays what follows. Withwait=truethe tool runs the job inside the call, so the call stays open for the whole generation — minutes for a video — which can outlast an MCP client's tool timeout. Raise the client's tool timeout, or usewait=false. Withwait=falsethe tool returns{"status": "pending", "task_id": ...}at once and runs the job in the background of the same server process, one job at a time per profile within that server. Read it back with the SEP-2663 Tasks extension (io.modelcontextprotocol/tasks): polltasks/get. While the job runs it answersworking; once it has finished it answerscompleted, and itsresultfield holds the same replywait=truereturns (files, project id, seed). A failed generation iscompletedtoo, withresult.isError: true— SEP-2663 keepsfailedfor protocol errors — so checkisErrorbefore you use the files.tasks/cancelstops the job and answers with an empty acknowledgement;tasks/getthen answerscancelled. There is notasks/result: SEP-2663 removed it, and the server answers it-32601. Two limits:tools/callreturns gflow's own{status, task_id}handle, not a SEP-2663resultType: "task", so a client's built-in task support will not pick the job up; and a task id is a bearer capability — any client that can reach the server and knows the id can read or cancel it. A job cancelled after its submit fired is stored asindeterminate, notfailed: a credit may have been spent, so check the Flow project before you retry. The job lives only as long as the server: when the client closes a stdio server, orgflow servestops, running jobs are cancelled and recorded the same way. A server killed outright leaves its rowprocessing— see Known issues. Jobs queued withwait=falseby v0.90.0 or earlier were never run and are not picked up now: they staypending, andtasks/cancelclears them.tasks/getsendsttlMs: null, because gflow keeps task rows with no expiry.
gflow_auth_status(profile): Credit-free, non-interactive Flow session probe (#497) — wraps the same fail-closedverify_flow_profilecheck asgflow auth status. Returns{"status": "authenticated", "profile", "user_email"}or a problem-details error with aremediation_hint; averification_erroroutcome (HTTP 503,retryable: true) means a network/endpoint problem, which re-login does not fix, and aprofile_marker_missingoutcome (HTTP 409,retryable: false, #796) means the profile lost its.gflow_browser_strategymarker so its cookies cannot be read — a retry does not help, and neither does a plaingflow auth login; re-rungflow auth login --browser chromefor that profile. Acookie_store_unreadableoutcome (HTTP 409,retryable: false, #796) means the profile's Chrome cookie database is corrupt or unreadable — a local file fault, not the network; close any Chrome window using that profile, then re-rungflow auth loginfor it. Any other unauthenticated outcome is a dead session (HTTP 401). Call it before a generation tool to fail fast on dead auth, before a long blocking generation call. Login/logout remain CLI-only (genuinely interactive).gflow_get_credits(profile, all_profiles, refresh): Current Flow balance query; spends no credits. Withall_profiles=true, inspects every saved profile sequentially and preserves successful balances plustotal_creditswhen another profile fails. The balance is read from flow.google.com in the profile's browser: the number the app itself loads, which is the one Flow's account menu shows (#795). That is one browser launch per profile, and the tier fields (sku,service_tier,user_paygate_tier,subscription_credits) are alwaysnull. Withrefresh=true, a profile that lands on a sign-in page is renewed in a headless browser and read again: one more Chrome launch per such profile, no credits. This is the MCP twin ofgflow credits user|list --refresh. It runs the same renewal asgflow auth renew, but only for a profile whose credits read lands on a sign-in page.auth renewitself has no MCP tool; see the exemption intests/mcp/test_cli_parity.py. Withoutrefreshthe call is read-only; with it, an expired profile's saved cookies are rewritten. This tool spends no credits and returns no cookies, bearer tokens, or browser API keys. The balance funds Veo video generation; image generation uses separate per-model daily quotas. A page that loads but never fetches the balance is aFlowAppError(exit 31), never an auth error.gflow_character_list(project, profile): Lists a project's saved Flow CHARACTER entities with their entity ids — read-only, spends no credits, drives a browser session. This is how an agent discovers what it can attach: anentity_idgoes toreference_entities, adisplay_namecan be used as an@Namemention or goes toreference_entity_namesat the id's position. On flow.google.com it cannot read the project yet and returns awire-formaterror (the CLI's exit 7, #875): ask the user for the name. An empty list means the project genuinely has none.gflow_character_show(project, entity_id, name, profile): Shows one character by id or exact display name (exactly one selector required). An ambiguous name is refused rather than resolved arbitrarily — which is the reason to prefer the id. Read-only; drives a browser session.gflow_character_voices(): Lists the preset voices available for Character TTS (name, description, sample URL). A static in-process lookup — no network, no browser, no profile, no cost. Call it to pick a valid voice name.gflow_list_projects(profile, limit, offset): Queries the SQLite catalog for recent generation folders, paginated — the response carriescount(rows in this page),offset,has_more, andnext_offset(pass it back asoffsetfor the next page;nullon the last page).gflow_usage_stats(weeks): Summarize local gflow-cli usage statistics over the last N weeks (default 12, range 1..53). Returns command invocation counts across surfaces from local telemetry (or null when telemetry is off) and generation counts by command, model, status, and month from the local catalog. Itstelemetryblock includesstate,switch,upload(enabledandreason), and the current report (#1164). The tool reads local data and spends no credits; like other MCP calls, its telemetry hook may start a separate weekly upload when eligible. MCP does not expose telemetryonoroff; those controls are CLI-only by design.gflow_download_media(media_id, out_dir, profile): Fetches an already-generated asset from Flow by its media ID and writes it to disk — the recovery path for a generation that was billed but whose download failed. Spends no credits. The bytes are verified against the size Flow reports before the file is written, and the catalog'slocal_filesrow is updated. Returnspath,bytes,media_id,workflow_id,project_idandprofile. The transfer retries a dropped connection internally (#895), so a failure markedretryablemeans pause and call again — not immediately, and not in a tight loop: each call opens a browser under a per-profile lease, and a second concurrent call fails on that lease instead (exit 11).gflow_upscale_image(media_id, scale, project, out_dir, profile): Upscales an already-generated image to 2K or 4K.scaleis2kor4k(4K requires Ultra; Pro accounts support 2K). Spends no credits.gflow_upscale_video(media_id, scale, project, out_dir, profile): Upscales or exports an already-generated video to 1080p Full HD, downloads the original 720p, or exports a 270p animated GIF.scaleis1080p,720p, or270p. Driven onflow.google.comonly; measured free (1 observation, 2026-10-07).gflow_list_tools(): Lists the prompt tools (name/title/description/category) accepted by the generate tools'toolsparam. Eachtoolsitem is{"name": <slug>, "options": {...}}, e.g.[{"name": "creative-director"}].gflow_list_models(): The image and video model catalog, the same object asgflow models --json(#883): per modelname,label(image: Flow's menu name), the CLI's--modelaliases(the generate tools accept each of them asmodel, plus wider spellings such asnarwhalorveo_lite),ref_cap,default(image) andmax_duration(video), plusaspectsand videotiers. Built from the same enums the generate tools use, so every alias it lists is accepted back. Static: no profile, no browser, no spend.gflow_instructions_list(project, profile): Lists a project's persistent Agent-Mode instruction cards (live server brief; credits-free).gflow_instructions_add(project, title, text, refs, enabled, profile): Adds a persistent instruction card. Each ref is classified automatically — local image path → uploaded image reference, asset UUID → image reference, anything else → character id/name (mirrors CLIinstructions add --ref).gflow_instructions_set_enabled(project, enabled, title|card_id, profile): Enables/disables one card selected by title or stable card id (covers CLIinstructions enable/disable).gflow_instructions_rm(project, title|card_id, profile): Removes one card from the brief.gflow_instructions_toggle_mode(project, enabled, profile): Flips the brief-level master switch; cards are left untouched.gflow_instructions_apply(project, cards, profile): Declarative full-sync — REPLACES all cards with the given set (destructive; same entry shape as the CLIinstructions applyfile).
CLI↔MCP parity is enforced programmatically: tests/mcp/test_cli_parity.py walks every CLI leaf command and fails when one has neither a mapped MCP tool nor an explicit exemption with a stated reason. Note the deliberate asymmetry: gflow_generate_video has no instructions param (unlike gflow_generate_image) because the video pipeline (GenerateVideoRequest / the worker) has no instructions support — agentic-video is a typed divergence, and a dead parameter would be silently dropped. Both gflow_generate_image and gflow_generate_video support an output parameter mirroring the CLI -o/--output flag (#414, #415), decoding explicit output destinations in the worker path. Both tools refuse an output that names a directory (including the output directory itself) with a 400 Invalid Output envelope before anything is queued, as the CLI's -o refuses one (#1114). Unlike the CLI's -o, an MCP output — and the out_dir of gflow_download_media, gflow_upscale_image and gflow_upscale_video — must resolve inside the configured output directory (GFLOW_CLI_OUTPUT_DIR): a relative value is taken relative to it, and a value that resolves outside it (.., an absolute path elsewhere, a link leading out) is refused with a 400 Invalid Output / Invalid out_dir envelope before anything runs. An MCP client may be steered by untrusted content, so it cannot choose where on disk the server writes; for the same reason a tool that opens a browser profile takes profile only as a plain name, never a path (GHSA-hqfj-4jww-76f2). gflow update is a considered exemption, not a backlog item: the package manager would replace the venv under a live gflow mcp run and write its output onto the JSON-RPC stdout channel — the operator runs it and restarts the server. A read-only twin of gflow update --check would be harmless; it is the upgrade path if agents ever need version awareness beyond the initialize handshake.
expand_prompt: Helps the agent structure simple ideas into Google's official 5-component prompt formula (Subject + Action + Location + Composition + Style) before sending them to the generation tools.create_character: Assists agents in defining face, body, and voice parameters for consistent subject generation.
gflow://docs/mcp-guide: A specialized, agent-targeted guide instructing the LLM to use the registered MCP tools (rather than running raw shell wrapper commands).gflow://docs/known-issues: Bounded index of KNOWN_ISSUES.md (titles + status, a few KB — #501); readgflow://docs/known-issues/{slug}for one issue's full text (capped at 16 KB). The old behavior injected the whole ~70 KB file per fetch.gflow://db/schema: Exposes SQLite schema definitions, allowing agents to understand project and media tables.
We analyzed whether a terminal-driven CLI guided by a text skill (e.g., skills/gflow-cli/SKILL.md) is sufficient. The LLM Council concluded that the MCP server is fully justified for these reasons:
| Dimension | Direct CLI via Terminal Execution | Native MCP Server Daemon |
|---|---|---|
| Output Fragility | Ephemeral stdout/stderr log outputs are fragile to format adjustments, progress bars, and ANSI colors. |
Strictly structured JSON payloads containing explicit metadata and absolute output file URIs. |
| Process Lifecycle | High process startup overhead (Python import latency) on every individual execution. | Warm daemon process. Retains active caching of database metadata. |
| Concurrency | A second concurrent process against the same profile is rejected immediately by the cross-process ProfileLease with ProfileLockedError (exit code 11) — a clean fail-fast, never a crash, never a wait. Different profiles run fully in parallel. |
Calls on one profile inside one server run one at a time — a second call waits for the first instead of failing. A profile held by a different process (a CLI run, another server) is waited out for up to 180 s (GFLOW_CLI_LEASE_WAIT_SECONDS, which a value you set yourself overrides) before ProfileLockedError; different profiles still run fully in parallel. |
| Error Handling | Agent must scan logs for strings or parse exit codes to check status. | Strongly-typed JSON-RPC errors with mapped codes and clear remediation. |
| Transport Safety | Volatile console printing. | Stdout is strictly isolated for JSON-RPC; all logs and warnings route to stderr. |
MCP tool failures return a structured error object built from the same RFC 9457
Problem Details as the CLI, plus a retryable boolean. retryable: true
marks a transient failure a scheduler can re-run without operator intervention —
WAF/reCAPTCHA bounce (WafRejectionError), rate-limit (RateLimitError),
transport timeout (TransportTimeoutError — but one raised after a submit click is
false, see below), network blip (NetworkError — but not a failed download of media Flow already made,
see below), a
dropped browser session (BrowserSessionClosedError), a Flow web-app crash
(FlowAppError), an agentic-cohort flap (FlowAgentUiError — but see the
agent-only exception in § 6.1, which overrides it to false), an unreachable
UI arm (UiModeUnavailableError), and a partially-completed sync
(SyncPartialError). That list is errors.RETRYABLE_ERRORS, but it is no longer
the whole answer: errors.is_retryable consults the instance first, so a raise
site can override its class. Among them, Flow's /about redirect raises
FlowAppError with retryable: false, and since 2026-09-11 that is a measurement, not a
preserved default: caught during a live occurrence, 5/5 consecutive attempts over ~3 minutes
landed on /about again, on the account's own project with a healthy session — so a retry is
doomed and costs ~35 s each (#756). The
A video submit gflow refuses on flow.google.com (#1178) is
WireFormatError, retryable: false, aborted before it was sent (its hint says nothing was
billed): re-running the same inputs usually repeats the refusal, so check the frames or
references first. The
second is the migrated agent-only composer (#799),
which gflow drives through its chat agent: there FlowAgentUiError carries retryable: false
because it means the agent did something other than what was asked — a credit
confirmation quoting more than requested (gflow rejects it, nothing is approved;
#988), a video confirmation whose count
gflow cannot read (not approved, and left open in Flow for you to reject), a second credit
confirmation, more results than requested, or an image turn that made nothing (a video turn
that makes nothing ends in TransportTimeoutError, also retryable: false there, since the
request was already sent) — and a blind re-run repeats the ask. Its detail may include the
agent's last reply. The
cause, and whether it ever clears, remain unmeasured. The same holds on the classic
flow.google.com composer for any TransportTimeoutError raised after the submit click
(#1010): an unreadable or missing
submit reply, or a generation that is not terminal in time, is retryable: false, because
Flow may already have made and billed it. An unreadable reply on a video run that uploaded a
file (reference_images, initial_frame or end_frame) is followed to its clip instead and completes
normally (#1112), and so is a one-clip
text-to-video run, found by its exact prompt and model (#1141). Text-to-video with count
above 1, a character-only run, or a clip the project listing cannot name exactly once still gets
the retryable: false error. Likewise a failed download of a finished
generation (MediaDownloadError, exit 6, type …/errors/media-download) is
retryable: false (#941): the clip
exists and was billed, so the recovery is gflow_download_media with its media_id, which
spends nothing and stays retryable: true. The override also runs the other
way: a reCAPTCHA mint failure (type …/errors/recaptcha-mint, exit 1, no request sent,
no credit spent) is retryable: true when a navigation raced the mint or the page was a
Flow page whose script had not loaded yet, and false on a page that is not a web page —
each measured live (#915).
Read the flag off the envelope; never re-derive it from the class list.
Everything
else (auth, content-policy, configuration, security) is terminal
(retryable: false): retrying the identical request fails the same way. This
flag is the same shared classification the CLI --json payload and the
worker-queue error record use (errors.is_retryable) — the three surfaces
cannot drift. On a captured failure the envelope also carries a remote-safe
incident object ({id, capture_status} only — never a local path); see
DEBUGGING § Automatic incident bundles.
Every tool routes through one error funnel. Unexpected (non-gflow) exceptions
are masked: the client sees only the exception class name
("Unexpected RuntimeError; details were logged server-side.", status: 500,
retryable: false) — raw exception text can embed filesystem paths, profile
names, or token material and never leaves the server; the full message and
traceback go to the server-side structured log (mcp.tool.unexpected_error).
A failed generation task can still name its media. When Flow generated the media and
only the download failed (a video whose transfer dropped, #895; an image whose local write
failed, #1035), the status: "failed" reply of gflow_generate_image /
gflow_generate_video also carries flow_media_id and, when the catalog knows it,
flow_project_id. Both keys are absent when the task failed before Flow named any media.
With count > 1, flow_media_id is the first image's id only; every image's id is in the
catalog (gflow data list images; there is no MCP twin for that listing).
A video is then recoverable with gflow_download_media; an image is not (that tool is
video-only, #877), so open the project in Flow. The catalog keeps the image's asset row,
with no local copy, under one FAILED operation.
/plugin marketplace add ffroliva/gflow-cli
/plugin install gflow@gflow-cli
This installs the gflow-cli and video-production skills and registers this MCP server,
so there is nothing further to configure.
It ships disabled on purpose. Claude Code starts a plugin's MCP servers automatically when the plugin is enabled — there is no separate prompt for the server itself. This server drives your own signed-in Google account, and Veo video generation bills your credits. So the plugin sets
defaultEnabled: false: installing it does not start anything, and enabling it is a deliberate act. Images and composition cost nothing; only video spends.The plugin also declares a required
userConfigacknowledgement, which Claude Code prompts for when you enable it in the UI. Be aware that it is a prompt, not an enforcement gate —claude plugin enableon the command line succeeds without it.defaultEnabled: falseis the control that actually holds.The hard guarantee, if you want one, is
--no-spend. It is not a policy the model is asked to respect: the credit-spending tools are never registered, so they do not appear intools/listat all. Register the server yourself withgflow mcp run --no-spend(see the manual setup below) instead of using the bundled entry.
The plugin runs gflow mcp run, so gflow must be on your PATH (uv tool install gflow-cli)
and you must have authenticated once with gflow auth login --browser chrome.
Which revision you get.
/plugin marketplace add ffroliva/gflow-clireads the marketplace from the repository's default branch, which in this project isdevelop— the integration branch, not the last release. So the plugin you install tracksdevelop, while the version string in its manifest and the documentation links inside its skills both point at the last released state. If you want a released revision, install from a tag instead of the default branch, or use the manual MCP setup below and a pinnedgflow-clifrom PyPI.
Run the configuration helper command in your terminal:
gflow mcp setupThis merges the server entry into your Claude Desktop configuration file — existing content is preserved, and a pre-existing file is backed up as <name>.gflow-backup first. A corrupt config fails loud (exit 11) and is never overwritten:
- Windows:
%APPDATA%\Claude\claude_desktop_config.json - macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Linux:
~/.config/Claude/claude_desktop_config.json
Other targets: gflow mcp setup --target cursor (~/.cursor/mcp.json) and --target vscode (the user-profile mcp.json, written with VS Code's servers + "type": "stdio" schema).
Existing entries are preserved: if your config already has a
gfloworgflow-cliserver entry (including the local-cloneuv --directoryvariant below),gflow mcp setupleaves it completely untouched and reports "Already configured" — it only ever adds a missing entry.
Depending on how you installed gflow-cli, add one of the following configuration blocks under the mcpServers key of your claude_desktop_config.json:
Use this if you installed gflow-cli globally (e.g. via uv tool install gflow-cli or pip install gflow-cli):
{
"mcpServers": {
"gflow-cli": {
"command": "gflow",
"args": [
"mcp",
"run"
]
}
}
}Use this when the agent must never be able to spend credits (#496). The two
credit-spending tools (gflow_generate_image, gflow_generate_video) are then
never registered, so they do not appear in tools/list at all — invisible
rather than refused. Every read-only tool stays available.
{
"mcpServers": {
"gflow-cli": {
"command": "gflow",
"args": [
"mcp",
"run",
"--no-spend"
]
}
}
}Setting GFLOW_MCP_NO_SPEND=1 in the environment does the same thing and also
covers gflow serve. gflow mcp setup writes the plain (spending) block above —
add the flag or the env var yourself if you want the read-only server.
Use this if you cloned the repository locally and run it via uv:
{
"mcpServers": {
"gflow-cli": {
"command": "uv",
"args": [
"--directory",
"/path/to/gflow-cli",
"run",
"gflow",
"mcp",
"run"
]
}
}
}- Open Cursor Settings -> Features -> MCP.
- Click + Add New MCP Server.
- Configure depending on your installation:
- Global Installation:
- Name:
gflow-cli - Type:
command - Command:
gflow mcp run
- Name:
- Local Clone (Development):
- Name:
gflow-cli - Type:
command - Command:
uv --directory /path/to/gflow-cli run gflow mcp run
- Name:
- Global Installation:
For decoupled clients, local web interfaces, or multi-process frontends, run the daemon as an HTTP service:
gflow serve --port 8000 --host 127.0.0.1 --profile default
# Read-only daemon — the two credit-spending tools are never registered (#496):
gflow serve --port 8000 --host 127.0.0.1 --profile default --no-spendThis serves the MCP server over Streamable HTTP, the current spec transport:
- Endpoint:
http://127.0.0.1:8000/mcp
The legacy HTTP+SSE transport is still available for one deprecation cycle:
gflow serve --transport sse --port 8000 # deprecated; logs a warning- Connection endpoint (SSE stream):
http://127.0.0.1:8000/sse - Command posting endpoint:
http://127.0.0.1:8000/messages/
Deprecated: the MCP 2026-07-28 spec reclassified HTTP+SSE as deprecated. Prefer the default
--transport http. The spec's lifecycle policy guarantees a minimum twelve months between deprecation and removal.
stateless_http is deliberately not enabled. The stateless core exists so
servers can scale out across interchangeable instances; gflow's value is the
opposite — a warm daemon holding one live Chromium profile, serialized by
ProfileLease. The protocol is stateless either way; that flag only governs
transport bookkeeping we want to keep.
Set GFLOW_DAEMON_TOKEN (alias GFLOW_CLI_DAEMON_TOKEN) and every request
to the daemon must present it:
Authorization: Bearer <token>A missing header, a non-Bearer scheme, or a wrong token is answered 401 with
WWW-Authenticate: Bearer — on /mcp and on both halves of the deprecated SSE
surface (/sse, /messages/). The comparison is constant-time
(hmac.compare_digest), and auth is the outermost layer, so an unauthenticated
caller learns nothing about the transport's DNS-rebinding allow-list.
Non-loopback binds (e.g. --host 0.0.0.0) require the token — gflow serve
refuses to start without one (exit 11). A loopback bind with no token stays
unauthenticated, which is the local single-user default.
Note: the background
FlowWorkerqueue manager and the REST/api/v1surface are built as internal foundation but are not yet wired intogflow serve— it currently runs the MCP/SSE server only. See the CHANGELOG for the roadmap.
Settings reach the server through its environment, not your shell's
.env. An MCP client startsgflow mcp runin a directory of its own choosing, so a.envbeside your shell is not read. Put settings such asGFLOW_CLI_BROWSER_PROXYin$GFLOW_CLI_HOME/.envor in the client'senvblock, and restart the server after changing them.
However you register it, that server then gates your upgrades. Every client above starts a long-lived
gflow mcp run— one per session — and on Windows it holds the install open, so a package manager can abort part-way throughgflow updateand leave the environment broken. Stop the servers first: USAGE §gflow update.
Because the MCP server runs locally, inheriting the host user's permissions and access to their authenticated browser cookies, the following security constraints are enforced:
- Profile Pre-flight: Before enqueuing work, each tool resolves and validates the target profile directory (
_resolve_and_validate_profile— existence and home-boundary checks). There is no session-validity probe before launching Chromium: expired cookies surface as a typedAuthExpiredErrorfrom the generation run itself, with the remediationRun 'gflow auth login' in your local terminal. - Channel Isolation: All internal
structlogconfigurations are forced to write tosys.stderr. The standard output stream (sys.stdout) is globally captured and redirected tosys.stderrfor any unexpected prints, preserving the integrity of the stdio JSON-RPC pipe. - Windows Stdio Encoding: During startup, stdio streams are explicitly reconfigured:
This prevents crashes caused by non-ASCII prompt strings on Windows.
sys.stdout.reconfigure(encoding='utf-8') sys.stdin.reconfigure(encoding='utf-8')
- Local Rate-Limiting: Enforces a token-bucket rate limiter with a capacity of 8 tokens and a refill rate of 1 token every 20 seconds (allowing burst filmmaking tasks without timeouts). This is the only spend brake — there is no credit-budget accounting or per-session/daily cap (#495; a registration-time
--no-spendgate is tracked in #496). - CLI-MCP Parameter Symmetry: Two CI layers guard the surfaces against drift:
tests/mcp/test_cli_parity.pyforces an explicit MCP decision (mapped tool or stated exemption) for every CLI leaf command, andtests/mcp/test_server.py::TestCliMcpParameterSymmetrycompares CLI Click parameters against registered tool signatures for the two generate tools. Parameter-level comparison does not yet cover the other tools. - No-Spend Mode (#496):
gflow mcp run --no-spend(orGFLOW_MCP_NO_SPEND=1, which also coversgflow serve) never registers the credit-spending generate tools —gflow_generate_imageandgflow_generate_videoare absent fromtools/listentirely, rather than present-but-refusing. Both are gated because image generation is only empirically free and no-spend is a hard guarantee. Listing, instructions, and other read-only tools remain available.
A failing MCP tool call gives an agent much less to go on than a CLI run gives a human:
there is no --help to re-read, no stderr to scroll, and the envelope is deliberately
narrow (no local paths, no raw exception text — see § Error envelope).
This section is the decoder.
Read the envelope in this order: retryable → status → title → detail. Never
re-derive retryable from the class name; a raise site can override its class, and one
does today.
Google has moved Flow from labs.google/fx/tools/flow onto flow.google.com, and the
move is not one step. Measured 2026-09-14 (3 accounts x 2 entry points x 2 runs): the old
labs URL answers HTTP 308 Permanent Redirect on every account tested, yet those same
accounts differ in which capabilities work. So the host you are served does not tell
you what works — several capability states coexist behind one frontend, they change without
notice, and most confusing failures here are really "this account is in a different state
than the one the message assumes". Establish the state before diagnosing anything else — it
is a $0, read-only check:
gflow auth status # which host minted the session, and for whom
gflow credits user # reads the balance from flow.google.com (#795)History. The
credits usercolumn describes builds before the #795 fix, when the balance came from a labs.google token. Since that fix,credits userreads the balance from flow.google.com (#795), so it no longer tells these rows apart. The table is kept for theauth statuscolumn.
auth status |
credits user |
What you are on | Consequence |
|---|---|---|---|
| verified | a balance | labs session alive | Everything documented works |
| verified | fails, "the labs.google session returned no access token" |
migrated; labs authenticates but never mints the ya29 Bearer |
credits cannot work. Since v0.73.2 the remediation says so (#795) |
| verified | fails, "credits endpoint returned 401" |
a token exists but aisandbox-pa rejects it — most often labs' token has lapsed (ACCESS_TOKEN_REFRESH_NEEDED) |
Until 2026-10-22, usually fixable (labs' session URL is gone now, see the note above): gflow_get_credits(refresh=true) (CLI: --refresh or gflow auth renew) renews it headlessly and reads again. Measured 2026-10-14: 7 of 10 such profiles, all served flow.google.com, recovered. The rest were held at Flow's public /about page, which renewal reports and cannot clear (#795) |
| "Signed in to Google, but not to the Flow app" forever | — | migrated; labs no longer mints a Flow session at all | Login cannot complete on the released build. Tracked in #791 |
| "Signed in to Google, but not to the Flow app" | — | or the account has no Flow access at all | Same sentence, second cause. The session endpoint answers 200 with an empty user for an abandoned sign-in and for an account with no Google AI Plus/Pro/Ultra (or qualifying Workspace) plan, so auth status cannot tell them apart. Open https://flow.google.com in a browser on that account: a landing on /unavailable means this row. A generation command says so directly — exit 39, FlowAccessUnavailableError |
In every migrated row above except the no-Flow-access one, generation over the migrated composer still works — only
the aisandbox REST reads fail (credits no longer is one, #795). On the agent-only composer described below that holds for
text-to-image and text-to-video only.
A
creditsfailure names its cause. Since #795 the balance is read from flow.google.com's own reply, not from a labs.google token. A sign-in landing is exit 3 (AuthExpiredError, renewable withrefresh=true/--refresh). Flow's public/aboutpage, or an app that loads without a readable balance reply, is exit 31 (FlowAppError). An account with no Flow access is exit 39. A balance reply in an unexpected shape is exit 7 (WireFormatError), and a profile another gflow process holds is exit 11 (ProfileLockedError). Only exit 3 is about your session. The other codes do not mean "re-rungflow auth login". If the profile has no browser-strategy marker yet, a re-login can make things worse, because a failed first login rolls that marker back. History: before #795, two raise sites in the since-deletedapi/credits.py(labs issuing no token, aisandbox-pa rejecting the token) produced everycreditsfailure. Through v0.73.2 the second one carried the class-default SAPISID advice; see the CHANGELOG.
The migrated composer itself also comes in more than one shape. An account whose composer
is agent-only — no classic composer at all — is driven through its chat agent for
gflow_generate_image (text-to-image) and gflow_generate_video (text-to-video): aspect,
count and model are set in Agent settings for the run and restored afterwards, and
"Confirm before generating" follows the server's GFLOW_CLI_AGENT_CONFIRM. Other forms on
that composer (references, image-to-video, instructions, Omni 1.2 Flash, more than one video)
return an exit-36-equivalent envelope before anything is clicked
(#799), after up to three free page reloads in
case Flow serves the classic composer on the next load (#1142). Through v0.73.2 such an account got
a generic selector-drift envelope (exit 23); v0.74.0 and v0.75.0 named it with exit 25.
The distinction that matters, because the DOM is identical either way: a pressed chip means the classic arm exists and gflow turns it back on itself (#749); no chip at all means there is no classic arm to return to, aspect / model / count are Agent-settings defaults rather than per-request controls, and no flag or profile change helps. Generating from the Flow web UI still works.
| What you see | Almost always | Do this |
|---|---|---|
retryable: true, WAF / rate-limit / timeout |
Transient | Re-run once. If it repeats immediately, stop — it is not transient |
| selector drift, "the frame picker stayed open … and stayed open after its confirm was clicked" | Flow changed the Frames picker again | File it with the verbatim detail; the raise sites are textually distinct and the string identifies which one |
| selector drift, "… and carries no confirm" | A cohort whose picker neither commits nor offers a confirm | As above — this is a new shape, not #792 |
| "the Start chip did not bind" | The search and the click both landed; the chip still did not take the thumbnail | Not retryable (retryable: false) — gflow refuses to submit rather than let it go out as text-to-video. File it with the verbatim detail. (Late server-side indexing is a different failure — "lists no asset named …" — and that path already retries internally) |
| exit-27-equivalent, upload rejected, a dialog opened and no request left the page | Flow's one-time "rights to use this image" confirmation | Open the project on flow.google.com, upload any image by hand, accept it. gflow will not click it for you: it affirms that you hold the rights. Once per account, then uploads run unattended |
| exit-39-equivalent, "This Google account cannot reach Flow" | The account has no Flow access — it needs a Google AI Plus/Pro/Ultra or qualifying Workspace plan, an age-verified account and a supported region | Stop. Do not retry, and do not call gflow_auth_status or suggest a re-login — this is the one envelope where a retry is guaranteed futile, because the editor does not exist for this account. Relay the remediation_hint and its link verbatim; only the human can fix it. Note auth status will separately claim the Flow sign-in was not completed, which is the same sentence with a second cause |
| exit-36-equivalent | The capability is not ported to the migrated host | See CONFIGURATION § GFLOW_CLI_FLOW_HOST for exactly what is served there |
"Profile is missing its browser-strategy marker" (409, retryable: false, from gflow_auth_status) |
The profile's browser-strategy marker is gone — typically after a failed first login | gflow auth login --browser chrome. It is not a network problem, whatever an older build told you |
"Unexpected <Class>; details were logged server-side." (500) |
A non-gflow exception; the text is masked on purpose | Read the server's structured log for mcp.tool.unexpected_error — the traceback is there, not in the envelope |
The envelope carries an incident object ({id, capture_status} only — never a path).
Resolve it on the machine running the server:
<GFLOW_CLI_HOME>/incidents/<YYYY-MM-DD>/<stamp>-<incident-id>-<rand>/
Layout and what triggers a capture are in DEBUGGING § Automatic incident bundles.
Judge the bundle before you trust it. A bundle whose ui.json has all tag counts at
0 and a blank white screenshot photographed an about:blank page, not your failure —
that was a real defect in gflow (fixed in v0.73.2), so a bundle with that signature from an
older build carries no information about what went wrong. It is not evidence that the
page was blank.
For a run you can reproduce, raise the log level on the server process
(GFLOW_CLI_LOG_LEVEL=DEBUG) and capture stderr — MCP writes every structured log line
there, never to the stdio pipe.
Include the verbatim detail string, the retryable/status pair, the two cohort
commands from §6.1, and gflow --version. Redact account identifiers, cookie and token
values, and signed media URLs before pasting anything into an issue. If you have an
incident bundle, say so — but do not paste it: it contains prompts and can contain
identity-bearing attributes.
Omni 1.2 uses model="omni_1_2_flash" (alias omni-1.2-flash), integer durations 3–15 (at most 10 with an end_frame: above that Flow drops the end frame and bills a start-only clip, so it is refused before queueing, #1178) and optional 360p/720p. CLI, direct MCP and queued MCP preserve the explicit model and duration. Unavailable account durations fail before Send; no model fallback or rounding is performed.