MacControlMCP

Um servidor MCP que permite que um LLM controle aplicativos macOS e o Simulador iOS por meio da API de Acessibilidade.

Documentação

MacControlMCP

Um servidor MCP que permite que um LLM controle aplicativos macOS — e o Simulador iOS — através da API de Acessibilidade.

Aponte um cliente MCP (Claude Code, Codex, Gemini/Antigravity, o MCP Inspector, …) para este servidor e o modelo pode resolver ou iniciar um aplicativo, ler um snapshot compacto e com referências da sua interface, e então clicar, pressionar, digitar, definir valores, rolar, navegar por menus e ler o resultado de volta — da mesma forma que uma pessoa faria, mas via Acessibilidade em vez de pixels.

Status: experimental, e automação de macOS é genuinamente difícil (foco, Spaces, peculiaridades de Acessibilidade específicas de cada aplicativo). Funciona bem para um conjunto crescente de aplicativos; espere arestas. Issues e PRs são bem-vindos.


O que ele pode fazer

  • Resolver ou iniciar um aplicativo por nome, bundle id, pid ou título de janela — e iniciá-lo automaticamente se não estiver em execução.
  • Ler uma hierarquia de interface compacta — cada elemento recebe um ref estável (e42), um papel, rótulo, valor, flags de estado e as ações que suporta. Projetado para ser eficiente em tokens e diretamente acionável, não um dump bruto de AX.
  • Acionar elementos por referência — ações de pressionar, cliques reais, digitar texto, definir valores de slider/stepper, alternar disclosure, rolar para a visualização, expandir subárvores carregadas lentamente.
  • Inserir texto de forma robusta — type tenta uma inserção direta de Acessibilidade (sem área de transferência), recorre a teclas sintéticas e, em seguida, a uma colagem da área de transferência, e informa qual caminho usou.
  • Navegar pela barra de menus por caminho de título (File ▸ Export… ▸ PDF…).
  • Gerenciar janelas (mover/redimensionar/minimizar/elevar) e encerrar aplicativos (escalonamento gracioso SIGHUP → SIGTERM → SIGKILL).
  • Capturar e ler a tela — capturas de tela (via ScreenCaptureKit) e OCR na imagem (via Vision).
  • Controlar o Simulador iOS via simctl (abrir URLs, definir aparência/barra de status, iniciar/encerrar aplicativos).
  • Observar mudanças — agir-e-assentar retorna um diff no mesmo vocabulário ref; wait_for consulta ativamente por uma condição.

Requisitos

  • macOS 14 (Sonoma) ou posterior, Apple Silicon ou Intel.
  • Permissão de Acessibilidade concedida ao host (solicitada no primeiro uso).
  • Permissão de Gravação de Tela para a ferramenta screenshot (solicitada na primeira captura).
  • Instalar via PyPI não precisa de mais nada — o wheel carrega um aplicativo assinado e notarizado.
  • Para compilar a partir do código-fonte: Xcode 16+, XcodeGen (brew install xcodegen, ou deixe install.sh instalá-lo) para gerar o projeto a partir de project.yml, e uma identidade de assinatura Developer ID Application — o serviço XPC Mach do host é limitado à equipe, então assinatura ad-hoc não funcionará.

Arquitetura

O macOS só permite que o processo que recebeu a permissão de Acessibilidade realmente a use — e um cliente MCP (um CLI ou aplicativo) é o lugar errado para manter essa concessão. Então o servidor é dividido:

  MCP client  ──stdio──▶  MacControlRelay  ──XPC──▶  MacControlHost (LaunchAgent)
  (Claude/Codex/…)        (tiny forwarder)           (holds the Accessibility grant,
                                                       runs the MCPServer + all tools)
  • MacControlRelay — o pequeno binário stdio que seu cliente MCP inicia. Ele encaminha JSON-RPC para o host através de um serviço XPC Mach assinado por código e escreve as respostas de volta. Ele se reconecta de forma transparente (e pode iniciar o host a frio) se necessário.
  • MacControlHost — um LaunchAgent sem rosto (LSUIElement) que possui as concessões de Acessibilidade / Gravação de Tela e executa o MCPServer real com cada ferramenta.
  • MacControlRegistrar / MacControlMCP.app — registram o LaunchAgent do host (via SMAppService) e acionam os prompts de permissão; o aplicativo auto-inicializa a pilha no primeiro uso.

Por que isso importa: você concede Acessibilidade uma vez, ao host, e cada cliente MCP que inicia o relay a reutiliza. O relay não carrega permissões próprias.

Estrutura do código-fonte (módulos SPM)

MóduloFunção
MacControlMCPCoreServidor MCP/JSON-RPC, o esboço compacto da interface + legenda, diffing, temporização de quiescência, ferramentas de simulador e listagem de aplicativos
AXKito motor de Acessibilidade: wrapper de elemento, percorredor de árvore, família de ferramentas control_app, resolução de aplicativos, agir-e-assentar
InputKitentrada sintética (cliques, teclas, rolagem, arrastar, digitação Unicode, colar)
CaptureKitcapturas de tela + OCR
HostKito serviço host XPC, a fiação completa do servidor e o log de depuração
MacControlHost / MacControlRelay / MacControlRegistrar / MacControlMCPos executáveis / aplicativo

Ferramentas

Dirigindo um aplicativo (a superfície principal)

control_app é o ponto de entrada: ele resolve (ou inicia) um aplicativo e retorna uma hierarquia compacta com referências, prefixada com uma legenda explicando o formato e os verbos. Todo o resto opera nos refs que ele retorna.

FerramentaO que faz
control_app(identity, window?, timeout?, maxLines?, maxChars?)Resolve por nome/bundle id/pid/título de janela → árvore com referências. Inicia automaticamente se não estiver em execução. A saída é limitada em tamanho (maxChars padrão 40k, maxLines padrão 1200); qualquer corte é relatado inline.
launch_app(app, activate?, timeout?)Inicia por caminho .app ou bundle id, aguarda a primeira janela, retorna a árvore.
action(ref, action, refresh?)Executa uma ação AX: press, menu, inc, dec, disclose, collapse, ou um rótulo de ação personalizada.
click(ref, count?, refresh?)Clique real no elemento (traz o aplicativo para a frente). count:2 dá duplo clique.
type(text, ref?, via?, refresh?)Insere texto: inserção AX direta → teclas → fallback de colagem da área de transferência. Relata via e focused.
change_text(ref, value, refresh?)Define o valor de texto de um campo semanticamente (sem teclas).
change_value(ref, value, refresh?)Define um controle numérico (slider/scrollbar/stepper), com limite de intervalo.
focus_keyboard(ref, observe?)Dá foco de teclado a um elemento (sem clique, não disruptivo).
reveal(ref, observe?)Rola um elemento para a visualização.
expand(ref, timeout?) / refresh(ref, timeout?)Carrega lentamente [N hidden] descendentes / relê uma subárvore.
window(ref, action, …)move / resize / minimize / unminimize / raise.
menu_pick(pid, path, observe?)Navega pela barra de menus por caminho de título, ex. ["File","New"].
find_elements(pid, role?, titleContains?, identifier?, value?, actionable?, limit?)Busca na árvore por referências correspondentes sem reler tudo.
element_detail(ref)Atributos completos / ações / atributos parametrizados para uma referência.
focused_element() / element_at(x, y)O elemento focado / teste de hit em um ponto da tela.
get_changes(pid, depth?)Diff da interface do aplicativo contra o último snapshot (adicionado/removido/alterado por referência).
wait_for(pid, mode, …)Consulta até idle / appears / disappears.
kill(identity, signal?)Encerra por pid/nome/bundle id; escalonamento padrão SIGHUP → SIGTERM → SIGKILL.

Entrada sintética (coordenadas brutas / teclas)

click_point(x, y, …), scroll(dy, dx?), key(keys), hover(x, y), drag(fromX, fromY, toX, toY) — nível de coordenadas/teclas. Prefira os verbos baseados em referência acima; use estes apenas quando tiver uma coordenada explícita.

Captura, descoberta, simulador

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

open é o equivalente sem concessão de dar duplo clique no Finder: ele abre um arquivo, pasta, URL ou aplicativo via /usr/bin/open. O alvo é passado como um elemento literal de array de argumentos (nunca através de um shell), e a forma de opção (-u para URLs, -a/-b para aplicativos, -- antes de operandos de arquivo) impede que um alvo que comece com - seja lido como uma flag.


Instalação

Via PyPI (sem Xcode, sem Developer ID)

uvx drews-mac-control-mcp --setup

O wheel carrega o aplicativo assinado e notarizado. Ele instala em ~/Applications no primeiro uso, abre para que o macOS exiba os prompts de permissão e imprime o comando de registro do cliente.

Aprove ambos os prompts. O primeiro permite um novo item de fundo — esse é o agente host, e dispensá-lo deixa o host incapaz de iniciar, com toda chamada de ferramenta posterior falhando e sem explicação (reative em Ajustes do Sistema ▸ Geral ▸ Itens de Login e Extensões). O segundo concede Acessibilidade, além de Gravação de Tela se você quiser capturas de tela.

Versões posteriores substituem o aplicativo instalado por conta própria: o wrapper compara a versão que carrega contra a que está em ~/Applications a cada inicialização, então uma atualização uvx traz o aplicativo junto. Nada neste caminho precisa de Xcode ou de uma identidade de assinatura própria.

Requer macOS 14 ou posterior. O wheel é marcado como macosx_14_0_universal2, então pip e uv recusam instalá-lo em qualquer outro lugar.

Com Homebrew

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

O mesmo aplicativo, instalado em /Applications em vez de ~/Applications.

As duas compilações são produtos separados, não duas cópias de um: a compilação ~/Applications carrega um sufixo .user nos seus identificadores de bundle, no rótulo do LaunchAgent e no serviço Mach. Instale ambos se quiser — nenhum percebe o outro. O custo é que o macOS vê dois aplicativos, então cada um precisa da sua própria concessão de Acessibilidade (e Gravação de Tela). Veja CLAUDE.md para o porquê.

Cada release do GitHub também carrega MacControlMCP-<version>.zip para instalação manual e um SHA256SUMS cobrindo todos os downloads.

Atualização

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

O wrapper PyPI se auto-atualiza sozinho — a cada inicialização ele re-verifica o índice do PyPI (cache ~10 min) e troca ~/Applications quando um wheel mais novo aparece; --refresh apenas força agora. Homebrew e install.sh substituem a compilação /Applications no lugar. Qualquer uma dessas entra em vigor na próxima vez que seu cliente MCP gerar o servidor, então reconecte-o depois (ex. /mcp reconnect maccontrol no Claude Code).

Desinstalação

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

Ambos removem o registro do agente host antes de excluir o aplicativo, que é a única ordem que funciona: excluir o bundle sozinho deixa o registro para trás como um item de login apontando para nada, encontrável apenas manualmente em Ajustes do Sistema. O aplicativo expõe --unregister-and-exit para exatamente isso, então nenhum caminho precisa da GUI.


Compilar e instalar a partir do código-fonte

Um comando

./install.sh

Isso é tudo. install.sh gera o projeto Xcode, compila o aplicativo Release, assina com seu Developer ID, instala em /Applications, inicia (o que registra o LaunchAgent do host e aciona os prompts de permissão do macOS) e registra o relay com qualquer cliente MCP (claude, codex) que encontrar no seu PATH. Quando terminar, conceda Acessibilidade (e Gravação de Tela para capturas de tela) se não foi solicitado, e você está pronto.

Pré-requisitos: macOS 14+, Xcode 16+, XcodeGen e uma identidade de assinatura Developer ID Application no seu chaveiro — o serviço XPC Mach do host é limitado à equipe, então assinatura ad-hoc não funcionará. Se o XcodeGen estiver ausente, o script oferece brew install xcodegen para você (--install-deps aceita antecipadamente, para execuções não assistidas).

Flags úteis:

./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

Compilação manual

O script orquestra os mesmos passos que você pode executar manualmente:

./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 é o loop interno rápido — ele assina e notariza o bundle que o Xcode já compilou, em vez de recompilar do zero como install.sh e scripts/build-release.sh fazem.

Todos os três delegam a assinatura em si a scripts/sign-app.sh, que é o único lugar que conhece a ordem de dentro para fora, o identificador explícito do relay e as verificações que ficam entre uma compilação e uma rejeição de notarização — runtime endurecido, timestamp seguro, autoridade Developer ID Application, sem get-task-allow, sem symlinks. Execute diretamente contra qualquer 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

Ele deriva o identificador de assinatura do relay do próprio bundle, então assina o relay de uma compilação .user sob o identificador que o host dessa compilação realmente exige — errar isso só aparece como um "host indisponível" inexplicável no primeiro uso.

scripts/gen-identity.sh escreve a identidade contra a qual uma compilação é feita (--variant system|user) e imprime o IDENTITY_SUFFIX para passar a xcodebuild. É o único lugar onde os identificadores são definidos; todo o resto deriva dele. Para uma verificação rápida apenas dos alvos de biblioteca/binário (sem o bundle do app, não exigirá a concessão de Acessibilidade):

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

A assinatura/notarização usa por padrão um Developer ID da Nuclear Cyborg e um perfil de chaveiro notarytool. Substitua a identidade com --identity / CODESIGN_IDENTITY e o perfil com --profile / NOTARY_PROFILE. Forks também precisam alterar os bundle ids e o prefixo da equipe em packaging/host.launchagent.plist.

Criando um release

scripts/build-release.sh é a outra metade de install.sh: mesmas etapas de build e assinatura, mas ele empacota o bundle notarizado no wheel do PyPI em vez de instalá-lo 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

Ele incrementa a versão em sincronia em AppVersion.swift, project.yml e python/pyproject.toml, executa os testes e então compila duas vezes — uma por identidade de instalação, já que os builds de /Applications e ~/Applications são produtos diferentes. Cada build é verificado (ambas as fatias de arquitetura, o plist embutido e que ele carrega a identidade que afirma), assinado e notarizado; o wheel recebe o build de usuário e o arquivo do cask, o de sistema. Por fim, ele extrai o app de volta do wheel finalizado para confirmar que ainda verifica e sela. Nada chega ao PyPI, GitHub ou origin sem --publish e um prompt de confirmação.

A publicação produz três artefatos de release — o wheel, MacControlMCP-<version>.zip e SHA256SUMS — envia o wheel para o PyPI e atualiza o cask do Homebrew em drewster99/homebrew-tap (--skip-tap opta por não participar). O cask é regenerado a partir do digest do próprio release e relido do tap para confirmar que ele aponta para a tag que acabou de ser criada.

Registrar com um cliente MCP

O que você registra depende de como você instalou, e a diferença decide se você recebe atualizações.

Instalado via PyPI — registre o wrapper, não o 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

O wrapper verifica em cada inicialização se o app em ~/Applications corresponde à versão que seu wheel carrega, e o substitui se não for o caso. uvx fornece a outra metade: ele armazena em cache o índice do PyPI por os dez minutos que o PyPI solicita (cache-control: max-age=600) e re-resolve quando isso expira, então um novo release é detectado por conta própria — em minutos, não necessariamente na próxima chamada. uvx --refresh drews-mac-control-mcp força isso imediatamente.

Apontar um cliente para o caminho do relay funciona hoje e nunca mais atualiza.

Instalado via Homebrew ou install.sh — registre o relay diretamente, já que brew upgrade e install.sh são o que o atualizam:

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 os clientes para você, e uvx drews-mac-control-mcp --setup imprime os comandos para seu próprio canal. Qualquer cliente MCP que inicie um servidor stdio funciona da mesma forma.


Logging

O relay e o host ambos anexam a uma única linha do tempo em:

~/Library/Logs/MacControlMCP/maccontrol.log

Ele registra inicialização / conexão / desconexão além de cada requisição e resposta por completo (cada linha marcada com [process:pid], protegida por flock para que os dois processos nunca se intercalem). Sempre ativo; defina MACCONTROL_LOG=0 para desabilitar ou MACCONTROL_LOG_PATH=/abs/file para redirecionar. Cada linha de inicialização inclui o timestamp de build do binário para que você possa confirmar qual build está ativo.


Versionamento

Há uma única versão para incrementar, declarada em três arquivos que o build mantém em sincronia:

  • Sources/MacControlMCPCore/AppVersion.swift — a fonte de verdade compilada que todos os componentes relatam (a linha "Versão" da GUI, o initialize serverInfo.version do MCP e a identidade de build no log de inicialização).
  • project.yml — MARKETING_VERSION / CURRENT_PROJECT_VERSION, que alimentam os CFBundleShortVersionString / CFBundleVersion dos bundles nativos.
  • python/pyproject.toml — a versão do wheel, para que uvx drews-mac-control-mcp e o app que ele instala nunca sejam duas histórias diferentes.

install.sh e scripts/build-release.sh incrementam todos os três para você; defini-los manualmente significa definir todos os três para o mesmo valor e então re-executar xcodegen generate. Uma fase de pré-build "Verificar versão" falha o build se a constante Swift e project.yml discordarem, e o script de release relê cada arquivo que escreveu — um sed que não corresponde a nada ainda sai com código 0.

A janela de configuração do app mostra sua própria versão e, consultando o host em execução via XPC, a versão do agente ativo — sinalizando uma incompatibilidade (por exemplo, um host obsoleto deixado registrado por uma instalação mais antiga). A verificação da versão do agente precisa do build assinado para satisfazer o requisito de chamador do host; um build de desenvolvimento não assinado mostrará o agente como "não alcançável".


Limitações conhecidas

  • Acessibilidade é por Espaço. AX enumera janelas no Espaço atual do Mission Control; um app cujas janelas estão em outro Espaço (ou com a tela adormecida) pode relatar zero janelas mesmo que elas existam.
  • Teclas sintéticas alcançam o app que detém o foco de teclado; o macOS 14 não permite que uma ferramenta em segundo plano roube o foco de teclado, por isso type(ref) clica no campo primeiro e recorre a uma colagem da área de transferência para visualizações de texto do AppKit.
  • A cobertura AX específica do app varia — Catalyst, Electron e conteúdo web expõem árvores diferentes (às vezes esparsas). Veja docs/ para as notas de design e docs/ROADMAP.md para o que está adiado.

Licença

Apache License 2.0.