@muloka/figma-console-mcp 0.1.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.
Files changed (443) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +898 -0
  3. package/dist/apps/design-system-dashboard/scoring/accessibility.d.ts +14 -0
  4. package/dist/apps/design-system-dashboard/scoring/accessibility.d.ts.map +1 -0
  5. package/dist/apps/design-system-dashboard/scoring/accessibility.js +278 -0
  6. package/dist/apps/design-system-dashboard/scoring/accessibility.js.map +1 -0
  7. package/dist/apps/design-system-dashboard/scoring/component-metadata.d.ts +29 -0
  8. package/dist/apps/design-system-dashboard/scoring/component-metadata.d.ts.map +1 -0
  9. package/dist/apps/design-system-dashboard/scoring/component-metadata.js +358 -0
  10. package/dist/apps/design-system-dashboard/scoring/component-metadata.js.map +1 -0
  11. package/dist/apps/design-system-dashboard/scoring/consistency.d.ts +14 -0
  12. package/dist/apps/design-system-dashboard/scoring/consistency.d.ts.map +1 -0
  13. package/dist/apps/design-system-dashboard/scoring/consistency.js +342 -0
  14. package/dist/apps/design-system-dashboard/scoring/consistency.js.map +1 -0
  15. package/dist/apps/design-system-dashboard/scoring/coverage.d.ts +14 -0
  16. package/dist/apps/design-system-dashboard/scoring/coverage.d.ts.map +1 -0
  17. package/dist/apps/design-system-dashboard/scoring/coverage.js +231 -0
  18. package/dist/apps/design-system-dashboard/scoring/coverage.js.map +1 -0
  19. package/dist/apps/design-system-dashboard/scoring/engine.d.ts +27 -0
  20. package/dist/apps/design-system-dashboard/scoring/engine.d.ts.map +1 -0
  21. package/dist/apps/design-system-dashboard/scoring/engine.js +93 -0
  22. package/dist/apps/design-system-dashboard/scoring/engine.js.map +1 -0
  23. package/dist/apps/design-system-dashboard/scoring/naming-semantics.d.ts +14 -0
  24. package/dist/apps/design-system-dashboard/scoring/naming-semantics.d.ts.map +1 -0
  25. package/dist/apps/design-system-dashboard/scoring/naming-semantics.js +309 -0
  26. package/dist/apps/design-system-dashboard/scoring/naming-semantics.js.map +1 -0
  27. package/dist/apps/design-system-dashboard/scoring/token-architecture.d.ts +14 -0
  28. package/dist/apps/design-system-dashboard/scoring/token-architecture.d.ts.map +1 -0
  29. package/dist/apps/design-system-dashboard/scoring/token-architecture.js +350 -0
  30. package/dist/apps/design-system-dashboard/scoring/token-architecture.js.map +1 -0
  31. package/dist/apps/design-system-dashboard/scoring/types.d.ts +89 -0
  32. package/dist/apps/design-system-dashboard/scoring/types.d.ts.map +1 -0
  33. package/dist/apps/design-system-dashboard/scoring/types.js +41 -0
  34. package/dist/apps/design-system-dashboard/scoring/types.js.map +1 -0
  35. package/dist/apps/design-system-dashboard/server.d.ts +24 -0
  36. package/dist/apps/design-system-dashboard/server.d.ts.map +1 -0
  37. package/dist/apps/design-system-dashboard/server.js +160 -0
  38. package/dist/apps/design-system-dashboard/server.js.map +1 -0
  39. package/dist/apps/token-browser/server.d.ts +26 -0
  40. package/dist/apps/token-browser/server.d.ts.map +1 -0
  41. package/dist/apps/token-browser/server.js +137 -0
  42. package/dist/apps/token-browser/server.js.map +1 -0
  43. package/dist/browser/base.d.ts +58 -0
  44. package/dist/browser/base.d.ts.map +1 -0
  45. package/dist/browser/base.js +6 -0
  46. package/dist/browser/base.js.map +1 -0
  47. package/dist/browser/local.d.ts +87 -0
  48. package/dist/browser/local.d.ts.map +1 -0
  49. package/dist/browser/local.js +318 -0
  50. package/dist/browser/local.js.map +1 -0
  51. package/dist/cloudflare/apps/design-system-dashboard/scoring/accessibility.js +277 -0
  52. package/dist/cloudflare/apps/design-system-dashboard/scoring/component-metadata.js +357 -0
  53. package/dist/cloudflare/apps/design-system-dashboard/scoring/consistency.js +341 -0
  54. package/dist/cloudflare/apps/design-system-dashboard/scoring/coverage.js +230 -0
  55. package/dist/cloudflare/apps/design-system-dashboard/scoring/engine.js +92 -0
  56. package/dist/cloudflare/apps/design-system-dashboard/scoring/naming-semantics.js +308 -0
  57. package/dist/cloudflare/apps/design-system-dashboard/scoring/token-architecture.js +349 -0
  58. package/dist/cloudflare/apps/design-system-dashboard/scoring/types.js +40 -0
  59. package/dist/cloudflare/apps/design-system-dashboard/server.js +159 -0
  60. package/dist/cloudflare/apps/token-browser/server.js +136 -0
  61. package/dist/cloudflare/browser-manager.js +157 -0
  62. package/dist/cloudflare/core/accessibility-tools.js +361 -0
  63. package/dist/cloudflare/core/annotation-tools.js +230 -0
  64. package/dist/cloudflare/core/cloud-websocket-connector.js +419 -0
  65. package/dist/cloudflare/core/cloud-websocket-relay.js +198 -0
  66. package/dist/cloudflare/core/comment-tools.js +292 -0
  67. package/dist/cloudflare/core/config.js +153 -0
  68. package/dist/cloudflare/core/console-monitor.js +427 -0
  69. package/dist/cloudflare/core/deep-component-tools.js +128 -0
  70. package/dist/cloudflare/core/design-code-tools.js +2793 -0
  71. package/dist/cloudflare/core/design-system-manifest.js +265 -0
  72. package/dist/cloudflare/core/design-system-tools.js +882 -0
  73. package/dist/cloudflare/core/diagnose-tool.js +100 -0
  74. package/dist/cloudflare/core/diff/changelog-formatter.js +275 -0
  75. package/dist/cloudflare/core/diff/diff-engine.js +334 -0
  76. package/dist/cloudflare/core/diff/property-compare.js +36 -0
  77. package/dist/cloudflare/core/diff/version-cache.js +74 -0
  78. package/dist/cloudflare/core/enrichment/enrichment-service.js +374 -0
  79. package/dist/cloudflare/core/enrichment/index.js +7 -0
  80. package/dist/cloudflare/core/enrichment/relationship-mapper.js +351 -0
  81. package/dist/cloudflare/core/enrichment/style-resolver.js +346 -0
  82. package/dist/cloudflare/core/figjam-tools.js +547 -0
  83. package/dist/cloudflare/core/figma-api.js +509 -0
  84. package/dist/cloudflare/core/figma-connector.js +7 -0
  85. package/dist/cloudflare/core/figma-reconstruction-spec.js +402 -0
  86. package/dist/cloudflare/core/figma-style-extractor.js +311 -0
  87. package/dist/cloudflare/core/figma-tools.js +3302 -0
  88. package/dist/cloudflare/core/identity.js +96 -0
  89. package/dist/cloudflare/core/library-tools.js +580 -0
  90. package/dist/cloudflare/core/logger.js +53 -0
  91. package/dist/cloudflare/core/port-discovery.js +935 -0
  92. package/dist/cloudflare/core/resolve-package-root.js +11 -0
  93. package/dist/cloudflare/core/slides-tools.js +714 -0
  94. package/dist/cloudflare/core/slot-tools.js +334 -0
  95. package/dist/cloudflare/core/snippet-injector.js +96 -0
  96. package/dist/cloudflare/core/tokens/alias-resolver.js +205 -0
  97. package/dist/cloudflare/core/tokens/config.js +294 -0
  98. package/dist/cloudflare/core/tokens/dialect.js +232 -0
  99. package/dist/cloudflare/core/tokens/figma-converter.js +323 -0
  100. package/dist/cloudflare/core/tokens/formatters/css-vars.js +338 -0
  101. package/dist/cloudflare/core/tokens/formatters/dtcg.js +376 -0
  102. package/dist/cloudflare/core/tokens/formatters/index.js +45 -0
  103. package/dist/cloudflare/core/tokens/formatters/json.js +205 -0
  104. package/dist/cloudflare/core/tokens/formatters/less.js +4 -0
  105. package/dist/cloudflare/core/tokens/formatters/scss.js +258 -0
  106. package/dist/cloudflare/core/tokens/formatters/stubs.js +13 -0
  107. package/dist/cloudflare/core/tokens/formatters/style-dictionary-v3.js +213 -0
  108. package/dist/cloudflare/core/tokens/formatters/tailwind-v3.js +237 -0
  109. package/dist/cloudflare/core/tokens/formatters/tailwind-v4.js +335 -0
  110. package/dist/cloudflare/core/tokens/formatters/tokens-studio.js +256 -0
  111. package/dist/cloudflare/core/tokens/formatters/ts-module.js +198 -0
  112. package/dist/cloudflare/core/tokens/index.js +16 -0
  113. package/dist/cloudflare/core/tokens/parsers/css-vars.js +4 -0
  114. package/dist/cloudflare/core/tokens/parsers/dtcg.js +280 -0
  115. package/dist/cloudflare/core/tokens/parsers/index.js +138 -0
  116. package/dist/cloudflare/core/tokens/parsers/json.js +7 -0
  117. package/dist/cloudflare/core/tokens/parsers/scss.js +4 -0
  118. package/dist/cloudflare/core/tokens/parsers/stubs.js +20 -0
  119. package/dist/cloudflare/core/tokens/parsers/style-dictionary-v3.js +4 -0
  120. package/dist/cloudflare/core/tokens/parsers/tailwind-v3.js +4 -0
  121. package/dist/cloudflare/core/tokens/parsers/tailwind-v4.js +4 -0
  122. package/dist/cloudflare/core/tokens/parsers/tokens-studio.js +4 -0
  123. package/dist/cloudflare/core/tokens/schemas.js +152 -0
  124. package/dist/cloudflare/core/tokens/transforms/color.js +12 -0
  125. package/dist/cloudflare/core/tokens/transforms/index.js +29 -0
  126. package/dist/cloudflare/core/tokens/transforms/size.js +7 -0
  127. package/dist/cloudflare/core/tokens/types.js +18 -0
  128. package/dist/cloudflare/core/tokens-tools.js +1788 -0
  129. package/dist/cloudflare/core/types/design-code.js +4 -0
  130. package/dist/cloudflare/core/types/enriched.js +5 -0
  131. package/dist/cloudflare/core/types/index.js +4 -0
  132. package/dist/cloudflare/core/variable-resolver.js +85 -0
  133. package/dist/cloudflare/core/version-tools.js +1199 -0
  134. package/dist/cloudflare/core/websocket-connector.js +436 -0
  135. package/dist/cloudflare/core/websocket-server.js +956 -0
  136. package/dist/cloudflare/core/write-tools.js +2506 -0
  137. package/dist/cloudflare/index.js +3010 -0
  138. package/dist/core/accessibility-tools.d.ts +21 -0
  139. package/dist/core/accessibility-tools.d.ts.map +1 -0
  140. package/dist/core/accessibility-tools.js +362 -0
  141. package/dist/core/accessibility-tools.js.map +1 -0
  142. package/dist/core/annotation-tools.d.ts +14 -0
  143. package/dist/core/annotation-tools.d.ts.map +1 -0
  144. package/dist/core/annotation-tools.js +231 -0
  145. package/dist/core/annotation-tools.js.map +1 -0
  146. package/dist/core/comment-tools.d.ts +11 -0
  147. package/dist/core/comment-tools.d.ts.map +1 -0
  148. package/dist/core/comment-tools.js +293 -0
  149. package/dist/core/comment-tools.js.map +1 -0
  150. package/dist/core/config.d.ts +17 -0
  151. package/dist/core/config.d.ts.map +1 -0
  152. package/dist/core/config.js +154 -0
  153. package/dist/core/config.js.map +1 -0
  154. package/dist/core/console-monitor.d.ts +82 -0
  155. package/dist/core/console-monitor.d.ts.map +1 -0
  156. package/dist/core/console-monitor.js +428 -0
  157. package/dist/core/console-monitor.js.map +1 -0
  158. package/dist/core/deep-component-tools.d.ts +14 -0
  159. package/dist/core/deep-component-tools.d.ts.map +1 -0
  160. package/dist/core/deep-component-tools.js +129 -0
  161. package/dist/core/deep-component-tools.js.map +1 -0
  162. package/dist/core/design-code-tools.d.ts +116 -0
  163. package/dist/core/design-code-tools.d.ts.map +1 -0
  164. package/dist/core/design-code-tools.js +2794 -0
  165. package/dist/core/design-code-tools.js.map +1 -0
  166. package/dist/core/design-system-manifest.d.ts +273 -0
  167. package/dist/core/design-system-manifest.d.ts.map +1 -0
  168. package/dist/core/design-system-manifest.js +266 -0
  169. package/dist/core/design-system-manifest.js.map +1 -0
  170. package/dist/core/design-system-tools.d.ts +67 -0
  171. package/dist/core/design-system-tools.d.ts.map +1 -0
  172. package/dist/core/design-system-tools.js +883 -0
  173. package/dist/core/design-system-tools.js.map +1 -0
  174. package/dist/core/diagnose-tool.d.ts +41 -0
  175. package/dist/core/diagnose-tool.d.ts.map +1 -0
  176. package/dist/core/diagnose-tool.js +101 -0
  177. package/dist/core/diagnose-tool.js.map +1 -0
  178. package/dist/core/diff/changelog-formatter.d.ts +35 -0
  179. package/dist/core/diff/changelog-formatter.d.ts.map +1 -0
  180. package/dist/core/diff/changelog-formatter.js +276 -0
  181. package/dist/core/diff/changelog-formatter.js.map +1 -0
  182. package/dist/core/diff/diff-engine.d.ts +127 -0
  183. package/dist/core/diff/diff-engine.d.ts.map +1 -0
  184. package/dist/core/diff/diff-engine.js +335 -0
  185. package/dist/core/diff/diff-engine.js.map +1 -0
  186. package/dist/core/diff/property-compare.d.ts +19 -0
  187. package/dist/core/diff/property-compare.d.ts.map +1 -0
  188. package/dist/core/diff/property-compare.js +37 -0
  189. package/dist/core/diff/property-compare.js.map +1 -0
  190. package/dist/core/diff/version-cache.d.ts +40 -0
  191. package/dist/core/diff/version-cache.d.ts.map +1 -0
  192. package/dist/core/diff/version-cache.js +75 -0
  193. package/dist/core/diff/version-cache.js.map +1 -0
  194. package/dist/core/enrichment/enrichment-service.d.ts +52 -0
  195. package/dist/core/enrichment/enrichment-service.d.ts.map +1 -0
  196. package/dist/core/enrichment/enrichment-service.js +375 -0
  197. package/dist/core/enrichment/enrichment-service.js.map +1 -0
  198. package/dist/core/enrichment/index.d.ts +8 -0
  199. package/dist/core/enrichment/index.d.ts.map +1 -0
  200. package/dist/core/enrichment/index.js +8 -0
  201. package/dist/core/enrichment/index.js.map +1 -0
  202. package/dist/core/enrichment/relationship-mapper.d.ts +106 -0
  203. package/dist/core/enrichment/relationship-mapper.d.ts.map +1 -0
  204. package/dist/core/enrichment/relationship-mapper.js +352 -0
  205. package/dist/core/enrichment/relationship-mapper.js.map +1 -0
  206. package/dist/core/enrichment/style-resolver.d.ts +85 -0
  207. package/dist/core/enrichment/style-resolver.d.ts.map +1 -0
  208. package/dist/core/enrichment/style-resolver.js +347 -0
  209. package/dist/core/enrichment/style-resolver.js.map +1 -0
  210. package/dist/core/figjam-tools.d.ts +8 -0
  211. package/dist/core/figjam-tools.d.ts.map +1 -0
  212. package/dist/core/figjam-tools.js +548 -0
  213. package/dist/core/figjam-tools.js.map +1 -0
  214. package/dist/core/figma-api.d.ts +254 -0
  215. package/dist/core/figma-api.d.ts.map +1 -0
  216. package/dist/core/figma-api.js +510 -0
  217. package/dist/core/figma-api.js.map +1 -0
  218. package/dist/core/figma-connector.d.ts +214 -0
  219. package/dist/core/figma-connector.d.ts.map +1 -0
  220. package/dist/core/figma-connector.js +8 -0
  221. package/dist/core/figma-connector.js.map +1 -0
  222. package/dist/core/figma-desktop-connector.d.ts +312 -0
  223. package/dist/core/figma-desktop-connector.d.ts.map +1 -0
  224. package/dist/core/figma-desktop-connector.js +1298 -0
  225. package/dist/core/figma-desktop-connector.js.map +1 -0
  226. package/dist/core/figma-reconstruction-spec.d.ts +166 -0
  227. package/dist/core/figma-reconstruction-spec.d.ts.map +1 -0
  228. package/dist/core/figma-reconstruction-spec.js +403 -0
  229. package/dist/core/figma-reconstruction-spec.js.map +1 -0
  230. package/dist/core/figma-style-extractor.d.ts +76 -0
  231. package/dist/core/figma-style-extractor.d.ts.map +1 -0
  232. package/dist/core/figma-style-extractor.js +312 -0
  233. package/dist/core/figma-style-extractor.js.map +1 -0
  234. package/dist/core/figma-tools.d.ts +22 -0
  235. package/dist/core/figma-tools.d.ts.map +1 -0
  236. package/dist/core/figma-tools.js +3303 -0
  237. package/dist/core/figma-tools.js.map +1 -0
  238. package/dist/core/identity.d.ts +41 -0
  239. package/dist/core/identity.d.ts.map +1 -0
  240. package/dist/core/identity.js +97 -0
  241. package/dist/core/identity.js.map +1 -0
  242. package/dist/core/library-tools.d.ts +17 -0
  243. package/dist/core/library-tools.d.ts.map +1 -0
  244. package/dist/core/library-tools.js +581 -0
  245. package/dist/core/library-tools.js.map +1 -0
  246. package/dist/core/logger.d.ts +22 -0
  247. package/dist/core/logger.d.ts.map +1 -0
  248. package/dist/core/logger.js +54 -0
  249. package/dist/core/logger.js.map +1 -0
  250. package/dist/core/port-discovery.d.ts +211 -0
  251. package/dist/core/port-discovery.d.ts.map +1 -0
  252. package/dist/core/port-discovery.js +936 -0
  253. package/dist/core/port-discovery.js.map +1 -0
  254. package/dist/core/resolve-package-root.d.ts +2 -0
  255. package/dist/core/resolve-package-root.d.ts.map +1 -0
  256. package/dist/core/resolve-package-root.js +12 -0
  257. package/dist/core/resolve-package-root.js.map +1 -0
  258. package/dist/core/slides-tools.d.ts +8 -0
  259. package/dist/core/slides-tools.d.ts.map +1 -0
  260. package/dist/core/slides-tools.js +715 -0
  261. package/dist/core/slides-tools.js.map +1 -0
  262. package/dist/core/slot-tools.d.ts +7 -0
  263. package/dist/core/slot-tools.d.ts.map +1 -0
  264. package/dist/core/slot-tools.js +335 -0
  265. package/dist/core/slot-tools.js.map +1 -0
  266. package/dist/core/snippet-injector.d.ts +24 -0
  267. package/dist/core/snippet-injector.d.ts.map +1 -0
  268. package/dist/core/snippet-injector.js +97 -0
  269. package/dist/core/snippet-injector.js.map +1 -0
  270. package/dist/core/tokens/alias-resolver.d.ts +97 -0
  271. package/dist/core/tokens/alias-resolver.d.ts.map +1 -0
  272. package/dist/core/tokens/alias-resolver.js +206 -0
  273. package/dist/core/tokens/alias-resolver.js.map +1 -0
  274. package/dist/core/tokens/config.d.ts +380 -0
  275. package/dist/core/tokens/config.d.ts.map +1 -0
  276. package/dist/core/tokens/config.js +295 -0
  277. package/dist/core/tokens/config.js.map +1 -0
  278. package/dist/core/tokens/dialect.d.ts +107 -0
  279. package/dist/core/tokens/dialect.d.ts.map +1 -0
  280. package/dist/core/tokens/dialect.js +233 -0
  281. package/dist/core/tokens/dialect.js.map +1 -0
  282. package/dist/core/tokens/figma-converter.d.ts +102 -0
  283. package/dist/core/tokens/figma-converter.d.ts.map +1 -0
  284. package/dist/core/tokens/figma-converter.js +324 -0
  285. package/dist/core/tokens/figma-converter.js.map +1 -0
  286. package/dist/core/tokens/formatters/css-vars.d.ts +24 -0
  287. package/dist/core/tokens/formatters/css-vars.d.ts.map +1 -0
  288. package/dist/core/tokens/formatters/css-vars.js +339 -0
  289. package/dist/core/tokens/formatters/css-vars.js.map +1 -0
  290. package/dist/core/tokens/formatters/dtcg.d.ts +28 -0
  291. package/dist/core/tokens/formatters/dtcg.d.ts.map +1 -0
  292. package/dist/core/tokens/formatters/dtcg.js +377 -0
  293. package/dist/core/tokens/formatters/dtcg.js.map +1 -0
  294. package/dist/core/tokens/formatters/index.d.ts +30 -0
  295. package/dist/core/tokens/formatters/index.d.ts.map +1 -0
  296. package/dist/core/tokens/formatters/index.js +46 -0
  297. package/dist/core/tokens/formatters/index.js.map +1 -0
  298. package/dist/core/tokens/formatters/json.d.ts +37 -0
  299. package/dist/core/tokens/formatters/json.d.ts.map +1 -0
  300. package/dist/core/tokens/formatters/json.js +206 -0
  301. package/dist/core/tokens/formatters/json.js.map +1 -0
  302. package/dist/core/tokens/formatters/less.d.ts +4 -0
  303. package/dist/core/tokens/formatters/less.d.ts.map +1 -0
  304. package/dist/core/tokens/formatters/less.js +5 -0
  305. package/dist/core/tokens/formatters/less.js.map +1 -0
  306. package/dist/core/tokens/formatters/scss.d.ts +26 -0
  307. package/dist/core/tokens/formatters/scss.d.ts.map +1 -0
  308. package/dist/core/tokens/formatters/scss.js +259 -0
  309. package/dist/core/tokens/formatters/scss.js.map +1 -0
  310. package/dist/core/tokens/formatters/stubs.d.ts +9 -0
  311. package/dist/core/tokens/formatters/stubs.d.ts.map +1 -0
  312. package/dist/core/tokens/formatters/stubs.js +14 -0
  313. package/dist/core/tokens/formatters/stubs.js.map +1 -0
  314. package/dist/core/tokens/formatters/style-dictionary-v3.d.ts +45 -0
  315. package/dist/core/tokens/formatters/style-dictionary-v3.d.ts.map +1 -0
  316. package/dist/core/tokens/formatters/style-dictionary-v3.js +214 -0
  317. package/dist/core/tokens/formatters/style-dictionary-v3.js.map +1 -0
  318. package/dist/core/tokens/formatters/tailwind-v3.d.ts +37 -0
  319. package/dist/core/tokens/formatters/tailwind-v3.d.ts.map +1 -0
  320. package/dist/core/tokens/formatters/tailwind-v3.js +238 -0
  321. package/dist/core/tokens/formatters/tailwind-v3.js.map +1 -0
  322. package/dist/core/tokens/formatters/tailwind-v4.d.ts +41 -0
  323. package/dist/core/tokens/formatters/tailwind-v4.d.ts.map +1 -0
  324. package/dist/core/tokens/formatters/tailwind-v4.js +336 -0
  325. package/dist/core/tokens/formatters/tailwind-v4.js.map +1 -0
  326. package/dist/core/tokens/formatters/tokens-studio.d.ts +44 -0
  327. package/dist/core/tokens/formatters/tokens-studio.d.ts.map +1 -0
  328. package/dist/core/tokens/formatters/tokens-studio.js +257 -0
  329. package/dist/core/tokens/formatters/tokens-studio.js.map +1 -0
  330. package/dist/core/tokens/formatters/ts-module.d.ts +35 -0
  331. package/dist/core/tokens/formatters/ts-module.d.ts.map +1 -0
  332. package/dist/core/tokens/formatters/ts-module.js +199 -0
  333. package/dist/core/tokens/formatters/ts-module.js.map +1 -0
  334. package/dist/core/tokens/index.d.ts +18 -0
  335. package/dist/core/tokens/index.d.ts.map +1 -0
  336. package/dist/core/tokens/index.js +17 -0
  337. package/dist/core/tokens/index.js.map +1 -0
  338. package/dist/core/tokens/parsers/css-vars.d.ts +3 -0
  339. package/dist/core/tokens/parsers/css-vars.d.ts.map +1 -0
  340. package/dist/core/tokens/parsers/css-vars.js +5 -0
  341. package/dist/core/tokens/parsers/css-vars.js.map +1 -0
  342. package/dist/core/tokens/parsers/dtcg.d.ts +21 -0
  343. package/dist/core/tokens/parsers/dtcg.d.ts.map +1 -0
  344. package/dist/core/tokens/parsers/dtcg.js +281 -0
  345. package/dist/core/tokens/parsers/dtcg.js.map +1 -0
  346. package/dist/core/tokens/parsers/index.d.ts +37 -0
  347. package/dist/core/tokens/parsers/index.d.ts.map +1 -0
  348. package/dist/core/tokens/parsers/index.js +139 -0
  349. package/dist/core/tokens/parsers/index.js.map +1 -0
  350. package/dist/core/tokens/parsers/json.d.ts +4 -0
  351. package/dist/core/tokens/parsers/json.d.ts.map +1 -0
  352. package/dist/core/tokens/parsers/json.js +8 -0
  353. package/dist/core/tokens/parsers/json.js.map +1 -0
  354. package/dist/core/tokens/parsers/scss.d.ts +3 -0
  355. package/dist/core/tokens/parsers/scss.d.ts.map +1 -0
  356. package/dist/core/tokens/parsers/scss.js +5 -0
  357. package/dist/core/tokens/parsers/scss.js.map +1 -0
  358. package/dist/core/tokens/parsers/stubs.d.ts +15 -0
  359. package/dist/core/tokens/parsers/stubs.d.ts.map +1 -0
  360. package/dist/core/tokens/parsers/stubs.js +21 -0
  361. package/dist/core/tokens/parsers/stubs.js.map +1 -0
  362. package/dist/core/tokens/parsers/style-dictionary-v3.d.ts +3 -0
  363. package/dist/core/tokens/parsers/style-dictionary-v3.d.ts.map +1 -0
  364. package/dist/core/tokens/parsers/style-dictionary-v3.js +5 -0
  365. package/dist/core/tokens/parsers/style-dictionary-v3.js.map +1 -0
  366. package/dist/core/tokens/parsers/tailwind-v3.d.ts +3 -0
  367. package/dist/core/tokens/parsers/tailwind-v3.d.ts.map +1 -0
  368. package/dist/core/tokens/parsers/tailwind-v3.js +5 -0
  369. package/dist/core/tokens/parsers/tailwind-v3.js.map +1 -0
  370. package/dist/core/tokens/parsers/tailwind-v4.d.ts +3 -0
  371. package/dist/core/tokens/parsers/tailwind-v4.d.ts.map +1 -0
  372. package/dist/core/tokens/parsers/tailwind-v4.js +5 -0
  373. package/dist/core/tokens/parsers/tailwind-v4.js.map +1 -0
  374. package/dist/core/tokens/parsers/tokens-studio.d.ts +3 -0
  375. package/dist/core/tokens/parsers/tokens-studio.d.ts.map +1 -0
  376. package/dist/core/tokens/parsers/tokens-studio.js +5 -0
  377. package/dist/core/tokens/parsers/tokens-studio.js.map +1 -0
  378. package/dist/core/tokens/schemas.d.ts +155 -0
  379. package/dist/core/tokens/schemas.d.ts.map +1 -0
  380. package/dist/core/tokens/schemas.js +153 -0
  381. package/dist/core/tokens/schemas.js.map +1 -0
  382. package/dist/core/tokens/transforms/color.d.ts +9 -0
  383. package/dist/core/tokens/transforms/color.d.ts.map +1 -0
  384. package/dist/core/tokens/transforms/color.js +13 -0
  385. package/dist/core/tokens/transforms/color.js.map +1 -0
  386. package/dist/core/tokens/transforms/index.d.ts +36 -0
  387. package/dist/core/tokens/transforms/index.d.ts.map +1 -0
  388. package/dist/core/tokens/transforms/index.js +30 -0
  389. package/dist/core/tokens/transforms/index.js.map +1 -0
  390. package/dist/core/tokens/transforms/size.d.ts +7 -0
  391. package/dist/core/tokens/transforms/size.d.ts.map +1 -0
  392. package/dist/core/tokens/transforms/size.js +8 -0
  393. package/dist/core/tokens/transforms/size.js.map +1 -0
  394. package/dist/core/tokens/types.d.ts +284 -0
  395. package/dist/core/tokens/types.d.ts.map +1 -0
  396. package/dist/core/tokens/types.js +19 -0
  397. package/dist/core/tokens/types.js.map +1 -0
  398. package/dist/core/tokens-tools.d.ts +285 -0
  399. package/dist/core/tokens-tools.d.ts.map +1 -0
  400. package/dist/core/tokens-tools.js +1789 -0
  401. package/dist/core/tokens-tools.js.map +1 -0
  402. package/dist/core/types/design-code.d.ts +271 -0
  403. package/dist/core/types/design-code.d.ts.map +1 -0
  404. package/dist/core/types/design-code.js +5 -0
  405. package/dist/core/types/design-code.js.map +1 -0
  406. package/dist/core/types/enriched.d.ts +213 -0
  407. package/dist/core/types/enriched.d.ts.map +1 -0
  408. package/dist/core/types/enriched.js +6 -0
  409. package/dist/core/types/enriched.js.map +1 -0
  410. package/dist/core/types/index.d.ts +104 -0
  411. package/dist/core/types/index.d.ts.map +1 -0
  412. package/dist/core/types/index.js +5 -0
  413. package/dist/core/types/index.js.map +1 -0
  414. package/dist/core/variable-resolver.d.ts +45 -0
  415. package/dist/core/variable-resolver.d.ts.map +1 -0
  416. package/dist/core/variable-resolver.js +86 -0
  417. package/dist/core/variable-resolver.js.map +1 -0
  418. package/dist/core/version-tools.d.ts +59 -0
  419. package/dist/core/version-tools.d.ts.map +1 -0
  420. package/dist/core/version-tools.js +1200 -0
  421. package/dist/core/version-tools.js.map +1 -0
  422. package/dist/core/websocket-connector.d.ts +247 -0
  423. package/dist/core/websocket-connector.d.ts.map +1 -0
  424. package/dist/core/websocket-connector.js +437 -0
  425. package/dist/core/websocket-connector.js.map +1 -0
  426. package/dist/core/websocket-server.d.ts +290 -0
  427. package/dist/core/websocket-server.d.ts.map +1 -0
  428. package/dist/core/websocket-server.js +957 -0
  429. package/dist/core/websocket-server.js.map +1 -0
  430. package/dist/core/write-tools.d.ts +7 -0
  431. package/dist/core/write-tools.d.ts.map +1 -0
  432. package/dist/core/write-tools.js +2507 -0
  433. package/dist/core/write-tools.js.map +1 -0
  434. package/dist/local.d.ts +105 -0
  435. package/dist/local.d.ts.map +1 -0
  436. package/dist/local.js +3314 -0
  437. package/dist/local.js.map +1 -0
  438. package/figma-desktop-bridge/README.md +365 -0
  439. package/figma-desktop-bridge/code.js +7359 -0
  440. package/figma-desktop-bridge/icon.png +0 -0
  441. package/figma-desktop-bridge/manifest.json +67 -0
  442. package/figma-desktop-bridge/ui.html +2783 -0
  443. package/package.json +103 -0
package/dist/local.js ADDED
@@ -0,0 +1,3314 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Figma Console MCP Server - Local Mode
4
+ *
5
+ * Entry point for local MCP server that connects to Figma Desktop
6
+ * via the WebSocket Desktop Bridge plugin.
7
+ *
8
+ * This implementation uses stdio transport for MCP communication,
9
+ * suitable for local IDE integrations and development workflows.
10
+ *
11
+ * Requirements:
12
+ * - Desktop Bridge plugin open in Figma (Plugins → Development → Figma Desktop Bridge)
13
+ * - FIGMA_ACCESS_TOKEN environment variable for API access
14
+ */
15
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
16
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
17
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
18
+ import { createServer } from "node:http";
19
+ import { z } from "zod";
20
+ import { fileURLToPath } from "url";
21
+ import { dirname, resolve, join } from "path";
22
+ import { realpathSync, existsSync, readFileSync, mkdirSync, copyFileSync, writeFileSync } from "fs";
23
+ import { homedir } from "os";
24
+ import { getConfig } from "./core/config.js";
25
+ import { createChildLogger } from "./core/logger.js";
26
+ import { FigmaAPI, extractFileKey, extractFigmaUrlInfo, formatVariables, } from "./core/figma-api.js";
27
+ import { registerFigmaAPITools } from "./core/figma-tools.js";
28
+ import { registerDesignCodeTools } from "./core/design-code-tools.js";
29
+ import { registerCommentTools } from "./core/comment-tools.js";
30
+ import { registerVersionTools } from "./core/version-tools.js";
31
+ import { registerAnnotationTools } from "./core/annotation-tools.js";
32
+ import { registerDeepComponentTools } from "./core/deep-component-tools.js";
33
+ import { registerDesignSystemTools } from "./core/design-system-tools.js";
34
+ import { registerLibraryTools, registerLibraryVariableTools } from "./core/library-tools.js";
35
+ import { registerAccessibilityTools } from "./core/accessibility-tools.js";
36
+ import { registerDiagnoseTool } from "./core/diagnose-tool.js";
37
+ import { registerWriteTools } from "./core/write-tools.js";
38
+ import { registerTokensTools } from "./core/tokens-tools.js";
39
+ import { wrapServerForIdentity } from "./core/identity.js";
40
+ import { PACKAGE_ROOT } from "./core/resolve-package-root.js";
41
+ import { FigmaWebSocketServer, getBundledPluginVersion } from "./core/websocket-server.js";
42
+ import { WebSocketConnector } from "./core/websocket-connector.js";
43
+ import { DEFAULT_WS_PORT, getPortRange, getPortFilePath, advertisePort, unadvertisePort, registerPortCleanup, startPeriodicReaper, discoverActiveInstances, cleanupStalePortFiles, cleanupOrphanedProcesses, evictOldestInstance, refreshPortAdvertisement, HEARTBEAT_INTERVAL_MS, } from "./core/port-discovery.js";
44
+ import { registerTokenBrowserApp } from "./apps/token-browser/server.js";
45
+ import { registerDesignSystemDashboardApp } from "./apps/design-system-dashboard/server.js";
46
+ import { registerFigJamTools } from "./core/figjam-tools.js";
47
+ import { registerSlidesTools } from "./core/slides-tools.js";
48
+ import { registerSlotTools } from "./core/slot-tools.js";
49
+ const logger = createChildLogger({ component: "local-server" });
50
+ /**
51
+ * Copy plugin files to a stable directory (~/.figma-console-mcp/plugin/).
52
+ * This gives users a permanent, predictable path to import from instead of
53
+ * the volatile npx cache path that changes between updates.
54
+ *
55
+ * Returns the stable manifest path, or null if copy failed.
56
+ */
57
+ function setupStablePluginDir(sourcePluginDir) {
58
+ try {
59
+ const stableDir = join(homedir(), ".figma-console-mcp", "plugin");
60
+ mkdirSync(stableDir, { recursive: true });
61
+ const filesToCopy = ["manifest.json", "code.js", "ui.html"];
62
+ for (const file of filesToCopy) {
63
+ const src = join(sourcePluginDir, file);
64
+ const dest = join(stableDir, file);
65
+ if (existsSync(src)) {
66
+ copyFileSync(src, dest);
67
+ }
68
+ }
69
+ // Write a version marker so we can detect stale copies
70
+ try {
71
+ const pkg = JSON.parse(readFileSync(join(sourcePluginDir, "..", "package.json"), "utf-8"));
72
+ writeFileSync(join(stableDir, ".version"), pkg.version, "utf-8");
73
+ }
74
+ catch {
75
+ // Non-critical — version marker is for diagnostics only
76
+ }
77
+ logger.info({ stableDir }, "Plugin files copied to stable directory");
78
+ return join(stableDir, "manifest.json");
79
+ }
80
+ catch (error) {
81
+ logger.warn({ error }, "Could not set up stable plugin directory (non-critical)");
82
+ return null;
83
+ }
84
+ }
85
+ /** Instructions shared by both the stdio and HTTP MCP server instances. */
86
+ const MCP_SERVER_INSTRUCTIONS = `## Figma Console MCP - Visual Design Workflow
87
+
88
+ This MCP server enables AI-assisted design creation in Figma. Follow these mandatory workflows:
89
+
90
+ ### VISUAL VALIDATION WORKFLOW (Required)
91
+ After creating or modifying ANY visual design elements:
92
+ 1. **CREATE**: Execute design code via figma_execute
93
+ 2. **SCREENSHOT**: Capture result with figma_take_screenshot
94
+ 3. **ANALYZE**: Check alignment, spacing, proportions, visual balance
95
+ 4. **ITERATE**: Fix issues and repeat (max 3 iterations)
96
+ 5. **VERIFY**: Final screenshot to confirm
97
+
98
+ ### COMPONENT INSTANTIATION
99
+ - ALWAYS call figma_search_components at the start of each session
100
+ - NodeIds are session-specific and become stale across conversations
101
+ - Never reuse nodeIds from previous sessions without re-searching
102
+
103
+ ### PAGE CREATION
104
+ - Before creating a page, check if it already exists to avoid duplicates
105
+ - Use: await figma.loadAllPagesAsync(); const existing = figma.root.children.find(p => p.name === 'PageName');
106
+
107
+ ### COMMON DESIGN ISSUES TO CHECK
108
+ - Elements using "hug contents" instead of "fill container" (causes lopsided layouts)
109
+ - Inconsistent padding (elements not visually balanced)
110
+ - Text/inputs not filling available width
111
+ - Items not centered properly in their containers
112
+ - Components floating on blank canvas - always place within a Section or Frame
113
+
114
+ ### COMPONENT PLACEMENT (REQUIRED)
115
+ Before creating ANY component, check for or create a proper parent container:
116
+ 1. First, check if a Section or Frame already exists on the current page
117
+ 2. If no container exists, create a Section first (e.g., "Design Components")
118
+ 3. Place all new components INSIDE the Section/Frame, not on blank canvas
119
+ 4. This keeps designs organized and prevents "floating" components
120
+
121
+ Example pattern:
122
+ \`\`\`javascript
123
+ // Find or create a Section for components
124
+ let section = figma.currentPage.findOne(n => n.type === 'SECTION' && n.name === 'Components');
125
+ if (!section) {
126
+ section = figma.createSection();
127
+ section.name = 'Components';
128
+ section.x = 0;
129
+ section.y = 0;
130
+ }
131
+ // Now create your component INSIDE the section
132
+ const frame = figma.createFrame();
133
+ section.appendChild(frame);
134
+ \`\`\`
135
+
136
+ ### BATCH OPERATIONS (Performance Critical)
137
+ When creating or updating **multiple variables**, ALWAYS prefer batch tools over repeated individual calls:
138
+ - **figma_batch_create_variables**: Create up to 100 variables in one call (vs. N calls to figma_create_variable)
139
+ - **figma_batch_update_variables**: Update up to 100 variable values in one call (vs. N calls to figma_update_variable)
140
+ - **figma_setup_design_tokens**: Create a complete token system (collection + modes + variables) atomically in one call
141
+
142
+ Batch tools are 10-50x faster because they execute in a single roundtrip. Use individual tools only for one-off operations.
143
+
144
+ ### DESIGN BEST PRACTICES
145
+ For component-specific design guidance (sizing, proportions, accessibility, etc.), query the Design Systems Assistant MCP which provides up-to-date best practices for any component type.
146
+
147
+ If Design Systems Assistant MCP is not available, install it from: https://github.com/southleft/design-systems-mcp`;
148
+ /**
149
+ * Local MCP Server
150
+ * Connects to Figma Desktop and provides identical tools to Cloudflare mode
151
+ */
152
+ class LocalFigmaConsoleMCP {
153
+ /**
154
+ * Invalidate the variables cache after a write operation.
155
+ * Called after any successful variable create/update/delete/batch operation
156
+ * to ensure the next figma_get_variables call returns fresh data.
157
+ */
158
+ invalidateVariablesCache() {
159
+ if (this.variablesCache.size > 0) {
160
+ this.variablesCache.clear();
161
+ logger.info('Variables cache invalidated after write operation');
162
+ }
163
+ }
164
+ constructor() {
165
+ this.figmaAPI = null;
166
+ this.wsServer = null;
167
+ this.wsStartupError = null;
168
+ /** The port the WebSocket server actually bound to (may differ from preferred if fallback occurred) */
169
+ this.wsActualPort = null;
170
+ /** The preferred port requested (from env var or default) */
171
+ this.wsPreferredPort = DEFAULT_WS_PORT;
172
+ /** Stops the periodic background reaper (set once the WS port is bound) */
173
+ this.wsReaperStop = null;
174
+ /** Heartbeat timer that refreshes port file to prove this server is active */
175
+ this.wsHeartbeatTimer = null;
176
+ /** HTTP server for Streamable HTTP MCP transport (opt-in via MCP_HTTP_PORT) */
177
+ this.httpServer = null;
178
+ /** The port the HTTP MCP server is listening on, if enabled */
179
+ this.mcpHttpPort = null;
180
+ this.config = getConfig();
181
+ // In-memory cache for variables data to avoid MCP token limits
182
+ // Maps fileKey -> {data, timestamp}
183
+ this.variablesCache = new Map();
184
+ /** Stable plugin directory path (set during startup) */
185
+ this.stablePluginPath = null;
186
+ this.server = new McpServer({
187
+ name: "Figma Console MCP (Local)",
188
+ version: "0.1.0",
189
+ }, {
190
+ instructions: MCP_SERVER_INSTRUCTIONS,
191
+ });
192
+ // Stamp every tool response (and every thrown error) with our MCP identity
193
+ // so LLMs can attribute output unambiguously when multiple Figma-related
194
+ // MCPs are connected. Idempotent for already-tagged responses.
195
+ wrapServerForIdentity(this.server);
196
+ }
197
+ /**
198
+ * Get or create Figma API client
199
+ */
200
+ async getFigmaAPI() {
201
+ if (!this.figmaAPI) {
202
+ const accessToken = process.env.FIGMA_ACCESS_TOKEN;
203
+ if (!accessToken) {
204
+ throw new Error("FIGMA_ACCESS_TOKEN not configured. " +
205
+ "Set it as an environment variable. " +
206
+ "Get your token at: https://www.figma.com/developers/api#access-tokens");
207
+ }
208
+ logger.debug({ authMethod: accessToken.startsWith('figu_') ? 'OAuth' : 'PAT' }, 'Initializing Figma API');
209
+ this.figmaAPI = new FigmaAPI({ accessToken });
210
+ }
211
+ return this.figmaAPI;
212
+ }
213
+ /**
214
+ * Get or create Desktop Connector for write operations.
215
+ * Returns the active WebSocket Desktop Bridge connector.
216
+ */
217
+ async getDesktopConnector() {
218
+ if (this.wsServer?.isClientConnected()) {
219
+ try {
220
+ const wsConnector = new WebSocketConnector(this.wsServer);
221
+ await wsConnector.initialize();
222
+ logger.debug("Desktop connector initialized via WebSocket bridge");
223
+ return wsConnector;
224
+ }
225
+ catch (wsError) {
226
+ const errorMsg = wsError instanceof Error ? wsError.message : String(wsError);
227
+ logger.debug({ error: errorMsg }, "WebSocket connector init failed");
228
+ }
229
+ }
230
+ const wsPort = this.wsActualPort || this.wsPreferredPort || DEFAULT_WS_PORT;
231
+ const err = new Error("Cannot connect to Figma Desktop.\n\n" +
232
+ "Open the Desktop Bridge plugin in Figma (Plugins → Development → Figma Desktop Bridge).\n" +
233
+ `The plugin will connect automatically to ws://localhost:${wsPort}.\n` +
234
+ "No special launch flags needed.");
235
+ // Attach structured connection error for programmatic agent recovery
236
+ err.connectionError = this.buildConnectionError(err);
237
+ throw err;
238
+ }
239
+ /**
240
+ * Build a bridge tool error response with structured connectionError.
241
+ * Extracts connectionError from enhanced Error objects thrown by getDesktopConnector(),
242
+ * or computes it on-demand for other errors. Backward compatible — adds connectionError
243
+ * alongside existing error/message/hint fields.
244
+ */
245
+ bridgeToolError(error, message, hint) {
246
+ const errorMsg = error instanceof Error ? error.message : String(error);
247
+ const connectionError = error?.connectionError || this.buildConnectionError(error);
248
+ return {
249
+ content: [
250
+ {
251
+ type: "text",
252
+ text: JSON.stringify({
253
+ error: errorMsg,
254
+ message,
255
+ hint,
256
+ connectionError,
257
+ }),
258
+ },
259
+ ],
260
+ isError: true,
261
+ };
262
+ }
263
+ /**
264
+ * Build a structured connectionError object for bridge-dependent tool failures.
265
+ * Added alongside existing error/message/hint fields for backward compatibility.
266
+ * Agents can key on this field for programmatic recovery instead of parsing hint strings.
267
+ */
268
+ buildConnectionError(error) {
269
+ const errorMsg = error instanceof Error ? error.message : String(error);
270
+ const wsServerRunning = this.wsServer?.isStarted() ?? false;
271
+ const isTimeout = errorMsg.includes('timed out');
272
+ const isNoClient = errorMsg.includes('No active file') || errorMsg.includes('No WebSocket client');
273
+ if (!wsServerRunning) {
274
+ return {
275
+ layer: 1,
276
+ type: 'MCP_SERVER_UNAVAILABLE',
277
+ canRetry: true,
278
+ recoverySteps: [
279
+ "Ensure your AI client is running with figma-console-mcp configured",
280
+ "Check for port conflicts: lsof -i :9223-9232 | grep LISTEN",
281
+ "Restart your AI client — the MCP server starts automatically",
282
+ ],
283
+ };
284
+ }
285
+ if (isTimeout) {
286
+ return {
287
+ layer: 2,
288
+ type: 'BRIDGE_COMMAND_TIMEOUT',
289
+ canRetry: true,
290
+ recoverySteps: [
291
+ "The plugin may be unresponsive — close and reopen the Desktop Bridge plugin in Figma",
292
+ "If the issue persists, restart Figma Desktop",
293
+ "Call figma_get_status with probe:true to verify the connection",
294
+ ],
295
+ };
296
+ }
297
+ return {
298
+ layer: isNoClient ? 2 : 2,
299
+ type: isNoClient ? 'BRIDGE_NOT_CONNECTED' : 'BRIDGE_ERROR',
300
+ canRetry: !isNoClient,
301
+ recoverySteps: [
302
+ "Open Figma Desktop with your target file",
303
+ "Go to Plugins → Development → Figma Desktop Bridge",
304
+ "Click 'Run' to open the plugin",
305
+ "Wait 3 seconds, then call figma_get_status with probe:true to verify",
306
+ ],
307
+ };
308
+ }
309
+ /**
310
+ * Get the current Figma file URL from the best available source.
311
+ * Priority: Browser URL (full URL with branch/node info) → WebSocket file identity (synthesized URL).
312
+ * The synthesized URL is compatible with extractFileKey() and extractFigmaUrlInfo().
313
+ */
314
+ getCurrentFileUrl() {
315
+ // Synthesize the URL from the WebSocket plugin's reported file identity.
316
+ // (Pre-Phase-3 this also tried a live Puppeteer browser URL; that path is
317
+ // gone now along with the LocalBrowserManager.)
318
+ const wsFileInfo = this.wsServer?.getConnectedFileInfo() ?? null;
319
+ if (wsFileInfo?.fileKey) {
320
+ const pageIdParam = wsFileInfo.currentPageId
321
+ ? `?node-id=${wsFileInfo.currentPageId.replace(/:/g, '-')}`
322
+ : '';
323
+ return `https://www.figma.com/design/${wsFileInfo.fileKey}/${encodeURIComponent(wsFileInfo.fileName || 'Untitled')}${pageIdParam}`;
324
+ }
325
+ return null;
326
+ }
327
+ /**
328
+ * Check if Figma Desktop is accessible via WebSocket
329
+ */
330
+ async checkFigmaDesktop() {
331
+ // Check WebSocket availability
332
+ const wsAvailable = this.wsServer?.isClientConnected() ?? false;
333
+ if (wsAvailable) {
334
+ logger.info("Transport: WebSocket bridge connected");
335
+ }
336
+ else {
337
+ // Not available yet — log guidance but don't throw
338
+ // The user may open the plugin later
339
+ logger.warn(`WebSocket transport not available yet.\n\n` +
340
+ `Open the Desktop Bridge plugin in Figma (Plugins → Development → Figma Desktop Bridge).\n` +
341
+ `No special launch flags needed — the plugin connects automatically.`);
342
+ }
343
+ }
344
+ /**
345
+ * Resolve the path to the Desktop Bridge plugin manifest.
346
+ * Prefers the stable directory (~/.figma-console-mcp/plugin/) over the npx cache path.
347
+ */
348
+ getPluginPath() {
349
+ // Prefer stable path — consistent across npx updates
350
+ if (this.stablePluginPath && existsSync(this.stablePluginPath)) {
351
+ return this.stablePluginPath;
352
+ }
353
+ try {
354
+ const thisFile = fileURLToPath(import.meta.url);
355
+ // From dist/local.js → go up to package root, then into figma-desktop-bridge
356
+ const packageRoot = dirname(dirname(thisFile));
357
+ const manifestPath = resolve(packageRoot, "figma-desktop-bridge", "manifest.json");
358
+ return existsSync(manifestPath) ? manifestPath : null;
359
+ }
360
+ catch {
361
+ return null;
362
+ }
363
+ }
364
+ /**
365
+ * Register all MCP tools
366
+ */
367
+ registerTools(target) {
368
+ const server = target ?? this.server;
369
+ // Tool 1: Get Console Logs
370
+ server.tool("figma_get_console_logs", "Retrieve console logs from Figma Desktop. FOR PLUGIN DEVELOPERS: This works immediately - no navigation needed! Just check logs, run your plugin in Figma Desktop, check logs again. All plugin logs ([Main], [Swapper], etc.) appear instantly.", {
371
+ count: z
372
+ .number()
373
+ .optional()
374
+ .default(100)
375
+ .describe("Number of recent logs to retrieve"),
376
+ level: z
377
+ .enum(["log", "info", "warn", "error", "debug", "all"])
378
+ .optional()
379
+ .default("all")
380
+ .describe("Filter by log level"),
381
+ since: z
382
+ .number()
383
+ .optional()
384
+ .describe("Only logs after this timestamp (Unix ms)"),
385
+ }, async ({ count, level, since }) => {
386
+ try {
387
+ // Try console monitor first, fall back to WebSocket console buffer
388
+ let logs;
389
+ let status;
390
+ let source = "websocket";
391
+ if (this.wsServer?.isClientConnected()) {
392
+ // Plugin-captured console logs delivered via WebSocket bridge
393
+ logs = this.wsServer.getConsoleLogs({ count, level, since });
394
+ status = this.wsServer.getConsoleStatus();
395
+ source = "websocket";
396
+ }
397
+ else {
398
+ throw new Error("No console monitoring available. Open the Desktop Bridge plugin in Figma for console capture.");
399
+ }
400
+ const responseData = {
401
+ logs,
402
+ totalCount: logs.length,
403
+ oldestTimestamp: logs[0]?.timestamp,
404
+ newestTimestamp: logs[logs.length - 1]?.timestamp,
405
+ status,
406
+ transport: source,
407
+ };
408
+ if (source === "websocket") {
409
+ responseData.ai_instruction =
410
+ "Console logs captured via WebSocket Bridge (plugin sandbox only). These logs include output from the Desktop Bridge plugin's code.js context.";
411
+ }
412
+ if (logs.length === 0) {
413
+ if (source === "websocket") {
414
+ responseData.ai_instruction =
415
+ "No console logs captured yet via WebSocket. The Desktop Bridge plugin is connected and monitoring. Plugin console output (console.log/warn/error from code.js) will appear here automatically. Try running a design operation that triggers plugin logging.";
416
+ }
417
+ else {
418
+ const isMonitoring = status.isMonitoring;
419
+ if (!isMonitoring) {
420
+ responseData.ai_instruction =
421
+ "Console monitoring is not active (likely lost connection after computer sleep). TAKE THESE STEPS: 1) Call figma_get_status to check connection, 2) Call figma_navigate with the Figma file URL to reconnect and restart monitoring, 3) Retry this tool.";
422
+ }
423
+ else {
424
+ responseData.ai_instruction =
425
+ "No console logs found. This usually means the Figma plugin hasn't run since monitoring started. Try running your Figma plugin, then check logs again.";
426
+ }
427
+ }
428
+ }
429
+ return {
430
+ content: [
431
+ {
432
+ type: "text",
433
+ text: JSON.stringify(responseData),
434
+ },
435
+ ],
436
+ };
437
+ }
438
+ catch (error) {
439
+ logger.error({ error }, "Failed to get console logs");
440
+ const errorMessage = error instanceof Error ? error.message : String(error);
441
+ return {
442
+ content: [
443
+ {
444
+ type: "text",
445
+ text: JSON.stringify({
446
+ error: errorMessage,
447
+ message: "Failed to retrieve console logs.",
448
+ troubleshooting: [
449
+ "Open the Desktop Bridge plugin in Figma for WebSocket-based console capture",
450
+ "Ensure the Desktop Bridge plugin is open and connected in Figma",
451
+ ],
452
+ }),
453
+ },
454
+ ],
455
+ isError: true,
456
+ };
457
+ }
458
+ });
459
+ // Tool 2: Take Screenshot (Desktop Bridge first, REST API fallback)
460
+ // Bridge-first: the plugin's exportAsync works on any Figma plan with no REST
461
+ // token, and reflects the current runtime state (no cloud-sync lag).
462
+ // Note: For screenshots of specific components, use figma_get_component_image instead
463
+ server.tool("figma_take_screenshot", `Export an image of the current Figma page or specific node. Uses the Desktop Bridge plugin (exportAsync) when connected — works on any plan, no REST token needed, reflects current runtime state. Falls back to the Figma REST API when the bridge is unavailable or for PDF format. Use for visual validation after design changes — check alignment, spacing, proportions. Pass nodeId to target specific elements. For components, prefer figma_get_component_image.`, {
464
+ nodeId: z
465
+ .string()
466
+ .optional()
467
+ .describe("Optional node ID to screenshot (e.g., '123:456'). If omitted, uses the node-id from the Desktop Bridge plugin's reported file URL when present. To screenshot what the user is currently looking at on the canvas, prefer figma_capture_screenshot (uses the plugin's exportAsync and reflects the current state)."),
468
+ scale: z
469
+ .number()
470
+ .min(0.01)
471
+ .max(4)
472
+ .optional()
473
+ .default(2)
474
+ .describe("Image scale factor (0.01-4, default: 2 for high quality)"),
475
+ format: z
476
+ .enum(["png", "jpg", "svg", "pdf"])
477
+ .optional()
478
+ .default("png")
479
+ .describe("Image format (default: png)"),
480
+ }, async ({ nodeId, scale, format }) => {
481
+ // Callers routinely paste URL-format ids (123-456); both the Plugin
482
+ // API and REST response maps use colon format (123:456).
483
+ nodeId = nodeId?.replace(/-/g, ":");
484
+ // Bridge-first: exportAsync via the Desktop Bridge works on any plan
485
+ // (no REST token) and reflects the current runtime state. PDF is the
486
+ // only format exportAsync can't produce, so it goes straight to REST.
487
+ if (format !== "pdf" && this.wsServer?.isClientConnected()) {
488
+ try {
489
+ const connector = await this.getDesktopConnector();
490
+ // Resolve the target node the same way the REST path does;
491
+ // empty string means the plugin captures the current page.
492
+ let bridgeNodeId = nodeId || "";
493
+ if (!bridgeNodeId) {
494
+ const currentUrl = this.getCurrentFileUrl();
495
+ const nodeIdParam = currentUrl
496
+ ? new URL(currentUrl).searchParams.get("node-id")
497
+ : null;
498
+ if (nodeIdParam) {
499
+ bridgeNodeId = nodeIdParam.replace(/-/g, ":");
500
+ }
501
+ }
502
+ const bridgeFormat = format === "jpg" ? "JPG" : format === "svg" ? "SVG" : "PNG";
503
+ // exportAsync scale floor is 0.5 (REST allows 0.01)
504
+ const bridgeScale = Math.min(Math.max(scale, 0.5), 4);
505
+ logger.info({ nodeId: bridgeNodeId, format: bridgeFormat, scale: bridgeScale }, "Capturing screenshot via Desktop Bridge (bridge-first)");
506
+ let result = await connector.captureScreenshot(bridgeNodeId, {
507
+ format: bridgeFormat,
508
+ scale: bridgeScale,
509
+ });
510
+ if (result &&
511
+ typeof result.success === "undefined" &&
512
+ result.image) {
513
+ result = { success: true, image: result };
514
+ }
515
+ if (!result?.success || !result.image?.base64) {
516
+ throw new Error(result?.error || "Bridge screenshot returned no image");
517
+ }
518
+ const bridgeMimeType = bridgeFormat === "JPG"
519
+ ? "image/jpeg"
520
+ : bridgeFormat === "SVG"
521
+ ? "image/svg+xml"
522
+ : "image/png";
523
+ return {
524
+ content: [
525
+ {
526
+ type: "text",
527
+ text: JSON.stringify({
528
+ nodeId: result.image.node?.id || bridgeNodeId || null,
529
+ scale: result.image.scale,
530
+ format,
531
+ byteLength: result.image.byteLength,
532
+ source: "desktop_bridge",
533
+ note: "Screenshot captured via the Desktop Bridge plugin (current runtime state, no REST token required). The image is included below for visual analysis.",
534
+ }),
535
+ },
536
+ {
537
+ type: "image",
538
+ data: result.image.base64,
539
+ mimeType: bridgeMimeType,
540
+ },
541
+ ],
542
+ };
543
+ }
544
+ catch (bridgeError) {
545
+ logger.warn({ error: bridgeError }, "Desktop Bridge screenshot failed, falling back to REST API");
546
+ }
547
+ }
548
+ try {
549
+ const api = await this.getFigmaAPI();
550
+ // Get current URL to extract file key and node ID if not provided
551
+ const currentUrl = this.getCurrentFileUrl();
552
+ if (!currentUrl) {
553
+ throw new Error("No Figma file open. Either provide a nodeId parameter, call figma_navigate, or ensure the Desktop Bridge plugin is connected.");
554
+ }
555
+ const fileKey = extractFileKey(currentUrl);
556
+ if (!fileKey) {
557
+ throw new Error(`Invalid Figma URL: ${currentUrl}`);
558
+ }
559
+ // Extract node ID from URL if not provided
560
+ let targetNodeId = nodeId;
561
+ if (!targetNodeId) {
562
+ const urlObj = new URL(currentUrl);
563
+ const nodeIdParam = urlObj.searchParams.get("node-id");
564
+ if (nodeIdParam) {
565
+ // Convert 123-456 to 123:456
566
+ targetNodeId = nodeIdParam.replace(/-/g, ":");
567
+ }
568
+ else {
569
+ throw new Error("No node ID found. Either provide nodeId parameter or ensure the Figma URL contains a node-id parameter (e.g., ?node-id=123-456)");
570
+ }
571
+ }
572
+ logger.info({ fileKey, nodeId: targetNodeId, scale, format }, "Rendering image via Figma API");
573
+ // Use Figma REST API to get image
574
+ const result = await api.getImages(fileKey, targetNodeId, {
575
+ scale,
576
+ format: format === "jpg" ? "jpg" : format, // normalize jpeg -> jpg
577
+ contents_only: true,
578
+ });
579
+ const imageUrl = result.images[targetNodeId];
580
+ if (!imageUrl) {
581
+ throw new Error(`Failed to render image for node ${targetNodeId}. The node may not exist or may not be renderable.`);
582
+ }
583
+ // Fetch the image and convert to base64 so Claude can actually see it
584
+ logger.info({ imageUrl }, "Fetching image from Figma S3 URL");
585
+ const imageResponse = await fetch(imageUrl);
586
+ if (!imageResponse.ok) {
587
+ throw new Error(`Failed to fetch image: ${imageResponse.status} ${imageResponse.statusText}`);
588
+ }
589
+ const imageBuffer = await imageResponse.arrayBuffer();
590
+ const base64Data = Buffer.from(imageBuffer).toString("base64");
591
+ const mimeType = format === "jpg"
592
+ ? "image/jpeg"
593
+ : format === "svg"
594
+ ? "image/svg+xml"
595
+ : format === "pdf"
596
+ ? "application/pdf"
597
+ : "image/png";
598
+ logger.info({ byteLength: imageBuffer.byteLength, mimeType }, "Image fetched and converted to base64");
599
+ // Return as MCP image content type so Claude can actually see the image
600
+ return {
601
+ content: [
602
+ {
603
+ type: "text",
604
+ text: JSON.stringify({
605
+ fileKey,
606
+ nodeId: targetNodeId,
607
+ scale,
608
+ format,
609
+ byteLength: imageBuffer.byteLength,
610
+ note: "Screenshot captured successfully. The image is included below for visual analysis.",
611
+ }),
612
+ },
613
+ {
614
+ type: "image",
615
+ data: base64Data,
616
+ mimeType: mimeType,
617
+ },
618
+ ],
619
+ };
620
+ }
621
+ catch (error) {
622
+ logger.error({ error }, "Failed to capture screenshot");
623
+ const errorMessage = error instanceof Error ? error.message : String(error);
624
+ // FigmaAPI.request() tags real token failures with isAuthError —
625
+ // a bare "403" substring also matches node IDs like "403:12"
626
+ // and permission-denied responses, which are not token problems.
627
+ const isAuthError = error?.isAuthError === true ||
628
+ errorMessage.toLowerCase().includes("token expired") ||
629
+ errorMessage.toLowerCase().includes("invalid token");
630
+ return {
631
+ content: [
632
+ {
633
+ type: "text",
634
+ text: JSON.stringify({
635
+ error: errorMessage,
636
+ message: "Failed to capture screenshot via Figma API",
637
+ hint: isAuthError
638
+ ? "Your FIGMA_ACCESS_TOKEN is expired or invalid. Generate a new personal access token at figma.com → Settings → Security → Personal access tokens, then update FIGMA_ACCESS_TOKEN in your MCP config. Alternatively, open the Desktop Bridge plugin in Figma Desktop — screenshots work through the bridge without any REST token."
639
+ : "Make sure you've called figma_navigate to open a file, or provide a valid nodeId parameter. Tip: with the Desktop Bridge plugin open, screenshots don't need a REST token at all.",
640
+ }),
641
+ },
642
+ ],
643
+ isError: true,
644
+ };
645
+ }
646
+ });
647
+ // Tool 3: Watch Console (Real-time streaming)
648
+ server.tool("figma_watch_console", "Stream console logs in real-time for a specified duration (max 5 minutes). Use for monitoring plugin execution while user tests manually. Returns all logs captured during watch period with summary statistics. NOT for retrieving past logs (use figma_get_console_logs). Best for: watching plugin output during manual testing, debugging race conditions, monitoring async operations.", {
649
+ duration: z
650
+ .number()
651
+ .optional()
652
+ .default(30)
653
+ .describe("How long to watch in seconds"),
654
+ level: z
655
+ .enum(["log", "info", "warn", "error", "debug", "all"])
656
+ .optional()
657
+ .default("all")
658
+ .describe("Filter by log level"),
659
+ }, async ({ duration, level }) => {
660
+ if (!this.wsServer?.isClientConnected()) {
661
+ throw new Error("No console monitoring available. Open the Desktop Bridge plugin in Figma for console capture.");
662
+ }
663
+ const startTime = Date.now();
664
+ const startLogCount = this.wsServer.getConsoleStatus().logCount;
665
+ // Wait for the specified duration while collecting logs
666
+ await new Promise((resolve) => setTimeout(resolve, duration * 1000));
667
+ const watchedLogs = this.wsServer.getConsoleLogs({
668
+ level: level === "all" ? undefined : level,
669
+ since: startTime,
670
+ });
671
+ const endLogCount = this.wsServer.getConsoleStatus().logCount;
672
+ const newLogsCount = endLogCount - startLogCount;
673
+ const responseData = {
674
+ status: "completed",
675
+ duration: `${duration} seconds`,
676
+ startTime: new Date(startTime).toISOString(),
677
+ endTime: new Date(Date.now()).toISOString(),
678
+ filter: level,
679
+ transport: "websocket",
680
+ statistics: {
681
+ totalLogsInBuffer: endLogCount,
682
+ logsAddedDuringWatch: newLogsCount,
683
+ logsMatchingFilter: watchedLogs.length,
684
+ },
685
+ logs: watchedLogs,
686
+ ai_instruction: "Console logs captured via WebSocket Bridge (plugin sandbox only).",
687
+ };
688
+ return {
689
+ content: [
690
+ {
691
+ type: "text",
692
+ text: JSON.stringify(responseData),
693
+ },
694
+ ],
695
+ };
696
+ });
697
+ // Tool 4: Reload Plugin
698
+ server.tool("figma_reload_plugin", "Reload the current Figma page/plugin to test code changes. Optionally clears console logs before reload. Use when user says: 'reload plugin', 'refresh page', 'restart plugin', 'test my changes'. Returns reload confirmation and current URL. Best for rapid iteration during plugin development.", {
699
+ clearConsole: z
700
+ .boolean()
701
+ .optional()
702
+ .default(true)
703
+ .describe("Clear console logs before reload"),
704
+ }, async ({ clearConsole: clearConsoleBefore }) => {
705
+ try {
706
+ let transport = "websocket";
707
+ let clearedCount = 0;
708
+ let currentUrl = null;
709
+ // Reload the plugin UI iframe through the WebSocket bridge.
710
+ if (this.wsServer?.isClientConnected()) {
711
+ transport = "websocket";
712
+ if (clearConsoleBefore) {
713
+ clearedCount = this.wsServer.clearConsoleLogs();
714
+ }
715
+ await this.wsServer.sendCommand("RELOAD_UI", {}, 10000);
716
+ // Wait for the UI to reload and WebSocket to reconnect
717
+ await new Promise((resolve) => setTimeout(resolve, 3000));
718
+ }
719
+ else {
720
+ throw new Error("No connection available. Open the Desktop Bridge plugin in Figma.");
721
+ }
722
+ const responseData = {
723
+ status: "reloaded",
724
+ timestamp: Date.now(),
725
+ transport,
726
+ consoleCleared: clearConsoleBefore,
727
+ clearedCount: clearConsoleBefore ? clearedCount : 0,
728
+ };
729
+ if (currentUrl) {
730
+ responseData.url = currentUrl;
731
+ }
732
+ if (transport === "websocket") {
733
+ responseData.ai_instruction =
734
+ "Plugin UI reloaded via WebSocket. The plugin's code.js continues running; only the UI iframe was refreshed. The WebSocket connection will auto-reconnect in a few seconds.";
735
+ }
736
+ return {
737
+ content: [
738
+ {
739
+ type: "text",
740
+ text: JSON.stringify(responseData),
741
+ },
742
+ ],
743
+ };
744
+ }
745
+ catch (error) {
746
+ logger.error({ error }, "Failed to reload plugin");
747
+ return {
748
+ content: [
749
+ {
750
+ type: "text",
751
+ text: JSON.stringify({
752
+ error: String(error),
753
+ message: "Failed to reload plugin",
754
+ troubleshooting: [
755
+ "Open the Desktop Bridge plugin in Figma for WebSocket-based reload",
756
+ "Ensure the Desktop Bridge plugin is open and connected in Figma",
757
+ ],
758
+ }),
759
+ },
760
+ ],
761
+ isError: true,
762
+ };
763
+ }
764
+ });
765
+ // Tool 5: Clear Console
766
+ server.tool("figma_clear_console", "Clear the console log buffer. Safely clears the buffer without disrupting the connection. Returns number of logs cleared.", {}, async () => {
767
+ try {
768
+ let clearedCount = 0;
769
+ let transport = "websocket";
770
+ // Clear the WebSocket plugin-side log buffer (non-disruptive)
771
+ if (this.wsServer?.isClientConnected()) {
772
+ clearedCount = this.wsServer.clearConsoleLogs();
773
+ transport = "websocket";
774
+ }
775
+ else {
776
+ throw new Error("No console monitoring available. Open the Desktop Bridge plugin in Figma.");
777
+ }
778
+ const responseData = {
779
+ status: "cleared",
780
+ clearedCount,
781
+ timestamp: Date.now(),
782
+ transport,
783
+ };
784
+ if (transport === "websocket") {
785
+ responseData.ai_instruction =
786
+ "Console buffer cleared via WebSocket. No reconnection needed — monitoring continues automatically.";
787
+ }
788
+ else {
789
+ responseData.ai_instruction =
790
+ "Console cleared successfully.";
791
+ }
792
+ return {
793
+ content: [
794
+ {
795
+ type: "text",
796
+ text: JSON.stringify(responseData),
797
+ },
798
+ ],
799
+ };
800
+ }
801
+ catch (error) {
802
+ logger.error({ error }, "Failed to clear console");
803
+ return {
804
+ content: [
805
+ {
806
+ type: "text",
807
+ text: JSON.stringify({
808
+ error: String(error),
809
+ message: "Failed to clear console buffer",
810
+ }),
811
+ },
812
+ ],
813
+ isError: true,
814
+ };
815
+ }
816
+ });
817
+ // Tool 6: Navigate / switch active file
818
+ server.tool("figma_navigate", "Switch the active Figma file target among files that already have the Desktop Bridge plugin running. Local mode is WebSocket-only — this tool does NOT launch a browser or open files. If the requested URL is already the active file, it confirms the connection. If another connected plugin matches the URL, it switches the active target so subsequent tool calls hit that file. If no connected plugin matches, returns guidance for the user to open the Desktop Bridge plugin in the target file. Use figma_list_open_files to see all connected files.", {
819
+ url: z
820
+ .string()
821
+ .url()
822
+ .describe("Figma URL to navigate to (e.g., https://www.figma.com/design/abc123)"),
823
+ }, async ({ url }) => {
824
+ try {
825
+ // Phase 3: local mode now talks to Figma exclusively through the
826
+ // WebSocket Desktop Bridge plugin. Navigation is plugin-side: we
827
+ // either switch the active file (if the target file already has
828
+ // the plugin open) or ask the user to open the plugin in the
829
+ // target file. Cross-file browser navigation via the old CDP
830
+ // path no longer exists.
831
+ if (this.wsServer?.isClientConnected()) {
832
+ {
833
+ const fileInfo = this.wsServer.getConnectedFileInfo();
834
+ // Check if the requested URL points to the same file already connected via WebSocket
835
+ const requestedFileKey = extractFileKey(url);
836
+ const isSameFile = !!(requestedFileKey && fileInfo?.fileKey && requestedFileKey === fileInfo.fileKey);
837
+ if (isSameFile) {
838
+ return {
839
+ content: [
840
+ {
841
+ type: "text",
842
+ text: JSON.stringify({
843
+ status: "already_connected",
844
+ timestamp: Date.now(),
845
+ connectedFile: {
846
+ fileName: fileInfo.fileName,
847
+ fileKey: fileInfo.fileKey,
848
+ },
849
+ message: "Already connected to this file via WebSocket. All tools are ready to use — no navigation needed.",
850
+ ai_instruction: "The requested file is already connected via WebSocket. You can proceed with any tool calls (figma_get_variables, figma_get_file_data, figma_execute, etc.) without further navigation.",
851
+ }),
852
+ },
853
+ ],
854
+ };
855
+ }
856
+ // Check if the requested file is connected via multi-client WebSocket
857
+ if (requestedFileKey) {
858
+ const connectedFiles = this.wsServer.getConnectedFiles();
859
+ const targetFile = connectedFiles.find(f => f.fileKey === requestedFileKey);
860
+ if (targetFile) {
861
+ this.wsServer.setActiveFile(requestedFileKey);
862
+ return {
863
+ content: [
864
+ {
865
+ type: "text",
866
+ text: JSON.stringify({
867
+ status: "switched_active_file",
868
+ timestamp: Date.now(),
869
+ activeFile: {
870
+ fileName: targetFile.fileName,
871
+ fileKey: targetFile.fileKey,
872
+ },
873
+ connectedFiles: connectedFiles.map(f => ({
874
+ fileName: f.fileName,
875
+ fileKey: f.fileKey,
876
+ isActive: f.fileKey === requestedFileKey,
877
+ })),
878
+ message: `Switched active file to "${targetFile.fileName}". All tools now target this file.`,
879
+ ai_instruction: "Active file has been switched via WebSocket. All subsequent tool calls (figma_get_variables, figma_execute, etc.) will target this file. No browser navigation needed.",
880
+ }),
881
+ },
882
+ ],
883
+ };
884
+ }
885
+ }
886
+ return {
887
+ content: [
888
+ {
889
+ type: "text",
890
+ text: JSON.stringify({
891
+ status: "websocket_file_not_connected",
892
+ timestamp: Date.now(),
893
+ connectedFile: fileInfo
894
+ ? {
895
+ fileName: fileInfo.fileName,
896
+ fileKey: fileInfo.fileKey,
897
+ }
898
+ : undefined,
899
+ connectedFiles: this.wsServer.getConnectedFiles().map(f => ({
900
+ fileName: f.fileName,
901
+ fileKey: f.fileKey,
902
+ isActive: f.isActive,
903
+ })),
904
+ requestedFileKey,
905
+ message: "The requested file is not connected via WebSocket. Open the Desktop Bridge plugin in the target file — it will auto-connect. Use figma_list_open_files to see all connected files.",
906
+ ai_instruction: "The requested file is not in the connected files list. The user needs to open the Desktop Bridge plugin in the target Figma file. Once opened, it will auto-connect and appear in figma_list_open_files. Then use figma_navigate to switch to it.",
907
+ }),
908
+ },
909
+ ],
910
+ };
911
+ }
912
+ throw new Error("No connection available. Open the Desktop Bridge plugin in Figma.");
913
+ }
914
+ // If we got here, the WebSocket plugin bridge wasn't connected.
915
+ // Tell the user how to recover — local mode has no Puppeteer
916
+ // fallback after the Phase 3 CDP cleanup.
917
+ throw new Error("Desktop Bridge plugin is not connected. Open the Figma Console MCP plugin in Figma Desktop and try again.");
918
+ }
919
+ catch (error) {
920
+ logger.error({ error }, "Failed to navigate to Figma");
921
+ const errorMessage = error instanceof Error ? error.message : String(error);
922
+ return {
923
+ content: [
924
+ {
925
+ type: "text",
926
+ text: JSON.stringify({
927
+ error: errorMessage,
928
+ message: "Failed to navigate to Figma URL",
929
+ troubleshooting: [
930
+ "In WebSocket mode: navigate manually in Figma and ensure Desktop Bridge plugin is open",
931
+ "Ensure the Desktop Bridge plugin is open in the target file",
932
+ ],
933
+ }),
934
+ },
935
+ ],
936
+ isError: true,
937
+ };
938
+ }
939
+ });
940
+ // Tool 7: Get Status (with setup validation)
941
+ server.tool("figma_get_status", "Check connection status to Figma Desktop. Reports transport status and connection health via the Desktop Bridge plugin (WebSocket transport). Use probe:true for an active roundtrip verification that the plugin is actually responding.", {
942
+ probe: z.boolean().optional().describe("When true, sends a live roundtrip command to the plugin to verify the connection is actually responsive (not just TCP-open). Returns probeResult with success/latency. Recommended for health checks."),
943
+ }, async ({ probe }) => {
944
+ try {
945
+ // Check WebSocket availability
946
+ const wsConnected = this.wsServer?.isClientConnected() ?? false;
947
+ // ConsoleMonitor is gone in WS-only local mode — both fields below
948
+ // (monitorWorkerCount, consoleMonitor) report static zero/null so the
949
+ // status-shape stays stable for any consumer that parses it.
950
+ let currentUrl = this.getCurrentFileUrl();
951
+ // Determine active transport
952
+ let activeTransport = "none";
953
+ if (wsConnected) {
954
+ activeTransport = "websocket";
955
+ }
956
+ // Get current file name — prefer cached info from WebSocket (instant, no roundtrip)
957
+ let currentFileName = null;
958
+ let currentFileKey = null;
959
+ const wsFileInfo = this.wsServer?.getConnectedFileInfo() ?? null;
960
+ if (wsFileInfo) {
961
+ currentFileName = wsFileInfo.fileName;
962
+ currentFileKey = wsFileInfo.fileKey;
963
+ }
964
+ else if (activeTransport !== "none") {
965
+ // Fallback: ask the plugin directly (requires roundtrip)
966
+ try {
967
+ const connector = await this.getDesktopConnector();
968
+ const fileInfo = await connector.executeCodeViaUI("return { fileName: figma.root.name, fileKey: figma.fileKey }", 5000);
969
+ if (fileInfo.success && fileInfo.result) {
970
+ currentFileName = fileInfo.result.fileName;
971
+ currentFileKey = fileInfo.result.fileKey;
972
+ }
973
+ }
974
+ catch {
975
+ // Non-critical - Desktop Bridge might not be running yet
976
+ }
977
+ }
978
+ const setupValid = activeTransport !== "none";
979
+ // Compute failure layer for machine-readable diagnostics
980
+ // Layer 1 = MCP server/WS server issue, Layer 2 = plugin bridge not connected
981
+ const wsServerRunning = this.wsServer?.isStarted() ?? false;
982
+ const failureLayer = setupValid
983
+ ? null
984
+ : !wsServerRunning
985
+ ? 1
986
+ : 2;
987
+ // Active probe: verify the plugin actually responds to commands
988
+ let probeResult;
989
+ if (probe) {
990
+ const probeStart = Date.now();
991
+ try {
992
+ const result = await this.wsServer.sendCommand('GET_FILE_INFO', {}, 3000);
993
+ probeResult = {
994
+ success: !!(result && result.fileInfo),
995
+ latencyMs: Date.now() - probeStart,
996
+ };
997
+ }
998
+ catch (probeError) {
999
+ probeResult = {
1000
+ success: false,
1001
+ latencyMs: Date.now() - probeStart,
1002
+ error: probeError?.message || String(probeError),
1003
+ };
1004
+ }
1005
+ }
1006
+ // Recovery steps for agents to act on programmatically
1007
+ const recoverySteps = setupValid
1008
+ ? undefined
1009
+ : failureLayer === 1
1010
+ ? [
1011
+ "Ensure your AI client (Claude Code, Cursor, etc.) is running with figma-console-mcp configured",
1012
+ "Check if all ports 9223-9232 are occupied: lsof -i :9223-9232 | grep LISTEN",
1013
+ "Kill stale processes if needed: pkill -f figma-console-mcp",
1014
+ "Restart your AI client — the MCP server will start automatically on the next tool call",
1015
+ ]
1016
+ : [
1017
+ "Open Figma Desktop with your target file",
1018
+ "Go to Plugins → Development → Figma Desktop Bridge",
1019
+ "Click 'Run' to open the plugin",
1020
+ "Wait 3 seconds for the WebSocket connection to establish",
1021
+ "Call figma_get_status with probe:true to verify the connection",
1022
+ ];
1023
+ return {
1024
+ content: [
1025
+ {
1026
+ type: "text",
1027
+ text: JSON.stringify({
1028
+ mode: "local",
1029
+ currentFileName: currentFileName ||
1030
+ "(unable to retrieve - Desktop Bridge may need to be opened)",
1031
+ currentFileKey: currentFileKey || undefined,
1032
+ editorType: this.wsServer?.getEditorType() || "figma",
1033
+ monitoredPageUrl: currentUrl,
1034
+ monitorWorkerCount: 0,
1035
+ transport: {
1036
+ active: activeTransport,
1037
+ websocket: {
1038
+ available: wsConnected,
1039
+ serverRunning: this.wsServer?.isStarted() ?? false,
1040
+ // Version of the plugin files this server ships — what a
1041
+ // manifest re-import installs. pluginUpdateAvailable on
1042
+ // connected files compares against THIS, not the server
1043
+ // version (which can be newer on server-only releases).
1044
+ bundledPluginVersion: getBundledPluginVersion(),
1045
+ port: this.wsActualPort ? String(this.wsActualPort) : null,
1046
+ preferredPort: String(this.wsPreferredPort),
1047
+ portFallbackUsed: this.wsActualPort !== null && this.wsActualPort !== this.wsPreferredPort,
1048
+ startupError: this.wsStartupError ? {
1049
+ code: this.wsStartupError.code,
1050
+ port: this.wsStartupError.port,
1051
+ message: `All ports in range ${this.wsPreferredPort}-${this.wsPreferredPort + 9} are in use`,
1052
+ } : undefined,
1053
+ otherInstances: (() => {
1054
+ try {
1055
+ const instances = discoverActiveInstances(this.wsPreferredPort);
1056
+ const others = instances.filter(i => i.pid !== process.pid);
1057
+ if (others.length === 0)
1058
+ return undefined;
1059
+ return others.map(i => ({
1060
+ port: i.port,
1061
+ pid: i.pid,
1062
+ startedAt: i.startedAt,
1063
+ }));
1064
+ }
1065
+ catch {
1066
+ return undefined;
1067
+ }
1068
+ })(),
1069
+ connectedFile: wsFileInfo ? {
1070
+ fileName: wsFileInfo.fileName,
1071
+ fileKey: wsFileInfo.fileKey,
1072
+ currentPage: wsFileInfo.currentPage,
1073
+ connectedAt: new Date(wsFileInfo.connectedAt).toISOString(),
1074
+ pluginVersion: wsFileInfo.pluginVersion ?? undefined,
1075
+ pluginUpdateAvailable: wsFileInfo.pluginUpdateAvailable || undefined,
1076
+ } : undefined,
1077
+ connectedFiles: (() => {
1078
+ const files = this.wsServer?.getConnectedFiles();
1079
+ if (!files || files.length === 0)
1080
+ return undefined;
1081
+ return files.map(f => ({
1082
+ fileName: f.fileName,
1083
+ fileKey: f.fileKey,
1084
+ currentPage: f.currentPage,
1085
+ editorType: f.editorType || 'figma',
1086
+ isActive: f.isActive,
1087
+ connectedAt: new Date(f.connectedAt).toISOString(),
1088
+ pluginVersion: f.pluginVersion ?? undefined,
1089
+ pluginUpdateAvailable: f.pluginUpdateAvailable || undefined,
1090
+ }));
1091
+ })(),
1092
+ currentSelection: (() => {
1093
+ const sel = this.wsServer?.getCurrentSelection();
1094
+ if (!sel || sel.count === 0)
1095
+ return undefined;
1096
+ return {
1097
+ count: sel.count,
1098
+ nodes: sel.nodes.slice(0, 5).map((n) => `${n.name} (${n.type})`),
1099
+ page: sel.page,
1100
+ };
1101
+ })(),
1102
+ lastPongAt: this.wsServer?.getActiveClientLastPongAt() ? new Date(this.wsServer.getActiveClientLastPongAt()).toISOString() : undefined,
1103
+ },
1104
+ },
1105
+ setup: {
1106
+ valid: setupValid,
1107
+ failureLayer,
1108
+ probeResult,
1109
+ recoverySteps,
1110
+ message: activeTransport === "websocket"
1111
+ ? this.wsActualPort !== this.wsPreferredPort
1112
+ ? `āœ… Connected to Figma Desktop via WebSocket Bridge (port ${this.wsActualPort}, fallback from ${this.wsPreferredPort})`
1113
+ : "āœ… Connected to Figma Desktop via WebSocket Bridge"
1114
+ : this.wsStartupError?.code === "EADDRINUSE"
1115
+ ? `āŒ All WebSocket ports ${this.wsPreferredPort}-${this.wsPreferredPort + 9} are in use`
1116
+ : this.wsActualPort !== null && this.wsActualPort !== this.wsPreferredPort
1117
+ ? `āŒ WebSocket server running on port ${this.wsActualPort} (fallback) but no plugin connected. Restart the Desktop Bridge plugin in Figma to reconnect.`
1118
+ : "āŒ No connection to Figma Desktop",
1119
+ setupInstructions: !setupValid
1120
+ ? this.wsStartupError?.code === "EADDRINUSE"
1121
+ ? {
1122
+ cause: `All ports in range ${this.wsPreferredPort}-${this.wsPreferredPort + 9} are in use by other MCP server instances.`,
1123
+ fix: "Close some of the other Claude Desktop tabs or terminal sessions running the MCP server, then restart this one.",
1124
+ }
1125
+ : {
1126
+ instructions: `Open the Desktop Bridge plugin in Figma (Plugins → Development → Figma Desktop Bridge). No special launch flags needed.${this.getPluginPath() ? ' Plugin manifest: ' + this.getPluginPath() : ''}`,
1127
+ }
1128
+ : undefined,
1129
+ ai_instruction: !setupValid
1130
+ ? this.wsStartupError?.code === "EADDRINUSE"
1131
+ ? `All WebSocket ports in range ${this.wsPreferredPort}-${this.wsPreferredPort + 9} are in use — most likely multiple Claude Desktop tabs or terminal sessions are running the Figma Console MCP server. Ask the user to close some sessions and restart.`
1132
+ : this.wsActualPort !== null && this.wsActualPort !== this.wsPreferredPort
1133
+ ? `Server is running on fallback port ${this.wsActualPort} (port ${this.wsPreferredPort} was taken by another instance). The Desktop Bridge plugin is not connected. TELL THE USER: Close and reopen the Desktop Bridge plugin in Figma to reconnect. The plugin scans the whole port range (9223–9232) on launch and will pick up this server automatically.`
1134
+ : `No connection to Figma Desktop. Open the Desktop Bridge plugin in Figma to connect.${this.getPluginPath() ? ' Plugin manifest: ' + this.getPluginPath() : ''}`
1135
+ : activeTransport === "websocket"
1136
+ ? `Connected via WebSocket Bridge to "${currentFileName || "unknown file"}" on port ${this.wsActualPort}. All design tools and console monitoring tools are available. Console logs are captured from the plugin sandbox (code.js). IMPORTANT: Always verify the file name before destructive operations when multiple files have the plugin open.`
1137
+ : "All tools are ready to use.",
1138
+ },
1139
+ pluginPath: this.getPluginPath() || undefined,
1140
+ consoleMonitor: null,
1141
+ initialized: setupValid,
1142
+ timestamp: Date.now(),
1143
+ }),
1144
+ },
1145
+ ],
1146
+ };
1147
+ }
1148
+ catch (error) {
1149
+ logger.error({ error }, "Failed to get status");
1150
+ return {
1151
+ content: [
1152
+ {
1153
+ type: "text",
1154
+ text: JSON.stringify({
1155
+ error: String(error),
1156
+ message: "Failed to retrieve status",
1157
+ }),
1158
+ },
1159
+ ],
1160
+ isError: true,
1161
+ };
1162
+ }
1163
+ });
1164
+ // ============================================================================
1165
+ // CONNECTION MANAGEMENT TOOLS
1166
+ // ============================================================================
1167
+ // Tool: Force reconnect to Figma Desktop
1168
+ server.tool("figma_reconnect", "Force a complete reconnection to Figma Desktop. Use when connection seems stale or after switching files.", {}, async () => {
1169
+ try {
1170
+ // figma_reconnect is informational in WebSocket-only mode — the
1171
+ // plugin handles its own reconnect logic. A TCP-open socket is not
1172
+ // proof of health (the plugin sandbox can be dead while ui.html
1173
+ // still pongs), so a live roundtrip through the sandbox is the
1174
+ // success criterion here.
1175
+ if (!this.wsServer?.isClientConnected()) {
1176
+ throw new Error("Cannot connect to Figma Desktop.\n\n" +
1177
+ "Open the Desktop Bridge plugin in Figma (Plugins → Development → Figma Desktop Bridge).");
1178
+ }
1179
+ const connector = await this.getDesktopConnector();
1180
+ const probe = async () => connector.executeCodeViaUI("return { fileName: figma.root.name, fileKey: figma.fileKey }", 5000);
1181
+ let fileInfo = await probe().catch(() => ({ success: false, result: null }));
1182
+ let selfHealed = false;
1183
+ if (!fileInfo.success) {
1184
+ // Sandbox-dead / wedged-relay state: attempt self-healing by
1185
+ // reloading the plugin iframe (RELOAD_UI re-runs figma.showUI,
1186
+ // which triggers a fresh port scan and reconnection). This only
1187
+ // works when the message relay is still partially functional —
1188
+ // if code.js itself is dead, the command times out and we fall
1189
+ // through to the honest error below.
1190
+ try {
1191
+ logger.info("Reconnect probe failed — attempting RELOAD_UI self-heal");
1192
+ await this.wsServer.sendCommand("RELOAD_UI", {}, 5000);
1193
+ // Give the fresh iframe time to rescan and re-identify
1194
+ await new Promise((resolve) => setTimeout(resolve, 4000));
1195
+ fileInfo = await probe().catch(() => ({ success: false, result: null }));
1196
+ selfHealed = fileInfo.success;
1197
+ }
1198
+ catch {
1199
+ // RELOAD_UI itself failed — the sandbox is truly unreachable
1200
+ }
1201
+ }
1202
+ if (!fileInfo.success) {
1203
+ const probeErr = new Error("Desktop Bridge socket is open but the plugin is not responding to commands " +
1204
+ "(automatic plugin reload was attempted and did not help). " +
1205
+ "Close and reopen the Figma Console MCP plugin in Figma Desktop (Plugins → Development → Figma Console MCP).");
1206
+ probeErr.connectionError = this.buildConnectionError(probeErr);
1207
+ throw probeErr;
1208
+ }
1209
+ const fileName = fileInfo.result?.fileName || null;
1210
+ return {
1211
+ content: [
1212
+ {
1213
+ type: "text",
1214
+ text: JSON.stringify({
1215
+ status: "connected",
1216
+ transport: "websocket",
1217
+ probeVerified: true,
1218
+ selfHealed: selfHealed || undefined,
1219
+ fileName,
1220
+ timestamp: Date.now(),
1221
+ message: selfHealed
1222
+ ? `Plugin was unresponsive; automatically reloaded it and verified the connection. Connected to: "${fileName || "(unnamed file)"}"`
1223
+ : `Connection verified via live roundtrip. Connected to: "${fileName || "(unnamed file)"}"`,
1224
+ }),
1225
+ },
1226
+ ],
1227
+ };
1228
+ }
1229
+ catch (error) {
1230
+ logger.error({ error }, "Failed to reconnect");
1231
+ return {
1232
+ content: [
1233
+ {
1234
+ type: "text",
1235
+ text: JSON.stringify({
1236
+ error: error instanceof Error ? error.message : String(error),
1237
+ message: "Failed to reconnect to Figma Desktop",
1238
+ hint: "Open the Desktop Bridge plugin in Figma",
1239
+ connectionError: this.buildConnectionError(error),
1240
+ }),
1241
+ },
1242
+ ],
1243
+ isError: true,
1244
+ };
1245
+ }
1246
+ });
1247
+ // ============================================================================
1248
+ // REAL-TIME AWARENESS TOOLS (WebSocket-only)
1249
+ // ============================================================================
1250
+ // Tool: Get current user selection in Figma
1251
+ server.tool("figma_get_selection", "Get the currently selected nodes in Figma. Returns node IDs, names, types, and dimensions. WebSocket-only — requires Desktop Bridge plugin. Use this to understand what the user is pointing at instead of asking them to describe it.", {
1252
+ verbose: z
1253
+ .boolean()
1254
+ .optional()
1255
+ .default(false)
1256
+ .describe("If true, fetches additional details (fills, strokes, styles) for each selected node via figma_execute"),
1257
+ }, async ({ verbose }) => {
1258
+ try {
1259
+ const selection = this.wsServer?.getCurrentSelection() ?? null;
1260
+ if (!this.wsServer?.isClientConnected()) {
1261
+ return {
1262
+ content: [{
1263
+ type: "text",
1264
+ text: JSON.stringify({
1265
+ error: "WebSocket not connected. Open the Desktop Bridge plugin in Figma.",
1266
+ selection: null,
1267
+ }),
1268
+ }],
1269
+ isError: true,
1270
+ };
1271
+ }
1272
+ if (!selection || selection.count === 0) {
1273
+ return {
1274
+ content: [{
1275
+ type: "text",
1276
+ text: JSON.stringify({
1277
+ selection: [],
1278
+ count: 0,
1279
+ page: selection?.page ?? "unknown",
1280
+ message: "Nothing is selected in Figma. Select one or more elements to use this tool.",
1281
+ }),
1282
+ }],
1283
+ };
1284
+ }
1285
+ let result = {
1286
+ selection: selection.nodes,
1287
+ count: selection.count,
1288
+ page: selection.page,
1289
+ timestamp: selection.timestamp,
1290
+ };
1291
+ // If verbose, fetch additional details for selected nodes
1292
+ if (verbose && selection.nodes.length > 0 && selection.nodes.length <= 10) {
1293
+ try {
1294
+ const connector = await this.getDesktopConnector();
1295
+ const nodeIds = selection.nodes.map((n) => `"${n.id}"`).join(",");
1296
+ const details = await connector.executeCodeViaUI(`var ids = [${nodeIds}];
1297
+ var results = [];
1298
+ for (var i = 0; i < ids.length; i++) {
1299
+ var node = figma.getNodeById(ids[i]);
1300
+ if (!node) continue;
1301
+ var info = { id: node.id, name: node.name, type: node.type };
1302
+ if ('fills' in node) info.fills = node.fills;
1303
+ if ('strokes' in node) info.strokes = node.strokes;
1304
+ if ('effects' in node) info.effects = node.effects;
1305
+ if ('characters' in node) info.characters = node.characters;
1306
+ if ('fontSize' in node) info.fontSize = node.fontSize;
1307
+ if ('fontName' in node) info.fontName = node.fontName;
1308
+ if ('opacity' in node) info.opacity = node.opacity;
1309
+ if ('cornerRadius' in node) info.cornerRadius = node.cornerRadius;
1310
+ if ('componentProperties' in node) info.componentProperties = node.componentProperties;
1311
+ if ('annotations' in node && node.annotations && node.annotations.length > 0) info.annotations = node.annotations;
1312
+ results.push(info);
1313
+ }
1314
+ return results;`, 10000);
1315
+ if (details.success && details.result) {
1316
+ result.details = details.result;
1317
+ }
1318
+ }
1319
+ catch (err) {
1320
+ result.detailsError = "Could not fetch detailed properties";
1321
+ }
1322
+ }
1323
+ return {
1324
+ content: [{
1325
+ type: "text",
1326
+ text: JSON.stringify(result),
1327
+ }],
1328
+ };
1329
+ }
1330
+ catch (error) {
1331
+ return {
1332
+ content: [{
1333
+ type: "text",
1334
+ text: JSON.stringify({
1335
+ error: error instanceof Error ? error.message : String(error),
1336
+ message: "Failed to get selection",
1337
+ }),
1338
+ }],
1339
+ isError: true,
1340
+ };
1341
+ }
1342
+ });
1343
+ // Tool: Get recent design changes
1344
+ server.tool("figma_get_design_changes", "Get recent document changes detected in Figma. Returns buffered change events including which nodes changed, whether styles were modified, and change counts. WebSocket-only — events are captured via Desktop Bridge plugin. Use this to understand what changed since you last checked.", {
1345
+ since: z
1346
+ .number()
1347
+ .optional()
1348
+ .describe("Only return changes after this Unix timestamp (ms). Useful for incremental polling."),
1349
+ count: z
1350
+ .number()
1351
+ .optional()
1352
+ .describe("Maximum number of change events to return (chronological order, oldest to newest; returns the last N events)"),
1353
+ clear: z
1354
+ .boolean()
1355
+ .optional()
1356
+ .default(false)
1357
+ .describe("Clear the change buffer after reading. Set to true for polling workflows."),
1358
+ }, async ({ since, count, clear }) => {
1359
+ try {
1360
+ if (!this.wsServer?.isClientConnected()) {
1361
+ return {
1362
+ content: [{
1363
+ type: "text",
1364
+ text: JSON.stringify({
1365
+ error: "WebSocket not connected. Open the Desktop Bridge plugin in Figma.",
1366
+ changes: [],
1367
+ }),
1368
+ }],
1369
+ isError: true,
1370
+ };
1371
+ }
1372
+ const changes = this.wsServer.getDocumentChanges({ since, count });
1373
+ // Compute summary
1374
+ let totalNodeChanges = 0;
1375
+ let totalStyleChanges = 0;
1376
+ const allChangedNodeIds = new Set();
1377
+ for (const change of changes) {
1378
+ if (change.hasNodeChanges)
1379
+ totalNodeChanges++;
1380
+ if (change.hasStyleChanges)
1381
+ totalStyleChanges++;
1382
+ for (const id of change.changedNodeIds) {
1383
+ allChangedNodeIds.add(id);
1384
+ }
1385
+ }
1386
+ if (clear) {
1387
+ this.wsServer.clearDocumentChanges();
1388
+ }
1389
+ return {
1390
+ content: [{
1391
+ type: "text",
1392
+ text: JSON.stringify({
1393
+ changes,
1394
+ summary: {
1395
+ eventCount: changes.length,
1396
+ nodeChangeEvents: totalNodeChanges,
1397
+ styleChangeEvents: totalStyleChanges,
1398
+ uniqueNodesChanged: allChangedNodeIds.size,
1399
+ oldestTimestamp: changes[0]?.timestamp,
1400
+ newestTimestamp: changes[changes.length - 1]?.timestamp,
1401
+ },
1402
+ bufferCleared: clear,
1403
+ }),
1404
+ }],
1405
+ };
1406
+ }
1407
+ catch (error) {
1408
+ return {
1409
+ content: [{
1410
+ type: "text",
1411
+ text: JSON.stringify({
1412
+ error: error instanceof Error ? error.message : String(error),
1413
+ message: "Failed to get design changes",
1414
+ }),
1415
+ }],
1416
+ isError: true,
1417
+ };
1418
+ }
1419
+ });
1420
+ // Tool: List all open files connected via WebSocket
1421
+ server.tool("figma_list_open_files", "List all Figma files currently connected via the Desktop Bridge plugin. Shows which files have the plugin open and which one is the active target for tool calls. Use figma_navigate to switch between files. WebSocket multi-client mode — each file with the Desktop Bridge plugin maintains its own connection.", {}, async () => {
1422
+ try {
1423
+ if (!this.wsServer?.isClientConnected()) {
1424
+ return {
1425
+ content: [{
1426
+ type: "text",
1427
+ text: JSON.stringify({
1428
+ error: "No files connected. Open the Desktop Bridge plugin in Figma to connect files.",
1429
+ files: [],
1430
+ }),
1431
+ }],
1432
+ isError: true,
1433
+ };
1434
+ }
1435
+ const connectedFiles = this.wsServer.getConnectedFiles();
1436
+ const activeFileKey = this.wsServer.getActiveFileKey();
1437
+ return {
1438
+ content: [{
1439
+ type: "text",
1440
+ text: JSON.stringify({
1441
+ transport: "websocket",
1442
+ activeFileKey,
1443
+ files: connectedFiles.map(f => ({
1444
+ fileName: f.fileName,
1445
+ fileKey: f.fileKey,
1446
+ currentPage: f.currentPage,
1447
+ isActive: f.isActive,
1448
+ connectedAt: f.connectedAt,
1449
+ url: f.fileKey
1450
+ ? `https://www.figma.com/design/${f.fileKey}/${encodeURIComponent(f.fileName || 'Untitled')}`
1451
+ : undefined,
1452
+ })),
1453
+ totalFiles: connectedFiles.length,
1454
+ message: connectedFiles.length === 1
1455
+ ? `Connected to 1 file: "${connectedFiles[0].fileName}"`
1456
+ : `Connected to ${connectedFiles.length} files. Active: "${connectedFiles.find(f => f.isActive)?.fileName || 'none'}"`,
1457
+ ai_instruction: "Use figma_navigate with a file URL to switch the active file. All tools target the active file by default.",
1458
+ }),
1459
+ }],
1460
+ };
1461
+ }
1462
+ catch (error) {
1463
+ return {
1464
+ content: [{
1465
+ type: "text",
1466
+ text: JSON.stringify({
1467
+ error: error instanceof Error ? error.message : String(error),
1468
+ message: "Failed to list open files",
1469
+ }),
1470
+ }],
1471
+ isError: true,
1472
+ };
1473
+ }
1474
+ });
1475
+ // ============================================================================
1476
+ // DESIGN SYSTEM TOOLS (Token-Efficient Tool Family)
1477
+ // ============================================================================
1478
+ // These tools provide progressive disclosure of design system data
1479
+ // to minimize context window usage. Start with summary, then search,
1480
+ // then get details for specific components.
1481
+ // Helper function to ensure design system cache is loaded (auto-loads if needed)
1482
+ const ensureDesignSystemCache = async () => {
1483
+ const { DesignSystemManifestCache, createEmptyManifest, figmaColorToHex, } = await import("./core/design-system-manifest.js");
1484
+ const cache = DesignSystemManifestCache.getInstance();
1485
+ const currentUrl = this.getCurrentFileUrl();
1486
+ // extractFileKey is branch-aware: on /design/KEY/branch/BRANCH_KEY/…
1487
+ // URLs the branch key is the effective file key — an ad-hoc regex
1488
+ // here returned the main key and cached the wrong manifest for
1489
+ // branch files.
1490
+ const fileKey = (currentUrl ? extractFileKey(currentUrl) : null) ?? "unknown";
1491
+ // Check cache first
1492
+ let cacheEntry = cache.get(fileKey);
1493
+ if (cacheEntry) {
1494
+ return { cacheEntry, fileKey, wasLoaded: false };
1495
+ }
1496
+ // Need to extract fresh data - do this silently without returning an error
1497
+ logger.info({ fileKey }, "Auto-loading design system cache");
1498
+ const connector = await this.getDesktopConnector();
1499
+ const manifest = createEmptyManifest(fileKey);
1500
+ manifest.fileUrl = currentUrl || undefined;
1501
+ // Get variables (tokens)
1502
+ try {
1503
+ const variablesResult = await connector.getVariables(fileKey);
1504
+ if (variablesResult.success && variablesResult.data) {
1505
+ for (const collection of variablesResult.data.variableCollections ||
1506
+ []) {
1507
+ manifest.collections.push({
1508
+ id: collection.id,
1509
+ name: collection.name,
1510
+ modes: collection.modes.map((m) => ({
1511
+ modeId: m.modeId,
1512
+ name: m.name,
1513
+ })),
1514
+ defaultModeId: collection.defaultModeId,
1515
+ });
1516
+ }
1517
+ for (const variable of variablesResult.data.variables || []) {
1518
+ const tokenName = variable.name;
1519
+ const defaultModeId = manifest.collections.find((c) => c.id === variable.variableCollectionId)?.defaultModeId;
1520
+ const defaultValue = defaultModeId
1521
+ ? variable.valuesByMode?.[defaultModeId]
1522
+ : undefined;
1523
+ if (variable.resolvedType === "COLOR") {
1524
+ manifest.tokens.colors[tokenName] = {
1525
+ name: tokenName,
1526
+ value: figmaColorToHex(defaultValue),
1527
+ variableId: variable.id,
1528
+ scopes: variable.scopes,
1529
+ };
1530
+ }
1531
+ else if (variable.resolvedType === "FLOAT") {
1532
+ manifest.tokens.spacing[tokenName] = {
1533
+ name: tokenName,
1534
+ value: typeof defaultValue === "number" ? defaultValue : 0,
1535
+ variableId: variable.id,
1536
+ };
1537
+ }
1538
+ }
1539
+ }
1540
+ }
1541
+ catch (error) {
1542
+ logger.warn({ error }, "Could not fetch variables during auto-load");
1543
+ }
1544
+ // Get components
1545
+ // Maps are keyed by component key (unique) rather than name — same-named
1546
+ // components on different pages would silently overwrite each other.
1547
+ let rawComponents;
1548
+ let componentsFetchError = null;
1549
+ try {
1550
+ const componentsResult = await connector.getLocalComponents();
1551
+ if (componentsResult.success && componentsResult.data) {
1552
+ rawComponents = {
1553
+ components: componentsResult.data.components || [],
1554
+ componentSets: componentsResult.data.componentSets || [],
1555
+ };
1556
+ for (const comp of rawComponents.components) {
1557
+ manifest.components[comp.key || comp.nodeId] = {
1558
+ key: comp.key,
1559
+ nodeId: comp.nodeId,
1560
+ name: comp.name,
1561
+ description: comp.description || undefined,
1562
+ defaultSize: { width: comp.width, height: comp.height },
1563
+ };
1564
+ }
1565
+ for (const compSet of rawComponents.componentSets) {
1566
+ manifest.componentSets[compSet.key || compSet.nodeId] = {
1567
+ key: compSet.key,
1568
+ nodeId: compSet.nodeId,
1569
+ name: compSet.name,
1570
+ description: compSet.description || undefined,
1571
+ variants: compSet.variants?.map((v) => ({
1572
+ key: v.key,
1573
+ nodeId: v.nodeId,
1574
+ name: v.name,
1575
+ })) || [],
1576
+ variantAxes: compSet.variantAxes?.map((a) => ({
1577
+ name: a.name,
1578
+ values: a.values,
1579
+ })) || [],
1580
+ };
1581
+ }
1582
+ }
1583
+ else {
1584
+ componentsFetchError =
1585
+ componentsResult?.error || "getLocalComponents returned no data";
1586
+ }
1587
+ }
1588
+ catch (error) {
1589
+ componentsFetchError =
1590
+ error instanceof Error ? error.message : String(error);
1591
+ logger.warn({ error }, "Could not fetch components during auto-load");
1592
+ }
1593
+ // Update summary
1594
+ manifest.summary = {
1595
+ totalTokens: Object.keys(manifest.tokens.colors).length +
1596
+ Object.keys(manifest.tokens.spacing).length,
1597
+ totalComponents: Object.keys(manifest.components).length,
1598
+ totalComponentSets: Object.keys(manifest.componentSets).length,
1599
+ colorPalette: Object.keys(manifest.tokens.colors).slice(0, 10),
1600
+ spacingScale: Object.values(manifest.tokens.spacing)
1601
+ .map((s) => s.value)
1602
+ .sort((a, b) => a - b)
1603
+ .slice(0, 10),
1604
+ typographyScale: [],
1605
+ componentCategories: [],
1606
+ };
1607
+ // Cache the result — but never cache a manifest built from a FAILED
1608
+ // components fetch: serving an empty manifest as "cached" for the full
1609
+ // TTL is exactly the "search returns 0 components" poisoning bug.
1610
+ if (!componentsFetchError) {
1611
+ cache.set(fileKey, manifest, rawComponents);
1612
+ cacheEntry = cache.get(fileKey);
1613
+ }
1614
+ else {
1615
+ cacheEntry = {
1616
+ manifest,
1617
+ timestamp: Date.now(),
1618
+ fileKey,
1619
+ rawComponents,
1620
+ };
1621
+ }
1622
+ return {
1623
+ cacheEntry,
1624
+ fileKey,
1625
+ wasLoaded: true,
1626
+ warning: componentsFetchError
1627
+ ? `Components fetch failed (${componentsFetchError}) — results may be incomplete and were NOT cached; retry after checking the Desktop Bridge plugin.`
1628
+ : undefined,
1629
+ };
1630
+ };
1631
+ // ============================================================================
1632
+ // READ-SIDE LIBRARY / DESIGN-SYSTEM TOOLS
1633
+ // (Previously interleaved with write tools in local.ts; restored after the
1634
+ // Phase-2 write-tools dedupe excised them along with the surrounding writes.)
1635
+ // ============================================================================
1636
+ server.tool("figma_get_design_system_summary", "Get a compact overview of the design system. Returns categories, component counts, and token collection names WITHOUT full details. Use this first to understand what's available, then use figma_search_components to find specific components. This tool is optimized for minimal token usage.", {
1637
+ forceRefresh: z
1638
+ .boolean()
1639
+ .optional()
1640
+ .default(false)
1641
+ .describe("Force refresh the cached data (use sparingly - extraction can take minutes for large files)"),
1642
+ }, async ({ forceRefresh }) => {
1643
+ try {
1644
+ const { DesignSystemManifestCache, createEmptyManifest, figmaColorToHex, getCategories, getTokenSummary, } = await import("./core/design-system-manifest.js");
1645
+ const cache = DesignSystemManifestCache.getInstance();
1646
+ const currentUrl = this.getCurrentFileUrl();
1647
+ const fileKeyMatch = currentUrl?.match(/\/(file|design|board|slides)\/([a-zA-Z0-9]+)/);
1648
+ const fileKey = fileKeyMatch ? fileKeyMatch[2] : "unknown";
1649
+ // Check cache first
1650
+ let cacheEntry = cache.get(fileKey);
1651
+ if (cacheEntry && !forceRefresh) {
1652
+ const categories = getCategories(cacheEntry.manifest);
1653
+ const tokenSummary = getTokenSummary(cacheEntry.manifest);
1654
+ return {
1655
+ content: [
1656
+ {
1657
+ type: "text",
1658
+ text: JSON.stringify({
1659
+ success: true,
1660
+ cached: true,
1661
+ cacheAge: Math.round((Date.now() - cacheEntry.timestamp) / 1000),
1662
+ fileKey,
1663
+ categories: categories.slice(0, 15),
1664
+ tokens: tokenSummary,
1665
+ totals: {
1666
+ components: cacheEntry.manifest.summary.totalComponents,
1667
+ componentSets: cacheEntry.manifest.summary.totalComponentSets,
1668
+ tokens: cacheEntry.manifest.summary.totalTokens,
1669
+ },
1670
+ hint: "Use figma_search_components to find specific components by name or category.",
1671
+ }),
1672
+ },
1673
+ ],
1674
+ };
1675
+ }
1676
+ // Need to extract fresh data
1677
+ const connector = await this.getDesktopConnector();
1678
+ const manifest = createEmptyManifest(fileKey);
1679
+ manifest.fileUrl = currentUrl || undefined;
1680
+ // Get variables (tokens)
1681
+ try {
1682
+ const variablesResult = await connector.getVariables(fileKey);
1683
+ if (variablesResult.success && variablesResult.data) {
1684
+ for (const collection of variablesResult.data
1685
+ .variableCollections || []) {
1686
+ manifest.collections.push({
1687
+ id: collection.id,
1688
+ name: collection.name,
1689
+ modes: collection.modes.map((m) => ({
1690
+ modeId: m.modeId,
1691
+ name: m.name,
1692
+ })),
1693
+ defaultModeId: collection.defaultModeId,
1694
+ });
1695
+ }
1696
+ for (const variable of variablesResult.data.variables || []) {
1697
+ const tokenName = variable.name;
1698
+ const defaultModeId = manifest.collections.find((c) => c.id === variable.variableCollectionId)?.defaultModeId;
1699
+ const defaultValue = defaultModeId
1700
+ ? variable.valuesByMode?.[defaultModeId]
1701
+ : undefined;
1702
+ if (variable.resolvedType === "COLOR") {
1703
+ manifest.tokens.colors[tokenName] = {
1704
+ name: tokenName,
1705
+ value: figmaColorToHex(defaultValue),
1706
+ variableId: variable.id,
1707
+ scopes: variable.scopes,
1708
+ };
1709
+ }
1710
+ else if (variable.resolvedType === "FLOAT") {
1711
+ manifest.tokens.spacing[tokenName] = {
1712
+ name: tokenName,
1713
+ value: typeof defaultValue === "number" ? defaultValue : 0,
1714
+ variableId: variable.id,
1715
+ };
1716
+ }
1717
+ }
1718
+ }
1719
+ }
1720
+ catch (error) {
1721
+ logger.warn({ error }, "Could not fetch variables");
1722
+ }
1723
+ // Get components (can be slow for large files)
1724
+ // Keyed by component key (unique) — name keys drop same-named
1725
+ // components on other pages.
1726
+ let rawComponents;
1727
+ let componentsFetchError = null;
1728
+ try {
1729
+ const componentsResult = await connector.getLocalComponents();
1730
+ if (componentsResult.success && componentsResult.data) {
1731
+ rawComponents = {
1732
+ components: componentsResult.data.components || [],
1733
+ componentSets: componentsResult.data.componentSets || [],
1734
+ };
1735
+ for (const comp of rawComponents.components) {
1736
+ manifest.components[comp.key || comp.nodeId] = {
1737
+ key: comp.key,
1738
+ nodeId: comp.nodeId,
1739
+ name: comp.name,
1740
+ description: comp.description || undefined,
1741
+ defaultSize: { width: comp.width, height: comp.height },
1742
+ };
1743
+ }
1744
+ for (const compSet of rawComponents.componentSets) {
1745
+ manifest.componentSets[compSet.key || compSet.nodeId] = {
1746
+ key: compSet.key,
1747
+ nodeId: compSet.nodeId,
1748
+ name: compSet.name,
1749
+ description: compSet.description || undefined,
1750
+ variants: compSet.variants?.map((v) => ({
1751
+ key: v.key,
1752
+ nodeId: v.nodeId,
1753
+ name: v.name,
1754
+ })) || [],
1755
+ variantAxes: compSet.variantAxes?.map((a) => ({
1756
+ name: a.name,
1757
+ values: a.values,
1758
+ })) || [],
1759
+ };
1760
+ }
1761
+ }
1762
+ else {
1763
+ componentsFetchError =
1764
+ componentsResult?.error || "getLocalComponents returned no data";
1765
+ }
1766
+ }
1767
+ catch (error) {
1768
+ componentsFetchError =
1769
+ error instanceof Error ? error.message : String(error);
1770
+ logger.warn({ error }, "Could not fetch components");
1771
+ }
1772
+ // Update summary
1773
+ manifest.summary = {
1774
+ totalTokens: Object.keys(manifest.tokens.colors).length +
1775
+ Object.keys(manifest.tokens.spacing).length,
1776
+ totalComponents: Object.keys(manifest.components).length,
1777
+ totalComponentSets: Object.keys(manifest.componentSets).length,
1778
+ colorPalette: Object.keys(manifest.tokens.colors).slice(0, 10),
1779
+ spacingScale: Object.values(manifest.tokens.spacing)
1780
+ .map((s) => s.value)
1781
+ .sort((a, b) => a - b)
1782
+ .slice(0, 10),
1783
+ typographyScale: [],
1784
+ componentCategories: [],
1785
+ };
1786
+ // Cache the result — never cache a manifest built from a failed
1787
+ // components fetch (would serve empty results for the full TTL).
1788
+ if (!componentsFetchError) {
1789
+ cache.set(fileKey, manifest, rawComponents);
1790
+ }
1791
+ const categories = getCategories(manifest);
1792
+ const tokenSummary = getTokenSummary(manifest);
1793
+ return {
1794
+ content: [
1795
+ {
1796
+ type: "text",
1797
+ text: JSON.stringify({
1798
+ success: true,
1799
+ cached: false,
1800
+ fileKey,
1801
+ categories: categories.slice(0, 15),
1802
+ tokens: tokenSummary,
1803
+ totals: {
1804
+ components: manifest.summary.totalComponents,
1805
+ componentSets: manifest.summary.totalComponentSets,
1806
+ tokens: manifest.summary.totalTokens,
1807
+ },
1808
+ warnings: componentsFetchError
1809
+ ? [
1810
+ `Components fetch failed (${componentsFetchError}) — component data is incomplete and was NOT cached. Check the Desktop Bridge plugin and retry.`,
1811
+ ]
1812
+ : undefined,
1813
+ hint: "Use figma_search_components to find specific components by name or category.",
1814
+ }),
1815
+ },
1816
+ ],
1817
+ };
1818
+ }
1819
+ catch (error) {
1820
+ logger.error({ error }, "Failed to get design system summary");
1821
+ return {
1822
+ content: [
1823
+ {
1824
+ type: "text",
1825
+ text: JSON.stringify({
1826
+ error: error instanceof Error ? error.message : String(error),
1827
+ hint: "Make sure the Desktop Bridge plugin is running in Figma",
1828
+ }),
1829
+ },
1830
+ ],
1831
+ isError: true,
1832
+ };
1833
+ }
1834
+ });
1835
+ // Tool 2: Search Components (~3000 tokens response max, paginated)
1836
+ server.tool("figma_search_components", `Search for components by name, category, or description. Returns paginated results with component keys for instantiation. Automatically loads the design system cache if needed.
1837
+
1838
+ **NEW: Cross-file library search!** Pass a libraryFileKey or libraryFileUrl to search for components in a published shared library (different file). This uses the REST API and requires FIGMA_ACCESS_TOKEN.
1839
+
1840
+ Without libraryFileKey/libraryFileUrl, searches the currently open file (local components via Plugin API).`, {
1841
+ query: z
1842
+ .string()
1843
+ .optional()
1844
+ .default("")
1845
+ .describe("Search query to match component names or descriptions"),
1846
+ category: z
1847
+ .string()
1848
+ .optional()
1849
+ .describe("Filter by category (e.g., 'Button', 'Input', 'Card')"),
1850
+ libraryFileKey: z
1851
+ .string()
1852
+ .optional()
1853
+ .describe("File key of a published library to search in (for cross-file library access). Overrides local search."),
1854
+ libraryFileUrl: z
1855
+ .string()
1856
+ .optional()
1857
+ .describe("URL of a published library file to search in (e.g., https://www.figma.com/design/abc123/...). Alternative to libraryFileKey."),
1858
+ limit: z
1859
+ .number()
1860
+ .optional()
1861
+ .default(10)
1862
+ .describe("Maximum results to return (default: 10, max: 25)"),
1863
+ offset: z
1864
+ .number()
1865
+ .optional()
1866
+ .default(0)
1867
+ .describe("Offset for pagination"),
1868
+ }, async ({ query, category, libraryFileKey, libraryFileUrl, limit, offset }) => {
1869
+ try {
1870
+ // Determine if this is a library search or local search
1871
+ let resolvedLibraryKey = libraryFileKey;
1872
+ if (!resolvedLibraryKey && libraryFileUrl) {
1873
+ const { extractFileKey } = await import("./core/figma-api.js");
1874
+ resolvedLibraryKey = extractFileKey(libraryFileUrl) ?? undefined;
1875
+ }
1876
+ // LIBRARY SEARCH PATH: Use REST API for cross-file access
1877
+ if (resolvedLibraryKey) {
1878
+ const api = await this.getFigmaAPI();
1879
+ const [componentsResponse, componentSetsResponse] = await Promise.all([
1880
+ api.getComponents(resolvedLibraryKey).catch((err) => {
1881
+ logger.warn({ error: err }, "Failed to fetch components from library");
1882
+ return { meta: { components: [] } };
1883
+ }),
1884
+ api
1885
+ .getComponentSets(resolvedLibraryKey)
1886
+ .catch((err) => {
1887
+ logger.warn({ error: err }, "Failed to fetch component sets from library");
1888
+ return { meta: { component_sets: [] } };
1889
+ }),
1890
+ ]);
1891
+ const rawComponents = componentsResponse?.meta?.components || [];
1892
+ const rawComponentSets = componentSetsResponse?.meta?.component_sets || [];
1893
+ // Build combined results — component sets + standalone components
1894
+ const componentSetNodeIds = new Set(rawComponentSets.map((cs) => cs.node_id));
1895
+ let results = [];
1896
+ // Add component sets with their variant info
1897
+ // NOTE: REST API returns containingComponentSet as an object { name, nodeId }
1898
+ // not a boolean. Match via containingComponentSet.nodeId or component_set_id.
1899
+ for (const cs of rawComponentSets) {
1900
+ const variants = rawComponents.filter((c) => {
1901
+ const ccs = c.containing_frame?.containingComponentSet;
1902
+ // Match via containingComponentSet object (preferred)
1903
+ if (ccs && typeof ccs === "object" && ccs.nodeId === cs.node_id)
1904
+ return true;
1905
+ // Fallback: match via containing_frame.nodeId (some API versions)
1906
+ if (ccs && c.containing_frame?.nodeId === cs.node_id)
1907
+ return true;
1908
+ // Fallback: match via component_set_id field
1909
+ if (c.component_set_id === cs.node_id)
1910
+ return true;
1911
+ return false;
1912
+ });
1913
+ results.push({
1914
+ name: cs.name,
1915
+ key: cs.key,
1916
+ nodeId: cs.node_id,
1917
+ description: cs.description || undefined,
1918
+ type: "COMPONENT_SET",
1919
+ variantCount: variants.length,
1920
+ variants: variants.slice(0, 5).map((v) => ({
1921
+ name: v.name,
1922
+ key: v.key,
1923
+ })),
1924
+ source: "library",
1925
+ });
1926
+ }
1927
+ // Add standalone components (not part of a set)
1928
+ for (const c of rawComponents) {
1929
+ const ccs = c.containing_frame?.containingComponentSet;
1930
+ const isVariant = ccs || c.component_set_id;
1931
+ if (!isVariant) {
1932
+ results.push({
1933
+ name: c.name,
1934
+ key: c.key,
1935
+ nodeId: c.node_id,
1936
+ description: c.description || undefined,
1937
+ type: "COMPONENT",
1938
+ source: "library",
1939
+ });
1940
+ }
1941
+ }
1942
+ // Apply search filter
1943
+ if (query) {
1944
+ const queryLower = query.toLowerCase();
1945
+ results = results.filter((item) => item.name.toLowerCase().includes(queryLower) ||
1946
+ item.description?.toLowerCase().includes(queryLower));
1947
+ }
1948
+ if (category) {
1949
+ const catLower = category.toLowerCase();
1950
+ results = results.filter((item) => item.name.toLowerCase().includes(catLower) ||
1951
+ item.description?.toLowerCase().includes(catLower));
1952
+ }
1953
+ // Sort and paginate
1954
+ results.sort((a, b) => a.name.localeCompare(b.name));
1955
+ const effectiveLimit = Math.min(limit || 10, 25);
1956
+ const effectiveOffset = offset || 0;
1957
+ const total = results.length;
1958
+ const paginatedResults = results
1959
+ .slice(effectiveOffset, effectiveOffset + effectiveLimit)
1960
+ // Search hits only need a teaser — some design systems carry
1961
+ // multi-KB doc blocks per component, which multiplies across
1962
+ // a result page. figma_get_component_details returns full text.
1963
+ .map((item) => item.description && item.description.length > 200
1964
+ ? {
1965
+ ...item,
1966
+ description: `${item.description.slice(0, 200)}…`,
1967
+ descriptionTruncated: true,
1968
+ }
1969
+ : item);
1970
+ return {
1971
+ content: [
1972
+ {
1973
+ type: "text",
1974
+ text: JSON.stringify({
1975
+ success: true,
1976
+ source: "library",
1977
+ libraryFileKey: resolvedLibraryKey,
1978
+ query: query || "(all)",
1979
+ category: category || "(all)",
1980
+ results: paginatedResults,
1981
+ pagination: {
1982
+ offset: effectiveOffset,
1983
+ limit: effectiveLimit,
1984
+ total,
1985
+ hasMore: effectiveOffset + effectiveLimit < total,
1986
+ },
1987
+ hint: "Use figma_instantiate_component with the componentKey to place library components in your current file.",
1988
+ }),
1989
+ },
1990
+ ],
1991
+ };
1992
+ }
1993
+ // LOCAL SEARCH PATH: Use cached design system manifest (existing behavior)
1994
+ const { searchComponents } = await import("./core/design-system-manifest.js");
1995
+ // Auto-load design system cache if needed (no error returned to user)
1996
+ const { cacheEntry, warning: cacheWarning } = await ensureDesignSystemCache();
1997
+ if (!cacheEntry) {
1998
+ return {
1999
+ content: [
2000
+ {
2001
+ type: "text",
2002
+ text: JSON.stringify({
2003
+ error: "Could not load design system data. Make sure the Desktop Bridge plugin is running.",
2004
+ hint: "If you're trying to search a published library from another file, pass the libraryFileKey or libraryFileUrl parameter.",
2005
+ }),
2006
+ },
2007
+ ],
2008
+ isError: true,
2009
+ };
2010
+ }
2011
+ const effectiveLimit = Math.min(limit || 10, 25);
2012
+ const results = searchComponents(cacheEntry.manifest, query || "", {
2013
+ category,
2014
+ limit: effectiveLimit,
2015
+ offset: offset || 0,
2016
+ });
2017
+ return {
2018
+ content: [
2019
+ {
2020
+ type: "text",
2021
+ text: JSON.stringify({
2022
+ success: true,
2023
+ source: "local",
2024
+ query: query || "(all)",
2025
+ category: category || "(all)",
2026
+ results: results.results,
2027
+ warnings: cacheWarning ? [cacheWarning] : undefined,
2028
+ pagination: {
2029
+ offset: offset || 0,
2030
+ limit: effectiveLimit,
2031
+ total: results.total,
2032
+ hasMore: results.hasMore,
2033
+ },
2034
+ hint: results.hasMore
2035
+ ? `Use offset=${(offset || 0) + effectiveLimit} to get more results.`
2036
+ : "Use figma_get_component_details with a component key for full details. To search a published library, pass libraryFileKey.",
2037
+ }),
2038
+ },
2039
+ ],
2040
+ };
2041
+ }
2042
+ catch (error) {
2043
+ logger.error({ error }, "Failed to search components");
2044
+ return {
2045
+ content: [
2046
+ {
2047
+ type: "text",
2048
+ text: JSON.stringify({
2049
+ error: error instanceof Error ? error.message : String(error),
2050
+ }),
2051
+ },
2052
+ ],
2053
+ isError: true,
2054
+ };
2055
+ }
2056
+ });
2057
+ // Tool 3: Get Component Details (~500 tokens per component)
2058
+ server.tool("figma_get_component_details", "Get full details for a specific component including all variants, properties, and keys needed for instantiation. Use the component key or name from figma_search_components.", {
2059
+ componentKey: z
2060
+ .string()
2061
+ .optional()
2062
+ .describe("The component key (preferred for exact match)"),
2063
+ componentName: z
2064
+ .string()
2065
+ .optional()
2066
+ .describe("The component name (used if key not provided)"),
2067
+ }, async ({ componentKey, componentName }) => {
2068
+ try {
2069
+ if (!componentKey && !componentName) {
2070
+ return {
2071
+ content: [
2072
+ {
2073
+ type: "text",
2074
+ text: JSON.stringify({
2075
+ error: "Either componentKey or componentName is required",
2076
+ }),
2077
+ },
2078
+ ],
2079
+ isError: true,
2080
+ };
2081
+ }
2082
+ // Auto-load design system cache if needed
2083
+ const { cacheEntry } = await ensureDesignSystemCache();
2084
+ if (!cacheEntry) {
2085
+ return {
2086
+ content: [
2087
+ {
2088
+ type: "text",
2089
+ text: JSON.stringify({
2090
+ error: "Could not load design system data. Make sure the Desktop Bridge plugin is running.",
2091
+ }),
2092
+ },
2093
+ ],
2094
+ isError: true,
2095
+ };
2096
+ }
2097
+ // Search for the component
2098
+ let component = null;
2099
+ let isComponentSet = false;
2100
+ // Check component sets first (they have variants).
2101
+ // Maps are keyed by component key; match names via the entry's
2102
+ // own name field, not the map key.
2103
+ for (const compSet of Object.values(cacheEntry.manifest.componentSets)) {
2104
+ if ((componentKey && compSet.key === componentKey) ||
2105
+ (componentName && compSet.name === componentName)) {
2106
+ component = compSet;
2107
+ isComponentSet = true;
2108
+ break;
2109
+ }
2110
+ }
2111
+ // Check standalone components
2112
+ if (!component) {
2113
+ for (const comp of Object.values(cacheEntry.manifest.components)) {
2114
+ if ((componentKey && comp.key === componentKey) ||
2115
+ (componentName && comp.name === componentName)) {
2116
+ component = comp;
2117
+ break;
2118
+ }
2119
+ }
2120
+ }
2121
+ if (!component) {
2122
+ return {
2123
+ content: [
2124
+ {
2125
+ type: "text",
2126
+ text: JSON.stringify({
2127
+ error: `Component not found: ${componentKey || componentName}`,
2128
+ hint: "Use figma_search_components to find available components.",
2129
+ }),
2130
+ },
2131
+ ],
2132
+ isError: true,
2133
+ };
2134
+ }
2135
+ return {
2136
+ content: [
2137
+ {
2138
+ type: "text",
2139
+ text: JSON.stringify({
2140
+ success: true,
2141
+ type: isComponentSet ? "componentSet" : "component",
2142
+ component,
2143
+ instantiation: {
2144
+ key: component.key,
2145
+ example: `Use figma_instantiate_component with componentKey: "${component.key}"`,
2146
+ },
2147
+ }),
2148
+ },
2149
+ ],
2150
+ };
2151
+ }
2152
+ catch (error) {
2153
+ logger.error({ error }, "Failed to get component details");
2154
+ return {
2155
+ content: [
2156
+ {
2157
+ type: "text",
2158
+ text: JSON.stringify({
2159
+ error: error instanceof Error ? error.message : String(error),
2160
+ }),
2161
+ },
2162
+ ],
2163
+ isError: true,
2164
+ };
2165
+ }
2166
+ });
2167
+ // Tool 4: Get Token Values (~2000 tokens response max)
2168
+ server.tool("figma_get_token_values", "Get actual values for design tokens (colors, spacing, etc). Use after figma_get_design_system_summary to get specific token values for implementation.", {
2169
+ type: z
2170
+ .enum(["colors", "spacing", "all"])
2171
+ .optional()
2172
+ .default("all")
2173
+ .describe("Type of tokens to retrieve"),
2174
+ filter: z
2175
+ .string()
2176
+ .optional()
2177
+ .describe("Filter token names (e.g., 'primary' to get all primary colors)"),
2178
+ limit: z
2179
+ .number()
2180
+ .optional()
2181
+ .default(50)
2182
+ .describe("Maximum tokens to return (default: 50)"),
2183
+ }, async ({ type, filter, limit }) => {
2184
+ try {
2185
+ // Auto-load design system cache if needed
2186
+ const { cacheEntry } = await ensureDesignSystemCache();
2187
+ if (!cacheEntry) {
2188
+ return {
2189
+ content: [
2190
+ {
2191
+ type: "text",
2192
+ text: JSON.stringify({
2193
+ error: "Could not load design system data. Make sure the Desktop Bridge plugin is running.",
2194
+ }),
2195
+ },
2196
+ ],
2197
+ isError: true,
2198
+ };
2199
+ }
2200
+ const tokens = cacheEntry.manifest.tokens;
2201
+ const effectiveLimit = Math.min(limit || 50, 100);
2202
+ const filterLower = filter?.toLowerCase();
2203
+ const result = {};
2204
+ if (type === "colors" || type === "all") {
2205
+ const colors = {};
2206
+ let count = 0;
2207
+ for (const [name, token] of Object.entries(tokens.colors)) {
2208
+ if (count >= effectiveLimit)
2209
+ break;
2210
+ if (!filterLower || name.toLowerCase().includes(filterLower)) {
2211
+ colors[name] = { value: token.value, scopes: token.scopes };
2212
+ count++;
2213
+ }
2214
+ }
2215
+ result.colors = colors;
2216
+ }
2217
+ if (type === "spacing" || type === "all") {
2218
+ const spacing = {};
2219
+ let count = 0;
2220
+ for (const [name, token] of Object.entries(tokens.spacing)) {
2221
+ if (count >= effectiveLimit)
2222
+ break;
2223
+ if (!filterLower || name.toLowerCase().includes(filterLower)) {
2224
+ spacing[name] = { value: token.value };
2225
+ count++;
2226
+ }
2227
+ }
2228
+ result.spacing = spacing;
2229
+ }
2230
+ return {
2231
+ content: [
2232
+ {
2233
+ type: "text",
2234
+ text: JSON.stringify({
2235
+ success: true,
2236
+ type,
2237
+ filter: filter || "(none)",
2238
+ tokens: result,
2239
+ hint: "Use these exact token names and values when generating designs.",
2240
+ }),
2241
+ },
2242
+ ],
2243
+ };
2244
+ }
2245
+ catch (error) {
2246
+ logger.error({ error }, "Failed to get token values");
2247
+ return {
2248
+ content: [
2249
+ {
2250
+ type: "text",
2251
+ text: JSON.stringify({
2252
+ error: error instanceof Error ? error.message : String(error),
2253
+ }),
2254
+ },
2255
+ ],
2256
+ isError: true,
2257
+ };
2258
+ }
2259
+ });
2260
+ // Tool 5: Instantiate Component
2261
+ server.tool("figma_get_library_components", `Discover published components from a shared/team library file.
2262
+
2263
+ **USE THIS when you need to use components from a published design system library** (a different file than the one currently open). This bridges the gap between library discovery and instantiation.
2264
+
2265
+ **WORKFLOW:**
2266
+ 1. Call this tool with the library file's URL or file key
2267
+ 2. Browse the returned components — results include COMPONENT_SET (with variants array) and standalone COMPONENT types
2268
+ 3. Use figma_instantiate_component with a VARIANT key (from the variants array inside a COMPONENT_SET result, NOT the component set key itself)
2269
+
2270
+ **SEARCH NOTE:** The query filter matches both component names AND descriptions. If you get unexpected results (e.g., "Accordion" when searching "Button"), verify the result name matches what you need — it may have matched on a description mention.
2271
+
2272
+ **MULTI-FILE TIP:** If you need to find a specific component and REST API search returns too many results, you can switch to the library file via figma_navigate, use figma_execute to find the exact component and its variant key, then switch back.
2273
+
2274
+ **NOTE:** Requires FIGMA_ACCESS_TOKEN to be set (uses the Figma REST API to read the library file).`, {
2275
+ libraryFileUrl: z
2276
+ .string()
2277
+ .optional()
2278
+ .describe("The URL of the library file (e.g., https://www.figma.com/design/abc123/My-Design-System). Either this or libraryFileKey is required."),
2279
+ libraryFileKey: z
2280
+ .string()
2281
+ .optional()
2282
+ .describe("The file key of the library file (e.g., 'abc123'). Either this or libraryFileUrl is required."),
2283
+ query: z
2284
+ .string()
2285
+ .optional()
2286
+ .describe("Search query to filter components by name (e.g., 'Button', 'Card'). Leave empty to get all components."),
2287
+ limit: z
2288
+ .number()
2289
+ .optional()
2290
+ .default(25)
2291
+ .describe("Maximum results to return (default: 25, max: 100)"),
2292
+ offset: z
2293
+ .number()
2294
+ .optional()
2295
+ .default(0)
2296
+ .describe("Offset for pagination"),
2297
+ includeVariants: z
2298
+ .boolean()
2299
+ .optional()
2300
+ .default(false)
2301
+ .describe("Include individual variant components (default: false, only returns component sets)"),
2302
+ }, async ({ libraryFileUrl, libraryFileKey, query, limit, offset, includeVariants, }) => {
2303
+ try {
2304
+ // Resolve file key from URL or direct key
2305
+ let fileKey = libraryFileKey;
2306
+ if (!fileKey && libraryFileUrl) {
2307
+ const { extractFileKey } = await import("./core/figma-api.js");
2308
+ fileKey = extractFileKey(libraryFileUrl) ?? undefined;
2309
+ if (!fileKey) {
2310
+ return {
2311
+ content: [
2312
+ {
2313
+ type: "text",
2314
+ text: JSON.stringify({
2315
+ error: "Could not extract file key from URL. Please provide a valid Figma file URL or use libraryFileKey directly.",
2316
+ example: "https://www.figma.com/design/abc123/My-Design-System",
2317
+ }),
2318
+ },
2319
+ ],
2320
+ isError: true,
2321
+ };
2322
+ }
2323
+ }
2324
+ if (!fileKey) {
2325
+ return {
2326
+ content: [
2327
+ {
2328
+ type: "text",
2329
+ text: JSON.stringify({
2330
+ error: "Either libraryFileUrl or libraryFileKey is required.",
2331
+ hint: "Provide the URL of your design system file, e.g., https://www.figma.com/design/abc123/My-Design-System",
2332
+ }),
2333
+ },
2334
+ ],
2335
+ isError: true,
2336
+ };
2337
+ }
2338
+ // Use REST API to get published components from the library file
2339
+ const api = await this.getFigmaAPI();
2340
+ // Fetch both components and component sets in parallel
2341
+ // Surface errors instead of swallowing them — token/scope issues need to be visible
2342
+ const apiErrors = [];
2343
+ const [componentsResponse, componentSetsResponse] = await Promise.all([
2344
+ api.getComponents(fileKey).catch((err) => {
2345
+ const msg = err.message || String(err);
2346
+ logger.warn({ error: err }, "Failed to fetch components from library");
2347
+ apiErrors.push(`Components API: ${msg}`);
2348
+ return { meta: { components: [] } };
2349
+ }),
2350
+ api.getComponentSets(fileKey).catch((err) => {
2351
+ const msg = err.message || String(err);
2352
+ logger.warn({ error: err }, "Failed to fetch component sets from library");
2353
+ apiErrors.push(`Component Sets API: ${msg}`);
2354
+ return { meta: { component_sets: [] } };
2355
+ }),
2356
+ ]);
2357
+ const rawComponents = componentsResponse?.meta?.components || [];
2358
+ const rawComponentSets = componentSetsResponse?.meta?.component_sets || [];
2359
+ // Helper: check if a component belongs to a given component set
2360
+ // REST API returns containingComponentSet as an object { name, nodeId }
2361
+ const isVariantOf = (c, csNodeId) => {
2362
+ const ccs = c.containing_frame?.containingComponentSet;
2363
+ if (ccs && typeof ccs === "object" && ccs.nodeId === csNodeId)
2364
+ return true;
2365
+ if (ccs && c.containing_frame?.nodeId === csNodeId)
2366
+ return true;
2367
+ if (c.component_set_id === csNodeId)
2368
+ return true;
2369
+ return false;
2370
+ };
2371
+ const isVariant = (c) => {
2372
+ return !!(c.containing_frame?.containingComponentSet || c.component_set_id);
2373
+ };
2374
+ const getParentSetName = (c) => {
2375
+ const ccs = c.containing_frame?.containingComponentSet;
2376
+ if (ccs && typeof ccs === "object" && ccs.name)
2377
+ return ccs.name;
2378
+ return c.containing_frame?.name || c.component_set_name || undefined;
2379
+ };
2380
+ // Process component sets (groups of variants)
2381
+ const componentSets = rawComponentSets.map((cs) => {
2382
+ const variants = rawComponents.filter((c) => isVariantOf(c, cs.node_id));
2383
+ return {
2384
+ name: cs.name,
2385
+ key: cs.key,
2386
+ nodeId: cs.node_id,
2387
+ description: cs.description || undefined,
2388
+ type: "COMPONENT_SET",
2389
+ variantCount: variants.length,
2390
+ variants: variants.map((v) => ({
2391
+ name: v.name,
2392
+ key: v.key,
2393
+ nodeId: v.node_id,
2394
+ })),
2395
+ };
2396
+ });
2397
+ // Process standalone components (not part of a set)
2398
+ const standaloneComponents = rawComponents
2399
+ .filter((c) => !isVariant(c))
2400
+ .map((c) => ({
2401
+ name: c.name,
2402
+ key: c.key,
2403
+ nodeId: c.node_id,
2404
+ description: c.description || undefined,
2405
+ type: "COMPONENT",
2406
+ }));
2407
+ // Combine results
2408
+ let allResults = [
2409
+ ...componentSets,
2410
+ ...standaloneComponents,
2411
+ ];
2412
+ // Include individual variants if requested
2413
+ if (includeVariants) {
2414
+ const variantComponents = rawComponents
2415
+ .filter((c) => isVariant(c))
2416
+ .map((c) => ({
2417
+ name: c.name,
2418
+ key: c.key,
2419
+ nodeId: c.node_id,
2420
+ description: c.description || undefined,
2421
+ type: "VARIANT",
2422
+ parentSetName: getParentSetName(c),
2423
+ }));
2424
+ allResults = [...allResults, ...variantComponents];
2425
+ }
2426
+ // Apply search filter
2427
+ if (query) {
2428
+ const queryLower = query.toLowerCase();
2429
+ allResults = allResults.filter((item) => item.name
2430
+ .toLowerCase()
2431
+ .includes(queryLower) ||
2432
+ item.description
2433
+ ?.toLowerCase()
2434
+ .includes(queryLower));
2435
+ }
2436
+ // Sort by name for consistent results
2437
+ allResults.sort((a, b) => a.name.localeCompare(b.name));
2438
+ // Apply pagination
2439
+ const effectiveLimit = Math.min(limit || 25, 100);
2440
+ const effectiveOffset = offset || 0;
2441
+ const total = allResults.length;
2442
+ const paginatedResults = allResults.slice(effectiveOffset, effectiveOffset + effectiveLimit);
2443
+ const hasMore = effectiveOffset + effectiveLimit < total;
2444
+ return {
2445
+ content: [
2446
+ {
2447
+ type: "text",
2448
+ text: JSON.stringify({
2449
+ success: apiErrors.length === 0,
2450
+ libraryFileKey: fileKey,
2451
+ query: query || "(all)",
2452
+ ...(apiErrors.length > 0 && {
2453
+ apiErrors,
2454
+ hint: "REST API errors occurred. Check that FIGMA_ACCESS_TOKEN is valid and has file_content:read scope. If the token is correct, the library components may not be published to a team library.",
2455
+ }),
2456
+ summary: {
2457
+ totalComponentSets: componentSets.length,
2458
+ totalStandaloneComponents: standaloneComponents.length,
2459
+ totalComponents: rawComponents.length,
2460
+ },
2461
+ results: paginatedResults,
2462
+ pagination: {
2463
+ offset: effectiveOffset,
2464
+ limit: effectiveLimit,
2465
+ total,
2466
+ hasMore,
2467
+ },
2468
+ usage: {
2469
+ instantiate: `To use a component: call figma_instantiate_component with a VARIANT key (not the component set key). For COMPONENT_SET results, pick a variant from the "variants" array.`,
2470
+ example: (() => {
2471
+ const first = paginatedResults[0];
2472
+ if (!first)
2473
+ return undefined;
2474
+ if (first.type === "COMPONENT_SET" && first.variants?.length > 0) {
2475
+ return `figma_instantiate_component({ componentKey: "${first.variants[0].key}" }) — using first variant of "${first.name}"`;
2476
+ }
2477
+ return `figma_instantiate_component({ componentKey: "${first.key}" })`;
2478
+ })(),
2479
+ note: "IMPORTANT: Use variant keys (type COMPONENT), not component set keys (type COMPONENT_SET). Component set keys will fail. Also pre-load any custom fonts the component uses via figma_execute before instantiating.",
2480
+ },
2481
+ }),
2482
+ },
2483
+ ],
2484
+ };
2485
+ }
2486
+ catch (error) {
2487
+ const errorMessage = error instanceof Error ? error.message : String(error);
2488
+ logger.error({ error }, "Failed to get library components");
2489
+ // Provide helpful guidance based on error type
2490
+ let hint = "Make sure FIGMA_ACCESS_TOKEN is set and the library file key is correct.";
2491
+ if (errorMessage.includes("FIGMA_ACCESS_TOKEN")) {
2492
+ hint =
2493
+ "Set FIGMA_ACCESS_TOKEN environment variable with your Figma personal access token. Get one at: https://www.figma.com/developers/api#access-tokens";
2494
+ }
2495
+ else if (errorMessage.includes("403") ||
2496
+ errorMessage.includes("Forbidden")) {
2497
+ hint =
2498
+ "Access denied. Make sure your Figma token has access to this file and the file's library is published.";
2499
+ }
2500
+ else if (errorMessage.includes("404") ||
2501
+ errorMessage.includes("Not found")) {
2502
+ hint =
2503
+ "File not found. Check the file URL/key and make sure the file exists and you have access to it.";
2504
+ }
2505
+ else if (errorMessage.includes("429") ||
2506
+ errorMessage.includes("Rate")) {
2507
+ hint =
2508
+ "Rate limited by Figma API. Wait a moment and try again, or reduce the number of requests.";
2509
+ }
2510
+ return {
2511
+ content: [
2512
+ {
2513
+ type: "text",
2514
+ text: JSON.stringify({
2515
+ error: errorMessage,
2516
+ hint,
2517
+ }),
2518
+ },
2519
+ ],
2520
+ isError: true,
2521
+ };
2522
+ }
2523
+ });
2524
+ // ============================================================================
2525
+ // NEW: Component Property Management Tools
2526
+ // ============================================================================
2527
+ // Tool: Set Node Description
2528
+ // Register all write/manipulation tools (figma_execute, variable CRUD, node mutations,
2529
+ // design-token setup, accessibility audits, etc.). Sourced from src/core/write-tools.ts
2530
+ // so local mode and cloud mode share the same 30 implementations — no risk of drift.
2531
+ registerWriteTools(server, () => this.getDesktopConnector());
2532
+ // Register token sync tools — figma_export_tokens and figma_import_tokens.
2533
+ // Replace Style Dictionary and Tokens Studio's export pipeline for the
2534
+ // popular styling methods (DTCG canonical — legacy + 2025.10 dialects —
2535
+ // plus CSS/Tailwind/SCSS/TS/JSON/Style Dictionary/Tokens Studio, all
2536
+ // derived from a single internal token model).
2537
+ registerTokensTools(server, () => this.getDesktopConnector());
2538
+ // Register Figma API tools (Tools 8-11)
2539
+ registerFigmaAPITools(server, () => this.getFigmaAPI(), () => this.getCurrentFileUrl(), this.variablesCache, // Pass cache for efficient variable queries
2540
+ undefined, // options (use default)
2541
+ () => this.getDesktopConnector());
2542
+ // Register Design-Code Parity & Documentation tools
2543
+ registerDesignCodeTools(server, () => this.getFigmaAPI(), () => this.getCurrentFileUrl(), this.variablesCache, undefined, // options
2544
+ () => this.getDesktopConnector());
2545
+ // Register Comment tools
2546
+ registerCommentTools(server, () => this.getFigmaAPI(), () => this.getCurrentFileUrl());
2547
+ // Register Version History tools
2548
+ registerVersionTools(server, () => this.getFigmaAPI(), () => this.getCurrentFileUrl(), undefined, // options
2549
+ () => {
2550
+ // Selection fallback for blame/diff/changelog tools
2551
+ const sel = this.wsServer?.getCurrentSelection();
2552
+ return sel?.nodes?.map((n) => n.id) ?? null;
2553
+ },
2554
+ // v1.25.0: metadata-change buffer reader. Surfaces description/annotation
2555
+ // edits captured by the Desktop Bridge plugin while it was connected.
2556
+ // Returns [] if the WebSocket server isn't running.
2557
+ (opts) => {
2558
+ if (!this.wsServer)
2559
+ return [];
2560
+ return this.wsServer.getMetadataChanges(opts);
2561
+ });
2562
+ // Register Design System Kit tool
2563
+ registerDesignSystemTools(server, () => this.getFigmaAPI(), () => this.getCurrentFileUrl(), this.variablesCache, undefined, // options (use default)
2564
+ () => this.getDesktopConnector());
2565
+ // Register Library Tools (key-based component inspection across shared libraries)
2566
+ registerLibraryTools(server, () => this.getFigmaAPI());
2567
+ // Register Library Variable Tools (Plugin-API based — list + import variables
2568
+ // from subscribed team libraries; works on every Figma plan, no Enterprise needed)
2569
+ registerLibraryVariableTools(server, () => this.getDesktopConnector());
2570
+ // Register code-side accessibility scanning (axe-core + JSDOM)
2571
+ registerAccessibilityTools(server);
2572
+ // Register figma_diagnose — designer-readable health check + cross-MCP disambiguator.
2573
+ // This is the first tool to point a confused user at: it self-identifies the server,
2574
+ // reports plugin/token state in plain language, and explicitly disclaims any
2575
+ // token/OAuth error that may have been emitted by a different Figma-related MCP.
2576
+ registerDiagnoseTool(server, {
2577
+ mode: "local",
2578
+ getServerVersion: () => {
2579
+ try {
2580
+ return JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf-8")).version;
2581
+ }
2582
+ catch {
2583
+ return "0.0.0";
2584
+ }
2585
+ },
2586
+ getBundledPluginVersion,
2587
+ getPluginState: () => {
2588
+ if (!this.wsServer)
2589
+ return null;
2590
+ const fileInfo = this.wsServer.getConnectedFileInfo();
2591
+ const connected = this.wsServer.isClientConnected();
2592
+ return {
2593
+ connected,
2594
+ fileName: fileInfo?.fileName,
2595
+ fileKey: fileInfo?.fileKey ?? undefined,
2596
+ currentPage: fileInfo?.currentPage,
2597
+ editorType: fileInfo?.editorType,
2598
+ port: this.wsActualPort ?? undefined,
2599
+ portFallbackFrom: this.wsPreferredPort,
2600
+ pluginVersion: fileInfo?.pluginVersion,
2601
+ pluginUpdateAvailable: fileInfo?.pluginUpdateAvailable,
2602
+ };
2603
+ },
2604
+ getTokenState: () => {
2605
+ const hasToken = !!process.env.FIGMA_ACCESS_TOKEN;
2606
+ return { hasToken, source: hasToken ? "env" : undefined };
2607
+ },
2608
+ });
2609
+ // Register Annotation tools (read/write design annotations via Desktop Bridge)
2610
+ registerAnnotationTools(server, () => this.getDesktopConnector());
2611
+ // Register Deep Component tools (full Plugin API tree extraction for code generation)
2612
+ registerDeepComponentTools(server, () => this.getDesktopConnector());
2613
+ // Register FigJam-specific tools (sticky notes, connectors, tables, etc.)
2614
+ registerFigJamTools(server, () => this.getDesktopConnector());
2615
+ // Register Figma Slides tools (slide management, transitions, content)
2616
+ registerSlidesTools(server, () => this.getDesktopConnector());
2617
+ registerSlotTools(server, () => this.getDesktopConnector());
2618
+ // MCP Apps - gated behind ENABLE_MCP_APPS env var
2619
+ if (process.env.ENABLE_MCP_APPS === "true") {
2620
+ registerTokenBrowserApp(server, async (fileUrl) => {
2621
+ const url = fileUrl || this.getCurrentFileUrl();
2622
+ if (!url) {
2623
+ throw new Error("No Figma file URL available. Either pass a fileUrl, call figma_navigate, or ensure the Desktop Bridge plugin is connected.");
2624
+ }
2625
+ const urlInfo = extractFigmaUrlInfo(url);
2626
+ if (!urlInfo) {
2627
+ throw new Error(`Invalid Figma URL: ${url}`);
2628
+ }
2629
+ const fileKey = urlInfo.branchId || urlInfo.fileKey;
2630
+ // Fetch file info for display (non-blocking, best-effort)
2631
+ let fileInfo;
2632
+ try {
2633
+ const api = await this.getFigmaAPI();
2634
+ const fileData = await api.getFile(fileKey, { depth: 0 });
2635
+ if (fileData?.name) {
2636
+ fileInfo = { name: fileData.name };
2637
+ }
2638
+ }
2639
+ catch {
2640
+ // Fall back to extracting name from URL
2641
+ try {
2642
+ const urlObj = new URL(url);
2643
+ const segments = urlObj.pathname.split("/").filter(Boolean);
2644
+ const branchIdx = segments.indexOf("branch");
2645
+ const nameSegment = branchIdx >= 0
2646
+ ? segments[branchIdx + 2]
2647
+ : segments.length >= 3
2648
+ ? segments[2]
2649
+ : undefined;
2650
+ if (nameSegment) {
2651
+ fileInfo = {
2652
+ name: decodeURIComponent(nameSegment).replace(/-/g, " "),
2653
+ };
2654
+ }
2655
+ }
2656
+ catch {
2657
+ // Leave fileInfo undefined
2658
+ }
2659
+ }
2660
+ // Check cache first (works for both Desktop Bridge and REST API data)
2661
+ const cacheEntry = this.variablesCache.get(fileKey);
2662
+ if (cacheEntry && Date.now() - cacheEntry.timestamp < 5 * 60 * 1000) {
2663
+ const cached = cacheEntry.data;
2664
+ // Desktop Bridge caches arrays directly; REST API data needs formatVariables
2665
+ if (Array.isArray(cached.variables)) {
2666
+ return {
2667
+ variables: cached.variables,
2668
+ collections: cached.variableCollections || [],
2669
+ fileInfo,
2670
+ };
2671
+ }
2672
+ const formatted = formatVariables(cached);
2673
+ return {
2674
+ variables: formatted.variables,
2675
+ collections: formatted.collections,
2676
+ fileInfo,
2677
+ };
2678
+ }
2679
+ // Priority 1: Try Desktop Bridge via transport-agnostic connector
2680
+ try {
2681
+ const connector = await this.getDesktopConnector();
2682
+ const desktopResult = await connector.getVariablesFromPluginUI(fileKey);
2683
+ if (desktopResult.success && desktopResult.variables) {
2684
+ // Cache the desktop result
2685
+ this.variablesCache.set(fileKey, {
2686
+ data: {
2687
+ variables: desktopResult.variables,
2688
+ variableCollections: desktopResult.variableCollections,
2689
+ },
2690
+ timestamp: Date.now(),
2691
+ });
2692
+ return {
2693
+ variables: desktopResult.variables,
2694
+ collections: desktopResult.variableCollections || [],
2695
+ fileInfo,
2696
+ };
2697
+ }
2698
+ }
2699
+ catch (desktopErr) {
2700
+ logger.warn({
2701
+ error: desktopErr instanceof Error
2702
+ ? desktopErr.message
2703
+ : String(desktopErr),
2704
+ }, "Desktop Bridge failed for token browser, trying REST API");
2705
+ }
2706
+ // Priority 2: Fall back to REST API (requires Enterprise plan)
2707
+ const api = await this.getFigmaAPI();
2708
+ const { local, localError } = await api.getAllVariables(fileKey);
2709
+ if (localError) {
2710
+ throw new Error(`Could not fetch variables. Desktop Bridge unavailable and REST API returned: ${localError}`);
2711
+ }
2712
+ // Cache raw REST API data
2713
+ this.variablesCache.set(fileKey, {
2714
+ data: local,
2715
+ timestamp: Date.now(),
2716
+ });
2717
+ const formatted = formatVariables(local);
2718
+ return {
2719
+ variables: formatted.variables,
2720
+ collections: formatted.collections,
2721
+ fileInfo,
2722
+ };
2723
+ });
2724
+ registerDesignSystemDashboardApp(server, async (fileUrl) => {
2725
+ const url = fileUrl || this.getCurrentFileUrl();
2726
+ if (!url) {
2727
+ throw new Error("No Figma file URL available. Either pass a fileUrl, call figma_navigate, or ensure the Desktop Bridge plugin is connected.");
2728
+ }
2729
+ const urlInfo = extractFigmaUrlInfo(url);
2730
+ if (!urlInfo) {
2731
+ throw new Error(`Invalid Figma URL: ${url}`);
2732
+ }
2733
+ const fileKey = urlInfo.branchId || urlInfo.fileKey;
2734
+ // Track data availability for transparent scoring
2735
+ let variablesAvailable = false;
2736
+ let variableError;
2737
+ let desktopBridgeAttempted = false;
2738
+ let desktopBridgeFailed = false;
2739
+ let restApiAttempted = false;
2740
+ let restApiFailed = false;
2741
+ // Fetch variables + collections
2742
+ // Fallback chain: Cache → Desktop Bridge → REST API → Actionable error
2743
+ let variables = [];
2744
+ let collections = [];
2745
+ // 1. Check cache first
2746
+ const cacheEntry = this.variablesCache.get(fileKey);
2747
+ if (cacheEntry && Date.now() - cacheEntry.timestamp < 5 * 60 * 1000) {
2748
+ const cached = cacheEntry.data;
2749
+ if (Array.isArray(cached.variables)) {
2750
+ variables = cached.variables;
2751
+ collections = cached.variableCollections || [];
2752
+ }
2753
+ else {
2754
+ const formatted = formatVariables(cached);
2755
+ variables = formatted.variables;
2756
+ collections = formatted.collections;
2757
+ }
2758
+ variablesAvailable = variables.length > 0;
2759
+ }
2760
+ // 2. Try Desktop Bridge via transport-agnostic connector
2761
+ if (variables.length === 0) {
2762
+ desktopBridgeAttempted = true;
2763
+ try {
2764
+ const connector = await this.getDesktopConnector();
2765
+ const desktopResult = await connector.getVariablesFromPluginUI(fileKey);
2766
+ if (desktopResult.success && desktopResult.variables) {
2767
+ this.variablesCache.set(fileKey, {
2768
+ data: {
2769
+ variables: desktopResult.variables,
2770
+ variableCollections: desktopResult.variableCollections,
2771
+ },
2772
+ timestamp: Date.now(),
2773
+ });
2774
+ variables = desktopResult.variables;
2775
+ collections = desktopResult.variableCollections || [];
2776
+ variablesAvailable = true;
2777
+ }
2778
+ else {
2779
+ desktopBridgeFailed = true;
2780
+ }
2781
+ }
2782
+ catch (desktopErr) {
2783
+ desktopBridgeFailed = true;
2784
+ logger.warn({
2785
+ error: desktopErr instanceof Error
2786
+ ? desktopErr.message
2787
+ : String(desktopErr),
2788
+ }, "Desktop Bridge failed for dashboard, trying REST API for variables");
2789
+ }
2790
+ }
2791
+ // 3. Try REST API (works only with Enterprise plan)
2792
+ if (variables.length === 0) {
2793
+ restApiAttempted = true;
2794
+ try {
2795
+ const api = await this.getFigmaAPI();
2796
+ const { local, localError } = await api.getAllVariables(fileKey);
2797
+ if (!localError && local) {
2798
+ this.variablesCache.set(fileKey, {
2799
+ data: local,
2800
+ timestamp: Date.now(),
2801
+ });
2802
+ const formatted = formatVariables(local);
2803
+ variables = formatted.variables;
2804
+ collections = formatted.collections;
2805
+ variablesAvailable = true;
2806
+ }
2807
+ else {
2808
+ restApiFailed = true;
2809
+ }
2810
+ }
2811
+ catch (varErr) {
2812
+ restApiFailed = true;
2813
+ logger.warn({
2814
+ error: varErr instanceof Error ? varErr.message : String(varErr),
2815
+ }, "REST API variable fetch failed for dashboard");
2816
+ }
2817
+ }
2818
+ // 4. Build actionable error message based on what was tried
2819
+ if (!variablesAvailable) {
2820
+ if (desktopBridgeFailed && restApiFailed) {
2821
+ variableError =
2822
+ "Desktop Bridge plugin not connected and REST API requires Enterprise plan. Please open the Desktop Bridge plugin in Figma to enable variable/token analysis.";
2823
+ }
2824
+ else if (desktopBridgeFailed) {
2825
+ variableError =
2826
+ "Desktop Bridge plugin not connected. Please open the Desktop Bridge plugin in Figma to enable variable/token analysis.";
2827
+ }
2828
+ else if (restApiFailed) {
2829
+ variableError =
2830
+ "REST API requires Figma Enterprise plan. Connect the Desktop Bridge plugin in Figma for variable/token access.";
2831
+ }
2832
+ else if (!desktopBridgeAttempted && !restApiAttempted) {
2833
+ variableError =
2834
+ "No variable fetch methods available. Connect the Desktop Bridge plugin in Figma.";
2835
+ }
2836
+ }
2837
+ // Fetch file metadata, components, component sets, and styles via REST API
2838
+ let fileInfo;
2839
+ let components = [];
2840
+ let componentSets = [];
2841
+ let styles = [];
2842
+ try {
2843
+ const api = await this.getFigmaAPI();
2844
+ const [fileData, compResult, compSetResult, styleResult] = await Promise.all([
2845
+ api.getFile(fileKey, { depth: 0 }).catch(() => null),
2846
+ api
2847
+ .getComponents(fileKey)
2848
+ .catch(() => ({ meta: { components: [] } })),
2849
+ api
2850
+ .getComponentSets(fileKey)
2851
+ .catch(() => ({ meta: { component_sets: [] } })),
2852
+ api.getStyles(fileKey).catch(() => ({ meta: { styles: [] } })),
2853
+ ]);
2854
+ if (fileData) {
2855
+ fileInfo = {
2856
+ name: fileData.name || "Unknown",
2857
+ lastModified: fileData.lastModified || "",
2858
+ version: fileData.version,
2859
+ thumbnailUrl: fileData.thumbnailUrl,
2860
+ };
2861
+ }
2862
+ components = compResult?.meta?.components || [];
2863
+ componentSets = compSetResult?.meta?.component_sets || [];
2864
+ styles = styleResult?.meta?.styles || [];
2865
+ }
2866
+ catch (apiErr) {
2867
+ logger.warn({
2868
+ error: apiErr instanceof Error ? apiErr.message : String(apiErr),
2869
+ }, "REST API fetch failed for dashboard");
2870
+ }
2871
+ // Fallback: extract file name from URL if getFile failed
2872
+ if (!fileInfo) {
2873
+ try {
2874
+ const urlObj = new URL(url);
2875
+ const segments = urlObj.pathname.split("/").filter(Boolean);
2876
+ // /design/KEY/File-Name or /design/KEY/branch/BRANCHKEY/File-Name
2877
+ const branchIdx = segments.indexOf("branch");
2878
+ const nameSegment = branchIdx >= 0
2879
+ ? segments[branchIdx + 2]
2880
+ : segments.length >= 3
2881
+ ? segments[2]
2882
+ : undefined;
2883
+ if (nameSegment) {
2884
+ fileInfo = {
2885
+ name: decodeURIComponent(nameSegment).replace(/-/g, " "),
2886
+ lastModified: "",
2887
+ };
2888
+ }
2889
+ }
2890
+ catch {
2891
+ // URL parsing failed — leave fileInfo undefined
2892
+ }
2893
+ }
2894
+ return {
2895
+ variables,
2896
+ collections,
2897
+ components,
2898
+ styles,
2899
+ componentSets,
2900
+ fileInfo,
2901
+ dataAvailability: {
2902
+ variables: variablesAvailable,
2903
+ collections: variablesAvailable,
2904
+ components: components.length > 0,
2905
+ styles: styles.length > 0,
2906
+ variableError,
2907
+ },
2908
+ };
2909
+ },
2910
+ // Pass getCurrentUrl so dashboard can track which file was audited
2911
+ () => this.getCurrentFileUrl());
2912
+ logger.info("MCP Apps registered (ENABLE_MCP_APPS=true)");
2913
+ }
2914
+ logger.info("All MCP tools registered successfully (including write operations)");
2915
+ }
2916
+ /**
2917
+ * Start an optional Streamable HTTP MCP server.
2918
+ * Only starts if MCP_HTTP_PORT env var is set. This provides an HTTP-based
2919
+ * transport alongside the default stdio transport, useful for HTTP-native
2920
+ * clients (Cursor, web UIs, etc.).
2921
+ */
2922
+ async startHttpServer() {
2923
+ const portStr = process.env.MCP_HTTP_PORT;
2924
+ if (!portStr)
2925
+ return;
2926
+ const port = parseInt(portStr, 10);
2927
+ if (isNaN(port) || port < 1 || port > 65535) {
2928
+ logger.warn({ portStr }, "Invalid MCP_HTTP_PORT, skipping HTTP server");
2929
+ return;
2930
+ }
2931
+ this.httpServer = createServer(async (req, res) => {
2932
+ const url = new URL(req.url ?? "/", `http://localhost:${port}`);
2933
+ if (url.pathname === "/mcp") {
2934
+ try {
2935
+ // The MCP SDK enforces one-request-per-transport in stateless mode
2936
+ // ("Stateless transport cannot be reused across requests"), so we
2937
+ // create a fresh transport + server per request — same pattern as
2938
+ // the Cloudflare entry point (src/index.ts).
2939
+ const transport = new StreamableHTTPServerTransport({
2940
+ sessionIdGenerator: undefined,
2941
+ });
2942
+ const reqServer = new McpServer({ name: "Figma Console MCP (Local)", version: "0.1.0" }, { instructions: MCP_SERVER_INSTRUCTIONS });
2943
+ this.registerTools(reqServer);
2944
+ wrapServerForIdentity(reqServer);
2945
+ await reqServer.connect(transport);
2946
+ await transport.handleRequest(req, res);
2947
+ }
2948
+ catch (error) {
2949
+ logger.error({ error }, "Error handling HTTP MCP request");
2950
+ if (!res.headersSent) {
2951
+ res.writeHead(500, { "Content-Type": "application/json" });
2952
+ res.end(JSON.stringify({ error: "Internal server error" }));
2953
+ }
2954
+ }
2955
+ return;
2956
+ }
2957
+ if (url.pathname === "/" && req.method === "GET") {
2958
+ res.writeHead(200, { "Content-Type": "application/json" });
2959
+ res.end(JSON.stringify({
2960
+ status: "ok",
2961
+ mode: "local",
2962
+ mcpEndpoint: "/mcp",
2963
+ wsPort: this.wsActualPort,
2964
+ mcpHttpPort: port,
2965
+ }));
2966
+ return;
2967
+ }
2968
+ res.writeHead(404);
2969
+ res.end("Not Found");
2970
+ });
2971
+ await new Promise((resolve, reject) => {
2972
+ this.httpServer.once("error", reject);
2973
+ this.httpServer.listen(port, "127.0.0.1", () => {
2974
+ this.httpServer.removeListener("error", reject);
2975
+ this.mcpHttpPort = port;
2976
+ logger.info({ port }, `Streamable HTTP MCP listening on http://localhost:${port}/mcp`);
2977
+ resolve();
2978
+ });
2979
+ });
2980
+ }
2981
+ /**
2982
+ * Start the MCP server
2983
+ */
2984
+ async start() {
2985
+ try {
2986
+ logger.info({ config: this.config }, "Starting Figma Console MCP (Local Mode)");
2987
+ // Copy plugin files to stable directory (~/.figma-console-mcp/plugin/)
2988
+ // so users have a permanent import path that survives npx cache changes.
2989
+ try {
2990
+ const thisFile = fileURLToPath(import.meta.url);
2991
+ const packageRoot = dirname(dirname(thisFile));
2992
+ const sourcePluginDir = resolve(packageRoot, "figma-desktop-bridge");
2993
+ if (existsSync(sourcePluginDir)) {
2994
+ this.stablePluginPath = setupStablePluginDir(sourcePluginDir);
2995
+ }
2996
+ }
2997
+ catch {
2998
+ // Non-critical — stable dir is a convenience feature
2999
+ }
3000
+ // Start WebSocket bridge server with port range fallback.
3001
+ // If the preferred port is taken (e.g., Claude Desktop Chat tab already bound it),
3002
+ // try subsequent ports in the range (9223-9232) so multiple instances can coexist.
3003
+ const wsHost = process.env.FIGMA_WS_HOST || 'localhost';
3004
+ this.wsPreferredPort = parseInt(process.env.FIGMA_WS_PORT || String(DEFAULT_WS_PORT), 10);
3005
+ // Clean up stale/orphaned MCP server instances before trying to bind.
3006
+ // Step 1: Remove stale port files and terminate zombie processes that have port files
3007
+ cleanupStalePortFiles();
3008
+ // Step 2: Deep scan for orphaned processes holding ports WITHOUT port files
3009
+ // (e.g., old instances from before port file tracking, or files already cleaned up)
3010
+ cleanupOrphanedProcesses(this.wsPreferredPort);
3011
+ const portsToTry = getPortRange(this.wsPreferredPort);
3012
+ let boundPort = null;
3013
+ // Distinguish "every port was in use" from "bind failed for another
3014
+ // reason" — eviction must only fire for genuine port exhaustion, and
3015
+ // figma_get_status must report the real error code.
3016
+ let lastNonPortError = null;
3017
+ for (const port of portsToTry) {
3018
+ try {
3019
+ this.wsServer = new FigmaWebSocketServer({ port, host: wsHost });
3020
+ await this.wsServer.start();
3021
+ // Get the actual bound port (should match, but verify)
3022
+ const addr = this.wsServer.address();
3023
+ boundPort = addr?.port ?? port;
3024
+ this.wsActualPort = boundPort;
3025
+ if (boundPort !== this.wsPreferredPort) {
3026
+ logger.info({ preferredPort: this.wsPreferredPort, actualPort: boundPort }, "Preferred WebSocket port was in use, bound to fallback port");
3027
+ }
3028
+ else {
3029
+ logger.info({ wsPort: boundPort }, "WebSocket bridge server started");
3030
+ }
3031
+ // Advertise the port so the Figma plugin and other tools can discover us
3032
+ advertisePort(boundPort, wsHost);
3033
+ registerPortCleanup(boundPort);
3034
+ // Start heartbeat — periodically refresh the port file to prove this server is active.
3035
+ // Other instances use this to detect zombie processes on startup.
3036
+ const heartbeatPort = boundPort;
3037
+ this.wsHeartbeatTimer = setInterval(() => refreshPortAdvertisement(heartbeatPort), HEARTBEAT_INTERVAL_MS);
3038
+ this.wsHeartbeatTimer.unref(); // Don't prevent process exit
3039
+ break;
3040
+ }
3041
+ catch (wsError) {
3042
+ const errorMsg = wsError instanceof Error ? wsError.message : String(wsError);
3043
+ const errorCode = wsError instanceof Error ? wsError.code : undefined;
3044
+ if (errorCode === "EADDRINUSE" || errorMsg.includes("EADDRINUSE")) {
3045
+ logger.debug({ port, error: errorMsg }, "Port in use, trying next in range");
3046
+ this.wsServer = null;
3047
+ continue;
3048
+ }
3049
+ // Non-port-conflict error — don't try more ports
3050
+ logger.warn({ error: errorMsg, port }, "Failed to start WebSocket bridge server");
3051
+ lastNonPortError = {
3052
+ code: errorCode || "UNKNOWN",
3053
+ message: errorMsg,
3054
+ };
3055
+ this.wsServer = null;
3056
+ break;
3057
+ }
3058
+ }
3059
+ // Phase 3: If all ports exhausted, try evicting the oldest instance and
3060
+ // retry ONCE. Only for genuine EADDRINUSE exhaustion — killing a healthy
3061
+ // sibling can't fix an EACCES/EADDRNOTAVAIL bind failure.
3062
+ if (!boundPort && !lastNonPortError && evictOldestInstance(this.wsPreferredPort)) {
3063
+ for (const port of portsToTry) {
3064
+ try {
3065
+ this.wsServer = new FigmaWebSocketServer({ port, host: wsHost });
3066
+ await this.wsServer.start();
3067
+ const addr = this.wsServer.address();
3068
+ boundPort = addr?.port ?? port;
3069
+ this.wsActualPort = boundPort;
3070
+ logger.info({ wsPort: boundPort, eviction: true }, "WebSocket bridge server started after evicting stale instance");
3071
+ advertisePort(boundPort, wsHost);
3072
+ registerPortCleanup(boundPort);
3073
+ const heartbeatPort = boundPort;
3074
+ this.wsHeartbeatTimer = setInterval(() => refreshPortAdvertisement(heartbeatPort), HEARTBEAT_INTERVAL_MS);
3075
+ this.wsHeartbeatTimer.unref();
3076
+ break;
3077
+ }
3078
+ catch (wsError) {
3079
+ const errorCode = wsError instanceof Error ? wsError.code : undefined;
3080
+ if (errorCode === "EADDRINUSE") {
3081
+ this.wsServer = null;
3082
+ continue;
3083
+ }
3084
+ this.wsServer = null;
3085
+ break;
3086
+ }
3087
+ }
3088
+ }
3089
+ if (!boundPort) {
3090
+ this.wsStartupError = {
3091
+ code: lastNonPortError?.code ?? "EADDRINUSE",
3092
+ port: this.wsPreferredPort,
3093
+ };
3094
+ const rangeEnd = this.wsPreferredPort + portsToTry.length - 1;
3095
+ logger.warn(lastNonPortError
3096
+ ? { error: lastNonPortError }
3097
+ : { portRange: `${this.wsPreferredPort}-${rangeEnd}` }, lastNonPortError
3098
+ ? "WebSocket bridge failed to start (non-port error) — running without WebSocket transport"
3099
+ : "All WebSocket ports in range are in use — running without WebSocket transport");
3100
+ }
3101
+ if (this.wsServer) {
3102
+ // Log when plugin files connect/disconnect (with file identity)
3103
+ this.wsServer.on("fileConnected", (data) => {
3104
+ logger.info({ fileKey: data.fileKey, fileName: data.fileName }, "Desktop Bridge plugin connected via WebSocket");
3105
+ });
3106
+ // Plugin disconnect leaves cached variables stale — when the plugin reconnects
3107
+ // after a sleep/wake or network blip, the file may have edits we missed
3108
+ // (no DOCUMENT_CHANGE event was delivered while we were disconnected).
3109
+ // Invalidate the cache for the disconnected file so the next read is fresh.
3110
+ this.wsServer.on("fileDisconnected", (data) => {
3111
+ logger.info({ fileKey: data.fileKey, fileName: data.fileName }, "Desktop Bridge plugin disconnected from WebSocket");
3112
+ if (data.fileKey) {
3113
+ this.variablesCache.delete(data.fileKey);
3114
+ // design-system-tools.ts stores token data under a prefixed key
3115
+ this.variablesCache.delete(`vars:${data.fileKey}`);
3116
+ void import("./core/design-system-manifest.js").then(({ DesignSystemManifestCache }) => {
3117
+ DesignSystemManifestCache.getInstance().invalidate(data.fileKey);
3118
+ }).catch(() => { });
3119
+ }
3120
+ });
3121
+ // Invalidate variable cache when document changes are reported.
3122
+ // Figma's documentchange API doesn't expose a specific variable change type —
3123
+ // variable operations manifest as node PROPERTY_CHANGE events, so we invalidate
3124
+ // on any style or node change to be safe.
3125
+ this.wsServer.on("documentChange", (data) => {
3126
+ if (data.hasStyleChanges || data.hasNodeChanges) {
3127
+ if (data.fileKey) {
3128
+ // Per-file cache invalidation — only clear the affected file's cache.
3129
+ // Also clear the design-system-tools token entry (prefixed key)
3130
+ // and the component manifest, so searches see new components
3131
+ // and the design-system kit sees edited variables.
3132
+ this.variablesCache.delete(data.fileKey);
3133
+ this.variablesCache.delete(`vars:${data.fileKey}`);
3134
+ void import("./core/design-system-manifest.js").then(({ DesignSystemManifestCache }) => {
3135
+ DesignSystemManifestCache.getInstance().invalidate(data.fileKey);
3136
+ }).catch(() => { });
3137
+ logger.debug({ fileKey: data.fileKey, changeCount: data.changeCount, hasStyleChanges: data.hasStyleChanges, hasNodeChanges: data.hasNodeChanges }, "Variable cache invalidated due to document changes");
3138
+ }
3139
+ else {
3140
+ // Unidentified file (event arrived before FILE_INFO handshake completed).
3141
+ // We don't know which cache entry to invalidate; do nothing rather than
3142
+ // blanket-clear other files' caches. FILE_INFO will arrive shortly and
3143
+ // any subsequent document changes will route correctly.
3144
+ logger.debug({ changeCount: data.changeCount }, "Document change received before file identification — cache untouched");
3145
+ }
3146
+ }
3147
+ });
3148
+ }
3149
+ // Periodically reap orphaned/zombie servers for the whole run, not just
3150
+ // at startup, so the port range stays clean over long sessions. Runs
3151
+ // only when the WS bridge actually bound a port. Unref'd internally.
3152
+ if (this.wsActualPort !== null && !this.wsReaperStop) {
3153
+ this.wsReaperStop = startPeriodicReaper(this.wsPreferredPort);
3154
+ }
3155
+ // Check if Figma Desktop is accessible (non-blocking, just for logging)
3156
+ logger.info("Checking Figma Desktop accessibility...");
3157
+ await this.checkFigmaDesktop();
3158
+ // Register all tools
3159
+ this.registerTools();
3160
+ // Start optional Streamable HTTP MCP server (if MCP_HTTP_PORT is set)
3161
+ await this.startHttpServer();
3162
+ // Update port discovery file with HTTP port if both WS and HTTP are active
3163
+ if (this.mcpHttpPort && this.wsActualPort) {
3164
+ const portFilePath = getPortFilePath(this.wsActualPort);
3165
+ try {
3166
+ const raw = readFileSync(portFilePath, 'utf-8');
3167
+ const data = JSON.parse(raw);
3168
+ data.mcpHttpPort = this.mcpHttpPort;
3169
+ writeFileSync(portFilePath, JSON.stringify(data, null, 2));
3170
+ }
3171
+ catch { /* best-effort — port file update is non-critical */ }
3172
+ }
3173
+ // Create stdio transport
3174
+ const transport = new StdioServerTransport();
3175
+ // Connect server to transport
3176
+ await this.server.connect(transport);
3177
+ logger.info("MCP server started successfully on stdio transport");
3178
+ // šŸ†• AUTO-CONNECT: Start monitoring immediately if Figma Desktop is available
3179
+ // This enables "get latest logs" workflow without requiring manual setup
3180
+ // In WS-only mode, no auto-connect is needed — the Desktop Bridge plugin
3181
+ // pushes a connection from the Figma side as soon as the user opens it.
3182
+ }
3183
+ catch (error) {
3184
+ logger.error({ error }, "Failed to start MCP server");
3185
+ // Log helpful error message to stderr
3186
+ console.error("\nāŒ Failed to start Figma Console MCP:\n");
3187
+ console.error(error instanceof Error ? error.message : String(error));
3188
+ console.error("\n");
3189
+ process.exit(1);
3190
+ }
3191
+ }
3192
+ /**
3193
+ * Cleanup and shutdown
3194
+ */
3195
+ async shutdown() {
3196
+ logger.info("Shutting down MCP server...");
3197
+ try {
3198
+ // Stop heartbeat timer
3199
+ if (this.wsHeartbeatTimer) {
3200
+ clearInterval(this.wsHeartbeatTimer);
3201
+ this.wsHeartbeatTimer = null;
3202
+ }
3203
+ // Stop the periodic reaper
3204
+ if (this.wsReaperStop) {
3205
+ this.wsReaperStop();
3206
+ this.wsReaperStop = null;
3207
+ }
3208
+ // Clean up port advertisement before stopping the server
3209
+ if (this.wsActualPort) {
3210
+ unadvertisePort(this.wsActualPort);
3211
+ }
3212
+ if (this.httpServer) {
3213
+ this.httpServer.close();
3214
+ this.httpServer = null;
3215
+ }
3216
+ if (this.wsServer) {
3217
+ await this.wsServer.stop();
3218
+ }
3219
+ logger.info("MCP server shutdown complete");
3220
+ }
3221
+ catch (error) {
3222
+ logger.error({ error }, "Error during shutdown");
3223
+ }
3224
+ }
3225
+ }
3226
+ /**
3227
+ * Main entry point
3228
+ */
3229
+ async function main() {
3230
+ const server = new LocalFigmaConsoleMCP();
3231
+ // Handle graceful shutdown. A hard backstop guarantees the process exits even
3232
+ // if shutdown() hangs (e.g. an HTTP/WebSocket close that blocks on a lingering
3233
+ // connection). Without this, the SIGTERM listener suppresses Node's default
3234
+ // terminate-on-SIGTERM and the process zombifies — holding its port forever.
3235
+ const SHUTDOWN_TIMEOUT_MS = 5000;
3236
+ let shuttingDown = false;
3237
+ const gracefulExit = async (code) => {
3238
+ if (shuttingDown)
3239
+ return;
3240
+ shuttingDown = true;
3241
+ const backstop = setTimeout(() => {
3242
+ logger.error(`Shutdown exceeded ${SHUTDOWN_TIMEOUT_MS}ms — forcing exit`);
3243
+ process.exit(code);
3244
+ }, SHUTDOWN_TIMEOUT_MS);
3245
+ backstop.unref();
3246
+ try {
3247
+ await server.shutdown();
3248
+ }
3249
+ catch (error) {
3250
+ logger.error({ error }, "Error during shutdown");
3251
+ }
3252
+ clearTimeout(backstop);
3253
+ process.exit(code);
3254
+ };
3255
+ process.on("SIGINT", () => { void gracefulExit(0); });
3256
+ process.on("SIGTERM", () => { void gracefulExit(0); });
3257
+ // Start the server
3258
+ await server.start();
3259
+ }
3260
+ // Run if executed directly
3261
+ // Note: On Windows, import.meta.url uses file:/// (3 slashes) while process.argv uses backslashes
3262
+ // We normalize both paths to compare correctly across platforms
3263
+ // realpathSync resolves symlinks (e.g. node_modules/.bin/figma-console-mcp -> dist/local.js)
3264
+ // which is required for npx to work, since npx runs the binary via a symlink
3265
+ const currentFile = fileURLToPath(import.meta.url);
3266
+ const entryFile = process.argv[1] ? realpathSync(resolve(process.argv[1])) : "";
3267
+ if (currentFile === entryFile) {
3268
+ // Handle --print-path: print the Desktop Bridge manifest path and exit.
3269
+ // MUST always print a path and exit — never fall through to main().
3270
+ if (process.argv.includes("--print-path")) {
3271
+ try {
3272
+ const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
3273
+ const sourceDir = resolve(packageRoot, "figma-desktop-bridge");
3274
+ // Try to set up stable directory with the latest plugin files.
3275
+ const stablePath = setupStablePluginDir(sourceDir);
3276
+ if (stablePath && existsSync(stablePath)) {
3277
+ console.log(stablePath);
3278
+ console.error("\nImport this manifest in Figma (Plugins → Development →\n" +
3279
+ "Import plugin from manifest). The MCP server refreshes the\n" +
3280
+ "plugin files in this directory on every startup.\n" +
3281
+ "\n" +
3282
+ "Re-importing after a package update is OPTIONAL — most\n" +
3283
+ "upgrades stay wire-compatible with the previous plugin.\n" +
3284
+ "Re-import only when release notes call for it, or when you\n" +
3285
+ "want the latest cosmetic touches (status-pill copy, plugin\n" +
3286
+ "version reporting). Figma caches plugin files at the app\n" +
3287
+ "level, so re-importing is what makes Figma pick up changes.\n");
3288
+ process.exit(0);
3289
+ }
3290
+ // Fallback to npm package path
3291
+ const manifestPath = resolve(sourceDir, "manifest.json");
3292
+ if (existsSync(manifestPath)) {
3293
+ console.log(manifestPath);
3294
+ process.exit(0);
3295
+ }
3296
+ // Last resort: print the stable dir path even if it doesn't exist yet
3297
+ // (the server will create it on first startup)
3298
+ const stableDir = join(homedir(), ".figma-console-mcp", "plugin", "manifest.json");
3299
+ console.log(stableDir);
3300
+ console.error("\nNote: This path will be populated when the MCP server starts.");
3301
+ process.exit(0);
3302
+ }
3303
+ catch (error) {
3304
+ console.error("Error resolving plugin path:", error);
3305
+ process.exit(1);
3306
+ }
3307
+ }
3308
+ main().catch((error) => {
3309
+ console.error("Fatal error:", error);
3310
+ process.exit(1);
3311
+ });
3312
+ }
3313
+ export { LocalFigmaConsoleMCP };
3314
+ //# sourceMappingURL=local.js.map