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:
Multiple Data Types: Access various metrics like heart rate, power, speed, GPS coordinates, etc.
Flexible Resolution: Choose data density from low (~100 points) to high (~10000 points)
Smart Pagination: Get data in manageable chunks or all at once
Rich Statistics: Includes min/max/avg for numeric streams
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:
Metadata: Activity overview, available streams, data points
Statistics: Summary stats for each stream type (max/min/avg where applicable)
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
| Name | Required | Description | Default |
|---|---|---|---|
| id | Yes | The Strava activity identifier to fetch streams for. This can be obtained from activity URLs or the get-activities tool. | |
| page | No | Optional 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_page | No | Optional 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. | |
| resolution | No | Optional data resolution. Affects number of data points returned: - low: ~100 points - medium: ~1000 points - high: ~10000 points Default varies based on activity length. | |
| series_type | No | Optional 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 |
| types | No | Array 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
- src/tools/getActivityStreams.ts:167-480 (handler)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); }