mcpbrowser 0.3.31 → 0.3.33
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/README.md +30 -0
- package/package.json +1 -1
- package/src/actions/click-element.js +114 -52
- package/src/actions/execute-javascript.js +244 -0
- package/src/mcp-browser.js +7 -0
- package/src/utils.js +65 -0
package/README.md
CHANGED
|
@@ -31,6 +31,7 @@ Example workflow for AI assistant to use MCPBrowser
|
|
|
31
31
|
- [npm Package](#option-4-npm-package)
|
|
32
32
|
- [MCP Tools](#mcp-tools)
|
|
33
33
|
- [fetch_webpage](#fetch_webpage)
|
|
34
|
+
- [execute_javascript](#execute_javascript)
|
|
34
35
|
- [click_element](#click_element)
|
|
35
36
|
- [type_text](#type_text)
|
|
36
37
|
- [get_current_html](#get_current_html)
|
|
@@ -165,6 +166,33 @@ Fetches web pages using your Chrome/Edge browser. Handles authentication, CAPTCH
|
|
|
165
166
|
|
|
166
167
|
---
|
|
167
168
|
|
|
169
|
+
### `execute_javascript`
|
|
170
|
+
|
|
171
|
+
Executes a JavaScript snippet in the active page context and returns the result with metadata (execution time, truncation flag, URL-change flag). Use this for structured extraction (e.g., inbox rows) or JS-driven UI actions that are unreliable via protocol clicks.
|
|
172
|
+
|
|
173
|
+
**⚠️ Note:** Page must be already loaded via `fetch_webpage` first.
|
|
174
|
+
|
|
175
|
+
**Parameters:**
|
|
176
|
+
- `url` (string, required) - The URL of the page (must match a previously fetched page)
|
|
177
|
+
- `script` (string, required) - JavaScript source to execute in the page context
|
|
178
|
+
- `timeoutMs` (number, optional, default: `30000`, max: `60000`) - Execution timeout
|
|
179
|
+
- `returnType` (string, optional, default: `json`) - `json` | `text` | `void`
|
|
180
|
+
|
|
181
|
+
**Returns:** Serialized result (`outerHTML` for DOM nodes), `type`, `executionTimeMs`, `truncated`, `urlChanged`, `currentUrl`, and structured `error` when the script throws or times out.
|
|
182
|
+
|
|
183
|
+
**Example:**
|
|
184
|
+
```json
|
|
185
|
+
{
|
|
186
|
+
"action": "execute_javascript",
|
|
187
|
+
"url": "https://mail.google.com/",
|
|
188
|
+
"script": "[...document.querySelectorAll('tr.zA')].slice(0,5).map((row,i)=>({index:i+1,sender:row.querySelector('.zF,.yP')?.textContent,subject:row.querySelector('.bog')?.textContent}))",
|
|
189
|
+
"timeoutMs": 30000,
|
|
190
|
+
"returnType": "json"
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
---
|
|
195
|
+
|
|
168
196
|
### `click_element`
|
|
169
197
|
|
|
170
198
|
Clicks on any clickable element (buttons, links, divs with onclick handlers, etc.). Can target by CSS selector or visible text content. Automatically scrolls element into view and waits for page stability after clicking.
|
|
@@ -195,6 +223,8 @@ Clicks on any clickable element (buttons, links, divs with onclick handlers, etc
|
|
|
195
223
|
{ url: "https://example.com", text: "Load More", postClickWait: 2000 }
|
|
196
224
|
```
|
|
197
225
|
|
|
226
|
+
**Fallback behavior:** If the native click times out after locating the element, MCPBrowser automatically retries with a JavaScript-based click and returns `fallbackUsed`, `nativeAttempt`, and `fallbackAttempt` metadata. If both attempts fail, the response lists both errors so you can choose another strategy.
|
|
227
|
+
|
|
198
228
|
---
|
|
199
229
|
|
|
200
230
|
### `type_text`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcpbrowser",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.33",
|
|
4
4
|
"mcpName": "io.github.cherchyk/mcpbrowser",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "MCP browser server - fetch web pages using real Chrome/Edge/Brave browser. Handles authentication, SSO, CAPTCHAs, and anti-bot protection. Browser automation for AI assistants.",
|
|
@@ -33,48 +33,38 @@ import logger from '../core/logger.js';
|
|
|
33
33
|
* @typedef {import('@modelcontextprotocol/sdk/types.js').Tool} Tool
|
|
34
34
|
*/
|
|
35
35
|
|
|
36
|
-
// ============================================================================
|
|
37
|
-
// RESPONSE CLASS
|
|
38
|
-
// ============================================================================
|
|
39
|
-
|
|
40
36
|
/**
|
|
41
|
-
*
|
|
37
|
+
* Structured response for click_element with JS fallback metadata
|
|
42
38
|
*/
|
|
43
|
-
export class
|
|
44
|
-
|
|
45
|
-
* @param {string} currentUrl - URL after click
|
|
46
|
-
* @param {string} message - Success message
|
|
47
|
-
* @param {string|null} html - Page HTML if returnHtml was true
|
|
48
|
-
* @param {string[]} nextSteps - Suggested next actions
|
|
49
|
-
*/
|
|
50
|
-
constructor(currentUrl, message, html, nextSteps) {
|
|
39
|
+
export class ClickWithFallbackResponse extends MCPResponse {
|
|
40
|
+
constructor({ status, fallbackUsed = false, nativeAttempt, fallbackAttempt, postClickWait, currentUrl, html = null, message, nextSteps = [] }) {
|
|
51
41
|
super(nextSteps);
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
throw new TypeError('message must be a string');
|
|
58
|
-
}
|
|
59
|
-
if (html !== null && typeof html !== 'string') {
|
|
60
|
-
throw new TypeError('html must be a string or null');
|
|
61
|
-
}
|
|
62
|
-
|
|
42
|
+
this.status = status;
|
|
43
|
+
this.fallbackUsed = fallbackUsed;
|
|
44
|
+
this.nativeAttempt = nativeAttempt;
|
|
45
|
+
this.fallbackAttempt = fallbackAttempt;
|
|
46
|
+
this.postClickWait = postClickWait;
|
|
63
47
|
this.currentUrl = currentUrl;
|
|
64
|
-
this.message = message;
|
|
65
48
|
this.html = html;
|
|
49
|
+
this.message = message;
|
|
66
50
|
}
|
|
67
51
|
|
|
68
52
|
_getAdditionalFields() {
|
|
69
53
|
return {
|
|
54
|
+
status: this.status,
|
|
55
|
+
fallbackUsed: this.fallbackUsed,
|
|
56
|
+
nativeAttempt: this.nativeAttempt,
|
|
57
|
+
fallbackAttempt: this.fallbackAttempt,
|
|
58
|
+
postClickWait: this.postClickWait,
|
|
70
59
|
currentUrl: this.currentUrl,
|
|
71
|
-
|
|
72
|
-
|
|
60
|
+
html: this.html,
|
|
61
|
+
message: this.message
|
|
73
62
|
};
|
|
74
63
|
}
|
|
75
64
|
|
|
76
65
|
getTextSummary() {
|
|
77
|
-
|
|
66
|
+
const base = this.message || 'Click completed';
|
|
67
|
+
return this.fallbackUsed ? `${base} (JS fallback used)` : base;
|
|
78
68
|
}
|
|
79
69
|
}
|
|
80
70
|
|
|
@@ -106,8 +96,38 @@ export const CLICK_ELEMENT_TOOL = {
|
|
|
106
96
|
outputSchema: {
|
|
107
97
|
type: "object",
|
|
108
98
|
properties: {
|
|
99
|
+
status: { type: "string", enum: ["success", "failed"], description: "Overall click status after native and fallback attempts" },
|
|
100
|
+
fallbackUsed: { type: "boolean", description: "True when native click timed out and JS fallback ran" },
|
|
101
|
+
nativeAttempt: {
|
|
102
|
+
type: "object",
|
|
103
|
+
properties: {
|
|
104
|
+
status: { type: "string", enum: ["success", "timeout", "error"] },
|
|
105
|
+
durationMs: { type: "number" },
|
|
106
|
+
error: { type: ["string", "null"] }
|
|
107
|
+
},
|
|
108
|
+
required: ["status", "durationMs"]
|
|
109
|
+
},
|
|
110
|
+
fallbackAttempt: {
|
|
111
|
+
type: ["object", "null"],
|
|
112
|
+
properties: {
|
|
113
|
+
status: { type: "string", enum: ["success", "timeout", "error"] },
|
|
114
|
+
durationMs: { type: "number" },
|
|
115
|
+
error: { type: ["string", "null"] }
|
|
116
|
+
},
|
|
117
|
+
required: ["status", "durationMs"],
|
|
118
|
+
description: "Present when fallbackUsed is true"
|
|
119
|
+
},
|
|
120
|
+
postClickWait: {
|
|
121
|
+
type: "object",
|
|
122
|
+
properties: {
|
|
123
|
+
applied: { type: "boolean" },
|
|
124
|
+
waitedMs: { type: "number" }
|
|
125
|
+
},
|
|
126
|
+
required: ["applied", "waitedMs"],
|
|
127
|
+
description: "Post-click wait metadata"
|
|
128
|
+
},
|
|
109
129
|
currentUrl: { type: "string", description: "URL after click" },
|
|
110
|
-
message: { type: "string", description: "
|
|
130
|
+
message: { type: "string", description: "Status message" },
|
|
111
131
|
html: {
|
|
112
132
|
type: ["string", "null"],
|
|
113
133
|
description: "Page HTML if returnHtml was true, null otherwise"
|
|
@@ -118,7 +138,7 @@ export const CLICK_ELEMENT_TOOL = {
|
|
|
118
138
|
description: "Suggested next actions"
|
|
119
139
|
}
|
|
120
140
|
},
|
|
121
|
-
required: ["currentUrl", "message", "html", "nextSteps"],
|
|
141
|
+
required: ["status", "fallbackUsed", "nativeAttempt", "currentUrl", "message", "html", "nextSteps"],
|
|
122
142
|
additionalProperties: false
|
|
123
143
|
}
|
|
124
144
|
};
|
|
@@ -252,28 +272,60 @@ export async function clickElement({ url, selector, text, waitForElementTimeout
|
|
|
252
272
|
}
|
|
253
273
|
|
|
254
274
|
// Scroll element into view and click
|
|
255
|
-
// For automation, use instant scroll instead of smooth animation to avoid delays
|
|
256
275
|
await page.evaluate(el => el.scrollIntoView({ behavior: 'auto', block: 'center' }), elementHandle);
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
276
|
+
|
|
277
|
+
const attemptClick = async (label, fn, timeoutMs) => {
|
|
278
|
+
const start = Date.now();
|
|
279
|
+
let timeoutId;
|
|
280
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
281
|
+
timeoutId = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
try {
|
|
285
|
+
await Promise.race([fn(), timeoutPromise]);
|
|
286
|
+
return { status: 'success', durationMs: Date.now() - start };
|
|
287
|
+
} catch (error) {
|
|
288
|
+
const status = /timeout|timed.out/i.test(error?.message || '') ? 'timeout' : 'error';
|
|
289
|
+
return { status, durationMs: Date.now() - start, error: error?.message || 'Unknown error' };
|
|
290
|
+
} finally {
|
|
291
|
+
clearTimeout(timeoutId);
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const clickTimeout = Math.min(Math.max(waitForElementTimeout, 500), 60000);
|
|
262
296
|
logger.debug(`Clicking: ${selector || `text="${text}"`}`);
|
|
263
|
-
await elementHandle.click();
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
await new Promise(resolve => setTimeout(resolve, postClickWait));
|
|
297
|
+
const nativeAttempt = await attemptClick('native click', () => elementHandle.click(), clickTimeout);
|
|
298
|
+
|
|
299
|
+
let fallbackUsed = false;
|
|
300
|
+
let fallbackAttempt = null;
|
|
301
|
+
|
|
302
|
+
if (nativeAttempt.status === 'timeout') {
|
|
303
|
+
fallbackUsed = true;
|
|
304
|
+
fallbackAttempt = await attemptClick('fallback click', () => page.evaluate(el => el.click(), elementHandle), clickTimeout);
|
|
272
305
|
}
|
|
273
|
-
|
|
306
|
+
|
|
307
|
+
const finalStatus = nativeAttempt.status === 'success' || (fallbackAttempt && fallbackAttempt.status === 'success')
|
|
308
|
+
? 'success'
|
|
309
|
+
: 'failed';
|
|
310
|
+
|
|
311
|
+
if (finalStatus === 'success') {
|
|
312
|
+
logger.debug(`Waiting for page to be ready${returnHtml ? '' : ' (fast mode)'}...`);
|
|
313
|
+
await waitForPageReady(page, { afterInteraction: true });
|
|
314
|
+
|
|
315
|
+
if (postClickWait > 0) {
|
|
316
|
+
await new Promise(resolve => setTimeout(resolve, postClickWait));
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
274
320
|
const currentUrl = page.url();
|
|
275
|
-
const
|
|
276
|
-
const
|
|
321
|
+
const html = finalStatus === 'success' && returnHtml ? await extractAndProcessHtml(page, removeUnnecessaryHTML) : null;
|
|
322
|
+
const baseMessage = selector ? `Clicked element: ${selector}` : `Clicked element with text: "${text}"`;
|
|
323
|
+
const message = finalStatus === 'success'
|
|
324
|
+
? baseMessage
|
|
325
|
+
: fallbackUsed
|
|
326
|
+
? `Click failed after fallback. Native: ${nativeAttempt.error || nativeAttempt.status}. Fallback: ${fallbackAttempt?.error || fallbackAttempt?.status}`
|
|
327
|
+
: `Click failed. Native: ${nativeAttempt.error || nativeAttempt.status}`;
|
|
328
|
+
|
|
277
329
|
const nextSteps = returnHtml
|
|
278
330
|
? [
|
|
279
331
|
"Use MCPBrowser's click_element again to navigate further",
|
|
@@ -288,10 +340,20 @@ export async function clickElement({ url, selector, text, waitForElementTimeout
|
|
|
288
340
|
"Use MCPBrowser's click_element or MCPBrowser's type_text for more interactions",
|
|
289
341
|
"Use MCPBrowser's close_tab when finished"
|
|
290
342
|
];
|
|
291
|
-
|
|
292
|
-
logger.info(`click_element completed: ${selector || `text="${text}"`}`);
|
|
293
|
-
|
|
294
|
-
return new
|
|
343
|
+
|
|
344
|
+
logger.info(`click_element completed: ${selector || `text="${text}"`}${fallbackUsed ? ' (fallback used)' : ''}`);
|
|
345
|
+
|
|
346
|
+
return new ClickWithFallbackResponse({
|
|
347
|
+
status: finalStatus,
|
|
348
|
+
fallbackUsed,
|
|
349
|
+
nativeAttempt,
|
|
350
|
+
fallbackAttempt,
|
|
351
|
+
postClickWait: { applied: finalStatus === 'success', waitedMs: finalStatus === 'success' ? postClickWait : 0 },
|
|
352
|
+
currentUrl,
|
|
353
|
+
html,
|
|
354
|
+
message,
|
|
355
|
+
nextSteps
|
|
356
|
+
});
|
|
295
357
|
} catch (err) {
|
|
296
358
|
logger.error(`click_element failed: ${err.message}`);
|
|
297
359
|
return new InformationalResponse(
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* execute-javascript.js - Run arbitrary JavaScript in the current page context
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { getBrowser, getValidatedPage } from '../core/browser.js';
|
|
6
|
+
import { waitForPageReady } from '../core/page.js';
|
|
7
|
+
import { MCPResponse, InformationalResponse } from '../core/responses.js';
|
|
8
|
+
import logger from '../core/logger.js';
|
|
9
|
+
import { serializeExecutionResult } from '../utils.js';
|
|
10
|
+
|
|
11
|
+
// Shared execution defaults for script actions
|
|
12
|
+
export const EXECUTION_TIMEOUT_DEFAULT_MS = 30_000;
|
|
13
|
+
export const EXECUTION_TIMEOUT_MAX_MS = 60_000;
|
|
14
|
+
export const EXECUTION_RESULT_MAX_BYTES = 100_000;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {import('@modelcontextprotocol/sdk/types.js').Tool} Tool
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Structured response for execute_javascript action
|
|
22
|
+
*/
|
|
23
|
+
export class ExecuteJavascriptResponse extends MCPResponse {
|
|
24
|
+
constructor({ result, type, executionTimeMs, truncated = false, urlChanged = false, currentUrl = '', error = null, nextSteps = [] }) {
|
|
25
|
+
super(nextSteps);
|
|
26
|
+
|
|
27
|
+
this.result = result;
|
|
28
|
+
this.type = type;
|
|
29
|
+
this.executionTimeMs = executionTimeMs;
|
|
30
|
+
this.truncated = truncated;
|
|
31
|
+
this.urlChanged = urlChanged;
|
|
32
|
+
this.currentUrl = currentUrl;
|
|
33
|
+
this.error = error;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
_getAdditionalFields() {
|
|
37
|
+
return {
|
|
38
|
+
result: this.result,
|
|
39
|
+
type: this.type,
|
|
40
|
+
executionTimeMs: this.executionTimeMs,
|
|
41
|
+
truncated: this.truncated,
|
|
42
|
+
urlChanged: this.urlChanged,
|
|
43
|
+
currentUrl: this.currentUrl,
|
|
44
|
+
error: this.error || undefined
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
getTextSummary() {
|
|
49
|
+
const outcome = this.error ? `Script error: ${this.error.message || 'Unknown error'}` : 'Script executed';
|
|
50
|
+
const timing = typeof this.executionTimeMs === 'number' ? ` in ${this.executionTimeMs}ms` : '';
|
|
51
|
+
const nav = this.urlChanged ? ' (navigation detected)' : '';
|
|
52
|
+
return `${outcome}${timing}${nav}`;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const EXECUTE_JAVASCRIPT_TOOL = {
|
|
57
|
+
name: 'execute_javascript',
|
|
58
|
+
title: 'Execute JavaScript',
|
|
59
|
+
description: '**BROWSER INTERACTION** - Executes a JavaScript snippet in the active page and returns the result with metadata (execution time, truncation, navigation detection). Use for structured extraction or UI actions that are unreliable via protocol clicks.',
|
|
60
|
+
inputSchema: {
|
|
61
|
+
type: 'object',
|
|
62
|
+
properties: {
|
|
63
|
+
url: { type: 'string', description: 'The URL of the page (must match a previously fetched page)' },
|
|
64
|
+
script: { type: 'string', description: 'JavaScript source code to execute in page context' },
|
|
65
|
+
timeoutMs: { type: 'number', description: 'Maximum execution time in milliseconds', default: EXECUTION_TIMEOUT_DEFAULT_MS },
|
|
66
|
+
returnType: { type: 'string', description: "How to interpret the result: 'json' | 'text' | 'void'", enum: ['json', 'text', 'void'], default: 'json' }
|
|
67
|
+
},
|
|
68
|
+
required: ['url', 'script'],
|
|
69
|
+
additionalProperties: false
|
|
70
|
+
},
|
|
71
|
+
outputSchema: {
|
|
72
|
+
type: 'object',
|
|
73
|
+
properties: {
|
|
74
|
+
result: { description: 'Serialized result of the script', nullable: true },
|
|
75
|
+
type: { type: 'string', description: 'Type of the returned result' },
|
|
76
|
+
executionTimeMs: { type: 'number', description: 'Script execution duration' },
|
|
77
|
+
truncated: { type: 'boolean', description: 'True if result was capped to size limit' },
|
|
78
|
+
urlChanged: { type: 'boolean', description: 'True if page URL changed during execution' },
|
|
79
|
+
currentUrl: { type: 'string', description: 'URL after execution' },
|
|
80
|
+
nextSteps: { type: 'array', items: { type: 'string' } },
|
|
81
|
+
error: { type: ['object', 'null'], description: 'Error object when script throws or times out' }
|
|
82
|
+
},
|
|
83
|
+
required: ['type', 'executionTimeMs', 'truncated', 'urlChanged', 'currentUrl', 'nextSteps'],
|
|
84
|
+
additionalProperties: false
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
function clampTimeout(timeoutMs) {
|
|
89
|
+
const numeric = typeof timeoutMs === 'number' && Number.isFinite(timeoutMs) ? timeoutMs : EXECUTION_TIMEOUT_DEFAULT_MS;
|
|
90
|
+
return Math.min(Math.max(numeric, 1), EXECUTION_TIMEOUT_MAX_MS);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function buildErrorResponse(message, reason, nextSteps) {
|
|
94
|
+
return new InformationalResponse(message, reason, nextSteps);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function executeJavascript({ url, script, timeoutMs = EXECUTION_TIMEOUT_DEFAULT_MS, returnType = 'json' }) {
|
|
98
|
+
logger.info(`execute_javascript called: ${url}`);
|
|
99
|
+
|
|
100
|
+
if (!url) throw new Error('url parameter is required');
|
|
101
|
+
if (!script || typeof script !== 'string' || !script.trim()) throw new Error('script parameter is required');
|
|
102
|
+
|
|
103
|
+
let hostname;
|
|
104
|
+
try {
|
|
105
|
+
hostname = new URL(url).hostname;
|
|
106
|
+
} catch {
|
|
107
|
+
throw new Error(`Invalid URL: ${url}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
await getBrowser();
|
|
112
|
+
} catch (err) {
|
|
113
|
+
logger.error(`execute_javascript: Failed to connect to browser: ${err.message}`);
|
|
114
|
+
return buildErrorResponse(
|
|
115
|
+
`Browser connection failed: ${err.message}`,
|
|
116
|
+
'The browser must be running with remote debugging enabled.',
|
|
117
|
+
[
|
|
118
|
+
'Ensure the browser is installed and running',
|
|
119
|
+
'Check that remote debugging is enabled (--remote-debugging-port)',
|
|
120
|
+
'Try restarting the MCP server'
|
|
121
|
+
]
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const { page, error: pageError } = await getValidatedPage(hostname);
|
|
126
|
+
if (!page) {
|
|
127
|
+
const isConnectionLost = pageError && pageError.includes('connection');
|
|
128
|
+
logger.debug(`execute_javascript: ${pageError || 'No page found for ' + hostname}`);
|
|
129
|
+
return buildErrorResponse(
|
|
130
|
+
isConnectionLost ? `Page connection lost for ${hostname}` : `No open page found for ${hostname}`,
|
|
131
|
+
isConnectionLost
|
|
132
|
+
? 'The browser tab was closed or the connection was lost. The page needs to be reloaded.'
|
|
133
|
+
: 'The page must be loaded before you can run scripts on it.',
|
|
134
|
+
[
|
|
135
|
+
"Use MCPBrowser's fetch_webpage tool to load the page first",
|
|
136
|
+
"Then retry MCPBrowser's execute_javascript with the same URL"
|
|
137
|
+
]
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const effectiveTimeout = clampTimeout(timeoutMs);
|
|
142
|
+
const beforeUrl = page.url();
|
|
143
|
+
const start = Date.now();
|
|
144
|
+
|
|
145
|
+
const evalPromise = page.evaluate(async ({ userScript, mode }) => {
|
|
146
|
+
const wrap = async () => {
|
|
147
|
+
const fn = new Function(`return (async () => { ${userScript} })();`);
|
|
148
|
+
return await fn();
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
const value = await wrap();
|
|
153
|
+
const isDom = typeof Element !== 'undefined' && value instanceof Element;
|
|
154
|
+
if (mode === 'void') {
|
|
155
|
+
return { value: null, type: 'void' };
|
|
156
|
+
}
|
|
157
|
+
if (mode === 'text') {
|
|
158
|
+
return { value: String(value), type: 'string' };
|
|
159
|
+
}
|
|
160
|
+
if (isDom) {
|
|
161
|
+
return { value: value.outerHTML, type: 'dom-html' };
|
|
162
|
+
}
|
|
163
|
+
const valueType = Array.isArray(value) ? 'array' : (value === null ? 'null' : typeof value);
|
|
164
|
+
return { value, type: valueType };
|
|
165
|
+
} catch (error) {
|
|
166
|
+
return { error: { name: error?.name || 'Error', message: error?.message || 'Script error', stack: error?.stack || '' } };
|
|
167
|
+
}
|
|
168
|
+
}, { userScript: script, mode: returnType });
|
|
169
|
+
|
|
170
|
+
let evalResult;
|
|
171
|
+
try {
|
|
172
|
+
evalResult = await Promise.race([
|
|
173
|
+
evalPromise,
|
|
174
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error(`Execution timed out after ${effectiveTimeout}ms`)), effectiveTimeout))
|
|
175
|
+
]);
|
|
176
|
+
} catch (err) {
|
|
177
|
+
const executionTimeMs = Date.now() - start;
|
|
178
|
+
let currentUrl = page.url();
|
|
179
|
+
try {
|
|
180
|
+
currentUrl = await page.evaluate(() => location.href);
|
|
181
|
+
} catch {
|
|
182
|
+
// ignore evaluation failures when the page is gone
|
|
183
|
+
}
|
|
184
|
+
return new ExecuteJavascriptResponse({
|
|
185
|
+
result: null,
|
|
186
|
+
type: 'error',
|
|
187
|
+
executionTimeMs,
|
|
188
|
+
truncated: false,
|
|
189
|
+
urlChanged: currentUrl !== beforeUrl,
|
|
190
|
+
currentUrl,
|
|
191
|
+
error: { name: 'TimeoutError', message: err.message }
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const executionTimeMs = Date.now() - start;
|
|
196
|
+
let currentUrl = page.url();
|
|
197
|
+
try {
|
|
198
|
+
currentUrl = await page.evaluate(() => location.href);
|
|
199
|
+
} catch {
|
|
200
|
+
// If we can't read location (e.g., cross-origin), fall back to page.url()
|
|
201
|
+
}
|
|
202
|
+
const urlChanged = currentUrl !== beforeUrl;
|
|
203
|
+
|
|
204
|
+
if (evalResult?.error) {
|
|
205
|
+
return new ExecuteJavascriptResponse({
|
|
206
|
+
result: null,
|
|
207
|
+
type: 'error',
|
|
208
|
+
executionTimeMs,
|
|
209
|
+
truncated: false,
|
|
210
|
+
urlChanged,
|
|
211
|
+
currentUrl,
|
|
212
|
+
error: evalResult.error
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const serialization = serializeExecutionResult(evalResult?.value, { maxBytes: EXECUTION_RESULT_MAX_BYTES });
|
|
217
|
+
|
|
218
|
+
return new ExecuteJavascriptResponse({
|
|
219
|
+
result: serialization.result,
|
|
220
|
+
type: evalResult?.type || serialization.type,
|
|
221
|
+
executionTimeMs,
|
|
222
|
+
truncated: serialization.truncated,
|
|
223
|
+
urlChanged,
|
|
224
|
+
currentUrl,
|
|
225
|
+
nextSteps: [
|
|
226
|
+
'Use click_element or type_text for follow-up actions',
|
|
227
|
+
'Inspect urlChanged to decide if navigation occurred',
|
|
228
|
+
serialization.truncated ? 'Narrow your selector or reduce returned fields to avoid truncation' : 'Proceed with the returned data'
|
|
229
|
+
]
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export async function executeJavascriptWithReady(params) {
|
|
234
|
+
const response = await executeJavascript(params);
|
|
235
|
+
try {
|
|
236
|
+
if (!response.error) {
|
|
237
|
+
const { page } = await getValidatedPage(new URL(params.url).hostname);
|
|
238
|
+
if (page) await waitForPageReady(page, { afterInteraction: true });
|
|
239
|
+
}
|
|
240
|
+
} catch (err) {
|
|
241
|
+
logger.debug(`execute_javascript post-wait skipped: ${err.message}`);
|
|
242
|
+
}
|
|
243
|
+
return response;
|
|
244
|
+
}
|
package/src/mcp-browser.js
CHANGED
|
@@ -27,6 +27,7 @@ import { closeTab, CLOSE_TAB_TOOL } from './actions/close-tab.js';
|
|
|
27
27
|
import { getCurrentHtml, GET_CURRENT_HTML_TOOL } from './actions/get-current-html.js';
|
|
28
28
|
import { takeScreenshot, TAKE_SCREENSHOT_TOOL } from './actions/take-screenshot.js';
|
|
29
29
|
import { scrollPage, SCROLL_PAGE_TOOL } from './actions/scroll-page.js';
|
|
30
|
+
import { executeJavascript, EXECUTE_JAVASCRIPT_TOOL } from './actions/execute-javascript.js';
|
|
30
31
|
|
|
31
32
|
// Import functions for testing exports
|
|
32
33
|
import { getBrowser, closeBrowser } from './core/browser.js';
|
|
@@ -60,6 +61,7 @@ async function main() {
|
|
|
60
61
|
const tools = [
|
|
61
62
|
// ACCEPT_EULA_TOOL,
|
|
62
63
|
FETCH_WEBPAGE_TOOL,
|
|
64
|
+
EXECUTE_JAVASCRIPT_TOOL,
|
|
63
65
|
CLICK_ELEMENT_TOOL,
|
|
64
66
|
TYPE_TEXT_TOOL,
|
|
65
67
|
CLOSE_TAB_TOOL,
|
|
@@ -91,6 +93,10 @@ async function main() {
|
|
|
91
93
|
case "fetch_webpage":
|
|
92
94
|
result = await fetchPage(safeArgs);
|
|
93
95
|
break;
|
|
96
|
+
|
|
97
|
+
case "execute_javascript":
|
|
98
|
+
result = await executeJavascript(safeArgs);
|
|
99
|
+
break;
|
|
94
100
|
|
|
95
101
|
case "click_element":
|
|
96
102
|
result = await clickElement(safeArgs);
|
|
@@ -158,6 +164,7 @@ export {
|
|
|
158
164
|
extractAndProcessHtml,
|
|
159
165
|
getBaseDomain,
|
|
160
166
|
isLikelyAuthUrl,
|
|
167
|
+
executeJavascript,
|
|
161
168
|
clickElement,
|
|
162
169
|
typeText,
|
|
163
170
|
closeTab,
|
package/src/utils.js
CHANGED
|
@@ -72,6 +72,71 @@ export function truncate(str, max) {
|
|
|
72
72
|
return str.length > max ? `${str.slice(0, max)}... [truncated]` : str;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
function byteLength(str) {
|
|
76
|
+
return new TextEncoder().encode(str).length;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function safeStringify(value) {
|
|
80
|
+
const seen = new WeakSet();
|
|
81
|
+
return JSON.stringify(value, (key, val) => {
|
|
82
|
+
if (typeof val === 'function' || typeof val === 'symbol') return undefined;
|
|
83
|
+
if (typeof val === 'bigint') return val.toString();
|
|
84
|
+
if (val && typeof val === 'object') {
|
|
85
|
+
if (seen.has(val)) return '[Circular]';
|
|
86
|
+
seen.add(val);
|
|
87
|
+
}
|
|
88
|
+
return val;
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Serialize a JS value for MCP responses with size and safety guards.
|
|
94
|
+
* Returns JSON-safe payload, detected type, and truncation flag.
|
|
95
|
+
* @param {any} value
|
|
96
|
+
* @param {object} options
|
|
97
|
+
* @param {number} [options.maxBytes=100000]
|
|
98
|
+
* @returns {{ result: any, type: string, truncated: boolean }}
|
|
99
|
+
*/
|
|
100
|
+
export function serializeExecutionResult(value, { maxBytes = 100_000 } = {}) {
|
|
101
|
+
let type = Array.isArray(value) ? 'array' : (value === null ? 'null' : typeof value);
|
|
102
|
+
let jsonString;
|
|
103
|
+
|
|
104
|
+
if (type === 'string') {
|
|
105
|
+
jsonString = String(value);
|
|
106
|
+
} else {
|
|
107
|
+
try {
|
|
108
|
+
jsonString = safeStringify(value);
|
|
109
|
+
} catch (err) {
|
|
110
|
+
jsonString = String(value);
|
|
111
|
+
type = 'string';
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let truncated = false;
|
|
116
|
+
if (byteLength(jsonString) > maxBytes) {
|
|
117
|
+
const encoder = new TextEncoder();
|
|
118
|
+
const decoder = new TextDecoder();
|
|
119
|
+
const sliced = encoder.encode(jsonString).slice(0, maxBytes);
|
|
120
|
+
jsonString = `${decoder.decode(sliced)}...[truncated]`;
|
|
121
|
+
truncated = true;
|
|
122
|
+
type = 'string';
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let parsed = value;
|
|
126
|
+
if (type === 'object' || type === 'array' || type === 'null') {
|
|
127
|
+
try {
|
|
128
|
+
parsed = JSON.parse(jsonString);
|
|
129
|
+
} catch {
|
|
130
|
+
parsed = jsonString;
|
|
131
|
+
type = 'string';
|
|
132
|
+
}
|
|
133
|
+
} else if (type === 'string') {
|
|
134
|
+
parsed = jsonString;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return { result: parsed, type, truncated };
|
|
138
|
+
}
|
|
139
|
+
|
|
75
140
|
/**
|
|
76
141
|
* Extract base domain from hostname (e.g., "mail.google.com" → "google.com")
|
|
77
142
|
* @param {string} hostname - The hostname to parse
|