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.
- avenir_mcp-0.1.0/LICENSE +21 -0
- avenir_mcp-0.1.0/PKG-INFO +142 -0
- avenir_mcp-0.1.0/README.md +108 -0
- avenir_mcp-0.1.0/avenir_mcp/__init__.py +3 -0
- avenir_mcp-0.1.0/avenir_mcp/__main__.py +5 -0
- avenir_mcp-0.1.0/avenir_mcp/amounts.py +20 -0
- avenir_mcp-0.1.0/avenir_mcp/analytics.py +299 -0
- avenir_mcp-0.1.0/avenir_mcp/app.py +94 -0
- avenir_mcp-0.1.0/avenir_mcp/classifier.py +172 -0
- avenir_mcp-0.1.0/avenir_mcp/client.py +661 -0
- avenir_mcp-0.1.0/avenir_mcp/confirm.py +402 -0
- avenir_mcp-0.1.0/avenir_mcp/context.py +235 -0
- avenir_mcp-0.1.0/avenir_mcp/forecast.py +415 -0
- avenir_mcp-0.1.0/avenir_mcp/http_auth.py +57 -0
- avenir_mcp-0.1.0/avenir_mcp/journal.py +178 -0
- avenir_mcp-0.1.0/avenir_mcp/logs.py +56 -0
- avenir_mcp-0.1.0/avenir_mcp/model.py +15 -0
- avenir_mcp-0.1.0/avenir_mcp/reconcile.py +131 -0
- avenir_mcp-0.1.0/avenir_mcp/schedule.py +149 -0
- avenir_mcp-0.1.0/avenir_mcp/search.py +189 -0
- avenir_mcp-0.1.0/avenir_mcp/server.py +130 -0
- avenir_mcp-0.1.0/avenir_mcp/split.py +192 -0
- avenir_mcp-0.1.0/avenir_mcp/text.py +38 -0
- avenir_mcp-0.1.0/avenir_mcp/tools_accounts.py +575 -0
- avenir_mcp-0.1.0/avenir_mcp/tools_budget.py +455 -0
- avenir_mcp-0.1.0/avenir_mcp/tools_categories.py +339 -0
- avenir_mcp-0.1.0/avenir_mcp/tools_classify.py +230 -0
- avenir_mcp-0.1.0/avenir_mcp/tools_undo.py +219 -0
- avenir_mcp-0.1.0/avenir_mcp/triage.py +318 -0
- avenir_mcp-0.1.0/avenir_mcp/writes.py +263 -0
- avenir_mcp-0.1.0/avenir_mcp.egg-info/PKG-INFO +142 -0
- avenir_mcp-0.1.0/avenir_mcp.egg-info/SOURCES.txt +70 -0
- avenir_mcp-0.1.0/avenir_mcp.egg-info/dependency_links.txt +1 -0
- avenir_mcp-0.1.0/avenir_mcp.egg-info/entry_points.txt +2 -0
- avenir_mcp-0.1.0/avenir_mcp.egg-info/requires.txt +6 -0
- avenir_mcp-0.1.0/avenir_mcp.egg-info/top_level.txt +1 -0
- avenir_mcp-0.1.0/pyproject.toml +148 -0
- avenir_mcp-0.1.0/setup.cfg +4 -0
- avenir_mcp-0.1.0/tests/test_amounts.py +72 -0
- avenir_mcp-0.1.0/tests/test_analytics.py +332 -0
- avenir_mcp-0.1.0/tests/test_api_coverage.py +98 -0
- avenir_mcp-0.1.0/tests/test_classifier.py +236 -0
- avenir_mcp-0.1.0/tests/test_client.py +896 -0
- avenir_mcp-0.1.0/tests/test_confirm.py +126 -0
- avenir_mcp-0.1.0/tests/test_context.py +158 -0
- avenir_mcp-0.1.0/tests/test_docs.py +60 -0
- avenir_mcp-0.1.0/tests/test_evals.py +196 -0
- avenir_mcp-0.1.0/tests/test_forecast.py +383 -0
- avenir_mcp-0.1.0/tests/test_http.py +126 -0
- avenir_mcp-0.1.0/tests/test_hygiene.py +84 -0
- avenir_mcp-0.1.0/tests/test_journal.py +159 -0
- avenir_mcp-0.1.0/tests/test_logging.py +135 -0
- avenir_mcp-0.1.0/tests/test_policy.py +80 -0
- avenir_mcp-0.1.0/tests/test_properties.py +177 -0
- avenir_mcp-0.1.0/tests/test_protocol.py +164 -0
- avenir_mcp-0.1.0/tests/test_protocol_budget.py +129 -0
- avenir_mcp-0.1.0/tests/test_protocol_categories.py +196 -0
- avenir_mcp-0.1.0/tests/test_protocol_create.py +161 -0
- avenir_mcp-0.1.0/tests/test_protocol_find.py +79 -0
- avenir_mcp-0.1.0/tests/test_protocol_forecast.py +261 -0
- avenir_mcp-0.1.0/tests/test_protocol_reconcile.py +221 -0
- avenir_mcp-0.1.0/tests/test_protocol_schedule.py +85 -0
- avenir_mcp-0.1.0/tests/test_protocol_split.py +111 -0
- avenir_mcp-0.1.0/tests/test_protocol_writes.py +294 -0
- avenir_mcp-0.1.0/tests/test_reconcile.py +118 -0
- avenir_mcp-0.1.0/tests/test_schedule.py +154 -0
- avenir_mcp-0.1.0/tests/test_search.py +175 -0
- avenir_mcp-0.1.0/tests/test_server.py +291 -0
- avenir_mcp-0.1.0/tests/test_split.py +125 -0
- avenir_mcp-0.1.0/tests/test_text.py +40 -0
- avenir_mcp-0.1.0/tests/test_triage.py +270 -0
- avenir_mcp-0.1.0/tests/test_writes.py +263 -0
avenir_mcp-0.1.0/LICENSE
ADDED
|
@@ -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
|
+
[](https://github.com/mathbeal/avenir-mcp/actions/workflows/quality.yml)
|
|
38
|
+
[](https://github.com/mathbeal/avenir-mcp/actions/workflows/inspector.yml)
|
|
39
|
+
[](https://mathbeal.github.io/avenir-mcp/)
|
|
40
|
+
|
|
41
|
+
[](https://mathbeal.github.io/avenir-mcp/project/development/#the-checks)
|
|
42
|
+
[](https://mathbeal.github.io/avenir-mcp/project/development/#the-checks)
|
|
43
|
+
[](https://mathbeal.github.io/avenir-mcp/project/development/#the-checks)
|
|
44
|
+
[](https://mathbeal.github.io/avenir-mcp/project/development/#the-checks)
|
|
45
|
+
[](https://mathbeal.github.io/avenir-mcp/project/development/#the-checks)
|
|
46
|
+
[](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
|
+
[](https://github.com/mathbeal/avenir-mcp/actions/workflows/quality.yml)
|
|
4
|
+
[](https://github.com/mathbeal/avenir-mcp/actions/workflows/inspector.yml)
|
|
5
|
+
[](https://mathbeal.github.io/avenir-mcp/)
|
|
6
|
+
|
|
7
|
+
[](https://mathbeal.github.io/avenir-mcp/project/development/#the-checks)
|
|
8
|
+
[](https://mathbeal.github.io/avenir-mcp/project/development/#the-checks)
|
|
9
|
+
[](https://mathbeal.github.io/avenir-mcp/project/development/#the-checks)
|
|
10
|
+
[](https://mathbeal.github.io/avenir-mcp/project/development/#the-checks)
|
|
11
|
+
[](https://mathbeal.github.io/avenir-mcp/project/development/#the-checks)
|
|
12
|
+
[](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,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
|
+
]
|