chromeflow 0.12.2 → 0.12.3

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.
Files changed (2) hide show
  1. package/bin/chromeflow.mjs +366 -165
  2. package/package.json +1 -1
@@ -2988,7 +2988,7 @@ var require_compile = __commonJS({
2988
2988
  const schOrFunc = root.refs[ref];
2989
2989
  if (schOrFunc)
2990
2990
  return schOrFunc;
2991
- let _sch = resolve2.call(this, root, ref);
2991
+ let _sch = resolve3.call(this, root, ref);
2992
2992
  if (_sch === void 0) {
2993
2993
  const schema = (_a = root.localRefs) === null || _a === void 0 ? void 0 : _a[ref];
2994
2994
  const { schemaId } = this.opts;
@@ -3015,7 +3015,7 @@ var require_compile = __commonJS({
3015
3015
  function sameSchemaEnv(s1, s2) {
3016
3016
  return s1.schema === s2.schema && s1.root === s2.root && s1.baseId === s2.baseId;
3017
3017
  }
3018
- function resolve2(root, ref) {
3018
+ function resolve3(root, ref) {
3019
3019
  let sch;
3020
3020
  while (typeof (sch = this.refs[ref]) == "string")
3021
3021
  ref = sch;
@@ -3590,55 +3590,55 @@ var require_fast_uri = __commonJS({
3590
3590
  }
3591
3591
  return uri;
3592
3592
  }
3593
- function resolve2(baseURI, relativeURI, options) {
3593
+ function resolve3(baseURI, relativeURI, options) {
3594
3594
  const schemelessOptions = options ? Object.assign({ scheme: "null" }, options) : { scheme: "null" };
3595
3595
  const resolved = resolveComponent(parse3(baseURI, schemelessOptions), parse3(relativeURI, schemelessOptions), schemelessOptions, true);
3596
3596
  schemelessOptions.skipEscape = true;
3597
3597
  return serialize(resolved, schemelessOptions);
3598
3598
  }
3599
- function resolveComponent(base, relative2, options, skipNormalization) {
3599
+ function resolveComponent(base, relative3, options, skipNormalization) {
3600
3600
  const target = {};
3601
3601
  if (!skipNormalization) {
3602
3602
  base = parse3(serialize(base, options), options);
3603
- relative2 = parse3(serialize(relative2, options), options);
3603
+ relative3 = parse3(serialize(relative3, options), options);
3604
3604
  }
3605
3605
  options = options || {};
3606
- if (!options.tolerant && relative2.scheme) {
3607
- target.scheme = relative2.scheme;
3608
- target.userinfo = relative2.userinfo;
3609
- target.host = relative2.host;
3610
- target.port = relative2.port;
3611
- target.path = removeDotSegments(relative2.path || "");
3612
- target.query = relative2.query;
3606
+ if (!options.tolerant && relative3.scheme) {
3607
+ target.scheme = relative3.scheme;
3608
+ target.userinfo = relative3.userinfo;
3609
+ target.host = relative3.host;
3610
+ target.port = relative3.port;
3611
+ target.path = removeDotSegments(relative3.path || "");
3612
+ target.query = relative3.query;
3613
3613
  } else {
3614
- if (relative2.userinfo !== void 0 || relative2.host !== void 0 || relative2.port !== void 0) {
3615
- target.userinfo = relative2.userinfo;
3616
- target.host = relative2.host;
3617
- target.port = relative2.port;
3618
- target.path = removeDotSegments(relative2.path || "");
3619
- target.query = relative2.query;
3614
+ if (relative3.userinfo !== void 0 || relative3.host !== void 0 || relative3.port !== void 0) {
3615
+ target.userinfo = relative3.userinfo;
3616
+ target.host = relative3.host;
3617
+ target.port = relative3.port;
3618
+ target.path = removeDotSegments(relative3.path || "");
3619
+ target.query = relative3.query;
3620
3620
  } else {
3621
- if (!relative2.path) {
3621
+ if (!relative3.path) {
3622
3622
  target.path = base.path;
3623
- if (relative2.query !== void 0) {
3624
- target.query = relative2.query;
3623
+ if (relative3.query !== void 0) {
3624
+ target.query = relative3.query;
3625
3625
  } else {
3626
3626
  target.query = base.query;
3627
3627
  }
3628
3628
  } else {
3629
- if (relative2.path[0] === "/") {
3630
- target.path = removeDotSegments(relative2.path);
3629
+ if (relative3.path[0] === "/") {
3630
+ target.path = removeDotSegments(relative3.path);
3631
3631
  } else {
3632
3632
  if ((base.userinfo !== void 0 || base.host !== void 0 || base.port !== void 0) && !base.path) {
3633
- target.path = "/" + relative2.path;
3633
+ target.path = "/" + relative3.path;
3634
3634
  } else if (!base.path) {
3635
- target.path = relative2.path;
3635
+ target.path = relative3.path;
3636
3636
  } else {
3637
- target.path = base.path.slice(0, base.path.lastIndexOf("/") + 1) + relative2.path;
3637
+ target.path = base.path.slice(0, base.path.lastIndexOf("/") + 1) + relative3.path;
3638
3638
  }
3639
3639
  target.path = removeDotSegments(target.path);
3640
3640
  }
3641
- target.query = relative2.query;
3641
+ target.query = relative3.query;
3642
3642
  }
3643
3643
  target.userinfo = base.userinfo;
3644
3644
  target.host = base.host;
@@ -3646,7 +3646,7 @@ var require_fast_uri = __commonJS({
3646
3646
  }
3647
3647
  target.scheme = base.scheme;
3648
3648
  }
3649
- target.fragment = relative2.fragment;
3649
+ target.fragment = relative3.fragment;
3650
3650
  return target;
3651
3651
  }
3652
3652
  function equal(uriA, uriB, options) {
@@ -3817,7 +3817,7 @@ var require_fast_uri = __commonJS({
3817
3817
  var fastUri = {
3818
3818
  SCHEMES,
3819
3819
  normalize,
3820
- resolve: resolve2,
3820
+ resolve: resolve3,
3821
3821
  resolveComponent,
3822
3822
  equal,
3823
3823
  serialize,
@@ -22495,7 +22495,7 @@ var Protocol = class {
22495
22495
  return;
22496
22496
  }
22497
22497
  const pollInterval = task2.pollInterval ?? this._options?.defaultTaskPollInterval ?? 1e3;
22498
- await new Promise((resolve2) => setTimeout(resolve2, pollInterval));
22498
+ await new Promise((resolve3) => setTimeout(resolve3, pollInterval));
22499
22499
  options?.signal?.throwIfAborted();
22500
22500
  }
22501
22501
  } catch (error2) {
@@ -22512,7 +22512,7 @@ var Protocol = class {
22512
22512
  */
22513
22513
  request(request, resultSchema, options) {
22514
22514
  const { relatedRequestId, resumptionToken, onresumptiontoken, task, relatedTask } = options ?? {};
22515
- return new Promise((resolve2, reject) => {
22515
+ return new Promise((resolve3, reject) => {
22516
22516
  const earlyReject = (error2) => {
22517
22517
  reject(error2);
22518
22518
  };
@@ -22590,7 +22590,7 @@ var Protocol = class {
22590
22590
  if (!parseResult.success) {
22591
22591
  reject(parseResult.error);
22592
22592
  } else {
22593
- resolve2(parseResult.data);
22593
+ resolve3(parseResult.data);
22594
22594
  }
22595
22595
  } catch (error2) {
22596
22596
  reject(error2);
@@ -22851,12 +22851,12 @@ var Protocol = class {
22851
22851
  }
22852
22852
  } catch {
22853
22853
  }
22854
- return new Promise((resolve2, reject) => {
22854
+ return new Promise((resolve3, reject) => {
22855
22855
  if (signal.aborted) {
22856
22856
  reject(new McpError(ErrorCode.InvalidRequest, "Request cancelled"));
22857
22857
  return;
22858
22858
  }
22859
- const timeoutId = setTimeout(resolve2, interval);
22859
+ const timeoutId = setTimeout(resolve3, interval);
22860
22860
  signal.addEventListener("abort", () => {
22861
22861
  clearTimeout(timeoutId);
22862
22862
  reject(new McpError(ErrorCode.InvalidRequest, "Request cancelled"));
@@ -23956,7 +23956,7 @@ var McpServer = class {
23956
23956
  let task = createTaskResult.task;
23957
23957
  const pollInterval = task.pollInterval ?? 5e3;
23958
23958
  while (task.status !== "completed" && task.status !== "failed" && task.status !== "cancelled") {
23959
- await new Promise((resolve2) => setTimeout(resolve2, pollInterval));
23959
+ await new Promise((resolve3) => setTimeout(resolve3, pollInterval));
23960
23960
  const updatedTask = await extra.taskStore.getTask(taskId);
23961
23961
  if (!updatedTask) {
23962
23962
  throw new McpError(ErrorCode.InternalError, `Task ${taskId} not found during polling`);
@@ -24599,12 +24599,12 @@ var StdioServerTransport = class {
24599
24599
  this.onclose?.();
24600
24600
  }
24601
24601
  send(message) {
24602
- return new Promise((resolve2) => {
24602
+ return new Promise((resolve3) => {
24603
24603
  const json = serializeMessage(message);
24604
24604
  if (this._stdout.write(json)) {
24605
- resolve2();
24605
+ resolve3();
24606
24606
  } else {
24607
- this._stdout.once("drain", resolve2);
24607
+ this._stdout.once("drain", resolve3);
24608
24608
  }
24609
24609
  });
24610
24610
  }
@@ -24725,7 +24725,7 @@ var WsBridge = class {
24725
24725
  }
24726
24726
  }
24727
24727
  const requestId = crypto.randomUUID();
24728
- return new Promise((resolve2, reject) => {
24728
+ return new Promise((resolve3, reject) => {
24729
24729
  let lastProgressAt = Date.now();
24730
24730
  const fire = () => {
24731
24731
  this.pending.delete(requestId);
@@ -24738,7 +24738,7 @@ var WsBridge = class {
24738
24738
  timer = setTimeout(fire, timeoutMs);
24739
24739
  };
24740
24740
  this.pending.set(requestId, {
24741
- resolve: resolve2,
24741
+ resolve: resolve3,
24742
24742
  reject,
24743
24743
  timer,
24744
24744
  refresh
@@ -24761,9 +24761,10 @@ import { homedir } from "node:os";
24761
24761
  import { join, dirname } from "node:path";
24762
24762
  import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from "node:fs";
24763
24763
  var PROMOTE_AT_SUCCESS = 2;
24764
+ var DEMOTE_AT_FAILS = 1;
24764
24765
  var PRUNE_AT_FAILS = 2;
24765
24766
  var PROVISIONAL_TTL_MS = 30 * 24 * 60 * 60 * 1e3;
24766
- var ATOM_KEYS = ["tool", "target", "selector", "recovered_via", "signal", "fragile", "reason"];
24767
+ var ATOM_KEYS = ["tool", "target", "selector", "recovered_via", "signal", "verification", "clear_first", "fragile", "reason"];
24767
24768
  function originKey(url) {
24768
24769
  if (!url) return void 0;
24769
24770
  try {
@@ -24779,6 +24780,9 @@ var FRAGILE_RE = /:nth-(of-type|child)\(|>\s*\w+:nth/;
24779
24780
  function signatureOf(steps) {
24780
24781
  return JSON.stringify(steps.map((a) => [a.tool, a.target, a.selector ?? ""]));
24781
24782
  }
24783
+ function flowCost(f) {
24784
+ return f.steps.reduce((c, s) => c + 1 + (s.fragile ? 1 : 0) + (s.recovered_via ? 0.5 : 0), 0);
24785
+ }
24782
24786
  function sanitizeAtom(a) {
24783
24787
  const out = {};
24784
24788
  for (const k of ATOM_KEYS) {
@@ -24795,6 +24799,27 @@ function autoLabel(k, steps) {
24795
24799
  }
24796
24800
  return `auto: ${tools} @ ${path2}`;
24797
24801
  }
24802
+ function actionableVia(recovered_via) {
24803
+ if (recovered_via && recovered_via.includes("fiber")) return "fiber";
24804
+ return null;
24805
+ }
24806
+ function renderStep(s, i) {
24807
+ const fragNote = s.fragile ? " \u26A0fragile \u2014 if it misses on the first try, do NOT retry it; rediscover" : "";
24808
+ if (s.tool === "type_text") {
24809
+ const sel = s.selector ?? s.target;
24810
+ const cf = s.clear_first ? ", clear_first=true" : "";
24811
+ return ` ${i + 1}. type_text(into_selector=${JSON.stringify(sel)}${cf})${fragNote}`;
24812
+ }
24813
+ if (s.tool === "click_element") {
24814
+ const isSel = s.target.startsWith("selector=");
24815
+ const targ = isSel ? `selector=${JSON.stringify(s.selector ?? s.target.replace(/^selector=/, ""))}` : `textHint=${JSON.stringify(s.target)}`;
24816
+ const via = actionableVia(s.recovered_via);
24817
+ const viaStr = via ? `, via=${JSON.stringify(via)}` : "";
24818
+ const ver = s.verification ? `, ${s.verification}` : s.signal === "navigated" || s.signal === "until_url_change" ? ", until_url_changes=true" : "";
24819
+ return ` ${i + 1}. click_element(${targ}${viaStr}${ver})${fragNote}`;
24820
+ }
24821
+ return ` ${i + 1}. ${s.tool} ${s.target}${s.fragile ? " \u26A0fragile" : ""}`;
24822
+ }
24798
24823
  var FlowStore = class {
24799
24824
  path;
24800
24825
  data;
@@ -24807,6 +24832,10 @@ var FlowStore = class {
24807
24832
  // origins whose recall hint already fired this session
24808
24833
  recalled = /* @__PURE__ */ new Set();
24809
24834
  // origins whose trusted flow was actually shown this session
24835
+ recalledFlows = /* @__PURE__ */ new Map();
24836
+ // the flows we showed, for mismatch/confirm attribution
24837
+ dinged = /* @__PURE__ */ new Set();
24838
+ // flow ids already failed this session (no double-count)
24810
24839
  lastOrigin;
24811
24840
  lastAutosaved = null;
24812
24841
  // most recent autosave, for save_flow to vouch for
@@ -24861,6 +24890,21 @@ var FlowStore = class {
24861
24890
  }
24862
24891
  if (changed) this.persist();
24863
24892
  }
24893
+ /**
24894
+ * Record a failed/mismatched replay against a flow: demote on first, prune on
24895
+ * second (via pruneExpired's PRUNE_AT_FAILS check). `dedupeKey` collapses the
24896
+ * repeated mismatch check (which runs on every observe) to one ding per flow
24897
+ * per session; explicit tool failures pass no key so each real failure counts.
24898
+ */
24899
+ failFlow(f, dedupeKey) {
24900
+ if (dedupeKey) {
24901
+ if (this.dinged.has(dedupeKey)) return;
24902
+ this.dinged.add(dedupeKey);
24903
+ }
24904
+ f.fail_count += 1;
24905
+ f.last_replay_ok = false;
24906
+ if (f.tier === "trusted" && f.fail_count >= DEMOTE_AT_FAILS) f.tier = "provisional";
24907
+ }
24864
24908
  /**
24865
24909
  * Update the "current origin". Crossing to a DIFFERENT origin first autosaves
24866
24910
  * the origin we are leaving — that boundary is our best server-side proxy for
@@ -24879,18 +24923,54 @@ var FlowStore = class {
24879
24923
  if (!atom) return;
24880
24924
  const k = originKey(url) ?? this.lastOrigin;
24881
24925
  if (!k) return;
24926
+ this.reconcileAgainstRecalled(k, atom);
24882
24927
  const list = this.buffer.get(k) ?? [];
24883
24928
  const sig = `${atom.tool}|${atom.target}|${atom.selector ?? ""}`;
24884
24929
  if (list.some((a) => `${a.tool}|${a.target}|${a.selector ?? ""}` === sig)) return;
24885
24930
  list.push(sanitizeAtom(atom));
24886
24931
  this.buffer.set(k, list);
24887
24932
  }
24933
+ /**
24934
+ * When the agent performs a notable step on an origin we recalled a flow for,
24935
+ * compare it to the recalled steps of the same tool:
24936
+ * - same locator -> the recalled step worked: mark the flow's replay OK.
24937
+ * - different locator (and the recalled one was never used) -> the stored
24938
+ * selector was wrong; the agent silently rediscovered -> fail the flow.
24939
+ * This catches the "wrong element but technically succeeded" case that the
24940
+ * explicit failure signal misses, and is what neutralises a drifted Reddit-style
24941
+ * shadow-DOM selector before it costs another session.
24942
+ */
24943
+ reconcileAgainstRecalled(k, atom) {
24944
+ const shown = this.recalledFlows.get(k);
24945
+ if (!shown) return;
24946
+ const atomLoc = atom.selector ?? atom.target;
24947
+ let changed = false;
24948
+ for (const f of shown) {
24949
+ const sameTool = f.steps.filter((s) => s.tool === atom.tool);
24950
+ if (sameTool.length === 0) continue;
24951
+ const usedRecalled = sameTool.some((s) => (s.selector ?? s.target) === atomLoc || s.target === atom.target);
24952
+ if (usedRecalled) {
24953
+ if (f.last_replay_ok !== true) {
24954
+ f.last_replay_ok = true;
24955
+ changed = true;
24956
+ }
24957
+ } else {
24958
+ const before = f.fail_count;
24959
+ this.failFlow(f, `mismatch:${f.id}`);
24960
+ if (f.fail_count !== before) changed = true;
24961
+ }
24962
+ }
24963
+ if (changed) this.persist();
24964
+ }
24888
24965
  /** Autosave a single origin's buffer as a provisional flow (or promote a match). */
24889
24966
  autoCommit(k) {
24890
24967
  const buf = this.buffer.get(k);
24891
24968
  if (!buf || buf.length === 0) return;
24892
24969
  this.buffer.delete(k);
24893
24970
  this.upsert(k, buf, null);
24971
+ if (buf.length > 1) {
24972
+ for (const atom of buf) this.upsert(k, [atom], null);
24973
+ }
24894
24974
  this.lastAutosaved = { key: k, sig: signatureOf(buf) };
24895
24975
  this.pruneExpired();
24896
24976
  this.persist();
@@ -24912,11 +24992,12 @@ var FlowStore = class {
24912
24992
  if (existing) {
24913
24993
  existing.success_count += 1;
24914
24994
  existing.last_verified = now;
24995
+ existing.last_replay_ok = true;
24915
24996
  existing.chromeflow_version = this.version;
24916
24997
  if (label !== null) {
24917
24998
  existing.tier = "trusted";
24918
24999
  existing.task_label = label;
24919
- } else if (existing.success_count >= PROMOTE_AT_SUCCESS) {
25000
+ } else if (existing.success_count - existing.fail_count >= PROMOTE_AT_SUCCESS) {
24920
25001
  existing.tier = "trusted";
24921
25002
  }
24922
25003
  } else {
@@ -24929,41 +25010,45 @@ var FlowStore = class {
24929
25010
  last_verified: now,
24930
25011
  success_count: 1,
24931
25012
  fail_count: 0,
25013
+ last_replay_ok: true,
24932
25014
  chromeflow_version: this.version
24933
25015
  });
24934
25016
  }
24935
25017
  this.data.origins[k] = flows;
24936
25018
  return { saved: steps.length };
24937
25019
  }
24938
- /** Compact recall hint for an origin (TRUSTED flows only), at most once per origin per session. */
25020
+ /** Compact recall hint for an origin (RELIABLE trusted flows only), once per origin per session. */
24939
25021
  recallHint(url) {
24940
25022
  const k = originKey(url);
24941
25023
  if (!k || this.surfaced.has(k)) return "";
24942
- const flows = (this.data.origins[k] ?? []).filter((f) => f.tier === "trusted");
25024
+ const flows = (this.data.origins[k] ?? []).filter(
25025
+ (f) => f.tier === "trusted" && f.success_count > f.fail_count && f.last_replay_ok !== false
25026
+ );
24943
25027
  if (flows.length === 0) return "";
24944
25028
  this.surfaced.add(k);
24945
25029
  this.recalled.add(k);
24946
- const best = [...flows].sort((a, b) => b.success_count - a.success_count).slice(0, 3);
25030
+ const best = [...flows].sort((a, b) => {
25031
+ const ca = flowCost(a), cb = flowCost(b);
25032
+ if (ca !== cb) return ca - cb;
25033
+ return b.success_count - a.success_count;
25034
+ }).slice(0, 3);
25035
+ this.recalledFlows.set(k, best);
24947
25036
  const lines = best.map((f) => {
24948
- const steps = f.steps.map((s, i) => {
24949
- const via = s.recovered_via ? ` [via ${s.recovered_via}]` : "";
24950
- const sig = s.signal ? ` (${s.signal})` : "";
24951
- const frag = s.fragile ? " \u26A0fragile-selector" : "";
24952
- return ` ${i + 1}. ${s.tool} ${s.target}${sig}${via}${frag}`;
24953
- }).join("\n");
25037
+ const steps = f.steps.map((s, i) => renderStep(s, i)).join("\n");
24954
25038
  const stale = f.chromeflow_version !== this.version ? ` recorded on v${f.chromeflow_version}, re-verify` : "";
24955
25039
  return ` "${f.task_label}" (${f.steps.length} steps, ${f.success_count}x ok${stale}):
24956
25040
  ${steps}`;
24957
25041
  });
24958
25042
  return `
24959
25043
 
24960
- \u2139 known_flow for ${k} \u2014 prefer these proven steps over rediscovery (verify each as usual):
25044
+ \u2139 known_flow for ${k} \u2014 these calls worked before; prefer them over rediscovery, but VERIFY each. If a recalled step fails or its element isn't found on the first attempt, do NOT retry it \u2014 discard the hint and rediscover from scratch.
24961
25045
  ${lines.join("\n")}`;
24962
25046
  }
24963
25047
  /**
24964
- * A recalled trusted step failed on replay. Raise its fail_count; drop the flow
24965
- * once it crosses PRUNE_AT_FAILS. Gated on the flow having actually been recalled
24966
- * this session, so an unrelated failure can't ding a flow the agent never used.
25048
+ * A recalled step failed on replay (a click that returned success:false, or a
25049
+ * type_text that did not land). Demote the matching flow on the first miss,
25050
+ * prune on the second. Gated on the flow having actually been recalled this
25051
+ * session, so an unrelated failure can't ding a flow the agent never used.
24967
25052
  */
24968
25053
  observeFailure(url, selectorOrText) {
24969
25054
  const k = originKey(url) ?? this.lastOrigin;
@@ -24972,10 +25057,11 @@ ${lines.join("\n")}`;
24972
25057
  if (!flows) return;
24973
25058
  let changed = false;
24974
25059
  for (const f of flows) {
24975
- if (f.tier !== "trusted") continue;
24976
- const hit = f.steps.some((s) => s.selector === selectorOrText || s.target === selectorOrText || s.target === `selector=${selectorOrText}`);
25060
+ const hit = f.steps.some(
25061
+ (s) => s.selector === selectorOrText || s.target === selectorOrText || s.target === `selector=${selectorOrText}`
25062
+ );
24977
25063
  if (hit) {
24978
- f.fail_count += 1;
25064
+ this.failFlow(f);
24979
25065
  changed = true;
24980
25066
  }
24981
25067
  }
@@ -25029,15 +25115,10 @@ ${lines.join("\n")}`;
25029
25115
  }
25030
25116
  };
25031
25117
  function isFragileSelector(selector) {
25032
- return !!selector && FRAGILE_RE.test(selector);
25118
+ if (!selector) return false;
25119
+ return FRAGILE_RE.test(selector) || selector.includes(",");
25033
25120
  }
25034
25121
 
25035
- // packages/mcp-server/src/tools/browser.ts
25036
- import { writeFileSync as writeFileSync2, copyFileSync, readFileSync as readFileSync2 } from "fs";
25037
- import { tmpdir, homedir as homedir2 } from "os";
25038
- import { join as join2 } from "path";
25039
- import { execSync } from "child_process";
25040
-
25041
25122
  // packages/mcp-server/src/policy.ts
25042
25123
  function isBlockedUrl(rawUrl) {
25043
25124
  let parsed;
@@ -25063,8 +25144,8 @@ function isBlockedUrl(rawUrl) {
25063
25144
  return { blocked: false };
25064
25145
  }
25065
25146
 
25066
- // packages/mcp-server/src/tools/browser.ts
25067
- function registerBrowserTools(server, bridge, flowStore) {
25147
+ // packages/mcp-server/src/tools/browser/navigation.ts
25148
+ function registerNavigationTools(server, bridge, flowStore) {
25068
25149
  server.tool(
25069
25150
  "open_page",
25070
25151
  `Navigate to a URL. By default reuses the active tab. Set new_tab=true to open alongside the current tab without losing it. After navigating, call get_page_text to read the page \u2014 do NOT take a screenshot.
@@ -25118,6 +25199,38 @@ After tabs.onUpdated fires status=complete, chromeflow also runs a 6s settle che
25118
25199
  return { content: [{ type: "text", text }] };
25119
25200
  }
25120
25201
  );
25202
+ server.tool(
25203
+ "inspect_request_headers",
25204
+ `Capture the request headers Chrome sends to a URL \u2014 useful for diagnosing server-side bot detection. Returns method, URL, and all headers. Cookie values are redacted by default to avoid leaking session tokens into the agent context; pass redact_cookies: false to see them. By default opens a background tab for the inspection so your active tab keeps its scroll position and form state \u2014 set new_tab: false to use the active tab instead.`,
25205
+ {
25206
+ url: external_exports.string().url().describe("URL to navigate to and capture headers for"),
25207
+ redact_cookies: external_exports.boolean().optional().describe("Replace each cookie's value with [REDACTED]. Default true. Set false only when you genuinely need the cookie content for debugging."),
25208
+ new_tab: external_exports.boolean().optional().describe("Open the inspection in a background tab and close it when done. Default true (preserves the active tab's state). Set false to use the active tab \u2014 the active tab WILL navigate.")
25209
+ },
25210
+ async ({ url, redact_cookies = true, new_tab = true }) => {
25211
+ const block = isBlockedUrl(url);
25212
+ if (block.blocked) {
25213
+ return { content: [{ type: "text", text: `inspect_request_headers refused: ${block.reason}` }] };
25214
+ }
25215
+ const response = await bridge.request({ type: "inspect_request_headers", url, new_tab }, 3e4);
25216
+ const r = response;
25217
+ let text = r.message ?? "(no headers captured)";
25218
+ if (redact_cookies) {
25219
+ text = text.replace(/^(cookie:\s*)(.+)$/gim, (_m, prefix, body) => {
25220
+ const pairs = String(body).split(";").map((s) => s.trim()).filter(Boolean);
25221
+ const names = pairs.map((p) => p.split("=")[0]);
25222
+ return `${prefix}[REDACTED \u2014 ${pairs.length} cookies: ${names.join(", ")}]`;
25223
+ });
25224
+ }
25225
+ return {
25226
+ content: [{ type: "text", text }]
25227
+ };
25228
+ }
25229
+ );
25230
+ }
25231
+
25232
+ // packages/mcp-server/src/tools/browser/tabs.ts
25233
+ function registerTabTools(server, bridge, flowStore) {
25121
25234
  server.tool(
25122
25235
  "switch_to_tab",
25123
25236
  `Switch the active tab to a different open tab. Use this after open_page(new_tab=true) to switch back to the original tab, or to jump between tabs.
@@ -25201,6 +25314,40 @@ ${keptList}` }]
25201
25314
  };
25202
25315
  }
25203
25316
  );
25317
+ }
25318
+
25319
+ // packages/mcp-server/src/tools/browser/snapshot.ts
25320
+ function registerSnapshotTools(server, bridge) {
25321
+ server.tool(
25322
+ "interactive_snapshot",
25323
+ `Compact, accessibility-style list of the page's ACTIONABLE elements \u2014 each as [role] name \u2014 selector. Use this INSTEAD of get_page_text or take_screenshot when your goal is to ACT (click / type / select), not to read prose: it is far cheaper in tokens than dumping page text, and every line gives a ready-to-use selector for click_element / type_text. Pierces open AND closed shadow roots (Reddit faceplate-*, Radix/Stencil/Lit), which a raw accessibility tree misses. Returns the top elements by document order; pass max to widen. For reading article/body text, still use get_page_text.`,
25324
+ {
25325
+ max: external_exports.number().int().min(1).optional().describe("Max elements to return (default 60).")
25326
+ },
25327
+ async ({ max }) => {
25328
+ let response;
25329
+ try {
25330
+ response = await bridge.request({ type: "interactive_snapshot", max });
25331
+ } catch {
25332
+ return { content: [{ type: "text", text: "interactive_snapshot is unavailable (reload/update the chromeflow extension). Fall back to get_page_text + find_text for now." }] };
25333
+ }
25334
+ const items = response.items ?? [];
25335
+ if (items.length === 0) {
25336
+ return { content: [{ type: "text", text: "No actionable elements found (page may render inside a cross-origin iframe, or content is non-interactive)." }] };
25337
+ }
25338
+ const lines = items.map((it, i) => `${i + 1}. [${it.role}]${it.name ? " " + it.name : ""} \u2014 ${it.selector}`);
25339
+ return { content: [{ type: "text", text: `Actionable elements (${items.length}):
25340
+ ${lines.join("\n")}` }] };
25341
+ }
25342
+ );
25343
+ }
25344
+
25345
+ // packages/mcp-server/src/tools/browser/screenshot.ts
25346
+ import { writeFileSync as writeFileSync2, copyFileSync, readFileSync as readFileSync2 } from "fs";
25347
+ import { tmpdir, homedir as homedir2 } from "os";
25348
+ import { join as join2 } from "path";
25349
+ import { execSync } from "child_process";
25350
+ function registerScreenshotTools(server, bridge) {
25204
25351
  server.tool(
25205
25352
  "take_screenshot",
25206
25353
  `Capture a screenshot of the active tab. By default the image is returned to the agent inline UNLESS it exceeds ~500KB base64, in which case it's saved to a temp file and the path is returned instead (preserves the agent's context window). Set inline="always" to force inline regardless of size, or inline="never" to always write to a file. Set save_to or copy_to_clipboard to also share the image with the user. Reserved for cases where DOM lookup has already failed \u2014 use get_page_text and find_text for reading content.
@@ -25334,6 +25481,10 @@ The saved file path can be passed directly to set_file_input(hint, file_path) to
25334
25481
  };
25335
25482
  }
25336
25483
  );
25484
+ }
25485
+
25486
+ // packages/mcp-server/src/tools/browser/forms.ts
25487
+ function registerFormFieldTools(server, bridge) {
25337
25488
  server.tool(
25338
25489
  "get_form_fields",
25339
25490
  `Inventory form fields on the active page (inputs, textareas, selects, CodeMirror editors). Sorted top-to-bottom by y-position; includes fields below the fold.
@@ -25393,6 +25544,10 @@ To fill: fill_input("${r2.fields[0].label}", "<value>")` }] };
25393
25544
  ${lines.join("\n")}${r.warning ?? ""}${captchaLine}${oauthLine}` }] };
25394
25545
  }
25395
25546
  );
25547
+ }
25548
+
25549
+ // packages/mcp-server/src/tools/browser/typing.ts
25550
+ function registerTypingTools(server, bridge, flowStore) {
25396
25551
  server.tool(
25397
25552
  "type_text",
25398
25553
  `Type text into the currently focused element via CDP keystrokes (produces isTrusted=true events). Use when fill_input fails because the page validates isTrusted (CodeMirror/Monaco/Ace editors, shadow DOM inputs, isTrusted-gated forms). Pass \`into_selector\` to focus the target before typing (shadow-piercing CSS) \u2014 combined with \`clear_first: true\`, this collapses the old "wait_for_click \u2192 execute_script selectAll \u2192 type_text" pattern into a single call. Pass \`frame: "iframe.selector"\` to type into a same-origin iframe's first editable element.
@@ -25417,23 +25572,32 @@ ${lines.join("\n")}${r.warning ?? ""}${captchaLine}${oauthLine}` }] };
25417
25572
  timeoutMs
25418
25573
  );
25419
25574
  const r = response;
25575
+ const typeFailed = r.success === false || r.landed === false;
25576
+ const locator = r.resolved_selector || into_selector;
25420
25577
  let capturable = "";
25421
- if (into_selector && r.success !== false) {
25578
+ if (into_selector && !typeFailed) {
25422
25579
  flowStore.observe({
25423
25580
  tool: "type_text",
25424
- target: into_selector,
25425
- selector: into_selector,
25581
+ target: locator,
25582
+ selector: locator,
25426
25583
  signal: clear_first ? "type_text(clear_first)" : "type_text",
25427
- fragile: isFragileSelector(into_selector),
25584
+ clear_first: clear_first || void 0,
25585
+ fragile: isFragileSelector(locator),
25428
25586
  reason: "field needs real keystrokes (type_text, not fill_input)"
25429
25587
  });
25430
25588
  capturable = flowStore.capturableHint(void 0);
25589
+ } else if (into_selector && typeFailed) {
25590
+ flowStore.observeFailure(void 0, locator);
25431
25591
  }
25432
25592
  return {
25433
25593
  content: [{ type: "text", text: (r.message ?? (r.success ? "Text typed successfully" : "Failed to type text")) + capturable }]
25434
25594
  };
25435
25595
  }
25436
25596
  );
25597
+ }
25598
+
25599
+ // packages/mcp-server/src/tools/browser/files.ts
25600
+ function registerFileInputTools(server, bridge) {
25437
25601
  server.tool(
25438
25602
  "set_file_input",
25439
25603
  `Upload a file to a file input \u2014 works even when the input is hidden behind a custom drag-and-drop zone. Returns success=true only after an observable commit (file count goes up, input gets reset, or verify_selector appears within wait_ms). See CLAUDE.md for batch-upload guidance.
@@ -25489,6 +25653,10 @@ Provide file_path OR file_content, not both.`,
25489
25653
  };
25490
25654
  }
25491
25655
  );
25656
+ }
25657
+
25658
+ // packages/mcp-server/src/tools/browser/scripting.ts
25659
+ function registerScriptingTools(server, bridge) {
25492
25660
  server.tool(
25493
25661
  "execute_script",
25494
25662
  `Execute JavaScript in a tab's MAIN world (the page's own context, not the extension's isolated world). 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.
@@ -25537,34 +25705,18 @@ PAGE ALERT: "${alert}" \u2014 the page showed a dialog with this message. Read i
25537
25705
  };
25538
25706
  }
25539
25707
  );
25540
- server.tool(
25541
- "inspect_request_headers",
25542
- `Capture the request headers Chrome sends to a URL \u2014 useful for diagnosing server-side bot detection. Returns method, URL, and all headers. Cookie values are redacted by default to avoid leaking session tokens into the agent context; pass redact_cookies: false to see them. By default opens a background tab for the inspection so your active tab keeps its scroll position and form state \u2014 set new_tab: false to use the active tab instead.`,
25543
- {
25544
- url: external_exports.string().url().describe("URL to navigate to and capture headers for"),
25545
- redact_cookies: external_exports.boolean().optional().describe("Replace each cookie's value with [REDACTED]. Default true. Set false only when you genuinely need the cookie content for debugging."),
25546
- new_tab: external_exports.boolean().optional().describe("Open the inspection in a background tab and close it when done. Default true (preserves the active tab's state). Set false to use the active tab \u2014 the active tab WILL navigate.")
25547
- },
25548
- async ({ url, redact_cookies = true, new_tab = true }) => {
25549
- const block = isBlockedUrl(url);
25550
- if (block.blocked) {
25551
- return { content: [{ type: "text", text: `inspect_request_headers refused: ${block.reason}` }] };
25552
- }
25553
- const response = await bridge.request({ type: "inspect_request_headers", url, new_tab }, 3e4);
25554
- const r = response;
25555
- let text = r.message ?? "(no headers captured)";
25556
- if (redact_cookies) {
25557
- text = text.replace(/^(cookie:\s*)(.+)$/gim, (_m, prefix, body) => {
25558
- const pairs = String(body).split(";").map((s) => s.trim()).filter(Boolean);
25559
- const names = pairs.map((p) => p.split("=")[0]);
25560
- return `${prefix}[REDACTED \u2014 ${pairs.length} cookies: ${names.join(", ")}]`;
25561
- });
25562
- }
25563
- return {
25564
- content: [{ type: "text", text }]
25565
- };
25566
- }
25567
- );
25708
+ }
25709
+
25710
+ // packages/mcp-server/src/tools/browser.ts
25711
+ function registerBrowserTools(server, bridge, flowStore) {
25712
+ registerNavigationTools(server, bridge, flowStore);
25713
+ registerTabTools(server, bridge, flowStore);
25714
+ registerSnapshotTools(server, bridge);
25715
+ registerScreenshotTools(server, bridge);
25716
+ registerFormFieldTools(server, bridge);
25717
+ registerTypingTools(server, bridge, flowStore);
25718
+ registerFileInputTools(server, bridge);
25719
+ registerScriptingTools(server, bridge);
25568
25720
  }
25569
25721
 
25570
25722
  // packages/mcp-server/src/tools/highlight.ts
@@ -25609,10 +25761,8 @@ Returns whether the element was found. Set valueToType only when the user must p
25609
25761
  );
25610
25762
  }
25611
25763
 
25612
- // packages/mcp-server/src/tools/capture.ts
25613
- import { appendFileSync, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
25614
- import { resolve, relative, isAbsolute, dirname as dirname2 } from "path";
25615
- function registerCaptureTools(server, bridge) {
25764
+ // packages/mcp-server/src/tools/capture/input.ts
25765
+ function registerInputTools(server, bridge) {
25616
25766
  server.tool(
25617
25767
  "fill_input",
25618
25768
  `Fill a form input by visible label / placeholder / aria-label (\`textHint\`) OR by direct CSS selector (\`selector\`). Pass exactly one.
@@ -25668,6 +25818,10 @@ Or pass selector="<css>" instead of textHint to bypass fuzzy matching entirely.`
25668
25818
  };
25669
25819
  }
25670
25820
  );
25821
+ }
25822
+
25823
+ // packages/mcp-server/src/tools/capture/extract.ts
25824
+ function registerExtractTools(server, bridge) {
25671
25825
  server.tool(
25672
25826
  "get_page_text",
25673
25827
  `Get the visible text content of the current page without taking a screenshot.
@@ -25770,6 +25924,12 @@ Pass level="error" to see only errors, or omit to see all levels.`,
25770
25924
  ${lines.join("\n")}` }] };
25771
25925
  }
25772
25926
  );
25927
+ }
25928
+
25929
+ // packages/mcp-server/src/tools/capture/files.ts
25930
+ import { appendFileSync, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
25931
+ import { resolve, relative, isAbsolute } from "path";
25932
+ function registerFileTools(server, bridge) {
25773
25933
  server.tool(
25774
25934
  "write_to_env",
25775
25935
  "Write a key=value pair to a .env file. Use this after capturing an API key or ID from the page.",
@@ -25884,6 +26044,12 @@ Size: ${r.size} bytes`
25884
26044
  };
25885
26045
  }
25886
26046
  );
26047
+ }
26048
+
26049
+ // packages/mcp-server/src/tools/capture/fetch.ts
26050
+ import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync4 } from "fs";
26051
+ import { resolve as resolve2, relative as relative2, isAbsolute as isAbsolute2, dirname as dirname2 } from "path";
26052
+ function registerFetchTools(server, bridge) {
25887
26053
  server.tool(
25888
26054
  "fetch_url",
25889
26055
  `Make an HTTP request to a URL from the extension's privileged context, bypassing the page's Content-Security-Policy.
@@ -25934,16 +26100,16 @@ Set binary=true for non-text responses (PDFs, images, zips) \u2014 the body is r
25934
26100
  \u26A0 anti_bot_detected: "${r.anti_bot_detected}" \u2014 response body matches a known block / challenge page. Don't parse as the expected JSON/HTML; the user's IP may be challenged or the endpoint may require a real browser context.` : "";
25935
26101
  if (to_file) {
25936
26102
  const cwd = process.cwd();
25937
- const resolved = isAbsolute(to_file) ? to_file : resolve(cwd, to_file);
25938
- const rel = relative(cwd, resolved);
25939
- if (rel.startsWith("..") || isAbsolute(rel)) {
26103
+ const resolved = isAbsolute2(to_file) ? to_file : resolve2(cwd, to_file);
26104
+ const rel = relative2(cwd, resolved);
26105
+ if (rel.startsWith("..") || isAbsolute2(rel)) {
25940
26106
  throw new Error(
25941
26107
  `Refusing to write fetch_url body outside the project directory. Target "${resolved}" is not under "${cwd}".`
25942
26108
  );
25943
26109
  }
25944
26110
  mkdirSync2(dirname2(resolved), { recursive: true });
25945
26111
  const buf = r.body_base64 ? Buffer.from(r.body_base64, "base64") : Buffer.from(r.body_text ?? "", "utf-8");
25946
- writeFileSync3(resolved, buf);
26112
+ writeFileSync4(resolved, buf);
25947
26113
  const hdrLines = Object.keys(r.headers).sort().map((k) => ` ${k}: ${r.headers[k]}`).join("\n");
25948
26114
  return {
25949
26115
  content: [{
@@ -25971,8 +26137,16 @@ ${r.body_text}` : "";
25971
26137
  );
25972
26138
  }
25973
26139
 
25974
- // packages/mcp-server/src/tools/flow.ts
25975
- function registerFlowTools(server, bridge, flowStore) {
26140
+ // packages/mcp-server/src/tools/capture.ts
26141
+ function registerCaptureTools(server, bridge) {
26142
+ registerInputTools(server, bridge);
26143
+ registerExtractTools(server, bridge);
26144
+ registerFileTools(server, bridge);
26145
+ registerFetchTools(server, bridge);
26146
+ }
26147
+
26148
+ // packages/mcp-server/src/tools/flow/click.ts
26149
+ function registerClickTools(server, bridge, flowStore) {
25976
26150
  server.tool(
25977
26151
  "click_element",
25978
26152
  `Click an interactive element by its visible text/aria-label (textHint) OR by direct CSS selector (selector). Pass exactly one.
@@ -26075,6 +26249,7 @@ Current URL: ${activeTab.url}`;
26075
26249
  const actionUrl = r.before_url ?? r.after_url;
26076
26250
  const nowUrl = r.after_url ?? r.before_url;
26077
26251
  const usedUntil = !!(until_selector || until_url_contains || until_text_contains || until_url_changes);
26252
+ const verification = until_url_changes ? "until_url_changes=true" : until_selector ? `until_selector=${JSON.stringify(until_selector)}` : until_url_contains ? `until_url_contains=${JSON.stringify(until_url_contains)}` : until_text_contains ? `until_text_contains=${JSON.stringify(until_text_contains)}` : expect_submit ? "expect_submit=true" : void 0;
26078
26253
  if (r.success && (r.recovered_via || r.navigated || usedUntil)) {
26079
26254
  flowStore.observe({
26080
26255
  tool: "click_element",
@@ -26082,6 +26257,7 @@ Current URL: ${activeTab.url}`;
26082
26257
  selector,
26083
26258
  recovered_via: r.recovered_via,
26084
26259
  signal: r.navigated ? "navigated" : until_url_changes ? "until_url_change" : usedUntil ? "until_*" : r.recovered_via,
26260
+ verification,
26085
26261
  fragile: isFragileSelector(selector),
26086
26262
  reason: r.recovered_via ? `click recovered via ${r.recovered_via}` : r.navigated ? "navigating submit/link" : "verified terminal click"
26087
26263
  }, actionUrl);
@@ -26105,6 +26281,33 @@ Current URL: ${activeTab.url}`;
26105
26281
  };
26106
26282
  }
26107
26283
  );
26284
+ server.tool(
26285
+ "click_at_coordinates",
26286
+ `Dispatch a real CDP mouse click at viewport (x, y). The only way to interact with cross-origin iframes \u2014 \`click_element\` refuses cross-origin frames because \`find_text\` can't enter them, but a CDP-level mouse event resolves at the renderer process and reaches the iframe's content the way an OS-level click does.
26287
+
26288
+ Coordinates are viewport CSS pixels, NOT screen coordinates. \`list_frames\` reports each iframe at \`(x, y, width, height)\` in this same space, so to click 50px in / 80px down inside an iframe: \`click_at_coordinates(frame.x + 50, frame.y + 80)\`.
26289
+
26290
+ Runs the same humanlike sequence as \`click_element\` (bezier approach path, settle-hover micro-tremor, press, release, post-click micro-move) so behavioural fingerprinters can't distinguish the call from any other chromeflow click. Skips the activity probe \u2014 cross-origin iframe activity isn't observable from the parent.
26291
+
26292
+ Refuses obviously-bad coordinates (negative, > 10000). Use this only when DOM matching has failed and you have a known target position from \`list_frames\` or a screenshot.`,
26293
+ {
26294
+ x: external_exports.number().describe("Viewport CSS X coordinate (left=0). Get from list_frames or a screenshot grid."),
26295
+ y: external_exports.number().describe("Viewport CSS Y coordinate (top=0). Get from list_frames or a screenshot grid."),
26296
+ button: external_exports.enum(["left", "right", "middle"]).optional().describe('Mouse button (default "left").'),
26297
+ double: external_exports.boolean().optional().describe("Fire a double-click instead of a single click. Default false.")
26298
+ },
26299
+ async ({ x, y, button, double }) => {
26300
+ const response = await bridge.request({ type: "click_at_coordinates", x, y, button, double });
26301
+ const r = response;
26302
+ const navLine = r.navigated && r.after_url ? `
26303
+ \u2192 Navigated: ${r.after_url}` : "";
26304
+ return { content: [{ type: "text", text: `${r.message}${navLine}` }] };
26305
+ }
26306
+ );
26307
+ }
26308
+
26309
+ // packages/mcp-server/src/tools/flow/save.ts
26310
+ function registerSaveFlowTools(server, flowStore) {
26108
26311
  server.tool(
26109
26312
  "save_flow",
26110
26313
  `Trust the hard-won interaction steps chromeflow buffered for the current site, immediately, as a named flow. chromeflow auto-buffers only NOTABLE resolutions (a click that needed a fallback, a verified submit, a field that needed real keystrokes), and AUTOSAVES them as a provisional flow when you leave the site \u2014 so memory works even if you never call this. Provisional flows are not recalled until they have been independently re-observed, or until you vouch for them here. Calling save_flow promotes the buffered steps to TRUSTED right away (an explicit "I confirm this worked"), so they are recalled next session instead of waiting to earn it.
@@ -26118,6 +26321,10 @@ Call this when a response shows \`flow_capturable\` and you are confident the ta
26118
26321
  return { content: [{ type: "text", text: res.message }] };
26119
26322
  }
26120
26323
  );
26324
+ }
26325
+
26326
+ // packages/mcp-server/src/tools/flow/wait.ts
26327
+ function registerWaitTools(server, bridge) {
26121
26328
  server.tool(
26122
26329
  "wait_for_click",
26123
26330
  `Wait for the user to click (or interact with) the currently highlighted element, then return.
@@ -26160,29 +26367,6 @@ CDP re-dispatched: isTrusted=true click at (${r.target?.x ?? 0}, ${r.target?.y ?
26160
26367
  };
26161
26368
  }
26162
26369
  );
26163
- server.tool(
26164
- "click_at_coordinates",
26165
- `Dispatch a real CDP mouse click at viewport (x, y). The only way to interact with cross-origin iframes \u2014 \`click_element\` refuses cross-origin frames because \`find_text\` can't enter them, but a CDP-level mouse event resolves at the renderer process and reaches the iframe's content the way an OS-level click does.
26166
-
26167
- Coordinates are viewport CSS pixels, NOT screen coordinates. \`list_frames\` reports each iframe at \`(x, y, width, height)\` in this same space, so to click 50px in / 80px down inside an iframe: \`click_at_coordinates(frame.x + 50, frame.y + 80)\`.
26168
-
26169
- Runs the same humanlike sequence as \`click_element\` (bezier approach path, settle-hover micro-tremor, press, release, post-click micro-move) so behavioural fingerprinters can't distinguish the call from any other chromeflow click. Skips the activity probe \u2014 cross-origin iframe activity isn't observable from the parent.
26170
-
26171
- Refuses obviously-bad coordinates (negative, > 10000). Use this only when DOM matching has failed and you have a known target position from \`list_frames\` or a screenshot.`,
26172
- {
26173
- x: external_exports.number().describe("Viewport CSS X coordinate (left=0). Get from list_frames or a screenshot grid."),
26174
- y: external_exports.number().describe("Viewport CSS Y coordinate (top=0). Get from list_frames or a screenshot grid."),
26175
- button: external_exports.enum(["left", "right", "middle"]).optional().describe('Mouse button (default "left").'),
26176
- double: external_exports.boolean().optional().describe("Fire a double-click instead of a single click. Default false.")
26177
- },
26178
- async ({ x, y, button, double }) => {
26179
- const response = await bridge.request({ type: "click_at_coordinates", x, y, button, double });
26180
- const r = response;
26181
- const navLine = r.navigated && r.after_url ? `
26182
- \u2192 Navigated: ${r.after_url}` : "";
26183
- return { content: [{ type: "text", text: `${r.message}${navLine}` }] };
26184
- }
26185
- );
26186
26370
  server.tool(
26187
26371
  "wait_for",
26188
26372
  `Wait for one of: a CSS selector to appear, a text substring (or any of an array of substrings) to appear, or an existing element's subtree to mutate. Pass exactly one of \`selector\`, \`text\`, or \`change_in\`. Pierces open AND closed shadow roots (text \`scope_selector\` pierces too). Pass \`shadow_root: true\` when waiting for the host's shadowRoot to attach (post-SPA-navigation hydration). \`scope_selector\` limits text-mode search; \`regex: true\` interprets text as a case-insensitive regex; \`frame: "iframe.selector"\` waits inside a same-origin iframe (text mode).
@@ -26272,6 +26456,10 @@ Examples: scroll_to_element("#submit-btn"), scroll_to_element("Billing address")
26272
26456
  return { content: [{ type: "text", text: msg }] };
26273
26457
  }
26274
26458
  );
26459
+ }
26460
+
26461
+ // packages/mcp-server/src/tools/flow/find.ts
26462
+ function registerFindTools(server, bridge) {
26275
26463
  server.tool(
26276
26464
  "find_text",
26277
26465
  `Search the active page for text and return actionable matches (text, surrounding context, best-effort CSS selector, clickable flag). Use this instead of get_page_text when checking "is X on the page?" or locating a clickable target. Pierces open AND closed shadow roots. Pass \`frame: "iframe.selector"\` for same-origin iframe search.
@@ -26340,38 +26528,6 @@ ${lines.join("\n")}` }]
26340
26528
  };
26341
26529
  }
26342
26530
  );
26343
- server.tool(
26344
- "fill_form",
26345
- `Fill multiple form fields in a single call by targeting each field by its label text.
26346
- Use this instead of calling fill_input repeatedly \u2014 it fills all fields in one round trip and returns a per-field success report.
26347
- Ideal for forms with many textareas or inputs where each fill would otherwise require a separate tool call.
26348
- fields is an array of {label, value} pairs. label should match the field's visible label, placeholder, or aria-label.
26349
-
26350
- Each per-field result includes the matched element description (e.g. \`<input name="title" id="..." placeholder="...">\`) so Claude can spot when fill_form picked the wrong field.
26351
-
26352
- Pass \`exact: true\` for forms with short generic labels (like "Rate" or "Amount") that may collide with similarly-labeled neighbours \u2014 fields without an exact aria-label/placeholder/name/id/label-text match will return success=false instead of silently filling the wrong field.`,
26353
- {
26354
- fields: external_exports.array(
26355
- external_exports.object({
26356
- label: external_exports.string().describe("Visible label, placeholder, or aria-label of the field"),
26357
- value: external_exports.string().describe("Value to fill in")
26358
- })
26359
- ).describe("List of fields to fill"),
26360
- exact: external_exports.boolean().optional().describe("If true, refuse fuzzy text-walk matches for every field. Default false.")
26361
- },
26362
- async ({ fields, exact }) => {
26363
- const response = await bridge.request({ type: "fill_form", fields, exact });
26364
- const r = response;
26365
- const lines = r.results.map((f) => `${f.success ? "\u2713" : "\u2717"} "${f.label}": ${f.message}`);
26366
- return {
26367
- content: [{
26368
- type: "text",
26369
- text: `Filled ${r.succeeded}/${r.total} fields:
26370
- ${lines.join("\n")}`
26371
- }]
26372
- };
26373
- }
26374
- );
26375
26531
  server.tool(
26376
26532
  "list_frames",
26377
26533
  `List every top-level iframe/frame on the active page, with its origin, whether its contentDocument is accessible (same-origin), and its on-screen position. Also reports shadow-host inventory so you can spot pages whose visible content is rendered inside closed shadow roots (Radix portals, Stencil/Lit, custom web components).
@@ -26430,8 +26586,53 @@ ${lines.join("\n")}${shadowSection}` }] };
26430
26586
  );
26431
26587
  }
26432
26588
 
26589
+ // packages/mcp-server/src/tools/flow/forms.ts
26590
+ function registerFillFormTools(server, bridge) {
26591
+ server.tool(
26592
+ "fill_form",
26593
+ `Fill multiple form fields in a single call by targeting each field by its label text.
26594
+ Use this instead of calling fill_input repeatedly \u2014 it fills all fields in one round trip and returns a per-field success report.
26595
+ Ideal for forms with many textareas or inputs where each fill would otherwise require a separate tool call.
26596
+ fields is an array of {label, value} pairs. label should match the field's visible label, placeholder, or aria-label.
26597
+
26598
+ Each per-field result includes the matched element description (e.g. \`<input name="title" id="..." placeholder="...">\`) so Claude can spot when fill_form picked the wrong field.
26599
+
26600
+ Pass \`exact: true\` for forms with short generic labels (like "Rate" or "Amount") that may collide with similarly-labeled neighbours \u2014 fields without an exact aria-label/placeholder/name/id/label-text match will return success=false instead of silently filling the wrong field.`,
26601
+ {
26602
+ fields: external_exports.array(
26603
+ external_exports.object({
26604
+ label: external_exports.string().describe("Visible label, placeholder, or aria-label of the field"),
26605
+ value: external_exports.string().describe("Value to fill in")
26606
+ })
26607
+ ).describe("List of fields to fill"),
26608
+ exact: external_exports.boolean().optional().describe("If true, refuse fuzzy text-walk matches for every field. Default false.")
26609
+ },
26610
+ async ({ fields, exact }) => {
26611
+ const response = await bridge.request({ type: "fill_form", fields, exact });
26612
+ const r = response;
26613
+ const lines = r.results.map((f) => `${f.success ? "\u2713" : "\u2717"} "${f.label}": ${f.message}`);
26614
+ return {
26615
+ content: [{
26616
+ type: "text",
26617
+ text: `Filled ${r.succeeded}/${r.total} fields:
26618
+ ${lines.join("\n")}`
26619
+ }]
26620
+ };
26621
+ }
26622
+ );
26623
+ }
26624
+
26625
+ // packages/mcp-server/src/tools/flow.ts
26626
+ function registerFlowTools(server, bridge, flowStore) {
26627
+ registerClickTools(server, bridge, flowStore);
26628
+ registerSaveFlowTools(server, flowStore);
26629
+ registerWaitTools(server, bridge);
26630
+ registerFindTools(server, bridge);
26631
+ registerFillFormTools(server, bridge);
26632
+ }
26633
+
26433
26634
  // packages/mcp-server/src/index.ts
26434
- var PACKAGE_VERSION = true ? "0.12.2" : "dev";
26635
+ var PACKAGE_VERSION = true ? "0.12.3" : "dev";
26435
26636
  main().catch((err) => {
26436
26637
  console.error("[chromeflow] Fatal error:", err);
26437
26638
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chromeflow",
3
- "version": "0.12.2",
3
+ "version": "0.12.3",
4
4
  "description": "MCP server for chromeflow \u2014 lets Claude Code or Codex CLI drive your real Chrome browser with sessions intact. Plugin install recommended; npx chromeflow for manual MCP wiring.",
5
5
  "type": "module",
6
6
  "main": "./bin/chromeflow.mjs",