@diegosouzacdv/jev-browser-mcp 0.4.2 → 0.6.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.
@@ -1,4 +1,5 @@
1
1
  import { spawnSync } from "node:child_process";
2
+ import { readlink } from "node:fs/promises";
2
3
  import path from "node:path";
3
4
  import { fileURLToPath } from "node:url";
4
5
  import { McpServer } from "@modelcontextprotocol/server";
@@ -18,12 +19,22 @@ class BrowserPool {
18
19
  #browser;
19
20
  #page;
20
21
  #startedAt;
22
+ #everStarted = false;
23
+ #navigationError;
21
24
  #flowQueue = Promise.resolve();
22
25
 
23
26
  constructor(settings) {
24
27
  this.#settings = settings;
25
28
  }
26
29
 
30
+ getNavigationError() {
31
+ return this.#navigationError;
32
+ }
33
+
34
+ setNavigationError(error) {
35
+ this.#navigationError = error || undefined;
36
+ }
37
+
27
38
  async runFlow(work) {
28
39
  const previous = this.#flowQueue;
29
40
  let release;
@@ -37,18 +48,60 @@ class BrowserPool {
37
48
  }
38
49
 
39
50
  async getPage() {
40
- if (this.#page?.isClosed() && this.#context) {
41
- this.#page = this.#context.pages()[0] || await this.#context.newPage();
42
- }
43
- const isOpening = !this.#contextPromise;
51
+ let isOpening = !this.#contextPromise;
44
52
  if (isOpening) {
53
+ this.#everStarted = true;
45
54
  this.#startedAt = performance.now();
46
55
  this.#contextPromise = this.#open();
47
56
  }
48
- await this.#contextPromise;
57
+ try {
58
+ await this.#contextPromise;
59
+ if (this.#page?.isClosed() && this.#context) {
60
+ this.#page = this.#context.pages()[0] || await this.#context.newPage();
61
+ }
62
+ } catch (error) {
63
+ this.#invalidate("BROWSER_DISCONNECTED");
64
+ if (!isOpening) {
65
+ isOpening = true;
66
+ this.#startedAt = performance.now();
67
+ this.#contextPromise = this.#open();
68
+ await this.#contextPromise;
69
+ } else {
70
+ throw error;
71
+ }
72
+ }
49
73
  return { page: this.#page, context: this.#context, sessionMs: isOpening ? performance.now() - this.#startedAt : 0 };
50
74
  }
51
75
 
76
+ #invalidate(reason) {
77
+ this.#navigationError = reason;
78
+ this.#context = undefined;
79
+ this.#browser = undefined;
80
+ this.#page = undefined;
81
+ this.#contextPromise = undefined;
82
+ }
83
+
84
+ async health() {
85
+ if (!this.#everStarted) {
86
+ return { status: "not_started", connected: false };
87
+ }
88
+ try {
89
+ const { page, sessionMs } = await this.getPage();
90
+ return { status: "connected", connected: true, recovered: sessionMs > 0, current_url: page.url() };
91
+ } catch (error) {
92
+ return { status: "disconnected", connected: false, reason: error?.message || error?.name || "browser unavailable" };
93
+ }
94
+ }
95
+
96
+ #watchContext(context, browser) {
97
+ context.once("close", () => {
98
+ if (this.#context === context) this.#invalidate("BROWSER_DISCONNECTED");
99
+ });
100
+ browser?.once("disconnected", () => {
101
+ if (this.#browser === browser) this.#invalidate("BROWSER_DISCONNECTED");
102
+ });
103
+ }
104
+
52
105
  async #open() {
53
106
  const { mode, channel, profileDir } = this.#settings.browser;
54
107
  try {
@@ -57,37 +110,97 @@ class BrowserPool {
57
110
  channel,
58
111
  headless: false,
59
112
  });
113
+ this.#watchContext(this.#context);
60
114
  this.#page = this.#context.pages()[0] || await this.#context.newPage();
61
- } else {
62
- this.#browser = await chromium.launch({ channel, headless: true });
63
- this.#context = await this.#browser.newContext();
64
- this.#page = await this.#context.newPage();
115
+ } else {
116
+ this.#browser = await chromium.launch({ channel, headless: true });
117
+ this.#context = await this.#browser.newContext();
118
+ this.#watchContext(this.#context, this.#browser);
119
+ this.#page = await this.#context.newPage();
65
120
  }
121
+ this.#navigationError = undefined;
66
122
  } catch (error) {
67
- this.#contextPromise = undefined;
123
+ this.#invalidate("BROWSER_DISCONNECTED");
124
+ const ownerPid = mode === "computer" ? await activeProfileOwnerPid(profileDir) : undefined;
125
+ if (ownerPid) {
126
+ throw new JevBrowserError(`browser profile is in use by PID ${ownerPid}; set JEV_BROWSER_PROFILE to a separate profile directory`);
127
+ }
68
128
  throw new JevBrowserError(
69
129
  `Could not start the configured ${channel} browser (${error?.name || "BrowserError"}); install it with jev-browser-mcp --install-browser`,
70
130
  );
71
131
  }
72
132
  }
73
133
 
74
- async close() {
75
- if (this.#context) await this.#context.close().catch(() => {});
76
- if (this.#browser) await this.#browser.close().catch(() => {});
134
+ async close() {
135
+ if (this.#context) await this.#context.close().catch(() => {});
136
+ if (this.#browser) await this.#browser.close().catch(() => {});
77
137
  this.#context = undefined;
78
138
  this.#browser = undefined;
79
139
  this.#page = undefined;
80
140
  this.#contextPromise = undefined;
141
+ this.#navigationError = undefined;
81
142
  }
82
143
  }
83
144
 
84
- function toolError(error) {
85
- const message = error instanceof JevBrowserError ? error.message : `Jev browser flow failed (${error?.name || "Error"})`;
86
- return { isError: true, content: [{ type: "text", text: message }] };
145
+ function toolError(error, settings) {
146
+ let message = error instanceof JevBrowserError ? error.message : error?.message || `Jev browser flow failed (${error?.name || "Error"})`;
147
+ const credential = settings.env?.[settings.jev.credentialEnv];
148
+ if (credential) message = message.split(credential).join("[redacted]");
149
+ message = message.replace(/\bBearer\s+\S+/gi, "Bearer [redacted]")
150
+ .replace(/([?&](?:token|api[_-]?key|password|secret)=)[^&\s]+/gi, "$1[redacted]");
151
+ return { isError: true, content: [{ type: "text", text: `[jev-browser ${packageManifest.version}] ${message}` }] };
87
152
  }
88
153
 
89
154
  function toolText(value) {
90
- return { content: [{ type: "text", text: JSON.stringify(value) }] };
155
+ const versioned = value && typeof value === "object" && !Array.isArray(value)
156
+ ? { ...value, server_version: packageManifest.version }
157
+ : { result: value, server_version: packageManifest.version };
158
+ return { content: [{ type: "text", text: JSON.stringify(versioned) }] };
159
+ }
160
+
161
+ export function profileOwnerPidFromProcessList(processes, profileDir, platform = process.platform) {
162
+ const normalize = platform === "win32" ? path.win32.normalize : path.resolve;
163
+ const expectedProfile = normalize(profileDir).replace(/[\\/]+$/, "").toLowerCase();
164
+ for (const processInfo of Array.isArray(processes) ? processes : [processes]) {
165
+ if (!processInfo || typeof processInfo !== "object") continue;
166
+ const commandLine = typeof processInfo.CommandLine === "string" ? processInfo.CommandLine : "";
167
+ const match = commandLine.match(/(?:^|\s)--user-data-dir(?:=|\s+)(?:"([^"]+)"|'([^']+)'|([^\s"']+))/i);
168
+ const processProfile = match?.[1] || match?.[2] || match?.[3];
169
+ const pid = Number(processInfo.ProcessId ?? processInfo.pid);
170
+ if (!processProfile || !Number.isSafeInteger(pid) || pid <= 0) continue;
171
+ if (normalize(processProfile).replace(/[\\/]+$/, "").toLowerCase() === expectedProfile) return pid;
172
+ }
173
+ return undefined;
174
+ }
175
+
176
+ async function activeProfileOwnerPid(profileDir) {
177
+ try {
178
+ const lockTarget = await readlink(path.join(profileDir, "SingletonLock"));
179
+ const pid = Number(path.basename(lockTarget).match(/-(\d+)$/)?.[1]);
180
+ if (!Number.isSafeInteger(pid) || pid <= 0) return undefined;
181
+ try {
182
+ process.kill(pid, 0);
183
+ return pid;
184
+ } catch (error) {
185
+ if (error?.code === "EPERM") return pid;
186
+ }
187
+ } catch {
188
+ // Windows holds profile ownership in a process lock rather than a PID symlink.
189
+ }
190
+ if (process.platform !== "win32") return undefined;
191
+ const script = "$ErrorActionPreference='Stop'; Get-CimInstance Win32_Process -Filter \"Name='chrome.exe' OR Name='msedge.exe'\" | Select-Object ProcessId,CommandLine | ConvertTo-Json -Compress";
192
+ const listing = spawnSync("powershell.exe", ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script], {
193
+ encoding: "utf8",
194
+ timeout: 2000,
195
+ windowsHide: true,
196
+ });
197
+ if (listing.status !== 0 || !listing.stdout?.trim()) return undefined;
198
+ try {
199
+ const processes = JSON.parse(listing.stdout);
200
+ return profileOwnerPidFromProcessList(processes, profileDir);
201
+ } catch {
202
+ return undefined;
203
+ }
91
204
  }
92
205
 
93
206
  export function createJevBrowserServer({ env = process.env, configPath, fetchImpl = fetch } = {}) {
@@ -109,6 +222,15 @@ export function createJevBrowserServer({ env = process.env, configPath, fetchImp
109
222
  }
110
223
  };
111
224
 
225
+ server.registerTool(
226
+ "browser_health",
227
+ {
228
+ description: "Check whether the managed browser session is connected. If a previously started browser exited, reconnects it and reports whether recovery succeeded.",
229
+ inputSchema: {},
230
+ },
231
+ async () => toolText(await browserPool.health()),
232
+ );
233
+
112
234
  server.registerTool(
113
235
  "choose_next_action",
114
236
  {
@@ -124,7 +246,7 @@ export function createJevBrowserServer({ env = process.env, configPath, fetchImp
124
246
  try {
125
247
  return toolText(await chooseNextAction({ flow, pageSnapshot, actions, completedSteps, settings, fetchImpl }));
126
248
  } catch (error) {
127
- return toolError(error);
249
+ return toolError(error, settings);
128
250
  }
129
251
  },
130
252
  );
@@ -132,29 +254,32 @@ export function createJevBrowserServer({ env = process.env, configPath, fetchImp
132
254
  server.registerTool(
133
255
  "run_browser_flow",
134
256
  {
135
- description: "Run a bounded screen flow in one warm Playwright session. Supports role/name and label, placeholder, title, text, test_id, CSS/XPath fallback selectors, relative row/dialog scopes, press keys, SPA readiness, per-step timeouts, same-origin page reuse, key-based typing, filtered network-idle and hidden-state waits, wait_for_text fail_on alerts, assertions, uploads, bounded downloads, axe audits, Playwright-indexed unnamed-control diagnostics split by initial/final screen, resolved locator and element evidence, and bounded same-origin JSON error bodies including chunked responses. All plan steps run before expected_outcome is checked unless stop_on_expected is enabled. Jev chooses among supplied plans; one plan skips the decision call when fast_path is enabled.",
257
+ 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.",
136
258
  inputSchema: {
137
259
  flow: z.string(),
138
260
  initial_url: z.string(),
139
261
  expected_outcome: z.string(),
140
262
  candidate_plans: z.record(z.string(), z.unknown()),
263
+ params: z.record(z.string(), z.unknown()).optional(),
141
264
  options: z.record(z.string(), z.unknown()).optional(),
142
265
  },
143
266
  },
144
- async ({ flow, initial_url: initialUrl, expected_outcome: expectedOutcome, candidate_plans: candidatePlans, options }) => {
267
+ async ({ flow, initial_url: initialUrl, expected_outcome: expectedOutcome, candidate_plans: candidatePlans, params, options }) => {
145
268
  try {
146
269
  return toolText(await browserPool.runFlow(() => executeBrowserFlow({
147
270
  flow,
148
271
  initialUrl,
149
272
  expectedOutcome,
150
273
  candidatePlans,
274
+ params,
151
275
  options,
152
276
  settings,
153
277
  browserPool,
154
278
  fetchImpl,
279
+ serverVersion: packageManifest.version,
155
280
  })));
156
281
  } catch (error) {
157
- return toolError(error);
282
+ return toolError(error, settings);
158
283
  }
159
284
  },
160
285
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegosouzacdv/jev-browser-mcp",
3
- "version": "0.4.2",
3
+ "version": "0.6.0",
4
4
  "description": "Portable MCP server for bounded Playwright screen flows selected by Jev",
5
5
  "license": "MIT",
6
6
  "repository": {