Build the read-only Withings body-metrics MCP server #2
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/withings-mcp-server"
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?
Closes #1
axum+rmcpstreamable HTTP in the shape of the other first-party servers here, with a thin rustls-only Withings client. Four tools, allread_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: 401and nothing connecting the symptom to the restart.seed_refresh_tokenrefuses 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
savethat returnsOkand keeps nothing is what a misconfigured destination actually does, so the read-back is the check rather than the return value —WriteOnceStorein the tests is that store, and deleting the read-back reds exactly that test.TokenStoreis 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
statusfield, so a check on the HTTP layer alone reports success for an expired token.read_envelopeis the only place either layer becomes an error.A measure is an integer and a base-10 exponent, so emitting
valueunscaled 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
/mcpis a bare401, OAuth and OIDC probes are404./oauth/callbackreplaces the server's credential and cannot carry the MCP bearer, since the caller is a browser following a Withings redirect. It is off unlessWITHINGS_MCP_OAUTH_STATEis set and thestateparameter 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 exchangedoes 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.mdlists 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 writesexpires_at: 0and 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
$?:cargo +1.93.0 check --all-features --lockedpasses, so therust-versionfloor and the digest-pinnedrust:1.93-bookwormbuilder 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.
persist_before_usea_refresh_that_cannot_be_persisted_never_yields_an_access_token(71 passed, 1 failed)DEFAULT_REFRESH_SKEWto zerothe_no_argument_constructor_uses_the_documented_skew,a_token_inside_the_skew_window_is_not_freshmeasures.rsstarts_withthe_expected_token_matches_only_itself,a_wrong_bearer_is_rejected_rather_than_forwardedThe skew and the constructor pairing is deliberate:
caldav-mcppassed 120 tests over defaults its deployment ran on because every test passed the parameter in, sothe_no_argument_constructor_uses_the_documented_skewandthe_defaults_a_deployment_gets_when_it_sets_nothingconstruct through the no-argument path.Gates run by me on both pinned toolchains, exit codes captured without a pipe:
CI reads
cargoanddockergreen on42f73ee.Two mutations of my own, both red at the case naming them:
The first is the one that mattered to check.
persist_before_usesaves, reads back, compares the refresh token and aborts on any of the three failing, so asavereturningOkwhile 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
perlmatched 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/callbackstate 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 astateis 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— plustoken,exchange,measuresandwithings_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 theunused_async_trait_implallow above#[tool_handler]reds 1.97 and below. That is whyci.ymlnames1.98.0rather thanstable.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.mdlists the four claims needing a real credential in the order they become checkable, rotation second.Merging.
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.adoptandseed_refresh_tokenmutated the store outside the refresh gate, sopersist_before_use's guarantee was not atomic. The interleaving:access_tokenrefreshesR0intoA1/R1and saves them.R1and succeeds./oauth/callbackadopts a new authorisation and writesRA.access_tokenreturnsA1.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_tokenhad 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,adoptand the whole refresh-persist-read-back-return sequence take it.an_adopt_cannot_interleave_with_a_refreshpins 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 ofadoptreds 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
TokenStoremust 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 check0.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".