From dbf8961b3852b59044170d90a8f45809e46f4bd4 Mon Sep 17 00:00:00 2001 From: Jamie Dubs <1903+jamiew@users.noreply.github.com> Date: Thu, 28 May 2026 22:39:34 -0400 Subject: [PATCH 1/7] Adopt MCP 2025-06-18 features: structured output, titles, templates, completions, progress - every tool now returns a typed Pydantic model so FastMCP advertises an outputSchema and emits structured content alongside a text fallback - add human-friendly titles to all tools, resources, and prompts - add parameterized resource templates: accounts://{account_id}/holdings and accounts://{account_id}/history - add a completions handler for the prompt `category` arg and resource-template `account_id`, backed by live monarch data (best-effort, never raises) - report progress via injected Context in the two batch tools (complete overview, spending patterns) - fix get_account_holdings to pass the required account_id (the underlying library always required it) - keep track_usage result-size logging accurate for model returns - expand tests to 202: structured-output schema coverage, resource templates, completions, progress, titles, plus migrating string-shape assertions to structured ones --- server.py | 506 ++++++++++++++++++++++++------- tests/test_analytics.py | 18 +- tests/test_bulk_updates.py | 12 +- tests/test_fastmcp.py | 25 +- tests/test_mcp_features.py | 111 +++++++ tests/test_new_tools.py | 41 ++- tests/test_search.py | 21 +- tests/test_structured_output.py | 68 +++++ tests/test_tool_coverage.py | 61 ++-- tests/test_validation_fastmcp.py | 26 +- 10 files changed, 671 insertions(+), 218 deletions(-) create mode 100644 tests/test_structured_output.py diff --git a/server.py b/server.py index d0738c6..1244205 100644 --- a/server.py +++ b/server.py @@ -23,9 +23,17 @@ import structlog from dateutil import parser as date_parser from dateutil.relativedelta import relativedelta -from mcp.server.fastmcp import FastMCP -from mcp.types import ToolAnnotations +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"] @@ -434,15 +442,22 @@ async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: result = await func(*args, **kwargs) execution_time = time.time() - start_time - # Calculate result size and stats - result_chars = len(str(result)) if result else 0 + # 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 isinstance(result, str) and result.strip().startswith("{"): - parsed = json.loads(result) + 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"]: @@ -487,12 +502,166 @@ async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: 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 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): + institutions: list[JsonValue] + count: int + + +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") +@mcp.resource("categories://list", title="Transaction Categories") async def list_categories_resource() -> str: """ List all transaction categories available in Monarch Money. @@ -506,7 +675,7 @@ async def list_categories_resource() -> str: return json.dumps(convert_dates_to_strings(categories), indent=2) -@mcp.resource("accounts://list") +@mcp.resource("accounts://list", title="Linked Accounts") async def list_accounts_resource() -> str: """ List all linked financial accounts in Monarch Money. @@ -520,7 +689,7 @@ async def list_accounts_resource() -> str: return json.dumps(convert_dates_to_strings(accounts), indent=2) -@mcp.resource("institutions://list") +@mcp.resource("institutions://list", title="Linked Institutions") async def list_institutions_resource() -> str: """ List all connected financial institutions in Monarch Money. @@ -533,12 +702,38 @@ async def list_institutions_resource() -> str: 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() +@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. @@ -561,7 +756,7 @@ def analyze_spending(period: str = "this month", category: str | None = None) -> Focus on practical insights rather than just listing numbers.""" -@mcp.prompt() +@mcp.prompt(title="Budget Review") def budget_review(month: str = "current") -> str: """ Generate a prompt template for reviewing budget performance. @@ -582,7 +777,7 @@ def budget_review(month: str = "current") -> str: Present the data in a clear, easy-to-scan format.""" -@mcp.prompt() +@mcp.prompt(title="Financial Health Check") def financial_health_check() -> str: """ Generate a comprehensive financial health assessment prompt. @@ -620,7 +815,7 @@ def financial_health_check() -> str: Be concise but thorough. Highlight the most important insights first.""" -@mcp.prompt() +@mcp.prompt(title="Categorize a Transaction") def transaction_categorization_help(description: str) -> str: """ Generate a prompt to help categorize a transaction. @@ -641,6 +836,57 @@ def transaction_categorization_help(description: str) -> str: 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 = await api_call_with_retry("get_transaction_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 = await api_call_with_retry("get_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.""" @@ -980,22 +1226,23 @@ async def ensure_authenticated() -> None: # FastMCP Tool definitions using decorators -@mcp.tool(annotations=READONLY) +@mcp.tool(annotations=READONLY, title="Get Accounts") @track_usage -async def get_accounts() -> str: +async def get_accounts() -> AccountsResult: """Retrieve all linked financial accounts.""" await ensure_authenticated() try: accounts = await api_call_with_retry("get_accounts") accounts = convert_dates_to_strings(accounts) - return json.dumps(accounts, indent=2) + account_list = accounts if isinstance(accounts, list) else [] + return AccountsResult(accounts=account_list, count=len(account_list)) except Exception as e: log.error("Failed to fetch accounts", error=str(e)) raise -@mcp.tool(annotations=READONLY) +@mcp.tool(annotations=READONLY, title="Get Transactions") @track_usage async def get_transactions( limit: int = 100, @@ -1011,7 +1258,7 @@ async def get_transactions( is_split: bool | None = None, is_recurring: bool | None = None, verbose: bool = False, -) -> str: +) -> TransactionsResult: """Fetch transactions with flexible date filtering and smart output formatting. Args: @@ -1131,13 +1378,15 @@ async def get_transactions( transactions = format_transactions_compact(transactions) log.info("Transactions retrieved", count=len(transactions)) - return json.dumps(transactions, indent=2) + return TransactionsResult.model_validate( + {"transactions": transactions, "count": len(transactions), "verbose": verbose} + ) except Exception as e: log.error("Failed to fetch transactions", error=str(e), limit=limit, start_date=start_date) raise -@mcp.tool(annotations=READONLY) +@mcp.tool(annotations=READONLY, title="Search Transactions") @track_usage async def search_transactions( query: str, @@ -1154,7 +1403,7 @@ async def search_transactions( is_split: bool | None = None, is_recurring: bool | None = None, verbose: bool = False, -) -> str: +) -> SearchResult: """Search transactions by text using Monarch Money's built-in search. Searches merchant names, descriptions, notes, and other fields. @@ -1208,26 +1457,23 @@ async def search_transactions( if not verbose: transactions = format_transactions_compact(transactions) - result = { - "search_metadata": { - "query": query_str, - "result_count": len(transactions), - "filters_applied": {k: v for k, v in filters.items() if k != "search"}, - }, - "transactions": transactions, - } + metadata = SearchMetadata( + query=query_str, + result_count=len(transactions), + filters_applied={k: v for k, v in filters.items() if k != "search"}, + ) log.info("Search complete", query=query_str, result_count=len(transactions)) - return json.dumps(result, indent=2) + return SearchResult.model_validate({"search_metadata": metadata, "transactions": transactions}) except Exception as e: log.error("Failed to search transactions", error=str(e), query=query) raise -@mcp.tool(annotations=READONLY) +@mcp.tool(annotations=READONLY, title="Get Budgets") @track_usage -async def get_budgets(start_date: str | None = None, end_date: str | None = None) -> str: +async def get_budgets(start_date: str | None = None, end_date: str | None = None) -> BudgetsResult: """Retrieve budget information with flexible date filtering. Args: @@ -1245,21 +1491,19 @@ async def get_budgets(start_date: str | None = None, end_date: str | None = None try: budgets = await api_call_with_retry("get_budgets", **kwargs) # type: ignore[arg-type] budgets = convert_dates_to_strings(budgets) - return json.dumps(budgets, indent=2) + return BudgetsResult(budgets=budgets) except Exception as e: # Handle the case where no budgets exist if "Something went wrong while processing: None" in str(e): - return json.dumps( - {"budgets": [], "message": "No budgets configured in your Monarch Money account"}, indent=2 - ) + return BudgetsResult(budgets=[], message="No budgets configured in your Monarch Money account") else: # Re-raise other errors raise -@mcp.tool(annotations=READONLY) +@mcp.tool(annotations=READONLY, title="Get Cashflow") @track_usage -async def get_cashflow(start_date: str | None = None, end_date: str | None = None) -> str: +async def get_cashflow(start_date: str | None = None, end_date: str | None = None) -> CashflowResult: """Analyze cashflow data with flexible date filtering. Args: @@ -1276,12 +1520,12 @@ async def get_cashflow(start_date: str | None = None, end_date: str | None = Non cashflow = await api_call_with_retry("get_cashflow", **kwargs) # type: ignore[arg-type] cashflow = convert_dates_to_strings(cashflow) - return json.dumps(cashflow, indent=2) + return CashflowResult(cashflow=cashflow) -@mcp.tool(annotations=READONLY) +@mcp.tool(annotations=READONLY, title="Get Transaction Categories") @track_usage -async def get_transaction_categories(verbose: bool = False) -> str: +async def get_transaction_categories(verbose: bool = False) -> CategoriesResult: """List all transaction categories. Args: @@ -1297,14 +1541,17 @@ async def get_transaction_categories(verbose: bool = False) -> str: categories = await api_call_with_retry("get_transaction_categories") categories = convert_dates_to_strings(categories) + category_list = categories if isinstance(categories, list) else [] - if not verbose and isinstance(categories, list): - categories = [{"id": cat.get("id"), "name": cat.get("name")} for cat in categories if isinstance(cat, dict)] + if not verbose: + category_list = [ + {"id": cat.get("id"), "name": cat.get("name")} for cat in category_list if isinstance(cat, dict) + ] - return json.dumps(categories, indent=2) + return CategoriesResult(categories=category_list, count=len(category_list), verbose=verbose) -@mcp.tool(annotations=WRITE_CREATE) +@mcp.tool(annotations=WRITE_CREATE, title="Create Transaction") @track_usage async def create_transaction( amount: float, @@ -1314,7 +1561,7 @@ async def create_transaction( category_id: str, notes: str | None = None, update_balance: bool = False, -) -> str: +) -> TransactionResult: """Create a new manual transaction. Args: @@ -1364,7 +1611,7 @@ async def create_transaction( timeout=30.0, # 30 second timeout ) result = convert_dates_to_strings(result) - return json.dumps(result, indent=2) + return TransactionResult(transaction=result) except asyncio.TimeoutError as e: log.error("create_transaction_timeout") raise ValueError("Transaction creation timed out after 30 seconds. Please try again.") from e @@ -1375,7 +1622,7 @@ async def create_transaction( raise -@mcp.tool(annotations=WRITE_IDEMPOTENT) +@mcp.tool(annotations=WRITE_IDEMPOTENT, title="Update Transaction") @track_usage async def update_transaction( transaction_id: str, @@ -1387,7 +1634,7 @@ async def update_transaction( goal_id: str | None = None, hide_from_reports: bool | None = None, needs_review: bool | None = None, -) -> str: +) -> TransactionResult: """Update an existing transaction. Args: @@ -1477,7 +1724,7 @@ async def update_transaction( timeout=30.0, # 30 second timeout ) result = convert_dates_to_strings(result) - return json.dumps(result, indent=2) + return TransactionResult(transaction=result) except asyncio.TimeoutError as e: log.error("update_transaction_timeout", transaction_id=transaction_id) raise ValueError("Transaction update timed out after 30 seconds. Please try again.") from e @@ -1492,9 +1739,9 @@ async def update_transaction( raise -@mcp.tool(annotations=WRITE_IDEMPOTENT) +@mcp.tool(annotations=WRITE_IDEMPOTENT, title="Bulk Update Transactions") @track_usage -async def update_transactions_bulk(updates: str) -> str: +async def update_transactions_bulk(updates: str) -> BulkUpdateResult: """Update multiple transactions in a single call to save round-trips. This is much more efficient than calling update_transaction multiple times. @@ -1534,19 +1781,21 @@ async def update_transactions_bulk(updates: str) -> str: raise ValueError("updates parameter must be a JSON array of transaction updates") if len(updates_list) == 0: - return json.dumps({"message": "No updates provided", "results": []}, indent=2) + return BulkUpdateResult( + summary=BulkSummary(total=0, succeeded=0, failed=0), results=[], message="No updates provided" + ) log.info("bulk_update_start", count=len(updates_list)) # Build list of update tasks - async def update_single(update_data: dict[str, Any]) -> dict[str, Any]: + async def update_single(update_data: dict[str, Any]) -> BulkItemResult: """Update a single transaction and return result with transaction_id.""" try: if not isinstance(update_data, dict): - return {"transaction_id": None, "status": "error", "error": "Update must be a dictionary"} + return BulkItemResult(transaction_id=None, status="error", error="Update must be a dictionary") if "transaction_id" not in update_data: - return {"transaction_id": None, "status": "error", "error": "transaction_id is required"} + return BulkItemResult(transaction_id=None, status="error", error="transaction_id is required") txn_id = update_data["transaction_id"] @@ -1575,16 +1824,16 @@ async def update_single(update_data: dict[str, Any]) -> dict[str, Any]: await asyncio.wait_for(api_call_with_retry("update_transaction", **update_params), timeout=30.0) # Compact success response: just confirm the update succeeded - return {"transaction_id": txn_id, "status": "success"} + return BulkItemResult(transaction_id=txn_id, status="success") except asyncio.TimeoutError: - return { - "transaction_id": update_data.get("transaction_id"), - "status": "error", - "error": "Update timed out after 30 seconds", - } + return BulkItemResult( + transaction_id=update_data.get("transaction_id"), + status="error", + error="Update timed out after 30 seconds", + ) except Exception as e: - return {"transaction_id": update_data.get("transaction_id"), "status": "error", "error": str(e)} + return BulkItemResult(transaction_id=update_data.get("transaction_id"), status="error", error=str(e)) # Execute all updates in parallel results = await asyncio.gather( @@ -1593,41 +1842,45 @@ async def update_single(update_data: dict[str, Any]) -> dict[str, Any]: ) # Count successes and failures - success_count = sum(1 for r in results if r["status"] == "success") + success_count = sum(1 for r in results if r.status == "success") failure_count = len(results) - success_count log.info("bulk_update_complete", succeeded=success_count, failed=failure_count) - response = { - "summary": {"total": len(results), "succeeded": success_count, "failed": failure_count}, - "results": results, - } - - return json.dumps(response, indent=2) + return BulkUpdateResult( + summary=BulkSummary(total=len(results), succeeded=success_count, failed=failure_count), + results=list(results), + ) except Exception as e: log.error("bulk_update_failed", error=str(e)) raise -@mcp.tool(annotations=READONLY) +@mcp.tool(annotations=READONLY, title="Get Account Holdings") @track_usage -async def get_account_holdings() -> str: - """Get investment portfolio data from brokerage accounts.""" +async def get_account_holdings(account_id: str) -> HoldingsResult: + """Get investment portfolio data (holdings) for a brokerage account. + + Args: + account_id: ID of the investment/brokerage account to fetch holdings for. + """ await ensure_authenticated() try: - holdings = await api_call_with_retry("get_account_holdings") + holdings = await api_call_with_retry("get_account_holdings", account_id=account_id) holdings = convert_dates_to_strings(holdings) - return json.dumps(holdings, indent=2) + return HoldingsResult(holdings=holdings) except Exception as e: - log.error("Failed to fetch account holdings", error=str(e)) + log.error("Failed to fetch account holdings", error=str(e), account_id=account_id) raise -@mcp.tool(annotations=READONLY) +@mcp.tool(annotations=READONLY, title="Get Account History") @track_usage -async def get_account_history(account_id: str, start_date: str | None = None, end_date: str | None = None) -> str: +async def get_account_history( + account_id: str, start_date: str | None = None, end_date: str | None = None +) -> AccountHistoryResult: """Get historical account balance data.""" await ensure_authenticated() @@ -1640,45 +1893,46 @@ async def get_account_history(account_id: str, start_date: str | None = None, en try: history = await api_call_with_retry("get_account_history", **kwargs) history = convert_dates_to_strings(history) - return json.dumps(history, indent=2) + return AccountHistoryResult(account_id=account_id, history=history) except Exception as e: log.error("Failed to fetch account history", error=str(e), account_id=account_id) raise -@mcp.tool(annotations=READONLY) +@mcp.tool(annotations=READONLY, title="Get Institutions") @track_usage -async def get_institutions() -> str: +async def get_institutions() -> InstitutionsResult: """Get linked financial institutions.""" await ensure_authenticated() try: institutions = await api_call_with_retry("get_institutions") institutions = convert_dates_to_strings(institutions) - return json.dumps(institutions, indent=2) + institution_list = institutions if isinstance(institutions, list) else [] + return InstitutionsResult(institutions=institution_list, count=len(institution_list)) except Exception as e: log.error("Failed to fetch institutions", error=str(e)) raise -@mcp.tool(annotations=READONLY) +@mcp.tool(annotations=READONLY, title="Get Recurring Transactions") @track_usage -async def get_recurring_transactions() -> str: +async def get_recurring_transactions() -> RecurringResult: """Get scheduled recurring transactions.""" await ensure_authenticated() try: recurring = await api_call_with_retry("get_recurring_transactions") recurring = convert_dates_to_strings(recurring) - return json.dumps(recurring, indent=2) + return RecurringResult(recurring=recurring) except Exception as e: log.error("Failed to fetch recurring transactions", error=str(e)) raise -@mcp.tool(annotations=WRITE_IDEMPOTENT) +@mcp.tool(annotations=WRITE_IDEMPOTENT, title="Set Budget Amount") @track_usage -async def set_budget_amount(category_id: str, amount: float) -> str: +async def set_budget_amount(category_id: str, amount: float) -> SetBudgetResult: """Set budget amount for a category.""" await ensure_authenticated() @@ -1686,15 +1940,15 @@ async def set_budget_amount(category_id: str, amount: float) -> str: result = await api_call_with_retry("set_budget_amount", category_id=category_id, amount=amount) result = convert_dates_to_strings(result) log.info("Budget amount updated", category_id=category_id, amount=amount) - return json.dumps(result, indent=2) + return SetBudgetResult(category_id=category_id, amount=amount, result=result) except Exception as e: log.error("Failed to set budget amount", error=str(e), category_id=category_id) raise -@mcp.tool(annotations=WRITE_CREATE) +@mcp.tool(annotations=WRITE_CREATE, title="Create Manual Account") @track_usage -async def create_manual_account(account_name: str, account_type: str, balance: float) -> str: +async def create_manual_account(account_name: str, account_type: str, balance: float) -> CreateAccountResult: """Create a manually tracked account.""" await ensure_authenticated() @@ -1704,17 +1958,17 @@ async def create_manual_account(account_name: str, account_type: str, balance: f ) result = convert_dates_to_strings(result) log.info("Manual account created", name=account_name, type=account_type) - return json.dumps(result, indent=2) + return CreateAccountResult(account=result) except Exception as e: log.error("Failed to create manual account", error=str(e), name=account_name) raise -@mcp.tool(annotations=READONLY) +@mcp.tool(annotations=READONLY, title="Get Spending Summary") @track_usage async def get_spending_summary( start_date: str | None = None, end_date: str | None = None, group_by: str = "category" -) -> str: +) -> SpendingSummaryResult: """Get intelligent spending summary with aggregations. Args: @@ -1784,25 +2038,35 @@ async def get_spending_summary( # Sort groups by total spending (expenses) sorted_groups = dict(sorted(summary["groups"].items(), key=lambda x: x[1]["expenses"], reverse=True)) - summary["groups"] = sorted_groups + + totals_data: dict[str, float] = summary["totals"] + result = SpendingSummaryResult( + period=Period(start=start_date, end=end_date), + group_by=group_by, + groups={ + key: GroupSummary(income=g["income"], expenses=g["expenses"], net=g["net"], count=int(g["count"])) + for key, g in sorted_groups.items() + }, + totals=Totals(income=totals_data["income"], expenses=totals_data["expenses"], net=totals_data["net"]), + ) log.info( "Spending summary generated", total_transactions=len(transactions), - groups_count=len(summary["groups"]), - net_amount=summary["totals"]["net"], + groups_count=len(result.groups), + net_amount=result.totals.net, ) - return json.dumps(summary, indent=2) + return result except Exception as e: log.error("Failed to generate spending summary", error=str(e)) raise -@mcp.tool(annotations=WRITE_SIDE_EFFECT) +@mcp.tool(annotations=WRITE_SIDE_EFFECT, title="Refresh Accounts") @track_usage -async def refresh_accounts() -> str: +async def refresh_accounts() -> RefreshResult: """Request a refresh of all account data from financial institutions.""" await ensure_authenticated() @@ -1810,15 +2074,15 @@ async def refresh_accounts() -> str: result = await api_call_with_retry("request_accounts_refresh") result = convert_dates_to_strings(result) log.info("Account refresh requested") - return json.dumps(result, indent=2) + return RefreshResult(result=result) except Exception as e: log.error("Failed to refresh accounts", error=str(e)) raise -@mcp.tool(annotations=READONLY) +@mcp.tool(annotations=READONLY, title="Complete Financial Overview") @track_usage -async def get_complete_financial_overview(period: str = "this month") -> str: +async def get_complete_financial_overview(period: str = "this month", ctx: Context | None = None) -> FinancialOverview: """Get complete financial overview in a single call - accounts, transactions, budgets, cashflow. This intelligent batch tool combines multiple API calls to provide comprehensive financial analysis, @@ -1830,6 +2094,9 @@ async def get_complete_financial_overview(period: str = "this month") -> str: await ensure_authenticated() try: + if ctx is not None: + await ctx.report_progress(0, 5, "Fetching accounts, budgets, cashflow, transactions, categories…") + # Parse the period into date filters filters = build_date_filter(period, None) @@ -1901,32 +2168,40 @@ async def get_complete_financial_overview(period: str = "this month") -> str: else: results["categories"] = {"error": str(categories)} - # Add metadata about the batch operation - results["_batch_metadata"] = { + if ctx is not None: + await ctx.report_progress(5, 5, "Assembled financial overview") + + # Metadata about the batch operation (leading underscore avoided so it + # round-trips through the Pydantic model rather than being treated as private). + results["batch_metadata"] = { "period": period, "filters_applied": convert_dates_to_strings(filters), "api_calls_made": 5, "timestamp": datetime.now().isoformat(), } + results["period"] = period accounts_val = results.get("accounts", []) + summary_val = results.get("transaction_summary") log.info( "Complete financial overview generated", period=period, accounts_count=len(accounts_val) if isinstance(accounts_val, list) else 0, - transactions_count=results.get("transaction_summary", {}).get("total_count", 0), + transactions_count=summary_val.get("total_count", 0) if isinstance(summary_val, dict) else 0, ) - return json.dumps(results, indent=2) + return FinancialOverview.model_validate(results) except Exception as e: log.error("Failed to generate financial overview", error=str(e), period=period) raise -@mcp.tool(annotations=READONLY) +@mcp.tool(annotations=READONLY, title="Analyze Spending Patterns") @track_usage -async def analyze_spending_patterns(lookback_months: int = 6, include_forecasting: bool = True) -> str: +async def analyze_spending_patterns( + lookback_months: int = 6, include_forecasting: bool = True, ctx: Context | None = None +) -> SpendingPatterns: """Intelligent spending pattern analysis with trend forecasting. Combines multiple data sources to provide deep spending insights including: @@ -1942,6 +2217,9 @@ async def analyze_spending_patterns(lookback_months: int = 6, include_forecastin await ensure_authenticated() try: + if ctx is not None: + await ctx.report_progress(0, 2, "Fetching transactions, budgets, accounts, categories…") + # Calculate date ranges for analysis end_date = datetime.now().date() start_date = end_date - relativedelta(months=lookback_months) @@ -1959,6 +2237,9 @@ async def analyze_spending_patterns(lookback_months: int = 6, include_forecastin ) transactions, budgets, accounts, categories = api_results + if ctx is not None: + await ctx.report_progress(1, 2, "Computing trends, category and account analysis…") + analysis = { "analysis_period": { "start_date": start_date.isoformat(), @@ -2051,14 +2332,17 @@ async def analyze_spending_patterns(lookback_months: int = 6, include_forecastin if not isinstance(budgets, Exception): analysis["budget_performance"] = convert_dates_to_strings(budgets) - # Add metadata + # Metadata (leading underscore avoided so it round-trips through the model). txn_count = len(transactions_list) if not isinstance(transactions, Exception) else 0 - analysis["_metadata"] = { + analysis["metadata"] = { "api_calls_made": 4, "total_transactions_analyzed": txn_count, "analysis_timestamp": datetime.now().isoformat(), } + if ctx is not None: + await ctx.report_progress(2, 2, "Spending pattern analysis complete") + log.info( "Spending pattern analysis completed", lookback_months=lookback_months, @@ -2066,7 +2350,7 @@ async def analyze_spending_patterns(lookback_months: int = 6, include_forecastin include_forecasting=include_forecasting, ) - return json.dumps(analysis, indent=2) + return SpendingPatterns.model_validate(analysis) except Exception as e: log.error("Failed to analyze spending patterns", error=str(e), lookback_months=lookback_months) diff --git a/tests/test_analytics.py b/tests/test_analytics.py index 72d32ae..edd2341 100644 --- a/tests/test_analytics.py +++ b/tests/test_analytics.py @@ -66,8 +66,8 @@ async def test_get_complete_financial_overview(self) -> None: with patch.object(server, "ensure_authenticated", new_callable=AsyncMock): result = await server.get_complete_financial_overview("this month") - assert isinstance(result, str) - overview = json.loads(result) + assert isinstance(result, server.FinancialOverview) + overview = json.loads(result.model_dump_json()) # Verify all data sources are included assert "accounts" in overview @@ -76,7 +76,7 @@ async def test_get_complete_financial_overview(self) -> None: assert "transactions" in overview assert "categories" in overview assert "transaction_summary" in overview - assert "_batch_metadata" in overview + assert "batch_metadata" in overview # Verify transaction summary summary = overview["transaction_summary"] @@ -85,7 +85,7 @@ async def test_get_complete_financial_overview(self) -> None: assert summary["unique_categories"] == 1 # Verify metadata - metadata = overview["_batch_metadata"] + metadata = overview["batch_metadata"] assert metadata["api_calls_made"] == 5 assert "timestamp" in metadata @@ -114,8 +114,8 @@ async def test_analyze_spending_patterns(self) -> None: with patch.object(server, "ensure_authenticated", new_callable=AsyncMock): result = await server.analyze_spending_patterns(lookback_months=3, include_forecasting=True) - assert isinstance(result, str) - analysis = json.loads(result) + assert isinstance(result, server.SpendingPatterns) + analysis = json.loads(result.model_dump_json()) # Verify analysis structure assert "analysis_period" in analysis @@ -123,7 +123,7 @@ async def test_analyze_spending_patterns(self) -> None: assert "category_analysis" in analysis assert "account_usage" in analysis assert "forecast" in analysis - assert "_metadata" in analysis + assert "metadata" in analysis # Verify monthly trends monthly_trends = analysis["monthly_trends"] @@ -165,8 +165,8 @@ async def test_batch_error_handling(self) -> None: with patch.object(server, "ensure_authenticated", new_callable=AsyncMock): result = await server.get_complete_financial_overview("this month") - assert isinstance(result, str) - overview = json.loads(result) + assert isinstance(result, server.FinancialOverview) + overview = json.loads(result.model_dump_json()) # Verify successful data is included assert "accounts" in overview diff --git a/tests/test_bulk_updates.py b/tests/test_bulk_updates.py index a58a456..f89525f 100644 --- a/tests/test_bulk_updates.py +++ b/tests/test_bulk_updates.py @@ -34,7 +34,7 @@ async def test_update_transactions_bulk_success(self): with patch("server.mm_client", mock_client), patch("server.ensure_authenticated", new_callable=AsyncMock): result_str = await update_transactions_bulk(updates_json) - result = json.loads(result_str) + result = json.loads(result_str.model_dump_json()) # Verify summary assert result["summary"]["total"] == 2 @@ -69,7 +69,7 @@ async def mock_update(**kwargs): with patch("server.mm_client", mock_client), patch("server.ensure_authenticated", new_callable=AsyncMock): result_str = await update_transactions_bulk(updates_json) - result = json.loads(result_str) + result = json.loads(result_str.model_dump_json()) # Verify summary shows mixed results assert result["summary"]["total"] == 2 @@ -110,7 +110,7 @@ async def test_update_transactions_bulk_missing_transaction_id(self): with patch("server.mm_client", mock_client), patch("server.ensure_authenticated", new_callable=AsyncMock): result_str = await update_transactions_bulk(updates_json) - result = json.loads(result_str) + result = json.loads(result_str.model_dump_json()) # Should have error result assert result["summary"]["failed"] == 1 @@ -124,7 +124,7 @@ async def test_update_transactions_bulk_empty_array(self): with patch("server.ensure_authenticated", new_callable=AsyncMock): result_str = await update_transactions_bulk("[]") - result = json.loads(result_str) + result = json.loads(result_str.model_dump_json()) assert result["message"] == "No updates provided" assert result["results"] == [] @@ -141,7 +141,7 @@ async def test_update_transactions_bulk_date_parsing(self): with patch("server.mm_client", mock_client), patch("server.ensure_authenticated", new_callable=AsyncMock): result_str = await update_transactions_bulk(updates_json) - result = json.loads(result_str) + result = json.loads(result_str.model_dump_json()) # Verify date was passed correctly assert result["results"][0]["status"] == "success" @@ -177,7 +177,7 @@ async def test_update_transactions_bulk_all_fields(self): with patch("server.mm_client", mock_client), patch("server.ensure_authenticated", new_callable=AsyncMock): result_str = await update_transactions_bulk(updates_json) - result = json.loads(result_str) + result = json.loads(result_str.model_dump_json()) assert result["results"][0]["status"] == "success" diff --git a/tests/test_fastmcp.py b/tests/test_fastmcp.py index ebc6bfd..25d4587 100644 --- a/tests/test_fastmcp.py +++ b/tests/test_fastmcp.py @@ -1,6 +1,5 @@ """Tests for FastMCP server implementation.""" -import json from unittest.mock import AsyncMock, patch import pytest @@ -99,10 +98,10 @@ async def test_get_accounts_with_mock_client(self) -> None: try: result = await server.get_accounts() - # Verify result is JSON string - assert isinstance(result, str) - parsed_result = json.loads(result) - assert parsed_result == mock_accounts_data + # Verify structured result wraps the account list with a count + assert isinstance(result, server.AccountsResult) + assert result.accounts == mock_accounts_data + assert result.count == len(mock_accounts_data) # Verify mock was called mock_client.get_accounts.assert_called_once() @@ -133,10 +132,11 @@ async def test_get_transactions_with_filters(self) -> None: verbose=True, # Get full transaction details for testing ) - # Verify result is JSON string - assert isinstance(result, str) - parsed_result = json.loads(result) - assert parsed_result == mock_transactions + # Verify structured result wraps the transaction list + assert isinstance(result, server.TransactionsResult) + assert result.transactions == mock_transactions + assert result.count == len(mock_transactions) + assert result.verbose is True # Verify mock was called with correct parameters mock_client.get_transactions.assert_called_once() @@ -170,10 +170,9 @@ async def test_create_transaction(self) -> None: notes="Test notes", ) - # Verify result is JSON string - assert isinstance(result, str) - parsed_result = json.loads(result) - assert parsed_result == mock_result + # Verify structured result wraps the created transaction + assert isinstance(result, server.TransactionResult) + assert result.transaction == mock_result # Verify mock was called with correct parameters mock_client.create_transaction.assert_called_once() diff --git a/tests/test_mcp_features.py b/tests/test_mcp_features.py index 9fa9cac..eaf7e34 100644 --- a/tests/test_mcp_features.py +++ b/tests/test_mcp_features.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, patch import pytest +from mcp.types import CompletionArgument, PromptReference, ResourceTemplateReference import server @@ -123,3 +124,113 @@ def test_transaction_categorization_help_returns_prompt(self) -> None: assert "Amazon Purchase" in result assert "categories://list" in result assert "Best Category Match" in result + + +class TestDisplayTitles: + """Every tool and prompt advertises a human-friendly title (2025-06-18).""" + + def test_all_tools_have_titles(self) -> None: + untitled = [t.name for t in server.mcp._tool_manager.list_tools() if not t.title] + assert untitled == [] + + def test_all_prompts_have_titles(self) -> None: + untitled = [p.name for p in server.mcp._prompt_manager.list_prompts() if not p.title] + assert untitled == [] + + def test_specific_tool_title(self) -> None: + assert server.mcp._tool_manager.get_tool("get_accounts").title == "Get Accounts" + + +class TestResourceTemplates: + """Parameterized resource templates for per-account holdings and history.""" + + @pytest.mark.asyncio + async def test_templates_are_registered(self) -> None: + templates = await server.mcp.list_resource_templates() + uris = {t.uriTemplate for t in templates} + assert "accounts://{account_id}/holdings" in uris + assert "accounts://{account_id}/history" in uris + + @pytest.mark.asyncio + async def test_holdings_template_calls_api_with_account_id(self) -> None: + with patch.object(server, "ensure_authenticated", new_callable=AsyncMock): + with patch.object( + server, "api_call_with_retry", new_callable=AsyncMock, return_value=[{"symbol": "AAPL"}] + ) as mock_api: + result = await server.account_holdings_resource(account_id="acc_1") + mock_api.assert_awaited_once_with("get_account_holdings", account_id="acc_1") + assert "AAPL" in result + + @pytest.mark.asyncio + async def test_history_template_calls_api_with_account_id(self) -> None: + with patch.object(server, "ensure_authenticated", new_callable=AsyncMock): + with patch.object( + server, "api_call_with_retry", new_callable=AsyncMock, return_value=[{"balance": 100}] + ) as mock_api: + result = await server.account_history_resource(account_id="acc_9") + mock_api.assert_awaited_once_with("get_account_history", account_id="acc_9") + assert "100" in result + + +class TestCompletions: + """Argument autocompletion for prompts and resource templates.""" + + @pytest.mark.asyncio + async def test_category_completion_filters_live_names(self, mock_api: AsyncMock) -> None: + mock_api.return_value = [{"id": "c1", "name": "Groceries"}, {"id": "c2", "name": "Gas"}] + ref = PromptReference(type="ref/prompt", name="analyze_spending") + completion = await server.handle_completion(ref, CompletionArgument(name="category", value="gro"), None) + assert completion is not None + assert completion.values == ["Groceries"] + + @pytest.mark.asyncio + async def test_account_id_completion_filters_live_ids(self, mock_api: AsyncMock) -> None: + mock_api.return_value = [{"id": "acc_123"}, {"id": "acc_999"}] + ref = ResourceTemplateReference(type="ref/resource", uri="accounts://{account_id}/holdings") + completion = await server.handle_completion(ref, CompletionArgument(name="account_id", value="123"), None) + assert completion is not None + assert completion.values == ["acc_123"] + + @pytest.mark.asyncio + async def test_completion_returns_none_for_unknown_argument(self, mock_api: AsyncMock) -> None: + ref = PromptReference(type="ref/prompt", name="budget_review") + completion = await server.handle_completion(ref, CompletionArgument(name="month", value="Jan"), None) + assert completion is None + + @pytest.mark.asyncio + async def test_completion_is_best_effort_on_api_failure(self, mock_api: AsyncMock) -> None: + # A failing upstream call must not raise out of a completion request. + mock_api.side_effect = RuntimeError("API down") + ref = PromptReference(type="ref/prompt", name="analyze_spending") + completion = await server.handle_completion(ref, CompletionArgument(name="category", value="x"), None) + assert completion is not None + assert completion.values == [] + + +class TestProgressReporting: + """Batch tools report progress through an injected Context.""" + + @pytest.mark.asyncio + async def test_overview_reports_progress(self, mock_api: AsyncMock) -> None: + mock_api.return_value = [] + ctx = AsyncMock() + await server.get_complete_financial_overview(period="this month", ctx=ctx) + assert ctx.report_progress.await_count >= 2 + first_progress = ctx.report_progress.await_args_list[0].args[0] + last_progress = ctx.report_progress.await_args_list[-1].args[0] + assert first_progress == 0 + assert last_progress == 5 + + @pytest.mark.asyncio + async def test_analyze_patterns_reports_progress(self, mock_api: AsyncMock) -> None: + mock_api.return_value = [] + ctx = AsyncMock() + await server.analyze_spending_patterns(lookback_months=2, include_forecasting=False, ctx=ctx) + assert ctx.report_progress.await_count >= 2 + + @pytest.mark.asyncio + async def test_overview_works_without_context(self, mock_api: AsyncMock) -> None: + # ctx is optional; omitting it must not raise. + mock_api.return_value = [] + result = await server.get_complete_financial_overview(period="this month") + assert isinstance(result, server.FinancialOverview) diff --git a/tests/test_new_tools.py b/tests/test_new_tools.py index b2ef05e..e7490ad 100644 --- a/tests/test_new_tools.py +++ b/tests/test_new_tools.py @@ -1,6 +1,5 @@ """Tests for new Monarch Money API tools.""" -import json from unittest.mock import AsyncMock import pytest @@ -25,12 +24,11 @@ async def test_get_account_holdings(self) -> None: server.mm_client = mock_client try: - result = await server.get_account_holdings() + result = await server.get_account_holdings(account_id="acc123") - assert isinstance(result, str) - parsed_result = json.loads(result) - assert parsed_result == mock_holdings - mock_client.get_account_holdings.assert_called_once() + assert isinstance(result, server.HoldingsResult) + assert result.holdings == mock_holdings + mock_client.get_account_holdings.assert_called_once_with(account_id="acc123") finally: server.mm_client = original_client @@ -50,9 +48,9 @@ async def test_get_account_history(self) -> None: account_id="acc123", start_date="2024-01-01", end_date="2024-01-31" ) - assert isinstance(result, str) - parsed_result = json.loads(result) - assert parsed_result == mock_history + assert isinstance(result, server.AccountHistoryResult) + assert result.history == mock_history + assert result.account_id == "acc123" # Verify call parameters mock_client.get_account_history.assert_called_once() @@ -77,9 +75,9 @@ async def test_get_institutions(self) -> None: try: result = await server.get_institutions() - assert isinstance(result, str) - parsed_result = json.loads(result) - assert parsed_result == mock_institutions + assert isinstance(result, server.InstitutionsResult) + assert result.institutions == mock_institutions + assert result.count == len(mock_institutions) mock_client.get_institutions.assert_called_once() finally: @@ -101,9 +99,8 @@ async def test_get_recurring_transactions(self) -> None: try: result = await server.get_recurring_transactions() - assert isinstance(result, str) - parsed_result = json.loads(result) - assert parsed_result == mock_recurring + assert isinstance(result, server.RecurringResult) + assert result.recurring == mock_recurring mock_client.get_recurring_transactions.assert_called_once() finally: @@ -122,9 +119,10 @@ async def test_set_budget_amount(self) -> None: try: result = await server.set_budget_amount(category_id="cat123", amount=500.0) - assert isinstance(result, str) - parsed_result = json.loads(result) - assert parsed_result == mock_result + assert isinstance(result, server.SetBudgetResult) + assert result.result == mock_result + assert result.category_id == "cat123" + assert result.amount == 500.0 # Verify parameters mock_client.set_budget_amount.assert_called_once() @@ -150,9 +148,8 @@ async def test_create_manual_account(self) -> None: account_name="My Savings", account_type="savings", balance=1000.0 ) - assert isinstance(result, str) - parsed_result = json.loads(result) - assert parsed_result == mock_result + assert isinstance(result, server.CreateAccountResult) + assert result.account == mock_result # Verify parameters mock_client.create_manual_account.assert_called_once() @@ -175,7 +172,7 @@ async def test_error_handling_in_new_tools(self) -> None: try: with pytest.raises(Exception, match="API Error"): - await server.get_account_holdings() + await server.get_account_holdings(account_id="acc123") mock_client.get_account_holdings.assert_called_once() diff --git a/tests/test_search.py b/tests/test_search.py index c4fe238..71a2f02 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -46,7 +46,7 @@ async def test_search_transactions_basic(self): result = await server.search_transactions(query="Apple") - result_data = json.loads(result) + result_data = json.loads(result.model_dump_json()) # Should receive 2 Apple transactions from the API assert result_data["search_metadata"]["result_count"] == 2 @@ -108,7 +108,7 @@ async def test_search_transactions_returns_api_results(self): mock_client.get_transactions = AsyncMock(return_value=mock_transactions) result = await server.search_transactions(query="laptop") - result_data = json.loads(result) + result_data = json.loads(result.model_dump_json()) assert result_data["search_metadata"]["result_count"] == 1 assert len(result_data["transactions"]) == 1 @@ -133,7 +133,7 @@ async def test_search_transactions_with_date_filter(self): result = await server.search_transactions(query="Apple", start_date="2024-01-01", end_date="2024-01-31") - result_data = json.loads(result) + result_data = json.loads(result.model_dump_json()) assert result_data["search_metadata"]["result_count"] == 1 # Verify filters were applied to API call @@ -150,7 +150,7 @@ async def test_search_transactions_no_matches(self): mock_client.get_transactions = AsyncMock(return_value=mock_transactions) result = await server.search_transactions(query="Apple") - result_data = json.loads(result) + result_data = json.loads(result.model_dump_json()) assert result_data["search_metadata"]["result_count"] == 0 assert len(result_data["transactions"]) == 0 @@ -186,7 +186,7 @@ async def test_search_transactions_verbose_format(self): # Test verbose=True (full details) result = await server.search_transactions(query="Apple", verbose=True) - result_data = json.loads(result) + result_data = json.loads(result.model_dump_json()) txn = result_data["transactions"][0] # Should have all fields including nested objects @@ -196,7 +196,7 @@ async def test_search_transactions_verbose_format(self): # Test verbose=False (compact) result = await server.search_transactions(query="Apple", verbose=False) - result_data = json.loads(result) + result_data = json.loads(result.model_dump_json()) txn = result_data["transactions"][0] # Should be compact format (merchant name as string) @@ -224,7 +224,7 @@ async def test_search_api_handles_query(self): # API does the matching result = await server.search_transactions(query="Apple") - result_data = json.loads(result) + result_data = json.loads(result.model_dump_json()) assert result_data["search_metadata"]["result_count"] == 1 # Verify search parameter was passed @@ -252,7 +252,7 @@ async def test_search_transactions_with_account_filter(self): result = await server.search_transactions(query="Apple", account_id="acc123") - result_data = json.loads(result) + result_data = json.loads(result.model_dump_json()) assert result_data["search_metadata"]["result_count"] == 1 # Verify the API was called with the account filter as a list @@ -287,12 +287,11 @@ async def test_search_returns_same_format_as_get_transactions(self): # Get search results search_result = await server.search_transactions(query="Apple", verbose=False) - search_data = json.loads(search_result) - search_txns = search_data["transactions"] + search_txns = json.loads(search_result.model_dump_json())["transactions"] # Get regular transactions with compact format get_result = await server.get_transactions(limit=100, verbose=False) - get_txns = json.loads(get_result) + get_txns = json.loads(get_result.model_dump_json())["transactions"] # Both should have same compact format assert search_txns and get_txns, "both queries should return the mocked transaction" diff --git a/tests/test_structured_output.py b/tests/test_structured_output.py new file mode 100644 index 0000000..4d0682e --- /dev/null +++ b/tests/test_structured_output.py @@ -0,0 +1,68 @@ +"""Tests for MCP structured tool output (outputSchema + structured content). + +Every tool returns a Pydantic model so FastMCP advertises an ``outputSchema`` and +emits machine-readable structured content alongside a text fallback. These tests +assert the schema is present for all tools and that a representative call produces +both content forms, with the structured payload validating against the tool's model. +""" + +from unittest.mock import AsyncMock + +import pytest + +import server + +mgr = server.mcp._tool_manager + + +def test_every_tool_advertises_output_schema() -> None: + """All registered tools must expose a non-null outputSchema.""" + without_schema = [t.name for t in mgr.list_tools() if getattr(t, "output_schema", None) is None] + assert without_schema == [] + + +def test_tool_count_is_nineteen() -> None: + assert len(mgr.list_tools()) == 19 + + +@pytest.mark.parametrize( + ("tool_name", "schema_props"), + [ + ("get_accounts", {"accounts", "count"}), + ("get_transactions", {"transactions", "count", "verbose"}), + ("get_spending_summary", {"period", "group_by", "groups", "totals"}), + ("update_transactions_bulk", {"summary", "results", "message"}), + ("get_complete_financial_overview", {"period", "accounts", "transaction_summary"}), + ], +) +def test_output_schema_declares_expected_properties(tool_name: str, schema_props: set[str]) -> None: + tool = mgr.get_tool(tool_name) + declared = set((tool.output_schema or {}).get("properties", {}).keys()) + assert schema_props.issubset(declared) + + +@pytest.mark.asyncio +async def test_call_tool_returns_text_and_structured_content(mock_api: AsyncMock) -> None: + """A converted tool yields both a text fallback and structured content.""" + mock_api.return_value = [{"id": "acc_1", "displayName": "Checking"}] + content, structured = await mgr.call_tool("get_accounts", {}, convert_result=True) + + # Text fallback present for older clients. + assert content and content[0].type == "text" + # Structured content matches the AccountsResult shape and re-validates. + assert structured == {"accounts": [{"id": "acc_1", "displayName": "Checking"}], "count": 1} + server.AccountsResult.model_validate(structured) + + +@pytest.mark.asyncio +async def test_structured_content_validates_for_derived_model(mock_api: AsyncMock) -> None: + """Spending summary structured content validates against its precise model.""" + mock_api.return_value = [ + {"amount": -40.0, "category": {"name": "Groceries"}, "date": "2024-01-05"}, + {"amount": 2000.0, "category": {"name": "Income"}, "date": "2024-01-01"}, + ] + _content, structured = await mgr.call_tool("get_spending_summary", {"group_by": "category"}, convert_result=True) + parsed = server.SpendingSummaryResult.model_validate(structured) + assert parsed.totals.income == 2000.0 + assert parsed.groups["Groceries"].expenses == 40.0 + assert parsed.groups["Groceries"].count == 1 diff --git a/tests/test_tool_coverage.py b/tests/test_tool_coverage.py index bb817a4..c067589 100644 --- a/tests/test_tool_coverage.py +++ b/tests/test_tool_coverage.py @@ -10,7 +10,6 @@ independent of global auth state. """ -import json from collections.abc import Awaitable, Callable from typing import Any from unittest.mock import AsyncMock @@ -52,7 +51,7 @@ def _side_effect(method_name: str, *args: Any, **kwargs: Any) -> Any: id="create_transaction", ), pytest.param(lambda: server.update_transaction(transaction_id="txn_1", notes="memo"), id="update_transaction"), - pytest.param(lambda: server.get_account_holdings(), id="get_account_holdings"), + pytest.param(lambda: server.get_account_holdings(account_id="acc_1"), id="get_account_holdings"), pytest.param(lambda: server.get_account_history(account_id="acc_1"), id="get_account_history"), pytest.param(lambda: server.get_institutions(), id="get_institutions"), pytest.param(lambda: server.get_recurring_transactions(), id="get_recurring_transactions"), @@ -100,23 +99,23 @@ class TestReadToolSuccess: @pytest.mark.asyncio async def test_get_budgets_returns_budget_data(self, mock_api: AsyncMock) -> None: mock_api.return_value = {"budgets": [{"category_id": "cat_1", "amount": 500}]} - data = json.loads(await server.get_budgets()) - assert data["budgets"][0]["category_id"] == "cat_1" + result = await server.get_budgets() + assert result.budgets["budgets"][0]["category_id"] == "cat_1" @pytest.mark.asyncio async def test_get_budgets_empty_when_none_configured(self, mock_api: AsyncMock) -> None: # The API raises this specific string when no budgets exist; the tool maps # it to an empty result rather than an error. mock_api.side_effect = Exception("Something went wrong while processing: None") - data = json.loads(await server.get_budgets()) - assert data["budgets"] == [] - assert "No budgets" in data["message"] + result = await server.get_budgets() + assert result.budgets == [] + assert "No budgets" in (result.message or "") @pytest.mark.asyncio async def test_get_cashflow_returns_cashflow_data(self, mock_api: AsyncMock) -> None: mock_api.return_value = {"income": 5000, "expenses": 3200} - data = json.loads(await server.get_cashflow()) - assert data["income"] == 5000 + result = await server.get_cashflow() + assert result.cashflow["income"] == 5000 @pytest.mark.asyncio async def test_get_transaction_categories_compact_strips_to_id_and_name(self, mock_api: AsyncMock) -> None: @@ -124,15 +123,17 @@ async def test_get_transaction_categories_compact_strips_to_id_and_name(self, mo {"id": "cat_1", "name": "Groceries", "group": {"name": "Food"}, "order": 3}, {"id": "cat_2", "name": "Transit", "group": {"name": "Auto"}, "order": 4}, ] - data = json.loads(await server.get_transaction_categories(verbose=False)) - assert data == [{"id": "cat_1", "name": "Groceries"}, {"id": "cat_2", "name": "Transit"}] + result = await server.get_transaction_categories(verbose=False) + assert result.categories == [{"id": "cat_1", "name": "Groceries"}, {"id": "cat_2", "name": "Transit"}] + assert result.count == 2 + assert result.verbose is False @pytest.mark.asyncio async def test_get_transaction_categories_verbose_keeps_all_fields(self, mock_api: AsyncMock) -> None: mock_api.return_value = [{"id": "cat_1", "name": "Groceries", "group": {"name": "Food"}, "order": 3}] - data = json.loads(await server.get_transaction_categories(verbose=True)) - assert data[0]["group"] == {"name": "Food"} - assert data[0]["order"] == 3 + result = await server.get_transaction_categories(verbose=True) + assert result.categories[0]["group"] == {"name": "Food"} + assert result.categories[0]["order"] == 3 @pytest.mark.asyncio async def test_get_spending_summary_aggregates_by_category(self, mock_api: AsyncMock) -> None: @@ -141,18 +142,18 @@ async def test_get_spending_summary_aggregates_by_category(self, mock_api: Async {"amount": -10.0, "category": {"name": "Groceries"}, "date": "2024-01-09"}, {"amount": 2000.0, "category": {"name": "Income"}, "date": "2024-01-01"}, ] - data = json.loads(await server.get_spending_summary(group_by="category")) - assert data["groups"]["Groceries"]["expenses"] == 50.0 - assert data["groups"]["Groceries"]["count"] == 2 - assert data["totals"]["income"] == 2000.0 - assert data["totals"]["expenses"] == 50.0 - assert data["totals"]["net"] == 1950.0 + result = await server.get_spending_summary(group_by="category") + assert result.groups["Groceries"].expenses == 50.0 + assert result.groups["Groceries"].count == 2 + assert result.totals.income == 2000.0 + assert result.totals.expenses == 50.0 + assert result.totals.net == 1950.0 @pytest.mark.asyncio async def test_refresh_accounts_returns_result(self, mock_api: AsyncMock) -> None: mock_api.return_value = {"status": "refresh_requested"} - data = json.loads(await server.refresh_accounts()) - assert data["status"] == "refresh_requested" + result = await server.refresh_accounts() + assert result.result["status"] == "refresh_requested" class TestResourceSuccess: @@ -179,11 +180,11 @@ async def test_analyze_spending_patterns_degrades_when_transactions_fail(self, m "get_transaction_categories": [], } ) - data = json.loads(await server.analyze_spending_patterns(lookback_months=3, include_forecasting=False)) + result = await server.analyze_spending_patterns(lookback_months=3, include_forecasting=False) # Tool returns a valid analysis with empty trends rather than raising. - assert data["monthly_trends"] == {} - assert data["category_analysis"] == {} - assert data["analysis_period"]["months_analyzed"] == 3 + assert result.monthly_trends == {} + assert result.category_analysis == {} + assert result.analysis_period["months_analyzed"] == 3 @pytest.mark.asyncio async def test_analyze_spending_patterns_builds_trends_on_success(self, mock_api: AsyncMock) -> None: @@ -199,7 +200,7 @@ async def test_analyze_spending_patterns_builds_trends_on_success(self, mock_api "get_transaction_categories": [], } ) - data = json.loads(await server.analyze_spending_patterns(lookback_months=3, include_forecasting=False)) - assert "2024-01" in data["monthly_trends"] - assert "2024-02" in data["monthly_trends"] - assert data["category_analysis"]["Food"]["total"] == 125.0 + result = await server.analyze_spending_patterns(lookback_months=3, include_forecasting=False) + assert "2024-01" in result.monthly_trends + assert "2024-02" in result.monthly_trends + assert result.category_analysis["Food"]["total"] == 125.0 diff --git a/tests/test_validation_fastmcp.py b/tests/test_validation_fastmcp.py index a0d43d0..ffb72f7 100644 --- a/tests/test_validation_fastmcp.py +++ b/tests/test_validation_fastmcp.py @@ -1,6 +1,5 @@ """Tests for FastMCP validation and parameter handling.""" -import json from unittest.mock import AsyncMock, patch import pytest @@ -34,9 +33,8 @@ async def test_get_transactions_with_valid_parameters(self) -> None: verbose=True, # Get full transaction details for testing ) - assert isinstance(result, str) - parsed_result = json.loads(result) - assert parsed_result == mock_transactions + assert isinstance(result, server.TransactionsResult) + assert result.transactions == mock_transactions # Verify mock was called with correct parameters mock_client.get_transactions.assert_called_once() @@ -62,9 +60,8 @@ async def test_get_transactions_with_defaults(self) -> None: # Test with no parameters (should use defaults) result = await server.get_transactions(verbose=True) # Get full transaction details for testing - assert isinstance(result, str) - parsed_result = json.loads(result) - assert parsed_result == mock_transactions + assert isinstance(result, server.TransactionsResult) + assert result.transactions == mock_transactions # Verify defaults were used mock_client.get_transactions.assert_called_once() @@ -95,9 +92,8 @@ async def test_create_transaction_with_required_parameters(self) -> None: category_id="cat123", ) - assert isinstance(result, str) - parsed_result = json.loads(result) - assert parsed_result == mock_result + assert isinstance(result, server.TransactionResult) + assert result.transaction == mock_result # Verify parameters were passed correctly mock_client.create_transaction.assert_called_once() @@ -131,9 +127,8 @@ async def test_create_transaction_with_optional_parameters(self) -> None: notes="Test notes", ) - assert isinstance(result, str) - parsed_result = json.loads(result) - assert parsed_result == mock_result + assert isinstance(result, server.TransactionResult) + assert result.transaction == mock_result # Verify optional parameters were passed mock_client.create_transaction.assert_called_once() @@ -163,9 +158,8 @@ async def test_update_transaction_with_partial_updates(self) -> None: # Other fields left as None (default) ) - assert isinstance(result, str) - parsed_result = json.loads(result) - assert parsed_result == mock_result + assert isinstance(result, server.TransactionResult) + assert result.transaction == mock_result # Verify only specified fields were passed mock_client.update_transaction.assert_called_once() From 17305d95451a1cb09d854e46dd4a065d83a0fc87 Mon Sep 17 00:00:00 2001 From: Jamie Dubs <1903+jamiew@users.noreply.github.com> Date: Thu, 28 May 2026 22:39:34 -0400 Subject: [PATCH 2/7] Refresh CLAUDE.md for MCP modernization - bump the cited MCP protocol version to 2025-11-25 and note the stateless draft (we already log to stderr) - record the newly adopted structured output, titles, resource templates, completions, and progress features - update stale metrics to 202 tests / 19 tools / mypy-clean --- CLAUDE.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c77d2f6..e1ff52c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -248,9 +248,10 @@ Server runs as MCP server configured in `.mcp.json` with: - **✅ Structured Logging**: Context-rich logs with `structlog` - **✅ Complete API Coverage**: All 14 Monarch Money API methods as tools -#### Quality Metrics (Updated October 2025) -- **70 passing tests** with comprehensive coverage including analytics, search, and bulk operations -- **MyPy errors reduced**: 111 → 84 (mainly untyped external decorators, ongoing improvement) +#### Quality Metrics (Updated May 2026) +- **202 passing tests** with comprehensive coverage including analytics, search, bulk operations, structured output, completions, resource templates, and progress +- **19 tools** (all returning typed Pydantic models / structured output), 3 static resources + 2 resource templates, 4 prompts +- **MyPy clean** under the repo's strict config (no `Any` at non-boundaries, no `as`) - **Security**: Proper session handling and MFA support - **Modern stack**: FastMCP 1.12.2, Pydantic, structlog, pytest - **Usage analytics**: Real-time performance tracking and optimization suggestions @@ -400,7 +401,7 @@ Server runs as MCP server configured in `.mcp.json` with: 5. **Phase 5 (Intelligence)**: ML features, advanced analytics, financial insights 6. **Phase 6 (Ecosystem)**: MCP extensions, developer tools, architectural improvements -**Current Status** (Updated October 2025): Production-ready with 70 passing tests, 22 intelligent tools, comprehensive analytics, robust error handling, and enhanced reliability. Recent features: `update_transactions_bulk()` for parallel batch updates, `search_transactions` tool for efficient context-aware search, detailed tool call debugging with result size tracking. Recent critical fixes completed: authentication retry bug (stale client after re-auth), date serialization, broken pipe handling, dependency updates, and enhanced date parsing. Server is stable and ready for real-world usage with excellent user experience for date filtering, bulk operations, and error recovery. +**Current Status** (Updated May 2026): Production-ready with 202 passing tests, 19 intelligent tools, comprehensive analytics, robust error handling, and enhanced reliability. Recent MCP modernization: every tool returns structured output (outputSchema + structured content with a text fallback), tools/resources/prompts carry human-friendly `title`s, parameterized resource templates (`accounts://{account_id}/holdings|history`), argument completions for prompts/templates, and Context-based progress reporting on the batch tools. Earlier features: `update_transactions_bulk()` for parallel batch updates, `search_transactions`, result-size tracking; fixes for the auth retry bug, date serialization, broken pipes, and date parsing. Note: `get_account_holdings` now requires an `account_id` (the underlying library always did). ## Upstream Library & Fork Landscape @@ -436,5 +437,6 @@ Re-run this analysis periodically (e.g. quarterly, or when something breaks): - **MCP Python SDK**: https://github.com/modelcontextprotocol/python-sdk - **Monarch Money API**: https://github.com/hammem/monarchmoney - **MCP Server Examples**: https://github.com/modelcontextprotocol/servers -- Current MCP Protocol Version: "2025-06-18" +- Current stable MCP Protocol Version: "2025-11-25" (a newer draft exists that goes stateless and deprecates Sampling/Roots/MCP-logging — this server already logs to stderr, so it's well-positioned) +- This server uses structured tool output (outputSchema), tool/resource/prompt titles, resource templates, completions, and Context progress reporting (all 2025-06-18 features) - Re-read all resources regularly to ensure compliance with any API or protocol changes \ No newline at end of file From 9314314bacb27aa9ef7863176aeb846ce78804b9 Mon Sep 17 00:00:00 2001 From: Jamie Dubs <1903+jamiew@users.noreply.github.com> Date: Thu, 28 May 2026 22:43:07 -0400 Subject: [PATCH 3/7] Add changelog entry for MCP modernization - documents the structured output, titles, resource templates, completions, and progress work under today's date. --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ad0f71..366c6dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## 2026-05-28 +### MCP 2025 protocol features: structured output, titles, resource templates, completions, progress + +- every tool now returns a typed Pydantic model, so FastMCP advertises an `outputSchema` and emits machine-readable structured content alongside a text fallback for older clients. +- added human-friendly `title`s to all tools, resources, and prompts. +- added parameterized resource templates: `accounts://{account_id}/holdings` and `accounts://{account_id}/history`. +- added argument completions for the prompt `category` and resource-template `account_id`, backed by live Monarch data. +- the batch tools (complete overview, spending patterns) now report progress via an injected Context. +- fixed `get_account_holdings` to pass the required `account_id` — the no-arg version was a latent bug. + ### Dependencies refreshed and mypy 2.x adopted - bumped the lockfile to latest compatible across the board (pydantic 2.13, cryptography 48, starlette 1.2, rich 15, mcp 1.27, monarchmoneycommunity 1.3.2). From 0e1ad66fdcf6d65a0d90c9756f19ed7781a6cc32 Mon Sep 17 00:00:00 2001 From: Jamie Dubs <1903+jamiew@users.noreply.github.com> Date: Thu, 28 May 2026 22:55:42 -0400 Subject: [PATCH 4/7] Unwrap dict-shaped Monarch responses in structured tools - the real client returns dicts like `{"accounts": [...]}` / `{"categories": [...]}`, so the new `list[...]`-typed models were collapsing successful calls to empty results - add an `extract_list(response, key)` helper beside `extract_transactions_list` and use it in `get_accounts`, `get_transaction_categories`, and both autocompletion helpers - make `InstitutionsResult` a passthrough since the institutions payload is a dict (credentials/accounts/subscription) with no flat list - update the affected test mocks to the real dict shapes so the regression can't slip through again - drop a dead `period` key in `get_spending_summary` and note why the transaction tools use `model_validate` (mypy list invariance) --- CHANGELOG.md | 1 + server.py | 36 +++++++++++++++++++++++++++--------- tests/test_fastmcp.py | 14 +++++++++----- tests/test_mcp_features.py | 4 ++-- tests/test_new_tools.py | 8 ++++++-- tests/test_tool_coverage.py | 15 ++++++++++----- 6 files changed, 55 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 366c6dc..dc3dbbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - added argument completions for the prompt `category` and resource-template `account_id`, backed by live Monarch data. - the batch tools (complete overview, spending patterns) now report progress via an injected Context. - fixed `get_account_holdings` to pass the required `account_id` — the no-arg version was a latent bug. +- unwrap dict-shaped API responses for `get_accounts`, `get_transaction_categories`, and the autocompletion helpers: the real client returns `{"accounts": [...]}` / `{"categories": [...]}`, so the new structured tools were reporting empty lists on otherwise-successful calls. `get_institutions` now passes its full `{credentials, accounts, subscription}` payload through. ### Dependencies refreshed and mypy 2.x adopted diff --git a/server.py b/server.py index 1244205..edfa0d8 100644 --- a/server.py +++ b/server.py @@ -270,6 +270,22 @@ def extract_transactions_list(response: Any) -> list[dict[str, Any]]: 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. @@ -588,8 +604,9 @@ class AccountHistoryResult(MMModel): class InstitutionsResult(MMModel): - institutions: list[JsonValue] - count: int + # 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): @@ -845,7 +862,7 @@ async def _category_name_completions(partial: str) -> list[str]: """Live category names for autocompletion. Best-effort: never raises.""" try: await ensure_authenticated() - categories = await api_call_with_retry("get_transaction_categories") + 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 [] @@ -858,7 +875,7 @@ async def _account_id_completions(partial: str) -> list[str]: """Live account IDs for autocompletion. Best-effort: never raises.""" try: await ensure_authenticated() - accounts = await api_call_with_retry("get_accounts") + accounts = extract_list(await api_call_with_retry("get_accounts"), "accounts") except Exception as e: log.warning("completion_accounts_failed", error=str(e)) return [] @@ -1235,7 +1252,7 @@ async def get_accounts() -> AccountsResult: try: accounts = await api_call_with_retry("get_accounts") accounts = convert_dates_to_strings(accounts) - account_list = accounts if isinstance(accounts, list) else [] + account_list = extract_list(accounts, "accounts") return AccountsResult(accounts=account_list, count=len(account_list)) except Exception as e: log.error("Failed to fetch accounts", error=str(e)) @@ -1378,6 +1395,8 @@ async def get_transactions( transactions = format_transactions_compact(transactions) log.info("Transactions retrieved", count=len(transactions)) + # model_validate (not the constructor) sidesteps mypy's list invariance: + # list[dict[str, Any]] is not assignable to the model's list[JsonValue]. return TransactionsResult.model_validate( {"transactions": transactions, "count": len(transactions), "verbose": verbose} ) @@ -1464,6 +1483,7 @@ async def search_transactions( ) log.info("Search complete", query=query_str, result_count=len(transactions)) + # model_validate (not the constructor) sidesteps mypy's list invariance. return SearchResult.model_validate({"search_metadata": metadata, "transactions": transactions}) except Exception as e: @@ -1541,7 +1561,7 @@ async def get_transaction_categories(verbose: bool = False) -> CategoriesResult: categories = await api_call_with_retry("get_transaction_categories") categories = convert_dates_to_strings(categories) - category_list = categories if isinstance(categories, list) else [] + category_list = extract_list(categories, "categories") if not verbose: category_list = [ @@ -1908,8 +1928,7 @@ async def get_institutions() -> InstitutionsResult: try: institutions = await api_call_with_retry("get_institutions") institutions = convert_dates_to_strings(institutions) - institution_list = institutions if isinstance(institutions, list) else [] - return InstitutionsResult(institutions=institution_list, count=len(institution_list)) + return InstitutionsResult(institutions=institutions) except Exception as e: log.error("Failed to fetch institutions", error=str(e)) raise @@ -1989,7 +2008,6 @@ async def get_spending_summary( # Aggregate spending data summary: dict[str, Any] = { - "period": {"start": start_date, "end": end_date}, "groups": {}, "totals": {"income": 0, "expenses": 0, "net": 0}, } diff --git a/tests/test_fastmcp.py b/tests/test_fastmcp.py index 25d4587..6d78ea6 100644 --- a/tests/test_fastmcp.py +++ b/tests/test_fastmcp.py @@ -83,13 +83,17 @@ async def test_get_accounts_no_client(self) -> None: @pytest.mark.asyncio async def test_get_accounts_with_mock_client(self) -> None: """Test get_accounts with mocked client.""" - # Setup mock client + # Setup mock client. The real client wraps accounts in a dict alongside + # householdPreferences, so the tool must unwrap the nested list. mock_client = AsyncMock() - mock_accounts_data = [ + mock_accounts_list = [ {"id": "1", "name": "Checking", "balance": 1000.0}, {"id": "2", "name": "Savings", "balance": 5000.0}, ] - mock_client.get_accounts.return_value = mock_accounts_data + mock_client.get_accounts.return_value = { + "accounts": mock_accounts_list, + "householdPreferences": {"id": "hp1"}, + } # Set global client original_client = server.mm_client @@ -100,8 +104,8 @@ async def test_get_accounts_with_mock_client(self) -> None: # Verify structured result wraps the account list with a count assert isinstance(result, server.AccountsResult) - assert result.accounts == mock_accounts_data - assert result.count == len(mock_accounts_data) + assert result.accounts == mock_accounts_list + assert result.count == len(mock_accounts_list) # Verify mock was called mock_client.get_accounts.assert_called_once() diff --git a/tests/test_mcp_features.py b/tests/test_mcp_features.py index eaf7e34..a3092d7 100644 --- a/tests/test_mcp_features.py +++ b/tests/test_mcp_features.py @@ -177,7 +177,7 @@ class TestCompletions: @pytest.mark.asyncio async def test_category_completion_filters_live_names(self, mock_api: AsyncMock) -> None: - mock_api.return_value = [{"id": "c1", "name": "Groceries"}, {"id": "c2", "name": "Gas"}] + mock_api.return_value = {"categories": [{"id": "c1", "name": "Groceries"}, {"id": "c2", "name": "Gas"}]} ref = PromptReference(type="ref/prompt", name="analyze_spending") completion = await server.handle_completion(ref, CompletionArgument(name="category", value="gro"), None) assert completion is not None @@ -185,7 +185,7 @@ async def test_category_completion_filters_live_names(self, mock_api: AsyncMock) @pytest.mark.asyncio async def test_account_id_completion_filters_live_ids(self, mock_api: AsyncMock) -> None: - mock_api.return_value = [{"id": "acc_123"}, {"id": "acc_999"}] + mock_api.return_value = {"accounts": [{"id": "acc_123"}, {"id": "acc_999"}]} ref = ResourceTemplateReference(type="ref/resource", uri="accounts://{account_id}/holdings") completion = await server.handle_completion(ref, CompletionArgument(name="account_id", value="123"), None) assert completion is not None diff --git a/tests/test_new_tools.py b/tests/test_new_tools.py index e7490ad..f843d14 100644 --- a/tests/test_new_tools.py +++ b/tests/test_new_tools.py @@ -66,7 +66,12 @@ async def test_get_account_history(self) -> None: async def test_get_institutions(self) -> None: """Test get_institutions functionality.""" mock_client = AsyncMock() - mock_institutions = [{"id": "inst1", "name": "Chase Bank"}, {"id": "inst2", "name": "Wells Fargo"}] + # The real client returns a dict (credentials/accounts/subscription), not a list. + mock_institutions = { + "credentials": [{"id": "cred1", "institution": {"name": "Chase Bank"}}], + "accounts": [{"id": "acc1", "displayName": "Checking"}], + "subscription": {"hasPremiumEntitlement": True}, + } mock_client.get_institutions.return_value = mock_institutions original_client = server.mm_client @@ -77,7 +82,6 @@ async def test_get_institutions(self) -> None: assert isinstance(result, server.InstitutionsResult) assert result.institutions == mock_institutions - assert result.count == len(mock_institutions) mock_client.get_institutions.assert_called_once() finally: diff --git a/tests/test_tool_coverage.py b/tests/test_tool_coverage.py index c067589..3489cee 100644 --- a/tests/test_tool_coverage.py +++ b/tests/test_tool_coverage.py @@ -119,10 +119,13 @@ async def test_get_cashflow_returns_cashflow_data(self, mock_api: AsyncMock) -> @pytest.mark.asyncio async def test_get_transaction_categories_compact_strips_to_id_and_name(self, mock_api: AsyncMock) -> None: - mock_api.return_value = [ - {"id": "cat_1", "name": "Groceries", "group": {"name": "Food"}, "order": 3}, - {"id": "cat_2", "name": "Transit", "group": {"name": "Auto"}, "order": 4}, - ] + # The real client wraps the list under a "categories" key. + mock_api.return_value = { + "categories": [ + {"id": "cat_1", "name": "Groceries", "group": {"name": "Food"}, "order": 3}, + {"id": "cat_2", "name": "Transit", "group": {"name": "Auto"}, "order": 4}, + ] + } result = await server.get_transaction_categories(verbose=False) assert result.categories == [{"id": "cat_1", "name": "Groceries"}, {"id": "cat_2", "name": "Transit"}] assert result.count == 2 @@ -130,7 +133,9 @@ async def test_get_transaction_categories_compact_strips_to_id_and_name(self, mo @pytest.mark.asyncio async def test_get_transaction_categories_verbose_keeps_all_fields(self, mock_api: AsyncMock) -> None: - mock_api.return_value = [{"id": "cat_1", "name": "Groceries", "group": {"name": "Food"}, "order": 3}] + mock_api.return_value = { + "categories": [{"id": "cat_1", "name": "Groceries", "group": {"name": "Food"}, "order": 3}] + } result = await server.get_transaction_categories(verbose=True) assert result.categories[0]["group"] == {"name": "Food"} assert result.categories[0]["order"] == 3 From 77d642c8f0325b308ef43f373a79c83f63bc599b Mon Sep 17 00:00:00 2001 From: Jamie Dubs <1903+jamiew@users.noreply.github.com> Date: Thu, 28 May 2026 22:55:47 -0400 Subject: [PATCH 5/7] Fix stale tool inventory in CLAUDE.md and README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - correct the tool count (22 → 19) and drop references to the removed `get_transactions_batch` and nonexistent `get_usage_analytics` - note that `get_account_holdings` now requires an `account_id` - document structured output, resource templates, completions, and progress reporting - replace the bogus `get_usage_analytics` README table row with `get_spending_summary` --- CLAUDE.md | 36 +++++++++++++++--------------------- README.md | 13 +++++++------ 2 files changed, 22 insertions(+), 27 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e1ff52c..2af0f87 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -171,22 +171,23 @@ refactor: split server.py into modular components (auth, tools, models) - Structured logging with `structlog` for debugging - Environment variables: `MONARCH_EMAIL`, `MONARCH_PASSWORD`, `MONARCH_MFA_SECRET` -**Complete Monarch Money API Coverage (22 Tools)** +**Complete Monarch Money API Coverage (19 Tools)** - **Core**: `get_accounts`, `get_transactions`, `get_budgets`, `get_cashflow` - **Categories**: `get_transaction_categories` - **Transactions**: `create_transaction`, `update_transaction`, `update_transactions_bulk`, `search_transactions` -- **Investments**: `get_account_holdings`, `get_account_history` +- **Investments**: `get_account_holdings` (requires `account_id`), `get_account_history` - **Banking**: `get_institutions`, `refresh_accounts` - **Planning**: `get_recurring_transactions`, `set_budget_amount` - **Manual**: `create_manual_account` -- **Batch Operations**: `get_transactions_batch`, `get_spending_summary`, `update_transactions_bulk` +- **Batch Operations**: `get_spending_summary`, `update_transactions_bulk` - **Intelligent Analysis**: `get_complete_financial_overview`, `analyze_spending_patterns` -- **Analytics**: `get_usage_analytics` -**Type-Safe Data Processing** -- Pydantic models for validation (still available for reference) -- `convert_dates_to_strings()` ensures JSON compatibility -- Strict typing throughout (only 8 mypy warnings remain - untyped decorators) +Also exposes 5 MCP resources (3 static lists + 2 parameterized templates: `accounts://{account_id}/holdings|history`) and 4 prompt templates. + +**Type-Safe Structured Output** +- Every tool returns a typed Pydantic model, so FastMCP advertises an `outputSchema` and emits structured content (plus a text fallback for older clients). See the "Structured output models" block in `server.py`. +- Monarch's GraphQL responses are dicts (e.g. `{"accounts": [...]}`), not bare lists. Use `extract_list(response, key)` (next to `extract_transactions_list`) to unwrap the inner list before counting it — passing the dict straight into a `list[...]` model field silently yields an empty list. +- `convert_dates_to_strings()` ensures JSON compatibility. ### Monarch Money API Integration @@ -260,8 +261,7 @@ Server runs as MCP server configured in `.mcp.json` with: ### ✅ ADVANCED FEATURES (Recently Completed) #### Smart Tool Design & UX -- **✅ Batch operations**: `get_transactions_batch()` for efficient multi-queries with parallel execution -- **✅ Bulk updates** (NEW October 2025): `update_transactions_bulk()` for updating multiple transactions in one call +- **✅ Bulk updates**: `update_transactions_bulk()` for updating multiple transactions in one call - Parallel execution for maximum performance - Individual error handling per transaction - Summary statistics (succeeded/failed counts) @@ -274,10 +274,9 @@ Server runs as MCP server configured in `.mcp.json` with: - **✅ Smart aggregations**: `get_spending_summary()` with category/account/month grouping - **✅ AsyncIO Runtime Fix**: Server now uses `mcp.run_stdio()` for proper MCP protocol compliance -#### Usage Analytics & Optimization (NEW) -- **✅ Usage tracking**: `@track_usage` decorator on all 20 tools for comprehensive analytics +#### Usage Analytics & Optimization +- **✅ Usage tracking**: `@track_usage` decorator on every tool for comprehensive analytics (logs tool calls, timing, and result sizes to stderr — there is no `get_usage_analytics` tool; analytics are observed via Claude's MCP log) - **✅ Performance monitoring**: Execution time, error rates, and pattern detection -- **✅ Intelligent batching**: Real-time analysis of usage patterns to suggest optimizations - **✅ Analytics logging**: Special markers in Claude's MCP log for easy filtering and optimization insights - **✅ Session-based tracking**: UUID-based session tracking with in-memory pattern analysis @@ -293,12 +292,7 @@ Server runs as MCP server configured in `.mcp.json` with: - Predictive forecasting based on 3-month rolling averages - Smart aggregations with confidence indicators - Account usage patterns and category performance metrics - -- **✅ Optimization Insights**: `get_usage_analytics()` - Real-time optimization: - - Tool usage frequency analysis and performance metrics - - Automatic detection of common usage sequences (30-second windows) - - Intelligent suggestions for batch operations - - Performance bottleneck identification + - Reports progress through an injected `Context` (`ctx.report_progress`) #### Production Stability & Reliability Fixes (NEW) - **✅ JSON-RPC Protocol Compliance**: All logging redirected to stderr to prevent stdout contamination @@ -355,7 +349,7 @@ Server runs as MCP server configured in `.mcp.json` with: - Add investment performance tracking and portfolio analysis #### 6. Advanced Tool Features -**Current State**: All 20 core tools implemented +**Current State**: 19 core tools implemented **Remaining Work:** - Add bulk transaction operations (import/export) - Implement transaction search with fuzzy matching @@ -365,7 +359,7 @@ Server runs as MCP server configured in `.mcp.json` with: ### 🔄 REMAINING LOW PRIORITY TASKS #### 7. Code Architecture & Organization -**Current State**: Single file with 20 tools, comprehensive tests +**Current State**: Single file with 19 tools, comprehensive tests **Remaining Work:** - Split into modules: `auth.py`, `tools.py`, `models.py`, `config.py` (optional - current structure works well) - Implement Pydantic Settings for configuration management diff --git a/README.md b/README.md index c219991..e23f666 100644 --- a/README.md +++ b/README.md @@ -10,13 +10,14 @@ Built on the [`monarchmoneycommunity`](https://github.com/bradleyseanf/monarchmo ## Features - **19 tools** covering accounts, transactions, budgets, cashflow, investments, categories, goals, net worth, recurring transactions, and more -- **MCP resources** for quick access to categories, accounts, and institutions -- **MCP prompts** for guided financial analysis workflows +- **Structured output** — every tool returns a typed schema (`outputSchema` + machine-readable structured content) with a text fallback for older clients +- **MCP resources** for quick access to categories, accounts, and institutions, plus parameterized templates for per-account holdings and history (`accounts://{account_id}/holdings|history`) +- **MCP prompts** for guided financial analysis workflows, with live argument autocompletion - **Smart output formatting** — compact transaction format reduces token usage by ~80% - **Natural language dates** — "last month", "30 days ago", "this year" all work -- **Batch operations** — parallel multi-account queries, bulk transaction updates +- **Batch operations** — parallel multi-account queries, bulk transaction updates, with progress reporting - **Spending analysis** — multi-month trend analysis with category/account breakdowns -- **Tool annotations** — proper read/write metadata for MCP clients +- **Tool annotations & titles** — read/write metadata and human-friendly titles for MCP clients ## Setup @@ -75,16 +76,16 @@ Use absolute paths — find yours with `which uv` and `pwd`. | `update_transactions_bulk` | Update multiple transactions in parallel | | `get_budgets` | Budget data and spending analysis | | `get_cashflow` | Income and expense analysis | -| `get_account_holdings` | Investment holdings | +| `get_account_holdings` | Investment holdings for an account (requires `account_id`) | | `get_account_history` | Account balance history | | `get_institutions` | Linked financial institutions | | `get_recurring_transactions` | Recurring transaction detection | | `set_budget_amount` | Set a budget category amount | | `create_manual_account` | Create a manually-tracked account | | `refresh_accounts` | Trigger account data refresh | +| `get_spending_summary` | Spending aggregated by category, account, or month | | `get_complete_financial_overview` | Combined 5-API call in parallel | | `analyze_spending_patterns` | Multi-month trend analysis | -| `get_usage_analytics` | Tool usage stats and optimization tips | ### Transaction format From 1296c6a1ea16f1ea5d694e946f51f6bba9ebc849 Mon Sep 17 00:00:00 2001 From: Jamie Dubs <1903+jamiew@users.noreply.github.com> Date: Thu, 28 May 2026 23:23:48 -0400 Subject: [PATCH 6/7] Fix broken uvx entry point and rewrite multi-client install docs - fix console-script entry point: it pointed at async `main()`, so `uvx monarch-mcp-jamiew` launched a coroutine that was never awaited and the server never started; add a sync `run()` wrapper instead - floor `monarchmoneycommunity>=1.3.2` since the git pin isn't in the wheel - rewrite README Setup as a uvx-first, per-client layout (Claude Desktop, Claude Code, Codex, + other clients + from-source) - document the OIDC release/publish flow in README and CLAUDE.md --- CLAUDE.md | 4 ++ README.md | 113 ++++++++++++++++++++++++++++++++++++++++++------- pyproject.toml | 4 +- server.py | 10 ++++- 4 files changed, 113 insertions(+), 18 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2af0f87..5d3b64e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -231,6 +231,10 @@ Server runs as MCP server configured in `.mcp.json` with: - Absolute paths required for proper MCP integration - Implements MCP capability negotiation for feature discovery +### Releasing & Publishing + +Published to PyPI as `monarch-mcp-jamiew` and to the MCP Registry as `io.github.jamiew/monarch-mcp`. Users install via `uvx monarch-mcp-jamiew` (no clone) — so the `[project.scripts]` `monarch-mcp-jamiew = "server:run"` entry point must stay a *synchronous* wrapper (`run()`), never the async `main()` directly, or `uvx` launches a coroutine that's never awaited. Release flow: `/release` bumps `pyproject.toml`, tags `vX.Y.Z`, and `gh release create`s; the `release: published` event triggers `.github/workflows/publish.yml`, which publishes to both PyPI and the registry via **OIDC trusted publishing — no tokens stored**. The workflow sets `server.json`'s version from the tag, so `pyproject.toml` is the only manual version bump. Note: `[tool.uv.sources]`'s git pin of `monarchmoneycommunity` is dev-only and is *not* in the published wheel — PyPI installs resolve the `>=1.3.2` floor from `pyproject.toml` dependencies. + ### Session Management - Session files stored in `.mm/` directory (created automatically) diff --git a/README.md b/README.md index e23f666..e9abd07 100644 --- a/README.md +++ b/README.md @@ -21,29 +21,105 @@ Built on the [`monarchmoneycommunity`](https://github.com/bradleyseanf/monarchmo ## Setup -### 1. Install dependencies +The server is published to [PyPI](https://pypi.org/project/monarch-mcp-jamiew/), so there's nothing to clone — [`uv`](https://docs.astral.sh/uv/) runs it on demand with `uvx`. You'll need `uv` installed and your Monarch credentials (see [Getting your MFA secret](#getting-your-mfa-secret) below). + +### Standard config + +Every MCP client uses the same shape — command `uvx`, package `monarch-mcp-jamiew`, and your three credentials as env vars: + +```json +{ + "mcpServers": { + "monarch-money": { + "command": "uvx", + "args": ["monarch-mcp-jamiew"], + "env": { + "MONARCH_EMAIL": "your-email@example.com", + "MONARCH_PASSWORD": "your-password", + "MONARCH_MFA_SECRET": "your-mfa-secret-key" + } + } + } +} +``` + +Pick your client below for the exact steps. + +
+Claude Desktop + +Edit your config file (create it if it doesn't exist) and add the [standard config](#standard-config) above under `mcpServers`: + +- **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` +- **Windows**: `%APPDATA%\Claude\claude_desktop_config.json` + +Then fully quit and reopen Claude Desktop. + +
+ +
+Claude Code ```bash -cd /path/to/monarch-mcp -uv sync +claude mcp add monarch-money \ + -e MONARCH_EMAIL=your-email@example.com \ + -e MONARCH_PASSWORD=your-password \ + -e MONARCH_MFA_SECRET=your-mfa-secret-key \ + -- uvx monarch-mcp-jamiew ``` -### 2. Configure your MCP client +Add `-s user` to make it available across all your projects. Verify with `claude mcp list`. + +
+ +
+Codex CLI + +```bash +codex mcp add monarch-money \ + --env MONARCH_EMAIL=your-email@example.com \ + --env MONARCH_PASSWORD=your-password \ + --env MONARCH_MFA_SECRET=your-mfa-secret-key \ + -- uvx monarch-mcp-jamiew +``` + +Or add the equivalent block to `~/.codex/config.toml`: + +```toml +[mcp_servers.monarch-money] +command = "uvx" +args = ["monarch-mcp-jamiew"] +env = { MONARCH_EMAIL = "your-email@example.com", MONARCH_PASSWORD = "your-password", MONARCH_MFA_SECRET = "your-mfa-secret-key" } +``` + +
+ +
+Other clients (Cursor, VS Code, Windsurf, Cline, Zed, …) -Add to your `.mcp.json` (Claude Desktop, Claude Code, etc.): +These all accept the same [standard config](#standard-config) — drop it into the client's MCP config (e.g. Cursor's `~/.cursor/mcp.json`, or VS Code via `code --add-mcp`). Anything that speaks MCP over stdio works. + +
+ +
+From source (development) + +To run against a local checkout (and the git-pinned `monarchmoneycommunity` lib): + +```bash +git clone https://github.com/jamiew/monarch-mcp +cd monarch-mcp +uv sync +``` + +Then point your client at the local copy with absolute paths (find them with `which uv` and `pwd`): ```json { "mcpServers": { "monarch-money": { - "command": "/path/to/uv", - "args": [ - "--directory", - "/path/to/monarch-mcp", - "run", - "python", - "server.py" - ], + "command": "/abs/path/to/uv", + "args": ["--directory", "/abs/path/to/monarch-mcp", "run", "python", "server.py"], "env": { "MONARCH_EMAIL": "your-email@example.com", "MONARCH_PASSWORD": "your-password", @@ -54,9 +130,12 @@ Add to your `.mcp.json` (Claude Desktop, Claude Code, etc.): } ``` -Use absolute paths — find yours with `which uv` and `pwd`. +
+ +> [!NOTE] +> The `claude mcp add` / `codex mcp add` one-liners put your credentials in shell history. If that bothers you, edit the client's config file directly (as shown for Claude Desktop / Codex above) instead. -### 3. Get your MFA secret +### Getting your MFA secret 1. Go to Monarch Money settings and enable 2FA 2. When shown the QR code, look for "Can't scan?" or "Enter manually" @@ -143,6 +222,10 @@ Run all checks locally (same as GitHub Actions CI): uv run python scripts/ci.py ``` +### Releasing + +Cut a release with the `/release` flow (bump version in `pyproject.toml` → commit → tag `vX.Y.Z` → push → `gh release create`). Publishing the GitHub release triggers [`.github/workflows/publish.yml`](.github/workflows/publish.yml), which builds and pushes to **PyPI** and the **MCP Registry** via OIDC trusted publishing — no API tokens are stored anywhere. The workflow injects the tag version into `server.json` automatically, so `pyproject.toml` is the only version field you bump by hand. + ### Log analysis Tools for measuring and optimizing token usage across MCP sessions: diff --git a/pyproject.toml b/pyproject.toml index bf9f303..f9793d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ classifiers = [ ] dependencies = [ "mcp[cli]>=1.26.0,<2", - "monarchmoneycommunity", + "monarchmoneycommunity>=1.3.2", "pydantic>=2.12.5", "python-dateutil>=2.9.0.post0", "structlog>=25.5.0", @@ -40,7 +40,7 @@ Repository = "https://github.com/jamiew/monarch-mcp" Issues = "https://github.com/jamiew/monarch-mcp/issues" [project.scripts] -monarch-mcp-jamiew = "server:main" +monarch-mcp-jamiew = "server:run" [build-system] requires = ["hatchling"] diff --git a/server.py b/server.py index edfa0d8..169b705 100644 --- a/server.py +++ b/server.py @@ -2394,7 +2394,11 @@ async def main() -> None: raise -if __name__ == "__main__": +def run() -> None: + """Synchronous console-script entry point (`monarch-mcp-jamiew`). + + Wraps the async `main()` so the published entry point actually awaits it. + """ def signal_handler(signum: int, frame: Any) -> None: log.info("signal_received", signum=signum) @@ -2424,3 +2428,7 @@ def signal_handler(signum: int, frame: Any) -> None: else: log.error("fatal_error", error=str(eg)) raise + + +if __name__ == "__main__": + run() From b529c4cfc0ba647998be8766eeee18e575ee775f Mon Sep 17 00:00:00 2001 From: Jamie Dubs <1903+jamiew@users.noreply.github.com> Date: Thu, 28 May 2026 23:26:54 -0400 Subject: [PATCH 7/7] v0.3.2 --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- server.json | 4 ++-- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc3dbbd..b431b8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## 2026-05-28 +### Fix broken `uvx` install (0.3.2) + +- fixed the published console-script entry point: it pointed at the async `main()`, so `uvx monarch-mcp-jamiew` launched a coroutine that was never awaited and the server never started. Added a synchronous `run()` wrapper. (0.3.0/0.3.1 on PyPI were unusable via `uvx`.) +- floored `monarchmoneycommunity>=1.3.2` since the dev-only git pin isn't carried in the published wheel. +- rewrote the README install section to be `uvx`-first with per-client steps (Claude Desktop, Claude Code, Codex, others, from-source). + ### MCP 2025 protocol features: structured output, titles, resource templates, completions, progress - every tool now returns a typed Pydantic model, so FastMCP advertises an `outputSchema` and emits machine-readable structured content alongside a text fallback for older clients. diff --git a/pyproject.toml b/pyproject.toml index f9793d8..343fe39 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "monarch-mcp-jamiew" -version = "0.3.1" +version = "0.3.2" description = "MCP server for Monarch Money personal finance" readme = "README.md" requires-python = ">=3.10" diff --git a/server.json b/server.json index 73f3caa..eba64e1 100644 --- a/server.json +++ b/server.json @@ -3,7 +3,7 @@ "name": "io.github.jamiew/monarch-mcp", "title": "Monarch MCP Server", "description": "Access Monarch financial data — accounts, transactions, budgets, and more", - "version": "0.3.1", + "version": "0.3.2", "repository": { "url": "https://github.com/jamiew/monarch-mcp", "source": "github" @@ -12,7 +12,7 @@ { "registryType": "pypi", "identifier": "monarch-mcp-jamiew", - "version": "0.3.1", + "version": "0.3.2", "transport": { "type": "stdio" },