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 +10 -0
- package/dist/bridge.js +334 -29
- package/dist/bridge.js.map +1 -1
- package/dist/fairqueue.js +83 -0
- package/dist/fairqueue.js.map +1 -0
- package/dist/tools.js +48 -23
- package/dist/tools.js.map +1 -1
- package/extension/background.js +494 -127
- package/extension/manifest.json +1 -1
- package/extension/teams.js +124 -10
- package/package.json +1 -1
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(
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
364
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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" ?
|
|
765
|
+
const wait = Math.max(0, end - now) + (this.role === "peer" ? 5_000 : 0);
|
|
502
766
|
return new Promise((resolve, reject) => {
|
|
503
|
-
const
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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.
|
|
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();
|