personal-health-mcp
Aggregates personal health data from Google Health, including activity, body metrics, sleep, and more.
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@personal-health-mcpwhat was my average heart rate last night?"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
Health Insights MCP Server
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) orauto(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/inis 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 APIsSee 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 -dOpen 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 .envand run from the repo root. Compose's${VAR}interpolation (used forCF_TUNNEL_TOKEN) loads its.envfrom the compose file's directory (deploy/), not your shell's working directory — so without--env-file .envCF_TUNNEL_TOKENresolves empty even though your repo-root.envis 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 |
| ✅ | External HTTPS origin, no trailing slash (e.g. |
| ✅ | Bearer token MCP clients must send (unless GitHub OAuth is configured below). |
| optional | Set both to protect |
| optional | Comma-separated GitHub logins allowed when GitHub OAuth is on (set it — blank = any account). Applies to web + MCP. |
| ✅ | Web UI password (argon2id-hashed at boot). Break-glass when GitHub login is on: accepted only from |
| – | Comma-separated CIDRs from which password login is accepted. Blank = private/loopback LAN ranges ("same VLAN"). |
| 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. |
| ✅ | Signs session cookies. |
| ✅ | Fernet key encrypting tokens & client secrets at rest. Comma-separate multiple keys (newest first) to rotate. |
| – | SQLite path (default |
| – |
|
| Cloudflare | Named-tunnel connector token. |
| optional | Headless fallback — normally set in the UI. |
| optional | Headless fallback. |
| 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) |
|
|
Oura | https://cloud.ouraring.com → OAuth applications |
|
|
Withings | https://developer.withings.com → your app |
|
|
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. OverrideIMAGEto pin a tag.docker-compose-dev.yml— builds 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:latestOr 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.
Add your domain to Cloudflare (free plan is fine).
Create a named tunnel (Zero Trust dashboard → Networks → Tunnels, or
cloudflared tunnel create health). Copy the tunnel token.Route a hostname to the app: in the tunnel's Public Hostname config, map
health.example.com→http://app:8000.In
.env: setCF_TUNNEL_TOKEN=…andPUBLIC_BASE_URL=https://health.example.com.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:8000OAuth 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 access3. 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 |
| Providers and their connection status. |
| Whether a provider is connected/usable. |
| Metrics available from connected providers. |
| A metric over a date range, resolved (provider named per point). |
| A metric from every provider side-by-side (unresolved). |
| Composite sleep summary for a night. |
| Multi-metric summary for a day. |
| 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/SECRETare set) — the same OAuth app andGITHUB_ALLOWED_USERSallowlist that guard/mcp. Works from anywhere.Password (break-glass) —
WEB_PASSWORDis accepted only from client IPs inWEB_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-IPfor Cloudflare) only when the direct peer is inTRUSTED_PROXY_CIDRS— otherwise the peer address is used and forwarded headers are ignored (anti-spoofing). SetTRUSTED_PROXY_CIDRSto 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-datavolume (/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 theAuthorization: Bearerheader /MCP_AUTH_TOKEN.Redirect-URI mismatch → the registered URI must equal
PUBLIC_BASE_URL+/oauth/<provider>/callbackexactly.Token refresh failed / reconnect prompt → re-connect the provider on the Providers page (e.g. credentials changed or refresh token revoked).
Security
/mcprequires 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
Create
src/personal_health_mcp/providers/<vendor>.pysubclassingHealthProvider; implementcapabilities()andfetch_metric()(map the raw response into canonicalDataPoints, reusing existing metric keys), setoauth, and decorate with@register.Import it in
providers/__init__.py.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 :8000Project 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 + integrationVersion history
2.6.0 — Simplified deployment: Cloudflare Tunnel only, two compose files.
Removed the Caddy / Let's Encrypt hosting option (
Caddyfile,compose.caddy.yml, theCADDY_DOMAINenv var, and thecaddy_domainconfig field). Cloudflare Tunnel is now the sole supported public-hosting path. A staleCADDY_DOMAINin.envis harmless — config ignores it.Collapsed the base + cloudflared overlay into a single self-contained compose file.
deploy/docker-compose.ymlnow runs the published GHCR image (ghcr.io/adamconde/personal-health-mcp:latest); the newdeploy/docker-compose-dev.ymlbuilds 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
warningseverity, others aterror.Errors are persisted to a new
error_logtable (everyset_statusthat records alast_errornow 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(andcompare_metric) now surface per-provider failures in anerrorsmap instead of masking them as an empty result — a 4xx/auth error no longer reads as "no data for this metric"; an explainingnoteis 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 dailydate(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_USERSallowlist that protect/mcp(web callback…/auth/callback/web, a subdirectory of the existing one — no GitHub change).WEB_PASSWORDbecomes a break-glass login accepted only fromWEB_PASSWORD_ALLOWED_CIDRS(default: private/loopback LAN ranges).Real client IP is resolved securely behind proxies via
TRUSTED_PROXY_CIDRS(right-to-leftX-Forwarded-Forpeeling +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, distancemi, temperature°F, heightft/in.Height offers two choices —
cmorft/in— andft/inrenders as whole feet and inches (e.g.5' 9") via a newformattedfield 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
getmeaspagination 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_SECRETis required (the insecure built-in fallback was removed);MCP_AUTH_TOKENis required unless GitHub OAuth is configured; andGITHUB_ALLOWED_USERSis 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 interpolatesCF_TUNNEL_TOKEN/CADDY_DOMAINfrom the repo-root.env).
License
MIT.
This server cannot be installed
Maintenance
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
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
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