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