fastbrowser_cli 1.0.31 → 1.0.35

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 (60) hide show
  1. package/README.md +1 -2
  2. package/dist/fastbrowser_cli/fastbrowser_cli.js +11 -19
  3. package/dist/fastbrowser_cli/fastbrowser_cli.js.map +1 -1
  4. package/dist/fastbrowser_cli/libs/query-builder.d.ts +2 -0
  5. package/dist/fastbrowser_cli/libs/query-builder.d.ts.map +1 -1
  6. package/dist/fastbrowser_cli/libs/query-builder.js +4 -0
  7. package/dist/fastbrowser_cli/libs/query-builder.js.map +1 -1
  8. package/dist/fastbrowser_httpd/libs/tool-schemas.d.ts +2 -0
  9. package/dist/fastbrowser_httpd/libs/tool-schemas.d.ts.map +1 -1
  10. package/dist/fastbrowser_mcp/fastbrowser_mcp.js +193 -32
  11. package/dist/fastbrowser_mcp/fastbrowser_mcp.js.map +1 -1
  12. package/dist/fastbrowser_mcp/libs/mcp_my_client.d.ts.map +1 -1
  13. package/dist/fastbrowser_mcp/libs/mcp_my_client.js +8 -0
  14. package/dist/fastbrowser_mcp/libs/mcp_my_client.js.map +1 -1
  15. package/dist/fastbrowser_mcp/libs/mcp_target_helper.d.ts.map +1 -1
  16. package/dist/fastbrowser_mcp/libs/mcp_target_helper.js +15 -2
  17. package/dist/fastbrowser_mcp/libs/mcp_target_helper.js.map +1 -1
  18. package/dist/fastbrowser_mcp/libs/playwright_a11y_helper.d.ts +1 -0
  19. package/dist/fastbrowser_mcp/libs/playwright_a11y_helper.d.ts.map +1 -1
  20. package/dist/fastbrowser_mcp/libs/playwright_a11y_helper.js +33 -1
  21. package/dist/fastbrowser_mcp/libs/playwright_a11y_helper.js.map +1 -1
  22. package/dist/fastbrowser_mcp/libs/schemas.d.ts +4 -0
  23. package/dist/fastbrowser_mcp/libs/schemas.d.ts.map +1 -1
  24. package/dist/fastbrowser_mcp/libs/schemas.js +6 -0
  25. package/dist/fastbrowser_mcp/libs/schemas.js.map +1 -1
  26. package/dist/shared/logger.d.ts +86 -0
  27. package/dist/shared/logger.d.ts.map +1 -0
  28. package/dist/shared/logger.js +269 -0
  29. package/dist/shared/logger.js.map +1 -0
  30. package/docs/brainstorm_scrap_by_ai.md +1 -1
  31. package/docs/feature_support_cli.md +7 -8
  32. package/docs/target_tools/target_tools_chrome_devtools.md +963 -0
  33. package/docs/target_tools/target_tools_playwright.md +763 -0
  34. package/examples/linkedin_cli/linked_dm.sh +19 -0
  35. package/examples/linkedin_cli/linked_post.sh +13 -0
  36. package/examples/linkedin_cli/linkedin.snapshot.txt +1245 -0
  37. package/examples/todomvc/todomvc.a11y.txt +44 -0
  38. package/examples/todomvc/todomvc.sh +11 -0
  39. package/examples/wttj_cli/fastbrowser_helper.ts +39 -0
  40. package/examples/wttj_cli/wttf_job-original.a11y.txt +652 -0
  41. package/examples/wttj_cli/wttf_job.a11y.txt +317 -0
  42. package/examples/{welcometothejungle/wttj-job.ts → wttj_cli/wttj_job copy.ts } +60 -11
  43. package/examples/wttj_cli/wttj_job.ts +179 -0
  44. package/examples/wttj_cli/wttj_search.ts +162 -0
  45. package/package.json +10 -3
  46. package/skills/fastbrowser/SKILL.md +10 -11
  47. package/skills/fastbrowser-script/SKILL.md +4 -4
  48. package/src/fastbrowser_cli/fastbrowser_cli.ts +14 -25
  49. package/src/fastbrowser_cli/libs/query-builder.ts +6 -0
  50. package/src/fastbrowser_mcp/fastbrowser_mcp.ts +231 -36
  51. package/src/fastbrowser_mcp/libs/mcp_my_client.ts +17 -0
  52. package/src/fastbrowser_mcp/libs/mcp_target_helper.ts +15 -2
  53. package/src/fastbrowser_mcp/libs/playwright_a11y_helper.ts +33 -1
  54. package/src/fastbrowser_mcp/libs/schemas.ts +6 -0
  55. package/src/shared/logger.ts +317 -0
  56. package/test.a11y.txt +828 -0
  57. package/tests/query-builder.test.ts +51 -11
  58. package/examples/welcometothejungle/fastbrowser_helper.ts +0 -39
  59. package/examples/welcometothejungle/wttj-search.ts +0 -82
  60. /package/examples/{post-to-x.sh → twitter_cli/twitter_post.sh} +0 -0
@@ -1,7 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // node imports
4
- import * as Assert from 'node:assert/strict';
5
4
  import Fs from 'node:fs';
6
5
  import Path from 'node:path';
7
6
 
@@ -11,6 +10,7 @@ import { z } from "zod";
11
10
  import * as A11yParse from "a11y_parse";
12
11
 
13
12
  // local imports
13
+ import { Logger } from "../shared/logger.js"
14
14
  import { McpMyClient } from "./libs/mcp_my_client.js";
15
15
  import { McpProxy } from "./libs/mcp_proxy.js";
16
16
  import { ResponseFormatter } from "./libs/response_formatter.js";
@@ -40,6 +40,10 @@ export {
40
40
  type QuerySelectorsFirstInput,
41
41
  };
42
42
 
43
+ const logger = Logger.fromMetaUrl(import.meta.url, {
44
+ allToStderr: true,
45
+ });
46
+
43
47
  ///////////////////////////////////////////////////////////////////////////////
44
48
  ///////////////////////////////////////////////////////////////////////////////
45
49
  //
@@ -47,21 +51,62 @@ export {
47
51
  ///////////////////////////////////////////////////////////////////////////////
48
52
 
49
53
  class MainHelper {
54
+ /**
55
+ * Multiple retry... not sure it is actually useful - https://github.com/jeromeetienne/skillmd_collection/issues/47
56
+ *
57
+ * @param mcpClient
58
+ * @returns
59
+ */
50
60
  private static async _getA11yText(mcpClient: McpMyClient): Promise<string> {
51
61
  const mcpTarget = await mcpClient.getMcpTarget();
52
62
  const toolConfig = await McpTargetHelper.targetToolTakeSnapshot(mcpTarget);
53
63
 
54
- // FIXME: the first take_snapshot call after connecting to the MCP target often returns an empty snapshot for unknown reasons
55
- // working around this by calling it once and discarding the result before calling it again to get the actual snapshot text
56
- await mcpClient.callTool(toolConfig.toolName, toolConfig.toolArgs);
64
+ // `take_snapshot` is racy for reasons we haven't traced (could be playwright's a11y serializer, the
65
+ // `@playwright/mcp` extension transport, or the chrome extension itself), back-to-back calls on an unchanged
66
+ // page sometimes return incomplete or empty trees. Stabilize by taking snapshots in pairs and only returning
67
+ // once two consecutive ones agree on node count within STABLE_TOLERANCE. On exhaustion, return best-effort
68
+ // rather than throw, so legitimately tiny pages don't break workflows.
69
+ const MAX_ATTEMPTS = 6;
70
+ const RETRY_DELAY_MS = 250;
71
+ const STABLE_TOLERANCE = 2;
72
+
73
+ let prev: { text: string; nodeCount: number } | undefined = undefined;
74
+ let last: { text: string; nodeCount: number } = { text: '', nodeCount: 0 };
75
+
76
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
77
+ const callToolResult = await mcpClient.callTool(toolConfig.toolName, toolConfig.toolArgs);
78
+ const text = await ResponseFormatter.formatTakeSnapshot(mcpTarget, callToolResult);
79
+ const nodeCount = MainHelper._countSnapshotNodes(text);
80
+ last = { text, nodeCount };
81
+
82
+ if (prev !== undefined && Math.abs(nodeCount - prev.nodeCount) <= STABLE_TOLERANCE) {
83
+ if (nodeCount === 0) {
84
+ logger.warn(`${mcpTarget}:take_snapshot: settled at empty after ${attempt} attempt(s) — returning empty snapshot`);
85
+ }
86
+ return text;
87
+ }
57
88
 
58
- const callToolResult = await mcpClient.callTool(toolConfig.toolName, toolConfig.toolArgs);
59
- const snapshotText = await ResponseFormatter.formatTakeSnapshot(mcpTarget, callToolResult);
60
- // sanity check
61
- Assert.ok(snapshotText !== undefined, "Snapshot text is empty");
89
+ logger.warn(`${mcpTarget}:take_snapshot: attempt ${attempt}/${MAX_ATTEMPTS}: nodeCount=${nodeCount}, prev=${prev === undefined ? 'n/a' : prev.nodeCount} — not stable, retrying`);
62
90
 
63
- // return the snapshot text
64
- return snapshotText;
91
+ prev = last;
92
+ if (attempt < MAX_ATTEMPTS) {
93
+ await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
94
+ }
95
+ }
96
+
97
+ logger.warn(`${mcpTarget}:take_snapshot: exhausted ${MAX_ATTEMPTS} attempts without stabilizing — returning best-effort (nodeCount=${last.nodeCount})`);
98
+ return last.text;
99
+ }
100
+
101
+ private static _countSnapshotNodes(snapshotText: string): number {
102
+ if (snapshotText.trim().length === 0) return 0;
103
+ let count = 0;
104
+ for (const line of snapshotText.split('\n')) {
105
+ if (line.trim().startsWith('uid=')) {
106
+ count += 1;
107
+ }
108
+ }
109
+ return count;
65
110
  }
66
111
 
67
112
  /**
@@ -106,18 +151,32 @@ class MainHelper {
106
151
  selector: string,
107
152
  selectedNodes: A11yParse.AxNode[],
108
153
  withAncestors: boolean,
154
+ withChildren: boolean,
109
155
  ): string {
110
- let text: string = `## Node found for selector '${selector}' (${selectedNodes.length} node${selectedNodes.length > 1 ? 's' : ''}${withAncestors ? ', with ancestors' : ''}):\n`;
111
- if (withAncestors) {
112
- if (selectedNodes.length === 0) {
113
- text += "No node found";
114
- } else {
115
- const ancestorTree = A11yParse.A11yTree.buildAncestorTree(selectedNodes);
116
- text += A11yParse.A11yTree.stringify(ancestorTree);
156
+ const nodeCount = selectedNodes.length;
157
+ const pluralS = nodeCount > 1 ? 's' : '';
158
+ const ancestorsText = withAncestors ? ', with ancestors' : '';
159
+ const childrenText = withChildren ? ', with children' : '';
160
+ let text: string = `## Node${pluralS} found for selector '${selector}' (${nodeCount} node${pluralS}${ancestorsText}${childrenText}):`;
161
+ if (selectedNodes.length === 0) {
162
+ if (text.length > 0) text += '\n';
163
+ text += "No node found";
164
+ } else if (withAncestors === true) {
165
+ const subsetTree = A11yParse.A11yTree.buildSubsetTree(selectedNodes, {
166
+ withAncestors: true,
167
+ withChildren,
168
+ });
169
+ if (text.length > 0) text += '\n';
170
+ text += A11yParse.A11yDisplay.stringifyTree(subsetTree);
171
+ } else if (withChildren === true) {
172
+ for (const selectedNode of selectedNodes) {
173
+ if (text.length > 0) text += '\n';
174
+ text += A11yParse.A11yDisplay.stringifyTree(selectedNode);
117
175
  }
118
176
  } else {
119
177
  for (const selectedNode of selectedNodes) {
120
- text += A11yParse.A11yTree.stringify(selectedNode) + '\n';
178
+ if (text.length > 0) text += '\n';
179
+ text += A11yParse.A11yDisplay.stringifyNode(selectedNode);
121
180
  }
122
181
  }
123
182
  text += '\n';
@@ -144,7 +203,7 @@ class MainHelper {
144
203
  selectedNodes.splice(querySelector.limit);
145
204
  }
146
205
 
147
- responseTexts.push(this._formatSelectedNodes(querySelector.selector, selectedNodes, querySelector.withAncestors));
206
+ responseTexts.push(this._formatSelectedNodes(querySelector.selector, selectedNodes, querySelector.withAncestors, querySelector.withChildren));
148
207
  }
149
208
 
150
209
  // join the response texts for all selectors and return
@@ -167,7 +226,7 @@ class MainHelper {
167
226
  for (const querySelector of querySelectors.selectors) {
168
227
  const selectedNodes = A11yParse.A11yQuery.querySelectorAll(a11yTree, querySelector.selector);
169
228
  const firstNode = selectedNodes.length > 0 ? [selectedNodes[0]] : [];
170
- responseTexts.push(this._formatSelectedNodes(querySelector.selector, firstNode, querySelector.withAncestors));
229
+ responseTexts.push(this._formatSelectedNodes(querySelector.selector, firstNode, querySelector.withAncestors, querySelector.withChildren));
171
230
  }
172
231
 
173
232
  return responseTexts.join('\n');
@@ -195,10 +254,16 @@ class MainHelper {
195
254
  inputSchema: z.object({}),
196
255
  },
197
256
  async () => {
257
+ // log the events
258
+ logger.warn(`${mcpTarget}:${McpTargetHelper.EXTERNAL_TOOL_NAME.listPages}: listing pages`);
259
+
198
260
  const toolConfig = await McpTargetHelper.targetToolListPages(mcpTarget);
199
261
  const callToolResult = await mcpClient.callTool(toolConfig.toolName, toolConfig.toolArgs);
200
262
  let outputStr = await ResponseFormatter.formatListPages(mcpTarget, callToolResult);
201
263
 
264
+ // log the events
265
+ logger.warn(`${mcpTarget}:${McpTargetHelper.EXTERNAL_TOOL_NAME.listPages}: output:`)
266
+ logger.warn(`${outputStr}`);
202
267
  return {
203
268
  content: [{ type: "text", text: outputStr }],
204
269
  };
@@ -228,11 +293,18 @@ class MainHelper {
228
293
  // const callToolResult = await mcpClient.callTool(toolConfig.toolName, toolConfig.toolArgs);
229
294
  // let outputStr = await ResponseFormatter.formatNewPage(mcpTarget, callToolResult, url);
230
295
 
296
+ // log the events
297
+ logger.warn(`${mcpTarget}:${McpTargetHelper.EXTERNAL_TOOL_NAME.newPage}: url=${url}`);
298
+
299
+
231
300
  // so working around this by calling the navigate_page tool instead of new_page when the target is playwright
232
301
  const toolConfig = await McpTargetHelper.targetToolNavigatePage(mcpTarget, url);
233
302
  const callToolResult = await mcpClient.callTool(toolConfig.toolName, toolConfig.toolArgs);
234
303
  let outputStr = await ResponseFormatter.formatNavigatePage(mcpTarget, callToolResult);
235
304
 
305
+ // log the events
306
+ logger.warn(`${mcpTarget}:${McpTargetHelper.EXTERNAL_TOOL_NAME.newPage}: output:`)
307
+ logger.warn(`${outputStr}`);
236
308
 
237
309
  return {
238
310
  content: [{ type: "text", text: outputStr }],
@@ -255,10 +327,17 @@ class MainHelper {
255
327
  }),
256
328
  },
257
329
  async ({ pageId }: { pageId: number }) => {
330
+ // log the events
331
+ logger.warn(`${mcpTarget}:${McpTargetHelper.EXTERNAL_TOOL_NAME.closePage}: pageId=${pageId}`);
332
+
258
333
  const toolConfig = await McpTargetHelper.targetToolClosePage(mcpTarget, pageId);
259
334
  const callToolResult = await mcpClient.callTool(toolConfig.toolName, toolConfig.toolArgs);
260
335
  let outputStr = await ResponseFormatter.formatClosePage(mcpTarget, callToolResult, pageId);
261
336
 
337
+ // log the events
338
+ logger.warn(`${mcpTarget}:${McpTargetHelper.EXTERNAL_TOOL_NAME.closePage}: output:`)
339
+ logger.warn(`${outputStr}`);
340
+
262
341
  return {
263
342
  content: [{ type: "text", text: outputStr }],
264
343
  };
@@ -280,10 +359,17 @@ class MainHelper {
280
359
  }),
281
360
  },
282
361
  async ({ url }: { url: string }) => {
362
+ // log the events
363
+ logger.warn(`${mcpTarget}:${McpTargetHelper.EXTERNAL_TOOL_NAME.navigatePage}: url=${url}`);
364
+
283
365
  const toolConfig = await McpTargetHelper.targetToolNavigatePage(mcpTarget, url);
284
366
  const callToolResult = await mcpClient.callTool(toolConfig.toolName, toolConfig.toolArgs);
285
367
  let outputStr = await ResponseFormatter.formatNavigatePage(mcpTarget, callToolResult);
286
368
 
369
+ // log the events
370
+ logger.warn(`${mcpTarget}:${McpTargetHelper.EXTERNAL_TOOL_NAME.navigatePage}: output:`)
371
+ logger.warn(`${outputStr}`);
372
+
287
373
  return {
288
374
  content: [{ type: "text", text: outputStr }],
289
375
  };
@@ -303,9 +389,16 @@ class MainHelper {
303
389
  inputSchema: z.object({}),
304
390
  },
305
391
  async () => {
392
+ // log the events
393
+ logger.warn(`${mcpTarget}:${McpTargetHelper.EXTERNAL_TOOL_NAME.takeSnapshot}: taking snapshot`);
394
+
306
395
  const a11yText: string = await MainHelper._getA11yText(mcpClient);
307
396
  let outputStr = a11yText
308
397
 
398
+ // log the events
399
+ logger.warn(`${mcpTarget}:${McpTargetHelper.EXTERNAL_TOOL_NAME.takeSnapshot}: output:`);
400
+ logger.warn(`${outputStr}`);
401
+
309
402
  return {
310
403
  content: [{ type: "text", text: outputStr }],
311
404
  };
@@ -342,9 +435,16 @@ class MainHelper {
342
435
  inputSchema: QuerySelectorsInputSchema,
343
436
  },
344
437
  async (querySelectorsInput: QuerySelectorsInput) => {
438
+ // log the events
439
+ logger.warn(`${mcpTarget}:${McpTargetHelper.EXTERNAL_TOOL_NAME.querySelectorsAll}: querying selectors: ${JSON.stringify(querySelectorsInput)}`);
440
+
345
441
  // query the accessibility tree with the provided selector
346
442
  const outputText: string = await MainHelper.querySelectorsAll(mcpClient, querySelectorsInput);
347
443
 
444
+ // log the events
445
+ logger.warn(`${mcpTarget}:${McpTargetHelper.EXTERNAL_TOOL_NAME.querySelectorsAll}: output:`);
446
+ logger.warn(`${outputText}`);
447
+
348
448
  return {
349
449
  content: [{ type: "text", text: outputText }],
350
450
  };
@@ -367,7 +467,15 @@ class MainHelper {
367
467
  inputSchema: QuerySelectorsFirstInputSchema,
368
468
  },
369
469
  async (querySelectorsInput: QuerySelectorsFirstInput) => {
470
+ // log the events
471
+ logger.warn(`${mcpTarget}:${McpTargetHelper.EXTERNAL_TOOL_NAME.querySelectors}: querying selectors: ${JSON.stringify(querySelectorsInput)}`);
472
+
370
473
  const outputText: string = await MainHelper.querySelectors(mcpClient, querySelectorsInput);
474
+
475
+ // log the events
476
+ logger.warn(`${mcpTarget}:${McpTargetHelper.EXTERNAL_TOOL_NAME.querySelectors}: output:`);
477
+ logger.warn(`${outputText}`);
478
+
371
479
  return {
372
480
  content: [{ type: "text", text: outputText }],
373
481
  };
@@ -389,6 +497,9 @@ class MainHelper {
389
497
  }),
390
498
  },
391
499
  async ({ keys }: { keys: string }) => {
500
+ // log the events
501
+ logger.warn(`${mcpTarget}:${McpTargetHelper.EXTERNAL_TOOL_NAME.pressKeys}: pressing keys: ${keys}`);
502
+
392
503
  // Build the list of keys to send, splitting regular characters into individual key presses, but keeping special keys as-is
393
504
  const keysToSend: string[] = [];
394
505
  const keysSplit = keys.split(',').map((key) => key.trim());
@@ -419,6 +530,10 @@ class MainHelper {
419
530
 
420
531
  let outputText = await ResponseFormatter.formatPressKeys(mcpTarget, keysToSend);
421
532
 
533
+ // log the events
534
+ logger.warn(`${mcpTarget}:${McpTargetHelper.EXTERNAL_TOOL_NAME.pressKeys}: output:`);
535
+ logger.warn(`${outputText}`);
536
+
422
537
  // return a response indicating which keys were pressed
423
538
  return {
424
539
  content: [{ type: "text", text: outputText }],
@@ -444,12 +559,17 @@ class MainHelper {
444
559
  },
445
560
  },
446
561
  async ({ selector }: { selector: string }) => {
447
- const uid = await MainHelper._resolveSelectorToUid(mcpClient, selector);
562
+ // log the events
563
+ logger.warn(`${mcpTarget}:${McpTargetHelper.EXTERNAL_TOOL_NAME.click}: clicking selector: ${selector}`);
448
564
 
565
+ const uid = await MainHelper._resolveSelectorToUid(mcpClient, selector);
449
566
  const toolConfig = await McpTargetHelper.targetToolClick(mcpTarget, uid);
450
567
  const callToolResult = await mcpClient.callTool(toolConfig.toolName, toolConfig.toolArgs);
451
568
  let outputText = await ResponseFormatter.formatClick(mcpTarget, callToolResult);
452
569
 
570
+ // log the events
571
+ logger.warn(`${mcpTarget}:${McpTargetHelper.EXTERNAL_TOOL_NAME.click}: output:`);
572
+
453
573
  return {
454
574
  content: [{ type: "text", text: outputText }],
455
575
  };
@@ -477,6 +597,9 @@ class MainHelper {
477
597
  },
478
598
  },
479
599
  async ({ elements }: { elements: { selector: string; value: string }[] }) => {
600
+ // log the events
601
+ logger.warn(`${mcpTarget}:${McpTargetHelper.EXTERNAL_TOOL_NAME.fillForm}: filling form with elements: ${JSON.stringify(elements)}`);
602
+
480
603
  const resolved = [];
481
604
  for (const element of elements) {
482
605
  const uid = await MainHelper._resolveSelectorToUid(mcpClient, element.selector);
@@ -485,32 +608,36 @@ class MainHelper {
485
608
  value: element.value,
486
609
  });
487
610
  }
611
+ let callToolResult: CallToolResult;
488
612
  if (mcpTarget === 'chrome_devtools') {
489
613
  const toolConfig = await McpTargetHelper.targetToolFillForm(mcpTarget, resolved);
490
- const callToolResult = await mcpClient.callTool(toolConfig.toolName, toolConfig.toolArgs);
491
- // const callToolResult = await mcpClient.callTool('fill_form', { elements: resolved });
492
- return callToolResult
614
+ callToolResult = await mcpClient.callTool(toolConfig.toolName, toolConfig.toolArgs);
493
615
  } else if (mcpTarget === 'playwright') {
494
616
  type Field = {
495
617
  name: string; // Human readable name for the field, e.g. "Email address"
496
618
  // type can be textbox, checkbox, radio, combobox, or slider
497
619
  type: 'textbox' | 'checkbox' | 'radio' | 'combobox' | 'slider';
498
620
  // the uid of the field's corresponding node in the accessibility tree
499
- ref: string;
621
+ target: string;
500
622
  // the value to fill into the field - for checkboxes this can be "checked" or "unchecked", for radio buttons this can be "selected", for comboboxes this can be the option to select, and for sliders this can be the value to set the slider to
501
623
  value: string
502
624
  };
503
625
  const fields: Field[] = resolved.map((element, index) => ({
504
626
  name: `Field ${index + 1}`,
505
627
  type: 'textbox', // for simplicity, we assume all fields are textboxes in this example
506
- ref: element.uid,
628
+ target: element.uid,
507
629
  value: element.value,
508
630
  }));
509
- const callToolResult = await mcpClient.callTool('browser_fill_form', { fields: fields });
510
- return callToolResult
631
+ callToolResult = await mcpClient.callTool('browser_fill_form', { fields: fields });
511
632
  } else {
512
633
  throw new Error(`Unsupported MCP target: ${mcpTarget}`);
513
634
  }
635
+
636
+ // log the events
637
+ logger.warn(`${mcpTarget}:${McpTargetHelper.EXTERNAL_TOOL_NAME.fillForm}: output:`);
638
+ logger.warn(`${JSON.stringify(callToolResult)}`);
639
+
640
+ return callToolResult
514
641
  }
515
642
  );
516
643
 
@@ -530,6 +657,10 @@ class MainHelper {
530
657
  inputSchema: timezoneSchema,
531
658
  },
532
659
  async ({ timezone }) => {
660
+ // log the events
661
+ logger.warn(`${mcpTarget}:${McpTargetHelper.EXTERNAL_TOOL_NAME.getCurrentDateTime}: getting current date and time with timezone: ${timezone ?? 'local system timezone'}`);
662
+
663
+
533
664
  const date = new Date();
534
665
  const options: Intl.DateTimeFormatOptions = {
535
666
  timeZone: timezone,
@@ -542,6 +673,10 @@ class MainHelper {
542
673
  timeZoneName: "short",
543
674
  };
544
675
  const formatted = new Intl.DateTimeFormat("en-US", options).format(date);
676
+
677
+ // log the events
678
+ logger.warn(`${mcpTarget}:${McpTargetHelper.EXTERNAL_TOOL_NAME.getCurrentDateTime}: output: ${formatted}`);
679
+
545
680
  return {
546
681
  content: [{ type: "text", text: formatted }],
547
682
  };
@@ -564,6 +699,11 @@ class MainHelper {
564
699
  mcpTarget: FastBrowserMcpTarget,
565
700
  verbose?: boolean
566
701
  }): Promise<void> {
702
+ // Redirect process.stderr to a log file, so that the MCP communication is not polluted by logs.
703
+ const logFile = Path.resolve(import.meta.dirname, `../../outputs/fastbrowser_mcp_${new Date().toISOString()}.log`);
704
+ const logStream = Fs.createWriteStream(logFile, { flags: 'a' });
705
+ process.stderr.write = logStream.write.bind(logStream);
706
+
567
707
  ///////////////////////////////////////////////////////////////////////////////
568
708
  ///////////////////////////////////////////////////////////////////////////////
569
709
  // mcp client
@@ -616,11 +756,13 @@ class MainHelper {
616
756
  {
617
757
  selector: "link, button",
618
758
  withAncestors: true,
759
+ withChildren: false,
619
760
  limit: 0,
620
761
  },
621
762
  {
622
763
  selector: 'heading[level="1"]',
623
764
  withAncestors: false,
765
+ withChildren: false,
624
766
  limit: 0,
625
767
  },
626
768
  ],
@@ -646,19 +788,64 @@ class MainHelper {
646
788
  for (const toolName of toolsToProxys) {
647
789
  await mcpProxy.proxyToolCall(mcpClient, toolName)
648
790
  }
791
+
649
792
  ///////////////////////////////////////////////////////////////////////////////
650
793
  ///////////////////////////////////////////////////////////////////////////////
651
794
  // .querySelectorsAll tool implementation
652
795
  ///////////////////////////////////////////////////////////////////////////////
653
796
  ///////////////////////////////////////////////////////////////////////////////
654
- const mcpServer = await mcpProxy.getMcpServer();
655
797
 
798
+ const mcpServer = await mcpProxy.getMcpServer();
656
799
  await MainHelper.initExternalTools(mcpServer, mcpClient);
657
800
 
658
-
659
801
  // Connect the MCP proxy server to start accepting connections from MCP clients (e.g. LLM agents).
660
802
  await mcpProxy.connect();
661
803
  }
804
+
805
+ ///////////////////////////////////////////////////////////////////////////////
806
+ ///////////////////////////////////////////////////////////////////////////////
807
+ // .commandTargetTools: connect to the target MCP and print each tool's name, description, and input schema.
808
+ ///////////////////////////////////////////////////////////////////////////////
809
+ ///////////////////////////////////////////////////////////////////////////////
810
+
811
+ static async commandTargetTools({
812
+ mcpTarget,
813
+ }: {
814
+ mcpTarget: FastBrowserMcpTarget,
815
+ }): Promise<void> {
816
+ const { command: mcpCommand, args: mcpArgs } = McpTargetHelper.mcpArgs(mcpTarget);
817
+
818
+ const mcpClient = new McpMyClient({
819
+ name: 'fastbrowser_target_tools_client',
820
+ version: '1.0.0',
821
+ mcpTarget,
822
+ transport: {
823
+ type: 'stdio',
824
+ command: mcpCommand,
825
+ args: mcpArgs,
826
+ },
827
+ });
828
+
829
+ await mcpClient.connect();
830
+ try {
831
+ const tools = await mcpClient.listTools();
832
+ console.log(`# Tools available on MCP target '${mcpTarget}' (${tools.length})\n`);
833
+ for (const tool of tools) {
834
+ console.log(`## ${tool.name}`);
835
+ console.log('');
836
+ console.log(`### Description`)
837
+ console.log(`${tool.description ?? '(no description)'}`);
838
+ console.log('');
839
+ console.log(`### Input schema`);
840
+ console.log("```");
841
+ console.log(JSON.stringify(tool.inputSchema, null, 2));
842
+ console.log("```");
843
+ console.log('');
844
+ }
845
+ } finally {
846
+ await mcpClient.close();
847
+ }
848
+ }
662
849
  }
663
850
 
664
851
  ///////////////////////////////////////////////////////////////////////////////
@@ -668,12 +855,6 @@ class MainHelper {
668
855
  ///////////////////////////////////////////////////////////////////////////////
669
856
 
670
857
  async function main() {
671
- // Redirect process.stderr to a log file, so that the MCP communication is not polluted by logs.
672
- const logFile = Path.resolve(import.meta.dirname, `../../outputs/fastbrowser_mcp_${new Date().toISOString()}.log`);
673
- const logStream = Fs.createWriteStream(logFile, { flags: 'a' });
674
- process.stderr.write = logStream.write.bind(logStream);
675
-
676
-
677
858
  // throw Error("This entry point is not meant to be run directly. Please run one of the npm scripts defined in package.json, e.g. 'npm run start:fastbrowser_mcp' or 'npm run inspect:fastbrowser_mcp:chrome_devtools'");
678
859
 
679
860
  const program = new Command();
@@ -693,6 +874,20 @@ async function main() {
693
874
  });
694
875
  });
695
876
 
877
+ program
878
+ .command('target_tools')
879
+ .description('List the tools exposed by the target MCP (name, description, input schema)')
880
+ .addOption(
881
+ new Option('-b, --mcp_target <mcpTarget>', 'the MCP target to introspect')
882
+ .choices(['chrome_devtools', 'playwright'])
883
+ .default('playwright')
884
+ )
885
+ .action(async (options: { mcp_target: FastBrowserMcpTarget }) => {
886
+ await MainHelper.commandTargetTools({
887
+ mcpTarget: options.mcp_target,
888
+ });
889
+ });
890
+
696
891
  // display help if no command is provided
697
892
  if (process.argv.length < 3) {
698
893
  program.help();
@@ -1,4 +1,7 @@
1
+
2
+
1
3
  // node import
4
+ import { Logger } from "../../shared/logger.js"
2
5
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3
6
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
4
7
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
@@ -6,6 +9,12 @@ import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
6
9
  import type { CallToolResult, Prompt, Resource, Tool } from "@modelcontextprotocol/sdk/types.js";
7
10
  import { FastBrowserMcpTarget } from "../fastbrowser_types.js";
8
11
 
12
+ ///////////////////////////////////////////////////////////////////////////////
13
+ ///////////////////////////////////////////////////////////////////////////////
14
+ //
15
+ ///////////////////////////////////////////////////////////////////////////////
16
+ ///////////////////////////////////////////////////////////////////////////////
17
+
9
18
  export type StdioConfig = {
10
19
  type: "stdio";
11
20
  command: string;
@@ -34,6 +43,10 @@ export interface McpClientOptions {
34
43
  ///////////////////////////////////////////////////////////////////////////////
35
44
  ///////////////////////////////////////////////////////////////////////////////
36
45
 
46
+ const logger = Logger.fromMetaUrl(import.meta.url, {
47
+ allToStderr: true,
48
+ });
49
+
37
50
  // TODO remove this function - it seems to do vastly nothing - more confusing that helpful
38
51
  // - i use the McpServer directly, and i got a wrapper for the client... discrepancy is confusing
39
52
  export class McpMyClient {
@@ -57,9 +70,11 @@ export class McpMyClient {
57
70
 
58
71
  async connect(): Promise<void> {
59
72
  if (this.connected) return;
73
+ logger.warn(`McpMyClient:connect: Connecting to MCP with target ${this.mcpTarget}`);
60
74
 
61
75
  this.transport = this.createTransport(this.options.transport);
62
76
  await this.client.connect(this.transport);
77
+ logger.warn(`McpMyClient:connect: Connected to MCP with target ${this.mcpTarget}`);
63
78
  this.connected = true;
64
79
  }
65
80
 
@@ -72,6 +87,7 @@ export class McpMyClient {
72
87
  async listTools(): Promise<Tool[]> {
73
88
  this.assertConnected();
74
89
  const { tools } = await this.client.listTools();
90
+ logger.warn(`McpMyClient:listTools: ${JSON.stringify(tools, null, 2)}`);
75
91
  return tools;
76
92
  }
77
93
 
@@ -80,6 +96,7 @@ export class McpMyClient {
80
96
  args: Record<string, unknown> = {},
81
97
  ): Promise<CallToolResult> {
82
98
  this.assertConnected();
99
+ logger.warn(`McpMyClient:callTool: name: ${name} args: ${JSON.stringify(args, null, 2)}`);
83
100
  return this.client.callTool({ name, arguments: args }) as Promise<CallToolResult>;
84
101
  }
85
102
 
@@ -182,9 +182,21 @@ export class McpTargetHelper {
182
182
 
183
183
  static async targetToolClick(mcpTarget: FastBrowserMcpTarget, uid: string): Promise<TargetToolConfig> {
184
184
  if (mcpTarget === 'chrome_devtools') {
185
- return { toolName: 'click', toolArgs: { uid } };
185
+ const toolConfig: TargetToolConfig = {
186
+ toolName: 'click',
187
+ toolArgs: {
188
+ uid
189
+ }
190
+ };
191
+ return toolConfig;
186
192
  } else if (mcpTarget === 'playwright') {
187
- return { toolName: 'browser_click', toolArgs: { ref: uid } };
193
+ const toolConfig: TargetToolConfig = {
194
+ toolName: 'browser_click',
195
+ toolArgs: {
196
+ target: uid
197
+ }
198
+ };
199
+ return toolConfig;
188
200
  } else {
189
201
  throw new Error(`Unsupported MCP target: ${mcpTarget}`);
190
202
  }
@@ -194,6 +206,7 @@ export class McpTargetHelper {
194
206
  if (mcpTarget === 'chrome_devtools') {
195
207
  return { toolName: 'fill_form', toolArgs: { elements } };
196
208
  } else if (mcpTarget === 'playwright') {
209
+ // FIXME browser_fill_form tool exist in playwright MCP... implement it later
197
210
  throw new Error(`Fill form tool is not supported for Playwright MCP target yet`);
198
211
  } else {
199
212
  throw new Error(`Unsupported MCP target: ${mcpTarget}`);
@@ -115,7 +115,39 @@ export class PlaywrightA11yConverter {
115
115
  stack.push(node);
116
116
  }
117
117
 
118
- return allNodes.map((node) => PlaywrightA11yConverter._stringifyNode(node)).join('\n');
118
+ // Playwright's `browser_snapshot` response often interleaves the a11y tree with preamble lines like
119
+ // `- Page URL: ...`, `- Page Title: ...`, `- Console: 193 errors, 0 warnings`, plus trailing entries
120
+ // like `- New`. Each of those parses as a spurious top-level (indent=0) node. Downstream,
121
+ // `A11yParse.A11yTree.parse` silently overwrites `root` for every indent-0 line and returns only the
122
+ // LAST top-level subtree — so the real a11y page tree (usually a large `generic [active]` subtree
123
+ // in the middle) gets discarded and queries return zero hits even though the data is in the text.
124
+ // Pick the largest top-level subtree, which is reliably the actual page tree since the metadata
125
+ // subtrees are tiny (0–1 nodes).
126
+ const primary = PlaywrightA11yConverter._pickPrimarySubtree(allNodes);
127
+ return primary.map((node) => PlaywrightA11yConverter._stringifyNode(node)).join('\n');
128
+ }
129
+
130
+ private static _pickPrimarySubtree(allNodes: EmittedNode[]): EmittedNode[] {
131
+ if (allNodes.length === 0) return allNodes;
132
+
133
+ const topLevelIndices: number[] = [];
134
+ for (let i = 0; i < allNodes.length; i++) {
135
+ if (allNodes[i].indent === 0) topLevelIndices.push(i);
136
+ }
137
+ if (topLevelIndices.length <= 1) return allNodes;
138
+
139
+ let bestStart = topLevelIndices[0];
140
+ let bestSize = 0;
141
+ for (let k = 0; k < topLevelIndices.length; k++) {
142
+ const start = topLevelIndices[k];
143
+ const end = k + 1 < topLevelIndices.length ? topLevelIndices[k + 1] : allNodes.length;
144
+ const size = end - start;
145
+ if (size > bestSize) {
146
+ bestSize = size;
147
+ bestStart = start;
148
+ }
149
+ }
150
+ return allNodes.slice(bestStart, bestStart + bestSize);
119
151
  }
120
152
 
121
153
  ///////////////////////////////////////////////////////////////////////////////