diff --git a/README.md b/README.md index d1ee090..d26143e 100644 --- a/README.md +++ b/README.md @@ -388,6 +388,44 @@ CircledIconView(image: Image("flag"), width: 40, strokeColor: Color.red) WatchTo5K +## MCP Server (AI Tool Integration) + +StepperView ships with a Swift-based [Model Context Protocol](https://modelcontextprotocol.io) server that lets any MCP-compatible AI tool (Claude Desktop, Cursor, VS Code) generate production-ready StepperView code directly in your workflow — no example app required. + +### Build + +```bash +cd StepperViewMCP +swift build -c release +``` + +### Tools + +| Tool | Description | +|---|---| +| `generate_stepper_code` | Generate a complete SwiftUI `StepperView` struct from structured parameters | +| `list_colors` | List all valid named colors + hex color support | +| `list_sf_symbols` | List the curated SF Symbol allowlist | +| `get_example` | Return copy-paste Swift code for common patterns (`vertical`, `horizontal`, `pit_stops`, `hex_color`, `mixed_lifecycle`) | + +### Claude Desktop Integration + +Add to `~/Library/Application Support/Claude/claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "stepperview": { + "command": "/path/to/StepperView/StepperViewMCP/.build/release/StepperViewMCP" + } + } +} +``` + +Then ask Claude: *"Generate a 4-step onboarding stepper in teal with numbered circle indicators"* + +--- + ## Author Badarinath Venkatnarayansetty.Follow and contact me on Twitter or LinkedIn diff --git a/StepperViewMCP/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/StepperViewMCP/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/StepperViewMCP/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/StepperViewMCP/Package.swift b/StepperViewMCP/Package.swift new file mode 100644 index 0000000..33dd38e --- /dev/null +++ b/StepperViewMCP/Package.swift @@ -0,0 +1,28 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "StepperViewMCP", + platforms: [ + .macOS(.v14) + ], + dependencies: [ + .package(url: "https://github.com/modelcontextprotocol/swift-sdk.git", from: "0.9.0"), + .package(url: "https://github.com/jpsim/Yams.git", from: "5.0.0") + ], + targets: [ + .executableTarget( + name: "StepperViewMCP", + dependencies: [ + .product(name: "MCP", package: "swift-sdk"), + .product(name: "Yams", package: "Yams") + ], + resources: [ + .process("Resources") + ], + swiftSettings: [ + .swiftLanguageMode(.v6) + ] + ) + ] +) diff --git a/StepperViewMCP/Sources/StepperViewMCP/Config/YAMLLoader.swift b/StepperViewMCP/Sources/StepperViewMCP/Config/YAMLLoader.swift new file mode 100644 index 0000000..8545beb --- /dev/null +++ b/StepperViewMCP/Sources/StepperViewMCP/Config/YAMLLoader.swift @@ -0,0 +1,93 @@ +// +// YAMLLoader.swift +// StepperViewMCP +// +// Loads ai_config.yaml from the bundle at startup and exposes typed config +// values used by all four MCP tools. +// + +import Foundation +import Yams + +// MARK: - Public config types + +struct ConfigDefaults: Sendable { + let defaultStepCount: Int + let defaultMode: String + let defaultSpacing: Int + let indicatorSize: Double +} + +struct AppConfig: Sendable { + let validColors: [String] + let validSFSymbols: [String] + let validIndicatorTypes: [String] + let pitStopKeywords: [String] + let defaults: ConfigDefaults +} + +// MARK: - Raw YAML Codable models (mirrors StepperAIConfig in the main library) + +private struct RawConfig: Codable { + let defaults: RawDefaults + let validColors: [String] + let validIndicatorTypes: [String] + let validSFSymbols: [String] + let pitStopKeywords: [String] +} + +private struct RawDefaults: Codable { + let defaultStepCount: Int + let defaultMode: String + let defaultSpacing: Int + let indicatorSize: Double +} + +// MARK: - Loader + +enum YAMLLoader { + static func load() -> AppConfig { + if let url = Bundle.module.url(forResource: "ai_config", withExtension: "yaml"), + let yamlString = try? String(contentsOf: url, encoding: .utf8), + let raw = try? YAMLDecoder().decode(RawConfig.self, from: yamlString) { + return AppConfig( + validColors: raw.validColors, + validSFSymbols: raw.validSFSymbols, + validIndicatorTypes: raw.validIndicatorTypes, + pitStopKeywords: raw.pitStopKeywords, + defaults: ConfigDefaults( + defaultStepCount: raw.defaults.defaultStepCount, + defaultMode: raw.defaults.defaultMode, + defaultSpacing: raw.defaults.defaultSpacing, + indicatorSize: raw.defaults.indicatorSize + ) + ) + } + + // Fallback: hardcoded values that mirror ai_config.yaml + fputs("[StepperViewMCP] Warning: failed to load ai_config.yaml — using built-in defaults.\n", stderr) + return AppConfig( + validColors: ["teal", "red", "green", "blue", "orange", "lavender", "cyan", "black", "yellow", "polar"], + validSFSymbols: [ + "checkmark.circle.fill", "xmark.circle.fill", "star.fill", "heart.fill", + "bell.fill", "flag.fill", "bookmark.fill", "tag.fill", "bolt.fill", "cart.fill", + "house.fill", "gear", "person.fill", "envelope.fill", "phone.fill", + "mappin.circle.fill", "location.fill", "clock.fill", "calendar", "alarm.fill", + "shippingbox.fill", "cube.fill", "gift.fill", "creditcard.fill", "banknote.fill", + "doc.text.fill", "folder.fill", "tray.fill", "archivebox.fill", "paperplane.fill", + "leaf.fill", "drop.fill", "flame.fill", "snowflake", "sun.max.fill", + "moon.fill", "cloud.fill", "wrench.fill", "hammer.fill", "paintbrush.fill", + "scissors", "lock.fill", "key.fill", "wifi", "antenna.radiowaves.left.and.right", + "airplane", "car.fill", "bus.fill", "bicycle", "figure.walk" + ], + validIndicatorTypes: ["numberedCircle", "circle", "sfSymbol"], + pitStopKeywords: ["pit stop", "pit stops", "pitstop", "pitstops", "description", "details", "subtitles"], + defaults: ConfigDefaults( + defaultStepCount: 5, + defaultMode: "vertical", + defaultSpacing: 50, + indicatorSize: 40 + ) + ) + } +} diff --git a/StepperViewMCP/Sources/StepperViewMCP/Resources/ai_config.yaml b/StepperViewMCP/Sources/StepperViewMCP/Resources/ai_config.yaml new file mode 100644 index 0000000..f6a777a --- /dev/null +++ b/StepperViewMCP/Sources/StepperViewMCP/Resources/ai_config.yaml @@ -0,0 +1,124 @@ +# AI Stepper Configuration +# Edit this file to customize the AI generation behavior and UI. + +# UI Strings +ui: + navigationTitle: "StepperView AI" + headerText: "Describe your stepper" + placeholder: "e.g., Create a delivery tracking stepper with 5 stops" + generateButton: "Generate StepperView" + generatingButton: "Generating..." + previewTab: "Preview" + codeTab: "Code" + copyButton: "Copy" + errorPrefix: "Generation failed: " + +# Default values for stepper generation +defaults: + defaultStepCount: 5 + defaultMode: "vertical" + defaultSpacing: 50 + indicatorWidthMin: 30 + indicatorWidthMax: 40 + indicatorSize: 40 + pitStopHeightPerStep: 140 + defaultHeightPerStep: 80 + horizontalHeight: 160 + +# Named colors the AI model can use (hex colors like "#FF6B35" are also supported) +validColors: + - teal + - red + - green + - blue + - orange + - lavender + - cyan + - black + - yellow + - polar + +# Valid indicator types +validIndicatorTypes: + - numberedCircle + - circle + - sfSymbol + +# Curated list of valid SF Symbols +# Only these symbols are allowed — the model cannot invent names outside this list. +validSFSymbols: + - checkmark.circle.fill + - xmark.circle.fill + - star.fill + - heart.fill + - bell.fill + - flag.fill + - bookmark.fill + - tag.fill + - bolt.fill + - cart.fill + - house.fill + - gear + - person.fill + - envelope.fill + - phone.fill + - mappin.circle.fill + - location.fill + - clock.fill + - calendar + - alarm.fill + - shippingbox.fill + - cube.fill + - gift.fill + - creditcard.fill + - banknote.fill + - doc.text.fill + - folder.fill + - tray.fill + - archivebox.fill + - paperplane.fill + - leaf.fill + - drop.fill + - flame.fill + - snowflake + - sun.max.fill + - moon.fill + - cloud.fill + - wrench.fill + - hammer.fill + - paintbrush.fill + - scissors + - lock.fill + - key.fill + - wifi + - antenna.radiowaves.left.and.right + - airplane + - car.fill + - bus.fill + - bicycle + - figure.walk + +# Keywords in user prompt that trigger pit stop generation +pitStopKeywords: + - "pit stop" + - "pit stops" + - "pitstop" + - "pitstops" + - "description" + - "details" + - "subtitles" + +# System prompt template +# Uses {placeholders} that get replaced with values from this config. +systemPrompt: | + You are a StepperView configuration generator. Given a user's description, produce a structured stepper configuration. Follow these rules: + - Generate exactly {defaultStepCount} steps unless the user specifies a different number. + - Default to {defaultMode} mode with spacing {defaultSpacing}. + - For colors, you can use named colors ({validColors}) OR any hex color string (e.g. "#FF6B35", "#8B5CF6", "#EC4899"). Prefer hex colors when the user describes a specific color theme. Use the SAME color for ALL steps. + - Use indicatorType values: {validIndicatorTypes}. IMPORTANT: Use the SAME indicatorType for ALL steps. Do not mix different indicator types. + - When using sfSymbol, you MUST only use SF Symbol names from this list: {validSFSymbols}. Do NOT invent or guess symbol names outside this list. + - Set indicatorWidth between {indicatorWidthMin} and {indicatorWidthMax}. + - Mark earlier steps as completed and later steps as pending to show progress. + - IMPORTANT: Only include pitStopText when the user explicitly mentions "pit stop", "pit stops", "description", "details", or "subtitles" in their prompt. By default, do NOT include pitStopText. When pitStopText is omitted, set it to null for all steps. When pitStopText IS included, you MUST provide it for ALL steps with non-empty strings. + - Keep step titles concise (2-4 words). + - Set autoSpacing to true when pitStopText is provided, false otherwise. diff --git a/StepperViewMCP/Sources/StepperViewMCP/StepperMCPServer.swift b/StepperViewMCP/Sources/StepperViewMCP/StepperMCPServer.swift new file mode 100644 index 0000000..5c75f3a --- /dev/null +++ b/StepperViewMCP/Sources/StepperViewMCP/StepperMCPServer.swift @@ -0,0 +1,81 @@ +// +// StepperMCPServer.swift +// StepperViewMCP +// +// Registers the four MCP tools and starts the stdio transport. +// + +import MCP +import Foundation + +// MARK: - Server + +struct StepperMCPServer: Sendable { + + private let config: AppConfig + + init(config: AppConfig) { + self.config = config + } + + func run() async throws { + let server = Server( + name: "StepperView MCP", + version: "1.0.0", + capabilities: .init(tools: .init(listChanged: false)) + ) + + let allTools: [Tool] = [ + GenerateCodeTool.definition, + ListColorsTool.definition, + ListSymbolsTool.definition, + GetExampleTool.definition + ] + + let config = self.config // capture value type for Sendable closures + + await server.withMethodHandler(ListTools.self) { _ in + return .init(tools: allTools) + } + + await server.withMethodHandler(CallTool.self) { params in + do { + return try Self.dispatch(params: params, config: config) + } catch { + return .init( + content: [.text("Error: \(error.localizedDescription)")], + isError: true + ) + } + } + + let transport = StdioTransport() + // start() launches a background message-handling Task and returns immediately. + // waitUntilCompleted() blocks until stdin closes (i.e. the MCP client disconnects). + try await server.start(transport: transport) + await server.waitUntilCompleted() + } + + // MARK: - Dispatch + + private static func dispatch( + params: CallTool.Parameters, + config: AppConfig + ) throws -> CallTool.Result { + switch params.name { + case GenerateCodeTool.toolName: + return GenerateCodeTool.handle(params: params, config: config) + case ListColorsTool.toolName: + return ListColorsTool.handle(config: config) + case ListSymbolsTool.toolName: + return ListSymbolsTool.handle(config: config) + case GetExampleTool.toolName: + return GetExampleTool.handle(params: params) + default: + return .init( + content: [.text("Unknown tool: '\(params.name)'. Available: generate_stepper_code, list_colors, list_sf_symbols, get_example")], + isError: true + ) + } + } +} diff --git a/StepperViewMCP/Sources/StepperViewMCP/Tools/GenerateCodeTool.swift b/StepperViewMCP/Sources/StepperViewMCP/Tools/GenerateCodeTool.swift new file mode 100644 index 0000000..94a533c --- /dev/null +++ b/StepperViewMCP/Sources/StepperViewMCP/Tools/GenerateCodeTool.swift @@ -0,0 +1,350 @@ +// +// GenerateCodeTool.swift +// StepperViewMCP +// +// Core MCP tool: accepts structured parameters and returns production-ready +// SwiftUI StepperView code using the same fluent API format as the +// StepperAIBuilder.generateCode(from:) method in the main library. +// +// All code-generation logic is plain Swift — no SwiftUI imports required. +// + +import MCP +import Foundation + +// MARK: - Plain config models (no SwiftUI / @Generable) + +struct StepperConfig: Sendable { + var title: String + var mode: String // "vertical" | "horizontal" + var spacing: Double + var steps: [StepConfig] +} + +struct StepConfig: Sendable { + var stepNumber: Int + var title: String + var indicatorType: String // "numberedCircle" | "circle" | "sfSymbol" + var sfSymbolName: String? + var color: String // named color or hex + var lifeCycle: String // "completed" | "pending" + var pitStopText: String? +} + +// MARK: - Tool definition + +enum GenerateCodeTool { + + static let toolName = "generate_stepper_code" + + static var definition: Tool { + Tool( + name: toolName, + description: "Generate production-ready SwiftUI StepperView code from structured parameters. Returns an import-ready Swift file using StepperView's fluent API.", + inputSchema: .object([ + "type": .string("object"), + "properties": .object([ + "stepCount": .object([ + "type": .string("integer"), + "description": .string("Number of steps to generate (default: 5)"), + "default": .int(5) + ]), + "mode": .object([ + "type": .string("string"), + "description": .string("Layout orientation"), + "enum": .array([.string("vertical"), .string("horizontal")]), + "default": .string("vertical") + ]), + "color": .object([ + "type": .string("string"), + "description": .string("Named color (teal, red, green, blue, orange, lavender, cyan, black, yellow, polar) or hex string like #FF6B35"), + "default": .string("teal") + ]), + "indicatorType": .object([ + "type": .string("string"), + "description": .string("Indicator style for all steps"), + "enum": .array([.string("numberedCircle"), .string("circle"), .string("sfSymbol")]), + "default": .string("numberedCircle") + ]), + "sfSymbolName": .object([ + "type": .string("string"), + "description": .string("SF Symbol name when indicatorType is 'sfSymbol' (e.g. 'checkmark.circle.fill')") + ]), + "includePitStops": .object([ + "type": .string("boolean"), + "description": .string("Include pit stop content below each step indicator"), + "default": .bool(false) + ]), + "title": .object([ + "type": .string("string"), + "description": .string("Short descriptive title used as the SwiftUI struct name (e.g. 'Onboarding', 'DeliveryTracking')"), + "default": .string("Generated") + ]), + "stepTitles": .object([ + "type": .string("array"), + "description": .string("Optional array of step label strings. If omitted, generic labels are generated."), + "items": .object(["type": .string("string")]) + ]), + "lifeCycles": .object([ + "type": .string("array"), + "description": .string("Optional array of 'completed' or 'pending' per step. Defaults to first half completed."), + "items": .object(["type": .string("string")]) + ]) + ]), + "required": .array([]) + ]) + ) + } + + // MARK: - Handler + + static func handle(params: CallTool.Parameters, config: AppConfig) -> CallTool.Result { + let args = ToolArguments(params.arguments) + + let stepCount = max(1, args.int("stepCount", default: config.defaults.defaultStepCount)) + let mode = args.string("mode", default: config.defaults.defaultMode) + let color = normalizeColor(args.string("color", default: "teal"), validColors: config.validColors) + let indicatorType = normalizeIndicatorType(args.string("indicatorType", default: "numberedCircle")) + let sfSymbolName = normalizeSymbol(args.string("sfSymbolName", default: ""), validSymbols: config.validSFSymbols) + let includePitStops = args.bool("includePitStops", default: false) + let title = args.string("title", default: "Generated") + let spacing = Double(config.defaults.defaultSpacing) + + let customTitles = args.strings("stepTitles") + let customLifeCycles = args.strings("lifeCycles") + + // Build step configs + var steps: [StepConfig] = [] + for i in 1...stepCount { + let stepTitle: String + if i <= customTitles.count { + stepTitle = customTitles[i - 1] + } else { + stepTitle = "Step \(i)" + } + + let lifeCycle: String + if i <= customLifeCycles.count { + lifeCycle = customLifeCycles[i - 1].lowercased() == "completed" ? "completed" : "pending" + } else { + // Default: first half completed, rest pending + lifeCycle = i <= stepCount / 2 ? "completed" : "pending" + } + + let pitStopText: String? = includePitStops ? "Description for step \(i)" : nil + + steps.append(StepConfig( + stepNumber: i, + title: stepTitle, + indicatorType: indicatorType, + sfSymbolName: sfSymbolName.isEmpty ? nil : sfSymbolName, + color: color, + lifeCycle: lifeCycle, + pitStopText: pitStopText + )) + } + + let stepperConfig = StepperConfig( + title: title, + mode: mode, + spacing: spacing, + steps: steps + ) + + let generatedCode = generateCode(from: stepperConfig, validSymbols: Set(config.validSFSymbols)) + return .init(content: [.text(generatedCode)], isError: false) + } + + // MARK: - Code Generation + // Ported from StepperAIBuilder.generateCode(from:) — plain Swift, no SwiftUI. + + static func generateCode(from config: StepperConfig, validSymbols: Set) -> String { + let steps = config.steps.sorted { $0.stepNumber < $1.stepNumber } + let hasPitStops = steps.contains { $0.pitStopText != nil && !($0.pitStopText!.isEmpty) } + let structName = config.title + .split(separator: " ") + .map { $0.prefix(1).uppercased() + $0.dropFirst() } + .joined() + + var code = "import SwiftUI\nimport StepperView\n\n" + code += "struct \(structName)View: View {\n\n" + + // Steps array + code += " let steps = [\n" + for (i, step) in steps.enumerated() { + let comma = i < steps.count - 1 ? "," : "" + code += " TextView(text: \"\(step.title)\", font: .system(size: 14, weight: .semibold))\(comma)\n" + } + code += " ]\n\n" + + // Indicators array + code += " let indicators: [StepperIndicationType] = [\n" + for (i, step) in steps.enumerated() { + let comma = i < steps.count - 1 ? "," : "" + let colorCode = colorCodeString(from: step.color) + switch step.indicatorType.lowercased() { + case "numberedcircle": + code += " .custom(\n" + code += " NumberedCircleView(text: \"\(step.stepNumber)\", width: 40, color: \(colorCode))\n" + code += " .eraseToAnyView())\(comma)\n" + case "sfsymbol": + let requested = step.sfSymbolName ?? "circle.fill" + let symbolName = validSymbols.contains(requested) ? requested : "checkmark.circle.fill" + code += " .custom(\n" + code += " CircledIconView(image: Image(systemName: \"\(symbolName)\"),\n" + code += " width: 40,\n" + code += " strokeColor: \(colorCode))\n" + code += " .eraseToAnyView())\(comma)\n" + default: // "circle" + code += " .custom(\n" + code += " CircledIconView(image: Image(systemName: \"circle.fill\"),\n" + code += " width: 40,\n" + code += " color: \(colorCode),\n" + code += " strokeColor: \(colorCode))\n" + code += " .eraseToAnyView())\(comma)\n" + } + } + code += " ]\n\n" + + // Pit stops + if hasPitStops { + code += " let pitStops: [AnyView] = [\n" + for (i, step) in steps.enumerated() { + let comma = i < steps.count - 1 ? "," : "" + let text = step.pitStopText ?? " " + code += " TextView(text: \"\(text)\").eraseToAnyView()\(comma)\n" + } + code += " ]\n\n" + + code += " let pitStopLines: [StepperLineOptions] = [\n" + for (i, step) in steps.enumerated() { + let comma = i < steps.count - 1 ? "," : "" + let colorCode = colorCodeString(from: step.color) + code += " .custom(1, \(colorCode))\(comma)\n" + } + code += " ]\n\n" + } + + // Body + code += " var body: some View {\n" + code += " StepperView()\n" + code += " .addSteps(steps)\n" + code += " .indicators(indicators)\n" + + // Life cycles + let lcValues = steps.map { $0.lifeCycle.lowercased() == "completed" ? ".completed" : ".pending" } + code += " .stepLifeCycles([\(lcValues.joined(separator: ", "))])\n" + + if hasPitStops { + code += " .addPitStops(pitStops)\n" + code += " .pitStopLineOptions(pitStopLines)\n" + code += " .stepIndicatorMode(.\(config.mode.lowercased()))\n" + code += " .autoSpacing(true)\n" + } else { + let lineColorCode = steps.first.map { colorCodeString(from: $0.color) } ?? "Colors.teal.rawValue" + let isHorizontal = config.mode.lowercased() == "horizontal" + if isHorizontal { + code += " .lineOptions(.rounded(2, 4, \(lineColorCode)))\n" + } else { + code += " .lineOptions(.custom(2, \(lineColorCode)))\n" + } + code += " .stepIndicatorMode(.\(config.mode.lowercased()))\n" + code += " .spacing(\(isHorizontal ? 70 : Int(config.spacing)))\n" + } + + code += " }\n" + code += "}\n" + + return code + } + + // MARK: - Helpers + + private static func colorCodeString(from name: String) -> String { + switch name.lowercased() { + case "teal": return "Colors.teal.rawValue" + case "red": return "Colors.red(.normal).rawValue" + case "green": return "Colors.green(.normal).rawValue" + case "blue": return "Colors.blue(.teal).rawValue" + case "orange": return "Colors.orange.rawValue" + case "lavender": return "Colors.lavendar.rawValue" + case "cyan": return "Colors.cyan.rawValue" + case "black": return "Colors.black.rawValue" + case "yellow": return "Colors.yellow(.regular).rawValue" + case "polar": return "Colors.polar.rawValue" + default: + if isHexColor(name) { + let hex = name.hasPrefix("#") ? name : "#\(name)" + return "Color(hex: \"\(hex)\")!" + } + return "Colors.teal.rawValue" + } + } + + private static func isHexColor(_ name: String) -> Bool { + let trimmed = name.hasPrefix("#") ? String(name.dropFirst()) : name + return trimmed.count == 6 && trimmed.allSatisfy(\.isHexDigit) + } + + private static func normalizeColor(_ color: String, validColors: [String]) -> String { + let lower = color.lowercased() + if validColors.map({ $0.lowercased() }).contains(lower) { return lower } + if isHexColor(color) { return color } + return "teal" + } + + private static func normalizeIndicatorType(_ type: String) -> String { + switch type.lowercased() { + case "numberedcircle": return "numberedCircle" + case "sfsymbol": return "sfSymbol" + case "circle": return "circle" + default: return "numberedCircle" + } + } + + private static func normalizeSymbol(_ symbol: String, validSymbols: [String]) -> String { + guard !symbol.isEmpty else { return "" } + return validSymbols.contains(symbol) ? symbol : "checkmark.circle.fill" + } +} + +// MARK: - ToolArguments helper + +/// Type-safe argument extractor for MCP tool parameters. +/// Handles the `[String: Value]?` arguments dictionary from CallTool.Parameters. +struct ToolArguments: Sendable { + private let values: [String: Value] + + init(_ arguments: [String: Value]?) { + self.values = arguments ?? [:] + } + + func string(_ key: String, default defaultValue: String = "") -> String { + guard let v = values[key] else { return defaultValue } + if case .string(let s) = v { return s } + return defaultValue + } + + func int(_ key: String, default defaultValue: Int = 0) -> Int { + guard let v = values[key] else { return defaultValue } + switch v { + case .int(let n): return n + case .double(let d): return Int(d) + default: return defaultValue + } + } + + func bool(_ key: String, default defaultValue: Bool = false) -> Bool { + guard let v = values[key] else { return defaultValue } + if case .bool(let b) = v { return b } + return defaultValue + } + + func strings(_ key: String) -> [String] { + guard let v = values[key], case .array(let arr) = v else { return [] } + return arr.compactMap { + if case .string(let s) = $0 { return s } + return nil + } + } +} diff --git a/StepperViewMCP/Sources/StepperViewMCP/Tools/GetExampleTool.swift b/StepperViewMCP/Sources/StepperViewMCP/Tools/GetExampleTool.swift new file mode 100644 index 0000000..14b2ee7 --- /dev/null +++ b/StepperViewMCP/Sources/StepperViewMCP/Tools/GetExampleTool.swift @@ -0,0 +1,252 @@ +// +// GetExampleTool.swift +// StepperViewMCP +// +// MCP tool: returns copy-paste Swift code snippets for common StepperView patterns. +// + +import MCP +import Foundation + +enum GetExampleTool { + + static let toolName = "get_example" + + // Available example keys shown to the AI client + static let availablePatterns: [String: String] = [ + "vertical": "Basic vertical numbered stepper", + "horizontal": "Horizontal stepper with SF symbols", + "pit_stops": "Vertical stepper with pit stop descriptions", + "hex_color": "Stepper using a custom hex color", + "mixed_lifecycle": "Stepper showing completed and pending steps" + ] + + static var definition: Tool { + let patternList = availablePatterns + .map { "'\($0.key)' — \($0.value)" } + .sorted() + .joined(separator: ", ") + + return Tool( + name: toolName, + description: "Return a ready-to-use Swift code snippet for a common StepperView pattern. Patterns: \(patternList).", + inputSchema: .object([ + "type": .string("object"), + "properties": .object([ + "pattern": .object([ + "type": .string("string"), + "description": .string("The pattern name to retrieve. Options: \(availablePatterns.keys.sorted().joined(separator: ", "))"), + "enum": .array(availablePatterns.keys.sorted().map { .string($0) }) + ]) + ]), + "required": .array([.string("pattern")]) + ]) + ) + } + + // MARK: - Handler + + static func handle(params: CallTool.Parameters) -> CallTool.Result { + let args = ToolArguments(params.arguments) + let pattern = args.string("pattern", default: "vertical") + + guard let snippet = examples[pattern] else { + let available = availablePatterns.keys.sorted().joined(separator: ", ") + return .init( + content: [.text("Unknown pattern '\(pattern)'. Available: \(available)")], + isError: true + ) + } + + return .init(content: [.text(snippet)], isError: false) + } + + // MARK: - Snippet Library + + private static let examples: [String: String] = [ + + "vertical": """ + import SwiftUI + import StepperView + + struct OnboardingView: View { + + let steps = [ + TextView(text: "Create Account", font: .system(size: 14, weight: .semibold)), + TextView(text: "Verify Email", font: .system(size: 14, weight: .semibold)), + TextView(text: "Set Preferences", font: .system(size: 14, weight: .semibold)), + TextView(text: "All Done!", font: .system(size: 14, weight: .semibold)) + ] + + let indicators: [StepperIndicationType] = [ + .custom(NumberedCircleView(text: "1", width: 40, color: Colors.teal.rawValue).eraseToAnyView()), + .custom(NumberedCircleView(text: "2", width: 40, color: Colors.teal.rawValue).eraseToAnyView()), + .custom(NumberedCircleView(text: "3", width: 40, color: Colors.teal.rawValue).eraseToAnyView()), + .custom(NumberedCircleView(text: "4", width: 40, color: Colors.teal.rawValue).eraseToAnyView()) + ] + + var body: some View { + StepperView() + .addSteps(steps) + .indicators(indicators) + .stepLifeCycles([.completed, .completed, .pending, .pending]) + .lineOptions(.custom(2, Colors.teal.rawValue)) + .stepIndicatorMode(.vertical) + .spacing(50) + } + } + """, + + "horizontal": """ + import SwiftUI + import StepperView + + struct CheckoutView: View { + + let steps = [ + TextView(text: "Cart", font: .system(size: 12, weight: .semibold)), + TextView(text: "Address", font: .system(size: 12, weight: .semibold)), + TextView(text: "Payment", font: .system(size: 12, weight: .semibold)), + TextView(text: "Confirm", font: .system(size: 12, weight: .semibold)) + ] + + let indicators: [StepperIndicationType] = [ + .custom(CircledIconView(image: Image(systemName: "cart.fill"), width: 40, strokeColor: Colors.blue(.teal).rawValue).eraseToAnyView()), + .custom(CircledIconView(image: Image(systemName: "house.fill"), width: 40, strokeColor: Colors.blue(.teal).rawValue).eraseToAnyView()), + .custom(CircledIconView(image: Image(systemName: "creditcard.fill"), width: 40, strokeColor: Colors.blue(.teal).rawValue).eraseToAnyView()), + .custom(CircledIconView(image: Image(systemName: "checkmark.circle.fill"), width: 40, strokeColor: Colors.blue(.teal).rawValue).eraseToAnyView()) + ] + + var body: some View { + StepperView() + .addSteps(steps) + .indicators(indicators) + .stepLifeCycles([.completed, .completed, .pending, .pending]) + .lineOptions(.rounded(2, 4, Colors.blue(.teal).rawValue)) + .stepIndicatorMode(.horizontal) + .spacing(70) + } + } + """, + + "pit_stops": """ + import SwiftUI + import StepperView + + struct DeliveryTrackingView: View { + + let steps = [ + TextView(text: "Order Placed", font: .system(size: 14, weight: .semibold)), + TextView(text: "Processing", font: .system(size: 14, weight: .semibold)), + TextView(text: "Shipped", font: .system(size: 14, weight: .semibold)), + TextView(text: "Out for Delivery", font: .system(size: 14, weight: .semibold)), + TextView(text: "Delivered", font: .system(size: 14, weight: .semibold)) + ] + + let indicators: [StepperIndicationType] = [ + .custom(NumberedCircleView(text: "1", width: 40, color: Colors.green(.normal).rawValue).eraseToAnyView()), + .custom(NumberedCircleView(text: "2", width: 40, color: Colors.green(.normal).rawValue).eraseToAnyView()), + .custom(NumberedCircleView(text: "3", width: 40, color: Colors.green(.normal).rawValue).eraseToAnyView()), + .custom(NumberedCircleView(text: "4", width: 40, color: Colors.green(.normal).rawValue).eraseToAnyView()), + .custom(NumberedCircleView(text: "5", width: 40, color: Colors.green(.normal).rawValue).eraseToAnyView()) + ] + + let pitStops: [AnyView] = [ + TextView(text: "Your order has been confirmed.").eraseToAnyView(), + TextView(text: "We are preparing your items.").eraseToAnyView(), + TextView(text: "Package dispatched via FedEx.").eraseToAnyView(), + TextView(text: "Expected delivery by 6 PM.").eraseToAnyView(), + TextView(text: "Package left at front door.").eraseToAnyView() + ] + + let pitStopLines: [StepperLineOptions] = [ + .custom(1, Colors.green(.normal).rawValue), + .custom(1, Colors.green(.normal).rawValue), + .custom(1, Colors.green(.normal).rawValue), + .custom(1, Colors.green(.normal).rawValue), + .custom(1, Colors.green(.normal).rawValue) + ] + + var body: some View { + StepperView() + .addSteps(steps) + .indicators(indicators) + .stepLifeCycles([.completed, .completed, .completed, .pending, .pending]) + .addPitStops(pitStops) + .pitStopLineOptions(pitStopLines) + .stepIndicatorMode(.vertical) + .autoSpacing(true) + } + } + """, + + "hex_color": """ + import SwiftUI + import StepperView + + struct BrandedStepperView: View { + + // Using a custom brand hex color — any 6-digit hex is supported. + let brandColor = Color(hex: "#8B5CF6")! + + let steps = [ + TextView(text: "Sign Up", font: .system(size: 14, weight: .semibold)), + TextView(text: "Choose Plan", font: .system(size: 14, weight: .semibold)), + TextView(text: "Add Payment", font: .system(size: 14, weight: .semibold)), + TextView(text: "Start Using", font: .system(size: 14, weight: .semibold)) + ] + + var indicators: [StepperIndicationType] { + (1...4).map { n in + .custom(NumberedCircleView(text: "\\(n)", width: 40, color: brandColor).eraseToAnyView()) + } + } + + var body: some View { + StepperView() + .addSteps(steps) + .indicators(indicators) + .stepLifeCycles([.completed, .pending, .pending, .pending]) + .lineOptions(.custom(2, Color(hex: "#8B5CF6")!)) + .stepIndicatorMode(.vertical) + .spacing(50) + } + } + """, + + "mixed_lifecycle": """ + import SwiftUI + import StepperView + + struct ProgressTrackerView: View { + + let steps = [ + TextView(text: "Requirements", font: .system(size: 14, weight: .semibold)), + TextView(text: "Design", font: .system(size: 14, weight: .semibold)), + TextView(text: "Development", font: .system(size: 14, weight: .semibold)), + TextView(text: "Testing", font: .system(size: 14, weight: .semibold)), + TextView(text: "Deployment", font: .system(size: 14, weight: .semibold)) + ] + + // Mix colors to reflect lifecycle state visually + let indicators: [StepperIndicationType] = [ + .custom(CircledIconView(image: Image(systemName: "checkmark.circle.fill"), width: 40, strokeColor: Colors.green(.normal).rawValue).eraseToAnyView()), + .custom(CircledIconView(image: Image(systemName: "checkmark.circle.fill"), width: 40, strokeColor: Colors.green(.normal).rawValue).eraseToAnyView()), + .custom(CircledIconView(image: Image(systemName: "gear"), width: 40, strokeColor: Colors.orange.rawValue).eraseToAnyView()), + .custom(CircledIconView(image: Image(systemName: "clock.fill"), width: 40, strokeColor: Colors.teal.rawValue).eraseToAnyView()), + .custom(CircledIconView(image: Image(systemName: "flag.fill"), width: 40, strokeColor: Colors.teal.rawValue).eraseToAnyView()) + ] + + var body: some View { + StepperView() + .addSteps(steps) + .indicators(indicators) + .stepLifeCycles([.completed, .completed, .pending, .pending, .pending]) + .lineOptions(.custom(2, Colors.teal.rawValue)) + .stepIndicatorMode(.vertical) + .spacing(55) + } + } + """ + ] +} diff --git a/StepperViewMCP/Sources/StepperViewMCP/Tools/ListColorsTool.swift b/StepperViewMCP/Sources/StepperViewMCP/Tools/ListColorsTool.swift new file mode 100644 index 0000000..bbd0eb0 --- /dev/null +++ b/StepperViewMCP/Sources/StepperViewMCP/Tools/ListColorsTool.swift @@ -0,0 +1,42 @@ +// +// ListColorsTool.swift +// StepperViewMCP +// +// MCP tool: returns all valid named colors and a note about hex support. +// + +import MCP +import Foundation + +enum ListColorsTool { + + static let toolName = "list_colors" + + static var definition: Tool { + Tool( + name: toolName, + description: "List all valid named colors supported by StepperView. Hex colors (e.g. #FF6B35) are also accepted anywhere a color is required.", + inputSchema: .object([ + "type": .string("object"), + "properties": .object([:]) + ]) + ) + } + + static func handle(config: AppConfig) -> CallTool.Result { + var lines: [String] = [] + lines.append("# StepperView Valid Colors\n") + lines.append("## Named Colors") + for color in config.validColors { + lines.append(" - \(color)") + } + lines.append("") + lines.append("## Hex Colors") + lines.append(" Any 6-digit hex color is accepted (with or without #).") + lines.append(" Examples: #FF6B35, #8B5CF6, EC4899") + lines.append("") + lines.append("Pass any of the above to the `color` parameter of `generate_stepper_code`.") + + return .init(content: [.text(lines.joined(separator: "\n"))], isError: false) + } +} diff --git a/StepperViewMCP/Sources/StepperViewMCP/Tools/ListSymbolsTool.swift b/StepperViewMCP/Sources/StepperViewMCP/Tools/ListSymbolsTool.swift new file mode 100644 index 0000000..f31f8b7 --- /dev/null +++ b/StepperViewMCP/Sources/StepperViewMCP/Tools/ListSymbolsTool.swift @@ -0,0 +1,45 @@ +// +// ListSymbolsTool.swift +// StepperViewMCP +// +// MCP tool: returns the curated SF Symbol allowlist from ai_config.yaml. +// Only symbols from this list are valid for the 'sfSymbol' indicator type. +// + +import MCP +import Foundation + +enum ListSymbolsTool { + + static let toolName = "list_sf_symbols" + + static var definition: Tool { + Tool( + name: toolName, + description: "List all SF Symbol names that are valid for StepperView's 'sfSymbol' indicator type. Using a symbol outside this list will fall back to 'checkmark.circle.fill'.", + inputSchema: .object([ + "type": .string("object"), + "properties": .object([:]) + ]) + ) + } + + static func handle(config: AppConfig) -> CallTool.Result { + var lines: [String] = [] + lines.append("# Valid SF Symbols for StepperView\n") + lines.append("Use these names with `indicatorType: \"sfSymbol\"` in `generate_stepper_code`.\n") + + // Group into rows of 4 for readability + let symbols = config.validSFSymbols + stride(from: 0, to: symbols.count, by: 4).forEach { start in + let slice = symbols[start..