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 +1 -1
- package/src/actions/click-element.js +28 -5
- package/src/actions/detect-forms.js +502 -0
- package/src/actions/execute-javascript.js +18 -0
- package/src/actions/fetch-page.js +32 -8
- package/src/actions/get-current-html.js +46 -7
- package/src/core/html.js +3 -2
- package/src/core/page.js +39 -3
- package/src/mcp-browser.js +7 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcpbrowser",
|
|
3
|
-
"version": "0.3.
|
|
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
|
-
|
|
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
|
-
//
|
|
74
|
-
|
|
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(
|
|
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(
|
|
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
|
}
|
package/src/mcp-browser.js
CHANGED
|
@@ -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,
|