actual-mcp-server 0.11.6 → 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
- | `actual_accounts_close` | Close account (soft delete) |
318
- | `actual_accounts_reopen` | Reopen closed account |
317
+ | `actual_accounts_close` | Close account. An account with NO transactions is REMOVED by Actual, not closed |
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,4 +774,10 @@ The software is provided **as-is**, without warranty of any kind. The author acc
774
774
 
775
775
  ---
776
776
 
777
- **Version:** 0.11.6 | **Tool Count:** 74 (verified LibreChat-compatible)
777
+ **Version:** 0.13.0 | **Tool Count:** 74 (verified LibreChat-compatible)
778
+
779
+ ## Sponsor
780
+
781
+ I build and maintain this in my own time. It is free, it stays free, and it gets maintained either way.
782
+
783
+ If it saved you some time and you feel like saying thanks, you can do that at [github.com/sponsors/agigante80](https://github.com/sponsors/agigante80). Entirely optional, and nothing about the project changes either way.
@@ -816,6 +816,27 @@ export async function addTransactions(txs, options = {}) {
816
816
  if (!accountId) {
817
817
  throw new Error('Transaction must include account or accountId');
818
818
  }
819
+ // #359: upstream never validates the account. `addTransactions`
820
+ // (loot-core/src/server/accounts/sync.ts) takes acctId, normalises, runs rules and
821
+ // inserts, and `api/transactions-add` returns the string 'ok' unconditionally. Rule
822
+ // evaluation does not rescue us either: prepareTransactionForRules resolves the
823
+ // account with `r._account?.name || ''`. So a bogus account id produced rows with a
824
+ // dangling `account` column that NO listing tool can return (they all filter by
825
+ // account), that sync to every other client, and the tool reported success.
826
+ //
827
+ // This is the same guard `createTransfer` has performed since it was written, using
828
+ // the same single accounts read inside the same write cycle. A CLOSED account is
829
+ // deliberately still allowed: importing history into a closed account is legitimate,
830
+ // which is why this diverges from createTransfer, where a closed account would be an
831
+ // odd transfer destination.
832
+ const accounts = await withConcurrency(() => retry(() => rawGetAccounts(), { retries: 2, backoffMs: 200 }));
833
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
834
+ const accountExists = accounts.some((a) => a?.id === accountId);
835
+ if (!accountExists) {
836
+ throw new Error(`Account "${accountId}" not found. Use actual_accounts_list to find valid accounts. ` +
837
+ 'No transactions were created: Actual would otherwise have written them against an ' +
838
+ 'account that does not exist, where no tool could retrieve them.');
839
+ }
819
840
  // Remove account/accountId from transaction objects as they're passed separately
820
841
  const cleanedTxs = txArray.map(tx => {
821
842
  const { account, accountId: _, ...rest } = tx;
@@ -978,6 +999,21 @@ export async function setBudgetAmount(month, categoryId, amount) {
978
999
  if (!exists) {
979
1000
  throw new Error(`Category "${categoryId}" not found. Use actual_categories_get to list available categories.`);
980
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
+ }
981
1017
  const result = await withConcurrency(() => retry(() => rawSetBudgetAmount(month, categoryId, amount), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
982
1018
  return result;
983
1019
  });
@@ -1031,6 +1067,15 @@ export async function createAccount(account, initialBalance) {
1031
1067
  export async function updateAccount(id, fields) {
1032
1068
  observability.incrementToolCall('actual.accounts.update').catch(() => { });
1033
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
+ }
1034
1079
  await withConcurrency(() => retry(() => rawUpdateAccount(id, fields), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
1035
1080
  return null;
1036
1081
  });
@@ -1192,6 +1237,15 @@ export async function deleteTransaction(id) {
1192
1237
  export async function updateCategory(id, fields) {
1193
1238
  observability.incrementToolCall('actual.categories.update').catch(() => { });
1194
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
+ }
1195
1249
  await withConcurrency(() => retry(() => rawUpdateCategory(id, fields), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
1196
1250
  });
1197
1251
  }
@@ -1210,6 +1264,20 @@ export async function deleteCategory(id) {
1210
1264
  export async function updatePayee(id, fields) {
1211
1265
  observability.incrementToolCall('actual.payees.update').catch(() => { });
1212
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
+ }
1213
1281
  const fieldsObj = fields;
1214
1282
  // Extract `category` — it is NOT a direct column on the payees table in Actual Budget.
1215
1283
  // The "default category" feature is implemented via rules (condition: payee is X → action: set category).
@@ -1267,10 +1335,26 @@ export async function deletePayee(id) {
1267
1335
  return queueWriteOperation(async () => {
1268
1336
  // Pre-flight: verify payee exists to avoid ECONNRESET on missing id (BUG-2)
1269
1337
  const payees = await withConcurrency(() => retry(() => rawGetPayees(), { retries: 2, backoffMs: 200 }));
1270
- const exists = payees.some((p) => p.id === id);
1338
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1339
+ const found = payees.find((p) => p.id === id);
1340
+ const exists = Boolean(found);
1271
1341
  if (!exists) {
1272
1342
  throw new Error(`Payee "${id}" not found. Use actual_payees_get to list available payees.`);
1273
1343
  }
1344
+ // #356: the existence check above is not enough. Actual refuses to delete a
1345
+ // TRANSFER payee (the payee it auto-creates for each account) and refuses
1346
+ // SILENTLY: `db.deletePayee` opens with `if (transfer_acct) { return; }`, so the
1347
+ // call returns normally having done nothing and the tool reported success.
1348
+ //
1349
+ // `getPayees()` deliberately INCLUDES transfer payees, which is why they reach
1350
+ // this point at all, and it selects `COALESCE(a.name, p.name) AS name`, so a
1351
+ // transfer payee's own `name` is already the owning account's name. No second
1352
+ // lookup is needed to write a useful message.
1353
+ if (found?.transfer_acct) {
1354
+ throw new Error(`Payee "${found.name ?? id}" is a TRANSFER payee: it belongs to the account of the ` +
1355
+ 'same name and Actual will not delete it on its own. Delete the account instead ' +
1356
+ 'with actual_accounts_delete, which removes its transfer payee too.');
1357
+ }
1274
1358
  await withConcurrency(() => retry(() => rawDeletePayee(id), { retries: 0, backoffMs: 200 }));
1275
1359
  });
1276
1360
  }
@@ -1325,10 +1409,24 @@ export async function updateRule(id, fields) {
1325
1409
  logger.debug(`[UPDATE RULE] Update completed for rule ${id}`);
1326
1410
  });
1327
1411
  }
1412
+ /**
1413
+ * #355: RETURNS the upstream verdict instead of discarding it, for the same reason
1414
+ * as `holdBudgetForNextMonth` above. Upstream `deleteRule`
1415
+ * (loot-core/src/server/transactions/transaction-rules.ts) returns `false` without
1416
+ * throwing when a schedule owns the rule, because Actual keeps a schedule and its
1417
+ * generated rule in step and will not let the rule be removed on its own.
1418
+ *
1419
+ * `actual_rules_delete` does not route through here (it calls the raw API inside one
1420
+ * `withWriteSession` cycle, per #142), so this method currently has NO caller, and the
1421
+ * same is true of `holdBudgetForNextMonth` below since #355 moved that tool to the same
1422
+ * pattern. Both are kept, and both carry the verdict, so that the next caller inherits
1423
+ * the correct contract instead of rediscovering this the hard way. Neither widening is
1424
+ * exercised by a test today, for the same reason: nothing calls them.
1425
+ */
1328
1426
  export async function deleteRule(id) {
1329
1427
  observability.incrementToolCall('actual.rules.delete').catch(() => { });
1330
1428
  return queueWriteOperation(async () => {
1331
- await withConcurrency(() => retry(() => rawDeleteRule(id), { retries: 0, backoffMs: 200 }));
1429
+ return await withConcurrency(() => retry(() => rawDeleteRule(id), { retries: 0, backoffMs: 200 }));
1332
1430
  });
1333
1431
  }
1334
1432
  export async function getSchedules() {
@@ -1366,17 +1464,146 @@ export async function setBudgetCarryover(month, categoryId, flag) {
1366
1464
  await withConcurrency(() => retry(() => rawSetBudgetCarryover(month, categoryId, flag), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
1367
1465
  });
1368
1466
  }
1369
- export async function closeAccount(id) {
1467
+ /**
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:
1472
+ *
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.
1479
+ *
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.
1490
+ */
1491
+ export async function closeAccount(id, transferAccountId, transferCategoryId) {
1370
1492
  observability.incrementToolCall('actual.accounts.close').catch(() => { });
1371
1493
  return queueWriteOperation(async () => {
1372
- // Non-idempotent: do not retry (#165).
1373
- await withConcurrency(() => retry(() => rawCloseAccount(id), { 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 };
1374
1556
  });
1375
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
+ */
1376
1586
  export async function reopenAccount(id) {
1377
1587
  observability.incrementToolCall('actual.accounts.reopen').catch(() => { });
1378
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
+ }
1379
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
+ }
1380
1607
  });
1381
1608
  }
1382
1609
  export async function getCategoryGroups() {
@@ -1397,6 +1624,16 @@ export async function createCategoryGroup(group) {
1397
1624
  export async function updateCategoryGroup(id, fields) {
1398
1625
  observability.incrementToolCall('actual.category_groups.update').catch(() => { });
1399
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
+ }
1400
1637
  await withConcurrency(() => retry(() => rawUpdateCategoryGroup(id, fields), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
1401
1638
  });
1402
1639
  }
@@ -1407,12 +1644,72 @@ export async function deleteCategoryGroup(id) {
1407
1644
  await withConcurrency(() => retry(() => rawDeleteCategoryGroup(id), { retries: 0, backoffMs: 200 }));
1408
1645
  });
1409
1646
  }
1647
+ /**
1648
+ * #356: bound what an error message echoes back. The caller supplies these ids, they end
1649
+ * up in the tool response AND in `logger.error` via actualToolsManager, and `mergeIds`
1650
+ * accepts up to 50 of them at 64 characters each. Naming a handful is enough to act on;
1651
+ * naming all of them is an unbounded echo of caller-controlled input.
1652
+ */
1653
+ function summariseIds(ids, limit = 5) {
1654
+ const shown = ids.slice(0, limit).map((id) => (id.length > 64 ? `${id.slice(0, 64)}...` : id));
1655
+ const rest = ids.length - shown.length;
1656
+ return rest > 0 ? `${shown.join(', ')} (and ${rest} more)` : shown.join(', ');
1657
+ }
1658
+ /**
1659
+ * #356: merge, with the pre-flight it never had.
1660
+ *
1661
+ * Upstream `db.mergePayees` fails silently in two ways and throws unhelpfully in a
1662
+ * third:
1663
+ *
1664
+ * if (payees[target].transfer_acct != null) { return; } // silent no-op
1665
+ * ids = ids.filter(id => payees[id].transfer_acct == null); // sources dropped
1666
+ *
1667
+ * A transfer-payee target makes the whole call a no-op; transfer-payee sources are
1668
+ * dropped from the list without a word, while the tool still reported "merged N
1669
+ * payee(s)" using the length of its own INPUT. And an id that does not exist at all
1670
+ * is indexed straight off the map, so it surfaces as
1671
+ * `TypeError: Cannot read properties of undefined (reading 'transfer_acct')`.
1672
+ *
1673
+ * One `getPayees()` read inside this same write cycle answers all three. It returns the
1674
+ * de-duplicated ids it ACCEPTED for merge, which after those three refusals is the set
1675
+ * upstream will act on. Note what it is not: there is no post-write read here, unlike
1676
+ * close, reopen and hold, so a drop reason nobody has enumerated would still go unnoticed.
1677
+ */
1410
1678
  export async function mergePayees(targetId, mergeIds) {
1411
1679
  observability.incrementToolCall('actual.payees.merge').catch(() => { });
1412
1680
  return queueWriteOperation(async () => {
1681
+ const payees = await withConcurrency(() => retry(() => rawGetPayees(), { retries: 2, backoffMs: 200 }));
1682
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1683
+ const byId = new Map(payees.map((p) => [p.id, p]));
1684
+ const target = byId.get(targetId);
1685
+ if (!target) {
1686
+ throw new Error(`Payee "${targetId}" not found. Use actual_payees_get to list available payees.`);
1687
+ }
1688
+ if (target.transfer_acct) {
1689
+ throw new Error(`Target payee "${target.name ?? targetId}" is a TRANSFER payee and cannot be merged ` +
1690
+ 'into. Actual silently ignores such a merge. Pick a normal payee as the target.');
1691
+ }
1692
+ const missing = mergeIds.filter((id) => !byId.has(id));
1693
+ if (missing.length > 0) {
1694
+ throw new Error(`Payee(s) not found: ${summariseIds(missing)}. Use actual_payees_get to list available payees.`);
1695
+ }
1696
+ const transfers = mergeIds.filter((id) => byId.get(id)?.transfer_acct);
1697
+ if (transfers.length > 0) {
1698
+ const names = transfers.map((id) => String(byId.get(id)?.name ?? id));
1699
+ throw new Error(`Cannot merge TRANSFER payee(s): ${summariseIds(names)}. They belong to accounts of the ` +
1700
+ 'same name, and Actual silently drops them from a merge rather than merging them. ' +
1701
+ 'Remove them from mergeIds, or delete the account with actual_accounts_delete.');
1702
+ }
1703
+ if (mergeIds.includes(targetId)) {
1704
+ throw new Error(`Payee "${targetId}" appears as both the merge target and a merge source.`);
1705
+ }
1706
+ // De-duplicate before reporting, or `['p','p']` claims two merges for one. The whole
1707
+ // point of returning ids is that the count describes what happened.
1708
+ const unique = [...new Set(mergeIds)];
1413
1709
  // Non-idempotent: do not retry (#165). A second merge against an
1414
1710
  // already-removed source payee can corrupt merge state or mislead.
1415
- await withConcurrency(() => retry(() => rawMergePayees(targetId, mergeIds), { retries: 0, backoffMs: 200 }));
1711
+ await withConcurrency(() => retry(() => rawMergePayees(targetId, unique), { retries: 0, backoffMs: 200 }));
1712
+ return unique;
1416
1713
  });
1417
1714
  }
1418
1715
  /**
@@ -1463,10 +1760,51 @@ export async function batchBudgetUpdates(fn) {
1463
1760
  await withConcurrency(() => retry(() => rawBatchBudgetUpdates(fn), { retries: 2, backoffMs: 200 }));
1464
1761
  });
1465
1762
  }
1763
+ /**
1764
+ * #355: RETURNS the upstream verdict instead of discarding it.
1765
+ *
1766
+ * Upstream `holdForNextMonth` (loot-core/src/server/budget/actions.ts) returns a
1767
+ * BOOLEAN: `true` when it buffered the amount, `false` when the month's To Budget
1768
+ * is not positive and it held nothing. It does not throw in the second case. The
1769
+ * published reference documents the method as `Promise<null>`, which is why the
1770
+ * value was being thrown away here and the tool reported success for a hold that
1771
+ * never happened (CWE-252, unchecked return value).
1772
+ *
1773
+ * `undefined` is deliberately NOT treated as a refusal: an older or future build
1774
+ * that returns nothing must keep working. Only an explicit `false` is a verdict.
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
+ */
1466
1791
  export async function holdBudgetForNextMonth(month, amount) {
1467
1792
  observability.incrementToolCall('actual.budgets.holdForNextMonth').catch(() => { });
1468
1793
  return queueWriteOperation(async () => {
1469
- await withConcurrency(() => retry(() => rawHoldBudgetForNextMonth(month, amount), { retries: 2, backoffMs: 200 }));
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);
1802
+ // Non-idempotent: do not retry (#165). `calcBufferedAmount` is ADDITIVE
1803
+ // (`return buffered + amount`), so a second attempt after a committed first does not
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;
1470
1808
  });
1471
1809
  }
1472
1810
  export async function resetBudgetHold(month) {
@@ -1,16 +1,71 @@
1
1
  import { z } from 'zod';
2
2
  import adapter from '../lib/actual-adapter.js';
3
- const InputSchema = z.object({
4
- id: z.string().describe('Account ID to close'),
3
+ const InputSchema = z
4
+ .object({
5
+ id: z.string().min(1).max(64).describe('Account ID to close'),
6
+ // #357: documented by the Actual API reference as required when the account's
7
+ // balance is non-zero, and previously not exposed at all, which made such an
8
+ // account impossible to close through this server.
9
+ transferAccountId: z
10
+ .string()
11
+ .min(1)
12
+ .max(64)
13
+ .optional()
14
+ .describe('Account ID to move the remaining balance to. REQUIRED when the account has a non-zero balance.'),
15
+ transferCategoryId: z
16
+ .string()
17
+ .min(1)
18
+ .max(64)
19
+ .optional()
20
+ .describe('Category to assign the balancing transaction to. Optional, used with transferAccountId.'),
21
+ })
22
+ .superRefine((val, ctx) => {
23
+ if (val.transferAccountId && val.transferAccountId === val.id) {
24
+ ctx.addIssue({
25
+ code: z.ZodIssueCode.custom,
26
+ path: ['transferAccountId'],
27
+ message: 'transferAccountId must be a different account from the one being closed',
28
+ });
29
+ }
5
30
  });
31
+ /**
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.
36
+ *
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.
39
+ */
6
40
  const tool = {
7
41
  name: 'actual_accounts_close',
8
- description: `Mark an account as closed in Actual Budget. Closed accounts are hidden from most views but their transaction history is preserved. Useful for accounts that are no longer active but you want to keep the historical data.`,
42
+ description: 'Mark an account as closed in Actual Budget. Closed accounts are hidden from most views. ' +
43
+ 'IMPORTANT: an account with NO transactions is REMOVED by Actual rather than closed, and cannot ' +
44
+ 'be reopened; an account that has transactions keeps them. If the account has a non-zero balance, ' +
45
+ 'transferAccountId is required and the remaining balance is moved there as a dated "Closing account" ' +
46
+ 'transaction. Closing an already-closed account reports that rather than claiming a change.',
9
47
  inputSchema: InputSchema,
10
48
  call: async (args, _meta) => {
11
49
  const input = InputSchema.parse(args || {});
12
- await adapter.closeAccount(input.id);
13
- return { success: true };
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':
54
+ return {
55
+ success: true,
56
+ alreadyClosed: true,
57
+ message: `Account "${name}" was already closed. Nothing changed.`,
58
+ };
59
+ case 'removed':
60
+ return {
61
+ success: true,
62
+ removed: true,
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.',
65
+ };
66
+ default:
67
+ return { success: true, closed: true };
68
+ }
14
69
  },
15
70
  };
16
71
  export default tool;
@@ -1,11 +1,27 @@
1
1
  import { z } from 'zod';
2
2
  import adapter from '../lib/actual-adapter.js';
3
3
  const InputSchema = z.object({
4
- id: z.string().describe('Account ID to reopen'),
4
+ // Bounded, because the id is echoed into the error message and into the structured
5
+ // logs. Not tightened to CommonSchemas.accountId (a UUID) here: several fixtures use
6
+ // short non-UUID ids, and that sweep is tracked separately alongside #365.
7
+ id: z.string().min(1).max(64).describe('Account ID to reopen'),
5
8
  });
9
+ /**
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.
13
+ *
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.
17
+ */
6
18
  const tool = {
7
19
  name: 'actual_accounts_reopen',
8
- description: `Reopen a previously closed account in Actual Budget. The account will become active again and visible in all views. All historical transactions remain intact.`,
20
+ description: 'Reopen a previously closed account in Actual Budget. The account becomes active again and ' +
21
+ 'visible in all views, and all historical transactions remain intact. An id that is not an ' +
22
+ 'account is refused: Actual would otherwise create an empty account under that id. Note that ' +
23
+ 'an account closed while it had NO transactions was removed by Actual rather than closed, and ' +
24
+ 'cannot be reopened.',
9
25
  inputSchema: InputSchema,
10
26
  call: async (args, _meta) => {
11
27
  const input = InputSchema.parse(args || {});
@@ -2,18 +2,49 @@ import { z } from 'zod';
2
2
  import adapter from '../lib/actual-adapter.js';
3
3
  const InputSchema = z.object({
4
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'),
5
- amount: z.number().int().describe('Amount in cents to hold for next month'),
5
+ // #355: upstream rejects a non-positive amount with
6
+ // APIError('Amount to hold needs to be greater than 0') BEFORE it ever reaches
7
+ // holdForNextMonth, so the schema rejects it here instead: same answer, no wasted
8
+ // round trip, and the message names the field.
9
+ amount: z.number().int().positive('amount must be greater than 0 (integer cents)')
10
+ .describe('Amount in cents to hold for next month. Must be greater than 0.'),
6
11
  });
12
+ /**
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.
17
+ *
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.
20
+ */
7
21
  const tool = {
8
22
  name: 'actual_budgets_holdForNextMonth',
9
- description: `Hold an amount from this month's budget to carry into next month. The amount is in cents. This moves money from the current month's available budget into next month.
10
-
11
- Note: This operates on the month as a whole, not on a specific category.`,
23
+ description: "Hold an amount from this month's budget to carry into next month. The amount is in cents. " +
24
+ 'Actual can hold LESS than requested: it clamps the hold to the amount still left to budget, ' +
25
+ 'and holds nothing at all when that is zero or negative. The response reports the amount ' +
26
+ 'actually held and flags a partial hold, rather than reporting plain success.\n\n' +
27
+ 'Note: This operates on the month as a whole, not on a specific category.',
12
28
  inputSchema: InputSchema,
13
29
  call: async (args, _meta) => {
14
30
  const input = InputSchema.parse(args || {});
15
- await adapter.holdBudgetForNextMonth(input.month, input.amount);
16
- return { success: true };
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 };
17
48
  },
18
49
  };
19
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}`);
@@ -1,11 +1,14 @@
1
1
  import { z } from 'zod';
2
2
  import adapter from '../lib/actual-adapter.js';
3
3
  const InputSchema = z.object({
4
- id: z.string().describe('Payee ID to delete'),
4
+ // #356: bounded, because the id is echoed into the error message and the logs.
5
+ id: z.string().min(1).max(64).describe('Payee ID to delete'),
5
6
  });
6
7
  const tool = {
7
8
  name: 'actual_payees_delete',
8
- description: 'Delete a payee from Actual Budget. Transactions using this payee will have it removed. This operation cannot be undone.',
9
+ description: 'Delete a payee from Actual Budget. Transactions using this payee will have it removed. This ' +
10
+ 'operation cannot be undone. A TRANSFER payee (the payee Actual creates for each account, named ' +
11
+ 'after it) cannot be deleted on its own: the call is refused and points at actual_accounts_delete.',
9
12
  inputSchema: InputSchema,
10
13
  call: async (args, _meta) => {
11
14
  const input = InputSchema.parse(args || {});
@@ -1,17 +1,53 @@
1
1
  import { z } from 'zod';
2
2
  import adapter from '../lib/actual-adapter.js';
3
+ // #356: bounded on both axes. The ids are echoed back in error messages and into the
4
+ // structured logs, so an unbounded string or array is an unbounded echo. Actual's ids
5
+ // are UUIDs, so 64 characters is already generous, and no legitimate merge consolidates
6
+ // more than a few hundred payees in one call.
7
+ const PayeeIdSchema = z.string().min(1).max(64);
3
8
  const InputSchema = z.object({
4
- targetId: z.string().describe('ID of the target payee to merge into (this payee will be retained)'),
5
- mergeIds: z.array(z.string()).describe('Array of payee IDs to merge into the target payee (these will be consolidated)'),
9
+ targetId: PayeeIdSchema.describe('ID of the target payee to merge into (this payee will be retained)'),
10
+ mergeIds: z.array(PayeeIdSchema).min(1).max(50)
11
+ .describe('Array of payee IDs to merge into the target payee (these will be consolidated)'),
6
12
  });
7
13
  const tool = {
8
14
  name: 'actual_payees_merge',
9
- description: `Merge one or more payees into a target payee. This consolidates duplicate payees by merging the specified payees into the target, retaining the name of the target payee. All transactions from merged payees will be reassigned to the target payee.`,
15
+ description: 'Merge one or more payees into a target payee. This consolidates duplicate payees by merging ' +
16
+ 'the specified payees into the target, retaining the name of the target payee. All transactions ' +
17
+ 'from merged payees are reassigned to the target. TRANSFER payees (the payee Actual creates for ' +
18
+ 'each account) cannot be merged in either direction: such a merge is refused rather than ' +
19
+ 'silently ignored. The response lists the de-duplicated ids that were ACCEPTED for merge ' +
20
+ 'after those checks; it is not a post-write read, so verify with actual_payees_get if you ' +
21
+ 'need proof the payees are gone.',
10
22
  inputSchema: InputSchema,
11
23
  call: async (args, _meta) => {
12
24
  const input = InputSchema.parse(args || {});
13
- await adapter.mergePayees(input.targetId, input.mergeIds);
14
- return { success: true, message: `Successfully merged ${input.mergeIds.length} payee(s) into target payee` };
25
+ // #356: report what HAPPENED, not what was asked for. The old message counted
26
+ // `input.mergeIds.length`, which is a statement about the caller's input: it said
27
+ // "merged 3 payees" even when Actual had silently dropped every one of them for
28
+ // being a transfer payee. The adapter now refuses that case outright and returns
29
+ // the ids it actually merged.
30
+ // The adapter returns the ids it merged. If a future change ever made it return
31
+ // something else, falling back to `input.mergeIds` would silently reinstate exactly
32
+ // the "report the request, not the result" bug this ticket removed, so fail loudly
33
+ // instead.
34
+ const merged = await adapter.mergePayees(input.targetId, input.mergeIds);
35
+ if (!Array.isArray(merged)) {
36
+ throw new Error('Internal: adapter.mergePayees did not report which payees were merged. Verify the ' +
37
+ 'merge with actual_payees_get before assuming it happened.');
38
+ }
39
+ const mergedIds = merged;
40
+ // #356: `mergedIds` carries the full list for a caller that wants it; the human
41
+ // readable message names at most five, because up to 50 ids at 64 characters each
42
+ // is an unbounded echo into the response and the logs.
43
+ const shown = mergedIds.slice(0, 5).join(', ');
44
+ const rest = mergedIds.length - Math.min(mergedIds.length, 5);
45
+ return {
46
+ success: true,
47
+ mergedIds,
48
+ message: `Merged ${mergedIds.length} payee(s) into ${input.targetId}: ${shown}` +
49
+ (rest > 0 ? ` (and ${rest} more)` : ''),
50
+ };
15
51
  },
16
52
  };
17
53
  export default tool;
@@ -5,11 +5,15 @@ import api from '@actual-app/api';
5
5
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
6
6
  const { getRules: rawGetRules, deleteRule: rawDeleteRule } = api;
7
7
  const InputSchema = z.object({
8
- id: z.string().describe('Rule ID to delete'),
8
+ // Bounded for the same reason as the payee ids in #356: this value is echoed into the
9
+ // not-found message, into the schedule-owned refusal below, and into logger.error.
10
+ id: z.string().min(1).max(64).describe('Rule ID to delete'),
9
11
  });
10
12
  const tool = {
11
13
  name: 'actual_rules_delete',
12
- description: `Delete a budget rule from Actual Budget. The rule will no longer be applied to new or existing transactions. This operation cannot be undone.`,
14
+ description: 'Delete a budget rule from Actual Budget. The rule will no longer be applied to new or ' +
15
+ 'existing transactions. This operation cannot be undone. A rule that belongs to a SCHEDULE ' +
16
+ 'cannot be deleted on its own: the call is refused and names the schedule tool to use instead.',
13
17
  inputSchema: InputSchema,
14
18
  call: async (args, _meta) => {
15
19
  const input = InputSchema.parse(args || {});
@@ -24,7 +28,23 @@ const tool = {
24
28
  // error, consistent with every other delete tool's not-found behavior.
25
29
  throw new Error(notFoundMsg('Rule', input.id, 'actual_rules_get'));
26
30
  }
27
- await rawDeleteRule(input.id);
31
+ // #355: the raw call RETURNS a verdict and it used to be discarded.
32
+ // Upstream `deleteRule` returns `false`, without throwing, when a schedule owns
33
+ // this rule: Actual keeps a schedule and its generated rule in step and refuses
34
+ // to remove the rule on its own. The existence pre-check above cannot catch that
35
+ // case, because the rule genuinely exists. Reporting success there was a lie
36
+ // (CWE-252, unchecked return value).
37
+ //
38
+ // Only an EXPLICIT `false` is a refusal. A build that returns `undefined` (the
39
+ // published reference documents this method as `Promise<null>`) is treated as
40
+ // success, so this stays correct against older and future versions.
41
+ const deleted = await rawDeleteRule(input.id);
42
+ if (deleted === false) {
43
+ throw new Error(`Rule "${input.id}" belongs to a schedule and cannot be deleted on its own. ` +
44
+ 'Actual keeps a schedule and its generated rule in step. Delete the schedule ' +
45
+ 'instead with actual_schedules_delete (find it with actual_schedules_get), ' +
46
+ 'which removes this rule too.');
47
+ }
28
48
  return { success: true };
29
49
  });
30
50
  },
@@ -51,17 +51,17 @@ const tool = {
51
51
  try {
52
52
  // Use addTransactions - it reliably creates transactions.
53
53
  // Note: API may return "ok" string instead of a UUID depending on server version.
54
- // "ok" is a valid success indicator — the transaction WAS created.
54
+ // "ok" is a valid success indicator: the transaction WAS created.
55
55
  const result = await adapter.addTransactions(payload);
56
56
  if (!result || result.length === 0) {
57
57
  return {
58
58
  success: false,
59
- error: 'Failed to create transaction — no result returned from API. Use actual_accounts_list to verify the account ID.',
59
+ error: 'Failed to create transaction: no result returned from API. Use actual_accounts_list to verify the account ID.',
60
60
  id: null,
61
61
  };
62
62
  }
63
63
  // The API sometimes returns a UUID and sometimes "ok" depending on server version.
64
- // Both are success — "ok" means created but no ID available from this API version.
64
+ // Both are success: "ok" means created but no ID available from this API version.
65
65
  const maybeId = result[0] && result[0] !== 'ok' && result[0].length > 10
66
66
  ? result[0]
67
67
  : null;
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.11.6",
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/api_coverage_baseline.test.js && node tests/unit/train_preflight.test.js && node tests/unit/verify_published_artifacts.test.js && node tests/unit/report_train_stale.test.js && node tests/unit/stdio_framing_check.test.js && node tests/unit/server_info.test.js && node tests/unit/transfers_create.test.js && node tests/unit/bot_target_branch.test.js && node tests/unit/auth_posture.test.js && node tests/unit/auth_posture_wiring.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/httpServer_session_not_found.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/retry_classifier.test.js && node tests/unit/adapter_module_surface.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/accounts_delete.test.js && node tests/unit/payees_delete.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_oidc_jwks_discovery.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js && node tests/unit/tags_list.test.js && node tests/unit/tags_create.test.js && node tests/unit/tags_update.test.js && node tests/unit/tags_delete.test.js && node tests/unit/notes_get.test.js && node tests/unit/notes_update.test.js && node tests/unit/payees_common_list.test.js && node tests/unit/transactions_update_guard.test.js && node tests/unit/transactions_update_splits.test.js && node tests/unit/entities_search.test.js && node tests/unit/transactions_import_schema.test.js && node tests/unit/tool_schema_shape.test.js && node tests/unit/zod_error_format.test.js && node tests/unit/tool_count_sync.test.js && node tests/unit/stdio_transport.test.js && node tests/unit/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",
@@ -62,7 +62,8 @@
62
62
  "config-drift": "npm run build && node scripts/config-drift.mjs",
63
63
  "node-version-drift": "node scripts/node-version-drift.mjs",
64
64
  "knip": "knip",
65
- "actionlint": "bash scripts/install-actionlint.sh && ./actionlint -shellcheck= -color"
65
+ "actionlint": "bash scripts/install-actionlint.sh && ./actionlint -shellcheck= -color",
66
+ "audit:write-effect": "node scripts/check-write-effect-audit.mjs"
66
67
  },
67
68
  "dependencies": {
68
69
  "@actual-app/api": "^26.8.1",