# 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()
```