@diegosouzacdv/jev-browser-mcp 0.7.0 → 0.7.1
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 +90 -27
- package/docs/jev-browser-mcp.md +90 -27
- package/mcp_servers/jev-browser-npm/src/flow.mjs +575 -157
- package/mcp_servers/jev-browser-npm/src/server.mjs +146 -92
- package/package.json +1 -1
|
@@ -8,59 +8,13 @@ import { chromium } from "playwright";
|
|
|
8
8
|
import * as z from "zod/v4";
|
|
9
9
|
import packageManifest from "../../../package.json" with { type: "json" };
|
|
10
10
|
import { loadSettings, JevBrowserError } from "./config.mjs";
|
|
11
|
-
import { chooseNextAction, createMutationAuthorizationStore, describeBrowserActions, executeBrowserFlow } from "./flow.mjs";
|
|
11
|
+
import { chooseNextAction, collectFlowContractIssues, createMutationAuthorizationStore, describeBrowserActions, executeBrowserFlow } from "./flow.mjs";
|
|
12
12
|
|
|
13
13
|
const browserContract = describeBrowserActions();
|
|
14
|
-
const actionNames = Object.keys(browserContract.actions);
|
|
15
14
|
const jsonObject = z.record(z.string(), z.unknown());
|
|
16
|
-
function browserStepFieldSchema(field) {
|
|
17
|
-
if (["mutating", "screenshot", "expect_download", "expect_popup", "blur", "sensitive", "full_page"].includes(field)) return z.boolean();
|
|
18
|
-
if (["timeout_ms", "timeout_seconds"].includes(field)) return z.number().positive();
|
|
19
|
-
if (["index", "context_index", "ms", "duration_ms", "status", "close_code"].includes(field)) return z.number().int();
|
|
20
|
-
if (field === "file_paths") return z.union([z.string(), z.array(z.string())]);
|
|
21
|
-
if (["near", "within", "if_visible", "json"].includes(field)) return jsonObject;
|
|
22
|
-
if (field === "name_match") return z.enum(["exact", "contains", "regex"]);
|
|
23
|
-
if (field === "condition") return z.enum(["network_idle", "angular_idle", "url", "hidden"]);
|
|
24
|
-
if (field === "standard") return z.enum(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa", "section508"]);
|
|
25
|
-
return z.string();
|
|
26
|
-
}
|
|
27
|
-
function browserStepVariant(action, contract, aliases = []) {
|
|
28
|
-
const required = new Set(contract.required);
|
|
29
|
-
const optional = new Set([...contract.optional, "timeout_ms", "mutating", "screenshot", ...aliases]);
|
|
30
|
-
if (action === "wait") { required.delete("ms"); optional.add("ms"); }
|
|
31
|
-
if (action === "wait_for_text") { required.delete("text"); optional.add("text"); }
|
|
32
|
-
if (action === "assert_text" || action === "assert_value") { required.delete("expected"); optional.add("expected"); }
|
|
33
|
-
if (action === "upload_file") { required.delete("file_paths"); optional.add("file_paths"); }
|
|
34
|
-
if (action === "wait") optional.add("duration_ms");
|
|
35
|
-
if (action === "wait_for_text" || action === "assert_text" || action === "assert_value") optional.add("value");
|
|
36
|
-
if (action === "upload_file") optional.add("file_path");
|
|
37
|
-
if (action === "confirm_modal") {
|
|
38
|
-
required.delete("button");
|
|
39
|
-
optional.add("button");
|
|
40
|
-
optional.add("trigger");
|
|
41
|
-
optional.add("confirm");
|
|
42
|
-
}
|
|
43
|
-
const shape = { action: z.literal(action) };
|
|
44
|
-
for (const key of new Set([...required, ...optional])) {
|
|
45
|
-
const field = browserStepFieldSchema(key);
|
|
46
|
-
shape[key] = required.has(key) ? field : field.optional();
|
|
47
|
-
}
|
|
48
|
-
return z.object(shape).strict();
|
|
49
|
-
}
|
|
50
|
-
const browserStepVariants = Object.entries(browserContract.actions).map(([action, contract]) => browserStepVariant(action, contract));
|
|
51
|
-
browserStepVariants.push(
|
|
52
|
-
browserStepVariant("fill", browserContract.actions.type, ["value"]),
|
|
53
|
-
browserStepVariant("press_key", browserContract.actions.press),
|
|
54
|
-
);
|
|
55
|
-
const browserStepSchema = z.discriminatedUnion("action", browserStepVariants);
|
|
56
|
-
const candidatePlanSchema = z.object({
|
|
57
|
-
description: z.string(),
|
|
58
|
-
steps: z.array(browserStepSchema).min(1),
|
|
59
|
-
comment: z.string().optional(),
|
|
60
|
-
when: z.object({ visible: z.string() }).strict().optional(),
|
|
61
|
-
}).strict();
|
|
62
15
|
const browserOptionsShape = Object.fromEntries(browserContract.options.map((key) => {
|
|
63
16
|
const value = key === "return_snapshot" ? z.enum(["diff", "full", "none"])
|
|
17
|
+
: key === "capture_network" ? z.enum(["none", "errors", "all"])
|
|
64
18
|
: key === "reuse_page_match" ? z.enum(["url", "path"])
|
|
65
19
|
: key === "color_scheme" ? z.enum(["light", "dark", "no-preference"])
|
|
66
20
|
: key === "permissions" ? z.array(z.enum(["microphone", "geolocation"]))
|
|
@@ -74,6 +28,12 @@ const browserOptionsShape = Object.fromEntries(browserContract.options.map((key)
|
|
|
74
28
|
return [key, value.optional()];
|
|
75
29
|
}));
|
|
76
30
|
const browserOptionsSchema = z.object(browserOptionsShape).passthrough();
|
|
31
|
+
const candidatePlanSchema = z.object({
|
|
32
|
+
description: z.string().optional(),
|
|
33
|
+
steps: z.array(z.record(z.string(), z.unknown())).optional(),
|
|
34
|
+
comment: z.string().optional(),
|
|
35
|
+
when: z.unknown().optional(),
|
|
36
|
+
}).strict();
|
|
77
37
|
const expectedOutcomeSchema = z.union([
|
|
78
38
|
z.string(),
|
|
79
39
|
z.object({
|
|
@@ -97,8 +57,9 @@ class BrowserPool {
|
|
|
97
57
|
#contextBrowsers = new Map();
|
|
98
58
|
#page;
|
|
99
59
|
#startedAt;
|
|
100
|
-
#everStarted = false;
|
|
101
|
-
#navigationError;
|
|
60
|
+
#everStarted = false;
|
|
61
|
+
#navigationError;
|
|
62
|
+
#unrestoredPage = false;
|
|
102
63
|
#flowQueue = Promise.resolve();
|
|
103
64
|
#configurationKey;
|
|
104
65
|
#lastConfiguration = {};
|
|
@@ -107,12 +68,13 @@ class BrowserPool {
|
|
|
107
68
|
this.#settings = settings;
|
|
108
69
|
}
|
|
109
70
|
|
|
110
|
-
getNavigationError() {
|
|
111
|
-
return this.#navigationError;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
setNavigationError(error) {
|
|
115
|
-
this.#navigationError = error || undefined;
|
|
71
|
+
getNavigationError() {
|
|
72
|
+
return this.#unrestoredPage ? "BROWSER_PAGE_NOT_RESTORED" : this.#navigationError;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
setNavigationError(error) {
|
|
76
|
+
this.#navigationError = error || undefined;
|
|
77
|
+
this.#unrestoredPage = error === "BROWSER_PAGE_NOT_RESTORED";
|
|
116
78
|
}
|
|
117
79
|
|
|
118
80
|
async runFlow(work) {
|
|
@@ -125,9 +87,10 @@ class BrowserPool {
|
|
|
125
87
|
} finally {
|
|
126
88
|
release();
|
|
127
89
|
}
|
|
128
|
-
}
|
|
129
|
-
|
|
90
|
+
}
|
|
91
|
+
|
|
130
92
|
async getPage(configuration) {
|
|
93
|
+
const hadStartedBeforeRequest = this.#everStarted;
|
|
131
94
|
if (configuration === undefined) configuration = this.#lastConfiguration;
|
|
132
95
|
else this.#lastConfiguration = configuration;
|
|
133
96
|
const configurationKey = JSON.stringify(configuration);
|
|
@@ -156,7 +119,12 @@ class BrowserPool {
|
|
|
156
119
|
throw error;
|
|
157
120
|
}
|
|
158
121
|
}
|
|
159
|
-
return {
|
|
122
|
+
return {
|
|
123
|
+
page: this.#page,
|
|
124
|
+
context: this.#context,
|
|
125
|
+
sessionMs: isOpening ? performance.now() - this.#startedAt : 0,
|
|
126
|
+
recovered: isOpening && hadStartedBeforeRequest,
|
|
127
|
+
};
|
|
160
128
|
}
|
|
161
129
|
|
|
162
130
|
#invalidate(reason) {
|
|
@@ -290,17 +258,47 @@ class BrowserPool {
|
|
|
290
258
|
this.#contextPromise = undefined;
|
|
291
259
|
this.#navigationError = undefined;
|
|
292
260
|
this.#configurationKey = undefined;
|
|
261
|
+
this.#unrestoredPage = false;
|
|
293
262
|
}
|
|
294
263
|
}
|
|
295
264
|
|
|
296
|
-
function
|
|
297
|
-
let message = error instanceof JevBrowserError ? error.message : error?.message || `Jev browser flow failed (${error?.name || "Error"})`;
|
|
298
|
-
const credential = settings.env?.[settings.jev.credentialEnv];
|
|
299
|
-
if (credential) message = message.split(credential).join("[redacted]");
|
|
300
|
-
message = message.replace(/\bBearer\s+\S+/gi, "Bearer [redacted]")
|
|
301
|
-
.replace(/([?&](?:token|api[_-]?key|password|secret)=)[^&\s]+/gi, "$1[redacted]");
|
|
302
|
-
return
|
|
303
|
-
}
|
|
265
|
+
function safeErrorMessage(error, settings) {
|
|
266
|
+
let message = error instanceof JevBrowserError ? error.message : error?.message || `Jev browser flow failed (${error?.name || "Error"})`;
|
|
267
|
+
const credential = settings.env?.[settings.jev.credentialEnv];
|
|
268
|
+
if (credential) message = message.split(credential).join("[redacted]");
|
|
269
|
+
message = message.replace(/\bBearer\s+\S+/gi, "Bearer [redacted]")
|
|
270
|
+
.replace(/([?&](?:token|api[_-]?key|password|secret)=)[^&\s]+/gi, "$1[redacted]");
|
|
271
|
+
return message;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function toolError(error, settings) {
|
|
275
|
+
const message = safeErrorMessage(error, settings);
|
|
276
|
+
return { isError: true, content: [{ type: "text", text: `[jev-browser ${packageManifest.version}] ${message}` }] };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function isBrowserDisconnectError(error) {
|
|
280
|
+
return /(?:target page, context or browser has been closed|browser has been closed|browser disconnected|context has been closed|connection closed|connection is closed)/i
|
|
281
|
+
.test(String(error?.message || error?.name || error || ""));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function isBrowserStartupError(error) {
|
|
285
|
+
return /(?:could not start the configured .* browser|browser profile is in use by PID \d+)/i
|
|
286
|
+
.test(String(error?.message || error?.name || error || ""));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function browserEnvironmentError(reason, navigationError, extra = {}) {
|
|
290
|
+
return {
|
|
291
|
+
server_version: packageManifest.version,
|
|
292
|
+
status: "environment_error",
|
|
293
|
+
navigation_error: navigationError,
|
|
294
|
+
reason,
|
|
295
|
+
acceptance_criteria_met: false,
|
|
296
|
+
expected_outcome_visible: false,
|
|
297
|
+
steps_executed: 0,
|
|
298
|
+
steps: [],
|
|
299
|
+
...extra,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
304
302
|
|
|
305
303
|
export function fitToolResponse(value, maxBytes) {
|
|
306
304
|
const clone = structuredClone(value && typeof value === "object" ? value : { result: value });
|
|
@@ -472,37 +470,93 @@ export function createJevBrowserServer({ env = process.env, configPath, fetchImp
|
|
|
472
470
|
{
|
|
473
471
|
description: "Run a bounded screen flow in an isolated per-server browser profile. Continues the current SPA page by default. Supports media fakes, tabs and contexts, read-only page inspection, conditional plans, HTTP/network/WebSocket assertions, named storage state, guarded mutation confirmation, per-step timeouts and evidence. expected_outcome accepts descriptive text or structured text/request assertions. Use describe_actions for required and optional fields and the exact options contract. Jev chooses among eligible supplied plans; a single plan skips the decision call when fast_path is enabled.",
|
|
474
472
|
inputSchema: {
|
|
475
|
-
flow: z.string(),
|
|
473
|
+
flow: z.string().optional(),
|
|
476
474
|
initial_url: z.string().optional(),
|
|
477
475
|
expected_outcome: expectedOutcomeSchema.optional(),
|
|
478
|
-
candidate_plans: z.record(z.string(), candidatePlanSchema),
|
|
476
|
+
candidate_plans: z.record(z.string(), candidatePlanSchema).optional(),
|
|
479
477
|
params: z.record(z.string(), z.unknown()).optional(),
|
|
480
478
|
options: browserOptionsSchema.optional(),
|
|
481
479
|
},
|
|
482
|
-
},
|
|
483
|
-
async ({ flow, initial_url: initialUrl, expected_outcome: expectedOutcome, candidate_plans: candidatePlans, params, options }) => {
|
|
484
|
-
try {
|
|
480
|
+
},
|
|
481
|
+
async ({ flow, initial_url: initialUrl, expected_outcome: expectedOutcome, candidate_plans: candidatePlans, params, options }) => {
|
|
482
|
+
try {
|
|
483
|
+
const contractIssues = collectFlowContractIssues({ flow, candidatePlans });
|
|
484
|
+
if (contractIssues.length) throw new JevBrowserError(`flow contract validation failed:\n- ${contractIssues.join("\n- ")}`);
|
|
485
485
|
return toolText(await browserPool.runFlow(async () => {
|
|
486
|
-
const args = {
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
};
|
|
499
|
-
const
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
486
|
+
const args = {
|
|
487
|
+
flow,
|
|
488
|
+
initialUrl,
|
|
489
|
+
expectedOutcome,
|
|
490
|
+
candidatePlans,
|
|
491
|
+
params,
|
|
492
|
+
options,
|
|
493
|
+
settings,
|
|
494
|
+
browserPool,
|
|
495
|
+
fetchImpl,
|
|
496
|
+
serverVersion: packageManifest.version,
|
|
497
|
+
mutationAuthorizationStore,
|
|
498
|
+
};
|
|
499
|
+
const retryAfterRestart = async (first) => {
|
|
500
|
+
const recovery = await browserPool.restart();
|
|
501
|
+
if (recovery.reason) recovery.reason = safeErrorMessage(new Error(recovery.reason), settings);
|
|
502
|
+
if (!recovery.connected) {
|
|
503
|
+
if (!initialUrl) browserPool.setNavigationError("BROWSER_PAGE_NOT_RESTORED");
|
|
504
|
+
return {
|
|
505
|
+
...first,
|
|
506
|
+
...(initialUrl ? {} : { navigation_error: "BROWSER_PAGE_NOT_RESTORED" }),
|
|
507
|
+
browser_restart: recovery,
|
|
508
|
+
retry_count: 0,
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
if (!initialUrl) {
|
|
512
|
+
browserPool.setNavigationError("BROWSER_PAGE_NOT_RESTORED");
|
|
513
|
+
return {
|
|
514
|
+
...first,
|
|
515
|
+
navigation_error: "BROWSER_PAGE_NOT_RESTORED",
|
|
516
|
+
reason: "The browser reconnected, but the previous page was not restored. Provide initial_url to start a new navigation.",
|
|
517
|
+
browser_restart: recovery,
|
|
518
|
+
retry_count: 0,
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
try {
|
|
522
|
+
const retry = await executeBrowserFlow({ ...args, options: { ...options, reuse_page: false } });
|
|
523
|
+
return { ...retry, browser_restart: recovery, retry_count: 1 };
|
|
524
|
+
} catch (error) {
|
|
525
|
+
if (!isBrowserDisconnectError(error) && !isBrowserStartupError(error)) throw error;
|
|
526
|
+
return {
|
|
527
|
+
...browserEnvironmentError(
|
|
528
|
+
`The browser reconnected, but the flow could not restart: ${safeErrorMessage(error, settings)}`,
|
|
529
|
+
"BROWSER_RESTART_FAILED",
|
|
530
|
+
),
|
|
531
|
+
browser_restart: recovery,
|
|
532
|
+
retry_count: 1,
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
let first;
|
|
538
|
+
try {
|
|
539
|
+
first = await executeBrowserFlow(args);
|
|
540
|
+
} catch (error) {
|
|
541
|
+
if (isBrowserStartupError(error)) {
|
|
542
|
+
const profileInUse = /browser profile is in use by PID \d+/i.test(String(error.message || ""));
|
|
543
|
+
return browserEnvironmentError(
|
|
544
|
+
safeErrorMessage(error, settings),
|
|
545
|
+
profileInUse ? "BROWSER_PROFILE_IN_USE" : "BROWSER_START_FAILED",
|
|
546
|
+
{ retry_count: 0 },
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
if (!isBrowserDisconnectError(error)) throw error;
|
|
550
|
+
const failedBeforeSteps = browserEnvironmentError(
|
|
551
|
+
"The browser disconnected before the flow could obtain its page.",
|
|
552
|
+
"BROWSER_DISCONNECTED",
|
|
553
|
+
);
|
|
554
|
+
return retryAfterRestart(failedBeforeSteps);
|
|
555
|
+
}
|
|
556
|
+
const disconnected = /^(?:BROWSER_DISCONNECTED|CONNECTION_CLOSED)$/.test(String(first.navigation_error || ""));
|
|
557
|
+
if (first.status !== "environment_error" || !disconnected
|
|
558
|
+
|| first.steps_executed > 0 || first.mutating_steps?.length || options?.confirmation_token) return first;
|
|
559
|
+
return retryAfterRestart(first);
|
|
506
560
|
}), settings.browser.maxToolResponseBytes);
|
|
507
561
|
} catch (error) {
|
|
508
562
|
return toolError(error, settings);
|