feat: add automatic re-authentication with MFA support
All checks were successful
Build and Push Monarch MCP Docker Image / build (push) Successful in 8s
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:
@@ -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
|
||||
|
||||
41
PLAN.md
Normal file
41
PLAN.md
Normal 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.
|
||||
45
PROBLEM.md
45
PROBLEM.md
@@ -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
|
||||
@@ -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.")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user