mcpbrowser 0.3.46 → 0.3.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,6 +7,10 @@
7
7
 
8
8
  > ⚠️ **Security Notice:** MCPBrowser extracts webpage content and provides it to your AI agent (e.g., GitHub Copilot, Claude, Kiro, Antigravity), which then sends it to the LLM provider it uses (e.g., Anthropic, OpenAI, GitHub) for processing. Make sure you trust both your agent and the LLM provider — especially when accessing pages with sensitive or private data.
9
9
 
10
+ > 💡 **Why MCPBrowser over Puppeteer/Playwright MCP servers?** Puppeteer and Playwright are browser automation libraries — their MCP servers give agents raw, low-level browser commands. MCPBrowser uses Puppeteer under the hood and was built specifically for AI agents, adding an intelligence layer that handles the hard parts automatically.
11
+ >
12
+ > The agent gets clean HTML (90% smaller), automatic SPA detection (React, Vue, Angular), authentication flow handling (SSO, redirects, multi-step login), form discovery with multi-field filling, structured responses with next-step guidance, domain-based tab reuse, and instant DOM re-extraction without page reloads. Each MCPBrowser tool call replaces 5-8 raw browser automation calls — a typical 4-step workflow in MCPBrowser would take 20+ calls with Puppeteer/Playwright MCP, saving tokens and making the agent significantly faster. [See full comparison below.](#why-mcpbrowser-over-puppeteerplaywright-mcp-servers)
13
+
10
14
  **MCPBrowser is an MCP browser server that gives AI assistants the ability to browse web pages using a real Chrome, Edge, or Brave browser.** This browser-based MCP server fetches any web page — especially those protected by authentication, CAPTCHAs, anti-bot protection, or requiring JavaScript rendering. Uses your real browser for web automation so you can log in normally, then automatically extracts content. Works with corporate SSO, login forms, Cloudflare, and JavaScript-heavy sites (SPAs, dashboards).
11
15
 
12
16
  This is an [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) server using [stdio transport](https://modelcontextprotocol.io/docs/concepts/transports#stdio). Your AI assistant uses this web browser MCP server when standard HTTP requests fail — pages requiring authentication, CAPTCHA protection, or heavy JavaScript (SPAs). Once connected, the browser MCP server can navigate through websites, interact with elements, and send HTML back to the AI assistant. This gives your AI the ability to browse the web just like you do.
@@ -20,9 +24,26 @@ Example workflow for AI assistant to use MCPBrowser
20
24
  4. browser_get_current_html → Extract the content after login
21
25
  ```
22
26
 
27
+ ## Why MCPBrowser over Puppeteer/Playwright MCP servers?
28
+
29
+ Puppeteer and Playwright are browser automation libraries — their MCP servers expose low-level browser commands and the agent has to handle SPAs, auth flows, messy HTML, and edge cases on its own. **MCPBrowser was built specifically for AI agents.** It uses Puppeteer under the hood and adds an intelligence layer so the agent can focus on the task instead of fighting the browser.
30
+
31
+ | | MCPBrowser | Puppeteer/Playwright MCP |
32
+ |---|---|---|
33
+ | **HTML output** | Clean, LLM-optimized (~90% smaller) — strips scripts, styles, SVGs, tracking attrs, converts relative URLs | Raw DOM |
34
+ | **SPA support** | Auto-detects React, Vue, Angular, Svelte, Next.js, Nuxt — applies framework-aware wait strategies | Agent must configure waits manually |
35
+ | **Authentication** | Detects login pages, SSO redirects, multi-step auth — follows redirect chains, two-phase timeouts (5s SSO → 20min manual) | Agent must script each auth step |
36
+ | **Form interaction** | `browser_detect_forms` discovers all fields, labels, constraints; `browser_type_text` fills multiple fields at once | One field at a time, manual selectors |
37
+ | **Response format** | Typed, structured with `nextSteps` guidance — soft vs hard failure distinction with recovery actions | Raw results, generic errors |
38
+ | **Tab management** | Domain-pooled — reuses tabs, survives browser reconnection | New context per request |
39
+ | **DOM re-extraction** | `browser_get_current_html` — instant, no reload (10-50x faster) | Must re-fetch full page |
40
+ | **Plugin system** | Detects known sites by URL/DOM patterns, offers site-specific actions with confidence scoring | N/A |
41
+ | **Built for** | AI agents | Browser test automation |
42
+ | **Agent efficiency** | 1 tool call replaces 5-8 raw browser calls — a 4-step login flow takes 4 calls instead of 20+, saving tokens and round-trips | Each step (navigate, wait, query, type, click) is a separate call |
23
43
 
24
44
  ## Contents
25
45
 
46
+ - [Why MCPBrowser over Puppeteer/Playwright MCP servers?](#why-mcpbrowser-over-puppeteerplaywright-mcp-servers)
26
47
  - [Requirements](#requirements)
27
48
  - [Installation](#installation)
28
49
  - [VS Code Extension](#option-1-vs-code-extension)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcpbrowser",
3
- "version": "0.3.46",
3
+ "version": "0.3.48",
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
+ }
@@ -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
 
@@ -78,7 +86,8 @@ export const FETCH_WEBPAGE_TOOL = {
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." },
81
- 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 }
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 }
82
91
  },
83
92
  required: ["url"],
84
93
  additionalProperties: false
@@ -88,6 +97,9 @@ export const FETCH_WEBPAGE_TOOL = {
88
97
  properties: {
89
98
  currentUrl: { type: "string", description: "Final URL after any redirects" },
90
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" },
91
103
  nextSteps: {
92
104
  type: "array",
93
105
  items: { type: "string" },
@@ -123,7 +135,7 @@ export const FETCH_WEBPAGE_TOOL = {
123
135
  * @param {number} [params.postLoadWait=0] - Additional milliseconds to wait after page load before extracting HTML
124
136
  * @returns {Promise<Object>} Result object with success status, URL, HTML content, or error details
125
137
  */
126
- export async function fetchPage({ url, browser = '', removeUnnecessaryHTML = true, selector = null, postLoadWait = 0 }) {
138
+ export async function fetchPage({ url, browser = '', removeUnnecessaryHTML = true, selector = null, postLoadWait = 0, detectForms = false }) {
127
139
  logger.info(`browser_fetch_webpage called: url=${url}`);
128
140
 
129
141
  // Handle missing URL with environment variable fallback
@@ -151,7 +163,7 @@ export async function fetchPage({ url, browser = '', removeUnnecessaryHTML = tru
151
163
 
152
164
  // Queue this request - processed sequentially, one at a time
153
165
  return queueRequest(async () => {
154
- return await doFetchPage({ url, browser, removeUnnecessaryHTML, selector, postLoadWait });
166
+ return await doFetchPage({ url, browser, removeUnnecessaryHTML, selector, postLoadWait, detectForms });
155
167
  });
156
168
  }
157
169
 
@@ -159,7 +171,7 @@ export async function fetchPage({ url, browser = '', removeUnnecessaryHTML = tru
159
171
  * Internal function that does the actual page fetching.
160
172
  * Called by the queue processor - only one runs at a time.
161
173
  */
162
- async function doFetchPage({ url, browser, removeUnnecessaryHTML, selector, postLoadWait }) {
174
+ async function doFetchPage({ url, browser, removeUnnecessaryHTML, selector, postLoadWait, detectForms }) {
163
175
  const originalHostname = new URL(url).hostname;
164
176
 
165
177
  // Ensure browser connection
@@ -218,6 +230,16 @@ async function doFetchPage({ url, browser, removeUnnecessaryHTML, selector, post
218
230
  // Extract and process HTML
219
231
  const processedHtml = await extractAndProcessHtml(page, removeUnnecessaryHTML, selector);
220
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
+ }
242
+
221
243
  logger.info(`browser_fetch_webpage completed: ${page.url()}`);
222
244
 
223
245
  // Check for non-2xx HTTP status codes
@@ -237,7 +259,8 @@ async function doFetchPage({ url, browser, removeUnnecessaryHTML, selector, post
237
259
  "Use MCPBrowser's browser_take_screenshot if page has charts, images, or complex visual layout that's hard to understand from HTML",
238
260
  "Use MCPBrowser's browser_close_tab when finished to free browser resources"
239
261
  ],
240
- getRecommendedPlugins(page.url(), processedHtml)
262
+ getRecommendedPlugins(page.url(), processedHtml),
263
+ formData
241
264
  );
242
265
  } catch (err) {
243
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
 
@@ -70,7 +78,8 @@ export const GET_CURRENT_HTML_TOOL = {
70
78
  properties: {
71
79
  url: { type: "string", description: "The URL of the page (must match a previously fetched page)" },
72
80
  removeUnnecessaryHTML: { type: "boolean", description: "Remove Unnecessary HTML for size reduction by 90%.", default: true },
73
- 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." }
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 }
74
83
  },
75
84
  required: ["url"],
76
85
  additionalProperties: false
@@ -80,6 +89,9 @@ export const GET_CURRENT_HTML_TOOL = {
80
89
  properties: {
81
90
  currentUrl: { type: "string", description: "Current page URL" },
82
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" },
83
95
  nextSteps: {
84
96
  type: "array",
85
97
  items: { type: "string" },
@@ -108,7 +120,7 @@ export const GET_CURRENT_HTML_TOOL = {
108
120
  * @param {boolean} [params.removeUnnecessaryHTML=true] - Whether to clean HTML
109
121
  * @returns {Promise<Object>} Result object with current HTML
110
122
  */
111
- export async function getCurrentHtml({ url, removeUnnecessaryHTML = true, selector = null }) {
123
+ export async function getCurrentHtml({ url, removeUnnecessaryHTML = true, selector = null, detectForms = false }) {
112
124
  const startTime = Date.now();
113
125
  logger.info(`browser_get_current_html called: url=${url}${selector ? ` selector=${selector}` : ''}`);
114
126
 
@@ -161,6 +173,16 @@ export async function getCurrentHtml({ url, removeUnnecessaryHTML = true, select
161
173
  const currentUrl = page.url();
162
174
  const html = await extractAndProcessHtml(page, removeUnnecessaryHTML, selector);
163
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
+
164
186
  // Detect empty/near-empty HTML extraction (e.g., CSP blocking page.evaluate)
165
187
  if (!html || html.trim().length < 100) {
166
188
  logger.warn(`browser_get_current_html: HTML extraction returned empty/minimal content from ${currentUrl} (${html ? html.trim().length : 0} chars)`);
@@ -188,7 +210,8 @@ export async function getCurrentHtml({ url, removeUnnecessaryHTML = true, select
188
210
  "Use MCPBrowser's browser_take_screenshot if page layout or visual content is hard to understand from HTML",
189
211
  "Use MCPBrowser's browser_close_tab to free resources when done"
190
212
  ],
191
- getRecommendedPlugins(currentUrl, html)
213
+ getRecommendedPlugins(currentUrl, html),
214
+ formData
192
215
  );
193
216
  } catch (err) {
194
217
  logger.error(`browser_get_current_html failed: ${err.message}`);
@@ -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,