mcp-teams 0.2.13 → 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.
@@ -182,9 +182,12 @@ const SERVICE_HELP = {
182
182
 
183
183
  /**
184
184
  * Runs inside the Teams page (MAIN world) so cookies and origin are the page's own.
185
- * status 0: no answer (network error or timeoutMs passed).
185
+ * status 0: no answer (network error, or the deadline passed). `deadline` is absolute
186
+ * (ms since 1970): a tab that runs this only later (frozen or discarded) sends nothing after it.
186
187
  */
187
- async function pageFetch(url, method, headers, body, timeoutMs) {
188
+ async function pageFetch(url, method, headers, body, deadline) {
189
+ const timeoutMs = deadline - Date.now();
190
+ if (timeoutMs <= 0) return { status: 0, text: "", error: "timeout" };
188
191
  try {
189
192
  const res = await fetch(url, {
190
193
  method,
@@ -193,53 +196,156 @@ async function pageFetch(url, method, headers, body, timeoutMs) {
193
196
  body: body == null ? undefined : JSON.stringify(body),
194
197
  signal: AbortSignal.timeout(timeoutMs),
195
198
  });
196
- return { status: res.status, text: await res.text(), location: res.headers.get("location") };
199
+ return { status: res.status, text: await res.text(), location: res.headers.get("location"), retryAfter: res.headers.get("retry-after") };
197
200
  } catch (err) {
198
201
  return { status: 0, text: "", error: err?.name === "TimeoutError" ? "timeout" : String(err?.message ?? err) };
199
202
  }
200
203
  }
201
204
 
202
- async function runInTab(tab, url, method, headers, body) {
203
- const [injection] = await chrome.scripting.executeScript({
204
- target: { tabId: tab.id },
205
- world: "MAIN",
206
- func: pageFetch,
207
- // executeScript can't pass undefined.
208
- args: [url, method, headers, body ?? null, REQUEST_TIMEOUT_MS],
205
+ /** 60, 0.5: seconds for a message. */
206
+ const secondsOf = (ms) => +(ms / 1000).toFixed(1);
207
+
208
+ /** How long after its deadline a tab script may take to report back before the tab counts as frozen. */
209
+ const SCRIPT_MARGIN_MS = 1_000;
210
+
211
+ /** An error after which the request may have reached Teams (no answer came): `outcomeUnknown`. */
212
+ function unanswered(message) {
213
+ return Object.assign(new Error(message), { outcomeUnknown: true });
214
+ }
215
+
216
+ /**
217
+ * One fetch in the tab, by REQUEST_TIMEOUT_MS or the request's deadline (ctx), whichever
218
+ * comes first. Nothing starts once the request was withdrawn, and a tab that doesn't run
219
+ * the script in time (frozen, discarded) fails the call instead of holding it.
220
+ */
221
+ async function runInTab(tab, url, method, headers, body, ctx = null) {
222
+ ctx?.check();
223
+ const start = Date.now();
224
+ const deadline = Math.min(start + REQUEST_TIMEOUT_MS, ctx?.deadline ?? Infinity);
225
+ const seconds = secondsOf(deadline - start);
226
+ let timer;
227
+ const scriptTimeout = new Promise((_, reject) => {
228
+ timer = setTimeout(
229
+ () => reject(unanswered(`The Teams tab did not run the request within ${seconds} s (it may be frozen or asleep). Reload the Teams tab and try again.`)),
230
+ deadline - start + SCRIPT_MARGIN_MS,
231
+ );
209
232
  });
233
+ const run = chrome.scripting
234
+ .executeScript({
235
+ target: { tabId: tab.id },
236
+ world: "MAIN",
237
+ func: pageFetch,
238
+ // executeScript can't pass undefined.
239
+ args: [url, method, headers, body ?? null, deadline],
240
+ })
241
+ // E.g. the tab closed or reloaded while the request ran: it may have reached Teams.
242
+ .catch((err) => {
243
+ throw unanswered(String(err?.message ?? err));
244
+ });
245
+ let injection;
246
+ try {
247
+ [injection] = await guarded(Promise.race([run, scriptTimeout]), ctx);
248
+ } finally {
249
+ clearTimeout(timer);
250
+ }
210
251
  const res = injection?.result;
211
- if (!res) throw new Error("Could not run inside the Teams tab. Reload the Teams tab and try again.");
252
+ if (!res) throw unanswered("Could not run inside the Teams tab. Reload the Teams tab and try again.");
212
253
  if (res.status === 0) {
213
- throw new Error(
254
+ ctx?.check(); // the deadline passed: say that rather than "Teams did not answer"
255
+ // A fetch that failed may have been sent already (a reset connection, no answer in time).
256
+ throw unanswered(
214
257
  res.error === "timeout"
215
- ? `Teams did not answer within ${REQUEST_TIMEOUT_MS / 1000} s. Reload the Teams tab and try again.`
258
+ ? `Teams did not answer within ${seconds} s. Reload the Teams tab and try again.`
216
259
  : `Could not reach Teams: ${res.error}`,
217
260
  );
218
261
  }
219
262
  return res;
220
263
  }
221
264
 
265
+ // ------------------------------------------------------------ throttling
266
+
267
+ /**
268
+ * Gate key -> when it may be sent to again (ms since 1970), after a 429. A gate is per service
269
+ * and where it lives: its captured base for Teams' services (another region or account gets its
270
+ * own), the origin (one tenant's OneDrive) for SharePoint.
271
+ */
272
+ const gates = new Map();
273
+ const gateKey = (service, where) => `${service} ${where}`;
274
+
275
+ /** How long the gate `key` is still shut, in ms (0: open). */
276
+ function gatedFor(key) {
277
+ const ms = (gates.get(key) ?? 0) - Date.now();
278
+ if (ms > 0) return ms;
279
+ gates.delete(key);
280
+ return 0;
281
+ }
282
+
283
+ const busySeconds = (ms) => Math.max(1, Math.ceil(ms / 1000));
284
+
285
+ /** The error for a call Teams throttles until `until` (ms since 1970). */
286
+ function busy(until) {
287
+ return Object.assign(new Error(`Teams is busy (throttled) for ${busySeconds(until - Date.now())} s; try again after that.`), { throttled: true, until });
288
+ }
289
+
290
+ // A wait that would end this close to the request's deadline leaves no time for the request itself.
291
+ const GATE_MARGIN_MS = 250;
292
+
293
+ /**
294
+ * Before anything goes to a throttled service: waits until Teams' Retry-After is over when
295
+ * `wait` and that fits before the request's deadline, else fails at once as busy.
296
+ */
297
+ async function passGate(key, ctx, wait) {
298
+ for (let ms = gatedFor(key); ms > 0; ms = gatedFor(key)) {
299
+ const deadline = Math.min(ctx?.deadline ?? Infinity, Date.now() + REQUEST_TIMEOUT_MS);
300
+ if (!wait || Date.now() + ms + GATE_MARGIN_MS > deadline) throw busy(Date.now() + ms);
301
+ await guarded(sleep(ms), ctx);
302
+ }
303
+ ctx?.check();
304
+ }
305
+
306
+ /**
307
+ * runInTab through the gate `key`. A 429 closes the gate for its Retry-After. Teams refused
308
+ * that request without running it, so a read (a GET, or a POST that only reads: `repeatable`)
309
+ * goes once more after the wait (when `wait` and it fits the deadline). Anything else, such as
310
+ * a post, is never sent again: the caller hears that Teams is busy.
311
+ */
312
+ async function sendTo(key, tab, url, method, headers, body, ctx, wait, repeatable = method === "GET") {
313
+ for (let attempt = 0; ; attempt++) {
314
+ await passGate(key, ctx, wait);
315
+ const res = await runInTab(tab, url, method, headers, body, ctx);
316
+ if (res.status !== 429) return res;
317
+ const until = Math.max(gates.get(key) ?? 0, Date.now() + teams.retryAfterMs(res.retryAfter));
318
+ gates.set(key, until);
319
+ if (!repeatable || attempt > 0) throw busy(until);
320
+ }
321
+ }
322
+
222
323
  /**
223
324
  * One request to a Teams service. `path` is relative to the service's base URL (or a
224
- * full URL on it). Returns the parsed JSON (null for an empty answer).
325
+ * full URL on it). Returns the parsed JSON (null for an empty answer). While Teams throttles
326
+ * the service, the call waits that out if it fits the deadline; `wait: false` (for lookups
327
+ * that can be done without) fails at once as busy instead. `repeatable`: a POST that only
328
+ * reads (a search, a profile lookup), sent again after a 429 like a GET.
225
329
  */
226
- async function api(service, path, { method = "GET", body, raw = false, withLocation = false } = {}) {
330
+ async function api(service, path, { method = "GET", body, raw = false, withLocation = false, ctx = null, wait = true, repeatable = method === "GET" } = {}) {
331
+ ctx?.check();
227
332
  let cap = await getCapture(service);
228
- let tab = await findTeamsTab(cap ? new URL(cap.base).origin : null);
333
+ // Opening a tab or waiting for a session can take a minute; a withdrawn request stops waiting.
334
+ let tab = await guarded(findTeamsTab(cap ? new URL(cap.base).origin : null), ctx);
229
335
  if (!cap) {
230
- cap = await refreshSession(service, tab, { quickWaitMs: service === "substrate" ? 2_000 : 4_000 });
336
+ cap = await guarded(refreshSession(service, tab, { quickWaitMs: service === "substrate" ? 2_000 : 4_000 }), ctx);
231
337
  if (!cap) throw new Error(SERVICE_HELP[service]);
232
338
  }
233
339
  const urlFor = (c) => teams.serviceUrl(path, c.base);
234
- let res = await runInTab(tab, urlFor(cap), method, cap.headers, body);
340
+ let res = await sendTo(gateKey(service, cap.base), tab, urlFor(cap), method, cap.headers, body, ctx, wait, repeatable);
235
341
  if (res.status === 401) {
236
342
  // Rejected before running (expired token), so trying once more is safe. A 403 is a
237
343
  // real answer (e.g. no longer a member of that chat).
238
344
  meetingSessionsCache.clear(); // the session (maybe the account) is changing
239
- cap = await refreshSession(service, tab, { quickWaitMs: 0 });
345
+ cap = await guarded(refreshSession(service, tab, { quickWaitMs: 0 }), ctx);
240
346
  if (!cap) throw new Error(SERVICE_HELP[service]);
241
- tab = await findTeamsTab(new URL(cap.base).origin);
242
- res = await runInTab(tab, urlFor(cap), method, cap.headers, body);
347
+ tab = await guarded(findTeamsTab(new URL(cap.base).origin), ctx);
348
+ res = await sendTo(gateKey(service, cap.base), tab, urlFor(cap), method, cap.headers, body, ctx, wait, repeatable);
243
349
  }
244
350
  if (raw) return res;
245
351
  if (res.status >= 400) {
@@ -272,54 +378,106 @@ async function myMri() {
272
378
  }
273
379
 
274
380
  const LIST_CACHE_MS = 60_000;
275
- let listCache = null; // { at, data: { teams, chats } }
381
+ let listCache = null; // { until, data: { teams, chats, meetings, namesMissing } }
276
382
 
277
- /** Teams, channels and chats (csa), with names for untitled chats. Cached for a minute. */
278
- async function teamsAndChats(fresh = false) {
279
- if (!fresh && listCache && Date.now() - listCache.at < LIST_CACHE_MS) return listCache.data;
280
- const json = await api("csa", "/api/v1/teams/users/me?isPrefetch=false&enableMembershipSummary=true&enableRC2Fetch=false");
383
+ /**
384
+ * Teams, channels and chats (csa), with names for untitled chats. Cached for a minute, but
385
+ * when names are missing (`namesMissing`: { chats, organizers }, see lookupNames) only until
386
+ * Teams' throttle is over, or not at all after another failure, so they are looked up again.
387
+ * A meeting whose organizer's name is missing that way has `organizerNameMissing`.
388
+ */
389
+ async function teamsAndChats(fresh = false, ctx = null) {
390
+ if (!fresh && listCache && Date.now() < listCache.until) return listCache.data;
391
+ const json = await api("csa", "/api/v1/teams/users/me?isPrefetch=false&enableMembershipSummary=true&enableRC2Fetch=false", { ctx });
281
392
  const data = { teams: teams.parseTeams(json), chats: teams.parseCsaChats(json), meetings: teams.parseCsaMeetings(json) };
282
- await nameChats(data.chats);
283
- await lookupNames(data.meetings.map((m) => m.organizer));
284
- for (const m of data.meetings) m.organizerName = names.get(m.organizer) ?? null;
285
- listCache = { at: Date.now(), data };
393
+ const chats = await nameChats(data.chats, ctx);
394
+ const organizers = await lookupNames(data.meetings.map((m) => m.organizer), ctx);
395
+ for (const m of data.meetings) {
396
+ m.organizerName = names.get(m.organizer) ?? null;
397
+ if (!m.organizerName && organizers?.mris.has(m.organizer)) m.organizerNameMissing = organizers;
398
+ }
399
+ data.namesMissing = { chats, organizers };
400
+ const missing = [chats, organizers].filter(Boolean);
401
+ const until = missing.some((x) => !x.throttled) ? 0 : missing.length ? Math.max(...missing.map((x) => x.until)) : Date.now() + LIST_CACHE_MS;
402
+ listCache = { until, data };
286
403
  return data;
287
404
  }
288
405
 
406
+ /** A result's note when names are missing (`missing`, see lookupNames): throttled, or why. */
407
+ function namesNote(what, missing) {
408
+ if (!missing.throttled) return `${what} missing: some people couldn't be looked up, so they are shown without their names. Try again. The lookup failed with: ${missing.error}`;
409
+ const wait = missing.until - Date.now();
410
+ return `${what} missing (throttled): Teams asked to slow down, so some people couldn't be looked up and are shown without their names.${wait > 0 ? ` Try again in ${busySeconds(wait)} s.` : " Try again."}`;
411
+ }
412
+
289
413
  const names = new Map(); // mri -> display name; bounded, starts over when full
290
414
  const NAMES_MAX = 5000;
291
415
 
292
- /** Looks up display names for MRIs not seen yet (mt short profiles, 100 at a time). */
293
- async function lookupNames(mris) {
416
+ /**
417
+ * Looks up display names for MRIs not seen yet (mt short profiles, 100 at a time). Returns
418
+ * null when it could ask for all of them, else why some were left out (the caller says so):
419
+ * { throttled: true, until } while Teams throttles mt, or { error } for another failure, with
420
+ * `mris`: those it couldn't ask for (the failed batches, and the ones a throttle left out).
421
+ * Names are a nicety, so it doesn't wait out a throttle unless `wait`.
422
+ */
423
+ async function lookupNames(mris, ctx = null, { wait = false } = {}) {
294
424
  const unknown = [...new Set(mris)].filter((m) => m && !names.has(m) && /^8:/.test(m));
425
+ let missing = null;
426
+ const notAsked = new Set();
295
427
  for (let i = 0; i < unknown.length; i += 100) {
428
+ if (missing?.throttled) {
429
+ for (const m of unknown.slice(i)) notAsked.add(m);
430
+ break;
431
+ }
432
+ // Names that can't be looked up are left out, but a withdrawn request ends here (and isn't cached).
433
+ ctx?.check();
296
434
  const batch = unknown.slice(i, i + 100);
297
435
  const json = await api("mt", "/beta/users/fetchShortProfile?isMailAddress=false&enableGuest=true&includeIBBarredUsers=true&skypeTeamsInfo=true", {
298
436
  method: "POST",
299
437
  body: batch,
300
- }).catch(() => null);
438
+ ctx,
439
+ wait,
440
+ repeatable: true,
441
+ }).catch((err) => {
442
+ ctx?.check(); // a withdrawn request ends here (and isn't cached)
443
+ missing = err?.throttled ? { throttled: true, until: err.until } : (missing ?? { error: err?.message ?? String(err) });
444
+ for (const m of batch) notAsked.add(m);
445
+ return null;
446
+ });
301
447
  if (names.size > NAMES_MAX) names.clear();
302
448
  for (const p of teams.parseProfiles(json)) if (p.mri && p.name) names.set(p.mri, p.name);
303
449
  }
450
+ ctx?.check();
451
+ return missing && { ...missing, mris: notAsked };
304
452
  }
305
453
 
306
- async function nameChats(chats) {
454
+ /** Names untitled chats by their members; returns why names are missing, if they are (see lookupNames). */
455
+ async function nameChats(chats, ctx = null) {
307
456
  const me = await myMri();
308
- await lookupNames(chats.filter((c) => !c.title).flatMap((c) => c.members));
457
+ const missing = await lookupNames(chats.filter((c) => !c.title).flatMap((c) => c.members), ctx);
309
458
  for (const c of chats) {
310
459
  c.memberNames = c.members.filter((m) => m !== me).map((m) => names.get(m)).filter(Boolean);
311
460
  c.title = teams.chatTitle(c, names, me);
312
461
  }
462
+ return missing;
313
463
  }
314
464
 
315
- async function resolve(conversation) {
465
+ async function resolve(conversation, ctx = null) {
316
466
  const q = String(conversation ?? "").trim();
317
467
  if (!q) throw new Error("Say which chat or channel: an id, 'Team/Channel', a chat title or a person's name.");
318
468
  if (/^(19|48):/.test(q)) {
319
- const data = await teamsAndChats().catch(() => ({ teams: [], chats: [] }));
469
+ const data = await teamsAndChats(false, ctx).catch(() => ({ teams: [], chats: [] }));
470
+ ctx?.check();
320
471
  return describe(q.split(";messageid=")[0], data);
321
472
  }
322
- return teams.resolveConversation(q, await teamsAndChats());
473
+ const data = await teamsAndChats(false, ctx);
474
+ try {
475
+ return teams.resolveConversation(q, data);
476
+ } catch (err) {
477
+ // Untitled chats are found by their members' names: without them a match can be missed.
478
+ if (data.namesMissing?.chats) err.message += `\n${namesNote("Chat names", data.namesMissing.chats)}`;
479
+ throw err;
480
+ }
323
481
  }
324
482
 
325
483
  /** What we know about a conversation id from the lists. */
@@ -335,7 +493,7 @@ function describe(id, { teams: ts, chats }) {
335
493
  }
336
494
 
337
495
  /** People search as in Teams' people picker (substrate suggestions). */
338
- async function searchPeople(query, limit) {
496
+ async function searchPeople(query, limit, ctx = null) {
339
497
  const body = {
340
498
  EntityRequests: [{ Query: { QueryString: query, DisplayQueryString: query }, EntityType: "People", Size: limit, Fields: ["Id", "MRI", "DisplayName", "EmailAddresses", "PeopleType", "PeopleSubtype", "UserPrincipalName", "GivenName", "Surname", "JobTitle", "Department", "CompanyName", "OfficeLocation", "ExternalDirectoryObjectId"] }],
341
499
  cvid: crypto.randomUUID(),
@@ -343,26 +501,28 @@ async function searchPeople(query, limit) {
343
501
  AppName: "Microsoft Teams",
344
502
  Scenario: { Name: "peoplepicker.newChat" },
345
503
  };
346
- return teams.parseSuggestions(await api("substrate", "/search/api/v1/suggestions?scenario=peoplepicker.newChat", { method: "POST", body }));
504
+ return teams.parseSuggestions(await api("substrate", "/search/api/v1/suggestions?scenario=peoplepicker.newChat", { method: "POST", body, ctx, repeatable: true }));
347
505
  }
348
506
 
349
507
  /** Each name or address as the one person it means (see teams.pickPerson). */
350
- async function resolvePeople(queries, me) {
508
+ async function resolvePeople(queries, me, ctx = null) {
351
509
  const people = [];
352
510
  for (const q of queries) {
353
511
  const query = String(q).trim();
354
512
  if (/^8:orgid:/i.test(query)) {
355
- await lookupNames([query]);
513
+ // The window must show who it is: wait out a throttle if the deadline allows, else fail as busy.
514
+ const missing = await lookupNames([query], ctx, { wait: true });
515
+ if (missing?.throttled) throw busy(missing.until);
356
516
  people.push({ mri: query, name: names.get(query) ?? query, email: null });
357
517
  continue;
358
518
  }
359
- let found = await searchPeople(query, 10);
519
+ let found = await searchPeople(query, 10, ctx);
360
520
  // People search can miss a full name it would find by one of its words (seen with
361
521
  // "KIM MIN JUN" for "김민준(KIM MIN JUN)"): then search each word, keeping exact names.
362
522
  const words = query.split(/\s+/).filter((w) => w.length > 1);
363
523
  if (!found.length && words.length > 1) {
364
524
  for (const w of words) {
365
- found = (await searchPeople(w, 25)).filter((p) => teams.sameName(p.name, query));
525
+ found = (await searchPeople(w, 25, ctx)).filter((p) => teams.sameName(p.name, query));
366
526
  if (found.length) break;
367
527
  }
368
528
  }
@@ -412,20 +572,22 @@ async function sharepointTab(origin) {
412
572
  }
413
573
 
414
574
  /** A GET on SharePoint with the browser's own SharePoint session. */
415
- async function sharepointGet(url) {
575
+ async function sharepointGet(url, ctx = null) {
416
576
  if (!SHAREPOINT_URL.test(url)) throw new Error("Not a SharePoint URL");
417
- return runInTab(await sharepointTab(new URL(url).origin), url, "GET", {}, null);
577
+ ctx?.check();
578
+ const origin = new URL(url).origin;
579
+ return sendTo(gateKey("sharepoint", origin), await guarded(sharepointTab(origin), ctx), url, "GET", {}, null, ctx, true);
418
580
  }
419
581
 
420
582
  const transcripts = new Map(); // transcript URL -> parsed transcript; a finished transcript doesn't change
421
583
  const TRANSCRIPTS_MAX = 50;
422
584
 
423
585
  /** A call's transcript (Stream transcript.json) from the organizer's OneDrive. */
424
- async function transcriptOf(info, session) {
586
+ async function transcriptOf(info, session, ctx = null) {
425
587
  const cached = transcripts.get(session.transcriptUrl);
426
588
  if (cached) return cached;
427
589
  const url = session.transcriptUrl + (session.transcriptUrl.includes("?") ? "&" : "?") + "format=json";
428
- const res = await sharepointGet(url);
590
+ const res = await sharepointGet(url, ctx);
429
591
  if (res.status === 403 || res.status === 404) {
430
592
  throw new Error(`The transcript of "${info.title}" (${session.start}) is no longer available to you (HTTP ${res.status}): the organizer may have deleted it or not shared it.`);
431
593
  }
@@ -437,9 +599,9 @@ async function transcriptOf(info, session) {
437
599
  }
438
600
 
439
601
  /** A meeting chat by id, title or subject. */
440
- async function resolveMeeting(meeting) {
441
- const conv = await resolve(meeting);
442
- const info = (await teamsAndChats()).meetings.find((m) => m.id === conv.id);
602
+ async function resolveMeeting(meeting, ctx = null) {
603
+ const conv = await resolve(meeting, ctx);
604
+ const info = (await teamsAndChats(false, ctx)).meetings.find((m) => m.id === conv.id);
443
605
  if (!info) throw new Error(`"${conv.title ?? conv.id}" is not a meeting chat. list_meetings lists them.`);
444
606
  return info;
445
607
  }
@@ -454,11 +616,11 @@ const sessionsKey = (cap, id) => (cap ? `${cap.base}\n${id}` : null);
454
616
  * The calls held in a meeting chat (see teams.meetingSessions). Cached for a minute, so
455
617
  * read_transcript's continueFrom pages and get_meeting don't read the whole chat again.
456
618
  */
457
- async function sessionsOf(info) {
619
+ async function sessionsOf(info, ctx = null) {
458
620
  const key = sessionsKey(await getCapture("chatsvc"), info.id);
459
621
  const hit = key && meetingSessionsCache.get(key);
460
622
  if (hit && Date.now() - hit.at < SESSIONS_CACHE_MS) return hit.sessions;
461
- const sessions = await readSessions(info);
623
+ const sessions = await readSessions(info, ctx);
462
624
  // Filed under the base the chat was read from (a 401 on the way may have changed it).
463
625
  const keyNow = sessionsKey(await getCapture("chatsvc"), info.id);
464
626
  if (keyNow) {
@@ -469,10 +631,10 @@ async function sessionsOf(info) {
469
631
  return sessions;
470
632
  }
471
633
 
472
- async function readSessions(info) {
634
+ async function readSessions(info, ctx = null) {
473
635
  let raw;
474
636
  try {
475
- raw = await fetchMessages(info.id, { limit: 2000, includeSystem: true });
637
+ raw = await fetchMessages(info.id, { limit: 2000, includeSystem: true, ctx });
476
638
  } catch (err) {
477
639
  if (/HTTP 403/.test(err.message) && info.external) {
478
640
  throw new Error(`"${info.title}" was organized by another organization, and Teams no longer lets you read its meeting chat.`);
@@ -504,7 +666,7 @@ const messagesTarget = (id, threadId) => (threadId ? `${id};messageid=${threadId
504
666
  * page, or at `from` (a link from a cursor, see teams.readMessagesCursor), and takes only
505
667
  * messages older than `olderThan` ({ time, id }, see teams.compareMessageKeys) if given.
506
668
  */
507
- async function fetchMessagePages(id, { limit, olderThan, includeSystem, threadId, from }) {
669
+ async function fetchMessagePages(id, { limit, olderThan, includeSystem, threadId, from, ctx = null }) {
508
670
  const target = messagesTarget(id, threadId);
509
671
  const first = from ?? `/v1/users/ME/conversations/${encodeURIComponent(target)}/messages?view=${encodeURIComponent(MESSAGES_VIEW)}&pageSize=${Math.min(200, Math.max(20, limit * 2))}&startTime=1`;
510
672
  let path = first;
@@ -514,7 +676,7 @@ async function fetchMessagePages(id, { limit, olderThan, includeSystem, threadId
514
676
  let pages = 0;
515
677
  while (path) {
516
678
  if (pages === MAX_PAGES) return { raw, linkOf, first, next: path, capped: true, pages };
517
- const json = await api("chatsvc", path);
679
+ const json = await api("chatsvc", path, { ctx });
518
680
  pages++;
519
681
  const msgs = json?.messages ?? [];
520
682
  for (const m of msgs) {
@@ -549,31 +711,33 @@ export const handlers = {
549
711
  };
550
712
  },
551
713
 
552
- async listTeams() {
553
- return { teams: (await teamsAndChats(true)).teams };
714
+ async listTeams(_, ctx) {
715
+ return { teams: (await teamsAndChats(true, ctx)).teams };
554
716
  },
555
717
 
556
- async listChats({ query, limit = 30, unreadOnly = false }) {
557
- let chats = (await teamsAndChats(true)).chats;
718
+ async listChats({ query, limit = 30, unreadOnly = false }, ctx) {
719
+ const data = await teamsAndChats(true, ctx);
720
+ let chats = data.chats;
558
721
  if (unreadOnly) chats = chats.filter((c) => c.unread);
559
722
  if (query) chats = chats.filter((c) => teams.matchesWords(c.title, query) || c.memberNames?.some((n) => teams.matchesWords(n, query)));
560
723
  chats.sort((a, b) => (b.lastMessage?.time ?? "").localeCompare(a.lastMessage?.time ?? ""));
561
724
  return {
562
725
  total: chats.length,
563
726
  chats: chats.slice(0, limit).map(({ members, memberNames, lastFrom, ...c }) => ({ ...c, memberCount: members.length || undefined })),
727
+ ...(data.namesMissing.chats ? { note: namesNote("Chat names", data.namesMissing.chats) } : {}),
564
728
  };
565
729
  },
566
730
 
567
- async readMessages({ conversation, limit = 30, before, cursor, includeSystem = false, threadId, maxLength }) {
731
+ async readMessages({ conversation, limit = 30, before, cursor, includeSystem = false, threadId, maxLength }, ctx) {
568
732
  if (cursor && before) throw new Error("Give either cursor or before, not both (cursor already knows where to go on).");
569
- const conv = await resolve(conversation);
733
+ const conv = await resolve(conversation, ctx);
570
734
  // A cursor resumes at the page that held the oldest message returned last time, and
571
735
  // only takes what is older than that message (by time, then id).
572
736
  const from = cursor ? teams.readMessagesCursor(cursor, messagesTarget(conv.id, threadId)) : null;
573
737
  if (from) before = from.before ?? undefined;
574
738
  const mark = from ? { time: from.before, id: from.id } : { time: before ?? null, id: null };
575
739
  const olderThan = mark.time != null || mark.id != null ? mark : null;
576
- const { raw, linkOf, first, next, capped, pages } = await fetchMessagePages(conv.id, { limit, olderThan, includeSystem, threadId, from: from?.path });
740
+ const { raw, linkOf, first, next, capped, pages } = await fetchMessagePages(conv.id, { limit, olderThan, includeSystem, threadId, from: from?.path, ctx });
577
741
  const { messages, more, oldest } = teams.newestEntries(raw, { limit, includeSystem, channel: conv.kind === "channel" && !threadId });
578
742
  let olderCursor = null;
579
743
  if (oldest && (more || next)) olderCursor = teams.messagesCursor(linkOf.get(oldest.id) ?? first, oldest.time, oldest.id);
@@ -594,7 +758,7 @@ export const handlers = {
594
758
  };
595
759
  },
596
760
 
597
- async searchMessages({ query, limit = 25, offset = 0 }) {
761
+ async searchMessages({ query, limit = 25, offset = 0 }, ctx) {
598
762
  const body = {
599
763
  entityRequests: [
600
764
  {
@@ -612,40 +776,44 @@ export const handlers = {
612
776
  logicalId: crypto.randomUUID(),
613
777
  scenario: { Dimensions: [{ DimensionName: "QueryType", DimensionValue: "All" }, { DimensionName: "FormFactor", DimensionValue: "general.web.reactSearch" }], Name: "powerbar" },
614
778
  };
615
- const r = teams.parseSearch(await api("substrate", "/searchservice/api/v2/query", { method: "POST", body }));
779
+ const r = teams.parseSearch(await api("substrate", "/searchservice/api/v2/query", { method: "POST", body, ctx, repeatable: true }));
616
780
  return { query, ...r, results: r.results.slice(0, limit) };
617
781
  },
618
782
 
619
- async findPeople({ query, limit = 10 }) {
620
- const people = await searchPeople(query, limit);
783
+ async findPeople({ query, limit = 10 }, ctx) {
784
+ const people = await searchPeople(query, limit, ctx);
621
785
  return { count: people.length, people };
622
786
  },
623
787
 
624
- async listMeetings({ query, limit = 20, transcriptOnly = false }) {
625
- let meetings = (await teamsAndChats(true)).meetings;
788
+ async listMeetings({ query, limit = 20, transcriptOnly = false }, ctx) {
789
+ const data = await teamsAndChats(true, ctx);
790
+ let meetings = data.meetings;
626
791
  if (query) meetings = meetings.filter((m) => teams.matchesWords(m.title, query) || teams.matchesWords(m.organizerName, query));
627
792
  if (transcriptOnly) meetings = meetings.filter((m) => m.hasTranscript);
628
793
  meetings = [...meetings].sort((a, b) => (b.lastActivity ?? "").localeCompare(a.lastActivity ?? ""));
629
794
  return {
630
795
  total: meetings.length,
631
- meetings: meetings.slice(0, limit).map(({ organizer, organizerName, joinUrl, lastJoinAt, ...m }) => ({ ...m, organizer: organizerName ?? organizer })),
796
+ meetings: meetings.slice(0, limit).map(({ organizer, organizerName, organizerNameMissing, joinUrl, lastJoinAt, ...m }) => ({ ...m, organizer: organizerName ?? organizer })),
797
+ ...(data.namesMissing.organizers ? { note: namesNote("Organizer names", data.namesMissing.organizers) } : {}),
632
798
  };
633
799
  },
634
800
 
635
- async getMeeting({ meeting, limit = 10 }) {
636
- const info = await resolveMeeting(meeting);
637
- const sessions = await sessionsOf(info);
638
- const { organizer, organizerName, lastJoinAt, ...rest } = info;
801
+ async getMeeting({ meeting, limit = 10 }, ctx) {
802
+ const info = await resolveMeeting(meeting, ctx);
803
+ const sessions = await sessionsOf(info, ctx);
804
+ const { organizer, organizerName, organizerNameMissing, lastJoinAt, ...rest } = info;
639
805
  return {
640
806
  meeting: { ...rest, organizer: organizerName ?? organizer },
641
807
  sessionCount: sessions.length,
642
808
  sessions: sessions.slice(-limit).reverse().map(({ transcriptUrl, ...s }) => s),
809
+ // Only when its own lookup failed; the list it came from isn't kept long then (teamsAndChats).
810
+ ...(organizerNameMissing ? { note: namesNote("Organizer name", organizerNameMissing) } : {}),
643
811
  };
644
812
  },
645
813
 
646
- async readTranscript({ meeting, session, from, maxLength = 40_000 }) {
647
- const info = await resolveMeeting(meeting);
648
- const all = await sessionsOf(info);
814
+ async readTranscript({ meeting, session, from, maxLength = 40_000 }, ctx) {
815
+ const info = await resolveMeeting(meeting, ctx);
816
+ const all = await sessionsOf(info, ctx);
649
817
  const withTranscript = all.filter((s) => s.transcriptUrl);
650
818
  if (!withTranscript.length) {
651
819
  throw new Error(`"${info.title}" has no transcript${all.length ? ` in its ${all.length} recorded call(s)` : ""}. Teams keeps one only when transcription was turned on in the meeting.`);
@@ -659,7 +827,7 @@ export const handlers = {
659
827
  }
660
828
  s = matches.at(-1);
661
829
  }
662
- const json = await transcriptOf(info, s);
830
+ const json = await transcriptOf(info, s, ctx);
663
831
  const fromSeconds = from ? (String(from).split(":").reduce((a, x) => a * 60 + Number(x), 0) || 0) : 0;
664
832
  const lines = teams.transcriptLines(json, { fromSeconds });
665
833
  let text = "";
@@ -681,8 +849,8 @@ export const handlers = {
681
849
  };
682
850
  },
683
851
 
684
- async searchTranscripts({ query, meeting, limit = 30, meetings: maxMeetings = 10 }) {
685
- let list = meeting ? [await resolveMeeting(meeting)] : (await teamsAndChats(true)).meetings.filter((m) => m.hasTranscript);
852
+ async searchTranscripts({ query, meeting, limit = 30, meetings: maxMeetings = 10 }, ctx) {
853
+ let list = meeting ? [await resolveMeeting(meeting, ctx)] : (await teamsAndChats(true, ctx)).meetings.filter((m) => m.hasTranscript);
686
854
  list = [...list].sort((a, b) => (b.lastActivity ?? "").localeCompare(a.lastActivity ?? "")).slice(0, maxMeetings);
687
855
  const results = [];
688
856
  const skipped = [];
@@ -691,19 +859,22 @@ export const handlers = {
691
859
  let searched = 0;
692
860
  for (const info of list) {
693
861
  if (shown >= limit) break; // enough matches; older meetings aren't read
862
+ ctx?.check(); // withdrawn: no more meetings are read
694
863
  searched++;
695
864
  let sessions;
696
865
  try {
697
- sessions = (await sessionsOf(info)).filter((s) => s.transcriptUrl);
866
+ sessions = (await sessionsOf(info, ctx)).filter((s) => s.transcriptUrl);
698
867
  } catch (err) {
868
+ ctx?.check();
699
869
  skipped.push({ meeting: info.title, reason: err.message });
700
870
  continue;
701
871
  }
702
872
  for (const s of sessions.reverse()) {
703
873
  let json;
704
874
  try {
705
- json = await transcriptOf(info, s);
875
+ json = await transcriptOf(info, s, ctx);
706
876
  } catch (err) {
877
+ ctx?.check();
707
878
  skipped.push({ meeting: info.title, session: s.start, reason: err.message });
708
879
  continue;
709
880
  }
@@ -728,13 +899,13 @@ export const handlers = {
728
899
  },
729
900
 
730
901
  /** Read-only diagnostics for development: a GET on a service, never its headers. */
731
- async probe({ service, path, url }) {
902
+ async probe({ service, path, url }, ctx) {
732
903
  if (url) {
733
- const res = await sharepointGet(url);
904
+ const res = await sharepointGet(url, ctx);
734
905
  return { status: res.status, text: res.text.slice(0, 20_000_000) };
735
906
  }
736
907
  if (!["chatsvc", "csa", "mt", "substrate"].includes(service)) throw new Error("Unknown service");
737
- const res = await api(service, path, { raw: true });
908
+ const res = await api(service, path, { raw: true, ctx });
738
909
  return { status: res.status, text: res.text.slice(0, 20_000_000) };
739
910
  },
740
911
 
@@ -764,20 +935,20 @@ export const handlers = {
764
935
  if (threadId || subject) throw new Error("reply_to and subject are for channels; a new chat takes people and an optional title.");
765
936
  const me = await myMri();
766
937
  if (!me) throw new Error("Teams hasn't shown who is signed in yet. Reload the Teams tab and try again.");
767
- const people = await resolvePeople(to, me);
938
+ const people = await resolvePeople(to, me, ctx);
768
939
  if (title && people.length < 2) throw new Error("A title is only for group chats (two or more people besides you).");
769
- const existing = teams.findExistingChat((await teamsAndChats(true)).chats, people.map((p) => p.mri), me, title);
940
+ const existing = teams.findExistingChat((await teamsAndChats(true, ctx)).chats, people.map((p) => p.mri), me, title);
770
941
  const request = existing
771
942
  ? { conversation: { id: existing.id, kind: existing.kind, title: existing.title }, people, text: String(text), existingChat: true }
772
943
  : { conversation: null, newChat: { members: people.map((p) => p.mri), title: title ?? null }, people, text: String(text) };
773
944
  return askUserToConfirm(request, ctx);
774
945
  }
775
- const conv = await resolve(conversation);
946
+ const conv = await resolve(conversation, ctx);
776
947
  if (threadId && conv.kind !== "channel") throw new Error("reply_to is only for channel posts; in a chat, just send the message.");
777
948
  if (subject && (conv.kind !== "channel" || threadId)) throw new Error("A subject is only for a new channel post.");
778
949
  let replyingTo = null;
779
950
  if (threadId) {
780
- const root = (await fetchMessages(conv.id, { limit: 1, threadId, includeSystem: false })).find((m) => String(m.id) === String(threadId));
951
+ const root = (await fetchMessages(conv.id, { limit: 1, threadId, includeSystem: false, ctx })).find((m) => String(m.id) === String(threadId));
781
952
  replyingTo = root ? teams.parseMessage(root) : { id: threadId };
782
953
  }
783
954
  const request = { conversation: conv, text: String(text), threadId: threadId ?? null, subject: subject ?? null, replyingTo };
@@ -810,7 +981,14 @@ async function createChat({ members, title }) {
810
981
 
811
982
  async function sendMessage({ conversation, newChat, people, text, threadId, subject }) {
812
983
  if (newChat) {
813
- const id = await createChat(newChat);
984
+ let id;
985
+ try {
986
+ id = await createChat(newChat);
987
+ } catch (err) {
988
+ // Without an answer the chat may exist, but the message was never sent.
989
+ if (err.outcomeUnknown) err.chatMayExist = true;
990
+ throw err;
991
+ }
814
992
  const title = newChat.title ?? people.map((p) => p.name).join(", ");
815
993
  const r = await sendMessage({ conversation: { id, title }, text });
816
994
  return { ...r, newChat: true, conversationId: id };
@@ -919,7 +1097,14 @@ function onConfirmMessage(msg, sender, sendResponse) {
919
1097
  p.finish(await sendMessage(p.request), true);
920
1098
  sendResponse({ ok: true });
921
1099
  } catch (err) {
922
- p.finish({ sent: false, reason: `Sending failed: ${err.message}` }, true);
1100
+ // Without an answer the post may have gone through: saying it failed would invite a second one.
1101
+ const cause = /[.!?]$/.test(err.message) ? err.message : `${err.message}.`;
1102
+ const reason = err.chatMayExist
1103
+ ? `Creating the chat did not finish: ${cause} The chat may have been created, without the message: check with list_chats before trying again.`
1104
+ : err.outcomeUnknown
1105
+ ? `Sending did not finish: ${cause} The message may have been posted: check with read_messages before sending again.`
1106
+ : `Sending failed: ${err.message}`;
1107
+ p.finish({ sent: false, reason }, true);
923
1108
  sendResponse({ ok: false, error: err.message });
924
1109
  }
925
1110
  })();
@@ -953,6 +1138,37 @@ const RETRY_MAX_MS = 30_000;
953
1138
  let retryMs = RETRY_MIN_MS;
954
1139
  let retryTimer = null;
955
1140
 
1141
+ /**
1142
+ * The hub sends {type:"ping"} every keep-alive period (its welcome's heartbeatMs; 20 s in every
1143
+ * version). When none has come for about 2 periods (this long without heartbeatMs), the hub is
1144
+ * frozen or hung: drop its connection and connect again (or to whoever took the port over).
1145
+ */
1146
+ export const HEARTBEAT_TIMEOUT_MS = 45_000;
1147
+ /** The watchdog never waits less: a busy machine runs timers late, and that isn't a frozen hub. */
1148
+ const MIN_HEARTBEAT_TIMEOUT_MS = 2_000;
1149
+ let watchdog = null; // { socket, ms, timer } of the current connection
1150
+
1151
+ /** (Re)starts the watchdog of `socket`: no ping within `ms` drops it and connects again. */
1152
+ function watchHeartbeat(socket, ms) {
1153
+ clearTimeout(watchdog?.timer);
1154
+ const timer = setTimeout(() => {
1155
+ if (watchdog?.timer !== timer) return;
1156
+ watchdog = null;
1157
+ if (ws !== socket) return;
1158
+ // Its close may never complete: nobody can be told the outcome of what came on it, now.
1159
+ withdrawAll(socket);
1160
+ reconnectNow();
1161
+ connect();
1162
+ }, ms);
1163
+ watchdog = { socket, ms, timer };
1164
+ }
1165
+
1166
+ function stopWatchdog(socket) {
1167
+ if (watchdog?.socket !== socket) return;
1168
+ clearTimeout(watchdog.timer);
1169
+ watchdog = null;
1170
+ }
1171
+
956
1172
  /** Drops the current connection (even one still closing) and connects again. */
957
1173
  function reconnectNow() {
958
1174
  const old = ws;
@@ -1007,11 +1223,15 @@ async function connect() {
1007
1223
  let challenged = false;
1008
1224
  let authed = false;
1009
1225
  let opened = false;
1226
+ // The welcome's proof is checked asynchronously, and the server may send requests right behind
1227
+ // it: they wait for the check (in the order they came) instead of being dropped.
1228
+ let checking = null;
1010
1229
  socket.onopen = () => (opened = true);
1011
1230
  const refuse = (error) => {
1231
+ socket.close();
1232
+ if (ws !== socket) return; // replaced meanwhile: the newer connection's state stays
1012
1233
  state = "unauthorized";
1013
1234
  lastError = error;
1014
- socket.close();
1015
1235
  };
1016
1236
  socket.onmessage = async (ev) => {
1017
1237
  let msg;
@@ -1021,6 +1241,9 @@ async function connect() {
1021
1241
  return;
1022
1242
  }
1023
1243
  if (!msg || typeof msg !== "object") return;
1244
+ if (checking) await checking;
1245
+ // Closed or replaced meanwhile: nobody could be told the outcome (its requests were withdrawn).
1246
+ if (ws !== socket || socket.readyState !== WebSocket.OPEN) return;
1024
1247
  if (!authed) {
1025
1248
  if (msg.type === "challenge" && !challenged && typeof msg.nonce === "string") {
1026
1249
  challenged = true;
@@ -1028,23 +1251,33 @@ async function connect() {
1028
1251
  if (!secret) return refuse("This copy of the extension has no pairing file. Run `npx mcp-teams setup` and load the extension from the folder it shows.");
1029
1252
  const proof = await teams.pairingProof(secret, "client", msg.nonce);
1030
1253
  socket.send(JSON.stringify({ type: "hello", proof, nonce, version: VERSION, browser: browserName(), instance }));
1031
- } else if (msg.type === "welcome") {
1032
- if (!secret || msg.proof !== (await teams.pairingProof(secret, "server", nonce))) {
1033
- return refuse(`The program on port ${port || DEFAULT_PORT} could not prove it is your mcp-teams server, so the extension doesn't answer it.`);
1034
- }
1035
- authed = true;
1036
- state = "connected";
1037
- lastError = null;
1038
- retryMs = RETRY_MIN_MS;
1254
+ } else if (msg.type === "welcome" && !checking) {
1255
+ const notProven = () => refuse(`The program on port ${port || DEFAULT_PORT} could not prove it is your mcp-teams server, so the extension doesn't answer it.`);
1256
+ checking = (async () => {
1257
+ if (!secret || msg.proof !== (await teams.pairingProof(secret, "server", nonce))) return notProven();
1258
+ if (ws !== socket || socket.readyState !== WebSocket.OPEN) return; // a newer connection's state stays
1259
+ authed = true;
1260
+ state = "connected";
1261
+ lastError = null;
1262
+ retryMs = RETRY_MIN_MS;
1263
+ const heartbeatMs = Number(msg.heartbeatMs);
1264
+ watchHeartbeat(socket, heartbeatMs > 0 ? Math.max(MIN_HEARTBEAT_TIMEOUT_MS, Math.round(2.25 * heartbeatMs)) : HEARTBEAT_TIMEOUT_MS);
1265
+ })().catch(notProven);
1266
+ await checking;
1039
1267
  }
1040
1268
  return;
1041
1269
  }
1270
+ if (msg.type === "ping") {
1271
+ if (watchdog?.socket === socket) watchHeartbeat(socket, watchdog.ms);
1272
+ return;
1273
+ }
1042
1274
  await dispatch(msg, (reply) => socket.readyState === WebSocket.OPEN && socket.send(JSON.stringify(reply)), socket);
1043
1275
  };
1044
1276
  socket.onclose = (ev) => {
1045
1277
  // Nobody can be told the outcome of what came on this connection: withdraw it
1046
1278
  // (an open confirmation window closes; nothing is sent).
1047
1279
  withdrawAll(socket);
1280
+ stopWatchdog(socket);
1048
1281
  if (ws && ws !== socket) return; // replaced by a newer connection (reconnectNow)
1049
1282
  ws = null;
1050
1283
  // When a server session ends, another one takes over the port within a second:
@@ -1072,25 +1305,58 @@ async function connect() {
1072
1305
  /** Requests being run, by id, so they can be withdrawn (a cancel from the server, or their connection closed). */
1073
1306
  const running = new Map();
1074
1307
 
1075
- /** What a handler gets besides its params: whether the request was withdrawn, and onCancel(f) (returns a stop function). */
1076
- function requestContext(conn) {
1308
+ /**
1309
+ * What a handler gets besides its params: whether the request was withdrawn (by the server,
1310
+ * its connection closing, or its deadline passing), onCancel(f) (returns a stop function),
1311
+ * check() (throws once withdrawn) and its absolute `deadline` (null without timeoutMs).
1312
+ */
1313
+ function requestContext(conn, timeoutMs, sentDeadline) {
1077
1314
  const listeners = new Set();
1315
+ let timer = null;
1316
+ // The server's own deadline when it sends one (newer servers): it stopped waiting then, and the
1317
+ // request may have taken a moment to arrive.
1318
+ const deadline = timeoutMs > 0 ? Math.min(Date.now() + timeoutMs, sentDeadline > 0 ? sentDeadline : Infinity) : null;
1319
+ const limitReached = () => `The request's time limit (${secondsOf(timeoutMs)} s) passed before Teams answered.`;
1078
1320
  const ctx = {
1079
1321
  conn,
1080
1322
  cancelled: false,
1081
- cancel() {
1323
+ reason: null,
1324
+ deadline,
1325
+ cancel(reason = "The request was withdrawn.") {
1082
1326
  if (ctx.cancelled) return;
1083
1327
  ctx.cancelled = true;
1328
+ ctx.reason = new Error(reason);
1329
+ clearTimeout(timer);
1084
1330
  for (const f of [...listeners]) f();
1085
1331
  },
1086
1332
  onCancel(f) {
1087
1333
  listeners.add(f);
1088
1334
  return () => listeners.delete(f);
1089
1335
  },
1336
+ check() {
1337
+ // The deadline counts even before its timer fired (e.g. a server deadline that passed on the way).
1338
+ if (!ctx.cancelled && deadline !== null && Date.now() >= deadline) ctx.cancel(limitReached());
1339
+ if (ctx.cancelled) throw ctx.reason;
1340
+ },
1341
+ /** The request ended: its deadline no longer matters. */
1342
+ end() {
1343
+ clearTimeout(timer);
1344
+ },
1090
1345
  };
1346
+ if (deadline) timer = setTimeout(() => ctx.cancel(limitReached()), Math.max(0, deadline - Date.now()));
1091
1347
  return ctx;
1092
1348
  }
1093
1349
 
1350
+ /** `promise`, unless the request (ctx) is withdrawn first: then its reason. The promise itself goes on (others may share it). */
1351
+ function guarded(promise, ctx) {
1352
+ if (!ctx) return promise;
1353
+ if (ctx.cancelled) return Promise.reject(ctx.reason);
1354
+ return new Promise((resolve, reject) => {
1355
+ const stop = ctx.onCancel(() => reject(ctx.reason));
1356
+ promise.then(resolve, reject).finally(stop);
1357
+ });
1358
+ }
1359
+
1094
1360
  /** Withdraws the requests that came on `conn`: their answers can no longer arrive. */
1095
1361
  function withdrawAll(conn) {
1096
1362
  for (const ctx of running.values()) if (ctx.conn === conn) ctx.cancel();
@@ -1108,7 +1374,7 @@ export async function dispatch(msg, send, conn = null) {
1108
1374
  return;
1109
1375
  }
1110
1376
  if (msg.type !== "request") return;
1111
- const ctx = requestContext(conn);
1377
+ const ctx = requestContext(conn, Number(msg.timeoutMs), Number(msg.deadline));
1112
1378
  running.set(msg.id, ctx);
1113
1379
  const handler = Object.hasOwn(handlers, msg.method) ? handlers[msg.method] : null;
1114
1380
  try {
@@ -1118,6 +1384,7 @@ export async function dispatch(msg, send, conn = null) {
1118
1384
  } catch (err) {
1119
1385
  send({ type: "response", id: msg.id, ok: false, error: err?.message ?? String(err) });
1120
1386
  } finally {
1387
+ ctx.end();
1121
1388
  if (running.get(msg.id) === ctx) running.delete(msg.id);
1122
1389
  }
1123
1390
  }