feat: add automatic re-authentication with MFA support
All checks were successful
Build and Push Monarch MCP Docker Image / build (push) Successful in 8s

Implement automatic token refresh using stored credentials and TOTP MFA secret. When an API call fails with a 401/unauthorized error, the system now transparently re-authenticates using MONARCH_EMAIL, MONARCH_PASSWORD, and MONARCH_MFA_SECRET, then retries the original request.

Changes:
- Add refresh_authentication() function in auth.py for credential-based login
- Create @retry_on_auth_error decorator to handle and retry failed auth calls
- Apply decorator to all MCP tools (get_accounts, get_transactions, etc.)
- Add MONARCH_MFA_SECRET to .env.example with documentation
- Update login_setup.py to instruct users about required env vars
- Replace PROBLEM.md with PLAN.md documenting the implementation
This commit is contained in:
Ben
2025-12-24 15:45:43 +00:00
parent 27ef7f0e1e
commit 6fc09d956f
6 changed files with 153 additions and 65 deletions

View File

@@ -2,10 +2,15 @@
# You can use MONARCH_TOKEN (recommended) OR Email/Password # You can use MONARCH_TOKEN (recommended) OR Email/Password
MONARCH_TOKEN= MONARCH_TOKEN=
# Fallback credentials # Credentials for automatic re-authentication (required for token refresh)
MONARCH_EMAIL= MONARCH_EMAIL=
MONARCH_PASSWORD= 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 # Server Configuration
PORT=8000 PORT=8000
MONARCH_PORT=8070 MONARCH_PORT=8070

41
PLAN.md Normal file
View File

@@ -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.

View File

@@ -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

View File

@@ -5,7 +5,6 @@ Saves session securely and provides the token for Docker environment.
""" """
import asyncio import asyncio
import os
import getpass import getpass
import sys import sys
from pathlib import Path from pathlib import Path
@@ -53,6 +52,21 @@ async def main():
# Also save to local keyring for convenience # Also save to local keyring for convenience
save_token(token) save_token(token)
print("\n✅ Token also saved to local system keyring.") 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: else:
print("❌ Failed to retrieve token from MonarchMoney instance.") print("❌ Failed to retrieve token from MonarchMoney instance.")

View File

@@ -6,10 +6,14 @@ Prioritizes environment variables for Docker compatibility.
import os import os
import logging import logging
from typing import Optional from typing import Optional
from monarchmoney import MonarchMoney from functools import wraps
from monarchmoney import MonarchMoney, LoginFailedException, RequestFailedException
import pyotp
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_client_instance: Optional[MonarchMoney] = None
def load_token() -> Optional[str]: def load_token() -> Optional[str]:
""" """
@@ -43,28 +47,93 @@ async def get_authenticated_client() -> MonarchMoney:
Returns an authenticated MonarchMoney client. Returns an authenticated MonarchMoney client.
Raises RuntimeError if no authentication is found. Raises RuntimeError if no authentication is found.
""" """
global _client_instance
if _client_instance:
return _client_instance
token = load_token() token = load_token()
if token: if token:
try: try:
# The monarchmoney library supports passing the token directly _client_instance = MonarchMoney(token=token)
return MonarchMoney(token=token) return _client_instance
except Exception as e: except Exception as e:
logger.error(f"❌ Failed to initialize MonarchMoney with token: {e}") logger.error(f"❌ Failed to initialize MonarchMoney with token: {e}")
raise 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( raise RuntimeError(
"🔐 Authentication required. Please provide MONARCH_TOKEN or run login_setup.py" "🔐 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

View File

@@ -2,7 +2,6 @@
Monarch Money MCP Server - Custom SSE Implementation. Monarch Money MCP Server - Custom SSE Implementation.
""" """
import os
import logging import logging
import json import json
from typing import Optional, Any from typing import Optional, Any
@@ -13,7 +12,7 @@ from starlette.applications import Starlette
from starlette.responses import JSONResponse from starlette.responses import JSONResponse
from starlette.routing import Route, Mount 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 environment variables
load_dotenv() load_dotenv()
@@ -39,6 +38,7 @@ def serialize_json(data: Any) -> str:
@mcp.tool() @mcp.tool()
@retry_on_auth_error()
async def get_accounts(reason: Optional[str] = None) -> str: async def get_accounts(reason: Optional[str] = None) -> str:
"""Get all financial accounts from Monarch Money.""" """Get all financial accounts from Monarch Money."""
try: try:
@@ -64,6 +64,7 @@ async def get_accounts(reason: Optional[str] = None) -> str:
@mcp.tool() @mcp.tool()
@retry_on_auth_error()
async def get_transactions( async def get_transactions(
limit: int = 50, limit: int = 50,
offset: int = 0, offset: int = 0,
@@ -112,6 +113,7 @@ async def get_transactions(
@mcp.tool() @mcp.tool()
@retry_on_auth_error()
async def get_budgets(reason: Optional[str] = None) -> str: async def get_budgets(reason: Optional[str] = None) -> str:
"""Get current budget information.""" """Get current budget information."""
try: try:
@@ -137,6 +139,7 @@ async def get_budgets(reason: Optional[str] = None) -> str:
@mcp.tool() @mcp.tool()
@retry_on_auth_error()
async def get_account_holdings(account_id: str, reason: Optional[str] = None) -> str: async def get_account_holdings(account_id: str, reason: Optional[str] = None) -> str:
"""Get investment holdings for a specific account.""" """Get investment holdings for a specific account."""
try: try:
@@ -150,6 +153,7 @@ async def get_account_holdings(account_id: str, reason: Optional[str] = None) ->
@mcp.tool() @mcp.tool()
@retry_on_auth_error()
async def refresh_accounts(reason: Optional[str] = None) -> str: async def refresh_accounts(reason: Optional[str] = None) -> str:
"""Request a refresh of account data from financial institutions.""" """Request a refresh of account data from financial institutions."""
try: try: