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 +1 -1
- package/src/actions/click-element.js +45 -14
- package/src/actions/detect-forms.js +2 -2
- package/src/actions/execute-javascript.js +31 -10
- package/src/actions/fetch-page.js +36 -7
- package/src/actions/get-current-html.js +34 -6
- package/src/actions/navigate-history.js +1 -1
- package/src/actions/scroll-page.js +270 -28
- package/src/actions/take-screenshot.js +34 -10
- package/src/actions/type-text.js +1 -1
- package/src/core/page.js +21 -0
- package/src/mcp-browser.js +21 -1
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"],
|
|
@@ -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:
|
|
121
|
+
error: { type: "string" }
|
|
118
122
|
},
|
|
119
123
|
required: ["status", "durationMs"]
|
|
120
124
|
},
|
|
121
125
|
fallbackAttempt: {
|
|
122
|
-
type:
|
|
126
|
+
type: "object",
|
|
123
127
|
properties: {
|
|
124
128
|
status: { type: "string", enum: ["success", "timeout", "error"] },
|
|
125
129
|
durationMs: { type: "number" },
|
|
126
|
-
error: { type:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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) =>
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
-
//
|
|
222
|
-
if (evalResult === undefined
|
|
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
|
-
|
|
85
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
257
|
-
|
|
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
|
-
|
|
277
|
-
|
|
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
|
-
//
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
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
|
|
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/actions/type-text.js
CHANGED
|
@@ -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:
|
|
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
|
+
}
|
package/src/mcp-browser.js
CHANGED
|
@@ -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 () =>
|
|
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 }));
|