Skip to content

Instantly share code, notes, and snippets.

@rijieli
Last active April 10, 2026 01:48
Show Gist options
  • Select an option

  • Save rijieli/5c76d90f76baab73eeb8d369767574db to your computer and use it in GitHub Desktop.

Select an option

Save rijieli/5c76d90f76baab73eeb8d369767574db to your computer and use it in GitHub Desktop.
Directly call the lldb MCP via script.
#!/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