mcp-teams 0.2.12 → 0.2.14

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
  *
@@ -44,7 +45,58 @@ export const DEFAULT_BRIDGE_OPTIONS = Object.freeze({
44
45
  authTimeoutMs: 5_000,
45
46
  pickStatusTimeoutMs: 3_000,
46
47
  stepDownMaxWaitMs: 5 * 60_000,
48
+ cancelGraceMs: 2_000,
49
+ maxPeerBufferedBytes: 64 * 1024 * 1024,
50
+ maxConcurrentCalls: 4,
47
51
  });
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);
48
100
  /** True when version a is newer than b (x.y.z; anything else never counts as newer). */
49
101
  export function isNewer(a, b) {
50
102
  const parse = (v) => (typeof v === "string" && /^\d+\.\d+\.\d+$/.test(v) ? v.split(".").map(Number) : null);
@@ -58,9 +110,7 @@ export function isNewer(a, b) {
58
110
  return false;
59
111
  }
60
112
  function describeSocketError(err, limit) {
61
- return err.code === "WS_ERR_UNSUPPORTED_MESSAGE_LENGTH"
62
- ? `The response was too large (over ${Math.round(limit / 1024 / 1024)} MB) and was dropped. The connection is re-established automatically.`
63
- : `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);
64
114
  }
65
115
  export class ExtensionBridge {
66
116
  requestedPort;
@@ -79,12 +129,17 @@ export class ExtensionBridge {
79
129
  preferred; // hub: the extension that last answered a call
80
130
  hub; // peer: the hub
81
131
  peers = new Set();
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();
82
135
  pending = new Map();
83
136
  keepAlive;
137
+ peerBeat; // hub: pings the peers
84
138
  retry;
85
139
  closed = false;
86
140
  steppingDown = false;
87
- 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
88
143
  legacyReloaded = false; // hub: an extension of 0.2.0 or older was told to reload
89
144
  opts;
90
145
  boundPort; // hub: the port it listens on (port 0 picks a free one)
@@ -105,6 +160,20 @@ export class ExtensionBridge {
105
160
  get maxMessageBytes() {
106
161
  return this.opts.maxMessageBytes;
107
162
  }
163
+ /** Read-only, for tests: calls waiting for an answer, and (hub) peers' calls being run. */
164
+ get inFlight() {
165
+ let peerCalls = 0;
166
+ for (const calls of this.peerCalls.values())
167
+ peerCalls += calls.size;
168
+ return { pending: this.pending.size, peerCalls };
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
+ }
108
177
  /** Becomes the hub, or a peer of the existing hub. Keeps retrying in the background if neither works. */
109
178
  async start() {
110
179
  if (this.closed)
@@ -147,7 +216,7 @@ export class ExtensionBridge {
147
216
  });
148
217
  wss.on("error", (err) => {
149
218
  if (listening)
150
- return this.restartHub(`bridge server error: ${err.message}`);
219
+ return this.restartHub(LOST_CALL.serverError(err.message));
151
220
  if (err.code !== "EADDRINUSE")
152
221
  this.listenError = err.message;
153
222
  wss.close();
@@ -205,10 +274,15 @@ export class ExtensionBridge {
205
274
  ws.on("close", () => {
206
275
  clearTimeout(authTimer);
207
276
  this.peers.delete(ws);
277
+ // A peer that went away can't be told the outcome of its calls: withdraw them.
278
+ for (const ac of this.peerCalls.get(ws)?.values() ?? [])
279
+ ac.abort();
280
+ this.peerCalls.delete(ws);
208
281
  if (this.sockets.delete(ws)) {
209
282
  if (this.preferred === ws)
210
283
  this.preferred = undefined;
211
- this.rejectAll("Browser extension disconnected", ws);
284
+ this.dropTurns(ws);
285
+ this.rejectAll(LOST_CALL.disconnected, ws);
212
286
  this.updateExtensions();
213
287
  if (!this.sockets.size)
214
288
  clearInterval(this.keepAlive);
@@ -238,16 +312,30 @@ export class ExtensionBridge {
238
312
  const key = (i) => i.instance ?? `${i.origin}|${i.browser}`;
239
313
  for (const [s, i] of others) {
240
314
  if (key(i) === key(info) || (ours && i.origin !== TRUSTED_EXTENSION_ORIGIN) || (!ours && !i.instance)) {
315
+ // Its calls fail now (below), before it sees the close: have it drop them first, so an
316
+ // open confirmation window closes instead of posting on a click in between.
317
+ for (const [id, p] of this.pending) {
318
+ if (p.socket !== s || s.readyState !== WebSocket.OPEN)
319
+ continue;
320
+ try {
321
+ s.send(JSON.stringify({ type: "cancel", id }));
322
+ }
323
+ catch {
324
+ // closing anyway
325
+ }
326
+ }
241
327
  s.close(4000, "replaced");
242
328
  // Its close event comes later and no longer finds it, so fail its calls now.
243
329
  this.sockets.delete(s);
244
- this.rejectAll("Browser extension reconnected; try again.", s);
330
+ this.dropTurns(s);
331
+ this.rejectAll(LOST_CALL.reconnected, s);
245
332
  if (this.preferred === s)
246
333
  this.preferred = undefined;
247
334
  }
248
335
  }
249
336
  this.sockets.set(ws, info);
250
- 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 }));
251
339
  this.startKeepAlive(ws);
252
340
  this.updateExtensions();
253
341
  }
@@ -289,7 +377,16 @@ export class ExtensionBridge {
289
377
  }
290
378
  acceptPeer(ws, peerVersion, welcome) {
291
379
  this.peers.add(ws);
292
- 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);
293
390
  // A newer server joined: hand the port to it, so its fixes (and what the newer
294
391
  // extension expects) take effect without restarting this AI session.
295
392
  if (isNewer(peerVersion, this.serverVersion))
@@ -306,12 +403,12 @@ export class ExtensionBridge {
306
403
  return;
307
404
  this.steppingDown = true;
308
405
  const end = Date.now() + this.opts.stepDownMaxWaitMs;
309
- while (this.pending.size && Date.now() < end && !this.closed)
406
+ while ((this.pending.size || this.queued) && Date.now() < end && !this.closed)
310
407
  await new Promise((r) => setTimeout(r, 500));
311
408
  if (this.closed || this.role !== "hub")
312
409
  return void (this.steppingDown = false);
313
410
  console.error(`[mcp-teams ${this.serverVersion}] handing the bridge to mcp-teams ${newer}`);
314
- this.restartHub("mcp-teams was updated; try again in a few seconds.", this.opts.retryMs);
411
+ this.restartHub(LOST_CALL.updated, this.opts.retryMs);
315
412
  this.steppingDown = false;
316
413
  }
317
414
  /** Hub: closes the server and every connection, then becomes a hub or peer again. */
@@ -321,6 +418,8 @@ export class ExtensionBridge {
321
418
  this.boundPort = undefined;
322
419
  this.role = "none";
323
420
  clearInterval(this.keepAlive);
421
+ this.stopPeerBeat();
422
+ this.dropTurns();
324
423
  this.rejectAll(reason);
325
424
  for (const s of this.sockets.keys())
326
425
  s.close(1012, "restarting");
@@ -342,26 +441,56 @@ export class ExtensionBridge {
342
441
  if (msg.type === "response" && typeof msg.id === "string")
343
442
  this.settle(msg);
344
443
  }
345
- /** Runs a peer's call against the extension and sends back the outcome. */
444
+ /** Runs a peer's call against the extension and sends back the outcome; {type:"cancel", id} withdraws it. */
346
445
  onPeerMessage(ws, msg) {
446
+ if (msg.type === "cancel" && typeof msg.id === "string")
447
+ return void this.peerCalls.get(ws)?.get(msg.id)?.abort();
347
448
  if (msg.type !== "request" || typeof msg.id !== "string")
348
449
  return;
349
450
  const reply = (body) => {
350
451
  try {
351
- if (ws.readyState === WebSocket.OPEN)
352
- ws.send(JSON.stringify({ type: "response", id: msg.id, ...body }));
452
+ this.sendToPeer(ws, JSON.stringify({ type: "response", id: msg.id, ...body }));
353
453
  }
354
454
  catch (err) {
355
455
  console.error(`[mcp-teams] could not answer a peer: ${err.message}`);
356
456
  }
357
457
  };
358
- this.call(String(msg.method), msg.params ?? {}, Number(msg.timeoutMs) || undefined).then((result) => reply({ ok: true, result }), (err) => reply({ ok: false, error: err.message }));
458
+ let calls = this.peerCalls.get(ws);
459
+ if (!calls)
460
+ this.peerCalls.set(ws, (calls = new Map()));
461
+ const ac = new AbortController();
462
+ calls.set(msg.id, ac);
463
+ // The peer's deadline stays the call's deadline: the extension must not go on after the peer gave up.
464
+ this.run(ws, String(msg.method), msg.params ?? {}, Number(msg.timeoutMs) || undefined, ac.signal, Number(msg.deadline) || undefined)
465
+ .then((result) => reply({ ok: true, result }), (err) => reply({ ok: false, error: err.message }))
466
+ .finally(() => calls.delete(msg.id));
359
467
  }
360
468
  broadcastState() {
361
469
  const state = JSON.stringify({ type: "ext-state", extension: this.extension ?? null, extensions: this.extensions });
362
470
  for (const peer of this.peers)
363
- if (peer.readyState === WebSocket.OPEN)
364
- peer.send(state);
471
+ this.sendToPeer(peer, state);
472
+ }
473
+ /**
474
+ * Hub: sends to a peer that is open and keeping up. One with over 4x maxPeerBufferedBytes
475
+ * waiting is closed instead; a smaller backlog only when it lasts (see startPeerBeat), so a
476
+ * burst of large answers doesn't drop a healthy peer.
477
+ */
478
+ sendToPeer(ws, text) {
479
+ if (ws.readyState !== WebSocket.OPEN)
480
+ return;
481
+ if (ws.bufferedAmount > this.opts.maxPeerBufferedBytes * PEER_BUFFER_HARD_CAP)
482
+ return this.closeSlowPeer(ws);
483
+ ws.send(text);
484
+ }
485
+ /** Hub: closes a peer that isn't reading its answers (1013); its calls are withdrawn and it connects again. */
486
+ closeSlowPeer(ws) {
487
+ console.error(`[mcp-teams] closing a peer that doesn't read its answers (${Math.round(ws.bufferedAmount / 1024 / 1024)} MB waiting)`);
488
+ this.peers.delete(ws);
489
+ // Its close may wait for the backlog; its calls are withdrawn now (as on close).
490
+ for (const ac of this.peerCalls.get(ws)?.values() ?? [])
491
+ ac.abort();
492
+ this.peerCalls.delete(ws);
493
+ ws.close(1013, "too slow");
365
494
  }
366
495
  /**
367
496
  * Pings keep the MV3 service worker alive while connected. A connection that didn't
@@ -388,6 +517,49 @@ export class ExtensionBridge {
388
517
  }, this.opts.keepAliveMs);
389
518
  this.keepAlive.unref();
390
519
  }
520
+ /**
521
+ * Hub: pings each peer every keepAliveMs; one that didn't answer the previous ping (a
522
+ * suspended or hung session) is dropped, within 2 periods. The peers in turn drop a hub
523
+ * that stops pinging them (see join). A peer over maxPeerBufferedBytes at this tick and the
524
+ * previous one isn't draining its answers and is closed (1013).
525
+ */
526
+ startPeerBeat(ws) {
527
+ this.alive.add(ws);
528
+ ws.on("pong", () => this.alive.add(ws));
529
+ if (this.peerBeat)
530
+ return;
531
+ this.peerBeat = setInterval(() => {
532
+ if (!this.peers.size)
533
+ return this.stopPeerBeat();
534
+ for (const p of this.peers) {
535
+ if (p.readyState !== WebSocket.OPEN)
536
+ continue;
537
+ const backlog = p.bufferedAmount > this.opts.maxPeerBufferedBytes;
538
+ if (backlog && this.backlogged.has(p)) {
539
+ this.closeSlowPeer(p);
540
+ continue;
541
+ }
542
+ if (backlog)
543
+ this.backlogged.add(p);
544
+ else
545
+ this.backlogged.delete(p);
546
+ // With a backlog, the last ping may still wait behind the data, so a missing pong says
547
+ // nothing: the next tick closes the peer if the backlog lasts. It is still pinged (the
548
+ // ping queues behind the data), so a peer that drains it in time hears from its hub.
549
+ if (!backlog && !this.alive.has(p)) {
550
+ p.terminate();
551
+ continue;
552
+ }
553
+ this.alive.delete(p);
554
+ p.ping();
555
+ }
556
+ }, this.opts.keepAliveMs);
557
+ this.peerBeat.unref();
558
+ }
559
+ stopPeerBeat() {
560
+ clearInterval(this.peerBeat);
561
+ this.peerBeat = undefined;
562
+ }
391
563
  // ------------------------------------------------------------------ peer
392
564
  join() {
393
565
  return new Promise((resolve) => {
@@ -397,6 +569,23 @@ export class ExtensionBridge {
397
569
  let forged = false;
398
570
  const nonce = newNonce();
399
571
  const timer = setTimeout(() => ws.terminate(), this.opts.joinTimeoutMs);
572
+ // A hub that declares heartbeatMs pings that often. When its pings stop for 2 periods, never
573
+ // under 2 s (a busy machine runs timers late), the hub is suspended or hung: its calls fail
574
+ // now and the takeover path runs, instead of them hanging until they time out. Hubs of
575
+ // 0.2.13 and older never ping peers: no watchdog.
576
+ let watchdog;
577
+ let heartbeatMs = 0;
578
+ const watch = () => {
579
+ clearTimeout(watchdog);
580
+ watchdog = setTimeout(() => {
581
+ if (this.hub !== ws)
582
+ return;
583
+ this.rejectAll(LOST_CALL.hubSilent);
584
+ ws.terminate();
585
+ }, Math.max(MIN_HUB_SILENCE_MS, 2 * heartbeatMs));
586
+ watchdog.unref();
587
+ };
588
+ ws.on("ping", () => heartbeatMs && watch());
400
589
  ws.on("message", (raw) => {
401
590
  let msg;
402
591
  try {
@@ -423,6 +612,9 @@ export class ExtensionBridge {
423
612
  this.hubVersion = String(msg.serverVersion ?? "unknown");
424
613
  this.extension = msg.extension ?? undefined;
425
614
  this.extensions = msg.extensions ?? (msg.extension ? [msg.extension] : []);
615
+ heartbeatMs = Number(msg.heartbeatMs) > 0 ? Number(msg.heartbeatMs) : 0;
616
+ if (heartbeatMs)
617
+ watch();
426
618
  resolve(true);
427
619
  }
428
620
  else if (msg.type === "ext-state") {
@@ -439,6 +631,7 @@ export class ExtensionBridge {
439
631
  });
440
632
  ws.on("close", (code) => {
441
633
  clearTimeout(timer);
634
+ clearTimeout(watchdog);
442
635
  if (!joined) {
443
636
  this.listenError =
444
637
  code === 4401
@@ -456,7 +649,7 @@ export class ExtensionBridge {
456
649
  this.extension = undefined;
457
650
  this.extensions = [];
458
651
  this.role = "none";
459
- this.rejectAll("The mcp-teams hub restarted; try again in a few seconds.");
652
+ this.rejectAll(LOST_CALL.hubRestarted);
460
653
  if (!this.closed) {
461
654
  clearTimeout(this.retry);
462
655
  this.retry = setTimeout(() => void this.start(), 100 + Math.random() * 400);
@@ -466,7 +659,15 @@ export class ExtensionBridge {
466
659
  });
467
660
  }
468
661
  // ------------------------------------------------------------------ calls
469
- async call(method, params = {}, timeoutMs = 60_000) {
662
+ /**
663
+ * Calls the extension. Aborting `signal` withdraws the call (see withdraw). `deadline` (ms since
664
+ * 1970) ends it earlier than timeoutMs, for a call relayed from a peer.
665
+ */
666
+ call(method, params = {}, timeoutMs, signal, deadline) {
667
+ return this.run(null, method, params, timeoutMs, signal, deadline);
668
+ }
669
+ /** A call from `session` (null: this server's own, else the peer it came from). */
670
+ async run(session, method, params = {}, timeoutMs = 60_000, signal, deadline) {
470
671
  if (method === "reloadAll" && this.role === "hub")
471
672
  return this.reloadAll(timeoutMs);
472
673
  const target = this.role === "hub" ? await this.pickExtension() : this.role === "peer" ? this.hub : undefined;
@@ -476,13 +677,71 @@ export class ExtensionBridge {
476
677
  : "Browser extension is not connected. Open Chrome or Edge with the mcp-teams extension installed (run `mcp-teams setup`).";
477
678
  throw new Error(reason);
478
679
  }
479
- const result = await this.send(target, method, params, timeoutMs);
680
+ let result;
681
+ if (this.role === "hub" && !UNQUEUED.has(method)) {
682
+ // The call's time limit includes its wait for a turn.
683
+ const end = Math.min(Date.now() + timeoutMs, deadline ?? Infinity);
684
+ const queue = await this.takeTurn(target, session, end, signal);
685
+ try {
686
+ if (signal?.aborted)
687
+ throw new Error(NOT_SENT.cancelled);
688
+ result = await this.send(target, method, params, timeoutMs, signal, end);
689
+ }
690
+ finally {
691
+ queue.release(session); // handed to the next waiting call, if any
692
+ }
693
+ }
694
+ else {
695
+ result = await this.send(target, method, params, timeoutMs, signal, deadline);
696
+ }
480
697
  if (this.role === "hub" && this.preferred !== target && this.sockets.has(target)) {
481
698
  this.preferred = target;
482
699
  this.updateExtensions();
483
700
  }
484
701
  return result;
485
702
  }
703
+ /**
704
+ * Hub: waits for one of the extension's maxConcurrentCalls turns, fairly between sessions.
705
+ * Cancelling or reaching `end` first leaves the line, and the call is never sent.
706
+ */
707
+ takeTurn(target, session, end, signal) {
708
+ let turns = this.turns.get(target);
709
+ if (!turns)
710
+ this.turns.set(target, (turns = { queue: new FairQueue(this.opts.maxConcurrentCalls), waiting: new Set() }));
711
+ const { queue, waiting } = turns;
712
+ if (signal?.aborted)
713
+ return Promise.reject(new Error(NOT_SENT.cancelled));
714
+ if (queue.tryTake(session))
715
+ return Promise.resolve(queue);
716
+ return new Promise((resolve, reject) => {
717
+ const done = () => {
718
+ clearTimeout(timer);
719
+ signal?.removeEventListener("abort", onAbort);
720
+ waiting.delete(leave);
721
+ };
722
+ const leaveLine = queue.wait(session, () => (done(), resolve(queue)));
723
+ const leave = (err) => {
724
+ if (!leaveLine())
725
+ return; // already given its turn
726
+ done();
727
+ reject(err);
728
+ };
729
+ const onAbort = () => leave(new Error(NOT_SENT.cancelled));
730
+ const timer = setTimeout(() => leave(new Error(NOT_SENT.waitedOut(queue.max))), Math.max(0, end - Date.now()));
731
+ signal?.addEventListener("abort", onAbort, { once: true });
732
+ waiting.add(leave);
733
+ });
734
+ }
735
+ /** Hub: fails the calls still waiting for a turn on `socket` (every socket when not given); they were never sent. */
736
+ dropTurns(socket) {
737
+ for (const [s, { waiting }] of this.turns) {
738
+ if (socket && s !== socket)
739
+ continue;
740
+ this.turns.delete(s);
741
+ for (const leave of [...waiting])
742
+ leave(new Error(NOT_SENT.gone));
743
+ }
744
+ }
486
745
  /** Hub: asks every connected browser's extension to reload itself. */
487
746
  async reloadAll(timeoutMs) {
488
747
  const open = [...this.sockets.keys()].filter((s) => s.readyState === WebSocket.OPEN);
@@ -495,19 +754,63 @@ export class ExtensionBridge {
495
754
  return { reloading: results.length - failed.length };
496
755
  }
497
756
  /** Sends one request on a connection (an extension for the hub, the hub for a peer). */
498
- send(target, method, params, timeoutMs) {
757
+ send(target, method, params, timeoutMs, signal, deadline) {
758
+ if (signal?.aborted)
759
+ return Promise.reject(cancelledError());
499
760
  const id = randomUUID();
761
+ const now = Date.now();
762
+ // Taken before the timer starts, so the timer never fires before it.
763
+ const end = Math.min(now + timeoutMs, deadline ?? Infinity);
500
764
  // A peer waits a little longer than the hub, so the hub's own timeout message wins.
501
- const wait = this.role === "peer" ? timeoutMs + 5_000 : timeoutMs;
765
+ const wait = Math.max(0, end - now) + (this.role === "peer" ? 5_000 : 0);
502
766
  return new Promise((resolve, reject) => {
503
- const timer = setTimeout(() => {
504
- this.pending.delete(id);
505
- reject(new Error(`Extension call '${method}' timed out`));
506
- }, wait);
507
- this.pending.set(id, { resolve: resolve, reject, timer, socket: this.role === "hub" ? target : undefined });
508
- target.send(JSON.stringify({ type: "request", id, method, params, timeoutMs }));
767
+ const onAbort = () => this.withdraw(id, target, cancelledError());
768
+ const done = () => signal?.removeEventListener("abort", onAbort);
769
+ const timer = setTimeout(() => this.withdraw(id, target, new Error(LOST_CALL.timedOut(method))), wait);
770
+ this.pending.set(id, {
771
+ resolve: (v) => (done(), resolve(v)),
772
+ reject: (e) => (done(), reject(e)),
773
+ timer,
774
+ grace: method === "requestSend",
775
+ socket: this.role === "hub" ? target : undefined,
776
+ });
777
+ signal?.addEventListener("abort", onAbort, { once: true });
778
+ // deadline: when this side stops waiting (same machine, same clock), so the extension
779
+ // sends nothing to Teams after it, however long the request took to arrive.
780
+ target.send(JSON.stringify({ type: "request", id, method, params, timeoutMs, deadline: end }));
509
781
  });
510
782
  }
783
+ /**
784
+ * Withdraws a call nobody waits for any more (timed out or cancelled): tells the other side
785
+ * to drop it with {type:"cancel", id} (sides that don't know it ignore it). A send's answer
786
+ * still counts for cancelGraceMs. So a send the user already clicked reports its real
787
+ * outcome, and a confirmation window that closed reports "nothing was sent". Without an
788
+ * answer by then, the call fails with `err`.
789
+ */
790
+ withdraw(id, target, err) {
791
+ const p = this.pending.get(id);
792
+ if (!p || p.withdrawn)
793
+ return;
794
+ p.withdrawn = true;
795
+ clearTimeout(p.timer);
796
+ const fail = () => {
797
+ if (this.pending.get(id) !== p)
798
+ return;
799
+ this.pending.delete(id);
800
+ p.reject(err);
801
+ };
802
+ if (target.readyState !== WebSocket.OPEN)
803
+ return fail();
804
+ try {
805
+ target.send(JSON.stringify({ type: "cancel", id }));
806
+ }
807
+ catch {
808
+ return fail();
809
+ }
810
+ if (!p.grace)
811
+ return fail();
812
+ p.timer = setTimeout(fail, this.opts.cancelGraceMs);
813
+ }
511
814
  /**
512
815
  * Has the extension reload itself from disk, then waits until it is connected again.
513
816
  * Returns its version before and after.
@@ -556,7 +859,9 @@ export class ExtensionBridge {
556
859
  this.closed = true;
557
860
  clearTimeout(this.retry);
558
861
  clearInterval(this.keepAlive);
559
- this.rejectAll("mcp-teams is shutting down");
862
+ this.stopPeerBeat();
863
+ this.dropTurns();
864
+ this.rejectAll(LOST_CALL.shuttingDown);
560
865
  this.hub?.close();
561
866
  for (const s of this.sockets.keys())
562
867
  s.close();