@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.
- package/README.md +100 -15
- package/docs/jev-browser-mcp.md +100 -15
- package/mcp_servers/jev-browser-npm/src/flow.mjs +411 -46
- package/mcp_servers/jev-browser-npm/src/server.mjs +94 -28
- package/package.json +1 -1
|
@@ -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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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);
|