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.
- package/README.md +105 -13
- package/dist/cli.d.ts +7 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +57 -1
- package/dist/cli.js.map +1 -1
- package/dist/errors.d.ts +32 -8
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +50 -8
- package/dist/errors.js.map +1 -1
- package/dist/format.d.ts +8 -2
- package/dist/format.d.ts.map +1 -1
- package/dist/format.js +10 -3
- package/dist/format.js.map +1 -1
- package/dist/http.d.ts +32 -0
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +425 -52
- package/dist/http.js.map +1 -1
- package/dist/paths.d.ts +11 -6
- package/dist/paths.d.ts.map +1 -1
- package/dist/paths.js +14 -7
- package/dist/paths.js.map +1 -1
- package/dist/server.d.ts +1 -1
- package/dist/server.js +1 -1
- package/dist/tools/agent.js +12 -23
- package/dist/tools/agent.js.map +1 -1
- package/dist/tools/chat.d.ts.map +1 -1
- package/dist/tools/chat.js +46 -4
- package/dist/tools/chat.js.map +1 -1
- package/dist/tools/computers.d.ts.map +1 -1
- package/dist/tools/computers.js +31 -4
- package/dist/tools/computers.js.map +1 -1
- package/dist/tools/guest.d.ts.map +1 -1
- package/dist/tools/guest.js +21 -2
- package/dist/tools/guest.js.map +1 -1
- package/dist/tools/operations.d.ts +7 -2
- package/dist/tools/operations.d.ts.map +1 -1
- package/dist/tools/operations.js +29 -6
- package/dist/tools/operations.js.map +1 -1
- package/dist/tools/secrets.d.ts.map +1 -1
- package/dist/tools/secrets.js +4 -6
- package/dist/tools/secrets.js.map +1 -1
- package/dist/tools/snapshots.d.ts.map +1 -1
- package/dist/tools/snapshots.js +21 -8
- package/dist/tools/snapshots.js.map +1 -1
- package/dist/tools/templates.js +2 -2
- package/dist/tools/templates.js.map +1 -1
- package/dist/tools/webhooks.js +1 -1
- package/dist/tools/webhooks.js.map +1 -1
- 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 {
|
|
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
|
-
/**
|
|
216
|
-
const
|
|
217
|
-
const
|
|
218
|
-
return
|
|
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. `
|
|
223
|
-
*
|
|
224
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
234
|
-
|
|
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
|
|
244
|
-
//
|
|
245
|
-
// gives it the lowest role there is (viewer)
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
// sent.
|
|
253
|
-
await api.json('GET',
|
|
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,
|
|
264
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
666
|
+
if (heard())
|
|
667
|
+
live.lastSeen = Date.now();
|
|
601
668
|
};
|
|
602
669
|
};
|
|
603
|
-
|
|
604
|
-
|
|
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
|
-
/**
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
965
|
-
// slot and nothing the sweeper will reap. The
|
|
966
|
-
// this path without throwing; other early returns
|
|
967
|
-
|
|
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
|
|
1014
|
-
//
|
|
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;
|