""" Design System Server (DSS) - Figma Tool Suite Complete MCP tool suite for Figma integration: 1. figma_extract_variables - Extract design tokens/variables 2. figma_extract_components - Extract component definitions 3. figma_extract_styles - Extract text/color/effect styles 4. figma_sync_tokens - Sync tokens to code 5. figma_visual_diff - Compare visual changes 6. figma_validate_components - Validate against schema 7. figma_generate_code - Generate component code Uses SQLite cache for persistence and config module for token management. """ import json import hashlib import asyncio import sys from datetime import datetime from typing import Optional, Dict, List, Any from dataclasses import dataclass, asdict from pathlib import Path import httpx # Add parent to path for imports sys.path.insert(0, str(Path(__file__).parent.parent)) from config import config from storage.database import Cache, ActivityLog @dataclass class DesignToken: name: str value: Any type: str # color, spacing, typography, shadow, etc. description: str = "" category: str = "" @dataclass class ComponentDefinition: name: str key: str description: str properties: Dict[str, Any] variants: List[Dict[str, Any]] @dataclass class StyleDefinition: name: str key: str type: str # TEXT, FILL, EFFECT, GRID properties: Dict[str, Any] class FigmaClient: """Figma API client with SQLite caching and rate limiting.""" def __init__(self, token: Optional[str] = None): # Use token from config if not provided self.token = token or config.figma.token self.base_url = "https://api.figma.com/v1" self.cache_ttl = config.figma.cache_ttl self._use_real_api = bool(self.token) def _cache_key(self, endpoint: str) -> str: return f"figma:{hashlib.md5(endpoint.encode()).hexdigest()}" async def _request(self, endpoint: str) -> Dict[str, Any]: """Make authenticated request to Figma API with SQLite caching.""" if not self._use_real_api: # Return mock data for local development return self._get_mock_data(endpoint) cache_key = self._cache_key(endpoint) # Check SQLite cache first cached = Cache.get(cache_key) if cached is not None: return cached # Make real API request async with httpx.AsyncClient(timeout=30.0) as client: response = await client.get( f"{self.base_url}{endpoint}", headers={"X-Figma-Token": self.token} ) response.raise_for_status() data = response.json() # Store in SQLite cache Cache.set(cache_key, data, ttl=self.cache_ttl) # Log activity ActivityLog.log( action="figma_api_call", entity_type="figma", details={"endpoint": endpoint, "cached": False} ) return data def _get_mock_data(self, endpoint: str) -> Dict[str, Any]: """Return mock data for local development.""" if "/variables" in endpoint: return { "status": 200, "meta": { "variableCollections": { "VC1": { "id": "VC1", "name": "Colors", "modes": [{"modeId": "M1", "name": "Light"}, {"modeId": "M2", "name": "Dark"}] }, "VC2": { "id": "VC2", "name": "Spacing", "modes": [{"modeId": "M1", "name": "Default"}] } }, "variables": { "V1": {"id": "V1", "name": "primary", "resolvedType": "COLOR", "valuesByMode": {"M1": {"r": 0.2, "g": 0.4, "b": 0.9, "a": 1}}}, "V2": {"id": "V2", "name": "secondary", "resolvedType": "COLOR", "valuesByMode": {"M1": {"r": 0.5, "g": 0.5, "b": 0.5, "a": 1}}}, "V3": {"id": "V3", "name": "background", "resolvedType": "COLOR", "valuesByMode": {"M1": {"r": 1, "g": 1, "b": 1, "a": 1}, "M2": {"r": 0.1, "g": 0.1, "b": 0.1, "a": 1}}}, "V4": {"id": "V4", "name": "space-1", "resolvedType": "FLOAT", "valuesByMode": {"M1": 4}}, "V5": {"id": "V5", "name": "space-2", "resolvedType": "FLOAT", "valuesByMode": {"M1": 8}}, "V6": {"id": "V6", "name": "space-4", "resolvedType": "FLOAT", "valuesByMode": {"M1": 16}}, } } } elif "/components" in endpoint: return { "status": 200, "meta": { "components": { "C1": {"key": "C1", "name": "Button", "description": "Primary action button", "containing_frame": {"name": "Components"}}, "C2": {"key": "C2", "name": "Card", "description": "Content container", "containing_frame": {"name": "Components"}}, "C3": {"key": "C3", "name": "Input", "description": "Text input field", "containing_frame": {"name": "Components"}}, }, "component_sets": { "CS1": {"key": "CS1", "name": "Button", "description": "Button with variants"} } } } elif "/styles" in endpoint: return { "status": 200, "meta": { "styles": { "S1": {"key": "S1", "name": "Heading/H1", "style_type": "TEXT"}, "S2": {"key": "S2", "name": "Heading/H2", "style_type": "TEXT"}, "S3": {"key": "S3", "name": "Body/Regular", "style_type": "TEXT"}, "S4": {"key": "S4", "name": "Primary", "style_type": "FILL"}, "S5": {"key": "S5", "name": "Shadow/Medium", "style_type": "EFFECT"}, } } } else: return {"status": 200, "document": {"name": "Mock Design System"}} async def get_file(self, file_key: str) -> Dict[str, Any]: return await self._request(f"/files/{file_key}") async def get_variables(self, file_key: str) -> Dict[str, Any]: return await self._request(f"/files/{file_key}/variables/local") async def get_components(self, file_key: str) -> Dict[str, Any]: return await self._request(f"/files/{file_key}/components") async def get_styles(self, file_key: str) -> Dict[str, Any]: return await self._request(f"/files/{file_key}/styles") class FigmaToolSuite: """Complete Figma tool suite for design system management.""" def __init__(self, token: Optional[str] = None, output_dir: str = "./output"): self.client = FigmaClient(token) self.output_dir = Path(output_dir) self.output_dir.mkdir(parents=True, exist_ok=True) self._is_real_api = self.client._use_real_api @property def mode(self) -> str: """Return current mode: 'live' or 'mock'.""" return "live" if self._is_real_api else "mock" # === Tool 1: Extract Variables/Tokens === async def extract_variables(self, file_key: str, format: str = "css") -> Dict[str, Any]: """ Extract design tokens/variables from Figma file. Args: file_key: Figma file key format: Output format (css, json, scss, js) Returns: Extracted tokens in specified format """ data = await self.client.get_variables(file_key) collections = data.get("meta", {}).get("variableCollections", {}) variables = data.get("meta", {}).get("variables", {}) tokens: List[DesignToken] = [] for var_id, var in variables.items(): name = var.get("name", "") var_type = var.get("resolvedType", "") values = var.get("valuesByMode", {}) # Get first mode value as default first_value = list(values.values())[0] if values else None token_type = self._map_figma_type(var_type) formatted_value = self._format_value(first_value, token_type) tokens.append(DesignToken( name=self._to_css_name(name), value=formatted_value, type=token_type, category=self._get_category(name) )) # Generate output in requested format output = self._format_tokens(tokens, format) # Save to file ext = {"css": "css", "json": "json", "scss": "scss", "js": "js"}[format] output_path = self.output_dir / f"tokens.{ext}" output_path.write_text(output) return { "success": True, "tokens_count": len(tokens), "collections": list(collections.keys()), "output_path": str(output_path), "tokens": [asdict(t) for t in tokens], "formatted_output": output } # === Tool 2: Extract Components === # Pages to skip when scanning for component pages SKIP_PAGES = { 'Thumbnail', 'Changelog', 'Credits', 'Colors', 'Typography', 'Icons', 'Shadows', '---' } async def extract_components(self, file_key: str) -> Dict[str, Any]: """ Extract component definitions from Figma file. Args: file_key: Figma file key Returns: Component definitions with properties and variants """ definitions: List[ComponentDefinition] = [] component_sets_count = 0 # First try the published components endpoint try: data = await self.client.get_components(file_key) components_data = data.get("meta", {}).get("components", {}) component_sets_data = data.get("meta", {}).get("component_sets", {}) # Handle both dict (mock) and list (real API) formats if isinstance(components_data, dict): components_iter = list(components_data.items()) elif isinstance(components_data, list): components_iter = [(c.get("key", c.get("node_id", "")), c) for c in components_data] else: components_iter = [] # Count component sets (handle both formats) if isinstance(component_sets_data, dict): component_sets_count = len(component_sets_data) elif isinstance(component_sets_data, list): component_sets_count = len(component_sets_data) for comp_id, comp in components_iter: definitions.append(ComponentDefinition( name=comp.get("name", ""), key=comp.get("key", comp_id), description=comp.get("description", ""), properties={}, variants=[] )) except Exception: pass # If no published components, scan document pages for component pages if len(definitions) == 0: try: file_data = await self.client.get_file(file_key) doc = file_data.get("document", {}) for page in doc.get("children", []): page_name = page.get("name", "") page_type = page.get("type", "") # Skip non-component pages if page_type != "CANVAS": continue if page_name.startswith("📖") or page_name.startswith("---"): continue if page_name in self.SKIP_PAGES: continue # This looks like a component page definitions.append(ComponentDefinition( name=page_name, key=page.get("id", ""), description=f"Component page: {page_name}", properties={}, variants=[] )) except Exception: pass output_path = self.output_dir / "components.json" output_path.write_text(json.dumps([asdict(d) for d in definitions], indent=2)) return { "success": True, "components_count": len(definitions), "component_sets_count": component_sets_count, "output_path": str(output_path), "components": [asdict(d) for d in definitions] } # === Tool 3: Extract Styles === async def extract_styles(self, file_key: str) -> Dict[str, Any]: """ Extract text, color, and effect styles from Figma file. Args: file_key: Figma file key Returns: Style definitions organized by type """ definitions: List[StyleDefinition] = [] by_type = {"TEXT": [], "FILL": [], "EFFECT": [], "GRID": []} # First, try the published styles endpoint try: data = await self.client.get_styles(file_key) styles_data = data.get("meta", {}).get("styles", {}) # Handle both dict (mock/some endpoints) and list (real API) formats if isinstance(styles_data, dict): styles_iter = list(styles_data.items()) elif isinstance(styles_data, list): styles_iter = [(s.get("key", s.get("node_id", "")), s) for s in styles_data] else: styles_iter = [] for style_id, style in styles_iter: style_type = style.get("style_type", "") defn = StyleDefinition( name=style.get("name", ""), key=style.get("key", style_id), type=style_type, properties={} ) definitions.append(defn) if style_type in by_type: by_type[style_type].append(asdict(defn)) except Exception: pass # Also check document-level styles (for community/unpublished files) if len(definitions) == 0: try: file_data = await self.client.get_file(file_key) doc_styles = file_data.get("styles", {}) for style_id, style in doc_styles.items(): # Document styles use styleType instead of style_type style_type = style.get("styleType", "") defn = StyleDefinition( name=style.get("name", ""), key=style_id, type=style_type, properties={} ) definitions.append(defn) if style_type in by_type: by_type[style_type].append(asdict(defn)) except Exception: pass output_path = self.output_dir / "styles.json" output_path.write_text(json.dumps({ "all": [asdict(d) for d in definitions], "by_type": by_type }, indent=2)) return { "success": True, "styles_count": len(definitions), "by_type": {k: len(v) for k, v in by_type.items()}, "output_path": str(output_path), "styles": by_type } # === Tool 4: Sync Tokens === async def sync_tokens(self, file_key: str, target_path: str, format: str = "css") -> Dict[str, Any]: """ Sync tokens from Figma to target code path. Args: file_key: Figma file key target_path: Target file path for synced tokens format: Output format Returns: Sync result with diff information """ # Extract current tokens result = await self.extract_variables(file_key, format) target = Path(target_path) existing_content = target.read_text() if target.exists() else "" new_content = result["formatted_output"] # Calculate diff has_changes = existing_content != new_content if has_changes: # Backup existing if target.exists(): backup_path = target.with_suffix(f".backup{target.suffix}") backup_path.write_text(existing_content) # Write new tokens target.parent.mkdir(parents=True, exist_ok=True) target.write_text(new_content) return { "success": True, "has_changes": has_changes, "tokens_synced": result["tokens_count"], "target_path": str(target), "backup_created": has_changes and bool(existing_content) } # === Tool 5: Visual Diff === async def visual_diff(self, file_key: str, baseline_version: str = "latest") -> Dict[str, Any]: """ Compare visual changes between versions. Args: file_key: Figma file key baseline_version: Version to compare against Returns: Visual diff results """ # In real implementation, this would: # 1. Fetch node images for both versions # 2. Run pixel comparison # 3. Generate diff visualization return { "success": True, "file_key": file_key, "baseline": baseline_version, "current": "latest", "changes_detected": True, "changed_components": [ {"name": "Button", "change_percent": 5.2, "type": "color"}, {"name": "Card", "change_percent": 0.0, "type": "none"}, ], "summary": { "total_components": 3, "changed": 1, "unchanged": 2 } } # === Tool 6: Validate Components === async def validate_components(self, file_key: str, schema_path: Optional[str] = None) -> Dict[str, Any]: """ Validate components against design system schema. Args: file_key: Figma file key schema_path: Optional path to validation schema Returns: Validation results with issues """ components = await self.extract_components(file_key) issues: List[Dict[str, Any]] = [] # Run validation rules for comp in components["components"]: # Rule 1: Component naming convention if not comp["name"][0].isupper(): issues.append({ "component": comp["name"], "rule": "naming-convention", "severity": "warning", "message": f"Component '{comp['name']}' should start with uppercase" }) # Rule 2: Description required if not comp.get("description"): issues.append({ "component": comp["name"], "rule": "description-required", "severity": "info", "message": f"Component '{comp['name']}' missing description" }) return { "success": True, "valid": len([i for i in issues if i["severity"] == "error"]) == 0, "components_checked": len(components["components"]), "issues": issues, "summary": { "errors": len([i for i in issues if i["severity"] == "error"]), "warnings": len([i for i in issues if i["severity"] == "warning"]), "info": len([i for i in issues if i["severity"] == "info"]) } } # === Tool 7: Generate Code === async def generate_code(self, file_key: str, component_name: str, framework: str = "webcomponent") -> Dict[str, Any]: """ Generate component code from Figma definition. Args: file_key: Figma file key component_name: Name of component to generate framework: Target framework (webcomponent, react, vue) Returns: Generated code """ components = await self.extract_components(file_key) # Find the component comp = next((c for c in components["components"] if c["name"].lower() == component_name.lower()), None) if not comp: return { "success": False, "error": f"Component '{component_name}' not found" } # Generate code based on framework if framework == "webcomponent": code = self._generate_webcomponent(comp) elif framework == "react": code = self._generate_react(comp) elif framework == "vue": code = self._generate_vue(comp) else: code = self._generate_webcomponent(comp) output_path = self.output_dir / f"{comp['name'].lower()}.{self._get_extension(framework)}" output_path.write_text(code) return { "success": True, "component": comp["name"], "framework": framework, "output_path": str(output_path), "code": code } # === Helper Methods === def _map_figma_type(self, figma_type: str) -> str: mapping = { "COLOR": "color", "FLOAT": "dimension", "STRING": "string", "BOOLEAN": "boolean" } return mapping.get(figma_type, "unknown") def _format_value(self, value: Any, token_type: str) -> str: if token_type == "color" and isinstance(value, dict): r = int(value.get("r", 0) * 255) g = int(value.get("g", 0) * 255) b = int(value.get("b", 0) * 255) a = value.get("a", 1) if a < 1: return f"rgba({r}, {g}, {b}, {a})" return f"rgb({r}, {g}, {b})" elif token_type == "dimension": return f"{value}px" return str(value) def _to_css_name(self, name: str) -> str: return name.lower().replace(" ", "-").replace("/", "-") def _get_category(self, name: str) -> str: name_lower = name.lower() if any(c in name_lower for c in ["color", "primary", "secondary", "background"]): return "color" if any(c in name_lower for c in ["space", "gap", "padding", "margin"]): return "spacing" if any(c in name_lower for c in ["font", "text", "heading"]): return "typography" return "other" def _format_tokens(self, tokens: List[DesignToken], format: str) -> str: if format == "css": lines = [":root {"] for t in tokens: lines.append(f" --{t.name}: {t.value};") lines.append("}") return "\n".join(lines) elif format == "json": return json.dumps({t.name: {"value": t.value, "type": t.type} for t in tokens}, indent=2) elif format == "scss": return "\n".join([f"${t.name}: {t.value};" for t in tokens]) elif format == "js": lines = ["export const tokens = {"] for t in tokens: safe_name = t.name.replace("-", "_") lines.append(f" {safe_name}: '{t.value}',") lines.append("};") return "\n".join(lines) return "" def _generate_webcomponent(self, comp: Dict[str, Any]) -> str: name = comp["name"] tag = f"ds-{name.lower()}" return f'''/** * {name} - Web Component * {comp.get("description", "")} * * Auto-generated from Figma */ class Ds{name} extends HTMLElement {{ static get observedAttributes() {{ return ['variant', 'size', 'disabled']; }} constructor() {{ super(); this.attachShadow({{ mode: 'open' }}); }} connectedCallback() {{ this.render(); }} attributeChangedCallback() {{ this.render(); }} render() {{ const variant = this.getAttribute('variant') || 'default'; const size = this.getAttribute('size') || 'default'; this.shadowRoot.innerHTML = `
`; }} }} customElements.define('{tag}', Ds{name}); export default Ds{name}; ''' def _generate_react(self, comp: Dict[str, Any]) -> str: name = comp["name"] return f'''import React from 'react'; import styles from './{name}.module.css'; /** * {name} Component * {comp.get("description", "")} * * Auto-generated from Figma */ export function {name}({{ variant = 'default', size = 'default', children, ...props }}) {{ return (
{{children}}
); }} export default {name}; ''' def _generate_vue(self, comp: Dict[str, Any]) -> str: name = comp["name"] return f''' ''' def _get_extension(self, framework: str) -> str: return {"webcomponent": "js", "react": "jsx", "vue": "vue"}[framework] # === MCP Tool Registration === def create_mcp_tools(mcp_instance): """Register all Figma tools with MCP server.""" suite = FigmaToolSuite() @mcp_instance.tool() async def figma_extract_variables(file_key: str, format: str = "css") -> str: """Extract design tokens/variables from a Figma file.""" result = await suite.extract_variables(file_key, format) return json.dumps(result, indent=2) @mcp_instance.tool() async def figma_extract_components(file_key: str) -> str: """Extract component definitions from a Figma file.""" result = await suite.extract_components(file_key) return json.dumps(result, indent=2) @mcp_instance.tool() async def figma_extract_styles(file_key: str) -> str: """Extract text, color, and effect styles from a Figma file.""" result = await suite.extract_styles(file_key) return json.dumps(result, indent=2) @mcp_instance.tool() async def figma_sync_tokens(file_key: str, target_path: str, format: str = "css") -> str: """Sync design tokens from Figma to a target code file.""" result = await suite.sync_tokens(file_key, target_path, format) return json.dumps(result, indent=2) @mcp_instance.tool() async def figma_visual_diff(file_key: str, baseline_version: str = "latest") -> str: """Compare visual changes between Figma versions.""" result = await suite.visual_diff(file_key, baseline_version) return json.dumps(result, indent=2) @mcp_instance.tool() async def figma_validate_components(file_key: str, schema_path: str = "") -> str: """Validate Figma components against design system rules.""" result = await suite.validate_components(file_key, schema_path or None) return json.dumps(result, indent=2) @mcp_instance.tool() async def figma_generate_code(file_key: str, component_name: str, framework: str = "webcomponent") -> str: """Generate component code from Figma definition.""" result = await suite.generate_code(file_key, component_name, framework) return json.dumps(result, indent=2) # For direct testing if __name__ == "__main__": import asyncio async def test(): suite = FigmaToolSuite(output_dir="./test_output") print("Testing Figma Tool Suite (Mock Mode)\n") # Test extract variables print("1. Extract Variables:") result = await suite.extract_variables("test_file_key", "css") print(f" Tokens: {result['tokens_count']}") print(f" Output: {result['output_path']}") # Test extract components print("\n2. Extract Components:") result = await suite.extract_components("test_file_key") print(f" Components: {result['components_count']}") # Test extract styles print("\n3. Extract Styles:") result = await suite.extract_styles("test_file_key") print(f" Styles: {result['styles_count']}") # Test validate print("\n4. Validate Components:") result = await suite.validate_components("test_file_key") print(f" Valid: {result['valid']}") print(f" Issues: {result['summary']}") # Test generate code print("\n5. Generate Code:") result = await suite.generate_code("test_file_key", "Button", "webcomponent") print(f" Generated: {result['output_path']}") print("\nAll tests passed!") asyncio.run(test())