chromeflow 0.7.0 → 0.8.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/CLAUDE.md CHANGED
@@ -143,7 +143,13 @@ the image to Claude only.
143
143
  auto-saving DataAnnotation form): some auto-save logic diffs against the last-saved
144
144
  value and skips no-op writes. To force a real save on each tick without changing
145
145
  visible content, toggle a trailing space — add when absent, remove when present.
146
- `fill_input` value comparison handles both directions transparently.
146
+ `fill_input` value comparison handles both directions transparently. **Caveat:**
147
+ long-running heartbeats that toggle whitespace on a real form field have been
148
+ observed to drift other fields' React state out of sync (the re-render reset a
149
+ separate radio's checked state to the form-level store value). For heartbeat
150
+ loops, prefer writing to `localStorage` via `execute_script` instead — the
151
+ auto-save handler usually fires on any input event, but `localStorage` writes
152
+ don't perturb React state at all.
147
153
  - After any radio/checkbox click that reveals new fields, call `get_form_fields()` again —
148
154
  the inventory will include the new fields and warn if more hidden ones still exist.
149
155
  - If a form has collapsible sections, expand them all before calling `get_form_fields()` so
@@ -228,6 +234,14 @@ click_element("Confirm", until_text_contains="Order placed")
228
234
  ```
229
235
  If success=false: try `react_set_input` to fire the click via the page's own React handler, or use `execute_script("document.querySelector(...).click()")` directly.
230
236
 
237
+ **`click_element` timed out (the WS request, not until-polling)**: the message will say "the click MAY have already fired". On a busy React reconciliation, the click does land but the response read can outrun the 30s WS timeout. Don't blindly retry — re-clicking can toggle React radios OFF or fire a duplicate submit. Verify with `get_page_text`, `wait_for_selector`, or `wait_for_text` first; only retry if the page state confirms the click never took effect.
238
+
239
+ **Modal never opens / submit handler swallowed by stale validation state**: when a Submit button's onClick opens a modal that never renders (e.g. validation thinks the form is incomplete because the form-level React state is stale, but DOM inputs look filled), use `react_call_prop` to call the bypass handler directly:
240
+ ```
241
+ react_call_prop("input[name=justification]", "handleForceSubmitConfirmation", ["my justification text"])
242
+ ```
243
+ Walks up the React fiber from the selector, finds the nearest component with a prop function of the given name, and calls it with the JSON-serializable args. Returns the component name and stringified return value so you can verify the right handler ran.
244
+
231
245
  **`set_file_input` not committing on rapid back-to-back uploads:**
232
246
  The default 3000ms commit-wait is enough for most uploaders. For batch photo uploads on slow react file handlers (eBay's 25-photo carousel, Stripe Connect document upload), increase `wait_ms` to 6000–8000 OR pass `verify_selector` pointing at the thumbnail/Remove-button that should appear:
233
247
  ```
package/dist/index.js CHANGED
@@ -1,12 +1,22 @@
1
1
  #!/usr/bin/env node
2
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { readFileSync } from "node:fs";
5
+ import { fileURLToPath } from "node:url";
4
6
  import { WsBridge } from "./ws-bridge.js";
5
7
  import { registerBrowserTools } from "./tools/browser.js";
6
8
  import { registerHighlightTools } from "./tools/highlight.js";
7
9
  import { registerCaptureTools } from "./tools/capture.js";
8
10
  import { registerFlowTools } from "./tools/flow.js";
9
- import { runSetup, runUpdate, runUninstall } from "./setup.js";
11
+ import { runSetup, runUpdate, runUninstall, runDoctor } from "./setup.js";
12
+ const PACKAGE_VERSION = (() => {
13
+ try {
14
+ const pkgPath = fileURLToPath(new URL("../package.json", import.meta.url));
15
+ return JSON.parse(readFileSync(pkgPath, "utf8")).version;
16
+ } catch {
17
+ return "unknown";
18
+ }
19
+ })();
10
20
  if (process.argv[2] === "setup") {
11
21
  runSetup().catch((err) => {
12
22
  console.error(err);
@@ -22,6 +32,11 @@ if (process.argv[2] === "setup") {
22
32
  console.error(err);
23
33
  process.exit(1);
24
34
  });
35
+ } else if (process.argv[2] === "doctor") {
36
+ runDoctor(PACKAGE_VERSION).catch((err) => {
37
+ console.error(err);
38
+ process.exit(1);
39
+ });
25
40
  } else {
26
41
  main().catch((err) => {
27
42
  console.error("[chromeflow] Fatal error:", err);
@@ -32,12 +47,20 @@ async function main() {
32
47
  const bridge = new WsBridge();
33
48
  const server = new McpServer({
34
49
  name: "chromeflow",
35
- version: "0.1.14"
50
+ version: PACKAGE_VERSION
36
51
  });
37
52
  registerBrowserTools(server, bridge);
38
53
  registerHighlightTools(server, bridge);
39
54
  registerCaptureTools(server, bridge);
40
55
  registerFlowTools(server, bridge);
56
+ const registered = server._registeredTools ?? {};
57
+ const toolNames = Object.keys(registered).sort();
58
+ console.error(`[chromeflow] v${PACKAGE_VERSION} \u2014 registered ${toolNames.length} tools`);
59
+ if (toolNames.length > 0) {
60
+ console.error(`[chromeflow] tools: ${toolNames.join(", ")}`);
61
+ } else {
62
+ console.error(`[chromeflow] WARNING: no tools registered. Try \`npx chromeflow doctor\`.`);
63
+ }
41
64
  server.prompt(
42
65
  "chromeflow-status",
43
66
  "Check if the chromeflow Chrome extension is connected and which tab is active",
@@ -88,5 +111,5 @@ ${tabList}`
88
111
  );
89
112
  const transport = new StdioServerTransport();
90
113
  await server.connect(transport);
91
- console.error("[chromeflow] MCP server running. Waiting for Claude...");
114
+ console.error(`[chromeflow] v${PACKAGE_VERSION} MCP server running. Waiting for Claude...`);
92
115
  }
package/dist/setup.js CHANGED
@@ -1,4 +1,4 @@
1
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from "fs";
2
2
  import { homedir } from "os";
3
3
  import { join, resolve, dirname } from "path";
4
4
  import { fileURLToPath } from "url";
@@ -180,7 +180,11 @@ const CHROMEFLOW_TOOLS = [
180
180
  // v0.6.0+
181
181
  "find_text",
182
182
  "find_input",
183
- "wait_for_text"
183
+ "wait_for_text",
184
+ // v0.6.5+
185
+ "react_set_input",
186
+ // v0.8.0+
187
+ "react_call_prop"
184
188
  ].map((t) => `mcp__chromeflow__${t}`);
185
189
  function patchSettingsLocalJson(cwd) {
186
190
  const claudeDir = join(cwd, ".claude");
@@ -385,7 +389,104 @@ async function runUpdate() {
385
389
  }
386
390
  console.log("Done.\n");
387
391
  }
392
+ function readGlobalChromeflowVersion() {
393
+ try {
394
+ const root = execSync("npm root -g", { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
395
+ const pkgPath = join(root, "chromeflow", "package.json");
396
+ if (!existsSync(pkgPath)) return null;
397
+ return JSON.parse(readFileSync(pkgPath, "utf8")).version;
398
+ } catch {
399
+ return null;
400
+ }
401
+ }
402
+ function listNpxCachedChromeflowVersions() {
403
+ const cacheDir = join(HOME, ".npm", "_npx");
404
+ if (!existsSync(cacheDir)) return [];
405
+ const out = [];
406
+ let entries;
407
+ try {
408
+ entries = readdirSync(cacheDir);
409
+ } catch {
410
+ return [];
411
+ }
412
+ for (const hash of entries) {
413
+ const pkgPath = join(cacheDir, hash, "node_modules", "chromeflow", "package.json");
414
+ if (!existsSync(pkgPath)) continue;
415
+ try {
416
+ const v = JSON.parse(readFileSync(pkgPath, "utf8")).version;
417
+ out.push({ hash, version: v });
418
+ } catch {
419
+ }
420
+ }
421
+ return out;
422
+ }
423
+ async function fetchLatestPublishedVersion() {
424
+ try {
425
+ const res = await fetch("https://registry.npmjs.org/chromeflow/latest");
426
+ if (!res.ok) return null;
427
+ const json = await res.json();
428
+ return json.version ?? null;
429
+ } catch {
430
+ return null;
431
+ }
432
+ }
433
+ function compareSemver(a, b) {
434
+ const pa = a.split(".").map(Number);
435
+ const pb = b.split(".").map(Number);
436
+ for (let i = 0; i < 3; i++) {
437
+ const da = pa[i] ?? 0;
438
+ const db = pb[i] ?? 0;
439
+ if (da !== db) return da - db;
440
+ }
441
+ return 0;
442
+ }
443
+ async function runDoctor(runningVersion) {
444
+ console.log("\nChromeflow Doctor\n" + "\u2500".repeat(40));
445
+ const installScript = fileURLToPath(import.meta.url);
446
+ console.log(`Running version: ${runningVersion}`);
447
+ console.log(`Running from: ${installScript}`);
448
+ const globalVersion = readGlobalChromeflowVersion();
449
+ console.log(`Global install: ${globalVersion ?? "(none \u2014 not installed via `npm install -g chromeflow`)"}`);
450
+ const npxCaches = listNpxCachedChromeflowVersions();
451
+ if (npxCaches.length === 0) {
452
+ console.log(`npx cache: (empty)`);
453
+ } else {
454
+ console.log(`npx cache (${npxCaches.length} ${npxCaches.length === 1 ? "entry" : "entries"}):`);
455
+ for (const c of npxCaches) console.log(` ${c.hash}: ${c.version}`);
456
+ }
457
+ const latest = await fetchLatestPublishedVersion();
458
+ console.log(`Latest on npm: ${latest ?? "(unable to reach https://registry.npmjs.org)"}`);
459
+ const allKnown = [runningVersion];
460
+ if (globalVersion) allKnown.push(globalVersion);
461
+ for (const c of npxCaches) allKnown.push(c.version);
462
+ const stale = latest ? allKnown.some((v) => v !== "unknown" && compareSemver(v, latest) < 0) : false;
463
+ if (stale) {
464
+ console.log("\n\u26A0 Stale chromeflow install detected.");
465
+ console.log(" Reinstall recipe:");
466
+ console.log(" npm uninstall -g chromeflow");
467
+ console.log(" rm -rf ~/.npm/_npx");
468
+ console.log(" npm install -g chromeflow@latest");
469
+ console.log(" Then restart Claude Code so the MCP server picks up the new version.");
470
+ } else if (latest && compareSemver(runningVersion, latest) === 0) {
471
+ console.log("\n\u2713 chromeflow is up to date.");
472
+ } else if (!latest) {
473
+ console.log("\n? Could not check the registry; skipping freshness check.");
474
+ } else {
475
+ console.log("\n\u2713 Running version is current.");
476
+ }
477
+ console.log("\nTools shipped in this build (from setup manifest):");
478
+ const toolNames = CHROMEFLOW_TOOLS.map((t) => t.replace("mcp__chromeflow__", ""));
479
+ console.log(" " + toolNames.join(", "));
480
+ console.log(` (${toolNames.length} tools)`);
481
+ console.log("\nIf chromeflow tools are missing in Claude Code:");
482
+ console.log(" 1. Restart Claude Code (the MCP server is started at session start).");
483
+ console.log(" 2. If still missing, run the reinstall recipe above.");
484
+ console.log(" 3. Confirm the MCP startup log shows the version banner");
485
+ console.log(` ([chromeflow] v${latest ?? "X.Y.Z"} \u2014 registered N tools).`);
486
+ console.log("");
487
+ }
388
488
  export {
489
+ runDoctor,
389
490
  runSetup,
390
491
  runUninstall,
391
492
  runUpdate
@@ -323,6 +323,39 @@ Returns the matched element's tag/name/id/type so you can verify it was the righ
323
323
  };
324
324
  }
325
325
  );
326
+ server.tool(
327
+ "react_call_prop",
328
+ `Walk up the React fiber from a DOM element and call a named prop on the nearest component that has it. Use this as an escape hatch when the UI swallows clicks or a modal never renders \u2014 e.g. calling handleForceSubmitConfirmation directly to bypass a stuck submit modal.
329
+
330
+ Common cases:
331
+ - A submit button whose onClick opens a modal that never appears (validation thinks the form is incomplete because the form-level state is stale, even though the inputs look filled). Walk up to the page-level component and call the bypass handler directly.
332
+ - An onChange handler that the synthetic-event path didn't reach (when click_element fired but React's form-level store wasn't updated).
333
+
334
+ args MUST be JSON-serializable (primitives, arrays, plain objects). Functions, DOM nodes, and Promises cannot be passed in.
335
+
336
+ Returns the component name (when available), the fiber depth where the prop was found, and a stringified version of the return value. If the prop function returned a Promise, react_call_prop awaits it before returning.`,
337
+ {
338
+ selector: z.string().describe(`CSS selector of any element inside the target component's subtree (e.g. 'input[name="justification"]', '#submit-button')`),
339
+ prop_name: z.string().describe("Name of the prop function to call (e.g. 'handleForceSubmitConfirmation', 'onChange', 'onSubmit')"),
340
+ args: z.array(z.any()).optional().describe("Arguments to pass; must be JSON-serializable (primitives, arrays, plain objects). Default: empty."),
341
+ max_depth: z.number().int().min(1).optional().describe("How many fiber levels to walk up before giving up (default 30)"),
342
+ frame: z.string().optional().describe('Optional CSS selector for a same-origin iframe whose contents contain the element (e.g. "iframe.se-rte-frame"). Cross-origin iframes are not supported.')
343
+ },
344
+ async ({ selector, prop_name, args = [], max_depth = 30, frame }) => {
345
+ const response = await bridge.request({
346
+ type: "react_call_prop",
347
+ selector,
348
+ prop_name,
349
+ args,
350
+ max_depth,
351
+ frame
352
+ }, 3e4);
353
+ const r = response;
354
+ return {
355
+ content: [{ type: "text", text: r.message ?? (r.success ? "Called" : "Failed to call prop") }]
356
+ };
357
+ }
358
+ );
326
359
  server.tool(
327
360
  "execute_script",
328
361
  `Execute JavaScript in the current page's context and return the result. Use for reading framework state or DOM properties not visible in text \u2014 prefer get_page_text for visible content. Top-level \`return\` and \`await\` are supported.
@@ -37,10 +37,30 @@ If the until-condition is not met within until_timeout_ms (default 5000ms), clic
37
37
  },
38
38
  async ({ textHint, nth, until_selector, until_url_contains, until_text_contains, until_timeout_ms }) => {
39
39
  const wsTimeout = Math.max(3e4, (until_timeout_ms ?? 0) + 1e4);
40
- const response = await bridge.request(
41
- { type: "click_element", textHint, nth, until_selector, until_url_contains, until_text_contains, until_timeout_ms },
42
- wsTimeout
43
- );
40
+ let response;
41
+ try {
42
+ response = await bridge.request(
43
+ { type: "click_element", textHint, nth, until_selector, until_url_contains, until_text_contains, until_timeout_ms },
44
+ wsTimeout
45
+ );
46
+ } catch (err) {
47
+ const errMsg = err instanceof Error ? err.message : String(err);
48
+ if (errMsg.includes("timed out")) {
49
+ return {
50
+ content: [
51
+ {
52
+ type: "text",
53
+ text: `Could not confirm click on "${textHint}": ${errMsg}. The click MAY have already fired \u2014 the page just took longer than ${wsTimeout}ms to respond. Verify with get_page_text or wait_for_selector before retrying. Re-clicking can toggle the wrong way on React-controlled radios.`
54
+ }
55
+ ]
56
+ };
57
+ }
58
+ return {
59
+ content: [
60
+ { type: "text", text: `Could not click "${textHint}": ${errMsg}` }
61
+ ]
62
+ };
63
+ }
44
64
  const r = response;
45
65
  if (!r.success) {
46
66
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chromeflow",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Browser guidance MCP server for Claude Code — highlights, clicks, fills, and captures from the web so you don't have to.",
5
5
  "type": "module",
6
6
  "bin": {