From cc465d085a42905d21a8b27dc3798ea106d53a73 Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Fri, 20 Mar 2026 22:12:00 +0500 Subject: [PATCH 1/5] feat(cli): add exfig_export and exfig_download MCP tools Add two new MCP tools for AI-driven design system workflows: - exfig_export: runs platform code export as subprocess, returns structured JSON report (supports colors/icons/images/typography/all with cache, rate limit, and granular cache options) - exfig_download: exports W3C Design Tokens JSON inline without file I/O (supports colors/typography/tokens with w3c/raw formats) Includes 6 unit tests, updated DocC documentation, and CLAUDE.md guidance for adding future MCP tool handlers. --- CLAUDE.md | 63 +-- Sources/ExFigCLI/CLAUDE.md | 10 + Sources/ExFigCLI/ExFig.docc/MCPServer.md | 12 +- Sources/ExFigCLI/MCP/MCPToolDefinitions.swift | 99 +++++ Sources/ExFigCLI/MCP/MCPToolHandlers.swift | 380 ++++++++++++++++++ .../ExFigTests/MCP/MCPToolHandlerTests.swift | 100 +++++ 6 files changed, 628 insertions(+), 36 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index bb89b5b5..7e8acf8b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -366,37 +366,38 @@ NooraUI.formatLink("url", useColors: true) // underlined primary ## Troubleshooting -| Problem | Solution | -| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| codegen:pkl gen.pkl error | gen.pkl `read?` bug: needs `--generator-settings` + `--project-dir` flags (see mise.toml) | -| xcsift "signal code 5" | False positive when piping `swift test` through xcsift; run `swift test` directly to verify | -| PKL tests need Pkl 0.31+ | Schemas use `isNotEmpty`; run tests via `./bin/mise exec -- swift test` to get correct Pkl in PATH | -| PKL FrameSource change | Update ALL entry init calls in tests (EnumBridgingTests, IconsLoaderConfigTests) | -| Build fails | `swift package clean && swift build` | -| Tests fail | Check `FIGMA_PERSONAL_TOKEN` is set | -| Formatting fails | Run `./bin/mise run setup` to install tools | -| test:filter no matches | SPM converts hyphens→underscores: use `ExFig_FlutterTests` not `ExFig-FlutterTests` | -| Template errors | Check Jinja2 syntax and context variables | -| Linux test hangs | Build first: `swift build --build-tests`, then `swift test --skip-build --parallel` | -| Android pathData long | Simplify in Figma or use `--strict-path-validation` | -| PKL parse error 1 | Check `PklError.message` — actual error is in `.message`, not `.localizedDescription` | -| Test target won't compile | Broken test files block entire target; use `swift test --filter Target.Class` after `build` | -| Test helper JSON decode | `ContainingFrame` uses default Codable (camelCase: `nodeId`, `pageName`), NOT snake_case | -| Web entry test fails | Web entry types use `outputDirectory` field, while Android/Flutter use `output` | -| Logger concatenation err | `Logger.Message` (swift-log) requires interpolation `"\(a) \(b)"`, not concatenation `a + b` | -| Deleted variables in output | Filter `VariableValue.deletedButReferenced != true` in variable loaders AND `CodeSyntaxSyncer` | -| mise "sources up-to-date" | mise caches tasks with `sources`/`outputs` — run script directly via `bash` when debugging | -| Jinja trailing `\n` | `{% if false %}...{% endif %}\n` renders `"\n"`, not `""` — strip whitespace-only partial template results | -| `Bundle.module` in tests | SPM test targets without declared resources don't have `Bundle.module` — use `Bundle.main` or temp bundle | -| SwiftLint trailing closure | When function takes 2+ closures, use explicit label for last closure (`export: { ... }`) not trailing syntax | -| CLI flag default vs absent | swift-argument-parser can't distinguish explicit `--flag default_value` from omitted. Use `Optional` + computed `effectiveX` property for flags that wizard may override | -| MCP `Client` ambiguous | `FigmaAPI.Client` vs `MCP.Client` — always use `FigmaAPI.Client` in MCP/ files | -| MCP `FigmaConfig` fields | No `colorsFileId` — use `config.getFileIds()` or `figma.lightFileId`/`darkFileId` | -| `distantFuture` on clock | `ContinuousClock.Instant` has no `distantFuture`; use `withCheckedContinuation { _ in }` for infinite suspend | -| MCP stderr duplication | `TerminalOutputManager.setStderrMode(true)` handles all output routing — don't duplicate in `ExFigLogHandler` | -| PKL template word search | Template comments on `lightFileId` contain cross-refs (`variablesColors`, `typography`); test section removal by matching full markers (`colors = new Common.Colors {`) not bare words | -| CI llms-full.txt stale | `llms-full.txt` is generated from README + DocC articles; after editing `Usage.md`, `ExFig.md`, or `README.md`, run `./bin/mise run generate:llms` and commit the result | -| Release build .pcm warnings | Stale `ModuleCache` — clean with: `rm -r .build/*/release/ModuleCache` then rebuild | +| Problem | Solution | +| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| codegen:pkl gen.pkl error | gen.pkl `read?` bug: needs `--generator-settings` + `--project-dir` flags (see mise.toml) | +| xcsift "signal code 5" | False positive when piping `swift test` through xcsift; run `swift test` directly to verify | +| PKL tests need Pkl 0.31+ | Schemas use `isNotEmpty`; run tests via `./bin/mise exec -- swift test` to get correct Pkl in PATH | +| PKL FrameSource change | Update ALL entry init calls in tests (EnumBridgingTests, IconsLoaderConfigTests) | +| Build fails | `swift package clean && swift build` | +| Tests fail | Check `FIGMA_PERSONAL_TOKEN` is set | +| Formatting fails | Run `./bin/mise run setup` to install tools | +| test:filter no matches | SPM converts hyphens→underscores: use `ExFig_FlutterTests` not `ExFig-FlutterTests` | +| Template errors | Check Jinja2 syntax and context variables | +| Linux test hangs | Build first: `swift build --build-tests`, then `swift test --skip-build --parallel` | +| Android pathData long | Simplify in Figma or use `--strict-path-validation` | +| PKL parse error 1 | Check `PklError.message` — actual error is in `.message`, not `.localizedDescription` | +| Test target won't compile | Broken test files block entire target; use `swift test --filter Target.Class` after `build` | +| Test helper JSON decode | `ContainingFrame` uses default Codable (camelCase: `nodeId`, `pageName`), NOT snake_case | +| Web entry test fails | Web entry types use `outputDirectory` field, while Android/Flutter use `output` | +| Logger concatenation err | `Logger.Message` (swift-log) requires interpolation `"\(a) \(b)"`, not concatenation `a + b` | +| Deleted variables in output | Filter `VariableValue.deletedButReferenced != true` in variable loaders AND `CodeSyntaxSyncer` | +| mise "sources up-to-date" | mise caches tasks with `sources`/`outputs` — run script directly via `bash` when debugging | +| Jinja trailing `\n` | `{% if false %}...{% endif %}\n` renders `"\n"`, not `""` — strip whitespace-only partial template results | +| `Bundle.module` in tests | SPM test targets without declared resources don't have `Bundle.module` — use `Bundle.main` or temp bundle | +| SwiftLint trailing closure | When function takes 2+ closures, use explicit label for last closure (`export: { ... }`) not trailing syntax | +| CLI flag default vs absent | swift-argument-parser can't distinguish explicit `--flag default_value` from omitted. Use `Optional` + computed `effectiveX` property for flags that wizard may override | +| MCP `Client` ambiguous | `FigmaAPI.Client` vs `MCP.Client` — always use `FigmaAPI.Client` in MCP/ files | +| MCP `FigmaConfig` fields | No `colorsFileId` — use `config.getFileIds()` or `figma.lightFileId`/`darkFileId` | +| `distantFuture` on clock | `ContinuousClock.Instant` has no `distantFuture`; use `withCheckedContinuation { _ in }` for infinite suspend | +| MCP stderr duplication | `TerminalOutputManager.setStderrMode(true)` handles all output routing — don't duplicate in `ExFigLogHandler` | +| `VariablesColors` vs `Colors` | `ColorsVariablesLoader` takes `Common.VariablesColors?`, not `Common.Colors?` — different PKL types | +| PKL template word search | Template comments on `lightFileId` contain cross-refs (`variablesColors`, `typography`); test section removal by matching full markers (`colors = new Common.Colors {`) not bare words | +| CI llms-full.txt stale | `llms-full.txt` is generated from README + DocC articles; after editing `Usage.md`, `ExFig.md`, or `README.md`, run `./bin/mise run generate:llms` and commit the result | +| Release build .pcm warnings | Stale `ModuleCache` — clean with: `rm -r .build/*/release/ModuleCache` then rebuild | ## Additional Rules diff --git a/Sources/ExFigCLI/CLAUDE.md b/Sources/ExFigCLI/CLAUDE.md index 24a22b0f..fc03383b 100644 --- a/Sources/ExFigCLI/CLAUDE.md +++ b/Sources/ExFigCLI/CLAUDE.md @@ -188,6 +188,16 @@ reserved for MCP JSON-RPC protocol. **Tool handler order:** Validate input parameters BEFORE expensive operations (PKL eval, API client creation). +### Adding an MCP Tool Handler + +1. Add tool definition in `MCP/MCPToolDefinitions.swift` (JSON Schema via `.object([...])`) +2. Add case in `MCPToolHandlers.handle()` dispatch switch +3. Implement handler in an `extension MCPToolHandlers` (keeps `type_body_length` under 300) +4. `ExFigWarning` → string via `ExFigWarningFormatter().format(warning)` (no `.formattedMessage` property) +5. `Color.hex` is in AndroidExport, not accessible from ExFigCLI — use RGBA components +6. `ColorsVariablesLoader` takes `PKLConfig.Common.VariablesColors?`, not `.Colors?` +7. MCP `JSONValue` accessors: `.stringValue`, `.intValue`, `.boolValue` + ## Modification Patterns ### Adding a New Subcommand diff --git a/Sources/ExFigCLI/ExFig.docc/MCPServer.md b/Sources/ExFigCLI/ExFig.docc/MCPServer.md index 6670a8e3..17531d3a 100644 --- a/Sources/ExFigCLI/ExFig.docc/MCPServer.md +++ b/Sources/ExFigCLI/ExFig.docc/MCPServer.md @@ -40,11 +40,13 @@ Add to your `.mcp.json` (Claude Code, Cursor, Codex): ## Available Tools -| Tool | Description | Requires Token | -| ------------------- | -------------------------------- | -------------- | -| `exfig_validate` | Validate a PKL config file | No | -| `exfig_tokens_info` | Inspect a local `.tokens.json` | No | -| `exfig_inspect` | List resources in a Figma file | Yes | +| Tool | Description | Requires Token | +| ------------------- | ---------------------------------------------------- | -------------- | +| `exfig_validate` | Validate a PKL config file | No | +| `exfig_tokens_info` | Inspect a local `.tokens.json` | No | +| `exfig_inspect` | List resources in a Figma file | Yes | +| `exfig_export` | Run code export (writes files, returns JSON report) | Yes | +| `exfig_download` | Export W3C Design Tokens JSON (inline, no file I/O) | Yes | ## Resources diff --git a/Sources/ExFigCLI/MCP/MCPToolDefinitions.swift b/Sources/ExFigCLI/MCP/MCPToolDefinitions.swift index f36aa46a..38c49c73 100644 --- a/Sources/ExFigCLI/MCP/MCPToolDefinitions.swift +++ b/Sources/ExFigCLI/MCP/MCPToolDefinitions.swift @@ -6,6 +6,8 @@ enum MCPToolDefinitions { validateTool, tokensInfoTool, inspectTool, + exportTool, + downloadTool, ] // MARK: - Tool Definitions @@ -82,4 +84,101 @@ enum MCPToolDefinitions { "required": .array([.string("resource_type")]), ]) ) + static let exportTool = Tool( + name: "exfig_export", + description: """ + Run platform code export (Swift/Kotlin/Dart/CSS) from PKL config. \ + Writes generated files to disk and returns a structured JSON report. \ + Requires FIGMA_PERSONAL_TOKEN. + """, + inputSchema: .object([ + "type": .string("object"), + "properties": .object([ + "resource_type": .object([ + "type": .string("string"), + "description": .string("Type of resources to export"), + "enum": .array([ + .string("colors"), + .string("icons"), + .string("images"), + .string("typography"), + .string("all"), + ]), + ]), + "config_path": .object([ + "type": .string("string"), + "description": .string( + "Path to PKL config file. Auto-detects exfig.pkl in current directory if omitted." + ), + ]), + "filter": .object([ + "type": .string("string"), + "description": .string("Filter by name pattern (e.g., \"background/*\")"), + ]), + "rate_limit": .object([ + "type": .string("integer"), + "description": .string("Figma API requests per minute (default: 10)"), + ]), + "max_retries": .object([ + "type": .string("integer"), + "description": .string("Max retry attempts (default: 4)"), + ]), + "cache": .object([ + "type": .string("boolean"), + "description": .string("Enable version tracking cache (default: false)"), + ]), + "force": .object([ + "type": .string("boolean"), + "description": .string("Force export ignoring cache (default: false)"), + ]), + "granular_cache": .object([ + "type": .string("boolean"), + "description": .string("Enable per-node granular cache (default: false)"), + ]), + ]), + "required": .array([.string("resource_type")]), + ]) + ) + + static let downloadTool = Tool( + name: "exfig_download", + description: """ + Export design data from Figma as W3C Design Tokens JSON. \ + Returns JSON directly in the response — does not write files. \ + Requires FIGMA_PERSONAL_TOKEN. + """, + inputSchema: .object([ + "type": .string("object"), + "properties": .object([ + "resource_type": .object([ + "type": .string("string"), + "description": .string("Type of design data to export"), + "enum": .array([ + .string("colors"), + .string("typography"), + .string("tokens"), + ]), + ]), + "config_path": .object([ + "type": .string("string"), + "description": .string( + "Path to PKL config file. Auto-detects exfig.pkl if omitted." + ), + ]), + "format": .object([ + "type": .string("string"), + "description": .string("Token format: w3c (default) or raw"), + "enum": .array([ + .string("w3c"), + .string("raw"), + ]), + ]), + "filter": .object([ + "type": .string("string"), + "description": .string("Filter by name pattern. Only for colors."), + ]), + ]), + "required": .array([.string("resource_type")]), + ]) + ) } diff --git a/Sources/ExFigCLI/MCP/MCPToolHandlers.swift b/Sources/ExFigCLI/MCP/MCPToolHandlers.swift index ab8ec4d3..04f41d41 100644 --- a/Sources/ExFigCLI/MCP/MCPToolHandlers.swift +++ b/Sources/ExFigCLI/MCP/MCPToolHandlers.swift @@ -18,6 +18,10 @@ enum MCPToolHandlers { return try await handleTokensInfo(params: params) case "exfig_inspect": return try await handleInspect(params: params, state: state) + case "exfig_export": + return try await handleExport(params: params) + case "exfig_download": + return try await handleDownload(params: params, state: state) default: return .init(content: [.text("Unknown tool: \(params.name)")], isError: true) } @@ -288,6 +292,329 @@ enum MCPToolHandlers { } } +// MARK: - Export & Download Handlers + +extension MCPToolHandlers { + static func handleExport(params: CallTool.Parameters) async throws -> CallTool.Result { + guard let resourceType = params.arguments?["resource_type"]?.stringValue else { + return .init(content: [.text("Missing required parameter: resource_type")], isError: true) + } + + let validTypes: Set = ["colors", "icons", "images", "typography", "all"] + guard validTypes.contains(resourceType) else { + let valid = validTypes.sorted().joined(separator: ", ") + return .init( + content: [.text("Invalid resource_type: \(resourceType). Must be one of: \(valid)")], + isError: true + ) + } + + let configPath = try resolveConfigPath(from: params.arguments?["config_path"]?.stringValue) + + let reportPath = FileManager.default.temporaryDirectory + .appendingPathComponent("exfig-report-\(UUID().uuidString).json").path + + let exportParams = ExportParams( + resourceType: resourceType, + configPath: configPath, + reportPath: reportPath, + filter: params.arguments?["filter"]?.stringValue, + rateLimit: params.arguments?["rate_limit"]?.intValue, + maxRetries: params.arguments?["max_retries"]?.intValue, + cache: params.arguments?["cache"]?.boolValue ?? false, + force: params.arguments?["force"]?.boolValue ?? false, + granularCache: params.arguments?["granular_cache"]?.boolValue ?? false + ) + + let result = try await runSubprocess(arguments: exportParams.cliArgs) + + defer { try? FileManager.default.removeItem(atPath: reportPath) } + + if FileManager.default.fileExists(atPath: reportPath), + let reportData = FileManager.default.contents(atPath: reportPath), + let reportJSON = String(data: reportData, encoding: .utf8) + { + return .init(content: [.text(reportJSON)], isError: result.exitCode != 0) + } + + if result.exitCode != 0 { + let message = result.stderr.isEmpty + ? "Export failed with exit code \(result.exitCode)" + : result.stderr + return .init(content: [.text(message)], isError: true) + } + + return .init(content: [.text("{\"success\": true}")]) + } + + static func handleDownload( + params: CallTool.Parameters, + state: MCPServerState + ) async throws -> CallTool.Result { + guard let resourceType = params.arguments?["resource_type"]?.stringValue else { + return .init(content: [.text("Missing required parameter: resource_type")], isError: true) + } + + let validTypes: Set = ["colors", "typography", "tokens"] + guard validTypes.contains(resourceType) else { + let valid = validTypes.sorted().joined(separator: ", ") + return .init( + content: [.text("Invalid resource_type: \(resourceType). Must be one of: \(valid)")], + isError: true + ) + } + + let configPath = try resolveConfigPath(from: params.arguments?["config_path"]?.stringValue) + let configURL = URL(fileURLWithPath: configPath) + let config = try await PKLEvaluator.evaluate(configPath: configURL) + let client = try await state.getClient() + + let format = params.arguments?["format"]?.stringValue ?? "w3c" + let filter = params.arguments?["filter"]?.stringValue + + switch resourceType { + case "colors": + return try await downloadColors( + config: config, client: client, format: format, filter: filter + ) + case "typography": + return try await downloadTypography(config: config, client: client, format: format) + case "tokens": + return try await downloadUnifiedTokens(config: config, client: client) + default: + return .init(content: [.text("Unknown resource_type: \(resourceType)")], isError: true) + } + } +} + +// MARK: - Export Subprocess Helpers + +private struct ExportParams { + let resourceType: String + let configPath: String + let reportPath: String + let filter: String? + let rateLimit: Int? + let maxRetries: Int? + let cache: Bool + let force: Bool + let granularCache: Bool + + var cliArgs: [String] { + var args: [String] = if resourceType == "all" { + ["batch", configPath, "--quiet", "--report", reportPath] + } else { + [resourceType, "-i", configPath, "--quiet", "--report", reportPath] + } + if let filter { args += ["--filter", filter] } + if let rateLimit { args += ["--rate-limit", "\(rateLimit)"] } + if let maxRetries { args += ["--max-retries", "\(maxRetries)"] } + if cache { args.append("--cache") } + if force { args.append("--force") } + if granularCache { args.append("--experimental-granular-cache") } + return args + } +} + +struct SubprocessResult { + let exitCode: Int + let stderr: String +} + +extension MCPToolHandlers { + static func runSubprocess(arguments: [String]) async throws -> SubprocessResult { + let process = Process() + process.executableURL = URL(fileURLWithPath: ProcessInfo.processInfo.arguments[0]) + process.arguments = arguments + process.environment = ProcessInfo.processInfo.environment + + let stderrPipe = Pipe() + process.standardError = stderrPipe + process.standardOutput = FileHandle.nullDevice + + try process.run() + + await withCheckedContinuation { (continuation: CheckedContinuation) in + process.terminationHandler = { _ in continuation.resume() } + } + + let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() + let stderr = String(data: stderrData, encoding: .utf8) ?? "" + + return SubprocessResult(exitCode: Int(process.terminationStatus), stderr: stderr) + } +} + +// MARK: - Download Helpers + +extension MCPToolHandlers { + private static func downloadColors( + config: PKLConfig, client: FigmaAPI.Client, + format: String, filter: String? + ) async throws -> CallTool.Result { + guard let variableParams = config.common?.variablesColors else { + throw ExFigError.custom(errorString: "No variablesColors configured. Check config.") + } + + let loader = ColorsVariablesLoader(client: client, variableParams: variableParams, filter: filter) + let result = try await loader.load() + let warnings = result.warnings.map { ExFigWarningFormatter().format($0) } + + if format == "raw" { + return .init(content: [.text(encodeRawColors(result.output))]) + } + + let colorsByMode = ColorExportHelper.buildColorsByMode(from: result.output) + let exporter = W3CTokensExporter(version: .v2025) + let tokens = exporter.exportColors( + colorsByMode: colorsByMode, + descriptions: result.descriptions, + metadata: result.metadata, + aliases: result.aliases, + modeKeyToName: ColorExportHelper.modeKeyToName + ) + let tokenCount = colorsByMode.values.reduce(0) { $0 + $1.count } + + let meta = DownloadMeta( + resourceType: "colors", format: format, + tokenCount: tokenCount, + warnings: warnings.isEmpty ? nil : warnings + ) + return buildDownloadResponse(tokens: tokens, exporter: exporter, meta: meta) + } + + private static func downloadTypography( + config: PKLConfig, client: FigmaAPI.Client, format: String + ) async throws -> CallTool.Result { + guard let figmaParams = config.figma else { + throw ExFigError.custom(errorString: "No figma section configured. Check config.") + } + + let loader = TextStylesLoader(client: client, params: figmaParams) + let textStyles = try await loader.load() + + if format == "raw" { + return .init(content: [.text(encodeJSON(textStyles.map { RawTextStyle(from: $0) }))]) + } + + let exporter = W3CTokensExporter(version: .v2025) + let tokens = exporter.exportTypography(textStyles: textStyles) + + let meta = DownloadMeta( + resourceType: "typography", format: format, + tokenCount: textStyles.count, warnings: nil + ) + return buildDownloadResponse(tokens: tokens, exporter: exporter, meta: meta) + } + + // swiftlint:disable function_body_length + private static func downloadUnifiedTokens( + config: PKLConfig, client: FigmaAPI.Client + ) async throws -> CallTool.Result { + let exporter = W3CTokensExporter(version: .v2025) + var allTokens: [String: Any] = [:] + var warnings: [String] = [] + var tokenCount = 0 + + if let variableParams = config.common?.variablesColors { + let loader = ColorsVariablesLoader(client: client, variableParams: variableParams, filter: nil) + let colorsResult = try await loader.load() + warnings += colorsResult.warnings.map { ExFigWarningFormatter().format($0) } + let colorsByMode = ColorExportHelper.buildColorsByMode(from: colorsResult.output) + let colorTokens = exporter.exportColors( + colorsByMode: colorsByMode, + descriptions: colorsResult.descriptions, + metadata: colorsResult.metadata, + aliases: colorsResult.aliases, + modeKeyToName: ColorExportHelper.modeKeyToName + ) + W3CTokensExporter.mergeTokens(from: colorTokens, into: &allTokens) + tokenCount += colorsByMode.values.reduce(0) { $0 + $1.count } + } + + if let figmaParams = config.figma { + let loader = TextStylesLoader(client: client, params: figmaParams) + let textStyles = try await loader.load() + W3CTokensExporter.mergeTokens( + from: exporter.exportTypography(textStyles: textStyles), + into: &allTokens + ) + tokenCount += textStyles.count + } + + if let variableParams = config.common?.variablesColors { + let numLoader = NumberVariablesLoader( + client: client, + tokensFileId: variableParams.tokensFileId, + tokensCollectionName: variableParams.tokensCollectionName + ) + let numberResult = try await numLoader.load() + warnings += numberResult.warnings.map { ExFigWarningFormatter().format($0) } + if !numberResult.dimensions.isEmpty { + W3CTokensExporter.mergeTokens( + from: exporter.exportDimensions(tokens: numberResult.dimensions), + into: &allTokens + ) + tokenCount += numberResult.dimensions.count + } + if !numberResult.numbers.isEmpty { + W3CTokensExporter.mergeTokens( + from: exporter.exportNumbers(tokens: numberResult.numbers), + into: &allTokens + ) + tokenCount += numberResult.numbers.count + } + } + + if allTokens.isEmpty { + return .init( + content: [.text("No token sections configured for export. Check your config file.")], + isError: true + ) + } + + let meta = DownloadMeta( + resourceType: "tokens", format: "w3c", + tokenCount: tokenCount, + warnings: warnings.isEmpty ? nil : warnings + ) + return buildDownloadResponse(tokens: allTokens, exporter: exporter, meta: meta) + } + + // swiftlint:enable function_body_length + + private static func buildDownloadResponse( + tokens: [String: Any], exporter: W3CTokensExporter, + meta: DownloadMeta + ) -> CallTool.Result { + guard let tokensData = try? exporter.serializeToJSON(tokens, compact: false), + let tokensJSON = String(data: tokensData, encoding: .utf8) + else { + return .init(content: [.text("Failed to serialize tokens to JSON")], isError: true) + } + + return .init(content: [ + .text(encodeJSON(meta)), + .text(tokensJSON), + ]) + } + + private static func encodeRawColors(_ output: ColorsLoaderOutput) -> String { + func colorToRaw(_ color: Color) -> RawColor { + RawColor( + name: color.name, + red: color.red, green: color.green, + blue: color.blue, alpha: color.alpha + ) + } + var raw = RawColorsOutput(light: output.light.map(colorToRaw)) + raw.dark = output.dark.map { $0.map(colorToRaw) } + raw.lightHC = output.lightHC.map { $0.map(colorToRaw) } + raw.darkHC = output.darkHC.map { $0.map(colorToRaw) } + return encodeJSON(raw) + } +} + // MARK: - Response Types private struct ValidateSummary: Codable, Sendable { @@ -423,4 +750,57 @@ private struct TypographyInspectResult: Codable, Sendable { } } +private struct DownloadMeta: Codable, Sendable { + let resourceType: String + let format: String + let tokenCount: Int + var warnings: [String]? + + enum CodingKeys: String, CodingKey { + case resourceType = "resource_type" + case format + case tokenCount = "token_count" + case warnings + } +} + +private struct RawColorsOutput: Codable, Sendable { + let light: [RawColor] + var dark: [RawColor]? + var lightHC: [RawColor]? + var darkHC: [RawColor]? +} + +private struct RawColor: Codable, Sendable { + let name: String + let red: Double + let green: Double + let blue: Double + let alpha: Double +} + +private struct RawTextStyle: Codable, Sendable { + let name: String + let fontName: String + let fontSize: Double + let lineHeight: Double? + let letterSpacing: Double + + init(from textStyle: TextStyle) { + name = textStyle.name + fontName = textStyle.fontName + fontSize = textStyle.fontSize + lineHeight = textStyle.lineHeight + letterSpacing = textStyle.letterSpacing + } + + enum CodingKeys: String, CodingKey { + case name + case fontName = "font_name" + case fontSize = "font_size" + case lineHeight = "line_height" + case letterSpacing = "letter_spacing" + } +} + // swiftlint:enable file_length diff --git a/Tests/ExFigTests/MCP/MCPToolHandlerTests.swift b/Tests/ExFigTests/MCP/MCPToolHandlerTests.swift index 16ded889..b5b63d26 100644 --- a/Tests/ExFigTests/MCP/MCPToolHandlerTests.swift +++ b/Tests/ExFigTests/MCP/MCPToolHandlerTests.swift @@ -197,4 +197,104 @@ struct MCPToolHandlerTests { #expect(text.contains("resource_type")) } } + + // MARK: - Export Tool + + @Test("export returns error for missing resource_type") + func exportMissingResourceType() async { + let params = CallTool.Parameters( + name: "exfig_export", + arguments: nil + ) + + let result = await MCPToolHandlers.handle(params: params, state: MCPServerState()) + + #expect(result.isError == true) + if case let .text(text) = result.content.first { + #expect(text.contains("resource_type")) + } + } + + @Test("export returns error for invalid resource_type") + func exportInvalidResourceType() async { + let params = CallTool.Parameters( + name: "exfig_export", + arguments: ["resource_type": .string("invalid")] + ) + + let result = await MCPToolHandlers.handle(params: params, state: MCPServerState()) + + #expect(result.isError == true) + if case let .text(text) = result.content.first { + #expect(text.contains("Invalid resource_type")) + } + } + + @Test("export returns error for missing config") + func exportMissingConfig() async { + let params = CallTool.Parameters( + name: "exfig_export", + arguments: [ + "resource_type": .string("colors"), + "config_path": .string("/nonexistent/path.pkl"), + ] + ) + + let result = await MCPToolHandlers.handle(params: params, state: MCPServerState()) + + #expect(result.isError == true) + if case let .text(text) = result.content.first { + #expect(text.contains("not found")) + } + } + + // MARK: - Download Tool + + @Test("download returns error for missing resource_type") + func downloadMissingResourceType() async { + let params = CallTool.Parameters( + name: "exfig_download", + arguments: nil + ) + + let result = await MCPToolHandlers.handle(params: params, state: MCPServerState()) + + #expect(result.isError == true) + if case let .text(text) = result.content.first { + #expect(text.contains("resource_type")) + } + } + + @Test("download returns error for invalid resource_type") + func downloadInvalidResourceType() async { + let params = CallTool.Parameters( + name: "exfig_download", + arguments: ["resource_type": .string("invalid")] + ) + + let result = await MCPToolHandlers.handle(params: params, state: MCPServerState()) + + #expect(result.isError == true) + if case let .text(text) = result.content.first { + #expect(text.contains("Invalid resource_type")) + } + } + + @Test("download returns error for missing config") + func downloadMissingConfig() async { + let params = CallTool.Parameters( + name: "exfig_download", + arguments: [ + "resource_type": .string("colors"), + "config_path": .string("/nonexistent/path.pkl"), + ] + ) + + let result = await MCPToolHandlers.handle(params: params, state: MCPServerState()) + + #expect(result.isError == true) + if case let .text(text) = result.content.first { + #expect(text.contains("not found")) + } + } } From f5a0fe638f4ea6984095dfdac0cb62979f723e14 Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Fri, 20 Mar 2026 22:17:33 +0500 Subject: [PATCH 2/5] docs: add Claude Code plugins references to README and DocC Link to exfig-plugins marketplace from README, DocC landing page, MCP Server article, and CLAUDE.md files. --- CLAUDE.md | 1 + README.md | 10 ++++++++++ Sources/ExFigCLI/CLAUDE.md | 1 + Sources/ExFigCLI/ExFig.docc/ExFig.md | 5 +++-- Sources/ExFigCLI/ExFig.docc/MCPServer.md | 21 +++++++++++++++++++++ llms-full.txt | 10 ++++++++++ 6 files changed, 46 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7e8acf8b..7b059c6b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -120,6 +120,7 @@ Fourteen modules in `Sources/`: **MCP data flow:** `exfig mcp` → StdioTransport (JSON-RPC on stdin/stdout) → tool handlers → PKLEvaluator / TokensFileSource / FigmaAPI **MCP stdout safety:** `OutputMode.mcp` + `TerminalOutputManager.setStderrMode(true)` — all CLI output goes to stderr +**Claude Code plugins:** [exfig-plugins](https://github.com/DesignPipe/exfig-plugins) marketplace — MCP integration, setup wizard, export commands, config review, troubleshooting **Batch mode:** Single `@TaskLocal` via `BatchSharedState` actor — see `ExFigCLI/CLAUDE.md`. diff --git a/README.md b/README.md index 1f88bdf2..71b95382 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,16 @@ See the [Getting Started guide](https://DesignPipe.github.io/exfig/documentation cache: true ``` +## Claude Code Plugins + +Use ExFig directly from Claude Code with the [exfig-plugins](https://github.com/DesignPipe/exfig-plugins) marketplace: + +```bash +claude /plugin marketplace add https://github.com/DesignPipe/exfig-plugins +``` + +Includes MCP integration, setup wizard, config review, troubleshooting, and `/export-*` slash commands. + ## Documentation Full documentation — platform guides, configuration reference, batch processing, design tokens, custom templates, and MCP server — is available at **[DesignPipe.github.io/exfig](https://DesignPipe.github.io/exfig/documentation/exfig)**. diff --git a/Sources/ExFigCLI/CLAUDE.md b/Sources/ExFigCLI/CLAUDE.md index fc03383b..1eabcfdc 100644 --- a/Sources/ExFigCLI/CLAUDE.md +++ b/Sources/ExFigCLI/CLAUDE.md @@ -197,6 +197,7 @@ reserved for MCP JSON-RPC protocol. 5. `Color.hex` is in AndroidExport, not accessible from ExFigCLI — use RGBA components 6. `ColorsVariablesLoader` takes `PKLConfig.Common.VariablesColors?`, not `.Colors?` 7. MCP `JSONValue` accessors: `.stringValue`, `.intValue`, `.boolValue` +8. Export tools: `exfig_export` runs subprocess (self-invoke), reads JSON report from temp file; `exfig_download` returns tokens inline via loaders ## Modification Patterns diff --git a/Sources/ExFigCLI/ExFig.docc/ExFig.md b/Sources/ExFigCLI/ExFig.docc/ExFig.md index 88dba8a1..b4836b46 100644 --- a/Sources/ExFigCLI/ExFig.docc/ExFig.md +++ b/Sources/ExFigCLI/ExFig.docc/ExFig.md @@ -45,8 +45,9 @@ file version tracking, and experimental per-node granular cache. **Developer Experience** CI/CD ready (quiet mode, exit codes, JSON reports), GitHub Action for automated exports, -MCP server for AI assistant integration, customizable Jinja2 code templates, -and rich progress indicators with ETA. +MCP server for AI assistant integration, +[Claude Code plugins](https://github.com/DesignPipe/exfig-plugins) for setup wizards and slash commands, +customizable Jinja2 code templates, and rich progress indicators with ETA. **Code Generation** Type-safe Swift/Kotlin/Dart/TypeScript extensions, pre-configured UILabel subclasses, diff --git a/Sources/ExFigCLI/ExFig.docc/MCPServer.md b/Sources/ExFigCLI/ExFig.docc/MCPServer.md index 17531d3a..ffff1d75 100644 --- a/Sources/ExFigCLI/ExFig.docc/MCPServer.md +++ b/Sources/ExFigCLI/ExFig.docc/MCPServer.md @@ -64,6 +64,27 @@ AI assistants can read these to understand config structure and generate valid c | `setup-config` | Guide through creating an `exfig.pkl` config | | `troubleshoot-export` | Diagnose and fix export errors | +## Claude Code Plugins + +For a turnkey Claude Code experience, install the +[exfig-plugins](https://github.com/DesignPipe/exfig-plugins) marketplace: + +```bash +claude /plugin marketplace add https://github.com/DesignPipe/exfig-plugins +``` + +The marketplace includes: + +| Plugin | What it does | +| ------ | ------------ | +| **exfig-mcp** | Pre-configured `.mcp.json` + usage skill | +| **exfig-setup** | Interactive wizard: install → token → config → first export → CI | +| **exfig-export** | `/export-colors`, `/export-icons`, `/export-images`, `/export-all` commands | +| **exfig-config-review** | Reviews `exfig.pkl` for issues and optimizations | +| **exfig-troubleshooting** | Error catalog with diagnostic steps | +| **exfig-migration** | Migration guide between major versions | +| **exfig-rules** | Naming and structure conventions | + ## See Also - diff --git a/llms-full.txt b/llms-full.txt index 64188aa5..c244254f 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -63,6 +63,16 @@ See the [Getting Started guide](https://DesignPipe.github.io/exfig/documentation cache: true ``` +## Claude Code Plugins + +Use ExFig directly from Claude Code with the [exfig-plugins](https://github.com/DesignPipe/exfig-plugins) marketplace: + +```bash +claude /plugin marketplace add https://github.com/DesignPipe/exfig-plugins +``` + +Includes MCP integration, setup wizard, config review, troubleshooting, and `/export-*` slash commands. + ## Documentation Full documentation — platform guides, configuration reference, batch processing, design tokens, custom templates, and MCP server — is available at **[DesignPipe.github.io/exfig](https://DesignPipe.github.io/exfig/documentation/exfig)**. From 1037dcde3d5f36b9771ee8a4bd00476908003754 Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Fri, 20 Mar 2026 22:24:47 +0500 Subject: [PATCH 3/5] fix(mcp): add legacy colors fallback and refactor downloadUnifiedTokens - downloadColors now falls back to ColorsLoader (Styles API) when variablesColors is not configured, matching CLI behavior - Extract downloadAndMergeColors/Typography/Numbers helpers from downloadUnifiedTokens for better readability --- Sources/ExFigCLI/MCP/MCPToolHandlers.swift | 133 ++++++++++++++------- 1 file changed, 90 insertions(+), 43 deletions(-) diff --git a/Sources/ExFigCLI/MCP/MCPToolHandlers.swift b/Sources/ExFigCLI/MCP/MCPToolHandlers.swift index 04f41d41..eca5f3dd 100644 --- a/Sources/ExFigCLI/MCP/MCPToolHandlers.swift +++ b/Sources/ExFigCLI/MCP/MCPToolHandlers.swift @@ -452,12 +452,28 @@ extension MCPToolHandlers { config: PKLConfig, client: FigmaAPI.Client, format: String, filter: String? ) async throws -> CallTool.Result { - guard let variableParams = config.common?.variablesColors else { - throw ExFigError.custom(errorString: "No variablesColors configured. Check config.") + let result: ColorsVariablesLoader.LoadResult + + if let variableParams = config.common?.variablesColors { + let loader = ColorsVariablesLoader(client: client, variableParams: variableParams, filter: filter) + result = try await loader.load() + } else if let figmaParams = config.figma { + let loader = ColorsLoader( + client: client, + figmaParams: figmaParams, + colorParams: config.common?.colors, + filter: filter + ) + let output = try await loader.load() + result = ColorsVariablesLoader.LoadResult( + output: output, warnings: [], aliases: [:], descriptions: [:], metadata: [:] + ) + } else { + throw ExFigError.custom( + errorString: "No variablesColors or figma section configured. Check config." + ) } - let loader = ColorsVariablesLoader(client: client, variableParams: variableParams, filter: filter) - let result = try await loader.load() let warnings = result.warnings.map { ExFigWarningFormatter().format($0) } if format == "raw" { @@ -507,7 +523,6 @@ extension MCPToolHandlers { return buildDownloadResponse(tokens: tokens, exporter: exporter, meta: meta) } - // swiftlint:disable function_body_length private static func downloadUnifiedTokens( config: PKLConfig, client: FigmaAPI.Client ) async throws -> CallTool.Result { @@ -517,53 +532,31 @@ extension MCPToolHandlers { var tokenCount = 0 if let variableParams = config.common?.variablesColors { - let loader = ColorsVariablesLoader(client: client, variableParams: variableParams, filter: nil) - let colorsResult = try await loader.load() - warnings += colorsResult.warnings.map { ExFigWarningFormatter().format($0) } - let colorsByMode = ColorExportHelper.buildColorsByMode(from: colorsResult.output) - let colorTokens = exporter.exportColors( - colorsByMode: colorsByMode, - descriptions: colorsResult.descriptions, - metadata: colorsResult.metadata, - aliases: colorsResult.aliases, - modeKeyToName: ColorExportHelper.modeKeyToName + let (tokens, count, w) = try await downloadAndMergeColors( + client: client, variableParams: variableParams, exporter: exporter ) - W3CTokensExporter.mergeTokens(from: colorTokens, into: &allTokens) - tokenCount += colorsByMode.values.reduce(0) { $0 + $1.count } + W3CTokensExporter.mergeTokens(from: tokens, into: &allTokens) + tokenCount += count + warnings += w } if let figmaParams = config.figma { - let loader = TextStylesLoader(client: client, params: figmaParams) - let textStyles = try await loader.load() - W3CTokensExporter.mergeTokens( - from: exporter.exportTypography(textStyles: textStyles), - into: &allTokens + let (tokens, count) = try await downloadAndMergeTypography( + client: client, figmaParams: figmaParams, exporter: exporter ) - tokenCount += textStyles.count + W3CTokensExporter.mergeTokens(from: tokens, into: &allTokens) + tokenCount += count } if let variableParams = config.common?.variablesColors { - let numLoader = NumberVariablesLoader( - client: client, - tokensFileId: variableParams.tokensFileId, - tokensCollectionName: variableParams.tokensCollectionName + let (tokens, count, w) = try await downloadAndMergeNumbers( + client: client, variableParams: variableParams, exporter: exporter ) - let numberResult = try await numLoader.load() - warnings += numberResult.warnings.map { ExFigWarningFormatter().format($0) } - if !numberResult.dimensions.isEmpty { - W3CTokensExporter.mergeTokens( - from: exporter.exportDimensions(tokens: numberResult.dimensions), - into: &allTokens - ) - tokenCount += numberResult.dimensions.count - } - if !numberResult.numbers.isEmpty { - W3CTokensExporter.mergeTokens( - from: exporter.exportNumbers(tokens: numberResult.numbers), - into: &allTokens - ) - tokenCount += numberResult.numbers.count + for t in tokens { + W3CTokensExporter.mergeTokens(from: t, into: &allTokens) } + tokenCount += count + warnings += w } if allTokens.isEmpty { @@ -581,7 +574,61 @@ extension MCPToolHandlers { return buildDownloadResponse(tokens: allTokens, exporter: exporter, meta: meta) } - // swiftlint:enable function_body_length + private static func downloadAndMergeColors( + client: FigmaAPI.Client, + variableParams: PKLConfig.Common.VariablesColors, + exporter: W3CTokensExporter + ) async throws -> (tokens: [String: Any], count: Int, warnings: [String]) { + let loader = ColorsVariablesLoader(client: client, variableParams: variableParams, filter: nil) + let colorsResult = try await loader.load() + let warnings = colorsResult.warnings.map { ExFigWarningFormatter().format($0) } + let colorsByMode = ColorExportHelper.buildColorsByMode(from: colorsResult.output) + let colorTokens = exporter.exportColors( + colorsByMode: colorsByMode, + descriptions: colorsResult.descriptions, + metadata: colorsResult.metadata, + aliases: colorsResult.aliases, + modeKeyToName: ColorExportHelper.modeKeyToName + ) + let count = colorsByMode.values.reduce(0) { $0 + $1.count } + return (colorTokens, count, warnings) + } + + private static func downloadAndMergeTypography( + client: FigmaAPI.Client, + figmaParams: PKLConfig.Figma, + exporter: W3CTokensExporter + ) async throws -> (tokens: [String: Any], count: Int) { + let loader = TextStylesLoader(client: client, params: figmaParams) + let textStyles = try await loader.load() + let tokens = exporter.exportTypography(textStyles: textStyles) + return (tokens, textStyles.count) + } + + private static func downloadAndMergeNumbers( + client: FigmaAPI.Client, + variableParams: PKLConfig.Common.VariablesColors, + exporter: W3CTokensExporter + ) async throws -> (tokens: [[String: Any]], count: Int, warnings: [String]) { + let numLoader = NumberVariablesLoader( + client: client, + tokensFileId: variableParams.tokensFileId, + tokensCollectionName: variableParams.tokensCollectionName + ) + let numberResult = try await numLoader.load() + let warnings = numberResult.warnings.map { ExFigWarningFormatter().format($0) } + var tokens: [[String: Any]] = [] + var count = 0 + if !numberResult.dimensions.isEmpty { + tokens.append(exporter.exportDimensions(tokens: numberResult.dimensions)) + count += numberResult.dimensions.count + } + if !numberResult.numbers.isEmpty { + tokens.append(exporter.exportNumbers(tokens: numberResult.numbers)) + count += numberResult.numbers.count + } + return (tokens, count, warnings) + } private static func buildDownloadResponse( tokens: [String: Any], exporter: W3CTokensExporter, From 0520488156cf49fcab345a3ac51bf1008ae38080 Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Fri, 20 Mar 2026 22:44:15 +0500 Subject: [PATCH 4/5] fix(mcp): harden MCP tool handlers against race conditions, deadlocks, and silent failures Fix critical subprocess race condition where terminationHandler was set after process.run(), causing potential infinite hangs. Read stderr concurrently to prevent pipe buffer deadlock. Add 5-minute subprocess timeout via withThrowingTaskGroup race pattern. Make encodeJSON and buildDownloadResponse throw instead of silently swallowing errors with try?. Replace .localizedDescription with String(describing:) in catch-all for better error messages. Add format parameter validation, skipped-section warnings in downloadUnifiedTokens, and raw-format warning passthrough. Extract requireResourceType helper, test expectError helper, and add private access control to match existing handler conventions. --- CLAUDE.md | 3 + Sources/ExFigCLI/CLAUDE.md | 4 +- Sources/ExFigCLI/MCP/MCPToolDefinitions.swift | 1 + Sources/ExFigCLI/MCP/MCPToolHandlers.swift | 182 +++++++++++------ .../ExFigTests/MCP/MCPToolHandlerTests.swift | 187 ++++++------------ 5 files changed, 185 insertions(+), 192 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7b059c6b..80a7262e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -395,6 +395,9 @@ NooraUI.formatLink("url", useColors: true) // underlined primary | MCP `FigmaConfig` fields | No `colorsFileId` — use `config.getFileIds()` or `figma.lightFileId`/`darkFileId` | | `distantFuture` on clock | `ContinuousClock.Instant` has no `distantFuture`; use `withCheckedContinuation { _ in }` for infinite suspend | | MCP stderr duplication | `TerminalOutputManager.setStderrMode(true)` handles all output routing — don't duplicate in `ExFigLogHandler` | +| MCP `Process` race condition | Set `terminationHandler` BEFORE `process.run()` — process may exit before handler is installed, hanging the continuation forever | +| MCP pipe deadlock | Read stderr via concurrent `Task` BEFORE waiting for termination — pipe buffer (~64KB) can fill and block the subprocess | +| MCP `encodeJSON` errors | Use `throws` not `try?` — silently returning `"\(value)"` (Swift debug dump) breaks JSON consumers; top-level `do/catch` in `handle()` catches automatically | | `VariablesColors` vs `Colors` | `ColorsVariablesLoader` takes `Common.VariablesColors?`, not `Common.Colors?` — different PKL types | | PKL template word search | Template comments on `lightFileId` contain cross-refs (`variablesColors`, `typography`); test section removal by matching full markers (`colors = new Common.Colors {`) not bare words | | CI llms-full.txt stale | `llms-full.txt` is generated from README + DocC articles; after editing `Usage.md`, `ExFig.md`, or `README.md`, run `./bin/mise run generate:llms` and commit the result | diff --git a/Sources/ExFigCLI/CLAUDE.md b/Sources/ExFigCLI/CLAUDE.md index 1eabcfdc..d265c399 100644 --- a/Sources/ExFigCLI/CLAUDE.md +++ b/Sources/ExFigCLI/CLAUDE.md @@ -196,8 +196,10 @@ reserved for MCP JSON-RPC protocol. 4. `ExFigWarning` → string via `ExFigWarningFormatter().format(warning)` (no `.formattedMessage` property) 5. `Color.hex` is in AndroidExport, not accessible from ExFigCLI — use RGBA components 6. `ColorsVariablesLoader` takes `PKLConfig.Common.VariablesColors?`, not `.Colors?` -7. MCP `JSONValue` accessors: `.stringValue`, `.intValue`, `.boolValue` +7. MCP `CallTool.Parameters.arguments` type is `[String: Value]?` (not `JSONValue`); accessors: `.stringValue`, `.intValue`, `.boolValue` 8. Export tools: `exfig_export` runs subprocess (self-invoke), reads JSON report from temp file; `exfig_download` returns tokens inline via loaders +9. `runSubprocess` pattern: set `terminationHandler` BEFORE `process.run()` (race condition); read stderr pipe concurrently via `Task` (deadlock at 64KB buffer); use `withThrowingTaskGroup` race for timeout +10. Validate cheap params (format, resource_type) BEFORE expensive operations (PKL eval, `state.getClient()`) — keeps tests fast and error messages clear ## Modification Patterns diff --git a/Sources/ExFigCLI/MCP/MCPToolDefinitions.swift b/Sources/ExFigCLI/MCP/MCPToolDefinitions.swift index 38c49c73..f8e78099 100644 --- a/Sources/ExFigCLI/MCP/MCPToolDefinitions.swift +++ b/Sources/ExFigCLI/MCP/MCPToolDefinitions.swift @@ -84,6 +84,7 @@ enum MCPToolDefinitions { "required": .array([.string("resource_type")]), ]) ) + static let exportTool = Tool( name: "exfig_export", description: """ diff --git a/Sources/ExFigCLI/MCP/MCPToolHandlers.swift b/Sources/ExFigCLI/MCP/MCPToolHandlers.swift index eca5f3dd..5160df2e 100644 --- a/Sources/ExFigCLI/MCP/MCPToolHandlers.swift +++ b/Sources/ExFigCLI/MCP/MCPToolHandlers.swift @@ -33,7 +33,7 @@ enum MCPToolHandlers { isError: true ) } catch { - return .init(content: [.text("Error: \(error.localizedDescription)")], isError: true) + return .init(content: [.text("Error: \(error)")], isError: true) } } @@ -55,7 +55,7 @@ enum MCPToolHandlers { figmaFileIds: fileIDs.isEmpty ? nil : fileIDs ) - return .init(content: [.text(encodeJSON(summary))]) + return try .init(content: [.text(encodeJSON(summary))]) } private static func buildPlatformSummary(config: PKLConfig) -> [String: EntrySummary] { @@ -128,7 +128,7 @@ enum MCPToolHandlers { warnings: source.warnings.isEmpty ? nil : source.warnings ) - return .init(content: [.text(encodeJSON(result))]) + return try .init(content: [.text(encodeJSON(result))]) } // MARK: - Inspect @@ -168,7 +168,7 @@ enum MCPToolHandlers { } } - return .init(content: [.text(encodeJSON(results))]) + return try .init(content: [.text(encodeJSON(results))]) } // MARK: - Inspect Helpers @@ -284,35 +284,44 @@ enum MCPToolHandlers { } /// Encodes a Codable value as pretty-printed JSON with sorted keys. - private static func encodeJSON(_ value: some Encodable) -> String { - guard let data = try? JSONCodec.encodePrettySorted(value) else { - return "\(value)" + private static func encodeJSON(_ value: some Encodable) throws -> String { + let data = try JSONCodec.encodePrettySorted(value) + guard let string = String(data: data, encoding: .utf8) else { + throw ExFigError.custom(errorString: "JSON encoding produced non-UTF-8 data") } - return String(data: data, encoding: .utf8) ?? "\(value)" + return string } } // MARK: - Export & Download Handlers extension MCPToolHandlers { - static func handleExport(params: CallTool.Parameters) async throws -> CallTool.Result { + private static func requireResourceType( + from params: CallTool.Parameters, + validTypes: Set + ) throws -> String { guard let resourceType = params.arguments?["resource_type"]?.stringValue else { - return .init(content: [.text("Missing required parameter: resource_type")], isError: true) + throw ExFigError.custom(errorString: "Missing required parameter: resource_type") } - - let validTypes: Set = ["colors", "icons", "images", "typography", "all"] guard validTypes.contains(resourceType) else { let valid = validTypes.sorted().joined(separator: ", ") - return .init( - content: [.text("Invalid resource_type: \(resourceType). Must be one of: \(valid)")], - isError: true + throw ExFigError.custom( + errorString: "Invalid resource_type: \(resourceType). Must be one of: \(valid)" ) } + return resourceType + } + + private static func handleExport(params: CallTool.Parameters) async throws -> CallTool.Result { + let resourceType = try requireResourceType( + from: params, validTypes: ["colors", "icons", "images", "typography", "all"] + ) let configPath = try resolveConfigPath(from: params.arguments?["config_path"]?.stringValue) let reportPath = FileManager.default.temporaryDirectory .appendingPathComponent("exfig-report-\(UUID().uuidString).json").path + defer { try? FileManager.default.removeItem(atPath: reportPath) } let exportParams = ExportParams( resourceType: resourceType, @@ -328,8 +337,6 @@ extension MCPToolHandlers { let result = try await runSubprocess(arguments: exportParams.cliArgs) - defer { try? FileManager.default.removeItem(atPath: reportPath) } - if FileManager.default.fileExists(atPath: reportPath), let reportData = FileManager.default.contents(atPath: reportPath), let reportJSON = String(data: reportData, encoding: .utf8) @@ -347,31 +354,29 @@ extension MCPToolHandlers { return .init(content: [.text("{\"success\": true}")]) } - static func handleDownload( + private static func handleDownload( params: CallTool.Parameters, state: MCPServerState ) async throws -> CallTool.Result { - guard let resourceType = params.arguments?["resource_type"]?.stringValue else { - return .init(content: [.text("Missing required parameter: resource_type")], isError: true) - } + let resourceType = try requireResourceType( + from: params, validTypes: ["colors", "typography", "tokens"] + ) - let validTypes: Set = ["colors", "typography", "tokens"] - guard validTypes.contains(resourceType) else { - let valid = validTypes.sorted().joined(separator: ", ") - return .init( - content: [.text("Invalid resource_type: \(resourceType). Must be one of: \(valid)")], - isError: true + // Validate cheap parameters before expensive PKL eval / API client creation + let format = params.arguments?["format"]?.stringValue ?? "w3c" + let validFormats: Set = ["w3c", "raw"] + guard validFormats.contains(format) else { + throw ExFigError.custom( + errorString: "Invalid format: \(format). Must be one of: w3c, raw" ) } + let filter = params.arguments?["filter"]?.stringValue let configPath = try resolveConfigPath(from: params.arguments?["config_path"]?.stringValue) let configURL = URL(fileURLWithPath: configPath) let config = try await PKLEvaluator.evaluate(configPath: configURL) let client = try await state.getClient() - let format = params.arguments?["format"]?.stringValue ?? "w3c" - let filter = params.arguments?["filter"]?.stringValue - switch resourceType { case "colors": return try await downloadColors( @@ -416,15 +421,25 @@ private struct ExportParams { } } -struct SubprocessResult { +private struct SubprocessResult { let exitCode: Int let stderr: String } +private let subprocessTimeout: Duration = .seconds(300) + extension MCPToolHandlers { - static func runSubprocess(arguments: [String]) async throws -> SubprocessResult { + private static func runSubprocess(arguments: [String]) async throws -> SubprocessResult { + let executablePath = ProcessInfo.processInfo.arguments[0] + let executableURL = URL(fileURLWithPath: executablePath) + guard FileManager.default.isExecutableFile(atPath: executablePath) else { + throw ExFigError.custom( + errorString: "Cannot find exfig executable at \(executablePath) for subprocess export" + ) + } + let process = Process() - process.executableURL = URL(fileURLWithPath: ProcessInfo.processInfo.arguments[0]) + process.executableURL = executableURL process.arguments = arguments process.environment = ProcessInfo.processInfo.environment @@ -432,16 +447,37 @@ extension MCPToolHandlers { process.standardError = stderrPipe process.standardOutput = FileHandle.nullDevice - try process.run() - - await withCheckedContinuation { (continuation: CheckedContinuation) in - process.terminationHandler = { _ in continuation.resume() } + // Read stderr concurrently to avoid pipe buffer deadlock. + // Must start reading BEFORE waiting for termination. + let stderrTask = Task { + stderrPipe.fileHandleForReading.readDataToEndOfFile() + } + + // Set termination handler BEFORE run() to avoid race condition + // where process exits before handler is installed. + return try await withThrowingTaskGroup(of: SubprocessResult.self) { group in + group.addTask { + await withCheckedContinuation { (continuation: CheckedContinuation) in + process.terminationHandler = { _ in continuation.resume() } + do { try process.run() } catch { + continuation.resume() + } + } + let stderrData = await stderrTask.value + let stderr = String(data: stderrData, encoding: .utf8) ?? "" + return SubprocessResult(exitCode: Int(process.terminationStatus), stderr: stderr) + } + group.addTask { + try await Task.sleep(for: subprocessTimeout) + process.terminate() + throw ExFigError.custom( + errorString: "Export subprocess timed out after \(subprocessTimeout)" + ) + } + let result = try await group.next()! + group.cancelAll() + return result } - - let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() - let stderr = String(data: stderrData, encoding: .utf8) ?? "" - - return SubprocessResult(exitCode: Int(process.terminationStatus), stderr: stderr) } } @@ -477,7 +513,16 @@ extension MCPToolHandlers { let warnings = result.warnings.map { ExFigWarningFormatter().format($0) } if format == "raw" { - return .init(content: [.text(encodeRawColors(result.output))]) + var content: [Tool.Content] = [] + if !warnings.isEmpty { + let meta = DownloadMeta( + resourceType: "colors", format: "raw", + tokenCount: result.output.light.count, warnings: warnings + ) + try content.append(.text(encodeJSON(meta))) + } + try content.append(.text(encodeRawColors(result.output))) + return .init(content: content) } let colorsByMode = ColorExportHelper.buildColorsByMode(from: result.output) @@ -496,7 +541,7 @@ extension MCPToolHandlers { tokenCount: tokenCount, warnings: warnings.isEmpty ? nil : warnings ) - return buildDownloadResponse(tokens: tokens, exporter: exporter, meta: meta) + return try buildDownloadResponse(tokens: tokens, exporter: exporter, meta: meta) } private static func downloadTypography( @@ -510,7 +555,7 @@ extension MCPToolHandlers { let textStyles = try await loader.load() if format == "raw" { - return .init(content: [.text(encodeJSON(textStyles.map { RawTextStyle(from: $0) }))]) + return try .init(content: [.text(encodeJSON(textStyles.map { RawTextStyle(from: $0) }))]) } let exporter = W3CTokensExporter(version: .v2025) @@ -520,7 +565,7 @@ extension MCPToolHandlers { resourceType: "typography", format: format, tokenCount: textStyles.count, warnings: nil ) - return buildDownloadResponse(tokens: tokens, exporter: exporter, meta: meta) + return try buildDownloadResponse(tokens: tokens, exporter: exporter, meta: meta) } private static func downloadUnifiedTokens( @@ -531,13 +576,17 @@ extension MCPToolHandlers { var warnings: [String] = [] var tokenCount = 0 - if let variableParams = config.common?.variablesColors { + let variableParams = config.common?.variablesColors + + if let variableParams { let (tokens, count, w) = try await downloadAndMergeColors( client: client, variableParams: variableParams, exporter: exporter ) W3CTokensExporter.mergeTokens(from: tokens, into: &allTokens) tokenCount += count warnings += w + } else { + warnings.append("Skipped colors and numbers: no variablesColors configured") } if let figmaParams = config.figma { @@ -546,9 +595,11 @@ extension MCPToolHandlers { ) W3CTokensExporter.mergeTokens(from: tokens, into: &allTokens) tokenCount += count + } else { + warnings.append("Skipped typography: no figma section configured") } - if let variableParams = config.common?.variablesColors { + if let variableParams { let (tokens, count, w) = try await downloadAndMergeNumbers( client: client, variableParams: variableParams, exporter: exporter ) @@ -571,7 +622,7 @@ extension MCPToolHandlers { tokenCount: tokenCount, warnings: warnings.isEmpty ? nil : warnings ) - return buildDownloadResponse(tokens: allTokens, exporter: exporter, meta: meta) + return try buildDownloadResponse(tokens: allTokens, exporter: exporter, meta: meta) } private static func downloadAndMergeColors( @@ -633,32 +684,33 @@ extension MCPToolHandlers { private static func buildDownloadResponse( tokens: [String: Any], exporter: W3CTokensExporter, meta: DownloadMeta - ) -> CallTool.Result { - guard let tokensData = try? exporter.serializeToJSON(tokens, compact: false), - let tokensJSON = String(data: tokensData, encoding: .utf8) - else { - return .init(content: [.text("Failed to serialize tokens to JSON")], isError: true) + ) throws -> CallTool.Result { + let tokensData = try exporter.serializeToJSON(tokens, compact: false) + guard let tokensJSON = String(data: tokensData, encoding: .utf8) else { + throw ExFigError.custom(errorString: "Token JSON serialization produced non-UTF-8 data") } - return .init(content: [ + return try .init(content: [ .text(encodeJSON(meta)), .text(tokensJSON), ]) } - private static func encodeRawColors(_ output: ColorsLoaderOutput) -> String { - func colorToRaw(_ color: Color) -> RawColor { + private static func encodeRawColors(_ output: ColorsLoaderOutput) throws -> String { + let toRaw: (Color) -> RawColor = { color in RawColor( name: color.name, red: color.red, green: color.green, blue: color.blue, alpha: color.alpha ) } - var raw = RawColorsOutput(light: output.light.map(colorToRaw)) - raw.dark = output.dark.map { $0.map(colorToRaw) } - raw.lightHC = output.lightHC.map { $0.map(colorToRaw) } - raw.darkHC = output.darkHC.map { $0.map(colorToRaw) } - return encodeJSON(raw) + let raw = RawColorsOutput( + light: output.light.map(toRaw), + dark: output.dark.map { $0.map(toRaw) }, + lightHC: output.lightHC.map { $0.map(toRaw) }, + darkHC: output.darkHC.map { $0.map(toRaw) } + ) + return try encodeJSON(raw) } } @@ -813,9 +865,9 @@ private struct DownloadMeta: Codable, Sendable { private struct RawColorsOutput: Codable, Sendable { let light: [RawColor] - var dark: [RawColor]? - var lightHC: [RawColor]? - var darkHC: [RawColor]? + let dark: [RawColor]? + let lightHC: [RawColor]? + let darkHC: [RawColor]? } private struct RawColor: Codable, Sendable { diff --git a/Tests/ExFigTests/MCP/MCPToolHandlerTests.swift b/Tests/ExFigTests/MCP/MCPToolHandlerTests.swift index b5b63d26..9d21322c 100644 --- a/Tests/ExFigTests/MCP/MCPToolHandlerTests.swift +++ b/Tests/ExFigTests/MCP/MCPToolHandlerTests.swift @@ -12,37 +12,35 @@ struct MCPToolHandlerTests { .deletingLastPathComponent() .appendingPathComponent("Fixtures/PKL") + // MARK: - Test Helpers + + private func expectError( + tool: String, + arguments: [String: Value]?, + containing substring: String + ) async { + let params = CallTool.Parameters(name: tool, arguments: arguments) + let result = await MCPToolHandlers.handle(params: params, state: MCPServerState()) + #expect(result.isError == true) + if case let .text(text) = result.content.first { + #expect(text.contains(substring)) + } + } + // MARK: - Validate Tool @Test("validate returns error for missing config") func validateMissingConfig() async { - let params = CallTool.Parameters( - name: "exfig_validate", - arguments: ["config_path": .string("/nonexistent/path.pkl")] + await expectError( + tool: "exfig_validate", + arguments: ["config_path": .string("/nonexistent/path.pkl")], + containing: "not found" ) - - let result = await MCPToolHandlers.handle(params: params, state: MCPServerState()) - - #expect(result.isError == true) - if case let .text(text) = result.content.first { - #expect(text.contains("not found")) - } } @Test("validate auto-detects exfig.pkl when no path given") func validateAutoDetect() async { - let params = CallTool.Parameters( - name: "exfig_validate", - arguments: nil - ) - - // No exfig.pkl in working directory → error with helpful message - let result = await MCPToolHandlers.handle(params: params, state: MCPServerState()) - - #expect(result.isError == true) - if case let .text(text) = result.content.first { - #expect(text.contains("exfig.pkl")) - } + await expectError(tool: "exfig_validate", arguments: nil, containing: "exfig.pkl") } @Test("validate returns summary for valid config") @@ -69,32 +67,16 @@ struct MCPToolHandlerTests { @Test("tokens_info returns error for missing file_path") func tokensInfoMissingParam() async { - let params = CallTool.Parameters( - name: "exfig_tokens_info", - arguments: nil - ) - - let result = await MCPToolHandlers.handle(params: params, state: MCPServerState()) - - #expect(result.isError == true) - if case let .text(text) = result.content.first { - #expect(text.contains("file_path")) - } + await expectError(tool: "exfig_tokens_info", arguments: nil, containing: "file_path") } @Test("tokens_info returns error for nonexistent file") func tokensInfoFileNotFound() async { - let params = CallTool.Parameters( - name: "exfig_tokens_info", - arguments: ["file_path": .string("/tmp/nonexistent.tokens.json")] + await expectError( + tool: "exfig_tokens_info", + arguments: ["file_path": .string("/tmp/nonexistent.tokens.json")], + containing: "not found" ) - - let result = await MCPToolHandlers.handle(params: params, state: MCPServerState()) - - #expect(result.isError == true) - if case let .text(text) = result.content.first { - #expect(text.contains("not found")) - } } @Test("tokens_info parses valid tokens file") @@ -151,17 +133,7 @@ struct MCPToolHandlerTests { @Test("unknown tool returns error") func unknownTool() async { - let params = CallTool.Parameters( - name: "nonexistent_tool", - arguments: nil - ) - - let result = await MCPToolHandlers.handle(params: params, state: MCPServerState()) - - #expect(result.isError == true) - if case let .text(text) = result.content.first { - #expect(text.contains("Unknown tool")) - } + await expectError(tool: "nonexistent_tool", arguments: nil, containing: "Unknown tool") } // MARK: - Inspect Tool @@ -184,117 +156,80 @@ struct MCPToolHandlerTests { @Test("inspect returns error for missing resource_type") func inspectMissingResourceType() async { let configPath = Self.fixturesPath.appendingPathComponent("valid-config.pkl").path - - let params = CallTool.Parameters( - name: "exfig_inspect", - arguments: ["config_path": .string(configPath)] + await expectError( + tool: "exfig_inspect", + arguments: ["config_path": .string(configPath)], + containing: "resource_type" ) - - let result = await MCPToolHandlers.handle(params: params, state: MCPServerState()) - - #expect(result.isError == true) - if case let .text(text) = result.content.first { - #expect(text.contains("resource_type")) - } } // MARK: - Export Tool @Test("export returns error for missing resource_type") func exportMissingResourceType() async { - let params = CallTool.Parameters( - name: "exfig_export", - arguments: nil - ) - - let result = await MCPToolHandlers.handle(params: params, state: MCPServerState()) - - #expect(result.isError == true) - if case let .text(text) = result.content.first { - #expect(text.contains("resource_type")) - } + await expectError(tool: "exfig_export", arguments: nil, containing: "resource_type") } @Test("export returns error for invalid resource_type") func exportInvalidResourceType() async { - let params = CallTool.Parameters( - name: "exfig_export", - arguments: ["resource_type": .string("invalid")] + await expectError( + tool: "exfig_export", + arguments: ["resource_type": .string("invalid")], + containing: "Invalid resource_type" ) - - let result = await MCPToolHandlers.handle(params: params, state: MCPServerState()) - - #expect(result.isError == true) - if case let .text(text) = result.content.first { - #expect(text.contains("Invalid resource_type")) - } } @Test("export returns error for missing config") func exportMissingConfig() async { - let params = CallTool.Parameters( - name: "exfig_export", + await expectError( + tool: "exfig_export", arguments: [ "resource_type": .string("colors"), "config_path": .string("/nonexistent/path.pkl"), - ] + ], + containing: "not found" ) - - let result = await MCPToolHandlers.handle(params: params, state: MCPServerState()) - - #expect(result.isError == true) - if case let .text(text) = result.content.first { - #expect(text.contains("not found")) - } } // MARK: - Download Tool @Test("download returns error for missing resource_type") func downloadMissingResourceType() async { - let params = CallTool.Parameters( - name: "exfig_download", - arguments: nil - ) - - let result = await MCPToolHandlers.handle(params: params, state: MCPServerState()) - - #expect(result.isError == true) - if case let .text(text) = result.content.first { - #expect(text.contains("resource_type")) - } + await expectError(tool: "exfig_download", arguments: nil, containing: "resource_type") } @Test("download returns error for invalid resource_type") func downloadInvalidResourceType() async { - let params = CallTool.Parameters( - name: "exfig_download", - arguments: ["resource_type": .string("invalid")] + await expectError( + tool: "exfig_download", + arguments: ["resource_type": .string("invalid")], + containing: "Invalid resource_type" ) - - let result = await MCPToolHandlers.handle(params: params, state: MCPServerState()) - - #expect(result.isError == true) - if case let .text(text) = result.content.first { - #expect(text.contains("Invalid resource_type")) - } } @Test("download returns error for missing config") func downloadMissingConfig() async { - let params = CallTool.Parameters( - name: "exfig_download", + await expectError( + tool: "exfig_download", arguments: [ "resource_type": .string("colors"), "config_path": .string("/nonexistent/path.pkl"), - ] + ], + containing: "not found" ) + } - let result = await MCPToolHandlers.handle(params: params, state: MCPServerState()) - - #expect(result.isError == true) - if case let .text(text) = result.content.first { - #expect(text.contains("not found")) - } + @Test("download returns error for invalid format") + func downloadInvalidFormat() async { + let configPath = Self.fixturesPath.appendingPathComponent("valid-config.pkl").path + await expectError( + tool: "exfig_download", + arguments: [ + "resource_type": .string("colors"), + "config_path": .string(configPath), + "format": .string("csv"), + ], + containing: "Invalid format" + ) } } From 16c6c6482d0676834e7abfdbee08f96269bc3d4d Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Fri, 20 Mar 2026 22:52:55 +0500 Subject: [PATCH 5/5] ci: upgrade actions/cache v4 to v5 for Node.js 24 compatibility --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 647500ca..bb79ec19 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,7 +51,7 @@ jobs: lfs: true - name: Cache SPM dependencies - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: .build key: ${{ runner.os }}-xcode-26.1.1-spm-${{ hashFiles('Package.swift', 'Package.resolved') }} @@ -102,7 +102,7 @@ jobs: lfs: true - name: Cache SPM dependencies - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: .build key: ${{ runner.os }}-swift-6.2.3-spm-${{ hashFiles('Package.swift', 'Package.resolved') }}