mandala-computer-mcp 0.6.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 (72) hide show
  1. package/README.md +268 -27
  2. package/dist/api.d.ts +26 -1
  3. package/dist/api.d.ts.map +1 -1
  4. package/dist/api.js +23 -0
  5. package/dist/api.js.map +1 -1
  6. package/dist/cli.d.ts +7 -0
  7. package/dist/cli.d.ts.map +1 -1
  8. package/dist/cli.js +57 -1
  9. package/dist/cli.js.map +1 -1
  10. package/dist/errors.d.ts +114 -3
  11. package/dist/errors.d.ts.map +1 -1
  12. package/dist/errors.js +253 -3
  13. package/dist/errors.js.map +1 -1
  14. package/dist/format.d.ts +73 -2
  15. package/dist/format.d.ts.map +1 -1
  16. package/dist/format.js +81 -3
  17. package/dist/format.js.map +1 -1
  18. package/dist/http.d.ts +32 -0
  19. package/dist/http.d.ts.map +1 -1
  20. package/dist/http.js +440 -57
  21. package/dist/http.js.map +1 -1
  22. package/dist/paths.d.ts +75 -7
  23. package/dist/paths.d.ts.map +1 -1
  24. package/dist/paths.js +88 -12
  25. package/dist/paths.js.map +1 -1
  26. package/dist/server.d.ts +1 -1
  27. package/dist/server.d.ts.map +1 -1
  28. package/dist/server.js +3 -1
  29. package/dist/server.js.map +1 -1
  30. package/dist/tool-filters.d.ts +4 -4
  31. package/dist/tool-filters.d.ts.map +1 -1
  32. package/dist/tool-filters.js +13 -1
  33. package/dist/tool-filters.js.map +1 -1
  34. package/dist/tools/account.d.ts +14 -0
  35. package/dist/tools/account.d.ts.map +1 -1
  36. package/dist/tools/account.js +118 -0
  37. package/dist/tools/account.js.map +1 -1
  38. package/dist/tools/agent.d.ts.map +1 -1
  39. package/dist/tools/agent.js +19 -25
  40. package/dist/tools/agent.js.map +1 -1
  41. package/dist/tools/chat.d.ts.map +1 -1
  42. package/dist/tools/chat.js +46 -4
  43. package/dist/tools/chat.js.map +1 -1
  44. package/dist/tools/computers.d.ts.map +1 -1
  45. package/dist/tools/computers.js +297 -51
  46. package/dist/tools/computers.js.map +1 -1
  47. package/dist/tools/guest.d.ts.map +1 -1
  48. package/dist/tools/guest.js +27 -4
  49. package/dist/tools/guest.js.map +1 -1
  50. package/dist/tools/input.d.ts +9 -0
  51. package/dist/tools/input.d.ts.map +1 -1
  52. package/dist/tools/input.js +247 -16
  53. package/dist/tools/input.js.map +1 -1
  54. package/dist/tools/operations.d.ts +49 -0
  55. package/dist/tools/operations.d.ts.map +1 -0
  56. package/dist/tools/operations.js +255 -0
  57. package/dist/tools/operations.js.map +1 -0
  58. package/dist/tools/secrets.d.ts +10 -1
  59. package/dist/tools/secrets.d.ts.map +1 -1
  60. package/dist/tools/secrets.js +63 -18
  61. package/dist/tools/secrets.js.map +1 -1
  62. package/dist/tools/snapshots.d.ts.map +1 -1
  63. package/dist/tools/snapshots.js +54 -18
  64. package/dist/tools/snapshots.js.map +1 -1
  65. package/dist/tools/ssh.d.ts.map +1 -1
  66. package/dist/tools/ssh.js +19 -2
  67. package/dist/tools/ssh.js.map +1 -1
  68. package/dist/tools/templates.js +2 -2
  69. package/dist/tools/templates.js.map +1 -1
  70. package/dist/tools/webhooks.js +1 -1
  71. package/dist/tools/webhooks.js.map +1 -1
  72. 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().
@@ -785,9 +996,14 @@ export async function runHttp(cfg) {
785
996
  // Sessionless requests always use the small parser. This no-op keeps the
786
997
  // ownership explicit if the parser policy changes later.
787
998
  releaseLargeBody(res);
788
- if (!isInitializeRequest(req.body)) {
789
- return badRequest(res, 'No session id, and this is not an initialize request.', rpcId(req));
790
- }
999
+ // Who is asking is settled before what they asked for. A sessionless
1000
+ // request that is not an initialize is a protocol mistake (400), but a
1001
+ // client with no credential, or one the platform refuses, has an auth
1002
+ // problem first — and a 400 "No session id" sent it looking at its session
1003
+ // handling when the fix was its key. So the key, the Host check and, hosted,
1004
+ // the bearer probe all run before the initialize test; only the session cap
1005
+ // and the build below are an initialize's alone.
1006
+ const initialize = isInitializeRequest(req.body);
791
1007
  if (!key) {
792
1008
  return unauthorized(res, 'Send your Mandala API key as a bearer token: Authorization: Bearer com_…', rpcId(req));
793
1009
  }
@@ -802,6 +1018,8 @@ export async function runHttp(cfg) {
802
1018
  return rpcError(res, 403, -32000, refused, rpcId(req));
803
1019
  // Hosted: no session for a bearer the platform does not accept. Before the
804
1020
  // cap and the reservation, so a refused bearer never holds a slot.
1021
+ let suspended = false;
1022
+ let checkedAccount;
805
1023
  if (challenge) {
806
1024
  // A spent budget does not close the address. Many clients can share one
807
1025
  // (a NAT, an office), and one bad neighbour must not lock out the rest:
@@ -816,13 +1034,13 @@ export async function runHttp(cfg) {
816
1034
  const wait = entry?.lastProbeAt !== undefined ? entry.lastProbeAt + exhaustedInterval - Date.now() : 0;
817
1035
  if (wait > 0) {
818
1036
  res.set('Retry-After', String(Math.max(1, Math.ceil(wait / 1000))));
819
- return rpcError(res, 429, -32002, 'Too many refused initializes from this address. Retry later with a valid token.', rpcId(req));
1037
+ return rpcError(res, 429, -32002, 'Too many refused tokens from this address. Retry later with a valid token.', rpcId(req));
820
1038
  }
821
1039
  }
822
1040
  // The interval is spent only by a probe that starts: a request turned
823
1041
  // away because the process-wide cap is full reached nobody, and must not
824
1042
  // make the next client at this address wait out an interval for it.
825
- const verdict = await checkBearer(key, spent
1043
+ const { verdict, account } = await checkBearer(key, spent
826
1044
  ? () => {
827
1045
  const entry = failures.get(sourceOf(req));
828
1046
  if (entry)
@@ -833,26 +1051,100 @@ export async function runHttp(cfg) {
833
1051
  noteFailure(req);
834
1052
  return challenged(res, TOKEN_REFUSED, rpcId(req));
835
1053
  }
836
- if (verdict === 'unknown') {
1054
+ // Not an initialize, it is a 400 whatever the platform could say about
1055
+ // the key short of refusing it.
1056
+ if (verdict === 'unknown' && initialize) {
837
1057
  return unavailable(res, 'The platform could not confirm this token just now. Retry shortly.', rpcId(req));
838
1058
  }
1059
+ suspended = verdict === 'suspended';
1060
+ checkedAccount = account;
1061
+ }
1062
+ if (!initialize) {
1063
+ return badRequest(res, 'No session id, and this is not an initialize request.', rpcId(req));
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;
839
1112
  }
840
1113
  // Swept sessions free their slot on the timer; this is the backstop for the
841
1114
  // case the timer cannot help with, which is arrivals faster than the TTL.
842
- 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));
843
1121
  return unavailable(res, `This server is holding its maximum of ${maxSessions} sessions. Retry shortly.`, rpcId(req));
844
1122
  }
845
1123
  pending++;
1124
+ pendingByDigest.set(keyId, (pendingByDigest.get(keyId) ?? 0) + 1);
1125
+ pendingByAccount.set(account, (pendingByAccount.get(account) ?? 0) + 1);
846
1126
  // Released exactly once, whether the initialize lands in the map or throws
847
1127
  // on the way there. A reservation that leaked on the failure path would
848
1128
  // ratchet the cap down until the process restarted — every later initialize
849
1129
  // refused with 503 for the life of the process, which is the denial of
850
- // 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.
851
1133
  let reserved = true;
852
1134
  const release = () => {
853
1135
  if (reserved) {
854
1136
  reserved = false;
855
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);
856
1148
  }
857
1149
  };
858
1150
  // Everything from here to the map write is inside the reservation,
@@ -865,6 +1157,9 @@ export async function runHttp(cfg) {
865
1157
  let transport;
866
1158
  let mcp;
867
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;
868
1163
  try {
869
1164
  const t = new StreamableHTTPServerTransport({
870
1165
  sessionIdGenerator: () => randomUUID(),
@@ -879,9 +1174,39 @@ export async function runHttp(cfg) {
879
1174
  allowedHosts: hosts,
880
1175
  allowedOrigins,
881
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
+ }
882
1206
  const live = {
883
1207
  transport: t,
884
- keyDigest: digest(key),
1208
+ keyDigest,
1209
+ account,
885
1210
  lastSeen: Date.now(),
886
1211
  // Initialize is already in flight when the session first becomes
887
1212
  // visible to the sweeper. Count it until handleRequest settles.
@@ -951,10 +1276,11 @@ export async function runHttp(cfg) {
951
1276
  mcp = server;
952
1277
  await server.connect(t);
953
1278
  const handled = await t.handleRequest(req, res, req.body);
954
- // Any initialize that never reached `onsessioninitialized` has no map
955
- // slot and nothing the sweeper will reap. The SDK's 403 used to take
956
- // this path without throwing; other early returns still can.
957
- 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) {
958
1284
  void server.close().catch(() => { });
959
1285
  void t.close().catch(() => { });
960
1286
  }
@@ -1000,13 +1326,21 @@ export async function runHttp(cfg) {
1000
1326
  // DELETE is still honoured, since the digest shows it is the holder.
1001
1327
  if (challenge && live.refused && req.method === 'GET')
1002
1328
  return challenged(res, TOKEN_REFUSED);
1003
- // The DELETE is a request and is held for; the GET is the notification
1004
- // 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.
1005
1339
  if (req.method === 'GET') {
1006
- touch(live, res);
1340
+ touch(live, res, heardWhileAccepted(key));
1007
1341
  return live.transport.handleRequest(req, res);
1008
1342
  }
1009
- return serving(live, () => live.transport.handleRequest(req, res));
1343
+ return serving(live, () => live.transport.handleRequest(req, res), heardWhileAccepted(key));
1010
1344
  };
1011
1345
  app.get('/mcp', bySession);
1012
1346
  app.delete('/mcp', bySession);
@@ -1151,6 +1485,55 @@ const BEARER_CHECK_TIMEOUT_MS = 10_000;
1151
1485
  const MAX_BEARER_CHECKS_IN_FLIGHT = 32;
1152
1486
  const MAX_ACCEPTED_BEARERS = 4096;
1153
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;
1154
1537
  /** Whether a POST body holds a JSON-RPC request, which is what opens a stream. */
1155
1538
  function carriesRequest(body) {
1156
1539
  const isRequest = (m) => typeof m === 'object' && m !== null && 'method' in m && 'id' in m;