actual-mcp-server 0.9.6 → 0.11.0
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.
- package/README.md +53 -13
- package/dist/src/actualToolsManager.js +6 -0
- package/dist/src/auth/budget-acl-dynamic.js +155 -0
- package/dist/src/auth/budget-acl.js +40 -0
- package/dist/src/config.js +34 -0
- package/dist/src/lib/actual-adapter.js +64 -1
- package/dist/src/tools/budgets_export.js +102 -0
- package/dist/src/tools/budgets_import.js +109 -0
- package/dist/src/tools/index.js +3 -0
- package/dist/src/tools/preferences_get.js +42 -0
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -24,13 +24,13 @@ Actual MCP Server is a [Model Context Protocol](https://modelcontextprotocol.io/
|
|
|
24
24
|
┌─────────────┐ MCP/HTTP ┌──────────────────┐ Actual API ┌──────────────┐
|
|
25
25
|
│ LibreChat │ ◄───────────► │ Actual MCP │ ◄───────────► │ Actual │
|
|
26
26
|
│ LobeChat │ │ Server │ │ Budget │
|
|
27
|
-
│ (remote) │ │ (
|
|
27
|
+
│ (remote) │ │ (74 tools) │ │ Server │
|
|
28
28
|
└─────────────┘ └──────────────────┘ └──────────────┘
|
|
29
29
|
|
|
30
30
|
┌─────────────┐ MCP/stdio ┌──────────────────┐ Actual API ┌──────────────┐
|
|
31
31
|
│ Claude │ ◄───────────► │ Actual MCP │ ◄───────────► │ Actual │
|
|
32
32
|
│ Desktop │ │ Server │ │ Budget │
|
|
33
|
-
│ (local) │ │ (
|
|
33
|
+
│ (local) │ │ (74 tools) │ │ Server │
|
|
34
34
|
└─────────────┘ └──────────────────┘ └──────────────┘
|
|
35
35
|
```
|
|
36
36
|
|
|
@@ -38,14 +38,14 @@ Actual MCP Server is a [Model Context Protocol](https://modelcontextprotocol.io/
|
|
|
38
38
|
|
|
39
39
|
Most Actual Budget MCP implementations are simple stdio bridges designed for single-user, local use with Claude Desktop. This project goes further:
|
|
40
40
|
|
|
41
|
-
- **
|
|
41
|
+
- **74 tools, the most comprehensive coverage available.** Accounts, transactions, categories, payees, tags, notes, rules, budgets, batch operations, bank sync, and more. Covers the reachable Actual Budget API with no genuine gaps.
|
|
42
42
|
- **HTTP and stdio transport.** Runs as a real remote server for LibreChat/LobeChat (`--http`), or as a direct local process for Claude Desktop (`--stdio`). No Docker or HTTP server is needed for local use.
|
|
43
43
|
- **6 exclusive ActualQL-powered tools.** Search and summarise transactions by month, amount, category, or payee using Actual Budget's native query engine. Aggregated results, no raw data dumped into the AI context window.
|
|
44
44
|
- **Multi-budget switching at runtime.** Configure multiple budget files and let the AI switch between them mid-conversation with `actual_budgets_switch`.
|
|
45
45
|
- **Multi-user ready with OIDC.** Secure every session with JWKS-validated JWTs and per-user budget ACLs. No shared tokens required.
|
|
46
46
|
- **Production-grade reliability.** Connection pooling (up to 15 concurrent sessions), automatic retry with exponential backoff, and a full test suite (unit + E2E + integration).
|
|
47
47
|
|
|
48
|
-
> **Verified working** with [LibreChat](https://www.librechat.ai/), [LobeChat](https://lobehub.com/home), and [Claude Desktop](https://claude.ai/download). All
|
|
48
|
+
> **Verified working** with [LibreChat](https://www.librechat.ai/), [LobeChat](https://lobehub.com/home), and [Claude Desktop](https://claude.ai/download). All 74 tools tested end-to-end. Any MCP-compatible client should work.
|
|
49
49
|
|
|
50
50
|
---
|
|
51
51
|
|
|
@@ -178,7 +178,7 @@ Add to `claude_desktop_config.json` (see [docs/guides/MCP_CLIENTS_SETUP.md](docs
|
|
|
178
178
|
}
|
|
179
179
|
```
|
|
180
180
|
|
|
181
|
-
> **No token needed.** stdio runs as a local process owned by your user. The transport itself is the security boundary. All
|
|
181
|
+
> **No token needed.** stdio runs as a local process owned by your user. The transport itself is the security boundary. All 74 tools are available.
|
|
182
182
|
>
|
|
183
183
|
> **`MCP_BRIDGE_DATA_DIR` should be an absolute path.** Without one, the data directory resolves relative to wherever the client spawns the process, which can be unpredictable. The directory is created automatically on first run.
|
|
184
184
|
|
|
@@ -304,7 +304,7 @@ For Claude Desktop (stdio), restart Claude after upgrading.
|
|
|
304
304
|
|
|
305
305
|
## Available Tools
|
|
306
306
|
|
|
307
|
-
**
|
|
307
|
+
**74 tools** across all categories. All tools use the `actual_<category>_<action>` naming convention.
|
|
308
308
|
|
|
309
309
|
### Accounts (7)
|
|
310
310
|
|
|
@@ -318,7 +318,7 @@ For Claude Desktop (stdio), restart Claude after upgrading.
|
|
|
318
318
|
| `actual_accounts_reopen` | Reopen closed account |
|
|
319
319
|
| `actual_accounts_get_balance` | Get account balance at a date |
|
|
320
320
|
|
|
321
|
-
### Transactions (
|
|
321
|
+
### Transactions (14)
|
|
322
322
|
|
|
323
323
|
**Standard (6)**
|
|
324
324
|
|
|
@@ -333,11 +333,12 @@ For Claude Desktop (stdio), restart Claude after upgrading.
|
|
|
333
333
|
|
|
334
334
|
> **Split transactions:** pass a `subtransactions` array (each child needs an `amount` in integer cents; `category` and `notes` are optional). The child amounts must sum to the parent `amount` (the server does not enforce this, so the tool does). A split parent carries no category of its own: put categories on the children. `actual_transactions_update` can edit the children of a transaction that is ALREADY a split; converting a plain transaction into a split via update is not supported (create it as a split instead). Note: `actual_transactions_import` forwards a `subtransactions` array to the API but does NOT apply the sum check, so use `actual_transactions_create` for guaranteed-balanced splits.
|
|
335
335
|
|
|
336
|
-
**Utility (
|
|
336
|
+
**Utility (2)**
|
|
337
337
|
|
|
338
338
|
| Tool | Description |
|
|
339
339
|
|------|-------------|
|
|
340
340
|
| `actual_transactions_uncategorized` | Summary of uncategorized transactions (totalCount, totalAmount, per-account breakdown); pass `includeTransactions:true` for paginated rows |
|
|
341
|
+
| `actual_transactions_update_batch` | Apply many transaction updates in ONE call (`updates: [{ id, fields }]`); returns per-item success and failure counts |
|
|
341
342
|
|
|
342
343
|
**Exclusive ActualQL-powered (6)**, unique to this MCP server
|
|
343
344
|
|
|
@@ -390,7 +391,13 @@ For Claude Desktop (stdio), restart Claude after upgrading.
|
|
|
390
391
|
| `actual_notes_get` | Get the note for any entity (account/category/category-group/payee UUID, or budget-YYYY-MM) |
|
|
391
392
|
| `actual_notes_update` | Set or clear the note for any entity; validates entity exists or matches budget-YYYY-MM pattern |
|
|
392
393
|
|
|
393
|
-
###
|
|
394
|
+
### Preferences (1)
|
|
395
|
+
|
|
396
|
+
| Tool | Description |
|
|
397
|
+
|------|-------------|
|
|
398
|
+
| `actual_preferences_get` | Read the budget's synced display preferences (number format, date format, currency, first day of week). Display only: all amounts stay integer cents |
|
|
399
|
+
|
|
400
|
+
### Budgets (12)
|
|
394
401
|
|
|
395
402
|
| Tool | Description |
|
|
396
403
|
|------|-------------|
|
|
@@ -404,11 +411,26 @@ For Claude Desktop (stdio), restart Claude after upgrading.
|
|
|
404
411
|
| `actual_budgets_setCarryover` | Enable/disable carryover |
|
|
405
412
|
| `actual_budgets_holdForNextMonth` | Hold funds for next month |
|
|
406
413
|
| `actual_budgets_resetHold` | Reset hold status |
|
|
414
|
+
| `actual_budgets_export` | Export the active budget as a `.zip` into `ACTUAL_EXPORT_DIR`; returns path, byte size and sha256, never the file contents |
|
|
415
|
+
| `actual_budgets_import` | Restore a budget from an Actual `.zip` or a YNAB4/YNAB5 export. **Destructive:** the budget id comes from the archive, so re-importing an export *replaces* that budget's data rather than making a copy. Also loads the imported budget, changing the session's active budget |
|
|
407
416
|
|
|
408
|
-
### Rules (
|
|
417
|
+
### Rules (5)
|
|
409
418
|
|
|
410
419
|
`actual_rules_get` · `actual_rules_create` · `actual_rules_update` · `actual_rules_delete`
|
|
411
420
|
|
|
421
|
+
| Tool | Description |
|
|
422
|
+
|------|-------------|
|
|
423
|
+
| `actual_rules_create_or_update` | Idempotent upsert: create a rule, or update the existing one that matches the same conditions, in a single call. Use this instead of get-then-create when re-running a categorisation setup, so repeated runs do not pile up duplicate rules |
|
|
424
|
+
|
|
425
|
+
### Schedules (4)
|
|
426
|
+
|
|
427
|
+
| Tool | Description |
|
|
428
|
+
|------|-------------|
|
|
429
|
+
| `actual_schedules_get` | List scheduled transactions, with their next occurrence date |
|
|
430
|
+
| `actual_schedules_create` | Create a schedule (one-off or recurring); recurrence is a typed config, see `src/lib/schemas/recur.ts` |
|
|
431
|
+
| `actual_schedules_update` | Update an existing schedule's amount, payee, account, or recurrence |
|
|
432
|
+
| `actual_schedules_delete` | Delete a schedule |
|
|
433
|
+
|
|
412
434
|
### Advanced Query & Sync (2)
|
|
413
435
|
|
|
414
436
|
| Tool | Description |
|
|
@@ -458,6 +480,7 @@ All configuration is via environment variables. Copy `.env.example` to `.env` to
|
|
|
458
480
|
| `MCP_BRIDGE_PORT` | `3600` | No | Port for MCP server to listen on |
|
|
459
481
|
| `MCP_BRIDGE_BIND_HOST` | `0.0.0.0` | No | Host address to bind server to (`0.0.0.0` = all interfaces) |
|
|
460
482
|
| `MCP_BRIDGE_DATA_DIR` | `./actual-data` | No | Directory to store Actual Budget local data (SQLite). **Required to be a persistent path.** The `@actual-app/api` library downloads a local copy of your budget here to run queries; use a volume mount in Docker to persist it across restarts |
|
|
483
|
+
| `ACTUAL_EXPORT_DIR` | (unset) | No | Directory where `actual_budgets_export` writes budget `.zip` files. Unset means `<MCP_BRIDGE_DATA_DIR>/exports`, which is writable and persisted in the Docker image. Must be writable by the runtime user |
|
|
461
484
|
| `MCP_BRIDGE_PUBLIC_HOST` | auto-detected | No | Public hostname/IP for server (shown in logs) |
|
|
462
485
|
| `MCP_BRIDGE_PUBLIC_SCHEME` | auto-detected | No | Public scheme (`http` or `https`) |
|
|
463
486
|
| `MCP_BRIDGE_USE_TLS` | `false` | No | Set to `true` to advertise `https://` in the server URL (for reverse-proxy setups where TLS is terminated upstream) |
|
|
@@ -481,6 +504,8 @@ All configuration is via environment variables. Copy `.env.example` to `.env` to
|
|
|
481
504
|
| `OIDC_JWKS_TRUSTED_HOSTS` | _(none)_ | No | Opt-in cross-origin JWKS hosts, comma-separated `host` or `host:port` (#254). For IdPs whose `jwks_uri` lives on another host, e.g. Google needs `www.googleapis.com`. Exact match, no wildcards; empty default keeps same-origin-only |
|
|
482
505
|
| `OIDC_SCOPES` | _(none)_ | No | Comma-separated required scopes; leave empty for Casdoor |
|
|
483
506
|
| `AUTH_BUDGET_ACL` | _(none)_ | No | Per-user budget ACL; see [AI Client Setup](docs/guides/AI_CLIENT_SETUP.md#oidc-authentication-multi-user) |
|
|
507
|
+
| `AUTH_BUDGET_ACL_SOURCE` | `static` | No | `static` uses the `AUTH_BUDGET_ACL` map above. `actual` derives the ACL from the Actual server's own per-file access list, so revoking someone in Actual takes effect here without a config edit. Requires a multi-user (OpenID) Actual server that was password-bootstrapped first |
|
|
508
|
+
| `AUTH_BUDGET_ACL_CLAIM` | `sub` | No | Which OIDC claim is matched against Actual's user identity when the source is `actual`. `sub` matches Actual's `userId` and is strongly preferred; matching a name claim is riskier |
|
|
484
509
|
| `MCP_ENABLE_HTTPS` | `false` | No | Enable native TLS. Requires `MCP_HTTPS_CERT` and `MCP_HTTPS_KEY` |
|
|
485
510
|
| `MCP_HTTPS_CERT` | _(none)_ | No | Path to PEM certificate file (required when `MCP_ENABLE_HTTPS=true`) |
|
|
486
511
|
| `MCP_HTTPS_KEY` | _(none)_ | No | Path to PEM private key file (required when `MCP_ENABLE_HTTPS=true`) |
|
|
@@ -562,7 +587,7 @@ stdio is the simplest way to connect Claude Desktop directly to Actual Budget. T
|
|
|
562
587
|
- No auth token. Process ownership is the security boundary.
|
|
563
588
|
- All logs go to stderr so they never corrupt the JSON-RPC framing on stdout
|
|
564
589
|
- The process exits when stdin closes (Claude Desktop shutting down)
|
|
565
|
-
- All
|
|
590
|
+
- All 74 tools are available, identical to HTTP mode
|
|
566
591
|
|
|
567
592
|
**Start manually to verify:**
|
|
568
593
|
|
|
@@ -635,6 +660,21 @@ OIDC_SCOPES= # leave empty for Casdoor
|
|
|
635
660
|
- `GET /.well-known/oauth-protected-resource` (RFC 9728): identifies this server as a protected resource and points at your `OIDC_ISSUER` as the authorization server.
|
|
636
661
|
- `GET /.well-known/oauth-authorization-server` (RFC 8414, #285): the authorization server metadata (its `authorization_endpoint` / `token_endpoint` / `registration_endpoint`), re-served from your IdP's own OpenID discovery document. This is here because several clients look for it on the resource-server origin, and some IdPs (e.g. Authentik) do not expose it where those clients look. It is fetched once at startup and served verbatim, exposes only endpoints your IdP already publishes publicly, and requires no authentication (a client reads it before it has a token). No extra configuration is needed; it is absent when `AUTH_PROVIDER` is not `oidc`.
|
|
637
662
|
|
|
663
|
+
**Where the per-user budget ACL comes from.** By default the ACL is the `AUTH_BUDGET_ACL` map you maintain by hand. Since v0.10.x it can instead be derived from the Actual server's own per-file access list, so granting or revoking someone in Actual takes effect here without a config edit and a restart:
|
|
664
|
+
|
|
665
|
+
```bash
|
|
666
|
+
AUTH_BUDGET_ACL_SOURCE=actual # default: static (the AUTH_BUDGET_ACL map)
|
|
667
|
+
AUTH_BUDGET_ACL_CLAIM=sub # default: sub, matched against Actual's userId
|
|
668
|
+
```
|
|
669
|
+
|
|
670
|
+
Three things to know before enabling it:
|
|
671
|
+
|
|
672
|
+
- **It requires a multi-user Actual server**, meaning one configured with `ACTUAL_OPENID_*`. On a password-only server no named users exist and every principal resolves to nothing.
|
|
673
|
+
- **Your Actual server must have been bootstrapped with a PASSWORD before OpenID was added.** This is a hard prerequisite of this MCP server generally, not just of this feature: `@actual-app/api` authenticates with a password, and an Actual server set up with OpenID from scratch refuses password login, so this server cannot connect to it at all. A password cannot be added afterwards.
|
|
674
|
+
- **Keep `AUTH_BUDGET_ACL_CLAIM=sub` unless you have a specific reason not to.** `sub` is matched against Actual's stable `userId`. Matching a name-like claim instead is riskier: this server's own service account appears in every file's access list as the owner with a blank `userName`, so a principal whose name claim resolves to empty would match it.
|
|
675
|
+
|
|
676
|
+
It is opt-in and fails closed: if the budget list cannot be read, or the principal matches no file, access is denied rather than granted.
|
|
677
|
+
|
|
638
678
|
See [AI Client Setup, OIDC](docs/guides/AI_CLIENT_SETUP.md#oidc-authentication-multi-user) for `AUTH_BUDGET_ACL` format and Casdoor notes.
|
|
639
679
|
|
|
640
680
|
---
|
|
@@ -644,7 +684,7 @@ See [AI Client Setup, OIDC](docs/guides/AI_CLIENT_SETUP.md#oidc-authentication-m
|
|
|
644
684
|
| Command | What It Tests | Requires Live Server |
|
|
645
685
|
|---------|---------------|---------------------|
|
|
646
686
|
| `npm run build` | TypeScript compilation | No |
|
|
647
|
-
| `npm run test:unit-js` |
|
|
687
|
+
| `npm run test:unit-js` | 74-tool smoke, schema validation, auth ACL | No |
|
|
648
688
|
| `npm run test:adapter` | Adapter, retry logic, concurrency | No |
|
|
649
689
|
| `npm run test:e2e` | MCP protocol compliance (Playwright) | No |
|
|
650
690
|
| `npm run test:e2e:docker:full` | Full stack integration | Yes (Docker) |
|
|
@@ -729,4 +769,4 @@ The software is provided **as-is**, without warranty of any kind. The author acc
|
|
|
729
769
|
|
|
730
770
|
---
|
|
731
771
|
|
|
732
|
-
**Version:** 0.
|
|
772
|
+
**Version:** 0.11.0 | **Tool Count:** 74 (verified LibreChat-compatible)
|
|
@@ -75,6 +75,9 @@ const IMPLEMENTED_TOOLS = [
|
|
|
75
75
|
'actual_tags_delete',
|
|
76
76
|
'actual_notes_get',
|
|
77
77
|
'actual_notes_update',
|
|
78
|
+
'actual_budgets_export',
|
|
79
|
+
'actual_budgets_import',
|
|
80
|
+
'actual_preferences_get',
|
|
78
81
|
];
|
|
79
82
|
// 🔑 Mapping of Actual API function names → your MCP tool names
|
|
80
83
|
// This allows us to compare what exists in the API vs. what it has been wrapped.
|
|
@@ -118,6 +121,9 @@ const API_TOOL_MAP = {
|
|
|
118
121
|
deleteCategoryGroup: 'actual_category_groups_delete',
|
|
119
122
|
getIDByName: 'actual_get_id_by_name',
|
|
120
123
|
getServerVersion: 'actual_server_get_version',
|
|
124
|
+
exportBudget: 'actual_budgets_export',
|
|
125
|
+
importBudget: 'actual_budgets_import',
|
|
126
|
+
getPreferences: 'actual_preferences_get',
|
|
121
127
|
};
|
|
122
128
|
class ActualToolsManager {
|
|
123
129
|
tools = new Map();
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// src/auth/budget-acl-dynamic.ts
|
|
2
|
+
//
|
|
3
|
+
// #338: derive the budget ACL from the Actual server's own per-file access list
|
|
4
|
+
// instead of hand-maintained JSON, so revoking someone in Actual takes effect
|
|
5
|
+
// here without a config edit and a restart.
|
|
6
|
+
//
|
|
7
|
+
// WHY userId AND NOT userName, verified against a live multi-user server.
|
|
8
|
+
//
|
|
9
|
+
// `getBudgets()` returns, per remote file:
|
|
10
|
+
//
|
|
11
|
+
// "owner": "a6067f04-...",
|
|
12
|
+
// "usersWithAccess": [
|
|
13
|
+
// { "userId": "5311397e-...", "displayName": "Alice", "userName": "alice", "owner": false },
|
|
14
|
+
// { "userId": "a6067f04-...", "displayName": "", "userName": "", "owner": true }
|
|
15
|
+
// ]
|
|
16
|
+
//
|
|
17
|
+
// That second row is THIS SERVER'S OWN service account. `@actual-app/api`
|
|
18
|
+
// authenticates with a password, that password identity owns every file the API
|
|
19
|
+
// creates, and it has no userName because it is not an OpenID user. So a blank
|
|
20
|
+
// `userName` is present in every file's access list on every deployment.
|
|
21
|
+
//
|
|
22
|
+
// A name-based match therefore has a live escalation path: a principal whose
|
|
23
|
+
// configured claim is missing or empty would match the blank row, which carries
|
|
24
|
+
// `owner: true`, and be granted access to every file on the server. Matching
|
|
25
|
+
// UUIDs makes that unrepresentable, because no IdP subject equals the service
|
|
26
|
+
// account's UUID. AUTH_BUDGET_ACL_CLAIM still allows name matching for
|
|
27
|
+
// deployments that need it, and the empty-principal guard below is what keeps
|
|
28
|
+
// that option from reintroducing the hole.
|
|
29
|
+
//
|
|
30
|
+
// WHY THE RESOLUTION RUNS UNDER AN EXPLICIT WILDCARD CONTEXT.
|
|
31
|
+
//
|
|
32
|
+
// Resolving the ACL requires `adapter.getBudgets()`, and every adapter entry
|
|
33
|
+
// point runs `_enforceBudgetAcl`, which refuses when the request context has no
|
|
34
|
+
// allowedBudgets. Resolving the ACL would therefore be blocked by the ACL it is
|
|
35
|
+
// trying to compute. We break that circularity by running ONLY this one call
|
|
36
|
+
// inside `requestContext.run({ allowedBudgets: ['*'] }, ...)`.
|
|
37
|
+
//
|
|
38
|
+
// That is sound rather than a loophole: `getBudgets()` lists budget FILES
|
|
39
|
+
// (name, id, who may access them). It reads no budget CONTENTS: no accounts, no
|
|
40
|
+
// transactions, no balances. The wildcard is scoped to this single listing call
|
|
41
|
+
// and never leaks into the caller's context, because AsyncLocalStorage restores
|
|
42
|
+
// the previous store when the callback returns.
|
|
43
|
+
import config from '../config.js';
|
|
44
|
+
import adapter from '../lib/actual-adapter.js';
|
|
45
|
+
import { requestContext } from '../lib/requestContext.js';
|
|
46
|
+
import { createModuleLogger } from '../lib/loggerFactory.js';
|
|
47
|
+
const log = createModuleLogger('ACL');
|
|
48
|
+
/**
|
|
49
|
+
* Cache of principal to allowed sync IDs.
|
|
50
|
+
*
|
|
51
|
+
* Bounded by TTL rather than size: the key space is the set of authenticated
|
|
52
|
+
* principals, which is small and operator-controlled. A short TTL is what makes
|
|
53
|
+
* a revocation in Actual take effect here promptly; without it this feature
|
|
54
|
+
* would reintroduce exactly the staleness it exists to remove.
|
|
55
|
+
*/
|
|
56
|
+
const CACHE_TTL_MS = 60_000;
|
|
57
|
+
const _cache = new Map();
|
|
58
|
+
/** Clear the resolver cache (test helper, and used when config is re-read). */
|
|
59
|
+
export function _resetDynamicAclCache() {
|
|
60
|
+
_cache.clear();
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Extract the identity value to match on, from the claim named by
|
|
64
|
+
* AUTH_BUDGET_ACL_CLAIM. Returns null when the claim is absent or blank.
|
|
65
|
+
*
|
|
66
|
+
* Blank is deliberately null and not '': an empty string would compare equal to
|
|
67
|
+
* the service account's empty userName. See the header note.
|
|
68
|
+
*/
|
|
69
|
+
export function extractPrincipalValue(claims, subject, claimName) {
|
|
70
|
+
const raw = claimName === 'sub' ? (subject ?? claims['sub']) : claims[claimName];
|
|
71
|
+
if (typeof raw !== 'string')
|
|
72
|
+
return null;
|
|
73
|
+
const trimmed = raw.trim();
|
|
74
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Map a getBudgets() payload to the sync IDs the given principal may access.
|
|
78
|
+
*
|
|
79
|
+
* Pure, so the matching rules are unit-testable against the captured live
|
|
80
|
+
* fixture without a server. Exported for that reason.
|
|
81
|
+
*/
|
|
82
|
+
export function matchAllowedFiles(files, principalValue, claimName) {
|
|
83
|
+
if (!Array.isArray(files))
|
|
84
|
+
return [];
|
|
85
|
+
// An empty principal can never match. Belt and braces: extractPrincipalValue
|
|
86
|
+
// already returns null for blank, but this function is exported and must not
|
|
87
|
+
// depend on its callers for that guarantee.
|
|
88
|
+
if (!principalValue)
|
|
89
|
+
return [];
|
|
90
|
+
const matchesName = claimName !== 'sub';
|
|
91
|
+
const allowed = new Set();
|
|
92
|
+
for (const entry of files) {
|
|
93
|
+
if (!entry || typeof entry !== 'object')
|
|
94
|
+
continue;
|
|
95
|
+
// groupId, NOT cloudFileId. See the BudgetFileEntry note: groupId is the
|
|
96
|
+
// "Sync ID" that _enforceBudgetAcl compares against.
|
|
97
|
+
const syncId = entry.groupId;
|
|
98
|
+
const access = entry.usersWithAccess;
|
|
99
|
+
if (!syncId || !Array.isArray(access))
|
|
100
|
+
continue;
|
|
101
|
+
for (const u of access) {
|
|
102
|
+
if (!u || typeof u !== 'object')
|
|
103
|
+
continue;
|
|
104
|
+
// Read the whole list, not only `owner: true`. A file shared with a
|
|
105
|
+
// household member appears with owner: false, and that is exactly the
|
|
106
|
+
// case this feature exists to honour.
|
|
107
|
+
const candidate = matchesName ? u.userName : u.userId;
|
|
108
|
+
if (typeof candidate !== 'string')
|
|
109
|
+
continue;
|
|
110
|
+
const trimmed = candidate.trim();
|
|
111
|
+
if (trimmed.length === 0)
|
|
112
|
+
continue; // never match the blank service-account row
|
|
113
|
+
if (trimmed === principalValue) {
|
|
114
|
+
allowed.add(syncId);
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return [...allowed];
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Resolve the allowed sync IDs for a principal from the Actual server.
|
|
123
|
+
*
|
|
124
|
+
* FAILS CLOSED. Every error path returns [] (no access) rather than ['*'].
|
|
125
|
+
* An authorization source that opens up when the thing it queries is unreachable
|
|
126
|
+
* is worse than no authorization source at all.
|
|
127
|
+
*/
|
|
128
|
+
export async function resolveAllowedBudgetsFromActual(principalValue) {
|
|
129
|
+
if (!principalValue) {
|
|
130
|
+
log.warn('dynamic ACL: no usable principal value; denying', { claim: config.AUTH_BUDGET_ACL_CLAIM });
|
|
131
|
+
return [];
|
|
132
|
+
}
|
|
133
|
+
const cached = _cache.get(principalValue);
|
|
134
|
+
if (cached && Date.now() - cached.at < CACHE_TTL_MS)
|
|
135
|
+
return cached.allowed;
|
|
136
|
+
let files;
|
|
137
|
+
try {
|
|
138
|
+
// The scoped wildcard described in the header. Nothing outside this call
|
|
139
|
+
// observes it.
|
|
140
|
+
files = await requestContext.run({ allowedBudgets: ['*'] }, () => adapter.getBudgets());
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
log.error('dynamic ACL: could not read the budget list; denying access', err);
|
|
144
|
+
return [];
|
|
145
|
+
}
|
|
146
|
+
const allowed = matchAllowedFiles(files, principalValue, config.AUTH_BUDGET_ACL_CLAIM);
|
|
147
|
+
if (allowed.length === 0) {
|
|
148
|
+
log.warn('dynamic ACL: principal matched no budget file', {
|
|
149
|
+
claim: config.AUTH_BUDGET_ACL_CLAIM,
|
|
150
|
+
fileCount: Array.isArray(files) ? files.length : 0,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
_cache.set(principalValue, { at: Date.now(), allowed });
|
|
154
|
+
return allowed;
|
|
155
|
+
}
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
// to filter results by permitted sync IDs (forward-looking multi-budget support).
|
|
25
25
|
import config from '../config.js';
|
|
26
26
|
import logger from '../logger.js';
|
|
27
|
+
import { extractPrincipalValue, resolveAllowedBudgetsFromActual } from './budget-acl-dynamic.js';
|
|
27
28
|
// ---------------------------------------------------------------------------
|
|
28
29
|
// ACL map (parsed once from env, cached)
|
|
29
30
|
// ---------------------------------------------------------------------------
|
|
@@ -121,6 +122,12 @@ export function budgetAclMiddleware(req, res, next) {
|
|
|
121
122
|
next();
|
|
122
123
|
return;
|
|
123
124
|
}
|
|
125
|
+
// #338: dynamic source. Deliberately a separate branch so the static path
|
|
126
|
+
// below is unreachable-by-default and byte-for-byte what it was before.
|
|
127
|
+
if (config.AUTH_BUDGET_ACL_SOURCE === 'actual') {
|
|
128
|
+
void resolveDynamicAcl(req, res, next);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
124
131
|
const allowed = getAllowedBudgets(req);
|
|
125
132
|
if (allowed.includes('*') || allowed.length > 0) {
|
|
126
133
|
// Expose allowed budgets for use in tools
|
|
@@ -133,6 +140,39 @@ export function budgetAclMiddleware(req, res, next) {
|
|
|
133
140
|
logger.warn(`[ACL] Access denied for '${identity}': no permitted budgets in ACL`);
|
|
134
141
|
res.status(403).json({ error: 'Forbidden: no budget access configured for this user' });
|
|
135
142
|
}
|
|
143
|
+
/**
|
|
144
|
+
* #338: the async half of the middleware, for AUTH_BUDGET_ACL_SOURCE=actual.
|
|
145
|
+
*
|
|
146
|
+
* Split out rather than making budgetAclMiddleware itself async, because an
|
|
147
|
+
* async Express handler that rejects before Express 5's error plumbing sees it
|
|
148
|
+
* would hang the request. Here every path either calls next() or sends a
|
|
149
|
+
* response, and the catch is exhaustive, so a rejection cannot escape.
|
|
150
|
+
*
|
|
151
|
+
* The upstream read is cached per principal inside the resolver, so this is not
|
|
152
|
+
* an Actual API call per request.
|
|
153
|
+
*/
|
|
154
|
+
async function resolveDynamicAcl(req, res, next) {
|
|
155
|
+
const auth = req.auth;
|
|
156
|
+
const claims = (auth?.claims ?? {});
|
|
157
|
+
const subject = auth?.subject;
|
|
158
|
+
try {
|
|
159
|
+
const principalValue = extractPrincipalValue(claims, subject, config.AUTH_BUDGET_ACL_CLAIM);
|
|
160
|
+
const allowed = await resolveAllowedBudgetsFromActual(principalValue);
|
|
161
|
+
if (allowed.length > 0) {
|
|
162
|
+
req.allowedBudgets = allowed;
|
|
163
|
+
next();
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
logger.warn(`[ACL] Access denied for '${subject ?? 'unknown'}': no budget files grant this principal access`);
|
|
167
|
+
res.status(403).json({ error: 'Forbidden: no budget access configured for this user' });
|
|
168
|
+
}
|
|
169
|
+
catch (err) {
|
|
170
|
+
// Fail closed. A resolver fault must never fall through to the static path
|
|
171
|
+
// or to an unrestricted context.
|
|
172
|
+
logger.error('[ACL] Dynamic ACL resolution failed; denying access', err);
|
|
173
|
+
res.status(403).json({ error: 'Forbidden: budget access could not be determined' });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
136
176
|
/** Reset cached state (test helper). */
|
|
137
177
|
export function _resetAclForTests() {
|
|
138
178
|
_aclMap = null;
|
package/dist/src/config.js
CHANGED
|
@@ -34,6 +34,19 @@ export const configSchema = z.object({
|
|
|
34
34
|
// Off by default so a plaintext upstream + encryption password is refused.
|
|
35
35
|
ALLOW_INSECURE_UPSTREAM: z.string().optional().transform(val => val === 'true'),
|
|
36
36
|
MCP_BRIDGE_DATA_DIR: z.string().default('./actual-data'),
|
|
37
|
+
// #332: where actual_budgets_export writes budget zips. A budget export is
|
|
38
|
+
// megabytes of binary, so the tool returns a PATH plus metadata rather than
|
|
39
|
+
// base64 in the tool result: inlining it would flood the model's context and
|
|
40
|
+
// can exceed transport payload limits.
|
|
41
|
+
//
|
|
42
|
+
// Empty means "derive as <MCP_BRIDGE_DATA_DIR>/exports", resolved at call time
|
|
43
|
+
// rather than baked in here. A literal default like './actual-data/exports'
|
|
44
|
+
// would be resolved against the process CWD, which in the container is /app:
|
|
45
|
+
// that path is root-owned and the runtime drops to the unprivileged `app` user,
|
|
46
|
+
// so the very first export would fail with EACCES. The data dir is the one
|
|
47
|
+
// location guaranteed to be both writable and persisted (Dockerfile chowns it
|
|
48
|
+
// and it is a named volume), so exports belong under it.
|
|
49
|
+
ACTUAL_EXPORT_DIR: z.string().default(''),
|
|
37
50
|
MCP_BRIDGE_PORT: z.string().default('3600'),
|
|
38
51
|
MCP_TRANSPORT_MODE: z.enum(['--http']).default('--http'),
|
|
39
52
|
MCP_SSE_AUTHORIZATION: z.string().optional(),
|
|
@@ -82,6 +95,27 @@ export const configSchema = z.object({
|
|
|
82
95
|
// Example: {"alice@example.com":["budget-1"],"group:admin":["*"]}
|
|
83
96
|
// Leave unset to allow all authenticated users to access all budgets.
|
|
84
97
|
AUTH_BUDGET_ACL: z.string().optional(),
|
|
98
|
+
// #338: where the budget ACL comes from.
|
|
99
|
+
// 'static' (default) = AUTH_BUDGET_ACL above, exactly as before.
|
|
100
|
+
// 'actual' = derived from the Actual server's own per-file access
|
|
101
|
+
// list (`usersWithAccess` on getBudgets()).
|
|
102
|
+
// Opt-in on purpose. Two reasons: it moves the authorization source from local
|
|
103
|
+
// reviewable config to data the UPSTREAM server returns at runtime, and it only
|
|
104
|
+
// functions when that server runs in multi-user (OpenID) mode. Defaulting to
|
|
105
|
+
// 'static' means no existing deployment changes posture on upgrade.
|
|
106
|
+
AUTH_BUDGET_ACL_SOURCE: z.enum(['static', 'actual']).default('static'),
|
|
107
|
+
// #338: which OIDC claim is matched against Actual's user identity when
|
|
108
|
+
// AUTH_BUDGET_ACL_SOURCE=actual. Default 'sub', matched against `userId`.
|
|
109
|
+
//
|
|
110
|
+
// Do NOT casually change this to a name-like claim. Verified against a live
|
|
111
|
+
// multi-user server: the MCP server's own service account appears in EVERY
|
|
112
|
+
// file's usersWithAccess with `owner: true` and a BLANK `userName`. A
|
|
113
|
+
// name-based match whose claim resolves to empty would match that row and gain
|
|
114
|
+
// owner access to every file. Matching UUIDs makes that unrepresentable.
|
|
115
|
+
AUTH_BUDGET_ACL_CLAIM: z
|
|
116
|
+
.string()
|
|
117
|
+
.default('sub')
|
|
118
|
+
.refine((v) => /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/.test(v), 'AUTH_BUDGET_ACL_CLAIM must be a simple claim name'),
|
|
85
119
|
})
|
|
86
120
|
// When native TLS is enabled, both the cert and key paths must be provided.
|
|
87
121
|
// MCP_ENABLE_HTTPS is transformed to a boolean above, so this object-level
|
|
@@ -4,7 +4,7 @@ import api from '@actual-app/api';
|
|
|
4
4
|
// cannot expose its named exports via static import syntax. At runtime the default import
|
|
5
5
|
// IS module.exports, so all methods are accessible as properties.
|
|
6
6
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
7
|
-
const { addTransactions: rawAddTransactions, getAccounts: rawGetAccounts, importTransactions: rawImportTransactions, getTransactions: rawGetTransactions, getCategories: rawGetCategories, createCategory: rawCreateCategory, getPayees: rawGetPayees, getCommonPayees: rawGetCommonPayees, createPayee: rawCreatePayee, getBudgetMonths: rawGetBudgetMonths, getBudgetMonth: rawGetBudgetMonth, setBudgetAmount: rawSetBudgetAmount, createAccount: rawCreateAccount, updateAccount: rawUpdateAccount, getAccountBalance: rawGetAccountBalance, updateTransaction: rawUpdateTransaction, deleteTransaction: rawDeleteTransaction, updateCategory: rawUpdateCategory, deleteCategory: rawDeleteCategory, updatePayee: rawUpdatePayee, deletePayee: rawDeletePayee, deleteAccount: rawDeleteAccount, getRules: rawGetRules, createRule: rawCreateRule, updateRule: rawUpdateRule, deleteRule: rawDeleteRule, setBudgetCarryover: rawSetBudgetCarryover, closeAccount: rawCloseAccount, reopenAccount: rawReopenAccount, getCategoryGroups: rawGetCategoryGroups, createCategoryGroup: rawCreateCategoryGroup, updateCategoryGroup: rawUpdateCategoryGroup, deleteCategoryGroup: rawDeleteCategoryGroup, mergePayees: rawMergePayees, getPayeeRules: rawGetPayeeRules, batchBudgetUpdates: rawBatchBudgetUpdates, holdBudgetForNextMonth: rawHoldBudgetForNextMonth, resetBudgetHold: rawResetBudgetHold, runQuery: rawRunQuery, runBankSync: rawRunBankSync, getBudgets: rawGetBudgets, getIDByName: rawGetIDByName, getServerVersion: rawGetServerVersion, getSchedules: rawGetSchedules, createSchedule: rawCreateSchedule, updateSchedule: rawUpdateSchedule, deleteSchedule: rawDeleteSchedule, getTags: rawGetTags, createTag: rawCreateTag, updateTag: rawUpdateTag, deleteTag: rawDeleteTag, getNote: rawGetNote, updateNote: rawUpdateNote,
|
|
7
|
+
const { addTransactions: rawAddTransactions, getAccounts: rawGetAccounts, importTransactions: rawImportTransactions, getTransactions: rawGetTransactions, getCategories: rawGetCategories, createCategory: rawCreateCategory, getPayees: rawGetPayees, getCommonPayees: rawGetCommonPayees, createPayee: rawCreatePayee, getBudgetMonths: rawGetBudgetMonths, getBudgetMonth: rawGetBudgetMonth, setBudgetAmount: rawSetBudgetAmount, createAccount: rawCreateAccount, updateAccount: rawUpdateAccount, getAccountBalance: rawGetAccountBalance, updateTransaction: rawUpdateTransaction, deleteTransaction: rawDeleteTransaction, updateCategory: rawUpdateCategory, deleteCategory: rawDeleteCategory, updatePayee: rawUpdatePayee, deletePayee: rawDeletePayee, deleteAccount: rawDeleteAccount, getRules: rawGetRules, createRule: rawCreateRule, updateRule: rawUpdateRule, deleteRule: rawDeleteRule, setBudgetCarryover: rawSetBudgetCarryover, closeAccount: rawCloseAccount, reopenAccount: rawReopenAccount, getCategoryGroups: rawGetCategoryGroups, createCategoryGroup: rawCreateCategoryGroup, updateCategoryGroup: rawUpdateCategoryGroup, deleteCategoryGroup: rawDeleteCategoryGroup, mergePayees: rawMergePayees, getPayeeRules: rawGetPayeeRules, batchBudgetUpdates: rawBatchBudgetUpdates, holdBudgetForNextMonth: rawHoldBudgetForNextMonth, resetBudgetHold: rawResetBudgetHold, runQuery: rawRunQuery, runBankSync: rawRunBankSync, getBudgets: rawGetBudgets, getIDByName: rawGetIDByName, getServerVersion: rawGetServerVersion, getSchedules: rawGetSchedules, createSchedule: rawCreateSchedule, updateSchedule: rawUpdateSchedule, deleteSchedule: rawDeleteSchedule, getTags: rawGetTags, createTag: rawCreateTag, updateTag: rawUpdateTag, deleteTag: rawDeleteTag, getNote: rawGetNote, updateNote: rawUpdateNote, exportBudget: rawExportBudget, importBudget: rawImportBudget, getPreferences: rawGetPreferences,
|
|
8
8
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
9
9
|
} = api;
|
|
10
10
|
import { EventEmitter } from 'events';
|
|
@@ -1990,6 +1990,66 @@ export async function updateNote(id, note) {
|
|
|
1990
1990
|
await withConcurrency(() => retry(() => rawUpdateNote(id, note), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
|
|
1991
1991
|
});
|
|
1992
1992
|
}
|
|
1993
|
+
/**
|
|
1994
|
+
* Export the currently-loaded budget as a zip (#332).
|
|
1995
|
+
*
|
|
1996
|
+
* READ path (`withActualApi`), not the write queue: the upstream call produces a
|
|
1997
|
+
* snapshot and mutates nothing, so it is safe to retry and must not serialise
|
|
1998
|
+
* behind pending writes.
|
|
1999
|
+
*
|
|
2000
|
+
* Returns the raw bytes. Deliberately NOT base64 here: the encoding decision, the
|
|
2001
|
+
* size cap, and the write-to-disk policy belong to the tool layer, so a future
|
|
2002
|
+
* caller that wants to stream the buffer is not forced through a string.
|
|
2003
|
+
*/
|
|
2004
|
+
export async function exportBudget() {
|
|
2005
|
+
return withActualApi(async () => {
|
|
2006
|
+
observability.incrementToolCall('actual.budgets.export').catch(() => { });
|
|
2007
|
+
return await withConcurrency(() => retry(() => rawExportBudget(), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
|
|
2008
|
+
});
|
|
2009
|
+
}
|
|
2010
|
+
/**
|
|
2011
|
+
* Import a budget from an Actual `.zip` export or a YNAB4/YNAB5 export (#334).
|
|
2012
|
+
*
|
|
2013
|
+
* WRITE path, and NON-IDEMPOTENT: it creates a budget file and returns its new id,
|
|
2014
|
+
* so `retries: 0` is mandatory. A retry after a lost response would create a SECOND
|
|
2015
|
+
* budget rather than returning the first one's id. Guarded by
|
|
2016
|
+
* `tests/unit/adapter_nonidempotent_no_retry.test.js`.
|
|
2017
|
+
*
|
|
2018
|
+
* Side effect worth knowing at every call site: upstream `importBudget` LOADS the
|
|
2019
|
+
* imported budget, so the session's active budget changes to the new file. It is
|
|
2020
|
+
* therefore outside the `BUDGET_N_*` registry and outside whatever budget the ACL
|
|
2021
|
+
* resolved for this request. The write queue still enforces the ACL on entry, so a
|
|
2022
|
+
* caller cannot use this to escape a budget they were already denied, but the
|
|
2023
|
+
* resulting budget is un-ACL'd because it did not exist when the ACL was built.
|
|
2024
|
+
*/
|
|
2025
|
+
export async function importBudget(input, opts = {}) {
|
|
2026
|
+
observability.incrementToolCall('actual.budgets.import').catch(() => { });
|
|
2027
|
+
return queueWriteOperation(async () => {
|
|
2028
|
+
return await withConcurrency(() => retry(() => rawImportBudget(input, opts), {
|
|
2029
|
+
retries: 0,
|
|
2030
|
+
backoffMs: 200,
|
|
2031
|
+
isRetryable: isRetryableError,
|
|
2032
|
+
}));
|
|
2033
|
+
});
|
|
2034
|
+
}
|
|
2035
|
+
/**
|
|
2036
|
+
* Read the budget's synced preferences: number format, date format, currency,
|
|
2037
|
+
* first day of week, and similar display settings (#333).
|
|
2038
|
+
*
|
|
2039
|
+
* READ path. The shape is upstream's `SyncedPrefs`, which our tsconfig resolves to
|
|
2040
|
+
* the `@actual-app/core` stub (`any`), so there is no compile-time contract here.
|
|
2041
|
+
* The tool layer normalises it rather than trusting the shape.
|
|
2042
|
+
*/
|
|
2043
|
+
export async function getPreferences() {
|
|
2044
|
+
return withActualApi(async () => {
|
|
2045
|
+
observability.incrementToolCall('actual.preferences.get').catch(() => { });
|
|
2046
|
+
return await withConcurrency(() => retry(() => rawGetPreferences(), {
|
|
2047
|
+
retries: 2,
|
|
2048
|
+
backoffMs: 200,
|
|
2049
|
+
isRetryable: isRetryableError,
|
|
2050
|
+
}));
|
|
2051
|
+
});
|
|
2052
|
+
}
|
|
1993
2053
|
export default {
|
|
1994
2054
|
getAccounts,
|
|
1995
2055
|
getAccountsWithBalances,
|
|
@@ -2051,5 +2111,8 @@ export default {
|
|
|
2051
2111
|
deleteSchedule,
|
|
2052
2112
|
updateTransactionBatch,
|
|
2053
2113
|
withWriteSession,
|
|
2114
|
+
exportBudget,
|
|
2115
|
+
importBudget,
|
|
2116
|
+
getPreferences,
|
|
2054
2117
|
notifications,
|
|
2055
2118
|
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { basename, isAbsolute, join, resolve } from 'node:path';
|
|
5
|
+
import { createTool } from '../lib/toolFactory.js';
|
|
6
|
+
import adapter from '../lib/actual-adapter.js';
|
|
7
|
+
import config from '../config.js';
|
|
8
|
+
/**
|
|
9
|
+
* #332: expose `@actual-app/api`'s `exportBudget()`.
|
|
10
|
+
*
|
|
11
|
+
* WHY THIS WRITES TO DISK INSTEAD OF RETURNING THE ZIP.
|
|
12
|
+
*
|
|
13
|
+
* `exportBudget()` returns the whole budget as a zip: megabytes of binary for any
|
|
14
|
+
* real budget. Returning that inline would mean base64 in the JSON-RPC result,
|
|
15
|
+
* which (a) floods the model's context window with unreadable payload, and (b) can
|
|
16
|
+
* exceed transport limits (the HTTP body cap, and a stdio client's line buffer).
|
|
17
|
+
* So the tool writes the bytes to a configured directory and returns a PATH plus
|
|
18
|
+
* the metadata a caller actually needs to verify the export: byte size and sha256.
|
|
19
|
+
*
|
|
20
|
+
* The sha256 is what makes the export/import round trip assertable: a test can
|
|
21
|
+
* export, re-import, export again, and compare digests without ever moving the
|
|
22
|
+
* bytes through the transport.
|
|
23
|
+
*/
|
|
24
|
+
// Deliberately strict: a caller-supplied filename becomes a path on the server's
|
|
25
|
+
// filesystem, so it is restricted to a flat, boring charset. Traversal sequences,
|
|
26
|
+
// separators, and absolute paths are all rejected by this class rather than
|
|
27
|
+
// normalised away, because silently rewriting a caller's filename is worse than
|
|
28
|
+
// telling them it was invalid.
|
|
29
|
+
const SAFE_FILENAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
30
|
+
const InputSchema = z
|
|
31
|
+
.object({
|
|
32
|
+
filename: z
|
|
33
|
+
.string()
|
|
34
|
+
.min(1, 'filename must not be empty')
|
|
35
|
+
.max(120, 'filename must be at most 120 characters')
|
|
36
|
+
.refine((value) => SAFE_FILENAME.test(value), 'filename must start with a letter or digit and contain only letters, digits, dots, underscores, and hyphens (no path separators)')
|
|
37
|
+
.refine((value) => !value.includes('..'), 'filename must not contain ".."')
|
|
38
|
+
.optional()
|
|
39
|
+
.describe('Optional file name for the export, without any directory part. Defaults to a timestamped name. ' +
|
|
40
|
+
'A ".zip" suffix is appended if missing.'),
|
|
41
|
+
})
|
|
42
|
+
.strict();
|
|
43
|
+
/**
|
|
44
|
+
* Build the default export name. Uses a UTC timestamp flattened to a
|
|
45
|
+
* filesystem-safe form, e.g. `budget-export-2026-08-07T10-31-05Z.zip`.
|
|
46
|
+
*
|
|
47
|
+
* This is a FILE NAME, not a date field: the project rule that dates are
|
|
48
|
+
* `YYYY-MM-DD` strings applies to values sent to the Actual API, none of which
|
|
49
|
+
* this touches.
|
|
50
|
+
*/
|
|
51
|
+
function defaultExportName(now) {
|
|
52
|
+
const stamp = now.toISOString().replace(/\.\d{3}Z$/, 'Z').replace(/:/g, '-');
|
|
53
|
+
return `budget-export-${stamp}.zip`;
|
|
54
|
+
}
|
|
55
|
+
export default createTool({
|
|
56
|
+
name: 'actual_budgets_export',
|
|
57
|
+
description: 'Export the currently active budget as an Actual Budget ".zip" file, written to the server\'s ' +
|
|
58
|
+
'configured export directory (ACTUAL_EXPORT_DIR). Returns the file path, byte size, and sha256 ' +
|
|
59
|
+
'digest, NOT the file contents: a budget export is megabytes of binary and is never inlined into ' +
|
|
60
|
+
'the response. The resulting file can be restored with actual_budgets_import, or downloaded from ' +
|
|
61
|
+
'the server by whoever operates it. Use this before any bulk or destructive change as a backup.',
|
|
62
|
+
schema: InputSchema,
|
|
63
|
+
handler: async (input) => {
|
|
64
|
+
const data = await adapter.exportBudget();
|
|
65
|
+
// Guard the upstream contract rather than assuming it. `exportBudget` is a
|
|
66
|
+
// young method (added upstream in July 2026) and its own error path already
|
|
67
|
+
// throws on a missing payload, but a zero-length buffer would slip through and
|
|
68
|
+
// produce a silently useless "backup", which is the worst possible failure for
|
|
69
|
+
// a backup tool.
|
|
70
|
+
if (!data || data.length === 0) {
|
|
71
|
+
throw new Error('Actual Budget returned an empty export. The budget may not be fully loaded; ' +
|
|
72
|
+
'retry after confirming the budget is open, and check the server logs.');
|
|
73
|
+
}
|
|
74
|
+
const requested = input.filename ? basename(input.filename) : defaultExportName(new Date());
|
|
75
|
+
const filename = requested.toLowerCase().endsWith('.zip') ? requested : `${requested}.zip`;
|
|
76
|
+
// An unset ACTUAL_EXPORT_DIR derives from the data dir rather than the CWD.
|
|
77
|
+
// See the config comment: the CWD in the container is root-owned /app, and
|
|
78
|
+
// the runtime user is unprivileged, so a CWD-relative default cannot be
|
|
79
|
+
// created. MCP_BRIDGE_DATA_DIR is writable and persisted by construction.
|
|
80
|
+
const configured = config.ACTUAL_EXPORT_DIR.trim();
|
|
81
|
+
const target = configured || join(config.MCP_BRIDGE_DATA_DIR, 'exports');
|
|
82
|
+
const dir = isAbsolute(target) ? target : resolve(process.cwd(), target);
|
|
83
|
+
await mkdir(dir, { recursive: true });
|
|
84
|
+
const path = join(dir, filename);
|
|
85
|
+
await writeFile(path, data);
|
|
86
|
+
const sha256 = createHash('sha256').update(data).digest('hex');
|
|
87
|
+
return {
|
|
88
|
+
success: true,
|
|
89
|
+
path,
|
|
90
|
+
filename,
|
|
91
|
+
directory: dir,
|
|
92
|
+
bytes: data.length,
|
|
93
|
+
sha256,
|
|
94
|
+
message: `Exported the active budget to ${path} (${data.length} bytes, sha256 ${sha256}). ` +
|
|
95
|
+
'Restore it with actual_budgets_import using this path.',
|
|
96
|
+
};
|
|
97
|
+
},
|
|
98
|
+
examples: [
|
|
99
|
+
{ description: 'Export the active budget with an auto-generated timestamped name', input: {} },
|
|
100
|
+
{ description: 'Export to a specific file name', input: { filename: 'before-cleanup.zip' } },
|
|
101
|
+
],
|
|
102
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { createTool } from '../lib/toolFactory.js';
|
|
3
|
+
import adapter from '../lib/actual-adapter.js';
|
|
4
|
+
/**
|
|
5
|
+
* #334: expose `@actual-app/api`'s `importBudget()`.
|
|
6
|
+
*
|
|
7
|
+
* TWO SIDE EFFECTS EVERY CALLER MUST KNOW, both verified against `importActual` in
|
|
8
|
+
* the installed `@actual-app/api` and against a live server in the Docker E2E run:
|
|
9
|
+
*
|
|
10
|
+
* 1. IT CAN OVERWRITE AN EXISTING BUDGET. The budget id is taken from the ARCHIVE,
|
|
11
|
+
* not freshly generated. `importActual` closes the current budget, restores the
|
|
12
|
+
* archive under the id the archive carries, and loads it. So re-importing an
|
|
13
|
+
* export of budget X restores ONTO budget X, replacing that budget's local data.
|
|
14
|
+
* This is what makes export/import a genuine backup-and-restore pair, and it is
|
|
15
|
+
* also why an import against a budget with real data is destructive. It is only
|
|
16
|
+
* "a new budget" when the archive's id is not already present.
|
|
17
|
+
* 2. IT CHANGES THE ACTIVE BUDGET. The imported budget is loaded, so the session
|
|
18
|
+
* switches to it. If the archive's id is not in the `BUDGET_N_*` registry, that
|
|
19
|
+
* budget is also outside whatever set the ACL resolved for this request, and
|
|
20
|
+
* `actual_budgets_switch` cannot reach it by name.
|
|
21
|
+
*
|
|
22
|
+
* The write queue enforces the budget ACL when this is enqueued, so this cannot be
|
|
23
|
+
* used to reach a budget the caller was already denied. It does mean a budget
|
|
24
|
+
* created by the import is un-ACL'd, because it did not exist when the ACL was built.
|
|
25
|
+
*
|
|
26
|
+
* Accepts either a path on the server's filesystem or the file contents inline as
|
|
27
|
+
* base64. Exactly one, enforced by the schema.
|
|
28
|
+
*/
|
|
29
|
+
// A generous ceiling that still turns a runaway payload into a clear validation
|
|
30
|
+
// error instead of an out-of-memory kill. ~64 MB of base64 is ~48 MB of zip, far
|
|
31
|
+
// above any realistic Actual export. Consistent with the bounded-input policy the
|
|
32
|
+
// rest of the tool surface adopted in #156.
|
|
33
|
+
const MAX_BASE64_CHARS = 64 * 1024 * 1024;
|
|
34
|
+
const BASE64 = /^[A-Za-z0-9+/]+={0,2}$/;
|
|
35
|
+
const InputSchema = z
|
|
36
|
+
.object({
|
|
37
|
+
path: z
|
|
38
|
+
.string()
|
|
39
|
+
.min(1, 'path must not be empty')
|
|
40
|
+
.max(4096, 'path must be at most 4096 characters')
|
|
41
|
+
.optional()
|
|
42
|
+
.describe('Absolute or relative path to the export file ON THE SERVER\'S filesystem. ' +
|
|
43
|
+
'Use the "path" returned by actual_budgets_export to restore an earlier export.'),
|
|
44
|
+
base64: z
|
|
45
|
+
.string()
|
|
46
|
+
.min(1, 'base64 must not be empty')
|
|
47
|
+
.max(MAX_BASE64_CHARS, `base64 must be at most ${MAX_BASE64_CHARS} characters`)
|
|
48
|
+
.refine((value) => BASE64.test(value), 'base64 must be valid base64 (A-Z, a-z, 0-9, +, /, with optional = padding)')
|
|
49
|
+
.optional()
|
|
50
|
+
.describe('The export file contents, base64-encoded, for callers that cannot place a file on the server.'),
|
|
51
|
+
type: z
|
|
52
|
+
.enum(['actual', 'ynab4', 'ynab5'])
|
|
53
|
+
.default('actual')
|
|
54
|
+
.describe('Format of the file being imported. "actual" is an Actual Budget .zip export ' +
|
|
55
|
+
'(what actual_budgets_export produces). "ynab4" and "ynab5" import from YNAB.'),
|
|
56
|
+
filename: z
|
|
57
|
+
.string()
|
|
58
|
+
.min(1, 'filename must not be empty')
|
|
59
|
+
.max(120, 'filename must be at most 120 characters')
|
|
60
|
+
.optional()
|
|
61
|
+
.describe('Original file name, used only with "base64" so the importer can infer the budget name.'),
|
|
62
|
+
})
|
|
63
|
+
.strict()
|
|
64
|
+
.refine((value) => Boolean(value.path) !== Boolean(value.base64), 'Provide exactly one of "path" or "base64", not both and not neither.');
|
|
65
|
+
export default createTool({
|
|
66
|
+
name: 'actual_budgets_import',
|
|
67
|
+
description: 'Restore or import a budget from an Actual Budget ".zip" export or a YNAB4/YNAB5 export. ' +
|
|
68
|
+
'DESTRUCTIVE: the budget id comes from the archive, so re-importing an export REPLACES that ' +
|
|
69
|
+
'budget\'s data rather than creating a copy. Never call this to "have a look at" a file. Confirm ' +
|
|
70
|
+
'with the user before importing over a budget that holds real data. ' +
|
|
71
|
+
'It also LOADS the imported budget, so the active budget for this session changes; if the archive\'s ' +
|
|
72
|
+
'budget is not in the configured list, call actual_budgets_list_available and actual_budgets_switch ' +
|
|
73
|
+
'afterwards to go back. Provide exactly one of "path" (a file on the server) or "base64" (inline contents).',
|
|
74
|
+
schema: InputSchema,
|
|
75
|
+
handler: async (input) => {
|
|
76
|
+
const source = input.path
|
|
77
|
+
? input.path
|
|
78
|
+
: new Uint8Array(Buffer.from(input.base64, 'base64'));
|
|
79
|
+
// Reject an empty decode explicitly. Buffer.from(..., 'base64') is lenient and
|
|
80
|
+
// returns an empty buffer for input it cannot decode, which would otherwise
|
|
81
|
+
// reach upstream as a zero-byte zip and fail with a much less useful message.
|
|
82
|
+
if (typeof source !== 'string' && source.length === 0) {
|
|
83
|
+
throw new Error('The "base64" payload decoded to zero bytes. Check that it is a complete, valid base64 string.');
|
|
84
|
+
}
|
|
85
|
+
const result = await adapter.importBudget(source, {
|
|
86
|
+
type: input.type,
|
|
87
|
+
...(input.filename ? { filename: input.filename } : {}),
|
|
88
|
+
});
|
|
89
|
+
return {
|
|
90
|
+
success: true,
|
|
91
|
+
budgetId: result.id,
|
|
92
|
+
type: input.type,
|
|
93
|
+
source: input.path ? 'path' : 'base64',
|
|
94
|
+
message: `Imported the budget as id ${result.id}. This budget is now LOADED and active for this session, ` +
|
|
95
|
+
'and it is not in the server\'s configured budget list. Use actual_budgets_list_available and ' +
|
|
96
|
+
'actual_budgets_switch to return to a configured budget.',
|
|
97
|
+
};
|
|
98
|
+
},
|
|
99
|
+
examples: [
|
|
100
|
+
{
|
|
101
|
+
description: 'Restore an export previously produced by actual_budgets_export',
|
|
102
|
+
input: { path: './actual-data/exports/before-cleanup.zip', type: 'actual' },
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
description: 'Import a YNAB5 export sent inline',
|
|
106
|
+
input: { base64: 'UEsDBBQAAAAIA...', type: 'ynab5', filename: 'my-ynab-budget.zip' },
|
|
107
|
+
},
|
|
108
|
+
],
|
|
109
|
+
});
|
package/dist/src/tools/index.js
CHANGED
|
@@ -18,6 +18,8 @@ export { default as budgets_holdForNextMonth } from './budgets_holdForNextMonth.
|
|
|
18
18
|
export { default as budgets_resetHold } from './budgets_resetHold.js';
|
|
19
19
|
export { default as budgets_setAmount } from './budgets_setAmount.js';
|
|
20
20
|
export { default as budgets_setCarryover } from './budgets_setCarryover.js';
|
|
21
|
+
export { default as budgets_export } from './budgets_export.js';
|
|
22
|
+
export { default as budgets_import } from './budgets_import.js';
|
|
21
23
|
export { default as budgets_transfer } from './budgets_transfer.js';
|
|
22
24
|
export { default as categories_create } from './categories_create.js';
|
|
23
25
|
export { default as categories_delete } from './categories_delete.js';
|
|
@@ -70,3 +72,4 @@ export { default as tags_update } from './tags_update.js';
|
|
|
70
72
|
export { default as tags_delete } from './tags_delete.js';
|
|
71
73
|
export { default as notes_get } from './notes_get.js';
|
|
72
74
|
export { default as notes_update } from './notes_update.js';
|
|
75
|
+
export { default as preferences_get } from './preferences_get.js';
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { createTool } from '../lib/toolFactory.js';
|
|
3
|
+
import adapter from '../lib/actual-adapter.js';
|
|
4
|
+
/**
|
|
5
|
+
* #333: expose `@actual-app/api`'s `getPreferences()`.
|
|
6
|
+
*
|
|
7
|
+
* These are the budget's SYNCED preferences (number format, date format,
|
|
8
|
+
* currency, first day of week), which is exactly the context an assistant needs
|
|
9
|
+
* to format amounts and dates the way the user's own budget does. Amounts stay
|
|
10
|
+
* integer cents everywhere in this server; this tool tells a caller how the user
|
|
11
|
+
* expects those cents to be RENDERED, it does not change any unit.
|
|
12
|
+
*
|
|
13
|
+
* Read-only. The upstream return type is `SyncedPrefs`, which our tsconfig maps
|
|
14
|
+
* to the `@actual-app/core` stub (`any`), so there is no compile-time contract.
|
|
15
|
+
* We therefore normalise defensively rather than trusting the shape.
|
|
16
|
+
*/
|
|
17
|
+
export default createTool({
|
|
18
|
+
name: 'actual_preferences_get',
|
|
19
|
+
description: 'Read the budget\'s synced display preferences: number format, date format, currency, ' +
|
|
20
|
+
'first day of the week, and similar settings. Use this to format amounts and dates the ' +
|
|
21
|
+
'way this budget is configured to show them. Note these are DISPLAY preferences only: ' +
|
|
22
|
+
'all amounts in this API remain integer cents (5000 = $50.00) regardless of what this returns.',
|
|
23
|
+
schema: z.object({}),
|
|
24
|
+
handler: async () => {
|
|
25
|
+
const prefs = await adapter.getPreferences();
|
|
26
|
+
// Upstream returns a plain synced-prefs object, but it is `any` to us and has
|
|
27
|
+
// been observed to come back null when a budget has never had a preference
|
|
28
|
+
// written. Normalise to an object so a caller can always read `.preferences`
|
|
29
|
+
// without a null check, and report emptiness explicitly instead of silently.
|
|
30
|
+
const preferences = prefs && typeof prefs === 'object' && !Array.isArray(prefs) ? prefs : {};
|
|
31
|
+
const keys = Object.keys(preferences);
|
|
32
|
+
return {
|
|
33
|
+
preferences,
|
|
34
|
+
keys,
|
|
35
|
+
count: keys.length,
|
|
36
|
+
message: keys.length === 0
|
|
37
|
+
? 'This budget has no synced preferences set; Actual Budget is using its defaults.'
|
|
38
|
+
: `Read ${keys.length} synced preference(s) for the active budget.`,
|
|
39
|
+
};
|
|
40
|
+
},
|
|
41
|
+
examples: [{ description: 'Read the active budget display preferences', input: {} }],
|
|
42
|
+
});
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "actual-mcp-server",
|
|
3
3
|
"displayName": "Actual MCP Server",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.11.0",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=22.0.0",
|
|
7
7
|
"npm": ">=10.0.0"
|
|
8
8
|
},
|
|
9
|
-
"description": "MCP server with
|
|
9
|
+
"description": "MCP server with 74 tools for AI-driven financial management with Actual Budget. HTTP and stdio transports for LibreChat, Claude Desktop, Cursor, VS Code, Gemini CLI",
|
|
10
10
|
"homepage": "https://github.com/agigante80/actual-mcp-server#readme",
|
|
11
11
|
"repository": {
|
|
12
12
|
"type": "git",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"verify-tools": "npm run build && node scripts/verify-tools.js",
|
|
31
31
|
"check:coverage": "node scripts/list-actual-api-methods.mjs",
|
|
32
32
|
"direct-sync": "node scripts/direct-sync/bank-sync-direct.mjs",
|
|
33
|
-
"test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/generated_tools.smoke.test.js && node tests/unit/schema_validation.test.js && node tests/unit/schema_json_openai_compat.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_data_dir.test.js && node tests/unit/config_drift.test.js && node tests/unit/port_alignment.test.js && node tests/unit/dockerfile_data_dir_alignment.test.js && node tests/unit/unraid_template_alignment.test.js && node tests/unit/entrypoint_invariants.test.js && node tests/unit/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/knip_config.test.js && node tests/unit/readme_stats_sync.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/workflow_release_guards.test.js && node tests/unit/report_train_failure.test.js && node tests/unit/unit_chain_membership.test.js && node tests/unit/api_surface_drift.test.js && node tests/unit/api_coverage_baseline.test.js && node tests/unit/train_preflight.test.js && node tests/unit/verify_published_artifacts.test.js && node tests/unit/report_train_stale.test.js && node tests/unit/stdio_framing_check.test.js && node tests/unit/server_info.test.js && node tests/unit/transfers_create.test.js && node tests/unit/bot_target_branch.test.js && node tests/unit/auth_posture.test.js && node tests/unit/auth_posture_wiring.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/httpServer_session_not_found.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/retry_classifier.test.js && node tests/unit/adapter_module_surface.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/payees_delete.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_oidc_jwks_discovery.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js && node tests/unit/tags_list.test.js && node tests/unit/tags_create.test.js && node tests/unit/tags_update.test.js && node tests/unit/tags_delete.test.js && node tests/unit/notes_get.test.js && node tests/unit/notes_update.test.js && node tests/unit/payees_common_list.test.js && node tests/unit/transactions_update_guard.test.js && node tests/unit/transactions_update_splits.test.js && node tests/unit/entities_search.test.js && node tests/unit/transactions_import_schema.test.js && node tests/unit/tool_schema_shape.test.js && node tests/unit/zod_error_format.test.js && node tests/unit/tool_count_sync.test.js && node tests/unit/stdio_transport.test.js && node tests/unit/logger_structured.test.js && node tests/unit/logger_redaction.test.js && node tests/unit/logger_correlation.test.js && node tests/unit/check_coverage.test.js && node tests/unit/adapter_op_timeout.test.js && node tests/unit/adapter_write_queue_wakeup.test.js && node tests/unit/ci_workflow_guards.test.js && node tests/unit/node_version_guard.test.js && node tests/unit/node_version_drift.test.js && node tests/unit/observability_smoke.test.js && node tests/unit/residue_pattern.test.js && node tests/unit/dual_transport_gate.test.js && node tests/unit/manual_assertions_use_helper.test.js && node tests/unit/server_version_guard.test.js && node tests/unit/payee_rules_reference_filter.test.js && node tests/unit/oidc_metadata_discovery.test.js && node tests/unit/httpServer_oauth_metadata_route.test.js && node tests/unit/httpServer_mcp_info_route.test.js && node tests/unit/residue_sweep_balance_account.test.js && node tests/unit/tool_factory.test.js && node tests/unit/budget_registry_parse.test.js",
|
|
33
|
+
"test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/generated_tools.smoke.test.js && node tests/unit/schema_validation.test.js && node tests/unit/schema_json_openai_compat.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_data_dir.test.js && node tests/unit/config_drift.test.js && node tests/unit/port_alignment.test.js && node tests/unit/dockerfile_data_dir_alignment.test.js && node tests/unit/unraid_template_alignment.test.js && node tests/unit/entrypoint_invariants.test.js && node tests/unit/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/knip_config.test.js && node tests/unit/readme_stats_sync.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/workflow_release_guards.test.js && node tests/unit/report_train_failure.test.js && node tests/unit/unit_chain_membership.test.js && node tests/unit/api_surface_drift.test.js && node tests/unit/api_coverage_baseline.test.js && node tests/unit/train_preflight.test.js && node tests/unit/verify_published_artifacts.test.js && node tests/unit/report_train_stale.test.js && node tests/unit/stdio_framing_check.test.js && node tests/unit/server_info.test.js && node tests/unit/transfers_create.test.js && node tests/unit/bot_target_branch.test.js && node tests/unit/auth_posture.test.js && node tests/unit/auth_posture_wiring.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/httpServer_session_not_found.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/retry_classifier.test.js && node tests/unit/adapter_module_surface.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/payees_delete.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_oidc_jwks_discovery.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js && node tests/unit/tags_list.test.js && node tests/unit/tags_create.test.js && node tests/unit/tags_update.test.js && node tests/unit/tags_delete.test.js && node tests/unit/notes_get.test.js && node tests/unit/notes_update.test.js && node tests/unit/payees_common_list.test.js && node tests/unit/transactions_update_guard.test.js && node tests/unit/transactions_update_splits.test.js && node tests/unit/entities_search.test.js && node tests/unit/transactions_import_schema.test.js && node tests/unit/tool_schema_shape.test.js && node tests/unit/zod_error_format.test.js && node tests/unit/tool_count_sync.test.js && node tests/unit/stdio_transport.test.js && node tests/unit/logger_structured.test.js && node tests/unit/logger_redaction.test.js && node tests/unit/logger_correlation.test.js && node tests/unit/check_coverage.test.js && node tests/unit/adapter_op_timeout.test.js && node tests/unit/adapter_write_queue_wakeup.test.js && node tests/unit/ci_workflow_guards.test.js && node tests/unit/node_version_guard.test.js && node tests/unit/node_version_drift.test.js && node tests/unit/observability_smoke.test.js && node tests/unit/residue_pattern.test.js && node tests/unit/dual_transport_gate.test.js && node tests/unit/manual_assertions_use_helper.test.js && node tests/unit/server_version_guard.test.js && node tests/unit/payee_rules_reference_filter.test.js && node tests/unit/oidc_metadata_discovery.test.js && node tests/unit/httpServer_oauth_metadata_route.test.js && node tests/unit/httpServer_mcp_info_route.test.js && node tests/unit/residue_sweep_balance_account.test.js && node tests/unit/tool_factory.test.js && node tests/unit/budget_registry_parse.test.js && node tests/unit/preferences_get.test.js && node tests/unit/budgets_export.test.js && node tests/unit/budgets_import.test.js && node tests/unit/budget_acl_dynamic.test.js && node tests/unit/budget_acl_middleware.test.js",
|
|
34
34
|
"test:adapter": "npm run build && node dist/src/tests_adapter_runner.js",
|
|
35
35
|
"test:e2e": "npx playwright test",
|
|
36
36
|
"test:e2e:docker": "./tests/e2e/run-docker-e2e.sh",
|
|
@@ -71,7 +71,7 @@
|
|
|
71
71
|
"dotenv": "^17.4.2",
|
|
72
72
|
"express": "^5.2.1",
|
|
73
73
|
"fuse.js": "^7.4.2",
|
|
74
|
-
"jose": "^6.2.
|
|
74
|
+
"jose": "^6.2.8",
|
|
75
75
|
"mcp-auth": "^0.2.0",
|
|
76
76
|
"winston": "^3.18.3",
|
|
77
77
|
"winston-daily-rotate-file": "^5.0.0",
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
"@playwright/test": "^1.60.0",
|
|
85
85
|
"@types/express": "^5.0.3",
|
|
86
86
|
"@types/node": "^26.1.0",
|
|
87
|
-
"knip": "^6.
|
|
87
|
+
"knip": "^6.32.0",
|
|
88
88
|
"node-fetch": "^3.3.2",
|
|
89
89
|
"tsconfig-paths": "^4.2.0",
|
|
90
90
|
"typescript": "^7.0.2"
|