#
tokens: 9207/50000 9/9 files
lines: on (toggle) GitHub
raw markdown copy reset
# Directory Structure

```
├── .gitignore
├── config
│   └── .env.example
├── pyproject.toml
├── README.md
├── requirements.txt
├── run_server.sh
├── run_whoop_server.sh
└── src
    ├── whoop_http_server.py
    └── whoop_server.py
```

# Files

--------------------------------------------------------------------------------
/config/.env.example:
--------------------------------------------------------------------------------

```
1 | WHOOP_CLIENT_ID=your_client_id_here
2 | WHOOP_CLIENT_SECRET=your_client_secret_here
3 | WHOOP_REDIRECT_URI=http://localhost:8000/callback 
```

--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------

```
 1 | # Python
 2 | __pycache__/
 3 | *.py[cod]
 4 | *$py.class
 5 | *.so
 6 | .Python
 7 | build/
 8 | develop-eggs/
 9 | dist/
10 | downloads/
11 | eggs/
12 | .eggs/
13 | lib/
14 | lib64/
15 | parts/
16 | sdist/
17 | var/
18 | wheels/
19 | *.egg-info/
20 | .installed.cfg
21 | *.egg
22 | 
23 | # Environment files
24 | .env
25 | .env.*
26 | !.env.example
27 | config/.env
28 | config/.env.*
29 | !config/.env.example
30 | 
31 | # Virtual Environment
32 | venv/
33 | ENV/
34 | env/
35 | 
36 | # IDE specific files
37 | .idea/
38 | .vscode/
39 | *.swp
40 | *.swo
41 | 
42 | # OS specific files
43 | .DS_Store
44 | .DS_Store?
45 | ._*
46 | .Spotlight-V100
47 | .Trashes
48 | ehthumbs.db
49 | Thumbs.db
50 | 
51 | # Logs
52 | logs/
53 | *.log
54 | npm-debug.log*
55 | yarn-debug.log*
56 | yarn-error.log*
57 | 
58 | # Local configuration
59 | .local/ 
```

--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------

```markdown
 1 | # Whoop MCP Server
 2 | Python Package License: MIT Python 3.12
 3 | 
 4 | A Model Context Protocol (MCP) server that provides access to the Whoop API. It allows language models to query cycles, recovery, strain, and workout data from the Whoop API.
 5 | 
 6 | ## Available Tools
 7 | 
 8 | The server exposes the following tools:
 9 | 
10 | ### Cycle Queries
11 | - `get_cycle_collection(start_date: str, end_date: str)`: Get cycle data for a specific date range
12 | - `get_latest_cycle()`: Get the most recent cycle data
13 | 
14 | ### Recovery and Strain
15 | - `get_recovery_data(start_date: str, end_date: str)`: Get recovery data for a specific date range
16 | - `get_strain_data(start_date: str, end_date: str)`: Get strain data for a specific date range
17 | - `get_average_strain(days: int = 7)`: Calculate average strain over specified number of days
18 | 
19 | ### Profile and Authentication
20 | - `get_profile()`: Get user profile information
21 | - `check_auth_status()`: Check authentication status with Whoop API
22 | 
23 | Dates should be provided in ISO format (YYYY-MM-DD).
24 | 
25 | ## Usage
26 | 
27 | You'll need Whoop credentials to use this server. The server uses email/password authentication with the Whoop API.
28 | 
29 | ### Claude for Desktop
30 | 
31 | Update your `claude_desktop_config.json` (located in `~/Library/Application\ Support/Claude/claude_desktop_config.json` on macOS and `%APPDATA%/Claude/claude_desktop_config.json` on Windows) to include the following:
32 | 
33 | ```json
34 | {
35 |     "mcpServers": {
36 |         "Whoop": {
37 |             "command": "python",
38 |             "args": ["/path/to/whoop/src/whoop_server.py"],
39 |             "cwd": "/path/to/whoop",
40 |             "env": {
41 |                 "WHOOP_EMAIL": "[email protected]",
42 |                 "WHOOP_PASSWORD": "your_password"
43 |             }
44 |         }
45 |     }
46 | }
47 | ```
48 | 
49 | ### HTTP API Server
50 | 
51 | The project also includes an HTTP API server that exposes the same functionality over HTTP endpoints. To run it:
52 | 
53 | ```bash
54 | ./run_whoop_server.sh
55 | ```
56 | 
57 | ## Example Queries
58 | 
59 | Once connected, you can ask Claude questions like:
60 | 
61 | - "What's my recovery score for today?"
62 | - "Show me my strain data for the past week"
63 | - "What's my average strain over the last 7 days?"
64 | - "Get my latest cycle data"
65 | 
66 | ## Error Handling
67 | 
68 | The server provides human-readable error messages for common issues:
69 | - Invalid date formats
70 | - API authentication errors
71 | - Network connectivity problems
72 | - Missing or invalid credentials
73 | 
74 | ## Project Structure
75 | 
76 | ```
77 | whoop/
78 | ├── src/
79 | │   ├── whoop_server.py      # MCP server implementation
80 | │   └── whoop_http_server.py # HTTP API server implementation
81 | ├── config/
82 | │   └── .env                 # Environment variables
83 | ├── requirements.txt         # Python dependencies
84 | └── run_whoop_server.sh     # Script to run HTTP server
85 | ```
86 | 
87 | ## License
88 | 
89 | This project is licensed under the MIT License - see the LICENSE file for details. 
```

--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------

```
1 | git+https://github.com/modelcontextprotocol/python-sdk.git
2 | fastapi>=0.68.0
3 | uvicorn>=0.15.0
4 | requests>=2.26.0
5 | python-dotenv>=0.19.0
6 | pydantic>=1.8.2 
```

--------------------------------------------------------------------------------
/run_whoop_server.sh:
--------------------------------------------------------------------------------

```bash
 1 | #!/bin/bash
 2 | 
 3 | # Get the directory where the script is located
 4 | SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
 5 | 
 6 | # Change to the script directory
 7 | cd "$SCRIPT_DIR"
 8 | 
 9 | # Activate the virtual environment
10 | source "$SCRIPT_DIR/venv/bin/activate"
11 | echo "Activated virtual environment" >&2
12 | 
13 | # Run the Whoop server
14 | python src/whoop_http_server.py 
```

--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------

```toml
 1 | [project]
 2 | name = "whoop-mcp-server"
 3 | version = "0.1.0"
 4 | description = "MCP server for Whoop API integration"
 5 | requires-python = ">=3.8"
 6 | dependencies = [
 7 |     "whoop",
 8 |     "python-dotenv",
 9 |     "fastapi",
10 |     "uvicorn",
11 | ]
12 | 
13 | [build-system]
14 | requires = ["hatchling"]
15 | build-backend = "hatchling.build"
16 | 
17 | [project.scripts]
18 | whoop-mcp-server = "whoop_server:main"
19 | 
20 | [tool.hatch.build.targets.wheel]
21 | packages = ["src"] 
```

--------------------------------------------------------------------------------
/run_server.sh:
--------------------------------------------------------------------------------

```bash
 1 | #!/bin/bash
 2 | # Script to start the Whoop MCP server
 3 | 
 4 | # Get the absolute path of the script directory
 5 | SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
 6 | echo "Script directory: $SCRIPT_DIR" >&2
 7 | 
 8 | # Change to the project directory
 9 | cd "$SCRIPT_DIR"
10 | echo "Changed to directory: $(pwd)" >&2
11 | 
12 | # Activate the virtual environment
13 | source "$SCRIPT_DIR/venv/bin/activate"
14 | echo "Activated virtual environment" >&2
15 | 
16 | # Print debug information
17 | echo "Starting Whoop MCP server..." >&2
18 | echo "Current directory: $(pwd)" >&2
19 | echo "Python executable: $(which python)" >&2
20 | echo "Python version: $(python --version)" >&2
21 | echo "PATH: $PATH" >&2
22 | 
23 | # Kill any existing server processes
24 | pkill -f "python $SCRIPT_DIR/src/whoop_server.py" || true
25 | echo "Killed any existing server processes" >&2
26 | 
27 | # Start the server with absolute path
28 | echo "Starting server with command: python $SCRIPT_DIR/src/whoop_server.py" >&2
29 | exec python "$SCRIPT_DIR/src/whoop_server.py"
```

--------------------------------------------------------------------------------
/src/whoop_http_server.py:
--------------------------------------------------------------------------------

```python
  1 | #!/usr/bin/env python3
  2 | """
  3 | HTTP server for Whoop API integration.
  4 | This server exposes HTTP endpoints to query the Whoop API for cycles, recovery, and strain data.
  5 | """
  6 | 
  7 | import os
  8 | import sys
  9 | from pathlib import Path
 10 | from typing import Any, Dict, List, Optional
 11 | from datetime import datetime, timedelta
 12 | from dotenv import load_dotenv
 13 | import logging
 14 | from fastapi import FastAPI, HTTPException
 15 | import uvicorn
 16 | from whoop import WhoopClient
 17 | 
 18 | # Configure logging
 19 | logging.basicConfig(
 20 |     level=logging.DEBUG,
 21 |     format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
 22 |     stream=sys.stderr
 23 | )
 24 | logger = logging.getLogger(__name__)
 25 | 
 26 | # Create FastAPI app
 27 | app = FastAPI(title="Whoop API Server", description="HTTP server for Whoop API integration")
 28 | 
 29 | # Initialize Whoop client
 30 | whoop_client: Optional[WhoopClient] = None
 31 | 
 32 | def initialize_whoop_client() -> None:
 33 |     """Initialize the Whoop client using environment variables."""
 34 |     global whoop_client
 35 |     
 36 |     # Load environment variables
 37 |     env_path = Path(__file__).parent.parent / 'config' / '.env'
 38 |     logger.info(f"Looking for .env file at: {env_path}")
 39 |     
 40 |     if not env_path.exists():
 41 |         logger.error(f"Environment file not found at {env_path}")
 42 |         return
 43 |     
 44 |     load_dotenv(dotenv_path=env_path)
 45 |     logger.info("Environment variables loaded")
 46 |     
 47 |     # Get credentials
 48 |     email = os.getenv("WHOOP_EMAIL")
 49 |     password = os.getenv("WHOOP_PASSWORD")
 50 |     
 51 |     if not email or not password:
 52 |         logger.error("Missing Whoop credentials in environment variables")
 53 |         return
 54 |         
 55 |     try:
 56 |         whoop_client = WhoopClient(username=email, password=password)
 57 |         logger.info("Successfully authenticated with Whoop API")
 58 |     except Exception as e:
 59 |         logger.error(f"Authentication failed: {str(e)}")
 60 | 
 61 | @app.get("/auth/status")
 62 | def check_auth_status() -> Dict[str, Any]:
 63 |     """Check if we're authenticated with Whoop."""
 64 |     if not whoop_client:
 65 |         return {
 66 |             "authenticated": False,
 67 |             "message": "Not authenticated with Whoop"
 68 |         }
 69 |     
 70 |     try:
 71 |         # Test authentication by getting profile
 72 |         profile = whoop_client.get_profile()
 73 |         return {
 74 |             "authenticated": True,
 75 |             "message": "Successfully authenticated with Whoop",
 76 |             "profile": profile
 77 |         }
 78 |     except Exception as e:
 79 |         return {
 80 |             "authenticated": False,
 81 |             "message": f"Authentication error: {str(e)}"
 82 |         }
 83 | 
 84 | @app.get("/cycles/latest")
 85 | def get_latest_cycle() -> Dict[str, Any]:
 86 |     """Get the latest cycle data from Whoop."""
 87 |     if not whoop_client:
 88 |         raise HTTPException(status_code=401, detail="Not authenticated with Whoop")
 89 |     
 90 |     try:
 91 |         # Get today's date and yesterday's date
 92 |         end_date = datetime.now().strftime("%Y-%m-%d")
 93 |         start_date = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")
 94 |         
 95 |         # Get cycle collection for the last day
 96 |         cycles = whoop_client.get_cycle_collection(start_date, end_date)
 97 |         logger.debug(f"Received cycles response: {cycles}")
 98 |         if not cycles:
 99 |             raise HTTPException(status_code=404, detail="No cycle data available")
100 |         return cycles[0]  # Return the most recent cycle
101 |     except Exception as e:
102 |         logger.error(f"Error getting latest cycle: {str(e)}")
103 |         raise HTTPException(status_code=500, detail=str(e))
104 | 
105 | @app.get("/strain/average")
106 | def get_average_strain(days: int = 7) -> Dict[str, Any]:
107 |     """Calculate average strain over the specified number of days."""
108 |     if not whoop_client:
109 |         raise HTTPException(status_code=401, detail="Not authenticated with Whoop")
110 |     
111 |     try:
112 |         # Calculate date range
113 |         end_date = datetime.now().strftime("%Y-%m-%d")
114 |         start_date = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
115 |         
116 |         # Get cycle collection
117 |         cycles = whoop_client.get_cycle_collection(start_date, end_date)
118 |         if not cycles:
119 |             raise HTTPException(status_code=404, detail="No cycle data available")
120 |             
121 |         # Extract strain values
122 |         strains = []
123 |         for cycle in cycles:
124 |             if cycle.get('score') and cycle['score'].get('strain'):
125 |                 strains.append(cycle['score']['strain'])
126 |         
127 |         if not strains:
128 |             raise HTTPException(status_code=404, detail="No strain data available")
129 |             
130 |         return {
131 |             "average_strain": sum(strains) / len(strains),
132 |             "days_analyzed": days,
133 |             "samples": len(strains)
134 |         }
135 |     except Exception as e:
136 |         raise HTTPException(status_code=500, detail=str(e))
137 | 
138 | @app.get("/cycles")
139 | def get_cycles(limit: int = 10) -> List[Dict[str, Any]]:
140 |     """Get multiple cycles from Whoop API."""
141 |     if not whoop_client:
142 |         raise HTTPException(status_code=401, detail="Not authenticated with Whoop")
143 |     
144 |     try:
145 |         # Calculate date range based on limit
146 |         end_date = datetime.now().strftime("%Y-%m-%d")
147 |         start_date = (datetime.now() - timedelta(days=limit)).strftime("%Y-%m-%d")
148 |         
149 |         # Get cycle collection
150 |         cycles = whoop_client.get_cycle_collection(start_date, end_date)
151 |         if not cycles:
152 |             raise HTTPException(status_code=404, detail="No cycle data available")
153 |         return cycles[:limit]  # Return only the requested number of cycles
154 |     except Exception as e:
155 |         raise HTTPException(status_code=500, detail=str(e))
156 | 
157 | def main() -> None:
158 |     """Main entry point for the server."""
159 |     try:
160 |         logger.info("Starting Whoop HTTP Server...")
161 |         initialize_whoop_client()
162 |         uvicorn.run(app, host="0.0.0.0", port=8000)
163 |     except Exception as e:
164 |         logger.error(f"Server error: {str(e)}", exc_info=True)
165 |         raise
166 | 
167 | if __name__ == "__main__":
168 |     main() 
```

--------------------------------------------------------------------------------
/src/whoop_server.py:
--------------------------------------------------------------------------------

```python
  1 | #!/usr/bin/env python3
  2 | """
  3 | MCP server for Whoop API integration.
  4 | This server exposes methods to query the Whoop API for cycles, recovery, and strain data.
  5 | """
  6 | 
  7 | import os
  8 | import sys
  9 | from pathlib import Path
 10 | from typing import Any, Dict, List, Optional
 11 | from datetime import datetime, timedelta
 12 | from dotenv import load_dotenv
 13 | import logging
 14 | 
 15 | from mcp.server.fastmcp import FastMCP
 16 | from whoop import WhoopClient
 17 | 
 18 | # Configure logging
 19 | logging.basicConfig(
 20 |     level=logging.DEBUG,
 21 |     format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
 22 |     stream=sys.stderr
 23 | )
 24 | logger = logging.getLogger(__name__)
 25 | 
 26 | # Create MCP server
 27 | mcp = FastMCP("Whoop API MCP Server")
 28 | 
 29 | # Initialize Whoop client
 30 | whoop_client: Optional[WhoopClient] = None
 31 | 
 32 | def initialize_whoop_client() -> None:
 33 |     """Initialize the Whoop client using environment variables."""
 34 |     global whoop_client
 35 |     
 36 |     # Load environment variables
 37 |     env_path = Path(__file__).parent.parent / 'config' / '.env'
 38 |     logger.info(f"Looking for .env file at: {env_path}")
 39 |     
 40 |     if not env_path.exists():
 41 |         logger.error(f"Environment file not found at {env_path}")
 42 |         return
 43 |     
 44 |     load_dotenv(dotenv_path=env_path)
 45 |     logger.info("Environment variables loaded")
 46 |     
 47 |     # Get credentials
 48 |     email = os.getenv("WHOOP_EMAIL")
 49 |     password = os.getenv("WHOOP_PASSWORD")
 50 |     
 51 |     if not email or not password:
 52 |         logger.error("Missing Whoop credentials in environment variables")
 53 |         return
 54 |         
 55 |     try:
 56 |         whoop_client = WhoopClient(username=email, password=password)
 57 |         logger.info("Successfully authenticated with Whoop API")
 58 |     except Exception as e:
 59 |         logger.error(f"Authentication failed: {str(e)}")
 60 | 
 61 | @mcp.tool()
 62 | def get_latest_cycle() -> Dict[str, Any]:
 63 |     """
 64 |     Get the latest cycle data from Whoop.
 65 |     
 66 |     Returns:
 67 |         Dictionary containing the latest cycle data including recovery score
 68 |     """
 69 |     if not whoop_client:
 70 |         return {"error": "Not authenticated with Whoop"}
 71 |     
 72 |     try:
 73 |         # Get today's date and yesterday's date
 74 |         end_date = datetime.now().strftime("%Y-%m-%d")
 75 |         start_date = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")
 76 |         
 77 |         # Get cycle collection for the last day - pass as positional arguments
 78 |         cycles = whoop_client.get_cycle_collection(start_date, end_date)
 79 |         logger.debug(f"Received cycles response: {cycles}")
 80 |         
 81 |         if not cycles:
 82 |             return {"error": "No cycle data available"}
 83 |             
 84 |         latest_cycle = cycles[0]  # Most recent cycle
 85 |         
 86 |         # Extract recovery score if available
 87 |         recovery_score = None
 88 |         if latest_cycle.get('score') and latest_cycle['score'].get('recovery'):
 89 |             recovery_score = latest_cycle['score']['recovery']
 90 |             
 91 |         return {
 92 |             "cycle": latest_cycle,
 93 |             "recovery_score": recovery_score,
 94 |             "timestamp": datetime.now().isoformat()
 95 |         }
 96 |     except Exception as e:
 97 |         logger.error(f"Error getting latest cycle: {str(e)}")
 98 |         return {"error": str(e)}
 99 | 
100 | @mcp.tool()
101 | def get_average_strain(days: int = 7) -> Dict[str, Any]:
102 |     """
103 |     Calculate average strain over the specified number of days.
104 |     
105 |     Args:
106 |         days: Number of days to analyze (default: 7)
107 |         
108 |     Returns:
109 |         Dictionary containing average strain data
110 |     """
111 |     if not whoop_client:
112 |         return {"error": "Not authenticated with Whoop"}
113 |     
114 |     try:
115 |         # Calculate date range
116 |         end_date = datetime.now().strftime("%Y-%m-%d")
117 |         start_date = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
118 |         
119 |         # Get cycle collection
120 |         cycles = whoop_client.get_cycle_collection(start_date, end_date)
121 |         if not cycles:
122 |             return {"error": "No cycle data available"}
123 |             
124 |         # Extract strain values
125 |         strains = []
126 |         for cycle in cycles:
127 |             if cycle.get('score') and cycle['score'].get('strain'):
128 |                 strains.append(cycle['score']['strain'])
129 |         
130 |         if not strains:
131 |             return {"error": "No strain data available"}
132 |             
133 |         return {
134 |             "average_strain": sum(strains) / len(strains),
135 |             "days_analyzed": days,
136 |             "samples": len(strains),
137 |             "timestamp": datetime.now().isoformat()
138 |         }
139 |     except Exception as e:
140 |         logger.error(f"Error calculating average strain: {str(e)}")
141 |         return {"error": str(e)}
142 | 
143 | @mcp.tool()
144 | def check_auth_status() -> Dict[str, Any]:
145 |     """
146 |     Check if we're authenticated with Whoop.
147 |     
148 |     Returns:
149 |         Dictionary containing authentication status and profile info if available
150 |     """
151 |     if not whoop_client:
152 |         return {
153 |             "authenticated": False,
154 |             "message": "Not authenticated with Whoop"
155 |         }
156 |     
157 |     try:
158 |         # Test authentication by getting profile
159 |         profile = whoop_client.get_profile()
160 |         return {
161 |             "authenticated": True,
162 |             "message": "Successfully authenticated with Whoop",
163 |             "profile": profile,
164 |             "timestamp": datetime.now().isoformat()
165 |         }
166 |     except Exception as e:
167 |         return {
168 |             "authenticated": False,
169 |             "message": f"Authentication error: {str(e)}"
170 |         }
171 | 
172 | @mcp.tool()
173 | def get_cycles(days: int = 10) -> List[Dict[str, Any]]:
174 |     """
175 |     Get multiple cycles from Whoop API.
176 |     
177 |     Args:
178 |         days: Number of days to fetch (default: 10)
179 |         
180 |     Returns:
181 |         List of cycle data dictionaries
182 |     """
183 |     if not whoop_client:
184 |         return [{"error": "Not authenticated with Whoop"}]
185 |     
186 |     try:
187 |         # Calculate date range
188 |         end_date = datetime.now().strftime("%Y-%m-%d")
189 |         start_date = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
190 |         
191 |         # Get cycle collection
192 |         cycles = whoop_client.get_cycle_collection(start_date, end_date)
193 |         if not cycles:
194 |             return [{"error": "No cycle data available"}]
195 |             
196 |         return [{
197 |             "cycle": cycle,
198 |             "timestamp": datetime.now().isoformat()
199 |         } for cycle in cycles]
200 |     except Exception as e:
201 |         logger.error(f"Error getting cycles: {str(e)}")
202 |         return [{"error": str(e)}]
203 | 
204 | # NEW: Specific ID-Based Retrieval Methods
205 | 
206 | @mcp.tool()
207 | def get_cycle_by_id(cycle_id: str) -> Dict[str, Any]:
208 |     """
209 |     Get a specific cycle by its ID from Whoop API.
210 |     
211 |     Args:
212 |         cycle_id: The ID of the cycle to retrieve
213 |         
214 |     Returns:
215 |         Dictionary containing the cycle data
216 |     """
217 |     if not whoop_client:
218 |         return {"error": "Not authenticated with Whoop"}
219 |     
220 |     try:
221 |         cycle = whoop_client.get_cycle(cycle_id)  # Assumes this method exists
222 |         if not cycle:
223 |             return {"error": f"No cycle found with ID {cycle_id}"}
224 |         return {
225 |             "cycle": cycle,
226 |             "timestamp": datetime.now().isoformat()
227 |         }
228 |     except Exception as e:
229 |         logger.error(f"Error getting cycle {cycle_id}: {str(e)}")
230 |         return {"error": str(e)}
231 | 
232 | @mcp.tool()
233 | def get_recovery_by_id(recovery_id: str) -> Dict[str, Any]:
234 |     """
235 |     Get a specific recovery entry by its ID from Whoop API.
236 |     
237 |     Args:
238 |         recovery_id: The ID of the recovery entry to retrieve
239 |         
240 |     Returns:
241 |         Dictionary containing the recovery data
242 |     """
243 |     if not whoop_client:
244 |         return {"error": "Not authenticated with Whoop"}
245 |     
246 |     try:
247 |         recovery = whoop_client.get_recovery(recovery_id)  # Assumes this method exists
248 |         if not recovery:
249 |             return {"error": f"No recovery found with ID {recovery_id}"}
250 |         return {
251 |             "recovery": recovery,
252 |             "timestamp": datetime.now().isoformat()
253 |         }
254 |     except Exception as e:
255 |         logger.error(f"Error getting recovery {recovery_id}: {str(e)}")
256 |         return {"error": str(e)}
257 | 
258 | @mcp.tool()
259 | def get_sleep_by_id(sleep_id: str) -> Dict[str, Any]:
260 |     """
261 |     Get a specific sleep entry by its ID from Whoop API.
262 |     
263 |     Args:
264 |         sleep_id: The ID of the sleep entry to retrieve
265 |         
266 |     Returns:
267 |         Dictionary containing the sleep data
268 |     """
269 |     if not whoop_client:
270 |         return {"error": "Not authenticated with Whoop"}
271 |     
272 |     try:
273 |         sleep = whoop_client.get_sleep(sleep_id)  # Assumes this method exists
274 |         if not sleep:
275 |             return {"error": f"No sleep found with ID {sleep_id}"}
276 |         return {
277 |             "sleep": sleep,
278 |             "timestamp": datetime.now().isoformat()
279 |         }
280 |     except Exception as e:
281 |         logger.error(f"Error getting sleep {sleep_id}: {str(e)}")
282 |         return {"error": str(e)}
283 | 
284 | @mcp.tool()
285 | def get_workout_by_id(workout_id: str) -> Dict[str, Any]:
286 |     """
287 |     Get a specific workout by its ID from Whoop API.
288 |     
289 |     Args:
290 |         workout_id: The ID of the workout to retrieve
291 |         
292 |     Returns:
293 |         Dictionary containing the workout data
294 |     """
295 |     if not whoop_client:
296 |         return {"error": "Not authenticated with Whoop"}
297 |     
298 |     try:
299 |         workout = whoop_client.get_workout(workout_id)  # Assumes this method exists
300 |         if not workout:
301 |             return {"error": f"No workout found with ID {workout_id}"}
302 |         return {
303 |             "workout": workout,
304 |             "timestamp": datetime.now().isoformat()
305 |         }
306 |     except Exception as e:
307 |         logger.error(f"Error getting workout {workout_id}: {str(e)}")
308 |         return {"error": str(e)}
309 | 
310 | @mcp.tool()
311 | def get_strain_by_id(strain_id: str) -> Dict[str, Any]:
312 |     """
313 |     Get a specific strain entry by its ID from Whoop API.
314 |     
315 |     Args:
316 |         strain_id: The ID of the strain entry to retrieve
317 |         
318 |     Returns:
319 |         Dictionary containing the strain data
320 |     """
321 |     if not whoop_client:
322 |         return {"error": "Not authenticated with Whoop"}
323 |     
324 |     try:
325 |         strain = whoop_client.get_strain(strain_id)  # Assumes this method exists
326 |         if not strain:
327 |             return {"error": f"No strain found with ID {strain_id}"}
328 |         return {
329 |             "strain": strain,
330 |             "timestamp": datetime.now().isoformat()
331 |         }
332 |     except Exception as e:
333 |         logger.error(f"Error getting strain {strain_id}: {str(e)}")
334 |         return {"error": str(e)}
335 | 
336 | # NEW: Standalone Data Retrieval Methods
337 | 
338 | @mcp.tool()
339 | def get_recoveries(days: int = 10) -> List[Dict[str, Any]]:
340 |     """
341 |     Get multiple recovery entries from Whoop API, independent of cycles.
342 |     
343 |     Args:
344 |         days: Number of days to fetch (default: 10)
345 |         
346 |     Returns:
347 |         List of recovery data dictionaries
348 |     """
349 |     if not whoop_client:
350 |         return [{"error": "Not authenticated with Whoop"}]
351 |     
352 |     try:
353 |         # Calculate date range
354 |         end_date = datetime.now().strftime("%Y-%m-%d")
355 |         start_date = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
356 |         
357 |         # Get recovery collection
358 |         recoveries = whoop_client.get_recovery_collection(start_date, end_date)  # Assumes this method exists
359 |         if not recoveries:
360 |             return [{"error": "No recovery data available"}]
361 |             
362 |         return [{
363 |             "recovery": recovery,
364 |             "timestamp": datetime.now().isoformat()
365 |         } for recovery in recoveries]
366 |     except Exception as e:
367 |         logger.error(f"Error getting recoveries: {str(e)}")
368 |         return [{"error": str(e)}]
369 | 
370 | @mcp.tool()
371 | def get_sleeps(days: int = 10) -> List[Dict[str, Any]]:
372 |     """
373 |     Get multiple sleep entries from Whoop API, independent of cycles.
374 |     
375 |     Args:
376 |         days: Number of days to fetch (default: 10)
377 |         
378 |     Returns:
379 |         List of sleep data dictionaries
380 |     """
381 |     if not whoop_client:
382 |         return [{"error": "Not authenticated with Whoop"}]
383 |     
384 |     try:
385 |         # Calculate date range
386 |         end_date = datetime.now().strftime("%Y-%m-%d")
387 |         start_date = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
388 |         
389 |         # Get sleep collection
390 |         sleeps = whoop_client.get_sleep_collection(start_date, end_date)  # Assumes this method exists
391 |         if not sleeps:
392 |             return [{"error": "No sleep data available"}]
393 |             
394 |         return [{
395 |             "sleep": sleep,
396 |             "timestamp": datetime.now().isoformat()
397 |         } for sleep in sleeps]
398 |     except Exception as e:
399 |         logger.error(f"Error getting sleeps: {str(e)}")
400 |         return [{"error": str(e)}]
401 | 
402 | @mcp.tool()
403 | def get_workouts(days: int = 10) -> List[Dict[str, Any]]:
404 |     """
405 |     Get multiple workout entries from Whoop API.
406 |     
407 |     Args:
408 |         days: Number of days to fetch (default: 10)
409 |         
410 |     Returns:
411 |         List of workout data dictionaries
412 |     """
413 |     if not whoop_client:
414 |         return [{"error": "Not authenticated with Whoop"}]
415 |     
416 |     try:
417 |         # Calculate date range
418 |         end_date = datetime.now().strftime("%Y-%m-%d")
419 |         start_date = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
420 |         
421 |         # Get workout collection
422 |         workouts = whoop_client.get_workout_collection(start_date, end_date)  # Assumes this method exists
423 |         if not workouts:
424 |             return [{"error": "No workout data available"}]
425 |             
426 |         return [{
427 |             "workout": workout,
428 |             "timestamp": datetime.now().isoformat()
429 |         } for workout in workouts]
430 |     except Exception as e:
431 |         logger.error(f"Error getting workouts: {str(e)}")
432 |         return [{"error": str(e)}]
433 | 
434 | @mcp.tool()
435 | def get_strains(days: int = 10) -> List[Dict[str, Any]]:
436 |     """
437 |     Get multiple strain entries from Whoop API, independent of cycles.
438 |     
439 |     Args:
440 |         days: Number of days to fetch (default: 10)
441 |         
442 |     Returns:
443 |         List of strain data dictionaries
444 |     """
445 |     if not whoop_client:
446 |         return [{"error": "Not authenticated with Whoop"}]
447 |     
448 |     try:
449 |         # Calculate date range
450 |         end_date = datetime.now().strftime("%Y-%m-%d")
451 |         start_date = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
452 |         
453 |         # Get strain collection
454 |         strains = whoop_client.get_strain_collection(start_date, end_date)  # Assumes this method exists
455 |         if not strains:
456 |             return [{"error": "No strain data available"}]
457 |             
458 |         return [{
459 |             "strain": strain,
460 |             "timestamp": datetime.now().isoformat()
461 |         } for strain in strains]
462 |     except Exception as e:
463 |         logger.error(f"Error getting strains: {str(e)}")
464 |         return [{"error": str(e)}]
465 | 
466 | # User Measurements Retrieval
467 | 
468 | @mcp.tool()
469 | def get_user_body_measurements() -> Dict[str, Any]:
470 |     """
471 |     Get the user's body measurements from Whoop.
472 |     Uses /v1/user/measurement/body endpoint.
473 |     
474 |     Returns:
475 |         Dictionary containing height, weight, and max heart rate
476 |     """
477 |     if not whoop_client:
478 |         return {"error": "Not authenticated with Whoop"}
479 |     
480 |     try:
481 |         # API endpoint: GET /v1/user/measurement/body
482 |         measurements = whoop_client.get_body_measurement()
483 |         
484 |         if not measurements:
485 |             return {"error": "No body measurement data available"}
486 |             
487 |         return {
488 |             "measurements": measurements,
489 |             "timestamp": datetime.now().isoformat()
490 |         }
491 |     except Exception as e:
492 |         logger.error(f"Error getting body measurements: {str(e)}")
493 |         return {"error": str(e)}
494 | 
495 | @mcp.tool()
496 | def get_latest_recovery() -> Dict[str, Any]:
497 |     """
498 |     Get the latest recovery data from Whoop.
499 |     Uses /v1/recovery endpoint.
500 |     
501 |     Returns:
502 |         Dictionary containing latest recovery data
503 |     """
504 |     if not whoop_client:
505 |         return {"error": "Not authenticated with Whoop"}
506 |     
507 |     try:
508 |         # Get today's recovery data
509 |         end_date = datetime.now().strftime("%Y-%m-%d")
510 |         start_date = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")
511 |         
512 |         # API endpoint: GET /v1/recovery
513 |         # Parameters: start, end, limit=10 (default)
514 |         recoveries = whoop_client.get_recovery_collection(start_date, end_date)
515 |         
516 |         if not recoveries or not recoveries.get('records'):
517 |             return {"error": "No recent recovery data available"}
518 |             
519 |         # Get the first record (most recent as API sorts by sleep start time descending)
520 |         latest_recovery = recoveries['records'][0]
521 |         
522 |         return {
523 |             "recovery": latest_recovery,
524 |             "recovery_score": latest_recovery.get('score', {}).get('recovery_score'),
525 |             "timestamp": datetime.now().isoformat()
526 |         }
527 |     except Exception as e:
528 |         logger.error(f"Error getting latest recovery: {str(e)}")
529 |         return {"error": str(e)}
530 | 
531 | # Team-Related Functionality (Optional)
532 | 
533 | @mcp.tool()
534 | def get_team_members(team_id: str) -> List[Dict[str, Any]]:
535 |     """
536 |     Get team member data from Whoop API.
537 |     
538 |     Args:
539 |         team_id: The ID of the team to retrieve members for
540 |         
541 |     Returns:
542 |         List of team member data dictionaries
543 |     """
544 |     if not whoop_client:
545 |         return [{"error": "Not authenticated with Whoop"}]
546 |     
547 |     try:
548 |         members = whoop_client.get_team_members(team_id)  # Assumes this method exists
549 |         if not members:
550 |             return [{"error": f"No members found for team {team_id}"}]
551 |         return [{
552 |             "member": member,
553 |             "timestamp": datetime.now().isoformat()
554 |         } for member in members]
555 |     except Exception as e:
556 |         logger.error(f"Error getting team members for team {team_id}: {str(e)}")
557 |         return [{"error": str(e)}]
558 | 
559 | def main() -> None:
560 |     """Main entry point for the server."""
561 |     try:
562 |         logger.info("Starting Whoop MCP Server...")
563 |         initialize_whoop_client()
564 |         logger.info("Running MCP server with stdio transport")
565 |         mcp.run(transport="stdio")
566 |     except Exception as e:
567 |         logger.error(f"Server error: {str(e)}", exc_info=True)
568 |         raise
569 | 
570 | if __name__ == "__main__":
571 |     main()
```