actual-mcp-server 0.22.49 → 0.22.50

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
@@ -516,6 +516,8 @@ All configuration is via environment variables. Copy `.env.example` to `.env` to
516
516
  | **Security & Authentication** ||||
517
517
  | `AUTH_PROVIDER` | `none` | No | Auth mode: `none` (static Bearer) or `oidc` (JWKS-validated JWT) |
518
518
  | `MCP_SSE_AUTHORIZATION` | _(none)_ | No | Static Bearer token (`AUTH_PROVIDER=none`; highly recommended in production) |
519
+ | `MCP_STATIC_TOKENS` | _(none)_ | No | Several static Bearer tokens, one per client, each with its own principal and budget allow-list (#572). JSON keyed by principal: `{"alice":{"tokenEnv":"MCP_TOKEN_ALICE","budgets":["Home"]}}`. Budgets are registry names. Can coexist with `MCP_SSE_AUTHORIZATION` (which stays unrestricted); refused with `AUTH_PROVIDER=oidc`. See [Security](docs/SECURITY_AND_PRIVACY.md) |
520
+ | `MCP_TOKEN_*` | _(none)_ | If named | The token values `MCP_STATIC_TOKENS` points at, one variable per principal (`MCP_TOKEN_ALICE`). At least 32 characters (`openssl rand -hex 32`), unique, RFC 6750 characters only; a bad one refuses to start |
519
521
  | `MCP_ALLOW_UNAUTHENTICATED` | `false` | No | Opt-out for required-by-default HTTP auth (#242). On a non-loopback bind with no token and no OIDC the server refuses to start; set to `true` to run open deliberately (e.g. behind your own proxy) |
520
522
  | `OIDC_ISSUER` | _(none)_ | If OIDC | OIDC issuer URL (e.g., `https://sso.example.com`) |
521
523
  | `OIDC_ALLOW_INSECURE_ISSUER` | `false` | No | Allow a plaintext (http) OIDC issuer on a trusted network (#244). Off by default (http issuer refused at startup); set `true` only for local/LAN testing |
@@ -836,7 +838,7 @@ The software is provided **as-is**, without warranty of any kind. The author acc
836
838
 
837
839
  ---
838
840
 
839
- **Version:** 0.22.49 | **Tool Count:** 84 (verified LibreChat-compatible)
841
+ **Version:** 0.22.50 | **Tool Count:** 84 (verified LibreChat-compatible)
840
842
 
841
843
  ## Sponsor
842
844
 
@@ -0,0 +1,167 @@
1
+ // src/auth/static-tokens.ts
2
+ //
3
+ // #572: several static Bearer tokens, each bound to its own principal and budget
4
+ // allow-list (MCP_STATIC_TOKENS). Proposed and first implemented by @jospaquim in
5
+ // PR #569; reimplemented here on develop.
6
+ //
7
+ // Why: with the single MCP_SSE_AUTHORIZATION token every caller is the one
8
+ // principal 'static-bearer', so several MCP clients on one server share the #189
9
+ // per-principal budget preference and have no budget isolation without OIDC.
10
+ // Here each client presents its own token, which resolves to a principal (keys
11
+ // the #189 preference and session ownership) and to the request's allowedBudgets,
12
+ // enforced by the SAME chokepoints OIDC uses (_enforceBudgetAcl, switchBudget,
13
+ // the #570 listing and import guards). One enforcement path, two ways to fill it.
14
+ //
15
+ // Format (JSON object keyed by principal; budgets are registry NAMES):
16
+ // MCP_STATIC_TOKENS={"alice":{"tokenEnv":"MCP_TOKEN_ALICE","budgets":["Home"]}}
17
+ // The token itself lives in the variable `tokenEnv` names, so the JSON can sit in
18
+ // a compose file while the secrets stay in the secret store.
19
+ //
20
+ // WHY THIS MODULE IMPORTS ONLY node:crypto. config.ts calls parseStaticTokensConfig
21
+ // at config-parse time (the budget-acl-parse.ts pattern, #551): importing the
22
+ // logger, config or the adapter from here would create an ESM cycle. It throws and
23
+ // never logs.
24
+ //
25
+ // ERROR-TEXT CONTRACT. A thrown message names principals, variable names and
26
+ // budget names only, never a token value.
27
+ import { createHash, timingSafeEqual } from 'node:crypto';
28
+ /** Principal reserved for the legacy MCP_SSE_AUTHORIZATION token. */
29
+ export const LEGACY_STATIC_PRINCIPAL = 'static-bearer';
30
+ /** Minimum token length: `openssl rand -hex 16` produces exactly this. */
31
+ export const MIN_TOKEN_LENGTH = 32;
32
+ const PRINCIPAL_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
33
+ // Restricted to one family so a principal cannot point at ACTUAL_PASSWORD or any
34
+ // other secret, and so logger.ts can scrub every value of the family.
35
+ const TOKEN_ENV_RE = /^MCP_TOKEN_[A-Z0-9_]+$/;
36
+ // RFC 6750 b64token: '=' only as trailing padding, so whitespace and other
37
+ // characters a header cannot carry intact are refused at startup.
38
+ const B64TOKEN_RE = /^[A-Za-z0-9._~+/-]+=*$/;
39
+ /** True when the raw value is set (unset and blank both mean "feature off"). */
40
+ export function staticTokensConfigured(raw) {
41
+ return raw !== undefined && raw.trim() !== '';
42
+ }
43
+ /**
44
+ * Shape check of MCP_STATIC_TOKENS, run by config.ts at startup on both
45
+ * transports. Returns {} when unset or blank. Touches no environment and no
46
+ * registry: that is resolveStaticTokens, HTTP only.
47
+ */
48
+ export function parseStaticTokensConfig(raw) {
49
+ if (!staticTokensConfigured(raw))
50
+ return {};
51
+ let parsed;
52
+ try {
53
+ parsed = JSON.parse(raw);
54
+ }
55
+ catch {
56
+ throw new Error('MCP_STATIC_TOKENS is not valid JSON');
57
+ }
58
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
59
+ throw new Error('MCP_STATIC_TOKENS must be a JSON object keyed by principal');
60
+ }
61
+ const entries = Object.entries(parsed);
62
+ if (entries.length === 0) {
63
+ throw new Error('MCP_STATIC_TOKENS is set but defines no principals');
64
+ }
65
+ const out = {};
66
+ for (const [principal, value] of entries) {
67
+ if (!PRINCIPAL_RE.test(principal)) {
68
+ throw new Error(`MCP_STATIC_TOKENS: invalid principal name "${principal}" (letters, digits, . _ -; at most 64)`);
69
+ }
70
+ if (principal === LEGACY_STATIC_PRINCIPAL) {
71
+ throw new Error(`MCP_STATIC_TOKENS: principal "${LEGACY_STATIC_PRINCIPAL}" is reserved for MCP_SSE_AUTHORIZATION`);
72
+ }
73
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
74
+ throw new Error(`MCP_STATIC_TOKENS: entry for "${principal}" must be an object {"tokenEnv", "budgets"}`);
75
+ }
76
+ const { tokenEnv, budgets } = value;
77
+ if (typeof tokenEnv !== 'string' || !TOKEN_ENV_RE.test(tokenEnv)) {
78
+ throw new Error(`MCP_STATIC_TOKENS: "${principal}".tokenEnv must be a variable name starting with MCP_TOKEN_ (A to Z, 0 to 9, _)`);
79
+ }
80
+ if (!Array.isArray(budgets) || budgets.length === 0 || !budgets.every((b) => typeof b === 'string' && b.trim() !== '')) {
81
+ throw new Error(`MCP_STATIC_TOKENS: "${principal}".budgets must be a non-empty array of budget names`);
82
+ }
83
+ out[principal] = { tokenEnv, budgets: [...budgets] };
84
+ }
85
+ return out;
86
+ }
87
+ function digestOf(token) {
88
+ return createHash('sha256').update(token, 'utf8').digest();
89
+ }
90
+ /**
91
+ * Resolve MCP_STATIC_TOKENS against the environment and the budget registry, and
92
+ * build the matcher's entries: one per static principal, plus the legacy token
93
+ * when set. Called by startHttpServer before it binds, so a refusal stops startup
94
+ * the way an unreadable TLS key does. Returns [] when neither token source is set.
95
+ */
96
+ export function resolveStaticTokens(raw, env, registry, legacyToken) {
97
+ const parsed = parseStaticTokensConfig(raw);
98
+ const byName = new Map();
99
+ for (const b of registry)
100
+ byName.set(b.name.toLowerCase(), b);
101
+ const entries = [];
102
+ // Uniqueness is checked on digests: equal digests are equal tokens, and no
103
+ // token value has to be held to compare them.
104
+ const seen = new Map();
105
+ if (legacyToken)
106
+ seen.set(digestOf(legacyToken).toString('hex'), 'MCP_SSE_AUTHORIZATION');
107
+ for (const [principal, { tokenEnv, budgets }] of Object.entries(parsed)) {
108
+ const token = env[tokenEnv];
109
+ if (token === undefined || token === '') {
110
+ throw new Error(`MCP_STATIC_TOKENS: "${principal}".tokenEnv names ${tokenEnv}, which is unset or empty`);
111
+ }
112
+ if (token.length < MIN_TOKEN_LENGTH) {
113
+ throw new Error(`MCP_STATIC_TOKENS: the token in ${tokenEnv} is shorter than ${MIN_TOKEN_LENGTH} characters`);
114
+ }
115
+ if (!B64TOKEN_RE.test(token)) {
116
+ throw new Error(`MCP_STATIC_TOKENS: the token in ${tokenEnv} contains characters outside the RFC 6750 token set ` +
117
+ '(letters, digits, . _ ~ + / -, and = only as trailing padding)');
118
+ }
119
+ const allowedBudgets = [];
120
+ for (const name of budgets) {
121
+ const found = byName.get(name.toLowerCase());
122
+ // A "*" sync id would read as "unrestricted" to every ACL check, so it is
123
+ // refused like an unknown name: a static principal is always restricted, and
124
+ // the unrestricted case is the legacy token.
125
+ if (!found || found.syncId === '*') {
126
+ const available = [...byName.values()].map((b) => `"${b.name}"`).join(', ');
127
+ throw new Error(`MCP_STATIC_TOKENS: "${principal}" references unknown budget "${name}". Available: ${available}`);
128
+ }
129
+ if (!allowedBudgets.includes(found.syncId))
130
+ allowedBudgets.push(found.syncId);
131
+ }
132
+ const digest = digestOf(token);
133
+ const other = seen.get(digest.toString('hex'));
134
+ if (other) {
135
+ throw new Error(`MCP_STATIC_TOKENS: the token in ${tokenEnv} is not unique (also used by ${other})`);
136
+ }
137
+ seen.set(digest.toString('hex'), tokenEnv);
138
+ entries.push({ principal, allowedBudgets, digest });
139
+ }
140
+ if (legacyToken)
141
+ entries.push({ principal: LEGACY_STATIC_PRINCIPAL, digest: digestOf(legacyToken) });
142
+ return entries;
143
+ }
144
+ function matchWith(presented, entries, compare) {
145
+ const digest = digestOf(presented);
146
+ let match;
147
+ // Every entry is compared, with no early exit, so timing reveals neither which
148
+ // principal matched nor how many exist. Digests are all 32 bytes, so there is
149
+ // no length short-circuit either (the pre-#572 matcher had one).
150
+ for (const e of entries) {
151
+ if (compare(digest, e.digest) && !match)
152
+ match = e;
153
+ }
154
+ return match;
155
+ }
156
+ /** Constant-time lookup of a presented Bearer token. Used for static AND legacy tokens. */
157
+ export function matchBearerToken(presented, entries) {
158
+ return matchWith(presented, entries, timingSafeEqual);
159
+ }
160
+ /**
161
+ * Test seam: the matcher with an injected compare, so a test can count compares.
162
+ * Separate from matchBearerToken so no production caller can pass a compare that
163
+ * is not constant time.
164
+ */
165
+ export function _matchBearerTokenWithCompareForTests(presented, entries, compare) {
166
+ return matchWith(presented, entries, compare);
167
+ }
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { parseIdentityMap } from './auth/identity-map.js';
3
3
  import { parseBudgetAcl } from './auth/budget-acl-parse.js';
4
+ import { parseStaticTokensConfig, staticTokensConfigured } from './auth/static-tokens.js';
4
5
  // #466: the byte count body-parser will ACTUALLY enforce for MCP_HTTP_BODY_LIMIT, or
5
6
  // null when the value must be refused. Mirrors `bytes.parse` (what body-parser uses)
6
7
  // without importing that transitive package: floor(number * unit), units b and kb
@@ -101,6 +102,24 @@ export const configSchema = z.object({
101
102
  MCP_BRIDGE_PORT: z.string().default('3600'),
102
103
  MCP_TRANSPORT_MODE: z.enum(['--http']).default('--http'),
103
104
  MCP_SSE_AUTHORIZATION: z.string().optional(),
105
+ // #572: several static Bearer tokens, each with its own principal and budget
106
+ // allow-list, as a JSON object keyed by principal:
107
+ // {"alice":{"tokenEnv":"MCP_TOKEN_ALICE","budgets":["Home"]}}
108
+ // Only the SHAPE is checked here (both transports). The tokens themselves
109
+ // (MCP_TOKEN_*) and the budget names are resolved by the HTTP server before it
110
+ // binds (src/auth/static-tokens.ts). Unset or blank means the feature is off.
111
+ MCP_STATIC_TOKENS: z
112
+ .string()
113
+ .optional()
114
+ .superRefine((v, ctx) => {
115
+ try {
116
+ parseStaticTokensConfig(v);
117
+ }
118
+ catch (err) {
119
+ // Safe by contract: names principals and variables, never a token value.
120
+ ctx.addIssue({ code: 'custom', message: err instanceof Error ? err.message : String(err) });
121
+ }
122
+ }),
104
123
  // #242: explicit opt-out for the required-by-default HTTP auth gate. When auth
105
124
  // is unconfigured AND the bind is non-loopback, the server refuses to start
106
125
  // unless this is the exact string 'true'. Strict parse (only 'true' enables)
@@ -318,6 +337,14 @@ export const configSchema = z.object({
318
337
  'unrelated userId, so matching sub denies every user. Use AUTH_BUDGET_ACL_CLAIM=auto (the default, ' +
319
338
  'which mirrors that precedence), or pin a claim that your IdP also used to populate Actual user_name. ' +
320
339
  'See #343.',
340
+ })
341
+ // #572: static tokens are their own authentication mode. Under OIDC the verified
342
+ // JWT decides the principal and AUTH_BUDGET_ACL the budgets, so a static token
343
+ // would either be ignored or open a second door; refuse the pairing.
344
+ .refine((cfg) => !(cfg.AUTH_PROVIDER === 'oidc' && staticTokensConfigured(cfg.MCP_STATIC_TOKENS)), {
345
+ path: ['MCP_STATIC_TOKENS'],
346
+ message: 'MCP_STATIC_TOKENS cannot be combined with AUTH_PROVIDER=oidc: under OIDC the verified token decides ' +
347
+ 'the principal and AUTH_BUDGET_ACL decides its budgets. Unset one of them (#572).',
321
348
  })
322
349
  // When native TLS is enabled, both the cert and key paths must be provided.
323
350
  // MCP_ENABLE_HTTPS is transformed to a boolean above, so this object-level
package/dist/src/index.js CHANGED
@@ -303,7 +303,9 @@ Docs & source: https://github.com/agigante80/actual-mcp-server`);
303
303
  if (useHttp || useTestMcpClient) {
304
304
  const decision = enforceHttpAuthPosture({
305
305
  bindHost,
306
- hasStaticToken: !!process.env.MCP_SSE_AUTHORIZATION,
306
+ // #572: MCP_STATIC_TOKENS is authentication too. A set-but-unresolvable value
307
+ // still counts here: startHttpServer refuses it before binding.
308
+ hasStaticToken: !!process.env.MCP_SSE_AUTHORIZATION || !!process.env.MCP_STATIC_TOKENS?.trim(),
307
309
  oidcEnabled: process.env.AUTH_PROVIDER === 'oidc',
308
310
  allowUnauthenticated: process.env.MCP_ALLOW_UNAUTHENTICATED === 'true',
309
311
  });
@@ -379,7 +381,10 @@ Docs & source: https://github.com/agigante80/actual-mcp-server`);
379
381
  }
380
382
  }
381
383
  main().catch((err) => {
382
- logger.error('Failed to start Actual MCP bridge:', err.message || String(err));
384
+ // #572: the message goes INTO the format string. Passed as a second argument, winston
385
+ // treats a string as metadata and printed it one character per line ("0": "M", ...),
386
+ // so an operator never saw why startup failed.
387
+ logger.error(`Failed to start Actual MCP bridge: ${err?.message || String(err)}`);
383
388
  process.exit(1);
384
389
  });
385
390
  })();
@@ -108,7 +108,23 @@ function getActiveBudgetConfig() {
108
108
  return restored;
109
109
  }
110
110
  }
111
- return [...budgetRegistry.values()][0];
111
+ // #572: a restricted caller with no selection and no restorable preference starts
112
+ // on its FIRST PERMITTED budget in registry order (the order the operator controls
113
+ // and every listing shows; the allow-list is a set), instead of the default it may
114
+ // not use. Keyed on the list, not the auth mode, so OIDC restricted principals get
115
+ // it too: before #572 one whose list excluded the default got "Budget ACL:" on every
116
+ // call until it switched. With no restricting list, or nothing in the registry
117
+ // permitted, this is the default as before, and _enforceBudgetAcl then refuses.
118
+ // Not memoised: a later switch writes the session slot, and the next call
119
+ // recomputes the same answer from the same list.
120
+ const values = [...budgetRegistry.values()];
121
+ const restricting = restrictingAllowList(store?.allowedBudgets);
122
+ if (restricting) {
123
+ const permitted = values.find((b) => restricting.includes(b.syncId));
124
+ if (permitted)
125
+ return permitted;
126
+ }
127
+ return values[0];
112
128
  }
113
129
  /**
114
130
  * #571: the budget a NEW session should open on, resolved before its session id is live.
@@ -447,10 +463,12 @@ function abortBatchOnInfrastructureError(error, ctx) {
447
463
  function _enforceBudgetAcl(toolName) {
448
464
  const sessionId = requestContext.getStore()?.sessionId;
449
465
  // Trusted-local short-circuit. stdio and startup health checks run with no
450
- // sessionId; in non-OIDC modes those are by-construction trusted (single
451
- // user, local process). The ACL only applies when an authenticated multi-
452
- // user context is in play (AUTH_PROVIDER === 'oidc').
453
- if (!sessionId && config.AUTH_PROVIDER !== 'oidc') {
466
+ // sessionId AND no allow-list; in non-OIDC modes those are by-construction
467
+ // trusted (single user, local process). #572: a context that CARRIES a list is
468
+ // always checked, session or not (the HTTP initialize context has a static
469
+ // principal's list and no session id yet), so the skip is narrowed to contexts
470
+ // with no list.
471
+ if (!sessionId && config.AUTH_PROVIDER !== 'oidc' && !requestContext.getStore()?.allowedBudgets) {
454
472
  return;
455
473
  }
456
474
  const allowedBudgets = restrictedAllowedBudgets(toolName);
@@ -502,6 +520,17 @@ function restrictedAllowedBudgets(toolName) {
502
520
  throw new Error('Budget ACL: no allowedBudgets in request context. ' +
503
521
  'This request bypassed the budget-acl middleware. Refusing for safety. See #156.');
504
522
  }
523
+ return restrictingAllowList(allowedBudgets);
524
+ }
525
+ /**
526
+ * #572: THE allow-list predicate: the list when it restricts, else `undefined`.
527
+ * A missing list and a list containing `"*"` do not restrict. An EMPTY list does:
528
+ * it is the deny state (budget-acl.ts gives [] to a principal with no access), so it
529
+ * restricts to nothing. Used by restrictedAllowedBudgets, switchBudget, the
530
+ * _enforceBudgetAcl skip and the getActiveBudgetConfig fallback, so they cannot
531
+ * disagree about who is restricted.
532
+ */
533
+ function restrictingAllowList(allowedBudgets) {
505
534
  if (!allowedBudgets || allowedBudgets.includes('*'))
506
535
  return undefined;
507
536
  return allowedBudgets;
@@ -4008,8 +4037,11 @@ export async function switchBudget(name) {
4008
4037
  if (!found)
4009
4038
  throw notFound();
4010
4039
  // ACL enforcement: the target budget must be in this session's allowedBudgets.
4011
- // OIDC mode: explicit ACL required. Non-OIDC: short-circuit allow (single-user).
4012
- if (config.AUTH_PROVIDER === 'oidc') {
4040
+ // OIDC mode: explicit ACL required. #572: any mode whose request carries a
4041
+ // restricting list (MCP_STATIC_TOKENS) is checked against it too; with no list
4042
+ // outside OIDC the caller is unrestricted (legacy bearer, no-auth).
4043
+ const restricting = restrictingAllowList(allowedBudgets);
4044
+ if (config.AUTH_PROVIDER === 'oidc' || restricting) {
4013
4045
  if (!allowedBudgets) {
4014
4046
  logger.warn(JSON.stringify({
4015
4047
  event: 'acl_denied',
@@ -4020,7 +4052,7 @@ export async function switchBudget(name) {
4020
4052
  }));
4021
4053
  throw new Error(`Budget ACL: cannot switch to "${found.name}". No allowedBudgets in request context.`);
4022
4054
  }
4023
- if (!allowedBudgets.includes('*') && !allowedBudgets.includes(found.syncId)) {
4055
+ if (restricting && !restricting.includes(found.syncId)) {
4024
4056
  logger.warn(JSON.stringify({
4025
4057
  event: 'acl_denied',
4026
4058
  attemptedBudget: found.syncId,
@@ -76,7 +76,8 @@ export function enforceHttpAuthPosture(input, log = logger, exit = process.exit)
76
76
  const { decision, reason } = evaluateHttpAuthPosture(input);
77
77
  if (decision === 'refuse') {
78
78
  log.error(`Refusing to start: ${reason}. HTTP auth is required-by-default. ` +
79
- 'Set MCP_SSE_AUTHORIZATION to a strong token (openssl rand -hex 32), or ' +
79
+ 'Set MCP_SSE_AUTHORIZATION to a strong token (openssl rand -hex 32), or set ' +
80
+ 'MCP_STATIC_TOKENS (one token per client, #572), or ' +
80
81
  'set AUTH_PROVIDER=oidc, or bind a loopback host. To deliberately run open ' +
81
82
  '(for example behind your own authenticating proxy), set MCP_ALLOW_UNAUTHENTICATED=true.');
82
83
  exit(1);
@@ -85,7 +86,7 @@ export function enforceHttpAuthPosture(input, log = logger, exit = process.exit)
85
86
  log.warn(`Serving HTTP WITHOUT authentication: ${reason}. Anyone who can reach this ` +
86
87
  'port can read and modify your financial data. This is allowed only because ' +
87
88
  'MCP_ALLOW_UNAUTHENTICATED=true. Put an authenticating reverse proxy in front, ' +
88
- 'or set MCP_SSE_AUTHORIZATION / AUTH_PROVIDER=oidc.');
89
+ 'or set MCP_SSE_AUTHORIZATION / MCP_STATIC_TOKENS / AUTH_PROVIDER=oidc.');
89
90
  }
90
91
  return decision;
91
92
  }
@@ -67,7 +67,8 @@ export const SCHEMA_VARS_ALSO_READ_RAW = [
67
67
  * one. `.env.example` documents example members (BUDGET_1_*, BUDGET_2_*, ...); the
68
68
  * drift guard collapses any name starting with one of these to the family.
69
69
  */
70
- export const DYNAMIC_ENV_FAMILIES = ['BUDGET_'];
70
+ // MCP_TOKEN_ (#572): the per-principal static token values that MCP_STATIC_TOKENS names.
71
+ export const DYNAMIC_ENV_FAMILIES = ['BUDGET_', 'MCP_TOKEN_'];
71
72
  /**
72
73
  * Variables documented for the operator (e.g. in Docker) but read by the OS or
73
74
  * runtime, not by the app. The drift guard allows these in docs without requiring a
@@ -168,7 +168,9 @@ export function collectSecretValues(env = process.env) {
168
168
  // length guard avoids masking short/common values that could collide with benign log
169
169
  // text (a short secret is still masked by KEY name when under a sensitive field; only
170
170
  // the free-text VALUE scrub is skipped for it).
171
- if (typeof v === 'string' && v.length >= 8 && /(PASSWORD|SECRET|AUTHORIZATION|_TOKEN)$/.test(k)) {
171
+ // MCP_TOKEN_* (#572) are the static Bearer tokens MCP_STATIC_TOKENS names; MCP_TOKEN_ALICE
172
+ // ends in neither suffix, so the family is matched by prefix.
173
+ if (typeof v === 'string' && v.length >= 8 && (/(PASSWORD|SECRET|AUTHORIZATION|_TOKEN)$/.test(k) || k.startsWith('MCP_TOKEN_'))) {
172
174
  out.push(v);
173
175
  }
174
176
  }
@@ -1,5 +1,5 @@
1
1
  import express from 'express';
2
- import { randomUUID, timingSafeEqual } from 'node:crypto';
2
+ import { randomUUID } from 'node:crypto';
3
3
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
4
4
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
5
5
  import { ListToolsRequestSchema, CallToolRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
@@ -9,7 +9,7 @@ import { getLocalIp } from '../utils.js';
9
9
  import actualToolsManager from '../actualToolsManager.js';
10
10
  import { getConnectionState, connectToActualForSession, shutdownActualForSession, shutdownActual, canAcceptNewSession, isConnectionPoolEnabled } from '../actualConnection.js';
11
11
  import { connectionPool } from '../lib/ActualConnectionPool.js';
12
- import { clearSessionBudgetState, setSessionLivenessCheck, initialBudgetForSession } from '../lib/actual-adapter.js';
12
+ import { clearSessionBudgetState, setSessionLivenessCheck, initialBudgetForSession, getBudgetRegistry } from '../lib/actual-adapter.js';
13
13
  import { errorLogFields } from '../lib/errors.js';
14
14
  import observability from '../observability.js';
15
15
  import config from '../config.js';
@@ -23,6 +23,7 @@ import { stripJoseCause } from '../lib/oidc-error-cause.js';
23
23
  import { buildAcceptedAudiences } from '../lib/oidc-audiences.js';
24
24
  import { parseScopeList } from '../lib/oidc-scopes.js';
25
25
  import { budgetAclMiddleware } from '../auth/budget-acl.js';
26
+ import { resolveStaticTokens, matchBearerToken, LEGACY_STATIC_PRINCIPAL } from '../auth/static-tokens.js';
26
27
  import { runAclPreflight } from '../auth/budget-acl-dynamic.js';
27
28
  import * as https from 'node:https';
28
29
  import { finished } from 'node:stream';
@@ -42,14 +43,19 @@ import { createHttpErrorHandler } from './httpErrorHandler.js';
42
43
  export { classifyInitFailure };
43
44
  export { requestContext };
44
45
  // Resolve the authenticated principal for the per-principal budget preference
45
- // (#189). OIDC: the verified JWT subject. Static-bearer: a single shared
46
- // identity (all bearer callers are the same user). Auth-disabled: undefined, so
47
- // the preference simply no-ops. Never derived from a client-supplied value.
46
+ // (#189). OIDC: the verified JWT subject. A MCP_STATIC_TOKENS token (#572): its
47
+ // own principal, set by authenticateRequest on a match. Legacy static bearer: a
48
+ // single shared identity (all its callers are the same user). Auth-disabled:
49
+ // undefined, so the preference simply no-ops. Never derived from a
50
+ // client-supplied value.
48
51
  function resolvePrincipal(req) {
49
52
  if (config.AUTH_PROVIDER === 'oidc') {
50
53
  return req.auth?.subject;
51
54
  }
52
- return config.MCP_SSE_AUTHORIZATION ? 'static-bearer' : undefined;
55
+ const staticPrincipal = req.staticPrincipal;
56
+ if (staticPrincipal)
57
+ return staticPrincipal;
58
+ return config.MCP_SSE_AUTHORIZATION ? LEGACY_STATIC_PRINCIPAL : undefined;
53
59
  }
54
60
  export async function startHttpServer(mcp, port, httpPath, capabilities, // was passed by index.ts
55
61
  implementedTools, // was passed by index.ts
@@ -59,6 +65,14 @@ toolSchemas, // was passed by index.ts
59
65
  version, // server version from package.json
60
66
  bindHost = 'localhost', // #239: interface to bind; forwarded to listen()
61
67
  advertisedUrl) {
68
+ // #572: resolve MCP_STATIC_TOKENS (token variables, budget names) before anything
69
+ // binds, so a bad entry refuses startup like an unreadable TLS key. The registry is
70
+ // read outside any request context, so it is the full, unfiltered list. config.ts
71
+ // already refused the variable under OIDC, where these entries are never consulted.
72
+ const bearerEntries = config.AUTH_PROVIDER === 'oidc'
73
+ ? []
74
+ : resolveStaticTokens(config.MCP_STATIC_TOKENS, process.env, getBudgetRegistry(), config.MCP_SSE_AUTHORIZATION);
75
+ const staticPrincipals = bearerEntries.filter((e) => e.allowedBudgets);
62
76
  const app = express();
63
77
  // Explicit body-size cap (#168). An oversized payload is rejected with HTTP 413
64
78
  // rather than buffered unbounded. Tunable via MCP_HTTP_BODY_LIMIT (default 512kb).
@@ -336,9 +350,11 @@ advertisedUrl) {
336
350
  res.status(401).json({ error: 'Unauthorized: OIDC authentication required' });
337
351
  return false;
338
352
  }
339
- // Legacy static Bearer token mode (default).
340
- // If MCP_SSE_AUTHORIZATION is not configured, allow all requests
341
- if (!config.MCP_SSE_AUTHORIZATION) {
353
+ // Static Bearer token mode (default): MCP_SSE_AUTHORIZATION and/or MCP_STATIC_TOKENS
354
+ // (#572). Only when NEITHER is configured is every request allowed. Static tokens are
355
+ // authentication, so MCP_ALLOW_UNAUTHENTICATED (a posture opt-out for the no-auth
356
+ // case) does not relax this.
357
+ if (bearerEntries.length === 0) {
342
358
  return true;
343
359
  }
344
360
  const authHeader = req.headers.authorization;
@@ -354,20 +370,22 @@ advertisedUrl) {
354
370
  res.status(401).json({ error: 'Unauthorized: Invalid Authorization header format. Expected "Bearer <token>"' });
355
371
  return false;
356
372
  }
357
- const token = match[1];
358
- const expected = config.MCP_SSE_AUTHORIZATION;
359
- // Constant-time comparison to defeat timing oracles (#157).
360
- // Length-equality short-circuit precedes timingSafeEqual because the
361
- // function requires equal-length buffers. Token length is observable
362
- // via response timing regardless; the secret bytes are not.
363
- const tokenBuf = Buffer.from(token, 'utf8');
364
- const expectedBuf = Buffer.from(expected, 'utf8');
365
- const equal = tokenBuf.length === expectedBuf.length && timingSafeEqual(tokenBuf, expectedBuf);
366
- if (!equal) {
373
+ // Constant-time comparison to defeat timing oracles (#157): matchBearerToken
374
+ // compares SHA-256 digests with timingSafeEqual against every configured token,
375
+ // static and legacy, with no early exit (#572).
376
+ const hit = matchBearerToken(match[1], bearerEntries);
377
+ if (!hit) {
367
378
  logger.warn(`[HTTP] Unauthorized request from ${req.ip || req.connection.remoteAddress}: Invalid token`);
368
379
  res.status(401).json({ error: 'Unauthorized: Invalid token' });
369
380
  return false;
370
381
  }
382
+ // A static principal carries its resolved allow-list into the requestContext.run
383
+ // calls below, where the adapter's ACL chokepoints read it. The legacy token sets
384
+ // neither field: principal 'static-bearer', unrestricted, exactly as before #572.
385
+ if (hit.allowedBudgets) {
386
+ req.staticPrincipal = hit.principal;
387
+ req.allowedBudgets = [...hit.allowedBudgets];
388
+ }
371
389
  return true;
372
390
  };
373
391
  // Create a fresh Server instance per session
@@ -994,11 +1012,15 @@ advertisedUrl) {
994
1012
  void runAclPreflight();
995
1013
  }
996
1014
  }
997
- else if (config.MCP_SSE_AUTHORIZATION) {
998
- logger.info(`🔒 HTTP authentication enabled (static Bearer token required)`);
1015
+ else if (bearerEntries.length > 0) {
1016
+ // #572: principal names and budget COUNTS only; budget names are personal data.
1017
+ const principals = staticPrincipals.map((e) => `${e.principal} (${e.allowedBudgets.length} budget(s))`).join(', ');
1018
+ logger.info(`🔒 HTTP authentication enabled (Bearer token required)` +
1019
+ (principals ? `; static principals: ${principals}` : '') +
1020
+ (config.MCP_SSE_AUTHORIZATION ? `; MCP_SSE_AUTHORIZATION also accepted (unrestricted)` : ''));
999
1021
  }
1000
1022
  else {
1001
- logger.warn(`⚠️ HTTP authentication disabled (no MCP_SSE_AUTHORIZATION or OIDC configured)`);
1023
+ logger.warn(`⚠️ HTTP authentication disabled (no MCP_SSE_AUTHORIZATION, MCP_STATIC_TOKENS or OIDC configured)`);
1002
1024
  }
1003
1025
  });
1004
1026
  // Configure keep-alive to maintain persistent connections
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.49",
4
+ "version": "0.22.50",
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_acl_listing.test.js && node tests/unit/budget_selection_precondition.test.js && node tests/unit/multi_server_budget_selection.test.js && node tests/unit/multi_server_budget_selection_stdio.test.js && node tests/unit/multi_server_session_open_http.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/multi_server_budget_selection.test.js && node tests/unit/static_tokens.test.js && node tests/unit/httpServer_static_tokens.test.js && node tests/unit/multi_server_budget_selection_stdio.test.js && node tests/unit/multi_server_session_open_http.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",