mcpbrowser 0.3.57 → 0.3.59

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcpbrowser",
3
- "version": "0.3.57",
3
+ "version": "0.3.59",
4
4
  "mcpName": "io.github.cherchyk/mcpbrowser",
5
5
  "type": "module",
6
6
  "description": "MCP browser server — loads and interacts with web pages using the user's Chromium-based browser with full JavaScript execution. Handles authentication, SSO, and anti-bot protection automatically via the user's existing browser session.",
@@ -25,11 +25,12 @@
25
25
  */
26
26
 
27
27
  import { getBrowser, getValidatedPage } from '../core/browser.js';
28
- import { extractAndProcessHtml, waitForPageReady } from '../core/page.js';
28
+ import { extractAndProcessHtml, waitForPageReady, getLargeHtmlHints } from '../core/page.js';
29
29
  import { MCPResponse, InformationalResponse } from '../core/responses.js';
30
30
  import logger from '../core/logger.js';
31
31
  import { getPluginNextSteps, getRecommendedPlugins } from '../core/plugin-loader.js';
32
32
  import { scanPageForms } from './detect-forms.js';
33
+ import { scanScrollableAreas } from './scroll-page.js';
33
34
 
34
35
  /**
35
36
  * @typedef {import('@modelcontextprotocol/sdk/types.js').Tool} Tool
@@ -39,7 +40,7 @@ import { scanPageForms } from './detect-forms.js';
39
40
  * Structured response for browser_click_element with JS fallback metadata
40
41
  */
41
42
  export class ClickWithFallbackResponse extends MCPResponse {
42
- constructor({ status, fallbackUsed = false, nativeAttempt, fallbackAttempt, postClickWait, currentUrl, html = null, message, nextSteps = [], recommendedPlugins = [], formData = null }) {
43
+ constructor({ status, fallbackUsed = false, nativeAttempt, fallbackAttempt, postClickWait, currentUrl, html = null, message, nextSteps = [], recommendedPlugins = [], formData = null, scrollableAreas = [] }) {
43
44
  super(nextSteps);
44
45
  this.status = status;
45
46
  this.fallbackUsed = fallbackUsed;
@@ -53,6 +54,7 @@ export class ClickWithFallbackResponse extends MCPResponse {
53
54
  this.forms = formData?.forms || [];
54
55
  this.orphanedFields = formData?.orphanedFields || [];
55
56
  this.totalFieldCount = formData?.totalFieldCount || 0;
57
+ this.scrollableAreas = scrollableAreas;
56
58
  }
57
59
 
58
60
  _getAdditionalFields() {
@@ -68,7 +70,8 @@ export class ClickWithFallbackResponse extends MCPResponse {
68
70
  recommendedPlugins: this.recommendedPlugins,
69
71
  forms: this.forms,
70
72
  orphanedFields: this.orphanedFields,
71
- totalFieldCount: this.totalFieldCount
73
+ totalFieldCount: this.totalFieldCount,
74
+ scrollableAreas: this.scrollableAreas
72
75
  };
73
76
  }
74
77
 
@@ -99,6 +102,7 @@ export const CLICK_ELEMENT_TOOL = {
99
102
  returnHtml: { type: "boolean", description: "Whether to wait for stability and return HTML after clicking. Set to false for fast form interactions (checkboxes, radio buttons).", default: true },
100
103
  removeUnnecessaryHTML: { type: "boolean", description: "Remove Unnecessary HTML for size reduction by 90%. Only used when returnHtml is true.", default: true },
101
104
  postClickWait: { type: "number", description: "Milliseconds to wait after click for SPAs to render dynamic content.", default: 1000 },
105
+ htmlSelector: { type: "string", description: "CSS selector to extract a specific DOM subtree from the post-click page instead of the full page. Use on heavy SPAs (e.g., ADO, Jira) to reduce response size. Only used when returnHtml is true. Example: '.activity-feed', '[role=\"main\"]'." },
102
106
  detectForms: { type: "boolean", description: "Scan page for forms after click and return structured form data (fields, selectors, submit buttons, orphaned inputs). Only applies when returnHtml=true. Set to true when you need to fill or interact with forms after clicking.", default: false }
103
107
  },
104
108
  required: ["url"],
@@ -114,16 +118,16 @@ export const CLICK_ELEMENT_TOOL = {
114
118
  properties: {
115
119
  status: { type: "string", enum: ["success", "timeout", "error"] },
116
120
  durationMs: { type: "number" },
117
- error: { type: ["string", "null"] }
121
+ error: { type: "string" }
118
122
  },
119
123
  required: ["status", "durationMs"]
120
124
  },
121
125
  fallbackAttempt: {
122
- type: ["object", "null"],
126
+ type: "object",
123
127
  properties: {
124
128
  status: { type: "string", enum: ["success", "timeout", "error"] },
125
129
  durationMs: { type: "number" },
126
- error: { type: ["string", "null"] }
130
+ error: { type: "string" }
127
131
  },
128
132
  required: ["status", "durationMs"],
129
133
  description: "Present when fallbackUsed is true"
@@ -140,7 +144,7 @@ export const CLICK_ELEMENT_TOOL = {
140
144
  currentUrl: { type: "string", description: "URL after click" },
141
145
  message: { type: "string", description: "Status message" },
142
146
  html: {
143
- type: ["string", "null"],
147
+ type: "string",
144
148
  description: "Page HTML if returnHtml was true, null otherwise"
145
149
  },
146
150
  forms: { type: "array", items: { type: "object" }, description: "Detected forms with fields, selectors, and metadata (when returnHtml is true)" },
@@ -155,6 +159,21 @@ export const CLICK_ELEMENT_TOOL = {
155
159
  type: "array",
156
160
  items: { type: "object" },
157
161
  description: "Detected site-specific plugins available for this domain"
162
+ },
163
+ scrollableAreas: {
164
+ type: "array",
165
+ items: {
166
+ type: "object",
167
+ properties: {
168
+ selector: { type: "string" },
169
+ scrollHeight: { type: "number" },
170
+ clientHeight: { type: "number" },
171
+ scrollTop: { type: "number" },
172
+ hiddenPixels: { type: "number" },
173
+ description: { type: "string" }
174
+ }
175
+ },
176
+ description: "Scrollable containers on the page. Pass a selector to browser_scroll_page's 'container' parameter to scroll within a specific area."
158
177
  }
159
178
  },
160
179
  required: ["status", "fallbackUsed", "nativeAttempt", "currentUrl", "message", "html", "nextSteps"],
@@ -199,7 +218,7 @@ export const CLICK_ELEMENT_TOOL = {
199
218
  * returnHtml: false
200
219
  * });
201
220
  */
202
- export async function clickElement({ url, selector, text, waitForElementTimeout = 30000, returnHtml = true, removeUnnecessaryHTML = true, postClickWait = 1000, detectForms = false }) {
221
+ export async function clickElement({ url, selector, text, waitForElementTimeout = 30000, returnHtml = true, removeUnnecessaryHTML = true, htmlSelector = null, postClickWait = 1000, detectForms = false }) {
203
222
  logger.info(`browser_click_element called: ${selector || `text="${text}"`}`);
204
223
 
205
224
  if (!url) {
@@ -290,7 +309,7 @@ export async function clickElement({ url, selector, text, waitForElementTimeout
290
309
  'The element could not be located on the page. It may be hidden, dynamically loaded, or the selector/text may be incorrect.',
291
310
  [
292
311
  "Use MCPBrowser's browser_get_current_html to verify page content",
293
- "Use MCPBrowser's browser_take_screenshot to see the visual layout if HTML is unclear",
312
+ "Use MCPBrowser's browser_take_screenshot with fullPage=true to see the full visual layout if HTML is unclear",
294
313
  "Try a different selector or text",
295
314
  "Check if the element is visible on the page"
296
315
  ]
@@ -344,7 +363,7 @@ export async function clickElement({ url, selector, text, waitForElementTimeout
344
363
  }
345
364
 
346
365
  const currentUrl = page.url();
347
- const html = finalStatus === 'success' && returnHtml ? await extractAndProcessHtml(page, removeUnnecessaryHTML) : null;
366
+ const html = finalStatus === 'success' && returnHtml ? await extractAndProcessHtml(page, removeUnnecessaryHTML, htmlSelector) : null;
348
367
 
349
368
  // Scan for forms when requested and returning HTML (lightweight, ~50-100ms)
350
369
  let formData = null;
@@ -356,6 +375,16 @@ export async function clickElement({ url, selector, text, waitForElementTimeout
356
375
  }
357
376
  }
358
377
 
378
+ // Scan for scrollable areas when returning HTML (lightweight, ~20-50ms)
379
+ let scrollableAreas = [];
380
+ if (finalStatus === 'success' && returnHtml) {
381
+ try {
382
+ scrollableAreas = await scanScrollableAreas(page);
383
+ } catch (err) {
384
+ logger.debug(`Scrollable area scan failed (non-fatal): ${err.message}`);
385
+ }
386
+ }
387
+
359
388
  const baseMessage = selector ? `Clicked element: ${selector}` : `Clicked element with text: "${text}"`;
360
389
  const message = finalStatus === 'success'
361
390
  ? baseMessage
@@ -365,16 +394,17 @@ export async function clickElement({ url, selector, text, waitForElementTimeout
365
394
 
366
395
  const nextSteps = returnHtml
367
396
  ? [
397
+ ...(html ? getLargeHtmlHints(html, htmlSelector) : []),
368
398
  ...(html ? getPluginNextSteps(currentUrl, html) : []),
369
399
  "Use MCPBrowser's browser_click_element again to navigate further",
370
400
  "Use MCPBrowser's browser_type_text to fill forms if needed",
371
401
  "Use MCPBrowser's browser_get_current_html to refresh page state",
372
- "Use MCPBrowser's browser_take_screenshot if page has popups or visual content that's hard to parse from HTML",
402
+ "Use MCPBrowser's browser_take_screenshot with fullPage=true if page has popups or visual content that's hard to parse from HTML",
373
403
  "Use MCPBrowser's browser_close_tab when finished"
374
404
  ]
375
405
  : [
376
406
  "Use MCPBrowser's browser_get_current_html to see updated page state",
377
- "Use MCPBrowser's browser_take_screenshot if the page has popups, modals, or visual content",
407
+ "Use MCPBrowser's browser_take_screenshot with fullPage=true if the page has popups, modals, or visual content",
378
408
  "Use MCPBrowser's browser_click_element or MCPBrowser's browser_type_text for more interactions",
379
409
  "Use MCPBrowser's browser_close_tab when finished"
380
410
  ];
@@ -392,7 +422,8 @@ export async function clickElement({ url, selector, text, waitForElementTimeout
392
422
  message,
393
423
  nextSteps,
394
424
  recommendedPlugins: html ? getRecommendedPlugins(currentUrl, html) : [],
395
- formData
425
+ formData,
426
+ scrollableAreas
396
427
  });
397
428
  } catch (err) {
398
429
  logger.error(`browser_click_element failed: ${err.message}`);
@@ -401,7 +432,7 @@ export async function clickElement({ url, selector, text, waitForElementTimeout
401
432
  'The element was found but could not be clicked. It may be covered by another element, not interactable, or the page may have changed.',
402
433
  [
403
434
  "Use MCPBrowser's browser_get_current_html to check current page state",
404
- "Use MCPBrowser's browser_take_screenshot to see what's visually blocking the element",
435
+ "Use MCPBrowser's browser_take_screenshot with fullPage=true to see what's visually blocking the element",
405
436
  "Verify the selector or text is correct",
406
437
  "Try MCPBrowser's browser_fetch_webpage to reload if page is stale"
407
438
  ]
@@ -123,7 +123,7 @@ export const DETECT_FORMS_TOOL = {
123
123
  }
124
124
  },
125
125
  submitButton: {
126
- type: ["object", "null"],
126
+ type: "object",
127
127
  properties: {
128
128
  selector: { type: "string" },
129
129
  text: { type: "string" },
@@ -502,7 +502,7 @@ function buildNextSteps(forms, orphanedFields) {
502
502
  steps.push("Use MCPBrowser's browser_type_text for orphaned fields (SPA inputs not inside a <form>)");
503
503
  }
504
504
 
505
- steps.push("Use MCPBrowser's browser_take_screenshot if form layout is unclear from the data");
505
+ steps.push("Use MCPBrowser's browser_take_screenshot with fullPage=true if form layout is unclear from the data");
506
506
  steps.push("Use MCPBrowser's browser_get_current_html to see full page HTML");
507
507
 
508
508
  return steps;
@@ -36,8 +36,13 @@ export class ExecuteJavascriptResponse extends MCPResponse {
36
36
  }
37
37
 
38
38
  _getAdditionalFields() {
39
+ // outputSchema declares result as type: 'string' (serialized).
40
+ // Ensure non-string values (numbers, objects, arrays) are stringified.
41
+ const serializedResult = this.result == null ? null
42
+ : typeof this.result === 'string' ? this.result
43
+ : JSON.stringify(this.result);
39
44
  return {
40
- result: this.result,
45
+ result: serializedResult,
41
46
  type: this.type,
42
47
  executionTimeMs: this.executionTimeMs,
43
48
  truncated: this.truncated,
@@ -74,14 +79,14 @@ export const EXECUTE_JAVASCRIPT_TOOL = {
74
79
  outputSchema: {
75
80
  type: 'object',
76
81
  properties: {
77
- result: { description: 'Serialized result of the script', nullable: true },
82
+ result: { type: 'string', description: 'Serialized result of the script', nullable: true },
78
83
  type: { type: 'string', description: 'Type of the returned result' },
79
84
  executionTimeMs: { type: 'number', description: 'Script execution duration' },
80
85
  truncated: { type: 'boolean', description: 'True if result was capped to size limit' },
81
86
  urlChanged: { type: 'boolean', description: 'True if page URL changed during execution' },
82
87
  currentUrl: { type: 'string', description: 'URL after execution' },
83
88
  nextSteps: { type: 'array', items: { type: 'string' } },
84
- error: { type: ['object', 'null'], description: 'Error object when script throws or times out' },
89
+ error: { type: 'object', description: 'Error object when script throws or times out' },
85
90
  recommendedPlugins: {
86
91
  type: 'array',
87
92
  items: { type: 'object' },
@@ -109,11 +114,14 @@ function buildErrorResponse(message, reason, nextSteps) {
109
114
  return new InformationalResponse(message, reason, nextSteps);
110
115
  }
111
116
 
117
+ const VALID_RETURN_TYPES = new Set(['json', 'text', 'void']);
118
+
112
119
  export async function executeJavascript({ url, script, timeoutMs = EXECUTION_TIMEOUT_DEFAULT_MS, returnType = 'json' }) {
113
120
  logger.info(`browser_execute_javascript called: ${url}`);
114
121
 
115
122
  if (!url) throw new Error('url parameter is required');
116
123
  if (!script || typeof script !== 'string' || !script.trim()) throw new Error('script parameter is required');
124
+ if (!VALID_RETURN_TYPES.has(returnType)) throw new Error(`Invalid returnType: '${returnType}'. Must be one of: json, text, void`);
117
125
 
118
126
  let hostname;
119
127
  try {
@@ -154,7 +162,15 @@ export async function executeJavascript({ url, script, timeoutMs = EXECUTION_TIM
154
162
  }
155
163
 
156
164
  const effectiveTimeout = clampTimeout(timeoutMs);
157
- const beforeUrl = page.url();
165
+
166
+ // Capture beforeUrl via evaluate for consistency with post-exec currentUrl check
167
+ let beforeUrl;
168
+ try {
169
+ beforeUrl = await page.evaluate(() => location.href);
170
+ } catch {
171
+ beforeUrl = page.url();
172
+ }
173
+
158
174
  const start = Date.now();
159
175
 
160
176
  const evalPromise = page.evaluate(async ({ userScript, mode }) => {
@@ -183,14 +199,17 @@ export async function executeJavascript({ url, script, timeoutMs = EXECUTION_TIM
183
199
  }, { userScript: script, mode: returnType });
184
200
 
185
201
  let evalResult;
202
+ let timeoutTimer;
186
203
  try {
187
204
  evalResult = await Promise.race([
188
205
  evalPromise,
189
- new Promise((_, reject) => setTimeout(() => reject(new Error(`Execution timed out after ${effectiveTimeout}ms`)), effectiveTimeout))
206
+ new Promise((_, reject) => {
207
+ timeoutTimer = setTimeout(() => reject(new Error(`Execution timed out after ${effectiveTimeout}ms`)), effectiveTimeout);
208
+ })
190
209
  ]);
191
210
  } catch (err) {
192
211
  const executionTimeMs = Date.now() - start;
193
- let currentUrl = page.url();
212
+ let currentUrl = beforeUrl;
194
213
  try {
195
214
  currentUrl = await page.evaluate(() => location.href);
196
215
  } catch {
@@ -205,21 +224,23 @@ export async function executeJavascript({ url, script, timeoutMs = EXECUTION_TIM
205
224
  currentUrl,
206
225
  error: { name: 'TimeoutError', message: err.message }
207
226
  });
227
+ } finally {
228
+ clearTimeout(timeoutTimer);
208
229
  }
209
230
 
210
231
  const executionTimeMs = Date.now() - start;
211
- let currentUrl = page.url();
232
+ let currentUrl = beforeUrl;
212
233
  try {
213
234
  currentUrl = await page.evaluate(() => location.href);
214
235
  } catch {
215
- // If we can't read location (e.g., cross-origin), fall back to page.url()
236
+ // If we can't read location (e.g., cross-origin), fall back to beforeUrl
216
237
  }
217
238
  const urlChanged = currentUrl !== beforeUrl;
218
239
 
219
240
  // Detect CSP block or silent evaluation failure:
220
241
  // When page.evaluate() is blocked by CSP, Puppeteer returns undefined (not an error).
221
- // Distinguish this from a script that intentionally returns nothing.
222
- if (evalResult === undefined || evalResult === null) {
242
+ // Our inner wrapper always returns { value, type } or { error }, never undefined.
243
+ if (evalResult === undefined) {
223
244
  return new ExecuteJavascriptResponse({
224
245
  result: null,
225
246
  type: 'undefined',
@@ -4,12 +4,13 @@
4
4
  */
5
5
 
6
6
  import { getBrowser, domainPages } from '../core/browser.js';
7
- import { getOrCreatePage, queueRequest, navigateToUrl, waitForPageReady, extractAndProcessHtml } from '../core/page.js';
7
+ import { getOrCreatePage, queueRequest, navigateToUrl, waitForPageReady, extractAndProcessHtml, getLargeHtmlHints } from '../core/page.js';
8
8
  import { isLikelyAuthUrl, waitForAuth } from '../core/auth.js';
9
9
  import { MCPResponse, ErrorResponse, HttpStatusResponse, InformationalResponse } from '../core/responses.js';
10
10
  import logger from '../core/logger.js';
11
11
  import { getPluginNextSteps, getRecommendedPlugins } from '../core/plugin-loader.js';
12
12
  import { scanPageForms } from './detect-forms.js';
13
+ import { scanScrollableAreas } from './scroll-page.js';
13
14
 
14
15
  /**
15
16
  * @typedef {import('@modelcontextprotocol/sdk/types.js').Tool} Tool
@@ -30,7 +31,7 @@ export class FetchPageSuccessResponse extends MCPResponse {
30
31
  * @param {Array} [recommendedPlugins] - Detected plugin metadata
31
32
  * @param {Object} [formData] - Detected forms data
32
33
  */
33
- constructor(currentUrl, html, nextSteps, recommendedPlugins = [], formData = null) {
34
+ constructor(currentUrl, html, nextSteps, recommendedPlugins = [], formData = null, scrollableAreas = []) {
34
35
  super(nextSteps);
35
36
 
36
37
  if (typeof currentUrl !== 'string') {
@@ -46,6 +47,7 @@ export class FetchPageSuccessResponse extends MCPResponse {
46
47
  this.forms = formData?.forms || [];
47
48
  this.orphanedFields = formData?.orphanedFields || [];
48
49
  this.totalFieldCount = formData?.totalFieldCount || 0;
50
+ this.scrollableAreas = scrollableAreas;
49
51
  }
50
52
 
51
53
  _getAdditionalFields() {
@@ -55,7 +57,8 @@ export class FetchPageSuccessResponse extends MCPResponse {
55
57
  recommendedPlugins: this.recommendedPlugins,
56
58
  forms: this.forms,
57
59
  orphanedFields: this.orphanedFields,
58
- totalFieldCount: this.totalFieldCount
60
+ totalFieldCount: this.totalFieldCount,
61
+ scrollableAreas: this.scrollableAreas
59
62
  };
60
63
  }
61
64
 
@@ -81,8 +84,9 @@ export const FETCH_WEBPAGE_TOOL = {
81
84
  url: { type: "string", description: "The URL to fetch" },
82
85
  browser: {
83
86
  type: "string",
84
- description: "Browser to use: 'chrome' or 'edge'. Leave empty for auto-detection. Uses CDP to connect to the user's existing browser session.",
85
- enum: ["", "chrome", "edge"]
87
+ enum: ["chrome", "edge", "brave"],
88
+ description: "Browser to use. Uses CDP to connect to the user's existing browser session.",
89
+ default: "chrome"
86
90
  },
87
91
  removeUnnecessaryHTML: { type: "boolean", description: "Remove Unnecessary HTML for size reduction by 90%.", default: true },
88
92
  selector: { type: "string", description: "CSS selector to extract a specific DOM subtree instead of the full page. Use to scope extraction and reduce response size (e.g., 'main', '[role=\"main\"]', 'body > div:first-child'). If no elements match, falls back to full page with a note." },
@@ -109,6 +113,21 @@ export const FETCH_WEBPAGE_TOOL = {
109
113
  type: "array",
110
114
  items: { type: "object" },
111
115
  description: "Detected site-specific plugins available for this domain"
116
+ },
117
+ scrollableAreas: {
118
+ type: "array",
119
+ items: {
120
+ type: "object",
121
+ properties: {
122
+ selector: { type: "string" },
123
+ scrollHeight: { type: "number" },
124
+ clientHeight: { type: "number" },
125
+ scrollTop: { type: "number" },
126
+ hiddenPixels: { type: "number" },
127
+ description: { type: "string" }
128
+ }
129
+ },
130
+ description: "Scrollable containers on the page. Pass a selector to browser_scroll_page's 'container' parameter to scroll within a specific area."
112
131
  }
113
132
  },
114
133
  required: ["currentUrl", "html", "nextSteps"],
@@ -246,6 +265,14 @@ async function doFetchPage({ url, browser, removeUnnecessaryHTML, selector, post
246
265
  logger.debug(`Form scan failed (non-fatal): ${err.message}`);
247
266
  }
248
267
  }
268
+
269
+ // Scan for scrollable areas (lightweight, ~20-50ms)
270
+ let scrollableAreas = [];
271
+ try {
272
+ scrollableAreas = await scanScrollableAreas(page);
273
+ } catch (err) {
274
+ logger.debug(`Scrollable area scan failed (non-fatal): ${err.message}`);
275
+ }
249
276
 
250
277
  logger.info(`browser_fetch_webpage completed: ${page.url()}`);
251
278
 
@@ -259,15 +286,17 @@ async function doFetchPage({ url, browser, removeUnnecessaryHTML, selector, post
259
286
  page.url(),
260
287
  processedHtml,
261
288
  [
289
+ ...getLargeHtmlHints(processedHtml, selector),
262
290
  ...getPluginNextSteps(page.url(), processedHtml),
263
291
  "Use MCPBrowser's browser_click_element to interact with buttons/links on the page",
264
292
  "Use MCPBrowser's browser_type_text to fill in form fields",
265
293
  "Use MCPBrowser's browser_get_current_html to re-check page state after interactions",
266
- "Use MCPBrowser's browser_take_screenshot if page has charts, images, or complex visual layout that's hard to understand from HTML",
294
+ "Use MCPBrowser's browser_take_screenshot with fullPage=true to capture the entire page visually (charts, images, complex layouts)",
267
295
  "Use MCPBrowser's browser_close_tab when finished to free browser resources"
268
296
  ],
269
297
  getRecommendedPlugins(page.url(), processedHtml),
270
- formData
298
+ formData,
299
+ scrollableAreas
271
300
  );
272
301
  } catch (err) {
273
302
  logger.error(`browser_fetch_webpage failed: ${err.message || String(err)}`);
@@ -3,11 +3,12 @@
3
3
  */
4
4
 
5
5
  import { getBrowser, getValidatedPage } from '../core/browser.js';
6
- import { extractAndProcessHtml } from '../core/page.js';
6
+ import { extractAndProcessHtml, getLargeHtmlHints } from '../core/page.js';
7
7
  import { MCPResponse, InformationalResponse } from '../core/responses.js';
8
8
  import logger from '../core/logger.js';
9
9
  import { getPluginNextSteps, getRecommendedPlugins } from '../core/plugin-loader.js';
10
10
  import { scanPageForms } from './detect-forms.js';
11
+ import { scanScrollableAreas } from './scroll-page.js';
11
12
 
12
13
  /**
13
14
  * @typedef {import('@modelcontextprotocol/sdk/types.js').Tool} Tool
@@ -28,7 +29,7 @@ export class GetCurrentHtmlSuccessResponse extends MCPResponse {
28
29
  * @param {Array} [recommendedPlugins] - Detected plugin metadata
29
30
  * @param {Object} [formData] - Detected forms data
30
31
  */
31
- constructor(currentUrl, html, nextSteps, recommendedPlugins = [], formData = null) {
32
+ constructor(currentUrl, html, nextSteps, recommendedPlugins = [], formData = null, scrollableAreas = []) {
32
33
  super(nextSteps);
33
34
 
34
35
  if (typeof currentUrl !== 'string') {
@@ -44,6 +45,7 @@ export class GetCurrentHtmlSuccessResponse extends MCPResponse {
44
45
  this.forms = formData?.forms || [];
45
46
  this.orphanedFields = formData?.orphanedFields || [];
46
47
  this.totalFieldCount = formData?.totalFieldCount || 0;
48
+ this.scrollableAreas = scrollableAreas;
47
49
  }
48
50
 
49
51
  _getAdditionalFields() {
@@ -53,7 +55,8 @@ export class GetCurrentHtmlSuccessResponse extends MCPResponse {
53
55
  recommendedPlugins: this.recommendedPlugins,
54
56
  forms: this.forms,
55
57
  orphanedFields: this.orphanedFields,
56
- totalFieldCount: this.totalFieldCount
58
+ totalFieldCount: this.totalFieldCount,
59
+ scrollableAreas: this.scrollableAreas
57
60
  };
58
61
  }
59
62
 
@@ -101,6 +104,21 @@ export const GET_CURRENT_HTML_TOOL = {
101
104
  type: "array",
102
105
  items: { type: "object" },
103
106
  description: "Detected site-specific plugins available for this domain"
107
+ },
108
+ scrollableAreas: {
109
+ type: "array",
110
+ items: {
111
+ type: "object",
112
+ properties: {
113
+ selector: { type: "string" },
114
+ scrollHeight: { type: "number" },
115
+ clientHeight: { type: "number" },
116
+ scrollTop: { type: "number" },
117
+ hiddenPixels: { type: "number" },
118
+ description: { type: "string" }
119
+ }
120
+ },
121
+ description: "Scrollable containers on the page. Pass a selector to browser_scroll_page's 'container' parameter to scroll within a specific area."
104
122
  }
105
123
  },
106
124
  required: ["currentUrl", "html", "nextSteps"],
@@ -189,6 +207,14 @@ export async function getCurrentHtml({ url, removeUnnecessaryHTML = true, select
189
207
  logger.debug(`Form scan failed (non-fatal): ${err.message}`);
190
208
  }
191
209
  }
210
+
211
+ // Scan for scrollable areas (lightweight, ~20-50ms)
212
+ let scrollableAreas = [];
213
+ try {
214
+ scrollableAreas = await scanScrollableAreas(page);
215
+ } catch (err) {
216
+ logger.debug(`Scrollable area scan failed (non-fatal): ${err.message}`);
217
+ }
192
218
 
193
219
  // Detect empty/near-empty HTML extraction (e.g., CSP blocking page.evaluate)
194
220
  if (!html || html.trim().length < 100) {
@@ -197,7 +223,7 @@ export async function getCurrentHtml({ url, removeUnnecessaryHTML = true, select
197
223
  `HTML extraction returned empty content from ${currentUrl}`,
198
224
  'The page may be blocking evaluation via Content Security Policy (CSP), the page has not fully rendered, or the page uses a sandboxed context that prevents DOM reading.',
199
225
  [
200
- "Use MCPBrowser's browser_take_screenshot to verify the page is visually loaded",
226
+ "Use MCPBrowser's browser_take_screenshot with fullPage=true to verify the page is visually loaded",
201
227
  "Use MCPBrowser's browser_execute_javascript with a simple script like 'document.title' to test page accessibility",
202
228
  "Try MCPBrowser's browser_fetch_webpage to reload the page",
203
229
  "Wait and retry — the page may still be rendering"
@@ -211,14 +237,16 @@ export async function getCurrentHtml({ url, removeUnnecessaryHTML = true, select
211
237
  currentUrl,
212
238
  html,
213
239
  [
240
+ ...getLargeHtmlHints(html, selector),
214
241
  ...getPluginNextSteps(currentUrl, html),
215
242
  "Use MCPBrowser's browser_click_element to interact with elements",
216
243
  "Use MCPBrowser's browser_type_text to fill forms",
217
- "Use MCPBrowser's browser_take_screenshot if page layout or visual content is hard to understand from HTML",
244
+ "Use MCPBrowser's browser_take_screenshot with fullPage=true if page layout or visual content is hard to understand from HTML",
218
245
  "Use MCPBrowser's browser_close_tab to free resources when done"
219
246
  ],
220
247
  getRecommendedPlugins(currentUrl, html),
221
- formData
248
+ formData,
249
+ scrollableAreas
222
250
  );
223
251
  } catch (err) {
224
252
  logger.error(`browser_get_current_html failed: ${err.message}`);
@@ -94,7 +94,7 @@ export const NAVIGATE_HISTORY_TOOL = {
94
94
  direction: { type: "string", enum: ["back", "forward"], description: "Navigation direction used" },
95
95
  previousUrl: { type: "string", description: "URL before navigation" },
96
96
  currentUrl: { type: "string", description: "URL after navigation" },
97
- html: { type: ["string", "null"], description: "Page HTML content after navigation (null if returnHtml=false)" },
97
+ html: { type: "string", description: "Page HTML content after navigation (null if returnHtml=false)" },
98
98
  nextSteps: {
99
99
  type: "array",
100
100
  items: { type: "string" },
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * scroll-page.js - Scroll within browser page
3
- * Scrolls the page in various ways for visibility and screenshot capture
3
+ * Scrolls the page in various ways for visibility and screenshot capture.
4
+ * Automatically detects scrollable containers inside SPAs.
4
5
  */
5
6
 
6
7
  import { getBrowser, getValidatedPage } from '../core/browser.js';
@@ -11,6 +12,139 @@ import logger from '../core/logger.js';
11
12
  * @typedef {import('@modelcontextprotocol/sdk/types.js').Tool} Tool
12
13
  */
13
14
 
15
+ // ============================================================================
16
+ // SCROLLABLE AREA SCANNER (exported for reuse by fetch-page, click-element, etc.)
17
+ // ============================================================================
18
+
19
+ /**
20
+ * Scan a page for scrollable containers and return structured data.
21
+ * Lightweight (~20-50ms) — safe to call on every page load.
22
+ *
23
+ * Returns an array of scrollable areas sorted by visible area (largest first).
24
+ * Each entry has a CSS selector the agent can pass to browser_scroll_page's
25
+ * `container` parameter to target that specific area.
26
+ *
27
+ * @param {Object} page - Puppeteer page object
28
+ * @returns {Promise<Array<{selector: string, scrollHeight: number, clientHeight: number, scrollTop: number, hiddenPixels: number, description: string}>>}
29
+ */
30
+ export async function scanScrollableAreas(page) {
31
+ return await page.evaluate(() => {
32
+ const results = [];
33
+ const minScrollable = 200; // ignore tiny scroll areas (dropdowns, etc.)
34
+
35
+ // Check if the window/document itself scrolls
36
+ const docEl = document.documentElement;
37
+ const body = document.body;
38
+ const windowScrollH = Math.max(docEl.scrollHeight, body.scrollHeight);
39
+ const windowClientH = docEl.clientHeight;
40
+ const htmlOverflow = getComputedStyle(docEl).overflowY;
41
+ const bodyOverflow = getComputedStyle(body).overflowY;
42
+ const windowBlocked = (htmlOverflow === 'hidden' && bodyOverflow === 'hidden');
43
+
44
+ if (!windowBlocked && windowScrollH > windowClientH + minScrollable) {
45
+ results.push({
46
+ selector: 'window',
47
+ scrollHeight: windowScrollH,
48
+ clientHeight: windowClientH,
49
+ scrollTop: window.scrollY,
50
+ hiddenPixels: windowScrollH - windowClientH - window.scrollY,
51
+ description: 'Main page (window scroll)'
52
+ });
53
+ }
54
+
55
+ // Scan for inner scrollable containers
56
+ // Check common SPA wrappers + anything with overflow: auto/scroll
57
+ const seen = new WeakSet();
58
+ const candidates = document.querySelectorAll(
59
+ 'body > *, body > * > *, [class*="scroll"], [role="main"], [role="region"], ' +
60
+ '#root > *, #app > *, #__next > *, [class*="content"], [class*="container"], ' +
61
+ '[class*="panel"], [class*="feed"], [class*="list"], [data-is-scrollable]'
62
+ );
63
+
64
+ for (const el of candidates) {
65
+ if (seen.has(el)) continue;
66
+ seen.add(el);
67
+
68
+ const overflow = el.scrollHeight - el.clientHeight;
69
+ if (overflow < minScrollable) continue;
70
+
71
+ const style = getComputedStyle(el);
72
+ if (style.overflowY === 'hidden' || style.overflowY === 'visible') continue;
73
+ // auto, scroll, overlay are all scrollable
74
+
75
+ // Skip elements that are too small to be meaningful content areas
76
+ if (el.clientWidth < 100 || el.clientHeight < 100) continue;
77
+
78
+ // Build a stable selector
79
+ let selector;
80
+ if (el.id) {
81
+ selector = '#' + CSS.escape(el.id);
82
+ } else if (el.getAttribute('role')) {
83
+ const role = el.getAttribute('role');
84
+ const roleEls = document.querySelectorAll(`[role="${role}"]`);
85
+ if (roleEls.length === 1) {
86
+ selector = `[role="${role}"]`;
87
+ }
88
+ }
89
+ if (!selector) {
90
+ // Try class-based selector
91
+ const classes = Array.from(el.classList).filter(c =>
92
+ c.includes('scroll') || c.includes('content') || c.includes('main') ||
93
+ c.includes('panel') || c.includes('feed') || c.includes('list') ||
94
+ c.includes('container') || c.includes('body') || c.includes('region')
95
+ );
96
+ if (classes.length > 0) {
97
+ const candidate = '.' + CSS.escape(classes[0]);
98
+ if (document.querySelectorAll(candidate).length === 1) {
99
+ selector = candidate;
100
+ }
101
+ }
102
+ }
103
+ if (!selector) {
104
+ // Use nth-child path as last resort
105
+ const tag = el.tagName.toLowerCase();
106
+ const parent = el.parentElement;
107
+ if (parent) {
108
+ const siblings = Array.from(parent.children).filter(s => s.tagName === el.tagName);
109
+ const idx = siblings.indexOf(el) + 1;
110
+ const parentSel = parent.id ? '#' + CSS.escape(parent.id) : parent.tagName.toLowerCase();
111
+ selector = `${parentSel} > ${tag}:nth-of-type(${idx})`;
112
+ } else {
113
+ selector = tag;
114
+ }
115
+ }
116
+
117
+ // Build a human-readable description from ARIA, class, or tag
118
+ let description = el.getAttribute('aria-label') || '';
119
+ if (!description) {
120
+ const meaningful = Array.from(el.classList).filter(c =>
121
+ !c.match(/^(bolt-|flex|ms-|css-|_|sc-)/i)
122
+ ).slice(0, 2).join(' ');
123
+ description = meaningful || el.tagName.toLowerCase();
124
+ }
125
+
126
+ results.push({
127
+ selector,
128
+ scrollHeight: el.scrollHeight,
129
+ clientHeight: el.clientHeight,
130
+ scrollTop: el.scrollTop,
131
+ hiddenPixels: el.scrollHeight - el.clientHeight - el.scrollTop,
132
+ description
133
+ });
134
+ }
135
+
136
+ // Sort by visible area (largest containers first) and deduplicate nested
137
+ results.sort((a, b) => {
138
+ if (a.selector === 'window') return -1;
139
+ if (b.selector === 'window') return 1;
140
+ return (b.clientHeight * 100 + b.scrollHeight) - (a.clientHeight * 100 + a.scrollHeight);
141
+ });
142
+
143
+ // Cap at 5 most significant scrollable areas
144
+ return results.slice(0, 5);
145
+ });
146
+ }
147
+
14
148
  // ============================================================================
15
149
  // RESPONSE CLASS
16
150
  // ============================================================================
@@ -29,8 +163,9 @@ export class ScrollPageSuccessResponse extends MCPResponse {
29
163
  * @param {number} viewportWidth - Viewport width
30
164
  * @param {number} viewportHeight - Viewport height
31
165
  * @param {string[]} nextSteps - Suggested next actions
166
+ * @param {Array} [scrollableAreas] - Detected scrollable containers
32
167
  */
33
- constructor(currentUrl, scrollX, scrollY, pageWidth, pageHeight, viewportWidth, viewportHeight, nextSteps) {
168
+ constructor(currentUrl, scrollX, scrollY, pageWidth, pageHeight, viewportWidth, viewportHeight, nextSteps, scrollableAreas = []) {
34
169
  super(nextSteps);
35
170
 
36
171
  if (typeof currentUrl !== 'string') {
@@ -62,6 +197,7 @@ export class ScrollPageSuccessResponse extends MCPResponse {
62
197
  this.pageHeight = pageHeight;
63
198
  this.viewportWidth = viewportWidth;
64
199
  this.viewportHeight = viewportHeight;
200
+ this.scrollableAreas = scrollableAreas;
65
201
  }
66
202
 
67
203
  _getAdditionalFields() {
@@ -72,7 +208,8 @@ export class ScrollPageSuccessResponse extends MCPResponse {
72
208
  pageWidth: this.pageWidth,
73
209
  pageHeight: this.pageHeight,
74
210
  viewportWidth: this.viewportWidth,
75
- viewportHeight: this.viewportHeight
211
+ viewportHeight: this.viewportHeight,
212
+ scrollableAreas: this.scrollableAreas
76
213
  };
77
214
  }
78
215
 
@@ -94,7 +231,7 @@ export class ScrollPageSuccessResponse extends MCPResponse {
94
231
  export const SCROLL_PAGE_TOOL = {
95
232
  name: "browser_scroll_page",
96
233
  title: "Scroll Page",
97
- description: "Scroll within a browser-loaded page. Use when: you need to see more content below the fold, bring an element into view before clicking, scroll to a specific section, or navigate long pages. Supports scroll by direction, to a CSS selector, or to absolute coordinates. PREREQUISITE: Page must be loaded with browser_fetch_webpage first.",
234
+ description: "Scroll within a browser-loaded page. Automatically detects scrollable containers inside SPAs (e.g., ADO, Jira, Gmail) where the main content scrolls inside an inner div rather than the window. Use when: you need to see more content below the fold, bring an element into view before clicking, scroll to a specific section, or navigate long pages. Supports scroll by direction, to a CSS selector, or to absolute coordinates. PREREQUISITE: Page must be loaded with browser_fetch_webpage first.",
98
235
  inputSchema: {
99
236
  type: "object",
100
237
  properties: {
@@ -105,7 +242,8 @@ export const SCROLL_PAGE_TOOL = {
105
242
  direction: {
106
243
  type: "string",
107
244
  enum: ["up", "down", "left", "right"],
108
- description: "Direction to scroll. Use with 'amount' parameter."
245
+ description: "Direction to scroll. Use with 'amount' parameter.",
246
+ default: "down"
109
247
  },
110
248
  amount: {
111
249
  type: "number",
@@ -123,6 +261,10 @@ export const SCROLL_PAGE_TOOL = {
123
261
  y: {
124
262
  type: "number",
125
263
  description: "Absolute vertical scroll position. Use with 'x' for precise positioning."
264
+ },
265
+ container: {
266
+ type: "string",
267
+ description: "CSS selector of a specific scrollable container to scroll within. Use when the page has multiple scroll areas (e.g., a sidebar + main content). Get available selectors from the scrollableAreas field in fetch/get_current_html/click responses. If omitted, auto-detects the primary scrollable container."
126
268
  }
127
269
  },
128
270
  required: ["url"],
@@ -138,6 +280,21 @@ export const SCROLL_PAGE_TOOL = {
138
280
  pageHeight: { type: "number", description: "Total scrollable page height" },
139
281
  viewportWidth: { type: "number", description: "Visible viewport width" },
140
282
  viewportHeight: { type: "number", description: "Visible viewport height" },
283
+ scrollableAreas: {
284
+ type: "array",
285
+ items: {
286
+ type: "object",
287
+ properties: {
288
+ selector: { type: "string", description: "CSS selector to target this container (or 'window' for main page)" },
289
+ scrollHeight: { type: "number", description: "Total scrollable height in pixels" },
290
+ clientHeight: { type: "number", description: "Visible height in pixels" },
291
+ scrollTop: { type: "number", description: "Current scroll position" },
292
+ hiddenPixels: { type: "number", description: "Pixels of content below current scroll position" },
293
+ description: { type: "string", description: "Human-readable description of the container" }
294
+ }
295
+ },
296
+ description: "Scrollable containers detected on the page. Pass a selector to the 'container' parameter to scroll within a specific area."
297
+ },
141
298
  nextSteps: {
142
299
  type: "array",
143
300
  items: { type: "string" },
@@ -162,7 +319,9 @@ export const SCROLL_PAGE_TOOL = {
162
319
 
163
320
  /**
164
321
  * Scroll within an already-loaded page
165
- * Supports directional scrolling, scroll-to-element, and absolute positioning
322
+ * Supports directional scrolling, scroll-to-element, and absolute positioning.
323
+ * Automatically detects scrollable containers in SPAs, or accepts an explicit
324
+ * container selector from the agent.
166
325
  * @param {Object} params - Parameters
167
326
  * @param {string} params.url - The URL of the page to scroll
168
327
  * @param {string} [params.direction] - Direction to scroll: 'up', 'down', 'left', 'right'
@@ -170,10 +329,11 @@ export const SCROLL_PAGE_TOOL = {
170
329
  * @param {string} [params.selector] - CSS selector to scroll into view
171
330
  * @param {number} [params.x] - Absolute x scroll position
172
331
  * @param {number} [params.y] - Absolute y scroll position
332
+ * @param {string} [params.container] - CSS selector of scrollable container to scroll within
173
333
  * @returns {Promise<Object>} Result object with scroll position data
174
334
  */
175
- export async function scrollPage({ url, direction, amount = 500, selector, x, y }) {
176
- logger.info(`browser_scroll_page called: url=${url}, direction=${direction}, amount=${amount}, selector=${selector}, x=${x}, y=${y}`);
335
+ export async function scrollPage({ url, direction, amount = 500, selector, x, y, container }) {
336
+ logger.info(`browser_scroll_page called: url=${url}, direction=${direction}, amount=${amount}, selector=${selector}, x=${x}, y=${y}${container ? `, container=${container}` : ''}`);
177
337
 
178
338
  if (!url) {
179
339
  throw new Error("url parameter is required");
@@ -222,6 +382,38 @@ export async function scrollPage({ url, direction, amount = 500, selector, x, y
222
382
 
223
383
  try {
224
384
  const currentUrl = page.url();
385
+
386
+ // Scan all scrollable areas on the page (lightweight, ~20-50ms)
387
+ const scrollableAreas = await scanScrollableAreas(page);
388
+ logger.debug(`browser_scroll_page: found ${scrollableAreas.length} scrollable area(s)`);
389
+
390
+ // Resolve which container to target:
391
+ // 1. Explicit container param from agent
392
+ // 2. Auto-detect: largest non-window scrollable area (if window is blocked)
393
+ // 3. Fallback: window
394
+ let containerInfo; // { isWindow: boolean, selector?: string }
395
+ if (container) {
396
+ // Agent explicitly specified a container
397
+ if (container === 'window') {
398
+ containerInfo = { isWindow: true };
399
+ } else {
400
+ containerInfo = { isWindow: false, selector: container };
401
+ }
402
+ logger.debug(`browser_scroll_page: using explicit container=${container}`);
403
+ } else {
404
+ // Auto-detect: check if window scrolls; if not, pick the largest inner container
405
+ const windowArea = scrollableAreas.find(a => a.selector === 'window');
406
+ const innerAreas = scrollableAreas.filter(a => a.selector !== 'window');
407
+
408
+ if (windowArea && windowArea.hiddenPixels > 0) {
409
+ containerInfo = { isWindow: true };
410
+ } else if (innerAreas.length > 0) {
411
+ containerInfo = { isWindow: false, selector: innerAreas[0].selector };
412
+ } else {
413
+ containerInfo = { isWindow: true };
414
+ }
415
+ logger.debug(`browser_scroll_page: auto-detected container=${containerInfo.isWindow ? 'window' : containerInfo.selector}`);
416
+ }
225
417
 
226
418
  // Determine scroll mode and execute
227
419
  if (selector) {
@@ -249,15 +441,24 @@ export async function scrollPage({ url, direction, amount = 500, selector, x, y
249
441
  }, selector);
250
442
 
251
443
  } else if (typeof x === 'number' && typeof y === 'number') {
252
- // Absolute position mode
444
+ // Absolute position mode — target the resolved container
253
445
  logger.debug(`browser_scroll_page: Scrolling to absolute position: (${x}, ${y})`);
254
446
 
255
- await page.evaluate(({ scrollX, scrollY }) => {
256
- window.scrollTo(scrollX, scrollY);
257
- }, { scrollX: x, scrollY: y });
447
+ await page.evaluate(({ scrollX, scrollY, ctr }) => {
448
+ if (ctr.isWindow) {
449
+ window.scrollTo(scrollX, scrollY);
450
+ } else {
451
+ const el = document.querySelector(ctr.selector);
452
+ if (el) {
453
+ el.scrollTo(scrollX, scrollY);
454
+ } else {
455
+ window.scrollTo(scrollX, scrollY);
456
+ }
457
+ }
458
+ }, { scrollX: x, scrollY: y, ctr: containerInfo });
258
459
 
259
460
  } else if (direction) {
260
- // Directional scroll mode
461
+ // Directional scroll mode — target the resolved container
261
462
  logger.debug(`browser_scroll_page: Scrolling ${direction} by ${amount}px`);
262
463
 
263
464
  const scrollDeltas = {
@@ -272,9 +473,18 @@ export async function scrollPage({ url, direction, amount = 500, selector, x, y
272
473
  throw new Error(`Invalid direction: ${direction}. Must be one of: up, down, left, right`);
273
474
  }
274
475
 
275
- await page.evaluate(({ dx, dy }) => {
276
- window.scrollBy(dx, dy);
277
- }, { dx: delta.x, dy: delta.y });
476
+ await page.evaluate(({ dx, dy, ctr }) => {
477
+ if (ctr.isWindow) {
478
+ window.scrollBy(dx, dy);
479
+ } else {
480
+ const el = document.querySelector(ctr.selector);
481
+ if (el) {
482
+ el.scrollBy(dx, dy);
483
+ } else {
484
+ window.scrollBy(dx, dy);
485
+ }
486
+ }
487
+ }, { dx: delta.x, dy: delta.y, ctr: containerInfo });
278
488
 
279
489
  } else {
280
490
  // No scroll parameters provided - just return current position
@@ -284,18 +494,48 @@ export async function scrollPage({ url, direction, amount = 500, selector, x, y
284
494
  // Small delay to let scroll complete
285
495
  await new Promise(resolve => setTimeout(resolve, 100));
286
496
 
287
- // Get final scroll position and page dimensions
288
- const scrollInfo = await page.evaluate(() => ({
289
- scrollX: window.scrollX,
290
- scrollY: window.scrollY,
291
- pageWidth: document.documentElement.scrollWidth,
292
- pageHeight: document.documentElement.scrollHeight,
293
- viewportWidth: window.innerWidth,
294
- viewportHeight: window.innerHeight
295
- }));
497
+ // Re-scan scrollable areas to get updated positions after scroll
498
+ const updatedAreas = await scanScrollableAreas(page);
499
+
500
+ // Get final scroll position from the targeted container
501
+ const scrollInfo = await page.evaluate((ctr) => {
502
+ if (ctr.isWindow) {
503
+ return {
504
+ scrollX: window.scrollX,
505
+ scrollY: window.scrollY,
506
+ pageWidth: document.documentElement.scrollWidth,
507
+ pageHeight: document.documentElement.scrollHeight,
508
+ viewportWidth: window.innerWidth,
509
+ viewportHeight: window.innerHeight
510
+ };
511
+ }
512
+ const el = document.querySelector(ctr.selector);
513
+ if (el) {
514
+ return {
515
+ scrollX: el.scrollLeft,
516
+ scrollY: el.scrollTop,
517
+ pageWidth: el.scrollWidth,
518
+ pageHeight: el.scrollHeight,
519
+ viewportWidth: el.clientWidth,
520
+ viewportHeight: el.clientHeight
521
+ };
522
+ }
523
+ return {
524
+ scrollX: window.scrollX,
525
+ scrollY: window.scrollY,
526
+ pageWidth: document.documentElement.scrollWidth,
527
+ pageHeight: document.documentElement.scrollHeight,
528
+ viewportWidth: window.innerWidth,
529
+ viewportHeight: window.innerHeight
530
+ };
531
+ }, containerInfo);
296
532
 
297
- logger.info(`browser_scroll_page completed: position=(${scrollInfo.scrollX}, ${scrollInfo.scrollY}), page=(${scrollInfo.pageWidth}x${scrollInfo.pageHeight})`);
533
+ logger.info(`browser_scroll_page completed: position=(${scrollInfo.scrollX}, ${scrollInfo.scrollY}), page=(${scrollInfo.pageWidth}x${scrollInfo.pageHeight})${containerInfo.isWindow ? '' : ` container=${containerInfo.selector}`}`);
298
534
 
535
+ const containerHint = containerInfo.isWindow
536
+ ? []
537
+ : [`Scrolled within inner container (${containerInfo.selector}). Use container="${containerInfo.selector}" to keep targeting it.`];
538
+
299
539
  return new ScrollPageSuccessResponse(
300
540
  currentUrl,
301
541
  scrollInfo.scrollX,
@@ -305,11 +545,13 @@ export async function scrollPage({ url, direction, amount = 500, selector, x, y
305
545
  scrollInfo.viewportWidth,
306
546
  scrollInfo.viewportHeight,
307
547
  [
308
- "Use MCPBrowser's browser_take_screenshot to capture the current view",
548
+ ...containerHint,
549
+ "Use MCPBrowser's browser_take_screenshot with fullPage=true to capture the entire page in one shot",
309
550
  "Use MCPBrowser's browser_scroll_page again to navigate further",
310
551
  "Use MCPBrowser's browser_click_element to interact with visible elements",
311
552
  "Use MCPBrowser's browser_get_current_html to get the page content"
312
- ]
553
+ ],
554
+ updatedAreas
313
555
  );
314
556
  } catch (err) {
315
557
  logger.error(`browser_scroll_page failed: ${err.message}`);
@@ -89,12 +89,12 @@ export class TakeScreenshotSuccessResponse extends MCPResponse {
89
89
  export const TAKE_SCREENSHOT_TOOL = {
90
90
  name: "browser_take_screenshot",
91
91
  title: "Take Screenshot",
92
- description: "Capture a screenshot of a browser-loaded page as PNG. Use when: you need to see what a page looks like, analyze visual layout, view charts/images/graphs, debug UI issues, or when HTML alone is insufficient to understand the page content. Returns base64-encoded PNG. PREREQUISITE: Page must be loaded with browser_fetch_webpage first.",
92
+ description: "Capture a screenshot of a browser-loaded page as PNG. Set fullPage=true to capture the entire scrollable page in one shot — this avoids multiple scroll+screenshot cycles. Only use fullPage=false when you specifically need just the current viewport (rare). Use when: you need to see what a page looks like, analyze visual layout, view charts/images/graphs, debug UI issues, or when HTML alone is insufficient. Returns base64-encoded PNG. PREREQUISITE: Page must be loaded with browser_fetch_webpage first.",
93
93
  inputSchema: {
94
94
  type: "object",
95
95
  properties: {
96
96
  url: { type: "string", description: "The URL of the page (must match a previously fetched page)" },
97
- fullPage: { type: "boolean", description: "Capture the full scrollable page instead of just the viewport. Default: false (viewport only).", default: false }
97
+ fullPage: { type: "boolean", description: "RECOMMENDED: set to true to capture the entire scrollable page in one shot instead of just the viewport. Avoids multiple scroll+screenshot cycles. Automatically falls back to viewport if the page is extremely tall.", default: false }
98
98
  },
99
99
  required: ["url"],
100
100
  additionalProperties: false
@@ -135,6 +135,10 @@ export const TAKE_SCREENSHOT_TOOL = {
135
135
  * @param {boolean} [params.fullPage=false] - Whether to capture full scrollable page
136
136
  * @returns {Promise<Object>} Result object with screenshot data
137
137
  */
138
+
139
+ /** Max page height (px) for safe full-page capture. Beyond this, fall back to viewport to avoid giant payloads. */
140
+ const MAX_FULL_PAGE_HEIGHT = 12000;
141
+
138
142
  export async function takeScreenshot({ url, fullPage = false }) {
139
143
  logger.info(`browser_take_screenshot called: url=${url}, fullPage=${fullPage}`);
140
144
 
@@ -186,25 +190,45 @@ export async function takeScreenshot({ url, fullPage = false }) {
186
190
  try {
187
191
  const currentUrl = page.url();
188
192
 
193
+ // Safety cap: if fullPage requested, check page height first
194
+ let effectiveFullPage = fullPage;
195
+ let wasClipped = false;
196
+ if (fullPage) {
197
+ const pageHeight = await page.evaluate(() => document.documentElement.scrollHeight);
198
+ if (pageHeight > MAX_FULL_PAGE_HEIGHT) {
199
+ logger.warn(`browser_take_screenshot: page height ${pageHeight}px exceeds ${MAX_FULL_PAGE_HEIGHT}px cap, falling back to viewport`);
200
+ effectiveFullPage = false;
201
+ wasClipped = true;
202
+ }
203
+ }
204
+
189
205
  // Take screenshot as base64
190
206
  const screenshotBuffer = await page.screenshot({
191
207
  encoding: 'base64',
192
208
  type: 'png',
193
- fullPage: fullPage
209
+ fullPage: effectiveFullPage
194
210
  });
195
211
 
196
- logger.info(`browser_take_screenshot completed: captured from ${currentUrl} (fullPage=${fullPage})`);
212
+ logger.info(`browser_take_screenshot completed: captured from ${currentUrl} (fullPage=${effectiveFullPage}${wasClipped ? ', clipped from full' : ''})`);
213
+
214
+ const nextSteps = [];
215
+ if (wasClipped) {
216
+ nextSteps.push(`Page too tall for full-page capture (>${MAX_FULL_PAGE_HEIGHT}px). Viewport screenshot taken instead. Use browser_scroll_page to see more content, then browser_take_screenshot again.`);
217
+ } else if (!fullPage) {
218
+ nextSteps.push("Use MCPBrowser's browser_take_screenshot with fullPage=true to capture the entire page in one shot");
219
+ }
220
+ nextSteps.push(
221
+ "Use MCPBrowser's browser_get_current_html if you need the HTML instead",
222
+ "Use MCPBrowser's browser_click_element to interact with elements",
223
+ "Use MCPBrowser's browser_type_text to fill forms",
224
+ "Use MCPBrowser's browser_close_tab to free resources when done"
225
+ );
197
226
 
198
227
  return new TakeScreenshotSuccessResponse(
199
228
  currentUrl,
200
229
  screenshotBuffer,
201
230
  'image/png',
202
- [
203
- "Use MCPBrowser's browser_get_current_html if you need the HTML instead",
204
- "Use MCPBrowser's browser_click_element to interact with elements",
205
- "Use MCPBrowser's browser_type_text to fill forms",
206
- "Use MCPBrowser's browser_close_tab to free resources when done"
207
- ]
231
+ nextSteps
208
232
  );
209
233
  } catch (err) {
210
234
  logger.error(`browser_take_screenshot failed: ${err.message}`);
@@ -100,7 +100,7 @@ export const TYPE_TEXT_TOOL = {
100
100
  currentUrl: { type: "string", description: "URL after typing" },
101
101
  message: { type: "string", description: "Success message" },
102
102
  html: {
103
- type: ["string", "null"],
103
+ type: "string",
104
104
  description: "Page HTML if returnHtml was true, null otherwise"
105
105
  },
106
106
  nextSteps: {
package/src/core/page.js CHANGED
@@ -539,3 +539,24 @@ export async function extractAndProcessHtml(page, removeUnnecessaryHTML, selecto
539
539
 
540
540
  return processedHtml;
541
541
  }
542
+
543
+ /**
544
+ * Returns nextStep hints when the HTML response is large and no selector was used.
545
+ * Call after extractAndProcessHtml to get agent-visible guidance on using the selector
546
+ * parameter to scope extraction on heavy SPAs.
547
+ *
548
+ * @param {string} html - The processed HTML string
549
+ * @param {string|null} selector - The selector that was used (null = full page)
550
+ * @returns {string[]} Array of nextStep hint strings (empty if HTML is small or selector was used)
551
+ */
552
+ export function getLargeHtmlHints(html, selector) {
553
+ if (selector) return [];
554
+ const byteLength = new TextEncoder().encode(html).length;
555
+ if (byteLength > 200_000) {
556
+ const sizeKB = (byteLength / 1024).toFixed(0);
557
+ return [
558
+ `⚠ Large HTML response (${sizeKB}KB). Use the "selector" parameter (e.g., selector: '[role="main"]', 'main', '.content') to extract only the relevant DOM subtree and reduce response size.`
559
+ ];
560
+ }
561
+ return [];
562
+ }
@@ -71,6 +71,17 @@ async function main() {
71
71
  }
72
72
  );
73
73
 
74
+ // Capture the negotiated MCP protocol version so the ListTools handler can
75
+ // strip fields that older clients cannot parse (outputSchema, annotations,
76
+ // title were added in MCP protocol 2025-03-26).
77
+ let negotiatedProtocolVersion = null;
78
+ const _origOnInitialize = server._oninitialize.bind(server);
79
+ server._oninitialize = async function(request) {
80
+ const result = await _origOnInitialize(request);
81
+ negotiatedProtocolVersion = result.protocolVersion;
82
+ return result;
83
+ };
84
+
74
85
  // Wire server to logger so logs flow to agent via notifications/message.
75
86
  attachLoggerServer(server);
76
87
 
@@ -106,7 +117,16 @@ async function main() {
106
117
  ...pluginTools
107
118
  ];
108
119
 
109
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
120
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
121
+ // outputSchema, annotations, and title require MCP protocol 2025-03-26+.
122
+ // Strip them for clients that negotiated an older version (e.g. Antigravity/Gemini).
123
+ if (!negotiatedProtocolVersion || negotiatedProtocolVersion < '2025-03-26') {
124
+ return {
125
+ tools: tools.map(({ outputSchema, annotations, title, ...core }) => core)
126
+ };
127
+ }
128
+ return { tools };
129
+ });
110
130
 
111
131
  // --- Prompts handlers ---
112
132
  server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: PROMPTS }));