actual-mcp-server 0.22.44 → 0.22.47

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
@@ -836,7 +836,7 @@ The software is provided **as-is**, without warranty of any kind. The author acc
836
836
 
837
837
  ---
838
838
 
839
- **Version:** 0.22.44 | **Tool Count:** 84 (verified LibreChat-compatible)
839
+ **Version:** 0.22.47 | **Tool Count:** 84 (verified LibreChat-compatible)
840
840
 
841
841
  ## Sponsor
842
842
 
@@ -40,7 +40,10 @@
40
40
  // point runs `_enforceBudgetAcl`, which refuses when the request context has no
41
41
  // allowedBudgets. Resolving the ACL would therefore be blocked by the ACL it is
42
42
  // trying to compute. We break that circularity by running ONLY this one call
43
- // inside `requestContext.run({ allowedBudgets: ['*'] }, ...)`.
43
+ // inside `requestContext.run({ allowedBudgets: ['*'] }, ...)`. Since #570 the
44
+ // wildcard matters twice: `getBudgets()` also FILTERS its result to the context's
45
+ // allowedBudgets, so without it the resolver would see only the files the
46
+ // caller's (not yet computed) list already permits.
44
47
  //
45
48
  // That is sound rather than a loophole: `getBudgets()` lists budget FILES
46
49
  // (name, id, who may access them). It reads no budget CONTENTS: no accounts, no
package/dist/src/index.js CHANGED
@@ -105,7 +105,9 @@ const KNOWN_FLAGS = new Set([
105
105
  '--http', '--stdio', '--test-actual-connection', '--test-mcp-client',
106
106
  '--debug', '--help', '-h', '--version', '-v',
107
107
  ]);
108
- const unknownFlags = argsEarly.filter(a => a.startsWith('-') && !KNOWN_FLAGS.has(a));
108
+ // A bare `--` is the conventional end-of-options marker that `node script.js -- --flag` passes
109
+ // through (the npm `dev` and `test:mcp-client` launchers did, #565); it is not an option.
110
+ const unknownFlags = argsEarly.filter(a => a.startsWith('-') && a !== '--' && !KNOWN_FLAGS.has(a));
109
111
  if (unknownFlags.length > 0) {
110
112
  console.error(`Unknown option: ${unknownFlags.join(', ')}\nRun \`actual-mcp-server --help\` for usage.`);
111
113
  process.exit(1);
@@ -314,13 +316,23 @@ Docs & source: https://github.com/agigante80/actual-mcp-server`);
314
316
  // If requested, run the MCP client-side tests now that all variables are ready
315
317
  if (useTestMcpClient) {
316
318
  logger.info('⚙️ --test-mcp-client specified, starting HTTP server and running client-side MCP tests...');
317
- // start server in http mode. Bind all interfaces by default (#239): advertisedUrl
318
- // uses the LAN IP (getLocalIp), so the self-test client must be able to reach it.
319
+ // start server in http mode. Bind all interfaces by default (#239).
319
320
  await startHttpServer(mcp, PORT, HTTP_PATH, capabilities, implementedTools, SERVER_DESCRIPTION, serverInstructionsText, toolSchemas, version, bindHost, advertisedUrl);
320
321
  // dynamic import to avoid circular at top
321
322
  const { testMcpClient } = await import('./tests/testMcpClient.js');
322
323
  try {
323
- await testMcpClient(advertisedUrl, PORT, HTTP_PATH);
324
+ // #565: the self-test talks to the address this process bound, not advertisedUrl. The
325
+ // advertised LAN IP can be answered by another listener on the same port (a Docker
326
+ // container publishing it is NATed ahead of local sockets), which turned the run into a
327
+ // 401 from a different server. A wildcard bind is reached on loopback, '::' on '::1' (an
328
+ // IPv6-only socket refuses IPv4). The scheme is the listener's, not the advertised one
329
+ // (MCP_BRIDGE_PUBLIC_SCHEME and MCP_BRIDGE_USE_TLS only change what is advertised).
330
+ // Under native TLS the advertised host is kept: the certificate names it, not 127.0.0.1.
331
+ const nativeTls = process.env.MCP_ENABLE_HTTPS === 'true';
332
+ const loopback = bindHost === '0.0.0.0' ? '127.0.0.1' : bindHost === '::' ? '::1' : bindHost;
333
+ const selfHost = nativeTls ? advertisedHost : loopback;
334
+ const selfUrl = `${nativeTls ? 'https' : 'http'}://${selfHost.includes(':') ? `[${selfHost}]` : selfHost}:${PORT}${HTTP_PATH}`;
335
+ await testMcpClient(selfUrl, HTTP_PATH);
324
336
  logger.info('✅ MCP client-side tests passed');
325
337
  process.exit(0);
326
338
  }
@@ -48,6 +48,15 @@ function _splitInList(valuesStr) {
48
48
  /** A single-quoted literal whose internal quotes are SQL-ESCAPED by doubling.
49
49
  * `'McDonald''s'` matches; `'McDonald's'` (a lone quote) does not. */
50
50
  const SQL_ESCAPED_SINGLE_QUOTED = /^'(?:[^']|'')*'$/;
51
+ // ActualQL's value compiler escapes comparison and LIKE strings for SQLite, so SQL's doubled
52
+ // apostrophe must be decoded first. IN uses a separate path that deliberately preserves it.
53
+ function _stripWhereSqlLiteral(s) {
54
+ const t = s.trim();
55
+ if (SQL_ESCAPED_SINGLE_QUOTED.test(t)) {
56
+ return t.slice(1, -1).replace(/''/g, "'");
57
+ }
58
+ return _stripWhereQuotes(s);
59
+ }
51
60
  function _isSafeInListElement(raw) {
52
61
  const t = raw.trim();
53
62
  // #433: a single-quoted element may now carry DOUBLED internal quotes. That is
@@ -83,11 +92,11 @@ function _coerceInListValue(s) {
83
92
  }
84
93
  return _coerceWhereValue(s);
85
94
  }
86
- // Coerce a SQL value literal to a number when it looks numeric, else keep the
87
- // (unquoted) string. Used for IN lists and comparison operands. Empty stays a
88
- // string so an empty literal is not silently turned into 0.
95
+ // Coerce a comparison operand (and an IN element with no doubled quote) to a number when it
96
+ // looks numeric, else keep the unquoted string. Empty stays a string so an empty literal is not
97
+ // turned into 0.
89
98
  function _coerceWhereValue(s) {
90
- const v = _stripWhereQuotes(s);
99
+ const v = _stripWhereSqlLiteral(s);
91
100
  if (v === '')
92
101
  return v;
93
102
  const n = Number(v);
@@ -158,7 +167,7 @@ export function parseWhereClause(query, whereClause, tableName) {
158
167
  const notLikeMatch = trimmedCondition.match(/^([\w.]+)\s+NOT\s+LIKE\s+(.+)$/i);
159
168
  if (notLikeMatch) {
160
169
  const [, field, valueStr] = notLikeMatch;
161
- query = query.filter({ [field]: { $notlike: _stripWhereQuotes(valueStr) } });
170
+ query = query.filter({ [field]: { $notlike: _stripWhereSqlLiteral(valueStr) } });
162
171
  continue;
163
172
  }
164
173
  // LIKE: pattern match. ActualQL's $like runs through NORMALISE + UNICODE_LIKE,
@@ -167,7 +176,7 @@ export function parseWhereClause(query, whereClause, tableName) {
167
176
  const likeMatch = trimmedCondition.match(/^([\w.]+)\s+LIKE\s+(.+)$/i);
168
177
  if (likeMatch) {
169
178
  const [, field, valueStr] = likeMatch;
170
- query = query.filter({ [field]: { $like: _stripWhereQuotes(valueStr) } });
179
+ query = query.filter({ [field]: { $like: _stripWhereSqlLiteral(valueStr) } });
171
180
  continue;
172
181
  }
173
182
  // IN clause: field IN (value1, value2, ...)
@@ -221,7 +230,25 @@ export function parseWhereClause(query, whereClause, tableName) {
221
230
  // operators are left to their normal path, where ActualQL will reject them for a boolean as
222
231
  // it does today, rather than us inventing an ordering the column does not have.
223
232
  const isBool = _isBooleanField(tableName, field) && (actualOp === '$eq' || actualOp === '$ne');
224
- const finalValue = isBool ? _coerceBoolean(field, valueStr) : _coerceWhereValue(valueStr);
233
+ const isText = tableName !== undefined && getFieldType(tableName, field) === 'string';
234
+ // #567 review: a quoted number stays a string on a text column. Coercing `notes = '123'` to
235
+ // the number 123 made upstream throw "Can't convert integer to string".
236
+ const finalValue = isBool
237
+ ? _coerceBoolean(field, valueStr)
238
+ : isText ? _stripWhereSqlLiteral(valueStr) : _coerceWhereValue(valueStr);
239
+ // #567 review: upstream's val() (aql/compiler.ts) re-escapes a value only when it casts to
240
+ // type `string`. A value compared against an `id` column is inlined as `'${value}'` with NO
241
+ // escaping, so a quote in it closes the literal: `account = 'x'||(SELECT ...)||'x'` ran the
242
+ // subquery, past both validators, and decoding `''` made the doubled form do the same. A
243
+ // quote is therefore allowed only on a column our schema types as `string` (checked against
244
+ // upstream's schema: every one of them is `string` there, or does not exist and fails to
245
+ // compile). Any other or unknown type refuses it, which is fail closed: no id, date, number
246
+ // or boolean legitimately contains a single quote. LIKE and NOT LIKE always cast to string,
247
+ // so they need no such check.
248
+ if (!isText && typeof finalValue === 'string' && finalValue.includes("'")) {
249
+ throw new Error(`Invalid value for column "${field}": a single quote is only allowed in a value ` +
250
+ `compared against a text column (for example payee.name = 'O''Brien').`);
251
+ }
225
252
  if (actualOp === '$eq') {
226
253
  // Simple equality can use the direct field: value shorthand.
227
254
  query = query.filter({ [field]: finalValue });
@@ -252,8 +279,9 @@ const NAME_CONDITION_TABLES = {
252
279
  * `NOT LIKE` are skipped: an unknown name there cannot make the result empty. Each condition is
253
280
  * ONE lookup, an IN list included (the ticket allows one extra read per name condition), and the
254
281
  * lookup reuses the main query's operator and value, so a `$oneof` element compiles exactly as in
255
- * the main query (where upstream inlines the doubled-quote form #433 keeps). A numeric-looking
256
- * literal arrives coerced to a number and is kept as is.
282
+ * the main query (where upstream inlines the doubled-quote form #433 keeps). The three name
283
+ * columns are text, so since #567 a numeric-looking `=` value arrives as a string; a number is
284
+ * still accepted (an IN element can be one) and kept as is.
257
285
  */
258
286
  export function nameConditionLookups(filterExpressions) {
259
287
  const out = [];
@@ -379,9 +379,7 @@ function abortBatchOnInfrastructureError(error, ctx) {
379
379
  * Claude Desktop / Claude Code compatibility.
380
380
  */
381
381
  function _enforceBudgetAcl(toolName) {
382
- const store = requestContext.getStore();
383
- const sessionId = store?.sessionId;
384
- const allowedBudgets = store?.allowedBudgets;
382
+ const sessionId = requestContext.getStore()?.sessionId;
385
383
  // Trusted-local short-circuit. stdio and startup health checks run with no
386
384
  // sessionId; in non-OIDC modes those are by-construction trusted (single
387
385
  // user, local process). The ACL only applies when an authenticated multi-
@@ -389,20 +387,8 @@ function _enforceBudgetAcl(toolName) {
389
387
  if (!sessionId && config.AUTH_PROVIDER !== 'oidc') {
390
388
  return;
391
389
  }
392
- // OIDC + no allowedBudgets in context: the request slipped past the
393
- // middleware. Defence-in-depth: refuse rather than fail open.
394
- if (config.AUTH_PROVIDER === 'oidc' && !allowedBudgets) {
395
- logger.warn(JSON.stringify({
396
- event: 'acl_denied',
397
- reason: 'no_allowed_budgets_in_context',
398
- sessionId: sessionId ?? null,
399
- tool: toolName ?? null,
400
- }));
401
- throw new Error('Budget ACL: no allowedBudgets in request context. ' +
402
- 'This request bypassed the budget-acl middleware. Refusing for safety. See #156.');
403
- }
404
- // No restriction.
405
- if (!allowedBudgets || allowedBudgets.includes('*'))
390
+ const allowedBudgets = restrictedAllowedBudgets(toolName);
391
+ if (!allowedBudgets)
406
392
  return;
407
393
  const target = getActiveBudgetConfig();
408
394
  if (!allowedBudgets.includes(target.syncId)) {
@@ -414,9 +400,46 @@ function _enforceBudgetAcl(toolName) {
414
400
  sessionId: sessionId ?? null,
415
401
  tool: toolName ?? null,
416
402
  }));
417
- throw new Error(`Budget ACL: budget "${target.name}" (${target.syncId}) is not in this session's allowedBudgets.`);
403
+ // #570: the name and syncId stay in the log above, never in the caller-facing
404
+ // text. With no selection the active budget is the registry's first entry, so
405
+ // naming it told a principal allowed only another budget what the default was.
406
+ throw new Error("Budget ACL: the active budget is not in this session's allowedBudgets. " +
407
+ 'Call actual_budgets_list_available and switch to a permitted budget.');
418
408
  }
419
409
  }
410
+ /**
411
+ * The request's budget allow-list when the caller is RESTRICTED, else `undefined` (#570).
412
+ *
413
+ * Shared by `_enforceBudgetAcl` (the active budget) and the paths that check nothing
414
+ * about the active budget: the two listings, the import and the switch's not-found
415
+ * list. One decision, so the two kinds of check cannot drift apart.
416
+ *
417
+ * - `AUTH_PROVIDER=oidc` with no `allowedBudgets` in context: the request slipped
418
+ * past the middleware, so throw (fail closed), with an `acl_denied` warn.
419
+ * - a list containing `"*"`, or no list outside OIDC (bearer, no-auth, stdio, CLI,
420
+ * startup): unrestricted.
421
+ * - otherwise: the list.
422
+ *
423
+ * Keyed on the request's allow-list, not on AUTH_PROVIDER, so any auth mode that
424
+ * attaches a list is restricted by it.
425
+ */
426
+ function restrictedAllowedBudgets(toolName) {
427
+ const store = requestContext.getStore();
428
+ const allowedBudgets = store?.allowedBudgets;
429
+ if (config.AUTH_PROVIDER === 'oidc' && !allowedBudgets) {
430
+ logger.warn(JSON.stringify({
431
+ event: 'acl_denied',
432
+ reason: 'no_allowed_budgets_in_context',
433
+ sessionId: store?.sessionId ?? null,
434
+ tool: toolName ?? null,
435
+ }));
436
+ throw new Error('Budget ACL: no allowedBudgets in request context. ' +
437
+ 'This request bypassed the budget-acl middleware. Refusing for safety. See #156.');
438
+ }
439
+ if (!allowedBudgets || allowedBudgets.includes('*'))
440
+ return undefined;
441
+ return allowedBudgets;
442
+ }
420
443
  /**
421
444
  * Helper to run an operation with the Actual API ready, deciding the lifecycle
422
445
  * mode automatically:
@@ -3862,7 +3885,18 @@ export async function getBudgets() {
3862
3885
  return withActualApi(async () => {
3863
3886
  observability.incrementToolCall('actual.budgets.getAll').catch(() => { });
3864
3887
  const raw = await withConcurrency(() => retry(() => rawGetBudgets(), { retries: 2, backoffMs: 200 }));
3865
- return Array.isArray(raw) ? raw : [];
3888
+ const files = Array.isArray(raw) ? raw : [];
3889
+ // #570: a restricted caller sees only files whose groupId (the sync id) it may
3890
+ // use. A file with no groupId was never uploaded and belongs to no list. The
3891
+ // dynamic ACL resolver calls this under its own `allowedBudgets: ['*']`, so it
3892
+ // still sees every file.
3893
+ const allowed = restrictedAllowedBudgets('actual_budgets_get_all');
3894
+ if (!allowed)
3895
+ return files;
3896
+ return files.filter(f => {
3897
+ const groupId = f?.groupId;
3898
+ return typeof groupId === 'string' && allowed.includes(groupId);
3899
+ });
3866
3900
  });
3867
3901
  }
3868
3902
  /**
@@ -3899,12 +3933,18 @@ export async function switchBudget(name) {
3899
3933
  }
3900
3934
  const key = name.toLowerCase();
3901
3935
  const found = budgetRegistry.get(key);
3902
- if (!found) {
3903
- const available = [...budgetRegistry.values()].map(b => `"${b.name}"`).join(', ');
3904
- throw new Error(`Budget "${name}" not found in configuration. ` +
3936
+ // #570: a restricted caller is told only about budgets it may use.
3937
+ const notFound = () => {
3938
+ const permitted = restrictedAllowedBudgets('actual_budgets_switch');
3939
+ const available = [...budgetRegistry.values()]
3940
+ .filter(b => !permitted || permitted.includes(b.syncId))
3941
+ .map(b => `"${b.name}"`).join(', ') || 'none permitted for this session';
3942
+ return new Error(`Budget "${name}" not found in configuration. ` +
3905
3943
  `Available budgets: ${available}. ` +
3906
3944
  `Add BUDGET_n_NAME/SYNC_ID/SERVER_URL vars to configure additional budgets.`);
3907
- }
3945
+ };
3946
+ if (!found)
3947
+ throw notFound();
3908
3948
  // ACL enforcement: the target budget must be in this session's allowedBudgets.
3909
3949
  // OIDC mode: explicit ACL required. Non-OIDC: short-circuit allow (single-user).
3910
3950
  if (config.AUTH_PROVIDER === 'oidc') {
@@ -3926,7 +3966,12 @@ export async function switchBudget(name) {
3926
3966
  sessionId,
3927
3967
  tool: 'actual_budgets_switch',
3928
3968
  }));
3929
- throw new Error(`Budget ACL: budget "${found.name}" (${found.syncId}) is not in this session's allowedBudgets.`);
3969
+ // #570: a denied budget gets the SAME error as an unknown name, so guessing
3970
+ // names cannot confirm that a budget outside the list exists. The round 1
3971
+ // review accepted a distinct "Budget ACL:" refusal here; that was reversed
3972
+ // because hiding other budgets is the point of this ticket. The log above
3973
+ // keeps the syncId for the operator.
3974
+ throw notFound();
3930
3975
  }
3931
3976
  }
3932
3977
  // #189 Phase 1: the principal's chosen budget is persisted at each commit
@@ -4033,10 +4078,13 @@ export function clearSessionBudgetState(sessionId) {
4033
4078
  sessionBudgetState.delete(sessionId);
4034
4079
  }
4035
4080
  /**
4036
- * Return all configured budgets from the registry (for listing in actual_budgets_list_available).
4081
+ * Return the configured budgets this caller may use (for actual_budgets_list_available).
4082
+ * A restricted caller sees only its permitted entries (#570); the startup registry log
4083
+ * reads the Map directly and is not filtered.
4037
4084
  */
4038
4085
  export function getBudgetRegistry() {
4039
- return [...budgetRegistry.values()].map(b => ({
4086
+ const allowed = restrictedAllowedBudgets('actual_budgets_list_available');
4087
+ return [...budgetRegistry.values()].filter(b => !allowed || allowed.includes(b.syncId)).map(b => ({
4040
4088
  name: b.name,
4041
4089
  syncId: b.syncId,
4042
4090
  serverUrl: b.serverUrl,
@@ -4237,11 +4285,28 @@ export async function exportBudget() {
4237
4285
  * Side effect worth knowing at every call site: upstream `importBudget` LOADS the
4238
4286
  * imported budget, so the session's active budget changes to the new file. It is
4239
4287
  * therefore outside the `BUDGET_N_*` registry and outside whatever budget the ACL
4240
- * resolved for this request. The write queue still enforces the ACL on entry, so a
4241
- * caller cannot use this to escape a budget they were already denied, but the
4242
- * resulting budget is un-ACL'd because it did not exist when the ACL was built.
4288
+ * resolved for this request. The resulting budget is un-ACL'd because it did not
4289
+ * exist when the ACL was built, so a RESTRICTED caller is refused outright (#570),
4290
+ * before any lock or upstream call. Wildcard and unrestricted callers can still
4291
+ * import; the write queue also enforces the ACL on their active budget.
4292
+ *
4293
+ * Upstream creates a NEW cloud file for the import (fresh id, best-effort upload),
4294
+ * so it never overwrites another budget on the server. The local copy is keyed by
4295
+ * the archive's own id, so an archive of budget X replaces X's LOCAL copy.
4243
4296
  */
4244
4297
  export async function importBudget(input, opts = {}) {
4298
+ const restricted = restrictedAllowedBudgets('actual_budgets_import');
4299
+ if (restricted) {
4300
+ logger.warn(JSON.stringify({
4301
+ event: 'acl_denied',
4302
+ reason: 'import_by_restricted_principal',
4303
+ allowedBudgets: restricted,
4304
+ sessionId: requestContext.getStore()?.sessionId ?? null,
4305
+ tool: 'actual_budgets_import',
4306
+ }));
4307
+ throw new Error('Budget ACL: actual_budgets_import is not available to a session with a restricted ' +
4308
+ 'allowedBudgets list: the imported budget would belong to no permitted list.');
4309
+ }
4245
4310
  observability.incrementToolCall('actual.budgets.import').catch(() => { });
4246
4311
  const result = await queueWriteOperation(async () => {
4247
4312
  // #394: an import re-points the process-global singleton exactly as a download does, so it
@@ -175,7 +175,12 @@ function extractWhereFields(sql) {
175
175
  // comparison operator, but also before IN / LIKE / IS / BETWEEN (with their NOT forms). Before
176
176
  // this, `bogus_col IN ('a')`, `bogus_col LIKE '%a%'` and `bogus_col IS NULL` extracted no field
177
177
  // and so skipped the allowlist check entirely, while `bogus_col = 'a'` was correctly rejected.
178
- const fieldMatches = whereClause.matchAll(/(\w+)\.(\w+)|(?:^|\s)(\w+)\s*[=<>!]|(?:^|\s)(\w+)\s+(?:NOT\s+)?(?:IN|LIKE|BETWEEN)\b|(?:^|\s)(\w+)\s+IS\b/gi);
178
+ // #566: the IN / LIKE / BETWEEN alternative must not capture the keyword NOT as a field. After
179
+ // `(\w+)\.(\w+)` consumes a joined name such as `payee.name`, the scan resumes at ` NOT LIKE`,
180
+ // and without the `(?!NOT\s)` lookahead that alternative read `NOT` as a column and rejected a
181
+ // valid query with `Field "NOT" does not exist`. The lookahead is exact on purpose: it skips only
182
+ // the word NOT followed by whitespace, so every real column name is still extracted and checked.
183
+ const fieldMatches = whereClause.matchAll(/(\w+)\.(\w+)|(?:^|\s)(\w+)\s*[=<>!]|(?:^|\s)(?!NOT\s)(\w+)\s+(?:NOT\s+)?(?:IN|LIKE|BETWEEN)\b|(?:^|\s)(\w+)\s+IS\b/gi);
179
184
  for (const match of fieldMatches) {
180
185
  if (match[1] && match[2]) {
181
186
  // table.field
@@ -1,9 +1,9 @@
1
- export async function testMcpClient(advertisedUrl, _port, httpPath) {
1
+ export async function testMcpClient(serverUrl, httpPath) {
2
2
  // Try builtin fetch or fallback to node-fetch if necessary
3
3
  const globalFetch = globalThis.fetch;
4
4
  const fetchAny = globalFetch ?? (await import('node-fetch')).default;
5
5
  const fetchFn = fetchAny;
6
- const base = new URL(advertisedUrl);
6
+ const base = new URL(serverUrl);
7
7
  // quick probe endpoint (#286: the server-info probe moved off the reserved
8
8
  // /.well-known/oauth-protected-resource OAuth path to the non-reserved /mcp-info,
9
9
  // now a bare object; the `result ?? raw` unwrap below tolerates both shapes).
@@ -104,8 +104,12 @@ export async function testMcpClient(advertisedUrl, _port, httpPath) {
104
104
  headers: listHeaders,
105
105
  body: JSON.stringify(listPayload),
106
106
  });
107
- if (!listRes.ok)
108
- throw new Error(`tools/list failed: ${listRes.status}`);
107
+ if (!listRes.ok) {
108
+ // #565: a 404 here is usually the session's Actual connection failing (bad credentials or no
109
+ // reachable server); the body names the cause (#438), so carry it rather than a bare status.
110
+ const body = await listRes.json().catch(() => undefined);
111
+ throw new Error(`tools/list failed: ${listRes.status}${body === undefined ? '' : ` ${JSON.stringify(body).slice(0, 300)}`}`);
112
+ }
109
113
  const listJsonRaw = await listRes.json();
110
114
  const listJsonObj = listJsonRaw && typeof listJsonRaw === 'object' ? listJsonRaw : undefined;
111
115
  if (!listJsonObj || !('result' in listJsonObj))
@@ -3,7 +3,7 @@ import adapter from '../lib/actual-adapter.js';
3
3
  const InputSchema = z.object({});
4
4
  const tool = {
5
5
  name: 'actual_budgets_get_all',
6
- description: 'Get a list of all available budget files. Useful for multi-budget management and discovering available budgets on the server.',
6
+ description: 'Get the budget files on the server that this session may use. Useful for multi-budget management and discovering available budgets.',
7
7
  inputSchema: InputSchema,
8
8
  call: async (_args, _meta) => {
9
9
  const result = await adapter.getBudgets();
@@ -7,13 +7,12 @@ import adapter from '../lib/actual-adapter.js';
7
7
  * TWO SIDE EFFECTS EVERY CALLER MUST KNOW, both verified against `importActual` in
8
8
  * the installed `@actual-app/api` and against a live server in the Docker E2E run:
9
9
  *
10
- * 1. IT CAN OVERWRITE AN EXISTING BUDGET. The budget id is taken from the ARCHIVE,
11
- * not freshly generated. `importActual` closes the current budget, restores the
12
- * archive under the id the archive carries, and loads it. So re-importing an
13
- * export of budget X restores ONTO budget X, replacing that budget's local data.
14
- * This is what makes export/import a genuine backup-and-restore pair, and it is
15
- * also why an import against a budget with real data is destructive. It is only
16
- * "a new budget" when the archive's id is not already present.
10
+ * 1. IT CAN OVERWRITE AN EXISTING BUDGET'S LOCAL COPY. The local budget directory is
11
+ * keyed by the id the ARCHIVE carries, so re-importing an export of budget X
12
+ * replaces X's local data. On the SERVER it never overwrites anything (#570):
13
+ * `importActual` clears the cloud file and group ids and uploads the result as a
14
+ * NEW budget file (best effort; a failed upload leaves it local only).
15
+ * A session with a restricted budget ACL is refused this tool outright (#570).
17
16
  * 2. IT CHANGES THE ACTIVE BUDGET. The imported budget is loaded, so the session
18
17
  * switches to it. If the archive's id is not in the `BUDGET_N_*` registry, that
19
18
  * budget is also outside whatever set the ACL resolved for this request, and
@@ -66,7 +65,7 @@ export default createTool({
66
65
  name: 'actual_budgets_import',
67
66
  description: 'Restore or import a budget from an Actual Budget ".zip" export or a YNAB4/YNAB5 export. ' +
68
67
  'DESTRUCTIVE: the budget id comes from the archive, so re-importing an export REPLACES that ' +
69
- 'budget\'s data rather than creating a copy. Never call this to "have a look at" a file. Confirm ' +
68
+ 'budget\'s local copy, and the result is uploaded to the server as a new budget file. Never call this to "have a look at" a file. Confirm ' +
70
69
  'with the user before importing over a budget that holds real data. ' +
71
70
  'It also LOADS the imported budget, so the active budget for this session changes; if the archive\'s ' +
72
71
  'budget is not in the configured list, call actual_budgets_list_available and actual_budgets_switch ' +
@@ -3,7 +3,7 @@ import adapter from '../lib/actual-adapter.js';
3
3
  const InputSchema = z.object({});
4
4
  const tool = {
5
5
  name: 'actual_budgets_list_available',
6
- description: 'List all pre-configured budgets available for switching. ' +
6
+ description: 'List the pre-configured budgets this session may switch to (a session limited to some budgets sees only those). ' +
7
7
  'Each entry shows the budget name, sync ID, server URL, and whether it uses E2E encryption. ' +
8
8
  'Budgets are configured via BUDGET_n_NAME / BUDGET_n_SYNC_ID / BUDGET_n_SERVER_URL env vars. ' +
9
9
  'Pass the budget name to actual_budgets_switch to change the active budget.',
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.22.44",
4
+ "version": "0.22.47",
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/transactions_aggregate.test.js && node tests/unit/account_flow_summary.test.js && node tests/unit/recurring_expenses_summary.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/new_tool_completeness.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/adapter_set_budget_batch.test.js && node tests/unit/budget_updates_batch.test.js && node tests/unit/rules_create.test.js && node tests/unit/adapter_rule_reference_guard.test.js && node tests/unit/rules_create_batch.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/httpServer_init_failure_report.test.js && node tests/unit/stdio_init_failure_report.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/session_ownership.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/oidc_resource_identifier.test.js && node tests/unit/httpServer_oidc_resource_metadata.test.js && node tests/unit/oidc_error_cause.test.js && node tests/unit/httpServer_oidc_error_body.test.js && node tests/unit/oidc_token_source.test.js && node tests/unit/config_oidc_assertion_refines.test.js && node tests/unit/httpServer_oidc_assertion_mode.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpErrorHandler.test.js && node tests/unit/httpServer_error_handler_wiring.test.js && node tests/unit/httpServer_trust_proxy.test.js && node tests/unit/httpServer_batch_cap.test.js && node tests/unit/dotenv_stdout_quiet.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/transactions_update_split_convert.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/transactions_update_batch.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/tool_content_logging.test.js && node tests/unit/adapter_error_logging.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/playwright_version_drift.test.js && node tests/unit/observability_smoke.test.js && node tests/unit/residue_pattern.test.js && node tests/unit/batch_uncategorized_cleanup.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/server_version_probe_outside_op_timeout.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 && node tests/unit/oidc_scope_list.test.js && node tests/unit/get_context.test.js && node tests/unit/contributor_docs.test.js && node tests/unit/toolsets.test.js && node tests/unit/toolsets_entrypoint.test.js && node tests/unit/tools_list_size_budget.test.js && node tests/unit/rules_update_schedules_schema.test.js && node tests/unit/schedules_update_amount_guard.test.js && node tests/unit/source_text_helper.test.js && node tests/unit/transport_onerror.test.js && node tests/unit/adapter_transaction_batch_abort.test.js && node tests/unit/transactions_merge_adapter.test.js && node tests/unit/schedules_dead_conditions.test.js && node tests/unit/session_ownership_express.test.js && node tests/unit/pool_orphan_sweep.test.js && node tests/unit/session_budget_state_cleanup.test.js && node tests/unit/http_session_delete.test.js && node tests/unit/budget_acl_parse.test.js && node tests/unit/server_instructions.test.js && node tests/unit/query_run_empty_hint.test.js",
33
+ "test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/transactions_aggregate.test.js && node tests/unit/account_flow_summary.test.js && node tests/unit/recurring_expenses_summary.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/new_tool_completeness.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/adapter_set_budget_batch.test.js && node tests/unit/budget_updates_batch.test.js && node tests/unit/rules_create.test.js && node tests/unit/adapter_rule_reference_guard.test.js && node tests/unit/rules_create_batch.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/httpServer_init_failure_report.test.js && node tests/unit/stdio_init_failure_report.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/session_ownership.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/oidc_resource_identifier.test.js && node tests/unit/httpServer_oidc_resource_metadata.test.js && node tests/unit/oidc_error_cause.test.js && node tests/unit/httpServer_oidc_error_body.test.js && node tests/unit/oidc_token_source.test.js && node tests/unit/config_oidc_assertion_refines.test.js && node tests/unit/httpServer_oidc_assertion_mode.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpErrorHandler.test.js && node tests/unit/httpServer_error_handler_wiring.test.js && node tests/unit/httpServer_trust_proxy.test.js && node tests/unit/httpServer_batch_cap.test.js && node tests/unit/dotenv_stdout_quiet.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_acl_listing.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/transactions_update_split_convert.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/transactions_update_batch.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/tool_content_logging.test.js && node tests/unit/adapter_error_logging.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/playwright_version_drift.test.js && node tests/unit/observability_smoke.test.js && node tests/unit/residue_pattern.test.js && node tests/unit/batch_uncategorized_cleanup.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/server_version_probe_outside_op_timeout.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 && node tests/unit/oidc_scope_list.test.js && node tests/unit/get_context.test.js && node tests/unit/contributor_docs.test.js && node tests/unit/toolsets.test.js && node tests/unit/toolsets_entrypoint.test.js && node tests/unit/tools_list_size_budget.test.js && node tests/unit/rules_update_schedules_schema.test.js && node tests/unit/schedules_update_amount_guard.test.js && node tests/unit/source_text_helper.test.js && node tests/unit/transport_onerror.test.js && node tests/unit/adapter_transaction_batch_abort.test.js && node tests/unit/transactions_merge_adapter.test.js && node tests/unit/schedules_dead_conditions.test.js && node tests/unit/session_ownership_express.test.js && node tests/unit/pool_orphan_sweep.test.js && node tests/unit/session_budget_state_cleanup.test.js && node tests/unit/http_session_delete.test.js && node tests/unit/budget_acl_parse.test.js && node tests/unit/server_instructions.test.js && node tests/unit/query_run_empty_hint.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",