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 +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/actions/navigate-history.js +248 -0
- package/src/mcp-browser.js +15 -1
- 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.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
|
-
*
|
|
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
|
+
}
|
|
@@ -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
|
+
}
|
package/src/mcp-browser.js
CHANGED
|
@@ -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
|