@diegosouzacdv/jev-browser-mcp 0.6.2 → 0.7.0

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.
@@ -7,22 +7,101 @@ import { serveStdio } from "@modelcontextprotocol/server/stdio";
7
7
  import { chromium } from "playwright";
8
8
  import * as z from "zod/v4";
9
9
  import packageManifest from "../../../package.json" with { type: "json" };
10
- import { loadSettings, JevBrowserError } from "./config.mjs";
11
- import { chooseNextAction, createMutationAuthorizationStore, executeBrowserFlow } from "./flow.mjs";
12
-
13
- const browserPools = new Set();
10
+ import { loadSettings, JevBrowserError } from "./config.mjs";
11
+ import { chooseNextAction, createMutationAuthorizationStore, describeBrowserActions, executeBrowserFlow } from "./flow.mjs";
12
+
13
+ const browserContract = describeBrowserActions();
14
+ const actionNames = Object.keys(browserContract.actions);
15
+ 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
+ const browserOptionsShape = Object.fromEntries(browserContract.options.map((key) => {
63
+ const value = key === "return_snapshot" ? z.enum(["diff", "full", "none"])
64
+ : key === "reuse_page_match" ? z.enum(["url", "path"])
65
+ : key === "color_scheme" ? z.enum(["light", "dark", "no-preference"])
66
+ : key === "permissions" ? z.array(z.enum(["microphone", "geolocation"]))
67
+ : key === "console_levels" ? z.array(z.enum(["log", "info", "debug", "warn", "error"]))
68
+ : key === "ready" ? z.union([z.string(), z.record(z.string(), z.unknown()), z.array(z.union([z.string(), z.record(z.string(), z.unknown())]))])
69
+ : ["allow_mutations", "block_trackers", "capture_console_errors", "capture_network_error_bodies", "capture_network_errors", "auto_angular_idle", "block_fonts", "continue_from_current_page", "dry_run", "fast_path", "local_only", "ready_network_idle", "reuse_page", "screenshot_on_failure", "screenshot_on_success", "record_video", "mobile", "snapshot_include_hidden", "include_unnamed_controls", "stop_on_expected", "trace_on_failure", "trace_on_success", "fresh_context", "clear_storage"].includes(key) ? z.boolean()
70
+ : ["max_flow_steps", "ready_stable_ms", "ready_timeout_seconds", "step_timeout_seconds"].includes(key) ? z.number().positive()
71
+ : ["busy_selectors", "login_text", "login_url_contains"].includes(key) ? z.array(z.string())
72
+ : ["viewport", "geolocation", "wait_for_http", "fake_media"].includes(key) ? z.union([z.string(), jsonObject])
73
+ : z.string();
74
+ return [key, value.optional()];
75
+ }));
76
+ const browserOptionsSchema = z.object(browserOptionsShape).passthrough();
77
+ const expectedOutcomeSchema = z.union([
78
+ z.string(),
79
+ z.object({
80
+ text: z.string().optional(),
81
+ request: z.string().optional(),
82
+ method: z.string().optional(),
83
+ status: z.number().int().min(100).max(599).optional(),
84
+ message_contains: z.string().optional(),
85
+ }).strict().refine((value) => Boolean(value.text || value.request), "expected_outcome needs text or request"),
86
+ ]);
87
+
88
+ const browserPools = new Set();
14
89
 
15
90
  class BrowserPool {
16
91
  #settings;
17
92
  #contextPromise;
18
93
  #context;
19
- #browser;
94
+ #browser;
95
+ #extraContexts = new Set();
96
+ #extraBrowsers = new Set();
97
+ #contextBrowsers = new Map();
20
98
  #page;
21
99
  #startedAt;
22
100
  #everStarted = false;
23
101
  #navigationError;
24
102
  #flowQueue = Promise.resolve();
25
103
  #configurationKey;
104
+ #lastConfiguration = {};
26
105
 
27
106
  constructor(settings) {
28
107
  this.#settings = settings;
@@ -48,7 +127,9 @@ class BrowserPool {
48
127
  }
49
128
  }
50
129
 
51
- async getPage(configuration = {}) {
130
+ async getPage(configuration) {
131
+ if (configuration === undefined) configuration = this.#lastConfiguration;
132
+ else this.#lastConfiguration = configuration;
52
133
  const configurationKey = JSON.stringify(configuration);
53
134
  if (this.#contextPromise && this.#configurationKey !== configurationKey) await this.close();
54
135
  let isOpening = !this.#contextPromise;
@@ -102,7 +183,7 @@ class BrowserPool {
102
183
  await this.close();
103
184
  this.#everStarted = true;
104
185
  this.#startedAt = performance.now();
105
- this.#contextPromise = this.#open();
186
+ this.#contextPromise = this.#open(this.#lastConfiguration);
106
187
  try {
107
188
  await this.#contextPromise;
108
189
  return { status: "connected", connected: true, recovered: true, restarted: true, current_url: this.#page.url() };
@@ -115,6 +196,44 @@ class BrowserPool {
115
196
  if (this.#page !== page || !this.#context) return;
116
197
  this.#page = this.#context.pages()[0] || await this.#context.newPage();
117
198
  }
199
+
200
+ async createIsolatedContext(contextOptions = {}, launchArgs = []) {
201
+ let browser = this.#browser;
202
+ let ownsBrowser = false;
203
+ if (!browser) {
204
+ browser = await chromium.launch({
205
+ channel: this.#settings.browser.channel,
206
+ headless: this.#settings.browser.mode !== "computer",
207
+ args: launchArgs,
208
+ });
209
+ ownsBrowser = true;
210
+ }
211
+ try {
212
+ const context = await browser.newContext(contextOptions);
213
+ this.#extraContexts.add(context);
214
+ this.#contextBrowsers.set(context, { browser, ownsBrowser });
215
+ context.once("close", () => this.#extraContexts.delete(context));
216
+ if (ownsBrowser) {
217
+ this.#extraBrowsers.add(browser);
218
+ browser.once("disconnected", () => this.#extraBrowsers.delete(browser));
219
+ }
220
+ return context;
221
+ } catch (error) {
222
+ if (ownsBrowser) await browser.close().catch(() => {});
223
+ throw error;
224
+ }
225
+ }
226
+
227
+ async releaseContext(context) {
228
+ const ownership = this.#contextBrowsers.get(context);
229
+ await context?.close().catch(() => {});
230
+ this.#extraContexts.delete(context);
231
+ this.#contextBrowsers.delete(context);
232
+ if (ownership?.ownsBrowser) {
233
+ await ownership.browser.close().catch(() => {});
234
+ this.#extraBrowsers.delete(ownership.browser);
235
+ }
236
+ }
118
237
 
119
238
  #watchContext(context, browser) {
120
239
  context.once("close", () => {
@@ -125,8 +244,9 @@ class BrowserPool {
125
244
  });
126
245
  }
127
246
 
128
- async #open({ contextOptions = {}, launchArgs = [] } = {}) {
129
- const { mode, channel, profileDir } = this.#settings.browser;
247
+ async #open({ contextOptions = {}, launchArgs = [], profileDir: configuredProfileDir } = {}) {
248
+ const { mode, channel } = this.#settings.browser;
249
+ const profileDir = configuredProfileDir || this.#settings.browser.profileDir;
130
250
  try {
131
251
  if (mode === "computer") {
132
252
  this.#context = await chromium.launchPersistentContext(profileDir, {
@@ -156,7 +276,12 @@ class BrowserPool {
156
276
  }
157
277
  }
158
278
 
159
- async close() {
279
+ async close() {
280
+ await Promise.all([...this.#extraContexts].map((context) => this.releaseContext(context)));
281
+ await Promise.all([...this.#extraBrowsers].map((browser) => browser.close().catch(() => {})));
282
+ this.#extraContexts.clear();
283
+ this.#extraBrowsers.clear();
284
+ this.#contextBrowsers.clear();
160
285
  if (this.#context) await this.#context.close().catch(() => {});
161
286
  if (this.#browser) await this.#browser.close().catch(() => {});
162
287
  this.#context = undefined;
@@ -290,11 +415,33 @@ export function createJevBrowserServer({ env = process.env, configPath, fetchImp
290
415
  description: "Check the managed browser connection; set restart=true to close and restart only the browser session owned by this MCP process.",
291
416
  inputSchema: { restart: z.boolean().optional() },
292
417
  },
293
- async ({ restart = false } = {}) => toolText(
294
- restart ? await browserPool.restart() : await browserPool.health(),
295
- settings.browser.maxToolResponseBytes,
296
- ),
297
- );
418
+ async ({ restart = false } = {}) => {
419
+ const health = restart ? await browserPool.restart() : await browserPool.health();
420
+ return toolText({
421
+ ...health,
422
+ server_version: packageManifest.version,
423
+ browser_mode: settings.browser.mode,
424
+ session_id: settings.browser.sessionId,
425
+ capabilities: {
426
+ actions: Object.keys(describeBrowserActions().actions),
427
+ options: describeBrowserActions().options,
428
+ media_permissions: ["microphone", "geolocation"],
429
+ fake_media: "WAV audio input",
430
+ storage_state: "named account snapshots",
431
+ contexts: ["current", "new isolated context"],
432
+ },
433
+ }, settings.browser.maxToolResponseBytes);
434
+ },
435
+ );
436
+
437
+ server.registerTool(
438
+ "describe_actions",
439
+ {
440
+ description: "Return the live JSON contract for every supported Jev Browser MCP action, option, alias, timeout and placeholder rule.",
441
+ inputSchema: {},
442
+ },
443
+ async () => toolText({ server_version: packageManifest.version, ...describeBrowserActions() }, settings.browser.maxToolResponseBytes),
444
+ );
298
445
 
299
446
  server.registerTool(
300
447
  "choose_next_action",
@@ -320,18 +467,18 @@ export function createJevBrowserServer({ env = process.env, configPath, fetchImp
320
467
  },
321
468
  );
322
469
 
323
- server.registerTool(
324
- "run_browser_flow",
325
- {
326
- description: "Run a bounded screen flow in a warm Playwright session. Continues the current same-origin page by default without repeating navigation. Supports role and label locators, exact row scopes, guarded dialog confirmation, network assertions, variables and extraction, AngularJS idle waits, field-value/enabled waits, dry runs before mutating steps, per-step timing and screenshots, Markdown/JUnit reports, uploads, downloads, accessibility audits, and clear navigation diagnostics. Jev chooses among supplied plans; one plan skips the decision call when fast_path is enabled.",
327
- inputSchema: {
328
- flow: z.string(),
329
- initial_url: z.string(),
330
- expected_outcome: z.string(),
331
- candidate_plans: z.record(z.string(), z.unknown()),
332
- params: z.record(z.string(), z.unknown()).optional(),
333
- options: z.record(z.string(), z.unknown()).optional(),
334
- },
470
+ server.registerTool(
471
+ "run_browser_flow",
472
+ {
473
+ 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
+ inputSchema: {
475
+ flow: z.string(),
476
+ initial_url: z.string().optional(),
477
+ expected_outcome: expectedOutcomeSchema.optional(),
478
+ candidate_plans: z.record(z.string(), candidatePlanSchema),
479
+ params: z.record(z.string(), z.unknown()).optional(),
480
+ options: browserOptionsSchema.optional(),
481
+ },
335
482
  },
336
483
  async ({ flow, initial_url: initialUrl, expected_outcome: expectedOutcome, candidate_plans: candidatePlans, params, options }) => {
337
484
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegosouzacdv/jev-browser-mcp",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
4
4
  "description": "Portable MCP server for bounded Playwright screen flows selected by Jev",
5
5
  "license": "MIT",
6
6
  "repository": {