mcpbrowser 0.3.19 → 0.3.20
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/accept-eula.js +188 -0
- package/src/actions/click-element.js +4 -4
- package/src/actions/fetch-page.js +5 -5
- package/src/actions/get-current-html.js +1 -1
- package/src/actions/scroll-page.js +5 -5
- package/src/actions/take-screenshot.js +1 -1
- package/src/actions/type-text.js +4 -4
- package/src/core/eula.js +120 -0
- package/src/core/logger.js +17 -1
- package/src/core/page.js +6 -6
- package/src/mcp-browser.js +17 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcpbrowser",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.20",
|
|
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.",
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* accept-eula.js - Accept the End User License Agreement
|
|
3
|
+
* This tool must be called before any other MCPBrowser tools can be used.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { MCPResponse } from '../core/responses.js';
|
|
7
|
+
import { acceptEula, isEulaAccepted, EULA_URL } from '../core/eula.js';
|
|
8
|
+
import logger from '../core/logger.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {import('@modelcontextprotocol/sdk/types.js').Tool} Tool
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// ============================================================================
|
|
15
|
+
// EULA CHECK HELPER
|
|
16
|
+
// ============================================================================
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Check if EULA acceptance is required before executing a tool.
|
|
20
|
+
* @param {string} toolName - The name of the tool being called
|
|
21
|
+
* @returns {Object|null} MCP-formatted response if EULA not accepted, null if OK to proceed
|
|
22
|
+
*/
|
|
23
|
+
export function requireEulaAcceptance(toolName) {
|
|
24
|
+
if (isEulaAccepted()) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
logger.info(`Tool ${toolName} requested - EULA acceptance required`);
|
|
29
|
+
return new EulaPendingResponse(
|
|
30
|
+
EULA_URL,
|
|
31
|
+
[],
|
|
32
|
+
toolName
|
|
33
|
+
).toMcpFormat();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ============================================================================
|
|
37
|
+
// RESPONSE CLASSES
|
|
38
|
+
// ============================================================================
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Response when EULA needs to be accepted (shown before acceptance)
|
|
42
|
+
*/
|
|
43
|
+
export class EulaPendingResponse extends MCPResponse {
|
|
44
|
+
/**
|
|
45
|
+
* @param {string} eulaUrl - URL to the EULA
|
|
46
|
+
* @param {string[]} nextSteps - Suggested next actions
|
|
47
|
+
* @param {string} [attemptedAction] - The tool the user tried to use (optional)
|
|
48
|
+
*/
|
|
49
|
+
constructor(eulaUrl, nextSteps, attemptedAction = null) {
|
|
50
|
+
super(nextSteps);
|
|
51
|
+
|
|
52
|
+
if (typeof eulaUrl !== 'string') {
|
|
53
|
+
throw new TypeError('eulaUrl must be a string');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
this.eulaUrl = eulaUrl;
|
|
57
|
+
this.accepted = false;
|
|
58
|
+
this.attemptedAction = attemptedAction;
|
|
59
|
+
this.requiresUserConfirmation = true;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
_getAdditionalFields() {
|
|
63
|
+
return {
|
|
64
|
+
accepted: this.accepted,
|
|
65
|
+
eulaUrl: this.eulaUrl,
|
|
66
|
+
requiresUserConfirmation: this.requiresUserConfirmation,
|
|
67
|
+
...(this.attemptedAction && { attemptedAction: this.attemptedAction })
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
getTextSummary() {
|
|
72
|
+
return `STOP - EXPLICIT USER CONFIRMATION REQUIRED !!!
|
|
73
|
+
|
|
74
|
+
Use ask questions tool to show a Yes/No options asking the user: "Do you accept the MCPBrowser EULA at ${this.eulaUrl}?"
|
|
75
|
+
|
|
76
|
+
DO NOT call accept_eula until the user explicitly selects "Yes".
|
|
77
|
+
If the user selects "No" or does not respond, DO NOT proceed.
|
|
78
|
+
|
|
79
|
+
When calling accept_eula, you MUST set userExplicitlyConfirmed=true ONLY if user said Yes.`;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Response for successful EULA acceptance
|
|
85
|
+
*/
|
|
86
|
+
export class EulaAcceptedResponse extends MCPResponse {
|
|
87
|
+
/**
|
|
88
|
+
* @param {string} eulaUrl - URL to the EULA that was accepted
|
|
89
|
+
* @param {string[]} nextSteps - Suggested next actions
|
|
90
|
+
*/
|
|
91
|
+
constructor(eulaUrl, nextSteps) {
|
|
92
|
+
super(nextSteps);
|
|
93
|
+
|
|
94
|
+
if (typeof eulaUrl !== 'string') {
|
|
95
|
+
throw new TypeError('eulaUrl must be a string');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
this.eulaUrl = eulaUrl;
|
|
99
|
+
this.accepted = true;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
_getAdditionalFields() {
|
|
103
|
+
return {
|
|
104
|
+
accepted: this.accepted,
|
|
105
|
+
eulaUrl: this.eulaUrl
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
getTextSummary() {
|
|
110
|
+
return `EULA accepted (${this.eulaUrl}). You now have full access to MCPBrowser tools.`;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ============================================================================
|
|
115
|
+
// TOOL DEFINITION
|
|
116
|
+
// ============================================================================
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @type {Tool}
|
|
120
|
+
*/
|
|
121
|
+
export const ACCEPT_EULA_TOOL = {
|
|
122
|
+
name: "accept_eula",
|
|
123
|
+
description: "CRITICAL: You MUST ask the user 'Do you accept the MCPBrowser EULA?' and wait for their explicit 'Yes' response BEFORE calling this tool. Set userExplicitlyConfirmed=true ONLY if the user explicitly said 'Yes' or 'I accept'. If the user has not responded or said 'No', DO NOT call this tool. Calling this tool without user consent violates the EULA terms.",
|
|
124
|
+
inputSchema: {
|
|
125
|
+
type: "object",
|
|
126
|
+
properties: {
|
|
127
|
+
userExplicitlyConfirmed: {
|
|
128
|
+
type: "boolean",
|
|
129
|
+
description: "REQUIRED: Must be true. Set to true ONLY if the user explicitly said 'Yes' or 'I accept' to the EULA prompt. Never set to true without explicit user confirmation."
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
required: ["userExplicitlyConfirmed"]
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// ============================================================================
|
|
137
|
+
// TOOL IMPLEMENTATION
|
|
138
|
+
// ============================================================================
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Handle EULA acceptance
|
|
142
|
+
* @param {Object} args - Tool arguments
|
|
143
|
+
* @param {boolean} args.userExplicitlyConfirmed - Whether user explicitly confirmed acceptance
|
|
144
|
+
* @returns {Promise<MCPResponse>} Response indicating EULA status
|
|
145
|
+
*/
|
|
146
|
+
export async function handleAcceptEula(args) {
|
|
147
|
+
const { userExplicitlyConfirmed } = args;
|
|
148
|
+
|
|
149
|
+
logger.debug(`accept_eula called with userExplicitlyConfirmed: ${userExplicitlyConfirmed}`);
|
|
150
|
+
|
|
151
|
+
// If already accepted and calling again, just confirm
|
|
152
|
+
if (isEulaAccepted()) {
|
|
153
|
+
logger.info('EULA already accepted');
|
|
154
|
+
return new EulaAcceptedResponse(
|
|
155
|
+
EULA_URL,
|
|
156
|
+
[
|
|
157
|
+
'Use fetch_webpage to navigate to a URL',
|
|
158
|
+
'Use get_current_html to see the current page content'
|
|
159
|
+
]
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// CRITICAL: Validate user explicitly confirmed
|
|
164
|
+
if (userExplicitlyConfirmed !== true) {
|
|
165
|
+
logger.warn('accept_eula called without userExplicitlyConfirmed=true - rejecting');
|
|
166
|
+
return new EulaPendingResponse(
|
|
167
|
+
EULA_URL,
|
|
168
|
+
[
|
|
169
|
+
'Ask the user: "Do you accept the MCPBrowser EULA?"',
|
|
170
|
+
'Wait for explicit "Yes" response',
|
|
171
|
+
'Then call accept_eula with userExplicitlyConfirmed=true'
|
|
172
|
+
]
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Accept the EULA
|
|
177
|
+
acceptEula(EULA_URL);
|
|
178
|
+
logger.info('EULA accepted with explicit user confirmation');
|
|
179
|
+
|
|
180
|
+
return new EulaAcceptedResponse(
|
|
181
|
+
EULA_URL,
|
|
182
|
+
[
|
|
183
|
+
'Use fetch_webpage to navigate to a URL',
|
|
184
|
+
'Use click_element to interact with page elements',
|
|
185
|
+
'Use type_text to enter text into forms'
|
|
186
|
+
]
|
|
187
|
+
);
|
|
188
|
+
}
|
|
@@ -192,7 +192,7 @@ export async function clickElement({ url, selector, text, waitForElementTimeout
|
|
|
192
192
|
|
|
193
193
|
if (!page) {
|
|
194
194
|
const isConnectionLost = pageError && pageError.includes('connection');
|
|
195
|
-
logger.
|
|
195
|
+
logger.debug(`click_element: ${pageError || 'No page found for ' + hostname}`);
|
|
196
196
|
return new InformationalResponse(
|
|
197
197
|
isConnectionLost ? `Page connection lost for ${hostname}` : `No open page found for ${hostname}`,
|
|
198
198
|
isConnectionLost
|
|
@@ -259,13 +259,13 @@ export async function clickElement({ url, selector, text, waitForElementTimeout
|
|
|
259
259
|
// await page.evaluate(el => el.scrollIntoView({ behavior: 'smooth', block: 'center' }), elementHandle);
|
|
260
260
|
// await new Promise(r => setTimeout(r, 300)); // Brief delay after scroll
|
|
261
261
|
|
|
262
|
-
logger.
|
|
262
|
+
logger.debug(`Clicking: ${selector || `text="${text}"`}`);
|
|
263
263
|
await elementHandle.click();
|
|
264
264
|
|
|
265
265
|
if (returnHtml) {
|
|
266
266
|
// Wait for page to stabilize (handles both navigation and SPA content updates)
|
|
267
267
|
// This ensures content is fully loaded before returning, just like fetch_webpage does
|
|
268
|
-
logger.
|
|
268
|
+
logger.debug('Waiting for page stability...');
|
|
269
269
|
await waitForPageStability(page);
|
|
270
270
|
|
|
271
271
|
// Wait for SPAs to render dynamic content after click
|
|
@@ -292,7 +292,7 @@ export async function clickElement({ url, selector, text, waitForElementTimeout
|
|
|
292
292
|
);
|
|
293
293
|
} else {
|
|
294
294
|
// Wait for page to stabilize even for fast clicks (ensures JS has finished)
|
|
295
|
-
logger.
|
|
295
|
+
logger.debug('Waiting for page stability (fast mode)...');
|
|
296
296
|
await waitForPageStability(page);
|
|
297
297
|
|
|
298
298
|
// Wait for SPAs to render dynamic content after click
|
|
@@ -190,7 +190,7 @@ async function doFetchPage({ url, hostname, browser, removeUnnecessaryHTML, post
|
|
|
190
190
|
const redirectInfo = detectRedirectType(url, hostname, currentUrl, currentHostname);
|
|
191
191
|
|
|
192
192
|
if (redirectInfo.type === 'requested_auth') {
|
|
193
|
-
logger.
|
|
193
|
+
logger.debug('User requested auth page directly, returning content');
|
|
194
194
|
// Update domain mapping if needed
|
|
195
195
|
if (redirectInfo.currentHostname !== hostname) {
|
|
196
196
|
domainPages.delete(hostname);
|
|
@@ -198,13 +198,13 @@ async function doFetchPage({ url, hostname, browser, removeUnnecessaryHTML, post
|
|
|
198
198
|
hostname = redirectInfo.currentHostname;
|
|
199
199
|
}
|
|
200
200
|
} else if (redirectInfo.type === 'permanent') {
|
|
201
|
-
logger.
|
|
201
|
+
logger.debug(`Redirect: ${hostname} → ${redirectInfo.currentHostname}`);
|
|
202
202
|
|
|
203
203
|
// Check if we already have a tab for the redirected hostname
|
|
204
204
|
// (can happen after reconnect - we mapped mail.google.com but not gmail.com)
|
|
205
205
|
const existingPage = domainPages.get(redirectInfo.currentHostname);
|
|
206
206
|
if (existingPage && existingPage !== page && !existingPage.isClosed()) {
|
|
207
|
-
logger.
|
|
207
|
+
logger.debug(`Found existing tab for ${redirectInfo.currentHostname}, reusing it`);
|
|
208
208
|
// Close the new tab we just opened, use the existing one
|
|
209
209
|
await page.close().catch(() => {});
|
|
210
210
|
domainPages.delete(hostname);
|
|
@@ -263,7 +263,7 @@ async function doFetchPage({ url, hostname, browser, removeUnnecessaryHTML, post
|
|
|
263
263
|
|
|
264
264
|
// Additional wait if requested (for pages that need extra time)
|
|
265
265
|
if (postLoadWait > 0) {
|
|
266
|
-
logger.
|
|
266
|
+
logger.debug(`Waiting ${postLoadWait}ms (postLoadWait)...`);
|
|
267
267
|
await new Promise(resolve => setTimeout(resolve, postLoadWait));
|
|
268
268
|
}
|
|
269
269
|
|
|
@@ -274,7 +274,7 @@ async function doFetchPage({ url, hostname, browser, removeUnnecessaryHTML, post
|
|
|
274
274
|
|
|
275
275
|
// Check for non-2xx HTTP status codes - return informational response (not red error)
|
|
276
276
|
if (statusCode && (statusCode >= 400 && statusCode < 600)) {
|
|
277
|
-
logger.
|
|
277
|
+
logger.debug(`HTTP ${statusCode} ${statusText} - returning as informational response`);
|
|
278
278
|
return new HttpStatusResponse(
|
|
279
279
|
page.url(),
|
|
280
280
|
statusCode,
|
|
@@ -134,7 +134,7 @@ export async function getCurrentHtml({ url, removeUnnecessaryHTML = true }) {
|
|
|
134
134
|
|
|
135
135
|
if (!page) {
|
|
136
136
|
const isConnectionLost = pageError && pageError.includes('connection');
|
|
137
|
-
logger.
|
|
137
|
+
logger.debug(`get_current_html: ${pageError || 'No page found for ' + hostname}`);
|
|
138
138
|
return new InformationalResponse(
|
|
139
139
|
isConnectionLost ? `Page connection lost for ${hostname}` : `No open page found for ${hostname}`,
|
|
140
140
|
isConnectionLost
|
|
@@ -200,7 +200,7 @@ export async function scrollPage({ url, direction, amount = 500, selector, x, y
|
|
|
200
200
|
|
|
201
201
|
if (!page) {
|
|
202
202
|
const isConnectionLost = pageError && pageError.includes('connection');
|
|
203
|
-
logger.
|
|
203
|
+
logger.debug(`scroll_page: ${pageError || 'No page found for ' + hostname}`);
|
|
204
204
|
return new InformationalResponse(
|
|
205
205
|
isConnectionLost ? `Page connection lost for ${hostname}` : `No open page found for ${hostname}`,
|
|
206
206
|
isConnectionLost
|
|
@@ -219,7 +219,7 @@ export async function scrollPage({ url, direction, amount = 500, selector, x, y
|
|
|
219
219
|
// Determine scroll mode and execute
|
|
220
220
|
if (selector) {
|
|
221
221
|
// Scroll to element mode
|
|
222
|
-
logger.
|
|
222
|
+
logger.debug(`scroll_page: Scrolling to element: ${selector}`);
|
|
223
223
|
|
|
224
224
|
const elementExists = await page.$(selector);
|
|
225
225
|
if (!elementExists) {
|
|
@@ -243,7 +243,7 @@ export async function scrollPage({ url, direction, amount = 500, selector, x, y
|
|
|
243
243
|
|
|
244
244
|
} else if (typeof x === 'number' && typeof y === 'number') {
|
|
245
245
|
// Absolute position mode
|
|
246
|
-
logger.
|
|
246
|
+
logger.debug(`scroll_page: Scrolling to absolute position: (${x}, ${y})`);
|
|
247
247
|
|
|
248
248
|
await page.evaluate(({ scrollX, scrollY }) => {
|
|
249
249
|
window.scrollTo(scrollX, scrollY);
|
|
@@ -251,7 +251,7 @@ export async function scrollPage({ url, direction, amount = 500, selector, x, y
|
|
|
251
251
|
|
|
252
252
|
} else if (direction) {
|
|
253
253
|
// Directional scroll mode
|
|
254
|
-
logger.
|
|
254
|
+
logger.debug(`scroll_page: Scrolling ${direction} by ${amount}px`);
|
|
255
255
|
|
|
256
256
|
const scrollDeltas = {
|
|
257
257
|
up: { x: 0, y: -amount },
|
|
@@ -271,7 +271,7 @@ export async function scrollPage({ url, direction, amount = 500, selector, x, y
|
|
|
271
271
|
|
|
272
272
|
} else {
|
|
273
273
|
// No scroll parameters provided - just return current position
|
|
274
|
-
logger.
|
|
274
|
+
logger.debug(`scroll_page: No scroll action specified, returning current position`);
|
|
275
275
|
}
|
|
276
276
|
|
|
277
277
|
// Small delay to let scroll complete
|
|
@@ -163,7 +163,7 @@ export async function takeScreenshot({ url, fullPage = false }) {
|
|
|
163
163
|
|
|
164
164
|
if (!page) {
|
|
165
165
|
const isConnectionLost = pageError && pageError.includes('connection');
|
|
166
|
-
logger.
|
|
166
|
+
logger.debug(`take_screenshot: ${pageError || 'No page found for ' + hostname}`);
|
|
167
167
|
return new InformationalResponse(
|
|
168
168
|
isConnectionLost ? `Page connection lost for ${hostname}` : `No open page found for ${hostname}`,
|
|
169
169
|
isConnectionLost
|
package/src/actions/type-text.js
CHANGED
|
@@ -186,7 +186,7 @@ export async function typeText({ url, fields, returnHtml = true, removeUnnecessa
|
|
|
186
186
|
|
|
187
187
|
if (!page) {
|
|
188
188
|
const isConnectionLost = pageError && pageError.includes('connection');
|
|
189
|
-
logger.
|
|
189
|
+
logger.debug(`type_text: ${pageError || 'No page found for ' + hostname}`);
|
|
190
190
|
return new InformationalResponse(
|
|
191
191
|
isConnectionLost ? `Page connection lost for ${hostname}` : `No open page found for ${hostname}`,
|
|
192
192
|
isConnectionLost
|
|
@@ -216,7 +216,7 @@ export async function typeText({ url, fields, returnHtml = true, removeUnnecessa
|
|
|
216
216
|
await page.keyboard.press('Backspace');
|
|
217
217
|
}
|
|
218
218
|
|
|
219
|
-
logger.
|
|
219
|
+
logger.debug(`Typing into: ${selector}`);
|
|
220
220
|
await page.type(selector, String(text), { delay: TYPE_DELAY_MS });
|
|
221
221
|
filledSelectors.push(selector);
|
|
222
222
|
currentFieldIndex++;
|
|
@@ -228,7 +228,7 @@ export async function typeText({ url, fields, returnHtml = true, removeUnnecessa
|
|
|
228
228
|
|
|
229
229
|
if (returnHtml) {
|
|
230
230
|
// Wait for page to stabilize (handles form validation, autocomplete, etc.)
|
|
231
|
-
logger.
|
|
231
|
+
logger.debug('Waiting for page stability after typing...');
|
|
232
232
|
await waitForPageStability(page);
|
|
233
233
|
|
|
234
234
|
// Wait for SPAs to render dynamic content after typing
|
|
@@ -255,7 +255,7 @@ export async function typeText({ url, fields, returnHtml = true, removeUnnecessa
|
|
|
255
255
|
);
|
|
256
256
|
} else {
|
|
257
257
|
// Wait for page to stabilize even without returning HTML
|
|
258
|
-
logger.
|
|
258
|
+
logger.debug('Waiting for page stability after typing (fast mode)...');
|
|
259
259
|
await waitForPageStability(page);
|
|
260
260
|
|
|
261
261
|
// Wait for SPAs to render dynamic content after typing
|
package/src/core/eula.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EULA (End User License Agreement) Management
|
|
3
|
+
* Tracks whether the user has accepted the EULA.
|
|
4
|
+
* EULA acceptance is persisted to disk and remembered across sessions.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import logger from './logger.js';
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
9
|
+
import { join } from 'path';
|
|
10
|
+
import { homedir } from 'os';
|
|
11
|
+
|
|
12
|
+
// EULA URL - where the full license agreement can be found
|
|
13
|
+
export const EULA_URL = 'https://github.com/cherchyk/MCPBrowser/blob/main/EULA.md';
|
|
14
|
+
|
|
15
|
+
// Config directory and file paths
|
|
16
|
+
const CONFIG_DIR = join(homedir(), '.mcpbrowser');
|
|
17
|
+
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
|
18
|
+
|
|
19
|
+
// In-memory cache of EULA acceptance status
|
|
20
|
+
let eulaAccepted = null;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Load config from disk
|
|
24
|
+
* @returns {Object} Config object
|
|
25
|
+
*/
|
|
26
|
+
function loadConfig() {
|
|
27
|
+
try {
|
|
28
|
+
if (existsSync(CONFIG_FILE)) {
|
|
29
|
+
const data = readFileSync(CONFIG_FILE, 'utf-8');
|
|
30
|
+
return JSON.parse(data);
|
|
31
|
+
}
|
|
32
|
+
} catch (error) {
|
|
33
|
+
logger.warn(`Failed to load config: ${error.message}`);
|
|
34
|
+
}
|
|
35
|
+
return {};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Save config to disk
|
|
40
|
+
* @param {Object} config - Config object to save
|
|
41
|
+
*/
|
|
42
|
+
function saveConfig(config) {
|
|
43
|
+
try {
|
|
44
|
+
if (!existsSync(CONFIG_DIR)) {
|
|
45
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
46
|
+
}
|
|
47
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8');
|
|
48
|
+
logger.info(`Config saved to ${CONFIG_FILE}`);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
logger.error(`Failed to save config: ${error.message}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Initialize EULA status from persisted config
|
|
56
|
+
*/
|
|
57
|
+
function initEulaStatus() {
|
|
58
|
+
if (eulaAccepted === null) {
|
|
59
|
+
const config = loadConfig();
|
|
60
|
+
eulaAccepted = config.eulaAccepted === true;
|
|
61
|
+
if (eulaAccepted) {
|
|
62
|
+
logger.info('EULA previously accepted, loaded from config');
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Check if the EULA has been accepted
|
|
69
|
+
* @returns {boolean} True if EULA has been accepted
|
|
70
|
+
*/
|
|
71
|
+
export function isEulaAccepted() {
|
|
72
|
+
initEulaStatus();
|
|
73
|
+
return eulaAccepted;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Accept the EULA (persisted across sessions)
|
|
78
|
+
* @param {string} eulaUrl - The EULA URL being accepted
|
|
79
|
+
*/
|
|
80
|
+
export function acceptEula(eulaUrl) {
|
|
81
|
+
eulaAccepted = true;
|
|
82
|
+
const config = loadConfig();
|
|
83
|
+
config.eulaAccepted = true;
|
|
84
|
+
config.eulaAcceptedAt = new Date().toISOString();
|
|
85
|
+
config.eulaUrl = eulaUrl;
|
|
86
|
+
saveConfig(config);
|
|
87
|
+
logger.info(`EULA accepted and persisted (${eulaUrl})`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Reset EULA acceptance (clears both memory and persisted state)
|
|
92
|
+
* @param {boolean} [persistReset=true] - Whether to also clear the persisted config
|
|
93
|
+
*/
|
|
94
|
+
export function resetEula(persistReset = true) {
|
|
95
|
+
eulaAccepted = false;
|
|
96
|
+
if (persistReset) {
|
|
97
|
+
const config = loadConfig();
|
|
98
|
+
delete config.eulaAccepted;
|
|
99
|
+
delete config.eulaAcceptedAt;
|
|
100
|
+
delete config.eulaUrl;
|
|
101
|
+
saveConfig(config);
|
|
102
|
+
logger.debug('EULA acceptance reset (memory and disk)');
|
|
103
|
+
} else {
|
|
104
|
+
logger.debug('EULA acceptance reset (memory only)');
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Get EULA status summary
|
|
110
|
+
* @returns {{ accepted: boolean, acceptedAt: string|null, eulaUrl: string }} Current EULA status
|
|
111
|
+
*/
|
|
112
|
+
export function getEulaStatus() {
|
|
113
|
+
initEulaStatus();
|
|
114
|
+
const config = loadConfig();
|
|
115
|
+
return {
|
|
116
|
+
accepted: eulaAccepted,
|
|
117
|
+
acceptedAt: config.eulaAcceptedAt || null,
|
|
118
|
+
eulaUrl: EULA_URL
|
|
119
|
+
};
|
|
120
|
+
}
|
package/src/core/logger.js
CHANGED
|
@@ -14,6 +14,14 @@ function info(message) {
|
|
|
14
14
|
console.error(`${PREFIX} ${message}`);
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Log a warning message
|
|
19
|
+
* @param {string} message - The message to log
|
|
20
|
+
*/
|
|
21
|
+
function warn(message) {
|
|
22
|
+
console.error(`${PREFIX} ⚠️ ${message}`);
|
|
23
|
+
}
|
|
24
|
+
|
|
17
25
|
/**
|
|
18
26
|
* Log an error message
|
|
19
27
|
* @param {string} message - The message to log
|
|
@@ -22,5 +30,13 @@ function error(message) {
|
|
|
22
30
|
console.error(`${PREFIX} ❌ ${message}`);
|
|
23
31
|
}
|
|
24
32
|
|
|
25
|
-
|
|
33
|
+
/**
|
|
34
|
+
* Log a debug message
|
|
35
|
+
* @param {string} message - The message to log
|
|
36
|
+
*/
|
|
37
|
+
function debug(message) {
|
|
38
|
+
console.error(`${PREFIX} 🔍 ${message}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const logger = { info, warn, error, debug };
|
|
26
42
|
export default logger;
|
package/src/core/page.js
CHANGED
|
@@ -43,7 +43,7 @@ async function processQueue() {
|
|
|
43
43
|
const queueLength = requestQueue.length;
|
|
44
44
|
|
|
45
45
|
if (queueLength > 0) {
|
|
46
|
-
logger.
|
|
46
|
+
logger.debug(`Queue: ${queueLength} requests waiting`);
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
try {
|
|
@@ -285,7 +285,7 @@ export async function waitForPageReady(page) {
|
|
|
285
285
|
const spaCheck = await isItSPA(page);
|
|
286
286
|
|
|
287
287
|
if (spaCheck.isSPA) {
|
|
288
|
-
logger.
|
|
288
|
+
logger.debug(`SPA detected: ${spaCheck.indicators.join(', ')}`);
|
|
289
289
|
|
|
290
290
|
// Wait for SPA to render
|
|
291
291
|
await new Promise(resolve => setTimeout(resolve, 3000));
|
|
@@ -296,7 +296,7 @@ export async function waitForPageReady(page) {
|
|
|
296
296
|
} catch {
|
|
297
297
|
// OK if timeout - SPA might have websockets or long-polling
|
|
298
298
|
}
|
|
299
|
-
logger.
|
|
299
|
+
logger.debug('SPA content ready');
|
|
300
300
|
} else {
|
|
301
301
|
// For non-SPAs, just wait briefly for any pending network requests
|
|
302
302
|
try {
|
|
@@ -314,17 +314,17 @@ export async function waitForPageReady(page) {
|
|
|
314
314
|
* @returns {Promise<void>}
|
|
315
315
|
*/
|
|
316
316
|
export async function waitForPageStability(page) {
|
|
317
|
-
logger.
|
|
317
|
+
logger.debug('Waiting for page stability (network idle)...');
|
|
318
318
|
|
|
319
319
|
// Give time for any triggered actions to complete
|
|
320
320
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
321
321
|
|
|
322
322
|
try {
|
|
323
323
|
await page.waitForNetworkIdle({ timeout: 5000 });
|
|
324
|
-
logger.
|
|
324
|
+
logger.debug('Page stabilized');
|
|
325
325
|
} catch {
|
|
326
326
|
// Ignore timeout - page may have long-polling or websockets
|
|
327
|
-
logger.
|
|
327
|
+
logger.debug('Network still active, continuing anyway');
|
|
328
328
|
}
|
|
329
329
|
}
|
|
330
330
|
|
package/src/mcp-browser.js
CHANGED
|
@@ -16,6 +16,9 @@ import { dirname, join } from 'path';
|
|
|
16
16
|
import { ErrorResponse } from './core/responses.js';
|
|
17
17
|
import logger from './core/logger.js';
|
|
18
18
|
|
|
19
|
+
// Import EULA functionality
|
|
20
|
+
import { handleAcceptEula, ACCEPT_EULA_TOOL, requireEulaAcceptance } from './actions/accept-eula.js';
|
|
21
|
+
|
|
19
22
|
// Import core functionality
|
|
20
23
|
import { fetchPage, FETCH_WEBPAGE_TOOL } from './actions/fetch-page.js';
|
|
21
24
|
import { clickElement, CLICK_ELEMENT_TOOL } from './actions/click-element.js';
|
|
@@ -47,7 +50,9 @@ async function main() {
|
|
|
47
50
|
const server = new Server({ name: "MCP Browser", version: packageJson.version }, { capabilities: { tools: {} } });
|
|
48
51
|
|
|
49
52
|
// Assemble tools from action imports
|
|
53
|
+
// ACCEPT_EULA_TOOL must be first - it's required before using other tools
|
|
50
54
|
const tools = [
|
|
55
|
+
ACCEPT_EULA_TOOL,
|
|
51
56
|
FETCH_WEBPAGE_TOOL,
|
|
52
57
|
CLICK_ELEMENT_TOOL,
|
|
53
58
|
TYPE_TEXT_TOOL,
|
|
@@ -66,7 +71,17 @@ async function main() {
|
|
|
66
71
|
let result;
|
|
67
72
|
|
|
68
73
|
try {
|
|
74
|
+
// EULA check - accept_eula is always allowed, other tools require EULA acceptance
|
|
75
|
+
if (name !== "accept_eula") {
|
|
76
|
+
const eulaResponse = requireEulaAcceptance(name);
|
|
77
|
+
if (eulaResponse) return eulaResponse;
|
|
78
|
+
}
|
|
79
|
+
|
|
69
80
|
switch (name) {
|
|
81
|
+
case "accept_eula":
|
|
82
|
+
result = await handleAcceptEula(safeArgs);
|
|
83
|
+
break;
|
|
84
|
+
|
|
70
85
|
case "fetch_webpage":
|
|
71
86
|
result = await fetchPage(safeArgs);
|
|
72
87
|
break;
|
|
@@ -144,7 +159,8 @@ export {
|
|
|
144
159
|
closeTab,
|
|
145
160
|
getCurrentHtml,
|
|
146
161
|
takeScreenshot,
|
|
147
|
-
scrollPage
|
|
162
|
+
scrollPage,
|
|
163
|
+
handleAcceptEula
|
|
148
164
|
};
|
|
149
165
|
|
|
150
166
|
// Run the MCP server only if this is the main module (not imported for testing)
|