@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.
- package/dist/__tests__/auth.test.js +24 -3
- package/dist/__tests__/tools/mail.test.js +14 -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 +238 -49
- package/dist/index.js +8 -2
- package/dist/lib/tools/mail.js +7 -3
- 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 +117 -27
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
386
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
//
|
|
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/mail.js
CHANGED
|
@@ -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
|
-
|
|
45
|
-
|
|
44
|
+
const select = 'subject,from,receivedDateTime,bodyPreview,isRead,importance';
|
|
45
|
+
let path;
|
|
46
46
|
if (args.search) {
|
|
47
|
-
|
|
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,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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
287
|
+
const meetingId = meetingIdMap.get(joinUrl);
|
|
188
288
|
if (!meetingId)
|
|
189
289
|
continue;
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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 =
|
|
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
|
-
|
|
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