mcpbrowser 0.3.45 → 0.3.47

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcpbrowser",
3
- "version": "0.3.45",
3
+ "version": "0.3.47",
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.",
@@ -29,6 +29,7 @@ import { extractAndProcessHtml, waitForPageReady } from '../core/page.js';
29
29
  import { MCPResponse, InformationalResponse } from '../core/responses.js';
30
30
  import logger from '../core/logger.js';
31
31
  import { getPluginNextSteps, getRecommendedPlugins } from '../core/plugin-loader.js';
32
+ import { scanPageForms } from './detect-forms.js';
32
33
 
33
34
  /**
34
35
  * @typedef {import('@modelcontextprotocol/sdk/types.js').Tool} Tool
@@ -38,7 +39,7 @@ import { getPluginNextSteps, getRecommendedPlugins } from '../core/plugin-loader
38
39
  * Structured response for browser_click_element with JS fallback metadata
39
40
  */
40
41
  export class ClickWithFallbackResponse extends MCPResponse {
41
- constructor({ status, fallbackUsed = false, nativeAttempt, fallbackAttempt, postClickWait, currentUrl, html = null, message, nextSteps = [], recommendedPlugins = [] }) {
42
+ constructor({ status, fallbackUsed = false, nativeAttempt, fallbackAttempt, postClickWait, currentUrl, html = null, message, nextSteps = [], recommendedPlugins = [], formData = null }) {
42
43
  super(nextSteps);
43
44
  this.status = status;
44
45
  this.fallbackUsed = fallbackUsed;
@@ -49,6 +50,9 @@ export class ClickWithFallbackResponse extends MCPResponse {
49
50
  this.html = html;
50
51
  this.message = message;
51
52
  this.recommendedPlugins = recommendedPlugins;
53
+ this.forms = formData?.forms || [];
54
+ this.orphanedFields = formData?.orphanedFields || [];
55
+ this.totalFieldCount = formData?.totalFieldCount || 0;
52
56
  }
53
57
 
54
58
  _getAdditionalFields() {
@@ -61,7 +65,10 @@ export class ClickWithFallbackResponse extends MCPResponse {
61
65
  currentUrl: this.currentUrl,
62
66
  html: this.html,
63
67
  message: this.message,
64
- recommendedPlugins: this.recommendedPlugins
68
+ recommendedPlugins: this.recommendedPlugins,
69
+ forms: this.forms,
70
+ orphanedFields: this.orphanedFields,
71
+ totalFieldCount: this.totalFieldCount
65
72
  };
66
73
  }
67
74
 
@@ -91,7 +98,8 @@ export const CLICK_ELEMENT_TOOL = {
91
98
  waitForElementTimeout: { type: "number", description: "Maximum time to wait for element in milliseconds", default: 1000 },
92
99
  returnHtml: { type: "boolean", description: "Whether to wait for stability and return HTML after clicking. Set to false for fast form interactions (checkboxes, radio buttons).", default: true },
93
100
  removeUnnecessaryHTML: { type: "boolean", description: "Remove Unnecessary HTML for size reduction by 90%. Only used when returnHtml is true.", default: true },
94
- postClickWait: { type: "number", description: "Milliseconds to wait after click for SPAs to render dynamic content.", default: 1000 }
101
+ postClickWait: { type: "number", description: "Milliseconds to wait after click for SPAs to render dynamic content.", default: 1000 },
102
+ detectForms: { type: "boolean", description: "Scan page for forms after click and return structured form data (fields, selectors, submit buttons, orphaned inputs). Only applies when returnHtml=true. Set to true when you need to fill or interact with forms after clicking.", default: false }
95
103
  },
96
104
  required: ["url"],
97
105
  additionalProperties: false,
@@ -135,6 +143,9 @@ export const CLICK_ELEMENT_TOOL = {
135
143
  type: ["string", "null"],
136
144
  description: "Page HTML if returnHtml was true, null otherwise"
137
145
  },
146
+ forms: { type: "array", items: { type: "object" }, description: "Detected forms with fields, selectors, and metadata (when returnHtml is true)" },
147
+ orphanedFields: { type: "array", items: { type: "object" }, description: "Input/select/textarea elements not inside any <form> (when returnHtml is true)" },
148
+ totalFieldCount: { type: "number", description: "Total number of form fields found on the page" },
138
149
  nextSteps: {
139
150
  type: "array",
140
151
  items: { type: "string" },
@@ -181,7 +192,7 @@ export const CLICK_ELEMENT_TOOL = {
181
192
  * returnHtml: false
182
193
  * });
183
194
  */
184
- export async function clickElement({ url, selector, text, waitForElementTimeout = 30000, returnHtml = true, removeUnnecessaryHTML = true, postClickWait = 1000 }) {
195
+ export async function clickElement({ url, selector, text, waitForElementTimeout = 30000, returnHtml = true, removeUnnecessaryHTML = true, postClickWait = 1000, detectForms = false }) {
185
196
  logger.info(`browser_click_element called: ${selector || `text="${text}"`}`);
186
197
 
187
198
  if (!url) {
@@ -327,6 +338,17 @@ export async function clickElement({ url, selector, text, waitForElementTimeout
327
338
 
328
339
  const currentUrl = page.url();
329
340
  const html = finalStatus === 'success' && returnHtml ? await extractAndProcessHtml(page, removeUnnecessaryHTML) : null;
341
+
342
+ // Scan for forms when requested and returning HTML (lightweight, ~50-100ms)
343
+ let formData = null;
344
+ if (detectForms && finalStatus === 'success' && returnHtml) {
345
+ try {
346
+ formData = await scanPageForms(page);
347
+ } catch (err) {
348
+ logger.debug(`Form scan failed (non-fatal): ${err.message}`);
349
+ }
350
+ }
351
+
330
352
  const baseMessage = selector ? `Clicked element: ${selector}` : `Clicked element with text: "${text}"`;
331
353
  const message = finalStatus === 'success'
332
354
  ? baseMessage
@@ -362,7 +384,8 @@ export async function clickElement({ url, selector, text, waitForElementTimeout
362
384
  html,
363
385
  message,
364
386
  nextSteps,
365
- recommendedPlugins: html ? getRecommendedPlugins(currentUrl, html) : []
387
+ recommendedPlugins: html ? getRecommendedPlugins(currentUrl, html) : [],
388
+ formData
366
389
  });
367
390
  } catch (err) {
368
391
  logger.error(`browser_click_element failed: ${err.message}`);
@@ -0,0 +1,502 @@
1
+ /**
2
+ * detect-forms.js - Auto Form Discovery
3
+ * Scans the current page and returns structured JSON of all forms,
4
+ * their fields, submit buttons, and orphaned inputs (common in SPAs).
5
+ */
6
+
7
+ import { getBrowser, getValidatedPage } from '../core/browser.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 detect_forms operations
21
+ */
22
+ export class DetectFormsResponse extends MCPResponse {
23
+ /**
24
+ * @param {Object} params
25
+ * @param {Array} params.forms - Array of form objects
26
+ * @param {Array} params.orphanedFields - Fields not inside any <form>
27
+ * @param {number} params.totalFieldCount - Total number of fields found
28
+ * @param {string} params.summary - Human-readable summary
29
+ * @param {string[]} params.nextSteps - Suggested next actions
30
+ */
31
+ constructor({ forms, orphanedFields, totalFieldCount, summary, nextSteps = [] }) {
32
+ super(nextSteps);
33
+
34
+ if (!Array.isArray(forms)) {
35
+ throw new TypeError('forms must be an array');
36
+ }
37
+ if (!Array.isArray(orphanedFields)) {
38
+ throw new TypeError('orphanedFields must be an array');
39
+ }
40
+ if (typeof totalFieldCount !== 'number') {
41
+ throw new TypeError('totalFieldCount must be a number');
42
+ }
43
+ if (typeof summary !== 'string') {
44
+ throw new TypeError('summary must be a string');
45
+ }
46
+
47
+ this.forms = forms;
48
+ this.orphanedFields = orphanedFields;
49
+ this.totalFieldCount = totalFieldCount;
50
+ this.summary = summary;
51
+ }
52
+
53
+ _getAdditionalFields() {
54
+ return {
55
+ forms: this.forms,
56
+ orphanedFields: this.orphanedFields,
57
+ totalFieldCount: this.totalFieldCount,
58
+ summary: this.summary
59
+ };
60
+ }
61
+
62
+ getTextSummary() {
63
+ return this.summary;
64
+ }
65
+ }
66
+
67
+ // ============================================================================
68
+ // TOOL DEFINITION
69
+ // ============================================================================
70
+
71
+ /**
72
+ * @type {Tool}
73
+ */
74
+ export const DETECT_FORMS_TOOL = {
75
+ name: "browser_detect_forms",
76
+ title: "Detect Forms",
77
+ description: "**AUTO FORM DISCOVERY** - Scans the current page and returns structured JSON of all forms: fields (name, type, required, placeholder, current value, validation constraints), submit buttons, and orphaned inputs not inside any <form> (common in SPAs). Use this BEFORE filling forms to understand what fields exist and how to interact with them.\n\n**PREREQUISITE**: Page MUST be loaded with browser_fetch_webpage first.",
78
+ inputSchema: {
79
+ type: "object",
80
+ properties: {
81
+ url: { type: "string", description: "URL of the already-loaded page" },
82
+ includeHidden: { type: "boolean", default: false, description: "Include hidden fields (type=hidden). Useful for understanding form state." }
83
+ },
84
+ required: ["url"],
85
+ additionalProperties: false
86
+ },
87
+ outputSchema: {
88
+ type: "object",
89
+ properties: {
90
+ forms: {
91
+ type: "array",
92
+ items: {
93
+ type: "object",
94
+ properties: {
95
+ formSelector: { type: "string" },
96
+ action: { type: "string" },
97
+ method: { type: "string" },
98
+ formType: { type: "string" },
99
+ fields: {
100
+ type: "array",
101
+ items: {
102
+ type: "object",
103
+ properties: {
104
+ selector: { type: "string" },
105
+ name: { type: "string" },
106
+ id: { type: "string" },
107
+ tag: { type: "string" },
108
+ type: { type: "string" },
109
+ required: { type: "boolean" },
110
+ placeholder: { type: "string" },
111
+ currentValue: { type: "string" },
112
+ label: { type: "string" },
113
+ validation: {
114
+ type: "object",
115
+ properties: {
116
+ min: { type: "string" },
117
+ max: { type: "string" },
118
+ pattern: { type: "string" },
119
+ maxLength: { type: "number" }
120
+ }
121
+ }
122
+ }
123
+ }
124
+ },
125
+ submitButton: {
126
+ type: ["object", "null"],
127
+ properties: {
128
+ selector: { type: "string" },
129
+ text: { type: "string" },
130
+ type: { type: "string" }
131
+ }
132
+ }
133
+ }
134
+ },
135
+ description: "Array of detected forms with fields and metadata"
136
+ },
137
+ orphanedFields: {
138
+ type: "array",
139
+ items: { type: "object" },
140
+ description: "Input/select/textarea elements not inside any <form>"
141
+ },
142
+ totalFieldCount: { type: "number", description: "Total number of fields found" },
143
+ summary: { type: "string", description: "Human-readable summary of detected forms" },
144
+ nextSteps: {
145
+ type: "array",
146
+ items: { type: "string" },
147
+ description: "Suggested next actions"
148
+ }
149
+ },
150
+ required: ["forms", "orphanedFields", "totalFieldCount", "summary", "nextSteps"],
151
+ additionalProperties: false
152
+ }
153
+ };
154
+
155
+ // ============================================================================
156
+ // PAGE EVALUATION FUNCTION
157
+ // ============================================================================
158
+
159
+ /**
160
+ * Runs inside the browser context via page.evaluate().
161
+ * Scans all forms and orphaned fields, resolves labels, classifies form types.
162
+ * @param {boolean} includeHidden - Whether to include hidden fields
163
+ * @returns {Object} Raw form data
164
+ */
165
+ function buildScanFunction() {
166
+ return (includeHidden) => {
167
+ /**
168
+ * Build a CSS selector for an element
169
+ */
170
+ function buildSelector(el) {
171
+ if (el.id) return `#${CSS.escape(el.id)}`;
172
+ if (el.name) {
173
+ const tag = el.tagName.toLowerCase();
174
+ const sel = `${tag}[name="${CSS.escape(el.name)}"]`;
175
+ if (document.querySelectorAll(sel).length === 1) return sel;
176
+ }
177
+ // Fallback: nth-of-type relative to parent
178
+ const parent = el.parentElement;
179
+ if (!parent) return el.tagName.toLowerCase();
180
+ const tag = el.tagName.toLowerCase();
181
+ const siblings = Array.from(parent.children).filter(c => c.tagName === el.tagName);
182
+ if (siblings.length === 1) return `${buildSelector(parent)} > ${tag}`;
183
+ const idx = siblings.indexOf(el) + 1;
184
+ return `${buildSelector(parent)} > ${tag}:nth-of-type(${idx})`;
185
+ }
186
+
187
+ /**
188
+ * Resolve label text for a field element
189
+ * Priority: <label for> → parent <label> → aria-label → aria-labelledby → placeholder
190
+ */
191
+ function resolveLabel(el) {
192
+ // 1. Explicit <label for="id">
193
+ if (el.id) {
194
+ const label = document.querySelector(`label[for="${CSS.escape(el.id)}"]`);
195
+ if (label) return label.textContent.trim();
196
+ }
197
+ // 2. Parent <label> wrapping the input
198
+ const parentLabel = el.closest('label');
199
+ if (parentLabel) {
200
+ // Get text content excluding the input itself
201
+ const clone = parentLabel.cloneNode(true);
202
+ clone.querySelectorAll('input, select, textarea').forEach(c => c.remove());
203
+ const text = clone.textContent.trim();
204
+ if (text) return text;
205
+ }
206
+ // 3. aria-label
207
+ const ariaLabel = el.getAttribute('aria-label');
208
+ if (ariaLabel) return ariaLabel.trim();
209
+ // 4. aria-labelledby
210
+ const ariaLabelledBy = el.getAttribute('aria-labelledby');
211
+ if (ariaLabelledBy) {
212
+ const refEl = document.getElementById(ariaLabelledBy);
213
+ if (refEl) return refEl.textContent.trim();
214
+ }
215
+ // 5. placeholder
216
+ const placeholder = el.getAttribute('placeholder');
217
+ if (placeholder) return placeholder.trim();
218
+ return '';
219
+ }
220
+
221
+ /**
222
+ * Extract field info from an input/select/textarea element
223
+ */
224
+ function extractField(el) {
225
+ const tag = el.tagName.toLowerCase();
226
+ const type = el.getAttribute('type') || (tag === 'select' ? 'select' : tag === 'textarea' ? 'textarea' : 'text');
227
+ return {
228
+ selector: buildSelector(el),
229
+ name: el.name || '',
230
+ id: el.id || '',
231
+ tag,
232
+ type,
233
+ required: el.required || el.getAttribute('aria-required') === 'true',
234
+ placeholder: el.getAttribute('placeholder') || '',
235
+ currentValue: el.value || '',
236
+ label: resolveLabel(el),
237
+ validation: {
238
+ min: el.getAttribute('min') || '',
239
+ max: el.getAttribute('max') || '',
240
+ pattern: el.getAttribute('pattern') || '',
241
+ maxLength: el.maxLength >= 0 ? el.maxLength : null
242
+ }
243
+ };
244
+ }
245
+
246
+ /**
247
+ * Find the submit button for a form
248
+ */
249
+ function findSubmitButton(form) {
250
+ // Explicit submit button
251
+ const submit = form.querySelector('button[type="submit"], input[type="submit"]');
252
+ if (submit) {
253
+ return {
254
+ selector: buildSelector(submit),
255
+ text: (submit.textContent || submit.value || '').trim(),
256
+ type: submit.getAttribute('type') || 'submit'
257
+ };
258
+ }
259
+ // Fallback: first <button> without type (default is submit)
260
+ const btn = form.querySelector('button:not([type])');
261
+ if (btn) {
262
+ return {
263
+ selector: buildSelector(btn),
264
+ text: btn.textContent.trim(),
265
+ type: 'submit'
266
+ };
267
+ }
268
+ return null;
269
+ }
270
+
271
+ /**
272
+ * Classify form type via heuristics
273
+ */
274
+ function classifyForm(fields, form) {
275
+ const visibleFields = fields.filter(f => f.type !== 'hidden');
276
+ const hasPassword = visibleFields.some(f => f.type === 'password');
277
+ const hasEmail = visibleFields.some(f => f.type === 'email' || f.name.includes('email') || f.id.includes('email'));
278
+ const hasTextarea = visibleFields.some(f => f.tag === 'textarea');
279
+ const hasSearch = visibleFields.some(f => f.type === 'search');
280
+
281
+ // Check for credit card patterns
282
+ const cardPatterns = /card|cc[-_]?num|cvv|cvc|expir|ccv/i;
283
+ const hasCardFields = visibleFields.some(f =>
284
+ cardPatterns.test(f.name) || cardPatterns.test(f.id) || cardPatterns.test(f.label)
285
+ );
286
+
287
+ if (hasCardFields) return 'checkout';
288
+ if (hasPassword && visibleFields.length <= 3) return 'login';
289
+ if (hasPassword && hasEmail && visibleFields.length > 3) return 'registration';
290
+ if (hasSearch) return 'search';
291
+ if (hasTextarea && hasEmail && !hasPassword) return 'contact';
292
+
293
+ // Check form action/class for search hints
294
+ const formAction = (form.getAttribute('action') || '').toLowerCase();
295
+ const formClass = (form.getAttribute('class') || '').toLowerCase();
296
+ const formRole = (form.getAttribute('role') || '').toLowerCase();
297
+ if (formRole === 'search' || formAction.includes('search') || formClass.includes('search')) return 'search';
298
+
299
+ // Single text input with submit = likely search
300
+ if (visibleFields.length === 1 && (visibleFields[0].type === 'text' || visibleFields[0].type === 'search')) return 'search';
301
+
302
+ return 'other';
303
+ }
304
+
305
+ const fieldSelector = 'input, select, textarea';
306
+ const forms = [];
307
+
308
+ // Process each <form> element
309
+ document.querySelectorAll('form').forEach((form, index) => {
310
+ const fieldElements = Array.from(form.querySelectorAll(fieldSelector));
311
+ let fields = fieldElements.map(extractField);
312
+
313
+ // Filter hidden fields unless includeHidden
314
+ if (!includeHidden) {
315
+ fields = fields.filter(f => f.type !== 'hidden');
316
+ }
317
+
318
+ forms.push({
319
+ formSelector: buildSelector(form),
320
+ action: form.getAttribute('action') || '',
321
+ method: (form.getAttribute('method') || 'GET').toUpperCase(),
322
+ formType: classifyForm(fields, form),
323
+ fields,
324
+ submitButton: findSubmitButton(form)
325
+ });
326
+ });
327
+
328
+ // Collect orphaned fields (not inside any <form>)
329
+ const allFields = Array.from(document.querySelectorAll(fieldSelector));
330
+ let orphanedFields = allFields
331
+ .filter(el => !el.closest('form'))
332
+ .map(extractField);
333
+
334
+ if (!includeHidden) {
335
+ orphanedFields = orphanedFields.filter(f => f.type !== 'hidden');
336
+ }
337
+
338
+ const totalFieldCount = forms.reduce((sum, f) => sum + f.fields.length, 0) + orphanedFields.length;
339
+
340
+ return { forms, orphanedFields, totalFieldCount };
341
+ };
342
+ }
343
+
344
+ // ============================================================================
345
+ // SHARED SCAN FUNCTION (used by fetch-page, get-current-html, click-element)
346
+ // ============================================================================
347
+
348
+ /**
349
+ * Scan a page for forms and return structured data.
350
+ * Lightweight (~50-100ms) - safe to call on every page load.
351
+ * @param {Object} page - Puppeteer page object
352
+ * @param {boolean} [includeHidden=false] - Whether to include hidden fields
353
+ * @returns {Promise<{forms: Array, orphanedFields: Array, totalFieldCount: number}>}
354
+ */
355
+ export async function scanPageForms(page, includeHidden = false) {
356
+ const scanFn = buildScanFunction();
357
+ return await page.evaluate(scanFn, includeHidden);
358
+ }
359
+
360
+ // ============================================================================
361
+ // ACTION FUNCTION
362
+ // ============================================================================
363
+
364
+ /**
365
+ * Detect all forms on the current page
366
+ * @param {Object} params - Parameters
367
+ * @param {string} params.url - The URL of the page to scan
368
+ * @param {boolean} [params.includeHidden=false] - Whether to include hidden fields
369
+ * @returns {Promise<DetectFormsResponse|InformationalResponse>}
370
+ */
371
+ export async function detectForms({ url, includeHidden = false }) {
372
+ logger.info(`browser_detect_forms called: url=${url}, includeHidden=${includeHidden}`);
373
+
374
+ if (!url) {
375
+ throw new Error("url parameter is required");
376
+ }
377
+
378
+ let hostname;
379
+ try {
380
+ hostname = new URL(url).hostname;
381
+ } catch {
382
+ throw new Error(`Invalid URL: ${url}`);
383
+ }
384
+
385
+ // Ensure browser connection
386
+ try {
387
+ await getBrowser();
388
+ } catch (err) {
389
+ logger.error(`browser_detect_forms: Failed to connect to browser: ${err.message}`);
390
+ return new InformationalResponse(
391
+ `Browser connection failed: ${err.message}`,
392
+ 'The browser must be running with remote debugging enabled.',
393
+ [
394
+ 'Ensure the browser is installed and running',
395
+ 'Check that remote debugging is enabled (--remote-debugging-port)',
396
+ 'Try restarting the MCP server'
397
+ ]
398
+ );
399
+ }
400
+
401
+ // Validate page exists and is usable
402
+ const { page, error: pageError } = await getValidatedPage(hostname);
403
+
404
+ if (!page) {
405
+ const isConnectionLost = pageError && pageError.includes('connection');
406
+ logger.debug(`browser_detect_forms: ${pageError || 'No page found for ' + hostname}`);
407
+ return new InformationalResponse(
408
+ isConnectionLost ? `Page connection lost for ${hostname}` : `No open page found for ${hostname}`,
409
+ isConnectionLost
410
+ ? 'The browser tab was closed or the connection was lost. The page needs to be reloaded.'
411
+ : 'The page must be loaded before you can detect forms',
412
+ [
413
+ "Use MCPBrowser's browser_fetch_webpage tool to load the page first",
414
+ "Then retry MCPBrowser's browser_detect_forms with the same URL"
415
+ ]
416
+ );
417
+ }
418
+
419
+ try {
420
+ const raw = await scanPageForms(page, includeHidden);
421
+
422
+ // Build summary
423
+ const summary = buildSummary(raw.forms, raw.orphanedFields, raw.totalFieldCount);
424
+
425
+ logger.info(`browser_detect_forms completed: ${summary}`);
426
+
427
+ // Build next steps based on discovered forms
428
+ const nextSteps = buildNextSteps(raw.forms, raw.orphanedFields);
429
+
430
+ return new DetectFormsResponse({
431
+ forms: raw.forms,
432
+ orphanedFields: raw.orphanedFields,
433
+ totalFieldCount: raw.totalFieldCount,
434
+ summary,
435
+ nextSteps
436
+ });
437
+ } catch (err) {
438
+ logger.error(`browser_detect_forms failed: ${err.message}`);
439
+ return new InformationalResponse(
440
+ `Failed to detect forms: ${err.message}`,
441
+ 'Could not scan the page for forms. The page may have navigated away or the connection was lost.',
442
+ [
443
+ "Try MCPBrowser's browser_fetch_webpage to reload the page",
444
+ "Use MCPBrowser's browser_close_tab and start fresh if needed"
445
+ ]
446
+ );
447
+ }
448
+ }
449
+
450
+ // ============================================================================
451
+ // HELPERS
452
+ // ============================================================================
453
+
454
+ /**
455
+ * Build a human-readable summary of detected forms
456
+ */
457
+ function buildSummary(forms, orphanedFields, totalFieldCount) {
458
+ if (forms.length === 0 && orphanedFields.length === 0) {
459
+ return 'No forms or input fields found on this page';
460
+ }
461
+
462
+ const parts = [];
463
+ if (forms.length > 0) {
464
+ const formDescriptions = forms.map(f => {
465
+ const fieldCount = f.fields.length;
466
+ return `1 ${f.formType} form (${fieldCount} field${fieldCount !== 1 ? 's' : ''})`;
467
+ });
468
+ parts.push(formDescriptions.join(', '));
469
+ }
470
+ if (orphanedFields.length > 0) {
471
+ parts.push(`${orphanedFields.length} orphaned field${orphanedFields.length !== 1 ? 's' : ''} (not in any form)`);
472
+ }
473
+
474
+ return `Found ${forms.length} form${forms.length !== 1 ? 's' : ''}: ${parts.join('; ')}. Total fields: ${totalFieldCount}`;
475
+ }
476
+
477
+ /**
478
+ * Build contextual next steps based on what was found
479
+ */
480
+ function buildNextSteps(forms, orphanedFields) {
481
+ const steps = [];
482
+
483
+ if (forms.length > 0) {
484
+ const primaryForm = forms[0];
485
+ if (primaryForm.fields.length > 0) {
486
+ const firstField = primaryForm.fields[0];
487
+ steps.push(`Use MCPBrowser's browser_type_text to fill form fields (e.g., selector: '${firstField.selector}')`);
488
+ }
489
+ if (primaryForm.submitButton) {
490
+ steps.push(`Use MCPBrowser's browser_click_element to submit the form (selector: '${primaryForm.submitButton.selector}')`);
491
+ }
492
+ }
493
+
494
+ if (orphanedFields.length > 0) {
495
+ steps.push("Use MCPBrowser's browser_type_text for orphaned fields (SPA inputs not inside a <form>)");
496
+ }
497
+
498
+ steps.push("Use MCPBrowser's browser_take_screenshot if form layout is unclear from the data");
499
+ steps.push("Use MCPBrowser's browser_get_current_html to see full page HTML");
500
+
501
+ return steps;
502
+ }
@@ -209,6 +209,24 @@ export async function executeJavascript({ url, script, timeoutMs = EXECUTION_TIM
209
209
  }
210
210
  const urlChanged = currentUrl !== beforeUrl;
211
211
 
212
+ // Detect CSP block or silent evaluation failure:
213
+ // When page.evaluate() is blocked by CSP, Puppeteer returns undefined (not an error).
214
+ // Distinguish this from a script that intentionally returns nothing.
215
+ if (evalResult === undefined || evalResult === null) {
216
+ return new ExecuteJavascriptResponse({
217
+ result: null,
218
+ type: 'undefined',
219
+ executionTimeMs,
220
+ truncated: false,
221
+ urlChanged,
222
+ currentUrl,
223
+ error: {
224
+ name: 'EvaluationEmpty',
225
+ message: 'Script evaluation returned no result. Possible causes: page Content Security Policy (CSP) blocked evaluation, the script has no return value, or the page context is sandboxed. Try browser_take_screenshot to verify the page is loaded, or use a simpler expression like "document.title" to test page accessibility.'
226
+ }
227
+ });
228
+ }
229
+
212
230
  if (evalResult?.error) {
213
231
  return new ExecuteJavascriptResponse({
214
232
  result: null,
@@ -9,6 +9,7 @@ import { isLikelyAuthUrl, waitForAuth } from '../core/auth.js';
9
9
  import { MCPResponse, ErrorResponse, HttpStatusResponse, InformationalResponse } from '../core/responses.js';
10
10
  import logger from '../core/logger.js';
11
11
  import { getPluginNextSteps, getRecommendedPlugins } from '../core/plugin-loader.js';
12
+ import { scanPageForms } from './detect-forms.js';
12
13
 
13
14
  /**
14
15
  * @typedef {import('@modelcontextprotocol/sdk/types.js').Tool} Tool
@@ -27,8 +28,9 @@ export class FetchPageSuccessResponse extends MCPResponse {
27
28
  * @param {string} html - Page HTML content
28
29
  * @param {string[]} nextSteps - Suggested next actions
29
30
  * @param {Array} [recommendedPlugins] - Detected plugin metadata
31
+ * @param {Object} [formData] - Detected forms data
30
32
  */
31
- constructor(currentUrl, html, nextSteps, recommendedPlugins = []) {
33
+ constructor(currentUrl, html, nextSteps, recommendedPlugins = [], formData = null) {
32
34
  super(nextSteps);
33
35
 
34
36
  if (typeof currentUrl !== 'string') {
@@ -41,13 +43,19 @@ export class FetchPageSuccessResponse extends MCPResponse {
41
43
  this.currentUrl = currentUrl;
42
44
  this.html = html;
43
45
  this.recommendedPlugins = recommendedPlugins;
46
+ this.forms = formData?.forms || [];
47
+ this.orphanedFields = formData?.orphanedFields || [];
48
+ this.totalFieldCount = formData?.totalFieldCount || 0;
44
49
  }
45
50
 
46
51
  _getAdditionalFields() {
47
52
  return {
48
53
  currentUrl: this.currentUrl,
49
54
  html: this.html,
50
- recommendedPlugins: this.recommendedPlugins
55
+ recommendedPlugins: this.recommendedPlugins,
56
+ forms: this.forms,
57
+ orphanedFields: this.orphanedFields,
58
+ totalFieldCount: this.totalFieldCount
51
59
  };
52
60
  }
53
61
 
@@ -77,7 +85,9 @@ export const FETCH_WEBPAGE_TOOL = {
77
85
  enum: ["", "chrome", "edge"]
78
86
  },
79
87
  removeUnnecessaryHTML: { type: "boolean", description: "Remove Unnecessary HTML for size reduction by 90%.", default: true },
80
- postLoadWait: { type: "number", description: "Additional milliseconds to wait after page load before extracting HTML. Use for pages that need extra time to render. Default: 0 (no extra wait, SPA detection handles most cases automatically).", default: 0 }
88
+ selector: { type: "string", description: "CSS selector to extract a specific DOM subtree instead of the full page. Use to scope extraction and reduce response size (e.g., 'main', '[role=\"main\"]', 'body > div:first-child'). If no elements match, falls back to full page with a note." },
89
+ postLoadWait: { type: "number", description: "Additional milliseconds to wait after page load before extracting HTML. Use for pages that need extra time to render. Default: 0 (no extra wait, SPA detection handles most cases automatically).", default: 0 },
90
+ detectForms: { type: "boolean", description: "Scan page for forms and return structured form data (fields, selectors, submit buttons, orphaned inputs). Set to true when you need to fill or interact with forms.", default: false }
81
91
  },
82
92
  required: ["url"],
83
93
  additionalProperties: false
@@ -87,6 +97,9 @@ export const FETCH_WEBPAGE_TOOL = {
87
97
  properties: {
88
98
  currentUrl: { type: "string", description: "Final URL after any redirects" },
89
99
  html: { type: "string", description: "Page HTML content" },
100
+ forms: { type: "array", items: { type: "object" }, description: "Detected forms with fields, selectors, and metadata" },
101
+ orphanedFields: { type: "array", items: { type: "object" }, description: "Input/select/textarea elements not inside any <form>" },
102
+ totalFieldCount: { type: "number", description: "Total number of form fields found on the page" },
90
103
  nextSteps: {
91
104
  type: "array",
92
105
  items: { type: "string" },
@@ -122,7 +135,7 @@ export const FETCH_WEBPAGE_TOOL = {
122
135
  * @param {number} [params.postLoadWait=0] - Additional milliseconds to wait after page load before extracting HTML
123
136
  * @returns {Promise<Object>} Result object with success status, URL, HTML content, or error details
124
137
  */
125
- export async function fetchPage({ url, browser = '', removeUnnecessaryHTML = true, postLoadWait = 0 }) {
138
+ export async function fetchPage({ url, browser = '', removeUnnecessaryHTML = true, selector = null, postLoadWait = 0, detectForms = false }) {
126
139
  logger.info(`browser_fetch_webpage called: url=${url}`);
127
140
 
128
141
  // Handle missing URL with environment variable fallback
@@ -150,7 +163,7 @@ export async function fetchPage({ url, browser = '', removeUnnecessaryHTML = tru
150
163
 
151
164
  // Queue this request - processed sequentially, one at a time
152
165
  return queueRequest(async () => {
153
- return await doFetchPage({ url, browser, removeUnnecessaryHTML, postLoadWait });
166
+ return await doFetchPage({ url, browser, removeUnnecessaryHTML, selector, postLoadWait, detectForms });
154
167
  });
155
168
  }
156
169
 
@@ -158,7 +171,7 @@ export async function fetchPage({ url, browser = '', removeUnnecessaryHTML = tru
158
171
  * Internal function that does the actual page fetching.
159
172
  * Called by the queue processor - only one runs at a time.
160
173
  */
161
- async function doFetchPage({ url, browser, removeUnnecessaryHTML, postLoadWait }) {
174
+ async function doFetchPage({ url, browser, removeUnnecessaryHTML, selector, postLoadWait, detectForms }) {
162
175
  const originalHostname = new URL(url).hostname;
163
176
 
164
177
  // Ensure browser connection
@@ -215,7 +228,17 @@ async function doFetchPage({ url, browser, removeUnnecessaryHTML, postLoadWait }
215
228
  }
216
229
 
217
230
  // Extract and process HTML
218
- const processedHtml = await extractAndProcessHtml(page, removeUnnecessaryHTML);
231
+ const processedHtml = await extractAndProcessHtml(page, removeUnnecessaryHTML, selector);
232
+
233
+ // Scan for forms when requested (lightweight, ~50-100ms)
234
+ let formData = null;
235
+ if (detectForms) {
236
+ try {
237
+ formData = await scanPageForms(page);
238
+ } catch (err) {
239
+ logger.debug(`Form scan failed (non-fatal): ${err.message}`);
240
+ }
241
+ }
219
242
 
220
243
  logger.info(`browser_fetch_webpage completed: ${page.url()}`);
221
244
 
@@ -236,7 +259,8 @@ async function doFetchPage({ url, browser, removeUnnecessaryHTML, postLoadWait }
236
259
  "Use MCPBrowser's browser_take_screenshot if page has charts, images, or complex visual layout that's hard to understand from HTML",
237
260
  "Use MCPBrowser's browser_close_tab when finished to free browser resources"
238
261
  ],
239
- getRecommendedPlugins(page.url(), processedHtml)
262
+ getRecommendedPlugins(page.url(), processedHtml),
263
+ formData
240
264
  );
241
265
  } catch (err) {
242
266
  logger.error(`browser_fetch_webpage failed: ${err.message || String(err)}`);
@@ -7,6 +7,7 @@ import { extractAndProcessHtml } from '../core/page.js';
7
7
  import { MCPResponse, InformationalResponse } from '../core/responses.js';
8
8
  import logger from '../core/logger.js';
9
9
  import { getPluginNextSteps, getRecommendedPlugins } from '../core/plugin-loader.js';
10
+ import { scanPageForms } from './detect-forms.js';
10
11
 
11
12
  /**
12
13
  * @typedef {import('@modelcontextprotocol/sdk/types.js').Tool} Tool
@@ -25,8 +26,9 @@ export class GetCurrentHtmlSuccessResponse extends MCPResponse {
25
26
  * @param {string} html - Page HTML content
26
27
  * @param {string[]} nextSteps - Suggested next actions
27
28
  * @param {Array} [recommendedPlugins] - Detected plugin metadata
29
+ * @param {Object} [formData] - Detected forms data
28
30
  */
29
- constructor(currentUrl, html, nextSteps, recommendedPlugins = []) {
31
+ constructor(currentUrl, html, nextSteps, recommendedPlugins = [], formData = null) {
30
32
  super(nextSteps);
31
33
 
32
34
  if (typeof currentUrl !== 'string') {
@@ -39,13 +41,19 @@ export class GetCurrentHtmlSuccessResponse extends MCPResponse {
39
41
  this.currentUrl = currentUrl;
40
42
  this.html = html;
41
43
  this.recommendedPlugins = recommendedPlugins;
44
+ this.forms = formData?.forms || [];
45
+ this.orphanedFields = formData?.orphanedFields || [];
46
+ this.totalFieldCount = formData?.totalFieldCount || 0;
42
47
  }
43
48
 
44
49
  _getAdditionalFields() {
45
50
  return {
46
51
  currentUrl: this.currentUrl,
47
52
  html: this.html,
48
- recommendedPlugins: this.recommendedPlugins
53
+ recommendedPlugins: this.recommendedPlugins,
54
+ forms: this.forms,
55
+ orphanedFields: this.orphanedFields,
56
+ totalFieldCount: this.totalFieldCount
49
57
  };
50
58
  }
51
59
 
@@ -69,7 +77,9 @@ export const GET_CURRENT_HTML_TOOL = {
69
77
  type: "object",
70
78
  properties: {
71
79
  url: { type: "string", description: "The URL of the page (must match a previously fetched page)" },
72
- removeUnnecessaryHTML: { type: "boolean", description: "Remove Unnecessary HTML for size reduction by 90%.", default: true }
80
+ removeUnnecessaryHTML: { type: "boolean", description: "Remove Unnecessary HTML for size reduction by 90%.", default: true },
81
+ selector: { type: "string", description: "CSS selector to extract a specific DOM subtree instead of the full page. Use to scope extraction and reduce response size (e.g., 'main', '[role=\"main\"]', 'body > div:first-child'). If no elements match, falls back to full page with a note." },
82
+ detectForms: { type: "boolean", description: "Scan page for forms and return structured form data (fields, selectors, submit buttons, orphaned inputs). Set to true when you need to fill or interact with forms.", default: false }
73
83
  },
74
84
  required: ["url"],
75
85
  additionalProperties: false
@@ -79,6 +89,9 @@ export const GET_CURRENT_HTML_TOOL = {
79
89
  properties: {
80
90
  currentUrl: { type: "string", description: "Current page URL" },
81
91
  html: { type: "string", description: "Page HTML content" },
92
+ forms: { type: "array", items: { type: "object" }, description: "Detected forms with fields, selectors, and metadata" },
93
+ orphanedFields: { type: "array", items: { type: "object" }, description: "Input/select/textarea elements not inside any <form>" },
94
+ totalFieldCount: { type: "number", description: "Total number of form fields found on the page" },
82
95
  nextSteps: {
83
96
  type: "array",
84
97
  items: { type: "string" },
@@ -107,9 +120,9 @@ export const GET_CURRENT_HTML_TOOL = {
107
120
  * @param {boolean} [params.removeUnnecessaryHTML=true] - Whether to clean HTML
108
121
  * @returns {Promise<Object>} Result object with current HTML
109
122
  */
110
- export async function getCurrentHtml({ url, removeUnnecessaryHTML = true }) {
123
+ export async function getCurrentHtml({ url, removeUnnecessaryHTML = true, selector = null, detectForms = false }) {
111
124
  const startTime = Date.now();
112
- logger.info(`browser_get_current_html called: url=${url}`);
125
+ logger.info(`browser_get_current_html called: url=${url}${selector ? ` selector=${selector}` : ''}`);
113
126
 
114
127
  if (!url) {
115
128
  throw new Error("url parameter is required");
@@ -158,7 +171,32 @@ export async function getCurrentHtml({ url, removeUnnecessaryHTML = true }) {
158
171
 
159
172
  try {
160
173
  const currentUrl = page.url();
161
- const html = await extractAndProcessHtml(page, removeUnnecessaryHTML);
174
+ const html = await extractAndProcessHtml(page, removeUnnecessaryHTML, selector);
175
+
176
+ // Scan for forms when requested (lightweight, ~50-100ms)
177
+ let formData = null;
178
+ if (detectForms) {
179
+ try {
180
+ formData = await scanPageForms(page);
181
+ } catch (err) {
182
+ logger.debug(`Form scan failed (non-fatal): ${err.message}`);
183
+ }
184
+ }
185
+
186
+ // Detect empty/near-empty HTML extraction (e.g., CSP blocking page.evaluate)
187
+ if (!html || html.trim().length < 100) {
188
+ logger.warn(`browser_get_current_html: HTML extraction returned empty/minimal content from ${currentUrl} (${html ? html.trim().length : 0} chars)`);
189
+ return new InformationalResponse(
190
+ `HTML extraction returned empty content from ${currentUrl}`,
191
+ 'The page may be blocking evaluation via Content Security Policy (CSP), the page has not fully rendered, or the page uses a sandboxed context that prevents DOM reading.',
192
+ [
193
+ "Use MCPBrowser's browser_take_screenshot to verify the page is visually loaded",
194
+ "Use MCPBrowser's browser_execute_javascript with a simple script like 'document.title' to test page accessibility",
195
+ "Try MCPBrowser's browser_fetch_webpage to reload the page",
196
+ "Wait and retry — the page may still be rendering"
197
+ ]
198
+ );
199
+ }
162
200
 
163
201
  logger.info(`browser_get_current_html completed: got HTML from ${currentUrl}`);
164
202
 
@@ -172,7 +210,8 @@ export async function getCurrentHtml({ url, removeUnnecessaryHTML = true }) {
172
210
  "Use MCPBrowser's browser_take_screenshot if page layout or visual content is hard to understand from HTML",
173
211
  "Use MCPBrowser's browser_close_tab to free resources when done"
174
212
  ],
175
- getRecommendedPlugins(currentUrl, html)
213
+ getRecommendedPlugins(currentUrl, html),
214
+ formData
176
215
  );
177
216
  } catch (err) {
178
217
  logger.error(`browser_get_current_html failed: ${err.message}`);
package/src/core/html.js CHANGED
@@ -70,8 +70,9 @@ export function cleanHtml(html) {
70
70
  // Remove event handler attributes (onclick, onload, etc.)
71
71
  cleaned = cleaned.replace(/\s+on[a-z]+\s*=\s*["'][^"']*["']/gi, '');
72
72
 
73
- // Remove role attributes
74
- cleaned = cleaned.replace(/\s+role=["'][^"']*["']/gi, '');
73
+ // Keep role attributes — they're semantically valuable for LLM understanding
74
+ // and enable stable selectors like [role="main"], [role="navigation"]
75
+ // cleaned = cleaned.replace(/\s+role=["'][^"']*["']/gi, '');
75
76
 
76
77
  // Remove aria-* attributes
77
78
  cleaned = cleaned.replace(/\s+aria-[a-z0-9-]+=["'][^"']*["']/gi, '');
package/src/core/page.js CHANGED
@@ -475,23 +475,52 @@ async function waitForNavigationToSettle(page) {
475
475
  * settle and retries once.
476
476
  * @param {Page} page - The Puppeteer page instance
477
477
  * @param {boolean} removeUnnecessaryHTML - Whether to clean the HTML
478
+ * @param {string|null} [selector=null] - CSS selector to extract a DOM subtree instead of full page
478
479
  * @returns {Promise<string>} The processed HTML
479
480
  */
480
- export async function extractAndProcessHtml(page, removeUnnecessaryHTML) {
481
+ export async function extractAndProcessHtml(page, removeUnnecessaryHTML, selector = null) {
481
482
  let html;
483
+
484
+ const extractFn = selector
485
+ ? (sel) => {
486
+ const els = document.querySelectorAll(sel);
487
+ if (!els.length) return null;
488
+ return Array.from(els).map(el => el.outerHTML).join('\n');
489
+ }
490
+ : () => document.documentElement?.outerHTML || "";
491
+
492
+ const extractArg = selector || undefined;
493
+
482
494
  try {
483
- html = await page.evaluate(() => document.documentElement?.outerHTML || "");
495
+ html = await page.evaluate(extractFn, extractArg);
484
496
  } catch (err) {
485
497
  if (isNavigationError(err)) {
486
498
  logger.debug('Late navigation during HTML extraction, waiting for settle...');
487
499
  await waitForNavigationToSettle(page);
488
500
  // Re-run page readiness — the new page may be a SPA that needs rendering time
489
501
  await waitForPageReady(page);
490
- html = await page.evaluate(() => document.documentElement?.outerHTML || "");
502
+ html = await page.evaluate(extractFn, extractArg);
491
503
  } else {
492
504
  throw err;
493
505
  }
494
506
  }
507
+
508
+ // If selector matched nothing, fall back to full page with a note
509
+ if (selector && html === null) {
510
+ logger.debug(`Selector "${selector}" matched no elements, falling back to full page`);
511
+ try {
512
+ html = await page.evaluate(() => document.documentElement?.outerHTML || "");
513
+ } catch (err) {
514
+ if (isNavigationError(err)) {
515
+ await waitForNavigationToSettle(page);
516
+ await waitForPageReady(page);
517
+ html = await page.evaluate(() => document.documentElement?.outerHTML || "");
518
+ } else {
519
+ throw err;
520
+ }
521
+ }
522
+ html = `<!-- selector "${selector}" matched no elements; returning full page -->\n` + html;
523
+ }
495
524
 
496
525
  let processedHtml;
497
526
  if (removeUnnecessaryHTML) {
@@ -501,5 +530,12 @@ export async function extractAndProcessHtml(page, removeUnnecessaryHTML) {
501
530
  processedHtml = enrichHtml(html, page.url());
502
531
  }
503
532
 
533
+ // Warn when response is very large — the agent should use the selector parameter
534
+ // to scope extraction to a DOM subtree instead of fetching the entire page.
535
+ const htmlByteLength = new TextEncoder().encode(processedHtml).length;
536
+ if (htmlByteLength > 500_000) {
537
+ logger.warn(`Large HTML response (${(htmlByteLength / 1024).toFixed(0)}KB). Consider using the "selector" parameter to extract a specific DOM subtree instead of the full page.`);
538
+ }
539
+
504
540
  return processedHtml;
505
541
  }
@@ -32,6 +32,7 @@ import { takeScreenshot, TAKE_SCREENSHOT_TOOL } from './actions/take-screenshot.
32
32
  import { scrollPage, SCROLL_PAGE_TOOL } from './actions/scroll-page.js';
33
33
  import { executeJavascript, EXECUTE_JAVASCRIPT_TOOL } from './actions/execute-javascript.js';
34
34
  import { navigateHistory, NAVIGATE_HISTORY_TOOL } from './actions/navigate-history.js';
35
+ import { detectForms, DETECT_FORMS_TOOL } from './actions/detect-forms.js';
35
36
 
36
37
  // Import plugin dispatch tools
37
38
  import { pluginAction, PLUGIN_ACTION_TOOL } from './actions/plugin-action.js';
@@ -80,6 +81,7 @@ async function main() {
80
81
  TAKE_SCREENSHOT_TOOL,
81
82
  SCROLL_PAGE_TOOL,
82
83
  NAVIGATE_HISTORY_TOOL,
84
+ DETECT_FORMS_TOOL,
83
85
  PLUGIN_INFO_TOOL,
84
86
  PLUGIN_ACTION_TOOL
85
87
  ];
@@ -146,6 +148,10 @@ async function main() {
146
148
  result = await navigateHistory(safeArgs);
147
149
  break;
148
150
 
151
+ case "browser_detect_forms":
152
+ result = await detectForms(safeArgs);
153
+ break;
154
+
149
155
  case "browser_plugin_info":
150
156
  result = pluginInfo(safeArgs);
151
157
  break;
@@ -213,6 +219,7 @@ export {
213
219
  takeScreenshot,
214
220
  scrollPage,
215
221
  navigateHistory,
222
+ detectForms,
216
223
  handleAcceptEula,
217
224
  // CLI exports
218
225
  isCliMode,