* notes / swift
IPC y XPC en Swift: cómo construí un MCP para mi editor de vídeo
— Comunicación entre procesos en macOS: del NSXPCConnection a un servidor MCP que expone acciones de edición de vídeo a Claude.
La comunicación entre procesos. El editor vivía en su propio sandbox, el servidor MCP corría como proceso auxiliar, y ambos necesitaban dialogar con garantías de seguridad y latencia. Ahí emergió XPC como protagonista.
Contexto: por qué XPC y no IPC «a pelo»
En macOS conviven varias primitivas para que dos procesos intercambien información: pipes anónimos, sockets de dominio Unix, Mach ports, distributed notifications, e incluso protocolos sobre TCP loopback. Funcionan, sí, pero acarrean una cantidad considerable de plomería: serialización manual, gestión del ciclo de vida, manejo de errores y, lo más delicado, ninguna integración natural con el modelo de sandboxing de Apple.
XPC, por el contrario, fue diseñado precisamente para este escenario. Es un mecanismo de IPC de alto nivel que ofrece:
- Separación de privilegios: cada servicio XPC corre en su propio proceso con su propio sandbox, con entitlements explícitos.
- Lanzamiento bajo demanda:
launchdinstancia el servicio cuando hay un cliente conectado y lo detiene cuando deja de haberlo. - Interfaz tipada: a través de
NSXPCInterfacese publica un protocolo Objective-C/Swift que actúa como contrato entre los procesos. - Codificación transparente: los argumentos viajan vía
NSSecureCodingsin escribir un soloencode/decodemanual.
Cabe destacar que XPC no es solo para servicios incrustados en .app/Contents/XPCServices/. Desde macOS Big Sur disponemos de NSXPCListener con Mach service registrado en launchd, lo cual permite que un daemon (en mi caso, el bridge MCP) exponga una interfaz consumible por la app del editor.
Topología del sistema
El diseño quedó así: la app del editor mantiene el estado de la línea de tiempo; el servicio XPC traduce mensajes; el servidor MCP habla con Claude mediante stdio. Tres procesos, dos saltos.
El contrato: protocolo XPC
La piedra angular de XPC es el protocolo. Aquí defino qué puede pedírsele al servicio. Conviene marcarlo @objc porque la maquinaria de proxying se apoya todavía en el runtime de Objective-C.
import Foundation
@objc public protocol VideoEditingXPCProtocol {
func listAvailableTools(reply: @escaping ([ToolDescriptor]) -> Void)
func cutClip(
clipID: String,
atSeconds time: Double,
reply: @escaping (Bool, NSError?) -> Void
)
func exportTimeline(
toURL url: URL,
preset: String,
reply: @escaping (Bool, NSError?) -> Void
)
}El servicio publica un NSXPCListener. Tanto el lado del servidor como el del cliente comparten el mismo .swift que declara el protocolo, lo que evita derivas de tipos.
final class VideoEditingService: NSObject, NSXPCListenerDelegate, VideoEditingXPCProtocol {
func listener(_ listener: NSXPCListener,
shouldAcceptNewConnection connection: NSXPCConnection) -> Bool {
connection.exportedInterface = NSXPCInterface(with: VideoEditingXPCProtocol.self)
connection.exportedObject = self
connection.resume()
return true
}
func cutClip(clipID: String, atSeconds time: Double,
reply: @escaping (Bool, NSError?) -> Void) {
// Aquí delego en el motor de edición real (AVMutableComposition).
do {
try TimelineStore.shared.cut(clipID: clipID, at: time)
reply(true, nil)
} catch {
reply(false, error as NSError)
}
}
// ...
}Del lado del editor establezco la conexión hacia el Mach service registrado por launchd:
let connection = NSXPCConnection(machServiceName: "dev.akzu.editor.helper",
options: .privileged)
connection.remoteObjectInterface = NSXPCInterface(with: VideoEditingXPCProtocol.self)
connection.invalidationHandler = { print("XPC invalidado") }
connection.interruptionHandler = { print("XPC interrumpido") }
connection.resume()
let proxy = connection.remoteObjectProxyWithErrorHandler { error in
NSLog("Fallo de proxy: %@", error.localizedDescription)
} as? VideoEditingXPCProtocol
proxy?.cutClip(clipID: "clip-42", atSeconds: 12.5) { ok, err in
print("Resultado:", ok, err ?? "-")
}No obstante, hay sutilezas que conviene subrayar. Primera: los callbacks se ejecutan en colas internas del runtime XPC, de manera que cualquier mutación de UI requiere un salto explícito a MainActor. Segunda: si el cliente abandona el proxy sin cancelarlo, launchd mantendrá el servicio vivo durante un tiempo de gracia; conviene invalidar la conexión para liberar recursos en cuanto el agente termine la sesión.
El puente con MCP
El servidor MCP propiamente dicho es un binario ligero que habla JSON-RPC por stdio con el cliente (en mi caso, la app de Claude Desktop). Cada herramienta que expongo es, esencialmente, una llamada XPC envuelta.
mcp.registerTool(
name: "video.cut_clip",
description: "Corta un clip de la línea de tiempo en el segundo indicado.",
schema: [
"clipId": .string,
"time": .number
]
) { args, ctx in
guard let id = args["clipId"] as? String,
let t = args["time"] as? Double else {
throw MCPError.invalidArguments
}
return try await withCheckedThrowingContinuation { cont in
proxy?.cutClip(clipID: id, atSeconds: t) { ok, err in
if let err { cont.resume(throwing: err) }
else { cont.resume(returning: ["ok": ok]) }
}
}
}Asimismo, el description no es prosa decorativa: Claude lo lee para decidir cuándo invocar la herramienta. Aprendí a redactar descripciones con verbos directos y aclarando precondiciones; un texto vago provoca llamadas espurias.
Hallazgos que no figuran en la documentación
- Los entitlements son no negociables. El editor está sandboxed y necesité
com.apple.security.temporary-exception.mach-lookup.global-namepara hablar con el helper, además de firmar ambos binarios con el mismo Team ID; cualquier desajuste y la conexión se invalida sin diagnóstico claro. NSSecureCodingpor defecto en todas las clases personalizadas. Si una tabla de propiedades incluye un tipo no codificable, XPC rompe la conexión silenciosamente. Acabé adoptando estructuras simples (Codable+ serialización aData) para payloads complejos.- El reintento no es gratis.
invalidationHandlerse dispara una sola vez; tras ello hay que reconstruir laNSXPCConnection. Implementé un pequeño circuit breaker con tres reintentos y backoff exponencial. - Mediciones de latencia: en mi máquina (M2 Pro) un ida y vuelta XPC ronda los 0,2 ms con payloads pequeños. Es órdenes de magnitud inferior a la latencia de Claude, por lo que el cuello de botella jamás está en el bus.