@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
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fitbit-compatible tool set.
|
|
3
|
+
*
|
|
4
|
+
* Enabled with GOOGLE_HEALTH_OUTPUT_MODE=fitbit. These tools keep the legacy Fitbit tool
|
|
5
|
+
* names, parameters (period / inclusive end dates) and response shapes/units, but are
|
|
6
|
+
* served from the Google Health API. They deliberately deviate from the 1:1 proxy
|
|
7
|
+
* philosophy of the default mode so that consumers written against the Fitbit Web API
|
|
8
|
+
* (such as the "Heather's Coach" Claude project) need no changes.
|
|
9
|
+
*
|
|
10
|
+
* Differences that cannot be hidden are reported in a top-level `_compat.notes` array.
|
|
11
|
+
*/
|
|
12
|
+
import { z } from 'zod';
|
|
13
|
+
import { registerTool, CommonSchemas, buildRangeFilter, createSuccessResponse, createErrorResponse, createNoDataResponse, } from '../utils.js';
|
|
14
|
+
import { DATE_REGEX, VALIDATION_MESSAGES } from '../config.js';
|
|
15
|
+
import { guard, listAll, rollUpAll, getUserContext, getZonesForRange, } from './api.js';
|
|
16
|
+
import { addDays, convertDistance, convertWater, eachDate, inclusiveDays, mapHrv, mapSleep, mapWeight, num, nutritionSummary, periodStartDate, restingHeartRateByDate, rollupDate, round, todayInZone, zonedMidnightIso, activityLevelMinutes, } from './convert.js';
|
|
17
|
+
// === Shared helpers ===
|
|
18
|
+
const dateSchema = z
|
|
19
|
+
.string()
|
|
20
|
+
.regex(DATE_REGEX, VALIDATION_MESSAGES.DATE_FORMAT);
|
|
21
|
+
const startDateSchema = dateSchema.describe('The start date of the range, inclusive (YYYY-MM-DD)');
|
|
22
|
+
const endDateSchema = dateSchema.describe('The end date of the range, INCLUSIVE (YYYY-MM-DD), as in the Fitbit API');
|
|
23
|
+
const optionalDateSchema = dateSchema
|
|
24
|
+
.optional()
|
|
25
|
+
.describe('Optional end date of the period (YYYY-MM-DD). Defaults to today');
|
|
26
|
+
function withNotes(body, notes) {
|
|
27
|
+
return notes.length > 0 ? { ...body, _compat: { notes } } : body;
|
|
28
|
+
}
|
|
29
|
+
function checkRange(start, end, maxDays) {
|
|
30
|
+
if (end < start)
|
|
31
|
+
return `endDate ${end} is before startDate ${start}.`;
|
|
32
|
+
if (inclusiveDays(start, end) > maxDays) {
|
|
33
|
+
return `The date range is limited to ${maxDays} days.`;
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
const rangeParams = { startDate: startDateSchema, endDate: endDateSchema };
|
|
38
|
+
// === Tools ===
|
|
39
|
+
/**
|
|
40
|
+
* Registers the Fitbit-compatible tools. Names, parameters and response shapes follow the
|
|
41
|
+
* legacy Fitbit tools of this server.
|
|
42
|
+
*/
|
|
43
|
+
export function registerCompatTools(server, getToken) {
|
|
44
|
+
registerWeight(server, getToken);
|
|
45
|
+
registerSleep(server, getToken);
|
|
46
|
+
registerDailySummary(server, getToken);
|
|
47
|
+
registerHeartRate(server, getToken);
|
|
48
|
+
registerHrv(server, getToken);
|
|
49
|
+
registerActivityTimeSeries(server, getToken);
|
|
50
|
+
registerAzm(server, getToken);
|
|
51
|
+
registerNutrition(server, getToken);
|
|
52
|
+
}
|
|
53
|
+
// --- Weight ---
|
|
54
|
+
function registerWeight(server, t) {
|
|
55
|
+
registerTool(server, {
|
|
56
|
+
name: 'get_weight',
|
|
57
|
+
description: "Fitbit-compatible weight log for a period ending today (or on 'date'). Returns { weight: [{ date, time, logId, source, weight }] } with weight in the account's unit (pounds by default), as the Fitbit API did. period is one of 1d, 7d, 30d, 3m, 6m, 1y.",
|
|
58
|
+
parametersSchema: {
|
|
59
|
+
period: z.enum(['1d', '7d', '30d', '3m', '6m', '1y']).describe('Period ending on date (default today)'),
|
|
60
|
+
date: optionalDateSchema,
|
|
61
|
+
},
|
|
62
|
+
handler: async ({ period, date }) => guard(`weight for period '${period}'`, async () => {
|
|
63
|
+
const ctx = await getUserContext(t);
|
|
64
|
+
const end = date ?? todayInZone(ctx.timeZone);
|
|
65
|
+
const start = periodStartDate(end, period);
|
|
66
|
+
const points = await listAll('weight', t, {
|
|
67
|
+
filter: buildRangeFilter('weight.sample_time.physical_time', zonedMidnightIso(start, ctx.timeZone), zonedMidnightIso(addDays(end, 1), ctx.timeZone)),
|
|
68
|
+
pageSize: 10000,
|
|
69
|
+
});
|
|
70
|
+
const body = mapWeight(points, ctx.weightUnit);
|
|
71
|
+
if (body.weight.length === 0) {
|
|
72
|
+
return createNoDataResponse(`weight for ${start} to ${end}`);
|
|
73
|
+
}
|
|
74
|
+
return createSuccessResponse(withNotes(body, ['bmi and body fat are not included.']));
|
|
75
|
+
}),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
// --- Sleep ---
|
|
79
|
+
function registerSleep(server, t) {
|
|
80
|
+
registerTool(server, {
|
|
81
|
+
name: 'get_sleep_by_date_range',
|
|
82
|
+
description: "Fitbit-compatible sleep logs by date of sleep (the wake date), start and end INCLUSIVE, max 100 days. Returns { sleep: [{ dateOfSleep, startTime, endTime, duration(ms), minutesAsleep, minutesAwake, timeInBed, isMainSleep, type, levels: { summary: { deep, light, rem, wake: { count, minutes } }, data } }] }. There is no sleep score or efficiency.",
|
|
83
|
+
parametersSchema: rangeParams,
|
|
84
|
+
handler: async ({ startDate, endDate }) => guard(`sleep from ${startDate} to ${endDate}`, async () => {
|
|
85
|
+
const problem = checkRange(startDate, endDate, 100);
|
|
86
|
+
if (problem)
|
|
87
|
+
return createErrorResponse(problem);
|
|
88
|
+
const points = await listAll('sleep', t, {
|
|
89
|
+
filter: buildRangeFilter('sleep.interval.civil_end_time', startDate, addDays(endDate, 1)),
|
|
90
|
+
pageSize: 25,
|
|
91
|
+
maxPages: 20,
|
|
92
|
+
});
|
|
93
|
+
const body = mapSleep(points);
|
|
94
|
+
if (body.sleep.length === 0) {
|
|
95
|
+
return createNoDataResponse(`sleep from ${startDate} to ${endDate}`);
|
|
96
|
+
}
|
|
97
|
+
return createSuccessResponse(body);
|
|
98
|
+
}),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
// --- Daily summary ---
|
|
102
|
+
function registerDailySummary(server, t) {
|
|
103
|
+
registerTool(server, {
|
|
104
|
+
name: 'get_daily_activity_summary',
|
|
105
|
+
description: "Fitbit-compatible daily activity summary for one date: { activities: [], goals: {}, summary: { caloriesOut, steps, distances, floors, sedentary/lightlyActive/fairlyActive/veryActiveMinutes, restingHeartRate, heartRateZones } }. Distance uses the account's unit (miles by default). heartRateZones are Fitbit's Out of Range / Fat Burn / Cardio / Peak zones with minutes and calories (Google's Light / Moderate / Vigorous / Peak). Exercise sessions and goals are not included.",
|
|
106
|
+
parametersSchema: { date: CommonSchemas.date },
|
|
107
|
+
handler: async ({ date }) => guard(`daily summary for ${date}`, async () => {
|
|
108
|
+
const next = addDays(date, 1);
|
|
109
|
+
const ctx = await getUserContext(t);
|
|
110
|
+
const notes = [];
|
|
111
|
+
const [steps, distance, calories] = await Promise.all([
|
|
112
|
+
rollUpAll('steps', t, date, next),
|
|
113
|
+
rollUpAll('distance', t, date, next),
|
|
114
|
+
rollUpAll('total-calories', t, date, next),
|
|
115
|
+
]);
|
|
116
|
+
if (!steps.length && !distance.length && !calories.length) {
|
|
117
|
+
return createNoDataResponse(`daily activity for ${date}`);
|
|
118
|
+
}
|
|
119
|
+
const [floors, level, resting, zoneResult] = await Promise.all([
|
|
120
|
+
rollUpAll('floors', t, date, next).catch(() => []),
|
|
121
|
+
// activity-level has no dailyRollUp (list/reconcile only): sum the minute points
|
|
122
|
+
listAll('activity-level', t, {
|
|
123
|
+
filter: buildRangeFilter('activity_level.interval.civil_start_time', `${date}T00:00:00`, `${next}T00:00:00`),
|
|
124
|
+
pageSize: 10000,
|
|
125
|
+
}).catch((error) => {
|
|
126
|
+
notes.push(`activity minutes unavailable: ${error instanceof Error ? error.message : String(error)}`);
|
|
127
|
+
return [];
|
|
128
|
+
}),
|
|
129
|
+
listAll('daily-resting-heart-rate', t, {
|
|
130
|
+
filter: buildRangeFilter('daily_resting_heart_rate.date', date, next),
|
|
131
|
+
}).catch(() => []),
|
|
132
|
+
getZonesForRange(t, date, next).catch((error) => {
|
|
133
|
+
notes.push(`heartRateZones unavailable: ${error instanceof Error ? error.message : String(error)}`);
|
|
134
|
+
return null;
|
|
135
|
+
}),
|
|
136
|
+
]);
|
|
137
|
+
const zones = zoneResult?.zones.get(date);
|
|
138
|
+
notes.push(...(zoneResult?.notes ?? []));
|
|
139
|
+
const summary = {
|
|
140
|
+
caloriesOut: Math.round(calories[0]?.totalCalories?.kcalSum ?? 0),
|
|
141
|
+
steps: num(steps[0]?.steps?.countSum),
|
|
142
|
+
distances: [
|
|
143
|
+
{
|
|
144
|
+
activity: 'total',
|
|
145
|
+
distance: convertDistance(num(distance[0]?.distance?.millimetersSum), ctx.distanceUnit),
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
floors: num(floors[0]?.floors?.countSum),
|
|
149
|
+
...(activityLevelMinutes(level) ?? {}),
|
|
150
|
+
};
|
|
151
|
+
const restingHr = restingHeartRateByDate(resting).get(date);
|
|
152
|
+
if (restingHr !== undefined)
|
|
153
|
+
summary.restingHeartRate = restingHr;
|
|
154
|
+
if (zones)
|
|
155
|
+
summary.heartRateZones = zones;
|
|
156
|
+
return createSuccessResponse(withNotes({ activities: [], goals: {}, summary }, notes));
|
|
157
|
+
}),
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
// --- Heart rate ---
|
|
161
|
+
async function buildHeartDays(t, start, end) {
|
|
162
|
+
const notes = [];
|
|
163
|
+
const next = addDays(end, 1);
|
|
164
|
+
const [restingPoints, zoneResult] = await Promise.all([
|
|
165
|
+
listAll('daily-resting-heart-rate', t, {
|
|
166
|
+
filter: buildRangeFilter('daily_resting_heart_rate.date', start, next),
|
|
167
|
+
pageSize: 10000,
|
|
168
|
+
}),
|
|
169
|
+
getZonesForRange(t, start, next).catch((error) => {
|
|
170
|
+
notes.push(`heartRateZones unavailable: ${error instanceof Error ? error.message : String(error)}`);
|
|
171
|
+
return null;
|
|
172
|
+
}),
|
|
173
|
+
]);
|
|
174
|
+
const resting = restingHeartRateByDate(restingPoints);
|
|
175
|
+
const zoneByDate = zoneResult?.zones ?? new Map();
|
|
176
|
+
notes.push(...(zoneResult?.notes ?? []));
|
|
177
|
+
const days = eachDate(start, end)
|
|
178
|
+
.filter((d) => resting.has(d) || zoneByDate.has(d))
|
|
179
|
+
.map((d) => ({
|
|
180
|
+
dateTime: d,
|
|
181
|
+
value: {
|
|
182
|
+
customHeartRateZones: [],
|
|
183
|
+
...(zoneByDate.has(d) ? { heartRateZones: zoneByDate.get(d) } : {}),
|
|
184
|
+
...(resting.has(d) ? { restingHeartRate: resting.get(d) } : {}),
|
|
185
|
+
},
|
|
186
|
+
}));
|
|
187
|
+
if (days.length === 0) {
|
|
188
|
+
return createNoDataResponse(`heart rate from ${start} to ${end}`);
|
|
189
|
+
}
|
|
190
|
+
return createSuccessResponse(withNotes({ 'activities-heart': days }, notes));
|
|
191
|
+
}
|
|
192
|
+
function registerHeartRate(server, t) {
|
|
193
|
+
registerTool(server, {
|
|
194
|
+
name: 'get_heart_rate',
|
|
195
|
+
description: "Fitbit-compatible daily heart rate for a period ending today (or on 'date'): { 'activities-heart': [{ dateTime, value: { restingHeartRate, heartRateZones: [{ name, min, max, minutes, caloriesOut }] } }] }. Zones are Fitbit's Out of Range / Fat Burn / Cardio / Peak (Google's Light / Moderate / Vigorous / Peak) with the account's own bpm bounds. period is one of 1d, 7d, 30d, 1w, 1m.",
|
|
196
|
+
parametersSchema: {
|
|
197
|
+
period: z.enum(['1d', '7d', '30d', '1w', '1m']).describe('Period ending on date (default today)'),
|
|
198
|
+
date: optionalDateSchema,
|
|
199
|
+
},
|
|
200
|
+
handler: async ({ period, date }) => guard(`heart rate for period '${period}'`, async () => {
|
|
201
|
+
const ctx = await getUserContext(t);
|
|
202
|
+
const end = date ?? todayInZone(ctx.timeZone);
|
|
203
|
+
return buildHeartDays(t, periodStartDate(end, period), end);
|
|
204
|
+
}),
|
|
205
|
+
});
|
|
206
|
+
registerTool(server, {
|
|
207
|
+
name: 'get_heart_rate_by_date_range',
|
|
208
|
+
description: 'Fitbit-compatible daily heart rate for a date range, start and end INCLUSIVE, max 365 days. Same shape as get_heart_rate.',
|
|
209
|
+
parametersSchema: rangeParams,
|
|
210
|
+
handler: async ({ startDate, endDate }) => guard(`heart rate from ${startDate} to ${endDate}`, async () => {
|
|
211
|
+
const problem = checkRange(startDate, endDate, 365);
|
|
212
|
+
if (problem)
|
|
213
|
+
return createErrorResponse(problem);
|
|
214
|
+
return buildHeartDays(t, startDate, endDate);
|
|
215
|
+
}),
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
// --- HRV ---
|
|
219
|
+
function registerHrv(server, t) {
|
|
220
|
+
const fetchHrv = async (start, end) => {
|
|
221
|
+
const points = await listAll('daily-heart-rate-variability', t, {
|
|
222
|
+
filter: buildRangeFilter('daily_heart_rate_variability.date', start, addDays(end, 1)),
|
|
223
|
+
pageSize: 10000,
|
|
224
|
+
});
|
|
225
|
+
const body = mapHrv(points);
|
|
226
|
+
if (body.hrv.length === 0)
|
|
227
|
+
return createNoDataResponse(`HRV from ${start} to ${end}`);
|
|
228
|
+
return createSuccessResponse(body);
|
|
229
|
+
};
|
|
230
|
+
registerTool(server, {
|
|
231
|
+
name: 'get_hrv',
|
|
232
|
+
description: 'Fitbit-compatible daily HRV for one date: { hrv: [{ dateTime, value: { dailyRmssd, deepRmssd } }] } in milliseconds.',
|
|
233
|
+
parametersSchema: { date: CommonSchemas.date },
|
|
234
|
+
handler: async ({ date }) => guard(`HRV for ${date}`, () => fetchHrv(date, date)),
|
|
235
|
+
});
|
|
236
|
+
registerTool(server, {
|
|
237
|
+
name: 'get_hrv_by_date_range',
|
|
238
|
+
description: 'Fitbit-compatible daily HRV for a date range, start and end INCLUSIVE, max 30 days. Same shape as get_hrv.',
|
|
239
|
+
parametersSchema: rangeParams,
|
|
240
|
+
handler: async ({ startDate, endDate }) => guard(`HRV from ${startDate} to ${endDate}`, async () => {
|
|
241
|
+
const problem = checkRange(startDate, endDate, 30);
|
|
242
|
+
if (problem)
|
|
243
|
+
return createErrorResponse(problem);
|
|
244
|
+
return fetchHrv(startDate, endDate);
|
|
245
|
+
}),
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
// --- Activity time series ---
|
|
249
|
+
const ACTIVITY_RESOURCES = [
|
|
250
|
+
'steps',
|
|
251
|
+
'distance',
|
|
252
|
+
'calories',
|
|
253
|
+
'activityCalories',
|
|
254
|
+
'caloriesBMR',
|
|
255
|
+
'tracker/activityCalories',
|
|
256
|
+
'tracker/calories',
|
|
257
|
+
'tracker/distance',
|
|
258
|
+
];
|
|
259
|
+
function registerActivityTimeSeries(server, t) {
|
|
260
|
+
registerTool(server, {
|
|
261
|
+
name: 'get_activity_timeseries',
|
|
262
|
+
description: "Fitbit-compatible daily activity time series over a date range (start and end INCLUSIVE, max 30 days): { 'activities-<resource>': [{ dateTime, value: '<string>' }] }. Supports steps, distance (account unit, miles by default), calories (total kcal), activityCalories (active kcal) and the tracker/ variants. caloriesBMR is not available.",
|
|
263
|
+
parametersSchema: {
|
|
264
|
+
resourcePath: z.enum(ACTIVITY_RESOURCES).describe("Activity resource to retrieve (e.g. 'steps', 'distance', 'calories')"),
|
|
265
|
+
...rangeParams,
|
|
266
|
+
},
|
|
267
|
+
handler: async ({ resourcePath, startDate, endDate, }) => guard(`${resourcePath} from ${startDate} to ${endDate}`, async () => {
|
|
268
|
+
const problem = checkRange(startDate, endDate, 30);
|
|
269
|
+
if (problem)
|
|
270
|
+
return createErrorResponse(problem);
|
|
271
|
+
const resource = resourcePath.replace('tracker/', '');
|
|
272
|
+
if (resource === 'caloriesBMR') {
|
|
273
|
+
return createErrorResponse('caloriesBMR is not available from the Google Health API.');
|
|
274
|
+
}
|
|
275
|
+
const ctx = await getUserContext(t);
|
|
276
|
+
const dataType = resource === 'steps'
|
|
277
|
+
? 'steps'
|
|
278
|
+
: resource === 'distance'
|
|
279
|
+
? 'distance'
|
|
280
|
+
: resource === 'calories'
|
|
281
|
+
? 'total-calories'
|
|
282
|
+
: 'active-energy-burned';
|
|
283
|
+
const extract = (p) => {
|
|
284
|
+
switch (dataType) {
|
|
285
|
+
case 'steps':
|
|
286
|
+
return p.steps ? num(p.steps.countSum) : undefined;
|
|
287
|
+
case 'distance':
|
|
288
|
+
return p.distance
|
|
289
|
+
? convertDistance(num(p.distance.millimetersSum), ctx.distanceUnit)
|
|
290
|
+
: undefined;
|
|
291
|
+
case 'total-calories':
|
|
292
|
+
return p.totalCalories ? Math.round(p.totalCalories.kcalSum ?? 0) : undefined;
|
|
293
|
+
default:
|
|
294
|
+
return p.activeEnergyBurned ? Math.round(p.activeEnergyBurned.kcalSum ?? 0) : undefined;
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
const points = await rollUpAll(dataType, t, startDate, addDays(endDate, 1));
|
|
298
|
+
const byDate = new Map();
|
|
299
|
+
for (const p of points) {
|
|
300
|
+
const d = rollupDate(p);
|
|
301
|
+
const v = extract(p);
|
|
302
|
+
if (d && v !== undefined)
|
|
303
|
+
byDate.set(d, v);
|
|
304
|
+
}
|
|
305
|
+
if (byDate.size === 0) {
|
|
306
|
+
return createNoDataResponse(`${resourcePath} from ${startDate} to ${endDate}`);
|
|
307
|
+
}
|
|
308
|
+
const series = eachDate(startDate, endDate).map((d) => ({
|
|
309
|
+
dateTime: d,
|
|
310
|
+
value: String(byDate.get(d) ?? 0),
|
|
311
|
+
}));
|
|
312
|
+
return createSuccessResponse({
|
|
313
|
+
[`activities-${resourcePath.replace('/', '-')}`]: series,
|
|
314
|
+
});
|
|
315
|
+
}),
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
// --- Active Zone Minutes ---
|
|
319
|
+
function registerAzm(server, t) {
|
|
320
|
+
registerTool(server, {
|
|
321
|
+
name: 'get_azm_timeseries',
|
|
322
|
+
description: "Fitbit-compatible Active Zone Minutes by day (start and end INCLUSIVE, max 1095 days): { 'activities-active-zone-minutes': [{ dateTime, value: { activeZoneMinutes, fatBurnActiveZoneMinutes, cardioActiveZoneMinutes, peakActiveZoneMinutes } }] }. Only days with data are returned.",
|
|
323
|
+
parametersSchema: rangeParams,
|
|
324
|
+
handler: async ({ startDate, endDate }) => guard(`active zone minutes from ${startDate} to ${endDate}`, async () => {
|
|
325
|
+
const problem = checkRange(startDate, endDate, 1095);
|
|
326
|
+
if (problem)
|
|
327
|
+
return createErrorResponse(problem);
|
|
328
|
+
const points = await rollUpAll('active-zone-minutes', t, startDate, addDays(endDate, 1), 60);
|
|
329
|
+
const series = points
|
|
330
|
+
.map((p) => {
|
|
331
|
+
const date = rollupDate(p);
|
|
332
|
+
const azm = p.activeZoneMinutes;
|
|
333
|
+
if (!date || !azm)
|
|
334
|
+
return null;
|
|
335
|
+
const fat = num(azm.sumInFatBurnHeartZone);
|
|
336
|
+
const cardio = num(azm.sumInCardioHeartZone);
|
|
337
|
+
const peak = num(azm.sumInPeakHeartZone);
|
|
338
|
+
return {
|
|
339
|
+
dateTime: date,
|
|
340
|
+
value: {
|
|
341
|
+
activeZoneMinutes: fat + cardio + peak,
|
|
342
|
+
fatBurnActiveZoneMinutes: fat,
|
|
343
|
+
cardioActiveZoneMinutes: cardio,
|
|
344
|
+
peakActiveZoneMinutes: peak,
|
|
345
|
+
},
|
|
346
|
+
};
|
|
347
|
+
})
|
|
348
|
+
.filter((r) => r !== null)
|
|
349
|
+
.sort((a, b) => a.dateTime.localeCompare(b.dateTime));
|
|
350
|
+
if (series.length === 0) {
|
|
351
|
+
return createNoDataResponse(`active zone minutes from ${startDate} to ${endDate}`);
|
|
352
|
+
}
|
|
353
|
+
return createSuccessResponse({ 'activities-active-zone-minutes': series });
|
|
354
|
+
}),
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
// --- Nutrition ---
|
|
358
|
+
const NUTRITION_RESOURCES = ['caloriesIn', 'water'];
|
|
359
|
+
function registerNutrition(server, t) {
|
|
360
|
+
registerTool(server, {
|
|
361
|
+
name: 'get_food_log',
|
|
362
|
+
description: "Fitbit-compatible food log summary for one date: { foods: [], goals: {}, summary: { calories, carbs, fat, fiber, protein, sodium, water } } (kcal; g; sodium in mg; water in the account's unit). Individual food entries and goals are not included.",
|
|
363
|
+
parametersSchema: { date: CommonSchemas.date },
|
|
364
|
+
handler: async ({ date }) => guard(`food log for ${date}`, async () => {
|
|
365
|
+
const next = addDays(date, 1);
|
|
366
|
+
const ctx = await getUserContext(t);
|
|
367
|
+
const [nutrition, hydration] = await Promise.all([
|
|
368
|
+
rollUpAll('nutrition-log', t, date, next),
|
|
369
|
+
rollUpAll('hydration-log', t, date, next).catch(() => []),
|
|
370
|
+
]);
|
|
371
|
+
const summary = nutritionSummary(nutrition[0]);
|
|
372
|
+
if (!summary)
|
|
373
|
+
return createNoDataResponse(`food log for ${date}`);
|
|
374
|
+
const water = convertWater(hydration[0]?.hydrationLog?.amountConsumed?.millilitersSum ?? 0, ctx.waterUnit);
|
|
375
|
+
return createSuccessResponse(withNotes({ foods: [], goals: {}, summary: { ...summary, water } }, [
|
|
376
|
+
'Individual foods and goals are not included.',
|
|
377
|
+
]));
|
|
378
|
+
}),
|
|
379
|
+
});
|
|
380
|
+
const fetchSeries = async (resource, start, end) => {
|
|
381
|
+
const problem = checkRange(start, end, 1095);
|
|
382
|
+
if (problem)
|
|
383
|
+
return createErrorResponse(problem);
|
|
384
|
+
const ctx = await getUserContext(t);
|
|
385
|
+
const dataType = resource === 'caloriesIn' ? 'nutrition-log' : 'hydration-log';
|
|
386
|
+
const points = await rollUpAll(dataType, t, start, addDays(end, 1), 60);
|
|
387
|
+
const byDate = new Map();
|
|
388
|
+
for (const p of points) {
|
|
389
|
+
const d = rollupDate(p);
|
|
390
|
+
if (!d)
|
|
391
|
+
continue;
|
|
392
|
+
if (resource === 'caloriesIn' && p.nutritionLog) {
|
|
393
|
+
byDate.set(d, Math.round(p.nutritionLog.energy?.kcalSum ?? 0));
|
|
394
|
+
}
|
|
395
|
+
else if (resource === 'water' && p.hydrationLog) {
|
|
396
|
+
byDate.set(d, convertWater(p.hydrationLog.amountConsumed?.millilitersSum ?? 0, ctx.waterUnit));
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
if (byDate.size === 0) {
|
|
400
|
+
return createNoDataResponse(`${resource} from ${start} to ${end}`);
|
|
401
|
+
}
|
|
402
|
+
return createSuccessResponse({
|
|
403
|
+
[`foods-log-${resource}`]: eachDate(start, end).map((d) => ({
|
|
404
|
+
dateTime: d,
|
|
405
|
+
value: String(round(byDate.get(d) ?? 0, 1)),
|
|
406
|
+
})),
|
|
407
|
+
});
|
|
408
|
+
};
|
|
409
|
+
registerTool(server, {
|
|
410
|
+
name: 'get_nutrition',
|
|
411
|
+
description: "Fitbit-compatible nutrition time series for a period ending today (or on 'date'): { 'foods-log-<resource>': [{ dateTime, value: '<string>' }] }. resource is caloriesIn (kcal) or water (account unit). period is one of 1d, 7d, 30d, 1w, 1m, 3m, 6m, 1y.",
|
|
412
|
+
parametersSchema: {
|
|
413
|
+
resource: z.enum(NUTRITION_RESOURCES).describe('The nutrition resource to retrieve'),
|
|
414
|
+
period: z.enum(['1d', '7d', '30d', '1w', '1m', '3m', '6m', '1y']).describe('Period ending on date (default today)'),
|
|
415
|
+
date: optionalDateSchema,
|
|
416
|
+
},
|
|
417
|
+
handler: async ({ resource, period, date, }) => guard(`${resource} for period '${period}'`, async () => {
|
|
418
|
+
const ctx = await getUserContext(t);
|
|
419
|
+
const end = date ?? todayInZone(ctx.timeZone);
|
|
420
|
+
return fetchSeries(resource, periodStartDate(end, period), end);
|
|
421
|
+
}),
|
|
422
|
+
});
|
|
423
|
+
registerTool(server, {
|
|
424
|
+
name: 'get_nutrition_by_date_range',
|
|
425
|
+
description: "Fitbit-compatible nutrition time series for a date range (start and end INCLUSIVE, max 1095 days). resource is caloriesIn (kcal) or water (account unit). Same shape as get_nutrition.",
|
|
426
|
+
parametersSchema: {
|
|
427
|
+
resource: z.enum(NUTRITION_RESOURCES).describe('The nutrition resource to retrieve'),
|
|
428
|
+
...rangeParams,
|
|
429
|
+
},
|
|
430
|
+
handler: async ({ resource, startDate, endDate, }) => guard(`${resource} from ${startDate} to ${endDate}`, () => fetchSeries(resource, startDate, endDate)),
|
|
431
|
+
});
|
|
432
|
+
}
|
package/build/config.js
CHANGED
|
@@ -1,56 +1,126 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Centralized configuration for the MCP
|
|
2
|
+
* Centralized configuration for the Google Health MCP server
|
|
3
3
|
* Contains all constants, API URLs, and shared configurations
|
|
4
4
|
*/
|
|
5
5
|
// === API Configuration ===
|
|
6
|
-
export const
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
6
|
+
export const GOOGLE_HEALTH_API_BASE = 'https://health.googleapis.com/v4';
|
|
7
|
+
const SCOPE_PREFIX = 'https://www.googleapis.com/auth/googlehealth';
|
|
8
|
+
export const GOOGLE_OAUTH_CONFIG = {
|
|
9
|
+
TOKEN_HOST: 'https://oauth2.googleapis.com',
|
|
10
|
+
TOKEN_PATH: '/token',
|
|
11
|
+
AUTHORIZE_HOST: 'https://accounts.google.com',
|
|
12
|
+
AUTHORIZE_PATH: '/o/oauth2/v2/auth',
|
|
13
13
|
CALLBACK_URI: 'http://localhost:3000/callback',
|
|
14
14
|
SCOPES: [
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
].join(' ')
|
|
15
|
+
`${SCOPE_PREFIX}.activity_and_fitness.readonly`,
|
|
16
|
+
`${SCOPE_PREFIX}.health_metrics_and_measurements.readonly`,
|
|
17
|
+
`${SCOPE_PREFIX}.sleep.readonly`,
|
|
18
|
+
`${SCOPE_PREFIX}.nutrition.readonly`,
|
|
19
|
+
`${SCOPE_PREFIX}.profile.readonly`,
|
|
20
|
+
`${SCOPE_PREFIX}.settings.readonly`,
|
|
21
|
+
].join(' '),
|
|
22
22
|
};
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
/**
|
|
24
|
+
* Resolves OAuth client credentials from the environment.
|
|
25
|
+
* GOOGLE_HEALTH_* is preferred; the legacy FITBIT_* names are accepted as a fallback.
|
|
26
|
+
*/
|
|
27
|
+
export function resolveClientCredentials() {
|
|
28
|
+
return {
|
|
29
|
+
id: process.env.GOOGLE_HEALTH_CLIENT_ID || process.env.FITBIT_CLIENT_ID || '',
|
|
30
|
+
secret: process.env.GOOGLE_HEALTH_CLIENT_SECRET ||
|
|
31
|
+
process.env.FITBIT_CLIENT_SECRET ||
|
|
32
|
+
'',
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export function resolveOutputMode() {
|
|
36
|
+
const value = (process.env.GOOGLE_HEALTH_OUTPUT_MODE || 'google').toLowerCase();
|
|
37
|
+
return value === 'fitbit' ? 'fitbit' : 'google';
|
|
38
|
+
}
|
|
39
|
+
// === Data Types ===
|
|
40
|
+
// All data types exposed by the Google Health API (kebab-case, as used in URL paths)
|
|
41
|
+
export const DATA_TYPES = [
|
|
42
|
+
'active-energy-burned',
|
|
43
|
+
'active-minutes',
|
|
44
|
+
'active-zone-minutes',
|
|
45
|
+
'activity-level',
|
|
46
|
+
'altitude',
|
|
47
|
+
'blood-glucose',
|
|
48
|
+
'body-fat',
|
|
49
|
+
'calories-in-heart-rate-zone',
|
|
50
|
+
'core-body-temperature',
|
|
51
|
+
'daily-heart-rate-variability',
|
|
52
|
+
'daily-heart-rate-zones',
|
|
53
|
+
'daily-oxygen-saturation',
|
|
54
|
+
'daily-respiratory-rate',
|
|
55
|
+
'daily-resting-heart-rate',
|
|
56
|
+
'daily-sleep-temperature-derivations',
|
|
57
|
+
'daily-vo2-max',
|
|
58
|
+
'distance',
|
|
59
|
+
'electrocardiogram',
|
|
60
|
+
'exercise',
|
|
61
|
+
'floors',
|
|
62
|
+
'food',
|
|
63
|
+
'food-measurement-unit',
|
|
64
|
+
'heart-rate',
|
|
65
|
+
'heart-rate-variability',
|
|
66
|
+
'height',
|
|
67
|
+
'hydration-log',
|
|
68
|
+
'irregular-rhythm-notification',
|
|
69
|
+
'nutrition-log',
|
|
70
|
+
'oxygen-saturation',
|
|
71
|
+
'respiratory-rate-sleep-summary',
|
|
72
|
+
'run-vo2-max',
|
|
73
|
+
'sedentary-period',
|
|
74
|
+
'sleep',
|
|
75
|
+
'steps',
|
|
76
|
+
'swim-lengths-data',
|
|
77
|
+
'time-in-heart-rate-zone',
|
|
78
|
+
'total-calories',
|
|
79
|
+
'vo2-max',
|
|
80
|
+
'weight',
|
|
81
|
+
];
|
|
82
|
+
export const DATA_SOURCE_FAMILIES = [
|
|
83
|
+
'users/me/dataSourceFamilies/all-sources',
|
|
84
|
+
'users/me/dataSourceFamilies/google-wearables',
|
|
85
|
+
'users/me/dataSourceFamilies/google-sources',
|
|
86
|
+
];
|
|
87
|
+
// === Common Validation ===
|
|
88
|
+
// YYYY-MM-DD
|
|
26
89
|
export const DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/;
|
|
27
90
|
export const DATE_FORMAT_ERROR = 'Date must be in YYYY-MM-DD format';
|
|
28
|
-
|
|
29
|
-
|
|
91
|
+
// Civil time: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS (no UTC offset)
|
|
92
|
+
export const CIVIL_TIME_REGEX = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2})?$/;
|
|
93
|
+
export const CIVIL_TIME_FORMAT_ERROR = "Must be a civil time: 'YYYY-MM-DD' or 'YYYY-MM-DDTHH:MM:SS' (no UTC offset)";
|
|
94
|
+
// RFC 3339 timestamp with offset, e.g. 2026-03-04T00:00:00Z
|
|
95
|
+
export const RFC3339_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
|
|
96
|
+
export const RFC3339_FORMAT_ERROR = "Must be an RFC 3339 timestamp, e.g. '2026-03-04T00:00:00Z'";
|
|
30
97
|
export const VALIDATION_MESSAGES = {
|
|
31
98
|
DATE_FORMAT: DATE_FORMAT_ERROR,
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
MAX_RANGE_31_DAYS: 'Note: The API enforces a maximum range of 31 days'
|
|
99
|
+
START_DATE_REQUIRED: 'The first date of the range, inclusive (YYYY-MM-DD)',
|
|
100
|
+
END_DATE_REQUIRED: 'The end of the range, EXCLUSIVE (YYYY-MM-DD). To include a day, pass the following day',
|
|
101
|
+
MAX_RANGE_90_DAYS: 'Note: The API allows at most 90 days per request',
|
|
102
|
+
MAX_RANGE_14_DAYS: 'Note: The API allows at most 14 days per request for this data type',
|
|
103
|
+
PAGINATION: 'Use nextPageToken from the response as pageToken to fetch the next page',
|
|
38
104
|
};
|
|
39
105
|
// === HTTP Configuration ===
|
|
40
106
|
export const HTTP_CONFIG = {
|
|
41
|
-
USER_AGENT: 'mcp-
|
|
107
|
+
USER_AGENT: 'mcp-google-health-server/2.0',
|
|
42
108
|
TIMEOUT_MS: 30000,
|
|
43
|
-
MAX_RETRIES: 3
|
|
109
|
+
MAX_RETRIES: 3,
|
|
110
|
+
RETRY_BASE_DELAY_MS: 500,
|
|
111
|
+
// Status codes the Google Health docs recommend retrying with exponential backoff
|
|
112
|
+
RETRYABLE_STATUSES: [429, 504],
|
|
44
113
|
};
|
|
45
114
|
// === File Paths ===
|
|
46
115
|
export const FILE_PATHS = {
|
|
47
|
-
TOKEN_STORAGE: '.
|
|
116
|
+
TOKEN_STORAGE: '.google-health-token.json',
|
|
48
117
|
};
|
|
49
118
|
// === Error Messages ===
|
|
50
119
|
export const ERROR_MESSAGES = {
|
|
51
|
-
NO_ACCESS_TOKEN: 'No
|
|
52
|
-
API_REQUEST_FAILED: 'Failed to retrieve data from
|
|
120
|
+
NO_ACCESS_TOKEN: 'No Google Health access token available. Please authorize first.',
|
|
121
|
+
API_REQUEST_FAILED: 'Failed to retrieve data from Google Health API',
|
|
53
122
|
TOKEN_EXPIRED: 'Access token might be expired or invalid. Re-authorization may be needed.',
|
|
54
123
|
NO_DATA_FOUND: 'No data found for the specified period/range',
|
|
55
|
-
CHECK_TOKEN_PERMISSIONS: 'Check token and permissions'
|
|
124
|
+
CHECK_TOKEN_PERMISSIONS: 'Check token and permissions',
|
|
125
|
+
ACCOUNT_NOT_LINKED: 'This Google Account is not linked to Google Health. Open the Google Health mobile app, choose "Sign in with Google" with this account, then authorize again.',
|
|
56
126
|
};
|
package/build/daily-activity.js
CHANGED
|
@@ -1,4 +1,14 @@
|
|
|
1
|
-
import { registerTool, CommonSchemas,
|
|
1
|
+
import { registerTool, CommonSchemas, createErrorResponse, createSuccessResponse, makeGoogleHealthRequest, dataPointsPath, buildDailyRollUpBody, nextDate, GoogleHealthApiError, } from './utils.js';
|
|
2
|
+
import { ERROR_MESSAGES } from './config.js';
|
|
3
|
+
// The Google Health API has no single "daily summary" endpoint, so the summary
|
|
4
|
+
// is the daily roll-up of each of these data types for the requested date.
|
|
5
|
+
export const DAILY_SUMMARY_DATA_TYPES = [
|
|
6
|
+
'steps',
|
|
7
|
+
'distance',
|
|
8
|
+
'total-calories',
|
|
9
|
+
'active-zone-minutes',
|
|
10
|
+
'floors',
|
|
11
|
+
];
|
|
2
12
|
/**
|
|
3
13
|
* Registers a daily activity summary tool with the MCP server.
|
|
4
14
|
* @param server The McpServer instance.
|
|
@@ -7,15 +17,33 @@ import { registerTool, CommonSchemas, handleFitbitApiCall } from './utils.js';
|
|
|
7
17
|
export function registerDailyActivityTool(server, getAccessTokenFn) {
|
|
8
18
|
registerTool(server, {
|
|
9
19
|
name: 'get_daily_activity_summary',
|
|
10
|
-
description:
|
|
20
|
+
description: `Get the raw JSON daily roll-ups from the Google Health API for a single date, one dailyRollUp call per data type (${DAILY_SUMMARY_DATA_TYPES.join(', ')}), returned together keyed by data type. A data type that fails is reported under its key as an error.`,
|
|
11
21
|
parametersSchema: {
|
|
12
22
|
date: CommonSchemas.date,
|
|
13
23
|
},
|
|
14
24
|
handler: async ({ date }) => {
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
25
|
+
const body = buildDailyRollUpBody(date, nextDate(date));
|
|
26
|
+
const entries = await Promise.all(DAILY_SUMMARY_DATA_TYPES.map(async (dataType) => {
|
|
27
|
+
try {
|
|
28
|
+
const result = await makeGoogleHealthRequest(dataPointsPath(dataType, ':dailyRollUp'), getAccessTokenFn, { method: 'POST', body });
|
|
29
|
+
return [
|
|
30
|
+
dataType,
|
|
31
|
+
result ?? { error: ERROR_MESSAGES.NO_ACCESS_TOKEN },
|
|
32
|
+
];
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
const message = error instanceof GoogleHealthApiError
|
|
36
|
+
? `HTTP ${error.status}: ${error.body}`
|
|
37
|
+
: String(error);
|
|
38
|
+
return [dataType, { error: message }];
|
|
39
|
+
}
|
|
40
|
+
}));
|
|
41
|
+
const summary = Object.fromEntries(entries);
|
|
42
|
+
const allFailed = entries.every(([, value]) => 'error' in value);
|
|
43
|
+
if (allFailed) {
|
|
44
|
+
return createErrorResponse(`${ERROR_MESSAGES.API_REQUEST_FAILED} for date '${date}': ${JSON.stringify(summary)}`);
|
|
45
|
+
}
|
|
46
|
+
return createSuccessResponse(summary);
|
|
47
|
+
},
|
|
20
48
|
});
|
|
21
49
|
}
|