msteams-mcp 0.15.0 → 0.16.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/README.md CHANGED
@@ -121,13 +121,14 @@ The server uses your system's Chrome (macOS/Linux) or Edge (Windows) for authent
121
121
 
122
122
  **Quick reactions:** `like`, `heart`, `laugh`, `surprised`, `sad`, `angry` can be used directly without searching.
123
123
 
124
- ### Calendar
124
+ ### Calendar & Meetings
125
125
 
126
126
  | Tool | Description |
127
127
  |------|-------------|
128
128
  | `teams_get_meetings` | Get meetings from calendar (defaults to next 7 days) |
129
+ | `teams_get_transcript` | Get meeting transcript (requires `threadId` from `teams_get_meetings`) |
129
130
 
130
- Returns: subject, times, organiser, join URL, `threadId` for meeting chat. Use with `teams_get_thread` to read meeting discussions.
131
+ `teams_get_meetings` returns: subject, times, organiser, join URL, `threadId` for meeting chat. Use `threadId` with `teams_get_thread` to read meeting chat, or with `teams_get_transcript` to get the full transcript with speakers and timestamps.
131
132
 
132
133
  ### Session
133
134
 
@@ -618,35 +618,38 @@ function parseContentWithMentionsAndLinks(content) {
618
618
  if (matches.length === 0) {
619
619
  return { html: markdownToTeamsHtml(content), mentions: [] };
620
620
  }
621
- // Build result with mentions/links as inline elements
622
- // Text segments between mentions/links get markdown conversion
621
+ // Strategy: replace mentions/links with unique placeholders, run the whole
622
+ // content through markdownToTeamsHtml (so links stay inline within their
623
+ // paragraph), then substitute placeholders back with actual HTML.
623
624
  const mentions = [];
624
- let result = '';
625
- let lastIndex = 0;
625
+ const placeholders = new Map();
626
626
  let mentionId = 0;
627
- for (const m of matches) {
628
- // Add markdown-converted text before this match
629
- const textBefore = content.substring(lastIndex, m.index);
630
- if (textBefore) {
631
- result += markdownToTeamsHtml(textBefore);
632
- }
627
+ // Build content with placeholders (process in reverse to preserve indices)
628
+ let placeholderContent = content;
629
+ for (let i = matches.length - 1; i >= 0; i--) {
630
+ const m = matches[i];
631
+ const placeholder = `\uE000MCP_PH_${i}\uE001`;
632
+ let html;
633
633
  if (m.type === 'mention') {
634
634
  mentions.push({ mri: m.target, displayName: m.text });
635
- result += buildMentionHtml(m.text, mentionId);
635
+ html = buildMentionHtml(m.text, mentionId);
636
636
  mentionId++;
637
637
  }
638
638
  else {
639
- // Link
640
639
  const safeText = escapeHtml(m.text);
641
640
  const safeUrl = m.target.replace(/"/g, '"');
642
- result += `<a href="${safeUrl}">${safeText}</a>`;
641
+ html = `<a href="${safeUrl}">${safeText}</a>`;
643
642
  }
644
- lastIndex = m.index + m.length;
645
- }
646
- // Add remaining text with markdown conversion
647
- const remaining = content.substring(lastIndex);
648
- if (remaining) {
649
- result += markdownToTeamsHtml(remaining);
643
+ placeholders.set(placeholder, html);
644
+ placeholderContent = placeholderContent.substring(0, m.index) + placeholder + placeholderContent.substring(m.index + m.length);
645
+ }
646
+ // Reverse mentions array since we processed in reverse order
647
+ mentions.reverse();
648
+ // Convert the whole content (with placeholders) through markdown pipeline
649
+ let result = markdownToTeamsHtml(placeholderContent);
650
+ // Substitute placeholders back with actual HTML
651
+ for (const [placeholder, html] of placeholders) {
652
+ result = result.replace(placeholder, html);
650
653
  }
651
654
  return { html: result, mentions };
652
655
  }
@@ -5,3 +5,4 @@ export * from './substrate-api.js';
5
5
  export * from './chatsvc-api.js';
6
6
  export * from './csa-api.js';
7
7
  export * from './calendar-api.js';
8
+ export * from './transcript-api.js';
package/dist/api/index.js CHANGED
@@ -5,3 +5,4 @@ export * from './substrate-api.js';
5
5
  export * from './chatsvc-api.js';
6
6
  export * from './csa-api.js';
7
7
  export * from './calendar-api.js';
8
+ export * from './transcript-api.js';
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Transcript API client for meeting transcript operations.
3
+ *
4
+ * Uses Substrate WorkingSetFiles API to fetch meeting transcripts.
5
+ * The transcript is embedded as JSON in the WorkingSetFiles response,
6
+ * using the same Substrate token already used for search.
7
+ *
8
+ * Flow: threadId → Substrate WorkingSetFiles (filter by MeetingThreadId) → parse TranscriptJson
9
+ */
10
+ import { type Result } from '../types/result.js';
11
+ import { type TranscriptEntry } from '../utils/parsers.js';
12
+ /** Result of fetching a transcript. */
13
+ export interface TranscriptResult {
14
+ /** Meeting title from the recording metadata. */
15
+ meetingTitle?: string;
16
+ /** Meeting thread ID used for the lookup. */
17
+ threadId: string;
18
+ /** When the recording started. */
19
+ recordingStartTime?: string;
20
+ /** When the recording ended. */
21
+ recordingEndTime?: string;
22
+ /** Parsed transcript entries with timestamps and speakers. */
23
+ entries: TranscriptEntry[];
24
+ /** Formatted readable transcript text. */
25
+ formattedText: string;
26
+ /** Number of transcript entries. */
27
+ entryCount: number;
28
+ /** List of unique speakers in the transcript. */
29
+ speakers: string[];
30
+ }
31
+ /**
32
+ * Gets the transcript for a meeting by its thread ID.
33
+ *
34
+ * Uses Substrate WorkingSetFiles to find the recording associated with the
35
+ * meeting thread, then extracts the embedded TranscriptJson.
36
+ *
37
+ * @param threadId - The meeting thread ID (e.g., "19:meeting_xxx@thread.v2")
38
+ * @param meetingDate - ISO date string of the meeting (used to narrow the search window)
39
+ * @returns Parsed transcript with entries and formatted text
40
+ */
41
+ export declare function getTranscriptContent(threadId: string, meetingDate?: string): Promise<Result<TranscriptResult>>;
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Transcript API client for meeting transcript operations.
3
+ *
4
+ * Uses Substrate WorkingSetFiles API to fetch meeting transcripts.
5
+ * The transcript is embedded as JSON in the WorkingSetFiles response,
6
+ * using the same Substrate token already used for search.
7
+ *
8
+ * Flow: threadId → Substrate WorkingSetFiles (filter by MeetingThreadId) → parse TranscriptJson
9
+ */
10
+ import { httpRequest } from '../utils/http.js';
11
+ import { ok, err } from '../types/result.js';
12
+ import { ErrorCode, createError } from '../types/errors.js';
13
+ import { requireSubstrateTokenAsync } from '../utils/auth-guards.js';
14
+ import { formatTranscriptText } from '../utils/parsers.js';
15
+ // ─────────────────────────────────────────────────────────────────────────────
16
+ // Constants
17
+ // ─────────────────────────────────────────────────────────────────────────────
18
+ /** Substrate WorkingSetFiles endpoint for finding meeting recordings + transcripts. */
19
+ const WORKING_SET_FILES_URL = 'https://substrate.office.com/api/beta/me/WorkingSetFiles/';
20
+ /** Fields to select from WorkingSetFiles. */
21
+ const WORKING_SET_SELECT = [
22
+ 'SharePointItem',
23
+ 'Visualization',
24
+ 'ItemProperties/Default/MeetingCallId',
25
+ 'ItemProperties/Default/DriveId',
26
+ 'ItemProperties/Default/RecordingStartDateTime',
27
+ 'ItemProperties/Default/RecordingEndDateTime',
28
+ 'ItemProperties/Default/TranscriptJson',
29
+ 'ItemProperties/Default/DocumentLink',
30
+ ].join(',');
31
+ // ─────────────────────────────────────────────────────────────────────────────
32
+ // API Functions
33
+ // ─────────────────────────────────────────────────────────────────────────────
34
+ /**
35
+ * Gets the transcript for a meeting by its thread ID.
36
+ *
37
+ * Uses Substrate WorkingSetFiles to find the recording associated with the
38
+ * meeting thread, then extracts the embedded TranscriptJson.
39
+ *
40
+ * @param threadId - The meeting thread ID (e.g., "19:meeting_xxx@thread.v2")
41
+ * @param meetingDate - ISO date string of the meeting (used to narrow the search window)
42
+ * @returns Parsed transcript with entries and formatted text
43
+ */
44
+ export async function getTranscriptContent(threadId, meetingDate) {
45
+ const authResult = await requireSubstrateTokenAsync();
46
+ if (!authResult.ok)
47
+ return authResult;
48
+ const token = authResult.value;
49
+ // Build date filter: search ±1 day around the meeting date, or last 30 days
50
+ let dateFilter = '';
51
+ if (meetingDate) {
52
+ const date = new Date(meetingDate);
53
+ const dayBefore = new Date(date);
54
+ dayBefore.setDate(dayBefore.getDate() - 1);
55
+ const dayAfter = new Date(date);
56
+ dayAfter.setDate(dayAfter.getDate() + 1);
57
+ dateFilter = ` AND FileCreatedTime gt ${dayBefore.toISOString()} AND FileCreatedTime lt ${dayAfter.toISOString()}`;
58
+ }
59
+ const filter = `ItemProperties/Default/MeetingThreadId eq '${threadId}'${dateFilter}`;
60
+ const url = `${WORKING_SET_FILES_URL}?$filter=${encodeURIComponent(filter)}&$orderby=${encodeURIComponent('FileCreatedTime desc')}&$select=${encodeURIComponent(WORKING_SET_SELECT)}`;
61
+ const response = await httpRequest(url, {
62
+ method: 'GET',
63
+ headers: {
64
+ 'Authorization': `Bearer ${token}`,
65
+ 'Accept': 'application/json',
66
+ 'Content-Type': 'application/json',
67
+ 'Prefer': 'substrate.flexibleschema,outlook.data-source="Substrate",exchange.behavior="SubstrateFiles"',
68
+ },
69
+ });
70
+ if (!response.ok) {
71
+ return response;
72
+ }
73
+ const data = response.value.data;
74
+ const items = data.value;
75
+ if (!items || items.length === 0) {
76
+ return err(createError(ErrorCode.NOT_FOUND, 'No recording found for this meeting. Transcription may not have been enabled, or the recording has not finished processing.', { suggestions: [
77
+ 'Check that transcription/recording was enabled during the meeting',
78
+ 'Wait a few minutes if the meeting just ended',
79
+ 'The meeting organiser must have recording enabled',
80
+ ] }));
81
+ }
82
+ const item = items[0];
83
+ const props = item.ItemProperties?.Default;
84
+ const viz = item.Visualization;
85
+ // Extract TranscriptJson
86
+ const transcriptJsonStr = props?.TranscriptJson;
87
+ if (!transcriptJsonStr) {
88
+ return err(createError(ErrorCode.NOT_FOUND, 'Recording found but no transcript available. Transcription may not have been enabled for this meeting.', { suggestions: ['Check that transcription was enabled during the meeting'] }));
89
+ }
90
+ // Parse the transcript JSON
91
+ let transcriptData;
92
+ try {
93
+ transcriptData = JSON.parse(transcriptJsonStr);
94
+ }
95
+ catch {
96
+ return err(createError(ErrorCode.UNKNOWN, 'Failed to parse transcript data.'));
97
+ }
98
+ const rawEntries = transcriptData.entries || [];
99
+ if (rawEntries.length === 0) {
100
+ return err(createError(ErrorCode.NOT_FOUND, 'Transcript is empty — no speech was detected during the meeting.'));
101
+ }
102
+ // Map to TranscriptEntry format
103
+ const entries = rawEntries.map(e => ({
104
+ startTime: (e.startOffset || '').replace(/0{4}$/, ''),
105
+ endTime: (e.endOffset || '').replace(/0{4}$/, ''),
106
+ speaker: e.speakerDisplayName || '',
107
+ text: e.text || '',
108
+ }));
109
+ const formattedText = formatTranscriptText(entries);
110
+ const speakers = [...new Set(entries.map(e => e.speaker).filter(s => s.length > 0))];
111
+ return ok({
112
+ meetingTitle: viz?.Title,
113
+ threadId,
114
+ recordingStartTime: props?.RecordingStartDateTime,
115
+ recordingEndTime: props?.RecordingEndDateTime,
116
+ entries,
117
+ formattedText,
118
+ entryCount: entries.length,
119
+ speakers,
120
+ });
121
+ }
@@ -4,7 +4,6 @@
4
4
  */
5
5
  import { saveSessionState } from './context.js';
6
6
  import { OVERLAY_STEP_PAUSE_MS, OVERLAY_COMPLETE_PAUSE_MS, } from '../constants.js';
7
- import { extractSubstrateToken, clearTokenCache } from '../auth/token-extractor.js';
8
7
  /**
9
8
  * Default Teams URL for initial login.
10
9
  *
@@ -285,18 +284,61 @@ async function hasAuthenticatedContent(page) {
285
284
  }
286
285
  return false;
287
286
  }
287
+ /**
288
+ * Waits for the Teams SPA to be fully loaded and interactive.
289
+ *
290
+ * Teams is a heavy SPA — after navigation, the shell loads quickly but MSAL
291
+ * (which manages tokens) needs time to bootstrap. We wait for key UI elements
292
+ * that only render after the app has fully initialised, then wait for network
293
+ * activity to settle (indicating MSAL token requests have completed).
294
+ */
295
+ async function waitForTeamsReady(page, log, timeoutMs = 60000) {
296
+ log('Waiting for Teams to fully load...');
297
+ try {
298
+ // Wait for any SPA UI element that indicates the app has bootstrapped
299
+ await page.waitForSelector(AUTH_SUCCESS_SELECTORS.join(', '), { timeout: timeoutMs });
300
+ log('Teams UI elements detected.');
301
+ // Wait for network to settle — MSAL token refresh requests should complete
302
+ try {
303
+ await page.waitForLoadState('networkidle', { timeout: 15000 });
304
+ log('Network settled.');
305
+ }
306
+ catch {
307
+ // Network may not become fully idle (websockets, polling), that's OK
308
+ log('Network did not fully settle, continuing...');
309
+ }
310
+ return true;
311
+ }
312
+ catch {
313
+ log('Teams did not fully load within timeout.');
314
+ return false;
315
+ }
316
+ }
288
317
  /**
289
318
  * Triggers MSAL to acquire the Substrate token.
290
319
  *
291
320
  * MSAL only acquires tokens for specific scopes when the app makes API calls
292
321
  * requiring those scopes. The Substrate API is only used for search, so we
293
- * perform a minimal search ("is:Messages") to trigger token acquisition.
322
+ * perform a search to trigger token acquisition.
323
+ *
324
+ * Returns true if a Substrate API call was detected, false otherwise.
294
325
  */
295
326
  async function triggerTokenAcquisition(page, log) {
296
327
  log('Triggering token acquisition...');
297
328
  try {
298
- // Wait for the app to be ready
299
- await page.waitForTimeout(5000);
329
+ // Wait for Teams SPA to be fully loaded (MSAL must bootstrap first)
330
+ const ready = await waitForTeamsReady(page, log);
331
+ if (!ready) {
332
+ log('Teams did not load — cannot trigger token acquisition.');
333
+ return false;
334
+ }
335
+ // Set up Substrate API listener BEFORE triggering search
336
+ let substrateDetected = false;
337
+ const substratePromise = page.waitForResponse(resp => resp.url().includes('substrate.office.com') && resp.status() === 200, { timeout: 30000 }).then(() => {
338
+ substrateDetected = true;
339
+ }).catch(() => {
340
+ // Timeout — no Substrate call detected
341
+ });
300
342
  // Try multiple methods to trigger search
301
343
  let searchTriggered = false;
302
344
  // Method 1: Navigate to search results URL (triggers Substrate API call directly)
@@ -306,7 +348,6 @@ async function triggerTokenAcquisition(page, log) {
306
348
  waitUntil: 'domcontentloaded',
307
349
  timeout: 30000,
308
350
  });
309
- await page.waitForTimeout(5000);
310
351
  searchTriggered = true;
311
352
  log('Search results page loaded.');
312
353
  }
@@ -355,28 +396,31 @@ async function triggerTokenAcquisition(page, log) {
355
396
  await page.keyboard.press('Enter');
356
397
  searchTriggered = true;
357
398
  }
358
- // Wait for the Substrate search API call to complete
359
- log('Waiting for search API...');
360
- try {
361
- // Wait for the actual API request to substrate.office.com
362
- await Promise.race([
363
- page.waitForResponse(resp => resp.url().includes('substrate.office.com') && resp.status() === 200, { timeout: 15000 }),
364
- page.waitForTimeout(15000),
365
- ]);
366
- log('Substrate API call detected.');
399
+ // Wait for the Substrate API response
400
+ log('Waiting for Substrate API...');
401
+ await substratePromise;
402
+ if (substrateDetected) {
403
+ log('Substrate API call detected — tokens acquired.');
367
404
  }
368
- catch {
369
- log('No Substrate API call detected, continuing...');
405
+ else {
406
+ log('No Substrate API call detected within timeout.');
370
407
  }
408
+ // Give MSAL a moment to persist tokens to localStorage
371
409
  await page.waitForTimeout(2000);
372
410
  // Close search and reset
373
- await page.keyboard.press('Escape');
374
- await page.waitForTimeout(1000);
411
+ try {
412
+ await page.keyboard.press('Escape');
413
+ await page.waitForTimeout(500);
414
+ }
415
+ catch {
416
+ // Page may have navigated, ignore
417
+ }
375
418
  log('Token acquisition complete.');
419
+ return substrateDetected;
376
420
  }
377
421
  catch (error) {
378
422
  log(`Token acquisition warning: ${error instanceof Error ? error.message : String(error)}`);
379
- await page.waitForTimeout(3000);
423
+ return false;
380
424
  }
381
425
  }
382
426
  /**
@@ -517,23 +561,20 @@ export async function waitForManualLogin(page, context, timeoutMs = 5 * 60 * 100
517
561
  await showLoginProgress(page, 'acquiring');
518
562
  }
519
563
  // Trigger a search to cause MSAL to acquire the Substrate token
520
- await triggerTokenAcquisition(page, log);
564
+ const acquired = await triggerTokenAcquisition(page, log);
565
+ if (!acquired) {
566
+ log('Warning: Substrate API call was not detected after login.');
567
+ if (showOverlay) {
568
+ await showLoginProgress(page, 'error', { pause: true });
569
+ }
570
+ throw new Error('Login completed but token acquisition failed. Please try again.');
571
+ }
521
572
  if (showOverlay) {
522
573
  await showLoginProgress(page, 'saving');
523
574
  }
524
575
  // Save the session state with fresh tokens
525
576
  await saveSessionState(context);
526
- clearTokenCache();
527
577
  log('Session state saved.');
528
- // Verify tokens were actually acquired
529
- const token = extractSubstrateToken();
530
- if (!token || token.expiry.getTime() <= Date.now()) {
531
- log('Warning: No valid Substrate token found after login. You may need to try again.');
532
- if (showOverlay) {
533
- await showLoginProgress(page, 'error', { pause: true });
534
- }
535
- throw new Error('Login completed but token acquisition failed. Please try again.');
536
- }
537
578
  if (showOverlay) {
538
579
  await showLoginProgress(page, 'complete', { pause: true });
539
580
  }
@@ -566,39 +607,23 @@ export async function ensureAuthenticated(page, context, onProgress, showOverlay
566
607
  const status = await navigateToTeams(page);
567
608
  if (status.isAuthenticated) {
568
609
  log('Already authenticated.');
569
- // Snapshot token expiry before refresh attempt
570
- const beforeToken = extractSubstrateToken();
571
- const beforeExpiry = beforeToken?.expiry?.getTime() ?? 0;
572
610
  if (showOverlay) {
573
611
  await showLoginProgress(page, 'refreshing');
574
612
  }
575
613
  // Trigger a search to cause MSAL to acquire/refresh the Substrate token
576
- await triggerTokenAcquisition(page, log);
614
+ const acquired = await triggerTokenAcquisition(page, log);
615
+ if (!acquired) {
616
+ log('Token refresh failed: Substrate API call was not detected.');
617
+ if (showOverlay) {
618
+ await showLoginProgress(page, 'error', { pause: true });
619
+ }
620
+ throw new Error('Token refresh failed. Please use teams_login with forceNew to re-authenticate.');
621
+ }
577
622
  if (showOverlay) {
578
623
  await showLoginProgress(page, 'saving');
579
624
  }
580
625
  // Save the session state with fresh tokens
581
626
  await saveSessionState(context);
582
- // Clear token cache so we re-extract from the freshly saved session
583
- clearTokenCache();
584
- // Verify tokens were actually refreshed
585
- const afterToken = extractSubstrateToken();
586
- const afterExpiry = afterToken?.expiry?.getTime() ?? 0;
587
- const tokenIsValid = afterToken !== null && afterExpiry > Date.now();
588
- const tokenWasRefreshed = afterExpiry > beforeExpiry;
589
- if (!tokenIsValid) {
590
- log('Warning: No valid token found after refresh attempt.');
591
- if (headless) {
592
- throw new Error('Token refresh failed: no valid token after SSO. Browser-based re-authentication required.');
593
- }
594
- }
595
- else if (!tokenWasRefreshed && beforeToken && beforeExpiry <= Date.now() + 10 * 60 * 1000) {
596
- // Token was close to expiry but wasn't refreshed - MSAL likely didn't initialise in time
597
- log('Warning: Token was not refreshed despite being close to expiry.');
598
- if (headless) {
599
- throw new Error('Token refresh failed: token not refreshed despite being close to expiry. Browser-based re-authentication required.');
600
- }
601
- }
602
627
  if (showOverlay) {
603
628
  await showLoginProgress(page, 'complete', { pause: true });
604
629
  }
@@ -11,18 +11,14 @@
11
11
  import { chromium } from 'playwright';
12
12
  import * as fs from 'fs';
13
13
  import * as path from 'path';
14
- import { fileURLToPath } from 'url';
15
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
- const PROJECT_ROOT = path.resolve(__dirname, '../..');
17
- const USER_DATA_DIR = path.join(PROJECT_ROOT, '.user-data');
18
- const SESSION_STATE_PATH = path.join(PROJECT_ROOT, 'session-state.json');
14
+ import { PROJECT_ROOT, USER_DATA_DIR, readSessionState, writeSessionState, } from '../auth/session-store.js';
19
15
  const FINDINGS_PATH = path.join(PROJECT_ROOT, 'research-findings.json');
20
16
  // Track interesting API calls
21
17
  const capturedNetworkData = {
22
18
  requests: [],
23
19
  responses: [],
24
20
  };
25
- // Patterns that indicate search-related API calls
21
+ // Patterns that indicate interesting API calls
26
22
  const SEARCH_PATTERNS = [
27
23
  /search/i,
28
24
  /query/i,
@@ -32,6 +28,13 @@ const SEARCH_PATTERNS = [
32
28
  /teams.*api/i,
33
29
  /chatservice/i,
34
30
  /emea\.ng\.msg/i,
31
+ /transcript/i,
32
+ /recording/i,
33
+ /onlineMeeting/i,
34
+ /callRecord/i,
35
+ /\.vtt/i,
36
+ /asyncgw/i,
37
+ /mediaservice/i,
35
38
  ];
36
39
  function isInterestingUrl(url) {
37
40
  // Skip static assets, telemetry, and auth-related noise
@@ -90,7 +93,7 @@ async function captureResponse(response) {
90
93
  let body;
91
94
  try {
92
95
  const contentType = response.headers()['content-type'] || '';
93
- if (contentType.includes('application/json')) {
96
+ if (contentType.includes('application/json') || contentType.includes('text/vtt') || contentType.includes('text/plain')) {
94
97
  body = await response.text();
95
98
  }
96
99
  }
@@ -190,15 +193,17 @@ async function main() {
190
193
  let context;
191
194
  try {
192
195
  // Launch browser with persistent context
196
+ const channel = process.platform === 'win32' ? 'msedge' : 'chrome';
193
197
  browser = await chromium.launch({
194
198
  headless: false, // Must be visible for manual login
199
+ channel,
195
200
  });
196
- // Check if we have saved session state
197
- const hasSessionState = fs.existsSync(SESSION_STATE_PATH);
198
- if (hasSessionState) {
201
+ // Check if we have saved session state (uses encrypted store)
202
+ const existingState = readSessionState();
203
+ if (existingState) {
199
204
  console.log('📂 Found existing session state, attempting to restore...');
200
205
  context = await browser.newContext({
201
- storageState: SESSION_STATE_PATH,
206
+ storageState: existingState,
202
207
  viewport: { width: 1280, height: 800 },
203
208
  });
204
209
  }
@@ -221,7 +226,8 @@ async function main() {
221
226
  await waitForAuthentication(page);
222
227
  // Save session state after successful authentication
223
228
  console.log('💾 Saving session state...');
224
- await context.storageState({ path: SESSION_STATE_PATH });
229
+ const state = await context.storageState();
230
+ writeSessionState(state);
225
231
  console.log('✅ Session state saved!');
226
232
  }
227
233
  else {
@@ -246,23 +252,34 @@ async function main() {
246
252
  resolve();
247
253
  });
248
254
  });
249
- // Save session state before closing
250
- console.log('💾 Saving final session state...');
251
- await context.storageState({ path: SESSION_STATE_PATH });
252
- // Save research findings
255
+ // Save research findings FIRST (before browser might close)
253
256
  await saveFindings();
257
+ // Try to save session state (may fail if browser already closed)
258
+ try {
259
+ console.log('💾 Saving final session state...');
260
+ const finalState = await context.storageState();
261
+ writeSessionState(finalState);
262
+ }
263
+ catch {
264
+ console.log('⚠️ Could not save session state (browser already closed)');
265
+ }
254
266
  }
255
267
  catch (error) {
268
+ // Still try to save findings on error
269
+ await saveFindings();
256
270
  console.error('❌ Error:', error);
257
- throw error;
258
271
  }
259
272
  finally {
260
- if (context) {
261
- await context.close();
273
+ try {
274
+ if (context)
275
+ await context.close();
262
276
  }
263
- if (browser) {
264
- await browser.close();
277
+ catch { /* already closed */ }
278
+ try {
279
+ if (browser)
280
+ await browser.close();
265
281
  }
282
+ catch { /* already closed */ }
266
283
  }
267
284
  console.log('\n✅ Research session complete!');
268
285
  console.log(` Review findings in: ${FINDINGS_PATH}`);
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Quick diagnostic: dump all MSAL token targets from the session.
4
+ * Run: npm run build && node dist/test/dump-tokens.js
5
+ * Or: npx tsx src/test/dump-tokens.ts
6
+ */
7
+ export {};
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Quick diagnostic: dump all MSAL token targets from the session.
4
+ * Run: npm run build && node dist/test/dump-tokens.js
5
+ * Or: npx tsx src/test/dump-tokens.ts
6
+ */
7
+ import { readSessionState, getTeamsOrigin } from '../auth/session-store.js';
8
+ function decodeJwtPayload(token) {
9
+ try {
10
+ const parts = token.split('.');
11
+ if (parts.length < 2)
12
+ return null;
13
+ return JSON.parse(Buffer.from(parts[1], 'base64').toString());
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ }
19
+ const sessionState = readSessionState();
20
+ if (!sessionState) {
21
+ console.log('No session state found. Run teams_login first.');
22
+ process.exit(1);
23
+ }
24
+ const teamsOrigin = getTeamsOrigin(sessionState);
25
+ const localStorage = teamsOrigin?.localStorage ?? [];
26
+ console.log(`Found ${localStorage.length} localStorage entries\n`);
27
+ // Collect all tokens with their targets
28
+ const tokens = [];
29
+ for (const item of localStorage) {
30
+ try {
31
+ const entry = JSON.parse(item.value);
32
+ const target = entry.target;
33
+ const secret = entry.secret;
34
+ if (!target || !secret || !secret.startsWith('ey'))
35
+ continue;
36
+ const payload = decodeJwtPayload(secret);
37
+ if (!payload?.exp || typeof payload.exp !== 'number')
38
+ continue;
39
+ const expiry = new Date(payload.exp * 1000);
40
+ const minutesLeft = Math.round((expiry.getTime() - Date.now()) / 1000 / 60);
41
+ // Extract scopes from the token's scp claim
42
+ const scp = payload.scp;
43
+ const scopes = scp ? scp.split(' ') : [];
44
+ tokens.push({
45
+ target,
46
+ scopes,
47
+ expiry: expiry.toISOString(),
48
+ minutesLeft,
49
+ hasGraph: target.includes('graph.microsoft.com'),
50
+ });
51
+ }
52
+ catch {
53
+ continue;
54
+ }
55
+ }
56
+ console.log('=== ALL MSAL TOKENS ===\n');
57
+ for (const t of tokens) {
58
+ const status = t.minutesLeft > 0 ? `✅ ${t.minutesLeft}m left` : '❌ EXPIRED';
59
+ console.log(`Target: ${t.target}`);
60
+ console.log(`Status: ${status}`);
61
+ if (t.scopes.length > 0) {
62
+ console.log(`Scopes: ${t.scopes.join(', ')}`);
63
+ }
64
+ console.log();
65
+ }
66
+ // Highlight Graph tokens
67
+ const graphTokens = tokens.filter(t => t.hasGraph);
68
+ console.log('=== GRAPH TOKENS ===\n');
69
+ if (graphTokens.length === 0) {
70
+ console.log('⚠️ No graph.microsoft.com tokens found in session.');
71
+ console.log(' The Teams web app may not store Graph tokens in localStorage.');
72
+ console.log(' Transcript API will need to use the middle tier instead.');
73
+ }
74
+ else {
75
+ for (const t of graphTokens) {
76
+ console.log(`Target: ${t.target}`);
77
+ console.log(`Scopes: ${t.scopes.join(', ')}`);
78
+ console.log(`Expiry: ${t.expiry} (${t.minutesLeft}m left)`);
79
+ const hasTranscriptScope = t.scopes.some(s => s.toLowerCase().includes('transcript') ||
80
+ s.toLowerCase().includes('onlinemeeting'));
81
+ if (hasTranscriptScope) {
82
+ console.log('✅ Has transcript-related scopes!');
83
+ }
84
+ else {
85
+ console.log('⚠️ No transcript-specific scopes found in this token.');
86
+ console.log(' May still work if the first-party Teams app has implicit access.');
87
+ }
88
+ console.log();
89
+ }
90
+ }
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Test the transcript API end-to-end.
4
+ * Run: npx tsx src/test/test-transcript.ts
5
+ */
6
+ export {};
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Test the transcript API end-to-end.
4
+ * Run: npx tsx src/test/test-transcript.ts
5
+ */
6
+ import { getCalendarView } from '../api/calendar-api.js';
7
+ import { getTranscriptContent } from '../api/transcript-api.js';
8
+ async function main() {
9
+ console.log('=== Get recent meetings with threadId ===');
10
+ const now = new Date();
11
+ const pastWeek = new Date(now);
12
+ pastWeek.setDate(pastWeek.getDate() - 14);
13
+ const meetingsResult = await getCalendarView({
14
+ startDate: pastWeek.toISOString(),
15
+ endDate: now.toISOString(),
16
+ limit: 50,
17
+ });
18
+ if (!meetingsResult.ok) {
19
+ console.log(`❌ ${meetingsResult.error.message}`);
20
+ process.exit(1);
21
+ }
22
+ const meetings = meetingsResult.value.meetings.filter(m => m.threadId);
23
+ console.log(`Found ${meetings.length} meetings with threadId\n`);
24
+ // Try each meeting until we find one with a transcript
25
+ for (const meeting of meetings.slice(0, 10)) {
26
+ console.log(`--- "${meeting.subject}" (${meeting.startTime}) ---`);
27
+ console.log(`Thread: ${meeting.threadId}`);
28
+ const result = await getTranscriptContent(meeting.threadId, meeting.startTime);
29
+ if (result.ok) {
30
+ console.log(`\n✅ SUCCESS!`);
31
+ console.log(`Title: ${result.value.meetingTitle}`);
32
+ console.log(`Speakers: ${result.value.speakers.join(', ')}`);
33
+ console.log(`Entries: ${result.value.entryCount}`);
34
+ console.log(`Recording: ${result.value.recordingStartTime} → ${result.value.recordingEndTime}`);
35
+ console.log(`\nFirst 800 chars:\n${result.value.formattedText.substring(0, 800)}`);
36
+ process.exit(0);
37
+ }
38
+ else {
39
+ console.log(` ❌ ${result.error.code}: ${result.error.message}\n`);
40
+ }
41
+ }
42
+ console.log('No transcripts found for any recent meeting.');
43
+ }
44
+ main().catch(e => {
45
+ console.error('Fatal error:', e);
46
+ process.exit(1);
47
+ });
@@ -16,9 +16,20 @@ export declare const GetMeetingsInputSchema: z.ZodObject<{
16
16
  startDate?: string | undefined;
17
17
  endDate?: string | undefined;
18
18
  }>;
19
+ export declare const GetTranscriptInputSchema: z.ZodObject<{
20
+ threadId: z.ZodString;
21
+ meetingDate: z.ZodOptional<z.ZodString>;
22
+ }, "strip", z.ZodTypeAny, {
23
+ threadId: string;
24
+ meetingDate?: string | undefined;
25
+ }, {
26
+ threadId: string;
27
+ meetingDate?: string | undefined;
28
+ }>;
19
29
  export declare const getMeetingsTool: RegisteredTool<typeof GetMeetingsInputSchema>;
30
+ export declare const getTranscriptTool: RegisteredTool<typeof GetTranscriptInputSchema>;
20
31
  /** All meeting-related tools. */
21
- export declare const meetingTools: RegisteredTool<z.ZodObject<{
32
+ export declare const meetingTools: (RegisteredTool<z.ZodObject<{
22
33
  startDate: z.ZodOptional<z.ZodString>;
23
34
  endDate: z.ZodOptional<z.ZodString>;
24
35
  limit: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
@@ -30,4 +41,13 @@ export declare const meetingTools: RegisteredTool<z.ZodObject<{
30
41
  limit?: number | undefined;
31
42
  startDate?: string | undefined;
32
43
  endDate?: string | undefined;
33
- }>>[];
44
+ }>> | RegisteredTool<z.ZodObject<{
45
+ threadId: z.ZodString;
46
+ meetingDate: z.ZodOptional<z.ZodString>;
47
+ }, "strip", z.ZodTypeAny, {
48
+ threadId: string;
49
+ meetingDate?: string | undefined;
50
+ }, {
51
+ threadId: string;
52
+ meetingDate?: string | undefined;
53
+ }>>)[];
@@ -4,6 +4,7 @@
4
4
  import { z } from 'zod';
5
5
  import { handleApiResult } from './index.js';
6
6
  import { getCalendarView } from '../api/calendar-api.js';
7
+ import { getTranscriptContent } from '../api/transcript-api.js';
7
8
  import { DEFAULT_MEETING_LIMIT, MAX_MEETING_LIMIT, } from '../constants.js';
8
9
  // ─────────────────────────────────────────────────────────────────────────────
9
10
  // Schemas
@@ -13,6 +14,10 @@ export const GetMeetingsInputSchema = z.object({
13
14
  endDate: z.string().optional(),
14
15
  limit: z.number().min(1).max(MAX_MEETING_LIMIT).optional().default(DEFAULT_MEETING_LIMIT),
15
16
  });
17
+ export const GetTranscriptInputSchema = z.object({
18
+ threadId: z.string(),
19
+ meetingDate: z.string().optional(),
20
+ });
16
21
  // ─────────────────────────────────────────────────────────────────────────────
17
22
  // Tool Definitions
18
23
  // ─────────────────────────────────────────────────────────────────────────────
@@ -53,6 +58,36 @@ async function handleGetMeetings(input, _ctx) {
53
58
  }));
54
59
  }
55
60
  // ─────────────────────────────────────────────────────────────────────────────
61
+ // Transcript Tool
62
+ // ─────────────────────────────────────────────────────────────────────────────
63
+ const getTranscriptToolDefinition = {
64
+ name: 'teams_get_transcript',
65
+ description: 'Get the transcript of a Teams meeting. Requires the meeting\'s threadId (from teams_get_meetings). Returns the full transcript with timestamps and speakers, formatted as readable text. The meeting must have had transcription enabled. Optionally pass meetingDate (ISO string, e.g. the startTime from teams_get_meetings) to narrow the search.',
66
+ inputSchema: {
67
+ type: 'object',
68
+ properties: {
69
+ threadId: {
70
+ type: 'string',
71
+ description: 'The meeting thread ID (from the threadId field of teams_get_meetings results, e.g., "19:meeting_xxx@thread.v2").',
72
+ },
73
+ meetingDate: {
74
+ type: 'string',
75
+ description: 'Optional ISO date/time of the meeting (e.g., the startTime from teams_get_meetings). Helps narrow the search for recurring meetings.',
76
+ },
77
+ },
78
+ required: ['threadId'],
79
+ },
80
+ };
81
+ async function handleGetTranscript(input, _ctx) {
82
+ const result = await getTranscriptContent(input.threadId, input.meetingDate);
83
+ return handleApiResult(result, (value) => ({
84
+ meetingTitle: value.meetingTitle,
85
+ speakers: value.speakers,
86
+ entryCount: value.entryCount,
87
+ transcript: value.formattedText,
88
+ }));
89
+ }
90
+ // ─────────────────────────────────────────────────────────────────────────────
56
91
  // Exports
57
92
  // ─────────────────────────────────────────────────────────────────────────────
58
93
  export const getMeetingsTool = {
@@ -60,5 +95,10 @@ export const getMeetingsTool = {
60
95
  schema: GetMeetingsInputSchema,
61
96
  handler: handleGetMeetings,
62
97
  };
98
+ export const getTranscriptTool = {
99
+ definition: getTranscriptToolDefinition,
100
+ schema: GetTranscriptInputSchema,
101
+ handler: handleGetTranscript,
102
+ };
63
103
  /** All meeting-related tools. */
64
- export const meetingTools = [getMeetingsTool];
104
+ export const meetingTools = [getMeetingsTool, getTranscriptTool];
@@ -266,3 +266,24 @@ export interface VirtualConversationItem {
266
266
  * @returns Parsed virtual conversation item, or null if message should be skipped
267
267
  */
268
268
  export declare function parseVirtualConversationMessage(msg: Record<string, unknown>, referencePattern: RegExp): VirtualConversationItem | null;
269
+ /** A single entry from a meeting transcript. */
270
+ export interface TranscriptEntry {
271
+ /** Start time (e.g., "00:00:22.287"). */
272
+ startTime: string;
273
+ /** End time (e.g., "00:00:23.167"). */
274
+ endTime: string;
275
+ /** Speaker display name. */
276
+ speaker: string;
277
+ /** Spoken text content. */
278
+ text: string;
279
+ }
280
+ /**
281
+ * Formats transcript entries into a readable text format.
282
+ *
283
+ * Merges consecutive entries from the same speaker into a single block
284
+ * to reduce noise and improve readability.
285
+ *
286
+ * @param entries - Transcript entries
287
+ * @returns Formatted transcript string
288
+ */
289
+ export declare function formatTranscriptText(entries: TranscriptEntry[]): string;
@@ -878,3 +878,45 @@ export function parseVirtualConversationMessage(msg, referencePattern) {
878
878
  links: links.length > 0 ? links : undefined,
879
879
  };
880
880
  }
881
+ /**
882
+ * Formats transcript entries into a readable text format.
883
+ *
884
+ * Merges consecutive entries from the same speaker into a single block
885
+ * to reduce noise and improve readability.
886
+ *
887
+ * @param entries - Transcript entries
888
+ * @returns Formatted transcript string
889
+ */
890
+ export function formatTranscriptText(entries) {
891
+ if (entries.length === 0)
892
+ return '';
893
+ const blocks = [];
894
+ let currentSpeaker = null;
895
+ let currentTexts = [];
896
+ let blockStartTime = '';
897
+ for (const entry of entries) {
898
+ if (entry.speaker !== currentSpeaker) {
899
+ // Flush previous block
900
+ if (currentTexts.length > 0) {
901
+ const prefix = currentSpeaker
902
+ ? `[${blockStartTime}] ${currentSpeaker}:`
903
+ : `[${blockStartTime}]`;
904
+ blocks.push(`${prefix}\n${currentTexts.join(' ')}`);
905
+ }
906
+ currentSpeaker = entry.speaker;
907
+ currentTexts = [entry.text];
908
+ blockStartTime = entry.startTime;
909
+ }
910
+ else {
911
+ currentTexts.push(entry.text);
912
+ }
913
+ }
914
+ // Flush last block
915
+ if (currentTexts.length > 0) {
916
+ const prefix = currentSpeaker
917
+ ? `[${blockStartTime}] ${currentSpeaker}:`
918
+ : `[${blockStartTime}]`;
919
+ blocks.push(`${prefix}\n${currentTexts.join(' ')}`);
920
+ }
921
+ return blocks.join('\n\n');
922
+ }
@@ -5,7 +5,7 @@
5
5
  * produce expected outputs regardless of internal logic.
6
6
  */
7
7
  import { describe, it, expect } from 'vitest';
8
- import { stripHtml, extractLinks, buildMessageLink, getConversationType, extractMessageTimestamp, parsePersonSuggestion, parseV2Result, parseJwtProfile, calculateTokenStatus, parseSearchResults, parsePeopleResults, extractObjectId, buildOneOnOneConversationId, decodeBase64Guid, extractActivityTimestamp, markdownToTeamsHtml, } from './parsers.js';
8
+ import { stripHtml, extractLinks, buildMessageLink, getConversationType, extractMessageTimestamp, parsePersonSuggestion, parseV2Result, parseJwtProfile, calculateTokenStatus, parseSearchResults, parsePeopleResults, extractObjectId, buildOneOnOneConversationId, decodeBase64Guid, extractActivityTimestamp, markdownToTeamsHtml, formatTranscriptText, } from './parsers.js';
9
9
  import { searchResultItem, searchResultWithHtml, searchResultMinimal, searchResultTooShort, searchResultThreadReply, searchEntitySetsResponse, personSuggestion, personMinimal, personWithBase64Id, peopleGroupsResponse, jwtPayloadFull, jwtPayloadMinimal, jwtPayloadCommaName, jwtPayloadSpaceName, sourceWithMessageId, sourceWithConvIdMessageId, } from '../__fixtures__/api-responses.js';
10
10
  describe('stripHtml', () => {
11
11
  it('removes HTML tags', () => {
@@ -581,3 +581,32 @@ describe('markdownToTeamsHtml', () => {
581
581
  expect(markdownToTeamsHtml(' ')).toBe('<p></p>');
582
582
  });
583
583
  });
584
+ describe('formatTranscriptText', () => {
585
+ it('formats entries with speaker names and timestamps', () => {
586
+ const entries = [
587
+ { startTime: '00:00:01.000', endTime: '00:00:05.000', speaker: 'Alice', text: 'Hello everyone.' },
588
+ { startTime: '00:00:06.000', endTime: '00:00:10.000', speaker: 'Bob', text: 'Hi Alice!' },
589
+ ];
590
+ const result = formatTranscriptText(entries);
591
+ expect(result).toBe('[00:00:01.000] Alice:\nHello everyone.\n\n[00:00:06.000] Bob:\nHi Alice!');
592
+ });
593
+ it('merges consecutive entries from the same speaker', () => {
594
+ const entries = [
595
+ { startTime: '00:00:01.000', endTime: '00:00:03.000', speaker: 'Alice', text: 'First part.' },
596
+ { startTime: '00:00:03.000', endTime: '00:00:06.000', speaker: 'Alice', text: 'Second part.' },
597
+ { startTime: '00:00:07.000', endTime: '00:00:10.000', speaker: 'Bob', text: 'Response.' },
598
+ ];
599
+ const result = formatTranscriptText(entries);
600
+ expect(result).toBe('[00:00:01.000] Alice:\nFirst part. Second part.\n\n[00:00:07.000] Bob:\nResponse.');
601
+ });
602
+ it('handles entries without speaker names', () => {
603
+ const entries = [
604
+ { startTime: '00:00:01.000', endTime: '00:00:05.000', speaker: '', text: 'Unknown speaker.' },
605
+ ];
606
+ const result = formatTranscriptText(entries);
607
+ expect(result).toBe('[00:00:01.000]\nUnknown speaker.');
608
+ });
609
+ it('returns empty string for empty entries', () => {
610
+ expect(formatTranscriptText([])).toBe('');
611
+ });
612
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "msteams-mcp",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "MCP server for Microsoft Teams - search messages, send replies, manage favourites",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",