Single-user MCP server that drives the reverse-engineered Polar Flow web API
(flow.polar.com) from any MCP client β including creating and deleting training
targets, which the official AccessLink API does not allow.
It speaks standard MCP over stdio and streamable HTTP, so it works with any MCP-capable client or agent. The setup examples below use Claude clients (Claude Code, Claude Desktop, Claude.ai) because those are the ones it is tested with; other clients take the same command or URL.
Not the official Polar AccessLink API. The web API is reverse-engineered from browser traffic. Use a test account where possible. See
internal/flow/README.md.
π Full documentation: https://lmgarret.github.io/polar-flow-mcp/ β tutorials, how-to guides, reference, and explanation (built with Astro Starlight).
More in the MCP app UIs reference.
git clone https://github.com/lmgarret/polar-flow-mcp.git
cd polar-flow-mcp
cp .env.example .env
# Edit .env β set POLAR_EMAIL and POLAR_PASSWORD
go run ./cmd/polar-flow-mcpThe server binds its listener immediately and logs into Polar Flow lazily: on a
cold start the login runs in a background warm-up (and again on the first request
if needed), so the MCP handshake is never blocked behind the login round-trip.
The session cookies are persisted to ./polar-cookies.json (chmod 600) and
re-used on subsequent runs. The password is held in memory only.
If you connect over HTTP (
"type": "http"in your MCP client config), make sure the server is already running and reachable at the URL before the client connects β start the binary ordocker runit first. The client expects a live endpoint; it does not launch the process for you.
Pre-built linux/amd64 images are published to GHCR:
| Tag | Points to |
|---|---|
latest |
Newest tagged release (recommended) |
X.Y.Z, X.Y, X |
Semver ladder β pin as tightly as you like |
edge |
Rolling build of main |
sha-<short> |
Exact commit |
Tagged releases (vX.Y.Z) also get AI-summarised release notes on the
Releases page. Every
merge to main also refreshes a rolling edge pre-release there, listing the
commits since the last tagged release.
# Put credentials in a chmod-600 file, not on the command line
echo "POLAR_EMAIL=you@example.com" | sudo tee /etc/polar-flow/secrets.env
echo "POLAR_PASSWORD=yourpassword" | sudo tee -a /etc/polar-flow/secrets.env
sudo chmod 600 /etc/polar-flow/secrets.env
docker run -d \
-v /etc/polar-flow/secrets.env:/.env:ro \
-e TRANSPORT=http \
-e BIND_ADDRESS=0.0.0.0 \
-e COOKIE_JAR_PATH=/data/cookies.json \
-v polar-cookies:/data \
-p 127.0.0.1:8080:8080 \
ghcr.io/lmgarret/polar-flow-mcp:latest
BIND_ADDRESS=0.0.0.0is required β the default127.0.0.1is unreachable from outside the container.
Then point your MCP client at http://127.0.0.1:8080/mcp.
Pass docker run as the MCP command so the client spawns the container itself:
{
"mcpServers": {
"polar-flow-mcp": {
"command": "docker",
"args": [
"run", "--rm", "-i",
"-e", "POLAR_EMAIL=you@example.com",
"-e", "POLAR_PASSWORD=yourpassword",
"-e", "TRANSPORT=stdio",
"-e", "COOKIE_JAR_PATH=/data/cookies.json",
"-v", "polar-cookies:/data",
"ghcr.io/lmgarret/polar-flow-mcp:latest"
]
}
}
}Two things are non-negotiable for the stdio spawn, and both must be present:
-iondocker runβ without it the container's stdin is not attached, so the client's JSON-RPC messages never reach the process.TRANSPORT=stdioβ the default ishttp, which makes the server listen on a port and ignore stdin entirely.
Also use --rm (not --name/--restart): the client spawns a fresh container
per launch, and a leftover named container makes the next launch fail with
"name already in use".
Symptom of getting this wrong: the client logs
initializeand then, ~60s later,Request timed out/transport closed, while the server's own logs showmsg="polar-flow-mcp starting" transport=http. That mismatch β the client speaking stdio to an HTTP-mode server (ordocker runmissing-i) β means the handshake never arrives. Add-iandTRANSPORT=stdio. The HTTP access log (msg="http: request" rpc_method=initialize) only appears when a request actually reaches the HTTP listener, so its absence confirms a stdio/http mixup.
Credentials are loaded from a secrets file bind-mounted into the container so
they never appear in docker inspect, Portainer's UI, or the environment block.
1. Create the secrets file on the host (once):
sudo mkdir -p /etc/polar-flow
sudo tee /etc/polar-flow/secrets.env <<'EOF'
POLAR_EMAIL=you@example.com
POLAR_PASSWORD=yourpassword
EOF
sudo chmod 600 /etc/polar-flow/secrets.env2. Deploy with the bundled docker-compose.yml:
services:
polar-flow-mcp:
image: ghcr.io/lmgarret/polar-flow-mcp:latest
volumes:
- /etc/polar-flow/secrets.env:/.env:ro # secrets, never in env block
- polar-data:/data
environment:
COOKIE_JAR_PATH: /data/polar-cookies.json
TRANSPORT: http
BIND_ADDRESS: "0.0.0.0"
ports:
- "127.0.0.1:8080:8080"
restart: unless-stopped
volumes:
polar-data:The server reads /.env on startup via godotenv β credentials are in memory only and never written to disk by the server itself.
| Tool | Description |
|---|---|
get_user_info |
Linked Polar account identity + profile basics |
list_sports |
Full Polar sport-id β name catalogue (the sport_id values for targets/sessions) |
get_training_zones |
Heart-rate / speed (pace) / power zones 1β5 of a sport in bpm, km/h (min/km) and W β what the zone numbers on targets mean |
create_sport_profile |
Create a sport's profile with Polar's default zones (idempotent) |
update_training_zones |
Set a sport's HR / speed / power zones by hand, or reset them to Polar's defaults |
delete_sport_profile |
Delete a sport's profile (confirms first; Polar keeps the last one) |
create_training_target |
Schedule a target: no goal, one volume metric (time / distance / kcal), a race pace, or phases (nested repeats, zone ranges, manual advance); multi-sport via exercises |
list_training_targets |
Targets in a date range |
delete_training_target |
Delete a target by ID |
get_training_target |
Read one target by ID |
update_training_target |
Full-replace edit of one target by ID (multi-sport: all sports in exercises; Polar can't add or remove one) |
get_calendar_events |
Raw calendar events in a date range |
get_calendar_week_summary |
Per-ISO-week totals strip (β€45-day range) |
list_training_sessions |
Completed sessions in a date range |
get_training_session_summary |
Summary view of one session |
get_training_session_details |
Compact detail of one session: totals, time in HR / speed / power zones, per-rep splits of a planned target (pace, HR, power), laps, hills |
get_training_session_samples |
A session's HR / speed / pace / power / cadence / altitude curves, averaged into fixed buckets; one rep or a time window at a time |
get_progress_summary |
Aggregated training totals over a range |
get_daily_activity |
24/7 activity per day: steps, active time, calories, intensity bands, day/night HR; intraday curves for one day |
get_sleep |
Recorded nights: sleep times, duration, Sleep Score, continuity, stages and hypnogram |
create_training_session |
Log a manually-entered completed session (writes real data β coach must only call on explicit user request) |
edit_training_session |
Edit a completed session: note, feeling (1β5), and β on manual sessions β name, sport, duration, distance, HR, kcal, speed |
delete_training_session |
Delete a completed session by ID |
list_favorites |
Favorites (workout templates) and imported routes |
get_favorite |
Read one favorite by ID |
create_favorite |
Create a favorite (duration, distance, kcal, race-pace or phased goal β same goal rules as targets, single sport) |
update_favorite |
Full-replace edit of one favorite |
rename_favorite |
Rename a favorite or route |
set_favorite_sport |
Change a favorite's or route's sport (validated and verified) |
delete_favorite |
Delete a favorite or route |
save_target_as_favorite |
Save a scheduled target as a new favorite |
schedule_favorite |
Put a favorite in the diary as a training target on a date |
import_route |
Import a GPX/TCX file (parsed server-side) as a route favorite |
get_route |
Read a route's geometry (down-sampled waypoints) |
create_training_session, delete_training_target, delete_training_session
and delete_favorite ask the user to confirm before they run, on hosts that
support MCP elicitation. Where the client cannot
be asked, the call proceeds unconfirmed β see
User confirmation on writes.
The polar-flow skill (skill/polar-flow/) teaches Claude how to use these
tools together β which one fits, full-replace edits, safe writes. Install it
from the plugin marketplace in this repo (Claude Code):
/plugin marketplace add lmgarret/polar-flow-mcp
/plugin install polar-flow@polar-flow-mcp
or download polar-flow.skill from a release
and upload it in Claude.ai. See
Install the polar-flow skill.
See .env.example. Required:
POLAR_EMAIL,POLAR_PASSWORDβ credentials for the Polar Flow account
Optional:
COOKIE_JAR_PATHβ where to persist session cookies (default./polar-cookies.json)TRANSPORTβstdioorhttp(defaulthttp)BIND_ADDRESS,PORTβ HTTP listen addressLOG_LEVEL,LOG_FILEMCP_API_KEYβ enable inbound auth with a fixed pre-shared key (see below)OAUTH_PUBLIC_URL(+OAUTH_ALLOWED_EMAIL,OAUTH_TRUSTED_PROXIES) β enable inbound OAuth (see below)
By default the HTTP transport has no inbound auth β run it on localhost or a trusted network. Two mutually-exclusive mechanisms secure a public deployment.
Set MCP_API_KEY to a secret of at least 24 characters (openssl rand -base64 32)
and every /mcp request must carry it as Authorization: Bearer <key>. Nothing
else to run β no issuer, no signing key, no forward-auth proxy. Works with
Claude Code:
claude mcp add --transport http polar-flow https://polar.example.com/mcp \
--header "Authorization: Bearer $MCP_API_KEY"The trade-off: one long-lived secret shared by every caller, no per-client identity, and revoking it means restarting with a new key. Serve it over TLS. Claude.ai's connector UI expects a full OAuth flow, so use OAuth for that.
Set OAUTH_PUBLIC_URL to turn the server into its own OAuth 2.1
Authorization Server: clients self-register via Dynamic Client Registration
(nothing to paste), browser login on the consent step is delegated to a
forward-auth proxy (Authelia), and an email allowlist decides who may connect.
Access tokens are short-lived EdDSA JWTs the server signs and validates itself β
no database. Because it provides DCR, Claude Code (CLI) works over the same
public URL.
See Expose the server securely (Caddy + Authelia + Claude.ai)
for the complete walkthrough β including why forward-auth goes on /authorize
only and which hardening env vars to set.
This server is single-user by design: it acts as exactly one Polar account. To serve more than one account, run multiple instances (e.g. one container per user). The previous OAuth / proxy-auth machinery was removed because the Polar Flow web API requires email/password and per-user credential storage is a materially heavier risk profile than per-user OAuth tokens.
internal/flow/ wraps an ogen-generated
Go client against a vendored copy of the OpenAPI spec from the sibling
polar-openapi-maker repo.
The wrapper:
- Performs Polar's internal OAuth login chain headlessly (see
internal/flow/login.go). - Persists
FLOW_SESSION+remember-meto a chmod-600 cookie jar. - Injects
X-Requested-With: XMLHttpRequeston every/api/*mutation (Play's CSRF filter requires it). - Detects
401 {"error":"NotAuthenticated"}and runs a 3-hop silent refresh before retrying the request once.
This project is not affiliated with, endorsed by, or sponsored by Polar Electro Oy. "Polar" and "Polar Flow" are trademarks of their respective owner.
This software drives an unofficial, reverse-engineered web API that is not publicly documented, may change or break without notice, and whose use may be contrary to Polar's Terms of Service. Use at your own risk. The author accepts no responsibility for account suspension or any other consequences arising from its use. Use a dedicated test account where possible.
MIT β see LICENSE.

