MacControlMCP

Un servidor MCP que permite a un LLM controlar aplicaciones de macOS y el Simulador de iOS a través de la API de Accesibilidad.

Documentación

MacControlMCP

Un servidor MCP que permite a un LLM controlar aplicaciones de macOS — y el Simulador de iOS — a través de la API de Accesibilidad.

Apunta un cliente MCP (Claude Code, Codex, Gemini/Antigravity, el MCP Inspector, …) a este servidor y el modelo puede resolver o lanzar una aplicación, leer una instantánea compacta de su interfaz con referencias, y luego hacer clic, presionar, escribir, establecer valores, desplazarse, manejar menús y leer el resultado — de la misma manera que lo haría una persona, pero a través de Accesibilidad en lugar de píxeles.

Estado: experimental, y la automatización de macOS es genuinamente difícil (enfoque, Spaces, peculiaridades de Accesibilidad específicas de cada aplicación). Funciona bien para un conjunto creciente de aplicaciones; espera bordes ásperos. Issues y PRs son bienvenidos.


Lo que puede hacer

  • Resolver o lanzar una aplicación por nombre, bundle id, pid o título de ventana — y auto-lanzarla si no está en ejecución.
  • Leer una jerarquía de interfaz compacta — cada elemento obtiene un ref estable (e42), un rol, etiqueta, valor, banderas de estado y las acciones que soporta. Diseñado para ser eficiente en tokens y directamente manejable, no un volcado AX crudo.
  • Manejar elementos por referencia — presionar acciones, clics reales, escribir texto, establecer valores de sliders/steppers, alternar disclosure, desplazarse a la vista, expandir subárboles cargados perezosamente.
  • Ingresar texto de manera robusta — type intenta una inserción directa de Accesibilidad (sin portapapeles), recurre a pulsaciones de teclas sintéticas, luego a un pegado del portapapeles, y te dice qué ruta usó.
  • Manejar la barra de menús por ruta de títulos (File ▸ Export… ▸ PDF…).
  • Gestionar ventanas (mover/redimensionar/minimizar/elevar) y terminar aplicaciones (escalada elegante de SIGHUP → SIGTERM → SIGKILL).
  • Capturar y leer la pantalla — capturas de pantalla (vía ScreenCaptureKit) y OCR en imagen (vía Vision).
  • Manejar el Simulador de iOS vía simctl (abrir URLs, establecer apariencia/barra de estado, lanzar/terminar aplicaciones).
  • Observar cambios — actuar-y-asentarse devuelve un diff en el mismo vocabulario de ref; wait_for consulta activamente una condición.

Requisitos

  • macOS 14 (Sonoma) o posterior, Apple Silicon o Intel.
  • Permiso de Accesibilidad otorgado al host (solicitado en el primer uso).
  • Permiso de Grabación de Pantalla para la herramienta screenshot (solicitado en la primera captura).
  • Instalar desde PyPI no necesita nada más — el wheel incluye una aplicación firmada y notarizada.
  • Para compilar desde el código fuente: Xcode 16+, XcodeGen (brew install xcodegen, o deja que install.sh lo instale) para generar el proyecto desde project.yml, y una identidad de firma Developer ID Application — el servicio XPC Mach del host está limitado al equipo, por lo que la firma ad-hoc no funcionará.

Arquitectura

macOS solo permite que el proceso que recibió el permiso de Accesibilidad realmente lo use — y un cliente MCP (una CLI o aplicación) es el lugar equivocado para mantener ese permiso. Así que el servidor está dividido:

  MCP client  ──stdio──▶  MacControlRelay  ──XPC──▶  MacControlHost (LaunchAgent)
  (Claude/Codex/…)        (tiny forwarder)           (holds the Accessibility grant,
                                                       runs the MCPServer + all tools)
  • MacControlRelay — el pequeño binario stdio que tu cliente MCP lanza. Reenvía JSON-RPC al host a través de un servicio XPC Mach firmado con código y escribe las respuestas de vuelta. Se reconecta de manera transparente (y puede iniciar el host en frío) si es necesario.
  • MacControlHost — un LaunchAgent sin rostro (LSUIElement) que posee los permisos de Accesibilidad / Grabación de Pantalla y ejecuta el MCPServer real con cada herramienta.
  • MacControlRegistrar / MacControlMCP.app — registran el LaunchAgent del host (vía SMAppService) y activan las solicitudes de permisos; la aplicación auto-inicializa la pila en el primer uso.

Por qué esto importa: otorgas Accesibilidad una vez, al host, y cada cliente MCP que lanza el relay la reutiliza. El relay no lleva permisos propios.

Estructura del código fuente (módulos SPM)

MóduloRol
MacControlMCPCoreServidor MCP/JSON-RPC, el esquema compacto de interfaz + leyenda, diffing, temporización de quiescencia, herramientas de simulador y listado de aplicaciones
AXKitel motor de Accesibilidad: envoltorio de elementos, recorrido de árbol, familia de herramientas control_app, resolución de aplicaciones, actuar-y-asentarse
InputKitentrada sintética (clics, teclas, desplazamiento, arrastre, escritura Unicode, pegado)
CaptureKitcapturas de pantalla + OCR
HostKitel servicio host XPC, el cableado completo del servidor y el registro de depuración
MacControlHost / MacControlRelay / MacControlRegistrar / MacControlMCPlos ejecutables / la aplicación

Herramientas

Manejar una aplicación (la superficie principal)

control_app es el punto de entrada: resuelve (o lanza) una aplicación y devuelve una jerarquía compacta con referencias, precedida por una leyenda que explica el formato y los verbos. Todo lo demás opera sobre los refs que devuelve.

HerramientaQué hace
control_app(identity, window?, timeout?, maxLines?, maxChars?)Resolver por nombre/bundle id/pid/título de ventana → árbol con referencias. Auto-lanza si no está en ejecución. La salida está limitada en tamaño (maxChars predeterminado 40k, maxLines predeterminado 1200); cualquier corte se reporta en línea.
launch_app(app, activate?, timeout?)Lanzar por ruta de .app o bundle id, esperar la primera ventana, devolver el árbol.
action(ref, action, refresh?)Realizar una acción AX: press, menu, inc, dec, disclose, collapse, o una etiqueta de acción personalizada.
click(ref, count?, refresh?)Clic real en el elemento (trae su aplicación al frente). count:2 hace doble clic.
type(text, ref?, via?, refresh?)Ingresar texto: inserción AX directa → pulsaciones de teclas → respaldo de pegado del portapapeles. Reporta via y focused.
change_text(ref, value, refresh?)Establecer el valor de texto de un campo semánticamente (sin pulsaciones de teclas).
change_value(ref, value, refresh?)Establecer un control numérico (slider/scrollbar/stepper), con límites aplicados.
focus_keyboard(ref, observe?)Dar enfoque de teclado a un elemento (sin clic, no disruptivo).
reveal(ref, observe?)Desplazar un elemento a la vista.
expand(ref, timeout?) / refresh(ref, timeout?)Cargar perezosamente [N hidden] descendientes / re-leer un subárbol.
window(ref, action, …)move / resize / minimize / unminimize / raise.
menu_pick(pid, path, observe?)Manejar la barra de menús por ruta de títulos, p. ej. ["File","New"].
find_elements(pid, role?, titleContains?, identifier?, value?, actionable?, limit?)Buscar en el árbol referencias coincidentes sin re-leerlo todo.
element_detail(ref)Atributos completos / acciones / atributos parametrizados para una referencia.
focused_element() / element_at(x, y)El elemento enfocado / prueba de impacto en un punto de pantalla.
get_changes(pid, depth?)Diff de la interfaz de la aplicación contra la última instantánea (añadido/eliminado/cambiado por referencia).
wait_for(pid, mode, …)Consultar hasta que idle / appears / disappears.
kill(identity, signal?)Terminar por pid/nombre/bundle id; escalada predeterminada SIGHUP → SIGTERM → SIGKILL.

Entrada sintética (coordenadas crudas / teclas)

click_point(x, y, …), scroll(dy, dx?), key(keys), hover(x, y), drag(fromX, fromY, toX, toY) — nivel de coordenadas/pulsaciones de teclas. Prefiere los verbos basados en referencias anteriores; recurre a estos solo cuando tengas una coordenada explícita.

Captura, descubrimiento, simulador

screenshot(target, …), ocr(path), list_running_apps(), list_simulators(), sim(action, …), open(target, application?, background?, newInstance?).

open es el equivalente sin permisos de hacer doble clic en Finder: abre un archivo, carpeta, URL o aplicación vía /usr/bin/open. El objetivo se pasa como un elemento literal de la matriz de argumentos (nunca a través de un shell), y la forma de opción (-u para URLs, -a/-b para aplicaciones, -- antes de operandos de archivo) evita que un objetivo que comienza con - se lea como una bandera.


Instalación

Desde PyPI (sin Xcode, sin Developer ID)

uvx drews-mac-control-mcp --setup

El wheel incluye la aplicación firmada y notarizada. Se instala en ~/Applications en el primer uso, la abre para que macOS muestre las solicitudes de permisos e imprime el comando de registro del cliente.

Aprueba ambas solicitudes. La primera permite un nuevo elemento de fondo — ese es el agente host, y descartarlo deja al host incapaz de iniciarse, con cada llamada de herramienta posterior fallando sin explicación (re-habilítalo en Configuración del Sistema ▸ General ▸ Elementos de Inicio de Sesión y Extensiones). La segunda otorga Accesibilidad, además de Grabación de Pantalla si quieres capturas de pantalla.

Las versiones posteriores reemplazan la aplicación instalada por sí mismas: el envoltorio compara la versión que lleva contra la de ~/Applications en cada lanzamiento, por lo que una actualización de uvx trae la aplicación consigo. Nada en esta ruta necesita Xcode o una identidad de firma propia.

Requiere macOS 14 o posterior. El wheel está etiquetado como macosx_14_0_universal2, por lo que pip y uv se niegan a instalarlo en cualquier otro lugar.

Con Homebrew

brew install --cask drewster99/tap/maccontrol-mcp

La misma aplicación, instalada en /Applications en lugar de ~/Applications.

Las dos compilaciones son productos separados, no dos copias de uno: la compilación de ~/Applications lleva un sufijo de .user en sus identificadores de bundle, su etiqueta de LaunchAgent y su servicio Mach. Instala ambos si quieres — ninguno nota al otro. El costo es que macOS ve dos aplicaciones, por lo que cada una necesita su propio permiso de Accesibilidad (y Grabación de Pantalla). Consulta CLAUDE.md para saber por qué.

Cada release de GitHub también incluye MacControlMCP-<version>.zip para una instalación manual, y un SHA256SUMS que cubre cada descarga.

Actualización

uvx --refresh drews-mac-control-mcp                   # PyPI (or just wait ~10 min for it to self-update)
brew upgrade --cask drewster99/tap/maccontrol-mcp     # Homebrew
./install.sh                                          # from a source checkout

El envoltorio de PyPI se auto-actualiza por sí solo — en cada lanzamiento re-verifica el índice de PyPI (cacheado ~10 min) y cambia ~/Applications cuando aparece un wheel más nuevo; --refresh solo lo fuerza ahora. Homebrew y install.sh reemplazan la compilación de /Applications en su lugar. Cualquiera de estos surte efecto la próxima vez que tu cliente MCP genere el servidor, así que reconéctalo después (p. ej. /mcp reconnect maccontrol en Claude Code).

Desinstalación

uvx drews-mac-control-mcp --uninstall          # PyPI install
brew uninstall --cask drewster99/tap/maccontrol-mcp   # Homebrew install

Ambos eliminan el registro del agente host antes de borrar la aplicación, que es el único orden que funciona: borrar el bundle por sí solo deja el registro como un elemento de inicio de sesión que apunta a nada, localizable solo a mano en Configuración del Sistema. La aplicación expone --unregister-and-exit para exactamente esto, por lo que ninguna ruta necesita la GUI.


Compilar e instalar desde el código fuente

Un comando

./install.sh

Eso es todo. install.sh genera el proyecto Xcode, compila la aplicación Release, la firma con tu Developer ID, la instala en /Applications, la lanza (lo que registra el LaunchAgent del host y activa las solicitudes de permisos de macOS) y registra el relay con cualquier cliente MCP (claude, codex) que encuentre en tu PATH. Cuando termine, otorga Accesibilidad (y Grabación de Pantalla para capturas de pantalla) si no se te solicitó antes, y estás listo.

Prerrequisitos: macOS 14+, Xcode 16+, XcodeGen y una identidad de firma Developer ID Application en tu llavero — el servicio XPC Mach del host está limitado al equipo, por lo que la firma ad-hoc no funcionará. Si XcodeGen falta, el script ofrece brew install xcodegen por ti (--install-deps acepta de antemano, para ejecuciones sin supervisión).

Banderas útiles:

./install.sh --notarize              # also notarize + staple (for distribution; needs a notarytool profile)
./install.sh --identity "Developer ID Application: …"   # pick a specific signing identity
./install.sh --clients claude        # only register Claude Code (or: codex / none / claude,codex)
./install.sh --prefix ~/Applications # install somewhere other than /Applications
./install.sh --no-launch             # build + install but don't open the app
./install.sh --install-deps          # install missing deps (xcodegen, via Homebrew) without asking
./install.sh --help                  # all options

Compilación manual

El script orquesta los mismos pasos que puedes ejecutar a mano:

./scripts/generate.sh      # write the generated sources, then generate the .xcodeproj
# build the Release scheme in Xcode, then sign (+ notarize) the result:
./notarize-app.sh          # produces a signed, notarized dist/MacControlMCP.app
cp -R dist/MacControlMCP.app /Applications/
open /Applications/MacControlMCP.app

notarize-app.sh es el bucle interno rápido — firma y notariza el bundle que Xcode ya compiló, en lugar de recompilar desde cero como hacen install.sh y scripts/build-release.sh.

Los tres delegan la firma en sí a scripts/sign-app.sh, que es el único lugar que conoce el orden interno, el identificador explícito del relay y las comprobaciones que se interponen entre una compilación y un rechazo de notarización — runtime endurecido, timestamp seguro, autoridad de Developer ID Application, sin get-task-allow, sin symlinks. Ejecútalo directamente contra cualquier bundle compilado:

./scripts/sign-app.sh --app dist/system/MacControlMCP.app             # signed for distribution
./scripts/sign-app.sh --app dist/system/MacControlMCP.app --no-timestamp   # offline/local

Deriva el identificador de firma del relay del propio bundle, por lo que firma el relay de una compilación de .user bajo el identificador que el host de esa compilación realmente requiere — equivocarse en esto solo se manifiesta como un "host no disponible" inexplicable en el primer uso.

scripts/gen-identity.sh escribe la identidad contra la que compila una compilación (--variant system|user) e imprime el IDENTITY_SUFFIX para pasar a xcodebuild. Es el único lugar donde se definen los identificadores; todo lo demás se deriva de él. Para una verificación rápida solo de los objetivos de biblioteca/binario (sin el paquete de aplicación, no retendrá la concesión de Accesibilidad):

swift build            # debug build of all targets
swift test             # run the unit tests

La firma/notarización usa por defecto una identidad de desarrollador Nuclear Cyborg y un perfil de llavero notarytool. Anula la identidad con --identity / CODESIGN_IDENTITY y el perfil con --profile / NOTARY_PROFILE. Los forks también deben cambiar los identificadores de paquete y el prefijo de equipo en packaging/host.launchagent.plist.

Publicar una versión

scripts/build-release.sh es la otra mitad de install.sh: los mismos pasos de compilación y firma, pero empaqueta el paquete notarizado en la rueda de PyPI en lugar de instalarlo localmente.

./scripts/build-release.sh                       # build + verify, publish nothing
./scripts/build-release.sh --publish --testpypi  # rehearse on TestPyPI
./scripts/build-release.sh --publish             # PyPI + git tag + GitHub release
./scripts/build-release.sh --help                # all options

Incrementa la versión de forma sincronizada en AppVersion.swift, project.yml y python/pyproject.toml, ejecuta las pruebas y luego compila dos veces — una por identidad de instalación, ya que las compilaciones de /Applications y ~/Applications son productos diferentes. Cada compilación se verifica (ambos segmentos de arquitectura, el plist incrustado y que lleva la identidad que declara), se firma y se notariza; la rueda recibe la compilación de usuario y el archivo cask la del sistema. Finalmente extrae la aplicación de la rueda terminada para confirmar que aún se verifica y se sella. Nada llega a PyPI, GitHub o origin sin --publish y un mensaje de confirmación.

La publicación produce tres artefactos de lanzamiento — la rueda, MacControlMCP-<version>.zip y SHA256SUMS — sube la rueda a PyPI y actualiza el cask de Homebrew en drewster99/homebrew-tap (--skip-tap opta por no participar). El cask se regenera a partir del digest del propio lanzamiento y se lee de vuelta desde el tap para confirmar que apunta a la etiqueta que se acaba de cortar.

Registrarse con un cliente MCP

Lo que registres depende de cómo instalaste, y la diferencia decide si recibes actualizaciones.

Instalado desde PyPI — registra el wrapper, no el relay.

claude mcp add --scope user maccontrol -- "$(command -v uvx)" drews-mac-control-mcp
codex  mcp add maccontrol -- "$(command -v uvx)" drews-mac-control-mcp

El wrapper verifica en cada lanzamiento que la aplicación en ~/Applications coincida con la versión que lleva su rueda, y la reemplaza si no es así. uvx proporciona la otra mitad: almacena en caché el índice de PyPI durante los diez minutos que PyPI solicita (cache-control: max-age=600) y vuelve a resolver una vez que expira, por lo que una nueva versión se detecta por sí sola — en minutos, no necesariamente en la siguiente llamada. uvx --refresh drews-mac-control-mcp lo fuerza inmediatamente.

Apuntar un cliente a la ruta del relay funciona hoy y nunca se actualiza de nuevo.

Instalado vía Homebrew o install.sh — registra el relay directamente, ya que brew upgrade y install.sh son los que lo actualizan:

claude mcp add --scope user maccontrol /Applications/MacControlMCP.app/Contents/Helpers/MacControlRelay
codex  mcp add maccontrol -- /Applications/MacControlMCP.app/Contents/Helpers/MacControlRelay

install.sh registra ambos clientes por ti, y uvx drews-mac-control-mcp --setup imprime los comandos para su propio canal. Cualquier cliente MCP que lance un servidor stdio funciona de la misma manera.


Registro

El relay y el host agregan ambos a una sola línea de tiempo en:

~/Library/Logs/MacControlMCP/maccontrol.log

Registra inicio / conexión / desconexión más cada solicitud y respuesta en su totalidad (cada línea etiquetada [process:pid], protegida por flock para que los dos procesos nunca se intercalen). Siempre activo; establece MACCONTROL_LOG=0 para deshabilitar o MACCONTROL_LOG_PATH=/abs/file para redirigir. Cada línea de inicio incluye la marca de tiempo de compilación del binario para que puedas confirmar qué compilación está activa.


Versionado

Hay una sola versión que incrementar, declarada en tres archivos que la compilación mantiene sincronizados:

  • Sources/MacControlMCPCore/AppVersion.swift — la fuente de verdad compilada que informa cada componente (la línea "Versión" de la GUI, el initialize serverInfo.version de MCP y la identidad de compilación del registro de inicio).
  • project.yml — MARKETING_VERSION / CURRENT_PROJECT_VERSION, que alimentan los CFBundleShortVersionString / CFBundleVersion de los paquetes nativos.
  • python/pyproject.toml — la versión de la rueda, para que uvx drews-mac-control-mcp y la aplicación que instala nunca sean dos historias diferentes.

install.sh y scripts/build-release.sh incrementan los tres por ti; configurarlos manualmente significa establecer los tres al mismo valor y luego volver a ejecutar xcodegen generate. Una fase de compilación previa "Verificar versión" falla la compilación si la constante Swift y project.yml no coinciden, y el script de lanzamiento vuelve a leer cada archivo que escribió — un sed que no coincide con nada aún sale con 0.

La ventana de configuración de la aplicación muestra su propia versión y, consultando el host en ejecución a través de XPC, la versión del agente en vivo — señalando una discrepancia (por ejemplo, un host obsoleto dejado registrado por una instalación anterior). La verificación de la versión del agente necesita la compilación firmada para satisfacer el requisito de llamador del host; una compilación de desarrollo sin firmar mostrará el agente como "no alcanzable".


Limitaciones conocidas

  • La accesibilidad es por Espacio. AX enumera ventanas en el Espacio actual de Mission Control; una aplicación cuyas ventanas están en otro Espacio (o con la pantalla dormida) puede informar cero ventanas aunque existan.
  • Las pulsaciones de teclas sintéticas llegan a la aplicación que tiene el foco de teclado; macOS 14 no permite que una herramienta en segundo plano robe el foco de teclado, por eso type(ref) hace clic en el campo primero y recurre a un pegado de portapapeles para vistas de texto de AppKit.
  • La cobertura de AX específica de la aplicación varía — Catalyst, Electron y contenido web exponen árboles diferentes (a veces escasos). Consulta docs/ para las notas de diseño y docs/ROADMAP.md para lo que está diferido.

Licencia

Apache License 2.0.