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.
@@ -2,7 +2,7 @@
2
2
  "manifest_version": 3,
3
3
  "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvoz9Wexl4ZqY9YtEyBZsu/Qi5Ft5EjNUNB8b30wgt3AR8TjXI5UumcPQFBIm7ZfvBsjgrh6tBJlygsgFbZ5cB1EiI+WSz/2Wcqwz4eowKMXXPvGuDGrO+2O2zbSpTG19/MwTljI+g0TLfn8Vkoqd8z9MskKk+vCZjjV0Jk8QjVEdHypgM7H8zTuUM06SO12IIZMdeE3IURzf35zbjlfPPkJiDWoo3wB9nxAvLd57xrQhPUB/lGtCJk9mS3SY+/VwOlG5kJ9WS/RCHXcs5Qs7huIlKIatmFz7YiducvTR3DKBbjqbMTyqHzXkmkUmJRqzsYtgtKcz0C0cjFOt6uNdhwIDAQAB",
4
4
  "name": "mcp-teams bridge",
5
- "version": "0.2.12",
5
+ "version": "0.2.14",
6
6
  "description": "Lets AI assistants (via the mcp-teams MCP server) read and search Microsoft Teams chats, channels and meeting transcripts and, with your explicit confirmation, post messages.",
7
7
  "minimum_chrome_version": "116",
8
8
  "icons": {
@@ -78,6 +78,24 @@ export function serviceUrl(path, base) {
78
78
  return url;
79
79
  }
80
80
 
81
+ // ------------------------------------------------------------ throttling
82
+
83
+ const RETRY_AFTER_DEFAULT_MS = 5_000;
84
+ const RETRY_AFTER_MIN_MS = 1_000;
85
+ const RETRY_AFTER_MAX_MS = 60_000;
86
+
87
+ /**
88
+ * How long to leave a service alone after a 429, from its Retry-After header: seconds or an
89
+ * HTTP date. 5 s when it's missing or unreadable, at least 1 s, at most 60 s (a request's own limit).
90
+ */
91
+ export function retryAfterMs(value, now = Date.now()) {
92
+ const v = String(value ?? "").trim();
93
+ let ms = RETRY_AFTER_DEFAULT_MS;
94
+ if (/^\d+(\.\d+)?$/.test(v)) ms = Number(v) * 1000;
95
+ else if (v && !Number.isNaN(Date.parse(v))) ms = Date.parse(v) - now;
96
+ return Math.min(RETRY_AFTER_MAX_MS, Math.max(RETRY_AFTER_MIN_MS, Math.round(ms)));
97
+ }
98
+
81
99
  // ------------------------------------------------------------ read_messages cursor
82
100
 
83
101
  const toBase64Url = (s) => btoa(String.fromCharCode(...new TextEncoder().encode(s))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
@@ -85,13 +103,49 @@ const fromBase64Url = (s) => new TextDecoder().decode(Uint8Array.from(atob(s.rep
85
103
 
86
104
  /**
87
105
  * An opaque read_messages cursor: where to resume (a chatsvc messages link, kept as a path
88
- * from /v1/ so it goes to whatever base the session uses) and, optionally, the time the
89
- * next page must be older than.
106
+ * from /v1/ so it goes to whatever base the session uses) and, optionally, the message the
107
+ * next page must be older than: its time `before` and its `id` (see compareMessageKeys).
108
+ * Cursors without an id (made before ids were kept) mean "older than that time".
90
109
  */
91
- export function messagesCursor(link, before) {
110
+ export function messagesCursor(link, before, id = null) {
92
111
  const s = String(link);
93
112
  const i = s.indexOf("/v1/");
94
- return toBase64Url(JSON.stringify({ p: i === -1 ? s : s.slice(i), b: before ?? null }));
113
+ return toBase64Url(JSON.stringify({ p: i === -1 ? s : s.slice(i), b: before ?? null, ...(id != null ? { i: String(id) } : {}) }));
114
+ }
115
+
116
+ /** Ids compare as numbers when both are digits (chatsvc's are), else as plain strings. */
117
+ export function compareMessageIds(a, b) {
118
+ if (/^\d+$/.test(a) && /^\d+$/.test(b)) {
119
+ const x = BigInt(a);
120
+ const y = BigInt(b);
121
+ return x < y ? -1 : x > y ? 1 : 0;
122
+ }
123
+ return a < b ? -1 : a > b ? 1 : 0;
124
+ }
125
+
126
+ /**
127
+ * When a message was sent, in ms: its time, else its id when that is digits (chatsvc ids
128
+ * are the arrival time in ms), else -Infinity (oldest).
129
+ */
130
+ const msOfKey = ({ time, id }) => {
131
+ const t = time ? Date.parse(time) : NaN;
132
+ if (!Number.isNaN(t)) return t;
133
+ return id != null && /^\d{1,16}$/.test(id) ? Number(id) : -Infinity;
134
+ };
135
+
136
+ /** { time, id } of a raw chatsvc message, as parseMessage reads them. */
137
+ export const messageKey = (m) => ({ time: isoTime(m.originalarrivaltime ?? m.composetime ?? m.composeTime), id: String(m.id ?? m.clientmessageid ?? "") });
138
+
139
+ /**
140
+ * Orders messages by { time, id }: by time, then by id. A key `b` without an id stands for
141
+ * a moment: messages at that time compare equal to it (so "older than b" is strictly older).
142
+ */
143
+ export function compareMessageKeys(a, b) {
144
+ const x = msOfKey(a);
145
+ const y = msOfKey(b);
146
+ if (x !== y) return x < y ? -1 : 1;
147
+ if (a.id == null || b.id == null) return 0;
148
+ return compareMessageIds(a.id, b.id);
95
149
  }
96
150
 
97
151
  /**
@@ -101,14 +155,15 @@ export function messagesCursor(link, before) {
101
155
  */
102
156
  export function readMessagesCursor(cursor, target) {
103
157
  const invalid = () => new Error("Not a valid cursor for this conversation. Pass olderCursor exactly as the previous read_messages call on the same conversation returned it.");
104
- let p, b;
158
+ let p, b, i;
105
159
  try {
106
- ({ p, b } = JSON.parse(fromBase64Url(String(cursor))));
160
+ ({ p, b, i } = JSON.parse(fromBase64Url(String(cursor))));
107
161
  } catch {
108
162
  throw invalid();
109
163
  }
110
- if (typeof p !== "string" || !p.startsWith("/v1/users/ME/conversations/") || /[\\\s]/.test(p)) throw invalid();
164
+ if (typeof p !== "string" || !p.startsWith("/v1/users/ME/conversations/") || /[\\\s#]/.test(p)) throw invalid();
111
165
  if (b != null && (typeof b !== "string" || Number.isNaN(Date.parse(b)))) throw invalid();
166
+ if (i != null && (typeof i !== "string" || i.length > 128)) throw invalid();
112
167
  let url;
113
168
  try {
114
169
  url = new URL(p, "https://cursor.invalid");
@@ -125,7 +180,7 @@ export function readMessagesCursor(cursor, target) {
125
180
  }
126
181
  const ok = url.origin === "https://cursor.invalid" && url.pathname === p.split("?")[0] && segs.length === 7 && segs[6] === "messages" && id === String(target);
127
182
  if (!ok) throw invalid();
128
- return { path: p, before: b ?? null };
183
+ return { path: p, before: b ?? null, id: i ?? null };
129
184
  }
130
185
 
131
186
  // ------------------------------------------------------------ bridge pairing
@@ -364,10 +419,50 @@ export function parseMessage(m) {
364
419
  * events dropped unless asked for. Channel replies are nested under their post.
365
420
  */
366
421
  export function buildConversation(raw, { includeSystem = false, channel = false } = {}) {
367
- const msgs = raw
422
+ return group(ordered(raw, includeSystem), channel);
423
+ }
424
+
425
+ /** Parsed messages, oldest first by compareMessageKeys, each with its channel `root`. */
426
+ const ordered = (raw, includeSystem) =>
427
+ raw
368
428
  .map((m) => ({ ...parseMessage(m), root: threadRootOf(m) }))
369
429
  .filter((m) => includeSystem || !m.system)
370
- .sort((a, b) => (a.time ?? "").localeCompare(b.time ?? "") || a.id.localeCompare(b.id));
430
+ .sort(compareMessageKeys);
431
+
432
+ /**
433
+ * buildConversation's newest `limit` entries (channel: posts, and replies whose post is
434
+ * not among them). Cuts the ordered messages so that every message after the cut is
435
+ * returned and none before it: `oldest` is the { time, id } of the oldest one returned,
436
+ * and a later page takes exactly what is older (compareMessageKeys). In a channel, a reply
437
+ * newer than the cut to a post older than it comes as a reply with `replyTo` on this page,
438
+ * and its post on a later one.
439
+ */
440
+ export function newestEntries(raw, { limit, includeSystem = false, channel = false }) {
441
+ const msgs = ordered(raw, includeSystem);
442
+ let start = Math.max(0, msgs.length - limit);
443
+ if (channel) {
444
+ // Entries the messages from i on make: each post and each reply whose post is older
445
+ // than i. Take the longest run of newest messages that makes at most `limit`.
446
+ const waiting = new Map(); // post id -> replies after it, counted as entries until the post joins
447
+ let entries = 0;
448
+ start = msgs.length;
449
+ for (let i = msgs.length - 1; i >= 0; i--) {
450
+ const m = msgs[i];
451
+ if (m.root && m.root !== m.id) {
452
+ entries++;
453
+ waiting.set(m.root, (waiting.get(m.root) ?? 0) + 1);
454
+ } else {
455
+ entries += 1 - (waiting.get(m.id) ?? 0);
456
+ waiting.delete(m.id);
457
+ }
458
+ if (entries <= limit) start = i;
459
+ }
460
+ }
461
+ const kept = msgs.slice(start);
462
+ return { messages: group(kept, channel), more: start > 0, oldest: kept.length ? { time: kept[0].time, id: kept[0].id } : null };
463
+ }
464
+
465
+ function group(msgs, channel) {
371
466
  if (!channel) return msgs.map(({ root, ...m }) => m);
372
467
  const posts = [];
373
468
  const byId = new Map();
@@ -388,6 +483,25 @@ export function buildConversation(raw, { includeSystem = false, channel = false
388
483
  return posts;
389
484
  }
390
485
 
486
+ /** The first `n` UTF-16 units of `text`, one fewer if the last would be half a surrogate pair (an emoji). */
487
+ export function cutAt(text, n) {
488
+ const code = text.charCodeAt(n - 1);
489
+ return text.slice(0, code >= 0xd800 && code <= 0xdbff ? n - 1 : n);
490
+ }
491
+
492
+ /**
493
+ * Cuts each message's text (replies too) to `maxLength` UTF-16 units plus "…", marking it
494
+ * `truncated`; never through an emoji. The server cuts the same way, for extensions older than this.
495
+ */
496
+ export function capText(messages, maxLength) {
497
+ if (!maxLength) return messages;
498
+ const cut = (m) => {
499
+ const r = m.text.length > maxLength ? { ...m, text: `${cutAt(m.text, maxLength)}…`, truncated: true } : m;
500
+ return r.replies ? { ...r, replies: r.replies.map(cut) } : r;
501
+ };
502
+ return messages.map(cut);
503
+ }
504
+
391
505
  // ------------------------------------------------------------ teams and chats
392
506
 
393
507
  const kindOf = (id, t = {}) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-teams",
3
- "version": "0.2.12",
3
+ "version": "0.2.14",
4
4
  "description": "MCP server that lets AI assistants work with Microsoft Teams (web) through a Chrome/Edge extension bridge",
5
5
  "keywords": ["mcp", "model-context-protocol", "teams", "microsoft-teams", "microsoft-365", "chat", "claude", "chrome-extension"],
6
6
  "repository": {