#
tokens: 46185/50000 30/31 files (page 1/2)
lines: on (toggle) GitHub
raw markdown copy reset
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 | [![MseeP.ai Security Assessment Badge](https://mseep.net/pr/r-huijts-strava-mcp-badge.png)](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 | } 
```
Page 1/2FirstPrevNextLast