mcp-web-inspector 0.4.4 → 0.5.0

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
@@ -997,14 +997,13 @@ Upload a file to an input[type='file'] element on the page
997
997
  ### Content
998
998
 
999
999
  #### `get_html`
1000
- ⚠️ RARELY NEEDED: Get raw HTML markup from the page (no rendering, just source code). Most tasks need structured inspection instead. ONLY use get_html for: (1) checking specific HTML attributes or element nesting, (2) analyzing markup structure, (3) debugging SSR/HTML issues. For structured tasks, use: inspect_dom() to understand page structure with positions, query_selector() to find and inspect elements, get_computed_styles() for CSS values. Auto-returns HTML if <2000 chars (small elements), shows preview with token-based confirmation if larger. Scripts removed by default for security/size. Supports testid shortcuts.
1000
+ ⚠️ RARELY NEEDED: Get raw HTML markup from the page (no rendering, just source code). Most tasks need structured inspection instead. ONLY use get_html for: (1) checking specific HTML attributes or element nesting, (2) analyzing markup structure, (3) debugging SSR/HTML issues. For structured tasks, use: inspect_dom() to understand page structure with positions, query_selector() to find and inspect elements, get_computed_styles() for CSS values. Auto-returns HTML if <2000 chars (small elements); if larger, returns a preview and a one-time token to fetch the full output. Scripts removed by default for security/size. Supports testid shortcuts.
1001
1001
 
1002
1002
  - Parameters:
1003
1003
  - selector (string, optional): CSS selector, text selector, or testid shorthand to limit HTML extraction to a specific container. Omit to get entire page HTML. Example: 'testid:main-content' or '#app'
1004
1004
  - elementIndex (number, optional): When selector matches multiple elements, use this 1-based index to select a specific one (e.g., 2 = second element). Default: first visible element.
1005
1005
  - clean (boolean, optional): Remove noise from HTML: false (default) = remove scripts only, true = remove scripts + styles + comments + meta tags for minimal markup
1006
1006
  - maxLength (number, optional): Maximum number of characters to return (default: 20000)
1007
- - confirmToken (string, optional): Confirmation token from preview response (required to retrieve large HTML). Get this token by calling without confirmToken first - the preview will include the token to use.
1008
1007
 
1009
1008
  #### `get_text`
1010
1009
  ⚠️ RARELY NEEDED: Get ALL visible text content from the entire page (no structure, just raw text). Most tasks need structured inspection instead. ONLY use get_text for: (1) extracting text for content analysis (word count, language detection), (2) searching for text when location is completely unknown, (3) text-only snapshots for comparison. For structured tasks, use: inspect_dom() to understand page structure, find_by_text() to locate specific text with context, query_selector() to find elements. Returns plain text up to 20000 chars (truncated if longer). Supports testid shortcuts.
@@ -1044,24 +1043,40 @@ Screenshots saved to ./.mcp-web-inspector/screenshots. Example: { name: "login-p
1044
1043
 
1045
1044
  ### Console
1046
1045
 
1046
+ #### `clear_console_logs`
1047
+ Clears captured console logs and returns the number of entries cleared.
1048
+
1047
1049
  #### `get_console_logs`
1048
- Retrieve console logs from the browser with filtering options
1050
+ Retrieve console logs with filtering and token‑efficient output. Defaults: since='last-interaction', limit=20, format='grouped'. Grouped output deduplicates identical lines and shows counts. Use format='raw' for chronological, ungrouped lines. Large outputs return a preview and a one-time token to fetch the full payload.
1049
1051
 
1050
1052
  - Parameters:
1051
1053
  - type (string, optional): Type of logs to retrieve (all, error, warning, log, info, debug, exception)
1052
1054
  - search (string, optional): Text to search for in logs (handles text with square brackets)
1053
- - limit (number, optional): Maximum number of logs to return
1054
- - since (string, optional): Filter logs since a specific event: 'last-call' (since last get_console_logs call), 'last-navigation' (since last page navigation), or 'last-interaction' (since last user interaction like click, fill, etc.)
1055
- - clear (boolean, optional): Whether to clear logs after retrieval (default: false)
1055
+ - limit (number, optional): Maximum entries to return (groups when grouped, lines when raw). Default: 20
1056
+ - since (string, optional): Filter logs since a specific event: 'last-call' (since last get_console_logs call), 'last-navigation' (since last page navigation), or 'last-interaction' (since last user interaction like click, fill, etc.). Default: 'last-interaction'
1057
+ - format (string, optional): Output format: 'grouped' (default, deduped with counts) or 'raw' (chronological, ungrouped)
1056
1058
 
1057
1059
  ### Evaluation
1058
1060
 
1059
1061
  #### `evaluate`
1060
- ⚙️ CUSTOM JAVASCRIPT EXECUTION - Execute arbitrary JavaScript in the browser console and return the result (JSON-stringified). ⚠️ NOT for: scroll detection (inspect_dom shows 'scrollable ↕️'), element dimensions (use measure_element), DOM inspection (use inspect_dom), CSS properties (use get_computed_styles), position comparison (use compare_positions). Use ONLY when specialized tools cannot accomplish the task. Essential for: custom page interactions, complex calculations not covered by other tools. Automatically detects common patterns and suggests better alternatives. High flexibility but less efficient than specialized tools.
1062
+ ⚙️ CUSTOM JAVASCRIPT EXECUTION - Execute arbitrary JavaScript in the browser console and return a compact, token-efficient summary of the result. Includes a large-output preview guard with a one-time token. ⚠️ NOT for: scroll detection (inspect_dom shows 'scrollable ↕️'), element dimensions (use measure_element), DOM inspection (use inspect_dom), CSS properties (use get_computed_styles), position comparison (use compare_element_alignment). Use ONLY when specialized tools cannot accomplish the task. Automatically detects common patterns and suggests better alternatives.
1061
1063
 
1062
1064
  - Parameters:
1063
1065
  - script (string, required): JavaScript code to execute
1064
1066
 
1067
+ - Output Format:
1068
+ - Header: '✓ JavaScript execution result:'
1069
+ - Default result: compact summary string (arrays/objects/dom nodes summarized)
1070
+ - Array summary: 'Array(n) [first, second, third…]' (shows first 3 items)
1071
+ - Object summary (large): 'Object(n keys): key1, key2, key3…' (top-level keys only)
1072
+ - DOM node summary: '<tag id=#id class=.a.b> @ (x,y) WxH' (rounded ints)
1073
+ - NodeList/HTMLCollection summary: 'NodeList(n) [<div…>, <span…>, <a…>…]'
1074
+ - Preview guard when result is large (≥ ~2000 chars):
1075
+ - 'Preview (first 500 chars):' followed by excerpt
1076
+ - Counts: 'totalLength: N, shownLength: M, truncated: true'
1077
+ - One-time token string to fetch full output
1078
+ - Suggestions block (conditional): compact tips for specialized tools based on script patterns
1079
+
1065
1080
  ### Network
1066
1081
 
1067
1082
  #### `get_request_details`
@@ -1103,6 +1118,18 @@ Set the browser color scheme that controls CSS prefers-color-scheme. Defaults to
1103
1118
 
1104
1119
  - Parameters:
1105
1120
  - scheme (string, required): Color scheme to emulate: 'system', 'dark', 'light', or 'no-preference'. Example: { scheme: 'dark' }
1121
+
1122
+ ### Other
1123
+
1124
+ #### `confirm_output`
1125
+ Return full output for a previously previewed large result using a one-time token. Use when a tool responded with a preview + token. Safer than resending original parameters.
1126
+
1127
+ - Parameters:
1128
+ - token (string, required): One-time token obtained from a tool's preview response
1129
+
1130
+ - Output Format:
1131
+ - Full original payload if token is valid (one-time)
1132
+ - Error: 'Invalid or expired token'
1106
1133
  ## Selector Shortcuts ⭐ Time-Saver
1107
1134
 
1108
1135
  All browser tools support **convenient test ID shortcuts** that save typing and improve readability:
@@ -24,6 +24,11 @@ export declare abstract class BrowserToolBase implements ToolHandler {
24
24
  * @returns Normalized selector
25
25
  */
26
26
  protected normalizeSelector(selector: string): string;
27
+ /**
28
+ * Sanitize verbose Playwright selector engine messages by removing stack traces and
29
+ * keeping only the essential syntax error information.
30
+ */
31
+ private sanitizeSelectorEngineMessage;
27
32
  /**
28
33
  * Ensures a page is available and returns it
29
34
  * @param context The tool context containing browser and page
@@ -33,7 +33,7 @@ export class BrowserToolBase {
33
33
  }
34
34
  }
35
35
  // Clean up common escaping mistakes that LLMs make in CSS selectors
36
- // These characters don't need to be escaped in CSS selectors: [ ] :
36
+ // These characters don't need to be escaped in many selector contexts: [ ] :
37
37
  let cleaned = selector;
38
38
  // Remove backslash escapes before brackets and colons
39
39
  // Handle both single escapes (\[) and double escapes (\\[)
@@ -42,6 +42,29 @@ export class BrowserToolBase {
42
42
  cleaned = cleaned.replace(/\\+:/g, ':');
43
43
  return cleaned;
44
44
  }
45
+ /**
46
+ * Sanitize verbose Playwright selector engine messages by removing stack traces and
47
+ * keeping only the essential syntax error information.
48
+ */
49
+ sanitizeSelectorEngineMessage(msg) {
50
+ if (!msg)
51
+ return '';
52
+ // Prefer to cut at the common phrase used by the browser
53
+ const cutoffPhrases = [
54
+ "is not a valid selector.",
55
+ "is not a valid selector",
56
+ ];
57
+ for (const phrase of cutoffPhrases) {
58
+ const idx = msg.indexOf(phrase);
59
+ if (idx !== -1) {
60
+ return msg.slice(0, idx + phrase.length).trim();
61
+ }
62
+ }
63
+ // Otherwise remove stack-like lines (e.g., " at query (…)")
64
+ const lines = msg.split(/\r?\n/);
65
+ const filtered = lines.filter(l => !/^\s*at\b/.test(l) && !/<anonymous>:\d+:\d+/.test(l));
66
+ return filtered.join('\n').trim();
67
+ }
45
68
  /**
46
69
  * Ensures a page is available and returns it
47
70
  * @param context The tool context containing browser and page
@@ -164,20 +187,23 @@ export class BrowserToolBase {
164
187
  errorMsg.includes('Invalid selector') ||
165
188
  errorMsg.includes('SyntaxError') ||
166
189
  errorMsg.includes('selector')) {
190
+ const conciseMsg = this.sanitizeSelectorEngineMessage(errorMsg);
191
+ // Helpful, accurate guidance with Tailwind-style examples
192
+ const tips = [
193
+ '💡 Tips:',
194
+ ' • Tailwind arbitrary values need escaping in class selectors: .min-w-\\[300px\\]',
195
+ ' • Colons in class names must be escaped: .dark\\:bg-gray-700',
196
+ ' • Prefer robust selectors: use testid:name or [data-testid="..."]',
197
+ ' • Attribute selectors avoid escaping issues: [class*="min-w-[300px]"]',
198
+ '',
199
+ 'Examples:',
200
+ ' ✓ .min-w-\\[300px\\] .flex-1',
201
+ ' ✓ testid:submit-button',
202
+ ' ✓ #login-form'
203
+ ].join('\n');
167
204
  throw new Error(`Invalid CSS selector: "${selector}"\n\n` +
168
- `Selector syntax error: ${errorMsg}\n\n` +
169
- `💡 Tips:\n` +
170
- ` • CSS selectors don't need backslash escapes for [ ] or :\n` +
171
- ` • Use .class-name or #id without escaping\n` +
172
- ` • For data attributes, use [data-attr="value"]\n` +
173
- ` • For testid shortcuts, use testid:name\n\n` +
174
- `Examples:\n` +
175
- ` ✓ .dark:bg-gray-700\n` +
176
- ` ✓ .top-[36px]\n` +
177
- ` ✓ testid:submit-button\n` +
178
- ` ✓ #login-form\n` +
179
- ` ✗ .dark\\:bg-gray-700 (unnecessary escape)\n` +
180
- ` ✗ .top-\\[36px\\] (unnecessary escape)`);
205
+ (conciseMsg ? `Selector syntax error: ${conciseMsg}\n\n` : '') +
206
+ tips);
181
207
  }
182
208
  // Re-throw other errors as-is
183
209
  throw error;
@@ -1,4 +1,4 @@
1
- import { GetConsoleLogsTool } from '../get_console_logs.js';
1
+ import { GetConsoleLogsTool, ClearConsoleLogsTool } from '../get_console_logs.js';
2
2
  import { jest } from '@jest/globals';
3
3
  // Mock the server
4
4
  const mockServer = {
@@ -67,16 +67,14 @@ describe('GetConsoleLogsTool', () => {
67
67
  const logText = result.content[1].text;
68
68
  expect(logText).toContain('Test log message');
69
69
  });
70
- test('should clear console logs when requested', async () => {
70
+ test('should clear console logs using clear_console_logs tool', async () => {
71
71
  consoleLogsTool.registerConsoleMessage('log', 'Test log message');
72
72
  consoleLogsTool.registerConsoleMessage('error', 'Test error message');
73
- const args = {
74
- clear: true
75
- };
76
- const result = await consoleLogsTool.execute(args, mockContext);
73
+ const clearer = new ClearConsoleLogsTool(mockServer);
74
+ const result = await clearer.execute({}, mockContext);
77
75
  expect(result.isError).toBe(false);
78
- expect(result.content[0].text).toContain('Retrieved 2 console log(s)');
79
- // Logs should be cleared after retrieval
76
+ expect(result.content[0].text).toContain('Cleared 2 console log(s)');
77
+ // Logs should be cleared
80
78
  const logs = consoleLogsTool.getConsoleLogs();
81
79
  expect(logs.length).toBe(0);
82
80
  });
@@ -8,6 +8,8 @@ export declare class GetConsoleLogsTool extends BrowserToolBase {
8
8
  private lastCallTimestamp;
9
9
  private lastNavigationTimestamp;
10
10
  private lastInteractionTimestamp;
11
+ static latestInstance: GetConsoleLogsTool | null;
12
+ constructor(server: any);
11
13
  static getMetadata(sessionConfig?: SessionConfig): ToolMetadata;
12
14
  /**
13
15
  * Register a console message
@@ -35,3 +37,10 @@ export declare class GetConsoleLogsTool extends BrowserToolBase {
35
37
  */
36
38
  getLogsSinceLastNavigation(): string[];
37
39
  }
40
+ /**
41
+ * Tool for clearing console logs (atomic operation)
42
+ */
43
+ export declare class ClearConsoleLogsTool extends BrowserToolBase {
44
+ static getMetadata(sessionConfig?: SessionConfig): ToolMetadata;
45
+ execute(args: any, context: ToolContext): Promise<ToolResponse>;
46
+ }
@@ -1,20 +1,23 @@
1
1
  import { BrowserToolBase } from '../base.js';
2
2
  import { createSuccessResponse } from '../../common/types.js';
3
+ import { makeConfirmPreview } from '../../common/confirmHelpers.js';
3
4
  /**
4
5
  * Tool for retrieving and filtering console logs from the browser
5
6
  */
6
7
  export class GetConsoleLogsTool extends BrowserToolBase {
7
- constructor() {
8
- super(...arguments);
8
+ constructor(server) {
9
+ super(server);
10
+ // Stored logs and timestamps
9
11
  this.consoleLogs = [];
10
12
  this.lastCallTimestamp = 0;
11
13
  this.lastNavigationTimestamp = 0;
12
14
  this.lastInteractionTimestamp = 0;
15
+ GetConsoleLogsTool.latestInstance = this;
13
16
  }
14
17
  static getMetadata(sessionConfig) {
15
18
  return {
16
19
  name: "get_console_logs",
17
- description: "Retrieve console logs from the browser with filtering options",
20
+ description: "Retrieve console logs with filtering and token‑efficient output. Defaults: since='last-interaction', limit=20, format='grouped'. Grouped output deduplicates identical lines and shows counts. Use format='raw' for chronological, ungrouped lines. Large outputs return a preview and a one-time token to fetch the full payload.",
18
21
  inputSchema: {
19
22
  type: "object",
20
23
  properties: {
@@ -29,17 +32,18 @@ export class GetConsoleLogsTool extends BrowserToolBase {
29
32
  },
30
33
  limit: {
31
34
  type: "number",
32
- description: "Maximum number of logs to return"
35
+ description: "Maximum entries to return (groups when grouped, lines when raw). Default: 20"
33
36
  },
34
37
  since: {
35
38
  type: "string",
36
- description: "Filter logs since a specific event: 'last-call' (since last get_console_logs call), 'last-navigation' (since last page navigation), or 'last-interaction' (since last user interaction like click, fill, etc.)",
39
+ description: "Filter logs since a specific event: 'last-call' (since last get_console_logs call), 'last-navigation' (since last page navigation), or 'last-interaction' (since last user interaction like click, fill, etc.). Default: 'last-interaction'",
37
40
  enum: ["last-call", "last-navigation", "last-interaction"]
38
41
  },
39
- clear: {
40
- type: "boolean",
41
- description: "Whether to clear logs after retrieval (default: false)"
42
- }
42
+ format: {
43
+ type: "string",
44
+ description: "Output format: 'grouped' (default, deduped with counts) or 'raw' (chronological, ungrouped)",
45
+ enum: ["grouped", "raw"]
46
+ },
43
47
  },
44
48
  required: [],
45
49
  },
@@ -68,11 +72,16 @@ export class GetConsoleLogsTool extends BrowserToolBase {
68
72
  this.lastInteractionTimestamp = Date.now();
69
73
  }
70
74
  async execute(args, context) {
75
+ // Defaults
76
+ const format = args.format === 'raw' ? 'raw' : 'grouped';
77
+ const limit = typeof args.limit === 'number' && args.limit > 0 ? Math.floor(args.limit) : 20;
78
+ const sinceArg = args.since || 'last-interaction';
79
+ const PREVIEW_THRESHOLD = 2000; // chars
71
80
  let logs = [...this.consoleLogs];
72
81
  // Filter by timestamp if 'since' parameter is specified
73
- if (args.since) {
82
+ if (sinceArg) {
74
83
  let sinceTimestamp;
75
- switch (args.since) {
84
+ switch (sinceArg) {
76
85
  case 'last-call':
77
86
  sinceTimestamp = this.lastCallTimestamp;
78
87
  break;
@@ -83,7 +92,7 @@ export class GetConsoleLogsTool extends BrowserToolBase {
83
92
  sinceTimestamp = this.lastInteractionTimestamp;
84
93
  break;
85
94
  default:
86
- return createSuccessResponse(`Invalid 'since' value: ${args.since}. Must be one of: last-call, last-navigation, last-interaction`);
95
+ return createSuccessResponse(`Invalid 'since' value: ${sinceArg}. Must be one of: last-call, last-navigation, last-interaction`);
87
96
  }
88
97
  logs = logs.filter(log => log.timestamp > sinceTimestamp);
89
98
  }
@@ -97,26 +106,71 @@ export class GetConsoleLogsTool extends BrowserToolBase {
97
106
  if (args.search) {
98
107
  logs = logs.filter(log => log.message.includes(args.search));
99
108
  }
100
- // Limit the number of logs if specified
101
- if (args.limit && args.limit > 0) {
102
- logs = logs.slice(-args.limit);
109
+ // Build output according to format
110
+ if (format === 'raw') {
111
+ // Chronological lines, limit applied to last N entries
112
+ const limited = limit > 0 ? logs.slice(-limit) : logs;
113
+ const messages = limited.map(l => l.message);
114
+ if (messages.length === 0) {
115
+ return createSuccessResponse("No console logs matching the criteria");
116
+ }
117
+ // Guard large outputs by character size
118
+ const header = `Retrieved ${messages.length} console log(s):`;
119
+ const textPayload = [header, ...messages].join('\n');
120
+ if (textPayload.length >= PREVIEW_THRESHOLD) {
121
+ const previewLines = [
122
+ `Matched ${logs.length} log(s). Showing ${Math.min(messages.length, 10)} line(s) preview.`,
123
+ ...messages.slice(0, Math.min(messages.length, 10)),
124
+ ];
125
+ const preview = makeConfirmPreview(textPayload, {
126
+ counts: { totalLength: textPayload.length, shownLength: previewLines.join('\n').length, totalMatched: logs.length, shownCount: Math.min(messages.length, 10), truncated: true },
127
+ previewLines,
128
+ extraTips: ['Tip: refine with search/type/since/limit or prefer grouped format.'],
129
+ });
130
+ return createSuccessResponse(preview.lines.join('\n'));
131
+ }
132
+ return createSuccessResponse([header, ...messages]);
103
133
  }
104
- // Extract messages from log entries
105
- const messages = logs.map(log => log.message);
106
- // Clear logs if requested
107
- if (args.clear) {
108
- this.consoleLogs = [];
134
+ // Grouped format (default)
135
+ const groups = new Map();
136
+ for (const l of logs) {
137
+ const key = l.message; // includes [type] prefix per registerConsoleMessage
138
+ const g = groups.get(key);
139
+ if (g) {
140
+ g.count += 1;
141
+ g.lastTs = l.timestamp;
142
+ }
143
+ else {
144
+ groups.set(key, { count: 1, firstTs: l.timestamp, lastTs: l.timestamp, example: l.message });
145
+ }
109
146
  }
110
- // Format the response
111
- if (messages.length === 0) {
147
+ if (groups.size === 0) {
112
148
  return createSuccessResponse("No console logs matching the criteria");
113
149
  }
114
- else {
115
- return createSuccessResponse([
116
- `Retrieved ${messages.length} console log(s):`,
117
- ...messages
118
- ]);
150
+ // Order groups by first occurrence time
151
+ const ordered = Array.from(groups.entries()).sort((a, b) => a[1].firstTs - b[1].firstTs);
152
+ const limitedGroups = limit > 0 ? ordered.slice(0, limit) : ordered;
153
+ const lines = [];
154
+ lines.push(`Retrieved ${limitedGroups.length} console log(s):`);
155
+ for (const [msg, info] of limitedGroups) {
156
+ const line = `${msg} (× ${info.count})`;
157
+ lines.push(line);
119
158
  }
159
+ // Guard large grouped outputs
160
+ const textPayload = lines.join('\n');
161
+ if (textPayload.length >= PREVIEW_THRESHOLD) {
162
+ const previewLines = [
163
+ `Matched ${groups.size} group(s). Showing ${limitedGroups.length}.`,
164
+ ...lines.slice(0, Math.min(lines.length, 12)),
165
+ ];
166
+ const preview = makeConfirmPreview(textPayload, {
167
+ counts: { totalLength: textPayload.length, shownLength: previewLines.join('\n').length, totalMatched: groups.size, shownCount: limitedGroups.length, truncated: true },
168
+ previewLines,
169
+ extraTips: ['Tip: refine with search/type/since/limit.'],
170
+ });
171
+ return createSuccessResponse(preview.lines.join('\n'));
172
+ }
173
+ return createSuccessResponse(lines);
120
174
  }
121
175
  /**
122
176
  * Get all console logs
@@ -140,3 +194,30 @@ export class GetConsoleLogsTool extends BrowserToolBase {
140
194
  .map(log => log.message);
141
195
  }
142
196
  }
197
+ // Track latest instance for sibling tool access (module-level singleton pattern)
198
+ GetConsoleLogsTool.latestInstance = null;
199
+ /**
200
+ * Tool for clearing console logs (atomic operation)
201
+ */
202
+ export class ClearConsoleLogsTool extends BrowserToolBase {
203
+ static getMetadata(sessionConfig) {
204
+ return {
205
+ name: "clear_console_logs",
206
+ description: "Clears captured console logs and returns the number of entries cleared.",
207
+ inputSchema: {
208
+ type: "object",
209
+ properties: {},
210
+ required: [],
211
+ },
212
+ };
213
+ }
214
+ async execute(args, context) {
215
+ const inst = GetConsoleLogsTool.latestInstance;
216
+ if (!inst) {
217
+ return createSuccessResponse('Cleared 0 console log(s)');
218
+ }
219
+ const count = inst.getConsoleLogs().length;
220
+ inst.clearConsoleLogs();
221
+ return createSuccessResponse(`Cleared ${count} console log(s)`);
222
+ }
223
+ }
@@ -1 +1 @@
1
- export { GetConsoleLogsTool } from './get_console_logs.js';
1
+ export { GetConsoleLogsTool, ClearConsoleLogsTool } from './get_console_logs.js';
@@ -1 +1 @@
1
- export { GetConsoleLogsTool } from './get_console_logs.js';
1
+ export { GetConsoleLogsTool, ClearConsoleLogsTool } from './get_console_logs.js';
@@ -392,9 +392,9 @@ describe('GetHtmlTool', () => {
392
392
  expect(result.content[0].text).toContain('⚠️ Full HTML not returned to save tokens');
393
393
  expect(result.content[0].text).toContain('💡 RECOMMENDED: Use token-efficient alternatives');
394
394
  expect(result.content[0].text).toContain('inspect_dom()');
395
- expect(result.content[0].text).toMatch(/confirmToken: "[\w\d]{8}"/);
395
+ expect(result.content[0].text).toMatch(/confirm_output\(\{ token: \"[\w\d]+\" \}\)/);
396
396
  });
397
- test('should return full HTML with valid confirmToken', async () => {
397
+ test('should return full HTML with valid token via confirm_output', async () => {
398
398
  const args = {};
399
399
  // Large HTML
400
400
  const largeHtml = '<div>' + 'Y'.repeat(3000) + '</div>';
@@ -402,29 +402,22 @@ describe('GetHtmlTool', () => {
402
402
  // First call - get preview and token
403
403
  const previewResult = await visibleHtmlTool.execute(args, mockContext);
404
404
  expect(previewResult.isError).toBe(false);
405
- // Extract token from preview
406
- const tokenMatch = previewResult.content[0].text.match(/confirmToken: "([\w\d]{8})"/);
405
+ // Extract token from preview and confirm using confirm_output tool
406
+ const tokenMatch = previewResult.content[0].text.match(/confirm_output\(\{ token: \"([\w\d]+)\" \}\)/);
407
407
  expect(tokenMatch).toBeTruthy();
408
408
  const token = tokenMatch[1];
409
- // Second call - with token
410
- const fullResult = await visibleHtmlTool.execute({ confirmToken: token }, mockContext);
409
+ const { ConfirmOutputTool } = await import('../../../common/confirm_output.js');
410
+ const confirmTool = new ConfirmOutputTool({});
411
+ const fullResult = await confirmTool.execute({ token }, {});
411
412
  expect(fullResult.isError).toBe(false);
412
413
  expect(fullResult.content[0].text).toContain(largeHtml);
413
- expect(fullResult.content[0].text).not.toContain('Preview (first 500 chars)');
414
- expect(fullResult.content[0].text).toContain('💡 TIP: If you need structured inspection');
415
414
  });
416
- test('should generate new token for invalid confirmToken', async () => {
417
- const args = { confirmToken: 'invalid123' };
418
- // Large HTML
419
- const largeHtml = '<div>' + 'Z'.repeat(3000) + '</div>';
420
- mockEvaluate.mockImplementationOnce(() => Promise.resolve(largeHtml));
421
- const result = await visibleHtmlTool.execute(args, mockContext);
422
- expect(result.isError).toBe(false);
423
- expect(result.content[0].text).toContain('HTML size:');
424
- expect(result.content[0].text).toContain('Preview (first 500 chars)');
425
- expect(result.content[0].text).toMatch(/confirmToken: "[\w\d]{8}"/);
426
- // Should not return full HTML
427
- expect(result.content[0].text).not.toContain('Z'.repeat(3000));
415
+ test('confirm_output should error on invalid token', async () => {
416
+ const { ConfirmOutputTool } = await import('../../../common/confirm_output.js');
417
+ const confirmTool = new ConfirmOutputTool({});
418
+ const res = await confirmTool.execute({ token: 'invalid123' }, {});
419
+ expect(res.isError).toBe(true);
420
+ expect(res.content[0].text).toContain('Invalid or expired token');
428
421
  });
429
422
  test('should return small HTML directly without token requirement', async () => {
430
423
  const args = {};
@@ -445,22 +438,20 @@ describe('GetHtmlTool', () => {
445
438
  mockEvaluate.mockImplementation(() => Promise.resolve(largeHtml));
446
439
  // First call - get token
447
440
  const previewResult = await visibleHtmlTool.execute(args, mockContext);
448
- const tokenMatch = previewResult.content[0].text.match(/confirmToken: "([\w\d]{8})"/);
441
+ const tokenMatch = previewResult.content[0].text.match(/confirm_output\(\{ token: \"([\w\d]+)\" \}\)/);
449
442
  const token = tokenMatch[1];
443
+ const { ConfirmOutputTool } = await import('../../../common/confirm_output.js');
444
+ const confirmTool = new ConfirmOutputTool({});
450
445
  // Second call - use token (should work)
451
- const fullResult = await visibleHtmlTool.execute({ confirmToken: token }, mockContext);
446
+ const fullResult = await confirmTool.execute({ token }, {});
452
447
  expect(fullResult.isError).toBe(false);
453
448
  expect(fullResult.content[0].text).toContain(largeHtml);
454
- // Third call - try to reuse same token (should fail, get new preview)
455
- const retryResult = await visibleHtmlTool.execute({ confirmToken: token }, mockContext);
456
- expect(retryResult.isError).toBe(false);
457
- expect(retryResult.content[0].text).toContain('Preview (first 500 chars)');
458
- expect(retryResult.content[0].text).toMatch(/confirmToken: "[\w\d]{8}"/);
459
- // Should have different token
460
- const newTokenMatch = retryResult.content[0].text.match(/confirmToken: "([\w\d]{8})"/);
461
- expect(newTokenMatch[1]).not.toBe(token);
462
- });
463
- test('should handle large HTML with selector and confirmToken', async () => {
449
+ // Third call - try to reuse same token (should fail)
450
+ const retryResult = await confirmTool.execute({ token }, {});
451
+ expect(retryResult.isError).toBe(true);
452
+ expect(retryResult.content[0].text).toContain('Invalid or expired token');
453
+ });
454
+ test('should handle large HTML with selector and confirm_output', async () => {
464
455
  const largeHtml = '<section>' + 'S'.repeat(3000) + '</section>';
465
456
  const args = { selector: 'testid:large-section' };
466
457
  mockLocator.mockImplementation(() => ({}));
@@ -474,10 +465,12 @@ describe('GetHtmlTool', () => {
474
465
  // First call - get preview
475
466
  const previewResult = await visibleHtmlTool.execute(args, mockContext);
476
467
  expect(previewResult.content[0].text).toContain('(from "testid:large-section")');
477
- const tokenMatch = previewResult.content[0].text.match(/confirmToken: "([\w\d]{8})"/);
468
+ const tokenMatch = previewResult.content[0].text.match(/confirm_output\(\{ token: \"([\w\d]+)\" \}\)/);
478
469
  const token = tokenMatch[1];
479
- // Second call - with token
480
- const fullResult = await visibleHtmlTool.execute({ selector: 'testid:large-section', confirmToken: token }, mockContext);
470
+ // Second call - with token via confirm tool
471
+ const { ConfirmOutputTool } = await import('../../../common/confirm_output.js');
472
+ const confirmTool = new ConfirmOutputTool({});
473
+ const fullResult = await confirmTool.execute({ token }, {});
481
474
  expect(fullResult.isError).toBe(false);
482
475
  expect(fullResult.content[0].text).toContain(largeHtml);
483
476
  selectSpy.mockRestore();
@@ -4,7 +4,6 @@ import { ToolContext, ToolResponse, ToolMetadata, SessionConfig } from '../../co
4
4
  * Tool for getting HTML from the page
5
5
  */
6
6
  export declare class GetHtmlTool extends BrowserToolBase {
7
- private confirmTokens;
8
7
  static getMetadata(sessionConfig?: SessionConfig): ToolMetadata;
9
8
  execute(args: any, context: ToolContext): Promise<ToolResponse>;
10
9
  }
@@ -1,17 +1,14 @@
1
1
  import { BrowserToolBase } from '../base.js';
2
2
  import { createSuccessResponse, createErrorResponse, } from '../../common/types.js';
3
+ import { makeConfirmPreview } from '../../common/confirmHelpers.js';
3
4
  /**
4
5
  * Tool for getting HTML from the page
5
6
  */
6
7
  export class GetHtmlTool extends BrowserToolBase {
7
- constructor() {
8
- super(...arguments);
9
- this.confirmTokens = new Map(); // Stores tokens for two-step confirmation
10
- }
11
8
  static getMetadata(sessionConfig) {
12
9
  return {
13
10
  name: "get_html",
14
- description: "⚠️ RARELY NEEDED: Get raw HTML markup from the page (no rendering, just source code). Most tasks need structured inspection instead. ONLY use get_html for: (1) checking specific HTML attributes or element nesting, (2) analyzing markup structure, (3) debugging SSR/HTML issues. For structured tasks, use: inspect_dom() to understand page structure with positions, query_selector() to find and inspect elements, get_computed_styles() for CSS values. Auto-returns HTML if <2000 chars (small elements), shows preview with token-based confirmation if larger. Scripts removed by default for security/size. Supports testid shortcuts.",
11
+ description: "⚠️ RARELY NEEDED: Get raw HTML markup from the page (no rendering, just source code). Most tasks need structured inspection instead. ONLY use get_html for: (1) checking specific HTML attributes or element nesting, (2) analyzing markup structure, (3) debugging SSR/HTML issues. For structured tasks, use: inspect_dom() to understand page structure with positions, query_selector() to find and inspect elements, get_computed_styles() for CSS values. Auto-returns HTML if <2000 chars (small elements); if larger, returns a preview and a one-time token to fetch the full output. Scripts removed by default for security/size. Supports testid shortcuts.",
15
12
  inputSchema: {
16
13
  type: "object",
17
14
  properties: {
@@ -30,10 +27,6 @@ export class GetHtmlTool extends BrowserToolBase {
30
27
  maxLength: {
31
28
  type: "number",
32
29
  description: "Maximum number of characters to return (default: 20000)"
33
- },
34
- confirmToken: {
35
- type: "string",
36
- description: "Confirmation token from preview response (required to retrieve large HTML). Get this token by calling without confirmToken first - the preview will include the token to use."
37
30
  }
38
31
  },
39
32
  required: [],
@@ -45,7 +38,6 @@ export class GetHtmlTool extends BrowserToolBase {
45
38
  ? Math.floor(args.maxLength)
46
39
  : 20000;
47
40
  const clean = args.clean ?? false;
48
- const confirmToken = args.confirmToken;
49
41
  const PREVIEW_THRESHOLD = 2000;
50
42
  if (!context.page) {
51
43
  return createErrorResponse('Page is not available');
@@ -111,37 +103,27 @@ export class GetHtmlTool extends BrowserToolBase {
111
103
  const processedHtml = sanitizedHtml ?? '';
112
104
  const originalLength = processedHtml.length;
113
105
  // Generate key for this HTML request
114
- const tokenKey = `${args.selector || 'page'}:${originalLength}`;
115
- // Check if HTML is too large
106
+ // Check if HTML is too large => return preview + token for confirm_output
116
107
  if (originalLength >= PREVIEW_THRESHOLD) {
117
- // Verify if confirmToken matches
118
- const expectedToken = this.confirmTokens.get(tokenKey);
119
- if (confirmToken && expectedToken && confirmToken === expectedToken) {
120
- // Valid token - delete it (one-time use) and proceed to return HTML
121
- this.confirmTokens.delete(tokenKey);
122
- }
123
- else {
124
- // No token or invalid token - show preview and generate new token
125
- const newToken = Math.random().toString(36).substring(2, 10);
126
- this.confirmTokens.set(tokenKey, newToken);
127
- lines.push(`HTML size: ${originalLength.toLocaleString()} characters (exceeds ${PREVIEW_THRESHOLD} char threshold)`);
128
- lines.push('');
129
- lines.push('Preview (first 500 chars):');
130
- lines.push(processedHtml.slice(0, 500));
131
- if (originalLength > 500) {
132
- lines.push('...');
133
- }
134
- lines.push('');
135
- lines.push('⚠️ Full HTML not returned to save tokens (~' + Math.round(originalLength / 3) + ' tokens)');
136
- lines.push('');
137
- lines.push('💡 RECOMMENDED: Use token-efficient alternatives:');
138
- lines.push(' • inspect_dom() - structured view with positions and layout');
139
- lines.push(' • query_selector_all() - find specific elements');
140
- lines.push(' • get_computed_styles() - CSS values for debugging');
141
- lines.push('');
142
- lines.push(`To get full HTML anyway, call again with: confirmToken: "${newToken}"`);
143
- return createSuccessResponse(lines.join('\n'));
144
- }
108
+ const preview = makeConfirmPreview(processedHtml, {
109
+ counts: { totalLength: originalLength, shownLength: Math.min(500, originalLength), truncated: true },
110
+ previewLines: [
111
+ 'Preview (first 500 chars):',
112
+ processedHtml.slice(0, 500),
113
+ ...(originalLength > 500 ? ['...'] : []),
114
+ '',
115
+ '⚠️ Full HTML not returned to save tokens',
116
+ '',
117
+ '💡 RECOMMENDED: Use token-efficient alternatives:',
118
+ ' • inspect_dom() - structured view with positions and layout',
119
+ ' • query_selector_all() - find specific elements',
120
+ ' get_computed_styles() - CSS values for debugging',
121
+ ],
122
+ });
123
+ lines.push(`HTML size: ${originalLength.toLocaleString()} characters (exceeds ${PREVIEW_THRESHOLD} char threshold)`);
124
+ lines.push('');
125
+ lines.push(...preview.lines);
126
+ return createSuccessResponse(lines.join('\n'));
145
127
  }
146
128
  // Return full HTML (either small or explicitly requested)
147
129
  let displayHtml = processedHtml;
@@ -1,5 +1,6 @@
1
1
  import { BrowserToolBase } from '../base.js';
2
- import { createSuccessResponse } from '../../common/types.js';
2
+ import { createSuccessResponse, createErrorResponse, } from '../../common/types.js';
3
+ import { makeConfirmPreview } from '../../common/confirmHelpers.js';
3
4
  /**
4
5
  * Tool for executing JavaScript in the browser
5
6
  */
@@ -7,7 +8,20 @@ export class EvaluateTool extends BrowserToolBase {
7
8
  static getMetadata(sessionConfig) {
8
9
  return {
9
10
  name: "evaluate",
10
- description: "⚙️ CUSTOM JAVASCRIPT EXECUTION - Execute arbitrary JavaScript in the browser console and return the result (JSON-stringified). ⚠️ NOT for: scroll detection (inspect_dom shows 'scrollable ↕️'), element dimensions (use measure_element), DOM inspection (use inspect_dom), CSS properties (use get_computed_styles), position comparison (use compare_positions). Use ONLY when specialized tools cannot accomplish the task. Essential for: custom page interactions, complex calculations not covered by other tools. Automatically detects common patterns and suggests better alternatives. High flexibility but less efficient than specialized tools.",
11
+ description: "⚙️ CUSTOM JAVASCRIPT EXECUTION - Execute arbitrary JavaScript in the browser console and return a compact, token-efficient summary of the result. Includes a large-output preview guard with a one-time token. ⚠️ NOT for: scroll detection (inspect_dom shows 'scrollable ↕️'), element dimensions (use measure_element), DOM inspection (use inspect_dom), CSS properties (use get_computed_styles), position comparison (use compare_element_alignment). Use ONLY when specialized tools cannot accomplish the task. Automatically detects common patterns and suggests better alternatives.",
12
+ outputs: [
13
+ "Header: '✓ JavaScript execution result:'",
14
+ "Default result: compact summary string (arrays/objects/dom nodes summarized)",
15
+ "Array summary: 'Array(n) [first, second, third…]' (shows first 3 items)",
16
+ "Object summary (large): 'Object(n keys): key1, key2, key3…' (top-level keys only)",
17
+ "DOM node summary: '<tag id=#id class=.a.b> @ (x,y) WxH' (rounded ints)",
18
+ "NodeList/HTMLCollection summary: 'NodeList(n) [<div…>, <span…>, <a…>…]'",
19
+ "Preview guard when result is large (≥ ~2000 chars):",
20
+ " - 'Preview (first 500 chars):' followed by excerpt",
21
+ " - Counts: 'totalLength: N, shownLength: M, truncated: true'",
22
+ " - One-time token string to fetch full output",
23
+ "Suggestions block (conditional): compact tips for specialized tools based on script patterns",
24
+ ],
11
25
  inputSchema: {
12
26
  type: "object",
13
27
  properties: {
@@ -32,7 +46,7 @@ export class EvaluateTool extends BrowserToolBase {
32
46
  // Pattern: Getting text content
33
47
  if (scriptLower.match(/textcontent|innertext/)) {
34
48
  suggestions.push('📝 Text Content\n' +
35
- ' • get_visible_text() - Extract all visible text\n' +
49
+ ' • get_text - Extract all visible text\n' +
36
50
  ' • find_by_text({ text: "..." }) - Locate elements by content');
37
51
  }
38
52
  // Pattern: Checking if element is scrollable (scrollHeight > clientHeight)
@@ -60,7 +74,7 @@ export class EvaluateTool extends BrowserToolBase {
60
74
  }
61
75
  // Pattern: Checking visibility
62
76
  if (scriptLower.match(/offsetparent|visibility|display.*none|opacity/)) {
63
- suggestions.push('👁️ Visibility Check - Use element_visibility({ selector: "..." })\n' +
77
+ suggestions.push('👁️ Visibility Check - Use check_visibility({ selector: "..." })\n' +
64
78
  ' Returns: isVisible, inViewport, opacity, display, visibility properties\n' +
65
79
  ' More reliable: Handles edge cases (opacity:0, visibility:hidden, etc.)');
66
80
  }
@@ -85,7 +99,7 @@ export class EvaluateTool extends BrowserToolBase {
85
99
  // Pattern: Comparing positions/alignment
86
100
  if (scriptLower.match(/getboundingclientrect.*getboundingclientrect/) ||
87
101
  (scriptLower.match(/\.left|\.top|\.right|\.bottom/) && scriptLower.match(/===|==|!==|!=/))) {
88
- suggestions.push('⚖️ Position Comparison - Use compare_positions({ selector1: "...", selector2: "..." })\n' +
102
+ suggestions.push('⚖️ Position Comparison - Use compare_element_alignment({ selector1: "...", selector2: "..." })\n' +
89
103
  ' Returns: Alignment status (left/right/top/bottom/center), pixel gaps\n' +
90
104
  ' Perfect for: Checking if elements are aligned or overlapping');
91
105
  }
@@ -104,21 +118,182 @@ export class EvaluateTool extends BrowserToolBase {
104
118
  async execute(args, context) {
105
119
  this.recordInteraction();
106
120
  return this.safeExecute(context, async (page) => {
107
- const result = await page.evaluate(args.script);
108
- // Convert result to string for display
121
+ const PREVIEW_THRESHOLD = 2000; // chars
122
+ // Execute the script and produce a compact textual summary entirely in the page context
123
+ // to safely handle DOM nodes and browser-specific objects.
124
+ const evalReturn = await page.evaluate(async (userScript) => {
125
+ const toInt = (n) => Math.max(0, Math.round(n || 0));
126
+ // Summarize a DOM element
127
+ const summarizeElement = (el) => {
128
+ try {
129
+ const tag = (el.tagName || '').toLowerCase();
130
+ const id = el.id ? ` #${el.id}` : '';
131
+ const cls = el.classList?.length
132
+ ? ' ' + Array.from(el.classList)
133
+ .map(c => `.${c}`)
134
+ .join('')
135
+ : '';
136
+ const rect = el.getBoundingClientRect?.();
137
+ const x = toInt(rect?.left ?? 0);
138
+ const y = toInt(rect?.top ?? 0);
139
+ const w = toInt(rect?.width ?? 0);
140
+ const h = toInt(rect?.height ?? 0);
141
+ return `<${tag}${id}${cls}> @ (${x},${y}) ${w}x${h}`;
142
+ }
143
+ catch {
144
+ const tag = (el.tagName || '').toLowerCase();
145
+ return `<${tag}>`;
146
+ }
147
+ };
148
+ // Render values compactly
149
+ const render = (val, depth, seen) => {
150
+ const MAX_DEPTH = 3;
151
+ const ARRAY_PREVIEW = 3;
152
+ const LARGE_ARRAY_THRESHOLD = 10;
153
+ const LARGE_OBJECT_THRESHOLD = 15;
154
+ const t = Object.prototype.toString.call(val);
155
+ if (val === null)
156
+ return 'null';
157
+ if (val === undefined)
158
+ return 'undefined';
159
+ if (typeof val === 'string')
160
+ return JSON.stringify(val);
161
+ if (typeof val === 'number' || typeof val === 'boolean')
162
+ return String(val);
163
+ if (typeof val === 'bigint')
164
+ return `${String(val)}n`;
165
+ if (typeof val === 'function')
166
+ return `[Function ${val.name || 'anonymous'}]`;
167
+ if (t === '[object Date]')
168
+ return `Date(${val.toISOString?.() || String(val)})`;
169
+ if (t === '[object RegExp]')
170
+ return String(val);
171
+ if (t === '[object Error]')
172
+ return `${val.name || 'Error'}: ${val.message || String(val)}`;
173
+ // DOM element
174
+ if (typeof Element !== 'undefined' && val instanceof Element) {
175
+ return summarizeElement(val);
176
+ }
177
+ // NodeList / HTMLCollection
178
+ if ((typeof NodeList !== 'undefined' && val instanceof NodeList) ||
179
+ (typeof HTMLCollection !== 'undefined' && val instanceof HTMLCollection)) {
180
+ const arr = Array.from(val);
181
+ const head = arr.slice(0, ARRAY_PREVIEW).map((e) => typeof Element !== 'undefined' && e instanceof Element ? summarizeElement(e) : render(e, depth + 1, seen));
182
+ const more = arr.length > ARRAY_PREVIEW ? '…' : '';
183
+ return `NodeList(${arr.length}) [${head.join(', ')}${more}]`;
184
+ }
185
+ if (depth >= MAX_DEPTH) {
186
+ if (Array.isArray(val))
187
+ return `Array(${val.length}) […]`;
188
+ if (val && typeof val === 'object')
189
+ return `Object(${Object.keys(val).length} keys) …`;
190
+ return String(val);
191
+ }
192
+ // Avoid circular structures
193
+ if (val && typeof val === 'object') {
194
+ if (seen.has(val))
195
+ return '[Circular]';
196
+ seen.add(val);
197
+ }
198
+ if (Array.isArray(val)) {
199
+ if (val.length > LARGE_ARRAY_THRESHOLD) {
200
+ const head = val.slice(0, ARRAY_PREVIEW).map((v) => render(v, depth + 1, seen));
201
+ const more = val.length > ARRAY_PREVIEW ? '…' : '';
202
+ return `Array(${val.length}) [${head.join(', ')}${more}]`;
203
+ }
204
+ return `[${val.map((v) => render(v, depth + 1, seen)).join(', ')}]`;
205
+ }
206
+ // Map / Set
207
+ if (t === '[object Map]') {
208
+ const m = val;
209
+ const entries = Array.from(m.entries()).slice(0, ARRAY_PREVIEW).map(([k, v]) => `${render(k, depth + 1, seen)} => ${render(v, depth + 1, seen)}`);
210
+ const more = m.size > ARRAY_PREVIEW ? '…' : '';
211
+ return `Map(${m.size}) {${entries.join(', ')}${more}}`;
212
+ }
213
+ if (t === '[object Set]') {
214
+ const s = val;
215
+ const entries = Array.from(s.values()).slice(0, ARRAY_PREVIEW).map((v) => render(v, depth + 1, seen));
216
+ const more = s.size > ARRAY_PREVIEW ? '…' : '';
217
+ return `Set(${s.size}) {${entries.join(', ')}${more}}`;
218
+ }
219
+ if (val && typeof val === 'object') {
220
+ const keys = Object.keys(val);
221
+ if (keys.length > LARGE_OBJECT_THRESHOLD) {
222
+ const head = keys.slice(0, ARRAY_PREVIEW).join(', ');
223
+ const more = keys.length > ARRAY_PREVIEW ? '…' : '';
224
+ return `Object(${keys.length} keys): ${head}${more}`;
225
+ }
226
+ // Render small object inline key: value
227
+ const parts = [];
228
+ for (const k of keys) {
229
+ try {
230
+ parts.push(`${k}: ${render(val[k], depth + 1, seen)}`);
231
+ }
232
+ catch (e) {
233
+ parts.push(`${k}: [Unserializable]`);
234
+ }
235
+ }
236
+ return `{ ${parts.join(', ')} }`;
237
+ }
238
+ return String(val);
239
+ };
240
+ try {
241
+ // Build an async function so both sync and async scripts are supported
242
+ const AsyncFunction = Object.getPrototypeOf(async function () { }).constructor;
243
+ const fn = new AsyncFunction(userScript);
244
+ const result = await fn();
245
+ const text = render(result, 0, new WeakSet());
246
+ return { ok: true, text };
247
+ }
248
+ catch (e) {
249
+ return { ok: false, error: e?.message || String(e) };
250
+ }
251
+ }, args.script);
252
+ // Backward compatibility: if the page evaluation returns a raw value (string/any)
253
+ // instead of the { ok, text } envelope, treat it as the final result string.
109
254
  let resultStr;
110
- try {
111
- resultStr = JSON.stringify(result, null, 2);
255
+ if (evalReturn && typeof evalReturn === 'object' && 'ok' in evalReturn) {
256
+ const { ok, text, error: execError } = evalReturn;
257
+ if (!ok) {
258
+ return createErrorResponse(`JavaScript execution failed: ${execError}`);
259
+ }
260
+ resultStr = text || '';
112
261
  }
113
- catch (error) {
114
- resultStr = String(result);
262
+ else {
263
+ try {
264
+ resultStr = typeof evalReturn === 'string' ? evalReturn : JSON.stringify(evalReturn, null, 2);
265
+ }
266
+ catch {
267
+ resultStr = String(evalReturn);
268
+ }
115
269
  }
116
- const messages = [
117
- `✓ JavaScript execution result:`,
118
- `${resultStr}`
119
- ];
120
- // Detect if specialized tools would be better
270
+ // Guard for large outputs: preview + confirm
271
+ const totalLength = resultStr.length;
272
+ const lines = [];
121
273
  const suggestions = this.detectBetterToolSuggestions(args.script);
274
+ if (totalLength >= PREVIEW_THRESHOLD) {
275
+ const previewLen = Math.min(500, totalLength);
276
+ const preview = resultStr.slice(0, previewLen);
277
+ const previewBlock = makeConfirmPreview(resultStr, {
278
+ headerLine: '✓ JavaScript execution result (preview):',
279
+ counts: { totalLength, shownLength: previewLen, truncated: true },
280
+ previewLines: [
281
+ 'Preview (first 500 chars):',
282
+ preview,
283
+ ...(totalLength > previewLen ? ['...'] : []),
284
+ ],
285
+ extraTips: ['Tip: Prefer specialized tools or narrow the script when possible.'],
286
+ });
287
+ lines.push(...previewBlock.lines);
288
+ if (suggestions.length > 0) {
289
+ lines.push('');
290
+ lines.push('💡 Consider specialized tools:');
291
+ suggestions.forEach(s => lines.push(` ${s}`));
292
+ }
293
+ return createSuccessResponse(lines);
294
+ }
295
+ const messages = [`✓ JavaScript execution result:`, resultStr];
296
+ // Detect if specialized tools would be better
122
297
  if (suggestions.length > 0) {
123
298
  messages.push('');
124
299
  messages.push('💡 Consider using specialized tools instead:');
@@ -330,4 +330,27 @@ describe('MeasureElementTool', () => {
330
330
  expect(text).toContain('Margin:');
331
331
  expect(text).toMatch(/↑15px|↓15px|←15px|→15px/); // Should show margin with arrows
332
332
  });
333
+ it('returns clean error for invalid CSS selector without stack trace', async () => {
334
+ await page.setContent(`
335
+ <html>
336
+ <body>
337
+ <div class="flex min-w-[300px] flex-1">Hello</div>
338
+ </body>
339
+ </html>
340
+ `);
341
+ // Intentionally pass an invalid selector (missing escapes for [ ])
342
+ const result = await tool.execute({ selector: '.flex.min-w-[300px].flex-1' }, { page, browser });
343
+ expect(result.isError).toBe(true);
344
+ const text = result.content[0].text;
345
+ // Should present a concise, user-friendly error without stack traces
346
+ expect(text).toContain('Invalid CSS selector:');
347
+ expect(text).toContain('Selector syntax error:');
348
+ expect(text).toContain('not a valid selector');
349
+ // No internal stack frames from the selector engine
350
+ expect(text).not.toMatch(/\n\s*at\b/);
351
+ expect(text).not.toContain('<anonymous>:');
352
+ // Provide helpful guidance for Tailwind-style class names
353
+ expect(text).toContain('Tailwind arbitrary values need escaping');
354
+ expect(text).toContain('.min-w-\\[300px\\]');
355
+ });
333
356
  });
@@ -219,7 +219,11 @@ describe('Browser Interaction Tools', () => {
219
219
  script: 'return document.title'
220
220
  };
221
221
  const result = await evaluateTool.execute(args, mockContext);
222
- expect(mockEvaluate).toHaveBeenCalledWith('return document.title');
222
+ // evaluate() wraps script inside a function passed to page.evaluate
223
+ expect(mockEvaluate).toHaveBeenCalled();
224
+ const argsPassed = mockEvaluate.mock.calls[0];
225
+ expect(typeof argsPassed[0]).toBe('function');
226
+ expect(argsPassed[1]).toBe('return document.title');
223
227
  expect(result.isError).toBe(false);
224
228
  expect(result.content[0].text).toContain('JavaScript execution result');
225
229
  });
@@ -233,7 +237,7 @@ describe('Browser Interaction Tools', () => {
233
237
  expect(fullResponse).toContain('💡 Consider using specialized tools instead');
234
238
  expect(fullResponse).toContain('inspect_dom');
235
239
  });
236
- test('should suggest get_visible_text for textContent usage', async () => {
240
+ test('should suggest get_text for textContent usage', async () => {
237
241
  const args = {
238
242
  script: 'document.body.textContent'
239
243
  };
@@ -241,7 +245,7 @@ describe('Browser Interaction Tools', () => {
241
245
  const fullResponse = result.content.map(c => c.text).join('\n');
242
246
  expect(result.isError).toBe(false);
243
247
  expect(fullResponse).toContain('💡 Consider using specialized tools instead');
244
- expect(fullResponse).toContain('get_visible_text');
248
+ expect(fullResponse).toContain('get_text');
245
249
  });
246
250
  test('should suggest measure_element for getBoundingClientRect usage', async () => {
247
251
  const args = {
@@ -253,7 +257,7 @@ describe('Browser Interaction Tools', () => {
253
257
  expect(fullResponse).toContain('💡 Consider using specialized tools instead');
254
258
  expect(fullResponse).toContain('measure_element');
255
259
  });
256
- test('should suggest element_visibility for visibility checks', async () => {
260
+ test('should suggest check_visibility for visibility checks', async () => {
257
261
  const args = {
258
262
  script: 'window.getComputedStyle(document.querySelector("#el")).visibility'
259
263
  };
@@ -261,7 +265,7 @@ describe('Browser Interaction Tools', () => {
261
265
  const fullResponse = result.content.map(c => c.text).join('\n');
262
266
  expect(result.isError).toBe(false);
263
267
  expect(fullResponse).toContain('💡 Consider using specialized tools instead');
264
- expect(fullResponse).toContain('element_visibility');
268
+ expect(fullResponse).toContain('check_visibility');
265
269
  });
266
270
  test('should suggest get_computed_styles for getComputedStyle usage', async () => {
267
271
  const args = {
@@ -293,7 +297,7 @@ describe('Browser Interaction Tools', () => {
293
297
  expect(fullResponse).toContain('💡 Consider using specialized tools instead');
294
298
  expect(fullResponse).toContain('get_test_ids');
295
299
  });
296
- test('should suggest compare_positions for position comparison', async () => {
300
+ test('should suggest compare_element_alignment for position comparison', async () => {
297
301
  const args = {
298
302
  script: 'document.querySelector("#a").getBoundingClientRect().top === document.querySelector("#b").getBoundingClientRect().top'
299
303
  };
@@ -301,7 +305,7 @@ describe('Browser Interaction Tools', () => {
301
305
  const fullResponse = result.content.map(c => c.text).join('\n');
302
306
  expect(result.isError).toBe(false);
303
307
  expect(fullResponse).toContain('💡 Consider using specialized tools instead');
304
- expect(fullResponse).toContain('compare_positions');
308
+ expect(fullResponse).toContain('compare_element_alignment');
305
309
  });
306
310
  test('should suggest scroll tools for scrolling operations', async () => {
307
311
  const args = {
@@ -33,7 +33,7 @@ import { GetComputedStylesTool } from './inspection/get_computed_styles.js';
33
33
  // Evaluation
34
34
  import { EvaluateTool } from './evaluation/evaluate.js';
35
35
  // Console
36
- import { GetConsoleLogsTool } from './console/get_console_logs.js';
36
+ import { GetConsoleLogsTool, ClearConsoleLogsTool } from './console/get_console_logs.js';
37
37
  // Network
38
38
  import { ListNetworkRequestsTool } from './network/list_network_requests.js';
39
39
  import { GetRequestDetailsTool } from './network/get_request_details.js';
@@ -75,8 +75,9 @@ export const BROWSER_TOOL_CLASSES = [
75
75
  GetComputedStylesTool,
76
76
  // Evaluation (1)
77
77
  EvaluateTool,
78
- // Console (1)
78
+ // Console (2)
79
79
  GetConsoleLogsTool,
80
+ ClearConsoleLogsTool,
80
81
  // Network (2)
81
82
  ListNetworkRequestsTool,
82
83
  GetRequestDetailsTool,
@@ -0,0 +1,16 @@
1
+ export interface PreviewCounts {
2
+ totalLength?: number;
3
+ shownLength?: number;
4
+ totalMatched?: number;
5
+ shownCount?: number;
6
+ truncated?: boolean;
7
+ }
8
+ export declare function makeConfirmPreview(payload: string, options: {
9
+ headerLine?: string;
10
+ previewLines: string[];
11
+ counts?: PreviewCounts;
12
+ extraTips?: string[];
13
+ }): {
14
+ token: string;
15
+ lines: string[];
16
+ };
@@ -0,0 +1,37 @@
1
+ import { registerPayload } from './confirmStore.js';
2
+ export function makeConfirmPreview(payload, options) {
3
+ const token = registerPayload(payload);
4
+ const lines = [];
5
+ if (options.headerLine) {
6
+ lines.push(options.headerLine);
7
+ }
8
+ const parts = [];
9
+ const c = options.counts || {};
10
+ if (typeof c.totalLength === 'number')
11
+ parts.push(`totalLength=${c.totalLength}`);
12
+ if (typeof c.shownLength === 'number')
13
+ parts.push(`shownLength=${c.shownLength}`);
14
+ if (typeof c.totalMatched === 'number')
15
+ parts.push(`totalMatched=${c.totalMatched}`);
16
+ if (typeof c.shownCount === 'number')
17
+ parts.push(`shownCount=${c.shownCount}`);
18
+ if (typeof c.truncated === 'boolean')
19
+ parts.push(`truncated=${c.truncated}`);
20
+ if (parts.length) {
21
+ lines.push(`counts: ${parts.join(', ')}`);
22
+ }
23
+ if (lines.length)
24
+ lines.push('');
25
+ // Caller provides preview content lines (e.g., label + excerpt or list)
26
+ for (const l of options.previewLines)
27
+ lines.push(l);
28
+ if (options.previewLines.length)
29
+ lines.push('');
30
+ const estTokens = Math.round((typeof c.totalLength === 'number' ? c.totalLength : payload.length) / 3);
31
+ lines.push(`Output is large (~${estTokens} tokens). To fetch full content without resending parameters, call confirm_output({ token: "${token}" }).`);
32
+ if (options.extraTips && options.extraTips.length) {
33
+ for (const tip of options.extraTips)
34
+ lines.push(tip);
35
+ }
36
+ return { token, lines };
37
+ }
@@ -0,0 +1,8 @@
1
+ export declare function registerPayload(payload: string, ttlMs?: number): string;
2
+ export declare function consumePayload(token: string): {
3
+ ok: true;
4
+ payload: string;
5
+ } | {
6
+ ok: false;
7
+ error: string;
8
+ };
@@ -0,0 +1,31 @@
1
+ const STORE = new Map();
2
+ function now() {
3
+ return Date.now();
4
+ }
5
+ function purgeExpired() {
6
+ const t = now();
7
+ for (const [k, v] of STORE.entries()) {
8
+ if (v.expiresAt <= t)
9
+ STORE.delete(k);
10
+ }
11
+ }
12
+ function genToken(len = 12) {
13
+ return Math.random().toString(36).slice(2, 2 + len);
14
+ }
15
+ export function registerPayload(payload, ttlMs = 120000) {
16
+ purgeExpired();
17
+ const token = genToken();
18
+ const expiresAt = now() + Math.max(1000, ttlMs);
19
+ STORE.set(token, { payload, expiresAt });
20
+ return token;
21
+ }
22
+ export function consumePayload(token) {
23
+ purgeExpired();
24
+ const entry = STORE.get(token);
25
+ if (!entry)
26
+ return { ok: false, error: 'Invalid or expired token' };
27
+ STORE.delete(token);
28
+ if (entry.expiresAt <= now())
29
+ return { ok: false, error: 'Invalid or expired token' };
30
+ return { ok: true, payload: entry.payload };
31
+ }
@@ -0,0 +1,7 @@
1
+ import type { ToolHandler, ToolContext, ToolResponse, ToolMetadata, SessionConfig } from './types.js';
2
+ export declare class ConfirmOutputTool implements ToolHandler {
3
+ private server;
4
+ constructor(server: any);
5
+ static getMetadata(sessionConfig?: SessionConfig): ToolMetadata;
6
+ execute(args: any, context: ToolContext): Promise<ToolResponse>;
7
+ }
@@ -0,0 +1,40 @@
1
+ import { createSuccessResponse, createErrorResponse } from './types.js';
2
+ import { consumePayload } from './confirmStore.js';
3
+ export class ConfirmOutputTool {
4
+ constructor(server) {
5
+ this.server = server;
6
+ }
7
+ static getMetadata(sessionConfig) {
8
+ return {
9
+ name: 'confirm_output',
10
+ description: "Return full output for a previously previewed large result using a one-time token. Use when a tool responded with a preview + token. Safer than resending original parameters.",
11
+ outputs: [
12
+ "Full original payload if token is valid (one-time)",
13
+ "Error: 'Invalid or expired token'",
14
+ ],
15
+ inputSchema: {
16
+ type: 'object',
17
+ properties: {
18
+ token: {
19
+ type: 'string',
20
+ description: "One-time token obtained from a tool's preview response",
21
+ },
22
+ },
23
+ required: ['token'],
24
+ },
25
+ priority: 2,
26
+ category: 'Other',
27
+ };
28
+ }
29
+ async execute(args, context) {
30
+ const token = typeof args.token === 'string' ? args.token : '';
31
+ if (!token)
32
+ return createErrorResponse('Token is required');
33
+ const res = consumePayload(token);
34
+ if (!res.ok) {
35
+ const err = res.error;
36
+ return createErrorResponse(err);
37
+ }
38
+ return createSuccessResponse(res.payload);
39
+ }
40
+ }
@@ -1,6 +1,7 @@
1
1
  import { createErrorResponse } from './types.js';
2
2
  import { BrowserToolBase } from '../browser/base.js';
3
3
  import { BROWSER_TOOL_CLASSES } from '../browser/register.js';
4
+ import { ConfirmOutputTool } from './confirm_output.js';
4
5
  const toolClasses = new Map();
5
6
  const toolInstances = new Map();
6
7
  const browserToolNames = new Set();
@@ -46,3 +47,4 @@ export function clearToolInstances() {
46
47
  toolInstances.clear();
47
48
  }
48
49
  registerTools(BROWSER_TOOL_CLASSES);
50
+ registerTools([ConfirmOutputTool]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-web-inspector",
3
- "version": "0.4.4",
3
+ "version": "0.5.0",
4
4
  "description": "Web Inspector MCP: Give LLMs visual superpowers to see, debug, and test any web page.",
5
5
  "license": "MIT",
6
6
  "author": "Anton",