actual-mcp-server 0.22.28 → 0.22.31
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 +14 -13
- package/dist/src/actualToolsManager.js +2 -0
- package/dist/src/lib/actual-adapter.js +70 -2
- package/dist/src/lib/schemas/common.js +1 -1
- package/dist/src/lib/schemas/recur.js +36 -3
- package/dist/src/lib/schemas/schedule-amount.js +41 -0
- package/dist/src/lib/tool-annotations.js +2 -0
- package/dist/src/lib/toolsets.js +2 -0
- package/dist/src/tools/index.js +1 -0
- package/dist/src/tools/schedules_create.js +11 -3
- package/dist/src/tools/schedules_update.js +13 -4
- package/dist/src/tools/transactions_merge.js +26 -0
- package/package.json +3 -3
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) │ │ (84 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) │ │ (84 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
|
+
- **84 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`. Works on both transports: HTTP keys the active budget to the MCP session, and stdio (Claude Desktop, Claude Code, Cursor) gets a synthetic per-process session so a switch is scoped to that process rather than shared globally (#348).
|
|
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 on both transports.** HTTP connection pooling (up to 15 concurrent sessions), and a long-lived stdio process that logs in ONCE and reuses that connection for every tool call instead of re-authenticating per call, so Claude Desktop and Claude Code stay fast and a burst of calls no longer risks a per-call login storm against the upstream limiter. 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 84 tools tested end-to-end. Any MCP-compatible client should work.
|
|
49
49
|
|
|
50
50
|
---
|
|
51
51
|
|
|
@@ -182,7 +182,7 @@ Add to `claude_desktop_config.json` (see [docs/guides/MCP_CLIENTS_SETUP.md](docs
|
|
|
182
182
|
}
|
|
183
183
|
```
|
|
184
184
|
|
|
185
|
-
> **No token needed.** stdio runs as a local process owned by your user. The transport itself is the security boundary. All
|
|
185
|
+
> **No token needed.** stdio runs as a local process owned by your user. The transport itself is the security boundary. All 84 tools are available.
|
|
186
186
|
>
|
|
187
187
|
> **`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.
|
|
188
188
|
|
|
@@ -308,7 +308,7 @@ For Claude Desktop (stdio), restart Claude after upgrading.
|
|
|
308
308
|
|
|
309
309
|
## Available Tools
|
|
310
310
|
|
|
311
|
-
**
|
|
311
|
+
**84 tools** across all categories. All tools use the `actual_<category>_<action>` naming convention.
|
|
312
312
|
|
|
313
313
|
### Accounts (12)
|
|
314
314
|
|
|
@@ -327,9 +327,9 @@ For Claude Desktop (stdio), restart Claude after upgrading.
|
|
|
327
327
|
| `actual_account_groups_update` | Rename an account group or change its sidebar position. A group that does not exist is refused, writing nothing |
|
|
328
328
|
| `actual_account_groups_delete` | Delete an account group. Its accounts are NOT deleted, they become ungrouped |
|
|
329
329
|
|
|
330
|
-
### Transactions (
|
|
330
|
+
### Transactions (17)
|
|
331
331
|
|
|
332
|
-
**Standard (
|
|
332
|
+
**Standard (7)**
|
|
333
333
|
|
|
334
334
|
| Tool | Description |
|
|
335
335
|
|------|-------------|
|
|
@@ -339,6 +339,7 @@ For Claude Desktop (stdio), restart Claude after upgrading.
|
|
|
339
339
|
| `actual_transactions_import` | Import and reconcile transactions |
|
|
340
340
|
| `actual_transactions_update` | Update a transaction, split a plain one in place, or edit the children of an existing split via `subtransactions` |
|
|
341
341
|
| `actual_transactions_delete` | Delete a transaction |
|
|
342
|
+
| `actual_transactions_merge` | Merge two same-account, same-amount transactions (e.g. a bank-imported duplicate and a manual entry). Upstream keeps one (the imported one first) and DELETES the other, filling only the kept row's empty payee, category, notes and schedule and its false cleared and reconciled flags; irreversible |
|
|
342
343
|
|
|
343
344
|
> **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` with `subtransactions` edits the children of a transaction that is already a split, or splits a plain transaction IN PLACE: it keeps its id, `imported_id` and cleared state, so a bank-synced transaction can be split without breaking import matching. Its own category is cleared, and a child that names no category inherits it. A split child cannot itself be split. 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.
|
|
344
345
|
|
|
@@ -556,7 +557,7 @@ All configuration is via environment variables. Copy `.env.example` to `.env` to
|
|
|
556
557
|
|
|
557
558
|
### Reducing the tool surface
|
|
558
559
|
|
|
559
|
-
All
|
|
560
|
+
All 84 tools are published by default. Chat clients with a small tool budget (Gemini, LibreChat) do better with fewer, so the surface can be narrowed at startup. A tool that is not published is also refused if a client calls it by name, and the refusal names the setting responsible.
|
|
560
561
|
|
|
561
562
|
| Toolset | Tools | Contents |
|
|
562
563
|
|---------|-------|----------|
|
|
@@ -570,7 +571,7 @@ All 83 tools are published by default. Chat clients with a small tool budget (Ge
|
|
|
570
571
|
| `query` | 1 | `actual_query_run` (SELECT only) |
|
|
571
572
|
| `admin` | 6 | Budget switching, import, export, bank sync and session management |
|
|
572
573
|
|
|
573
|
-
Each tool is in exactly one group. The `chat` preset cuts across them and publishes 12 tools: `actual_get_context`, `actual_query_run`, `actual_transactions_create`, `actual_transactions_update`, `actual_transactions_delete`, `actual_transactions_update_batch`, `actual_rules_create`, `actual_rules_create_batch`, `actual_budget_updates_batch`, `actual_budgets_transfer`, `actual_budgets_setAmount` and `actual_budgets_getMonth`. Measured on the `tools/list` payload, all
|
|
574
|
+
Each tool is in exactly one group. The `chat` preset cuts across them and publishes 12 tools: `actual_get_context`, `actual_query_run`, `actual_transactions_create`, `actual_transactions_update`, `actual_transactions_delete`, `actual_transactions_update_batch`, `actual_rules_create`, `actual_rules_create_batch`, `actual_budget_updates_batch`, `actual_budgets_transfer`, `actual_budgets_setAmount` and `actual_budgets_getMonth`. Measured on the `tools/list` payload, all 84 tools are 108,164 bytes and the `chat` preset is 25,949 bytes (about 6.5k tokens saved, a 76% cut).
|
|
574
575
|
|
|
575
576
|
`MCP_READ_ONLY=true` additionally hides and refuses every write-capable tool (47 of the 83, including `actual_bank_sync`, `actual_budgets_export`, `actual_budgets_switch` and `actual_session_close`), leaving 36. `actual_session_list` stays available under read-only. See [Security and Privacy](docs/SECURITY_AND_PRIVACY.md#tool-surface-mcp_toolsets-and-mcp_read_only).
|
|
576
577
|
|
|
@@ -648,7 +649,7 @@ stdio is the simplest way to connect Claude Desktop directly to Actual Budget. T
|
|
|
648
649
|
- No auth token. Process ownership is the security boundary.
|
|
649
650
|
- All logs go to stderr so they never corrupt the JSON-RPC framing on stdout
|
|
650
651
|
- The process exits when stdin closes (Claude Desktop shutting down)
|
|
651
|
-
- All
|
|
652
|
+
- All 84 tools are available, identical to HTTP mode
|
|
652
653
|
|
|
653
654
|
**Start manually to verify:**
|
|
654
655
|
|
|
@@ -751,7 +752,7 @@ See [AI Client Setup, OIDC](docs/guides/AI_CLIENT_SETUP.md#oidc-authentication-m
|
|
|
751
752
|
| Command | What It Tests | Requires Live Server |
|
|
752
753
|
|---------|---------------|---------------------|
|
|
753
754
|
| `npm run build` | TypeScript compilation | No |
|
|
754
|
-
| `npm run test:unit-js` |
|
|
755
|
+
| `npm run test:unit-js` | 84-tool smoke, schema validation, auth ACL | No |
|
|
755
756
|
| `npm run test:adapter` | Adapter, retry logic, concurrency | No |
|
|
756
757
|
| `npm run test:e2e` | MCP protocol compliance (Playwright) | No |
|
|
757
758
|
| `npm run test:e2e:docker:full` | Full stack integration | Yes (Docker) |
|
|
@@ -835,7 +836,7 @@ The software is provided **as-is**, without warranty of any kind. The author acc
|
|
|
835
836
|
|
|
836
837
|
---
|
|
837
838
|
|
|
838
|
-
**Version:** 0.22.
|
|
839
|
+
**Version:** 0.22.31 | **Tool Count:** 84 (verified LibreChat-compatible)
|
|
839
840
|
|
|
840
841
|
## Sponsor
|
|
841
842
|
|
|
@@ -62,6 +62,7 @@ const IMPLEMENTED_TOOLS = [
|
|
|
62
62
|
'actual_transactions_aggregate',
|
|
63
63
|
'actual_transactions_create',
|
|
64
64
|
'actual_transactions_delete',
|
|
65
|
+
'actual_transactions_merge',
|
|
65
66
|
'actual_transactions_filter',
|
|
66
67
|
'actual_transactions_get',
|
|
67
68
|
'actual_transactions_import',
|
|
@@ -105,6 +106,7 @@ const API_TOOL_MAP = {
|
|
|
105
106
|
importTransactions: 'actual_transactions_import',
|
|
106
107
|
updateTransaction: 'actual_transactions_update',
|
|
107
108
|
deleteTransaction: 'actual_transactions_delete',
|
|
109
|
+
mergeTransactions: 'actual_transactions_merge',
|
|
108
110
|
getBudgetMonths: 'actual_budgets_getMonths',
|
|
109
111
|
getBudgetMonth: 'actual_budgets_getMonth',
|
|
110
112
|
setBudgetAmount: 'actual_budgets_setAmount',
|
|
@@ -5,7 +5,7 @@ import api from '@actual-app/api';
|
|
|
5
5
|
// cannot expose its named exports via static import syntax. At runtime the default import
|
|
6
6
|
// IS module.exports, so all methods are accessible as properties.
|
|
7
7
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
8
|
-
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, 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, getAccountGroups: rawGetAccountGroups, createAccountGroup: rawCreateAccountGroup, updateAccountGroup: rawUpdateAccountGroup, deleteAccountGroup: rawDeleteAccountGroup,
|
|
8
|
+
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, mergeTransactions: rawMergeTransactions, getPayeeRules: rawGetPayeeRules, 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, getAccountGroups: rawGetAccountGroups, createAccountGroup: rawCreateAccountGroup, updateAccountGroup: rawUpdateAccountGroup, deleteAccountGroup: rawDeleteAccountGroup,
|
|
9
9
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
10
10
|
} = api;
|
|
11
11
|
import { EventEmitter } from 'events';
|
|
@@ -14,6 +14,7 @@ import { retry, isRetryableError, isRateLimitError } from './retry.js';
|
|
|
14
14
|
import { withOpTimeout } from './opTimeout.js';
|
|
15
15
|
import { markInitFailure } from './init-failure.js';
|
|
16
16
|
import { NotFoundRefusal, OutOfRangeRefusal, constraintErrorMsg } from './errors.js';
|
|
17
|
+
import { amountOpMismatch } from './schemas/schedule-amount.js';
|
|
17
18
|
import { findMatchingRule } from './rule-matching.js';
|
|
18
19
|
import logger from '../logger.js';
|
|
19
20
|
import { checkServerVersionOnce } from './server-version-guard.js';
|
|
@@ -2234,6 +2235,51 @@ export async function deleteTransaction(id) {
|
|
|
2234
2235
|
await withConcurrency(() => retry(() => rawDeleteTransaction(id), { retries: 0, backoffMs: 200 }));
|
|
2235
2236
|
});
|
|
2236
2237
|
}
|
|
2238
|
+
/**
|
|
2239
|
+
* #514: merge two transactions via upstream `mergeTransactions`. The PUBLIC api takes the plain
|
|
2240
|
+
* string tuple and returns the id of the KEPT transaction; the `{ id }[]` form belongs to the
|
|
2241
|
+
* internal handler and throws "Invalid field type [object Object]" here. Upstream picks which one
|
|
2242
|
+
* survives (imported_id, then imported_payee, then earlier date, then the SECOND id) and deletes
|
|
2243
|
+
* the other, so the dropped id is derived from the returned kept id.
|
|
2244
|
+
*
|
|
2245
|
+
* The guards below cover what upstream would refuse with a less useful message, or (a split child)
|
|
2246
|
+
* only incidentally. Transfers to different accounts are left to upstream, whose message passes
|
|
2247
|
+
* through unchanged. Everything runs in one queueWriteOperation, so a read-then-write guard stays
|
|
2248
|
+
* in the adapter (never in the tool).
|
|
2249
|
+
*/
|
|
2250
|
+
export async function mergeTransactions(ids) {
|
|
2251
|
+
observability.incrementToolCall('actual.transactions.merge').catch(() => { });
|
|
2252
|
+
return queueWriteOperation(async () => {
|
|
2253
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2254
|
+
const { q } = (await import('@actual-app/api'));
|
|
2255
|
+
const rows = await withConcurrency(() => retry(async () => {
|
|
2256
|
+
// `splits: 'all'` is REQUIRED (#305): the default query excludes split PARENT rows.
|
|
2257
|
+
const res = (await rawRunQuery(q('transactions').options({ splits: 'all' }).filter({ id: { $oneof: ids } }).select(['id', 'account', 'amount', 'parent_id', 'is_child'])));
|
|
2258
|
+
return Array.isArray(res?.data) ? res.data : [];
|
|
2259
|
+
}, { retries: 2, backoffMs: 200 }));
|
|
2260
|
+
const byId = new Map(rows.map((r) => [r.id, r]));
|
|
2261
|
+
for (const id of ids) {
|
|
2262
|
+
if (!byId.has(id))
|
|
2263
|
+
throw new NotFoundRefusal('Transaction', id, 'actual_transactions_get');
|
|
2264
|
+
}
|
|
2265
|
+
const [first, second] = [byId.get(ids[0]), byId.get(ids[1])];
|
|
2266
|
+
for (const t of [first, second]) {
|
|
2267
|
+
if (t.is_child || (t.parent_id !== null && t.parent_id !== undefined)) {
|
|
2268
|
+
throw new OutOfRangeRefusal(`Cannot merge a split child transaction (${t.id}); merge the parent transactions instead. Use actual_transactions_get to find the parent.`, t.id);
|
|
2269
|
+
}
|
|
2270
|
+
}
|
|
2271
|
+
if (first.account !== second.account) {
|
|
2272
|
+
throw new OutOfRangeRefusal(`Cannot merge transactions from different accounts: ${ids[0]} is in account ${first.account}, ${ids[1]} is in account ${second.account}. Nothing was changed.`, String(second.account));
|
|
2273
|
+
}
|
|
2274
|
+
if (first.amount !== second.amount) {
|
|
2275
|
+
throw new OutOfRangeRefusal(`Cannot merge transactions with different amounts: ${ids[0]} is ${first.amount} cents, ${ids[1]} is ${second.amount} cents. Nothing was changed.`, String(second.amount));
|
|
2276
|
+
}
|
|
2277
|
+
// Non-idempotent: do not retry (#165, #514). After a lost response the dropped id is gone, so
|
|
2278
|
+
// a retry would be refused or act on the wrong pair.
|
|
2279
|
+
const keptId = (await withConcurrency(() => retry(() => rawMergeTransactions(ids), { retries: 0, backoffMs: 200 })));
|
|
2280
|
+
return { keptId, deletedId: keptId === ids[0] ? ids[1] : ids[0] };
|
|
2281
|
+
});
|
|
2282
|
+
}
|
|
2237
2283
|
export async function updateCategory(id, fields) {
|
|
2238
2284
|
observability.incrementToolCall('actual.categories.update').catch(() => { });
|
|
2239
2285
|
return queueWriteOperation(async () => {
|
|
@@ -2761,7 +2807,28 @@ export async function createSchedule(schedule) {
|
|
|
2761
2807
|
export async function updateSchedule(id, fields, resetNextDate) {
|
|
2762
2808
|
observability.incrementToolCall('actual.schedules.update').catch(() => { });
|
|
2763
2809
|
return queueWriteOperation(async () => {
|
|
2764
|
-
|
|
2810
|
+
// #527: upstream MERGES a partial update, so `amountOp` alone keeps the stored amount and
|
|
2811
|
+
// `amount` alone keeps the stored op. Whether the pair fits ('isbetween' takes a
|
|
2812
|
+
// { num1, num2 } range, every other op a number) therefore depends on the stored row when
|
|
2813
|
+
// only one of the two is supplied. The tool's own refinement covers both-supplied. Decided
|
|
2814
|
+
// and thrown BEFORE the non-retried write, so a refusal is never retried or swallowed. A
|
|
2815
|
+
// schedule that is not found falls through to upstream's own not-found error, unchanged.
|
|
2816
|
+
const patch = (fields ?? {});
|
|
2817
|
+
const hasAmount = patch.amount !== undefined;
|
|
2818
|
+
const hasOp = patch.amountOp !== undefined;
|
|
2819
|
+
if (hasAmount !== hasOp) {
|
|
2820
|
+
const schedules = await withConcurrency(() => retry(() => rawGetSchedules(), { retries: 2, backoffMs: 200 }));
|
|
2821
|
+
const stored = Array.isArray(schedules) ? schedules.find((sch) => sch?.id === id) : undefined;
|
|
2822
|
+
if (stored) {
|
|
2823
|
+
const op = hasOp ? patch.amountOp : stored.amountOp;
|
|
2824
|
+
const amount = hasAmount ? patch.amount : stored.amount;
|
|
2825
|
+
const message = amountOpMismatch(op, amount);
|
|
2826
|
+
if (message) {
|
|
2827
|
+
throw new OutOfRangeRefusal(`Schedule "${id}" was not updated: ${message} (stored amountOp ${JSON.stringify(stored.amountOp ?? null)}, stored amount ${JSON.stringify(stored.amount ?? null)}).`, JSON.stringify({ amountOp: op ?? null, amount: amount ?? null }));
|
|
2828
|
+
}
|
|
2829
|
+
}
|
|
2830
|
+
}
|
|
2831
|
+
await withConcurrency(() => retry(() => rawUpdateSchedule(id, patch, resetNextDate), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
|
|
2765
2832
|
}, { preservesListings: PRESERVES_ALL_ENTITY_LISTINGS });
|
|
2766
2833
|
}
|
|
2767
2834
|
/**
|
|
@@ -4105,6 +4172,7 @@ export default {
|
|
|
4105
4172
|
deleteAccount,
|
|
4106
4173
|
updateTransaction,
|
|
4107
4174
|
deleteTransaction,
|
|
4175
|
+
mergeTransactions,
|
|
4108
4176
|
updateCategory,
|
|
4109
4177
|
deleteCategory,
|
|
4110
4178
|
updatePayee,
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Common validation schemas used across MCP tools for Actual Budget.
|
|
5
5
|
* These schemas provide consistent validation, better error messages,
|
|
6
|
-
* and reduce duplication across the
|
|
6
|
+
* and reduce duplication across the 84 tool definitions.
|
|
7
7
|
*/
|
|
8
8
|
import { z } from 'zod';
|
|
9
9
|
import { MAX_NAME_LENGTH, MAX_NOTES_LENGTH, DATE_PATTERN, MONTH_PATTERN, UUID_PATTERN } from '../constants.js';
|
|
@@ -4,21 +4,54 @@ import { z } from 'zod';
|
|
|
4
4
|
// `date` branch in tools/list (#225). This mirrors the config Actual's `@actual-app/api`
|
|
5
5
|
// accepts for a recurring schedule; it is a closed object so a malformed config gets an
|
|
6
6
|
// actionable validation error rather than being forwarded unshaped.
|
|
7
|
+
// #478: Actual's monthly patterns (the UI's "the 1st and 15th", "last day", "second Monday").
|
|
8
|
+
// `day` maps to a day of the month, a weekday to the nth weekday; upstream honours them only
|
|
9
|
+
// for frequency 'monthly'.
|
|
10
|
+
const RecurPatternSchema = z.object({
|
|
11
|
+
type: z.enum(['day', 'SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA']),
|
|
12
|
+
value: z.number().int(),
|
|
13
|
+
}).strict();
|
|
14
|
+
export const PATTERNS_NEED_MONTHLY = "patterns apply only to frequency 'monthly'; omit patterns or send patterns: []";
|
|
15
|
+
export const DAY_PATTERN_RANGE = "a 'day' pattern value must be 1 to 31, or -1 for the last day";
|
|
16
|
+
export const WEEKDAY_PATTERN_RANGE = 'a weekday pattern value must be 1 to 5 (nth), or -1 (last)';
|
|
17
|
+
// The object is strict: a plain z.object STRIPS unknown keys, which is how a `patterns` field
|
|
18
|
+
// used to vanish while the create reported success (#478). Every key upstream's RecurConfig
|
|
19
|
+
// defines is listed, so strictness refuses only keys Actual would ignore anyway.
|
|
7
20
|
export const RecurConfigSchema = z.object({
|
|
8
21
|
frequency: z.enum(['daily', 'weekly', 'monthly', 'yearly'])
|
|
9
22
|
.describe('How often it repeats'),
|
|
10
23
|
start: z.string().regex(/^\d{4}-\d{2}-\d{2}$/)
|
|
11
24
|
.describe('Start date, YYYY-MM-DD'),
|
|
12
|
-
endMode
|
|
13
|
-
|
|
25
|
+
// Optional because upstream reads a schedule back without endMode when none was set and
|
|
26
|
+
// treats a missing one as never; passed through as given, no default injected.
|
|
27
|
+
endMode: z.enum(['never', 'after_n_occurrences', 'on_date']).optional()
|
|
28
|
+
.describe('When it stops. Omitted means never'),
|
|
14
29
|
interval: z.number().int().positive().optional()
|
|
15
30
|
.describe('Every N periods. Default 1'),
|
|
31
|
+
patterns: z.array(RecurPatternSchema).optional()
|
|
32
|
+
.describe('Monthly only. day: 1 to 31, -1 last day. SU to SA: 1 to 5 nth, -1 last'),
|
|
16
33
|
skipWeekend: z.boolean().optional()
|
|
17
|
-
.describe('Move
|
|
34
|
+
.describe('Move weekend occurrences'),
|
|
18
35
|
weekendSolveMode: z.enum(['before', 'after']).optional()
|
|
19
36
|
.describe('Friday before or Monday after. Requires skipWeekend'),
|
|
20
37
|
endOccurrences: z.number().int().positive().optional()
|
|
21
38
|
.describe('Required when endMode is after_n_occurrences'),
|
|
22
39
|
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional()
|
|
23
40
|
.describe('YYYY-MM-DD. Required when endMode is on_date'),
|
|
41
|
+
}).strict().superRefine((cfg, ctx) => {
|
|
42
|
+
const patterns = cfg.patterns ?? [];
|
|
43
|
+
// An empty list is accepted on any frequency: upstream reads every schedule back with
|
|
44
|
+
// patterns: [], and a client echoing it must not be refused.
|
|
45
|
+
if (patterns.length > 0 && cfg.frequency !== 'monthly') {
|
|
46
|
+
ctx.addIssue({ code: 'custom', path: ['patterns'], message: PATTERNS_NEED_MONTHLY });
|
|
47
|
+
}
|
|
48
|
+
// The index in the path and the pattern in the message tell a caller which entry to fix
|
|
49
|
+
// when several are sent.
|
|
50
|
+
patterns.forEach((p, i) => {
|
|
51
|
+
const ok = p.value === -1 || (p.type === 'day' ? p.value >= 1 && p.value <= 31 : p.value >= 1 && p.value <= 5);
|
|
52
|
+
if (!ok) {
|
|
53
|
+
const range = p.type === 'day' ? DAY_PATTERN_RANGE : WEEKDAY_PATTERN_RANGE;
|
|
54
|
+
ctx.addIssue({ code: 'custom', path: ['patterns', i], message: `${p.type} ${p.value}: ${range}` });
|
|
55
|
+
}
|
|
56
|
+
});
|
|
24
57
|
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
// #527: a schedule's amount is a plain number for amountOp 'is' and 'isapprox', and a
|
|
3
|
+
// { num1, num2 } range for 'isbetween'. That is upstream's own rule (`number.parse` in
|
|
4
|
+
// @actual-app/api 26.10.0): its matcher reads value.num1 and value.num2, so a scalar
|
|
5
|
+
// 'isbetween' is stored but can never match a transaction. createSchedule and
|
|
6
|
+
// updateSchedule do no validation of their own, so every refusal here is ours.
|
|
7
|
+
//
|
|
8
|
+
// The create refinement, the update refinement and the adapter's read-then-write guard all
|
|
9
|
+
// call amountOpMismatch, so the three cannot drift apart in what they refuse or how they say it.
|
|
10
|
+
export const AmountRangeSchema = z.object({
|
|
11
|
+
num1: z.number().int(),
|
|
12
|
+
num2: z.number().int(),
|
|
13
|
+
}).strict();
|
|
14
|
+
// A bare union failure reads "Invalid input", which tells a caller nothing; the custom
|
|
15
|
+
// error names both accepted shapes, and the central formatter passes it through with its path.
|
|
16
|
+
export const AMOUNT_SHAPE = "amount must be integer cents (for example -5000), or { num1, num2 } as two integers with amountOp 'isbetween'";
|
|
17
|
+
export const ScheduleAmountSchema = z.union([z.number().int(), AmountRangeSchema], { error: AMOUNT_SHAPE });
|
|
18
|
+
export const ISBETWEEN_NEEDS_RANGE = "amountOp 'isbetween' needs amount as { num1, num2 } in cents, for example { num1: -10000, num2: -5000 }. " +
|
|
19
|
+
"To match a single amount, send amountOp 'is' or 'isapprox' with a number instead.";
|
|
20
|
+
export const RANGE_NEEDS_ISBETWEEN = "an amount given as { num1, num2 } needs amountOp: 'isbetween'. For a single amount, send a number.";
|
|
21
|
+
function isRange(amount) {
|
|
22
|
+
if (typeof amount !== 'object' || amount === null)
|
|
23
|
+
return false;
|
|
24
|
+
const { num1, num2 } = amount;
|
|
25
|
+
return typeof num1 === 'number' && typeof num2 === 'number';
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Returns the refusal message when the op and the amount do not fit together, else null.
|
|
29
|
+
* A missing op counts as 'is' (create defaults it, and upstream keeps a stored null op on a
|
|
30
|
+
* partial update). An amount that is neither a number nor a range (absent, or a stored value
|
|
31
|
+
* of an unexpected shape) is not judged here: returning null keeps the guard from refusing
|
|
32
|
+
* an update it cannot decode.
|
|
33
|
+
*/
|
|
34
|
+
export function amountOpMismatch(op, amount) {
|
|
35
|
+
const effectiveOp = op ?? 'is';
|
|
36
|
+
if (typeof amount === 'number')
|
|
37
|
+
return effectiveOp === 'isbetween' ? ISBETWEEN_NEEDS_RANGE : null;
|
|
38
|
+
if (isRange(amount))
|
|
39
|
+
return effectiveOp === 'isbetween' ? null : RANGE_NEEDS_ISBETWEEN;
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
@@ -127,6 +127,8 @@ const DESTRUCTIVE = new Set([
|
|
|
127
127
|
'actual_schedules_delete',
|
|
128
128
|
'actual_tags_delete',
|
|
129
129
|
'actual_transactions_delete',
|
|
130
|
+
// #514: deletes the transaction upstream drops (and a transfer counterpart). Not idempotent: a repeat is refused.
|
|
131
|
+
'actual_transactions_merge',
|
|
130
132
|
// THE NON-OBVIOUS ONE. `fields.subtransactions` (CommonSchemas, `.strict()`) has no `id`,
|
|
131
133
|
// so upstream's `makeChild` mints a fresh UUID for every child and `diffItems` tombstones
|
|
132
134
|
// every existing one. Editing one amount on a six-way split DELETES all six rows and
|
package/dist/src/lib/toolsets.js
CHANGED
|
@@ -59,6 +59,7 @@ export const TOOLSETS = {
|
|
|
59
59
|
'actual_transactions_update_batch',
|
|
60
60
|
'actual_transactions_delete',
|
|
61
61
|
'actual_transactions_import',
|
|
62
|
+
'actual_transactions_merge',
|
|
62
63
|
'actual_transfers_create',
|
|
63
64
|
],
|
|
64
65
|
analysis: [
|
|
@@ -221,6 +222,7 @@ export const WRITE_CAPABLE = new Set([
|
|
|
221
222
|
'actual_transactions_create',
|
|
222
223
|
'actual_transactions_delete',
|
|
223
224
|
'actual_transactions_import',
|
|
225
|
+
'actual_transactions_merge',
|
|
224
226
|
'actual_transactions_update',
|
|
225
227
|
'actual_transactions_update_batch',
|
|
226
228
|
'actual_transfers_create',
|
package/dist/src/tools/index.js
CHANGED
|
@@ -62,6 +62,7 @@ export { default as transactions_delete } from './transactions_delete.js';
|
|
|
62
62
|
export { default as transactions_filter } from './transactions_filter.js';
|
|
63
63
|
export { default as transactions_get } from './transactions_get.js';
|
|
64
64
|
export { default as transactions_import } from './transactions_import.js';
|
|
65
|
+
export { default as transactions_merge } from './transactions_merge.js';
|
|
65
66
|
export { default as transactions_search_by_amount } from './transactions_search_by_amount.js';
|
|
66
67
|
export { default as transactions_search_by_category } from './transactions_search_by_category.js';
|
|
67
68
|
export { default as transactions_search_by_month } from './transactions_search_by_month.js';
|
|
@@ -2,6 +2,7 @@ import { z } from 'zod';
|
|
|
2
2
|
import adapter from '../lib/actual-adapter.js';
|
|
3
3
|
import { CommonSchemas } from '../lib/schemas/common.js';
|
|
4
4
|
import { RecurConfigSchema } from '../lib/schemas/recur.js';
|
|
5
|
+
import { ScheduleAmountSchema, amountOpMismatch, ISBETWEEN_NEEDS_RANGE } from '../lib/schemas/schedule-amount.js';
|
|
5
6
|
const InputSchema = z.object({
|
|
6
7
|
name: z.string().optional()
|
|
7
8
|
.describe('Unique display name'),
|
|
@@ -9,8 +10,8 @@ const InputSchema = z.object({
|
|
|
9
10
|
.describe('Payee UUID'),
|
|
10
11
|
account: CommonSchemas.accountId.optional()
|
|
11
12
|
.describe('Account UUID. If omitted the schedule is not tied to an account'),
|
|
12
|
-
amount:
|
|
13
|
-
.describe('Amount in cents, negative = expense'),
|
|
13
|
+
amount: ScheduleAmountSchema.optional()
|
|
14
|
+
.describe('Amount in cents, negative = expense. A number, or { num1, num2 } only with amountOp isbetween (order does not matter)'),
|
|
14
15
|
amountOp: z.enum(['is', 'isapprox', 'isbetween']).optional().default('is')
|
|
15
16
|
.describe('How to match the amount'),
|
|
16
17
|
date: z.union([
|
|
@@ -21,10 +22,17 @@ const InputSchema = z.object({
|
|
|
21
22
|
]).describe('YYYY-MM-DD for one-off, RecurConfig object for recurring. Required.'),
|
|
22
23
|
posts_transaction: z.boolean().optional().default(false)
|
|
23
24
|
.describe('Auto-post a transaction on each occurrence'),
|
|
25
|
+
}).superRefine((input, ctx) => {
|
|
26
|
+
// #527: refused here, before any upstream call; see src/lib/schemas/schedule-amount.ts.
|
|
27
|
+
const message = input.amount === undefined
|
|
28
|
+
? (input.amountOp === 'isbetween' ? ISBETWEEN_NEEDS_RANGE : null)
|
|
29
|
+
: amountOpMismatch(input.amountOp, input.amount);
|
|
30
|
+
if (message)
|
|
31
|
+
ctx.addIssue({ code: 'custom', path: ['amount'], message });
|
|
24
32
|
});
|
|
25
33
|
const tool = {
|
|
26
34
|
name: 'actual_schedules_create',
|
|
27
|
-
description: `Create a schedule, one-off (YYYY-MM-DD date) or recurring (RecurConfig object
|
|
35
|
+
description: `Create a schedule, one-off (YYYY-MM-DD date) or recurring (RecurConfig object: frequency and start, optional endMode, monthly patterns such as the last day or the second Monday). Amounts are in cents, negative for expenses.`,
|
|
28
36
|
inputSchema: InputSchema,
|
|
29
37
|
call: async (args, _meta) => {
|
|
30
38
|
const input = InputSchema.parse(args || {});
|
|
@@ -2,6 +2,7 @@ import { z } from 'zod';
|
|
|
2
2
|
import adapter from '../lib/actual-adapter.js';
|
|
3
3
|
import { CommonSchemas } from '../lib/schemas/common.js';
|
|
4
4
|
import { RecurConfigSchema } from '../lib/schemas/recur.js';
|
|
5
|
+
import { ScheduleAmountSchema, amountOpMismatch } from '../lib/schemas/schedule-amount.js';
|
|
5
6
|
const InputSchema = z.object({
|
|
6
7
|
id: CommonSchemas.scheduleId.describe('Schedule UUID'),
|
|
7
8
|
name: z.string().optional()
|
|
@@ -10,8 +11,8 @@ const InputSchema = z.object({
|
|
|
10
11
|
.describe('New payee UUID, null to clear'),
|
|
11
12
|
account: CommonSchemas.accountId.nullable().optional()
|
|
12
13
|
.describe('New account UUID, null to clear'),
|
|
13
|
-
amount:
|
|
14
|
-
.describe('New amount in cents, negative = expense'),
|
|
14
|
+
amount: ScheduleAmountSchema.optional()
|
|
15
|
+
.describe('New amount in cents, negative = expense. A number, or { num1, num2 } only with amountOp isbetween'),
|
|
15
16
|
amountOp: z.enum(['is', 'isapprox', 'isbetween']).optional()
|
|
16
17
|
.describe('How to match the amount'),
|
|
17
18
|
date: z.union([
|
|
@@ -26,11 +27,19 @@ const InputSchema = z.object({
|
|
|
26
27
|
completed: z.boolean().optional()
|
|
27
28
|
.describe('true marks it completed, false reactivates it'),
|
|
28
29
|
resetNextDate: z.boolean().optional().default(false)
|
|
29
|
-
.describe('Recalculate next_date
|
|
30
|
+
.describe('Recalculate next_date'),
|
|
31
|
+
}).superRefine((input, ctx) => {
|
|
32
|
+
// #527: with both fields supplied the input alone decides. With only one, the answer
|
|
33
|
+
// depends on the stored schedule, so adapter.updateSchedule checks it inside the write queue.
|
|
34
|
+
if (input.amount === undefined || input.amountOp === undefined)
|
|
35
|
+
return;
|
|
36
|
+
const message = amountOpMismatch(input.amountOp, input.amount);
|
|
37
|
+
if (message)
|
|
38
|
+
ctx.addIssue({ code: 'custom', path: ['amount'], message });
|
|
30
39
|
});
|
|
31
40
|
const tool = {
|
|
32
41
|
name: 'actual_schedules_update',
|
|
33
|
-
description: `Update a schedule; supply only the fields to change. Set resetNextDate: true when changing the date or recurrence.`,
|
|
42
|
+
description: `Update a schedule; supply only the fields to change. A new date replaces the stored recurrence, patterns included. Set resetNextDate: true when changing the date or recurrence.`,
|
|
34
43
|
inputSchema: InputSchema,
|
|
35
44
|
call: async (args, _meta) => {
|
|
36
45
|
const input = InputSchema.parse(args || {});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { createTool } from '../lib/toolFactory.js';
|
|
3
|
+
import { CommonSchemas } from '../lib/schemas/common.js';
|
|
4
|
+
import adapter from '../lib/actual-adapter.js';
|
|
5
|
+
const InputSchema = z.object({
|
|
6
|
+
ids: z.array(CommonSchemas.transactionId).length(2)
|
|
7
|
+
.refine((ids) => ids[0] !== ids[1], { message: 'ids must be two DIFFERENT transaction ids' })
|
|
8
|
+
.describe('Exactly two transaction ids to merge'),
|
|
9
|
+
});
|
|
10
|
+
export default createTool({
|
|
11
|
+
name: 'actual_transactions_merge',
|
|
12
|
+
description: 'Merge two transactions (e.g. a bank-imported duplicate and a manual entry). Same account and same amount required; split children cannot be merged. ' +
|
|
13
|
+
'Upstream keeps the one with imported_id, else imported_payee, else the earlier date, else the SECOND id, and DELETES the other (cannot be undone). ' +
|
|
14
|
+
'The kept row keeps its own values: only its EMPTY payee, category, notes and schedule, and false cleared and reconciled flags, are filled from the deleted row, so a deleted row value is lost where the kept row has one. ' +
|
|
15
|
+
'Returns keptId and deletedId. If only one is a transfer, the kept row takes the transfer payee; if both are transfers, their counterparts are merged and one more row is deleted there. ' +
|
|
16
|
+
'Merging a split in, or a transfer to an on-budget account, clears the category. Read both with actual_transactions_get first.',
|
|
17
|
+
schema: InputSchema,
|
|
18
|
+
handler: async (input) => {
|
|
19
|
+
const ids = [input.ids[0], input.ids[1]];
|
|
20
|
+
const { keptId, deletedId } = await adapter.mergeTransactions(ids);
|
|
21
|
+
return { success: true, keptId, deletedId };
|
|
22
|
+
},
|
|
23
|
+
examples: [
|
|
24
|
+
{ description: 'Merge a manual transaction with its imported duplicate', input: { ids: ['00000000-0000-4000-8000-000000000001', '00000000-0000-4000-8000-000000000002'] } },
|
|
25
|
+
],
|
|
26
|
+
});
|
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.22.
|
|
4
|
+
"version": "0.22.31",
|
|
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 84 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/transactions_aggregate.test.js && node tests/unit/account_flow_summary.test.js && node tests/unit/recurring_expenses_summary.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/e2e_spec_collection.test.js && node tests/unit/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/new_tool_completeness.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/write_effect_audit_staleness.test.js && node tests/unit/write_effect_audit_membership.test.js && node tests/unit/tool_annotations.test.js && node tests/unit/tool_id_schema_drift.test.js && node tests/unit/filter_id_resolution.test.js && node tests/unit/filter_id_tool_wiring.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/adapter_set_budget_batch.test.js && node tests/unit/budget_updates_batch.test.js && node tests/unit/rules_create.test.js && node tests/unit/adapter_rule_reference_guard.test.js && node tests/unit/rules_create_batch.test.js && node tests/unit/budgets_hold_for_next_month.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/httpServer_init_failure_report.test.js && node tests/unit/stdio_init_failure_report.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/accounts_delete.test.js && node tests/unit/update_tools_not_found.test.js && node tests/unit/accounts_close.test.js && node tests/unit/accounts_reopen.test.js && node tests/unit/payees_delete.test.js && node tests/unit/payees_merge.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/session_ownership.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/oidc_resource_identifier.test.js && node tests/unit/httpServer_oidc_resource_metadata.test.js && node tests/unit/oidc_error_cause.test.js && node tests/unit/httpServer_oidc_error_body.test.js && node tests/unit/oidc_token_source.test.js && node tests/unit/config_oidc_assertion_refines.test.js && node tests/unit/httpServer_oidc_assertion_mode.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpErrorHandler.test.js && node tests/unit/httpServer_error_handler_wiring.test.js && node tests/unit/httpServer_trust_proxy.test.js && node tests/unit/httpServer_batch_cap.test.js && node tests/unit/dotenv_stdout_quiet.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/adapter_drain_listing_cache.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_selection_precondition.test.js && node tests/unit/budget_load_abandonment.test.js && node tests/unit/budget_loader_postcondition.test.js && node tests/unit/budget_loader_import_allowlist.test.js && node tests/unit/budget_import_tracking.test.js && node tests/unit/pool_shutdown_under_lock.test.js && node tests/unit/api_lock_affinity.test.js && node tests/unit/commit_message_closing_keyword.test.js && node tests/unit/write_queue_stranding.test.js && node tests/unit/write_queue_stranded_bound.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/transactions_update_split_convert.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/transactions_update_batch.test.js && node tests/unit/tool_count_sync.test.js && node tests/unit/stdio_transport.test.js && node tests/unit/stdio_budget_switch.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/playwright_version_drift.test.js && node tests/unit/observability_smoke.test.js && node tests/unit/residue_pattern.test.js && node tests/unit/batch_uncategorized_cleanup.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/server_version_probe_outside_op_timeout.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/budgets_import_syncid.test.js && node tests/unit/budget_acl_dynamic.test.js && node tests/unit/budget_acl_identity_map.test.js && node tests/unit/budget_acl_userinfo.test.js && node tests/unit/acl_e2e_fixture_contract.test.js && node tests/unit/budget_acl_middleware.test.js && node tests/unit/rules_stage.test.js && node tests/unit/oidc_scope_list.test.js && node tests/unit/get_context.test.js && node tests/unit/contributor_docs.test.js && node tests/unit/toolsets.test.js && node tests/unit/toolsets_entrypoint.test.js && node tests/unit/tools_list_size_budget.test.js && node tests/unit/rules_update_schedules_schema.test.js && node tests/unit/source_text_helper.test.js && node tests/unit/transport_onerror.test.js && node tests/unit/adapter_transaction_batch_abort.test.js",
|
|
33
|
+
"test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/transactions_aggregate.test.js && node tests/unit/account_flow_summary.test.js && node tests/unit/recurring_expenses_summary.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/e2e_spec_collection.test.js && node tests/unit/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/new_tool_completeness.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/write_effect_audit_staleness.test.js && node tests/unit/write_effect_audit_membership.test.js && node tests/unit/tool_annotations.test.js && node tests/unit/tool_id_schema_drift.test.js && node tests/unit/filter_id_resolution.test.js && node tests/unit/filter_id_tool_wiring.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/adapter_set_budget_batch.test.js && node tests/unit/budget_updates_batch.test.js && node tests/unit/rules_create.test.js && node tests/unit/adapter_rule_reference_guard.test.js && node tests/unit/rules_create_batch.test.js && node tests/unit/budgets_hold_for_next_month.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/httpServer_init_failure_report.test.js && node tests/unit/stdio_init_failure_report.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/accounts_delete.test.js && node tests/unit/update_tools_not_found.test.js && node tests/unit/accounts_close.test.js && node tests/unit/accounts_reopen.test.js && node tests/unit/payees_delete.test.js && node tests/unit/payees_merge.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/session_ownership.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/oidc_resource_identifier.test.js && node tests/unit/httpServer_oidc_resource_metadata.test.js && node tests/unit/oidc_error_cause.test.js && node tests/unit/httpServer_oidc_error_body.test.js && node tests/unit/oidc_token_source.test.js && node tests/unit/config_oidc_assertion_refines.test.js && node tests/unit/httpServer_oidc_assertion_mode.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpErrorHandler.test.js && node tests/unit/httpServer_error_handler_wiring.test.js && node tests/unit/httpServer_trust_proxy.test.js && node tests/unit/httpServer_batch_cap.test.js && node tests/unit/dotenv_stdout_quiet.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/adapter_drain_listing_cache.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_selection_precondition.test.js && node tests/unit/budget_load_abandonment.test.js && node tests/unit/budget_loader_postcondition.test.js && node tests/unit/budget_loader_import_allowlist.test.js && node tests/unit/budget_import_tracking.test.js && node tests/unit/pool_shutdown_under_lock.test.js && node tests/unit/api_lock_affinity.test.js && node tests/unit/commit_message_closing_keyword.test.js && node tests/unit/write_queue_stranding.test.js && node tests/unit/write_queue_stranded_bound.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/transactions_update_split_convert.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/transactions_update_batch.test.js && node tests/unit/tool_count_sync.test.js && node tests/unit/stdio_transport.test.js && node tests/unit/stdio_budget_switch.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/playwright_version_drift.test.js && node tests/unit/observability_smoke.test.js && node tests/unit/residue_pattern.test.js && node tests/unit/batch_uncategorized_cleanup.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/server_version_probe_outside_op_timeout.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/budgets_import_syncid.test.js && node tests/unit/budget_acl_dynamic.test.js && node tests/unit/budget_acl_identity_map.test.js && node tests/unit/budget_acl_userinfo.test.js && node tests/unit/acl_e2e_fixture_contract.test.js && node tests/unit/budget_acl_middleware.test.js && node tests/unit/rules_stage.test.js && node tests/unit/oidc_scope_list.test.js && node tests/unit/get_context.test.js && node tests/unit/contributor_docs.test.js && node tests/unit/toolsets.test.js && node tests/unit/toolsets_entrypoint.test.js && node tests/unit/tools_list_size_budget.test.js && node tests/unit/rules_update_schedules_schema.test.js && node tests/unit/schedules_update_amount_guard.test.js && node tests/unit/source_text_helper.test.js && node tests/unit/transport_onerror.test.js && node tests/unit/adapter_transaction_batch_abort.test.js && node tests/unit/transactions_merge_adapter.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",
|