actual-mcp-server 0.12.0 → 0.13.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 CHANGED
@@ -312,10 +312,10 @@ For Claude Desktop (stdio), restart Claude after upgrading.
312
312
  |------|-------------|
313
313
  | `actual_accounts_list` | List all accounts |
314
314
  | `actual_accounts_create` | Create new account |
315
- | `actual_accounts_update` | Update account details |
315
+ | `actual_accounts_update` | Update account details. An id that does not exist is refused rather than creating a partial record |
316
316
  | `actual_accounts_delete` | Permanently delete account |
317
317
  | `actual_accounts_close` | Close account. An account with NO transactions is REMOVED by Actual, not closed |
318
- | `actual_accounts_reopen` | Reopen closed account |
318
+ | `actual_accounts_reopen` | Reopen closed account. An id that is not an account is refused rather than creating one |
319
319
  | `actual_accounts_get_balance` | Get account balance at a date |
320
320
 
321
321
  ### Transactions (14)
@@ -406,10 +406,10 @@ For Claude Desktop (stdio), restart Claude after upgrading.
406
406
  | `actual_budgets_get_all` | List available budget files |
407
407
  | `actual_budgets_getMonths` | List budget months |
408
408
  | `actual_budgets_getMonth` | Get budget for a specific month |
409
- | `actual_budgets_setAmount` | Set category budget amount |
409
+ | `actual_budgets_setAmount` | Set category budget amount. The month must be one the budget has (see `actual_budgets_getMonths`) |
410
410
  | `actual_budgets_transfer` | Transfer amount between categories |
411
411
  | `actual_budgets_setCarryover` | Enable/disable carryover |
412
- | `actual_budgets_holdForNextMonth` | Hold funds for next month |
412
+ | `actual_budgets_holdForNextMonth` | Hold funds for next month. Actual clamps the hold to what is left to budget, so the response reports the amount actually held |
413
413
  | `actual_budgets_resetHold` | Reset hold status |
414
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
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 |
@@ -774,7 +774,7 @@ The software is provided **as-is**, without warranty of any kind. The author acc
774
774
 
775
775
  ---
776
776
 
777
- **Version:** 0.12.0 | **Tool Count:** 74 (verified LibreChat-compatible)
777
+ **Version:** 0.13.0 | **Tool Count:** 74 (verified LibreChat-compatible)
778
778
 
779
779
  ## Sponsor
780
780
 
@@ -999,6 +999,21 @@ export async function setBudgetAmount(month, categoryId, amount) {
999
999
  if (!exists) {
1000
1000
  throw new Error(`Category "${categoryId}" not found. Use actual_categories_get to list available categories.`);
1001
1001
  }
1002
+ // #361: the MONTH is unvalidated, in format AND range. `api/budget-set-amount` is the
1003
+ // one budget handler that does not call upstream's `validateMonth`, unlike
1004
+ // budget-set-carryover, budget-hold-for-next-month and budget-reset-hold, and the
1005
+ // tool's schema is a bare `z.string().min(1)`, so even 'banana' reaches this point.
1006
+ // Upstream then runs `dbMonth(month)` and INSERTs a row keyed `<month>-<category>`.
1007
+ //
1008
+ // A membership test against the budget's own months covers both halves at once:
1009
+ // upstream's `validateMonth` and `api/budget-months` share the same
1010
+ // `get-budget-bounds()` plus `range()` computation, so this reproduces its range check
1011
+ // exactly, and a malformed string cannot be a member either.
1012
+ const months = await withConcurrency(() => retry(() => rawGetBudgetMonths(), { retries: 2, backoffMs: 200 }));
1013
+ if (Array.isArray(months) && months.length > 0 && !months.includes(String(month))) {
1014
+ throw new Error(`Month "${month}" is not in this budget. It runs from ${months[0]} to ${months[months.length - 1]}. ` +
1015
+ 'Use actual_budgets_getMonths to see the months you can budget to.');
1016
+ }
1002
1017
  const result = await withConcurrency(() => retry(() => rawSetBudgetAmount(month, categoryId, amount), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
1003
1018
  return result;
1004
1019
  });
@@ -1052,6 +1067,15 @@ export async function createAccount(account, initialBalance) {
1052
1067
  export async function updateAccount(id, fields) {
1053
1068
  observability.incrementToolCall('actual.accounts.update').catch(() => { });
1054
1069
  return queueWriteOperation(async () => {
1070
+ // #360: `db.update` does not run a SQL UPDATE. It sends CRDT messages, and the apply
1071
+ // path INSERTs when the row was absent, so an unknown id CREATES a partial row rather
1072
+ // than matching nothing. Refuse first, the way updateTag and updateRule already do.
1073
+ // A CLOSED account is still updatable: getAccounts filters `tombstone = 0`, not
1074
+ // `closed = 0`, so this refuses only ids that genuinely do not exist.
1075
+ const accounts = await withConcurrency(() => retry(() => rawGetAccounts(), { retries: 2, backoffMs: 200 }));
1076
+ if (!(Array.isArray(accounts) && accounts.some((a) => a?.id === id))) {
1077
+ throw new Error(notFoundMsg('Account', id, 'actual_accounts_list'));
1078
+ }
1055
1079
  await withConcurrency(() => retry(() => rawUpdateAccount(id, fields), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
1056
1080
  return null;
1057
1081
  });
@@ -1213,6 +1237,15 @@ export async function deleteTransaction(id) {
1213
1237
  export async function updateCategory(id, fields) {
1214
1238
  observability.incrementToolCall('actual.categories.update').catch(() => { });
1215
1239
  return queueWriteOperation(async () => {
1240
+ // #360: `db.update` does not run a SQL UPDATE. It sends CRDT messages, and the apply
1241
+ // path INSERTs when the row was absent, so an unknown id CREATES a partial row rather
1242
+ // than matching nothing. Refuse first, the way updateTag and updateRule already do.
1243
+ // Called with no argument, upstream returns every category in every group, hidden
1244
+ // included, so a hidden category is not misreported as missing.
1245
+ const categories = await withConcurrency(() => retry(() => rawGetCategories(), { retries: 2, backoffMs: 200 }));
1246
+ if (!(Array.isArray(categories) && categories.some((c) => c?.id === id))) {
1247
+ throw new Error(notFoundMsg('Category', id, 'actual_categories_get'));
1248
+ }
1216
1249
  await withConcurrency(() => retry(() => rawUpdateCategory(id, fields), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
1217
1250
  });
1218
1251
  }
@@ -1231,6 +1264,20 @@ export async function deleteCategory(id) {
1231
1264
  export async function updatePayee(id, fields) {
1232
1265
  observability.incrementToolCall('actual.payees.update').catch(() => { });
1233
1266
  return queueWriteOperation(async () => {
1267
+ // #360: `db.update` does not run a SQL UPDATE. It sends CRDT messages, and the apply
1268
+ // path INSERTs when the row was absent, so an unknown id CREATES a partial row rather
1269
+ // than matching nothing. Refuse first, the way updateTag and updateRule already do.
1270
+ // Known edge, and why it is acceptable: `db.getPayees()` excludes a transfer payee whose
1271
+ // linked account has been hard-tombstoned, so such an orphan is refused here as
1272
+ // not-found. A caller CAN hold such an id (transaction rows carry `payee`, and
1273
+ // actual_entities_search and actual_get_id_by_name surface ids too), so this is not
1274
+ // strictly unreachable. It is accepted because updating an orphaned transfer payee,
1275
+ // whose account no longer exists, is not a real workflow, and because the alternative
1276
+ // is leaving four tools able to create phantom rows.
1277
+ const payees = await withConcurrency(() => retry(() => rawGetPayees(), { retries: 2, backoffMs: 200 }));
1278
+ if (!(Array.isArray(payees) && payees.some((p) => p?.id === id))) {
1279
+ throw new Error(notFoundMsg('Payee', id, 'actual_payees_get'));
1280
+ }
1234
1281
  const fieldsObj = fields;
1235
1282
  // Extract `category` — it is NOT a direct column on the payees table in Actual Budget.
1236
1283
  // The "default category" feature is implemented via rules (condition: payee is X → action: set category).
@@ -1418,27 +1465,145 @@ export async function setBudgetCarryover(month, categoryId, flag) {
1418
1465
  });
1419
1466
  }
1420
1467
  /**
1421
- * #357: forwards the two transfer arguments the published API documents.
1468
+ * #357, at the adapter layer since #371.
1469
+ *
1470
+ * Three upstream behaviours have to be handled together, because any one of them rewrites
1471
+ * this function:
1422
1472
  *
1423
- * `closeAccount(id, transferAccountId?, transferCategoryId?)` is the documented
1424
- * signature, and upstream throws `balance is non-zero: transferAccountId is required`
1425
- * when an account with a balance is closed without one. This adapter used to pass the
1426
- * id alone, so an account with a non-zero balance could not be closed through this
1427
- * server at all: the caller was told to supply a parameter no tool accepted.
1473
+ * (a) `closeAccount` opens with `if (!account || account.closed === 1) return;`, so an
1474
+ * unknown id or an already-closed account did nothing and reported success.
1475
+ * (b) `if (numTransactions === 0) await db.deleteAccount({id})`: an account with no
1476
+ * transactions is TOMBSTONED, not closed, and cannot be reopened.
1477
+ * (c) a non-zero balance throws `balance is non-zero: transferAccountId is required`, and
1478
+ * the transfer arguments are documented but were not exposed until #357.
1428
1479
  *
1429
- * Both remain OPTIONAL. A zero-balance account still closes with the id alone.
1480
+ * Read, write and re-read share ONE `queueWriteOperation` cycle. A read-BEFORE is required
1481
+ * (unlike #347's pure verify-after) because (b) makes "absent afterwards" ambiguous: an
1482
+ * account deleted by the close and an id that never existed look identical from a single
1483
+ * post-read.
1484
+ *
1485
+ * WHY THIS LIVES HERE AND NOT IN THE TOOL. It was in the tool until #371. Keeping the read
1486
+ * and the write in one cycle does NOT require the raw api: this is the shape
1487
+ * `mergePayees` already used, and doing it here keeps `retry` on the reads, keeps ONE
1488
+ * observability call site, and leaves no unguarded `adapter.closeAccount` for a future
1489
+ * caller to reach for. The tool maps the outcome below onto its response wording.
1430
1490
  */
1431
1491
  export async function closeAccount(id, transferAccountId, transferCategoryId) {
1432
1492
  observability.incrementToolCall('actual.accounts.close').catch(() => { });
1433
1493
  return queueWriteOperation(async () => {
1434
- // Non-idempotent: do not retry (#165).
1435
- await withConcurrency(() => retry(() => rawCloseAccount(id, transferAccountId, transferCategoryId), { retries: 0, backoffMs: 200 }));
1494
+ const before = await withConcurrency(() => retry(() => rawGetAccounts(), { retries: 2, backoffMs: 200 }));
1495
+ const list = Array.isArray(before) ? before : [];
1496
+ const target = list.find((a) => a?.id === id);
1497
+ if (!target) {
1498
+ throw new Error(`Account "${id}" not found. Use actual_accounts_list to see the accounts that exist. ` +
1499
+ 'Note that an account closed while it had no transactions was REMOVED by Actual, ' +
1500
+ 'not closed, so it will not appear there.');
1501
+ }
1502
+ if (target.closed === true) {
1503
+ // Idempotent and truthful: the requested state already holds, and the caller is told
1504
+ // that nothing changed rather than being implied a state change happened.
1505
+ return { outcome: 'already-closed', name: target.name };
1506
+ }
1507
+ if (transferAccountId) {
1508
+ const destination = list.find((a) => a?.id === transferAccountId);
1509
+ if (!destination) {
1510
+ throw new Error(`Transfer destination account "${transferAccountId}" not found. Use ` +
1511
+ 'actual_accounts_list to pick an account to move the remaining balance to.');
1512
+ }
1513
+ if (destination.closed === true) {
1514
+ throw new Error(`Transfer destination account "${destination.name ?? transferAccountId}" is CLOSED, ` +
1515
+ 'so the closing balance would be moved somewhere hidden from most views. Pick an ' +
1516
+ 'open account, or reopen that one with actual_accounts_reopen first.');
1517
+ }
1518
+ }
1519
+ if (transferCategoryId) {
1520
+ // #359's lesson applied to the only WRITE this path can add. Upstream forwards this id
1521
+ // straight into `transaction-add` unchecked, so a bogus value writes a "Closing
1522
+ // account" transaction carrying a category that does not exist, and syncs it.
1523
+ const categories = await withConcurrency(() => retry(() => rawGetCategories(), { retries: 2, backoffMs: 200 }));
1524
+ const known = Array.isArray(categories) && categories.some((c) => c?.id === transferCategoryId);
1525
+ if (!known) {
1526
+ throw new Error(`Transfer category "${transferCategoryId}" not found. Use actual_categories_get to ` +
1527
+ 'pick a category for the closing transaction, or omit transferCategoryId to leave ' +
1528
+ 'it uncategorised.');
1529
+ }
1530
+ }
1531
+ try {
1532
+ // Non-idempotent: do not retry (#165).
1533
+ await withConcurrency(() => retry(() => rawCloseAccount(id, transferAccountId, transferCategoryId), { retries: 0, backoffMs: 200 }));
1534
+ }
1535
+ catch (err) {
1536
+ const msg = err instanceof Error ? err.message : String(err);
1537
+ if (/balance is non-zero/i.test(msg)) {
1538
+ throw new Error(`Account "${target.name ?? id}" has a non-zero balance, so closing it requires ` +
1539
+ 'transferAccountId: the id of the account to move the remaining balance to. ' +
1540
+ 'Optionally pass transferCategoryId to categorise the balancing transaction. ' +
1541
+ 'Use actual_accounts_list to pick a destination.');
1542
+ }
1543
+ throw err;
1544
+ }
1545
+ const after = await withConcurrency(() => retry(() => rawGetAccounts(), { retries: 2, backoffMs: 200 }));
1546
+ const survivor = (Array.isArray(after) ? after : []).find((a) => a?.id === id);
1547
+ if (!survivor) {
1548
+ // (b): upstream tombstoned a zero-transaction account instead of closing it.
1549
+ return { outcome: 'removed', name: target.name };
1550
+ }
1551
+ if (survivor.closed !== true) {
1552
+ throw new Error(`Account "${survivor.name ?? id}" (${id}) is still open after the close. The call was ` +
1553
+ 'accepted but had no effect; check the account state in Actual.');
1554
+ }
1555
+ return { outcome: 'closed', name: survivor.name };
1436
1556
  });
1437
1557
  }
1558
+ /**
1559
+ * #358, at the adapter layer since #371.
1560
+ *
1561
+ * Upstream `reopenAccount` is a bare `db.update('accounts', {id, closed: 0})`. `db.update`
1562
+ * does not run a SQL UPDATE: it sends CRDT messages, and the apply path INSERTs when the
1563
+ * row was absent. The accounts table has no NOT NULL columns and `tombstone` defaults to 0,
1564
+ * while `getAccounts()` filters `tombstone = 0`, so reopening an id that is not an account
1565
+ * CREATES a visible nameless account that syncs to every client.
1566
+ *
1567
+ * A pre-check is right here and was wrong in #347: there, the caller's intent ("make this
1568
+ * account not exist") could already be satisfied by an absent row, so refusing on absence
1569
+ * would have failed a complete request. Here the intent ("reopen this account") cannot be
1570
+ * satisfied by an absent row, and proceeding actively creates one.
1571
+ *
1572
+ * TWO DELIBERATE LIMITS, carried over from the tool-layer version so they are not
1573
+ * rediscovered as oversights.
1574
+ *
1575
+ * One `queueWriteOperation` keeps this read, write and re-read in a single api lock cycle.
1576
+ * It does NOT serialise against other operations in the same batch: `processWriteQueue`
1577
+ * dispatches with `Promise.allSettled`, so operations queued in the same drain window
1578
+ * interleave at await points. The cycle excludes other SESSIONS, not batch siblings.
1579
+ *
1580
+ * The not-found message covers both reasons an id can be missing (never existed, or removed
1581
+ * by a close while it had no transactions) rather than distinguishing them. `q().withDead()`
1582
+ * would tell them apart by reading tombstoned rows, at the cost of an extra query on the
1583
+ * failure path. Collapsing them was chosen because the message names both cases and the
1584
+ * remedy is the same either way.
1585
+ */
1438
1586
  export async function reopenAccount(id) {
1439
1587
  observability.incrementToolCall('actual.accounts.reopen').catch(() => { });
1440
1588
  return queueWriteOperation(async () => {
1589
+ const before = await withConcurrency(() => retry(() => rawGetAccounts(), { retries: 2, backoffMs: 200 }));
1590
+ const target = (Array.isArray(before) ? before : []).find((a) => a?.id === id);
1591
+ if (!target) {
1592
+ throw new Error(`Account "${id}" not found, so it cannot be reopened. Use actual_accounts_list to see ` +
1593
+ 'the accounts that exist. If this account was closed while it had no transactions, ' +
1594
+ 'Actual removed it rather than closing it, and it cannot be reopened: create a new ' +
1595
+ 'one with actual_accounts_create.');
1596
+ }
1441
1597
  await withConcurrency(() => retry(() => rawReopenAccount(id), { retries: 2, backoffMs: 200 }));
1598
+ const after = await withConcurrency(() => retry(() => rawGetAccounts(), { retries: 2, backoffMs: 200 }));
1599
+ const survivor = (Array.isArray(after) ? after : []).find((a) => a?.id === id);
1600
+ if (!survivor) {
1601
+ throw new Error(`Account "${id}" disappeared while being reopened. Check the account state in Actual.`);
1602
+ }
1603
+ if (survivor.closed === true) {
1604
+ throw new Error(`Account "${survivor.name ?? id}" (${id}) is still closed after the reopen. The call ` +
1605
+ 'was accepted but had no effect; check the account state in Actual.');
1606
+ }
1442
1607
  });
1443
1608
  }
1444
1609
  export async function getCategoryGroups() {
@@ -1459,6 +1624,16 @@ export async function createCategoryGroup(group) {
1459
1624
  export async function updateCategoryGroup(id, fields) {
1460
1625
  observability.incrementToolCall('actual.category_groups.update').catch(() => { });
1461
1626
  return queueWriteOperation(async () => {
1627
+ // #360: `db.update` does not run a SQL UPDATE. It sends CRDT messages, and the apply
1628
+ // path INSERTs when the row was absent, so an unknown id CREATES a partial row rather
1629
+ // than matching nothing. Refuse first, the way updateTag and updateRule already do.
1630
+ // Called with no argument, upstream returns all groups including hidden ones, so a
1631
+ // hidden group is not misreported as missing (the same reason category_groups_delete
1632
+ // can rely on this listing).
1633
+ const groups = await withConcurrency(() => retry(() => rawGetCategoryGroups(), { retries: 2, backoffMs: 200 }));
1634
+ if (!(Array.isArray(groups) && groups.some((g) => g?.id === id))) {
1635
+ throw new Error(notFoundMsg('Category group', id, 'actual_category_groups_get'));
1636
+ }
1462
1637
  await withConcurrency(() => retry(() => rawUpdateCategoryGroup(id, fields), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
1463
1638
  });
1464
1639
  }
@@ -1598,16 +1773,38 @@ export async function batchBudgetUpdates(fn) {
1598
1773
  * `undefined` is deliberately NOT treated as a refusal: an older or future build
1599
1774
  * that returns nothing must keep working. Only an explicit `false` is a verdict.
1600
1775
  */
1776
+ /**
1777
+ * #355, at the adapter layer since #371.
1778
+ *
1779
+ * Upstream has TWO ways of doing less than asked, and only one of them is a boolean:
1780
+ *
1781
+ * holdForNextMonth: if (toBudget > 0) { ...; return true; } return false;
1782
+ * calcBufferedAmount: amount = Math.min(Math.max(amount, -buffered), Math.max(toBudget, 0));
1783
+ *
1784
+ * So `false` means nothing was held, and `true` can still mean a PARTIAL hold: ask for
1785
+ * 100.00 with 30.00 left to budget and 30.00 is held, silently. Reading `forNextMonth`
1786
+ * before and after settles both from observed state, which is the #347 principle and is
1787
+ * strictly better than trusting either return value.
1788
+ *
1789
+ * Returns the amount ACTUALLY held. The caller compares it with what it requested.
1790
+ */
1601
1791
  export async function holdBudgetForNextMonth(month, amount) {
1602
1792
  observability.incrementToolCall('actual.budgets.holdForNextMonth').catch(() => { });
1603
1793
  return queueWriteOperation(async () => {
1794
+ // `isRetryable` matters here, unlike most read sites: upstream's `api/budget-month` opens
1795
+ // with `validateMonth` and throws for an out-of-range month. Without a classifier,
1796
+ // retry() retries EVERY rejection (see retry.ts), so a deterministic domain error would
1797
+ // cost three upstream calls and 600ms of sleeping inside the api mutex, against the one
1798
+ // withOpTimeout budget this whole callback shares. #177 doctrine: domain errors are
1799
+ // terminal.
1800
+ const before = await withConcurrency(() => retry(() => rawGetBudgetMonth(month), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
1801
+ const heldBefore = Number(before?.forNextMonth ?? 0);
1604
1802
  // Non-idempotent: do not retry (#165). `calcBufferedAmount` is ADDITIVE
1605
1803
  // (`return buffered + amount`), so a second attempt after a committed first does not
1606
- // re-set the buffer, it adds to it. The window is narrow, because the call runs
1607
- // against local SQLite and no `TRANSIENT_ERROR_PATTERNS` message can arise inside
1608
- // it, but the operation is non-idempotent by construction and belongs on the same
1609
- // footing as `deleteRule` and `closeAccount`.
1610
- return await withConcurrency(() => retry(() => rawHoldBudgetForNextMonth(month, amount), { retries: 0, backoffMs: 200 }));
1804
+ // re-set the buffer, it adds to it.
1805
+ await withConcurrency(() => retry(() => rawHoldBudgetForNextMonth(month, amount), { retries: 0, backoffMs: 200 }));
1806
+ const after = await withConcurrency(() => retry(() => rawGetBudgetMonth(month), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
1807
+ return Number(after?.forNextMonth ?? 0) - heldBefore;
1611
1808
  });
1612
1809
  }
1613
1810
  export async function resetBudgetHold(month) {
@@ -1,9 +1,5 @@
1
1
  import { z } from 'zod';
2
2
  import adapter from '../lib/actual-adapter.js';
3
- import * as observability from '../observability.js';
4
- import api from '@actual-app/api';
5
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
6
- const { getAccounts: rawGetAccounts, getCategories: rawGetCategories, closeAccount: rawCloseAccount } = api;
7
3
  const InputSchema = z
8
4
  .object({
9
5
  id: z.string().min(1).max(64).describe('Account ID to close'),
@@ -33,40 +29,13 @@ const InputSchema = z
33
29
  }
34
30
  });
35
31
  /**
36
- * #357: three defects on one code path, fixed together because any one of them
37
- * rewrites this handler.
32
+ * #357. The three defects (silent no-op on unknown or already-closed, close-deletes a
33
+ * zero-transaction account, and the documented transfer arguments that were not exposed)
34
+ * are handled in `adapter.closeAccount`, which reads, writes and re-reads in one write-queue
35
+ * cycle and returns WHICH of the three outcomes happened.
38
36
  *
39
- * (a) FALSE SUCCESS. Upstream `closeAccount` opens with
40
- * `if (!account || account.closed === 1) { return; }`, so closing an unknown id
41
- * or an already-closed account did nothing and reported success. Same upstream
42
- * early-return that produced #347.
43
- *
44
- * (b) CLOSE CAN DELETE. `if (numTransactions === 0) { await db.deleteAccount({id}); }`.
45
- * An account with no transactions is TOMBSTONED rather than closed, it disappears
46
- * from every listing, and `actual_accounts_reopen` cannot bring it back. The old
47
- * description promised "their transaction history is preserved", which is false in
48
- * exactly this case. The repo already knew: tests/manual/tests/account.js adds a
49
- * dummy transaction with a comment explaining it.
50
- *
51
- * (c) A BALANCE MADE IT UNCLOSABLE. `transferAccountId` and `transferCategoryId` are
52
- * documented parameters, the schema did not accept them, and upstream throws
53
- * `balance is non-zero: transferAccountId is required` without them. The caller
54
- * was told to supply something no tool would take.
55
- *
56
- * The read, the write and the verification run inside ONE `withWriteSession` cycle
57
- * (#142). A read-BEFORE is required here, unlike #347's pure verify-after, because (b)
58
- * makes "absent afterwards" ambiguous: an account deleted by the close and an id that
59
- * never existed look identical from a single post-read.
60
- *
61
- * Going through the raw api inside the session means this path no longer gets the
62
- * adapter's `retry`. That is inherent to the #142 pattern, shared with every tool using
63
- * it, and it is the half of #368 that still stands. The per-tool counter is NOT lost:
64
- * it is incremented in the handler below.
65
- *
66
- * Already-closed is reported as success rather than as an error, deliberately. The
67
- * caller's intent ("this account should be closed") is already satisfied, which is the
68
- * same idempotence argument #347 used for delete. What changes is that the response no
69
- * longer CLAIMS a state change that did not happen.
37
+ * #371 moved that logic there from this file. This tool now owns only the published schema
38
+ * and the response wording, which is where the rest of the tool surface keeps them.
70
39
  */
71
40
  const tool = {
72
41
  name: 'actual_accounts_close',
@@ -78,95 +47,25 @@ const tool = {
78
47
  inputSchema: InputSchema,
79
48
  call: async (args, _meta) => {
80
49
  const input = InputSchema.parse(args || {});
81
- // #368: the adapter method that used to own this counter is no longer on the path,
82
- // because this tool reads and writes through the raw api inside one session. Counting
83
- // here keeps `actual.accounts.close` honest; the retry half of #368 still stands.
84
- observability.incrementToolCall('actual.accounts.close').catch(() => { });
85
- return await adapter.withWriteSession(async () => {
86
- const before = (await rawGetAccounts());
87
- const target = Array.isArray(before) ? before.find((a) => a?.id === input.id) : undefined;
88
- if (!target) {
89
- throw new Error(`Account "${input.id}" not found. Use actual_accounts_list to see the accounts that ` +
90
- 'exist. Note that an account closed while it had no transactions was REMOVED by ' +
91
- 'Actual, not closed, so it will not appear there.');
92
- }
93
- if (target.closed === true) {
94
- // Idempotent and truthful: the requested state already holds, and the response
95
- // says so instead of implying this call changed anything.
50
+ const result = await adapter.closeAccount(input.id, input.transferAccountId, input.transferCategoryId);
51
+ const name = result.name ?? input.id;
52
+ switch (result.outcome) {
53
+ case 'already-closed':
96
54
  return {
97
55
  success: true,
98
56
  alreadyClosed: true,
99
- message: `Account "${target.name ?? input.id}" was already closed. Nothing changed.`,
57
+ message: `Account "${name}" was already closed. Nothing changed.`,
100
58
  };
101
- }
102
- if (input.transferAccountId) {
103
- const destination = Array.isArray(before)
104
- ? before.find((a) => a?.id === input.transferAccountId)
105
- : undefined;
106
- if (!destination) {
107
- throw new Error(`Transfer destination account "${input.transferAccountId}" not found. Use ` +
108
- 'actual_accounts_list to pick an account to move the remaining balance to.');
109
- }
110
- if (destination.closed === true) {
111
- // The balancing transaction would land in a closed account, where it is hidden
112
- // from most views. Upstream does not stop this; refusing is the kinder answer.
113
- throw new Error(`Transfer destination account "${destination.name ?? input.transferAccountId}" is ` +
114
- 'CLOSED, so the closing balance would be moved somewhere hidden from most views. ' +
115
- 'Pick an open account, or reopen that one with actual_accounts_reopen first.');
116
- }
117
- }
118
- if (input.transferCategoryId) {
119
- // #359's lesson, applied to the only NEW write this change adds. Upstream forwards
120
- // this id straight into `transaction-add` without checking it (api.ts's
121
- // account-close passes it as `categoryId`, and addTransaction is a bare batch
122
- // update), so a bogus value writes a "Closing account" transaction carrying a
123
- // category that does not exist, and syncs it to every client. Same dangling
124
- // reference the transactions_create guard exists to prevent.
125
- const categories = (await rawGetCategories());
126
- const categoryExists = Array.isArray(categories)
127
- ? categories.some((c) => c?.id === input.transferCategoryId)
128
- : false;
129
- if (!categoryExists) {
130
- throw new Error(`Transfer category "${input.transferCategoryId}" not found. Use ` +
131
- 'actual_categories_get to pick a category for the closing transaction, or omit ' +
132
- 'transferCategoryId to leave it uncategorised.');
133
- }
134
- }
135
- try {
136
- await rawCloseAccount(input.id, input.transferAccountId, input.transferCategoryId);
137
- }
138
- catch (err) {
139
- const msg = err instanceof Error ? err.message : String(err);
140
- if (/balance is non-zero/i.test(msg)) {
141
- // Translate upstream's message into one the caller can act on, now that the
142
- // parameter it asks for actually exists on this tool.
143
- throw new Error(`Account "${target.name ?? input.id}" has a non-zero balance, so closing it requires ` +
144
- 'transferAccountId: the id of the account to move the remaining balance to. ' +
145
- 'Optionally pass transferCategoryId to categorise the balancing transaction. ' +
146
- 'Use actual_accounts_list to pick a destination.');
147
- }
148
- throw err;
149
- }
150
- // Verify the effect rather than the call returning (#347).
151
- const after = (await rawGetAccounts());
152
- const survivor = Array.isArray(after) ? after.find((a) => a?.id === input.id) : undefined;
153
- if (!survivor) {
154
- // Defect (b): upstream tombstones a zero-transaction account instead of closing
155
- // it. Saying so is the whole point; the old description claimed the opposite.
59
+ case 'removed':
156
60
  return {
157
61
  success: true,
158
62
  removed: true,
159
- message: `Account "${target.name ?? input.id}" had no transactions, so Actual REMOVED it ` +
160
- 'rather than closing it. It will not appear in actual_accounts_list and cannot be ' +
161
- 'reopened.',
63
+ message: `Account "${name}" had no transactions, so Actual REMOVED it rather than closing ` +
64
+ 'it. It will not appear in actual_accounts_list and cannot be reopened.',
162
65
  };
163
- }
164
- if (survivor.closed !== true) {
165
- throw new Error(`Account "${survivor.name ?? input.id}" (${input.id}) is still open after the close. ` +
166
- 'The call was accepted but had no effect; check the account state in Actual.');
167
- }
168
- return { success: true, closed: true };
169
- });
66
+ default:
67
+ return { success: true, closed: true };
68
+ }
170
69
  },
171
70
  };
172
71
  export default tool;
@@ -1,9 +1,5 @@
1
1
  import { z } from 'zod';
2
2
  import adapter from '../lib/actual-adapter.js';
3
- import * as observability from '../observability.js';
4
- import api from '@actual-app/api';
5
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
6
- const { getAccounts: rawGetAccounts, reopenAccount: rawReopenAccount } = api;
7
3
  const InputSchema = z.object({
8
4
  // Bounded, because the id is echoed into the error message and into the structured
9
5
  // logs. Not tightened to CommonSchemas.accountId (a UUID) here: several fixtures use
@@ -11,58 +7,13 @@ const InputSchema = z.object({
11
7
  id: z.string().min(1).max(64).describe('Account ID to reopen'),
12
8
  });
13
9
  /**
14
- * #358: this tool used to call reopen and return `{success: true}` unconditionally.
15
- * For an id that is not an account, that was not merely a false success: it CREATED
16
- * a nameless account.
10
+ * #358. The guard lives in `adapter.reopenAccount`, which reads, writes and re-reads inside
11
+ * one write-queue cycle. See that function for why a pre-check is right here and was wrong
12
+ * in #347.
17
13
  *
18
- * Verified in upstream source (26.8.0). `reopenAccount` is a bare update:
19
- *
20
- * async function reopenAccount({ id }) { await db.update('accounts', { id, closed: 0 }); }
21
- *
22
- * `db.update` does not run a SQL UPDATE. It sends CRDT messages, and `apply()` in
23
- * loot-core/src/server/sync/index.ts picks INSERT when the row was absent from the
24
- * pre-read:
25
- *
26
- * INSERT INTO accounts (id, closed) VALUES (?, ?)
27
- *
28
- * The accounts table has no NOT NULL columns and `tombstone` defaults to 0, while
29
- * `getAccounts()` selects `WHERE a.tombstone = 0`. So the phantom row is a fully
30
- * visible account with a null name, and because it is a CRDT message it SYNCS to
31
- * every other client on the budget.
32
- *
33
- * WHY A PRE-CHECK HERE, WHEN #347 DELIBERATELY REJECTED ONE.
34
- *
35
- * The two cases look identical and are not. In #347 the caller's intent ("make this
36
- * account not exist") could ALREADY be satisfied by an absent row, so refusing on
37
- * absence would have failed a request that was in fact complete. Here the caller's
38
- * intent ("reopen this account") cannot possibly be satisfied by an absent row, and
39
- * proceeding actively creates one. Refusing is the only correct answer.
40
- *
41
- * The post-verify is kept as well, so the success claim is about observed state
42
- * rather than about the call having returned. Read, write and re-read all run inside
43
- * ONE `withWriteSession` cycle (#142), which keeps them in a single api lock cycle
44
- * rather than three.
45
- *
46
- * What that does NOT give, stated precisely because an overstated guarantee gets relied
47
- * on: `processWriteQueue` dispatches a batch with `Promise.allSettled`, so operations
48
- * queued in the same drain window still interleave at await points. The single cycle
49
- * excludes other SESSIONS, not other operations in the same batch. A concurrent
50
- * close of the same account can therefore still land between this read and this write.
51
- *
52
- * TWO DELIBERATE LIMITS, recorded so they are not mistaken for oversights.
53
- *
54
- * The not-found message covers both reasons an id can be missing (never existed, or
55
- * removed by a close while it had no transactions) rather than distinguishing them.
56
- * `q().withDead()` would tell them apart by reading tombstoned rows, at the cost of an
57
- * extra query on the failure path. Collapsing them was chosen because the message names
58
- * both cases and the remedy is the same either way. If that proves confusing in
59
- * practice, `withDead()` is the tool to reach for.
60
- *
61
- * Going through the raw api inside the session means this path no longer gets the
62
- * adapter's `retry`. That is inherent to the #142 pattern, shared with every other tool
63
- * using it (`rules_delete`, `schedules_delete`, `category_groups_delete`), and it is the
64
- * half of #368 that still stands. The per-tool counter is NOT lost: it is incremented in
65
- * the handler below.
14
+ * #371 moved it there from this file. The read-then-write property never needed the raw
15
+ * api: doing it in the adapter keeps `retry` on the reads, keeps one observability call
16
+ * site, and leaves no unguarded `adapter.reopenAccount` for a future caller to reach for.
66
17
  */
67
18
  const tool = {
68
19
  name: 'actual_accounts_reopen',
@@ -74,32 +25,8 @@ const tool = {
74
25
  inputSchema: InputSchema,
75
26
  call: async (args, _meta) => {
76
27
  const input = InputSchema.parse(args || {});
77
- // #368: the adapter method that used to own this counter is no longer on the path,
78
- // because this tool reads and writes through the raw api inside one session. Counting
79
- // here keeps `actual.accounts.reopen` honest; the retry half of #368 still stands.
80
- observability.incrementToolCall('actual.accounts.reopen').catch(() => { });
81
- return await adapter.withWriteSession(async () => {
82
- const before = (await rawGetAccounts());
83
- const target = Array.isArray(before) ? before.find((a) => a?.id === input.id) : undefined;
84
- if (!target) {
85
- throw new Error(`Account "${input.id}" not found, so it cannot be reopened. Use actual_accounts_list to ` +
86
- 'see the accounts that exist. If this account was closed while it had no transactions, ' +
87
- 'Actual removed it rather than closing it, and it cannot be reopened: create a new one ' +
88
- 'with actual_accounts_create.');
89
- }
90
- await rawReopenAccount(input.id);
91
- // Verify the effect rather than the call returning. Same reasoning as #347.
92
- const after = (await rawGetAccounts());
93
- const survivor = Array.isArray(after) ? after.find((a) => a?.id === input.id) : undefined;
94
- if (!survivor) {
95
- throw new Error(`Account "${input.id}" disappeared while being reopened. Check the account state in Actual.`);
96
- }
97
- if (survivor.closed === true) {
98
- throw new Error(`Account "${survivor.name ?? input.id}" (${input.id}) is still closed after the reopen. ` +
99
- 'The call was accepted but had no effect; check the account state in Actual.');
100
- }
101
- return { success: true };
102
- });
28
+ await adapter.reopenAccount(input.id);
29
+ return { success: true };
103
30
  },
104
31
  };
105
32
  export default tool;
@@ -1,44 +1,22 @@
1
1
  import { z } from 'zod';
2
2
  import adapter from '../lib/actual-adapter.js';
3
- import * as observability from '../observability.js';
4
- import api from '@actual-app/api';
5
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
6
- const { getBudgetMonth: rawGetBudgetMonth, holdBudgetForNextMonth: rawHold } = api;
7
3
  const InputSchema = z.object({
8
4
  month: z.string().regex(/^\d{4}-(0[1-9]|1[0-2])$/, 'month must be in YYYY-MM format').describe('Budget month in YYYY-MM format'),
9
5
  // #355: upstream rejects a non-positive amount with
10
6
  // APIError('Amount to hold needs to be greater than 0') BEFORE it ever reaches
11
7
  // holdForNextMonth, so the schema rejects it here instead: same answer, no wasted
12
- // round trip, and the message names the field. No working client can be sending a
13
- // value upstream already refuses.
8
+ // round trip, and the message names the field.
14
9
  amount: z.number().int().positive('amount must be greater than 0 (integer cents)')
15
10
  .describe('Amount in cents to hold for next month. Must be greater than 0.'),
16
11
  });
17
12
  /**
18
- * #355: report the amount actually held, not the fact that the call returned.
13
+ * #355. `adapter.holdBudgetForNextMonth` reads `forNextMonth` before and after the write and
14
+ * returns the amount ACTUALLY held, because upstream can do less than asked in two ways: it
15
+ * holds nothing when the month has no positive To Budget, and it silently CLAMPS the hold to
16
+ * whatever is left. A boolean catches the first and misses the second.
19
17
  *
20
- * There are TWO ways this operation quietly does less than asked, and a boolean only
21
- * catches one of them.
22
- *
23
- * upstream holdForNextMonth:
24
- * if (toBudget > 0) { await setBuffer(month, calcBufferedAmount(...)); return true; }
25
- * return false;
26
- *
27
- * calcBufferedAmount:
28
- * amount = Math.min(Math.max(amount, -buffered), Math.max(toBudget, 0));
29
- *
30
- * So `false` means nothing was held, and `true` can still mean a PARTIAL hold: ask for
31
- * 100.00 when only 30.00 is left to budget and 30.00 is held, silently, with a `true`.
32
- * Reporting plain success for that is the same defect in a different costume.
33
- *
34
- * Reading `forNextMonth` (the buffered amount) before and after settles both cases from
35
- * observed state rather than from a return value, which is the #347 principle. Both
36
- * reads and the write share ONE `withWriteSession` cycle (#142).
37
- *
38
- * Cost and trade-off, recorded: two extra reads on a low-frequency budget tool, and the
39
- * raw-call path forgoes the adapter's retry, which is inherent to the #142 pattern and is
40
- * the half of #368 that still stands. The per-tool counter is NOT lost: it is incremented
41
- * in the handler below.
18
+ * #371 moved that logic into the adapter from this file. This tool owns the schema and the
19
+ * wording of the three answers: held in full, held in part, held nothing.
42
20
  */
43
21
  const tool = {
44
22
  name: 'actual_budgets_holdForNextMonth',
@@ -50,34 +28,23 @@ const tool = {
50
28
  inputSchema: InputSchema,
51
29
  call: async (args, _meta) => {
52
30
  const input = InputSchema.parse(args || {});
53
- // #368: the adapter method that used to own this counter is no longer on the path,
54
- // because this tool reads and writes through the raw api inside one session. Counting
55
- // here keeps `actual.budgets.holdForNextMonth` honest; the retry half of #368 stands.
56
- observability.incrementToolCall('actual.budgets.holdForNextMonth').catch(() => { });
57
- return await adapter.withWriteSession(async () => {
58
- const before = (await rawGetBudgetMonth(input.month));
59
- const heldBefore = Number(before?.forNextMonth ?? 0);
60
- await rawHold(input.month, input.amount);
61
- const after = (await rawGetBudgetMonth(input.month));
62
- const heldAfter = Number(after?.forNextMonth ?? 0);
63
- const held = heldAfter - heldBefore;
64
- if (held <= 0) {
65
- throw new Error(`Nothing was held for ${input.month}: that month has no positive amount left to budget, ` +
66
- 'so there is nothing to carry into next month. Budget less to categories in that month, ' +
67
- 'or check it with actual_budgets_getMonth first.');
68
- }
69
- if (held < input.amount) {
70
- return {
71
- success: true,
72
- partial: true,
73
- requested: input.amount,
74
- held,
75
- message: `Held ${held} of the requested ${input.amount} cents for ${input.month}. Actual clamps ` +
76
- 'the hold to the amount still left to budget in that month.',
77
- };
78
- }
79
- return { success: true, held };
80
- });
31
+ const held = await adapter.holdBudgetForNextMonth(input.month, input.amount);
32
+ if (held <= 0) {
33
+ throw new Error(`Nothing was held for ${input.month}: that month has no positive amount left to budget, ` +
34
+ 'so there is nothing to carry into next month. Budget less to categories in that month, ' +
35
+ 'or check it with actual_budgets_getMonth first.');
36
+ }
37
+ if (held < input.amount) {
38
+ return {
39
+ success: true,
40
+ partial: true,
41
+ requested: input.amount,
42
+ held,
43
+ message: `Held ${held} of the requested ${input.amount} cents for ${input.month}. Actual clamps ` +
44
+ 'the hold to the amount still left to budget in that month.',
45
+ };
46
+ }
47
+ return { success: true, held };
81
48
  },
82
49
  };
83
50
  export default tool;
@@ -1,9 +1,19 @@
1
1
  import { z } from 'zod';
2
2
  import adapter from '../lib/actual-adapter.js';
3
- const InputSchema = z.object({ month: z.string().min(1), categoryId: z.string().min(1), amount: z.number() });
3
+ const InputSchema = z.object({
4
+ // #361: this was a bare z.string().min(1), so 'banana' parsed and reached the API. The
5
+ // regex matches actual_budgets_holdForNextMonth and the other month-taking tools; the
6
+ // adapter additionally checks the month is one this budget actually has.
7
+ month: z.string().regex(/^\d{4}-(0[1-9]|1[0-2])$/, 'month must be in YYYY-MM format'),
8
+ categoryId: z.string().min(1).max(64),
9
+ amount: z.number().int('amount must be an integer (cents)'),
10
+ });
4
11
  const tool = {
5
12
  name: 'actual_budgets_setAmount',
6
- description: "Set the budgeted amount for a specific category in a given month. Amount in cents (e.g., 50000 = $500). Use this to allocate money to spending categories for budget planning.",
13
+ description: 'Set the budgeted amount for a specific category in a given month. Amount in cents ' +
14
+ '(e.g., 50000 = $500). Use this to allocate money to spending categories for budget planning. ' +
15
+ 'The month must be one the budget actually has: Actual allows from three months before the ' +
16
+ 'earliest transaction to twelve months ahead. Use actual_budgets_getMonths to see the range.',
7
17
  inputSchema: InputSchema,
8
18
  call: async (args, _meta) => {
9
19
  const input = InputSchema.parse(args || {});
@@ -13,10 +23,20 @@ const tool = {
13
23
  }
14
24
  catch (error) {
15
25
  const msg = error instanceof Error ? error.message : String(error);
16
- // The pre-flight in adapter.setBudgetAmount throws:
17
- // Category "${categoryId}" not found. Use actual_categories_get to list available categories.
18
- // That string always contains BOTH "not found" AND "category", so && is the correct operator.
19
- if (msg.toLowerCase().includes('not found') && msg.toLowerCase().includes('category')) {
26
+ const lower = msg.toLowerCase();
27
+ // Both PRE-FLIGHT refusals from adapter.setBudgetAmount return the same structured
28
+ // shape, so a caller can handle "you asked for something that does not exist" once
29
+ // rather than parsing two forms:
30
+ //
31
+ // Category "<id>" not found. Use actual_categories_get ... (#89)
32
+ // Month "<month>" is not in this budget. It runs from X to Y ... (#361)
33
+ //
34
+ // #361 added the second one, and before this it fell through to the rethrow below,
35
+ // so one tool answered two identical situations in two different shapes. Anything
36
+ // else (a genuine upstream or transport failure) still throws.
37
+ const isCategoryRefusal = lower.includes('not found') && lower.includes('category');
38
+ const isMonthRefusal = lower.includes('not in this budget');
39
+ if (isCategoryRefusal || isMonthRefusal) {
20
40
  return { success: false, error: msg };
21
41
  }
22
42
  throw new Error(`Failed to set budget amount: ${msg}`);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "actual-mcp-server",
3
3
  "displayName": "Actual MCP Server",
4
- "version": "0.12.0",
4
+ "version": "0.13.0",
5
5
  "engines": {
6
6
  "node": ">=22.0.0",
7
7
  "npm": ">=10.0.0"
@@ -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/write_effect_audit_staleness.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_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/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/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/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/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/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/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",
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/write_effect_audit_staleness.test.js && node tests/unit/write_effect_audit_membership.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_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/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/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/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/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/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",
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",