Withings MCP Server
by owenobyrne
README.md
# Withings MCP Server
A [Model Context Protocol](https://modelcontextprotocol.io) server that wraps the
[Withings Health API](https://developer.withings.com/) — including the full
OAuth2 handshake and token refresh — so an MCP client (Claude, etc.) can read
your smart-scale data: weight, body fat, muscle mass, bone mass, water, and more.
It runs as a **remote, hosted server on AWS Lambda** (Streamable-HTTP transport),
with:
- **Withings credentials** in **SSM Parameter Store** SecureString parameters (KMS-encrypted).
- **Withings tokens** in **DynamoDB** (encrypted at rest), refreshed automatically.
- A server-side **`/callback`** route that completes the Withings OAuth exchange for you.
- **Its own minimal OAuth 2.0 server** guarding the MCP endpoint, so you connect
it to Claude by pasting a client ID + secret — no custom headers required.
It also runs locally (single process, JSON-file token cache) for development.
> **Two OAuth flows, don't confuse them.** (1) *Claude → this server*: the
> built-in OAuth authorization server (`/authorize`, `/token`) that lets Claude
> obtain an access token. (2) *This server → Withings*: the flow that links your
> Withings account (`withings_start_authorization` + `/callback`). You set up (1)
> once when adding the connector, and (2) once to link your scale.
---
## Architecture
```
Claude ──OAuth (id+secret)──▶ Lambda Function URL
│
┌──────────────┴────────────────────┐
│ Starlette ASGI app (Mangum) │
│ /.well-known/oauth-* discovery │
│ /authorize /token OAuth AS │ ← Claude authenticates
│ /mcp Streamable-HTTP │──▶ tools (Bearer-protected)
│ /callback Withings code→token │
│ /health │
└───────┬────────────────────┬───────┘
│ │
SSM SecureString DynamoDB
(withings + oauth client creds) (withings tokens + oauth codes)
│
└──▶ Withings API (account.withings.com, wbsapi.withings.net)
```
Why Lambda + Streamable-HTTP: the transport runs **stateless** (one self-contained
JSON request/response per call), which maps cleanly onto Lambda — no long-lived
SSE connection to hold open. Tokens live in DynamoDB precisely because Lambda
containers are ephemeral.
### How the MCP OAuth works (Claude → this server)
The `/mcp` endpoint is protected by an access token, but you never paste a token
anywhere. This server implements just enough of OAuth 2.0 for Claude's built-in
client to obtain one:
1. Claude hits `/mcp`, gets `401` with a `WWW-Authenticate` header pointing at the
discovery metadata.
2. Claude reads `/.well-known/oauth-protected-resource` and
`/.well-known/oauth-authorization-server`.
3. Claude runs the authorization-code + **PKCE** flow using the **client ID/secret
you configured**, and receives an access token (+ refresh token) it stores and
sends automatically on every call.
Security with minimal machinery: token issuance at `/token` requires the
**client secret** (a confidential client), PKCE prevents code interception, and
`redirect_uri` hosts are allow-listed so the server can't be abused as an open
redirector. Access tokens are HMAC-signed with the client secret, so validating a
request needs no database lookup.
---
## Tools
| Tool | Description |
|------|-------------|
| `withings_connection_status` | Whether an account is linked and when the token expires. |
| `withings_start_authorization` | Returns an `authorization_url` to open in a browser to grant access. |
| `withings_get_measurements` | Weight + body-composition groups, filterable by `meastypes`, `start_date`/`end_date`, `days`, `limit`. |
| `withings_get_latest_weight` | The most recent weigh-in with full body composition. |
| `withings_list_measure_types` | All Withings measurement type codes with names/units. |
| `withings_disconnect` | Delete the stored tokens. |
Measurement values are returned already decoded (Withings sends `value` + `unit`
exponent; the server computes `value × 10^unit`), e.g.
`{"weight": {"value": 70.5, "unit": "kg"}}`.
---
## Prerequisites
1. A **Withings developer account** and application:
[developer.withings.com](https://developer.withings.com/) → *Dashboard* →
create an app. Note the **Client ID** and **Client Secret**. You will add a
**Callback URL** after the first deploy (step 5).
2. **AWS account** with credentials configured (`aws configure`).
3. **AWS SAM CLI** (`brew install aws-sam-cli` or
[install docs](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html))
and **Python 3.12**.
---
## Deploy to AWS (Lambda)
### 1. Store secrets in SSM Parameter Store
These are KMS-encrypted SecureStrings (default `aws/ssm` key — no extra cost) and
are never placed in the CloudFormation template or Lambda env vars.
```bash
WITHINGS_CLIENT_ID=xxxxxxxx \
WITHINGS_CLIENT_SECRET=yyyyyyyy \
./scripts/setup-ssm.sh
```
`WITHINGS_CLIENT_ID/SECRET` are from your **Withings developer app**. The script
also generates an **OAuth Client ID + Client Secret** (for Claude → this server)
and prints them — **save these**, you'll paste them into Claude in the last step.
(Override with `OAUTH_CLIENT_ID=... OAUTH_CLIENT_SECRET=...`, or change the SSM
prefix with `SSM_PREFIX=...`.)
### 2. Build
```bash
sam build
```
### 3. First deploy (redirect URL not known yet)
```bash
cp samconfig.toml.example samconfig.toml # edit region if needed
sam deploy --guided # accept defaults; leave PublicBaseUrl empty
```
When it finishes, note the stack **Outputs**:
- `FunctionUrl` — e.g. `https://abc123.lambda-url.eu-west-1.on.aws/`
- `McpEndpoint` — that URL + `mcp`
- `RedirectUri` — that URL + `callback`
### 4. Register the callback in Withings
In your Withings app settings, add the **`RedirectUri`** from the outputs
(exactly, including `https://` and `/callback`) to the app's **Callback URLs**.
### 5. Redeploy with the public base URL
The server needs to know its own address to build the OAuth `redirect_uri`. Pass
the `FunctionUrl` **without a trailing slash**:
```bash
sam deploy --parameter-overrides \
"SsmPrefix=/withings-mcp PublicBaseUrl=https://abc123.lambda-url.eu-west-1.on.aws"
```
That's it — the server is live.
---
## Connect an MCP client (Claude)
Add a **custom / remote MCP connector** pointing at the **`McpEndpoint`**
(`.../mcp`). In the connector's **Advanced / OAuth** settings, paste the **OAuth
Client ID** and **OAuth Client Secret** that `setup-ssm.sh` printed.
That's all the configuration — no headers, no tokens. When you enable the
connector, Claude discovers the server's OAuth metadata, opens a browser to
complete the authorization-code flow, and stores the resulting access token
itself. It refreshes the token automatically when it expires.
> If a client insists on **Dynamic Client Registration** and has no field for a
> client ID/secret, tell me — this server can add a minimal `/register`
> endpoint. The current design targets the "paste client ID + secret" path you
> asked for.
### First-run authorization (link your Withings account)
Once the connector is authenticated:
1. Ask Claude to call **`withings_start_authorization`**.
2. Open the returned `authorization_url`, sign in to Withings, approve.
3. You're redirected to `/callback`, which stores your Withings tokens and shows a
success page.
4. Call **`withings_connection_status`** to confirm, then
**`withings_get_latest_weight`** or **`withings_get_measurements`**.
Withings access tokens are refreshed automatically using the stored refresh
token, so this is a one-time step.
---
## Local development
```bash
python -m venv .venv && source .venv/bin/activate
pip install -r requirements-dev.txt
cp .env.example .env # fill in Withings client id/secret
python scripts/run_local.py # serves http://localhost:8000
```
Register `http://localhost:8000/callback` as a callback URL in your Withings app
and set `PUBLIC_BASE_URL=http://localhost:8000` in `.env`. Tokens are cached to
`~/.withings-mcp/tokens.json`. The MCP endpoint is `http://localhost:8000/mcp`.
To skip the MCP OAuth handshake while poking at the server locally, set
`OAUTH_DISABLE=1` in `.env` (the `/mcp` endpoint is then unauthenticated — local
use only).
Run the tests (no network/AWS needed):
```bash
pytest
```
---
## Security notes
- **Secrets** never leave SSM except as decrypted values inside the Lambda
execution environment. The IAM role grants `ssm:GetParameter*` scoped to
`<prefix>/*` and `kms:Decrypt` only `ViaService ssm`.
- **The MCP endpoint is OAuth-protected.** The Function URL is `AuthType: NONE`
(so Withings and Claude's browser flow can reach the public routes), but `/mcp`
requires a valid access token this server issued. Tokens are HMAC-signed with
the OAuth client secret and checked in constant time; rotate access by
re-running `setup-ssm.sh` with a new `OAUTH_CLIENT_SECRET` (this also
invalidates all previously issued tokens).
- **The OAuth authorization server is deliberately minimal but not loose**:
confidential client (secret required at `/token`), PKCE S256 enforced, and
`redirect_uri` restricted to an allow-listed set of hosts
(`OAUTH_ALLOWED_REDIRECT_HOSTS`). Authorization codes are single-use with a
5-minute TTL.
- **`/callback` (Withings) is public but CSRF-protected**: it only accepts an
OAuth `state` that this server issued (stored in DynamoDB with a 10-minute TTL)
and consumes it once.
- **Tokens** sit in DynamoDB with encryption at rest (AWS-owned KMS key by
default). For a customer-managed key, add an `SSESpecification` with
`SSEType: KMS` and your key ARN in `template.yaml`.
- This is a **single-user** server ("my scale data"). To support multiple
Withings accounts, key the token store by the Withings `userid` returned in the
token response instead of the fixed `tokens` partition key.
---
## Measurement type reference (common scale metrics)
| Code | Metric | Unit |
|------|--------|------|
| 1 | Weight | kg |
| 5 | Fat-free mass | kg |
| 6 | Fat ratio | % |
| 8 | Fat mass weight | kg |
| 76 | Muscle mass | kg |
| 77 | Hydration | kg |
| 88 | Bone mass | kg |
| 170 | Visceral fat | — |
Call `withings_list_measure_types` for the full set (blood pressure, heart rate,
SpO₂, temperature, pulse wave velocity, etc.).
---
## License
MIT
This server cannot be deployed
Maintenance
ActivityStale
ResponsivenessNo issues