chrometools-mcp 2.4.2 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/figma-tools.js CHANGED
@@ -377,3 +377,123 @@ export function collectAllText(node, texts = []) {
377
377
  }
378
378
  return texts;
379
379
  }
380
+
381
+ /**
382
+ * Simplify Figma node structure for code generation
383
+ * Extracts only essential properties: layout, styling, text, and children
384
+ */
385
+ export function simplifyNode(node) {
386
+ if (!node) return null;
387
+
388
+ const simplified = {
389
+ type: node.type,
390
+ name: node.name,
391
+ };
392
+
393
+ // Dimensions
394
+ if (node.absoluteBoundingBox) {
395
+ simplified.size = {
396
+ width: Math.round(node.absoluteBoundingBox.width),
397
+ height: Math.round(node.absoluteBoundingBox.height),
398
+ };
399
+ }
400
+
401
+ // Layout properties (Auto Layout / Flexbox)
402
+ if (node.layoutMode) {
403
+ simplified.layout = {
404
+ mode: node.layoutMode, // HORIZONTAL or VERTICAL
405
+ padding: (node.paddingLeft || node.paddingTop || node.paddingRight || node.paddingBottom) ? {
406
+ top: node.paddingTop || 0,
407
+ right: node.paddingRight || 0,
408
+ bottom: node.paddingBottom || 0,
409
+ left: node.paddingLeft || 0,
410
+ } : undefined,
411
+ gap: node.itemSpacing,
412
+ align: node.primaryAxisAlignItems,
413
+ justify: node.counterAxisAlignItems,
414
+ };
415
+ }
416
+
417
+ // Border radius
418
+ if (node.cornerRadius) {
419
+ simplified.borderRadius = node.cornerRadius;
420
+ } else if (node.rectangleCornerRadii) {
421
+ simplified.borderRadius = node.rectangleCornerRadii;
422
+ }
423
+
424
+ // Fills (backgrounds)
425
+ if (node.fills && node.fills.length > 0) {
426
+ simplified.fills = node.fills
427
+ .filter(fill => fill.visible !== false)
428
+ .map(fill => ({
429
+ type: fill.type,
430
+ color: fill.color ? {
431
+ r: Math.round(fill.color.r * 255),
432
+ g: Math.round(fill.color.g * 255),
433
+ b: Math.round(fill.color.b * 255),
434
+ a: fill.color.a !== undefined ? Math.round(fill.color.a * 100) / 100 : 1,
435
+ } : undefined,
436
+ opacity: fill.opacity,
437
+ }));
438
+ }
439
+
440
+ // Strokes (borders)
441
+ if (node.strokes && node.strokes.length > 0) {
442
+ simplified.strokes = node.strokes
443
+ .filter(stroke => stroke.visible !== false)
444
+ .map(stroke => ({
445
+ type: stroke.type,
446
+ color: stroke.color ? {
447
+ r: Math.round(stroke.color.r * 255),
448
+ g: Math.round(stroke.color.g * 255),
449
+ b: Math.round(stroke.color.b * 255),
450
+ a: stroke.color.a !== undefined ? Math.round(stroke.color.a * 100) / 100 : 1,
451
+ } : undefined,
452
+ }));
453
+
454
+ if (node.strokeWeight) {
455
+ simplified.strokeWeight = node.strokeWeight;
456
+ }
457
+ }
458
+
459
+ // Effects (shadows, blurs)
460
+ if (node.effects && node.effects.length > 0) {
461
+ simplified.effects = node.effects
462
+ .filter(effect => effect.visible !== false)
463
+ .map(effect => ({
464
+ type: effect.type,
465
+ radius: effect.radius,
466
+ offset: effect.offset,
467
+ color: effect.color ? {
468
+ r: Math.round(effect.color.r * 255),
469
+ g: Math.round(effect.color.g * 255),
470
+ b: Math.round(effect.color.b * 255),
471
+ a: Math.round(effect.color.a * 100) / 100,
472
+ } : undefined,
473
+ }));
474
+ }
475
+
476
+ // Text properties
477
+ if (node.type === 'TEXT') {
478
+ simplified.text = node.characters;
479
+ if (node.style) {
480
+ simplified.textStyle = {
481
+ fontFamily: node.style.fontFamily,
482
+ fontWeight: node.style.fontWeight,
483
+ fontSize: node.style.fontSize,
484
+ lineHeight: node.style.lineHeightPx,
485
+ letterSpacing: node.style.letterSpacing,
486
+ textAlign: node.style.textAlignHorizontal,
487
+ };
488
+ }
489
+ }
490
+
491
+ // Recursively simplify children
492
+ if (node.children && node.children.length > 0) {
493
+ simplified.children = node.children
494
+ .map(child => simplifyNode(child))
495
+ .filter(Boolean); // Remove null values
496
+ }
497
+
498
+ return simplified;
499
+ }
package/index.js CHANGED
@@ -63,7 +63,8 @@ import {
63
63
  listFigmaPages,
64
64
  normalizeFigmaNodeId,
65
65
  parseFigmaUrl,
66
- searchFigmaFrames
66
+ searchFigmaFrames,
67
+ simplifyNode
67
68
  } from './figma-tools.js';
68
69
 
69
70
  // Debug mode - only use stderr for actual errors, not debug info
@@ -908,6 +909,240 @@ async function executeToolInternal(name, args) {
908
909
  };
909
910
  }
910
911
 
912
+ if (name === "selectOption") {
913
+ const validatedArgs = schemas.SelectOptionSchema.parse(args);
914
+ const page = await getLastOpenPage();
915
+
916
+ // Select option with priority: value > text > index
917
+ const result = await page.evaluate((selector, value, text, index) => {
918
+ const selectElement = document.querySelector(selector);
919
+ if (!selectElement || selectElement.tagName !== 'SELECT') {
920
+ return { success: false, error: `Select element not found: ${selector}` };
921
+ }
922
+
923
+ let selectedOption = null;
924
+
925
+ // Priority 1: Select by value
926
+ if (value !== undefined && value !== null) {
927
+ const option = Array.from(selectElement.options).find(opt => opt.value === value);
928
+ if (option) {
929
+ selectElement.value = value;
930
+ selectedOption = option;
931
+ }
932
+ }
933
+
934
+ // Priority 2: Select by text
935
+ if (!selectedOption && text !== undefined && text !== null) {
936
+ const option = Array.from(selectElement.options).find(opt => opt.textContent.trim() === text);
937
+ if (option) {
938
+ selectElement.value = option.value;
939
+ selectedOption = option;
940
+ }
941
+ }
942
+
943
+ // Priority 3: Select by index
944
+ if (!selectedOption && index !== undefined && index !== null) {
945
+ if (index >= 0 && index < selectElement.options.length) {
946
+ selectElement.selectedIndex = index;
947
+ selectedOption = selectElement.options[index];
948
+ }
949
+ }
950
+
951
+ if (!selectedOption) {
952
+ return { success: false, error: 'No matching option found' };
953
+ }
954
+
955
+ // Trigger events for React and other frameworks
956
+ selectElement.dispatchEvent(new Event('input', { bubbles: true }));
957
+ selectElement.dispatchEvent(new Event('change', { bubbles: true }));
958
+
959
+ return {
960
+ success: true,
961
+ selectedValue: selectElement.value,
962
+ selectedText: selectedOption.textContent.trim(),
963
+ selectedIndex: selectElement.selectedIndex
964
+ };
965
+ }, validatedArgs.selector, validatedArgs.value, validatedArgs.text, validatedArgs.index);
966
+
967
+ if (!result.success) {
968
+ throw new Error(result.error);
969
+ }
970
+
971
+ return {
972
+ content: [{
973
+ type: "text",
974
+ text: `Selected option in ${validatedArgs.selector}:\n` +
975
+ ` Value: ${result.selectedValue}\n` +
976
+ ` Text: ${result.selectedText}\n` +
977
+ ` Index: ${result.selectedIndex}`
978
+ }],
979
+ };
980
+ }
981
+
982
+ if (name === "drag") {
983
+ const validatedArgs = schemas.DragSchema.parse(args);
984
+ const page = await getLastOpenPage();
985
+
986
+ const distance = validatedArgs.distance || 100;
987
+ const duration = validatedArgs.duration || 500;
988
+
989
+ // Calculate drag deltas based on direction
990
+ let deltaX = 0;
991
+ let deltaY = 0;
992
+
993
+ switch (validatedArgs.direction) {
994
+ case 'up':
995
+ deltaY = -distance;
996
+ break;
997
+ case 'down':
998
+ deltaY = distance;
999
+ break;
1000
+ case 'left':
1001
+ deltaX = -distance;
1002
+ break;
1003
+ case 'right':
1004
+ deltaX = distance;
1005
+ break;
1006
+ case 'up-left':
1007
+ deltaY = -distance;
1008
+ deltaX = -distance;
1009
+ break;
1010
+ case 'up-right':
1011
+ deltaY = -distance;
1012
+ deltaX = distance;
1013
+ break;
1014
+ case 'down-left':
1015
+ deltaY = distance;
1016
+ deltaX = -distance;
1017
+ break;
1018
+ case 'down-right':
1019
+ deltaY = distance;
1020
+ deltaX = distance;
1021
+ break;
1022
+ }
1023
+
1024
+ // Get element center position for drag start
1025
+ const elementInfo = await page.evaluate((selector) => {
1026
+ const element = document.querySelector(selector);
1027
+ if (!element) {
1028
+ return { success: false, error: `Element not found: ${selector}` };
1029
+ }
1030
+
1031
+ const rect = element.getBoundingClientRect();
1032
+ return {
1033
+ success: true,
1034
+ centerX: rect.left + rect.width / 2,
1035
+ centerY: rect.top + rect.height / 2,
1036
+ width: rect.width,
1037
+ height: rect.height
1038
+ };
1039
+ }, validatedArgs.selector);
1040
+
1041
+ if (!elementInfo.success) {
1042
+ throw new Error(elementInfo.error);
1043
+ }
1044
+
1045
+ // Perform drag: mousedown → mousemove → mouseup
1046
+ const startX = elementInfo.centerX;
1047
+ const startY = elementInfo.centerY;
1048
+ const endX = startX + deltaX;
1049
+ const endY = startY + deltaY;
1050
+
1051
+ // Move to start position
1052
+ await page.mouse.move(startX, startY);
1053
+
1054
+ // Press mouse button (start drag)
1055
+ await page.mouse.down();
1056
+
1057
+ // Wait a bit to ensure drag is registered
1058
+ await new Promise(resolve => setTimeout(resolve, 50));
1059
+
1060
+ // Move mouse to end position (drag)
1061
+ const steps = Math.max(10, Math.floor(duration / 20)); // Smooth movement
1062
+ await page.mouse.move(endX, endY, { steps });
1063
+
1064
+ // Wait for duration
1065
+ await new Promise(resolve => setTimeout(resolve, Math.max(0, duration - steps * 20)));
1066
+
1067
+ // Release mouse button (end drag)
1068
+ await page.mouse.up();
1069
+
1070
+ return {
1071
+ content: [{
1072
+ type: "text",
1073
+ text: `Dragged ${validatedArgs.selector} ${validatedArgs.direction} by ${distance}px:\n` +
1074
+ ` Start position: (${Math.round(startX)}, ${Math.round(startY)})\n` +
1075
+ ` End position: (${Math.round(endX)}, ${Math.round(endY)})\n` +
1076
+ ` Delta: (${deltaX}px, ${deltaY}px)\n` +
1077
+ ` Duration: ${duration}ms`
1078
+ }],
1079
+ };
1080
+ }
1081
+
1082
+ if (name === "scrollHorizontal") {
1083
+ const validatedArgs = schemas.ScrollHorizontalSchema.parse(args);
1084
+ const page = await getLastOpenPage();
1085
+
1086
+ const behavior = validatedArgs.behavior || 'auto';
1087
+
1088
+ const result = await page.evaluate((selector, direction, amount, behavior) => {
1089
+ const element = document.querySelector(selector);
1090
+ if (!element) {
1091
+ return { success: false, error: `Element not found: ${selector}` };
1092
+ }
1093
+
1094
+ // Determine scroll amount
1095
+ let scrollAmount;
1096
+ if (amount === 'full') {
1097
+ // Scroll to the end
1098
+ scrollAmount = direction === 'right'
1099
+ ? element.scrollWidth - element.clientWidth
1100
+ : 0;
1101
+ } else {
1102
+ // Relative scroll
1103
+ scrollAmount = direction === 'right'
1104
+ ? element.scrollLeft + amount
1105
+ : element.scrollLeft - amount;
1106
+ }
1107
+
1108
+ // Perform scroll
1109
+ element.scrollTo({
1110
+ left: scrollAmount,
1111
+ behavior: behavior
1112
+ });
1113
+
1114
+ // Wait a bit for scroll to complete (if smooth)
1115
+ return new Promise(resolve => {
1116
+ setTimeout(() => {
1117
+ resolve({
1118
+ success: true,
1119
+ scrollLeft: element.scrollLeft,
1120
+ scrollWidth: element.scrollWidth,
1121
+ clientWidth: element.clientWidth,
1122
+ canScrollRight: element.scrollLeft < (element.scrollWidth - element.clientWidth),
1123
+ canScrollLeft: element.scrollLeft > 0
1124
+ });
1125
+ }, behavior === 'smooth' ? 300 : 50);
1126
+ });
1127
+ }, validatedArgs.selector, validatedArgs.direction, validatedArgs.amount, behavior);
1128
+
1129
+ if (!result.success) {
1130
+ throw new Error(result.error);
1131
+ }
1132
+
1133
+ return {
1134
+ content: [{
1135
+ type: "text",
1136
+ text: `Scrolled ${validatedArgs.selector} ${validatedArgs.direction}:\n` +
1137
+ ` Scroll position: ${result.scrollLeft}px\n` +
1138
+ ` Total width: ${result.scrollWidth}px\n` +
1139
+ ` Visible width: ${result.clientWidth}px\n` +
1140
+ ` Can scroll right: ${result.canScrollRight}\n` +
1141
+ ` Can scroll left: ${result.canScrollLeft}`
1142
+ }],
1143
+ };
1144
+ }
1145
+
911
1146
  if (name === "setStyles") {
912
1147
  const validatedArgs = schemas.SetStylesSchema.parse(args);
913
1148
  const page = await getLastOpenPage();
@@ -1464,6 +1699,111 @@ async function executeToolInternal(name, args) {
1464
1699
  };
1465
1700
  }
1466
1701
 
1702
+ if (name === "convertFigmaToCode") {
1703
+ const validatedArgs = schemas.ConvertFigmaToCodeSchema.parse(args);
1704
+ const token = validatedArgs.figmaToken || FIGMA_TOKEN;
1705
+ if (!token) {
1706
+ throw new Error('Figma token is required. Pass it as parameter or set FIGMA_TOKEN environment variable in MCP config.');
1707
+ }
1708
+
1709
+ // Normalize node ID
1710
+ const nodeId = normalizeFigmaNodeId(validatedArgs.nodeId);
1711
+ const framework = validatedArgs.framework || 'react';
1712
+ const includeComments = validatedArgs.includeComments !== false; // default true
1713
+
1714
+ // Fetch node structure
1715
+ const nodesData = await fetchFigmaAPI(
1716
+ `files/${validatedArgs.fileKey}/nodes?ids=${encodeURIComponent(nodeId)}`,
1717
+ token
1718
+ );
1719
+
1720
+ if (!nodesData.nodes || !nodesData.nodes[nodeId]) {
1721
+ throw new Error(`Node ${nodeId} not found in Figma file ${validatedArgs.fileKey}`);
1722
+ }
1723
+
1724
+ // Fetch rendered image at 2x scale
1725
+ const exportData = await fetchFigmaAPI(
1726
+ `images/${validatedArgs.fileKey}?ids=${nodeId}&scale=2&format=png`,
1727
+ token
1728
+ );
1729
+
1730
+ if (!exportData.images || !exportData.images[nodeId]) {
1731
+ throw new Error(`Failed to export image for node ${nodeId}`);
1732
+ }
1733
+
1734
+ const imageUrl = exportData.images[nodeId];
1735
+
1736
+ // Simplify node structure
1737
+ const nodeInfo = nodesData.nodes[nodeId];
1738
+ const simplifiedNode = simplifyNode(nodeInfo.document);
1739
+
1740
+ // Build AI instruction based on framework
1741
+ const frameworkInstructions = {
1742
+ 'react': 'React (JavaScript) with Tailwind CSS',
1743
+ 'react-typescript': 'React (TypeScript) with Tailwind CSS',
1744
+ 'html': 'Pure HTML with Tailwind CSS classes'
1745
+ };
1746
+
1747
+ const instruction = `# Figma to Code Conversion
1748
+
1749
+ ## Design Image
1750
+ ![Design](${imageUrl})
1751
+
1752
+ ## Task
1753
+ Convert this Figma design to ${frameworkInstructions[framework]}.
1754
+
1755
+ ## Design Structure (Simplified)
1756
+ \`\`\`json
1757
+ ${JSON.stringify(simplifiedNode, null, 2)}
1758
+ \`\`\`
1759
+
1760
+ ## Instructions
1761
+
1762
+ ### Framework: ${framework.toUpperCase()}
1763
+ ${framework.startsWith('react') ? `
1764
+ - Create a functional React component
1765
+ - Use Tailwind CSS for all styling
1766
+ - Props: Accept any necessary data as props
1767
+ - Use semantic HTML elements (div, section, button, h1-h6, p, etc.)
1768
+ ${framework === 'react-typescript' ? '- Add TypeScript type definitions for props' : ''}
1769
+ ` : `
1770
+ - Create clean, semantic HTML structure
1771
+ - Use Tailwind CSS classes for styling
1772
+ - No JavaScript required unless interactive elements present
1773
+ `}
1774
+
1775
+ ### Styling Guidelines
1776
+ 1. **Colors**: Convert RGB values to Tailwind colors or use arbitrary values: \`bg-[rgb(r,g,b)]\`
1777
+ 2. **Spacing**: Use Tailwind spacing scale (p-4, m-2, gap-4) matching design padding/gaps
1778
+ 3. **Layout**:
1779
+ - HORIZONTAL → \`flex flex-row\`
1780
+ - VERTICAL → \`flex flex-col\`
1781
+ - Use \`justify-*\` and \`items-*\` for alignment
1782
+ 4. **Typography**: Match font families, weights, sizes from textStyle properties
1783
+ 5. **Border Radius**: \`rounded-[Npx]\` for exact values
1784
+ 6. **Shadows**: Use Tailwind shadow utilities or arbitrary values
1785
+ 7. **Responsive**: Add responsive variants if design suggests multiple breakpoints
1786
+
1787
+ ### Quality Requirements
1788
+ - **Clean code**: No unnecessary divs, proper semantic structure
1789
+ - **Accurate spacing**: Match design padding, gaps, and margins
1790
+ - **Proper hierarchy**: Respect component nesting from design structure
1791
+ ${includeComments ? '- **Comments**: Add brief comments explaining complex layout decisions' : ''}
1792
+ - **Accessibility**: Use proper ARIA labels where needed
1793
+
1794
+ ### Output Format
1795
+ Return ONLY the code, no explanations. ${framework.startsWith('react') ? 'Export the component as default.' : 'Provide complete HTML structure.'}
1796
+
1797
+ Start coding now.`;
1798
+
1799
+ return {
1800
+ content: [{
1801
+ type: "text",
1802
+ text: instruction
1803
+ }],
1804
+ };
1805
+ }
1806
+
1467
1807
  // New AI optimization tools
1468
1808
  if (name === "smartFindElement") {
1469
1809
  const validatedArgs = schemas.SmartFindElementSchema.parse(args);
@@ -1629,9 +1969,9 @@ async function executeToolInternal(name, args) {
1629
1969
  form.querySelectorAll('input, textarea, select').forEach(field => {
1630
1970
  if (field.type === 'submit' || field.type === 'button') return;
1631
1971
 
1632
- formData.fields.push({
1972
+ const fieldData = {
1633
1973
  selector: getUniqueSelectorInPage(field),
1634
- type: field.type || 'text',
1974
+ type: field.type || (field.tagName === 'SELECT' ? 'select' : 'text'),
1635
1975
  name: field.name,
1636
1976
  id: field.id,
1637
1977
  placeholder: field.placeholder,
@@ -1640,7 +1980,23 @@ async function executeToolInternal(name, args) {
1640
1980
  return label ? label.textContent.trim() : null;
1641
1981
  })(),
1642
1982
  required: field.required,
1643
- });
1983
+ };
1984
+
1985
+ // Add select-specific information
1986
+ if (field.tagName === 'SELECT') {
1987
+ fieldData.options = Array.from(field.options).map((opt, idx) => ({
1988
+ value: opt.value,
1989
+ text: opt.textContent.trim(),
1990
+ index: idx,
1991
+ selected: opt.selected,
1992
+ disabled: opt.disabled
1993
+ }));
1994
+ fieldData.selectedIndex = field.selectedIndex;
1995
+ fieldData.selectedValue = field.value;
1996
+ fieldData.selectedText = field.options[field.selectedIndex]?.textContent.trim() || null;
1997
+ }
1998
+
1999
+ formData.fields.push(fieldData);
1644
2000
  });
1645
2001
 
1646
2002
  // Find submit button
@@ -1672,12 +2028,28 @@ async function executeToolInternal(name, args) {
1672
2028
  if (input.type === 'submit' || input.type === 'button' || input.type === 'hidden') return;
1673
2029
  if (input.offsetWidth === 0 && input.offsetHeight === 0) return;
1674
2030
 
1675
- result.inputs.push({
2031
+ const inputData = {
1676
2032
  selector: getUniqueSelectorInPage(input),
1677
- type: input.type || 'text',
2033
+ type: input.type || (input.tagName === 'SELECT' ? 'select' : 'text'),
1678
2034
  name: input.name,
1679
2035
  placeholder: input.placeholder,
1680
- });
2036
+ };
2037
+
2038
+ // Add select-specific information
2039
+ if (input.tagName === 'SELECT') {
2040
+ inputData.options = Array.from(input.options).map((opt, idx) => ({
2041
+ value: opt.value,
2042
+ text: opt.textContent.trim(),
2043
+ index: idx,
2044
+ selected: opt.selected,
2045
+ disabled: opt.disabled
2046
+ }));
2047
+ inputData.selectedIndex = input.selectedIndex;
2048
+ inputData.selectedValue = input.value;
2049
+ inputData.selectedText = input.options[input.selectedIndex]?.textContent.trim() || null;
2050
+ }
2051
+
2052
+ result.inputs.push(inputData);
1681
2053
  });
1682
2054
 
1683
2055
  // All links
@@ -1740,7 +2112,18 @@ async function executeToolInternal(name, args) {
1740
2112
  selector: getUniqueSelectorInPage(el),
1741
2113
  tag: el.tagName.toLowerCase(),
1742
2114
  text: ownText.substring(0, 100),
1743
- classes: el.className ? el.className.split(' ').filter(c => c) : [],
2115
+ classes: (() => {
2116
+ // Handle both string className (HTML) and SVGAnimatedString (SVG)
2117
+ if (!el.className) return [];
2118
+ if (typeof el.className === 'string') {
2119
+ return el.className.split(' ').filter(c => c);
2120
+ }
2121
+ // SVG elements have className.baseVal
2122
+ if (el.className.baseVal) {
2123
+ return el.className.baseVal.split(' ').filter(c => c);
2124
+ }
2125
+ return [];
2126
+ })(),
1744
2127
  id: el.id || null,
1745
2128
  attributes: {
1746
2129
  role: el.getAttribute('role') || null,
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chrometools-mcp",
3
- "version": "2.4.2",
3
+ "version": "2.5.0",
4
4
  "description": "MCP (Model Context Protocol) server for Chrome automation using Puppeteer. Persistent browser sessions, visual testing, Figma comparison, and design validation. Works seamlessly in WSL, Linux, and macOS.",
5
5
  "type": "module",
6
6
  "main": "index.js",