msteams-mcp 0.18.2 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,8 +2,13 @@
2
2
  * Token refresh via headless browser.
3
3
  *
4
4
  * Teams uses SPA OAuth2 which restricts refresh tokens to browser-based CORS
5
- * requests. We open a headless browser with saved session state, let MSAL
6
- * silently refresh tokens, then save the updated state. Seamless to the user.
5
+ * requests. We open a headless browser with the persistent profile, let MSAL
6
+ * silently refresh tokens using the profile's long-lived session cookies,
7
+ * then save the updated state. Seamless to the user — no browser window shown.
8
+ *
9
+ * The persistent profile is shared with visible login, so only one browser
10
+ * can use it at a time (Chromium profile lock). A module-level flag prevents
11
+ * concurrent refresh attempts.
7
12
  */
8
13
  import { type Result } from '../types/result.js';
9
14
  /** Result of a successful token refresh. */
@@ -18,8 +23,9 @@ export interface TokenRefreshResult {
18
23
  refreshNeeded: boolean;
19
24
  }
20
25
  /**
21
- * Refreshes tokens by opening a headless browser with saved session state.
22
- * MSAL only refreshes tokens when an API call requires them, so we trigger
23
- * a search via ensureAuthenticated to force token acquisition.
26
+ * Refreshes tokens by opening a headless browser with the persistent profile.
27
+ * The profile's long-lived Microsoft session cookies enable silent re-auth
28
+ * without user interaction. MSAL only refreshes tokens when an API call
29
+ * requires them, so we trigger a search via ensureAuthenticated.
24
30
  */
25
31
  export declare function refreshTokensViaBrowser(): Promise<Result<TokenRefreshResult>>;
@@ -2,26 +2,30 @@
2
2
  * Token refresh via headless browser.
3
3
  *
4
4
  * Teams uses SPA OAuth2 which restricts refresh tokens to browser-based CORS
5
- * requests. We open a headless browser with saved session state, let MSAL
6
- * silently refresh tokens, then save the updated state. Seamless to the user.
5
+ * requests. We open a headless browser with the persistent profile, let MSAL
6
+ * silently refresh tokens using the profile's long-lived session cookies,
7
+ * then save the updated state. Seamless to the user — no browser window shown.
8
+ *
9
+ * The persistent profile is shared with visible login, so only one browser
10
+ * can use it at a time (Chromium profile lock). A module-level flag prevents
11
+ * concurrent refresh attempts.
7
12
  */
8
13
  import { TOKEN_REFRESH_THRESHOLD_MS } from '../constants.js';
9
14
  import { ErrorCode, createError } from '../types/errors.js';
10
15
  import { ok, err } from '../types/result.js';
11
16
  import { extractSubstrateToken, clearTokenCache, } from './token-extractor.js';
12
- import { hasSessionState, isSessionLikelyExpired, } from './session-store.js';
17
+ /** Module-level flag to prevent concurrent browser profile access. */
18
+ let refreshInProgress = false;
13
19
  /**
14
- * Refreshes tokens by opening a headless browser with saved session state.
15
- * MSAL only refreshes tokens when an API call requires them, so we trigger
16
- * a search via ensureAuthenticated to force token acquisition.
20
+ * Refreshes tokens by opening a headless browser with the persistent profile.
21
+ * The profile's long-lived Microsoft session cookies enable silent re-auth
22
+ * without user interaction. MSAL only refreshes tokens when an API call
23
+ * requires them, so we trigger a search via ensureAuthenticated.
17
24
  */
18
25
  export async function refreshTokensViaBrowser() {
19
- // Check we have a session to work with
20
- if (!hasSessionState()) {
21
- return err(createError(ErrorCode.AUTH_REQUIRED, 'No session state available. Please run teams_login to authenticate.', { suggestions: ['Call teams_login to authenticate'] }));
22
- }
23
- if (isSessionLikelyExpired()) {
24
- return err(createError(ErrorCode.AUTH_EXPIRED, 'Session is too old and likely expired. Please re-authenticate.', { suggestions: ['Call teams_login to re-authenticate'] }));
26
+ // Prevent concurrent access to the shared browser profile
27
+ if (refreshInProgress) {
28
+ return err(createError(ErrorCode.UNKNOWN, 'Token refresh already in progress. Please wait and try again.', { retryable: true, suggestions: ['Wait a moment and retry the request'] }));
25
29
  }
26
30
  // Get current token expiry for comparison
27
31
  const beforeToken = extractSubstrateToken();
@@ -32,8 +36,9 @@ export async function refreshTokensViaBrowser() {
32
36
  // Import browser functions dynamically to avoid circular dependencies
33
37
  const { createBrowserContext, closeBrowser } = await import('../browser/context.js');
34
38
  let manager = null;
39
+ refreshInProgress = true;
35
40
  try {
36
- // Open headless browser with saved session
41
+ // Open headless browser with persistent profile
37
42
  manager = await createBrowserContext({ headless: true });
38
43
  // Import auth functions
39
44
  const { ensureAuthenticated } = await import('../browser/auth.js');
@@ -82,4 +87,7 @@ export async function refreshTokensViaBrowser() {
82
87
  const message = error instanceof Error ? error.message : 'Unknown error';
83
88
  return err(createError(ErrorCode.UNKNOWN, `Token refresh via browser failed: ${message}`, { suggestions: ['Call teams_login to re-authenticate'] }));
84
89
  }
90
+ finally {
91
+ refreshInProgress = false;
92
+ }
85
93
  }
@@ -23,10 +23,6 @@ const OVERLAY_CONTENT = {
23
23
  message: "You're signed in!",
24
24
  detail: 'Setting up your connection to Teams...',
25
25
  },
26
- 'acquiring': {
27
- message: 'Acquiring permissions...',
28
- detail: 'Getting access to search and messages...',
29
- },
30
26
  'saving': {
31
27
  message: 'Saving your session...',
32
28
  detail: "So you won't need to log in again.",
@@ -35,31 +31,11 @@ const OVERLAY_CONTENT = {
35
31
  message: 'All done!',
36
32
  detail: 'This window will close automatically.',
37
33
  },
38
- 'refreshing': {
39
- message: 'Refreshing your session...',
40
- detail: 'Updating your access tokens...',
41
- },
42
34
  'error': {
43
35
  message: 'Something went wrong',
44
36
  detail: 'Please try again or check the console for details.',
45
37
  },
46
38
  };
47
- /** Detail messages that cycle during the acquiring/refreshing phases. */
48
- const ACQUIRING_DETAILS = [
49
- 'Preparing Teams connection...',
50
- 'Navigating to search...',
51
- 'Waiting for API response...',
52
- 'Acquiring search permissions...',
53
- 'Convincing Microsoft we mean well...',
54
- 'Negotiating with the UI...',
55
- 'Gathering auth tokens...',
56
- 'Good things come to those who wait...',
57
- 'Almost there...',
58
- ];
59
- /** Interval for cycling detail messages (ms). */
60
- const DETAIL_CYCLE_INTERVAL_MS = 3000;
61
- /** ID for the detail text element (for cycling updates). */
62
- const DETAIL_ELEMENT_ID = 'mcp-login-detail';
63
39
  /**
64
40
  * Shows a progress overlay for a specific phase.
65
41
  * Handles injection, content, and optional pause.
@@ -69,43 +45,12 @@ async function showLoginProgress(page, phase, options = {}) {
69
45
  const content = OVERLAY_CONTENT[phase];
70
46
  const isComplete = phase === 'complete';
71
47
  const isError = phase === 'error';
72
- const isAnimated = phase === 'acquiring' || phase === 'refreshing';
73
48
  try {
74
- await page.evaluate(({ id, detailId, message, detail, complete, error, animated, cycleDetails, cycleInterval }) => {
75
- // Remove existing overlay if present, clearing any running timer
49
+ await page.evaluate(({ id, message, detail, complete, error }) => {
50
+ // Remove existing overlay if present
76
51
  const existing = document.getElementById(id);
77
- if (existing) {
78
- const existingWithTimer = existing;
79
- if (existingWithTimer._cycleTimer) {
80
- clearInterval(existingWithTimer._cycleTimer);
81
- }
52
+ if (existing)
82
53
  existing.remove();
83
- }
84
- // Remove any existing style element
85
- const existingStyle = document.getElementById(`${id}-style`);
86
- if (existingStyle) {
87
- existingStyle.remove();
88
- }
89
- // Add keyframe animations for spinner
90
- const style = document.createElement('style');
91
- style.id = `${id}-style`;
92
- style.textContent = `
93
- @keyframes mcp-spin {
94
- 0% { transform: rotate(0deg); }
95
- 100% { transform: rotate(360deg); }
96
- }
97
- @keyframes mcp-pulse {
98
- 0%, 100% { opacity: 1; }
99
- 50% { opacity: 0.6; }
100
- }
101
- @keyframes mcp-fade {
102
- 0% { opacity: 0; transform: translateY(4px); }
103
- 15% { opacity: 1; transform: translateY(0); }
104
- 85% { opacity: 1; transform: translateY(0); }
105
- 100% { opacity: 0; transform: translateY(-4px); }
106
- }
107
- `;
108
- document.head.appendChild(style);
109
54
  // Create overlay container
110
55
  const overlay = document.createElement('div');
111
56
  overlay.id = id;
@@ -132,14 +77,6 @@ async function showLoginProgress(page, phase, options = {}) {
132
77
  textAlign: 'center',
133
78
  boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)',
134
79
  });
135
- // Create icon container (for animation)
136
- const iconContainer = document.createElement('div');
137
- Object.assign(iconContainer.style, {
138
- width: '64px',
139
- height: '64px',
140
- margin: '0 auto 24px',
141
- position: 'relative',
142
- });
143
80
  // Create icon
144
81
  const icon = document.createElement('div');
145
82
  const iconBg = error ? '#c42b1c' : complete ? '#107c10' : '#5b5fc7';
@@ -153,30 +90,9 @@ async function showLoginProgress(page, phase, options = {}) {
153
90
  fontSize: '32px',
154
91
  background: iconBg,
155
92
  color: 'white',
93
+ margin: '0 auto 24px',
156
94
  });
157
- if (animated) {
158
- // Spinner ring for animated states
159
- const spinner = document.createElement('div');
160
- Object.assign(spinner.style, {
161
- position: 'absolute',
162
- top: '-4px',
163
- left: '-4px',
164
- width: '72px',
165
- height: '72px',
166
- borderRadius: '50%',
167
- border: '3px solid transparent',
168
- borderTopColor: iconBg,
169
- borderRightColor: iconBg,
170
- animation: 'mcp-spin 1.2s linear infinite',
171
- });
172
- iconContainer.appendChild(spinner);
173
- icon.textContent = '⋯';
174
- icon.style.animation = 'mcp-pulse 2s ease-in-out infinite';
175
- }
176
- else {
177
- icon.textContent = error ? '✕' : complete ? '✓' : '⋯';
178
- }
179
- iconContainer.appendChild(icon);
95
+ icon.textContent = error ? '✕' : complete ? '✓' : '⋯';
180
96
  // Create title
181
97
  const title = document.createElement('h2');
182
98
  Object.assign(title.style, {
@@ -188,50 +104,25 @@ async function showLoginProgress(page, phase, options = {}) {
188
104
  title.textContent = message;
189
105
  // Create detail text
190
106
  const detailEl = document.createElement('p');
191
- detailEl.id = detailId;
192
107
  Object.assign(detailEl.style, {
193
108
  margin: '0',
194
109
  fontSize: '14px',
195
110
  color: '#616161',
196
111
  lineHeight: '1.5',
197
- minHeight: '21px', // Prevent layout shift
198
112
  });
199
- if (animated) {
200
- detailEl.style.animation = `mcp-fade ${cycleInterval}ms ease-in-out infinite`;
201
- }
202
113
  detailEl.textContent = detail;
203
114
  // Assemble and append
204
- modal.appendChild(iconContainer);
115
+ modal.appendChild(icon);
205
116
  modal.appendChild(title);
206
117
  modal.appendChild(detailEl);
207
118
  overlay.appendChild(modal);
208
119
  document.body.appendChild(overlay);
209
- // Set up detail cycling for animated states
210
- if (animated && cycleDetails && cycleDetails.length > 0) {
211
- let detailIndex = 0;
212
- const cycleTimer = setInterval(() => {
213
- const el = document.getElementById(detailId);
214
- if (el) {
215
- el.textContent = cycleDetails[detailIndex];
216
- detailIndex = (detailIndex + 1) % cycleDetails.length;
217
- }
218
- else {
219
- clearInterval(cycleTimer);
220
- }
221
- }, cycleInterval);
222
- // Store timer ID on overlay for potential future cleanup
223
- overlay._cycleTimer = cycleTimer;
224
- }
225
120
  }, {
226
121
  id: PROGRESS_OVERLAY_ID,
227
- detailId: DETAIL_ELEMENT_ID,
228
122
  message: content.message,
229
123
  detail: content.detail,
230
124
  complete: isComplete,
231
125
  error: isError,
232
- animated: isAnimated,
233
- cycleDetails: isAnimated ? ACQUIRING_DETAILS : [],
234
- cycleInterval: DETAIL_CYCLE_INTERVAL_MS,
235
126
  });
236
127
  // Pause if requested (for steps that need user to see the message)
237
128
  if (options.pause) {
@@ -241,7 +132,6 @@ async function showLoginProgress(page, phase, options = {}) {
241
132
  }
242
133
  catch {
243
134
  // Overlay is cosmetic - don't fail login if it can't be shown
244
- // To debug: change to `catch (e)` and add `console.debug('[overlay]', e);`
245
135
  }
246
136
  }
247
137
  // ─────────────────────────────────────────────────────────────────────────────
@@ -284,145 +174,6 @@ async function hasAuthenticatedContent(page) {
284
174
  }
285
175
  return false;
286
176
  }
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
- }
317
- /**
318
- * Triggers MSAL to acquire the Substrate token.
319
- *
320
- * MSAL only acquires tokens for specific scopes when the app makes API calls
321
- * requiring those scopes. The Substrate API is only used for search, so we
322
- * perform a search to trigger token acquisition.
323
- *
324
- * Returns true if a Substrate API call was detected, false otherwise.
325
- */
326
- async function triggerTokenAcquisition(page, log) {
327
- log('Triggering token acquisition...');
328
- try {
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
- });
342
- // Try multiple methods to trigger search
343
- let searchTriggered = false;
344
- // Method 1: Navigate to search results URL (triggers Substrate API call directly)
345
- log('Navigating to search results...');
346
- try {
347
- await page.goto('https://teams.microsoft.com/v2/#/search?query=test', {
348
- waitUntil: 'domcontentloaded',
349
- timeout: 30000,
350
- });
351
- searchTriggered = true;
352
- log('Search results page loaded.');
353
- }
354
- catch (e) {
355
- log(`Search navigation failed: ${e instanceof Error ? e.message : String(e)}`);
356
- }
357
- // Method 2: Fallback - focus and type
358
- if (!searchTriggered) {
359
- log('Trying focus+type fallback...');
360
- try {
361
- const focused = await page.evaluate(() => {
362
- const selectors = [
363
- '#ms-searchux-input',
364
- '[data-tid="searchInputField"]',
365
- 'input[placeholder*="Search"]',
366
- ];
367
- for (const sel of selectors) {
368
- const el = document.querySelector(sel);
369
- if (el) {
370
- el.focus();
371
- el.click();
372
- return true;
373
- }
374
- }
375
- return false;
376
- });
377
- if (focused) {
378
- await page.waitForTimeout(500);
379
- await page.keyboard.type('test', { delay: 30 });
380
- await page.keyboard.press('Enter');
381
- searchTriggered = true;
382
- log('Search submitted via typing.');
383
- }
384
- }
385
- catch {
386
- // Continue
387
- }
388
- }
389
- // Method 3: Keyboard shortcut fallback
390
- if (!searchTriggered) {
391
- log('Trying keyboard shortcut...');
392
- const isMac = process.platform === 'darwin';
393
- await page.keyboard.press(isMac ? 'Meta+e' : 'Control+e');
394
- await page.waitForTimeout(1000);
395
- await page.keyboard.type('is:Messages', { delay: 30 });
396
- await page.keyboard.press('Enter');
397
- searchTriggered = true;
398
- }
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.');
404
- }
405
- else {
406
- log('No Substrate API call detected within timeout.');
407
- }
408
- // Give MSAL a moment to persist tokens to localStorage
409
- await page.waitForTimeout(2000);
410
- // Close search and reset
411
- try {
412
- await page.keyboard.press('Escape');
413
- await page.waitForTimeout(500);
414
- }
415
- catch {
416
- // Page may have navigated, ignore
417
- }
418
- log('Token acquisition complete.');
419
- return substrateDetected;
420
- }
421
- catch (error) {
422
- log(`Token acquisition warning: ${error instanceof Error ? error.message : String(error)}`);
423
- return false;
424
- }
425
- }
426
177
  /**
427
178
  * Gets the current authentication status.
428
179
  */
@@ -555,24 +306,12 @@ export async function waitForManualLogin(page, context, timeoutMs = 5 * 60 * 100
555
306
  const status = await getAuthStatus(page);
556
307
  if (status.isAuthenticated) {
557
308
  log('Authentication successful!');
558
- // Show progress through login steps (only if overlay enabled)
559
309
  if (showOverlay) {
560
310
  await showLoginProgress(page, 'signed-in', { pause: true });
561
- await showLoginProgress(page, 'acquiring');
562
- }
563
- // Trigger a search to cause MSAL to acquire the Substrate token
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
- }
572
- if (showOverlay) {
573
311
  await showLoginProgress(page, 'saving');
574
312
  }
575
- // Save the session state with fresh tokens
313
+ // The persistent browser profile already has MSAL tokens in localStorage
314
+ // from the login flow. Just save the session state directly.
576
315
  await saveSessionState(context);
577
316
  log('Session state saved.');
578
317
  if (showOverlay) {
@@ -606,27 +345,13 @@ export async function ensureAuthenticated(page, context, onProgress, showOverlay
606
345
  log('Navigating to Teams...');
607
346
  const status = await navigateToTeams(page);
608
347
  if (status.isAuthenticated) {
609
- log('Already authenticated.');
610
- if (showOverlay) {
611
- await showLoginProgress(page, 'refreshing');
612
- }
613
- // Trigger a search to cause MSAL to acquire/refresh the Substrate token
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
- }
622
- if (showOverlay) {
623
- await showLoginProgress(page, 'saving');
624
- }
625
- // Save the session state with fresh tokens
348
+ log('Already authenticated — saving session state.');
349
+ // The persistent browser profile already has valid MSAL tokens in localStorage.
350
+ // Just save the session state directly — no need to trigger a search and wait
351
+ // for Substrate API calls. Token acquisition is only needed after a fresh
352
+ // manual login where MSAL hasn't yet acquired the Substrate token.
626
353
  await saveSessionState(context);
627
- if (showOverlay) {
628
- await showLoginProgress(page, 'complete', { pause: true });
629
- }
354
+ log('Session state saved.');
630
355
  return;
631
356
  }
632
357
  // User interaction required - fail fast if headless
@@ -639,8 +364,6 @@ export async function ensureAuthenticated(page, context, onProgress, showOverlay
639
364
  if (status.isOnLoginPage) {
640
365
  log('Login required. Please complete authentication in the browser window.');
641
366
  await waitForManualLogin(page, context, undefined, onProgress, showOverlay);
642
- // Navigate back to Teams after login (in case we're on a callback URL)
643
- await navigateToTeams(page);
644
367
  }
645
368
  else {
646
369
  // Unexpected state - might need manual intervention
@@ -4,13 +4,23 @@
4
4
  *
5
5
  * Uses the system's installed Chrome or Edge browser rather than downloading
6
6
  * Playwright's bundled Chromium. This significantly reduces install size.
7
+ *
8
+ * All modes (headless and visible) use a persistent browser profile stored at
9
+ * ~/.teams-mcp-server/browser-profile/. This means:
10
+ * - Microsoft session cookies persist across launches (longer-lived than MSAL tokens)
11
+ * - Headless token refresh can silently re-authenticate using the profile's session
12
+ * - Visible login retains extensions (e.g. Bitwarden) and form autofill data
13
+ * - No need for storageState temp files or encrypted session restoration for browser use
7
14
  */
8
- import { type Browser, type BrowserContext, type Page } from 'playwright';
15
+ import { type BrowserContext, type Page } from 'playwright';
9
16
  export interface BrowserManager {
10
- browser: Browser;
17
+ /** Always null — persistent contexts have no separate Browser object. */
18
+ browser: null;
11
19
  context: BrowserContext;
12
20
  page: Page;
13
21
  isNewSession: boolean;
22
+ /** Always true — all contexts use the persistent browser profile. */
23
+ persistent: true;
14
24
  }
15
25
  export interface CreateBrowserOptions {
16
26
  headless?: boolean;
@@ -20,13 +30,23 @@ export interface CreateBrowserOptions {
20
30
  };
21
31
  }
22
32
  /**
23
- * Creates a browser context with optional session state restoration.
33
+ * Creates a browser context using a persistent profile.
24
34
  *
25
35
  * Uses the system's installed Chrome or Edge browser rather than downloading
26
36
  * Playwright's bundled Chromium (~180MB savings).
27
37
  *
38
+ * The persistent profile at ~/.teams-mcp-server/browser-profile/ is shared
39
+ * between headless and visible modes. This provides:
40
+ * - Silent headless re-auth via long-lived Microsoft session cookies
41
+ * - Extensions (e.g. Bitwarden) and form autofill for visible login
42
+ * - No storageState temp file management needed
43
+ *
44
+ * Note: Only one process can use the profile at a time (Chromium profile lock).
45
+ * The MCP server serialises tool calls, and token-refresh checks for an active
46
+ * browser before attempting refresh to avoid lock contention.
47
+ *
28
48
  * @param options - Browser configuration options
29
- * @returns Browser manager with browser, context, and page
49
+ * @returns Browser manager with context and page
30
50
  * @throws Error if system browser is not found (with helpful suggestions)
31
51
  */
32
52
  export declare function createBrowserContext(options?: CreateBrowserOptions): Promise<BrowserManager>;
@@ -35,6 +55,6 @@ export declare function createBrowserContext(options?: CreateBrowserOptions): Pr
35
55
  */
36
56
  export declare function saveSessionState(context: BrowserContext): Promise<void>;
37
57
  /**
38
- * Closes the browser and optionally saves session state.
58
+ * Closes the browser context and optionally saves session state.
39
59
  */
40
60
  export declare function closeBrowser(manager: BrowserManager, saveSession?: boolean): Promise<void>;
@@ -4,14 +4,32 @@
4
4
  *
5
5
  * Uses the system's installed Chrome or Edge browser rather than downloading
6
6
  * Playwright's bundled Chromium. This significantly reduces install size.
7
+ *
8
+ * All modes (headless and visible) use a persistent browser profile stored at
9
+ * ~/.teams-mcp-server/browser-profile/. This means:
10
+ * - Microsoft session cookies persist across launches (longer-lived than MSAL tokens)
11
+ * - Headless token refresh can silently re-authenticate using the profile's session
12
+ * - Visible login retains extensions (e.g. Bitwarden) and form autofill data
13
+ * - No need for storageState temp files or encrypted session restoration for browser use
7
14
  */
8
15
  import { chromium } from 'playwright';
9
- import { ensureUserDataDir, hasSessionState, SESSION_STATE_PATH, isSessionLikelyExpired, writeSessionState, readSessionState, } from '../auth/session-store.js';
16
+ import * as path from 'path';
17
+ import { ensureUserDataDir, CONFIG_DIR, writeSessionState, } from '../auth/session-store.js';
10
18
  import { clearRegionCache } from '../utils/auth-guards.js';
11
19
  const DEFAULT_OPTIONS = {
12
20
  headless: true,
13
21
  viewport: { width: 1280, height: 800 },
14
22
  };
23
+ /**
24
+ * Directory for the persistent browser profile.
25
+ * This is a dedicated Chrome/Edge profile within the config dir, so extensions
26
+ * (e.g. Bitwarden) and form autofill data persist across login sessions
27
+ * without conflicting with the user's running browser instance.
28
+ *
29
+ * Both headless and visible modes share this profile, so Microsoft's long-lived
30
+ * session cookies enable silent headless re-authentication without user interaction.
31
+ */
32
+ const BROWSER_PROFILE_DIR = path.join(CONFIG_DIR, 'browser-profile');
15
33
  /**
16
34
  * Determines the browser channel to use based on the platform.
17
35
  * - Windows: Use Microsoft Edge (always installed on Windows 10+)
@@ -23,25 +41,45 @@ function getBrowserChannel() {
23
41
  return process.platform === 'win32' ? 'msedge' : 'chrome';
24
42
  }
25
43
  /**
26
- * Creates a browser context with optional session state restoration.
44
+ * Creates a browser context using a persistent profile.
27
45
  *
28
46
  * Uses the system's installed Chrome or Edge browser rather than downloading
29
47
  * Playwright's bundled Chromium (~180MB savings).
30
48
  *
49
+ * The persistent profile at ~/.teams-mcp-server/browser-profile/ is shared
50
+ * between headless and visible modes. This provides:
51
+ * - Silent headless re-auth via long-lived Microsoft session cookies
52
+ * - Extensions (e.g. Bitwarden) and form autofill for visible login
53
+ * - No storageState temp file management needed
54
+ *
55
+ * Note: Only one process can use the profile at a time (Chromium profile lock).
56
+ * The MCP server serialises tool calls, and token-refresh checks for an active
57
+ * browser before attempting refresh to avoid lock contention.
58
+ *
31
59
  * @param options - Browser configuration options
32
- * @returns Browser manager with browser, context, and page
60
+ * @returns Browser manager with context and page
33
61
  * @throws Error if system browser is not found (with helpful suggestions)
34
62
  */
35
63
  export async function createBrowserContext(options = {}) {
36
64
  const opts = { ...DEFAULT_OPTIONS, ...options };
37
65
  ensureUserDataDir();
38
66
  const channel = getBrowserChannel();
39
- let browser;
40
67
  try {
41
- browser = await chromium.launch({
68
+ const context = await chromium.launchPersistentContext(BROWSER_PROFILE_DIR, {
42
69
  headless: opts.headless,
43
70
  channel,
71
+ viewport: opts.viewport,
72
+ acceptDownloads: false,
44
73
  });
74
+ // Persistent contexts start with one page; use it or create one
75
+ const page = context.pages()[0] ?? await context.newPage();
76
+ return {
77
+ browser: null,
78
+ context,
79
+ page,
80
+ isNewSession: true,
81
+ persistent: true,
82
+ };
45
83
  }
46
84
  catch (error) {
47
85
  const browserName = channel === 'msedge' ? 'Microsoft Edge' : 'Google Chrome';
@@ -51,55 +89,6 @@ export async function createBrowserContext(options = {}) {
51
89
  throw new Error(`Could not launch ${browserName}. ${installHint}\n\n` +
52
90
  `Original error: ${error instanceof Error ? error.message : String(error)}`);
53
91
  }
54
- const hasSession = hasSessionState();
55
- const sessionExpired = isSessionLikelyExpired();
56
- // Restore session if we have one and it's not ancient
57
- const shouldRestoreSession = hasSession && !sessionExpired;
58
- let context;
59
- if (shouldRestoreSession) {
60
- try {
61
- // Read the decrypted session state
62
- const state = readSessionState();
63
- if (state) {
64
- // Create a temporary file for Playwright (it needs a file path)
65
- // We write the decrypted state to a temp location
66
- const tempPath = SESSION_STATE_PATH + '.tmp';
67
- const fs = await import('fs');
68
- fs.writeFileSync(tempPath, JSON.stringify(state), { mode: 0o600 });
69
- try {
70
- context = await browser.newContext({
71
- storageState: tempPath,
72
- viewport: opts.viewport,
73
- });
74
- }
75
- finally {
76
- // Clean up temp file
77
- fs.unlinkSync(tempPath);
78
- }
79
- }
80
- else {
81
- throw new Error('Failed to read session state');
82
- }
83
- }
84
- catch (error) {
85
- console.warn('Failed to restore session state, starting fresh:', error);
86
- context = await browser.newContext({
87
- viewport: opts.viewport,
88
- });
89
- }
90
- }
91
- else {
92
- context = await browser.newContext({
93
- viewport: opts.viewport,
94
- });
95
- }
96
- const page = await context.newPage();
97
- return {
98
- browser,
99
- context,
100
- page,
101
- isNewSession: !shouldRestoreSession,
102
- };
103
92
  }
104
93
  /**
105
94
  * Saves the current browser context's session state.
@@ -111,12 +100,11 @@ export async function saveSessionState(context) {
111
100
  clearRegionCache();
112
101
  }
113
102
  /**
114
- * Closes the browser and optionally saves session state.
103
+ * Closes the browser context and optionally saves session state.
115
104
  */
116
105
  export async function closeBrowser(manager, saveSession = true) {
117
106
  if (saveSession) {
118
107
  await saveSessionState(manager.context);
119
108
  }
120
109
  await manager.context.close();
121
- await manager.browser.close();
122
110
  }
@@ -28,20 +28,8 @@ export declare const MAX_CONTACTS_LIMIT = 500;
28
28
  export declare const DEFAULT_CHANNEL_LIMIT = 10;
29
29
  /** Maximum limit for channel search. */
30
30
  export declare const MAX_CHANNEL_LIMIT = 50;
31
- /** Default timeout for waiting for search results. */
32
- export declare const SEARCH_RESULT_TIMEOUT_MS = 10000;
33
31
  /** Default HTTP request timeout. */
34
32
  export declare const HTTP_REQUEST_TIMEOUT_MS = 30000;
35
- /** Short delay for UI interactions. */
36
- export declare const UI_SHORT_DELAY_MS = 300;
37
- /** Medium delay for UI state changes. */
38
- export declare const UI_MEDIUM_DELAY_MS = 1000;
39
- /** Long delay for API responses to settle. */
40
- export declare const UI_LONG_DELAY_MS = 2000;
41
- /** Authentication check interval. */
42
- export declare const AUTH_CHECK_INTERVAL_MS = 2000;
43
- /** Default login timeout (5 minutes). */
44
- export declare const LOGIN_TIMEOUT_MS: number;
45
33
  /** Pause after showing progress overlay step (ms). */
46
34
  export declare const OVERLAY_STEP_PAUSE_MS = 1500;
47
35
  /** Pause after showing final "All done" overlay (ms). */
package/dist/constants.js CHANGED
@@ -37,20 +37,8 @@ export const MAX_CHANNEL_LIMIT = 50;
37
37
  // ─────────────────────────────────────────────────────────────────────────────
38
38
  // Timeouts (milliseconds)
39
39
  // ─────────────────────────────────────────────────────────────────────────────
40
- /** Default timeout for waiting for search results. */
41
- export const SEARCH_RESULT_TIMEOUT_MS = 10000;
42
40
  /** Default HTTP request timeout. */
43
41
  export const HTTP_REQUEST_TIMEOUT_MS = 30000;
44
- /** Short delay for UI interactions. */
45
- export const UI_SHORT_DELAY_MS = 300;
46
- /** Medium delay for UI state changes. */
47
- export const UI_MEDIUM_DELAY_MS = 1000;
48
- /** Long delay for API responses to settle. */
49
- export const UI_LONG_DELAY_MS = 2000;
50
- /** Authentication check interval. */
51
- export const AUTH_CHECK_INTERVAL_MS = 2000;
52
- /** Default login timeout (5 minutes). */
53
- export const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
54
42
  /** Pause after showing progress overlay step (ms). */
55
43
  export const OVERLAY_STEP_PAUSE_MS = 1500;
56
44
  /** Pause after showing final "All done" overlay (ms). */
@@ -72,13 +72,11 @@ async function handleLogin(input, ctx) {
72
72
  };
73
73
  }
74
74
  }
75
- // Smart headless strategy:
76
- // 1. No session or session too old visible browser (definitely need login)
77
- // 2. Session exists and is recent (< 12 hours) try headless first (SSO likely)
78
- // 3. forceNew requested visible browser (user wants fresh login)
79
- const hasRecentSession = hasSessionState() && !isSessionLikelyExpired();
80
- const shouldTryHeadless = hasRecentSession && !input.forceNew;
81
- if (shouldTryHeadless) {
75
+ // Headless-first strategy:
76
+ // The persistent browser profile retains Microsoft's long-lived session cookies,
77
+ // so headless SSO can succeed even without a session-state file. Always try
78
+ // headless first unless the user explicitly wants a fresh login.
79
+ if (!input.forceNew) {
82
80
  // Try headless first - SSO may complete silently
83
81
  const headlessManager = await createBrowserContext({ headless: true });
84
82
  ctx.server.setBrowserManager(headlessManager);
@@ -28,12 +28,12 @@ export declare const GetThreadInputSchema: z.ZodObject<{
28
28
  conversationId: string;
29
29
  limit: number;
30
30
  markRead: boolean;
31
- order: "desc" | "asc";
31
+ order: "asc" | "desc";
32
32
  }, {
33
33
  conversationId: string;
34
34
  limit?: number | undefined;
35
35
  markRead?: boolean | undefined;
36
- order?: "desc" | "asc" | undefined;
36
+ order?: "asc" | "desc" | undefined;
37
37
  }>;
38
38
  export declare const FindChannelInputSchema: z.ZodObject<{
39
39
  query: z.ZodString;
@@ -73,12 +73,12 @@ export declare const searchTools: (RegisteredTool<z.ZodObject<{
73
73
  conversationId: string;
74
74
  limit: number;
75
75
  markRead: boolean;
76
- order: "desc" | "asc";
76
+ order: "asc" | "desc";
77
77
  }, {
78
78
  conversationId: string;
79
79
  limit?: number | undefined;
80
80
  markRead?: boolean | undefined;
81
- order?: "desc" | "asc" | undefined;
81
+ order?: "asc" | "desc" | undefined;
82
82
  }>> | RegisteredTool<z.ZodObject<{
83
83
  query: z.ZodString;
84
84
  limit: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "msteams-mcp",
3
- "version": "0.18.2",
3
+ "version": "0.19.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",