@einlogic/mcp-fitbit 1.0.1 → 2.1.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 +185 -209
- package/build/account.js +32 -0
- package/build/activities.js +12 -14
- package/build/activity-timeseries.js +14 -20
- package/build/auth-tools.js +31 -0
- package/build/auth.js +197 -105
- package/build/azm-timeseries.js +4 -8
- package/build/compat/api.js +136 -0
- package/build/compat/convert.js +434 -0
- package/build/compat/tools.js +432 -0
- package/build/config.js +102 -32
- package/build/daily-activity.js +35 -7
- package/build/data-points.js +44 -0
- package/build/heart-rate.js +28 -98
- package/build/hrv.js +15 -15
- package/build/index.js +36 -20
- package/build/nutrition.js +41 -152
- package/build/profile.js +6 -9
- package/build/sleep.js +12 -16
- package/build/utils.js +193 -50
- package/build/weight.js +12 -13
- package/package.json +74 -74
package/build/auth.js
CHANGED
|
@@ -1,36 +1,40 @@
|
|
|
1
1
|
import dotenv from 'dotenv';
|
|
2
2
|
import express from 'express';
|
|
3
|
+
import crypto from 'crypto';
|
|
3
4
|
import open from 'open';
|
|
4
5
|
import path from 'path';
|
|
5
6
|
import { AuthorizationCode } from 'simple-oauth2';
|
|
6
7
|
import { fileURLToPath } from 'url';
|
|
7
8
|
import fs from 'fs/promises';
|
|
8
9
|
import { existsSync } from 'fs';
|
|
9
|
-
import {
|
|
10
|
+
import { GOOGLE_OAUTH_CONFIG, GOOGLE_HEALTH_API_BASE, FILE_PATHS, ERROR_MESSAGES, HTTP_CONFIG, resolveClientCredentials, } from './config.js';
|
|
10
11
|
// Determine the directory of the current module (build/auth.js)
|
|
11
12
|
const currentFilename = fileURLToPath(import.meta.url);
|
|
12
13
|
const currentDirname = path.dirname(currentFilename);
|
|
13
14
|
// Load environment variables from .env file located in the project root
|
|
14
15
|
const envPath = path.resolve(currentDirname, '..', '.env');
|
|
15
16
|
dotenv.config({ path: envPath });
|
|
16
|
-
//
|
|
17
|
-
const
|
|
17
|
+
// Google OAuth2 Configuration
|
|
18
|
+
const clientCredentials = resolveClientCredentials();
|
|
19
|
+
const googleConfig = {
|
|
18
20
|
client: {
|
|
19
|
-
id:
|
|
20
|
-
secret:
|
|
21
|
+
id: clientCredentials.id,
|
|
22
|
+
secret: clientCredentials.secret,
|
|
21
23
|
},
|
|
22
24
|
auth: {
|
|
23
|
-
tokenHost:
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
tokenHost: GOOGLE_OAUTH_CONFIG.TOKEN_HOST,
|
|
26
|
+
tokenPath: GOOGLE_OAUTH_CONFIG.TOKEN_PATH,
|
|
27
|
+
authorizeHost: GOOGLE_OAUTH_CONFIG.AUTHORIZE_HOST,
|
|
28
|
+
authorizePath: GOOGLE_OAUTH_CONFIG.AUTHORIZE_PATH,
|
|
26
29
|
},
|
|
27
30
|
options: {
|
|
28
|
-
|
|
31
|
+
// Google expects client_id/client_secret in the form body of token requests
|
|
32
|
+
authorizationMethod: 'body',
|
|
29
33
|
},
|
|
30
34
|
};
|
|
31
35
|
// OAuth2 Redirect URI and local server port
|
|
32
|
-
const REDIRECT_URI =
|
|
33
|
-
const PORT = 3000;
|
|
36
|
+
const REDIRECT_URI = GOOGLE_OAUTH_CONFIG.CALLBACK_URI;
|
|
37
|
+
const PORT = Number(new URL(REDIRECT_URI).port) || 3000;
|
|
34
38
|
// --- State Management ---
|
|
35
39
|
// Storage for the access token and token data
|
|
36
40
|
let accessToken = null;
|
|
@@ -38,16 +42,15 @@ let tokenData = null;
|
|
|
38
42
|
// Holds the temporary HTTP server instance used for the OAuth callback
|
|
39
43
|
let oauthServer = null;
|
|
40
44
|
// --- File paths for token persistence ---
|
|
41
|
-
const TOKEN_FILE_PATH = path.resolve(currentDirname, '..',
|
|
45
|
+
const TOKEN_FILE_PATH = path.resolve(currentDirname, '..', FILE_PATHS.TOKEN_STORAGE);
|
|
42
46
|
// --- OAuth Client Initialization ---
|
|
43
|
-
const oauthClient = new AuthorizationCode(
|
|
47
|
+
const oauthClient = new AuthorizationCode(googleConfig);
|
|
44
48
|
/**
|
|
45
49
|
* Saves the token data to a file for persistence
|
|
46
|
-
* @param tokenData The token data to save
|
|
47
50
|
*/
|
|
48
|
-
async function saveTokenToFile(
|
|
51
|
+
async function saveTokenToFile(data) {
|
|
49
52
|
try {
|
|
50
|
-
await fs.writeFile(TOKEN_FILE_PATH, JSON.stringify(
|
|
53
|
+
await fs.writeFile(TOKEN_FILE_PATH, JSON.stringify(data, null, 2), 'utf8');
|
|
51
54
|
console.error(`Token saved to ${TOKEN_FILE_PATH}`);
|
|
52
55
|
}
|
|
53
56
|
catch (error) {
|
|
@@ -74,11 +77,82 @@ async function loadTokenFromFile() {
|
|
|
74
77
|
return null;
|
|
75
78
|
}
|
|
76
79
|
}
|
|
77
|
-
// --- Fitbit Authorization Flow ---
|
|
78
80
|
/**
|
|
79
|
-
*
|
|
81
|
+
* Checks that the authorized Google Account is linked to Google Health.
|
|
82
|
+
* @returns true when linked (or when the check could not decide), false when
|
|
83
|
+
* the API reports ACCOUNT_NOT_LINKED.
|
|
84
|
+
*/
|
|
85
|
+
async function verifyAccountLinked(token) {
|
|
86
|
+
try {
|
|
87
|
+
const response = await fetch(`${GOOGLE_HEALTH_API_BASE}/users/me/identity`, {
|
|
88
|
+
headers: {
|
|
89
|
+
'User-Agent': HTTP_CONFIG.USER_AGENT,
|
|
90
|
+
Authorization: `Bearer ${token}`,
|
|
91
|
+
Accept: 'application/json',
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
if (response.ok) {
|
|
95
|
+
console.error('Google Health identity verified.');
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
const body = await response.text();
|
|
99
|
+
if (response.status === 400 && body.includes('ACCOUNT_NOT_LINKED')) {
|
|
100
|
+
console.error(ERROR_MESSAGES.ACCOUNT_NOT_LINKED);
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
console.error(`Identity check returned HTTP ${response.status}: ${body}. Continuing.`);
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
console.error('Identity check failed, continuing:', error);
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function isExpired(data) {
|
|
112
|
+
if (!data.expires_at)
|
|
113
|
+
return false;
|
|
114
|
+
// Refresh a minute early to avoid racing the expiry
|
|
115
|
+
return new Date(data.expires_at).getTime() - 60_000 < Date.now();
|
|
116
|
+
}
|
|
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)) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const refreshToken = tokenData.refresh_token;
|
|
126
|
+
if (!refreshToken) {
|
|
127
|
+
console.error('Token is expired and no refresh token is available.');
|
|
128
|
+
accessToken = null;
|
|
129
|
+
tokenData = null;
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
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;
|
|
139
|
+
}
|
|
140
|
+
tokenData = refreshedData;
|
|
141
|
+
accessToken = refreshedData.access_token;
|
|
142
|
+
await saveTokenToFile(refreshedData);
|
|
143
|
+
console.error('Token refreshed and saved successfully.');
|
|
144
|
+
}
|
|
145
|
+
catch (refreshError) {
|
|
146
|
+
console.error('Failed to refresh token:', refreshError);
|
|
147
|
+
accessToken = null;
|
|
148
|
+
tokenData = null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// --- Google Authorization Flow ---
|
|
152
|
+
/**
|
|
153
|
+
* Initiates the Google OAuth2 authorization code flow.
|
|
80
154
|
* Starts a temporary local web server to handle the redirect callback.
|
|
81
|
-
* Opens the user's browser to the
|
|
155
|
+
* Opens the user's browser to the Google consent page.
|
|
82
156
|
*/
|
|
83
157
|
export function startAuthorizationFlow() {
|
|
84
158
|
// Prevent multiple authorization flows from running simultaneously
|
|
@@ -87,36 +161,57 @@ export function startAuthorizationFlow() {
|
|
|
87
161
|
return;
|
|
88
162
|
}
|
|
89
163
|
// Ensure Client ID and Secret are loaded before starting
|
|
90
|
-
if (!
|
|
91
|
-
console.error('Error:
|
|
164
|
+
if (!googleConfig.client.id || !googleConfig.client.secret) {
|
|
165
|
+
console.error('Error: Google Health Client ID or Secret not found. Check environment variables.');
|
|
92
166
|
return;
|
|
93
167
|
}
|
|
94
168
|
const app = express();
|
|
95
|
-
//
|
|
96
|
-
const
|
|
169
|
+
// CSRF protection: the callback must echo this value
|
|
170
|
+
const expectedState = crypto.randomBytes(16).toString('hex');
|
|
171
|
+
// Generate the Google authorization URL
|
|
172
|
+
const baseAuthorizationUri = new URL(oauthClient.authorizeURL({
|
|
97
173
|
redirect_uri: REDIRECT_URI,
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
});
|
|
101
|
-
//
|
|
174
|
+
scope: GOOGLE_OAUTH_CONFIG.SCOPES,
|
|
175
|
+
state: expectedState,
|
|
176
|
+
}));
|
|
177
|
+
// Request a refresh token, and force the consent screen so one is issued
|
|
178
|
+
baseAuthorizationUri.searchParams.set('access_type', 'offline');
|
|
179
|
+
baseAuthorizationUri.searchParams.set('prompt', 'consent');
|
|
180
|
+
const authorizationUri = baseAuthorizationUri.toString();
|
|
181
|
+
const shutdownServer = (reason) => {
|
|
182
|
+
if (oauthServer) {
|
|
183
|
+
oauthServer.close(() => {
|
|
184
|
+
console.error(reason);
|
|
185
|
+
});
|
|
186
|
+
oauthServer = null;
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
// Route to initiate the authorization flow by redirecting the user to Google
|
|
102
190
|
app.get('/auth', (req, res) => {
|
|
103
|
-
console.error('Redirecting to
|
|
191
|
+
console.error('Redirecting to Google for authorization...');
|
|
104
192
|
res.redirect(authorizationUri);
|
|
105
193
|
});
|
|
106
|
-
// Callback route that
|
|
194
|
+
// Callback route that Google redirects to after user authorization
|
|
107
195
|
app.get('/callback', async (req, res) => {
|
|
108
196
|
const code = req.query.code;
|
|
197
|
+
const state = req.query.state;
|
|
198
|
+
if (req.query.error) {
|
|
199
|
+
console.error(`Authorization denied or failed: ${req.query.error}`);
|
|
200
|
+
res.status(400).send(`Authorization failed: ${req.query.error}`);
|
|
201
|
+
shutdownServer('OAuth server closed after authorization error.');
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (state !== expectedState) {
|
|
205
|
+
console.error('OAuth state mismatch in callback.');
|
|
206
|
+
res.status(400).send('Error: Invalid state parameter.');
|
|
207
|
+
shutdownServer('OAuth server closed due to state mismatch.');
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
109
210
|
// Handle cases where the authorization code is missing
|
|
110
211
|
if (!code) {
|
|
111
212
|
console.error('Authorization code missing in callback.');
|
|
112
213
|
res.status(400).send('Error: Authorization code missing.');
|
|
113
|
-
|
|
114
|
-
if (oauthServer) {
|
|
115
|
-
oauthServer.close(() => {
|
|
116
|
-
console.error('OAuth server closed due to missing code.');
|
|
117
|
-
});
|
|
118
|
-
oauthServer = null;
|
|
119
|
-
}
|
|
214
|
+
shutdownServer('OAuth server closed due to missing code.');
|
|
120
215
|
return;
|
|
121
216
|
}
|
|
122
217
|
console.error('Received authorization code. Exchanging for token...');
|
|
@@ -124,14 +219,18 @@ export function startAuthorizationFlow() {
|
|
|
124
219
|
try {
|
|
125
220
|
// Exchange the authorization code for an access token
|
|
126
221
|
const tokenResult = await oauthClient.getToken(tokenParams);
|
|
222
|
+
const newToken = tokenResult.token;
|
|
127
223
|
console.error('Access Token received successfully!');
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
console.error('Token data has been persisted to file');
|
|
224
|
+
// Only treat the account as connected once Google Health recognises it
|
|
225
|
+
const linked = await verifyAccountLinked(newToken.access_token);
|
|
226
|
+
if (!linked) {
|
|
227
|
+
res.status(400).send(ERROR_MESSAGES.ACCOUNT_NOT_LINKED);
|
|
228
|
+
return;
|
|
134
229
|
}
|
|
230
|
+
accessToken = newToken.access_token;
|
|
231
|
+
tokenData = newToken;
|
|
232
|
+
await saveTokenToFile(newToken);
|
|
233
|
+
console.error('Token data has been persisted to file');
|
|
135
234
|
res.send('Authorization successful! You can close this window. The MCP Server is now authenticated.');
|
|
136
235
|
}
|
|
137
236
|
catch (error) {
|
|
@@ -142,7 +241,6 @@ export function startAuthorizationFlow() {
|
|
|
142
241
|
try {
|
|
143
242
|
const errorDetails = await typedError.response.text();
|
|
144
243
|
console.error('Error details:', errorDetails);
|
|
145
|
-
// Optionally parse errorDetails if it's JSON and log typedError.response.body
|
|
146
244
|
}
|
|
147
245
|
catch {
|
|
148
246
|
console.error('Could not parse error response body.');
|
|
@@ -154,20 +252,14 @@ export function startAuthorizationFlow() {
|
|
|
154
252
|
}
|
|
155
253
|
finally {
|
|
156
254
|
// Ensure the temporary server is always shut down after handling the callback
|
|
157
|
-
|
|
158
|
-
console.error('Shutting down temporary OAuth server...');
|
|
159
|
-
oauthServer.close(() => {
|
|
160
|
-
console.error('OAuth server closed.');
|
|
161
|
-
oauthServer = null;
|
|
162
|
-
});
|
|
163
|
-
}
|
|
255
|
+
shutdownServer('OAuth server closed.');
|
|
164
256
|
}
|
|
165
257
|
});
|
|
166
258
|
// Start the temporary local server
|
|
167
259
|
oauthServer = app.listen(PORT, async () => {
|
|
168
260
|
const authUrl = `http://localhost:${PORT}/auth`;
|
|
169
261
|
console.error(`--------------------------------------------------------------------`);
|
|
170
|
-
console.error(`ACTION REQUIRED:
|
|
262
|
+
console.error(`ACTION REQUIRED: Google Health Authorization Needed`);
|
|
171
263
|
console.error(`Attempting to open authorization page in your browser:`);
|
|
172
264
|
console.error(authUrl);
|
|
173
265
|
console.error(`If the browser doesn't open, please navigate there manually.`);
|
|
@@ -189,41 +281,65 @@ export function startAuthorizationFlow() {
|
|
|
189
281
|
});
|
|
190
282
|
}
|
|
191
283
|
/**
|
|
192
|
-
*
|
|
284
|
+
* Stops a pending authorization callback server (if any) and waits until its
|
|
285
|
+
* port is free, so a new flow can bind to it.
|
|
286
|
+
*/
|
|
287
|
+
async function stopAuthorizationServer() {
|
|
288
|
+
const server = oauthServer;
|
|
289
|
+
if (!server)
|
|
290
|
+
return;
|
|
291
|
+
oauthServer = null;
|
|
292
|
+
await new Promise((resolve) => {
|
|
293
|
+
server.close(() => resolve());
|
|
294
|
+
// Drop keep-alive connections (e.g. an open browser tab) so close() completes
|
|
295
|
+
server.closeAllConnections?.();
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Forgets the current token: clears it from memory and deletes the token file.
|
|
300
|
+
* @returns true when a token file was deleted, false when there was none.
|
|
301
|
+
*/
|
|
302
|
+
export async function clearStoredToken() {
|
|
303
|
+
accessToken = null;
|
|
304
|
+
tokenData = null;
|
|
305
|
+
try {
|
|
306
|
+
await fs.unlink(TOKEN_FILE_PATH);
|
|
307
|
+
console.error(`Token file deleted: ${TOKEN_FILE_PATH}`);
|
|
308
|
+
return true;
|
|
309
|
+
}
|
|
310
|
+
catch (error) {
|
|
311
|
+
if (error.code === 'ENOENT')
|
|
312
|
+
return false;
|
|
313
|
+
throw error;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Clears the local tokens and starts a fresh authorization flow. Any flow that
|
|
318
|
+
* is still waiting for a callback is cancelled first. The new token is stored
|
|
319
|
+
* (in memory and on disk) once the user finishes consent in the browser.
|
|
320
|
+
* @returns whether a token file was removed, and the URL that starts consent
|
|
321
|
+
* (null when the flow could not start, e.g. missing client credentials).
|
|
322
|
+
*/
|
|
323
|
+
export async function reauthenticate() {
|
|
324
|
+
await stopAuthorizationServer();
|
|
325
|
+
const tokenFileCleared = await clearStoredToken();
|
|
326
|
+
startAuthorizationFlow();
|
|
327
|
+
return {
|
|
328
|
+
tokenFileCleared,
|
|
329
|
+
authUrl: oauthServer ? `http://localhost:${PORT}/auth` : null,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Retrieves the current Google Health access token.
|
|
193
334
|
* Automatically checks for token expiry and refreshes if necessary.
|
|
194
335
|
* @returns The access token string or null if not available.
|
|
195
336
|
*/
|
|
196
337
|
export async function getAccessToken() {
|
|
197
|
-
// Return null if no token data exists
|
|
198
338
|
if (!tokenData || !accessToken) {
|
|
199
|
-
console.error('No valid access token found.');
|
|
339
|
+
console.error('No valid access token found.');
|
|
200
340
|
return null;
|
|
201
341
|
}
|
|
202
|
-
|
|
203
|
-
if (tokenData.expires_at && new Date(tokenData.expires_at) < new Date()) {
|
|
204
|
-
console.error('Token is expired. Attempting to refresh...');
|
|
205
|
-
try {
|
|
206
|
-
// Create AccessToken instance from the current token data
|
|
207
|
-
// FitbitTokenData is compatible with the 'Token' type expected by createToken
|
|
208
|
-
const accessTokenObj = oauthClient.createToken(tokenData);
|
|
209
|
-
// Refresh the token
|
|
210
|
-
if (accessTokenObj.expired()) {
|
|
211
|
-
const refreshedToken = await accessTokenObj.refresh();
|
|
212
|
-
accessToken = refreshedToken.token.access_token;
|
|
213
|
-
tokenData = refreshedToken.token;
|
|
214
|
-
// Save the refreshed token
|
|
215
|
-
await saveTokenToFile(tokenData);
|
|
216
|
-
console.error('Token refreshed and saved successfully.');
|
|
217
|
-
return accessToken;
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
catch (refreshError) {
|
|
221
|
-
console.error('Failed to refresh token:', refreshError);
|
|
222
|
-
accessToken = null;
|
|
223
|
-
tokenData = null;
|
|
224
|
-
return null;
|
|
225
|
-
}
|
|
226
|
-
}
|
|
342
|
+
await refreshTokenIfNeeded();
|
|
227
343
|
return accessToken;
|
|
228
344
|
}
|
|
229
345
|
/**
|
|
@@ -233,39 +349,15 @@ export async function getAccessToken() {
|
|
|
233
349
|
export async function initializeAuth() {
|
|
234
350
|
console.error('Auth initialized. Checking for persisted token...');
|
|
235
351
|
try {
|
|
236
|
-
// Load token data from file
|
|
237
352
|
tokenData = await loadTokenFromFile();
|
|
238
353
|
if (tokenData && tokenData.access_token) {
|
|
239
354
|
accessToken = tokenData.access_token;
|
|
240
355
|
console.error('Persisted access token loaded successfully.');
|
|
241
|
-
|
|
242
|
-
if (tokenData.expires_at && new Date(tokenData.expires_at) < new Date()) {
|
|
243
|
-
console.error('Token is expired. Attempting to refresh...');
|
|
244
|
-
try {
|
|
245
|
-
// Create AccessToken instance from the loaded token data
|
|
246
|
-
// FitbitTokenData is compatible with the 'Token' type expected by createToken
|
|
247
|
-
const accessTokenObj = oauthClient.createToken(tokenData);
|
|
248
|
-
// Refresh the token
|
|
249
|
-
if (accessTokenObj.expired()) {
|
|
250
|
-
const refreshedToken = await accessTokenObj.refresh();
|
|
251
|
-
accessToken = refreshedToken.token.access_token;
|
|
252
|
-
tokenData = refreshedToken.token;
|
|
253
|
-
// Save the refreshed token
|
|
254
|
-
if (tokenData) {
|
|
255
|
-
await saveTokenToFile(tokenData);
|
|
256
|
-
console.error('Refreshed token saved successfully.');
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
catch (refreshError) {
|
|
261
|
-
console.error('Error refreshing token:', refreshError);
|
|
262
|
-
accessToken = null;
|
|
263
|
-
tokenData = null;
|
|
264
|
-
}
|
|
265
|
-
}
|
|
356
|
+
await refreshTokenIfNeeded();
|
|
266
357
|
}
|
|
267
358
|
else {
|
|
268
359
|
console.error('No valid access token found.');
|
|
360
|
+
tokenData = null;
|
|
269
361
|
}
|
|
270
362
|
}
|
|
271
363
|
catch (error) {
|
package/build/azm-timeseries.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { registerTool, CommonSchemas,
|
|
1
|
+
import { registerTool, CommonSchemas, dailyRollUp } from './utils.js';
|
|
2
|
+
import { VALIDATION_MESSAGES } from './config.js';
|
|
2
3
|
/**
|
|
3
4
|
* Registers an Active Zone Minutes time series tool with the MCP server.
|
|
4
5
|
* @param server The McpServer instance.
|
|
@@ -7,16 +8,11 @@ import { registerTool, CommonSchemas, handleFitbitApiCall } from './utils.js';
|
|
|
7
8
|
export function registerAzmTimeSeriesTool(server, getAccessTokenFn) {
|
|
8
9
|
registerTool(server, {
|
|
9
10
|
name: 'get_azm_timeseries',
|
|
10
|
-
description:
|
|
11
|
+
description: `Get the raw JSON response for daily Active Zone Minutes (AZM) roll-ups from the Google Health API (dailyRollUp of "active-zone-minutes") over a civil date range. startDate is inclusive, endDate is exclusive. ${VALIDATION_MESSAGES.MAX_RANGE_90_DAYS}.`,
|
|
11
12
|
parametersSchema: {
|
|
12
13
|
startDate: CommonSchemas.startDate,
|
|
13
14
|
endDate: CommonSchemas.endDate,
|
|
14
15
|
},
|
|
15
|
-
handler: async ({ startDate, endDate }) => {
|
|
16
|
-
const endpoint = `activities/active-zone-minutes/date/${startDate}/${endDate}.json`;
|
|
17
|
-
return handleFitbitApiCall(endpoint, { startDate, endDate }, getAccessTokenFn, {
|
|
18
|
-
errorContext: `AZM data from ${startDate} to ${endDate}`
|
|
19
|
-
});
|
|
20
|
-
}
|
|
16
|
+
handler: async ({ startDate, endDate, }) => dailyRollUp('active-zone-minutes', getAccessTokenFn, { startDate, endDate }, `AZM data from ${startDate} to ${endDate}`),
|
|
21
17
|
});
|
|
22
18
|
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google Health API access helpers for the Fitbit-compatible output mode.
|
|
3
|
+
* Unlike the proxy tools these fetch *all* pages and return typed arrays, because the
|
|
4
|
+
* compat tools aggregate several calls into one Fitbit-shaped response.
|
|
5
|
+
*/
|
|
6
|
+
import { makeGoogleHealthRequest, dataPointsPath, buildDailyRollUpBody, buildRangeFilter, createErrorResponse, GoogleHealthApiError, } from '../utils.js';
|
|
7
|
+
import { ERROR_MESSAGES } from '../config.js';
|
|
8
|
+
import { buildFitbitZones, rollupDate, zoneBoundsByDate, } from './convert.js';
|
|
9
|
+
// === Error handling ===
|
|
10
|
+
/** Runs a compat handler and converts failures into MCP error responses. */
|
|
11
|
+
export async function guard(context, fn) {
|
|
12
|
+
try {
|
|
13
|
+
return await fn();
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
if (error instanceof GoogleHealthApiError) {
|
|
17
|
+
if (error.status === 400 && error.body.includes('ACCOUNT_NOT_LINKED')) {
|
|
18
|
+
return createErrorResponse(ERROR_MESSAGES.ACCOUNT_NOT_LINKED);
|
|
19
|
+
}
|
|
20
|
+
return createErrorResponse(`${ERROR_MESSAGES.API_REQUEST_FAILED} for ${context} (HTTP ${error.status}): ${error.body}`);
|
|
21
|
+
}
|
|
22
|
+
return createErrorResponse(`${ERROR_MESSAGES.API_REQUEST_FAILED} for ${context}: ${error instanceof Error ? error.message : String(error)}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
async function request(path, getToken, options = {}) {
|
|
26
|
+
const result = await makeGoogleHealthRequest(path, getToken, options);
|
|
27
|
+
if (result === null)
|
|
28
|
+
throw new Error(ERROR_MESSAGES.NO_ACCESS_TOKEN);
|
|
29
|
+
return result;
|
|
30
|
+
}
|
|
31
|
+
// === Paging helpers ===
|
|
32
|
+
/** Lists every data point matching a filter, following nextPageToken. */
|
|
33
|
+
export async function listAll(dataType, getToken, params) {
|
|
34
|
+
const out = [];
|
|
35
|
+
let pageToken;
|
|
36
|
+
for (let page = 0; page < (params.maxPages ?? 20); page++) {
|
|
37
|
+
const res = await request(dataPointsPath(dataType), getToken, {
|
|
38
|
+
query: { filter: params.filter, pageSize: params.pageSize, pageToken },
|
|
39
|
+
});
|
|
40
|
+
out.push(...(res.dataPoints ?? []));
|
|
41
|
+
pageToken = res.nextPageToken;
|
|
42
|
+
if (!pageToken)
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
/** Daily roll-up of a data type over [startDate, endDateExclusive), following nextPageToken. */
|
|
48
|
+
export async function rollUpAll(dataType, getToken, startDate, endDateExclusive, maxPages = 20) {
|
|
49
|
+
const out = [];
|
|
50
|
+
let pageToken;
|
|
51
|
+
for (let page = 0; page < maxPages; page++) {
|
|
52
|
+
const res = await request(dataPointsPath(dataType, ':dailyRollUp'), getToken, {
|
|
53
|
+
method: 'POST',
|
|
54
|
+
body: {
|
|
55
|
+
...buildDailyRollUpBody(startDate, endDateExclusive),
|
|
56
|
+
...(pageToken ? { pageToken } : {}),
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
out.push(...(res.rollupDataPoints ?? []));
|
|
60
|
+
pageToken = res.nextPageToken;
|
|
61
|
+
if (!pageToken)
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
const CONTEXT_TTL_MS = 60 * 60 * 1000;
|
|
67
|
+
let cachedContext = null;
|
|
68
|
+
export function resetCompatCaches() {
|
|
69
|
+
cachedContext = null;
|
|
70
|
+
}
|
|
71
|
+
/** The account's time zone and display units, from Google Health settings (cached for an hour). */
|
|
72
|
+
export async function getUserContext(getToken) {
|
|
73
|
+
if (cachedContext && Date.now() - cachedContext.at < CONTEXT_TTL_MS) {
|
|
74
|
+
return cachedContext.value;
|
|
75
|
+
}
|
|
76
|
+
let value;
|
|
77
|
+
try {
|
|
78
|
+
const settings = await request('users/me/settings', getToken);
|
|
79
|
+
value = {
|
|
80
|
+
timeZone: process.env.COMPAT_TIME_ZONE || settings.timeZone || 'UTC',
|
|
81
|
+
weightUnit: settings.weightUnit,
|
|
82
|
+
distanceUnit: settings.distanceUnit,
|
|
83
|
+
waterUnit: settings.waterUnit,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
console.error('Could not load Google Health settings, using defaults:', error);
|
|
88
|
+
return { timeZone: process.env.COMPAT_TIME_ZONE || 'UTC' };
|
|
89
|
+
}
|
|
90
|
+
cachedContext = { value, at: Date.now() };
|
|
91
|
+
return value;
|
|
92
|
+
}
|
|
93
|
+
// === Heart-rate zones ===
|
|
94
|
+
/**
|
|
95
|
+
* Fitbit-style zones per civil date over [startDate, endDateExclusive), built from Google's own
|
|
96
|
+
* zone data: Active Zone Minutes / time-in-zone for minutes, calories-in-zone for kcal and
|
|
97
|
+
* daily-heart-rate-zones for the bpm bounds. Individual source failures degrade to missing
|
|
98
|
+
* fields (reported in `notes`); if every minutes source fails the first error is thrown.
|
|
99
|
+
*/
|
|
100
|
+
export async function getZonesForRange(getToken, startDate, endDateExclusive) {
|
|
101
|
+
const [azm, tiz, kcal, bounds] = await Promise.allSettled([
|
|
102
|
+
rollUpAll('active-zone-minutes', getToken, startDate, endDateExclusive, 60),
|
|
103
|
+
rollUpAll('time-in-heart-rate-zone', getToken, startDate, endDateExclusive, 60),
|
|
104
|
+
rollUpAll('calories-in-heart-rate-zone', getToken, startDate, endDateExclusive, 60),
|
|
105
|
+
listAll('daily-heart-rate-zones', getToken, {
|
|
106
|
+
filter: buildRangeFilter('daily_heart_rate_zones.date', startDate, endDateExclusive),
|
|
107
|
+
pageSize: 10000,
|
|
108
|
+
}),
|
|
109
|
+
]);
|
|
110
|
+
if (azm.status === 'rejected' && tiz.status === 'rejected')
|
|
111
|
+
throw azm.reason;
|
|
112
|
+
const notes = [];
|
|
113
|
+
const value = (r, what) => {
|
|
114
|
+
if (r.status === 'fulfilled')
|
|
115
|
+
return r.value;
|
|
116
|
+
notes.push(`${what} unavailable: ${r.reason instanceof Error ? r.reason.message : String(r.reason)}`);
|
|
117
|
+
return [];
|
|
118
|
+
};
|
|
119
|
+
const byDate = (points) => new Map(points.flatMap((p) => (rollupDate(p) ? [[rollupDate(p), p]] : [])));
|
|
120
|
+
const azmByDate = byDate(value(azm, 'Active Zone Minutes'));
|
|
121
|
+
const tizByDate = byDate(value(tiz, 'time-in-heart-rate-zone'));
|
|
122
|
+
const kcalByDate = byDate(value(kcal, 'calories-in-heart-rate-zone'));
|
|
123
|
+
const boundsByDate = zoneBoundsByDate(value(bounds, 'daily-heart-rate-zones'));
|
|
124
|
+
const zones = new Map();
|
|
125
|
+
for (const date of new Set([...azmByDate.keys(), ...tizByDate.keys()])) {
|
|
126
|
+
const built = buildFitbitZones({
|
|
127
|
+
azm: azmByDate.get(date),
|
|
128
|
+
timeInZone: tizByDate.get(date),
|
|
129
|
+
calories: kcalByDate.get(date),
|
|
130
|
+
bounds: boundsByDate.get(date),
|
|
131
|
+
});
|
|
132
|
+
if (built)
|
|
133
|
+
zones.set(date, built);
|
|
134
|
+
}
|
|
135
|
+
return { zones, notes };
|
|
136
|
+
}
|