@einlogic/mcp-fitbit 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,184 +1,196 @@
1
- # Google Health (Fitbit) MCP Connector for AI
2
-
3
- [![CI](https://github.com/TheDigitalNinja/mcp-fitbit/actions/workflows/ci.yml/badge.svg)](https://github.com/TheDigitalNinja/mcp-fitbit/actions/workflows/ci.yml)
4
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
-
6
- > Connect AI assistants to your Fitbit / Pixel Watch health data through the **Google Health API**
7
-
8
- The legacy Fitbit Web API was shut down on **2 November 2026**. Google replaced it with the
9
- [Google Health API](https://developers.google.com/health), which uses Google OAuth 2.0. This server now talks to
10
- the Google Health API and returns its raw JSON.
11
-
12
- ## What it does
13
-
14
- 🏃 **Exercise & Activities** - Workout sessions and daily activity roll-ups
15
- 😴 **Sleep Analysis** - Sleep sessions
16
- ⚖️ **Weight Tracking** - Weight data points
17
- ❤️ **Heart Rate Data** - Samples, daily roll-ups and HRV
18
- 🍎 **Nutrition Logs** - Food log, calories, macros and water
19
- 👤 **Profile & Devices** - Profile, settings, identity and paired trackers
20
-
21
- *Ask your AI things like: "Show me my sleep patterns this week" or "What's my step count trend this month?"*
22
-
23
- ## ⚠️ Breaking changes from the Fitbit version
24
-
25
- - You must create a **Google Cloud** OAuth client (see below) and re-authorize. Old Fitbit tokens cannot be reused.
26
- - Credentials are now `GOOGLE_HEALTH_CLIENT_ID` / `GOOGLE_HEALTH_CLIENT_SECRET`.- The token file is now `.google-health-token.json` (the old `.fitbit-token.json` is ignored and can be deleted).
27
- - Responses are the Google Health API's JSON, not Fitbit's. Fitbit-style `period` (`1d`, `7d`, ...) parameters are gone: tools take explicit start/end times, as the API does.
28
- - `get_activity_goals` was removed: the Google Health API has no goals endpoint.
29
- - New tools: `get_identity`, `get_settings`, `list_paired_devices`, `list_data_points`, `reconcile_data_points`.
30
-
31
- ## Setup
32
-
33
- ### 1. Link your account to Google Health
34
-
35
- In the **Google Health mobile app**, tap **Sign in with Google** and choose the Google Account to use (this creates a
36
- Google Health profile or migrates your existing Fitbit account). Without this, the API returns `ACCOUNT_NOT_LINKED`.
37
-
38
- ### 2. Create Google OAuth credentials
39
-
40
- 1. In the [Google Cloud Console](https://console.cloud.google.com/), create (or pick) a project.
41
- 2. [Enable the Google Health API](https://console.developers.google.com/apis/library/health.googleapis.com).
42
- 3. Create an **OAuth client ID** of type **Web application** and add the authorized redirect URI
43
- `http://localhost:3000/callback`. Note the **Client ID** and **Client secret**.
44
- 4. On the [OAuth consent screen / Audience](https://console.developers.google.com/auth/audience) page, keep the user
45
- type **External** and add your Google Account under **Test users**.
46
- 5. On the [Data Access](https://console.developers.google.com/auth/scopes) page, add these scopes (all read-only):
47
- - `https://www.googleapis.com/auth/googlehealth.activity_and_fitness.readonly`
48
- - `https://www.googleapis.com/auth/googlehealth.health_metrics_and_measurements.readonly`
49
- - `https://www.googleapis.com/auth/googlehealth.sleep.readonly`
50
- - `https://www.googleapis.com/auth/googlehealth.nutrition.readonly`
51
- - `https://www.googleapis.com/auth/googlehealth.profile.readonly`
52
- - `https://www.googleapis.com/auth/googlehealth.settings.readonly`
53
-
54
- > **Refresh tokens in Testing mode expire after 7 days.** For a personal tool, set the app's publishing status to
55
- > **In production** (an unverified app is limited to 100 users and shows a warning screen at consent, which is fine
56
- > for your own account). Verification is only required beyond that.
57
-
58
- ### 3. Install and configure
59
-
60
- **From npm:**
61
- ```bash
62
- npm install -g mcp-fitbit
63
- ```
64
-
65
- **From source:**
66
- ```bash
67
- git clone https://github.com/TheDigitalNinja/mcp-fitbit
68
- cd mcp-fitbit
69
- npm install
70
- npm run build
71
- ```
72
-
73
- Create a `.env` file in the package directory (the server prints the expected path if it is missing):
74
- ```bash
75
- GOOGLE_HEALTH_CLIENT_ID=your_client_id_here
76
- GOOGLE_HEALTH_CLIENT_SECRET=your_client_secret_here
77
- ```
78
-
79
- ### 4. Claude Desktop
80
-
81
- Add to `claude_desktop_config.json`:
82
- ```json
83
- {
84
- "mcpServers": {
85
- "google-health": {
86
- "command": "mcp-fitbit",
87
- "args": [],
88
- "env": {
89
- "GOOGLE_HEALTH_CLIENT_ID": "your_client_id_here",
90
- "GOOGLE_HEALTH_CLIENT_SECRET": "your_client_secret_here"
91
- }
92
- }
93
- }
94
- }
95
- ```
96
- For a local build use `"command": "node", "args": ["C:\\path\\to\\mcp-fitbit\\build\\index.js"]`.
97
-
98
- Config file locations:
99
- - Windows: `%AppData%\Claude\claude_desktop_config.json`
100
- - macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
101
- - Linux: `~/.config/Claude/claude_desktop_config.json`
102
-
103
- ### First Run Authorization
104
-
105
- 1. The server opens your browser to `http://localhost:3000/auth`
106
- 2. Sign in to Google and grant the requested Google Health permissions
107
- 3. The server verifies your Google Health link (`/v4/users/me/identity`) and saves the token
108
- 4. Your AI can now access your data. Access tokens refresh automatically.
109
-
110
- ## Available Tools
111
-
112
- Time parameters follow the API: ranges are **start inclusive, end exclusive**. Samples (`get_weight`, `get_heart_rate`)
113
- use RFC 3339 timestamps (e.g. `2026-03-04T00:00:00Z`); sessions use civil time (`YYYY-MM-DD` or `YYYY-MM-DDTHH:MM:SS`).
114
- List tools accept `pageSize` and `pageToken` (use `nextPageToken` from the response).
115
-
116
- | Tool | Google Health API | Parameters |
117
- |------|-------------------|------------|
118
- | `get_weight` | `weight` data points | `startTime`, optional `endTime` (RFC 3339) |
119
- | `get_sleep_by_date_range` | `sleep` data points (max 25/page) | `startTime`, optional `endTime` (civil) |
120
- | `get_exercises` | `exercise` data points (max 25/page) | `startTime`, optional `endTime` (civil) |
121
- | `get_daily_activity_summary` | daily roll-ups of steps, distance, total calories, active zone minutes, floors | `date` |
122
- | `get_activity_timeseries` | `dailyRollUp` (max 90 days; 14 for calories/active minutes) | `dataType`, `startDate`, `endDate` |
123
- | `get_azm_timeseries` | `dailyRollUp` of `active-zone-minutes` | `startDate`, `endDate` |
124
- | `get_heart_rate` | `heart-rate` samples | `startTime`, optional `endTime` (RFC 3339) |
125
- | `get_heart_rate_by_date_range` | `dailyRollUp` of `heart-rate` (max 14 days) | `startDate`, `endDate` |
126
- | `get_hrv` / `get_hrv_by_date_range` | `daily-heart-rate-variability` | `date` / `startDate`, `endDate` |
127
- | `get_food_log` | `nutrition-log` data points | `startTime`, optional `endTime` (civil) |
128
- | `get_nutrition` / `get_nutrition_by_date_range` | `dailyRollUp` of `nutrition-log` / `hydration-log` | `dataType`, `date` / `startDate`, `endDate` |
129
- | `get_profile` | `users/me/profile` | none |
130
- | `get_identity` | `users/me/identity` | none |
131
- | `get_settings` | `users/me/settings` | none |
132
- | `list_paired_devices` | `users/me/pairedDevices` | none |
133
- | `reauthenticate` | not an API call: deletes the local tokens (memory + `.google-health-token.json`) and opens a new Google sign-in; new tokens are saved on approval, no restart needed. Cancels a pending sign-in. Confirm with `get_identity` | none |
134
- | `list_data_points` | any of the 38 data types | `dataType`, optional raw `filter` |
135
- | `reconcile_data_points` | `dataPoints:reconcile` for any data type | `dataType`, optional `filter`, `dataSourceFamily` |
136
-
137
- **Activity data types:** `steps`, `distance`, `total-calories`, `active-energy-burned`, `floors`, `active-minutes`
138
-
139
- See the [data types reference](https://developers.google.com/health/data-types) for everything `list_data_points` can query.
140
-
141
- ## Fitbit-compatible output mode
142
-
143
- Set `GOOGLE_HEALTH_OUTPUT_MODE=fitbit` (in the Claude Desktop `env` block or `.env`) to make the overlapping tools behave
144
- like the old Fitbit Web API server, so prompts and projects written against it keep working. The default (`google`) mode
145
- is unchanged. In `fitbit` mode these tools are replaced by Fitbit-shaped versions (same names, `period` parameters and
146
- **inclusive** end dates); everything else (`get_profile`, `get_exercises`, `get_identity`, `get_settings`,
147
- `list_paired_devices`, `list_data_points`, `reconcile_data_points`) stays a Google proxy.
148
-
149
- | Tool | Fitbit-style response | Notes |
150
- |------|----------------------|-------|
151
- | `get_weight` (`period`, `date?`) | `{ weight: [{ date, time, logId, weight }] }` | Pounds (follows the account unit). Duplicate weigh-ins from several sources are collapsed to one. No `bmi`/`fat`. |
152
- | `get_sleep_by_date_range` | `{ sleep: [{ dateOfSleep, minutesAsleep, timeInBed, levels.summary.{deep,light,rem,wake} }] }` | Max 100 days. No sleep score or efficiency (the Fitbit API had neither). |
153
- | `get_daily_activity_summary` | `{ activities: [], goals: {}, summary: { caloriesOut, steps, distances, floors, *ActiveMinutes, restingHeartRate, heartRateZones } }` | Miles. Exercise sessions and goals are not included. |
154
- | `get_heart_rate` (`period`) / `get_heart_rate_by_date_range` | `{ 'activities-heart': [{ dateTime, value: { restingHeartRate, heartRateZones } }] }` | Zones: Out of Range / Fat Burn / Cardio / Peak with `min`, `max`, `minutes`, `caloriesOut`. |
155
- | `get_hrv` / `get_hrv_by_date_range` | `{ hrv: [{ dateTime, value: { dailyRmssd, deepRmssd } }] }` | Milliseconds; max 30 days. |
156
- | `get_activity_timeseries` | `{ 'activities-<resource>': [{ dateTime, value: '<string>' }] }` | `steps`, `distance`, `calories`, `activityCalories` (+ `tracker/` variants). `caloriesBMR` is not available. |
157
- | `get_azm_timeseries` | `{ 'activities-active-zone-minutes': [...] }` | Total + fat burn / cardio / peak. |
158
- | `get_food_log` / `get_nutrition` / `get_nutrition_by_date_range` | Fitbit food-log summary / `foods-log-caloriesIn`, `foods-log-water` | No individual food entries or goals. |
159
-
160
- How the numbers are mapped (verified against a real day of Fitbit-era data):
161
-
162
- - **Heart-rate zones:** Fitbit's Out of Range / Fat Burn / Cardio / Peak are Google's Light / Moderate / Vigorous / Peak.
163
- Fat Burn minutes equal Active Zone Minutes fat burn (1x); Cardio and Peak minutes are Active Zone Minutes divided by 2
164
- (they count double). Out of Range comes from time-in-zone. Calories per zone come from `calories-in-heart-rate-zone`
165
- and the bpm bounds from the account's own `daily-heart-rate-zones`.
166
- - **Sleep:** stage minutes come straight from Google's stage summary, which matched Fitbit's deep/REM numbers exactly.
167
- The wake date (`dateOfSleep`) is derived from the sleep end time and its UTC offset.
168
- - **Units and time zone:** weight, distance and water follow the account's Google Health unit settings; days are
169
- interpreted in the account time zone (override with `COMPAT_TIME_ZONE`).
170
- - **Known small differences:** Google re-derives some values, so a historical day can differ slightly from what Fitbit
171
- returned at the time (e.g. a few steps or 1 bpm of resting heart rate). Fields that could not be provided are listed in a
172
- top-level `_compat.notes` array when relevant.
173
-
174
- ## Development
175
-
176
- ```bash
177
- npm run build # Compile TypeScript
178
- npm test # Run tests
179
- npm run lint # Check code quality
180
- npm run format # Fix formatting
181
- npm run dev # Run with MCP inspector
182
- ```
183
-
184
- **Architecture:** See [CLAUDE.md](CLAUDE.md) and [MIGRATION_PLAN.md](MIGRATION_PLAN.md).
1
+ # Google Health (Fitbit) MCP Connector for AI
2
+
3
+ [![CI](https://github.com/TheDigitalNinja/mcp-fitbit/actions/workflows/ci.yml/badge.svg)](https://github.com/TheDigitalNinja/mcp-fitbit/actions/workflows/ci.yml)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+
6
+ > Connect AI assistants to your Fitbit / Pixel Watch health data through the **Google Health API**
7
+
8
+ The legacy Fitbit Web API was shut down on **2 November 2026**. Google replaced it with the
9
+ [Google Health API](https://developers.google.com/health), which uses Google OAuth 2.0. This server now talks to
10
+ the Google Health API and returns its raw JSON.
11
+
12
+ ## What it does
13
+
14
+ 🏃 **Exercise & Activities** - Workout sessions and daily activity roll-ups
15
+ 😴 **Sleep Analysis** - Sleep sessions
16
+ ⚖️ **Weight Tracking** - Weight data points
17
+ ❤️ **Heart Rate Data** - Samples, daily roll-ups and HRV
18
+ 🍎 **Nutrition Logs** - Food log, calories, macros and water
19
+ 👤 **Profile & Devices** - Profile, settings, identity and paired trackers
20
+
21
+ *Ask your AI things like: "Show me my sleep patterns this week" or "What's my step count trend this month?"*
22
+
23
+ ## ⚠️ Breaking changes from the Fitbit version
24
+
25
+ - You must create a **Google Cloud** OAuth client (see below) and re-authorize. Old Fitbit tokens cannot be reused.
26
+ - Credentials are now `GOOGLE_HEALTH_CLIENT_ID` / `GOOGLE_HEALTH_CLIENT_SECRET`.
27
+ - The token is now stored per user at `~/.google-health-mcp/token.json` (see [Token storage](#token-storage)). The old `.fitbit-token.json` and any `.google-health-token.json` inside the package folder are ignored and can be deleted; you will sign in once after upgrading.
28
+ - Responses are the Google Health API's JSON, not Fitbit's. Fitbit-style `period` (`1d`, `7d`, ...) parameters are gone: tools take explicit start/end times, as the API does.
29
+ - `get_activity_goals` was removed: the Google Health API has no goals endpoint.
30
+ - New tools: `get_identity`, `get_settings`, `list_paired_devices`, `list_data_points`, `reconcile_data_points`.
31
+
32
+ ## Setup
33
+
34
+ ### 1. Link your account to Google Health
35
+
36
+ In the **Google Health mobile app**, tap **Sign in with Google** and choose the Google Account to use (this creates a
37
+ Google Health profile or migrates your existing Fitbit account). Without this, the API returns `ACCOUNT_NOT_LINKED`.
38
+
39
+ ### 2. Create Google OAuth credentials
40
+
41
+ 1. In the [Google Cloud Console](https://console.cloud.google.com/), create (or pick) a project.
42
+ 2. [Enable the Google Health API](https://console.developers.google.com/apis/library/health.googleapis.com).
43
+ 3. Create an **OAuth client ID** of type **Web application** and add the authorized redirect URI
44
+ `http://localhost:3000/callback`. Note the **Client ID** and **Client secret**.
45
+ 4. On the [OAuth consent screen / Audience](https://console.developers.google.com/auth/audience) page, keep the user
46
+ type **External** and add your Google Account under **Test users**.
47
+ 5. On the [Data Access](https://console.developers.google.com/auth/scopes) page, add these scopes (all read-only):
48
+ - `https://www.googleapis.com/auth/googlehealth.activity_and_fitness.readonly`
49
+ - `https://www.googleapis.com/auth/googlehealth.health_metrics_and_measurements.readonly`
50
+ - `https://www.googleapis.com/auth/googlehealth.sleep.readonly`
51
+ - `https://www.googleapis.com/auth/googlehealth.nutrition.readonly`
52
+ - `https://www.googleapis.com/auth/googlehealth.profile.readonly`
53
+ - `https://www.googleapis.com/auth/googlehealth.settings.readonly`
54
+
55
+ > **Refresh tokens in Testing mode expire after 7 days.** For a personal tool, set the app's publishing status to
56
+ > **In production** (an unverified app is limited to 100 users and shows a warning screen at consent, which is fine
57
+ > for your own account). Verification is only required beyond that.
58
+
59
+ ### 3. Install and configure
60
+
61
+ **From npm:**
62
+ ```bash
63
+ npm install -g mcp-fitbit
64
+ ```
65
+
66
+ **From source:**
67
+ ```bash
68
+ git clone https://github.com/TheDigitalNinja/mcp-fitbit
69
+ cd mcp-fitbit
70
+ npm install
71
+ npm run build
72
+ ```
73
+
74
+ Create a `.env` file in the package directory (the server prints the expected path if it is missing):
75
+ ```bash
76
+ GOOGLE_HEALTH_CLIENT_ID=your_client_id_here
77
+ GOOGLE_HEALTH_CLIENT_SECRET=your_client_secret_here
78
+ ```
79
+
80
+ ### 4. Claude Desktop
81
+
82
+ Add to `claude_desktop_config.json`:
83
+ ```json
84
+ {
85
+ "mcpServers": {
86
+ "google-health": {
87
+ "command": "mcp-fitbit",
88
+ "args": [],
89
+ "env": {
90
+ "GOOGLE_HEALTH_CLIENT_ID": "your_client_id_here",
91
+ "GOOGLE_HEALTH_CLIENT_SECRET": "your_client_secret_here"
92
+ }
93
+ }
94
+ }
95
+ }
96
+ ```
97
+ For a local build use `"command": "node", "args": ["C:\\path\\to\\mcp-fitbit\\build\\index.js"]`.
98
+
99
+ Config file locations:
100
+ - Windows: `%AppData%\Claude\claude_desktop_config.json`
101
+ - macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
102
+ - Linux: `~/.config/Claude/claude_desktop_config.json`
103
+
104
+ ### First Run Authorization
105
+
106
+ 1. The server opens your browser to `http://localhost:3000/auth`
107
+ 2. Sign in to Google and grant the requested Google Health permissions
108
+ 3. The server verifies your Google Health link (`/v4/users/me/identity`) and saves the token
109
+ 4. Your AI can now access your data. Access tokens refresh automatically.
110
+
111
+ ### Token storage
112
+
113
+ The token is saved to `~/.google-health-mcp/token.json` (on Windows `C:\Users\<you>\.google-health-mcp\token.json`),
114
+ outside the install folder, so it survives upgrades and works when the global npm folder is read-only. New chat
115
+ sessions reuse it and refresh the access token silently; you only sign in again if Google rejects the refresh token
116
+ (for example after the 7-day Testing-mode limit, or if you revoke access).
117
+
118
+ Set `GOOGLE_HEALTH_TOKEN_PATH` (in the `env` block or `.env`) to store it elsewhere. If the token cannot be saved, the
119
+ browser page after sign-in says so, and the server log (stderr) shows the path and the reason. A temporary failure to
120
+ refresh (for example no network) keeps the saved token and does not open a sign-in window.
121
+
122
+ ## Available Tools
123
+
124
+ Time parameters follow the API: ranges are **start inclusive, end exclusive**. Samples (`get_weight`, `get_heart_rate`)
125
+ use RFC 3339 timestamps (e.g. `2026-03-04T00:00:00Z`); sessions use civil time (`YYYY-MM-DD` or `YYYY-MM-DDTHH:MM:SS`).
126
+ List tools accept `pageSize` and `pageToken` (use `nextPageToken` from the response).
127
+
128
+ | Tool | Google Health API | Parameters |
129
+ |------|-------------------|------------|
130
+ | `get_weight` | `weight` data points | `startTime`, optional `endTime` (RFC 3339) |
131
+ | `get_sleep_by_date_range` | `sleep` data points (max 25/page) | `startTime`, optional `endTime` (civil) |
132
+ | `get_exercises` | `exercise` data points (max 25/page) | `startTime`, optional `endTime` (civil) |
133
+ | `get_daily_activity_summary` | daily roll-ups of steps, distance, total calories, active zone minutes, floors | `date` |
134
+ | `get_activity_timeseries` | `dailyRollUp` (max 90 days; 14 for calories/active minutes) | `dataType`, `startDate`, `endDate` |
135
+ | `get_azm_timeseries` | `dailyRollUp` of `active-zone-minutes` | `startDate`, `endDate` |
136
+ | `get_heart_rate` | `heart-rate` samples | `startTime`, optional `endTime` (RFC 3339) |
137
+ | `get_heart_rate_by_date_range` | `dailyRollUp` of `heart-rate` (max 14 days) | `startDate`, `endDate` |
138
+ | `get_hrv` / `get_hrv_by_date_range` | `daily-heart-rate-variability` | `date` / `startDate`, `endDate` |
139
+ | `get_food_log` | `nutrition-log` data points | `startTime`, optional `endTime` (civil) |
140
+ | `get_nutrition` / `get_nutrition_by_date_range` | `dailyRollUp` of `nutrition-log` / `hydration-log` | `dataType`, `date` / `startDate`, `endDate` |
141
+ | `get_profile` | `users/me/profile` | none |
142
+ | `get_identity` | `users/me/identity` | none |
143
+ | `get_settings` | `users/me/settings` | none |
144
+ | `list_paired_devices` | `users/me/pairedDevices` | none |
145
+ | `reauthenticate` | not an API call: deletes the local tokens (memory + the token file, see [Token storage](#token-storage)) and opens a new Google sign-in; new tokens are saved on approval, no restart needed. Cancels a pending sign-in. Confirm with `get_identity` | none |
146
+ | `list_data_points` | any of the 38 data types | `dataType`, optional raw `filter` |
147
+ | `reconcile_data_points` | `dataPoints:reconcile` for any data type | `dataType`, optional `filter`, `dataSourceFamily` |
148
+
149
+ **Activity data types:** `steps`, `distance`, `total-calories`, `active-energy-burned`, `floors`, `active-minutes`
150
+
151
+ See the [data types reference](https://developers.google.com/health/data-types) for everything `list_data_points` can query.
152
+
153
+ ## Fitbit-compatible output mode
154
+
155
+ Set `GOOGLE_HEALTH_OUTPUT_MODE=fitbit` (in the Claude Desktop `env` block or `.env`) to make the overlapping tools behave
156
+ like the old Fitbit Web API server, so prompts and projects written against it keep working. The default (`google`) mode
157
+ is unchanged. In `fitbit` mode these tools are replaced by Fitbit-shaped versions (same names, `period` parameters and
158
+ **inclusive** end dates); everything else (`get_profile`, `get_exercises`, `get_identity`, `get_settings`,
159
+ `list_paired_devices`, `list_data_points`, `reconcile_data_points`) stays a Google proxy.
160
+
161
+ | Tool | Fitbit-style response | Notes |
162
+ |------|----------------------|-------|
163
+ | `get_weight` (`period`, `date?`) | `{ weight: [{ date, time, logId, weight }] }` | Pounds (follows the account unit). Duplicate weigh-ins from several sources are collapsed to one. No `bmi`/`fat`. |
164
+ | `get_sleep_by_date_range` | `{ sleep: [{ dateOfSleep, minutesAsleep, timeInBed, levels.summary.{deep,light,rem,wake} }] }` | Max 100 days. No sleep score or efficiency (the Fitbit API had neither). |
165
+ | `get_daily_activity_summary` | `{ activities: [], goals: {}, summary: { caloriesOut, steps, distances, floors, *ActiveMinutes, restingHeartRate, heartRateZones } }` | Miles. Exercise sessions and goals are not included. |
166
+ | `get_heart_rate` (`period`) / `get_heart_rate_by_date_range` | `{ 'activities-heart': [{ dateTime, value: { restingHeartRate, heartRateZones } }] }` | Zones: Out of Range / Fat Burn / Cardio / Peak with `min`, `max`, `minutes`, `caloriesOut`. |
167
+ | `get_hrv` / `get_hrv_by_date_range` | `{ hrv: [{ dateTime, value: { dailyRmssd, deepRmssd } }] }` | Milliseconds; max 30 days. |
168
+ | `get_activity_timeseries` | `{ 'activities-<resource>': [{ dateTime, value: '<string>' }] }` | `steps`, `distance`, `calories`, `activityCalories` (+ `tracker/` variants). `caloriesBMR` is not available. |
169
+ | `get_azm_timeseries` | `{ 'activities-active-zone-minutes': [...] }` | Total + fat burn / cardio / peak. |
170
+ | `get_food_log` / `get_nutrition` / `get_nutrition_by_date_range` | Fitbit food-log summary / `foods-log-caloriesIn`, `foods-log-water` | No individual food entries or goals. |
171
+
172
+ How the numbers are mapped (verified against a real day of Fitbit-era data):
173
+
174
+ - **Heart-rate zones:** Fitbit's Out of Range / Fat Burn / Cardio / Peak are Google's Light / Moderate / Vigorous / Peak.
175
+ Fat Burn minutes equal Active Zone Minutes fat burn (1x); Cardio and Peak minutes are Active Zone Minutes divided by 2
176
+ (they count double). Out of Range comes from time-in-zone. Calories per zone come from `calories-in-heart-rate-zone`
177
+ and the bpm bounds from the account's own `daily-heart-rate-zones`.
178
+ - **Sleep:** stage minutes come straight from Google's stage summary, which matched Fitbit's deep/REM numbers exactly.
179
+ The wake date (`dateOfSleep`) is derived from the sleep end time and its UTC offset.
180
+ - **Units and time zone:** weight, distance and water follow the account's Google Health unit settings; days are
181
+ interpreted in the account time zone (override with `COMPAT_TIME_ZONE`).
182
+ - **Known small differences:** Google re-derives some values, so a historical day can differ slightly from what Fitbit
183
+ returned at the time (e.g. a few steps or 1 bpm of resting heart rate). Fields that could not be provided are listed in a
184
+ top-level `_compat.notes` array when relevant.
185
+
186
+ ## Development
187
+
188
+ ```bash
189
+ npm run build # Compile TypeScript
190
+ npm test # Run tests
191
+ npm run lint # Check code quality
192
+ npm run format # Fix formatting
193
+ npm run dev # Run with MCP inspector
194
+ ```
195
+
196
+ **Architecture:** See [CLAUDE.md](CLAUDE.md) and [MIGRATION_PLAN.md](MIGRATION_PLAN.md).
@@ -7,7 +7,7 @@ import { registerTool, createSuccessResponse, createErrorResponse, } from './uti
7
7
  export function registerAuthTools(server, reauthenticate) {
8
8
  registerTool(server, {
9
9
  name: 'reauthenticate',
10
- description: "Clear this MCP server's locally stored Google Health tokens (memory and .google-health-token.json) and start a new Google sign-in. A browser window opens for consent; once approved, new tokens are saved and the other tools work again without restarting the server. Use when the token has expired (testing-mode refresh tokens last 7 days), was revoked, or the wrong Google account is connected. Cancels any sign-in that is still pending. Verify afterwards with get_identity.",
10
+ description: "Clear this MCP server's locally stored Google Health tokens (memory and the token file) and start a new Google sign-in. A browser window opens for consent; once approved, new tokens are saved and the other tools work again without restarting the server. Use when the token has expired (testing-mode refresh tokens last 7 days), was revoked, or the wrong Google account is connected. Cancels any sign-in that is still pending. Verify afterwards with get_identity.",
11
11
  parametersSchema: {},
12
12
  handler: async () => {
13
13
  try {
package/build/auth.js CHANGED
@@ -7,7 +7,7 @@ import { AuthorizationCode } from 'simple-oauth2';
7
7
  import { fileURLToPath } from 'url';
8
8
  import fs from 'fs/promises';
9
9
  import { existsSync } from 'fs';
10
- import { GOOGLE_OAUTH_CONFIG, GOOGLE_HEALTH_API_BASE, FILE_PATHS, ERROR_MESSAGES, HTTP_CONFIG, resolveClientCredentials, } from './config.js';
10
+ import { GOOGLE_OAUTH_CONFIG, GOOGLE_HEALTH_API_BASE, FILE_PATHS, ERROR_MESSAGES, HTTP_CONFIG, resolveClientCredentials, resolveTokenPath, } from './config.js';
11
11
  // Determine the directory of the current module (build/auth.js)
12
12
  const currentFilename = fileURLToPath(import.meta.url);
13
13
  const currentDirname = path.dirname(currentFilename);
@@ -41,20 +41,42 @@ let accessToken = null;
41
41
  let tokenData = null;
42
42
  // Holds the temporary HTTP server instance used for the OAuth callback
43
43
  let oauthServer = null;
44
- // --- File paths for token persistence ---
45
- const TOKEN_FILE_PATH = path.resolve(currentDirname, '..', FILE_PATHS.TOKEN_STORAGE);
44
+ // A refresh already running, so parallel callers share one Google request
45
+ let refreshInFlight = null;
46
+ // Refresh token Google rejected with invalid_grant. A copy of it found on disk
47
+ // is ignored, so an unauthenticated session doesn't retry it on every call.
48
+ let rejectedRefreshToken = null;
49
+ // How many times a refresh is tried before giving up for now
50
+ const REFRESH_MAX_ATTEMPTS = 3;
51
+ // --- File path for token persistence (per-user, outside the install dir) ---
52
+ const TOKEN_FILE_PATH = resolveTokenPath();
46
53
  // --- OAuth Client Initialization ---
47
54
  const oauthClient = new AuthorizationCode(googleConfig);
48
55
  /**
49
- * Saves the token data to a file for persistence
56
+ * Saves the token data to a file for persistence. The write is atomic (temp
57
+ * file + rename) so a session starting at the same moment never reads a
58
+ * half-written file.
59
+ * @returns null on success, otherwise a description of why the save failed.
50
60
  */
51
61
  async function saveTokenToFile(data) {
62
+ const tempPath = `${TOKEN_FILE_PATH}.${process.pid}.tmp`;
52
63
  try {
53
- await fs.writeFile(TOKEN_FILE_PATH, JSON.stringify(data, null, 2), 'utf8');
64
+ await fs.mkdir(path.dirname(TOKEN_FILE_PATH), { recursive: true });
65
+ await fs.writeFile(tempPath, JSON.stringify(data, null, 2), {
66
+ encoding: 'utf8',
67
+ mode: 0o600,
68
+ });
69
+ await fs.rename(tempPath, TOKEN_FILE_PATH);
54
70
  console.error(`Token saved to ${TOKEN_FILE_PATH}`);
71
+ return null;
55
72
  }
56
73
  catch (error) {
57
- console.error(`Error saving token to file: ${error}`);
74
+ await fs.unlink(tempPath).catch(() => undefined);
75
+ const reason = error instanceof Error ? error.message : String(error);
76
+ console.error(`Error saving token to ${TOKEN_FILE_PATH}: ${reason}. ` +
77
+ `You will have to sign in again next session. ` +
78
+ `Set ${FILE_PATHS.TOKEN_PATH_ENV} to a writable file path.`);
79
+ return reason;
58
80
  }
59
81
  }
60
82
  /**
@@ -108,45 +130,115 @@ async function verifyAccountLinked(token) {
108
130
  return true;
109
131
  }
110
132
  }
111
- function isExpired(data) {
133
+ function isExpired(data, marginMs = 60_000) {
112
134
  if (!data.expires_at)
113
135
  return false;
114
- // Refresh a minute early to avoid racing the expiry
115
- return new Date(data.expires_at).getTime() - 60_000 < Date.now();
136
+ // By default refresh a minute early to avoid racing the expiry
137
+ return new Date(data.expires_at).getTime() - marginMs < Date.now();
116
138
  }
117
- /**
118
- * Refreshes the access token if it has expired. Google does not return a new
119
- * refresh token on refresh, so the existing one is carried over.
120
- */
121
- async function refreshTokenIfNeeded() {
122
- if (!tokenData || !isExpired(tokenData)) {
139
+ function forgetTokenInMemory() {
140
+ accessToken = null;
141
+ tokenData = null;
142
+ }
143
+ /** The error text plus Google's response body (e.g. {"error":"invalid_grant"}). */
144
+ function describeRefreshError(error) {
145
+ const e = error;
146
+ const payload = e?.data?.payload;
147
+ const body = payload === undefined
148
+ ? ''
149
+ : ` ${typeof payload === 'string' ? payload : JSON.stringify(payload)}`;
150
+ return `${e?.message ?? String(error)}${body}`;
151
+ }
152
+ /** invalid_grant: the refresh token is expired, revoked or otherwise dead. */
153
+ function isInvalidGrant(error) {
154
+ return describeRefreshError(error).includes('invalid_grant');
155
+ }
156
+ /** A client error (4xx, except timeout/rate limit): retrying cannot help. */
157
+ function isNonRetryable(error) {
158
+ const e = error;
159
+ const status = e?.output?.statusCode ?? e?.data?.res?.statusCode;
160
+ return (typeof status === 'number' &&
161
+ status >= 400 &&
162
+ status < 500 &&
163
+ status !== 408 &&
164
+ status !== 429);
165
+ }
166
+ async function refreshAccessToken() {
167
+ const current = tokenData;
168
+ if (!current)
123
169
  return;
124
- }
125
- const refreshToken = tokenData.refresh_token;
170
+ const refreshToken = current.refresh_token;
126
171
  if (!refreshToken) {
127
172
  console.error('Token is expired and no refresh token is available.');
128
- accessToken = null;
129
- tokenData = null;
173
+ forgetTokenInMemory();
130
174
  return;
131
175
  }
132
- console.error('Token is expired. Attempting to refresh...');
133
- try {
134
- const accessTokenObj = oauthClient.createToken(tokenData);
135
- const refreshed = await accessTokenObj.refresh();
136
- const refreshedData = refreshed.token;
137
- if (!refreshedData.refresh_token) {
138
- refreshedData.refresh_token = refreshToken;
176
+ for (let attempt = 1; attempt <= REFRESH_MAX_ATTEMPTS; attempt++) {
177
+ console.error(`Token is expired. Attempting to refresh (attempt ${attempt}/${REFRESH_MAX_ATTEMPTS})...`);
178
+ try {
179
+ const refreshed = await oauthClient.createToken(current).refresh();
180
+ const refreshedData = refreshed.token;
181
+ // Google does not return a refresh token on refresh: keep the existing one
182
+ if (!refreshedData.refresh_token) {
183
+ refreshedData.refresh_token = refreshToken;
184
+ }
185
+ tokenData = refreshedData;
186
+ accessToken = refreshedData.access_token;
187
+ await saveTokenToFile(refreshedData);
188
+ console.error('Token refreshed successfully.');
189
+ return;
190
+ }
191
+ catch (error) {
192
+ const detail = describeRefreshError(error);
193
+ if (isInvalidGrant(error)) {
194
+ // Only this means the refresh token itself is dead and sign-in is needed
195
+ console.error(`Google rejected the refresh token (invalid_grant): ${detail}. Sign-in is required.`);
196
+ rejectedRefreshToken = refreshToken;
197
+ forgetTokenInMemory();
198
+ return;
199
+ }
200
+ console.error(`Token refresh attempt ${attempt} failed: ${detail}`);
201
+ if (isNonRetryable(error)) {
202
+ console.error('Google refused the refresh request. Check GOOGLE_HEALTH_CLIENT_ID / GOOGLE_HEALTH_CLIENT_SECRET match the client that issued the token.');
203
+ break;
204
+ }
205
+ if (attempt < REFRESH_MAX_ATTEMPTS) {
206
+ const delayMs = HTTP_CONFIG.RETRY_BASE_DELAY_MS * 2 ** (attempt - 1);
207
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
208
+ }
139
209
  }
140
- tokenData = refreshedData;
141
- accessToken = refreshedData.access_token;
142
- await saveTokenToFile(refreshedData);
143
- console.error('Token refreshed and saved successfully.');
144
210
  }
145
- catch (refreshError) {
146
- console.error('Failed to refresh token:', refreshError);
147
- accessToken = null;
148
- tokenData = null;
211
+ // The stored token is kept: a network blip must not force a new sign-in
212
+ console.error('Could not refresh the token right now. Keeping it and retrying on the next request.');
213
+ }
214
+ /**
215
+ * Refreshes the access token if it has expired. Parallel callers share a single
216
+ * refresh. Only an invalid_grant response discards the token; any other
217
+ * failure keeps it so a later call can try again.
218
+ */
219
+ function refreshTokenIfNeeded() {
220
+ if (!tokenData || !isExpired(tokenData)) {
221
+ return Promise.resolve();
222
+ }
223
+ refreshInFlight ??= refreshAccessToken().finally(() => {
224
+ refreshInFlight = null;
225
+ });
226
+ return refreshInFlight;
227
+ }
228
+ /**
229
+ * Loads the stored token into memory. Also called when a session has no token,
230
+ * to pick up one that another session has saved since startup.
231
+ */
232
+ async function loadPersistedToken() {
233
+ const stored = await loadTokenFromFile();
234
+ // A refresh may have set a newer token while we were reading the file
235
+ if (tokenData || !stored || !stored.access_token)
236
+ return;
237
+ if (rejectedRefreshToken && stored.refresh_token === rejectedRefreshToken) {
238
+ return;
149
239
  }
240
+ tokenData = stored;
241
+ accessToken = stored.access_token;
150
242
  }
151
243
  // --- Google Authorization Flow ---
152
244
  /**
@@ -229,9 +321,11 @@ export function startAuthorizationFlow() {
229
321
  }
230
322
  accessToken = newToken.access_token;
231
323
  tokenData = newToken;
232
- await saveTokenToFile(newToken);
233
- console.error('Token data has been persisted to file');
234
- res.send('Authorization successful! You can close this window. The MCP Server is now authenticated.');
324
+ rejectedRefreshToken = null;
325
+ const saveError = await saveTokenToFile(newToken);
326
+ res.send(saveError
327
+ ? `Authorization successful for this session, but the token could not be saved (${saveError}). You will have to sign in again next session. Set ${FILE_PATHS.TOKEN_PATH_ENV} to a writable file path.`
328
+ : 'Authorization successful! You can close this window. The MCP Server is now authenticated.');
235
329
  }
236
330
  catch (error) {
237
331
  // Handle errors during token exchange
@@ -300,8 +394,8 @@ async function stopAuthorizationServer() {
300
394
  * @returns true when a token file was deleted, false when there was none.
301
395
  */
302
396
  export async function clearStoredToken() {
303
- accessToken = null;
304
- tokenData = null;
397
+ forgetTokenInMemory();
398
+ rejectedRefreshToken = null;
305
399
  try {
306
400
  await fs.unlink(TOKEN_FILE_PATH);
307
401
  console.error(`Token file deleted: ${TOKEN_FILE_PATH}`);
@@ -335,29 +429,46 @@ export async function reauthenticate() {
335
429
  * @returns The access token string or null if not available.
336
430
  */
337
431
  export async function getAccessToken() {
432
+ if (!tokenData) {
433
+ // Another session may have signed in and saved a token since we started
434
+ await loadPersistedToken();
435
+ }
338
436
  if (!tokenData || !accessToken) {
339
437
  console.error('No valid access token found.');
340
438
  return null;
341
439
  }
342
440
  await refreshTokenIfNeeded();
441
+ if (!tokenData || !accessToken) {
442
+ return null;
443
+ }
444
+ // A temporary refresh failure keeps the token but leaves it expired
445
+ if (isExpired(tokenData, 0)) {
446
+ return null;
447
+ }
343
448
  return accessToken;
344
449
  }
450
+ /**
451
+ * Whether the user has to sign in: there is no stored token, or Google rejected
452
+ * the refresh token. False while a stored token exists but could not be
453
+ * refreshed yet (e.g. offline), which a later request can still recover.
454
+ */
455
+ export function isAuthorizationRequired() {
456
+ return !tokenData;
457
+ }
345
458
  /**
346
459
  * Initializes the authentication module.
347
460
  * Loads persisted token from file storage if available.
348
461
  */
349
462
  export async function initializeAuth() {
350
- console.error('Auth initialized. Checking for persisted token...');
463
+ console.error(`Auth initialized. Token file: ${TOKEN_FILE_PATH}`);
351
464
  try {
352
- tokenData = await loadTokenFromFile();
353
- if (tokenData && tokenData.access_token) {
354
- accessToken = tokenData.access_token;
465
+ await loadPersistedToken();
466
+ if (tokenData) {
355
467
  console.error('Persisted access token loaded successfully.');
356
468
  await refreshTokenIfNeeded();
357
469
  }
358
470
  else {
359
471
  console.error('No valid access token found.');
360
- tokenData = null;
361
472
  }
362
473
  }
363
474
  catch (error) {
package/build/config.js CHANGED
@@ -2,6 +2,8 @@
2
2
  * Centralized configuration for the Google Health MCP server
3
3
  * Contains all constants, API URLs, and shared configurations
4
4
  */
5
+ import os from 'os';
6
+ import path from 'path';
5
7
  // === API Configuration ===
6
8
  export const GOOGLE_HEALTH_API_BASE = 'https://health.googleapis.com/v4';
7
9
  const SCOPE_PREFIX = 'https://www.googleapis.com/auth/googlehealth';
@@ -108,8 +110,25 @@ export const HTTP_CONFIG = {
108
110
  };
109
111
  // === File Paths ===
110
112
  export const FILE_PATHS = {
111
- TOKEN_STORAGE: '.google-health-token.json',
113
+ /** Per-user directory (under the home directory) that holds the token file. */
114
+ TOKEN_DIR: '.google-health-mcp',
115
+ TOKEN_FILE: 'token.json',
116
+ /** Env var that overrides the full token file path. */
117
+ TOKEN_PATH_ENV: 'GOOGLE_HEALTH_TOKEN_PATH',
112
118
  };
119
+ /**
120
+ * Where the OAuth token is persisted. Deliberately NOT inside the package/install
121
+ * directory: that is often read-only (global npm installs) and is wiped on
122
+ * upgrade, which would force a new sign-in every session.
123
+ * Override with GOOGLE_HEALTH_TOKEN_PATH.
124
+ */
125
+ export function resolveTokenPath() {
126
+ const override = process.env[FILE_PATHS.TOKEN_PATH_ENV];
127
+ if (override && override.trim()) {
128
+ return path.resolve(override.trim());
129
+ }
130
+ return path.join(os.homedir(), FILE_PATHS.TOKEN_DIR, FILE_PATHS.TOKEN_FILE);
131
+ }
113
132
  // === Error Messages ===
114
133
  export const ERROR_MESSAGES = {
115
134
  NO_ACCESS_TOKEN: 'No Google Health access token available. Please authorize first.',
package/build/index.js CHANGED
@@ -5,7 +5,7 @@ import dotenv from 'dotenv';
5
5
  import path from 'path';
6
6
  import { fileURLToPath } from 'url';
7
7
  // Import authentication functions
8
- import { initializeAuth, startAuthorizationFlow, getAccessToken, reauthenticate, } from './auth.js';
8
+ import { initializeAuth, startAuthorizationFlow, getAccessToken, isAuthorizationRequired, reauthenticate, } from './auth.js';
9
9
  // Import tool registration function(s)
10
10
  import { registerWeightTool } from './weight.js';
11
11
  import { registerSleepTool } from './sleep.js';
@@ -58,7 +58,7 @@ console.error('✅ Environment variables loaded successfully');
58
58
  // Create the main MCP server instance
59
59
  const server = new McpServer({
60
60
  name: 'google-health',
61
- version: '2.2.0',
61
+ version: '2.3.0',
62
62
  capabilities: {
63
63
  resources: {},
64
64
  tools: {}, // Tools are registered dynamically below
@@ -99,12 +99,17 @@ async function main() {
99
99
  // Check if an access token is available after connection
100
100
  // If not, initiate the OAuth2 authorization flow
101
101
  const token = await getAccessToken();
102
- if (!token) {
102
+ if (token) {
103
+ console.error('Using existing/loaded access token.');
104
+ }
105
+ else if (isAuthorizationRequired()) {
103
106
  console.error('No access token found. Starting Google Health authorization flow...');
104
107
  startAuthorizationFlow(); // Start flow in background, do not await
105
108
  }
106
109
  else {
107
- console.error('Using existing/loaded access token.');
110
+ // A stored token exists but refreshing failed for a temporary reason
111
+ // (e.g. offline). Do not force a new sign-in; the next request retries.
112
+ console.error('A stored token exists but could not be refreshed right now. Not starting sign-in; it will be retried on the next request.');
108
113
  }
109
114
  }
110
115
  catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@einlogic/mcp-fitbit",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "type": "module",
5
5
  "main": "./build/index.js",
6
6
  "bin": {