actual-mcp-server 0.16.9 → 0.16.10

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
@@ -775,7 +775,7 @@ The software is provided **as-is**, without warranty of any kind. The author acc
775
775
 
776
776
  ---
777
777
 
778
- **Version:** 0.16.9 | **Tool Count:** 74 (verified LibreChat-compatible)
778
+ **Version:** 0.16.10 | **Tool Count:** 74 (verified LibreChat-compatible)
779
779
 
780
780
  ## Sponsor
781
781
 
@@ -0,0 +1,85 @@
1
+ /**
2
+ * #388: what an OPTIONAL FILTER id means when the caller passes a NAME.
3
+ *
4
+ * The surface used to answer that question three different ways, and the inconsistency was the
5
+ * real defect rather than the schema question that surfaced it:
6
+ *
7
+ * 1. `transactions_search_by_amount` DETECTED the name, looked it up, and answered with the
8
+ * correct UUID. Strictly the most useful thing any of them did.
9
+ * 2. `transactions_get` pre-flighted and returned `notFoundMsg`, naming the entity and the
10
+ * listing tool but not resolving the name.
11
+ * 3. Everything else silently returned an EMPTY result set, which is the worst of the three:
12
+ * a plausible-looking wrong answer that reads as "no transactions match".
13
+ *
14
+ * #380 swept the REQUIRED lookup ids onto `CommonSchemas` and deliberately left these behind,
15
+ * because tightening them is a contract decision rather than a sweep. Tightening alone (making
16
+ * the schema reject a non-UUID) would have deleted accommodation 1, trading the best message on
17
+ * the surface for a ZodError. So the schema stays a bare string and the answer moves here.
18
+ *
19
+ * THE FAST PATH READS NOTHING. A value that is already a UUID is returned untouched, without a
20
+ * listing call, so a correct call costs exactly what it costs today. The listing read happens
21
+ * only on the unhappy path, where the caller has already made a mistake and the alternative is
22
+ * a wrong answer.
23
+ *
24
+ * This module is deliberately PURE so it can be unit-tested without an api session; the adapter
25
+ * owns the impure half (fetching the listing) because that is where a listing read gets `retry`
26
+ * and the observability call site, and because #371 and #376 concluded that a check the tools
27
+ * share belongs in the adapter rather than copied into ten tool files.
28
+ */
29
+ import { UUID_PATTERN } from '../constants.js';
30
+ /** Human-readable entity name and the tool that lists it, for the refusal message. */
31
+ export const FILTER_ID_ENTITIES = {
32
+ account: { entity: 'Account', listTool: 'actual_accounts_list' },
33
+ category: { entity: 'Category', listTool: 'actual_categories_get' },
34
+ payee: { entity: 'Payee', listTool: 'actual_payees_get' },
35
+ };
36
+ /**
37
+ * Is this value already a well-formed entity id?
38
+ *
39
+ * When it is, NOTHING else in this module runs: the value is handed straight back and no
40
+ * listing is read. That is what keeps the change free for every correct call, and it is also
41
+ * why a non-existent but well-formed UUID still behaves exactly as it does today (an empty
42
+ * result). Checking existence would mean a listing read on EVERY call, which is a cost this
43
+ * ticket explicitly declined to impose to fix a mistake nobody makes: the failure being fixed
44
+ * is a name passed where an id belongs, not a mistyped UUID.
45
+ */
46
+ export function isEntityId(value) {
47
+ return UUID_PATTERN.test(value);
48
+ }
49
+ /**
50
+ * Find the row whose NAME is what the caller passed.
51
+ *
52
+ * Case-insensitive and trimmed, matching the accommodation this generalises. Returns undefined
53
+ * rather than throwing so the caller decides between "resolved" and "no such name", which are
54
+ * two different messages.
55
+ *
56
+ * Ambiguity is resolved by taking the FIRST match, deliberately. Actual permits two categories
57
+ * with the same name in different groups, so a name is not a key. Reporting the first is still
58
+ * strictly better than the empty result set this replaces, and the message names the id it
59
+ * chose, so a caller who meant the other one can see that it is not theirs.
60
+ */
61
+ export function matchByName(rows, value) {
62
+ const wanted = value.trim().toLowerCase();
63
+ if (wanted === '')
64
+ return undefined;
65
+ return rows.find((r) => typeof r.name === 'string' && r.name.trim().toLowerCase() === wanted);
66
+ }
67
+ /**
68
+ * The refusal text for a name that DID resolve.
69
+ *
70
+ * It names the id rather than silently substituting it. Substituting would be friendlier for
71
+ * one call and wrong as a contract: the caller's next call would still pass the name, and a
72
+ * server that quietly accepts names has no way to report the ambiguity above.
73
+ */
74
+ export function resolvedNameDetail(kind, name, id) {
75
+ const { entity, listTool } = FILTER_ID_ENTITIES[kind];
76
+ const noun = entity.toLowerCase();
77
+ // The article matters here only because getting it wrong ("a account") reads as a bug in the
78
+ // server to the person who hits this, and this string is the one they see.
79
+ const article = /^[aeiou]/.test(noun) ? 'an' : 'a';
80
+ // The ANSWER comes first, deliberately. The generic not-found prefix would otherwise put "use
81
+ // the listing tool" ahead of the id we already resolved, and that is the instruction a model
82
+ // follows.
83
+ return (`"${name}" is ${article} ${noun} NAME, not an id. Use "${id}" instead ` +
84
+ `(${listTool} lists them if that is not the ${noun} you meant).`);
85
+ }
@@ -1345,6 +1345,7 @@ export const notifications = new EventEmitter();
1345
1345
  // Extracted to ./actual-adapter/normalize.ts (#166). Imported for internal use
1346
1346
  // and re-exported so the public surface and external importers are unchanged.
1347
1347
  import { normalizeToTransactionArray, normalizeToId, normalizeImportResult } from './actual-adapter/normalize.js';
1348
+ import { isEntityId, matchByName, resolvedNameDetail, FILTER_ID_ENTITIES } from './actual-adapter/filter-ids.js';
1348
1349
  export { normalizeToTransactionArray, normalizeToId, normalizeImportResult };
1349
1350
  // ---------------------------------------------------------------------------
1350
1351
  export async function getAccounts() {
@@ -1524,6 +1525,64 @@ export async function getPayees() {
1524
1525
  return await withConcurrency(() => retry(() => rawGetPayees(), { retries: 2, backoffMs: 200 }));
1525
1526
  });
1526
1527
  }
1528
+ /**
1529
+ * #388: turn an optional FILTER id that is actually a NAME into a refusal that names the id.
1530
+ *
1531
+ * Every Category B field routes through here, so the surface has ONE answer to the single most
1532
+ * likely caller mistake instead of the three it had (a helpful resolution in one tool, a bare
1533
+ * not-found in another, and a silent empty result set in the other nine).
1534
+ *
1535
+ * COSTS NOTHING ON THE HAPPY PATH. A well-formed id returns immediately, with no listing read,
1536
+ * so a correct call is byte-identical to before. Only a non-id value pays a listing, and only
1537
+ * because the alternative is answering it with an empty result the caller will read as "no
1538
+ * matches".
1539
+ *
1540
+ * It lives in the adapter rather than in the ten tool files for the reason #371 and #376 both
1541
+ * landed on: a check the tools share belongs where the reads keep `retry` and the observability
1542
+ * call site, and where the next caller of the same listing cannot skip it by accident.
1543
+ *
1544
+ * It THROWS rather than returning a result envelope, per #377's taxonomy: does-not-exist throws,
1545
+ * and `{ success: false }` is only for a genuine multi-outcome contract. A filter id that names
1546
+ * nothing is not a multi-outcome contract. That is also why the four tools that already had an
1547
+ * accommodation stop returning `{ transactions: [], count: 0, error }`: an empty result set with
1548
+ * an error tucked inside it is the shape this ticket exists to remove, not a milder version of it.
1549
+ *
1550
+ * `verifyExists` EXISTS SO THIS CHANGE REMOVES NOTHING, and the asymmetry is deliberate rather
1551
+ * than an oversight, so do not "tidy" it into one behaviour without deciding the same question
1552
+ * again. Five tools already read the listing unconditionally and already refuse a well-formed id
1553
+ * that names nothing; they pass `verifyExists: true` and keep exactly that, with a better message.
1554
+ * The rest never paid a listing read, and making them pay one on EVERY call to catch a mistyped
1555
+ * UUID would impose a cost on every correct call to fix a mistake nobody makes. The mistake that
1556
+ * actually happens, and that this ticket is about, is a NAME passed where an id belongs, which is
1557
+ * caught on both paths because a name is never a UUID.
1558
+ */
1559
+ export async function resolveFilterId(kind, value, opts) {
1560
+ // The free path, and the one every correct call takes.
1561
+ if (!opts?.verifyExists && isEntityId(value))
1562
+ return value;
1563
+ const { entity, listTool } = FILTER_ID_ENTITIES[kind];
1564
+ // `rows` lets a caller that ALREADY holds the listing avoid a second read of it. Without it,
1565
+ // `transactions_search_by_category` (which fetches accounts anyway, for off-budget filtering
1566
+ // and enrichment) would pay two listing calls on every filtered call, which is a cost this
1567
+ // change is supposed to avoid rather than introduce.
1568
+ const rows = opts?.rows
1569
+ ?? (kind === 'account' ? await getAccounts()
1570
+ : kind === 'category' ? await getCategories()
1571
+ : await getPayees());
1572
+ if (isEntityId(value)) {
1573
+ // Only reachable under verifyExists. A well-formed id that names nothing is a not-found,
1574
+ // not a name to resolve.
1575
+ if (rows.some((r) => r.id === value))
1576
+ return value;
1577
+ throw new NotFoundRefusal(entity, value, listTool);
1578
+ }
1579
+ const hit = matchByName(rows, value);
1580
+ if (hit && typeof hit.id === 'string') {
1581
+ const resolved = resolvedNameDetail(kind, String(hit.name), hit.id);
1582
+ throw new NotFoundRefusal(entity, value, listTool, undefined, resolved);
1583
+ }
1584
+ throw new NotFoundRefusal(entity, value, listTool);
1585
+ }
1527
1586
  export async function getCommonPayees() {
1528
1587
  return withActualApi(async () => {
1529
1588
  observability.incrementToolCall('actual.payees.commonList').catch(() => { });
@@ -3196,6 +3255,7 @@ export async function getPreferences() {
3196
3255
  }
3197
3256
  export default {
3198
3257
  getAccounts,
3258
+ resolveFilterId,
3199
3259
  getAccountsWithBalances,
3200
3260
  addTransactions,
3201
3261
  importTransactions,
@@ -60,8 +60,17 @@ export class NotFoundRefusal extends PreflightRefusal {
60
60
  * @param listTool MCP tool name the caller should use to get valid ids
61
61
  * @param detail Optional extra sentence, e.g. what was NOT written as a result
62
62
  */
63
- constructor(entity, id, listTool, detail) {
64
- super(notFoundMsg(entity, id, listTool) + (detail ? ` ${detail}` : ''), 'not-found');
63
+ /**
64
+ * `overrideMessage` is for the one case where we can say something strictly MORE useful than
65
+ * "not found, go and list them": we already know the answer. #388 resolves a NAME passed where
66
+ * an id belongs, so telling the caller to list the accounts buries the id we just found behind
67
+ * an instruction they no longer need, and a model reading top to bottom will follow the
68
+ * instruction rather than the answer. The TYPE is unchanged, because the request still names
69
+ * something that does not exist under that identifier, and #377 is explicit that callers branch
70
+ * on the type rather than on message prose.
71
+ */
72
+ constructor(entity, id, listTool, detail, overrideMessage) {
73
+ super(overrideMessage ?? (notFoundMsg(entity, id, listTool) + (detail ? ` ${detail}` : '')), 'not-found');
65
74
  this.entity = entity;
66
75
  this.id = id;
67
76
  this.listTool = listTool;
@@ -9,6 +9,14 @@ const tool = {
9
9
  inputSchema: InputSchema,
10
10
  call: async (args, _meta) => {
11
11
  const input = InputSchema.parse(args || {});
12
+ // #388: the one Category B field where `verifyExists: true` is worth a listing read on the
13
+ // HAPPY path too. Every other filter id costs at most a wrong-looking local result; this one
14
+ // reaches a THIRD PARTY, so an id that names nothing spends a provider call (and, with
15
+ // GoCardless, part of a rate-limited quota) to accomplish nothing. Omitting accountId still
16
+ // means "sync everything" and is not validated, because there is nothing to validate.
17
+ if (input.accountId) {
18
+ await adapter.resolveFilterId('account', input.accountId, { verifyExists: true });
19
+ }
12
20
  try {
13
21
  await adapter.runBankSync(input.accountId ?? undefined);
14
22
  return { result: 'Bank sync initiated successfully' };
@@ -19,6 +19,15 @@ const tool = {
19
19
  call: async (args, _meta) => {
20
20
  const input = InputSchema.parse(args || {});
21
21
  // Convert null to undefined for adapter (LibreChat sends null, adapter expects undefined)
22
+ // #388: these three filters silently returned an EMPTY result set when given a NAME, which
23
+ // is the worst of the three answers the surface used to give: a plausible-looking wrong
24
+ // answer. No listing is read for a well-formed id, so a correct call costs what it did.
25
+ if (input.accountId)
26
+ await adapter.resolveFilterId('account', input.accountId);
27
+ if (input.categoryId)
28
+ await adapter.resolveFilterId('category', input.categoryId);
29
+ if (input.payeeId)
30
+ await adapter.resolveFilterId('payee', input.payeeId);
22
31
  const accountId = input.accountId ?? undefined;
23
32
  const startDate = input.startDate ?? undefined;
24
33
  const endDate = input.endDate ?? undefined;
@@ -1,6 +1,5 @@
1
1
  import { z } from 'zod';
2
2
  import adapter from '../lib/actual-adapter.js';
3
- import { notFoundMsg } from '../lib/errors.js';
4
3
  const InputSchema = z.object({ accountId: z.string().optional(), startDate: z.string().optional(), endDate: z.string().optional() });
5
4
  const tool = {
6
5
  name: 'actual_transactions_get',
@@ -8,13 +7,13 @@ const tool = {
8
7
  inputSchema: InputSchema,
9
8
  call: async (args, _meta) => {
10
9
  const input = InputSchema.parse(args || {});
11
- // Pre-flight: verify account exists when accountId is provided (BUG-7)
10
+ // Pre-flight: verify the account exists when accountId is provided (BUG-7, then #388).
11
+ // This tool already refused a well-formed id that names nothing, and `verifyExists: true`
12
+ // keeps exactly that. What it gains is the third case: a NAME passed where an id belongs
13
+ // now comes back with the id it resolves to, instead of a refusal that only says not found.
14
+ // It also stops returning `{ error }` and THROWS, per #377: does-not-exist throws.
12
15
  if (input.accountId) {
13
- const accounts = await adapter.getAccounts();
14
- const accountExists = accounts.some((a) => a.id === input.accountId);
15
- if (!accountExists) {
16
- return { error: notFoundMsg('Account', input.accountId, 'actual_accounts_list') };
17
- }
16
+ await adapter.resolveFilterId('account', input.accountId, { verifyExists: true });
18
17
  }
19
18
  const result = await adapter.getTransactions(input.accountId, input.startDate, input.endDate);
20
19
  return { result };
@@ -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
  minAmount: z.number().optional().describe('Minimum amount in cents (use negative for expenses, e.g., -10000 for $-100.00). For expenses, use negative values (e.g., -5000 for -$50.00)'),
5
6
  maxAmount: z.number().optional().describe('Maximum amount in cents (e.g., 10000 for $100.00). For expenses, use negative values (e.g., -5000 for -$50.00)'),
@@ -23,36 +24,17 @@ const tool = {
23
24
  throw new Error('Unbounded query: provide at least one of accountId, startDate, or endDate ' +
24
25
  'to limit the scan scope. Full-database scans without filters can exhaust memory.');
25
26
  }
26
- // Validate accountId exists if provided
27
+ // #388: ONE answer to a name passed where an id belongs, shared by every Category B field.
28
+ // This block used to return an empty result set with the error tucked inside it, which reads
29
+ // to a model as "no transactions match". `verifyExists: true` keeps the existence check this
30
+ // tool already paid for.
31
+ // Fetched HERE rather than at the enrichment step below so the guard can reuse it.
32
+ // Both need the same listing, and reading it twice on every filtered call is a cost this
33
+ // change is meant to avoid rather than introduce.
34
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
35
+ const accounts = await adapter.getAccounts();
27
36
  if (input.accountId) {
28
- const accounts = await adapter.getAccounts();
29
- const accountExists = accounts.some((acc) => acc.id === input.accountId);
30
- if (!accountExists) {
31
- // Check if user provided account name instead of UUID
32
- const accountByName = accounts.find((acc) => acc.name && acc.name.toLowerCase() === input.accountId.toLowerCase());
33
- if (accountByName) {
34
- return {
35
- transactions: [],
36
- count: 0,
37
- totalAmount: 0,
38
- amountRange: {
39
- min: input.minAmount,
40
- max: input.maxAmount,
41
- },
42
- error: `Account '${input.accountId}' appears to be a name, not an ID. Use account UUID '${accountByName.id}' instead.`,
43
- };
44
- }
45
- return {
46
- transactions: [],
47
- count: 0,
48
- totalAmount: 0,
49
- amountRange: {
50
- min: input.minAmount,
51
- max: input.maxAmount,
52
- },
53
- error: `Account '${input.accountId}' not found. Did you mean to use account UUID instead of name? Use actual_accounts_list to get valid account UUIDs.`,
54
- };
55
- }
37
+ await adapter.resolveFilterId('account', input.accountId, { verifyExists: true, rows: accounts });
56
38
  }
57
39
  // Get base transactions (filtered by account and date range if provided)
58
40
  const allTransactions = await adapter.getTransactions(input.accountId, input.startDate, input.endDate);
@@ -111,8 +93,7 @@ const tool = {
111
93
  return amountB - amountA;
112
94
  });
113
95
  const limited = filtered.slice(0, input.limit || 100);
114
- // Enrich transactions with account names
115
- const accounts = await adapter.getAccounts();
96
+ // Enrich transactions with account names (listing already in hand, see above)
116
97
  const accountMap = new Map(accounts.map((acc) => [acc.id, acc.name]));
117
98
  const enrichedTransactions = limited.map((t) => ({
118
99
  ...t,
@@ -131,6 +112,12 @@ const tool = {
131
112
  }
132
113
  catch (error) {
133
114
  const message = error?.message || String(error);
115
+ // #377/#388: a PREFLIGHT REFUSAL is not a crash and must not be flattened into one.
116
+ // This guard exists so an unexpected failure does not take the server down. A refusal is
117
+ // the opposite: a deliberate answer that the request cannot be served, and burying it here
118
+ // would restore exactly the empty-result-with-an-error shape #388 removed.
119
+ if (isPreflightRefusal(error))
120
+ throw error;
134
121
  // Don't crash the server — return structured error
135
122
  return {
136
123
  transactions: [],
@@ -18,29 +18,12 @@ const tool = {
18
18
  // Fetch accounts once — used for validation, off-budget filtering (issue #81), and enrichment
19
19
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
20
20
  const allAccounts = await adapter.getAccounts();
21
- // Step 0: Validate accountId exists if provided
21
+ // #388: ONE answer to a name passed where an id belongs, shared by every Category B field.
22
+ // This block used to return an empty result set with the error tucked inside it, which reads
23
+ // to a model as "no transactions match". `verifyExists: true` keeps the existence check this
24
+ // tool already paid for.
22
25
  if (input.accountId) {
23
- const accountExists = allAccounts.some((acc) => acc.id === input.accountId);
24
- if (!accountExists) {
25
- // Check if user provided account name instead of UUID
26
- const accountByName = allAccounts.find((acc) => acc.name && acc.name.toLowerCase() === input.accountId.toLowerCase());
27
- if (accountByName) {
28
- return {
29
- transactions: [],
30
- count: 0,
31
- totalAmount: 0,
32
- categoryName: input.categoryName,
33
- error: `Account '${input.accountId}' appears to be a name, not an ID. Use account UUID '${accountByName.id}' instead.`,
34
- };
35
- }
36
- return {
37
- transactions: [],
38
- count: 0,
39
- totalAmount: 0,
40
- categoryName: input.categoryName,
41
- error: `Account '${input.accountId}' not found. Did you mean to use account UUID instead of name? Use actual_accounts_list to get valid account UUIDs.`,
42
- };
43
- }
26
+ await adapter.resolveFilterId('account', input.accountId, { verifyExists: true, rows: allAccounts });
44
27
  }
45
28
  // Step 1: Find category ID by name
46
29
  let categoryId;
@@ -15,31 +15,17 @@ const tool = {
15
15
  inputSchema: InputSchema,
16
16
  call: async (args, _meta) => {
17
17
  const input = InputSchema.parse(args || {});
18
- // Step 0: Validate accountId exists if provided
18
+ // Fetched HERE rather than further down so the #388 guard below can reuse it. The guard
19
+ // needs the listing and so does the off-budget filtering, and without sharing them this
20
+ // handler read the same listing twice on every filtered call.
21
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
22
+ const accounts = await adapter.getAccounts();
23
+ // #388: ONE answer to a name passed where an id belongs, shared by every Category B field.
24
+ // This block used to return { transactions: [], count: 0, ..., error }, an empty result set
25
+ // with the error tucked inside it, which reads to a model as "no transactions match".
26
+ // `verifyExists: true` keeps the existence check this tool already paid for.
19
27
  if (input.accountId) {
20
- const accounts = await adapter.getAccounts();
21
- const accountExists = accounts.some((acc) => acc.id === input.accountId);
22
- if (!accountExists) {
23
- // Check if user provided account name instead of UUID
24
- const accountByName = accounts.find((acc) => acc.name && acc.name.toLowerCase() === input.accountId.toLowerCase());
25
- const month = input.month || `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`;
26
- if (accountByName) {
27
- return {
28
- transactions: [],
29
- count: 0,
30
- totalAmount: 0,
31
- month,
32
- error: `Account '${input.accountId}' appears to be a name, not an ID. Use account UUID '${accountByName.id}' instead.`,
33
- };
34
- }
35
- return {
36
- transactions: [],
37
- count: 0,
38
- totalAmount: 0,
39
- month,
40
- error: `Account '${input.accountId}' not found. Did you mean to use account UUID instead of name? Use actual_accounts_list to get valid account UUIDs.`,
41
- };
42
- }
28
+ await adapter.resolveFilterId('account', input.accountId, { verifyExists: true, rows: accounts });
43
29
  }
44
30
  // Default to current month if not provided
45
31
  const today = new Date();
@@ -60,9 +46,6 @@ const tool = {
60
46
  month,
61
47
  };
62
48
  }
63
- // Fetch accounts once — used for off-budget filtering (issue #81) and enrichment
64
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
65
- const accounts = await adapter.getAccounts();
66
49
  // Exclude off-budget accounts (issue #81) — their transactions cannot have
67
50
  // categories set; any update is silently discarded by Actual Budget.
68
51
  const offBudgetIds = new Set(
@@ -16,30 +16,12 @@ const tool = {
16
16
  inputSchema: InputSchema,
17
17
  call: async (args, _meta) => {
18
18
  const input = InputSchema.parse(args || {});
19
- // Step 0: Validate accountId exists if provided
19
+ // #388: ONE answer to a name passed where an id belongs, shared by every Category B field.
20
+ // This block used to return an empty result set with the error tucked inside it, which reads
21
+ // to a model as "no transactions match". `verifyExists: true` keeps the existence check this
22
+ // tool already paid for.
20
23
  if (input.accountId) {
21
- const accounts = await adapter.getAccounts();
22
- const accountExists = accounts.some((acc) => acc.id === input.accountId);
23
- if (!accountExists) {
24
- // Check if user provided account name instead of UUID
25
- const accountByName = accounts.find((acc) => acc.name && acc.name.toLowerCase() === input.accountId.toLowerCase());
26
- if (accountByName) {
27
- return {
28
- transactions: [],
29
- count: 0,
30
- totalAmount: 0,
31
- payeeName: input.payeeName,
32
- error: `Account '${input.accountId}' appears to be a name, not an ID. Use account UUID '${accountByName.id}' instead.`,
33
- };
34
- }
35
- return {
36
- transactions: [],
37
- count: 0,
38
- totalAmount: 0,
39
- payeeName: input.payeeName,
40
- error: `Account '${input.accountId}' not found. Did you mean to use account UUID instead of name? Use actual_accounts_list to get valid account UUIDs.`,
41
- };
42
- }
24
+ await adapter.resolveFilterId('account', input.accountId, { verifyExists: true });
43
25
  }
44
26
  // Step 1: Find payee ID by name
45
27
  let payeeId;
@@ -12,6 +12,11 @@ const tool = {
12
12
  inputSchema: InputSchema,
13
13
  call: async (args, _meta) => {
14
14
  const input = InputSchema.parse(args || {});
15
+ // #388: this filter silently returned an EMPTY result set when given an account NAME, a
16
+ // plausible-looking wrong answer. A well-formed id reads no listing, so a correct call is
17
+ // unchanged.
18
+ if (input.accountId)
19
+ await adapter.resolveFilterId('account', input.accountId);
15
20
  // Default to current month if dates not provided
16
21
  const today = new Date();
17
22
  const firstDayOfMonth = new Date(today.getFullYear(), today.getMonth(), 1);
@@ -12,6 +12,11 @@ const tool = {
12
12
  inputSchema: InputSchema,
13
13
  call: async (args, _meta) => {
14
14
  const input = InputSchema.parse(args || {});
15
+ // #388: this filter silently returned an EMPTY result set when given an account NAME, a
16
+ // plausible-looking wrong answer. A well-formed id reads no listing, so a correct call is
17
+ // unchanged.
18
+ if (input.accountId)
19
+ await adapter.resolveFilterId('account', input.accountId);
15
20
  // Build ActualQL query with groupBy and aggregation
16
21
  const api = await import('@actual-app/api');
17
22
  const q = api.q;
@@ -15,7 +15,12 @@ import { CommonSchemas } from '../lib/schemas/common.js';
15
15
  const InputSchema = z.object({
16
16
  startDate: CommonSchemas.date.optional().describe('Start date in YYYY-MM-DD format (default: all-time)'),
17
17
  endDate: CommonSchemas.date.optional().describe('End date in YYYY-MM-DD format (default: today)'),
18
- accountId: CommonSchemas.accountId.optional().describe('Filter to a specific account ID (optional)'),
18
+ // #388: an OPTIONAL FILTER, so it belongs with the other twelve rather than with the required
19
+ // lookup ids #380 tightened. Under `CommonSchemas.accountId` a caller passing an account NAME
20
+ // got "Invalid uuid" and no way to learn the id, which is the answer this ticket argues is worse
21
+ // than resolving the name. The schema is bare so the handler runs and can resolve it; the
22
+ // exception entry in tool_id_schema_drift records that this is deliberate.
23
+ accountId: z.string().optional().describe('Filter to a specific account ID (optional)'),
19
24
  includeTransactions: z.boolean().optional().default(false).describe('When true, include paginated transaction rows in the response (default: false)'),
20
25
  limit: z.number().int().min(1).max(1000).optional().default(50).describe('Max transactions per page when includeTransactions is true (default: 50, max: 1000)'),
21
26
  offset: z.number().int().min(0).optional().default(0).describe('Pagination start index when includeTransactions is true (default: 0)'),
@@ -46,6 +51,8 @@ const tool = {
46
51
  const today = new Date();
47
52
  const startDate = input.startDate || '2000-01-01';
48
53
  const endDate = input.endDate || today.toISOString().split('T')[0];
54
+ if (input.accountId)
55
+ await adapter.resolveFilterId('account', input.accountId);
49
56
  const accountId = input.accountId ?? undefined;
50
57
  // Full table scan when no accountId — reliably returns newly written transactions
51
58
  // across separate WAL sessions in CI Docker. Filtering by accountId uses an index
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.16.9",
4
+ "version": "0.16.10",
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/e2e_spec_collection.test.js && node tests/unit/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/knip_config.test.js && node tests/unit/readme_stats_sync.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/workflow_release_guards.test.js && node tests/unit/report_train_failure.test.js && node tests/unit/unit_chain_membership.test.js && node tests/unit/api_surface_drift.test.js && node tests/unit/write_effect_audit_staleness.test.js && node tests/unit/write_effect_audit_membership.test.js && node tests/unit/tool_annotations.test.js && node tests/unit/tool_id_schema_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_hold_for_next_month.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/httpServer_session_not_found.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/retry_classifier.test.js && node tests/unit/adapter_module_surface.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/accounts_delete.test.js && node tests/unit/update_tools_not_found.test.js && node tests/unit/accounts_close.test.js && node tests/unit/accounts_reopen.test.js && node tests/unit/payees_delete.test.js && node tests/unit/payees_merge.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_oidc_jwks_discovery.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/adapter_drain_listing_cache.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_selection_precondition.test.js && node tests/unit/budget_load_abandonment.test.js && node tests/unit/budget_loader_postcondition.test.js && node tests/unit/budget_loader_import_allowlist.test.js && node tests/unit/budget_import_tracking.test.js && node tests/unit/pool_shutdown_under_lock.test.js && node tests/unit/api_lock_affinity.test.js && node tests/unit/commit_message_closing_keyword.test.js && node tests/unit/write_queue_stranding.test.js && node tests/unit/write_queue_stranded_bound.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js && node tests/unit/tags_list.test.js && node tests/unit/tags_create.test.js && node tests/unit/tags_update.test.js && node tests/unit/tags_delete.test.js && node tests/unit/notes_get.test.js && node tests/unit/notes_update.test.js && node tests/unit/payees_common_list.test.js && node tests/unit/transactions_update_guard.test.js && node tests/unit/transactions_update_splits.test.js && node tests/unit/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/e2e_spec_collection.test.js && node tests/unit/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/knip_config.test.js && node tests/unit/readme_stats_sync.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/workflow_release_guards.test.js && node tests/unit/report_train_failure.test.js && node tests/unit/unit_chain_membership.test.js && node tests/unit/api_surface_drift.test.js && node tests/unit/write_effect_audit_staleness.test.js && node tests/unit/write_effect_audit_membership.test.js && node tests/unit/tool_annotations.test.js && node tests/unit/tool_id_schema_drift.test.js && node tests/unit/filter_id_resolution.test.js && node tests/unit/filter_id_tool_wiring.test.js && node tests/unit/api_coverage_baseline.test.js && node tests/unit/train_preflight.test.js && node tests/unit/verify_published_artifacts.test.js && node tests/unit/report_train_stale.test.js && node tests/unit/stdio_framing_check.test.js && node tests/unit/server_info.test.js && node tests/unit/transfers_create.test.js && node tests/unit/bot_target_branch.test.js && node tests/unit/auth_posture.test.js && node tests/unit/auth_posture_wiring.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/budgets_hold_for_next_month.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/httpServer_session_not_found.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/retry_classifier.test.js && node tests/unit/adapter_module_surface.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/accounts_delete.test.js && node tests/unit/update_tools_not_found.test.js && node tests/unit/accounts_close.test.js && node tests/unit/accounts_reopen.test.js && node tests/unit/payees_delete.test.js && node tests/unit/payees_merge.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_oidc_jwks_discovery.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/adapter_drain_listing_cache.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_selection_precondition.test.js && node tests/unit/budget_load_abandonment.test.js && node tests/unit/budget_loader_postcondition.test.js && node tests/unit/budget_loader_import_allowlist.test.js && node tests/unit/budget_import_tracking.test.js && node tests/unit/pool_shutdown_under_lock.test.js && node tests/unit/api_lock_affinity.test.js && node tests/unit/commit_message_closing_keyword.test.js && node tests/unit/write_queue_stranding.test.js && node tests/unit/write_queue_stranded_bound.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js && node tests/unit/tags_list.test.js && node tests/unit/tags_create.test.js && node tests/unit/tags_update.test.js && node tests/unit/tags_delete.test.js && node tests/unit/notes_get.test.js && node tests/unit/notes_update.test.js && node tests/unit/payees_common_list.test.js && node tests/unit/transactions_update_guard.test.js && node tests/unit/transactions_update_splits.test.js && node tests/unit/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",