mcpbrowser 0.3.58 → 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 +1 -1
- package/src/actions/click-element.js +41 -10
- package/src/actions/detect-forms.js +1 -1
- package/src/actions/execute-javascript.js +23 -7
- package/src/actions/fetch-page.js +33 -5
- package/src/actions/get-current-html.js +34 -6
- package/src/actions/scroll-page.js +268 -27
- package/src/actions/take-screenshot.js +34 -10
- package/src/core/page.js +21 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcpbrowser",
|
|
3
|
-
"version": "0.3.
|
|
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"],
|
|
@@ -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
|
]
|
|
@@ -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;
|
|
@@ -114,11 +114,14 @@ function buildErrorResponse(message, reason, nextSteps) {
|
|
|
114
114
|
return new InformationalResponse(message, reason, nextSteps);
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
const VALID_RETURN_TYPES = new Set(['json', 'text', 'void']);
|
|
118
|
+
|
|
117
119
|
export async function executeJavascript({ url, script, timeoutMs = EXECUTION_TIMEOUT_DEFAULT_MS, returnType = 'json' }) {
|
|
118
120
|
logger.info(`browser_execute_javascript called: ${url}`);
|
|
119
121
|
|
|
120
122
|
if (!url) throw new Error('url parameter is required');
|
|
121
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`);
|
|
122
125
|
|
|
123
126
|
let hostname;
|
|
124
127
|
try {
|
|
@@ -159,7 +162,15 @@ export async function executeJavascript({ url, script, timeoutMs = EXECUTION_TIM
|
|
|
159
162
|
}
|
|
160
163
|
|
|
161
164
|
const effectiveTimeout = clampTimeout(timeoutMs);
|
|
162
|
-
|
|
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
|
+
|
|
163
174
|
const start = Date.now();
|
|
164
175
|
|
|
165
176
|
const evalPromise = page.evaluate(async ({ userScript, mode }) => {
|
|
@@ -188,14 +199,17 @@ export async function executeJavascript({ url, script, timeoutMs = EXECUTION_TIM
|
|
|
188
199
|
}, { userScript: script, mode: returnType });
|
|
189
200
|
|
|
190
201
|
let evalResult;
|
|
202
|
+
let timeoutTimer;
|
|
191
203
|
try {
|
|
192
204
|
evalResult = await Promise.race([
|
|
193
205
|
evalPromise,
|
|
194
|
-
new Promise((_, reject) =>
|
|
206
|
+
new Promise((_, reject) => {
|
|
207
|
+
timeoutTimer = setTimeout(() => reject(new Error(`Execution timed out after ${effectiveTimeout}ms`)), effectiveTimeout);
|
|
208
|
+
})
|
|
195
209
|
]);
|
|
196
210
|
} catch (err) {
|
|
197
211
|
const executionTimeMs = Date.now() - start;
|
|
198
|
-
let currentUrl =
|
|
212
|
+
let currentUrl = beforeUrl;
|
|
199
213
|
try {
|
|
200
214
|
currentUrl = await page.evaluate(() => location.href);
|
|
201
215
|
} catch {
|
|
@@ -210,21 +224,23 @@ export async function executeJavascript({ url, script, timeoutMs = EXECUTION_TIM
|
|
|
210
224
|
currentUrl,
|
|
211
225
|
error: { name: 'TimeoutError', message: err.message }
|
|
212
226
|
});
|
|
227
|
+
} finally {
|
|
228
|
+
clearTimeout(timeoutTimer);
|
|
213
229
|
}
|
|
214
230
|
|
|
215
231
|
const executionTimeMs = Date.now() - start;
|
|
216
|
-
let currentUrl =
|
|
232
|
+
let currentUrl = beforeUrl;
|
|
217
233
|
try {
|
|
218
234
|
currentUrl = await page.evaluate(() => location.href);
|
|
219
235
|
} catch {
|
|
220
|
-
// If we can't read location (e.g., cross-origin), fall back to
|
|
236
|
+
// If we can't read location (e.g., cross-origin), fall back to beforeUrl
|
|
221
237
|
}
|
|
222
238
|
const urlChanged = currentUrl !== beforeUrl;
|
|
223
239
|
|
|
224
240
|
// Detect CSP block or silent evaluation failure:
|
|
225
241
|
// When page.evaluate() is blocked by CSP, Puppeteer returns undefined (not an error).
|
|
226
|
-
//
|
|
227
|
-
if (evalResult === undefined
|
|
242
|
+
// Our inner wrapper always returns { value, type } or { error }, never undefined.
|
|
243
|
+
if (evalResult === undefined) {
|
|
228
244
|
return new ExecuteJavascriptResponse({
|
|
229
245
|
result: null,
|
|
230
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
|
|
|
@@ -110,6 +113,21 @@ export const FETCH_WEBPAGE_TOOL = {
|
|
|
110
113
|
type: "array",
|
|
111
114
|
items: { type: "object" },
|
|
112
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."
|
|
113
131
|
}
|
|
114
132
|
},
|
|
115
133
|
required: ["currentUrl", "html", "nextSteps"],
|
|
@@ -247,6 +265,14 @@ async function doFetchPage({ url, browser, removeUnnecessaryHTML, selector, post
|
|
|
247
265
|
logger.debug(`Form scan failed (non-fatal): ${err.message}`);
|
|
248
266
|
}
|
|
249
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
|
+
}
|
|
250
276
|
|
|
251
277
|
logger.info(`browser_fetch_webpage completed: ${page.url()}`);
|
|
252
278
|
|
|
@@ -260,15 +286,17 @@ async function doFetchPage({ url, browser, removeUnnecessaryHTML, selector, post
|
|
|
260
286
|
page.url(),
|
|
261
287
|
processedHtml,
|
|
262
288
|
[
|
|
289
|
+
...getLargeHtmlHints(processedHtml, selector),
|
|
263
290
|
...getPluginNextSteps(page.url(), processedHtml),
|
|
264
291
|
"Use MCPBrowser's browser_click_element to interact with buttons/links on the page",
|
|
265
292
|
"Use MCPBrowser's browser_type_text to fill in form fields",
|
|
266
293
|
"Use MCPBrowser's browser_get_current_html to re-check page state after interactions",
|
|
267
|
-
"Use MCPBrowser's browser_take_screenshot
|
|
294
|
+
"Use MCPBrowser's browser_take_screenshot with fullPage=true to capture the entire page visually (charts, images, complex layouts)",
|
|
268
295
|
"Use MCPBrowser's browser_close_tab when finished to free browser resources"
|
|
269
296
|
],
|
|
270
297
|
getRecommendedPlugins(page.url(), processedHtml),
|
|
271
|
-
formData
|
|
298
|
+
formData,
|
|
299
|
+
scrollableAreas
|
|
272
300
|
);
|
|
273
301
|
} catch (err) {
|
|
274
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}`);
|
|
@@ -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: {
|
|
@@ -124,6 +261,10 @@ export const SCROLL_PAGE_TOOL = {
|
|
|
124
261
|
y: {
|
|
125
262
|
type: "number",
|
|
126
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."
|
|
127
268
|
}
|
|
128
269
|
},
|
|
129
270
|
required: ["url"],
|
|
@@ -139,6 +280,21 @@ export const SCROLL_PAGE_TOOL = {
|
|
|
139
280
|
pageHeight: { type: "number", description: "Total scrollable page height" },
|
|
140
281
|
viewportWidth: { type: "number", description: "Visible viewport width" },
|
|
141
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
|
+
},
|
|
142
298
|
nextSteps: {
|
|
143
299
|
type: "array",
|
|
144
300
|
items: { type: "string" },
|
|
@@ -163,7 +319,9 @@ export const SCROLL_PAGE_TOOL = {
|
|
|
163
319
|
|
|
164
320
|
/**
|
|
165
321
|
* Scroll within an already-loaded page
|
|
166
|
-
* 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.
|
|
167
325
|
* @param {Object} params - Parameters
|
|
168
326
|
* @param {string} params.url - The URL of the page to scroll
|
|
169
327
|
* @param {string} [params.direction] - Direction to scroll: 'up', 'down', 'left', 'right'
|
|
@@ -171,10 +329,11 @@ export const SCROLL_PAGE_TOOL = {
|
|
|
171
329
|
* @param {string} [params.selector] - CSS selector to scroll into view
|
|
172
330
|
* @param {number} [params.x] - Absolute x scroll position
|
|
173
331
|
* @param {number} [params.y] - Absolute y scroll position
|
|
332
|
+
* @param {string} [params.container] - CSS selector of scrollable container to scroll within
|
|
174
333
|
* @returns {Promise<Object>} Result object with scroll position data
|
|
175
334
|
*/
|
|
176
|
-
export async function scrollPage({ url, direction, amount = 500, selector, x, y }) {
|
|
177
|
-
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}` : ''}`);
|
|
178
337
|
|
|
179
338
|
if (!url) {
|
|
180
339
|
throw new Error("url parameter is required");
|
|
@@ -223,6 +382,38 @@ export async function scrollPage({ url, direction, amount = 500, selector, x, y
|
|
|
223
382
|
|
|
224
383
|
try {
|
|
225
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
|
+
}
|
|
226
417
|
|
|
227
418
|
// Determine scroll mode and execute
|
|
228
419
|
if (selector) {
|
|
@@ -250,15 +441,24 @@ export async function scrollPage({ url, direction, amount = 500, selector, x, y
|
|
|
250
441
|
}, selector);
|
|
251
442
|
|
|
252
443
|
} else if (typeof x === 'number' && typeof y === 'number') {
|
|
253
|
-
// Absolute position mode
|
|
444
|
+
// Absolute position mode — target the resolved container
|
|
254
445
|
logger.debug(`browser_scroll_page: Scrolling to absolute position: (${x}, ${y})`);
|
|
255
446
|
|
|
256
|
-
await page.evaluate(({ scrollX, scrollY }) => {
|
|
257
|
-
|
|
258
|
-
|
|
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 });
|
|
259
459
|
|
|
260
460
|
} else if (direction) {
|
|
261
|
-
// Directional scroll mode
|
|
461
|
+
// Directional scroll mode — target the resolved container
|
|
262
462
|
logger.debug(`browser_scroll_page: Scrolling ${direction} by ${amount}px`);
|
|
263
463
|
|
|
264
464
|
const scrollDeltas = {
|
|
@@ -273,9 +473,18 @@ export async function scrollPage({ url, direction, amount = 500, selector, x, y
|
|
|
273
473
|
throw new Error(`Invalid direction: ${direction}. Must be one of: up, down, left, right`);
|
|
274
474
|
}
|
|
275
475
|
|
|
276
|
-
await page.evaluate(({ dx, dy }) => {
|
|
277
|
-
|
|
278
|
-
|
|
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 });
|
|
279
488
|
|
|
280
489
|
} else {
|
|
281
490
|
// No scroll parameters provided - just return current position
|
|
@@ -285,18 +494,48 @@ export async function scrollPage({ url, direction, amount = 500, selector, x, y
|
|
|
285
494
|
// Small delay to let scroll complete
|
|
286
495
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
287
496
|
|
|
288
|
-
//
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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);
|
|
297
532
|
|
|
298
|
-
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}`}`);
|
|
299
534
|
|
|
535
|
+
const containerHint = containerInfo.isWindow
|
|
536
|
+
? []
|
|
537
|
+
: [`Scrolled within inner container (${containerInfo.selector}). Use container="${containerInfo.selector}" to keep targeting it.`];
|
|
538
|
+
|
|
300
539
|
return new ScrollPageSuccessResponse(
|
|
301
540
|
currentUrl,
|
|
302
541
|
scrollInfo.scrollX,
|
|
@@ -306,11 +545,13 @@ export async function scrollPage({ url, direction, amount = 500, selector, x, y
|
|
|
306
545
|
scrollInfo.viewportWidth,
|
|
307
546
|
scrollInfo.viewportHeight,
|
|
308
547
|
[
|
|
309
|
-
|
|
548
|
+
...containerHint,
|
|
549
|
+
"Use MCPBrowser's browser_take_screenshot with fullPage=true to capture the entire page in one shot",
|
|
310
550
|
"Use MCPBrowser's browser_scroll_page again to navigate further",
|
|
311
551
|
"Use MCPBrowser's browser_click_element to interact with visible elements",
|
|
312
552
|
"Use MCPBrowser's browser_get_current_html to get the page content"
|
|
313
|
-
]
|
|
553
|
+
],
|
|
554
|
+
updatedAreas
|
|
314
555
|
);
|
|
315
556
|
} catch (err) {
|
|
316
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
|
|
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: "
|
|
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:
|
|
209
|
+
fullPage: effectiveFullPage
|
|
194
210
|
});
|
|
195
211
|
|
|
196
|
-
logger.info(`browser_take_screenshot completed: captured from ${currentUrl} (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}`);
|
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
|
+
}
|