avenir-mcp 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. avenir_mcp-0.1.0/LICENSE +21 -0
  2. avenir_mcp-0.1.0/PKG-INFO +142 -0
  3. avenir_mcp-0.1.0/README.md +108 -0
  4. avenir_mcp-0.1.0/avenir_mcp/__init__.py +3 -0
  5. avenir_mcp-0.1.0/avenir_mcp/__main__.py +5 -0
  6. avenir_mcp-0.1.0/avenir_mcp/amounts.py +20 -0
  7. avenir_mcp-0.1.0/avenir_mcp/analytics.py +299 -0
  8. avenir_mcp-0.1.0/avenir_mcp/app.py +94 -0
  9. avenir_mcp-0.1.0/avenir_mcp/classifier.py +172 -0
  10. avenir_mcp-0.1.0/avenir_mcp/client.py +661 -0
  11. avenir_mcp-0.1.0/avenir_mcp/confirm.py +402 -0
  12. avenir_mcp-0.1.0/avenir_mcp/context.py +235 -0
  13. avenir_mcp-0.1.0/avenir_mcp/forecast.py +415 -0
  14. avenir_mcp-0.1.0/avenir_mcp/http_auth.py +57 -0
  15. avenir_mcp-0.1.0/avenir_mcp/journal.py +178 -0
  16. avenir_mcp-0.1.0/avenir_mcp/logs.py +56 -0
  17. avenir_mcp-0.1.0/avenir_mcp/model.py +15 -0
  18. avenir_mcp-0.1.0/avenir_mcp/reconcile.py +131 -0
  19. avenir_mcp-0.1.0/avenir_mcp/schedule.py +149 -0
  20. avenir_mcp-0.1.0/avenir_mcp/search.py +189 -0
  21. avenir_mcp-0.1.0/avenir_mcp/server.py +130 -0
  22. avenir_mcp-0.1.0/avenir_mcp/split.py +192 -0
  23. avenir_mcp-0.1.0/avenir_mcp/text.py +38 -0
  24. avenir_mcp-0.1.0/avenir_mcp/tools_accounts.py +575 -0
  25. avenir_mcp-0.1.0/avenir_mcp/tools_budget.py +455 -0
  26. avenir_mcp-0.1.0/avenir_mcp/tools_categories.py +339 -0
  27. avenir_mcp-0.1.0/avenir_mcp/tools_classify.py +230 -0
  28. avenir_mcp-0.1.0/avenir_mcp/tools_undo.py +219 -0
  29. avenir_mcp-0.1.0/avenir_mcp/triage.py +318 -0
  30. avenir_mcp-0.1.0/avenir_mcp/writes.py +263 -0
  31. avenir_mcp-0.1.0/avenir_mcp.egg-info/PKG-INFO +142 -0
  32. avenir_mcp-0.1.0/avenir_mcp.egg-info/SOURCES.txt +70 -0
  33. avenir_mcp-0.1.0/avenir_mcp.egg-info/dependency_links.txt +1 -0
  34. avenir_mcp-0.1.0/avenir_mcp.egg-info/entry_points.txt +2 -0
  35. avenir_mcp-0.1.0/avenir_mcp.egg-info/requires.txt +6 -0
  36. avenir_mcp-0.1.0/avenir_mcp.egg-info/top_level.txt +1 -0
  37. avenir_mcp-0.1.0/pyproject.toml +148 -0
  38. avenir_mcp-0.1.0/setup.cfg +4 -0
  39. avenir_mcp-0.1.0/tests/test_amounts.py +72 -0
  40. avenir_mcp-0.1.0/tests/test_analytics.py +332 -0
  41. avenir_mcp-0.1.0/tests/test_api_coverage.py +98 -0
  42. avenir_mcp-0.1.0/tests/test_classifier.py +236 -0
  43. avenir_mcp-0.1.0/tests/test_client.py +896 -0
  44. avenir_mcp-0.1.0/tests/test_confirm.py +126 -0
  45. avenir_mcp-0.1.0/tests/test_context.py +158 -0
  46. avenir_mcp-0.1.0/tests/test_docs.py +60 -0
  47. avenir_mcp-0.1.0/tests/test_evals.py +196 -0
  48. avenir_mcp-0.1.0/tests/test_forecast.py +383 -0
  49. avenir_mcp-0.1.0/tests/test_http.py +126 -0
  50. avenir_mcp-0.1.0/tests/test_hygiene.py +84 -0
  51. avenir_mcp-0.1.0/tests/test_journal.py +159 -0
  52. avenir_mcp-0.1.0/tests/test_logging.py +135 -0
  53. avenir_mcp-0.1.0/tests/test_policy.py +80 -0
  54. avenir_mcp-0.1.0/tests/test_properties.py +177 -0
  55. avenir_mcp-0.1.0/tests/test_protocol.py +164 -0
  56. avenir_mcp-0.1.0/tests/test_protocol_budget.py +129 -0
  57. avenir_mcp-0.1.0/tests/test_protocol_categories.py +196 -0
  58. avenir_mcp-0.1.0/tests/test_protocol_create.py +161 -0
  59. avenir_mcp-0.1.0/tests/test_protocol_find.py +79 -0
  60. avenir_mcp-0.1.0/tests/test_protocol_forecast.py +261 -0
  61. avenir_mcp-0.1.0/tests/test_protocol_reconcile.py +221 -0
  62. avenir_mcp-0.1.0/tests/test_protocol_schedule.py +85 -0
  63. avenir_mcp-0.1.0/tests/test_protocol_split.py +111 -0
  64. avenir_mcp-0.1.0/tests/test_protocol_writes.py +294 -0
  65. avenir_mcp-0.1.0/tests/test_reconcile.py +118 -0
  66. avenir_mcp-0.1.0/tests/test_schedule.py +154 -0
  67. avenir_mcp-0.1.0/tests/test_search.py +175 -0
  68. avenir_mcp-0.1.0/tests/test_server.py +291 -0
  69. avenir_mcp-0.1.0/tests/test_split.py +125 -0
  70. avenir_mcp-0.1.0/tests/test_text.py +40 -0
  71. avenir_mcp-0.1.0/tests/test_triage.py +270 -0
  72. avenir_mcp-0.1.0/tests/test_writes.py +263 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mathieu Beal
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,142 @@
1
+ Metadata-Version: 2.4
2
+ Name: avenir-mcp
3
+ Version: 0.1.0
4
+ Summary: An unofficial MCP server for YNAB: budgets, spending and transaction classification, built for agents.
5
+ Author: Mathieu Beal
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/mathbeal/avenir-mcp
8
+ Project-URL: Documentation, https://mathbeal.github.io/avenir-mcp/
9
+ Project-URL: Repository, https://github.com/mathbeal/avenir-mcp
10
+ Project-URL: Issues, https://github.com/mathbeal/avenir-mcp/issues
11
+ Project-URL: Changelog, https://github.com/mathbeal/avenir-mcp/blob/main/CHANGELOG.md
12
+ Keywords: mcp,model-context-protocol,ynab,budget,personal-finance,agents
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Environment :: Console
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Intended Audience :: End Users/Desktop
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Topic :: Office/Business :: Financial
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.12
25
+ Description-Content-Type: text/markdown
26
+ License-File: LICENSE
27
+ Requires-Dist: fastmcp==4.0.9
28
+ Requires-Dist: httpx<1,>=0.28.1
29
+ Requires-Dist: pydantic<3,>=2.13
30
+ Requires-Dist: mcp<3,>=2.2.0
31
+ Requires-Dist: mcp-types<3,>=2.2.0
32
+ Requires-Dist: starlette<2,>=1.7.0
33
+ Dynamic: license-file
34
+
35
+ # avenir-mcp — an unofficial MCP server for YNAB
36
+
37
+ [![quality](https://github.com/mathbeal/avenir-mcp/actions/workflows/quality.yml/badge.svg)](https://github.com/mathbeal/avenir-mcp/actions/workflows/quality.yml)
38
+ [![MCP Inspector](https://github.com/mathbeal/avenir-mcp/actions/workflows/inspector.yml/badge.svg)](https://github.com/mathbeal/avenir-mcp/actions/workflows/inspector.yml)
39
+ [![docs](https://github.com/mathbeal/avenir-mcp/actions/workflows/docs.yml/badge.svg)](https://mathbeal.github.io/avenir-mcp/)
40
+
41
+ [![python](https://img.shields.io/badge/python-3.12%20%7C%203.13%20%7C%203.14-blue)](https://mathbeal.github.io/avenir-mcp/project/development/#the-checks)
42
+ [![coverage](https://img.shields.io/badge/coverage-100%25%20lines%20and%20branches-brightgreen)](https://mathbeal.github.io/avenir-mcp/project/development/#the-checks)
43
+ [![types](https://img.shields.io/badge/types-mypy%20strict-blue)](https://mathbeal.github.io/avenir-mcp/project/development/#the-checks)
44
+ [![docstrings](https://img.shields.io/badge/docstrings-Google%20style%2C%20ruff-blue)](https://mathbeal.github.io/avenir-mcp/project/development/#the-checks)
45
+ [![code style](https://img.shields.io/badge/code%20style-black-000000)](https://mathbeal.github.io/avenir-mcp/project/development/#the-checks)
46
+ [![licence](https://img.shields.io/badge/licence-MIT-blue)](https://github.com/mathbeal/avenir-mcp/blob/main/LICENSE)
47
+
48
+ > 🇬🇧 **avenir-mcp** (*avenir* is French for *the future*) lets an AI agent read your YNAB budget
49
+ > and help you plan what comes next.
50
+ >
51
+ > 🇫🇷 **avenir-mcp** permet à un agent IA de lire votre budget YNAB et de vous aider
52
+ > à préparer la suite.
53
+
54
+ Ask Claude — or any [MCP](https://modelcontextprotocol.io) client — about your budget
55
+ in plain words: where the money went, what still needs a category, whether an account
56
+ matches the bank, when money would run out. Every change is previewed, confirmed by
57
+ you, and can be undone.
58
+
59
+ **📖 Documentation: <https://mathbeal.github.io/avenir-mcp/>**
60
+
61
+ > **Unofficial project.** We are not affiliated, associated, or in any way officially
62
+ > connected with YNAB or any of its subsidiaries or affiliates. YNAB and You Need A Budget
63
+ > are registered trademarks of YNAB, named here only to say which service avenir-mcp works
64
+ > with.
65
+ >
66
+ > avenir-mcp is for **personal use on your own machine, with your own YNAB token**. Running it
67
+ > as a public or shared server is not supported. It is provided as is, without warranty,
68
+ > and is not financial advice: you remain responsible for the changes you confirm. See the
69
+ > [legal notice](https://mathbeal.github.io/avenir-mcp/project/legal/).
70
+
71
+ ## Why avenir-mcp
72
+
73
+ - **Tools for tasks, not endpoints.** Classify a month of transactions, reconcile an
74
+ account, forecast your balance: one tool each, not a wrapper of YNAB's API.
75
+ - **Read-only by default.** Tools that change your budget exist only when you enable
76
+ them.
77
+ - **Preview, confirm, undo.** Every write shows what will change and waits for your
78
+ yes; `undo_operation` reverts it.
79
+ - **Answers an agent can read.** Currency units, short typed answers, pagination,
80
+ errors that say what to fix, bank text treated as untrusted.
81
+ - **Verified.** 100 % line and branch coverage, and an evaluation where a real agent
82
+ works on an invented plan: 17/17 tasks.
83
+
84
+ ## Install
85
+
86
+ You need [uv](https://docs.astral.sh/uv/) and a YNAB personal access token
87
+ (YNAB → Account Settings → Developer Settings → New Token).
88
+
89
+ ```bash
90
+ # Claude Code
91
+ claude mcp add avenir-mcp --env YNAB_API_KEY=your-token --env AVENIR_MCP_WRITE=1 -- uvx avenir-mcp
92
+ ```
93
+
94
+ ```jsonc
95
+ // Claude Desktop, Cursor: the mcpServers block of the client's configuration
96
+ {
97
+ "mcpServers": {
98
+ "avenir-mcp": {
99
+ "command": "uvx",
100
+ "args": ["avenir-mcp"],
101
+ "env": { "YNAB_API_KEY": "your-token", "AVENIR_MCP_WRITE": "1" }
102
+ }
103
+ }
104
+ }
105
+ ```
106
+
107
+ Drop `AVENIR_MCP_WRITE` to stay read-only. Other clients and every option:
108
+ [Install](https://mathbeal.github.io/avenir-mcp/getting-started/install/) ·
109
+ [Configuration](https://mathbeal.github.io/avenir-mcp/reference/configuration/).
110
+
111
+ ## What you can ask
112
+
113
+ | You ask | avenir-mcp |
114
+ |---|---|
115
+ | "Which category is overspent this month?" | `get_monthly_summary` — totals and overspent categories |
116
+ | "Categorise what is pending." | `suggest_categories`, then `apply_categories` after your yes |
117
+ | "Split this receipt: 81.15 groceries, 5.25 household." | `split_transaction` — one transaction across categories, after your yes |
118
+ | "Which transaction is my 86.40 receipt from the 12th?" | `find_transactions` — by dates, exact amount and account, categorised or not |
119
+ | "My bank shows 3,440.80. Does YNAB agree?" | `reconcile_account` — explains the gap, changes nothing until it matches |
120
+ | "Will I go below zero before December?" | `forecast_balance` — month by month, with its assumptions |
121
+ | "Move 30 from Tennis to Restaurants." | `set_category_budget`, previewed and undoable |
122
+ | "Undo that." | `undo_operation` |
123
+
124
+ Walk-throughs with real answers: [Use cases](https://mathbeal.github.io/avenir-mcp/use-cases/classify/).
125
+ Every tool, resource and prompt: [Reference](https://mathbeal.github.io/avenir-mcp/reference/tools/).
126
+
127
+ ## Development
128
+
129
+ ```bash
130
+ git clone https://github.com/mathbeal/avenir-mcp && cd avenir-mcp
131
+ uv sync
132
+ just check # lint, types, tests at 100 % coverage, vocabulary, lockfile
133
+ just docs-serve # the documentation, live
134
+ just evaluate # a real agent on the demo budget (uses your Claude plan)
135
+ ```
136
+
137
+ Read [AGENTS.md](AGENTS.md) and [CONTRIBUTING.md](CONTRIBUTING.md) before opening a
138
+ pull request. Security reports: [SECURITY.md](SECURITY.md).
139
+
140
+ ## Licence
141
+
142
+ MIT.
@@ -0,0 +1,108 @@
1
+ # avenir-mcp — an unofficial MCP server for YNAB
2
+
3
+ [![quality](https://github.com/mathbeal/avenir-mcp/actions/workflows/quality.yml/badge.svg)](https://github.com/mathbeal/avenir-mcp/actions/workflows/quality.yml)
4
+ [![MCP Inspector](https://github.com/mathbeal/avenir-mcp/actions/workflows/inspector.yml/badge.svg)](https://github.com/mathbeal/avenir-mcp/actions/workflows/inspector.yml)
5
+ [![docs](https://github.com/mathbeal/avenir-mcp/actions/workflows/docs.yml/badge.svg)](https://mathbeal.github.io/avenir-mcp/)
6
+
7
+ [![python](https://img.shields.io/badge/python-3.12%20%7C%203.13%20%7C%203.14-blue)](https://mathbeal.github.io/avenir-mcp/project/development/#the-checks)
8
+ [![coverage](https://img.shields.io/badge/coverage-100%25%20lines%20and%20branches-brightgreen)](https://mathbeal.github.io/avenir-mcp/project/development/#the-checks)
9
+ [![types](https://img.shields.io/badge/types-mypy%20strict-blue)](https://mathbeal.github.io/avenir-mcp/project/development/#the-checks)
10
+ [![docstrings](https://img.shields.io/badge/docstrings-Google%20style%2C%20ruff-blue)](https://mathbeal.github.io/avenir-mcp/project/development/#the-checks)
11
+ [![code style](https://img.shields.io/badge/code%20style-black-000000)](https://mathbeal.github.io/avenir-mcp/project/development/#the-checks)
12
+ [![licence](https://img.shields.io/badge/licence-MIT-blue)](https://github.com/mathbeal/avenir-mcp/blob/main/LICENSE)
13
+
14
+ > 🇬🇧 **avenir-mcp** (*avenir* is French for *the future*) lets an AI agent read your YNAB budget
15
+ > and help you plan what comes next.
16
+ >
17
+ > 🇫🇷 **avenir-mcp** permet à un agent IA de lire votre budget YNAB et de vous aider
18
+ > à préparer la suite.
19
+
20
+ Ask Claude — or any [MCP](https://modelcontextprotocol.io) client — about your budget
21
+ in plain words: where the money went, what still needs a category, whether an account
22
+ matches the bank, when money would run out. Every change is previewed, confirmed by
23
+ you, and can be undone.
24
+
25
+ **📖 Documentation: <https://mathbeal.github.io/avenir-mcp/>**
26
+
27
+ > **Unofficial project.** We are not affiliated, associated, or in any way officially
28
+ > connected with YNAB or any of its subsidiaries or affiliates. YNAB and You Need A Budget
29
+ > are registered trademarks of YNAB, named here only to say which service avenir-mcp works
30
+ > with.
31
+ >
32
+ > avenir-mcp is for **personal use on your own machine, with your own YNAB token**. Running it
33
+ > as a public or shared server is not supported. It is provided as is, without warranty,
34
+ > and is not financial advice: you remain responsible for the changes you confirm. See the
35
+ > [legal notice](https://mathbeal.github.io/avenir-mcp/project/legal/).
36
+
37
+ ## Why avenir-mcp
38
+
39
+ - **Tools for tasks, not endpoints.** Classify a month of transactions, reconcile an
40
+ account, forecast your balance: one tool each, not a wrapper of YNAB's API.
41
+ - **Read-only by default.** Tools that change your budget exist only when you enable
42
+ them.
43
+ - **Preview, confirm, undo.** Every write shows what will change and waits for your
44
+ yes; `undo_operation` reverts it.
45
+ - **Answers an agent can read.** Currency units, short typed answers, pagination,
46
+ errors that say what to fix, bank text treated as untrusted.
47
+ - **Verified.** 100 % line and branch coverage, and an evaluation where a real agent
48
+ works on an invented plan: 17/17 tasks.
49
+
50
+ ## Install
51
+
52
+ You need [uv](https://docs.astral.sh/uv/) and a YNAB personal access token
53
+ (YNAB → Account Settings → Developer Settings → New Token).
54
+
55
+ ```bash
56
+ # Claude Code
57
+ claude mcp add avenir-mcp --env YNAB_API_KEY=your-token --env AVENIR_MCP_WRITE=1 -- uvx avenir-mcp
58
+ ```
59
+
60
+ ```jsonc
61
+ // Claude Desktop, Cursor: the mcpServers block of the client's configuration
62
+ {
63
+ "mcpServers": {
64
+ "avenir-mcp": {
65
+ "command": "uvx",
66
+ "args": ["avenir-mcp"],
67
+ "env": { "YNAB_API_KEY": "your-token", "AVENIR_MCP_WRITE": "1" }
68
+ }
69
+ }
70
+ }
71
+ ```
72
+
73
+ Drop `AVENIR_MCP_WRITE` to stay read-only. Other clients and every option:
74
+ [Install](https://mathbeal.github.io/avenir-mcp/getting-started/install/) ·
75
+ [Configuration](https://mathbeal.github.io/avenir-mcp/reference/configuration/).
76
+
77
+ ## What you can ask
78
+
79
+ | You ask | avenir-mcp |
80
+ |---|---|
81
+ | "Which category is overspent this month?" | `get_monthly_summary` — totals and overspent categories |
82
+ | "Categorise what is pending." | `suggest_categories`, then `apply_categories` after your yes |
83
+ | "Split this receipt: 81.15 groceries, 5.25 household." | `split_transaction` — one transaction across categories, after your yes |
84
+ | "Which transaction is my 86.40 receipt from the 12th?" | `find_transactions` — by dates, exact amount and account, categorised or not |
85
+ | "My bank shows 3,440.80. Does YNAB agree?" | `reconcile_account` — explains the gap, changes nothing until it matches |
86
+ | "Will I go below zero before December?" | `forecast_balance` — month by month, with its assumptions |
87
+ | "Move 30 from Tennis to Restaurants." | `set_category_budget`, previewed and undoable |
88
+ | "Undo that." | `undo_operation` |
89
+
90
+ Walk-throughs with real answers: [Use cases](https://mathbeal.github.io/avenir-mcp/use-cases/classify/).
91
+ Every tool, resource and prompt: [Reference](https://mathbeal.github.io/avenir-mcp/reference/tools/).
92
+
93
+ ## Development
94
+
95
+ ```bash
96
+ git clone https://github.com/mathbeal/avenir-mcp && cd avenir-mcp
97
+ uv sync
98
+ just check # lint, types, tests at 100 % coverage, vocabulary, lockfile
99
+ just docs-serve # the documentation, live
100
+ just evaluate # a real agent on the demo budget (uses your Claude plan)
101
+ ```
102
+
103
+ Read [AGENTS.md](AGENTS.md) and [CONTRIBUTING.md](CONTRIBUTING.md) before opening a
104
+ pull request. Security reports: [SECURITY.md](SECURITY.md).
105
+
106
+ ## Licence
107
+
108
+ MIT.
@@ -0,0 +1,3 @@
1
+ """avenir-mcp — an MCP server for YNAB, built for agents."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ """Allow `python -m avenir_mcp`."""
2
+
3
+ from avenir_mcp.server import main
4
+
5
+ main()
@@ -0,0 +1,20 @@
1
+ """Amounts an agent may pass: finite, and within what a household budget can hold."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ from pydantic import Field # pylint: disable=import-error
8
+
9
+ MAX_AMOUNT = 1_000_000_000
10
+
11
+ Amount = Annotated[
12
+ float,
13
+ Field(
14
+ allow_inf_nan=False,
15
+ ge=-MAX_AMOUNT,
16
+ le=MAX_AMOUNT,
17
+ description="In currency units, negative for money out; at most a billion either way.",
18
+ ),
19
+ ]
20
+ """An amount in currency units, checked by the tool's argument schema before any call."""
@@ -0,0 +1,299 @@
1
+ """Financial analytics computed from YNAB data — pure functions, no I/O."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any
7
+
8
+ from avenir_mcp import client
9
+ from avenir_mcp.model import Model
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class BudgetUsage(Model):
15
+ """How much of a category's budget was used in a month."""
16
+
17
+ id: str
18
+ """YNAB id of the category."""
19
+ name: str
20
+ """Category name."""
21
+ group: str
22
+ """Name of the category's group, e.g. Fun for Tennis."""
23
+ budgeted: float
24
+ """Amount assigned to the category this month."""
25
+ actual: float
26
+ """Amount spent this month, as a positive number."""
27
+ balance: float
28
+ """What is left: budgeted minus spent, plus what was carried over; negative when overspent."""
29
+ utilization_pct: float
30
+ """Spent as a share of budgeted, in percent; above 100 means overspent, 0 when nothing
31
+ is budgeted."""
32
+
33
+
34
+ def budget_vs_actual(
35
+ month_categories: list[dict[str, Any]],
36
+ ) -> list[BudgetUsage]:
37
+ """Compute budget-vs-actual for each category in a given month.
38
+
39
+ Args:
40
+ month_categories: List of YNAB category dicts for a specific month,
41
+ each containing ``budgeted`` and ``activity`` in milliunits.
42
+
43
+ Returns:
44
+ One usage per usable category, amounts in currency units.
45
+
46
+ Examples:
47
+ >>> cat = {"id": "c1", "name": "Rent", "budgeted": 500000,
48
+ ... "activity": -400000, "balance": 100000}
49
+ >>> budget_vs_actual([cat])[0].utilization_pct
50
+ 80.0
51
+ """
52
+ results: list[BudgetUsage] = []
53
+ for cat in filter(_usable, month_categories):
54
+ budgeted_mu = cat.get("budgeted", 0)
55
+ activity_mu = cat.get("activity", 0)
56
+ balance_mu = cat.get("balance", 0)
57
+ budgeted = client.milliunit_to_amount(budgeted_mu)
58
+ actual = client.milliunit_to_amount(abs(activity_mu))
59
+ balance = client.milliunit_to_amount(balance_mu)
60
+
61
+ if budgeted_mu == 0:
62
+ utilization_pct = 0.0
63
+ else:
64
+ utilization_pct = round(actual / budgeted * 100, 1)
65
+
66
+ results.append(
67
+ BudgetUsage(
68
+ id=cat.get("id", ""),
69
+ name=cat.get("name", ""),
70
+ group=cat.get("category_group_name", ""),
71
+ budgeted=budgeted,
72
+ actual=actual,
73
+ balance=balance,
74
+ utilization_pct=utilization_pct,
75
+ )
76
+ )
77
+ return results
78
+
79
+
80
+ class MonthSpending(Model):
81
+ """What a category spent in one month."""
82
+
83
+ month: str
84
+ """First day of the month, YYYY-MM-01."""
85
+ amount: float
86
+ """Amount spent, as a positive number in currency units."""
87
+
88
+
89
+ def spending_trends(
90
+ months_data: list[tuple[str, list[dict[str, Any]]]],
91
+ ) -> dict[str, list[MonthSpending]]:
92
+ """Build a time-series of spending per category across multiple months.
93
+
94
+ Args:
95
+ months_data: List of ``(month_label, month_categories)`` tuples,
96
+ ordered chronologically. Each element is a ``(str, list)`` pair
97
+ where the string is an ISO month label and the list is the output of
98
+ :func:`client.get_month_categories`.
99
+
100
+ Returns:
101
+ Dict mapping category name to its spending, month by month in chronological
102
+ order.
103
+
104
+ Examples:
105
+ >>> cats = [{"id": "c1", "name": "AWS", "budgeted": 0, "activity": -100000, "balance": 0}]
106
+ >>> spending_trends([("2026-01-01", cats)])["AWS"]
107
+ [MonthSpending(month='2026-01-01', amount=100.0)]
108
+ """
109
+ trends: dict[str, list[MonthSpending]] = {}
110
+ for month_label, categories in months_data:
111
+ for cat in filter(_usable, categories):
112
+ name = cat.get("name", "")
113
+ if not name:
114
+ continue
115
+ amount = client.milliunit_to_amount(abs(cat.get("activity", 0)))
116
+ if name not in trends:
117
+ trends[name] = []
118
+ trends[name].append(MonthSpending(month=month_label, amount=amount))
119
+ return trends
120
+
121
+
122
+ class PayeeTotal(Model):
123
+ """What was spent with one payee."""
124
+
125
+ payee_name: str
126
+ """Payee name; Unknown when the transactions have none."""
127
+ total: float
128
+ """Total spent, as a positive number in currency units."""
129
+ count: int
130
+ """Number of transactions."""
131
+
132
+
133
+ def top_payees(
134
+ transactions: list[dict[str, Any]],
135
+ limit: int = 10,
136
+ ) -> list[PayeeTotal]:
137
+ """Aggregate transactions by payee and return the top spenders.
138
+
139
+ Args:
140
+ transactions: List of YNAB transaction dicts with ``payee_name``
141
+ and ``amount`` fields (amounts are in milliunits, negative = expense).
142
+ limit: Maximum number of payees to return (default 10).
143
+
144
+ Returns:
145
+ The payees, the biggest spending first.
146
+
147
+ Examples:
148
+ >>> txs = [
149
+ ... {"payee_name": "AWS", "amount": -50000},
150
+ ... {"payee_name": "AWS", "amount": -30000},
151
+ ... {"payee_name": "Loyer", "amount": -500000},
152
+ ... ]
153
+ >>> top_payees(txs, limit=1)[0].payee_name
154
+ 'Loyer'
155
+ """
156
+ totals: dict[str, dict[str, Any]] = {}
157
+ for tx in transactions:
158
+ payee = tx.get("payee_name") or "Unknown"
159
+ amount_mu = tx.get("amount", 0)
160
+ if payee not in totals:
161
+ totals[payee] = {"total_mu": 0, "count": 0}
162
+ totals[payee]["total_mu"] += abs(amount_mu)
163
+ totals[payee]["count"] += 1
164
+
165
+ sorted_payees = sorted(totals.items(), key=lambda kv: kv[1]["total_mu"], reverse=True)
166
+ return [
167
+ PayeeTotal(
168
+ payee_name=name,
169
+ total=client.milliunit_to_amount(data["total_mu"]),
170
+ count=data["count"],
171
+ )
172
+ for name, data in sorted_payees[:limit]
173
+ ]
174
+
175
+
176
+ _INTERNAL_GROUP = "Internal Master Category"
177
+
178
+
179
+ class Overspent(Model):
180
+ """A category whose available balance is negative."""
181
+
182
+ category_id: str
183
+ """YNAB id of the category."""
184
+ name: str
185
+ """Category name."""
186
+ group: str
187
+ """Name of the category's group."""
188
+ balance: float
189
+ """Available balance, negative: the amount overspent, in currency units."""
190
+
191
+
192
+ class MonthOverview(Model):
193
+ """A month's totals and the categories that need attention."""
194
+
195
+ month: str
196
+ """First day of the month, YYYY-MM-01."""
197
+ income: float
198
+ """Money received in the month and assigned to Ready to Assign."""
199
+ budgeted: float
200
+ """Total assigned to categories in the month."""
201
+ activity: float
202
+ """Total spent (negative) and received in categories during the month."""
203
+ ready_to_assign: float
204
+ """Money not yet given a job; negative when more was assigned than received."""
205
+ age_of_money: int | None
206
+ """Days between receiving money and spending it, as YNAB computes it; null when unknown."""
207
+ overspent: list[Overspent]
208
+ """Categories whose available balance is negative this month."""
209
+
210
+
211
+ class CategoryBalance(Model):
212
+ """One category's month in currency units."""
213
+
214
+ category_id: str
215
+ """YNAB id of the category."""
216
+ name: str
217
+ """Category name."""
218
+ group: str
219
+ """Name of the category's group."""
220
+ budgeted: float
221
+ """Amount assigned to the category this month."""
222
+ activity: float
223
+ """Amount spent (negative) or received in the category this month."""
224
+ balance: float
225
+ """Available at the end of the month: carried over, plus budgeted, plus activity."""
226
+
227
+
228
+ def _usable(cat: dict[str, Any]) -> bool:
229
+ """Tell whether a category counts as the user's spending.
230
+
231
+ Args:
232
+ cat: A YNAB category.
233
+
234
+ Returns:
235
+ True when it is visible, not deleted, and not one of YNAB's internal categories.
236
+ """
237
+ return (
238
+ not cat.get("hidden")
239
+ and not cat.get("deleted")
240
+ and cat.get("category_group_name") != _INTERNAL_GROUP
241
+ )
242
+
243
+
244
+ def month_overview(month: dict[str, Any]) -> MonthOverview:
245
+ """Summarise a YNAB month: totals in currency units and overspent categories.
246
+
247
+ Args:
248
+ month: A YNAB month, as returned by :func:`client.get_month`.
249
+
250
+ Returns:
251
+ The month's totals and the usable categories whose balance is negative.
252
+ """
253
+ amount = client.milliunit_to_amount
254
+ return MonthOverview(
255
+ month=month["month"],
256
+ income=amount(month.get("income", 0)),
257
+ budgeted=amount(month.get("budgeted", 0)),
258
+ activity=amount(month.get("activity", 0)),
259
+ ready_to_assign=amount(month.get("to_be_budgeted", 0)),
260
+ age_of_money=month.get("age_of_money"),
261
+ overspent=[
262
+ Overspent(
263
+ category_id=cat["id"],
264
+ name=cat["name"],
265
+ group=cat.get("category_group_name", ""),
266
+ balance=amount(cat["balance"]),
267
+ )
268
+ for cat in month.get("categories", [])
269
+ if _usable(cat) and cat.get("balance", 0) < 0
270
+ ],
271
+ )
272
+
273
+
274
+ def category_balances(
275
+ categories: list[dict[str, Any]], include_empty: bool = False
276
+ ) -> list[CategoryBalance]:
277
+ """Give one line per usable category of a month.
278
+
279
+ Args:
280
+ categories: The month's YNAB categories, amounts in milliunits.
281
+ include_empty: Also list categories with nothing budgeted, spent or available.
282
+
283
+ Returns:
284
+ Each category's budgeted, activity and balance, in currency units.
285
+ """
286
+ amount = client.milliunit_to_amount
287
+ return [
288
+ CategoryBalance(
289
+ category_id=cat["id"],
290
+ name=cat["name"],
291
+ group=cat.get("category_group_name", ""),
292
+ budgeted=amount(cat.get("budgeted", 0)),
293
+ activity=amount(cat.get("activity", 0)),
294
+ balance=amount(cat.get("balance", 0)),
295
+ )
296
+ for cat in categories
297
+ if _usable(cat)
298
+ and (include_empty or any(cat.get(key, 0) for key in ("budgeted", "activity", "balance")))
299
+ ]