MCP server for Withings body metrics, delegated OAuth2, read-only
  • Rust 95.5%
  • Python 3.2%
  • Dockerfile 0.8%
  • Shell 0.5%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-10-29 01:30:51 +00:00
.forgejo/workflows fix: refuse stale dependency stub in Withings release build 2026-10-29 09:14:48 +08:00
scripts build: qualify immutable Withings 0.1.4 runtime releases 2026-10-29 09:10:15 +08:00
src fix: qualify fixed SHA-2 MCP and telemetry dependencies 2026-10-29 08:52:50 +08:00
.gitignore Build the read-only Withings body-metrics MCP server 2026-08-29 07:38:15 +08:00
AGENTS.md fix: refuse stale dependency stub in Withings release build 2026-10-29 09:14:48 +08:00
Cargo.lock build: qualify immutable Withings 0.1.4 runtime releases 2026-10-29 09:10:15 +08:00
Cargo.toml build: qualify immutable Withings 0.1.4 runtime releases 2026-10-29 09:10:15 +08:00
CLAUDE.md Build the read-only Withings body-metrics MCP server 2026-08-29 07:38:15 +08:00
deny.toml Build the read-only Withings body-metrics MCP server 2026-08-29 07:38:15 +08:00
Dockerfile fix: refuse stale dependency stub in Withings release build 2026-10-29 09:14:48 +08:00
LICENSE Initial commit 2026-08-28 23:16:06 +00:00
README.md build: qualify immutable Withings 0.1.4 runtime releases 2026-10-29 09:10:15 +08:00
rustfmt.toml Build the read-only Withings body-metrics MCP server 2026-08-29 07:38:15 +08:00

withings-mcp

Rust MCP server for Withings body metrics. It uses axum, rmcp streamable HTTP, and a thin rustls-only client for the official Withings API. It does not use a third-party Withings MCP server or a Withings client crate.

Read-only. There is no tool that writes to Withings.

Self-host the /mcp endpoint on your own domain.

Authentication

Two credentials, in opposite directions.

Inbound, from the caller, is a shared bearer the deployment configures:

Authorization: Bearer <WITHINGS_MCP_AUTH_TOKEN>

Missing, malformed or wrong Authorization on /mcp returns 401 with no WWW-Authenticate header. OAuth and OIDC well-known probes return 404. The server refuses to start with no inbound bearer configured rather than serving open.

Outbound, to Withings, is OAuth2. Withings issues no long-lived API key: a user authorises once with scope=user.metrics, and the server holds a refresh token it exchanges for three-hour access tokens itself. Nothing about the caller reaches Withings; every request reads one account.

Refresh tokens rotate

Every grant_type=refresh_token call returns a new refresh token, and the previous one expires 8 hours after the new one is issued or as soon as the new access token is used, whichever comes first.

So WITHINGS_MCP_REFRESH_TOKEN is a seed rather than a standing credential. It is used only when the token store holds nothing, because the stored value is newer by construction, and it is applied while the manager is being built rather than by a method anything could call later. A deployment that sets no WITHINGS_MCP_TOKEN_STATE_PATH keeps the rotated token in memory, authenticates once per restart, and then cannot — so it logs a warning at startup saying exactly that.

The refresh path writes the rotated token down and reads it back before the new access token is used for anything. A failed or silently dropped write aborts the refresh and surfaces an error, which leaves the previous refresh token alive for its 8 hours; using first and persisting second would trade that window for none at all, and the two orders look identical while everything works.

Getting the first refresh token

An authorisation code lives minutes, so the exchange runs from a terminal and needs no deployment. It writes no file and reads its three secrets from stdin, one per line, so none of them reaches a shell history or a process listing.

# 1. Build the consent URL (client id on stdin).
printf '%s\n' "$CLIENT_ID" \
  | withings-mcp exchange --authorize-url \
      --redirect-uri https://withings-mcp.example/oauth/callback \
      --state "$STATE"

# 2. Open it, approve, and copy `code` out of the browser's address bar.

# 3. Exchange it. The refresh token is the only thing on stdout.
printf '%s\n%s\n%s\n' "$CLIENT_ID" "$CLIENT_SECRET" "$CODE" \
  | withings-mcp exchange --redirect-uri https://withings-mcp.example/oauth/callback

A running deployment can also complete the flow at GET /oauth/callback, which exchanges the code and adopts the tokens. That endpoint replaces the server's credential, and the state it checks is static and sits in every consent URL, so it is gated three ways:

  • it is disabled unless WITHINGS_MCP_OAUTH_STATE is set, and the state query parameter must match it;
  • it is open only for WITHINGS_MCP_OAUTH_WINDOW_SECS (default 600, at most 3600) after the process starts, and it closes for good on the first authorisation it adopts;
  • it closes after five wrong state values. A request with no state is not counted, but anyone can send five wrong ones, so if it closed before you consented, restart and try again.

Closed, used and disabled all answer the same 404. To re-authorise, restart the process and consent inside the window. Use a long random state; one shorter than 32 characters is warned about at startup.

Tools

tool reads Withings what it returns
whoami only to refresh the authorised user id, the scope, and how long the access token is still valid
measurement_types no the four supported types with their meastype numbers and units
list_measurements yes a series over a time range, newest first
latest_measurements yes the most recent reading of each requested type

Every tool is annotated read_only_hint = true.

meastype name unit
1 weight kg
6 body_fat_percentage %
8 fat_mass kg
76 muscle_mass kg

Values are scaled: Withings sends an integer and a base-10 exponent, so 70.5 kg arrives as {"value": 70500, "unit": -3} and is returned as 70.5.

A type with no reading in the window is absent from latest_measurements and named in no_reading_in_window, never returned as zero. An unknown type name is an error rather than a silent omission, because three series where four were asked for is indistinguishable from a missing measurement.

Withings pages a long range. Both reads follow the pages, up to 20, and say "complete": true when they reached the last one. false means the range had more than was read: list_measurements then reports "truncated": true, and neither the newest readings nor no_reading_in_window can be trusted, because which end of the range Withings pages from has not been measured. Each page is a Withings call, so one tool call can make up to 20.

list_measurements with a start after its end is an invalid argument.

A rate limit — ours or Withings' — reaches the caller as a JSON-RPC error with data.class == "rate_limited". An empty series is a successful result. One comparison separates "unreadable" from "nothing recorded".

Environment

WITHINGS_MCP_AUTH_TOKEN=<inbound bearer>
WITHINGS_MCP_CLIENT_ID=<Withings client id>
WITHINGS_MCP_CLIENT_SECRET=<Withings client secret>
WITHINGS_MCP_REFRESH_TOKEN=<seed refresh token>
WITHINGS_MCP_TOKEN_STATE_PATH=/var/lib/withings-mcp/tokens.json
WITHINGS_MCP_REDIRECT_URI=https://withings-mcp.example/oauth/callback
WITHINGS_MCP_OAUTH_STATE=<long random value>
WITHINGS_MCP_OAUTH_WINDOW_SECS=600
WITHINGS_MCP_ALLOWED_HOSTS=withings-mcp.example
WITHINGS_MCP_API_BASE_URL=https://wbsapi.withings.net
WITHINGS_MCP_BIND_ADDR=0.0.0.0:3000
WITHINGS_MCP_METRICS_BIND_ADDR=127.0.0.1:9090
WITHINGS_MCP_RATE_LIMIT_READS_PER_MIN=60
WITHINGS_MCP_LOG_FORMAT=json

The first three have no default and the server will not start without them.

WITHINGS_MCP_ALLOWED_HOSTS defaults to localhost,127.0.0.1,::1, and a request whose Host is not on the list is answered 403, so a deployment reachable on a public name must set it to that name or it will reject every real request while /health keeps answering 200. The default is loopback rather than any particular origin because this repository is public and an origin is deployment configuration.

The token state file holds a live credential. It is written 0600 and replaced atomically, and it belongs only on storage the deployment already treats as secret.

Metrics

The internal listener serves Prometheus text at /metrics. Besides tool call counts and latency:

  • withings_mcp_withings_failures_total{stage, code} counts failed Withings calls by refresh or measure and the stable error code;
  • withings_mcp_reauthorisation_required is 1 once a refresh has found the stored authorisation dead, and 0 once a token has been served again.

/health answers 200 regardless, because it is the liveness probe and a restart cannot revive a refresh token. Alert on the gauge instead.

Development

cargo fmt --all --check
cargo clippy --all-targets --all-features --locked -- -D warnings
cargo test --all-features --locked
cargo audit
osv-scanner scan source --lockfile Cargo.lock
cargo deny check bans licenses sources

Release qualification

The next runtime release is v0.1.4. It retains the Rust 1.93 builder and server-owned token storage, with the current digest-pinned Debian 13 distroless runtime. A release tag must match the package version and descend from reviewed main. Scheduled source audits do not build or write the image cache.

CI solves one OCI artifact with SPDX/SLSA attestations, scans that exact layout and configuration for HIGH/CRITICAL findings, then copies the same complete index by digest. The version tag cannot replace a different existing artifact; latest is updated only after digest readback. Attestation presence is not a signature, and the tag precheck is not registry compare-and-swap: keep one release writer. Pull requests never receive publication credentials or export the main cache. Final image and native authenticated readback remain mandatory before deployment; test health alone cannot prove upstream authorization.

python3 scripts/test_publish_oci.py --layout /path/to/oci --digest sha256:... uses a disposable localhost registry and an attested fixture. It verifies every previously absent object, identical replay, conflicting version refusal and missing descendants without writing to a production registry. Use the same checksum-pinned regctl as CI.