diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0ad0f71..b431b8c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,22 @@
## 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.
+- 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.
+- 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
- 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).
diff --git a/CLAUDE.md b/CLAUDE.md
index c77d2f6..5d3b64e 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
@@ -230,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)
@@ -248,9 +253,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
@@ -259,8 +265,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)
@@ -273,10 +278,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
@@ -292,12 +296,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
@@ -354,7 +353,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
@@ -364,7 +363,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
@@ -400,7 +399,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 +435,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
diff --git a/README.md b/README.md
index c219991..e9abd07 100644
--- a/README.md
+++ b/README.md
@@ -10,39 +10,116 @@ 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
-### 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",
@@ -53,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"
@@ -75,16 +155,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
@@ -142,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..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"
@@ -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.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"
},
diff --git a/server.py b/server.py
index d0738c6..169b705 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"]
@@ -262,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.
@@ -434,15 +458,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 +518,167 @@ 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):
+ # Monarch's institution-settings query returns a dict (credentials, accounts,
+ # subscription), not a flat list, so the full payload is passed through.
+ institutions: JsonValue
+
+
+class RecurringResult(MMModel):
+ recurring: JsonValue
+
+
+class SetBudgetResult(MMModel):
+ category_id: str
+ amount: float
+ result: JsonValue
+
+
+class CreateAccountResult(MMModel):
+ account: JsonValue
+
+
+class RefreshResult(MMModel):
+ result: JsonValue
+
+
+class Totals(BaseModel):
+ income: float
+ expenses: float
+ net: float
+
+
+class GroupSummary(BaseModel):
+ income: float
+ expenses: float
+ net: float
+ count: int
+
+
+class Period(BaseModel):
+ start: str | None = None
+ end: str | None = None
+
+
+class SpendingSummaryResult(MMModel):
+ period: Period
+ group_by: str
+ groups: dict[str, GroupSummary]
+ totals: Totals
+
+
+class FinancialOverview(MMModel):
+ period: str
+ accounts: JsonValue = None
+ budgets: JsonValue = None
+ cashflow: JsonValue = None
+ transactions: JsonValue = None
+ categories: JsonValue = None
+ transaction_summary: JsonValue = None
+ batch_metadata: JsonValue = None
+
+
+class SpendingPatterns(MMModel):
+ analysis_period: JsonValue = None
+ monthly_trends: JsonValue = None
+ category_analysis: JsonValue = None
+ account_usage: JsonValue = None
+ budget_performance: JsonValue = None
+ forecast: JsonValue = None
+ metadata: JsonValue = None
+
+
# =============================================================================
# MCP Resources - Read-only data endpoints for reference data
# =============================================================================
-@mcp.resource("categories://list")
+@mcp.resource("categories://list", title="Transaction Categories")
async def list_categories_resource() -> str:
"""
List all transaction categories available in Monarch Money.
@@ -506,7 +692,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 +706,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 +719,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 +773,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 +794,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 +832,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 +853,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 = extract_list(await api_call_with_retry("get_transaction_categories"), "categories")
+ except Exception as e:
+ log.warning("completion_categories_failed", error=str(e))
+ return []
+ names = [c.get("name", "") for c in categories if isinstance(c, dict) and c.get("name")]
+ needle = partial.lower()
+ return [n for n in names if needle in n.lower()][:100]
+
+
+async def _account_id_completions(partial: str) -> list[str]:
+ """Live account IDs for autocompletion. Best-effort: never raises."""
+ try:
+ await ensure_authenticated()
+ accounts = extract_list(await api_call_with_retry("get_accounts"), "accounts")
+ except Exception as e:
+ log.warning("completion_accounts_failed", error=str(e))
+ return []
+ ids = [a.get("id", "") for a in accounts if isinstance(a, dict) and a.get("id")]
+ needle = partial.lower()
+ return [i for i in ids if needle in i.lower()][:100]
+
+
+@mcp.completion()
+async def handle_completion(
+ ref: PromptReference | ResourceTemplateReference,
+ argument: CompletionArgument,
+ context: CompletionContext | None,
+) -> Completion | None:
+ """Autocomplete prompt/resource-template arguments from live Monarch data.
+
+ - prompt ``category`` argument -> transaction category names
+ - resource-template ``account_id`` argument -> account IDs
+ """
+ if isinstance(ref, PromptReference) and argument.name == "category":
+ return Completion(values=await _category_name_completions(argument.value), hasMore=False)
+
+ if isinstance(ref, ResourceTemplateReference) and argument.name == "account_id":
+ return Completion(values=await _account_id_completions(argument.value), hasMore=False)
+
+ return None
+
+
class AuthState(Enum):
"""Track authentication state to prevent duplicate initialization attempts."""
@@ -980,22 +1243,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 = 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))
raise
-@mcp.tool(annotations=READONLY)
+@mcp.tool(annotations=READONLY, title="Get Transactions")
@track_usage
async def get_transactions(
limit: int = 100,
@@ -1011,7 +1275,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 +1395,17 @@ async def get_transactions(
transactions = format_transactions_compact(transactions)
log.info("Transactions retrieved", count=len(transactions))
- return json.dumps(transactions, indent=2)
+ # 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}
+ )
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 +1422,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 +1476,24 @@ 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)
+ # model_validate (not the constructor) sidesteps mypy's list invariance.
+ 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 +1511,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 +1540,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 +1561,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 = extract_list(categories, "categories")
- 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 +1581,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 +1631,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 +1642,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 +1654,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 +1744,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 +1759,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 +1801,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 +1844,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 +1862,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 +1913,45 @@ 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)
+ return InstitutionsResult(institutions=institutions)
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 +1959,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 +1977,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:
@@ -1735,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},
}
@@ -1784,25 +2056,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 +2092,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 +2112,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 +2186,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 +2235,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 +2255,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 +2350,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 +2368,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)
@@ -2092,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)
@@ -2122,3 +2428,7 @@ def signal_handler(signum: int, frame: Any) -> None:
else:
log.error("fatal_error", error=str(eg))
raise
+
+
+if __name__ == "__main__":
+ run()
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..6d78ea6 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
@@ -84,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
@@ -99,10 +102,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_list
+ assert result.count == len(mock_accounts_list)
# Verify mock was called
mock_client.get_accounts.assert_called_once()
@@ -133,10 +136,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 +174,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..a3092d7 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 = {"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
+ 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 = {"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
+ 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..f843d14 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()
@@ -68,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,9 +80,8 @@ 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
mock_client.get_institutions.assert_called_once()
finally:
@@ -101,9 +103,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 +123,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 +152,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 +176,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..3489cee 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,39 +99,46 @@ 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:
- mock_api.return_value = [
- {"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"}]
+ # 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
+ 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
+ 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
@pytest.mark.asyncio
async def test_get_spending_summary_aggregates_by_category(self, mock_api: AsyncMock) -> None:
@@ -141,18 +147,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 +185,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 +205,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()