""" Monarch Money MCP Server - Custom SSE Implementation. """ import logging import json 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, Mount from monarch_mcp_custom.auth import get_authenticated_client, retry_on_auth_error # Load environment variables load_dotenv() # Configure logging with LOG_LEVEL from environment log_level = os.getenv("LOG_LEVEL", "INFO").upper() logging.basicConfig( 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") # --- 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() @retry_on_auth_error() async def get_accounts(reason: Optional[str] = None) -> str: """Get all financial accounts from Monarch Money.""" 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) @mcp.tool() @retry_on_auth_error() 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. """ 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) @mcp.tool() @retry_on_auth_error() async def get_budgets(reason: Optional[str] = None) -> str: """Get current budget information.""" client = await get_authenticated_client() budgets = await client.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( { "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"), } ) # 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() @retry_on_auth_error() async def get_account_holdings(account_id: str, reason: Optional[str] = None) -> str: """Get investment holdings for a specific account.""" 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() @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: 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"api_call failed for method={method}: {e}") return serialize_json({"error": str(e), "method": method}) # --- Health Check Endpoint --- async def health(request): """Health check endpoint for Docker.""" return JSONResponse({"status": "ok"}) # --- ASGI Application --- def create_app(): """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, methods=["GET"]), Mount("/", app=mcp_app), # MCP handles /mcp endpoint ] return Starlette(routes=routes, lifespan=mcp_app.lifespan) # Create the app instance for uvicorn app = create_app() if __name__ == "__main__": import uvicorn port = int(os.getenv("PORT", "8000")) uvicorn.run(app, host="0.0.0.0", port=port)