actual-mcp-server 0.23.3 → 0.24.0

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
@@ -512,6 +512,7 @@ All configuration is via environment variables. Copy `.env.example` to `.env` to
512
512
  | **Session Management** ||||
513
513
  | `USE_CONNECTION_POOL` | `true` | No | Enable session-based connection pooling |
514
514
  | `MAX_CONCURRENT_SESSIONS` | `15` | No | Maximum concurrent MCP sessions allowed |
515
+ | `MAX_SESSIONS_PER_PRINCIPAL` | unset | No | Maximum concurrent sessions per OIDC subject or named `MCP_STATIC_TOKENS` entry; unset means no per-principal cap. The legacy token and unauthenticated requests are not capped |
515
516
  | `SESSION_IDLE_TIMEOUT_MINUTES` | `5` | No | Minutes before idle session cleanup |
516
517
  | **Security & Authentication** ||||
517
518
  | `AUTH_PROVIDER` | `none` | No | Auth mode: `none` (static Bearer) or `oidc` (JWKS-validated JWT) |
@@ -838,7 +839,7 @@ The software is provided **as-is**, without warranty of any kind. The author acc
838
839
 
839
840
  ---
840
841
 
841
- **Version:** 0.23.3 | **Tool Count:** 84 (verified LibreChat-compatible)
842
+ **Version:** 0.24.0 | **Tool Count:** 84 (verified LibreChat-compatible)
842
843
 
843
844
  ## Sponsor
844
845
 
@@ -16,6 +16,10 @@ let initializing = false;
16
16
  let initializationError = null;
17
17
  // Feature flag to enable connection pooling - can be disabled via environment variable
18
18
  let useConnectionPool = process.env.USE_CONNECTION_POOL !== 'false';
19
+ // #577: the per-principal cap is a pool feature; with pooling off nothing is capped at all.
20
+ if (!useConnectionPool && config.MAX_SESSIONS_PER_PRINCIPAL !== undefined) {
21
+ log.warn('MAX_SESSIONS_PER_PRINCIPAL is set but USE_CONNECTION_POOL=false, so it has no effect');
22
+ }
19
23
  /** #530: httpServer registers sessions with the pool only when the pool is in use. */
20
24
  export function isConnectionPoolEnabled() {
21
25
  return useConnectionPool;
@@ -187,9 +191,18 @@ export function getConnectionState() {
187
191
  idleTimeoutMinutes: useConnectionPool ? connectionPool.getIdleTimeoutMinutes() : null,
188
192
  };
189
193
  }
190
- export function canAcceptNewSession() {
191
- if (!useConnectionPool) {
192
- return true; // Shared connection mode - always accept
193
- }
194
- return connectionPool.canAcceptNewSession();
194
+ /**
195
+ * #581/#577: take a session slot at admission, synchronously, under the global cap and the
196
+ * per-principal cap (see `connectionPool.admitSession`). Pass a principal only for a request that
197
+ * is capped. Shared connection mode has no cap, so it always admits and records nothing.
198
+ */
199
+ export function admitSession(sessionId, principal) {
200
+ if (!useConnectionPool)
201
+ return { admitted: true };
202
+ return connectionPool.admitSession(sessionId, principal);
203
+ }
204
+ /** #577: release the slot and the admission of an initialize that never registered. Idempotent. */
205
+ export function releaseAdmission(sessionId) {
206
+ if (useConnectionPool)
207
+ connectionPool.releaseAdmission(sessionId);
195
208
  }
@@ -139,6 +139,24 @@ export const configSchema = z.object({
139
139
  MCP_HTTP_BODY_LIMIT: z.string().default('512kb').refine((v) => parseStrictByteSize(v) !== null, 'MCP_HTTP_BODY_LIMIT must be a positive byte size such as 512kb, 1mb or 1048576 (units b, kb, mb, gb, tb, pb; ' +
140
140
  'no spaces around it, no separators; at least 1 byte and at most 9007199254740991 bytes) (#466)'),
141
141
  MAX_CONCURRENT_SESSIONS: z.string().default('15').transform(val => parseInt(val, 10)),
142
+ // #577: optional per-principal session cap under MAX_CONCURRENT_SESSIONS. Unset or empty means
143
+ // no per-principal cap; an empty value in a compose file must not abort startup, which
144
+ // z.coerce.number() would do (it turns '' into 0). Anything set must be a positive integer.
145
+ MAX_SESSIONS_PER_PRINCIPAL: z
146
+ .string()
147
+ .optional()
148
+ .transform((raw, ctx) => {
149
+ const v = (raw ?? '').trim();
150
+ if (v === '')
151
+ return undefined;
152
+ if (/^[1-9]\d*$/.test(v))
153
+ return parseInt(v, 10);
154
+ ctx.addIssue({
155
+ code: 'custom',
156
+ message: `MAX_SESSIONS_PER_PRINCIPAL must be a positive integer (unset or empty means no per-principal cap), got ${JSON.stringify(raw)}.`,
157
+ });
158
+ return z.NEVER;
159
+ }),
142
160
  // --- Published tool surface (#483) ---
143
161
  // Which tools this process publishes AND dispatches. Names are validated, and an unknown
144
162
  // one aborts startup, by resolvePublishedToolNames (src/lib/toolsets.ts) in
@@ -52,16 +52,38 @@ class ActualConnectionPool {
52
52
  // would close its transport mid-operation. Entries have the #392 lock re-check and the
53
53
  // #378 in-drain touch for the same hazard; registrations have neither.
54
54
  inFlight = new Map();
55
+ // #581: session ids that hold a slot under MAX_CONCURRENT_SESSIONS but have no initialized
56
+ // entry yet. Before this the cap counted initialized entries only, and an entry is created
57
+ // after the login and budget download, so every initialize that arrived while those were in
58
+ // flight saw the same count and was admitted: 6 parallel initializes all logged in under a cap
59
+ // of 2. A slot is now taken synchronously at admission (`reserveSession`) and handed over to
60
+ // the entry in one synchronous block in `getConnection`. Ids only: this count reaches the
61
+ // unauthenticated /health as a number, never as a list.
62
+ reservations = new Set();
63
+ // #577: session id to the principal its admission was capped under, for the session's whole
64
+ // lifetime (not the pool entry's: a mid-session pool drop keeps the session, so it keeps
65
+ // counting). A principal's count is the number of values equal to it, computed by iterating,
66
+ // so there is no counter to drift and a principal with no sessions has no value here. Only
67
+ // capped admissions are recorded: the cap must be set and the caller must pass a principal.
68
+ // Deleted by `forgetSession` (every session end) and `releaseAdmission` (an initialize that
69
+ // never registered). Never logged, never in getStats.
70
+ admissions = new Map();
71
+ MAX_SESSIONS_PER_PRINCIPAL;
55
72
  constructor() {
56
73
  // Read from environment variable or default to 15
57
74
  // @actual-app/api is a singleton, so concurrent sessions cause conflicts
58
75
  this.MAX_CONCURRENT_SESSIONS = parseInt(process.env.MAX_CONCURRENT_SESSIONS || '15', 10);
76
+ // #577: validated by the config schema, so no raw read here.
77
+ this.MAX_SESSIONS_PER_PRINCIPAL = config.MAX_SESSIONS_PER_PRINCIPAL;
59
78
  // Configurable idle timeout (in minutes)
60
79
  const idleTimeoutMinutes = parseInt(process.env.SESSION_IDLE_TIMEOUT_MINUTES || '5', 10);
61
80
  this.IDLE_TIMEOUT = idleTimeoutMinutes * 60 * 1000;
62
81
  // Cleanup runs at half the idle timeout (or 2 minutes minimum)
63
82
  this.CLEANUP_INTERVAL = Math.max(Math.floor(this.IDLE_TIMEOUT / 5), 2 * 60 * 1000);
64
83
  logger.info(`[ConnectionPool] Max concurrent sessions: ${this.MAX_CONCURRENT_SESSIONS}`);
84
+ if (this.MAX_SESSIONS_PER_PRINCIPAL !== undefined) {
85
+ logger.info(`[ConnectionPool] Max sessions per principal: ${this.MAX_SESSIONS_PER_PRINCIPAL}`);
86
+ }
65
87
  logger.info(`[ConnectionPool] Session idle timeout: ${idleTimeoutMinutes} minutes`);
66
88
  logger.info(`[ConnectionPool] Cleanup interval: ${Math.floor(this.CLEANUP_INTERVAL / 1000)}s`);
67
89
  // Initialize asynchronously (force close stale connections, then start cleanup timer)
@@ -147,9 +169,15 @@ class ActualConnectionPool {
147
169
  registerSession(sessionId) {
148
170
  this.registered.set(sessionId, Date.now());
149
171
  }
150
- /** #530: drop a registration (idempotent). Every eviction path calls this itself. */
172
+ /**
173
+ * #530: drop a registration (idempotent). Every eviction path calls this itself.
174
+ * #577: also drops the session's per-principal admission. Every session end (DELETE, idle
175
+ * eviction, an evicting shutdown, the orphan sweep) reaches here through the transport's
176
+ * teardown; a non-evicting `shutdownConnection` does not, so the session keeps counting.
177
+ */
151
178
  forgetSession(sessionId) {
152
179
  this.registered.delete(sessionId);
180
+ this.admissions.delete(sessionId);
153
181
  }
154
182
  /**
155
183
  * Refresh a session's activity timestamp. Called by the transport layer on
@@ -219,12 +247,82 @@ class ActualConnectionPool {
219
247
  }
220
248
  }
221
249
  /**
222
- * Check if we can accept a new session (under the concurrent limit)
223
- * Returns true if limit not reached, false otherwise
250
+ * #581: slots held under MAX_CONCURRENT_SESSIONS, optionally leaving one session out: every
251
+ * initialized entry, plus every reservation whose id has no initialized entry (a session that
252
+ * holds both counts once).
253
+ */
254
+ slotsInUse(exclude) {
255
+ let n = 0;
256
+ for (const [id, c] of this.connections)
257
+ if (c.initialized && id !== exclude)
258
+ n++;
259
+ for (const id of this.reservations)
260
+ if (id !== exclude && !this.connections.get(id)?.initialized)
261
+ n++;
262
+ return n;
263
+ }
264
+ /** #581: reservations still waiting for their entry (a count only, see `reservations`). */
265
+ pendingReservations() {
266
+ let n = 0;
267
+ for (const id of this.reservations)
268
+ if (!this.connections.get(id)?.initialized)
269
+ n++;
270
+ return n;
271
+ }
272
+ /**
273
+ * #581: take a slot for `sessionId`, synchronously. The count and the add have no `await`
274
+ * between them, which is the whole fix: parallel callers cannot all see the same count.
275
+ * A session that already holds a slot (an initialized entry or a reservation) reserves again
276
+ * without counting twice, which is how switchBudget keeps its slot across its slow path.
277
+ * Every successful call must be paired with `releaseReservation` on the caller's exit path;
278
+ * `getConnection` hands a reservation over to the entry on success.
279
+ */
280
+ reserveSession(sessionId) {
281
+ return this.admitSession(sessionId).admitted;
282
+ }
283
+ /**
284
+ * #577: `reserveSession` plus the per-principal cap, in the same synchronous call. The global
285
+ * check runs first, then the per-principal one; a refusal of either scope records nothing.
286
+ * Only the initialize route passes a principal, and only for a request that is capped (see
287
+ * `capPrincipal` in httpServer.ts); every other caller is bounded by the global cap alone.
288
+ * A principal refusal carries the caller's own cap and count and nothing about anyone else.
289
+ */
290
+ admitSession(sessionId, principal) {
291
+ const holds = this.reservations.has(sessionId) || this.connections.get(sessionId)?.initialized === true;
292
+ if (!holds && this.slotsInUse() >= this.MAX_CONCURRENT_SESSIONS)
293
+ return { admitted: false, scope: 'global' };
294
+ const cap = this.MAX_SESSIONS_PER_PRINCIPAL;
295
+ const capped = principal !== undefined && cap !== undefined;
296
+ if (capped && !this.admissions.has(sessionId)) {
297
+ const count = this.principalSessions(principal);
298
+ if (count >= cap)
299
+ return { admitted: false, scope: 'principal', cap, count };
300
+ }
301
+ this.reservations.add(sessionId);
302
+ if (capped)
303
+ this.admissions.set(sessionId, principal);
304
+ return { admitted: true };
305
+ }
306
+ /** #577: sessions admitted under `principal` (iterated, see `admissions`). */
307
+ principalSessions(principal) {
308
+ let n = 0;
309
+ for (const p of this.admissions.values())
310
+ if (p === principal)
311
+ n++;
312
+ return n;
313
+ }
314
+ /** #581: give back a slot that never became an entry. Idempotent; a no-op after the hand-over. */
315
+ releaseReservation(sessionId) {
316
+ this.reservations.delete(sessionId);
317
+ }
318
+ /**
319
+ * #577: give back everything an initialize that never registered took: its slot and its
320
+ * per-principal admission. Idempotent. A session that did register keeps its admission until
321
+ * `forgetSession`.
224
322
  */
225
- canAcceptNewSession() {
226
- const activeConnections = Array.from(this.connections.values()).filter(c => c.initialized).length;
227
- return activeConnections < this.MAX_CONCURRENT_SESSIONS;
323
+ releaseAdmission(sessionId) {
324
+ this.reservations.delete(sessionId);
325
+ this.admissions.delete(sessionId);
228
326
  }
229
327
  /**
230
328
  * Read-only view of the pool entry for a session. Used by switchBudget to
@@ -285,8 +383,11 @@ class ActualConnectionPool {
285
383
  logger.debug(`[ConnectionPool] Reusing connection for session: ${sessionId}`);
286
384
  return;
287
385
  }
288
- // Check concurrent session limit
289
- const activeConnections = Array.from(this.connections.values()).filter(c => c.initialized).length;
386
+ // Check concurrent session limit. #581: against every OTHER session's slot, reservations
387
+ // included, and take this session's slot in the same synchronous block, so a caller that
388
+ // did not reserve first (switchBudget's slow path, a non-HTTP caller) is just as atomic as
389
+ // the initialize route. A caller that already reserved keeps its reservation.
390
+ const activeConnections = this.slotsInUse(sessionId);
290
391
  if (activeConnections >= this.MAX_CONCURRENT_SESSIONS) {
291
392
  const errorMsg = `[ConnectionPool] Max concurrent sessions (${this.MAX_CONCURRENT_SESSIONS}) reached. Active: ${activeConnections}. Please close some connections or wait for idle sessions to timeout.`;
292
393
  logger.warn(errorMsg);
@@ -306,6 +407,9 @@ class ActualConnectionPool {
306
407
  if (!fs.existsSync(DATA_DIR)) {
307
408
  fs.mkdirSync(DATA_DIR, { recursive: true });
308
409
  }
410
+ // #581: taken here, still with no `await` since the check above, so that a throw before the
411
+ // try (the mkdir) cannot leave a slot behind; the catch below releases it on any failure.
412
+ this.reservations.add(sessionId);
309
413
  try {
310
414
  // #390 round 2: hold the api mutex across init + download.
311
415
  //
@@ -349,7 +453,10 @@ class ActualConnectionPool {
349
453
  encryptionPassword: BUDGET_PASSWORD,
350
454
  syncId: BUDGET_SYNC_ID,
351
455
  };
456
+ // #581: the hand-over. Both in one synchronous block, so the slot is never counted twice
457
+ // (entry plus reservation) or not at all.
352
458
  this.connections.set(sessionId, conn);
459
+ this.reservations.delete(sessionId);
353
460
  logger.info(`[ConnectionPool] Connection ready for session: ${sessionId}`);
354
461
  }
355
462
  catch (err) {
@@ -382,6 +489,8 @@ class ActualConnectionPool {
382
489
  setApiInitialized(false);
383
490
  // Ensure this session is not in the connections map
384
491
  this.connections.delete(sessionId);
492
+ // #581: and holds no slot.
493
+ this.reservations.delete(sessionId);
385
494
  throw err;
386
495
  }
387
496
  }
@@ -780,6 +889,7 @@ class ActualConnectionPool {
780
889
  return {
781
890
  totalSessions: this.connections.size,
782
891
  activeSessions: Array.from(this.connections.values()).filter(c => c.initialized).length,
892
+ pendingReservations: this.pendingReservations(),
783
893
  maxConcurrent: this.MAX_CONCURRENT_SESSIONS,
784
894
  sharedConnection: this.sharedConnection?.initialized || false,
785
895
  sessions
@@ -4111,55 +4111,72 @@ export async function switchBudget(name) {
4111
4111
  logger.info(`[ADAPTER] Active budget switched for session ${sessionId} to: "${found.name}" (${found.syncId}) on ${found.serverUrl}`);
4112
4112
  return { name: found.name, syncId: found.syncId, serverUrl: found.serverUrl };
4113
4113
  }
4114
- // Slow path: different server or credentials. Release the existing pool
4115
- // entry (bound to the previous syncId / server) BEFORE mutating the session
4116
- // map. Swallow shutdown errors: a stale or missing pool entry is benign.
4117
- try {
4118
- await connectionPool.shutdownConnection(sessionId);
4119
- }
4120
- catch (e) {
4121
- logger.debug(`[ADAPTER] switchBudget: shutdownConnection raised (likely no prior entry): ${e}`);
4122
- }
4123
- // Update the per-session active-budget slot. Subsequent getActiveBudgetConfig
4124
- // calls for this session now return the new budget.
4125
- setSessionBudget(sessionId, key);
4126
- setPreferredBudgetSyncId(store?.principal, found.syncId); // #189: persist at commit
4127
- // Materialise a fresh pool entry bound to the new budget. Without this, the
4128
- // next withActualApi call would find no pool entry and fall back to the
4129
- // legacy init+shutdown path. Failure here is logged but not fatal: the
4130
- // legacy fallback still works, just less efficiently.
4131
- //
4132
- // #348 EXCEPTION: never for a stdio session. stdio gained a synthetic session
4133
- // id so it could switch budgets at all, but it must stay on the legacy
4134
- // init/shutdown cycle. A pooled entry for stdio would be created here and then
4135
- // NEVER refreshed, because connectionPool.touch() is called only from
4136
- // httpServer.ts; it would expire after SESSION_IDLE_TIMEOUT_MINUTES and
4137
- // cleanupIdleConnections would tear it down. Since #392 that teardown takes the api mutex and
4138
- // re-checks expiry, so it is no longer a mid-operation hazard, but an entry that is never
4139
- // refreshed still expires, and that is the reason that matters here. The
4140
- // legacy path re-reads getActiveBudgetConfig() on every call, so the switch
4141
- // still takes effect; it just costs an init per operation, which is what stdio
4142
- // already did before this ticket.
4143
- if (store?.transport === 'stdio') {
4144
- logger.debug(`[ADAPTER] switchBudget: session ${sessionId} is stdio; skipping pool materialisation ` +
4145
- '(stdio stays on the legacy init/shutdown path by design, #348).');
4146
- }
4147
- else if (_skipApiInitForTests) {
4148
- setApiInitialized(true);
4114
+ // #581: hold this session's slot across the window between releasing the old entry and
4115
+ // creating the new one, so a concurrent initialize cannot take it and leave this session over
4116
+ // the cap. A session that holds a slot always re-reserves; one whose entry was already dropped
4117
+ // may be refused when the pool is full, and getConnection below then fails the same way it
4118
+ // always has (the legacy fallback). stdio never has a pool entry, so it reserves nothing.
4119
+ const holdsSlot = store?.transport !== 'stdio' && connectionPool.reserveSession(sessionId);
4120
+ if (store?.transport !== 'stdio' && !holdsSlot) {
4121
+ logger.warn(`[ADAPTER] switchBudget: session ${sessionId} holds no pool slot and the pool is full; ` +
4122
+ 'the switch proceeds on the legacy init+shutdown fallback.');
4149
4123
  }
4150
- else {
4124
+ try {
4125
+ // Slow path: different server or credentials. Release the existing pool
4126
+ // entry (bound to the previous syncId / server) BEFORE mutating the session
4127
+ // map. Swallow shutdown errors: a stale or missing pool entry is benign.
4151
4128
  try {
4152
- await connectionPool.getConnection(sessionId, {
4153
- serverUrl: found.serverUrl,
4154
- password: found.password || '',
4155
- syncId: found.syncId,
4156
- encryptionPassword: found.encryptionPassword,
4157
- });
4129
+ await connectionPool.shutdownConnection(sessionId);
4158
4130
  }
4159
- catch (poolErr) {
4160
- logger.warn(`[ADAPTER] switchBudget: failed to materialise new pool entry for session ${sessionId}: ${poolErr}. ` +
4161
- 'Subsequent calls will use the legacy init+shutdown fallback.');
4131
+ catch (e) {
4132
+ logger.debug(`[ADAPTER] switchBudget: shutdownConnection raised (likely no prior entry): ${e}`);
4162
4133
  }
4134
+ // Update the per-session active-budget slot. Subsequent getActiveBudgetConfig
4135
+ // calls for this session now return the new budget.
4136
+ setSessionBudget(sessionId, key);
4137
+ setPreferredBudgetSyncId(store?.principal, found.syncId); // #189: persist at commit
4138
+ // Materialise a fresh pool entry bound to the new budget. Without this, the
4139
+ // next withActualApi call would find no pool entry and fall back to the
4140
+ // legacy init+shutdown path. Failure here is logged but not fatal: the
4141
+ // legacy fallback still works, just less efficiently.
4142
+ //
4143
+ // #348 EXCEPTION: never for a stdio session. stdio gained a synthetic session
4144
+ // id so it could switch budgets at all, but it must stay on the legacy
4145
+ // init/shutdown cycle. A pooled entry for stdio would be created here and then
4146
+ // NEVER refreshed, because connectionPool.touch() is called only from
4147
+ // httpServer.ts; it would expire after SESSION_IDLE_TIMEOUT_MINUTES and
4148
+ // cleanupIdleConnections would tear it down. Since #392 that teardown takes the api mutex and
4149
+ // re-checks expiry, so it is no longer a mid-operation hazard, but an entry that is never
4150
+ // refreshed still expires, and that is the reason that matters here. The
4151
+ // legacy path re-reads getActiveBudgetConfig() on every call, so the switch
4152
+ // still takes effect; it just costs an init per operation, which is what stdio
4153
+ // already did before this ticket.
4154
+ if (store?.transport === 'stdio') {
4155
+ logger.debug(`[ADAPTER] switchBudget: session ${sessionId} is stdio; skipping pool materialisation ` +
4156
+ '(stdio stays on the legacy init/shutdown path by design, #348).');
4157
+ }
4158
+ else if (_skipApiInitForTests) {
4159
+ setApiInitialized(true);
4160
+ }
4161
+ else {
4162
+ try {
4163
+ await connectionPool.getConnection(sessionId, {
4164
+ serverUrl: found.serverUrl,
4165
+ password: found.password || '',
4166
+ syncId: found.syncId,
4167
+ encryptionPassword: found.encryptionPassword,
4168
+ });
4169
+ }
4170
+ catch (poolErr) {
4171
+ logger.warn(`[ADAPTER] switchBudget: failed to materialise new pool entry for session ${sessionId}: ${poolErr}. ` +
4172
+ 'Subsequent calls will use the legacy init+shutdown fallback.');
4173
+ }
4174
+ }
4175
+ }
4176
+ finally {
4177
+ // A no-op once getConnection handed the reservation over to the new entry.
4178
+ if (holdsSlot)
4179
+ connectionPool.releaseReservation(sessionId);
4163
4180
  }
4164
4181
  logger.info(`[ADAPTER] Active budget switched for session ${sessionId} to: "${found.name}" (${found.syncId}) on ${found.serverUrl}`);
4165
4182
  return { name: found.name, syncId: found.syncId, serverUrl: found.serverUrl };
@@ -7,7 +7,7 @@ import logger, { resolveRequestId } from '../logger.js';
7
7
  import { attachTransportErrorLogger } from '../lib/transportErrorLogger.js';
8
8
  import { getLocalIp } from '../utils.js';
9
9
  import actualToolsManager from '../actualToolsManager.js';
10
- import { getConnectionState, connectToActualForSession, shutdownActualForSession, shutdownActual, canAcceptNewSession, isConnectionPoolEnabled } from '../actualConnection.js';
10
+ import { getConnectionState, connectToActualForSession, shutdownActualForSession, shutdownActual, admitSession, releaseAdmission, isConnectionPoolEnabled } from '../actualConnection.js';
11
11
  import { connectionPool } from '../lib/ActualConnectionPool.js';
12
12
  import { clearSessionBudgetState, setSessionLivenessCheck, initialBudgetForSession, getBudgetRegistry } from '../lib/actual-adapter.js';
13
13
  import { errorLogFields } from '../lib/errors.js';
@@ -57,6 +57,18 @@ function resolvePrincipal(req) {
57
57
  return staticPrincipal;
58
58
  return config.MCP_SSE_AUTHORIZATION ? LEGACY_STATIC_PRINCIPAL : undefined;
59
59
  }
60
+ // #577: the principal MAX_SESSIONS_PER_PRINCIPAL caps, decided by HOW the request authenticated,
61
+ // never by the principal string (an OIDC subject that happens to read "static-bearer" is capped).
62
+ // OIDC: the verified subject. A MCP_STATIC_TOKENS token: its own principal. Exempt, so bounded by
63
+ // the global cap alone: the legacy MCP_SSE_AUTHORIZATION token, which every holder shares (capping
64
+ // it would silently lower the effective global cap on every single-token deployment), and an
65
+ // unauthenticated request, which has no principal.
66
+ function capPrincipal(req) {
67
+ if (config.AUTH_PROVIDER === 'oidc') {
68
+ return req.auth?.subject;
69
+ }
70
+ return req.staticPrincipal;
71
+ }
60
72
  export async function startHttpServer(mcp, port, httpPath, capabilities, // was passed by index.ts
61
73
  implementedTools, // was passed by index.ts
62
74
  serverDescription, // was passed by index.ts
@@ -497,8 +509,34 @@ advertisedUrl) {
497
509
  });
498
510
  return;
499
511
  }
500
- // Check if we can accept a new session (concurrent limit)
501
- if (!canAcceptNewSession()) {
512
+ // Check if we can accept a new session (concurrent limit). #581: the session id is chosen
513
+ // HERE and its slot reserved synchronously under it, before anything awaits. Checking a
514
+ // count and reserving later (at login) admitted every initialize that arrived while
515
+ // earlier ones were still logging in.
516
+ // #577: the per-principal cap is checked in the same synchronous call, after the global one.
517
+ const sid = randomUUID();
518
+ const admission = admitSession(sid, capPrincipal(req));
519
+ if (!admission.admitted && admission.scope === 'principal') {
520
+ const timeoutMinutes = getConnectionState().idleTimeoutMinutes || 2;
521
+ // Only the caller's own cap and count: nothing here is derived from another principal,
522
+ // and the log line carries no principal and no session id.
523
+ logger.warn('[SESSION] Rejecting new session: per-principal cap reached', { cap: admission.cap, count: admission.count });
524
+ res.status(503).json({
525
+ jsonrpc: '2.0',
526
+ id: payload?.id ?? null,
527
+ error: {
528
+ code: -32000,
529
+ message: `Max sessions per principal (${admission.cap}) reached. Yours: ${admission.count}. Please close one of your sessions or wait for idle sessions to time out (${timeoutMinutes} minutes).`,
530
+ data: {
531
+ maxSessionsPerPrincipal: admission.cap,
532
+ yourSessions: admission.count,
533
+ idleTimeoutMinutes: timeoutMinutes,
534
+ },
535
+ },
536
+ });
537
+ return;
538
+ }
539
+ if (!admission.admitted) {
502
540
  const state = getConnectionState();
503
541
  const stats = state.connectionPool;
504
542
  const timeoutMinutes = state.idleTimeoutMinutes || 2;
@@ -513,107 +551,121 @@ advertisedUrl) {
513
551
  data: {
514
552
  maxConcurrent: stats?.maxConcurrent,
515
553
  activeSessions: stats?.activeSessions,
516
- availableSlots: (stats?.maxConcurrent ?? 0) - (stats?.activeSessions ?? 0),
554
+ pendingReservations: stats?.pendingReservations,
555
+ availableSlots: Math.max(0, (stats?.maxConcurrent ?? 0) - (stats?.activeSessions ?? 0) - (stats?.pendingReservations ?? 0)),
517
556
  idleTimeoutMinutes: timeoutMinutes
518
557
  }
519
558
  },
520
559
  });
521
560
  return;
522
561
  }
523
- logger.debug('[SESSION] Creating new MCP server + transport for initialize');
524
- const { server } = createServerInstance();
525
- // Create a promise to track session initialization completion
526
- let resolveInit;
527
- let rejectInit;
528
- const initPromise = new Promise((resolve, reject) => {
529
- resolveInit = resolve;
530
- rejectInit = reject;
531
- });
532
- // Always-on safety net: if no concurrent code path is awaiting initPromise
533
- // when rejectInit fires (e.g. session init fails before any tools/call
534
- // arrives), the rejection would otherwise hit process.on('unhandledRejection')
535
- // and exit the server. The original error is already logged inside the
536
- // onsessioninitialized catch block below: we deliberately do NOT re-log here
537
- // to avoid duplicate noise and any risk of leaking credentials.
538
- initPromise.catch(() => { });
539
- // The session belongs to the principal that initialized it. Resolved here,
540
- // from this request, so the owner can never come from a later request.
541
- const sessionOwner = resolvePrincipal(req);
542
- const transport = new StreamableHTTPServerTransport({
543
- sessionIdGenerator: () => randomUUID(),
544
- enableJsonResponse: true,
545
- // #563: the SDK awaits this on a valid DELETE, BEFORE it answers 200, so the pool
546
- // slot is free by the time the client hears the session ended. It waits for the api
547
- // mutex (#392), so another session's call in flight finishes first. Without evict:
548
- // the DELETE route tears the session down itself after the 200.
549
- onsessionclosed: async (sid) => {
550
- if (isConnectionPoolEnabled())
551
- await connectionPool.shutdownConnection(sid);
552
- },
553
- onsessioninitialized: async (sid) => {
554
- logger.debug(`Session initialized: ${sid}`);
555
- // Store the promise before starting initialization
556
- sessionInitPromises.set(sid, initPromise);
557
- // Initialize connection pool for this session
558
- try {
559
- // #571: open on the session's own budget (a restored #189 preference may live on
560
- // another server), not on the default one.
561
- const budget = initialBudgetForSession(sid);
562
- await connectToActualForSession(sid, {
563
- serverUrl: budget.serverUrl,
564
- password: budget.password || '',
565
- syncId: budget.syncId,
566
- encryptionPassword: budget.encryptionPassword,
567
- });
568
- // Only register the transport if the pool connection succeeded.
569
- // The pool stamped lastActivity when it created the entry, so it
570
- // is already the source of truth for this session's idle clock.
571
- recordSessionOwner(sid, sessionOwner);
572
- transports.set(sid, transport);
573
- // #530: let the pool time this session even after its entry is recycled.
574
- // Pooling off (USE_CONNECTION_POOL=false) has no sweep, so nothing to register.
575
- if (isConnectionPoolEnabled())
576
- connectionPool.registerSession(sid);
577
- logger.info(`[SESSION] Actual connection initialized for session: ${sid}`);
578
- resolveInit?.();
579
- }
580
- catch (err) {
581
- logger.error(`[SESSION] Failed to initialize Actual for session ${sid}:`, err);
582
- // Don't add failed sessions to transports map - they won't be usable anyway
583
- // This prevents accumulation of dead sessions
584
- // #438: remember WHY. The transport is still deliberately not registered,
585
- // so the dead-session protection above is unchanged; without this the
586
- // cause is known here and discarded, and the client's next request gets
587
- // a bare "Session not found" that says nothing about a schema mismatch,
588
- // a bad password or an unreachable server.
589
- rememberInitFailure(sid, err, sessionOwner);
590
- rejectInit?.(err);
591
- }
592
- finally {
593
- // Clean up the promise after a short delay to allow pending requests to complete
594
- setTimeout(() => sessionInitPromises.delete(sid), 1000);
595
- }
596
- },
597
- });
598
- // connect transport then handle request (matching working example)
599
- await server.connect(transport);
562
+ // #581: ONE try/finally from the reservation to the answer. The SDK answers 406 (Accept),
563
+ // 400 (bad JSON), an invalid JSON-RPC body and an already-initialized transport WITHOUT
564
+ // calling onsessioninitialized, and `server.connect` can throw; none of those reach the
565
+ // pool, and neither the idle sweep nor the #530 orphan sweep can see a slot that has no
566
+ // entry and no registration. This finally is the only thing that can give it back. The
567
+ // SDK awaits onsessioninitialized inside handleRequest, so here the session either
568
+ // registered (its reservation was handed over to the pool entry) or never will.
600
569
  try {
601
- // Run in AsyncLocalStorage context so tools can access sessionId
602
- // and the adapter can enforce per-request budget ACL (#156).
603
- const allowedBudgetsInit = req.allowedBudgets;
604
- const requestId = resolveRequestId(req.get('x-correlation-id'));
605
- await requestContext.run({ sessionId: undefined, requestId, allowedBudgets: allowedBudgetsInit, principal: resolvePrincipal(req) }, async () => {
606
- await transport.handleRequest(req, res, req.body);
570
+ logger.debug('[SESSION] Creating new MCP server + transport for initialize');
571
+ const { server } = createServerInstance();
572
+ // Create a promise to track session initialization completion
573
+ let resolveInit;
574
+ let rejectInit;
575
+ const initPromise = new Promise((resolve, reject) => {
576
+ resolveInit = resolve;
577
+ rejectInit = reject;
607
578
  });
579
+ // Always-on safety net: if no concurrent code path is awaiting initPromise
580
+ // when rejectInit fires (e.g. session init fails before any tools/call
581
+ // arrives), the rejection would otherwise hit process.on('unhandledRejection')
582
+ // and exit the server. The original error is already logged inside the
583
+ // onsessioninitialized catch block below: we deliberately do NOT re-log here
584
+ // to avoid duplicate noise and any risk of leaking credentials.
585
+ initPromise.catch(() => { });
586
+ // The session belongs to the principal that initialized it. Resolved here,
587
+ // from this request, so the owner can never come from a later request.
588
+ const sessionOwner = resolvePrincipal(req);
589
+ const transport = new StreamableHTTPServerTransport({
590
+ sessionIdGenerator: () => sid,
591
+ enableJsonResponse: true,
592
+ // #563: the SDK awaits this on a valid DELETE, BEFORE it answers 200, so the pool
593
+ // slot is free by the time the client hears the session ended. It waits for the api
594
+ // mutex (#392), so another session's call in flight finishes first. Without evict:
595
+ // the DELETE route tears the session down itself after the 200.
596
+ onsessionclosed: async (sid) => {
597
+ if (isConnectionPoolEnabled())
598
+ await connectionPool.shutdownConnection(sid);
599
+ },
600
+ onsessioninitialized: async (sid) => {
601
+ logger.debug(`Session initialized: ${sid}`);
602
+ // Store the promise before starting initialization
603
+ sessionInitPromises.set(sid, initPromise);
604
+ // Initialize connection pool for this session
605
+ try {
606
+ // #571: open on the session's own budget (a restored #189 preference may live on
607
+ // another server), not on the default one.
608
+ const budget = initialBudgetForSession(sid);
609
+ await connectToActualForSession(sid, {
610
+ serverUrl: budget.serverUrl,
611
+ password: budget.password || '',
612
+ syncId: budget.syncId,
613
+ encryptionPassword: budget.encryptionPassword,
614
+ });
615
+ // Only register the transport if the pool connection succeeded.
616
+ // The pool stamped lastActivity when it created the entry, so it
617
+ // is already the source of truth for this session's idle clock.
618
+ recordSessionOwner(sid, sessionOwner);
619
+ transports.set(sid, transport);
620
+ // #530: let the pool time this session even after its entry is recycled.
621
+ // Pooling off (USE_CONNECTION_POOL=false) has no sweep, so nothing to register.
622
+ if (isConnectionPoolEnabled())
623
+ connectionPool.registerSession(sid);
624
+ logger.info(`[SESSION] Actual connection initialized for session: ${sid}`);
625
+ resolveInit?.();
626
+ }
627
+ catch (err) {
628
+ logger.error(`[SESSION] Failed to initialize Actual for session ${sid}:`, err);
629
+ // Don't add failed sessions to transports map - they won't be usable anyway
630
+ // This prevents accumulation of dead sessions
631
+ // #438: remember WHY. The transport is still deliberately not registered,
632
+ // so the dead-session protection above is unchanged; without this the
633
+ // cause is known here and discarded, and the client's next request gets
634
+ // a bare "Session not found" that says nothing about a schema mismatch,
635
+ // a bad password or an unreachable server.
636
+ rememberInitFailure(sid, err, sessionOwner);
637
+ rejectInit?.(err);
638
+ }
639
+ finally {
640
+ // Clean up the promise after a short delay to allow pending requests to complete
641
+ setTimeout(() => sessionInitPromises.delete(sid), 1000);
642
+ }
643
+ },
644
+ });
645
+ // connect transport then handle request (matching working example)
646
+ await server.connect(transport);
647
+ try {
648
+ // Run in AsyncLocalStorage context so tools can access sessionId
649
+ // and the adapter can enforce per-request budget ACL (#156).
650
+ const allowedBudgetsInit = req.allowedBudgets;
651
+ const requestId = resolveRequestId(req.get('x-correlation-id'));
652
+ await requestContext.run({ sessionId: undefined, requestId, allowedBudgets: allowedBudgetsInit, principal: resolvePrincipal(req) }, async () => {
653
+ await transport.handleRequest(req, res, req.body);
654
+ });
655
+ }
656
+ catch (err) {
657
+ logger.error('Transport.handleRequest failed during initialize: %o', err);
658
+ const e = err;
659
+ if (e && typeof e.stack === 'string')
660
+ logger.error(e.stack);
661
+ throw err;
662
+ }
663
+ return;
608
664
  }
609
- catch (err) {
610
- logger.error('Transport.handleRequest failed during initialize: %o', err);
611
- const e = err;
612
- if (e && typeof e.stack === 'string')
613
- logger.error(e.stack);
614
- throw err;
665
+ finally {
666
+ if (!transports.has(sid))
667
+ releaseAdmission(sid);
615
668
  }
616
- return;
617
669
  }
618
670
  // sessionId present -> reuse. Transport presence is the liveness signal:
619
671
  // the pool's eviction listener (#167) removes the transport the moment a
@@ -9,7 +9,7 @@ const InputSchema = z.object({
9
9
  });
10
10
  const tool = {
11
11
  name: 'actual_session_close',
12
- description: 'Close one of your idle MCP sessions to free up connection slots. Useful when you get "Max concurrent sessions reached" errors. Only closes sessions you opened, other than the current one.',
12
+ description: 'Close one of your idle MCP sessions to free up connection slots. Useful when you get "Max concurrent sessions reached" or "Max sessions per principal reached" errors. Only closes sessions you opened, other than the current one.',
13
13
  inputSchema: InputSchema,
14
14
  call: async (args, _meta) => {
15
15
  const input = InputSchema.parse(args || {});
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.23.3",
4
+ "version": "0.24.0",
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/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/api_login_fetch.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/harness_cleanup.test.js && node tests/unit/harness_stdio_data_dir.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/api_login_fetch.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/harness_cleanup.test.js && node tests/unit/harness_stdio_data_dir.test.js && node tests/unit/batch_uncategorized_cleanup.test.js && node tests/unit/rules_stage342_cleanup.test.js && node tests/unit/residue_unreadable.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 && node tests/unit/session_admission_race.test.js && node tests/unit/pool_admission_reservation.test.js && node tests/unit/config_max_sessions_per_principal.test.js && node tests/unit/session_principal_cap.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",