This is page 1 of 2. Use http://codebase.md/r-huijts/strava-mcp?lines=true&page={x} to view the full context. # 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: -------------------------------------------------------------------------------- ``` 1 | # Dependencies 2 | node_modules/ 3 | 4 | # Build output 5 | dist/ 6 | 7 | # Environment variables 8 | .env 9 | 10 | # Log files 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | 15 | # Editor directories and files 16 | .idea 17 | .vscode 18 | *.suo 19 | *.ntvs* 20 | *.njsproj 21 | *.sln 22 | *.sw? 23 | 24 | # Cursor IDE files 25 | .cursor/ 26 | *.mdc 27 | cursor_rules.json 28 | .mcpregistry_registry_token 29 | .mcpregistry_github_token 30 | ``` -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- ``` 1 | # Strava API Access Token 2 | # Get yours from https://www.strava.com/settings/api 3 | STRAVA_ACCESS_TOKEN=YOUR_STRAVA_ACCESS_TOKEN_HERE 4 | # Optional: Define a path for saving exported route files (GPX/TCX) 5 | # Ensure this directory exists and the server process has write permissions. 6 | # Example: ROUTE_EXPORT_PATH=/Users/your_username/strava-exports 7 | ROUTE_EXPORT_PATH=./strava-exports ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown 1 | [](https://mseep.ai/app/r-huijts-strava-mcp) 2 | 3 | # Strava MCP Server 4 | 5 | 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. 6 | 7 | <a href="https://glama.ai/mcp/servers/@r-huijts/strava-mcp"> 8 | <img width="380" height="200" src="https://glama.ai/mcp/servers/@r-huijts/strava-mcp/badge" alt="Strava Server MCP server" /> 9 | </a> 10 | 11 | ## Features 12 | 13 | - 🏃 Access recent activities, profile, and stats. 14 | - 📊 Fetch detailed activity streams (power, heart rate, cadence, etc.). 15 | - 🗺️ Explore, view, star, and manage segments. 16 | - ⏱️ View detailed activity and segment effort information. 17 | - 📍 List and view details of saved routes. 18 | - 💾 Export routes in GPX or TCX format to the local filesystem. 19 | - 🤖 AI-friendly JSON responses via MCP. 20 | - 🔧 Uses Strava API V3. 21 | 22 | ## Natural Language Interaction Examples 23 | 24 | Ask your AI assistant questions like these to interact with your Strava data: 25 | 26 | **Recent Activity & Profile:** 27 | * "Show me my recent Strava activities." 28 | * "What were my last 3 rides?" 29 | * "Get my Strava profile information." 30 | * "What's my Strava username?" 31 | 32 | **Activity Streams & Data:** 33 | * "Get the heart rate data from my morning run yesterday." 34 | * "Show me the power data from my last ride." 35 | * "What was my cadence profile for my weekend century ride?" 36 | * "Get all stream data for my Thursday evening workout." 37 | * "Show me the elevation profile for my Mt. Diablo climb." 38 | 39 | **Stats:** 40 | * "What are my running stats for this year on Strava?" 41 | * "How far have I cycled in total?" 42 | * "Show me my all-time swim totals." 43 | 44 | **Specific Activities:** 45 | * "Give me the details for my last run." 46 | * "What was the average power for my interval training on Tuesday?" 47 | * "Did I use my Trek bike for my commute yesterday?" 48 | 49 | **Clubs:** 50 | * "What Strava clubs am I in?" 51 | * "List the clubs I've joined." 52 | 53 | **Segments:** 54 | * "List the segments I starred near Boulder, Colorado." 55 | * "Show my favorite segments." 56 | * "Get details for the 'Alpe du Zwift' segment." 57 | * "Are there any good running segments near Golden Gate Park?" 58 | * "Find challenging climbs near Boulders Flagstaff Mountain." 59 | * "Star the 'Flagstaff Road Climb' segment for me." 60 | * "Unstar the 'Lefthand Canyon' segment." 61 | 62 | **Segment Efforts:** 63 | * "Show my efforts on the 'Sunshine Canyon' segment this month." 64 | * "List my attempts on Box Hill between January and June this year." 65 | * "Get the details for my personal record on Alpe d'Huez." 66 | 67 | **Routes:** 68 | * "List my saved Strava routes." 69 | * "Show the second page of my routes." 70 | * "What is the elevation gain for my Boulder Loop route?" 71 | * "Get the description for my 'Boulder Loop' route." 72 | * "Export my 'Boulder Loop' route as a GPX file." 73 | * "Save my Sunday morning route as a TCX file." 74 | 75 | ## Advanced Prompt Example 76 | 77 | Here's an example of a more advanced prompt to create a professional cycling coach analysis of your Strava activities: 78 | 79 | ``` 80 | 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. 81 | 82 | 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). 83 | 84 | 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. 85 | 86 | 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. 87 | 88 | Goal: Deliver a professional-grade performance analysis that looks and feels like it came straight from the inner circle of world-class cycling. 89 | ``` 90 | 91 | This prompt creates a personalized analysis of your most recent Strava activity, complete with professional coaching feedback and a custom visualization dashboard. 92 | 93 | ## ⚠️ Important Setup Sequence 94 | 95 | For successful integration with Claude, follow these steps in exact order: 96 | 97 | 1. Install the server and its dependencies 98 | 2. Configure the server in Claude's configuration 99 | 3. Complete the Strava authentication flow 100 | 4. Restart Claude to ensure proper environment variable loading 101 | 102 | Skipping steps or performing them out of order may result in environment variables not being properly read by Claude. 103 | 104 | ## Installation & Setup 105 | 106 | 1. **Prerequisites:** 107 | - Node.js (v18 or later recommended) 108 | - npm (usually comes with Node.js) 109 | - A Strava Account 110 | 111 | ### 1. From Source 112 | 113 | 1. **Clone Repository:** 114 | ```bash 115 | git clone https://github.com/r-huijts/strava-mcp.git 116 | cd strava-mcp 117 | ``` 118 | 119 | 2. **Install Dependencies:** 120 | ```bash 121 | npm install 122 | ``` 123 | 3. **Build the Project:** 124 | ```bash 125 | npm run build 126 | ``` 127 | 128 | ### 2. Configure Claude Desktop 129 | 130 | Update your Claude configuration file: 131 | 132 | ```json 133 | { 134 | "mcpServers": { 135 | "strava-mcp-local": { 136 | "command": "node", 137 | "args": [ 138 | "/absolute/path/to/your/strava-mcp/dist/server.js" 139 | ] 140 | // Environment variables are read from the .env file by the server 141 | } 142 | } 143 | } 144 | ``` 145 | 146 | Make sure to replace `/absolute/path/to/your/strava-mcp/` with the actual path to your installation. 147 | 148 | ### 3. Strava Authentication Setup 149 | 150 | The `setup-auth.ts` script makes it easy to set up authentication with the Strava API. Follow these steps carefully: 151 | 152 | #### Create a Strava API Application 153 | 154 | 1. Go to [https://www.strava.com/settings/api](https://www.strava.com/settings/api) 155 | 2. Create a new application: 156 | - Enter your application details (name, website, description) 157 | - Important: Set "Authorization Callback Domain" to `localhost` 158 | - Note down your Client ID and Client Secret 159 | 160 | #### Run the Setup Script 161 | 162 | ```bash 163 | # In your strava-mcp directory 164 | npx tsx scripts/setup-auth.ts 165 | ``` 166 | 167 | Follow the prompts to complete the authentication flow (detailed instructions in the Authentication section below). 168 | 169 | ### 4. Restart Claude 170 | 171 | After completing all the above steps, restart Claude Desktop for the changes to take effect. This ensures that: 172 | - The new configuration is loaded 173 | - The environment variables are properly read 174 | - The Strava MCP server is properly initialized 175 | 176 | ## 🔑 Environment Variables 177 | 178 | | Variable | Description | 179 | |----------|-------------| 180 | | STRAVA_CLIENT_ID | Your Strava Application Client ID (required) | 181 | | STRAVA_CLIENT_SECRET | Your Strava Application Client Secret (required) | 182 | | STRAVA_ACCESS_TOKEN | Your Strava API access token (generated during setup) | 183 | | STRAVA_REFRESH_TOKEN | Your Strava API refresh token (generated during setup) | 184 | | ROUTE_EXPORT_PATH | Absolute path for saving exported route files (optional) | 185 | 186 | ## Token Handling 187 | 188 | 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. 189 | 190 | You only need to run the `scripts/setup-auth.ts` script once for the initial setup. 191 | 192 | ## Configure Export Path (Optional) 193 | 194 | If you intend to use the `export-route-gpx` or `export-route-tcx` tools, you need to specify a directory for saving exported files. 195 | 196 | Edit your `.env` file and add/update the `ROUTE_EXPORT_PATH` variable: 197 | ```dotenv 198 | # Optional: Define an *absolute* path for saving exported route files (GPX/TCX) 199 | # Ensure this directory exists and the server process has write permissions. 200 | # Example: ROUTE_EXPORT_PATH=/Users/your_username/strava-exports 201 | ROUTE_EXPORT_PATH= 202 | ``` 203 | 204 | 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. 205 | 206 | ## API Reference 207 | 208 | The server exposes the following MCP tools: 209 | 210 | --- 211 | 212 | ### `get-recent-activities` 213 | 214 | Fetches the authenticated user's recent activities. 215 | 216 | - **When to use:** When the user asks about their recent workouts, activities, runs, rides, etc. 217 | - **Parameters:** 218 | - `perPage` (optional): 219 | - Type: `number` 220 | - Description: Number of activities to retrieve. 221 | - Default: 30 222 | - **Output:** Formatted text list of recent activities (Name, ID, Distance, Date). 223 | - **Errors:** Missing/invalid token, Strava API errors. 224 | 225 | --- 226 | 227 | ### `get-athlete-profile` 228 | 229 | Fetches the profile information for the authenticated athlete. 230 | 231 | - **When to use:** When the user asks for their profile details, username, location, weight, premium status, etc. 232 | - **Parameters:** None 233 | - **Output:** Formatted text string with profile details. 234 | - **Errors:** Missing/invalid token, Strava API errors. 235 | 236 | --- 237 | 238 | ### `get-athlete-stats` 239 | 240 | Fetches activity statistics (recent, YTD, all-time) for the authenticated athlete. 241 | 242 | - **When to use:** When the user asks for their overall statistics, totals for runs/rides/swims, personal records (longest ride, biggest climb). 243 | - **Parameters:** None 244 | - **Output:** Formatted text summary of stats, respecting user's measurement preference. 245 | - **Errors:** Missing/invalid token, Strava API errors. 246 | 247 | --- 248 | 249 | ### `get-activity-details` 250 | 251 | Fetches detailed information about a specific activity using its ID. 252 | 253 | - **When to use:** When the user asks for details about a *specific* activity identified by its ID. 254 | - **Parameters:** 255 | - `activityId` (required): 256 | - Type: `number` 257 | - Description: The unique identifier of the activity. 258 | - **Output:** Formatted text string with detailed activity information (type, date, distance, time, speed, HR, power, gear, etc.), respecting user's measurement preference. 259 | - **Errors:** Missing/invalid token, Invalid `activityId`, Strava API errors. 260 | 261 | --- 262 | 263 | ### `list-athlete-clubs` 264 | 265 | Lists the clubs the authenticated athlete is a member of. 266 | 267 | - **When to use:** When the user asks about the clubs they have joined. 268 | - **Parameters:** None 269 | - **Output:** Formatted text list of clubs (Name, ID, Sport, Members, Location). 270 | - **Errors:** Missing/invalid token, Strava API errors. 271 | 272 | --- 273 | 274 | ### `list-starred-segments` 275 | 276 | Lists the segments starred by the authenticated athlete. 277 | 278 | - **When to use:** When the user asks about their starred or favorite segments. 279 | - **Parameters:** None 280 | - **Output:** Formatted text list of starred segments (Name, ID, Type, Distance, Grade, Location). 281 | - **Errors:** Missing/invalid token, Strava API errors. 282 | 283 | --- 284 | 285 | ### `get-segment` 286 | 287 | Fetches detailed information about a specific segment using its ID. 288 | 289 | - **When to use:** When the user asks for details about a *specific* segment identified by its ID. 290 | - **Parameters:** 291 | - `segmentId` (required): 292 | - Type: `number` 293 | - Description: The unique identifier of the segment. 294 | - **Output:** Formatted text string with detailed segment information (distance, grade, elevation, location, stars, efforts, etc.), respecting user's measurement preference. 295 | - **Errors:** Missing/invalid token, Invalid `segmentId`, Strava API errors. 296 | 297 | --- 298 | 299 | ### `explore-segments` 300 | 301 | Searches for popular segments within a given geographical area (bounding box). 302 | 303 | - **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. 304 | - **Parameters:** 305 | - `bounds` (required): 306 | - Type: `string` 307 | - Description: Comma-separated: `south_west_lat,south_west_lng,north_east_lat,north_east_lng`. 308 | - `activityType` (optional): 309 | - Type: `string` (`"running"` or `"riding"`) 310 | - Description: Filter by activity type. 311 | - `minCat` (optional): 312 | - Type: `number` (0-5) 313 | - Description: Minimum climb category. Requires `activityType: 'riding'`. 314 | - `maxCat` (optional): 315 | - Type: `number` (0-5) 316 | - Description: Maximum climb category. Requires `activityType: 'riding'`. 317 | - **Output:** Formatted text list of found segments (Name, ID, Climb Cat, Distance, Grade, Elevation). 318 | - **Errors:** Missing/invalid token, Invalid `bounds` format, Invalid filter combination, Strava API errors. 319 | 320 | --- 321 | 322 | ### `star-segment` 323 | 324 | Stars or unstars a specific segment for the authenticated athlete. 325 | 326 | - **When to use:** When the user explicitly asks to star, favorite, unstar, or unfavorite a specific segment identified by its ID. 327 | - **Parameters:** 328 | - `segmentId` (required): 329 | - Type: `number` 330 | - Description: The unique identifier of the segment. 331 | - `starred` (required): 332 | - Type: `boolean` 333 | - Description: `true` to star, `false` to unstar. 334 | - **Output:** Success message confirming the action and the segment's new starred status. 335 | - **Errors:** Missing/invalid token, Invalid `segmentId`, Strava API errors (e.g., segment not found, rate limit). 336 | 337 | - **Notes:** 338 | - Requires `profile:write` scope for star-ing and unstar-ing segments 339 | 340 | --- 341 | 342 | ### `get-segment-effort` 343 | 344 | Fetches detailed information about a specific segment effort using its ID. 345 | 346 | - **When to use:** When the user asks for details about a *specific* segment effort identified by its ID. 347 | - **Parameters:** 348 | - `effortId` (required): 349 | - Type: `number` 350 | - Description: The unique identifier of the segment effort. 351 | - **Output:** Formatted text string with detailed effort information (segment name, activity ID, time, distance, HR, power, rank, etc.). 352 | - **Errors:** Missing/invalid token, Invalid `effortId`, Strava API errors. 353 | 354 | --- 355 | 356 | ### `list-segment-efforts` 357 | 358 | Lists the authenticated athlete's efforts on a given segment, optionally filtered by date. 359 | 360 | - **When to use:** When the user asks to list their efforts or attempts on a specific segment, possibly within a date range. 361 | - **Parameters:** 362 | - `segmentId` (required): 363 | - Type: `number` 364 | - Description: The ID of the segment. 365 | - `startDateLocal` (optional): 366 | - Type: `string` (ISO 8601 format) 367 | - Description: Filter efforts starting after this date-time. 368 | - `endDateLocal` (optional): 369 | - Type: `string` (ISO 8601 format) 370 | - Description: Filter efforts ending before this date-time. 371 | - `perPage` (optional): 372 | - Type: `number` 373 | - Description: Number of results per page. 374 | - Default: 30 375 | - **Output:** Formatted text list of matching segment efforts. 376 | - **Errors:** Missing/invalid token, Invalid `segmentId`, Invalid date format, Strava API errors. 377 | 378 | --- 379 | 380 | ### `list-athlete-routes` 381 | 382 | Lists the routes created by the authenticated athlete. 383 | 384 | - **When to use:** When the user asks to see the routes they have created or saved. 385 | - **Parameters:** 386 | - `page` (optional): 387 | - Type: `number` 388 | - Description: Page number for pagination. 389 | - `perPage` (optional): 390 | - Type: `number` 391 | - Description: Number of routes per page. 392 | - Default: 30 393 | - **Output:** Formatted text list of routes (Name, ID, Type, Distance, Elevation, Date). 394 | - **Errors:** Missing/invalid token, Strava API errors. 395 | 396 | --- 397 | 398 | ### `get-route` 399 | 400 | Fetches detailed information for a specific route using its ID. 401 | 402 | - **When to use:** When the user asks for details about a *specific* route identified by its ID. 403 | - **Parameters:** 404 | - `routeId` (required): 405 | - Type: `number` 406 | - Description: The unique identifier of the route. 407 | - **Output:** Formatted text string with route details (Name, ID, Type, Distance, Elevation, Est. Time, Description, Segment Count). 408 | - **Errors:** Missing/invalid token, Invalid `routeId`, Strava API errors. 409 | 410 | --- 411 | 412 | ### `export-route-gpx` 413 | 414 | Exports a specific route in GPX format and saves it locally. 415 | 416 | - **When to use:** When the user explicitly asks to export or save a specific route as a GPX file. 417 | - **Prerequisite:** The `ROUTE_EXPORT_PATH` environment variable must be correctly configured on the server. 418 | - **Parameters:** 419 | - `routeId` (required): 420 | - Type: `number` 421 | - Description: The unique identifier of the route. 422 | - **Output:** Success message indicating the save location, or an error message. 423 | - **Errors:** Missing/invalid token, Missing/invalid `ROUTE_EXPORT_PATH`, File system errors (permissions, disk space), Invalid `routeId`, Strava API errors. 424 | 425 | --- 426 | 427 | ### `export-route-tcx` 428 | 429 | Exports a specific route in TCX format and saves it locally. 430 | 431 | - **When to use:** When the user explicitly asks to export or save a specific route as a TCX file. 432 | - **Prerequisite:** The `ROUTE_EXPORT_PATH` environment variable must be correctly configured on the server. 433 | - **Parameters:** 434 | - `routeId` (required): 435 | - Type: `number` 436 | - Description: The unique identifier of the route. 437 | - **Output:** Success message indicating the save location, or an error message. 438 | - **Errors:** Missing/invalid token, Missing/invalid `ROUTE_EXPORT_PATH`, File system errors (permissions, disk space), Invalid `routeId`, Strava API errors. 439 | 440 | --- 441 | 442 | ### `get-activity-streams` 443 | 444 | Retrieves detailed time-series data streams from a Strava activity, perfect for analyzing workout metrics, visualizing routes, or performing detailed activity analysis. 445 | 446 | - **When to use:** When you need detailed time-series data from an activity for: 447 | - Analyzing workout intensity through heart rate zones 448 | - Calculating power metrics for cycling activities 449 | - Visualizing route data using GPS coordinates 450 | - Analyzing pace and elevation changes 451 | - Detailed segment analysis 452 | 453 | - **Parameters:** 454 | - `id` (required): 455 | - Type: `number | string` 456 | - Description: The Strava activity identifier to fetch streams for 457 | - `types` (optional): 458 | - Type: `array` 459 | - Default: `['time', 'distance', 'heartrate', 'cadence', 'watts']` 460 | - Available types: 461 | - `time`: Time in seconds from start 462 | - `distance`: Distance in meters from start 463 | - `latlng`: Array of [latitude, longitude] pairs 464 | - `altitude`: Elevation in meters 465 | - `velocity_smooth`: Smoothed speed in meters/second 466 | - `heartrate`: Heart rate in beats per minute 467 | - `cadence`: Cadence in revolutions per minute 468 | - `watts`: Power output in watts 469 | - `temp`: Temperature in Celsius 470 | - `moving`: Boolean indicating if moving 471 | - `grade_smooth`: Road grade as percentage 472 | - `resolution` (optional): 473 | - Type: `string` 474 | - Values: `'low'` (~100 points), `'medium'` (~1000 points), `'high'` (~10000 points) 475 | - Description: Data resolution/density 476 | - `series_type` (optional): 477 | - Type: `string` 478 | - Values: `'time'` or `'distance'` 479 | - Default: `'distance'` 480 | - Description: Base series type for data point indexing 481 | - `page` (optional): 482 | - Type: `number` 483 | - Default: 1 484 | - Description: Page number for paginated results 485 | - `points_per_page` (optional): 486 | - Type: `number` 487 | - Default: 100 488 | - Special value: `-1` returns ALL data points split into multiple messages 489 | - Description: Number of data points per page 490 | 491 | - **Output Format:** 492 | 1. Metadata: 493 | - Available stream types 494 | - Total data points 495 | - Resolution and series type 496 | - Pagination info (current page, total pages) 497 | 2. Statistics (where applicable): 498 | - Heart rate: max, min, average 499 | - Power: max, average, normalized power 500 | - Speed: max and average in km/h 501 | 3. Stream Data: 502 | - Formatted time-series data for each requested stream 503 | - Human-readable formats (e.g., formatted time, km/h for speed) 504 | - Consistent numeric precision 505 | - Labeled data points 506 | 507 | - **Example Request:** 508 | ```json 509 | { 510 | "id": 12345678, 511 | "types": ["time", "heartrate", "watts", "velocity_smooth", "cadence"], 512 | "resolution": "high", 513 | "points_per_page": 100, 514 | "page": 1 515 | } 516 | ``` 517 | 518 | - **Special Features:** 519 | - Smart pagination for large datasets 520 | - Complete data retrieval mode (points_per_page = -1) 521 | - Rich statistics and metadata 522 | - Formatted output for both human and LLM consumption 523 | - Automatic unit conversions 524 | 525 | - **Notes:** 526 | - Requires activity:read scope 527 | - Not all streams are available for all activities 528 | - Older activities might have limited data 529 | - Large activities are automatically paginated 530 | - Stream availability depends on recording device and activity type 531 | 532 | - **Errors:** 533 | - Missing/invalid token 534 | - Invalid activity ID 535 | - Insufficient permissions 536 | - Unavailable stream types 537 | - Invalid pagination parameters 538 | 539 | --- 540 | 541 | ### `get-activity-laps` 542 | 543 | Retrieves the laps recorded for a specific Strava activity. 544 | 545 | - **When to use:** 546 | - Analyze performance variations across different segments (laps) of an activity. 547 | - Compare lap times, speeds, heart rates, or power outputs. 548 | - Understand how an activity was structured (e.g., interval training). 549 | 550 | - **Parameters:** 551 | - `id` (required): 552 | - Type: `number | string` 553 | - Description: The unique identifier of the Strava activity. 554 | 555 | - **Output Format:** 556 | A text summary detailing each lap, including: 557 | - Lap Index 558 | - Lap Name (if available) 559 | - Elapsed Time (formatted as HH:MM:SS) 560 | - Moving Time (formatted as HH:MM:SS) 561 | - Distance (in km) 562 | - Average Speed (in km/h) 563 | - Max Speed (in km/h) 564 | - Total Elevation Gain (in meters) 565 | - Average Heart Rate (if available, in bpm) 566 | - Max Heart Rate (if available, in bpm) 567 | - Average Cadence (if available, in rpm) 568 | - Average Watts (if available, in W) 569 | 570 | - **Example Request:** 571 | ```json 572 | { 573 | "id": 1234567890 574 | } 575 | ``` 576 | 577 | - **Example Response Snippet:** 578 | ```text 579 | Activity Laps Summary (ID: 1234567890): 580 | 581 | Lap 1: Warmup Lap 582 | Time: 15:02 (Moving: 14:35) 583 | Distance: 5.01 km 584 | Avg Speed: 20.82 km/h 585 | Max Speed: 35.50 km/h 586 | Elevation Gain: 50.2 m 587 | Avg HR: 135.5 bpm 588 | Max HR: 150 bpm 589 | Avg Cadence: 85.0 rpm 590 | 591 | Lap 2: Interval 1 592 | Time: 05:15 (Moving: 05:10) 593 | Distance: 2.50 km 594 | Avg Speed: 29.03 km/h 595 | Max Speed: 42.10 km/h 596 | Elevation Gain: 10.1 m 597 | Avg HR: 168.2 bpm 598 | Max HR: 175 bpm 599 | Avg Cadence: 92.1 rpm 600 | Avg Power: 280.5 W (Sensor) 601 | 602 | ... 603 | ``` 604 | 605 | - **Notes:** 606 | - Requires `activity:read` scope for public/followers activities, `activity:read_all` for private activities. 607 | - Lap data availability depends on the recording device and activity type (e.g., manual activities may not have laps). 608 | 609 | - **Errors:** 610 | - Missing/invalid token 611 | - Invalid activity ID 612 | - Insufficient permissions 613 | - Activity not found 614 | 615 | --- 616 | 617 | ### `get-athlete-zones` 618 | 619 | Retrieves the authenticated athlete's configured heart rate and power zones. 620 | 621 | - **When to use:** When the user asks about their heart rate zones, power zones, or training zone settings. 622 | - **Parameters:** None 623 | - **Output Format:** 624 | Returns two text blocks: 625 | 1. A **formatted summary** detailing configured zones: 626 | - Heart Rate Zones: Custom status, Zone ranges, Time Distribution (if available) 627 | - Power Zones: Zone ranges, Time Distribution (if available) 628 | 2. The **complete raw JSON data** as returned by the Strava API. 629 | - **Example Response Snippet (Summary):** 630 | ```text 631 | **Athlete Zones:** 632 | 633 | ❤️ **Heart Rate Zones** 634 | Custom Zones: No 635 | Zone 1: 0 - 115 bpm 636 | Zone 2: 115 - 145 bpm 637 | Zone 3: 145 - 165 bpm 638 | Zone 4: 165 - 180 bpm 639 | Zone 5: 180+ bpm 640 | 641 | ⚡ **Power Zones** 642 | Zone 1: 0 - 150 W 643 | Zone 2: 151 - 210 W 644 | Zone 3: 211 - 250 W 645 | Zone 4: 251 - 300 W 646 | Zone 5: 301 - 350 W 647 | Zone 6: 351 - 420 W 648 | Zone 7: 421+ W 649 | Time Distribution: 650 | - 0-50: 0:24:58 651 | - 50-100: 0:01:02 652 | ... 653 | - 450-∞: 0:05:43 654 | ``` 655 | - **Notes:** 656 | - Requires `profile:read_all` scope. 657 | - Zones might not be configured for all athletes. 658 | - **Errors:** 659 | - Missing/invalid token 660 | - Insufficient permissions (Missing `profile:read_all` scope - 403 error) 661 | - Subscription Required (Potentially, if Strava changes API access) 662 | 663 | --- 664 | 665 | ## Contributing 666 | 667 | Contributions are welcome! Please feel free to submit a Pull Request. 668 | 669 | ## License 670 | 671 | This project is licensed under the MIT License - see the LICENSE file for details. (Assuming MIT, update if different) ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "name": "strava-mcp-server", 3 | "version": "1.0.1", 4 | "description": "MCP server for Strava API", 5 | "mcpName": "io.github.r-huijts/strava-mcp", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/r-huijts/strava-mcp" 9 | }, 10 | "main": "dist/server.js", 11 | "type": "module", 12 | "scripts": { 13 | "build": "tsc", 14 | "start": "node dist/server.js", 15 | "dev": "tsx src/server.ts", 16 | "lint": "eslint . --ext .ts", 17 | "setup-auth": "tsx scripts/setup-auth.ts" 18 | }, 19 | "keywords": [ 20 | "mcp", 21 | "strava", 22 | "llm", 23 | "ai" 24 | ], 25 | "author": "", 26 | "license": "ISC", 27 | "dependencies": { 28 | "@modelcontextprotocol/sdk": "^1.8.0", 29 | "axios": "^1.6.0", 30 | "dotenv": "^16.3.0", 31 | "zod": "^3.22.0" 32 | }, 33 | "devDependencies": { 34 | "@types/node": "^20.11.0", 35 | "@typescript-eslint/eslint-plugin": "^6.19.0", 36 | "@typescript-eslint/parser": "^6.19.0", 37 | "eslint": "^8.56.0", 38 | "ts-node": "^10.9.0", 39 | "tsx": "^4.7.0", 40 | "typescript": "^5.3.0" 41 | } 42 | } 43 | ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "compilerOptions": { 3 | /* Base Options: */ 4 | "esModuleInterop": true, 5 | "skipLibCheck": true, 6 | "target": "ES2022", // Target modern Node.js versions 7 | "allowJs": true, 8 | "resolveJsonModule": true, 9 | "moduleDetection": "force", 10 | "isolatedModules": true, 11 | 12 | /* Strictness */ 13 | "strict": true, 14 | "noUncheckedIndexedAccess": true, 15 | "noImplicitAny": true, 16 | "strictNullChecks": true, 17 | 18 | /* If NOT transpiling with TypeScript: */ 19 | "moduleResolution": "Bundler", // Use "NodeNext" or "Bundler" for modern Node.js 20 | "module": "ESNext", // Align with "type": "module" in package.json 21 | 22 | /* If your code runs in the DOM: */ 23 | // "lib": ["es2022", "dom", "dom.iterable"], 24 | 25 | /* If you want tsc to emit files: */ 26 | "outDir": "dist", 27 | "sourceMap": true, 28 | 29 | /* Linting */ 30 | "noUnusedLocals": true, 31 | "noUnusedParameters": true, 32 | "noFallthroughCasesInSwitch": true, 33 | }, 34 | "include": ["src/**/*.ts"], // Include all TypeScript files in the src directory 35 | "exclude": ["node_modules", "dist"] // Exclude node_modules and the output directory 36 | } ``` -------------------------------------------------------------------------------- /server.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "$schema": "https://static.modelcontextprotocol.io/schemas/2025-07-09/server.schema.json", 3 | "name": "io.github.r-huijts/strava-mcp", 4 | "description": "MCP server for accessing Strava API", 5 | "status": "active", 6 | "repository": { 7 | "url": "https://github.com/r-huijts/strava-mcp", 8 | "source": "github" 9 | }, 10 | "version": "1.0.0", 11 | "packages": [ 12 | { 13 | "registry_type": "npm", 14 | "registry_base_url": "https://registry.npmjs.org", 15 | "identifier": "strava-mcp-server", 16 | "version": "1.0.0", 17 | "transport": { 18 | "type": "stdio" 19 | }, 20 | "environment_variables": [ 21 | { 22 | "name": "STRAVA_CLIENT_ID", 23 | "description": "Your Strava API client ID", 24 | "is_required": true, 25 | "format": "string" 26 | }, 27 | { 28 | "name": "STRAVA_CLIENT_SECRET", 29 | "description": "Your Strava API client secret", 30 | "is_required": true, 31 | "format": "string", 32 | "is_secret": true 33 | }, 34 | { 35 | "name": "STRAVA_ACCESS_TOKEN", 36 | "description": "Your Strava API access token", 37 | "is_required": true, 38 | "format": "string", 39 | "is_secret": true 40 | } 41 | ] 42 | } 43 | ] 44 | } 45 | ``` -------------------------------------------------------------------------------- /src/formatters.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { StravaRoute } from './stravaClient'; 2 | 3 | /** 4 | * Converts meters to kilometers, rounding to 2 decimal places. 5 | * @param meters - Distance in meters. 6 | * @returns Distance in kilometers as a string (e.g., "10.25 km"). 7 | */ 8 | function metersToKmString(meters: number): string { 9 | if (meters === undefined || meters === null) return 'N/A'; 10 | return (meters / 1000).toFixed(2) + ' km'; 11 | } 12 | 13 | /** 14 | * Formats elevation gain in meters. 15 | * @param meters - Elevation gain in meters. 16 | * @returns Elevation gain as a string (e.g., "150 m"). 17 | */ 18 | function formatElevation(meters: number | null | undefined): string { 19 | if (meters === undefined || meters === null) return 'N/A'; 20 | return Math.round(meters) + ' m'; 21 | } 22 | 23 | /** 24 | * Formats a Strava route object into a concise summary string using metric units. 25 | * 26 | * @param route - The StravaRoute object. 27 | * @returns A formatted string summarizing the route. 28 | */ 29 | export function formatRouteSummary(route: StravaRoute): string { 30 | const distanceKm = metersToKmString(route.distance); 31 | const elevation = formatElevation(route.elevation_gain); 32 | const date = new Date(route.created_at).toLocaleDateString(); 33 | const type = route.type === 1 ? 'Ride' : route.type === 2 ? 'Run' : 'Walk'; // Assuming 3 is Walk based on typical Strava usage 34 | 35 | let summary = `📍 Route: ${route.name} (#${route.id})\n`; 36 | summary += ` - Type: ${type}, Distance: ${distanceKm}, Elevation: ${elevation}\n`; 37 | summary += ` - Created: ${date}, Segments: ${route.segments?.length ?? 'N/A'}\n`; 38 | if (route.description) { 39 | summary += ` - Description: ${route.description.substring(0, 100)}${route.description.length > 100 ? '...' : ''}\n`; 40 | } 41 | return summary; 42 | } 43 | 44 | // Add other shared formatters here as needed (e.g., formatActivity, formatSegment) ``` -------------------------------------------------------------------------------- /src/tools/listAthleteClubs.ts: -------------------------------------------------------------------------------- ```typescript 1 | // import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; // Removed 2 | import { listAthleteClubs as fetchClubs } from "../stravaClient.js"; // Renamed import 3 | 4 | // Export the tool definition directly 5 | export const listAthleteClubs = { 6 | name: "list-athlete-clubs", 7 | description: "Lists the clubs the authenticated athlete is a member of.", 8 | inputSchema: undefined, 9 | execute: async () => { 10 | const token = process.env.STRAVA_ACCESS_TOKEN; 11 | 12 | if (!token || token === 'YOUR_STRAVA_ACCESS_TOKEN_HERE') { 13 | console.error("Missing or placeholder STRAVA_ACCESS_TOKEN in .env"); 14 | return { 15 | content: [{ type: "text" as const, text: "❌ Configuration Error: STRAVA_ACCESS_TOKEN is missing or not set in the .env file." }], 16 | isError: true, 17 | }; 18 | } 19 | 20 | try { 21 | console.error("Fetching athlete clubs..."); 22 | const clubs = await fetchClubs(token); 23 | console.error(`Successfully fetched ${clubs?.length ?? 0} clubs.`); 24 | 25 | if (!clubs || clubs.length === 0) { 26 | return { content: [{ type: "text" as const, text: " MNo clubs found for the athlete." }] }; 27 | } 28 | 29 | const clubText = clubs.map(club => 30 | ` 31 | 👥 **${club.name}** (ID: ${club.id}) 32 | - Sport: ${club.sport_type} 33 | - Members: ${club.member_count} 34 | - Location: ${club.city}, ${club.state}, ${club.country} 35 | - Private: ${club.private ? 'Yes' : 'No'} 36 | - URL: ${club.url || 'N/A'} 37 | `.trim() 38 | ).join("\n---\n"); 39 | 40 | const responseText = `**Your Strava Clubs:**\n\n${clubText}`; 41 | 42 | return { content: [{ type: "text" as const, text: responseText }] }; 43 | } catch (error) { 44 | const errorMessage = error instanceof Error ? error.message : "An unknown error occurred"; 45 | console.error("Error in list-athlete-clubs tool:", errorMessage); 46 | return { 47 | content: [{ type: "text" as const, text: `❌ API Error: ${errorMessage}` }], 48 | isError: true, 49 | }; 50 | } 51 | } 52 | }; 53 | 54 | // Remove the old registration function 55 | /* 56 | export function registerListAthleteClubsTool(server: McpServer) { 57 | server.tool( 58 | listAthleteClubs.name, 59 | listAthleteClubs.description, 60 | listAthleteClubs.execute // No input schema 61 | ); 62 | } 63 | */ ``` -------------------------------------------------------------------------------- /src/tools/getRoute.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from "zod"; 2 | import { getRouteById /*, handleApiError */ } from "../stravaClient.js"; // Removed handleApiError import 3 | import { formatRouteSummary } from "../formatters.js"; // Import shared formatter 4 | 5 | // Zod schema for input validation 6 | const GetRouteInputSchema = z.object({ 7 | routeId: z.string() 8 | .regex(/^\d+$/, "Route ID must contain only digits") 9 | .refine(val => val.length > 0, "Route ID cannot be empty") 10 | .describe("The unique identifier of the route to fetch.")}); 11 | 12 | type GetRouteInput = z.infer<typeof GetRouteInputSchema>; 13 | 14 | // Tool definition 15 | export const getRouteTool = { 16 | name: "get-route", 17 | description: "Fetches detailed information about a specific route using its ID.", 18 | inputSchema: GetRouteInputSchema, 19 | execute: async (input: GetRouteInput) => { 20 | const { routeId } = input; 21 | const token = process.env.STRAVA_ACCESS_TOKEN; 22 | 23 | if (!token) { 24 | console.error("Missing STRAVA_ACCESS_TOKEN environment variable."); 25 | return { 26 | content: [{ type: "text" as const, text: "Configuration error: Missing Strava access token." }], 27 | isError: true 28 | }; 29 | } 30 | 31 | try { 32 | console.error(`Fetching route details for ID: ${routeId}...`); 33 | const route = await getRouteById(token, routeId); 34 | const summary = formatRouteSummary(route); // Call shared formatter without units 35 | 36 | console.error(`Successfully fetched route ${routeId}.`); 37 | return { content: [{ type: "text" as const, text: summary }] }; 38 | } catch (error) { 39 | const errorMessage = error instanceof Error ? error.message : String(error); 40 | console.error(`Error fetching route ${routeId}: ${errorMessage}`); 41 | const userFriendlyMessage = errorMessage.includes("Record Not Found") || errorMessage.includes("404") 42 | ? `Route with ID ${routeId} not found.` 43 | : `An unexpected error occurred while fetching route ${routeId}. Details: ${errorMessage}`; 44 | return { 45 | content: [{ type: "text" as const, text: `❌ ${userFriendlyMessage}` }], 46 | isError: true 47 | }; 48 | } 49 | } 50 | }; 51 | 52 | // Removed local formatRouteSummary function 53 | 54 | // Removed old registration function 55 | /* 56 | export function registerGetRouteTool(server: McpServer) { 57 | server.tool( 58 | getRoute.name, 59 | getRoute.description, 60 | getRoute.inputSchema.shape, 61 | getRoute.execute 62 | ); 63 | } 64 | */ ``` -------------------------------------------------------------------------------- /src/tools/starSegment.ts: -------------------------------------------------------------------------------- ```typescript 1 | // import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; // Removed 2 | import { z } from "zod"; 3 | import { starSegment as updateStarStatus } from "../stravaClient.js"; // Renamed import 4 | 5 | const StarSegmentInputSchema = z.object({ 6 | segmentId: z.number().int().positive().describe("The unique identifier of the segment to star or unstar."), 7 | starred: z.boolean().describe("Set to true to star the segment, false to unstar it."), 8 | }); 9 | 10 | type StarSegmentInput = z.infer<typeof StarSegmentInputSchema>; 11 | 12 | // Export the tool definition directly 13 | export const starSegment = { 14 | name: "star-segment", 15 | description: "Stars or unstars a specific segment for the authenticated athlete.", 16 | inputSchema: StarSegmentInputSchema, 17 | execute: async ({ segmentId, starred }: StarSegmentInput) => { 18 | const token = process.env.STRAVA_ACCESS_TOKEN; 19 | 20 | if (!token || token === 'YOUR_STRAVA_ACCESS_TOKEN_HERE') { 21 | console.error("Missing or placeholder STRAVA_ACCESS_TOKEN in .env"); 22 | return { 23 | content: [{ type: "text" as const, text: "❌ Configuration Error: STRAVA_ACCESS_TOKEN is missing or not set in the .env file." }], 24 | isError: true, 25 | }; 26 | } 27 | 28 | try { 29 | const action = starred ? 'starring' : 'unstarring'; 30 | console.error(`Attempting to ${action} segment ID: ${segmentId}...`); 31 | 32 | const updatedSegment = await updateStarStatus(token, segmentId, starred); 33 | 34 | const successMessage = `Successfully ${action} segment: "${updatedSegment.name}" (ID: ${updatedSegment.id}). Its starred status is now: ${updatedSegment.starred}.`; 35 | console.error(successMessage); 36 | 37 | return { content: [{ type: "text" as const, text: successMessage }] }; 38 | 39 | } catch (error) { 40 | const errorMessage = error instanceof Error ? error.message : "An unknown error occurred"; 41 | const action = starred ? 'star' : 'unstar'; 42 | console.error(`Error attempting to ${action} segment ID ${segmentId}:`, errorMessage); 43 | return { 44 | content: [{ type: "text" as const, text: `❌ API Error: Failed to ${action} segment ${segmentId}. ${errorMessage}` }], 45 | isError: true, 46 | }; 47 | } 48 | } 49 | }; 50 | 51 | // Removed old registration function 52 | /* 53 | export function registerStarSegmentTool(server: McpServer) { 54 | server.tool( 55 | starSegment.name, 56 | starSegment.description, 57 | starSegment.inputSchema.shape, 58 | starSegment.execute 59 | ); 60 | } 61 | */ ``` -------------------------------------------------------------------------------- /src/tools/getAthleteProfile.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { getAuthenticatedAthlete } from "../stravaClient.js"; 2 | 3 | // Export the tool definition directly 4 | export const getAthleteProfile = { 5 | name: "get-athlete-profile", 6 | description: "Fetches the profile information for the authenticated athlete, including their unique numeric ID needed for other tools like get-athlete-stats.", 7 | // No input schema needed for this tool 8 | inputSchema: undefined, 9 | execute: async () => { // No input parameters needed 10 | const token = process.env.STRAVA_ACCESS_TOKEN; 11 | 12 | if (!token || token === 'YOUR_STRAVA_ACCESS_TOKEN_HERE') { 13 | console.error("Missing or placeholder STRAVA_ACCESS_TOKEN in .env"); 14 | return { 15 | content: [{ type: "text" as const, text: "❌ Configuration Error: STRAVA_ACCESS_TOKEN is missing or not set in the .env file." }], 16 | isError: true, 17 | }; 18 | } 19 | 20 | try { 21 | console.error("Fetching athlete profile..."); 22 | const athlete = await getAuthenticatedAthlete(token); 23 | console.error(`Successfully fetched profile for ${athlete.firstname} ${athlete.lastname} (ID: ${athlete.id}).`); 24 | 25 | const profileParts = [ 26 | `👤 **Profile for ${athlete.firstname} ${athlete.lastname}** (ID: ${athlete.id})`, 27 | ` - Username: ${athlete.username || 'N/A'}`, 28 | ` - Location: ${[athlete.city, athlete.state, athlete.country].filter(Boolean).join(", ") || 'N/A'}`, 29 | ` - Sex: ${athlete.sex || 'N/A'}`, 30 | ` - Weight: ${athlete.weight ? `${athlete.weight} kg` : 'N/A'}`, 31 | ` - Measurement Units: ${athlete.measurement_preference}`, 32 | ` - Strava Summit Member: ${athlete.summit ? 'Yes' : 'No'}`, 33 | ` - Profile Image (Medium): ${athlete.profile_medium}`, 34 | ` - Joined Strava: ${athlete.created_at ? new Date(athlete.created_at).toLocaleDateString() : 'N/A'}`, 35 | ` - Last Updated: ${athlete.updated_at ? new Date(athlete.updated_at).toLocaleDateString() : 'N/A'}`, 36 | ]; 37 | 38 | // Ensure return object matches expected structure 39 | const response = { 40 | content: [{ type: "text" as const, text: profileParts.join("\n") }] 41 | }; 42 | return response; 43 | 44 | } catch (error) { 45 | const errorMessage = error instanceof Error ? error.message : "An unknown error occurred"; 46 | console.error("Error in get-athlete-profile tool:", errorMessage); 47 | return { 48 | content: [{ type: "text" as const, text: `❌ API Error: ${errorMessage}` }], 49 | isError: true, 50 | }; 51 | } 52 | } 53 | }; 54 | 55 | // Removed old registration function 56 | /* 57 | export function registerGetAthleteProfileTool(server: McpServer) { 58 | server.tool( 59 | getAthleteProfile.name, 60 | getAthleteProfile.description, 61 | getAthleteProfile.execute // No input schema 62 | ); 63 | } 64 | */ ``` -------------------------------------------------------------------------------- /src/tools/getRecentActivities.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from "zod"; 2 | import { getRecentActivities as fetchActivities } from "../stravaClient.js"; 3 | // Reverted SDK type imports 4 | 5 | const GetRecentActivitiesInputSchema = z.object({ 6 | perPage: z.number().int().positive().optional().default(30).describe("Number of activities to retrieve (default: 30)"), 7 | }); 8 | 9 | type GetRecentActivitiesInput = z.infer<typeof GetRecentActivitiesInputSchema>; 10 | 11 | // Export the tool definition directly 12 | export const getRecentActivities = { 13 | name: "get-recent-activities", 14 | description: "Fetches the most recent activities for the authenticated athlete.", 15 | inputSchema: GetRecentActivitiesInputSchema, 16 | // Ensure the return type matches the expected structure, relying on inference 17 | execute: async ({ perPage }: GetRecentActivitiesInput) => { 18 | const token = process.env.STRAVA_ACCESS_TOKEN; 19 | 20 | // --- DEBUGGING: Print the token being used --- 21 | console.error(`[DEBUG] Using STRAVA_ACCESS_TOKEN: ${token?.substring(0, 5)}...${token?.slice(-5)}`); 22 | // --------------------------------------------- 23 | 24 | if (!token || token === 'YOUR_STRAVA_ACCESS_TOKEN_HERE') { 25 | console.error("Missing or placeholder STRAVA_ACCESS_TOKEN in .env"); 26 | // Use literal type for content item 27 | return { 28 | content: [{ type: "text" as const, text: "❌ Configuration Error: STRAVA_ACCESS_TOKEN is missing or not set in the .env file." }], 29 | isError: true, 30 | }; 31 | } 32 | 33 | try { 34 | console.error(`Fetching ${perPage} recent activities...`); 35 | const activities = await fetchActivities(token, perPage); 36 | console.error(`Successfully fetched ${activities?.length ?? 0} activities.`); 37 | 38 | if (!activities || activities.length === 0) { 39 | return { 40 | content: [{ type: "text" as const, text: " MNo recent activities found." }] 41 | }; 42 | } 43 | 44 | // Map to content items with literal type 45 | const contentItems = activities.map(activity => { 46 | const dateStr = activity.start_date ? new Date(activity.start_date).toLocaleDateString() : 'N/A'; 47 | const distanceStr = activity.distance ? `${activity.distance}m` : 'N/A'; 48 | // Ensure each item conforms to { type: "text", text: string } 49 | const item: { type: "text", text: string } = { 50 | type: "text" as const, 51 | text: `🏃 ${activity.name} (ID: ${activity.id ?? 'N/A'}) — ${distanceStr} on ${dateStr}` 52 | }; 53 | return item; 54 | }); 55 | 56 | // Return the basic McpResponse structure 57 | return { content: contentItems }; 58 | 59 | } catch (error) { 60 | const errorMessage = error instanceof Error ? error.message : "An unknown error occurred"; 61 | console.error("Error in get-recent-activities tool:", errorMessage); 62 | return { 63 | content: [{ type: "text" as const, text: `❌ API Error: ${errorMessage}` }], 64 | isError: true, 65 | }; 66 | } 67 | } 68 | }; ``` -------------------------------------------------------------------------------- /src/tools/listStarredSegments.ts: -------------------------------------------------------------------------------- ```typescript 1 | // import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; // Removed 2 | import { getAuthenticatedAthlete, listStarredSegments as fetchSegments } from "../stravaClient.js"; // Renamed import 3 | 4 | // Export the tool definition directly 5 | export const listStarredSegments = { 6 | name: "list-starred-segments", 7 | description: "Lists the segments starred by the authenticated athlete.", 8 | // No input schema needed 9 | inputSchema: undefined, 10 | execute: async () => { 11 | const token = process.env.STRAVA_ACCESS_TOKEN; 12 | 13 | if (!token || token === 'YOUR_STRAVA_ACCESS_TOKEN_HERE') { 14 | console.error("Missing or placeholder STRAVA_ACCESS_TOKEN in .env"); 15 | return { 16 | content: [{ type: "text" as const, text: "❌ Configuration Error: STRAVA_ACCESS_TOKEN is missing or not set in the .env file." }], 17 | isError: true, 18 | }; 19 | } 20 | 21 | try { 22 | console.error("Fetching starred segments..."); 23 | // Need athlete measurement preference for formatting distance 24 | const athlete = await getAuthenticatedAthlete(token); 25 | // Use renamed import 26 | const segments = await fetchSegments(token); 27 | console.error(`Successfully fetched ${segments?.length ?? 0} starred segments.`); 28 | 29 | if (!segments || segments.length === 0) { 30 | return { content: [{ type: "text" as const, text: " MNo starred segments found." }] }; 31 | } 32 | 33 | const distanceFactor = athlete.measurement_preference === 'feet' ? 0.000621371 : 0.001; 34 | const distanceUnit = athlete.measurement_preference === 'feet' ? 'mi' : 'km'; 35 | 36 | // Format the segments into a text response 37 | const segmentText = segments.map(segment => { 38 | const location = [segment.city, segment.state, segment.country].filter(Boolean).join(", ") || 'N/A'; 39 | const distance = (segment.distance * distanceFactor).toFixed(2); 40 | return ` 41 | ⭐ **${segment.name}** (ID: ${segment.id}) 42 | - Activity Type: ${segment.activity_type} 43 | - Distance: ${distance} ${distanceUnit} 44 | - Avg Grade: ${segment.average_grade}% 45 | - Location: ${location} 46 | - Private: ${segment.private ? 'Yes' : 'No'} 47 | `.trim(); 48 | }).join("\n---\n"); 49 | 50 | const responseText = `**Your Starred Segments:**\n\n${segmentText}`; 51 | 52 | return { content: [{ type: "text" as const, text: responseText }] }; 53 | } catch (error) { 54 | const errorMessage = error instanceof Error ? error.message : "An unknown error occurred"; 55 | console.error("Error in list-starred-segments tool:", errorMessage); 56 | return { 57 | content: [{ type: "text" as const, text: `❌ API Error: ${errorMessage}` }], 58 | isError: true, 59 | }; 60 | } 61 | } 62 | }; 63 | 64 | // Remove the old registration function 65 | /* 66 | export function registerListStarredSegmentsTool(server: McpServer) { 67 | server.tool( 68 | listStarredSegments.name, 69 | listStarredSegments.description, 70 | listStarredSegments.execute // No input schema 71 | ); 72 | } 73 | */ ``` -------------------------------------------------------------------------------- /src/tools/exportRouteTcx.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from "zod"; 2 | import * as fs from 'node:fs'; 3 | import * as path from 'node:path'; 4 | import { exportRouteTcx as fetchTcxData } from "../stravaClient.js"; 5 | 6 | // Define the input schema for the tool 7 | const ExportRouteTcxInputSchema = z.object({ 8 | routeId: z.string().describe("The ID of the Strava route to export."), 9 | }); 10 | 11 | // Infer the input type from the schema 12 | type ExportRouteTcxInput = z.infer<typeof ExportRouteTcxInputSchema>; 13 | 14 | // Export the tool definition directly 15 | export const exportRouteTcx = { 16 | name: "export-route-tcx", 17 | description: "Exports a specific Strava route in TCX format and saves it to a pre-configured local directory.", 18 | inputSchema: ExportRouteTcxInputSchema, 19 | execute: async ({ routeId }: ExportRouteTcxInput) => { 20 | const token = process.env.STRAVA_ACCESS_TOKEN; 21 | if (!token) { 22 | // Strict return structure 23 | return { 24 | content: [{ type: "text" as const, text: "❌ Error: Missing STRAVA_ACCESS_TOKEN in .env file." }], 25 | isError: true 26 | }; 27 | } 28 | 29 | const exportDir = process.env.ROUTE_EXPORT_PATH; 30 | if (!exportDir) { 31 | // Strict return structure 32 | return { 33 | content: [{ type: "text" as const, text: "❌ Error: Missing ROUTE_EXPORT_PATH in .env file. Please configure the directory for saving exports." }], 34 | isError: true 35 | }; 36 | } 37 | 38 | try { 39 | // Ensure the directory exists, create if not 40 | if (!fs.existsSync(exportDir)) { 41 | console.error(`Export directory ${exportDir} not found, creating it...`); 42 | fs.mkdirSync(exportDir, { recursive: true }); 43 | } else { 44 | // Check if it's a directory and writable (existing logic) 45 | const stats = fs.statSync(exportDir); 46 | if (!stats.isDirectory()) { 47 | // Strict return structure 48 | return { 49 | content: [{ type: "text" as const, text: `❌ Error: ROUTE_EXPORT_PATH (${exportDir}) is not a valid directory.` }], 50 | isError: true 51 | }; 52 | } 53 | fs.accessSync(exportDir, fs.constants.W_OK); 54 | } 55 | 56 | const tcxData = await fetchTcxData(token, routeId); 57 | const filename = `route-${routeId}.tcx`; 58 | const fullPath = path.join(exportDir, filename); 59 | fs.writeFileSync(fullPath, tcxData); 60 | 61 | // Strict return structure 62 | return { 63 | content: [{ type: "text" as const, text: `✅ Route ${routeId} exported successfully as TCX to: ${fullPath}` }], 64 | }; 65 | 66 | } catch (err: any) { 67 | // Handle potential errors during directory creation/check or file writing 68 | console.error(`Error in export-route-tcx tool for route ${routeId}:`, err); 69 | let userMessage = `❌ Error exporting route ${routeId} as TCX: ${err.message}`; 70 | if (err.code === 'EACCES') { 71 | userMessage = `❌ Error: No write permission for ROUTE_EXPORT_PATH directory (${exportDir}).`; 72 | } 73 | // Strict return structure 74 | return { 75 | content: [{ type: "text" as const, text: userMessage }], 76 | isError: true 77 | }; 78 | } 79 | }, 80 | }; ``` -------------------------------------------------------------------------------- /src/tools/exportRouteGpx.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from "zod"; 2 | import * as fs from 'node:fs'; 3 | import * as path from 'node:path'; 4 | import { exportRouteGpx as fetchGpxData } from "../stravaClient.js"; 5 | // import { McpServerTool } from "@modelcontextprotocol/sdk/server/mcp.js"; // Type doesn't seem exported/needed 6 | // import { McpResponse } from "@modelcontextprotocol/sdk/server/mcp.js"; // Type doesn't seem exported 7 | 8 | // Define the input schema for the tool 9 | const ExportRouteGpxInputSchema = z.object({ 10 | routeId: z.string().describe("The ID of the Strava route to export."), 11 | }); 12 | 13 | // Infer the input type from the schema 14 | type ExportRouteGpxInput = z.infer<typeof ExportRouteGpxInputSchema>; 15 | 16 | // Export the tool definition directly 17 | export const exportRouteGpx = { 18 | name: "export-route-gpx", 19 | description: "Exports a specific Strava route in GPX format and saves it to a pre-configured local directory.", 20 | inputSchema: ExportRouteGpxInputSchema, 21 | execute: async ({ routeId }: ExportRouteGpxInput) => { 22 | const token = process.env.STRAVA_ACCESS_TOKEN; 23 | if (!token) { 24 | // Strict return structure 25 | return { 26 | content: [{ type: "text" as const, text: "❌ Error: Missing STRAVA_ACCESS_TOKEN in .env file." }], 27 | isError: true 28 | }; 29 | } 30 | 31 | const exportDir = process.env.ROUTE_EXPORT_PATH; 32 | if (!exportDir) { 33 | // Strict return structure 34 | return { 35 | content: [{ type: "text" as const, text: "❌ Error: Missing ROUTE_EXPORT_PATH in .env file. Please configure the directory for saving exports." }], 36 | isError: true 37 | }; 38 | } 39 | 40 | try { 41 | // Ensure the directory exists, create if not 42 | if (!fs.existsSync(exportDir)) { 43 | console.error(`Export directory ${exportDir} not found, creating it...`); 44 | fs.mkdirSync(exportDir, { recursive: true }); 45 | } else { 46 | // Check if it's a directory and writable (existing logic) 47 | const stats = fs.statSync(exportDir); 48 | if (!stats.isDirectory()) { 49 | // Strict return structure 50 | return { 51 | content: [{ type: "text" as const, text: `❌ Error: ROUTE_EXPORT_PATH (${exportDir}) is not a valid directory.` }], 52 | isError: true 53 | }; 54 | } 55 | fs.accessSync(exportDir, fs.constants.W_OK); 56 | } 57 | 58 | const gpxData = await fetchGpxData(token, routeId); 59 | const filename = `route-${routeId}.gpx`; 60 | const fullPath = path.join(exportDir, filename); 61 | fs.writeFileSync(fullPath, gpxData); 62 | 63 | // Strict return structure 64 | return { 65 | content: [{ type: "text" as const, text: `✅ Route ${routeId} exported successfully as GPX to: ${fullPath}` }], 66 | }; 67 | 68 | } catch (err: any) { 69 | console.error(`Error in export-route-gpx tool for route ${routeId}:`, err); 70 | // Strict return structure 71 | let userMessage = `❌ Error exporting route ${routeId} as GPX: ${err.message}`; 72 | if (err.code === 'EACCES') { 73 | userMessage = `❌ Error: No write permission for ROUTE_EXPORT_PATH directory (${exportDir}).`; 74 | } 75 | return { 76 | content: [{ type: "text" as const, text: userMessage }], 77 | isError: true 78 | }; 79 | } 80 | }, 81 | }; ``` -------------------------------------------------------------------------------- /test-strava-api.ts: -------------------------------------------------------------------------------- ```typescript 1 | import axios from 'axios'; 2 | import * as dotenv from 'dotenv'; 3 | import * as path from 'node:path'; 4 | import { fileURLToPath } from 'node:url'; 5 | 6 | // --- Environment Variable Loading --- 7 | // Explicitly load .env from the project root 8 | const __filename = fileURLToPath(import.meta.url); 9 | const __dirname = path.dirname(__filename); 10 | const envPath = path.resolve(__dirname, '.env'); // Assumes test script is in root, .env is in root 11 | console.log(`[TEST] Attempting to load .env file from: ${envPath}`); 12 | dotenv.config({ path: envPath }); 13 | 14 | // Get the token 15 | const accessToken = process.env.STRAVA_ACCESS_TOKEN; 16 | 17 | // Basic validation 18 | if (!accessToken || accessToken === 'YOUR_STRAVA_ACCESS_TOKEN_HERE') { 19 | console.error('❌ Error: STRAVA_ACCESS_TOKEN is not set or is a placeholder in the .env file.'); 20 | process.exit(1); 21 | } 22 | 23 | console.log(`[TEST] Using token: ${accessToken.substring(0, 5)}...${accessToken.slice(-5)}`); 24 | 25 | // Function to test the /athlete endpoint 26 | async function testAthleteCall() { 27 | console.log("--- Testing /athlete Endpoint ---"); 28 | if (!accessToken) { 29 | console.error("❌ STRAVA_ACCESS_TOKEN is not set in the .env file or environment."); 30 | return; 31 | } 32 | console.log(`Using token: ${accessToken.substring(0, 5)}...${accessToken.substring(accessToken.length - 5)}`); 33 | 34 | try { 35 | const response = await axios.get('https://www.strava.com/api/v3/athlete', { 36 | headers: { 37 | Authorization: `Bearer ${accessToken}`, 38 | }, 39 | }); 40 | console.log("✅ Request to /athlete successful:", response.status); 41 | console.log("Athlete Data:", JSON.stringify(response.data, null, 2)); 42 | } catch (error: any) { 43 | console.error("❌ Error calling /athlete:", error.message); 44 | if (error.response) { 45 | console.error("Status:", error.response.status); 46 | console.error("Data:", JSON.stringify(error.response.data, null, 2)); 47 | } 48 | } 49 | console.log("-------------------------------\n"); 50 | } 51 | 52 | // Function to test the /athlete/activities endpoint 53 | async function testActivitiesCall() { 54 | console.log("--- Testing /athlete/activities Endpoint ---"); 55 | if (!accessToken) { 56 | console.error("❌ STRAVA_ACCESS_TOKEN is not set in the .env file or environment."); 57 | return; 58 | } 59 | console.log(`Using token: ${accessToken.substring(0, 5)}...${accessToken.substring(accessToken.length - 5)}`); 60 | const perPage = 5; // Fetch 5 activities for the test 61 | 62 | try { 63 | const response = await axios.get('https://www.strava.com/api/v3/athlete/activities', { 64 | headers: { 65 | Authorization: `Bearer ${accessToken}`, 66 | }, 67 | params: { 68 | per_page: perPage 69 | } 70 | }); 71 | console.log(`✅ Request to /athlete/activities successful:`, response.status); 72 | console.log(`Received ${response.data?.length ?? 0} activities.`); 73 | // Optionally log activity names or IDs 74 | if(response.data && response.data.length > 0) { 75 | console.log("First activity name:", response.data[0].name); 76 | } 77 | } catch (error: any) { 78 | console.error("❌ Error calling /athlete/activities:", error.message); 79 | if (error.response) { 80 | console.error("Status:", error.response.status); 81 | console.error("Data:", JSON.stringify(error.response.data, null, 2)); 82 | } 83 | } 84 | console.log("---------------------------------------\n"); 85 | } 86 | 87 | // Run the tests 88 | (async () => { 89 | await testAthleteCall(); 90 | await testActivitiesCall(); 91 | })(); ``` -------------------------------------------------------------------------------- /src/tools/listAthleteRoutes.ts: -------------------------------------------------------------------------------- ```typescript 1 | // import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; // Removed 2 | import { z } from "zod"; 3 | import { 4 | listAthleteRoutes as fetchAthleteRoutes, 5 | StravaRoute, 6 | // StravaRoute is needed for the formatter 7 | } from "../stravaClient.js"; 8 | // Remove the imported formatter since we're defining our own locally 9 | // import { formatRouteSummary } from "../formatters.js"; 10 | 11 | // Define input schema with zod 12 | const ListAthleteRoutesInputSchema = z.object({ 13 | page: z.number().int().positive().optional().default(1).describe("Page number for pagination"), 14 | perPage: z.number().int().positive().min(1).max(50).optional().default(20).describe("Number of routes per page (max 50)"), 15 | }); 16 | 17 | // Export the type for use in the execute function 18 | type ListAthleteRoutesInput = z.infer<typeof ListAthleteRoutesInputSchema>; 19 | 20 | // Function to format a route for display 21 | function formatRouteSummary(route: StravaRoute): string { 22 | const distance = route.distance ? `${(route.distance / 1000).toFixed(1)} km` : 'N/A'; 23 | const elevation = route.elevation_gain ? `${route.elevation_gain.toFixed(0)} m` : 'N/A'; 24 | 25 | return `🗺️ **${route.name}** (ID: ${route.id}) 26 | - Distance: ${distance} 27 | - Elevation: ${elevation} 28 | - Created: ${new Date(route.created_at).toLocaleDateString()} 29 | - Type: ${route.type === 1 ? 'Ride' : route.type === 2 ? 'Run' : 'Other'}`; 30 | } 31 | 32 | // Tool definition 33 | export const listAthleteRoutesTool = { 34 | name: "list-athlete-routes", 35 | description: "Lists the routes created by the authenticated athlete, with pagination.", 36 | inputSchema: ListAthleteRoutesInputSchema, 37 | execute: async ({ page = 1, perPage = 20 }: ListAthleteRoutesInput) => { 38 | const token = process.env.STRAVA_ACCESS_TOKEN; 39 | 40 | if (!token) { 41 | console.error("Missing STRAVA_ACCESS_TOKEN in .env"); 42 | return { 43 | content: [{ type: "text" as const, text: "❌ Configuration Error: STRAVA_ACCESS_TOKEN is missing or not set in the .env file." }], 44 | isError: true 45 | }; 46 | } 47 | 48 | try { 49 | console.error(`Fetching routes (page ${page}, per_page: ${perPage})...`); 50 | 51 | const routes = await fetchAthleteRoutes(token, page, perPage); 52 | 53 | if (!routes || routes.length === 0) { 54 | console.error(`No routes found for athlete.`); 55 | return { content: [{ type: "text" as const, text: "No routes found for the athlete." }] }; 56 | } 57 | 58 | console.error(`Successfully fetched ${routes.length} routes.`); 59 | const summaries = routes.map(route => formatRouteSummary(route)); 60 | const responseText = `**Athlete Routes (Page ${page}):**\n\n${summaries.join("\n")}`; 61 | 62 | return { content: [{ type: "text" as const, text: responseText }] }; 63 | } catch (error) { 64 | const errorMessage = error instanceof Error ? error.message : String(error); 65 | console.error(`Error listing athlete routes (page ${page}, perPage: ${perPage}): ${errorMessage}`); 66 | // Removed call to handleApiError and its retry logic 67 | // Note: 404 is less likely for a list endpoint like this 68 | const userFriendlyMessage = `An unexpected error occurred while listing athlete routes. Details: ${errorMessage}`; 69 | return { 70 | content: [{ type: "text" as const, text: `❌ ${userFriendlyMessage}` }], 71 | isError: true 72 | }; 73 | } 74 | } 75 | }; 76 | 77 | // Removed local formatRouteSummary and formatDuration functions 78 | 79 | // Removed old registration function 80 | /* 81 | export function registerListAthleteRoutesTool(server: McpServer) { 82 | server.tool( 83 | listAthleteRoutes.name, 84 | listAthleteRoutes.description, 85 | listAthleteRoutes.inputSchema.shape, 86 | listAthleteRoutes.execute 87 | ); 88 | } 89 | */ ``` -------------------------------------------------------------------------------- /src/tools/getSegment.ts: -------------------------------------------------------------------------------- ```typescript 1 | // import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; // Removed 2 | import { z } from "zod"; 3 | import { 4 | getSegmentById as fetchSegmentById, 5 | // handleApiError, // Removed unused import 6 | StravaDetailedSegment // Type needed for formatter 7 | } from "../stravaClient.js"; 8 | 9 | // Input schema 10 | const GetSegmentInputSchema = z.object({ 11 | segmentId: z.number().int().positive().describe("The unique identifier of the segment to fetch.") 12 | }); 13 | type GetSegmentInput = z.infer<typeof GetSegmentInputSchema>; 14 | 15 | // Helper Functions (Metric Only) 16 | function formatDistance(meters: number | null | undefined): string { 17 | if (meters === null || meters === undefined) return 'N/A'; 18 | return (meters / 1000).toFixed(2) + ' km'; 19 | } 20 | 21 | function formatElevation(meters: number | null | undefined): string { 22 | if (meters === null || meters === undefined) return 'N/A'; 23 | return Math.round(meters) + ' m'; 24 | } 25 | 26 | // Format segment details (Metric Only) 27 | function formatSegmentDetails(segment: StravaDetailedSegment): string { 28 | const distance = formatDistance(segment.distance); 29 | const elevationGain = formatElevation(segment.total_elevation_gain); 30 | const elevationHigh = formatElevation(segment.elevation_high); 31 | const elevationLow = formatElevation(segment.elevation_low); 32 | 33 | let details = `🗺️ **Segment: ${segment.name}** (ID: ${segment.id})\n`; 34 | details += ` - Activity Type: ${segment.activity_type}\n`; 35 | details += ` - Location: ${segment.city || 'N/A'}, ${segment.state || 'N/A'}, ${segment.country || 'N/A'}\n`; 36 | details += ` - Distance: ${distance}\n`; 37 | details += ` - Avg Grade: ${segment.average_grade?.toFixed(1) ?? 'N/A'}%, Max Grade: ${segment.maximum_grade?.toFixed(1) ?? 'N/A'}%\n`; 38 | details += ` - Elevation: Gain ${elevationGain}, High ${elevationHigh}, Low ${elevationLow}\n`; 39 | details += ` - Climb Category: ${segment.climb_category ?? 'N/A'}\n`; 40 | details += ` - Private: ${segment.private ? 'Yes' : 'No'}\n`; 41 | details += ` - Starred by You: ${segment.starred ? 'Yes' : 'No'}\n`; // Assumes starred comes from auth'd user context if present 42 | details += ` - Total Efforts: ${segment.effort_count}, Athletes: ${segment.athlete_count}\n`; 43 | details += ` - Star Count: ${segment.star_count}\n`; 44 | details += ` - Created: ${new Date(segment.created_at).toLocaleDateString()}\n`; 45 | return details; 46 | } 47 | 48 | // Tool definition 49 | export const getSegmentTool = { 50 | name: "get-segment", 51 | description: "Fetches detailed information about a specific segment using its ID.", 52 | inputSchema: GetSegmentInputSchema, 53 | execute: async ({ segmentId }: GetSegmentInput) => { 54 | const token = process.env.STRAVA_ACCESS_TOKEN; 55 | 56 | if (!token) { 57 | console.error("Missing STRAVA_ACCESS_TOKEN environment variable."); 58 | return { 59 | content: [{ type: "text" as const, text: "Configuration error: Missing Strava access token." }], 60 | isError: true 61 | }; 62 | } 63 | 64 | try { 65 | console.error(`Fetching details for segment ID: ${segmentId}...`); 66 | // Removed getAuthenticatedAthlete call 67 | const segment = await fetchSegmentById(token, segmentId); 68 | const segmentDetailsText = formatSegmentDetails(segment); // Use metric formatter 69 | 70 | console.error(`Successfully fetched details for segment: ${segment.name}`); 71 | return { content: [{ type: "text" as const, text: segmentDetailsText }] }; 72 | } catch (error) { 73 | const errorMessage = error instanceof Error ? error.message : String(error); 74 | console.error(`Error fetching segment ${segmentId}: ${errorMessage}`); 75 | // Removed call to handleApiError 76 | const userFriendlyMessage = errorMessage.includes("Record Not Found") || errorMessage.includes("404") 77 | ? `Segment with ID ${segmentId} not found.` 78 | : `An unexpected error occurred while fetching segment details for ID ${segmentId}. Details: ${errorMessage}`; 79 | return { 80 | content: [{ type: "text" as const, text: `❌ ${userFriendlyMessage}` }], 81 | isError: true 82 | }; 83 | } 84 | } 85 | }; ``` -------------------------------------------------------------------------------- /src/tools/getAthleteZones.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from "zod"; 2 | import { getAthleteZones as fetchAthleteZones, StravaAthleteZones } from "../stravaClient.js"; 3 | import { formatDuration } from "../server.js"; // Shared helper 4 | 5 | const name = "get-athlete-zones"; 6 | const description = "Retrieves the authenticated athlete's configured heart rate and power zones."; 7 | 8 | // No input schema needed for this tool 9 | const inputSchema = z.object({}); 10 | 11 | type GetAthleteZonesInput = z.infer<typeof inputSchema>; 12 | 13 | // Helper to format a single zone range 14 | function formatZoneRange(zone: { min: number; max?: number }): string { 15 | return zone.max ? `${zone.min} - ${zone.max}` : `${zone.min}+`; 16 | } 17 | 18 | // Helper to format distribution buckets 19 | function formatDistribution(buckets: { max: number; min: number; time: number }[] | undefined): string { 20 | if (!buckets || buckets.length === 0) return " Distribution data not available."; 21 | 22 | return buckets.map(bucket => 23 | ` - ${bucket.min}-${bucket.max === -1 ? '∞' : bucket.max}: ${formatDuration(bucket.time)}` 24 | ).join('\n'); 25 | } 26 | 27 | // Format the zones response 28 | function formatAthleteZones(zonesData: StravaAthleteZones): string { 29 | let responseText = "**Athlete Zones:**\n"; 30 | 31 | if (zonesData.heart_rate) { 32 | responseText += "\n❤️ **Heart Rate Zones**\n"; 33 | responseText += ` Custom Zones: ${zonesData.heart_rate.custom_zones ? 'Yes' : 'No'}\n`; 34 | zonesData.heart_rate.zones.forEach((zone, index) => { 35 | responseText += ` Zone ${index + 1}: ${formatZoneRange(zone)} bpm\n`; 36 | }); 37 | if (zonesData.heart_rate.distribution_buckets) { 38 | responseText += " Time Distribution:\n" + formatDistribution(zonesData.heart_rate.distribution_buckets) + "\n"; 39 | } 40 | } else { 41 | responseText += "\n❤️ Heart Rate Zones: Not configured\n"; 42 | } 43 | 44 | if (zonesData.power) { 45 | responseText += "\n⚡ **Power Zones**\n"; 46 | zonesData.power.zones.forEach((zone, index) => { 47 | responseText += ` Zone ${index + 1}: ${formatZoneRange(zone)} W\n`; 48 | }); 49 | if (zonesData.power.distribution_buckets) { 50 | responseText += " Time Distribution:\n" + formatDistribution(zonesData.power.distribution_buckets) + "\n"; 51 | } 52 | } else { 53 | responseText += "\n⚡ Power Zones: Not configured\n"; 54 | } 55 | 56 | return responseText; 57 | } 58 | 59 | export const getAthleteZonesTool = { 60 | name, 61 | description: description + "\n\nOutput includes both a formatted summary and the raw JSON data.", 62 | inputSchema, 63 | execute: async (_input: GetAthleteZonesInput) => { 64 | const token = process.env.STRAVA_ACCESS_TOKEN; 65 | 66 | if (!token) { 67 | console.error("Missing STRAVA_ACCESS_TOKEN environment variable."); 68 | return { 69 | content: [{ type: "text" as const, text: "Configuration error: Missing Strava access token." }], 70 | isError: true 71 | }; 72 | } 73 | 74 | try { 75 | console.error("Fetching athlete zones..."); 76 | const zonesData = await fetchAthleteZones(token); 77 | 78 | // Format the summary 79 | const formattedText = formatAthleteZones(zonesData); 80 | 81 | // Prepare the raw data 82 | const rawDataText = `\n\nRaw Athlete Zone Data:\n${JSON.stringify(zonesData, null, 2)}`; 83 | 84 | console.error("Successfully fetched athlete zones."); 85 | // Return both summary and raw data 86 | return { 87 | content: [ 88 | { type: "text" as const, text: formattedText }, 89 | { type: "text" as const, text: rawDataText } 90 | ] 91 | }; 92 | 93 | } catch (error) { 94 | const errorMessage = error instanceof Error ? error.message : String(error); 95 | console.error(`Error fetching athlete zones: ${errorMessage}`); 96 | 97 | let userFriendlyMessage; 98 | // Check for common errors like missing scope (403 Forbidden) 99 | if (errorMessage.includes("403")) { 100 | userFriendlyMessage = "🔒 Access denied. This tool requires 'profile:read_all' permission. Please re-authorize with the correct scope."; 101 | } else if (errorMessage.startsWith("SUBSCRIPTION_REQUIRED:")) { // In case Strava changes this later 102 | userFriendlyMessage = `🔒 Accessing zones might require a Strava subscription. Details: ${errorMessage}`; 103 | } else { 104 | userFriendlyMessage = `An unexpected error occurred while fetching athlete zones. Details: ${errorMessage}`; 105 | } 106 | 107 | return { 108 | content: [{ type: "text" as const, text: `❌ ${userFriendlyMessage}` }], 109 | isError: true 110 | }; 111 | } 112 | } 113 | }; ``` -------------------------------------------------------------------------------- /src/tools/getActivityLaps.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from "zod"; 2 | import { getActivityLaps as getActivityLapsClient } from "../stravaClient.js"; 3 | import { formatDuration } from "../server.js"; // Import helper 4 | 5 | const name = "get-activity-laps"; 6 | 7 | const description = ` 8 | Retrieves detailed lap data for a specific Strava activity. 9 | 10 | Use Cases: 11 | - Get complete lap data including timestamps, speeds, and metrics 12 | - Access raw values for detailed analysis or visualization 13 | - Extract specific lap metrics for comparison or tracking 14 | 15 | Parameters: 16 | - id (required): The unique identifier of the Strava activity. 17 | 18 | Output Format: 19 | Returns both a human-readable summary and complete JSON data for each lap, including: 20 | 1. A text summary with formatted metrics 21 | 2. Raw lap data containing all fields from the Strava API: 22 | - Unique lap ID and indices 23 | - Timestamps (start_date, start_date_local) 24 | - Distance and timing metrics 25 | - Speed metrics (average and max) 26 | - Performance metrics (heart rate, cadence, power if available) 27 | - Elevation data 28 | - Resource state information 29 | - Activity and athlete references 30 | 31 | Notes: 32 | - Requires activity:read scope for public/followers activities, activity:read_all for private activities 33 | - Returns complete data as received from Strava API without omissions 34 | - All numeric values are preserved in their original precision 35 | `; 36 | 37 | const inputSchema = z.object({ 38 | id: z.union([z.number(), z.string()]).describe("The identifier of the activity to fetch laps for."), 39 | }); 40 | 41 | type GetActivityLapsInput = z.infer<typeof inputSchema>; 42 | 43 | export const getActivityLapsTool = { 44 | name, 45 | description, 46 | inputSchema, 47 | execute: async ({ id }: GetActivityLapsInput) => { 48 | const token = process.env.STRAVA_ACCESS_TOKEN; 49 | 50 | if (!token) { 51 | console.error("Missing STRAVA_ACCESS_TOKEN environment variable."); 52 | return { 53 | content: [{ type: "text" as const, text: "Configuration error: Missing Strava access token." }], 54 | isError: true 55 | }; 56 | } 57 | 58 | try { 59 | console.error(`Fetching laps for activity ID: ${id}...`); 60 | const laps = await getActivityLapsClient(token, id); 61 | 62 | if (!laps || laps.length === 0) { 63 | return { 64 | content: [{ type: "text" as const, text: `✅ No laps found for activity ID: ${id}` }] 65 | }; 66 | } 67 | 68 | // Generate human-readable summary 69 | const lapSummaries = laps.map(lap => { 70 | const details = [ 71 | `Lap ${lap.lap_index}: ${lap.name || 'Unnamed Lap'}`, 72 | ` Time: ${formatDuration(lap.elapsed_time)} (Moving: ${formatDuration(lap.moving_time)})`, 73 | ` Distance: ${(lap.distance / 1000).toFixed(2)} km`, 74 | ` Avg Speed: ${lap.average_speed ? (lap.average_speed * 3.6).toFixed(2) + ' km/h' : 'N/A'}`, 75 | ` Max Speed: ${lap.max_speed ? (lap.max_speed * 3.6).toFixed(2) + ' km/h' : 'N/A'}`, 76 | lap.total_elevation_gain ? ` Elevation Gain: ${lap.total_elevation_gain.toFixed(1)} m` : null, 77 | lap.average_heartrate ? ` Avg HR: ${lap.average_heartrate.toFixed(1)} bpm` : null, 78 | lap.max_heartrate ? ` Max HR: ${lap.max_heartrate} bpm` : null, 79 | lap.average_cadence ? ` Avg Cadence: ${lap.average_cadence.toFixed(1)} rpm` : null, 80 | lap.average_watts ? ` Avg Power: ${lap.average_watts.toFixed(1)} W ${lap.device_watts ? '(Sensor)' : ''}` : null, 81 | ]; 82 | return details.filter(d => d !== null).join('\n'); 83 | }); 84 | 85 | const summaryText = `Activity Laps Summary (ID: ${id}):\n\n${lapSummaries.join('\n\n')}`; 86 | 87 | // Add raw data section 88 | const rawDataText = `\n\nComplete Lap Data:\n${JSON.stringify(laps, null, 2)}`; 89 | 90 | console.error(`Successfully fetched ${laps.length} laps for activity ${id}`); 91 | 92 | return { 93 | content: [ 94 | { type: "text" as const, text: summaryText }, 95 | { type: "text" as const, text: rawDataText } 96 | ] 97 | }; 98 | } catch (error) { 99 | const errorMessage = error instanceof Error ? error.message : String(error); 100 | console.error(`Error fetching laps for activity ${id}: ${errorMessage}`); 101 | const userFriendlyMessage = errorMessage.includes("Record Not Found") || errorMessage.includes("404") 102 | ? `Activity with ID ${id} not found.` 103 | : `An unexpected error occurred while fetching laps for activity ${id}. Details: ${errorMessage}`; 104 | return { 105 | content: [{ type: "text" as const, text: `❌ ${userFriendlyMessage}` }], 106 | isError: true 107 | }; 108 | } 109 | } 110 | }; ``` -------------------------------------------------------------------------------- /src/tools/exploreSegments.ts: -------------------------------------------------------------------------------- ```typescript 1 | // import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; // Removed 2 | import { z } from "zod"; 3 | import { 4 | getAuthenticatedAthlete, 5 | exploreSegments as fetchExploreSegments, // Renamed import 6 | StravaExplorerResponse 7 | } from "../stravaClient.js"; 8 | 9 | const ExploreSegmentsInputSchema = z.object({ 10 | bounds: z.string() 11 | .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") 12 | .describe("The geographical area to search, specified as a comma-separated string: south_west_lat,south_west_lng,north_east_lat,north_east_lng"), 13 | activityType: z.enum(["running", "riding"]) 14 | .optional() 15 | .describe("Filter segments by activity type (optional: 'running' or 'riding')."), 16 | minCat: z.number().int().min(0).max(5).optional() 17 | .describe("Filter by minimum climb category (optional, 0-5). Requires riding activityType."), 18 | maxCat: z.number().int().min(0).max(5).optional() 19 | .describe("Filter by maximum climb category (optional, 0-5). Requires riding activityType."), 20 | }); 21 | 22 | type ExploreSegmentsInput = z.infer<typeof ExploreSegmentsInputSchema>; 23 | 24 | // Export the tool definition directly 25 | export const exploreSegments = { 26 | name: "explore-segments", 27 | description: "Searches for popular segments within a given geographical area.", 28 | inputSchema: ExploreSegmentsInputSchema, 29 | execute: async ({ bounds, activityType, minCat, maxCat }: ExploreSegmentsInput) => { 30 | const token = process.env.STRAVA_ACCESS_TOKEN; 31 | 32 | if (!token || token === 'YOUR_STRAVA_ACCESS_TOKEN_HERE') { 33 | console.error("Missing or placeholder STRAVA_ACCESS_TOKEN in .env"); 34 | return { 35 | content: [{ type: "text" as const, text: "❌ Configuration Error: STRAVA_ACCESS_TOKEN is missing or not set in the .env file." }], 36 | isError: true, 37 | }; 38 | } 39 | if ((minCat !== undefined || maxCat !== undefined) && activityType !== 'riding') { 40 | return { 41 | content: [{ type: "text" as const, text: "❌ Input Error: Climb category filters (minCat, maxCat) require activityType to be 'riding'." }], 42 | isError: true, 43 | }; 44 | } 45 | 46 | try { 47 | console.error(`Exploring segments within bounds: ${bounds}...`); 48 | const athlete = await getAuthenticatedAthlete(token); 49 | const response: StravaExplorerResponse = await fetchExploreSegments(token, bounds, activityType, minCat, maxCat); 50 | console.error(`Found ${response.segments?.length ?? 0} segments.`); 51 | 52 | if (!response.segments || response.segments.length === 0) { 53 | return { content: [{ type: "text" as const, text: " MNo segments found in the specified area with the given filters." }] }; 54 | } 55 | 56 | const distanceFactor = athlete.measurement_preference === 'feet' ? 0.000621371 : 0.001; 57 | const distanceUnit = athlete.measurement_preference === 'feet' ? 'mi' : 'km'; 58 | const elevationFactor = athlete.measurement_preference === 'feet' ? 3.28084 : 1; 59 | const elevationUnit = athlete.measurement_preference === 'feet' ? 'ft' : 'm'; 60 | 61 | const segmentItems = response.segments.map(segment => { 62 | const distance = (segment.distance * distanceFactor).toFixed(2); 63 | const elevDifference = (segment.elev_difference * elevationFactor).toFixed(0); 64 | const text = ` 65 | 🗺️ **${segment.name}** (ID: ${segment.id}) 66 | - Climb: Cat ${segment.climb_category_desc} (${segment.climb_category}) 67 | - Distance: ${distance} ${distanceUnit} 68 | - Avg Grade: ${segment.avg_grade}% 69 | - Elev Difference: ${elevDifference} ${elevationUnit} 70 | - Starred: ${segment.starred ? 'Yes' : 'No'} 71 | `.trim(); 72 | const item: { type: "text", text: string } = { type: "text" as const, text }; 73 | return item; 74 | }); 75 | 76 | const responseText = `**Found Segments:**\n\n${segmentItems.map(item => item.text).join("\n---\n")}`; 77 | 78 | return { content: [{ type: "text" as const, text: responseText }] }; 79 | } catch (error) { 80 | const errorMessage = error instanceof Error ? error.message : "An unknown error occurred"; 81 | console.error("Error in explore-segments tool:", errorMessage); 82 | return { 83 | content: [{ type: "text" as const, text: `❌ API Error: ${errorMessage}` }], 84 | isError: true, 85 | }; 86 | } 87 | } 88 | }; 89 | 90 | // Remove the old registration function 91 | /* 92 | export function registerExploreSegmentsTool(server: McpServer) { 93 | server.tool( 94 | exploreSegments.name, 95 | exploreSegments.description, 96 | exploreSegments.inputSchema.shape, 97 | exploreSegments.execute 98 | ); 99 | } 100 | */ ``` -------------------------------------------------------------------------------- /src/tools/getSegmentEffort.ts: -------------------------------------------------------------------------------- ```typescript 1 | // import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; // Removed 2 | import { z } from "zod"; 3 | import { 4 | StravaDetailedSegmentEffort, 5 | getSegmentEffort as fetchSegmentEffort, 6 | } from "../stravaClient.js"; 7 | // import { formatDuration } from "../server.js"; // Removed, now local 8 | 9 | const GetSegmentEffortInputSchema = z.object({ 10 | effortId: z.number().int().positive().describe("The unique identifier of the segment effort to fetch.") 11 | }); 12 | 13 | type GetSegmentEffortInput = z.infer<typeof GetSegmentEffortInputSchema>; 14 | 15 | // Helper Functions (Metric Only) 16 | function formatDuration(seconds: number | null | undefined): string { 17 | if (seconds === null || seconds === undefined || isNaN(seconds) || seconds < 0) return 'N/A'; 18 | const hours = Math.floor(seconds / 3600); 19 | const minutes = Math.floor((seconds % 3600) / 60); 20 | const secs = Math.floor(seconds % 60); 21 | const parts: string[] = []; 22 | if (hours > 0) parts.push(hours.toString().padStart(2, '0')); 23 | parts.push(minutes.toString().padStart(2, '0')); 24 | parts.push(secs.toString().padStart(2, '0')); 25 | return parts.join(':'); 26 | } 27 | 28 | function formatDistance(meters: number | null | undefined): string { 29 | if (meters === null || meters === undefined) return 'N/A'; 30 | return (meters / 1000).toFixed(2) + ' km'; 31 | } 32 | 33 | // Format segment effort details (Metric Only) 34 | function formatSegmentEffort(effort: StravaDetailedSegmentEffort): string { 35 | const movingTime = formatDuration(effort.moving_time); 36 | const elapsedTime = formatDuration(effort.elapsed_time); 37 | const distance = formatDistance(effort.distance); 38 | // Remove speed/pace calculations as fields are not available on effort object 39 | // const avgSpeed = formatSpeed(effort.average_speed); 40 | // const maxSpeed = formatSpeed(effort.max_speed); 41 | // const avgPace = formatPace(effort.average_speed); 42 | 43 | let details = `⏱️ **Segment Effort: ${effort.name}** (ID: ${effort.id})\n`; 44 | details += ` - Activity ID: ${effort.activity.id}, Athlete ID: ${effort.athlete.id}\n`; 45 | details += ` - Segment ID: ${effort.segment.id}\n`; 46 | details += ` - Date: ${new Date(effort.start_date_local).toLocaleString()}\n`; 47 | details += ` - Moving Time: ${movingTime}, Elapsed Time: ${elapsedTime}\n`; 48 | if (effort.distance !== undefined) details += ` - Distance: ${distance}\n`; 49 | // Remove speed/pace display lines 50 | // if (effort.average_speed !== undefined) { ... } 51 | // if (effort.max_speed !== undefined) { ... } 52 | if (effort.average_cadence !== undefined && effort.average_cadence !== null) details += ` - Avg Cadence: ${effort.average_cadence.toFixed(1)}\n`; 53 | if (effort.average_watts !== undefined && effort.average_watts !== null) details += ` - Avg Watts: ${effort.average_watts.toFixed(1)}\n`; 54 | if (effort.average_heartrate !== undefined && effort.average_heartrate !== null) details += ` - Avg Heart Rate: ${effort.average_heartrate.toFixed(1)} bpm\n`; 55 | if (effort.max_heartrate !== undefined && effort.max_heartrate !== null) details += ` - Max Heart Rate: ${effort.max_heartrate.toFixed(0)} bpm\n`; 56 | if (effort.kom_rank !== null) details += ` - KOM Rank: ${effort.kom_rank}\n`; 57 | if (effort.pr_rank !== null) details += ` - PR Rank: ${effort.pr_rank}\n`; 58 | details += ` - Hidden: ${effort.hidden ? 'Yes' : 'No'}\n`; 59 | 60 | return details; 61 | } 62 | 63 | // Tool definition 64 | export const getSegmentEffortTool = { 65 | name: "get-segment-effort", 66 | description: "Fetches detailed information about a specific segment effort using its ID.", 67 | inputSchema: GetSegmentEffortInputSchema, 68 | execute: async ({ effortId }: GetSegmentEffortInput) => { 69 | const token = process.env.STRAVA_ACCESS_TOKEN; 70 | 71 | if (!token) { 72 | console.error("Missing STRAVA_ACCESS_TOKEN environment variable."); 73 | return { 74 | content: [{ type: "text" as const, text: "Configuration error: Missing Strava access token." }], 75 | isError: true 76 | }; 77 | } 78 | 79 | try { 80 | console.error(`Fetching details for segment effort ID: ${effortId}...`); 81 | // Removed getAuthenticatedAthlete call 82 | const effort = await fetchSegmentEffort(token, effortId); 83 | const effortDetailsText = formatSegmentEffort(effort); // Use metric formatter 84 | 85 | console.error(`Successfully fetched details for effort: ${effort.name}`); 86 | return { content: [{ type: "text" as const, text: effortDetailsText }] }; 87 | } catch (error) { 88 | const errorMessage = error instanceof Error ? error.message : String(error); 89 | console.error(`Error fetching segment effort ${effortId}: ${errorMessage}`); 90 | 91 | let userFriendlyMessage; 92 | if (errorMessage.startsWith("SUBSCRIPTION_REQUIRED:")) { 93 | userFriendlyMessage = `🔒 Accessing this segment effort (ID: ${effortId}) requires a Strava subscription. Please check your subscription status.`; 94 | } else if (errorMessage.includes("Record Not Found") || errorMessage.includes("404")) { 95 | userFriendlyMessage = `Segment effort with ID ${effortId} not found.`; 96 | } else { 97 | userFriendlyMessage = `An unexpected error occurred while fetching segment effort ${effortId}. Details: ${errorMessage}`; 98 | } 99 | 100 | return { 101 | content: [{ type: "text" as const, text: `❌ ${userFriendlyMessage}` }], 102 | isError: true 103 | }; 104 | } 105 | } 106 | }; 107 | 108 | // Removed old registration function 109 | /* 110 | export function registerGetSegmentEffortTool(server: McpServer) { 111 | server.tool( 112 | getSegmentEffort.name, 113 | getSegmentEffort.description, 114 | getSegmentEffort.inputSchema.shape, 115 | getSegmentEffort.execute 116 | ); 117 | } 118 | */ ``` -------------------------------------------------------------------------------- /src/tools/listSegmentEfforts.ts: -------------------------------------------------------------------------------- ```typescript 1 | // import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; // Removed 2 | import { z } from "zod"; 3 | import { 4 | listSegmentEfforts as fetchSegmentEfforts, 5 | // handleApiError, // Removed unused import 6 | StravaDetailedSegmentEffort // Type needed for formatter 7 | } from "../stravaClient.js"; 8 | // We need the formatter, but can't import the full tool. Let's copy it here for now. 9 | // TODO: Move formatters to a shared utils.ts file 10 | 11 | // Zod schema for input validation 12 | const ListSegmentEffortsInputSchema = z.object({ 13 | segmentId: z.number().int().positive().describe("The ID of the segment for which to list efforts."), 14 | startDateLocal: z.string().datetime({ message: "Invalid start date format. Use ISO 8601." }).optional().describe("Filter efforts starting after this ISO 8601 date-time (optional)."), 15 | endDateLocal: z.string().datetime({ message: "Invalid end date format. Use ISO 8601." }).optional().describe("Filter efforts ending before this ISO 8601 date-time (optional)."), 16 | perPage: z.number().int().positive().max(200).optional().default(30).describe("Number of efforts to return per page (default: 30, max: 200).") 17 | }); 18 | 19 | type ListSegmentEffortsInput = z.infer<typeof ListSegmentEffortsInputSchema>; 20 | 21 | // Helper Functions (Metric Only) - Copied locally 22 | function formatDuration(seconds: number | null | undefined): string { 23 | if (seconds === null || seconds === undefined || isNaN(seconds) || seconds < 0) return 'N/A'; 24 | const hours = Math.floor(seconds / 3600); 25 | const minutes = Math.floor((seconds % 3600) / 60); 26 | const secs = Math.floor(seconds % 60); 27 | const parts: string[] = []; 28 | if (hours > 0) parts.push(hours.toString().padStart(2, '0')); 29 | parts.push(minutes.toString().padStart(2, '0')); 30 | parts.push(secs.toString().padStart(2, '0')); 31 | return parts.join(':'); 32 | } 33 | 34 | function formatDistance(meters: number | null | undefined): string { 35 | if (meters === null || meters === undefined) return 'N/A'; 36 | return (meters / 1000).toFixed(2) + ' km'; 37 | } 38 | 39 | // Format segment effort summary (Metric Only) 40 | function formatSegmentEffort(effort: StravaDetailedSegmentEffort): string { 41 | const movingTime = formatDuration(effort.moving_time); 42 | const elapsedTime = formatDuration(effort.elapsed_time); 43 | const distance = formatDistance(effort.distance); 44 | 45 | // Basic summary: Effort ID, Date, Moving Time, Distance, PR Rank 46 | let summary = `⏱️ Effort ID: ${effort.id} (${new Date(effort.start_date_local).toLocaleDateString()})`; 47 | summary += ` | Time: ${movingTime} (Moving), ${elapsedTime} (Elapsed)`; 48 | summary += ` | Dist: ${distance}`; 49 | if (effort.pr_rank !== null) summary += ` | PR Rank: ${effort.pr_rank}`; 50 | if (effort.kom_rank !== null) summary += ` | KOM Rank: ${effort.kom_rank}`; // Add KOM if available 51 | return summary; 52 | } 53 | 54 | // Tool definition 55 | export const listSegmentEffortsTool = { 56 | name: "list-segment-efforts", 57 | description: "Lists the authenticated athlete's efforts on a specific segment, optionally filtering by date.", 58 | inputSchema: ListSegmentEffortsInputSchema, 59 | execute: async ({ segmentId, startDateLocal, endDateLocal, perPage }: ListSegmentEffortsInput) => { 60 | const token = process.env.STRAVA_ACCESS_TOKEN; 61 | 62 | if (!token) { 63 | console.error("Missing STRAVA_ACCESS_TOKEN environment variable."); 64 | return { 65 | content: [{ type: "text" as const, text: "Configuration error: Missing Strava access token." }], 66 | isError: true 67 | }; 68 | } 69 | 70 | try { 71 | console.error(`Fetching segment efforts for segment ID: ${segmentId}...`); 72 | 73 | // Use the new params object structure 74 | const efforts = await fetchSegmentEfforts(token, segmentId, { 75 | startDateLocal, 76 | endDateLocal, 77 | perPage 78 | }); 79 | 80 | if (!efforts || efforts.length === 0) { 81 | console.error(`No efforts found for segment ${segmentId} with the given filters.`); 82 | return { content: [{ type: "text" as const, text: `No efforts found for segment ${segmentId} matching the criteria.` }] }; 83 | } 84 | 85 | console.error(`Successfully fetched ${efforts.length} efforts for segment ${segmentId}.`); 86 | const effortSummaries = efforts.map(effort => formatSegmentEffort(effort)); // Use metric formatter 87 | const responseText = `**Segment ${segmentId} Efforts:**\n\n${effortSummaries.join("\n")}`; 88 | 89 | return { content: [{ type: "text" as const, text: responseText }] }; 90 | } catch (error) { 91 | const errorMessage = error instanceof Error ? error.message : String(error); 92 | console.error(`Error listing efforts for segment ${segmentId}: ${errorMessage}`); 93 | 94 | let userFriendlyMessage; 95 | if (errorMessage.startsWith("SUBSCRIPTION_REQUIRED:")) { 96 | userFriendlyMessage = `🔒 Accessing segment efforts requires a Strava subscription. Please check your subscription status.`; 97 | } else if (errorMessage.includes("Record Not Found") || errorMessage.includes("404")) { 98 | userFriendlyMessage = `Segment with ID ${segmentId} not found (when listing efforts).`; 99 | } else { 100 | userFriendlyMessage = `An unexpected error occurred while listing efforts for segment ${segmentId}. Details: ${errorMessage}`; 101 | } 102 | 103 | return { 104 | content: [{ type: "text" as const, text: `❌ ${userFriendlyMessage}` }], 105 | isError: true 106 | }; 107 | } 108 | } 109 | }; 110 | 111 | // Removed old registration function 112 | /* 113 | export function registerListSegmentEffortsTool(server: McpServer) { 114 | server.tool( 115 | listSegmentEfforts.name, 116 | listSegmentEfforts.description, 117 | listSegmentEfforts.inputSchema.shape, 118 | listSegmentEfforts.execute 119 | ); 120 | } 121 | */ ``` -------------------------------------------------------------------------------- /src/tools/formatWorkoutFile.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from "zod"; 2 | 3 | // Define types for workout segments 4 | interface WorkoutSegment { 5 | type: string; 6 | duration: { 7 | value: number; 8 | unit: 'min' | 'sec'; 9 | }; 10 | target: string; 11 | cadence?: number; 12 | notes?: string; 13 | } 14 | 15 | // Helper to convert various intensity targets to Zwift power zones 16 | function targetToZwiftPower(target: string): number { 17 | // Convert various formats to percentage of FTP 18 | const targetLower = target.toLowerCase(); 19 | 20 | // Handle direct FTP percentages 21 | const ftpMatch = targetLower.match(/(\d+)%\s*ftp/); 22 | if (ftpMatch?.[1]) { 23 | return parseInt(ftpMatch[1]) / 100; 24 | } 25 | 26 | // Handle common zone descriptions 27 | const zoneMap: { [key: string]: number } = { 28 | 'very easy': 0.5, // 50% FTP 29 | 'easy': 0.6, // 60% FTP 30 | 'zone 1': 0.6, // 60% FTP 31 | 'zone 2': 0.75, // 75% FTP 32 | 'moderate': 0.75, // 75% FTP 33 | 'tempo': 0.85, // 85% FTP 34 | 'zone 3': 0.85, // 85% FTP 35 | 'threshold': 1.0, // 100% FTP 36 | 'zone 4': 1.0, // 100% FTP 37 | 'hard': 1.05, // 105% FTP 38 | 'zone 5': 1.1, // 110% FTP 39 | 'very hard': 1.15, // 115% FTP 40 | 'max': 1.2, // 120% FTP 41 | }; 42 | 43 | // Try to match known descriptions 44 | for (const [desc, power] of Object.entries(zoneMap)) { 45 | if (targetLower.includes(desc)) { 46 | return power; 47 | } 48 | } 49 | 50 | // Default to moderate intensity if we can't determine 51 | return 0.75; 52 | } 53 | 54 | // Parse a duration string into seconds 55 | function parseDuration(duration: string): { value: number; unit: 'min' | 'sec' } { 56 | const match = duration.match(/(\d+)\s*(min|sec)/i); 57 | if (!match?.[1] || !match?.[2]) { 58 | throw new Error(`Invalid duration format: ${duration}`); 59 | } 60 | 61 | const value = parseInt(match[1]); 62 | const unit = match[2].toLowerCase() as 'min' | 'sec'; 63 | 64 | return { value, unit }; 65 | } 66 | 67 | // Parse workout text into structured segments 68 | function parseWorkoutText(text: string): WorkoutSegment[] { 69 | const segments: WorkoutSegment[] = []; 70 | const lines = text.split('\n'); 71 | 72 | for (const line of lines) { 73 | if (!line.trim().startsWith('-')) continue; 74 | 75 | // Extract the main parts using regex 76 | const segmentMatch = line.match(/^-\s*([^:]+):\s*(\d+\s*(?:min|sec))\s*at\s*([^[\n]+)(?:\s*\[([^\]]+)\])?/i); 77 | if (!segmentMatch?.[1] || !segmentMatch?.[2] || !segmentMatch?.[3]) continue; 78 | 79 | const [, type, duration, target, extras] = segmentMatch; 80 | 81 | const segment: WorkoutSegment = { 82 | type: type.trim(), 83 | duration: parseDuration(duration.trim()), 84 | target: target.trim() 85 | }; 86 | 87 | // Parse optional extras (cadence and notes) 88 | if (extras) { 89 | const cadenceMatch = extras.match(/Cadence:\s*(\d+)/i); 90 | if (cadenceMatch?.[1]) { 91 | segment.cadence = parseInt(cadenceMatch[1]); 92 | } 93 | 94 | const notesMatch = extras.match(/Notes:\s*([^\]]+)/i); 95 | if (notesMatch?.[1]) { 96 | segment.notes = notesMatch[1].trim(); 97 | } 98 | } 99 | 100 | segments.push(segment); 101 | } 102 | 103 | return segments; 104 | } 105 | 106 | // Generate ZWO XML content 107 | function generateZwoContent(segments: WorkoutSegment[]): string { 108 | const workoutSegments = segments.map(segment => { 109 | const durationSeconds = segment.duration.unit === 'min' 110 | ? segment.duration.value * 60 111 | : segment.duration.value; 112 | 113 | const power = targetToZwiftPower(segment.target); 114 | 115 | const cadenceAttr = segment.cadence ? ` Cadence="${segment.cadence}"` : ''; 116 | const showsTarget = segment.target.toLowerCase().includes('ftp') ? ' ShowsPower="1"' : ''; 117 | 118 | return ` <SteadyState Duration="${durationSeconds}" Power="${power}"${cadenceAttr}${showsTarget}${segment.notes ? ` textEvent="${segment.notes}"` : ''}/>` 119 | }).join('\n'); 120 | 121 | return `<workout_file> 122 | <author>Strava MCP Server</author> 123 | <name>Generated Workout</name> 124 | <description>Workout generated based on recent activities</description> 125 | <sportType>bike</sportType> 126 | <tags></tags> 127 | <workout> 128 | ${workoutSegments} 129 | </workout> 130 | </workout_file>`; 131 | } 132 | 133 | // Tool definition 134 | export const formatWorkoutFile = { 135 | name: "format-workout-file", 136 | description: "Formats a workout plan into a structured file format (currently supports Zwift .zwo)", 137 | inputSchema: z.object({ 138 | workoutText: z.string().describe("The workout plan text in the specified format"), 139 | format: z.enum(['zwo']).default('zwo').describe("Output format (currently only 'zwo' is supported)") 140 | }), 141 | execute: async ({ workoutText, format }: { workoutText: string; format: 'zwo' }) => { 142 | try { 143 | // Parse the workout text into structured segments 144 | const segments = parseWorkoutText(workoutText); 145 | 146 | if (segments.length === 0) { 147 | return { 148 | content: [{ 149 | type: "text", 150 | text: "❌ No valid workout segments found in the input text. Please ensure the format matches the expected pattern." 151 | }], 152 | isError: true 153 | }; 154 | } 155 | 156 | // Generate the appropriate format 157 | if (format === 'zwo') { 158 | const zwoContent = generateZwoContent(segments); 159 | return { 160 | content: [{ 161 | type: "text", 162 | text: zwoContent, 163 | mimeType: "application/xml" // Help clients understand this is XML content 164 | }] 165 | }; 166 | } 167 | 168 | // Should never reach here due to zod validation 169 | throw new Error(`Unsupported format: ${format}`); 170 | 171 | } catch (error) { 172 | return { 173 | content: [{ 174 | type: "text", 175 | text: `❌ Failed to format workout: ${(error as Error).message}` 176 | }], 177 | isError: true 178 | }; 179 | } 180 | } 181 | }; ``` -------------------------------------------------------------------------------- /src/tools/getActivityDetails.ts: -------------------------------------------------------------------------------- ```typescript 1 | // import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; // Removed 2 | import { z } from "zod"; 3 | import { 4 | getActivityById as fetchActivityById, 5 | StravaDetailedActivity // Type needed for formatter 6 | } from "../stravaClient.js"; 7 | // import { formatDuration } from "../server.js"; // Removed, now local 8 | 9 | // Zod schema for input validation 10 | const GetActivityDetailsInputSchema = z.object({ 11 | activityId: z.number().int().positive().describe("The unique identifier of the activity to fetch details for.") 12 | }); 13 | 14 | type GetActivityDetailsInput = z.infer<typeof GetActivityDetailsInputSchema>; 15 | 16 | // Helper Functions (Metric Only) 17 | function formatDuration(seconds: number | null | undefined): string { 18 | if (seconds === null || seconds === undefined || isNaN(seconds) || seconds < 0) return 'N/A'; 19 | const hours = Math.floor(seconds / 3600); 20 | const minutes = Math.floor((seconds % 3600) / 60); 21 | const secs = Math.floor(seconds % 60); 22 | const parts: string[] = []; 23 | if (hours > 0) parts.push(hours.toString().padStart(2, '0')); 24 | parts.push(minutes.toString().padStart(2, '0')); 25 | parts.push(secs.toString().padStart(2, '0')); 26 | return parts.join(':'); 27 | } 28 | 29 | function formatDistance(meters: number | null | undefined): string { 30 | if (meters === null || meters === undefined) return 'N/A'; 31 | return (meters / 1000).toFixed(2) + ' km'; 32 | } 33 | 34 | function formatElevation(meters: number | null | undefined): string { 35 | if (meters === null || meters === undefined) return 'N/A'; 36 | return Math.round(meters) + ' m'; 37 | } 38 | 39 | function formatSpeed(mps: number | null | undefined): string { 40 | if (mps === null || mps === undefined) return 'N/A'; 41 | return (mps * 3.6).toFixed(1) + ' km/h'; // Convert m/s to km/h 42 | } 43 | 44 | function formatPace(mps: number | null | undefined): string { 45 | if (mps === null || mps === undefined || mps <= 0) return 'N/A'; 46 | const minutesPerKm = 1000 / (mps * 60); 47 | const minutes = Math.floor(minutesPerKm); 48 | const seconds = Math.round((minutesPerKm - minutes) * 60); 49 | return `${minutes}:${seconds.toString().padStart(2, '0')} /km`; 50 | } 51 | 52 | // Format activity details (Metric Only) 53 | function formatActivityDetails(activity: StravaDetailedActivity): string { 54 | const date = new Date(activity.start_date_local).toLocaleString(); 55 | const movingTime = formatDuration(activity.moving_time); 56 | const elapsedTime = formatDuration(activity.elapsed_time); 57 | const distance = formatDistance(activity.distance); 58 | const elevation = formatElevation(activity.total_elevation_gain); 59 | const avgSpeed = formatSpeed(activity.average_speed); 60 | const maxSpeed = formatSpeed(activity.max_speed); 61 | const avgPace = formatPace(activity.average_speed); // Calculate pace from speed 62 | 63 | let details = `🏃 **${activity.name}** (ID: ${activity.id})\n`; 64 | details += ` - Type: ${activity.type} (${activity.sport_type})\n`; 65 | details += ` - Date: ${date}\n`; 66 | details += ` - Moving Time: ${movingTime}, Elapsed Time: ${elapsedTime}\n`; 67 | if (activity.distance !== undefined) details += ` - Distance: ${distance}\n`; 68 | if (activity.total_elevation_gain !== undefined) details += ` - Elevation Gain: ${elevation}\n`; 69 | if (activity.average_speed !== undefined) { 70 | details += ` - Average Speed: ${avgSpeed}`; 71 | if (activity.type === 'Run') details += ` (Pace: ${avgPace})`; 72 | details += '\n'; 73 | } 74 | if (activity.max_speed !== undefined) details += ` - Max Speed: ${maxSpeed}\n`; 75 | if (activity.average_cadence !== undefined && activity.average_cadence !== null) details += ` - Avg Cadence: ${activity.average_cadence.toFixed(1)}\n`; 76 | if (activity.average_watts !== undefined && activity.average_watts !== null) details += ` - Avg Watts: ${activity.average_watts.toFixed(1)}\n`; 77 | if (activity.average_heartrate !== undefined && activity.average_heartrate !== null) details += ` - Avg Heart Rate: ${activity.average_heartrate.toFixed(1)} bpm\n`; 78 | if (activity.max_heartrate !== undefined && activity.max_heartrate !== null) details += ` - Max Heart Rate: ${activity.max_heartrate.toFixed(0)} bpm\n`; 79 | if (activity.calories !== undefined) details += ` - Calories: ${activity.calories.toFixed(0)}\n`; 80 | if (activity.description) details += ` - Description: ${activity.description}\n`; 81 | if (activity.gear) details += ` - Gear: ${activity.gear.name}\n`; 82 | 83 | return details; 84 | } 85 | 86 | // Tool definition 87 | export const getActivityDetailsTool = { 88 | name: "get-activity-details", 89 | description: "Fetches detailed information about a specific activity using its ID.", 90 | inputSchema: GetActivityDetailsInputSchema, 91 | execute: async ({ activityId }: GetActivityDetailsInput) => { 92 | const token = process.env.STRAVA_ACCESS_TOKEN; 93 | 94 | if (!token) { 95 | console.error("Missing STRAVA_ACCESS_TOKEN environment variable."); 96 | return { 97 | content: [{ type: "text" as const, text: "Configuration error: Missing Strava access token." }], 98 | isError: true 99 | }; 100 | } 101 | 102 | try { 103 | console.error(`Fetching details for activity ID: ${activityId}...`); 104 | // Removed getAuthenticatedAthlete call 105 | const activity = await fetchActivityById(token, activityId); 106 | const activityDetailsText = formatActivityDetails(activity); // Use metric formatter 107 | 108 | console.error(`Successfully fetched details for activity: ${activity.name}`); 109 | return { content: [{ type: "text" as const, text: activityDetailsText }] }; 110 | } catch (error) { 111 | const errorMessage = error instanceof Error ? error.message : String(error); 112 | console.error(`Error fetching activity ${activityId}: ${errorMessage}`); 113 | // Removed call to handleApiError 114 | const userFriendlyMessage = errorMessage.includes("Record Not Found") || errorMessage.includes("404") 115 | ? `Activity with ID ${activityId} not found.` 116 | : `An unexpected error occurred while fetching activity details for ID ${activityId}. Details: ${errorMessage}`; 117 | return { 118 | content: [{ type: "text" as const, text: `❌ ${userFriendlyMessage}` }], 119 | isError: true 120 | }; 121 | } 122 | } 123 | }; 124 | 125 | // Removed old registration function 126 | /* 127 | export function registerGetActivityDetailsTool(server: McpServer) { 128 | server.tool( 129 | getActivityDetails.name, 130 | getActivityDetails.description, 131 | getActivityDetails.inputSchema.shape, 132 | getActivityDetails.execute 133 | ); 134 | } 135 | */ ``` -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 2 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 3 | import * as dotenv from "dotenv"; 4 | import path from "path"; 5 | import { fileURLToPath } from "url"; 6 | 7 | // Import all tool definitions with the correct names 8 | import { getAthleteProfile } from './tools/getAthleteProfile.js'; 9 | import { getAthleteStatsTool } from "./tools/getAthleteStats.js"; 10 | import { getActivityDetailsTool } from "./tools/getActivityDetails.js"; 11 | import { getRecentActivities } from "./tools/getRecentActivities.js"; 12 | import { listAthleteClubs } from './tools/listAthleteClubs.js'; 13 | import { listStarredSegments } from './tools/listStarredSegments.js'; 14 | import { getSegmentTool } from "./tools/getSegment.js"; 15 | import { exploreSegments } from './tools/exploreSegments.js'; 16 | import { starSegment } from './tools/starSegment.js'; 17 | import { getSegmentEffortTool } from './tools/getSegmentEffort.js'; 18 | import { listSegmentEffortsTool } from './tools/listSegmentEfforts.js'; 19 | import { listAthleteRoutesTool } from './tools/listAthleteRoutes.js'; 20 | import { getRouteTool } from './tools/getRoute.js'; 21 | import { exportRouteGpx } from './tools/exportRouteGpx.js'; 22 | import { exportRouteTcx } from './tools/exportRouteTcx.js'; 23 | import { getActivityStreamsTool } from './tools/getActivityStreams.js'; 24 | import { getActivityLapsTool } from './tools/getActivityLaps.js'; 25 | import { getAthleteZonesTool } from './tools/getAthleteZones.js'; 26 | import { getAllActivities } from './tools/getAllActivities.js'; 27 | 28 | // Import the actual client function 29 | // import { 30 | // // exportRouteGpx as exportRouteGpxClient, // Removed unused alias 31 | // // exportRouteTcx as exportRouteTcxClient, // Removed unused alias 32 | // getActivityLaps as getActivityLapsClient 33 | // } from './stravaClient.js'; 34 | 35 | // Load .env file explicitly from project root 36 | const __filename = fileURLToPath(import.meta.url); 37 | const __dirname = path.dirname(__filename); 38 | const projectRoot = path.resolve(__dirname, '..'); 39 | const envPath = path.join(projectRoot, '.env'); 40 | // REMOVE THIS DEBUG LOG - Interferes with MCP Stdio transport 41 | // console.log(`[DEBUG] Attempting to load .env file from: ${envPath}`); 42 | dotenv.config({ path: envPath }); 43 | 44 | const server = new McpServer({ 45 | name: "Strava MCP Server", 46 | version: "1.0.0" 47 | }); 48 | 49 | // Register all tools using server.tool and the correct imported objects 50 | server.tool( 51 | getAthleteProfile.name, 52 | getAthleteProfile.description, 53 | {}, 54 | getAthleteProfile.execute 55 | ); 56 | server.tool( 57 | getAthleteStatsTool.name, 58 | getAthleteStatsTool.description, 59 | getAthleteStatsTool.inputSchema?.shape ?? {}, 60 | getAthleteStatsTool.execute 61 | ); 62 | server.tool( 63 | getActivityDetailsTool.name, 64 | getActivityDetailsTool.description, 65 | getActivityDetailsTool.inputSchema?.shape ?? {}, 66 | getActivityDetailsTool.execute 67 | ); 68 | server.tool( 69 | getRecentActivities.name, 70 | getRecentActivities.description, 71 | getRecentActivities.inputSchema?.shape ?? {}, 72 | getRecentActivities.execute 73 | ); 74 | server.tool( 75 | listAthleteClubs.name, 76 | listAthleteClubs.description, 77 | {}, 78 | listAthleteClubs.execute 79 | ); 80 | server.tool( 81 | listStarredSegments.name, 82 | listStarredSegments.description, 83 | {}, 84 | listStarredSegments.execute 85 | ); 86 | server.tool( 87 | getSegmentTool.name, 88 | getSegmentTool.description, 89 | getSegmentTool.inputSchema?.shape ?? {}, 90 | getSegmentTool.execute 91 | ); 92 | server.tool( 93 | exploreSegments.name, 94 | exploreSegments.description, 95 | exploreSegments.inputSchema?.shape ?? {}, 96 | exploreSegments.execute 97 | ); 98 | server.tool( 99 | starSegment.name, 100 | starSegment.description, 101 | starSegment.inputSchema?.shape ?? {}, 102 | starSegment.execute 103 | ); 104 | server.tool( 105 | getSegmentEffortTool.name, 106 | getSegmentEffortTool.description, 107 | getSegmentEffortTool.inputSchema?.shape ?? {}, 108 | getSegmentEffortTool.execute 109 | ); 110 | server.tool( 111 | listSegmentEffortsTool.name, 112 | listSegmentEffortsTool.description, 113 | listSegmentEffortsTool.inputSchema?.shape ?? {}, 114 | listSegmentEffortsTool.execute 115 | ); 116 | server.tool( 117 | listAthleteRoutesTool.name, 118 | listAthleteRoutesTool.description, 119 | listAthleteRoutesTool.inputSchema?.shape ?? {}, 120 | listAthleteRoutesTool.execute 121 | ); 122 | server.tool( 123 | getRouteTool.name, 124 | getRouteTool.description, 125 | getRouteTool.inputSchema?.shape ?? {}, 126 | getRouteTool.execute 127 | ); 128 | server.tool( 129 | exportRouteGpx.name, 130 | exportRouteGpx.description, 131 | exportRouteGpx.inputSchema?.shape ?? {}, 132 | exportRouteGpx.execute 133 | ); 134 | server.tool( 135 | exportRouteTcx.name, 136 | exportRouteTcx.description, 137 | exportRouteTcx.inputSchema?.shape ?? {}, 138 | exportRouteTcx.execute 139 | ); 140 | server.tool( 141 | getActivityStreamsTool.name, 142 | getActivityStreamsTool.description, 143 | getActivityStreamsTool.inputSchema?.shape ?? {}, 144 | getActivityStreamsTool.execute 145 | ); 146 | 147 | // --- Register get-activity-laps tool (Simplified) --- 148 | server.tool( 149 | getActivityLapsTool.name, 150 | getActivityLapsTool.description, 151 | getActivityLapsTool.inputSchema?.shape ?? {}, 152 | getActivityLapsTool.execute 153 | ); 154 | 155 | // --- Register get-athlete-zones tool --- 156 | server.tool( 157 | getAthleteZonesTool.name, 158 | getAthleteZonesTool.description, 159 | getAthleteZonesTool.inputSchema?.shape ?? {}, 160 | getAthleteZonesTool.execute 161 | ); 162 | 163 | // --- Register get-all-activities tool --- 164 | server.tool( 165 | getAllActivities.name, 166 | getAllActivities.description, 167 | getAllActivities.inputSchema?.shape ?? {}, 168 | getAllActivities.execute 169 | ); 170 | 171 | // --- Helper Functions --- 172 | // Moving formatDuration to utils or keeping it here if broadly used. 173 | // For now, it's imported by getActivityLaps.ts 174 | export function formatDuration(seconds: number): string { 175 | if (isNaN(seconds) || seconds < 0) { 176 | return 'N/A'; 177 | } 178 | const hours = Math.floor(seconds / 3600); 179 | const minutes = Math.floor((seconds % 3600) / 60); 180 | const secs = Math.floor(seconds % 60); 181 | 182 | const parts: string[] = []; 183 | if (hours > 0) { 184 | parts.push(hours.toString().padStart(2, '0')); 185 | } 186 | parts.push(minutes.toString().padStart(2, '0')); 187 | parts.push(secs.toString().padStart(2, '0')); 188 | 189 | return parts.join(':'); 190 | } 191 | 192 | // Removed other formatters - they are now local to their respective tools. 193 | 194 | // --- Server Startup --- 195 | async function startServer() { 196 | try { 197 | console.error("Starting Strava MCP Server..."); 198 | const transport = new StdioServerTransport(); 199 | await server.connect(transport); 200 | console.error(`Strava MCP Server connected via Stdio. Tools registered.`); 201 | } catch (error) { 202 | console.error("Failed to start server:", error); 203 | process.exit(1); 204 | } 205 | } 206 | 207 | startServer(); ``` -------------------------------------------------------------------------------- /scripts/setup-auth.ts: -------------------------------------------------------------------------------- ```typescript 1 | import axios from 'axios'; 2 | import * as dotenv from 'dotenv'; 3 | import * as readline from 'readline/promises'; 4 | import * as fs from 'fs/promises'; 5 | import * as path from 'path'; 6 | import { fileURLToPath } from 'url'; 7 | 8 | // Define required scopes for all current and planned tools 9 | // Explicitly request profile and activity read access. 10 | const REQUIRED_SCOPES = 'profile:read_all,activity:read_all,activity:read,profile:write'; 11 | const REDIRECT_URI = 'http://localhost'; // Must match one configured in Strava App settings 12 | 13 | const __filename = fileURLToPath(import.meta.url); 14 | const __dirname = path.dirname(__filename); 15 | const projectRoot = path.resolve(__dirname, '..'); 16 | const envPath = path.join(projectRoot, '.env'); 17 | 18 | const rl = readline.createInterface({ 19 | input: process.stdin, 20 | output: process.stdout, 21 | }); 22 | 23 | async function promptUser(question: string): Promise<string> { 24 | const answer = await rl.question(question); 25 | return answer.trim(); 26 | } 27 | 28 | async function loadEnv(): Promise<{ clientId?: string; clientSecret?: string }> { 29 | try { 30 | await fs.access(envPath); // Check if .env exists 31 | const envConfig = dotenv.parse(await fs.readFile(envPath)); 32 | return { 33 | clientId: envConfig.STRAVA_CLIENT_ID, 34 | clientSecret: envConfig.STRAVA_CLIENT_SECRET, 35 | }; 36 | } catch (error) { 37 | console.log('.env file not found or not readable. Will prompt for all values.'); 38 | return {}; 39 | } 40 | } 41 | 42 | async function updateEnvFile(tokens: { accessToken: string; refreshToken: string }): Promise<void> { 43 | let envContent = ''; 44 | try { 45 | envContent = await fs.readFile(envPath, 'utf-8'); 46 | } catch (error) { 47 | console.log('.env file not found, creating a new one.'); 48 | } 49 | 50 | const lines = envContent.split('\n'); 51 | const newLines: string[] = []; 52 | let accessTokenUpdated = false; 53 | let refreshTokenUpdated = false; 54 | 55 | for (const line of lines) { 56 | if (line.startsWith('STRAVA_ACCESS_TOKEN=')) { 57 | newLines.push(`STRAVA_ACCESS_TOKEN=${tokens.accessToken}`); 58 | accessTokenUpdated = true; 59 | } else if (line.startsWith('STRAVA_REFRESH_TOKEN=')) { 60 | newLines.push(`STRAVA_REFRESH_TOKEN=${tokens.refreshToken}`); 61 | refreshTokenUpdated = true; 62 | } else if (line.trim() !== '') { 63 | newLines.push(line); 64 | } 65 | } 66 | 67 | if (!accessTokenUpdated) { 68 | newLines.push(`STRAVA_ACCESS_TOKEN=${tokens.accessToken}`); 69 | } 70 | if (!refreshTokenUpdated) { 71 | newLines.push(`STRAVA_REFRESH_TOKEN=${tokens.refreshToken}`); 72 | } 73 | 74 | await fs.writeFile(envPath, newLines.join('\n').trim() + '\n'); 75 | console.log('✅ Tokens successfully saved to .env file.'); 76 | } 77 | 78 | 79 | async function main() { 80 | console.log('--- Strava API Token Setup ---'); 81 | 82 | const existingEnv = await loadEnv(); 83 | let clientId = existingEnv.clientId; 84 | let clientSecret = existingEnv.clientSecret; 85 | 86 | if (!clientId) { 87 | clientId = await promptUser('Enter your Strava Application Client ID: '); 88 | if (!clientId) { 89 | console.error('❌ Client ID is required.'); 90 | process.exit(1); 91 | } 92 | } else { 93 | console.log(`ℹ️ Using Client ID from .env: ${clientId}`); 94 | } 95 | 96 | if (!clientSecret) { 97 | clientSecret = await promptUser('Enter your Strava Application Client Secret: '); 98 | if (!clientSecret) { 99 | console.error('❌ Client Secret is required.'); 100 | process.exit(1); 101 | } 102 | } else { 103 | console.log(`ℹ️ Using Client Secret from .env.`); 104 | } 105 | 106 | 107 | const authUrl = `https://www.strava.com/oauth/authorize?client_id=${clientId}&response_type=code&redirect_uri=${REDIRECT_URI}&approval_prompt=force&scope=${REQUIRED_SCOPES}`; 108 | 109 | console.log('\nStep 1: Authorize Application'); 110 | console.log('Please visit the following URL in your browser:'); 111 | console.log(`\n${authUrl}\n`); 112 | console.log(`After authorizing, Strava will redirect you to ${REDIRECT_URI}.`); 113 | console.log('Copy the \'code\' value from the URL in your browser\'s address bar.'); 114 | console.log('(e.g., http://localhost/?state=&code=THIS_PART&scope=...)'); 115 | 116 | const authCode = await promptUser('\nPaste the authorization code here: '); 117 | 118 | if (!authCode) { 119 | console.error('❌ Authorization code is required.'); 120 | process.exit(1); 121 | } 122 | 123 | console.log('\nStep 2: Exchanging code for tokens...'); 124 | 125 | try { 126 | const response = await axios.post('https://www.strava.com/oauth/token', { 127 | client_id: clientId, 128 | client_secret: clientSecret, 129 | code: authCode, 130 | grant_type: 'authorization_code', 131 | }); 132 | 133 | const { access_token, refresh_token, expires_at } = response.data; 134 | 135 | if (!access_token || !refresh_token) { 136 | throw new Error('Failed to retrieve tokens from Strava.'); 137 | } 138 | 139 | console.log('\n✅ Successfully obtained tokens!'); 140 | console.log(`Access Token: ${access_token}`); 141 | console.log(`Refresh Token: ${refresh_token}`); 142 | console.log(`Access Token Expires At: ${new Date(expires_at * 1000).toLocaleString()}`); 143 | 144 | 145 | const save = await promptUser('\nDo you want to save these tokens to your .env file? (yes/no): '); 146 | 147 | if (save.toLowerCase() === 'yes' || save.toLowerCase() === 'y') { 148 | await updateEnvFile({ accessToken: access_token, refreshToken: refresh_token }); 149 | // Optionally save client_id and client_secret if they weren't in .env initially 150 | let envContent = ''; 151 | try { 152 | envContent = await fs.readFile(envPath, 'utf-8'); 153 | } catch (readError) { /* Ignore if file doesn't exist, it was created in updateEnvFile */ } 154 | 155 | let needsUpdate = false; 156 | if (!envContent.includes('STRAVA_CLIENT_ID=')) { 157 | envContent = `STRAVA_CLIENT_ID=${clientId}\n` + envContent; 158 | needsUpdate = true; 159 | } 160 | if (!envContent.includes('STRAVA_CLIENT_SECRET=')) { 161 | // Add secret before tokens if they exist 162 | const tokenLineIndex = envContent.indexOf('STRAVA_ACCESS_TOKEN='); 163 | if (tokenLineIndex !== -1) { 164 | envContent = envContent.substring(0, tokenLineIndex) + `STRAVA_CLIENT_SECRET=${clientSecret}\n` + envContent.substring(tokenLineIndex); 165 | } else { 166 | envContent = `STRAVA_CLIENT_SECRET=${clientSecret}\n` + envContent; // Add at the beginning if tokens aren't there 167 | } 168 | needsUpdate = true; 169 | } 170 | if (needsUpdate) { 171 | await fs.writeFile(envPath, envContent.trim() + '\n'); 172 | console.log('ℹ️ Client ID and Secret also saved/updated in .env.'); 173 | } 174 | 175 | } else { 176 | console.log('\nTokens not saved. Please store them securely yourself.'); 177 | } 178 | 179 | } catch (error: any) { 180 | console.error('\n❌ Error exchanging code for tokens:'); 181 | if (axios.isAxiosError(error) && error.response) { 182 | console.error(`Status: ${error.response.status}`); 183 | console.error(`Data: ${JSON.stringify(error.response.data)}`); 184 | } else { 185 | console.error(error.message || error); 186 | } 187 | process.exit(1); 188 | } finally { 189 | rl.close(); 190 | } 191 | } 192 | 193 | main(); ``` -------------------------------------------------------------------------------- /src/tools/getAthleteStats.ts: -------------------------------------------------------------------------------- ```typescript 1 | // import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; // Removed 2 | import { z } from "zod"; 3 | import { 4 | // getAuthenticatedAthlete as fetchAuthenticatedAthlete, // Removed 5 | getAthleteStats as fetchAthleteStats, 6 | // handleApiError, // Removed unused import 7 | StravaStats // Type needed for formatter 8 | } from "../stravaClient.js"; 9 | // formatDuration is now local or in utils, not imported from server.ts 10 | 11 | // Input schema: Now requires athleteId 12 | const GetAthleteStatsInputSchema = z.object({ 13 | 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.") 14 | }); 15 | 16 | // Define type alias for input 17 | type GetAthleteStatsInput = z.infer<typeof GetAthleteStatsInputSchema>; 18 | 19 | // Remove unused formatDuration function 20 | /* 21 | function formatDuration(seconds: number): string { 22 | if (isNaN(seconds) || seconds < 0) { 23 | return 'N/A'; 24 | } 25 | const hours = Math.floor(seconds / 3600); 26 | const minutes = Math.floor((seconds % 3600) / 60); 27 | const secs = Math.floor(seconds % 60); 28 | 29 | const parts: string[] = []; 30 | if (hours > 0) { 31 | parts.push(hours.toString().padStart(2, '0')); 32 | } 33 | parts.push(minutes.toString().padStart(2, '0')); 34 | parts.push(secs.toString().padStart(2, '0')); 35 | 36 | return parts.join(':'); 37 | } 38 | */ 39 | 40 | // Helper function to format numbers as strings with labels (metric) 41 | function formatStat(value: number | null | undefined, unit: 'km' | 'm' | 'hrs'): string { 42 | if (value === null || value === undefined) return 'N/A'; 43 | 44 | let formattedValue: string; 45 | if (unit === 'km') { 46 | formattedValue = (value / 1000).toFixed(2); 47 | } else if (unit === 'm') { 48 | formattedValue = Math.round(value).toString(); 49 | } else if (unit === 'hrs') { 50 | formattedValue = (value / 3600).toFixed(1); 51 | } else { 52 | formattedValue = value.toString(); 53 | } 54 | return `${formattedValue} ${unit}`; 55 | } 56 | 57 | // Format athlete stats (metric only) 58 | function formatStats(stats: StravaStats): string { 59 | const format = (label: string, total: number | null | undefined, unit: 'km' | 'm' | 'hrs', count?: number | null, time?: number | null) => { 60 | let line = ` - ${label}: ${formatStat(total, unit)}`; 61 | if (count !== undefined && count !== null) line += ` (${count} activities)`; 62 | if (time !== undefined && time !== null) line += ` / ${formatStat(time, 'hrs')} hours`; 63 | return line; 64 | }; 65 | 66 | let response = "📊 **Your Strava Stats:**\n"; 67 | 68 | if (stats.biggest_ride_distance !== undefined) { 69 | response += "**Rides:**\n"; 70 | response += format("Biggest Ride", stats.biggest_ride_distance, 'km') + '\n'; 71 | } 72 | if (stats.recent_ride_totals) { 73 | response += "*Recent Rides (last 4 weeks):*\n"; 74 | response += format("Distance", stats.recent_ride_totals.distance, 'km', stats.recent_ride_totals.count, stats.recent_ride_totals.moving_time) + '\n'; 75 | response += format("Elevation Gain", stats.recent_ride_totals.elevation_gain, 'm') + '\n'; 76 | } 77 | if (stats.ytd_ride_totals) { 78 | response += "*Year-to-Date Rides:*\n"; 79 | response += format("Distance", stats.ytd_ride_totals.distance, 'km', stats.ytd_ride_totals.count, stats.ytd_ride_totals.moving_time) + '\n'; 80 | response += format("Elevation Gain", stats.ytd_ride_totals.elevation_gain, 'm') + '\n'; 81 | } 82 | if (stats.all_ride_totals) { 83 | response += "*All-Time Rides:*\n"; 84 | response += format("Distance", stats.all_ride_totals.distance, 'km', stats.all_ride_totals.count, stats.all_ride_totals.moving_time) + '\n'; 85 | response += format("Elevation Gain", stats.all_ride_totals.elevation_gain, 'm') + '\n'; 86 | } 87 | 88 | // Similar blocks for Runs and Swims if needed... 89 | if (stats.recent_run_totals || stats.ytd_run_totals || stats.all_run_totals) { 90 | response += "\n**Runs:**\n"; 91 | if (stats.recent_run_totals) { 92 | response += "*Recent Runs (last 4 weeks):*\n"; 93 | response += format("Distance", stats.recent_run_totals.distance, 'km', stats.recent_run_totals.count, stats.recent_run_totals.moving_time) + '\n'; 94 | response += format("Elevation Gain", stats.recent_run_totals.elevation_gain, 'm') + '\n'; 95 | } 96 | if (stats.ytd_run_totals) { 97 | response += "*Year-to-Date Runs:*\n"; 98 | response += format("Distance", stats.ytd_run_totals.distance, 'km', stats.ytd_run_totals.count, stats.ytd_run_totals.moving_time) + '\n'; 99 | response += format("Elevation Gain", stats.ytd_run_totals.elevation_gain, 'm') + '\n'; 100 | } 101 | if (stats.all_run_totals) { 102 | response += "*All-Time Runs:*\n"; 103 | response += format("Distance", stats.all_run_totals.distance, 'km', stats.all_run_totals.count, stats.all_run_totals.moving_time) + '\n'; 104 | response += format("Elevation Gain", stats.all_run_totals.elevation_gain, 'm') + '\n'; 105 | } 106 | } 107 | 108 | // Add Swims similarly if needed 109 | 110 | return response; 111 | } 112 | 113 | // Tool definition 114 | export const getAthleteStatsTool = { 115 | name: "get-athlete-stats", 116 | 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.", 117 | inputSchema: GetAthleteStatsInputSchema, 118 | execute: async ({ athleteId }: GetAthleteStatsInput) => { 119 | const token = process.env.STRAVA_ACCESS_TOKEN; 120 | 121 | if (!token) { 122 | console.error("Missing STRAVA_ACCESS_TOKEN environment variable."); 123 | return { 124 | content: [{ type: "text" as const, text: "Configuration error: Missing Strava access token." }], 125 | isError: true 126 | }; 127 | } 128 | 129 | try { 130 | console.error(`Fetching stats for athlete ${athleteId}...`); 131 | const stats = await fetchAthleteStats(token, athleteId); 132 | const formattedStats = formatStats(stats); 133 | 134 | console.error(`Successfully fetched stats for athlete ${athleteId}.`); 135 | return { content: [{ type: "text" as const, text: formattedStats }] }; 136 | } catch (error) { 137 | const errorMessage = error instanceof Error ? error.message : String(error); 138 | console.error(`Error fetching stats for athlete ${athleteId}: ${errorMessage}`); 139 | const userFriendlyMessage = errorMessage.includes("Record Not Found") || errorMessage.includes("404") 140 | ? `Athlete with ID ${athleteId} not found (when fetching stats).` 141 | : `An unexpected error occurred while fetching stats for athlete ${athleteId}. Details: ${errorMessage}`; 142 | return { 143 | content: [{ type: "text" as const, text: `❌ ${userFriendlyMessage}` }], 144 | isError: true 145 | }; 146 | } 147 | } 148 | }; 149 | 150 | // Removed old registration function 151 | /* 152 | export function registerGetAthleteStatsTool(server: McpServer) { 153 | server.tool( 154 | getAthleteStats.name, 155 | getAthleteStats.description, 156 | getAthleteStats.execute // No input schema 157 | ); 158 | } 159 | */ ``` -------------------------------------------------------------------------------- /src/tools/getAllActivities.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from "zod"; 2 | import { getAllActivities as fetchAllActivities } from "../stravaClient.js"; 3 | 4 | // Common activity types 5 | export const ACTIVITY_TYPES = { 6 | // Core types 7 | RIDE: "Ride", 8 | RUN: "Run", 9 | SWIM: "Swim", 10 | 11 | // Common types 12 | WALK: "Walk", 13 | HIKE: "Hike", 14 | VIRTUAL_RIDE: "VirtualRide", 15 | VIRTUAL_RUN: "VirtualRun", 16 | WORKOUT: "Workout", 17 | WEIGHT_TRAINING: "WeightTraining", 18 | YOGA: "Yoga", 19 | 20 | // Winter sports 21 | ALPINE_SKI: "AlpineSki", 22 | BACKCOUNTRY_SKI: "BackcountrySki", 23 | NORDIC_SKI: "NordicSki", 24 | SNOWBOARD: "Snowboard", 25 | ICE_SKATE: "IceSkate", 26 | 27 | // Water sports 28 | KAYAKING: "Kayaking", 29 | ROWING: "Rowing", 30 | STAND_UP_PADDLING: "StandUpPaddling", 31 | SURFING: "Surfing", 32 | 33 | // Other 34 | GOLF: "Golf", 35 | ROCK_CLIMBING: "RockClimbing", 36 | SOCCER: "Soccer", 37 | ELLIPTICAL: "Elliptical", 38 | STAIR_STEPPER: "StairStepper" 39 | } as const; 40 | 41 | // Common sport types (more granular) 42 | export const SPORT_TYPES = { 43 | MOUNTAIN_BIKE_RIDE: "MountainBikeRide", 44 | GRAVEL_RIDE: "GravelRide", 45 | E_BIKE_RIDE: "EBikeRide", 46 | TRAIL_RUN: "TrailRun", 47 | VIRTUAL_RIDE: "VirtualRide", 48 | VIRTUAL_RUN: "VirtualRun" 49 | } as const; 50 | 51 | const GetAllActivitiesInputSchema = z.object({ 52 | startDate: z.string().optional().describe("ISO date string for activities after this date (e.g., '2024-01-01')"), 53 | endDate: z.string().optional().describe("ISO date string for activities before this date (e.g., '2024-12-31')"), 54 | activityTypes: z.array(z.string()).optional().describe("Array of activity types to filter (e.g., ['Run', 'Ride'])"), 55 | sportTypes: z.array(z.string()).optional().describe("Array of sport types for granular filtering (e.g., ['MountainBikeRide', 'TrailRun'])"), 56 | maxActivities: z.number().int().positive().optional().default(500).describe("Maximum activities to return after filtering (default: 500)"), 57 | maxApiCalls: z.number().int().positive().optional().default(10).describe("Maximum API calls to prevent quota exhaustion (default: 10 = ~2000 activities)"), 58 | perPage: z.number().int().positive().min(1).max(200).optional().default(200).describe("Activities per API call (default: 200, max: 200)") 59 | }); 60 | 61 | type GetAllActivitiesInput = z.infer<typeof GetAllActivitiesInputSchema>; 62 | 63 | // Helper function to format activity summary 64 | function formatActivitySummary(activity: any): string { 65 | const date = activity.start_date ? new Date(activity.start_date).toLocaleDateString() : 'N/A'; 66 | const distance = activity.distance ? `${(activity.distance / 1000).toFixed(2)} km` : 'N/A'; 67 | const duration = activity.moving_time ? formatDuration(activity.moving_time) : 'N/A'; 68 | const type = activity.sport_type || activity.type || 'Unknown'; 69 | 70 | let emoji = '🏃'; 71 | if (type.toLowerCase().includes('ride') || type.toLowerCase().includes('bike')) emoji = '🚴'; 72 | else if (type.toLowerCase().includes('swim')) emoji = '🏊'; 73 | else if (type.toLowerCase().includes('ski')) emoji = '⛷️'; 74 | else if (type.toLowerCase().includes('hike') || type.toLowerCase().includes('walk')) emoji = '🥾'; 75 | else if (type.toLowerCase().includes('yoga')) emoji = '🧘'; 76 | else if (type.toLowerCase().includes('weight')) emoji = '💪'; 77 | 78 | return `${emoji} ${activity.name} (${type}) - ${distance} in ${duration} on ${date}`; 79 | } 80 | 81 | // Helper function to format duration 82 | function formatDuration(seconds: number): string { 83 | const hours = Math.floor(seconds / 3600); 84 | const minutes = Math.floor((seconds % 3600) / 60); 85 | const secs = seconds % 60; 86 | 87 | if (hours > 0) { 88 | return `${hours}h ${minutes}m`; 89 | } else if (minutes > 0) { 90 | return `${minutes}m ${secs}s`; 91 | } 92 | return `${secs}s`; 93 | } 94 | 95 | // Export the tool definition 96 | export const getAllActivities = { 97 | name: "get-all-activities", 98 | description: "Fetches complete activity history with optional filtering by date range and activity type. Supports pagination to retrieve all activities.", 99 | inputSchema: GetAllActivitiesInputSchema, 100 | execute: async (input: GetAllActivitiesInput) => { 101 | const token = process.env.STRAVA_ACCESS_TOKEN; 102 | 103 | if (!token || token === 'YOUR_STRAVA_ACCESS_TOKEN_HERE') { 104 | console.error("Missing or placeholder STRAVA_ACCESS_TOKEN in .env"); 105 | return { 106 | content: [{ type: "text" as const, text: "❌ Configuration Error: STRAVA_ACCESS_TOKEN is missing or not set in the .env file." }], 107 | isError: true, 108 | }; 109 | } 110 | 111 | const { 112 | startDate, 113 | endDate, 114 | activityTypes, 115 | sportTypes, 116 | maxActivities = 500, 117 | maxApiCalls = 10, 118 | perPage = 200 119 | } = input; 120 | 121 | try { 122 | // Convert dates to epoch timestamps if provided 123 | const before = endDate ? Math.floor(new Date(endDate).getTime() / 1000) : undefined; 124 | const after = startDate ? Math.floor(new Date(startDate).getTime() / 1000) : undefined; 125 | 126 | // Validate date inputs 127 | if (before && isNaN(before)) { 128 | return { 129 | content: [{ type: "text" as const, text: "❌ Invalid endDate format. Please use ISO date format (e.g., '2024-12-31')." }], 130 | isError: true 131 | }; 132 | } 133 | if (after && isNaN(after)) { 134 | return { 135 | content: [{ type: "text" as const, text: "❌ Invalid startDate format. Please use ISO date format (e.g., '2024-01-01')." }], 136 | isError: true 137 | }; 138 | } 139 | 140 | console.error(`Fetching activities with filters:`); 141 | console.error(` Date range: ${startDate || 'any'} to ${endDate || 'any'}`); 142 | console.error(` Activity types: ${activityTypes?.join(', ') || 'any'}`); 143 | console.error(` Sport types: ${sportTypes?.join(', ') || 'any'}`); 144 | console.error(` Max activities: ${maxActivities}, Max API calls: ${maxApiCalls}`); 145 | 146 | const allActivities: any[] = []; 147 | const filteredActivities: any[] = []; 148 | let apiCalls = 0; 149 | let currentPage = 1; 150 | let hasMore = true; 151 | 152 | // Progress callback 153 | const onProgress = (fetched: number, page: number) => { 154 | console.error(` Page ${page}: Fetched ${fetched} total activities...`); 155 | }; 156 | 157 | // Fetch activities page by page 158 | while (hasMore && apiCalls < maxApiCalls && filteredActivities.length < maxActivities) { 159 | apiCalls++; 160 | 161 | // Fetch a page of activities 162 | const pageActivities = await fetchAllActivities(token, { 163 | page: currentPage, 164 | perPage, 165 | before, 166 | after, 167 | onProgress 168 | }); 169 | 170 | // Check if we got any activities 171 | if (pageActivities.length === 0) { 172 | hasMore = false; 173 | break; 174 | } 175 | 176 | // Add to all activities 177 | allActivities.push(...pageActivities); 178 | 179 | // Apply filters if specified 180 | let toFilter = pageActivities; 181 | 182 | // Filter by activity type 183 | if (activityTypes && activityTypes.length > 0) { 184 | toFilter = toFilter.filter(a => 185 | activityTypes.some(type => 186 | a.type?.toLowerCase() === type.toLowerCase() 187 | ) 188 | ); 189 | } 190 | 191 | // Filter by sport type (more specific) 192 | if (sportTypes && sportTypes.length > 0) { 193 | toFilter = toFilter.filter(a => 194 | sportTypes.some(type => 195 | a.sport_type?.toLowerCase() === type.toLowerCase() 196 | ) 197 | ); 198 | } 199 | 200 | // Add filtered activities 201 | filteredActivities.push(...toFilter); 202 | 203 | // Check if we should continue 204 | hasMore = pageActivities.length === perPage; 205 | currentPage++; 206 | 207 | // Log progress 208 | console.error(` After page ${currentPage - 1}: ${allActivities.length} fetched, ${filteredActivities.length} match filters`); 209 | } 210 | 211 | // Limit results to maxActivities 212 | const resultsToReturn = filteredActivities.slice(0, maxActivities); 213 | 214 | // Prepare summary statistics 215 | const stats = { 216 | totalFetched: allActivities.length, 217 | totalMatching: filteredActivities.length, 218 | returned: resultsToReturn.length, 219 | apiCalls: apiCalls 220 | }; 221 | 222 | console.error(`\nFetch complete:`); 223 | console.error(` Total activities fetched: ${stats.totalFetched}`); 224 | console.error(` Activities matching filters: ${stats.totalMatching}`); 225 | console.error(` Activities returned: ${stats.returned}`); 226 | console.error(` API calls made: ${stats.apiCalls}`); 227 | 228 | if (resultsToReturn.length === 0) { 229 | return { 230 | content: [{ 231 | type: "text" as const, 232 | 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` 233 | }] 234 | }; 235 | } 236 | 237 | // Format activities for display 238 | const summaries = resultsToReturn.map(activity => formatActivitySummary(activity)); 239 | 240 | // Build response text 241 | let responseText = `**Found ${stats.returned} activities**\n\n`; 242 | responseText += `📊 Statistics:\n`; 243 | responseText += `- Total fetched: ${stats.totalFetched}\n`; 244 | responseText += `- Matching filters: ${stats.totalMatching}\n`; 245 | responseText += `- API calls: ${stats.apiCalls}\n\n`; 246 | 247 | if (stats.returned < stats.totalMatching) { 248 | responseText += `⚠️ Showing first ${stats.returned} of ${stats.totalMatching} matching activities (limited by maxActivities)\n\n`; 249 | } 250 | 251 | responseText += `**Activities:**\n${summaries.join('\n')}`; 252 | 253 | return { 254 | content: [{ type: "text" as const, text: responseText }] 255 | }; 256 | 257 | } catch (error) { 258 | const errorMessage = error instanceof Error ? error.message : "An unknown error occurred"; 259 | console.error("Error in get-all-activities tool:", errorMessage); 260 | 261 | // Check for rate limiting 262 | if (errorMessage.includes('429')) { 263 | return { 264 | content: [{ 265 | type: "text" as const, 266 | text: `⚠️ Rate limit reached. Please wait a few minutes before trying again.\n\nStrava API limits: 100 requests per 15 minutes, 1000 per day.` 267 | }], 268 | isError: true, 269 | }; 270 | } 271 | 272 | return { 273 | content: [{ type: "text" as const, text: `❌ API Error: ${errorMessage}` }], 274 | isError: true, 275 | }; 276 | } 277 | } 278 | }; ``` -------------------------------------------------------------------------------- /src/tools/getActivityStreams.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from 'zod'; 2 | import { stravaApi } from '../stravaClient.js'; 3 | 4 | // Define stream types available in Strava API 5 | const STREAM_TYPES = [ 6 | 'time', 'distance', 'latlng', 'altitude', 'velocity_smooth', 7 | 'heartrate', 'cadence', 'watts', 'temp', 'moving', 'grade_smooth' 8 | ] as const; 9 | 10 | // Define resolution types 11 | const RESOLUTION_TYPES = ['low', 'medium', 'high'] as const; 12 | 13 | // Input schema using Zod 14 | export const inputSchema = z.object({ 15 | id: z.number().or(z.string()).describe( 16 | 'The Strava activity identifier to fetch streams for. This can be obtained from activity URLs or the get-activities tool.' 17 | ), 18 | types: z.array(z.enum(STREAM_TYPES)) 19 | .default(['time', 'distance', 'heartrate', 'cadence', 'watts']) 20 | .describe( 21 | 'Array of stream types to fetch. Available types:\n' + 22 | '- time: Time in seconds from start\n' + 23 | '- distance: Distance in meters from start\n' + 24 | '- latlng: Array of [latitude, longitude] pairs\n' + 25 | '- altitude: Elevation in meters\n' + 26 | '- velocity_smooth: Smoothed speed in meters/second\n' + 27 | '- heartrate: Heart rate in beats per minute\n' + 28 | '- cadence: Cadence in revolutions per minute\n' + 29 | '- watts: Power output in watts\n' + 30 | '- temp: Temperature in Celsius\n' + 31 | '- moving: Boolean indicating if moving\n' + 32 | '- grade_smooth: Road grade as percentage' 33 | ), 34 | resolution: z.enum(RESOLUTION_TYPES).optional() 35 | .describe( 36 | 'Optional data resolution. Affects number of data points returned:\n' + 37 | '- low: ~100 points\n' + 38 | '- medium: ~1000 points\n' + 39 | '- high: ~10000 points\n' + 40 | 'Default varies based on activity length.' 41 | ), 42 | series_type: z.enum(['time', 'distance']).optional() 43 | .default('distance') 44 | .describe( 45 | 'Optional base series type for the streams:\n' + 46 | '- time: Data points are indexed by time (seconds from start)\n' + 47 | '- distance: Data points are indexed by distance (meters from start)\n' + 48 | 'Useful for comparing different activities or analyzing specific segments.' 49 | ), 50 | page: z.number().optional().default(1) 51 | .describe( 52 | 'Optional page number for paginated results. Use with points_per_page to retrieve specific data ranges.\n' + 53 | 'Example: page=2 with points_per_page=100 gets points 101-200.' 54 | ), 55 | points_per_page: z.number().optional().default(100) 56 | .describe( 57 | 'Optional number of data points per page. Special values:\n' + 58 | '- Positive number: Returns that many points per page\n' + 59 | '- -1: Returns ALL data points split into multiple messages (~1000 points each)\n' + 60 | 'Use -1 when you need the complete activity data for analysis.' 61 | ) 62 | }); 63 | 64 | // Type for the input parameters 65 | type GetActivityStreamsParams = z.infer<typeof inputSchema>; 66 | 67 | // Stream interfaces based on Strava API types 68 | interface BaseStream { 69 | type: string; 70 | data: any[]; 71 | series_type: 'distance' | 'time'; 72 | original_size: number; 73 | resolution: 'low' | 'medium' | 'high'; 74 | } 75 | 76 | interface TimeStream extends BaseStream { 77 | type: 'time'; 78 | data: number[]; // seconds 79 | } 80 | 81 | interface DistanceStream extends BaseStream { 82 | type: 'distance'; 83 | data: number[]; // meters 84 | } 85 | 86 | interface LatLngStream extends BaseStream { 87 | type: 'latlng'; 88 | data: [number, number][]; // [latitude, longitude] 89 | } 90 | 91 | interface AltitudeStream extends BaseStream { 92 | type: 'altitude'; 93 | data: number[]; // meters 94 | } 95 | 96 | interface VelocityStream extends BaseStream { 97 | type: 'velocity_smooth'; 98 | data: number[]; // meters per second 99 | } 100 | 101 | interface HeartrateStream extends BaseStream { 102 | type: 'heartrate'; 103 | data: number[]; // beats per minute 104 | } 105 | 106 | interface CadenceStream extends BaseStream { 107 | type: 'cadence'; 108 | data: number[]; // rpm 109 | } 110 | 111 | interface PowerStream extends BaseStream { 112 | type: 'watts'; 113 | data: number[]; // watts 114 | } 115 | 116 | interface TempStream extends BaseStream { 117 | type: 'temp'; 118 | data: number[]; // celsius 119 | } 120 | 121 | interface MovingStream extends BaseStream { 122 | type: 'moving'; 123 | data: boolean[]; 124 | } 125 | 126 | interface GradeStream extends BaseStream { 127 | type: 'grade_smooth'; 128 | data: number[]; // percent grade 129 | } 130 | 131 | type StreamSet = (TimeStream | DistanceStream | LatLngStream | AltitudeStream | 132 | VelocityStream | HeartrateStream | CadenceStream | PowerStream | 133 | TempStream | MovingStream | GradeStream)[]; 134 | 135 | // Tool definition 136 | export const getActivityStreamsTool = { 137 | name: 'get-activity-streams', 138 | description: 139 | 'Retrieves detailed time-series data streams from a Strava activity. Perfect for analyzing workout metrics, ' + 140 | 'visualizing routes, or performing detailed activity analysis.\n\n' + 141 | 142 | 'Key Features:\n' + 143 | '1. Multiple Data Types: Access various metrics like heart rate, power, speed, GPS coordinates, etc.\n' + 144 | '2. Flexible Resolution: Choose data density from low (~100 points) to high (~10000 points)\n' + 145 | '3. Smart Pagination: Get data in manageable chunks or all at once\n' + 146 | '4. Rich Statistics: Includes min/max/avg for numeric streams\n' + 147 | '5. Formatted Output: Data is processed into human and LLM-friendly formats\n\n' + 148 | 149 | 'Common Use Cases:\n' + 150 | '- Analyzing workout intensity through heart rate zones\n' + 151 | '- Calculating power metrics for cycling activities\n' + 152 | '- Visualizing route data using GPS coordinates\n' + 153 | '- Analyzing pace and elevation changes\n' + 154 | '- Detailed segment analysis\n\n' + 155 | 156 | 'Output Format:\n' + 157 | '1. Metadata: Activity overview, available streams, data points\n' + 158 | '2. Statistics: Summary stats for each stream type (max/min/avg where applicable)\n' + 159 | '3. Stream Data: Actual time-series data, formatted for easy use\n\n' + 160 | 161 | 'Notes:\n' + 162 | '- Requires activity:read scope\n' + 163 | '- Not all streams are available for all activities\n' + 164 | '- Older activities might have limited data\n' + 165 | '- Large activities are automatically paginated to handle size limits', 166 | inputSchema, 167 | execute: async ({ id, types, resolution, series_type, page = 1, points_per_page = 100 }: GetActivityStreamsParams) => { 168 | const token = process.env.STRAVA_ACCESS_TOKEN; 169 | if (!token) { 170 | return { 171 | content: [{ type: 'text' as const, text: '❌ Missing STRAVA_ACCESS_TOKEN in .env' }], 172 | isError: true 173 | }; 174 | } 175 | 176 | try { 177 | // Set the auth token for this request 178 | stravaApi.defaults.headers.common['Authorization'] = `Bearer ${token}`; 179 | 180 | // Build query parameters 181 | const params: Record<string, any> = {}; 182 | if (resolution) params.resolution = resolution; 183 | if (series_type) params.series_type = series_type; 184 | 185 | // Convert query params to string 186 | const queryString = new URLSearchParams(params).toString(); 187 | 188 | // Build the endpoint URL with types in the path 189 | const endpoint = `/activities/${id}/streams/${types.join(',')}${queryString ? '?' + queryString : ''}`; 190 | 191 | const response = await stravaApi.get<StreamSet>(endpoint); 192 | const streams = response.data; 193 | 194 | if (!streams || streams.length === 0) { 195 | return { 196 | content: [{ 197 | type: 'text' as const, 198 | text: '⚠️ No streams were returned. This could mean:\n' + 199 | '1. The activity was recorded without this data\n' + 200 | '2. The activity is not a GPS-based activity\n' + 201 | '3. The activity is too old (Strava may not keep all stream data indefinitely)' 202 | }], 203 | isError: true 204 | }; 205 | } 206 | 207 | // At this point we know streams[0] exists because we checked length > 0 208 | const referenceStream = streams[0]!; 209 | const totalPoints = referenceStream.data.length; 210 | 211 | // Generate stream statistics first (they're always included) 212 | const streamStats: Record<string, any> = {}; 213 | streams.forEach(stream => { 214 | const data = stream.data; 215 | let stats: any = { 216 | total_points: data.length, 217 | resolution: stream.resolution, 218 | series_type: stream.series_type 219 | }; 220 | 221 | // Add type-specific statistics 222 | switch (stream.type) { 223 | case 'heartrate': 224 | const hrData = data as number[]; 225 | stats = { 226 | ...stats, 227 | max: Math.max(...hrData), 228 | min: Math.min(...hrData), 229 | avg: Math.round(hrData.reduce((a, b) => a + b, 0) / hrData.length) 230 | }; 231 | break; 232 | case 'watts': 233 | const powerData = data as number[]; 234 | stats = { 235 | ...stats, 236 | max: Math.max(...powerData), 237 | avg: Math.round(powerData.reduce((a, b) => a + b, 0) / powerData.length), 238 | normalized_power: calculateNormalizedPower(powerData) 239 | }; 240 | break; 241 | case 'velocity_smooth': 242 | const velocityData = data as number[]; 243 | stats = { 244 | ...stats, 245 | max_kph: Math.round(Math.max(...velocityData) * 3.6 * 10) / 10, 246 | avg_kph: Math.round(velocityData.reduce((a, b) => a + b, 0) / velocityData.length * 3.6 * 10) / 10 247 | }; 248 | break; 249 | } 250 | 251 | streamStats[stream.type] = stats; 252 | }); 253 | 254 | // Special case: return all data in multiple messages if points_per_page is -1 255 | if (points_per_page === -1) { 256 | // Calculate optimal chunk size (aim for ~500KB per message) 257 | const CHUNK_SIZE = 1000; // Adjust this if needed 258 | const numChunks = Math.ceil(totalPoints / CHUNK_SIZE); 259 | 260 | // Return array of messages 261 | return { 262 | content: [ 263 | // First message with metadata 264 | { 265 | type: 'text' as const, 266 | text: `📊 Activity Stream Data (${totalPoints} points)\n` + 267 | `Will be sent in ${numChunks + 1} messages:\n` + 268 | `1. Metadata and Statistics\n` + 269 | `2-${numChunks + 1}. Stream Data (${CHUNK_SIZE} points per message)\n\n` + 270 | `Message 1/${numChunks + 1}:\n` + 271 | JSON.stringify({ 272 | metadata: { 273 | available_types: streams.map(s => s.type), 274 | total_points: totalPoints, 275 | total_chunks: numChunks, 276 | chunk_size: CHUNK_SIZE, 277 | resolution: referenceStream.resolution, 278 | series_type: referenceStream.series_type 279 | }, 280 | statistics: streamStats 281 | }, null, 2) 282 | }, 283 | // Data messages 284 | ...Array.from({ length: numChunks }, (_, i) => { 285 | const chunkStart = i * CHUNK_SIZE; 286 | const chunkEnd = Math.min(chunkStart + CHUNK_SIZE, totalPoints); 287 | const streamData: Record<string, any> = { streams: {} }; 288 | 289 | // Process each stream for this chunk 290 | streams.forEach(stream => { 291 | const chunkData = stream.data.slice(chunkStart, chunkEnd); 292 | let processedData: any; 293 | 294 | switch (stream.type) { 295 | case 'latlng': 296 | const latlngData = chunkData as [number, number][]; 297 | processedData = latlngData.map(([lat, lng]) => ({ 298 | latitude: Number(lat.toFixed(6)), 299 | longitude: Number(lng.toFixed(6)) 300 | })); 301 | break; 302 | 303 | case 'time': 304 | const timeData = chunkData as number[]; 305 | processedData = timeData.map(seconds => ({ 306 | seconds_from_start: seconds, 307 | formatted: new Date(seconds * 1000).toISOString().substr(11, 8) 308 | })); 309 | break; 310 | 311 | case 'distance': 312 | const distanceData = chunkData as number[]; 313 | processedData = distanceData.map(meters => ({ 314 | meters, 315 | kilometers: Number((meters / 1000).toFixed(2)) 316 | })); 317 | break; 318 | 319 | case 'velocity_smooth': 320 | const velocityData = chunkData as number[]; 321 | processedData = velocityData.map(mps => ({ 322 | meters_per_second: mps, 323 | kilometers_per_hour: Number((mps * 3.6).toFixed(1)) 324 | })); 325 | break; 326 | 327 | case 'heartrate': 328 | case 'cadence': 329 | case 'watts': 330 | case 'temp': 331 | const numericData = chunkData as number[]; 332 | processedData = numericData.map(v => Number(v)); 333 | break; 334 | 335 | case 'grade_smooth': 336 | const gradeData = chunkData as number[]; 337 | processedData = gradeData.map(grade => Number(grade.toFixed(1))); 338 | break; 339 | 340 | case 'moving': 341 | processedData = chunkData as boolean[]; 342 | break; 343 | 344 | default: 345 | processedData = chunkData; 346 | } 347 | 348 | streamData.streams[stream.type] = processedData; 349 | }); 350 | 351 | return { 352 | type: 'text' as const, 353 | text: `Message ${i + 2}/${numChunks + 1} (points ${chunkStart + 1}-${chunkEnd}):\n` + 354 | JSON.stringify(streamData, null, 2) 355 | }; 356 | }) 357 | ] 358 | }; 359 | } 360 | 361 | // Regular paginated response 362 | const totalPages = Math.ceil(totalPoints / points_per_page); 363 | 364 | // Validate page number 365 | if (page < 1 || page > totalPages) { 366 | return { 367 | content: [{ 368 | type: 'text' as const, 369 | text: `❌ Invalid page number. Please specify a page between 1 and ${totalPages}` 370 | }], 371 | isError: true 372 | }; 373 | } 374 | 375 | // Calculate slice indices for pagination 376 | const startIdx = (page - 1) * points_per_page; 377 | const endIdx = Math.min(startIdx + points_per_page, totalPoints); 378 | 379 | // Process paginated stream data 380 | const streamData: Record<string, any> = { 381 | metadata: { 382 | available_types: streams.map(s => s.type), 383 | total_points: totalPoints, 384 | current_page: page, 385 | total_pages: totalPages, 386 | points_per_page, 387 | points_in_page: endIdx - startIdx 388 | }, 389 | statistics: streamStats, 390 | streams: {} 391 | }; 392 | 393 | // Process each stream with pagination 394 | streams.forEach(stream => { 395 | let processedData: any; 396 | const paginatedData = stream.data.slice(startIdx, endIdx); 397 | 398 | switch (stream.type) { 399 | case 'latlng': 400 | const latlngData = paginatedData as [number, number][]; 401 | processedData = latlngData.map(([lat, lng]) => ({ 402 | latitude: Number(lat.toFixed(6)), 403 | longitude: Number(lng.toFixed(6)) 404 | })); 405 | break; 406 | 407 | case 'time': 408 | const timeData = paginatedData as number[]; 409 | processedData = timeData.map(seconds => ({ 410 | seconds_from_start: seconds, 411 | formatted: new Date(seconds * 1000).toISOString().substr(11, 8) 412 | })); 413 | break; 414 | 415 | case 'distance': 416 | const distanceData = paginatedData as number[]; 417 | processedData = distanceData.map(meters => ({ 418 | meters, 419 | kilometers: Number((meters / 1000).toFixed(2)) 420 | })); 421 | break; 422 | 423 | case 'velocity_smooth': 424 | const velocityData = paginatedData as number[]; 425 | processedData = velocityData.map(mps => ({ 426 | meters_per_second: mps, 427 | kilometers_per_hour: Number((mps * 3.6).toFixed(1)) 428 | })); 429 | break; 430 | 431 | case 'heartrate': 432 | case 'cadence': 433 | case 'watts': 434 | case 'temp': 435 | const numericData = paginatedData as number[]; 436 | processedData = numericData.map(v => Number(v)); 437 | break; 438 | 439 | case 'grade_smooth': 440 | const gradeData = paginatedData as number[]; 441 | processedData = gradeData.map(grade => Number(grade.toFixed(1))); 442 | break; 443 | 444 | case 'moving': 445 | processedData = paginatedData as boolean[]; 446 | break; 447 | 448 | default: 449 | processedData = paginatedData; 450 | } 451 | 452 | streamData.streams[stream.type] = processedData; 453 | }); 454 | 455 | return { 456 | content: [{ 457 | type: 'text' as const, 458 | text: JSON.stringify(streamData, null, 2) 459 | }] 460 | }; 461 | } catch (error: any) { 462 | const statusCode = error.response?.status; 463 | const errorMessage = error.response?.data?.message || error.message; 464 | 465 | let userFriendlyError = `❌ Failed to fetch activity streams (${statusCode}): ${errorMessage}\n\n`; 466 | userFriendlyError += 'This could be because:\n'; 467 | userFriendlyError += '1. The activity ID is invalid\n'; 468 | userFriendlyError += '2. You don\'t have permission to view this activity\n'; 469 | userFriendlyError += '3. The requested stream types are not available\n'; 470 | userFriendlyError += '4. The activity is too old and the streams have been archived'; 471 | 472 | return { 473 | content: [{ 474 | type: 'text' as const, 475 | text: userFriendlyError 476 | }], 477 | isError: true 478 | }; 479 | } 480 | } 481 | }; 482 | 483 | // Helper function to calculate normalized power 484 | function calculateNormalizedPower(powerData: number[]): number { 485 | if (powerData.length < 30) return 0; 486 | 487 | // 30-second moving average 488 | const windowSize = 30; 489 | const movingAvg = []; 490 | for (let i = windowSize - 1; i < powerData.length; i++) { 491 | const window = powerData.slice(i - windowSize + 1, i + 1); 492 | const avg = window.reduce((a, b) => a + b, 0) / windowSize; 493 | movingAvg.push(Math.pow(avg, 4)); 494 | } 495 | 496 | // Calculate normalized power 497 | const avgPower = Math.pow( 498 | movingAvg.reduce((a, b) => a + b, 0) / movingAvg.length, 499 | 0.25 500 | ); 501 | 502 | return Math.round(avgPower); 503 | } ```