@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 +196 -184
- package/build/auth-tools.js +1 -1
- package/build/auth.js +155 -44
- package/build/config.js +20 -1
- package/build/index.js +9 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,184 +1,196 @@
|
|
|
1
|
-
# Google Health (Fitbit) MCP Connector for AI
|
|
2
|
-
|
|
3
|
-
[](https://github.com/TheDigitalNinja/mcp-fitbit/actions/workflows/ci.yml)
|
|
4
|
-
[](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
|
-
-
|
|
28
|
-
-
|
|
29
|
-
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
Google Health
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
- `https://www.googleapis.com/auth/googlehealth.
|
|
49
|
-
- `https://www.googleapis.com/auth/googlehealth.
|
|
50
|
-
- `https://www.googleapis.com/auth/googlehealth.
|
|
51
|
-
- `https://www.googleapis.com/auth/googlehealth.
|
|
52
|
-
- `https://www.googleapis.com/auth/googlehealth.
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
> **
|
|
56
|
-
>
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
npm
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
"
|
|
88
|
-
"
|
|
89
|
-
|
|
90
|
-
"
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
-
|
|
101
|
-
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
|
129
|
-
|
|
130
|
-
| `
|
|
131
|
-
| `
|
|
132
|
-
| `
|
|
133
|
-
| `
|
|
134
|
-
| `
|
|
135
|
-
| `
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
`
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
1
|
+
# Google Health (Fitbit) MCP Connector for AI
|
|
2
|
+
|
|
3
|
+
[](https://github.com/TheDigitalNinja/mcp-fitbit/actions/workflows/ci.yml)
|
|
4
|
+
[](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).
|
package/build/auth-tools.js
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
45
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
//
|
|
115
|
-
return new Date(data.expires_at).getTime() -
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
129
|
-
tokenData = null;
|
|
173
|
+
forgetTokenInMemory();
|
|
130
174
|
return;
|
|
131
175
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
refreshedData.refresh_token
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
res.send(
|
|
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
|
-
|
|
304
|
-
|
|
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(
|
|
463
|
+
console.error(`Auth initialized. Token file: ${TOKEN_FILE_PATH}`);
|
|
351
464
|
try {
|
|
352
|
-
|
|
353
|
-
if (tokenData
|
|
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
|
-
|
|
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.
|
|
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 (
|
|
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
|
-
|
|
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) {
|