Compare commits
36 Commits
f17e485612
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d704f5b82b | |||
| 9a6a0ac700 | |||
| 44b2f553d6 | |||
| 1eb8eaacb2 | |||
| 157645b64d | |||
| 6ef77b58ae | |||
| 77656c9925 | |||
| b10de36c17 | |||
| efddb0452b | |||
| 7a7140c76c | |||
| b0b3030033 | |||
| 545d48bd25 | |||
| 4382b02450 | |||
| 52f7a746f9 | |||
| bb38e2441d | |||
| a229537599 | |||
| 4a309cbfb3 | |||
| 88bf8a60d5 | |||
| e462c31907 | |||
| 1e8d484389 | |||
| 1d2bf199e1 | |||
| 6fc09d956f | |||
| 27ef7f0e1e | |||
| 1210cbf6d2 | |||
| 65c79efc60 | |||
| 92fa2c3f11 | |||
| ebb571a7a6 | |||
| df492c8bb4 | |||
| 8fc4312685 | |||
| 748cc5c711 | |||
| 75ba3433e8 | |||
| 177832053f | |||
| 776b4b8cc8 | |||
| f5a5bb3fc4 | |||
| 14db8e67b3 | |||
| 8ca4eae736 |
207
.deployment.md
Normal file
207
.deployment.md
Normal 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
|
||||||
@@ -2,10 +2,16 @@
|
|||||||
# 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 # Docker Compose host port mapping only
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
|
|||||||
21
Dockerfile
21
Dockerfile
@@ -3,14 +3,19 @@ FROM python:3.12-slim AS builder
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install uv for fast dependency management
|
# Install git (required for git-based dependencies) and uv
|
||||||
RUN pip install 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 pyproject.toml first to leverage Docker cache
|
||||||
COPY requirements.txt .
|
COPY pyproject.toml .
|
||||||
|
|
||||||
# Install dependencies into the system python environment of the builder
|
# Copy source directory for editable install
|
||||||
RUN uv pip install --system -r requirements.txt
|
COPY src/ ./src/
|
||||||
|
|
||||||
|
# Install dependencies into system python environment of builder
|
||||||
|
RUN uv pip install --system .
|
||||||
|
|
||||||
# Stage 2: Final Image
|
# Stage 2: Final Image
|
||||||
FROM python:3.12-slim
|
FROM python:3.12-slim
|
||||||
@@ -31,11 +36,11 @@ COPY src/ ./src/
|
|||||||
COPY pyproject.toml .
|
COPY pyproject.toml .
|
||||||
COPY README.md .
|
COPY README.md .
|
||||||
|
|
||||||
# Set Python path to find the package
|
# Set Python path to find package
|
||||||
ENV PYTHONPATH=/app/src
|
ENV PYTHONPATH=/app/src
|
||||||
|
|
||||||
# Default port
|
# Default port
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
# Run the server
|
# Run server
|
||||||
CMD ["python", "src/monarch_mcp_custom/server.py"]
|
CMD ["python", "src/monarch_mcp_custom/server.py"]
|
||||||
|
|||||||
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.
|
||||||
@@ -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.")
|
||||||
|
|
||||||
|
|||||||
@@ -18,12 +18,14 @@ classifiers = [
|
|||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"mcp[cli]>=1.0.0",
|
"mcp[cli]>=1.0.0",
|
||||||
"monarchmoney>=0.1.15",
|
"fastmcp>=0.4.1",
|
||||||
"gql>=3.4,<4.0",
|
"monarchmoneycommunity>=1.3.2",
|
||||||
|
"gql>=4.0,<5.0",
|
||||||
"python-dotenv>=1.0.0",
|
"python-dotenv>=1.0.0",
|
||||||
"pydantic>=2.0.0",
|
"pydantic>=2.0.0",
|
||||||
"starlette>=0.35.0",
|
"starlette>=0.35.0",
|
||||||
"uvicorn>=0.27.0",
|
"uvicorn>=0.27.0",
|
||||||
|
"pyotp>=2.9.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|||||||
@@ -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
20056
ses_4b1fce60bffecsLKNuZh1R971c
Normal file
File diff suppressed because one or more lines are too long
@@ -9,13 +9,14 @@ Classifier: Programming Language :: Python :: 3.12
|
|||||||
Requires-Python: >=3.12
|
Requires-Python: >=3.12
|
||||||
Description-Content-Type: text/markdown
|
Description-Content-Type: text/markdown
|
||||||
Requires-Dist: mcp[cli]>=1.0.0
|
Requires-Dist: mcp[cli]>=1.0.0
|
||||||
|
Requires-Dist: fastmcp>=0.4.1
|
||||||
Requires-Dist: monarchmoney>=0.1.15
|
Requires-Dist: monarchmoney>=0.1.15
|
||||||
Requires-Dist: gql<4.0,>=3.4
|
Requires-Dist: gql<4.0,>=3.4
|
||||||
Requires-Dist: keyring>=24.0.0
|
|
||||||
Requires-Dist: python-dotenv>=1.0.0
|
Requires-Dist: python-dotenv>=1.0.0
|
||||||
Requires-Dist: pydantic>=2.0.0
|
Requires-Dist: pydantic>=2.0.0
|
||||||
Requires-Dist: starlette>=0.35.0
|
Requires-Dist: starlette>=0.35.0
|
||||||
Requires-Dist: uvicorn>=0.27.0
|
Requires-Dist: uvicorn>=0.27.0
|
||||||
|
Requires-Dist: pyotp>=2.9.0
|
||||||
|
|
||||||
# Monarch Money Custom MCP Server
|
# Monarch Money Custom MCP Server
|
||||||
|
|
||||||
@@ -49,7 +50,7 @@ docker-compose up -d
|
|||||||
|
|
||||||
## 🔌 Connection
|
## 🔌 Connection
|
||||||
The server will be available at:
|
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`
|
- **Health Check**: `http://localhost:8000/health`
|
||||||
|
|
||||||
## 🛠️ Tools Included
|
## 🛠️ Tools Included
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
mcp[cli]>=1.0.0
|
mcp[cli]>=1.0.0
|
||||||
|
fastmcp>=0.4.1
|
||||||
monarchmoney>=0.1.15
|
monarchmoney>=0.1.15
|
||||||
gql<4.0,>=3.4
|
gql<4.0,>=3.4
|
||||||
keyring>=24.0.0
|
|
||||||
python-dotenv>=1.0.0
|
python-dotenv>=1.0.0
|
||||||
pydantic>=2.0.0
|
pydantic>=2.0.0
|
||||||
starlette>=0.35.0
|
starlette>=0.35.0
|
||||||
uvicorn>=0.27.0
|
uvicorn>=0.27.0
|
||||||
|
pyotp>=2.9.0
|
||||||
|
|||||||
Binary file not shown.
@@ -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]:
|
||||||
"""
|
"""
|
||||||
@@ -19,7 +23,7 @@ def load_token() -> Optional[str]:
|
|||||||
# 1. Check environment variable (Best for Docker)
|
# 1. Check environment variable (Best for Docker)
|
||||||
token = os.getenv("MONARCH_TOKEN")
|
token = os.getenv("MONARCH_TOKEN")
|
||||||
if token:
|
if token:
|
||||||
logger.info("✅ Token loaded from MONARCH_TOKEN environment variable")
|
logger.info("Token loaded from MONARCH_TOKEN environment variable")
|
||||||
return token
|
return token
|
||||||
|
|
||||||
return None
|
return None
|
||||||
@@ -33,9 +37,9 @@ def save_token(token: str) -> None:
|
|||||||
KEYRING_SERVICE = "com.mcp.monarch-mcp-server"
|
KEYRING_SERVICE = "com.mcp.monarch-mcp-server"
|
||||||
KEYRING_USERNAME = "monarch-token"
|
KEYRING_USERNAME = "monarch-token"
|
||||||
keyring.set_password(KEYRING_SERVICE, KEYRING_USERNAME, 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:
|
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:
|
async def get_authenticated_client() -> MonarchMoney:
|
||||||
@@ -43,28 +47,104 @@ 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
|
|
||||||
|
|
||||||
# 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
|
||||||
|
|
||||||
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, 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
|
||||||
|
|||||||
@@ -2,32 +2,67 @@
|
|||||||
Monarch Money MCP Server - Custom SSE Implementation.
|
Monarch Money MCP Server - Custom SSE Implementation.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
|
||||||
import logging
|
import logging
|
||||||
import json
|
import json
|
||||||
import asyncio
|
import os
|
||||||
from typing import Optional, List, Dict, Any
|
from typing import Optional, Any
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from fastmcp import FastMCP
|
from fastmcp import FastMCP
|
||||||
from starlette.applications import Starlette
|
from starlette.applications import Starlette
|
||||||
from starlette.responses import JSONResponse
|
from starlette.responses import JSONResponse
|
||||||
from starlette.routing import Route
|
from starlette.routing import Route, Mount
|
||||||
from mcp.server.sse import SseServerTransport
|
|
||||||
import uvicorn
|
|
||||||
|
|
||||||
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()
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging with LOG_LEVEL from environment
|
||||||
|
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||||
logging.basicConfig(
|
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__)
|
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
|
# Initialize FastMCP
|
||||||
mcp = FastMCP("Monarch Money Custom")
|
mcp = FastMCP("Monarch Money Custom")
|
||||||
|
|
||||||
@@ -43,31 +78,29 @@ def serialize_json(data: Any) -> str:
|
|||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@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."""
|
"""Get all financial accounts from Monarch Money."""
|
||||||
try:
|
client = await get_authenticated_client()
|
||||||
client = await get_authenticated_client()
|
accounts = await client.get_accounts()
|
||||||
accounts = await client.get_accounts()
|
|
||||||
|
|
||||||
account_list = []
|
account_list = []
|
||||||
for account in accounts.get("accounts", []):
|
for account in accounts.get("accounts", []):
|
||||||
account_info = {
|
account_info = {
|
||||||
"id": account.get("id"),
|
"id": account.get("id"),
|
||||||
"name": account.get("displayName") or account.get("name"),
|
"name": account.get("displayName") or account.get("name"),
|
||||||
"type": (account.get("type") or {}).get("name"),
|
"type": (account.get("type") or {}).get("name"),
|
||||||
"balance": account.get("currentBalance"),
|
"balance": account.get("currentBalance"),
|
||||||
"institution": (account.get("institution") or {}).get("name"),
|
"institution": (account.get("institution") or {}).get("name"),
|
||||||
"is_active": not account.get("deactivatedAt"),
|
"is_active": not account.get("deactivatedAt"),
|
||||||
}
|
}
|
||||||
account_list.append(account_info)
|
account_list.append(account_info)
|
||||||
|
|
||||||
return serialize_json(account_list)
|
return serialize_json(account_list)
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to get accounts: {e}")
|
|
||||||
return f"Error: {str(e)}"
|
|
||||||
|
|
||||||
|
|
||||||
@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,
|
||||||
@@ -79,138 +112,241 @@ async def get_transactions(
|
|||||||
Get transactions from Monarch Money.
|
Get transactions from Monarch Money.
|
||||||
Dates should be in YYYY-MM-DD format.
|
Dates should be in YYYY-MM-DD format.
|
||||||
"""
|
"""
|
||||||
try:
|
client = await get_authenticated_client()
|
||||||
client = await get_authenticated_client()
|
filters = {}
|
||||||
filters = {}
|
if start_date:
|
||||||
if start_date:
|
filters["start_date"] = start_date
|
||||||
filters["start_date"] = start_date
|
if end_date:
|
||||||
if end_date:
|
filters["end_date"] = end_date
|
||||||
filters["end_date"] = end_date
|
if account_id:
|
||||||
if account_id:
|
filters["account_id"] = account_id
|
||||||
filters["account_id"] = account_id
|
|
||||||
|
|
||||||
transactions = await client.get_transactions(
|
transactions = await client.get_transactions(limit=limit, offset=offset, **filters)
|
||||||
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", [])
|
return serialize_json(formatted)
|
||||||
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()
|
@mcp.tool()
|
||||||
async def get_budgets() -> str:
|
@retry_on_auth_error()
|
||||||
|
async def get_budgets(reason: Optional[str] = None) -> str:
|
||||||
"""Get current budget information."""
|
"""Get current budget information."""
|
||||||
try:
|
client = await get_authenticated_client()
|
||||||
client = await get_authenticated_client()
|
budgets = await client.get_budgets()
|
||||||
budgets = await client.get_budgets()
|
|
||||||
|
|
||||||
budget_list = []
|
# Build a category lookup from categoryGroups
|
||||||
for b in budgets.get("budgets", []):
|
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(
|
budget_list.append(
|
||||||
{
|
{
|
||||||
"name": b.get("name"),
|
"month": monthly.get("month"),
|
||||||
"amount": b.get("amount"),
|
"category": cat_info.get("name"),
|
||||||
"spent": b.get("spent"),
|
"group": cat_info.get("group"),
|
||||||
"remaining": b.get("remaining"),
|
"planned": monthly.get("plannedCashFlowAmount"),
|
||||||
"category": (b.get("category") or {}).get("name"),
|
"actual": monthly.get("actualAmount"),
|
||||||
|
"remaining": monthly.get("remainingAmount"),
|
||||||
|
"rollover": monthly.get("previousMonthRolloverAmount"),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
return serialize_json(budget_list)
|
# Also include monthly totals summary
|
||||||
except Exception as e:
|
totals = []
|
||||||
logger.error(f"Failed to get budgets: {e}")
|
for total in budgets.get("budgetData", {}).get("totalsByMonth", []):
|
||||||
return f"Error: {str(e)}"
|
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()
|
@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."""
|
"""Get investment holdings for a specific account."""
|
||||||
try:
|
validated_id = validate_account_id(account_id)
|
||||||
client = await get_authenticated_client()
|
client = await get_authenticated_client()
|
||||||
# The library expects an int for account_id
|
holdings = await client.get_account_holdings(validated_id)
|
||||||
holdings = await client.get_account_holdings(int(account_id))
|
return serialize_json(holdings)
|
||||||
return serialize_json(holdings)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to get holdings: {e}")
|
|
||||||
return f"Error: {str(e)}"
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@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."""
|
"""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:
|
try:
|
||||||
client = await get_authenticated_client()
|
parsed_params = json.loads(params) if params else {}
|
||||||
# Request refresh for all accounts (empty list often means all in this library)
|
except json.JSONDecodeError as e:
|
||||||
result = await client.request_accounts_refresh([])
|
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)
|
return serialize_json(result)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to refresh accounts: {e}")
|
logger.error(f"api_call failed for method={method}: {e}")
|
||||||
return f"Error: {str(e)}"
|
return serialize_json({"error": str(e), "method": method})
|
||||||
|
|
||||||
|
|
||||||
# --- Health Check ---
|
# --- Health Check Endpoint ---
|
||||||
|
|
||||||
|
|
||||||
async def health_check(request):
|
async def health(request):
|
||||||
"""Simple health check endpoint."""
|
"""Health check endpoint for Docker."""
|
||||||
return JSONResponse({"status": "ok", "timestamp": datetime.now().isoformat()})
|
return JSONResponse({"status": "ok"})
|
||||||
|
|
||||||
|
|
||||||
# --- ASGI App Setup ---
|
# --- ASGI Application ---
|
||||||
|
|
||||||
|
|
||||||
def create_app():
|
def create_app():
|
||||||
"""Create the Starlette application with MCP at /mcp."""
|
"""Create the ASGI application with health check and MCP routes."""
|
||||||
# SSE Transport following the "newer http-streamable" convention:
|
mcp_app = mcp.http_app()
|
||||||
# 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(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
# Wrapper app: /health is standalone, everything else goes to MCP
|
||||||
|
# IMPORTANT: Must pass mcp_app.lifespan for task group initialization
|
||||||
routes = [
|
routes = [
|
||||||
Route("/health", health_check, methods=["GET"]),
|
Route("/health", health, methods=["GET"]),
|
||||||
Route("/mcp", endpoint=handle_sse, methods=["GET"]),
|
Mount("/", app=mcp_app), # MCP handles /mcp endpoint
|
||||||
Route("/mcp/messages", endpoint=sse.handle_post_message, methods=["POST"]),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
return Starlette(routes=routes, lifespan=mcp.lifespan)
|
return Starlette(routes=routes, lifespan=mcp_app.lifespan)
|
||||||
|
|
||||||
|
|
||||||
|
# Create the app instance for uvicorn
|
||||||
app = create_app()
|
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__":
|
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
56
test_token.py
Executable 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())
|
||||||
Reference in New Issue
Block a user