Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
10 changes: 9 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,15 @@ jobs:
run: swift format lint --strict --recursive .

- name: Build
run: xcodebuild -quiet -scheme iMCP -configuration Debug -destination "platform=macOS" build
run: |
xcodebuild -quiet \
-scheme iMCP \
-configuration Debug \
-destination "platform=macOS" \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO \
HELPER_CODE_SIGNING_ALLOWED=NO \
build

# The cache action's exclude pattern has not kept the module cache out of
# the archive, and runner images with the same Xcode build can differ in
Expand Down
11 changes: 11 additions & 0 deletions App/Controllers/ServerController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ enum ServiceRegistry {
CalendarService.shared,
CaptureService.shared,
ContactsService.shared,
HomeService.shared,
LocationService.shared,
MapsService.shared,
MessageService.shared,
Expand All @@ -71,6 +72,7 @@ enum ServiceRegistry {
calendarEnabled: Binding<Bool>,
captureEnabled: Binding<Bool>,
contactsEnabled: Binding<Bool>,
homeEnabled: Binding<Bool>,
locationEnabled: Binding<Bool>,
mapsEnabled: Binding<Bool>,
messagesEnabled: Binding<Bool>,
Expand Down Expand Up @@ -102,6 +104,13 @@ enum ServiceRegistry {
service: ContactsService.shared,
binding: contactsEnabled
),
ServiceConfig(
name: "Home",
iconName: "house.fill",
color: .orange,
service: HomeService.shared,
binding: homeEnabled
),
ServiceConfig(
name: "Location",
iconName: "location.fill",
Expand Down Expand Up @@ -177,6 +186,7 @@ final class ServerController: ObservableObject {
@AppStorage("calendarEnabled") private var calendarEnabled = false
@AppStorage("captureEnabled") private var captureEnabled = false
@AppStorage("contactsEnabled") private var contactsEnabled = false
@AppStorage("homeEnabled") private var homeEnabled = false
@AppStorage("locationEnabled") private var locationEnabled = false
@AppStorage("mapsEnabled") private var mapsEnabled = true // Default enabled
@AppStorage("messagesEnabled") private var messagesEnabled = false
Expand All @@ -199,6 +209,7 @@ final class ServerController: ObservableObject {
calendarEnabled: $calendarEnabled,
captureEnabled: $captureEnabled,
contactsEnabled: $contactsEnabled,
homeEnabled: $homeEnabled,
locationEnabled: $locationEnabled,
mapsEnabled: $mapsEnabled,
messagesEnabled: $messagesEnabled,
Expand Down
1 change: 1 addition & 0 deletions App/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
<key>NSBonjourServices</key>
<array>
<string>_mcp._tcp</string>
<string>_imcp-helper._tcp</string>
</array>
<key>SUEnableInstallerLauncherService</key>
<true/>
Expand Down
258 changes: 258 additions & 0 deletions App/Services/Home.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
import AppKit
import Darwin
import MCP
import Network
import OSLog

/// Serves the Home tools by proxying to the Catalyst helper that holds HomeKit access.
///
/// HomeKit is not available to native macOS apps,
/// so the helper runs as a separate app in `Contents/Helpers`
/// and implements the same tool definitions from `HomeTools`.
/// This service launches the helper, connects to it over the loopback interface,
/// and forwards each call as an MCP `tools/call` request.
actor HomeService: Service, HomeBackend {
static let shared = HomeService()

private let log = Logger.service("home")
private let helperURL: URL?
private var client: MCP.Client?
private var connection: NWConnection?
private var connecting: Task<Void, Error>?
private var authorized = false
private var launchedHelper: LaunchedHelper?

private struct LaunchedHelper: Sendable {
let pid: pid_t
let endpoint: NWEndpoint
}

init(helperURL: URL? = nil) { self.helperURL = helperURL }

// MARK: - Service

nonisolated var tools: [Tool] { return HomeTools.tools(backend: self) }

var isActivated: Bool { authorized && connection?.state == .ready }

func activate() async throws {
if isActivated { return }
if let connecting { return try await connecting.value }
let task = Task { try await self.connect() }
connecting = task
defer { connecting = nil }
try await task.value
}

// MARK: - HomeBackend

func call(_ tool: String, _ input: [String: Value]) async throws -> Value {
try await activate()
guard let active = client else { throw HomeError("iMCP Helper is not connected.") }
do { return try await forward(active, tool, input) } catch let error as HomeError { throw error } catch {
if client === active {
authorized = false
client = nil
connection?.cancel()
connection = nil
await active.disconnect()
}
try await activate()
// A lost response does not prove that a write failed. Never duplicate a write.
guard tools.first(where: { $0.name == tool })?.annotations.readOnlyHint == true
else {
throw HomeError(
"The iMCP Helper connection was lost. The write may have completed. Inspect the home before retrying."
)
}
guard let client else { throw HomeError("iMCP Helper could not reconnect.") }
return try await forward(client, tool, input)
}
}

// MARK: - Connection

private func forward(_ client: MCP.Client, _ tool: String, _ input: [String: Value]) async throws -> Value {
let connection = self.connection
let monitor = Task {
guard let connection else { return }
while !Task.isCancelled {
do { try await Task.sleep(for: .milliseconds(250)) } catch { return }
switch connection.state {
case .failed, .cancelled, .waiting:
await client.disconnect()
return
default: break
}
}
}
defer { monitor.cancel() }
let result = try await client.callTool(name: tool, arguments: input)
let texts = result.content.compactMap { item -> String? in
if case .text(let text, _, _) = item { return text }
return nil
}
if result.isError == true { throw HomeError(texts.joined(separator: "\n")) }
guard let text = texts.first, let data = text.data(using: .utf8) else {
throw HomeError("iMCP Helper returned no JSON text.")
}
do { return try JSONDecoder().decode(Value.self, from: data) } catch {
throw HomeError("iMCP Helper returned invalid JSON: \(error.localizedDescription)")
}
}

private func connect() async throws {
authorized = false
let old = client
client = nil
connection?.cancel()
connection = nil
await old?.disconnect()
launchedHelper = try await launchHelper(previous: launchedHelper, overrideURL: helperURL)
let parameters = NWParameters.tcp
parameters.acceptLocalOnly = true
parameters.requiredInterfaceType = .loopback
parameters.includePeerToPeer = false
if let ip = parameters.defaultProtocolStack.internetProtocol as? NWProtocolIP.Options { ip.version = .v4 }
let endpoint: NWEndpoint
if let launchedHelper {
endpoint = launchedHelper.endpoint
} else {
let browser = NWBrowser(for: .bonjour(type: "_imcp-helper._tcp", domain: "local."), using: .tcp)
do {
endpoint = try await BonjourDiscovery.discoverEndpoint(
using: browser,
timeout: .seconds(15),
preferring: { String(describing: $0.endpoint).contains("iMCP Helper") }
)
} catch {
throw HomeError(
"iMCP Helper was not found. Quit any manually started iMCP Helper and enable Home again. \(error.localizedDescription)"
)
}
}
let deadline = Date().addingTimeInterval(15)
repeat {
if let launchedHelper { try await checkHelperIsRunning(launchedHelper) }
do {
try await connect(to: endpoint, parameters: parameters)
return
} catch let error as HomeError { throw error } catch {
guard let launchedHelper else { throw error }
try await checkHelperIsRunning(launchedHelper)
guard Date() < deadline else {
throw HomeError(
"iMCP Helper did not accept a local connection within 15 seconds. Check its status window, then retry."
)
}
try await Task.sleep(for: .milliseconds(250))
}
} while true
}

private func connect(to endpoint: NWEndpoint, parameters: NWParameters) async throws {
let connection = NWConnection(to: endpoint, using: parameters)
let transport = NetworkTransport(
connection: connection,
heartbeatConfig: .init(enabled: false),
reconnectionConfig: .disabled,
bufferConfig: .unlimited
)
let client = MCP.Client(name: "iMCP", version: Bundle.main.shortVersionString ?? "unknown")
let timeout = Task {
// NetworkTransport waits indefinitely in NWConnection.waiting.
// Retry refused connections while the helper starts.
for _ in 0 ..< 80 {
do { try await Task.sleep(for: .milliseconds(250)) } catch { return }
if case .waiting = connection.state { break }
}
connection.cancel()
await client.disconnect()
}
defer { timeout.cancel() }
do {
let result = try await client.connect(transport: transport)
guard result.serverInfo.name == "iMCP Helper" else {
throw HomeError("The selected endpoint is not iMCP Helper.")
}
_ = try await forward(client, "homes_list", [:])
self.client = client
self.connection = connection
authorized = true
log.info("Connected to iMCP Helper")
} catch {
connection.cancel()
await client.disconnect()
throw error
}
}

// MARK: - Helper Launch

@MainActor
private func checkHelperIsRunning(_ helper: LaunchedHelper) throws {
guard helper.pid > 0,
let application = NSRunningApplication(processIdentifier: helper.pid),
!application.isTerminated
else {
throw HomeError(
"iMCP Helper exited before the connection was ready. Check its crash report in Console, then retry."
)
}
}

@MainActor
private func launchHelper(previous: LaunchedHelper?, overrideURL: URL?) async throws -> LaunchedHelper? {
if let running = NSWorkspace.shared.runningApplications.first(where: {
$0.bundleIdentifier == "co.dododo.iMCP.Helper" && !$0.isTerminated
}) {
return previous?.pid == running.processIdentifier ? previous : nil
}
let embedded = Bundle.main.bundleURL.appendingPathComponent("Contents/Helpers/iMCP Helper.app")
let url =
overrideURL
?? (FileManager.default.fileExists(atPath: embedded.path)
? embedded : NSWorkspace.shared.urlForApplication(withBundleIdentifier: "co.dododo.iMCP.Helper"))
guard let url else { return nil }
let port = try availableLoopbackPort()
let configuration = NSWorkspace.OpenConfiguration()
configuration.activates = false
// Xcode's native macOS debugger libraries cannot load in a Catalyst app.
// Empty values override the launch environment; omitting the keys does not.
configuration.environment = [
"DYLD_INSERT_LIBRARIES": "",
"DYLD_LIBRARY_PATH": "",
"DYLD_FRAMEWORK_PATH": "",
]
configuration.arguments = ["--parent-pid", String(getpid()), "--port", String(port)]
let application = try await NSWorkspace.shared.openApplication(at: url, configuration: configuration)
return LaunchedHelper(
pid: application.processIdentifier,
endpoint: .hostPort(host: .ipv4(.loopback), port: NWEndpoint.Port(rawValue: port)!)
)
}

/// Selects an ephemeral loopback port for the child process.
/// The helper reports a bind error if another process takes it before launch.
private nonisolated func availableLoopbackPort() throws -> UInt16 {
let descriptor = socket(AF_INET, SOCK_STREAM, 0)
guard descriptor >= 0 else { throw HomeError("Cannot allocate a socket for iMCP Helper.") }
defer { close(descriptor) }
var address = sockaddr_in()
address.sin_len = UInt8(MemoryLayout<sockaddr_in>.size)
address.sin_family = sa_family_t(AF_INET)
address.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1"))
let result = withUnsafePointer(to: &address) { pointer in
pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) {
bind(descriptor, $0, socklen_t(MemoryLayout<sockaddr_in>.size))
}
}
guard result == 0 else { throw HomeError("Cannot bind a socket for iMCP Helper.") }
var length = socklen_t(MemoryLayout<sockaddr_in>.size)
let nameResult = withUnsafeMutablePointer(to: &address) { pointer in
pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { getsockname(descriptor, $0, &length) }
}
guard nameResult == 0 else { throw HomeError("Cannot select a port for iMCP Helper.") }
return UInt16(bigEndian: address.sin_port)
}
}
3 changes: 2 additions & 1 deletion CLI/MCPService.swift
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import Foundation
import Logging
import MCP
import Network
import ServiceLifecycle

private let serviceType = "_mcp._tcp"
private let serviceType = ProcessInfo.processInfo.environment["IMCP_SERVICE_TYPE"] ?? "_mcp._tcp"

private let parameters: NWParameters = {
let parameters = NWParameters.tcp
Expand Down
40 changes: 40 additions & 0 deletions CLITests/HomeProxyTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import AppKit
import XCTest

final class HomeProxyTests: XCTestCase {
@MainActor
func testLiveLaunchAndRecovery() async throws {
guard let path = ProcessInfo.processInfo.environment["IMCP_HELPER_PATH"] else {
throw XCTSkip("Set IMCP_HELPER_PATH to run the signed helper integration test.")
}
let backend = HomeService(helperURL: URL(fileURLWithPath: path))
let initial = await backend.isActivated
XCTAssertFalse(initial)
try await backend.activate()
let active = await backend.isActivated
XCTAssertTrue(active)
let homes = try await backend.call("homes_list", [:])
XCTAssertNotEqual(homes, .null)
do {
_ = try await backend.call("accessories_get", ["accessory": "invalid"])
XCTFail("The helper must report invalid accessory IDs.")
} catch let error as HomeError {
XCTAssertFalse(error.localizedDescription.isEmpty)
}
let helper = try XCTUnwrap(
NSWorkspace.shared.runningApplications.first {
$0.bundleIdentifier == "co.dododo.iMCP.Helper"
}
)
XCTAssertTrue(helper.forceTerminate())
for _ in 0 ..< 50 {
if helper.isTerminated { break }
try await Task.sleep(for: .milliseconds(100))
}
XCTAssertTrue(helper.isTerminated)
let recovered = try await backend.call("homes_list", [:])
XCTAssertEqual(recovered, homes)
let reactivated = await backend.isActivated
XCTAssertTrue(reactivated)
}
}
Loading
Loading