Skip to content

Support current Monarch auth flow#71

Merged
robcerda merged 1 commit into
robcerda:mainfrom
thewoolleyman:fix-current-monarch-auth
Jun 26, 2026
Merged

Support current Monarch auth flow#71
robcerda merged 1 commit into
robcerda:mainfrom
thewoolleyman:fix-current-monarch-auth

Conversation

@thewoolleyman

@thewoolleyman thewoolleyman commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Summary

Monarch's auth flow has changed in ways that break this MCP server's current setup:

  • API calls should use https://api.monarch.com, not the old https://api.monarchmoney.com host.
  • New/unrecognized sessions may require email OTP even when account MFA is disabled.
  • The existing monarchmoney package misclassifies the email-code response as a generic 403/MFA-style failure.
  • Reloading a saved token needs the same device UUID metadata used during login; saving only the raw token can produce 401 Unauthorized on GraphQL calls.
  • The upstream budget query asks for category-group fields that Monarch no longer accepts for this account, so get_budgets can fail even after auth is fixed.

This PR adds a small compatibility layer around the existing monarchmoney dependency instead of rewriting the MCP tools:

  • patches MonarchMoneyEndpoints.BASE_URL to the current Monarch API host
  • adds current web login payload fields: supports_email_otp, supports_recaptcha, and email_otp
  • detects email OTP responses separately from TOTP MFA responses
  • updates login_setup.py to prompt for email verification codes and save the resulting session to keyring
  • stores token plus device UUID in keyring, while still accepting legacy raw-token entries
  • creates stored-token clients with the current API host and saved device metadata
  • keeps credential login inside login_setup.py; MCP tool calls now use only an existing keyring session and do not trigger noninteractive password-login attempts
  • uses a narrower budget GraphQL query that avoids stale category-group fields and returns current-month category budget rows
  • suppresses verbose GraphQL transport logging to avoid logging account payloads
  • updates README auth/session docs

Upstream context

Related upstream dependency issues:

  • hammem/monarchmoney#184: API domain changed to api.monarch.com
  • hammem/monarchmoney#196: first non-interactive auth attempt returns 429; commenters report the new host fixes that part
  • hammem/monarchmoney#137: app update warning / auth 403 is misclassified as MFA

The TypeScript @hakimelek/monarchmoney package documents the same current behavior: Monarch may require email OTP for new devices/sessions even when MFA is off, and the OTP is submitted as email_otp.

Verification

uv run --with-editable . --with pytest python -m pytest -q
uv run python -m py_compile src/monarch_mcp_server/config.py src/monarch_mcp_server/monarch_auth.py src/monarch_mcp_server/secure_session.py src/monarch_mcp_server/server.py login_setup.py
uv run --with 'mcp[cli]' --with-editable . mcp run src/monarch_mcp_server/server.py

Additional local smoke verification completed:

  • login_setup.py loaded credentials from .env.local
  • email OTP flow completed successfully
  • setup fetched accounts during verification
  • session was saved to keyring
  • a fresh Python process loaded the saved keyring session successfully
  • fresh-process smoke checks passed for auth status, accounts, one transaction, budgets, cashflow, and holdings
  • Codex MCP tool calls for auth status, accounts, transactions, and cashflow succeeded after keyring renewal

@thewoolleyman
thewoolleyman force-pushed the fix-current-monarch-auth branch 6 times, most recently from df574c4 to 0f5778e Compare June 23, 2026 01:10
@robcerda

Copy link
Copy Markdown
Owner

Thanks for the thorough write-up. Holding this open rather than merging because there are blockers that need a coordinated decision.

Security concerns.

  1. load_dotenv is reintroduced and reads .env, .env.local, and CWD .env. PR Default to read-only; disable credential/auth mutations #61 (read-only hardening) is removing dotenv loading specifically because a stray .env next to the source tree is an authentication-bypass surface. Want to settle that before reintroducing it.

  2. README promotes MONARCH_PASSWORD in .env as a setup path. Plaintext password on disk is exactly what Default to read-only; disable credential/auth mutations #61 is hardening against.

  3. monarch-client-version v1.0.1668 is hardcoded. It will rot silently against a server-side check. At minimum extract it to a named constant with a comment about when it was captured and where to recheck.

  4. Log suppression at logging.getLogger("gql.transport.aiohttp").setLevel(logging.WARNING) is reasonable to avoid logging account payloads but should be documented inline so future debugging does not silently drop transport errors.

Architectural conflict.

  1. This edits the monolithic server.py, but server.py was refactored into the tools/ package in PR Rebase yonib05/all-features onto main (refresh of #29) #46 back in April. The PR is CONFLICTING and the conflict is not trivial. It needs a rebase onto the modular layout.

  2. Three competing auth approaches now exist for the same May-2026 Monarch change: Add cookie-based auth for Monarch's May 2026 API change #59 (cookie auth via session_id + csrftoken + x-csrftoken header), Default to read-only; disable credential/auth mutations #61 (read-only by default + auth tools hard-disabled), and this PR (api.monarch.com base URL + email OTP + token + device-uuid). They diverge on session model. Need a maintainer call on which line to pursue.

Breaking change to existing callers.

  1. get_budgets response shape changes from name/amount/spent/remaining/category/period to id/name/planned/actual/remaining/category_group/month. Any client (or LLM prompt) referring to the old field names breaks.

The email-OTP detection, the api.monarch.com host fix, and the device-uuid plus token storage are all good ideas. The form that lands here will most likely fold those into the cookie-auth path from #59 once that drops draft.

@robcerda

Copy link
Copy Markdown
Owner

This is conflicting against current main because of the April refactor of server.py into the tools/ package. Could you rebase onto current main so we can evaluate it cleanly?

Adds a compatibility layer around the monarchmoney dependency for
Monarch's current web API, rebased onto the modular tools/ package
(PR robcerda#46) instead of the old monolithic server.py.

- monarch_auth.py: patch BASE_URL to api.monarch.com, send current web
  login fields (supports_email_otp/supports_recaptcha/email_otp), detect
  email-OTP responses separately from TOTP MFA, and build stored-token
  clients with the saved device-uuid. monarch-client-version is now a
  documented MONARCH_CLIENT_VERSION constant.
- secure_session.py: store token + device-uuid as JSON (still accepting
  legacy raw-token entries) across the keyring and file-fallback backends.
- client.py: use only an existing keyring session; MCP tool calls no
  longer initiate a noninteractive password login.
- app.py: drop dotenv loading; suppress gql.transport INFO logging (with
  an inline comment) so account payloads are not written to logs.
- tools/budgets.py: narrower budget query that avoids stale category-group
  fields and returns current-month category rows.
- login_setup.py: prompt for email verification codes via the current
  auth flow; README documents email-OTP and keyring-only storage.

No dotenv/.env loading and no MONARCH_PASSWORD setup path (addresses the
read-only hardening concerns from robcerda#61).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@thewoolleyman
thewoolleyman force-pushed the fix-current-monarch-auth branch from 0f5778e to bbdd49e Compare June 24, 2026 07:47
@thewoolleyman

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed review — that all makes sense. I've force-pushed a version rebased onto the modular tools/ layout (so it's no longer the monolithic server.py conflict), and addressed the mechanical blockers:

Rebase (#5). Ported onto the tools/ package. server.py stays the re-export shim. The auth compat lives in a new monarch_auth.py; budget changes are in tools/budgets.py; the device-uuid storage folds into the post-#72 keyring/file-fallback secure_session.py.

dotenv (#1). Dropped entirely — no config.py, and I also removed the existing load_dotenv() from app.py. There's no .env/.env.local/CWD loading anywhere in this branch now. (This overlaps with what #61 is doing; happy to defer that one line to #61 if you'd rather keep the surface area of this PR tighter.)

README password (#2). Removed — no MONARCH_PASSWORD setup path. README now documents keyring-only storage plus the email-OTP flow.

Client version (#3). Extracted to a MONARCH_CLIENT_VERSION constant with a comment noting when it was captured (2026-06-23) and how to recheck it from the web app's request headers.

Log suppression (#4). Moved to app.py with an inline comment explaining it's to keep account payloads out of logs, that transport errors still surface, and how to re-enable for debugging.

I also removed the env-credential auto-login from client.py so MCP tool calls only ever use an existing keyring session — they never initiate a noninteractive password login. That lines up with the hardening direction in #61.

Budget shape (#7). Worth a second look: on current main, get_budgets already returns the raw SDK get_budgets() payload (not the name/amount/spent/remaining/... shape from my original monolithic diff), so there isn't a stable documented shape being broken anymore. The new query returns one row per category/month with id/name/planned/actual/remaining/category_group/month, documented in the tool docstring, and keeps the start_date/end_date params. If you'd prefer, I'm happy to split the budget fix into its own PR so the auth fix can land independently.

Auth strategy (#6). This is the real open question for you: you mentioned folding these into #59's cookie-auth path once it dropped draft, but #59 is now closed. So there's no longer a converging target. The pieces here — api.monarch.com host, email-OTP detection, and token+device-uuid storage — are deliberately modular, so I can graft them onto whichever session model you want to standardize on. Which line do you want to pursue now that #59 is out?

@robcerda
robcerda merged commit e4c2a01 into robcerda:main Jun 26, 2026
dstampfli pushed a commit to dstampfli/monarch-mcp-server that referenced this pull request Jul 3, 2026
Monarch's auth has two distinct failure modes that bit MCP users:

1. trusted_device=False produced short-lived tokens. PR robcerda#71 passed
   trusted_device=False in the login payload, which Monarch interprets
   as "untrusted device" and answers with a 1-hour token (tokenExpiration
   carries an explicit expiry instead of null). This is exactly the
   pattern documented in hammem/monarchmoney#139: sessions die in 30 to
   120 minutes mid-use. Flipping the flag to True and rejecting any
   tokenExpiration that is not None or "null" gets the long-lived token
   the web app uses.

2. Cloudflare blocks programmatic login for some accounts. The login
   endpoint can return 403 with error_code=CAPTCHA_REQUIRED, which the
   old code treated as a generic MFA challenge. The fix is upstream:
   monarchmoneycommunity 1.4.0 added set_cookies()/login_with_cookies()
   so the MCP server can authenticate using a Cookie header captured
   from a logged-in browser session, bypassing the login endpoint
   entirely. Cookie sessions also outlive token sessions because the
   browser's web app refreshes them on every page load.

Changes:

- pyproject.toml, requirements.txt: bump monarchmoneycommunity>=1.4.0
- monarch_auth.py:
  - build_login_payload sets trusted_device=True
  - login_with_current_auth detects CaptchaRequiredException, rejects
    JWT-shape tokens, rejects any tokenExpiration that is not null
  - new login_with_browser_cookies() helper delegates to upstream's
    login_with_cookies and verifies against get_accounts
  - new cookies_from_client() helper for session persistence
- secure_session.py:
  - save_session_blob stores either token+device_uuid or cookies+
    auth_mode (or both) in a JSON keyring entry
  - load_session returns a typed dict and infers auth_mode from
    presence of cookies when not explicitly stored
  - get_authenticated_client calls set_cookies() in cookie mode
  - backward compatible with bare-token strings and pre-cookie JSON
- login_setup.py: refactored to a three-option menu with cookies as
  the default; deleted the dead "retry on session expired" block
- README.md: documents the new cookie path and updates the expired-
  session troubleshooting entry
- Tests: 16 new tests covering cookie roundtrip storage, backward
  compatibility loading, get_authenticated_client dispatch, and the
  new monarch_auth helpers. Existing tests updated for trusted_device=
  True and the gql 4.x DocumentNode location change.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants