@diegosouzacdv/jev-browser-mcp 0.4.2 → 0.5.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
- import { spawnSync } from "node:child_process";
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";
@@ -11,18 +12,27 @@ import { chooseNextAction, executeBrowserFlow } from "./flow.mjs";
11
12
 
12
13
  const browserPools = new Set();
13
14
 
14
- class BrowserPool {
15
+ class BrowserPool {
15
16
  #settings;
16
17
  #contextPromise;
17
18
  #context;
18
19
  #browser;
19
20
  #page;
20
- #startedAt;
21
+ #startedAt;
22
+ #navigationError;
21
23
  #flowQueue = Promise.resolve();
22
24
 
23
- constructor(settings) {
25
+ constructor(settings) {
24
26
  this.#settings = settings;
25
- }
27
+ }
28
+
29
+ getNavigationError() {
30
+ return this.#navigationError;
31
+ }
32
+
33
+ setNavigationError(error) {
34
+ this.#navigationError = error || undefined;
35
+ }
26
36
 
27
37
  async runFlow(work) {
28
38
  const previous = this.#flowQueue;
@@ -63,11 +73,15 @@ class BrowserPool {
63
73
  this.#context = await this.#browser.newContext();
64
74
  this.#page = await this.#context.newPage();
65
75
  }
66
- } catch (error) {
67
- this.#contextPromise = undefined;
68
- throw new JevBrowserError(
69
- `Could not start the configured ${channel} browser (${error?.name || "BrowserError"}); install it with jev-browser-mcp --install-browser`,
70
- );
76
+ } catch (error) {
77
+ this.#contextPromise = undefined;
78
+ const ownerPid = mode === "computer" ? await activeProfileOwnerPid(profileDir) : undefined;
79
+ if (ownerPid) {
80
+ throw new JevBrowserError(`browser profile is in use by PID ${ownerPid}; set JEV_BROWSER_PROFILE to a separate profile directory`);
81
+ }
82
+ throw new JevBrowserError(
83
+ `Could not start the configured ${channel} browser (${error?.name || "BrowserError"}); install it with jev-browser-mcp --install-browser`,
84
+ );
71
85
  }
72
86
  }
73
87
 
@@ -76,19 +90,68 @@ class BrowserPool {
76
90
  if (this.#browser) await this.#browser.close().catch(() => {});
77
91
  this.#context = undefined;
78
92
  this.#browser = undefined;
79
- this.#page = undefined;
80
- this.#contextPromise = undefined;
93
+ this.#page = undefined;
94
+ this.#contextPromise = undefined;
95
+ this.#navigationError = undefined;
81
96
  }
82
97
  }
83
98
 
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 }] };
87
- }
88
-
89
- function toolText(value) {
90
- return { content: [{ type: "text", text: JSON.stringify(value) }] };
91
- }
99
+ function toolError(error) {
100
+ const message = error instanceof JevBrowserError ? error.message : `Jev browser flow failed (${error?.name || "Error"})`;
101
+ return { isError: true, content: [{ type: "text", text: `[jev-browser ${packageManifest.version}] ${message}` }] };
102
+ }
103
+
104
+ function toolText(value) {
105
+ const versioned = value && typeof value === "object" && !Array.isArray(value)
106
+ ? { ...value, server_version: packageManifest.version }
107
+ : { result: value, server_version: packageManifest.version };
108
+ return { content: [{ type: "text", text: JSON.stringify(versioned) }] };
109
+ }
110
+
111
+ export function profileOwnerPidFromProcessList(processes, profileDir, platform = process.platform) {
112
+ const normalize = platform === "win32" ? path.win32.normalize : path.resolve;
113
+ const expectedProfile = normalize(profileDir).replace(/[\\/]+$/, "").toLowerCase();
114
+ for (const processInfo of Array.isArray(processes) ? processes : [processes]) {
115
+ if (!processInfo || typeof processInfo !== "object") continue;
116
+ const commandLine = typeof processInfo.CommandLine === "string" ? processInfo.CommandLine : "";
117
+ const match = commandLine.match(/(?:^|\s)--user-data-dir(?:=|\s+)(?:"([^"]+)"|'([^']+)'|([^\s"']+))/i);
118
+ const processProfile = match?.[1] || match?.[2] || match?.[3];
119
+ const pid = Number(processInfo.ProcessId ?? processInfo.pid);
120
+ if (!processProfile || !Number.isSafeInteger(pid) || pid <= 0) continue;
121
+ if (normalize(processProfile).replace(/[\\/]+$/, "").toLowerCase() === expectedProfile) return pid;
122
+ }
123
+ return undefined;
124
+ }
125
+
126
+ async function activeProfileOwnerPid(profileDir) {
127
+ try {
128
+ const lockTarget = await readlink(path.join(profileDir, "SingletonLock"));
129
+ const pid = Number(path.basename(lockTarget).match(/-(\d+)$/)?.[1]);
130
+ if (!Number.isSafeInteger(pid) || pid <= 0) return undefined;
131
+ try {
132
+ process.kill(pid, 0);
133
+ return pid;
134
+ } catch (error) {
135
+ if (error?.code === "EPERM") return pid;
136
+ }
137
+ } catch {
138
+ // Windows holds profile ownership in a process lock rather than a PID symlink.
139
+ }
140
+ if (process.platform !== "win32") return undefined;
141
+ const script = "$ErrorActionPreference='Stop'; Get-CimInstance Win32_Process -Filter \"Name='chrome.exe' OR Name='msedge.exe'\" | Select-Object ProcessId,CommandLine | ConvertTo-Json -Compress";
142
+ const listing = spawnSync("powershell.exe", ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script], {
143
+ encoding: "utf8",
144
+ timeout: 2000,
145
+ windowsHide: true,
146
+ });
147
+ if (listing.status !== 0 || !listing.stdout?.trim()) return undefined;
148
+ try {
149
+ const processes = JSON.parse(listing.stdout);
150
+ return profileOwnerPidFromProcessList(processes, profileDir);
151
+ } catch {
152
+ return undefined;
153
+ }
154
+ }
92
155
 
93
156
  export function createJevBrowserServer({ env = process.env, configPath, fetchImpl = fetch } = {}) {
94
157
  const settings = loadSettings({ env, ...(configPath ? { configPath } : {}) });
@@ -132,26 +195,29 @@ export function createJevBrowserServer({ env = process.env, configPath, fetchImp
132
195
  server.registerTool(
133
196
  "run_browser_flow",
134
197
  {
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.",
198
+ description: "Run a bounded screen flow in a warm Playwright session. Supports role and label locators, exact row scopes, guarded dialog confirmation, network assertions, variables and extraction, 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
199
  inputSchema: {
137
200
  flow: z.string(),
138
201
  initial_url: z.string(),
139
202
  expected_outcome: z.string(),
140
- candidate_plans: z.record(z.string(), z.unknown()),
141
- options: z.record(z.string(), z.unknown()).optional(),
142
- },
143
- },
144
- async ({ flow, initial_url: initialUrl, expected_outcome: expectedOutcome, candidate_plans: candidatePlans, options }) => {
203
+ candidate_plans: z.record(z.string(), z.unknown()),
204
+ params: z.record(z.string(), z.unknown()).optional(),
205
+ options: z.record(z.string(), z.unknown()).optional(),
206
+ },
207
+ },
208
+ async ({ flow, initial_url: initialUrl, expected_outcome: expectedOutcome, candidate_plans: candidatePlans, params, options }) => {
145
209
  try {
146
210
  return toolText(await browserPool.runFlow(() => executeBrowserFlow({
147
211
  flow,
148
212
  initialUrl,
149
213
  expectedOutcome,
150
- candidatePlans,
214
+ candidatePlans,
215
+ params,
151
216
  options,
152
217
  settings,
153
218
  browserPool,
154
- fetchImpl,
219
+ fetchImpl,
220
+ serverVersion: packageManifest.version,
155
221
  })));
156
222
  } catch (error) {
157
223
  return toolError(error);
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.5.0",
4
4
  "description": "Portable MCP server for bounded Playwright screen flows selected by Jev",
5
5
  "license": "MIT",
6
6
  "repository": {