mcp-fitbit
Version:
Model Context Protocol (MCP) server for accessing Fitbit health and fitness data. Connect AI assistants like Claude to your Fitbit data for personalized health insights.
121 lines (120 loc) • 4.99 kB
JavaScript
/**
* Shared utility functions for Fitbit API integration
*/
import { z } from 'zod';
import { HTTP_CONFIG, ERROR_MESSAGES, FITBIT_API_VERSIONS, DATE_REGEX, VALIDATION_MESSAGES, TIME_PERIODS, HEART_RATE_DETAIL_LEVELS } from './config.js';
/**
* Makes a generic request to the Fitbit API.
* Handles adding the base URL, authorization header, and basic error handling.
* @param endpoint The specific API endpoint path (e.g., '/body/weight/date/today/30d.json').
* @param getAccessTokenFn A function that returns the current valid access token or null.
* @param apiBase The base URL for the API (defaults to v1, can be overridden for different API versions)
* @returns A promise resolving to the parsed JSON response (type T) or null if the request fails.
*/
export async function makeFitbitRequest(endpoint, getAccessTokenFn, apiBase = FITBIT_API_VERSIONS.V1) {
const currentAccessToken = await getAccessTokenFn();
if (!currentAccessToken) {
console.error(`Error: ${ERROR_MESSAGES.NO_ACCESS_TOKEN}`);
return null;
}
// Ensure endpoint starts correctly relative to the user path
const cleanEndpoint = endpoint.startsWith('/')
? endpoint.substring(1)
: endpoint;
// Construct the full URL including the user scope '-'.
const url = `${apiBase}/user/-/${cleanEndpoint}`;
console.error(`Attempting Fitbit API request to: ${url}`);
const headers = {
'User-Agent': HTTP_CONFIG.USER_AGENT,
Authorization: `Bearer ${currentAccessToken}`,
Accept: 'application/json',
};
try {
const response = await fetch(url, { headers });
if (!response.ok) {
const errorBody = await response.text();
console.error(`Fitbit API Error! Status: ${response.status}, URL: ${url}, Body: ${errorBody}`);
if (response.status === 401) {
console.error(ERROR_MESSAGES.TOKEN_EXPIRED);
}
return null;
}
// Handle potential empty response body for certain success statuses (e.g., 204 No Content)
if (response.status === 204) {
return {}; // Return an empty object or appropriate type for no content
}
return (await response.json());
}
catch (error) {
console.error(`Error making Fitbit request to ${url}:`, error);
return null;
}
}
// === Parameter Validation Schemas ===
export const CommonSchemas = {
period: z
.enum(TIME_PERIODS)
.describe(VALIDATION_MESSAGES.PERIOD_REQUIRED),
startDate: z
.string()
.regex(DATE_REGEX, VALIDATION_MESSAGES.DATE_FORMAT)
.describe(VALIDATION_MESSAGES.START_DATE_REQUIRED),
endDate: z
.string()
.regex(DATE_REGEX, VALIDATION_MESSAGES.DATE_FORMAT)
.describe(VALIDATION_MESSAGES.END_DATE_REQUIRED),
detailLevel: z
.enum(HEART_RATE_DETAIL_LEVELS)
.describe(VALIDATION_MESSAGES.DETAIL_LEVEL_REQUIRED),
date: z
.string()
.regex(DATE_REGEX, VALIDATION_MESSAGES.DATE_FORMAT)
.describe('The date for which to retrieve data (YYYY-MM-DD)'),
afterDate: z
.string()
.regex(DATE_REGEX, VALIDATION_MESSAGES.DATE_FORMAT)
.describe('Retrieve activities after this date (YYYY-MM-DD)'),
limit: z
.number()
.min(1)
.max(100)
.optional()
.describe('Maximum number of items to return (1-100, default: 20)')
};
// === Tool Response Helpers ===
export function createErrorResponse(message) {
return {
content: [{ type: 'text', text: message }],
isError: true,
};
}
export function createSuccessResponse(data) {
const rawJsonResponse = JSON.stringify(data, null, 2);
return {
content: [{ type: 'text', text: rawJsonResponse }],
};
}
export function createNoDataResponse(context) {
return {
content: [{ type: 'text', text: `${ERROR_MESSAGES.NO_DATA_FOUND} ${context}.` }],
};
}
export function registerTool(server, config) {
server.tool(config.name, config.description, config.parametersSchema, config.handler);
}
// === Fitbit-Specific Tool Helpers ===
export async function handleFitbitApiCall(endpoint, params, getAccessTokenFn, options = {}) {
const { apiBase = FITBIT_API_VERSIONS.V1, successDataExtractor, noDataMessage, errorContext = JSON.stringify(params) } = options;
const responseData = await makeFitbitRequest(endpoint, getAccessTokenFn, apiBase);
if (!responseData) {
return createErrorResponse(`${ERROR_MESSAGES.API_REQUEST_FAILED} for ${errorContext}. ${ERROR_MESSAGES.CHECK_TOKEN_PERMISSIONS}.`);
}
// Check for empty data if extractor provided
if (successDataExtractor) {
const extractedData = successDataExtractor(responseData);
if (!extractedData || extractedData.length === 0) {
return createNoDataResponse(noDataMessage || errorContext);
}
}
return createSuccessResponse(responseData);
}