actual-mcp-server 0.6.31 → 0.6.33

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
@@ -730,4 +730,4 @@ The software is provided **as-is**, without warranty of any kind. The author acc
730
730
 
731
731
  ---
732
732
 
733
- **Version:** 0.6.31 | **Tool Count:** 63 (verified LibreChat-compatible)
733
+ **Version:** 0.6.33 | **Tool Count:** 63 (verified LibreChat-compatible)
package/dist/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "actual-mcp-server",
3
3
  "displayName": "Actual MCP Server",
4
- "version": "0.6.31",
4
+ "version": "0.6.33",
5
5
  "engines": {
6
6
  "node": ">=20.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_insecure_upstream.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_body_limit.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.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_insecure_upstream.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_body_limit.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",
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",
@@ -15,6 +15,7 @@ import retry, { isRetryableError } from './retry.js';
15
15
  import logger from '../logger.js';
16
16
  import config from '../config.js';
17
17
  import { parseBudgetRegistry } from './budget-registry.js';
18
+ import { getPreferredBudgetSyncId, setPreferredBudgetSyncId, pickAllowedPreferredBudget } from './budget-preference-store.js';
18
19
  import { requestContext } from './requestContext.js';
19
20
  import { connectionPool } from './ActualConnectionPool.js';
20
21
  import { isApiInitialized, setApiInitialized } from './apiState.js';
@@ -54,7 +55,8 @@ function getActiveBudgetConfig() {
54
55
  // call works at runtime. If we're not in any requestContext.run scope (stdio,
55
56
  // startup health checks), sessionId is undefined and we fall back to the
56
57
  // env-default budget (first registry entry).
57
- const sessionId = requestContext.getStore()?.sessionId;
58
+ const store = requestContext.getStore();
59
+ const sessionId = store?.sessionId;
58
60
  if (sessionId) {
59
61
  const key = sessionBudgetState.get(sessionId);
60
62
  if (key) {
@@ -62,6 +64,16 @@ function getActiveBudgetConfig() {
62
64
  if (found)
63
65
  return found;
64
66
  }
67
+ // #189 Phase 1: no in-session selection yet (e.g. a fresh session after a
68
+ // server restart + client re-initialize). Restore the principal's persisted
69
+ // budget, but ONLY if the live ACL still permits it (pickAllowedPreferredBudget
70
+ // re-checks allowedBudgets, so a stale preference can never widen access).
71
+ // Memoize into the session slot so subsequent calls take the fast path above.
72
+ const restored = pickAllowedPreferredBudget(getPreferredBudgetSyncId(store?.principal), store?.allowedBudgets, [...budgetRegistry.values()]);
73
+ if (restored) {
74
+ sessionBudgetState.set(sessionId, restored.name.toLowerCase());
75
+ return restored;
76
+ }
65
77
  }
66
78
  return [...budgetRegistry.values()][0];
67
79
  }
@@ -357,6 +369,14 @@ let _skipApiInitForTests = false;
357
369
  export function _setSkipApiInitForTests(value) {
358
370
  _skipApiInitForTests = value;
359
371
  }
372
+ /**
373
+ * Test-only readback of the active budget for the current requestContext, so a
374
+ * restart-replay test (#189) can assert the per-principal preference is restored
375
+ * on a fresh session. NOT part of the package public surface.
376
+ */
377
+ export function _getActiveBudgetConfigForTests() {
378
+ return getActiveBudgetConfig();
379
+ }
360
380
  // ----------------------------------------------------------------------------
361
381
  // Auth-rate-limit retry — issue #127
362
382
  // ----------------------------------------------------------------------------
@@ -1607,6 +1627,10 @@ export async function switchBudget(name) {
1607
1627
  throw new Error(`Budget ACL: budget "${found.name}" (${found.syncId}) is not in this session's allowedBudgets.`);
1608
1628
  }
1609
1629
  }
1630
+ // #189 Phase 1: the principal's chosen budget is persisted at each commit
1631
+ // point below (paired with the sessionBudgetState write), NOT here, so a
1632
+ // switch that throws before committing never leaves a stale preference. The
1633
+ // helper is keyed by a hash of the principal and never throws.
1610
1634
  // Fast path (#172): if the current pool entry's auth descriptor matches the
1611
1635
  // target budget's (same serverUrl + password + encryptionPassword), skip
1612
1636
  // release + re-auth. Just download the new budget file on the already-
@@ -1620,6 +1644,7 @@ export async function switchBudget(name) {
1620
1644
  if (sameAuth && currentEntry.syncId === found.syncId) {
1621
1645
  // No-op: already on this exact budget. Keep session map consistent and return.
1622
1646
  sessionBudgetState.set(sessionId, key);
1647
+ setPreferredBudgetSyncId(store?.principal, found.syncId); // #189: persist at commit
1623
1648
  logger.info(`[ADAPTER] switchBudget no-op for session ${sessionId}: already on "${found.name}" (${found.syncId})`);
1624
1649
  return { name: found.name, syncId: found.syncId, serverUrl: found.serverUrl };
1625
1650
  }
@@ -1644,6 +1669,7 @@ export async function switchBudget(name) {
1644
1669
  }
1645
1670
  connectionPool.updateLoadedSyncId(sessionId, found.syncId);
1646
1671
  sessionBudgetState.set(sessionId, key);
1672
+ setPreferredBudgetSyncId(store?.principal, found.syncId); // #189: persist at commit
1647
1673
  logger.info(`[ADAPTER] Active budget switched for session ${sessionId} to: "${found.name}" (${found.syncId}) on ${found.serverUrl}`);
1648
1674
  return { name: found.name, syncId: found.syncId, serverUrl: found.serverUrl };
1649
1675
  }
@@ -1659,6 +1685,7 @@ export async function switchBudget(name) {
1659
1685
  // Update the per-session active-budget slot. Subsequent getActiveBudgetConfig
1660
1686
  // calls for this session now return the new budget.
1661
1687
  sessionBudgetState.set(sessionId, key);
1688
+ setPreferredBudgetSyncId(store?.principal, found.syncId); // #189: persist at commit
1662
1689
  // Materialise a fresh pool entry bound to the new budget. Without this, the
1663
1690
  // next withActualApi call would find no pool entry and fall back to the
1664
1691
  // legacy init+shutdown path. Failure here is logged but not fatal: the
@@ -0,0 +1,97 @@
1
+ // Per-principal active-budget preference store (#189, Phase 1 of #173).
2
+ //
3
+ // After a server restart the client re-initializes and gets a NEW session, so
4
+ // the per-session active-budget selection (sessionBudgetState in actual-adapter)
5
+ // is lost and the user silently reverts to the env-default budget. This store
6
+ // remembers a principal's last active budget so the new session can restore it.
7
+ //
8
+ // Security model (see #189):
9
+ // * The key at rest is sha256(principal) hex, never the raw OIDC sub / email /
10
+ // token. The file maps hash -> budget syncId only.
11
+ // * It is NEVER trusted as authorization: the caller re-checks the live ACL
12
+ // (allowedBudgets) before applying a restored preference, so a stale
13
+ // preference can never widen access (pickAllowedPreferredBudget below).
14
+ // * It is keyed per principal, not per session-id, so it does not participate
15
+ // in the #167 session-liveness pool and cannot drift with it.
16
+ // * Missing / corrupt / unwritable file degrades to a no-op (the feature
17
+ // simply does nothing); it never throws into the request path.
18
+ import { createHash } from 'node:crypto';
19
+ import { readFileSync, writeFileSync, renameSync, mkdirSync, chmodSync } from 'node:fs';
20
+ import { dirname, join } from 'node:path';
21
+ import logger from '../logger.js';
22
+ import config from '../config.js';
23
+ const FILE_NAME = 'budget-preferences.json';
24
+ /** sha256 hex of the principal. Never store the raw principal. */
25
+ function hashPrincipal(principal) {
26
+ return createHash('sha256').update(principal, 'utf8').digest('hex');
27
+ }
28
+ function storePath() {
29
+ const dir = config.MCP_BRIDGE_DATA_DIR;
30
+ if (!dir)
31
+ return null;
32
+ return join(dir, FILE_NAME);
33
+ }
34
+ function readAll() {
35
+ const p = storePath();
36
+ if (!p)
37
+ return {};
38
+ try {
39
+ const parsed = JSON.parse(readFileSync(p, 'utf8'));
40
+ return parsed && typeof parsed === 'object' ? parsed : {};
41
+ }
42
+ catch {
43
+ // Missing or corrupt file: treat as empty. The feature no-ops rather than
44
+ // crashing the request that triggered the read.
45
+ return {};
46
+ }
47
+ }
48
+ /** The budget syncId this principal last selected, or undefined. */
49
+ export function getPreferredBudgetSyncId(principal) {
50
+ if (!principal)
51
+ return undefined;
52
+ return readAll()[hashPrincipal(principal)];
53
+ }
54
+ /** Persist the principal's active budget. Best-effort: never throws. */
55
+ export function setPreferredBudgetSyncId(principal, syncId) {
56
+ const p = storePath();
57
+ if (!p || !principal || !syncId)
58
+ return;
59
+ try {
60
+ const all = readAll();
61
+ if (all[hashPrincipal(principal)] === syncId)
62
+ return; // unchanged, skip write
63
+ all[hashPrincipal(principal)] = syncId;
64
+ mkdirSync(dirname(p), { recursive: true });
65
+ // Atomic write: temp + rename so a crash mid-write cannot corrupt the file.
66
+ const tmp = `${p}.tmp`;
67
+ writeFileSync(tmp, JSON.stringify(all), { mode: 0o600 });
68
+ renameSync(tmp, p);
69
+ try {
70
+ chmodSync(p, 0o600);
71
+ }
72
+ catch { /* best-effort perms */ }
73
+ }
74
+ catch (e) {
75
+ logger.debug(`[BUDGET-PREF] failed to persist preference (non-fatal): ${e instanceof Error ? e.message : e}`);
76
+ }
77
+ }
78
+ /**
79
+ * Pure restore decision: given a principal's stored syncId, the live ACL, and
80
+ * the budget registry, return the budget to restore, or undefined.
81
+ *
82
+ * The ACL re-check is the security boundary: a stored preference is applied
83
+ * ONLY if `allowedBudgets` is unrestricted (`['*']`) or contains the budget's
84
+ * syncId. This is why a stale preference can never widen access. Extracted as a
85
+ * pure function so it is unit-testable without the adapter / requestContext.
86
+ */
87
+ export function pickAllowedPreferredBudget(storedSyncId, allowedBudgets, registryValues) {
88
+ if (!storedSyncId)
89
+ return undefined;
90
+ const found = registryValues.find(b => b.syncId === storedSyncId);
91
+ if (!found)
92
+ return undefined;
93
+ if (allowedBudgets && !allowedBudgets.includes('*') && !allowedBudgets.includes(found.syncId)) {
94
+ return undefined; // ACL no longer permits this budget
95
+ }
96
+ return found;
97
+ }
@@ -21,6 +21,16 @@ import * as fs from 'node:fs';
21
21
  // `requestContext` from this module.
22
22
  import { requestContext } from '../lib/requestContext.js';
23
23
  export { requestContext };
24
+ // Resolve the authenticated principal for the per-principal budget preference
25
+ // (#189). OIDC: the verified JWT subject. Static-bearer: a single shared
26
+ // identity (all bearer callers are the same user). Auth-disabled: undefined, so
27
+ // the preference simply no-ops. Never derived from a client-supplied value.
28
+ function resolvePrincipal(req) {
29
+ if (config.AUTH_PROVIDER === 'oidc') {
30
+ return req.auth?.subject;
31
+ }
32
+ return config.MCP_SSE_AUTHORIZATION ? 'static-bearer' : undefined;
33
+ }
24
34
  export async function startHttpServer(mcp, port, httpPath, capabilities, // was passed by index.ts
25
35
  implementedTools, // was passed by index.ts
26
36
  serverDescription, // was passed by index.ts
@@ -379,7 +389,7 @@ bindHost = 'localhost', advertisedUrl) {
379
389
  // Run in AsyncLocalStorage context so tools can access sessionId
380
390
  // and the adapter can enforce per-request budget ACL (#156).
381
391
  const allowedBudgetsInit = req.allowedBudgets;
382
- await requestContext.run({ sessionId: undefined, allowedBudgets: allowedBudgetsInit }, async () => {
392
+ await requestContext.run({ sessionId: undefined, allowedBudgets: allowedBudgetsInit, principal: resolvePrincipal(req) }, async () => {
383
393
  await transport.handleRequest(req, res, req.body);
384
394
  });
385
395
  }
@@ -473,7 +483,7 @@ bindHost = 'localhost', advertisedUrl) {
473
483
  // Run in AsyncLocalStorage context so tools and the adapter can access
474
484
  // sessionId (pool branch, #134) and allowedBudgets (ACL enforcement, #156).
475
485
  const allowedBudgets = req.allowedBudgets;
476
- await requestContext.run({ sessionId, allowedBudgets }, async () => {
486
+ await requestContext.run({ sessionId, allowedBudgets, principal: resolvePrincipal(req) }, async () => {
477
487
  await transport.handleRequest(req, res, req.body);
478
488
  });
479
489
  }
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.6.31",
4
+ "version": "0.6.33",
5
5
  "engines": {
6
6
  "node": ">=20.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_insecure_upstream.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_body_limit.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.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_insecure_upstream.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_body_limit.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",
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",