#
tokens: 18642/50000 18/18 files
lines: on (toggle) GitHub
raw markdown copy reset
# 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 | ![Example Usage in Claude Desktop](assets/mcp.png)
  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 | [![Sponsor TheSethRose](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe64a0)](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 | 
```