diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 5466049..1db4f55 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,7 +1,8 @@ { "permissions": { "allow": [ - "Bash(swift package:*)" + "Bash(swift package:*)", + "Bash(swift build)" ], "deny": [ "Bash(sudo:*)", diff --git a/Package.swift b/Package.swift index 23a4fcb..2cb55a8 100644 --- a/Package.swift +++ b/Package.swift @@ -22,5 +22,9 @@ let package = Package( "SwiftAutoGUI" ] ), + .testTarget( + name: "swift-mcp-guiTests", + dependencies: ["swift-mcp-gui"] + ), ] ) diff --git a/Sources/main.swift b/Sources/main.swift deleted file mode 100644 index 9c73378..0000000 --- a/Sources/main.swift +++ /dev/null @@ -1,253 +0,0 @@ -import Foundation -import MCP -import SwiftAutoGUI -import AppKit -import CoreGraphics - -// Initialize the server -let server = Server( - name: "mac-control-server", - version: "1.0.0", - capabilities: .init( - prompts: .init(listChanged: true), - tools: .init(listChanged: true) - ) -) - -// Create transport and start server -print("Starting MCP GUI Server...") -let transport = StdioTransport() -try await server.start(transport: transport) - -// Register tool handlers -await server.withMethodHandler(ListTools.self) { _ in - return ListTools.Result( - tools: [ - .init( - name: "moveMouse", - description: "Move mouse cursor to specific coordinates", - inputSchema: [ - "type": "object", - "properties": [ - "x": ["type": "number", "description": "X coordinate"], - "y": ["type": "number", "description": "Y coordinate"] - ], - "required": ["x", "y"] - ] - ), - .init( - name: "mouseClick", - description: "Perform mouse click", - inputSchema: [ - "type": "object", - "properties": [ - "button": ["type": "string", "description": "Button to click (left or right)"] - ], - "required": ["button"] - ] - ), - .init( - name: "scroll", - description: "Scroll in specified direction", - inputSchema: [ - "type": "object", - "properties": [ - "direction": ["type": "string", "description": "Scroll direction (up, down, left, right)"], - "clicks": ["type": "number", "description": "Number of scroll clicks"] - ], - "required": ["direction", "clicks"] - ] - ), - .init( - name: "sendKeys", - description: "Sends keyboard shortcuts or key combinations", - inputSchema: [ - "type": "object", - "properties": [ - "keys": ["type": "array", - "items": ["type": "string"], - "description": "Sends keyboard shortcuts or key combinations"] - ], - "required": ["keys"] - ] - ), - .init( - name: "getScreenSize", - description: "Get screen dimensions", - inputSchema: [ - "type": "object", - "properties": [:] - ] - ), - .init( - name: "getPixelColor", - description: "Get color of specific pixel", - inputSchema: [ - "type": "object", - "properties": [ - "x": ["type": "number", "description": "X coordinate"], - "y": ["type": "number", "description": "Y coordinate"] - ], - "required": ["x", "y"] - ] - ) - ] - ) -} - -await server.withMethodHandler(CallTool.self) { params in - guard let arguments = params.arguments else { - return .init(content: [.text("No arguments provided")], isError: true) - } - - switch params.name { - case "moveMouse": - // Try to get x and y as any numeric type - let xValue = arguments["x"] - let yValue = arguments["y"] - - guard let xValue = xValue, let yValue = yValue else { - return .init(content: [.text("Missing parameters: x and y are required")], isError: true) - } - - // Try multiple ways to extract the numeric value - var x: Double? - var y: Double? - - // Try direct double conversion first - if let doubleX = xValue.doubleValue { - x = doubleX - } - // Try integer conversion - else if let intX = xValue.intValue { - x = Double(intX) - } - // Try getting as string and parsing - else if let xStr = xValue.stringValue { - x = Double(xStr) - } - - // Same for y - if let doubleY = yValue.doubleValue { - y = doubleY - } - else if let intY = yValue.intValue { - y = Double(intY) - } - else if let yStr = yValue.stringValue { - y = Double(yStr) - } - - guard let finalX = x, let finalY = y else { - return .init(content: [.text("Invalid parameters: x and y must be numbers. Received x=\(xValue), y=\(yValue)")], isError: true) - } - - SwiftAutoGUI.move(to: CGPoint(x: finalX, y: finalY)) - return .init(content: [.text("Mouse moved to (\(finalX), \(finalY))")], isError: false) - - case "mouseClick": - guard let button = arguments["button"]?.stringValue else { - return .init(content: [.text("Invalid parameter: button must be a string")], isError: true) - } - switch button.lowercased() { - case "left": - SwiftAutoGUI.leftClick() - case "right": - SwiftAutoGUI.rightClick() - default: - return .init(content: [.text("Invalid button type. Must be 'left' or 'right'")], isError: true) - } - return .init(content: [.text("\(button) click performed")], isError: false) - - case "scroll": - guard let direction = arguments["direction"]?.stringValue, - let clicks = arguments["clicks"]?.intValue else { - return .init(content: [.text("Invalid parameters: direction must be a string and clicks must be a number")], isError: true) - } - switch direction.lowercased() { - case "up": - SwiftAutoGUI.vscroll(clicks: Int(clicks)) - case "down": - SwiftAutoGUI.vscroll(clicks: -Int(clicks)) - case "left": - SwiftAutoGUI.hscroll(clicks: -Int(clicks)) - case "right": - SwiftAutoGUI.hscroll(clicks: Int(clicks)) - default: - return .init(content: [.text("Invalid scroll direction. Must be 'up', 'down', 'left', or 'right'")], isError: true) - } - return .init(content: [.text("Scrolled \(direction) by \(Int(clicks)) clicks")], isError: false) - - case "sendKeys": - guard let keys = arguments["keys"]?.arrayValue?.compactMap({ $0.stringValue }), !keys.isEmpty else { - return .init(content: [.text("Invalid parameters: keys must be a non-empty array of strings")], isError: true) - } - - let sendKeys = keys.compactMap(Key.init(rawValue:)) - SwiftAutoGUI.sendKeyShortcut(sendKeys) - return .init(content: [.text("Send key shortcut \(keys)")], isError: false) - - case "getScreenSize": - let screenSize = SwiftAutoGUI.size() - return .init(content: [.text("Screen size: {\"width\": \(screenSize.width), \"height\": \(screenSize.height)}")], isError: false) - - case "getPixelColor": - let xValue = arguments["x"] - let yValue = arguments["y"] - - guard let xValue = xValue, let yValue = yValue else { - return .init(content: [.text("Missing parameters: x and y are required")], isError: true) - } - - // Try multiple ways to extract the numeric value - var x: Int? - var y: Int? - - // Try direct integer conversion first - if let intX = xValue.intValue { - x = intX - } - // Try double conversion and cast to int - else if let doubleX = xValue.doubleValue { - x = Int(doubleX) - } - // Try getting as string and parsing - else if let xStr = xValue.stringValue { - x = Int(xStr) - } - - // Same for y - if let intY = yValue.intValue { - y = intY - } - else if let doubleY = yValue.doubleValue { - y = Int(doubleY) - } - else if let yStr = yValue.stringValue { - y = Int(yStr) - } - - guard let finalX = x, let finalY = y else { - return .init(content: [.text("Invalid parameters: x and y must be numbers. Received x=\(xValue), y=\(yValue)")], isError: true) - } - - // Use SwiftAutoGUI to get pixel color - guard let color = SwiftAutoGUI.pixel(x: finalX, y: finalY) else { - return .init(content: [.text("Failed to get pixel color at (\(finalX), \(finalY))")], isError: true) - } - - // Extract RGBA components - let red = Int(color.redComponent * 255) - let green = Int(color.greenComponent * 255) - let blue = Int(color.blueComponent * 255) - let alpha = Int(color.alphaComponent * 255) - - return .init(content: [.text("Pixel color at (\(finalX), \(finalY)): {\"red\": \(red), \"green\": \(green), \"blue\": \(blue), \"alpha\": \(alpha)}")], isError: false) - - default: - return .init(content: [.text("Unknown tool: \(params.name)")], isError: true) - } -} - -try await server.notify(ToolListChangedNotification.message()) -await server.waitForDisconnection() diff --git a/Sources/Sever+Extension.swift b/Sources/swift-mcp-gui/Extensions/Server+Extension.swift similarity index 98% rename from Sources/Sever+Extension.swift rename to Sources/swift-mcp-gui/Extensions/Server+Extension.swift index 7e1cd22..126fb28 100644 --- a/Sources/Sever+Extension.swift +++ b/Sources/swift-mcp-gui/Extensions/Server+Extension.swift @@ -4,4 +4,4 @@ extension Server { func waitForDisconnection() async { await withUnsafeContinuation { (_ continuation: UnsafeContinuation) in } } -} +} \ No newline at end of file diff --git a/Sources/swift-mcp-gui/Server/MCPServer.swift b/Sources/swift-mcp-gui/Server/MCPServer.swift new file mode 100644 index 0000000..91fd03e --- /dev/null +++ b/Sources/swift-mcp-gui/Server/MCPServer.swift @@ -0,0 +1,67 @@ +import Foundation +import MCP + +final class MCPServer: @unchecked Sendable { + private let server: Server + private let toolRegistry: ToolRegistry + + init() { + self.server = Server( + name: "mac-control-server", + version: "1.0.0", + capabilities: .init( + prompts: .init(listChanged: true), + tools: .init(listChanged: true) + ) + ) + self.toolRegistry = ToolRegistry() + } + + func start(transport: StdioTransport) async throws { + print("Starting MCP GUI Server...") + try await server.start(transport: transport) + + // Register all tools + toolRegistry.registerAllTools() + + // Register method handlers + await registerMethodHandlers() + + // Notify about tool list changes + try await server.notify(ToolListChangedNotification.message()) + } + + private func registerMethodHandlers() async { + // Register list tools handler + await server.withMethodHandler(ListTools.self) { [weak self] _ in + guard let self = self else { + return ListTools.Result(tools: []) + } + return ListTools.Result(tools: self.toolRegistry.listTools()) + } + + // Register call tool handler + await server.withMethodHandler(CallTool.self) { [weak self] params in + guard let self = self else { + return .init(content: [.text("Server not available")], isError: true) + } + + let argumentsValue: Value + if let args = params.arguments { + argumentsValue = .object(args) + } else { + argumentsValue = .object([:]) + } + + do { + return try await self.toolRegistry.execute(name: params.name, arguments: argumentsValue) + } catch { + return .init(content: [.text("Error executing tool: \(error.localizedDescription)")], isError: true) + } + } + } + + func waitForDisconnection() async { + await server.waitForDisconnection() + } +} \ No newline at end of file diff --git a/Sources/swift-mcp-gui/Tools/Keyboard/SendKeysTool.swift b/Sources/swift-mcp-gui/Tools/Keyboard/SendKeysTool.swift new file mode 100644 index 0000000..47d83e1 --- /dev/null +++ b/Sources/swift-mcp-gui/Tools/Keyboard/SendKeysTool.swift @@ -0,0 +1,77 @@ +import Foundation +import MCP +import SwiftAutoGUI + +struct SendKeysTool { + static func register(in registry: ToolRegistry) { + let tool = Tool( + name: "sendKeys", + description: "Send keyboard shortcuts or key combinations", + inputSchema: .object([ + "type": .string("object"), + "properties": .object([ + "keys": .object([ + "type": .string("array"), + "items": .object(["type": .string("string")]), + "description": .string("Array of key names") + ]) + ]), + "required": .array([.string("keys")]) + ]) + ) + + registry.registerTool(definition: tool) { arguments in + let parser = ParameterParser(arguments: arguments) + + do { + let keysArray = try parser.parseStringArray("keys") + + var keyCollection: [Key] = [] + for keyString in keysArray { + switch keyString.lowercased() { + case "command", "cmd": keyCollection.append(.command) + case "control", "ctrl": keyCollection.append(.control) + case "option", "opt", "alt": keyCollection.append(.option) + case "shift": keyCollection.append(.shift) + case "return", "enter": keyCollection.append(.returnKey) + case "space": keyCollection.append(.space) + case "tab": keyCollection.append(.tab) + case "escape", "esc": keyCollection.append(.escape) + case "delete", "del": keyCollection.append(.delete) + case "backspace": keyCollection.append(.delete) // SwiftAutoGUI uses .delete for backspace + case "up": keyCollection.append(.upArrow) + case "down": keyCollection.append(.downArrow) + case "left": keyCollection.append(.leftArrow) + case "right": keyCollection.append(.rightArrow) + case "a": keyCollection.append(.a) + case "c": keyCollection.append(.c) + case "v": keyCollection.append(.v) + case "x": keyCollection.append(.x) + case "z": keyCollection.append(.z) + case "1": keyCollection.append(.one) + case "2": keyCollection.append(.two) + case "3": keyCollection.append(.three) + case "4": keyCollection.append(.four) + case "5": keyCollection.append(.five) + case "6": keyCollection.append(.six) + case "7": keyCollection.append(.seven) + case "8": keyCollection.append(.eight) + case "9": keyCollection.append(.nine) + case "0": keyCollection.append(.zero) + default: + return .init(content: [.text("Unknown key: \(keyString)")], isError: true) + } + } + + if keyCollection.isEmpty { + return .init(content: [.text("No keys specified")], isError: true) + } + + SwiftAutoGUI.sendKeyShortcut(keyCollection) + return .init(content: [.text("Sent key combination: \(keysArray.joined(separator: "+"))")], isError: false) + } catch { + return .init(content: [.text(error.localizedDescription)], isError: true) + } + } + } +} \ No newline at end of file diff --git a/Sources/swift-mcp-gui/Tools/Mouse/MouseClickTool.swift b/Sources/swift-mcp-gui/Tools/Mouse/MouseClickTool.swift new file mode 100644 index 0000000..3ebf4f3 --- /dev/null +++ b/Sources/swift-mcp-gui/Tools/Mouse/MouseClickTool.swift @@ -0,0 +1,40 @@ +import Foundation +import MCP +import SwiftAutoGUI + +struct MouseClickTool { + static func register(in registry: ToolRegistry) { + let tool = Tool( + name: "mouseClick", + description: "Perform mouse click", + inputSchema: .object([ + "type": .string("object"), + "properties": .object([ + "button": .object(["type": .string("string"), "description": .string("Button to click (left or right)")]) + ]), + "required": .array([.string("button")]) + ]) + ) + + registry.registerTool(definition: tool) { arguments in + let parser = ParameterParser(arguments: arguments) + + do { + let button = try parser.parseString("button") + + switch button.lowercased() { + case "left": + SwiftAutoGUI.leftClick() + case "right": + SwiftAutoGUI.rightClick() + default: + return .init(content: [.text("Invalid button type. Must be 'left' or 'right'")], isError: true) + } + + return .init(content: [.text("\(button) click performed")], isError: false) + } catch { + return .init(content: [.text(error.localizedDescription)], isError: true) + } + } + } +} \ No newline at end of file diff --git a/Sources/swift-mcp-gui/Tools/Mouse/MoveMouseTool.swift b/Sources/swift-mcp-gui/Tools/Mouse/MoveMouseTool.swift new file mode 100644 index 0000000..33e8926 --- /dev/null +++ b/Sources/swift-mcp-gui/Tools/Mouse/MoveMouseTool.swift @@ -0,0 +1,35 @@ +import Foundation +import MCP +import SwiftAutoGUI +import CoreGraphics + +struct MoveMouseTool { + static func register(in registry: ToolRegistry) { + let tool = Tool( + name: "moveMouse", + description: "Move mouse cursor to specific coordinates", + inputSchema: .object([ + "type": .string("object"), + "properties": .object([ + "x": .object(["type": .string("number"), "description": .string("X coordinate")]), + "y": .object(["type": .string("number"), "description": .string("Y coordinate")]) + ]), + "required": .array([.string("x"), .string("y")]) + ]) + ) + + registry.registerTool(definition: tool) { arguments in + let parser = ParameterParser(arguments: arguments) + + do { + let x = try parser.parseDouble("x") + let y = try parser.parseDouble("y") + + SwiftAutoGUI.move(to: CGPoint(x: x, y: y)) + return .init(content: [.text("Mouse moved to (\(x), \(y))")], isError: false) + } catch { + return .init(content: [.text(error.localizedDescription)], isError: true) + } + } + } +} \ No newline at end of file diff --git a/Sources/swift-mcp-gui/Tools/Screen/GetPixelColorTool.swift b/Sources/swift-mcp-gui/Tools/Screen/GetPixelColorTool.swift new file mode 100644 index 0000000..64acf23 --- /dev/null +++ b/Sources/swift-mcp-gui/Tools/Screen/GetPixelColorTool.swift @@ -0,0 +1,43 @@ +import Foundation +import MCP +import SwiftAutoGUI + +struct GetPixelColorTool { + static func register(in registry: ToolRegistry) { + let tool = Tool( + name: "getPixelColor", + description: "Get color of specific pixel", + inputSchema: .object([ + "type": .string("object"), + "properties": .object([ + "x": .object(["type": .string("number"), "description": .string("X coordinate")]), + "y": .object(["type": .string("number"), "description": .string("Y coordinate")]) + ]), + "required": .array([.string("x"), .string("y")]) + ]) + ) + + registry.registerTool(definition: tool) { arguments in + let parser = ParameterParser(arguments: arguments) + + do { + let x = try parser.parseInt("x") + let y = try parser.parseInt("y") + + guard let color = SwiftAutoGUI.pixel(x: x, y: y) else { + return .init(content: [.text("Failed to get pixel color at (\(x), \(y))")], isError: true) + } + + // Extract RGBA components + let red = Int(color.redComponent * 255) + let green = Int(color.greenComponent * 255) + let blue = Int(color.blueComponent * 255) + let alpha = Int(color.alphaComponent * 255) + + return .init(content: [.text("Pixel color at (\(x), \(y)): {\"red\": \(red), \"green\": \(green), \"blue\": \(blue), \"alpha\": \(alpha)}")], isError: false) + } catch { + return .init(content: [.text(error.localizedDescription)], isError: true) + } + } + } +} \ No newline at end of file diff --git a/Sources/swift-mcp-gui/Tools/Screen/GetScreenSizeTool.swift b/Sources/swift-mcp-gui/Tools/Screen/GetScreenSizeTool.swift new file mode 100644 index 0000000..4629ee1 --- /dev/null +++ b/Sources/swift-mcp-gui/Tools/Screen/GetScreenSizeTool.swift @@ -0,0 +1,21 @@ +import Foundation +import MCP +import SwiftAutoGUI + +struct GetScreenSizeTool { + static func register(in registry: ToolRegistry) { + let tool = Tool( + name: "getScreenSize", + description: "Get screen dimensions", + inputSchema: .object([ + "type": .string("object"), + "properties": .object([:]) + ]) + ) + + registry.registerTool(definition: tool) { _ in + let screenSize = SwiftAutoGUI.size() + return .init(content: [.text("Screen size: {\"width\": \(screenSize.width), \"height\": \(screenSize.height)}")], isError: false) + } + } +} \ No newline at end of file diff --git a/Sources/swift-mcp-gui/Tools/Screen/ScrollTool.swift b/Sources/swift-mcp-gui/Tools/Screen/ScrollTool.swift new file mode 100644 index 0000000..396a7b8 --- /dev/null +++ b/Sources/swift-mcp-gui/Tools/Screen/ScrollTool.swift @@ -0,0 +1,46 @@ +import Foundation +import MCP +import SwiftAutoGUI + +struct ScrollTool { + static func register(in registry: ToolRegistry) { + let tool = Tool( + name: "scroll", + description: "Scroll in specified direction", + inputSchema: .object([ + "type": .string("object"), + "properties": .object([ + "direction": .object(["type": .string("string"), "description": .string("Scroll direction (up, down, left, right)")]), + "clicks": .object(["type": .string("number"), "description": .string("Number of scroll clicks")]) + ]), + "required": .array([.string("direction"), .string("clicks")]) + ]) + ) + + registry.registerTool(definition: tool) { arguments in + let parser = ParameterParser(arguments: arguments) + + do { + let direction = try parser.parseString("direction") + let clicks = try parser.parseInt("clicks") + + switch direction.lowercased() { + case "up": + SwiftAutoGUI.vscroll(clicks: clicks) + case "down": + SwiftAutoGUI.vscroll(clicks: -clicks) + case "left": + SwiftAutoGUI.hscroll(clicks: -clicks) + case "right": + SwiftAutoGUI.hscroll(clicks: clicks) + default: + return .init(content: [.text("Invalid scroll direction. Must be 'up', 'down', 'left', or 'right'")], isError: true) + } + + return .init(content: [.text("Scrolled \(direction) by \(clicks) clicks")], isError: false) + } catch { + return .init(content: [.text(error.localizedDescription)], isError: true) + } + } + } +} \ No newline at end of file diff --git a/Sources/swift-mcp-gui/Tools/ToolRegistry.swift b/Sources/swift-mcp-gui/Tools/ToolRegistry.swift new file mode 100644 index 0000000..7b107e7 --- /dev/null +++ b/Sources/swift-mcp-gui/Tools/ToolRegistry.swift @@ -0,0 +1,34 @@ +import Foundation +import MCP + +class ToolRegistry { + private var toolHandlers: [String: (Value) async throws -> CallTool.Result] = [:] + private var toolDefinitions: [Tool] = [] + + func registerTool(definition: Tool, handler: @escaping (Value) async throws -> CallTool.Result) { + toolDefinitions.append(definition) + toolHandlers[definition.name] = handler + } + + func execute(name: String, arguments: Value) async throws -> CallTool.Result { + guard let handler = toolHandlers[name] else { + return .init(content: [.text("Unknown tool: \(name)")], isError: true) + } + + return try await handler(arguments) + } + + func listTools() -> [Tool] { + return toolDefinitions + } + + func registerAllTools() { + // Register each tool with its definition and handler + MoveMouseTool.register(in: self) + MouseClickTool.register(in: self) + ScrollTool.register(in: self) + SendKeysTool.register(in: self) + GetScreenSizeTool.register(in: self) + GetPixelColorTool.register(in: self) + } +} \ No newline at end of file diff --git a/Sources/swift-mcp-gui/Utilities/ParameterParser.swift b/Sources/swift-mcp-gui/Utilities/ParameterParser.swift new file mode 100644 index 0000000..089a5e9 --- /dev/null +++ b/Sources/swift-mcp-gui/Utilities/ParameterParser.swift @@ -0,0 +1,116 @@ +import Foundation +import MCP + +struct ParameterParser { + private let arguments: Value + + init(arguments: Value) { + self.arguments = arguments + } + + func parseDouble(_ key: String) throws -> Double { + // Check if arguments is an object + guard case .object(let dict) = arguments, + let value = dict[key] else { + throw ParameterError.missingParameter(key) + } + + // Try double conversion first + if case .double(let num) = value { + return num + } + + // Try int conversion + if case .int(let num) = value { + return Double(num) + } + + // Try getting as string and parsing + if case .string(let str) = value { + guard let parsed = Double(str) else { + throw ParameterError.invalidType(key: key, expected: "number", received: str) + } + return parsed + } + + throw ParameterError.invalidType(key: key, expected: "number", received: String(describing: value)) + } + + func parseInt(_ key: String) throws -> Int { + // Check if arguments is an object + guard case .object(let dict) = arguments, + let value = dict[key] else { + throw ParameterError.missingParameter(key) + } + + // Try int conversion first + if case .int(let num) = value { + return num + } + + // Try double conversion and cast to int + if case .double(let num) = value { + return Int(num) + } + + // Try getting as string and parsing + if case .string(let str) = value { + guard let parsed = Int(str) else { + throw ParameterError.invalidType(key: key, expected: "number", received: str) + } + return parsed + } + + throw ParameterError.invalidType(key: key, expected: "number", received: String(describing: value)) + } + + func parseString(_ key: String) throws -> String { + // Check if arguments is an object + guard case .object(let dict) = arguments, + let value = dict[key] else { + throw ParameterError.missingParameter(key) + } + + guard case .string(let str) = value else { + throw ParameterError.invalidType(key: key, expected: "string", received: String(describing: value)) + } + + return str + } + + func parseStringArray(_ key: String) throws -> [String] { + // Check if arguments is an object + guard case .object(let dict) = arguments, + let value = dict[key] else { + throw ParameterError.missingParameter(key) + } + + guard case .array(let arr) = value else { + throw ParameterError.invalidType(key: key, expected: "array", received: String(describing: value)) + } + + var stringArray: [String] = [] + for item in arr { + guard case .string(let str) = item else { + throw ParameterError.invalidType(key: key, expected: "array of strings", received: String(describing: value)) + } + stringArray.append(str) + } + + return stringArray + } +} + +enum ParameterError: Swift.Error, LocalizedError { + case missingParameter(String) + case invalidType(key: String, expected: String, received: String) + + var errorDescription: String? { + switch self { + case .missingParameter(let key): + return "Missing parameter: \(key)" + case .invalidType(let key, let expected, let received): + return "Invalid parameter \(key): expected \(expected), received \(received)" + } + } +} \ No newline at end of file diff --git a/Sources/swift-mcp-gui/main.swift b/Sources/swift-mcp-gui/main.swift new file mode 100644 index 0000000..f5900eb --- /dev/null +++ b/Sources/swift-mcp-gui/main.swift @@ -0,0 +1,12 @@ +import Foundation +import MCP + + +let server = MCPServer() +let transport = StdioTransport() + +// Start server and register tools +try await server.start(transport: transport) + +// Wait for disconnection +await server.waitForDisconnection() diff --git a/Tests/swift-mcp-guiTests/Tools/KeyboardToolsTests.swift b/Tests/swift-mcp-guiTests/Tools/KeyboardToolsTests.swift new file mode 100644 index 0000000..077f66b --- /dev/null +++ b/Tests/swift-mcp-guiTests/Tools/KeyboardToolsTests.swift @@ -0,0 +1,37 @@ +import XCTest +import MCP +@testable import swift_mcp_gui + +final class KeyboardToolsTests: XCTestCase { + + func testSendKeysToolExecution() async throws { + let tool = SendKeysTool() + let arguments: JSONValue = [ + "keys": ["cmd", "c"] + ] + + let result = try await tool.execute(arguments: arguments) + XCTAssertFalse(result.isError) + XCTAssertTrue(result.content.first?.text?.contains("Send key shortcut") ?? false) + } + + func testSendKeysToolEmptyArray() async throws { + let tool = SendKeysTool() + let arguments: JSONValue = [ + "keys": [] + ] + + let result = try await tool.execute(arguments: arguments) + XCTAssertTrue(result.isError) + XCTAssertTrue(result.content.first?.text?.contains("Keys array cannot be empty") ?? false) + } + + func testSendKeysToolMissingParameter() async throws { + let tool = SendKeysTool() + let arguments: JSONValue = [:] + + let result = try await tool.execute(arguments: arguments) + XCTAssertTrue(result.isError) + XCTAssertTrue(result.content.first?.text?.contains("Missing parameter: keys") ?? false) + } +} \ No newline at end of file diff --git a/Tests/swift-mcp-guiTests/Tools/MouseToolsTests.swift b/Tests/swift-mcp-guiTests/Tools/MouseToolsTests.swift new file mode 100644 index 0000000..b418512 --- /dev/null +++ b/Tests/swift-mcp-guiTests/Tools/MouseToolsTests.swift @@ -0,0 +1,52 @@ +import XCTest +import MCP +@testable import swift_mcp_gui + +final class MouseToolsTests: XCTestCase { + + func testMoveMouseToolExecution() async throws { + let tool = MoveMouseTool() + let arguments: JSONValue = [ + "x": 100, + "y": 200 + ] + + let result = try await tool.execute(arguments: arguments) + XCTAssertFalse(result.isError) + XCTAssertEqual(result.content.first?.text, "Mouse moved to (100.0, 200.0)") + } + + func testMoveMouseToolMissingParameters() async throws { + let tool = MoveMouseTool() + let arguments: JSONValue = [ + "x": 100 + // Missing y parameter + ] + + let result = try await tool.execute(arguments: arguments) + XCTAssertTrue(result.isError) + XCTAssertTrue(result.content.first?.text?.contains("Missing parameter: y") ?? false) + } + + func testMouseClickToolExecution() async throws { + let tool = MouseClickTool() + let arguments: JSONValue = [ + "button": "left" + ] + + let result = try await tool.execute(arguments: arguments) + XCTAssertFalse(result.isError) + XCTAssertEqual(result.content.first?.text, "left click performed") + } + + func testMouseClickToolInvalidButton() async throws { + let tool = MouseClickTool() + let arguments: JSONValue = [ + "button": "invalid" + ] + + let result = try await tool.execute(arguments: arguments) + XCTAssertTrue(result.isError) + XCTAssertTrue(result.content.first?.text?.contains("Invalid button type") ?? false) + } +} \ No newline at end of file diff --git a/Tests/swift-mcp-guiTests/Tools/ScreenToolsTests.swift b/Tests/swift-mcp-guiTests/Tools/ScreenToolsTests.swift new file mode 100644 index 0000000..1aa329f --- /dev/null +++ b/Tests/swift-mcp-guiTests/Tools/ScreenToolsTests.swift @@ -0,0 +1,64 @@ +import XCTest +import MCP +@testable import swift_mcp_gui + +final class ScreenToolsTests: XCTestCase { + + func testScrollToolExecution() async throws { + let tool = ScrollTool() + let arguments: JSONValue = [ + "direction": "up", + "clicks": 3 + ] + + let result = try await tool.execute(arguments: arguments) + XCTAssertFalse(result.isError) + XCTAssertEqual(result.content.first?.text, "Scrolled up by 3 clicks") + } + + func testScrollToolInvalidDirection() async throws { + let tool = ScrollTool() + let arguments: JSONValue = [ + "direction": "invalid", + "clicks": 3 + ] + + let result = try await tool.execute(arguments: arguments) + XCTAssertTrue(result.isError) + XCTAssertTrue(result.content.first?.text?.contains("Invalid scroll direction") ?? false) + } + + func testGetScreenSizeToolExecution() async throws { + let tool = GetScreenSizeTool() + let arguments: JSONValue = [:] + + let result = try await tool.execute(arguments: arguments) + XCTAssertFalse(result.isError) + XCTAssertTrue(result.content.first?.text?.contains("Screen size:") ?? false) + } + + func testGetPixelColorToolExecution() async throws { + let tool = GetPixelColorTool() + let arguments: JSONValue = [ + "x": 100, + "y": 200 + ] + + let result = try await tool.execute(arguments: arguments) + // Note: This test might fail in CI/CD environments without proper display access + // In a real implementation, you might want to mock SwiftAutoGUI for testing + XCTAssertFalse(result.isError) + } + + func testGetPixelColorToolMissingParameters() async throws { + let tool = GetPixelColorTool() + let arguments: JSONValue = [ + "x": 100 + // Missing y parameter + ] + + let result = try await tool.execute(arguments: arguments) + XCTAssertTrue(result.isError) + XCTAssertTrue(result.content.first?.text?.contains("Missing parameter: y") ?? false) + } +} \ No newline at end of file diff --git a/Tests/swift-mcp-guiTests/Utilities/ParameterParserTests.swift b/Tests/swift-mcp-guiTests/Utilities/ParameterParserTests.swift new file mode 100644 index 0000000..2067fa5 --- /dev/null +++ b/Tests/swift-mcp-guiTests/Utilities/ParameterParserTests.swift @@ -0,0 +1,87 @@ +import XCTest +import MCP +@testable import swift_mcp_gui + +final class ParameterParserTests: XCTestCase { + + func testParseDoubleFromDouble() throws { + let arguments: JSONValue = ["value": 123.45] + let parser = ParameterParser(arguments: arguments) + + let result = try parser.parseDouble("value") + XCTAssertEqual(result, 123.45, accuracy: 0.001) + } + + func testParseDoubleFromInt() throws { + let arguments: JSONValue = ["value": 123] + let parser = ParameterParser(arguments: arguments) + + let result = try parser.parseDouble("value") + XCTAssertEqual(result, 123.0, accuracy: 0.001) + } + + func testParseDoubleFromString() throws { + let arguments: JSONValue = ["value": "123.45"] + let parser = ParameterParser(arguments: arguments) + + let result = try parser.parseDouble("value") + XCTAssertEqual(result, 123.45, accuracy: 0.001) + } + + func testParseDoubleMissingParameter() { + let arguments: JSONValue = [:] + let parser = ParameterParser(arguments: arguments) + + XCTAssertThrowsError(try parser.parseDouble("value")) { error in + XCTAssertTrue(error is ParameterError) + if case ParameterError.missingParameter(let key) = error { + XCTAssertEqual(key, "value") + } + } + } + + func testParseIntFromInt() throws { + let arguments: JSONValue = ["value": 123] + let parser = ParameterParser(arguments: arguments) + + let result = try parser.parseInt("value") + XCTAssertEqual(result, 123) + } + + func testParseIntFromDouble() throws { + let arguments: JSONValue = ["value": 123.0] + let parser = ParameterParser(arguments: arguments) + + let result = try parser.parseInt("value") + XCTAssertEqual(result, 123) + } + + func testParseString() throws { + let arguments: JSONValue = ["value": "hello world"] + let parser = ParameterParser(arguments: arguments) + + let result = try parser.parseString("value") + XCTAssertEqual(result, "hello world") + } + + func testParseStringArray() throws { + let arguments: JSONValue = ["value": ["cmd", "c", "v"]] + let parser = ParameterParser(arguments: arguments) + + let result = try parser.parseStringArray("value") + XCTAssertEqual(result, ["cmd", "c", "v"]) + } + + func testParseStringArrayInvalidType() { + let arguments: JSONValue = ["value": "not an array"] + let parser = ParameterParser(arguments: arguments) + + XCTAssertThrowsError(try parser.parseStringArray("value")) { error in + XCTAssertTrue(error is ParameterError) + if case ParameterError.invalidType(let key, let expected, _) = error { + XCTAssertEqual(key, "value") + XCTAssertEqual(expected, "array") + } + } + } +} \ No newline at end of file