@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
|
@@ -0,0 +1,3187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Figma API MCP Tools
|
|
3
|
+
* MCP tool definitions for Figma REST API data extraction
|
|
4
|
+
*/
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import * as fs from "fs";
|
|
7
|
+
import * as path from "path";
|
|
8
|
+
import { extractFileKey, extractFigmaUrlInfo, formatVariables, formatComponentData, withTimeout } from "./figma-api.js";
|
|
9
|
+
import { createChildLogger } from "./logger.js";
|
|
10
|
+
import { identifiedError, withIdentity } from "./identity.js";
|
|
11
|
+
import { EnrichmentService } from "./enrichment/index.js";
|
|
12
|
+
import { extractNodeSpec, validateReconstructionSpec, listVariants } from "./figma-reconstruction-spec.js";
|
|
13
|
+
const logger = createChildLogger({ component: "figma-tools" });
|
|
14
|
+
// Initialize enrichment service
|
|
15
|
+
const enrichmentService = new EnrichmentService(logger);
|
|
16
|
+
/**
|
|
17
|
+
* Scan a codebase components directory to discover existing components.
|
|
18
|
+
* Returns a registry of component names, paths, and exports.
|
|
19
|
+
* Works with any framework — looks for index.ts/tsx/js barrel exports.
|
|
20
|
+
*/
|
|
21
|
+
function scanCodebaseComponents(componentsDir) {
|
|
22
|
+
const registry = [];
|
|
23
|
+
try {
|
|
24
|
+
if (!fs.existsSync(componentsDir))
|
|
25
|
+
return registry;
|
|
26
|
+
const dirs = fs.readdirSync(componentsDir, { withFileTypes: true });
|
|
27
|
+
for (const dir of dirs) {
|
|
28
|
+
if (!dir.isDirectory())
|
|
29
|
+
continue;
|
|
30
|
+
const compDir = path.join(componentsDir, dir.name);
|
|
31
|
+
// Look for barrel export (index.ts, index.tsx, index.js)
|
|
32
|
+
const barrelFiles = ["index.ts", "index.tsx", "index.js"];
|
|
33
|
+
let barrelPath = "";
|
|
34
|
+
for (const bf of barrelFiles) {
|
|
35
|
+
const candidate = path.join(compDir, bf);
|
|
36
|
+
if (fs.existsSync(candidate)) {
|
|
37
|
+
barrelPath = candidate;
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (!barrelPath) {
|
|
42
|
+
// No barrel — check for a main component file matching the directory name
|
|
43
|
+
const mainFiles = [`${dir.name}.tsx`, `${dir.name}.ts`, `${dir.name}.jsx`, `${dir.name}.js`];
|
|
44
|
+
for (const mf of mainFiles) {
|
|
45
|
+
if (fs.existsSync(path.join(compDir, mf))) {
|
|
46
|
+
registry.push({ name: dir.name, path: `src/components/${dir.name}`, exports: [dir.name] });
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
// Parse exports from barrel file
|
|
53
|
+
try {
|
|
54
|
+
const content = fs.readFileSync(barrelPath, "utf-8");
|
|
55
|
+
const exportNames = [];
|
|
56
|
+
// Match: export { Foo, Bar } from ...
|
|
57
|
+
const namedExports = content.matchAll(/export\s*\{([^}]+)\}/g);
|
|
58
|
+
for (const match of namedExports) {
|
|
59
|
+
const names = match[1].split(",").map(n => n.trim().split(/\s+as\s+/).pop()?.trim() || "").filter(Boolean);
|
|
60
|
+
exportNames.push(...names.filter(n => !n.startsWith("type ")));
|
|
61
|
+
}
|
|
62
|
+
// Match: export default ...
|
|
63
|
+
if (content.includes("export default")) {
|
|
64
|
+
exportNames.push("default");
|
|
65
|
+
}
|
|
66
|
+
// Filter out type-only exports
|
|
67
|
+
const cleanExports = exportNames.filter(n => !n.startsWith("type") || n[4] !== " ");
|
|
68
|
+
registry.push({
|
|
69
|
+
name: dir.name,
|
|
70
|
+
path: `src/components/${dir.name}`,
|
|
71
|
+
exports: cleanExports.length > 0 ? cleanExports : [dir.name],
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
registry.push({ name: dir.name, path: `src/components/${dir.name}`, exports: [dir.name] });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
logger.debug({ err, componentsDir }, "Could not scan codebase components directory");
|
|
81
|
+
}
|
|
82
|
+
return registry;
|
|
83
|
+
}
|
|
84
|
+
// ============================================================================
|
|
85
|
+
// Cache Management & Data Processing Helpers
|
|
86
|
+
// ============================================================================
|
|
87
|
+
/**
|
|
88
|
+
* Cache configuration
|
|
89
|
+
*/
|
|
90
|
+
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
91
|
+
const MAX_CACHE_ENTRIES = 10; // LRU eviction
|
|
92
|
+
/**
|
|
93
|
+
* Check if cache entry is still valid based on TTL
|
|
94
|
+
*/
|
|
95
|
+
function isCacheValid(timestamp, ttlMs = CACHE_TTL_MS) {
|
|
96
|
+
return Date.now() - timestamp < ttlMs;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Rough token estimation for response size checking
|
|
100
|
+
* Approximation: 1 token ≈ 4 characters for JSON
|
|
101
|
+
*/
|
|
102
|
+
function estimateTokens(data) {
|
|
103
|
+
const jsonString = JSON.stringify(data);
|
|
104
|
+
return Math.ceil(jsonString.length / 4);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Response size thresholds for adaptive verbosity
|
|
108
|
+
* Based on typical Claude Desktop context window limits
|
|
109
|
+
*/
|
|
110
|
+
const RESPONSE_SIZE_THRESHOLDS = {
|
|
111
|
+
// Conservative thresholds to leave room for conversation context
|
|
112
|
+
IDEAL_SIZE_KB: 100,
|
|
113
|
+
WARNING_SIZE_KB: 200,
|
|
114
|
+
CRITICAL_SIZE_KB: 500,
|
|
115
|
+
MAX_SIZE_KB: 1000, // Absolute maximum before emergency compression
|
|
116
|
+
};
|
|
117
|
+
/**
|
|
118
|
+
* Calculate JSON string size in KB
|
|
119
|
+
*/
|
|
120
|
+
function calculateSizeKB(data) {
|
|
121
|
+
const jsonString = JSON.stringify(data);
|
|
122
|
+
return jsonString.length / 1024;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Generic adaptive response wrapper - automatically compresses responses that exceed size thresholds
|
|
126
|
+
* Can be used by any tool to prevent context window exhaustion
|
|
127
|
+
*
|
|
128
|
+
* @param responseData - The response data to potentially compress
|
|
129
|
+
* @param options - Configuration options for compression behavior
|
|
130
|
+
* @returns Response content array with optional AI instruction
|
|
131
|
+
*/
|
|
132
|
+
function adaptiveResponse(responseData, options) {
|
|
133
|
+
// Tag every response with our MCP identity so LLMs can attribute it
|
|
134
|
+
// unambiguously when other Figma-related MCPs are also connected.
|
|
135
|
+
const tagged = responseData && typeof responseData === "object" && !Array.isArray(responseData)
|
|
136
|
+
? withIdentity(responseData)
|
|
137
|
+
: { _mcp: "figma-console-mcp", data: responseData };
|
|
138
|
+
const sizeKB = calculateSizeKB(tagged);
|
|
139
|
+
// No compression needed
|
|
140
|
+
if (sizeKB <= RESPONSE_SIZE_THRESHOLDS.IDEAL_SIZE_KB) {
|
|
141
|
+
return {
|
|
142
|
+
content: [
|
|
143
|
+
{
|
|
144
|
+
type: "text",
|
|
145
|
+
text: JSON.stringify(tagged),
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
// Determine compression level and message
|
|
151
|
+
let compressionLevel = "info";
|
|
152
|
+
let aiInstruction = "";
|
|
153
|
+
let shouldCompress = false;
|
|
154
|
+
if (sizeKB > RESPONSE_SIZE_THRESHOLDS.MAX_SIZE_KB) {
|
|
155
|
+
compressionLevel = "emergency";
|
|
156
|
+
shouldCompress = true;
|
|
157
|
+
aiInstruction =
|
|
158
|
+
`⚠️ RESPONSE AUTO-COMPRESSED: The ${options.toolName} response was automatically reduced because the full response would be ${sizeKB.toFixed(0)}KB, which would exhaust Claude Desktop's context window.\n\n`;
|
|
159
|
+
}
|
|
160
|
+
else if (sizeKB > RESPONSE_SIZE_THRESHOLDS.CRITICAL_SIZE_KB) {
|
|
161
|
+
compressionLevel = "critical";
|
|
162
|
+
shouldCompress = true;
|
|
163
|
+
aiInstruction =
|
|
164
|
+
`⚠️ RESPONSE AUTO-COMPRESSED: The ${options.toolName} response was automatically reduced because it would be ${sizeKB.toFixed(0)}KB, risking context window exhaustion.\n\n`;
|
|
165
|
+
}
|
|
166
|
+
else if (sizeKB > RESPONSE_SIZE_THRESHOLDS.WARNING_SIZE_KB) {
|
|
167
|
+
compressionLevel = "warning";
|
|
168
|
+
shouldCompress = true;
|
|
169
|
+
aiInstruction =
|
|
170
|
+
`ℹ️ RESPONSE OPTIMIZED: The ${options.toolName} response was automatically reduced because it would be ${sizeKB.toFixed(0)}KB.\n\n`;
|
|
171
|
+
}
|
|
172
|
+
// Map compression level to verbosity level
|
|
173
|
+
const verbosityMap = {
|
|
174
|
+
"info": "standard",
|
|
175
|
+
"warning": "summary",
|
|
176
|
+
"critical": "summary",
|
|
177
|
+
"emergency": "inventory"
|
|
178
|
+
};
|
|
179
|
+
// If compression needed, apply callback to reduce data
|
|
180
|
+
let finalData = responseData;
|
|
181
|
+
if (shouldCompress && options.compressionCallback) {
|
|
182
|
+
const targetVerbosity = verbosityMap[compressionLevel] || "summary";
|
|
183
|
+
finalData = options.compressionCallback(targetVerbosity);
|
|
184
|
+
// Add compression metadata
|
|
185
|
+
finalData.compression = {
|
|
186
|
+
originalSizeKB: Math.round(sizeKB),
|
|
187
|
+
finalSizeKB: Math.round(calculateSizeKB(finalData)),
|
|
188
|
+
compressionLevel,
|
|
189
|
+
};
|
|
190
|
+
logger.info({
|
|
191
|
+
tool: options.toolName,
|
|
192
|
+
originalSizeKB: sizeKB.toFixed(2),
|
|
193
|
+
finalSizeKB: calculateSizeKB(finalData).toFixed(2),
|
|
194
|
+
compressionLevel,
|
|
195
|
+
}, "Response compressed to prevent context exhaustion");
|
|
196
|
+
}
|
|
197
|
+
// Build AI instruction with suggested actions
|
|
198
|
+
if (shouldCompress) {
|
|
199
|
+
if (options.suggestedActions && options.suggestedActions.length > 0) {
|
|
200
|
+
aiInstruction += `To get more detail:\n`;
|
|
201
|
+
options.suggestedActions.forEach(action => {
|
|
202
|
+
aiInstruction += `• ${action}\n`;
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
// Build response content (tagged with identity so cross-MCP attribution is clear)
|
|
207
|
+
const taggedFinal = finalData && typeof finalData === "object" && !Array.isArray(finalData)
|
|
208
|
+
? withIdentity(finalData)
|
|
209
|
+
: { _mcp: "figma-console-mcp", data: finalData };
|
|
210
|
+
const content = [
|
|
211
|
+
{
|
|
212
|
+
type: "text",
|
|
213
|
+
text: JSON.stringify(taggedFinal),
|
|
214
|
+
},
|
|
215
|
+
];
|
|
216
|
+
// Add AI instruction as separate content block if needed
|
|
217
|
+
if (aiInstruction) {
|
|
218
|
+
content.unshift({
|
|
219
|
+
type: "text",
|
|
220
|
+
text: aiInstruction.trim(),
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
return { content };
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Adaptive verbosity system - automatically downgrades verbosity based on response size
|
|
227
|
+
* Returns adjusted verbosity level and compression info for AI instructions
|
|
228
|
+
*
|
|
229
|
+
* @deprecated Use adaptiveResponse instead for more flexible compression
|
|
230
|
+
*/
|
|
231
|
+
function adaptiveVerbosity(data, requestedVerbosity) {
|
|
232
|
+
const sizeKB = calculateSizeKB(data);
|
|
233
|
+
// No adjustment needed - response is within ideal size
|
|
234
|
+
if (sizeKB <= RESPONSE_SIZE_THRESHOLDS.IDEAL_SIZE_KB) {
|
|
235
|
+
return {
|
|
236
|
+
adjustedVerbosity: requestedVerbosity,
|
|
237
|
+
sizeKB,
|
|
238
|
+
wasCompressed: false,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
// Determine appropriate verbosity based on size
|
|
242
|
+
let adjustedVerbosity = requestedVerbosity;
|
|
243
|
+
let compressionReason = "";
|
|
244
|
+
let aiInstruction = "";
|
|
245
|
+
if (sizeKB > RESPONSE_SIZE_THRESHOLDS.MAX_SIZE_KB) {
|
|
246
|
+
// Emergency: Force inventory mode
|
|
247
|
+
adjustedVerbosity = "inventory";
|
|
248
|
+
compressionReason = `Response size (${sizeKB.toFixed(0)}KB) exceeds maximum threshold (${RESPONSE_SIZE_THRESHOLDS.MAX_SIZE_KB}KB)`;
|
|
249
|
+
aiInstruction =
|
|
250
|
+
`⚠️ RESPONSE AUTO-COMPRESSED: The response was automatically reduced to 'inventory' verbosity (names/IDs only) because the full response would be ${sizeKB.toFixed(0)}KB, which would exhaust Claude Desktop's context window.\n\n` +
|
|
251
|
+
`To get more detail:\n` +
|
|
252
|
+
`• Use format='filtered' with collection/namePattern/mode filters to narrow the scope\n` +
|
|
253
|
+
`• Use pagination (page=1, pageSize=20) to retrieve data in smaller chunks\n` +
|
|
254
|
+
`• Use returnAsLinks=true to get resource_link references instead of full data\n\n` +
|
|
255
|
+
`Current response contains variable/collection names and IDs only.`;
|
|
256
|
+
}
|
|
257
|
+
else if (sizeKB > RESPONSE_SIZE_THRESHOLDS.CRITICAL_SIZE_KB) {
|
|
258
|
+
// Critical: Downgrade to summary if higher was requested
|
|
259
|
+
if (requestedVerbosity === "full" || requestedVerbosity === "standard") {
|
|
260
|
+
adjustedVerbosity = "summary";
|
|
261
|
+
compressionReason = `Response size (${sizeKB.toFixed(0)}KB) exceeds critical threshold (${RESPONSE_SIZE_THRESHOLDS.CRITICAL_SIZE_KB}KB)`;
|
|
262
|
+
aiInstruction =
|
|
263
|
+
`⚠️ RESPONSE AUTO-COMPRESSED: The response was automatically reduced to 'summary' verbosity because the ${requestedVerbosity} response would be ${sizeKB.toFixed(0)}KB, risking context window exhaustion.\n\n` +
|
|
264
|
+
`To get more detail, use filtering options:\n` +
|
|
265
|
+
`• format='filtered' with collection='CollectionName' to focus on specific collections\n` +
|
|
266
|
+
`• namePattern='color' to filter by variable name\n` +
|
|
267
|
+
`• mode='Light' to filter by mode\n` +
|
|
268
|
+
`• pagination with smaller pageSize values\n\n` +
|
|
269
|
+
`Current response includes variable names, types, and mode information.`;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
else if (sizeKB > RESPONSE_SIZE_THRESHOLDS.WARNING_SIZE_KB) {
|
|
273
|
+
// Warning: Downgrade full to standard
|
|
274
|
+
if (requestedVerbosity === "full") {
|
|
275
|
+
adjustedVerbosity = "standard";
|
|
276
|
+
compressionReason = `Response size (${sizeKB.toFixed(0)}KB) exceeds warning threshold (${RESPONSE_SIZE_THRESHOLDS.WARNING_SIZE_KB}KB)`;
|
|
277
|
+
aiInstruction =
|
|
278
|
+
`ℹ️ RESPONSE OPTIMIZED: The response was automatically reduced to 'standard' verbosity because the full response would be ${sizeKB.toFixed(0)}KB.\n\n` +
|
|
279
|
+
`This response includes essential variable properties. For specific details, use filtering:\n` +
|
|
280
|
+
`• format='filtered' with collection/namePattern/mode filters\n` +
|
|
281
|
+
`• Request verbosity='full' with specific filters to get complete data for a subset`;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
const wasCompressed = adjustedVerbosity !== requestedVerbosity;
|
|
285
|
+
if (wasCompressed) {
|
|
286
|
+
logger.info({
|
|
287
|
+
originalVerbosity: requestedVerbosity,
|
|
288
|
+
adjustedVerbosity,
|
|
289
|
+
sizeKB: sizeKB.toFixed(2),
|
|
290
|
+
threshold: compressionReason,
|
|
291
|
+
}, "Adaptive compression applied");
|
|
292
|
+
}
|
|
293
|
+
return {
|
|
294
|
+
adjustedVerbosity,
|
|
295
|
+
sizeKB,
|
|
296
|
+
wasCompressed,
|
|
297
|
+
compressionReason: wasCompressed ? compressionReason : undefined,
|
|
298
|
+
aiInstruction: wasCompressed ? aiInstruction : undefined,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Generate compact summary of variables data (~2K tokens)
|
|
303
|
+
* Returns high-level overview with counts and names
|
|
304
|
+
*/
|
|
305
|
+
function generateSummary(data) {
|
|
306
|
+
const summary = {
|
|
307
|
+
fileKey: data.fileKey,
|
|
308
|
+
timestamp: data.timestamp,
|
|
309
|
+
source: data.source || 'cache',
|
|
310
|
+
overview: {
|
|
311
|
+
total_variables: data.variables?.length || 0,
|
|
312
|
+
total_collections: data.variableCollections?.length || 0,
|
|
313
|
+
},
|
|
314
|
+
collections: data.variableCollections?.map((c) => ({
|
|
315
|
+
id: c.id,
|
|
316
|
+
name: c.name,
|
|
317
|
+
modes: c.modes?.map((m) => ({ id: m.modeId, name: m.name })),
|
|
318
|
+
variable_count: c.variableIds?.length || 0,
|
|
319
|
+
})) || [],
|
|
320
|
+
variables_by_type: {},
|
|
321
|
+
variable_names: [],
|
|
322
|
+
};
|
|
323
|
+
// Count variables by type
|
|
324
|
+
const typeCount = {};
|
|
325
|
+
const names = [];
|
|
326
|
+
data.variables?.forEach((v) => {
|
|
327
|
+
typeCount[v.resolvedType] = (typeCount[v.resolvedType] || 0) + 1;
|
|
328
|
+
names.push(v.name);
|
|
329
|
+
});
|
|
330
|
+
summary.variables_by_type = typeCount;
|
|
331
|
+
summary.variable_names = names;
|
|
332
|
+
return summary;
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Apply filters to variables data
|
|
336
|
+
*/
|
|
337
|
+
function applyFilters(data, filters, verbosity = "standard") {
|
|
338
|
+
let filteredVariables = [...(data.variables || [])];
|
|
339
|
+
let filteredCollections = [...(data.variableCollections || [])];
|
|
340
|
+
// Filter by collection name or ID
|
|
341
|
+
if (filters.collection) {
|
|
342
|
+
const collectionFilter = filters.collection.toLowerCase();
|
|
343
|
+
filteredCollections = filteredCollections.filter((c) => c.name?.toLowerCase().includes(collectionFilter) ||
|
|
344
|
+
c.id === filters.collection);
|
|
345
|
+
const collectionIds = new Set(filteredCollections.map((c) => c.id));
|
|
346
|
+
filteredVariables = filteredVariables.filter((v) => collectionIds.has(v.variableCollectionId));
|
|
347
|
+
}
|
|
348
|
+
// Filter by variable name pattern (regex or substring)
|
|
349
|
+
if (filters.namePattern) {
|
|
350
|
+
try {
|
|
351
|
+
const regex = new RegExp(filters.namePattern, 'i');
|
|
352
|
+
filteredVariables = filteredVariables.filter((v) => regex.test(v.name));
|
|
353
|
+
}
|
|
354
|
+
catch (e) {
|
|
355
|
+
// If regex fails, fall back to substring match
|
|
356
|
+
const pattern = filters.namePattern.toLowerCase();
|
|
357
|
+
filteredVariables = filteredVariables.filter((v) => v.name?.toLowerCase().includes(pattern));
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
// Find target mode ID if mode filter specified (needed for both filtering and transformation)
|
|
361
|
+
let targetModeId = null;
|
|
362
|
+
let targetModeName = null;
|
|
363
|
+
if (filters.mode) {
|
|
364
|
+
const modeFilter = filters.mode.toLowerCase();
|
|
365
|
+
// Try direct mode ID match first
|
|
366
|
+
if (data.variableCollections || filteredCollections.length > 0) {
|
|
367
|
+
for (const collection of filteredCollections) {
|
|
368
|
+
if (collection.modes) {
|
|
369
|
+
const mode = collection.modes.find((m) => m.modeId === filters.mode ||
|
|
370
|
+
m.name?.toLowerCase().includes(modeFilter));
|
|
371
|
+
if (mode) {
|
|
372
|
+
targetModeId = mode.modeId;
|
|
373
|
+
targetModeName = mode.name;
|
|
374
|
+
break;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
// Filter by mode name or ID
|
|
381
|
+
if (filters.mode) {
|
|
382
|
+
filteredVariables = filteredVariables.filter((v) => {
|
|
383
|
+
// Check if variable has values for the specified mode
|
|
384
|
+
if (v.valuesByMode) {
|
|
385
|
+
// Try to match by mode ID directly
|
|
386
|
+
if (v.valuesByMode[filters.mode]) {
|
|
387
|
+
return true;
|
|
388
|
+
}
|
|
389
|
+
// Try using resolved targetModeId
|
|
390
|
+
if (targetModeId && v.valuesByMode[targetModeId]) {
|
|
391
|
+
return true;
|
|
392
|
+
}
|
|
393
|
+
// Try to match by mode name through collections
|
|
394
|
+
const collection = filteredCollections.find((c) => c.id === v.variableCollectionId);
|
|
395
|
+
if (collection?.modes) {
|
|
396
|
+
const mode = collection.modes.find((m) => m.name?.toLowerCase().includes(filters.mode.toLowerCase()) || m.modeId === filters.mode);
|
|
397
|
+
return mode && v.valuesByMode[mode.modeId];
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return false;
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
// Transform valuesByMode based on verbosity level
|
|
404
|
+
// This is critical for reducing response size with multi-mode variables
|
|
405
|
+
if (verbosity !== "full") {
|
|
406
|
+
filteredVariables = filteredVariables.map((v) => {
|
|
407
|
+
const variable = { ...v };
|
|
408
|
+
// Use original collections array for lookup, not filtered, since we need mode metadata
|
|
409
|
+
// Handle both variableCollections and collections property names
|
|
410
|
+
const collections = data.variableCollections || data.collections || [];
|
|
411
|
+
const collection = collections.find((c) => c.id === v.variableCollectionId);
|
|
412
|
+
if (verbosity === "inventory") {
|
|
413
|
+
// Inventory: Remove valuesByMode entirely, add mode count
|
|
414
|
+
delete variable.valuesByMode;
|
|
415
|
+
if (collection?.modes) {
|
|
416
|
+
variable.modeCount = collection.modes.length;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
else if (verbosity === "summary") {
|
|
420
|
+
// Summary: Replace valuesByMode with mode names array
|
|
421
|
+
if (variable.valuesByMode && collection?.modes) {
|
|
422
|
+
variable.modeNames = collection.modes.map((m) => m.name);
|
|
423
|
+
variable.modeCount = collection.modes.length;
|
|
424
|
+
}
|
|
425
|
+
delete variable.valuesByMode;
|
|
426
|
+
}
|
|
427
|
+
else if (verbosity === "standard") {
|
|
428
|
+
// Standard: If mode parameter specified, filter to that mode only
|
|
429
|
+
if (targetModeId && variable.valuesByMode) {
|
|
430
|
+
const singleModeValue = variable.valuesByMode[targetModeId];
|
|
431
|
+
variable.valuesByMode = { [targetModeId]: singleModeValue };
|
|
432
|
+
variable.selectedMode = {
|
|
433
|
+
modeId: targetModeId,
|
|
434
|
+
modeName: targetModeName,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
// If no mode specified, keep all valuesByMode but add metadata for context
|
|
438
|
+
else if (variable.valuesByMode && collection?.modes) {
|
|
439
|
+
variable.modeMetadata = collection.modes.map((m) => ({
|
|
440
|
+
modeId: m.modeId,
|
|
441
|
+
modeName: m.name,
|
|
442
|
+
}));
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return variable;
|
|
446
|
+
});
|
|
447
|
+
// Apply field-level filtering based on verbosity
|
|
448
|
+
if (verbosity === "inventory") {
|
|
449
|
+
filteredVariables = filteredVariables.map((v) => ({
|
|
450
|
+
id: v.id,
|
|
451
|
+
name: v.name,
|
|
452
|
+
resolvedType: v.resolvedType,
|
|
453
|
+
variableCollectionId: v.variableCollectionId,
|
|
454
|
+
...(v.modeCount && { modeCount: v.modeCount }),
|
|
455
|
+
}));
|
|
456
|
+
}
|
|
457
|
+
else if (verbosity === "summary") {
|
|
458
|
+
filteredVariables = filteredVariables.map((v) => ({
|
|
459
|
+
id: v.id,
|
|
460
|
+
name: v.name,
|
|
461
|
+
resolvedType: v.resolvedType,
|
|
462
|
+
variableCollectionId: v.variableCollectionId,
|
|
463
|
+
...(v.modeNames && { modeNames: v.modeNames }),
|
|
464
|
+
...(v.modeCount && { modeCount: v.modeCount }),
|
|
465
|
+
}));
|
|
466
|
+
}
|
|
467
|
+
else if (verbosity === "standard") {
|
|
468
|
+
filteredVariables = filteredVariables.map((v) => ({
|
|
469
|
+
id: v.id,
|
|
470
|
+
name: v.name,
|
|
471
|
+
resolvedType: v.resolvedType,
|
|
472
|
+
valuesByMode: v.valuesByMode,
|
|
473
|
+
description: v.description,
|
|
474
|
+
variableCollectionId: v.variableCollectionId,
|
|
475
|
+
...(v.scopes && { scopes: v.scopes }),
|
|
476
|
+
...(v.selectedMode && { selectedMode: v.selectedMode }),
|
|
477
|
+
...(v.modeMetadata && { modeMetadata: v.modeMetadata }),
|
|
478
|
+
}));
|
|
479
|
+
}
|
|
480
|
+
// For "full" verbosity, return all fields (no filtering)
|
|
481
|
+
}
|
|
482
|
+
// IMPORTANT: Only return filtered data, not the entire original data object
|
|
483
|
+
// The ...data spread was including massive metadata that bloated responses
|
|
484
|
+
return {
|
|
485
|
+
variables: filteredVariables,
|
|
486
|
+
variableCollections: filteredCollections,
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Apply pagination to variables
|
|
491
|
+
*/
|
|
492
|
+
function paginateVariables(data, page = 1, pageSize = 50) {
|
|
493
|
+
const variables = data.variables || [];
|
|
494
|
+
const totalVariables = variables.length;
|
|
495
|
+
const totalPages = Math.ceil(totalVariables / pageSize);
|
|
496
|
+
// Validate page number
|
|
497
|
+
const currentPage = Math.max(1, Math.min(page, totalPages || 1));
|
|
498
|
+
// Calculate pagination
|
|
499
|
+
const startIndex = (currentPage - 1) * pageSize;
|
|
500
|
+
const endIndex = startIndex + pageSize;
|
|
501
|
+
const paginatedVariables = variables.slice(startIndex, endIndex);
|
|
502
|
+
return {
|
|
503
|
+
data: {
|
|
504
|
+
...data,
|
|
505
|
+
variables: paginatedVariables,
|
|
506
|
+
},
|
|
507
|
+
pagination: {
|
|
508
|
+
currentPage,
|
|
509
|
+
pageSize,
|
|
510
|
+
totalVariables,
|
|
511
|
+
totalPages,
|
|
512
|
+
hasNextPage: currentPage < totalPages,
|
|
513
|
+
hasPrevPage: currentPage > 1,
|
|
514
|
+
},
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* Manage LRU cache eviction
|
|
519
|
+
*/
|
|
520
|
+
function evictOldestCacheEntry(cache) {
|
|
521
|
+
if (cache.size >= MAX_CACHE_ENTRIES) {
|
|
522
|
+
// Find oldest entry
|
|
523
|
+
let oldestKey = null;
|
|
524
|
+
let oldestTime = Infinity;
|
|
525
|
+
for (const [key, entry] of cache.entries()) {
|
|
526
|
+
if (entry.timestamp < oldestTime) {
|
|
527
|
+
oldestTime = entry.timestamp;
|
|
528
|
+
oldestKey = key;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
if (oldestKey) {
|
|
532
|
+
cache.delete(oldestKey);
|
|
533
|
+
logger.info({ evictedKey: oldestKey }, 'Evicted oldest cache entry (LRU)');
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Resolve variable aliases to their final values for all modes
|
|
539
|
+
* @param variables Array of variables to resolve
|
|
540
|
+
* @param allVariablesMap Map of all variables by ID for lookup
|
|
541
|
+
* @param collectionsMap Map of collections by ID for mode info
|
|
542
|
+
* @returns Variables with added resolvedValuesByMode field
|
|
543
|
+
*/
|
|
544
|
+
function resolveVariableAliases(variables, allVariablesMap, collectionsMap) {
|
|
545
|
+
// Helper to format color value to hex
|
|
546
|
+
const formatColorToHex = (color) => {
|
|
547
|
+
if (typeof color === 'string')
|
|
548
|
+
return color;
|
|
549
|
+
if (color && typeof color.r === 'number' && typeof color.g === 'number' && typeof color.b === 'number') {
|
|
550
|
+
const r = Math.round(color.r * 255);
|
|
551
|
+
const g = Math.round(color.g * 255);
|
|
552
|
+
const b = Math.round(color.b * 255);
|
|
553
|
+
const a = typeof color.a === 'number' ? color.a : 1;
|
|
554
|
+
if (a < 1) {
|
|
555
|
+
const aHex = Math.round(a * 255).toString(16).padStart(2, '0');
|
|
556
|
+
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}${aHex}`.toUpperCase();
|
|
557
|
+
}
|
|
558
|
+
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`.toUpperCase();
|
|
559
|
+
}
|
|
560
|
+
return null;
|
|
561
|
+
};
|
|
562
|
+
// Helper to get mode ID from a mode object (handles both 'modeId' and 'id' properties)
|
|
563
|
+
const getModeId = (mode) => {
|
|
564
|
+
return mode?.modeId || mode?.id || null;
|
|
565
|
+
};
|
|
566
|
+
// Helper to get default mode ID from a collection
|
|
567
|
+
const getDefaultModeId = (collection, variable) => {
|
|
568
|
+
// Try explicit defaultModeId first
|
|
569
|
+
if (collection?.defaultModeId) {
|
|
570
|
+
return collection.defaultModeId;
|
|
571
|
+
}
|
|
572
|
+
// Try first mode's ID
|
|
573
|
+
if (collection?.modes?.length > 0) {
|
|
574
|
+
return getModeId(collection.modes[0]);
|
|
575
|
+
}
|
|
576
|
+
// Fallback to first key in valuesByMode
|
|
577
|
+
const modeKeys = Object.keys(variable?.valuesByMode || {});
|
|
578
|
+
return modeKeys.length > 0 ? modeKeys[0] : null;
|
|
579
|
+
};
|
|
580
|
+
// Helper to resolve a single value, following alias chains
|
|
581
|
+
const resolveValue = (value, resolvedType, visited = new Set(), depth = 0) => {
|
|
582
|
+
if (depth > 10) {
|
|
583
|
+
logger.warn({ depth }, 'Max alias resolution depth reached');
|
|
584
|
+
return { resolved: null, aliasChain: Array.from(visited) };
|
|
585
|
+
}
|
|
586
|
+
// Check if this is an alias
|
|
587
|
+
if (value && typeof value === 'object' && value.type === 'VARIABLE_ALIAS') {
|
|
588
|
+
const targetId = value.id;
|
|
589
|
+
// Prevent circular references
|
|
590
|
+
if (visited.has(targetId)) {
|
|
591
|
+
logger.warn({ targetId, visited: Array.from(visited) }, 'Circular alias reference detected');
|
|
592
|
+
return { resolved: null, aliasChain: Array.from(visited) };
|
|
593
|
+
}
|
|
594
|
+
visited.add(targetId);
|
|
595
|
+
const targetVar = allVariablesMap.get(targetId);
|
|
596
|
+
if (!targetVar) {
|
|
597
|
+
logger.debug({ targetId }, 'Target variable not found in map');
|
|
598
|
+
return { resolved: null, aliasChain: Array.from(visited) };
|
|
599
|
+
}
|
|
600
|
+
// Get the target's collection to find its default mode
|
|
601
|
+
const targetCollection = collectionsMap.get(targetVar.variableCollectionId);
|
|
602
|
+
const targetModeId = getDefaultModeId(targetCollection, targetVar);
|
|
603
|
+
if (!targetModeId) {
|
|
604
|
+
logger.debug({ targetId, collectionId: targetVar.variableCollectionId }, 'Could not determine target mode ID');
|
|
605
|
+
return { resolved: null, aliasChain: Array.from(visited) };
|
|
606
|
+
}
|
|
607
|
+
const targetValue = targetVar.valuesByMode?.[targetModeId];
|
|
608
|
+
if (targetValue === undefined) {
|
|
609
|
+
logger.debug({ targetId, targetModeId, availableModes: Object.keys(targetVar.valuesByMode || {}) }, 'Target value not found for mode');
|
|
610
|
+
return { resolved: null, aliasChain: Array.from(visited) };
|
|
611
|
+
}
|
|
612
|
+
// Recursively resolve
|
|
613
|
+
const result = resolveValue(targetValue, targetVar.resolvedType, visited, depth + 1);
|
|
614
|
+
return {
|
|
615
|
+
resolved: result.resolved,
|
|
616
|
+
aliasChain: [targetVar.name, ...(result.aliasChain || [])]
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
// Not an alias - format the value based on type
|
|
620
|
+
if (resolvedType === 'COLOR') {
|
|
621
|
+
return { resolved: formatColorToHex(value) };
|
|
622
|
+
}
|
|
623
|
+
return { resolved: value };
|
|
624
|
+
};
|
|
625
|
+
// Process each variable
|
|
626
|
+
return variables.map(variable => {
|
|
627
|
+
const collection = collectionsMap.get(variable.variableCollectionId);
|
|
628
|
+
const modes = collection?.modes || [];
|
|
629
|
+
const resolvedValuesByMode = {};
|
|
630
|
+
// Use the full cached variable's valuesByMode if the response variable was stripped by verbosity filtering
|
|
631
|
+
const fullVariable = allVariablesMap.get(variable.id);
|
|
632
|
+
const valuesByMode = variable.valuesByMode || fullVariable?.valuesByMode;
|
|
633
|
+
for (const mode of modes) {
|
|
634
|
+
const modeId = getModeId(mode);
|
|
635
|
+
if (!modeId)
|
|
636
|
+
continue;
|
|
637
|
+
const rawValue = valuesByMode?.[modeId];
|
|
638
|
+
if (rawValue === undefined)
|
|
639
|
+
continue;
|
|
640
|
+
const { resolved, aliasChain } = resolveValue(rawValue, variable.resolvedType, new Set());
|
|
641
|
+
const modeName = mode.name || modeId;
|
|
642
|
+
resolvedValuesByMode[modeName] = {
|
|
643
|
+
value: resolved,
|
|
644
|
+
...(aliasChain && aliasChain.length > 0 && { aliasTo: aliasChain[0] })
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
return {
|
|
648
|
+
...variable,
|
|
649
|
+
resolvedValuesByMode
|
|
650
|
+
};
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* Register Figma API tools with the MCP server
|
|
655
|
+
*/
|
|
656
|
+
export function registerFigmaAPITools(server, getFigmaAPI, getCurrentUrl, variablesCache, options, getDesktopConnector) {
|
|
657
|
+
const isRemoteMode = options?.isRemoteMode ?? false;
|
|
658
|
+
/**
|
|
659
|
+
* Build a designer-readable REST-auth error tagged with our MCP identity.
|
|
660
|
+
* Shows only the remediation path that applies to the caller's mode — local
|
|
661
|
+
* users never see OAuth instructions, cloud users never see env-var
|
|
662
|
+
* instructions. Identity prefix lets LLMs disambiguate this error from
|
|
663
|
+
* errors thrown by other Figma-related MCP servers running in parallel.
|
|
664
|
+
*/
|
|
665
|
+
function restAuthError(context, originalError, extra) {
|
|
666
|
+
const remediation = isRemoteMode
|
|
667
|
+
? "Re-authenticate via the OAuth flow in your MCP client, or pass a Figma personal access token (figd_...) as a Bearer token."
|
|
668
|
+
: "Set FIGMA_ACCESS_TOKEN in your MCP client config to a Figma personal access token. Generate one at https://www.figma.com/developers/api#access-tokens.";
|
|
669
|
+
return identifiedError(`${context}\nError: ${originalError}\n\nTo fix: ${remediation}${extra ? `\n\n${extra}` : ""}`);
|
|
670
|
+
}
|
|
671
|
+
// Tool 8: Get File Data (General Purpose)
|
|
672
|
+
// NOTE: For specific use cases, consider using specialized tools:
|
|
673
|
+
// - figma_get_component_for_development: For UI component implementation
|
|
674
|
+
// - figma_get_file_for_plugin: For plugin development
|
|
675
|
+
server.tool("figma_get_file_data", "Get full file structure and document tree. WARNING: Can consume large amounts of tokens. NOT recommended for component descriptions (use figma_get_component instead). Best for understanding file structure or finding component nodeIds. Start with verbosity='summary' and depth=1 for initial exploration.", {
|
|
676
|
+
fileUrl: z
|
|
677
|
+
.string()
|
|
678
|
+
.url()
|
|
679
|
+
.optional()
|
|
680
|
+
.describe("Figma file URL (e.g., https://figma.com/design/abc123). Auto-detected from WebSocket Desktop Bridge connection. Only required if not connected."),
|
|
681
|
+
depth: z
|
|
682
|
+
.number()
|
|
683
|
+
.min(0)
|
|
684
|
+
.max(3)
|
|
685
|
+
.optional()
|
|
686
|
+
.default(1)
|
|
687
|
+
.describe("How many levels of children to include (default: 1, max: 3). Start with 1 to prevent context exhaustion. Use 0 for full tree only when absolutely necessary."),
|
|
688
|
+
verbosity: z
|
|
689
|
+
.enum(["summary", "standard", "full"])
|
|
690
|
+
.optional()
|
|
691
|
+
.default("summary")
|
|
692
|
+
.describe("Controls payload size: 'summary' (IDs/names/types only, ~90% smaller - RECOMMENDED), 'standard' (essential properties, ~50% smaller), 'full' (everything). Default: summary for token efficiency."),
|
|
693
|
+
nodeIds: z
|
|
694
|
+
.array(z.string())
|
|
695
|
+
.optional()
|
|
696
|
+
.describe("Specific node IDs to retrieve (optional)"),
|
|
697
|
+
enrich: z
|
|
698
|
+
.boolean()
|
|
699
|
+
.optional()
|
|
700
|
+
.describe("Set to true when user asks for: file statistics, health metrics, design system audit, or quality analysis. Adds statistics, health scores, and audit summaries. Default: false"),
|
|
701
|
+
}, async ({ fileUrl, depth, nodeIds, enrich, verbosity }) => {
|
|
702
|
+
try {
|
|
703
|
+
// Initialize API client (required for file data - no Desktop Bridge alternative)
|
|
704
|
+
let api;
|
|
705
|
+
try {
|
|
706
|
+
api = await getFigmaAPI();
|
|
707
|
+
}
|
|
708
|
+
catch (apiError) {
|
|
709
|
+
const errorMessage = apiError instanceof Error ? apiError.message : String(apiError);
|
|
710
|
+
throw restAuthError("Cannot retrieve file data. REST API authentication required.", errorMessage, "Note: figma_get_file_data requires REST API access. For component-specific data, use figma_get_component which has Desktop Bridge fallback.");
|
|
711
|
+
}
|
|
712
|
+
// Use provided URL or current URL from browser
|
|
713
|
+
const url = fileUrl || getCurrentUrl();
|
|
714
|
+
if (!url) {
|
|
715
|
+
throw new Error("No Figma file URL available. Pass the fileUrl parameter or ensure the Desktop Bridge plugin is open in Figma.");
|
|
716
|
+
}
|
|
717
|
+
const fileKey = extractFileKey(url);
|
|
718
|
+
if (!fileKey) {
|
|
719
|
+
throw new Error(`Invalid Figma URL: ${url}`);
|
|
720
|
+
}
|
|
721
|
+
logger.info({ fileKey, depth, nodeIds, enrich, verbosity }, "Fetching file data");
|
|
722
|
+
const fileData = await api.getFile(fileKey, {
|
|
723
|
+
depth,
|
|
724
|
+
ids: nodeIds,
|
|
725
|
+
});
|
|
726
|
+
// Apply verbosity filtering to reduce payload size
|
|
727
|
+
const filterNode = (node, level) => {
|
|
728
|
+
if (!node)
|
|
729
|
+
return node;
|
|
730
|
+
if (level === "summary") {
|
|
731
|
+
// Summary: Only IDs, names, types (~90% reduction)
|
|
732
|
+
return {
|
|
733
|
+
id: node.id,
|
|
734
|
+
name: node.name,
|
|
735
|
+
type: node.type,
|
|
736
|
+
...(node.children && {
|
|
737
|
+
children: node.children.map((child) => filterNode(child, level))
|
|
738
|
+
}),
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
if (level === "standard") {
|
|
742
|
+
// Standard: Essential properties for plugin development (~50% reduction)
|
|
743
|
+
const filtered = {
|
|
744
|
+
id: node.id,
|
|
745
|
+
name: node.name,
|
|
746
|
+
type: node.type,
|
|
747
|
+
visible: node.visible,
|
|
748
|
+
locked: node.locked,
|
|
749
|
+
};
|
|
750
|
+
// Include bounds for layout calculations
|
|
751
|
+
if (node.absoluteBoundingBox)
|
|
752
|
+
filtered.absoluteBoundingBox = node.absoluteBoundingBox;
|
|
753
|
+
if (node.size)
|
|
754
|
+
filtered.size = node.size;
|
|
755
|
+
// Include component/instance info for plugin work
|
|
756
|
+
if (node.componentId)
|
|
757
|
+
filtered.componentId = node.componentId;
|
|
758
|
+
if (node.componentPropertyReferences)
|
|
759
|
+
filtered.componentPropertyReferences = node.componentPropertyReferences;
|
|
760
|
+
// Include basic styling (but not full details)
|
|
761
|
+
if (node.fills && node.fills.length > 0) {
|
|
762
|
+
filtered.fills = node.fills.map((fill) => ({
|
|
763
|
+
type: fill.type,
|
|
764
|
+
visible: fill.visible,
|
|
765
|
+
...(fill.color && { color: fill.color }),
|
|
766
|
+
}));
|
|
767
|
+
}
|
|
768
|
+
// Include plugin data if present
|
|
769
|
+
if (node.pluginData)
|
|
770
|
+
filtered.pluginData = node.pluginData;
|
|
771
|
+
if (node.sharedPluginData)
|
|
772
|
+
filtered.sharedPluginData = node.sharedPluginData;
|
|
773
|
+
// Recursively filter children
|
|
774
|
+
if (node.children) {
|
|
775
|
+
filtered.children = node.children.map((child) => filterNode(child, level));
|
|
776
|
+
}
|
|
777
|
+
return filtered;
|
|
778
|
+
}
|
|
779
|
+
// Full: Return everything
|
|
780
|
+
return node;
|
|
781
|
+
};
|
|
782
|
+
const filteredDocument = verbosity !== "full"
|
|
783
|
+
? filterNode(fileData.document, verbosity || "standard")
|
|
784
|
+
: fileData.document;
|
|
785
|
+
let response = {
|
|
786
|
+
fileKey,
|
|
787
|
+
name: fileData.name,
|
|
788
|
+
lastModified: fileData.lastModified,
|
|
789
|
+
version: fileData.version,
|
|
790
|
+
document: filteredDocument,
|
|
791
|
+
components: fileData.components
|
|
792
|
+
? Object.keys(fileData.components).length
|
|
793
|
+
: 0,
|
|
794
|
+
styles: fileData.styles
|
|
795
|
+
? Object.keys(fileData.styles).length
|
|
796
|
+
: 0,
|
|
797
|
+
verbosity: verbosity || "standard",
|
|
798
|
+
...(nodeIds && {
|
|
799
|
+
requestedNodes: nodeIds,
|
|
800
|
+
nodes: fileData.nodes,
|
|
801
|
+
}),
|
|
802
|
+
};
|
|
803
|
+
// Apply enrichment if requested
|
|
804
|
+
if (enrich) {
|
|
805
|
+
const enrichmentOptions = {
|
|
806
|
+
enrich: true,
|
|
807
|
+
include_usage: true,
|
|
808
|
+
};
|
|
809
|
+
response = await enrichmentService.enrichFileData({ ...response, ...fileData }, enrichmentOptions);
|
|
810
|
+
}
|
|
811
|
+
const finalResponse = {
|
|
812
|
+
...response,
|
|
813
|
+
enriched: enrich || false,
|
|
814
|
+
};
|
|
815
|
+
// Use adaptive response to prevent context exhaustion
|
|
816
|
+
return adaptiveResponse(finalResponse, {
|
|
817
|
+
toolName: "figma_get_file_data",
|
|
818
|
+
compressionCallback: (adjustedLevel) => {
|
|
819
|
+
// Re-apply node filtering with lower verbosity
|
|
820
|
+
const level = adjustedLevel;
|
|
821
|
+
const refiltered = {
|
|
822
|
+
...finalResponse,
|
|
823
|
+
document: verbosity !== "full"
|
|
824
|
+
? filterNode(fileData.document, level)
|
|
825
|
+
: fileData.document,
|
|
826
|
+
verbosity: level,
|
|
827
|
+
};
|
|
828
|
+
return refiltered;
|
|
829
|
+
},
|
|
830
|
+
suggestedActions: [
|
|
831
|
+
"Use verbosity='summary' with depth=1 for initial exploration",
|
|
832
|
+
"Use verbosity='standard' for essential properties",
|
|
833
|
+
"Request specific nodeIds to narrow the scope",
|
|
834
|
+
"Reduce depth parameter (max 3, recommend 1-2)",
|
|
835
|
+
],
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
catch (error) {
|
|
839
|
+
logger.error({ error }, "Failed to get file data");
|
|
840
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
841
|
+
return {
|
|
842
|
+
content: [
|
|
843
|
+
{
|
|
844
|
+
type: "text",
|
|
845
|
+
text: JSON.stringify({
|
|
846
|
+
error: errorMessage,
|
|
847
|
+
message: "Failed to retrieve Figma file data",
|
|
848
|
+
hint: "Make sure FIGMA_ACCESS_TOKEN is configured and the file is accessible",
|
|
849
|
+
}),
|
|
850
|
+
},
|
|
851
|
+
],
|
|
852
|
+
isError: true,
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
});
|
|
856
|
+
/**
|
|
857
|
+
* Tool 9: Get Variables (Design Tokens)
|
|
858
|
+
*
|
|
859
|
+
* RESOLUTION ORDER (in order, first success wins):
|
|
860
|
+
* 1. Cache hit (if fresh and refreshCache=false)
|
|
861
|
+
* 2. Desktop Bridge plugin via WebSocket — works on any Figma plan
|
|
862
|
+
* 3. REST API — requires Enterprise plan (returns 403 otherwise)
|
|
863
|
+
* 4. Styles API — partial fallback for non-Enterprise (styles, not variables)
|
|
864
|
+
*
|
|
865
|
+
* The legacy `parseFromConsole` two-call console-snippet workflow was
|
|
866
|
+
* removed in the Phase 3 cleanup. Setting parseFromConsole=true now
|
|
867
|
+
* throws an identified error pointing the caller at the bridge.
|
|
868
|
+
*/
|
|
869
|
+
server.tool("figma_get_variables", "Extract design tokens and variables from a Figma file with code export support (CSS, Tailwind, TypeScript, Sass). Use when user asks for: design system tokens, variables, color/spacing values, theme data, or code exports. Handles multi-mode variables (Light/Dark themes). NOT for component metadata (use figma_get_component). Supports filtering by collection/mode/name and verbosity control to prevent token exhaustion. Resolution order: Desktop Bridge plugin (works on any plan) → Variables REST API (Enterprise only) → Styles API as a partial fallback. TIP: For full design system extraction (tokens + components + styles combined), prefer figma_get_design_system_kit instead — it returns everything in one optimized call.", {
|
|
870
|
+
fileUrl: z
|
|
871
|
+
.string()
|
|
872
|
+
.url()
|
|
873
|
+
.optional()
|
|
874
|
+
.describe("Figma file URL (e.g., https://figma.com/design/abc123). Auto-detected from WebSocket Desktop Bridge connection. Only required if not connected."),
|
|
875
|
+
includePublished: z
|
|
876
|
+
.boolean()
|
|
877
|
+
.optional()
|
|
878
|
+
.default(true)
|
|
879
|
+
.describe("Include published variables from libraries"),
|
|
880
|
+
verbosity: z
|
|
881
|
+
.enum(["inventory", "summary", "standard", "full"])
|
|
882
|
+
.optional()
|
|
883
|
+
.default("standard")
|
|
884
|
+
.describe("Controls payload size: 'inventory' (names/IDs only, ~95% smaller, use with filtered), 'summary' (names/values only, ~80% smaller), 'standard' (essential properties, ~45% smaller), 'full' (everything). Default: standard"),
|
|
885
|
+
enrich: z
|
|
886
|
+
.boolean()
|
|
887
|
+
.optional()
|
|
888
|
+
.describe("Set to true when user asks for: CSS/Sass/Tailwind exports, code examples, design tokens, usage information, dependencies, or any export format. Adds resolved values, dependency graphs, and usage analysis. Default: false"),
|
|
889
|
+
include_usage: z
|
|
890
|
+
.boolean()
|
|
891
|
+
.optional()
|
|
892
|
+
.describe("Include usage in styles and components (requires enrich=true)"),
|
|
893
|
+
include_dependencies: z
|
|
894
|
+
.boolean()
|
|
895
|
+
.optional()
|
|
896
|
+
.describe("Include variable dependency graph (requires enrich=true)"),
|
|
897
|
+
include_exports: z
|
|
898
|
+
.boolean()
|
|
899
|
+
.optional()
|
|
900
|
+
.describe("Include export format examples (requires enrich=true)"),
|
|
901
|
+
export_formats: z
|
|
902
|
+
.array(z.enum(["css", "sass", "tailwind", "typescript", "json"]))
|
|
903
|
+
.optional()
|
|
904
|
+
.describe("Which code formats to generate examples for. Use when user mentions specific formats like 'CSS', 'Tailwind', 'SCSS', 'TypeScript', etc. Automatically enables enrichment."),
|
|
905
|
+
format: z
|
|
906
|
+
.enum(["summary", "filtered", "full"])
|
|
907
|
+
.optional()
|
|
908
|
+
.default("full")
|
|
909
|
+
.describe("Response format: 'summary' (~2K tokens with overview and names only), 'filtered' (apply collection/name/mode filters), 'full' (complete dataset from cache or fetch). " +
|
|
910
|
+
"Summary is recommended for initial exploration. Full format returns all data but may be auto-summarized if >25K tokens. Default: full"),
|
|
911
|
+
collection: z
|
|
912
|
+
.string()
|
|
913
|
+
.optional()
|
|
914
|
+
.describe("Filter variables by collection name or ID. Case-insensitive substring match. Only applies when format='filtered'. Example: 'Primitives' or 'VariableCollectionId:123'"),
|
|
915
|
+
namePattern: z
|
|
916
|
+
.string()
|
|
917
|
+
.optional()
|
|
918
|
+
.describe("Filter variables by name using regex pattern or substring. Case-insensitive. Only applies when format='filtered'. Example: 'color/brand' or '^typography'"),
|
|
919
|
+
mode: z
|
|
920
|
+
.string()
|
|
921
|
+
.optional()
|
|
922
|
+
.describe("Filter variables by mode name or ID. Only returns variables that have values for this mode. Only applies when format='filtered'. Example: 'Light' or 'Dark'"),
|
|
923
|
+
returnAsLinks: z
|
|
924
|
+
.boolean()
|
|
925
|
+
.optional()
|
|
926
|
+
.default(false)
|
|
927
|
+
.describe("Return variables as resource_link references instead of full data. Drastically reduces payload size (100+ variables = ~20KB vs >1MB). Recommended for large variable sets — combine with format='filtered' + namePattern/collection/mode to fetch only the variables you need. Default: false"),
|
|
928
|
+
refreshCache: z
|
|
929
|
+
.boolean()
|
|
930
|
+
.optional()
|
|
931
|
+
.default(false)
|
|
932
|
+
.describe("Force refresh cache by fetching fresh data from Figma. Use when data may have changed since last fetch. Default: false (use cached data if available and fresh)"),
|
|
933
|
+
useConsoleFallback: z
|
|
934
|
+
.boolean()
|
|
935
|
+
.optional()
|
|
936
|
+
.default(true)
|
|
937
|
+
.describe("DEPRECATED — has no effect. The console-snippet workflow was removed in the Phase 3 CDP cleanup; the Desktop Bridge plugin now handles all non-REST variable extraction automatically. Kept for parameter compatibility only — safe to ignore."),
|
|
938
|
+
parseFromConsole: z
|
|
939
|
+
.boolean()
|
|
940
|
+
.optional()
|
|
941
|
+
.default(false)
|
|
942
|
+
.describe("DEPRECATED — setting this to true now raises an explicit error. The Puppeteer-based console parser no longer exists. Open the Figma Console MCP Desktop Bridge plugin in Figma Desktop and call figma_get_variables() without parseFromConsole; the plugin returns full variable data through the WebSocket bridge."),
|
|
943
|
+
page: z
|
|
944
|
+
.number()
|
|
945
|
+
.int()
|
|
946
|
+
.min(1)
|
|
947
|
+
.optional()
|
|
948
|
+
.default(1)
|
|
949
|
+
.describe("Page number for paginated results (1-based). Use when response is too large (>1MB). Each page returns up to 50 variables."),
|
|
950
|
+
pageSize: z
|
|
951
|
+
.number()
|
|
952
|
+
.int()
|
|
953
|
+
.min(1)
|
|
954
|
+
.max(100)
|
|
955
|
+
.optional()
|
|
956
|
+
.default(50)
|
|
957
|
+
.describe("Number of variables per page (1-100). Default: 50. Smaller values reduce response size."),
|
|
958
|
+
resolveAliases: z
|
|
959
|
+
.boolean()
|
|
960
|
+
.optional()
|
|
961
|
+
.default(false)
|
|
962
|
+
.describe("Automatically resolve variable aliases to their final values (hex colors, numbers, etc.). " +
|
|
963
|
+
"When true, each variable will include a 'resolvedValuesByMode' field with the actual values " +
|
|
964
|
+
"instead of just alias references. Useful for getting color hex values without manual resolution. " +
|
|
965
|
+
"Default: false."),
|
|
966
|
+
}, async ({ fileUrl, includePublished, verbosity, enrich, include_usage, include_dependencies, include_exports, export_formats, format, collection, namePattern, mode, returnAsLinks, refreshCache, useConsoleFallback, parseFromConsole, page, pageSize, resolveAliases }) => {
|
|
967
|
+
// Extract fileKey and optional branchId outside try block so they're available in catch block
|
|
968
|
+
const url = fileUrl || getCurrentUrl();
|
|
969
|
+
if (!url) {
|
|
970
|
+
return {
|
|
971
|
+
content: [
|
|
972
|
+
{
|
|
973
|
+
type: "text",
|
|
974
|
+
text: JSON.stringify({
|
|
975
|
+
error: "No Figma file URL available",
|
|
976
|
+
message: "Pass the fileUrl parameter or ensure the Desktop Bridge plugin is open in Figma."
|
|
977
|
+
}),
|
|
978
|
+
},
|
|
979
|
+
],
|
|
980
|
+
isError: true,
|
|
981
|
+
};
|
|
982
|
+
}
|
|
983
|
+
// Use extractFigmaUrlInfo to get fileKey, branchId, and nodeId
|
|
984
|
+
const urlInfo = extractFigmaUrlInfo(url);
|
|
985
|
+
if (!urlInfo) {
|
|
986
|
+
return {
|
|
987
|
+
content: [
|
|
988
|
+
{
|
|
989
|
+
type: "text",
|
|
990
|
+
text: JSON.stringify({
|
|
991
|
+
error: `Invalid Figma URL: ${url}`,
|
|
992
|
+
message: "Could not extract file key from URL"
|
|
993
|
+
}),
|
|
994
|
+
},
|
|
995
|
+
],
|
|
996
|
+
isError: true,
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
// For branch URLs, the branchId IS the file key to use for API calls
|
|
1000
|
+
// Figma branch URLs contain the branch key directly in the path
|
|
1001
|
+
const fileKey = urlInfo.branchId || urlInfo.fileKey;
|
|
1002
|
+
const mainFileKey = urlInfo.fileKey;
|
|
1003
|
+
const branchId = urlInfo.branchId;
|
|
1004
|
+
if (branchId) {
|
|
1005
|
+
logger.info({ mainFileKey, branchId, effectiveFileKey: fileKey }, 'Branch URL detected, using branch key for API calls');
|
|
1006
|
+
}
|
|
1007
|
+
try {
|
|
1008
|
+
// =====================================================================
|
|
1009
|
+
// CACHE-FIRST LOGIC: Check if we have cached data before fetching
|
|
1010
|
+
// =====================================================================
|
|
1011
|
+
let cachedData = null;
|
|
1012
|
+
let shouldFetch = true;
|
|
1013
|
+
if (variablesCache && !parseFromConsole) {
|
|
1014
|
+
const cacheEntry = variablesCache.get(fileKey);
|
|
1015
|
+
if (cacheEntry) {
|
|
1016
|
+
const isValid = isCacheValid(cacheEntry.timestamp);
|
|
1017
|
+
if (isValid && !refreshCache) {
|
|
1018
|
+
// Cache hit! Use cached data
|
|
1019
|
+
cachedData = cacheEntry.data;
|
|
1020
|
+
shouldFetch = false;
|
|
1021
|
+
logger.info({
|
|
1022
|
+
fileKey,
|
|
1023
|
+
cacheAge: Date.now() - cacheEntry.timestamp,
|
|
1024
|
+
variableCount: cachedData.variables?.length,
|
|
1025
|
+
}, 'Using cached variables data');
|
|
1026
|
+
}
|
|
1027
|
+
else if (!isValid) {
|
|
1028
|
+
logger.info({ fileKey, cacheAge: Date.now() - cacheEntry.timestamp }, 'Cache expired, will refresh');
|
|
1029
|
+
}
|
|
1030
|
+
else if (refreshCache) {
|
|
1031
|
+
logger.info({ fileKey }, 'Refresh cache requested, will fetch fresh data');
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
else {
|
|
1035
|
+
logger.info({ fileKey }, 'No cache entry found, will fetch data');
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
// If we have cached data, skip fetching and jump to formatting
|
|
1039
|
+
if (cachedData && !shouldFetch) {
|
|
1040
|
+
// Apply format logic based on user request
|
|
1041
|
+
let responseData = cachedData;
|
|
1042
|
+
let paginationInfo = null;
|
|
1043
|
+
if (format === 'summary') {
|
|
1044
|
+
// Return compact summary
|
|
1045
|
+
responseData = generateSummary(cachedData);
|
|
1046
|
+
logger.info({ fileKey, estimatedTokens: estimateTokens(responseData) }, 'Generated summary from cache');
|
|
1047
|
+
}
|
|
1048
|
+
else if (format === 'filtered') {
|
|
1049
|
+
// Apply filters with verbosity-aware valuesByMode transformation
|
|
1050
|
+
responseData = applyFilters(cachedData, {
|
|
1051
|
+
collection,
|
|
1052
|
+
namePattern,
|
|
1053
|
+
mode,
|
|
1054
|
+
}, verbosity || 'standard');
|
|
1055
|
+
// ALWAYS apply pagination for filtered results to prevent 1MB limit
|
|
1056
|
+
// Default to page 1, pageSize 50 if not specified
|
|
1057
|
+
const paginated = paginateVariables(responseData, page || 1, pageSize || 50);
|
|
1058
|
+
responseData = paginated.data;
|
|
1059
|
+
paginationInfo = paginated.pagination;
|
|
1060
|
+
// Apply verbosity filtering to minimize payload size
|
|
1061
|
+
// For filtered results, default to "inventory" for maximum size reduction
|
|
1062
|
+
const effectiveVerbosity = verbosity || "inventory";
|
|
1063
|
+
// CRITICAL FIX: Only include collections referenced by paginated variables
|
|
1064
|
+
const referencedCollectionIds = new Set(responseData.variables.map((v) => v.variableCollectionId));
|
|
1065
|
+
responseData.variableCollections = responseData.variableCollections.filter((c) => referencedCollectionIds.has(c.id));
|
|
1066
|
+
// Filter variables to minimal needed fields
|
|
1067
|
+
responseData.variables = responseData.variables.map((v) => {
|
|
1068
|
+
if (effectiveVerbosity === "inventory") {
|
|
1069
|
+
// Ultra-minimal: just names and IDs for inventory purposes
|
|
1070
|
+
// If mode filter is specified, include only that mode's value
|
|
1071
|
+
const result = {
|
|
1072
|
+
id: v.id,
|
|
1073
|
+
name: v.name,
|
|
1074
|
+
collectionId: v.variableCollectionId,
|
|
1075
|
+
};
|
|
1076
|
+
// If mode filter specified, include just that single mode's value
|
|
1077
|
+
if (mode && v.valuesByMode) {
|
|
1078
|
+
// Find the mode ID from the collection
|
|
1079
|
+
const collection = responseData.variableCollections.find((c) => c.id === v.variableCollectionId);
|
|
1080
|
+
if (collection?.modes) {
|
|
1081
|
+
const modeObj = collection.modes.find((m) => m.name?.toLowerCase().includes(mode.toLowerCase()) || m.modeId === mode);
|
|
1082
|
+
if (modeObj && v.valuesByMode[modeObj.modeId]) {
|
|
1083
|
+
result.value = v.valuesByMode[modeObj.modeId];
|
|
1084
|
+
result.mode = modeObj.name;
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
return result;
|
|
1089
|
+
}
|
|
1090
|
+
if (effectiveVerbosity === "summary") {
|
|
1091
|
+
return {
|
|
1092
|
+
id: v.id,
|
|
1093
|
+
name: v.name,
|
|
1094
|
+
resolvedType: v.resolvedType,
|
|
1095
|
+
valuesByMode: v.valuesByMode,
|
|
1096
|
+
variableCollectionId: v.variableCollectionId,
|
|
1097
|
+
// Include modeNames and modeCount added by applyFilters
|
|
1098
|
+
...(v.modeNames && { modeNames: v.modeNames }),
|
|
1099
|
+
...(v.modeCount && { modeCount: v.modeCount }),
|
|
1100
|
+
};
|
|
1101
|
+
}
|
|
1102
|
+
if (effectiveVerbosity === "standard") {
|
|
1103
|
+
return {
|
|
1104
|
+
id: v.id,
|
|
1105
|
+
name: v.name,
|
|
1106
|
+
resolvedType: v.resolvedType,
|
|
1107
|
+
valuesByMode: v.valuesByMode,
|
|
1108
|
+
description: v.description,
|
|
1109
|
+
variableCollectionId: v.variableCollectionId,
|
|
1110
|
+
};
|
|
1111
|
+
}
|
|
1112
|
+
return v; // full
|
|
1113
|
+
});
|
|
1114
|
+
// Filter collections to remove massive variableIds arrays
|
|
1115
|
+
responseData.variableCollections = responseData.variableCollections.map((c) => {
|
|
1116
|
+
if (effectiveVerbosity === "inventory") {
|
|
1117
|
+
// Ultra-minimal: just ID and name, mode names only (no full mode objects)
|
|
1118
|
+
return {
|
|
1119
|
+
id: c.id,
|
|
1120
|
+
name: c.name,
|
|
1121
|
+
modeNames: c.modes?.map((m) => m.name) || [],
|
|
1122
|
+
};
|
|
1123
|
+
}
|
|
1124
|
+
if (effectiveVerbosity === "summary") {
|
|
1125
|
+
return {
|
|
1126
|
+
id: c.id,
|
|
1127
|
+
name: c.name,
|
|
1128
|
+
modes: c.modes, // Keep modes for user to understand mode structure
|
|
1129
|
+
};
|
|
1130
|
+
}
|
|
1131
|
+
if (effectiveVerbosity === "standard") {
|
|
1132
|
+
return {
|
|
1133
|
+
id: c.id,
|
|
1134
|
+
name: c.name,
|
|
1135
|
+
modes: c.modes,
|
|
1136
|
+
defaultModeId: c.defaultModeId,
|
|
1137
|
+
};
|
|
1138
|
+
}
|
|
1139
|
+
// For full, remove variableIds array to reduce size
|
|
1140
|
+
const { variableIds, ...rest } = c;
|
|
1141
|
+
return rest;
|
|
1142
|
+
});
|
|
1143
|
+
logger.info({
|
|
1144
|
+
fileKey,
|
|
1145
|
+
originalCount: cachedData.variables?.length,
|
|
1146
|
+
filteredCount: paginationInfo.totalVariables,
|
|
1147
|
+
returnedCount: responseData.variables?.length,
|
|
1148
|
+
page: paginationInfo.currentPage,
|
|
1149
|
+
totalPages: paginationInfo.totalPages,
|
|
1150
|
+
verbosity: effectiveVerbosity,
|
|
1151
|
+
}, 'Applied filters, pagination, and verbosity filtering to cached data');
|
|
1152
|
+
// Apply alias resolution if requested
|
|
1153
|
+
if (resolveAliases && responseData.variables?.length > 0) {
|
|
1154
|
+
// Build maps from ALL cached variables (not just filtered) for resolution
|
|
1155
|
+
const allVariablesMap = new Map();
|
|
1156
|
+
const collectionsMap = new Map();
|
|
1157
|
+
for (const v of cachedData.variables || []) {
|
|
1158
|
+
allVariablesMap.set(v.id, v);
|
|
1159
|
+
}
|
|
1160
|
+
for (const c of cachedData.variableCollections || []) {
|
|
1161
|
+
collectionsMap.set(c.id, c);
|
|
1162
|
+
}
|
|
1163
|
+
responseData.variables = resolveVariableAliases(responseData.variables, allVariablesMap, collectionsMap);
|
|
1164
|
+
logger.info({ fileKey, resolvedCount: responseData.variables.length }, 'Applied alias resolution to filtered variables');
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
else {
|
|
1168
|
+
// format === 'full'
|
|
1169
|
+
// Check if we need to auto-summarize
|
|
1170
|
+
const estimatedTokens = estimateTokens(responseData);
|
|
1171
|
+
if (estimatedTokens > 25000) {
|
|
1172
|
+
logger.warn({ fileKey, estimatedTokens }, 'Full data exceeds MCP token limit (25K), auto-summarizing. Use format=summary or format=filtered to get specific data.');
|
|
1173
|
+
const summary = generateSummary(responseData);
|
|
1174
|
+
return {
|
|
1175
|
+
content: [
|
|
1176
|
+
{
|
|
1177
|
+
type: "text",
|
|
1178
|
+
text: JSON.stringify({
|
|
1179
|
+
fileKey,
|
|
1180
|
+
source: 'cache_auto_summarized',
|
|
1181
|
+
warning: 'Full dataset exceeds MCP token limit (25,000 tokens)',
|
|
1182
|
+
suggestion: 'Use format="summary" for overview or format="filtered" with collection/namePattern/mode filters to get specific variables',
|
|
1183
|
+
estimatedTokens,
|
|
1184
|
+
summary,
|
|
1185
|
+
}),
|
|
1186
|
+
},
|
|
1187
|
+
],
|
|
1188
|
+
};
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
// Apply alias resolution for 'full' format if not already applied (filtered format handles it above)
|
|
1192
|
+
if (resolveAliases && format !== 'filtered' && responseData.variables?.length > 0) {
|
|
1193
|
+
// Build maps from ALL cached variables for resolution
|
|
1194
|
+
const allVariablesMap = new Map();
|
|
1195
|
+
const collectionsMap = new Map();
|
|
1196
|
+
for (const v of cachedData.variables || []) {
|
|
1197
|
+
allVariablesMap.set(v.id, v);
|
|
1198
|
+
}
|
|
1199
|
+
for (const c of cachedData.variableCollections || []) {
|
|
1200
|
+
collectionsMap.set(c.id, c);
|
|
1201
|
+
}
|
|
1202
|
+
responseData.variables = resolveVariableAliases(responseData.variables, allVariablesMap, collectionsMap);
|
|
1203
|
+
logger.info({ fileKey, resolvedCount: responseData.variables.length, format }, 'Applied alias resolution to variables (full/summary format)');
|
|
1204
|
+
}
|
|
1205
|
+
// Return cached/processed data
|
|
1206
|
+
// If returnAsLinks=true, return resource_link references instead of full data
|
|
1207
|
+
if (returnAsLinks) {
|
|
1208
|
+
const summary = {
|
|
1209
|
+
fileKey,
|
|
1210
|
+
source: 'cache',
|
|
1211
|
+
totalVariables: responseData.variables?.length || 0,
|
|
1212
|
+
totalCollections: responseData.variableCollections?.length || 0,
|
|
1213
|
+
...(paginationInfo && { pagination: paginationInfo }),
|
|
1214
|
+
};
|
|
1215
|
+
// Build resource_link content for each variable
|
|
1216
|
+
const content = [
|
|
1217
|
+
{
|
|
1218
|
+
type: "text",
|
|
1219
|
+
text: JSON.stringify(summary),
|
|
1220
|
+
},
|
|
1221
|
+
];
|
|
1222
|
+
// Add resource_link for each variable (minimal overhead ~150 bytes each)
|
|
1223
|
+
responseData.variables?.forEach((v) => {
|
|
1224
|
+
content.push({
|
|
1225
|
+
type: "resource_link",
|
|
1226
|
+
uri: `figma://variable/${v.id}`,
|
|
1227
|
+
name: v.name || v.id,
|
|
1228
|
+
description: `${v.resolvedType || 'VARIABLE'} from ${fileKey}`,
|
|
1229
|
+
});
|
|
1230
|
+
});
|
|
1231
|
+
logger.info({
|
|
1232
|
+
fileKey,
|
|
1233
|
+
format: 'resource_links',
|
|
1234
|
+
variableCount: responseData.variables?.length || 0,
|
|
1235
|
+
linkCount: content.length - 1,
|
|
1236
|
+
estimatedSizeKB: (content.length * 150) / 1024,
|
|
1237
|
+
}, `Returning variables as resource_links`);
|
|
1238
|
+
return { content };
|
|
1239
|
+
}
|
|
1240
|
+
// Default: return full data
|
|
1241
|
+
const responsePayload = {
|
|
1242
|
+
fileKey,
|
|
1243
|
+
source: 'cache',
|
|
1244
|
+
format: format || 'full',
|
|
1245
|
+
timestamp: cachedData.timestamp,
|
|
1246
|
+
data: responseData,
|
|
1247
|
+
...(paginationInfo && { pagination: paginationInfo }),
|
|
1248
|
+
};
|
|
1249
|
+
// Remove pretty printing to reduce payload size by 30-40%
|
|
1250
|
+
const responseText = JSON.stringify(responsePayload);
|
|
1251
|
+
const responseSizeBytes = Buffer.byteLength(responseText, 'utf8');
|
|
1252
|
+
const responseSizeMB = (responseSizeBytes / (1024 * 1024)).toFixed(2);
|
|
1253
|
+
logger.info({
|
|
1254
|
+
fileKey,
|
|
1255
|
+
format: format || 'full',
|
|
1256
|
+
verbosity: verbosity || 'standard',
|
|
1257
|
+
variableCount: responseData.variables?.length || 0,
|
|
1258
|
+
collectionCount: responseData.variableCollections?.length || 0,
|
|
1259
|
+
responseSizeBytes,
|
|
1260
|
+
responseSizeMB: `${responseSizeMB} MB`,
|
|
1261
|
+
isUnder1MB: responseSizeBytes < 1024 * 1024,
|
|
1262
|
+
}, `Response size check: ${responseSizeMB} MB`);
|
|
1263
|
+
return {
|
|
1264
|
+
content: [
|
|
1265
|
+
{
|
|
1266
|
+
type: "text",
|
|
1267
|
+
text: responseText,
|
|
1268
|
+
},
|
|
1269
|
+
],
|
|
1270
|
+
};
|
|
1271
|
+
}
|
|
1272
|
+
// =====================================================================
|
|
1273
|
+
// FETCH LOGIC: No cache or cache invalid/refresh requested
|
|
1274
|
+
// =====================================================================
|
|
1275
|
+
// Check if REST API token is available
|
|
1276
|
+
const hasToken = !!process.env.FIGMA_ACCESS_TOKEN;
|
|
1277
|
+
let restApiSucceeded = false;
|
|
1278
|
+
const hasDesktopConnection = !!getDesktopConnector;
|
|
1279
|
+
// PRIORITY LOGIC:
|
|
1280
|
+
// 1. If Desktop Bridge connected → Try Desktop Bridge FIRST (instant, all plans, full Plugin API data)
|
|
1281
|
+
// 2. If no Desktop Bridge OR it fails → Try REST API as fallback (Enterprise users)
|
|
1282
|
+
// 3. If both fail → Console snippet fallback (manual user step)
|
|
1283
|
+
logger.info({ hasToken, hasDesktopConnection }, "Authentication method detection");
|
|
1284
|
+
// Try REST API only when Desktop Bridge is NOT available
|
|
1285
|
+
if (hasToken && !parseFromConsole && !hasDesktopConnection) {
|
|
1286
|
+
try {
|
|
1287
|
+
logger.info({ fileKey, includePublished, verbosity, enrich }, "Fetching variables via REST API (priority: token detected)");
|
|
1288
|
+
const api = await getFigmaAPI();
|
|
1289
|
+
// Wrap API call with timeout to prevent indefinite hangs (30s timeout)
|
|
1290
|
+
const { local, published, localError, publishedError } = await withTimeout(api.getAllVariables(fileKey), 30000, 'Figma Variables API');
|
|
1291
|
+
// If local variables failed (e.g., 403 without Enterprise), fall through to Desktop Bridge
|
|
1292
|
+
if (localError) {
|
|
1293
|
+
logger.warn({ error: localError, fileKey }, "REST API failed to get local variables, falling back to Desktop Bridge");
|
|
1294
|
+
throw new Error(localError);
|
|
1295
|
+
}
|
|
1296
|
+
let localFormatted = formatVariables(local);
|
|
1297
|
+
let publishedFormatted = includePublished
|
|
1298
|
+
? formatVariables(published)
|
|
1299
|
+
: null;
|
|
1300
|
+
// DEBUG: Check if valuesByMode exists before filtering
|
|
1301
|
+
if (localFormatted.variables[0]) {
|
|
1302
|
+
logger.info({
|
|
1303
|
+
hasValuesByMode: !!localFormatted.variables[0].valuesByMode,
|
|
1304
|
+
variableKeys: Object.keys(localFormatted.variables[0]),
|
|
1305
|
+
collectionCount: localFormatted.collections?.length,
|
|
1306
|
+
}, 'Variable structure before filtering');
|
|
1307
|
+
}
|
|
1308
|
+
// Apply collection/name/mode filtering if format is 'filtered'
|
|
1309
|
+
if (format === 'filtered') {
|
|
1310
|
+
// Create properly structured data for applyFilters
|
|
1311
|
+
const dataToFilter = {
|
|
1312
|
+
variables: localFormatted.variables,
|
|
1313
|
+
variableCollections: localFormatted.collections,
|
|
1314
|
+
};
|
|
1315
|
+
const filteredLocal = applyFilters(dataToFilter, { collection, namePattern, mode }, verbosity || "standard");
|
|
1316
|
+
localFormatted = {
|
|
1317
|
+
summary: localFormatted.summary,
|
|
1318
|
+
collections: filteredLocal.variableCollections,
|
|
1319
|
+
variables: filteredLocal.variables,
|
|
1320
|
+
};
|
|
1321
|
+
// Also filter published if included
|
|
1322
|
+
if (includePublished && publishedFormatted) {
|
|
1323
|
+
const dataToFilterPublished = {
|
|
1324
|
+
variables: publishedFormatted.variables,
|
|
1325
|
+
variableCollections: publishedFormatted.collections,
|
|
1326
|
+
};
|
|
1327
|
+
const filteredPublished = applyFilters(dataToFilterPublished, { collection, namePattern, mode }, verbosity || "standard");
|
|
1328
|
+
publishedFormatted = {
|
|
1329
|
+
summary: publishedFormatted.summary,
|
|
1330
|
+
collections: filteredPublished.variableCollections,
|
|
1331
|
+
variables: filteredPublished.variables,
|
|
1332
|
+
};
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
// Apply verbosity filtering after collection/name/mode filters
|
|
1336
|
+
if (verbosity && verbosity !== 'full') {
|
|
1337
|
+
const verbosityFiltered = applyFilters({
|
|
1338
|
+
variables: localFormatted.variables,
|
|
1339
|
+
variableCollections: localFormatted.collections,
|
|
1340
|
+
}, {}, verbosity);
|
|
1341
|
+
localFormatted = {
|
|
1342
|
+
...localFormatted,
|
|
1343
|
+
collections: verbosityFiltered.variableCollections,
|
|
1344
|
+
variables: verbosityFiltered.variables,
|
|
1345
|
+
};
|
|
1346
|
+
if (includePublished && publishedFormatted) {
|
|
1347
|
+
const verbosityFilteredPublished = applyFilters({
|
|
1348
|
+
variables: publishedFormatted.variables,
|
|
1349
|
+
variableCollections: publishedFormatted.collections,
|
|
1350
|
+
}, {}, verbosity);
|
|
1351
|
+
publishedFormatted = {
|
|
1352
|
+
...publishedFormatted,
|
|
1353
|
+
collections: verbosityFilteredPublished.variableCollections,
|
|
1354
|
+
variables: verbosityFilteredPublished.variables,
|
|
1355
|
+
};
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
// Apply pagination if requested
|
|
1359
|
+
let paginationInfo;
|
|
1360
|
+
if (pageSize) {
|
|
1361
|
+
const startIdx = (page - 1) * pageSize;
|
|
1362
|
+
const endIdx = startIdx + pageSize;
|
|
1363
|
+
const totalVars = localFormatted.variables.length;
|
|
1364
|
+
paginationInfo = {
|
|
1365
|
+
page,
|
|
1366
|
+
pageSize,
|
|
1367
|
+
totalItems: totalVars,
|
|
1368
|
+
totalPages: Math.ceil(totalVars / pageSize),
|
|
1369
|
+
hasNextPage: endIdx < totalVars,
|
|
1370
|
+
hasPrevPage: page > 1,
|
|
1371
|
+
};
|
|
1372
|
+
localFormatted.variables = localFormatted.variables.slice(startIdx, endIdx);
|
|
1373
|
+
if (includePublished && publishedFormatted) {
|
|
1374
|
+
publishedFormatted.variables = publishedFormatted.variables.slice(startIdx, endIdx);
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
// Cache the successful REST API response
|
|
1378
|
+
const dataForCache = {
|
|
1379
|
+
fileKey,
|
|
1380
|
+
local: {
|
|
1381
|
+
summary: localFormatted.summary,
|
|
1382
|
+
collections: localFormatted.collections,
|
|
1383
|
+
variables: localFormatted.variables,
|
|
1384
|
+
},
|
|
1385
|
+
...(includePublished &&
|
|
1386
|
+
publishedFormatted && {
|
|
1387
|
+
published: {
|
|
1388
|
+
summary: publishedFormatted.summary,
|
|
1389
|
+
collections: publishedFormatted.collections,
|
|
1390
|
+
variables: publishedFormatted.variables,
|
|
1391
|
+
},
|
|
1392
|
+
}),
|
|
1393
|
+
verbosity: verbosity || "standard",
|
|
1394
|
+
enriched: enrich || false,
|
|
1395
|
+
timestamp: Date.now(),
|
|
1396
|
+
source: "rest_api",
|
|
1397
|
+
};
|
|
1398
|
+
if (variablesCache) {
|
|
1399
|
+
variablesCache.set(fileKey, { data: dataForCache, timestamp: Date.now() });
|
|
1400
|
+
logger.info({ fileKey }, "Cached REST API variables");
|
|
1401
|
+
}
|
|
1402
|
+
// Apply alias resolution if requested (REST API format has local.variables)
|
|
1403
|
+
if (resolveAliases && localFormatted.variables?.length > 0) {
|
|
1404
|
+
// Build maps from local variables and collections
|
|
1405
|
+
const allVariablesMap = new Map();
|
|
1406
|
+
const collectionsMap = new Map();
|
|
1407
|
+
for (const v of localFormatted.variables || []) {
|
|
1408
|
+
allVariablesMap.set(v.id, v);
|
|
1409
|
+
}
|
|
1410
|
+
for (const c of localFormatted.collections || []) {
|
|
1411
|
+
collectionsMap.set(c.id, c);
|
|
1412
|
+
}
|
|
1413
|
+
// Also include published variables if available
|
|
1414
|
+
if (publishedFormatted?.variables) {
|
|
1415
|
+
for (const v of publishedFormatted.variables) {
|
|
1416
|
+
allVariablesMap.set(v.id, v);
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
if (publishedFormatted?.collections) {
|
|
1420
|
+
for (const c of publishedFormatted.collections) {
|
|
1421
|
+
collectionsMap.set(c.id, c);
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
localFormatted.variables = resolveVariableAliases(localFormatted.variables, allVariablesMap, collectionsMap);
|
|
1425
|
+
if (publishedFormatted?.variables) {
|
|
1426
|
+
publishedFormatted.variables = resolveVariableAliases(publishedFormatted.variables, allVariablesMap, collectionsMap);
|
|
1427
|
+
}
|
|
1428
|
+
logger.info({ fileKey, resolvedCount: localFormatted.variables.length }, 'Applied alias resolution to REST API variables');
|
|
1429
|
+
}
|
|
1430
|
+
// Handle resource_links format
|
|
1431
|
+
if (returnAsLinks) {
|
|
1432
|
+
const content = [
|
|
1433
|
+
{
|
|
1434
|
+
type: "text",
|
|
1435
|
+
text: `Variables for file ${fileKey} (${localFormatted.variables.length} variables). Call figma_get_variables again with format='filtered' and a namePattern/collection/mode filter to fetch specific variables:\n\n`,
|
|
1436
|
+
},
|
|
1437
|
+
];
|
|
1438
|
+
for (const variable of localFormatted.variables) {
|
|
1439
|
+
content.push({
|
|
1440
|
+
type: "resource",
|
|
1441
|
+
resource: {
|
|
1442
|
+
uri: `figma://variable/${fileKey}/${variable.id}`,
|
|
1443
|
+
mimeType: "application/json",
|
|
1444
|
+
text: `${variable.name} (${variable.resolvedType})`,
|
|
1445
|
+
},
|
|
1446
|
+
});
|
|
1447
|
+
}
|
|
1448
|
+
logger.info({
|
|
1449
|
+
fileKey,
|
|
1450
|
+
format: 'resource_links',
|
|
1451
|
+
variableCount: localFormatted.variables.length,
|
|
1452
|
+
linkCount: content.length - 1,
|
|
1453
|
+
}, `Returning REST API variables as resource_links`);
|
|
1454
|
+
return { content };
|
|
1455
|
+
}
|
|
1456
|
+
// Build initial response data
|
|
1457
|
+
const responseData = {
|
|
1458
|
+
fileKey,
|
|
1459
|
+
local: {
|
|
1460
|
+
summary: localFormatted.summary,
|
|
1461
|
+
collections: localFormatted.collections,
|
|
1462
|
+
variables: localFormatted.variables,
|
|
1463
|
+
},
|
|
1464
|
+
...(includePublished &&
|
|
1465
|
+
publishedFormatted && {
|
|
1466
|
+
published: {
|
|
1467
|
+
summary: publishedFormatted.summary,
|
|
1468
|
+
collections: publishedFormatted.collections,
|
|
1469
|
+
variables: publishedFormatted.variables,
|
|
1470
|
+
},
|
|
1471
|
+
}),
|
|
1472
|
+
verbosity: verbosity || "standard",
|
|
1473
|
+
enriched: enrich || false,
|
|
1474
|
+
...(paginationInfo && { pagination: paginationInfo }),
|
|
1475
|
+
};
|
|
1476
|
+
// Mark REST API as successful
|
|
1477
|
+
restApiSucceeded = true;
|
|
1478
|
+
logger.info({ fileKey }, "REST API fetch successful, skipping Desktop Bridge");
|
|
1479
|
+
// Use adaptive response to prevent context exhaustion
|
|
1480
|
+
return adaptiveResponse(responseData, {
|
|
1481
|
+
toolName: "figma_get_variables",
|
|
1482
|
+
compressionCallback: (adjustedLevel) => {
|
|
1483
|
+
// Re-apply filters with adjusted verbosity
|
|
1484
|
+
const level = adjustedLevel;
|
|
1485
|
+
const refiltered = applyFilters({
|
|
1486
|
+
variables: localFormatted.variables,
|
|
1487
|
+
variableCollections: localFormatted.collections,
|
|
1488
|
+
}, { collection, namePattern, mode }, level);
|
|
1489
|
+
return {
|
|
1490
|
+
...responseData,
|
|
1491
|
+
local: {
|
|
1492
|
+
...responseData.local,
|
|
1493
|
+
variables: refiltered.variables,
|
|
1494
|
+
collections: refiltered.variableCollections,
|
|
1495
|
+
},
|
|
1496
|
+
verbosity: level,
|
|
1497
|
+
};
|
|
1498
|
+
},
|
|
1499
|
+
suggestedActions: [
|
|
1500
|
+
"Use verbosity='inventory' or 'summary' for large variable sets",
|
|
1501
|
+
"Apply filters: collection, namePattern, or mode parameters",
|
|
1502
|
+
"Use pagination with pageSize parameter (default 50, max 100)",
|
|
1503
|
+
"Use returnAsLinks=true to get resource_link references instead of full data",
|
|
1504
|
+
],
|
|
1505
|
+
});
|
|
1506
|
+
}
|
|
1507
|
+
catch (restError) {
|
|
1508
|
+
const errorMessage = restError instanceof Error ? restError.message : String(restError);
|
|
1509
|
+
// Detect specific error types for better logging and handling
|
|
1510
|
+
const isTimeout = errorMessage.includes('timed out');
|
|
1511
|
+
const isRateLimit = errorMessage.includes('429') || errorMessage.toLowerCase().includes('rate limit');
|
|
1512
|
+
const isAuthError = errorMessage.includes('403') || errorMessage.includes('401');
|
|
1513
|
+
if (isTimeout) {
|
|
1514
|
+
logger.warn({ error: errorMessage, fileKey }, "REST API timed out after 30s, falling back to Desktop Bridge");
|
|
1515
|
+
}
|
|
1516
|
+
else if (isRateLimit) {
|
|
1517
|
+
logger.warn({ error: errorMessage, fileKey }, "REST API rate limited (429), falling back to Desktop Bridge");
|
|
1518
|
+
}
|
|
1519
|
+
else if (isAuthError) {
|
|
1520
|
+
logger.warn({ error: errorMessage, fileKey }, "REST API auth error, check FIGMA_ACCESS_TOKEN validity");
|
|
1521
|
+
}
|
|
1522
|
+
else {
|
|
1523
|
+
logger.warn({ error: errorMessage, fileKey }, "REST API failed, will try Desktop Bridge fallback");
|
|
1524
|
+
}
|
|
1525
|
+
// Don't throw - fall through to Desktop Bridge
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
// PRIMARY: Try Desktop Bridge (instant, all plans, full Plugin API data including aliases)
|
|
1529
|
+
// Also used as fallback when REST API fails (403, timeout, rate limit)
|
|
1530
|
+
if (hasDesktopConnection && !parseFromConsole && !restApiSucceeded && getDesktopConnector) {
|
|
1531
|
+
try {
|
|
1532
|
+
logger.info({ fileKey }, "Attempting to get variables via Desktop connection");
|
|
1533
|
+
const connector = await getDesktopConnector();
|
|
1534
|
+
logger.info({ transport: connector.getTransportType?.() || 'websocket' }, "Desktop connector ready");
|
|
1535
|
+
// When refreshCache is requested, bypass the plugin UI's stale snapshot
|
|
1536
|
+
// and fetch live data directly from the Figma Plugin API
|
|
1537
|
+
const desktopResult = refreshCache
|
|
1538
|
+
? await connector.getVariables(fileKey)
|
|
1539
|
+
: await connector.getVariablesFromPluginUI(fileKey);
|
|
1540
|
+
// EXECUTE_CODE responses come back wrapped one level deeper:
|
|
1541
|
+
// `{ success: true, result: { success: true, variables, ... } }`
|
|
1542
|
+
// because handleResult in ui.html nests the script return value
|
|
1543
|
+
// under `result`. The plugin-UI cache path (GET_VARIABLES_DATA) does
|
|
1544
|
+
// not nest. Unwrap when we detect the EXECUTE_CODE shape so both
|
|
1545
|
+
// paths produce a uniform { success, variables, ... } below. See #68.
|
|
1546
|
+
const variableData = desktopResult?.result?.variables
|
|
1547
|
+
? desktopResult.result
|
|
1548
|
+
: desktopResult;
|
|
1549
|
+
if (variableData?.success && variableData?.variables) {
|
|
1550
|
+
logger.info({
|
|
1551
|
+
variableCount: variableData.variables.length,
|
|
1552
|
+
collectionCount: variableData.variableCollections?.length
|
|
1553
|
+
}, "Successfully retrieved variables via Desktop connection!");
|
|
1554
|
+
// Prepare data for caching (using the raw data, not enriched)
|
|
1555
|
+
const dataForCache = {
|
|
1556
|
+
fileKey,
|
|
1557
|
+
source: "desktop_connection",
|
|
1558
|
+
timestamp: variableData.timestamp || Date.now(),
|
|
1559
|
+
variables: variableData.variables,
|
|
1560
|
+
variableCollections: variableData.variableCollections,
|
|
1561
|
+
};
|
|
1562
|
+
// Store in cache with LRU eviction
|
|
1563
|
+
if (variablesCache) {
|
|
1564
|
+
evictOldestCacheEntry(variablesCache);
|
|
1565
|
+
variablesCache.set(fileKey, {
|
|
1566
|
+
data: dataForCache,
|
|
1567
|
+
timestamp: Date.now(),
|
|
1568
|
+
});
|
|
1569
|
+
logger.info({ fileKey, cacheSize: variablesCache.size }, 'Stored variables in cache');
|
|
1570
|
+
}
|
|
1571
|
+
// Apply format logic
|
|
1572
|
+
let responseData = dataForCache;
|
|
1573
|
+
if (format === 'summary') {
|
|
1574
|
+
responseData = generateSummary(dataForCache);
|
|
1575
|
+
logger.info({ fileKey, estimatedTokens: estimateTokens(responseData) }, 'Generated summary from fetched data');
|
|
1576
|
+
}
|
|
1577
|
+
else if (format === 'filtered') {
|
|
1578
|
+
// Apply filters with verbosity-aware valuesByMode transformation
|
|
1579
|
+
responseData = applyFilters(dataForCache, {
|
|
1580
|
+
collection,
|
|
1581
|
+
namePattern,
|
|
1582
|
+
mode,
|
|
1583
|
+
}, verbosity || 'standard');
|
|
1584
|
+
logger.info({
|
|
1585
|
+
fileKey,
|
|
1586
|
+
originalCount: dataForCache.variables?.length,
|
|
1587
|
+
filteredCount: responseData.variables?.length,
|
|
1588
|
+
}, 'Applied filters to fetched data');
|
|
1589
|
+
// Apply pagination (CRITICAL - was missing!)
|
|
1590
|
+
let paginationInfo = null;
|
|
1591
|
+
const paginated = paginateVariables(responseData, page || 1, pageSize || 50);
|
|
1592
|
+
responseData = paginated.data;
|
|
1593
|
+
paginationInfo = paginated.pagination;
|
|
1594
|
+
// Apply verbosity filtering (CRITICAL - was missing!)
|
|
1595
|
+
const effectiveVerbosity = verbosity || "inventory";
|
|
1596
|
+
// Only include collections referenced by paginated variables
|
|
1597
|
+
const referencedCollectionIds = new Set(responseData.variables.map((v) => v.variableCollectionId));
|
|
1598
|
+
responseData.variableCollections = responseData.variableCollections.filter((c) => referencedCollectionIds.has(c.id));
|
|
1599
|
+
// Filter variables by verbosity
|
|
1600
|
+
responseData.variables = responseData.variables.map((v) => {
|
|
1601
|
+
if (effectiveVerbosity === "inventory") {
|
|
1602
|
+
return {
|
|
1603
|
+
id: v.id,
|
|
1604
|
+
name: v.name,
|
|
1605
|
+
collectionId: v.variableCollectionId,
|
|
1606
|
+
};
|
|
1607
|
+
}
|
|
1608
|
+
if (effectiveVerbosity === "summary") {
|
|
1609
|
+
return {
|
|
1610
|
+
id: v.id,
|
|
1611
|
+
name: v.name,
|
|
1612
|
+
resolvedType: v.resolvedType,
|
|
1613
|
+
valuesByMode: v.valuesByMode,
|
|
1614
|
+
variableCollectionId: v.variableCollectionId,
|
|
1615
|
+
};
|
|
1616
|
+
}
|
|
1617
|
+
return v; // standard/full
|
|
1618
|
+
});
|
|
1619
|
+
// Filter collections by verbosity
|
|
1620
|
+
responseData.variableCollections = responseData.variableCollections.map((c) => {
|
|
1621
|
+
if (effectiveVerbosity === "inventory") {
|
|
1622
|
+
return {
|
|
1623
|
+
id: c.id,
|
|
1624
|
+
name: c.name,
|
|
1625
|
+
modeNames: c.modes?.map((m) => m.name) || [],
|
|
1626
|
+
};
|
|
1627
|
+
}
|
|
1628
|
+
if (effectiveVerbosity === "summary") {
|
|
1629
|
+
return {
|
|
1630
|
+
id: c.id,
|
|
1631
|
+
name: c.name,
|
|
1632
|
+
modes: c.modes,
|
|
1633
|
+
};
|
|
1634
|
+
}
|
|
1635
|
+
return c; // standard/full
|
|
1636
|
+
});
|
|
1637
|
+
}
|
|
1638
|
+
else {
|
|
1639
|
+
// format === 'full'
|
|
1640
|
+
// Check if we need to auto-summarize
|
|
1641
|
+
const estimatedTokens = estimateTokens(responseData);
|
|
1642
|
+
if (estimatedTokens > 25000) {
|
|
1643
|
+
logger.warn({ fileKey, estimatedTokens }, 'Full data exceeds MCP token limit (25K), auto-summarizing. Use format=summary or format=filtered to get specific data.');
|
|
1644
|
+
const summary = generateSummary(responseData);
|
|
1645
|
+
return {
|
|
1646
|
+
content: [
|
|
1647
|
+
{
|
|
1648
|
+
type: "text",
|
|
1649
|
+
text: JSON.stringify({
|
|
1650
|
+
fileKey,
|
|
1651
|
+
source: 'desktop_connection_auto_summarized',
|
|
1652
|
+
warning: 'Full dataset exceeds MCP token limit (25,000 tokens)',
|
|
1653
|
+
suggestion: 'Use format="summary" for overview or format="filtered" with collection/namePattern/mode filters to get specific variables',
|
|
1654
|
+
estimatedTokens,
|
|
1655
|
+
summary,
|
|
1656
|
+
}),
|
|
1657
|
+
},
|
|
1658
|
+
],
|
|
1659
|
+
};
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
// Apply alias resolution if requested
|
|
1663
|
+
if (resolveAliases && responseData.variables?.length > 0) {
|
|
1664
|
+
// Build maps from ALL variables for resolution
|
|
1665
|
+
const allVariablesMap = new Map();
|
|
1666
|
+
const collectionsMap = new Map();
|
|
1667
|
+
for (const v of dataForCache.variables || []) {
|
|
1668
|
+
allVariablesMap.set(v.id, v);
|
|
1669
|
+
}
|
|
1670
|
+
for (const c of dataForCache.variableCollections || []) {
|
|
1671
|
+
collectionsMap.set(c.id, c);
|
|
1672
|
+
}
|
|
1673
|
+
responseData.variables = resolveVariableAliases(responseData.variables, allVariablesMap, collectionsMap);
|
|
1674
|
+
logger.info({ fileKey, resolvedCount: responseData.variables.length }, 'Applied alias resolution to Desktop variables');
|
|
1675
|
+
}
|
|
1676
|
+
// If returnAsLinks=true, return resource_link references
|
|
1677
|
+
if (returnAsLinks) {
|
|
1678
|
+
const summary = {
|
|
1679
|
+
fileKey,
|
|
1680
|
+
source: 'desktop_connection',
|
|
1681
|
+
totalVariables: responseData.variables?.length || 0,
|
|
1682
|
+
totalCollections: responseData.variableCollections?.length || 0,
|
|
1683
|
+
};
|
|
1684
|
+
const content = [
|
|
1685
|
+
{
|
|
1686
|
+
type: "text",
|
|
1687
|
+
text: JSON.stringify(summary),
|
|
1688
|
+
},
|
|
1689
|
+
];
|
|
1690
|
+
// Add resource_link for each variable
|
|
1691
|
+
responseData.variables?.forEach((v) => {
|
|
1692
|
+
content.push({
|
|
1693
|
+
type: "resource_link",
|
|
1694
|
+
uri: `figma://variable/${v.id}`,
|
|
1695
|
+
name: v.name || v.id,
|
|
1696
|
+
description: `${v.resolvedType || 'VARIABLE'} from ${fileKey}`,
|
|
1697
|
+
});
|
|
1698
|
+
});
|
|
1699
|
+
logger.info({
|
|
1700
|
+
fileKey,
|
|
1701
|
+
format: 'resource_links',
|
|
1702
|
+
variableCount: responseData.variables?.length || 0,
|
|
1703
|
+
linkCount: content.length - 1,
|
|
1704
|
+
}, `Returning Desktop variables as resource_links`);
|
|
1705
|
+
return { content };
|
|
1706
|
+
}
|
|
1707
|
+
// Default: return full data (removed pretty printing)
|
|
1708
|
+
return {
|
|
1709
|
+
content: [
|
|
1710
|
+
{
|
|
1711
|
+
type: "text",
|
|
1712
|
+
text: JSON.stringify({
|
|
1713
|
+
fileKey,
|
|
1714
|
+
source: "desktop_connection",
|
|
1715
|
+
format: format || 'full',
|
|
1716
|
+
timestamp: dataForCache.timestamp,
|
|
1717
|
+
data: responseData,
|
|
1718
|
+
cached: false,
|
|
1719
|
+
}),
|
|
1720
|
+
},
|
|
1721
|
+
],
|
|
1722
|
+
};
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
catch (desktopError) {
|
|
1726
|
+
const errorMessage = desktopError instanceof Error ? desktopError.message : String(desktopError);
|
|
1727
|
+
const errorStack = desktopError instanceof Error ? desktopError.stack : undefined;
|
|
1728
|
+
logger.error({
|
|
1729
|
+
error: desktopError,
|
|
1730
|
+
message: errorMessage,
|
|
1731
|
+
stack: errorStack
|
|
1732
|
+
}, "Desktop connection failed, falling back to other methods");
|
|
1733
|
+
// Continue to try REST API fallback
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
// SECONDARY FALLBACK: Try REST API if Desktop Bridge failed/unavailable and token exists
|
|
1737
|
+
if (hasToken && !parseFromConsole && !restApiSucceeded) {
|
|
1738
|
+
try {
|
|
1739
|
+
logger.info({ fileKey }, "Attempting REST API fallback for variables");
|
|
1740
|
+
const api = await getFigmaAPI();
|
|
1741
|
+
const { local, published, localError } = await withTimeout(api.getAllVariables(fileKey), 30000, 'Figma Variables API');
|
|
1742
|
+
if (!localError && local) {
|
|
1743
|
+
let localFormatted = formatVariables(local);
|
|
1744
|
+
let publishedFormatted = includePublished ? formatVariables(published) : null;
|
|
1745
|
+
// Apply filters
|
|
1746
|
+
if (format === 'filtered') {
|
|
1747
|
+
const filteredLocal = applyFilters({ variables: localFormatted.variables, variableCollections: localFormatted.collections }, { collection, namePattern, mode }, verbosity || "standard");
|
|
1748
|
+
localFormatted = { summary: localFormatted.summary, collections: filteredLocal.variableCollections, variables: filteredLocal.variables };
|
|
1749
|
+
}
|
|
1750
|
+
// Apply verbosity
|
|
1751
|
+
if (verbosity && verbosity !== 'full') {
|
|
1752
|
+
const verbosityFiltered = applyFilters({ variables: localFormatted.variables, variableCollections: localFormatted.collections }, {}, verbosity);
|
|
1753
|
+
localFormatted = { ...localFormatted, collections: verbosityFiltered.variableCollections, variables: verbosityFiltered.variables };
|
|
1754
|
+
}
|
|
1755
|
+
// Cache
|
|
1756
|
+
const dataForCache = {
|
|
1757
|
+
fileKey,
|
|
1758
|
+
local: { summary: localFormatted.summary, collections: localFormatted.collections, variables: localFormatted.variables },
|
|
1759
|
+
...(includePublished && publishedFormatted && { published: { summary: publishedFormatted.summary, collections: publishedFormatted.collections, variables: publishedFormatted.variables } }),
|
|
1760
|
+
verbosity: verbosity || "standard",
|
|
1761
|
+
enriched: enrich || false,
|
|
1762
|
+
timestamp: Date.now(),
|
|
1763
|
+
source: "rest_api",
|
|
1764
|
+
};
|
|
1765
|
+
if (variablesCache) {
|
|
1766
|
+
variablesCache.set(fileKey, { data: dataForCache, timestamp: Date.now() });
|
|
1767
|
+
}
|
|
1768
|
+
// Apply alias resolution
|
|
1769
|
+
if (resolveAliases && localFormatted.variables?.length > 0) {
|
|
1770
|
+
const allVariablesMap = new Map();
|
|
1771
|
+
const collectionsMap = new Map();
|
|
1772
|
+
for (const v of localFormatted.variables || [])
|
|
1773
|
+
allVariablesMap.set(v.id, v);
|
|
1774
|
+
for (const c of localFormatted.collections || [])
|
|
1775
|
+
collectionsMap.set(c.id, c);
|
|
1776
|
+
localFormatted.variables = resolveVariableAliases(localFormatted.variables, allVariablesMap, collectionsMap);
|
|
1777
|
+
}
|
|
1778
|
+
restApiSucceeded = true;
|
|
1779
|
+
logger.info({ fileKey }, "REST API fallback succeeded");
|
|
1780
|
+
const responseData = {
|
|
1781
|
+
fileKey,
|
|
1782
|
+
local: { summary: localFormatted.summary, collections: localFormatted.collections, variables: localFormatted.variables },
|
|
1783
|
+
verbosity: verbosity || "standard",
|
|
1784
|
+
enriched: enrich || false,
|
|
1785
|
+
};
|
|
1786
|
+
return adaptiveResponse(responseData, {
|
|
1787
|
+
toolName: "figma_get_variables",
|
|
1788
|
+
suggestedActions: [
|
|
1789
|
+
"Use verbosity='inventory' or 'summary' for large variable sets",
|
|
1790
|
+
"Apply filters: collection, namePattern, or mode parameters",
|
|
1791
|
+
],
|
|
1792
|
+
});
|
|
1793
|
+
}
|
|
1794
|
+
else {
|
|
1795
|
+
logger.warn({ error: localError, fileKey }, "REST API fallback also failed (likely non-Enterprise plan)");
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
catch (restFallbackError) {
|
|
1799
|
+
const msg = restFallbackError instanceof Error ? restFallbackError.message : String(restFallbackError);
|
|
1800
|
+
logger.warn({ error: msg, fileKey }, "REST API fallback failed");
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
// LAST RESORT: parseFromConsole was a Puppeteer-era workflow that read
|
|
1804
|
+
// the magic-string output of a console snippet from the browser's
|
|
1805
|
+
// console buffer. After the Phase 3 CDP cleanup there is no longer a
|
|
1806
|
+
// Puppeteer-attached console for the snippet's output to land in, so
|
|
1807
|
+
// the flag has become a no-op. Tell the caller what to do instead.
|
|
1808
|
+
if (parseFromConsole) {
|
|
1809
|
+
throw identifiedError("parseFromConsole is no longer supported.\n\n" +
|
|
1810
|
+
"The console-snippet workflow it relied on required a Puppeteer browser " +
|
|
1811
|
+
"connection to Figma Desktop, which was removed in Phase 3 of the cleanup. " +
|
|
1812
|
+
"To extract variables, open the Figma Console MCP Desktop Bridge plugin in " +
|
|
1813
|
+
"Figma Desktop and call figma_get_variables() without parseFromConsole — " +
|
|
1814
|
+
"the plugin returns full variable data through the WebSocket bridge.");
|
|
1815
|
+
}
|
|
1816
|
+
// No more fallback options available
|
|
1817
|
+
throw new Error(`Cannot retrieve variables. All methods failed.\n\n` +
|
|
1818
|
+
`Tried methods:\n` +
|
|
1819
|
+
`${hasToken ? '✗ REST API (failed)\n' : ''}` +
|
|
1820
|
+
`✗ Desktop Bridge (failed or not available)\n` +
|
|
1821
|
+
`\nTo fix:\n` +
|
|
1822
|
+
`1. If you have FIGMA_ACCESS_TOKEN: Check your token permissions\n` +
|
|
1823
|
+
`2. Install and run the Figma Desktop Bridge plugin and re-run this tool`);
|
|
1824
|
+
}
|
|
1825
|
+
catch (error) {
|
|
1826
|
+
logger.error({ error }, "Failed to get variables");
|
|
1827
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1828
|
+
// FIXED: Jump directly to Styles API (fast) instead of full file data (slow)
|
|
1829
|
+
if (errorMessage.includes("403")) {
|
|
1830
|
+
try {
|
|
1831
|
+
logger.info({ fileKey }, "Variables API requires Enterprise, falling back to Styles API");
|
|
1832
|
+
let api;
|
|
1833
|
+
try {
|
|
1834
|
+
api = await getFigmaAPI();
|
|
1835
|
+
}
|
|
1836
|
+
catch (apiError) {
|
|
1837
|
+
const errorMessage = apiError instanceof Error ? apiError.message : String(apiError);
|
|
1838
|
+
throw restAuthError("Cannot retrieve variables or styles. REST API authentication required for both.", errorMessage);
|
|
1839
|
+
}
|
|
1840
|
+
// Use the Styles API directly - much faster than getFile!
|
|
1841
|
+
const stylesData = await api.getStyles(fileKey);
|
|
1842
|
+
// Format the styles data similar to variables
|
|
1843
|
+
const formattedStyles = {
|
|
1844
|
+
summary: {
|
|
1845
|
+
total_styles: stylesData.meta?.styles?.length || 0,
|
|
1846
|
+
message: "Variables API requires Enterprise. Here are your design styles instead.",
|
|
1847
|
+
note: "These are Figma Styles (not Variables). Styles are the traditional way to store design tokens in Figma."
|
|
1848
|
+
},
|
|
1849
|
+
styles: stylesData.meta?.styles || []
|
|
1850
|
+
};
|
|
1851
|
+
logger.info({ styleCount: formattedStyles.summary.total_styles }, "Successfully retrieved styles as fallback!");
|
|
1852
|
+
return {
|
|
1853
|
+
content: [
|
|
1854
|
+
{
|
|
1855
|
+
type: "text",
|
|
1856
|
+
text: JSON.stringify({
|
|
1857
|
+
fileKey,
|
|
1858
|
+
source: "styles_api",
|
|
1859
|
+
message: "Variables API requires an Enterprise plan. Retrieved your design system styles instead.",
|
|
1860
|
+
data: formattedStyles,
|
|
1861
|
+
fallback_method: true,
|
|
1862
|
+
}),
|
|
1863
|
+
},
|
|
1864
|
+
],
|
|
1865
|
+
};
|
|
1866
|
+
}
|
|
1867
|
+
catch (styleError) {
|
|
1868
|
+
logger.warn({ error: styleError }, "Style extraction failed");
|
|
1869
|
+
// Return a simple error message without the console snippet
|
|
1870
|
+
return {
|
|
1871
|
+
content: [
|
|
1872
|
+
{
|
|
1873
|
+
type: "text",
|
|
1874
|
+
text: JSON.stringify({
|
|
1875
|
+
error: "Unable to extract variables or styles from this file",
|
|
1876
|
+
message: "The Variables API requires an Enterprise plan, and the automatic style extraction encountered an error.",
|
|
1877
|
+
possibleReasons: [
|
|
1878
|
+
"The file may be private or require additional permissions",
|
|
1879
|
+
"The file structure may not contain extractable styles",
|
|
1880
|
+
"There may be a network or authentication issue"
|
|
1881
|
+
],
|
|
1882
|
+
suggestion: "Please ensure the file is accessible and try again, or check if your token has the necessary permissions.",
|
|
1883
|
+
technical: styleError instanceof Error ? styleError.message : String(styleError)
|
|
1884
|
+
}),
|
|
1885
|
+
},
|
|
1886
|
+
],
|
|
1887
|
+
};
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
// Standard error response
|
|
1891
|
+
return {
|
|
1892
|
+
content: [
|
|
1893
|
+
{
|
|
1894
|
+
type: "text",
|
|
1895
|
+
text: JSON.stringify({
|
|
1896
|
+
error: errorMessage,
|
|
1897
|
+
message: "Failed to retrieve Figma variables",
|
|
1898
|
+
hint: errorMessage.includes("403")
|
|
1899
|
+
? "Variables API requires Enterprise plan. Set useConsoleFallback=true for alternative method."
|
|
1900
|
+
: "Make sure FIGMA_ACCESS_TOKEN is configured and has appropriate permissions",
|
|
1901
|
+
}),
|
|
1902
|
+
},
|
|
1903
|
+
],
|
|
1904
|
+
isError: true,
|
|
1905
|
+
};
|
|
1906
|
+
}
|
|
1907
|
+
});
|
|
1908
|
+
// Tool 10: Get Component Data
|
|
1909
|
+
const componentDescription = isRemoteMode
|
|
1910
|
+
? "Get a SINGLE component's metadata or reconstruction specification. Two export formats: (1) 'metadata' (default) - comprehensive documentation with properties, variants, and design tokens for style guides and references, (2) 'reconstruction' - node tree specification compatible with Figma Component Reconstructor plugin for programmatic component creation. TIP: To get ALL components with visual specs in one call, prefer figma_get_design_system_kit instead."
|
|
1911
|
+
: "Get a SINGLE component's metadata or reconstruction specification. Two export formats: (1) 'metadata' (default) - comprehensive documentation with properties, variants, and design tokens for style guides and references, (2) 'reconstruction' - node tree specification compatible with Figma Component Reconstructor plugin for programmatic component creation. IMPORTANT: For local/unpublished components with metadata format, ensure the Figma Desktop Bridge plugin is running (Right-click in Figma → Plugins → Development → Figma Desktop Bridge) to get complete description data. TIP: To get ALL components with visual specs in one call, prefer figma_get_design_system_kit instead.";
|
|
1912
|
+
server.tool("figma_get_component", componentDescription, {
|
|
1913
|
+
fileUrl: z
|
|
1914
|
+
.string()
|
|
1915
|
+
.url()
|
|
1916
|
+
.optional()
|
|
1917
|
+
.describe("Figma file URL (e.g., https://figma.com/design/abc123). Auto-detected from WebSocket Desktop Bridge connection. Only required if not connected."),
|
|
1918
|
+
nodeId: z
|
|
1919
|
+
.string()
|
|
1920
|
+
.describe("Component node ID (e.g., '123:456')"),
|
|
1921
|
+
format: z
|
|
1922
|
+
.enum(["metadata", "reconstruction"])
|
|
1923
|
+
.optional()
|
|
1924
|
+
.default("metadata")
|
|
1925
|
+
.describe("Export format: 'metadata' (default) for comprehensive documentation, 'reconstruction' for node tree specification compatible with Figma Component Reconstructor plugin"),
|
|
1926
|
+
enrich: z
|
|
1927
|
+
.boolean()
|
|
1928
|
+
.optional()
|
|
1929
|
+
.describe("Set to true when user asks for: design token coverage, hardcoded value analysis, or component quality metrics. Adds token coverage analysis and hardcoded value detection. Default: false. Only applicable for metadata format."),
|
|
1930
|
+
}, async ({ fileUrl, nodeId, format = "metadata", enrich }) => {
|
|
1931
|
+
try {
|
|
1932
|
+
const url = fileUrl || getCurrentUrl();
|
|
1933
|
+
if (!url) {
|
|
1934
|
+
throw new Error("No Figma file URL available. Pass the fileUrl parameter or ensure the Desktop Bridge plugin is open in Figma.");
|
|
1935
|
+
}
|
|
1936
|
+
const fileKey = extractFileKey(url);
|
|
1937
|
+
if (!fileKey) {
|
|
1938
|
+
throw new Error(`Invalid Figma URL: ${url}`);
|
|
1939
|
+
}
|
|
1940
|
+
logger.info({ fileKey, nodeId, format, enrich }, "Fetching component data");
|
|
1941
|
+
// PRIORITY 1: Try Desktop Bridge plugin UI first (has reliable description field!)
|
|
1942
|
+
if (getDesktopConnector) {
|
|
1943
|
+
try {
|
|
1944
|
+
logger.info({ nodeId }, "Attempting to get component via Desktop Bridge plugin UI");
|
|
1945
|
+
const connector = await getDesktopConnector();
|
|
1946
|
+
const desktopResult = await connector.getComponentFromPluginUI(nodeId);
|
|
1947
|
+
if (desktopResult.success && desktopResult.component) {
|
|
1948
|
+
logger.info({
|
|
1949
|
+
componentName: desktopResult.component.name,
|
|
1950
|
+
hasDescription: !!desktopResult.component.description,
|
|
1951
|
+
hasDescriptionMarkdown: !!desktopResult.component.descriptionMarkdown,
|
|
1952
|
+
annotationsCount: desktopResult.component.annotations?.length || 0
|
|
1953
|
+
}, "Successfully retrieved component via Desktop Bridge plugin UI!");
|
|
1954
|
+
// Handle reconstruction format
|
|
1955
|
+
if (format === "reconstruction") {
|
|
1956
|
+
const reconstructionSpec = extractNodeSpec(desktopResult.component);
|
|
1957
|
+
const validation = validateReconstructionSpec(reconstructionSpec);
|
|
1958
|
+
if (!validation.valid) {
|
|
1959
|
+
logger.warn({ errors: validation.errors }, "Reconstruction spec validation warnings");
|
|
1960
|
+
}
|
|
1961
|
+
// Check if this is a COMPONENT_SET - plugin cannot create these
|
|
1962
|
+
if (reconstructionSpec.type === 'COMPONENT_SET') {
|
|
1963
|
+
const variants = listVariants(desktopResult.component);
|
|
1964
|
+
return {
|
|
1965
|
+
content: [
|
|
1966
|
+
{
|
|
1967
|
+
type: "text",
|
|
1968
|
+
text: JSON.stringify({
|
|
1969
|
+
error: "COMPONENT_SET_NOT_SUPPORTED",
|
|
1970
|
+
message: "The Figma Component Reconstructor plugin cannot create COMPONENT_SET nodes (variant containers). Please select a specific variant component instead.",
|
|
1971
|
+
componentName: reconstructionSpec.name,
|
|
1972
|
+
availableVariants: variants,
|
|
1973
|
+
instructions: [
|
|
1974
|
+
"1. In Figma, expand the component set to see individual variants",
|
|
1975
|
+
"2. Select the specific variant you want to reconstruct",
|
|
1976
|
+
"3. Copy the node ID of that variant",
|
|
1977
|
+
"4. Use figma_get_component with that variant's node ID"
|
|
1978
|
+
],
|
|
1979
|
+
note: "COMPONENT_SET is automatically created by Figma when you have variants. The plugin can only create individual COMPONENT nodes."
|
|
1980
|
+
}),
|
|
1981
|
+
},
|
|
1982
|
+
],
|
|
1983
|
+
};
|
|
1984
|
+
}
|
|
1985
|
+
// Return spec directly for plugin compatibility
|
|
1986
|
+
// Plugin expects name, type, etc. at root level
|
|
1987
|
+
return {
|
|
1988
|
+
content: [
|
|
1989
|
+
{
|
|
1990
|
+
type: "text",
|
|
1991
|
+
text: JSON.stringify(reconstructionSpec),
|
|
1992
|
+
},
|
|
1993
|
+
],
|
|
1994
|
+
};
|
|
1995
|
+
}
|
|
1996
|
+
// Handle metadata format (original behavior)
|
|
1997
|
+
let formatted = desktopResult.component;
|
|
1998
|
+
// Apply enrichment if requested
|
|
1999
|
+
if (enrich) {
|
|
2000
|
+
const enrichmentOptions = {
|
|
2001
|
+
enrich: true,
|
|
2002
|
+
include_usage: true,
|
|
2003
|
+
};
|
|
2004
|
+
formatted = await enrichmentService.enrichComponent(formatted, fileKey, enrichmentOptions);
|
|
2005
|
+
}
|
|
2006
|
+
// Surface annotation summary at top level for easy AI consumption
|
|
2007
|
+
const annotations = formatted.annotations || [];
|
|
2008
|
+
const annotationSummary = annotations.length > 0
|
|
2009
|
+
? {
|
|
2010
|
+
count: annotations.length,
|
|
2011
|
+
labels: annotations
|
|
2012
|
+
.filter((a) => a.label || a.labelMarkdown)
|
|
2013
|
+
.map((a) => a.label || (a.labelMarkdown ? a.labelMarkdown.substring(0, 100) : null))
|
|
2014
|
+
.filter(Boolean),
|
|
2015
|
+
pinnedProperties: annotations
|
|
2016
|
+
.filter((a) => a.properties && a.properties.length > 0)
|
|
2017
|
+
.flatMap((a) => a.properties.map((p) => p.type)),
|
|
2018
|
+
hint: "Use figma_get_annotations for full annotation details including categories and markdown content",
|
|
2019
|
+
}
|
|
2020
|
+
: { count: 0, hint: "No annotations found. Designers can add annotations in Dev Mode to communicate specs." };
|
|
2021
|
+
return {
|
|
2022
|
+
content: [
|
|
2023
|
+
{
|
|
2024
|
+
type: "text",
|
|
2025
|
+
text: JSON.stringify({
|
|
2026
|
+
fileKey,
|
|
2027
|
+
nodeId,
|
|
2028
|
+
component: formatted,
|
|
2029
|
+
annotations: annotationSummary,
|
|
2030
|
+
source: "desktop_bridge_plugin",
|
|
2031
|
+
enriched: enrich || false,
|
|
2032
|
+
note: "Retrieved via Desktop Bridge plugin - description fields and annotations are reliable and current"
|
|
2033
|
+
}),
|
|
2034
|
+
},
|
|
2035
|
+
],
|
|
2036
|
+
};
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
catch (desktopError) {
|
|
2040
|
+
logger.warn({ error: desktopError, nodeId }, "Desktop Bridge plugin failed, falling back to REST API");
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
// FALLBACK: Use REST API (may have missing/outdated description)
|
|
2044
|
+
logger.info({ nodeId }, "Using REST API fallback");
|
|
2045
|
+
// Initialize API client (may throw if no token available)
|
|
2046
|
+
let api;
|
|
2047
|
+
try {
|
|
2048
|
+
api = await getFigmaAPI();
|
|
2049
|
+
}
|
|
2050
|
+
catch (apiError) {
|
|
2051
|
+
const errorMessage = apiError instanceof Error ? apiError.message : String(apiError);
|
|
2052
|
+
const dbStatus = getDesktopConnector
|
|
2053
|
+
? "Failed (see logs above)"
|
|
2054
|
+
: "Not available";
|
|
2055
|
+
throw restAuthError("Cannot retrieve component data. Both Desktop Bridge and REST API are unavailable.", `Desktop Bridge: ${dbStatus}; REST API: ${errorMessage}`, "Alternatively: open the Figma Desktop Bridge plugin in Figma Desktop to enable the plugin-based fallback.");
|
|
2056
|
+
}
|
|
2057
|
+
const componentData = await api.getComponentData(fileKey, nodeId);
|
|
2058
|
+
if (!componentData) {
|
|
2059
|
+
throw new Error(`Component not found: ${nodeId}`);
|
|
2060
|
+
}
|
|
2061
|
+
// Handle reconstruction format
|
|
2062
|
+
if (format === "reconstruction") {
|
|
2063
|
+
const reconstructionSpec = extractNodeSpec(componentData.document);
|
|
2064
|
+
const validation = validateReconstructionSpec(reconstructionSpec);
|
|
2065
|
+
if (!validation.valid) {
|
|
2066
|
+
logger.warn({ errors: validation.errors }, "Reconstruction spec validation warnings");
|
|
2067
|
+
}
|
|
2068
|
+
// Check if this is a COMPONENT_SET - plugin cannot create these
|
|
2069
|
+
if (reconstructionSpec.type === 'COMPONENT_SET') {
|
|
2070
|
+
const variants = listVariants(componentData.document);
|
|
2071
|
+
return {
|
|
2072
|
+
content: [
|
|
2073
|
+
{
|
|
2074
|
+
type: "text",
|
|
2075
|
+
text: JSON.stringify({
|
|
2076
|
+
error: "COMPONENT_SET_NOT_SUPPORTED",
|
|
2077
|
+
message: "The Figma Component Reconstructor plugin cannot create COMPONENT_SET nodes (variant containers). Please select a specific variant component instead.",
|
|
2078
|
+
componentName: reconstructionSpec.name,
|
|
2079
|
+
availableVariants: variants,
|
|
2080
|
+
instructions: [
|
|
2081
|
+
"1. In Figma, expand the component set to see individual variants",
|
|
2082
|
+
"2. Select the specific variant you want to reconstruct",
|
|
2083
|
+
"3. Copy the node ID of that variant",
|
|
2084
|
+
"4. Use figma_get_component with that variant's node ID"
|
|
2085
|
+
],
|
|
2086
|
+
note: "COMPONENT_SET is automatically created by Figma when you have variants. The plugin can only create individual COMPONENT nodes."
|
|
2087
|
+
}),
|
|
2088
|
+
},
|
|
2089
|
+
],
|
|
2090
|
+
};
|
|
2091
|
+
}
|
|
2092
|
+
// Return spec directly for plugin compatibility
|
|
2093
|
+
// Plugin expects name, type, etc. at root level
|
|
2094
|
+
return {
|
|
2095
|
+
content: [
|
|
2096
|
+
{
|
|
2097
|
+
type: "text",
|
|
2098
|
+
text: JSON.stringify(reconstructionSpec),
|
|
2099
|
+
},
|
|
2100
|
+
],
|
|
2101
|
+
};
|
|
2102
|
+
}
|
|
2103
|
+
// Handle metadata format (original behavior)
|
|
2104
|
+
let formatted = formatComponentData(componentData.document);
|
|
2105
|
+
// Apply enrichment if requested
|
|
2106
|
+
if (enrich) {
|
|
2107
|
+
const enrichmentOptions = {
|
|
2108
|
+
enrich: true,
|
|
2109
|
+
include_usage: true,
|
|
2110
|
+
};
|
|
2111
|
+
formatted = await enrichmentService.enrichComponent(formatted, fileKey, enrichmentOptions);
|
|
2112
|
+
}
|
|
2113
|
+
return {
|
|
2114
|
+
content: [
|
|
2115
|
+
{
|
|
2116
|
+
type: "text",
|
|
2117
|
+
text: JSON.stringify({
|
|
2118
|
+
fileKey,
|
|
2119
|
+
nodeId,
|
|
2120
|
+
component: formatted,
|
|
2121
|
+
source: "rest_api",
|
|
2122
|
+
enriched: enrich || false,
|
|
2123
|
+
warning: "Retrieved via REST API - description field may be missing due to known Figma API bug",
|
|
2124
|
+
action_required: formatted.description || formatted.descriptionMarkdown ? null : "To get reliable component descriptions, run the Desktop Bridge plugin in Figma Desktop: Right-click → Plugins → Development → Figma Desktop Bridge, then try again."
|
|
2125
|
+
}),
|
|
2126
|
+
},
|
|
2127
|
+
],
|
|
2128
|
+
};
|
|
2129
|
+
}
|
|
2130
|
+
catch (error) {
|
|
2131
|
+
logger.error({ error }, "Failed to get component");
|
|
2132
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2133
|
+
return {
|
|
2134
|
+
content: [
|
|
2135
|
+
{
|
|
2136
|
+
type: "text",
|
|
2137
|
+
text: JSON.stringify({
|
|
2138
|
+
error: errorMessage,
|
|
2139
|
+
message: "Failed to retrieve component data",
|
|
2140
|
+
hint: "Make sure the node ID is correct and the file is accessible",
|
|
2141
|
+
}),
|
|
2142
|
+
},
|
|
2143
|
+
],
|
|
2144
|
+
isError: true,
|
|
2145
|
+
};
|
|
2146
|
+
}
|
|
2147
|
+
});
|
|
2148
|
+
// Tool 11: Get Styles
|
|
2149
|
+
server.tool("figma_get_styles", "Get all styles (color, text, effects, grids) from a Figma file with optional code exports. Use when user asks for: text styles, color palette, design system styles, typography, or style documentation. Returns organized style definitions with resolved values. NOT for design tokens/variables (use figma_get_variables). Set enrich=true for CSS/Tailwind/Sass code examples. Supports verbosity control to manage payload size. TIP: For full design system extraction (tokens + components + styles combined), prefer figma_get_design_system_kit instead — it returns everything in one optimized call.", {
|
|
2150
|
+
fileUrl: z
|
|
2151
|
+
.string()
|
|
2152
|
+
.url()
|
|
2153
|
+
.optional()
|
|
2154
|
+
.describe("Figma file URL (e.g., https://figma.com/design/abc123). Auto-detected from WebSocket Desktop Bridge connection. Only required if not connected."),
|
|
2155
|
+
verbosity: z
|
|
2156
|
+
.enum(["summary", "standard", "full"])
|
|
2157
|
+
.optional()
|
|
2158
|
+
.default("standard")
|
|
2159
|
+
.describe("Controls payload size: 'summary' (names/types only, ~85% smaller), 'standard' (essential properties, ~40% smaller), 'full' (everything). Default: standard"),
|
|
2160
|
+
enrich: z
|
|
2161
|
+
.boolean()
|
|
2162
|
+
.optional()
|
|
2163
|
+
.describe("Set to true when user asks for: CSS/Sass/Tailwind code, export formats, usage information, code examples, or design system exports. Adds resolved values, usage analysis, and export format examples. Default: false for backward compatibility"),
|
|
2164
|
+
include_usage: z
|
|
2165
|
+
.boolean()
|
|
2166
|
+
.optional()
|
|
2167
|
+
.describe("Include component usage information (requires enrich=true)"),
|
|
2168
|
+
include_exports: z
|
|
2169
|
+
.boolean()
|
|
2170
|
+
.optional()
|
|
2171
|
+
.describe("Include export format examples (requires enrich=true)"),
|
|
2172
|
+
export_formats: z
|
|
2173
|
+
.array(z.enum(["css", "sass", "tailwind", "typescript", "json"]))
|
|
2174
|
+
.optional()
|
|
2175
|
+
.describe("Which code formats to generate examples for. Use when user mentions specific formats like 'CSS', 'Tailwind', 'SCSS', 'TypeScript', etc. Automatically enables enrichment. Default: all formats"),
|
|
2176
|
+
}, async ({ fileUrl, verbosity, enrich, include_usage, include_exports, export_formats }) => {
|
|
2177
|
+
try {
|
|
2178
|
+
let api;
|
|
2179
|
+
try {
|
|
2180
|
+
api = await getFigmaAPI();
|
|
2181
|
+
}
|
|
2182
|
+
catch (apiError) {
|
|
2183
|
+
const errorMessage = apiError instanceof Error ? apiError.message : String(apiError);
|
|
2184
|
+
throw restAuthError("Cannot retrieve styles. REST API authentication required.", errorMessage);
|
|
2185
|
+
}
|
|
2186
|
+
const url = fileUrl || getCurrentUrl();
|
|
2187
|
+
if (!url) {
|
|
2188
|
+
throw new Error("No Figma file URL available. Pass the fileUrl parameter or ensure the Desktop Bridge plugin is open in Figma.");
|
|
2189
|
+
}
|
|
2190
|
+
const fileKey = extractFileKey(url);
|
|
2191
|
+
if (!fileKey) {
|
|
2192
|
+
throw new Error(`Invalid Figma URL: ${url}`);
|
|
2193
|
+
}
|
|
2194
|
+
logger.info({ fileKey, verbosity, enrich }, "Fetching styles");
|
|
2195
|
+
// Get styles via REST API
|
|
2196
|
+
const stylesData = await api.getStyles(fileKey);
|
|
2197
|
+
let styles = stylesData.meta?.styles || [];
|
|
2198
|
+
logger.info({ styleCount: styles.length }, "Successfully retrieved styles via REST API");
|
|
2199
|
+
// Apply verbosity filtering
|
|
2200
|
+
const filterStyle = (style, level) => {
|
|
2201
|
+
if (!style)
|
|
2202
|
+
return style;
|
|
2203
|
+
if (level === "summary") {
|
|
2204
|
+
// Summary: Only key, name, type (~85% reduction)
|
|
2205
|
+
return {
|
|
2206
|
+
key: style.key,
|
|
2207
|
+
name: style.name,
|
|
2208
|
+
style_type: style.style_type,
|
|
2209
|
+
};
|
|
2210
|
+
}
|
|
2211
|
+
if (level === "standard") {
|
|
2212
|
+
// Standard: Essential properties (~40% reduction)
|
|
2213
|
+
return {
|
|
2214
|
+
key: style.key,
|
|
2215
|
+
name: style.name,
|
|
2216
|
+
description: style.description,
|
|
2217
|
+
style_type: style.style_type,
|
|
2218
|
+
...(style.remote && { remote: style.remote }),
|
|
2219
|
+
};
|
|
2220
|
+
}
|
|
2221
|
+
// Full: Return everything
|
|
2222
|
+
return style;
|
|
2223
|
+
};
|
|
2224
|
+
if (verbosity !== "full") {
|
|
2225
|
+
styles = styles.map((style) => filterStyle(style, verbosity || "standard"));
|
|
2226
|
+
}
|
|
2227
|
+
// Apply enrichment if requested
|
|
2228
|
+
if (enrich) {
|
|
2229
|
+
const enrichmentOptions = {
|
|
2230
|
+
enrich: true,
|
|
2231
|
+
include_usage: include_usage !== false,
|
|
2232
|
+
include_exports: include_exports !== false,
|
|
2233
|
+
export_formats: export_formats || [
|
|
2234
|
+
"css",
|
|
2235
|
+
"sass",
|
|
2236
|
+
"tailwind",
|
|
2237
|
+
"typescript",
|
|
2238
|
+
"json",
|
|
2239
|
+
],
|
|
2240
|
+
};
|
|
2241
|
+
styles = await enrichmentService.enrichStyles(styles, fileKey, enrichmentOptions);
|
|
2242
|
+
}
|
|
2243
|
+
const finalResponse = {
|
|
2244
|
+
fileKey,
|
|
2245
|
+
styles,
|
|
2246
|
+
totalStyles: styles.length,
|
|
2247
|
+
verbosity: verbosity || "standard",
|
|
2248
|
+
enriched: enrich || false,
|
|
2249
|
+
};
|
|
2250
|
+
// Use adaptive response to prevent context exhaustion
|
|
2251
|
+
return adaptiveResponse(finalResponse, {
|
|
2252
|
+
toolName: "figma_get_styles",
|
|
2253
|
+
compressionCallback: (adjustedLevel) => {
|
|
2254
|
+
// Re-apply style filtering with lower verbosity
|
|
2255
|
+
const level = adjustedLevel;
|
|
2256
|
+
const refilteredStyles = verbosity !== "full"
|
|
2257
|
+
? styles.map((style) => filterStyle(style, level))
|
|
2258
|
+
: styles;
|
|
2259
|
+
return {
|
|
2260
|
+
...finalResponse,
|
|
2261
|
+
styles: refilteredStyles,
|
|
2262
|
+
verbosity: level,
|
|
2263
|
+
};
|
|
2264
|
+
},
|
|
2265
|
+
suggestedActions: [
|
|
2266
|
+
"Use verbosity='summary' for style names and types only",
|
|
2267
|
+
"Use verbosity='standard' for essential style properties",
|
|
2268
|
+
"Filter to specific style types if needed",
|
|
2269
|
+
],
|
|
2270
|
+
});
|
|
2271
|
+
}
|
|
2272
|
+
catch (error) {
|
|
2273
|
+
logger.error({ error }, "Failed to get styles");
|
|
2274
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2275
|
+
return {
|
|
2276
|
+
content: [
|
|
2277
|
+
{
|
|
2278
|
+
type: "text",
|
|
2279
|
+
text: JSON.stringify({
|
|
2280
|
+
error: errorMessage,
|
|
2281
|
+
message: "Failed to retrieve styles",
|
|
2282
|
+
}),
|
|
2283
|
+
},
|
|
2284
|
+
],
|
|
2285
|
+
isError: true,
|
|
2286
|
+
};
|
|
2287
|
+
}
|
|
2288
|
+
});
|
|
2289
|
+
// Tool 12: Get Component Image (Visual Reference)
|
|
2290
|
+
server.tool("figma_get_component_image", "Render a specific component or node as an image (PNG, JPG, SVG, PDF). Returns image URL valid for 30 days. Use when user asks for: component screenshot, visual preview, rendered output, or 'show me'. NOT for component metadata/properties (use figma_get_component). NOT for getting code/layout data (use figma_get_component_for_development). Best for: visual references, design review, documentation.", {
|
|
2291
|
+
fileUrl: z
|
|
2292
|
+
.string()
|
|
2293
|
+
.url()
|
|
2294
|
+
.optional()
|
|
2295
|
+
.describe("Figma file URL (e.g., https://figma.com/design/abc123). Auto-detected from WebSocket Desktop Bridge connection. Only required if not connected."),
|
|
2296
|
+
nodeId: z
|
|
2297
|
+
.string()
|
|
2298
|
+
.describe("Component node ID to render as image (e.g., '695:313')"),
|
|
2299
|
+
scale: z
|
|
2300
|
+
.number()
|
|
2301
|
+
.min(0.01)
|
|
2302
|
+
.max(4)
|
|
2303
|
+
.optional()
|
|
2304
|
+
.default(2)
|
|
2305
|
+
.describe("Image scale factor (0.01-4, default: 2 for high quality)"),
|
|
2306
|
+
format: z
|
|
2307
|
+
.enum(["png", "jpg", "svg", "pdf"])
|
|
2308
|
+
.optional()
|
|
2309
|
+
.default("png")
|
|
2310
|
+
.describe("Image format (default: png)"),
|
|
2311
|
+
}, async ({ fileUrl, nodeId, scale, format }) => {
|
|
2312
|
+
try {
|
|
2313
|
+
let api;
|
|
2314
|
+
try {
|
|
2315
|
+
api = await getFigmaAPI();
|
|
2316
|
+
}
|
|
2317
|
+
catch (apiError) {
|
|
2318
|
+
const errorMessage = apiError instanceof Error ? apiError.message : String(apiError);
|
|
2319
|
+
throw restAuthError("Cannot render component image. REST API authentication required.", errorMessage, "Note: For component screenshots, figma_capture_screenshot may work as an alternative if the Desktop Bridge plugin is connected.");
|
|
2320
|
+
}
|
|
2321
|
+
const url = fileUrl || getCurrentUrl();
|
|
2322
|
+
if (!url) {
|
|
2323
|
+
throw new Error("No Figma file URL available. Pass the fileUrl parameter or ensure the Desktop Bridge plugin is open in Figma.");
|
|
2324
|
+
}
|
|
2325
|
+
const fileKey = extractFileKey(url);
|
|
2326
|
+
if (!fileKey) {
|
|
2327
|
+
throw new Error(`Invalid Figma URL: ${url}`);
|
|
2328
|
+
}
|
|
2329
|
+
logger.info({ fileKey, nodeId, scale, format }, "Rendering component image");
|
|
2330
|
+
// First, fetch the node to check if it's a COMPONENT_SET
|
|
2331
|
+
const fileData = await api.getNodes(fileKey, [nodeId]);
|
|
2332
|
+
const node = fileData.nodes?.[nodeId]?.document;
|
|
2333
|
+
if (!node) {
|
|
2334
|
+
throw new Error(`Node ${nodeId} not found in file ${fileKey}. Please verify the node ID is correct.`);
|
|
2335
|
+
}
|
|
2336
|
+
// Check if this is a COMPONENT_SET - cannot be rendered as image
|
|
2337
|
+
if (node.type === 'COMPONENT_SET') {
|
|
2338
|
+
const variants = listVariants(node);
|
|
2339
|
+
return {
|
|
2340
|
+
content: [
|
|
2341
|
+
{
|
|
2342
|
+
type: "text",
|
|
2343
|
+
text: JSON.stringify({
|
|
2344
|
+
error: "COMPONENT_SET_NOT_RENDERABLE",
|
|
2345
|
+
message: "Node is a COMPONENT_SET which cannot be rendered. Please use a specific variant component ID instead.",
|
|
2346
|
+
componentName: node.name,
|
|
2347
|
+
availableVariants: variants,
|
|
2348
|
+
instructions: [
|
|
2349
|
+
"1. In Figma, expand the component set to see individual variants",
|
|
2350
|
+
"2. Select the specific variant you want to render",
|
|
2351
|
+
"3. Copy the node ID of that variant",
|
|
2352
|
+
"4. Use figma_get_component_image with that variant's node ID"
|
|
2353
|
+
],
|
|
2354
|
+
note: "COMPONENT_SET is a container for variants. Only individual variant components can be rendered as images."
|
|
2355
|
+
}),
|
|
2356
|
+
},
|
|
2357
|
+
],
|
|
2358
|
+
};
|
|
2359
|
+
}
|
|
2360
|
+
// Call the new getImages method
|
|
2361
|
+
const result = await api.getImages(fileKey, nodeId, {
|
|
2362
|
+
scale,
|
|
2363
|
+
format,
|
|
2364
|
+
contents_only: true,
|
|
2365
|
+
});
|
|
2366
|
+
const imageUrl = result.images[nodeId];
|
|
2367
|
+
if (!imageUrl) {
|
|
2368
|
+
throw new Error(`Failed to render image for node ${nodeId}. The node may not exist or may not be renderable.`);
|
|
2369
|
+
}
|
|
2370
|
+
return {
|
|
2371
|
+
content: [
|
|
2372
|
+
{
|
|
2373
|
+
type: "text",
|
|
2374
|
+
text: JSON.stringify({
|
|
2375
|
+
fileKey,
|
|
2376
|
+
nodeId,
|
|
2377
|
+
imageUrl,
|
|
2378
|
+
scale,
|
|
2379
|
+
format,
|
|
2380
|
+
expiresIn: "30 days",
|
|
2381
|
+
note: "Use this image as visual reference for component development. Image URLs expire after 30 days.",
|
|
2382
|
+
}),
|
|
2383
|
+
},
|
|
2384
|
+
],
|
|
2385
|
+
};
|
|
2386
|
+
}
|
|
2387
|
+
catch (error) {
|
|
2388
|
+
logger.error({ error }, "Failed to render component image");
|
|
2389
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2390
|
+
return {
|
|
2391
|
+
content: [
|
|
2392
|
+
{
|
|
2393
|
+
type: "text",
|
|
2394
|
+
text: JSON.stringify({
|
|
2395
|
+
error: errorMessage,
|
|
2396
|
+
message: "Failed to render component image",
|
|
2397
|
+
hint: "Make sure the node ID is correct and the component is renderable",
|
|
2398
|
+
}),
|
|
2399
|
+
},
|
|
2400
|
+
],
|
|
2401
|
+
isError: true,
|
|
2402
|
+
};
|
|
2403
|
+
}
|
|
2404
|
+
});
|
|
2405
|
+
// Tool 13: Get Component for Development (UI Implementation)
|
|
2406
|
+
server.tool("figma_get_component_for_development", "Get component data optimized for high-fidelity UI implementation. Returns a deep component tree (depth 4) with design tokens (boundVariables), interaction states (reactions), sizing constraints (min/max/layoutSizing), text behavior (autoResize, truncation), and design annotations. Automatically includes 2x rendered image. Use when user asks to: 'build this component', 'implement this in React/Vue', 'generate code for', or needs both visual reference and technical specs for production-quality, accessible, token-aware code. For just metadata/descriptions, use figma_get_component. For just image, use figma_get_component_image. For full annotation details, use figma_get_annotations. To resolve variable IDs to names/values, use figma_get_variables.", {
|
|
2407
|
+
fileUrl: z
|
|
2408
|
+
.string()
|
|
2409
|
+
.url()
|
|
2410
|
+
.optional()
|
|
2411
|
+
.describe("Figma file URL (e.g., https://figma.com/design/abc123). REQUIRED unless figma_navigate was already called."),
|
|
2412
|
+
nodeId: z
|
|
2413
|
+
.string()
|
|
2414
|
+
.describe("Component node ID to get data for (e.g., '695:313')"),
|
|
2415
|
+
includeImage: z
|
|
2416
|
+
.boolean()
|
|
2417
|
+
.optional()
|
|
2418
|
+
.default(true)
|
|
2419
|
+
.describe("Include rendered image for visual reference (default: true)"),
|
|
2420
|
+
codebasePath: z
|
|
2421
|
+
.string()
|
|
2422
|
+
.optional()
|
|
2423
|
+
.describe("Path to target codebase components directory (e.g., '/Users/me/project/src/components'). When provided, scans for existing components and includes a registry in the response to prevent recreating components that already exist. Strongly recommended for design-to-code workflows."),
|
|
2424
|
+
}, async ({ fileUrl, nodeId, includeImage, codebasePath }) => {
|
|
2425
|
+
try {
|
|
2426
|
+
let api;
|
|
2427
|
+
try {
|
|
2428
|
+
api = await getFigmaAPI();
|
|
2429
|
+
}
|
|
2430
|
+
catch (apiError) {
|
|
2431
|
+
const errorMessage = apiError instanceof Error ? apiError.message : String(apiError);
|
|
2432
|
+
throw restAuthError("Cannot retrieve component for development. REST API authentication required.", errorMessage, "Note: For component metadata, figma_get_component has a Desktop Bridge fallback that works without a token (requires the Desktop Bridge plugin to be connected).");
|
|
2433
|
+
}
|
|
2434
|
+
const url = fileUrl || getCurrentUrl();
|
|
2435
|
+
if (!url) {
|
|
2436
|
+
throw new Error("No Figma file URL available. Pass the fileUrl parameter or ensure the Desktop Bridge plugin is open in Figma.");
|
|
2437
|
+
}
|
|
2438
|
+
const fileKey = extractFileKey(url);
|
|
2439
|
+
if (!fileKey) {
|
|
2440
|
+
throw new Error(`Invalid Figma URL: ${url}`);
|
|
2441
|
+
}
|
|
2442
|
+
logger.info({ fileKey, nodeId, includeImage }, "Fetching component for development");
|
|
2443
|
+
// Get node data with depth 4 for nested component structures
|
|
2444
|
+
// (depth 2 was too shallow for complex components like data tables, nested menus, etc.)
|
|
2445
|
+
const nodeData = await api.getNodes(fileKey, [nodeId], { depth: 4 });
|
|
2446
|
+
const node = nodeData.nodes?.[nodeId]?.document;
|
|
2447
|
+
if (!node) {
|
|
2448
|
+
throw new Error(`Component not found: ${nodeId}`);
|
|
2449
|
+
}
|
|
2450
|
+
// Filter to development-relevant properties — visual, layout, tokens, interactions
|
|
2451
|
+
const filterForDevelopment = (n) => {
|
|
2452
|
+
if (!n)
|
|
2453
|
+
return n;
|
|
2454
|
+
const result = {
|
|
2455
|
+
id: n.id,
|
|
2456
|
+
name: n.name,
|
|
2457
|
+
type: n.type,
|
|
2458
|
+
description: n.description,
|
|
2459
|
+
descriptionMarkdown: n.descriptionMarkdown,
|
|
2460
|
+
};
|
|
2461
|
+
// Layout & positioning
|
|
2462
|
+
if (n.absoluteBoundingBox)
|
|
2463
|
+
result.absoluteBoundingBox = n.absoluteBoundingBox;
|
|
2464
|
+
if (n.relativeTransform)
|
|
2465
|
+
result.relativeTransform = n.relativeTransform;
|
|
2466
|
+
if (n.size)
|
|
2467
|
+
result.size = n.size;
|
|
2468
|
+
if (n.constraints)
|
|
2469
|
+
result.constraints = n.constraints;
|
|
2470
|
+
if (n.layoutAlign)
|
|
2471
|
+
result.layoutAlign = n.layoutAlign;
|
|
2472
|
+
if (n.layoutGrow)
|
|
2473
|
+
result.layoutGrow = n.layoutGrow;
|
|
2474
|
+
if (n.layoutPositioning)
|
|
2475
|
+
result.layoutPositioning = n.layoutPositioning;
|
|
2476
|
+
// Auto-layout
|
|
2477
|
+
if (n.layoutMode)
|
|
2478
|
+
result.layoutMode = n.layoutMode;
|
|
2479
|
+
if (n.primaryAxisSizingMode)
|
|
2480
|
+
result.primaryAxisSizingMode = n.primaryAxisSizingMode;
|
|
2481
|
+
if (n.counterAxisSizingMode)
|
|
2482
|
+
result.counterAxisSizingMode = n.counterAxisSizingMode;
|
|
2483
|
+
if (n.primaryAxisAlignItems)
|
|
2484
|
+
result.primaryAxisAlignItems = n.primaryAxisAlignItems;
|
|
2485
|
+
if (n.counterAxisAlignItems)
|
|
2486
|
+
result.counterAxisAlignItems = n.counterAxisAlignItems;
|
|
2487
|
+
if (n.paddingLeft !== undefined)
|
|
2488
|
+
result.paddingLeft = n.paddingLeft;
|
|
2489
|
+
if (n.paddingRight !== undefined)
|
|
2490
|
+
result.paddingRight = n.paddingRight;
|
|
2491
|
+
if (n.paddingTop !== undefined)
|
|
2492
|
+
result.paddingTop = n.paddingTop;
|
|
2493
|
+
if (n.paddingBottom !== undefined)
|
|
2494
|
+
result.paddingBottom = n.paddingBottom;
|
|
2495
|
+
if (n.itemSpacing !== undefined)
|
|
2496
|
+
result.itemSpacing = n.itemSpacing;
|
|
2497
|
+
if (n.counterAxisSpacing !== undefined)
|
|
2498
|
+
result.counterAxisSpacing = n.counterAxisSpacing;
|
|
2499
|
+
if (n.itemReverseZIndex)
|
|
2500
|
+
result.itemReverseZIndex = n.itemReverseZIndex;
|
|
2501
|
+
if (n.strokesIncludedInLayout)
|
|
2502
|
+
result.strokesIncludedInLayout = n.strokesIncludedInLayout;
|
|
2503
|
+
if (n.layoutWrap)
|
|
2504
|
+
result.layoutWrap = n.layoutWrap;
|
|
2505
|
+
// Sizing constraints (maps to CSS min/max-width/height, width: auto/100%/fixed)
|
|
2506
|
+
if (n.layoutSizingHorizontal)
|
|
2507
|
+
result.layoutSizingHorizontal = n.layoutSizingHorizontal;
|
|
2508
|
+
if (n.layoutSizingVertical)
|
|
2509
|
+
result.layoutSizingVertical = n.layoutSizingVertical;
|
|
2510
|
+
if (n.minWidth !== undefined)
|
|
2511
|
+
result.minWidth = n.minWidth;
|
|
2512
|
+
if (n.maxWidth !== undefined)
|
|
2513
|
+
result.maxWidth = n.maxWidth;
|
|
2514
|
+
if (n.minHeight !== undefined)
|
|
2515
|
+
result.minHeight = n.minHeight;
|
|
2516
|
+
if (n.maxHeight !== undefined)
|
|
2517
|
+
result.maxHeight = n.maxHeight;
|
|
2518
|
+
// Visual properties
|
|
2519
|
+
if (n.fills)
|
|
2520
|
+
result.fills = n.fills;
|
|
2521
|
+
if (n.strokes)
|
|
2522
|
+
result.strokes = n.strokes;
|
|
2523
|
+
if (n.strokeWeight !== undefined)
|
|
2524
|
+
result.strokeWeight = n.strokeWeight;
|
|
2525
|
+
if (n.strokeAlign)
|
|
2526
|
+
result.strokeAlign = n.strokeAlign;
|
|
2527
|
+
if (n.strokeCap)
|
|
2528
|
+
result.strokeCap = n.strokeCap;
|
|
2529
|
+
if (n.strokeJoin)
|
|
2530
|
+
result.strokeJoin = n.strokeJoin;
|
|
2531
|
+
if (n.dashPattern)
|
|
2532
|
+
result.dashPattern = n.dashPattern;
|
|
2533
|
+
if (n.cornerRadius !== undefined)
|
|
2534
|
+
result.cornerRadius = n.cornerRadius;
|
|
2535
|
+
if (n.rectangleCornerRadii)
|
|
2536
|
+
result.rectangleCornerRadii = n.rectangleCornerRadii;
|
|
2537
|
+
if (n.effects)
|
|
2538
|
+
result.effects = n.effects;
|
|
2539
|
+
if (n.opacity !== undefined)
|
|
2540
|
+
result.opacity = n.opacity;
|
|
2541
|
+
if (n.blendMode)
|
|
2542
|
+
result.blendMode = n.blendMode;
|
|
2543
|
+
if (n.isMask)
|
|
2544
|
+
result.isMask = n.isMask;
|
|
2545
|
+
if (n.clipsContent)
|
|
2546
|
+
result.clipsContent = n.clipsContent;
|
|
2547
|
+
// Design tokens — variable bindings (maps fills/strokes/spacing/etc. to design tokens)
|
|
2548
|
+
if (n.boundVariables)
|
|
2549
|
+
result.boundVariables = n.boundVariables;
|
|
2550
|
+
if (n.styles)
|
|
2551
|
+
result.styles = n.styles;
|
|
2552
|
+
// Vector geometry (SVG path data — only for vector/icon nodes, not regular frames)
|
|
2553
|
+
const isVectorLike = n.type === 'VECTOR' || n.type === 'BOOLEAN_OPERATION' || n.type === 'LINE' || n.type === 'REGULAR_POLYGON' || n.type === 'STAR' || n.type === 'ELLIPSE';
|
|
2554
|
+
if (isVectorLike) {
|
|
2555
|
+
if (n.fillGeometry)
|
|
2556
|
+
result.fillGeometry = n.fillGeometry;
|
|
2557
|
+
if (n.strokeGeometry)
|
|
2558
|
+
result.strokeGeometry = n.strokeGeometry;
|
|
2559
|
+
}
|
|
2560
|
+
// Typography
|
|
2561
|
+
if (n.characters)
|
|
2562
|
+
result.characters = n.characters;
|
|
2563
|
+
if (n.style)
|
|
2564
|
+
result.style = n.style;
|
|
2565
|
+
if (n.characterStyleOverrides)
|
|
2566
|
+
result.characterStyleOverrides = n.characterStyleOverrides;
|
|
2567
|
+
if (n.styleOverrideTable)
|
|
2568
|
+
result.styleOverrideTable = n.styleOverrideTable;
|
|
2569
|
+
// Text behavior (maps to CSS overflow, text-overflow, white-space, text-transform)
|
|
2570
|
+
if (n.textAutoResize)
|
|
2571
|
+
result.textAutoResize = n.textAutoResize;
|
|
2572
|
+
if (n.textTruncation)
|
|
2573
|
+
result.textTruncation = n.textTruncation;
|
|
2574
|
+
if (n.textCase)
|
|
2575
|
+
result.textCase = n.textCase;
|
|
2576
|
+
if (n.textDecoration)
|
|
2577
|
+
result.textDecoration = n.textDecoration;
|
|
2578
|
+
// Component properties & variants
|
|
2579
|
+
if (n.componentProperties) {
|
|
2580
|
+
// Cap componentProperties size — icon instances can have 200KB+ of swap variants
|
|
2581
|
+
const cpJson = JSON.stringify(n.componentProperties);
|
|
2582
|
+
if (cpJson.length > 10000) {
|
|
2583
|
+
// Extract just the property names and types, not the full value catalogs
|
|
2584
|
+
const summary = {};
|
|
2585
|
+
for (const [key, val] of Object.entries(n.componentProperties)) {
|
|
2586
|
+
summary[key] = { type: val.type, value: typeof val.value === 'string' && val.value.length > 200 ? val.value.substring(0, 200) + '...' : val.value };
|
|
2587
|
+
}
|
|
2588
|
+
result.componentProperties = summary;
|
|
2589
|
+
result._componentPropertiesTruncated = true;
|
|
2590
|
+
}
|
|
2591
|
+
else {
|
|
2592
|
+
result.componentProperties = n.componentProperties;
|
|
2593
|
+
}
|
|
2594
|
+
}
|
|
2595
|
+
if (n.componentPropertyDefinitions)
|
|
2596
|
+
result.componentPropertyDefinitions = n.componentPropertyDefinitions;
|
|
2597
|
+
if (n.componentPropertyReferences)
|
|
2598
|
+
result.componentPropertyReferences = n.componentPropertyReferences;
|
|
2599
|
+
if (n.variantProperties)
|
|
2600
|
+
result.variantProperties = n.variantProperties;
|
|
2601
|
+
if (n.componentId)
|
|
2602
|
+
result.componentId = n.componentId;
|
|
2603
|
+
// Prototype interactions (hover, click, focus states and transitions)
|
|
2604
|
+
if (n.reactions && n.reactions.length > 0)
|
|
2605
|
+
result.reactions = n.reactions;
|
|
2606
|
+
if (n.transitionNodeID)
|
|
2607
|
+
result.transitionNodeID = n.transitionNodeID;
|
|
2608
|
+
if (n.transitionDuration !== undefined)
|
|
2609
|
+
result.transitionDuration = n.transitionDuration;
|
|
2610
|
+
if (n.transitionEasing)
|
|
2611
|
+
result.transitionEasing = n.transitionEasing;
|
|
2612
|
+
// State
|
|
2613
|
+
if (n.visible !== undefined)
|
|
2614
|
+
result.visible = n.visible;
|
|
2615
|
+
if (n.locked)
|
|
2616
|
+
result.locked = n.locked;
|
|
2617
|
+
// Recursively process children
|
|
2618
|
+
if (n.children) {
|
|
2619
|
+
result.children = n.children.map((child) => filterForDevelopment(child));
|
|
2620
|
+
}
|
|
2621
|
+
return result;
|
|
2622
|
+
};
|
|
2623
|
+
const componentData = filterForDevelopment(node);
|
|
2624
|
+
// Fetch annotations and descriptions via Desktop Bridge if available
|
|
2625
|
+
// (REST API never has annotations; Desktop Bridge has reliable descriptions)
|
|
2626
|
+
let annotations = [];
|
|
2627
|
+
let annotationSummary = { count: 0 };
|
|
2628
|
+
if (getDesktopConnector) {
|
|
2629
|
+
try {
|
|
2630
|
+
const connector = await getDesktopConnector();
|
|
2631
|
+
// Fetch annotations with child traversal (depth matches REST traversal)
|
|
2632
|
+
const annotResult = await connector.getAnnotations(nodeId, true, 4);
|
|
2633
|
+
if (annotResult?.success !== false && annotResult?.data) {
|
|
2634
|
+
const data = annotResult.data;
|
|
2635
|
+
annotations = data.annotations || [];
|
|
2636
|
+
const childAnnotations = data.children || [];
|
|
2637
|
+
const allAnnotations = [
|
|
2638
|
+
...annotations,
|
|
2639
|
+
...childAnnotations.flatMap((c) => (c.annotations || []).map((a) => ({ ...a, nodeId: c.nodeId, nodeName: c.nodeName })))
|
|
2640
|
+
];
|
|
2641
|
+
annotationSummary = allAnnotations.length > 0
|
|
2642
|
+
? {
|
|
2643
|
+
count: allAnnotations.length,
|
|
2644
|
+
labels: allAnnotations
|
|
2645
|
+
.filter((a) => a.label || a.labelMarkdown)
|
|
2646
|
+
.map((a) => ({
|
|
2647
|
+
text: a.labelMarkdown || a.label,
|
|
2648
|
+
...(a.nodeId ? { onNode: a.nodeName } : {}),
|
|
2649
|
+
})),
|
|
2650
|
+
pinnedProperties: allAnnotations
|
|
2651
|
+
.filter((a) => a.properties && a.properties.length > 0)
|
|
2652
|
+
.flatMap((a) => a.properties.map((p) => p.type)),
|
|
2653
|
+
}
|
|
2654
|
+
: { count: 0 };
|
|
2655
|
+
}
|
|
2656
|
+
// Also fetch description from bridge if REST returned empty
|
|
2657
|
+
if (!componentData.description && !componentData.descriptionMarkdown) {
|
|
2658
|
+
const bridgeResult = await connector.getComponentFromPluginUI(nodeId);
|
|
2659
|
+
if (bridgeResult?.success && bridgeResult.component) {
|
|
2660
|
+
if (bridgeResult.component.descriptionMarkdown) {
|
|
2661
|
+
componentData.descriptionMarkdown = bridgeResult.component.descriptionMarkdown;
|
|
2662
|
+
}
|
|
2663
|
+
if (bridgeResult.component.description) {
|
|
2664
|
+
componentData.description = bridgeResult.component.description;
|
|
2665
|
+
}
|
|
2666
|
+
}
|
|
2667
|
+
}
|
|
2668
|
+
}
|
|
2669
|
+
catch {
|
|
2670
|
+
// Desktop Bridge unavailable — continue without annotations
|
|
2671
|
+
logger.debug("Desktop Bridge unavailable for annotations/description enrichment");
|
|
2672
|
+
}
|
|
2673
|
+
}
|
|
2674
|
+
// Get image if requested
|
|
2675
|
+
let imageUrl = null;
|
|
2676
|
+
if (includeImage) {
|
|
2677
|
+
try {
|
|
2678
|
+
const imageResult = await api.getImages(fileKey, nodeId, {
|
|
2679
|
+
scale: 2,
|
|
2680
|
+
format: "png",
|
|
2681
|
+
contents_only: true,
|
|
2682
|
+
});
|
|
2683
|
+
imageUrl = imageResult.images[nodeId];
|
|
2684
|
+
}
|
|
2685
|
+
catch (error) {
|
|
2686
|
+
logger.warn({ error }, "Failed to render component image, continuing without it");
|
|
2687
|
+
}
|
|
2688
|
+
}
|
|
2689
|
+
// Extract composition dependencies — every INSTANCE sub-component used
|
|
2690
|
+
// This tells the AI which sub-components must exist before building this component
|
|
2691
|
+
const compositionDeps = new Map();
|
|
2692
|
+
const walkForInstances = (n) => {
|
|
2693
|
+
if (!n)
|
|
2694
|
+
return;
|
|
2695
|
+
if (n.type === "INSTANCE" && n.componentId) {
|
|
2696
|
+
const existing = compositionDeps.get(n.componentId);
|
|
2697
|
+
if (existing) {
|
|
2698
|
+
existing.count++;
|
|
2699
|
+
}
|
|
2700
|
+
else {
|
|
2701
|
+
compositionDeps.set(n.componentId, {
|
|
2702
|
+
name: n.name,
|
|
2703
|
+
componentId: n.componentId,
|
|
2704
|
+
count: 1,
|
|
2705
|
+
props: n.componentProperties ? Object.keys(n.componentProperties) : [],
|
|
2706
|
+
});
|
|
2707
|
+
}
|
|
2708
|
+
}
|
|
2709
|
+
if (n.children) {
|
|
2710
|
+
for (const child of n.children) {
|
|
2711
|
+
walkForInstances(child);
|
|
2712
|
+
}
|
|
2713
|
+
}
|
|
2714
|
+
};
|
|
2715
|
+
walkForInstances(componentData);
|
|
2716
|
+
const dependencies = Array.from(compositionDeps.values());
|
|
2717
|
+
// Scan codebase for existing components if path provided
|
|
2718
|
+
let codebaseRegistry = undefined;
|
|
2719
|
+
if (codebasePath) {
|
|
2720
|
+
const existingComponents = scanCodebaseComponents(codebasePath);
|
|
2721
|
+
if (existingComponents.length > 0) {
|
|
2722
|
+
// Cross-reference Figma dependencies against codebase components
|
|
2723
|
+
// Normalize a name to keywords for fuzzy matching
|
|
2724
|
+
// "Input label" → ["input", "label"], "FormLabel" → ["form", "label"], "_Helper text" → ["helper", "text"]
|
|
2725
|
+
const toKeywords = (name) => name.replace(/^_+/, "").replace(/([a-z])([A-Z])/g, "$1 $2").replace(/[-_/]/g, " ").toLowerCase().split(/\s+/).filter(w => w.length > 1);
|
|
2726
|
+
const crossRef = dependencies.map(dep => {
|
|
2727
|
+
const depNameLower = dep.name.replace(/^_/, "").replace(/\s+/g, "").toLowerCase();
|
|
2728
|
+
const depKeywords = toKeywords(dep.name);
|
|
2729
|
+
const match = existingComponents.find(c => {
|
|
2730
|
+
const cNameLower = c.name.toLowerCase();
|
|
2731
|
+
const cKeywords = toKeywords(c.name);
|
|
2732
|
+
// Exact name match
|
|
2733
|
+
if (cNameLower === depNameLower)
|
|
2734
|
+
return true;
|
|
2735
|
+
// Export name match
|
|
2736
|
+
if (c.exports.some(e => e.toLowerCase() === depNameLower))
|
|
2737
|
+
return true;
|
|
2738
|
+
// Substring containment
|
|
2739
|
+
if (depNameLower.includes(cNameLower) || cNameLower.includes(depNameLower))
|
|
2740
|
+
return true;
|
|
2741
|
+
// Keyword overlap — if most keywords from either name match, it's likely the same component
|
|
2742
|
+
// "Input label" ∩ "FormLabel" → ["label"] overlaps, plus "input" ~ "form" (both form-related)
|
|
2743
|
+
const overlap = depKeywords.filter(k => cKeywords.some(ck => ck.includes(k) || k.includes(ck)));
|
|
2744
|
+
if (overlap.length > 0 && overlap.length >= Math.min(depKeywords.length, cKeywords.length) * 0.5)
|
|
2745
|
+
return true;
|
|
2746
|
+
return false;
|
|
2747
|
+
});
|
|
2748
|
+
return {
|
|
2749
|
+
figmaComponent: dep.name,
|
|
2750
|
+
componentId: dep.componentId,
|
|
2751
|
+
codebaseMatch: match ? { name: match.name, path: match.path, exports: match.exports } : null,
|
|
2752
|
+
action: match ? "IMPORT_EXISTING" : "BUILD_NEW",
|
|
2753
|
+
};
|
|
2754
|
+
});
|
|
2755
|
+
codebaseRegistry = {
|
|
2756
|
+
scannedPath: codebasePath,
|
|
2757
|
+
existingComponents: existingComponents.map(c => ({ name: c.name, path: c.path, exports: c.exports })),
|
|
2758
|
+
componentCount: existingComponents.length,
|
|
2759
|
+
crossReference: crossRef.length > 0 ? crossRef : undefined,
|
|
2760
|
+
ai_instruction: `Found ${existingComponents.length} existing components in the target codebase. Components marked IMPORT_EXISTING MUST be imported — never recreate them. Components marked BUILD_NEW need to be created as standalone components (own directory, file, CSS module, stories) before building the parent.`,
|
|
2761
|
+
};
|
|
2762
|
+
}
|
|
2763
|
+
}
|
|
2764
|
+
// Build the full response
|
|
2765
|
+
const response = {
|
|
2766
|
+
fileKey,
|
|
2767
|
+
nodeId,
|
|
2768
|
+
imageUrl,
|
|
2769
|
+
component: componentData,
|
|
2770
|
+
annotations: annotationSummary,
|
|
2771
|
+
codebaseRegistry: codebaseRegistry || undefined,
|
|
2772
|
+
compositionDependencies: dependencies.length > 0 ? {
|
|
2773
|
+
count: dependencies.length,
|
|
2774
|
+
components: dependencies,
|
|
2775
|
+
ai_instruction: codebaseRegistry
|
|
2776
|
+
? `MANDATORY: Cross-reference each dependency against codebaseRegistry.crossReference above. Components marked IMPORT_EXISTING must be imported from their listed path. Components marked BUILD_NEW must be created as standalone components (own directory, file, CSS module, stories) before building the parent. Never inline sub-component logic.`
|
|
2777
|
+
: "MANDATORY BEFORE WRITING ANY CODE: Scan the target codebase's component directory for existing implementations. If a matching component exists, IMPORT it — never recreate with inline markup. Each sub-component that does NOT exist must be built FIRST as standalone (own directory, file, CSS module, stories, barrel export) before building the parent.",
|
|
2778
|
+
} : undefined,
|
|
2779
|
+
metadata: {
|
|
2780
|
+
purpose: "component_development",
|
|
2781
|
+
treeDepth: 4,
|
|
2782
|
+
note: [
|
|
2783
|
+
imageUrl ? "Image URL provided (valid for 30 days)." : null,
|
|
2784
|
+
"Component data optimized for UI implementation with design tokens (boundVariables), interaction states (reactions), sizing constraints, and text behavior.",
|
|
2785
|
+
annotationSummary.count > 0 ? `${annotationSummary.count} design annotation(s) found — check annotations field for implementation specs.` : null,
|
|
2786
|
+
dependencies.length > 0 ? `COMPOSITION: ${dependencies.length} sub-component(s) detected (${dependencies.map(d => d.name).join(", ")}). Build these as standalone components first, then compose.` : null,
|
|
2787
|
+
"Use figma_get_annotations for full annotation details. Use figma_get_variables to resolve variable IDs to token names/values.",
|
|
2788
|
+
].filter(Boolean).join(" "),
|
|
2789
|
+
},
|
|
2790
|
+
};
|
|
2791
|
+
// Adaptive compression for large responses (depth 4 can produce large payloads)
|
|
2792
|
+
const responseJson = JSON.stringify(response);
|
|
2793
|
+
const responseSizeKB = Math.round(responseJson.length / 1024);
|
|
2794
|
+
if (responseSizeKB > 500) {
|
|
2795
|
+
// Emergency: strip children beyond depth 2 and add truncation note
|
|
2796
|
+
logger.warn({ responseSizeKB, nodeId }, "Component response exceeds 500KB, truncating deep children");
|
|
2797
|
+
const truncate = (n, currentDepth) => {
|
|
2798
|
+
if (!n)
|
|
2799
|
+
return n;
|
|
2800
|
+
const copy = { ...n };
|
|
2801
|
+
if (copy.children && currentDepth >= 2) {
|
|
2802
|
+
copy.children = copy.children.map((c) => ({
|
|
2803
|
+
id: c.id, name: c.name, type: c.type,
|
|
2804
|
+
...(c.componentId ? { componentId: c.componentId } : {}),
|
|
2805
|
+
...(c.variantProperties ? { variantProperties: c.variantProperties } : {}),
|
|
2806
|
+
childCount: c.children?.length,
|
|
2807
|
+
}));
|
|
2808
|
+
copy._truncated = true;
|
|
2809
|
+
}
|
|
2810
|
+
else if (copy.children) {
|
|
2811
|
+
copy.children = copy.children.map((c) => truncate(c, currentDepth + 1));
|
|
2812
|
+
}
|
|
2813
|
+
return copy;
|
|
2814
|
+
};
|
|
2815
|
+
response.component = truncate(componentData, 0);
|
|
2816
|
+
response.metadata.truncated = true;
|
|
2817
|
+
response.metadata.originalSizeKB = responseSizeKB;
|
|
2818
|
+
response.metadata.note += " Response was truncated due to size. Use figma_execute for deeper traversal of specific subtrees.";
|
|
2819
|
+
}
|
|
2820
|
+
return {
|
|
2821
|
+
content: [
|
|
2822
|
+
{
|
|
2823
|
+
type: "text",
|
|
2824
|
+
text: JSON.stringify(response),
|
|
2825
|
+
},
|
|
2826
|
+
],
|
|
2827
|
+
};
|
|
2828
|
+
}
|
|
2829
|
+
catch (error) {
|
|
2830
|
+
logger.error({ error }, "Failed to get component for development");
|
|
2831
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2832
|
+
return {
|
|
2833
|
+
content: [
|
|
2834
|
+
{
|
|
2835
|
+
type: "text",
|
|
2836
|
+
text: JSON.stringify({
|
|
2837
|
+
error: errorMessage,
|
|
2838
|
+
message: "Failed to retrieve component development data",
|
|
2839
|
+
}),
|
|
2840
|
+
},
|
|
2841
|
+
],
|
|
2842
|
+
isError: true,
|
|
2843
|
+
};
|
|
2844
|
+
}
|
|
2845
|
+
});
|
|
2846
|
+
// Tool 14: Get File for Plugin Development
|
|
2847
|
+
server.tool("figma_get_file_for_plugin", "Get file data optimized for plugin development with filtered properties (IDs, structure, plugin data, component relationships). Excludes visual properties (fills, strokes, effects) to reduce payload. Use when user asks for: plugin development, file structure for manipulation, node IDs for plugin API. NOT for component descriptions (use figma_get_component). NOT for visual/styling data (use figma_get_component_for_development). Supports deeper tree traversal (max depth=5) than figma_get_file_data.", {
|
|
2848
|
+
fileUrl: z
|
|
2849
|
+
.string()
|
|
2850
|
+
.url()
|
|
2851
|
+
.optional()
|
|
2852
|
+
.describe("Figma file URL (e.g., https://figma.com/design/abc123). REQUIRED unless figma_navigate was already called."),
|
|
2853
|
+
depth: z
|
|
2854
|
+
.number()
|
|
2855
|
+
.min(0)
|
|
2856
|
+
.max(5)
|
|
2857
|
+
.optional()
|
|
2858
|
+
.default(2)
|
|
2859
|
+
.describe("How many levels of children to include (default: 2, max: 5). Higher depths are safe here due to filtering."),
|
|
2860
|
+
nodeIds: z
|
|
2861
|
+
.array(z.string())
|
|
2862
|
+
.optional()
|
|
2863
|
+
.describe("Specific node IDs to retrieve (optional)"),
|
|
2864
|
+
}, async ({ fileUrl, depth, nodeIds }) => {
|
|
2865
|
+
try {
|
|
2866
|
+
let api;
|
|
2867
|
+
try {
|
|
2868
|
+
api = await getFigmaAPI();
|
|
2869
|
+
}
|
|
2870
|
+
catch (apiError) {
|
|
2871
|
+
const errorMessage = apiError instanceof Error ? apiError.message : String(apiError);
|
|
2872
|
+
throw restAuthError("Cannot retrieve file data for plugin development. REST API authentication required.", errorMessage);
|
|
2873
|
+
}
|
|
2874
|
+
const url = fileUrl || getCurrentUrl();
|
|
2875
|
+
if (!url) {
|
|
2876
|
+
throw new Error("No Figma file URL available. Pass the fileUrl parameter or ensure the Desktop Bridge plugin is open in Figma.");
|
|
2877
|
+
}
|
|
2878
|
+
const fileKey = extractFileKey(url);
|
|
2879
|
+
if (!fileKey) {
|
|
2880
|
+
throw new Error(`Invalid Figma URL: ${url}`);
|
|
2881
|
+
}
|
|
2882
|
+
logger.info({ fileKey, depth, nodeIds }, "Fetching file data for plugin development");
|
|
2883
|
+
const fileData = await api.getFile(fileKey, {
|
|
2884
|
+
depth,
|
|
2885
|
+
ids: nodeIds,
|
|
2886
|
+
});
|
|
2887
|
+
// Filter to plugin-relevant properties only
|
|
2888
|
+
const filterForPlugin = (node) => {
|
|
2889
|
+
if (!node)
|
|
2890
|
+
return node;
|
|
2891
|
+
const result = {
|
|
2892
|
+
id: node.id,
|
|
2893
|
+
name: node.name,
|
|
2894
|
+
type: node.type,
|
|
2895
|
+
description: node.description,
|
|
2896
|
+
descriptionMarkdown: node.descriptionMarkdown,
|
|
2897
|
+
};
|
|
2898
|
+
// Navigation & structure
|
|
2899
|
+
if (node.visible !== undefined)
|
|
2900
|
+
result.visible = node.visible;
|
|
2901
|
+
if (node.locked)
|
|
2902
|
+
result.locked = node.locked;
|
|
2903
|
+
if (node.removed)
|
|
2904
|
+
result.removed = node.removed;
|
|
2905
|
+
// Lightweight bounds (just position/size)
|
|
2906
|
+
if (node.absoluteBoundingBox) {
|
|
2907
|
+
result.bounds = {
|
|
2908
|
+
x: node.absoluteBoundingBox.x,
|
|
2909
|
+
y: node.absoluteBoundingBox.y,
|
|
2910
|
+
width: node.absoluteBoundingBox.width,
|
|
2911
|
+
height: node.absoluteBoundingBox.height,
|
|
2912
|
+
};
|
|
2913
|
+
}
|
|
2914
|
+
// Plugin data (CRITICAL for plugins)
|
|
2915
|
+
if (node.pluginData)
|
|
2916
|
+
result.pluginData = node.pluginData;
|
|
2917
|
+
if (node.sharedPluginData)
|
|
2918
|
+
result.sharedPluginData = node.sharedPluginData;
|
|
2919
|
+
// Component relationships (important for plugins)
|
|
2920
|
+
if (node.componentId)
|
|
2921
|
+
result.componentId = node.componentId;
|
|
2922
|
+
if (node.mainComponent)
|
|
2923
|
+
result.mainComponent = node.mainComponent;
|
|
2924
|
+
if (node.componentPropertyReferences)
|
|
2925
|
+
result.componentPropertyReferences = node.componentPropertyReferences;
|
|
2926
|
+
if (node.instanceOf)
|
|
2927
|
+
result.instanceOf = node.instanceOf;
|
|
2928
|
+
if (node.exposedInstances)
|
|
2929
|
+
result.exposedInstances = node.exposedInstances;
|
|
2930
|
+
// Component properties (for manipulation)
|
|
2931
|
+
if (node.componentProperties)
|
|
2932
|
+
result.componentProperties = node.componentProperties;
|
|
2933
|
+
// Characters for text nodes (plugins often need this)
|
|
2934
|
+
if (node.characters !== undefined)
|
|
2935
|
+
result.characters = node.characters;
|
|
2936
|
+
// Recursively process children
|
|
2937
|
+
if (node.children) {
|
|
2938
|
+
result.children = node.children.map((child) => filterForPlugin(child));
|
|
2939
|
+
}
|
|
2940
|
+
return result;
|
|
2941
|
+
};
|
|
2942
|
+
const filteredDocument = filterForPlugin(fileData.document);
|
|
2943
|
+
const finalResponse = {
|
|
2944
|
+
fileKey,
|
|
2945
|
+
name: fileData.name,
|
|
2946
|
+
lastModified: fileData.lastModified,
|
|
2947
|
+
version: fileData.version,
|
|
2948
|
+
document: filteredDocument,
|
|
2949
|
+
components: fileData.components
|
|
2950
|
+
? Object.keys(fileData.components).length
|
|
2951
|
+
: 0,
|
|
2952
|
+
styles: fileData.styles
|
|
2953
|
+
? Object.keys(fileData.styles).length
|
|
2954
|
+
: 0,
|
|
2955
|
+
...(nodeIds && {
|
|
2956
|
+
requestedNodes: nodeIds,
|
|
2957
|
+
nodes: fileData.nodes,
|
|
2958
|
+
}),
|
|
2959
|
+
metadata: {
|
|
2960
|
+
purpose: "plugin_development",
|
|
2961
|
+
note: "Optimized for plugin development. Contains IDs, structure, plugin data, and component relationships.",
|
|
2962
|
+
},
|
|
2963
|
+
};
|
|
2964
|
+
// Use adaptive response to prevent context exhaustion
|
|
2965
|
+
return adaptiveResponse(finalResponse, {
|
|
2966
|
+
toolName: "figma_get_file_for_plugin",
|
|
2967
|
+
compressionCallback: (adjustedLevel) => {
|
|
2968
|
+
// For plugin format, we can't reduce much without breaking functionality
|
|
2969
|
+
// But we can strip some less critical metadata
|
|
2970
|
+
const compressNode = (node) => {
|
|
2971
|
+
const result = {
|
|
2972
|
+
id: node.id,
|
|
2973
|
+
name: node.name,
|
|
2974
|
+
type: node.type,
|
|
2975
|
+
};
|
|
2976
|
+
// Keep only essential properties based on compression level
|
|
2977
|
+
if (adjustedLevel !== "inventory") {
|
|
2978
|
+
if (node.visible !== undefined)
|
|
2979
|
+
result.visible = node.visible;
|
|
2980
|
+
if (node.locked !== undefined)
|
|
2981
|
+
result.locked = node.locked;
|
|
2982
|
+
if (node.absoluteBoundingBox)
|
|
2983
|
+
result.absoluteBoundingBox = node.absoluteBoundingBox;
|
|
2984
|
+
if (node.pluginData)
|
|
2985
|
+
result.pluginData = node.pluginData;
|
|
2986
|
+
if (node.sharedPluginData)
|
|
2987
|
+
result.sharedPluginData = node.sharedPluginData;
|
|
2988
|
+
if (node.componentId)
|
|
2989
|
+
result.componentId = node.componentId;
|
|
2990
|
+
}
|
|
2991
|
+
if (node.children) {
|
|
2992
|
+
result.children = node.children.map(compressNode);
|
|
2993
|
+
}
|
|
2994
|
+
return result;
|
|
2995
|
+
};
|
|
2996
|
+
return {
|
|
2997
|
+
...finalResponse,
|
|
2998
|
+
document: compressNode(filteredDocument),
|
|
2999
|
+
metadata: {
|
|
3000
|
+
...finalResponse.metadata,
|
|
3001
|
+
compressionApplied: adjustedLevel,
|
|
3002
|
+
},
|
|
3003
|
+
};
|
|
3004
|
+
},
|
|
3005
|
+
suggestedActions: [
|
|
3006
|
+
"Reduce depth parameter (recommend 1-2)",
|
|
3007
|
+
"Request specific nodeIds to narrow the scope",
|
|
3008
|
+
"Filter to specific component types if possible",
|
|
3009
|
+
],
|
|
3010
|
+
});
|
|
3011
|
+
}
|
|
3012
|
+
catch (error) {
|
|
3013
|
+
logger.error({ error }, "Failed to get file for plugin");
|
|
3014
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
3015
|
+
return {
|
|
3016
|
+
content: [
|
|
3017
|
+
{
|
|
3018
|
+
type: "text",
|
|
3019
|
+
text: JSON.stringify({
|
|
3020
|
+
error: errorMessage,
|
|
3021
|
+
message: "Failed to retrieve file data for plugin development",
|
|
3022
|
+
}),
|
|
3023
|
+
},
|
|
3024
|
+
],
|
|
3025
|
+
isError: true,
|
|
3026
|
+
};
|
|
3027
|
+
}
|
|
3028
|
+
});
|
|
3029
|
+
// Tool 15: Capture Screenshot via Plugin (Desktop Bridge)
|
|
3030
|
+
// This uses exportAsync() which reads the current plugin runtime state, not the cloud state
|
|
3031
|
+
// Solves race condition where REST API screenshots show stale data after changes
|
|
3032
|
+
server.tool("figma_capture_screenshot", "Capture a screenshot of a node using the plugin's exportAsync API. IMPORTANT: This tool captures the CURRENT state from the plugin runtime (not cloud state like REST API), making it reliable for validating changes immediately after making them. Use this instead of figma_get_component_image when you need to verify that changes were applied correctly. Defaults are AI-optimized: PNG at 1x with automatic downscaling so the longest side stays within the 1568px AI vision processing ceiling. PNG is the default because design tool content (flat colors, text, UI components) compresses significantly better as PNG. Use JPG for photographic or gradient-heavy content. Requires Desktop Bridge connection (Figma Desktop with plugin running).", {
|
|
3033
|
+
nodeId: z
|
|
3034
|
+
.string()
|
|
3035
|
+
.optional()
|
|
3036
|
+
.describe("ID of the node to capture (e.g., '1:234'). If not provided, captures the current page."),
|
|
3037
|
+
format: z
|
|
3038
|
+
.enum(["PNG", "JPG", "SVG"])
|
|
3039
|
+
.optional()
|
|
3040
|
+
.default("PNG")
|
|
3041
|
+
.describe("Image format (default: PNG). Use JPG for photographic or gradient-heavy content."),
|
|
3042
|
+
scale: z
|
|
3043
|
+
.number()
|
|
3044
|
+
.min(0.5)
|
|
3045
|
+
.max(4)
|
|
3046
|
+
.optional()
|
|
3047
|
+
.default(1)
|
|
3048
|
+
.describe("Scale factor (default: 1). The plugin automatically caps the effective scale so the exported image does not exceed 1568px on its longest side (the AI vision processing ceiling)."),
|
|
3049
|
+
}, async ({ nodeId, format, scale }) => {
|
|
3050
|
+
try {
|
|
3051
|
+
logger.info({ nodeId, format, scale }, "Capturing screenshot via Desktop Bridge");
|
|
3052
|
+
let result = null;
|
|
3053
|
+
// Use the connector abstraction (WebSocket transport)
|
|
3054
|
+
if (getDesktopConnector) {
|
|
3055
|
+
const connector = await getDesktopConnector();
|
|
3056
|
+
logger.info({ transport: connector.getTransportType?.() || 'unknown' }, "Screenshot via connector");
|
|
3057
|
+
result = await connector.captureScreenshot(nodeId || '', { format, scale });
|
|
3058
|
+
// Wrap in expected format only if connector returns raw data without a success flag
|
|
3059
|
+
if (result && typeof result.success === 'undefined' && result.image) {
|
|
3060
|
+
result = { success: true, image: result };
|
|
3061
|
+
}
|
|
3062
|
+
}
|
|
3063
|
+
if (!result) {
|
|
3064
|
+
throw new Error("Desktop Bridge plugin not found. Ensure the 'Figma Console MCP' plugin is running in Figma Desktop.");
|
|
3065
|
+
}
|
|
3066
|
+
if (!result.success) {
|
|
3067
|
+
throw new Error(result.error || "Screenshot capture failed");
|
|
3068
|
+
}
|
|
3069
|
+
// Determine MIME type based on format
|
|
3070
|
+
const mimeType = format === "JPG" ? "image/jpeg" : format === "SVG" ? "image/svg+xml" : "image/png";
|
|
3071
|
+
logger.info({ byteLength: result.image.byteLength, format, mimeType }, "Screenshot captured via plugin");
|
|
3072
|
+
// Return as MCP image content type so Claude can actually see and analyze the image
|
|
3073
|
+
return {
|
|
3074
|
+
content: [
|
|
3075
|
+
{
|
|
3076
|
+
type: "text",
|
|
3077
|
+
text: JSON.stringify({
|
|
3078
|
+
success: true,
|
|
3079
|
+
image: {
|
|
3080
|
+
format: result.image.format,
|
|
3081
|
+
scale: result.image.scale,
|
|
3082
|
+
byteLength: result.image.byteLength,
|
|
3083
|
+
node: result.image.node,
|
|
3084
|
+
bounds: result.image.bounds,
|
|
3085
|
+
},
|
|
3086
|
+
metadata: {
|
|
3087
|
+
source: "plugin_export_async",
|
|
3088
|
+
note: "Screenshot captured successfully. The image is included below for visual analysis. This shows the CURRENT plugin runtime state (guaranteed to reflect recent changes).",
|
|
3089
|
+
formatAdvice: result.image.formatAdvice || undefined,
|
|
3090
|
+
},
|
|
3091
|
+
}),
|
|
3092
|
+
},
|
|
3093
|
+
{
|
|
3094
|
+
type: "image",
|
|
3095
|
+
data: result.image.base64,
|
|
3096
|
+
mimeType: mimeType,
|
|
3097
|
+
},
|
|
3098
|
+
],
|
|
3099
|
+
};
|
|
3100
|
+
}
|
|
3101
|
+
catch (error) {
|
|
3102
|
+
logger.error({ error }, "Failed to capture screenshot");
|
|
3103
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
3104
|
+
return {
|
|
3105
|
+
content: [
|
|
3106
|
+
{
|
|
3107
|
+
type: "text",
|
|
3108
|
+
text: JSON.stringify({
|
|
3109
|
+
error: errorMessage,
|
|
3110
|
+
message: "Failed to capture screenshot via Desktop Bridge",
|
|
3111
|
+
suggestion: "Ensure Figma Desktop is open with the plugin running",
|
|
3112
|
+
}),
|
|
3113
|
+
},
|
|
3114
|
+
],
|
|
3115
|
+
isError: true,
|
|
3116
|
+
};
|
|
3117
|
+
}
|
|
3118
|
+
});
|
|
3119
|
+
// Tool 16: Set Instance Properties (Desktop Bridge)
|
|
3120
|
+
// Updates component properties on an instance using setProperties()
|
|
3121
|
+
// This is the correct way to update TEXT/BOOLEAN/VARIANT properties on component instances
|
|
3122
|
+
server.tool("figma_set_instance_properties", "Update component properties on a component instance. IMPORTANT: Use this tool instead of trying to edit text nodes directly when working with component instances. Components often expose TEXT, BOOLEAN, INSTANCE_SWAP, and VARIANT properties that control their content. Direct text node editing may fail silently if the component uses properties. This tool handles the #nodeId suffix pattern automatically. Requires Desktop Bridge connection.", {
|
|
3123
|
+
nodeId: z
|
|
3124
|
+
.string()
|
|
3125
|
+
.describe("ID of the INSTANCE node to update (e.g., '1:234'). Must be a component instance, not a regular frame."),
|
|
3126
|
+
properties: z
|
|
3127
|
+
.record(z.string(), z.union([z.string(), z.boolean()]))
|
|
3128
|
+
.describe("Properties to set. Keys are property names (e.g., 'Label', 'Show Icon', 'Size'). " +
|
|
3129
|
+
"Values are strings for TEXT/VARIANT properties, booleans for BOOLEAN properties. " +
|
|
3130
|
+
"The tool automatically handles the #nodeId suffix for TEXT/BOOLEAN/INSTANCE_SWAP properties."),
|
|
3131
|
+
}, async ({ nodeId, properties }) => {
|
|
3132
|
+
try {
|
|
3133
|
+
logger.info({ nodeId, properties: Object.keys(properties) }, "Setting instance properties via Desktop Bridge");
|
|
3134
|
+
let result = null;
|
|
3135
|
+
// Use the connector abstraction (WebSocket transport)
|
|
3136
|
+
if (getDesktopConnector) {
|
|
3137
|
+
const connector = await getDesktopConnector();
|
|
3138
|
+
logger.info({ transport: connector.getTransportType?.() || 'unknown' }, "Instance properties via connector");
|
|
3139
|
+
result = await connector.setInstanceProperties(nodeId, properties);
|
|
3140
|
+
}
|
|
3141
|
+
if (!result) {
|
|
3142
|
+
throw new Error("Desktop Bridge plugin not found. Ensure the 'Figma Console MCP' plugin is running in Figma Desktop.");
|
|
3143
|
+
}
|
|
3144
|
+
if (!result.success) {
|
|
3145
|
+
throw new Error(result.error || "Failed to set instance properties");
|
|
3146
|
+
}
|
|
3147
|
+
return {
|
|
3148
|
+
content: [
|
|
3149
|
+
{
|
|
3150
|
+
type: "text",
|
|
3151
|
+
text: JSON.stringify({
|
|
3152
|
+
success: true,
|
|
3153
|
+
instance: result.instance,
|
|
3154
|
+
metadata: {
|
|
3155
|
+
note: "Instance properties updated successfully. Use figma_capture_screenshot to verify visual changes.",
|
|
3156
|
+
},
|
|
3157
|
+
}),
|
|
3158
|
+
},
|
|
3159
|
+
],
|
|
3160
|
+
};
|
|
3161
|
+
}
|
|
3162
|
+
catch (error) {
|
|
3163
|
+
logger.error({ error }, "Failed to set instance properties");
|
|
3164
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
3165
|
+
return {
|
|
3166
|
+
content: [
|
|
3167
|
+
{
|
|
3168
|
+
type: "text",
|
|
3169
|
+
text: JSON.stringify({
|
|
3170
|
+
error: errorMessage,
|
|
3171
|
+
message: "Failed to set instance properties via Desktop Bridge",
|
|
3172
|
+
suggestions: [
|
|
3173
|
+
"Verify the node is a component INSTANCE (not a regular frame)",
|
|
3174
|
+
"Check available properties with figma_get_component first",
|
|
3175
|
+
"Ensure property names match exactly (case-sensitive)",
|
|
3176
|
+
"For TEXT properties, provide string values",
|
|
3177
|
+
"For BOOLEAN properties, provide true/false",
|
|
3178
|
+
],
|
|
3179
|
+
}),
|
|
3180
|
+
},
|
|
3181
|
+
],
|
|
3182
|
+
isError: true,
|
|
3183
|
+
};
|
|
3184
|
+
}
|
|
3185
|
+
});
|
|
3186
|
+
}
|
|
3187
|
+
//# sourceMappingURL=figma-tools.js.map
|