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.
- package/dist/auth/token-refresh.d.ts +11 -5
- package/dist/auth/token-refresh.js +21 -13
- package/dist/browser/auth.js +14 -291
- package/dist/browser/context.d.ts +25 -5
- package/dist/browser/context.js +44 -56
- package/dist/constants.d.ts +0 -12
- package/dist/constants.js +0 -12
- package/dist/tools/auth-tools.js +5 -7
- package/dist/tools/search-tools.d.ts +4 -4
- package/package.json +1 -1
|
@@ -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
|
|
6
|
-
* silently refresh tokens
|
|
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
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
|
6
|
-
* silently refresh tokens
|
|
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
|
-
|
|
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
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
-
//
|
|
20
|
-
if (
|
|
21
|
-
return err(createError(ErrorCode.
|
|
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
|
|
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
|
}
|
package/dist/browser/auth.js
CHANGED
|
@@ -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,
|
|
75
|
-
// Remove existing overlay if present
|
|
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
|
-
|
|
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(
|
|
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
|
-
//
|
|
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
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
//
|
|
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
|
-
|
|
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
|
|
15
|
+
import { type BrowserContext, type Page } from 'playwright';
|
|
9
16
|
export interface BrowserManager {
|
|
10
|
-
|
|
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
|
|
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
|
|
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>;
|
package/dist/browser/context.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/dist/constants.d.ts
CHANGED
|
@@ -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). */
|
package/dist/tools/auth-tools.js
CHANGED
|
@@ -72,13 +72,11 @@ async function handleLogin(input, ctx) {
|
|
|
72
72
|
};
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
|
|
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: "
|
|
31
|
+
order: "asc" | "desc";
|
|
32
32
|
}, {
|
|
33
33
|
conversationId: string;
|
|
34
34
|
limit?: number | undefined;
|
|
35
35
|
markRead?: boolean | undefined;
|
|
36
|
-
order?: "
|
|
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: "
|
|
76
|
+
order: "asc" | "desc";
|
|
77
77
|
}, {
|
|
78
78
|
conversationId: string;
|
|
79
79
|
limit?: number | undefined;
|
|
80
80
|
markRead?: boolean | undefined;
|
|
81
|
-
order?: "
|
|
81
|
+
order?: "asc" | "desc" | undefined;
|
|
82
82
|
}>> | RegisteredTool<z.ZodObject<{
|
|
83
83
|
query: z.ZodString;
|
|
84
84
|
limit: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
|