mcpbrowser 0.3.32 → 0.3.34

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.32",
3
+ "version": "0.3.34",
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
+ }
@@ -0,0 +1,248 @@
1
+ /**
2
+ * navigate-history.js - Browser back/forward navigation
3
+ * Navigates browser history on an already-loaded page.
4
+ */
5
+
6
+ import { getBrowser, getValidatedPage, domainPages } from '../core/browser.js';
7
+ import { extractAndProcessHtml, waitForPageReady } from '../core/page.js';
8
+ import { MCPResponse, InformationalResponse } from '../core/responses.js';
9
+ import logger from '../core/logger.js';
10
+
11
+ /**
12
+ * @typedef {import('@modelcontextprotocol/sdk/types.js').Tool} Tool
13
+ */
14
+
15
+ // ============================================================================
16
+ // RESPONSE CLASS
17
+ // ============================================================================
18
+
19
+ /**
20
+ * Response for successful navigate_history operations
21
+ */
22
+ export class NavigateHistorySuccessResponse extends MCPResponse {
23
+ /**
24
+ * @param {string} direction - Navigation direction (back or forward)
25
+ * @param {string} previousUrl - URL before navigation
26
+ * @param {string} currentUrl - URL after navigation
27
+ * @param {string|null} html - Page HTML content (null if returnHtml=false)
28
+ * @param {string[]} nextSteps - Suggested next actions
29
+ */
30
+ constructor(direction, previousUrl, currentUrl, html, nextSteps) {
31
+ super(nextSteps);
32
+
33
+ if (typeof direction !== 'string') {
34
+ throw new TypeError('direction must be a string');
35
+ }
36
+ if (typeof previousUrl !== 'string') {
37
+ throw new TypeError('previousUrl must be a string');
38
+ }
39
+ if (typeof currentUrl !== 'string') {
40
+ throw new TypeError('currentUrl must be a string');
41
+ }
42
+ if (html !== null && typeof html !== 'string') {
43
+ throw new TypeError('html must be a string or null');
44
+ }
45
+
46
+ this.direction = direction;
47
+ this.previousUrl = previousUrl;
48
+ this.currentUrl = currentUrl;
49
+ this.html = html;
50
+ }
51
+
52
+ _getAdditionalFields() {
53
+ return {
54
+ direction: this.direction,
55
+ previousUrl: this.previousUrl,
56
+ currentUrl: this.currentUrl,
57
+ html: this.html
58
+ };
59
+ }
60
+
61
+ getTextSummary() {
62
+ return `Navigated ${this.direction}: ${this.previousUrl} → ${this.currentUrl}`;
63
+ }
64
+ }
65
+
66
+ // ============================================================================
67
+ // TOOL DEFINITION
68
+ // ============================================================================
69
+
70
+ /** @type {Tool} */
71
+ export const NAVIGATE_HISTORY_TOOL = {
72
+ name: "navigate_history",
73
+ title: "Navigate Back/Forward",
74
+ description: "**BROWSER HISTORY NAVIGATION** - Navigate back or forward in browser history on an already-loaded page. Use after clicking links to return to the previous page, or to go forward after going back.\n\n**PREREQUISITE**: Page MUST be loaded with fetch_webpage first. This tool navigates the history of an existing browser tab.",
75
+ inputSchema: {
76
+ type: "object",
77
+ properties: {
78
+ url: { type: "string", description: "URL of the already-loaded page (identifies which tab to navigate)" },
79
+ direction: {
80
+ type: "string",
81
+ enum: ["back", "forward"],
82
+ description: "Navigation direction: 'back' to go to previous page, 'forward' to go to next page",
83
+ default: "back"
84
+ },
85
+ returnHtml: { type: "boolean", description: "Return page HTML after navigation", default: true },
86
+ removeUnnecessaryHTML: { type: "boolean", description: "Remove unnecessary HTML elements (scripts, styles, etc.) for size reduction.", default: true }
87
+ },
88
+ required: ["url"],
89
+ additionalProperties: false
90
+ },
91
+ outputSchema: {
92
+ type: "object",
93
+ properties: {
94
+ direction: { type: "string", enum: ["back", "forward"], description: "Navigation direction used" },
95
+ previousUrl: { type: "string", description: "URL before navigation" },
96
+ currentUrl: { type: "string", description: "URL after navigation" },
97
+ html: { type: ["string", "null"], description: "Page HTML content after navigation (null if returnHtml=false)" },
98
+ nextSteps: {
99
+ type: "array",
100
+ items: { type: "string" },
101
+ description: "Suggested next actions"
102
+ }
103
+ },
104
+ required: ["direction", "previousUrl", "currentUrl", "nextSteps"],
105
+ additionalProperties: false
106
+ },
107
+ annotations: {
108
+ title: "Navigate Back/Forward",
109
+ readOnlyHint: false,
110
+ destructiveHint: false,
111
+ openWorldHint: true
112
+ }
113
+ };
114
+
115
+ // ============================================================================
116
+ // ACTION FUNCTION
117
+ // ============================================================================
118
+
119
+ /**
120
+ * Navigate browser history (back/forward) on an already-loaded page
121
+ * @param {Object} params - Parameters
122
+ * @param {string} params.url - URL of the already-loaded page
123
+ * @param {string} [params.direction='back'] - Navigation direction
124
+ * @param {boolean} [params.returnHtml=true] - Return page HTML after navigation
125
+ * @param {boolean} [params.removeUnnecessaryHTML=true] - Clean HTML
126
+ * @returns {Promise<MCPResponse>} Navigation result
127
+ */
128
+ export async function navigateHistory({ url, direction = 'back', returnHtml = true, removeUnnecessaryHTML = true }) {
129
+ logger.info(`navigate_history called: url=${url}, direction=${direction}`);
130
+
131
+ if (!url) {
132
+ throw new Error("url parameter is required");
133
+ }
134
+
135
+ let hostname;
136
+ try {
137
+ hostname = new URL(url).hostname;
138
+ } catch {
139
+ throw new Error(`Invalid URL: ${url}`);
140
+ }
141
+
142
+ // Ensure browser connection
143
+ try {
144
+ await getBrowser();
145
+ } catch (err) {
146
+ logger.error(`navigate_history: Failed to connect to browser: ${err.message}`);
147
+ return new InformationalResponse(
148
+ `Browser connection failed: ${err.message}`,
149
+ 'The browser must be running with remote debugging enabled.',
150
+ [
151
+ 'Ensure the browser is installed and running',
152
+ 'Check that remote debugging is enabled (--remote-debugging-port)',
153
+ 'Try restarting the MCP server'
154
+ ]
155
+ );
156
+ }
157
+
158
+ // Validate page exists and is usable
159
+ const { page, error: pageError } = await getValidatedPage(hostname);
160
+
161
+ if (!page) {
162
+ const isConnectionLost = pageError && pageError.includes('connection');
163
+ logger.debug(`navigate_history: ${pageError || 'No page found for ' + hostname}`);
164
+ return new InformationalResponse(
165
+ isConnectionLost ? `Page connection lost for ${hostname}` : `No open page found for ${hostname}`,
166
+ isConnectionLost
167
+ ? 'The browser tab was closed or the connection was lost. The page needs to be reloaded.'
168
+ : 'The page must be loaded before you can navigate its history.',
169
+ [
170
+ "Use MCPBrowser's fetch_webpage tool to load the page first",
171
+ "Then retry MCPBrowser's navigate_history with the same URL"
172
+ ]
173
+ );
174
+ }
175
+
176
+ try {
177
+ const previousUrl = page.url();
178
+
179
+ // Navigate history
180
+ let response;
181
+ if (direction === 'forward') {
182
+ response = await page.goForward({ waitUntil: 'domcontentloaded', timeout: 30000 });
183
+ } else {
184
+ response = await page.goBack({ waitUntil: 'domcontentloaded', timeout: 30000 });
185
+ }
186
+
187
+ // goBack/goForward return null if there's no history entry
188
+ if (response === null) {
189
+ logger.info(`navigate_history: No ${direction} history entry available`);
190
+ return new InformationalResponse(
191
+ `No ${direction} history entry available`,
192
+ `The page has no ${direction} history to navigate to. This means you're already at the ${direction === 'back' ? 'first' : 'last'} page in the browsing history for this tab.`,
193
+ [
194
+ direction === 'back'
195
+ ? "Use MCPBrowser's fetch_webpage to navigate to a different URL"
196
+ : "Use MCPBrowser's navigate_history with direction='back' to go back instead",
197
+ "Use MCPBrowser's get_current_html to check the current page content"
198
+ ]
199
+ );
200
+ }
201
+
202
+ const currentUrl = page.url();
203
+
204
+ // Update domainPages if hostname changed after navigation
205
+ try {
206
+ const newHostname = new URL(currentUrl).hostname;
207
+ if (newHostname !== hostname) {
208
+ domainPages.delete(hostname);
209
+ domainPages.set(newHostname, page);
210
+ logger.info(`navigate_history: Updated domainPages mapping: ${hostname} → ${newHostname}`);
211
+ }
212
+ } catch {
213
+ // If URL parsing fails, keep existing mapping
214
+ }
215
+
216
+ // Extract HTML if requested
217
+ let html = null;
218
+ if (returnHtml) {
219
+ await waitForPageReady(page);
220
+ html = await extractAndProcessHtml(page, removeUnnecessaryHTML);
221
+ }
222
+
223
+ logger.info(`navigate_history completed: ${direction} from ${previousUrl} to ${currentUrl}`);
224
+
225
+ return new NavigateHistorySuccessResponse(
226
+ direction,
227
+ previousUrl,
228
+ currentUrl,
229
+ html,
230
+ [
231
+ "Use MCPBrowser's navigate_history to go back or forward again",
232
+ "Use MCPBrowser's click_element to interact with elements on the page",
233
+ "Use MCPBrowser's get_current_html to re-read the page content",
234
+ "Use MCPBrowser's fetch_webpage to navigate to a new URL"
235
+ ]
236
+ );
237
+ } catch (err) {
238
+ logger.error(`navigate_history failed: ${err.message}`);
239
+ return new InformationalResponse(
240
+ `Navigation ${direction} failed: ${err.message}`,
241
+ 'The browser could not navigate. The page may have been closed or the connection was lost.',
242
+ [
243
+ "Try MCPBrowser's fetch_webpage to reload the page",
244
+ "Use MCPBrowser's close_tab and start fresh if needed"
245
+ ]
246
+ );
247
+ }
248
+ }
@@ -27,6 +27,8 @@ 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';
31
+ import { navigateHistory, NAVIGATE_HISTORY_TOOL } from './actions/navigate-history.js';
30
32
 
31
33
  // Import functions for testing exports
32
34
  import { getBrowser, closeBrowser } from './core/browser.js';
@@ -60,12 +62,14 @@ async function main() {
60
62
  const tools = [
61
63
  // ACCEPT_EULA_TOOL,
62
64
  FETCH_WEBPAGE_TOOL,
65
+ EXECUTE_JAVASCRIPT_TOOL,
63
66
  CLICK_ELEMENT_TOOL,
64
67
  TYPE_TEXT_TOOL,
65
68
  CLOSE_TAB_TOOL,
66
69
  GET_CURRENT_HTML_TOOL,
67
70
  TAKE_SCREENSHOT_TOOL,
68
- SCROLL_PAGE_TOOL
71
+ SCROLL_PAGE_TOOL,
72
+ NAVIGATE_HISTORY_TOOL
69
73
  ];
70
74
 
71
75
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
@@ -91,6 +95,10 @@ async function main() {
91
95
  case "fetch_webpage":
92
96
  result = await fetchPage(safeArgs);
93
97
  break;
98
+
99
+ case "execute_javascript":
100
+ result = await executeJavascript(safeArgs);
101
+ break;
94
102
 
95
103
  case "click_element":
96
104
  result = await clickElement(safeArgs);
@@ -115,6 +123,10 @@ async function main() {
115
123
  case "scroll_page":
116
124
  result = await scrollPage(safeArgs);
117
125
  break;
126
+
127
+ case "navigate_history":
128
+ result = await navigateHistory(safeArgs);
129
+ break;
118
130
 
119
131
  default:
120
132
  throw new Error(`Unknown tool: ${name}`);
@@ -158,12 +170,14 @@ export {
158
170
  extractAndProcessHtml,
159
171
  getBaseDomain,
160
172
  isLikelyAuthUrl,
173
+ executeJavascript,
161
174
  clickElement,
162
175
  typeText,
163
176
  closeTab,
164
177
  getCurrentHtml,
165
178
  takeScreenshot,
166
179
  scrollPage,
180
+ navigateHistory,
167
181
  handleAcceptEula
168
182
  };
169
183
 
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