mandala-computer-mcp 0.7.0 → 0.8.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 (49) hide show
  1. package/README.md +105 -13
  2. package/dist/cli.d.ts +7 -0
  3. package/dist/cli.d.ts.map +1 -1
  4. package/dist/cli.js +57 -1
  5. package/dist/cli.js.map +1 -1
  6. package/dist/errors.d.ts +32 -8
  7. package/dist/errors.d.ts.map +1 -1
  8. package/dist/errors.js +50 -8
  9. package/dist/errors.js.map +1 -1
  10. package/dist/format.d.ts +8 -2
  11. package/dist/format.d.ts.map +1 -1
  12. package/dist/format.js +10 -3
  13. package/dist/format.js.map +1 -1
  14. package/dist/http.d.ts +32 -0
  15. package/dist/http.d.ts.map +1 -1
  16. package/dist/http.js +425 -52
  17. package/dist/http.js.map +1 -1
  18. package/dist/paths.d.ts +11 -6
  19. package/dist/paths.d.ts.map +1 -1
  20. package/dist/paths.js +14 -7
  21. package/dist/paths.js.map +1 -1
  22. package/dist/server.d.ts +1 -1
  23. package/dist/server.js +1 -1
  24. package/dist/tools/agent.js +12 -23
  25. package/dist/tools/agent.js.map +1 -1
  26. package/dist/tools/chat.d.ts.map +1 -1
  27. package/dist/tools/chat.js +46 -4
  28. package/dist/tools/chat.js.map +1 -1
  29. package/dist/tools/computers.d.ts.map +1 -1
  30. package/dist/tools/computers.js +31 -4
  31. package/dist/tools/computers.js.map +1 -1
  32. package/dist/tools/guest.d.ts.map +1 -1
  33. package/dist/tools/guest.js +21 -2
  34. package/dist/tools/guest.js.map +1 -1
  35. package/dist/tools/operations.d.ts +7 -2
  36. package/dist/tools/operations.d.ts.map +1 -1
  37. package/dist/tools/operations.js +29 -6
  38. package/dist/tools/operations.js.map +1 -1
  39. package/dist/tools/secrets.d.ts.map +1 -1
  40. package/dist/tools/secrets.js +4 -6
  41. package/dist/tools/secrets.js.map +1 -1
  42. package/dist/tools/snapshots.d.ts.map +1 -1
  43. package/dist/tools/snapshots.js +21 -8
  44. package/dist/tools/snapshots.js.map +1 -1
  45. package/dist/tools/templates.js +2 -2
  46. package/dist/tools/templates.js.map +1 -1
  47. package/dist/tools/webhooks.js +1 -1
  48. package/dist/tools/webhooks.js.map +1 -1
  49. 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,26 @@ 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.
82
+ */
83
+ const DEFAULT_MAX_SESSIONS_PER_BEARER = 16;
84
+ const MAX_SESSIONS_SUSPENDED = 1;
85
+ /** How long a caller whose bearer holds its maximum is asked to wait, in s. */
86
+ const BEARER_FULL_RETRY_AFTER_S = 30;
87
+ /** How long a caller is asked to wait when the whole pool is full, in s. */
88
+ const POOL_FULL_RETRY_AFTER_S = 30;
70
89
  const DEFAULT_MAX_LARGE_BODY_PARSES = 4;
71
90
  const SMALL_BODY_BYTES = 256 * 1024;
72
91
  /**
@@ -212,64 +231,81 @@ export async function runHttp(cfg) {
212
231
  const checkTtl = cfg.bearerCheckTtlMs ?? DEFAULT_BEARER_CHECK_TTL_MS;
213
232
  const accepted = new Map();
214
233
  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();
234
+ /** The platform's acceptance of this bearer, while the cache window lasts. */
235
+ const acceptance = (key) => {
236
+ const entry = accepted.get(digest(key).toString('hex'));
237
+ return entry !== undefined && entry.until > Date.now() ? entry : undefined;
219
238
  };
239
+ /** Whether the platform accepted this bearer within the cache window. */
240
+ const isAccepted = (key) => acceptance(key) !== undefined;
220
241
  /**
221
242
  * `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
243
+ * platform authenticated this credential AND let it act. `suspended`: the
244
+ * same 2xx, for an account whose whoami says `status: "suspended"` — admitted
245
+ * as `ok` is, and held to one session. `refused`: a 401.
246
+ * `unknown`: anything else — a 403 (a lost membership, say; a suspended
247
+ * account is answered 2xx on whoami and admitted), a 404 (the route moved), a 429, a 5xx, a timeout, or this
225
248
  * server already asking about as many bearers as it will at once. Only `ok`
226
- * is cached.
249
+ * and `suspended` are cached, the standing and the account id with the
250
+ * acceptance. `account` is the account key the bearer counts against (see
251
+ * {@link accountKey}); it is set whenever the verdict is `ok` or
252
+ * `suspended`, and returned rather than read back from the cache, which a
253
+ * zero TTL or a full cache may already have let go of.
227
254
  */
228
255
  // `onStart` runs only when a probe really starts — never for a cached answer
229
256
  // or a full cap — and synchronously, before the first await, so a caller's
230
257
  // bookkeeping and its check against it cannot interleave with another's.
231
258
  const checkBearer = async (key, onStart) => {
232
259
  const id = digest(key).toString('hex');
233
- if (isAccepted(key))
234
- return 'ok';
260
+ const cached = acceptance(key);
261
+ if (cached) {
262
+ return {
263
+ verdict: cached.suspended ? 'suspended' : 'ok',
264
+ account: accountKey(id, cached.account),
265
+ };
266
+ }
235
267
  if (checksInFlight >= MAX_BEARER_CHECKS_IN_FLIGHT)
236
- return 'unknown';
268
+ return { verdict: 'unknown' };
237
269
  checksInFlight++;
238
270
  onStart?.();
271
+ let suspended;
272
+ let confirmed;
239
273
  try {
240
274
  const api = new Api(key, cfg.baseUrl, AbortSignal.timeout(BEARER_CHECK_TIMEOUT_MS), {
241
275
  serviceSecret,
242
276
  });
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);
277
+ // `GET whoami`, chosen because every valid credential is answered 2xx
278
+ // on it, a suspended account's included. The platform's route table
279
+ // gives it the lowest role there is (viewer) and marks it readable by a
280
+ // suspended account, and the handler answers from the control plane's
281
+ // own tables without asking any host — so no role a grant can carry, no
282
+ // workspace scope, no account standing and no host being down turns it
283
+ // into a refusal. It was `GET ssh-keys`, which a suspended account is
284
+ // refused (403): its holder was answered 503 on every initialize and so
285
+ // could never reach whoami, the one tool that would say it is suspended
286
+ // (OPL-5437). It takes no parameters, so none are sent.
287
+ const who = await api.json('GET', WHOAMI);
288
+ suspended = isSuspended(who);
289
+ confirmed = accountIdOf(who);
254
290
  }
255
291
  catch (err) {
256
- return err instanceof APIError && err.status === 401 ? 'refused' : 'unknown';
292
+ return { verdict: err instanceof APIError && err.status === 401 ? 'refused' : 'unknown' };
257
293
  }
258
294
  finally {
259
295
  checksInFlight--;
260
296
  }
261
297
  const now = Date.now();
262
298
  if (accepted.size >= MAX_ACCEPTED_BEARERS) {
263
- for (const [k, t] of accepted)
264
- if (t <= now)
299
+ for (const [k, e] of accepted)
300
+ if (e.until <= now)
265
301
  accepted.delete(k);
266
302
  // Still full: drop the oldest, which Map iteration yields first.
267
303
  if (accepted.size >= MAX_ACCEPTED_BEARERS) {
268
304
  accepted.delete(accepted.keys().next().value);
269
305
  }
270
306
  }
271
- accepted.set(id, now + checkTtl);
272
- return 'ok';
307
+ accepted.set(id, { until: now + checkTtl, suspended, account: confirmed });
308
+ return { verdict: suspended ? 'suspended' : 'ok', account: accountKey(id, confirmed) };
273
309
  };
274
310
  // Refused initializes per source, in fixed one-minute windows. Kept apart
275
311
  // from the session cap so a flood of invented tokens is turned away before
@@ -350,6 +386,31 @@ export async function runHttp(cfg) {
350
386
  // the same `sessions.size`, all of them pass, and the cap bounds nothing —
351
387
  // which is the exact memory exhaustion it was put here to stop.
352
388
  let pending = 0;
389
+ // The same reservations again, per bearer digest (hex), for the per-bearer
390
+ // cap and for the same reason: a burst of initializes from one bearer would
391
+ // otherwise all count the same sessions and all pass. An entry is deleted
392
+ // when it reaches zero, so the map holds only bearers mid-initialize.
393
+ const pendingByDigest = new Map();
394
+ // Normalised, because the cap is also arithmetic against the process-wide
395
+ // one: a NaN here (`Number(process.env.X)` with X unset) made every
396
+ // comparison below false and switched off BOTH caps, the 256 backstop
397
+ // included. Anything that is not a number of at least one is the default;
398
+ // Infinity is let through, and leaves only the process-wide cap.
399
+ const perBearer = cfg.maxSessionsPerBearer;
400
+ const maxPerBearer = typeof perBearer === 'number' && perBearer >= 1
401
+ ? Math.floor(perBearer)
402
+ : DEFAULT_MAX_SESSIONS_PER_BEARER;
403
+ // The same reservations per account (OPL-5447): an account may hold any
404
+ // number of bearers, so a burst spread across its keys would otherwise all
405
+ // count the same sessions and all pass the account ceiling.
406
+ const pendingByAccount = new Map();
407
+ // The ceiling no account may pass, live plus pending: half the pool by
408
+ // default, so no one tenant can take the whole of it however many keys it
409
+ // holds. Normalised as the per-bearer cap is, and clamped to the pool.
410
+ const perAccount = cfg.maxSessionsPerAccount;
411
+ const maxPerAccount = typeof perAccount === 'number' && perAccount >= 1
412
+ ? Math.min(Math.floor(perAccount), maxSessions)
413
+ : Math.max(1, Math.floor(maxSessions / 2));
353
414
  // Two parsers, chosen by whether this server has already checked who is
354
415
  // asking.
355
416
  //
@@ -587,21 +648,133 @@ export async function runHttp(cfg) {
587
648
  * exists for, a laptop that slept and left the socket half-open, is exactly
588
649
  * the one where `close` never fires to undo the count. The transport and its
589
650
  * fully-registered server would sit on a `maxSessions` slot forever.
651
+ *
652
+ * `heard` says whether this traffic may stamp the session as heard from; it
653
+ * is asked at the start and again at the end. Only `active` is unconditional,
654
+ * so a session with work in flight is never swept whatever `heard` says.
590
655
  */
591
- const beginActivity = (live) => {
656
+ const beginActivity = (live, heard = always) => {
592
657
  live.active++;
593
- live.lastSeen = Date.now();
658
+ if (heard())
659
+ live.lastSeen = Date.now();
594
660
  let held = true;
595
661
  return () => {
596
662
  if (!held)
597
663
  return;
598
664
  held = false;
599
665
  live.active--;
600
- live.lastSeen = Date.now();
666
+ if (heard())
667
+ live.lastSeen = Date.now();
601
668
  };
602
669
  };
603
- const serving = async (live, handle) => {
604
- const release = beginActivity(live);
670
+ /**
671
+ * Whether traffic on this bearer that carries no request may keep its
672
+ * session from idling out (OPL-5455). Hosted, only while the platform's
673
+ * acceptance of the bearer is still cached: a notification, a response or
674
+ * the standing stream never asks the platform, so without this a revoked
675
+ * token's holder could keep its sessions (and its account's ceiling) held
676
+ * for ever by sending nothing but those. Once the acceptance lapses and no
677
+ * request renews it, they idle out after the session TTL. A cache peek, never
678
+ * a probe. Self-hosted: always.
679
+ */
680
+ const heardWhileAccepted = (key) => challenge ? () => isAccepted(key) : always;
681
+ /** The live sessions opened with this bearer, by its digest. */
682
+ const sessionsOf = (keyDigest) => {
683
+ const own = [];
684
+ for (const entry of sessions) {
685
+ const theirs = entry[1].keyDigest;
686
+ if (theirs.length === keyDigest.length && timingSafeEqual(theirs, keyDigest))
687
+ own.push(entry);
688
+ }
689
+ return own;
690
+ };
691
+ /**
692
+ * The live sessions counted against this account, whichever bearer opened
693
+ * them. One whose bearer the platform has refused is included: it holds its
694
+ * transport and its slot in the pool until it is closed, so leaving it out
695
+ * would let an account that revokes its own keys hold more than its
696
+ * ceiling, up to the whole pool.
697
+ */
698
+ const sessionsOfAccount = (account) => {
699
+ const counted = [];
700
+ for (const entry of sessions)
701
+ if (entry[1].account === account)
702
+ counted.push(entry);
703
+ return counted;
704
+ };
705
+ /**
706
+ * The sessions to close so that one more fits under both this bearer's cap
707
+ * and its account's ceiling (OPL-5447), or which of the two it cannot fit
708
+ * under.
709
+ *
710
+ * Only idle sessions (`active === 0`, what the sweeper closes) are
711
+ * candidates, and of two kinds. The bearer's own, which make room under
712
+ * whichever limit binds: its own cap, as before, or its account's ceiling.
713
+ * And the account's sessions of its other bearers that the platform has
714
+ * refused: nothing more is served on those, yet they count against the
715
+ * ceiling until closed, so without this a revoked key would hold its
716
+ * account's other keys out until the sweep. Refused ones go first, then the
717
+ * least recently seen. A live session of another bearer is never a
718
+ * candidate, and neither is anything of another account; an own session
719
+ * counts toward the ceiling's need only if it counts against this account.
720
+ *
721
+ * `pendingBearer`/`pendingAccount` are initializes admitted and not yet
722
+ * landed; at landing both are 0 (see `onsessioninitialized`). The result
723
+ * names sessions, so nothing that outlives the call may keep it.
724
+ */
725
+ const planRoom = (keyDigest, account, bearerCap, pendingBearer, pendingAccount) => {
726
+ const own = sessionsOf(keyDigest);
727
+ const counted = sessionsOfAccount(account);
728
+ let bearerNeed = own.length + pendingBearer - bearerCap + 1;
729
+ let accountNeed = counted.length + pendingAccount - maxPerAccount + 1;
730
+ const victims = [];
731
+ if (bearerNeed <= 0 && accountNeed <= 0)
732
+ return { victims };
733
+ const mine = new Set(own.map(([id]) => id));
734
+ const candidates = [...own, ...counted.filter(([id, live]) => !mine.has(id) && live.refused)]
735
+ .filter(([, live]) => live.active === 0)
736
+ .sort((a, b) => Number(b[1].refused) - Number(a[1].refused) || a[1].lastSeen - b[1].lastSeen);
737
+ for (const entry of candidates) {
738
+ if (bearerNeed <= 0 && accountNeed <= 0)
739
+ break;
740
+ const ofBearer = mine.has(entry[0]);
741
+ const ofAccount = entry[1].account === account;
742
+ if (!(ofBearer && bearerNeed > 0) && !(ofAccount && accountNeed > 0))
743
+ continue;
744
+ victims.push(entry);
745
+ if (ofBearer)
746
+ bearerNeed--;
747
+ if (ofAccount)
748
+ accountNeed--;
749
+ }
750
+ if (bearerNeed > 0)
751
+ return { full: 'bearer', holds: own.length };
752
+ if (accountNeed > 0)
753
+ return { full: 'account' };
754
+ return { victims };
755
+ };
756
+ /**
757
+ * Close this bearer's idle sessions, least recently seen first, until it
758
+ * holds no more than `cap` or none idle is left. Never `keep`, and never one
759
+ * with a request in flight: the same test the sweeper and an initialize's
760
+ * room-making apply. Only a cap that has fallen needs this — an account
761
+ * suspended after its bearer opened sessions (OPL-5448) — since an
762
+ * initialize never admits a session over the cap it is checked against.
763
+ */
764
+ const trimIdle = (keyDigest, cap, keep) => {
765
+ const own = sessionsOf(keyDigest);
766
+ const excess = own.length - cap;
767
+ if (excess <= 0)
768
+ return;
769
+ const idle = own.filter(([id, live]) => id !== keep && live.active === 0);
770
+ idle.sort((a, b) => a[1].lastSeen - b[1].lastSeen);
771
+ for (const [id, gone] of idle.slice(0, excess)) {
772
+ sessions.delete(id);
773
+ void gone.transport.close().catch(() => { });
774
+ }
775
+ };
776
+ const serving = async (live, handle, heard = always) => {
777
+ const release = beginActivity(live, heard);
605
778
  try {
606
779
  return await handle();
607
780
  }
@@ -613,11 +786,16 @@ export async function runHttp(cfg) {
613
786
  release();
614
787
  }
615
788
  };
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', () => {
789
+ /**
790
+ * Stamp a session as heard from, without claiming anything is in flight, on
791
+ * open and on close, each only when `heard` allows it (see `beginActivity`).
792
+ */
793
+ const touch = (live, res, heard) => {
794
+ if (heard())
620
795
  live.lastSeen = Date.now();
796
+ res.on('close', () => {
797
+ if (heard())
798
+ live.lastSeen = Date.now();
621
799
  });
622
800
  };
623
801
  /**
@@ -736,6 +914,7 @@ export async function runHttp(cfg) {
736
914
  return challenged(res, NO_TOKEN, rpcId(req));
737
915
  }
738
916
  if (sessionId) {
917
+ let unpin = () => { };
739
918
  try {
740
919
  const live = sessions.get(sessionId);
741
920
  if (!live)
@@ -758,24 +937,56 @@ export async function runHttp(cfg) {
758
937
  }
759
938
  return unauthorized(res, 'This session belongs to a different API key.', rpcId(req));
760
939
  }
940
+ // In flight from here, not only once it is dispatched: the bearer
941
+ // check below awaits the platform, for up to BEARER_CHECK_TIMEOUT_MS on
942
+ // a cache miss, and an initialize on the same bearer at its cap closes
943
+ // an idle session to make room. Counted as busy through that wait, this
944
+ // one is never the session closed under a request already routed to it
945
+ // (OPL-5443). Only `active` is raised: a request refused below has not
946
+ // been served, and does not stamp the session as heard from.
947
+ live.active++;
948
+ let pinned = true;
949
+ unpin = () => {
950
+ if (!pinned)
951
+ return;
952
+ pinned = false;
953
+ live.active--;
954
+ };
761
955
  if (challenge && live.refused)
762
956
  return challenged(res, TOKEN_REFUSED, rpcId(req));
763
957
  // Checked before a request is dispatched, while the answer can still
764
958
  // be a 401 whatever the call goes on to do. A platform that cannot say
765
959
  // is not a refusal: the call goes ahead, and a real refusal during it
766
960
  // still reaches the client through the held answer or the mark.
767
- if (challenge && carriesRequest(req.body) && (await checkBearer(key)) === 'refused') {
961
+ const request = carriesRequest(req.body);
962
+ const verdict = challenge && request ? (await checkBearer(key)).verdict : undefined;
963
+ if (verdict === 'refused') {
768
964
  live.refused = true;
769
965
  return challenged(res, TOKEN_REFUSED, rpcId(req));
770
966
  }
967
+ // An account suspended after its bearer opened sessions kept every one
968
+ // of them, up to the 16 an active bearer may hold, while an initialize
969
+ // on it was held to one (OPL-5448). Its other idle sessions go now,
970
+ // down to that one; this session is pinned above and one busy
971
+ // elsewhere is skipped, and closed on a later request once it is idle.
972
+ if (verdict === 'suspended')
973
+ trimIdle(live.keyDigest, MAX_SESSIONS_SUSPENDED, sessionId);
771
974
  const lease = res.locals.largeBodyLease;
772
975
  const id = rpcId(req);
773
976
  const held = challenge
774
977
  ? holdAnswer(res, () => challenged(res, TOKEN_REFUSED, id))
775
978
  : undefined;
776
- return await heldAnswer.run(held, () => requestBodyLease.run(lease, () => serving(live, () => live.transport.handleRequest(req, res, req.body))));
979
+ return await heldAnswer.run(held, () => requestBodyLease.run(lease, () => serving(live, () => live.transport.handleRequest(req, res, req.body),
980
+ // A request keeps the session as before: it was just put to
981
+ // the platform, or found its acceptance cached. Anything else
982
+ // keeps it only while that acceptance lasts.
983
+ request ? always : heardWhileAccepted(key))));
777
984
  }
778
985
  finally {
986
+ // serving() held its own lease across handleRequest, and a tool
987
+ // callback that outlives it holds another through activity(), so the
988
+ // pin can go once the route is done with the request.
989
+ unpin();
779
990
  // A verified session is the only request allowed through the large
780
991
  // parser. Release the route's ownership here; a tool callback that
781
992
  // outlives handleRequest retains its own reference through activity().
@@ -807,6 +1018,8 @@ export async function runHttp(cfg) {
807
1018
  return rpcError(res, 403, -32000, refused, rpcId(req));
808
1019
  // Hosted: no session for a bearer the platform does not accept. Before the
809
1020
  // cap and the reservation, so a refused bearer never holds a slot.
1021
+ let suspended = false;
1022
+ let checkedAccount;
810
1023
  if (challenge) {
811
1024
  // A spent budget does not close the address. Many clients can share one
812
1025
  // (a NAT, an office), and one bad neighbour must not lock out the rest:
@@ -827,7 +1040,7 @@ export async function runHttp(cfg) {
827
1040
  // The interval is spent only by a probe that starts: a request turned
828
1041
  // away because the process-wide cap is full reached nobody, and must not
829
1042
  // make the next client at this address wait out an interval for it.
830
- const verdict = await checkBearer(key, spent
1043
+ const { verdict, account } = await checkBearer(key, spent
831
1044
  ? () => {
832
1045
  const entry = failures.get(sourceOf(req));
833
1046
  if (entry)
@@ -843,26 +1056,95 @@ export async function runHttp(cfg) {
843
1056
  if (verdict === 'unknown' && initialize) {
844
1057
  return unavailable(res, 'The platform could not confirm this token just now. Retry shortly.', rpcId(req));
845
1058
  }
1059
+ suspended = verdict === 'suspended';
1060
+ checkedAccount = account;
846
1061
  }
847
1062
  if (!initialize) {
848
1063
  return badRequest(res, 'No session id, and this is not an initialize request.', rpcId(req));
849
1064
  }
1065
+ // One bearer may not hold the whole pool (OPL-5443), nor one account more
1066
+ // than its ceiling however many bearers it holds (OPL-5447). Both counted
1067
+ // before the process-wide cap, so a bearer that can make room by closing
1068
+ // one of its idle sessions frees a process-wide slot as well. From the
1069
+ // count to the reservation below is synchronous, so a burst cannot all
1070
+ // read the same count.
1071
+ const keyDigest = digest(key);
1072
+ const keyId = keyDigest.toString('hex');
1073
+ // Hosted, the account the platform confirmed for this bearer (or the
1074
+ // bearer itself, when whoami named none); self-hosted, the bearer.
1075
+ const account = checkedAccount ?? accountKey(keyId, undefined);
1076
+ const bearerCap = suspended ? MAX_SESSIONS_SUSPENDED : maxPerBearer;
1077
+ // A bearer can hold more than its cap only when the cap fell under it: an
1078
+ // account suspended after it opened sessions (OPL-5448). Those over the
1079
+ // cap are closed first if idle, whatever becomes of this initialize, so
1080
+ // what is left to count, and to name in a 429, is what it really holds.
1081
+ trimIdle(keyDigest, bearerCap);
1082
+ const tooMany = (message) => {
1083
+ res.set('Retry-After', String(BEARER_FULL_RETRY_AFTER_S));
1084
+ return rpcError(res, 429, -32002, message, rpcId(req));
1085
+ };
1086
+ // Room is made from this bearer's own IDLE sessions, least recently seen
1087
+ // first, exactly as the sweeper would close them, under whichever limit
1088
+ // binds: its own cap or its account's ceiling. Never another bearer's live
1089
+ // session, and never one with a request in flight: past the ceiling an
1090
+ // initialize is refused, since the account's holder is the one who knows
1091
+ // which of its clients can spare a session. An idle session of the
1092
+ // account's whose bearer the platform refused is the one exception (see
1093
+ // `planRoom`). A client whose old session is gone gets 404 for it and
1094
+ // initializes again, as MCP clients do. Decided here, so a caller with
1095
+ // nothing idle to give back is refused at once; carried out only once the
1096
+ // new session exists (in `onsessioninitialized`), so an initialize the SDK
1097
+ // then refuses (406, 415, 400) or that throws on the way does not also
1098
+ // cost the caller a working session. Only the number is kept past here.
1099
+ let planned = 0;
1100
+ {
1101
+ const room = planRoom(keyDigest, account, bearerCap, pendingByDigest.get(keyId) ?? 0, pendingByAccount.get(account) ?? 0);
1102
+ if ('full' in room) {
1103
+ if (room.full === 'account')
1104
+ return tooMany(accountFullMessage(account, maxPerAccount));
1105
+ // Over the cap after the trim means every session over it is busy,
1106
+ // and closing one would not be enough: the client can only wait.
1107
+ return tooMany(room.holds > bearerCap
1108
+ ? `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.`
1109
+ : `This token already holds its maximum of ${bearerCap} ${bearerCap === 1 ? 'session' : 'sessions'} on this server. Close one (DELETE /mcp) or retry shortly.`);
1110
+ }
1111
+ planned = room.victims.length;
1112
+ }
850
1113
  // Swept sessions free their slot on the timer; this is the backstop for the
851
1114
  // case the timer cannot help with, which is arrivals faster than the TTL.
852
- if (sessions.size + pending >= maxSessions) {
1115
+ // The sessions this initialize will close are still in the map, and are not
1116
+ // counted against it: a bearer making room in its own share is not newly
1117
+ // refused for the room it is about to give back. Nothing is closed to make
1118
+ // room here, whoever holds the pool.
1119
+ if (sessions.size - planned + pending >= maxSessions) {
1120
+ res.set('Retry-After', String(POOL_FULL_RETRY_AFTER_S));
853
1121
  return unavailable(res, `This server is holding its maximum of ${maxSessions} sessions. Retry shortly.`, rpcId(req));
854
1122
  }
855
1123
  pending++;
1124
+ pendingByDigest.set(keyId, (pendingByDigest.get(keyId) ?? 0) + 1);
1125
+ pendingByAccount.set(account, (pendingByAccount.get(account) ?? 0) + 1);
856
1126
  // Released exactly once, whether the initialize lands in the map or throws
857
1127
  // on the way there. A reservation that leaked on the failure path would
858
1128
  // ratchet the cap down until the process restarted — every later initialize
859
1129
  // refused with 503 for the life of the process, which is the denial of
860
- // service the counter was added to prevent, self-inflicted.
1130
+ // service the counter was added to prevent, self-inflicted. The per-bearer
1131
+ // and per-account counts leaking would do the same to that bearer and that
1132
+ // account.
861
1133
  let reserved = true;
862
1134
  const release = () => {
863
1135
  if (reserved) {
864
1136
  reserved = false;
865
1137
  pending--;
1138
+ const left = (pendingByDigest.get(keyId) ?? 1) - 1;
1139
+ if (left > 0)
1140
+ pendingByDigest.set(keyId, left);
1141
+ else
1142
+ pendingByDigest.delete(keyId);
1143
+ const accountLeft = (pendingByAccount.get(account) ?? 1) - 1;
1144
+ if (accountLeft > 0)
1145
+ pendingByAccount.set(account, accountLeft);
1146
+ else
1147
+ pendingByAccount.delete(account);
866
1148
  }
867
1149
  };
868
1150
  // Everything from here to the map write is inside the reservation,
@@ -875,6 +1157,9 @@ export async function runHttp(cfg) {
875
1157
  let transport;
876
1158
  let mcp;
877
1159
  let finishInitialize = () => { };
1160
+ // Set when the session was dropped at birth for want of room (see
1161
+ // `onsessioninitialized`): it has an id but no map slot.
1162
+ let dropped = false;
878
1163
  try {
879
1164
  const t = new StreamableHTTPServerTransport({
880
1165
  sessionIdGenerator: () => randomUUID(),
@@ -889,9 +1174,39 @@ export async function runHttp(cfg) {
889
1174
  allowedHosts: hosts,
890
1175
  allowedOrigins,
891
1176
  onsessioninitialized: (id) => {
1177
+ // The room the check above planned, made now that there is a session
1178
+ // to make it for. Chosen again from what is here NOW: a session
1179
+ // closed or put to work since then is not a candidate. Should too
1180
+ // few still be idle, nothing is closed and this session is dropped
1181
+ // instead of admitted over the cap. The transport closes before the
1182
+ // SDK answers, so the SDK answers this initialize itself, with its
1183
+ // 404 -32001 'Session not found' and no Retry-After — not the 429
1184
+ // the refusal above gives, which cannot be written from here. An
1185
+ // SDK client surfaces that as a failed connect; it does not retry
1186
+ // on its own. Only a race reaches this: a request landing on a
1187
+ // planned session between the check above and this callback.
1188
+ //
1189
+ // The account's ceiling is re-checked here too (OPL-5447), against
1190
+ // the map alone. Initializes still pending were each admitted
1191
+ // counting this one's reservation, and the room they will make is
1192
+ // still in the map, so counting them again here would drop a session
1193
+ // that fits; what keeps a burst under the ceiling is the pending
1194
+ // count at admission. Every landing leaves the map within both.
1195
+ const room = planRoom(keyDigest, account, bearerCap, 0, 0);
1196
+ if ('full' in room) {
1197
+ dropped = true;
1198
+ release();
1199
+ void t.close().catch(() => { });
1200
+ return;
1201
+ }
1202
+ for (const [other, gone] of room.victims) {
1203
+ sessions.delete(other);
1204
+ void gone.transport.close().catch(() => { });
1205
+ }
892
1206
  const live = {
893
1207
  transport: t,
894
- keyDigest: digest(key),
1208
+ keyDigest,
1209
+ account,
895
1210
  lastSeen: Date.now(),
896
1211
  // Initialize is already in flight when the session first becomes
897
1212
  // visible to the sweeper. Count it until handleRequest settles.
@@ -961,10 +1276,11 @@ export async function runHttp(cfg) {
961
1276
  mcp = server;
962
1277
  await server.connect(t);
963
1278
  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) {
1279
+ // Any initialize that never reached `onsessioninitialized`, or was
1280
+ // dropped there, has no map slot and nothing the sweeper will reap. The
1281
+ // SDK's 403 used to take this path without throwing; other early returns
1282
+ // still can.
1283
+ if (!t.sessionId || dropped) {
968
1284
  void server.close().catch(() => { });
969
1285
  void t.close().catch(() => { });
970
1286
  }
@@ -1010,13 +1326,21 @@ export async function runHttp(cfg) {
1010
1326
  // DELETE is still honoured, since the digest shows it is the holder.
1011
1327
  if (challenge && live.refused && req.method === 'GET')
1012
1328
  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`.
1329
+ // The GET is the notification stream and is only noted; anything else
1330
+ // here (a DELETE, or a HEAD, which Express routes to the GET handler with
1331
+ // the method unchanged) is held for while the SDK handles it. See
1332
+ // `serving`. None of it is put to the platform, so none of it may keep the
1333
+ // session from idling out once the token's acceptance lapses: a HEAD is
1334
+ // answered 405 and a DELETE the SDK refuses (an unsupported protocol
1335
+ // version, say) 400, both leaving the session open, and a revoked token's
1336
+ // holder could otherwise send either for ever to keep its sessions, and
1337
+ // its account's ceiling, held (OPL-5455). A DELETE that succeeds closes
1338
+ // the session anyway, and `active` is counted whatever `heard` says.
1015
1339
  if (req.method === 'GET') {
1016
- touch(live, res);
1340
+ touch(live, res, heardWhileAccepted(key));
1017
1341
  return live.transport.handleRequest(req, res);
1018
1342
  }
1019
- return serving(live, () => live.transport.handleRequest(req, res));
1343
+ return serving(live, () => live.transport.handleRequest(req, res), heardWhileAccepted(key));
1020
1344
  };
1021
1345
  app.get('/mcp', bySession);
1022
1346
  app.delete('/mcp', bySession);
@@ -1161,6 +1485,55 @@ const BEARER_CHECK_TIMEOUT_MS = 10_000;
1161
1485
  const MAX_BEARER_CHECKS_IN_FLIGHT = 32;
1162
1486
  const MAX_ACCEPTED_BEARERS = 4096;
1163
1487
  const MAX_FAILURE_SOURCES = 10_000;
1488
+ /**
1489
+ * Whether a whoami record says its account is suspended.
1490
+ *
1491
+ * Only an explicit `account.status` of `"suspended"` counts. A record this
1492
+ * cannot read is not evidence of suspension, and treating it as such would hold
1493
+ * a working account to one session on the strength of a shape change.
1494
+ */
1495
+ function isSuspended(who) {
1496
+ if (typeof who !== 'object' || who === null)
1497
+ return false;
1498
+ const account = who.account;
1499
+ if (typeof account !== 'object' || account === null)
1500
+ return false;
1501
+ return account.status === 'suspended';
1502
+ }
1503
+ /**
1504
+ * The account id a whoami record names: a non-empty string of at most 256
1505
+ * characters. Anything else is no account, and the bearer then counts as an
1506
+ * account of its own rather than as one this server could not confirm.
1507
+ */
1508
+ function accountIdOf(who) {
1509
+ if (typeof who !== 'object' || who === null)
1510
+ return undefined;
1511
+ const account = who.account;
1512
+ if (typeof account !== 'object' || account === null)
1513
+ return undefined;
1514
+ const id = account.id;
1515
+ return typeof id === 'string' && id.length > 0 && id.length <= 256 ? id : undefined;
1516
+ }
1517
+ /**
1518
+ * What a session counts against for the account ceiling (OPL-5447).
1519
+ *
1520
+ * The account id the platform confirmed for the bearer when there is one
1521
+ * (hosted mode), and otherwise the bearer's own digest: a hosted whoami that
1522
+ * names no account, and every bearer on a self-hosted server, which verifies
1523
+ * none and so puts no trust in a string it cannot check. Prefixed so the two
1524
+ * can never name the same thing.
1525
+ */
1526
+ function accountKey(keyId, confirmed) {
1527
+ return confirmed !== undefined ? `account:${confirmed}` : `bearer:${keyId}`;
1528
+ }
1529
+ /** The 429 for an account at its ceiling. It names the limit and nothing else. */
1530
+ function accountFullMessage(account, cap) {
1531
+ const noun = cap === 1 ? 'session' : 'sessions';
1532
+ return account.startsWith('account:')
1533
+ ? `This account has reached its maximum of ${cap} ${noun} on this server, across all its tokens. Close one (DELETE /mcp) or retry shortly.`
1534
+ : `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.`;
1535
+ }
1536
+ const always = () => true;
1164
1537
  /** Whether a POST body holds a JSON-RPC request, which is what opens a stream. */
1165
1538
  function carriesRequest(body) {
1166
1539
  const isRequest = (m) => typeof m === 'object' && m !== null && 'method' in m && 'id' in m;