v0.9.0 — OAuth2 migration + three silent API bugs fixed
⚠️ Breaking: Personal Access Tokens are gone — this release moves to OAuth2
Oura deprecated Personal Access Tokens in August 2026. Existing ones keep working
for a while, new ones cannot be created, and they will be switched off. If you
run this server, you need to migrate.
What you have to do
- Register an application at https://developer.ouraring.com/applications
(redirect URIhttp://localhost:8080/callback) - Put
OURA_CLIENT_IDandOURA_CLIENT_SECRETin.env - Run
python generate_tokens.pyonce
The README walks through it field by field,
including the Privacy Policy and Terms of Service URLs the registration form
demands.
How refresh is handled
Oura refresh tokens are single-use — every refresh invalidates the previous
one. Two things guard your access:
- Refreshes are serialized with a file lock. Several server processes can run
at once (one per client session, plus cron jobs); without it two would spend the
same single-use token and one would be left with a dead credential. Inside the
lock the stored tokens are re-read first, so a rotation another process just
completed is adopted rather than duplicated. - The new pair is written atomically (temp file +
os.replace, mode 600), so a
crash mid-write cannot truncate the only copy of your refresh token.
A 401 drives one refresh and one retry. A second 401 means the credentials are
dead rather than stale, and the error tells you to re-run generate_tokens.py.
🐛 Three silent bugs fixed
Found by diffing the client against the official OpenAPI spec (v2.0 / 1.37).
None of them ever raised an error — they returned wrong data, no data, or a
placeholder, which is why none had been noticed.
| Fix | Before | After |
|---|---|---|
get_workout_sessions queried /usercollection/session |
empty list, always — that endpoint is Oura's guided breathing / meditation collection, not sport | 9 workouts in a 9-day window |
vo2_max → vO2_max |
HTTP 404 — the spec spells it with a capital O | HTTP 200 |
workout renderer read the field type |
Type: Unknown on every entry |
Activity: walking · Intensity: moderate · Duration: 12m · Calories: 41 kcal |
The first one has a tidy root cause: the docstring on get_sessions said
"Get workout/activity sessions." — a wrong description produced a wrong call.
It now says what the endpoint actually returns.
The renderer had also been written against the session schema, so it looked for
total_duration and heart_rate, neither of which exists in PublicWorkout.
Duration is now computed from the timestamps, and calories are rounded to whole
kcal instead of 15 decimal places of float noise.
Also added: get_ring_battery_level, which is in the spec and returns data but
had no client method.
🔒 Protocol-safe logging — thanks to @gjsduarte
Logging now defaults to stderr, and stdout logging is redirected to stderr
whenever the stdio transport is in use, so a log line can no longer corrupt the
JSON-RPC stream. Includes regression tests. From #2 — thank you.
The tracked machine-specific config/claude_desktop_config.json is gone with it.
Upgrading
git pull
pip install -r requirements.txt
python generate_tokens.py # one-time authorizationIf you were using a Personal Access Token, revoke it afterwards — it is not needed
any more and will stop working regardless.