# Directory Structure
```
├── .github
│ └── FUNDING.yml
├── .gitignore
├── assets
│ └── mcp.png
├── docker-compose.yml
├── Dockerfile
├── LICENSE
├── pyrightconfig.json
├── README.md
└── src
├── __init__.py
├── api
│ ├── alexa_api.py
│ ├── config.py
│ ├── main.py
│ └── requirements.txt
├── auth
│ ├── config.py
│ ├── login.py
│ └── requirements.txt
└── mcp
├── __init__.py
├── config.py
├── mcp_server.py
└── requirements.txt
```
# Files
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
1 | # Environment variables
2 | .env
3 |
4 | # Python cache
5 | __pycache__/
6 | src/__pycache__/
7 | *.pyc
8 | *.pyo
9 | *.pyd
10 |
11 | # Virtual environments
12 | venv/
13 | env/
14 | .venv/
15 |
16 | # OS-specific files
17 | .DS_Store
18 |
19 | # Cookie file
20 | *.pickle
21 |
22 | # Example data directory
23 | app_data_host/
24 |
25 | # Build/install artifacts
26 | *.egg-info/
27 |
28 | # Custom Exclusions
29 | .cursor/
30 |
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
1 | # Alexa Shopping List
2 |
3 | 
4 |
5 | ## About
6 |
7 | Seamlessly manage your Alexa shopping list. Add, remove, and view items instantly.
8 | Interact with your Alexa shopping list via MCP, using AI assistants like Claude or Cursor.
9 |
10 | > [!WARNING]
11 | > **Requires Manual Authentication & Cookie Refresh**
12 | >
13 | > This tool uses browser cookies extracted via a manual login process.
14 | > Amazon sessions expire.
15 | > You **will** need to re-run the login script periodically (Step 5 & 6) when the tool stops working.
16 |
17 | ## Components
18 |
19 | 1. **API Server (`src/api`):** Docker container (FastAPI) talking to Alexa.
20 | 2. **MCP Server (`src/mcp`):** Local script providing MCP tools. Proxies to the API server.
21 | 3. **Login Script (`src/auth`):** Local script using Selenium for login and cookie injection.
22 |
23 | ## Prerequisites
24 |
25 | - Python 3.10+
26 | - `uv` (Install: `pip install uv` or see [astral.sh/uv](https://astral.sh/uv))
27 | - Docker & Docker Compose (or Docker Desktop)
28 | - Google Chrome (for login script)
29 | - Amazon Account (with Alexa)
30 |
31 | ## Setup & Run
32 |
33 | **1. Clone Repository**
34 |
35 | ```bash
36 | # git clone <repository_url>
37 | cd alexa-mcp
38 | ```
39 |
40 | **2. Configure Components**
41 |
42 | Adjust settings in the `config.py` file within each component directory:
43 |
44 | - `src/api/config.py`: API server settings (port, internal paths).
45 | - `src/auth/config.py`: Login script settings (Amazon URL, API location, **EMAIL/PASSWORD**).
46 | - `src/mcp/config.py`: MCP server settings (API location).
47 |
48 | *Ensure `AMAZON_URL` matches your region and **set your `AMAZON_EMAIL` and `AMAZON_PASSWORD` in `src/auth/config.py`**.* You only need to set these temporarily for the login script to know which Amazon URL to open; the script no longer uses them automatically.
49 |
50 | **3. Start API Server Container**
51 |
52 | Builds the image and runs the API server in the background.
53 |
54 | ```bash
55 | docker compose up --build -d alexa_api
56 | ```
57 |
58 | *(Use `docker compose logs -f alexa_api` to view logs; `docker compose down` to stop.)*
59 |
60 | **4. Set Up Local Environment & Install Auth Dependencies**
61 |
62 | ```bash
63 | # In the project root (alexa-mcp)
64 | uv venv
65 | source .venv/bin/activate
66 | uv pip install -r src/auth/requirements.txt
67 | ```
68 |
69 | **5. Run Login Script**
70 |
71 | This opens a browser window to the Amazon sign-in page.
72 |
73 | ```bash
74 | # Ensure virtual env is active
75 | python -m src.auth.login
76 | ```
77 |
78 | **6. Manual Login & Confirmation**
79 |
80 | Log in manually using the browser window opened by the script. Handle any 2FA or CAPTCHA steps presented by Amazon.
81 |
82 | Once you are successfully logged into Amazon in that browser window, return to the terminal where you ran the script and press `ENTER`.
83 |
84 | The script will then attempt to extract the session cookies and send them to the API server.
85 |
86 | **7. Test API**
87 |
88 | Verify the API server received the cookies and can access your list by opening this URL in your browser (or using `curl`):
89 |
90 | [http://127.0.0.1:8000/items/all](http://127.0.0.1:8000/items/all)
91 |
92 | You should see a JSON response containing your current Alexa shopping list items. If you get an error (like 401 Unauthorized or 503 Service Unavailable), check the API logs (`docker compose logs alexa_api`) and potentially rerun steps 5 & 6.
93 |
94 | * **API Documentation:** FastAPI automatically generates interactive documentation. You can explore all available endpoints and test them directly in your browser at [http://127.0.0.1:8000/docs](http://127.0.0.1:8000/docs).
95 |
96 | ## Troubleshooting
97 |
98 | - **MCP Server Issues:**
99 | - `spawn ENOENT` (Claude Desktop): Verify absolute paths in `mcp.json`.
100 | - Connection Errors/Disconnects: Check API container logs (`docker compose logs alexa_api`). Ensure API container is running and accessible (check `src/mcp/config.py`).
101 | - Import Errors: Ensure dependencies installed in the correct venv (`uv pip install -r src/mcp/requirements.txt`).
102 | - **API Container Issues:**
103 | - Startup Failure: Check logs (`docker compose logs alexa_api`).
104 | - Config Errors: Verify settings in `src/api/config.py`.
105 | - Port Conflicts: Ensure host port `8000` (or mapped port) is free.
106 | - **Login Script Issues (`src/auth/login.py`):**
107 | - Import Errors: Ensure dependencies installed (`uv pip install -r src/auth/requirements.txt`).
108 | - `ModuleNotFoundError: No module named 'distutils'` (on Python 3.12+): Ensure `setuptools` is included in `src/auth/requirements.txt` and dependencies are reinstalled.
109 | - WebDriver Errors: Ensure Chrome is installed/updated. Check `nodriver` compatibility.
110 | - Cookie Errors: Occurs if login fails or cookies cannot be extracted after successful login.
111 | - API Connection Error: Ensure API container is running and reachable (check `src/auth/config.py`). Check `docker compose logs alexa_api`.
112 | - Login Failures: Verify credentials in `src/auth/config.py`. Check for unexpected page changes or Captcha/2FA prompts mentioned in logs or screenshots. Amazon might change selectors (`#ap_email`, `#signInSubmit`, etc.).
113 | - **Tool Errors (401 Unauthorized):** Login failed or cookies expired. Rerun the login script (`python -m src.auth.login`). Ensure credentials in `src/auth/config.py` are correct and check `auth` logs for any 2FA/Captcha issues during the last run.
114 |
115 | ## Connecting an MCP Client (Claude Desktop / Cursor)
116 |
117 | To use this server with an MCP client like Claude Desktop or Cursor, you need to add its configuration to your client's `mcp.json` file. This file tells the client how to find and run your local MCP server.
118 |
119 | 1. Locate your MCP client's configuration file (often named `mcp.json`). The location varies depending on the client.
120 | 2. Open the file and add the following entry within the main `"mcpServers": { ... }` object:
121 |
122 | ```json
123 | "alexa-shopping-list": {
124 | "displayName": "Alexa Shopping List MCP",
125 | "description": "MCP Server for interacting with Alexa shopping list via local API",
126 | "command": "/path/to/your/alexa-mcp/.venv/bin/python",
127 | "args": [
128 | "-m",
129 | "src.mcp.mcp_server"
130 | ],
131 | "workingDirectory": "/path/to/your/alexa-mcp",
132 | "env": {
133 | "PYTHONPATH": "/path/to/your/alexa-mcp"
134 | }
135 | }
136 | ```
137 |
138 | **IMPORTANT:**
139 |
140 | * You **MUST** replace the placeholder absolute paths `/path/to/your/alexa-mcp` in the `command`, `workingDirectory`, and `env.PYTHONPATH` fields with the actual absolute path to **your** project directory on your machine.
141 | * Ensure the `.venv` virtual environment exists at that location and has the MCP dependencies installed (`uv pip install -r src/mcp/requirements.txt`).
142 |
143 | 3. Save the `mcp.json` file.
144 | 4. Restart your MCP client. The "Alexa Shopping List MCP" server should now be available.
145 |
146 | ## Sponsorship
147 |
148 | Like this tool? Consider sponsoring the developer:
149 |
150 | [](https://github.com/sponsors/TheSethRose)
151 |
```
--------------------------------------------------------------------------------
/src/__init__.py:
--------------------------------------------------------------------------------
```python
1 |
```
--------------------------------------------------------------------------------
/src/mcp/requirements.txt:
--------------------------------------------------------------------------------
```
1 | fastmcp
2 | requests
3 |
```
--------------------------------------------------------------------------------
/src/auth/requirements.txt:
--------------------------------------------------------------------------------
```
1 | nodriver
2 | requests
3 |
```
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
```yaml
1 | github: TheSethRose
2 |
```
--------------------------------------------------------------------------------
/src/mcp/__init__.py:
--------------------------------------------------------------------------------
```python
1 | # This file marks src/mcp as a Python package
2 |
```
--------------------------------------------------------------------------------
/pyrightconfig.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "executionEnvironments": [
3 | {
4 | "root": "src"
5 | }
6 | ]
7 | }
8 |
```
--------------------------------------------------------------------------------
/src/api/requirements.txt:
--------------------------------------------------------------------------------
```
1 | fastapi
2 | uvicorn[standard]
3 | requests
4 | python-multipart
5 | apscheduler
6 | pydantic>=2.0
7 | python-dotenv
8 |
```
--------------------------------------------------------------------------------
/src/mcp/config.py:
--------------------------------------------------------------------------------
```python
1 | # Configuration for the MCP Server
2 | import logging
3 |
4 | # Logging level for the MCP server
5 | LOG_LEVEL = "INFO"
6 |
7 | # Host and Port where the API container is running
8 | # Assumes API container is accessible on localhost from where MCP server runs
9 | API_HOST = "localhost"
10 | API_PORT = 8000
11 |
12 | # --- Derived --- #
13 | LOG_LEVEL_INT = getattr(logging, LOG_LEVEL.upper(), logging.INFO)
14 | API_BASE_URL = f"http://{API_HOST}:{API_PORT}"
15 |
```
--------------------------------------------------------------------------------
/src/auth/config.py:
--------------------------------------------------------------------------------
```python
1 | # Configuration for the Auth (Login) Script
2 | import logging
3 |
4 | # Amazon URL for your locale (e.g., amazon.com, amazon.co.uk)
5 | AMAZON_URL = "https://www.amazon.com"
6 |
7 | # Path where the login script temporarily saves the cookie file locally
8 | # before sending it to the API container.
9 | LOCAL_TEMP_COOKIE_PATH = "./alexa_cookie.pickle"
10 |
11 | # Logging level for the login script
12 | LOG_LEVEL = "INFO"
13 |
14 | # Host and Port of the running API container to send cookies to
15 | # Assumes API container is accessible on localhost from where login script runs
16 | API_HOST = "localhost"
17 | API_PORT = 8000
18 |
19 | # --- Derived --- #
20 | LOG_LEVEL_INT = getattr(logging, LOG_LEVEL.upper(), logging.INFO)
21 | API_COOKIE_ENDPOINT = f"http://{API_HOST}:{API_PORT}/auth/cookies"
22 |
```
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
```yaml
1 | services:
2 | alexa_api:
3 | build:
4 | context: .
5 | dockerfile: Dockerfile # Use the new Dockerfile at the root
6 | container_name: shopping_list_api
7 | ports:
8 | # Map host port 8000 to container port 8000 (which is defined in src/api/config.py)
9 | - "8000:8000"
10 | volumes:
11 | # Use a named volume for persistent data (e.g., cookies)
12 | - cookie_data:/app/data
13 | # Healthcheck to ensure the application is running
14 | healthcheck:
15 | test: ["CMD", "curl", "-f", "http://localhost:8000/"] # Use the root endpoint
16 | interval: 30s
17 | timeout: 10s
18 | retries: 3
19 | # Optional: Resource limits (uncomment if needed)
20 | # deploy:
21 | # resources:
22 | # limits:
23 | # cpus: '1'
24 | # memory: 512M
25 | restart: unless-stopped
26 |
27 | # Declare the named volume used by the service
28 | volumes:
29 | cookie_data:
30 |
```
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
```dockerfile
1 | # Use a slim Python base image
2 | FROM python:3.12-slim-bookworm
3 |
4 | # Set environment variables
5 | ENV PYTHONUNBUFFERED=1
6 | ENV DEBIAN_FRONTEND=noninteractive
7 | # Add the root app directory to PYTHONPATH so src.* imports work
8 | ENV PYTHONPATH="/app"
9 |
10 | # Install tini for proper signal handling
11 | RUN apt-get update && apt-get install -y --no-install-recommends tini \
12 | && rm -rf /var/lib/apt/lists/*
13 |
14 | # Set working directory
15 | WORKDIR /app
16 |
17 | # Copy only the API requirements file first to leverage Docker cache
18 | COPY src/api/requirements.txt ./
19 |
20 | # Install API Python dependencies
21 | RUN pip install --no-cache-dir --upgrade pip \
22 | && pip install --no-cache-dir -r requirements.txt
23 |
24 | # Copy the entire src directory which contains api, mcp, auth
25 | # The API needs access to shared modules like config if they exist at the src level
26 | COPY ./src ./src
27 |
28 | # Create the data directory for cookies using an absolute path
29 | # This assumes the API server might write cookies here, adjust if needed
30 | RUN mkdir -p /app/data
31 |
32 | # Use tini as the entrypoint
33 | ENTRYPOINT ["/usr/bin/tini", "--"]
34 |
35 | # Command to run the application using uvicorn
36 | # Points to the FastAPI app instance within the copied src structure
37 | CMD ["uvicorn", "src.api.main:app", "--host", "0.0.0.0", "--port", "8000"]
38 |
39 |
```
--------------------------------------------------------------------------------
/src/api/config.py:
--------------------------------------------------------------------------------
```python
1 | """Configuration management using environment variables."""
2 |
3 | import os
4 | import logging
5 | from dataclasses import dataclass
6 | from typing import Optional
7 | import sys
8 |
9 | logger = logging.getLogger(__name__)
10 |
11 | # Configuration for the API Server (inside Docker)
12 | COOKIE_PATH = "/app/data/cookies.json"
13 |
14 | # Amazon URL for your locale (e.g., amazon.com, amazon.co.uk)
15 | # Needs to match the one used for login to construct API paths correctly.
16 | AMAZON_URL = "https://www.amazon.com"
17 |
18 | # Logging level for the API server
19 | LOG_LEVEL = "INFO"
20 |
21 | # Port the API server listens on inside the container
22 | API_PORT = 8000
23 |
24 | # --- Derived --- #
25 | LOG_LEVEL_INT = getattr(logging, LOG_LEVEL.upper(), logging.INFO)
26 |
27 | #-def load_config(project_root: Optional[str] = None) -> AppConfig:
28 | #- """Loads configuration from .env file and environment variables."""
29 | #- # Construct the path to the .env file
30 | #- if project_root is None:
31 | #- project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
32 | #- dotenv_path = os.path.join(project_root, '.env')
33 | #-
34 | #- logger.debug(f"Attempting to load .env file from: {dotenv_path}")
35 | #-
36 | #- # Use find_dotenv to locate the .env file reliably
37 | #- dotenv_path_found = find_dotenv(filename='.env', raise_error_if_not_found=False)
38 | #-
39 | #- if dotenv_path_found:
40 | #- logger.info(f"Loading environment variables from: {dotenv_path_found}")
41 | #- load_dotenv(dotenv_path=dotenv_path_found)
42 | #- else:
43 | #- logger.warning(".env file not found. Relying on environment variables or defaults.")
44 | #-
45 | #- # Load values, providing defaults
46 | #- amazon_url = os.getenv("AMAZON_URL")
47 | #- cookie_path = os.getenv("COOKIE_PATH", "./alexa_cookie.pickle") # Default local path
48 | #- log_level = os.getenv("LOG_LEVEL", "INFO")
49 | #- api_port_str = os.getenv("API_PORT", "8000")
50 | #-
51 | #- # Basic validation
52 | #- if not amazon_url:
53 | #- raise EnvironmentError("AMAZON_URL environment variable is not set.")
54 | #- if not cookie_path:
55 | #- logger.warning("COOKIE_PATH not set, using default '{cookie_path}'.")
56 | #- if log_level.upper() not in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]:
57 | #- logger.warning(f"Invalid LOG_LEVEL '{log_level}', using default 'INFO'.")
58 | #- log_level = "INFO"
59 | #-
60 | #- try:
61 | #- api_port = int(api_port_str)
62 | #- if not (1024 <= api_port <= 65535):
63 | #- logger.warning(f"Invalid API_PORT '{api_port_str}', using default 8000.")
64 | #- api_port = 8000
65 | #- except ValueError:
66 | #- logger.warning(f"Invalid API_PORT '{api_port_str}', using default 8000.")
67 | #- api_port = 8000
68 | #-
69 | #- return AppConfig(
70 | #- amazon_url=amazon_url,
71 | #- cookie_path=cookie_path,
72 | #- log_level=log_level,
73 | #- api_port=api_port
74 | #- )
75 |
```
--------------------------------------------------------------------------------
/src/auth/login.py:
--------------------------------------------------------------------------------
```python
1 | """Script to force login/re-login, generate the Alexa cookie file, and send it to the API container."""
2 |
3 | import logging
4 | import sys
5 | import asyncio
6 | import os
7 | from pathlib import Path
8 | import requests # Needed for POSTing cookies
9 | from typing import List, Dict # For cookie formatting type hint
10 | import nodriver as uc
11 | import json # To save cookies locally
12 | import datetime
13 |
14 | try:
15 | # Import the local auth config
16 | from . import config as auth_config
17 | except ImportError as e:
18 | print(f"Error importing local config: {e}", file=sys.stderr)
19 | print("Ensure you are running from the project root or have activated the correct environment.", file=sys.stderr)
20 | sys.exit(1)
21 |
22 | logger = logging.getLogger("login_script") # Renamed logger for clarity
23 |
24 | # Constructing URL based on signIn.js structure but with our return_to target
25 | direct_signin_url = "https://www.amazon.com/"
26 |
27 | async def post_cookies_to_api(cookies_for_requests: List[Dict]):
28 | """Posts the cookies (in requests format) to the API endpoint as JSON."""
29 | # Post to API
30 | logger.info(f"Attempting to send cookies as JSON to API endpoint: {auth_config.API_COOKIE_ENDPOINT}")
31 | try:
32 | # Send the list of cookie dictionaries as JSON
33 | response = requests.post(auth_config.API_COOKIE_ENDPOINT, json=cookies_for_requests, timeout=15)
34 | response.raise_for_status()
35 | logger.info(f"Successfully sent cookies to API. Status: {response.status_code}")
36 | return True
37 | except requests.exceptions.ConnectionError as conn_err:
38 | logger.error(f"Could not connect to the API server at {auth_config.API_COOKIE_ENDPOINT}. Is it running?")
39 | logger.error(f"Error: {conn_err}")
40 | except requests.exceptions.Timeout:
41 | logger.error(f"Timeout while sending cookie file to {auth_config.API_COOKIE_ENDPOINT}.")
42 | except requests.exceptions.RequestException as req_err:
43 | logger.error(f"Error sending cookie file to API: {req_err}")
44 | if req_err.response is not None:
45 | logger.error(f"API Response Status: {req_err.response.status_code}")
46 | logger.error(f"API Response Body: {req_err.response.text}")
47 | except Exception as upload_err:
48 | logger.error(f"Unexpected error during cookie file upload: {upload_err}", exc_info=True)
49 | return False
50 |
51 | async def main():
52 | """Opens browser to sign-in page, waits for manual login, then extracts cookies."""
53 | logging.basicConfig(
54 | level=auth_config.LOG_LEVEL_INT,
55 | format='%(asctime)s - %(name)s [%(levelname)s] %(message)s',
56 | stream=sys.stdout
57 | )
58 |
59 | logger.info("Starting Alexa authentication process with nodriver...")
60 |
61 | browser = None
62 | page = None
63 |
64 | # Flag for final outcome
65 | cookie_upload_success = False
66 |
67 | try:
68 | # --- Step 1: Open Browser to Sign-in Page --- #
69 | logger.info("Initializing nodriver browser...")
70 | browser = await uc.start() # Headless by default
71 | page = await browser.get(direct_signin_url)
72 | logger.info(f"Navigated directly to: {direct_signin_url}")
73 |
74 | # --- Step 2: Wait for Manual Login --- #
75 | print("-" * 60)
76 | print("*** MANUAL LOGIN REQUIRED ***")
77 | print("A browser window should have opened to the Amazon sign-in page.")
78 | print("Please log in manually within that browser window.")
79 | print("If you encounter 2FA or CAPTCHA, complete those steps in the browser.")
80 | input("--> Press Enter here AFTER you have successfully logged in... ")
81 | print("-" * 60)
82 | logger.info("User indicated manual login complete. Attempting to extract cookies.")
83 |
84 | # --- Step 5: Extract and Post Cookies ---
85 | # Always attempt cookie extraction after verification steps or manual intervention pause
86 | # The login_success flag now only indicates if the *automated* part seemed to succeed.
87 | # The login_and_upload_success flag tracks the actual cookie extraction/upload result.
88 | logger.info("Proceeding to Step 5: Extract and Post Cookies attempt...")
89 |
90 | # --- Start Cookie Extraction Logic --- #
91 | logger.info("Attempting to extract cookies...")
92 | # Use the documented way to get all cookies for the current context/page
93 | # Access cookies through the browser object, not the page/tab object
94 | raw_cookies = await browser.cookies.get_all(requests_cookie_format=True)
95 |
96 | if not raw_cookies:
97 | logger.error("Failed to extract cookies after manual login attempt.")
98 | # Add a re-check here based on greeting if possible (might be useful even after manual intervention)
99 | try:
100 | final_greeting = await page.evaluate('''() => {
101 | const el = document.querySelector('#nav-link-accountList .nav-line-1');
102 | return el ? el.innerText.trim() : null;
103 | }''')
104 | if not (final_greeting and "Hello," in final_greeting):
105 | logger.error("Double-check: Still not logged in according to greeting element, despite manual confirmation.")
106 | # No need for specific log if cookies extraction failed BUT login_success was True and greeting was found
107 | except Exception:
108 | logger.warning("Could not perform final greeting check.")
109 | # login_and_upload_success remains False
110 | else:
111 | logger.info(f"Successfully extracted {len(raw_cookies)} raw cookie objects.")
112 |
113 | # Convert Cookie objects to JSON serializable list of dicts
114 | serializable_cookies = []
115 | for cookie in raw_cookies:
116 | # Extract common attributes, handle potential None values
117 | cookie_dict = {
118 | "name": getattr(cookie, 'name', None),
119 | "value": getattr(cookie, 'value', None),
120 | "domain": getattr(cookie, 'domain', None),
121 | "path": getattr(cookie, 'path', None),
122 | "expires": getattr(cookie, 'expires', None), # May need conversion if not serializable
123 | "secure": getattr(cookie, 'secure', False),
124 | "httpOnly": getattr(cookie, 'httpOnly', False), # Try direct access
125 | # Add other relevant fields if needed, e.g., sameSite
126 | }
127 | # Filter out None values if necessary, or handle expires conversion
128 | serializable_cookies.append({k: v for k, v in cookie_dict.items() if v is not None})
129 |
130 | logger.info(f"Formatted {len(serializable_cookies)} cookies for JSON.")
131 | # Send the *serializable* list to the API
132 | cookie_upload_success = await post_cookies_to_api(serializable_cookies)
133 |
134 | if cookie_upload_success:
135 | logger.info("Manual login confirmation received and cookies sent successfully.")
136 | else:
137 | logger.error("Cookie extraction may have succeeded, but upload to API failed. See previous logs.")
138 | # --- End Cookie Extraction Logic --- #
139 |
140 | if not cookie_upload_success:
141 | logger.error("Overall process failed (cookie extraction or upload failed). Exiting with error status.")
142 | sys.exit(1)
143 | else:
144 | logger.info("Process completed successfully.")
145 |
146 | except Exception as e:
147 | # Catch top-level errors in the main login flow
148 | logger.exception(f"An unexpected error occurred during the nodriver login process: {e}")
149 | if page:
150 | try:
151 | # Save final page state on uncaught exception - REMOVED
152 | pass
153 | except Exception as screenshot_err:
154 | logger.warning(f"Could not save error screenshot: {screenshot_err}")
155 | sys.exit(1)
156 | finally:
157 | if browser:
158 | logger.info("Closing nodriver browser...")
159 | try:
160 | browser.stop() # Use stop() to close nodriver browser (synchronous)
161 | logger.info("Browser closed.")
162 | except Exception as close_err:
163 | logger.warning(f"Error closing browser: {close_err}")
164 |
165 | if __name__ == "__main__":
166 | try:
167 | uc.loop().run_until_complete(main())
168 | except KeyboardInterrupt:
169 | logging.getLogger().info("Login process interrupted by user.")
170 | sys.exit(0)
171 | except Exception as main_err:
172 | # Catch errors happening outside the main async function itself
173 | logging.getLogger().exception(f"Critical error running main: {main_err}")
174 | sys.exit(1)
175 |
```
--------------------------------------------------------------------------------
/src/api/alexa_api.py:
--------------------------------------------------------------------------------
```python
1 | """Functions for interacting with the Alexa API (shopping list)."""
2 |
3 | import json
4 | import logging
5 | import requests
6 | from http.cookies import SimpleCookie
7 | from collections import defaultdict
8 | from typing import Optional, List, Dict, Any
9 |
10 | # Import the local config module itself
11 | from . import config as api_config
12 |
13 | logger = logging.getLogger(__name__)
14 |
15 | # Define headers for requests (Consider making configurable or dynamic)
16 | DEFAULT_HEADERS = {
17 | "User-Agent": ("Mozilla/5.0 (iPhone; CPU iPhone OS 13_5_1 like Mac OS X)"
18 | " AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148"
19 | " PitanguiBridge/2.2.345247.0-[HARDWARE=iPhone10_4][SOFTWARE=13.5.1]"),
20 | "Accept": "*/*",
21 | "Accept-Language": "*",
22 | "DNT": "1",
23 | "Upgrade-Insecure-Requests": "1"
24 | }
25 |
26 | # --- Cookie Handling ---
27 |
28 | # Hardcoded path for cookie loading *within the container*
29 | # Adjusted for JSON format
30 | CONTAINER_COOKIE_PATH = "/app/data/cookies.json"
31 |
32 | def load_cookies_from_json_file(cookie_file_path: str) -> Optional[List[Dict[str, Any]]]:
33 | """Loads cookies from a JSON file (expected list of dicts)."""
34 | try:
35 | with open(cookie_file_path, 'r', encoding='utf-8') as f:
36 | cookies_list = json.load(f) # Load list of cookie dicts
37 |
38 | if not isinstance(cookies_list, list):
39 | logger.error(f"Expected a list in {cookie_file_path}, got {type(cookies_list)}.")
40 | return None
41 |
42 | # Return the full list of dictionaries for detailed processing
43 | logger.debug(f"Successfully loaded {len(cookies_list)} cookie dicts from JSON: {cookie_file_path}")
44 | return cookies_list
45 |
46 | except FileNotFoundError:
47 | logger.error(f"Cookie file not found: {cookie_file_path}")
48 | return None
49 | except json.JSONDecodeError as json_err:
50 | logger.error(f"Failed to decode JSON from {cookie_file_path}: {json_err}")
51 | return None
52 | except Exception as err:
53 | logger.error(f"Failed to load or parse cookies from JSON file {cookie_file_path}: {err}", exc_info=True)
54 | return None
55 |
56 | # --- API Request Function ---
57 | def make_authenticated_request(
58 | url: str,
59 | # cookie_file_path: str, # No longer needed as parameter
60 | method: str = 'GET',
61 | payload: Optional[Dict[str, Any]] = None
62 | ) -> Optional[requests.Response]:
63 | """Makes an authenticated request using cookies from the fixed container path."""
64 | try:
65 | session = requests.Session()
66 | session.headers.update(DEFAULT_HEADERS)
67 | # Always load from the container path
68 | cookie_list_of_dicts = load_cookies_from_json_file(CONTAINER_COOKIE_PATH)
69 |
70 | if not cookie_list_of_dicts:
71 | logger.error(f"No cookies loaded from {CONTAINER_COOKIE_PATH} for authenticated request.")
72 | return None
73 |
74 | # Set cookies individually using requests' set method
75 | for cookie_dict in cookie_list_of_dicts:
76 | name = cookie_dict.get('name')
77 | value = cookie_dict.get('value')
78 | domain = cookie_dict.get('domain')
79 | path = cookie_dict.get('path')
80 |
81 | if name and value:
82 | logger.debug(f"Setting cookie: name={name}, domain={domain}, path={path}")
83 | session.cookies.set(
84 | name=name,
85 | value=value,
86 | domain=domain,
87 | path=path
88 | # requests automatically handles secure/expires/httpOnly for its context
89 | # We mainly need name, value, domain, path for session management
90 | )
91 | else:
92 | logger.warning(f"Skipping cookie dict with missing name/value: {cookie_dict}")
93 |
94 | logger.debug(f"Making {method} request to {url}")
95 | if method.upper() == 'GET':
96 | response = session.get(url)
97 | elif method.upper() == 'PUT':
98 | logger.debug(f"PUT payload: {payload}")
99 | response = session.put(url, json=payload)
100 | elif method.upper() == 'POST':
101 | logger.debug(f"POST payload: {payload}")
102 | response = session.post(url, json=payload)
103 | elif method.upper() == 'DELETE':
104 | logger.debug(f"DELETE request to {url}")
105 | # Allow DELETE with an optional payload (needed for Alexa API)
106 | response = session.delete(url, json=payload)
107 | else:
108 | logger.error(f"Unsupported method specified: {method}")
109 | return None
110 |
111 | response.raise_for_status() # Raise HTTPError for bad responses (4xx or 5xx)
112 | logger.debug(f"Request successful ({response.status_code})")
113 | return response
114 |
115 | except requests.exceptions.RequestException as err:
116 | logger.error(f"HTTP request failed: {err}")
117 | return None
118 | except Exception as e:
119 | logger.exception(f"Unexpected error during authenticated request: {e}")
120 | return None
121 |
122 | # --- Shopping List Specific Functions ---
123 | def extract_list_items(response_data: Dict[str, Any]) -> Optional[List[Dict[str, Any]]]:
124 | """Extracts list items from the API response."""
125 | # Adapt based on actual API response structure if needed
126 | for key in response_data.keys():
127 | if isinstance(response_data[key], dict) and 'listItems' in response_data[key]:
128 | return response_data[key]['listItems']
129 | logger.warning("Could not find 'listItems' in response data structure.")
130 | logger.debug(f"Full response keys: {list(response_data.keys())}")
131 | return None
132 |
133 | def filter_incomplete_items(list_items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
134 | """Filters a list of items to include only those not marked completed."""
135 | return [item for item in list_items if not item.get('completed', False)]
136 |
137 | def get_shopping_list_items() -> Optional[List[Dict[str, Any]]]:
138 | """Gets all items from the Alexa shopping list."""
139 | list_items_url = f"{api_config.AMAZON_URL}/alexashoppinglists/api/getlistitems"
140 | # Pass the config but the function now ignores the cookie_path within it
141 | response = make_authenticated_request(list_items_url, method='GET')
142 | if response:
143 | try:
144 | response_data = response.json()
145 | logger.debug("Successfully retrieved shopping list data.")
146 | return extract_list_items(response_data)
147 | except requests.exceptions.JSONDecodeError as e:
148 | logger.error(f"Failed to decode JSON response from shopping list API: {e}")
149 | logger.debug(f"Response text: {response.text[:500]}") # Log first 500 chars
150 | return None
151 | else:
152 | logger.error("Failed to retrieve shopping list data.")
153 | return None
154 |
155 | def add_shopping_list_item(item_value: str) -> bool:
156 | """Adds a new item to the Alexa shopping list."""
157 | logger.info(f"Adding item to shopping list: {item_value}")
158 | # Use the correct endpoint from documentation
159 | add_item_path = "/alexashoppinglists/api/addlistitem/YW16bjEuYWNjb3VudC5BSERXNEkyVE00U1I0UVQ2VUpINzNWUVpaQU5BLVNIT1BQSU5HX0lURU0="
160 | url = f"{api_config.AMAZON_URL}{add_item_path}"
161 | payload = {
162 | "value": item_value,
163 | "type": "TASK" # Assuming 'TASK' type, common for shopping/todo lists
164 | }
165 |
166 | response = make_authenticated_request(
167 | url,
168 | # config.cookie_path, # Removed
169 | method='POST', # Assuming POST for creation
170 | payload=payload
171 | )
172 |
173 | if response and response.status_code == 200: # Assuming 200 OK for success
174 | logger.info(f"Successfully added item: {item_value}")
175 | return True
176 | else:
177 | status = response.status_code if response else 'No Response'
178 | logger.error(f"Failed to add item: {item_value} (Status: {status})")
179 | # Log response text for debugging if available and failed
180 | if response is not None:
181 | logger.debug(f"Add item response text: {response.text[:500]}")
182 | return False
183 |
184 | def mark_item_as_completed(list_item: Dict[str, Any]) -> bool:
185 | """Marks a specific shopping list item as completed via the API."""
186 | return _update_item_completion_status(list_item, completed_status=True)
187 |
188 | def delete_shopping_list_item(list_item: Dict[str, Any]) -> bool:
189 | """Deletes a specific shopping list item via the API."""
190 | item_value = list_item.get('value', 'unknown')
191 | item_id = list_item.get('id')
192 |
193 | if not item_id:
194 | logger.error(f"Cannot delete item '{item_value}' without an ID.")
195 | return False
196 |
197 | logger.info(f"Deleting item: {item_value} (ID: {item_id})")
198 | # Use the correct base endpoint from documentation
199 | delete_item_path = "/alexashoppinglists/api/deletelistitem"
200 | url = f"{api_config.AMAZON_URL}{delete_item_path}"
201 |
202 | # Send the item dict (containing ID) as payload
203 | response = make_authenticated_request(
204 | url,
205 | # config.cookie_path, # Removed
206 | method='DELETE',
207 | payload=list_item # Send the whole item dict
208 | )
209 |
210 | # Check for successful deletion (often 200 OK or 204 No Content)
211 | if response and (response.status_code == 200 or response.status_code == 204):
212 | logger.info(f"Successfully deleted item: {item_value}")
213 | return True
214 | else:
215 | status = response.status_code if response else 'No Response'
216 | logger.error(f"Failed to delete item: {item_value} (Status: {status})")
217 | # Log response text for debugging if available and failed
218 | if response is not None:
219 | logger.debug(f"Delete item response text: {response.text[:500]}")
220 | return False
221 |
222 | def unmark_item_as_completed(list_item: Dict[str, Any]) -> bool:
223 | """Unmarks a specific shopping list item as completed via the API."""
224 | return _update_item_completion_status(list_item, completed_status=False)
225 |
226 | def _update_item_completion_status(list_item: Dict[str, Any], completed_status: bool) -> bool:
227 | """Internal helper to update the completed status of an item."""
228 | item_value = list_item.get('value', 'unknown')
229 | action = "Marking" if completed_status else "Unmarking"
230 | action_past = "marked" if completed_status else "unmarked"
231 |
232 | logger.info(f"{action} item as completed: {item_value}")
233 | url = f"{api_config.AMAZON_URL}/alexashoppinglists/api/updatelistitem"
234 | list_item_copy = list_item.copy()
235 | list_item_copy['completed'] = completed_status
236 |
237 | response = make_authenticated_request(
238 | url,
239 | # config.cookie_path, # Removed
240 | method='PUT',
241 | payload=list_item_copy
242 | )
243 |
244 | if response and response.status_code == 200:
245 | logger.info(f"Successfully {action_past} item as completed: {item_value}")
246 | return True
247 | else:
248 | status = response.status_code if response else 'No Response'
249 | logger.error(f"Failed to {action.lower()} item as completed: {item_value} (Status: {status})")
250 | if response is not None:
251 | logger.debug(f"{action} item response text: {response.text[:500]}")
252 | return False
253 |
```
--------------------------------------------------------------------------------
/src/api/main.py:
--------------------------------------------------------------------------------
```python
1 | import sys
2 | import os
3 | import logging
4 | from typing import List, Dict, Any, Optional, Union
5 | import json # Added json for saving cookies
6 |
7 | # --- Path Modification ---
8 | # Add the project root directory to the Python path
9 | # This allows importing modules from the 'src' directory (e.g., alexa_shopping_list)
10 | project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
11 | if project_root not in sys.path:
12 | sys.path.append(project_root)
13 | # --- End Path Modification ---
14 |
15 | from fastapi import FastAPI, HTTPException
16 | from pydantic import BaseModel, Field # For request body validation
17 |
18 | # --- Scheduler Imports ---
19 | from apscheduler.schedulers.asyncio import AsyncIOScheduler
20 | from contextlib import asynccontextmanager
21 | import asyncio # For potential sleep in task
22 | # --- End Scheduler Imports ---
23 |
24 | # Import necessary components using relative imports
25 | try:
26 | # Use the new local config
27 | from . import config as api_config # Alias to avoid name clashes
28 | from .alexa_api import ( # Relative import
29 | get_shopping_list_items,
30 | add_shopping_list_item,
31 | delete_shopping_list_item,
32 | mark_item_as_completed,
33 | unmark_item_as_completed,
34 | filter_incomplete_items,
35 | # No filter_completed_items, we'll do it inline
36 | )
37 | except ImportError as e:
38 | print(f"FATAL ERROR: Could not import alexa_shopping_list modules: {e}", file=sys.stderr)
39 | print("Ensure the script is run from the project root or the src directory is in PYTHONPATH.", file=sys.stderr)
40 | sys.exit(1)
41 |
42 | # --- Globals & Setup ---
43 |
44 | # Configure basic logging
45 | # Note: Uvicorn will likely handle more advanced logging config when run
46 | logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
47 | logger = logging.getLogger(__name__)
48 |
49 | # Configure logging based on local config
50 | logging.basicConfig(level=api_config.LOG_LEVEL_INT, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
51 | logger = logging.getLogger(__name__)
52 |
53 | # Suppress noisy library logs based on loaded config
54 | if api_config.LOG_LEVEL_INT > logging.DEBUG:
55 | logging.getLogger("urllib3").setLevel(logging.WARNING)
56 | logging.getLogger("selenium").setLevel(logging.WARNING) # Likely not needed here, but safe
57 | logging.getLogger("webdriver_manager").setLevel(logging.WARNING) # Likely not needed here
58 | logger.debug("Suppressed noisy library logs.")
59 |
60 | # --- Scheduler Setup ---
61 | scheduler = AsyncIOScheduler()
62 |
63 | async def perform_keep_alive():
64 | """Task to periodically fetch shopping list to keep session active."""
65 | logger.info("Keep-alive task started: Attempting to fetch shopping list...")
66 |
67 | # Check if cookies exist before attempting the request
68 | cookie_path = api_config.COOKIE_PATH
69 | if not os.path.exists(cookie_path):
70 | logger.info(f"Keep-alive skipped: Cookie file not found at {cookie_path}. Login required.")
71 | return # Skip this interval
72 |
73 | try:
74 | # Call the function that gets all items, which uses make_authenticated_request
75 | items = get_shopping_list_items()
76 | if items is not None:
77 | logger.info(f"Keep-alive successful: Fetched {len(items)} items.")
78 | else:
79 | # This likely means cookies are invalid/expired or another API error occurred
80 | logger.warning("Keep-alive failed: Could not retrieve shopping list (cookies might be expired). Re-authentication needed.")
81 | except Exception as e:
82 | # Catch any unexpected error during the keep-alive attempt
83 | logger.error(f"Keep-alive task encountered an unexpected error: {e}", exc_info=True)
84 |
85 | @asynccontextmanager
86 | async def lifespan(app: FastAPI):
87 | # Startup
88 | logger.info("Starting keep-alive scheduler...")
89 | # Schedule the job to run every 60 seconds
90 | scheduler.add_job(perform_keep_alive, 'interval', seconds=60, id='keep_alive_job')
91 | scheduler.start()
92 | yield
93 | # Shutdown
94 | logger.info("Shutting down keep-alive scheduler...")
95 | scheduler.shutdown()
96 |
97 | # --- FastAPI App Instance ---
98 | app = FastAPI(
99 | title="Alexa Shopping List API",
100 | description="API to interact with an Alexa Shopping List using pre-generated cookies.",
101 | version="1.0.0",
102 | lifespan=lifespan # Add the lifespan manager
103 | )
104 |
105 | # --- Helper Function ---
106 | def find_item_by_name(items: List[Dict[str, Any]], name: str) -> Dict[str, Any] | None:
107 | """Finds the first item in a list matching the name (case-insensitive)."""
108 | if items is None:
109 | return None
110 | for item in items:
111 | if item.get("value", "").lower() == name.lower():
112 | return item
113 | return None
114 |
115 | # --- Pydantic Models (for Request Bodies) ---
116 | class ItemNameModel(BaseModel):
117 | item_name: str = Field(..., description="The name of the shopping list item.")
118 |
119 | # Define a Pydantic model for the expected cookie structure (adjust if needed)
120 | class CookieModel(BaseModel):
121 | name: str
122 | value: str
123 | domain: Optional[str] = None
124 | path: Optional[str] = None
125 | # Add missing fields based on what login.py sends
126 | expires: Optional[Union[str, int, float]] = None # Allow various types for expiry
127 | secure: Optional[bool] = None
128 | httpOnly: Optional[bool] = None
129 | # sameSite: Optional[str] = None # Could add if needed
130 |
131 | # --- API Endpoints ---
132 |
133 | @app.get("/", tags=["Status"])
134 | async def read_root():
135 | """Simple health check endpoint."""
136 | return {"status": "Alexa Shopping List API is running"}
137 |
138 | @app.get("/items/all", tags=["Items"], response_model=List[Dict[str, Any]])
139 | async def get_all_list_items():
140 | """Retrieves all items (completed and incomplete) from the shopping list."""
141 | logger.info("Endpoint GET /items/all called.")
142 | items = get_shopping_list_items()
143 | if items is None:
144 | logger.error("Failed to retrieve items from Alexa API.")
145 | raise HTTPException(status_code=503, detail="Could not retrieve shopping list from Alexa.")
146 | return items
147 |
148 | @app.get("/items/incomplete", tags=["Items"], response_model=List[Dict[str, Any]])
149 | async def get_incomplete_list_items():
150 | """Retrieves only the incomplete items from the shopping list."""
151 | logger.info("Endpoint GET /items/incomplete called.")
152 | all_items = get_shopping_list_items() # No longer needs config passed
153 | if all_items is None:
154 | logger.error("Failed to retrieve items from Alexa API.")
155 | raise HTTPException(status_code=503, detail="Could not retrieve shopping list from Alexa.")
156 | incomplete_items = filter_incomplete_items(all_items)
157 | return incomplete_items
158 |
159 | @app.get("/items/completed", tags=["Items"], response_model=List[Dict[str, Any]])
160 | async def get_completed_list_items():
161 | """Retrieves only the completed items from the shopping list."""
162 | logger.info("Endpoint GET /items/completed called.")
163 | all_items = get_shopping_list_items() # No longer needs config passed
164 | if all_items is None:
165 | logger.error("Failed to retrieve items from Alexa API.")
166 | raise HTTPException(status_code=503, detail="Could not retrieve shopping list from Alexa.")
167 | # Filter completed items directly
168 | completed_items = [item for item in all_items if item.get('completed', False)]
169 | return completed_items
170 |
171 | @app.post("/items", tags=["Items"], status_code=201) # 201 Created
172 | async def add_new_item(item_data: ItemNameModel):
173 | """Adds a new item to the shopping list."""
174 | item_name = item_data.item_name
175 | logger.info(f"Endpoint POST /items called to add: '{item_name}'")
176 | success = add_shopping_list_item(item_name) # No longer needs config passed
177 | if not success:
178 | logger.error(f"Failed to add item '{item_name}' via Alexa API.")
179 | raise HTTPException(status_code=500, detail=f"Failed to add item '{item_name}'.")
180 | return {"message": f"Item '{item_name}' added successfully."}
181 |
182 | @app.delete("/items", tags=["Items"])
183 | async def remove_item(item_data: ItemNameModel):
184 | """Deletes an item from the shopping list by name (case-insensitive)."""
185 | item_name = item_data.item_name
186 | logger.info(f"Endpoint DELETE /items called for: '{item_name}'")
187 | all_items = get_shopping_list_items() # No longer needs config passed
188 | item_to_delete = find_item_by_name(all_items or [], item_name)
189 |
190 | if not item_to_delete:
191 | logger.warning(f"Item '{item_name}' not found for deletion.")
192 | raise HTTPException(status_code=404, detail=f"Item '{item_name}' not found.")
193 |
194 | success = delete_shopping_list_item(item_to_delete) # No longer needs config passed
195 | if not success:
196 | logger.error(f"Failed to delete item '{item_name}' via Alexa API.")
197 | raise HTTPException(status_code=500, detail=f"Failed to delete item '{item_name}'.")
198 | return {"message": f"Item '{item_name}' deleted successfully."}
199 |
200 | @app.put("/items/mark_completed", tags=["Items"])
201 | async def mark_item_complete(item_data: ItemNameModel):
202 | """Marks an item as completed by name (case-insensitive)."""
203 | item_name = item_data.item_name
204 | logger.info(f"Endpoint PUT /items/mark_completed called for: '{item_name}'")
205 | all_items = get_shopping_list_items() # No longer needs config passed
206 | # Find an *incomplete* item matching the name
207 | item_to_mark = find_item_by_name(filter_incomplete_items(all_items or []), item_name)
208 |
209 | if not item_to_mark:
210 | logger.warning(f"Incomplete item '{item_name}' not found to mark complete.")
211 | raise HTTPException(status_code=404, detail=f"Incomplete item '{item_name}' not found.")
212 |
213 | success = mark_item_as_completed(item_to_mark) # No longer needs config passed
214 | if not success:
215 | logger.error(f"Failed to mark item '{item_name}' completed via Alexa API.")
216 | raise HTTPException(status_code=500, detail=f"Failed to mark item '{item_name}' as completed.")
217 | return {"message": f"Item '{item_name}' marked as completed."}
218 |
219 | @app.put("/items/mark_incomplete", tags=["Items"])
220 | async def mark_item_incomplete_endpoint(item_data: ItemNameModel):
221 | """Marks an item as incomplete by name (case-insensitive)."""
222 | item_name = item_data.item_name
223 | logger.info(f"Endpoint PUT /items/mark_incomplete called for: '{item_name}'")
224 | all_items = get_shopping_list_items() # No longer needs config passed
225 | # Find a *complete* item matching the name
226 | completed_items = [item for item in (all_items or []) if item.get('completed', False)]
227 | item_to_mark = find_item_by_name(completed_items, item_name)
228 |
229 | if not item_to_mark:
230 | logger.warning(f"Completed item '{item_name}' not found to mark incomplete.")
231 | raise HTTPException(status_code=404, detail=f"Completed item '{item_name}' not found.")
232 |
233 | success = unmark_item_as_completed(item_to_mark) # No longer needs config passed
234 | if not success:
235 | logger.error(f"Failed to mark item '{item_name}' incomplete via Alexa API.")
236 | raise HTTPException(status_code=500, detail=f"Failed to mark item '{item_name}' as incomplete.")
237 | return {"message": f"Item '{item_name}' marked as incomplete."}
238 |
239 | # --- Authentication Endpoint ---
240 | @app.post("/auth/cookies", tags=["Authentication"], status_code=200)
241 | async def receive_cookies(cookies_data: List[CookieModel]): # Expect a list of CookieModel
242 | """Accepts cookies as JSON and saves them to the persistent data volume."""
243 | # Use the COOKIE_PATH directly from the local API config
244 | cookie_path = api_config.COOKIE_PATH
245 | data_dir_container = os.path.dirname(cookie_path) # Get directory from the path
246 |
247 | logger.info(f"Received {len(cookies_data)} cookies. Attempting to save as JSON to: {cookie_path}")
248 |
249 | # Create directory if it doesn't exist
250 | try:
251 | os.makedirs(data_dir_container, exist_ok=True)
252 | except OSError as e:
253 | logger.error(f"Could not create data directory '{data_dir_container}': {e}", exc_info=True)
254 | raise HTTPException(status_code=500, detail=f"Server error: Could not create data directory.")
255 |
256 | try:
257 | # Convert Pydantic models back to dicts for JSON serialization
258 | cookies_list_of_dicts = [cookie.model_dump(exclude_unset=True) for cookie in cookies_data]
259 |
260 | # Save the received cookie list as a JSON file
261 | with open(cookie_path, "w", encoding="utf-8") as f:
262 | json.dump(cookies_list_of_dicts, f, indent=2)
263 |
264 | logger.info(f"Successfully saved cookie data as JSON to {cookie_path}")
265 | return {"message": "Cookie data received and saved successfully."}
266 |
267 | except Exception as e:
268 | logger.error(f"Failed to save cookie data as JSON to {cookie_path}: {e}", exc_info=True)
269 | raise HTTPException(status_code=500, detail=f"Failed to save cookie data: {e}")
270 |
271 | # --- Optional: Add main block to run with uvicorn for direct execution ---
272 | if __name__ == "__main__":
273 | import uvicorn
274 | logger.info("Starting Uvicorn server directly for development (keep-alive active)...")
275 | # Note: Host '0.0.0.0' makes it accessible on your network
276 | # Use '127.0.0.1' for local access only
277 | # Reload=True is for development, disable for production
278 | uvicorn.run("main:app", host="127.0.0.1", port=8000, reload=True)
279 |
```
--------------------------------------------------------------------------------
/src/mcp/mcp_server.py:
--------------------------------------------------------------------------------
```python
1 | #!/Users/sethrose/Developer/github/Temp/alexa-mcp/.venv/bin/python
2 | import sys
3 | import os
4 | import logging
5 | import requests # For making API calls
6 | import json
7 | from typing import List, Dict, Any, Optional, Union
8 | from pathlib import Path
9 |
10 | # --- Path Modification ---
11 | # No longer needed as we read API_PORT directly from env
12 | # --- End Path Modification ---
13 |
14 | # --- Setup Project Root ---
15 | # REMOVED File Logging Setup
16 | # --- End Setup ---
17 |
18 | # Import the local config
19 | try:
20 | from . import config as mcp_config
21 | except ImportError as e:
22 | print(f"Error importing local MCP config: {e}", file=sys.stderr)
23 | print("Ensure you are running from the project root or have activated the correct environment.", file=sys.stderr)
24 | sys.exit(1)
25 |
26 | from fastmcp import FastMCP
27 |
28 | # Configure logging based on local config
29 | logging.basicConfig(level=mcp_config.LOG_LEVEL_INT, format='%(asctime)s - %(name)s [%(levelname)s] %(message)s')
30 | logger = logging.getLogger(__name__)
31 |
32 | # --- Add File Handler ---
33 | # Create file handler which logs even debug messages
34 | # REMOVED File Handler Setup
35 | # --- End File Handler Setup ---
36 |
37 | # API server configuration
38 | # Use base URL directly from local config
39 | API_BASE_URL = mcp_config.API_BASE_URL
40 |
41 | logger.info(f"MCP Server configured to connect to API at: {mcp_config.API_BASE_URL}")
42 |
43 | # Suppress noisy library logs based on loaded config
44 | if mcp_config.LOG_LEVEL_INT > logging.DEBUG:
45 | logging.getLogger("requests").setLevel(logging.WARNING)
46 | logging.getLogger("urllib3").setLevel(logging.WARNING)
47 | logger.debug("Suppressed noisy library logs.")
48 |
49 | print("--- DEBUG: Initializing FastMCP server...", file=sys.stderr)
50 |
51 | # --- FastMCP Server Instance ---
52 | mcp = FastMCP("Alexa Shopping List")
53 |
54 | print("--- DEBUG: FastMCP server instance created.", file=sys.stderr)
55 |
56 | # --- Helper Functions ---
57 | def make_api_request(method: str, endpoint: str, json_data: Optional[Dict] = None) -> Dict:
58 | """Makes a request to the FastAPI server and handles errors."""
59 | url = f"{API_BASE_URL}{endpoint}"
60 | logger.debug(f"Making {method} request to FastAPI: {url}")
61 |
62 | try:
63 | if method.upper() == "GET":
64 | response = requests.get(url)
65 | elif method.upper() == "POST":
66 | response = requests.post(url, json=json_data)
67 | elif method.upper() == "PUT":
68 | response = requests.put(url, json=json_data)
69 | elif method.upper() == "DELETE":
70 | response = requests.delete(url, json=json_data)
71 | else:
72 | logger.error(f"Unsupported HTTP method: {method}")
73 | return {"error": f"Unsupported HTTP method: {method}"}
74 |
75 | # Raise exception for 4xx/5xx status codes
76 | response.raise_for_status()
77 |
78 | # Try to parse JSON, fall back to text if not JSON
79 | try:
80 | return response.json()
81 | except json.JSONDecodeError:
82 | return {"message": response.text}
83 |
84 | except requests.exceptions.ConnectionError:
85 | logger.error(f"Connection error: Could not connect to FastAPI server at {API_BASE_URL}")
86 | return {"error": "Could not connect to FastAPI server. Is it running?"}
87 | except requests.exceptions.HTTPError as e:
88 | logger.error(f"HTTP error: {e}")
89 | # Try to get error details from the response
90 | try:
91 | error_detail = response.json().get("detail", str(e))
92 | except (json.JSONDecodeError, AttributeError):
93 | error_detail = str(e)
94 | return {"error": error_detail}
95 | except Exception as e:
96 | logger.error(f"Error making API request: {e}")
97 | return {"error": str(e)}
98 |
99 | # --- Tool Definitions ---
100 | # These now proxy requests to our FastAPI server
101 |
102 | @mcp.tool()
103 | def get_all_items() -> list[dict]:
104 | """
105 | Retrieves all items currently on the Alexa shopping list, including both active (incomplete) and completed items.
106 | Returns a list of dictionaries, where each dictionary represents an item and includes keys like 'id', 'value', and 'completed'.
107 | An empty list is returned if the shopping list is empty or an error occurs.
108 | """
109 | logger.info("Tool 'get_all_items' called.")
110 | response = make_api_request("GET", "/items/all")
111 |
112 | if "error" in response:
113 | logger.error(f"Error in get_all_items: {response['error']}")
114 | return [] # Return empty list on error
115 |
116 | # Make sure we return a list even if API somehow returns something else
117 | if isinstance(response, list):
118 | return response # API already returns the list format we need
119 | else:
120 | logger.warning(f"Unexpected response format from API, expected list but got: {type(response)}")
121 | return []
122 |
123 | @mcp.tool()
124 | def get_incomplete_items() -> list[dict]:
125 | """
126 | Retrieves only the active (incomplete) items currently on the Alexa shopping list.
127 | This is useful for seeing what still needs to be purchased.
128 | Returns a list of dictionaries, each representing an item with keys like 'id', 'value', and 'completed' (which will be false).
129 | An empty list is returned if there are no active items or an error occurs.
130 | """
131 | logger.info("Tool 'get_incomplete_items' called.")
132 | response = make_api_request("GET", "/items/incomplete")
133 |
134 | if "error" in response:
135 | logger.error(f"Error in get_incomplete_items: {response['error']}")
136 | return []
137 |
138 | # Make sure we return a list even if API somehow returns something else
139 | if isinstance(response, list):
140 | return response
141 | else:
142 | logger.warning(f"Unexpected response format from API, expected list but got: {type(response)}")
143 | return []
144 |
145 | @mcp.tool()
146 | def get_completed_items() -> list[dict]:
147 | """
148 | Retrieves only the completed items currently on the Alexa shopping list.
149 | This shows items that have been marked as done.
150 | Returns a list of dictionaries, each representing an item with keys like 'id', 'value', and 'completed' (which will be true).
151 | An empty list is returned if there are no completed items or an error occurs.
152 | """
153 | logger.info("Tool 'get_completed_items' called.")
154 | response = make_api_request("GET", "/items/completed")
155 |
156 | if "error" in response:
157 | logger.error(f"Error in get_completed_items: {response['error']}")
158 | return []
159 |
160 | # Make sure we return a list even if API somehow returns something else
161 | if isinstance(response, list):
162 | return response
163 | else:
164 | logger.warning(f"Unexpected response format from API, expected list but got: {type(response)}")
165 | return []
166 |
167 | @mcp.tool()
168 | def add_item(item_name: Union[str, List[str]]) -> dict:
169 | """
170 | Adds one or more new items to the Alexa shopping list.
171 | Input can be a single item name as a string (e.g., "Milk") or a list of item names as strings (e.g., ["Eggs", "Bread"]).
172 | Returns a dictionary indicating the overall success or failure and a summary message.
173 | If adding multiple items, it attempts to add each one; the overall result is success only if all additions succeed.
174 | """
175 | logger.info(f"Tool 'add_item' called with item_name(s): '{item_name}'")
176 |
177 | item_names = [item_name] if isinstance(item_name, str) else item_name
178 | results = []
179 | all_succeeded = True
180 |
181 | for name in item_names:
182 | if not isinstance(name, str) or not name.strip():
183 | logger.warning(f"Skipping invalid item name: {name}")
184 | results.append({"item": name, "success": False, "message": "Invalid item name provided."})
185 | all_succeeded = False
186 | continue
187 |
188 | response = make_api_request("POST", "/items", {"item_name": name.strip()})
189 | success = "error" not in response
190 | message = response.get("message", response.get("error", "Unknown result"))
191 | results.append({"item": name.strip(), "success": success, "message": message})
192 | if not success:
193 | all_succeeded = False
194 | logger.error(f"Error adding item '{name.strip()}': {message}")
195 |
196 | # Construct summary message
197 | if len(item_names) == 1:
198 | summary_message = results[0]['message']
199 | else:
200 | success_count = sum(1 for r in results if r['success'])
201 | fail_count = len(results) - success_count
202 | if all_succeeded:
203 | summary_message = f"Successfully added {success_count} items."
204 | elif success_count > 0:
205 | summary_message = f"Added {success_count} items, failed to add {fail_count} items. Check logs for details."
206 | else:
207 | summary_message = f"Failed to add all {fail_count} items. Check logs for details."
208 |
209 | return {"success": all_succeeded, "message": summary_message, "details": results}
210 |
211 | @mcp.tool()
212 | def delete_item(item_name: Union[str, List[str]]) -> dict:
213 | """
214 | Deletes one or more items from the Alexa shopping list by their exact name (case-insensitive).
215 | Input can be a single item name as a string (e.g., "Milk") or a list of item names as strings (e.g., ["Old Bread", "Expired Yogurt"]).
216 | Requires an exact match of the item name to find it on the list. If multiple items have the same name, only one might be deleted per name provided.
217 | Returns a dictionary indicating the overall success or failure and a summary message.
218 | If deleting multiple items, it attempts each one; the overall result is success only if all deletions succeed.
219 | """
220 | logger.info(f"Tool 'delete_item' called with item_name(s): '{item_name}'")
221 |
222 | item_names = [item_name] if isinstance(item_name, str) else item_name
223 | results = []
224 | all_succeeded = True
225 |
226 | for name in item_names:
227 | if not isinstance(name, str) or not name.strip():
228 | logger.warning(f"Skipping invalid item name for deletion: {name}")
229 | results.append({"item": name, "success": False, "message": "Invalid item name provided."})
230 | all_succeeded = False
231 | continue
232 |
233 | response = make_api_request("DELETE", "/items", {"item_name": name.strip()})
234 | success = "error" not in response
235 | message = response.get("message", response.get("error", "Unknown result"))
236 | results.append({"item": name.strip(), "success": success, "message": message})
237 | if not success:
238 | all_succeeded = False
239 | logger.error(f"Error deleting item '{name.strip()}': {message}")
240 |
241 | # Construct summary message
242 | if len(item_names) == 1:
243 | summary_message = results[0]['message']
244 | else:
245 | success_count = sum(1 for r in results if r['success'])
246 | fail_count = len(results) - success_count
247 | if all_succeeded:
248 | summary_message = f"Successfully deleted {success_count} items."
249 | elif success_count > 0:
250 | summary_message = f"Deleted {success_count} items, failed to delete {fail_count} items (may not exist or error occurred). Check logs."
251 | else:
252 | summary_message = f"Failed to delete any of the {fail_count} specified items (may not exist or error occurred). Check logs."
253 |
254 | return {"success": all_succeeded, "message": summary_message, "details": results}
255 |
256 | @mcp.tool()
257 | def mark_item_completed(item_name: Union[str, List[str]]) -> dict:
258 | """
259 | Marks one or more items on the Alexa shopping list as completed by their exact name (case-insensitive).
260 | Input can be a single item name as a string (e.g., "Milk") or a list of item names as strings (e.g., ["Eggs", "Bread"]).
261 | Requires an exact match of the item name to find it on the list. If multiple items have the same name, only one might be marked per name provided.
262 | Returns a dictionary indicating the overall success or failure and a summary message.
263 | If marking multiple items, it attempts each one; the overall result is success only if all attempts succeed.
264 | """
265 | logger.info(f"Tool 'mark_item_completed' called with item_name(s): '{item_name}'")
266 |
267 | item_names = [item_name] if isinstance(item_name, str) else item_name
268 | results = []
269 | all_succeeded = True
270 |
271 | for name in item_names:
272 | if not isinstance(name, str) or not name.strip():
273 | logger.warning(f"Skipping invalid item name for completion: {name}")
274 | results.append({"item": name, "success": False, "message": "Invalid item name provided."})
275 | all_succeeded = False
276 | continue
277 |
278 | response = make_api_request("PUT", "/items/mark_completed", {"item_name": name.strip()})
279 | success = "error" not in response
280 | message = response.get("message", response.get("error", "Unknown result"))
281 | results.append({"item": name.strip(), "success": success, "message": message})
282 | if not success:
283 | all_succeeded = False
284 | logger.error(f"Error marking item '{name.strip()}' completed: {message}")
285 |
286 | # Construct summary message
287 | if len(item_names) == 1:
288 | summary_message = results[0]['message']
289 | else:
290 | success_count = sum(1 for r in results if r['success'])
291 | fail_count = len(results) - success_count
292 | if all_succeeded:
293 | summary_message = f"Successfully marked {success_count} items as completed."
294 | elif success_count > 0:
295 | summary_message = f"Marked {success_count} items completed, failed to mark {fail_count} items (may not exist or error occurred). Check logs."
296 | else:
297 | summary_message = f"Failed to mark any of the {fail_count} specified items as completed (may not exist or error occurred). Check logs."
298 |
299 | return {"success": all_succeeded, "message": summary_message, "details": results}
300 |
301 | @mcp.tool()
302 | def mark_item_incomplete(item_name: Union[str, List[str]]) -> dict:
303 | """
304 | Marks one or more previously completed items on the Alexa shopping list as incomplete (active) by their exact name (case-insensitive).
305 | Input can be a single item name as a string (e.g., "Milk") or a list of item names as strings (e.g., ["Eggs", "Bread"]).
306 | Requires an exact match of the item name to find it on the list. If multiple items have the same name, only one might be marked per name provided.
307 | Use this if an item was marked completed by mistake.
308 | Returns a dictionary indicating the overall success or failure and a summary message.
309 | If marking multiple items, it attempts each one; the overall result is success only if all attempts succeed.
310 | """
311 | logger.info(f"Tool 'mark_item_incomplete' called with item_name(s): '{item_name}'")
312 |
313 | item_names = [item_name] if isinstance(item_name, str) else item_name
314 | results = []
315 | all_succeeded = True
316 |
317 | for name in item_names:
318 | if not isinstance(name, str) or not name.strip():
319 | logger.warning(f"Skipping invalid item name for marking incomplete: {name}")
320 | results.append({"item": name, "success": False, "message": "Invalid item name provided."})
321 | all_succeeded = False
322 | continue
323 |
324 | response = make_api_request("PUT", "/items/mark_incomplete", {"item_name": name.strip()})
325 | success = "error" not in response
326 | message = response.get("message", response.get("error", "Unknown result"))
327 | results.append({"item": name.strip(), "success": success, "message": message})
328 | if not success:
329 | all_succeeded = False
330 | logger.error(f"Error marking item '{name.strip()}' incomplete: {message}")
331 |
332 | # Construct summary message
333 | if len(item_names) == 1:
334 | summary_message = results[0]['message']
335 | else:
336 | success_count = sum(1 for r in results if r['success'])
337 | fail_count = len(results) - success_count
338 | if all_succeeded:
339 | summary_message = f"Successfully marked {success_count} items as incomplete."
340 | elif success_count > 0:
341 | summary_message = f"Marked {success_count} items incomplete, failed to mark {fail_count} items (may not exist or error occurred). Check logs."
342 | else:
343 | summary_message = f"Failed to mark any of the {fail_count} specified items as incomplete (may not exist or error occurred). Check logs."
344 |
345 | return {"success": all_succeeded, "message": summary_message, "details": results}
346 |
347 | # --- API Status Check ---
348 | @mcp.tool()
349 | def check_api_status() -> dict:
350 | """
351 | Checks if the backend FastAPI server (responsible for communicating with the actual Alexa API) is running and accessible.
352 | This is useful for diagnosing connection issues between the MCP server and the FastAPI server.
353 | Returns a dictionary with 'status' ('OK' or 'ERROR') and a descriptive 'message'.
354 | """
355 | logger.info("Tool 'check_api_status' called.")
356 | response = make_api_request("GET", "/")
357 |
358 | if "error" in response:
359 | logger.error(f"API status check failed: {response['error']}")
360 | return {
361 | "status": "ERROR",
362 | "message": f"FastAPI server is not accessible: {response['error']}"
363 | }
364 |
365 | return {
366 | "status": "OK",
367 | "message": "FastAPI server is running and accessible.",
368 | "details": response
369 | }
370 |
371 | # --- Run Server ---
372 | if __name__ == "__main__":
373 | print("--- DEBUG: Entering __main__ block.", file=sys.stderr)
374 | logger.info("Starting FastMCP server...")
375 | print("--- MCP Server: Starting ---", file=sys.stderr); sys.stderr.flush()
376 |
377 | # Initial API health check with added error handling
378 | # --- TEMPORARILY DISABLED Initial API Health Check for Debugging Startup ---
379 | print("--- MCP Server: Skipping initial API health check... ---", file=sys.stderr); sys.stderr.flush()
380 | # --- End Disabled Check ---
381 |
382 | try:
383 | print("--- DEBUG: Calling mcp.run()...", file=sys.stderr)
384 | print("--- MCP Server: Entering mcp.run() ---", file=sys.stderr); sys.stderr.flush()
385 | mcp.run()
386 | print("--- DEBUG: mcp.run() completed (or exited).", file=sys.stderr)
387 | except Exception as e:
388 | print(f"--- MCP Server FATAL ERROR: Exception from mcp.run(): {e} ---", file=sys.stderr)
389 | logger.exception(f"Exception from mcp.run(): {e}") # Log with traceback via logger
390 | import traceback
391 | traceback.print_exc(file=sys.stderr) # Also print traceback directly
392 | sys.stderr.flush()
393 | sys.exit(1) # Ensure exit on error from run
394 | finally:
395 | print("--- MCP Server: mcp.run() exited ---", file=sys.stderr); sys.stderr.flush()
396 | logger.info("FastMCP server finished.")
397 | print("--- DEBUG: Exiting __main__ block normally.", file=sys.stderr)
398 |
```