Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
Add MCP server for AI-driven control of InjectionNext
- ControlServer: local TCP server (localhost:8919) that exposes app
  actions as JSON commands (watch project, enable devices, get status, etc.)
- LogBuffer: ring buffer capturing injection logs, compilation errors,
  and file watcher activity for AI consumption
- MCP server (Node.js): 13 tools exposing InjectionNext to AI agents
  via the Model Context Protocol (get_status, watch_project, get_logs, etc.)
- Hook log() and InjectionServer.log/error into LogBuffer for real-time
  debug console access

Made-with: Cursor
  • Loading branch information
maatheusgois-dd committed Apr 12, 2026
commit 84862b08d23e3f37ae027e6ddf735b79a08591a0
4 changes: 4 additions & 0 deletions App/InjectionNext.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@
BB4788972EB6B81A00464AB4 /* xt_forwarding_trampoline_x86.s in Sources */ = {isa = PBXBuildFile; fileRef = BB47888E2EB6B81A00464AB4 /* xt_forwarding_trampoline_x86.s */; };
BB4788982EB6B81A00464AB4 /* fishhook.c in Sources */ = {isa = PBXBuildFile; fileRef = BB4788862EB6B81A00464AB4 /* fishhook.c */; };
BB5155DA2CDED44F00704C7A /* InjectionHybrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB5155D92CDED44400704C7A /* InjectionHybrid.swift */; };
AA0000012F08000000000001 /* ControlServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000002F08000000000001 /* ControlServer.swift */; };
BB52AD552F1501F500297CD9 /* Unhider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22B6459F2C18DD9D00F99B61 /* Unhider.swift */; };
BB6A56F02C50E73600C92112 /* Defaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB6A56EF2C50E73600C92112 /* Defaults.swift */; };
BB6A56F12C50E79800C92112 /* copy_bundle.sh in Resources */ = {isa = PBXBuildFile; fileRef = BBDD84582C4FF0B9000F3124 /* copy_bundle.sh */; };
Expand Down Expand Up @@ -229,6 +230,7 @@
BB47888D2EB6B81A00464AB4 /* xt_forwarding_trampoline_x64.s */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.asm; path = xt_forwarding_trampoline_x64.s; sourceTree = "<group>"; };
BB47888E2EB6B81A00464AB4 /* xt_forwarding_trampoline_x86.s */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.asm; path = xt_forwarding_trampoline_x86.s; sourceTree = "<group>"; };
BB5155D92CDED44400704C7A /* InjectionHybrid.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InjectionHybrid.swift; sourceTree = "<group>"; };
AA0000002F08000000000001 /* ControlServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlServer.swift; sourceTree = "<group>"; };
BB67DBB21FB0CDA8000EAC8A /* SimpleSocket.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SimpleSocket.h; path = ../../Sources/InjectionNextC/include/SimpleSocket.h; sourceTree = "<group>"; };
BB67DBB31FB0CDA8000EAC8A /* SimpleSocket.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; name = SimpleSocket.mm; path = ../../Sources/InjectionNextC/SimpleSocket.mm; sourceTree = "<group>"; };
BB6A56EF2C50E73600C92112 /* Defaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Defaults.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -506,6 +508,7 @@
CEC17029253ED117002E823F /* Experimental.swift */,
BB6A56EF2C50E73600C92112 /* Defaults.swift */,
22B6459F2C18DD9D00F99B61 /* Unhider.swift */,
AA0000002F08000000000001 /* ControlServer.swift */,
224E57FC2C08BAE200B71C79 /* InjectionClient.h */,
BB67DBB21FB0CDA8000EAC8A /* SimpleSocket.h */,
BB67DBB31FB0CDA8000EAC8A /* SimpleSocket.mm */,
Expand Down Expand Up @@ -801,6 +804,7 @@
BB42F95E2EB63EFF00FDBBCC /* FilenameMatcher.swift in Sources */,
224E57FE2C08BBE300B71C79 /* InjectionServer.swift in Sources */,
22B645A02C18DD9D00F99B61 /* Unhider.swift in Sources */,
AA0000012F08000000000001 /* ControlServer.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
2 changes: 2 additions & 0 deletions App/InjectionNext/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
if let project = Defaults.projectPath {
_ = MonitorXcode(args: " '\(project)'")
}

ControlServer.start()
}

func setMenuIcon(_ state: InjectionState) {
Expand Down
356 changes: 356 additions & 0 deletions App/InjectionNext/ControlServer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,356 @@
//
// ControlServer.swift
// InjectionNext
//
// Local TCP control server for MCP integration.
// Listens on localhost:8919 for JSON commands and
// maps them to existing AppDelegate actions.
//

import Cocoa

// MARK: - Log Buffer

class LogBuffer {

static let shared = LogBuffer()

struct Entry {
let timestamp: TimeInterval
let message: String
let level: String
}

private let lock = NSLock()
private var entries = [Entry]()
private let maxEntries = 2000

func append(_ message: String, level: String = "info") {
lock.lock()
defer { lock.unlock() }
entries.append(Entry(
timestamp: Date().timeIntervalSince1970,
message: message,
level: level
))
if entries.count > maxEntries {
entries.removeFirst(entries.count - maxEntries)
}
Comment on lines +31 to +38
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LogBuffer prunes with removeFirst(...) once over capacity. On Array this is O(n) due to element shifting, which can become noticeable with frequent logging. If the intent is a ring buffer, consider implementing a fixed-size circular buffer (head index + count) to keep appends O(1).

Copilot uses AI. Check for mistakes.
}

func get(since: TimeInterval = 0, limit: Int = 200) -> [[String: Any]] {
lock.lock()
defer { lock.unlock() }
let filtered = entries.filter { $0.timestamp > since }
let sliced = filtered.suffix(limit)
return sliced.map {
["timestamp": $0.timestamp, "message": $0.message, "level": $0.level]
}
}

func clear() {
lock.lock()
defer { lock.unlock() }
entries.removeAll()
}

var count: Int {
lock.lock()
defer { lock.unlock() }
return entries.count
}
}

// MARK: - Control Server

class ControlServer {

static let port: UInt16 = 8919
static var shared: ControlServer?

private var serverSocket: Int32 = -1
private let queue = DispatchQueue(label: "ControlServer", attributes: .concurrent)

static func start() {
guard shared == nil else { return }
shared = ControlServer()
shared?.listen()
}

private func listen() {
queue.async { [weak self] in
guard let self = self else { return }

self.serverSocket = socket(AF_INET, SOCK_STREAM, 0)
guard self.serverSocket >= 0 else {
NSLog("\(APP_PREFIX)ControlServer: socket() failed")
return
}

var reuse: Int32 = 1
setsockopt(self.serverSocket, SOL_SOCKET, SO_REUSEADDR, &reuse, socklen_t(MemoryLayout<Int32>.size))

Comment on lines +84 to +92
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This control port accepts arbitrary JSON commands from any local process without authentication/authorization. Even bound to 127.0.0.1, this allows other local apps to toggle injection, change Xcode paths, launch Xcode, etc. Consider an opt-in enable flag plus a shared secret (token) requirement per request, or using a Unix domain socket with filesystem permissions to restrict who can connect.

Copilot uses AI. Check for mistakes.
var addr = sockaddr_in()
addr.sin_len = UInt8(MemoryLayout<sockaddr_in>.size)
addr.sin_family = sa_family_t(AF_INET)
addr.sin_port = Self.port.bigEndian
addr.sin_addr.s_addr = inet_addr("127.0.0.1")

let bindResult = withUnsafePointer(to: &addr) {
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
bind(self.serverSocket, $0, socklen_t(MemoryLayout<sockaddr_in>.size))
}
}

guard bindResult == 0 else {
NSLog("\(APP_PREFIX)ControlServer: bind() failed on port \(Self.port): \(String(cString: strerror(errno)))")
close(self.serverSocket)
return
}

guard Darwin.listen(self.serverSocket, 5) == 0 else {
NSLog("\(APP_PREFIX)ControlServer: listen() failed")
close(self.serverSocket)
return
}

NSLog("\(APP_PREFIX)ControlServer: listening on localhost:\(Self.port)")

while true {
var clientAddr = sockaddr_in()
var clientLen = socklen_t(MemoryLayout<sockaddr_in>.size)
let clientSocket = withUnsafeMutablePointer(to: &clientAddr) {
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
accept(self.serverSocket, $0, &clientLen)
}
}
guard clientSocket >= 0 else { continue }
self.queue.async {
self.handleClient(clientSocket)
}
}
}
}

private func handleClient(_ sock: Int32) {
defer { close(sock) }

var data = Data()
var buf = [UInt8](repeating: 0, count: 4096)
while true {
let n = recv(sock, &buf, buf.count, 0)
guard n > 0 else { break }
data.append(contentsOf: buf[0..<n])
if data.contains(UInt8(ascii: "\n")) { break }
}
Comment on lines +139 to +150
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

handleClient keeps appending to data until it sees a newline, with no maximum size. A local client can send an unbounded stream without \n and cause unbounded memory growth. Add a maximum request size (and optionally a read timeout) and close the connection with an error when exceeded.

Copilot uses AI. Check for mistakes.

guard !data.isEmpty,
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let action = json["action"] as? String else {
sendResponse(sock, success: false, error: "Invalid JSON or missing 'action'")
return
}

let result = executeAction(action, params: json)
sendResponse(sock, success: result.success, data: result.data, error: result.error)
}

private func sendResponse(_ sock: Int32, success: Bool, data: [String: Any]? = nil, error: String? = nil) {
var response: [String: Any] = ["success": success]
if let error = error { response["error"] = error }
if let data = data { response["data"] = data }
guard let jsonData = try? JSONSerialization.data(withJSONObject: response),
let jsonStr = String(data: jsonData, encoding: .utf8) else { return }
let line = jsonStr + "\n"
_ = line.withCString { ptr in
send(sock, ptr, strlen(ptr), 0)
}
}

struct ActionResult {
let success: Bool
let data: [String: Any]?
let error: String?

static func ok(_ data: [String: Any]? = nil) -> ActionResult {
ActionResult(success: true, data: data, error: nil)
}
static func fail(_ error: String) -> ActionResult {
ActionResult(success: false, data: nil, error: error)
}
}

private func executeAction(_ action: String, params: [String: Any]) -> ActionResult {
switch action {

case "status":
return getStatus()

case "watch_project":
guard let path = params["path"] as? String else {
return .fail("Missing 'path' parameter")
}
return watchProject(path: path)

case "stop_watching":
return stopWatching()

case "launch_xcode":
return launchXcode()

case "intercept_compiler":
return interceptCompiler()

case "enable_devices":
let enable = params["enable"] as? Bool ?? true
return enableDevices(enable: enable)

case "unhide_symbols":
return unhideSymbols()

case "get_last_error":
return getLastError()

case "prepare_swiftui_source":
return prepareSwiftUISource()

case "prepare_swiftui_project":
return prepareSwiftUIProject()

case "set_xcode_path":
guard let path = params["path"] as? String else {
return .fail("Missing 'path' parameter")
}
return setXcodePath(path: path)

case "get_logs":
let since = params["since"] as? TimeInterval ?? 0
let limit = params["limit"] as? Int ?? 200
return getLogs(since: since, limit: limit)

Comment on lines +231 to +235
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

limit from the client can be negative, and suffix(limit) in LogBuffer.get will trap for negative values. Clamp limit to a non-negative range (e.g. 0...500) and/or reject requests with invalid limit types/values to avoid a local request crashing the app.

Copilot uses AI. Check for mistakes.
case "clear_logs":
return clearLogs()

default:
return .fail("Unknown action: \(action)")
}
}

// MARK: - Actions

private func getStatus() -> ActionResult {
var result = [String: Any]()
DispatchQueue.main.sync {
let delegate = AppDelegate.ui!
result["xcode_running"] = MonitorXcode.runningXcode != nil
result["xcode_path"] = Defaults.xcodePath
result["compiler_intercepted"] = delegate.updatePatchUnpatch() == .patched
result["devices_enabled"] = delegate.enableDevicesItem.state == .on
result["watching_directories"] = Array(AppDelegate.watchers.keys)
result["has_connected_client"] = InjectionServer.currentClient != nil
result["auto_restart_xcode"] = Defaults.xcodeRestart
result["last_error"] = NextCompiler.lastError
}
return .ok(result)
}

private func watchProject(path: String) -> ActionResult {
guard FileManager.default.fileExists(atPath: path) else {
return .fail("Path does not exist: \(path)")
}
DispatchQueue.main.sync {
Reloader.xcodeDev = Defaults.xcodePath + "/Contents/Developer"
AppDelegate.ui.watch(path: path)
}
return .ok(["watching": path])
}

private func stopWatching() -> ActionResult {
DispatchQueue.main.sync {
AppDelegate.watchers.removeAll()
AppDelegate.lastWatched = nil
AppDelegate.ui.watchDirectoryItem.state = .off
}
return .ok()
}

private func launchXcode() -> ActionResult {
DispatchQueue.main.sync {
if MonitorXcode.runningXcode == nil {
_ = MonitorXcode()
}
}
return .ok(["xcode_path": Defaults.xcodePath])
}

private func interceptCompiler() -> ActionResult {
var state = ""
DispatchQueue.main.sync {
let delegate = AppDelegate.ui!
let currentState = delegate.updatePatchUnpatch()
state = currentState == .patched ? "patched" : "unpatched"
}
return .ok(["compiler_state": state,
"note": "Use Xcode UI to toggle interception (requires user confirmation alert)"])
}

private func enableDevices(enable: Bool) -> ActionResult {
DispatchQueue.main.sync {
let delegate = AppDelegate.ui!
let currentlyEnabled = delegate.enableDevicesItem.state == .on
if enable != currentlyEnabled {
delegate.deviceEnable(delegate.enableDevicesItem)
}
}
return .ok(["devices_enabled": enable])
}

private func unhideSymbols() -> ActionResult {
Unhider.startUnhide()
return .ok()
}

private func getLastError() -> ActionResult {
let error = NextCompiler.lastError ?? "No error."
return .ok(["error": error])
}

private func prepareSwiftUISource() -> ActionResult {
guard let lastSource = NextCompiler.lastSource else {
return .fail("No source file currently being edited")
}
DispatchQueue.main.sync {
AppDelegate.ui.prepareSwiftUI(source: lastSource)
}
return .ok(["source": lastSource])
}

private func prepareSwiftUIProject() -> ActionResult {
DispatchQueue.main.sync {
AppDelegate.ui.prepareProject(AppDelegate.ui.patchCompilerItem)
}
return .ok()
}

private func setXcodePath(path: String) -> ActionResult {
guard FileManager.default.fileExists(atPath: path) else {
return .fail("Xcode not found at: \(path)")
}
DispatchQueue.main.sync {
Defaults.xcodeDefault = path
AppDelegate.ui.selectXcodeItem.toolTip = path
AppDelegate.ui.updatePatchUnpatch()
}
return .ok(["xcode_path": path])
}

private func getLogs(since: TimeInterval, limit: Int) -> ActionResult {
let logs = LogBuffer.shared.get(since: since, limit: min(limit, 500))
return .ok(["logs": logs, "count": LogBuffer.shared.count])
}

private func clearLogs() -> ActionResult {
LogBuffer.shared.clear()
return .ok()
}
}
Loading