actual-mcp-server 0.13.0 → 0.14.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
@@ -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.13.0 | **Tool Count:** 74 (verified LibreChat-compatible)
777
+ **Version:** 0.14.0 | **Tool Count:** 74 (verified LibreChat-compatible)
778
778
 
779
779
  ## Sponsor
780
780
 
@@ -11,7 +11,8 @@ import { EventEmitter } from 'events';
11
11
  import observability from '../observability.js';
12
12
  import { retry, isRetryableError } from './retry.js';
13
13
  import { withOpTimeout } from './opTimeout.js';
14
- import { notFoundMsg } from './errors.js';
14
+ import { NotFoundRefusal, OutOfRangeRefusal, constraintErrorMsg } from './errors.js';
15
+ import { findMatchingRule } from './rule-matching.js';
15
16
  import logger from '../logger.js';
16
17
  import { checkServerVersionOnce } from './server-version-guard.js';
17
18
  import config from '../config.js';
@@ -833,8 +834,7 @@ export async function addTransactions(txs, options = {}) {
833
834
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
834
835
  const accountExists = accounts.some((a) => a?.id === accountId);
835
836
  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 ' +
837
+ throw new NotFoundRefusal('Account', accountId, 'actual_accounts_list', 'No transactions were created: Actual would otherwise have written them against an ' +
838
838
  'account that does not exist, where no tool could retrieve them.');
839
839
  }
840
840
  // Remove account/accountId from transaction objects as they're passed separately
@@ -997,7 +997,13 @@ export async function setBudgetAmount(month, categoryId, amount) {
997
997
  const categories = await withConcurrency(() => retry(() => rawGetCategories(), { retries: 2, backoffMs: 200 }));
998
998
  const exists = categories.some((c) => c.id === categoryId);
999
999
  if (!exists) {
1000
- throw new Error(`Category "${categoryId}" not found. Use actual_categories_get to list available categories.`);
1000
+ // #377: MUST be a typed refusal. `budgets_setAmount` is one of the two tools whose
1001
+ // published contract is the SHAPE (#89: an unknown category returns
1002
+ // { success: false, error }, it does not throw), and the tool decides that by type
1003
+ // now. Leaving this as a bare Error silently undid #89, and the unit test did not
1004
+ // catch it because it stubbed the adapter and threw the refusal itself, reproducing
1005
+ // the fixture on both sides. There is a real-adapter case for it now.
1006
+ throw new NotFoundRefusal('Category', String(categoryId), 'actual_categories_get');
1001
1007
  }
1002
1008
  // #361: the MONTH is unvalidated, in format AND range. `api/budget-set-amount` is the
1003
1009
  // one budget handler that does not call upstream's `validateMonth`, unlike
@@ -1011,8 +1017,8 @@ export async function setBudgetAmount(month, categoryId, amount) {
1011
1017
  // exactly, and a malformed string cannot be a member either.
1012
1018
  const months = await withConcurrency(() => retry(() => rawGetBudgetMonths(), { retries: 2, backoffMs: 200 }));
1013
1019
  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.');
1020
+ throw new OutOfRangeRefusal(`Month "${month}" is outside this budget's range, which runs from ${months[0]} to ` +
1021
+ `${months[months.length - 1]}. Use actual_budgets_getMonths to see the months you can budget to.`, String(month));
1016
1022
  }
1017
1023
  const result = await withConcurrency(() => retry(() => rawSetBudgetAmount(month, categoryId, amount), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
1018
1024
  return result;
@@ -1074,7 +1080,7 @@ export async function updateAccount(id, fields) {
1074
1080
  // `closed = 0`, so this refuses only ids that genuinely do not exist.
1075
1081
  const accounts = await withConcurrency(() => retry(() => rawGetAccounts(), { retries: 2, backoffMs: 200 }));
1076
1082
  if (!(Array.isArray(accounts) && accounts.some((a) => a?.id === id))) {
1077
- throw new Error(notFoundMsg('Account', id, 'actual_accounts_list'));
1083
+ throw new NotFoundRefusal('Account', id, 'actual_accounts_list');
1078
1084
  }
1079
1085
  await withConcurrency(() => retry(() => rawUpdateAccount(id, fields), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
1080
1086
  return null;
@@ -1244,7 +1250,7 @@ export async function updateCategory(id, fields) {
1244
1250
  // included, so a hidden category is not misreported as missing.
1245
1251
  const categories = await withConcurrency(() => retry(() => rawGetCategories(), { retries: 2, backoffMs: 200 }));
1246
1252
  if (!(Array.isArray(categories) && categories.some((c) => c?.id === id))) {
1247
- throw new Error(notFoundMsg('Category', id, 'actual_categories_get'));
1253
+ throw new NotFoundRefusal('Category', id, 'actual_categories_get');
1248
1254
  }
1249
1255
  await withConcurrency(() => retry(() => rawUpdateCategory(id, fields), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
1250
1256
  });
@@ -1256,7 +1262,7 @@ export async function deleteCategory(id) {
1256
1262
  const categories = await withConcurrency(() => retry(() => rawGetCategories(), { retries: 2, backoffMs: 200 }));
1257
1263
  const exists = categories.some((c) => c.id === id);
1258
1264
  if (!exists) {
1259
- throw new Error(`Category "${id}" not found. Use actual_categories_get to list available categories.`);
1265
+ throw new NotFoundRefusal('Category', id, 'actual_categories_get');
1260
1266
  }
1261
1267
  await withConcurrency(() => retry(() => rawDeleteCategory(id), { retries: 0, backoffMs: 200 }));
1262
1268
  });
@@ -1276,7 +1282,7 @@ export async function updatePayee(id, fields) {
1276
1282
  // is leaving four tools able to create phantom rows.
1277
1283
  const payees = await withConcurrency(() => retry(() => rawGetPayees(), { retries: 2, backoffMs: 200 }));
1278
1284
  if (!(Array.isArray(payees) && payees.some((p) => p?.id === id))) {
1279
- throw new Error(notFoundMsg('Payee', id, 'actual_payees_get'));
1285
+ throw new NotFoundRefusal('Payee', id, 'actual_payees_get');
1280
1286
  }
1281
1287
  const fieldsObj = fields;
1282
1288
  // Extract `category` — it is NOT a direct column on the payees table in Actual Budget.
@@ -1339,7 +1345,7 @@ export async function deletePayee(id) {
1339
1345
  const found = payees.find((p) => p.id === id);
1340
1346
  const exists = Boolean(found);
1341
1347
  if (!exists) {
1342
- throw new Error(`Payee "${id}" not found. Use actual_payees_get to list available payees.`);
1348
+ throw new NotFoundRefusal('Payee', id, 'actual_payees_get');
1343
1349
  }
1344
1350
  // #356: the existence check above is not enough. Actual refuses to delete a
1345
1351
  // TRANSFER payee (the payee it auto-creates for each account) and refuses
@@ -1423,10 +1429,96 @@ export async function updateRule(id, fields) {
1423
1429
  * the correct contract instead of rediscovering this the hard way. Neither widening is
1424
1430
  * exercised by a test today, for the same reason: nothing calls them.
1425
1431
  */
1432
+ /**
1433
+ * Idempotent rule upsert (#376): update the rule whose conditions match, or create one.
1434
+ *
1435
+ * Moved here from `src/tools/rules_create_or_update.ts`, which ran the read and the write
1436
+ * inside its own `withWriteSession` using raw api calls. Same single-cycle property, but the
1437
+ * read now goes through `retry` and there is one observability call site.
1438
+ *
1439
+ * The caller has already validated the payload; this owns identity and merge only.
1440
+ */
1441
+ export async function upsertRule(input, stageWasSupplied) {
1442
+ observability.incrementToolCall('actual.rules.create_or_update').catch(() => { });
1443
+ return queueWriteOperation(async () => {
1444
+ const existingRules = await withConcurrency(() => retry(() => rawGetRules(), { retries: 2, backoffMs: 200 }));
1445
+ const matchedRule = findMatchingRule(existingRules, input.conditions, input.conditionsOp);
1446
+ const ruleData = JSON.parse(JSON.stringify(input)); // deep clone for the API call
1447
+ if (matchedRule) {
1448
+ // The Actual Budget API expects the FULL merged rule object as one argument.
1449
+ //
1450
+ // #342: `??` is WRONG for stage. null is a MEANINGFUL value here (Actual's default
1451
+ // stage), not an absent one, so `ruleData.stage ?? existing` would silently discard
1452
+ // an explicit `stage: null` and keep the old stage, making it impossible to move a
1453
+ // rule back to the default. Decide on PRESENCE instead. That presence test used to
1454
+ // be `'stage' in ruleData`, which worked only because the tool passed its parsed
1455
+ // input straight through; it is now an explicit parameter so the meaning survives
1456
+ // the extra call boundary.
1457
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1458
+ const merged = {
1459
+ id: matchedRule.id,
1460
+ stage: stageWasSupplied ? ruleData.stage : matchedRule.stage,
1461
+ conditionsOp: ruleData.conditionsOp ?? matchedRule.conditionsOp,
1462
+ conditions: ruleData.conditions ?? matchedRule.conditions ?? [],
1463
+ actions: ruleData.actions ?? matchedRule.actions ?? [],
1464
+ };
1465
+ // Non-idempotent from upstream's point of view: do not retry the write.
1466
+ await withConcurrency(() => retry(() => rawUpdateRule(merged), { retries: 0, backoffMs: 200 }));
1467
+ return { id: matchedRule.id, created: false };
1468
+ }
1469
+ // #342: stage has no Zod default on this tool, so an omitted stage arrives as
1470
+ // undefined. Actual's validator ALWAYS runs on a create and rejects undefined with
1471
+ // `Invalid rule stage: undefined`, so the key must be present. null is the correct
1472
+ // value: Actual's default stage.
1473
+ if (ruleData.stage === undefined)
1474
+ ruleData.stage = null;
1475
+ const rawId = await withConcurrency(() => retry(() => rawCreateRule(ruleData), { retries: 0, backoffMs: 200 }));
1476
+ return { id: normalizeToId(rawId), created: true };
1477
+ });
1478
+ }
1479
+ /**
1480
+ * #376: the existence guard and the schedule-owned refusal moved here from
1481
+ * `src/tools/rules_delete.ts`, which held them inside its own `withWriteSession`.
1482
+ *
1483
+ * WHY THE ADAPTER. Doing it in the tool meant reaching past `adapter.*` for the read, so
1484
+ * the read had no `retry` at all, and it left THIS method reachable and unguarded: calling
1485
+ * it directly silently failed to delete a schedule-owned rule, which is the #355 defect the
1486
+ * tool had already fixed. See "Where a read-then-write guard belongs" in CLAUDE.md.
1487
+ *
1488
+ * Both halves stay inside ONE `queueWriteOperation`, so the read, the decision and the
1489
+ * write share a single api lock cycle (#142). That excludes other SESSIONS; it does not
1490
+ * serialise against other operations in the same drain (see `reopenAccount` for the long
1491
+ * form of that caveat).
1492
+ */
1426
1493
  export async function deleteRule(id) {
1427
1494
  observability.incrementToolCall('actual.rules.delete').catch(() => { });
1428
1495
  return queueWriteOperation(async () => {
1429
- return await withConcurrency(() => retry(() => rawDeleteRule(id), { retries: 0, backoffMs: 200 }));
1496
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1497
+ const allRules = await withConcurrency(() => retry(() => rawGetRules(), { retries: 2, backoffMs: 200 }));
1498
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1499
+ if (!Array.isArray(allRules) || !allRules.some((r) => r?.id === id)) {
1500
+ throw new NotFoundRefusal('Rule', id, 'actual_rules_get');
1501
+ }
1502
+ // #355: the raw call RETURNS a verdict and it used to be discarded. Upstream
1503
+ // `deleteRule` returns `false`, without throwing, when a schedule owns this rule:
1504
+ // Actual keeps a schedule and its generated rule in step and refuses to remove the
1505
+ // rule on its own. The existence check above cannot catch that case, because the rule
1506
+ // genuinely exists. Reporting success there was a lie (CWE-252).
1507
+ //
1508
+ // Only an EXPLICIT `false` is a refusal. A build that returns `undefined` (the
1509
+ // published reference documents this method as `Promise<null>`) is treated as success,
1510
+ // so this stays correct against older and future versions.
1511
+ //
1512
+ // Not a PreflightRefusal: the write WAS attempted and upstream declined it. A
1513
+ // PreflightRefusal means nothing was tried. See the taxonomy in
1514
+ // .claude/skills/api-design-principles/SKILL.md.
1515
+ const deleted = await withConcurrency(() => retry(() => rawDeleteRule(id), { retries: 0, backoffMs: 200 }));
1516
+ if (deleted === false) {
1517
+ throw new Error(`Rule "${id}" belongs to a schedule and cannot be deleted on its own. ` +
1518
+ 'Actual keeps a schedule and its generated rule in step. Delete the schedule ' +
1519
+ 'instead with actual_schedules_delete (find it with actual_schedules_get), ' +
1520
+ 'which removes this rule too.');
1521
+ }
1430
1522
  });
1431
1523
  }
1432
1524
  export async function getSchedules() {
@@ -1452,10 +1544,28 @@ export async function updateSchedule(id, fields, resetNextDate) {
1452
1544
  await withConcurrency(() => retry(() => rawUpdateSchedule(id, fields, resetNextDate), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
1453
1545
  });
1454
1546
  }
1547
+ /**
1548
+ * #376: the existence guard and the constraint-error translation moved here from
1549
+ * `src/tools/schedules_delete.ts`. The translation stays wrapped tightly around the delete
1550
+ * call so the message cannot regress into the raw SQLite text.
1551
+ */
1455
1552
  export async function deleteSchedule(id) {
1456
1553
  observability.incrementToolCall('actual.schedules.delete').catch(() => { });
1457
1554
  return queueWriteOperation(async () => {
1458
- await withConcurrency(() => retry(() => rawDeleteSchedule(id), { retries: 0, backoffMs: 200 }));
1555
+ const schedules = await withConcurrency(() => retry(() => rawGetSchedules(), { retries: 2, backoffMs: 200 }));
1556
+ if (!Array.isArray(schedules) || !schedules.some((sch) => sch?.id === id)) {
1557
+ throw new NotFoundRefusal('Schedule', id, 'actual_schedules_get');
1558
+ }
1559
+ try {
1560
+ await withConcurrency(() => retry(() => rawDeleteSchedule(id), { retries: 0, backoffMs: 200 }));
1561
+ }
1562
+ catch (err) {
1563
+ const msg = err instanceof Error ? err.message : String(err);
1564
+ if (msg.includes('NOT NULL constraint') || msg.includes('messages_crdt')) {
1565
+ throw new Error(constraintErrorMsg('Schedule', id, 'actual_schedules_get'));
1566
+ }
1567
+ throw err;
1568
+ }
1459
1569
  });
1460
1570
  }
1461
1571
  export async function setBudgetCarryover(month, categoryId, flag) {
@@ -1555,34 +1665,6 @@ export async function closeAccount(id, transferAccountId, transferCategoryId) {
1555
1665
  return { outcome: 'closed', name: survivor.name };
1556
1666
  });
1557
1667
  }
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
- */
1586
1668
  export async function reopenAccount(id) {
1587
1669
  observability.incrementToolCall('actual.accounts.reopen').catch(() => { });
1588
1670
  return queueWriteOperation(async () => {
@@ -1594,6 +1676,15 @@ export async function reopenAccount(id) {
1594
1676
  'Actual removed it rather than closing it, and it cannot be reopened: create a new ' +
1595
1677
  'one with actual_accounts_create.');
1596
1678
  }
1679
+ // #369 item 5: an already-open account needs no write. Upstream `reopenAccount` is a
1680
+ // db.update, which in Actual means a CRDT MESSAGE that syncs to every other client, so
1681
+ // issuing it for a no-op is not free: it is sync traffic and a device-state bump for a
1682
+ // change nobody made. Reporting the non-change is also the taxonomy's rule 1 (the
1683
+ // requested end state already holds, so this is a SUCCESS naming what did not happen),
1684
+ // and it matches what closeAccount already does with `already-closed`.
1685
+ if (target.closed !== true) {
1686
+ return { outcome: 'already-open', name: target.name };
1687
+ }
1597
1688
  await withConcurrency(() => retry(() => rawReopenAccount(id), { retries: 2, backoffMs: 200 }));
1598
1689
  const after = await withConcurrency(() => retry(() => rawGetAccounts(), { retries: 2, backoffMs: 200 }));
1599
1690
  const survivor = (Array.isArray(after) ? after : []).find((a) => a?.id === id);
@@ -1604,6 +1695,7 @@ export async function reopenAccount(id) {
1604
1695
  throw new Error(`Account "${survivor.name ?? id}" (${id}) is still closed after the reopen. The call ` +
1605
1696
  'was accepted but had no effect; check the account state in Actual.');
1606
1697
  }
1698
+ return { outcome: 'reopened', name: survivor.name };
1607
1699
  });
1608
1700
  }
1609
1701
  export async function getCategoryGroups() {
@@ -1632,14 +1724,23 @@ export async function updateCategoryGroup(id, fields) {
1632
1724
  // can rely on this listing).
1633
1725
  const groups = await withConcurrency(() => retry(() => rawGetCategoryGroups(), { retries: 2, backoffMs: 200 }));
1634
1726
  if (!(Array.isArray(groups) && groups.some((g) => g?.id === id))) {
1635
- throw new Error(notFoundMsg('Category group', id, 'actual_category_groups_get'));
1727
+ throw new NotFoundRefusal('Category group', id, 'actual_category_groups_get');
1636
1728
  }
1637
1729
  await withConcurrency(() => retry(() => rawUpdateCategoryGroup(id, fields), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
1638
1730
  });
1639
1731
  }
1732
+ /**
1733
+ * #376: the existence guard moved here from `src/tools/category_groups_delete.ts`. Same
1734
+ * reasoning as `deleteRule` above: the tool-layer version forwent `retry` on the read and
1735
+ * left this method reachable with no guard at all.
1736
+ */
1640
1737
  export async function deleteCategoryGroup(id) {
1641
1738
  observability.incrementToolCall('actual.category_groups.delete').catch(() => { });
1642
1739
  return queueWriteOperation(async () => {
1740
+ const groups = await withConcurrency(() => retry(() => rawGetCategoryGroups(), { retries: 2, backoffMs: 200 }));
1741
+ if (!Array.isArray(groups) || !groups.some((g) => g?.id === id)) {
1742
+ throw new NotFoundRefusal('Category group', id, 'actual_category_groups_get');
1743
+ }
1643
1744
  // Non-idempotent: do not retry (#165).
1644
1745
  await withConcurrency(() => retry(() => rawDeleteCategoryGroup(id), { retries: 0, backoffMs: 200 }));
1645
1746
  });
@@ -2330,7 +2431,7 @@ export async function updateTag(id, fields) {
2330
2431
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
2331
2432
  const exists = tags.some((t) => t.id === id);
2332
2433
  if (!exists) {
2333
- throw new Error(notFoundMsg('Tag', id, 'actual_tags_list'));
2434
+ throw new NotFoundRefusal('Tag', id, 'actual_tags_list');
2334
2435
  }
2335
2436
  await withConcurrency(() => retry(() => rawUpdateTag(id, fields), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
2336
2437
  });
@@ -2343,7 +2444,7 @@ export async function deleteTag(id) {
2343
2444
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
2344
2445
  const exists = tags.some((t) => t.id === id);
2345
2446
  if (!exists) {
2346
- throw new Error(notFoundMsg('Tag', id, 'actual_tags_list'));
2447
+ throw new NotFoundRefusal('Tag', id, 'actual_tags_list');
2347
2448
  }
2348
2449
  await withConcurrency(() => retry(() => rawDeleteTag(id), { retries: 0, backoffMs: 200 }));
2349
2450
  });
@@ -2354,9 +2455,42 @@ export async function getNote(id) {
2354
2455
  return await withConcurrency(() => retry(() => rawGetNote(id), { retries: 2, backoffMs: 200 }));
2355
2456
  });
2356
2457
  }
2458
+ /** A note id of this shape is a budget MONTH note and resolves to no entity row. */
2459
+ const BUDGET_MONTH_NOTE_RE = /^budget-\d{4}-\d{2}$/;
2460
+ /**
2461
+ * #376: the orphan-id guard moved here from `src/tools/notes_update.ts`.
2462
+ *
2463
+ * WHY THIS ONE MATTERS MOST OF THE FIVE. The tool version issued FOUR `adapter.get*` calls
2464
+ * through `Promise.all`, each opening its own `withActualApi` cycle, and then a fifth cycle
2465
+ * for the write: five api lock acquisitions for one logical operation. `Promise.all` made it
2466
+ * look concurrent, but the api mutex is process-global, so they serialised anyway. Reading
2467
+ * inside one `queueWriteOperation` makes it ONE cycle, and the guard now sees the same
2468
+ * snapshot the write lands on.
2469
+ *
2470
+ * The refusal is a NotFoundRefusal so the tool can recognise it by type (#377). The tool
2471
+ * converts it to its published `{ error }` shape, which is a known deviation from the
2472
+ * refusal taxonomy and is tracked on #377 rather than changed here.
2473
+ */
2357
2474
  export async function updateNote(id, note) {
2358
2475
  observability.incrementToolCall('actual.notes.update').catch(() => { });
2359
2476
  return queueWriteOperation(async () => {
2477
+ // A budget-YYYY-MM id is synthetic: it names a month, not a row, so there is nothing
2478
+ // to look up and the four reads below would all miss.
2479
+ if (!BUDGET_MONTH_NOTE_RE.test(id)) {
2480
+ const has = (rows) => Array.isArray(rows) && rows.some((e) => e?.id === id);
2481
+ const [accounts, categories, categoryGroups, payees] = await Promise.all([
2482
+ withConcurrency(() => retry(() => rawGetAccounts(), { retries: 2, backoffMs: 200 })),
2483
+ withConcurrency(() => retry(() => rawGetCategories(), { retries: 2, backoffMs: 200 })),
2484
+ withConcurrency(() => retry(() => rawGetCategoryGroups(), { retries: 2, backoffMs: 200 })),
2485
+ withConcurrency(() => retry(() => rawGetPayees(), { retries: 2, backoffMs: 200 })),
2486
+ ]);
2487
+ if (!has(accounts) && !has(categories) && !has(categoryGroups) && !has(payees)) {
2488
+ // Guarding matters here for the same reason as #360: upstream's note write is a
2489
+ // CRDT message, and the apply path INSERTs when the row is absent, so an unknown
2490
+ // id creates an orphan note that no tool can ever read back.
2491
+ throw new NotFoundRefusal('Entity', id, 'actual_accounts_list, actual_categories_get, actual_category_groups_get or actual_payees_get', 'A budget month note uses an id like "budget-2026-01".');
2492
+ }
2493
+ }
2360
2494
  await withConcurrency(() => retry(() => rawUpdateNote(id, note), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
2361
2495
  });
2362
2496
  }
@@ -2482,6 +2616,7 @@ export default {
2482
2616
  createRule,
2483
2617
  updateRule,
2484
2618
  deleteRule,
2619
+ upsertRule,
2485
2620
  setBudgetCarryover,
2486
2621
  closeAccount,
2487
2622
  reopenAccount,
@@ -1,11 +1,113 @@
1
1
  /**
2
- * Build a "not found" error message with a next-step hint.
2
+ * Refusal messages and the typed refusals that carry them.
3
+ *
4
+ * #377: the WORDING helpers below came first, and two tools ended up deciding their
5
+ * published response shape by substring-matching that wording:
6
+ *
7
+ * const isCategoryRefusal = lower.includes('not found') && lower.includes('category');
8
+ *
9
+ * which means a copy-edit to a message in `actual-adapter.ts` silently flips a tool from
10
+ * returning a structured refusal to throwing. The classes here make that decision a type
11
+ * question instead, so the prose is free to change.
12
+ *
13
+ * THE TAXONOMY these encode (the full version, with the reasoning, lives in
14
+ * `.claude/skills/api-design-principles/SKILL.md`):
15
+ *
16
+ * - The requested end state ALREADY HOLDS (already-closed, delete-what-is-absent):
17
+ * SUCCESS, with a field naming the non-change. That is #347's idempotence argument,
18
+ * and it is not a refusal, so nothing here represents it.
19
+ * - The request NAMES SOMETHING THAT DOES NOT EXIST, or asks for something upstream
20
+ * will not do: a PreflightRefusal. It is a caller error and the caller can fix it.
21
+ * - Anything else is a genuine failure and must keep propagating as an ordinary Error.
22
+ *
23
+ * A PreflightRefusal is thrown, like any other error. What the type buys is that a tool
24
+ * needing the structured `{ success: false }` shape can recognise one without reading the
25
+ * message, and that a tool must NEVER swallow a non-refusal into that shape.
26
+ */
27
+ /**
28
+ * Cross-realm brand. `instanceof` is the primary check and is correct in normal operation,
29
+ * where every caller resolves the same `dist/src/lib/errors.js`. The brand covers the case
30
+ * where two copies of this module exist at once (a bundler, or a test that loads the source
31
+ * while the runtime loaded the build). That failure is worth guarding because it is SILENT:
32
+ * a bare `instanceof` would simply return false, and the tool would rethrow a refusal as a
33
+ * generic failure, changing its published contract with nothing red to show for it.
34
+ */
35
+ const REFUSAL_BRAND = Symbol.for('actual-mcp-server.PreflightRefusal');
36
+ /**
37
+ * A pre-flight refusal: the operation was NOT attempted, because the request named
38
+ * something that cannot be acted on. Nothing was written.
39
+ */
40
+ export class PreflightRefusal extends Error {
41
+ refusalKind;
42
+ constructor(message, refusalKind) {
43
+ super(message);
44
+ this.name = new.target.name;
45
+ this.refusalKind = refusalKind;
46
+ Object.defineProperty(this, REFUSAL_BRAND, { value: true, enumerable: false });
47
+ // Restores the prototype chain when this is compiled down for an older target, so
48
+ // `instanceof` keeps working on a subclass.
49
+ Object.setPrototypeOf(this, new.target.prototype);
50
+ }
51
+ }
52
+ /** The entity named by the request does not exist. */
53
+ export class NotFoundRefusal extends PreflightRefusal {
54
+ entity;
55
+ id;
56
+ listTool;
57
+ /**
58
+ * @param entity Human-readable entity name, e.g. "Account", "Category"
59
+ * @param id The id that was not found
60
+ * @param listTool MCP tool name the caller should use to get valid ids
61
+ * @param detail Optional extra sentence, e.g. what was NOT written as a result
62
+ */
63
+ constructor(entity, id, listTool, detail) {
64
+ super(notFoundMsg(entity, id, listTool) + (detail ? ` ${detail}` : ''), 'not-found');
65
+ this.entity = entity;
66
+ this.id = id;
67
+ this.listTool = listTool;
68
+ }
69
+ }
70
+ /** The request is well-formed but falls outside a range the budget actually has. */
71
+ export class OutOfRangeRefusal extends PreflightRefusal {
72
+ value;
73
+ constructor(message, value) {
74
+ super(message, 'out-of-range');
75
+ this.value = value;
76
+ }
77
+ }
78
+ /**
79
+ * The check a tool uses to decide "structured refusal" versus "rethrow". Prefer this over
80
+ * a bare `instanceof` so the brand fallback above applies.
81
+ */
82
+ export function isPreflightRefusal(error) {
83
+ if (error instanceof PreflightRefusal)
84
+ return true;
85
+ return (typeof error === 'object' &&
86
+ error !== null &&
87
+ error[REFUSAL_BRAND] === true);
88
+ }
89
+ /**
90
+ * Build a "not found" message with a next-step hint.
91
+ *
92
+ * Prefer THROWING a `NotFoundRefusal` (which builds this string itself) over throwing a
93
+ * bare Error carrying it: the message is for the human or model reading the response, and
94
+ * the type is for the code deciding what to do about it.
95
+ *
3
96
  * @param entityType Human-readable entity name, e.g. "Account", "Category"
4
97
  * @param id The ID that was not found
5
98
  * @param listTool MCP tool name the caller should use to get valid IDs
6
99
  */
7
100
  export function notFoundMsg(entityType, id, listTool) {
8
- return `${entityType} "${id}" not found. Use ${listTool} to list available ${entityType.toLowerCase()}s.`;
101
+ return `${entityType} "${id}" not found. Use ${listTool} to list available ${pluralize(entityType.toLowerCase())}.`;
102
+ }
103
+ /**
104
+ * Pluralise an entity name for the message above. This used to be a bare `+ 's'`, which
105
+ * produced "list available categorys" and, once #376 added an `Entity` refusal,
106
+ * "list available entitys". The consonant-plus-y rule covers every entity name this
107
+ * codebase uses (account, category, payee, category group, tag, rule, schedule, entity).
108
+ */
109
+ function pluralize(word) {
110
+ return /[^aeiou]y$/.test(word) ? `${word.slice(0, -1)}ies` : `${word}s`;
9
111
  }
10
112
  /**
11
113
  * Build a "constraint error" message for SQLite-level failures.
@@ -29,7 +29,15 @@
29
29
  export function isKnownBenignRejection(reason) {
30
30
  const reasonStr = String(reason);
31
31
  const reasonObj = reason;
32
- return (reasonStr.includes('does not exist in table') ||
32
+ return (
33
+ // #377: a pre-flight refusal is benign by construction (the operation was not
34
+ // attempted and nothing was written), so recognise it by BRAND rather than by prose.
35
+ // The brand is read via `Symbol.for`, not by importing `isPreflightRefusal`, because
36
+ // this file's INVARIANT is that it has no project imports (enforced by
37
+ // rejection-allowlist-purity.test.js). The prose lines below remain as the fallback
38
+ // for errors this server does not raise itself.
39
+ reason?.[Symbol.for('actual-mcp-server.PreflightRefusal')] === true ||
40
+ reasonStr.includes('does not exist in table') ||
33
41
  (reasonStr.includes('Field') && reasonStr.includes('does not exist')) ||
34
42
  reasonStr.includes('Expression stack') ||
35
43
  reasonStr.includes('Date is required') ||
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Rule identity for the idempotent upsert behind `actual_rules_create_or_update`.
3
+ *
4
+ * #376 moved this out of `src/tools/rules_create_or_update.ts` so `adapter.upsertRule` can
5
+ * own the read-match-write cycle. The logic is unchanged; extracting it keeps the matching
6
+ * rules unit-testable on their own and keeps `actual-adapter.ts` from growing a second
7
+ * concern.
8
+ *
9
+ * Two rules are "the same rule" when they carry the same SET of (field, op, value) triples
10
+ * and the same `conditionsOp`. Order within the conditions array is deliberately irrelevant:
11
+ * an AI client that regenerates the same rule with its conditions in a different order must
12
+ * not create a duplicate, which is the whole point of the tool.
13
+ */
14
+ /** Canonical JSON for one object: keys sorted, `undefined` values dropped. */
15
+ export function canonicalize(obj) {
16
+ const sorted = {};
17
+ for (const key of Object.keys(obj).sort()) {
18
+ if (obj[key] !== undefined)
19
+ sorted[key] = obj[key];
20
+ }
21
+ return JSON.stringify(sorted);
22
+ }
23
+ /** True when two rules have semantically equivalent conditions (set-based, order-free). */
24
+ export function conditionsMatch(existingConditions, existingConditionsOp, newConditions, newConditionsOp) {
25
+ if ((existingConditionsOp || 'and') !== newConditionsOp)
26
+ return false;
27
+ if (!Array.isArray(existingConditions))
28
+ return false;
29
+ if (existingConditions.length !== newConditions.length)
30
+ return false;
31
+ const existingSet = new Set(existingConditions.map((c) => {
32
+ const cond = c;
33
+ return canonicalize({ field: cond.field, op: cond.op, value: cond.value });
34
+ }));
35
+ const newSet = new Set(newConditions.map((c) => canonicalize({ field: c.field, op: c.op, value: c.value })));
36
+ if (existingSet.size !== newSet.size)
37
+ return false;
38
+ for (const item of newSet) {
39
+ if (!existingSet.has(item))
40
+ return false;
41
+ }
42
+ return true;
43
+ }
44
+ /**
45
+ * Find the rule an upsert should update, or null to create a new one.
46
+ * Returns the FIRST match, which is the pre-existing behaviour.
47
+ */
48
+ export function findMatchingRule(existingRules, newConditions, newConditionsOp) {
49
+ if (!Array.isArray(existingRules))
50
+ return null;
51
+ for (const rule of existingRules) {
52
+ const r = rule;
53
+ if (!r?.id || typeof r.id !== 'string')
54
+ continue;
55
+ const existingConditions = Array.isArray(r.conditions) ? r.conditions : [];
56
+ const existingConditionsOp = r.conditionsOp || 'and';
57
+ if (conditionsMatch(existingConditions, existingConditionsOp, newConditions, newConditionsOp)) {
58
+ return r;
59
+ }
60
+ }
61
+ return null;
62
+ }
@@ -43,7 +43,10 @@ const tool = {
43
43
  'IMPORTANT: an account with NO transactions is REMOVED by Actual rather than closed, and cannot ' +
44
44
  'be reopened; an account that has transactions keeps them. If the account has a non-zero balance, ' +
45
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.',
46
+ 'transaction. Closing an already-closed account reports that rather than claiming a change. ' +
47
+ 'If this call reports an error, re-read the account with actual_accounts_list before retrying: ' +
48
+ 'the state is verified AFTER the write, so a failure of that verification read is reported ' +
49
+ 'even though the close itself may have succeeded.',
47
50
  inputSchema: InputSchema,
48
51
  call: async (args, _meta) => {
49
52
  const input = InputSchema.parse(args || {});
@@ -14,19 +14,34 @@ const InputSchema = z.object({
14
14
  * #371 moved it there from this file. The read-then-write property never needed the raw
15
15
  * api: doing it in the adapter keeps `retry` on the reads, keeps one observability call
16
16
  * site, and leaves no unguarded `adapter.reopenAccount` for a future caller to reach for.
17
+ *
18
+ * #369 item 5: an already-open account is now reported as a non-change and issues NO write.
19
+ * Upstream's reopen is a db.update, which in Actual is a CRDT message that syncs to every
20
+ * client, so a no-op write is real sync traffic.
17
21
  */
18
22
  const tool = {
19
23
  name: 'actual_accounts_reopen',
20
24
  description: 'Reopen a previously closed account in Actual Budget. The account becomes active again and ' +
21
25
  'visible in all views, and all historical transactions remain intact. An id that is not an ' +
22
26
  '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.',
27
+ 'an account closed while it had NO transactions was removed by Actual, not closed, and ' +
28
+ 'cannot be reopened. Reopening an account that is already open reports that rather than ' +
29
+ 'claiming a change, and issues no write. ' +
30
+ 'If this call reports an error, re-read the account with actual_accounts_list before ' +
31
+ 'retrying: the state is verified AFTER the write, so a failure of that verification read ' +
32
+ 'is reported even though the reopen itself may have succeeded.',
25
33
  inputSchema: InputSchema,
26
34
  call: async (args, _meta) => {
27
35
  const input = InputSchema.parse(args || {});
28
- await adapter.reopenAccount(input.id);
29
- return { success: true };
36
+ const result = await adapter.reopenAccount(input.id);
37
+ if (result.outcome === 'already-open') {
38
+ return {
39
+ success: true,
40
+ alreadyOpen: true,
41
+ message: `Account "${result.name ?? input.id}" was already open. Nothing changed.`,
42
+ };
43
+ }
44
+ return { success: true, reopened: true };
30
45
  },
31
46
  };
32
47
  export default tool;
@@ -24,7 +24,11 @@ const tool = {
24
24
  'Actual can hold LESS than requested: it clamps the hold to the amount still left to budget, ' +
25
25
  'and holds nothing at all when that is zero or negative. The response reports the amount ' +
26
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.',
27
+ 'Note: This operates on the month as a whole, not on a specific category.\n\n' +
28
+ 'If this call reports an error, re-read the month with actual_budgets_getMonth before ' +
29
+ 'retrying: the amount held is measured by reading the month AFTER the write, so a failure ' +
30
+ 'of that verification read is reported even though the hold itself may have succeeded. ' +
31
+ 'Retrying blindly would hold the amount twice, because Actual ADDS to the existing buffer.',
28
32
  inputSchema: InputSchema,
29
33
  call: async (args, _meta) => {
30
34
  const input = InputSchema.parse(args || {});
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import adapter from '../lib/actual-adapter.js';
3
+ import { isPreflightRefusal } from '../lib/errors.js';
3
4
  const InputSchema = z.object({
4
5
  // #361: this was a bare z.string().min(1), so 'banana' parsed and reached the API. The
5
6
  // regex matches actual_budgets_holdForNextMonth and the other month-taking tools; the
@@ -23,20 +24,19 @@ const tool = {
23
24
  }
24
25
  catch (error) {
25
26
  const msg = error instanceof Error ? error.message : String(error);
26
- const lower = msg.toLowerCase();
27
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:
28
+ // shape, so a caller can handle "you asked for something that cannot be acted on"
29
+ // once rather than parsing two forms:
30
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)
31
+ // NotFoundRefusal the category id does not exist (#89)
32
+ // OutOfRangeRefusal the month is outside the budget's range (#361)
33
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) {
34
+ // #377: this used to ask `msg.includes('not found') && msg.includes('category')`,
35
+ // so rewording either message in the adapter silently flipped this tool from a
36
+ // structured refusal to a thrown error, with nothing to catch it. The decision is
37
+ // now made by TYPE, and the message is free to change. Anything that is not a
38
+ // refusal (a genuine upstream or transport failure) still throws.
39
+ if (isPreflightRefusal(error)) {
40
40
  return { success: false, error: msg };
41
41
  }
42
42
  throw new Error(`Failed to set budget amount: ${msg}`);
@@ -1,32 +1,21 @@
1
1
  import { z } from 'zod';
2
2
  import adapter from '../lib/actual-adapter.js';
3
- import { notFoundMsg } from '../lib/errors.js';
4
- import api from '@actual-app/api';
5
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
6
- const { getCategoryGroups: rawGetCategoryGroups, deleteCategoryGroup: rawDeleteCategoryGroup } = api;
7
3
  const InputSchema = z.object({
8
4
  id: z.string().describe('Category group ID to delete'),
9
5
  });
6
+ /**
7
+ * #376: the existence guard lives in `adapter.deleteCategoryGroup`, which reads and writes
8
+ * in one write-queue cycle. It used to sit here inside the tool's own `withWriteSession`,
9
+ * which cost `retry` on the read and left the adapter method unguarded.
10
+ */
10
11
  const tool = {
11
12
  name: 'actual_category_groups_delete',
12
13
  description: `Delete a category group from Actual Budget. Note: Categories within the group will be moved to a default group or ungrouped. This operation cannot be undone.`,
13
14
  inputSchema: InputSchema,
14
15
  call: async (args, _meta) => {
15
16
  const input = InputSchema.parse(args || {});
16
- // Read+write inside one withWriteSession cycle (#142).
17
- return await adapter.withWriteSession(async () => {
18
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
19
- const groups = await rawGetCategoryGroups();
20
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
21
- const groupExists = groups.some((g) => g.id === input.id);
22
- if (!groupExists) {
23
- // Throw (not return {success:false}) so a non-existent id surfaces as an MCP
24
- // error, consistent with every other delete tool's not-found behavior.
25
- throw new Error(notFoundMsg('Category group', input.id, 'actual_category_groups_get'));
26
- }
27
- await rawDeleteCategoryGroup(input.id);
28
- return { success: true };
29
- });
17
+ await adapter.deleteCategoryGroup(input.id);
18
+ return { success: true };
30
19
  },
31
20
  };
32
21
  export default tool;
@@ -1,7 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { createTool } from '../lib/toolFactory.js';
3
3
  import adapter from '../lib/actual-adapter.js';
4
- const BUDGET_MONTH_RE = /^budget-\d{4}-\d{2}$/;
4
+ import { isPreflightRefusal } from '../lib/errors.js';
5
5
  export default createTool({
6
6
  name: 'actual_notes_update',
7
7
  description: 'Set or clear the note attached to an entity in Actual Budget. ' +
@@ -17,30 +17,25 @@ export default createTool({
17
17
  note: z.string().describe('Note text to set. Pass an empty string to clear the note.'),
18
18
  }),
19
19
  handler: async ({ id, note }) => {
20
- // Fast path: budget-YYYY-MM synthetic ids need no entity lookup.
21
- if (!BUDGET_MONTH_RE.test(id)) {
22
- // Validate that the id resolves to a known entity.
23
- // Fetch in parallel to minimise latency.
24
- const [accounts, categories, categoryGroups, payees] = await Promise.all([
25
- adapter.getAccounts(),
26
- adapter.getCategories(),
27
- adapter.getCategoryGroups(),
28
- adapter.getPayees(),
29
- ]);
30
- const known = (Array.isArray(accounts) && accounts.some((e) => e.id === id)) ||
31
- (Array.isArray(categories) && categories.some((e) => e.id === id)) ||
32
- (Array.isArray(categoryGroups) && categoryGroups.some((e) => e.id === id)) ||
33
- (Array.isArray(payees) && payees.some((e) => e.id === id));
34
- if (!known) {
35
- return {
36
- error: `Entity "${id}" not found. ` +
37
- 'The id must be a UUID from actual_accounts_list, actual_categories_get, ' +
38
- 'actual_category_groups_get, or actual_payees_get, ' +
39
- 'or a budget month id like "budget-2026-01".',
40
- };
20
+ // #376: the orphan-id guard lives in `adapter.updateNote`, which reads and writes in
21
+ // ONE write-queue cycle. It used to sit here as four `adapter.get*` calls plus the
22
+ // write, which was five api lock acquisitions for one operation; `Promise.all` made
23
+ // that look concurrent, but the api mutex is process-global so they serialised anyway.
24
+ //
25
+ // This tool answers a refusal with `{ error }` rather than throwing. That shape is a
26
+ // documented deviation from the refusal taxonomy (see the api-design-principles skill)
27
+ // and it is this tool's PUBLISHED contract, so it is preserved here rather than
28
+ // quietly changed; #377 tracks the decision. What did change is that the shape is
29
+ // chosen by TYPE now, not by matching the message.
30
+ try {
31
+ await adapter.updateNote(id, note);
32
+ }
33
+ catch (error) {
34
+ if (isPreflightRefusal(error)) {
35
+ return { error: error.message };
41
36
  }
37
+ throw error;
42
38
  }
43
- await adapter.updateNote(id, note);
44
39
  return {
45
40
  success: true,
46
41
  id,
@@ -1,8 +1,13 @@
1
1
  import { z } from 'zod';
2
2
  import adapter from '../lib/actual-adapter.js';
3
+ import { CommonSchemas } from '../lib/schemas/common.js';
3
4
  const InputSchema = z.object({
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
+ // #365: the shared payee-id schema, which is the UUID pattern. #356 bounded this at
6
+ // .max(64) (the id is echoed into the error message and the logs) but deliberately
7
+ // stopped short of the regex, because adopting it meant rewriting non-UUID fixtures in
8
+ // two test files and that was not the transfer-payee bug #356 existed to fix. The
9
+ // regex is strictly tighter than the bound it replaces, so the echo is still bounded.
10
+ id: CommonSchemas.payeeId.describe('Payee ID to delete'),
6
11
  });
7
12
  const tool = {
8
13
  name: 'actual_payees_delete',
@@ -1,13 +1,16 @@
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
+ import { CommonSchemas } from '../lib/schemas/common.js';
4
+ // #365: the shared payee-id schema (the UUID pattern) on both axes, replacing the
5
+ // #356 length bound. #356 bounded rather than typed these because the regex would have
6
+ // rejected the non-UUID fixtures the existing tests fed these exact tools, and that
7
+ // sweep was not the transfer-payee bug it existed to fix. The array bound is unchanged
8
+ // and is a separate concern: the ids are echoed back in error messages and into the
9
+ // structured logs, and no legitimate merge consolidates more than a few dozen payees in
10
+ // one call.
8
11
  const InputSchema = z.object({
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)
12
+ targetId: CommonSchemas.payeeId.describe('ID of the target payee to merge into (this payee will be retained)'),
13
+ mergeIds: z.array(CommonSchemas.payeeId).min(1).max(50)
11
14
  .describe('Array of payee IDs to merge into the target payee (these will be consolidated)'),
12
15
  });
13
16
  const tool = {
@@ -18,7 +21,8 @@ const tool = {
18
21
  'each account) cannot be merged in either direction: such a merge is refused rather than ' +
19
22
  'silently ignored. The response lists the de-duplicated ids that were ACCEPTED for merge ' +
20
23
  '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.',
24
+ 'need proof the payees are gone. mergeIds must contain between 1 and 50 ids: an empty array ' +
25
+ 'is an error rather than a no-op, and a larger consolidation must be split across calls.',
22
26
  inputSchema: InputSchema,
23
27
  call: async (args, _meta) => {
24
28
  const input = InputSchema.parse(args || {});
@@ -14,14 +14,16 @@
14
14
  * Credit: ZanzyTHEbar (https://github.com/ZanzyTHEbar)
15
15
  *
16
16
  * Adapted for this project's conventions:
17
- * - No wrapToolCall — uses direct call() pattern
18
- * - Reuses exact same ConditionSchema / ActionSchema / FIELD_OPERATORS as rules_create.ts
17
+ * - No wrapToolCall: uses the direct call() pattern
18
+ * - Reuses the exact same ConditionSchema / ActionSchema / FIELD_OPERATORS as rules_create.ts
19
+ *
20
+ * #376: the read-match-write cycle lives in `adapter.upsertRule` (identity rules in
21
+ * `src/lib/rule-matching.ts`). This tool owns the schema, the operator/UUID validation and
22
+ * the response. It used to run the cycle itself inside `withWriteSession` with raw api
23
+ * calls, which meant no `retry` on the read and a second observability blind spot.
19
24
  */
20
25
  import { z } from 'zod';
21
- import adapter, { normalizeToId } from '../lib/actual-adapter.js';
22
- import api from '@actual-app/api';
23
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
24
- const { getRules: rawGetRules, createRule: rawCreateRule, updateRule: rawUpdateRule } = api;
26
+ import adapter from '../lib/actual-adapter.js';
25
27
  // Mirrors the same schemas used in rules_create.ts
26
28
  const ConditionSchema = z.object({
27
29
  field: z.string().describe('Field to match (e.g., "payee", "notes", "amount", "category", "imported_payee")'),
@@ -70,42 +72,6 @@ const InputSchema = z.object({
70
72
  conditions: z.array(ConditionSchema).describe('Array of conditions that must be met'),
71
73
  actions: z.array(ActionSchema).describe('Array of actions to perform when conditions match'),
72
74
  });
73
- /**
74
- * Normalize a single object for stable JSON comparison by sorting its keys
75
- * and stripping undefined values.
76
- */
77
- function canonicalize(obj) {
78
- const sorted = {};
79
- for (const key of Object.keys(obj).sort()) {
80
- if (obj[key] !== undefined)
81
- sorted[key] = obj[key];
82
- }
83
- return JSON.stringify(sorted);
84
- }
85
- /**
86
- * Return true when two rules have semantically equivalent conditions.
87
- * Matching is set-based on (field, op, value) triples — order is irrelevant.
88
- */
89
- function conditionsMatch(existingConditions, existingConditionsOp, newConditions, newConditionsOp) {
90
- if ((existingConditionsOp || 'and') !== newConditionsOp)
91
- return false;
92
- if (!Array.isArray(existingConditions))
93
- return false;
94
- if (existingConditions.length !== newConditions.length)
95
- return false;
96
- const existingSet = new Set(existingConditions.map((c) => {
97
- const cond = c;
98
- return canonicalize({ field: cond.field, op: cond.op, value: cond.value });
99
- }));
100
- const newSet = new Set(newConditions.map((c) => canonicalize({ field: c.field, op: c.op, value: c.value })));
101
- if (existingSet.size !== newSet.size)
102
- return false;
103
- for (const item of newSet) {
104
- if (!existingSet.has(item))
105
- return false;
106
- }
107
- return true;
108
- }
109
75
  const tool = {
110
76
  name: 'actual_rules_create_or_update',
111
77
  description: `Create a rule if no matching rule exists, or update the existing rule if one with the same conditions already exists. Prevents duplicate rules.
@@ -163,57 +129,25 @@ Returns: { id, created: boolean } — created=true if new rule was created, fals
163
129
  throw new Error(`Action "${action.op}" requires a string value.`);
164
130
  }
165
131
  }
166
- // Fetch existing rules and either update (matched) or create (no match) inside
167
- // ONE withWriteSession cycle so the read and the write share a single lock
168
- // acquisition (#142).
169
- return await adapter.withWriteSession(async () => {
170
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
171
- const existingRules = await rawGetRules();
172
- let matchedRule = null;
173
- for (const rule of existingRules) {
174
- const r = rule;
175
- if (!r.id || typeof r.id !== 'string')
176
- continue;
177
- const existingConditions = Array.isArray(r.conditions) ? r.conditions : [];
178
- const existingConditionsOp = r.conditionsOp || 'and';
179
- if (conditionsMatch(existingConditions, existingConditionsOp, input.conditions, input.conditionsOp)) {
180
- matchedRule = r;
181
- break;
182
- }
183
- }
184
- const ruleData = JSON.parse(JSON.stringify(input)); // deep clone for API call
185
- if (matchedRule) {
186
- // UPDATE existing rule. The Actual Budget API expects the FULL merged rule
187
- // object passed as a single argument (matches adapter.updateRule's behaviour).
188
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
189
- const merged = {
190
- id: matchedRule.id,
191
- // #342: `??` is WRONG for stage. null is a MEANINGFUL value here (Actual's
192
- // default stage), not an absent one, so `ruleData.stage ?? existing` would
193
- // silently discard an explicit `stage: null` and keep the old stage,
194
- // making it impossible to move a rule back to the default stage. Decide on
195
- // PRESENCE of the key instead. Every other field below is fine with `??`
196
- // because none of them treats null as a distinct legal value.
197
- stage: 'stage' in ruleData ? ruleData.stage : matchedRule.stage,
198
- conditionsOp: ruleData.conditionsOp ?? matchedRule.conditionsOp,
199
- conditions: ruleData.conditions ?? matchedRule.conditions ?? [],
200
- actions: ruleData.actions ?? matchedRule.actions ?? [],
201
- };
202
- await rawUpdateRule(merged);
203
- return { id: matchedRule.id, created: false };
204
- }
205
- else {
206
- // CREATE new rule.
207
- // #342: stage has no Zod default on this tool (see the schema note), so an
208
- // omitted stage arrives as undefined. Actual's validator ALWAYS runs on a
209
- // create and rejects undefined with `Invalid rule stage: undefined`, so the
210
- // key must be present. null is the correct value: Actual's default stage.
211
- if (ruleData.stage === undefined)
212
- ruleData.stage = null;
213
- const rawId = await rawCreateRule(ruleData);
214
- return { id: normalizeToId(rawId), created: true };
215
- }
216
- });
132
+ // The read, the match and the write happen in ONE write-queue cycle inside
133
+ // adapter.upsertRule (#142, relocated in #376).
134
+ //
135
+ // `stage` is passed as an explicit presence flag, because null is a MEANINGFUL stage
136
+ // (Actual's default) and "omitted" must stay distinguishable from "explicitly null"
137
+ // across the call boundary (#342).
138
+ //
139
+ // Derived from the PARSED value, not from `hasOwnProperty` on the raw args. The schema
140
+ // is `.nullable().optional()` with no default, so `undefined` means omitted and `null`
141
+ // means the default stage; a raw-args key test would call an explicit
142
+ // `{ stage: undefined }` "supplied" and send `undefined` into the update, which is the
143
+ // exact value #342 records Actual rejecting with `Invalid rule stage: undefined`.
144
+ // Unreachable over JSON-RPC (which cannot carry undefined) but reachable in-process.
145
+ return await adapter.upsertRule({
146
+ stage: input.stage,
147
+ conditionsOp: input.conditionsOp,
148
+ conditions: input.conditions,
149
+ actions: input.actions,
150
+ }, input.stage !== undefined);
217
151
  },
218
152
  };
219
153
  export default tool;
@@ -1,14 +1,21 @@
1
1
  import { z } from 'zod';
2
2
  import adapter from '../lib/actual-adapter.js';
3
- import { notFoundMsg } from '../lib/errors.js';
4
- import api from '@actual-app/api';
5
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
6
- const { getRules: rawGetRules, deleteRule: rawDeleteRule } = api;
7
3
  const InputSchema = z.object({
8
4
  // 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.
5
+ // not-found message, into the schedule-owned refusal, and into logger.error.
10
6
  id: z.string().min(1).max(64).describe('Rule ID to delete'),
11
7
  });
8
+ /**
9
+ * #355 fixed two defects here (a silent no-op on an unknown id, and success reported for a
10
+ * schedule-owned rule that upstream declined to delete). #376 moved both guards into
11
+ * `adapter.deleteRule`, which performs the read, the decision and the write in one
12
+ * write-queue cycle.
13
+ *
14
+ * This tool now owns only the published schema and the response wording, which is where the
15
+ * rest of the tool surface keeps them. Doing the guard in the adapter restores `retry` on
16
+ * the read, keeps ONE observability call site, and leaves no unguarded `adapter.deleteRule`
17
+ * for a future caller to reach for. See "Where a read-then-write guard belongs" in CLAUDE.md.
18
+ */
12
19
  const tool = {
13
20
  name: 'actual_rules_delete',
14
21
  description: 'Delete a budget rule from Actual Budget. The rule will no longer be applied to new or ' +
@@ -17,36 +24,8 @@ const tool = {
17
24
  inputSchema: InputSchema,
18
25
  call: async (args, _meta) => {
19
26
  const input = InputSchema.parse(args || {});
20
- // Read+write inside one withWriteSession cycle (#142).
21
- return await adapter.withWriteSession(async () => {
22
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
23
- const allRules = await rawGetRules();
24
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
25
- const ruleExists = allRules.some((r) => r.id === input.id);
26
- if (!ruleExists) {
27
- // Throw (not return {success:false}) so a non-existent id surfaces as an MCP
28
- // error, consistent with every other delete tool's not-found behavior.
29
- throw new Error(notFoundMsg('Rule', input.id, 'actual_rules_get'));
30
- }
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
- }
48
- return { success: true };
49
- });
27
+ await adapter.deleteRule(input.id);
28
+ return { success: true };
50
29
  },
51
30
  };
52
31
  export default tool;
@@ -1,46 +1,23 @@
1
1
  import { z } from 'zod';
2
2
  import adapter from '../lib/actual-adapter.js';
3
3
  import { UUID_PATTERN } from '../lib/constants.js';
4
- import { notFoundMsg, constraintErrorMsg } from '../lib/errors.js';
5
- import api from '@actual-app/api';
6
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
7
- const { getSchedules: rawGetSchedules, deleteSchedule: rawDeleteSchedule } = api;
8
4
  const InputSchema = z.object({
9
5
  id: z.string().regex(UUID_PATTERN, 'Invalid UUID format')
10
6
  .describe('UUID of the schedule to delete (from actual_schedules_get)'),
11
7
  });
8
+ /**
9
+ * #376: the existence guard and the SQLite constraint-error translation live in
10
+ * `adapter.deleteSchedule`, which reads and writes in one write-queue cycle. They used to
11
+ * sit here inside the tool's own `withWriteSession`.
12
+ */
12
13
  const tool = {
13
14
  name: 'actual_schedules_delete',
14
15
  description: `Permanently delete a schedule from Actual Budget. The schedule's underlying rule is also removed. This operation cannot be undone.`,
15
16
  inputSchema: InputSchema,
16
17
  call: async (args, _meta) => {
17
18
  const input = InputSchema.parse(args || {});
18
- // Read+write inside one withWriteSession cycle (#142). The constraint-error
19
- // translation stays right around the delete call so error messages do not regress.
20
- return await adapter.withWriteSession(async () => {
21
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
22
- const schedules = await rawGetSchedules();
23
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
24
- const scheduleExists = schedules.some((s) => s.id === input.id);
25
- if (!scheduleExists) {
26
- // Throw (not return {success:false}) so a non-existent id surfaces as an MCP
27
- // error, consistent with every other delete tool's not-found behavior.
28
- throw new Error(notFoundMsg('Schedule', input.id, 'actual_schedules_get'));
29
- }
30
- try {
31
- await rawDeleteSchedule(input.id);
32
- return { success: true };
33
- }
34
- catch (err) {
35
- const msg = err instanceof Error ? err.message : String(err);
36
- if (msg.includes('NOT NULL constraint') || msg.includes('messages_crdt')) {
37
- // Translate the raw SQLite constraint error into an actionable message, thrown
38
- // for the same consistency reason as the not-found case above.
39
- throw new Error(constraintErrorMsg('Schedule', input.id, 'actual_schedules_get'));
40
- }
41
- throw err;
42
- }
43
- });
19
+ await adapter.deleteSchedule(input.id);
20
+ return { success: true };
44
21
  },
45
22
  };
46
23
  export default tool;
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import adapter from '../lib/actual-adapter.js';
3
+ import { isPreflightRefusal } from '../lib/errors.js';
3
4
  import { CommonSchemas, subtransactionsSum } from '../lib/schemas/common.js';
4
5
  const InputSchema = z
5
6
  .object({
@@ -69,8 +70,11 @@ const tool = {
69
70
  }
70
71
  catch (error) {
71
72
  const msg = error instanceof Error ? error.message : String(error);
72
- if (msg.toLowerCase().includes('not found') && msg.toLowerCase().includes('account')) {
73
- // Return structured error (not throw) so callers receive { success: false, error }
73
+ // #377: decided by TYPE, not by substring-matching the adapter's prose. The refusal
74
+ // this catches is adapter.addTransactions' account-existence guard, which throws a
75
+ // NotFoundRefusal and writes nothing. Returning it structured (rather than throwing)
76
+ // is this tool's published contract; a genuine failure still throws.
77
+ if (isPreflightRefusal(error)) {
74
78
  return { success: false, error: msg, id: null };
75
79
  }
76
80
  throw new Error(`Failed to create transaction: ${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.13.0",
4
+ "version": "0.14.0",
5
5
  "engines": {
6
6
  "node": ">=22.0.0",
7
7
  "npm": ">=10.0.0"
@@ -61,6 +61,7 @@
61
61
  "tool-count": "node scripts/tool-count.mjs",
62
62
  "config-drift": "npm run build && node scripts/config-drift.mjs",
63
63
  "node-version-drift": "node scripts/node-version-drift.mjs",
64
+ "typecheck:e2e": "tsc --noEmit --project tests/e2e/tsconfig.json",
64
65
  "knip": "knip",
65
66
  "actionlint": "bash scripts/install-actionlint.sh && ./actionlint -shellcheck= -color",
66
67
  "audit:write-effect": "node scripts/check-write-effect-audit.mjs"