@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/build/utils.js CHANGED
@@ -1,60 +1,140 @@
1
1
  /**
2
- * Shared utility functions for Fitbit API integration
2
+ * Shared utility functions for Google Health API integration
3
3
  */
4
4
  import { z } from 'zod';
5
- import { HTTP_CONFIG, ERROR_MESSAGES, FITBIT_API_VERSIONS, DATE_REGEX, VALIDATION_MESSAGES, TIME_PERIODS, HEART_RATE_DETAIL_LEVELS } from './config.js';
5
+ import { HTTP_CONFIG, ERROR_MESSAGES, GOOGLE_HEALTH_API_BASE, DATE_REGEX, CIVIL_TIME_REGEX, CIVIL_TIME_FORMAT_ERROR, RFC3339_REGEX, RFC3339_FORMAT_ERROR, VALIDATION_MESSAGES, DATA_SOURCE_FAMILIES, } from './config.js';
6
6
  /**
7
- * Makes a generic request to the Fitbit API.
8
- * Handles adding the base URL, authorization header, and basic error handling.
9
- * @param endpoint The specific API endpoint path (e.g., '/body/weight/date/today/30d.json').
7
+ * Error thrown when the Google Health API returns a non-success status.
8
+ */
9
+ export class GoogleHealthApiError extends Error {
10
+ status;
11
+ body;
12
+ constructor(status, body, url) {
13
+ super(`Google Health API Error! Status: ${status}, URL: ${url}, Body: ${body}`);
14
+ this.name = 'GoogleHealthApiError';
15
+ this.status = status;
16
+ this.body = body;
17
+ }
18
+ }
19
+ function buildQueryString(query) {
20
+ if (!query)
21
+ return '';
22
+ const parts = Object.entries(query)
23
+ .filter(([, value]) => value !== undefined && value !== '')
24
+ .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
25
+ return parts.length > 0 ? `?${parts.join('&')}` : '';
26
+ }
27
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
28
+ /**
29
+ * Makes a request to the Google Health API.
30
+ * @param path Path relative to the API base, e.g. 'users/me/dataTypes/steps/dataPoints'
10
31
  * @param getAccessTokenFn A function that returns the current valid access token or null.
11
- * @param apiBase The base URL for the API (defaults to v1, can be overridden for different API versions)
12
- * @returns A promise resolving to the parsed JSON response (type T) or null if the request fails.
32
+ * @param options Method, query parameters and JSON body.
33
+ * @returns The parsed JSON response, or null when no access token is available.
34
+ * @throws GoogleHealthApiError for non-success responses (after retrying 429/504),
35
+ * or the underlying error for network failures.
13
36
  */
14
- export async function makeFitbitRequest(endpoint, getAccessTokenFn, apiBase = FITBIT_API_VERSIONS.V1) {
37
+ export async function makeGoogleHealthRequest(path, getAccessTokenFn, options = {}) {
15
38
  const currentAccessToken = await getAccessTokenFn();
16
39
  if (!currentAccessToken) {
17
40
  console.error(`Error: ${ERROR_MESSAGES.NO_ACCESS_TOKEN}`);
18
41
  return null;
19
42
  }
20
- // Ensure endpoint starts correctly relative to the user path
21
- const cleanEndpoint = endpoint.startsWith('/')
22
- ? endpoint.substring(1)
23
- : endpoint;
24
- // Construct the full URL including the user scope '-'.
25
- const url = `${apiBase}/user/-/${cleanEndpoint}`;
26
- console.error(`Attempting Fitbit API request to: ${url}`);
43
+ const { method = 'GET', query, body } = options;
44
+ const retryBaseDelayMs = options.retryBaseDelayMs ?? HTTP_CONFIG.RETRY_BASE_DELAY_MS;
45
+ const cleanPath = path.startsWith('/') ? path.substring(1) : path;
46
+ const url = `${GOOGLE_HEALTH_API_BASE}/${cleanPath}${buildQueryString(query)}`;
47
+ console.error(`Attempting Google Health API request: ${method} ${url}`);
27
48
  const headers = {
28
49
  'User-Agent': HTTP_CONFIG.USER_AGENT,
29
50
  Authorization: `Bearer ${currentAccessToken}`,
30
51
  Accept: 'application/json',
31
52
  };
32
- try {
33
- const response = await fetch(url, { headers });
34
- if (!response.ok) {
35
- const errorBody = await response.text();
36
- console.error(`Fitbit API Error! Status: ${response.status}, URL: ${url}, Body: ${errorBody}`);
37
- if (response.status === 401) {
38
- console.error(ERROR_MESSAGES.TOKEN_EXPIRED);
53
+ if (body !== undefined) {
54
+ headers['Content-Type'] = 'application/json';
55
+ }
56
+ for (let attempt = 0;; attempt++) {
57
+ let response;
58
+ try {
59
+ response = await fetch(url, {
60
+ method,
61
+ headers,
62
+ body: body !== undefined ? JSON.stringify(body) : undefined,
63
+ });
64
+ }
65
+ catch (error) {
66
+ console.error(`Error making Google Health request to ${url}:`, error);
67
+ throw error;
68
+ }
69
+ if (response.ok) {
70
+ // Handle potential empty response body (e.g., 204 No Content)
71
+ if (response.status === 204) {
72
+ return {};
39
73
  }
40
- return null;
74
+ return (await response.json());
75
+ }
76
+ const errorBody = await response.text();
77
+ const retryable = HTTP_CONFIG.RETRYABLE_STATUSES.includes(response.status);
78
+ if (retryable && attempt < HTTP_CONFIG.MAX_RETRIES) {
79
+ await sleep(retryBaseDelayMs * 2 ** attempt);
80
+ continue;
41
81
  }
42
- // Handle potential empty response body for certain success statuses (e.g., 204 No Content)
43
- if (response.status === 204) {
44
- return {}; // Return an empty object or appropriate type for no content
82
+ console.error(`Google Health API Error! Status: ${response.status}, URL: ${url}, Body: ${errorBody}`);
83
+ if (response.status === 401) {
84
+ console.error(ERROR_MESSAGES.TOKEN_EXPIRED);
45
85
  }
46
- return (await response.json());
86
+ throw new GoogleHealthApiError(response.status, errorBody, url);
47
87
  }
48
- catch (error) {
49
- console.error(`Error making Fitbit request to ${url}:`, error);
50
- return null;
88
+ }
89
+ // === Filter / path / range helpers ===
90
+ /** Path for the data points collection of a data type (kebab-case). */
91
+ export function dataPointsPath(dataType, suffix = '') {
92
+ return `users/me/dataTypes/${dataType}/dataPoints${suffix}`;
93
+ }
94
+ /** Filters use snake_case data type names, URLs use kebab-case. */
95
+ export function toFilterName(dataType) {
96
+ return dataType.replace(/-/g, '_');
97
+ }
98
+ /**
99
+ * Builds an AIP-160 style range filter on a field.
100
+ * Start is inclusive; end (optional) is exclusive.
101
+ */
102
+ export function buildRangeFilter(field, start, end) {
103
+ const clauses = [`${field} >= "${start}"`];
104
+ if (end) {
105
+ clauses.push(`${field} < "${end}"`);
51
106
  }
107
+ return clauses.join(' AND ');
108
+ }
109
+ /** Returns the calendar day after a YYYY-MM-DD date. */
110
+ export function nextDate(date) {
111
+ const d = new Date(`${date}T00:00:00Z`);
112
+ d.setUTCDate(d.getUTCDate() + 1);
113
+ return d.toISOString().substring(0, 10);
114
+ }
115
+ function toCivilDateTime(date) {
116
+ const [year, month, day] = date.split('-').map(Number);
117
+ return { date: { year, month, day }, time: {} };
118
+ }
119
+ /** Body for POST dataPoints:dailyRollUp over a closed-open civil date range. */
120
+ export function buildDailyRollUpBody(startDate, endDate, extra = {}) {
121
+ return {
122
+ range: {
123
+ start: toCivilDateTime(startDate),
124
+ end: toCivilDateTime(endDate),
125
+ },
126
+ windowSizeDays: extra.windowSizeDays ?? 1,
127
+ ...(extra.dataSourceFamily
128
+ ? { dataSourceFamily: extra.dataSourceFamily }
129
+ : {}),
130
+ };
52
131
  }
53
132
  // === Parameter Validation Schemas ===
54
133
  export const CommonSchemas = {
55
- period: z
56
- .enum(TIME_PERIODS)
57
- .describe(VALIDATION_MESSAGES.PERIOD_REQUIRED),
134
+ date: z
135
+ .string()
136
+ .regex(DATE_REGEX, VALIDATION_MESSAGES.DATE_FORMAT)
137
+ .describe('The date for which to retrieve data (YYYY-MM-DD)'),
58
138
  startDate: z
59
139
  .string()
60
140
  .regex(DATE_REGEX, VALIDATION_MESSAGES.DATE_FORMAT)
@@ -63,23 +143,39 @@ export const CommonSchemas = {
63
143
  .string()
64
144
  .regex(DATE_REGEX, VALIDATION_MESSAGES.DATE_FORMAT)
65
145
  .describe(VALIDATION_MESSAGES.END_DATE_REQUIRED),
66
- detailLevel: z
67
- .enum(HEART_RATE_DETAIL_LEVELS)
68
- .describe(VALIDATION_MESSAGES.DETAIL_LEVEL_REQUIRED),
69
- date: z
146
+ civilStartTime: z
70
147
  .string()
71
- .regex(DATE_REGEX, VALIDATION_MESSAGES.DATE_FORMAT)
72
- .describe('The date for which to retrieve data (YYYY-MM-DD)'),
73
- afterDate: z
148
+ .regex(CIVIL_TIME_REGEX, CIVIL_TIME_FORMAT_ERROR)
149
+ .describe("Start of the range, inclusive, as civil time ('YYYY-MM-DD' or 'YYYY-MM-DDTHH:MM:SS', user's local time)"),
150
+ civilEndTime: z
74
151
  .string()
75
- .regex(DATE_REGEX, VALIDATION_MESSAGES.DATE_FORMAT)
76
- .describe('Retrieve activities after this date (YYYY-MM-DD)'),
77
- limit: z
152
+ .regex(CIVIL_TIME_REGEX, CIVIL_TIME_FORMAT_ERROR)
153
+ .optional()
154
+ .describe("Optional end of the range, EXCLUSIVE, as civil time ('YYYY-MM-DD' or 'YYYY-MM-DDTHH:MM:SS')"),
155
+ physicalStartTime: z
156
+ .string()
157
+ .regex(RFC3339_REGEX, RFC3339_FORMAT_ERROR)
158
+ .describe("Start of the range, inclusive, as an RFC 3339 timestamp (e.g. '2026-03-04T00:00:00Z')"),
159
+ physicalEndTime: z
160
+ .string()
161
+ .regex(RFC3339_REGEX, RFC3339_FORMAT_ERROR)
162
+ .optional()
163
+ .describe("Optional end of the range, EXCLUSIVE, as an RFC 3339 timestamp (e.g. '2026-03-05T00:00:00Z')"),
164
+ pageSize: z
78
165
  .number()
166
+ .int()
79
167
  .min(1)
80
- .max(100)
168
+ .max(10000)
169
+ .optional()
170
+ .describe('Maximum number of data points to return. Most data types allow up to 10000; sleep and exercise are capped at 25'),
171
+ pageToken: z
172
+ .string()
173
+ .optional()
174
+ .describe(`Page token for pagination. ${VALIDATION_MESSAGES.PAGINATION}`),
175
+ dataSourceFamily: z
176
+ .enum(DATA_SOURCE_FAMILIES)
81
177
  .optional()
82
- .describe('Maximum number of items to return (1-100, default: 20)')
178
+ .describe('Optional data source family. Defaults to all sources (Google/Fitbit wearables plus third-party)'),
83
179
  };
84
180
  // === Tool Response Helpers ===
85
181
  export function createErrorResponse(message) {
@@ -96,16 +192,30 @@ export function createSuccessResponse(data) {
96
192
  }
97
193
  export function createNoDataResponse(context) {
98
194
  return {
99
- content: [{ type: 'text', text: `${ERROR_MESSAGES.NO_DATA_FOUND} ${context}.` }],
195
+ content: [
196
+ { type: 'text', text: `${ERROR_MESSAGES.NO_DATA_FOUND} ${context}.` },
197
+ ],
100
198
  };
101
199
  }
102
200
  export function registerTool(server, config) {
103
201
  server.tool(config.name, config.description, config.parametersSchema, config.handler);
104
202
  }
105
- // === Fitbit-Specific Tool Helpers ===
106
- export async function handleFitbitApiCall(endpoint, params, getAccessTokenFn, options = {}) {
107
- const { apiBase = FITBIT_API_VERSIONS.V1, successDataExtractor, noDataMessage, errorContext = JSON.stringify(params) } = options;
108
- const responseData = await makeFitbitRequest(endpoint, getAccessTokenFn, apiBase);
203
+ // === Google Health-Specific Tool Helpers ===
204
+ export async function handleGoogleHealthCall(path, getAccessTokenFn, options = {}) {
205
+ const { successDataExtractor, noDataMessage, errorContext = path, ...requestOptions } = options;
206
+ let responseData;
207
+ try {
208
+ responseData = await makeGoogleHealthRequest(path, getAccessTokenFn, requestOptions);
209
+ }
210
+ catch (error) {
211
+ if (error instanceof GoogleHealthApiError) {
212
+ if (error.status === 400 && error.body.includes('ACCOUNT_NOT_LINKED')) {
213
+ return createErrorResponse(ERROR_MESSAGES.ACCOUNT_NOT_LINKED);
214
+ }
215
+ return createErrorResponse(`${ERROR_MESSAGES.API_REQUEST_FAILED} for ${errorContext} (HTTP ${error.status}): ${error.body}`);
216
+ }
217
+ return createErrorResponse(`${ERROR_MESSAGES.API_REQUEST_FAILED} for ${errorContext}: ${error instanceof Error ? error.message : String(error)}`);
218
+ }
109
219
  if (!responseData) {
110
220
  return createErrorResponse(`${ERROR_MESSAGES.API_REQUEST_FAILED} for ${errorContext}. ${ERROR_MESSAGES.CHECK_TOKEN_PERMISSIONS}.`);
111
221
  }
@@ -118,3 +228,36 @@ export async function handleFitbitApiCall(endpoint, params, getAccessTokenFn, op
118
228
  }
119
229
  return createSuccessResponse(responseData);
120
230
  }
231
+ /**
232
+ * Lists data points of a data type with a filter (GET .../dataPoints).
233
+ */
234
+ export function listDataPoints(dataType, getAccessTokenFn, params, errorContext, suffix = '') {
235
+ return handleGoogleHealthCall(dataPointsPath(dataType, suffix), getAccessTokenFn, {
236
+ query: {
237
+ filter: params.filter,
238
+ pageSize: params.pageSize,
239
+ pageToken: params.pageToken,
240
+ },
241
+ successDataExtractor: (data) => data.dataPoints,
242
+ errorContext,
243
+ });
244
+ }
245
+ /**
246
+ * Rolls up data points of a data type per civil day (POST .../dataPoints:dailyRollUp).
247
+ * endDate is exclusive (closed-open range), as in the Google Health API.
248
+ */
249
+ export function dailyRollUp(dataType, getAccessTokenFn, params, errorContext) {
250
+ const body = {
251
+ ...buildDailyRollUpBody(params.startDate, params.endDate, {
252
+ dataSourceFamily: params.dataSourceFamily,
253
+ }),
254
+ ...(params.pageSize ? { pageSize: params.pageSize } : {}),
255
+ ...(params.pageToken ? { pageToken: params.pageToken } : {}),
256
+ };
257
+ return handleGoogleHealthCall(dataPointsPath(dataType, ':dailyRollUp'), getAccessTokenFn, {
258
+ method: 'POST',
259
+ body,
260
+ successDataExtractor: (data) => data.rollupDataPoints,
261
+ errorContext,
262
+ });
263
+ }
package/build/weight.js CHANGED
@@ -1,24 +1,23 @@
1
- import { registerTool, CommonSchemas, handleFitbitApiCall } from './utils.js';
2
- // --- Tool Registration ---
1
+ import { registerTool, CommonSchemas, listDataPoints, buildRangeFilter, } from './utils.js';
3
2
  /**
4
- * Registers a single, parameterized Fitbit weight tool with the MCP server.
3
+ * Registers the weight tool with the MCP server (Google Health `weight` data type).
5
4
  * @param server The McpServer instance.
6
5
  * @param getAccessTokenFn Function to retrieve the current access token.
7
6
  */
8
7
  export function registerWeightTool(server, getAccessTokenFn) {
9
8
  registerTool(server, {
10
9
  name: 'get_weight',
11
- description: "Get the raw JSON response for weight entries from Fitbit for a specified period ending today. Requires a 'period' parameter such as '1d', '7d', '30d', '3m', '6m', '1y'",
10
+ description: 'Get the raw JSON response for weight data points from the Google Health API (data type "weight"), filtered by sample time. Requires a start timestamp (RFC 3339); the end is optional and exclusive. Supports pagination via pageToken.',
12
11
  parametersSchema: {
13
- period: CommonSchemas.period,
12
+ startTime: CommonSchemas.physicalStartTime,
13
+ endTime: CommonSchemas.physicalEndTime,
14
+ pageSize: CommonSchemas.pageSize,
15
+ pageToken: CommonSchemas.pageToken,
14
16
  },
15
- handler: async ({ period }) => {
16
- const endpoint = `/body/weight/date/today/${period}.json`;
17
- return handleFitbitApiCall(endpoint, { period }, getAccessTokenFn, {
18
- successDataExtractor: (data) => data['body-weight'] || [],
19
- noDataMessage: `the period '${period}'`,
20
- errorContext: `period '${period}'`
21
- });
22
- }
17
+ handler: async ({ startTime, endTime, pageSize, pageToken, }) => listDataPoints('weight', getAccessTokenFn, {
18
+ filter: buildRangeFilter('weight.sample_time.physical_time', startTime, endTime),
19
+ pageSize,
20
+ pageToken,
21
+ }, `weight from '${startTime}'${endTime ? ` to '${endTime}'` : ''}`),
23
22
  });
24
23
  }
package/package.json CHANGED
@@ -1,74 +1,74 @@
1
- {
2
- "name": "@einlogic/mcp-fitbit",
3
- "version": "1.0.1",
4
- "type": "module",
5
- "main": "./build/index.js",
6
- "bin": {
7
- "mcp-fitbit": "./build/index.js",
8
- "mkoziel2000-mcp-fitbit": "./build/index.js"
9
- },
10
- "repository": {
11
- "type": "git",
12
- "url": "https://github.com/mkoziel2000/mcp-fitbit.git"
13
- },
14
- "homepage": "https://github.com/mkoziel2000/mcp-fitbit",
15
- "bugs": {
16
- "url": "https://github.com/mkoziel2000/mcp-fitbit/issues"
17
- },
18
- "scripts": {
19
- "test": "vitest run",
20
- "test:ui": "vitest --ui",
21
- "test:coverage": "vitest run --coverage",
22
- "build": "tsc",
23
- "start": "node build/index.js",
24
- "dev": "npm run build && npx @modelcontextprotocol/inspector node build/index.js",
25
- "lint": "eslint src/**/*.ts --ignore-pattern src/**/*.test.ts",
26
- "lint:fix": "eslint src/**/*.ts --fix",
27
- "format": "prettier --write src/**/*.ts",
28
- "format:check": "prettier --check src/**/*.ts",
29
- "prepublishOnly": "npm run build && npm run test && npm run lint",
30
- "prepack": "npm run build"
31
- },
32
- "keywords": [
33
- "mcp",
34
- "fitbit",
35
- "fitbit-api",
36
- "mcp-server",
37
- "claude",
38
- "model-context-protocol",
39
- "health-data",
40
- "fitness-tracking",
41
- "ai-assistant",
42
- "typescript"
43
- ],
44
- "author": "mkoziel2000",
45
- "license": "MIT",
46
- "description": "Fork of TheDigitalNinja's mcp-fitbit with the addition of the Heart Rate Variability (HRV) tool. MCP server for accessing Fitbit health and fitness data. Connect AI assistants like Claude to your Fitbit data for personalized health insights.",
47
- "files": [
48
- "build/",
49
- "README.md",
50
- "LICENSE"
51
- ],
52
- "dependencies": {
53
- "@modelcontextprotocol/sdk": "^1.11.0",
54
- "@types/express": "^5.0.1",
55
- "dotenv": "^16.5.0",
56
- "express": "^5.1.0",
57
- "open": "^10.1.2",
58
- "simple-oauth2": "^5.1.0",
59
- "zod": "^3.24.3"
60
- },
61
- "devDependencies": {
62
- "@eslint/js": "^9.27.0",
63
- "@types/node": "^22.15.3",
64
- "@types/simple-oauth2": "^5.0.7",
65
- "@typescript-eslint/eslint-plugin": "^8.32.1",
66
- "@typescript-eslint/parser": "^8.32.1",
67
- "@vitest/coverage-v8": "^3.1.4",
68
- "@vitest/ui": "^3.1.4",
69
- "eslint": "^9.27.0",
70
- "prettier": "^3.5.3",
71
- "typescript": "^5.8.3",
72
- "vitest": "^3.1.4"
73
- }
74
- }
1
+ {
2
+ "name": "@einlogic/mcp-fitbit",
3
+ "version": "2.1.0",
4
+ "type": "module",
5
+ "main": "./build/index.js",
6
+ "bin": {
7
+ "mcp-fitbit": "./build/index.js",
8
+ "mkoziel2000-mcp-fitbit": "./build/index.js"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "https://github.com/mkoziel2000/mcp-fitbit.git"
13
+ },
14
+ "homepage": "https://github.com/mkoziel2000/mcp-fitbit",
15
+ "bugs": {
16
+ "url": "https://github.com/mkoziel2000/mcp-fitbit/issues"
17
+ },
18
+ "scripts": {
19
+ "test": "vitest run",
20
+ "test:ui": "vitest --ui",
21
+ "test:coverage": "vitest run --coverage",
22
+ "build": "tsc",
23
+ "start": "node build/index.js",
24
+ "dev": "npm run build && npx @modelcontextprotocol/inspector node build/index.js",
25
+ "lint": "eslint src/**/*.ts --ignore-pattern src/**/*.test.ts",
26
+ "lint:fix": "eslint src/**/*.ts --fix",
27
+ "format": "prettier --write src/**/*.ts",
28
+ "format:check": "prettier --check src/**/*.ts",
29
+ "prepublishOnly": "npm run build && npm run test && npm run lint",
30
+ "prepack": "npm run build"
31
+ },
32
+ "keywords": [
33
+ "mcp",
34
+ "fitbit",
35
+ "google-health-api",
36
+ "mcp-server",
37
+ "claude",
38
+ "model-context-protocol",
39
+ "health-data",
40
+ "fitness-tracking",
41
+ "ai-assistant",
42
+ "typescript"
43
+ ],
44
+ "author": "mkoziel2000",
45
+ "license": "MIT",
46
+ "description": "MCP server for accessing Fitbit / Pixel Watch health and fitness data through the Google Health API. Connect AI assistants like Claude to your data for personalized health insights.",
47
+ "files": [
48
+ "build/",
49
+ "README.md",
50
+ "LICENSE"
51
+ ],
52
+ "dependencies": {
53
+ "@modelcontextprotocol/sdk": "^1.11.0",
54
+ "@types/express": "^5.0.1",
55
+ "dotenv": "^16.5.0",
56
+ "express": "^5.1.0",
57
+ "open": "^10.1.2",
58
+ "simple-oauth2": "^5.1.0",
59
+ "zod": "^3.24.3"
60
+ },
61
+ "devDependencies": {
62
+ "@eslint/js": "^9.27.0",
63
+ "@types/node": "^22.15.3",
64
+ "@types/simple-oauth2": "^5.0.7",
65
+ "@typescript-eslint/eslint-plugin": "^8.32.1",
66
+ "@typescript-eslint/parser": "^8.32.1",
67
+ "@vitest/coverage-v8": "^3.1.4",
68
+ "@vitest/ui": "^3.1.4",
69
+ "eslint": "^9.27.0",
70
+ "prettier": "^3.5.3",
71
+ "typescript": "^5.8.3",
72
+ "vitest": "^3.1.4"
73
+ }
74
+ }