Compare commits

...

36 Commits

Author SHA1 Message Date
d704f5b82b docs: add internal deployment guide, remove temp debug files
All checks were successful
Build and Push Monarch MCP Docker Image / build (push) Successful in 11s
- Delete BUG-REPORT.md (bug resolved)
- Delete MonarchAPI.md (research complete, library chosen)
- Add .deployment.md with Komodo stack ops, auth notes, and troubleshooting
2026-05-05 03:27:20 +00:00
9a6a0ac700 fix: switch to monarchmoneycommunity library (v1.3.2)
All checks were successful
Build and Push Monarch MCP Docker Image / build (push) Successful in 19s
Replaces the abandoned monarchmoney (hammem) with the actively maintained
monarchmoneycommunity fork (bradleyseanf). This resolves the API domain
migration issue where api.monarchmoney.com 301-redirects to api.monarch.com,
causing Authorization header stripping and 401 errors.

- Update dependency: monarchmoney>=0.1.15 -> monarchmoneycommunity>=1.3.2
- Update gql pin: >=3.4,<4.0 -> >=4.0,<5.0 (required by community fork)
- Remove runtime BASE_URL patch (no longer needed, fork has correct URL)
2026-05-05 03:21:03 +00:00
44b2f553d6 debug: log URL before API call
All checks were successful
Build and Push Monarch MCP Docker Image / build (push) Successful in 20s
2026-05-05 02:59:31 +00:00
1eb8eaacb2 enhance: add package location and BASE_URL verification to diagnostics
All checks were successful
Build and Push Monarch MCP Docker Image / build (push) Successful in 19s
2026-05-05 02:58:03 +00:00
157645b64d feat: add startup diagnostics for debugging environment and library version
All checks were successful
Build and Push Monarch MCP Docker Image / build (push) Successful in 20s
2026-05-05 02:54:38 +00:00
6ef77b58ae fix: handle rate limiting with backoff, fix auth error detection
All checks were successful
Build and Push Monarch MCP Docker Image / build (push) Successful in 22s
2026-05-05 02:43:23 +00:00
77656c9925 fix: add error handling to api_call for better debugging
All checks were successful
Build and Push Monarch MCP Docker Image / build (push) Successful in 20s
2026-05-05 02:38:08 +00:00
b10de36c17 fix: pin gql to 3.x for monarchmoney compatibility
All checks were successful
Build and Push Monarch MCP Docker Image / build (push) Successful in 21s
2026-05-05 02:37:08 +00:00
efddb0452b fix: use set_token() instead of token= constructor arg
All checks were successful
Build and Push Monarch MCP Docker Image / build (push) Successful in 21s
MonarchMoney constructor no longer accepts token= kwarg, use set_token() instead
2026-05-05 02:36:27 +00:00
7a7140c76c feat: add api_call pass-through tool and API reference resource
All checks were successful
Build and Push Monarch MCP Docker Image / build (push) Successful in 46s
- Add single pass-through tool following blueprint 'escape hatch' pattern
- Add monarch://api-reference resource with available methods documentation
- Fix dependency: use monarchmoney>=0.1.15 instead of monarchmoneycommunity
- Add JSON error handling for malformed params
2026-05-05 02:32:32 +00:00
b0b3030033 fix: switch to monarchmoneycommunity for 2026 domain migration
All checks were successful
Build and Push Monarch MCP Docker Image / build (push) Successful in 1m12s
2026-04-23 21:59:51 +00:00
Ben
545d48bd25 fix: install git in Docker builder for git-based dependencies
All checks were successful
Build and Push Monarch MCP Docker Image / build (push) Successful in 26s
2025-12-25 04:35:58 +00:00
Ben
4382b02450 fix: use monarchmoney from git main to get budget fix (#119)
Some checks failed
Build and Push Monarch MCP Docker Image / build (push) Failing after 8s
Since no release has been published in ~11 months, install monarchmoney
directly from git main branch which contains the fix for flexible budgets.
Also updates gql to >=4.0 as required by the main branch.
2025-12-25 04:35:06 +00:00
Ben
52f7a746f9 fix: disable get_budgets tool due to upstream Monarch Money API bug
All checks were successful
Build and Push Monarch MCP Docker Image / build (push) Successful in 17s
The Monarch Money API incorrectly processes @include(if: false) GraphQL
directives, causing errors on goals-related fields. Tool commented out
until upstream issue is resolved. See budget_bug.md for details.
2025-12-25 04:28:19 +00:00
Ben
bb38e2441d fix: disable goals queries in get_budgets to work around Monarch API errors
All checks were successful
Build and Push Monarch MCP Docker Image / build (push) Successful in 16s
The Monarch Money API returns errors for goals-related GraphQL fields
(lines 119-131 in query). Setting use_legacy_goals=False and use_v2_goals=False
skips these problematic fields while still returning budget data.
2025-12-25 04:24:52 +00:00
Ben
a229537599 fix: add graceful error handling for get_budgets API errors
All checks were successful
Build and Push Monarch MCP Docker Image / build (push) Successful in 16s
Returns informative error message when Monarch Money API fails,
which may occur if budgets are not configured in the account.
2025-12-25 04:20:39 +00:00
Ben
4a309cbfb3 fix: update get_budgets to match actual monarchmoney API response structure
All checks were successful
Build and Push Monarch MCP Docker Image / build (push) Successful in 17s
The monarchmoney library's get_budgets() returns budgetData.monthlyAmountsByCategory
and categoryGroups, not a simple 'budgets' array. Updated parsing to correctly
extract budget data by category with monthly planned/actual/remaining amounts.
2025-12-25 04:17:48 +00:00
Ben
88bf8a60d5 fix: improve input validation, error logging, and env var handling
All checks were successful
Build and Push Monarch MCP Docker Image / build (push) Successful in 30s
- Add validate_account_id() for get_account_holdings input validation
- Fix double logging bug in retry_on_auth_error decorator
- Remove emojis from log messages for cleaner log parsing
- Make PORT and LOG_LEVEL environment variables functional
- Delete redundant requirements.txt (pyproject.toml is authoritative)
- Clarify MONARCH_PORT is for Docker Compose only in .env.example
2025-12-25 04:11:03 +00:00
Ben
e462c31907 fix: remove try-except blocks from MCP tools to allow auth-retry decorator to work
All checks were successful
Build and Push Monarch MCP Docker Image / build (push) Successful in 16s
2025-12-24 21:48:34 +00:00
Ben
1e8d484389 fix: copy src directory before pip install
All checks were successful
Build and Push Monarch MCP Docker Image / build (push) Successful in 16s
Dockerfile was failing because we tried to install in editable mode
before copying the src directory. Copy src/ first so that uv can
find the package structure properly.
2025-12-24 21:42:24 +00:00
Ben
1d2bf199e1 fix: update Dockerfile to use pyproject.toml for dependencies
Some checks failed
Build and Push Monarch MCP Docker Image / build (push) Failing after 10s
Change from requirements.txt to pyproject.toml as source of truth for
dependencies. This ensures pyotp is properly installed in the Docker image
when built. Using 'uv pip install -e .' will install the package
with all dependencies from pyproject.toml.
2025-12-24 21:41:04 +00:00
Ben
6fc09d956f 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
2025-12-24 15:45:43 +00:00
Ben
27ef7f0e1e fix: add optional reason parameter to all tools for MCP compatibility
All checks were successful
Build and Push Monarch MCP Docker Image / build (push) Successful in 10s
2025-12-24 05:20:30 +00:00
Ben
1210cbf6d2 fix: use correct Starlette mount pattern for MCP SSE routing
All checks were successful
Build and Push Monarch MCP Docker Image / build (push) Successful in 8s
The previous implementation added health route directly to mcp_app and
returned it, which broke MCP's internal /mcp endpoint routing.

Now matches the working pattern from komodo-mcp-custom:
- Mount mcp_app at / inside a parent Starlette app
- Pass lifespan=mcp_app.lifespan for proper task group init
- Health check is a separate route in the parent app
2025-12-24 05:10:32 +00:00
Ben
65c79efc60 refactor: use simplified FastMCP app pattern with health check
All checks were successful
Build and Push Monarch MCP Docker Image / build (push) Successful in 8s
2025-12-24 05:03:05 +00:00
Ben
92fa2c3f11 refactor: mount FastMCP app at root and retain /health for compatibility
All checks were successful
Build and Push Monarch MCP Docker Image / build (push) Successful in 7s
2025-12-24 04:56:33 +00:00
Ben
ebb571a7a6 refactor: mount FastMCP app at /mcp and re-add /health to root for compatibility
All checks were successful
Build and Push Monarch MCP Docker Image / build (push) Successful in 8s
2025-12-24 04:55:05 +00:00
Ben
df492c8bb4 refactor: simplify ASGI app setup by mounting FastMCP app at root
All checks were successful
Build and Push Monarch MCP Docker Image / build (push) Successful in 7s
2025-12-24 04:52:24 +00:00
Ben
8fc4312685 refactor: mount FastMCP app at / and remove health_check from root to avoid overlap
Some checks failed
Build and Push Monarch MCP Docker Image / build (push) Has been cancelled
2025-12-24 04:52:17 +00:00
Ben
748cc5c711 fix: mount FastMCP app at /mcp to correctly route SSE traffic
All checks were successful
Build and Push Monarch MCP Docker Image / build (push) Successful in 7s
2025-12-24 04:50:48 +00:00
Ben
75ba3433e8 refactor: use FastMCP.http_app() for robust SSE and health check support
All checks were successful
Build and Push Monarch MCP Docker Image / build (push) Successful in 7s
2025-12-24 04:48:18 +00:00
Ben
177832053f fix: refactor handle_sse to standard ASGI to avoid Starlette TypeError
All checks were successful
Build and Push Monarch MCP Docker Image / build (push) Successful in 8s
2025-12-24 04:43:06 +00:00
Ben
776b4b8cc8 fix: use correct underlying server attribute for SSE handling in FastMCP
All checks were successful
Build and Push Monarch MCP Docker Image / build (push) Successful in 9s
2025-12-24 04:41:06 +00:00
Ben
f5a5bb3fc4 fix: use correct internal lifespan attribute for FastMCP
All checks were successful
Build and Push Monarch MCP Docker Image / build (push) Successful in 8s
2025-12-24 02:09:53 +00:00
Ben
14db8e67b3 fix: add missing fastmcp dependency
All checks were successful
Build and Push Monarch MCP Docker Image / build (push) Successful in 16s
2025-12-24 02:09:01 +00:00
Ben
8ca4eae736 docs: add MONARCH_PORT to .env.example
All checks were successful
Build and Push Monarch MCP Docker Image / build (push) Successful in 7s
2025-12-24 02:05:13 +00:00
15 changed files with 21557 additions and 249 deletions

207
.deployment.md Normal file
View File

@@ -0,0 +1,207 @@
# Monarch MCP Custom — Deployment Guide
> **Internal / Private.** Not for public distribution. This doc covers how the local Komodo deployment works and how to operate it.
---
## Architecture
```
Git push to main
|
v
Gitea Actions (.gitea/workflows/build.yaml)
|
v
Docker build + push to gitea.ext.ben.io/b3nw/monarch-mcp-custom:latest
|
v
Komodo Stack "monarch-mcp" pulls image and deploys
|
v
Container runs on host port 8070 (mapped to container 8000)
```
---
## Gitea Build Workflow
**File:** `.gitea/workflows/build.yaml`
Triggers on every push to `main` or `master`:
1. Checks out the repo
2. Logs into `gitea.ext.ben.io` container registry (uses `secrets.CR_PAT`)
3. Builds and pushes `gitea.ext.ben.io/b3nw/monarch-mcp-custom:latest`
### Manual Build Trigger
If you need to force a rebuild without a code change, push an empty commit:
```bash
git commit --allow-empty -m "chore: trigger rebuild"
git push origin main
```
Or edit `.gitea/workflows/build.yaml` and push.
---
## Komodo Stack Deployment
**Stack Name:** `monarch-mcp`
**Server:** `6942f06939bbe4eb5a9f21aa` (main Docker host)
**Image:** `gitea.ext.ben.io/b3nw/monarch-mcp-custom:latest`
**Host Port:** `8070` → Container `8000`
### Environment Variables (from `.env`)
| Variable | Purpose | Source |
|---|---|---|
| `MONARCH_TOKEN` | Session token for Monarch Money API | Extract from browser DevTools |
| `MONARCH_EMAIL` | Account email for re-auth fallback | Plaintext |
| `MONARCH_PASSWORD` | Account password for re-auth fallback | Plaintext |
| `MONARCH_MFA_SECRET` | TOTP secret for re-auth fallback | Plaintext (from 2FA setup) |
| `MONARCH_PORT` | Host port mapping (default 8070) | `.env` |
| `LOG_LEVEL` | Python logging level (INFO/DEBUG) | `.env` |
### How to Redeploy
**Via Komodo UI:**
1. Navigate to Stacks → monarch-mcp
2. Click **Deploy** (or **Pull** then **Deploy** to force fresh image)
**Via MCP / API:**
```python
# Using the komodo_mcp tool
DeployStack(stack="monarch-mcp")
```
**Force fresh image pull:**
The stack has `auto_pull: true`, so a standard Deploy should pull the latest image. If not, use **Pull Stack** first.
---
## Container Operations
### Check Logs
**Via Komodo UI:**
Stack → monarch-mcp → Logs → Select service `monarch-mcp`
**Via Komodo API:**
```python
GetStackLog(stack="monarch-mcp", services=["monarch-mcp"], tail=50)
```
### Health Check
The container exposes a health endpoint:
```bash
curl http://localhost:8070/health
# Expected: {"status": "ok"}
```
Docker compose healthcheck config (in `compose.yaml`):
- Interval: 30s
- Timeout: 10s
- Retries: 3
- Start period: 10s
---
## Local Development
### Install Dependencies
```bash
uv sync
```
### Run Locally
```bash
uv run python src/monarch_mcp_custom/server.py
# or
python -m monarch_mcp_custom.server
```
### Test the Library Directly
```bash
uv run python -c "
from monarchmoney import MonarchMoney, MonarchMoneyEndpoints
print(MonarchMoneyEndpoints.BASE_URL)
"
```
---
## Authentication Notes
### Token Source
The `MONARCH_TOKEN` is a session token extracted from browser DevTools:
1. Log into Monarch Money at `https://app.monarch.com`
2. Open DevTools → Network → filter by `graphql`
3. Click any request → Headers → `Authorization: Token <value>`
4. Copy the token value (without `Token ` prefix)
### Token Lifetime
Tokens appear to last several months. If API calls start returning 401:
1. Extract a fresh token from the browser
2. Update the `MONARCH_TOKEN` env var in Komodo
3. Redeploy the stack
### Re-Authentication Fallback
If token auth fails, the MCP server will attempt to re-authenticate using `MONARCH_EMAIL`, `MONARCH_PASSWORD`, and `MONARCH_MFA_SECRET`. This is subject to rate limiting by Monarch.
---
## Troubleshooting
### 401 Unauthorized
- Token expired? Extract fresh token from browser.
- Check `MONARCH_TOKEN` is set correctly in Komodo stack env.
- Verify library version in startup logs (should show `monarchmoney 1.3.2+` and `BASE_URL: https://api.monarch.com`).
### 429 Rate Limited
- Monarch Money rate-limits login attempts.
- Wait ~15-60 seconds before retrying.
- The retry decorator handles 429 with a 15s backoff.
### Image Not Updating
- Check Gitea Actions for build failures.
- Force a **Pull Stack** in Komodo before deploying.
- Verify the commit hash in Komodo matches the latest.
### Build Fails
- Check `.gitea/workflows/build.yaml` logs in Gitea.
- Ensure `secrets.CR_PAT` is valid for `gitea.ext.ben.io`.
---
## Useful Commands
```bash
# Quick health check
curl -s http://localhost:8070/health | jq .
# Check running container
docker ps | grep monarch-mcp
# Inspect container logs directly on host
docker logs monarch-mcp-custom --tail 50 -f
# Restart container
docker restart monarch-mcp-custom
```
---
## Related Resources
- **Repo:** `git.local.ben.io:b3nw/monarch-mcp-custom.git`
- **Container Registry:** `gitea.ext.ben.io/b3nw/monarch-mcp-custom`
- **Komodo Stack:** `monarch-mcp` (ID: `694b492b6e2254e6ad636e9b`)
- **Upstream Library:** https://github.com/bradleyseanf/monarchmoneycommunity

View File

@@ -2,10 +2,16 @@
# 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 # Docker Compose host port mapping only
LOG_LEVEL=INFO

View File

@@ -3,14 +3,19 @@ FROM python:3.12-slim AS builder
WORKDIR /app
# Install uv for fast dependency management
RUN pip install uv
# Install git (required for git-based dependencies) and uv
RUN apt-get update && apt-get install -y --no-install-recommends git \
&& rm -rf /var/lib/apt/lists/* \
&& pip install uv
# Copy only requirements first to leverage Docker cache
COPY requirements.txt .
# Copy pyproject.toml first to leverage Docker cache
COPY pyproject.toml .
# Install dependencies into the system python environment of the builder
RUN uv pip install --system -r requirements.txt
# Copy source directory for editable install
COPY src/ ./src/
# Install dependencies into system python environment of builder
RUN uv pip install --system .
# Stage 2: Final Image
FROM python:3.12-slim
@@ -31,11 +36,11 @@ COPY src/ ./src/
COPY pyproject.toml .
COPY README.md .
# Set Python path to find the package
# Set Python path to find package
ENV PYTHONPATH=/app/src
# Default port
EXPOSE 8000
# Run the server
# Run server
CMD ["python", "src/monarch_mcp_custom/server.py"]

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

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

View File

@@ -18,12 +18,14 @@ classifiers = [
requires-python = ">=3.12"
dependencies = [
"mcp[cli]>=1.0.0",
"monarchmoney>=0.1.15",
"gql>=3.4,<4.0",
"fastmcp>=0.4.1",
"monarchmoneycommunity>=1.3.2",
"gql>=4.0,<5.0",
"python-dotenv>=1.0.0",
"pydantic>=2.0.0",
"starlette>=0.35.0",
"uvicorn>=0.27.0",
"pyotp>=2.9.0",
]
[project.scripts]

View File

@@ -1,7 +0,0 @@
mcp[cli]>=1.0.0
monarchmoney>=0.1.15
gql>=3.4,<4.0
python-dotenv>=1.0.0
pydantic>=2.0.0
starlette>=0.35.0
uvicorn>=0.27.0

20056
ses_4b1fce60bffecsLKNuZh1R971c Normal file

File diff suppressed because one or more lines are too long

View File

@@ -9,13 +9,14 @@ Classifier: Programming Language :: Python :: 3.12
Requires-Python: >=3.12
Description-Content-Type: text/markdown
Requires-Dist: mcp[cli]>=1.0.0
Requires-Dist: fastmcp>=0.4.1
Requires-Dist: monarchmoney>=0.1.15
Requires-Dist: gql<4.0,>=3.4
Requires-Dist: keyring>=24.0.0
Requires-Dist: python-dotenv>=1.0.0
Requires-Dist: pydantic>=2.0.0
Requires-Dist: starlette>=0.35.0
Requires-Dist: uvicorn>=0.27.0
Requires-Dist: pyotp>=2.9.0
# Monarch Money Custom MCP Server
@@ -49,7 +50,7 @@ docker-compose up -d
## 🔌 Connection
The server will be available at:
- **SSE Endpoint**: `http://localhost:8000/mcp/sse`
- **MCP Endpoint**: `http://localhost:8000/mcp`
- **Health Check**: `http://localhost:8000/health`
## 🛠️ Tools Included

View File

@@ -1,8 +1,9 @@
mcp[cli]>=1.0.0
fastmcp>=0.4.1
monarchmoney>=0.1.15
gql<4.0,>=3.4
keyring>=24.0.0
python-dotenv>=1.0.0
pydantic>=2.0.0
starlette>=0.35.0
uvicorn>=0.27.0
pyotp>=2.9.0

View File

@@ -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]:
"""
@@ -19,7 +23,7 @@ def load_token() -> Optional[str]:
# 1. Check environment variable (Best for Docker)
token = os.getenv("MONARCH_TOKEN")
if token:
logger.info("Token loaded from MONARCH_TOKEN environment variable")
logger.info("Token loaded from MONARCH_TOKEN environment variable")
return token
return None
@@ -33,9 +37,9 @@ def save_token(token: str) -> None:
KEYRING_SERVICE = "com.mcp.monarch-mcp-server"
KEYRING_USERNAME = "monarch-token"
keyring.set_password(KEYRING_SERVICE, KEYRING_USERNAME, token)
logger.info("Token saved securely to keyring")
logger.info("Token saved securely to keyring")
except Exception as e:
logger.warning(f"⚠️ Failed to save token to keyring (non-fatal): {e}")
logger.warning(f"Failed to save token to keyring (non-fatal): {e}")
async def get_authenticated_client() -> MonarchMoney:
@@ -43,28 +47,104 @@ 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}")
logger.error(f"Failed to initialize MonarchMoney with token: {e}")
raise
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, LoginFailedException)
)
is_rate_limit = "429" in error_str or "rate limit" in error_str
if is_rate_limit and attempt < max_retries:
import asyncio
logger.warning(f"Rate limited in {func.__name__}, waiting 15s before retry...")
await asyncio.sleep(15)
continue
if is_auth_error and attempt < max_retries:
logger.warning(
f"Authentication failed in {func.__name__}, refreshing token... "
f"(attempt {attempt + 1}/{max_retries + 1})"
)
await refresh_authentication()
continue
# Only log error for non-auth errors (auth errors were already logged as warnings)
if not is_auth_error:
logger.error(f"Request failed in {func.__name__}: {e}")
raise
return wrapper
return decorator

View File

@@ -2,32 +2,67 @@
Monarch Money MCP Server - Custom SSE Implementation.
"""
import os
import logging
import json
import asyncio
from typing import Optional, List, Dict, Any
from datetime import datetime
import os
from typing import Optional, Any
from dotenv import load_dotenv
from fastmcp import FastMCP
from starlette.applications import Starlette
from starlette.responses import JSONResponse
from starlette.routing import Route
from mcp.server.sse import SseServerTransport
import uvicorn
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()
# Configure logging
# Configure logging with LOG_LEVEL from environment
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
level=getattr(logging, log_level, logging.INFO),
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger(__name__)
def run_startup_diagnostics():
"""Print startup diagnostics for debugging."""
import monarchmoney
import monarchmoney.monarchmoney as mm_lib
from monarchmoney import MonarchMoneyEndpoints
print("\n" + "=" * 50)
print("Monarch MCP Server - Startup Diagnostics")
print("=" * 50)
print(f"\n📦 Library Version: monarchmoney {monarchmoney.__version__}")
print(f"📍 Package Location: {monarchmoney.__file__}")
print(f"\n🔗 API Endpoints:")
print(f" GraphQL: {MonarchMoneyEndpoints.getGraphQL()}")
print(f" Login: {MonarchMoneyEndpoints.getLoginEndpoint()}")
# Verify the actual BASE_URL in the source
print(f"\n🔍 BASE_URL from source: {mm_lib.MonarchMoneyEndpoints.BASE_URL}")
print(f"\n🔐 Environment Variables:")
env_vars = ['MONARCH_TOKEN', 'MONARCH_EMAIL', 'MONARCH_PASSWORD', 'MONARCH_MFA_SECRET']
for var in env_vars:
value = os.getenv(var)
if value:
masked = value[:8] + "..." if len(value) > 8 else "***"
print(f" {var}: ✓ ({masked})")
else:
print(f" {var}: ✗ (not set or empty)")
print("\n" + "=" * 50 + "\n")
# Run startup diagnostics
run_startup_diagnostics()
# Initialize FastMCP
mcp = FastMCP("Monarch Money Custom")
@@ -43,31 +78,29 @@ def serialize_json(data: Any) -> str:
@mcp.tool()
async def get_accounts() -> str:
@retry_on_auth_error()
async def get_accounts(reason: Optional[str] = None) -> str:
"""Get all financial accounts from Monarch Money."""
try:
client = await get_authenticated_client()
accounts = await client.get_accounts()
client = await get_authenticated_client()
accounts = await client.get_accounts()
account_list = []
for account in accounts.get("accounts", []):
account_info = {
"id": account.get("id"),
"name": account.get("displayName") or account.get("name"),
"type": (account.get("type") or {}).get("name"),
"balance": account.get("currentBalance"),
"institution": (account.get("institution") or {}).get("name"),
"is_active": not account.get("deactivatedAt"),
}
account_list.append(account_info)
account_list = []
for account in accounts.get("accounts", []):
account_info = {
"id": account.get("id"),
"name": account.get("displayName") or account.get("name"),
"type": (account.get("type") or {}).get("name"),
"balance": account.get("currentBalance"),
"institution": (account.get("institution") or {}).get("name"),
"is_active": not account.get("deactivatedAt"),
}
account_list.append(account_info)
return serialize_json(account_list)
except Exception as e:
logger.error(f"Failed to get accounts: {e}")
return f"Error: {str(e)}"
return serialize_json(account_list)
@mcp.tool()
@retry_on_auth_error()
async def get_transactions(
limit: int = 50,
offset: int = 0,
@@ -79,138 +112,241 @@ async def get_transactions(
Get transactions from Monarch Money.
Dates should be in YYYY-MM-DD format.
"""
try:
client = await get_authenticated_client()
filters = {}
if start_date:
filters["start_date"] = start_date
if end_date:
filters["end_date"] = end_date
if account_id:
filters["account_id"] = account_id
client = await get_authenticated_client()
filters = {}
if start_date:
filters["start_date"] = start_date
if end_date:
filters["end_date"] = end_date
if account_id:
filters["account_id"] = account_id
transactions = await client.get_transactions(
limit=limit, offset=offset, **filters
transactions = await client.get_transactions(limit=limit, offset=offset, **filters)
results = transactions.get("allTransactions", {}).get("results", [])
formatted = []
for txn in results:
formatted.append(
{
"id": txn.get("id"),
"date": txn.get("date"),
"amount": txn.get("amount"),
"description": txn.get("description"),
"category": (txn.get("category") or {}).get("name"),
"account": (txn.get("account") or {}).get("displayName"),
"merchant": (txn.get("merchant") or {}).get("name"),
"is_pending": txn.get("isPending", False),
}
)
results = transactions.get("allTransactions", {}).get("results", [])
formatted = []
for txn in results:
formatted.append(
{
"id": txn.get("id"),
"date": txn.get("date"),
"amount": txn.get("amount"),
"description": txn.get("description"),
"category": (txn.get("category") or {}).get("name"),
"account": (txn.get("account") or {}).get("displayName"),
"merchant": (txn.get("merchant") or {}).get("name"),
"is_pending": txn.get("isPending", False),
}
)
return serialize_json(formatted)
except Exception as e:
logger.error(f"Failed to get transactions: {e}")
return f"Error: {str(e)}"
return serialize_json(formatted)
@mcp.tool()
async def get_budgets() -> str:
@retry_on_auth_error()
async def get_budgets(reason: Optional[str] = None) -> str:
"""Get current budget information."""
try:
client = await get_authenticated_client()
budgets = await client.get_budgets()
client = await get_authenticated_client()
budgets = await client.get_budgets()
budget_list = []
for b in budgets.get("budgets", []):
# Build a category lookup from categoryGroups
category_lookup = {}
for group in budgets.get("categoryGroups", []):
for cat in group.get("categories", []):
category_lookup[cat.get("id")] = {
"name": cat.get("name"),
"group": group.get("name"),
"variability": cat.get("budgetVariability"),
}
# Process monthly amounts by category
budget_list = []
for item in budgets.get("budgetData", {}).get("monthlyAmountsByCategory", []):
cat_id = (item.get("category") or {}).get("id")
cat_info = category_lookup.get(cat_id, {})
for monthly in item.get("monthlyAmounts", []):
budget_list.append(
{
"name": b.get("name"),
"amount": b.get("amount"),
"spent": b.get("spent"),
"remaining": b.get("remaining"),
"category": (b.get("category") or {}).get("name"),
"month": monthly.get("month"),
"category": cat_info.get("name"),
"group": cat_info.get("group"),
"planned": monthly.get("plannedCashFlowAmount"),
"actual": monthly.get("actualAmount"),
"remaining": monthly.get("remainingAmount"),
"rollover": monthly.get("previousMonthRolloverAmount"),
}
)
return serialize_json(budget_list)
except Exception as e:
logger.error(f"Failed to get budgets: {e}")
return f"Error: {str(e)}"
# Also include monthly totals summary
totals = []
for total in budgets.get("budgetData", {}).get("totalsByMonth", []):
totals.append(
{
"month": total.get("month"),
"income_planned": (total.get("totalIncome") or {}).get("plannedAmount"),
"income_actual": (total.get("totalIncome") or {}).get("actualAmount"),
"expenses_planned": (total.get("totalExpenses") or {}).get(
"plannedAmount"
),
"expenses_actual": (total.get("totalExpenses") or {}).get(
"actualAmount"
),
}
)
return serialize_json({"budgets": budget_list, "totals": totals})
def validate_account_id(account_id: str) -> int:
"""Validate and convert account_id to integer."""
if not account_id or not account_id.strip():
raise ValueError("account_id must be provided and cannot be empty")
try:
return int(account_id.strip())
except (ValueError, TypeError):
raise ValueError("account_id must be a valid integer")
@mcp.tool()
async def get_account_holdings(account_id: str) -> str:
@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:
client = await get_authenticated_client()
# The library expects an int for account_id
holdings = await client.get_account_holdings(int(account_id))
return serialize_json(holdings)
except Exception as e:
logger.error(f"Failed to get holdings: {e}")
return f"Error: {str(e)}"
validated_id = validate_account_id(account_id)
client = await get_authenticated_client()
holdings = await client.get_account_holdings(validated_id)
return serialize_json(holdings)
@mcp.tool()
async def refresh_accounts() -> str:
@retry_on_auth_error()
async def refresh_accounts(reason: Optional[str] = None) -> str:
"""Request a refresh of account data from financial institutions."""
client = await get_authenticated_client()
# Request refresh for all accounts (empty list often means all in this library)
result = await client.request_accounts_refresh([])
return serialize_json(result)
# --- API Reference Resource ---
API_REFERENCE = """
# Monarch Money API Reference
Full method signatures: https://github.com/hammem/monarchmoney
## Transaction Operations
- get_transactions(limit=100, offset=0, start_date=None, end_date=None, search='', category_ids=[], account_ids=[])
- get_transaction_splits(transaction_id: str)
- update_transaction_splits(transaction_id: str, split_data: List[Dict])
- create_transaction(date, account_id, amount, merchant_name, category_id, notes='', update_balance=False)
- update_transaction(transaction_id, category_id=None, merchant_name=None, amount=None, date=None, notes=None)
- delete_transaction(transaction_id: str)
- get_transaction_details(transaction_id: str)
## Categories & Tags
- get_transaction_categories()
- get_transaction_category_groups()
- create_transaction_category(group_id, transaction_category_name, ...)
- create_transaction_tag(name, color)
- set_transaction_tags(transaction_id, tag_ids)
- get_transaction_tags()
## Accounts
- get_accounts()
- get_account_holdings(account_id: int)
- create_manual_account(account_type, account_sub_type, is_in_net_worth, account_name, account_balance=0)
- update_account(account_id, account_name=None, account_balance=None, ...)
## Budget & Cashflow
- get_budgets(start_date=None, end_date=None)
- get_cashflow(limit=100, start_date=None, end_date=None)
- get_recurring_transactions(start_date=None, end_date=None)
## Sync
- request_accounts_refresh(account_ids: List[str])
- is_accounts_refresh_complete(account_ids=None)
"""
@mcp.resource("monarch://api-reference")
def get_api_docs() -> str:
"""Returns the API documentation for using the 'api_call' tool."""
return API_REFERENCE
# --- Pass-through Tool ---
@mcp.tool()
@retry_on_auth_error()
async def api_call(method: str, params: str = "{}") -> str:
"""
Execute a raw method call to the Monarch Money API.
Args:
method: The method name to call (e.g., 'get_transaction_splits', 'update_transaction_splits')
params: JSON string of parameters to pass to the method
Returns:
JSON result from the API call
Example:
api_call(method="get_transaction_categories", params="{}")
api_call(method="get_transaction_splits", params='{"transaction_id": "12345"}')
api_call(method="update_transaction_splits", params='{"transaction_id": "12345", "split_data": [{"amount": -50.00, "categoryId": "100", "merchantName": "Groceries"}]}')
"""
client = await get_authenticated_client()
method_func = getattr(client, method, None)
if not method_func:
return serialize_json({"error": f"Method '{method}' not found"})
try:
client = await get_authenticated_client()
# Request refresh for all accounts (empty list often means all in this library)
result = await client.request_accounts_refresh([])
parsed_params = json.loads(params) if params else {}
except json.JSONDecodeError as e:
return serialize_json({"error": f"Invalid JSON in params: {e}"})
# Debug: log what URL the client will use
from monarchmoney import MonarchMoneyEndpoints
logger.info(f"api_call: method={method}, GraphQL URL={MonarchMoneyEndpoints.getGraphQL()}")
try:
result = await method_func(**parsed_params)
return serialize_json(result)
except Exception as e:
logger.error(f"Failed to refresh accounts: {e}")
return f"Error: {str(e)}"
logger.error(f"api_call failed for method={method}: {e}")
return serialize_json({"error": str(e), "method": method})
# --- Health Check ---
# --- Health Check Endpoint ---
async def health_check(request):
"""Simple health check endpoint."""
return JSONResponse({"status": "ok", "timestamp": datetime.now().isoformat()})
async def health(request):
"""Health check endpoint for Docker."""
return JSONResponse({"status": "ok"})
# --- ASGI App Setup ---
# --- ASGI Application ---
def create_app():
"""Create the Starlette application with MCP at /mcp."""
# SSE Transport following the "newer http-streamable" convention:
# Stream endpoint at /mcp, messages at /mcp/messages
sse = SseServerTransport("/mcp/messages")
async def handle_sse(request):
async with sse.connect_sse(request.scope, request.receive, request._send) as (
read_stream,
write_stream,
):
await mcp.run(
read_stream,
write_stream,
mcp.create_initialization_options(),
)
"""Create the ASGI application with health check and MCP routes."""
mcp_app = mcp.http_app()
# Wrapper app: /health is standalone, everything else goes to MCP
# IMPORTANT: Must pass mcp_app.lifespan for task group initialization
routes = [
Route("/health", health_check, methods=["GET"]),
Route("/mcp", endpoint=handle_sse, methods=["GET"]),
Route("/mcp/messages", endpoint=sse.handle_post_message, methods=["POST"]),
Route("/health", health, methods=["GET"]),
Mount("/", app=mcp_app), # MCP handles /mcp endpoint
]
return Starlette(routes=routes, lifespan=mcp.lifespan)
return Starlette(routes=routes, lifespan=mcp_app.lifespan)
# Create the app instance for uvicorn
app = create_app()
def main():
"""Entry point for running the server."""
port = int(os.getenv("PORT", 8000))
uvicorn.run(app, host="0.0.0.0", port=port)
if __name__ == "__main__":
main()
import uvicorn
port = int(os.getenv("PORT", "8000"))
uvicorn.run(app, host="0.0.0.0", port=port)

56
test_token.py Executable file
View File

@@ -0,0 +1,56 @@
import asyncio
import os
from monarchmoney import MonarchMoney
from dotenv import load_dotenv
async def test_token():
load_dotenv(override=True)
token = os.getenv("MONARCH_TOKEN")
email = os.getenv("MONARCH_EMAIL")
password = os.getenv("MONARCH_PASSWORD")
if token and token.strip():
# Strip potential prefix
if token.startswith("MONARCH_TOKEN="):
token = token.replace("MONARCH_TOKEN=", "")
print(f"Testing with TOKEN: {token[:10]}...")
mm = MonarchMoney(token=token)
elif email and password:
print(f"Testing with EMAIL: {email}")
mm = MonarchMoney()
try:
await mm.login(email, password)
print("✅ Login successful with email/password!")
except Exception as e:
print(f"❌ Login failed: {e}")
return
else:
print("❌ No credentials found in .env")
return
try:
accounts = await mm.get_accounts()
print(f"Success! Found {len(accounts.get('accounts', []))} accounts.")
except Exception as e:
print(f"Error fetching accounts: {e}")
if __name__ == "__main__":
asyncio.run(test_token())

880
uv.lock generated

File diff suppressed because it is too large Load Diff