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.
- package/LICENSE +21 -0
- package/README.md +544 -0
- package/dist/api.d.ts +186 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +932 -0
- package/dist/api.js.map +1 -0
- package/dist/cli.d.ts +55 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +292 -0
- package/dist/cli.js.map +1 -0
- package/dist/errors.d.ts +560 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +873 -0
- package/dist/errors.js.map +1 -0
- package/dist/events.d.ts +406 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +1679 -0
- package/dist/events.js.map +1 -0
- package/dist/format.d.ts +125 -0
- package/dist/format.d.ts.map +1 -0
- package/dist/format.js +180 -0
- package/dist/format.js.map +1 -0
- package/dist/http.d.ts +46 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +792 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/paths.d.ts +394 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +677 -0
- package/dist/paths.js.map +1 -0
- package/dist/server.d.ts +18 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +97 -0
- package/dist/server.js.map +1 -0
- package/dist/session.d.ts +78 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +166 -0
- package/dist/session.js.map +1 -0
- package/dist/stdio.d.ts +11 -0
- package/dist/stdio.d.ts.map +1 -0
- package/dist/stdio.js +43 -0
- package/dist/stdio.js.map +1 -0
- package/dist/tools/agent.d.ts +16 -0
- package/dist/tools/agent.d.ts.map +1 -0
- package/dist/tools/agent.js +147 -0
- package/dist/tools/agent.js.map +1 -0
- package/dist/tools/computers.d.ts +3 -0
- package/dist/tools/computers.d.ts.map +1 -0
- package/dist/tools/computers.js +1037 -0
- package/dist/tools/computers.js.map +1 -0
- package/dist/tools/events.d.ts +3 -0
- package/dist/tools/events.d.ts.map +1 -0
- package/dist/tools/events.js +1077 -0
- package/dist/tools/events.js.map +1 -0
- package/dist/tools/guest.d.ts +3 -0
- package/dist/tools/guest.d.ts.map +1 -0
- package/dist/tools/guest.js +761 -0
- package/dist/tools/guest.js.map +1 -0
- package/dist/tools/input.d.ts +3 -0
- package/dist/tools/input.d.ts.map +1 -0
- package/dist/tools/input.js +240 -0
- package/dist/tools/input.js.map +1 -0
- package/dist/tools/snapshots.d.ts +3 -0
- package/dist/tools/snapshots.d.ts.map +1 -0
- package/dist/tools/snapshots.js +333 -0
- package/dist/tools/snapshots.js.map +1 -0
- package/dist/tools/templates.d.ts +3 -0
- package/dist/tools/templates.d.ts.map +1 -0
- package/dist/tools/templates.js +492 -0
- package/dist/tools/templates.js.map +1 -0
- package/dist/tools/types.d.ts +18 -0
- package/dist/tools/types.d.ts.map +1 -0
- package/dist/tools/types.js +2 -0
- package/dist/tools/types.js.map +1 -0
- package/dist/tools/webhooks.d.ts +3 -0
- package/dist/tools/webhooks.d.ts.map +1 -0
- package/dist/tools/webhooks.js +260 -0
- package/dist/tools/webhooks.js.map +1 -0
- 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
|