mandala-computer-mcp 0.7.0 → 0.9.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.
Files changed (84) hide show
  1. package/README.md +232 -29
  2. package/dist/api.d.ts +6 -0
  3. package/dist/api.d.ts.map +1 -1
  4. package/dist/api.js +13 -3
  5. package/dist/api.js.map +1 -1
  6. package/dist/cli.d.ts +12 -0
  7. package/dist/cli.d.ts.map +1 -1
  8. package/dist/cli.js +76 -1
  9. package/dist/cli.js.map +1 -1
  10. package/dist/credentials.d.ts +55 -0
  11. package/dist/credentials.d.ts.map +1 -1
  12. package/dist/credentials.js +104 -2
  13. package/dist/credentials.js.map +1 -1
  14. package/dist/errors.d.ts +57 -16
  15. package/dist/errors.d.ts.map +1 -1
  16. package/dist/errors.js +112 -42
  17. package/dist/errors.js.map +1 -1
  18. package/dist/format.d.ts +17 -2
  19. package/dist/format.d.ts.map +1 -1
  20. package/dist/format.js +27 -4
  21. package/dist/format.js.map +1 -1
  22. package/dist/http.d.ts +58 -0
  23. package/dist/http.d.ts.map +1 -1
  24. package/dist/http.js +713 -57
  25. package/dist/http.js.map +1 -1
  26. package/dist/paths.d.ts +18 -11
  27. package/dist/paths.d.ts.map +1 -1
  28. package/dist/paths.js +31 -14
  29. package/dist/paths.js.map +1 -1
  30. package/dist/secret-store.d.ts +2 -1
  31. package/dist/secret-store.d.ts.map +1 -1
  32. package/dist/secret-store.js +6 -1
  33. package/dist/secret-store.js.map +1 -1
  34. package/dist/server.d.ts +2 -1
  35. package/dist/server.d.ts.map +1 -1
  36. package/dist/server.js +2 -1
  37. package/dist/server.js.map +1 -1
  38. package/dist/session.d.ts +24 -0
  39. package/dist/session.d.ts.map +1 -1
  40. package/dist/session.js +6 -0
  41. package/dist/session.js.map +1 -1
  42. package/dist/stdio.d.ts.map +1 -1
  43. package/dist/stdio.js +9 -1
  44. package/dist/stdio.js.map +1 -1
  45. package/dist/tool-filters.d.ts +1 -1
  46. package/dist/tool-filters.d.ts.map +1 -1
  47. package/dist/tool-filters.js +3 -0
  48. package/dist/tool-filters.js.map +1 -1
  49. package/dist/tools/account.d.ts.map +1 -1
  50. package/dist/tools/account.js +80 -6
  51. package/dist/tools/account.js.map +1 -1
  52. package/dist/tools/agent.js +12 -23
  53. package/dist/tools/agent.js.map +1 -1
  54. package/dist/tools/chat.d.ts.map +1 -1
  55. package/dist/tools/chat.js +56 -7
  56. package/dist/tools/chat.js.map +1 -1
  57. package/dist/tools/computers.d.ts.map +1 -1
  58. package/dist/tools/computers.js +93 -8
  59. package/dist/tools/computers.js.map +1 -1
  60. package/dist/tools/guest.d.ts.map +1 -1
  61. package/dist/tools/guest.js +28 -3
  62. package/dist/tools/guest.js.map +1 -1
  63. package/dist/tools/input.d.ts.map +1 -1
  64. package/dist/tools/input.js +98 -48
  65. package/dist/tools/input.js.map +1 -1
  66. package/dist/tools/operations.d.ts +7 -2
  67. package/dist/tools/operations.d.ts.map +1 -1
  68. package/dist/tools/operations.js +29 -6
  69. package/dist/tools/operations.js.map +1 -1
  70. package/dist/tools/secrets.d.ts.map +1 -1
  71. package/dist/tools/secrets.js +80 -25
  72. package/dist/tools/secrets.js.map +1 -1
  73. package/dist/tools/snapshots.d.ts.map +1 -1
  74. package/dist/tools/snapshots.js +21 -8
  75. package/dist/tools/snapshots.js.map +1 -1
  76. package/dist/tools/templates.js +2 -2
  77. package/dist/tools/templates.js.map +1 -1
  78. package/dist/tools/webhooks.js +1 -1
  79. package/dist/tools/webhooks.js.map +1 -1
  80. package/dist/version.d.ts +8 -0
  81. package/dist/version.d.ts.map +1 -0
  82. package/dist/version.js +8 -0
  83. package/dist/version.js.map +1 -0
  84. package/package.json +1 -1
package/dist/http.js CHANGED
@@ -7,7 +7,7 @@ import express, {} from 'express';
7
7
  import { Api, MODEL_KEY_HEADER } from './api.js';
8
8
  import { APIError } from './errors.js';
9
9
  import { MeteredBody } from './http-body.js';
10
- import { SSH_KEYS } from './paths.js';
10
+ import { WHOAMI } from './paths.js';
11
11
  import { createServer, SERVER_NAME, SERVER_VERSION } from './server.js';
12
12
  import { toolFilter } from './tool-filters.js';
13
13
  /** The OAuth scope the hosted resource asks for. */
@@ -66,7 +66,28 @@ const DEFAULT_TTL_MS = 30 * 60 * 1000;
66
66
  * any string gets that far; without a cap, a loop of initializes is a memory
67
67
  * exhaustion that costs the sender nothing.
68
68
  */
69
- const DEFAULT_MAX_SESSIONS = 256;
69
+ export const DEFAULT_MAX_SESSIONS = 256;
70
+ /**
71
+ * A ceiling on live sessions per bearer, under the one above.
72
+ *
73
+ * The process-wide cap bounds memory, but on its own it is a pool one caller
74
+ * can drain: a single accepted bearer could open every one of the 256 sessions
75
+ * and every other tenant's initialize was 503'd until the sweep, half an hour
76
+ * later. 16 leaves room for one person running several MCP clients on one key
77
+ * and still leaves the pool to everybody else.
78
+ *
79
+ * A suspended account's bearer is admitted (OPL-5437: it is how its holder
80
+ * reaches `whoami` and learns it is suspended), and one session is all that
81
+ * needs. Every other call it could make is refused by the platform. One
82
+ * session for the ACCOUNT, however many keys or tokens it holds (OPL-5452):
83
+ * see `standing` in {@link runHttp}.
84
+ */
85
+ const DEFAULT_MAX_SESSIONS_PER_BEARER = 16;
86
+ const MAX_SESSIONS_SUSPENDED = 1;
87
+ /** How long a caller whose bearer holds its maximum is asked to wait, in s. */
88
+ const BEARER_FULL_RETRY_AFTER_S = 30;
89
+ /** How long a caller is asked to wait when the whole pool is full, in s. */
90
+ const POOL_FULL_RETRY_AFTER_S = 30;
70
91
  const DEFAULT_MAX_LARGE_BODY_PARSES = 4;
71
92
  const SMALL_BODY_BYTES = 256 * 1024;
72
93
  /**
@@ -212,64 +233,122 @@ export async function runHttp(cfg) {
212
233
  const checkTtl = cfg.bearerCheckTtlMs ?? DEFAULT_BEARER_CHECK_TTL_MS;
213
234
  const accepted = new Map();
214
235
  let checksInFlight = 0;
215
- /** Whether the platform accepted this bearer within the cache window. */
216
- const isAccepted = (key) => {
217
- const until = accepted.get(digest(key).toString('hex'));
218
- return until !== undefined && until > Date.now();
236
+ const standing = new Map();
237
+ let probeSeq = 0;
238
+ /** Whether the latest answer about this account says it is suspended. */
239
+ const accountSuspended = (account) => standing.get(account)?.suspended === true;
240
+ /** Take a probe's answer for its account, unless a newer-started one answered already. */
241
+ const noteStanding = (account, suspended, seq) => {
242
+ const known = standing.get(account);
243
+ if (known && known.seq >= seq)
244
+ return;
245
+ // Re-inserted, so Map order is the order answers were taken in.
246
+ standing.delete(account);
247
+ if (standing.size >= MAX_ACCEPTED_BEARERS) {
248
+ // An account holding no session needs no answer kept: a later request
249
+ // or initialize for it finds none and asks again.
250
+ const holding = new Set();
251
+ for (const live of sessions.values())
252
+ holding.add(live.account);
253
+ for (const k of standing.keys())
254
+ if (!holding.has(k))
255
+ standing.delete(k);
256
+ if (standing.size >= MAX_ACCEPTED_BEARERS) {
257
+ standing.delete(standing.keys().next().value);
258
+ }
259
+ }
260
+ standing.set(account, { suspended, seq });
261
+ };
262
+ /** The platform's acceptance of this bearer, while the cache window lasts. */
263
+ const acceptance = (key) => {
264
+ const entry = accepted.get(digest(key).toString('hex'));
265
+ return entry !== undefined && entry.until > Date.now() ? entry : undefined;
219
266
  };
267
+ /** Whether the platform accepted this bearer within the cache window. */
268
+ const isAccepted = (key) => acceptance(key) !== undefined;
220
269
  /**
221
270
  * `ok`: the probe answered 2xx, and only that — the one answer that says the
222
- * platform authenticated this credential AND let it act. `refused`: a 401.
223
- * `unknown`: anything else — a 403 (a suspended account or a lost
224
- * membership), a 404 (the route moved), a 429, a 5xx, a timeout, or this
271
+ * platform authenticated this credential AND let it act. `suspended`: the
272
+ * same 2xx, for an account whose standing says `status: "suspended"` —
273
+ * admitted as `ok` is, and held to one session in all. `refused`: a 401.
274
+ * `unknown`: anything else — a 403 (a lost membership, say; a suspended
275
+ * account is answered 2xx on whoami and admitted), a 404 (the route moved), a 429, a 5xx, a timeout, or this
225
276
  * server already asking about as many bearers as it will at once. Only `ok`
226
- * is cached.
277
+ * and `suspended` are cached: the account key with the acceptance, the
278
+ * standing per account. `account` is the account key the bearer counts against (see
279
+ * {@link accountKey}); it is set whenever the verdict is `ok` or
280
+ * `suspended`, and returned rather than read back from the cache, which a
281
+ * zero TTL or a full cache may already have let go of. `workspace` is the
282
+ * workspace key (see {@link workspaceKey}), returned the same way, when
283
+ * whoami named a workspace for an account it named (OPL-5453).
227
284
  */
228
285
  // `onStart` runs only when a probe really starts — never for a cached answer
229
286
  // or a full cap — and synchronously, before the first await, so a caller's
230
287
  // bookkeeping and its check against it cannot interleave with another's.
288
+ //
289
+ // `ok` and `suspended` are the account's STANDING when this returns, which
290
+ // a newer-started probe of another of its bearers may have set, not what
291
+ // this bearer's own probe (or cached acceptance) said (OPL-5452).
231
292
  const checkBearer = async (key, onStart) => {
232
293
  const id = digest(key).toString('hex');
233
- if (isAccepted(key))
234
- return 'ok';
294
+ const cached = acceptance(key);
295
+ // Believed only while its account has a standing to read (see above).
296
+ if (cached && standing.has(cached.account)) {
297
+ return {
298
+ verdict: accountSuspended(cached.account) ? 'suspended' : 'ok',
299
+ account: cached.account,
300
+ workspace: cached.workspace,
301
+ };
302
+ }
235
303
  if (checksInFlight >= MAX_BEARER_CHECKS_IN_FLIGHT)
236
- return 'unknown';
304
+ return { verdict: 'unknown' };
237
305
  checksInFlight++;
306
+ // Numbered as it starts, before the first await: see `standing`.
307
+ const seq = ++probeSeq;
238
308
  onStart?.();
309
+ let suspended;
310
+ let confirmed;
311
+ let workspaceId;
239
312
  try {
240
313
  const api = new Api(key, cfg.baseUrl, AbortSignal.timeout(BEARER_CHECK_TIMEOUT_MS), {
241
314
  serviceSecret,
242
315
  });
243
- // `GET ssh-keys`, chosen because it is the cheapest read that every
244
- // valid credential is answered 2xx on. The platform's route table
245
- // gives it the lowest role there is (viewer), a workspace-scoped key is
246
- // refused only the key WRITES, and the handler lists the caller's own
247
- // keys from the control plane without asking any host — so no role a
248
- // grant can carry, and no host being down, turns it into a refusal.
249
- // `account` is the alternative and costs two fleet inventories. The one
250
- // valid credential it does not admit is a suspended account's (403),
251
- // which is answered as unconfirmed. It takes no parameters, so none are
252
- // sent.
253
- await api.json('GET', SSH_KEYS);
316
+ // `GET whoami`, chosen because every valid credential is answered 2xx
317
+ // on it, a suspended account's included. The platform's route table
318
+ // gives it the lowest role there is (viewer) and marks it readable by a
319
+ // suspended account, and the handler answers from the control plane's
320
+ // own tables without asking any host — so no role a grant can carry, no
321
+ // workspace scope, no account standing and no host being down turns it
322
+ // into a refusal. It was `GET ssh-keys`, which a suspended account is
323
+ // refused (403): its holder was answered 503 on every initialize and so
324
+ // could never reach whoami, the one tool that would say it is suspended
325
+ // (OPL-5437). It takes no parameters, so none are sent.
326
+ const who = await api.json('GET', WHOAMI);
327
+ suspended = isSuspended(who);
328
+ confirmed = accountIdOf(who);
329
+ workspaceId = workspaceIdOf(who);
254
330
  }
255
331
  catch (err) {
256
- return err instanceof APIError && err.status === 401 ? 'refused' : 'unknown';
332
+ return { verdict: err instanceof APIError && err.status === 401 ? 'refused' : 'unknown' };
257
333
  }
258
334
  finally {
259
335
  checksInFlight--;
260
336
  }
261
337
  const now = Date.now();
262
338
  if (accepted.size >= MAX_ACCEPTED_BEARERS) {
263
- for (const [k, t] of accepted)
264
- if (t <= now)
339
+ for (const [k, e] of accepted)
340
+ if (e.until <= now)
265
341
  accepted.delete(k);
266
342
  // Still full: drop the oldest, which Map iteration yields first.
267
343
  if (accepted.size >= MAX_ACCEPTED_BEARERS) {
268
344
  accepted.delete(accepted.keys().next().value);
269
345
  }
270
346
  }
271
- accepted.set(id, now + checkTtl);
272
- return 'ok';
347
+ const account = accountKey(id, confirmed);
348
+ const workspace = workspaceKey(account, workspaceId);
349
+ accepted.set(id, { until: now + checkTtl, account, workspace });
350
+ noteStanding(account, suspended, seq);
351
+ return { verdict: accountSuspended(account) ? 'suspended' : 'ok', account, workspace };
273
352
  };
274
353
  // Refused initializes per source, in fixed one-minute windows. Kept apart
275
354
  // from the session cap so a flood of invented tokens is turned away before
@@ -350,6 +429,50 @@ export async function runHttp(cfg) {
350
429
  // the same `sessions.size`, all of them pass, and the cap bounds nothing —
351
430
  // which is the exact memory exhaustion it was put here to stop.
352
431
  let pending = 0;
432
+ // The same reservations again, per bearer digest (hex), for the per-bearer
433
+ // cap and for the same reason: a burst of initializes from one bearer would
434
+ // otherwise all count the same sessions and all pass. An entry is deleted
435
+ // when it reaches zero, so the map holds only bearers mid-initialize.
436
+ const pendingByDigest = new Map();
437
+ // Normalised, because the cap is also arithmetic against the process-wide
438
+ // one: a NaN here (`Number(process.env.X)` with X unset) made every
439
+ // comparison below false and switched off BOTH caps, the 256 backstop
440
+ // included. Anything that is not a number of at least one is the default;
441
+ // Infinity is let through, and leaves only the process-wide cap.
442
+ const perBearer = cfg.maxSessionsPerBearer;
443
+ const maxPerBearer = typeof perBearer === 'number' && perBearer >= 1
444
+ ? Math.floor(perBearer)
445
+ : DEFAULT_MAX_SESSIONS_PER_BEARER;
446
+ // The same reservations per account (OPL-5447): an account may hold any
447
+ // number of bearers, so a burst spread across its keys would otherwise all
448
+ // count the same sessions and all pass the account ceiling.
449
+ const pendingByAccount = new Map();
450
+ // The ceiling no account may pass, live plus pending: a quarter of the pool
451
+ // by default (OPL-5454). Half let two accounts take the whole of it between
452
+ // them, however many others were waiting. Normalised as the per-bearer cap
453
+ // is, and clamped to the pool.
454
+ const perAccount = cfg.maxSessionsPerAccount;
455
+ const maxPerAccount = typeof perAccount === 'number' && perAccount >= 1
456
+ ? Math.min(Math.floor(perAccount), maxSessions)
457
+ : Math.max(1, Math.floor(maxSessions / 4));
458
+ // The same reservations per workspace (OPL-5453), keyed by workspace key and
459
+ // kept only for workspace-scoped bearers, for the same reason again.
460
+ const pendingByWorkspace = new Map();
461
+ // The sub-ceiling no workspace may pass under its account's: a quarter of
462
+ // the account's ceiling by default, the EFFECTIVE one, so raising only the
463
+ // account ceiling raises this with it. Normalised likewise, and clamped to
464
+ // the account ceiling, which holds a workspace whatever this says.
465
+ const perWorkspace = cfg.maxSessionsPerWorkspace;
466
+ const maxPerWorkspace = typeof perWorkspace === 'number' && perWorkspace >= 1
467
+ ? Math.min(Math.floor(perWorkspace), maxPerAccount)
468
+ : Math.max(1, Math.floor(maxPerAccount / 4));
469
+ // The last quarter of the pool, kept for accounts holding less than their
470
+ // share of the rest (OPL-5454; see the initialize's admission). None in a
471
+ // pool under 4. Not configurable. The share is taken of the UNRESERVED part:
472
+ // of the whole pool, a lone account would always hold less than its share
473
+ // and could take the reserve too, which would then protect nobody.
474
+ const poolReserve = Math.floor(maxSessions / 4);
475
+ const unreserved = maxSessions - poolReserve;
353
476
  // Two parsers, chosen by whether this server has already checked who is
354
477
  // asking.
355
478
  //
@@ -587,21 +710,223 @@ export async function runHttp(cfg) {
587
710
  * exists for, a laptop that slept and left the socket half-open, is exactly
588
711
  * the one where `close` never fires to undo the count. The transport and its
589
712
  * fully-registered server would sit on a `maxSessions` slot forever.
713
+ *
714
+ * `heard` says whether this traffic may stamp the session as heard from; it
715
+ * is asked at the start and again at the end. Only `active` is unconditional,
716
+ * so a session with work in flight is never swept whatever `heard` says.
590
717
  */
591
- const beginActivity = (live) => {
718
+ const beginActivity = (live, heard = always) => {
592
719
  live.active++;
593
- live.lastSeen = Date.now();
720
+ if (heard())
721
+ live.lastSeen = Date.now();
594
722
  let held = true;
595
723
  return () => {
596
724
  if (!held)
597
725
  return;
598
726
  held = false;
599
727
  live.active--;
600
- live.lastSeen = Date.now();
728
+ if (heard())
729
+ live.lastSeen = Date.now();
601
730
  };
602
731
  };
603
- const serving = async (live, handle) => {
604
- const release = beginActivity(live);
732
+ /**
733
+ * Whether traffic on this bearer that carries no request may keep its
734
+ * session from idling out (OPL-5455). Hosted, only while the platform's
735
+ * acceptance of the bearer is still cached: a notification, a response or
736
+ * the standing stream never asks the platform, so without this a revoked
737
+ * token's holder could keep its sessions (and its account's ceiling) held
738
+ * for ever by sending nothing but those. Once the acceptance lapses and no
739
+ * request renews it, they idle out after the session TTL. A cache peek, never
740
+ * a probe. Self-hosted: always.
741
+ */
742
+ const heardWhileAccepted = (key) => challenge ? () => isAccepted(key) : always;
743
+ /** The live sessions opened with this bearer, by its digest. */
744
+ const sessionsOf = (keyDigest) => {
745
+ const own = [];
746
+ for (const entry of sessions) {
747
+ const theirs = entry[1].keyDigest;
748
+ if (theirs.length === keyDigest.length && timingSafeEqual(theirs, keyDigest))
749
+ own.push(entry);
750
+ }
751
+ return own;
752
+ };
753
+ /**
754
+ * The live sessions counted against this account, whichever bearer opened
755
+ * them. One whose bearer the platform has refused is included: it holds its
756
+ * transport and its slot in the pool until it is closed, so leaving it out
757
+ * would let an account that revokes its own keys hold more than its
758
+ * ceiling, up to the whole pool.
759
+ */
760
+ const sessionsOfAccount = (account) => {
761
+ const counted = [];
762
+ for (const entry of sessions)
763
+ if (entry[1].account === account)
764
+ counted.push(entry);
765
+ return counted;
766
+ };
767
+ /**
768
+ * The live sessions counted against this workspace key (OPL-5453; see
769
+ * {@link workspaceKey}), whichever bearer opened them, refused ones
770
+ * included for the reason {@link sessionsOfAccount} gives.
771
+ */
772
+ const sessionsOfWorkspace = (workspace) => {
773
+ const counted = [];
774
+ for (const entry of sessions)
775
+ if (entry[1].workspace === workspace)
776
+ counted.push(entry);
777
+ return counted;
778
+ };
779
+ /**
780
+ * The sessions to close so that one more fits under this bearer's cap, its
781
+ * account's ceiling (OPL-5447) and, for a workspace-scoped bearer, its
782
+ * workspace's sub-ceiling (OPL-5453), or which of them it cannot fit under:
783
+ * `bearer`, then `workspace`, then `account`, when more than one cannot be
784
+ * met.
785
+ *
786
+ * Only idle sessions (`active === 0`, what the sweeper closes) are
787
+ * candidates, and of two kinds. The bearer's own, which make room under
788
+ * whichever limit binds: its own cap, as before, or its account's ceiling.
789
+ * And the account's sessions of its other bearers that the platform has
790
+ * refused: nothing more is served on those, yet they count against the
791
+ * ceiling until closed, so without this a revoked key would hold its
792
+ * account's other keys out until the sweep. Refused ones go first, then the
793
+ * least recently seen. A live session of another bearer is never a
794
+ * candidate, and neither is anything of another account; an own session
795
+ * counts toward the ceiling's need only if it counts against this account,
796
+ * and any candidate toward the workspace's only if it counts against this
797
+ * workspace. A candidate is taken only if it meets a need that is still
798
+ * unmet.
799
+ *
800
+ * `suspended` (hosted mode, the account's standing, OPL-5452) lowers both
801
+ * limits to one, and makes every idle session of the account a candidate,
802
+ * whichever bearer opened it: a suspended account holds one session in all,
803
+ * and the bearer asking now (a refreshed token, say) may take it over while
804
+ * it is idle. One with a request in flight is still never a candidate. The
805
+ * workspace sub-ceiling is not applied then: the account's one session is
806
+ * below any sub-ceiling, and applying it could close a second session that
807
+ * the account's one-session limit does not need closed.
808
+ *
809
+ * `workspace` is the requester's workspace key, undefined when its bearer
810
+ * is not workspace-scoped. `pendingBearer`/`pendingWorkspace`/
811
+ * `pendingAccount` are initializes admitted and not yet landed; at landing
812
+ * all are 0 (see `onsessioninitialized`). The result names sessions, so
813
+ * nothing that outlives the call may keep it.
814
+ */
815
+ const planRoom = (keyDigest, account, workspace, suspended, pendingBearer, pendingWorkspace, pendingAccount) => {
816
+ const bearerCap = suspended ? MAX_SESSIONS_SUSPENDED : maxPerBearer;
817
+ const accountCap = suspended ? MAX_SESSIONS_SUSPENDED : maxPerAccount;
818
+ const own = sessionsOf(keyDigest);
819
+ const counted = sessionsOfAccount(account);
820
+ let bearerNeed = own.length + pendingBearer - bearerCap + 1;
821
+ let accountNeed = counted.length + pendingAccount - accountCap + 1;
822
+ let workspaceNeed = workspace !== undefined && !suspended
823
+ ? sessionsOfWorkspace(workspace).length + pendingWorkspace - maxPerWorkspace + 1
824
+ : 0;
825
+ const victims = [];
826
+ if (bearerNeed <= 0 && accountNeed <= 0 && workspaceNeed <= 0)
827
+ return { victims };
828
+ const mine = new Set(own.map(([id]) => id));
829
+ const candidates = [
830
+ ...own,
831
+ ...counted.filter(([id, live]) => !mine.has(id) && (live.refused || suspended)),
832
+ ]
833
+ .filter(([, live]) => live.active === 0)
834
+ .sort(closingOrder);
835
+ for (const entry of candidates) {
836
+ if (bearerNeed <= 0 && accountNeed <= 0 && workspaceNeed <= 0)
837
+ break;
838
+ const ofBearer = mine.has(entry[0]);
839
+ const ofAccount = entry[1].account === account;
840
+ const ofWorkspace = workspace !== undefined && entry[1].workspace === workspace;
841
+ if (!(ofBearer && bearerNeed > 0) &&
842
+ !(ofAccount && accountNeed > 0) &&
843
+ !(ofWorkspace && workspaceNeed > 0)) {
844
+ continue;
845
+ }
846
+ victims.push(entry);
847
+ if (ofBearer)
848
+ bearerNeed--;
849
+ if (ofAccount)
850
+ accountNeed--;
851
+ if (ofWorkspace)
852
+ workspaceNeed--;
853
+ }
854
+ if (bearerNeed > 0)
855
+ return { full: 'bearer', holds: own.length };
856
+ if (workspaceNeed > 0)
857
+ return { full: 'workspace' };
858
+ if (accountNeed > 0)
859
+ return { full: 'account' };
860
+ return { victims };
861
+ };
862
+ /**
863
+ * Hold a suspended account to one session in all (OPL-5452): close its idle
864
+ * sessions, whichever of its bearers opened them, in {@link closingOrder},
865
+ * until it holds one or none idle is left. Never `keep` (the session being
866
+ * served), and never one with a request in flight, which goes once it is
867
+ * idle, at the next of these. Does nothing unless the account's standing
868
+ * says suspended, so a self-hosted server, which has none, is untouched.
869
+ *
870
+ * A request whose body is still arriving has not reached its session yet,
871
+ * so its session can look idle here; for a suspended account that costs
872
+ * nothing, since the platform refuses every call it could make but whoami.
873
+ */
874
+ const enforceAccountSuspension = (account, keep) => {
875
+ if (!accountSuspended(account))
876
+ return;
877
+ const counted = sessionsOfAccount(account);
878
+ const excess = counted.length - MAX_SESSIONS_SUSPENDED;
879
+ if (excess <= 0)
880
+ return;
881
+ const idle = counted.filter(([id, live]) => id !== keep && live.active === 0);
882
+ idle.sort(closingOrder);
883
+ for (const [id, gone] of idle.slice(0, excess)) {
884
+ sessions.delete(id);
885
+ void gone.transport.close().catch(() => { });
886
+ }
887
+ };
888
+ /**
889
+ * {@link enforceAccountSuspension} for a session served under its own
890
+ * bearer, whose account the platform may have named only since the session
891
+ * was admitted: `bearerAccount` is what the bearer counts against NOW (its
892
+ * probe's or cached acceptance's account). A session is counted against the
893
+ * account key it was admitted under and is never re-keyed, so one admitted
894
+ * while whoami named no account sits in a `bearer:` bucket that holds no
895
+ * standing. When the bearer's account now differs from that and is
896
+ * suspended, the bearer's own idle sessions go down to one as well, on the
897
+ * account's standing as it is now: a newer-started probe that found the
898
+ * account active leaves them be.
899
+ */
900
+ const enforceSuspensionFor = (live, bearerAccount, keep) => {
901
+ if (bearerAccount !== undefined &&
902
+ bearerAccount !== live.account &&
903
+ accountSuspended(bearerAccount)) {
904
+ trimIdle(live.keyDigest, MAX_SESSIONS_SUSPENDED, keep);
905
+ }
906
+ enforceAccountSuspension(live.account, keep);
907
+ };
908
+ /**
909
+ * Close this bearer's idle sessions, least recently seen first, until it
910
+ * holds no more than `cap` or none idle is left. Never `keep`, and never one
911
+ * with a request in flight: the same test the sweeper and an initialize's
912
+ * room-making apply. Only a cap that has fallen needs this — an account
913
+ * suspended after its bearer opened sessions (OPL-5448) — since an
914
+ * initialize never admits a session over the cap it is checked against.
915
+ */
916
+ const trimIdle = (keyDigest, cap, keep) => {
917
+ const own = sessionsOf(keyDigest);
918
+ const excess = own.length - cap;
919
+ if (excess <= 0)
920
+ return;
921
+ const idle = own.filter(([id, live]) => id !== keep && live.active === 0);
922
+ idle.sort((a, b) => a[1].lastSeen - b[1].lastSeen);
923
+ for (const [id, gone] of idle.slice(0, excess)) {
924
+ sessions.delete(id);
925
+ void gone.transport.close().catch(() => { });
926
+ }
927
+ };
928
+ const serving = async (live, handle, heard = always) => {
929
+ const release = beginActivity(live, heard);
605
930
  try {
606
931
  return await handle();
607
932
  }
@@ -613,11 +938,16 @@ export async function runHttp(cfg) {
613
938
  release();
614
939
  }
615
940
  };
616
- /** Stamp a session as heard from, without claiming anything is in flight. */
617
- const touch = (live, res) => {
618
- live.lastSeen = Date.now();
619
- res.on('close', () => {
941
+ /**
942
+ * Stamp a session as heard from, without claiming anything is in flight, on
943
+ * open and on close, each only when `heard` allows it (see `beginActivity`).
944
+ */
945
+ const touch = (live, res, heard) => {
946
+ if (heard())
620
947
  live.lastSeen = Date.now();
948
+ res.on('close', () => {
949
+ if (heard())
950
+ live.lastSeen = Date.now();
621
951
  });
622
952
  };
623
953
  /**
@@ -736,6 +1066,7 @@ export async function runHttp(cfg) {
736
1066
  return challenged(res, NO_TOKEN, rpcId(req));
737
1067
  }
738
1068
  if (sessionId) {
1069
+ let unpin = () => { };
739
1070
  try {
740
1071
  const live = sessions.get(sessionId);
741
1072
  if (!live)
@@ -758,24 +1089,67 @@ export async function runHttp(cfg) {
758
1089
  }
759
1090
  return unauthorized(res, 'This session belongs to a different API key.', rpcId(req));
760
1091
  }
1092
+ // In flight from here, not only once it is dispatched: the bearer
1093
+ // check below awaits the platform, for up to BEARER_CHECK_TIMEOUT_MS on
1094
+ // a cache miss, and an initialize on the same bearer at its cap closes
1095
+ // an idle session to make room. Counted as busy through that wait, this
1096
+ // one is never the session closed under a request already routed to it
1097
+ // (OPL-5443). Only `active` is raised: a request refused below has not
1098
+ // been served, and does not stamp the session as heard from.
1099
+ live.active++;
1100
+ let pinned = true;
1101
+ unpin = () => {
1102
+ if (!pinned)
1103
+ return;
1104
+ pinned = false;
1105
+ live.active--;
1106
+ };
761
1107
  if (challenge && live.refused)
762
- return challenged(res, TOKEN_REFUSED, rpcId(req));
1108
+ return challenged(res, refusedMessage(key), rpcId(req));
763
1109
  // Checked before a request is dispatched, while the answer can still
764
1110
  // be a 401 whatever the call goes on to do. A platform that cannot say
765
1111
  // is not a refusal: the call goes ahead, and a real refusal during it
766
1112
  // still reaches the client through the held answer or the mark.
767
- if (challenge && carriesRequest(req.body) && (await checkBearer(key)) === 'refused') {
1113
+ const request = carriesRequest(req.body);
1114
+ const checked = challenge && request ? await checkBearer(key) : undefined;
1115
+ const verdict = checked?.verdict;
1116
+ if (verdict === 'refused') {
768
1117
  live.refused = true;
769
- return challenged(res, TOKEN_REFUSED, rpcId(req));
1118
+ return challenged(res, refusedMessage(key), rpcId(req));
1119
+ }
1120
+ // An account suspended after its bearers opened sessions kept every
1121
+ // one of them, while an initialize on it was held to one (OPL-5448).
1122
+ // Its other idle sessions go now, whichever of its bearers opened
1123
+ // them (OPL-5452), down to this one: it is pinned above, and one busy
1124
+ // elsewhere is skipped and closed at a later one of these once idle.
1125
+ // What decides is the account's standing as it is now, not the
1126
+ // verdict above: a newer-started probe of another bearer may have
1127
+ // found the account reinstated while this one awaited its own.
1128
+ // A request has just had its bearer's standing confirmed (or found it
1129
+ // cached). Anything else — a notification, a response — asks the
1130
+ // platform nothing, so it acts on the account's standing only while
1131
+ // its bearer's acceptance is cached: the standing is then no older
1132
+ // than that, and never an answer from long before a reinstatement.
1133
+ const confirmed = request ? verdict === 'ok' || verdict === 'suspended' : isAccepted(key);
1134
+ if (challenge && confirmed) {
1135
+ enforceSuspensionFor(live, request ? checked?.account : acceptance(key)?.account, sessionId);
770
1136
  }
771
1137
  const lease = res.locals.largeBodyLease;
772
1138
  const id = rpcId(req);
773
1139
  const held = challenge
774
- ? holdAnswer(res, () => challenged(res, TOKEN_REFUSED, id))
1140
+ ? holdAnswer(res, () => challenged(res, refusedMessage(key), id))
775
1141
  : undefined;
776
- return await heldAnswer.run(held, () => requestBodyLease.run(lease, () => serving(live, () => live.transport.handleRequest(req, res, req.body))));
1142
+ return await heldAnswer.run(held, () => requestBodyLease.run(lease, () => serving(live, () => live.transport.handleRequest(req, res, req.body),
1143
+ // A request keeps the session as before: it was just put to
1144
+ // the platform, or found its acceptance cached. Anything else
1145
+ // keeps it only while that acceptance lasts.
1146
+ request ? always : heardWhileAccepted(key))));
777
1147
  }
778
1148
  finally {
1149
+ // serving() held its own lease across handleRequest, and a tool
1150
+ // callback that outlives it holds another through activity(), so the
1151
+ // pin can go once the route is done with the request.
1152
+ unpin();
779
1153
  // A verified session is the only request allowed through the large
780
1154
  // parser. Release the route's ownership here; a tool callback that
781
1155
  // outlives handleRequest retains its own reference through activity().
@@ -807,6 +1181,8 @@ export async function runHttp(cfg) {
807
1181
  return rpcError(res, 403, -32000, refused, rpcId(req));
808
1182
  // Hosted: no session for a bearer the platform does not accept. Before the
809
1183
  // cap and the reservation, so a refused bearer never holds a slot.
1184
+ let checkedAccount;
1185
+ let checkedWorkspace;
810
1186
  if (challenge) {
811
1187
  // A spent budget does not close the address. Many clients can share one
812
1188
  // (a NAT, an office), and one bad neighbour must not lock out the rest:
@@ -827,7 +1203,7 @@ export async function runHttp(cfg) {
827
1203
  // The interval is spent only by a probe that starts: a request turned
828
1204
  // away because the process-wide cap is full reached nobody, and must not
829
1205
  // make the next client at this address wait out an interval for it.
830
- const verdict = await checkBearer(key, spent
1206
+ const { verdict, account, workspace } = await checkBearer(key, spent
831
1207
  ? () => {
832
1208
  const entry = failures.get(sourceOf(req));
833
1209
  if (entry)
@@ -836,33 +1212,155 @@ export async function runHttp(cfg) {
836
1212
  : undefined);
837
1213
  if (verdict === 'refused') {
838
1214
  noteFailure(req);
839
- return challenged(res, TOKEN_REFUSED, rpcId(req));
1215
+ return challenged(res, refusedMessage(key), rpcId(req));
840
1216
  }
841
1217
  // Not an initialize, it is a 400 whatever the platform could say about
842
1218
  // the key short of refusing it.
843
1219
  if (verdict === 'unknown' && initialize) {
844
1220
  return unavailable(res, 'The platform could not confirm this token just now. Retry shortly.', rpcId(req));
845
1221
  }
1222
+ checkedAccount = account;
1223
+ checkedWorkspace = workspace;
846
1224
  }
847
1225
  if (!initialize) {
848
1226
  return badRequest(res, 'No session id, and this is not an initialize request.', rpcId(req));
849
1227
  }
1228
+ // One bearer may not hold the whole pool (OPL-5443), nor one account more
1229
+ // than its ceiling however many bearers it holds (OPL-5447). Both counted
1230
+ // before the process-wide cap, so a bearer that can make room by closing
1231
+ // one of its idle sessions frees a process-wide slot as well. From the
1232
+ // count to the reservation below is synchronous, so a burst cannot all
1233
+ // read the same count.
1234
+ const keyDigest = digest(key);
1235
+ const keyId = keyDigest.toString('hex');
1236
+ // Hosted, the account the platform confirmed for this bearer (or the
1237
+ // bearer itself, when whoami named none); self-hosted, the bearer.
1238
+ const account = checkedAccount ?? accountKey(keyId, undefined);
1239
+ // Hosted, the workspace key when the platform named a workspace for the
1240
+ // bearer's account (OPL-5453); otherwise none, and no sub-ceiling.
1241
+ const workspace = checkedAccount !== undefined ? checkedWorkspace : undefined;
1242
+ // The account's standing, read after the check above (OPL-5452): what the
1243
+ // newest-started probe of ANY of its bearers said, not only this one's.
1244
+ // Self-hosted there is none, and nothing is suspended.
1245
+ const suspended = accountSuspended(account);
1246
+ const bearerCap = suspended ? MAX_SESSIONS_SUSPENDED : maxPerBearer;
1247
+ // A bearer, or a suspended account, can hold more than its limit only when
1248
+ // the limit fell under it: an account suspended after it opened sessions
1249
+ // (OPL-5448, OPL-5452). Those over it are closed first if idle, whatever
1250
+ // becomes of this initialize, so what is left to count, and to name in a
1251
+ // 429, is what it really holds.
1252
+ enforceAccountSuspension(account);
1253
+ trimIdle(keyDigest, bearerCap);
1254
+ const tooMany = (message) => {
1255
+ res.set('Retry-After', String(BEARER_FULL_RETRY_AFTER_S));
1256
+ return rpcError(res, 429, -32002, message, rpcId(req));
1257
+ };
1258
+ // Room is made from this bearer's own IDLE sessions, least recently seen
1259
+ // first, exactly as the sweeper would close them, under whichever limit
1260
+ // binds: its own cap or its account's ceiling. Never another bearer's live
1261
+ // session, and never one with a request in flight: past the ceiling an
1262
+ // initialize is refused, since the account's holder is the one who knows
1263
+ // which of its clients can spare a session. Two exceptions (see
1264
+ // `planRoom`): an idle session of the account's whose bearer the platform
1265
+ // refused, and, while the account is suspended, any idle session of the
1266
+ // account's, since it holds one in all (OPL-5452). A client whose old
1267
+ // session is gone gets 404 for it and
1268
+ // initializes again, as MCP clients do. Decided here, so a caller with
1269
+ // nothing idle to give back is refused at once; carried out only once the
1270
+ // new session exists (in `onsessioninitialized`), so an initialize the SDK
1271
+ // then refuses (406, 415, 400) or that throws on the way does not also
1272
+ // cost the caller a working session. Only the number is kept past here,
1273
+ // and, for the pool reserve's count below, the ids, which do not leave
1274
+ // this synchronous block.
1275
+ let planned = 0;
1276
+ let plannedIds;
1277
+ {
1278
+ const room = planRoom(keyDigest, account, workspace, suspended, pendingByDigest.get(keyId) ?? 0, workspace !== undefined ? (pendingByWorkspace.get(workspace) ?? 0) : 0, pendingByAccount.get(account) ?? 0);
1279
+ if ('full' in room) {
1280
+ if (room.full === 'account') {
1281
+ return tooMany(suspended ? SUSPENDED_ACCOUNT_FULL : accountFullMessage(account, maxPerAccount));
1282
+ }
1283
+ if (room.full === 'workspace')
1284
+ return tooMany(workspaceFullMessage(maxPerWorkspace));
1285
+ // Over the cap after the trim means every session over it is busy,
1286
+ // and closing one would not be enough: the client can only wait.
1287
+ return tooMany(room.holds > bearerCap
1288
+ ? `This token holds ${room.holds} sessions on this server, over its maximum of ${bearerCap}, and the ones over it are all serving a request. Retry once they finish; idle ones are closed to make room.`
1289
+ : `This token already holds its maximum of ${bearerCap} ${bearerCap === 1 ? 'session' : 'sessions'} on this server. Close one (DELETE /mcp) or retry shortly.`);
1290
+ }
1291
+ planned = room.victims.length;
1292
+ plannedIds = new Set(room.victims.map(([id]) => id));
1293
+ }
850
1294
  // Swept sessions free their slot on the timer; this is the backstop for the
851
1295
  // case the timer cannot help with, which is arrivals faster than the TTL.
852
- if (sessions.size + pending >= maxSessions) {
1296
+ // The sessions this initialize will close are still in the map, and are not
1297
+ // counted against it: a bearer making room in its own share is not newly
1298
+ // refused for the room it is about to give back. Nothing is closed to make
1299
+ // room here, whoever holds the pool.
1300
+ if (sessions.size - planned + pending >= maxSessions) {
1301
+ res.set('Retry-After', String(POOL_FULL_RETRY_AFTER_S));
853
1302
  return unavailable(res, `This server is holding its maximum of ${maxSessions} sessions. Retry shortly.`, rpcId(req));
854
1303
  }
1304
+ // The last quarter of the pool is kept for accounts holding less than
1305
+ // their share (OPL-5454): without it the ceilings alone let four accounts
1306
+ // take every session between them, and the fifth was refused until one
1307
+ // gave some back. Counted as the check above counts, pending included, so
1308
+ // a burst cannot all read the same count. The share is of the unreserved
1309
+ // part, divided among the accounts holding or opening sessions, this one
1310
+ // included. Checked at admission only, as the pool is; nothing is closed
1311
+ // for it.
1312
+ const inUse = sessions.size - planned + pending;
1313
+ if (poolReserve > 0 && inUse >= unreserved) {
1314
+ const holders = new Set([account, ...pendingByAccount.keys()]);
1315
+ let held = pendingByAccount.get(account) ?? 0;
1316
+ for (const [id, live] of sessions) {
1317
+ if (plannedIds.has(id))
1318
+ continue;
1319
+ holders.add(live.account);
1320
+ if (live.account === account)
1321
+ held++;
1322
+ }
1323
+ const fairShare = Math.max(1, Math.floor(unreserved / holders.size));
1324
+ if (held >= fairShare) {
1325
+ res.set('Retry-After', String(POOL_FULL_RETRY_AFTER_S));
1326
+ return unavailable(res, `This server keeps its last ${poolReserve} sessions for accounts holding less than their share of it, and this account holds its share. Retry shortly.`, rpcId(req));
1327
+ }
1328
+ }
855
1329
  pending++;
1330
+ pendingByDigest.set(keyId, (pendingByDigest.get(keyId) ?? 0) + 1);
1331
+ pendingByAccount.set(account, (pendingByAccount.get(account) ?? 0) + 1);
1332
+ if (workspace !== undefined) {
1333
+ pendingByWorkspace.set(workspace, (pendingByWorkspace.get(workspace) ?? 0) + 1);
1334
+ }
856
1335
  // Released exactly once, whether the initialize lands in the map or throws
857
1336
  // on the way there. A reservation that leaked on the failure path would
858
1337
  // ratchet the cap down until the process restarted — every later initialize
859
1338
  // refused with 503 for the life of the process, which is the denial of
860
- // service the counter was added to prevent, self-inflicted.
1339
+ // service the counter was added to prevent, self-inflicted. The per-bearer,
1340
+ // per-account and per-workspace counts leaking would do the same to that
1341
+ // bearer, account and workspace.
861
1342
  let reserved = true;
862
1343
  const release = () => {
863
1344
  if (reserved) {
864
1345
  reserved = false;
865
1346
  pending--;
1347
+ const left = (pendingByDigest.get(keyId) ?? 1) - 1;
1348
+ if (left > 0)
1349
+ pendingByDigest.set(keyId, left);
1350
+ else
1351
+ pendingByDigest.delete(keyId);
1352
+ const accountLeft = (pendingByAccount.get(account) ?? 1) - 1;
1353
+ if (accountLeft > 0)
1354
+ pendingByAccount.set(account, accountLeft);
1355
+ else
1356
+ pendingByAccount.delete(account);
1357
+ if (workspace !== undefined) {
1358
+ const workspaceLeft = (pendingByWorkspace.get(workspace) ?? 1) - 1;
1359
+ if (workspaceLeft > 0)
1360
+ pendingByWorkspace.set(workspace, workspaceLeft);
1361
+ else
1362
+ pendingByWorkspace.delete(workspace);
1363
+ }
866
1364
  }
867
1365
  };
868
1366
  // Everything from here to the map write is inside the reservation,
@@ -875,6 +1373,9 @@ export async function runHttp(cfg) {
875
1373
  let transport;
876
1374
  let mcp;
877
1375
  let finishInitialize = () => { };
1376
+ // Set when the session was dropped at birth for want of room (see
1377
+ // `onsessioninitialized`): it has an id but no map slot.
1378
+ let dropped = false;
878
1379
  try {
879
1380
  const t = new StreamableHTTPServerTransport({
880
1381
  sessionIdGenerator: () => randomUUID(),
@@ -889,9 +1390,43 @@ export async function runHttp(cfg) {
889
1390
  allowedHosts: hosts,
890
1391
  allowedOrigins,
891
1392
  onsessioninitialized: (id) => {
1393
+ // The room the check above planned, made now that there is a session
1394
+ // to make it for. Chosen again from what is here NOW: a session
1395
+ // closed or put to work since then is not a candidate. Should too
1396
+ // few still be idle, nothing is closed and this session is dropped
1397
+ // instead of admitted over the cap. The transport closes before the
1398
+ // SDK answers, so the SDK answers this initialize itself, with its
1399
+ // 404 -32001 'Session not found' and no Retry-After — not the 429
1400
+ // the refusal above gives, which cannot be written from here. An
1401
+ // SDK client surfaces that as a failed connect; it does not retry
1402
+ // on its own. Only a race reaches this: a request landing on a
1403
+ // planned session between the check above and this callback.
1404
+ //
1405
+ // The account's ceiling is re-checked here too (OPL-5447), and the
1406
+ // workspace's sub-ceiling (OPL-5453), against
1407
+ // the map alone. Initializes still pending were each admitted
1408
+ // counting this one's reservation, and the room they will make is
1409
+ // still in the map, so counting them again here would drop a session
1410
+ // that fits; what keeps a burst under the ceiling is the pending
1411
+ // count at admission. Every landing leaves the map within both.
1412
+ // The account's standing is read again: a probe that answered in
1413
+ // between is the newer word on whether it is suspended.
1414
+ const room = planRoom(keyDigest, account, workspace, accountSuspended(account), 0, 0, 0);
1415
+ if ('full' in room) {
1416
+ dropped = true;
1417
+ release();
1418
+ void t.close().catch(() => { });
1419
+ return;
1420
+ }
1421
+ for (const [other, gone] of room.victims) {
1422
+ sessions.delete(other);
1423
+ void gone.transport.close().catch(() => { });
1424
+ }
892
1425
  const live = {
893
1426
  transport: t,
894
- keyDigest: digest(key),
1427
+ keyDigest,
1428
+ account,
1429
+ workspace,
895
1430
  lastSeen: Date.now(),
896
1431
  // Initialize is already in flight when the session first becomes
897
1432
  // visible to the sweeper. Count it until handleRequest settles.
@@ -961,10 +1496,11 @@ export async function runHttp(cfg) {
961
1496
  mcp = server;
962
1497
  await server.connect(t);
963
1498
  const handled = await t.handleRequest(req, res, req.body);
964
- // Any initialize that never reached `onsessioninitialized` has no map
965
- // slot and nothing the sweeper will reap. The SDK's 403 used to take
966
- // this path without throwing; other early returns still can.
967
- if (!t.sessionId) {
1499
+ // Any initialize that never reached `onsessioninitialized`, or was
1500
+ // dropped there, has no map slot and nothing the sweeper will reap. The
1501
+ // SDK's 403 used to take this path without throwing; other early returns
1502
+ // still can.
1503
+ if (!t.sessionId || dropped) {
968
1504
  void server.close().catch(() => { });
969
1505
  void t.close().catch(() => { });
970
1506
  }
@@ -1009,14 +1545,28 @@ export async function runHttp(cfg) {
1009
1545
  // The stream is refused on a token the platform has already refused; a
1010
1546
  // DELETE is still honoured, since the digest shows it is the holder.
1011
1547
  if (challenge && live.refused && req.method === 'GET')
1012
- return challenged(res, TOKEN_REFUSED);
1013
- // The DELETE is a request and is held for; the GET is the notification
1014
- // stream and is only noted. See `serving`.
1548
+ return challenged(res, refusedMessage(key));
1549
+ // A suspended account is held to one session on its stream too, as on a
1550
+ // notification: the stream asks the platform nothing, so only while this
1551
+ // bearer's acceptance is cached (OPL-5452). This session is the one kept.
1552
+ if (challenge && req.method !== 'DELETE' && isAccepted(key)) {
1553
+ enforceSuspensionFor(live, acceptance(key)?.account, sessionId);
1554
+ }
1555
+ // The GET is the notification stream and is only noted; anything else
1556
+ // here (a DELETE, or a HEAD, which Express routes to the GET handler with
1557
+ // the method unchanged) is held for while the SDK handles it. See
1558
+ // `serving`. None of it is put to the platform, so none of it may keep the
1559
+ // session from idling out once the token's acceptance lapses: a HEAD is
1560
+ // answered 405 and a DELETE the SDK refuses (an unsupported protocol
1561
+ // version, say) 400, both leaving the session open, and a revoked token's
1562
+ // holder could otherwise send either for ever to keep its sessions, and
1563
+ // its account's ceiling, held (OPL-5455). A DELETE that succeeds closes
1564
+ // the session anyway, and `active` is counted whatever `heard` says.
1015
1565
  if (req.method === 'GET') {
1016
- touch(live, res);
1566
+ touch(live, res, heardWhileAccepted(key));
1017
1567
  return live.transport.handleRequest(req, res);
1018
1568
  }
1019
- return serving(live, () => live.transport.handleRequest(req, res));
1569
+ return serving(live, () => live.transport.handleRequest(req, res), heardWhileAccepted(key));
1020
1570
  };
1021
1571
  app.get('/mcp', bySession);
1022
1572
  app.delete('/mcp', bySession);
@@ -1161,6 +1711,101 @@ const BEARER_CHECK_TIMEOUT_MS = 10_000;
1161
1711
  const MAX_BEARER_CHECKS_IN_FLIGHT = 32;
1162
1712
  const MAX_ACCEPTED_BEARERS = 4096;
1163
1713
  const MAX_FAILURE_SOURCES = 10_000;
1714
+ /**
1715
+ * Whether a whoami record says its account is suspended.
1716
+ *
1717
+ * Only an explicit `account.status` of `"suspended"` counts. A record this
1718
+ * cannot read is not evidence of suspension, and treating it as such would hold
1719
+ * a working account to one session on the strength of a shape change.
1720
+ */
1721
+ function isSuspended(who) {
1722
+ if (typeof who !== 'object' || who === null)
1723
+ return false;
1724
+ const account = who.account;
1725
+ if (typeof account !== 'object' || account === null)
1726
+ return false;
1727
+ return account.status === 'suspended';
1728
+ }
1729
+ /**
1730
+ * The account id a whoami record names: a non-empty string of at most 256
1731
+ * characters. Anything else is no account, and the bearer then counts as an
1732
+ * account of its own rather than as one this server could not confirm.
1733
+ */
1734
+ function accountIdOf(who) {
1735
+ if (typeof who !== 'object' || who === null)
1736
+ return undefined;
1737
+ const account = who.account;
1738
+ if (typeof account !== 'object' || account === null)
1739
+ return undefined;
1740
+ const id = account.id;
1741
+ return typeof id === 'string' && id.length > 0 && id.length <= 256 ? id : undefined;
1742
+ }
1743
+ /**
1744
+ * The workspace id a whoami record names for a workspace-scoped bearer
1745
+ * (OPL-5453): `workspace.id`, a non-empty string of at most 256 characters.
1746
+ * Anything else, `workspace: null` (a key of the account as a whole)
1747
+ * included, is no workspace, and the bearer is held by no sub-ceiling.
1748
+ */
1749
+ function workspaceIdOf(who) {
1750
+ if (typeof who !== 'object' || who === null)
1751
+ return undefined;
1752
+ const workspace = who.workspace;
1753
+ if (typeof workspace !== 'object' || workspace === null)
1754
+ return undefined;
1755
+ const id = workspace.id;
1756
+ return typeof id === 'string' && id.length > 0 && id.length <= 256 ? id : undefined;
1757
+ }
1758
+ /**
1759
+ * What a session counts against for the account ceiling (OPL-5447).
1760
+ *
1761
+ * The account id the platform confirmed for the bearer when there is one
1762
+ * (hosted mode), and otherwise the bearer's own digest: a hosted whoami that
1763
+ * names no account, and every bearer on a self-hosted server, which verifies
1764
+ * none and so puts no trust in a string it cannot check. Prefixed so the two
1765
+ * can never name the same thing.
1766
+ */
1767
+ function accountKey(keyId, confirmed) {
1768
+ return confirmed !== undefined ? `account:${confirmed}` : `bearer:${keyId}`;
1769
+ }
1770
+ /**
1771
+ * The 429 for a suspended account with nothing idle to give up (OPL-5452):
1772
+ * its session is serving a request, or another initialize of its is in
1773
+ * flight. An idle one would have been closed to make room.
1774
+ */
1775
+ const SUSPENDED_ACCOUNT_FULL = 'This account is suspended, so it may hold only 1 session on this server across all its tokens, and none it holds is idle. Retry once its session finishes its request, or close it (DELETE /mcp).';
1776
+ /**
1777
+ * The workspace key a workspace-scoped bearer counts against for the workspace
1778
+ * sub-ceiling (OPL-5453), or undefined when there is none: its account key and
1779
+ * the workspace id together, so the same workspace id under two accounts can
1780
+ * never share a count. Only an account the platform confirmed (`account:`)
1781
+ * has workspaces; a `bearer:` account never does.
1782
+ */
1783
+ function workspaceKey(account, workspaceId) {
1784
+ return account.startsWith('account:') && workspaceId !== undefined
1785
+ ? `${account}/workspace:${workspaceId}`
1786
+ : undefined;
1787
+ }
1788
+ /** The 429 for a workspace at its sub-ceiling. It names the limit and nothing else. */
1789
+ function workspaceFullMessage(cap) {
1790
+ const noun = cap === 1 ? 'session' : 'sessions';
1791
+ return `This workspace has reached its maximum of ${cap} ${noun} on this server, across all its tokens. Close one (DELETE /mcp) or retry shortly.`;
1792
+ }
1793
+ /** The 429 for an account at its ceiling. It names the limit and nothing else. */
1794
+ function accountFullMessage(account, cap) {
1795
+ const noun = cap === 1 ? 'session' : 'sessions';
1796
+ return account.startsWith('account:')
1797
+ ? `This account has reached its maximum of ${cap} ${noun} on this server, across all its tokens. Close one (DELETE /mcp) or retry shortly.`
1798
+ : `This token has reached the per-account maximum of ${cap} ${noun} on this server, as an account of its own. Close one (DELETE /mcp) or retry shortly.`;
1799
+ }
1800
+ const always = () => true;
1801
+ /**
1802
+ * The order idle sessions are closed in to make room: those whose bearer the
1803
+ * platform has refused first, since nothing more is served on them, then the
1804
+ * least recently seen.
1805
+ */
1806
+ function closingOrder(a, b) {
1807
+ return Number(b[1].refused) - Number(a[1].refused) || a[1].lastSeen - b[1].lastSeen;
1808
+ }
1164
1809
  /** Whether a POST body holds a JSON-RPC request, which is what opens a stream. */
1165
1810
  function carriesRequest(body) {
1166
1811
  const isRequest = (m) => typeof m === 'object' && m !== null && 'method' in m && 'id' in m;
@@ -1168,6 +1813,17 @@ function carriesRequest(body) {
1168
1813
  }
1169
1814
  const NO_TOKEN = 'Authorization required. Authorize this client with Mandala (OAuth), or send a Mandala API key: Authorization: Bearer …';
1170
1815
  const TOKEN_REFUSED = 'The platform no longer accepts this access token. Refresh it, or authorize again.';
1816
+ const API_KEY_REFUSED = 'The platform does not accept this API key. Check it, or create a new one in Settings → Credentials.';
1817
+ /**
1818
+ * What a refused bearer is told. A `com_…` API key was never an OAuth access
1819
+ * token and cannot be refreshed, so it is not told to refresh (OPL-5464); any
1820
+ * other bearer is an access token and keeps the OAuth wording. Only the message
1821
+ * text differs — the 401 and its challenge are the same — and the bearer itself
1822
+ * is never part of it.
1823
+ */
1824
+ function refusedMessage(key) {
1825
+ return key?.startsWith('com_') ? API_KEY_REFUSED : TOKEN_REFUSED;
1826
+ }
1171
1827
  /**
1172
1828
  * Hold a response's status line until its first byte of body (hosted mode).
1173
1829
  *