mandala-computer-mcp 0.8.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 (71) hide show
  1. package/README.md +143 -32
  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 +5 -0
  7. package/dist/cli.d.ts.map +1 -1
  8. package/dist/cli.js +28 -9
  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 +25 -8
  15. package/dist/errors.d.ts.map +1 -1
  16. package/dist/errors.js +62 -34
  17. package/dist/errors.js.map +1 -1
  18. package/dist/format.d.ts +9 -0
  19. package/dist/format.d.ts.map +1 -1
  20. package/dist/format.js +17 -1
  21. package/dist/format.js.map +1 -1
  22. package/dist/http.d.ts +33 -7
  23. package/dist/http.d.ts.map +1 -1
  24. package/dist/http.js +344 -61
  25. package/dist/http.js.map +1 -1
  26. package/dist/paths.d.ts +7 -5
  27. package/dist/paths.d.ts.map +1 -1
  28. package/dist/paths.js +17 -7
  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/chat.d.ts.map +1 -1
  53. package/dist/tools/chat.js +14 -7
  54. package/dist/tools/chat.js.map +1 -1
  55. package/dist/tools/computers.d.ts.map +1 -1
  56. package/dist/tools/computers.js +62 -4
  57. package/dist/tools/computers.js.map +1 -1
  58. package/dist/tools/guest.d.ts.map +1 -1
  59. package/dist/tools/guest.js +7 -1
  60. package/dist/tools/guest.js.map +1 -1
  61. package/dist/tools/input.d.ts.map +1 -1
  62. package/dist/tools/input.js +98 -48
  63. package/dist/tools/input.js.map +1 -1
  64. package/dist/tools/secrets.d.ts.map +1 -1
  65. package/dist/tools/secrets.js +76 -19
  66. package/dist/tools/secrets.js.map +1 -1
  67. package/dist/version.d.ts +8 -0
  68. package/dist/version.d.ts.map +1 -0
  69. package/dist/version.js +8 -0
  70. package/dist/version.js.map +1 -0
  71. package/package.json +1 -1
package/dist/http.js CHANGED
@@ -78,7 +78,9 @@ export const DEFAULT_MAX_SESSIONS = 256;
78
78
  *
79
79
  * A suspended account's bearer is admitted (OPL-5437: it is how its holder
80
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.
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}.
82
84
  */
83
85
  const DEFAULT_MAX_SESSIONS_PER_BEARER = 16;
84
86
  const MAX_SESSIONS_SUSPENDED = 1;
@@ -231,6 +233,32 @@ export async function runHttp(cfg) {
231
233
  const checkTtl = cfg.bearerCheckTtlMs ?? DEFAULT_BEARER_CHECK_TTL_MS;
232
234
  const accepted = new Map();
233
235
  let checksInFlight = 0;
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
+ };
234
262
  /** The platform's acceptance of this bearer, while the cache window lasts. */
235
263
  const acceptance = (key) => {
236
264
  const entry = accepted.get(digest(key).toString('hex'));
@@ -241,35 +269,46 @@ export async function runHttp(cfg) {
241
269
  /**
242
270
  * `ok`: the probe answered 2xx, and only that — the one answer that says the
243
271
  * 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.
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.
246
274
  * `unknown`: anything else — a 403 (a lost membership, say; a suspended
247
275
  * account is answered 2xx on whoami and admitted), a 404 (the route moved), a 429, a 5xx, a timeout, or this
248
276
  * server already asking about as many bearers as it will at once. Only `ok`
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
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
251
279
  * {@link accountKey}); it is set whenever the verdict is `ok` or
252
280
  * `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.
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).
254
284
  */
255
285
  // `onStart` runs only when a probe really starts — never for a cached answer
256
286
  // or a full cap — and synchronously, before the first await, so a caller's
257
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).
258
292
  const checkBearer = async (key, onStart) => {
259
293
  const id = digest(key).toString('hex');
260
294
  const cached = acceptance(key);
261
- if (cached) {
295
+ // Believed only while its account has a standing to read (see above).
296
+ if (cached && standing.has(cached.account)) {
262
297
  return {
263
- verdict: cached.suspended ? 'suspended' : 'ok',
264
- account: accountKey(id, cached.account),
298
+ verdict: accountSuspended(cached.account) ? 'suspended' : 'ok',
299
+ account: cached.account,
300
+ workspace: cached.workspace,
265
301
  };
266
302
  }
267
303
  if (checksInFlight >= MAX_BEARER_CHECKS_IN_FLIGHT)
268
304
  return { verdict: 'unknown' };
269
305
  checksInFlight++;
306
+ // Numbered as it starts, before the first await: see `standing`.
307
+ const seq = ++probeSeq;
270
308
  onStart?.();
271
309
  let suspended;
272
310
  let confirmed;
311
+ let workspaceId;
273
312
  try {
274
313
  const api = new Api(key, cfg.baseUrl, AbortSignal.timeout(BEARER_CHECK_TIMEOUT_MS), {
275
314
  serviceSecret,
@@ -287,6 +326,7 @@ export async function runHttp(cfg) {
287
326
  const who = await api.json('GET', WHOAMI);
288
327
  suspended = isSuspended(who);
289
328
  confirmed = accountIdOf(who);
329
+ workspaceId = workspaceIdOf(who);
290
330
  }
291
331
  catch (err) {
292
332
  return { verdict: err instanceof APIError && err.status === 401 ? 'refused' : 'unknown' };
@@ -304,8 +344,11 @@ export async function runHttp(cfg) {
304
344
  accepted.delete(accepted.keys().next().value);
305
345
  }
306
346
  }
307
- accepted.set(id, { until: now + checkTtl, suspended, account: confirmed });
308
- return { verdict: suspended ? 'suspended' : 'ok', account: accountKey(id, confirmed) };
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 };
309
352
  };
310
353
  // Refused initializes per source, in fixed one-minute windows. Kept apart
311
354
  // from the session cap so a flood of invented tokens is turned away before
@@ -404,13 +447,32 @@ export async function runHttp(cfg) {
404
447
  // number of bearers, so a burst spread across its keys would otherwise all
405
448
  // count the same sessions and all pass the account ceiling.
406
449
  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.
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.
410
454
  const perAccount = cfg.maxSessionsPerAccount;
411
455
  const maxPerAccount = typeof perAccount === 'number' && perAccount >= 1
412
456
  ? Math.min(Math.floor(perAccount), maxSessions)
413
- : Math.max(1, Math.floor(maxSessions / 2));
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;
414
476
  // Two parsers, chosen by whether this server has already checked who is
415
477
  // asking.
416
478
  //
@@ -703,9 +765,23 @@ export async function runHttp(cfg) {
703
765
  return counted;
704
766
  };
705
767
  /**
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.
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.
709
785
  *
710
786
  * Only idle sessions (`active === 0`, what the sweeper closes) are
711
787
  * candidates, and of two kinds. The bearer's own, which make room under
@@ -716,43 +792,119 @@ export async function runHttp(cfg) {
716
792
  * account's other keys out until the sweep. Refused ones go first, then the
717
793
  * least recently seen. A live session of another bearer is never a
718
794
  * 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.
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.
720
808
  *
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.
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.
724
814
  */
725
- const planRoom = (keyDigest, account, bearerCap, pendingBearer, pendingAccount) => {
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;
726
818
  const own = sessionsOf(keyDigest);
727
819
  const counted = sessionsOfAccount(account);
728
820
  let bearerNeed = own.length + pendingBearer - bearerCap + 1;
729
- let accountNeed = counted.length + pendingAccount - maxPerAccount + 1;
821
+ let accountNeed = counted.length + pendingAccount - accountCap + 1;
822
+ let workspaceNeed = workspace !== undefined && !suspended
823
+ ? sessionsOfWorkspace(workspace).length + pendingWorkspace - maxPerWorkspace + 1
824
+ : 0;
730
825
  const victims = [];
731
- if (bearerNeed <= 0 && accountNeed <= 0)
826
+ if (bearerNeed <= 0 && accountNeed <= 0 && workspaceNeed <= 0)
732
827
  return { victims };
733
828
  const mine = new Set(own.map(([id]) => id));
734
- const candidates = [...own, ...counted.filter(([id, live]) => !mine.has(id) && live.refused)]
829
+ const candidates = [
830
+ ...own,
831
+ ...counted.filter(([id, live]) => !mine.has(id) && (live.refused || suspended)),
832
+ ]
735
833
  .filter(([, live]) => live.active === 0)
736
- .sort((a, b) => Number(b[1].refused) - Number(a[1].refused) || a[1].lastSeen - b[1].lastSeen);
834
+ .sort(closingOrder);
737
835
  for (const entry of candidates) {
738
- if (bearerNeed <= 0 && accountNeed <= 0)
836
+ if (bearerNeed <= 0 && accountNeed <= 0 && workspaceNeed <= 0)
739
837
  break;
740
838
  const ofBearer = mine.has(entry[0]);
741
839
  const ofAccount = entry[1].account === account;
742
- if (!(ofBearer && bearerNeed > 0) && !(ofAccount && accountNeed > 0))
840
+ const ofWorkspace = workspace !== undefined && entry[1].workspace === workspace;
841
+ if (!(ofBearer && bearerNeed > 0) &&
842
+ !(ofAccount && accountNeed > 0) &&
843
+ !(ofWorkspace && workspaceNeed > 0)) {
743
844
  continue;
845
+ }
744
846
  victims.push(entry);
745
847
  if (ofBearer)
746
848
  bearerNeed--;
747
849
  if (ofAccount)
748
850
  accountNeed--;
851
+ if (ofWorkspace)
852
+ workspaceNeed--;
749
853
  }
750
854
  if (bearerNeed > 0)
751
855
  return { full: 'bearer', holds: own.length };
856
+ if (workspaceNeed > 0)
857
+ return { full: 'workspace' };
752
858
  if (accountNeed > 0)
753
859
  return { full: 'account' };
754
860
  return { victims };
755
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
+ };
756
908
  /**
757
909
  * Close this bearer's idle sessions, least recently seen first, until it
758
910
  * holds no more than `cap` or none idle is left. Never `keep`, and never one
@@ -953,28 +1105,39 @@ export async function runHttp(cfg) {
953
1105
  live.active--;
954
1106
  };
955
1107
  if (challenge && live.refused)
956
- return challenged(res, TOKEN_REFUSED, rpcId(req));
1108
+ return challenged(res, refusedMessage(key), rpcId(req));
957
1109
  // Checked before a request is dispatched, while the answer can still
958
1110
  // be a 401 whatever the call goes on to do. A platform that cannot say
959
1111
  // is not a refusal: the call goes ahead, and a real refusal during it
960
1112
  // still reaches the client through the held answer or the mark.
961
1113
  const request = carriesRequest(req.body);
962
- const verdict = challenge && request ? (await checkBearer(key)).verdict : undefined;
1114
+ const checked = challenge && request ? await checkBearer(key) : undefined;
1115
+ const verdict = checked?.verdict;
963
1116
  if (verdict === 'refused') {
964
1117
  live.refused = true;
965
- 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);
966
1136
  }
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);
974
1137
  const lease = res.locals.largeBodyLease;
975
1138
  const id = rpcId(req);
976
1139
  const held = challenge
977
- ? holdAnswer(res, () => challenged(res, TOKEN_REFUSED, id))
1140
+ ? holdAnswer(res, () => challenged(res, refusedMessage(key), id))
978
1141
  : undefined;
979
1142
  return await heldAnswer.run(held, () => requestBodyLease.run(lease, () => serving(live, () => live.transport.handleRequest(req, res, req.body),
980
1143
  // A request keeps the session as before: it was just put to
@@ -1018,8 +1181,8 @@ export async function runHttp(cfg) {
1018
1181
  return rpcError(res, 403, -32000, refused, rpcId(req));
1019
1182
  // Hosted: no session for a bearer the platform does not accept. Before the
1020
1183
  // cap and the reservation, so a refused bearer never holds a slot.
1021
- let suspended = false;
1022
1184
  let checkedAccount;
1185
+ let checkedWorkspace;
1023
1186
  if (challenge) {
1024
1187
  // A spent budget does not close the address. Many clients can share one
1025
1188
  // (a NAT, an office), and one bad neighbour must not lock out the rest:
@@ -1040,7 +1203,7 @@ export async function runHttp(cfg) {
1040
1203
  // The interval is spent only by a probe that starts: a request turned
1041
1204
  // away because the process-wide cap is full reached nobody, and must not
1042
1205
  // make the next client at this address wait out an interval for it.
1043
- const { verdict, account } = await checkBearer(key, spent
1206
+ const { verdict, account, workspace } = await checkBearer(key, spent
1044
1207
  ? () => {
1045
1208
  const entry = failures.get(sourceOf(req));
1046
1209
  if (entry)
@@ -1049,15 +1212,15 @@ export async function runHttp(cfg) {
1049
1212
  : undefined);
1050
1213
  if (verdict === 'refused') {
1051
1214
  noteFailure(req);
1052
- return challenged(res, TOKEN_REFUSED, rpcId(req));
1215
+ return challenged(res, refusedMessage(key), rpcId(req));
1053
1216
  }
1054
1217
  // Not an initialize, it is a 400 whatever the platform could say about
1055
1218
  // the key short of refusing it.
1056
1219
  if (verdict === 'unknown' && initialize) {
1057
1220
  return unavailable(res, 'The platform could not confirm this token just now. Retry shortly.', rpcId(req));
1058
1221
  }
1059
- suspended = verdict === 'suspended';
1060
1222
  checkedAccount = account;
1223
+ checkedWorkspace = workspace;
1061
1224
  }
1062
1225
  if (!initialize) {
1063
1226
  return badRequest(res, 'No session id, and this is not an initialize request.', rpcId(req));
@@ -1073,11 +1236,20 @@ export async function runHttp(cfg) {
1073
1236
  // Hosted, the account the platform confirmed for this bearer (or the
1074
1237
  // bearer itself, when whoami named none); self-hosted, the bearer.
1075
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);
1076
1246
  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.
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);
1081
1253
  trimIdle(keyDigest, bearerCap);
1082
1254
  const tooMany = (message) => {
1083
1255
  res.set('Retry-After', String(BEARER_FULL_RETRY_AFTER_S));
@@ -1088,20 +1260,28 @@ export async function runHttp(cfg) {
1088
1260
  // binds: its own cap or its account's ceiling. Never another bearer's live
1089
1261
  // session, and never one with a request in flight: past the ceiling an
1090
1262
  // 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
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
1094
1268
  // initializes again, as MCP clients do. Decided here, so a caller with
1095
1269
  // nothing idle to give back is refused at once; carried out only once the
1096
1270
  // new session exists (in `onsessioninitialized`), so an initialize the SDK
1097
1271
  // 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.
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.
1099
1275
  let planned = 0;
1276
+ let plannedIds;
1100
1277
  {
1101
- const room = planRoom(keyDigest, account, bearerCap, pendingByDigest.get(keyId) ?? 0, pendingByAccount.get(account) ?? 0);
1278
+ const room = planRoom(keyDigest, account, workspace, suspended, pendingByDigest.get(keyId) ?? 0, workspace !== undefined ? (pendingByWorkspace.get(workspace) ?? 0) : 0, pendingByAccount.get(account) ?? 0);
1102
1279
  if ('full' in room) {
1103
- if (room.full === 'account')
1104
- return tooMany(accountFullMessage(account, maxPerAccount));
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));
1105
1285
  // Over the cap after the trim means every session over it is busy,
1106
1286
  // and closing one would not be enough: the client can only wait.
1107
1287
  return tooMany(room.holds > bearerCap
@@ -1109,6 +1289,7 @@ export async function runHttp(cfg) {
1109
1289
  : `This token already holds its maximum of ${bearerCap} ${bearerCap === 1 ? 'session' : 'sessions'} on this server. Close one (DELETE /mcp) or retry shortly.`);
1110
1290
  }
1111
1291
  planned = room.victims.length;
1292
+ plannedIds = new Set(room.victims.map(([id]) => id));
1112
1293
  }
1113
1294
  // Swept sessions free their slot on the timer; this is the backstop for the
1114
1295
  // case the timer cannot help with, which is arrivals faster than the TTL.
@@ -1120,16 +1301,44 @@ export async function runHttp(cfg) {
1120
1301
  res.set('Retry-After', String(POOL_FULL_RETRY_AFTER_S));
1121
1302
  return unavailable(res, `This server is holding its maximum of ${maxSessions} sessions. Retry shortly.`, rpcId(req));
1122
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
+ }
1123
1329
  pending++;
1124
1330
  pendingByDigest.set(keyId, (pendingByDigest.get(keyId) ?? 0) + 1);
1125
1331
  pendingByAccount.set(account, (pendingByAccount.get(account) ?? 0) + 1);
1332
+ if (workspace !== undefined) {
1333
+ pendingByWorkspace.set(workspace, (pendingByWorkspace.get(workspace) ?? 0) + 1);
1334
+ }
1126
1335
  // Released exactly once, whether the initialize lands in the map or throws
1127
1336
  // on the way there. A reservation that leaked on the failure path would
1128
1337
  // ratchet the cap down until the process restarted — every later initialize
1129
1338
  // refused with 503 for the life of the process, which is the denial of
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.
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.
1133
1342
  let reserved = true;
1134
1343
  const release = () => {
1135
1344
  if (reserved) {
@@ -1145,6 +1354,13 @@ export async function runHttp(cfg) {
1145
1354
  pendingByAccount.set(account, accountLeft);
1146
1355
  else
1147
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
+ }
1148
1364
  }
1149
1365
  };
1150
1366
  // Everything from here to the map write is inside the reservation,
@@ -1186,13 +1402,16 @@ export async function runHttp(cfg) {
1186
1402
  // on its own. Only a race reaches this: a request landing on a
1187
1403
  // planned session between the check above and this callback.
1188
1404
  //
1189
- // The account's ceiling is re-checked here too (OPL-5447), against
1405
+ // The account's ceiling is re-checked here too (OPL-5447), and the
1406
+ // workspace's sub-ceiling (OPL-5453), against
1190
1407
  // the map alone. Initializes still pending were each admitted
1191
1408
  // counting this one's reservation, and the room they will make is
1192
1409
  // still in the map, so counting them again here would drop a session
1193
1410
  // that fits; what keeps a burst under the ceiling is the pending
1194
1411
  // count at admission. Every landing leaves the map within both.
1195
- const room = planRoom(keyDigest, account, bearerCap, 0, 0);
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);
1196
1415
  if ('full' in room) {
1197
1416
  dropped = true;
1198
1417
  release();
@@ -1207,6 +1426,7 @@ export async function runHttp(cfg) {
1207
1426
  transport: t,
1208
1427
  keyDigest,
1209
1428
  account,
1429
+ workspace,
1210
1430
  lastSeen: Date.now(),
1211
1431
  // Initialize is already in flight when the session first becomes
1212
1432
  // visible to the sweeper. Count it until handleRequest settles.
@@ -1325,7 +1545,13 @@ export async function runHttp(cfg) {
1325
1545
  // The stream is refused on a token the platform has already refused; a
1326
1546
  // DELETE is still honoured, since the digest shows it is the holder.
1327
1547
  if (challenge && live.refused && req.method === 'GET')
1328
- return challenged(res, TOKEN_REFUSED);
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
+ }
1329
1555
  // The GET is the notification stream and is only noted; anything else
1330
1556
  // here (a DELETE, or a HEAD, which Express routes to the GET handler with
1331
1557
  // the method unchanged) is held for while the SDK handles it. See
@@ -1514,6 +1740,21 @@ function accountIdOf(who) {
1514
1740
  const id = account.id;
1515
1741
  return typeof id === 'string' && id.length > 0 && id.length <= 256 ? id : undefined;
1516
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
+ }
1517
1758
  /**
1518
1759
  * What a session counts against for the account ceiling (OPL-5447).
1519
1760
  *
@@ -1526,6 +1767,29 @@ function accountIdOf(who) {
1526
1767
  function accountKey(keyId, confirmed) {
1527
1768
  return confirmed !== undefined ? `account:${confirmed}` : `bearer:${keyId}`;
1528
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
+ }
1529
1793
  /** The 429 for an account at its ceiling. It names the limit and nothing else. */
1530
1794
  function accountFullMessage(account, cap) {
1531
1795
  const noun = cap === 1 ? 'session' : 'sessions';
@@ -1534,6 +1798,14 @@ function accountFullMessage(account, cap) {
1534
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.`;
1535
1799
  }
1536
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
+ }
1537
1809
  /** Whether a POST body holds a JSON-RPC request, which is what opens a stream. */
1538
1810
  function carriesRequest(body) {
1539
1811
  const isRequest = (m) => typeof m === 'object' && m !== null && 'method' in m && 'id' in m;
@@ -1541,6 +1813,17 @@ function carriesRequest(body) {
1541
1813
  }
1542
1814
  const NO_TOKEN = 'Authorization required. Authorize this client with Mandala (OAuth), or send a Mandala API key: Authorization: Bearer …';
1543
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
+ }
1544
1827
  /**
1545
1828
  * Hold a response's status line until its first byte of body (hosted mode).
1546
1829
  *