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 +2 -1
- package/dist/src/actualConnection.js +18 -5
- package/dist/src/config.js +18 -0
- package/dist/src/lib/ActualConnectionPool.js +118 -8
- package/dist/src/lib/actual-adapter.js +62 -45
- package/dist/src/server/httpServer.js +146 -94
- package/dist/src/tools/session_close.js +1 -1
- package/package.json +2 -2
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.
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
}
|
package/dist/src/config.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
*
|
|
223
|
-
*
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
4115
|
-
//
|
|
4116
|
-
//
|
|
4117
|
-
|
|
4118
|
-
|
|
4119
|
-
|
|
4120
|
-
|
|
4121
|
-
logger.
|
|
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
|
-
|
|
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.
|
|
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 (
|
|
4160
|
-
logger.
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
524
|
-
|
|
525
|
-
//
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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
|
-
|
|
610
|
-
|
|
611
|
-
|
|
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.
|
|
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",
|