@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.
- package/dist/__tests__/auth.test.js +24 -3
- package/dist/__tests__/tools/chat.test.js +71 -1
- package/dist/__tests__/tools/mail.test.js +113 -2
- package/dist/__tests__/tools/server-info.test.d.ts +1 -0
- package/dist/__tests__/tools/server-info.test.js +60 -0
- package/dist/__tests__/tools/transcripts.test.js +291 -50
- package/dist/index.js +8 -2
- package/dist/lib/tools/chat.d.ts +5 -0
- package/dist/lib/tools/chat.js +19 -4
- package/dist/lib/tools/mail.d.ts +9 -0
- package/dist/lib/tools/mail.js +92 -4
- package/dist/lib/tools/server-info.d.ts +9 -0
- package/dist/lib/tools/server-info.js +53 -0
- package/dist/lib/tools/transcripts.d.ts +47 -1
- package/dist/lib/tools/transcripts.js +128 -29
- package/package.json +1 -1
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
//
|
|
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;
|
package/dist/lib/tools/chat.d.ts
CHANGED
|
@@ -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.
|
package/dist/lib/tools/chat.js
CHANGED
|
@@ -11,10 +11,24 @@ export const chatToolDefinition = {
|
|
|
11
11
|
},
|
|
12
12
|
};
|
|
13
13
|
/**
|
|
14
|
-
*
|
|
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
|
|
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(/ /g, ' ')
|
|
25
|
+
.replace(/&/g, '&')
|
|
26
|
+
.replace(/</g, '<')
|
|
27
|
+
.replace(/>/g, '>')
|
|
28
|
+
.replace(/"/g, '"')
|
|
29
|
+
.replace(/'/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
|
|
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
|
: '';
|
package/dist/lib/tools/mail.d.ts
CHANGED
|
@@ -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>;
|