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
|
# 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
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 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.")
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user