mcpbrowser 0.3.28 → 0.3.30

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/src/core/auth.js CHANGED
@@ -2,9 +2,13 @@
2
2
  * Authentication flow handling for MCPBrowser
3
3
  */
4
4
 
5
- import { getBaseDomain } from '../utils.js';
6
5
  import logger from './logger.js';
7
6
 
7
+ // Consider user active on the login page if they interacted within this window (ms)
8
+ const INTERACTION_RECENT_MS = 15000;
9
+ // Emit a periodic log while we keep waiting due to user activity (ms)
10
+ const INTERACTION_LOG_INTERVAL_MS = 60000;
11
+
8
12
  // ============================================================================
9
13
  // AUTH URL DETECTION
10
14
  // ============================================================================
@@ -61,92 +65,89 @@ export function isLikelyAuthUrl(url) {
61
65
  }
62
66
 
63
67
  // ============================================================================
64
- // TIMEOUTS
65
- // ============================================================================
66
-
67
- const DEFAULT_AUTO_AUTH_TIMEOUT = 5000; // 5 seconds for auto-auth check
68
- const DEFAULT_MANUAL_AUTH_TIMEOUT = 600000; // 10 minutes for manual auth
69
-
70
- // ============================================================================
71
- // REDIRECT DETECTION
68
+ // AUTH WAITING
72
69
  // ============================================================================
73
70
 
74
71
  /**
75
- * Detect redirect type: permanent redirect, auth flow, or same-domain auth path change.
76
- * @param {string} url - Original requested URL
77
- * @param {string} hostname - Original hostname
78
- * @param {string} currentUrl - Current page URL
79
- * @param {string} currentHostname - Current page hostname
80
- * @returns {Object} Object with redirect type and related info
72
+ * Wait for authentication to complete. Two-phase approach:
73
+ * 1. Quick SSO/cookie check (5s, fast poll) — handles auto-auth
74
+ * 2. Manual auth with login page detection (10-20 min, slow poll)
75
+ *
76
+ * @param {Page} page - The Puppeteer page instance
77
+ * @returns {Promise<{success: boolean, hostname?: string, error?: string, hint?: string}>}
81
78
  */
82
- export function detectRedirectType(url, hostname, currentUrl, currentHostname) {
83
- const isDifferentDomain = currentHostname !== hostname;
84
- const requestedAuthPage = isLikelyAuthUrl(url);
85
- const currentIsAuthPage = isLikelyAuthUrl(currentUrl);
86
- const isSameDomainAuthPath = !isDifferentDomain && currentIsAuthPage && !requestedAuthPage;
87
-
88
- // If user requested auth page directly and landed on it (same domain), return content
89
- if (requestedAuthPage && currentHostname === hostname && !isDifferentDomain) {
90
- return { type: 'requested_auth', currentHostname };
79
+ export async function waitForAuth(page) {
80
+ await ensureInteractionTracker(page);
81
+
82
+ // Phase 1: Quick SSO/cookie check (5s)
83
+ logger.info('Checking for auto-authentication (5s)...');
84
+ const auto = await pollUntilAuthDone(page, 5000, 500);
85
+ if (auto.success) {
86
+ logger.info(`Auto-authentication successful: ${page.url()}`);
87
+ return auto;
91
88
  }
92
-
93
- // No redirect scenario
94
- if (!isDifferentDomain && !isSameDomainAuthPath) {
95
- return { type: 'none' };
89
+
90
+ // Phase 2: Manual auth — detect login page to pick timeout
91
+ const { isLoginPage } = await detectLoginPage(page);
92
+ const timeout = isLoginPage ? 1200000 : 600000; // 20 min for login pages, 10 min otherwise
93
+ const timeoutMinutes = Math.round(timeout / 60000);
94
+
95
+ if (isLoginPage) {
96
+ logger.info(`Login page detected: ${page.url()}`);
96
97
  }
97
-
98
- const originalBase = getBaseDomain(hostname);
99
- const currentBase = getBaseDomain(currentHostname);
100
-
101
- // Permanent redirect: Different domain without auth patterns
102
- if (!currentIsAuthPage) {
103
- return { type: 'permanent', currentHostname };
98
+ logger.info(`Waiting for manual authentication (${timeoutMinutes} min timeout)...`);
99
+
100
+ const result = await pollUntilAuthDone(page, timeout, 2000);
101
+ if (result.success) {
102
+ logger.info(`Manual authentication successful: ${page.url()}`);
104
103
  }
105
-
106
- // Authentication flow
107
- const flowType = isSameDomainAuthPath ? 'same-domain path change' : 'cross-domain redirect';
108
- return {
109
- type: 'auth',
110
- flowType,
111
- originalBase,
112
- currentBase,
113
- currentUrl,
114
- hostname,
115
- currentHostname
116
- };
104
+ return result;
117
105
  }
118
106
 
119
107
  /**
120
- * Check if authentication auto-completes quickly (valid session/cookies).
121
- * Waits to see if the browser automatically completes auth (e.g., SSO with existing session).
108
+ * Poll page.url() until it leaves an auth URL, or timeout.
122
109
  * @param {Page} page - The Puppeteer page instance
123
- * @param {number} timeoutMs - How long to wait for auto-auth
124
- * @returns {Promise<Object>} Object with success status and final hostname
110
+ * @param {number} timeout - Max wait in ms
111
+ * @param {number} interval - Poll interval in ms
112
+ * @returns {Promise<{success: boolean, hostname?: string, error?: string, hint?: string}>}
125
113
  */
126
- export async function waitForAutoAuth(page, timeoutMs = DEFAULT_AUTO_AUTH_TIMEOUT) {
127
- logger.info(`Checking for auto-authentication (${timeoutMs}ms timeout)...`);
128
-
129
- const deadline = Date.now() + timeoutMs;
130
-
114
+ export async function pollUntilAuthDone(page, timeout, interval) {
115
+ const deadline = Date.now() + timeout;
116
+ let lastInteractionLog = 0;
117
+
131
118
  while (Date.now() < deadline) {
132
119
  try {
133
- const checkUrl = page.url();
134
-
135
- // Auth complete when we leave the auth page
136
- // Browser handles redirects - we just need to detect when auth flow ends
137
- if (!isLikelyAuthUrl(checkUrl)) {
138
- const checkHostname = new URL(checkUrl).hostname;
139
- logger.info(`Auto-authentication successful: ${checkUrl}`);
140
- return { success: true, hostname: checkHostname };
120
+ const url = page.url();
121
+ if (!isLikelyAuthUrl(url)) {
122
+ return { success: true, hostname: new URL(url).hostname };
141
123
  }
142
-
143
- await new Promise(resolve => setTimeout(resolve, 500));
144
- } catch (error) {
145
- await new Promise(resolve => setTimeout(resolve, 500));
124
+
125
+ // If user is actively interacting (typing/clicking), keep waiting without logging noise
126
+ const recentInteraction = await hasRecentInteraction(page);
127
+ if (recentInteraction) {
128
+ const now = Date.now();
129
+ if (now - lastInteractionLog >= INTERACTION_LOG_INTERVAL_MS) {
130
+ const waitedMs = now + interval - (deadline - timeout); // elapsed since start of this poll
131
+ const waitedSeconds = Math.round(waitedMs / 1000);
132
+ logger.info(`User activity detected on auth page; waiting for user to finish... (waited ~${waitedSeconds}s)`);
133
+ lastInteractionLog = now;
134
+ }
135
+ await new Promise(r => setTimeout(r, interval));
136
+ continue;
137
+ }
138
+ } catch {
139
+ // Page not accessible — keep waiting
146
140
  }
141
+ await new Promise(r => setTimeout(r, interval));
147
142
  }
148
-
149
- return { success: false };
143
+
144
+ const currentUrl = (() => { try { return page.url(); } catch { return 'unknown'; } })();
145
+ const minutes = Math.round(timeout / 60000);
146
+ return {
147
+ success: false,
148
+ error: `Authentication timeout after ${minutes} minutes`,
149
+ hint: `Tab is left open at ${currentUrl}. Complete authentication and retry.`
150
+ };
150
151
  }
151
152
 
152
153
  // ============================================================================
@@ -160,162 +161,68 @@ export async function waitForAutoAuth(page, timeoutMs = DEFAULT_AUTO_AUTH_TIMEOU
160
161
  */
161
162
  export async function detectLoginPage(page) {
162
163
  try {
163
- const result = await page.evaluate(() => {
164
+ return await page.evaluate(() => {
164
165
  const indicators = [];
165
-
166
- // Check for password input fields
167
- const passwordFields = document.querySelectorAll('input[type="password"]');
168
- if (passwordFields.length > 0) {
166
+
167
+ if (document.querySelectorAll('input[type="password"]').length > 0)
169
168
  indicators.push('password field');
170
- }
171
-
172
- // Check for username/email fields near password fields
173
- const usernameFields = document.querySelectorAll(
169
+
170
+ if (document.querySelectorAll(
174
171
  'input[type="email"], input[name*="user"], input[name*="email"], input[name*="login"], input[id*="user"], input[id*="email"]'
175
- );
176
- if (usernameFields.length > 0) {
172
+ ).length > 0)
177
173
  indicators.push('username/email field');
178
- }
179
-
180
- // Check for login-related buttons
181
- const buttons = Array.from(document.querySelectorAll('button, input[type="submit"]'));
182
- const loginButtons = buttons.filter(btn => {
183
- const text = (btn.textContent || btn.value || '').toLowerCase();
184
- return text.includes('sign in') || text.includes('log in') || text.includes('login') ||
185
- text.includes('submit') || text.includes('continue');
186
- });
187
- if (loginButtons.length > 0) {
188
- indicators.push('login button');
189
- }
190
-
191
- // Check for common login form identifiers
192
- const forms = document.querySelectorAll('form[id*="login"], form[id*="signin"], form[class*="login"], form[class*="signin"]');
193
- if (forms.length > 0) {
174
+
175
+ const loginBtn = Array.from(document.querySelectorAll('button, input[type="submit"]'))
176
+ .some(btn => /sign in|log in|login|submit|continue/i.test(btn.textContent || btn.value || ''));
177
+ if (loginBtn) indicators.push('login button');
178
+
179
+ if (document.querySelectorAll('form[id*="login"], form[id*="signin"], form[class*="login"], form[class*="signin"]').length > 0)
194
180
  indicators.push('login form');
195
- }
196
-
197
- // Check page title
181
+
198
182
  const title = document.title.toLowerCase();
199
- if (title.includes('sign in') || title.includes('log in') || title.includes('login')) {
183
+ if (title.includes('sign in') || title.includes('log in') || title.includes('login'))
200
184
  indicators.push('login page title');
201
- }
202
-
203
- return {
204
- isLoginPage: indicators.length >= 2, // Require at least 2 indicators
205
- indicators
206
- };
185
+
186
+ return { isLoginPage: indicators.length >= 2, indicators };
207
187
  });
208
-
209
- return result;
210
- } catch (error) {
211
- // If page.evaluate fails (e.g., mock in tests), return safe default
188
+ } catch {
212
189
  return { isLoginPage: false, indicators: [] };
213
190
  }
214
191
  }
215
192
 
216
193
  // ============================================================================
217
- // MANUAL AUTH WITH STATUS CALLBACKS
194
+ // USER INTERACTION TRACKING
218
195
  // ============================================================================
219
196
 
220
- const EXTENDED_LOGIN_TIMEOUT = 1200000; // 20 minutes when login page detected
221
-
222
197
  /**
223
- * Wait for user to complete manual authentication.
224
- * Detects login pages and extends timeout, sends status updates via callback.
225
- * @param {Page} page - The Puppeteer page instance
226
- * @param {number} timeoutMs - Base timeout for manual auth
227
- * @param {Object} options - Optional settings
228
- * @param {Function} options.onStatusChange - Callback for status updates
229
- * @returns {Promise<Object>} Object with success status, final hostname, and optional error
198
+ * Inject lightweight listeners to record recent user interaction on the page.
199
+ * Stored on window.__mcpAuthLastInteraction.
230
200
  */
231
- export async function waitForManualAuth(page, timeoutMs = DEFAULT_MANUAL_AUTH_TIMEOUT, options = {}) {
232
- const { onStatusChange } = options;
233
-
234
- // Detect if this is a login page requiring user input
235
- const loginDetection = await detectLoginPage(page);
236
- const isLoginPage = loginDetection.isLoginPage;
237
-
238
- // Extend timeout for login pages (user needs time to type credentials)
239
- const shouldExtendTimeout = isLoginPage && timeoutMs < EXTENDED_LOGIN_TIMEOUT;
240
- const effectiveTimeout = shouldExtendTimeout ? EXTENDED_LOGIN_TIMEOUT : timeoutMs;
241
- const effectiveTimeoutMinutes = Math.round(effectiveTimeout / 60000);
242
-
243
- // Log login page detection
244
- if (isLoginPage && shouldExtendTimeout) {
245
- logger.info(`Login page detected: ${page.url()} (${loginDetection.indicators.join(', ')})`);
246
- logger.info(`Extended wait time to ${effectiveTimeoutMinutes} minutes for user authentication`);
247
- }
248
-
249
- logger.info(`Waiting for manual authentication (${effectiveTimeoutMinutes} min timeout, loginPage=${isLoginPage})...`);
250
-
251
- // Send initial waiting notification
252
- if (onStatusChange) {
253
- onStatusChange({
254
- status: 'waiting',
255
- message: isLoginPage
256
- ? `⏳ Waiting for you to complete authentication. Login page detected - take your time (${effectiveTimeoutMinutes} min timeout).`
257
- : `⏳ Waiting for authentication to complete (${effectiveTimeoutMinutes} min timeout)...`,
258
- isLoginPage,
259
- indicators: loginDetection.indicators,
260
- remainingSeconds: Math.round(effectiveTimeout / 1000),
261
- currentUrl: page.url()
201
+ async function ensureInteractionTracker(page) {
202
+ try {
203
+ await page.evaluate(() => {
204
+ if (window.__mcpAuthTrackerInstalled) return;
205
+ const updateInteraction = () => { window.__mcpAuthLastInteraction = Date.now(); };
206
+ ['pointerdown', 'keydown', 'input', 'paste'].forEach(evt => {
207
+ window.addEventListener(evt, updateInteraction, { capture: true, passive: true });
208
+ });
209
+ window.__mcpAuthTrackerInstalled = true;
262
210
  });
211
+ } catch {
212
+ // best effort
263
213
  }
264
-
265
- const deadline = Date.now() + effectiveTimeout;
266
- let lastStatusUpdate = Date.now();
267
-
268
- while (Date.now() < deadline) {
269
- try {
270
- const checkUrl = page.url();
271
-
272
- // Auth complete when we leave the auth page
273
- if (!isLikelyAuthUrl(checkUrl)) {
274
- const checkHostname = new URL(checkUrl).hostname;
275
- logger.info(`Manual authentication successful: ${checkUrl}`);
276
-
277
- if (onStatusChange) {
278
- onStatusChange({
279
- status: 'completed',
280
- message: `✅ Authentication completed!`,
281
- currentUrl: checkUrl
282
- });
283
- }
284
-
285
- return { success: true, hostname: checkHostname };
286
- }
287
-
288
- // Send periodic status updates (every 30 seconds)
289
- if (onStatusChange && Date.now() - lastStatusUpdate > 30000) {
290
- const remainingSeconds = Math.round((deadline - Date.now()) / 1000);
291
- onStatusChange({
292
- status: 'waiting',
293
- message: `⏳ Still waiting for authentication... (${Math.round(remainingSeconds / 60)} min remaining)`,
294
- remainingSeconds,
295
- currentUrl: checkUrl
296
- });
297
- lastStatusUpdate = Date.now();
298
- }
299
-
300
- await new Promise(resolve => setTimeout(resolve, 2000));
301
- } catch (error) {
302
- await new Promise(resolve => setTimeout(resolve, 2000));
303
- }
304
- }
305
-
306
- const currentUrl = page.url();
307
-
308
- if (onStatusChange) {
309
- onStatusChange({
310
- status: 'timeout',
311
- message: `⚠️ Authentication timeout after ${effectiveTimeoutMinutes} minutes`,
312
- currentUrl
313
- });
214
+ }
215
+
216
+ /**
217
+ * Check if user interacted with the page recently.
218
+ * @param {Page} page
219
+ * @returns {Promise<boolean>}
220
+ */
221
+ async function hasRecentInteraction(page) {
222
+ try {
223
+ const last = await page.evaluate(() => window.__mcpAuthLastInteraction || 0);
224
+ return last > 0 && (Date.now() - last) < INTERACTION_RECENT_MS;
225
+ } catch {
226
+ return false;
314
227
  }
315
-
316
- return {
317
- success: false,
318
- error: `Authentication timeout after ${effectiveTimeoutMinutes} minutes`,
319
- hint: `Tab is left open at ${currentUrl}. Complete authentication and retry.`
320
- };
321
228
  }
package/src/core/html.js CHANGED
@@ -15,6 +15,9 @@ export function cleanHtml(html) {
15
15
  if (!html) return "";
16
16
 
17
17
  let cleaned = html;
18
+
19
+ // Remove spaces between tags
20
+ cleaned = cleaned.replace(/>\s+</g, '><');
18
21
 
19
22
  // Remove HTML comments
20
23
  cleaned = cleaned.replace(/<!--[\s\S]*?-->/g, '');
@@ -76,9 +79,6 @@ export function cleanHtml(html) {
76
79
  // Collapse multiple whitespace/newlines into single space
77
80
  cleaned = cleaned.replace(/\s+/g, ' ');
78
81
 
79
- // Remove spaces between tags
80
- cleaned = cleaned.replace(/>\s+</g, '><');
81
-
82
82
  return cleaned;
83
83
  }
84
84
 
@@ -1,42 +1,78 @@
1
1
  /**
2
- * Logger - Simple logging for MCPBrowser
3
- *
4
- * All output goes to stderr so it doesn't interfere with MCP protocol on stdout.
2
+ * Logger - Emits to stderr and, when available, via MCP logging notifications.
3
+ * Stderr stays the primary sink to avoid interfering with MCP stdout traffic.
5
4
  */
6
5
 
7
6
  const PREFIX = '[MCPBrowser]';
8
7
 
8
+ // Optional MCP server reference for notifications/message logs.
9
+ let mcpServer = null;
10
+
11
+ // Optional stdout mirroring (off by default to avoid corrupting MCP stdout).
12
+ // Auto-enable during tests so test runners capture output.
13
+ let consoleOutputEnabled = process.env.NODE_ENV === 'test';
14
+ const envStdout = process.env.MCPBROWSER_LOG_TO_STDOUT;
15
+ if (envStdout && ['1', 'true', 'yes', 'on'].includes(envStdout.toLowerCase())) {
16
+ consoleOutputEnabled = true;
17
+ }
18
+
9
19
  /**
10
- * Log an info message
11
- * @param {string} message - The message to log
20
+ * Attach the MCP server so logs can flow to the agent via notifications/message.
21
+ * @param {import('@modelcontextprotocol/sdk/dist/esm/server/index.js').Server} server
12
22
  */
13
- function info(message) {
14
- console.error(`${PREFIX} ${message}`);
23
+ function attachServer(server) {
24
+ mcpServer = server;
15
25
  }
16
26
 
17
27
  /**
18
- * Log a warning message
19
- * @param {string} message - The message to log
28
+ * Enable/disable stdout mirroring. Avoid enabling when running under MCP stdio transport unless the client tolerates extra stdout noise.
29
+ * @param {boolean} enabled
20
30
  */
31
+ function setConsoleOutput(enabled = true) {
32
+ consoleOutputEnabled = !!enabled;
33
+ }
34
+
35
+ async function notifyAgent(level, data) {
36
+ if (!mcpServer?.sendLoggingMessage) return;
37
+ try {
38
+ // Skip if client requested a higher threshold.
39
+ if (mcpServer.isMessageIgnored?.(level)) return;
40
+ await mcpServer.sendLoggingMessage({ level, logger: 'mcpbrowser', data });
41
+ } catch (err) {
42
+ // Fall back to stderr without recursing through logger.
43
+ try {
44
+ process.stderr.write(`${PREFIX} logging notification failed: ${err?.message || err}\n`);
45
+ } catch (_) {
46
+ /* ignore */
47
+ }
48
+ }
49
+ }
50
+
51
+ function emit(level, message, symbol = '') {
52
+ const line = symbol ? `${PREFIX} ${symbol} ${message}` : `${PREFIX} ${message}`;
53
+ console.error(line);
54
+ if (consoleOutputEnabled) {
55
+ console.log(line);
56
+ }
57
+ void notifyAgent(level, message);
58
+ }
59
+
60
+ function info(message) {
61
+ emit('info', message);
62
+ }
63
+
21
64
  function warn(message) {
22
- console.error(`${PREFIX} ⚠️ ${message}`);
65
+ emit('warning', message, '⚠️');
23
66
  }
24
67
 
25
- /**
26
- * Log an error message
27
- * @param {string} message - The message to log
28
- */
29
68
  function error(message) {
30
- console.error(`${PREFIX} ${message}`);
69
+ emit('error', message, '');
31
70
  }
32
71
 
33
- /**
34
- * Log a debug message
35
- * @param {string} message - The message to log
36
- */
37
72
  function debug(message) {
38
- console.error(`${PREFIX} 🔍 ${message}`);
73
+ emit('debug', message, '🔍');
39
74
  }
40
75
 
41
- export const logger = { info, warn, error, debug };
76
+ export const logger = { info, warn, error, debug, attachServer, setConsoleOutput };
77
+ export { attachServer, setConsoleOutput };
42
78
  export default logger;