Skip to main content
Glama

Withings MCP Client

by gchallen
withingsClient.ts19.2 kB
import axios from 'axios'; import * as fs from 'fs/promises'; import * as path from 'path'; import { WithingsConfig, WithingsApiResponse, TokenResponse, MeasureType, WithingsMeasureGroup, WithingsMeasure, } from './types.js'; export class WithingsClient { private config: WithingsConfig; private configPath: string; private baseUrl = 'https://wbsapi.withings.net'; private authUrl = 'https://account.withings.com'; private tokenUrl = 'https://wbsapi.withings.net/v2/oauth2'; // In-memory access token cache private cachedAccessToken?: string; private accessTokenExpiry?: Date; constructor(config: WithingsConfig) { this.config = config; this.configPath = path.join(process.env.HOME || '~', '.withings-mcp', 'tokens.json'); } async loadTokens(): Promise<void> { // Try to load refresh token from file first (gets updated during token refresh) // Fall back to environment variables if file doesn't exist try { const data = await fs.readFile(this.configPath, 'utf-8'); const tokens = JSON.parse(data); if (tokens.refreshToken) { this.config.refreshToken = tokens.refreshToken; return; } } catch (error) { // File doesn't exist or is invalid, continue to environment variables } // Fallback to environment variables (for initial setup) if (process.env.WITHINGS_REFRESH_TOKEN) { this.config.refreshToken = process.env.WITHINGS_REFRESH_TOKEN; return; } console.log('No saved refresh token found'); } async saveTokens(): Promise<void> { const dir = path.dirname(this.configPath); await fs.mkdir(dir, { recursive: true }); await fs.writeFile( this.configPath, JSON.stringify({ refreshToken: this.config.refreshToken, }) ); } async getValidAccessToken(): Promise<string> { // Check if we have a cached access token that hasn't expired if (this.cachedAccessToken && this.accessTokenExpiry && this.accessTokenExpiry > new Date()) { return this.cachedAccessToken; } // No valid cached token, refresh it await this.refreshAccessToken(); if (!this.cachedAccessToken) { throw new Error('Failed to obtain valid access token'); } return this.cachedAccessToken; } getAccessToken(): string | undefined { return this.cachedAccessToken; } getRefreshToken(): string | undefined { return this.config.refreshToken; } getDefaultUserAttrib(): number | undefined { const envUserAttrib = process.env.WITHINGS_USER_ATTRIB; return envUserAttrib ? parseInt(envUserAttrib) : undefined; } getDefaultUnit(): 'metric' | 'imperial' { const envUnit = process.env.WITHINGS_UNIT_SYSTEM; return envUnit === 'imperial' ? 'imperial' : 'metric'; } private convertWeight(kg: number, targetUnit?: 'metric' | 'imperial'): { value: number; unit: string } { const unit = targetUnit ?? this.getDefaultUnit(); if (unit === 'imperial') { return { value: kg * 2.20462, unit: 'lb' }; } return { value: kg, unit: 'kg' }; } private convertMass(kg: number, targetUnit?: 'metric' | 'imperial'): { value: number; unit: string } { return this.convertWeight(kg, targetUnit); // Same conversion as weight } getAuthorizationUrl(): string { const params = new URLSearchParams({ response_type: 'code', client_id: this.config.clientId, redirect_uri: this.config.redirectUri, scope: 'user.info,user.metrics,user.activity', state: Math.random().toString(36).substring(7), }); return `${this.authUrl}/oauth2_user/authorize2?${params}`; } async exchangeCodeForToken(code: string): Promise<void> { const params = new URLSearchParams({ action: 'requesttoken', grant_type: 'authorization_code', client_id: this.config.clientId, client_secret: this.config.clientSecret, code, redirect_uri: this.config.redirectUri, }); try { const response = await axios.post<TokenResponse>( this.tokenUrl, params.toString(), { headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json', }, } ); if (response.data.status !== 0) { throw new Error(`Withings API error: ${response.data.error || 'Unknown error'}`); } if (!response.data.body?.access_token || !response.data.body?.refresh_token) { throw new Error(`Invalid token response: ${JSON.stringify(response.data)}`); } // Cache access token in memory with expiration (expires in 3 hours, cache for 2.5 hours to be safe) this.cachedAccessToken = response.data.body.access_token; this.accessTokenExpiry = new Date(Date.now() + 2.5 * 60 * 60 * 1000); // 2.5 hours from now // Store only the refresh token persistently this.config.refreshToken = response.data.body.refresh_token; await this.saveTokens(); } catch (error: any) { if (error.response) { console.error('Withings API error:', error.response.status, error.response.data); throw new Error(`Withings API error: ${error.response.status} - ${JSON.stringify(error.response.data)}`); } throw error; } } async refreshAccessToken(): Promise<void> { if (!this.config.refreshToken) { throw new Error('No refresh token available'); } const params = new URLSearchParams({ action: 'requesttoken', grant_type: 'refresh_token', client_id: this.config.clientId, client_secret: this.config.clientSecret, refresh_token: this.config.refreshToken, }); const response = await axios.post<TokenResponse>( this.tokenUrl, params.toString(), { headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json', }, } ); if (response.data.status !== 0) { throw new Error(`Withings API error: ${response.data.error || 'Unknown error'}`); } if (!response.data.body?.access_token || !response.data.body?.refresh_token) { throw new Error(`Invalid refresh token response: ${JSON.stringify(response.data)}`); } // Cache access token in memory with expiration (expires in 3 hours, cache for 2.5 hours to be safe) this.cachedAccessToken = response.data.body.access_token; this.accessTokenExpiry = new Date(Date.now() + 2.5 * 60 * 60 * 1000); // 2.5 hours from now // Update refresh token and save only that to persistent storage this.config.refreshToken = response.data.body.refresh_token; await this.saveTokens(); } async makeApiRequest(endpoint: string, params: Record<string, any>): Promise<any> { // Ensure we have a valid access token (will refresh if needed) const accessToken = await this.getValidAccessToken(); try { const response = await axios.get(`${this.baseUrl}${endpoint}`, { headers: { Authorization: `Bearer ${accessToken}`, }, params, }); // Check if Withings API returned an authentication error // Status 401 in the response body means invalid/expired access token // Also check for "invalid_token" in the error message if (response.data.status === 401 || (response.data.error && response.data.error.includes('invalid_token'))) { try { // Try to refresh the token await this.refreshAccessToken(); // Retry the request with the new access token const retryResponse = await axios.get(`${this.baseUrl}${endpoint}`, { headers: { Authorization: `Bearer ${this.cachedAccessToken}`, }, params, }); return retryResponse.data; } catch (refreshError: any) { // If refresh fails, it might mean the refresh token is also invalid // Check if it's a refresh token error if (refreshError.message.includes('invalid refresh_token') || refreshError.message.includes('Invalid Params')) { throw new Error( 'Both access and refresh tokens are invalid. Please run "bun tokens" to re-authenticate.' ); } throw refreshError; } } return response.data; } catch (error: any) { // Also handle HTTP 401 status (though Withings typically uses status in body) if (error.response?.status === 401) { try { await this.refreshAccessToken(); const response = await axios.get(`${this.baseUrl}${endpoint}`, { headers: { Authorization: `Bearer ${this.cachedAccessToken}`, }, params, }); return response.data; } catch (refreshError: any) { if (refreshError.message.includes('invalid refresh_token') || refreshError.message.includes('Invalid Params')) { throw new Error( 'Both access and refresh tokens are invalid. Please run "bun tokens" to re-authenticate.' ); } throw refreshError; } } throw error; } } async getMeasures( meastype?: number[], startdate?: number, enddate?: number, userAttrib?: number, limit?: number, offset?: number ): Promise<WithingsMeasureGroup[]> { const params: Record<string, any> = { action: 'getmeas', }; if (meastype && meastype.length > 0) { params.meastype = meastype.join(','); } if (startdate) { params.startdate = startdate; } if (enddate) { params.enddate = enddate; } if (limit !== undefined) { params.limit = limit; } if (offset !== undefined) { params.offset = offset; } const response: WithingsApiResponse = await this.makeApiRequest('/measure', params); if (response.status !== 0) { throw new Error(`API Error: ${response.error || 'Unknown error'}`); } let measureGroups = response.body?.measuregrps || []; // Filter by user attribution if specified if (userAttrib !== undefined) { measureGroups = measureGroups.filter(group => group.attrib === userAttrib); } return measureGroups; } async getMeasuresWithPagination( meastype?: number[], startdate?: number, enddate?: number, userAttrib?: number, limit?: number, offset?: number ): Promise<{ measures: WithingsMeasureGroup[]; pagination: { limit?: number; offset?: number; more?: boolean; total_returned: number; }; }> { const params: Record<string, any> = { action: 'getmeas', }; if (meastype && meastype.length > 0) { params.meastype = meastype.join(','); } if (startdate) { params.startdate = startdate; } if (enddate) { params.enddate = enddate; } if (limit !== undefined) { params.limit = limit; } if (offset !== undefined) { params.offset = offset; } const response: WithingsApiResponse = await this.makeApiRequest('/measure', params); if (response.status !== 0) { throw new Error(`API Error: ${response.error || 'Unknown error'}`); } let measureGroups = response.body?.measuregrps || []; // Filter by user attribution if specified if (userAttrib !== undefined) { measureGroups = measureGroups.filter(group => group.attrib === userAttrib); } return { measures: measureGroups, pagination: { limit, offset, more: response.body?.more || false, total_returned: measureGroups.length, }, }; } async getLatestWeight(userAttrib?: number, unitSystem?: 'metric' | 'imperial'): Promise<{ value: number; unit: string; date: string; timestamp: number } | null> { const effectiveUserAttrib = userAttrib ?? this.getDefaultUserAttrib(); const measures = await this.getMeasures([MeasureType.WEIGHT], undefined, undefined, effectiveUserAttrib); if (measures.length === 0) { return null; } const latestGroup = measures[0]; const weightMeasure = latestGroup.measures.find((m: WithingsMeasure) => m.type === MeasureType.WEIGHT); if (!weightMeasure) { return null; } const weightKg = weightMeasure.value * Math.pow(10, weightMeasure.unit); const weightData = this.convertWeight(weightKg, unitSystem); return { value: weightData.value, unit: weightData.unit, date: new Date(latestGroup.date * 1000).toISOString(), timestamp: latestGroup.date }; } async getBodyComposition(userAttrib?: number, unitSystem?: 'metric' | 'imperial'): Promise<Record<string, number | string>> { const effectiveUserAttrib = userAttrib ?? this.getDefaultUserAttrib(); const effectiveUnitSystem = unitSystem ?? this.getDefaultUnit(); const measTypes = [ MeasureType.WEIGHT, MeasureType.FAT_FREE_MASS, // Type 5 - Fat Free Mass (kg) MeasureType.FAT_RATIO_PERCENTAGE, // Type 6 - Fat Ratio (%) MeasureType.FAT_MASS_WEIGHT, // Type 8 - Fat Mass Weight (kg) MeasureType.MUSCLE_MASS_WEIGHT, // Type 76 - Muscle Mass (kg) MeasureType.HYDRATION_PERCENTAGE, // Type 77 MeasureType.BONE_MASS, // Type 88 - Bone Mass (kg) MeasureType.UNKNOWN_170, // Type 170 - possibly visceral fat ]; const measures = await this.getMeasures(measTypes, undefined, undefined, effectiveUserAttrib); const composition: Record<string, number | string> = {}; // Find the latest measurement for each type across all groups const latestMeasurements: Record<number, { value: number; date: number }> = {}; for (const group of measures) { for (const measure of group.measures) { const currentLatest = latestMeasurements[measure.type]; if (!currentLatest || group.date > currentLatest.date) { latestMeasurements[measure.type] = { value: measure.value * Math.pow(10, measure.unit), date: group.date, }; } } } // Convert to readable format with unit conversion const massUnit = effectiveUnitSystem === 'imperial' ? 'lb' : 'kg'; for (const [typeStr, data] of Object.entries(latestMeasurements)) { const type = parseInt(typeStr); const value = data.value; switch (type) { case MeasureType.WEIGHT: const weight = this.convertWeight(value, effectiveUnitSystem); composition[`weight_${weight.unit}`] = parseFloat(weight.value.toFixed(2)); break; case MeasureType.FAT_FREE_MASS: // Type 5 - Fat Free Mass (kg) const fatFreeMass = this.convertMass(value, effectiveUnitSystem); composition[`fat_free_mass_${fatFreeMass.unit}`] = parseFloat(fatFreeMass.value.toFixed(2)); break; case MeasureType.FAT_RATIO_PERCENTAGE: // Type 6 - Fat Ratio (%) - official documentation composition.fat_percentage = parseFloat(value.toFixed(1)); break; case MeasureType.FAT_MASS_WEIGHT: // Type 8 - Fat Mass Weight (kg) const fatMass = this.convertMass(value, effectiveUnitSystem); composition[`fat_mass_${fatMass.unit}`] = parseFloat(fatMass.value.toFixed(2)); break; case MeasureType.MUSCLE_MASS_WEIGHT: // Type 76 - Muscle Mass (kg) const muscleMass = this.convertMass(value, effectiveUnitSystem); composition[`muscle_mass_${muscleMass.unit}`] = parseFloat(muscleMass.value.toFixed(2)); // Calculate muscle percentage from mass const weightKgForMuscle = latestMeasurements[MeasureType.WEIGHT]?.value; if (weightKgForMuscle) { composition.muscle_percentage = parseFloat((value / weightKgForMuscle * 100).toFixed(1)); } break; case MeasureType.HYDRATION_PERCENTAGE: // Type 77 composition.hydration_percentage = parseFloat(value.toFixed(1)); break; case MeasureType.BONE_MASS: // Type 88 - Bone Mass (kg) - official documentation const boneMass = this.convertMass(value, effectiveUnitSystem); composition[`bone_mass_${boneMass.unit}`] = parseFloat(boneMass.value.toFixed(2)); // Calculate bone percentage from mass const weightKgForBone = latestMeasurements[MeasureType.WEIGHT]?.value; if (weightKgForBone) { composition.bone_percentage = parseFloat((value / weightKgForBone * 100).toFixed(1)); } break; case MeasureType.UNKNOWN_170: // Type 170 - possibly visceral fat index composition.visceral_fat_index = parseFloat(value.toFixed(1)); break; } } // Add unit system and measurement date composition.unit_system = effectiveUnitSystem; if (Object.keys(latestMeasurements).length > 0) { const mostRecentDate = Math.max(...Object.values(latestMeasurements).map(m => m.date)); composition.measurement_date = new Date(mostRecentDate * 1000).toISOString(); composition.measurement_timestamp = mostRecentDate; } return composition; } async getAvailableUsers(): Promise<{ attrib: number; count: number; latestDate: string }[]> { // Get recent measurements to see which users have data const measures = await this.getMeasures([MeasureType.WEIGHT]); const userStats: Record<number, { count: number; latestDate: number }> = {}; for (const group of measures) { if (!userStats[group.attrib]) { userStats[group.attrib] = { count: 0, latestDate: 0 }; } userStats[group.attrib].count++; if (group.date > userStats[group.attrib].latestDate) { userStats[group.attrib].latestDate = group.date; } } return Object.entries(userStats).map(([attrib, stats]) => ({ attrib: parseInt(attrib), count: stats.count, latestDate: new Date(stats.latestDate * 1000).toISOString(), })).sort((a, b) => b.latestDate.localeCompare(a.latestDate)); } async getUserSettings(): Promise<Record<string, string | number | boolean | undefined>> { // Get timezone from API response and combine with environment settings const response = await this.makeApiRequest('/measure', { action: 'getmeas', meastype: '1', // Weight only for lightweight call limit: 1 }); const settings: Record<string, string | number | boolean | undefined> = {}; // Timezone from API response if (response.body?.timezone) { settings.timezone = response.body.timezone; } // Unit system from environment or default settings.unit_system = this.getDefaultUnit(); // Default user attribution from environment const userAttrib = this.getDefaultUserAttrib(); if (userAttrib !== undefined) { settings.default_user_attrib = userAttrib; } // Client configuration (without secrets) settings.client_id = this.config.clientId; settings.redirect_uri = this.config.redirectUri; // Token status settings.has_access_token = !!this.config.accessToken; settings.has_refresh_token = !!this.config.refreshToken; return settings; } }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/gchallen/withings-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server