msteams-mcp 0.2.1

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.

Potentially problematic release.


This version of msteams-mcp might be problematic. Click here for more details.

Files changed (77) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +261 -0
  3. package/dist/__fixtures__/api-responses.d.ts +254 -0
  4. package/dist/__fixtures__/api-responses.js +245 -0
  5. package/dist/api/calendar-api.d.ts +66 -0
  6. package/dist/api/calendar-api.js +179 -0
  7. package/dist/api/chatsvc-api.d.ts +352 -0
  8. package/dist/api/chatsvc-api.js +1100 -0
  9. package/dist/api/csa-api.d.ts +64 -0
  10. package/dist/api/csa-api.js +200 -0
  11. package/dist/api/index.d.ts +7 -0
  12. package/dist/api/index.js +7 -0
  13. package/dist/api/substrate-api.d.ts +50 -0
  14. package/dist/api/substrate-api.js +305 -0
  15. package/dist/auth/crypto.d.ts +32 -0
  16. package/dist/auth/crypto.js +66 -0
  17. package/dist/auth/index.d.ts +7 -0
  18. package/dist/auth/index.js +7 -0
  19. package/dist/auth/session-store.d.ts +87 -0
  20. package/dist/auth/session-store.js +230 -0
  21. package/dist/auth/token-extractor.d.ts +185 -0
  22. package/dist/auth/token-extractor.js +674 -0
  23. package/dist/auth/token-refresh.d.ts +25 -0
  24. package/dist/auth/token-refresh.js +85 -0
  25. package/dist/browser/auth.d.ts +53 -0
  26. package/dist/browser/auth.js +603 -0
  27. package/dist/browser/context.d.ts +40 -0
  28. package/dist/browser/context.js +122 -0
  29. package/dist/constants.d.ts +104 -0
  30. package/dist/constants.js +195 -0
  31. package/dist/index.d.ts +8 -0
  32. package/dist/index.js +12 -0
  33. package/dist/research/auth-research.d.ts +10 -0
  34. package/dist/research/auth-research.js +175 -0
  35. package/dist/research/explore.d.ts +11 -0
  36. package/dist/research/explore.js +270 -0
  37. package/dist/research/search-research.d.ts +17 -0
  38. package/dist/research/search-research.js +317 -0
  39. package/dist/server.d.ts +66 -0
  40. package/dist/server.js +295 -0
  41. package/dist/test/debug-search.d.ts +10 -0
  42. package/dist/test/debug-search.js +147 -0
  43. package/dist/test/mcp-harness.d.ts +17 -0
  44. package/dist/test/mcp-harness.js +474 -0
  45. package/dist/tools/auth-tools.d.ts +26 -0
  46. package/dist/tools/auth-tools.js +191 -0
  47. package/dist/tools/index.d.ts +56 -0
  48. package/dist/tools/index.js +34 -0
  49. package/dist/tools/meeting-tools.d.ts +33 -0
  50. package/dist/tools/meeting-tools.js +64 -0
  51. package/dist/tools/message-tools.d.ts +269 -0
  52. package/dist/tools/message-tools.js +856 -0
  53. package/dist/tools/people-tools.d.ts +46 -0
  54. package/dist/tools/people-tools.js +112 -0
  55. package/dist/tools/registry.d.ts +23 -0
  56. package/dist/tools/registry.js +63 -0
  57. package/dist/tools/search-tools.d.ts +91 -0
  58. package/dist/tools/search-tools.js +222 -0
  59. package/dist/types/errors.d.ts +58 -0
  60. package/dist/types/errors.js +132 -0
  61. package/dist/types/result.d.ts +43 -0
  62. package/dist/types/result.js +51 -0
  63. package/dist/types/server.d.ts +27 -0
  64. package/dist/types/server.js +7 -0
  65. package/dist/types/teams.d.ts +85 -0
  66. package/dist/types/teams.js +4 -0
  67. package/dist/utils/api-config.d.ts +103 -0
  68. package/dist/utils/api-config.js +158 -0
  69. package/dist/utils/auth-guards.d.ts +67 -0
  70. package/dist/utils/auth-guards.js +147 -0
  71. package/dist/utils/http.d.ts +29 -0
  72. package/dist/utils/http.js +112 -0
  73. package/dist/utils/parsers.d.ts +247 -0
  74. package/dist/utils/parsers.js +731 -0
  75. package/dist/utils/parsers.test.d.ts +7 -0
  76. package/dist/utils/parsers.test.js +511 -0
  77. package/package.json +62 -0
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Unit tests for parsing functions.
3
+ *
4
+ * Tests outcomes, not implementations - verify that given inputs
5
+ * produce expected outputs regardless of internal logic.
6
+ */
7
+ export {};
@@ -0,0 +1,511 @@
1
+ /**
2
+ * Unit tests for parsing functions.
3
+ *
4
+ * Tests outcomes, not implementations - verify that given inputs
5
+ * produce expected outputs regardless of internal logic.
6
+ */
7
+ import { describe, it, expect } from 'vitest';
8
+ import { stripHtml, extractLinks, buildMessageLink, getConversationType, extractMessageTimestamp, parsePersonSuggestion, parseV2Result, parseJwtProfile, calculateTokenStatus, parseSearchResults, parsePeopleResults, extractObjectId, buildOneOnOneConversationId, decodeBase64Guid, extractActivityTimestamp, } from './parsers.js';
9
+ import { searchResultItem, searchResultWithHtml, searchResultMinimal, searchResultTooShort, searchResultThreadReply, searchEntitySetsResponse, personSuggestion, personMinimal, personWithBase64Id, peopleGroupsResponse, jwtPayloadFull, jwtPayloadMinimal, jwtPayloadCommaName, jwtPayloadSpaceName, sourceWithMessageId, sourceWithConvIdMessageId, } from '../__fixtures__/api-responses.js';
10
+ describe('stripHtml', () => {
11
+ it('removes HTML tags', () => {
12
+ expect(stripHtml('<p>Hello</p>')).toBe('Hello');
13
+ expect(stripHtml('<div><strong>Bold</strong> text</div>')).toBe('Bold text');
14
+ });
15
+ it('decodes HTML entities', () => {
16
+ expect(stripHtml('Tom &amp; Jerry')).toBe('Tom & Jerry');
17
+ expect(stripHtml('1 &lt; 2 &gt; 0')).toBe('1 < 2 > 0');
18
+ expect(stripHtml('&quot;quoted&quot;')).toBe('"quoted"');
19
+ expect(stripHtml("it&#39;s")).toBe("it's");
20
+ expect(stripHtml('non&nbsp;breaking')).toBe('non breaking');
21
+ });
22
+ it('collapses whitespace', () => {
23
+ expect(stripHtml('hello world')).toBe('hello world');
24
+ expect(stripHtml(' trimmed ')).toBe('trimmed');
25
+ expect(stripHtml('line\n\nbreak')).toBe('line break');
26
+ });
27
+ it('handles complex HTML', () => {
28
+ const html = '<p>Meeting <strong>notes</strong> from &amp; yesterday&apos;s call</p><br/><div>Action items:</div>';
29
+ expect(stripHtml(html)).toBe("Meeting notes from & yesterday's call Action items:");
30
+ });
31
+ it('returns empty string for empty input', () => {
32
+ expect(stripHtml('')).toBe('');
33
+ });
34
+ });
35
+ describe('extractLinks', () => {
36
+ it('extracts simple links', () => {
37
+ const html = 'Check out <a href="https://example.com">this link</a> here';
38
+ expect(extractLinks(html)).toEqual([
39
+ { url: 'https://example.com', text: 'this link' }
40
+ ]);
41
+ });
42
+ it('extracts multiple links', () => {
43
+ const html = '<a href="https://a.com">A</a> and <a href="https://b.com">B</a>';
44
+ expect(extractLinks(html)).toEqual([
45
+ { url: 'https://a.com', text: 'A' },
46
+ { url: 'https://b.com', text: 'B' }
47
+ ]);
48
+ });
49
+ it('strips nested HTML from link text', () => {
50
+ const html = '<a href="https://example.com"><strong>Bold</strong> link</a>';
51
+ expect(extractLinks(html)).toEqual([
52
+ { url: 'https://example.com', text: 'Bold link' }
53
+ ]);
54
+ });
55
+ it('uses URL as text when link text is empty', () => {
56
+ const html = '<a href="https://example.com"></a>';
57
+ expect(extractLinks(html)).toEqual([
58
+ { url: 'https://example.com', text: 'https://example.com' }
59
+ ]);
60
+ });
61
+ it('ignores javascript: links', () => {
62
+ const html = '<a href="javascript:void(0)">Click</a>';
63
+ expect(extractLinks(html)).toEqual([]);
64
+ });
65
+ it('handles links with extra attributes', () => {
66
+ const html = '<a class="link" href="https://example.com" target="_blank">Link</a>';
67
+ expect(extractLinks(html)).toEqual([
68
+ { url: 'https://example.com', text: 'Link' }
69
+ ]);
70
+ });
71
+ it('returns empty array when no links', () => {
72
+ expect(extractLinks('No links here')).toEqual([]);
73
+ expect(extractLinks('')).toEqual([]);
74
+ });
75
+ });
76
+ describe('getConversationType', () => {
77
+ it('identifies channel conversations', () => {
78
+ expect(getConversationType('19:abc@thread.tacv2')).toBe('channel');
79
+ expect(getConversationType('19:QsLXSoyGdLTIChUa-elhfgq_VyIauBGVMBk3-7orc1w1@thread.tacv2')).toBe('channel');
80
+ });
81
+ it('identifies meeting conversations', () => {
82
+ expect(getConversationType('19:meeting_OWVkMDgzYWMtOGQyNi00NjQ0@thread.v2')).toBe('meeting');
83
+ expect(getConversationType('19:meeting_abc123@thread.v2')).toBe('meeting');
84
+ });
85
+ it('identifies 1:1 chat conversations', () => {
86
+ expect(getConversationType('19:ab76f827-27e2-4c67-a765-f1a53145fa24_b71f4d0f-ed13-4f3e-abdf-037e146be579@unq.gbl.spaces')).toBe('chat');
87
+ });
88
+ it('identifies group chat conversations', () => {
89
+ // Group chats use @thread.v2 but don't have meeting_ prefix
90
+ expect(getConversationType('19:abc123@thread.v2')).toBe('chat');
91
+ });
92
+ });
93
+ describe('buildMessageLink', () => {
94
+ it('builds channel link without context parameter', () => {
95
+ const link = buildMessageLink('19:abc@thread.tacv2', '1705760000000');
96
+ expect(link).toBe('https://teams.microsoft.com/l/message/19%3Aabc%40thread.tacv2/1705760000000');
97
+ expect(link).not.toContain('context');
98
+ });
99
+ it('builds chat link with context parameter', () => {
100
+ const link = buildMessageLink('19:guid1_guid2@unq.gbl.spaces', '1705760000000');
101
+ expect(link).toContain('context=%7B%22contextType%22%3A%22chat%22%7D');
102
+ });
103
+ it('builds meeting link with context parameter', () => {
104
+ const link = buildMessageLink('19:meeting_abc@thread.v2', 1705760000000);
105
+ expect(link).toContain('context=%7B%22contextType%22%3A%22chat%22%7D');
106
+ });
107
+ it('builds group chat link with context parameter', () => {
108
+ const link = buildMessageLink('19:abc@thread.v2', 1705760000000);
109
+ expect(link).toContain('context=%7B%22contextType%22%3A%22chat%22%7D');
110
+ });
111
+ it('builds channel thread reply link with parentMessageId', () => {
112
+ // Thread reply: message timestamp differs from parent
113
+ const link = buildMessageLink('19:abc@thread.tacv2', '1705770000000', '1705760000000');
114
+ expect(link).toBe('https://teams.microsoft.com/l/message/19%3Aabc%40thread.tacv2/1705770000000?parentMessageId=1705760000000');
115
+ });
116
+ it('omits parentMessageId for top-level channel posts', () => {
117
+ // Top-level post: message timestamp equals parent (or no parent)
118
+ const link = buildMessageLink('19:abc@thread.tacv2', '1705760000000', '1705760000000');
119
+ expect(link).not.toContain('parentMessageId');
120
+ });
121
+ it('encodes special characters in conversation ID', () => {
122
+ const link = buildMessageLink('19:special@thread.tacv2', '123');
123
+ expect(link).toContain('19%3Aspecial%40thread.tacv2');
124
+ });
125
+ });
126
+ describe('extractMessageTimestamp', () => {
127
+ it('extracts from MessageId field', () => {
128
+ expect(extractMessageTimestamp(sourceWithMessageId)).toBe('1705760000000');
129
+ });
130
+ it('extracts from ClientConversationId suffix', () => {
131
+ expect(extractMessageTimestamp(sourceWithConvIdMessageId)).toBe('1705770000000');
132
+ });
133
+ it('falls back to parsing ISO timestamp', () => {
134
+ const timestamp = extractMessageTimestamp(undefined, '2026-01-20T12:00:00.000Z');
135
+ expect(timestamp).toBe(String(new Date('2026-01-20T12:00:00.000Z').getTime()));
136
+ });
137
+ it('returns undefined for missing data', () => {
138
+ expect(extractMessageTimestamp(undefined)).toBeUndefined();
139
+ expect(extractMessageTimestamp({})).toBeUndefined();
140
+ });
141
+ it('ignores invalid timestamp formats', () => {
142
+ expect(extractMessageTimestamp(undefined, 'not-a-date')).toBeUndefined();
143
+ });
144
+ });
145
+ describe('decodeBase64Guid', () => {
146
+ it('decodes base64-encoded GUID correctly', () => {
147
+ // '93qkaTtFGWpUHjyRafgdhg==' is a real base64-encoded GUID
148
+ const result = decodeBase64Guid('93qkaTtFGWpUHjyRafgdhg==');
149
+ expect(result).toBe('69a47af7-453b-6a19-541e-3c9169f81d86');
150
+ });
151
+ it('returns null for invalid base64', () => {
152
+ expect(decodeBase64Guid('not-valid-base64!')).toBeNull();
153
+ });
154
+ it('returns null for wrong length', () => {
155
+ // Too short (only 8 bytes when decoded)
156
+ expect(decodeBase64Guid('AAAAAAAAAAA=')).toBeNull();
157
+ // Too long (24 bytes when decoded)
158
+ expect(decodeBase64Guid('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==')).toBeNull();
159
+ });
160
+ it('returns lowercase GUID', () => {
161
+ const result = decodeBase64Guid('93qkaTtFGWpUHjyRafgdhg==');
162
+ expect(result).toBe(result?.toLowerCase());
163
+ });
164
+ });
165
+ describe('parsePersonSuggestion', () => {
166
+ it('parses complete person data', () => {
167
+ const result = parsePersonSuggestion(personSuggestion);
168
+ expect(result).not.toBeNull();
169
+ expect(result.id).toBe('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
170
+ expect(result.mri).toBe('8:orgid:a1b2c3d4-e5f6-7890-abcd-ef1234567890');
171
+ expect(result.displayName).toBe('Smith, John');
172
+ expect(result.givenName).toBe('John');
173
+ expect(result.surname).toBe('Smith');
174
+ expect(result.email).toBe('john.smith@company.com');
175
+ expect(result.department).toBe('Engineering');
176
+ expect(result.jobTitle).toBe('Senior Engineer');
177
+ expect(result.companyName).toBe('Acme Corp');
178
+ });
179
+ it('handles minimal person data with GUID format', () => {
180
+ const result = parsePersonSuggestion(personMinimal);
181
+ expect(result).not.toBeNull();
182
+ expect(result.id).toBe('b1c2d3e4-f5a6-7890-bcde-1234567890ab');
183
+ expect(result.mri).toBe('8:orgid:b1c2d3e4-f5a6-7890-bcde-1234567890ab');
184
+ expect(result.displayName).toBe('Jane Doe');
185
+ expect(result.email).toBeUndefined();
186
+ });
187
+ it('decodes base64-encoded IDs', () => {
188
+ const result = parsePersonSuggestion(personWithBase64Id);
189
+ expect(result).not.toBeNull();
190
+ expect(result.id).toBe('69a47af7-453b-6a19-541e-3c9169f81d86');
191
+ expect(result.mri).toBe('8:orgid:69a47af7-453b-6a19-541e-3c9169f81d86');
192
+ expect(result.displayName).toBe('Rob MacDonald');
193
+ expect(result.email).toBe('rob@company.com');
194
+ });
195
+ it('extracts ID from tenant-qualified GUID format', () => {
196
+ const result = parsePersonSuggestion({
197
+ Id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890@tenant.onmicrosoft.com',
198
+ DisplayName: 'Test User',
199
+ });
200
+ expect(result.id).toBe('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
201
+ });
202
+ it('returns null for missing ID', () => {
203
+ expect(parsePersonSuggestion({ DisplayName: 'No ID' })).toBeNull();
204
+ });
205
+ it('returns null for invalid ID format', () => {
206
+ expect(parsePersonSuggestion({ Id: 'invalid-format', DisplayName: 'Test' })).toBeNull();
207
+ });
208
+ });
209
+ describe('parseV2Result', () => {
210
+ it('parses complete search result', () => {
211
+ const result = parseV2Result(searchResultItem);
212
+ expect(result).not.toBeNull();
213
+ expect(result.type).toBe('message');
214
+ expect(result.content).toBe('Let me check the budget report for Q3');
215
+ expect(result.timestamp).toBe('2026-01-20T14:30:00.000Z');
216
+ expect(result.channelName).toBe('General');
217
+ expect(result.teamName).toBe('Finance Team');
218
+ expect(result.conversationId).toBe('19:abcdef123456@thread.tacv2');
219
+ expect(result.messageLink).toContain('teams.microsoft.com/l/message');
220
+ });
221
+ it('strips HTML from content', () => {
222
+ const result = parseV2Result(searchResultWithHtml);
223
+ expect(result).not.toBeNull();
224
+ expect(result.content).toBe("Meeting notes from & yesterday's call Action items:");
225
+ expect(result.content).not.toContain('<');
226
+ expect(result.content).not.toContain('>');
227
+ });
228
+ it('handles minimal result', () => {
229
+ const result = parseV2Result(searchResultMinimal);
230
+ expect(result).not.toBeNull();
231
+ expect(result.id).toBe('minimal-id');
232
+ expect(result.content).toBe('A short message here');
233
+ expect(result.conversationId).toBeUndefined();
234
+ expect(result.messageLink).toBeUndefined();
235
+ });
236
+ it('returns null for content too short', () => {
237
+ expect(parseV2Result(searchResultTooShort)).toBeNull();
238
+ });
239
+ it('extracts conversationId from Extensions', () => {
240
+ const result = parseV2Result(searchResultItem);
241
+ expect(result.conversationId).toBe('19:abcdef123456@thread.tacv2');
242
+ });
243
+ it('falls back to ClientThreadId for conversationId', () => {
244
+ const result = parseV2Result(searchResultWithHtml);
245
+ expect(result.conversationId).toBe('19:meeting123@thread.v2');
246
+ });
247
+ it('generates messageLink with parentMessageId for thread replies', () => {
248
+ const result = parseV2Result(searchResultThreadReply);
249
+ expect(result).not.toBeNull();
250
+ // Parent message ID from ClientConversationId;messageid=xxx
251
+ expect(result.messageLink).toContain('parentMessageId=1768919400000');
252
+ // The message's own timestamp (from DateTimeReceived 2026-01-20T15:00:00.000Z)
253
+ expect(result.messageLink).toContain('/1768921200000');
254
+ });
255
+ it('generates messageLink without parentMessageId for top-level posts', () => {
256
+ const result = parseV2Result(searchResultItem);
257
+ expect(result).not.toBeNull();
258
+ // Top-level post: messageid matches the message timestamp, so no parentMessageId needed
259
+ expect(result.messageLink).not.toContain('parentMessageId');
260
+ });
261
+ it('generates messageLink with context for meeting chats', () => {
262
+ const result = parseV2Result(searchResultWithHtml);
263
+ expect(result).not.toBeNull();
264
+ // Meeting chats need context parameter
265
+ expect(result.messageLink).toContain('context=');
266
+ });
267
+ });
268
+ describe('parseJwtProfile', () => {
269
+ it('parses complete JWT payload', () => {
270
+ const profile = parseJwtProfile(jwtPayloadFull);
271
+ expect(profile).not.toBeNull();
272
+ expect(profile.id).toBe('user-object-id-guid');
273
+ expect(profile.mri).toBe('8:orgid:user-object-id-guid');
274
+ expect(profile.email).toBe('rob.macdonald@company.com');
275
+ expect(profile.displayName).toBe('Macdonald, Rob');
276
+ expect(profile.givenName).toBe('Rob');
277
+ expect(profile.surname).toBe('Macdonald');
278
+ expect(profile.tenantId).toBe('tenant-id-guid');
279
+ });
280
+ it('handles minimal JWT payload', () => {
281
+ const profile = parseJwtProfile(jwtPayloadMinimal);
282
+ expect(profile).not.toBeNull();
283
+ expect(profile.id).toBe('another-user-guid');
284
+ expect(profile.displayName).toBe('Alice Smith');
285
+ expect(profile.email).toBe('');
286
+ // Should parse from "Alice Smith" format
287
+ expect(profile.givenName).toBe('Alice');
288
+ expect(profile.surname).toBe('Smith');
289
+ });
290
+ it('parses "Surname, GivenName" format', () => {
291
+ const profile = parseJwtProfile(jwtPayloadCommaName);
292
+ expect(profile.surname).toBe('Jones');
293
+ expect(profile.givenName).toBe('David');
294
+ });
295
+ it('parses "GivenName Surname" format', () => {
296
+ const profile = parseJwtProfile(jwtPayloadSpaceName);
297
+ expect(profile.givenName).toBe('Sarah');
298
+ expect(profile.surname).toBe('Connor');
299
+ });
300
+ it('returns null for missing required fields', () => {
301
+ expect(parseJwtProfile({})).toBeNull();
302
+ expect(parseJwtProfile({ oid: 'id-only' })).toBeNull();
303
+ expect(parseJwtProfile({ name: 'name-only' })).toBeNull();
304
+ });
305
+ it('prefers upn over other email fields', () => {
306
+ const profile = parseJwtProfile(jwtPayloadFull);
307
+ expect(profile.email).toBe('rob.macdonald@company.com');
308
+ });
309
+ });
310
+ describe('calculateTokenStatus', () => {
311
+ const now = 1705846400000; // Fixed "now" for testing
312
+ it('returns valid for unexpired token', () => {
313
+ const expiry = now + 3600000; // 1 hour from now
314
+ const status = calculateTokenStatus(expiry, now);
315
+ expect(status.isValid).toBe(true);
316
+ expect(status.minutesRemaining).toBe(60);
317
+ });
318
+ it('returns invalid for expired token', () => {
319
+ const expiry = now - 60000; // 1 minute ago
320
+ const status = calculateTokenStatus(expiry, now);
321
+ expect(status.isValid).toBe(false);
322
+ expect(status.minutesRemaining).toBe(0);
323
+ });
324
+ it('returns correct ISO date string', () => {
325
+ const expiry = now + 3600000;
326
+ const status = calculateTokenStatus(expiry, now);
327
+ expect(status.expiresAt).toBe(new Date(expiry).toISOString());
328
+ });
329
+ it('rounds minutes correctly', () => {
330
+ const status = calculateTokenStatus(now + 90000, now); // 1.5 minutes
331
+ expect(status.minutesRemaining).toBe(2); // Rounds up
332
+ });
333
+ });
334
+ describe('parseSearchResults', () => {
335
+ it('parses EntitySets structure', () => {
336
+ const { results, total } = parseSearchResults(searchEntitySetsResponse.EntitySets);
337
+ expect(results).toHaveLength(2);
338
+ expect(total).toBe(4307);
339
+ });
340
+ it('returns empty for undefined input', () => {
341
+ const { results, total } = parseSearchResults(undefined);
342
+ expect(results).toHaveLength(0);
343
+ expect(total).toBeUndefined();
344
+ });
345
+ it('returns empty for non-array input', () => {
346
+ const { results } = parseSearchResults('not an array');
347
+ expect(results).toHaveLength(0);
348
+ });
349
+ it('filters out results with short content', () => {
350
+ const entitySets = [{
351
+ ResultSets: [{
352
+ Results: [
353
+ { Id: '1', HitHighlightedSummary: 'Valid content here' },
354
+ { Id: '2', HitHighlightedSummary: 'Hi' }, // Too short
355
+ ],
356
+ }],
357
+ }];
358
+ const { results } = parseSearchResults(entitySets);
359
+ expect(results).toHaveLength(1);
360
+ });
361
+ });
362
+ describe('parsePeopleResults', () => {
363
+ it('parses Groups/Suggestions structure', () => {
364
+ const results = parsePeopleResults(peopleGroupsResponse.Groups);
365
+ expect(results).toHaveLength(2);
366
+ expect(results[0].displayName).toBe('Smith, John');
367
+ expect(results[1].displayName).toBe('Jane Doe');
368
+ });
369
+ it('returns empty for undefined input', () => {
370
+ expect(parsePeopleResults(undefined)).toHaveLength(0);
371
+ });
372
+ it('returns empty for non-array input', () => {
373
+ expect(parsePeopleResults('not an array')).toHaveLength(0);
374
+ });
375
+ it('handles groups with no suggestions', () => {
376
+ const groups = [{ Suggestions: [] }, { OtherField: 'value' }];
377
+ expect(parsePeopleResults(groups)).toHaveLength(0);
378
+ });
379
+ });
380
+ describe('extractObjectId', () => {
381
+ it('extracts GUID from MRI format', () => {
382
+ expect(extractObjectId('8:orgid:ab76f827-27e2-4c67-a765-f1a53145fa24'))
383
+ .toBe('ab76f827-27e2-4c67-a765-f1a53145fa24');
384
+ });
385
+ it('extracts GUID from Skype ID format (without 8: prefix)', () => {
386
+ expect(extractObjectId('orgid:ab76f827-27e2-4c67-a765-f1a53145fa24'))
387
+ .toBe('ab76f827-27e2-4c67-a765-f1a53145fa24');
388
+ });
389
+ it('extracts GUID from ID with tenant format', () => {
390
+ expect(extractObjectId('5817f485-f870-46eb-bbc4-de216babac62@56b731a8-a2ac-4c32-bf6b-616810e913c6'))
391
+ .toBe('5817f485-f870-46eb-bbc4-de216babac62');
392
+ });
393
+ it('returns raw GUID unchanged', () => {
394
+ expect(extractObjectId('ab76f827-27e2-4c67-a765-f1a53145fa24'))
395
+ .toBe('ab76f827-27e2-4c67-a765-f1a53145fa24');
396
+ });
397
+ it('normalises to lowercase', () => {
398
+ expect(extractObjectId('AB76F827-27E2-4C67-A765-F1A53145FA24'))
399
+ .toBe('ab76f827-27e2-4c67-a765-f1a53145fa24');
400
+ });
401
+ it('decodes base64-encoded GUID', () => {
402
+ expect(extractObjectId('93qkaTtFGWpUHjyRafgdhg=='))
403
+ .toBe('69a47af7-453b-6a19-541e-3c9169f81d86');
404
+ });
405
+ it('decodes base64 GUID from MRI format', () => {
406
+ expect(extractObjectId('8:orgid:93qkaTtFGWpUHjyRafgdhg=='))
407
+ .toBe('69a47af7-453b-6a19-541e-3c9169f81d86');
408
+ });
409
+ it('decodes base64 GUID from Skype ID format', () => {
410
+ expect(extractObjectId('orgid:93qkaTtFGWpUHjyRafgdhg=='))
411
+ .toBe('69a47af7-453b-6a19-541e-3c9169f81d86');
412
+ });
413
+ it('returns null for invalid formats', () => {
414
+ expect(extractObjectId('')).toBeNull();
415
+ expect(extractObjectId('not-a-guid')).toBeNull();
416
+ expect(extractObjectId('8:orgid:invalid')).toBeNull();
417
+ expect(extractObjectId('orgid:invalid')).toBeNull();
418
+ expect(extractObjectId('missing-sections-1234')).toBeNull();
419
+ });
420
+ });
421
+ describe('buildOneOnOneConversationId', () => {
422
+ const userId1 = 'ab76f827-27e2-4c67-a765-f1a53145fa24';
423
+ const userId2 = '5817f485-f870-46eb-bbc4-de216babac62';
424
+ it('builds conversation ID with sorted user IDs', () => {
425
+ // userId2 ('5817...') comes before userId1 ('ab76...') alphabetically
426
+ const result = buildOneOnOneConversationId(userId1, userId2);
427
+ expect(result).toBe('19:5817f485-f870-46eb-bbc4-de216babac62_ab76f827-27e2-4c67-a765-f1a53145fa24@unq.gbl.spaces');
428
+ });
429
+ it('produces same result regardless of argument order', () => {
430
+ const result1 = buildOneOnOneConversationId(userId1, userId2);
431
+ const result2 = buildOneOnOneConversationId(userId2, userId1);
432
+ expect(result1).toBe(result2);
433
+ });
434
+ it('handles MRI format input', () => {
435
+ const mri1 = `8:orgid:${userId1}`;
436
+ const mri2 = `8:orgid:${userId2}`;
437
+ const result = buildOneOnOneConversationId(mri1, mri2);
438
+ expect(result).toBe('19:5817f485-f870-46eb-bbc4-de216babac62_ab76f827-27e2-4c67-a765-f1a53145fa24@unq.gbl.spaces');
439
+ });
440
+ it('handles ID with tenant format', () => {
441
+ const idWithTenant = '5817f485-f870-46eb-bbc4-de216babac62@56b731a8-a2ac-4c32-bf6b-616810e913c6';
442
+ const result = buildOneOnOneConversationId(userId1, idWithTenant);
443
+ expect(result).toBe('19:5817f485-f870-46eb-bbc4-de216babac62_ab76f827-27e2-4c67-a765-f1a53145fa24@unq.gbl.spaces');
444
+ });
445
+ it('handles base64-encoded GUID input', () => {
446
+ // '93qkaTtFGWpUHjyRafgdhg==' decodes to '69a47af7-453b-6a19-541e-3c9169f81d86'
447
+ const base64Id = '93qkaTtFGWpUHjyRafgdhg==';
448
+ const result = buildOneOnOneConversationId(base64Id, userId2);
449
+ // '5817...' < '69a4...' so 5817 comes first
450
+ expect(result).toBe('19:5817f485-f870-46eb-bbc4-de216babac62_69a47af7-453b-6a19-541e-3c9169f81d86@unq.gbl.spaces');
451
+ });
452
+ it('returns null for invalid input', () => {
453
+ expect(buildOneOnOneConversationId('invalid', userId2)).toBeNull();
454
+ expect(buildOneOnOneConversationId(userId1, 'invalid')).toBeNull();
455
+ expect(buildOneOnOneConversationId('', '')).toBeNull();
456
+ });
457
+ });
458
+ describe('extractActivityTimestamp', () => {
459
+ it('prefers originalarrivaltime when present', () => {
460
+ const msg = {
461
+ originalarrivaltime: '2024-01-15T10:30:00.000Z',
462
+ composetime: '2024-01-15T10:29:00.000Z',
463
+ id: '1705315800000',
464
+ };
465
+ expect(extractActivityTimestamp(msg)).toBe('2024-01-15T10:30:00.000Z');
466
+ });
467
+ it('falls back to composetime when originalarrivaltime is missing', () => {
468
+ const msg = {
469
+ composetime: '2024-01-15T10:29:00.000Z',
470
+ id: '1705315800000',
471
+ };
472
+ expect(extractActivityTimestamp(msg)).toBe('2024-01-15T10:29:00.000Z');
473
+ });
474
+ it('parses numeric id as timestamp when no time fields present', () => {
475
+ const msg = {
476
+ id: '1705315800000', // 2024-01-15T10:30:00.000Z
477
+ };
478
+ const result = extractActivityTimestamp(msg);
479
+ expect(result).toBe(new Date(1705315800000).toISOString());
480
+ });
481
+ it('returns null for non-numeric id when no time fields present', () => {
482
+ const msg = {
483
+ id: 'abc-not-a-number',
484
+ };
485
+ expect(extractActivityTimestamp(msg)).toBeNull();
486
+ });
487
+ it('returns null for empty message object', () => {
488
+ expect(extractActivityTimestamp({})).toBeNull();
489
+ });
490
+ it('returns null when id is undefined', () => {
491
+ const msg = {
492
+ originalarrivaltime: undefined,
493
+ composetime: undefined,
494
+ };
495
+ expect(extractActivityTimestamp(msg)).toBeNull();
496
+ });
497
+ it('handles zero id correctly (returns null)', () => {
498
+ const msg = {
499
+ id: '0',
500
+ };
501
+ // Zero is not a valid timestamp
502
+ expect(extractActivityTimestamp(msg)).toBeNull();
503
+ });
504
+ it('handles negative id correctly (returns null)', () => {
505
+ const msg = {
506
+ id: '-1705315800000',
507
+ };
508
+ // Negative timestamps are invalid
509
+ expect(extractActivityTimestamp(msg)).toBeNull();
510
+ });
511
+ });
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "msteams-mcp",
3
+ "version": "0.2.1",
4
+ "description": "MCP server for Microsoft Teams - search messages, send replies, manage favourites",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "msteams-mcp": "dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "README.md"
13
+ ],
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/m0nkmaster/msteams-mcp.git"
17
+ },
18
+ "homepage": "https://github.com/m0nkmaster/msteams-mcp#readme",
19
+ "bugs": {
20
+ "url": "https://github.com/m0nkmaster/msteams-mcp/issues"
21
+ },
22
+ "engines": {
23
+ "node": ">=18"
24
+ },
25
+ "scripts": {
26
+ "build": "tsc",
27
+ "start": "node dist/index.js",
28
+ "dev": "tsx src/index.ts",
29
+ "research": "tsx src/research/explore.ts",
30
+ "research:search": "tsx src/research/search-research.ts",
31
+ "test": "vitest run",
32
+ "test:watch": "vitest",
33
+ "test:coverage": "vitest run --coverage",
34
+ "cli": "tsx src/test/mcp-harness.ts",
35
+ "debug:search": "tsx src/test/debug-search.ts",
36
+ "typecheck": "tsc --noEmit",
37
+ "lint": "eslint src/",
38
+ "lint:fix": "eslint src/ --fix"
39
+ },
40
+ "keywords": [
41
+ "mcp",
42
+ "microsoft-teams",
43
+ "playwright",
44
+ "browser-automation"
45
+ ],
46
+ "author": "Rob MacDonald",
47
+ "license": "MIT",
48
+ "dependencies": {
49
+ "@modelcontextprotocol/sdk": "^1.0.0",
50
+ "playwright": "^1.40.0",
51
+ "zod": "^3.22.0"
52
+ },
53
+ "devDependencies": {
54
+ "@eslint/js": "^9.39.2",
55
+ "@types/node": "^20.10.0",
56
+ "eslint": "^9.39.2",
57
+ "tsx": "^4.7.0",
58
+ "typescript": "^5.3.0",
59
+ "typescript-eslint": "^8.53.1",
60
+ "vitest": "^4.0.18"
61
+ }
62
+ }