actual-mcp-server 0.11.6 → 0.12.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
@@ -314,7 +314,7 @@ For Claude Desktop (stdio), restart Claude after upgrading.
314
314
  | `actual_accounts_create` | Create new account |
315
315
  | `actual_accounts_update` | Update account details |
316
316
  | `actual_accounts_delete` | Permanently delete account |
317
- | `actual_accounts_close` | Close account (soft delete) |
317
+ | `actual_accounts_close` | Close account. An account with NO transactions is REMOVED by Actual, not closed |
318
318
  | `actual_accounts_reopen` | Reopen closed account |
319
319
  | `actual_accounts_get_balance` | Get account balance at a date |
320
320
 
@@ -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.12.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;
@@ -1267,10 +1288,26 @@ export async function deletePayee(id) {
1267
1288
  return queueWriteOperation(async () => {
1268
1289
  // Pre-flight: verify payee exists to avoid ECONNRESET on missing id (BUG-2)
1269
1290
  const payees = await withConcurrency(() => retry(() => rawGetPayees(), { retries: 2, backoffMs: 200 }));
1270
- const exists = payees.some((p) => p.id === id);
1291
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1292
+ const found = payees.find((p) => p.id === id);
1293
+ const exists = Boolean(found);
1271
1294
  if (!exists) {
1272
1295
  throw new Error(`Payee "${id}" not found. Use actual_payees_get to list available payees.`);
1273
1296
  }
1297
+ // #356: the existence check above is not enough. Actual refuses to delete a
1298
+ // TRANSFER payee (the payee it auto-creates for each account) and refuses
1299
+ // SILENTLY: `db.deletePayee` opens with `if (transfer_acct) { return; }`, so the
1300
+ // call returns normally having done nothing and the tool reported success.
1301
+ //
1302
+ // `getPayees()` deliberately INCLUDES transfer payees, which is why they reach
1303
+ // this point at all, and it selects `COALESCE(a.name, p.name) AS name`, so a
1304
+ // transfer payee's own `name` is already the owning account's name. No second
1305
+ // lookup is needed to write a useful message.
1306
+ if (found?.transfer_acct) {
1307
+ throw new Error(`Payee "${found.name ?? id}" is a TRANSFER payee: it belongs to the account of the ` +
1308
+ 'same name and Actual will not delete it on its own. Delete the account instead ' +
1309
+ 'with actual_accounts_delete, which removes its transfer payee too.');
1310
+ }
1274
1311
  await withConcurrency(() => retry(() => rawDeletePayee(id), { retries: 0, backoffMs: 200 }));
1275
1312
  });
1276
1313
  }
@@ -1325,10 +1362,24 @@ export async function updateRule(id, fields) {
1325
1362
  logger.debug(`[UPDATE RULE] Update completed for rule ${id}`);
1326
1363
  });
1327
1364
  }
1365
+ /**
1366
+ * #355: RETURNS the upstream verdict instead of discarding it, for the same reason
1367
+ * as `holdBudgetForNextMonth` above. Upstream `deleteRule`
1368
+ * (loot-core/src/server/transactions/transaction-rules.ts) returns `false` without
1369
+ * throwing when a schedule owns the rule, because Actual keeps a schedule and its
1370
+ * generated rule in step and will not let the rule be removed on its own.
1371
+ *
1372
+ * `actual_rules_delete` does not route through here (it calls the raw API inside one
1373
+ * `withWriteSession` cycle, per #142), so this method currently has NO caller, and the
1374
+ * same is true of `holdBudgetForNextMonth` below since #355 moved that tool to the same
1375
+ * pattern. Both are kept, and both carry the verdict, so that the next caller inherits
1376
+ * the correct contract instead of rediscovering this the hard way. Neither widening is
1377
+ * exercised by a test today, for the same reason: nothing calls them.
1378
+ */
1328
1379
  export async function deleteRule(id) {
1329
1380
  observability.incrementToolCall('actual.rules.delete').catch(() => { });
1330
1381
  return queueWriteOperation(async () => {
1331
- await withConcurrency(() => retry(() => rawDeleteRule(id), { retries: 0, backoffMs: 200 }));
1382
+ return await withConcurrency(() => retry(() => rawDeleteRule(id), { retries: 0, backoffMs: 200 }));
1332
1383
  });
1333
1384
  }
1334
1385
  export async function getSchedules() {
@@ -1366,11 +1417,22 @@ export async function setBudgetCarryover(month, categoryId, flag) {
1366
1417
  await withConcurrency(() => retry(() => rawSetBudgetCarryover(month, categoryId, flag), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
1367
1418
  });
1368
1419
  }
1369
- export async function closeAccount(id) {
1420
+ /**
1421
+ * #357: forwards the two transfer arguments the published API documents.
1422
+ *
1423
+ * `closeAccount(id, transferAccountId?, transferCategoryId?)` is the documented
1424
+ * signature, and upstream throws `balance is non-zero: transferAccountId is required`
1425
+ * when an account with a balance is closed without one. This adapter used to pass the
1426
+ * id alone, so an account with a non-zero balance could not be closed through this
1427
+ * server at all: the caller was told to supply a parameter no tool accepted.
1428
+ *
1429
+ * Both remain OPTIONAL. A zero-balance account still closes with the id alone.
1430
+ */
1431
+ export async function closeAccount(id, transferAccountId, transferCategoryId) {
1370
1432
  observability.incrementToolCall('actual.accounts.close').catch(() => { });
1371
1433
  return queueWriteOperation(async () => {
1372
1434
  // Non-idempotent: do not retry (#165).
1373
- await withConcurrency(() => retry(() => rawCloseAccount(id), { retries: 0, backoffMs: 200 }));
1435
+ await withConcurrency(() => retry(() => rawCloseAccount(id, transferAccountId, transferCategoryId), { retries: 0, backoffMs: 200 }));
1374
1436
  });
1375
1437
  }
1376
1438
  export async function reopenAccount(id) {
@@ -1407,12 +1469,72 @@ export async function deleteCategoryGroup(id) {
1407
1469
  await withConcurrency(() => retry(() => rawDeleteCategoryGroup(id), { retries: 0, backoffMs: 200 }));
1408
1470
  });
1409
1471
  }
1472
+ /**
1473
+ * #356: bound what an error message echoes back. The caller supplies these ids, they end
1474
+ * up in the tool response AND in `logger.error` via actualToolsManager, and `mergeIds`
1475
+ * accepts up to 50 of them at 64 characters each. Naming a handful is enough to act on;
1476
+ * naming all of them is an unbounded echo of caller-controlled input.
1477
+ */
1478
+ function summariseIds(ids, limit = 5) {
1479
+ const shown = ids.slice(0, limit).map((id) => (id.length > 64 ? `${id.slice(0, 64)}...` : id));
1480
+ const rest = ids.length - shown.length;
1481
+ return rest > 0 ? `${shown.join(', ')} (and ${rest} more)` : shown.join(', ');
1482
+ }
1483
+ /**
1484
+ * #356: merge, with the pre-flight it never had.
1485
+ *
1486
+ * Upstream `db.mergePayees` fails silently in two ways and throws unhelpfully in a
1487
+ * third:
1488
+ *
1489
+ * if (payees[target].transfer_acct != null) { return; } // silent no-op
1490
+ * ids = ids.filter(id => payees[id].transfer_acct == null); // sources dropped
1491
+ *
1492
+ * A transfer-payee target makes the whole call a no-op; transfer-payee sources are
1493
+ * dropped from the list without a word, while the tool still reported "merged N
1494
+ * payee(s)" using the length of its own INPUT. And an id that does not exist at all
1495
+ * is indexed straight off the map, so it surfaces as
1496
+ * `TypeError: Cannot read properties of undefined (reading 'transfer_acct')`.
1497
+ *
1498
+ * One `getPayees()` read inside this same write cycle answers all three. It returns the
1499
+ * de-duplicated ids it ACCEPTED for merge, which after those three refusals is the set
1500
+ * upstream will act on. Note what it is not: there is no post-write read here, unlike
1501
+ * close, reopen and hold, so a drop reason nobody has enumerated would still go unnoticed.
1502
+ */
1410
1503
  export async function mergePayees(targetId, mergeIds) {
1411
1504
  observability.incrementToolCall('actual.payees.merge').catch(() => { });
1412
1505
  return queueWriteOperation(async () => {
1506
+ const payees = await withConcurrency(() => retry(() => rawGetPayees(), { retries: 2, backoffMs: 200 }));
1507
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1508
+ const byId = new Map(payees.map((p) => [p.id, p]));
1509
+ const target = byId.get(targetId);
1510
+ if (!target) {
1511
+ throw new Error(`Payee "${targetId}" not found. Use actual_payees_get to list available payees.`);
1512
+ }
1513
+ if (target.transfer_acct) {
1514
+ throw new Error(`Target payee "${target.name ?? targetId}" is a TRANSFER payee and cannot be merged ` +
1515
+ 'into. Actual silently ignores such a merge. Pick a normal payee as the target.');
1516
+ }
1517
+ const missing = mergeIds.filter((id) => !byId.has(id));
1518
+ if (missing.length > 0) {
1519
+ throw new Error(`Payee(s) not found: ${summariseIds(missing)}. Use actual_payees_get to list available payees.`);
1520
+ }
1521
+ const transfers = mergeIds.filter((id) => byId.get(id)?.transfer_acct);
1522
+ if (transfers.length > 0) {
1523
+ const names = transfers.map((id) => String(byId.get(id)?.name ?? id));
1524
+ throw new Error(`Cannot merge TRANSFER payee(s): ${summariseIds(names)}. They belong to accounts of the ` +
1525
+ 'same name, and Actual silently drops them from a merge rather than merging them. ' +
1526
+ 'Remove them from mergeIds, or delete the account with actual_accounts_delete.');
1527
+ }
1528
+ if (mergeIds.includes(targetId)) {
1529
+ throw new Error(`Payee "${targetId}" appears as both the merge target and a merge source.`);
1530
+ }
1531
+ // De-duplicate before reporting, or `['p','p']` claims two merges for one. The whole
1532
+ // point of returning ids is that the count describes what happened.
1533
+ const unique = [...new Set(mergeIds)];
1413
1534
  // Non-idempotent: do not retry (#165). A second merge against an
1414
1535
  // already-removed source payee can corrupt merge state or mislead.
1415
- await withConcurrency(() => retry(() => rawMergePayees(targetId, mergeIds), { retries: 0, backoffMs: 200 }));
1536
+ await withConcurrency(() => retry(() => rawMergePayees(targetId, unique), { retries: 0, backoffMs: 200 }));
1537
+ return unique;
1416
1538
  });
1417
1539
  }
1418
1540
  /**
@@ -1463,10 +1585,29 @@ export async function batchBudgetUpdates(fn) {
1463
1585
  await withConcurrency(() => retry(() => rawBatchBudgetUpdates(fn), { retries: 2, backoffMs: 200 }));
1464
1586
  });
1465
1587
  }
1588
+ /**
1589
+ * #355: RETURNS the upstream verdict instead of discarding it.
1590
+ *
1591
+ * Upstream `holdForNextMonth` (loot-core/src/server/budget/actions.ts) returns a
1592
+ * BOOLEAN: `true` when it buffered the amount, `false` when the month's To Budget
1593
+ * is not positive and it held nothing. It does not throw in the second case. The
1594
+ * published reference documents the method as `Promise<null>`, which is why the
1595
+ * value was being thrown away here and the tool reported success for a hold that
1596
+ * never happened (CWE-252, unchecked return value).
1597
+ *
1598
+ * `undefined` is deliberately NOT treated as a refusal: an older or future build
1599
+ * that returns nothing must keep working. Only an explicit `false` is a verdict.
1600
+ */
1466
1601
  export async function holdBudgetForNextMonth(month, amount) {
1467
1602
  observability.incrementToolCall('actual.budgets.holdForNextMonth').catch(() => { });
1468
1603
  return queueWriteOperation(async () => {
1469
- await withConcurrency(() => retry(() => rawHoldBudgetForNextMonth(month, amount), { retries: 2, backoffMs: 200 }));
1604
+ // Non-idempotent: do not retry (#165). `calcBufferedAmount` is ADDITIVE
1605
+ // (`return buffered + amount`), so a second attempt after a committed first does not
1606
+ // re-set the buffer, it adds to it. The window is narrow, because the call runs
1607
+ // against local SQLite and no `TRANSIENT_ERROR_PATTERNS` message can arise inside
1608
+ // it, but the operation is non-idempotent by construction and belongs on the same
1609
+ // footing as `deleteRule` and `closeAccount`.
1610
+ return await withConcurrency(() => retry(() => rawHoldBudgetForNextMonth(month, amount), { retries: 0, backoffMs: 200 }));
1470
1611
  });
1471
1612
  }
1472
1613
  export async function resetBudgetHold(month) {
@@ -1,16 +1,172 @@
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
+ import * as observability from '../observability.js';
4
+ import api from '@actual-app/api';
5
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
6
+ const { getAccounts: rawGetAccounts, getCategories: rawGetCategories, closeAccount: rawCloseAccount } = api;
7
+ const InputSchema = z
8
+ .object({
9
+ id: z.string().min(1).max(64).describe('Account ID to close'),
10
+ // #357: documented by the Actual API reference as required when the account's
11
+ // balance is non-zero, and previously not exposed at all, which made such an
12
+ // account impossible to close through this server.
13
+ transferAccountId: z
14
+ .string()
15
+ .min(1)
16
+ .max(64)
17
+ .optional()
18
+ .describe('Account ID to move the remaining balance to. REQUIRED when the account has a non-zero balance.'),
19
+ transferCategoryId: z
20
+ .string()
21
+ .min(1)
22
+ .max(64)
23
+ .optional()
24
+ .describe('Category to assign the balancing transaction to. Optional, used with transferAccountId.'),
25
+ })
26
+ .superRefine((val, ctx) => {
27
+ if (val.transferAccountId && val.transferAccountId === val.id) {
28
+ ctx.addIssue({
29
+ code: z.ZodIssueCode.custom,
30
+ path: ['transferAccountId'],
31
+ message: 'transferAccountId must be a different account from the one being closed',
32
+ });
33
+ }
5
34
  });
35
+ /**
36
+ * #357: three defects on one code path, fixed together because any one of them
37
+ * rewrites this handler.
38
+ *
39
+ * (a) FALSE SUCCESS. Upstream `closeAccount` opens with
40
+ * `if (!account || account.closed === 1) { return; }`, so closing an unknown id
41
+ * or an already-closed account did nothing and reported success. Same upstream
42
+ * early-return that produced #347.
43
+ *
44
+ * (b) CLOSE CAN DELETE. `if (numTransactions === 0) { await db.deleteAccount({id}); }`.
45
+ * An account with no transactions is TOMBSTONED rather than closed, it disappears
46
+ * from every listing, and `actual_accounts_reopen` cannot bring it back. The old
47
+ * description promised "their transaction history is preserved", which is false in
48
+ * exactly this case. The repo already knew: tests/manual/tests/account.js adds a
49
+ * dummy transaction with a comment explaining it.
50
+ *
51
+ * (c) A BALANCE MADE IT UNCLOSABLE. `transferAccountId` and `transferCategoryId` are
52
+ * documented parameters, the schema did not accept them, and upstream throws
53
+ * `balance is non-zero: transferAccountId is required` without them. The caller
54
+ * was told to supply something no tool would take.
55
+ *
56
+ * The read, the write and the verification run inside ONE `withWriteSession` cycle
57
+ * (#142). A read-BEFORE is required here, unlike #347's pure verify-after, because (b)
58
+ * makes "absent afterwards" ambiguous: an account deleted by the close and an id that
59
+ * never existed look identical from a single post-read.
60
+ *
61
+ * Going through the raw api inside the session means this path no longer gets the
62
+ * adapter's `retry`. That is inherent to the #142 pattern, shared with every tool using
63
+ * it, and it is the half of #368 that still stands. The per-tool counter is NOT lost:
64
+ * it is incremented in the handler below.
65
+ *
66
+ * Already-closed is reported as success rather than as an error, deliberately. The
67
+ * caller's intent ("this account should be closed") is already satisfied, which is the
68
+ * same idempotence argument #347 used for delete. What changes is that the response no
69
+ * longer CLAIMS a state change that did not happen.
70
+ */
6
71
  const tool = {
7
72
  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.`,
73
+ description: 'Mark an account as closed in Actual Budget. Closed accounts are hidden from most views. ' +
74
+ 'IMPORTANT: an account with NO transactions is REMOVED by Actual rather than closed, and cannot ' +
75
+ 'be reopened; an account that has transactions keeps them. If the account has a non-zero balance, ' +
76
+ 'transferAccountId is required and the remaining balance is moved there as a dated "Closing account" ' +
77
+ 'transaction. Closing an already-closed account reports that rather than claiming a change.',
9
78
  inputSchema: InputSchema,
10
79
  call: async (args, _meta) => {
11
80
  const input = InputSchema.parse(args || {});
12
- await adapter.closeAccount(input.id);
13
- return { success: true };
81
+ // #368: the adapter method that used to own this counter is no longer on the path,
82
+ // because this tool reads and writes through the raw api inside one session. Counting
83
+ // here keeps `actual.accounts.close` honest; the retry half of #368 still stands.
84
+ observability.incrementToolCall('actual.accounts.close').catch(() => { });
85
+ return await adapter.withWriteSession(async () => {
86
+ const before = (await rawGetAccounts());
87
+ const target = Array.isArray(before) ? before.find((a) => a?.id === input.id) : undefined;
88
+ if (!target) {
89
+ throw new Error(`Account "${input.id}" not found. Use actual_accounts_list to see the accounts that ` +
90
+ 'exist. Note that an account closed while it had no transactions was REMOVED by ' +
91
+ 'Actual, not closed, so it will not appear there.');
92
+ }
93
+ if (target.closed === true) {
94
+ // Idempotent and truthful: the requested state already holds, and the response
95
+ // says so instead of implying this call changed anything.
96
+ return {
97
+ success: true,
98
+ alreadyClosed: true,
99
+ message: `Account "${target.name ?? input.id}" was already closed. Nothing changed.`,
100
+ };
101
+ }
102
+ if (input.transferAccountId) {
103
+ const destination = Array.isArray(before)
104
+ ? before.find((a) => a?.id === input.transferAccountId)
105
+ : undefined;
106
+ if (!destination) {
107
+ throw new Error(`Transfer destination account "${input.transferAccountId}" not found. Use ` +
108
+ 'actual_accounts_list to pick an account to move the remaining balance to.');
109
+ }
110
+ if (destination.closed === true) {
111
+ // The balancing transaction would land in a closed account, where it is hidden
112
+ // from most views. Upstream does not stop this; refusing is the kinder answer.
113
+ throw new Error(`Transfer destination account "${destination.name ?? input.transferAccountId}" is ` +
114
+ 'CLOSED, so the closing balance would be moved somewhere hidden from most views. ' +
115
+ 'Pick an open account, or reopen that one with actual_accounts_reopen first.');
116
+ }
117
+ }
118
+ if (input.transferCategoryId) {
119
+ // #359's lesson, applied to the only NEW write this change adds. Upstream forwards
120
+ // this id straight into `transaction-add` without checking it (api.ts's
121
+ // account-close passes it as `categoryId`, and addTransaction is a bare batch
122
+ // update), so a bogus value writes a "Closing account" transaction carrying a
123
+ // category that does not exist, and syncs it to every client. Same dangling
124
+ // reference the transactions_create guard exists to prevent.
125
+ const categories = (await rawGetCategories());
126
+ const categoryExists = Array.isArray(categories)
127
+ ? categories.some((c) => c?.id === input.transferCategoryId)
128
+ : false;
129
+ if (!categoryExists) {
130
+ throw new Error(`Transfer category "${input.transferCategoryId}" not found. Use ` +
131
+ 'actual_categories_get to pick a category for the closing transaction, or omit ' +
132
+ 'transferCategoryId to leave it uncategorised.');
133
+ }
134
+ }
135
+ try {
136
+ await rawCloseAccount(input.id, input.transferAccountId, input.transferCategoryId);
137
+ }
138
+ catch (err) {
139
+ const msg = err instanceof Error ? err.message : String(err);
140
+ if (/balance is non-zero/i.test(msg)) {
141
+ // Translate upstream's message into one the caller can act on, now that the
142
+ // parameter it asks for actually exists on this tool.
143
+ throw new Error(`Account "${target.name ?? input.id}" has a non-zero balance, so closing it requires ` +
144
+ 'transferAccountId: the id of the account to move the remaining balance to. ' +
145
+ 'Optionally pass transferCategoryId to categorise the balancing transaction. ' +
146
+ 'Use actual_accounts_list to pick a destination.');
147
+ }
148
+ throw err;
149
+ }
150
+ // Verify the effect rather than the call returning (#347).
151
+ const after = (await rawGetAccounts());
152
+ const survivor = Array.isArray(after) ? after.find((a) => a?.id === input.id) : undefined;
153
+ if (!survivor) {
154
+ // Defect (b): upstream tombstones a zero-transaction account instead of closing
155
+ // it. Saying so is the whole point; the old description claimed the opposite.
156
+ return {
157
+ success: true,
158
+ removed: true,
159
+ message: `Account "${target.name ?? input.id}" had no transactions, so Actual REMOVED it ` +
160
+ 'rather than closing it. It will not appear in actual_accounts_list and cannot be ' +
161
+ 'reopened.',
162
+ };
163
+ }
164
+ if (survivor.closed !== true) {
165
+ throw new Error(`Account "${survivor.name ?? input.id}" (${input.id}) is still open after the close. ` +
166
+ 'The call was accepted but had no effect; check the account state in Actual.');
167
+ }
168
+ return { success: true, closed: true };
169
+ });
14
170
  },
15
171
  };
16
172
  export default tool;
@@ -1,16 +1,105 @@
1
1
  import { z } from 'zod';
2
2
  import adapter from '../lib/actual-adapter.js';
3
+ import * as observability from '../observability.js';
4
+ import api from '@actual-app/api';
5
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
6
+ const { getAccounts: rawGetAccounts, reopenAccount: rawReopenAccount } = api;
3
7
  const InputSchema = z.object({
4
- id: z.string().describe('Account ID to reopen'),
8
+ // Bounded, because the id is echoed into the error message and into the structured
9
+ // logs. Not tightened to CommonSchemas.accountId (a UUID) here: several fixtures use
10
+ // short non-UUID ids, and that sweep is tracked separately alongside #365.
11
+ id: z.string().min(1).max(64).describe('Account ID to reopen'),
5
12
  });
13
+ /**
14
+ * #358: this tool used to call reopen and return `{success: true}` unconditionally.
15
+ * For an id that is not an account, that was not merely a false success: it CREATED
16
+ * a nameless account.
17
+ *
18
+ * Verified in upstream source (26.8.0). `reopenAccount` is a bare update:
19
+ *
20
+ * async function reopenAccount({ id }) { await db.update('accounts', { id, closed: 0 }); }
21
+ *
22
+ * `db.update` does not run a SQL UPDATE. It sends CRDT messages, and `apply()` in
23
+ * loot-core/src/server/sync/index.ts picks INSERT when the row was absent from the
24
+ * pre-read:
25
+ *
26
+ * INSERT INTO accounts (id, closed) VALUES (?, ?)
27
+ *
28
+ * The accounts table has no NOT NULL columns and `tombstone` defaults to 0, while
29
+ * `getAccounts()` selects `WHERE a.tombstone = 0`. So the phantom row is a fully
30
+ * visible account with a null name, and because it is a CRDT message it SYNCS to
31
+ * every other client on the budget.
32
+ *
33
+ * WHY A PRE-CHECK HERE, WHEN #347 DELIBERATELY REJECTED ONE.
34
+ *
35
+ * The two cases look identical and are not. In #347 the caller's intent ("make this
36
+ * account not exist") could ALREADY be satisfied by an absent row, so refusing on
37
+ * absence would have failed a request that was in fact complete. Here the caller's
38
+ * intent ("reopen this account") cannot possibly be satisfied by an absent row, and
39
+ * proceeding actively creates one. Refusing is the only correct answer.
40
+ *
41
+ * The post-verify is kept as well, so the success claim is about observed state
42
+ * rather than about the call having returned. Read, write and re-read all run inside
43
+ * ONE `withWriteSession` cycle (#142), which keeps them in a single api lock cycle
44
+ * rather than three.
45
+ *
46
+ * What that does NOT give, stated precisely because an overstated guarantee gets relied
47
+ * on: `processWriteQueue` dispatches a batch with `Promise.allSettled`, so operations
48
+ * queued in the same drain window still interleave at await points. The single cycle
49
+ * excludes other SESSIONS, not other operations in the same batch. A concurrent
50
+ * close of the same account can therefore still land between this read and this write.
51
+ *
52
+ * TWO DELIBERATE LIMITS, recorded so they are not mistaken for oversights.
53
+ *
54
+ * The not-found message covers both reasons an id can be missing (never existed, or
55
+ * removed by a close while it had no transactions) rather than distinguishing them.
56
+ * `q().withDead()` would tell them apart by reading tombstoned rows, at the cost of an
57
+ * extra query on the failure path. Collapsing them was chosen because the message names
58
+ * both cases and the remedy is the same either way. If that proves confusing in
59
+ * practice, `withDead()` is the tool to reach for.
60
+ *
61
+ * Going through the raw api inside the session means this path no longer gets the
62
+ * adapter's `retry`. That is inherent to the #142 pattern, shared with every other tool
63
+ * using it (`rules_delete`, `schedules_delete`, `category_groups_delete`), and it is the
64
+ * half of #368 that still stands. The per-tool counter is NOT lost: it is incremented in
65
+ * the handler below.
66
+ */
6
67
  const tool = {
7
68
  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.`,
69
+ description: 'Reopen a previously closed account in Actual Budget. The account becomes active again and ' +
70
+ 'visible in all views, and all historical transactions remain intact. An id that is not an ' +
71
+ 'account is refused: Actual would otherwise create an empty account under that id. Note that ' +
72
+ 'an account closed while it had NO transactions was removed by Actual rather than closed, and ' +
73
+ 'cannot be reopened.',
9
74
  inputSchema: InputSchema,
10
75
  call: async (args, _meta) => {
11
76
  const input = InputSchema.parse(args || {});
12
- await adapter.reopenAccount(input.id);
13
- return { success: true };
77
+ // #368: the adapter method that used to own this counter is no longer on the path,
78
+ // because this tool reads and writes through the raw api inside one session. Counting
79
+ // here keeps `actual.accounts.reopen` honest; the retry half of #368 still stands.
80
+ observability.incrementToolCall('actual.accounts.reopen').catch(() => { });
81
+ return await adapter.withWriteSession(async () => {
82
+ const before = (await rawGetAccounts());
83
+ const target = Array.isArray(before) ? before.find((a) => a?.id === input.id) : undefined;
84
+ if (!target) {
85
+ throw new Error(`Account "${input.id}" not found, so it cannot be reopened. Use actual_accounts_list to ` +
86
+ 'see the accounts that exist. If this account was closed while it had no transactions, ' +
87
+ 'Actual removed it rather than closing it, and it cannot be reopened: create a new one ' +
88
+ 'with actual_accounts_create.');
89
+ }
90
+ await rawReopenAccount(input.id);
91
+ // Verify the effect rather than the call returning. Same reasoning as #347.
92
+ const after = (await rawGetAccounts());
93
+ const survivor = Array.isArray(after) ? after.find((a) => a?.id === input.id) : undefined;
94
+ if (!survivor) {
95
+ throw new Error(`Account "${input.id}" disappeared while being reopened. Check the account state in Actual.`);
96
+ }
97
+ if (survivor.closed === true) {
98
+ throw new Error(`Account "${survivor.name ?? input.id}" (${input.id}) is still closed after the reopen. ` +
99
+ 'The call was accepted but had no effect; check the account state in Actual.');
100
+ }
101
+ return { success: true };
102
+ });
14
103
  },
15
104
  };
16
105
  export default tool;
@@ -1,19 +1,83 @@
1
1
  import { z } from 'zod';
2
2
  import adapter from '../lib/actual-adapter.js';
3
+ import * as observability from '../observability.js';
4
+ import api from '@actual-app/api';
5
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
6
+ const { getBudgetMonth: rawGetBudgetMonth, holdBudgetForNextMonth: rawHold } = api;
3
7
  const InputSchema = z.object({
4
8
  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'),
9
+ // #355: upstream rejects a non-positive amount with
10
+ // APIError('Amount to hold needs to be greater than 0') BEFORE it ever reaches
11
+ // holdForNextMonth, so the schema rejects it here instead: same answer, no wasted
12
+ // round trip, and the message names the field. No working client can be sending a
13
+ // value upstream already refuses.
14
+ amount: z.number().int().positive('amount must be greater than 0 (integer cents)')
15
+ .describe('Amount in cents to hold for next month. Must be greater than 0.'),
6
16
  });
17
+ /**
18
+ * #355: report the amount actually held, not the fact that the call returned.
19
+ *
20
+ * There are TWO ways this operation quietly does less than asked, and a boolean only
21
+ * catches one of them.
22
+ *
23
+ * upstream holdForNextMonth:
24
+ * if (toBudget > 0) { await setBuffer(month, calcBufferedAmount(...)); return true; }
25
+ * return false;
26
+ *
27
+ * calcBufferedAmount:
28
+ * amount = Math.min(Math.max(amount, -buffered), Math.max(toBudget, 0));
29
+ *
30
+ * So `false` means nothing was held, and `true` can still mean a PARTIAL hold: ask for
31
+ * 100.00 when only 30.00 is left to budget and 30.00 is held, silently, with a `true`.
32
+ * Reporting plain success for that is the same defect in a different costume.
33
+ *
34
+ * Reading `forNextMonth` (the buffered amount) before and after settles both cases from
35
+ * observed state rather than from a return value, which is the #347 principle. Both
36
+ * reads and the write share ONE `withWriteSession` cycle (#142).
37
+ *
38
+ * Cost and trade-off, recorded: two extra reads on a low-frequency budget tool, and the
39
+ * raw-call path forgoes the adapter's retry, which is inherent to the #142 pattern and is
40
+ * the half of #368 that still stands. The per-tool counter is NOT lost: it is incremented
41
+ * in the handler below.
42
+ */
7
43
  const tool = {
8
44
  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.`,
45
+ description: "Hold an amount from this month's budget to carry into next month. The amount is in cents. " +
46
+ 'Actual can hold LESS than requested: it clamps the hold to the amount still left to budget, ' +
47
+ 'and holds nothing at all when that is zero or negative. The response reports the amount ' +
48
+ 'actually held and flags a partial hold, rather than reporting plain success.\n\n' +
49
+ 'Note: This operates on the month as a whole, not on a specific category.',
12
50
  inputSchema: InputSchema,
13
51
  call: async (args, _meta) => {
14
52
  const input = InputSchema.parse(args || {});
15
- await adapter.holdBudgetForNextMonth(input.month, input.amount);
16
- return { success: true };
53
+ // #368: the adapter method that used to own this counter is no longer on the path,
54
+ // because this tool reads and writes through the raw api inside one session. Counting
55
+ // here keeps `actual.budgets.holdForNextMonth` honest; the retry half of #368 stands.
56
+ observability.incrementToolCall('actual.budgets.holdForNextMonth').catch(() => { });
57
+ return await adapter.withWriteSession(async () => {
58
+ const before = (await rawGetBudgetMonth(input.month));
59
+ const heldBefore = Number(before?.forNextMonth ?? 0);
60
+ await rawHold(input.month, input.amount);
61
+ const after = (await rawGetBudgetMonth(input.month));
62
+ const heldAfter = Number(after?.forNextMonth ?? 0);
63
+ const held = heldAfter - heldBefore;
64
+ if (held <= 0) {
65
+ throw new Error(`Nothing was held for ${input.month}: that month has no positive amount left to budget, ` +
66
+ 'so there is nothing to carry into next month. Budget less to categories in that month, ' +
67
+ 'or check it with actual_budgets_getMonth first.');
68
+ }
69
+ if (held < input.amount) {
70
+ return {
71
+ success: true,
72
+ partial: true,
73
+ requested: input.amount,
74
+ held,
75
+ message: `Held ${held} of the requested ${input.amount} cents for ${input.month}. Actual clamps ` +
76
+ 'the hold to the amount still left to budget in that month.',
77
+ };
78
+ }
79
+ return { success: true, held };
80
+ });
17
81
  },
18
82
  };
19
83
  export default tool;
@@ -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.12.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/api_coverage_baseline.test.js && node tests/unit/train_preflight.test.js && node tests/unit/verify_published_artifacts.test.js && node tests/unit/report_train_stale.test.js && node tests/unit/stdio_framing_check.test.js && node tests/unit/server_info.test.js && node tests/unit/transfers_create.test.js && node tests/unit/bot_target_branch.test.js && node tests/unit/auth_posture.test.js && node tests/unit/auth_posture_wiring.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/budgets_hold_for_next_month.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/httpServer_session_not_found.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/retry_classifier.test.js && node tests/unit/adapter_module_surface.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/accounts_delete.test.js && node tests/unit/accounts_close.test.js && node tests/unit/accounts_reopen.test.js && node tests/unit/payees_delete.test.js && node tests/unit/payees_merge.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_oidc_jwks_discovery.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js && node tests/unit/tags_list.test.js && node tests/unit/tags_create.test.js && node tests/unit/tags_update.test.js && node tests/unit/tags_delete.test.js && node tests/unit/notes_get.test.js && node tests/unit/notes_update.test.js && node tests/unit/payees_common_list.test.js && node tests/unit/transactions_update_guard.test.js && node tests/unit/transactions_update_splits.test.js && node tests/unit/entities_search.test.js && node tests/unit/transactions_import_schema.test.js && node tests/unit/tool_schema_shape.test.js && node tests/unit/zod_error_format.test.js && node tests/unit/tool_count_sync.test.js && node tests/unit/stdio_transport.test.js && node tests/unit/stdio_budget_switch.test.js && node tests/unit/logger_structured.test.js && node tests/unit/logger_redaction.test.js && node tests/unit/logger_correlation.test.js && node tests/unit/check_coverage.test.js && node tests/unit/adapter_op_timeout.test.js && node tests/unit/adapter_write_queue_wakeup.test.js && node tests/unit/ci_workflow_guards.test.js && node tests/unit/node_version_guard.test.js && node tests/unit/node_version_drift.test.js && node tests/unit/observability_smoke.test.js && node tests/unit/residue_pattern.test.js && node tests/unit/dual_transport_gate.test.js && node tests/unit/manual_assertions_use_helper.test.js && node tests/unit/server_version_guard.test.js && node tests/unit/payee_rules_reference_filter.test.js && node tests/unit/oidc_metadata_discovery.test.js && node tests/unit/httpServer_oauth_metadata_route.test.js && node tests/unit/httpServer_mcp_info_route.test.js && node tests/unit/residue_sweep_balance_account.test.js && node tests/unit/tool_factory.test.js && node tests/unit/budget_registry_parse.test.js && node tests/unit/preferences_get.test.js && node tests/unit/budgets_export.test.js && node tests/unit/budgets_import.test.js && node tests/unit/budgets_import_syncid.test.js && node tests/unit/budget_acl_dynamic.test.js && node tests/unit/budget_acl_identity_map.test.js && node tests/unit/budget_acl_userinfo.test.js && node tests/unit/acl_e2e_fixture_contract.test.js && node tests/unit/budget_acl_middleware.test.js && node tests/unit/rules_stage.test.js",
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",