@masonator/m365-mcp 0.2.0 → 0.4.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.
@@ -2,7 +2,13 @@ import { jest } from '@jest/globals';
2
2
  import { mkdtempSync, rmSync, readFileSync, writeFileSync, statSync, existsSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { tmpdir } from 'node:os';
5
- import { getConfigDir, loadTokens, saveTokens, deleteTokens, isTokenExpired, loadAuthConfig, SCOPES, refreshAccessToken, getAccessToken, findAvailablePort, exchangeCodeForTokens, openBrowser, waitForAuthCallback, } from '../lib/auth.js';
5
+ // Mock child_process to prevent browser tabs opening during tests
6
+ const mockExecFile = jest.fn();
7
+ jest.unstable_mockModule('node:child_process', () => ({
8
+ execFile: mockExecFile,
9
+ }));
10
+ // Dynamic import AFTER mock registration
11
+ const { getConfigDir, loadTokens, saveTokens, deleteTokens, isTokenExpired, loadAuthConfig, SCOPES, refreshAccessToken, getAccessToken, findAvailablePort, exchangeCodeForTokens, openBrowser, waitForAuthCallback, } = await import('../lib/auth.js');
6
12
  const sampleTokens = {
7
13
  access_token: 'access-abc-123',
8
14
  refresh_token: 'refresh-xyz-789',
@@ -382,8 +388,23 @@ describe('findAvailablePort', () => {
382
388
  });
383
389
  });
384
390
  describe('openBrowser', () => {
385
- it('does not throw for a URL string', () => {
386
- // openBrowser calls execFile which may fail, but should not throw
391
+ afterEach(() => {
392
+ mockExecFile.mockClear();
393
+ });
394
+ it('calls execFile with the correct command for the platform', () => {
395
+ openBrowser('https://example.com');
396
+ // On macOS (darwin), should call 'open'
397
+ if (process.platform === 'darwin') {
398
+ expect(mockExecFile).toHaveBeenCalledWith('open', ['https://example.com']);
399
+ }
400
+ else {
401
+ expect(mockExecFile).toHaveBeenCalled();
402
+ }
403
+ });
404
+ it('does not throw when execFile throws', () => {
405
+ mockExecFile.mockImplementation(() => {
406
+ throw new Error('Command not found');
407
+ });
387
408
  expect(() => openBrowser('https://example.com')).not.toThrow();
388
409
  });
389
410
  });
@@ -31,13 +31,25 @@ describe('executeMail', () => {
31
31
  expect(result).toContain('Importance: high | Read: Yes');
32
32
  expect(result).toContain('Here are the meeting notes from today.');
33
33
  });
34
- it('handles search param', async () => {
34
+ it('handles search param without $orderBy', async () => {
35
35
  mockGraphFetch.mockResolvedValue({
36
36
  ok: true,
37
37
  data: { value: [] },
38
38
  });
39
39
  await executeMail('test-token', { search: 'quarterly report' });
40
- expect(mockGraphFetch).toHaveBeenCalledWith(expect.stringContaining('$search="quarterly%20report"'), 'test-token', { timezone: false });
40
+ const calledPath = mockGraphFetch.mock.calls[0][0];
41
+ expect(calledPath).toContain('$search="quarterly%20report"');
42
+ expect(calledPath).not.toContain('$orderby');
43
+ });
44
+ it('includes $orderBy when no search is provided', async () => {
45
+ mockGraphFetch.mockResolvedValue({
46
+ ok: true,
47
+ data: { value: [] },
48
+ });
49
+ await executeMail('test-token', {});
50
+ const calledPath = mockGraphFetch.mock.calls[0][0];
51
+ expect(calledPath).toContain('$orderby=receivedDateTime desc');
52
+ expect(calledPath).not.toContain('$search');
41
53
  });
42
54
  it('handles empty results', async () => {
43
55
  mockGraphFetch.mockResolvedValue({
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,60 @@
1
+ import { executeServerInfo } from '../../lib/tools/server-info.js';
2
+ describe('executeServerInfo', () => {
3
+ it('returns server version and metadata', () => {
4
+ const result = executeServerInfo();
5
+ expect(result).toContain('# m365-mcp v');
6
+ expect(result).toContain(`Node: ${process.version}`);
7
+ expect(result).toContain(`Platform: ${process.platform} ${process.arch}`);
8
+ });
9
+ it('lists all available tools', () => {
10
+ const result = executeServerInfo();
11
+ expect(result).toContain('ms_auth_status');
12
+ expect(result).toContain('ms_profile');
13
+ expect(result).toContain('ms_calendar');
14
+ expect(result).toContain('ms_mail');
15
+ expect(result).toContain('ms_chat');
16
+ expect(result).toContain('ms_files');
17
+ expect(result).toContain('ms_transcripts');
18
+ expect(result).toContain('ms_server_info');
19
+ expect(result).toContain('Tools (8)');
20
+ });
21
+ it('shows environment variable status without exposing values', () => {
22
+ const result = executeServerInfo();
23
+ expect(result).toContain('MS365_MCP_CLIENT_ID:');
24
+ expect(result).toContain('MS365_MCP_TENANT_ID:');
25
+ // Should show 'set' or 'not set', never the actual value
26
+ expect(result).not.toMatch(/MS365_MCP_CLIENT_ID: [a-f0-9-]{10,}/);
27
+ });
28
+ it('reports env var as set when present', () => {
29
+ const original = process.env['MS365_MCP_TIMEZONE'];
30
+ try {
31
+ process.env['MS365_MCP_TIMEZONE'] = 'Europe/London';
32
+ const result = executeServerInfo();
33
+ expect(result).toContain('MS365_MCP_TIMEZONE: Europe/London');
34
+ }
35
+ finally {
36
+ if (original === undefined) {
37
+ delete process.env['MS365_MCP_TIMEZONE'];
38
+ }
39
+ else {
40
+ process.env['MS365_MCP_TIMEZONE'] = original;
41
+ }
42
+ }
43
+ });
44
+ it('reports default for MS365_MCP_REDIRECT_URL when not set', () => {
45
+ const original = process.env['MS365_MCP_REDIRECT_URL'];
46
+ try {
47
+ delete process.env['MS365_MCP_REDIRECT_URL'];
48
+ const result = executeServerInfo();
49
+ expect(result).toContain('MS365_MCP_REDIRECT_URL: default (dynamic port)');
50
+ }
51
+ finally {
52
+ if (original === undefined) {
53
+ delete process.env['MS365_MCP_REDIRECT_URL'];
54
+ }
55
+ else {
56
+ process.env['MS365_MCP_REDIRECT_URL'] = original;
57
+ }
58
+ }
59
+ });
60
+ });
@@ -8,7 +8,7 @@ jest.unstable_mockModule('../../lib/graph.js', () => ({
8
8
  const mockFetch = jest.fn();
9
9
  globalThis.fetch = mockFetch;
10
10
  // Dynamic import AFTER mocks are registered
11
- const { executeTranscripts, extractMeetingId, parseTranscriptId } = await import('../../lib/tools/transcripts.js');
11
+ const { executeTranscripts, extractMeetingId, parseTranscriptId, matchTranscriptsToEvent } = await import('../../lib/tools/transcripts.js');
12
12
  // ── Helpers ────────────────────────────────────────────────────────────────
13
13
  function makeJoinUrl(threadId, oid, tid = 'tenant-123') {
14
14
  const context = JSON.stringify({ Tid: tid, Oid: oid });
@@ -113,6 +113,73 @@ describe('parseTranscriptId', () => {
113
113
  });
114
114
  });
115
115
  });
116
+ // ── matchTranscriptsToEvent ────────────────────────────────────────────────
117
+ describe('matchTranscriptsToEvent', () => {
118
+ it('returns single transcript without filtering', () => {
119
+ const transcripts = [{ id: 'tx-1', createdDateTime: '2025-06-15T10:00:00Z' }];
120
+ const event = { start: { dateTime: '2025-06-22T10:00:00' } };
121
+ const result = matchTranscriptsToEvent(transcripts, event);
122
+ expect(result).toEqual([{ id: 'tx-1', createdDateTime: '2025-06-15T10:00:00Z' }]);
123
+ });
124
+ it('returns closest transcript when multiple exist', () => {
125
+ const transcripts = [
126
+ { id: 'tx-week1', createdDateTime: '2025-06-15T10:02:00Z' },
127
+ { id: 'tx-week2', createdDateTime: '2025-06-22T10:03:00Z' },
128
+ { id: 'tx-week3', createdDateTime: '2025-06-29T10:01:00Z' },
129
+ ];
130
+ const event = { start: { dateTime: '2025-06-22T10:00:00Z' } };
131
+ const result = matchTranscriptsToEvent(transcripts, event);
132
+ expect(result).toEqual([{ id: 'tx-week2', createdDateTime: '2025-06-22T10:03:00Z' }]);
133
+ });
134
+ it('returns all transcripts when event has no start time', () => {
135
+ const transcripts = [
136
+ { id: 'tx-1', createdDateTime: '2025-06-15T10:00:00Z' },
137
+ { id: 'tx-2', createdDateTime: '2025-06-22T10:00:00Z' },
138
+ ];
139
+ const event = {};
140
+ const result = matchTranscriptsToEvent(transcripts, event);
141
+ expect(result).toHaveLength(2);
142
+ });
143
+ it('returns all transcripts when no createdDateTime available', () => {
144
+ const transcripts = [{ id: 'tx-1' }, { id: 'tx-2' }];
145
+ const event = { start: { dateTime: '2025-06-15T10:00:00' } };
146
+ const result = matchTranscriptsToEvent(transcripts, event);
147
+ expect(result).toHaveLength(2);
148
+ });
149
+ it('returns empty array for zero transcripts', () => {
150
+ const event = { start: { dateTime: '2025-06-15T10:00:00' } };
151
+ const result = matchTranscriptsToEvent([], event);
152
+ expect(result).toHaveLength(0);
153
+ });
154
+ it('ignores transcripts with invalid createdDateTime', () => {
155
+ const transcripts = [
156
+ { id: 'tx-bad', createdDateTime: 'not-a-date' },
157
+ { id: 'tx-good', createdDateTime: '2025-06-15T10:05:00Z' },
158
+ ];
159
+ const event = { start: { dateTime: '2025-06-15T10:00:00' } };
160
+ const result = matchTranscriptsToEvent(transcripts, event);
161
+ expect(result).toEqual([{ id: 'tx-good', createdDateTime: '2025-06-15T10:05:00Z' }]);
162
+ });
163
+ it('returns all when event start dateTime is invalid', () => {
164
+ const transcripts = [
165
+ { id: 'tx-1', createdDateTime: '2025-06-15T10:00:00Z' },
166
+ { id: 'tx-2', createdDateTime: '2025-06-22T10:00:00Z' },
167
+ ];
168
+ const event = { start: { dateTime: 'not-a-date' } };
169
+ const result = matchTranscriptsToEvent(transcripts, event);
170
+ expect(result).toHaveLength(2);
171
+ });
172
+ it('returns empty when closest transcript is beyond 24-hour threshold', () => {
173
+ const transcripts = [
174
+ { id: 'tx-1', createdDateTime: '2025-06-15T10:00:00Z' },
175
+ { id: 'tx-2', createdDateTime: '2025-06-22T10:00:00Z' },
176
+ ];
177
+ // Event is 4 days away from nearest transcript
178
+ const event = { start: { dateTime: '2025-06-19T10:00:00Z' } };
179
+ const result = matchTranscriptsToEvent(transcripts, event);
180
+ expect(result).toHaveLength(0);
181
+ });
182
+ });
116
183
  // ── executeTranscripts ─────────────────────────────────────────────────────
117
184
  describe('executeTranscripts', () => {
118
185
  afterEach(() => {
@@ -121,11 +188,9 @@ describe('executeTranscripts', () => {
121
188
  });
122
189
  // ── Drill-down mode ──────────────────────────────────────────────────
123
190
  describe('drill-down mode', () => {
124
- it('returns full transcript for valid compound ID', async () => {
191
+ it('returns short transcript marked as complete', async () => {
125
192
  const vttContent = 'WEBVTT\n\n00:00:00.000 --> 00:00:05.000\nHello world';
126
- // VTT content fetch (raw fetch, v1.0 succeeds)
127
193
  mockFetch.mockResolvedValueOnce(mockResponse(vttContent));
128
- // Meeting subject fetch
129
194
  mockGraphFetch.mockResolvedValueOnce({
130
195
  ok: true,
131
196
  data: { subject: 'Team Standup' },
@@ -134,15 +199,77 @@ describe('executeTranscripts', () => {
134
199
  transcript_id: 'meetingId123/transcriptId456',
135
200
  });
136
201
  expect(result).toContain('# Transcript: Team Standup');
202
+ expect(result).toContain('(complete)');
137
203
  expect(result).toContain(vttContent);
204
+ expect(result).not.toContain('To continue reading');
205
+ });
206
+ it('paginates long transcript with continuation instructions', async () => {
207
+ const vttContent = 'WEBVTT\n\n' + 'A'.repeat(15000);
208
+ mockFetch.mockResolvedValueOnce(mockResponse(vttContent));
209
+ mockGraphFetch.mockResolvedValueOnce({
210
+ ok: true,
211
+ data: { subject: 'Long Meeting' },
212
+ });
213
+ const result = await executeTranscripts('test-token', {
214
+ transcript_id: 'meeting123/transcript456',
215
+ });
216
+ expect(result).toContain('# Transcript: Long Meeting');
217
+ expect(result).toContain(`Length: ${vttContent.length} chars`);
218
+ expect(result).toContain('Showing: 0–10000');
219
+ expect(result).toContain(`Remaining: ${vttContent.length - 10000}`);
220
+ expect(result).toContain('To continue reading');
221
+ expect(result).toContain('offset=10000');
222
+ // Should not contain the full content
223
+ expect(result).not.toContain('A'.repeat(15000));
224
+ });
225
+ it('returns next chunk when offset is provided', async () => {
226
+ const vttContent = 'B'.repeat(5000) + 'C'.repeat(5000) + 'D'.repeat(5000);
227
+ mockFetch.mockResolvedValueOnce(mockResponse(vttContent));
228
+ mockGraphFetch.mockResolvedValueOnce({
229
+ ok: true,
230
+ data: { subject: 'Offset Test' },
231
+ });
232
+ const result = await executeTranscripts('test-token', {
233
+ transcript_id: 'meeting/transcript',
234
+ offset: 10000,
235
+ });
236
+ expect(result).toContain('Showing: 10000–15000');
237
+ expect(result).toContain('Remaining: 0');
238
+ expect(result).not.toContain('To continue reading');
239
+ // Should contain only 'D' content
240
+ expect(result).toContain('D'.repeat(5000));
241
+ });
242
+ it('respects custom length parameter', async () => {
243
+ const vttContent = 'X'.repeat(30000);
244
+ mockFetch.mockResolvedValueOnce(mockResponse(vttContent));
245
+ mockGraphFetch.mockResolvedValueOnce({
246
+ ok: true,
247
+ data: { subject: 'Custom Length' },
248
+ });
249
+ const result = await executeTranscripts('test-token', {
250
+ transcript_id: 'meeting/transcript',
251
+ length: 20000,
252
+ });
253
+ expect(result).toContain('Showing: 0–20000');
254
+ expect(result).toContain('Remaining: 10000');
255
+ });
256
+ it('clamps length to max 50000', async () => {
257
+ const vttContent = 'Y'.repeat(80000);
258
+ mockFetch.mockResolvedValueOnce(mockResponse(vttContent));
259
+ mockGraphFetch.mockResolvedValueOnce({
260
+ ok: true,
261
+ data: { subject: 'Max Length' },
262
+ });
263
+ const result = await executeTranscripts('test-token', {
264
+ transcript_id: 'meeting/transcript',
265
+ length: 999999,
266
+ });
267
+ expect(result).toContain('Showing: 0–50000');
138
268
  });
139
269
  it('falls back to beta when v1.0 returns 403', async () => {
140
270
  const vttContent = 'WEBVTT\n\nFallback content';
141
- // v1.0 returns 403
142
271
  mockFetch.mockResolvedValueOnce(mockResponse('Forbidden', 403));
143
- // beta returns successfully
144
272
  mockFetch.mockResolvedValueOnce(mockResponse(vttContent));
145
- // Meeting subject
146
273
  mockGraphFetch.mockResolvedValueOnce({
147
274
  ok: true,
148
275
  data: { subject: 'Beta Meeting' },
@@ -225,46 +352,14 @@ describe('executeTranscripts', () => {
225
352
  ok: true,
226
353
  data: { value: [{ id: 'transcript-001' }] },
227
354
  });
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
355
  const result = await executeTranscripts('test-token', { date: '2025-06-15' });
232
356
  expect(result).toContain('Found 1 meetings, 1 with transcripts.');
233
357
  expect(result).toContain('## Sprint Planning');
234
358
  expect(result).toContain('Date: 2025-06-15T10:00:00');
235
359
  expect(result).toContain('Attendees: Alice, Bob');
236
360
  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));
361
+ // List mode no longer fetches VTT previews — drill-down handles full content
362
+ expect(mockFetch).not.toHaveBeenCalled();
268
363
  });
269
364
  it('handles empty calendar (no meetings)', async () => {
270
365
  mockGraphFetch.mockResolvedValueOnce({
@@ -358,13 +453,12 @@ describe('executeTranscripts', () => {
358
453
  ok: true,
359
454
  data: { value: [{ id: 'tx-500' }] },
360
455
  });
361
- // v1.0 VTT fetch returns 500 (non-retryable)
362
- mockFetch.mockResolvedValueOnce(mockResponse('Internal Server Error', 500));
363
456
  const result = await executeTranscripts('test-token', { date: '2025-06-15' });
364
- // Meeting has transcripts but VTT download failed still shows meeting without preview
457
+ // Meeting has transcripts — listed without VTT preview
365
458
  expect(result).toContain('1 with transcripts');
366
459
  expect(result).toContain('Server Error Meeting');
367
- expect(mockFetch).toHaveBeenCalledTimes(1); // No beta retry for 500
460
+ // List mode no longer fetches VTT content
461
+ expect(mockFetch).not.toHaveBeenCalled();
368
462
  });
369
463
  it('skips meeting when both v1.0 and beta transcript listing fail with non-retryable error', async () => {
370
464
  const threadId = '19:meeting_fail@thread.v2';
@@ -423,8 +517,6 @@ describe('executeTranscripts', () => {
423
517
  ok: true,
424
518
  data: { value: [{ id: 'tx-beta' }] },
425
519
  });
426
- // VTT content
427
- mockFetch.mockResolvedValueOnce(mockResponse('WEBVTT\n\nBeta content'));
428
520
  const result = await executeTranscripts('test-token', { date: '2025-06-15' });
429
521
  expect(result).toContain('1 with transcripts');
430
522
  expect(result).toContain('Beta Transcripts');
@@ -456,13 +548,110 @@ describe('executeTranscripts', () => {
456
548
  ok: true,
457
549
  data: { value: [{ id: 'tx-1' }] },
458
550
  });
459
- // VTT content
460
- mockFetch.mockResolvedValueOnce(mockResponse('WEBVTT\n\nOnline content'));
461
551
  const result = await executeTranscripts('test-token', { date: '2025-06-15' });
462
552
  // Only 1 meeting (with joinUrl) counted, 1 with transcripts
463
553
  expect(result).toContain('Found 1 meetings, 1 with transcripts.');
464
554
  expect(result).toContain('Online Meeting');
465
555
  expect(result).not.toContain('In-Person Meeting');
466
556
  });
557
+ it('matches correct transcript to each occurrence of a recurring meeting', async () => {
558
+ const threadId = '19:recurring_standup@thread.v2';
559
+ const oid = 'organizer-oid';
560
+ const joinUrl = makeJoinUrl(threadId, oid);
561
+ const meetingId = Buffer.from(`1*${oid}*0**${threadId}`).toString('base64');
562
+ // Calendar view returns two occurrences with the SAME join URL
563
+ mockGraphFetch.mockResolvedValueOnce({
564
+ ok: true,
565
+ data: {
566
+ value: [
567
+ {
568
+ subject: 'Weekly Standup',
569
+ start: { dateTime: '2025-06-15T10:00:00' },
570
+ end: { dateTime: '2025-06-15T10:30:00' },
571
+ onlineMeeting: { joinUrl },
572
+ },
573
+ {
574
+ subject: 'Weekly Standup',
575
+ start: { dateTime: '2025-06-22T10:00:00' },
576
+ end: { dateTime: '2025-06-22T10:30:00' },
577
+ onlineMeeting: { joinUrl },
578
+ },
579
+ ],
580
+ },
581
+ });
582
+ // Transcript list fetched ONCE (cached for second occurrence)
583
+ mockGraphFetch.mockResolvedValueOnce({
584
+ ok: true,
585
+ data: {
586
+ value: [
587
+ { id: 'tx-week1', createdDateTime: '2025-06-15T10:02:00Z' },
588
+ { id: 'tx-week2', createdDateTime: '2025-06-22T10:03:00Z' },
589
+ ],
590
+ },
591
+ });
592
+ const result = await executeTranscripts('test-token', {
593
+ start: '2025-06-14T00:00:00Z',
594
+ end: '2025-06-23T00:00:00Z',
595
+ });
596
+ // Both occurrences found with transcripts
597
+ expect(result).toContain('2 with transcripts');
598
+ // Each occurrence has its own distinct transcript ID
599
+ expect(result).toContain(`${meetingId}/tx-week1`);
600
+ expect(result).toContain(`${meetingId}/tx-week2`);
601
+ // List mode no longer fetches VTT content
602
+ expect(mockFetch).not.toHaveBeenCalled();
603
+ // Transcript list fetched only once (not twice) — cached for recurring
604
+ const transcriptCalls = mockGraphFetch.mock.calls.filter((call) => call[0].includes('/transcripts'));
605
+ expect(transcriptCalls).toHaveLength(1);
606
+ });
607
+ it('skips recurring occurrence that has no matching transcript', async () => {
608
+ const threadId = '19:recurring_weekly@thread.v2';
609
+ const oid = 'oid-recurring';
610
+ const joinUrl = makeJoinUrl(threadId, oid);
611
+ const meetingId = Buffer.from(`1*${oid}*0**${threadId}`).toString('base64');
612
+ // Three occurrences, but only two have transcripts
613
+ mockGraphFetch.mockResolvedValueOnce({
614
+ ok: true,
615
+ data: {
616
+ value: [
617
+ {
618
+ subject: 'Team Sync',
619
+ start: { dateTime: '2025-06-08T14:00:00' },
620
+ onlineMeeting: { joinUrl },
621
+ },
622
+ {
623
+ subject: 'Team Sync',
624
+ start: { dateTime: '2025-06-15T14:00:00' },
625
+ onlineMeeting: { joinUrl },
626
+ },
627
+ {
628
+ subject: 'Team Sync',
629
+ start: { dateTime: '2025-06-22T14:00:00' },
630
+ onlineMeeting: { joinUrl },
631
+ },
632
+ ],
633
+ },
634
+ });
635
+ // Only two transcripts exist (meeting on Jun 15 wasn't recorded)
636
+ mockGraphFetch.mockResolvedValueOnce({
637
+ ok: true,
638
+ data: {
639
+ value: [
640
+ { id: 'tx-jun8', createdDateTime: '2025-06-08T14:01:00Z' },
641
+ { id: 'tx-jun22', createdDateTime: '2025-06-22T14:02:00Z' },
642
+ ],
643
+ },
644
+ });
645
+ const result = await executeTranscripts('test-token', {
646
+ start: '2025-06-01T00:00:00Z',
647
+ end: '2025-06-30T00:00:00Z',
648
+ });
649
+ // 3 meetings found, but only 2 have matching transcripts
650
+ expect(result).toContain('Found 3 meetings, 2 with transcripts.');
651
+ expect(result).toContain(`${meetingId}/tx-jun8`);
652
+ expect(result).toContain(`${meetingId}/tx-jun22`);
653
+ // List mode no longer fetches VTT content
654
+ expect(mockFetch).not.toHaveBeenCalled();
655
+ });
467
656
  });
468
657
  });
package/dist/index.js CHANGED
@@ -10,6 +10,7 @@ import { mailToolDefinition, executeMail } from './lib/tools/mail.js';
10
10
  import { chatToolDefinition, executeChat } from './lib/tools/chat.js';
11
11
  import { filesToolDefinition, executeFiles } from './lib/tools/files.js';
12
12
  import { transcriptsToolDefinition, executeTranscripts } from './lib/tools/transcripts.js';
13
+ import { serverInfoToolDefinition, executeServerInfo } from './lib/tools/server-info.js';
13
14
  // Validate env vars at startup
14
15
  try {
15
16
  loadAuthConfig();
@@ -23,7 +24,7 @@ catch (error) {
23
24
  process.stderr.write(' MS365_MCP_CLIENT_SECRET - Azure AD client secret (confidential clients only)\n');
24
25
  process.exit(1);
25
26
  }
26
- const server = new Server({ name: 'm365-mcp', version: '0.2.0' }, { capabilities: { tools: {} } });
27
+ const server = new Server({ name: 'm365-mcp', version: '0.4.0' }, { capabilities: { tools: {} } });
27
28
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
28
29
  tools: [
29
30
  authStatusToolDefinition,
@@ -33,17 +34,22 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
33
34
  chatToolDefinition,
34
35
  filesToolDefinition,
35
36
  transcriptsToolDefinition,
37
+ serverInfoToolDefinition,
36
38
  ],
37
39
  }));
38
40
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
39
41
  const { name, arguments: args = {} } = request.params;
40
42
  try {
41
43
  const config = loadAuthConfig();
42
- // auth_status handles its own auth
44
+ // Tools that don't require a valid token
43
45
  if (name === 'ms_auth_status') {
44
46
  const result = await executeAuthStatus(config);
45
47
  return { content: [{ type: 'text', text: result }] };
46
48
  }
49
+ if (name === 'ms_server_info') {
50
+ const result = executeServerInfo();
51
+ return { content: [{ type: 'text', text: result }] };
52
+ }
47
53
  // All other tools need a valid token
48
54
  const token = await getAccessToken(config);
49
55
  let result;
@@ -41,10 +41,14 @@ function formatMessage(msg) {
41
41
  */
42
42
  export async function executeMail(token, args) {
43
43
  const count = Math.min(Math.max(args.count ?? 10, 1), 25);
44
- let path = `/me/messages?$top=${count}&$orderby=receivedDateTime desc` +
45
- `&$select=subject,from,receivedDateTime,bodyPreview,isRead,importance`;
44
+ const select = 'subject,from,receivedDateTime,bodyPreview,isRead,importance';
45
+ let path;
46
46
  if (args.search) {
47
- path += `&$search="${encodeURIComponent(args.search)}"`;
47
+ // $orderBy is not supported with $search — Graph returns results by relevance
48
+ path = `/me/messages?$top=${count}&$select=${select}&$search="${encodeURIComponent(args.search)}"`;
49
+ }
50
+ else {
51
+ path = `/me/messages?$top=${count}&$orderby=receivedDateTime desc&$select=${select}`;
48
52
  }
49
53
  const result = await graphFetch(path, token, { timezone: false });
50
54
  if (!result.ok) {
@@ -0,0 +1,9 @@
1
+ export declare const serverInfoToolDefinition: {
2
+ name: string;
3
+ description: string;
4
+ inputSchema: {
5
+ type: "object";
6
+ properties: {};
7
+ };
8
+ };
9
+ export declare function executeServerInfo(): string;
@@ -0,0 +1,53 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join, dirname } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ export const serverInfoToolDefinition = {
5
+ name: 'ms_server_info',
6
+ description: 'Returns m365-mcp server metadata: version, available tools, and runtime info. Useful for debugging.',
7
+ inputSchema: {
8
+ type: 'object',
9
+ properties: {},
10
+ },
11
+ };
12
+ const TOOL_NAMES = [
13
+ 'ms_auth_status',
14
+ 'ms_profile',
15
+ 'ms_calendar',
16
+ 'ms_mail',
17
+ 'ms_chat',
18
+ 'ms_files',
19
+ 'ms_transcripts',
20
+ 'ms_server_info',
21
+ ];
22
+ function getVersion() {
23
+ try {
24
+ const dir = dirname(fileURLToPath(import.meta.url));
25
+ const pkgPath = join(dir, '..', '..', '..', 'package.json');
26
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
27
+ return pkg.version ?? 'unknown';
28
+ }
29
+ catch {
30
+ return 'unknown';
31
+ }
32
+ }
33
+ export function executeServerInfo() {
34
+ const version = getVersion();
35
+ const lines = [];
36
+ lines.push(`# m365-mcp v${version}`);
37
+ lines.push('');
38
+ lines.push(`Node: ${process.version}`);
39
+ lines.push(`Platform: ${process.platform} ${process.arch}`);
40
+ lines.push('');
41
+ lines.push(`## Tools (${TOOL_NAMES.length})`);
42
+ for (const name of TOOL_NAMES) {
43
+ lines.push(`- ${name}`);
44
+ }
45
+ lines.push('');
46
+ lines.push('## Environment');
47
+ lines.push(`MS365_MCP_CLIENT_ID: ${process.env['MS365_MCP_CLIENT_ID'] ? 'set' : 'not set'}`);
48
+ lines.push(`MS365_MCP_CLIENT_SECRET: ${process.env['MS365_MCP_CLIENT_SECRET'] ? 'set' : 'not set'}`);
49
+ lines.push(`MS365_MCP_TENANT_ID: ${process.env['MS365_MCP_TENANT_ID'] ? 'set' : 'not set'}`);
50
+ lines.push(`MS365_MCP_REDIRECT_URL: ${process.env['MS365_MCP_REDIRECT_URL'] || 'default (dynamic port)'}`);
51
+ lines.push(`MS365_MCP_TIMEZONE: ${process.env['MS365_MCP_TIMEZONE'] || 'auto'}`);
52
+ return lines.join('\n');
53
+ }
@@ -20,9 +20,43 @@ export declare const transcriptsToolDefinition: {
20
20
  type: string;
21
21
  description: string;
22
22
  };
23
+ offset: {
24
+ type: string;
25
+ description: string;
26
+ };
27
+ length: {
28
+ type: string;
29
+ description: string;
30
+ };
23
31
  };
24
32
  };
25
33
  };
34
+ interface CalendarEvent {
35
+ subject?: string;
36
+ start?: {
37
+ dateTime?: string;
38
+ };
39
+ end?: {
40
+ dateTime?: string;
41
+ };
42
+ attendees?: Array<{
43
+ emailAddress?: {
44
+ name?: string;
45
+ };
46
+ }>;
47
+ organizer?: {
48
+ emailAddress?: {
49
+ name?: string;
50
+ };
51
+ };
52
+ onlineMeeting?: {
53
+ joinUrl?: string;
54
+ } | null;
55
+ }
56
+ interface TranscriptEntry {
57
+ id: string;
58
+ createdDateTime?: string;
59
+ }
26
60
  /**
27
61
  * Extracts a Graph online meeting ID from a Teams join URL.
28
62
  *
@@ -39,13 +73,25 @@ export declare function parseTranscriptId(transcriptId: string): {
39
73
  meetingId: string;
40
74
  transcriptId: string;
41
75
  } | null;
76
+ /**
77
+ * Matches transcripts to a specific calendar event occurrence.
78
+ * For recurring meetings (multiple transcripts sharing the same meeting ID),
79
+ * finds the transcript whose createdDateTime is closest to the event's start time.
80
+ *
81
+ * When there's only one transcript, returns it without filtering.
82
+ * When no createdDateTime data is available, falls back to returning all transcripts.
83
+ */
84
+ export declare function matchTranscriptsToEvent(transcripts: TranscriptEntry[], event: CalendarEvent): TranscriptEntry[];
42
85
  /**
43
86
  * Fetches meeting transcripts from Microsoft Teams.
44
- * Supports two modes: list (date range) and drill-down (specific transcript).
87
+ * Supports two modes: list (date range) and drill-down (specific transcript with pagination).
45
88
  */
46
89
  export declare function executeTranscripts(token: string, args: {
47
90
  date?: string;
48
91
  start?: string;
49
92
  end?: string;
50
93
  transcript_id?: string;
94
+ offset?: number;
95
+ length?: number;
51
96
  }): Promise<string>;
97
+ export {};
@@ -1,7 +1,12 @@
1
1
  import { graphFetch } from '../graph.js';
2
+ const DEFAULT_CHUNK_SIZE = 10000;
3
+ const MAX_CHUNK_SIZE = 50000;
2
4
  export const transcriptsToolDefinition = {
3
5
  name: 'ms_transcripts',
4
- description: 'Fetch meeting transcripts from Microsoft Teams. Returns preview (~3000 chars) + transcript_id for drill-down. Use transcript_id to get the full transcript.',
6
+ description: 'Fetch meeting transcripts from Microsoft Teams. ' +
7
+ 'Without transcript_id: lists meetings with ~3000 char previews. ' +
8
+ 'With transcript_id: returns transcript content in chunks (default 10,000 chars). ' +
9
+ 'Use offset to paginate through long transcripts.',
5
10
  inputSchema: {
6
11
  type: 'object',
7
12
  properties: {
@@ -10,7 +15,15 @@ export const transcriptsToolDefinition = {
10
15
  end: { type: 'string', description: 'End of date range (ISO 8601)' },
11
16
  transcript_id: {
12
17
  type: 'string',
13
- description: 'Transcript ID for full content drill-down (from a previous list call)',
18
+ description: 'Transcript ID for content drill-down (from a previous list call)',
19
+ },
20
+ offset: {
21
+ type: 'integer',
22
+ description: 'Character offset for pagination (default 0). Use the value from the previous response to continue reading.',
23
+ },
24
+ length: {
25
+ type: 'integer',
26
+ description: 'Max characters to return (default 10000, max 50000)',
14
27
  },
15
28
  },
16
29
  },
@@ -79,6 +92,55 @@ function todayRange() {
79
92
  const day = String(now.getDate()).padStart(2, '0');
80
93
  return dateRangeForDay(`${year}-${month}-${day}`);
81
94
  }
95
+ /**
96
+ * Matches transcripts to a specific calendar event occurrence.
97
+ * For recurring meetings (multiple transcripts sharing the same meeting ID),
98
+ * finds the transcript whose createdDateTime is closest to the event's start time.
99
+ *
100
+ * When there's only one transcript, returns it without filtering.
101
+ * When no createdDateTime data is available, falls back to returning all transcripts.
102
+ */
103
+ export function matchTranscriptsToEvent(transcripts, event) {
104
+ if (transcripts.length <= 1) {
105
+ return transcripts;
106
+ }
107
+ const eventStartStr = event.start?.dateTime;
108
+ if (!eventStartStr) {
109
+ return transcripts;
110
+ }
111
+ const eventStartMs = new Date(eventStartStr).getTime();
112
+ if (isNaN(eventStartMs)) {
113
+ return transcripts;
114
+ }
115
+ // Find transcript with createdDateTime closest to event start
116
+ let closest = null;
117
+ let closestDiff = Infinity;
118
+ for (const t of transcripts) {
119
+ if (!t.createdDateTime)
120
+ continue;
121
+ const createdMs = new Date(t.createdDateTime).getTime();
122
+ if (isNaN(createdMs))
123
+ continue;
124
+ const diff = Math.abs(createdMs - eventStartMs);
125
+ if (diff < closestDiff) {
126
+ closest = t;
127
+ closestDiff = diff;
128
+ }
129
+ }
130
+ // Only match if within 24 hours — handles timezone discrepancies between
131
+ // event times (user's preferred timezone) and transcript UTC timestamps,
132
+ // while still distinguishing daily recurring meeting occurrences.
133
+ const MAX_DIFF_MS = 24 * 60 * 60 * 1000;
134
+ if (closest && closestDiff <= MAX_DIFF_MS) {
135
+ return [closest];
136
+ }
137
+ if (!closest) {
138
+ // No transcripts have valid createdDateTime — fall back to returning all
139
+ return transcripts;
140
+ }
141
+ // Closest transcript is too far from this event — no match for this occurrence
142
+ return [];
143
+ }
82
144
  /**
83
145
  * Fetches VTT transcript content using raw fetch (not graphFetch, since it returns text).
84
146
  * Tries v1.0 first, falls back to beta on 403/400.
@@ -120,9 +182,9 @@ async function fetchTranscriptsList(token, meetingId) {
120
182
  return null;
121
183
  }
122
184
  /**
123
- * Handles drill-down mode: fetch full transcript by compound ID.
185
+ * Handles drill-down mode: fetch transcript by compound ID with pagination.
124
186
  */
125
- async function executeDrillDown(token, compoundId) {
187
+ async function executeDrillDown(token, compoundId, offset, length) {
126
188
  const parsed = parseTranscriptId(compoundId);
127
189
  if (!parsed) {
128
190
  return 'Error: Invalid transcript_id format. Expected "{meetingId}/{transcriptId}".';
@@ -138,7 +200,25 @@ async function executeDrillDown(token, compoundId) {
138
200
  if (meetingResult.ok && meetingResult.data.subject) {
139
201
  subject = meetingResult.data.subject;
140
202
  }
141
- return `# Transcript: ${subject}\n\n${vtt}`;
203
+ const totalLength = vtt.length;
204
+ // Short transcript — return it all, no pagination needed
205
+ if (totalLength <= length) {
206
+ return `# Transcript: ${subject}\nLength: ${totalLength} chars (complete)\n\n${vtt}`;
207
+ }
208
+ // Paginated: slice the requested chunk
209
+ const chunk = vtt.slice(offset, offset + length);
210
+ const end = offset + chunk.length;
211
+ const remaining = totalLength - end;
212
+ const lines = [];
213
+ lines.push(`# Transcript: ${subject}`);
214
+ lines.push(`Length: ${totalLength} chars | Showing: ${offset}–${end} | Remaining: ${remaining}`);
215
+ lines.push('');
216
+ lines.push(chunk);
217
+ if (remaining > 0) {
218
+ lines.push('');
219
+ lines.push(`--- To continue reading, call again with transcript_id="${compoundId}" offset=${end} ---`);
220
+ }
221
+ return lines.join('\n');
142
222
  }
143
223
  /**
144
224
  * Handles list mode: find meetings with transcripts in date range.
@@ -178,33 +258,45 @@ async function executeList(token, args) {
178
258
  if (meetingEvents.length === 0) {
179
259
  return 'No Teams meetings found in the given date range.';
180
260
  }
261
+ // Extract unique meeting IDs and map events to them
262
+ const meetingIdMap = new Map(); // joinUrl -> meetingId
263
+ const uniqueMeetingIds = new Set();
264
+ for (const event of meetingEvents) {
265
+ const joinUrl = event.onlineMeeting?.joinUrl;
266
+ if (!joinUrl || meetingIdMap.has(joinUrl))
267
+ continue;
268
+ const meetingId = extractMeetingId(joinUrl);
269
+ if (!meetingId)
270
+ continue;
271
+ meetingIdMap.set(joinUrl, meetingId);
272
+ uniqueMeetingIds.add(meetingId);
273
+ }
274
+ // Fetch all transcript lists in parallel (one per unique meeting ID)
275
+ const transcriptCache = new Map();
276
+ const fetchPromises = [...uniqueMeetingIds].map(async (meetingId) => {
277
+ const data = await fetchTranscriptsList(token, meetingId);
278
+ transcriptCache.set(meetingId, data?.value ?? []);
279
+ });
280
+ await Promise.all(fetchPromises);
181
281
  const sections = [];
182
282
  let transcriptCount = 0;
183
283
  for (const event of meetingEvents) {
184
284
  const joinUrl = event.onlineMeeting?.joinUrl;
185
285
  if (!joinUrl)
186
286
  continue;
187
- const meetingId = extractMeetingId(joinUrl);
287
+ const meetingId = meetingIdMap.get(joinUrl);
188
288
  if (!meetingId)
189
289
  continue;
190
- // Check for transcripts
191
- const transcriptsData = await fetchTranscriptsList(token, meetingId);
192
- if (!transcriptsData || transcriptsData.value.length === 0)
290
+ const allTranscripts = transcriptCache.get(meetingId);
291
+ if (!allTranscripts || allTranscripts.length === 0)
292
+ continue;
293
+ // Match transcripts to this specific event occurrence (handles recurring meetings)
294
+ const eventTranscripts = matchTranscriptsToEvent(allTranscripts, event);
295
+ if (eventTranscripts.length === 0)
193
296
  continue;
194
297
  transcriptCount++;
195
- const firstTranscript = transcriptsData.value[0];
298
+ const firstTranscript = eventTranscripts[0];
196
299
  const compoundId = `${meetingId}/${firstTranscript.id}`;
197
- // Download VTT preview
198
- let vttPreview = '';
199
- const vtt = await fetchVttContent(token, meetingId, firstTranscript.id);
200
- if (vtt) {
201
- if (vtt.length > 3000) {
202
- vttPreview = vtt.slice(0, 3000) + '\n... [truncated — use transcript_id for full content]';
203
- }
204
- else {
205
- vttPreview = vtt;
206
- }
207
- }
208
300
  const lines = [];
209
301
  lines.push(`## ${event.subject || 'Untitled'}`);
210
302
  lines.push(`Date: ${event.start?.dateTime || 'N/A'}`);
@@ -216,10 +308,6 @@ async function executeList(token, args) {
216
308
  lines.push(`Attendees: ${attendeeNames}`);
217
309
  }
218
310
  lines.push(`Transcript ID: ${compoundId}`);
219
- if (vttPreview) {
220
- lines.push('');
221
- lines.push(vttPreview);
222
- }
223
311
  sections.push(lines.join('\n'));
224
312
  }
225
313
  if (transcriptCount === 0) {
@@ -234,11 +322,13 @@ async function executeList(token, args) {
234
322
  }
235
323
  /**
236
324
  * Fetches meeting transcripts from Microsoft Teams.
237
- * Supports two modes: list (date range) and drill-down (specific transcript).
325
+ * Supports two modes: list (date range) and drill-down (specific transcript with pagination).
238
326
  */
239
327
  export async function executeTranscripts(token, args) {
240
328
  if (args.transcript_id) {
241
- return executeDrillDown(token, args.transcript_id);
329
+ const offset = Math.max(args.offset ?? 0, 0);
330
+ const length = Math.min(Math.max(args.length ?? DEFAULT_CHUNK_SIZE, 1), MAX_CHUNK_SIZE);
331
+ return executeDrillDown(token, args.transcript_id, offset, length);
242
332
  }
243
333
  return executeList(token, args);
244
334
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@masonator/m365-mcp",
3
3
  "scope": "@masonator",
4
- "version": "0.2.0",
4
+ "version": "0.4.0",
5
5
  "description": "MCP server implementation for Microsoft 365 via Microsoft Graph API",
6
6
  "type": "module",
7
7
  "main": "./dist/index.js",