Build the server: read-only Withings body metrics over MCP #1

Closed
opened 2026-08-28 23:20:42 +00:00 by jlxq0 · 1 comment
Owner

Rust MCP server for Withings body metrics, in the same shape as the other first-party servers here: axum + rmcp streamable HTTP, a thin rustls-only client, distroless image, the five-gate CI pipeline.

Scope

Read-only. Every tool carries read_only_hint = true, and there is no write tool. The deny lists other agents run are generated from those annotations, so a wrong hint silently widens an agent rather than merely mislabelling a tool.

Four measurement types, from scope=user.metrics:

meastype quantity
1 weight
6 body fat percentage
8 fat mass
76 muscle mass

Auth is OAuth2 and there is no static token

Withings issues no long-lived API key. The user authorises once and what the server holds afterwards is a refresh token it exchanges for three-hour access tokens itself. The credential is therefore one opaque string supplied by the deployment; it is not a bearer the caller presents.

Refresh tokens rotate. Every grant_type=refresh_token call returns a new refresh_token, and the previous one dies 8 hours after issuance or as soon as the new access token is used. A server that only ever reads its refresh token from the environment therefore works until its first restart after a refresh and then cannot authenticate at all, with nothing in its own logs saying why. Token persistence is a design constraint rather than an optimisation, and it is called out separately below.

Inbound auth is a shared bearer supplied by the deployment and compared in constant time. Missing or non-Bearer Authorization on /mcp returns a bare 401 with no WWW-Authenticate; OAuth and OIDC discovery probes return 404. The server refuses to start with no inbound secret configured rather than serving unauthenticated.

Deliverables

  • One-shot authorisation-code exchange path, usable before the server is deployed and writing nothing to disk
  • withings_client: token exchange, refresh, and measure?action=getmeas, with the outer status/body envelope mapped to typed errors
  • Token store behind a trait, with an in-memory implementation and a file-backed one
  • Tools: whoami, measurement_types, list_measurements, latest_measurements
  • Per-bearer rate limiting, session cap, envelope-only audit log, Prometheus metrics on a separate internal listener
  • AGENTS.md, README.md, deny.toml, Dockerfile, .forgejo/workflows/ci.yml
  • Five gates green on the pinned toolchain

What a green suite proves, and what it does not

Until the user consent lands, every claim about Withings' response shapes is read from Withings' published documentation and pinned against a local fixture. A green suite proves the code parses the JSON as the documentation describes it, and proves nothing about what the live API returns. The split is reported honestly rather than folded into a single "done".

Open question: where the rotated refresh token lives

The rotation above forces a persistent store, and every option has a cost:

  • a mounted file, which puts a live credential on a volume;
  • a Kubernetes Secret the server patches, which needs the credential to be outside the secret-manager's sync loop or the manager reverts it to the stale bootstrap value;
  • in-memory only, which needs the user to re-authorise by hand after every pod restart.

The trait lands with the in-memory and file-backed implementations so the choice is a deployment decision rather than a rewrite. Which one is used in the deployment is not decided here.

Rust MCP server for Withings body metrics, in the same shape as the other first-party servers here: `axum` + `rmcp` streamable HTTP, a thin rustls-only client, distroless image, the five-gate CI pipeline. ## Scope Read-only. Every tool carries `read_only_hint = true`, and there is no write tool. The deny lists other agents run are generated from those annotations, so a wrong hint silently widens an agent rather than merely mislabelling a tool. Four measurement types, from `scope=user.metrics`: | meastype | quantity | |---|---| | 1 | weight | | 6 | body fat percentage | | 8 | fat mass | | 76 | muscle mass | ## Auth is OAuth2 and there is no static token Withings issues no long-lived API key. The user authorises once and what the server holds afterwards is a refresh token it exchanges for three-hour access tokens itself. The credential is therefore one opaque string supplied by the deployment; it is not a bearer the caller presents. **Refresh tokens rotate.** Every `grant_type=refresh_token` call returns a new `refresh_token`, and the previous one dies 8 hours after issuance or as soon as the new access token is used. A server that only ever reads its refresh token from the environment therefore works until its first restart after a refresh and then cannot authenticate at all, with nothing in its own logs saying why. Token persistence is a design constraint rather than an optimisation, and it is called out separately below. Inbound auth is a shared bearer supplied by the deployment and compared in constant time. Missing or non-Bearer `Authorization` on `/mcp` returns a bare `401` with no `WWW-Authenticate`; OAuth and OIDC discovery probes return `404`. The server refuses to start with no inbound secret configured rather than serving unauthenticated. ## Deliverables - [ ] One-shot authorisation-code exchange path, usable before the server is deployed and writing nothing to disk - [ ] `withings_client`: token exchange, refresh, and `measure?action=getmeas`, with the outer `status`/`body` envelope mapped to typed errors - [ ] Token store behind a trait, with an in-memory implementation and a file-backed one - [ ] Tools: `whoami`, `measurement_types`, `list_measurements`, `latest_measurements` - [ ] Per-bearer rate limiting, session cap, envelope-only audit log, Prometheus metrics on a separate internal listener - [ ] `AGENTS.md`, `README.md`, `deny.toml`, `Dockerfile`, `.forgejo/workflows/ci.yml` - [ ] Five gates green on the pinned toolchain ## What a green suite proves, and what it does not Until the user consent lands, every claim about Withings' response shapes is read from Withings' published documentation and pinned against a local fixture. A green suite proves the code parses the JSON as the documentation describes it, and proves nothing about what the live API returns. The split is reported honestly rather than folded into a single "done". ## Open question: where the rotated refresh token lives The rotation above forces a persistent store, and every option has a cost: - a mounted file, which puts a live credential on a volume; - a Kubernetes Secret the server patches, which needs the credential to be outside the secret-manager's sync loop or the manager reverts it to the stale bootstrap value; - in-memory only, which needs the user to re-authorise by hand after every pod restart. The trait lands with the in-memory and file-backed implementations so the choice is a deployment decision rather than a rewrite. Which one is used in the deployment is not decided here.
Author
Owner

Withings rotates the refresh token. Read from their documentation, not yet from a live refresh.

Two Withings guide pages carry the same wording, and it is unambiguous:

each time you request a new access_token, you also receive a new refresh_token

Always replace the previous refresh_token with the new one.

With the lifetimes stated beside it:

access token expires after 3 hours
refresh token expires after 1 year
the previous refresh token expires 8 hours after the new one is issued, or as soon as the new access token is used

Sources, both fetched 2026-08-29:

So the option where one long-lived value never rotates is not available. A process that reads its refresh token from configuration and never writes back authenticates once per restart and then stops, and the stop is silent until the three-hour access token expires.

This is documentation, and a first real refresh is what would confirm it. That measurement needs a credential, so it is on the far side of consent. The code is written so the refresh is the first thing exercisable rather than something time reveals: TokenManager::access_token refreshes on demand whenever the stored access token is inside the skew window, and seeding the store with an expired entry (expires_at: 0) makes the very first call a refresh. Confirming rotation against the live API is therefore one tool call after the credential exists, not a fortnight of waiting.

Where the rotated value is written

Rotation forces a persistent store and every destination has a cost:

destination cost
a file on secret-grade storage a live credential sits in a file, against the standing rule
a secret the server updates itself the durable credential lives outside the password manager, unlike every other server here, and needs the secret to sit outside the manager's sync loop or the manager reverts it to the stale seed
writing back to the password manager a capability nothing else in this fleet has
memory only re-authorisation by hand after every restart

TokenStore is a trait with the in-memory and file-backed implementations shipped, so the destination is configuration rather than a rewrite. The deployment choice is not made here.

One property the code already has, independent of destination: refreshes are serialised behind a mutex. Concurrent refreshes would rotate the token several times and persist only the last result, leaving the store holding a value Withings has already superseded — which looks exactly like the no-write-back failure and is reachable without a restart.

## Withings rotates the refresh token. Read from their documentation, not yet from a live refresh. Two Withings guide pages carry the same wording, and it is unambiguous: > each time you request a new access_token, you also receive a new refresh_token > Always replace the previous refresh_token with the new one. With the lifetimes stated beside it: | | | |---|---| | access token | expires after 3 hours | | refresh token | expires after 1 year | | the **previous** refresh token | expires 8 hours after the new one is issued, **or as soon as the new access token is used** | Sources, both fetched 2026-08-29: - https://developer.withings.com/developer-guide/v3/integration-guide/public-health-data-api/get-access/access-and-refresh-tokens-no-recover/ - https://developer.withings.com/developer-guide/v3/integration-guide/dropship-cellular/get-access/access-and-refresh-tokens/ **So the option where one long-lived value never rotates is not available.** A process that reads its refresh token from configuration and never writes back authenticates once per restart and then stops, and the stop is silent until the three-hour access token expires. **This is documentation, and a first real refresh is what would confirm it.** That measurement needs a credential, so it is on the far side of consent. The code is written so the refresh is the *first* thing exercisable rather than something time reveals: `TokenManager::access_token` refreshes on demand whenever the stored access token is inside the skew window, and seeding the store with an expired entry (`expires_at: 0`) makes the very first call a refresh. Confirming rotation against the live API is therefore one tool call after the credential exists, not a fortnight of waiting. ## Where the rotated value is written Rotation forces a persistent store and every destination has a cost: | destination | cost | |---|---| | a file on secret-grade storage | a live credential sits in a file, against the standing rule | | a secret the server updates itself | the durable credential lives outside the password manager, unlike every other server here, and needs the secret to sit outside the manager's sync loop or the manager reverts it to the stale seed | | writing back to the password manager | a capability nothing else in this fleet has | | memory only | re-authorisation by hand after every restart | `TokenStore` is a trait with the in-memory and file-backed implementations shipped, so the destination is configuration rather than a rewrite. **The deployment choice is not made here.** One property the code already has, independent of destination: refreshes are serialised behind a mutex. Concurrent refreshes would rotate the token several times and persist only the last result, leaving the store holding a value Withings has already superseded — which looks exactly like the no-write-back failure and is reachable without a restart.
jlxq0 closed this issue 2026-08-28 23:43:11 +00:00
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
jlxq0/withings-mcp#1
No description provided.