Build the read-only Withings body-metrics MCP server #2

Merged
jlxq0 merged 1 commit from feat/withings-mcp-server into main 2026-08-28 23:43:11 +00:00
Owner

Closes #1

axum + rmcp streamable HTTP in the shape of the other first-party servers here, with a thin rustls-only Withings client. Four tools, all read_only_hint = true, and no tool that writes.

What to read first

src/token.rs. Withings issues no static API key and rotates the refresh token on every refresh: the previous one dies 8 hours after issuance or as soon as the new access token is used, whichever comes first. Two consequences shape everything else.

The environment variable is a seed, not a credential. A process that re-reads it after each restart authenticates exactly once and then stops — three hours later, with a Withings status: 401 and nothing connecting the symptom to the restart. seed_refresh_token refuses to overwrite a stored value, because the stored one is newer by construction.

The rotated token is persisted and read back before the new access token is used for anything. Reversing that spends the 8-hour grace immediately: the served call succeeds, the old token dies, and if the write did not land the only recovery is a person at a browser. The two orders are indistinguishable on the happy path. A save that returns Ok and keeps nothing is what a misconfigured destination actually does, so the read-back is the check rather than the return value — WriteOnceStore in the tests is that store, and deleting the read-back reds exactly that test.

TokenStore is a trait with in-memory and file-backed implementations. Where the rotated value goes in a deployment is not decided here; the four candidates and their costs are in #1.

Two mappings that each exist in one place

Withings answers HTTP 200 for application errors and puts the outcome in the body's status field, so a check on the HTTP layer alone reports success for an expired token. read_envelope is the only place either layer becomes an error.

A measure is an integer and a base-10 exponent, so emitting value unscaled reports a weight of 70,500 rather than 70.5 — a confidently wrong number rather than a missing one.

Auth, in both directions

Inbound is a shared bearer the deployment configures, compared by digest in constant time; a prefix of the right value must not pass. The server refuses to start with none configured rather than serving open. Unauthenticated /mcp is a bare 401, OAuth and OIDC probes are 404.

/oauth/callback replaces the server's credential and cannot carry the MCP bearer, since the caller is a browser following a Withings redirect. It is off unless WITHINGS_MCP_OAUTH_STATE is set and the state parameter matches. Without that, anyone reaching the origin points this server at their own account and every read afterwards is of a stranger's data while every signal stays green.

withings-mcp exchange does the one-shot code exchange from a terminal, reads its three secrets from stdin one per line so none reaches a shell history or a process listing, and writes no file.

Which half is done

Nothing here has spoken to the live Withings API. Every response shape is read from Withings' published documentation and pinned against an invented fixture, so a green suite proves the code parses the JSON as the documentation describes it and proves nothing about what Withings returns. AGENTS.md lists the four claims that need a real credential, in the order they become checkable. Rotation is second on that list and is checkable in one tool call rather than after three hours, because seeding writes expires_at: 0 and the first call therefore refreshes.

No measurement values, user ids, credentials or internal hostnames anywhere in the tree; the repository is public and the fixtures say they are invented.

Gates

On the pinned toolchain, exit codes captured without a pipe between the command and $?:

fmt=0 clippy=0 test=0 audit=0 deny=0 build-floor-1.93=0
test result: ok. 72 passed; 0 failed

cargo +1.93.0 check --all-features --locked passes, so the rust-version floor and the digest-pinned rust:1.93-bookworm builder are a gate rather than a comment. That digest was verified to be the current index digest for the tag rather than a per-platform one.

Mutations, with the case each reddens

Control green either side of all four.

mutation reds
delete the read-back in persist_before_use a_refresh_that_cannot_be_persisted_never_yields_an_access_token (71 passed, 1 failed)
DEFAULT_REFRESH_SKEW to zero the_no_argument_constructor_uses_the_documented_skew, a_token_inside_the_skew_window_is_not_fresh
emit the raw measure unscaled four tests in measures.rs
compare the inbound bearer with starts_with the_expected_token_matches_only_itself, a_wrong_bearer_is_rejected_rather_than_forwarded

The skew and the constructor pairing is deliberate: caldav-mcp passed 120 tests over defaults its deployment ran on because every test passed the parameter in, so the_no_argument_constructor_uses_the_documented_skew and the_defaults_a_deployment_gets_when_it_sets_nothing construct through the no-argument path.

Closes #1 `axum` + `rmcp` streamable HTTP in the shape of the other first-party servers here, with a thin rustls-only Withings client. Four tools, all `read_only_hint = true`, and no tool that writes. ## What to read first `src/token.rs`. Withings issues no static API key and **rotates the refresh token on every refresh**: the previous one dies 8 hours after issuance or as soon as the new access token is used, whichever comes first. Two consequences shape everything else. **The environment variable is a seed, not a credential.** A process that re-reads it after each restart authenticates exactly once and then stops — three hours later, with a Withings `status: 401` and nothing connecting the symptom to the restart. `seed_refresh_token` refuses to overwrite a stored value, because the stored one is newer by construction. **The rotated token is persisted and read back before the new access token is used for anything.** Reversing that spends the 8-hour grace immediately: the served call succeeds, the old token dies, and if the write did not land the only recovery is a person at a browser. The two orders are indistinguishable on the happy path. A `save` that returns `Ok` and keeps nothing is what a misconfigured destination actually does, so the read-back is the check rather than the return value — `WriteOnceStore` in the tests is that store, and deleting the read-back reds exactly that test. `TokenStore` is a trait with in-memory and file-backed implementations. **Where the rotated value goes in a deployment is not decided here**; the four candidates and their costs are in #1. ## Two mappings that each exist in one place **Withings answers HTTP 200 for application errors** and puts the outcome in the body's `status` field, so a check on the HTTP layer alone reports success for an expired token. `read_envelope` is the only place either layer becomes an error. **A measure is an integer and a base-10 exponent**, so emitting `value` unscaled reports a weight of 70,500 rather than 70.5 — a confidently wrong number rather than a missing one. ## Auth, in both directions Inbound is a shared bearer the deployment configures, compared by digest in constant time; a prefix of the right value must not pass. The server refuses to start with none configured rather than serving open. Unauthenticated `/mcp` is a bare `401`, OAuth and OIDC probes are `404`. `/oauth/callback` **replaces the server's credential** and cannot carry the MCP bearer, since the caller is a browser following a Withings redirect. It is off unless `WITHINGS_MCP_OAUTH_STATE` is set and the `state` parameter matches. Without that, anyone reaching the origin points this server at their own account and every read afterwards is of a stranger's data while every signal stays green. `withings-mcp exchange` does the one-shot code exchange from a terminal, reads its three secrets from stdin one per line so none reaches a shell history or a process listing, and writes no file. ## Which half is done **Nothing here has spoken to the live Withings API.** Every response shape is read from Withings' published documentation and pinned against an invented fixture, so a green suite proves the code parses the JSON as the documentation describes it and proves nothing about what Withings returns. `AGENTS.md` lists the four claims that need a real credential, in the order they become checkable. Rotation is second on that list and is checkable in **one tool call** rather than after three hours, because seeding writes `expires_at: 0` and the first call therefore refreshes. No measurement values, user ids, credentials or internal hostnames anywhere in the tree; the repository is public and the fixtures say they are invented. ## Gates On the pinned toolchain, exit codes captured without a pipe between the command and `$?`: ``` fmt=0 clippy=0 test=0 audit=0 deny=0 build-floor-1.93=0 test result: ok. 72 passed; 0 failed ``` `cargo +1.93.0 check --all-features --locked` passes, so the `rust-version` floor and the digest-pinned `rust:1.93-bookworm` builder are a gate rather than a comment. That digest was verified to be the current index digest for the tag rather than a per-platform one. ## Mutations, with the case each reddens Control green either side of all four. | mutation | reds | |---|---| | delete the read-back in `persist_before_use` | `a_refresh_that_cannot_be_persisted_never_yields_an_access_token` (71 passed, 1 failed) | | `DEFAULT_REFRESH_SKEW` to zero | `the_no_argument_constructor_uses_the_documented_skew`, `a_token_inside_the_skew_window_is_not_fresh` | | emit the raw measure unscaled | four tests in `measures.rs` | | compare the inbound bearer with `starts_with` | `the_expected_token_matches_only_itself`, `a_wrong_bearer_is_rejected_rather_than_forwarded` | The skew and the constructor pairing is deliberate: `caldav-mcp` passed 120 tests over defaults its deployment ran on because every test passed the parameter in, so `the_no_argument_constructor_uses_the_documented_skew` and `the_defaults_a_deployment_gets_when_it_sets_nothing` construct through the no-argument path.
Build the read-only Withings body-metrics MCP server
All checks were successful
CI / cargo (pull_request) Successful in 1m45s
CI / docker (pull_request) Successful in 49s
42f73eed12
axum + rmcp streamable HTTP in the shape of the other first-party servers
here, with a thin rustls-only Withings client. Four tools, all annotated
read_only_hint = true, and no tool that writes.

Withings issues no static API key, so the server holds an OAuth2 credential
of its own and the caller presents a shared bearer the deployment configures.
Two things follow that are not obvious and are the reason for most of the
design:

Withings rotates the refresh token on every refresh, and the previous one
dies 8 hours after issuance or as soon as the new access token is used,
whichever comes first. The environment variable is therefore a seed rather
than a standing credential, and a process that re-reads it authenticates once
per restart and then stops, three hours later, with nothing connecting the
symptom to the cause. TokenStore is a trait with in-memory and file-backed
implementations; where the rotated value goes in a deployment is not decided
here.

The rotated token is written down and read back before the new access token
is used for anything. Reversing that order spends the 8-hour grace
immediately, and the two orders are indistinguishable while everything works.
A save that returns Ok and keeps nothing is what a misconfigured destination
actually does, so the read-back is the check rather than the return value.

Withings answers HTTP 200 for application errors and puts the outcome in the
body's status field, and a measure is an integer with a base-10 exponent, so
an unscaled value reports a weight of 70,500 rather than 70.5. Both are
single-point mappings with tests that red when removed.

Nothing here has spoken to the live API: every response shape is read from
Withings' documentation and pinned against an invented fixture. AGENTS.md
lists the four claims that need a real credential to confirm.

Gates on the pinned toolchain, exit codes read without a pipe: fmt 0,
clippy 0, test 0 (72 passed), audit 0, deny 0, and cargo +1.93.0 check 0.
Four mutations red the tests named beside them in AGENTS.md.

Closes #1
Author
Owner

Gates run by me on both pinned toolchains, exit codes captured without a pipe:

fmt          rc=0
clippy 1.98  rc=0   RUSTFLAGS=-Dwarnings, --all-targets --all-features --locked
test         rc=0   72 passed, 0 failed
check 1.93   rc=0   the build floor as a gate rather than a comment

CI reads cargo and docker green on 42f73ee.

Two mutations of my own, both red at the case naming them:

read-back replaced with the value just written   a_refresh_that_cannot_be_persisted_never_yields_an_access_token
state comparison replaced with `if false`        the_oauth_callback_rejects_a_wrong_state_before_exchanging

The first is the one that mattered to check. persist_before_use saves, reads back, compares the refresh token and aborts on any of the three failing, so a save returning Ok while keeping nothing does not yield an access token. A store that drops writes silently is what a misconfigured destination actually is, and the return value alone would have passed.

My first attempt at the callback mutation was a no-op and the suite was right to stay green: my perl matched nothing and I checked the count rather than reading the result. A mutation producing no red is a claim about the mutation before it is a claim about the test.

The two decisions that were not in the brief

Both stand, and the second is the one I would not have specified.

The inbound bearer, compared by digest in constant time with the server refusing to start when none is set. There is no caller-forwarded credential here as there is in hevy-mcp, so something had to authenticate the caller, and refusing to start is the right direction for a server whose only credential is the deployment's.

The /oauth/callback state guard is a real vulnerability closed rather than a hardening. That endpoint replaces the server's credential and cannot carry the MCP bearer, so unguarded, anyone reaching the origin repoints the server at their own account and every read afterwards looks healthy. Off unless a state is configured, and a wrong one rejected before the exchange.

The shape matches the brief

The module set is hevy-mcp'saudit, auth, config, main, mcp, metrics, rate_limit, session, telemetry — plus token, exchange, measures and withings_client, which is what OAuth2 adds. Not scope creep; that repository is 3,692 lines of the same skeleton.

The two-floor toolchain reasoning is documented with its mechanism: an #[allow] naming a lint the running clippy does not have is itself an error under -D warnings, so the unused_async_trait_impl allow above #[tool_handler] reds 1.97 and below. That is why ci.yml names 1.98.0 rather than stable.

Which half is done

The code half, and the report said so before I asked. Nothing here has spoken to the live API, every response shape is documentation pinned against an invented fixture, and a green suite proves the code parses that JSON as documented. AGENTS.md lists the four claims needing a real credential in the order they become checkable, rotation second.

Merging.

**Gates run by me on both pinned toolchains, exit codes captured without a pipe:** fmt rc=0 clippy 1.98 rc=0 RUSTFLAGS=-Dwarnings, --all-targets --all-features --locked test rc=0 72 passed, 0 failed check 1.93 rc=0 the build floor as a gate rather than a comment CI reads `cargo` and `docker` green on `42f73ee`. **Two mutations of my own, both red at the case naming them:** read-back replaced with the value just written a_refresh_that_cannot_be_persisted_never_yields_an_access_token state comparison replaced with `if false` the_oauth_callback_rejects_a_wrong_state_before_exchanging **The first is the one that mattered to check.** `persist_before_use` saves, reads back, compares the **refresh token** and aborts on any of the three failing, so a `save` returning `Ok` while keeping nothing does not yield an access token. **A store that drops writes silently is what a misconfigured destination actually is**, and the return value alone would have passed. **My first attempt at the callback mutation was a no-op** and the suite was right to stay green: my `perl` matched nothing and I checked the count rather than reading the result. **A mutation producing no red is a claim about the mutation before it is a claim about the test.** ## The two decisions that were not in the brief **Both stand, and the second is the one I would not have specified.** **The inbound bearer**, compared by digest in constant time with the server refusing to start when none is set. There is no caller-forwarded credential here as there is in `hevy-mcp`, so something had to authenticate the caller, and refusing to start is the right direction for a server whose only credential is the deployment's. **The `/oauth/callback` state guard is a real vulnerability closed rather than a hardening.** That endpoint *replaces the server's credential* and cannot carry the MCP bearer, so unguarded, anyone reaching the origin repoints the server at their own account **and every read afterwards looks healthy.** Off unless a `state` is configured, and a wrong one rejected before the exchange. ## The shape matches the brief **The module set is `hevy-mcp`'s** — `audit`, `auth`, `config`, `main`, `mcp`, `metrics`, `rate_limit`, `session`, `telemetry` — plus `token`, `exchange`, `measures` and `withings_client`, which is what OAuth2 adds. **Not scope creep; that repository is 3,692 lines of the same skeleton.** **The two-floor toolchain reasoning is documented with its mechanism**: an `#[allow]` naming a lint the running clippy does not have is itself an error under `-D warnings`, so the `unused_async_trait_impl` allow above `#[tool_handler]` reds 1.97 and below. That is why `ci.yml` names `1.98.0` rather than `stable`. ## Which half is done **The code half, and the report said so before I asked.** Nothing here has spoken to the live API, every response shape is documentation pinned against an invented fixture, and a green suite proves the code parses that JSON as documented. **`AGENTS.md` lists the four claims needing a real credential in the order they become checkable, rotation second.** Merging.
jlxq0 merged commit 87e33509e0 into main 2026-08-28 23:43:11 +00:00
jlxq0 deleted branch feat/withings-mcp-server 2026-08-28 23:43:11 +00:00
Author
Owner

Second commit: the gate covered refreshes and not every mutation

Cross-engine review of token.rs (Codex, one specific question about the new code rather than a general review) found a real hole in the thing the first commit was built around.

adopt and seed_refresh_token mutated the store outside the refresh gate, so persist_before_use's guarantee was not atomic. The interleaving:

  1. access_token refreshes R0 into A1/R1 and saves them.
  2. Its read-back sees R1 and succeeds.
  3. /oauth/callback adopts a new authorisation and writes RA.
  4. access_token returns A1.

The store no longer holds the token matching the access token just handed out. Every individual step is correct and every log line is clean.

seed_refresh_token had the matching load/save race: observe an empty store, pause, an adopt installs a valid pair, the seed overwrites it. Startup ordering covered that by accident and the type enforced nothing.

The gate is now a store gate: seed_refresh_token, adopt and the whole refresh-persist-read-back-return sequence take it.

an_adopt_cannot_interleave_with_a_refresh pins it by write order rather than by outcome — the refresh is delayed 300 ms, the adopt is issued 50 ms in, and the recorded order must be seed, refresh, adopt. Taking the gate back out of adopt reds exactly that test: 72 passed, 1 failed.

One caveat documented rather than fixed, because fixing it in the type is a larger change than it is worth for one manager: the gate is per manager, so one TokenStore must not be shared by two. Two managers over one store have two mutexes and none of this holds.

Gates re-run after the change: fmt 0, clippy 0, test 0 (73 passed), audit 0, deny 0, cargo +1.93.0 check 0.

That this was worth running is the point rather than the finding: the code looked finished, the mutex was already there with a comment explaining why, and the question that found it was "can this new code do the wrong thing in the case it was written for" rather than "review my change".

## Second commit: the gate covered refreshes and not every mutation Cross-engine review of `token.rs` (Codex, one specific question about the new code rather than a general review) found a real hole in the thing the first commit was built around. `adopt` and `seed_refresh_token` mutated the store **outside** the refresh gate, so `persist_before_use`'s guarantee was not atomic. The interleaving: 1. `access_token` refreshes `R0` into `A1`/`R1` and saves them. 2. Its read-back sees `R1` and succeeds. 3. `/oauth/callback` adopts a new authorisation and writes `RA`. 4. `access_token` returns `A1`. The store no longer holds the token matching the access token just handed out. Every individual step is correct and every log line is clean. `seed_refresh_token` had the matching load/save race: observe an empty store, pause, an adopt installs a valid pair, the seed overwrites it. Startup ordering covered that by accident and the type enforced nothing. The gate is now a **store gate**: `seed_refresh_token`, `adopt` and the whole refresh-persist-read-back-return sequence take it. `an_adopt_cannot_interleave_with_a_refresh` pins it by write order rather than by outcome — the refresh is delayed 300 ms, the adopt is issued 50 ms in, and the recorded order must be seed, refresh, adopt. **Taking the gate back out of `adopt` reds exactly that test**: 72 passed, 1 failed. One caveat documented rather than fixed, because fixing it in the type is a larger change than it is worth for one manager: **the gate is per manager, so one `TokenStore` must not be shared by two.** Two managers over one store have two mutexes and none of this holds. Gates re-run after the change: fmt 0, clippy 0, test 0 (73 passed), audit 0, deny 0, `cargo +1.93.0 check` 0. That this was worth running is the point rather than the finding: the code looked finished, the mutex was already there with a comment explaining why, and the question that found it was "can this new code do the wrong thing in the case it was written for" rather than "review my change".
Sign in to join this conversation.
No reviewers
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!2
No description provided.