mcpbrowser 0.2.35 → 0.2.37
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/LICENSE +21 -0
- package/README.md +149 -4
- package/package.json +4 -2
- package/server.json +1 -1
- package/src/actions/click-element.js +176 -0
- package/src/actions/close-tab.js +100 -0
- package/src/actions/fetch-page.js +130 -0
- package/src/actions/get-current-html.js +53 -0
- package/src/actions/type-text.js +107 -0
- package/src/core/auth.js +130 -0
- package/src/core/browser.js +256 -0
- package/src/core/html.js +136 -0
- package/src/core/page.js +122 -0
- package/src/mcp-browser.js +147 -818
- package/src/utils.js +78 -0
- package/tests/README.md +147 -48
- package/tests/actions/click-element.test.js +75 -0
- package/tests/actions/close-tab.test.js +368 -0
- package/tests/{integration.test.js → actions/fetch-page.test.js} +57 -11
- package/tests/actions/get-current-html.test.js +101 -0
- package/tests/actions/type-text.test.js +84 -0
- package/tests/{auth-flow.test.js → core/auth.test.js} +1 -1
- package/tests/{domain-tab-pooling.test.js → core/browser.test.js} +1 -1
- package/tests/{prepare-html.test.js → core/html.test.js} +46 -26
- package/tests/{redirect-detection.test.js → core/page.test.js} +1 -1
- package/tests/mcp-browser.test.js +190 -0
- package/tests/run-all.js +100 -33
- package/tests/run-unit.js +98 -0
- package/tests/mcp-server.test.js +0 -154
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 cherchyk
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -20,7 +20,7 @@ Or search "MCPBrowser" in VS Code Extensions view.
|
|
|
20
20
|
**From GitHub Release:**
|
|
21
21
|
Download from [GitHub Releases](https://github.com/cherchyk/MCPBrowser/releases):
|
|
22
22
|
```bash
|
|
23
|
-
code --install-extension mcpbrowser-0.2.
|
|
23
|
+
code --install-extension mcpbrowser-0.2.37.vsix
|
|
24
24
|
```
|
|
25
25
|
|
|
26
26
|
The extension automatically:
|
|
@@ -31,7 +31,7 @@ The extension automatically:
|
|
|
31
31
|
📦 [View on Marketplace](https://marketplace.visualstudio.com/items?itemName=cherchyk.mcpbrowser)
|
|
32
32
|
|
|
33
33
|
### Option 2: npm Package (Recommended for Manual Setup)
|
|
34
|
-
Published on npm as [mcpbrowser](https://www.npmjs.com/package/mcpbrowser) v0.2.
|
|
34
|
+
Published on npm as [mcpbrowser](https://www.npmjs.com/package/mcpbrowser) v0.2.37.
|
|
35
35
|
|
|
36
36
|
Add to your `mcp.json`:
|
|
37
37
|
```jsonc
|
|
@@ -48,7 +48,7 @@ Add to your `mcp.json`:
|
|
|
48
48
|
- Mac/Linux: `~/.config/Code/User/mcp.json`
|
|
49
49
|
|
|
50
50
|
### Option 3: MCP Registry
|
|
51
|
-
Available in the [MCP Registry](https://registry.modelcontextprotocol.io/) as `io.github.cherchyk/browser` v0.2.
|
|
51
|
+
Available in the [MCP Registry](https://registry.modelcontextprotocol.io/) as `io.github.cherchyk/browser` v0.2.37.
|
|
52
52
|
|
|
53
53
|
Search for "browser" in the registry to find configuration instructions.
|
|
54
54
|
|
|
@@ -132,7 +132,7 @@ Restart VS Code or reload the window for the changes to take effect.
|
|
|
132
132
|
In Claude Code or Copilot Chat, you should see the `MCPBrowser` server listed. Ask it to fetch an authenticated URL and it will drive your signed-in Chrome session.
|
|
133
133
|
|
|
134
134
|
## How it works
|
|
135
|
-
- Tool `
|
|
135
|
+
- Tool `fetch_webpage` (inside the MCP server) drives your live Chrome/Edge (DevTools Protocol) so it inherits your auth cookies, returning `html` (truncated up to 2M chars) for analysis.
|
|
136
136
|
- **Smart confirmation**: AI assistant asks for confirmation ONLY on first request to a new domain - explains browser will open for authentication. Subsequent requests to same domain work automatically (session preserved).
|
|
137
137
|
- **Domain-aware tab reuse**: Automatically reuses the same tab for URLs on the same domain, preserving authentication session. Different domains open new tabs.
|
|
138
138
|
- **Automatic page loading**: Waits for network idle (`networkidle0`) by default, ensuring JavaScript-heavy pages (SPAs, dashboards) fully load before returning content.
|
|
@@ -141,6 +141,151 @@ In Claude Code or Copilot Chat, you should see the `MCPBrowser` server listed. A
|
|
|
141
141
|
- **Smart timeouts**: 60s default for page fetch, 10 min for auth redirects. Tabs stay open indefinitely for reuse (no auto-close).
|
|
142
142
|
- The AI assistant's LLM invokes this tool via MCP; this repo itself does not run an LLM.
|
|
143
143
|
|
|
144
|
+
## 🎯 Interactive Features (NEW!)
|
|
145
|
+
|
|
146
|
+
MCPBrowser now supports **human-like interaction** with web pages! You can click buttons, fill forms, and interact with dynamic content just like a human would.
|
|
147
|
+
|
|
148
|
+
### Available Tools
|
|
149
|
+
|
|
150
|
+
#### 1. `click_element` - Click on page elements
|
|
151
|
+
Click on any element - buttons, links, divs with onclick handlers, or any clickable element.
|
|
152
|
+
|
|
153
|
+
**Parameters:**
|
|
154
|
+
- `url` (required): URL of the page (must be already loaded via `fetch_webpage`)
|
|
155
|
+
- `selector` (optional): CSS selector for the element (e.g., `#submit-btn`, `.login-button`)
|
|
156
|
+
- `text` (optional): Text content to search for if selector not provided (e.g., "Sign In", "Submit")
|
|
157
|
+
- `timeout` (optional): Maximum wait time in milliseconds (default: 30000)
|
|
158
|
+
|
|
159
|
+
**Example:**
|
|
160
|
+
```javascript
|
|
161
|
+
// Click by selector
|
|
162
|
+
{ url: "https://example.com", selector: "#login-button" }
|
|
163
|
+
|
|
164
|
+
// Click by text content
|
|
165
|
+
{ url: "https://example.com", text: "Sign In" }
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
#### 2. `type_text` - Type text into input fields
|
|
169
|
+
Type text into input fields, textareas, or any editable element with human-like typing simulation.
|
|
170
|
+
|
|
171
|
+
**Parameters:**
|
|
172
|
+
- `url` (required): URL of the page
|
|
173
|
+
- `selector` (required): CSS selector for the input element
|
|
174
|
+
- `text` (required): Text to type
|
|
175
|
+
- `clear` (optional): Clear existing text first (default: true)
|
|
176
|
+
- `delay` (optional): Delay between keystrokes in ms (default: 50)
|
|
177
|
+
- `timeout` (optional): Maximum wait time in milliseconds (default: 30000)
|
|
178
|
+
|
|
179
|
+
**Example:**
|
|
180
|
+
```javascript
|
|
181
|
+
{
|
|
182
|
+
url: "https://example.com",
|
|
183
|
+
selector: "#username",
|
|
184
|
+
text: "myuser@example.com"
|
|
185
|
+
}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
#### 3. `get_interactive_elements` - List all interactive elements
|
|
189
|
+
Discover all clickable and interactive elements on the page - links, buttons, inputs, elements with onclick handlers.
|
|
190
|
+
|
|
191
|
+
**Parameters:**
|
|
192
|
+
- `url` (required): URL of the page
|
|
193
|
+
- `limit` (optional): Maximum number of elements to return (default: 50)
|
|
194
|
+
|
|
195
|
+
**Returns:** Array of elements with details (tag, text, selector, href, type, id, hasOnClick, role)
|
|
196
|
+
|
|
197
|
+
**Example:**
|
|
198
|
+
```javascript
|
|
199
|
+
{ url: "https://example.com", limit: 20 }
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
#### 4. `wait_for_element` - Wait for element to appear
|
|
203
|
+
Wait for an element to appear on the page (useful after clicking something that triggers dynamic content).
|
|
204
|
+
|
|
205
|
+
**Parameters:**
|
|
206
|
+
- `url` (required): URL of the page
|
|
207
|
+
- `selector` (optional): CSS selector to wait for
|
|
208
|
+
- `text` (optional): Text content to wait for if selector not provided
|
|
209
|
+
- `timeout` (optional): Maximum wait time in milliseconds (default: 30000)
|
|
210
|
+
|
|
211
|
+
**Example:**
|
|
212
|
+
```javascript
|
|
213
|
+
{ url: "https://example.com", selector: ".success-message" }
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
#### 5. `get_current_html` - Get updated HTML without reloading (NEW! 🚀)
|
|
217
|
+
**Efficiently** get the current HTML state from an already-loaded page **without navigation or reloading**. Use this after interactions (`click_element`, `type_text`, `wait_for_element`) to get the updated DOM state.
|
|
218
|
+
|
|
219
|
+
**Why use this instead of `fetch_webpage`?**
|
|
220
|
+
- ⚡ **Much faster** - no page reload, just extracts current DOM
|
|
221
|
+
- 🎯 **More accurate** - captures exact state after interaction
|
|
222
|
+
- 💾 **Preserves state** - doesn't lose dynamic JavaScript state
|
|
223
|
+
- 🔄 **Efficient** - perfect for interactive workflows
|
|
224
|
+
|
|
225
|
+
**Parameters:**
|
|
226
|
+
- `url` (required): URL of the page (must be already loaded via `fetch_webpage`)
|
|
227
|
+
- `removeUnnecessaryHTML` (optional): Clean HTML for size reduction (default: true, ~90% smaller)
|
|
228
|
+
|
|
229
|
+
**Example:**
|
|
230
|
+
```javascript
|
|
231
|
+
{ url: "https://example.com", removeUnnecessaryHTML: true }
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
**Performance comparison:**
|
|
235
|
+
```
|
|
236
|
+
fetch_webpage after interaction: 2-5 seconds (reloads page)
|
|
237
|
+
get_current_html after interaction: 0.1-0.3 seconds (just extracts HTML) ✅
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
### Usage Workflow
|
|
241
|
+
|
|
242
|
+
**Efficient interactive workflow (NEW!):**
|
|
243
|
+
|
|
244
|
+
1. **First, fetch the page:**
|
|
245
|
+
```javascript
|
|
246
|
+
fetch_webpage({ url: "https://example.com/login" })
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
2. **Discover interactive elements:**
|
|
250
|
+
```javascript
|
|
251
|
+
get_interactive_elements({ url: "https://example.com/login" })
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
3. **Fill in the form:**
|
|
255
|
+
```javascript
|
|
256
|
+
type_text({ url: "https://example.com/login", selector: "#username", text: "user@example.com" })
|
|
257
|
+
type_text({ url: "https://example.com/login", selector: "#password", text: "mypassword" })
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
4. **Click the submit button:**
|
|
261
|
+
```javascript
|
|
262
|
+
click_element({ url: "https://example.com/login", selector: "#submit" })
|
|
263
|
+
// or by text: click_element({ url: "https://example.com/login", text: "Sign In" })
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
5. **Wait for content to load:**
|
|
267
|
+
```javascript
|
|
268
|
+
wait_for_element({ url: "https://example.com/dashboard", selector: ".dashboard-content" })
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
6. **Get updated HTML efficiently (no reload!):**
|
|
272
|
+
```javascript
|
|
273
|
+
get_current_html({ url: "https://example.com/dashboard" })
|
|
274
|
+
// ✅ Fast! Just extracts current DOM without reloading the page
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
**When to use `get_current_html` vs `fetch_webpage`:**
|
|
278
|
+
- Use `fetch_webpage`: Initial page load, navigation to new URLs, auth flows
|
|
279
|
+
- Use `get_current_html`: After clicks, form fills, waits - when page is already loaded ✅
|
|
280
|
+
|
|
281
|
+
### Key Features
|
|
282
|
+
- ✅ Works with **any clickable element** - not just `<a>` tags (buttons, divs with onclick, etc.)
|
|
283
|
+
- ✅ **Text-based selection** - click elements by their visible text
|
|
284
|
+
- ✅ **Human-like typing** - simulates natural keystroke delays
|
|
285
|
+
- ✅ **Automatic scrolling** - scrolls elements into view before interaction
|
|
286
|
+
- ✅ **Smart element detection** - finds the most specific match when searching by text
|
|
287
|
+
- ✅ **Session preservation** - all interactions happen in the same browser tab
|
|
288
|
+
|
|
144
289
|
## Auth-assisted fetch flow
|
|
145
290
|
- AI assistant can call with just the URL, or with no params if you set an env default (`DEFAULT_FETCH_URL` or `MCP_DEFAULT_FETCH_URL`). By default tabs stay open indefinitely for reuse (domain-aware).
|
|
146
291
|
- First call opens the tab and leaves it open so you can sign in. No extra params needed.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcpbrowser",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.37",
|
|
4
4
|
"mcpName": "io.github.cherchyk/mcpbrowser",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "MCP server for in-browser web page fetching using Chrome DevTools Protocol",
|
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
},
|
|
11
11
|
"scripts": {
|
|
12
12
|
"mcp": "node src/mcp-browser.js",
|
|
13
|
-
"test": "node
|
|
13
|
+
"test": "node tests/run-all.js",
|
|
14
|
+
"test:unit": "node tests/run-unit.js",
|
|
15
|
+
"test:ci": "node tests/run-unit.js"
|
|
14
16
|
},
|
|
15
17
|
"keywords": [
|
|
16
18
|
"mcp",
|
package/server.json
CHANGED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* click.js - Click element action
|
|
3
|
+
*
|
|
4
|
+
* This function handles two distinct use cases:
|
|
5
|
+
*
|
|
6
|
+
* 1. NAVIGATION/CONTENT UPDATES (returnHtml: true, default):
|
|
7
|
+
* - Clicks the element
|
|
8
|
+
* - Waits for page stability (network idle, DOM updates)
|
|
9
|
+
* - Returns the updated HTML content
|
|
10
|
+
* - Use for: Links, navigation buttons, SPA route changes (e.g., Gmail folders)
|
|
11
|
+
* - Takes 3-8 seconds due to stability wait
|
|
12
|
+
*
|
|
13
|
+
* 2. FAST FORM INTERACTIONS (returnHtml: false):
|
|
14
|
+
* - Clicks the element
|
|
15
|
+
* - Minimal 300ms wait
|
|
16
|
+
* - Returns success without HTML
|
|
17
|
+
* - Use for: Checkboxes, radio buttons, form fields that don't navigate
|
|
18
|
+
* - Takes <1 second
|
|
19
|
+
*
|
|
20
|
+
* Why this design?
|
|
21
|
+
* - Solves SPA navigation issue: URL hash changes instantly (#inbox → #trash),
|
|
22
|
+
* but content loads asynchronously. Without waiting, we'd return old content.
|
|
23
|
+
* - Consistent with fetch_webpage: Both wait for stability and return HTML
|
|
24
|
+
* - Flexible: Can disable waiting for fast form interactions
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { getBrowser, domainPages } from '../core/browser.js';
|
|
28
|
+
import { extractAndProcessHtml, waitForPageStability } from '../core/page.js';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Click on an element on the page
|
|
32
|
+
*
|
|
33
|
+
* @param {Object} params - Click parameters
|
|
34
|
+
* @param {string} params.url - The URL of the page to interact with
|
|
35
|
+
* @param {string} [params.selector] - CSS selector for the element to click
|
|
36
|
+
* @param {string} [params.text] - Text content to search for (alternative to selector)
|
|
37
|
+
* @param {number} [params.waitForElementTimeout=30000] - Maximum time (ms) to wait for element to appear before failing
|
|
38
|
+
* @param {boolean} [params.returnHtml=true] - Whether to wait for stability and return HTML
|
|
39
|
+
* @param {boolean} [params.removeUnnecessaryHTML=true] - Whether to clean HTML (only if returnHtml is true)
|
|
40
|
+
* @param {number} [params.postClickWait=1000] - Milliseconds to wait after click for SPAs to render dynamic content
|
|
41
|
+
* @returns {Promise<Object>} Result object with success status and details
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* // Navigate to Gmail Bin folder (waits for emails to load, returns HTML)
|
|
45
|
+
* const result = await clickElement({ url: gmailUrl, text: "Bin" });
|
|
46
|
+
* console.log(result.html); // Contains bin emails
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* // Fast checkbox click (no wait, no HTML)
|
|
50
|
+
* const result = await clickElement({
|
|
51
|
+
* url: formUrl,
|
|
52
|
+
* selector: "#agree-checkbox",
|
|
53
|
+
* returnHtml: false
|
|
54
|
+
* });
|
|
55
|
+
*/
|
|
56
|
+
export async function clickElement({ url, selector, text, waitForElementTimeout = 30000, returnHtml = true, removeUnnecessaryHTML = true, postClickWait = 1000 }) {
|
|
57
|
+
if (!url) {
|
|
58
|
+
throw new Error("url parameter is required");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!selector && !text) {
|
|
62
|
+
throw new Error("Either selector or text parameter is required");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let hostname;
|
|
66
|
+
try {
|
|
67
|
+
hostname = new URL(url).hostname;
|
|
68
|
+
} catch {
|
|
69
|
+
throw new Error(`Invalid URL: ${url}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const browser = await getBrowser();
|
|
73
|
+
let page = domainPages.get(hostname);
|
|
74
|
+
|
|
75
|
+
if (!page || page.isClosed()) {
|
|
76
|
+
return {
|
|
77
|
+
success: false,
|
|
78
|
+
error: `No open page found for ${hostname}. Please fetch the page first using fetch_webpage.`
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
let elementHandle;
|
|
84
|
+
|
|
85
|
+
if (selector) {
|
|
86
|
+
// Use CSS selector
|
|
87
|
+
await page.waitForSelector(selector, { timeout: waitForElementTimeout, visible: true });
|
|
88
|
+
elementHandle = await page.$(selector);
|
|
89
|
+
} else {
|
|
90
|
+
// Search by text content
|
|
91
|
+
await page.waitForFunction(
|
|
92
|
+
(searchText) => {
|
|
93
|
+
const elements = Array.from(document.querySelectorAll('*'));
|
|
94
|
+
return elements.some(el => {
|
|
95
|
+
const text = el.textContent?.trim();
|
|
96
|
+
return text && text.includes(searchText) && el.offsetParent !== null;
|
|
97
|
+
});
|
|
98
|
+
},
|
|
99
|
+
{ timeout: waitForElementTimeout },
|
|
100
|
+
text
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
elementHandle = await page.evaluateHandle((searchText) => {
|
|
104
|
+
const elements = Array.from(document.querySelectorAll('*'));
|
|
105
|
+
// Prioritize smaller elements (more specific matches)
|
|
106
|
+
const matches = elements.filter(el => {
|
|
107
|
+
const elText = el.textContent?.trim();
|
|
108
|
+
return elText && elText.includes(searchText) && el.offsetParent !== null;
|
|
109
|
+
});
|
|
110
|
+
matches.sort((a, b) => a.textContent.length - b.textContent.length);
|
|
111
|
+
return matches[0];
|
|
112
|
+
}, text);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (!elementHandle || !elementHandle.asElement()) {
|
|
116
|
+
return {
|
|
117
|
+
success: false,
|
|
118
|
+
error: selector ? `Element not found: ${selector}` : `Element with text "${text}" not found`
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Scroll element into view and click
|
|
123
|
+
// For automation, use instant scroll instead of smooth animation to avoid delays
|
|
124
|
+
await page.evaluate(el => el.scrollIntoView({ behavior: 'auto', block: 'center' }), elementHandle);
|
|
125
|
+
// original:
|
|
126
|
+
// Smooth scroll (commented out for performance):
|
|
127
|
+
// await page.evaluate(el => el.scrollIntoView({ behavior: 'smooth', block: 'center' }), elementHandle);
|
|
128
|
+
// await new Promise(r => setTimeout(r, 300)); // Brief delay after scroll
|
|
129
|
+
|
|
130
|
+
await elementHandle.click();
|
|
131
|
+
|
|
132
|
+
if (returnHtml) {
|
|
133
|
+
// Wait for page to stabilize (handles both navigation and SPA content updates)
|
|
134
|
+
// This ensures content is fully loaded before returning, just like fetch_webpage does
|
|
135
|
+
await waitForPageStability(page);
|
|
136
|
+
|
|
137
|
+
// Wait for SPAs to render dynamic content after click
|
|
138
|
+
if (postClickWait > 0) {
|
|
139
|
+
await new Promise(resolve => setTimeout(resolve, postClickWait));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const currentUrl = page.url();
|
|
143
|
+
const html = await extractAndProcessHtml(page, removeUnnecessaryHTML);
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
success: true,
|
|
147
|
+
message: selector ? `Clicked element: ${selector}` : `Clicked element with text: "${text}"`,
|
|
148
|
+
currentUrl,
|
|
149
|
+
html,
|
|
150
|
+
clicked: selector || `text:"${text}"`
|
|
151
|
+
};
|
|
152
|
+
} else {
|
|
153
|
+
// Wait for page to stabilize even for fast clicks (ensures JS has finished)
|
|
154
|
+
await waitForPageStability(page);
|
|
155
|
+
|
|
156
|
+
// Wait for SPAs to render dynamic content after click
|
|
157
|
+
if (postClickWait > 0) {
|
|
158
|
+
await new Promise(resolve => setTimeout(resolve, postClickWait));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const currentUrl = page.url();
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
success: true,
|
|
165
|
+
message: selector ? `Clicked element: ${selector}` : `Clicked element with text: "${text}"`,
|
|
166
|
+
currentUrl,
|
|
167
|
+
clicked: selector || `text:"${text}"`
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
} catch (err) {
|
|
171
|
+
return {
|
|
172
|
+
success: false,
|
|
173
|
+
error: `Failed to click element: ${err.message}`
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Close a tab for a specific domain
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { domainPages } from '../core/browser.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Closes the browser tab for the given URL's hostname and removes it from the tab pool.
|
|
9
|
+
* This forces a fresh session on the next visit to that hostname.
|
|
10
|
+
* @param {object} params - Parameters
|
|
11
|
+
* @param {string} params.url - The URL whose hostname tab should be closed
|
|
12
|
+
* @returns {Promise<object>} Result indicating success or failure
|
|
13
|
+
*/
|
|
14
|
+
export async function closeTab({ url }) {
|
|
15
|
+
try {
|
|
16
|
+
// Validate URL
|
|
17
|
+
if (!url || typeof url !== 'string') {
|
|
18
|
+
return {
|
|
19
|
+
success: false,
|
|
20
|
+
error: 'Invalid or missing URL parameter'
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Extract hostname from URL
|
|
25
|
+
let hostname;
|
|
26
|
+
try {
|
|
27
|
+
hostname = new URL(url).hostname;
|
|
28
|
+
} catch {
|
|
29
|
+
return {
|
|
30
|
+
success: false,
|
|
31
|
+
error: 'Invalid URL format'
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Check if we have a tab for this hostname
|
|
36
|
+
if (!domainPages.has(hostname)) {
|
|
37
|
+
// Hostname not found - try to find by actual page URL
|
|
38
|
+
// This handles redirects where tab was created with original hostname
|
|
39
|
+
// but now the page URL is different
|
|
40
|
+
let foundHostname = null;
|
|
41
|
+
for (const [key, page] of domainPages.entries()) {
|
|
42
|
+
try {
|
|
43
|
+
if (!page.isClosed() && page.url() === url) {
|
|
44
|
+
foundHostname = key;
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
} catch {
|
|
48
|
+
// Skip pages we can't access
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!foundHostname) {
|
|
53
|
+
return {
|
|
54
|
+
success: true,
|
|
55
|
+
hostname,
|
|
56
|
+
message: 'No open tab found for this hostname',
|
|
57
|
+
alreadyClosed: true
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Found the page by URL - use that hostname
|
|
62
|
+
hostname = foundHostname;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Get and close the page
|
|
66
|
+
const page = domainPages.get(hostname);
|
|
67
|
+
|
|
68
|
+
// Check if page is already closed
|
|
69
|
+
if (page.isClosed()) {
|
|
70
|
+
domainPages.delete(hostname);
|
|
71
|
+
return {
|
|
72
|
+
success: true,
|
|
73
|
+
hostname,
|
|
74
|
+
message: 'Tab was already closed',
|
|
75
|
+
alreadyClosed: true
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Close the page
|
|
80
|
+
await page.close();
|
|
81
|
+
|
|
82
|
+
// Remove from domain pool
|
|
83
|
+
domainPages.delete(hostname);
|
|
84
|
+
|
|
85
|
+
console.error(`[MCPBrowser] Closed tab for hostname: ${hostname}`);
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
success: true,
|
|
89
|
+
hostname,
|
|
90
|
+
message: `Successfully closed tab for ${hostname}`
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
} catch (error) {
|
|
94
|
+
console.error(`[MCPBrowser] Error closing tab:`, error);
|
|
95
|
+
return {
|
|
96
|
+
success: false,
|
|
97
|
+
error: error.message
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fetch.js - Main page fetching functionality
|
|
3
|
+
* Handles web page fetching with authentication flows and tab reuse
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { getBrowser, domainPages } from '../core/browser.js';
|
|
7
|
+
import { getOrCreatePage, navigateToUrl, extractAndProcessHtml, waitForPageStability } from '../core/page.js';
|
|
8
|
+
import { detectRedirectType, waitForAutoAuth, waitForManualAuth } from '../core/auth.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Fetch a web page using Chrome browser, with support for authentication flows and tab reuse.
|
|
12
|
+
* Reuses existing tabs per domain when possible. Handles authentication redirects by waiting
|
|
13
|
+
* for user to complete login (up to 10 minutes). Processes HTML to remove unnecessary elements
|
|
14
|
+
* and convert relative URLs to absolute.
|
|
15
|
+
* @param {Object} params - Fetch parameters
|
|
16
|
+
* @param {string} params.url - The URL to fetch
|
|
17
|
+
* @param {boolean} [params.removeUnnecessaryHTML=true] - Whether to clean HTML (removes scripts, styles, etc.)
|
|
18
|
+
* @param {number} [params.postLoadWait=1000] - Milliseconds to wait after page load for SPAs to render
|
|
19
|
+
* @returns {Promise<Object>} Result object with success status, URL, HTML content, or error details
|
|
20
|
+
*/
|
|
21
|
+
export async function fetchPage({ url, removeUnnecessaryHTML = true, postLoadWait = 1000 }) {
|
|
22
|
+
// Hardcoded smart defaults - use 'domcontentloaded' for fastest loading
|
|
23
|
+
// (waits for HTML parsed, not all resources loaded - much faster for SPAs)
|
|
24
|
+
const waitUntil = "domcontentloaded";
|
|
25
|
+
const navigationTimeout = 30000;
|
|
26
|
+
const authCompletionTimeout = 600000;
|
|
27
|
+
const reuseLastKeptPage = true;
|
|
28
|
+
|
|
29
|
+
if (!url) {
|
|
30
|
+
throw new Error("url parameter is required");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Parse hostname for domain-based tab reuse
|
|
34
|
+
let hostname;
|
|
35
|
+
try {
|
|
36
|
+
hostname = new URL(url).hostname;
|
|
37
|
+
} catch {
|
|
38
|
+
throw new Error(`Invalid URL: ${url}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const browser = await getBrowser();
|
|
42
|
+
let page = null;
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
// Get or create page for this domain
|
|
46
|
+
page = await getOrCreatePage(browser, hostname, reuseLastKeptPage);
|
|
47
|
+
|
|
48
|
+
// Navigate to URL with fallback strategy
|
|
49
|
+
await navigateToUrl(page, url, waitUntil, navigationTimeout);
|
|
50
|
+
|
|
51
|
+
const currentUrl = page.url();
|
|
52
|
+
const currentHostname = new URL(currentUrl).hostname;
|
|
53
|
+
console.error(`[MCPBrowser] Navigation completed: ${currentUrl}`);
|
|
54
|
+
|
|
55
|
+
// Detect redirect type and handle accordingly
|
|
56
|
+
const redirectInfo = detectRedirectType(url, hostname, currentUrl, currentHostname);
|
|
57
|
+
|
|
58
|
+
if (redirectInfo.type === 'requested_auth') {
|
|
59
|
+
console.error(`[MCPBrowser] User requested auth page directly, returning content`);
|
|
60
|
+
// Update domain mapping if needed
|
|
61
|
+
if (redirectInfo.currentHostname !== hostname) {
|
|
62
|
+
domainPages.delete(hostname);
|
|
63
|
+
domainPages.set(redirectInfo.currentHostname, page);
|
|
64
|
+
hostname = redirectInfo.currentHostname;
|
|
65
|
+
}
|
|
66
|
+
} else if (redirectInfo.type === 'permanent') {
|
|
67
|
+
console.error(`[MCPBrowser] Permanent redirect detected: ${hostname} → ${redirectInfo.currentHostname}`);
|
|
68
|
+
console.error(`[MCPBrowser] Accepting redirect and updating domain mapping`);
|
|
69
|
+
domainPages.delete(hostname);
|
|
70
|
+
domainPages.set(redirectInfo.currentHostname, page);
|
|
71
|
+
hostname = redirectInfo.currentHostname;
|
|
72
|
+
} else if (redirectInfo.type === 'auth') {
|
|
73
|
+
console.error(`[MCPBrowser] Authentication flow detected (${redirectInfo.flowType})`);
|
|
74
|
+
console.error(`[MCPBrowser] Current location: ${redirectInfo.currentUrl}`);
|
|
75
|
+
|
|
76
|
+
// Try auto-auth first
|
|
77
|
+
const autoAuthResult = await waitForAutoAuth(page, redirectInfo.hostname, redirectInfo.originalBase);
|
|
78
|
+
|
|
79
|
+
if (autoAuthResult.success) {
|
|
80
|
+
// Update hostname if changed
|
|
81
|
+
if (autoAuthResult.hostname !== hostname) {
|
|
82
|
+
domainPages.delete(hostname);
|
|
83
|
+
domainPages.set(autoAuthResult.hostname, page);
|
|
84
|
+
hostname = autoAuthResult.hostname;
|
|
85
|
+
}
|
|
86
|
+
} else {
|
|
87
|
+
// Wait for manual auth
|
|
88
|
+
const manualAuthResult = await waitForManualAuth(page, redirectInfo.hostname, redirectInfo.originalBase, authCompletionTimeout);
|
|
89
|
+
|
|
90
|
+
if (!manualAuthResult.success) {
|
|
91
|
+
return {
|
|
92
|
+
success: false,
|
|
93
|
+
error: manualAuthResult.error,
|
|
94
|
+
pageKeptOpen: true,
|
|
95
|
+
hint: manualAuthResult.hint
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Update hostname if changed
|
|
100
|
+
if (manualAuthResult.hostname !== hostname) {
|
|
101
|
+
domainPages.delete(hostname);
|
|
102
|
+
domainPages.set(manualAuthResult.hostname, page);
|
|
103
|
+
hostname = manualAuthResult.hostname;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Wait for page stability after auth
|
|
108
|
+
await waitForPageStability(page);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Wait for SPAs to render dynamic content after page load
|
|
112
|
+
if (postLoadWait > 0) {
|
|
113
|
+
await new Promise(resolve => setTimeout(resolve, postLoadWait));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Extract and process HTML
|
|
117
|
+
const processedHtml = await extractAndProcessHtml(page, removeUnnecessaryHTML);
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
success: true,
|
|
121
|
+
currentUrl: page.url(),
|
|
122
|
+
html: processedHtml
|
|
123
|
+
};
|
|
124
|
+
} catch (err) {
|
|
125
|
+
const hint = "Tab is left open. Complete sign-in there, then call fetch_webpage again with just the URL.";
|
|
126
|
+
return { success: false, error: err.message || String(err), pageKeptOpen: true, hint };
|
|
127
|
+
} finally {
|
|
128
|
+
// Tab always stays open - domain-aware reuse handles cleanup
|
|
129
|
+
}
|
|
130
|
+
}
|