@masonator/m365-mcp 0.1.0

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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +143 -0
  3. package/dist/__tests__/auth.test.d.ts +1 -0
  4. package/dist/__tests__/auth.test.js +598 -0
  5. package/dist/__tests__/graph.test.d.ts +1 -0
  6. package/dist/__tests__/graph.test.js +161 -0
  7. package/dist/__tests__/tools/auth-status.test.d.ts +1 -0
  8. package/dist/__tests__/tools/auth-status.test.js +179 -0
  9. package/dist/__tests__/tools/calendar.test.d.ts +1 -0
  10. package/dist/__tests__/tools/calendar.test.js +154 -0
  11. package/dist/__tests__/tools/chat.test.d.ts +1 -0
  12. package/dist/__tests__/tools/chat.test.js +162 -0
  13. package/dist/__tests__/tools/files.test.d.ts +1 -0
  14. package/dist/__tests__/tools/files.test.js +143 -0
  15. package/dist/__tests__/tools/mail.test.d.ts +1 -0
  16. package/dist/__tests__/tools/mail.test.js +169 -0
  17. package/dist/__tests__/tools/profile.test.d.ts +1 -0
  18. package/dist/__tests__/tools/profile.test.js +54 -0
  19. package/dist/__tests__/tools/transcripts.test.d.ts +1 -0
  20. package/dist/__tests__/tools/transcripts.test.js +468 -0
  21. package/dist/index.d.ts +2 -0
  22. package/dist/index.js +90 -0
  23. package/dist/lib/auth.d.ts +75 -0
  24. package/dist/lib/auth.js +333 -0
  25. package/dist/lib/graph.d.ts +20 -0
  26. package/dist/lib/graph.js +53 -0
  27. package/dist/lib/tools/auth-status.d.ts +15 -0
  28. package/dist/lib/tools/auth-status.js +90 -0
  29. package/dist/lib/tools/calendar.d.ts +30 -0
  30. package/dist/lib/tools/calendar.js +117 -0
  31. package/dist/lib/tools/chat.d.ts +25 -0
  32. package/dist/lib/tools/chat.js +79 -0
  33. package/dist/lib/tools/files.d.ts +34 -0
  34. package/dist/lib/tools/files.js +71 -0
  35. package/dist/lib/tools/mail.d.ts +25 -0
  36. package/dist/lib/tools/mail.js +58 -0
  37. package/dist/lib/tools/profile.d.ts +13 -0
  38. package/dist/lib/tools/profile.js +27 -0
  39. package/dist/lib/tools/transcripts.d.ts +51 -0
  40. package/dist/lib/tools/transcripts.js +244 -0
  41. package/dist/types/tokens.d.ts +11 -0
  42. package/dist/types/tokens.js +1 -0
  43. package/package.json +78 -0
@@ -0,0 +1,468 @@
1
+ import { jest } from '@jest/globals';
2
+ // ── Mocks ──────────────────────────────────────────────────────────────────
3
+ const mockGraphFetch = jest.fn();
4
+ jest.unstable_mockModule('../../lib/graph.js', () => ({
5
+ graphFetch: mockGraphFetch,
6
+ }));
7
+ // Mock global fetch for VTT content downloads
8
+ const mockFetch = jest.fn();
9
+ globalThis.fetch = mockFetch;
10
+ // Dynamic import AFTER mocks are registered
11
+ const { executeTranscripts, extractMeetingId, parseTranscriptId } = await import('../../lib/tools/transcripts.js');
12
+ // ── Helpers ────────────────────────────────────────────────────────────────
13
+ function makeJoinUrl(threadId, oid, tid = 'tenant-123') {
14
+ const context = JSON.stringify({ Tid: tid, Oid: oid });
15
+ return `https://teams.microsoft.com/l/meetup-join/${encodeURIComponent(threadId)}/0?context=${encodeURIComponent(context)}`;
16
+ }
17
+ function mockResponse(body, status = 200) {
18
+ return {
19
+ ok: status >= 200 && status < 300,
20
+ status,
21
+ text: () => Promise.resolve(body),
22
+ headers: new Headers(),
23
+ redirected: false,
24
+ statusText: status === 200 ? 'OK' : 'Error',
25
+ type: 'basic',
26
+ url: '',
27
+ clone: () => mockResponse(body, status),
28
+ body: null,
29
+ bodyUsed: false,
30
+ arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
31
+ blob: () => Promise.resolve(new Blob()),
32
+ formData: () => Promise.resolve(new FormData()),
33
+ json: () => Promise.resolve(JSON.parse(body)),
34
+ bytes: () => Promise.resolve(new Uint8Array()),
35
+ };
36
+ }
37
+ // ── extractMeetingId ───────────────────────────────────────────────────────
38
+ describe('extractMeetingId', () => {
39
+ it('extracts valid meeting ID from standard Teams join URL', () => {
40
+ const threadId = '19:meeting_abc123@thread.v2';
41
+ const oid = 'user-oid-456';
42
+ const url = makeJoinUrl(threadId, oid);
43
+ const result = extractMeetingId(url);
44
+ const expected = Buffer.from(`1*${oid}*0**${threadId}`).toString('base64');
45
+ expect(result).toBe(expected);
46
+ });
47
+ it('returns null for URL without meetup-join path', () => {
48
+ const result = extractMeetingId('https://teams.microsoft.com/l/channel/foo/bar');
49
+ expect(result).toBeNull();
50
+ });
51
+ it('returns null for URL missing context param', () => {
52
+ const result = extractMeetingId('https://teams.microsoft.com/l/meetup-join/19:abc@thread.v2/0');
53
+ expect(result).toBeNull();
54
+ });
55
+ it('returns null for URL with context missing Oid', () => {
56
+ const context = JSON.stringify({ Tid: 'tenant-123' });
57
+ const url = `https://teams.microsoft.com/l/meetup-join/19:abc@thread.v2/0?context=${encodeURIComponent(context)}`;
58
+ const result = extractMeetingId(url);
59
+ expect(result).toBeNull();
60
+ });
61
+ it('returns null for completely invalid URLs', () => {
62
+ expect(extractMeetingId('not-a-url')).toBeNull();
63
+ expect(extractMeetingId('')).toBeNull();
64
+ });
65
+ it('returns null when meetup-join has no thread segment after it', () => {
66
+ const context = JSON.stringify({ Tid: 'tenant-123', Oid: 'oid-456' });
67
+ const url = `https://teams.microsoft.com/l/meetup-join?context=${encodeURIComponent(context)}`;
68
+ const result = extractMeetingId(url);
69
+ expect(result).toBeNull();
70
+ });
71
+ it('handles URL-encoded thread IDs', () => {
72
+ const threadId = '19:meeting_special+chars@thread.v2';
73
+ const oid = 'oid-789';
74
+ const url = makeJoinUrl(threadId, oid);
75
+ const result = extractMeetingId(url);
76
+ const expected = Buffer.from(`1*${oid}*0**${threadId}`).toString('base64');
77
+ expect(result).toBe(expected);
78
+ });
79
+ });
80
+ // ── parseTranscriptId ──────────────────────────────────────────────────────
81
+ describe('parseTranscriptId', () => {
82
+ it('parses valid compound ID', () => {
83
+ const result = parseTranscriptId('meetingABC/transcript123');
84
+ expect(result).toEqual({
85
+ meetingId: 'meetingABC',
86
+ transcriptId: 'transcript123',
87
+ });
88
+ });
89
+ it('handles transcript IDs containing slashes after the first', () => {
90
+ const result = parseTranscriptId('meetingABC/transcript/with/slashes');
91
+ expect(result).toEqual({
92
+ meetingId: 'meetingABC',
93
+ transcriptId: 'transcript/with/slashes',
94
+ });
95
+ });
96
+ it('returns null for input without slash', () => {
97
+ expect(parseTranscriptId('noslashhere')).toBeNull();
98
+ });
99
+ it('returns null for empty string', () => {
100
+ expect(parseTranscriptId('')).toBeNull();
101
+ });
102
+ it('returns null when slash is at start', () => {
103
+ expect(parseTranscriptId('/transcriptOnly')).toBeNull();
104
+ });
105
+ it('returns null when slash is at end', () => {
106
+ expect(parseTranscriptId('meetingOnly/')).toBeNull();
107
+ });
108
+ it('handles base64 meeting IDs with = padding', () => {
109
+ const result = parseTranscriptId('MSo1Njc4OTAx==/transcript-id');
110
+ expect(result).toEqual({
111
+ meetingId: 'MSo1Njc4OTAx==',
112
+ transcriptId: 'transcript-id',
113
+ });
114
+ });
115
+ });
116
+ // ── executeTranscripts ─────────────────────────────────────────────────────
117
+ describe('executeTranscripts', () => {
118
+ afterEach(() => {
119
+ mockGraphFetch.mockReset();
120
+ mockFetch.mockReset();
121
+ });
122
+ // ── Drill-down mode ──────────────────────────────────────────────────
123
+ describe('drill-down mode', () => {
124
+ it('returns full transcript for valid compound ID', async () => {
125
+ const vttContent = 'WEBVTT\n\n00:00:00.000 --> 00:00:05.000\nHello world';
126
+ // VTT content fetch (raw fetch, v1.0 succeeds)
127
+ mockFetch.mockResolvedValueOnce(mockResponse(vttContent));
128
+ // Meeting subject fetch
129
+ mockGraphFetch.mockResolvedValueOnce({
130
+ ok: true,
131
+ data: { subject: 'Team Standup' },
132
+ });
133
+ const result = await executeTranscripts('test-token', {
134
+ transcript_id: 'meetingId123/transcriptId456',
135
+ });
136
+ expect(result).toContain('# Transcript: Team Standup');
137
+ expect(result).toContain(vttContent);
138
+ });
139
+ it('falls back to beta when v1.0 returns 403', async () => {
140
+ const vttContent = 'WEBVTT\n\nFallback content';
141
+ // v1.0 returns 403
142
+ mockFetch.mockResolvedValueOnce(mockResponse('Forbidden', 403));
143
+ // beta returns successfully
144
+ mockFetch.mockResolvedValueOnce(mockResponse(vttContent));
145
+ // Meeting subject
146
+ mockGraphFetch.mockResolvedValueOnce({
147
+ ok: true,
148
+ data: { subject: 'Beta Meeting' },
149
+ });
150
+ const result = await executeTranscripts('test-token', {
151
+ transcript_id: 'meetingId/transcriptId',
152
+ });
153
+ expect(result).toContain('# Transcript: Beta Meeting');
154
+ expect(result).toContain(vttContent);
155
+ expect(mockFetch).toHaveBeenCalledTimes(2);
156
+ expect(mockFetch).toHaveBeenNthCalledWith(1, expect.stringContaining('graph.microsoft.com/v1.0'), expect.any(Object));
157
+ expect(mockFetch).toHaveBeenNthCalledWith(2, expect.stringContaining('graph.microsoft.com/beta'), expect.any(Object));
158
+ });
159
+ it('falls back to beta when v1.0 returns 400', async () => {
160
+ const vttContent = 'WEBVTT\n\nBeta fallback';
161
+ mockFetch.mockResolvedValueOnce(mockResponse('Bad Request', 400));
162
+ mockFetch.mockResolvedValueOnce(mockResponse(vttContent));
163
+ mockGraphFetch.mockResolvedValueOnce({
164
+ ok: true,
165
+ data: { subject: 'Meeting 400' },
166
+ });
167
+ const result = await executeTranscripts('test-token', {
168
+ transcript_id: 'meeting/transcript',
169
+ });
170
+ expect(result).toContain(vttContent);
171
+ });
172
+ it('returns error when both v1.0 and beta fail', async () => {
173
+ mockFetch.mockResolvedValueOnce(mockResponse('Forbidden', 403));
174
+ mockFetch.mockResolvedValueOnce(mockResponse('Forbidden', 403));
175
+ const result = await executeTranscripts('test-token', {
176
+ transcript_id: 'meeting/transcript',
177
+ });
178
+ expect(result).toContain('Error: Could not fetch transcript content');
179
+ });
180
+ it('returns error for invalid compound ID format', async () => {
181
+ const result = await executeTranscripts('test-token', {
182
+ transcript_id: 'no-slash-here',
183
+ });
184
+ expect(result).toContain('Error: Invalid transcript_id format');
185
+ });
186
+ it('uses (Unknown meeting) when subject fetch fails', async () => {
187
+ const vttContent = 'WEBVTT\n\nContent here';
188
+ mockFetch.mockResolvedValueOnce(mockResponse(vttContent));
189
+ mockGraphFetch.mockResolvedValueOnce({
190
+ ok: false,
191
+ error: { status: 404, message: 'Not found' },
192
+ });
193
+ const result = await executeTranscripts('test-token', {
194
+ transcript_id: 'meeting/transcript',
195
+ });
196
+ expect(result).toContain('# Transcript: (Unknown meeting)');
197
+ expect(result).toContain(vttContent);
198
+ });
199
+ });
200
+ // ── List mode ────────────────────────────────────────────────────────
201
+ describe('list mode', () => {
202
+ it('returns previews with transcript IDs', async () => {
203
+ const threadId = '19:meeting_abc@thread.v2';
204
+ const oid = 'organizer-oid';
205
+ const joinUrl = makeJoinUrl(threadId, oid);
206
+ const meetingId = Buffer.from(`1*${oid}*0**${threadId}`).toString('base64');
207
+ // Calendar view
208
+ mockGraphFetch.mockResolvedValueOnce({
209
+ ok: true,
210
+ data: {
211
+ value: [
212
+ {
213
+ subject: 'Sprint Planning',
214
+ start: { dateTime: '2025-06-15T10:00:00' },
215
+ end: { dateTime: '2025-06-15T11:00:00' },
216
+ attendees: [{ emailAddress: { name: 'Alice' } }, { emailAddress: { name: 'Bob' } }],
217
+ organizer: { emailAddress: { name: 'Alice' } },
218
+ onlineMeeting: { joinUrl },
219
+ },
220
+ ],
221
+ },
222
+ });
223
+ // Transcripts list (v1.0)
224
+ mockGraphFetch.mockResolvedValueOnce({
225
+ ok: true,
226
+ data: { value: [{ id: 'transcript-001' }] },
227
+ });
228
+ // VTT content
229
+ const vttContent = 'WEBVTT\n\n00:00:00.000 --> 00:00:05.000\nHello from sprint';
230
+ mockFetch.mockResolvedValueOnce(mockResponse(vttContent));
231
+ const result = await executeTranscripts('test-token', { date: '2025-06-15' });
232
+ expect(result).toContain('Found 1 meetings, 1 with transcripts.');
233
+ expect(result).toContain('## Sprint Planning');
234
+ expect(result).toContain('Date: 2025-06-15T10:00:00');
235
+ expect(result).toContain('Attendees: Alice, Bob');
236
+ expect(result).toContain(`Transcript ID: ${meetingId}/transcript-001`);
237
+ expect(result).toContain(vttContent);
238
+ });
239
+ it('truncates VTT preview to ~3000 chars', async () => {
240
+ const threadId = '19:meeting_long@thread.v2';
241
+ const oid = 'oid-long';
242
+ const joinUrl = makeJoinUrl(threadId, oid);
243
+ // Calendar view
244
+ mockGraphFetch.mockResolvedValueOnce({
245
+ ok: true,
246
+ data: {
247
+ value: [
248
+ {
249
+ subject: 'Long Meeting',
250
+ start: { dateTime: '2025-06-15T10:00:00' },
251
+ onlineMeeting: { joinUrl },
252
+ },
253
+ ],
254
+ },
255
+ });
256
+ // Transcripts list
257
+ mockGraphFetch.mockResolvedValueOnce({
258
+ ok: true,
259
+ data: { value: [{ id: 'tx-long' }] },
260
+ });
261
+ // Long VTT content
262
+ const longVtt = 'WEBVTT\n\n' + 'A'.repeat(4000);
263
+ mockFetch.mockResolvedValueOnce(mockResponse(longVtt));
264
+ const result = await executeTranscripts('test-token', { date: '2025-06-15' });
265
+ expect(result).toContain('... [truncated — use transcript_id for full content]');
266
+ // The preview should not contain the full 4000-char string
267
+ expect(result).not.toContain('A'.repeat(4000));
268
+ });
269
+ it('handles empty calendar (no meetings)', async () => {
270
+ mockGraphFetch.mockResolvedValueOnce({
271
+ ok: true,
272
+ data: { value: [] },
273
+ });
274
+ const result = await executeTranscripts('test-token', { date: '2025-06-15' });
275
+ expect(result).toBe('No Teams meetings found in the given date range.');
276
+ });
277
+ it('handles meetings with no transcripts', async () => {
278
+ const threadId = '19:meeting_notx@thread.v2';
279
+ const oid = 'oid-notx';
280
+ const joinUrl = makeJoinUrl(threadId, oid);
281
+ // Calendar view
282
+ mockGraphFetch.mockResolvedValueOnce({
283
+ ok: true,
284
+ data: {
285
+ value: [
286
+ {
287
+ subject: 'No Recording',
288
+ start: { dateTime: '2025-06-15T14:00:00' },
289
+ onlineMeeting: { joinUrl },
290
+ },
291
+ ],
292
+ },
293
+ });
294
+ // Transcripts list returns empty
295
+ mockGraphFetch.mockResolvedValueOnce({
296
+ ok: true,
297
+ data: { value: [] },
298
+ });
299
+ const result = await executeTranscripts('test-token', { date: '2025-06-15' });
300
+ expect(result).toContain('none have transcripts recorded');
301
+ expect(result).toContain('No Recording');
302
+ });
303
+ it('handles Graph API error in calendar fetch', async () => {
304
+ mockGraphFetch.mockResolvedValueOnce({
305
+ ok: false,
306
+ error: {
307
+ status: 403,
308
+ message: 'Insufficient permissions. Check granted scopes with ms_auth_status.',
309
+ },
310
+ });
311
+ const result = await executeTranscripts('test-token', { date: '2025-06-15' });
312
+ expect(result).toBe('Error: Insufficient permissions. Check granted scopes with ms_auth_status.');
313
+ });
314
+ it('uses start/end params directly when provided', async () => {
315
+ mockGraphFetch.mockResolvedValueOnce({
316
+ ok: true,
317
+ data: { value: [] },
318
+ });
319
+ await executeTranscripts('test-token', {
320
+ start: '2025-06-15T09:00:00Z',
321
+ end: '2025-06-15T17:00:00Z',
322
+ });
323
+ expect(mockGraphFetch).toHaveBeenCalledWith(expect.stringContaining('startDateTime=2025-06-15T09:00:00Z'), 'test-token', { timezone: true });
324
+ expect(mockGraphFetch).toHaveBeenCalledWith(expect.stringContaining('endDateTime=2025-06-15T17:00:00Z'), 'test-token', { timezone: true });
325
+ });
326
+ it('defaults to today when no date params given', async () => {
327
+ mockGraphFetch.mockResolvedValueOnce({
328
+ ok: true,
329
+ data: { value: [] },
330
+ });
331
+ const now = new Date();
332
+ const year = now.getFullYear();
333
+ const month = String(now.getMonth() + 1).padStart(2, '0');
334
+ const day = String(now.getDate()).padStart(2, '0');
335
+ const todayStr = `${year}-${month}-${day}`;
336
+ await executeTranscripts('test-token', {});
337
+ expect(mockGraphFetch).toHaveBeenCalledWith(expect.stringContaining(`startDateTime=${todayStr}`), 'test-token', { timezone: true });
338
+ });
339
+ it('returns null VTT gracefully when v1.0 returns non-retryable status (e.g. 500)', async () => {
340
+ const threadId = '19:meeting_500@thread.v2';
341
+ const oid = 'oid-500';
342
+ const joinUrl = makeJoinUrl(threadId, oid);
343
+ // Calendar view
344
+ mockGraphFetch.mockResolvedValueOnce({
345
+ ok: true,
346
+ data: {
347
+ value: [
348
+ {
349
+ subject: 'Server Error Meeting',
350
+ start: { dateTime: '2025-06-15T10:00:00' },
351
+ onlineMeeting: { joinUrl },
352
+ },
353
+ ],
354
+ },
355
+ });
356
+ // Transcripts list succeeds
357
+ mockGraphFetch.mockResolvedValueOnce({
358
+ ok: true,
359
+ data: { value: [{ id: 'tx-500' }] },
360
+ });
361
+ // v1.0 VTT fetch returns 500 (non-retryable)
362
+ mockFetch.mockResolvedValueOnce(mockResponse('Internal Server Error', 500));
363
+ const result = await executeTranscripts('test-token', { date: '2025-06-15' });
364
+ // Meeting has transcripts but VTT download failed — still shows meeting without preview
365
+ expect(result).toContain('1 with transcripts');
366
+ expect(result).toContain('Server Error Meeting');
367
+ expect(mockFetch).toHaveBeenCalledTimes(1); // No beta retry for 500
368
+ });
369
+ it('skips meeting when both v1.0 and beta transcript listing fail with non-retryable error', async () => {
370
+ const threadId = '19:meeting_fail@thread.v2';
371
+ const oid = 'oid-fail';
372
+ const joinUrl = makeJoinUrl(threadId, oid);
373
+ // Calendar view
374
+ mockGraphFetch.mockResolvedValueOnce({
375
+ ok: true,
376
+ data: {
377
+ value: [
378
+ {
379
+ subject: 'Failed Transcript List',
380
+ start: { dateTime: '2025-06-15T10:00:00' },
381
+ onlineMeeting: { joinUrl },
382
+ },
383
+ ],
384
+ },
385
+ });
386
+ // v1.0 transcript list fails with 500 (non-retryable)
387
+ mockGraphFetch.mockResolvedValueOnce({
388
+ ok: false,
389
+ error: { status: 500, message: 'Server error' },
390
+ });
391
+ const result = await executeTranscripts('test-token', { date: '2025-06-15' });
392
+ // Meeting found but transcript listing failed — shows as "none have transcripts"
393
+ expect(result).toContain('none have transcripts');
394
+ });
395
+ it('returns error for invalid date format', async () => {
396
+ const result = await executeTranscripts('test-token', { date: 'not-a-date' });
397
+ expect(result).toBe('Error: Invalid date format. Expected YYYY-MM-DD.');
398
+ });
399
+ it('falls back to beta for transcript listing on 403', async () => {
400
+ const threadId = '19:meeting_beta@thread.v2';
401
+ const oid = 'oid-beta';
402
+ const joinUrl = makeJoinUrl(threadId, oid);
403
+ // Calendar view
404
+ mockGraphFetch.mockResolvedValueOnce({
405
+ ok: true,
406
+ data: {
407
+ value: [
408
+ {
409
+ subject: 'Beta Transcripts',
410
+ start: { dateTime: '2025-06-15T10:00:00' },
411
+ onlineMeeting: { joinUrl },
412
+ },
413
+ ],
414
+ },
415
+ });
416
+ // v1.0 transcripts list fails with 403
417
+ mockGraphFetch.mockResolvedValueOnce({
418
+ ok: false,
419
+ error: { status: 403, message: 'Forbidden' },
420
+ });
421
+ // beta transcripts list succeeds
422
+ mockGraphFetch.mockResolvedValueOnce({
423
+ ok: true,
424
+ data: { value: [{ id: 'tx-beta' }] },
425
+ });
426
+ // VTT content
427
+ mockFetch.mockResolvedValueOnce(mockResponse('WEBVTT\n\nBeta content'));
428
+ const result = await executeTranscripts('test-token', { date: '2025-06-15' });
429
+ expect(result).toContain('1 with transcripts');
430
+ expect(result).toContain('Beta Transcripts');
431
+ });
432
+ it('skips events without onlineMeeting joinUrl', async () => {
433
+ const threadId = '19:meeting_ok@thread.v2';
434
+ const oid = 'oid-ok';
435
+ const joinUrl = makeJoinUrl(threadId, oid);
436
+ // Calendar view with mix of online and non-online meetings
437
+ mockGraphFetch.mockResolvedValueOnce({
438
+ ok: true,
439
+ data: {
440
+ value: [
441
+ {
442
+ subject: 'In-Person Meeting',
443
+ start: { dateTime: '2025-06-15T09:00:00' },
444
+ onlineMeeting: null,
445
+ },
446
+ {
447
+ subject: 'Online Meeting',
448
+ start: { dateTime: '2025-06-15T10:00:00' },
449
+ onlineMeeting: { joinUrl },
450
+ },
451
+ ],
452
+ },
453
+ });
454
+ // Transcripts list for the online meeting
455
+ mockGraphFetch.mockResolvedValueOnce({
456
+ ok: true,
457
+ data: { value: [{ id: 'tx-1' }] },
458
+ });
459
+ // VTT content
460
+ mockFetch.mockResolvedValueOnce(mockResponse('WEBVTT\n\nOnline content'));
461
+ const result = await executeTranscripts('test-token', { date: '2025-06-15' });
462
+ // Only 1 meeting (with joinUrl) counted, 1 with transcripts
463
+ expect(result).toContain('Found 1 meetings, 1 with transcripts.');
464
+ expect(result).toContain('Online Meeting');
465
+ expect(result).not.toContain('In-Person Meeting');
466
+ });
467
+ });
468
+ });
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
5
+ import { loadAuthConfig, getAccessToken } from './lib/auth.js';
6
+ import { authStatusToolDefinition, executeAuthStatus } from './lib/tools/auth-status.js';
7
+ import { profileToolDefinition, executeProfile } from './lib/tools/profile.js';
8
+ import { calendarToolDefinition, executeCalendar } from './lib/tools/calendar.js';
9
+ import { mailToolDefinition, executeMail } from './lib/tools/mail.js';
10
+ import { chatToolDefinition, executeChat } from './lib/tools/chat.js';
11
+ import { filesToolDefinition, executeFiles } from './lib/tools/files.js';
12
+ import { transcriptsToolDefinition, executeTranscripts } from './lib/tools/transcripts.js';
13
+ // Validate env vars at startup
14
+ try {
15
+ loadAuthConfig();
16
+ }
17
+ catch (error) {
18
+ process.stderr.write(`Configuration error: ${error instanceof Error ? error.message : String(error)}\n`);
19
+ process.stderr.write('\nRequired environment variables:\n');
20
+ process.stderr.write(' MS365_MCP_CLIENT_ID - Azure AD application (client) ID\n');
21
+ process.stderr.write(' MS365_MCP_CLIENT_SECRET - Azure AD client secret\n');
22
+ process.stderr.write(' MS365_MCP_TENANT_ID - Azure AD tenant ID\n');
23
+ process.exit(1);
24
+ }
25
+ const server = new Server({ name: 'm365-mcp', version: '0.1.0' }, { capabilities: { tools: {} } });
26
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
27
+ tools: [
28
+ authStatusToolDefinition,
29
+ profileToolDefinition,
30
+ calendarToolDefinition,
31
+ mailToolDefinition,
32
+ chatToolDefinition,
33
+ filesToolDefinition,
34
+ transcriptsToolDefinition,
35
+ ],
36
+ }));
37
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
38
+ const { name, arguments: args = {} } = request.params;
39
+ try {
40
+ const config = loadAuthConfig();
41
+ // auth_status handles its own auth
42
+ if (name === 'ms_auth_status') {
43
+ const result = await executeAuthStatus(config);
44
+ return { content: [{ type: 'text', text: result }] };
45
+ }
46
+ // All other tools need a valid token
47
+ const token = await getAccessToken(config);
48
+ let result;
49
+ switch (name) {
50
+ case 'ms_profile':
51
+ result = await executeProfile(token);
52
+ break;
53
+ case 'ms_calendar':
54
+ result = await executeCalendar(token, args);
55
+ break;
56
+ case 'ms_mail':
57
+ result = await executeMail(token, args);
58
+ break;
59
+ case 'ms_chat':
60
+ result = await executeChat(token, args);
61
+ break;
62
+ case 'ms_files':
63
+ result = await executeFiles(token, args);
64
+ break;
65
+ case 'ms_transcripts':
66
+ result = await executeTranscripts(token, args);
67
+ break;
68
+ default:
69
+ return {
70
+ content: [{ type: 'text', text: `Unknown tool: ${name}` }],
71
+ isError: true,
72
+ };
73
+ }
74
+ return { content: [{ type: 'text', text: result }] };
75
+ }
76
+ catch (error) {
77
+ const message = error instanceof Error ? error.message : String(error);
78
+ return {
79
+ content: [
80
+ {
81
+ type: 'text',
82
+ text: `Error: ${message}\n\nTip: Use ms_auth_status to check or fix your connection.`,
83
+ },
84
+ ],
85
+ isError: true,
86
+ };
87
+ }
88
+ });
89
+ const transport = new StdioServerTransport();
90
+ await server.connect(transport);
@@ -0,0 +1,75 @@
1
+ import { type Server } from 'node:http';
2
+ import type { TokenData, AuthConfig } from '../types/tokens.js';
3
+ export declare const SCOPES: string[];
4
+ /**
5
+ * Returns the config directory for m365-mcp.
6
+ * Respects XDG_CONFIG_HOME, falls back to ~/.config/m365-mcp.
7
+ * Creates the directory (recursively) if it doesn't exist.
8
+ */
9
+ export declare function getConfigDir(): string;
10
+ /**
11
+ * Loads tokens from tokens.json in the given config directory.
12
+ * Returns null if the file doesn't exist or contains invalid JSON.
13
+ */
14
+ export declare function loadTokens(configDir?: string): TokenData | null;
15
+ /**
16
+ * Saves tokens to tokens.json in the given config directory.
17
+ * Sets file permissions to 0o600 (user read/write only).
18
+ */
19
+ export declare function saveTokens(tokens: TokenData, configDir?: string): void;
20
+ /**
21
+ * Deletes tokens.json from the given config directory.
22
+ * Does nothing if the file doesn't exist.
23
+ */
24
+ export declare function deleteTokens(configDir?: string): void;
25
+ /**
26
+ * Returns true if the token expires within 2 minutes (120 000 ms safety buffer).
27
+ */
28
+ export declare function isTokenExpired(tokens: TokenData): boolean;
29
+ /**
30
+ * Loads auth configuration from environment variables.
31
+ * Throws with a clear message if any required variable is missing.
32
+ */
33
+ export declare function loadAuthConfig(): AuthConfig;
34
+ /**
35
+ * Finds an available port by binding to port 0 and reading the assigned port.
36
+ * @internal Exported for testing only.
37
+ */
38
+ export declare function findAvailablePort(): Promise<number>;
39
+ /**
40
+ * Opens a URL in the default browser.
41
+ * Falls back to printing the URL to stderr if the browser cannot be opened.
42
+ * @internal Exported for testing only.
43
+ */
44
+ export declare function openBrowser(url: string): void;
45
+ /**
46
+ * Exchanges an authorization code for tokens via Azure AD token endpoint.
47
+ * @internal Exported for testing only.
48
+ */
49
+ export declare function exchangeCodeForTokens(config: AuthConfig, code: string, redirectUri: string): Promise<TokenData>;
50
+ /**
51
+ * Starts an HTTP server on the given port and waits for the OAuth callback.
52
+ * Returns the authorization code from the callback query string.
53
+ * @internal Exported for testing only.
54
+ */
55
+ export declare function waitForAuthCallback(port: number, expectedState: string, timeoutMs?: number): {
56
+ promise: Promise<string>;
57
+ server: Server;
58
+ };
59
+ /**
60
+ * Browser-popup + localhost-callback OAuth2 flow.
61
+ * Opens the browser for Microsoft 365 sign-in, listens for the callback,
62
+ * exchanges the authorization code for tokens, saves them, and returns the TokenData.
63
+ */
64
+ export declare function startAuthFlow(config: AuthConfig): Promise<TokenData>;
65
+ /**
66
+ * Refreshes an access token using a refresh token.
67
+ * On success, saves and returns the new TokenData.
68
+ * On failure, deletes stored tokens and returns null.
69
+ */
70
+ export declare function refreshAccessToken(config: AuthConfig, refreshToken: string): Promise<TokenData | null>;
71
+ /**
72
+ * Main entry point — returns a valid access token.
73
+ * Loads cached tokens, refreshes if expired, or starts a new auth flow if needed.
74
+ */
75
+ export declare function getAccessToken(config: AuthConfig): Promise<string>;