@diegosouzacdv/jev-browser-mcp 0.6.1 → 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,21 +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
- #flowQueue = Promise.resolve();
102
+ #flowQueue = Promise.resolve();
103
+ #configurationKey;
104
+ #lastConfiguration = {};
25
105
 
26
106
  constructor(settings) {
27
107
  this.#settings = settings;
@@ -47,12 +127,17 @@ class BrowserPool {
47
127
  }
48
128
  }
49
129
 
50
- async getPage() {
51
- let isOpening = !this.#contextPromise;
52
- if (isOpening) {
53
- this.#everStarted = true;
54
- this.#startedAt = performance.now();
55
- this.#contextPromise = this.#open();
130
+ async getPage(configuration) {
131
+ if (configuration === undefined) configuration = this.#lastConfiguration;
132
+ else this.#lastConfiguration = configuration;
133
+ const configurationKey = JSON.stringify(configuration);
134
+ if (this.#contextPromise && this.#configurationKey !== configurationKey) await this.close();
135
+ let isOpening = !this.#contextPromise;
136
+ if (isOpening) {
137
+ this.#everStarted = true;
138
+ this.#startedAt = performance.now();
139
+ this.#configurationKey = configurationKey;
140
+ this.#contextPromise = this.#open(configuration);
56
141
  }
57
142
  try {
58
143
  await this.#contextPromise;
@@ -64,7 +149,8 @@ class BrowserPool {
64
149
  if (!isOpening) {
65
150
  isOpening = true;
66
151
  this.#startedAt = performance.now();
67
- this.#contextPromise = this.#open();
152
+ this.#configurationKey = configurationKey;
153
+ this.#contextPromise = this.#open(configuration);
68
154
  await this.#contextPromise;
69
155
  } else {
70
156
  throw error;
@@ -93,18 +179,61 @@ class BrowserPool {
93
179
  }
94
180
  }
95
181
 
96
- async restart() {
182
+ async restart() {
97
183
  await this.close();
98
184
  this.#everStarted = true;
99
185
  this.#startedAt = performance.now();
100
- this.#contextPromise = this.#open();
186
+ this.#contextPromise = this.#open(this.#lastConfiguration);
101
187
  try {
102
188
  await this.#contextPromise;
103
189
  return { status: "connected", connected: true, recovered: true, restarted: true, current_url: this.#page.url() };
104
190
  } catch (error) {
105
191
  return { status: "disconnected", connected: false, restarted: true, reason: error?.message || error?.name || "browser unavailable" };
106
192
  }
107
- }
193
+ }
194
+
195
+ async replaceClosedPage(page) {
196
+ if (this.#page !== page || !this.#context) return;
197
+ this.#page = this.#context.pages()[0] || await this.#context.newPage();
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
+ }
108
237
 
109
238
  #watchContext(context, browser) {
110
239
  context.once("close", () => {
@@ -115,19 +244,22 @@ class BrowserPool {
115
244
  });
116
245
  }
117
246
 
118
- async #open() {
119
- 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;
120
250
  try {
121
251
  if (mode === "computer") {
122
- this.#context = await chromium.launchPersistentContext(profileDir, {
123
- channel,
124
- headless: false,
125
- });
252
+ this.#context = await chromium.launchPersistentContext(profileDir, {
253
+ channel,
254
+ headless: false,
255
+ args: launchArgs,
256
+ ...contextOptions,
257
+ });
126
258
  this.#watchContext(this.#context);
127
259
  this.#page = this.#context.pages()[0] || await this.#context.newPage();
128
260
  } else {
129
- this.#browser = await chromium.launch({ channel, headless: true });
130
- this.#context = await this.#browser.newContext();
261
+ this.#browser = await chromium.launch({ channel, headless: true, args: launchArgs });
262
+ this.#context = await this.#browser.newContext(contextOptions);
131
263
  this.#watchContext(this.#context, this.#browser);
132
264
  this.#page = await this.#context.newPage();
133
265
  }
@@ -144,14 +276,20 @@ class BrowserPool {
144
276
  }
145
277
  }
146
278
 
147
- 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();
148
285
  if (this.#context) await this.#context.close().catch(() => {});
149
286
  if (this.#browser) await this.#browser.close().catch(() => {});
150
287
  this.#context = undefined;
151
288
  this.#browser = undefined;
152
289
  this.#page = undefined;
153
- this.#contextPromise = undefined;
154
- this.#navigationError = undefined;
290
+ this.#contextPromise = undefined;
291
+ this.#navigationError = undefined;
292
+ this.#configurationKey = undefined;
155
293
  }
156
294
  }
157
295
 
@@ -164,11 +302,46 @@ function toolError(error, settings) {
164
302
  return { isError: true, content: [{ type: "text", text: `[jev-browser ${packageManifest.version}] ${message}` }] };
165
303
  }
166
304
 
167
- function toolText(value) {
168
- const versioned = value && typeof value === "object" && !Array.isArray(value)
169
- ? { ...value, server_version: packageManifest.version }
170
- : { result: value, server_version: packageManifest.version };
171
- return { content: [{ type: "text", text: JSON.stringify(versioned) }] };
305
+ export function fitToolResponse(value, maxBytes) {
306
+ const clone = structuredClone(value && typeof value === "object" ? value : { result: value });
307
+ const measure = () => Buffer.byteLength(JSON.stringify(clone), "utf8");
308
+ if (measure() <= maxBytes) return clone;
309
+ clone.output_truncated = true;
310
+ delete clone.unnamed_controls;
311
+ if (typeof clone.final_snapshot === "string") clone.final_snapshot = "[snapshot omitted to fit the configured MCP response limit]";
312
+ for (const key of ["unnamed_controls_initial", "unnamed_controls_final"]) {
313
+ if (Array.isArray(clone[key])) clone[key] = clone[key].map(({ role, index, nearest_label }) => ({ role, index, nearest_label }));
314
+ }
315
+ for (const key of ["console_errors", "network_failures", "warnings"]) {
316
+ if (Array.isArray(clone[key])) clone[key] = clone[key].slice(-10);
317
+ }
318
+ for (const step of clone.steps || []) {
319
+ delete step.html;
320
+ if (typeof step.error === "string") step.error = step.error.slice(0, 300);
321
+ }
322
+ while (measure() > maxBytes) {
323
+ if (Array.isArray(clone.steps) && clone.steps.length > 4) clone.steps.splice(1, clone.steps.length - 4);
324
+ else if (Array.isArray(clone.network_failures) && clone.network_failures.length) clone.network_failures.shift();
325
+ else if (Array.isArray(clone.console_errors) && clone.console_errors.length) clone.console_errors.shift();
326
+ else if (Array.isArray(clone.warnings) && clone.warnings.length) clone.warnings.shift();
327
+ else break;
328
+ }
329
+ if (measure() <= maxBytes) return clone;
330
+ return {
331
+ ...(clone.server_version ? { server_version: clone.server_version } : {}),
332
+ status: clone.status || "failed",
333
+ output_truncated: true,
334
+ ...(clone.reason ? { reason: String(clone.reason).slice(0, 300) } : {}),
335
+ ...(Number.isSafeInteger(clone.steps_executed) ? { steps_executed: clone.steps_executed } : {}),
336
+ };
337
+ }
338
+
339
+ function toolText(value, maxBytes) {
340
+ const versioned = value && typeof value === "object" && !Array.isArray(value)
341
+ ? { ...value, server_version: packageManifest.version }
342
+ : { result: value, server_version: packageManifest.version };
343
+ const bounded = maxBytes ? fitToolResponse(versioned, maxBytes) : versioned;
344
+ return { content: [{ type: "text", text: JSON.stringify(bounded) }] };
172
345
  }
173
346
 
174
347
  export function profileOwnerPidFromProcessList(processes, profileDir, platform = process.platform) {
@@ -242,8 +415,33 @@ export function createJevBrowserServer({ env = process.env, configPath, fetchImp
242
415
  description: "Check the managed browser connection; set restart=true to close and restart only the browser session owned by this MCP process.",
243
416
  inputSchema: { restart: z.boolean().optional() },
244
417
  },
245
- async ({ restart = false } = {}) => toolText(restart ? await browserPool.restart() : await browserPool.health()),
246
- );
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
+ );
247
445
 
248
446
  server.registerTool(
249
447
  "choose_next_action",
@@ -259,29 +457,32 @@ export function createJevBrowserServer({ env = process.env, configPath, fetchImp
259
457
  },
260
458
  async ({ flow, page_snapshot: pageSnapshot, actions, completed_steps: completedSteps, local_only: localOnly }) => {
261
459
  try {
262
- return toolText(await chooseNextAction({ flow, pageSnapshot, actions, completedSteps, localOnly, settings, fetchImpl }));
460
+ return toolText(
461
+ await chooseNextAction({ flow, pageSnapshot, actions, completedSteps, localOnly, settings, fetchImpl }),
462
+ settings.browser.maxToolResponseBytes,
463
+ );
263
464
  } catch (error) {
264
465
  return toolError(error, settings);
265
466
  }
266
467
  },
267
468
  );
268
469
 
269
- server.registerTool(
270
- "run_browser_flow",
271
- {
272
- 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.",
273
- inputSchema: {
274
- flow: z.string(),
275
- initial_url: z.string(),
276
- expected_outcome: z.string(),
277
- candidate_plans: z.record(z.string(), z.unknown()),
278
- params: z.record(z.string(), z.unknown()).optional(),
279
- options: z.record(z.string(), z.unknown()).optional(),
280
- },
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
+ },
281
482
  },
282
483
  async ({ flow, initial_url: initialUrl, expected_outcome: expectedOutcome, candidate_plans: candidatePlans, params, options }) => {
283
484
  try {
284
- return toolText(await browserPool.runFlow(async () => {
485
+ return toolText(await browserPool.runFlow(async () => {
285
486
  const args = {
286
487
  flow,
287
488
  initialUrl,
@@ -302,7 +503,7 @@ export function createJevBrowserServer({ env = process.env, configPath, fetchImp
302
503
  if (!recovery.connected) return { ...first, browser_restart: recovery, retry_count: 0 };
303
504
  const retry = await executeBrowserFlow({ ...args, options: { ...options, reuse_page: false } });
304
505
  return { ...retry, browser_restart: recovery, retry_count: 1 };
305
- }));
506
+ }), settings.browser.maxToolResponseBytes);
306
507
  } catch (error) {
307
508
  return toolError(error, settings);
308
509
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegosouzacdv/jev-browser-mcp",
3
- "version": "0.6.1",
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": {