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/extension/manifest.json
CHANGED
|
@@ -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.
|
|
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": {
|
package/extension/teams.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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": {
|