@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.
@@ -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 { page: this.#page, context: this.#context, sessionMs: isOpening ? performance.now() - this.#startedAt : 0 };
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 toolError(error, settings) {
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 { isError: true, content: [{ type: "text", text: `[jev-browser ${packageManifest.version}] ${message}` }] };
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
- 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 first = await executeBrowserFlow(args);
500
- if (first.status !== "environment_error" || first.navigation_error !== "BROWSER_DISCONNECTED"
501
- || first.mutating_steps?.length || options?.confirmation_token) return first;
502
- const recovery = await browserPool.restart();
503
- if (!recovery.connected) return { ...first, browser_restart: recovery, retry_count: 0 };
504
- const retry = await executeBrowserFlow({ ...args, options: { ...options, reuse_page: false } });
505
- return { ...retry, browser_restart: recovery, retry_count: 1 };
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegosouzacdv/jev-browser-mcp",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "Portable MCP server for bounded Playwright screen flows selected by Jev",
5
5
  "license": "MIT",
6
6
  "repository": {