actual-mcp-server 0.11.5 → 0.11.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -774,4 +774,4 @@ The software is provided **as-is**, without warranty of any kind. The author acc
774
774
 
775
775
  ---
776
776
 
777
- **Version:** 0.11.5 | **Tool Count:** 74 (verified LibreChat-compatible)
777
+ **Version:** 0.11.6 | **Tool Count:** 74 (verified LibreChat-compatible)
@@ -172,6 +172,28 @@ class ActualConnectionPool {
172
172
  * downloadBudget(newSyncId) call. switchBudget's fast path uses this so
173
173
  * subsequent comparisons reflect the loaded budget. See #172.
174
174
  */
175
+ /**
176
+ * #349 (review follow-up): invalidate the tracked syncId on EVERY entry.
177
+ *
178
+ * `@actual-app/api` is a process-global singleton with ONE loaded budget, while
179
+ * this pool tracks up to MAX_CONCURRENT_SESSIONS entries that each carry their
180
+ * own `syncId`. An import performed by session A therefore changes the loaded
181
+ * budget for sessions B..N as well, but only A knows it happened. Invalidating
182
+ * A alone leaves B's next switchBudget matching the #172 fast path against a
183
+ * record that is no longer true, returning success while B operates on A's
184
+ * imported budget: the same failure #349 fixed, arriving from a direction the
185
+ * user cannot see.
186
+ *
187
+ * Returns the number of entries invalidated, for logging.
188
+ */
189
+ invalidateAllLoadedSyncIds(sentinel) {
190
+ let n = 0;
191
+ for (const entry of this.connections.values()) {
192
+ entry.syncId = sentinel;
193
+ n += 1;
194
+ }
195
+ return n;
196
+ }
175
197
  updateLoadedSyncId(sessionId, newSyncId) {
176
198
  const entry = this.connections.get(sessionId);
177
199
  if (entry) {
@@ -2056,13 +2056,40 @@ export async function exportBudget() {
2056
2056
  */
2057
2057
  export async function importBudget(input, opts = {}) {
2058
2058
  observability.incrementToolCall('actual.budgets.import').catch(() => { });
2059
- return queueWriteOperation(async () => {
2059
+ const result = await queueWriteOperation(async () => {
2060
2060
  return await withConcurrency(() => retry(() => rawImportBudget(input, opts), {
2061
2061
  retries: 0,
2062
2062
  backoffMs: 200,
2063
2063
  isRetryable: isRetryableError,
2064
2064
  }));
2065
2065
  });
2066
+ // #349: an import CHANGES WHICH BUDGET IS LOADED, so the pool's record of it
2067
+ // must stop naming the old one.
2068
+ //
2069
+ // switchBudget's #172 fast path skips the re-download when
2070
+ // `currentEntry.syncId === found.syncId`, trusting that field as the record of
2071
+ // what is loaded. Every other path that changes the loaded budget keeps it in
2072
+ // sync; this one did not. The result was a switch BACK to the configured budget
2073
+ // returning `{success: true}` while the session stayed on the imported copy, so
2074
+ // subsequent writes and deletes landed in the wrong budget. That is the
2075
+ // reported-success-with-no-effect class of #347, with financial data at the
2076
+ // other end of it.
2077
+ //
2078
+ // The sentinel is deliberate rather than the imported id alone: configured sync
2079
+ // ids are UUIDs, so an `imported:` prefix can never compare equal to one, which
2080
+ // makes the fast path structurally unable to match after an import. An imported
2081
+ // budget frequently has no cloud sync id at all, so there is no true value to
2082
+ // record here; what matters is that the stale one is gone.
2083
+ // EVERY entry, not just this session's. The api singleton is process-global with
2084
+ // one loaded budget, so an import by session A changes what B..N are looking at
2085
+ // too. Invalidating only A would leave the others matching the fast path against
2086
+ // a record that is no longer true.
2087
+ const invalidated = connectionPool.invalidateAllLoadedSyncIds(`imported:${result.id}`);
2088
+ if (invalidated > 0) {
2089
+ logger.debug(`[ADAPTER] importBudget: invalidated the pooled syncId on ${invalidated} session(s) ` +
2090
+ `(now "imported:${result.id}"), so a switch back re-downloads instead of no-opping (#349).`);
2091
+ }
2092
+ return result;
2066
2093
  }
2067
2094
  /**
2068
2095
  * Read the budget's synced preferences: number format, date format, currency,
@@ -3,13 +3,65 @@ import adapter from '../lib/actual-adapter.js';
3
3
  const InputSchema = z.object({
4
4
  id: z.string().describe('Account ID to delete'),
5
5
  });
6
+ /**
7
+ * #347: this tool used to return `{success: true}` unconditionally, which was a
8
+ * lie for CLOSED accounts.
9
+ *
10
+ * Verified in upstream source. `api.deleteAccount(id)` maps to
11
+ * `account-close({id, forced: true})`, and `closeAccount` opens with:
12
+ *
13
+ * const account = await db.first('SELECT * FROM accounts WHERE id = ? AND tombstone = 0', [id]);
14
+ * if (!account || account.closed === 1) return; // <- silent no-op
15
+ *
16
+ * So a closed account is a no-op with no error, and the tool reported success
17
+ * anyway. Confirmed against a live server: nine deletions all reported success
18
+ * and the seven closed ones survived.
19
+ *
20
+ * WHY VERIFY AFTER, NOT CHECK BEFORE. The obvious fix is to look the account up
21
+ * first and refuse if it is closed. That breaks a case which looks identical from
22
+ * here but is not: `closeAccount` DELETES (tombstones) an account that has zero
23
+ * transactions, and `reopenAccount` only clears the `closed` flag, never the
24
+ * tombstone. `getAccounts()` filters `tombstone = 0`, so a close-then-reopen
25
+ * leaves an id that no longer appears in any listing. A pre-check would call that
26
+ * "not found" and throw, where the caller's intent (make this account not exist)
27
+ * is in fact already satisfied. The repo's own teardown idiom does exactly this:
28
+ * close first, then delete.
29
+ *
30
+ * Verifying AFTER answers the question the caller actually asked, "is it gone?",
31
+ * rather than a proxy for it. It also closes a race the pre-check could not: the
32
+ * read and the write land in different sessions, so a concurrent close could
33
+ * commit between them and reproduce the original false success.
34
+ *
35
+ * The deliberate consequence is that deleting an id that never existed still
36
+ * reports success. That is idempotent-delete semantics, it matches the behaviour
37
+ * before this change, and the post-condition ("no account with this id exists")
38
+ * is true either way.
39
+ */
6
40
  const tool = {
7
41
  name: 'actual_accounts_delete',
8
- description: 'Delete an account from Actual Budget. Note: The account must not have any transactions. This operation cannot be undone.',
42
+ description: 'Delete an account from Actual Budget. DESTRUCTIVE: if the account has transactions, they are ' +
43
+ 'deleted too, paired transfer transactions are unlinked, and the transfer payee is removed. This ' +
44
+ 'cannot be undone. A CLOSED account cannot be deleted (Actual silently ignores it): reopen it with ' +
45
+ 'actual_accounts_reopen first. Deleting an account that does not exist reports success.',
9
46
  inputSchema: InputSchema,
10
47
  call: async (args, _meta) => {
11
48
  const input = InputSchema.parse(args || {});
12
49
  await adapter.deleteAccount(input.id);
50
+ // The verification the old code skipped. One read, after the write, so the
51
+ // success claim is about the observed state rather than about the call having
52
+ // returned.
53
+ const remaining = (await adapter.getAccounts());
54
+ const survivor = Array.isArray(remaining) ? remaining.find((a) => a?.id === input.id) : undefined;
55
+ if (survivor) {
56
+ // Reachable when the account is closed: upstream returned without acting.
57
+ // Anything else that leaves it present is equally worth surfacing, so the
58
+ // message covers the general case and names the likely one.
59
+ throw new Error(`Account "${survivor.name ?? input.id}" (${input.id}) still exists after the delete` +
60
+ (survivor.closed === true
61
+ ? ': it is CLOSED, and Actual silently ignores a delete on a closed account. Reopen it with ' +
62
+ 'actual_accounts_reopen and delete it again, or leave it closed if you only wanted it out of the way.'
63
+ : '. The delete was accepted but had no effect; check the account state in Actual.'));
64
+ }
13
65
  return { success: true };
14
66
  },
15
67
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "actual-mcp-server",
3
3
  "displayName": "Actual MCP Server",
4
- "version": "0.11.5",
4
+ "version": "0.11.6",
5
5
  "engines": {
6
6
  "node": ">=22.0.0",
7
7
  "npm": ">=10.0.0"
@@ -30,7 +30,7 @@
30
30
  "verify-tools": "npm run build && node scripts/verify-tools.js",
31
31
  "check:coverage": "node scripts/list-actual-api-methods.mjs",
32
32
  "direct-sync": "node scripts/direct-sync/bank-sync-direct.mjs",
33
- "test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/generated_tools.smoke.test.js && node tests/unit/schema_validation.test.js && node tests/unit/schema_json_openai_compat.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_data_dir.test.js && node tests/unit/config_drift.test.js && node tests/unit/port_alignment.test.js && node tests/unit/dockerfile_data_dir_alignment.test.js && node tests/unit/unraid_template_alignment.test.js && node tests/unit/entrypoint_invariants.test.js && node tests/unit/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/knip_config.test.js && node tests/unit/readme_stats_sync.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/workflow_release_guards.test.js && node tests/unit/report_train_failure.test.js && node tests/unit/unit_chain_membership.test.js && node tests/unit/api_surface_drift.test.js && node tests/unit/api_coverage_baseline.test.js && node tests/unit/train_preflight.test.js && node tests/unit/verify_published_artifacts.test.js && node tests/unit/report_train_stale.test.js && node tests/unit/stdio_framing_check.test.js && node tests/unit/server_info.test.js && node tests/unit/transfers_create.test.js && node tests/unit/bot_target_branch.test.js && node tests/unit/auth_posture.test.js && node tests/unit/auth_posture_wiring.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/httpServer_session_not_found.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/retry_classifier.test.js && node tests/unit/adapter_module_surface.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/payees_delete.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_oidc_jwks_discovery.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js && node tests/unit/tags_list.test.js && node tests/unit/tags_create.test.js && node tests/unit/tags_update.test.js && node tests/unit/tags_delete.test.js && node tests/unit/notes_get.test.js && node tests/unit/notes_update.test.js && node tests/unit/payees_common_list.test.js && node tests/unit/transactions_update_guard.test.js && node tests/unit/transactions_update_splits.test.js && node tests/unit/entities_search.test.js && node tests/unit/transactions_import_schema.test.js && node tests/unit/tool_schema_shape.test.js && node tests/unit/zod_error_format.test.js && node tests/unit/tool_count_sync.test.js && node tests/unit/stdio_transport.test.js && node tests/unit/stdio_budget_switch.test.js && node tests/unit/logger_structured.test.js && node tests/unit/logger_redaction.test.js && node tests/unit/logger_correlation.test.js && node tests/unit/check_coverage.test.js && node tests/unit/adapter_op_timeout.test.js && node tests/unit/adapter_write_queue_wakeup.test.js && node tests/unit/ci_workflow_guards.test.js && node tests/unit/node_version_guard.test.js && node tests/unit/node_version_drift.test.js && node tests/unit/observability_smoke.test.js && node tests/unit/residue_pattern.test.js && node tests/unit/dual_transport_gate.test.js && node tests/unit/manual_assertions_use_helper.test.js && node tests/unit/server_version_guard.test.js && node tests/unit/payee_rules_reference_filter.test.js && node tests/unit/oidc_metadata_discovery.test.js && node tests/unit/httpServer_oauth_metadata_route.test.js && node tests/unit/httpServer_mcp_info_route.test.js && node tests/unit/residue_sweep_balance_account.test.js && node tests/unit/tool_factory.test.js && node tests/unit/budget_registry_parse.test.js && node tests/unit/preferences_get.test.js && node tests/unit/budgets_export.test.js && node tests/unit/budgets_import.test.js && node tests/unit/budget_acl_dynamic.test.js && node tests/unit/budget_acl_identity_map.test.js && node tests/unit/budget_acl_userinfo.test.js && node tests/unit/acl_e2e_fixture_contract.test.js && node tests/unit/budget_acl_middleware.test.js && node tests/unit/rules_stage.test.js",
33
+ "test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/generated_tools.smoke.test.js && node tests/unit/schema_validation.test.js && node tests/unit/schema_json_openai_compat.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_data_dir.test.js && node tests/unit/config_drift.test.js && node tests/unit/port_alignment.test.js && node tests/unit/dockerfile_data_dir_alignment.test.js && node tests/unit/unraid_template_alignment.test.js && node tests/unit/entrypoint_invariants.test.js && node tests/unit/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/knip_config.test.js && node tests/unit/readme_stats_sync.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/workflow_release_guards.test.js && node tests/unit/report_train_failure.test.js && node tests/unit/unit_chain_membership.test.js && node tests/unit/api_surface_drift.test.js && node tests/unit/api_coverage_baseline.test.js && node tests/unit/train_preflight.test.js && node tests/unit/verify_published_artifacts.test.js && node tests/unit/report_train_stale.test.js && node tests/unit/stdio_framing_check.test.js && node tests/unit/server_info.test.js && node tests/unit/transfers_create.test.js && node tests/unit/bot_target_branch.test.js && node tests/unit/auth_posture.test.js && node tests/unit/auth_posture_wiring.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/httpServer_session_not_found.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/retry_classifier.test.js && node tests/unit/adapter_module_surface.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/accounts_delete.test.js && node tests/unit/payees_delete.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_oidc_jwks_discovery.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js && node tests/unit/tags_list.test.js && node tests/unit/tags_create.test.js && node tests/unit/tags_update.test.js && node tests/unit/tags_delete.test.js && node tests/unit/notes_get.test.js && node tests/unit/notes_update.test.js && node tests/unit/payees_common_list.test.js && node tests/unit/transactions_update_guard.test.js && node tests/unit/transactions_update_splits.test.js && node tests/unit/entities_search.test.js && node tests/unit/transactions_import_schema.test.js && node tests/unit/tool_schema_shape.test.js && node tests/unit/zod_error_format.test.js && node tests/unit/tool_count_sync.test.js && node tests/unit/stdio_transport.test.js && node tests/unit/stdio_budget_switch.test.js && node tests/unit/logger_structured.test.js && node tests/unit/logger_redaction.test.js && node tests/unit/logger_correlation.test.js && node tests/unit/check_coverage.test.js && node tests/unit/adapter_op_timeout.test.js && node tests/unit/adapter_write_queue_wakeup.test.js && node tests/unit/ci_workflow_guards.test.js && node tests/unit/node_version_guard.test.js && node tests/unit/node_version_drift.test.js && node tests/unit/observability_smoke.test.js && node tests/unit/residue_pattern.test.js && node tests/unit/dual_transport_gate.test.js && node tests/unit/manual_assertions_use_helper.test.js && node tests/unit/server_version_guard.test.js && node tests/unit/payee_rules_reference_filter.test.js && node tests/unit/oidc_metadata_discovery.test.js && node tests/unit/httpServer_oauth_metadata_route.test.js && node tests/unit/httpServer_mcp_info_route.test.js && node tests/unit/residue_sweep_balance_account.test.js && node tests/unit/tool_factory.test.js && node tests/unit/budget_registry_parse.test.js && node tests/unit/preferences_get.test.js && node tests/unit/budgets_export.test.js && node tests/unit/budgets_import.test.js && node tests/unit/budgets_import_syncid.test.js && node tests/unit/budget_acl_dynamic.test.js && node tests/unit/budget_acl_identity_map.test.js && node tests/unit/budget_acl_userinfo.test.js && node tests/unit/acl_e2e_fixture_contract.test.js && node tests/unit/budget_acl_middleware.test.js && node tests/unit/rules_stage.test.js",
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",