Build the server: read-only Withings body metrics over MCP #1
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Rust MCP server for Withings body metrics, in the same shape as the other first-party servers here:
axum+rmcpstreamable 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: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_tokencall returns a newrefresh_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
Authorizationon/mcpreturns a bare401with noWWW-Authenticate; OAuth and OIDC discovery probes return404. The server refuses to start with no inbound secret configured rather than serving unauthenticated.Deliverables
withings_client: token exchange, refresh, andmeasure?action=getmeas, with the outerstatus/bodyenvelope mapped to typed errorswhoami,measurement_types,list_measurements,latest_measurementsAGENTS.md,README.md,deny.toml,Dockerfile,.forgejo/workflows/ci.ymlWhat 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:
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.
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:
With the lifetimes stated beside it:
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_tokenrefreshes 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:
TokenStoreis 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.