mcp-teams 0.2.13 → 0.2.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -55,6 +55,16 @@ Teams reports the call's length, not each person's time in it, so attendance lis
55
55
 
56
56
  The AI can only **request** a send. The extension then opens its own window showing where the message goes (for a new chat: who will be in it, and its title), the post it replies to and the message itself. The message is posted, and a new chat created, only if you click **Send** there. Closing the window, clicking *Don't send*, or waiting 5 minutes all mean nothing is posted.
57
57
 
58
+ If the AI stops waiting (it cancels, times out or loses its connection) while the window is open, the window closes and nothing is posted. If you had already clicked *Send* but no answer came back, the AI is told the message may have been posted and to check the chat before sending again, so it doesn't post twice.
59
+
60
+ ### When Teams is busy
61
+
62
+ When Teams asks to slow down (HTTP 429), mcp-teams sends nothing more to that service, for that region or account, until Teams' wait is over. A call waits that out if it has time; otherwise it fails with "Teams is busy (throttled) for N s". A list that came back without some people's names says so in a `note`, instead of leaving them out silently.
63
+
64
+ ### When something hangs
65
+
66
+ Several AI sessions share one connection to the browser: the first one runs it, the others go through it. They check on each other every 20 s. If the session running it stops responding (its app is suspended or hung), the others' calls fail within about 40 s with "The mcp-teams hub stopped responding; try again in a few seconds", and one of them takes over. A session that stops reading what it is sent is disconnected and reconnects. The extension reconnects by itself when it hasn't heard from the server for 45 s.
67
+
58
68
  ## Setup
59
69
 
60
70
  You need [Node.js](https://nodejs.org) 20 or later, and Chrome or Edge. Then, in a terminal:
package/dist/bridge.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
2
2
  import { WebSocketServer, WebSocket } from "ws";
3
3
  import { EXTENSION_ORIGIN as TRUSTED_EXTENSION_ORIGIN } from "./extension.js";
4
+ import { FairQueue } from "./fairqueue.js";
4
5
  /**
5
6
  * Local WebSocket bridge between MCP server processes and the browser extension.
6
7
  *
@@ -45,8 +46,57 @@ export const DEFAULT_BRIDGE_OPTIONS = Object.freeze({
45
46
  pickStatusTimeoutMs: 3_000,
46
47
  stepDownMaxWaitMs: 5 * 60_000,
47
48
  cancelGraceMs: 2_000,
49
+ maxPeerBufferedBytes: 64 * 1024 * 1024,
50
+ maxConcurrentCalls: 4,
48
51
  });
49
- const cancelledError = () => new Error("Cancelled: the request was withdrawn.");
52
+ /**
53
+ * Calls that never wait for a turn: the connection's own (status, reload) answer at once, and a
54
+ * send waits for the user's click (up to minutes), so it must not hold a turn others wait for.
55
+ */
56
+ const UNQUEUED = new Set(["status", "reload", "reloadAll", "requestSend"]);
57
+ /**
58
+ * What a call fails with when it waited for a turn and was never sent: nothing reached Teams,
59
+ * so none of these is a lost call (isLostCall is false for each).
60
+ */
61
+ export const NOT_SENT = Object.freeze({
62
+ cancelled: "Cancelled while it waited for its turn; it was not sent.",
63
+ waitedOut: (running) => `Busy: this call waited its whole time limit for a turn (${running} calls at once run on the Teams tab, shared between AI sessions) and was not sent; try again shortly.`,
64
+ gone: "The connection to the browser extension closed before this call's turn came; it was not sent. Try again.",
65
+ });
66
+ /** A peer's watchdog never waits less for its hub's ping: a busy machine runs timers late. */
67
+ const MIN_HUB_SILENCE_MS = 2_000;
68
+ /** Hub: a peer with more than this many times maxPeerBufferedBytes waiting is closed at once. */
69
+ const PEER_BUFFER_HARD_CAP = 4;
70
+ /**
71
+ * What a call fails with when the bridge lost it after sending it, so its outcome is unknown (a
72
+ * send may have been posted). A hub relays them to its peers word for word. src/tools.ts tells
73
+ * the AI to check before sending again on exactly these (isLostCall), so add any new one here.
74
+ */
75
+ export const LOST_CALL = Object.freeze({
76
+ timedOut: (method) => `Extension call '${method}' timed out`,
77
+ withdrawn: "Cancelled: the request was withdrawn.",
78
+ disconnected: "Browser extension disconnected",
79
+ reconnected: "Browser extension reconnected; try again.",
80
+ hubRestarted: "The mcp-teams hub restarted; try again in a few seconds.",
81
+ hubSilent: "The mcp-teams hub stopped responding; try again in a few seconds.",
82
+ updated: "mcp-teams was updated; try again in a few seconds.",
83
+ shuttingDown: "mcp-teams is shutting down",
84
+ serverError: (detail) => `bridge server error: ${detail}`,
85
+ socketError: (detail) => `Bridge connection error: ${detail}`,
86
+ tooLarge: (limitMb) => `The response was too large (over ${limitMb} MB) and was dropped. The connection is re-established automatically.`,
87
+ });
88
+ // Each LOST_CALL message as a pattern: its text, with anything in the place of a value.
89
+ const SLOT = "\u0000";
90
+ const escapeRegExp = (t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
91
+ const LOST_CALL_PATTERNS = Object.values(LOST_CALL).map((m) => {
92
+ const text = typeof m === "string" ? m : m(SLOT);
93
+ return new RegExp(`^${text.split(SLOT).map(escapeRegExp).join(".*")}`, "s");
94
+ });
95
+ /** True when `message` is one of LOST_CALL's: the call was sent, and its outcome is unknown. */
96
+ export function isLostCall(message) {
97
+ return LOST_CALL_PATTERNS.some((p) => p.test(message));
98
+ }
99
+ const cancelledError = () => new Error(LOST_CALL.withdrawn);
50
100
  /** True when version a is newer than b (x.y.z; anything else never counts as newer). */
51
101
  export function isNewer(a, b) {
52
102
  const parse = (v) => (typeof v === "string" && /^\d+\.\d+\.\d+$/.test(v) ? v.split(".").map(Number) : null);
@@ -60,9 +110,7 @@ export function isNewer(a, b) {
60
110
  return false;
61
111
  }
62
112
  function describeSocketError(err, limit) {
63
- return err.code === "WS_ERR_UNSUPPORTED_MESSAGE_LENGTH"
64
- ? `The response was too large (over ${Math.round(limit / 1024 / 1024)} MB) and was dropped. The connection is re-established automatically.`
65
- : `Bridge connection error: ${err.message}`;
113
+ return err.code === "WS_ERR_UNSUPPORTED_MESSAGE_LENGTH" ? LOST_CALL.tooLarge(Math.round(limit / 1024 / 1024)) : LOST_CALL.socketError(err.message);
66
114
  }
67
115
  export class ExtensionBridge {
68
116
  requestedPort;
@@ -82,12 +130,16 @@ export class ExtensionBridge {
82
130
  hub; // peer: the hub
83
131
  peers = new Set();
84
132
  peerCalls = new Map(); // hub: each peer's calls, by the peer's id
133
+ /** Hub: per extension connection, its turns (keyed by session: null for the hub's own calls, else the peer) and who waits. */
134
+ turns = new Map();
85
135
  pending = new Map();
86
136
  keepAlive;
137
+ peerBeat; // hub: pings the peers
87
138
  retry;
88
139
  closed = false;
89
140
  steppingDown = false;
90
- alive = new WeakSet(); // hub: extensions that answered the last ping
141
+ alive = new WeakSet(); // hub: extensions and peers that answered the last ping
142
+ backlogged = new WeakSet(); // hub: peers over maxPeerBufferedBytes at the last tick
91
143
  legacyReloaded = false; // hub: an extension of 0.2.0 or older was told to reload
92
144
  opts;
93
145
  boundPort; // hub: the port it listens on (port 0 picks a free one)
@@ -115,6 +167,13 @@ export class ExtensionBridge {
115
167
  peerCalls += calls.size;
116
168
  return { pending: this.pending.size, peerCalls };
117
169
  }
170
+ /** Read-only, for tests: hub calls waiting for a turn on an extension (not sent yet). */
171
+ get queued() {
172
+ let n = 0;
173
+ for (const t of this.turns.values())
174
+ n += t.queue.waiting;
175
+ return n;
176
+ }
118
177
  /** Becomes the hub, or a peer of the existing hub. Keeps retrying in the background if neither works. */
119
178
  async start() {
120
179
  if (this.closed)
@@ -157,7 +216,7 @@ export class ExtensionBridge {
157
216
  });
158
217
  wss.on("error", (err) => {
159
218
  if (listening)
160
- return this.restartHub(`bridge server error: ${err.message}`);
219
+ return this.restartHub(LOST_CALL.serverError(err.message));
161
220
  if (err.code !== "EADDRINUSE")
162
221
  this.listenError = err.message;
163
222
  wss.close();
@@ -202,7 +261,7 @@ export class ExtensionBridge {
202
261
  return;
203
262
  }
204
263
  if (this.sockets.has(ws))
205
- this.onExtensionMessage(msg);
264
+ this.onExtensionMessage(ws, msg);
206
265
  else if (this.peers.has(ws))
207
266
  this.onPeerMessage(ws, msg);
208
267
  });
@@ -222,7 +281,8 @@ export class ExtensionBridge {
222
281
  if (this.sockets.delete(ws)) {
223
282
  if (this.preferred === ws)
224
283
  this.preferred = undefined;
225
- this.rejectAll("Browser extension disconnected", ws);
284
+ this.dropTurns(ws);
285
+ this.rejectAll(LOST_CALL.disconnected, ws);
226
286
  this.updateExtensions();
227
287
  if (!this.sockets.size)
228
288
  clearInterval(this.keepAlive);
@@ -267,13 +327,15 @@ export class ExtensionBridge {
267
327
  s.close(4000, "replaced");
268
328
  // Its close event comes later and no longer finds it, so fail its calls now.
269
329
  this.sockets.delete(s);
270
- this.rejectAll("Browser extension reconnected; try again.", s);
330
+ this.dropTurns(s);
331
+ this.rejectAll(LOST_CALL.reconnected, s);
271
332
  if (this.preferred === s)
272
333
  this.preferred = undefined;
273
334
  }
274
335
  }
275
336
  this.sockets.set(ws, info);
276
- ws.send(JSON.stringify({ type: "welcome", serverVersion: this.serverVersion, ...welcome }));
337
+ // heartbeatMs: this hub sends {type:"ping"} that often; the extension reconnects when they stop.
338
+ ws.send(JSON.stringify({ type: "welcome", serverVersion: this.serverVersion, heartbeatMs: this.opts.keepAliveMs, ...welcome }));
277
339
  this.startKeepAlive(ws);
278
340
  this.updateExtensions();
279
341
  }
@@ -315,7 +377,16 @@ export class ExtensionBridge {
315
377
  }
316
378
  acceptPeer(ws, peerVersion, welcome) {
317
379
  this.peers.add(ws);
318
- ws.send(JSON.stringify({ type: "peer-welcome", serverVersion: this.serverVersion, extension: this.extension ?? null, extensions: this.extensions, ...welcome }));
380
+ // heartbeatMs: this hub pings the peer that often, so the peer can tell when it stops.
381
+ ws.send(JSON.stringify({
382
+ type: "peer-welcome",
383
+ serverVersion: this.serverVersion,
384
+ extension: this.extension ?? null,
385
+ extensions: this.extensions,
386
+ heartbeatMs: this.opts.keepAliveMs,
387
+ ...welcome,
388
+ }));
389
+ this.startPeerBeat(ws);
319
390
  // A newer server joined: hand the port to it, so its fixes (and what the newer
320
391
  // extension expects) take effect without restarting this AI session.
321
392
  if (isNewer(peerVersion, this.serverVersion))
@@ -332,12 +403,12 @@ export class ExtensionBridge {
332
403
  return;
333
404
  this.steppingDown = true;
334
405
  const end = Date.now() + this.opts.stepDownMaxWaitMs;
335
- while (this.pending.size && Date.now() < end && !this.closed)
406
+ while ((this.pending.size || this.queued) && Date.now() < end && !this.closed)
336
407
  await new Promise((r) => setTimeout(r, 500));
337
408
  if (this.closed || this.role !== "hub")
338
409
  return void (this.steppingDown = false);
339
410
  console.error(`[mcp-teams ${this.serverVersion}] handing the bridge to mcp-teams ${newer}`);
340
- this.restartHub("mcp-teams was updated; try again in a few seconds.", this.opts.retryMs);
411
+ this.restartHub(LOST_CALL.updated, this.opts.retryMs);
341
412
  this.steppingDown = false;
342
413
  }
343
414
  /** Hub: closes the server and every connection, then becomes a hub or peer again. */
@@ -347,6 +418,8 @@ export class ExtensionBridge {
347
418
  this.boundPort = undefined;
348
419
  this.role = "none";
349
420
  clearInterval(this.keepAlive);
421
+ this.stopPeerBeat();
422
+ this.dropTurns();
350
423
  this.rejectAll(reason);
351
424
  for (const s of this.sockets.keys())
352
425
  s.close(1012, "restarting");
@@ -364,9 +437,33 @@ export class ExtensionBridge {
364
437
  this.retry = setTimeout(() => void this.start(), delayMs);
365
438
  this.retry.unref();
366
439
  }
367
- onExtensionMessage(msg) {
368
- if (msg.type === "response" && typeof msg.id === "string")
369
- this.settle(msg);
440
+ onExtensionMessage(ws, msg) {
441
+ if (typeof msg.id !== "string")
442
+ return;
443
+ if (msg.type === "response")
444
+ return this.settle(msg);
445
+ // A queued call waiting out a throttle (35): {type:"gate-wait"} gives its turn back to the
446
+ // others, {type:"gate-open"} asks for one again, and {type:"turn"} says it may go on.
447
+ const p = this.pending.get(msg.id);
448
+ const turn = p?.socket === ws ? p.turn : undefined;
449
+ if (!turn || turn.done)
450
+ return;
451
+ if (msg.type === "gate-wait" && turn.held) {
452
+ turn.held = false;
453
+ turn.queue.release(turn.session);
454
+ }
455
+ else if (msg.type === "gate-open" && !turn.held && !turn.rejoin) {
456
+ const rejoin = (turn.rejoin = new AbortController());
457
+ // Not given in time: the call's own timer withdraws it (it may have sent requests already).
458
+ this.takeTurn(ws, turn.session, turn.end, rejoin.signal).then(() => {
459
+ turn.rejoin = undefined;
460
+ if (turn.done)
461
+ return turn.queue.release(turn.session);
462
+ turn.held = true;
463
+ if (ws.readyState === WebSocket.OPEN)
464
+ ws.send(JSON.stringify({ type: "turn", id: msg.id }));
465
+ }, () => (turn.rejoin = undefined));
466
+ }
370
467
  }
371
468
  /** Runs a peer's call against the extension and sends back the outcome; {type:"cancel", id} withdraws it. */
372
469
  onPeerMessage(ws, msg) {
@@ -376,8 +473,7 @@ export class ExtensionBridge {
376
473
  return;
377
474
  const reply = (body) => {
378
475
  try {
379
- if (ws.readyState === WebSocket.OPEN)
380
- ws.send(JSON.stringify({ type: "response", id: msg.id, ...body }));
476
+ this.sendToPeer(ws, JSON.stringify({ type: "response", id: msg.id, ...body }));
381
477
  }
382
478
  catch (err) {
383
479
  console.error(`[mcp-teams] could not answer a peer: ${err.message}`);
@@ -388,15 +484,37 @@ export class ExtensionBridge {
388
484
  this.peerCalls.set(ws, (calls = new Map()));
389
485
  const ac = new AbortController();
390
486
  calls.set(msg.id, ac);
391
- this.call(String(msg.method), msg.params ?? {}, Number(msg.timeoutMs) || undefined, ac.signal)
487
+ // The peer's deadline stays the call's deadline: the extension must not go on after the peer gave up.
488
+ this.run(ws, String(msg.method), msg.params ?? {}, Number(msg.timeoutMs) || undefined, ac.signal, Number(msg.deadline) || undefined)
392
489
  .then((result) => reply({ ok: true, result }), (err) => reply({ ok: false, error: err.message }))
393
490
  .finally(() => calls.delete(msg.id));
394
491
  }
395
492
  broadcastState() {
396
493
  const state = JSON.stringify({ type: "ext-state", extension: this.extension ?? null, extensions: this.extensions });
397
494
  for (const peer of this.peers)
398
- if (peer.readyState === WebSocket.OPEN)
399
- peer.send(state);
495
+ this.sendToPeer(peer, state);
496
+ }
497
+ /**
498
+ * Hub: sends to a peer that is open and keeping up. One with over 4x maxPeerBufferedBytes
499
+ * waiting is closed instead; a smaller backlog only when it lasts (see startPeerBeat), so a
500
+ * burst of large answers doesn't drop a healthy peer.
501
+ */
502
+ sendToPeer(ws, text) {
503
+ if (ws.readyState !== WebSocket.OPEN)
504
+ return;
505
+ if (ws.bufferedAmount > this.opts.maxPeerBufferedBytes * PEER_BUFFER_HARD_CAP)
506
+ return this.closeSlowPeer(ws);
507
+ ws.send(text);
508
+ }
509
+ /** Hub: closes a peer that isn't reading its answers (1013); its calls are withdrawn and it connects again. */
510
+ closeSlowPeer(ws) {
511
+ console.error(`[mcp-teams] closing a peer that doesn't read its answers (${Math.round(ws.bufferedAmount / 1024 / 1024)} MB waiting)`);
512
+ this.peers.delete(ws);
513
+ // Its close may wait for the backlog; its calls are withdrawn now (as on close).
514
+ for (const ac of this.peerCalls.get(ws)?.values() ?? [])
515
+ ac.abort();
516
+ this.peerCalls.delete(ws);
517
+ ws.close(1013, "too slow");
400
518
  }
401
519
  /**
402
520
  * Pings keep the MV3 service worker alive while connected. A connection that didn't
@@ -423,6 +541,49 @@ export class ExtensionBridge {
423
541
  }, this.opts.keepAliveMs);
424
542
  this.keepAlive.unref();
425
543
  }
544
+ /**
545
+ * Hub: pings each peer every keepAliveMs; one that didn't answer the previous ping (a
546
+ * suspended or hung session) is dropped, within 2 periods. The peers in turn drop a hub
547
+ * that stops pinging them (see join). A peer over maxPeerBufferedBytes at this tick and the
548
+ * previous one isn't draining its answers and is closed (1013).
549
+ */
550
+ startPeerBeat(ws) {
551
+ this.alive.add(ws);
552
+ ws.on("pong", () => this.alive.add(ws));
553
+ if (this.peerBeat)
554
+ return;
555
+ this.peerBeat = setInterval(() => {
556
+ if (!this.peers.size)
557
+ return this.stopPeerBeat();
558
+ for (const p of this.peers) {
559
+ if (p.readyState !== WebSocket.OPEN)
560
+ continue;
561
+ const backlog = p.bufferedAmount > this.opts.maxPeerBufferedBytes;
562
+ if (backlog && this.backlogged.has(p)) {
563
+ this.closeSlowPeer(p);
564
+ continue;
565
+ }
566
+ if (backlog)
567
+ this.backlogged.add(p);
568
+ else
569
+ this.backlogged.delete(p);
570
+ // With a backlog, the last ping may still wait behind the data, so a missing pong says
571
+ // nothing: the next tick closes the peer if the backlog lasts. It is still pinged (the
572
+ // ping queues behind the data), so a peer that drains it in time hears from its hub.
573
+ if (!backlog && !this.alive.has(p)) {
574
+ p.terminate();
575
+ continue;
576
+ }
577
+ this.alive.delete(p);
578
+ p.ping();
579
+ }
580
+ }, this.opts.keepAliveMs);
581
+ this.peerBeat.unref();
582
+ }
583
+ stopPeerBeat() {
584
+ clearInterval(this.peerBeat);
585
+ this.peerBeat = undefined;
586
+ }
426
587
  // ------------------------------------------------------------------ peer
427
588
  join() {
428
589
  return new Promise((resolve) => {
@@ -432,6 +593,23 @@ export class ExtensionBridge {
432
593
  let forged = false;
433
594
  const nonce = newNonce();
434
595
  const timer = setTimeout(() => ws.terminate(), this.opts.joinTimeoutMs);
596
+ // A hub that declares heartbeatMs pings that often. When its pings stop for 2 periods, never
597
+ // under 2 s (a busy machine runs timers late), the hub is suspended or hung: its calls fail
598
+ // now and the takeover path runs, instead of them hanging until they time out. Hubs of
599
+ // 0.2.13 and older never ping peers: no watchdog.
600
+ let watchdog;
601
+ let heartbeatMs = 0;
602
+ const watch = () => {
603
+ clearTimeout(watchdog);
604
+ watchdog = setTimeout(() => {
605
+ if (this.hub !== ws)
606
+ return;
607
+ this.rejectAll(LOST_CALL.hubSilent);
608
+ ws.terminate();
609
+ }, Math.max(MIN_HUB_SILENCE_MS, 2 * heartbeatMs));
610
+ watchdog.unref();
611
+ };
612
+ ws.on("ping", () => heartbeatMs && watch());
435
613
  ws.on("message", (raw) => {
436
614
  let msg;
437
615
  try {
@@ -458,6 +636,9 @@ export class ExtensionBridge {
458
636
  this.hubVersion = String(msg.serverVersion ?? "unknown");
459
637
  this.extension = msg.extension ?? undefined;
460
638
  this.extensions = msg.extensions ?? (msg.extension ? [msg.extension] : []);
639
+ heartbeatMs = Number(msg.heartbeatMs) > 0 ? Number(msg.heartbeatMs) : 0;
640
+ if (heartbeatMs)
641
+ watch();
461
642
  resolve(true);
462
643
  }
463
644
  else if (msg.type === "ext-state") {
@@ -474,6 +655,7 @@ export class ExtensionBridge {
474
655
  });
475
656
  ws.on("close", (code) => {
476
657
  clearTimeout(timer);
658
+ clearTimeout(watchdog);
477
659
  if (!joined) {
478
660
  this.listenError =
479
661
  code === 4401
@@ -491,7 +673,7 @@ export class ExtensionBridge {
491
673
  this.extension = undefined;
492
674
  this.extensions = [];
493
675
  this.role = "none";
494
- this.rejectAll("The mcp-teams hub restarted; try again in a few seconds.");
676
+ this.rejectAll(LOST_CALL.hubRestarted);
495
677
  if (!this.closed) {
496
678
  clearTimeout(this.retry);
497
679
  this.retry = setTimeout(() => void this.start(), 100 + Math.random() * 400);
@@ -501,8 +683,15 @@ export class ExtensionBridge {
501
683
  });
502
684
  }
503
685
  // ------------------------------------------------------------------ calls
504
- /** Calls the extension. Aborting `signal` withdraws the call (see withdraw). */
505
- async call(method, params = {}, timeoutMs = 60_000, signal) {
686
+ /**
687
+ * Calls the extension. Aborting `signal` withdraws the call (see withdraw). `deadline` (ms since
688
+ * 1970) ends it earlier than timeoutMs, for a call relayed from a peer.
689
+ */
690
+ call(method, params = {}, timeoutMs, signal, deadline) {
691
+ return this.run(null, method, params, timeoutMs, signal, deadline);
692
+ }
693
+ /** A call from `session` (null: this server's own, else the peer it came from). */
694
+ async run(session, method, params = {}, timeoutMs = 60_000, signal, deadline) {
506
695
  if (method === "reloadAll" && this.role === "hub")
507
696
  return this.reloadAll(timeoutMs);
508
697
  const target = this.role === "hub" ? await this.pickExtension() : this.role === "peer" ? this.hub : undefined;
@@ -512,13 +701,74 @@ export class ExtensionBridge {
512
701
  : "Browser extension is not connected. Open Chrome or Edge with the mcp-teams extension installed (run `mcp-teams setup`).";
513
702
  throw new Error(reason);
514
703
  }
515
- const result = await this.send(target, method, params, timeoutMs, signal);
704
+ let result;
705
+ if (this.role === "hub" && !UNQUEUED.has(method)) {
706
+ // The call's time limit includes its wait for a turn.
707
+ const end = Math.min(Date.now() + timeoutMs, deadline ?? Infinity);
708
+ const turn = { queue: await this.takeTurn(target, session, end, signal), session, end, held: true };
709
+ try {
710
+ if (signal?.aborted)
711
+ throw new Error(NOT_SENT.cancelled);
712
+ result = await this.send(target, method, params, timeoutMs, signal, end, turn);
713
+ }
714
+ finally {
715
+ turn.done = true;
716
+ turn.rejoin?.abort();
717
+ if (turn.held)
718
+ turn.queue.release(session); // handed to the next waiting call, if any
719
+ }
720
+ }
721
+ else {
722
+ result = await this.send(target, method, params, timeoutMs, signal, deadline);
723
+ }
516
724
  if (this.role === "hub" && this.preferred !== target && this.sockets.has(target)) {
517
725
  this.preferred = target;
518
726
  this.updateExtensions();
519
727
  }
520
728
  return result;
521
729
  }
730
+ /**
731
+ * Hub: waits for one of the extension's maxConcurrentCalls turns, fairly between sessions.
732
+ * Cancelling or reaching `end` first leaves the line, and the call is never sent.
733
+ */
734
+ takeTurn(target, session, end, signal) {
735
+ let turns = this.turns.get(target);
736
+ if (!turns)
737
+ this.turns.set(target, (turns = { queue: new FairQueue(this.opts.maxConcurrentCalls), waiting: new Set() }));
738
+ const { queue, waiting } = turns;
739
+ if (signal?.aborted)
740
+ return Promise.reject(new Error(NOT_SENT.cancelled));
741
+ if (queue.tryTake(session))
742
+ return Promise.resolve(queue);
743
+ return new Promise((resolve, reject) => {
744
+ const done = () => {
745
+ clearTimeout(timer);
746
+ signal?.removeEventListener("abort", onAbort);
747
+ waiting.delete(leave);
748
+ };
749
+ const leaveLine = queue.wait(session, () => (done(), resolve(queue)));
750
+ const leave = (err) => {
751
+ if (!leaveLine())
752
+ return; // already given its turn
753
+ done();
754
+ reject(err);
755
+ };
756
+ const onAbort = () => leave(new Error(NOT_SENT.cancelled));
757
+ const timer = setTimeout(() => leave(new Error(NOT_SENT.waitedOut(queue.max))), Math.max(0, end - Date.now()));
758
+ signal?.addEventListener("abort", onAbort, { once: true });
759
+ waiting.add(leave);
760
+ });
761
+ }
762
+ /** Hub: fails the calls still waiting for a turn on `socket` (every socket when not given); they were never sent. */
763
+ dropTurns(socket) {
764
+ for (const [s, { waiting }] of this.turns) {
765
+ if (socket && s !== socket)
766
+ continue;
767
+ this.turns.delete(s);
768
+ for (const leave of [...waiting])
769
+ leave(new Error(NOT_SENT.gone));
770
+ }
771
+ }
522
772
  /** Hub: asks every connected browser's extension to reload itself. */
523
773
  async reloadAll(timeoutMs) {
524
774
  const open = [...this.sockets.keys()].filter((s) => s.readyState === WebSocket.OPEN);
@@ -531,25 +781,32 @@ export class ExtensionBridge {
531
781
  return { reloading: results.length - failed.length };
532
782
  }
533
783
  /** Sends one request on a connection (an extension for the hub, the hub for a peer). */
534
- send(target, method, params, timeoutMs, signal) {
784
+ send(target, method, params, timeoutMs, signal, deadline, turn) {
535
785
  if (signal?.aborted)
536
786
  return Promise.reject(cancelledError());
537
787
  const id = randomUUID();
788
+ const now = Date.now();
789
+ // Taken before the timer starts, so the timer never fires before it.
790
+ const end = Math.min(now + timeoutMs, deadline ?? Infinity);
538
791
  // A peer waits a little longer than the hub, so the hub's own timeout message wins.
539
- const wait = this.role === "peer" ? timeoutMs + 5_000 : timeoutMs;
792
+ const wait = Math.max(0, end - now) + (this.role === "peer" ? 5_000 : 0);
540
793
  return new Promise((resolve, reject) => {
541
794
  const onAbort = () => this.withdraw(id, target, cancelledError());
542
795
  const done = () => signal?.removeEventListener("abort", onAbort);
543
- const timer = setTimeout(() => this.withdraw(id, target, new Error(`Extension call '${method}' timed out`)), wait);
796
+ const timer = setTimeout(() => this.withdraw(id, target, new Error(LOST_CALL.timedOut(method))), wait);
544
797
  this.pending.set(id, {
545
798
  resolve: (v) => (done(), resolve(v)),
546
799
  reject: (e) => (done(), reject(e)),
547
800
  timer,
548
801
  grace: method === "requestSend",
549
802
  socket: this.role === "hub" ? target : undefined,
803
+ turn,
550
804
  });
551
805
  signal?.addEventListener("abort", onAbort, { once: true });
552
- target.send(JSON.stringify({ type: "request", id, method, params, timeoutMs }));
806
+ // deadline: when this side stops waiting (same machine, same clock), so the extension
807
+ // sends nothing to Teams after it, however long the request took to arrive. turn: it holds
808
+ // a turn, which it gives back while it waits out a throttle (see onExtensionMessage).
809
+ target.send(JSON.stringify({ type: "request", id, method, params, timeoutMs, deadline: end, ...(turn ? { turn: true } : {}) }));
553
810
  });
554
811
  }
555
812
  /**
@@ -631,7 +888,9 @@ export class ExtensionBridge {
631
888
  this.closed = true;
632
889
  clearTimeout(this.retry);
633
890
  clearInterval(this.keepAlive);
634
- this.rejectAll("mcp-teams is shutting down");
891
+ this.stopPeerBeat();
892
+ this.dropTurns();
893
+ this.rejectAll(LOST_CALL.shuttingDown);
635
894
  this.hub?.close();
636
895
  for (const s of this.sockets.keys())
637
896
  s.close();