actual-mcp-server 0.8.9 → 0.8.11

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
@@ -722,4 +722,4 @@ The software is provided **as-is**, without warranty of any kind. The author acc
722
722
 
723
723
  ---
724
724
 
725
- **Version:** 0.8.9 | **Tool Count:** 71 (verified LibreChat-compatible)
725
+ **Version:** 0.8.11 | **Tool Count:** 71 (verified LibreChat-compatible)
@@ -12,6 +12,7 @@ import { retry, isRetryableError } from './retry.js';
12
12
  import { withOpTimeout } from './opTimeout.js';
13
13
  import { notFoundMsg } from './errors.js';
14
14
  import logger from '../logger.js';
15
+ import { checkServerVersionOnce } from './server-version-guard.js';
15
16
  import config from '../config.js';
16
17
  import { parseBudgetRegistry } from './budget-registry.js';
17
18
  import { getPreferredBudgetSyncId, setPreferredBudgetSyncId, pickAllowedPreferredBudget } from './budget-preference-store.js';
@@ -229,11 +230,21 @@ function _enforceBudgetAcl(toolName) {
229
230
  * In either mode `withApiLock` serialises against concurrent callers because
230
231
  * `@actual-app/api` is a process-wide singleton.
231
232
  */
232
- export async function withActualApi(operation) {
233
+ export async function withActualApi(rawOperation) {
233
234
  // ACL enforcement BEFORE pool branching or lock acquisition (#156).
234
235
  // Denial here means the lock is never acquired and no upstream resource is
235
236
  // touched.
236
237
  _enforceBudgetAcl();
238
+ // #276: on the FIRST successful op, warn (once) if the Actual server version is outside
239
+ // the range this build supports. It runs inside the op while the connection is live and
240
+ // reuses it via rawGetServerVersion (NOT the withActualApi-wrapped getServerVersion, which
241
+ // would re-enter the lock). The once-guard flips its flag synchronously, so this never
242
+ // repeats per session or op. It is advisory: it never throws and never blocks the op.
243
+ const operation = async () => {
244
+ const result = await rawOperation();
245
+ await checkServerVersionOnce(() => rawGetServerVersion(), logger);
246
+ return result;
247
+ };
237
248
  const sessionId = _resolveSessionId();
238
249
  if (_hasPooledConnection(sessionId)) {
239
250
  // Pooled mode: skip init+shutdown.
@@ -1350,14 +1361,46 @@ export async function mergePayees(targetId, mergeIds) {
1350
1361
  await withConcurrency(() => retry(() => rawMergePayees(targetId, mergeIds), { retries: 0, backoffMs: 200 }));
1351
1362
  });
1352
1363
  }
1364
+ /**
1365
+ * Does a serialized Actual rule reference this payee? The payee id lives INSIDE a
1366
+ * condition or action whose `field` is 'payee': as `value` (op 'is'/'isNot') or as
1367
+ * a member of `value` (op 'oneOf', an array). A serialized rule has NO top-level
1368
+ * `payee_id` column, so the original BUG-3 post-filter (`r.payee_id === payeeId`)
1369
+ * matched nothing and made actual_payee_rules_get always return empty. This mirrors
1370
+ * `@actual-app/api`'s own getRulesForPayee, which scans payee-typed ids in the rule.
1371
+ */
1372
+ export function ruleReferencesPayee(rule, payeeId) {
1373
+ const r = rule;
1374
+ const clauses = [
1375
+ ...(Array.isArray(r?.conditions) ? r.conditions : []),
1376
+ ...(Array.isArray(r?.actions) ? r.actions : []),
1377
+ ];
1378
+ return clauses.some((c) => {
1379
+ if (c?.field !== 'payee')
1380
+ return false;
1381
+ return Array.isArray(c.value) ? c.value.includes(payeeId) : c?.value === payeeId;
1382
+ });
1383
+ }
1353
1384
  export async function getPayeeRules(payeeId) {
1354
1385
  return withActualApi(async () => {
1355
1386
  observability.incrementToolCall('actual.payees.getPayeeRules').catch(() => { });
1356
1387
  const allRules = await withConcurrency(() => retry(() => rawGetPayeeRules(payeeId), { retries: 2, backoffMs: 200 }));
1357
1388
  if (!Array.isArray(allRules))
1358
1389
  return [];
1359
- // API ignores payeeId filter — apply post-filter (BUG-3)
1360
- return allRules.filter((r) => r?.payee_id === payeeId);
1390
+ // Defensive narrowing to the requested payee. @actual-app/api already scopes
1391
+ // payee-rules-get to the id; the filter must use the real rule shape (payee
1392
+ // referenced in a condition/action), NOT a nonexistent `payee_id` column.
1393
+ const filtered = allRules.filter((r) => ruleReferencesPayee(r, payeeId));
1394
+ // Upstream already scopes to the payee, so this filter should be a no-op. If it
1395
+ // ever drops rows, the serialized rule shape has drifted and the predicate is now
1396
+ // silently under-matching: exactly the failure class that made #284 return empty
1397
+ // for months. Surface it instead of hiding it.
1398
+ if (filtered.length !== allRules.length) {
1399
+ logger.debug('[ADAPTER] getPayeeRules post-filter dropped rows; rule shape may have drifted', {
1400
+ payeeId, upstream: allRules.length, kept: filtered.length,
1401
+ });
1402
+ }
1403
+ return filtered;
1361
1404
  });
1362
1405
  }
1363
1406
  export async function batchBudgetUpdates(fn) {
@@ -81,3 +81,21 @@ export const MONTH_PATTERN = /^\d{4}-\d{2}$/;
81
81
  * UUID pattern for ID validation
82
82
  */
83
83
  export const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
84
+ /**
85
+ * #276: the Actual Budget SERVER version range this build's `@actual-app/api` is known
86
+ * to work with. HAND-MAINTAINED: `@actual-app/api` v26 exposes no supported-server range
87
+ * (verified: getServerVersion just proxies the server's build.version), so there is no
88
+ * upstream fact to derive this from and no #275-style drift guard is possible.
89
+ *
90
+ * UPDATE THIS when bumping `@actual-app/api`: set `minVersion` to the oldest server the new
91
+ * client supports, and `testedMaxMajor` to the newest server major it has been tested
92
+ * against. The check is ADVISORY only (the real compatibility contract is the migration
93
+ * check `@actual-app/api` runs at downloadBudget), so a wrong value here can at most produce
94
+ * a spurious or missing warning, never a broken deployment.
95
+ */
96
+ export const SUPPORTED_ACTUAL_SERVER_RANGE = {
97
+ /** Oldest server version this client supports. Below this: "too old" warning. */
98
+ minVersion: '25.0.0',
99
+ /** Newest server MAJOR tested against `@actual-app/api` v26. Above this: "newer than tested". */
100
+ testedMaxMajor: 26,
101
+ };
@@ -0,0 +1,105 @@
1
+ /**
2
+ * #276: warn (once) when the Actual Budget SERVER version is outside the range this build's
3
+ * `@actual-app/api` is known to work with.
4
+ *
5
+ * Reporting already existed (actual_server_get_version, actual_server_info) but nothing
6
+ * enforced or even flagged compatibility. This adds an ADVISORY startup-ish warning: the
7
+ * real compatibility contract is the migration check `@actual-app/api` runs at
8
+ * downloadBudget, so this warning never fails startup and never blocks an operation.
9
+ *
10
+ * Design (mirrors src/lib/node-version-guard.ts):
11
+ * - The comparator is PURE (no I/O, no logging), so its truth table is unit-testable.
12
+ * - It FAILS OPEN: an unparseable version produces no warning. A spurious warning on a
13
+ * healthy deployment would be worse than staying quiet.
14
+ * - The firing is a per-PROCESS once-guard: the flag is flipped SYNCHRONOUSLY before any
15
+ * await, so the check runs exactly once no matter how many ops or sessions occur, and it
16
+ * reuses the connection the triggering op already established (no extra auth burst,
17
+ * avoiding the #127/#134 class). There is NO boot-time probe: index.ts deliberately has
18
+ * no startup connection, and a naive boot call would double-init then be torn down by
19
+ * shutdownActualApi.
20
+ */
21
+ import { SUPPORTED_ACTUAL_SERVER_RANGE } from './constants.js';
22
+ /** Parse `26.7.0` (or `v26.7.0`) into `[26, 7, 0]`. Returns null when unparseable. */
23
+ export function parseVersion(v) {
24
+ if (typeof v !== 'string')
25
+ return null;
26
+ const m = /^v?(\d+)\.(\d+)(?:\.(\d+))?/.exec(v.trim());
27
+ if (!m)
28
+ return null;
29
+ return [Number(m[1]), Number(m[2]), Number(m[3] ?? 0)];
30
+ }
31
+ /** a < b as semver triples. */
32
+ function lessThan(a, b) {
33
+ for (let i = 0; i < 3; i++) {
34
+ if (a[i] !== b[i])
35
+ return a[i] < b[i];
36
+ }
37
+ return false;
38
+ }
39
+ /**
40
+ * Pure. Compare a running server version against the supported range.
41
+ * FAILS OPEN on anything unparseable (returns ok:true, no message), so a version string we
42
+ * did not anticipate never produces a false warning.
43
+ */
44
+ export function checkServerVersion(running, range = SUPPORTED_ACTUAL_SERVER_RANGE) {
45
+ const have = parseVersion(running);
46
+ const min = parseVersion(range.minVersion);
47
+ if (!have || !min)
48
+ return { ok: true };
49
+ if (lessThan(have, min)) {
50
+ return {
51
+ ok: false,
52
+ message: `Actual Budget server ${running} is older than the minimum this build supports ` +
53
+ `(>= ${range.minVersion}). Some tools may misbehave. Upgrade the Actual Budget server, ` +
54
+ 'or pin an older actual-mcp-server. This is advisory: the server, not this warning, ' +
55
+ 'enforces compatibility at budget download.',
56
+ };
57
+ }
58
+ if (have[0] > range.testedMaxMajor) {
59
+ return {
60
+ ok: false,
61
+ message: `Actual Budget server ${running} is newer than this build was tested against ` +
62
+ `(tested up to major ${range.testedMaxMajor}.x). It will very likely work, but if a ` +
63
+ 'tool behaves oddly, update actual-mcp-server. Advisory only.',
64
+ };
65
+ }
66
+ return { ok: true };
67
+ }
68
+ // ---------------------------------------------------------------------------
69
+ // Per-process once-guard.
70
+ // ---------------------------------------------------------------------------
71
+ let checked = false;
72
+ /**
73
+ * Run the compatibility check at most once per process. Call this from inside a successful
74
+ * `withActualApi` operation (while the connection is live), passing a function that reads the
75
+ * server version through the SAME live connection (rawGetServerVersion, not the withActualApi
76
+ * wrapped getServerVersion, to avoid re-entering the lock).
77
+ *
78
+ * NEVER throws. A read failure is a single `debug`; being outside the range is a single
79
+ * `warn`; being in range is silent. The `log` argument is the module logger so all output
80
+ * goes through the redaction-protected logger (never console.*), keeping stdio framing intact.
81
+ */
82
+ export async function checkServerVersionOnce(readVersion, log) {
83
+ if (checked)
84
+ return;
85
+ checked = true; // set synchronously, before any await, so a concurrent op cannot double-fire
86
+ try {
87
+ const result = await readVersion();
88
+ if (!result || 'error' in result || typeof result.version !== 'string') {
89
+ log.debug('[server-version] could not read the Actual server version; skipping the compatibility check');
90
+ return;
91
+ }
92
+ const verdict = checkServerVersion(result.version);
93
+ if (!verdict.ok && verdict.message) {
94
+ log.warn(verdict.message);
95
+ }
96
+ }
97
+ catch {
98
+ // Advisory only: a failure to check must never surface as an error or affect the op.
99
+ log.debug('[server-version] compatibility check threw; ignored');
100
+ }
101
+ }
102
+ /** Test-only: reset the once-guard between cases. */
103
+ export function _resetForTests() {
104
+ checked = false;
105
+ }
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.8.9",
4
+ "version": "0.8.11",
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/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/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/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/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/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",
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/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/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/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/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/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",
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",