@masonator/m365-mcp 0.2.0 → 0.5.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.
@@ -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,87 @@ describe('parseTranscriptId', () => {
113
113
  });
114
114
  });
115
115
  });
116
+ // ── matchTranscriptsToEvent ────────────────────────────────────────────────
117
+ describe('matchTranscriptsToEvent', () => {
118
+ it('returns single transcript when close to event', () => {
119
+ const transcripts = [{ id: 'tx-1', createdDateTime: '2025-06-15T10:02:00Z' }];
120
+ const event = { start: { dateTime: '2025-06-15T10:00:00' } };
121
+ const result = matchTranscriptsToEvent(transcripts, event);
122
+ expect(result).toEqual([{ id: 'tx-1', createdDateTime: '2025-06-15T10:02:00Z' }]);
123
+ });
124
+ it('skips single transcript when far from event (recurring meeting, different occurrence)', () => {
125
+ const transcripts = [{ id: 'tx-1', createdDateTime: '2025-06-15T10:00:00Z' }];
126
+ // Event is 7 days later — different occurrence of recurring meeting
127
+ const event = { start: { dateTime: '2025-06-22T10:00:00' } };
128
+ const result = matchTranscriptsToEvent(transcripts, event);
129
+ expect(result).toHaveLength(0);
130
+ });
131
+ it('returns single transcript without createdDateTime as fallback', () => {
132
+ const transcripts = [{ id: 'tx-1' }];
133
+ const event = { start: { dateTime: '2025-06-15T10:00:00' } };
134
+ const result = matchTranscriptsToEvent(transcripts, event);
135
+ // No createdDateTime means we can't match — fall back to returning it
136
+ expect(result).toEqual([{ id: 'tx-1' }]);
137
+ });
138
+ it('returns closest transcript when multiple exist', () => {
139
+ const transcripts = [
140
+ { id: 'tx-week1', createdDateTime: '2025-06-15T10:02:00Z' },
141
+ { id: 'tx-week2', createdDateTime: '2025-06-22T10:03:00Z' },
142
+ { id: 'tx-week3', createdDateTime: '2025-06-29T10:01:00Z' },
143
+ ];
144
+ const event = { start: { dateTime: '2025-06-22T10:00:00Z' } };
145
+ const result = matchTranscriptsToEvent(transcripts, event);
146
+ expect(result).toEqual([{ id: 'tx-week2', createdDateTime: '2025-06-22T10:03:00Z' }]);
147
+ });
148
+ it('returns all transcripts when event has no start time', () => {
149
+ const transcripts = [
150
+ { id: 'tx-1', createdDateTime: '2025-06-15T10:00:00Z' },
151
+ { id: 'tx-2', createdDateTime: '2025-06-22T10:00:00Z' },
152
+ ];
153
+ const event = {};
154
+ const result = matchTranscriptsToEvent(transcripts, event);
155
+ expect(result).toHaveLength(2);
156
+ });
157
+ it('returns all transcripts when no createdDateTime available', () => {
158
+ const transcripts = [{ id: 'tx-1' }, { id: 'tx-2' }];
159
+ const event = { start: { dateTime: '2025-06-15T10:00:00' } };
160
+ const result = matchTranscriptsToEvent(transcripts, event);
161
+ expect(result).toHaveLength(2);
162
+ });
163
+ it('returns empty array for zero transcripts', () => {
164
+ const event = { start: { dateTime: '2025-06-15T10:00:00' } };
165
+ const result = matchTranscriptsToEvent([], event);
166
+ expect(result).toHaveLength(0);
167
+ });
168
+ it('ignores transcripts with invalid createdDateTime', () => {
169
+ const transcripts = [
170
+ { id: 'tx-bad', createdDateTime: 'not-a-date' },
171
+ { id: 'tx-good', createdDateTime: '2025-06-15T10:05:00Z' },
172
+ ];
173
+ const event = { start: { dateTime: '2025-06-15T10:00:00' } };
174
+ const result = matchTranscriptsToEvent(transcripts, event);
175
+ expect(result).toEqual([{ id: 'tx-good', createdDateTime: '2025-06-15T10:05:00Z' }]);
176
+ });
177
+ it('returns all when event start dateTime is invalid', () => {
178
+ const transcripts = [
179
+ { id: 'tx-1', createdDateTime: '2025-06-15T10:00:00Z' },
180
+ { id: 'tx-2', createdDateTime: '2025-06-22T10:00:00Z' },
181
+ ];
182
+ const event = { start: { dateTime: 'not-a-date' } };
183
+ const result = matchTranscriptsToEvent(transcripts, event);
184
+ expect(result).toHaveLength(2);
185
+ });
186
+ it('returns empty when closest transcript is beyond 24-hour threshold', () => {
187
+ const transcripts = [
188
+ { id: 'tx-1', createdDateTime: '2025-06-15T10:00:00Z' },
189
+ { id: 'tx-2', createdDateTime: '2025-06-22T10:00:00Z' },
190
+ ];
191
+ // Event is 4 days away from nearest transcript
192
+ const event = { start: { dateTime: '2025-06-19T10:00:00Z' } };
193
+ const result = matchTranscriptsToEvent(transcripts, event);
194
+ expect(result).toHaveLength(0);
195
+ });
196
+ });
116
197
  // ── executeTranscripts ─────────────────────────────────────────────────────
117
198
  describe('executeTranscripts', () => {
118
199
  afterEach(() => {
@@ -121,11 +202,9 @@ describe('executeTranscripts', () => {
121
202
  });
122
203
  // ── Drill-down mode ──────────────────────────────────────────────────
123
204
  describe('drill-down mode', () => {
124
- it('returns full transcript for valid compound ID', async () => {
205
+ it('returns short transcript marked as complete', async () => {
125
206
  const vttContent = 'WEBVTT\n\n00:00:00.000 --> 00:00:05.000\nHello world';
126
- // VTT content fetch (raw fetch, v1.0 succeeds)
127
207
  mockFetch.mockResolvedValueOnce(mockResponse(vttContent));
128
- // Meeting subject fetch
129
208
  mockGraphFetch.mockResolvedValueOnce({
130
209
  ok: true,
131
210
  data: { subject: 'Team Standup' },
@@ -134,15 +213,77 @@ describe('executeTranscripts', () => {
134
213
  transcript_id: 'meetingId123/transcriptId456',
135
214
  });
136
215
  expect(result).toContain('# Transcript: Team Standup');
216
+ expect(result).toContain('(complete)');
137
217
  expect(result).toContain(vttContent);
218
+ expect(result).not.toContain('To continue reading');
219
+ });
220
+ it('paginates long transcript with continuation instructions', async () => {
221
+ const vttContent = 'WEBVTT\n\n' + 'A'.repeat(15000);
222
+ mockFetch.mockResolvedValueOnce(mockResponse(vttContent));
223
+ mockGraphFetch.mockResolvedValueOnce({
224
+ ok: true,
225
+ data: { subject: 'Long Meeting' },
226
+ });
227
+ const result = await executeTranscripts('test-token', {
228
+ transcript_id: 'meeting123/transcript456',
229
+ });
230
+ expect(result).toContain('# Transcript: Long Meeting');
231
+ expect(result).toContain(`Length: ${vttContent.length} chars`);
232
+ expect(result).toContain('Showing: 0–10000');
233
+ expect(result).toContain(`Remaining: ${vttContent.length - 10000}`);
234
+ expect(result).toContain('To continue reading');
235
+ expect(result).toContain('offset=10000');
236
+ // Should not contain the full content
237
+ expect(result).not.toContain('A'.repeat(15000));
238
+ });
239
+ it('returns next chunk when offset is provided', async () => {
240
+ const vttContent = 'B'.repeat(5000) + 'C'.repeat(5000) + 'D'.repeat(5000);
241
+ mockFetch.mockResolvedValueOnce(mockResponse(vttContent));
242
+ mockGraphFetch.mockResolvedValueOnce({
243
+ ok: true,
244
+ data: { subject: 'Offset Test' },
245
+ });
246
+ const result = await executeTranscripts('test-token', {
247
+ transcript_id: 'meeting/transcript',
248
+ offset: 10000,
249
+ });
250
+ expect(result).toContain('Showing: 10000–15000');
251
+ expect(result).toContain('Remaining: 0');
252
+ expect(result).not.toContain('To continue reading');
253
+ // Should contain only 'D' content
254
+ expect(result).toContain('D'.repeat(5000));
255
+ });
256
+ it('respects custom length parameter', async () => {
257
+ const vttContent = 'X'.repeat(30000);
258
+ mockFetch.mockResolvedValueOnce(mockResponse(vttContent));
259
+ mockGraphFetch.mockResolvedValueOnce({
260
+ ok: true,
261
+ data: { subject: 'Custom Length' },
262
+ });
263
+ const result = await executeTranscripts('test-token', {
264
+ transcript_id: 'meeting/transcript',
265
+ length: 20000,
266
+ });
267
+ expect(result).toContain('Showing: 0–20000');
268
+ expect(result).toContain('Remaining: 10000');
269
+ });
270
+ it('clamps length to max 50000', async () => {
271
+ const vttContent = 'Y'.repeat(80000);
272
+ mockFetch.mockResolvedValueOnce(mockResponse(vttContent));
273
+ mockGraphFetch.mockResolvedValueOnce({
274
+ ok: true,
275
+ data: { subject: 'Max Length' },
276
+ });
277
+ const result = await executeTranscripts('test-token', {
278
+ transcript_id: 'meeting/transcript',
279
+ length: 999999,
280
+ });
281
+ expect(result).toContain('Showing: 0–50000');
138
282
  });
139
283
  it('falls back to beta when v1.0 returns 403', async () => {
140
284
  const vttContent = 'WEBVTT\n\nFallback content';
141
- // v1.0 returns 403
142
285
  mockFetch.mockResolvedValueOnce(mockResponse('Forbidden', 403));
143
- // beta returns successfully
144
286
  mockFetch.mockResolvedValueOnce(mockResponse(vttContent));
145
- // Meeting subject
146
287
  mockGraphFetch.mockResolvedValueOnce({
147
288
  ok: true,
148
289
  data: { subject: 'Beta Meeting' },
@@ -183,9 +324,10 @@ describe('executeTranscripts', () => {
183
324
  });
184
325
  expect(result).toContain('Error: Invalid transcript_id format');
185
326
  });
186
- it('uses (Unknown meeting) when subject fetch fails', async () => {
327
+ it('uses (Unknown meeting) when subject fetch fails with non-retryable error', async () => {
187
328
  const vttContent = 'WEBVTT\n\nContent here';
188
329
  mockFetch.mockResolvedValueOnce(mockResponse(vttContent));
330
+ // 404 is not retryable — no beta fallback
189
331
  mockGraphFetch.mockResolvedValueOnce({
190
332
  ok: false,
191
333
  error: { status: 404, message: 'Not found' },
@@ -196,6 +338,43 @@ describe('executeTranscripts', () => {
196
338
  expect(result).toContain('# Transcript: (Unknown meeting)');
197
339
  expect(result).toContain(vttContent);
198
340
  });
341
+ it('falls back to beta for meeting subject on 403', async () => {
342
+ const vttContent = 'WEBVTT\n\nBeta subject content';
343
+ mockFetch.mockResolvedValueOnce(mockResponse(vttContent));
344
+ // v1.0 subject fetch fails with 403
345
+ mockGraphFetch.mockResolvedValueOnce({
346
+ ok: false,
347
+ error: { status: 403, message: 'Forbidden' },
348
+ });
349
+ // beta subject fetch succeeds
350
+ mockGraphFetch.mockResolvedValueOnce({
351
+ ok: true,
352
+ data: { subject: 'Beta Subject Meeting' },
353
+ });
354
+ const result = await executeTranscripts('test-token', {
355
+ transcript_id: 'meeting/transcript',
356
+ });
357
+ expect(result).toContain('# Transcript: Beta Subject Meeting');
358
+ expect(mockGraphFetch).toHaveBeenCalledWith(expect.stringContaining('onlineMeetings'), 'test-token', { beta: true });
359
+ });
360
+ it('shows (Unknown meeting) when both v1.0 and beta subject fetch fail', async () => {
361
+ const vttContent = 'WEBVTT\n\nContent';
362
+ mockFetch.mockResolvedValueOnce(mockResponse(vttContent));
363
+ // v1.0 fails with 400
364
+ mockGraphFetch.mockResolvedValueOnce({
365
+ ok: false,
366
+ error: { status: 400, message: 'Bad request' },
367
+ });
368
+ // beta also fails
369
+ mockGraphFetch.mockResolvedValueOnce({
370
+ ok: false,
371
+ error: { status: 400, message: 'Bad request' },
372
+ });
373
+ const result = await executeTranscripts('test-token', {
374
+ transcript_id: 'meeting/transcript',
375
+ });
376
+ expect(result).toContain('# Transcript: (Unknown meeting)');
377
+ });
199
378
  });
200
379
  // ── List mode ────────────────────────────────────────────────────────
201
380
  describe('list mode', () => {
@@ -225,46 +404,14 @@ describe('executeTranscripts', () => {
225
404
  ok: true,
226
405
  data: { value: [{ id: 'transcript-001' }] },
227
406
  });
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
407
  const result = await executeTranscripts('test-token', { date: '2025-06-15' });
232
408
  expect(result).toContain('Found 1 meetings, 1 with transcripts.');
233
409
  expect(result).toContain('## Sprint Planning');
234
410
  expect(result).toContain('Date: 2025-06-15T10:00:00');
235
411
  expect(result).toContain('Attendees: Alice, Bob');
236
412
  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));
413
+ // List mode no longer fetches VTT previews — drill-down handles full content
414
+ expect(mockFetch).not.toHaveBeenCalled();
268
415
  });
269
416
  it('handles empty calendar (no meetings)', async () => {
270
417
  mockGraphFetch.mockResolvedValueOnce({
@@ -358,13 +505,12 @@ describe('executeTranscripts', () => {
358
505
  ok: true,
359
506
  data: { value: [{ id: 'tx-500' }] },
360
507
  });
361
- // v1.0 VTT fetch returns 500 (non-retryable)
362
- mockFetch.mockResolvedValueOnce(mockResponse('Internal Server Error', 500));
363
508
  const result = await executeTranscripts('test-token', { date: '2025-06-15' });
364
- // Meeting has transcripts but VTT download failed still shows meeting without preview
509
+ // Meeting has transcripts — listed without VTT preview
365
510
  expect(result).toContain('1 with transcripts');
366
511
  expect(result).toContain('Server Error Meeting');
367
- expect(mockFetch).toHaveBeenCalledTimes(1); // No beta retry for 500
512
+ // List mode no longer fetches VTT content
513
+ expect(mockFetch).not.toHaveBeenCalled();
368
514
  });
369
515
  it('skips meeting when both v1.0 and beta transcript listing fail with non-retryable error', async () => {
370
516
  const threadId = '19:meeting_fail@thread.v2';
@@ -423,8 +569,6 @@ describe('executeTranscripts', () => {
423
569
  ok: true,
424
570
  data: { value: [{ id: 'tx-beta' }] },
425
571
  });
426
- // VTT content
427
- mockFetch.mockResolvedValueOnce(mockResponse('WEBVTT\n\nBeta content'));
428
572
  const result = await executeTranscripts('test-token', { date: '2025-06-15' });
429
573
  expect(result).toContain('1 with transcripts');
430
574
  expect(result).toContain('Beta Transcripts');
@@ -456,13 +600,110 @@ describe('executeTranscripts', () => {
456
600
  ok: true,
457
601
  data: { value: [{ id: 'tx-1' }] },
458
602
  });
459
- // VTT content
460
- mockFetch.mockResolvedValueOnce(mockResponse('WEBVTT\n\nOnline content'));
461
603
  const result = await executeTranscripts('test-token', { date: '2025-06-15' });
462
604
  // Only 1 meeting (with joinUrl) counted, 1 with transcripts
463
605
  expect(result).toContain('Found 1 meetings, 1 with transcripts.');
464
606
  expect(result).toContain('Online Meeting');
465
607
  expect(result).not.toContain('In-Person Meeting');
466
608
  });
609
+ it('matches correct transcript to each occurrence of a recurring meeting', async () => {
610
+ const threadId = '19:recurring_standup@thread.v2';
611
+ const oid = 'organizer-oid';
612
+ const joinUrl = makeJoinUrl(threadId, oid);
613
+ const meetingId = Buffer.from(`1*${oid}*0**${threadId}`).toString('base64');
614
+ // Calendar view returns two occurrences with the SAME join URL
615
+ mockGraphFetch.mockResolvedValueOnce({
616
+ ok: true,
617
+ data: {
618
+ value: [
619
+ {
620
+ subject: 'Weekly Standup',
621
+ start: { dateTime: '2025-06-15T10:00:00' },
622
+ end: { dateTime: '2025-06-15T10:30:00' },
623
+ onlineMeeting: { joinUrl },
624
+ },
625
+ {
626
+ subject: 'Weekly Standup',
627
+ start: { dateTime: '2025-06-22T10:00:00' },
628
+ end: { dateTime: '2025-06-22T10:30:00' },
629
+ onlineMeeting: { joinUrl },
630
+ },
631
+ ],
632
+ },
633
+ });
634
+ // Transcript list fetched ONCE (cached for second occurrence)
635
+ mockGraphFetch.mockResolvedValueOnce({
636
+ ok: true,
637
+ data: {
638
+ value: [
639
+ { id: 'tx-week1', createdDateTime: '2025-06-15T10:02:00Z' },
640
+ { id: 'tx-week2', createdDateTime: '2025-06-22T10:03:00Z' },
641
+ ],
642
+ },
643
+ });
644
+ const result = await executeTranscripts('test-token', {
645
+ start: '2025-06-14T00:00:00Z',
646
+ end: '2025-06-23T00:00:00Z',
647
+ });
648
+ // Both occurrences found with transcripts
649
+ expect(result).toContain('2 with transcripts');
650
+ // Each occurrence has its own distinct transcript ID
651
+ expect(result).toContain(`${meetingId}/tx-week1`);
652
+ expect(result).toContain(`${meetingId}/tx-week2`);
653
+ // List mode no longer fetches VTT content
654
+ expect(mockFetch).not.toHaveBeenCalled();
655
+ // Transcript list fetched only once (not twice) — cached for recurring
656
+ const transcriptCalls = mockGraphFetch.mock.calls.filter((call) => call[0].includes('/transcripts'));
657
+ expect(transcriptCalls).toHaveLength(1);
658
+ });
659
+ it('skips recurring occurrence that has no matching transcript', async () => {
660
+ const threadId = '19:recurring_weekly@thread.v2';
661
+ const oid = 'oid-recurring';
662
+ const joinUrl = makeJoinUrl(threadId, oid);
663
+ const meetingId = Buffer.from(`1*${oid}*0**${threadId}`).toString('base64');
664
+ // Three occurrences, but only two have transcripts
665
+ mockGraphFetch.mockResolvedValueOnce({
666
+ ok: true,
667
+ data: {
668
+ value: [
669
+ {
670
+ subject: 'Team Sync',
671
+ start: { dateTime: '2025-06-08T14:00:00' },
672
+ onlineMeeting: { joinUrl },
673
+ },
674
+ {
675
+ subject: 'Team Sync',
676
+ start: { dateTime: '2025-06-15T14:00:00' },
677
+ onlineMeeting: { joinUrl },
678
+ },
679
+ {
680
+ subject: 'Team Sync',
681
+ start: { dateTime: '2025-06-22T14:00:00' },
682
+ onlineMeeting: { joinUrl },
683
+ },
684
+ ],
685
+ },
686
+ });
687
+ // Only two transcripts exist (meeting on Jun 15 wasn't recorded)
688
+ mockGraphFetch.mockResolvedValueOnce({
689
+ ok: true,
690
+ data: {
691
+ value: [
692
+ { id: 'tx-jun8', createdDateTime: '2025-06-08T14:01:00Z' },
693
+ { id: 'tx-jun22', createdDateTime: '2025-06-22T14:02:00Z' },
694
+ ],
695
+ },
696
+ });
697
+ const result = await executeTranscripts('test-token', {
698
+ start: '2025-06-01T00:00:00Z',
699
+ end: '2025-06-30T00:00:00Z',
700
+ });
701
+ // 3 meetings found, but only 2 have matching transcripts
702
+ expect(result).toContain('Found 3 meetings, 2 with transcripts.');
703
+ expect(result).toContain(`${meetingId}/tx-jun8`);
704
+ expect(result).toContain(`${meetingId}/tx-jun22`);
705
+ // List mode no longer fetches VTT content
706
+ expect(mockFetch).not.toHaveBeenCalled();
707
+ });
467
708
  });
468
709
  });
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.5.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;
@@ -15,6 +15,11 @@ export declare const chatToolDefinition: {
15
15
  };
16
16
  };
17
17
  };
18
+ /**
19
+ * Converts Teams HTML message content to plain text.
20
+ * Handles <br>, <p>, <emoji alt="...">, <at>, <attachment>, and other tags.
21
+ */
22
+ export declare function stripHtml(html: string): string;
18
23
  /**
19
24
  * Fetches Teams chats or messages from a specific chat thread and returns
20
25
  * a human-readable summary.
@@ -11,10 +11,24 @@ export const chatToolDefinition = {
11
11
  },
12
12
  };
13
13
  /**
14
- * Strips HTML tags from a string.
14
+ * Converts Teams HTML message content to plain text.
15
+ * Handles <br>, <p>, <emoji alt="...">, <at>, <attachment>, and other tags.
15
16
  */
16
- function stripHtml(html) {
17
- return html.replace(/<[^>]*>/g, '').trim();
17
+ export function stripHtml(html) {
18
+ return html
19
+ .replace(/<br\s*\/?>/gi, '\n')
20
+ .replace(/<\/p>/gi, '\n')
21
+ .replace(/<emoji[^>]*alt="([^"]*)"[^>]*\/?>/gi, '$1')
22
+ .replace(/<attachment[^>]*>.*?<\/attachment>/gis, '')
23
+ .replace(/<[^>]*>/g, '')
24
+ .replace(/&nbsp;/g, ' ')
25
+ .replace(/&amp;/g, '&')
26
+ .replace(/&lt;/g, '<')
27
+ .replace(/&gt;/g, '>')
28
+ .replace(/&quot;/g, '"')
29
+ .replace(/&#39;/g, "'")
30
+ .replace(/\n{3,}/g, '\n\n')
31
+ .trim();
18
32
  }
19
33
  /**
20
34
  * Formats a chat message into a readable line.
@@ -37,7 +51,8 @@ function formatChatListing(chat) {
37
51
  lines.push(`## ${topic}`);
38
52
  lines.push(`Type: ${chat.chatType || 'unknown'}`);
39
53
  if (chat.lastMessagePreview) {
40
- const preview = chat.lastMessagePreview.body?.content || '(no preview)';
54
+ const rawPreview = chat.lastMessagePreview.body?.content || '(no preview)';
55
+ const preview = rawPreview === '(no preview)' ? rawPreview : stripHtml(rawPreview);
41
56
  const time = chat.lastMessagePreview.createdDateTime
42
57
  ? new Date(chat.lastMessagePreview.createdDateTime).toLocaleString()
43
58
  : '';
@@ -12,14 +12,23 @@ export declare const mailToolDefinition: {
12
12
  type: string;
13
13
  description: string;
14
14
  };
15
+ message_id: {
16
+ type: string;
17
+ description: string;
18
+ };
15
19
  };
16
20
  };
17
21
  };
18
22
  /**
19
23
  * Fetches recent emails from the user's mailbox with optional search filtering
20
24
  * and returns a human-readable summary.
25
+ *
26
+ * Supports two modes:
27
+ * - List mode: returns email summaries with previews and message IDs
28
+ * - Drill-down mode: when message_id is provided, returns the full email body
21
29
  */
22
30
  export declare function executeMail(token: string, args: {
23
31
  search?: string;
24
32
  count?: number;
33
+ message_id?: string;
25
34
  }): Promise<string>;