# Directory Structure ``` ├── .env.example ├── .gitignore ├── LICENSE ├── package-lock.json ├── package.json ├── README.md ├── scripts │ └── setup-auth.ts ├── server.json ├── src │ ├── formatters.ts │ ├── server.ts │ ├── stravaClient.ts │ └── tools │ ├── exploreSegments.ts │ ├── exportRouteGpx.ts │ ├── exportRouteTcx.ts │ ├── formatWorkoutFile.ts │ ├── getActivityDetails.ts │ ├── getActivityLaps.ts │ ├── getActivityStreams.ts │ ├── getAllActivities.ts │ ├── getAthleteProfile.ts │ ├── getAthleteStats.ts │ ├── getAthleteZones.ts │ ├── getRecentActivities.ts │ ├── getRoute.ts │ ├── getSegment.ts │ ├── getSegmentEffort.ts │ ├── listAthleteClubs.ts │ ├── listAthleteRoutes.ts │ ├── listSegmentEfforts.ts │ ├── listStarredSegments.ts │ └── starSegment.ts ├── test-strava-api.ts └── tsconfig.json ``` # Files -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` # Dependencies node_modules/ # Build output dist/ # Environment variables .env # Log files npm-debug.log* yarn-debug.log* yarn-error.log* # Editor directories and files .idea .vscode *.suo *.ntvs* *.njsproj *.sln *.sw? # Cursor IDE files .cursor/ *.mdc cursor_rules.json .mcpregistry_registry_token .mcpregistry_github_token ``` -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- ``` # Strava API Access Token # Get yours from https://www.strava.com/settings/api STRAVA_ACCESS_TOKEN=YOUR_STRAVA_ACCESS_TOKEN_HERE # Optional: Define a path for saving exported route files (GPX/TCX) # Ensure this directory exists and the server process has write permissions. # Example: ROUTE_EXPORT_PATH=/Users/your_username/strava-exports ROUTE_EXPORT_PATH=./strava-exports ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown [](https://mseep.ai/app/r-huijts-strava-mcp) # Strava MCP Server This project implements a Model Context Protocol (MCP) server in TypeScript that acts as a bridge to the Strava API. It exposes Strava data and functionalities as "tools" that Large Language Models (LLMs) can utilize through the MCP standard. <a href="https://glama.ai/mcp/servers/@r-huijts/strava-mcp"> <img width="380" height="200" src="https://glama.ai/mcp/servers/@r-huijts/strava-mcp/badge" alt="Strava Server MCP server" /> </a> ## Features - 🏃 Access recent activities, profile, and stats. - 📊 Fetch detailed activity streams (power, heart rate, cadence, etc.). - 🗺️ Explore, view, star, and manage segments. - ⏱️ View detailed activity and segment effort information. - 📍 List and view details of saved routes. - 💾 Export routes in GPX or TCX format to the local filesystem. - 🤖 AI-friendly JSON responses via MCP. - 🔧 Uses Strava API V3. ## Natural Language Interaction Examples Ask your AI assistant questions like these to interact with your Strava data: **Recent Activity & Profile:** * "Show me my recent Strava activities." * "What were my last 3 rides?" * "Get my Strava profile information." * "What's my Strava username?" **Activity Streams & Data:** * "Get the heart rate data from my morning run yesterday." * "Show me the power data from my last ride." * "What was my cadence profile for my weekend century ride?" * "Get all stream data for my Thursday evening workout." * "Show me the elevation profile for my Mt. Diablo climb." **Stats:** * "What are my running stats for this year on Strava?" * "How far have I cycled in total?" * "Show me my all-time swim totals." **Specific Activities:** * "Give me the details for my last run." * "What was the average power for my interval training on Tuesday?" * "Did I use my Trek bike for my commute yesterday?" **Clubs:** * "What Strava clubs am I in?" * "List the clubs I've joined." **Segments:** * "List the segments I starred near Boulder, Colorado." * "Show my favorite segments." * "Get details for the 'Alpe du Zwift' segment." * "Are there any good running segments near Golden Gate Park?" * "Find challenging climbs near Boulders Flagstaff Mountain." * "Star the 'Flagstaff Road Climb' segment for me." * "Unstar the 'Lefthand Canyon' segment." **Segment Efforts:** * "Show my efforts on the 'Sunshine Canyon' segment this month." * "List my attempts on Box Hill between January and June this year." * "Get the details for my personal record on Alpe d'Huez." **Routes:** * "List my saved Strava routes." * "Show the second page of my routes." * "What is the elevation gain for my Boulder Loop route?" * "Get the description for my 'Boulder Loop' route." * "Export my 'Boulder Loop' route as a GPX file." * "Save my Sunday morning route as a TCX file." ## Advanced Prompt Example Here's an example of a more advanced prompt to create a professional cycling coach analysis of your Strava activities: ``` You are Tom Verhaegen, elite cycling coach and mentor to world champion Mathieu van der Poel. Analyze my most recent Strava activity. Provide a thorough, data-driven assessment of the ride, combining both quantitative insights and textual interpretation. Begin your report with a written summary that highlights key findings and context. Then, bring the raw numbers to life: build an interactive, visually striking dashboard using HTML, CSS, and JavaScript. Use bold, high-contrast colors and intuitive, insightful chart types that best suit each metric (e.g., heart rate, power, cadence, elevation). Embed clear coaching feedback and personalized training recommendations directly within the visualization. These should be practical, actionable, and grounded solely in the data provided—no assumptions or fabrications. As a bonus, sprinkle in motivational quotes and cheeky commentary from Mathieu van der Poel himself—he's been watching my rides with one eyebrow raised and a smirk of both concern and amusement. Goal: Deliver a professional-grade performance analysis that looks and feels like it came straight from the inner circle of world-class cycling. ``` This prompt creates a personalized analysis of your most recent Strava activity, complete with professional coaching feedback and a custom visualization dashboard. ## ⚠️ Important Setup Sequence For successful integration with Claude, follow these steps in exact order: 1. Install the server and its dependencies 2. Configure the server in Claude's configuration 3. Complete the Strava authentication flow 4. Restart Claude to ensure proper environment variable loading Skipping steps or performing them out of order may result in environment variables not being properly read by Claude. ## Installation & Setup 1. **Prerequisites:** - Node.js (v18 or later recommended) - npm (usually comes with Node.js) - A Strava Account ### 1. From Source 1. **Clone Repository:** ```bash git clone https://github.com/r-huijts/strava-mcp.git cd strava-mcp ``` 2. **Install Dependencies:** ```bash npm install ``` 3. **Build the Project:** ```bash npm run build ``` ### 2. Configure Claude Desktop Update your Claude configuration file: ```json { "mcpServers": { "strava-mcp-local": { "command": "node", "args": [ "/absolute/path/to/your/strava-mcp/dist/server.js" ] // Environment variables are read from the .env file by the server } } } ``` Make sure to replace `/absolute/path/to/your/strava-mcp/` with the actual path to your installation. ### 3. Strava Authentication Setup The `setup-auth.ts` script makes it easy to set up authentication with the Strava API. Follow these steps carefully: #### Create a Strava API Application 1. Go to [https://www.strava.com/settings/api](https://www.strava.com/settings/api) 2. Create a new application: - Enter your application details (name, website, description) - Important: Set "Authorization Callback Domain" to `localhost` - Note down your Client ID and Client Secret #### Run the Setup Script ```bash # In your strava-mcp directory npx tsx scripts/setup-auth.ts ``` Follow the prompts to complete the authentication flow (detailed instructions in the Authentication section below). ### 4. Restart Claude After completing all the above steps, restart Claude Desktop for the changes to take effect. This ensures that: - The new configuration is loaded - The environment variables are properly read - The Strava MCP server is properly initialized ## 🔑 Environment Variables | Variable | Description | |----------|-------------| | STRAVA_CLIENT_ID | Your Strava Application Client ID (required) | | STRAVA_CLIENT_SECRET | Your Strava Application Client Secret (required) | | STRAVA_ACCESS_TOKEN | Your Strava API access token (generated during setup) | | STRAVA_REFRESH_TOKEN | Your Strava API refresh token (generated during setup) | | ROUTE_EXPORT_PATH | Absolute path for saving exported route files (optional) | ## Token Handling This server implements automatic token refreshing. When the initial access token expires (typically after 6 hours), the server will automatically use the refresh token stored in `.env` to obtain a new access token and refresh token. These new tokens are then updated in both the running process and the `.env` file, ensuring continuous operation. You only need to run the `scripts/setup-auth.ts` script once for the initial setup. ## Configure Export Path (Optional) If you intend to use the `export-route-gpx` or `export-route-tcx` tools, you need to specify a directory for saving exported files. Edit your `.env` file and add/update the `ROUTE_EXPORT_PATH` variable: ```dotenv # Optional: Define an *absolute* path for saving exported route files (GPX/TCX) # Ensure this directory exists and the server process has write permissions. # Example: ROUTE_EXPORT_PATH=/Users/your_username/strava-exports ROUTE_EXPORT_PATH= ``` Replace the placeholder with the **absolute path** to your desired export directory. Ensure the directory exists and the server has permission to write to it. ## API Reference The server exposes the following MCP tools: --- ### `get-recent-activities` Fetches the authenticated user's recent activities. - **When to use:** When the user asks about their recent workouts, activities, runs, rides, etc. - **Parameters:** - `perPage` (optional): - Type: `number` - Description: Number of activities to retrieve. - Default: 30 - **Output:** Formatted text list of recent activities (Name, ID, Distance, Date). - **Errors:** Missing/invalid token, Strava API errors. --- ### `get-athlete-profile` Fetches the profile information for the authenticated athlete. - **When to use:** When the user asks for their profile details, username, location, weight, premium status, etc. - **Parameters:** None - **Output:** Formatted text string with profile details. - **Errors:** Missing/invalid token, Strava API errors. --- ### `get-athlete-stats` Fetches activity statistics (recent, YTD, all-time) for the authenticated athlete. - **When to use:** When the user asks for their overall statistics, totals for runs/rides/swims, personal records (longest ride, biggest climb). - **Parameters:** None - **Output:** Formatted text summary of stats, respecting user's measurement preference. - **Errors:** Missing/invalid token, Strava API errors. --- ### `get-activity-details` Fetches detailed information about a specific activity using its ID. - **When to use:** When the user asks for details about a *specific* activity identified by its ID. - **Parameters:** - `activityId` (required): - Type: `number` - Description: The unique identifier of the activity. - **Output:** Formatted text string with detailed activity information (type, date, distance, time, speed, HR, power, gear, etc.), respecting user's measurement preference. - **Errors:** Missing/invalid token, Invalid `activityId`, Strava API errors. --- ### `list-athlete-clubs` Lists the clubs the authenticated athlete is a member of. - **When to use:** When the user asks about the clubs they have joined. - **Parameters:** None - **Output:** Formatted text list of clubs (Name, ID, Sport, Members, Location). - **Errors:** Missing/invalid token, Strava API errors. --- ### `list-starred-segments` Lists the segments starred by the authenticated athlete. - **When to use:** When the user asks about their starred or favorite segments. - **Parameters:** None - **Output:** Formatted text list of starred segments (Name, ID, Type, Distance, Grade, Location). - **Errors:** Missing/invalid token, Strava API errors. --- ### `get-segment` Fetches detailed information about a specific segment using its ID. - **When to use:** When the user asks for details about a *specific* segment identified by its ID. - **Parameters:** - `segmentId` (required): - Type: `number` - Description: The unique identifier of the segment. - **Output:** Formatted text string with detailed segment information (distance, grade, elevation, location, stars, efforts, etc.), respecting user's measurement preference. - **Errors:** Missing/invalid token, Invalid `segmentId`, Strava API errors. --- ### `explore-segments` Searches for popular segments within a given geographical area (bounding box). - **When to use:** When the user wants to find or discover segments in a specific geographic area, optionally filtering by activity type or climb category. - **Parameters:** - `bounds` (required): - Type: `string` - Description: Comma-separated: `south_west_lat,south_west_lng,north_east_lat,north_east_lng`. - `activityType` (optional): - Type: `string` (`"running"` or `"riding"`) - Description: Filter by activity type. - `minCat` (optional): - Type: `number` (0-5) - Description: Minimum climb category. Requires `activityType: 'riding'`. - `maxCat` (optional): - Type: `number` (0-5) - Description: Maximum climb category. Requires `activityType: 'riding'`. - **Output:** Formatted text list of found segments (Name, ID, Climb Cat, Distance, Grade, Elevation). - **Errors:** Missing/invalid token, Invalid `bounds` format, Invalid filter combination, Strava API errors. --- ### `star-segment` Stars or unstars a specific segment for the authenticated athlete. - **When to use:** When the user explicitly asks to star, favorite, unstar, or unfavorite a specific segment identified by its ID. - **Parameters:** - `segmentId` (required): - Type: `number` - Description: The unique identifier of the segment. - `starred` (required): - Type: `boolean` - Description: `true` to star, `false` to unstar. - **Output:** Success message confirming the action and the segment's new starred status. - **Errors:** Missing/invalid token, Invalid `segmentId`, Strava API errors (e.g., segment not found, rate limit). - **Notes:** - Requires `profile:write` scope for star-ing and unstar-ing segments --- ### `get-segment-effort` Fetches detailed information about a specific segment effort using its ID. - **When to use:** When the user asks for details about a *specific* segment effort identified by its ID. - **Parameters:** - `effortId` (required): - Type: `number` - Description: The unique identifier of the segment effort. - **Output:** Formatted text string with detailed effort information (segment name, activity ID, time, distance, HR, power, rank, etc.). - **Errors:** Missing/invalid token, Invalid `effortId`, Strava API errors. --- ### `list-segment-efforts` Lists the authenticated athlete's efforts on a given segment, optionally filtered by date. - **When to use:** When the user asks to list their efforts or attempts on a specific segment, possibly within a date range. - **Parameters:** - `segmentId` (required): - Type: `number` - Description: The ID of the segment. - `startDateLocal` (optional): - Type: `string` (ISO 8601 format) - Description: Filter efforts starting after this date-time. - `endDateLocal` (optional): - Type: `string` (ISO 8601 format) - Description: Filter efforts ending before this date-time. - `perPage` (optional): - Type: `number` - Description: Number of results per page. - Default: 30 - **Output:** Formatted text list of matching segment efforts. - **Errors:** Missing/invalid token, Invalid `segmentId`, Invalid date format, Strava API errors. --- ### `list-athlete-routes` Lists the routes created by the authenticated athlete. - **When to use:** When the user asks to see the routes they have created or saved. - **Parameters:** - `page` (optional): - Type: `number` - Description: Page number for pagination. - `perPage` (optional): - Type: `number` - Description: Number of routes per page. - Default: 30 - **Output:** Formatted text list of routes (Name, ID, Type, Distance, Elevation, Date). - **Errors:** Missing/invalid token, Strava API errors. --- ### `get-route` Fetches detailed information for a specific route using its ID. - **When to use:** When the user asks for details about a *specific* route identified by its ID. - **Parameters:** - `routeId` (required): - Type: `number` - Description: The unique identifier of the route. - **Output:** Formatted text string with route details (Name, ID, Type, Distance, Elevation, Est. Time, Description, Segment Count). - **Errors:** Missing/invalid token, Invalid `routeId`, Strava API errors. --- ### `export-route-gpx` Exports a specific route in GPX format and saves it locally. - **When to use:** When the user explicitly asks to export or save a specific route as a GPX file. - **Prerequisite:** The `ROUTE_EXPORT_PATH` environment variable must be correctly configured on the server. - **Parameters:** - `routeId` (required): - Type: `number` - Description: The unique identifier of the route. - **Output:** Success message indicating the save location, or an error message. - **Errors:** Missing/invalid token, Missing/invalid `ROUTE_EXPORT_PATH`, File system errors (permissions, disk space), Invalid `routeId`, Strava API errors. --- ### `export-route-tcx` Exports a specific route in TCX format and saves it locally. - **When to use:** When the user explicitly asks to export or save a specific route as a TCX file. - **Prerequisite:** The `ROUTE_EXPORT_PATH` environment variable must be correctly configured on the server. - **Parameters:** - `routeId` (required): - Type: `number` - Description: The unique identifier of the route. - **Output:** Success message indicating the save location, or an error message. - **Errors:** Missing/invalid token, Missing/invalid `ROUTE_EXPORT_PATH`, File system errors (permissions, disk space), Invalid `routeId`, Strava API errors. --- ### `get-activity-streams` Retrieves detailed time-series data streams from a Strava activity, perfect for analyzing workout metrics, visualizing routes, or performing detailed activity analysis. - **When to use:** When you need detailed time-series data from an activity for: - 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 - **Parameters:** - `id` (required): - Type: `number | string` - Description: The Strava activity identifier to fetch streams for - `types` (optional): - Type: `array` - Default: `['time', 'distance', 'heartrate', 'cadence', 'watts']` - 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 - `resolution` (optional): - Type: `string` - Values: `'low'` (~100 points), `'medium'` (~1000 points), `'high'` (~10000 points) - Description: Data resolution/density - `series_type` (optional): - Type: `string` - Values: `'time'` or `'distance'` - Default: `'distance'` - Description: Base series type for data point indexing - `page` (optional): - Type: `number` - Default: 1 - Description: Page number for paginated results - `points_per_page` (optional): - Type: `number` - Default: 100 - Special value: `-1` returns ALL data points split into multiple messages - Description: Number of data points per page - **Output Format:** 1. Metadata: - Available stream types - Total data points - Resolution and series type - Pagination info (current page, total pages) 2. Statistics (where applicable): - Heart rate: max, min, average - Power: max, average, normalized power - Speed: max and average in km/h 3. Stream Data: - Formatted time-series data for each requested stream - Human-readable formats (e.g., formatted time, km/h for speed) - Consistent numeric precision - Labeled data points - **Example Request:** ```json { "id": 12345678, "types": ["time", "heartrate", "watts", "velocity_smooth", "cadence"], "resolution": "high", "points_per_page": 100, "page": 1 } ``` - **Special Features:** - Smart pagination for large datasets - Complete data retrieval mode (points_per_page = -1) - Rich statistics and metadata - Formatted output for both human and LLM consumption - Automatic unit conversions - **Notes:** - Requires activity:read scope - Not all streams are available for all activities - Older activities might have limited data - Large activities are automatically paginated - Stream availability depends on recording device and activity type - **Errors:** - Missing/invalid token - Invalid activity ID - Insufficient permissions - Unavailable stream types - Invalid pagination parameters --- ### `get-activity-laps` Retrieves the laps recorded for a specific Strava activity. - **When to use:** - Analyze performance variations across different segments (laps) of an activity. - Compare lap times, speeds, heart rates, or power outputs. - Understand how an activity was structured (e.g., interval training). - **Parameters:** - `id` (required): - Type: `number | string` - Description: The unique identifier of the Strava activity. - **Output Format:** A text summary detailing each lap, including: - Lap Index - Lap Name (if available) - Elapsed Time (formatted as HH:MM:SS) - Moving Time (formatted as HH:MM:SS) - Distance (in km) - Average Speed (in km/h) - Max Speed (in km/h) - Total Elevation Gain (in meters) - Average Heart Rate (if available, in bpm) - Max Heart Rate (if available, in bpm) - Average Cadence (if available, in rpm) - Average Watts (if available, in W) - **Example Request:** ```json { "id": 1234567890 } ``` - **Example Response Snippet:** ```text Activity Laps Summary (ID: 1234567890): Lap 1: Warmup Lap Time: 15:02 (Moving: 14:35) Distance: 5.01 km Avg Speed: 20.82 km/h Max Speed: 35.50 km/h Elevation Gain: 50.2 m Avg HR: 135.5 bpm Max HR: 150 bpm Avg Cadence: 85.0 rpm Lap 2: Interval 1 Time: 05:15 (Moving: 05:10) Distance: 2.50 km Avg Speed: 29.03 km/h Max Speed: 42.10 km/h Elevation Gain: 10.1 m Avg HR: 168.2 bpm Max HR: 175 bpm Avg Cadence: 92.1 rpm Avg Power: 280.5 W (Sensor) ... ``` - **Notes:** - Requires `activity:read` scope for public/followers activities, `activity:read_all` for private activities. - Lap data availability depends on the recording device and activity type (e.g., manual activities may not have laps). - **Errors:** - Missing/invalid token - Invalid activity ID - Insufficient permissions - Activity not found --- ### `get-athlete-zones` Retrieves the authenticated athlete's configured heart rate and power zones. - **When to use:** When the user asks about their heart rate zones, power zones, or training zone settings. - **Parameters:** None - **Output Format:** Returns two text blocks: 1. A **formatted summary** detailing configured zones: - Heart Rate Zones: Custom status, Zone ranges, Time Distribution (if available) - Power Zones: Zone ranges, Time Distribution (if available) 2. The **complete raw JSON data** as returned by the Strava API. - **Example Response Snippet (Summary):** ```text **Athlete Zones:** ❤️ **Heart Rate Zones** Custom Zones: No Zone 1: 0 - 115 bpm Zone 2: 115 - 145 bpm Zone 3: 145 - 165 bpm Zone 4: 165 - 180 bpm Zone 5: 180+ bpm ⚡ **Power Zones** Zone 1: 0 - 150 W Zone 2: 151 - 210 W Zone 3: 211 - 250 W Zone 4: 251 - 300 W Zone 5: 301 - 350 W Zone 6: 351 - 420 W Zone 7: 421+ W Time Distribution: - 0-50: 0:24:58 - 50-100: 0:01:02 ... - 450-∞: 0:05:43 ``` - **Notes:** - Requires `profile:read_all` scope. - Zones might not be configured for all athletes. - **Errors:** - Missing/invalid token - Insufficient permissions (Missing `profile:read_all` scope - 403 error) - Subscription Required (Potentially, if Strava changes API access) --- ## Contributing Contributions are welcome! Please feel free to submit a Pull Request. ## License This project is licensed under the MIT License - see the LICENSE file for details. (Assuming MIT, update if different) ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json { "name": "strava-mcp-server", "version": "1.0.1", "description": "MCP server for Strava API", "mcpName": "io.github.r-huijts/strava-mcp", "repository": { "type": "git", "url": "https://github.com/r-huijts/strava-mcp" }, "main": "dist/server.js", "type": "module", "scripts": { "build": "tsc", "start": "node dist/server.js", "dev": "tsx src/server.ts", "lint": "eslint . --ext .ts", "setup-auth": "tsx scripts/setup-auth.ts" }, "keywords": [ "mcp", "strava", "llm", "ai" ], "author": "", "license": "ISC", "dependencies": { "@modelcontextprotocol/sdk": "^1.8.0", "axios": "^1.6.0", "dotenv": "^16.3.0", "zod": "^3.22.0" }, "devDependencies": { "@types/node": "^20.11.0", "@typescript-eslint/eslint-plugin": "^6.19.0", "@typescript-eslint/parser": "^6.19.0", "eslint": "^8.56.0", "ts-node": "^10.9.0", "tsx": "^4.7.0", "typescript": "^5.3.0" } } ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json { "compilerOptions": { /* Base Options: */ "esModuleInterop": true, "skipLibCheck": true, "target": "ES2022", // Target modern Node.js versions "allowJs": true, "resolveJsonModule": true, "moduleDetection": "force", "isolatedModules": true, /* Strictness */ "strict": true, "noUncheckedIndexedAccess": true, "noImplicitAny": true, "strictNullChecks": true, /* If NOT transpiling with TypeScript: */ "moduleResolution": "Bundler", // Use "NodeNext" or "Bundler" for modern Node.js "module": "ESNext", // Align with "type": "module" in package.json /* If your code runs in the DOM: */ // "lib": ["es2022", "dom", "dom.iterable"], /* If you want tsc to emit files: */ "outDir": "dist", "sourceMap": true, /* Linting */ "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, }, "include": ["src/**/*.ts"], // Include all TypeScript files in the src directory "exclude": ["node_modules", "dist"] // Exclude node_modules and the output directory } ``` -------------------------------------------------------------------------------- /server.json: -------------------------------------------------------------------------------- ```json { "$schema": "https://static.modelcontextprotocol.io/schemas/2025-07-09/server.schema.json", "name": "io.github.r-huijts/strava-mcp", "description": "MCP server for accessing Strava API", "status": "active", "repository": { "url": "https://github.com/r-huijts/strava-mcp", "source": "github" }, "version": "1.0.0", "packages": [ { "registry_type": "npm", "registry_base_url": "https://registry.npmjs.org", "identifier": "strava-mcp-server", "version": "1.0.0", "transport": { "type": "stdio" }, "environment_variables": [ { "name": "STRAVA_CLIENT_ID", "description": "Your Strava API client ID", "is_required": true, "format": "string" }, { "name": "STRAVA_CLIENT_SECRET", "description": "Your Strava API client secret", "is_required": true, "format": "string", "is_secret": true }, { "name": "STRAVA_ACCESS_TOKEN", "description": "Your Strava API access token", "is_required": true, "format": "string", "is_secret": true } ] } ] } ``` -------------------------------------------------------------------------------- /src/formatters.ts: -------------------------------------------------------------------------------- ```typescript import { StravaRoute } from './stravaClient'; /** * Converts meters to kilometers, rounding to 2 decimal places. * @param meters - Distance in meters. * @returns Distance in kilometers as a string (e.g., "10.25 km"). */ function metersToKmString(meters: number): string { if (meters === undefined || meters === null) return 'N/A'; return (meters / 1000).toFixed(2) + ' km'; } /** * Formats elevation gain in meters. * @param meters - Elevation gain in meters. * @returns Elevation gain as a string (e.g., "150 m"). */ function formatElevation(meters: number | null | undefined): string { if (meters === undefined || meters === null) return 'N/A'; return Math.round(meters) + ' m'; } /** * Formats a Strava route object into a concise summary string using metric units. * * @param route - The StravaRoute object. * @returns A formatted string summarizing the route. */ export function formatRouteSummary(route: StravaRoute): string { const distanceKm = metersToKmString(route.distance); const elevation = formatElevation(route.elevation_gain); const date = new Date(route.created_at).toLocaleDateString(); const type = route.type === 1 ? 'Ride' : route.type === 2 ? 'Run' : 'Walk'; // Assuming 3 is Walk based on typical Strava usage let summary = `📍 Route: ${route.name} (#${route.id})\n`; summary += ` - Type: ${type}, Distance: ${distanceKm}, Elevation: ${elevation}\n`; summary += ` - Created: ${date}, Segments: ${route.segments?.length ?? 'N/A'}\n`; if (route.description) { summary += ` - Description: ${route.description.substring(0, 100)}${route.description.length > 100 ? '...' : ''}\n`; } return summary; } // Add other shared formatters here as needed (e.g., formatActivity, formatSegment) ``` -------------------------------------------------------------------------------- /src/tools/listAthleteClubs.ts: -------------------------------------------------------------------------------- ```typescript // import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; // Removed import { listAthleteClubs as fetchClubs } from "../stravaClient.js"; // Renamed import // Export the tool definition directly export const listAthleteClubs = { name: "list-athlete-clubs", description: "Lists the clubs the authenticated athlete is a member of.", inputSchema: undefined, execute: async () => { const token = process.env.STRAVA_ACCESS_TOKEN; if (!token || token === 'YOUR_STRAVA_ACCESS_TOKEN_HERE') { console.error("Missing or placeholder STRAVA_ACCESS_TOKEN in .env"); return { content: [{ type: "text" as const, text: "❌ Configuration Error: STRAVA_ACCESS_TOKEN is missing or not set in the .env file." }], isError: true, }; } try { console.error("Fetching athlete clubs..."); const clubs = await fetchClubs(token); console.error(`Successfully fetched ${clubs?.length ?? 0} clubs.`); if (!clubs || clubs.length === 0) { return { content: [{ type: "text" as const, text: " MNo clubs found for the athlete." }] }; } const clubText = clubs.map(club => ` 👥 **${club.name}** (ID: ${club.id}) - Sport: ${club.sport_type} - Members: ${club.member_count} - Location: ${club.city}, ${club.state}, ${club.country} - Private: ${club.private ? 'Yes' : 'No'} - URL: ${club.url || 'N/A'} `.trim() ).join("\n---\n"); const responseText = `**Your Strava Clubs:**\n\n${clubText}`; return { content: [{ type: "text" as const, text: responseText }] }; } catch (error) { const errorMessage = error instanceof Error ? error.message : "An unknown error occurred"; console.error("Error in list-athlete-clubs tool:", errorMessage); return { content: [{ type: "text" as const, text: `❌ API Error: ${errorMessage}` }], isError: true, }; } } }; // Remove the old registration function /* export function registerListAthleteClubsTool(server: McpServer) { server.tool( listAthleteClubs.name, listAthleteClubs.description, listAthleteClubs.execute // No input schema ); } */ ``` -------------------------------------------------------------------------------- /src/tools/getRoute.ts: -------------------------------------------------------------------------------- ```typescript import { z } from "zod"; import { getRouteById /*, handleApiError */ } from "../stravaClient.js"; // Removed handleApiError import import { formatRouteSummary } from "../formatters.js"; // Import shared formatter // Zod schema for input validation const GetRouteInputSchema = z.object({ routeId: z.string() .regex(/^\d+$/, "Route ID must contain only digits") .refine(val => val.length > 0, "Route ID cannot be empty") .describe("The unique identifier of the route to fetch.")}); type GetRouteInput = z.infer<typeof GetRouteInputSchema>; // Tool definition export const getRouteTool = { name: "get-route", description: "Fetches detailed information about a specific route using its ID.", inputSchema: GetRouteInputSchema, execute: async (input: GetRouteInput) => { const { routeId } = input; const token = process.env.STRAVA_ACCESS_TOKEN; if (!token) { console.error("Missing STRAVA_ACCESS_TOKEN environment variable."); return { content: [{ type: "text" as const, text: "Configuration error: Missing Strava access token." }], isError: true }; } try { console.error(`Fetching route details for ID: ${routeId}...`); const route = await getRouteById(token, routeId); const summary = formatRouteSummary(route); // Call shared formatter without units console.error(`Successfully fetched route ${routeId}.`); return { content: [{ type: "text" as const, text: summary }] }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); console.error(`Error fetching route ${routeId}: ${errorMessage}`); const userFriendlyMessage = errorMessage.includes("Record Not Found") || errorMessage.includes("404") ? `Route with ID ${routeId} not found.` : `An unexpected error occurred while fetching route ${routeId}. Details: ${errorMessage}`; return { content: [{ type: "text" as const, text: `❌ ${userFriendlyMessage}` }], isError: true }; } } }; // Removed local formatRouteSummary function // Removed old registration function /* export function registerGetRouteTool(server: McpServer) { server.tool( getRoute.name, getRoute.description, getRoute.inputSchema.shape, getRoute.execute ); } */ ``` -------------------------------------------------------------------------------- /src/tools/starSegment.ts: -------------------------------------------------------------------------------- ```typescript // import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; // Removed import { z } from "zod"; import { starSegment as updateStarStatus } from "../stravaClient.js"; // Renamed import const StarSegmentInputSchema = z.object({ segmentId: z.number().int().positive().describe("The unique identifier of the segment to star or unstar."), starred: z.boolean().describe("Set to true to star the segment, false to unstar it."), }); type StarSegmentInput = z.infer<typeof StarSegmentInputSchema>; // Export the tool definition directly export const starSegment = { name: "star-segment", description: "Stars or unstars a specific segment for the authenticated athlete.", inputSchema: StarSegmentInputSchema, execute: async ({ segmentId, starred }: StarSegmentInput) => { const token = process.env.STRAVA_ACCESS_TOKEN; if (!token || token === 'YOUR_STRAVA_ACCESS_TOKEN_HERE') { console.error("Missing or placeholder STRAVA_ACCESS_TOKEN in .env"); return { content: [{ type: "text" as const, text: "❌ Configuration Error: STRAVA_ACCESS_TOKEN is missing or not set in the .env file." }], isError: true, }; } try { const action = starred ? 'starring' : 'unstarring'; console.error(`Attempting to ${action} segment ID: ${segmentId}...`); const updatedSegment = await updateStarStatus(token, segmentId, starred); const successMessage = `Successfully ${action} segment: "${updatedSegment.name}" (ID: ${updatedSegment.id}). Its starred status is now: ${updatedSegment.starred}.`; console.error(successMessage); return { content: [{ type: "text" as const, text: successMessage }] }; } catch (error) { const errorMessage = error instanceof Error ? error.message : "An unknown error occurred"; const action = starred ? 'star' : 'unstar'; console.error(`Error attempting to ${action} segment ID ${segmentId}:`, errorMessage); return { content: [{ type: "text" as const, text: `❌ API Error: Failed to ${action} segment ${segmentId}. ${errorMessage}` }], isError: true, }; } } }; // Removed old registration function /* export function registerStarSegmentTool(server: McpServer) { server.tool( starSegment.name, starSegment.description, starSegment.inputSchema.shape, starSegment.execute ); } */ ``` -------------------------------------------------------------------------------- /src/tools/getAthleteProfile.ts: -------------------------------------------------------------------------------- ```typescript import { getAuthenticatedAthlete } from "../stravaClient.js"; // Export the tool definition directly export const getAthleteProfile = { name: "get-athlete-profile", description: "Fetches the profile information for the authenticated athlete, including their unique numeric ID needed for other tools like get-athlete-stats.", // No input schema needed for this tool inputSchema: undefined, execute: async () => { // No input parameters needed const token = process.env.STRAVA_ACCESS_TOKEN; if (!token || token === 'YOUR_STRAVA_ACCESS_TOKEN_HERE') { console.error("Missing or placeholder STRAVA_ACCESS_TOKEN in .env"); return { content: [{ type: "text" as const, text: "❌ Configuration Error: STRAVA_ACCESS_TOKEN is missing or not set in the .env file." }], isError: true, }; } try { console.error("Fetching athlete profile..."); const athlete = await getAuthenticatedAthlete(token); console.error(`Successfully fetched profile for ${athlete.firstname} ${athlete.lastname} (ID: ${athlete.id}).`); const profileParts = [ `👤 **Profile for ${athlete.firstname} ${athlete.lastname}** (ID: ${athlete.id})`, ` - Username: ${athlete.username || 'N/A'}`, ` - Location: ${[athlete.city, athlete.state, athlete.country].filter(Boolean).join(", ") || 'N/A'}`, ` - Sex: ${athlete.sex || 'N/A'}`, ` - Weight: ${athlete.weight ? `${athlete.weight} kg` : 'N/A'}`, ` - Measurement Units: ${athlete.measurement_preference}`, ` - Strava Summit Member: ${athlete.summit ? 'Yes' : 'No'}`, ` - Profile Image (Medium): ${athlete.profile_medium}`, ` - Joined Strava: ${athlete.created_at ? new Date(athlete.created_at).toLocaleDateString() : 'N/A'}`, ` - Last Updated: ${athlete.updated_at ? new Date(athlete.updated_at).toLocaleDateString() : 'N/A'}`, ]; // Ensure return object matches expected structure const response = { content: [{ type: "text" as const, text: profileParts.join("\n") }] }; return response; } catch (error) { const errorMessage = error instanceof Error ? error.message : "An unknown error occurred"; console.error("Error in get-athlete-profile tool:", errorMessage); return { content: [{ type: "text" as const, text: `❌ API Error: ${errorMessage}` }], isError: true, }; } } }; // Removed old registration function /* export function registerGetAthleteProfileTool(server: McpServer) { server.tool( getAthleteProfile.name, getAthleteProfile.description, getAthleteProfile.execute // No input schema ); } */ ``` -------------------------------------------------------------------------------- /src/tools/getRecentActivities.ts: -------------------------------------------------------------------------------- ```typescript import { z } from "zod"; import { getRecentActivities as fetchActivities } from "../stravaClient.js"; // Reverted SDK type imports const GetRecentActivitiesInputSchema = z.object({ perPage: z.number().int().positive().optional().default(30).describe("Number of activities to retrieve (default: 30)"), }); type GetRecentActivitiesInput = z.infer<typeof GetRecentActivitiesInputSchema>; // Export the tool definition directly export const getRecentActivities = { name: "get-recent-activities", description: "Fetches the most recent activities for the authenticated athlete.", inputSchema: GetRecentActivitiesInputSchema, // Ensure the return type matches the expected structure, relying on inference execute: async ({ perPage }: GetRecentActivitiesInput) => { const token = process.env.STRAVA_ACCESS_TOKEN; // --- DEBUGGING: Print the token being used --- console.error(`[DEBUG] Using STRAVA_ACCESS_TOKEN: ${token?.substring(0, 5)}...${token?.slice(-5)}`); // --------------------------------------------- if (!token || token === 'YOUR_STRAVA_ACCESS_TOKEN_HERE') { console.error("Missing or placeholder STRAVA_ACCESS_TOKEN in .env"); // Use literal type for content item return { content: [{ type: "text" as const, text: "❌ Configuration Error: STRAVA_ACCESS_TOKEN is missing or not set in the .env file." }], isError: true, }; } try { console.error(`Fetching ${perPage} recent activities...`); const activities = await fetchActivities(token, perPage); console.error(`Successfully fetched ${activities?.length ?? 0} activities.`); if (!activities || activities.length === 0) { return { content: [{ type: "text" as const, text: " MNo recent activities found." }] }; } // Map to content items with literal type const contentItems = activities.map(activity => { const dateStr = activity.start_date ? new Date(activity.start_date).toLocaleDateString() : 'N/A'; const distanceStr = activity.distance ? `${activity.distance}m` : 'N/A'; // Ensure each item conforms to { type: "text", text: string } const item: { type: "text", text: string } = { type: "text" as const, text: `🏃 ${activity.name} (ID: ${activity.id ?? 'N/A'}) — ${distanceStr} on ${dateStr}` }; return item; }); // Return the basic McpResponse structure return { content: contentItems }; } catch (error) { const errorMessage = error instanceof Error ? error.message : "An unknown error occurred"; console.error("Error in get-recent-activities tool:", errorMessage); return { content: [{ type: "text" as const, text: `❌ API Error: ${errorMessage}` }], isError: true, }; } } }; ``` -------------------------------------------------------------------------------- /src/tools/listStarredSegments.ts: -------------------------------------------------------------------------------- ```typescript // import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; // Removed import { getAuthenticatedAthlete, listStarredSegments as fetchSegments } from "../stravaClient.js"; // Renamed import // Export the tool definition directly export const listStarredSegments = { name: "list-starred-segments", description: "Lists the segments starred by the authenticated athlete.", // No input schema needed inputSchema: undefined, execute: async () => { const token = process.env.STRAVA_ACCESS_TOKEN; if (!token || token === 'YOUR_STRAVA_ACCESS_TOKEN_HERE') { console.error("Missing or placeholder STRAVA_ACCESS_TOKEN in .env"); return { content: [{ type: "text" as const, text: "❌ Configuration Error: STRAVA_ACCESS_TOKEN is missing or not set in the .env file." }], isError: true, }; } try { console.error("Fetching starred segments..."); // Need athlete measurement preference for formatting distance const athlete = await getAuthenticatedAthlete(token); // Use renamed import const segments = await fetchSegments(token); console.error(`Successfully fetched ${segments?.length ?? 0} starred segments.`); if (!segments || segments.length === 0) { return { content: [{ type: "text" as const, text: " MNo starred segments found." }] }; } const distanceFactor = athlete.measurement_preference === 'feet' ? 0.000621371 : 0.001; const distanceUnit = athlete.measurement_preference === 'feet' ? 'mi' : 'km'; // Format the segments into a text response const segmentText = segments.map(segment => { const location = [segment.city, segment.state, segment.country].filter(Boolean).join(", ") || 'N/A'; const distance = (segment.distance * distanceFactor).toFixed(2); return ` ⭐ **${segment.name}** (ID: ${segment.id}) - Activity Type: ${segment.activity_type} - Distance: ${distance} ${distanceUnit} - Avg Grade: ${segment.average_grade}% - Location: ${location} - Private: ${segment.private ? 'Yes' : 'No'} `.trim(); }).join("\n---\n"); const responseText = `**Your Starred Segments:**\n\n${segmentText}`; return { content: [{ type: "text" as const, text: responseText }] }; } catch (error) { const errorMessage = error instanceof Error ? error.message : "An unknown error occurred"; console.error("Error in list-starred-segments tool:", errorMessage); return { content: [{ type: "text" as const, text: `❌ API Error: ${errorMessage}` }], isError: true, }; } } }; // Remove the old registration function /* export function registerListStarredSegmentsTool(server: McpServer) { server.tool( listStarredSegments.name, listStarredSegments.description, listStarredSegments.execute // No input schema ); } */ ``` -------------------------------------------------------------------------------- /src/tools/exportRouteTcx.ts: -------------------------------------------------------------------------------- ```typescript import { z } from "zod"; import * as fs from 'node:fs'; import * as path from 'node:path'; import { exportRouteTcx as fetchTcxData } from "../stravaClient.js"; // Define the input schema for the tool const ExportRouteTcxInputSchema = z.object({ routeId: z.string().describe("The ID of the Strava route to export."), }); // Infer the input type from the schema type ExportRouteTcxInput = z.infer<typeof ExportRouteTcxInputSchema>; // Export the tool definition directly export const exportRouteTcx = { name: "export-route-tcx", description: "Exports a specific Strava route in TCX format and saves it to a pre-configured local directory.", inputSchema: ExportRouteTcxInputSchema, execute: async ({ routeId }: ExportRouteTcxInput) => { const token = process.env.STRAVA_ACCESS_TOKEN; if (!token) { // Strict return structure return { content: [{ type: "text" as const, text: "❌ Error: Missing STRAVA_ACCESS_TOKEN in .env file." }], isError: true }; } const exportDir = process.env.ROUTE_EXPORT_PATH; if (!exportDir) { // Strict return structure return { content: [{ type: "text" as const, text: "❌ Error: Missing ROUTE_EXPORT_PATH in .env file. Please configure the directory for saving exports." }], isError: true }; } try { // Ensure the directory exists, create if not if (!fs.existsSync(exportDir)) { console.error(`Export directory ${exportDir} not found, creating it...`); fs.mkdirSync(exportDir, { recursive: true }); } else { // Check if it's a directory and writable (existing logic) const stats = fs.statSync(exportDir); if (!stats.isDirectory()) { // Strict return structure return { content: [{ type: "text" as const, text: `❌ Error: ROUTE_EXPORT_PATH (${exportDir}) is not a valid directory.` }], isError: true }; } fs.accessSync(exportDir, fs.constants.W_OK); } const tcxData = await fetchTcxData(token, routeId); const filename = `route-${routeId}.tcx`; const fullPath = path.join(exportDir, filename); fs.writeFileSync(fullPath, tcxData); // Strict return structure return { content: [{ type: "text" as const, text: `✅ Route ${routeId} exported successfully as TCX to: ${fullPath}` }], }; } catch (err: any) { // Handle potential errors during directory creation/check or file writing console.error(`Error in export-route-tcx tool for route ${routeId}:`, err); let userMessage = `❌ Error exporting route ${routeId} as TCX: ${err.message}`; if (err.code === 'EACCES') { userMessage = `❌ Error: No write permission for ROUTE_EXPORT_PATH directory (${exportDir}).`; } // Strict return structure return { content: [{ type: "text" as const, text: userMessage }], isError: true }; } }, }; ``` -------------------------------------------------------------------------------- /src/tools/exportRouteGpx.ts: -------------------------------------------------------------------------------- ```typescript import { z } from "zod"; import * as fs from 'node:fs'; import * as path from 'node:path'; import { exportRouteGpx as fetchGpxData } from "../stravaClient.js"; // import { McpServerTool } from "@modelcontextprotocol/sdk/server/mcp.js"; // Type doesn't seem exported/needed // import { McpResponse } from "@modelcontextprotocol/sdk/server/mcp.js"; // Type doesn't seem exported // Define the input schema for the tool const ExportRouteGpxInputSchema = z.object({ routeId: z.string().describe("The ID of the Strava route to export."), }); // Infer the input type from the schema type ExportRouteGpxInput = z.infer<typeof ExportRouteGpxInputSchema>; // Export the tool definition directly export const exportRouteGpx = { name: "export-route-gpx", description: "Exports a specific Strava route in GPX format and saves it to a pre-configured local directory.", inputSchema: ExportRouteGpxInputSchema, execute: async ({ routeId }: ExportRouteGpxInput) => { const token = process.env.STRAVA_ACCESS_TOKEN; if (!token) { // Strict return structure return { content: [{ type: "text" as const, text: "❌ Error: Missing STRAVA_ACCESS_TOKEN in .env file." }], isError: true }; } const exportDir = process.env.ROUTE_EXPORT_PATH; if (!exportDir) { // Strict return structure return { content: [{ type: "text" as const, text: "❌ Error: Missing ROUTE_EXPORT_PATH in .env file. Please configure the directory for saving exports." }], isError: true }; } try { // Ensure the directory exists, create if not if (!fs.existsSync(exportDir)) { console.error(`Export directory ${exportDir} not found, creating it...`); fs.mkdirSync(exportDir, { recursive: true }); } else { // Check if it's a directory and writable (existing logic) const stats = fs.statSync(exportDir); if (!stats.isDirectory()) { // Strict return structure return { content: [{ type: "text" as const, text: `❌ Error: ROUTE_EXPORT_PATH (${exportDir}) is not a valid directory.` }], isError: true }; } fs.accessSync(exportDir, fs.constants.W_OK); } const gpxData = await fetchGpxData(token, routeId); const filename = `route-${routeId}.gpx`; const fullPath = path.join(exportDir, filename); fs.writeFileSync(fullPath, gpxData); // Strict return structure return { content: [{ type: "text" as const, text: `✅ Route ${routeId} exported successfully as GPX to: ${fullPath}` }], }; } catch (err: any) { console.error(`Error in export-route-gpx tool for route ${routeId}:`, err); // Strict return structure let userMessage = `❌ Error exporting route ${routeId} as GPX: ${err.message}`; if (err.code === 'EACCES') { userMessage = `❌ Error: No write permission for ROUTE_EXPORT_PATH directory (${exportDir}).`; } return { content: [{ type: "text" as const, text: userMessage }], isError: true }; } }, }; ``` -------------------------------------------------------------------------------- /test-strava-api.ts: -------------------------------------------------------------------------------- ```typescript import axios from 'axios'; import * as dotenv from 'dotenv'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; // --- Environment Variable Loading --- // Explicitly load .env from the project root const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const envPath = path.resolve(__dirname, '.env'); // Assumes test script is in root, .env is in root console.log(`[TEST] Attempting to load .env file from: ${envPath}`); dotenv.config({ path: envPath }); // Get the token const accessToken = process.env.STRAVA_ACCESS_TOKEN; // Basic validation if (!accessToken || accessToken === 'YOUR_STRAVA_ACCESS_TOKEN_HERE') { console.error('❌ Error: STRAVA_ACCESS_TOKEN is not set or is a placeholder in the .env file.'); process.exit(1); } console.log(`[TEST] Using token: ${accessToken.substring(0, 5)}...${accessToken.slice(-5)}`); // Function to test the /athlete endpoint async function testAthleteCall() { console.log("--- Testing /athlete Endpoint ---"); if (!accessToken) { console.error("❌ STRAVA_ACCESS_TOKEN is not set in the .env file or environment."); return; } console.log(`Using token: ${accessToken.substring(0, 5)}...${accessToken.substring(accessToken.length - 5)}`); try { const response = await axios.get('https://www.strava.com/api/v3/athlete', { headers: { Authorization: `Bearer ${accessToken}`, }, }); console.log("✅ Request to /athlete successful:", response.status); console.log("Athlete Data:", JSON.stringify(response.data, null, 2)); } catch (error: any) { console.error("❌ Error calling /athlete:", error.message); if (error.response) { console.error("Status:", error.response.status); console.error("Data:", JSON.stringify(error.response.data, null, 2)); } } console.log("-------------------------------\n"); } // Function to test the /athlete/activities endpoint async function testActivitiesCall() { console.log("--- Testing /athlete/activities Endpoint ---"); if (!accessToken) { console.error("❌ STRAVA_ACCESS_TOKEN is not set in the .env file or environment."); return; } console.log(`Using token: ${accessToken.substring(0, 5)}...${accessToken.substring(accessToken.length - 5)}`); const perPage = 5; // Fetch 5 activities for the test try { const response = await axios.get('https://www.strava.com/api/v3/athlete/activities', { headers: { Authorization: `Bearer ${accessToken}`, }, params: { per_page: perPage } }); console.log(`✅ Request to /athlete/activities successful:`, response.status); console.log(`Received ${response.data?.length ?? 0} activities.`); // Optionally log activity names or IDs if(response.data && response.data.length > 0) { console.log("First activity name:", response.data[0].name); } } catch (error: any) { console.error("❌ Error calling /athlete/activities:", error.message); if (error.response) { console.error("Status:", error.response.status); console.error("Data:", JSON.stringify(error.response.data, null, 2)); } } console.log("---------------------------------------\n"); } // Run the tests (async () => { await testAthleteCall(); await testActivitiesCall(); })(); ``` -------------------------------------------------------------------------------- /src/tools/listAthleteRoutes.ts: -------------------------------------------------------------------------------- ```typescript // import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; // Removed import { z } from "zod"; import { listAthleteRoutes as fetchAthleteRoutes, StravaRoute, // StravaRoute is needed for the formatter } from "../stravaClient.js"; // Remove the imported formatter since we're defining our own locally // import { formatRouteSummary } from "../formatters.js"; // Define input schema with zod const ListAthleteRoutesInputSchema = z.object({ page: z.number().int().positive().optional().default(1).describe("Page number for pagination"), perPage: z.number().int().positive().min(1).max(50).optional().default(20).describe("Number of routes per page (max 50)"), }); // Export the type for use in the execute function type ListAthleteRoutesInput = z.infer<typeof ListAthleteRoutesInputSchema>; // Function to format a route for display function formatRouteSummary(route: StravaRoute): string { const distance = route.distance ? `${(route.distance / 1000).toFixed(1)} km` : 'N/A'; const elevation = route.elevation_gain ? `${route.elevation_gain.toFixed(0)} m` : 'N/A'; return `🗺️ **${route.name}** (ID: ${route.id}) - Distance: ${distance} - Elevation: ${elevation} - Created: ${new Date(route.created_at).toLocaleDateString()} - Type: ${route.type === 1 ? 'Ride' : route.type === 2 ? 'Run' : 'Other'}`; } // Tool definition export const listAthleteRoutesTool = { name: "list-athlete-routes", description: "Lists the routes created by the authenticated athlete, with pagination.", inputSchema: ListAthleteRoutesInputSchema, execute: async ({ page = 1, perPage = 20 }: ListAthleteRoutesInput) => { const token = process.env.STRAVA_ACCESS_TOKEN; if (!token) { console.error("Missing STRAVA_ACCESS_TOKEN in .env"); return { content: [{ type: "text" as const, text: "❌ Configuration Error: STRAVA_ACCESS_TOKEN is missing or not set in the .env file." }], isError: true }; } try { console.error(`Fetching routes (page ${page}, per_page: ${perPage})...`); const routes = await fetchAthleteRoutes(token, page, perPage); if (!routes || routes.length === 0) { console.error(`No routes found for athlete.`); return { content: [{ type: "text" as const, text: "No routes found for the athlete." }] }; } console.error(`Successfully fetched ${routes.length} routes.`); const summaries = routes.map(route => formatRouteSummary(route)); const responseText = `**Athlete Routes (Page ${page}):**\n\n${summaries.join("\n")}`; return { content: [{ type: "text" as const, text: responseText }] }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); console.error(`Error listing athlete routes (page ${page}, perPage: ${perPage}): ${errorMessage}`); // Removed call to handleApiError and its retry logic // Note: 404 is less likely for a list endpoint like this const userFriendlyMessage = `An unexpected error occurred while listing athlete routes. Details: ${errorMessage}`; return { content: [{ type: "text" as const, text: `❌ ${userFriendlyMessage}` }], isError: true }; } } }; // Removed local formatRouteSummary and formatDuration functions // Removed old registration function /* export function registerListAthleteRoutesTool(server: McpServer) { server.tool( listAthleteRoutes.name, listAthleteRoutes.description, listAthleteRoutes.inputSchema.shape, listAthleteRoutes.execute ); } */ ``` -------------------------------------------------------------------------------- /src/tools/getSegment.ts: -------------------------------------------------------------------------------- ```typescript // import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; // Removed import { z } from "zod"; import { getSegmentById as fetchSegmentById, // handleApiError, // Removed unused import StravaDetailedSegment // Type needed for formatter } from "../stravaClient.js"; // Input schema const GetSegmentInputSchema = z.object({ segmentId: z.number().int().positive().describe("The unique identifier of the segment to fetch.") }); type GetSegmentInput = z.infer<typeof GetSegmentInputSchema>; // Helper Functions (Metric Only) function formatDistance(meters: number | null | undefined): string { if (meters === null || meters === undefined) return 'N/A'; return (meters / 1000).toFixed(2) + ' km'; } function formatElevation(meters: number | null | undefined): string { if (meters === null || meters === undefined) return 'N/A'; return Math.round(meters) + ' m'; } // Format segment details (Metric Only) function formatSegmentDetails(segment: StravaDetailedSegment): string { const distance = formatDistance(segment.distance); const elevationGain = formatElevation(segment.total_elevation_gain); const elevationHigh = formatElevation(segment.elevation_high); const elevationLow = formatElevation(segment.elevation_low); let details = `🗺️ **Segment: ${segment.name}** (ID: ${segment.id})\n`; details += ` - Activity Type: ${segment.activity_type}\n`; details += ` - Location: ${segment.city || 'N/A'}, ${segment.state || 'N/A'}, ${segment.country || 'N/A'}\n`; details += ` - Distance: ${distance}\n`; details += ` - Avg Grade: ${segment.average_grade?.toFixed(1) ?? 'N/A'}%, Max Grade: ${segment.maximum_grade?.toFixed(1) ?? 'N/A'}%\n`; details += ` - Elevation: Gain ${elevationGain}, High ${elevationHigh}, Low ${elevationLow}\n`; details += ` - Climb Category: ${segment.climb_category ?? 'N/A'}\n`; details += ` - Private: ${segment.private ? 'Yes' : 'No'}\n`; details += ` - Starred by You: ${segment.starred ? 'Yes' : 'No'}\n`; // Assumes starred comes from auth'd user context if present details += ` - Total Efforts: ${segment.effort_count}, Athletes: ${segment.athlete_count}\n`; details += ` - Star Count: ${segment.star_count}\n`; details += ` - Created: ${new Date(segment.created_at).toLocaleDateString()}\n`; return details; } // Tool definition export const getSegmentTool = { name: "get-segment", description: "Fetches detailed information about a specific segment using its ID.", inputSchema: GetSegmentInputSchema, execute: async ({ segmentId }: GetSegmentInput) => { const token = process.env.STRAVA_ACCESS_TOKEN; if (!token) { console.error("Missing STRAVA_ACCESS_TOKEN environment variable."); return { content: [{ type: "text" as const, text: "Configuration error: Missing Strava access token." }], isError: true }; } try { console.error(`Fetching details for segment ID: ${segmentId}...`); // Removed getAuthenticatedAthlete call const segment = await fetchSegmentById(token, segmentId); const segmentDetailsText = formatSegmentDetails(segment); // Use metric formatter console.error(`Successfully fetched details for segment: ${segment.name}`); return { content: [{ type: "text" as const, text: segmentDetailsText }] }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); console.error(`Error fetching segment ${segmentId}: ${errorMessage}`); // Removed call to handleApiError const userFriendlyMessage = errorMessage.includes("Record Not Found") || errorMessage.includes("404") ? `Segment with ID ${segmentId} not found.` : `An unexpected error occurred while fetching segment details for ID ${segmentId}. Details: ${errorMessage}`; return { content: [{ type: "text" as const, text: `❌ ${userFriendlyMessage}` }], isError: true }; } } }; ``` -------------------------------------------------------------------------------- /src/tools/getAthleteZones.ts: -------------------------------------------------------------------------------- ```typescript import { z } from "zod"; import { getAthleteZones as fetchAthleteZones, StravaAthleteZones } from "../stravaClient.js"; import { formatDuration } from "../server.js"; // Shared helper const name = "get-athlete-zones"; const description = "Retrieves the authenticated athlete's configured heart rate and power zones."; // No input schema needed for this tool const inputSchema = z.object({}); type GetAthleteZonesInput = z.infer<typeof inputSchema>; // Helper to format a single zone range function formatZoneRange(zone: { min: number; max?: number }): string { return zone.max ? `${zone.min} - ${zone.max}` : `${zone.min}+`; } // Helper to format distribution buckets function formatDistribution(buckets: { max: number; min: number; time: number }[] | undefined): string { if (!buckets || buckets.length === 0) return " Distribution data not available."; return buckets.map(bucket => ` - ${bucket.min}-${bucket.max === -1 ? '∞' : bucket.max}: ${formatDuration(bucket.time)}` ).join('\n'); } // Format the zones response function formatAthleteZones(zonesData: StravaAthleteZones): string { let responseText = "**Athlete Zones:**\n"; if (zonesData.heart_rate) { responseText += "\n❤️ **Heart Rate Zones**\n"; responseText += ` Custom Zones: ${zonesData.heart_rate.custom_zones ? 'Yes' : 'No'}\n`; zonesData.heart_rate.zones.forEach((zone, index) => { responseText += ` Zone ${index + 1}: ${formatZoneRange(zone)} bpm\n`; }); if (zonesData.heart_rate.distribution_buckets) { responseText += " Time Distribution:\n" + formatDistribution(zonesData.heart_rate.distribution_buckets) + "\n"; } } else { responseText += "\n❤️ Heart Rate Zones: Not configured\n"; } if (zonesData.power) { responseText += "\n⚡ **Power Zones**\n"; zonesData.power.zones.forEach((zone, index) => { responseText += ` Zone ${index + 1}: ${formatZoneRange(zone)} W\n`; }); if (zonesData.power.distribution_buckets) { responseText += " Time Distribution:\n" + formatDistribution(zonesData.power.distribution_buckets) + "\n"; } } else { responseText += "\n⚡ Power Zones: Not configured\n"; } return responseText; } export const getAthleteZonesTool = { name, description: description + "\n\nOutput includes both a formatted summary and the raw JSON data.", inputSchema, execute: async (_input: GetAthleteZonesInput) => { const token = process.env.STRAVA_ACCESS_TOKEN; if (!token) { console.error("Missing STRAVA_ACCESS_TOKEN environment variable."); return { content: [{ type: "text" as const, text: "Configuration error: Missing Strava access token." }], isError: true }; } try { console.error("Fetching athlete zones..."); const zonesData = await fetchAthleteZones(token); // Format the summary const formattedText = formatAthleteZones(zonesData); // Prepare the raw data const rawDataText = `\n\nRaw Athlete Zone Data:\n${JSON.stringify(zonesData, null, 2)}`; console.error("Successfully fetched athlete zones."); // Return both summary and raw data return { content: [ { type: "text" as const, text: formattedText }, { type: "text" as const, text: rawDataText } ] }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); console.error(`Error fetching athlete zones: ${errorMessage}`); let userFriendlyMessage; // Check for common errors like missing scope (403 Forbidden) if (errorMessage.includes("403")) { userFriendlyMessage = "🔒 Access denied. This tool requires 'profile:read_all' permission. Please re-authorize with the correct scope."; } else if (errorMessage.startsWith("SUBSCRIPTION_REQUIRED:")) { // In case Strava changes this later userFriendlyMessage = `🔒 Accessing zones might require a Strava subscription. Details: ${errorMessage}`; } else { userFriendlyMessage = `An unexpected error occurred while fetching athlete zones. Details: ${errorMessage}`; } return { content: [{ type: "text" as const, text: `❌ ${userFriendlyMessage}` }], isError: true }; } } }; ``` -------------------------------------------------------------------------------- /src/tools/getActivityLaps.ts: -------------------------------------------------------------------------------- ```typescript import { z } from "zod"; import { getActivityLaps as getActivityLapsClient } from "../stravaClient.js"; import { formatDuration } from "../server.js"; // Import helper const name = "get-activity-laps"; const description = ` Retrieves detailed lap data for a specific Strava activity. Use Cases: - Get complete lap data including timestamps, speeds, and metrics - Access raw values for detailed analysis or visualization - Extract specific lap metrics for comparison or tracking Parameters: - id (required): The unique identifier of the Strava activity. Output Format: Returns both a human-readable summary and complete JSON data for each lap, including: 1. A text summary with formatted metrics 2. Raw lap data containing all fields from the Strava API: - Unique lap ID and indices - Timestamps (start_date, start_date_local) - Distance and timing metrics - Speed metrics (average and max) - Performance metrics (heart rate, cadence, power if available) - Elevation data - Resource state information - Activity and athlete references Notes: - Requires activity:read scope for public/followers activities, activity:read_all for private activities - Returns complete data as received from Strava API without omissions - All numeric values are preserved in their original precision `; const inputSchema = z.object({ id: z.union([z.number(), z.string()]).describe("The identifier of the activity to fetch laps for."), }); type GetActivityLapsInput = z.infer<typeof inputSchema>; export const getActivityLapsTool = { name, description, inputSchema, execute: async ({ id }: GetActivityLapsInput) => { const token = process.env.STRAVA_ACCESS_TOKEN; if (!token) { console.error("Missing STRAVA_ACCESS_TOKEN environment variable."); return { content: [{ type: "text" as const, text: "Configuration error: Missing Strava access token." }], isError: true }; } try { console.error(`Fetching laps for activity ID: ${id}...`); const laps = await getActivityLapsClient(token, id); if (!laps || laps.length === 0) { return { content: [{ type: "text" as const, text: `✅ No laps found for activity ID: ${id}` }] }; } // Generate human-readable summary const lapSummaries = laps.map(lap => { const details = [ `Lap ${lap.lap_index}: ${lap.name || 'Unnamed Lap'}`, ` Time: ${formatDuration(lap.elapsed_time)} (Moving: ${formatDuration(lap.moving_time)})`, ` Distance: ${(lap.distance / 1000).toFixed(2)} km`, ` Avg Speed: ${lap.average_speed ? (lap.average_speed * 3.6).toFixed(2) + ' km/h' : 'N/A'}`, ` Max Speed: ${lap.max_speed ? (lap.max_speed * 3.6).toFixed(2) + ' km/h' : 'N/A'}`, lap.total_elevation_gain ? ` Elevation Gain: ${lap.total_elevation_gain.toFixed(1)} m` : null, lap.average_heartrate ? ` Avg HR: ${lap.average_heartrate.toFixed(1)} bpm` : null, lap.max_heartrate ? ` Max HR: ${lap.max_heartrate} bpm` : null, lap.average_cadence ? ` Avg Cadence: ${lap.average_cadence.toFixed(1)} rpm` : null, lap.average_watts ? ` Avg Power: ${lap.average_watts.toFixed(1)} W ${lap.device_watts ? '(Sensor)' : ''}` : null, ]; return details.filter(d => d !== null).join('\n'); }); const summaryText = `Activity Laps Summary (ID: ${id}):\n\n${lapSummaries.join('\n\n')}`; // Add raw data section const rawDataText = `\n\nComplete Lap Data:\n${JSON.stringify(laps, null, 2)}`; console.error(`Successfully fetched ${laps.length} laps for activity ${id}`); return { content: [ { type: "text" as const, text: summaryText }, { type: "text" as const, text: rawDataText } ] }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); console.error(`Error fetching laps for activity ${id}: ${errorMessage}`); const userFriendlyMessage = errorMessage.includes("Record Not Found") || errorMessage.includes("404") ? `Activity with ID ${id} not found.` : `An unexpected error occurred while fetching laps for activity ${id}. Details: ${errorMessage}`; return { content: [{ type: "text" as const, text: `❌ ${userFriendlyMessage}` }], isError: true }; } } }; ``` -------------------------------------------------------------------------------- /src/tools/exploreSegments.ts: -------------------------------------------------------------------------------- ```typescript // import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; // Removed import { z } from "zod"; import { getAuthenticatedAthlete, exploreSegments as fetchExploreSegments, // Renamed import StravaExplorerResponse } from "../stravaClient.js"; const ExploreSegmentsInputSchema = z.object({ bounds: z.string() .regex(/^-?\d+(\.\d+)?,-?\d+(\.\d+)?,-?\d+(\.\d+)?,-?\d+(\.\d+)?$/, "Bounds must be in the format: south_west_lat,south_west_lng,north_east_lat,north_east_lng") .describe("The geographical area to search, specified as a comma-separated string: south_west_lat,south_west_lng,north_east_lat,north_east_lng"), activityType: z.enum(["running", "riding"]) .optional() .describe("Filter segments by activity type (optional: 'running' or 'riding')."), minCat: z.number().int().min(0).max(5).optional() .describe("Filter by minimum climb category (optional, 0-5). Requires riding activityType."), maxCat: z.number().int().min(0).max(5).optional() .describe("Filter by maximum climb category (optional, 0-5). Requires riding activityType."), }); type ExploreSegmentsInput = z.infer<typeof ExploreSegmentsInputSchema>; // Export the tool definition directly export const exploreSegments = { name: "explore-segments", description: "Searches for popular segments within a given geographical area.", inputSchema: ExploreSegmentsInputSchema, execute: async ({ bounds, activityType, minCat, maxCat }: ExploreSegmentsInput) => { const token = process.env.STRAVA_ACCESS_TOKEN; if (!token || token === 'YOUR_STRAVA_ACCESS_TOKEN_HERE') { console.error("Missing or placeholder STRAVA_ACCESS_TOKEN in .env"); return { content: [{ type: "text" as const, text: "❌ Configuration Error: STRAVA_ACCESS_TOKEN is missing or not set in the .env file." }], isError: true, }; } if ((minCat !== undefined || maxCat !== undefined) && activityType !== 'riding') { return { content: [{ type: "text" as const, text: "❌ Input Error: Climb category filters (minCat, maxCat) require activityType to be 'riding'." }], isError: true, }; } try { console.error(`Exploring segments within bounds: ${bounds}...`); const athlete = await getAuthenticatedAthlete(token); const response: StravaExplorerResponse = await fetchExploreSegments(token, bounds, activityType, minCat, maxCat); console.error(`Found ${response.segments?.length ?? 0} segments.`); if (!response.segments || response.segments.length === 0) { return { content: [{ type: "text" as const, text: " MNo segments found in the specified area with the given filters." }] }; } const distanceFactor = athlete.measurement_preference === 'feet' ? 0.000621371 : 0.001; const distanceUnit = athlete.measurement_preference === 'feet' ? 'mi' : 'km'; const elevationFactor = athlete.measurement_preference === 'feet' ? 3.28084 : 1; const elevationUnit = athlete.measurement_preference === 'feet' ? 'ft' : 'm'; const segmentItems = response.segments.map(segment => { const distance = (segment.distance * distanceFactor).toFixed(2); const elevDifference = (segment.elev_difference * elevationFactor).toFixed(0); const text = ` 🗺️ **${segment.name}** (ID: ${segment.id}) - Climb: Cat ${segment.climb_category_desc} (${segment.climb_category}) - Distance: ${distance} ${distanceUnit} - Avg Grade: ${segment.avg_grade}% - Elev Difference: ${elevDifference} ${elevationUnit} - Starred: ${segment.starred ? 'Yes' : 'No'} `.trim(); const item: { type: "text", text: string } = { type: "text" as const, text }; return item; }); const responseText = `**Found Segments:**\n\n${segmentItems.map(item => item.text).join("\n---\n")}`; return { content: [{ type: "text" as const, text: responseText }] }; } catch (error) { const errorMessage = error instanceof Error ? error.message : "An unknown error occurred"; console.error("Error in explore-segments tool:", errorMessage); return { content: [{ type: "text" as const, text: `❌ API Error: ${errorMessage}` }], isError: true, }; } } }; // Remove the old registration function /* export function registerExploreSegmentsTool(server: McpServer) { server.tool( exploreSegments.name, exploreSegments.description, exploreSegments.inputSchema.shape, exploreSegments.execute ); } */ ``` -------------------------------------------------------------------------------- /src/tools/getSegmentEffort.ts: -------------------------------------------------------------------------------- ```typescript // import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; // Removed import { z } from "zod"; import { StravaDetailedSegmentEffort, getSegmentEffort as fetchSegmentEffort, } from "../stravaClient.js"; // import { formatDuration } from "../server.js"; // Removed, now local const GetSegmentEffortInputSchema = z.object({ effortId: z.number().int().positive().describe("The unique identifier of the segment effort to fetch.") }); type GetSegmentEffortInput = z.infer<typeof GetSegmentEffortInputSchema>; // Helper Functions (Metric Only) function formatDuration(seconds: number | null | undefined): string { if (seconds === null || seconds === undefined || isNaN(seconds) || seconds < 0) return 'N/A'; const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); const secs = Math.floor(seconds % 60); const parts: string[] = []; if (hours > 0) parts.push(hours.toString().padStart(2, '0')); parts.push(minutes.toString().padStart(2, '0')); parts.push(secs.toString().padStart(2, '0')); return parts.join(':'); } function formatDistance(meters: number | null | undefined): string { if (meters === null || meters === undefined) return 'N/A'; return (meters / 1000).toFixed(2) + ' km'; } // Format segment effort details (Metric Only) function formatSegmentEffort(effort: StravaDetailedSegmentEffort): string { const movingTime = formatDuration(effort.moving_time); const elapsedTime = formatDuration(effort.elapsed_time); const distance = formatDistance(effort.distance); // Remove speed/pace calculations as fields are not available on effort object // const avgSpeed = formatSpeed(effort.average_speed); // const maxSpeed = formatSpeed(effort.max_speed); // const avgPace = formatPace(effort.average_speed); let details = `⏱️ **Segment Effort: ${effort.name}** (ID: ${effort.id})\n`; details += ` - Activity ID: ${effort.activity.id}, Athlete ID: ${effort.athlete.id}\n`; details += ` - Segment ID: ${effort.segment.id}\n`; details += ` - Date: ${new Date(effort.start_date_local).toLocaleString()}\n`; details += ` - Moving Time: ${movingTime}, Elapsed Time: ${elapsedTime}\n`; if (effort.distance !== undefined) details += ` - Distance: ${distance}\n`; // Remove speed/pace display lines // if (effort.average_speed !== undefined) { ... } // if (effort.max_speed !== undefined) { ... } if (effort.average_cadence !== undefined && effort.average_cadence !== null) details += ` - Avg Cadence: ${effort.average_cadence.toFixed(1)}\n`; if (effort.average_watts !== undefined && effort.average_watts !== null) details += ` - Avg Watts: ${effort.average_watts.toFixed(1)}\n`; if (effort.average_heartrate !== undefined && effort.average_heartrate !== null) details += ` - Avg Heart Rate: ${effort.average_heartrate.toFixed(1)} bpm\n`; if (effort.max_heartrate !== undefined && effort.max_heartrate !== null) details += ` - Max Heart Rate: ${effort.max_heartrate.toFixed(0)} bpm\n`; if (effort.kom_rank !== null) details += ` - KOM Rank: ${effort.kom_rank}\n`; if (effort.pr_rank !== null) details += ` - PR Rank: ${effort.pr_rank}\n`; details += ` - Hidden: ${effort.hidden ? 'Yes' : 'No'}\n`; return details; } // Tool definition export const getSegmentEffortTool = { name: "get-segment-effort", description: "Fetches detailed information about a specific segment effort using its ID.", inputSchema: GetSegmentEffortInputSchema, execute: async ({ effortId }: GetSegmentEffortInput) => { const token = process.env.STRAVA_ACCESS_TOKEN; if (!token) { console.error("Missing STRAVA_ACCESS_TOKEN environment variable."); return { content: [{ type: "text" as const, text: "Configuration error: Missing Strava access token." }], isError: true }; } try { console.error(`Fetching details for segment effort ID: ${effortId}...`); // Removed getAuthenticatedAthlete call const effort = await fetchSegmentEffort(token, effortId); const effortDetailsText = formatSegmentEffort(effort); // Use metric formatter console.error(`Successfully fetched details for effort: ${effort.name}`); return { content: [{ type: "text" as const, text: effortDetailsText }] }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); console.error(`Error fetching segment effort ${effortId}: ${errorMessage}`); let userFriendlyMessage; if (errorMessage.startsWith("SUBSCRIPTION_REQUIRED:")) { userFriendlyMessage = `🔒 Accessing this segment effort (ID: ${effortId}) requires a Strava subscription. Please check your subscription status.`; } else if (errorMessage.includes("Record Not Found") || errorMessage.includes("404")) { userFriendlyMessage = `Segment effort with ID ${effortId} not found.`; } else { userFriendlyMessage = `An unexpected error occurred while fetching segment effort ${effortId}. Details: ${errorMessage}`; } return { content: [{ type: "text" as const, text: `❌ ${userFriendlyMessage}` }], isError: true }; } } }; // Removed old registration function /* export function registerGetSegmentEffortTool(server: McpServer) { server.tool( getSegmentEffort.name, getSegmentEffort.description, getSegmentEffort.inputSchema.shape, getSegmentEffort.execute ); } */ ``` -------------------------------------------------------------------------------- /src/tools/listSegmentEfforts.ts: -------------------------------------------------------------------------------- ```typescript // import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; // Removed import { z } from "zod"; import { listSegmentEfforts as fetchSegmentEfforts, // handleApiError, // Removed unused import StravaDetailedSegmentEffort // Type needed for formatter } from "../stravaClient.js"; // We need the formatter, but can't import the full tool. Let's copy it here for now. // TODO: Move formatters to a shared utils.ts file // Zod schema for input validation const ListSegmentEffortsInputSchema = z.object({ segmentId: z.number().int().positive().describe("The ID of the segment for which to list efforts."), startDateLocal: z.string().datetime({ message: "Invalid start date format. Use ISO 8601." }).optional().describe("Filter efforts starting after this ISO 8601 date-time (optional)."), endDateLocal: z.string().datetime({ message: "Invalid end date format. Use ISO 8601." }).optional().describe("Filter efforts ending before this ISO 8601 date-time (optional)."), perPage: z.number().int().positive().max(200).optional().default(30).describe("Number of efforts to return per page (default: 30, max: 200).") }); type ListSegmentEffortsInput = z.infer<typeof ListSegmentEffortsInputSchema>; // Helper Functions (Metric Only) - Copied locally function formatDuration(seconds: number | null | undefined): string { if (seconds === null || seconds === undefined || isNaN(seconds) || seconds < 0) return 'N/A'; const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); const secs = Math.floor(seconds % 60); const parts: string[] = []; if (hours > 0) parts.push(hours.toString().padStart(2, '0')); parts.push(minutes.toString().padStart(2, '0')); parts.push(secs.toString().padStart(2, '0')); return parts.join(':'); } function formatDistance(meters: number | null | undefined): string { if (meters === null || meters === undefined) return 'N/A'; return (meters / 1000).toFixed(2) + ' km'; } // Format segment effort summary (Metric Only) function formatSegmentEffort(effort: StravaDetailedSegmentEffort): string { const movingTime = formatDuration(effort.moving_time); const elapsedTime = formatDuration(effort.elapsed_time); const distance = formatDistance(effort.distance); // Basic summary: Effort ID, Date, Moving Time, Distance, PR Rank let summary = `⏱️ Effort ID: ${effort.id} (${new Date(effort.start_date_local).toLocaleDateString()})`; summary += ` | Time: ${movingTime} (Moving), ${elapsedTime} (Elapsed)`; summary += ` | Dist: ${distance}`; if (effort.pr_rank !== null) summary += ` | PR Rank: ${effort.pr_rank}`; if (effort.kom_rank !== null) summary += ` | KOM Rank: ${effort.kom_rank}`; // Add KOM if available return summary; } // Tool definition export const listSegmentEffortsTool = { name: "list-segment-efforts", description: "Lists the authenticated athlete's efforts on a specific segment, optionally filtering by date.", inputSchema: ListSegmentEffortsInputSchema, execute: async ({ segmentId, startDateLocal, endDateLocal, perPage }: ListSegmentEffortsInput) => { const token = process.env.STRAVA_ACCESS_TOKEN; if (!token) { console.error("Missing STRAVA_ACCESS_TOKEN environment variable."); return { content: [{ type: "text" as const, text: "Configuration error: Missing Strava access token." }], isError: true }; } try { console.error(`Fetching segment efforts for segment ID: ${segmentId}...`); // Use the new params object structure const efforts = await fetchSegmentEfforts(token, segmentId, { startDateLocal, endDateLocal, perPage }); if (!efforts || efforts.length === 0) { console.error(`No efforts found for segment ${segmentId} with the given filters.`); return { content: [{ type: "text" as const, text: `No efforts found for segment ${segmentId} matching the criteria.` }] }; } console.error(`Successfully fetched ${efforts.length} efforts for segment ${segmentId}.`); const effortSummaries = efforts.map(effort => formatSegmentEffort(effort)); // Use metric formatter const responseText = `**Segment ${segmentId} Efforts:**\n\n${effortSummaries.join("\n")}`; return { content: [{ type: "text" as const, text: responseText }] }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); console.error(`Error listing efforts for segment ${segmentId}: ${errorMessage}`); let userFriendlyMessage; if (errorMessage.startsWith("SUBSCRIPTION_REQUIRED:")) { userFriendlyMessage = `🔒 Accessing segment efforts requires a Strava subscription. Please check your subscription status.`; } else if (errorMessage.includes("Record Not Found") || errorMessage.includes("404")) { userFriendlyMessage = `Segment with ID ${segmentId} not found (when listing efforts).`; } else { userFriendlyMessage = `An unexpected error occurred while listing efforts for segment ${segmentId}. Details: ${errorMessage}`; } return { content: [{ type: "text" as const, text: `❌ ${userFriendlyMessage}` }], isError: true }; } } }; // Removed old registration function /* export function registerListSegmentEffortsTool(server: McpServer) { server.tool( listSegmentEfforts.name, listSegmentEfforts.description, listSegmentEfforts.inputSchema.shape, listSegmentEfforts.execute ); } */ ``` -------------------------------------------------------------------------------- /src/tools/formatWorkoutFile.ts: -------------------------------------------------------------------------------- ```typescript import { z } from "zod"; // Define types for workout segments interface WorkoutSegment { type: string; duration: { value: number; unit: 'min' | 'sec'; }; target: string; cadence?: number; notes?: string; } // Helper to convert various intensity targets to Zwift power zones function targetToZwiftPower(target: string): number { // Convert various formats to percentage of FTP const targetLower = target.toLowerCase(); // Handle direct FTP percentages const ftpMatch = targetLower.match(/(\d+)%\s*ftp/); if (ftpMatch?.[1]) { return parseInt(ftpMatch[1]) / 100; } // Handle common zone descriptions const zoneMap: { [key: string]: number } = { 'very easy': 0.5, // 50% FTP 'easy': 0.6, // 60% FTP 'zone 1': 0.6, // 60% FTP 'zone 2': 0.75, // 75% FTP 'moderate': 0.75, // 75% FTP 'tempo': 0.85, // 85% FTP 'zone 3': 0.85, // 85% FTP 'threshold': 1.0, // 100% FTP 'zone 4': 1.0, // 100% FTP 'hard': 1.05, // 105% FTP 'zone 5': 1.1, // 110% FTP 'very hard': 1.15, // 115% FTP 'max': 1.2, // 120% FTP }; // Try to match known descriptions for (const [desc, power] of Object.entries(zoneMap)) { if (targetLower.includes(desc)) { return power; } } // Default to moderate intensity if we can't determine return 0.75; } // Parse a duration string into seconds function parseDuration(duration: string): { value: number; unit: 'min' | 'sec' } { const match = duration.match(/(\d+)\s*(min|sec)/i); if (!match?.[1] || !match?.[2]) { throw new Error(`Invalid duration format: ${duration}`); } const value = parseInt(match[1]); const unit = match[2].toLowerCase() as 'min' | 'sec'; return { value, unit }; } // Parse workout text into structured segments function parseWorkoutText(text: string): WorkoutSegment[] { const segments: WorkoutSegment[] = []; const lines = text.split('\n'); for (const line of lines) { if (!line.trim().startsWith('-')) continue; // Extract the main parts using regex const segmentMatch = line.match(/^-\s*([^:]+):\s*(\d+\s*(?:min|sec))\s*at\s*([^[\n]+)(?:\s*\[([^\]]+)\])?/i); if (!segmentMatch?.[1] || !segmentMatch?.[2] || !segmentMatch?.[3]) continue; const [, type, duration, target, extras] = segmentMatch; const segment: WorkoutSegment = { type: type.trim(), duration: parseDuration(duration.trim()), target: target.trim() }; // Parse optional extras (cadence and notes) if (extras) { const cadenceMatch = extras.match(/Cadence:\s*(\d+)/i); if (cadenceMatch?.[1]) { segment.cadence = parseInt(cadenceMatch[1]); } const notesMatch = extras.match(/Notes:\s*([^\]]+)/i); if (notesMatch?.[1]) { segment.notes = notesMatch[1].trim(); } } segments.push(segment); } return segments; } // Generate ZWO XML content function generateZwoContent(segments: WorkoutSegment[]): string { const workoutSegments = segments.map(segment => { const durationSeconds = segment.duration.unit === 'min' ? segment.duration.value * 60 : segment.duration.value; const power = targetToZwiftPower(segment.target); const cadenceAttr = segment.cadence ? ` Cadence="${segment.cadence}"` : ''; const showsTarget = segment.target.toLowerCase().includes('ftp') ? ' ShowsPower="1"' : ''; return ` <SteadyState Duration="${durationSeconds}" Power="${power}"${cadenceAttr}${showsTarget}${segment.notes ? ` textEvent="${segment.notes}"` : ''}/>` }).join('\n'); return `<workout_file> <author>Strava MCP Server</author> <name>Generated Workout</name> <description>Workout generated based on recent activities</description> <sportType>bike</sportType> <tags></tags> <workout> ${workoutSegments} </workout> </workout_file>`; } // Tool definition export const formatWorkoutFile = { name: "format-workout-file", description: "Formats a workout plan into a structured file format (currently supports Zwift .zwo)", inputSchema: z.object({ workoutText: z.string().describe("The workout plan text in the specified format"), format: z.enum(['zwo']).default('zwo').describe("Output format (currently only 'zwo' is supported)") }), execute: async ({ workoutText, format }: { workoutText: string; format: 'zwo' }) => { try { // Parse the workout text into structured segments const segments = parseWorkoutText(workoutText); if (segments.length === 0) { return { content: [{ type: "text", text: "❌ No valid workout segments found in the input text. Please ensure the format matches the expected pattern." }], isError: true }; } // Generate the appropriate format if (format === 'zwo') { const zwoContent = generateZwoContent(segments); return { content: [{ type: "text", text: zwoContent, mimeType: "application/xml" // Help clients understand this is XML content }] }; } // Should never reach here due to zod validation throw new Error(`Unsupported format: ${format}`); } catch (error) { return { content: [{ type: "text", text: `❌ Failed to format workout: ${(error as Error).message}` }], isError: true }; } } }; ``` -------------------------------------------------------------------------------- /src/tools/getActivityDetails.ts: -------------------------------------------------------------------------------- ```typescript // import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; // Removed import { z } from "zod"; import { getActivityById as fetchActivityById, StravaDetailedActivity // Type needed for formatter } from "../stravaClient.js"; // import { formatDuration } from "../server.js"; // Removed, now local // Zod schema for input validation const GetActivityDetailsInputSchema = z.object({ activityId: z.number().int().positive().describe("The unique identifier of the activity to fetch details for.") }); type GetActivityDetailsInput = z.infer<typeof GetActivityDetailsInputSchema>; // Helper Functions (Metric Only) function formatDuration(seconds: number | null | undefined): string { if (seconds === null || seconds === undefined || isNaN(seconds) || seconds < 0) return 'N/A'; const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); const secs = Math.floor(seconds % 60); const parts: string[] = []; if (hours > 0) parts.push(hours.toString().padStart(2, '0')); parts.push(minutes.toString().padStart(2, '0')); parts.push(secs.toString().padStart(2, '0')); return parts.join(':'); } function formatDistance(meters: number | null | undefined): string { if (meters === null || meters === undefined) return 'N/A'; return (meters / 1000).toFixed(2) + ' km'; } function formatElevation(meters: number | null | undefined): string { if (meters === null || meters === undefined) return 'N/A'; return Math.round(meters) + ' m'; } function formatSpeed(mps: number | null | undefined): string { if (mps === null || mps === undefined) return 'N/A'; return (mps * 3.6).toFixed(1) + ' km/h'; // Convert m/s to km/h } function formatPace(mps: number | null | undefined): string { if (mps === null || mps === undefined || mps <= 0) return 'N/A'; const minutesPerKm = 1000 / (mps * 60); const minutes = Math.floor(minutesPerKm); const seconds = Math.round((minutesPerKm - minutes) * 60); return `${minutes}:${seconds.toString().padStart(2, '0')} /km`; } // Format activity details (Metric Only) function formatActivityDetails(activity: StravaDetailedActivity): string { const date = new Date(activity.start_date_local).toLocaleString(); const movingTime = formatDuration(activity.moving_time); const elapsedTime = formatDuration(activity.elapsed_time); const distance = formatDistance(activity.distance); const elevation = formatElevation(activity.total_elevation_gain); const avgSpeed = formatSpeed(activity.average_speed); const maxSpeed = formatSpeed(activity.max_speed); const avgPace = formatPace(activity.average_speed); // Calculate pace from speed let details = `🏃 **${activity.name}** (ID: ${activity.id})\n`; details += ` - Type: ${activity.type} (${activity.sport_type})\n`; details += ` - Date: ${date}\n`; details += ` - Moving Time: ${movingTime}, Elapsed Time: ${elapsedTime}\n`; if (activity.distance !== undefined) details += ` - Distance: ${distance}\n`; if (activity.total_elevation_gain !== undefined) details += ` - Elevation Gain: ${elevation}\n`; if (activity.average_speed !== undefined) { details += ` - Average Speed: ${avgSpeed}`; if (activity.type === 'Run') details += ` (Pace: ${avgPace})`; details += '\n'; } if (activity.max_speed !== undefined) details += ` - Max Speed: ${maxSpeed}\n`; if (activity.average_cadence !== undefined && activity.average_cadence !== null) details += ` - Avg Cadence: ${activity.average_cadence.toFixed(1)}\n`; if (activity.average_watts !== undefined && activity.average_watts !== null) details += ` - Avg Watts: ${activity.average_watts.toFixed(1)}\n`; if (activity.average_heartrate !== undefined && activity.average_heartrate !== null) details += ` - Avg Heart Rate: ${activity.average_heartrate.toFixed(1)} bpm\n`; if (activity.max_heartrate !== undefined && activity.max_heartrate !== null) details += ` - Max Heart Rate: ${activity.max_heartrate.toFixed(0)} bpm\n`; if (activity.calories !== undefined) details += ` - Calories: ${activity.calories.toFixed(0)}\n`; if (activity.description) details += ` - Description: ${activity.description}\n`; if (activity.gear) details += ` - Gear: ${activity.gear.name}\n`; return details; } // Tool definition export const getActivityDetailsTool = { name: "get-activity-details", description: "Fetches detailed information about a specific activity using its ID.", inputSchema: GetActivityDetailsInputSchema, execute: async ({ activityId }: GetActivityDetailsInput) => { const token = process.env.STRAVA_ACCESS_TOKEN; if (!token) { console.error("Missing STRAVA_ACCESS_TOKEN environment variable."); return { content: [{ type: "text" as const, text: "Configuration error: Missing Strava access token." }], isError: true }; } try { console.error(`Fetching details for activity ID: ${activityId}...`); // Removed getAuthenticatedAthlete call const activity = await fetchActivityById(token, activityId); const activityDetailsText = formatActivityDetails(activity); // Use metric formatter console.error(`Successfully fetched details for activity: ${activity.name}`); return { content: [{ type: "text" as const, text: activityDetailsText }] }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); console.error(`Error fetching activity ${activityId}: ${errorMessage}`); // Removed call to handleApiError const userFriendlyMessage = errorMessage.includes("Record Not Found") || errorMessage.includes("404") ? `Activity with ID ${activityId} not found.` : `An unexpected error occurred while fetching activity details for ID ${activityId}. Details: ${errorMessage}`; return { content: [{ type: "text" as const, text: `❌ ${userFriendlyMessage}` }], isError: true }; } } }; // Removed old registration function /* export function registerGetActivityDetailsTool(server: McpServer) { server.tool( getActivityDetails.name, getActivityDetails.description, getActivityDetails.inputSchema.shape, getActivityDetails.execute ); } */ ``` -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- ```typescript import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import * as dotenv from "dotenv"; import path from "path"; import { fileURLToPath } from "url"; // Import all tool definitions with the correct names import { getAthleteProfile } from './tools/getAthleteProfile.js'; import { getAthleteStatsTool } from "./tools/getAthleteStats.js"; import { getActivityDetailsTool } from "./tools/getActivityDetails.js"; import { getRecentActivities } from "./tools/getRecentActivities.js"; import { listAthleteClubs } from './tools/listAthleteClubs.js'; import { listStarredSegments } from './tools/listStarredSegments.js'; import { getSegmentTool } from "./tools/getSegment.js"; import { exploreSegments } from './tools/exploreSegments.js'; import { starSegment } from './tools/starSegment.js'; import { getSegmentEffortTool } from './tools/getSegmentEffort.js'; import { listSegmentEffortsTool } from './tools/listSegmentEfforts.js'; import { listAthleteRoutesTool } from './tools/listAthleteRoutes.js'; import { getRouteTool } from './tools/getRoute.js'; import { exportRouteGpx } from './tools/exportRouteGpx.js'; import { exportRouteTcx } from './tools/exportRouteTcx.js'; import { getActivityStreamsTool } from './tools/getActivityStreams.js'; import { getActivityLapsTool } from './tools/getActivityLaps.js'; import { getAthleteZonesTool } from './tools/getAthleteZones.js'; import { getAllActivities } from './tools/getAllActivities.js'; // Import the actual client function // import { // // exportRouteGpx as exportRouteGpxClient, // Removed unused alias // // exportRouteTcx as exportRouteTcxClient, // Removed unused alias // getActivityLaps as getActivityLapsClient // } from './stravaClient.js'; // Load .env file explicitly from project root const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const projectRoot = path.resolve(__dirname, '..'); const envPath = path.join(projectRoot, '.env'); // REMOVE THIS DEBUG LOG - Interferes with MCP Stdio transport // console.log(`[DEBUG] Attempting to load .env file from: ${envPath}`); dotenv.config({ path: envPath }); const server = new McpServer({ name: "Strava MCP Server", version: "1.0.0" }); // Register all tools using server.tool and the correct imported objects server.tool( getAthleteProfile.name, getAthleteProfile.description, {}, getAthleteProfile.execute ); server.tool( getAthleteStatsTool.name, getAthleteStatsTool.description, getAthleteStatsTool.inputSchema?.shape ?? {}, getAthleteStatsTool.execute ); server.tool( getActivityDetailsTool.name, getActivityDetailsTool.description, getActivityDetailsTool.inputSchema?.shape ?? {}, getActivityDetailsTool.execute ); server.tool( getRecentActivities.name, getRecentActivities.description, getRecentActivities.inputSchema?.shape ?? {}, getRecentActivities.execute ); server.tool( listAthleteClubs.name, listAthleteClubs.description, {}, listAthleteClubs.execute ); server.tool( listStarredSegments.name, listStarredSegments.description, {}, listStarredSegments.execute ); server.tool( getSegmentTool.name, getSegmentTool.description, getSegmentTool.inputSchema?.shape ?? {}, getSegmentTool.execute ); server.tool( exploreSegments.name, exploreSegments.description, exploreSegments.inputSchema?.shape ?? {}, exploreSegments.execute ); server.tool( starSegment.name, starSegment.description, starSegment.inputSchema?.shape ?? {}, starSegment.execute ); server.tool( getSegmentEffortTool.name, getSegmentEffortTool.description, getSegmentEffortTool.inputSchema?.shape ?? {}, getSegmentEffortTool.execute ); server.tool( listSegmentEffortsTool.name, listSegmentEffortsTool.description, listSegmentEffortsTool.inputSchema?.shape ?? {}, listSegmentEffortsTool.execute ); server.tool( listAthleteRoutesTool.name, listAthleteRoutesTool.description, listAthleteRoutesTool.inputSchema?.shape ?? {}, listAthleteRoutesTool.execute ); server.tool( getRouteTool.name, getRouteTool.description, getRouteTool.inputSchema?.shape ?? {}, getRouteTool.execute ); server.tool( exportRouteGpx.name, exportRouteGpx.description, exportRouteGpx.inputSchema?.shape ?? {}, exportRouteGpx.execute ); server.tool( exportRouteTcx.name, exportRouteTcx.description, exportRouteTcx.inputSchema?.shape ?? {}, exportRouteTcx.execute ); server.tool( getActivityStreamsTool.name, getActivityStreamsTool.description, getActivityStreamsTool.inputSchema?.shape ?? {}, getActivityStreamsTool.execute ); // --- Register get-activity-laps tool (Simplified) --- server.tool( getActivityLapsTool.name, getActivityLapsTool.description, getActivityLapsTool.inputSchema?.shape ?? {}, getActivityLapsTool.execute ); // --- Register get-athlete-zones tool --- server.tool( getAthleteZonesTool.name, getAthleteZonesTool.description, getAthleteZonesTool.inputSchema?.shape ?? {}, getAthleteZonesTool.execute ); // --- Register get-all-activities tool --- server.tool( getAllActivities.name, getAllActivities.description, getAllActivities.inputSchema?.shape ?? {}, getAllActivities.execute ); // --- Helper Functions --- // Moving formatDuration to utils or keeping it here if broadly used. // For now, it's imported by getActivityLaps.ts export function formatDuration(seconds: number): string { if (isNaN(seconds) || seconds < 0) { return 'N/A'; } const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); const secs = Math.floor(seconds % 60); const parts: string[] = []; if (hours > 0) { parts.push(hours.toString().padStart(2, '0')); } parts.push(minutes.toString().padStart(2, '0')); parts.push(secs.toString().padStart(2, '0')); return parts.join(':'); } // Removed other formatters - they are now local to their respective tools. // --- Server Startup --- async function startServer() { try { console.error("Starting Strava MCP Server..."); const transport = new StdioServerTransport(); await server.connect(transport); console.error(`Strava MCP Server connected via Stdio. Tools registered.`); } catch (error) { console.error("Failed to start server:", error); process.exit(1); } } startServer(); ``` -------------------------------------------------------------------------------- /scripts/setup-auth.ts: -------------------------------------------------------------------------------- ```typescript import axios from 'axios'; import * as dotenv from 'dotenv'; import * as readline from 'readline/promises'; import * as fs from 'fs/promises'; import * as path from 'path'; import { fileURLToPath } from 'url'; // Define required scopes for all current and planned tools // Explicitly request profile and activity read access. const REQUIRED_SCOPES = 'profile:read_all,activity:read_all,activity:read,profile:write'; const REDIRECT_URI = 'http://localhost'; // Must match one configured in Strava App settings const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const projectRoot = path.resolve(__dirname, '..'); const envPath = path.join(projectRoot, '.env'); const rl = readline.createInterface({ input: process.stdin, output: process.stdout, }); async function promptUser(question: string): Promise<string> { const answer = await rl.question(question); return answer.trim(); } async function loadEnv(): Promise<{ clientId?: string; clientSecret?: string }> { try { await fs.access(envPath); // Check if .env exists const envConfig = dotenv.parse(await fs.readFile(envPath)); return { clientId: envConfig.STRAVA_CLIENT_ID, clientSecret: envConfig.STRAVA_CLIENT_SECRET, }; } catch (error) { console.log('.env file not found or not readable. Will prompt for all values.'); return {}; } } async function updateEnvFile(tokens: { accessToken: string; refreshToken: string }): Promise<void> { let envContent = ''; try { envContent = await fs.readFile(envPath, 'utf-8'); } catch (error) { console.log('.env file not found, creating a new one.'); } const lines = envContent.split('\n'); const newLines: string[] = []; let accessTokenUpdated = false; let refreshTokenUpdated = false; for (const line of lines) { if (line.startsWith('STRAVA_ACCESS_TOKEN=')) { newLines.push(`STRAVA_ACCESS_TOKEN=${tokens.accessToken}`); accessTokenUpdated = true; } else if (line.startsWith('STRAVA_REFRESH_TOKEN=')) { newLines.push(`STRAVA_REFRESH_TOKEN=${tokens.refreshToken}`); refreshTokenUpdated = true; } else if (line.trim() !== '') { newLines.push(line); } } if (!accessTokenUpdated) { newLines.push(`STRAVA_ACCESS_TOKEN=${tokens.accessToken}`); } if (!refreshTokenUpdated) { newLines.push(`STRAVA_REFRESH_TOKEN=${tokens.refreshToken}`); } await fs.writeFile(envPath, newLines.join('\n').trim() + '\n'); console.log('✅ Tokens successfully saved to .env file.'); } async function main() { console.log('--- Strava API Token Setup ---'); const existingEnv = await loadEnv(); let clientId = existingEnv.clientId; let clientSecret = existingEnv.clientSecret; if (!clientId) { clientId = await promptUser('Enter your Strava Application Client ID: '); if (!clientId) { console.error('❌ Client ID is required.'); process.exit(1); } } else { console.log(`ℹ️ Using Client ID from .env: ${clientId}`); } if (!clientSecret) { clientSecret = await promptUser('Enter your Strava Application Client Secret: '); if (!clientSecret) { console.error('❌ Client Secret is required.'); process.exit(1); } } else { console.log(`ℹ️ Using Client Secret from .env.`); } const authUrl = `https://www.strava.com/oauth/authorize?client_id=${clientId}&response_type=code&redirect_uri=${REDIRECT_URI}&approval_prompt=force&scope=${REQUIRED_SCOPES}`; console.log('\nStep 1: Authorize Application'); console.log('Please visit the following URL in your browser:'); console.log(`\n${authUrl}\n`); console.log(`After authorizing, Strava will redirect you to ${REDIRECT_URI}.`); console.log('Copy the \'code\' value from the URL in your browser\'s address bar.'); console.log('(e.g., http://localhost/?state=&code=THIS_PART&scope=...)'); const authCode = await promptUser('\nPaste the authorization code here: '); if (!authCode) { console.error('❌ Authorization code is required.'); process.exit(1); } console.log('\nStep 2: Exchanging code for tokens...'); try { const response = await axios.post('https://www.strava.com/oauth/token', { client_id: clientId, client_secret: clientSecret, code: authCode, grant_type: 'authorization_code', }); const { access_token, refresh_token, expires_at } = response.data; if (!access_token || !refresh_token) { throw new Error('Failed to retrieve tokens from Strava.'); } console.log('\n✅ Successfully obtained tokens!'); console.log(`Access Token: ${access_token}`); console.log(`Refresh Token: ${refresh_token}`); console.log(`Access Token Expires At: ${new Date(expires_at * 1000).toLocaleString()}`); const save = await promptUser('\nDo you want to save these tokens to your .env file? (yes/no): '); if (save.toLowerCase() === 'yes' || save.toLowerCase() === 'y') { await updateEnvFile({ accessToken: access_token, refreshToken: refresh_token }); // Optionally save client_id and client_secret if they weren't in .env initially let envContent = ''; try { envContent = await fs.readFile(envPath, 'utf-8'); } catch (readError) { /* Ignore if file doesn't exist, it was created in updateEnvFile */ } let needsUpdate = false; if (!envContent.includes('STRAVA_CLIENT_ID=')) { envContent = `STRAVA_CLIENT_ID=${clientId}\n` + envContent; needsUpdate = true; } if (!envContent.includes('STRAVA_CLIENT_SECRET=')) { // Add secret before tokens if they exist const tokenLineIndex = envContent.indexOf('STRAVA_ACCESS_TOKEN='); if (tokenLineIndex !== -1) { envContent = envContent.substring(0, tokenLineIndex) + `STRAVA_CLIENT_SECRET=${clientSecret}\n` + envContent.substring(tokenLineIndex); } else { envContent = `STRAVA_CLIENT_SECRET=${clientSecret}\n` + envContent; // Add at the beginning if tokens aren't there } needsUpdate = true; } if (needsUpdate) { await fs.writeFile(envPath, envContent.trim() + '\n'); console.log('ℹ️ Client ID and Secret also saved/updated in .env.'); } } else { console.log('\nTokens not saved. Please store them securely yourself.'); } } catch (error: any) { console.error('\n❌ Error exchanging code for tokens:'); if (axios.isAxiosError(error) && error.response) { console.error(`Status: ${error.response.status}`); console.error(`Data: ${JSON.stringify(error.response.data)}`); } else { console.error(error.message || error); } process.exit(1); } finally { rl.close(); } } main(); ``` -------------------------------------------------------------------------------- /src/tools/getAthleteStats.ts: -------------------------------------------------------------------------------- ```typescript // import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; // Removed import { z } from "zod"; import { // getAuthenticatedAthlete as fetchAuthenticatedAthlete, // Removed getAthleteStats as fetchAthleteStats, // handleApiError, // Removed unused import StravaStats // Type needed for formatter } from "../stravaClient.js"; // formatDuration is now local or in utils, not imported from server.ts // Input schema: Now requires athleteId const GetAthleteStatsInputSchema = z.object({ athleteId: z.number().int().positive().describe("The unique identifier of the athlete to fetch stats for. Obtain this ID first by calling the get-athlete-profile tool.") }); // Define type alias for input type GetAthleteStatsInput = z.infer<typeof GetAthleteStatsInputSchema>; // Remove unused formatDuration function /* function formatDuration(seconds: number): string { if (isNaN(seconds) || seconds < 0) { return 'N/A'; } const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); const secs = Math.floor(seconds % 60); const parts: string[] = []; if (hours > 0) { parts.push(hours.toString().padStart(2, '0')); } parts.push(minutes.toString().padStart(2, '0')); parts.push(secs.toString().padStart(2, '0')); return parts.join(':'); } */ // Helper function to format numbers as strings with labels (metric) function formatStat(value: number | null | undefined, unit: 'km' | 'm' | 'hrs'): string { if (value === null || value === undefined) return 'N/A'; let formattedValue: string; if (unit === 'km') { formattedValue = (value / 1000).toFixed(2); } else if (unit === 'm') { formattedValue = Math.round(value).toString(); } else if (unit === 'hrs') { formattedValue = (value / 3600).toFixed(1); } else { formattedValue = value.toString(); } return `${formattedValue} ${unit}`; } // Format athlete stats (metric only) function formatStats(stats: StravaStats): string { const format = (label: string, total: number | null | undefined, unit: 'km' | 'm' | 'hrs', count?: number | null, time?: number | null) => { let line = ` - ${label}: ${formatStat(total, unit)}`; if (count !== undefined && count !== null) line += ` (${count} activities)`; if (time !== undefined && time !== null) line += ` / ${formatStat(time, 'hrs')} hours`; return line; }; let response = "📊 **Your Strava Stats:**\n"; if (stats.biggest_ride_distance !== undefined) { response += "**Rides:**\n"; response += format("Biggest Ride", stats.biggest_ride_distance, 'km') + '\n'; } if (stats.recent_ride_totals) { response += "*Recent Rides (last 4 weeks):*\n"; response += format("Distance", stats.recent_ride_totals.distance, 'km', stats.recent_ride_totals.count, stats.recent_ride_totals.moving_time) + '\n'; response += format("Elevation Gain", stats.recent_ride_totals.elevation_gain, 'm') + '\n'; } if (stats.ytd_ride_totals) { response += "*Year-to-Date Rides:*\n"; response += format("Distance", stats.ytd_ride_totals.distance, 'km', stats.ytd_ride_totals.count, stats.ytd_ride_totals.moving_time) + '\n'; response += format("Elevation Gain", stats.ytd_ride_totals.elevation_gain, 'm') + '\n'; } if (stats.all_ride_totals) { response += "*All-Time Rides:*\n"; response += format("Distance", stats.all_ride_totals.distance, 'km', stats.all_ride_totals.count, stats.all_ride_totals.moving_time) + '\n'; response += format("Elevation Gain", stats.all_ride_totals.elevation_gain, 'm') + '\n'; } // Similar blocks for Runs and Swims if needed... if (stats.recent_run_totals || stats.ytd_run_totals || stats.all_run_totals) { response += "\n**Runs:**\n"; if (stats.recent_run_totals) { response += "*Recent Runs (last 4 weeks):*\n"; response += format("Distance", stats.recent_run_totals.distance, 'km', stats.recent_run_totals.count, stats.recent_run_totals.moving_time) + '\n'; response += format("Elevation Gain", stats.recent_run_totals.elevation_gain, 'm') + '\n'; } if (stats.ytd_run_totals) { response += "*Year-to-Date Runs:*\n"; response += format("Distance", stats.ytd_run_totals.distance, 'km', stats.ytd_run_totals.count, stats.ytd_run_totals.moving_time) + '\n'; response += format("Elevation Gain", stats.ytd_run_totals.elevation_gain, 'm') + '\n'; } if (stats.all_run_totals) { response += "*All-Time Runs:*\n"; response += format("Distance", stats.all_run_totals.distance, 'km', stats.all_run_totals.count, stats.all_run_totals.moving_time) + '\n'; response += format("Elevation Gain", stats.all_run_totals.elevation_gain, 'm') + '\n'; } } // Add Swims similarly if needed return response; } // Tool definition export const getAthleteStatsTool = { name: "get-athlete-stats", description: "Fetches the activity statistics (recent, YTD, all-time) for a specific athlete using their ID. Requires the athleteId obtained from the get-athlete-profile tool.", inputSchema: GetAthleteStatsInputSchema, execute: async ({ athleteId }: GetAthleteStatsInput) => { const token = process.env.STRAVA_ACCESS_TOKEN; if (!token) { console.error("Missing STRAVA_ACCESS_TOKEN environment variable."); return { content: [{ type: "text" as const, text: "Configuration error: Missing Strava access token." }], isError: true }; } try { console.error(`Fetching stats for athlete ${athleteId}...`); const stats = await fetchAthleteStats(token, athleteId); const formattedStats = formatStats(stats); console.error(`Successfully fetched stats for athlete ${athleteId}.`); return { content: [{ type: "text" as const, text: formattedStats }] }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); console.error(`Error fetching stats for athlete ${athleteId}: ${errorMessage}`); const userFriendlyMessage = errorMessage.includes("Record Not Found") || errorMessage.includes("404") ? `Athlete with ID ${athleteId} not found (when fetching stats).` : `An unexpected error occurred while fetching stats for athlete ${athleteId}. Details: ${errorMessage}`; return { content: [{ type: "text" as const, text: `❌ ${userFriendlyMessage}` }], isError: true }; } } }; // Removed old registration function /* export function registerGetAthleteStatsTool(server: McpServer) { server.tool( getAthleteStats.name, getAthleteStats.description, getAthleteStats.execute // No input schema ); } */ ``` -------------------------------------------------------------------------------- /src/tools/getAllActivities.ts: -------------------------------------------------------------------------------- ```typescript import { z } from "zod"; import { getAllActivities as fetchAllActivities } from "../stravaClient.js"; // Common activity types export const ACTIVITY_TYPES = { // Core types RIDE: "Ride", RUN: "Run", SWIM: "Swim", // Common types WALK: "Walk", HIKE: "Hike", VIRTUAL_RIDE: "VirtualRide", VIRTUAL_RUN: "VirtualRun", WORKOUT: "Workout", WEIGHT_TRAINING: "WeightTraining", YOGA: "Yoga", // Winter sports ALPINE_SKI: "AlpineSki", BACKCOUNTRY_SKI: "BackcountrySki", NORDIC_SKI: "NordicSki", SNOWBOARD: "Snowboard", ICE_SKATE: "IceSkate", // Water sports KAYAKING: "Kayaking", ROWING: "Rowing", STAND_UP_PADDLING: "StandUpPaddling", SURFING: "Surfing", // Other GOLF: "Golf", ROCK_CLIMBING: "RockClimbing", SOCCER: "Soccer", ELLIPTICAL: "Elliptical", STAIR_STEPPER: "StairStepper" } as const; // Common sport types (more granular) export const SPORT_TYPES = { MOUNTAIN_BIKE_RIDE: "MountainBikeRide", GRAVEL_RIDE: "GravelRide", E_BIKE_RIDE: "EBikeRide", TRAIL_RUN: "TrailRun", VIRTUAL_RIDE: "VirtualRide", VIRTUAL_RUN: "VirtualRun" } as const; const GetAllActivitiesInputSchema = z.object({ startDate: z.string().optional().describe("ISO date string for activities after this date (e.g., '2024-01-01')"), endDate: z.string().optional().describe("ISO date string for activities before this date (e.g., '2024-12-31')"), activityTypes: z.array(z.string()).optional().describe("Array of activity types to filter (e.g., ['Run', 'Ride'])"), sportTypes: z.array(z.string()).optional().describe("Array of sport types for granular filtering (e.g., ['MountainBikeRide', 'TrailRun'])"), maxActivities: z.number().int().positive().optional().default(500).describe("Maximum activities to return after filtering (default: 500)"), maxApiCalls: z.number().int().positive().optional().default(10).describe("Maximum API calls to prevent quota exhaustion (default: 10 = ~2000 activities)"), perPage: z.number().int().positive().min(1).max(200).optional().default(200).describe("Activities per API call (default: 200, max: 200)") }); type GetAllActivitiesInput = z.infer<typeof GetAllActivitiesInputSchema>; // Helper function to format activity summary function formatActivitySummary(activity: any): string { const date = activity.start_date ? new Date(activity.start_date).toLocaleDateString() : 'N/A'; const distance = activity.distance ? `${(activity.distance / 1000).toFixed(2)} km` : 'N/A'; const duration = activity.moving_time ? formatDuration(activity.moving_time) : 'N/A'; const type = activity.sport_type || activity.type || 'Unknown'; let emoji = '🏃'; if (type.toLowerCase().includes('ride') || type.toLowerCase().includes('bike')) emoji = '🚴'; else if (type.toLowerCase().includes('swim')) emoji = '🏊'; else if (type.toLowerCase().includes('ski')) emoji = '⛷️'; else if (type.toLowerCase().includes('hike') || type.toLowerCase().includes('walk')) emoji = '🥾'; else if (type.toLowerCase().includes('yoga')) emoji = '🧘'; else if (type.toLowerCase().includes('weight')) emoji = '💪'; return `${emoji} ${activity.name} (${type}) - ${distance} in ${duration} on ${date}`; } // Helper function to format duration function formatDuration(seconds: number): string { const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); const secs = seconds % 60; if (hours > 0) { return `${hours}h ${minutes}m`; } else if (minutes > 0) { return `${minutes}m ${secs}s`; } return `${secs}s`; } // Export the tool definition export const getAllActivities = { name: "get-all-activities", description: "Fetches complete activity history with optional filtering by date range and activity type. Supports pagination to retrieve all activities.", inputSchema: GetAllActivitiesInputSchema, execute: async (input: GetAllActivitiesInput) => { const token = process.env.STRAVA_ACCESS_TOKEN; if (!token || token === 'YOUR_STRAVA_ACCESS_TOKEN_HERE') { console.error("Missing or placeholder STRAVA_ACCESS_TOKEN in .env"); return { content: [{ type: "text" as const, text: "❌ Configuration Error: STRAVA_ACCESS_TOKEN is missing or not set in the .env file." }], isError: true, }; } const { startDate, endDate, activityTypes, sportTypes, maxActivities = 500, maxApiCalls = 10, perPage = 200 } = input; try { // Convert dates to epoch timestamps if provided const before = endDate ? Math.floor(new Date(endDate).getTime() / 1000) : undefined; const after = startDate ? Math.floor(new Date(startDate).getTime() / 1000) : undefined; // Validate date inputs if (before && isNaN(before)) { return { content: [{ type: "text" as const, text: "❌ Invalid endDate format. Please use ISO date format (e.g., '2024-12-31')." }], isError: true }; } if (after && isNaN(after)) { return { content: [{ type: "text" as const, text: "❌ Invalid startDate format. Please use ISO date format (e.g., '2024-01-01')." }], isError: true }; } console.error(`Fetching activities with filters:`); console.error(` Date range: ${startDate || 'any'} to ${endDate || 'any'}`); console.error(` Activity types: ${activityTypes?.join(', ') || 'any'}`); console.error(` Sport types: ${sportTypes?.join(', ') || 'any'}`); console.error(` Max activities: ${maxActivities}, Max API calls: ${maxApiCalls}`); const allActivities: any[] = []; const filteredActivities: any[] = []; let apiCalls = 0; let currentPage = 1; let hasMore = true; // Progress callback const onProgress = (fetched: number, page: number) => { console.error(` Page ${page}: Fetched ${fetched} total activities...`); }; // Fetch activities page by page while (hasMore && apiCalls < maxApiCalls && filteredActivities.length < maxActivities) { apiCalls++; // Fetch a page of activities const pageActivities = await fetchAllActivities(token, { page: currentPage, perPage, before, after, onProgress }); // Check if we got any activities if (pageActivities.length === 0) { hasMore = false; break; } // Add to all activities allActivities.push(...pageActivities); // Apply filters if specified let toFilter = pageActivities; // Filter by activity type if (activityTypes && activityTypes.length > 0) { toFilter = toFilter.filter(a => activityTypes.some(type => a.type?.toLowerCase() === type.toLowerCase() ) ); } // Filter by sport type (more specific) if (sportTypes && sportTypes.length > 0) { toFilter = toFilter.filter(a => sportTypes.some(type => a.sport_type?.toLowerCase() === type.toLowerCase() ) ); } // Add filtered activities filteredActivities.push(...toFilter); // Check if we should continue hasMore = pageActivities.length === perPage; currentPage++; // Log progress console.error(` After page ${currentPage - 1}: ${allActivities.length} fetched, ${filteredActivities.length} match filters`); } // Limit results to maxActivities const resultsToReturn = filteredActivities.slice(0, maxActivities); // Prepare summary statistics const stats = { totalFetched: allActivities.length, totalMatching: filteredActivities.length, returned: resultsToReturn.length, apiCalls: apiCalls }; console.error(`\nFetch complete:`); console.error(` Total activities fetched: ${stats.totalFetched}`); console.error(` Activities matching filters: ${stats.totalMatching}`); console.error(` Activities returned: ${stats.returned}`); console.error(` API calls made: ${stats.apiCalls}`); if (resultsToReturn.length === 0) { return { content: [{ type: "text" as const, text: `No activities found matching your criteria.\n\nStatistics:\n- Fetched ${stats.totalFetched} activities\n- ${stats.totalMatching} matched filters\n- Used ${stats.apiCalls} API calls` }] }; } // Format activities for display const summaries = resultsToReturn.map(activity => formatActivitySummary(activity)); // Build response text let responseText = `**Found ${stats.returned} activities**\n\n`; responseText += `📊 Statistics:\n`; responseText += `- Total fetched: ${stats.totalFetched}\n`; responseText += `- Matching filters: ${stats.totalMatching}\n`; responseText += `- API calls: ${stats.apiCalls}\n\n`; if (stats.returned < stats.totalMatching) { responseText += `⚠️ Showing first ${stats.returned} of ${stats.totalMatching} matching activities (limited by maxActivities)\n\n`; } responseText += `**Activities:**\n${summaries.join('\n')}`; return { content: [{ type: "text" as const, text: responseText }] }; } catch (error) { const errorMessage = error instanceof Error ? error.message : "An unknown error occurred"; console.error("Error in get-all-activities tool:", errorMessage); // Check for rate limiting if (errorMessage.includes('429')) { return { content: [{ type: "text" as const, text: `⚠️ Rate limit reached. Please wait a few minutes before trying again.\n\nStrava API limits: 100 requests per 15 minutes, 1000 per day.` }], isError: true, }; } return { content: [{ type: "text" as const, text: `❌ API Error: ${errorMessage}` }], isError: true, }; } } }; ``` -------------------------------------------------------------------------------- /src/tools/getActivityStreams.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; import { stravaApi } from '../stravaClient.js'; // Define stream types available in Strava API const STREAM_TYPES = [ 'time', 'distance', 'latlng', 'altitude', 'velocity_smooth', 'heartrate', 'cadence', 'watts', 'temp', 'moving', 'grade_smooth' ] as const; // Define resolution types const RESOLUTION_TYPES = ['low', 'medium', 'high'] as const; // Input schema using Zod 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.' ) }); // Type for the input parameters type GetActivityStreamsParams = z.infer<typeof inputSchema>; // Stream interfaces based on Strava API types interface BaseStream { type: string; data: any[]; series_type: 'distance' | 'time'; original_size: number; resolution: 'low' | 'medium' | 'high'; } interface TimeStream extends BaseStream { type: 'time'; data: number[]; // seconds } interface DistanceStream extends BaseStream { type: 'distance'; data: number[]; // meters } interface LatLngStream extends BaseStream { type: 'latlng'; data: [number, number][]; // [latitude, longitude] } interface AltitudeStream extends BaseStream { type: 'altitude'; data: number[]; // meters } interface VelocityStream extends BaseStream { type: 'velocity_smooth'; data: number[]; // meters per second } interface HeartrateStream extends BaseStream { type: 'heartrate'; data: number[]; // beats per minute } interface CadenceStream extends BaseStream { type: 'cadence'; data: number[]; // rpm } interface PowerStream extends BaseStream { type: 'watts'; data: number[]; // watts } interface TempStream extends BaseStream { type: 'temp'; data: number[]; // celsius } interface MovingStream extends BaseStream { type: 'moving'; data: boolean[]; } interface GradeStream extends BaseStream { type: 'grade_smooth'; data: number[]; // percent grade } type StreamSet = (TimeStream | DistanceStream | LatLngStream | AltitudeStream | VelocityStream | HeartrateStream | CadenceStream | PowerStream | TempStream | MovingStream | GradeStream)[]; // Tool definition export const getActivityStreamsTool = { name: 'get-activity-streams', description: 'Retrieves detailed time-series data streams from a Strava activity. Perfect for analyzing workout metrics, ' + 'visualizing routes, or performing detailed activity analysis.\n\n' + 'Key Features:\n' + '1. Multiple Data Types: Access various metrics like heart rate, power, speed, GPS coordinates, etc.\n' + '2. Flexible Resolution: Choose data density from low (~100 points) to high (~10000 points)\n' + '3. Smart Pagination: Get data in manageable chunks or all at once\n' + '4. Rich Statistics: Includes min/max/avg for numeric streams\n' + '5. Formatted Output: Data is processed into human and LLM-friendly formats\n\n' + 'Common Use Cases:\n' + '- Analyzing workout intensity through heart rate zones\n' + '- Calculating power metrics for cycling activities\n' + '- Visualizing route data using GPS coordinates\n' + '- Analyzing pace and elevation changes\n' + '- Detailed segment analysis\n\n' + 'Output Format:\n' + '1. Metadata: Activity overview, available streams, data points\n' + '2. Statistics: Summary stats for each stream type (max/min/avg where applicable)\n' + '3. Stream Data: Actual time-series data, formatted for easy use\n\n' + 'Notes:\n' + '- Requires activity:read scope\n' + '- Not all streams are available for all activities\n' + '- Older activities might have limited data\n' + '- Large activities are automatically paginated to handle size limits', inputSchema, 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 }; } } }; // Helper function to calculate normalized 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); } ``` -------------------------------------------------------------------------------- /src/stravaClient.ts: -------------------------------------------------------------------------------- ```typescript import axios from "axios"; import { z } from "zod"; import fs from "fs/promises"; import path from "path"; import { fileURLToPath } from "url"; // --- Axios Instance & Interceptor --- // Create an Axios instance to apply interceptors globally for this client export const stravaApi = axios.create({ baseURL: 'https://www.strava.com/api/v3' }); // Add a request interceptor (can be used for logging or modifying requests) stravaApi.interceptors.request.use(config => { // REMOVE DEBUG LOGS - Interfere with MCP Stdio transport // let authHeaderLog = 'Not Set'; // const authHeaderValue = config.headers?.Authorization; // if (typeof authHeaderValue === 'string') { // authHeaderLog = `${authHeaderValue.substring(0, 12)}...[REDACTED]`; // } // console.error(`[DEBUG stravaClient] Sending Request: ${config.method?.toUpperCase()} ${config.url}`); // console.error(`[DEBUG stravaClient] Authorization Header: ${authHeaderLog}` ); return config; }, error => { console.error('[DEBUG stravaClient] Request Error Interceptor:', error); return Promise.reject(error); }); // ---------------------------------- // Define the expected structure of a Strava activity (add more fields as needed) const StravaActivitySchema = z.object({ id: z.number().int().optional(), // Include ID for recent activities name: z.string(), distance: z.number(), start_date: z.string().datetime(), // Add other relevant fields from the Strava API response if needed // e.g., moving_time: z.number(), type: z.string(), ... }); // Define the expected response structure for the activities endpoint const StravaActivitiesResponseSchema = z.array(StravaActivitySchema); // Define the expected structure for the Authenticated Athlete response const BaseAthleteSchema = z.object({ id: z.number().int(), resource_state: z.number().int(), }); const DetailedAthleteSchema = BaseAthleteSchema.extend({ username: z.string().nullable(), firstname: z.string(), lastname: z.string(), city: z.string().nullable(), state: z.string().nullable(), country: z.string().nullable(), sex: z.enum(["M", "F"]).nullable(), premium: z.boolean(), summit: z.boolean(), created_at: z.string().datetime(), updated_at: z.string().datetime(), profile_medium: z.string().url(), profile: z.string().url(), weight: z.number().nullable(), measurement_preference: z.enum(["feet", "meters"]).optional().nullable(), // Add other fields as needed (e.g., follower_count, friend_count, ftp, clubs, bikes, shoes) }); // Type alias for the inferred athlete type export type StravaAthlete = z.infer<typeof DetailedAthleteSchema>; // --- Stats Schemas --- // Schema for individual activity totals (like runs, rides, swims) const ActivityTotalSchema = z.object({ count: z.number().int(), distance: z.number(), // In meters moving_time: z.number().int(), // In seconds elapsed_time: z.number().int(), // In seconds elevation_gain: z.number(), // In meters achievement_count: z.number().int().optional().nullable(), // Optional based on Strava docs examples }); // Schema for the overall athlete stats response const ActivityStatsSchema = z.object({ biggest_ride_distance: z.number().optional().nullable(), biggest_climb_elevation_gain: z.number().optional().nullable(), recent_ride_totals: ActivityTotalSchema, recent_run_totals: ActivityTotalSchema, recent_swim_totals: ActivityTotalSchema, ytd_ride_totals: ActivityTotalSchema, ytd_run_totals: ActivityTotalSchema, ytd_swim_totals: ActivityTotalSchema, all_ride_totals: ActivityTotalSchema, all_run_totals: ActivityTotalSchema, all_swim_totals: ActivityTotalSchema, }); export type StravaStats = z.infer<typeof ActivityStatsSchema>; // --- Club Schema --- // Based on https://developers.strava.com/docs/reference/#api-models-SummaryClub const SummaryClubSchema = z.object({ id: z.number().int(), resource_state: z.number().int(), name: z.string(), profile_medium: z.string().url(), cover_photo: z.string().url().nullable(), cover_photo_small: z.string().url().nullable(), sport_type: z.string(), // cycling, running, triathlon, other activity_types: z.array(z.string()), // More specific types city: z.string(), state: z.string(), country: z.string(), private: z.boolean(), member_count: z.number().int(), featured: z.boolean(), verified: z.boolean(), url: z.string().nullable(), }); export type StravaClub = z.infer<typeof SummaryClubSchema>; const StravaClubsResponseSchema = z.array(SummaryClubSchema); // --- Gear Schema --- const SummaryGearSchema = z.object({ id: z.string(), resource_state: z.number().int(), primary: z.boolean(), name: z.string(), distance: z.number(), // Distance in meters for the gear }).nullable().optional(); // Activity might not have gear or it might be null // --- Map Schema --- const MapSchema = z.object({ id: z.string(), summary_polyline: z.string().optional().nullable(), resource_state: z.number().int(), }).nullable(); // Activity might not have a map // --- Segment Schema --- const SummarySegmentSchema = z.object({ id: z.number().int(), name: z.string(), activity_type: z.string(), distance: z.number(), average_grade: z.number(), maximum_grade: z.number(), elevation_high: z.number().optional().nullable(), elevation_low: z.number().optional().nullable(), start_latlng: z.array(z.number()).optional().nullable(), end_latlng: z.array(z.number()).optional().nullable(), climb_category: z.number().int().optional().nullable(), city: z.string().optional().nullable(), state: z.string().optional().nullable(), country: z.string().optional().nullable(), private: z.boolean().optional(), starred: z.boolean().optional(), }); const DetailedSegmentSchema = SummarySegmentSchema.extend({ created_at: z.string().datetime(), updated_at: z.string().datetime(), total_elevation_gain: z.number().optional().nullable(), map: MapSchema, // Now defined above effort_count: z.number().int(), athlete_count: z.number().int(), hazardous: z.boolean(), star_count: z.number().int(), }); export type StravaSegment = z.infer<typeof SummarySegmentSchema>; export type StravaDetailedSegment = z.infer<typeof DetailedSegmentSchema>; const StravaSegmentsResponseSchema = z.array(SummarySegmentSchema); // --- Explorer Schemas --- // Based on https://developers.strava.com/docs/reference/#api-models-ExplorerSegment const ExplorerSegmentSchema = z.object({ id: z.number().int(), name: z.string(), climb_category: z.number().int(), climb_category_desc: z.string(), // e.g., "NC", "4", "3", "2", "1", "HC" avg_grade: z.number(), start_latlng: z.array(z.number()), end_latlng: z.array(z.number()), elev_difference: z.number(), distance: z.number(), // meters points: z.string(), // Encoded polyline starred: z.boolean().optional(), // Only included if authenticated }); // Based on https://developers.strava.com/docs/reference/#api-models-ExplorerResponse const ExplorerResponseSchema = z.object({ segments: z.array(ExplorerSegmentSchema), }); export type StravaExplorerSegment = z.infer<typeof ExplorerSegmentSchema>; export type StravaExplorerResponse = z.infer<typeof ExplorerResponseSchema>; // --- Detailed Activity Schema --- // Based on https://developers.strava.com/docs/reference/#api-models-DetailedActivity const DetailedActivitySchema = z.object({ id: z.number().int(), resource_state: z.number().int(), // Should be 3 for detailed athlete: BaseAthleteSchema, // Contains athlete ID name: z.string(), distance: z.number().optional(), // Optional for stationary activities moving_time: z.number().int().optional(), elapsed_time: z.number().int(), total_elevation_gain: z.number().optional(), type: z.string(), // e.g., "Run", "Ride" sport_type: z.string(), start_date: z.string().datetime(), start_date_local: z.string().datetime(), timezone: z.string(), start_latlng: z.array(z.number()).nullable(), end_latlng: z.array(z.number()).nullable(), achievement_count: z.number().int().optional(), kudos_count: z.number().int(), comment_count: z.number().int(), athlete_count: z.number().int().optional(), // Number of athletes on the activity photo_count: z.number().int(), map: MapSchema, trainer: z.boolean(), commute: z.boolean(), manual: z.boolean(), private: z.boolean(), flagged: z.boolean(), gear_id: z.string().nullable(), // ID of the gear used average_speed: z.number().optional(), max_speed: z.number().optional(), average_cadence: z.number().optional().nullable(), average_temp: z.number().int().optional().nullable(), average_watts: z.number().optional().nullable(), // Rides only max_watts: z.number().int().optional().nullable(), // Rides only weighted_average_watts: z.number().int().optional().nullable(), // Rides only kilojoules: z.number().optional().nullable(), // Rides only device_watts: z.boolean().optional().nullable(), // Rides only has_heartrate: z.boolean(), average_heartrate: z.number().optional().nullable(), max_heartrate: z.number().optional().nullable(), calories: z.number().optional(), description: z.string().nullable(), // photos: // Add PhotosSummary schema if needed gear: SummaryGearSchema, device_name: z.string().optional().nullable(), // segment_efforts: // Add DetailedSegmentEffort schema if needed // splits_metric: // Add Split schema if needed // splits_standard: // Add Split schema if needed // laps: // Add Lap schema if needed // best_efforts: // Add DetailedSegmentEffort schema if needed }); export type StravaDetailedActivity = z.infer<typeof DetailedActivitySchema>; // --- Meta Schemas --- // Based on https://developers.strava.com/docs/reference/#api-models-MetaActivity const MetaActivitySchema = z.object({ id: z.number().int(), }); // BaseAthleteSchema serves as MetaAthleteSchema (id only needed for effort) // --- Segment Effort Schema --- // Based on https://developers.strava.com/docs/reference/#api-models-DetailedSegmentEffort const DetailedSegmentEffortSchema = z.object({ id: z.number().int(), activity: MetaActivitySchema, athlete: BaseAthleteSchema, segment: SummarySegmentSchema, // Reuse SummarySegmentSchema name: z.string(), // Segment name elapsed_time: z.number().int(), // seconds moving_time: z.number().int(), // seconds start_date: z.string().datetime(), start_date_local: z.string().datetime(), distance: z.number(), // meters start_index: z.number().int().optional().nullable(), end_index: z.number().int().optional().nullable(), average_cadence: z.number().optional().nullable(), device_watts: z.boolean().optional().nullable(), average_watts: z.number().optional().nullable(), average_heartrate: z.number().optional().nullable(), max_heartrate: z.number().optional().nullable(), kom_rank: z.number().int().optional().nullable(), // 1-10, null if not in top 10 pr_rank: z.number().int().optional().nullable(), // 1, 2, 3, or null hidden: z.boolean().optional().nullable(), }); export type StravaDetailedSegmentEffort = z.infer<typeof DetailedSegmentEffortSchema>; // --- Route Schema --- // Based on https://developers.strava.com/docs/reference/#api-models-Route const RouteSchema = z.object({ athlete: BaseAthleteSchema, // Reuse BaseAthleteSchema description: z.string().nullable(), distance: z.number(), // meters elevation_gain: z.number().nullable(), // meters id: z.number().int(), id_str: z.string(), map: MapSchema, // Reuse MapSchema map_urls: z.object({ // Assuming structure based on context retina_url: z.string().url().optional().nullable(), url: z.string().url().optional().nullable(), }).optional().nullable(), name: z.string(), private: z.boolean(), resource_state: z.number().int(), starred: z.boolean(), sub_type: z.number().int(), // 1 for "road", 2 for "mtb", 3 for "cx", 4 for "trail", 5 for "mixed" type: z.number().int(), // 1 for "ride", 2 for "run" created_at: z.string().datetime(), updated_at: z.string().datetime(), estimated_moving_time: z.number().int().optional().nullable(), // seconds segments: z.array(SummarySegmentSchema).optional().nullable(), // Array of segments within the route timestamp: z.number().int().optional().nullable(), // Added based on common patterns }); export type StravaRoute = z.infer<typeof RouteSchema>; const StravaRoutesResponseSchema = z.array(RouteSchema); // --- Token Refresh Functionality --- // Calculate path to .env file const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const projectRoot = path.resolve(__dirname, '..'); const envPath = path.join(projectRoot, '.env'); /** * Updates the .env file with new access and refresh tokens * @param accessToken - The new access token * @param refreshToken - The new refresh token */ async function updateTokensInEnvFile(accessToken: string, refreshToken: string): Promise<void> { try { let envContent = await fs.readFile(envPath, 'utf-8'); const lines = envContent.split('\n'); const newLines: string[] = []; let accessTokenUpdated = false; let refreshTokenUpdated = false; for (const line of lines) { if (line.startsWith('STRAVA_ACCESS_TOKEN=')) { newLines.push(`STRAVA_ACCESS_TOKEN=${accessToken}`); accessTokenUpdated = true; } else if (line.startsWith('STRAVA_REFRESH_TOKEN=')) { newLines.push(`STRAVA_REFRESH_TOKEN=${refreshToken}`); refreshTokenUpdated = true; } else if (line.trim() !== '') { newLines.push(line); } } if (!accessTokenUpdated) { newLines.push(`STRAVA_ACCESS_TOKEN=${accessToken}`); } if (!refreshTokenUpdated) { newLines.push(`STRAVA_REFRESH_TOKEN=${refreshToken}`); } await fs.writeFile(envPath, newLines.join('\n').trim() + '\n'); console.error('✅ Tokens successfully refreshed and updated in .env file.'); } catch (error) { console.error('Failed to update tokens in .env file:', error); // Continue execution even if file update fails } } /** * Refreshes the Strava API access token using the refresh token * @returns The new access token */ async function refreshAccessToken(): Promise<string> { const refreshToken = process.env.STRAVA_REFRESH_TOKEN; const clientId = process.env.STRAVA_CLIENT_ID; const clientSecret = process.env.STRAVA_CLIENT_SECRET; if (!refreshToken || !clientId || !clientSecret) { throw new Error("Missing refresh credentials in .env (STRAVA_REFRESH_TOKEN, STRAVA_CLIENT_ID, STRAVA_CLIENT_SECRET)"); } try { console.error('🔄 Refreshing Strava access token...'); const response = await axios.post('https://www.strava.com/oauth/token', { client_id: clientId, client_secret: clientSecret, refresh_token: refreshToken, grant_type: 'refresh_token' }); // Update tokens in environment variables for the current process const newAccessToken = response.data.access_token; const newRefreshToken = response.data.refresh_token; if (!newAccessToken || !newRefreshToken) { throw new Error('Refresh response missing required tokens'); } process.env.STRAVA_ACCESS_TOKEN = newAccessToken; process.env.STRAVA_REFRESH_TOKEN = newRefreshToken; // Also update .env file for persistence await updateTokensInEnvFile(newAccessToken, newRefreshToken); console.error(`✅ Token refreshed. New token expires: ${new Date(response.data.expires_at * 1000).toLocaleString()}`); return newAccessToken; } catch (error) { console.error('Failed to refresh access token:', error); throw new Error(`Failed to refresh Strava access token: ${error instanceof Error ? error.message : String(error)}`); } } /** * Helper function to handle API errors with token refresh capability * @param error - The caught error * @param context - The context in which the error occurred * @param retryFn - Optional function to retry after token refresh * @returns Never returns normally, always throws an error or returns via retryFn */ export async function handleApiError<T>(error: unknown, context: string, retryFn?: () => Promise<T>): Promise<T> { // Check if it's an authentication error (401) that might be fixed by refreshing the token if (axios.isAxiosError(error) && error.response?.status === 401 && retryFn) { try { console.error(`🔑 Authentication error in ${context}. Attempting to refresh token...`); await refreshAccessToken(); // Return the result of the retry function if it succeeds console.error(`🔄 Retrying ${context} after token refresh...`); return await retryFn(); } catch (refreshError) { console.error(`❌ Token refresh failed: ${refreshError instanceof Error ? refreshError.message : String(refreshError)}`); // Fall through to normal error handling if refresh fails } } // Check for subscription error (402) if (axios.isAxiosError(error) && error.response?.status === 402) { console.error(`🔒 Subscription Required in ${context}. Status: 402`); // Throw a specific error type or use a unique message throw new Error(`SUBSCRIPTION_REQUIRED: Access to this feature requires a Strava subscription. Context: ${context}`); } // Standard error handling (existing code) if (axios.isAxiosError(error)) { const status = error.response?.status || 'Unknown'; const responseData = error.response?.data; const message = (typeof responseData === 'object' && responseData !== null && 'message' in responseData && typeof responseData.message === 'string') ? responseData.message : error.message; console.error(`Strava API request failed in ${context} with status ${status}: ${message}`); // Include response data in error log if helpful (be careful with sensitive data) if (responseData) { console.error(`Response data (${context}):`, JSON.stringify(responseData, null, 2)); } throw new Error(`Strava API Error in ${context} (${status}): ${message}`); } else if (error instanceof Error) { console.error(`An unexpected error occurred in ${context}:`, error); throw new Error(`An unexpected error occurred in ${context}: ${error.message}`); } else { console.error(`An unknown error object was caught in ${context}:`, error); throw new Error(`An unknown error occurred in ${context}: ${String(error)}`); } } /** * Fetches recent activities for the authenticated athlete from the Strava API. * * @param accessToken - The Strava API access token. * @param perPage - The number of activities to fetch per page (default: 30). * @returns A promise that resolves to an array of Strava activities. * @throws Throws an error if the API request fails or the response format is unexpected. */ export async function getRecentActivities(accessToken: string, perPage = 30): Promise<any[]> { if (!accessToken) { throw new Error("Strava access token is required."); } try { const response = await stravaApi.get<unknown>("athlete/activities", { headers: { Authorization: `Bearer ${accessToken}` }, params: { per_page: perPage } }); const validationResult = StravaActivitiesResponseSchema.safeParse(response.data); if (!validationResult.success) { console.error("Strava API response validation failed (getRecentActivities):", validationResult.error); throw new Error(`Invalid data format received from Strava API: ${validationResult.error.message}`); } return validationResult.data; } catch (error) { // Pass a retry function to handleApiError return await handleApiError<any[]>(error, 'getRecentActivities', async () => { // Use new token from environment after refresh const newToken = process.env.STRAVA_ACCESS_TOKEN!; return getRecentActivities(newToken, perPage); }); } } /** * Fetches all activities for the authenticated athlete with pagination and date filtering. * Automatically handles multiple pages to retrieve complete activity history. * * @param accessToken - The Strava API access token. * @param params - Parameters for filtering and pagination. * @returns A promise that resolves to an array of all matching Strava activities. * @throws Throws an error if the API request fails or the response format is unexpected. */ export async function getAllActivities( accessToken: string, params: GetAllActivitiesParams = {} ): Promise<any[]> { if (!accessToken) { throw new Error("Strava access token is required."); } const { page = 1, perPage = 200, // Max allowed by Strava before, after, onProgress } = params; const allActivities: any[] = []; let currentPage = page; let hasMore = true; try { while (hasMore) { // Build query parameters const queryParams: Record<string, any> = { page: currentPage, per_page: perPage }; // Add date filters if provided if (before !== undefined) queryParams.before = before; if (after !== undefined) queryParams.after = after; // Fetch current page const response = await stravaApi.get<unknown>("athlete/activities", { headers: { Authorization: `Bearer ${accessToken}` }, params: queryParams }); const validationResult = StravaActivitiesResponseSchema.safeParse(response.data); if (!validationResult.success) { console.error(`Strava API response validation failed (getAllActivities page ${currentPage}):`, validationResult.error); throw new Error(`Invalid data format received from Strava API: ${validationResult.error.message}`); } const activities = validationResult.data; // Add activities to collection allActivities.push(...activities); // Report progress if callback provided if (onProgress) { onProgress(allActivities.length, currentPage); } // Check if we should continue // Stop if we got fewer activities than requested (indicating last page) hasMore = activities.length === perPage; currentPage++; // Add a small delay to be respectful of rate limits if (hasMore) { await new Promise(resolve => setTimeout(resolve, 100)); } } return allActivities; } catch (error) { // If it's an auth error and we're on first page, try token refresh if (currentPage === 1) { return await handleApiError<any[]>(error, 'getAllActivities', async () => { const newToken = process.env.STRAVA_ACCESS_TOKEN!; return getAllActivities(newToken, params); }); } // For subsequent pages, just throw the error throw error; } } /** * Fetches profile information for the authenticated athlete. * * @param accessToken - The Strava API access token. * @returns A promise that resolves to the detailed athlete profile. * @throws Throws an error if the API request fails or the response format is unexpected. */ export async function getAuthenticatedAthlete(accessToken: string): Promise<StravaAthlete> { if (!accessToken) { throw new Error("Strava access token is required."); } try { const response = await stravaApi.get<unknown>("athlete", { headers: { Authorization: `Bearer ${accessToken}` } }); // Validate the response data against the Zod schema const validationResult = DetailedAthleteSchema.safeParse(response.data); if (!validationResult.success) { // Log the raw response data on validation failure for debugging console.error("Strava API raw response data (getAuthenticatedAthlete):", JSON.stringify(response.data, null, 2)); console.error("Strava API response validation failed (getAuthenticatedAthlete):", validationResult.error); throw new Error(`Invalid data format received from Strava API: ${validationResult.error.message}`); } // Type assertion is safe here due to successful validation return validationResult.data; } catch (error) { return await handleApiError<StravaAthlete>(error, 'getAuthenticatedAthlete', async () => { // Use new token from environment after refresh const newToken = process.env.STRAVA_ACCESS_TOKEN!; return getAuthenticatedAthlete(newToken); }); } } /** * Fetches activity statistics for a specific athlete. * * @param accessToken - The Strava API access token. * @param athleteId - The ID of the athlete whose stats are being requested. * @returns A promise that resolves to the athlete's activity statistics. * @throws Throws an error if the API request fails or the response format is unexpected. */ export async function getAthleteStats(accessToken: string, athleteId: number): Promise<StravaStats> { if (!accessToken) { throw new Error("Strava access token is required."); } if (!athleteId) { throw new Error("Athlete ID is required to fetch stats."); } try { const response = await stravaApi.get<unknown>(`athletes/${athleteId}/stats`, { headers: { Authorization: `Bearer ${accessToken}` } }); const validationResult = ActivityStatsSchema.safeParse(response.data); if (!validationResult.success) { console.error("Strava API response validation failed (getAthleteStats):", validationResult.error); throw new Error(`Invalid data format received from Strava API: ${validationResult.error.message}`); } return validationResult.data; } catch (error) { return await handleApiError<StravaStats>(error, `getAthleteStats for ID ${athleteId}`, async () => { // Use new token from environment after refresh const newToken = process.env.STRAVA_ACCESS_TOKEN!; return getAthleteStats(newToken, athleteId); }); } } /** * Fetches detailed information for a specific activity by its ID. * * @param accessToken - The Strava API access token. * @param activityId - The ID of the activity to fetch. * @returns A promise that resolves to the detailed activity data. * @throws Throws an error if the API request fails or the response format is unexpected. */ export async function getActivityById(accessToken: string, activityId: number): Promise<StravaDetailedActivity> { if (!accessToken) { throw new Error("Strava access token is required."); } if (!activityId) { throw new Error("Activity ID is required to fetch details."); } try { const response = await stravaApi.get<unknown>(`activities/${activityId}`, { headers: { Authorization: `Bearer ${accessToken}` } }); const validationResult = DetailedActivitySchema.safeParse(response.data); if (!validationResult.success) { console.error(`Strava API validation failed (getActivityById: ${activityId}):`, validationResult.error); throw new Error(`Invalid data format received from Strava API: ${validationResult.error.message}`); } return validationResult.data; } catch (error) { return await handleApiError<StravaDetailedActivity>(error, `getActivityById for ID ${activityId}`, async () => { // Use new token from environment after refresh const newToken = process.env.STRAVA_ACCESS_TOKEN!; return getActivityById(newToken, activityId); }); } } /** * Lists the clubs the authenticated athlete belongs to. * * @param accessToken - The Strava API access token. * @returns A promise that resolves to an array of the athlete's clubs. * @throws Throws an error if the API request fails or the response format is unexpected. */ export async function listAthleteClubs(accessToken: string): Promise<StravaClub[]> { if (!accessToken) { throw new Error("Strava access token is required."); } try { const response = await stravaApi.get<unknown>("athlete/clubs", { headers: { Authorization: `Bearer ${accessToken}` } }); const validationResult = StravaClubsResponseSchema.safeParse(response.data); if (!validationResult.success) { console.error("Strava API validation failed (listAthleteClubs):", validationResult.error); throw new Error(`Invalid data format received from Strava API: ${validationResult.error.message}`); } return validationResult.data; } catch (error) { return await handleApiError<StravaClub[]>(error, 'listAthleteClubs', async () => { // Use new token from environment after refresh const newToken = process.env.STRAVA_ACCESS_TOKEN!; return listAthleteClubs(newToken); }); } } /** * Lists the segments starred by the authenticated athlete. * * @param accessToken - The Strava API access token. * @returns A promise that resolves to an array of the athlete's starred segments. * @throws Throws an error if the API request fails or the response format is unexpected. */ export async function listStarredSegments(accessToken: string): Promise<StravaSegment[]> { if (!accessToken) { throw new Error("Strava access token is required."); } try { // Strava API uses page/per_page but often defaults reasonably for lists like this. // Add pagination parameters if needed later. const response = await stravaApi.get<unknown>("segments/starred", { headers: { Authorization: `Bearer ${accessToken}` } }); const validationResult = StravaSegmentsResponseSchema.safeParse(response.data); if (!validationResult.success) { console.error("Strava API validation failed (listStarredSegments):", validationResult.error); throw new Error(`Invalid data format received from Strava API: ${validationResult.error.message}`); } return validationResult.data; } catch (error) { return await handleApiError<StravaSegment[]>(error, 'listStarredSegments', async () => { // Use new token from environment after refresh const newToken = process.env.STRAVA_ACCESS_TOKEN!; return listStarredSegments(newToken); }); } } /** * Fetches detailed information for a specific segment by its ID. * * @param accessToken - The Strava API access token. * @param segmentId - The ID of the segment to fetch. * @returns A promise that resolves to the detailed segment data. * @throws Throws an error if the API request fails or the response format is unexpected. */ export async function getSegmentById(accessToken: string, segmentId: number): Promise<StravaDetailedSegment> { if (!accessToken) { throw new Error("Strava access token is required."); } if (!segmentId) { throw new Error("Segment ID is required."); } try { const response = await stravaApi.get<unknown>(`segments/${segmentId}`, { headers: { Authorization: `Bearer ${accessToken}` } }); const validationResult = DetailedSegmentSchema.safeParse(response.data); if (!validationResult.success) { console.error(`Strava API validation failed (getSegmentById: ${segmentId}):`, validationResult.error); throw new Error(`Invalid data format received from Strava API: ${validationResult.error.message}`); } return validationResult.data; } catch (error) { return await handleApiError<StravaDetailedSegment>(error, `getSegmentById for ID ${segmentId}`, async () => { // Use new token from environment after refresh const newToken = process.env.STRAVA_ACCESS_TOKEN!; return getSegmentById(newToken, segmentId); }); } } /** * Returns the top 10 segments matching a specified query. * * @param accessToken - The Strava API access token. * @param bounds - String representing the latitudes and longitudes for the corners of the search map, `latitude,longitude,latitude,longitude`. * @param activityType - Optional filter for activity type ("running" or "riding"). * @param minCat - Optional minimum climb category filter. * @param maxCat - Optional maximum climb category filter. * @returns A promise that resolves to the explorer response containing matching segments. * @throws Throws an error if the API request fails or the response format is unexpected. */ export async function exploreSegments( accessToken: string, bounds: string, activityType?: 'running' | 'riding', minCat?: number, maxCat?: number ): Promise<StravaExplorerResponse> { if (!accessToken) { throw new Error("Strava access token is required."); } if (!bounds || !/^-?\d+(\.\d+)?,-?\d+(\.\d+)?,-?\d+(\.\d+)?,-?\d+(\.\d+)?$/.test(bounds)) { throw new Error("Valid bounds (lat,lng,lat,lng) are required for exploring segments."); } const params: Record<string, any> = { bounds: bounds, }; if (activityType) params.activity_type = activityType; if (minCat !== undefined) params.min_cat = minCat; if (maxCat !== undefined) params.max_cat = maxCat; try { const response = await stravaApi.get<unknown>("segments/explore", { headers: { Authorization: `Bearer ${accessToken}` }, params: params }); const validationResult = ExplorerResponseSchema.safeParse(response.data); if (!validationResult.success) { console.error("Strava API validation failed (exploreSegments):", validationResult.error); throw new Error(`Invalid data format received from Strava API: ${validationResult.error.message}`); } return validationResult.data; } catch (error) { return await handleApiError<StravaExplorerResponse>(error, `exploreSegments with bounds ${bounds}`, async () => { // Use new token from environment after refresh const newToken = process.env.STRAVA_ACCESS_TOKEN!; return exploreSegments(newToken, bounds, activityType); }); } } /** * Stars or unstars a segment for the authenticated athlete. * * @param accessToken - The Strava API access token. * @param segmentId - The ID of the segment to star/unstar. * @param starred - Boolean indicating whether to star (true) or unstar (false) the segment. * @returns A promise that resolves to the detailed segment data after the update. * @throws Throws an error if the API request fails or the response format is unexpected. */ export async function starSegment(accessToken: string, segmentId: number, starred: boolean): Promise<StravaDetailedSegment> { if (!accessToken) { throw new Error("Strava access token is required."); } if (!segmentId) { throw new Error("Segment ID is required to star/unstar."); } if (starred === undefined) { throw new Error("Starred status (true/false) is required."); } try { const response = await stravaApi.put<unknown>( `segments/${segmentId}/starred`, { starred: starred }, // Data payload for the PUT request { headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' // Important for PUT requests with body } } ); // The response is expected to be the updated DetailedSegment const validationResult = DetailedSegmentSchema.safeParse(response.data); if (!validationResult.success) { console.error(`Strava API validation failed (starSegment: ${segmentId}):`, validationResult.error); throw new Error(`Invalid data format received from Strava API: ${validationResult.error.message}`); } return validationResult.data; } catch (error) { return await handleApiError<StravaDetailedSegment>(error, `starSegment for ID ${segmentId} with starred=${starred}`, async () => { // Use new token from environment after refresh const newToken = process.env.STRAVA_ACCESS_TOKEN!; return starSegment(newToken, segmentId, starred); }); } } /** * Fetches detailed information about a specific segment effort by its ID. * * @param accessToken - The Strava API access token. * @param effortId - The ID of the segment effort to fetch. * @returns A promise that resolves to the detailed segment effort data. * @throws Throws an error if the API request fails or the response format is unexpected. */ export async function getSegmentEffort(accessToken: string, effortId: number): Promise<StravaDetailedSegmentEffort> { if (!accessToken) { throw new Error("Strava access token is required."); } if (!effortId) { throw new Error("Segment Effort ID is required to fetch details."); } try { const response = await stravaApi.get<unknown>(`segment_efforts/${effortId}`, { headers: { Authorization: `Bearer ${accessToken}` } }); const validationResult = DetailedSegmentEffortSchema.safeParse(response.data); if (!validationResult.success) { console.error(`Strava API validation failed (getSegmentEffort: ${effortId}):`, validationResult.error); throw new Error(`Invalid data format received from Strava API: ${validationResult.error.message}`); } return validationResult.data; } catch (error) { return await handleApiError<StravaDetailedSegmentEffort>(error, `getSegmentEffort for ID ${effortId}`, async () => { // Use new token from environment after refresh const newToken = process.env.STRAVA_ACCESS_TOKEN!; return getSegmentEffort(newToken, effortId); }); } } /** * Fetches a list of segment efforts for a given segment, filtered by date range for the authenticated athlete. * * @param accessToken - The Strava API access token. * @param segmentId - The ID of the segment. * @param startDateLocal - Optional ISO 8601 start date. * @param endDateLocal - Optional ISO 8601 end date. * @param perPage - Optional number of items per page. * @returns A promise that resolves to an array of segment efforts. * @throws Throws an error if the API request fails or the response format is unexpected. */ export async function listSegmentEfforts( accessToken: string, segmentId: number, params: SegmentEffortsParams = {} ): Promise<StravaDetailedSegmentEffort[]> { if (!accessToken) { throw new Error("Strava access token is required."); } if (!segmentId) { throw new Error("Segment ID is required to list efforts."); } const { startDateLocal, endDateLocal, perPage } = params; const queryParams: Record<string, any> = { segment_id: segmentId, }; if (startDateLocal) queryParams.start_date_local = startDateLocal; if (endDateLocal) queryParams.end_date_local = endDateLocal; if (perPage) queryParams.per_page = perPage; try { const response = await stravaApi.get<unknown>("segment_efforts", { headers: { Authorization: `Bearer ${accessToken}` }, params: queryParams }); // Response is an array of DetailedSegmentEffort const validationResult = z.array(DetailedSegmentEffortSchema).safeParse(response.data); if (!validationResult.success) { console.error(`Strava API validation failed (listSegmentEfforts: segment ${segmentId}):`, validationResult.error); throw new Error(`Invalid data format received from Strava API: ${validationResult.error.message}`); } return validationResult.data; } catch (error) { return await handleApiError<StravaDetailedSegmentEffort[]>(error, `listSegmentEfforts for segment ID ${segmentId}`, async () => { // Use new token from environment after refresh const newToken = process.env.STRAVA_ACCESS_TOKEN!; return listSegmentEfforts(newToken, segmentId, params); }); } } // Add the missing interface for segment efforts parameters export interface SegmentEffortsParams { startDateLocal?: string; endDateLocal?: string; perPage?: number; } // Interface for getAllActivities parameters export interface GetAllActivitiesParams { page?: number; perPage?: number; before?: number; // epoch timestamp in seconds after?: number; // epoch timestamp in seconds onProgress?: (fetched: number, page: number) => void; } /** * Lists routes created by a specific athlete. * * @param accessToken - The Strava API access token. * @param athleteId - The ID of the athlete whose routes are being requested. * @param page - Optional page number for pagination. * @param perPage - Optional number of items per page. * @returns A promise that resolves to an array of the athlete's routes. * @throws Throws an error if the API request fails or the response format is unexpected. */ export async function listAthleteRoutes(accessToken: string, page = 1, perPage = 30): Promise<StravaRoute[]> { if (!accessToken) { throw new Error("Strava access token is required."); } try { const response = await stravaApi.get<unknown>("athlete/routes", { headers: { Authorization: `Bearer ${accessToken}` }, params: { page: page, per_page: perPage } }); const validationResult = StravaRoutesResponseSchema.safeParse(response.data); if (!validationResult.success) { console.error("Strava API validation failed (listAthleteRoutes):", validationResult.error); throw new Error(`Invalid data format received from Strava API: ${validationResult.error.message}`); } return validationResult.data; } catch (error) { return await handleApiError<StravaRoute[]>(error, 'listAthleteRoutes', async () => { // Use new token from environment after refresh const newToken = process.env.STRAVA_ACCESS_TOKEN!; return listAthleteRoutes(newToken, page, perPage); }); } } /** * Fetches detailed information for a specific route by its ID. * * @param accessToken - The Strava API access token. * @param routeId - The ID of the route to fetch. * @returns A promise that resolves to the detailed route data. * @throws Throws an error if the API request fails or the response format is unexpected. */ export async function getRouteById(accessToken: string, routeId: string): Promise<StravaRoute> { const url = `routes/${routeId}`; try { const response = await stravaApi.get(url, { headers: { Authorization: `Bearer ${accessToken}` }, }); // Validate the response against the Zod schema const validatedRoute = RouteSchema.parse(response.data); return validatedRoute; } catch (error) { return await handleApiError<StravaRoute>(error, `fetching route ${routeId}`, async () => { // Use new token from environment after refresh const newToken = process.env.STRAVA_ACCESS_TOKEN!; return getRouteById(newToken, routeId); }); } } /** * Fetches the GPX data for a specific route. * Note: This endpoint returns raw GPX data (XML string), not JSON. * @param accessToken Strava API access token * @param routeId The ID of the route to export * @returns Promise resolving to the GPX data as a string */ export async function exportRouteGpx(accessToken: string, routeId: string): Promise<string> { const url = `routes/${routeId}/export_gpx`; try { // Expecting text/xml response, Axios should handle it as string const response = await stravaApi.get<string>(url, { headers: { Authorization: `Bearer ${accessToken}` }, // Ensure response is treated as text responseType: 'text', }); if (typeof response.data !== 'string') { throw new Error('Invalid response format received from Strava API for GPX export.'); } return response.data; } catch (error) { return await handleApiError<string>(error, `exporting route ${routeId} as GPX`, async () => { // Use new token from environment after refresh const newToken = process.env.STRAVA_ACCESS_TOKEN!; return exportRouteGpx(newToken, routeId); }); } } /** * Fetches the TCX data for a specific route. * Note: This endpoint returns raw TCX data (XML string), not JSON. * @param accessToken Strava API access token * @param routeId The ID of the route to export * @returns Promise resolving to the TCX data as a string */ export async function exportRouteTcx(accessToken: string, routeId: string): Promise<string> { const url = `routes/${routeId}/export_tcx`; try { // Expecting text/xml response, Axios should handle it as string const response = await stravaApi.get<string>(url, { headers: { Authorization: `Bearer ${accessToken}` }, // Ensure response is treated as text responseType: 'text', }); if (typeof response.data !== 'string') { throw new Error('Invalid response format received from Strava API for TCX export.'); } return response.data; } catch (error) { return await handleApiError<string>(error, `exporting route ${routeId} as TCX`, async () => { // Use new token from environment after refresh const newToken = process.env.STRAVA_ACCESS_TOKEN!; return exportRouteTcx(newToken, routeId); }); } } // --- Lap Schema --- // Based on https://developers.strava.com/docs/reference/#api-models-Lap and user-provided image const LapSchema = z.object({ id: z.number().int(), resource_state: z.number().int(), name: z.string(), activity: BaseAthleteSchema, // Reusing BaseAthleteSchema for {id, resource_state} athlete: BaseAthleteSchema, // Reusing BaseAthleteSchema for {id, resource_state} elapsed_time: z.number().int(), // In seconds moving_time: z.number().int(), // In seconds start_date: z.string().datetime(), start_date_local: z.string().datetime(), distance: z.number(), // In meters start_index: z.number().int().optional().nullable(), // Index in the activity stream end_index: z.number().int().optional().nullable(), // Index in the activity stream total_elevation_gain: z.number().optional().nullable(), // In meters average_speed: z.number().optional().nullable(), // In meters per second max_speed: z.number().optional().nullable(), // In meters per second average_cadence: z.number().optional().nullable(), // RPM average_watts: z.number().optional().nullable(), // Rides only device_watts: z.boolean().optional().nullable(), // Whether power sensor was used average_heartrate: z.number().optional().nullable(), // Average heart rate during lap max_heartrate: z.number().optional().nullable(), // Max heart rate during lap lap_index: z.number().int(), // The position of this lap in the activity split: z.number().int().optional().nullable(), // Associated split number (e.g., for marathons) }); export type StravaLap = z.infer<typeof LapSchema>; const StravaLapsResponseSchema = z.array(LapSchema); /** * Retrieves the laps for a specific activity. * @param accessToken The Strava API access token. * @param activityId The ID of the activity. * @returns A promise resolving to an array of lap objects. */ export async function getActivityLaps(accessToken: string, activityId: number | string): Promise<StravaLap[]> { if (!accessToken) { throw new Error("Strava access token is required."); } try { const response = await stravaApi.get(`/activities/${activityId}/laps`, { headers: { Authorization: `Bearer ${accessToken}` }, }); const validationResult = StravaLapsResponseSchema.safeParse(response.data); if (!validationResult.success) { console.error(`Strava API validation failed (getActivityLaps: ${activityId}):`, validationResult.error); throw new Error(`Invalid data format received from Strava API: ${validationResult.error.message}`); } return validationResult.data; } catch (error) { return await handleApiError<StravaLap[]>(error, `getActivityLaps(${activityId})`, async () => { // Use new token from environment after refresh const newToken = process.env.STRAVA_ACCESS_TOKEN!; return getActivityLaps(newToken, activityId); }); } } // --- Zone Schemas --- const DistributionBucketSchema = z.object({ max: z.number(), min: z.number(), time: z.number().int(), // Time in seconds spent in this bucket }); const ZoneSchema = z.object({ min: z.number(), max: z.number().optional(), // Max might be absent for the last zone }); const HeartRateZoneSchema = z.object({ custom_zones: z.boolean(), zones: z.array(ZoneSchema), distribution_buckets: z.array(DistributionBucketSchema).optional(), // Optional based on sample resource_state: z.number().int().optional(), // Optional based on sample sensor_based: z.boolean().optional(), // Optional based on sample points: z.number().int().optional(), // Optional based on sample type: z.literal('heartrate').optional(), // Optional based on sample }); const PowerZoneSchema = z.object({ zones: z.array(ZoneSchema), distribution_buckets: z.array(DistributionBucketSchema).optional(), // Optional based on sample resource_state: z.number().int().optional(), // Optional based on sample sensor_based: z.boolean().optional(), // Optional based on sample points: z.number().int().optional(), // Optional based on sample type: z.literal('power').optional(), // Optional based on sample }); // Combined Zones Response Schema const AthleteZonesSchema = z.object({ heart_rate: HeartRateZoneSchema.optional(), // Heart rate zones might not be set power: PowerZoneSchema.optional(), // Power zones might not be set }); export type StravaAthleteZones = z.infer<typeof AthleteZonesSchema>; /** * Retrieves the heart rate and power zones for the authenticated athlete. * @param accessToken The Strava API access token. * @returns A promise resolving to the athlete's zone data. */ export async function getAthleteZones(accessToken: string): Promise<StravaAthleteZones> { if (!accessToken) { throw new Error("Strava access token is required."); } try { const response = await stravaApi.get<unknown>("/athlete/zones", { headers: { Authorization: `Bearer ${accessToken}` }, }); const validationResult = AthleteZonesSchema.safeParse(response.data); if (!validationResult.success) { console.error(`Strava API validation failed (getAthleteZones):`, validationResult.error); throw new Error(`Invalid data format received from Strava API: ${validationResult.error.message}`); } return validationResult.data; } catch (error) { // Note: This endpoint requires profile:read_all scope // Handle potential 403 Forbidden if scope is missing, or 402 if it becomes sub-only? return await handleApiError<StravaAthleteZones>(error, `getAthleteZones`, async () => { // Use new token from environment after refresh const newToken = process.env.STRAVA_ACCESS_TOKEN!; return getAthleteZones(newToken); }); } } ```