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 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.31",
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
- * Response for successful click_element operations
37
+ * Structured response for click_element with JS fallback metadata
42
38
  */
43
- export class ClickElementSuccessResponse extends MCPResponse {
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
- if (typeof currentUrl !== 'string') {
54
- throw new TypeError('currentUrl must be a string');
55
- }
56
- if (typeof message !== 'string') {
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
- message: this.message,
72
- html: this.html
60
+ html: this.html,
61
+ message: this.message
73
62
  };
74
63
  }
75
64
 
76
65
  getTextSummary() {
77
- return this.message || "Element clicked successfully";
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: "Success message" },
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
- // original:
258
- // Smooth scroll (commented out for performance):
259
- // await page.evaluate(el => el.scrollIntoView({ behavior: 'smooth', block: 'center' }), elementHandle);
260
- // await new Promise(r => setTimeout(r, 300)); // Brief delay after scroll
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
- // Wait for page to stabilize (handles both navigation and SPA content updates)
266
- logger.debug(`Waiting for page to be ready${returnHtml ? '' : ' (fast mode)'}...`);
267
- await waitForPageReady(page, { afterInteraction: true });
268
-
269
- // Wait for SPAs to render dynamic content after click
270
- if (postClickWait > 0) {
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 clickMessage = selector ? `Clicked element: ${selector}` : `Clicked element with text: "${text}"`;
276
- const html = returnHtml ? await extractAndProcessHtml(page, removeUnnecessaryHTML) : null;
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 ClickElementSuccessResponse(currentUrl, clickMessage, html, nextSteps);
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
+ }
@@ -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