Asked for in the Emaculation forum by a reader who wants AppleBridge driven by a locally hosted model rather than by Claude Code, and who sees it as a possible replacement for ToolDaemon. Both are reasonable, and both need the same thing: a description of the client side that does not assume which client you are.

The short version is that MCP is a wrapper, not the interface. Underneath it is a line protocol on a local TCP port that any language can speak in twenty lines. Which of the two you want depends on whether your model already speaks MCP.

What the client is actually talking to

flowchart LR
  C1["Your client
MCP over stdio"] --> M["mcp.server
JSON-RPC, 30 tools"] M --> P["control port
localhost:9001"] C2["Any client
plain TCP"] --> P P --> H["host_server.py"] H -->|"port 9000"| D["Mac daemon
System 7 / Mac OS 9"] D --> T["ToolServer / MPW
via Apple Events"]

host_server.py serves both ports: 9000 for the guest daemon, which dials out because the emulator sits behind NAT, and 9001 for control. The MCP server is a client of 9001 like any other. It adds a tool schema, argument validation, MacBinary framing for file transfer, image content blocks for screenshots, and a set of host-side conveniences — it adds no access.

Path 1 — the MCP server

Requirements

Python 3.9 or newer, and nothing else. The mcp/ package and everything it imports from host/ are stdlib-only: there is no pip install, no virtual environment, no SDK. That is deliberate and load-bearing elsewhere in the project, and it means a client author’s setup step is a path, not a dependency tree.

Configuration

The server speaks MCP over stdio. Any client that can launch a subprocess can host it. In Claude Code it lives in .mcp.json at the repository root:

{
  "mcpServers": {
    "applebridge": {
      "type": "stdio",
      "command": "uv",
      "args": ["run", "python", "-m", "mcp.server"],
      "env": {}
    }
  }
}

Without uv, or from another client whose config wants an absolute invocation:

{
  "command": "/usr/bin/python3",
  "args": ["-m", "mcp.server"],
  "cwd": "/path/to/AppleBridge",
  "env": {}
}

cwd matters: the package resolves host/ relative to its own file, but the module must be importable as mcp.server, so the working directory has to be the repository root. Startup notes go to stderr — stdout carries JSON-RPC and nothing else.

What the server implements, and what it does not

MethodSupported
initializeyes — protocol 2024-11-05, serverInfo applebridge
tools/listyes — all 30 tools with JSON Schema
tools/callyes
pingyes
resources, prompts, sampling, notificationsno

This is a deliberately minimal server. A client that requires resources or prompt templates will need to tolerate their absence; a client that only calls tools will not notice.

Environment

VariableEffect
APPLEBRIDGE_CTRL_TOKENopt-in shared secret for the control port. When set, every request is prefixed with an AUTH:<token> line. It must match the host server’s value — fail-closed. Unset (the default) means the guard is off.
APPLEBRIDGE_KEY_LAYOUT=deswaps Y and Z for a guest running a German KCHR, so mac_key/mac_menu derive the right physical key code.

Note that the control-port token is separate from the wire token between host and daemon. They are different guards on different hops.

The tool surface

Thirty tools, in the groups they were built in. Arguments marked * are required.

Drive a build, read its output

ToolArguments
mpw_executecommand*, timeout
mac_compilesource_path*, output_path, options, lint
mac_buildproject_dir*, app_name, sources, libraries, rez_file, file_type, creator, model, run
mac_read_filepath*
mac_list_filespath*
mac_send_apple_eventtarget_creator*, event_class*, event_id*, direct_object, expect_reply, wait_seconds

Move bytes, run, observe, interact

ToolArguments
mac_put_filehost_path*, mac_path*, type, creator, resource_path
mac_get_filemac_path*, host_path*, format
mac_write_filepath*, content*, type, creator
launch_apppath*
mac_screenshotregion
mac_typetext*
mac_keykey, char_code, key_code, modifiers
mac_clickx*, y*, count, modifiers
mac_menukey, modifiers, title, item
mac_menu_frontmenu_id*, item*, menu_x
mac_clipboard_get / mac_clipboard_set— / text*

The host’s real mouse (local emulator, macOS host only)

ToolArguments
mac_host_clickx*, y*, count, keep_front, modifiers
mac_host_menutitle_x*, title_y*, item_x*, item_y*, keep_front
mac_host_screenshotregion
run_applescriptscript*

Network, lifecycle, diagnosis

ToolArguments
mac_appletalk_browseentity_type, zone, name
mac_status
bridge_doctor
mac_verbose_logmax_bytes
mac_restart_toolserverpath
mac_update_daemonhost_path*, mac_dir, staged_name
mac_reboot / mac_shutdown

Two of these answer when the rest cannot: mac_status reports even with the daemon down, and bridge_doctor runs entirely host-side, so it diagnoses a stack whose server is not running. Start there when nothing works.

Content blocks

Most tools return a JSON text block. mac_screenshot and mac_host_screenshot return two blocks: a real image block carrying base64 PNG, and a text block with the metadata (dimensions, depth) and the base64 stripped out. A client that concatenates all text blocks therefore gets a compact summary rather than a megabyte of base64 — but a client that ignores image blocks gets no picture at all. Worth checking before concluding the screenshot is broken.

Path 2 — the control port directly

If your model cannot speak MCP, skip it. Port 9001 takes one command per connection:

[AUTH:<token>\n]         optional, only when a token is configured
[DEADLINE:<epoch>\n]     optional, see below
<command>\n\n

Then read until the server closes the socket. That close is the end-of-reply signal; a read that times out first has an incomplete reply that must not be trusted.

The reply is framed:

STATUS:<code>
STDOUT:<len>
<data>
STDERR:<len>
<data>

Three rules that a client author will otherwise learn the hard way:

  1. A reply with no STATUS: line is never a success. The server emits bare sentinels — No response when the daemon is not connected, ERROR: … when a command never round-trips. Defaulting a missing status to zero turns all of those into clean empty results, which is how they hide.
  2. Read by declared length, not by terminator. The guest is a classic Mac: its line ending is CR, the host’s is LF, and both appear in the same response. Anything that splits on one of them will eventually cut a payload in half.
  3. Send DEADLINE: if your client can give up. Every well-behaved client half-closes after sending, so the server sees FIN while the caller is still waiting — it cannot tell an abandoned client from a patient one. The deadline is how you say when you stop caring; without it, a command whose caller has vanished still runs.

From the shell, for a smoke test:

printf 'DISKINFO\n\n' | nc localhost 9001
printf 'screenshot\n\n' | nc localhost 9001     # base64 PNG in the reply
cd host && ./send_command.py 'Echo HELLO'

host/send_command.py is forty lines of stdlib and is the shortest complete example of a client. Read it before writing your own.

The trap on this path

A verb with no host-side route is not an error. It falls through to ToolServer, which swallows it and answers STATUS:0 with empty output. So a misspelled verb, or one this host server does not know, reports success and does nothing — the single most expensive failure shape in this project. If a new verb “works but does nothing”, check the dispatch chain in host_server.py first, and read mac_verbose_log: the daemon’s console shows initAE / found=TS / send=0, which gives it away.

What will bite you regardless of path

  • A timeout is not a failure. Long commands — Link above all — return -1712 (an Apple Event timeout) and frequently complete anyway. Verify by the artefact, not by the status. This has been true since the first week and has not stopped being true.
  • Only ToolServer returns output. MPW Shell executes commands perfectly and answers with an empty Apple Event reply. If your output is always empty, you are talking to the wrong one.
  • One socket per command, by design. Persistent client connections were considered and deferred: the guest link is already persistent, and the gain sits entirely on the client side.
  • The tool list is cached by the client. A tool added to mcp/tools.py appears after the client restarts its MCP server, not before.
  • run_applescript and the mac_host_* tools are macOS-only, and the host-mouse ones additionally need the emulator visible and frontmost on the same machine. Everything else in the list is host-OS-independent, because it executes in the guest.
  • The control port has no authentication by default. It binds localhost and the token is opt-in. If you expose it beyond the machine — an ssh tunnel is the sane route — set APPLEBRIDGE_CTRL_TOKEN on both sides first.

Which path to choose

Use the MCP server if your client speaks MCP: you get schemas, validation, MacBinary handling and image blocks for free, and they are exactly the parts that are tedious to reimplement.

Use the control port if it does not, or if you are embedding AppleBridge in something that is not a chat loop at all — a build script, a test harness, a video-walkthrough driver. The line protocol is stable, it is what the MCP server itself uses, and nothing in the tool surface is reachable only through MCP.

Quellen

  1. Installing AppleBridge: https://td5.390er.de/applebridge/installing-applebridge/
  2. What You Can Do With It: https://td5.390er.de/applebridge/what-you-can-do-with-applebridge/
  3. Model Context Protocol specification: https://modelcontextprotocol.io
  4. Source: mcp/server.py, mcp/tools.py, mcp/mac_connection.py, host/host_server.py, host/send_command.py