mandala-computer-mcp 0.1.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 (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +544 -0
  3. package/dist/api.d.ts +186 -0
  4. package/dist/api.d.ts.map +1 -0
  5. package/dist/api.js +932 -0
  6. package/dist/api.js.map +1 -0
  7. package/dist/cli.d.ts +55 -0
  8. package/dist/cli.d.ts.map +1 -0
  9. package/dist/cli.js +292 -0
  10. package/dist/cli.js.map +1 -0
  11. package/dist/errors.d.ts +560 -0
  12. package/dist/errors.d.ts.map +1 -0
  13. package/dist/errors.js +873 -0
  14. package/dist/errors.js.map +1 -0
  15. package/dist/events.d.ts +406 -0
  16. package/dist/events.d.ts.map +1 -0
  17. package/dist/events.js +1679 -0
  18. package/dist/events.js.map +1 -0
  19. package/dist/format.d.ts +125 -0
  20. package/dist/format.d.ts.map +1 -0
  21. package/dist/format.js +180 -0
  22. package/dist/format.js.map +1 -0
  23. package/dist/http.d.ts +46 -0
  24. package/dist/http.d.ts.map +1 -0
  25. package/dist/http.js +792 -0
  26. package/dist/http.js.map +1 -0
  27. package/dist/index.d.ts +13 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +12 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/paths.d.ts +394 -0
  32. package/dist/paths.d.ts.map +1 -0
  33. package/dist/paths.js +677 -0
  34. package/dist/paths.js.map +1 -0
  35. package/dist/server.d.ts +18 -0
  36. package/dist/server.d.ts.map +1 -0
  37. package/dist/server.js +97 -0
  38. package/dist/server.js.map +1 -0
  39. package/dist/session.d.ts +78 -0
  40. package/dist/session.d.ts.map +1 -0
  41. package/dist/session.js +166 -0
  42. package/dist/session.js.map +1 -0
  43. package/dist/stdio.d.ts +11 -0
  44. package/dist/stdio.d.ts.map +1 -0
  45. package/dist/stdio.js +43 -0
  46. package/dist/stdio.js.map +1 -0
  47. package/dist/tools/agent.d.ts +16 -0
  48. package/dist/tools/agent.d.ts.map +1 -0
  49. package/dist/tools/agent.js +147 -0
  50. package/dist/tools/agent.js.map +1 -0
  51. package/dist/tools/computers.d.ts +3 -0
  52. package/dist/tools/computers.d.ts.map +1 -0
  53. package/dist/tools/computers.js +1037 -0
  54. package/dist/tools/computers.js.map +1 -0
  55. package/dist/tools/events.d.ts +3 -0
  56. package/dist/tools/events.d.ts.map +1 -0
  57. package/dist/tools/events.js +1077 -0
  58. package/dist/tools/events.js.map +1 -0
  59. package/dist/tools/guest.d.ts +3 -0
  60. package/dist/tools/guest.d.ts.map +1 -0
  61. package/dist/tools/guest.js +761 -0
  62. package/dist/tools/guest.js.map +1 -0
  63. package/dist/tools/input.d.ts +3 -0
  64. package/dist/tools/input.d.ts.map +1 -0
  65. package/dist/tools/input.js +240 -0
  66. package/dist/tools/input.js.map +1 -0
  67. package/dist/tools/snapshots.d.ts +3 -0
  68. package/dist/tools/snapshots.d.ts.map +1 -0
  69. package/dist/tools/snapshots.js +333 -0
  70. package/dist/tools/snapshots.js.map +1 -0
  71. package/dist/tools/templates.d.ts +3 -0
  72. package/dist/tools/templates.d.ts.map +1 -0
  73. package/dist/tools/templates.js +492 -0
  74. package/dist/tools/templates.js.map +1 -0
  75. package/dist/tools/types.d.ts +18 -0
  76. package/dist/tools/types.d.ts.map +1 -0
  77. package/dist/tools/types.js +2 -0
  78. package/dist/tools/types.js.map +1 -0
  79. package/dist/tools/webhooks.d.ts +3 -0
  80. package/dist/tools/webhooks.d.ts.map +1 -0
  81. package/dist/tools/webhooks.js +260 -0
  82. package/dist/tools/webhooks.js.map +1 -0
  83. package/package.json +59 -0
package/dist/http.js ADDED
@@ -0,0 +1,792 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
3
+ import { BlockList, isIP } from 'node:net';
4
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
5
+ import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
6
+ import express, {} from 'express';
7
+ import { Api, MODEL_KEY_HEADER } from './api.js';
8
+ import { createServer, SERVER_NAME, SERVER_VERSION } from './server.js';
9
+ const DEFAULT_TTL_MS = 30 * 60 * 1000;
10
+ /**
11
+ * A ceiling on live sessions.
12
+ *
13
+ * An initialize is cheap to send and expensive to serve — it builds a whole
14
+ * McpServer with every tool registered, plus a transport that then survives the
15
+ * TTL. The bearer cannot be checked without a round trip to the platform, so
16
+ * any string gets that far; without a cap, a loop of initializes is a memory
17
+ * exhaustion that costs the sender nothing.
18
+ */
19
+ const DEFAULT_MAX_SESSIONS = 256;
20
+ const DEFAULT_MAX_LARGE_BODY_PARSES = 4;
21
+ const SMALL_BODY_BYTES = 256 * 1024;
22
+ /**
23
+ * The addresses that mean "this machine only".
24
+ *
25
+ * `0.0.0.0` and `::` are deliberately absent: they bind every interface, which
26
+ * is an operator saying they want this reachable from elsewhere. Treating that
27
+ * as loopback would hand them a Host allowlist naming addresses their callers
28
+ * never send, and the deployment would answer 403 to everything.
29
+ *
30
+ * The whole of 127.0.0.0/8 is loopback, not only 127.0.0.1. Binding
31
+ * `127.0.0.2` used to skip the default Host check because the set named three
32
+ * spellings and nothing else on this machine.
33
+ *
34
+ * IPv4-mapped loopback — `::ffff:127.0.0.1`, and the bracketed spelling — is
35
+ * the same widening one notation further out. It is a v6 socket carrying a v4
36
+ * loopback address, Node will bind it, and it was falling through to "not
37
+ * loopback": no default Host allowlist, so DNS-rebinding protection silently
38
+ * off on a bind that is as local as `127.0.0.1` is.
39
+ *
40
+ * IPv6 loopback is the same hole one spelling further in. Node accepts
41
+ * `0:0:0:0:0:0:0:1` and `::0:1` and reports the bound address as `::1`, but
42
+ * `cfg.host` stays the operator's spelling — a string match on `::1` alone
43
+ * skipped the default Host allowlist and left DNS-rebinding protection off.
44
+ * Parsed as an address, every compression of all-zeros-then-one is loopback.
45
+ *
46
+ * A zone suffix (`%lo`) is dropped first, since it names an interface rather
47
+ * than an address.
48
+ */
49
+ const LOOPBACK = new BlockList();
50
+ LOOPBACK.addAddress('::1', 'ipv6');
51
+ LOOPBACK.addSubnet('127.0.0.0', 8, 'ipv4');
52
+ export function isLoopbackHost(host) {
53
+ let h = host.toLowerCase();
54
+ if (h.startsWith('[') && h.endsWith(']'))
55
+ h = h.slice(1, -1);
56
+ const zone = h.indexOf('%');
57
+ if (zone >= 0)
58
+ h = h.slice(0, zone);
59
+ if (h === 'localhost')
60
+ return true;
61
+ const version = isIP(h);
62
+ if (version === 4)
63
+ return LOOPBACK.check(h, 'ipv4');
64
+ if (version === 6)
65
+ return LOOPBACK.check(h, 'ipv6');
66
+ return false;
67
+ }
68
+ /**
69
+ * One operator-supplied allowlist entry, as the Host headers it should match.
70
+ *
71
+ * Bracket-aware, because that is the whole difficulty: a literal IPv6 address
72
+ * is full of colons and is only a `host:port` when the colon falls after the
73
+ * `]`. Three cases, and only the last is expanded:
74
+ *
75
+ * - already carries a port — left exactly as written. That operator has said
76
+ * which port their callers use, and it need not be the one bound here.
77
+ * - a BARE IPv6 address, unbracketed. `::1` is not a legal Host header and
78
+ * `::1` + `:3000` is `::1:3000`, which nothing can send — so it is BRACKETED
79
+ * into the spellings a client actually sends, rather than left as a dead
80
+ * entry that matches nothing. An operator who writes the address has said
81
+ * which host they mean; the brackets are notation, not a second guess.
82
+ * - anything else — matched with and without the bound port.
83
+ */
84
+ export function hostSpellings(host, port) {
85
+ if (host.startsWith('[')) {
86
+ // Already bracketed: with a port it is exactly what a client sends, and
87
+ // without one it still needs the ported spelling.
88
+ return /\]:\d+$/.test(host) ? [host] : [host, `${host}:${port}`];
89
+ }
90
+ // Unbracketed and full of colons is a v6 literal; one colon and digits is a
91
+ // name that already names its port.
92
+ if (/:\d+$/.test(host) && host.indexOf(':') === host.lastIndexOf(':'))
93
+ return [host];
94
+ if (host.includes(':'))
95
+ return [`[${host}]`, `[${host}]:${port}`];
96
+ return [host, `${host}:${port}`];
97
+ }
98
+ /**
99
+ * The Host/Origin refusal the SDK would have returned from handleRequest.
100
+ *
101
+ * Matched here so a DNS-rebinding initialize never reaches `createServer`.
102
+ * The SDK's check returns a 403 Response without throwing, so a refusal that
103
+ * ran after construction paid for every tool registration and never hit the
104
+ * catch that closes the transport (adversarial review, OPL-4314).
105
+ */
106
+ function dnsRebindingRefusal(req, hosts, origins) {
107
+ if (!(hosts?.length || origins?.length))
108
+ return undefined;
109
+ if (hosts?.length) {
110
+ const hostHeader = req.header('host');
111
+ if (!hostHeader || !hosts.includes(hostHeader)) {
112
+ return `Invalid Host header: ${hostHeader}`;
113
+ }
114
+ }
115
+ if (origins?.length) {
116
+ const originHeader = req.header('origin');
117
+ if (originHeader && !origins.includes(originHeader)) {
118
+ return `Invalid Origin header: ${originHeader}`;
119
+ }
120
+ }
121
+ return undefined;
122
+ }
123
+ /**
124
+ * The hosted install: one URL, and every caller brings their own key.
125
+ *
126
+ * The important property of this server is what it does NOT hold. There is no
127
+ * credential of its own, no store, and no state that outlives a session: a
128
+ * caller's `com_…` key arrives as their own bearer token, is used for their
129
+ * requests, and is never written down. What is kept is a digest of it, so that
130
+ * a later request on the same session can be shown to come from the same
131
+ * holder — a session id on its own is then not enough to drive somebody else's
132
+ * desktop, which it otherwise would be.
133
+ */
134
+ export async function runHttp(cfg) {
135
+ // HTTP does not construct an Api until the first initialize, which made a
136
+ // bad MANDALA_BASE_URL look like a working bind and then fail that caller
137
+ // with a generic 500. Validate it before opening the listening socket, using
138
+ // the same constructor and therefore the same rules as every real session.
139
+ new Api('startup-validation-only', cfg.baseUrl);
140
+ const app = express();
141
+ // parseBody consumes every POST /mcp body. Every other route deliberately
142
+ // ignores request bodies, so put those requests in flowing mode immediately:
143
+ // leaving bytes unread can strand a keep-alive connection behind one wrong
144
+ // path or a GET/DELETE client that sent a body anyway.
145
+ app.use((req, _res, next) => {
146
+ if (req.method !== 'POST' || req.path !== '/mcp')
147
+ req.resume();
148
+ next();
149
+ });
150
+ const sessions = new Map();
151
+ // The port sessions are actually reachable on, which is not cfg.port when the
152
+ // operator asked for 0. Read when a transport is built rather than captured
153
+ // at construction, because the default Host allowlist below carries it and a
154
+ // list naming port 0 would match nothing a client could ever send.
155
+ let boundPort = cfg.port;
156
+ const ttl = cfg.sessionTtlMs ?? DEFAULT_TTL_MS;
157
+ const maxSessions = cfg.maxSessions ?? DEFAULT_MAX_SESSIONS;
158
+ const maxLargeBodyParses = cfg.maxLargeBodyParses ?? DEFAULT_MAX_LARGE_BODY_PARSES;
159
+ let largeBodyParses = 0;
160
+ // Tool callbacks inherit the request's lease even when a disconnected
161
+ // response lets transport.handleRequest() settle before the callback does.
162
+ const requestBodyLease = new AsyncLocalStorage();
163
+ // Initializes that have passed the cap check but have not yet reached
164
+ // `onsessioninitialized`. Counted, because the check and the map write are
165
+ // two awaits apart: without a reservation every concurrent initialize reads
166
+ // the same `sessions.size`, all of them pass, and the cap bounds nothing —
167
+ // which is the exact memory exhaustion it was put here to stop.
168
+ let pending = 0;
169
+ // Two parsers, chosen by whether this server has already checked who is
170
+ // asking.
171
+ //
172
+ // `express.json` buffers and parses the whole body before any route runs, so
173
+ // mounted globally at 80mb it spent that on a caller who had sent no key —
174
+ // free to send, expensive to serve, and nothing about it needed a
175
+ // credential. The large limit is what `write_file` needs, and `write_file`
176
+ // always arrives on an established session, so it is given to exactly that:
177
+ // a request naming a live session whose key digest matches the bearer it
178
+ // carried, which is the same test the POST route applies before doing
179
+ // anything. Everyone else — including an initialize, which is a few hundred
180
+ // bytes — gets the small one and a 413. Large parses are capped separately:
181
+ // initialize does not contact the platform, so an arbitrary bearer can still
182
+ // earn a matching digest and must not be able to start hundreds of concurrent
183
+ // 96 MiB allocations.
184
+ //
185
+ // Presence of an `Authorization` header is deliberately not the test. This
186
+ // server cannot check a `com_…` key without a round trip to the platform, so
187
+ // a header alone identifies nobody: `Bearer x` would buy the 80mb buffer as
188
+ // cheaply as sending nothing at all, and the limit would bound only the
189
+ // callers who had not thought about it. The digest proves continuity with a
190
+ // session holder, not that the platform accepted the key; the concurrency
191
+ // cap above is therefore still required.
192
+ //
193
+ // Which status a request ends at is unchanged as long as it stays under the
194
+ // limit, because the body is still parsed: a non-initialize with no session
195
+ // is still a 400, not a 401 about the key it also did not send.
196
+ // 96mb rather than 80: the limit exists for write_file, and it could not
197
+ // carry one. The platform caps a transfer at 64 MiB, base64 is four bytes for
198
+ // every three, and the JSON-RPC envelope is on top of that — 89,478,488
199
+ // characters of content against a limit of 83,886,080, so the largest file
200
+ // this server is documented to write was refused with a 413 by the very
201
+ // allowance that was raised for it. The real ceiling was about 59 MiB, which
202
+ // is not a number anybody would have found except by hitting it.
203
+ const fullBody = express.json({ limit: '96mb' });
204
+ const smallBody = express.json({ limit: '256kb' });
205
+ const parseBody = (req, res, next) => {
206
+ if (!wouldUseLargeParser(req, sessions))
207
+ return smallBody(req, res, next);
208
+ if (largeBodyParses >= maxLargeBodyParses) {
209
+ // Drain before answering so unread request bytes cannot be mistaken for
210
+ // the next request on a keep-alive connection. A peer that aborts the
211
+ // upload gets a closed response rather than a misleading reusable one.
212
+ const cleanup = () => {
213
+ req.off('end', drained);
214
+ req.off('aborted', failed);
215
+ req.off('error', failed);
216
+ };
217
+ const drained = () => {
218
+ cleanup();
219
+ if (!res.destroyed && !res.headersSent) {
220
+ unavailable(res, `This server is already parsing its maximum of ${maxLargeBodyParses} large request bodies. Retry shortly.`);
221
+ }
222
+ };
223
+ const failed = () => {
224
+ cleanup();
225
+ res.destroy();
226
+ };
227
+ if (req.readableEnded)
228
+ drained();
229
+ else {
230
+ req.once('end', drained);
231
+ req.once('aborted', failed);
232
+ req.once('error', failed);
233
+ req.resume();
234
+ }
235
+ return;
236
+ }
237
+ largeBodyParses++;
238
+ let references = 1;
239
+ const drop = () => {
240
+ if (references <= 0)
241
+ return;
242
+ references--;
243
+ if (references === 0)
244
+ largeBodyParses--;
245
+ };
246
+ const lease = {
247
+ retain: () => {
248
+ if (references <= 0)
249
+ return () => { };
250
+ references++;
251
+ let held = true;
252
+ return () => {
253
+ if (!held)
254
+ return;
255
+ held = false;
256
+ drop();
257
+ };
258
+ },
259
+ release: drop,
260
+ };
261
+ return fullBody(req, res, (err) => {
262
+ // A failed parse never reaches the route that normally releases this
263
+ // lease. A successful one keeps it for the route and tool lifetimes:
264
+ // req.body is the large allocation being bounded, and express.json
265
+ // finishing does not free it while asynchronous MCP work still uses it.
266
+ if (err)
267
+ lease.release();
268
+ else
269
+ res.locals.largeBodyLease = lease;
270
+ next(err);
271
+ });
272
+ };
273
+ const releaseLargeBody = (res) => {
274
+ const lease = res.locals.largeBodyLease;
275
+ delete res.locals.largeBodyLease;
276
+ lease?.release();
277
+ };
278
+ // Abandoned sessions are closed rather than left holding a transport. A
279
+ // client that goes away without a DELETE is the ordinary case, not the odd
280
+ // one — laptops sleep and tabs close.
281
+ //
282
+ // Inspected once a minute, or as often as the TTL if that is shorter — a
283
+ // sessionTtlMs of ten seconds that was only looked at every sixty is not the
284
+ // TTL the operator asked for.
285
+ const sweepMs = Math.min(60_000, Math.max(1_000, ttl));
286
+ const sweeper = setInterval(() => {
287
+ const cutoff = Date.now() - ttl;
288
+ for (const [id, live] of sessions) {
289
+ // Idle means nothing arriving AND nothing in flight. `lastSeen` is
290
+ // stamped when a request begins and again when it ends, so a single call
291
+ // that outlives the TTL — run_agent is minutes of clicking, and
292
+ // wait_for_computer takes a timeout_s of up to 900 — would otherwise be
293
+ // swept while it was still being served, closing the transport under an
294
+ // answer the caller had not received yet.
295
+ if (live.active === 0 && live.lastSeen < cutoff) {
296
+ sessions.delete(id);
297
+ void live.transport.close().catch(() => { });
298
+ }
299
+ }
300
+ }, sweepMs);
301
+ sweeper.unref();
302
+ /**
303
+ * Hold a session open for as long as one request is being served on it.
304
+ *
305
+ * Only for request/response traffic. The standing server-to-client stream is
306
+ * deliberately not counted: a conforming client opens `GET /mcp` once and
307
+ * holds it for the whole session, so counting it would make `active` never
308
+ * reach zero and no session would ever be swept — and the case the sweeper
309
+ * exists for, a laptop that slept and left the socket half-open, is exactly
310
+ * the one where `close` never fires to undo the count. The transport and its
311
+ * fully-registered server would sit on a `maxSessions` slot forever.
312
+ */
313
+ const beginActivity = (live) => {
314
+ live.active++;
315
+ live.lastSeen = Date.now();
316
+ let held = true;
317
+ return () => {
318
+ if (!held)
319
+ return;
320
+ held = false;
321
+ live.active--;
322
+ live.lastSeen = Date.now();
323
+ };
324
+ };
325
+ const serving = async (live, handle) => {
326
+ const release = beginActivity(live);
327
+ try {
328
+ return await handle();
329
+ }
330
+ finally {
331
+ // The socket may close while the tool keeps running. Releasing on
332
+ // Response.close made the session look idle at that point, so a short
333
+ // TTL could sweep and close its transport underneath handleRequest.
334
+ // The work itself is the lifetime that matters.
335
+ release();
336
+ }
337
+ };
338
+ /** Stamp a session as heard from, without claiming anything is in flight. */
339
+ const touch = (live, res) => {
340
+ live.lastSeen = Date.now();
341
+ res.on('close', () => {
342
+ live.lastSeen = Date.now();
343
+ });
344
+ };
345
+ /**
346
+ * The Host headers a legitimate client sends, when nobody configured a list.
347
+ *
348
+ * A loopback bind is reached as `127.0.0.1:port`, `localhost:port` or
349
+ * `[::1]:port` depending on what was typed, and all three are this server; a
350
+ * name resolved to 127.0.0.1 by a page the user is visiting is not, and is
351
+ * exactly what the check exists to turn away.
352
+ *
353
+ * A non-loopback bind gets no default. The operator deliberately exposed this
354
+ * server and there is no way to guess the names it is legitimately reached
355
+ * by — inventing a list would break the deployment rather than protect it —
356
+ * so protection there stays opt-in, and startup says so.
357
+ */
358
+ function allowedHosts(portNow) {
359
+ // Lowercased to match the folded header — an operator who writes
360
+ // `Example.COM` means the same host the client sends as `example.com`.
361
+ //
362
+ // Expanded with and without the bound port, exactly as the loopback default
363
+ // below is, and for the same reason: the SDK's check is a whole-header
364
+ // `allowedHosts.includes(hostHeader)`, and a browser sends the port whenever
365
+ // it is not the scheme's default. An operator writing the obvious
366
+ // `MANDALA_ALLOWED_HOSTS=mcp.example.com` for a server bound on :3000 got a
367
+ // list that matched no header any direct client sends, so every request was
368
+ // answered 403 by the protection they had just turned on. See
369
+ // {@link hostSpellings} for which entries are expanded and which are not.
370
+ if (cfg.allowedHosts?.length) {
371
+ return [
372
+ ...new Set(cfg.allowedHosts.map((h) => h.toLowerCase()).flatMap((h) => hostSpellings(h, portNow))),
373
+ ];
374
+ }
375
+ if (!isLoopbackHost(cfg.host))
376
+ return undefined;
377
+ // `cfg.host` goes in as the spellings a client SENDS, which for a v6
378
+ // literal means bracketed. Widening isLoopbackHost to accept
379
+ // `::ffff:127.0.0.1` would otherwise have handed that bind a default
380
+ // allowlist naming only the bare form — so a client using the address it
381
+ // was given, `http://[::ffff:127.0.0.1]:port`, would be 403'd by
382
+ // protection that had not existed there before.
383
+ const bare = cfg.host.toLowerCase();
384
+ const own = bare.startsWith('[') || !bare.includes(':') ? [bare] : [`[${bare}]`];
385
+ const names = new Set(['127.0.0.1', 'localhost', '[::1]', ...own]);
386
+ return [...names].flatMap((h) => [`${h}:${portNow}`, h]);
387
+ }
388
+ const allowedOrigins = cfg.allowedOrigins?.map((origin) => origin.toLowerCase());
389
+ // Host names are case-insensitive, and the SDK's rebinding check is not: it
390
+ // is a plain `allowedHosts.includes(hostHeader)` against the header as sent,
391
+ // so a conformant client that says `Host: LOCALHOST:3000` is answered 403 by
392
+ // a list that contains `localhost:3000`. Nothing above can fix that from
393
+ // outside the SDK, but the header can be normalised to the one spelling the
394
+ // list is written in before it gets there. Safe to fold: RFC 3986 says the
395
+ // host is case-insensitive, and `new URL()` already lowercases it, which is
396
+ // why browsers and fetch never trip this and a hand-set header does.
397
+ //
398
+ // Folded in `rawHeaders`, not just `req.headers`. The transport is a wrapper
399
+ // over @hono/node-server, which rebuilds the web Request from
400
+ // `incoming.rawHeaders` and never looks at the parsed object Express hands
401
+ // around — so normalising only the latter changes nothing the check can see.
402
+ app.use((req, _res, next) => {
403
+ const raw = req.rawHeaders;
404
+ for (let i = 0; i < raw.length; i += 2) {
405
+ const name = raw[i].toLowerCase();
406
+ if (name === 'host' || name === 'origin')
407
+ raw[i + 1] = raw[i + 1].toLowerCase();
408
+ }
409
+ if (req.headers.host)
410
+ req.headers.host = req.headers.host.toLowerCase();
411
+ if (req.headers.origin)
412
+ req.headers.origin = req.headers.origin.toLowerCase();
413
+ next();
414
+ });
415
+ // Liveness is public; OCCUPANCY is not.
416
+ //
417
+ // `sessions` and `largeBodyParses` are the two caps this process enforces,
418
+ // and their current values are the two numbers somebody would want in order
419
+ // to time an exhaustion of either — how close the table is to 256, and
420
+ // whether all four parse slots are taken right now. Unauthenticated, on an
421
+ // exposed bind, that is a capacity oracle handed over on request.
422
+ //
423
+ // Kept where they are useful and cannot be read from outside: a loopback
424
+ // bind, where the only readers are on this machine, and an operator who has
425
+ // configured a Host allowlist, which is the deliberate act that says who may
426
+ // reach this server at all. Everywhere else the endpoint still answers, still
427
+ // says the server is alive, and simply does not count out loud.
428
+ const countsArePrivate = isLoopbackHost(cfg.host) || Boolean(cfg.allowedHosts?.length);
429
+ app.get('/healthz', (_req, res) => {
430
+ res.json({
431
+ ok: true,
432
+ name: SERVER_NAME,
433
+ version: SERVER_VERSION,
434
+ ...(countsArePrivate ? { sessions: sessions.size, largeBodyParses } : {}),
435
+ });
436
+ });
437
+ app.post('/mcp', parseBody, async (req, res) => {
438
+ const sessionId = req.header('mcp-session-id');
439
+ const key = bearer(req);
440
+ if (sessionId) {
441
+ try {
442
+ const live = sessions.get(sessionId);
443
+ if (!live)
444
+ return notFound(res, 'Unknown session. Initialize a new one.', rpcId(req));
445
+ // The key is re-checked on every request, not only at initialize. A
446
+ // session id travels in a plain header and is the sort of thing that ends
447
+ // up in a proxy log; on its own it must not be a credential.
448
+ if (!key || !sameKey(live.keyDigest, key)) {
449
+ return unauthorized(res, 'This session belongs to a different API key.', rpcId(req));
450
+ }
451
+ const lease = res.locals.largeBodyLease;
452
+ return await requestBodyLease.run(lease, () => serving(live, () => live.transport.handleRequest(req, res, req.body)));
453
+ }
454
+ finally {
455
+ // A verified session is the only request allowed through the large
456
+ // parser. Release the route's ownership here; a tool callback that
457
+ // outlives handleRequest retains its own reference through activity().
458
+ releaseLargeBody(res);
459
+ }
460
+ }
461
+ // Sessionless requests always use the small parser. This no-op keeps the
462
+ // ownership explicit if the parser policy changes later.
463
+ releaseLargeBody(res);
464
+ if (!isInitializeRequest(req.body)) {
465
+ return badRequest(res, 'No session id, and this is not an initialize request.', rpcId(req));
466
+ }
467
+ if (!key) {
468
+ return unauthorized(res, 'Send your Mandala API key as a bearer token: Authorization: Bearer com_…', rpcId(req));
469
+ }
470
+ // Host/Origin are already decided, and constructing the McpServer is the
471
+ // expensive part of an initialize. Checked here so a DNS-rebinding POST
472
+ // never pays that cost, never takes a pending slot, and is 403 even when
473
+ // the session cap is full — occupancy is not the answer to a Host the
474
+ // operator never served (adversarial review, OPL-4314).
475
+ const hosts = allowedHosts(boundPort);
476
+ const refused = dnsRebindingRefusal(req, hosts, allowedOrigins);
477
+ if (refused)
478
+ return rpcError(res, 403, -32000, refused);
479
+ // Swept sessions free their slot on the timer; this is the backstop for the
480
+ // case the timer cannot help with, which is arrivals faster than the TTL.
481
+ if (sessions.size + pending >= maxSessions) {
482
+ return unavailable(res, `This server is holding its maximum of ${maxSessions} sessions. Retry shortly.`, rpcId(req));
483
+ }
484
+ pending++;
485
+ // Released exactly once, whether the initialize lands in the map or throws
486
+ // on the way there. A reservation that leaked on the failure path would
487
+ // ratchet the cap down until the process restarted — every later initialize
488
+ // refused with 503 for the life of the process, which is the denial of
489
+ // service the counter was added to prevent, self-inflicted.
490
+ let reserved = true;
491
+ const release = () => {
492
+ if (reserved) {
493
+ reserved = false;
494
+ pending--;
495
+ }
496
+ };
497
+ // Everything from here to the map write is inside the reservation,
498
+ // constructors included: they are ordinary code that can throw, and Express
499
+ // turning that into a 500 is precisely the path that used to leak.
500
+ //
501
+ // `transport` is declared out here so the catch can reach it: the session
502
+ // is written to the map from inside handleRequest, so a throw after that
503
+ // point has something to clean up.
504
+ let transport;
505
+ let mcp;
506
+ let finishInitialize = () => { };
507
+ try {
508
+ const t = new StreamableHTTPServerTransport({
509
+ sessionIdGenerator: () => randomUUID(),
510
+ // On whenever there is a list to check against, which for a loopback
511
+ // bind is always — see `allowedHosts`. A browser cannot be stopped from
512
+ // resolving a name it controls to 127.0.0.1, so the Host header is the
513
+ // only thing separating the operator's own client from a page the user
514
+ // happened to open, and the MCP spec asks a locally-bound server to
515
+ // check it.
516
+ enableDnsRebindingProtection: Boolean(hosts?.length || allowedOrigins?.length),
517
+ allowedHosts: hosts,
518
+ allowedOrigins,
519
+ onsessioninitialized: (id) => {
520
+ const live = {
521
+ transport: t,
522
+ keyDigest: digest(key),
523
+ lastSeen: Date.now(),
524
+ // Initialize is already in flight when the session first becomes
525
+ // visible to the sweeper. Count it until handleRequest settles.
526
+ active: 1,
527
+ };
528
+ sessions.set(id, live);
529
+ let held = true;
530
+ finishInitialize = () => {
531
+ if (!held)
532
+ return;
533
+ held = false;
534
+ live.active--;
535
+ live.lastSeen = Date.now();
536
+ };
537
+ release();
538
+ },
539
+ onsessionclosed: (id) => {
540
+ sessions.delete(id);
541
+ },
542
+ });
543
+ transport = t;
544
+ t.onclose = () => {
545
+ if (t.sessionId)
546
+ sessions.delete(t.sessionId);
547
+ };
548
+ const server = createServer({
549
+ ...cfg,
550
+ apiKey: key,
551
+ activity: () => {
552
+ const id = t.sessionId;
553
+ const live = id ? sessions.get(id) : undefined;
554
+ const finishActivity = live ? beginActivity(live) : () => { };
555
+ const finishBody = requestBodyLease.getStore()?.retain();
556
+ return () => {
557
+ finishBody?.();
558
+ finishActivity();
559
+ };
560
+ },
561
+ // Per-caller, and deliberately with no fallback to cfg.modelKey: an
562
+ // operator who set MANDALA_MODEL_KEY for their own stdio use would
563
+ // otherwise be billed for every stranger's run here. Absent means
564
+ // run_agent is simply not offered to this session.
565
+ modelKey: req.header(MODEL_KEY_HEADER)?.trim() || undefined,
566
+ // Dropped for the same reason, one field further on. MANDALA_COMPUTER_ID
567
+ // is the operator's own machine, and spreading it into a stranger's
568
+ // session pre-binds their key to a computer on somebody else's account:
569
+ // every call until they run use_computer 404s, and the id of a machine
570
+ // that is not theirs is named back to them by way of explanation.
571
+ computerId: undefined,
572
+ });
573
+ mcp = server;
574
+ await server.connect(t);
575
+ const handled = await t.handleRequest(req, res, req.body);
576
+ // Any initialize that never reached `onsessioninitialized` has no map
577
+ // slot and nothing the sweeper will reap. The SDK's 403 used to take
578
+ // this path without throwing; other early returns still can.
579
+ if (!t.sessionId) {
580
+ void server.close().catch(() => { });
581
+ void t.close().catch(() => { });
582
+ }
583
+ return handled;
584
+ }
585
+ catch (err) {
586
+ // `onsessioninitialized` fires from inside handleRequest, so by the time
587
+ // anything past it throws the session is already in the map — holding a
588
+ // maxSessions slot and a key digest, under an id the client never learned
589
+ // and so can never DELETE. Left alone it sits there until the TTL sweep
590
+ // half an hour later, and enough of them ratchet the cap to zero.
591
+ if (transport?.sessionId)
592
+ sessions.delete(transport.sessionId);
593
+ if (mcp)
594
+ void mcp.close().catch(() => { });
595
+ else if (transport)
596
+ void transport.close().catch(() => { });
597
+ throw err;
598
+ }
599
+ finally {
600
+ finishInitialize();
601
+ release();
602
+ }
603
+ });
604
+ // The server-to-client stream, and session teardown. Both are addressed by
605
+ // session id alone in the protocol, so both re-check the key for the reason
606
+ // the POST does.
607
+ const bySession = async (req, res) => {
608
+ const sessionId = req.header('mcp-session-id');
609
+ const live = sessionId ? sessions.get(sessionId) : undefined;
610
+ if (!live)
611
+ return notFound(res, 'Unknown session.');
612
+ const key = bearer(req);
613
+ if (!key || !sameKey(live.keyDigest, key)) {
614
+ return unauthorized(res, 'This session belongs to a different API key.');
615
+ }
616
+ // The DELETE is a request and is held for; the GET is the notification
617
+ // stream and is only noted. See `serving`.
618
+ if (req.method === 'GET') {
619
+ touch(live, res);
620
+ return live.transport.handleRequest(req, res);
621
+ }
622
+ return serving(live, () => live.transport.handleRequest(req, res));
623
+ };
624
+ app.get('/mcp', bySession);
625
+ app.delete('/mcp', bySession);
626
+ // Express's default 404 is HTML. Keep every answer from this MCP-facing
627
+ // server in the JSON-RPC shape its clients can surface, even when the path
628
+ // itself was wrong.
629
+ app.use((req, res) => notFound(res, `Unknown path: ${req.method} ${req.path}. Use /mcp or /healthz.`, rpcId(req)));
630
+ // The last word on anything that threw, because Express's own last word is
631
+ // an HTML page.
632
+ //
633
+ // `finalhandler` renders the error — message, and outside NODE_ENV=production
634
+ // the whole stack, absolute paths and all — into the response body. Two
635
+ // things reach it here. A body-parser refusal is one: `express.json` throws
636
+ // `entity.too.large` past the limit and `entity.parse.failed` on malformed
637
+ // JSON, and neither is caught by a route, because neither ever reaches one.
638
+ // Anything a handler throws is the other. Both used to leave an MCP client
639
+ // holding markup it has no way to report to its user, and the too-large case
640
+ // in particular is now reachable by anyone who can open a socket, since the
641
+ // small limit is what an unidentified caller gets.
642
+ //
643
+ // Body-parser failures are answered with their own status and message: they
644
+ // describe the request the sender just made, and knowing it was too large or
645
+ // malformed is what lets them fix it. Everything else is a bug in this
646
+ // server, so it is logged here and the sender is told only that it happened
647
+ // — the stack is for the operator's terminal, not for the wire.
648
+ app.use((err, _req, res, next) => {
649
+ // Streaming answers are the ordinary case on /mcp, and once bytes are out
650
+ // the status line is long gone. Express's handler is the only thing that
651
+ // can destroy the socket at that point; ours would append JSON to an SSE
652
+ // stream.
653
+ if (res.headersSent)
654
+ return next(err);
655
+ const e = err;
656
+ // `type` is body-parser's marker, and its errors carry a status of their
657
+ // own. Anything else with a status did not come from parsing a body.
658
+ if (typeof e?.type === 'string' && typeof e.status === 'number') {
659
+ const message = e.type === 'entity.too.large'
660
+ ? tooLargeMessage(_req, sessions)
661
+ : typeof e.message === 'string'
662
+ ? e.message
663
+ : 'This request body could not be read.';
664
+ return rpcError(res, e.status, -32000, message, rpcId(_req));
665
+ }
666
+ console.error('mandala-computer-mcp: unhandled error serving a request', err);
667
+ return rpcError(res, 500, -32603, 'This server failed while serving the request.', rpcId(_req));
668
+ });
669
+ return new Promise((resolve, reject) => {
670
+ let listening = false;
671
+ const http = app.listen(cfg.port, cfg.host, () => {
672
+ listening = true;
673
+ // The bound port, not the requested one. `port()` deliberately accepts 0,
674
+ // which means "any free port" — and printing it back gives the operator
675
+ // http://127.0.0.1:0/mcp, a URL that cannot be used to reach the server
676
+ // they were just told was up.
677
+ const addr = http.address();
678
+ const bound = typeof addr === 'object' && addr ? addr.port : cfg.port;
679
+ boundPort = bound;
680
+ console.error(`mandala-computer-mcp on http://${cfg.host}:${bound}/mcp — callers authenticate with their own Mandala API key`);
681
+ // Said once, at the only moment anybody is reading. A bind that is not
682
+ // loopback cannot have its legitimate Host values guessed, so the check
683
+ // is off and the operator is the only one who can turn it on — and an
684
+ // exposed server with no Host check is reachable by any page that
685
+ // resolves its own name to this address.
686
+ if (!allowedHosts(bound)) {
687
+ console.error(` no Host allowlist for ${cfg.host} — set MANDALA_ALLOWED_HOSTS to the name(s) this is served under to enable DNS-rebinding protection`);
688
+ }
689
+ else if (!cfg.allowedHosts?.length) {
690
+ // The other half of the same sentence, and the one that costs an
691
+ // operator a working install if it goes unsaid. A loopback bind gets a
692
+ // Host allowlist by default, which is right for the local case and
693
+ // wrong for the very common one where this sits behind nginx, Caddy or
694
+ // cloudflared: the proxy forwards `Host: mcp.example.com`, the check
695
+ // refuses it, and every request 403s with nothing in the log to say
696
+ // which header was the problem. Named here so the fix is one line
697
+ // rather than an afternoon.
698
+ console.error(` answering only to Host: 127.0.0.1, localhost or [::1] (with or without :${bound}) — set MANDALA_ALLOWED_HOSTS if this is served under a name, e.g. behind a proxy`);
699
+ }
700
+ resolve(http);
701
+ });
702
+ // Without a listener, an 'error' event is rethrown as an uncaught
703
+ // exception — a stack trace for EADDRINUSE, the most ordinary operational
704
+ // failure there is, and a promise that never settles. Rejecting instead
705
+ // lets main()'s catch print the one sentence.
706
+ //
707
+ // Only while the bind is still pending, though. A server that is already
708
+ // up emits 'error' for things it goes on serving through, and tearing down
709
+ // the sweeper there would leave the session cap with nothing to reap
710
+ // against — every later caller refused, for a connection error minutes
711
+ // earlier that the reject could no longer report anyway.
712
+ http.on('error', (err) => {
713
+ if (listening) {
714
+ // Not rejected and not torn down, for the reasons above — but not
715
+ // discarded either. EMFILE on accept leaves a server that refuses every
716
+ // connection with nothing anywhere saying why, and the operator's only
717
+ // other clue is silence. Logged where the unhandled-error path already
718
+ // logs, so there is one place to look.
719
+ console.error('mandala-computer-mcp: server error after bind —', err);
720
+ return;
721
+ }
722
+ clearInterval(sweeper);
723
+ reject(err);
724
+ });
725
+ // Closing the server has to take the sessions with it, and has to do it on
726
+ // the way in. `close()` stops new connections and then waits for the ones
727
+ // already in flight, so a session holding an open server-to-client stream
728
+ // keeps it from ever completing — an embedding host that shuts this down
729
+ // would hang rather than exit. Doing this in the 'close' event instead
730
+ // would be too late by definition: that event cannot fire until the
731
+ // streams this needs to end are already gone.
732
+ const teardown = () => {
733
+ clearInterval(sweeper);
734
+ for (const live of sessions.values())
735
+ void live.transport.close().catch(() => { });
736
+ sessions.clear();
737
+ };
738
+ const closeServer = http.close.bind(http);
739
+ http.close = ((cb) => {
740
+ teardown();
741
+ return closeServer(cb);
742
+ });
743
+ http.on('close', teardown);
744
+ });
745
+ }
746
+ /**
747
+ * Whether this request is allowed through the 96mb parser.
748
+ *
749
+ * Shared with the 413 handler so a verified session that actually hit that
750
+ * ceiling is not told to initialize for 256KB.
751
+ */
752
+ function wouldUseLargeParser(req, sessions) {
753
+ const id = req.header('mcp-session-id');
754
+ const live = id ? sessions.get(id) : undefined;
755
+ const key = bearer(req);
756
+ const verified = Boolean(live && key && sameKey(live.keyDigest, key));
757
+ const rawLength = req.header('content-length');
758
+ const mayBeLarge = rawLength === undefined || !/^\d+$/.test(rawLength) || Number(rawLength) > SMALL_BODY_BYTES;
759
+ return verified && mayBeLarge;
760
+ }
761
+ function tooLargeMessage(req, sessions) {
762
+ return wouldUseLargeParser(req, sessions)
763
+ ? 'Request body is too large. This established session accepts at most 96MB.'
764
+ : 'Request body is too large. Bodies above 256KB are accepted only on an established session, by a caller whose key matches it — initialize first, then send this there.';
765
+ }
766
+ function bearer(req) {
767
+ const auth = req.header('authorization') ?? '';
768
+ // RFC 7235 §2.1 makes the scheme case-insensitive, and a client that sends
769
+ // `bearer com_…` is sending a well-formed credential. Matching only the
770
+ // capitalised spelling answers it with a 401 whose message tells it to do
771
+ // the thing it just did.
772
+ const m = /^bearer[ \t]+/i.exec(auth);
773
+ return m ? auth.slice(m[0].length).trim() || undefined : undefined;
774
+ }
775
+ const digest = (key) => createHash('sha256').update(key).digest();
776
+ /** Constant-time, because this comparison decides whether a session is yours. */
777
+ function sameKey(expected, candidate) {
778
+ const actual = digest(candidate);
779
+ return expected.length === actual.length && timingSafeEqual(expected, actual);
780
+ }
781
+ function rpcId(req) {
782
+ const id = req.body?.id;
783
+ return typeof id === 'string' || typeof id === 'number' || id === null ? id : null;
784
+ }
785
+ const rpcError = (res, status, code, message, id = null) => {
786
+ res.status(status).json({ jsonrpc: '2.0', error: { code, message }, id });
787
+ };
788
+ const badRequest = (res, m, id = null) => rpcError(res, 400, -32000, m, id);
789
+ const unauthorized = (res, m, id = null) => rpcError(res, 401, -32001, m, id);
790
+ const notFound = (res, m, id = null) => rpcError(res, 404, -32001, m, id);
791
+ const unavailable = (res, m, id = null) => rpcError(res, 503, -32002, m, id);
792
+ //# sourceMappingURL=http.js.map