Initial commit: Monarch MCP Custom SSE server

This commit is contained in:
Ben
2025-12-24 01:54:42 +00:00
commit 391bee1d1e
21 changed files with 1944 additions and 0 deletions

11
.env Normal file
View File

@@ -0,0 +1,11 @@
# Monarch Money Credentials
# You can use MONARCH_TOKEN (recommended) OR Email/Password
MONARCH_TOKEN=MONARCH_TOKEN=64422c9dec80f6009e89e571a601ccf31488cac0651eae6461be6f78d30fc0db
# Fallback credentials
MONARCH_EMAIL=
MONARCH_PASSWORD=
# Server Configuration
PORT=8100
LOG_LEVEL=INFO

11
.env.example Normal file
View File

@@ -0,0 +1,11 @@
# Monarch Money Credentials
# You can use MONARCH_TOKEN (recommended) OR Email/Password
MONARCH_TOKEN=
# Fallback credentials
MONARCH_EMAIL=
MONARCH_PASSWORD=
# Server Configuration
PORT=8000
LOG_LEVEL=INFO

View File

@@ -0,0 +1,30 @@
name: Build and Push Monarch MCP Docker Image
on:
push:
branches:
- main
- master
jobs:
build:
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Login to Gitea Container Registry
uses: docker/login-action@v2
with:
registry: gitea.ext.ben.io
username: ${{ gitea.actor }}
password: ${{ secrets.CR_PAT }}
- name: Build and Push
uses: docker/build-push-action@v4
with:
context: .
push: true
tags: gitea.ext.ben.io/${{ gitea.repository }}:latest

41
Dockerfile Normal file
View File

@@ -0,0 +1,41 @@
# Stage 1: Builder
FROM python:3.12-slim AS builder
WORKDIR /app
# Install uv for fast dependency management
RUN pip install uv
# Copy only requirements first to leverage Docker cache
COPY requirements.txt .
# Install dependencies into the system python environment of the builder
RUN uv pip install --system -r requirements.txt
# Stage 2: Final Image
FROM python:3.12-slim
WORKDIR /app
# Install curl for health checks
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/*
# Copy installed packages from builder
COPY --from=builder /usr/local/lib/python3.12/site-packages/ /usr/local/lib/python3.12/site-packages/
COPY --from=builder /usr/local/bin/ /usr/local/bin/
# Copy application code
COPY src/ ./src/
COPY pyproject.toml .
COPY README.md .
# Set Python path to find the package
ENV PYTHONPATH=/app/src
# Default port
EXPOSE 8000
# Run the server
CMD ["python", "src/monarch_mcp_custom/server.py"]

41
README.md Normal file
View File

@@ -0,0 +1,41 @@
# Monarch Money Custom MCP Server
A custom Model Context Protocol (MCP) server for Monarch Money, designed for Docker deployment with SSE (Server-Sent Events) support.
## 🚀 Setup
### 1. Obtain Authentication Token
Run the setup script locally to authenticate and generate a token:
```bash
python login_setup.py
```
Follow the prompts to log in. Once successful, copy the `MONARCH_TOKEN` printed in the terminal.
### 2. Configure Environment
Create a `.env` file based on `.env.example` and paste your token:
```bash
cp .env.example .env
# Edit .env and set MONARCH_TOKEN=your_token_here
```
### 3. Deploy with Docker
Start the server using Docker Compose:
```bash
docker-compose up -d
```
## 🔌 Connection
The server will be available at:
- **SSE Endpoint**: `http://localhost:8000/mcp/sse`
- **Health Check**: `http://localhost:8000/health`
## 🛠️ Tools Included
- `get_accounts`: View all financial accounts.
- `get_transactions`: Fetch recent transactions with filtering.
- `get_budgets`: View budget status.
- `get_account_holdings`: Detailed investment holdings.
- `refresh_accounts`: Trigger a refresh of account data.

15
docker-compose.yml Normal file
View File

@@ -0,0 +1,15 @@
services:
monarch-mcp:
image: gitea.ext.ben.io/b3nw/monarch-mcp-custom:latest
container_name: monarch-mcp-custom
ports:
- "8000:8000"
env_file:
- .env
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
restart: unless-stopped

64
login_setup.py Normal file
View File

@@ -0,0 +1,64 @@
#!/usr/bin/env python3
"""
Interactive Monarch Money login with MFA support.
Saves session securely and provides the token for Docker environment.
"""
import asyncio
import os
import getpass
import sys
from pathlib import Path
# Add src to sys.path
sys.path.insert(0, str(Path(__file__).parent / "src"))
from monarchmoney import MonarchMoney, RequireMFAException
from dotenv import load_dotenv
from monarch_mcp_custom.auth import save_token
async def main():
load_dotenv()
print("\n🏦 Monarch Money - Custom MCP Setup")
print("=" * 45)
print("This script will help you obtain an authentication token")
print("for use in your Docker .env file.\n")
mm = MonarchMoney()
try:
email = input("Email: ")
password = getpass.getpass("Password: ")
try:
await mm.login(email, password, use_saved_session=False, save_session=True)
print("✅ Login successful!")
except RequireMFAException:
print("🔐 MFA code required")
mfa_code = input("Two Factor Code: ")
await mm.multi_factor_authenticate(email, password, mfa_code)
print("✅ MFA authentication successful")
token = mm.token
if token:
print("\n" + "!" * 50)
print("🔑 YOUR MONARCH_TOKEN:")
print(f"\n{token}\n")
print("!" * 50)
print("\nCopy the token above into your .env file as:")
print(f"MONARCH_TOKEN={token}")
# Also save to local keyring for convenience
save_token(token)
print("\n✅ Token also saved to local system keyring.")
else:
print("❌ Failed to retrieve token from MonarchMoney instance.")
except Exception as e:
print(f"\n❌ Setup failed: {e}")
if __name__ == "__main__":
asyncio.run(main())

33
pyproject.toml Normal file
View File

@@ -0,0 +1,33 @@
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "monarch-mcp-custom"
version = "0.1.0"
description = "Custom Monarch Money MCP Server with SSE support"
readme = "README.md"
license = { text = "MIT" }
authors = [
{ name = "opencode" }
]
classifiers = [
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.12",
]
requires-python = ">=3.12"
dependencies = [
"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",
]
[project.scripts]
monarch-mcp-custom = "monarch_mcp_custom.server:main"
[tool.setuptools.packages.find]
where = ["src"]

7
requirements.txt Normal file
View File

@@ -0,0 +1,7 @@
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

View File

@@ -0,0 +1,60 @@
Metadata-Version: 2.4
Name: monarch-mcp-custom
Version: 0.1.0
Summary: Custom Monarch Money MCP Server with SSE support
Author: opencode
License: MIT
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Requires-Python: >=3.12
Description-Content-Type: text/markdown
Requires-Dist: mcp[cli]>=1.0.0
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
# Monarch Money Custom MCP Server
A custom Model Context Protocol (MCP) server for Monarch Money, designed for Docker deployment with SSE (Server-Sent Events) support.
## 🚀 Setup
### 1. Obtain Authentication Token
Run the setup script locally to authenticate and generate a token:
```bash
python login_setup.py
```
Follow the prompts to log in. Once successful, copy the `MONARCH_TOKEN` printed in the terminal.
### 2. Configure Environment
Create a `.env` file based on `.env.example` and paste your token:
```bash
cp .env.example .env
# Edit .env and set MONARCH_TOKEN=your_token_here
```
### 3. Deploy with Docker
Start the server using Docker Compose:
```bash
docker-compose up -d
```
## 🔌 Connection
The server will be available at:
- **SSE Endpoint**: `http://localhost:8000/mcp/sse`
- **Health Check**: `http://localhost:8000/health`
## 🛠️ Tools Included
- `get_accounts`: View all financial accounts.
- `get_transactions`: Fetch recent transactions with filtering.
- `get_budgets`: View budget status.
- `get_account_holdings`: Detailed investment holdings.
- `refresh_accounts`: Trigger a refresh of account data.

View File

@@ -0,0 +1,11 @@
README.md
pyproject.toml
src/monarch_mcp_custom/__init__.py
src/monarch_mcp_custom/auth.py
src/monarch_mcp_custom/server.py
src/monarch_mcp_custom.egg-info/PKG-INFO
src/monarch_mcp_custom.egg-info/SOURCES.txt
src/monarch_mcp_custom.egg-info/dependency_links.txt
src/monarch_mcp_custom.egg-info/entry_points.txt
src/monarch_mcp_custom.egg-info/requires.txt
src/monarch_mcp_custom.egg-info/top_level.txt

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,2 @@
[console_scripts]
monarch-mcp-custom = monarch_mcp_custom.server:main

View File

@@ -0,0 +1,8 @@
mcp[cli]>=1.0.0
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

View File

@@ -0,0 +1 @@
monarch_mcp_custom

View File

@@ -0,0 +1 @@
__version__ = "0.1.0"

View File

@@ -0,0 +1,70 @@
"""
Authentication and session management for Monarch Money.
Prioritizes environment variables for Docker compatibility.
"""
import os
import logging
from typing import Optional
from monarchmoney import MonarchMoney
logger = logging.getLogger(__name__)
def load_token() -> Optional[str]:
"""
Loads the authentication token.
Checks MONARCH_TOKEN environment variable first.
"""
# 1. Check environment variable (Best for Docker)
token = os.getenv("MONARCH_TOKEN")
if token:
logger.info("✅ Token loaded from MONARCH_TOKEN environment variable")
return token
return None
def save_token(token: str) -> None:
"""Saves the token to the system keyring if available."""
try:
import keyring
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")
except Exception as e:
logger.warning(f"⚠️ Failed to save token to keyring (non-fatal): {e}")
async def get_authenticated_client() -> MonarchMoney:
"""
Returns an authenticated MonarchMoney client.
Raises RuntimeError if no authentication is found.
"""
token = load_token()
if token:
try:
# The monarchmoney library supports passing the token directly
return MonarchMoney(token=token)
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"
)

View File

@@ -0,0 +1,200 @@
"""
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
from dotenv import load_dotenv
from fastmcp import FastMCP
from starlette.applications import Starlette
from starlette.responses import JSONResponse
from starlette.routing import Route, Mount
import uvicorn
from monarch_mcp_custom.auth import get_authenticated_client
# Load environment variables
load_dotenv()
# Configure logging
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
# Initialize FastMCP
mcp = FastMCP("Monarch Money Custom")
# --- Helpers ---
def serialize_json(data: Any) -> str:
"""Helper to serialize data to JSON safely."""
return json.dumps(data, indent=2, default=str)
# --- MCP Tools ---
@mcp.tool()
async def get_accounts() -> str:
"""Get all financial accounts from Monarch Money."""
try:
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)
return serialize_json(account_list)
except Exception as e:
logger.error(f"Failed to get accounts: {e}")
return f"Error: {str(e)}"
@mcp.tool()
async def get_transactions(
limit: int = 50,
offset: int = 0,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
account_id: Optional[str] = None,
) -> str:
"""
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
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),
}
)
return serialize_json(formatted)
except Exception as e:
logger.error(f"Failed to get transactions: {e}")
return f"Error: {str(e)}"
@mcp.tool()
async def get_budgets() -> str:
"""Get current budget information."""
try:
client = await get_authenticated_client()
budgets = await client.get_budgets()
budget_list = []
for b in budgets.get("budgets", []):
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"),
}
)
return serialize_json(budget_list)
except Exception as e:
logger.error(f"Failed to get budgets: {e}")
return f"Error: {str(e)}"
@mcp.tool()
async def get_account_holdings(account_id: str) -> str:
"""Get investment holdings for a specific account."""
try:
client = await get_authenticated_client()
# Ensure account_id is treated correctly (usually string ID in Monarch)
holdings = await client.get_account_holdings(account_id)
return serialize_json(holdings)
except Exception as e:
logger.error(f"Failed to get holdings: {e}")
return f"Error: {str(e)}"
@mcp.tool()
async def refresh_accounts() -> str:
"""Request a refresh of account data from financial institutions."""
try:
client = await get_authenticated_client()
result = await client.request_accounts_refresh()
return serialize_json(result)
except Exception as e:
logger.error(f"Failed to refresh accounts: {e}")
return f"Error: {str(e)}"
# --- Health Check ---
async def health_check(request):
"""Simple health check endpoint."""
return JSONResponse({"status": "ok", "timestamp": datetime.now().isoformat()})
# --- ASGI App Setup ---
def create_app():
"""Create the Starlette application with MCP mounted at /mcp."""
mcp_app = mcp.http_app()
routes = [
Route("/health", health_check, methods=["GET"]),
Mount("/mcp", app=mcp_app),
]
return Starlette(routes=routes, lifespan=mcp_app.lifespan)
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()

1337
uv.lock generated Normal file

File diff suppressed because it is too large Load Diff