garmin-mcp
by lopezec007
README.md
# garmin-mcp
An MCP server that gives MCP clients read-only access to one person's Garmin
Connect account (activities, sleep, heart rate, training metrics and so on). It
serves the MCP streamable HTTP transport and runs as a sandboxed systemd
service on a Debian 13 machine, without containers.
There are two ways in, and both are on out of the box. A client can send a
bearer token in a header. Or it can sign in with OAuth: the server is its own
OAuth 2.1 authorization server, and every client has to be approved in a
browser with a password that only the owner knows. OAuth needs no
configuration and no public address, but most clients only do it over HTTPS;
see [Connecting clients](#connecting-clients). To open the server to the
internet, put a TLS reverse proxy in front of it; see
[Public access with OAuth](#public-access-with-oauth).
It talks to Garmin through the unofficial
[`garminconnect`](https://github.com/cyberjunky/python-garminconnect) library,
which uses Garmin's private API. Garmin changes that API from time to time, and
logins or individual tools can then stop working until the library catches up.
The fix is to upgrade the dependency and reinstall; see [Upgrade](#upgrade).
## Install
Requirements: Debian 13 with systemd, root access, and outbound HTTPS (PyPI
while installing, Garmin while running). The installer uses the distribution's
Python (3.13; anything from 3.12 works) and installs `python3-venv` with apt if
it is missing.
```sh
git clone <repo-url> garmin-mcp
cd garmin-mcp
sudo ./install.sh
```
The installer creates the `garmin-mcp` system user, builds the package into a
virtualenv, installs the unit, starts the service, and checks that it answers
on `/healthz`. It ends by printing the steps that are still open. They are:
1. Note the bearer token. A first install generates a random
`GARMIN_MCP_AUTH_TOKEN` in `/etc/garmin-mcp/garmin-mcp.env`, so the endpoint
never starts open. Read it with
`sudo grep AUTH_TOKEN /etc/garmin-mcp/garmin-mcp.env`. To change it, edit
the file (`sudoedit /etc/garmin-mcp/garmin-mcp.env`) and run
`sudo systemctl restart garmin-mcp`.
2. Log in to Garmin once: `sudo garmin-mcp login`. It asks for email, password
and, if the account has MFA, the code. See
[Authenticating with Garmin](#authenticating-with-garmin) for the
alternative of storing the credentials.
3. Verify:
```sh
curl http://<server-ip>:8765/healthz # {"status":"ok","version":"..."}
sudo garmin-mcp check # OK: logged in as <your name>
# List the tools through the MCP endpoint itself:
curl -s http://<server-ip>:8765/mcp \
-H 'Authorization: Bearer <token>' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```
That is a complete setup for the home network. To use the server from outside
it, continue with [Public access with OAuth](#public-access-with-oauth).
What ends up where:
| Path | Contents |
| --- | --- |
| `/opt/garmin-mcp/venv` | Virtualenv holding a copy of the package and its dependencies. The checkout is not used at runtime. |
| `/etc/garmin-mcp/garmin-mcp.env` | Settings and secrets, `root:garmin-mcp`, mode 0640. Created on first install, never overwritten. |
| `/etc/garmin-mcp/garmin-mcp.env.example` | Current reference copy of the settings file, refreshed on every install. |
| `/var/lib/garmin-mcp/tokens` | Garmin tokens. Owned by `garmin-mcp`, mode 0700. |
| `/var/lib/garmin-mcp/oauth.db` | OAuth state: registered clients (including, for clients that have one, their client secret), hashed tokens and the hash of the owner password. An SQLite database; the `oauth.db-wal` and `oauth.db-shm` files next to it are part of it. Owned by `garmin-mcp`, mode 0600. Created when OAuth is first used. |
| `/etc/systemd/system/garmin-mcp.service` | The unit. Overwritten on every install; put local changes in a drop-in (`sudo systemctl edit garmin-mcp`). |
| `/usr/local/bin/garmin-mcp` | Admin wrapper for `sudo garmin-mcp login`, `check`, `set-password`, `clients` and `revoke`. |
## Configuration
All settings are environment variables in `/etc/garmin-mcp/garmin-mcp.env`.
Every one is optional. Restart the service after a change
(`sudo systemctl restart garmin-mcp`).
| Variable | Default | Meaning |
| --- | --- | --- |
| `GARMIN_EMAIL` | unset | Garmin account email. Only needed if the service should log in by itself. |
| `GARMIN_PASSWORD` | unset | Garmin account password, stored in clear text. Only needed together with `GARMIN_EMAIL`. |
| `GARMIN_IS_CN` | `false` | `true` for accounts on Garmin's China region. |
| `GARMIN_TOKEN_DIR` | `/var/lib/garmin-mcp/tokens` | Where tokens are saved. Under systemd the default is `$STATE_DIRECTORY/tokens`; outside systemd it is `~/.garminconnect`. Leave unset on the server: the sandbox allows writes nowhere else. |
| `GARMIN_MCP_LOGIN_COOLDOWN` | `300` | Seconds to wait after a failed Garmin login before trying again. |
| `GARMIN_MCP_AUTH_TOKEN` | unset | Shared secret: an MCP request that carries `Authorization: Bearer <token>` is let in. It works next to OAuth; unset leaves OAuth as the only way in. With `GARMIN_MCP_OAUTH=off`, unset means no authentication at all. Must be at least 32 characters when `GARMIN_MCP_PUBLIC_URL` is set. |
| `GARMIN_MCP_OAUTH` | `on` | The built-in OAuth sign-in. `off` removes the OAuth endpoints and leaves the bearer token as the only protection. Any other value is a configuration error. |
| `GARMIN_MCP_ALLOWED_HOSTS` | unset | Comma-separated allowlist for the HTTP `Host` header on the MCP endpoint. An entry is `host:port`, `host:*` (any port, but the header must have one) or a bare `host` (a name used without a port, such as the public name behind a TLS proxy). Unset accepts any host. |
| `GARMIN_MCP_PUBLIC_URL` | unset | Optional. The origin the server is reachable at, for example `https://garmin.example.com`: scheme and host name, no path. Unset, the server names itself in the OAuth discovery documents by the address each request was sent to, so it works under any address without configuration. Set, it always uses this value, whatever a request claims; do that once the server is on the internet. Must be `https` (plain `http` is accepted for `localhost` only). With a value that is not usable the service exits with a configuration error. |
| `GARMIN_MCP_TRUSTED_PROXIES` | `127.0.0.1` | Comma-separated addresses or networks of the reverse proxies whose `X-Forwarded-For` header is believed. Only needs changing when the proxy runs on another machine. |
| `GARMIN_MCP_OAUTH_ACCESS_TTL` | `3600` | Lifetime of an OAuth access token in seconds (at least 60). |
| `GARMIN_MCP_OAUTH_REFRESH_TTL` | `2592000` | Lifetime of an OAuth refresh token in seconds (30 days; not shorter than the access token's). |
| `GARMIN_MCP_OAUTH_DB` | `/var/lib/garmin-mcp/oauth.db` | The OAuth database. Under systemd the default is `$STATE_DIRECTORY/oauth.db`; outside systemd it is `~/.local/state/garmin-mcp/oauth.db`. Leave unset on the server, for the same reason as `GARMIN_TOKEN_DIR`. |
| `GARMIN_MCP_HOST` | `0.0.0.0` | Address to bind. |
| `GARMIN_MCP_PORT` | `8765` | TCP port. |
| `GARMIN_MCP_PATH` | `/mcp` | URL path of the MCP endpoint. |
| `GARMIN_MCP_LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARNING` or `ERROR`. `DEBUG` also logs each HTTP request. |
The file is a systemd `EnvironmentFile`, not a shell script: nothing is
expanded, and there are no trailing comments. Put a value that contains special
characters in single quotes (`GARMIN_PASSWORD='p@ss #1 $x'`). The comments in
the file give the full quoting rules.
## Authenticating with Garmin
The service needs saved tokens or credentials. There are two ways to provide
them:
- **One-time login (recommended).** Run `sudo garmin-mcp login`. It prompts for
email, password and MFA code, saves tokens to `/var/lib/garmin-mcp/tokens`,
and restarts the service. No password is stored on disk. Accounts with MFA
must use this, because the background service cannot answer the MFA prompt.
- **Stored credentials.** Set `GARMIN_EMAIL` and `GARMIN_PASSWORD` in the env
file. The service logs in on the first tool call and saves tokens the same
way. This only works for accounts without MFA.
The service always tries the saved tokens first. It refreshes them as they
expire and writes the new ones back to the token directory, so you should
rarely need to log in again. `sudo garmin-mcp check` tells you whether the
saved tokens (or the stored credentials) currently work.
`sudo garmin-mcp login` and `check` run the installed program the way the
service runs it: as the `garmin-mcp` user, with the env file loaded and the
same token directory. If `GARMIN_EMAIL` or `GARMIN_PASSWORD` is set in the env
file, `login` uses it instead of prompting.
When the tokens stop working, tool calls return an error that says so:
- With stored credentials and no MFA, the service logs in again by itself.
- Otherwise run `sudo garmin-mcp login` again.
After a failed login the service does not contact Garmin again for
`GARMIN_MCP_LOGIN_COOLDOWN` seconds (default 300), so that a wrong password or
a Garmin rate limit does not turn into a series of login attempts. During that
time tool calls return the last login error and the remaining wait. Restarting
the service clears the wait; a successful `sudo garmin-mcp login` restarts it
for you. If Garmin is rate-limiting logins (HTTP 429), wait before you try
again rather than repeating the login.
None of this has anything to do with the owner password of
[Public access with OAuth](#public-access-with-oauth): that one decides who may
use this server, and Garmin never sees it.
## Connecting clients
The server speaks the MCP streamable HTTP transport and is stateless, so
clients keep working across service restarts. There are two ways for a client
to get in.
**Bearer token, on the home network.** The endpoint is
`http://<server-ip>:8765/mcp`. If `GARMIN_MCP_AUTH_TOKEN` is set, each request
must carry `Authorization: Bearer <token>`.
Claude Code:
```sh
claude mcp add --transport http garmin http://<server-ip>:8765/mcp \
--header "Authorization: Bearer <token>"
```
Clients configured through an `mcpServers` JSON block:
```json
{
"mcpServers": {
"garmin": {
"type": "http",
"url": "http://<server-ip>:8765/mcp",
"headers": {
"Authorization": "Bearer <token>"
}
}
}
}
```
Any client that supports streamable HTTP with custom headers can connect this
way.
**OAuth.** An MCP client that implements the MCP authorization flow (OAuth 2.1
with dynamic client registration) needs nothing but the endpoint URL:
```sh
claude mcp add --transport http garmin https://garmin.example.com/mcp
```
The client discovers the sign-in endpoints from the URL, registers itself, and
sends your browser to the approval page, where you type the owner password
(set it first: `sudo garmin-mcp set-password`). See
[The approval page](#the-approval-page).
The server offers this under whatever address it is reached at, the LAN
address included. Whether a client will use it there is up to the client, and
the ones from Anthropic currently will not over plain HTTP:
- **Claude Code** runs the OAuth flow, but, according to its documentation,
sends OAuth credentials only to a token endpoint that is HTTPS or on
`localhost`. Against `http://<server-ip>:8765/mcp` the sign-in stops with
"Refusing to send credentials to non-https token endpoint". The documented
way around it is the environment variable `MCP_SDK_GENERATION=v1`; the
documentation recommends serving HTTPS instead. Note also that Claude Code
does not try OAuth for a server that has an `Authorization` header
configured: use one method or the other.
- **claude.ai, Claude Desktop and the mobile apps** (custom connectors)
connect from Anthropic's servers, not from your device. They cannot reach
an address that only exists on your network, whatever it serves.
So in practice: on the plain-HTTP LAN address use the header; OAuth becomes
useful once the server has HTTPS in front of it, which for the hosted clients
also has to be reachable from the internet. The header method keeps working
next to OAuth for as long as `GARMIN_MCP_AUTH_TOKEN` is set.
## Public access with OAuth
The server is an OAuth 2.1 authorization server for itself (unless
`GARMIN_MCP_OAUTH=off`). It serves, next to `/mcp`:
- the discovery documents `/.well-known/oauth-protected-resource/mcp` and
`/.well-known/oauth-authorization-server`,
- `/register` (dynamic client registration), `/authorize`, `/token` and
`/revoke`,
- the approval page `/oauth/login`.
A client that is given the endpoint URL works out the rest by itself. What you
do is approve it: the browser opens the approval page, you type the **owner
password** and click Approve. The owner password is a password for this server
only. It is not the Garmin password, and it is set on the server with
`sudo garmin-mcp set-password`.
### Prerequisites
- **A DNS name** for the server, for example `garmin.example.com`, that
resolves to the machine (or to the tunnel, see below). The server needs the
whole host name: it cannot live under a sub-path such as
`https://example.com/garmin/`.
- **TLS in front of the server.** The server itself still speaks plain HTTP on
port 8765. Something has to terminate HTTPS and pass the requests on:
- A reverse proxy with a certificate on the same machine. Caddy takes the
least effort on Debian: `sudo apt install caddy`, put this in
`/etc/caddy/Caddyfile`, and run `sudo systemctl reload caddy`.
```
garmin.example.com {
reverse_proxy 127.0.0.1:8765
}
```
Caddy gets and renews a Let's Encrypt certificate by itself. For that the
name must resolve to the machine, and ports 80 and 443 must be reachable
from the internet (forward them on the router; do not forward 8765).
- Or a tunnel service that terminates TLS on its side and forwards to
`http://127.0.0.1:8765`. No inbound port is needed then. Follow the
service's own documentation; the requirements below are the same.
Whatever sits in front must forward every path at the root of the host name
unchanged (no prefix, no rewriting), keep the `Host` header, and put the
client's address in `X-Forwarded-For`. Caddy's `reverse_proxy` does all of
that without further settings.
### Setup
1. Edit `/etc/garmin-mcp/garmin-mcp.env`:
```
GARMIN_MCP_PUBLIC_URL=https://garmin.example.com
GARMIN_MCP_HOST=127.0.0.1
```
`GARMIN_MCP_PUBLIC_URL` is the bare origin: no path and no trailing `/mcp`.
OAuth works without it, because the server otherwise goes by the address
each request arrives under. Set it anyway once the server is public: then
the addresses it publishes no longer depend on what a request claims.
`GARMIN_MCP_HOST=127.0.0.1` makes the plain-HTTP port reachable through the
local proxy only. Leave it at `0.0.0.0` only if clients on the LAN still
connect to port 8765 directly with the bearer token. If you use
`GARMIN_MCP_ALLOWED_HOSTS`, add the public name to it as a bare
`garmin.example.com`.
The proxy on the same machine is trusted to report client addresses by
default. If the proxy runs on another machine, also set
`GARMIN_MCP_TRUSTED_PROXIES` to its address.
2. Set the owner password:
```sh
sudo garmin-mcp set-password
```
It asks twice and wants at least 12 characters. Only a hash is stored (in
`/var/lib/garmin-mcp/oauth.db`). When it is not run from a terminal it
reads the password as one line from standard input instead. Run it again
at any time to change the password: the change applies at once, without a
restart, and does not sign out clients that are already authorized.
3. Restart and look at the log:
```sh
sudo systemctl restart garmin-mcp
journalctl -u garmin-mcp -e
```
It should say `OAuth enabled: public endpoint https://garmin.example.com/mcp`.
A warning that no owner password is set means step 2 is still open; until
then nobody can authorize a client.
4. Check from a machine outside your network:
```sh
curl https://garmin.example.com/healthz
curl https://garmin.example.com/.well-known/oauth-protected-resource/mcp
curl https://garmin.example.com/.well-known/oauth-authorization-server
curl -i -X POST https://garmin.example.com/mcp
```
The first document must name `"resource":"https://garmin.example.com/mcp"`,
exactly the URL you will give to clients. Every URL in the second must
start with `https://garmin.example.com`. The last command must answer
`401` with a `WWW-Authenticate` header that points at the first document:
that is how clients find the sign-in.
5. Add the server to a client by URL only, as shown under
[Connecting clients](#connecting-clients), and approve it.
### The approval page
When a client asks for access, your browser shows a page with:
- the name of the application. The application picks that name itself when it
registers, and nothing verifies it: anything can call itself anything.
- where the browser will be sent after approval. This is the part to read,
because the code that grants access goes there and nowhere else. For a
hosted service it is that service's own domain; for a program on your own
computer it is `localhost` or `127.0.0.1` with a port. Only the host is
shown, so it cannot be dressed up with a misleading prefix. For an app with
its own URL scheme (`cursor://...`) the whole link is shown instead, with a
note that it is not a web address.
- how long ago the request was started.
- a field for the owner password, and the buttons Approve and Deny.
Approve only when you started the connection yourself a moment ago and the
destination fits the application you are connecting. If the page appears
without your doing, or the destination is a domain you do not recognise, click
Deny or close it. **A link to the approval page that someone sends you is
always an attack**, however genuine the page looks: the page is real, but the
access would go to whoever started that request. Nothing is granted without
the password.
The page is valid for 5 minutes and for one approval. There is no login
session: every approval asks for the password again. Approving a client that
was already authorized replaces its earlier sign-in.
### Managing clients
```sh
sudo garmin-mcp clients # list the authorized clients
sudo garmin-mcp revoke <client_id> # sign one out and forget it
sudo garmin-mcp revoke --all # sign out every client
```
`clients` prints, for each client you approved, its id, the name it gave
itself, when it was authorized, when it last got a token, until when it stays
signed in, and its redirect URIs. `revoke` takes the id from that list. A
revoked client is cut off at once and has to register and be approved again.
None of these commands needs a restart.
If the owner password may have leaked, set a new one first, then run
`sudo garmin-mcp revoke --all` and approve your clients again.
### Token lifetimes
- An access token, which the client sends with every request, lives 1 hour
(`GARMIN_MCP_OAUTH_ACCESS_TTL`).
- A refresh token, with which the client gets the next access token without
asking you, lives 30 days (`GARMIN_MCP_OAUTH_REFRESH_TTL`) and is replaced
by a new one every time it is used. If a refresh token that was already
replaced shows up again more than a minute later, it has been copied, and
the server signs that client out entirely (it logs "Rotated refresh token
replayed"); the client then has to be approved again.
So a client stays signed in for as long as it is used at least once every 30
days, and has to be approved again after a longer pause. Authorized clients
survive service restarts and upgrades. A changed lifetime applies to tokens
issued after the restart. The static `GARMIN_MCP_AUTH_TOKEN` is not an OAuth
token and never expires.
### Troubleshooting
- **`/mcp` answers 401.** Expected for a request without a valid token; it is
the client's cue to start the sign-in. A client that reports only "401" or
"unauthorized" probably does not implement the OAuth flow: use the bearer
token header with it.
- **The discovery documents answer 404.** OAuth is switched off
(`GARMIN_MCP_OAUTH=off` in `/etc/garmin-mcp/garmin-mcp.env`). With it on,
the service logs `OAuth sign-in enabled` at startup. (Version 0.2.0 served
them only while `GARMIN_MCP_PUBLIC_URL` was set, and answered 401 otherwise,
which looked like the documents were behind the login.)
- **Claude Code says "Refusing to send credentials to non-https token
endpoint".** It will not do OAuth against a plain-HTTP address other than
`localhost`. Use the bearer token header on the LAN, or put HTTPS in front
of the server; see [Connecting clients](#connecting-clients).
- **The page says "Not set up yet" / no owner password.** Run
`sudo garmin-mcp set-password` on the server, then start connecting again
from the client.
- **The page says "Too many attempts".** Five wrong passwords from one address
lock that address out until 15 minutes after the first of them (see
[Security notes](#security-notes)). Wait, or restart the service, which
clears the counters. If it happens although you did not mistype, either
someone else is guessing and the overall limit is reached, or other visitors
are counted under your address because the proxy does not pass
`X-Forwarded-For` or is not listed in `GARMIN_MCP_TRUSTED_PROXIES`. The
log shows which.
- **The page says "Request expired".** The approval page was open for more
than 5 minutes, was already used, or the service was restarted in between.
Start connecting again from the client.
- **The page says "Request refused: this form must be submitted from its own
page".** The browser's `Origin` header did not match the address the server
thinks it has (the log line `Approval form refused` shows both). Without
`GARMIN_MCP_PUBLIC_URL`, that address is the `Host` header, and a reverse
proxy may replace it with the backend address it was configured with (nginx
does unless told `proxy_set_header Host $host;`). Set
`GARMIN_MCP_PUBLIC_URL`, or have the proxy pass `Host` (or
`X-Forwarded-Host`) through. Versions before 0.2.2 also refused the form in
that situation from browsers that vouched for it themselves; upgrade.
- **The client fails before any page opens, or the browser goes to the wrong
host.** The discovery documents name a different address than the one the
client uses. Compare the output of the `curl` commands in step 4 with the
URL you gave the client: scheme, host name and path must be identical. With
`GARMIN_MCP_PUBLIC_URL` set, the documents use that value, so fix the
value. Without it they use the `Host` header and the scheme of the request:
if they say `http` behind a TLS proxy, the proxy is not sending
`X-Forwarded-Proto` or is not listed in `GARMIN_MCP_TRUSTED_PROXIES`; if
the host is wrong, the proxy is rewriting `Host`. Setting
`GARMIN_MCP_PUBLIC_URL` settles both.
- **The service does not start after the change.** `journalctl -u garmin-mcp -e`
shows a line starting with `configuration error` that names the problem,
usually a `GARMIN_MCP_PUBLIC_URL` with a path, or `http` instead of `https`.
- **The proxy serves the server under a sub-path** (for example
`https://example.com/garmin/`). That cannot work: the discovery documents
must be at `/.well-known/...` of the host, and a `GARMIN_MCP_PUBLIC_URL`
with a path is refused. Give the server a host name of its own.
- **`/mcp` answers 421 "Invalid Host header".** `GARMIN_MCP_ALLOWED_HOSTS` is
set and lacks the public name. Add it without a port
(`garmin.example.com`); the `host:*` form does not match a name that
arrives without a port.
- **A client that worked asks to sign in again.** It was not used for 30
days, it was revoked, the same client was approved again elsewhere, one of
its refresh tokens was replayed (see [Token lifetimes](#token-lifetimes)),
or `/var/lib/garmin-mcp/oauth.db` was removed.
- **Registering a client fails with "too many pending registrations".**
Someone is flooding `/register`. Registrations made in the last 15 minutes
are never dropped to make room, so sign-ins already in progress finish, but
new clients have to wait. Rate-limit `/register` in the reverse proxy if it
keeps happening.
- **Who tried?** Failed and refused attempts on the approval page are logged
with the client address:
`journalctl -u garmin-mcp | grep -i 'login attempt'`.
## Security notes
For the home network only:
- **Plain HTTP.** Requests, responses, the bearer token and, if you sign in
with OAuth there, the owner password cross the LAN unencrypted. That is
acceptable on a home network you trust and nowhere else. Do not forward
port 8765 to the internet.
- **Nothing is open by default.** The MCP endpoint needs the bearer token or
an OAuth sign-in. Only with `GARMIN_MCP_OAUTH=off` *and* no
`GARMIN_MCP_AUTH_TOKEN` can anyone who reaches the port read the account's
health data; the service logs a warning at startup in that state.
- **The OAuth endpoints are there too.** Anyone on the network can reach the
registration endpoint and the approval page. Neither grants anything
without the owner password, and until one is set nobody can sign in at all.
With public access:
- **What protects the data.** The owner password and TLS, nothing else. Anyone
on the internet can reach the approval page, and whoever knows the password
can authorize a client and read the account's health data. Choose a long
password that is used nowhere else (a generated one from a password manager;
the minimum of 12 characters is a floor, not a target), and not the Garmin
password. It is stored only as an scrypt hash.
- **TLS is not optional.** The password and the tokens travel in requests to
the server. Expose the server only through the TLS proxy or tunnel, never by
forwarding port 8765, and bind it to `127.0.0.1` when the proxy is on the
same machine. `GARMIN_MCP_PUBLIC_URL` must be `https` for the same reason.
- **Password guessing is rate limited:** 5 failed attempts per client address
(per /64 network for IPv6) and 100 overall per 15 minutes; after that the
page refuses further tries until the oldest ones age out. The counters are
kept in memory, so a restart resets them. Form posts that a browser marks as
coming from another site are refused outright and do not count. The
per-address limit depends on the real client address, which
comes from `X-Forwarded-For` and is believed only from the addresses in
`GARMIN_MCP_TRUSTED_PROXIES`. With a proxy on another machine that is not
listed there, all visitors share one address and one limit. The overall
limit also means that someone who keeps guessing can keep you from approving
new clients for a while; clients that are already authorized are not
affected. The rate limits slow guessing down; they do not make a weak
password safe.
- **Registration is open.** Any OAuth client on the internet can register
itself at `/register`; MCP clients depend on that. Registering grants
nothing: every authorization needs the owner password. What a client may
register is restricted: redirect targets must be `https`, `http` on
localhost, or an app-specific scheme, without a user-info part, and names
and sizes are capped. Registrations that were never approved are capped at
2000 and dropped after a day. The limits bound what a flood can cost; they
do not stop one, so put a request rate limit on the reverse proxy if the
server attracts that kind of attention. The service is also capped at 1 GiB
of memory (`MemoryMax=` in the unit).
- **Phishing is the realistic attack.** Nobody can get in without the
password, so an attacker's best move is to get you to type it for them: by
starting a sign-in from their own client and sending you the approval link.
The page cannot tell who started a request. Treat any approval link you did
not just trigger yourself as hostile.
- **What an anonymous visitor can reach:** `/healthz` (status and version), the
two discovery documents, `/register`, `/authorize` and the approval page
(useless without the password), `/token` and `/revoke` (useless without a
valid code or token). `/mcp` answers 401 with a pointer to the discovery
document.
- **The static bearer token still works,** on the public URL too, and never
expires. It is as good as the owner password for reading the data, so keep
it as secret, or comment out `GARMIN_MCP_AUTH_TOKEN` and restart to make
OAuth the only way in. With a public URL set, the service refuses to start
if the token is shorter than 32 characters; the one the installer generated
is 64.
- **Tokens.** OAuth tokens are stored as hashes and are only valid for this
server's endpoint. `sudo garmin-mcp revoke` ends a client's access at once.
Client secrets of clients that register with one are stored as they are,
because the server has to compare them; they are worthless without the
owner's approval.
In both cases:
- **`/healthz` is always open** and returns only a status and the version.
- **Bind address and firewall.** The default `0.0.0.0` listens on every IPv4
interface. Set `GARMIN_MCP_HOST` to one LAN address, or to `127.0.0.1` behind
a local proxy, to narrow that, and restrict the port to the clients that
need it in the machine's firewall.
- **Host allowlist.** `GARMIN_MCP_ALLOWED_HOSTS` rejects requests to the MCP
endpoint whose `Host` header is not listed, which protects against DNS
rebinding from a browser on the LAN. List every name and address that
clients use in the URL, for example
`192.168.1.10:*,garmin.lan:*,garmin.example.com`. With the allowlist on,
requests that carry an `Origin` header (browsers send one) are rejected too.
- **Read-only.** The tools only read from Garmin Connect. None of them changes
anything in the account.
- **Secrets on disk.** The env file (bearer token, optionally the Garmin
password) is readable by root and the `garmin-mcp` group only. The Garmin
token file grants access to the Garmin account, and `oauth.db` holds the
password hash, the token hashes and client registrations; both are readable
by the `garmin-mcp` user only.
- **Sandbox.** The service runs as an unprivileged user with no capabilities,
a read-only view of the file system apart from `/var/lib/garmin-mcp`, no
access to `/home`, and a system-call filter. The settings are in
`deploy/garmin-mcp.service`; `systemd-analyze security garmin-mcp` shows the
result.
## Operations
```sh
systemctl status garmin-mcp # state and the last log lines
sudo systemctl restart garmin-mcp # apply a config change
journalctl -u garmin-mcp -f # follow the log
journalctl -u garmin-mcp -e # jump to the end of the log
sudo garmin-mcp check # do the Garmin tokens work?
sudo garmin-mcp clients # which OAuth clients are authorized?
sudo garmin-mcp revoke <client_id> # sign one of them out (or: revoke --all)
sudo garmin-mcp set-password # set or change the owner password
```
`sudo garmin-mcp` runs each command as the `garmin-mcp` user with the env file
and the state directory of the service, so the files it writes keep the right
owner. Only `login` restarts the service; the OAuth commands take effect on
the running service at once.
The unit restarts the service 15 seconds after a crash, indefinitely. A service
that keeps restarting usually has a bad value in the env file; the log shows
the error.
All state is in `/var/lib/garmin-mcp`. To back it up, copy the whole directory:
`oauth.db` alone is incomplete without its `-wal` file. Losing the directory
costs a new Garmin login, a new owner password and approving the clients
again.
### Upgrade
```sh
git pull
sudo ./install.sh
```
Re-running the installer rebuilds the package in the virtualenv, replaces the
unit and the wrapper, and restarts the service. It keeps the env file, the
Garmin tokens and the OAuth state, so authorized clients stay signed in.
Compare your env file with `/etc/garmin-mcp/garmin-mcp.env.example` to see
settings that a new version added.
Upgrading from 0.1.0 needs nothing else. The bearer token keeps working as
before. OAuth sign-in is on from the first start, and unusable until you set
an owner password. One difference: a server that ran without
`GARMIN_MCP_AUTH_TOKEN` was open to the whole network and is now closed; set
`GARMIN_MCP_OAUTH=off` to get the open server back.
By default the installer only upgrades dependencies that no longer satisfy
`pyproject.toml`. When Garmin has changed its API and a newer `garminconnect`
fixes it, pull in the newest allowed versions of all dependencies with:
```sh
sudo ./install.sh --upgrade-deps
```
If the fix is in a release outside the pinned range (`garminconnect>=0.3.20,<0.4`),
change the pin in `pyproject.toml` first.
The installer builds the virtualenv from `/usr/bin/python3`. Set `PYTHON` to
use another interpreter (`sudo PYTHON=/path/to/python3 ./install.sh`).
### Uninstall
```sh
sudo ./uninstall.sh # remove the service, /opt/garmin-mcp and the wrapper
sudo ./uninstall.sh --purge # also remove the config, the state and the garmin-mcp user
```
Without `--purge` the config stays in `/etc/garmin-mcp` and the state (Garmin
tokens, OAuth clients, owner password) in `/var/lib/garmin-mcp`, and a later
install picks them up again. Removing the server does not remove a reverse
proxy you set up in front of it.
## Development
```sh
uv sync # create .venv with the dev dependencies
uv run pytest # tests
uv run ruff check # lint
uv run garmin-mcp login # one-time Garmin login; tokens go to ~/.garminconnect
uv run garmin-mcp serve # serve on http://0.0.0.0:8765/mcp
```
Outside systemd the settings come from the shell environment, for example
`GARMIN_MCP_HOST=127.0.0.1 GARMIN_MCP_PORT=8766 uv run garmin-mcp serve`.
To try the OAuth side locally, no TLS and no further settings are needed:
```sh
uv run garmin-mcp set-password # stored in ~/.local/state/garmin-mcp/oauth.db
uv run garmin-mcp serve # sign-in at http://localhost:8765/mcp
```
Code layout:
- `src/garmin_mcp/config.py` reads the settings from the environment.
- `src/garmin_mcp/session.py` holds the single Garmin session: login, token
reuse, the login cooldown, and the mapping of Garmin errors to tool errors.
- `src/garmin_mcp/server.py` builds the MCP server and the HTTP app (bearer
token check, OAuth wiring, `/healthz`).
- `src/garmin_mcp/oauth.py` is the OAuth authorization server behind the MCP
SDK's endpoints: the SQLite store, codes and tokens, and the owner password.
`src/garmin_mcp/login.py` is the approval page and its rate limiter.
- `src/garmin_mcp/cli.py` is the command line: `serve`, `login`, `check`,
`set-password`, `clients`, `revoke`.
- `src/garmin_mcp/tools/` contains the tools, one module per area. Each module
exposes `register(mcp, garmin)` and is listed in `MODULES` in
`tools/__init__.py`. The conventions for writing a tool (date arguments,
calling Garmin only through `garmin.call(...)`, returning `dump(payload)`)
are described at the top of `src/garmin_mcp/tools/_common.py`.
- `deploy/`, `install.sh` and `uninstall.sh` are the systemd deployment.
## Tools
59 tools, all read-only. Dates are `YYYY-MM-DD`, `today`, `yesterday`,
`tomorrow`, `<N>d` (N days ago) or `+<N>d` (N days ahead), in the Garmin
account's timezone. Results are compact JSON with nulls stripped, and bulky
time series are left out unless a tool parameter asks for them. Each tool's
full description and parameters are served to clients through `tools/list`.
### Profile, devices, gear and achievements
| Tool | What it covers |
| --- | --- |
| `get_user_profile` | Who the Garmin account belongs to |
| `get_devices` | List the Garmin devices registered to the account (watches, bike computers, scales) with model, firmware version and device id |
| `get_device_last_used` | The device that most recently synced with Garmin Connect and when |
| `list_gear` | The user's gear (shoes, bikes, ...) |
| `get_gear_activities` | Usage of one piece of gear |
| `get_personal_records` | The user's personal records as Garmin tracks them |
| `get_goals` | The goals the user set up on Garmin Connect's Goals page |
| `get_recent_badges` | The badges the user earned most recently in Garmin Connect |
### Activities
| Tool | What it covers |
| --- | --- |
| `list_activities` | List recorded activities (workouts), newest first, as compact summaries |
| `get_activity` | Full summary of one activity |
| `get_activity_splits` | Per-lap breakdown of one activity |
| `get_activity_zones` | Time spent in each heart-rate (or power) zone during one activity |
| `get_activity_weather` | Weather Garmin recorded for one activity from the nearest station |
| `get_activity_details` | Time series of one activity, downsampled |
| `get_activity_exercise_sets` | Sets of a strength-training activity in order |
| `get_activity_gear` | Gear (shoes, bike, ...) linked to one activity |
| `get_activity_types` | Garmin's activity type keys as a tree |
| `get_progress_summary` | Totals for a date range |
### Daily health and wellness
| Tool | What it covers |
| --- | --- |
| `get_daily_summary` | One day's headline wellness numbers in a single call |
| `get_daily_summaries` | Compare days side by side |
| `get_sleep` | The night of sleep that ended on the given date |
| `get_sleep_trend` | Sleep night by night over a date range |
| `get_heart_rate` | Heart rate for one day |
| `get_resting_heart_rate` | Resting heart rate per day over a date range, with the average, lowest and highest for the period |
| `get_hrv` | Overnight heart rate variability (HRV), one row per night |
| `get_stress` | Stress for one day |
| `get_body_battery` | Body Battery (Garmin's energy-reserve score) per day |
| `get_body_battery_events` | What moved Body Battery on one day |
| `get_respiration` | Breathing rate for one day |
| `get_spo2` | Blood oxygen saturation (pulse ox, SpO2) for one day |
| `get_steps` | Step count per day over a date range, with each day's step goal and distance |
| `get_intensity_minutes` | Intensity minutes (moderate and vigorous activity time) |
| `get_floors` | Floors climbed and descended per day over a date range, with the daily floors goal |
| `get_hydration` | Fluid intake logged for one day against the daily hydration goal, with Garmin's estimated sweat loss |
### Training and performance metrics
| Tool | What it covers |
| --- | --- |
| `get_training_readiness` | Training Readiness |
| `get_training_status` | Training status as of a date |
| `get_vo2max` | VO2 max |
| `get_race_predictions` | Garmin's predicted race times for 5K, 10K, half marathon and marathon |
| `get_endurance_score` | Endurance Score |
| `get_hill_score` | Hill Score |
| `get_fitness_age` | Fitness Age as of a date |
| `get_lactate_threshold` | Running lactate threshold |
| `get_cycling_ftp` | Cycling Functional Threshold Power (FTP) |
| `get_heart_rate_zones` | The configured heart rate zones for each sport profile |
| `get_power_zones` | The configured power zones for every sport that has them (cycling, running) |
| `get_power_curve` | Power curve |
### Body
| Tool | What it covers |
| --- | --- |
| `get_body_composition` | Weigh-ins over a date range |
| `get_latest_weight` | The most recent weigh-in on or before a date |
| `get_blood_pressure` | Blood pressure readings over a date range |
### Workouts, calendar and training plans
| Tool | What it covers |
| --- | --- |
| `list_workouts` | List the saved workouts in the user's workout library (structured workout templates, not recorded activities) as compact summaries |
| `get_workout` | Full structure of one saved workout |
| `get_training_calendar` | What is on the Garmin Connect calendar between two dates |
| `get_scheduled_workouts` | Workouts scheduled between two dates |
| `get_next_scheduled_workout` | The next workout on the calendar (today or later, within this and next month) |
| `get_upcoming_events` | Upcoming races and goal events on the user's calendar (name, date, event type, location, distance / time goal), soonest first, plus the primary ("A race") event Garmin bases training guidance on |
| `list_training_plans` | The user's Garmin training plans (Garmin Coach and other plans they have joined), each with its trainingPlanId, name, category (`FBT_ADAPTIVE` marks an adaptive Garmin Coach plan), sport/type, level, status and start/end dates |
| `get_training_plan` | Details of one training plan |
| `list_courses` | The user's saved courses (routes to follow on a device) |
| `get_course` | One saved course |
## License
[0BSD](LICENSE): do whatever you like with it, no attribution required, no warranty.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo recent community issues