Design decisions
This page explains the load-bearing choices behind polar-flow-mcp — the ones that shape how it behaves and that shouldn’t be revisited without discussion.
Single-user via env vars
Section titled “Single-user via env vars”The server logs into exactly one Polar account (POLAR_EMAIL /
POLAR_PASSWORD). To serve more than one account, run more than one instance
(e.g. one container per user — see
Deploy with Docker Compose).
The Flow web API requires email + password. Storing per-user passwords is a step up in risk over OAuth tokens — passwords are often reused and can’t be revoked granularly — so one container per Polar account is the recommended multi-user pattern rather than a shared multi-tenant server.
Cookies-only persistence
Section titled “Cookies-only persistence”The password is held in memory only. The server logs in once, persists
FLOW_SESSION + remember-me to a chmod-600 JSON jar (COOKIE_JAR_PATH), and
re-uses them on later runs. Polar’s rolling 14-day remember-me cookie means an
active server never has to re-prompt.
There is no database — no SQLite, no migrations, no encrypted token store.
Just the cookie-jar file. This keeps the image tiny (FROM scratch, ~14 MB) and
the operational surface small.
Generated client, custom transport
Section titled “Generated client, custom transport”Every wire call is type-checked Go generated from the OpenAPI spec by ogen. A
custom ht.Client transport wraps ogen’s generated client so we can inject the
cookie jar, the required headers, and the 401-retry without touching generated
code (which gets overwritten on every regen).
ogen is used because it has the best OpenAPI 3.1 coverage in Go. Its one quirk:
it rejects the 3.1 nullable-union syntax, so the spec is preprocessed to 3.0.3
via internal/flow/preprocess-spec.py before generation.
Adapter layer for a unified contract
Section titled “Adapter layer for a unified contract”The Flow web API is internally inconsistent — durations arrive in five encodings,
dates in ~eight, and units and field names drift endpoint to endpoint (metres vs
km, km/h vs m/s, hrAverage vs hrAvg, ""/-1/" " used as null). Rather
than let those quirks reach the model and the MCP apps, a dedicated adapter
package (internal/convert) owns one canonical, unit-consistent contract —
seconds, metres, km/h, bpm, ISO 8601, real nulls — and does all wire conversion
in one place. internal/flow stays a thin transport; internal/convert holds
the pure primitives (units.go) and the response DTOs and their mappers
(dto.go). The full contract lives in
Units & dates.
This keeps a single seam for the divergences: every tool argument, tool result, and MCP-app payload speaks the same language, and the messiness is quarantined to one unit-tested package instead of being re-derived ad hoc in each handler.
Browser-fingerprint TLS + HTTP/2
Section titled “Browser-fingerprint TLS + HTTP/2”flow.polar.com sits behind a CloudFront WAF that inspects JA3/JA4 fingerprints
and HTTP/2 framing. Default Go net/http gets 403 X-Cache: Error from cloudfront on the login redirect. uTLS alone (TLS only) is insufficient. So:
- The login chain uses
azuretlswith a Chrome JA3 + HTTP/2 preset. - The stdlib
http.Clientused by ogen for API calls uses a Chrome uTLS ClientHello overhttp2.Transport.
The X-Requested-With header
Section titled “The X-Requested-With header”Play’s CSRF filter (the Polar backend) whitelists the
X-Requested-With: XMLHttpRequest header. The transport adds it on every
mutation (any method other than GET), not just /api/* calls —
DELETE /training/target/{id} sits outside /api/* and needs it too. Without
it you get a 403 with an HTML body. An easy footgun.
Bind-first / deferred login
Section titled “Bind-first / deferred login”flow.New never logs in. A full login takes seconds (CloudFront WAF + redirect
chain), and blocking startup on it would block the listener from binding, which
races the MCP client’s initialize call and times it out at 60 s. So the server
binds first and logs in lazily (EnsureSession runs from the transport, warmed
up in a background goroutine). Binding first makes the MCP handshake instant.
Optional inbound OAuth (app-as-Authorization-Server)
Section titled “Optional inbound OAuth (app-as-Authorization-Server)”Enabled by OAUTH_PUBLIC_URL (off by default), the server can act as its own
OAuth 2.1 Authorization Server with Dynamic Client Registration — so it can be a
Claude.ai connector and work with Claude Code, both of which speak DCR. It
signs and validates its own EdDSA JWT access tokens locally (no database, no
introspection). Browser login on /authorize is delegated to a forward-auth
proxy. See Expose the server securely and the
security model for the trust boundaries.
Confirm-before-write, but only where it works
Section titled “Confirm-before-write, but only where it works”create_training_session and delete_training_target ask the user to confirm
before they run, via an MCP elicitation the client answers and retries with (see
User confirmation on writes). Before this
the only thing standing between a coach model and an irreversible delete was
prose in the tool description — a hint, not a gate.
The subtlety is on the other side of it. mcp-go’s bridge for pre-2026-07-28
clients answers an input request by sending the client a server-initiated
elicitation/create, and both the stdio and streamable-HTTP session types
implement SessionWithElicitation unconditionally — so it will send that
request to a client that never declared the capability, and then fail the whole
tool call with ErrElicitationNotSupported or block until it times out. So the
server probes the client itself (canElicit in internal/mcp/confirm.go,
reading the declared capability from the session for legacy clients and from the
request _meta for modern ones) and skips the gate entirely when the client
cannot answer. An unconfirmable call behaves exactly as it did before the gate
existed. A confirmation that breaks the tool on half the hosts protects nothing.
Cacheable catalogues
Section titled “Cacheable catalogues”Every catalogue this server serves is fixed at build time: tools are registered
once at startup with no tool filter and no per-session set, and the MCP-app UI
resources are go:embeded into the binary. So WithCacheHints advertises them
as public, cacheable for five minutes — identical for every caller, changing
only when the binary does. The hint rides on protocol 2026-07-28 and later
only; older clients revalidate exactly as they always did. The TTL is short on
purpose, so a client holding a stale catalogue across a redeploy recovers in
minutes rather than for the life of its connection.
The request lifecycle
Section titled “The request lifecycle”What the server does on every Polar API request:
- The ogen-generated client builds the HTTP request.
- A custom
http.RoundTripperinjects:X-Requested-With: XMLHttpRequeston every mutation (Play’s CSRF filter requires this).- A browser-ish
User-Agent(Polar serves different responses to non-browser UAs on adjacent endpoints). - The current
FLOW_SESSIONcookie via Go’snet/http/cookiejar.
- If Polar responds
401 {"error":"NotAuthenticated"}, the wrapper runs the 3-hop silent refresh (/flowSso/login → /oauth/authorize?continue → /flowSso/redirect). On success it replaces the staleFLOW_SESSIONcookie on the original request and retries once. On failure it falls back to a full email/password login. - The updated cookie jar is persisted to disk best-effort (failures are logged but don’t fail the API call).
This is why callers never see a 401 unless the credentials themselves are bad —
the refresh-then-retry is transparent.