Last active
April 10, 2026 01:48
-
-
Save rijieli/5c76d90f76baab73eeb8d369767574db to your computer and use it in GitHub Desktop.
Directly call the lldb MCP via script.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env swift | |
| // protocol-server start MCP listen://localhost:59999 | |
| // protocol-server stop MCP | |
| import Foundation | |
| let protocolVersion = "2024-11-05" | |
| enum ClientError: Error, CustomStringConvertible { | |
| case invalidArguments(String) | |
| case connectionFailed(String) | |
| case connectionClosed | |
| case invalidResponse(String) | |
| case remoteError(String) | |
| case missingDebugger | |
| case missingTool | |
| var description: String { | |
| switch self { | |
| case .invalidArguments(let message): | |
| return message | |
| case .connectionFailed(let message): | |
| return message | |
| case .connectionClosed: | |
| return "MCP connection closed." | |
| case .invalidResponse(let message): | |
| return message | |
| case .remoteError(let message): | |
| return message | |
| case .missingDebugger: | |
| return "No debugger resource found. Is Xcode currently debugging something?" | |
| case .missingTool: | |
| return "No LLDB command tool found in tools/list." | |
| } | |
| } | |
| } | |
| struct Options { | |
| var command: String? | |
| var host = "127.0.0.1" | |
| var port: UInt16 = 59999 | |
| var debuggerID: Int? | |
| var showTools = false | |
| var showResources = false | |
| } | |
| func printUsage() { | |
| let text = """ | |
| Usage: | |
| lldb_mcp_raw.swift [--host HOST] [--port PORT] [--debugger-id ID] [--show-tools] [--show-resources] [command] | |
| Example: | |
| swift lldb_mcp_raw.swift "bt" | |
| swift lldb_mcp_raw.swift 'expr -l Swift -- import Foundation; Bundle.main.object(forInfoDictionaryKey: "CFBundleName") ?? "nil"' | |
| """ | |
| print(text) | |
| } | |
| func parseOptions(arguments: [String]) throws -> Options { | |
| var options = Options() | |
| var commandParts: [String] = [] | |
| var index = 0 | |
| while index < arguments.count { | |
| let argument = arguments[index] | |
| switch argument { | |
| case "--host": | |
| index += 1 | |
| guard index < arguments.count else { | |
| throw ClientError.invalidArguments("Missing value for --host.") | |
| } | |
| options.host = arguments[index] | |
| case "--port": | |
| index += 1 | |
| guard index < arguments.count, let port = UInt16(arguments[index]) else { | |
| throw ClientError.invalidArguments("Invalid value for --port.") | |
| } | |
| options.port = port | |
| case "--debugger-id": | |
| index += 1 | |
| guard index < arguments.count, let debuggerID = Int(arguments[index]) else { | |
| throw ClientError.invalidArguments("Invalid value for --debugger-id.") | |
| } | |
| options.debuggerID = debuggerID | |
| case "--show-tools": | |
| options.showTools = true | |
| case "--show-resources": | |
| options.showResources = true | |
| case "--help", "-h": | |
| printUsage() | |
| exit(0) | |
| default: | |
| commandParts.append(argument) | |
| } | |
| index += 1 | |
| } | |
| if !commandParts.isEmpty { | |
| options.command = commandParts.joined(separator: " ") | |
| } | |
| return options | |
| } | |
| func jsonData(_ object: Any, pretty: Bool = false) throws -> Data { | |
| let options: JSONSerialization.WritingOptions = pretty ? [.prettyPrinted, .sortedKeys] : [] | |
| return try JSONSerialization.data(withJSONObject: object, options: options) | |
| } | |
| func prettyJSONString(_ object: Any) throws -> String { | |
| let data = try jsonData(object, pretty: true) | |
| return String(decoding: data, as: UTF8.self) | |
| } | |
| final class MCPClient { | |
| private let socketFD: Int32 | |
| private var nextID = 1 | |
| private var readBuffer = Data() | |
| init(host: String, port: UInt16) throws { | |
| var hints = addrinfo( | |
| ai_flags: 0, | |
| ai_family: AF_UNSPEC, | |
| ai_socktype: SOCK_STREAM, | |
| ai_protocol: IPPROTO_TCP, | |
| ai_addrlen: 0, | |
| ai_canonname: nil, | |
| ai_addr: nil, | |
| ai_next: nil | |
| ) | |
| var addressList: UnsafeMutablePointer<addrinfo>? | |
| let status = getaddrinfo(host, String(port), &hints, &addressList) | |
| guard status == 0, let firstAddress = addressList else { | |
| throw ClientError.connectionFailed(String(cString: gai_strerror(status))) | |
| } | |
| var connectedFD: Int32 = -1 | |
| var current: UnsafeMutablePointer<addrinfo>? = firstAddress | |
| while let entry = current { | |
| let fd = socket(entry.pointee.ai_family, entry.pointee.ai_socktype, entry.pointee.ai_protocol) | |
| if fd >= 0 { | |
| let result = connect(fd, entry.pointee.ai_addr, entry.pointee.ai_addrlen) | |
| if result == 0 { | |
| connectedFD = fd | |
| break | |
| } | |
| close(fd) | |
| } | |
| current = entry.pointee.ai_next | |
| } | |
| freeaddrinfo(firstAddress) | |
| guard connectedFD >= 0 else { | |
| throw ClientError.connectionFailed("Failed to connect to \(host):\(port).") | |
| } | |
| socketFD = connectedFD | |
| } | |
| deinit { | |
| close(socketFD) | |
| } | |
| func send(message: [String: Any]) throws { | |
| var payload = try jsonData(message) | |
| payload.append(0x0A) | |
| try payload.withUnsafeBytes { buffer in | |
| guard let baseAddress = buffer.baseAddress else { return } | |
| var offset = 0 | |
| while offset < buffer.count { | |
| let bytesRemaining = buffer.count - offset | |
| let written = Darwin.write(socketFD, baseAddress.advanced(by: offset), bytesRemaining) | |
| if written < 0 { | |
| throw ClientError.connectionFailed("Failed to write to MCP socket.") | |
| } | |
| offset += written | |
| } | |
| } | |
| } | |
| func receive() throws -> [String: Any] { | |
| while true { | |
| if let newlineIndex = readBuffer.firstIndex(of: 0x0A) { | |
| let lineData = readBuffer.prefix(upTo: newlineIndex) | |
| readBuffer.removeSubrange(...newlineIndex) | |
| if lineData.isEmpty { | |
| continue | |
| } | |
| let object = try JSONSerialization.jsonObject(with: Data(lineData)) | |
| guard let dictionary = object as? [String: Any] else { | |
| throw ClientError.invalidResponse("Expected top-level JSON object from MCP server.") | |
| } | |
| return dictionary | |
| } | |
| var chunk = [UInt8](repeating: 0, count: 4096) | |
| let bytesRead = Darwin.read(socketFD, &chunk, chunk.count) | |
| if bytesRead < 0 { | |
| throw ClientError.connectionFailed("Failed to read from MCP socket.") | |
| } | |
| if bytesRead == 0 { | |
| throw ClientError.connectionClosed | |
| } | |
| readBuffer.append(contentsOf: chunk.prefix(bytesRead)) | |
| } | |
| } | |
| func request(method: String, params: [String: Any]? = nil) throws -> [String: Any] { | |
| let requestID = nextID | |
| nextID += 1 | |
| var message: [String: Any] = [ | |
| "jsonrpc": "2.0", | |
| "id": requestID, | |
| "method": method, | |
| ] | |
| if let params { | |
| message["params"] = params | |
| } | |
| try send(message: message) | |
| while true { | |
| let response = try receive() | |
| guard let id = response["id"] as? Int, id == requestID else { | |
| continue | |
| } | |
| if let error = response["error"] { | |
| throw ClientError.remoteError(try prettyJSONString(error)) | |
| } | |
| guard let result = response["result"] as? [String: Any] else { | |
| throw ClientError.invalidResponse("Missing result for MCP request \(method).") | |
| } | |
| return result | |
| } | |
| } | |
| func notify(method: String, params: [String: Any]? = nil) throws { | |
| var message: [String: Any] = [ | |
| "jsonrpc": "2.0", | |
| "method": method, | |
| ] | |
| if let params { | |
| message["params"] = params | |
| } | |
| try send(message: message) | |
| } | |
| func initialize() throws -> [String: Any] { | |
| let result = try request( | |
| method: "initialize", | |
| params: [ | |
| "protocolVersion": protocolVersion, | |
| "capabilities": [:], | |
| "clientInfo": [ | |
| "name": "lldb-mcp-raw-swift", | |
| "version": "0.1.0", | |
| ], | |
| ] | |
| ) | |
| try notify(method: "notifications/initialized") | |
| return result | |
| } | |
| } | |
| func findDebuggerID(resources: [[String: Any]]) throws -> Int { | |
| for resource in resources { | |
| guard let uri = resource["uri"] as? String else { | |
| continue | |
| } | |
| guard uri.hasPrefix("lldb://debugger/") else { | |
| continue | |
| } | |
| let suffix = String(uri.dropFirst("lldb://debugger/".count)) | |
| if suffix.contains("/") { | |
| continue | |
| } | |
| if let debuggerID = Int(suffix) { | |
| return debuggerID | |
| } | |
| } | |
| throw ClientError.missingDebugger | |
| } | |
| func findCommandTool(tools: [[String: Any]]) throws -> [String: Any] { | |
| for candidate in ["command", "lldb_command"] { | |
| if let tool = tools.first(where: { ($0["name"] as? String) == candidate }) { | |
| return tool | |
| } | |
| } | |
| throw ClientError.missingTool | |
| } | |
| func buildCommandArguments(tool: [String: Any], debuggerID: Int, command: String) -> [String: Any] { | |
| let inputSchema = tool["inputSchema"] as? [String: Any] | |
| let properties = inputSchema?["properties"] as? [String: Any] ?? [:] | |
| assert(!properties.isEmpty, "Expected MCP tool schema to include input properties.") | |
| let debuggerKey = properties.keys.first(where: { $0.localizedCaseInsensitiveContains("debugger") }) ?? "debugger_id" | |
| let commandKey = | |
| properties.keys.first(where: { | |
| $0.caseInsensitiveCompare("arguments") == .orderedSame | |
| || $0.caseInsensitiveCompare("command") == .orderedSame | |
| || $0.localizedCaseInsensitiveContains("command") | |
| }) ?? "arguments" | |
| return [ | |
| debuggerKey: debuggerID, | |
| commandKey: command, | |
| ] | |
| } | |
| func printToolResult(_ result: [String: Any]) throws { | |
| var printed = false | |
| if let content = result["content"] as? [[String: Any]] { | |
| for item in content { | |
| if let type = item["type"] as? String, type == "text" { | |
| let text = item["text"] as? String ?? "" | |
| FileHandle.standardOutput.write(Data(text.utf8)) | |
| if !text.isEmpty && !text.hasSuffix("\n") { | |
| FileHandle.standardOutput.write(Data("\n".utf8)) | |
| } | |
| printed = true | |
| } else { | |
| print(try prettyJSONString(item)) | |
| printed = true | |
| } | |
| } | |
| } | |
| if !printed, let structuredContent = result["structuredContent"] { | |
| print(try prettyJSONString(structuredContent)) | |
| } | |
| } | |
| do { | |
| let options = try parseOptions(arguments: Array(CommandLine.arguments.dropFirst())) | |
| let client = try MCPClient(host: options.host, port: options.port) | |
| let initResult = try client.initialize() | |
| if options.showTools || options.showResources { | |
| print(try prettyJSONString(initResult)) | |
| } | |
| let toolsResult = try client.request(method: "tools/list") | |
| if options.showTools { | |
| print(try prettyJSONString(toolsResult)) | |
| } | |
| let resourcesResult = try client.request(method: "resources/list") | |
| if options.showResources { | |
| print(try prettyJSONString(resourcesResult)) | |
| } | |
| if let command = options.command { | |
| let resources = resourcesResult["resources"] as? [[String: Any]] ?? [] | |
| let tools = toolsResult["tools"] as? [[String: Any]] ?? [] | |
| let debuggerID = try options.debuggerID ?? findDebuggerID(resources: resources) | |
| let tool = try findCommandTool(tools: tools) | |
| let arguments = buildCommandArguments(tool: tool, debuggerID: debuggerID, command: command) | |
| let result = try client.request( | |
| method: "tools/call", | |
| params: [ | |
| "name": tool["name"] as? String ?? "lldb_command", | |
| "arguments": arguments, | |
| ] | |
| ) | |
| try printToolResult(result) | |
| if let isError = result["isError"] as? Bool, isError { | |
| exit(2) | |
| } | |
| } | |
| } catch { | |
| FileHandle.standardError.write(Data("\(error)\n".utf8)) | |
| exit(1) | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment