-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathserver.py
More file actions
2583 lines (2099 loc) · 99.2 KB
/
Copy pathserver.py
File metadata and controls
2583 lines (2099 loc) · 99.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""MonarchMoney MCP Server - Provides access to Monarch Money financial data via MCP protocol."""
import asyncio
import contextlib
import functools
import io
import json
import logging
import os
import re
import signal
import sys
import time
import uuid
import warnings
from collections.abc import Awaitable, Callable
from datetime import date, datetime, timedelta
from enum import Enum
from pathlib import Path
from typing import Any, ParamSpec, TypeVar
import structlog
from dateutil import parser as date_parser
from dateutil.relativedelta import relativedelta
from mcp.server.fastmcp import Context, FastMCP
from mcp.types import (
Completion,
CompletionArgument,
CompletionContext,
PromptReference,
ResourceTemplateReference,
ToolAnnotations,
)
from monarchmoney import MonarchMoney, RequireMFAException
from pydantic import BaseModel, ConfigDict, JsonValue
# Type definitions for Monarch Money API responses
JsonSerializable = str | int | float | bool | None | list["JsonSerializable"] | dict[str, "JsonSerializable"]
# Reusable tool annotations — all tools are closed-world (only talk to Monarch Money API)
READONLY = ToolAnnotations(readOnlyHint=True, openWorldHint=False)
WRITE_IDEMPOTENT = ToolAnnotations(readOnlyHint=False, destructiveHint=False, idempotentHint=True, openWorldHint=False)
WRITE_CREATE = ToolAnnotations(readOnlyHint=False, destructiveHint=False, idempotentHint=False, openWorldHint=False)
WRITE_SIDE_EFFECT = ToolAnnotations(
readOnlyHint=False, destructiveHint=False, idempotentHint=False, openWorldHint=False
)
def parse_flexible_date(date_input: str) -> date:
"""
Parse flexible date inputs including natural language with comprehensive error handling.
Supports:
- "today", "now"
- "yesterday"
- "this month", "current month"
- "last month", "previous month"
- "this year", "current year"
- "last year", "previous year"
- "last week", "this week"
- "30 days ago", "6 months ago"
- Any date format supported by dateutil.parser
"""
if not date_input:
raise ValueError("Date input cannot be empty")
# Handle common natural language patterns
date_input = date_input.lower().strip()
today = date.today()
if date_input in ["today", "now"]:
return today
elif date_input == "yesterday":
return today - timedelta(days=1)
elif date_input in ["this month", "current month"]:
return date(today.year, today.month, 1)
elif date_input in ["last month", "previous month"]:
# Handle month rollover correctly
if today.month == 1:
return date(today.year - 1, 12, 1)
else:
return date(today.year, today.month - 1, 1)
elif date_input in ["this year", "current year"]:
return date(today.year, 1, 1)
elif date_input in ["last year", "previous year"]:
return date(today.year - 1, 1, 1)
elif date_input == "last week":
return today - timedelta(days=7)
elif date_input == "this week":
# Start of this week (Monday)
days_since_monday = today.weekday()
return today - timedelta(days=days_since_monday)
# Handle relative patterns like "30 days ago", "6 months ago"
relative_pattern = re.match(r"(\d+)\s+(days?|weeks?|months?|years?)\s+ago", date_input)
if relative_pattern:
amount = int(relative_pattern.group(1))
unit = relative_pattern.group(2).rstrip("s") # Remove plural 's'
try:
if unit == "day":
return today - timedelta(days=amount)
elif unit == "week":
return today - timedelta(weeks=amount)
elif unit == "month":
result = today - relativedelta(months=amount)
return result.date() if hasattr(result, "date") else result
elif unit == "year":
result = today - relativedelta(years=amount)
return result.date() if hasattr(result, "date") else result
except (ValueError, OverflowError) as e:
log.warning("Invalid relative date calculation", input=date_input, amount=amount, unit=unit, error=str(e))
raise ValueError(f"Invalid relative date: {date_input}") from e
# Try parsing with dateutil for standard date formats
try:
parsed_datetime = date_parser.parse(date_input)
parsed_date = parsed_datetime.date()
# Validate reasonable date range (1900 to 50 years in future)
min_date = date(1900, 1, 1)
max_date = date(today.year + 50, 12, 31)
if parsed_date < min_date or parsed_date > max_date:
log.warning("Date outside reasonable range", input=date_input, parsed_date=parsed_date.isoformat())
raise ValueError(f"Date {parsed_date.isoformat()} is outside reasonable range (1900-{today.year + 50})")
return parsed_date
except (ValueError, TypeError, OverflowError) as e:
log.warning("Failed to parse date with dateutil", input=date_input, error=str(e))
# Provide helpful error message with suggestions
suggestions = [
"Try formats like: 2024-01-15, Jan 15 2024, 15/01/2024",
"Or natural language: today, yesterday, last month, this year",
"Or relative: 30 days ago, 6 months ago, 1 year ago",
]
suggestion_text = ". ".join(suggestions)
raise ValueError(f"Could not parse date '{date_input}'. {suggestion_text}") from e
def build_date_filter(start_date: str | None, end_date: str | None) -> dict[str, str]:
"""
Build date filter dictionary with flexible parsing and comprehensive error recovery.
Args:
start_date: Start date string (flexible format supported)
end_date: End date string (flexible format supported)
Returns:
Dictionary with ISO format date strings
Raises:
ValueError: If date parsing fails completely after all fallback attempts
Note:
Monarch Money API requires BOTH start_date AND end_date when filtering by date.
If only one is provided, the other will be auto-filled with a sensible default:
- Missing end_date: defaults to today
- Missing start_date: defaults to start of current month
"""
filters: dict[str, str] = {}
# Auto-fill missing dates for better UX (Monarch API requires both or neither)
if start_date and not end_date:
# User provided start but not end - default end to today
end_date = "today"
log.info("Auto-filling missing end_date with 'today'", start_date=start_date)
elif end_date and not start_date:
# User provided end but not start - need to parse end_date first to choose smart default
# If end_date is in the past, use beginning of that month; otherwise use this month
try:
parsed_end = parse_flexible_date(end_date)
today = date.today()
# If end date is in the past or in a different month, use first of that month
if parsed_end < today or parsed_end.month != today.month or parsed_end.year != today.year:
# Use first day of the end_date's month
start_date = date(parsed_end.year, parsed_end.month, 1).isoformat()
log.info(
"Auto-filling missing start_date with first of end_date's month",
end_date=end_date,
calculated_start=start_date,
)
else:
# End date is this month, use "this month"
start_date = "this month"
log.info("Auto-filling missing start_date with 'this month'", end_date=end_date)
except ValueError:
# If we can't parse end_date yet, just use "this month" and let validation catch issues later
start_date = "this month"
log.info("Auto-filling missing start_date with 'this month' (end_date parse pending)", end_date=end_date)
# parse_flexible_date already handles all formats (natural language, ISO, dateutil)
if start_date:
parsed_date = parse_flexible_date(start_date)
filters["start_date"] = parsed_date.isoformat()
log.info("Parsed start_date", input=start_date, parsed=parsed_date.isoformat())
if end_date:
parsed_date = parse_flexible_date(end_date)
filters["end_date"] = parsed_date.isoformat()
log.info("Parsed end_date", input=end_date, parsed=parsed_date.isoformat())
# Validate date range logic
if "start_date" in filters and "end_date" in filters:
start = date.fromisoformat(filters["start_date"])
end = date.fromisoformat(filters["end_date"])
if start > end:
log.warning("Start date is after end date", start_date=filters["start_date"], end_date=filters["end_date"])
raise ValueError(f"Start date ({filters['start_date']}) cannot be after end date ({filters['end_date']})")
return filters
def convert_dates_to_strings(obj: Any) -> Any:
"""
Recursively convert all date/datetime objects to ISO format strings.
This ensures that the data can be serialized by any JSON encoder,
not just our custom one. This is necessary because the MCP framework
may attempt to serialize the response before we can use our custom encoder.
"""
if isinstance(obj, (date, datetime)):
return obj.isoformat()
elif isinstance(obj, dict):
return {key: convert_dates_to_strings(value) for key, value in obj.items()}
elif isinstance(obj, list):
return [convert_dates_to_strings(item) for item in obj]
elif isinstance(obj, tuple):
return tuple(convert_dates_to_strings(item) for item in obj)
else:
return obj
def extract_transactions_list(response: Any) -> list[dict[str, Any]]:
"""
Extract the transactions list from monarchmoney API response.
The monarchmoney library returns:
{
"allTransactions": {
"totalCount": 123,
"results": [...] # <-- actual transactions
},
"transactionRules": ...
}
This function extracts the results list from the nested structure.
"""
if isinstance(response, list):
# Already a list (shouldn't happen with current API)
return response
elif isinstance(response, dict):
# Check for the nested structure
if "allTransactions" in response:
all_txns = response["allTransactions"]
if isinstance(all_txns, dict) and "results" in all_txns:
results = all_txns["results"]
if isinstance(results, list):
return results
# Fallback: maybe it's a different structure
log.warning("Unexpected transaction response structure", keys=list(response.keys()))
return []
else:
log.error("Unexpected transaction response type", response_type=str(type(response)))
return []
def extract_list(response: Any, key: str) -> list[Any]:
"""Pull a named list out of a Monarch API response.
Most Monarch GraphQL queries return a dict like ``{"accounts": [...]}`` rather
than a bare list, so the inner list has to be unwrapped before counting it.
Tolerates an already-flat list and unexpected shapes (returns []).
"""
if isinstance(response, list):
return response
if isinstance(response, dict):
value = response.get(key)
if isinstance(value, list):
return value
return []
def format_transactions_compact(transactions: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""
Format transactions in a compact format with only essential fields.
Returns simplified transaction objects with only:
- id, date, amount
- merchant name, plaidName (original statement name)
- category id + name (id needed for updates)
- account display name
- needsReview flag
- pending flag (only if True)
- notes (only if present)
Use verbose=True to get full transaction details when needed.
"""
compact: list[dict[str, Any]] = []
for txn in transactions:
if not isinstance(txn, dict):
continue
category = txn.get("category")
compact_txn: dict[str, Any] = {
"id": txn.get("id"),
"date": txn.get("date"),
"amount": txn.get("amount"),
"merchant": txn.get("merchant", {}).get("name") if isinstance(txn.get("merchant"), dict) else None,
"plaidName": txn.get("plaidName"),
"category": category.get("name") if isinstance(category, dict) else None,
"categoryId": category.get("id") if isinstance(category, dict) else None,
"account": txn.get("account", {}).get("displayName") if isinstance(txn.get("account"), dict) else None,
"needsReview": txn.get("needsReview", False),
}
# Only include pending if actually pending (saves bytes on the common case)
if txn.get("pending"):
compact_txn["pending"] = True
# Include notes if present
if txn.get("notes"):
compact_txn["notes"] = txn.get("notes")
compact.append(compact_txn)
return compact
def _build_transaction_filters(
start_date: str | None,
end_date: str | None,
account_id: str | None = None,
category_id: str | None = None,
tag_ids: str | None = None,
has_attachments: bool | None = None,
has_notes: bool | None = None,
hidden_from_reports: bool | None = None,
is_split: bool | None = None,
is_recurring: bool | None = None,
) -> dict[str, Any]:
"""Build filters dict for get_transactions API calls.
Shared by get_transactions and search_transactions to avoid duplication.
"""
filters: dict[str, Any] = build_date_filter(start_date, end_date)
# monarchmoney expects account_ids and category_ids as LISTS
if account_id:
filters["account_ids"] = [account_id]
if category_id:
filters["category_ids"] = [category_id]
if tag_ids:
filters["tag_ids"] = [t.strip() for t in tag_ids.split(",")]
# Boolean filters (only include if explicitly set)
if has_attachments is not None:
filters["has_attachments"] = has_attachments
if has_notes is not None:
filters["has_notes"] = has_notes
if hidden_from_reports is not None:
filters["hidden_from_reports"] = hidden_from_reports
if is_split is not None:
filters["is_split"] = is_split
if is_recurring is not None:
filters["is_recurring"] = is_recurring
return filters
# Configure logger to output to stderr only with error handling
class SafeStreamHandler(logging.StreamHandler[Any]):
"""Stream handler that gracefully handles broken pipes."""
def emit(self, record: logging.LogRecord) -> None:
try:
super().emit(record)
except (BrokenPipeError, ConnectionResetError):
# Silently ignore broken pipe errors during logging
pass
except Exception:
# Let other logging errors bubble up
self.handleError(record)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
handlers=[SafeStreamHandler(sys.stderr)],
)
# Configure structured logging
structlog.configure(
processors=[
structlog.stdlib.filter_by_level,
structlog.stdlib.add_logger_name,
structlog.stdlib.add_log_level,
structlog.stdlib.PositionalArgumentsFormatter(),
structlog.processors.StackInfoRenderer(),
structlog.processors.format_exc_info,
structlog.processors.UnicodeDecoder(),
structlog.processors.JSONRenderer(),
],
context_class=dict,
logger_factory=structlog.stdlib.LoggerFactory(),
wrapper_class=structlog.stdlib.BoundLogger,
cache_logger_on_first_use=True,
)
# Get structured logger for this module
log = structlog.get_logger(__name__)
# Suppress third-party library logging to reduce noise
logging.getLogger("aiohttp").setLevel(logging.ERROR)
logging.getLogger("monarchmoney").setLevel(logging.ERROR)
logging.getLogger("gql").setLevel(logging.ERROR)
logging.getLogger("gql.transport").setLevel(logging.ERROR)
warnings.filterwarnings("ignore", category=UserWarning, module="gql.transport.aiohttp")
# Session tracking for usage analytics
current_session_id = str(uuid.uuid4())
usage_patterns: dict[str, list[dict[str, Any]]] = {}
P = ParamSpec("P")
R = TypeVar("R")
def track_usage(func: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]:
"""Decorator to track tool usage patterns for analytics with detailed debugging."""
@functools.wraps(func)
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
start_time = time.time()
tool_name = func.__name__
# Format args for logging (exclude sensitive data)
safe_kwargs = {k: v for k, v in kwargs.items() if k not in ["password", "mfa_secret"]}
log.info("tool_call", tool=tool_name, args=safe_kwargs)
# Track this call
call_info = {
"session_id": current_session_id,
"tool_name": tool_name,
"timestamp": time.time(),
"args": list(args),
"kwargs": safe_kwargs,
}
try:
result = await func(*args, **kwargs)
execution_time = time.time() - start_time
# Calculate result size and stats. Tools now return Pydantic models;
# serialize to JSON for an accurate wire-size measurement.
if isinstance(result, BaseModel):
payload = result.model_dump_json()
elif isinstance(result, str):
payload = result
else:
payload = str(result) if result else ""
result_chars = len(payload)
result_kb = result_chars / 1024
# Try to extract additional stats from JSON results
extra_stats = ""
try:
if payload.strip().startswith("{"):
parsed = json.loads(payload)
if isinstance(parsed, dict):
# Look for common list fields to count items
for key in ["transactions", "accounts", "budgets", "categories", "results"]:
if key in parsed and isinstance(parsed[key], list):
extra_stats += f" | {key}: {len(parsed[key])} items"
# Check for batch summaries
if "batch_summary" in parsed:
summary = parsed["batch_summary"]
if isinstance(summary, dict):
extra_stats += f" | batch: {summary}"
except (json.JSONDecodeError, KeyError, TypeError):
pass
call_info.update({"status": "success", "execution_time": execution_time, "result_size": result_chars})
log.info(
"tool_success",
tool=tool_name,
time_s=round(execution_time, 3),
result_chars=result_chars,
result_kb=round(result_kb, 2),
)
# Track usage patterns in memory for batching analysis
if tool_name not in usage_patterns:
usage_patterns[tool_name] = []
usage_patterns[tool_name].append(call_info)
return result
except Exception as e:
execution_time = time.time() - start_time
call_info.update({"status": "error", "execution_time": execution_time, "error": str(e)})
log.error("tool_error", tool=tool_name, time_s=round(execution_time, 3), error=str(e))
raise
return wrapper
# Initialize the FastMCP server
mcp = FastMCP("monarch-money")
# =============================================================================
# Structured output models
#
# Each tool returns a typed model so FastMCP emits an ``outputSchema`` and
# machine-readable structured content (plus a text fallback for older clients).
# Monarch's GraphQL payloads are deep and evolve, so passthrough fields are typed
# as ``JsonValue`` (recursive JSON, not ``Any``) and ``MMModel`` allows unknown
# extra keys to flow through. Shapes we construct ourselves are modeled precisely.
# =============================================================================
class MMModel(BaseModel):
"""Base for response models — tolerates extra upstream fields."""
model_config = ConfigDict(extra="allow")
class AccountsResult(MMModel):
accounts: list[JsonValue]
count: int
class TransactionsResult(MMModel):
transactions: list[JsonValue]
count: int
verbose: bool
class SearchMetadata(BaseModel):
query: str
result_count: int
filters_applied: dict[str, JsonValue]
class SearchResult(MMModel):
search_metadata: SearchMetadata
transactions: list[JsonValue]
class BudgetsResult(MMModel):
budgets: JsonValue
message: str | None = None
class CashflowResult(MMModel):
cashflow: JsonValue
class CategoriesResult(MMModel):
categories: list[JsonValue]
count: int
verbose: bool
class TransactionResult(MMModel):
transaction: JsonValue
class TransactionSplit(BaseModel):
"""One leg of a split transaction.
The split amounts must sum to the parent transaction's amount (Monarch
validates this and rejects the update otherwise). Amounts keep the parent's
sign convention — expenses are negative, income positive.
"""
amount: float
category_id: str | None = None
merchant_name: str | None = None
notes: str | None = None
class TransactionSplitsResult(MMModel):
transaction_id: str
has_split_transactions: bool
splits: list[JsonValue]
class UpdateSplitsResult(MMModel):
transaction_id: str
has_split_transactions: bool
splits: list[JsonValue]
message: str
class BulkSummary(BaseModel):
total: int
succeeded: int
failed: int
class BulkItemResult(BaseModel):
transaction_id: str | None = None
status: str
error: str | None = None
class BulkUpdateResult(MMModel):
summary: BulkSummary
results: list[BulkItemResult]
message: str | None = None
class HoldingsResult(MMModel):
holdings: JsonValue
class AccountHistoryResult(MMModel):
account_id: str
history: JsonValue
class InstitutionsResult(MMModel):
# Monarch's institution-settings query returns a dict (credentials, accounts,
# subscription), not a flat list, so the full payload is passed through.
institutions: JsonValue
class RecurringResult(MMModel):
recurring: JsonValue
class SetBudgetResult(MMModel):
category_id: str
amount: float
result: JsonValue
class CreateAccountResult(MMModel):
account: JsonValue
class RefreshResult(MMModel):
result: JsonValue
class Totals(BaseModel):
income: float
expenses: float
net: float
class GroupSummary(BaseModel):
income: float
expenses: float
net: float
count: int
class Period(BaseModel):
start: str | None = None
end: str | None = None
class SpendingSummaryResult(MMModel):
period: Period
group_by: str
groups: dict[str, GroupSummary]
totals: Totals
class FinancialOverview(MMModel):
period: str
accounts: JsonValue = None
budgets: JsonValue = None
cashflow: JsonValue = None
transactions: JsonValue = None
categories: JsonValue = None
transaction_summary: JsonValue = None
batch_metadata: JsonValue = None
class SpendingPatterns(MMModel):
analysis_period: JsonValue = None
monthly_trends: JsonValue = None
category_analysis: JsonValue = None
account_usage: JsonValue = None
budget_performance: JsonValue = None
forecast: JsonValue = None
metadata: JsonValue = None
# =============================================================================
# MCP Resources - Read-only data endpoints for reference data
# =============================================================================
@mcp.resource("categories://list", title="Transaction Categories")
async def list_categories_resource() -> str:
"""
List all transaction categories available in Monarch Money.
Returns a JSON array of category objects with id, name, group, and icon.
This is read-only reference data useful for understanding available categories
before creating or updating transactions.
"""
await ensure_authenticated()
categories = await api_call_with_retry("get_transaction_categories")
return json.dumps(convert_dates_to_strings(categories), indent=2)
@mcp.resource("accounts://list", title="Linked Accounts")
async def list_accounts_resource() -> str:
"""
List all linked financial accounts in Monarch Money.
Returns a JSON array of account objects including checking, savings,
credit cards, investments, and other account types with their balances
and institution information.
"""
await ensure_authenticated()
accounts = await api_call_with_retry("get_accounts")
return json.dumps(convert_dates_to_strings(accounts), indent=2)
@mcp.resource("institutions://list", title="Linked Institutions")
async def list_institutions_resource() -> str:
"""
List all connected financial institutions in Monarch Money.
Returns a JSON array of institution objects showing which banks,
brokerages, and other financial institutions are connected to the account.
"""
await ensure_authenticated()
institutions = await api_call_with_retry("get_institutions")
return json.dumps(convert_dates_to_strings(institutions), indent=2)
@mcp.resource("accounts://{account_id}/holdings", title="Account Holdings")
async def account_holdings_resource(account_id: str) -> str:
"""
Investment holdings for a specific account (resource template).
The ``account_id`` path segment selects which account's portfolio to return.
Mirrors the ``get_account_holdings`` tool but as an addressable resource.
"""
await ensure_authenticated()
holdings = await api_call_with_retry("get_account_holdings", account_id=account_id)
return json.dumps(convert_dates_to_strings(holdings), indent=2)
@mcp.resource("accounts://{account_id}/history", title="Account Balance History")
async def account_history_resource(account_id: str) -> str:
"""
Historical balance data for a specific account (resource template).
The ``account_id`` path segment selects which account's balance history to
return. Mirrors the ``get_account_history`` tool but as an addressable resource.
"""
await ensure_authenticated()
history = await api_call_with_retry("get_account_history", account_id=account_id)
return json.dumps(convert_dates_to_strings(history), indent=2)
# =============================================================================
# MCP Prompts - Reusable prompt templates for common financial analyses
# =============================================================================
@mcp.prompt(title="Analyze Spending")
def analyze_spending(period: str = "this month", category: str | None = None) -> str:
"""
Generate a prompt template for analyzing spending patterns.
Args:
period: Time period to analyze (e.g., "this month", "last 3 months", "2024")
category: Optional category to focus on (e.g., "Food & Dining", "Shopping")
"""
category_focus = f" specifically for {category}" if category else ""
return f"""Please analyze my spending{category_focus} for {period}.
Use the get_transactions tool to fetch transaction data for the specified period, then provide:
1. **Total Spending**: Sum of all expenses
2. **Top Categories**: Which categories had the most spending
3. **Trends**: Any notable patterns or changes
4. **Insights**: Specific observations about spending habits
5. **Recommendations**: Actionable suggestions to optimize spending
Focus on practical insights rather than just listing numbers."""
@mcp.prompt(title="Budget Review")
def budget_review(month: str = "current") -> str:
"""
Generate a prompt template for reviewing budget performance.
Args:
month: Which month to review ("current", "last", or "YYYY-MM" format)
"""
return f"""Please review my budget performance for {month}.
Use get_budgets and get_transactions tools to compare budgeted amounts vs actual spending:
1. **Budget vs Actual**: For each category, show budgeted amount, actual spending, and variance
2. **Over Budget**: Highlight categories where spending exceeded budget
3. **Under Budget**: Show categories with unused budget
4. **Overall Status**: Am I on track for the month?
5. **Adjustments**: Suggest any budget adjustments based on actual patterns
Present the data in a clear, easy-to-scan format."""
@mcp.prompt(title="Financial Health Check")
def financial_health_check() -> str:
"""
Generate a comprehensive financial health assessment prompt.
This prompt guides a thorough review of accounts, spending, and budgets.
"""
return """Please perform a comprehensive financial health check.
Use the available tools to gather data and provide:
1. **Account Overview**:
- Total assets and liabilities
- Net worth calculation
- Account balances summary
2. **Cash Flow Analysis**:
- Monthly income vs expenses
- Savings rate
- Recurring transactions review
3. **Spending Analysis**:
- Top spending categories (last 30 days)
- Unusual or large transactions
- Comparison to previous month
4. **Budget Status**:
- Categories on track vs off track
- Projected month-end status
5. **Action Items**:
- Specific recommendations
- Areas needing attention
- Positive trends to maintain
Be concise but thorough. Highlight the most important insights first."""
@mcp.prompt(title="Categorize a Transaction")
def transaction_categorization_help(description: str) -> str:
"""
Generate a prompt to help categorize a transaction.
Args:
description: The transaction description or merchant name
"""
return f"""Help me categorize this transaction: "{description}"
First, use the categories://list resource to see all available categories.
Then suggest:
1. **Best Category Match**: The most appropriate category for this transaction
2. **Alternative Options**: Other categories that might fit
3. **Reasoning**: Why you recommend this categorization
If this is a merchant I transact with frequently, also note if the categorization
should be applied to future transactions from the same merchant."""
# =============================================================================
# MCP Completions - Argument autocompletion for prompts and resource templates
# =============================================================================
async def _category_name_completions(partial: str) -> list[str]:
"""Live category names for autocompletion. Best-effort: never raises."""
try:
await ensure_authenticated()
categories = extract_list(await api_call_with_retry("get_transaction_categories"), "categories")
except Exception as e:
log.warning("completion_categories_failed", error=str(e))
return []
names = [c.get("name", "") for c in categories if isinstance(c, dict) and c.get("name")]
needle = partial.lower()
return [n for n in names if needle in n.lower()][:100]
async def _account_id_completions(partial: str) -> list[str]:
"""Live account IDs for autocompletion. Best-effort: never raises."""
try:
await ensure_authenticated()
accounts = extract_list(await api_call_with_retry("get_accounts"), "accounts")
except Exception as e:
log.warning("completion_accounts_failed", error=str(e))
return []
ids = [a.get("id", "") for a in accounts if isinstance(a, dict) and a.get("id")]
needle = partial.lower()
return [i for i in ids if needle in i.lower()][:100]
@mcp.completion()
async def handle_completion(
ref: PromptReference | ResourceTemplateReference,
argument: CompletionArgument,
context: CompletionContext | None,
) -> Completion | None:
"""Autocomplete prompt/resource-template arguments from live Monarch data.
- prompt ``category`` argument -> transaction category names
- resource-template ``account_id`` argument -> account IDs
"""
if isinstance(ref, PromptReference) and argument.name == "category":
return Completion(values=await _category_name_completions(argument.value), hasMore=False)
if isinstance(ref, ResourceTemplateReference) and argument.name == "account_id":
return Completion(values=await _account_id_completions(argument.value), hasMore=False)
return None
class AuthState(Enum):
"""Track authentication state to prevent duplicate initialization attempts."""
NOT_INITIALIZED = "not_initialized"
INITIALIZING = "initializing"
AUTHENTICATED = "authenticated"
FAILED = "failed"
# Global variables for authentication
mm_client: MonarchMoney | None = None
auth_state: AuthState = AuthState.NOT_INITIALIZED
auth_lock: asyncio.Lock | None = None # Created in async context
auth_error: str | None = None # Store last auth error for debugging
auth_failed_at: float | None = None # Timestamp of last auth failure for cooldown
AUTH_RETRY_COOLDOWN_SECONDS = 60 # Wait 60 seconds before retrying after FAILED state
# Secure session directory with proper permissions.
# Resolve to an absolute, writable path: many MCP clients (e.g. Claude Desktop)
# launch the server with a read-only working directory like "/", so a relative
# ".mm" would fail with "Read-only file system". Honor MONARCH_SESSION_DIR if set,
# otherwise default to ~/.monarch-mcp which is always writable.
_session_dir_env = os.getenv("MONARCH_SESSION_DIR")
session_dir = Path(_session_dir_env).expanduser() if _session_dir_env else Path.home() / ".monarch-mcp"
session_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
session_file = session_dir / "session.pickle"
def is_auth_error(error: Exception) -> bool:
"""Determine if an error is a genuine authentication/authorization failure.
Only returns True for actual auth failures like 401, 403, invalid credentials.
Does NOT treat library errors, connection issues, or other problems as auth failures.
"""
error_str = str(error).lower()
# Exclude false positives first - these are NOT auth errors
false_positives = [
"connector", # Library compatibility issue
"aiohttp", # Library issue
"transport", # Library issue
"connection refused", # Network issue, not auth
"connection reset", # Network issue, not auth
"timeout", # Network issue, not auth
]
# Check for false positives first
if any(fp in error_str for fp in false_positives):
return False
# Genuine authentication/authorization error indicators
auth_indicators = [
"401",
"403",
"unauthorized",
"forbidden",
"invalid credentials",
"bad credentials",
"authentication failed",
"auth failed", # Match "auth failed" messages
"not authenticated",
"invalid token",
"token expired",
"session expired",
"session has expired",
]