Skip to main content
Glama

get-activity-streams

Extract and analyze detailed time-series data from Strava activities, including heart rate, power, speed, and GPS coordinates. Choose data resolution and format for workout analysis, route visualization, or segment insights.

Instructions

Retrieves detailed time-series data streams from a Strava activity. Perfect for analyzing workout metrics, visualizing routes, or performing detailed activity analysis.

Key Features:

  1. Multiple Data Types: Access various metrics like heart rate, power, speed, GPS coordinates, etc.

  2. Flexible Resolution: Choose data density from low (~100 points) to high (~10000 points)

  3. Smart Pagination: Get data in manageable chunks or all at once

  4. Rich Statistics: Includes min/max/avg for numeric streams

  5. Formatted Output: Data is processed into human and LLM-friendly formats

Common Use Cases:

  • Analyzing workout intensity through heart rate zones

  • Calculating power metrics for cycling activities

  • Visualizing route data using GPS coordinates

  • Analyzing pace and elevation changes

  • Detailed segment analysis

Output Format:

  1. Metadata: Activity overview, available streams, data points

  2. Statistics: Summary stats for each stream type (max/min/avg where applicable)

  3. Stream Data: Actual time-series data, formatted for easy use

Notes:

  • Requires activity:read scope

  • Not all streams are available for all activities

  • Older activities might have limited data

  • Large activities are automatically paginated to handle size limits

Input Schema

TableJSON Schema
NameRequiredDescriptionDefault
idYesThe Strava activity identifier to fetch streams for. This can be obtained from activity URLs or the get-activities tool.
pageNoOptional page number for paginated results. Use with points_per_page to retrieve specific data ranges. Example: page=2 with points_per_page=100 gets points 101-200.
points_per_pageNoOptional number of data points per page. Special values: - Positive number: Returns that many points per page - -1: Returns ALL data points split into multiple messages (~1000 points each) Use -1 when you need the complete activity data for analysis.
resolutionNoOptional data resolution. Affects number of data points returned: - low: ~100 points - medium: ~1000 points - high: ~10000 points Default varies based on activity length.
series_typeNoOptional base series type for the streams: - time: Data points are indexed by time (seconds from start) - distance: Data points are indexed by distance (meters from start) Useful for comparing different activities or analyzing specific segments.distance
typesNoArray of stream types to fetch. Available types: - time: Time in seconds from start - distance: Distance in meters from start - latlng: Array of [latitude, longitude] pairs - altitude: Elevation in meters - velocity_smooth: Smoothed speed in meters/second - heartrate: Heart rate in beats per minute - cadence: Cadence in revolutions per minute - watts: Power output in watts - temp: Temperature in Celsius - moving: Boolean indicating if moving - grade_smooth: Road grade as percentage

Implementation Reference

  • The main handler function that executes the tool: authenticates with Strava, fetches activity streams data via API, processes different stream types (GPS, HR, power, etc.), computes statistics, handles pagination (including all-data mode), and returns formatted JSON.
    execute: async ({ id, types, resolution, series_type, page = 1, points_per_page = 100 }: GetActivityStreamsParams) => { const token = process.env.STRAVA_ACCESS_TOKEN; if (!token) { return { content: [{ type: 'text' as const, text: '❌ Missing STRAVA_ACCESS_TOKEN in .env' }], isError: true }; } try { // Set the auth token for this request stravaApi.defaults.headers.common['Authorization'] = `Bearer ${token}`; // Build query parameters const params: Record<string, any> = {}; if (resolution) params.resolution = resolution; if (series_type) params.series_type = series_type; // Convert query params to string const queryString = new URLSearchParams(params).toString(); // Build the endpoint URL with types in the path const endpoint = `/activities/${id}/streams/${types.join(',')}${queryString ? '?' + queryString : ''}`; const response = await stravaApi.get<StreamSet>(endpoint); const streams = response.data; if (!streams || streams.length === 0) { return { content: [{ type: 'text' as const, text: '⚠️ No streams were returned. This could mean:\n' + '1. The activity was recorded without this data\n' + '2. The activity is not a GPS-based activity\n' + '3. The activity is too old (Strava may not keep all stream data indefinitely)' }], isError: true }; } // At this point we know streams[0] exists because we checked length > 0 const referenceStream = streams[0]!; const totalPoints = referenceStream.data.length; // Generate stream statistics first (they're always included) const streamStats: Record<string, any> = {}; streams.forEach(stream => { const data = stream.data; let stats: any = { total_points: data.length, resolution: stream.resolution, series_type: stream.series_type }; // Add type-specific statistics switch (stream.type) { case 'heartrate': const hrData = data as number[]; stats = { ...stats, max: Math.max(...hrData), min: Math.min(...hrData), avg: Math.round(hrData.reduce((a, b) => a + b, 0) / hrData.length) }; break; case 'watts': const powerData = data as number[]; stats = { ...stats, max: Math.max(...powerData), avg: Math.round(powerData.reduce((a, b) => a + b, 0) / powerData.length), normalized_power: calculateNormalizedPower(powerData) }; break; case 'velocity_smooth': const velocityData = data as number[]; stats = { ...stats, max_kph: Math.round(Math.max(...velocityData) * 3.6 * 10) / 10, avg_kph: Math.round(velocityData.reduce((a, b) => a + b, 0) / velocityData.length * 3.6 * 10) / 10 }; break; } streamStats[stream.type] = stats; }); // Special case: return all data in multiple messages if points_per_page is -1 if (points_per_page === -1) { // Calculate optimal chunk size (aim for ~500KB per message) const CHUNK_SIZE = 1000; // Adjust this if needed const numChunks = Math.ceil(totalPoints / CHUNK_SIZE); // Return array of messages return { content: [ // First message with metadata { type: 'text' as const, text: `📊 Activity Stream Data (${totalPoints} points)\n` + `Will be sent in ${numChunks + 1} messages:\n` + `1. Metadata and Statistics\n` + `2-${numChunks + 1}. Stream Data (${CHUNK_SIZE} points per message)\n\n` + `Message 1/${numChunks + 1}:\n` + JSON.stringify({ metadata: { available_types: streams.map(s => s.type), total_points: totalPoints, total_chunks: numChunks, chunk_size: CHUNK_SIZE, resolution: referenceStream.resolution, series_type: referenceStream.series_type }, statistics: streamStats }, null, 2) }, // Data messages ...Array.from({ length: numChunks }, (_, i) => { const chunkStart = i * CHUNK_SIZE; const chunkEnd = Math.min(chunkStart + CHUNK_SIZE, totalPoints); const streamData: Record<string, any> = { streams: {} }; // Process each stream for this chunk streams.forEach(stream => { const chunkData = stream.data.slice(chunkStart, chunkEnd); let processedData: any; switch (stream.type) { case 'latlng': const latlngData = chunkData as [number, number][]; processedData = latlngData.map(([lat, lng]) => ({ latitude: Number(lat.toFixed(6)), longitude: Number(lng.toFixed(6)) })); break; case 'time': const timeData = chunkData as number[]; processedData = timeData.map(seconds => ({ seconds_from_start: seconds, formatted: new Date(seconds * 1000).toISOString().substr(11, 8) })); break; case 'distance': const distanceData = chunkData as number[]; processedData = distanceData.map(meters => ({ meters, kilometers: Number((meters / 1000).toFixed(2)) })); break; case 'velocity_smooth': const velocityData = chunkData as number[]; processedData = velocityData.map(mps => ({ meters_per_second: mps, kilometers_per_hour: Number((mps * 3.6).toFixed(1)) })); break; case 'heartrate': case 'cadence': case 'watts': case 'temp': const numericData = chunkData as number[]; processedData = numericData.map(v => Number(v)); break; case 'grade_smooth': const gradeData = chunkData as number[]; processedData = gradeData.map(grade => Number(grade.toFixed(1))); break; case 'moving': processedData = chunkData as boolean[]; break; default: processedData = chunkData; } streamData.streams[stream.type] = processedData; }); return { type: 'text' as const, text: `Message ${i + 2}/${numChunks + 1} (points ${chunkStart + 1}-${chunkEnd}):\n` + JSON.stringify(streamData, null, 2) }; }) ] }; } // Regular paginated response const totalPages = Math.ceil(totalPoints / points_per_page); // Validate page number if (page < 1 || page > totalPages) { return { content: [{ type: 'text' as const, text: `❌ Invalid page number. Please specify a page between 1 and ${totalPages}` }], isError: true }; } // Calculate slice indices for pagination const startIdx = (page - 1) * points_per_page; const endIdx = Math.min(startIdx + points_per_page, totalPoints); // Process paginated stream data const streamData: Record<string, any> = { metadata: { available_types: streams.map(s => s.type), total_points: totalPoints, current_page: page, total_pages: totalPages, points_per_page, points_in_page: endIdx - startIdx }, statistics: streamStats, streams: {} }; // Process each stream with pagination streams.forEach(stream => { let processedData: any; const paginatedData = stream.data.slice(startIdx, endIdx); switch (stream.type) { case 'latlng': const latlngData = paginatedData as [number, number][]; processedData = latlngData.map(([lat, lng]) => ({ latitude: Number(lat.toFixed(6)), longitude: Number(lng.toFixed(6)) })); break; case 'time': const timeData = paginatedData as number[]; processedData = timeData.map(seconds => ({ seconds_from_start: seconds, formatted: new Date(seconds * 1000).toISOString().substr(11, 8) })); break; case 'distance': const distanceData = paginatedData as number[]; processedData = distanceData.map(meters => ({ meters, kilometers: Number((meters / 1000).toFixed(2)) })); break; case 'velocity_smooth': const velocityData = paginatedData as number[]; processedData = velocityData.map(mps => ({ meters_per_second: mps, kilometers_per_hour: Number((mps * 3.6).toFixed(1)) })); break; case 'heartrate': case 'cadence': case 'watts': case 'temp': const numericData = paginatedData as number[]; processedData = numericData.map(v => Number(v)); break; case 'grade_smooth': const gradeData = paginatedData as number[]; processedData = gradeData.map(grade => Number(grade.toFixed(1))); break; case 'moving': processedData = paginatedData as boolean[]; break; default: processedData = paginatedData; } streamData.streams[stream.type] = processedData; }); return { content: [{ type: 'text' as const, text: JSON.stringify(streamData, null, 2) }] }; } catch (error: any) { const statusCode = error.response?.status; const errorMessage = error.response?.data?.message || error.message; let userFriendlyError = `❌ Failed to fetch activity streams (${statusCode}): ${errorMessage}\n\n`; userFriendlyError += 'This could be because:\n'; userFriendlyError += '1. The activity ID is invalid\n'; userFriendlyError += '2. You don\'t have permission to view this activity\n'; userFriendlyError += '3. The requested stream types are not available\n'; userFriendlyError += '4. The activity is too old and the streams have been archived'; return { content: [{ type: 'text' as const, text: userFriendlyError }], isError: true }; } }
  • Zod input schema defining parameters for fetching activity streams: activity ID, stream types, resolution, series type, pagination options.
    export const inputSchema = z.object({ id: z.number().or(z.string()).describe( 'The Strava activity identifier to fetch streams for. This can be obtained from activity URLs or the get-activities tool.' ), types: z.array(z.enum(STREAM_TYPES)) .default(['time', 'distance', 'heartrate', 'cadence', 'watts']) .describe( 'Array of stream types to fetch. Available types:\n' + '- time: Time in seconds from start\n' + '- distance: Distance in meters from start\n' + '- latlng: Array of [latitude, longitude] pairs\n' + '- altitude: Elevation in meters\n' + '- velocity_smooth: Smoothed speed in meters/second\n' + '- heartrate: Heart rate in beats per minute\n' + '- cadence: Cadence in revolutions per minute\n' + '- watts: Power output in watts\n' + '- temp: Temperature in Celsius\n' + '- moving: Boolean indicating if moving\n' + '- grade_smooth: Road grade as percentage' ), resolution: z.enum(RESOLUTION_TYPES).optional() .describe( 'Optional data resolution. Affects number of data points returned:\n' + '- low: ~100 points\n' + '- medium: ~1000 points\n' + '- high: ~10000 points\n' + 'Default varies based on activity length.' ), series_type: z.enum(['time', 'distance']).optional() .default('distance') .describe( 'Optional base series type for the streams:\n' + '- time: Data points are indexed by time (seconds from start)\n' + '- distance: Data points are indexed by distance (meters from start)\n' + 'Useful for comparing different activities or analyzing specific segments.' ), page: z.number().optional().default(1) .describe( 'Optional page number for paginated results. Use with points_per_page to retrieve specific data ranges.\n' + 'Example: page=2 with points_per_page=100 gets points 101-200.' ), points_per_page: z.number().optional().default(100) .describe( 'Optional number of data points per page. Special values:\n' + '- Positive number: Returns that many points per page\n' + '- -1: Returns ALL data points split into multiple messages (~1000 points each)\n' + 'Use -1 when you need the complete activity data for analysis.' ) });
  • src/server.ts:140-145 (registration)
    Registers the get-activity-streams tool with the MCP server using server.tool().
    server.tool( getActivityStreamsTool.name, getActivityStreamsTool.description, getActivityStreamsTool.inputSchema?.shape ?? {}, getActivityStreamsTool.execute );
  • Helper function to calculate normalized power (NP) from power stream data using 30s rolling average of 4th power.
    function calculateNormalizedPower(powerData: number[]): number { if (powerData.length < 30) return 0; // 30-second moving average const windowSize = 30; const movingAvg = []; for (let i = windowSize - 1; i < powerData.length; i++) { const window = powerData.slice(i - windowSize + 1, i + 1); const avg = window.reduce((a, b) => a + b, 0) / windowSize; movingAvg.push(Math.pow(avg, 4)); } // Calculate normalized power const avgPower = Math.pow( movingAvg.reduce((a, b) => a + b, 0) / movingAvg.length, 0.25 ); return Math.round(avgPower); }

Latest Blog Posts

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/r-huijts/strava-mcp'

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