Skip to main content
Glama
adamconde

personal-health-mcp

by adamconde

Health Insights MCP Server

CI Python 3.12+ MCP Built with FastMCP Docker Ruff Checked with mypy License: MIT

A self-hosted, single-user MCP server that aggregates your personal health data from Google Health, Oura, and Withings behind one normalized, provider-attributed interface — and is built so new vendors drop in with no changes to the core.

It exposes Model Context Protocol tools (over Streamable HTTP, reachable from Claude Desktop or any MCP client on another machine) and a small web UI for managing provider connections and preferences.

Table of contents

Related MCP server: Withings MCP

What it does

  • One canonical model. Every provider's data is mapped into one canonical unit per dimension (kg, metres, seconds, kcal, bpm, °C). Comparison and resolution happen in canonical units; conversion to your display units happens only at the edge.

  • You choose the source of truth. For any metric (Step Count, Weight, …) pick authority (prefer one provider; fall back to an ordered list if it has no data) or auto (the most recent value across all providers).

  • Provenance, always. Every response names the provider that supplied each value.

  • Your units. Choose kg vs lb, km vs mi, °C vs °F, and cm vs ft/in for height — imperial by default. When a provider can't serve a unit natively, the server converts; height in ft/in is rendered as feet and inches (e.g. 5' 9").

  • Broad coverage. Steps, distance, calories, weight & body composition, heart rate, HRV, SpO₂, sleep stages, readiness/sleep scores, VO₂max, temperature, blood pressure/glucose, and more — including metrics only one provider supplies.

  • Single user, self-hosted. No multi-tenant support by design. The preferences page is your preferences. If someone else wants it, they host their own.

Architecture at a glance

MCP clients (Claude Desktop, …) ──HTTPS──▶ [   Cloudflare Tunnel   ]
                                                      │  (TLS terminated at CF edge)
                                                      ▼  http (internal docker net)
                                            ┌─────────────────────────┐
                                            │  app (uvicorn)          │
                                            │   /mcp  → FastMCP        │  ← bearer token
                                            │   /     → web UI         │  ← session login
                                            │   /oauth/* → callbacks   │
                                            └───────────┬─────────────┘
                                                        ▼
                                            SQLite (/data) — prefs +
                                            ENCRYPTED tokens & secrets
                          ── outbound ──▶ Google Health · Oura · Withings APIs

See docs/ and the source under src/personal_health_mcp/ for the provider abstraction, resolution engine, and unit layer.


Prerequisites

  • Docker and Docker Compose.

  • A domain you own, added to Cloudflare. Hosting needs a stable HTTPS hostname because OAuth redirect URIs must be registered with each provider and cannot change.

  • A developer account / app with each provider you want to use (Google Health, Oura, Withings).


Quick start

git clone https://github.com/adamconde/personal-health-mcp.git
cd personal-health-mcp
cp .env.example .env        # then fill it in (see Configuration)
docker compose --env-file .env -f deploy/docker-compose.yml up -d

Open https://<your-domain>/, sign in (with GitHub if configured, otherwise WEB_PASSWORD), go to Providers, enter each provider's client id/secret, click Connect, then set your Metrics and Units preferences.

Always pass --env-file .env and run from the repo root. Compose's ${VAR} interpolation (used for CF_TUNNEL_TOKEN) loads its .env from the compose file's directory (deploy/), not your shell's working directory — so without --env-file .env CF_TUNNEL_TOKEN resolves empty even though your repo-root .env is correct.


Configuration

Configuration is via environment variables (.env). Generate the secrets:

# MCP bearer token and session secret
python -c "import secrets; print(secrets.token_urlsafe(48))"
# Token/secret encryption key (Fernet)
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"

Variable

Required

Description

PUBLIC_BASE_URL

External HTTPS origin, no trailing slash (e.g. https://health.example.com). Used to build OAuth redirect URIs.

MCP_AUTH_TOKEN

Bearer token MCP clients must send (unless GitHub OAuth is configured below).

GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET

optional

Set both to protect /mcp and offer "Sign in with GitHub" on the web UI, using the same OAuth app.

GITHUB_ALLOWED_USERS

optional

Comma-separated GitHub logins allowed when GitHub OAuth is on (set it — blank = any account). Applies to web + MCP.

WEB_PASSWORD

Web UI password (argon2id-hashed at boot). Break-glass when GitHub login is on: accepted only from WEB_PASSWORD_ALLOWED_CIDRS.

WEB_PASSWORD_ALLOWED_CIDRS

Comma-separated CIDRs from which password login is accepted. Blank = private/loopback LAN ranges ("same VLAN").

TRUSTED_PROXY_CIDRS

proxy

CIDRs of your reverse proxy (e.g. the Docker network). Required behind the Cloudflare Tunnel for the IP gate to see the real client.

SESSION_SECRET

Signs session cookies.

TOKEN_ENC_KEY

Fernet key encrypting tokens & client secrets at rest. Comma-separate multiple keys (newest first) to rotate.

DATABASE_PATH

SQLite path (default /data/health.db).

LOG_LEVEL

debug/info/warning/error.

CF_TUNNEL_TOKEN

Cloudflare

Named-tunnel connector token.

GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET

optional

Headless fallback — normally set in the UI.

OURA_CLIENT_ID / OURA_CLIENT_SECRET

optional

Headless fallback.

WITHINGS_CLIENT_ID / WITHINGS_CLIENT_SECRET

optional

Headless fallback.

Provider API credentials are normally entered in the web UI (/providers) and stored encrypted. The *_CLIENT_* env vars are only an optional bootstrap fallback for headless setups; the UI value always wins.

.env and *.db are git-ignored. Never commit secrets.


Provider setup (OAuth apps)

Create an OAuth app with each provider and register the exact redirect URI. Replace health.example.com with your domain.

Provider

Developer console

Redirect URI

Scopes

Google Health

Google Cloud Console → APIs & Services → Credentials → OAuth client (Web)

https://health.example.com/oauth/google/callback

…/googlehealth.activity_and_fitness.readonly, …health_metrics_and_measurements.readonly, …sleep.readonly

Oura

https://cloud.ouraring.com → OAuth applications

https://health.example.com/oauth/oura/callback

personal daily heartrate workout session spo2

Withings

https://developer.withings.com → your app

https://health.example.com/oauth/withings/callback

user.info,user.metrics,user.activity,user.sleepevents

Notes:

  • The redirect URI must match byte-for-byte what the server uses (PUBLIC_BASE_URL + /oauth/<provider>/callback).

  • Google requires consent screen configuration and returns a refresh token only with access_type=offline + prompt=consent (the server requests both).

  • Withings rotates its refresh token on every refresh; the server persists the new one automatically.

Paste each app's client id and client secret into the Providers page and click Connect to run the OAuth flow.


Install: from GHCR or build locally

Two compose files ship in deploy/:

  • docker-compose.yml — pulls the published image from GHCR (ghcr.io/adamconde/personal-health-mcp:latest). This is the default for running the server; no build toolchain needed. Override IMAGE to pin a tag.

  • docker-compose-dev.ymlbuilds the image from source in this repo. Use it when developing or running unreleased changes.

Pull a published image directly (the default compose file does this for you):

docker pull ghcr.io/adamconde/personal-health-mcp:latest

Or build locally (what docker-compose-dev.yml does):

docker build -f deploy/Dockerfile -t personal-health-mcp:local .

Sample docker-compose.yml

A complete single-file example. Adjust the image:

services:
  app:
    image: ghcr.io/adamconde/personal-health-mcp:latest
    env_file: [.env]
    environment:
      DATABASE_PATH: /data/health.db
    expose: ["8000"] # internal only — never publish to the host
    volumes: ["health-data:/data"]
    restart: unless-stopped

  cloudflared:
    image: cloudflare/cloudflared:latest
    command: tunnel --no-autoupdate run
    environment:
      - "TUNNEL_TOKEN=${CF_TUNNEL_TOKEN:?set CF_TUNNEL_TOKEN in .env}"
    depends_on: [app]
    restart: unless-stopped

volumes:
  health-data:

Hosting

A Cloudflare Tunnel publishes a stable HTTPS hostname (needed for OAuth) and keeps the app itself unpublished on the internal Docker network.

Cloudflare Tunnel (no open ports)

Free on a Cloudflare account; you only need a domain added to Cloudflare.

  1. Add your domain to Cloudflare (free plan is fine).

  2. Create a named tunnel (Zero Trust dashboard → Networks → Tunnels, or cloudflared tunnel create health). Copy the tunnel token.

  3. Route a hostname to the app: in the tunnel's Public Hostname config, map health.example.comhttp://app:8000.

  4. In .env: set CF_TUNNEL_TOKEN=… and PUBLIC_BASE_URL=https://health.example.com.

  5. Launch:

    docker compose --env-file .env -f deploy/docker-compose.yml up -d

No inbound ports are opened; Cloudflare terminates TLS at its edge.

LAN / development

You can run the app directly for development:

pip install -e ".[dev]"
make run    # uvicorn on http://localhost:8000

OAuth still requires a stable HTTPS redirect URL, so for real provider connections use the Cloudflare Tunnel (or a tunnel to your dev box).


Connecting MCP clients

Point any MCP client at https://<your-domain>/mcp with the bearer token.

Claude Desktop (claude_desktop_config.json):

{
  "mcpServers": {
    "personal-health": {
      "type": "streamableHttp",
      "url": "https://health.example.com/mcp",
      "headers": { "Authorization": "Bearer YOUR_MCP_AUTH_TOKEN" },
    },
  },
}

If your client lacks native remote Streamable HTTP, bridge over stdio:

{
  "mcpServers": {
    "personal-health": {
      "command": "npx",
      "args": [
        "mcp-remote",
        "https://health.example.com/mcp",
        "--header",
        "Authorization: Bearer YOUR_MCP_AUTH_TOKEN",
      ],
    },
  },
}

Authenticating with GitHub OAuth (optional)

Instead of the static bearer token, you can protect /mcp with GitHub OAuth — clients do a browser login, and access is restricted to GitHub logins you allow. The web UI uses the same OAuth app: when GitHub OAuth is configured the login page shows "Sign in with GitHub" (allowlisted by the same GITHUB_ALLOWED_USERS), and WEB_PASSWORD becomes a LAN-only break-glass fallback (see Web UI login).

1. Create a GitHub OAuth app (GitHub → Settings → Developer settings → OAuth Apps) with Authorization callback URL https://<your-domain>/auth/callback. The web-UI login flow uses …/auth/callback/web, a subdirectory GitHub permits for the same app — no second callback to register.

2. In .env set the following. When both id+secret are present, /mcp switches from bearer to GitHub OAuth automatically and the web UI offers GitHub sign-in. Set GITHUB_ALLOWED_USERS — otherwise any GitHub account that authorizes the app could reach your data.

GITHUB_CLIENT_ID=Ov23li...
GITHUB_CLIENT_SECRET=...
GITHUB_ALLOWED_USERS=your-github-login   # comma-separated; restricts access

3. Point the client at the URL with no Authorization header — it discovers OAuth and opens a browser login (or use npx mcp-remote https://health.example.com/mcp with no --header):

{
  "mcpServers": {
    "personal-health": { "type": "streamableHttp", "url": "https://health.example.com/mcp" }
  }
}

In this mode the server runs the MCP app at the origin root so OAuth discovery (/.well-known/...) and the callback (/auth/callback) resolve correctly; the web UI continues to serve at /.

Tools

Tool

Purpose

health_list_providers

Providers and their connection status.

health_provider_auth_status

Whether a provider is connected/usable.

health_list_metrics

Metrics available from connected providers.

health_get_metric

A metric over a date range, resolved (provider named per point).

health_compare_metric

A metric from every provider side-by-side (unresolved).

health_get_sleep

Composite sleep summary for a night.

health_get_daily_summary

Multi-metric summary for a day.

health_set_metric_authority

Set a metric's resolution preference.

Example prompts: “What was my weight last week, in pounds?”, “Compare my step count across providers for yesterday.”, “Make Withings the authority for weight, falling back to Google.”

Web UI pages

  • Dashboard — provider status + effective preferences.

  • Providers — enter credentials (secret is write-only), connect/disconnect.

  • Metrics — per-metric authority/auto + fallback order, saved for all metrics at once with a single button.

  • Units — mass, distance, height (cm or ft/in), temperature display units; imperial by default.

  • Logging — rolling history of provider API errors in a sortable, filterable table (severity, provider, time, error preview); click a row for the full message. Replaces the inline error text that used to appear on Providers/Dashboard.

Web UI login

  • GitHub sign-in (when GITHUB_CLIENT_ID/SECRET are set) — the same OAuth app and GITHUB_ALLOWED_USERS allowlist that guard /mcp. Works from anywhere.

  • Password (break-glass)WEB_PASSWORD is accepted only from client IPs in WEB_PASSWORD_ALLOWED_CIDRS (blank = private/loopback LAN ranges). The login page hides the password field entirely when the request isn't from an allowed IP.

  • Behind a proxy: the real client IP is read from forwarded headers (X-Forwarded-For, peeled right-to-left; CF-Connecting-IP for Cloudflare) only when the direct peer is in TRUSTED_PROXY_CIDRS — otherwise the peer address is used and forwarded headers are ignored (anti-spoofing). Set TRUSTED_PROXY_CIDRS to your Docker/proxy network so LAN clients are recognized; if unset, password login effectively fails closed behind the proxy (GitHub still works).


Operations

  • Backups: back up the health-data volume (/data/health.db*). It holds your preferences and encrypted tokens.

  • Key rotation: prepend a new Fernet key to TOKEN_ENC_KEY (new,old); reads still work with the old key, new writes use the new one. Once everything is re-encrypted you can drop the old key.

  • Upgrades: docker compose … pull && docker compose … up -d.

  • Troubleshooting:

    • 401 from /mcp → check the Authorization: Bearer header / MCP_AUTH_TOKEN.

    • Redirect-URI mismatch → the registered URI must equal PUBLIC_BASE_URL + /oauth/<provider>/callback exactly.

    • Token refresh failed / reconnect prompt → re-connect the provider on the Providers page (e.g. credentials changed or refresh token revoked).


Security

  • /mcp requires a static bearer token (or GitHub OAuth, if configured); the web UI requires a session login (argon2id-hashed password). Three distinct secrets (MCP bearer, session key, encryption key) — never reuse them.

  • OAuth tokens and client secrets are encrypted at rest (Fernet).

  • CSRF protection on all state-changing forms; strict security headers (HSTS, CSP, nosniff, frame-ancestors 'none').

  • The app is never published to the host; only the TLS proxy/tunnel is exposed.

  • Set cookie_secure=true (default) in any internet-facing deployment.


Adding a new provider

  1. Create src/personal_health_mcp/providers/<vendor>.py subclassing HealthProvider; implement capabilities() and fetch_metric() (map the raw response into canonical DataPoints, reusing existing metric keys), set oauth, and decorate with @register.

  2. Import it in providers/__init__.py.

  3. Run the one-time OAuth connect on the Providers page.

No changes to the aggregator, resolution engine, units, tools, or templates — the new provider appears automatically. (See tests/integration/test_extensibility.py.)


Development

make install      # editable install with dev extras
make lint         # ruff
make type         # mypy
make cov          # pytest with coverage
make check        # all of the above
make run          # run locally on :8000

Project layout

src/personal_health_mcp/
  server.py        # ASGI root: mounts /mcp (bearer) + web UI; uvicorn factory
  app.py           # wiring of shared services (AppContext)
  config.py        # settings / secrets
  models.py        # DataPoint, MetricSeries, ResponseEnvelope, Token, enums
  metrics.py       # canonical metric registry + preference groups
  units.py         # unit table + conversion
  display.py       # display-unit resolution
  resolution.py    # authority / fallback / auto engine
  aggregator.py    # fetch -> resolve -> convert -> envelope
  storage.py       # SQLite + encrypted token/secret store + prefs
  crypto.py        # Fernet/MultiFernet helper
  oauth.py         # AuthFlow + TokenManager (lazy refresh)
  tools.py         # MCP tools
  providers/       # base + google/oura/withings
  web/             # routes, templates, security middleware
deploy/            # Dockerfile, docker-compose.yml (GHCR image) + -dev.yml (build)
.github/workflows/ # ci.yml, release.yml (GHCR)
tests/             # unit + integration

Version history

  • 2.6.0 — Simplified deployment: Cloudflare Tunnel only, two compose files.

    • Removed the Caddy / Let's Encrypt hosting option (Caddyfile, compose.caddy.yml, the CADDY_DOMAIN env var, and the caddy_domain config field). Cloudflare Tunnel is now the sole supported public-hosting path. A stale CADDY_DOMAIN in .env is harmless — config ignores it.

    • Collapsed the base + cloudflared overlay into a single self-contained compose file. deploy/docker-compose.yml now runs the published GHCR image (ghcr.io/adamconde/personal-health-mcp:latest); the new deploy/docker-compose-dev.yml builds from source for development.

    • Run: docker compose --env-file .env -f deploy/docker-compose.yml up -d.

  • 2.5.0 — Logging tab for provider errors.

    • New Logging page: a rolling, capped (newest 500) history of provider API errors in a sortable/filterable table — severity, provider, timestamp, and a one-line preview; click any row for the full error. Auth failures (401/403) are recorded at warning severity, others at error.

    • Errors are persisted to a new error_log table (every set_status that records a last_error now also appends to the log).

    • Removed the inline error text from the Providers and Dashboard pages (status chips remain); error detail now lives on the Logging page.

  • 2.4.0 — Read-path error visibility & Google query correctness.

    • health_get_metric (and compare_metric) now surface per-provider failures in an errors map instead of masking them as an empty result — a 4xx/auth error no longer reads as "no data for this metric"; an explaining note is set when an empty series is due to provider errors.

    • Google Health: build the time-window filter from each data type's actual time field — interval.start_time, sample_time.physical_time, or daily date (YYYY-MM-DD) — fixing 400s on sample metrics (weight, heart rate, SpO₂, …) and daily metrics (resting heart rate, respiratory rate).

    • Removed Google total_calories: it has no standalone v4 dataPoints type (lives only on the dailyRollUp endpoint). Still served by Oura/Withings.

  • 2.3.0 — GitHub sign-in for the web UI + IP-gated password.

    • The web UI can authenticate with GitHub OAuth, reusing the same OAuth app and GITHUB_ALLOWED_USERS allowlist that protect /mcp (web callback …/auth/callback/web, a subdirectory of the existing one — no GitHub change).

    • WEB_PASSWORD becomes a break-glass login accepted only from WEB_PASSWORD_ALLOWED_CIDRS (default: private/loopback LAN ranges).

    • Real client IP is resolved securely behind proxies via TRUSTED_PROXY_CIDRS (right-to-left X-Forwarded-For peeling + CF-Connecting-IP); forwarded headers are ignored unless the direct peer is a trusted proxy.

  • 2.2.0 — Units & metric-preferences UX.

    • Display units now default to imperial across the board: mass lb, distance mi, temperature °F, height ft/in.

    • Height offers two choices — cm or ft/in — and ft/in renders as whole feet and inches (e.g. 5' 9") via a new formatted field on resolved points.

    • The Metrics tab saves every metric's resolution preference at once with a single button (one form) instead of one button per metric.

  • 2.1.0 — Read-path correctness & resilience.

    • Recover from a mid-fetch token rejection: providers classify 401/403 as an auth error and the aggregator refreshes the token once and retries (instead of masking it as "no data").

    • Malformed provider records are skipped individually rather than dropping the whole series; Withings getmeas pagination can no longer loop forever.

    • Auto-mode resolution tie-breaks are now fully deterministic.

    • Provider fetches run concurrently; date-range inputs are validated and capped.

  • 2.0.0 — Fail-fast security validation at startup (breaking: the server now refuses to boot on insecure defaults). SESSION_SECRET is required (the insecure built-in fallback was removed); MCP_AUTH_TOKEN is required unless GitHub OAuth is configured; and GITHUB_ALLOWED_USERS is required when GitHub OAuth is enabled (an empty allowlist no longer silently admits any GitHub user).

  • 1.0.0 — First stable release.

    • Google Health, Oura, and Withings providers behind a canonical, unit-normalized, provider-attributed model with authority/fallback/auto resolution.

    • MCP tools over Streamable HTTP; auth via a static bearer token or optional GitHub OAuth (browser login + GitHub-login allowlist).

    • Material Design 3 web UI (light/dark) for provider connections and metric/unit preferences, with links to each vendor's credential console.

    • Docker with Cloudflare Tunnel / Caddy hosting overlays; CI + GHCR release.

    • Run with docker compose --env-file .env … (so Compose interpolates CF_TUNNEL_TOKEN / CADDY_DOMAIN from the repo-root .env).

License

MIT.

A
license - permissive license
-
quality - not tested
C
maintenance

Maintenance

Maintainers
Response time
Release cycle
Releases (12mo)
Commit activity

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/adamconde/personal-health-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server