mcp-google-multi 6.0.0-alpha.60 → 6.0.0-alpha.61

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.
@@ -61,6 +61,11 @@ export interface HttpHostOptions {
61
61
  onArgRename?: (tool: string, renames: number) => void;
62
62
  /** Unknown-argument screening (arg-strict.ts); absent = off. */
63
63
  strictArgs?: StrictArgOptions;
64
+ /** Per-server cap on /mcp requests reading their body, queued or running;
65
+ * the next answers 429 lane_busy before its body is read. Unset = no cap. */
66
+ maxQueuedPerLane?: number;
67
+ /** Max JSON-RPC messages in one /mcp body (default 16). */
68
+ maxBatchFrames?: number;
64
69
  }
65
70
  export declare function parseOwnerEmails(env?: NodeJS.ProcessEnv): string[];
66
71
  /** Front guard: an Origin, if present, must be allowlisted; a Host must be
@@ -74,11 +79,28 @@ export declare class HttpTransportHost {
74
79
  private readonly opts;
75
80
  private httpServer?;
76
81
  private readonly locks;
82
+ private readonly depth;
83
+ private readonly sockets;
84
+ private readonly pending;
85
+ private readonly handling;
86
+ private closing;
87
+ private graceful?;
77
88
  constructor(opts: HttpHostOptions);
78
89
  private serializeFor;
79
90
  private log;
80
91
  start(): Promise<void>;
81
- close(): Promise<void>;
92
+ /** Without graceMs, every connection is cut at once, even during a graceful
93
+ * close. With it, the host stops accepting, lets in-flight requests finish
94
+ * and their responses finish writing, then resolves once each client has
95
+ * closed its side; whatever is still open after graceMs is cut. A repeat
96
+ * call with graceMs during a graceful close joins it, keeping the first
97
+ * grace. graceMs must be a number from 0 to 2^31-1 ms (about 24.8 days, the
98
+ * longest delay a timer holds); anything else, Infinity included, rejects
99
+ * with a TypeError and leaves the host as it was. */
100
+ close(opts?: {
101
+ graceMs?: number;
102
+ }): Promise<void>;
103
+ private closeGracefully;
82
104
  /** The bound port (useful when listening on port 0 in tests). */
83
105
  address(): {
84
106
  port: number;
@@ -86,6 +108,7 @@ export declare class HttpTransportHost {
86
108
  private handle;
87
109
  private frontGuard;
88
110
  private mcp;
111
+ private admitted;
89
112
  private health;
90
113
  private readJson;
91
114
  private fail;
@@ -44,6 +44,28 @@ export function jsonRpcMethod(body) {
44
44
  return body.map(one).filter(Boolean).join(',') || 'batch';
45
45
  return one(body) ?? 'unknown';
46
46
  }
47
+ /** setTimeout fires at once for a delay above this, so a longer grace would
48
+ * cut every connection immediately. */
49
+ const MAX_GRACE_MS = 2 ** 31 - 1;
50
+ // A lingering close: a socket destroyed while its client is still sending (a
51
+ // body pipelined after the close) is reset by the kernel, which drops whatever
52
+ // of the last response is still unsent. So the socket only ends its side and
53
+ // waits for the client's FIN, or for the grace to end.
54
+ function endAfterFlush(socket) {
55
+ if (socket.destroyed || socket.writableEnded)
56
+ return;
57
+ socket.end();
58
+ }
59
+ // A request that arrives while closing is never answered, so Node keeps it
60
+ // until the socket closes, and a flood pipelined behind a held response would
61
+ // pile up without limit. Reading stops for good instead, which lets TCP flow
62
+ // control stop the client. Node resumes a socket after every parsed request
63
+ // and on drain, so resume becomes a no-op. Such a socket never sees the
64
+ // client's FIN either: the grace ends it.
65
+ function stopReading(socket) {
66
+ socket.pause();
67
+ socket.resume = () => socket;
68
+ }
47
69
  export class HttpTransportHost {
48
70
  opts;
49
71
  httpServer;
@@ -54,6 +76,16 @@ export class HttpTransportHost {
54
76
  // core has one lane (the boot server); with resolveServer each resolved
55
77
  // server gets its own, however subjects map onto servers.
56
78
  locks = new Map();
79
+ // Requests holding a lane slot, per server; used only with maxQueuedPerLane.
80
+ depth = new Map();
81
+ // What a graceful close waits on: open sockets, per socket the requests it
82
+ // has accepted whose response has not settled and the last of them, and
83
+ // every handle() that has not settled.
84
+ sockets = new Set();
85
+ pending = new Map();
86
+ handling = new Set();
87
+ closing = false;
88
+ graceful;
57
89
  constructor(opts) {
58
90
  this.opts = opts;
59
91
  }
@@ -77,7 +109,49 @@ export class HttpTransportHost {
77
109
  async start() {
78
110
  const { config } = this.opts;
79
111
  const server = createServer((req, res) => {
80
- this.handle(req, res).catch((e) => this.fail(res, 500, 'internal_error', e.message));
112
+ const socket = req.socket;
113
+ // Once closing, a connection ends after the last request it accepted
114
+ // before the close, so one arriving now is never handled (RFC 9112 9.6).
115
+ // Its body is left unread: nothing reads the socket again.
116
+ if (this.closing) {
117
+ stopReading(socket);
118
+ return;
119
+ }
120
+ const entry = this.pending.get(socket);
121
+ if (entry) {
122
+ entry.count++;
123
+ entry.last = res;
124
+ }
125
+ else
126
+ this.pending.set(socket, { count: 1, last: res });
127
+ let settled = false;
128
+ const settle = () => {
129
+ if (settled)
130
+ return;
131
+ settled = true;
132
+ const e = this.pending.get(socket);
133
+ if (!e || --e.count > 0)
134
+ return;
135
+ this.pending.delete(socket);
136
+ if (this.closing)
137
+ endAfterFlush(socket);
138
+ };
139
+ res.once('finish', settle);
140
+ res.once('close', settle);
141
+ // Never rejects: close() waits on it, and a catch-all that itself threw
142
+ // must neither reject close() nor surface as an unhandled rejection.
143
+ const done = this.handle(req, res)
144
+ .catch((e) => this.fail(res, 500, 'internal_error', e.message))
145
+ .catch(() => void res.destroy());
146
+ this.handling.add(done);
147
+ void done.finally(() => this.handling.delete(done));
148
+ });
149
+ server.on('connection', (socket) => {
150
+ this.sockets.add(socket);
151
+ socket.once('close', () => {
152
+ this.sockets.delete(socket);
153
+ this.pending.delete(socket);
154
+ });
81
155
  });
82
156
  // Slow-loris mitigation behind the tunnel.
83
157
  server.headersTimeout = 15_000;
@@ -93,15 +167,72 @@ export class HttpTransportHost {
93
167
  });
94
168
  this.log(`listening on ${config.host}:${config.port} (public ${config.publicUrl}, transport ${config.transport})`);
95
169
  }
96
- async close() {
170
+ /** Without graceMs, every connection is cut at once, even during a graceful
171
+ * close. With it, the host stops accepting, lets in-flight requests finish
172
+ * and their responses finish writing, then resolves once each client has
173
+ * closed its side; whatever is still open after graceMs is cut. A repeat
174
+ * call with graceMs during a graceful close joins it, keeping the first
175
+ * grace. graceMs must be a number from 0 to 2^31-1 ms (about 24.8 days, the
176
+ * longest delay a timer holds); anything else, Infinity included, rejects
177
+ * with a TypeError and leaves the host as it was. */
178
+ async close(opts = {}) {
179
+ const { graceMs } = opts;
180
+ if (graceMs !== undefined && !(typeof graceMs === 'number' && graceMs >= 0 && graceMs <= MAX_GRACE_MS)) {
181
+ throw new TypeError(`graceMs must be a number from 0 to ${MAX_GRACE_MS}`);
182
+ }
97
183
  const s = this.httpServer;
98
184
  if (!s)
99
185
  return;
100
- await new Promise((resolve) => {
101
- s.close(() => resolve());
102
- s.closeAllConnections?.();
186
+ if (graceMs === undefined) {
187
+ await new Promise((resolve) => {
188
+ s.close(() => resolve());
189
+ s.closeAllConnections?.();
190
+ });
191
+ this.httpServer = undefined;
192
+ return;
193
+ }
194
+ // Starting over would destroy every socket in its lingering close.
195
+ this.graceful ??= this.closeGracefully(s, graceMs).finally(() => (this.graceful = undefined));
196
+ return this.graceful;
197
+ }
198
+ async closeGracefully(s, graceMs) {
199
+ this.closing = true;
200
+ // Node's close() also destroys every connection it deems idle, and that
201
+ // includes one whose response has ended but is still flushing: a large
202
+ // body would be cut short. Idle sockets are closed below instead.
203
+ const nodeIdleCloser = s.closeIdleConnections;
204
+ s.closeIdleConnections = () => undefined;
205
+ const closed = new Promise((resolve) => s.close(() => resolve()));
206
+ s.closeIdleConnections = nodeIdleCloser;
207
+ // Every request accepted before now is answered, and a connection ends
208
+ // after the last of them: that one says so if it has not started, and the
209
+ // settle hook in start() ends the socket once it finishes. Node itself
210
+ // destroys a socket once a Connection: close response is flushed, which
211
+ // resets it just as endAfterFlush explains, so that end lingers too.
212
+ for (const socket of this.sockets) {
213
+ const entry = this.pending.get(socket);
214
+ if (!entry) {
215
+ socket.destroy();
216
+ continue;
217
+ }
218
+ socket.destroySoon = () => endAfterFlush(socket);
219
+ if (!entry.last.headersSent)
220
+ entry.last.setHeader('Connection', 'close');
221
+ }
222
+ let timer;
223
+ const expired = new Promise((resolve) => {
224
+ timer = setTimeout(resolve, graceMs);
103
225
  });
104
- this.httpServer = undefined;
226
+ try {
227
+ await Promise.race([closed.then(() => Promise.all(this.handling)), expired]);
228
+ }
229
+ finally {
230
+ clearTimeout(timer);
231
+ s.closeAllConnections();
232
+ await closed;
233
+ this.httpServer = undefined;
234
+ this.closing = false;
235
+ }
105
236
  }
106
237
  /** The bound port (useful when listening on port 0 in tests). */
107
238
  address() {
@@ -154,6 +285,14 @@ export class HttpTransportHost {
154
285
  }
155
286
  if (!this.frontGuard(req, res))
156
287
  return;
288
+ // Registered before anything awaits: a client that leaves while its request
289
+ // waits on the lane must not have it dispatched once its turn comes. A
290
+ // response queued behind another on its connection never emits 'close', so
291
+ // the socket is checked too.
292
+ let gone = false;
293
+ res.once('close', () => (gone = true));
294
+ const socket = req.socket;
295
+ const clientGone = () => gone || socket.destroyed;
157
296
  const auth = await this.opts.authenticate(req);
158
297
  if (!auth.ok) {
159
298
  for (const [k, v] of Object.entries(auth.headers ?? {}))
@@ -186,6 +325,31 @@ export class HttpTransportHost {
186
325
  return;
187
326
  }
188
327
  }
328
+ // Checked before the body read, so a refused request never buffers its body.
329
+ const cap = this.opts.maxQueuedPerLane;
330
+ if (cap === undefined)
331
+ return this.admitted(req, res, target, sub, clientGone);
332
+ const lane = target.server;
333
+ const held = this.depth.get(lane) ?? 0;
334
+ if (held >= cap) {
335
+ res.setHeader('Retry-After', '1');
336
+ this.fail(res, 429, 'lane_busy', 'too many requests are already queued for this server');
337
+ this.log('429 lane_busy path=/mcp');
338
+ return;
339
+ }
340
+ this.depth.set(lane, held + 1);
341
+ try {
342
+ await this.admitted(req, res, target, sub, clientGone);
343
+ }
344
+ finally {
345
+ const left = (this.depth.get(lane) ?? 1) - 1;
346
+ if (left > 0)
347
+ this.depth.set(lane, left);
348
+ else
349
+ this.depth.delete(lane);
350
+ }
351
+ }
352
+ async admitted(req, res, target, sub, clientGone) {
189
353
  // Thread the verified subject to tool handlers: the Node transport forwards
190
354
  // req.auth verbatim as ctx.http.authInfo. Token/clientId stay empty — the
191
355
  // bearer value must not re-enter the dispatch path via handler context.
@@ -202,8 +366,9 @@ export class HttpTransportHost {
202
366
  catch (e) {
203
367
  return this.fail(res, 400, 'invalid_body', e.message);
204
368
  }
205
- if (Array.isArray(body) && body.length > MAX_BATCH_FRAMES) {
206
- return this.fail(res, 400, 'batch_too_large', `a JSON-RPC batch may hold at most ${MAX_BATCH_FRAMES} messages`);
369
+ const maxFrames = this.opts.maxBatchFrames ?? MAX_BATCH_FRAMES;
370
+ if (Array.isArray(body) && body.length > maxFrames) {
371
+ return this.fail(res, 400, 'batch_too_large', `a JSON-RPC batch may hold at most ${maxFrames} messages`);
207
372
  }
208
373
  // Host/Origin are enforced by the front guard above (uniformly for /mcp and
209
374
  // the mounted AS routes), so the SDK's own DNS-rebind guard is disabled: its
@@ -229,6 +394,10 @@ export class HttpTransportHost {
229
394
  // subjects to one) would otherwise race it through the connect gap.
230
395
  const { server: mcpServer, argShapeFor, strictArgs, validationEnvelope } = target;
231
396
  await this.serializeFor(mcpServer, async () => {
397
+ if (clientGone()) {
398
+ this.log('499 client_gone path=/mcp');
399
+ return;
400
+ }
232
401
  const disconnected = new Promise((resolve) => res.once('close', () => resolve('closed')));
233
402
  let timer;
234
403
  const timedOut = new Promise((resolve) => {
@@ -294,6 +463,10 @@ export class HttpTransportHost {
294
463
  readJson(req) {
295
464
  const max = this.opts.maxBodyBytes ?? 4_000_000;
296
465
  return new Promise((resolve, reject) => {
466
+ // A client that left while the request was authenticated or resolved has
467
+ // already destroyed it, and no 'end' or 'error' would ever follow.
468
+ if (req.destroyed)
469
+ return reject(new Error('request aborted'));
297
470
  let size = 0;
298
471
  const chunks = [];
299
472
  req.on('data', (c) => {
@@ -322,6 +495,12 @@ export class HttpTransportHost {
322
495
  fail(res, status, error, message) {
323
496
  if (res.writableEnded)
324
497
  return;
498
+ // A response already started cannot become an error one; cutting it tells
499
+ // the client it is incomplete.
500
+ if (res.headersSent) {
501
+ res.destroy();
502
+ return;
503
+ }
325
504
  res.writeHead(status, { 'Content-Type': 'application/json' });
326
505
  res.end(JSON.stringify({ error, message }));
327
506
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-google-multi",
3
- "version": "6.0.0-alpha.60",
3
+ "version": "6.0.0-alpha.61",
4
4
  "mcpName": "io.github.bakissation/mcp-google-multi",
5
5
  "description": "Local MCP server for Google Workspace (Gmail, Drive, Calendar, Sheets, Docs, Contacts, Tasks, Meet, Search Console, +Forms/Chat/Admin) across multiple accounts — OAuth-only, encrypted token storage, deny-by-default writes.",
6
6
  "type": "module",