diff --git a/.env.example b/.env.example index 5f5a0b3..77617c4 100644 --- a/.env.example +++ b/.env.example @@ -2,10 +2,15 @@ # You can use MONARCH_TOKEN (recommended) OR Email/Password MONARCH_TOKEN= -# Fallback credentials +# Credentials for automatic re-authentication (required for token refresh) MONARCH_EMAIL= MONARCH_PASSWORD= +# MFA Secret for TOTP-based re-authentication +# This is the secret you saved when you first set up 2FA for Monarch Money +# Required if you have MFA enabled on your Monarch account +MONARCH_MFA_SECRET= + # Server Configuration PORT=8000 MONARCH_PORT=8070 diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..12d1966 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,41 @@ +# Implementation Plan: Automatic Re-authentication with MFA + +## Problem +The Monarch Money API token expires periodically. Currently, there is no automatic refresh mechanism, causing the MCP server to fail until manually re-authenticated. + +## Objective +Implement automatic re-authentication functionality that detects expired tokens and transparently re-authenticates using stored credentials and an MFA secret (TOTP). + +## Proposed Solution +Use `pyotp` to generate MFA codes programmatically and wrap API calls with retry logic that handles authentication failures. + +## Prerequisites +- `pyotp` library (Installed) +- User needs to add `MONARCH_MFA_SECRET` to their environment variables. + +## Implementation Steps + +### 1. Update `auth.py` +- Add logic to handle re-authentication using `pyotp`. +- Implement a `login_with_mfa()` function that: + - Uses `MONARCH_EMAIL` and `MONARCH_PASSWORD`. + - Uses `MONARCH_MFA_SECRET` with `pyotp` to generate a TOTP code if MFA is requested. + - Updates the active client session. + +### 2. Create Re-authentication Decorator/Wrapper +- Create a Python decorator (e.g., `@retry_on_auth_error`) in `auth.py` or a new utility module. +- This decorator will: + 1. Execute the decorated function (API call). + 2. Catch specific exceptions indicating authentication failure (e.g., `LoginFailedException`, `RequestFailedException` with 401/403 status). + 3. Call the re-authentication logic. + 4. Retry the original function. + +### 3. Apply Wrapper in `server.py` +- Apply the decorator to the MCP tool implementations (`get_accounts`, `get_transactions`, etc.) or wrap the client calls to ensure they auto-recover from expired tokens. + +### 4. Update `login_setup.py` +- Modify the setup script to display the MFA Secret (seed) to the user during the initial login process. +- Instruct the user to save this as `MONARCH_MFA_SECRET` in their `.env` file alongside `MONARCH_TOKEN`. + +## Verification +- Test by simulating an expired token and verifying that the system automatically logs in using the MFA secret and completes the request. diff --git a/PROBLEM.md b/PROBLEM.md deleted file mode 100644 index 495b3ff..0000000 --- a/PROBLEM.md +++ /dev/null @@ -1,45 +0,0 @@ -# PROBLEM: Monarch MCP Server SSE Routing and Handshake Failures - -## Issue Description -The Monarch MCP server, implemented using `FastMCP` and `Starlette`, is experiencing persistent `404 Not Found`, `TypeError`, or `400 Bad Request` errors when AI agent clients attempt to establish an SSE connection at `http://docker.local.ben.io:8070/mcp`. While the `/health` endpoint is functioning correctly, the MCP handshake fails to complete or routes to non-existent endpoints. - -## Root Cause Analysis (Suspected) -The primary issue stems from the complexity of mounting a `FastMCP` HTTP application within a `Starlette` wrapper. -1. **Endpoint Overlap:** `FastMCP.http_app()` generates its own internal routing (e.g., `/mcp` or `/sse`). When this app is mounted at `/mcp` in a parent `Starlette` app, the resulting path becomes `/mcp/mcp`, leading to `404` errors on the expected `/mcp` path. -2. **ASGI Signature Mismatches:** Manual attempts to bridge the `SseServerTransport` with Starlette routes led to `TypeError: 'NoneType' object is not callable` or missing positional arguments (`receive`, `send`), indicating that the custom endpoint wrappers were not correctly following the ASGI/Starlette interface. -3. **Transport Defaults:** `FastMCP` default transport ("streamable-http") expects specific headers and session IDs that differ from standard MCP SSE implementations, causing `400 Bad Request: Missing session ID` when using generic tools like `curl`. - -## Troubleshooting Steps Taken -1. **Refactored Auth:** Removed `keyring` from the Docker runtime to prevent headless environment crashes; moved to `MONARCH_TOKEN` environment variable. -2. **Infrastructure Validation:** Verified port `8070` is unique on the `local.ben.io` server. -3. **Internal Logic Debugging:** - - Attempted mounting `FastMCP` at root (`/`) and sub-paths (`/mcp`). - - Inspected `FastMCP` source code to identify internal attributes (`_mcp_server`, `_lifespan`). - - Switched from manual `SseServerTransport` handling to `mcp.http_app()`. -4. **Endpoint Verification:** - - Confirmed `/health` returns `200 OK`. - - Tested `/mcp` with `Accept: text/event-stream`, which yielded `400 Bad Request` or `406 Not Acceptable` depending on the mount point. -5. **Session Exploration:** Investigated OpenCode session storage (`~/.local/share/opencode/storage/session/`) to understand how clients identify and connect to projects. - -## Current State -The server is running in a Komodo stack, healthy on port `8070`, and visible at `https://monarch-mcp.ext.ben.io`. However, the handshake remains broken. - -## Resolution (Applied) -The fix was to match the working pattern from `komodo-mcp-custom`: - -```python -def create_app(): - mcp_app = mcp.http_app() - routes = [ - Route("/health", health, methods=["GET"]), - Mount("/", app=mcp_app), # MCP handles /mcp endpoint internally - ] - return Starlette(routes=routes, lifespan=mcp_app.lifespan) - -app = create_app() -``` - -Key changes: -1. **Mount at root:** `Mount("/", app=mcp_app)` lets FastMCP handle `/mcp` internally -2. **Pass lifespan:** `lifespan=mcp_app.lifespan` ensures proper task group initialization -3. **Separate health route:** `/health` is a standalone route in the parent Starlette app diff --git a/login_setup.py b/login_setup.py index 84aff81..093188a 100644 --- a/login_setup.py +++ b/login_setup.py @@ -5,7 +5,6 @@ Saves session securely and provides the token for Docker environment. """ import asyncio -import os import getpass import sys from pathlib import Path @@ -53,6 +52,21 @@ async def main(): # Also save to local keyring for convenience save_token(token) print("\nāœ… Token also saved to local system keyring.") + + print("\n" + "=" * 50) + print("šŸ“ IMPORTANT: For automatic re-authentication") + print("=" * 50) + print("\nIf you have MFA enabled on your Monarch account,") + print("add your MONARCH_MFA_SECRET to your .env file:") + print("\n MONARCH_MFA_SECRET=your_totp_secret_here") + print("\nYou should have saved this secret when you first") + print("set up Google Authenticator/Authy for Monarch Money.") + print("\nThis allows the MCP server to automatically re-authenticate") + print("when your token expires.") + print("\nYou also need to add your credentials:") + print(" MONARCH_EMAIL=your_email@example.com") + print(" MONARCH_PASSWORD=your_password") + print("=" * 50) else: print("āŒ Failed to retrieve token from MonarchMoney instance.") diff --git a/src/monarch_mcp_custom/auth.py b/src/monarch_mcp_custom/auth.py index caf2b55..b54a4be 100644 --- a/src/monarch_mcp_custom/auth.py +++ b/src/monarch_mcp_custom/auth.py @@ -6,10 +6,14 @@ Prioritizes environment variables for Docker compatibility. import os import logging from typing import Optional -from monarchmoney import MonarchMoney +from functools import wraps +from monarchmoney import MonarchMoney, LoginFailedException, RequestFailedException +import pyotp logger = logging.getLogger(__name__) +_client_instance: Optional[MonarchMoney] = None + def load_token() -> Optional[str]: """ @@ -43,28 +47,93 @@ async def get_authenticated_client() -> MonarchMoney: Returns an authenticated MonarchMoney client. Raises RuntimeError if no authentication is found. """ + global _client_instance + + if _client_instance: + return _client_instance + token = load_token() if token: try: - # The monarchmoney library supports passing the token directly - return MonarchMoney(token=token) + _client_instance = MonarchMoney(token=token) + return _client_instance except Exception as e: logger.error(f"āŒ Failed to initialize MonarchMoney with token: {e}") raise - # Fallback to email/password if token is missing (only if both are present) - email = os.getenv("MONARCH_EMAIL") - password = os.getenv("MONARCH_PASSWORD") - if email and password: - try: - mm = MonarchMoney() - await mm.login(email, password) - logger.info("āœ… Logged in using email/password credentials") - return mm - except Exception as e: - logger.error(f"āŒ Login failed: {e}") - raise - raise RuntimeError( "šŸ” Authentication required. Please provide MONARCH_TOKEN or run login_setup.py" ) + + +async def refresh_authentication() -> MonarchMoney: + """ + Re-authenticate using stored credentials and MFA secret. + Returns a new authenticated client instance. + """ + global _client_instance + + email = os.getenv("MONARCH_EMAIL") + password = os.getenv("MONARCH_PASSWORD") + mfa_secret = os.getenv("MONARCH_MFA_SECRET") + + if not email or not password: + raise RuntimeError( + "MONARCH_EMAIL and MONARCH_PASSWORD are required for re-authentication" + ) + + mm = MonarchMoney() + + try: + await mm.login(email, password, mfa_secret_key=mfa_secret, save_session=False) + logger.info("āœ… Re-authentication successful") + _client_instance = mm + return mm + except Exception as e: + logger.error(f"āŒ Re-authentication failed: {e}") + raise + + +def generate_totp_secret_and_uri(email: str, issuer_name: str) -> tuple[str, str]: + """ + Generates a TOTP secret and its provisioning URI for a given email and issuer. + """ + secret = pyotp.random_base32() + totp = pyotp.TOTP(secret) + provisioning_uri = totp.provisioning_uri(name=email, issuer_name=issuer_name) + return secret, provisioning_uri + + +def retry_on_auth_error(max_retries: int = 1): + """ + Decorator to retry functions on authentication failures. + Catches RequestFailedException and re-authenticates before retrying. + """ + + def decorator(func): + @wraps(func) + async def wrapper(*args, **kwargs): + for attempt in range(max_retries + 1): + try: + return await func(*args, **kwargs) + except (RequestFailedException, LoginFailedException, Exception) as e: + error_str = str(e).lower() + is_auth_error = ( + "401" in error_str + or "unauthorized" in error_str + or "authentication" in error_str + or isinstance(e, (RequestFailedException, LoginFailedException)) + ) + + if is_auth_error and attempt < max_retries: + logger.warning( + f"āš ļø Authentication failure detected (attempt {attempt + 1}/{max_retries + 1}), re-authenticating..." + ) + await refresh_authentication() + continue + else: + raise + + return wrapper + + return decorator diff --git a/src/monarch_mcp_custom/server.py b/src/monarch_mcp_custom/server.py index 051a157..fbb1d62 100644 --- a/src/monarch_mcp_custom/server.py +++ b/src/monarch_mcp_custom/server.py @@ -2,7 +2,6 @@ Monarch Money MCP Server - Custom SSE Implementation. """ -import os import logging import json from typing import Optional, Any @@ -13,7 +12,7 @@ from starlette.applications import Starlette from starlette.responses import JSONResponse from starlette.routing import Route, Mount -from monarch_mcp_custom.auth import get_authenticated_client +from monarch_mcp_custom.auth import get_authenticated_client, retry_on_auth_error # Load environment variables load_dotenv() @@ -39,6 +38,7 @@ def serialize_json(data: Any) -> str: @mcp.tool() +@retry_on_auth_error() async def get_accounts(reason: Optional[str] = None) -> str: """Get all financial accounts from Monarch Money.""" try: @@ -64,6 +64,7 @@ async def get_accounts(reason: Optional[str] = None) -> str: @mcp.tool() +@retry_on_auth_error() async def get_transactions( limit: int = 50, offset: int = 0, @@ -112,6 +113,7 @@ async def get_transactions( @mcp.tool() +@retry_on_auth_error() async def get_budgets(reason: Optional[str] = None) -> str: """Get current budget information.""" try: @@ -137,6 +139,7 @@ async def get_budgets(reason: Optional[str] = None) -> str: @mcp.tool() +@retry_on_auth_error() async def get_account_holdings(account_id: str, reason: Optional[str] = None) -> str: """Get investment holdings for a specific account.""" try: @@ -150,6 +153,7 @@ async def get_account_holdings(account_id: str, reason: Optional[str] = None) -> @mcp.tool() +@retry_on_auth_error() async def refresh_accounts(reason: Optional[str] = None) -> str: """Request a refresh of account data from financial institutions.""" try: