Semantic tools, not a shell
Handing the console to a model
It would have been trivial to expose one tool called run_command and be done. The MCP
server instead exposes thirty-three named operations whose parameters are typed, whose device references are resolved
before anything executes, and whose failures come back as structured blockers with suggested next actions. The design
assumption is that an agent works better when the surface tells it what it is allowed to want.
The server is a single Python file built on mcp.server.fastmcp. It imports the same
ConnectorControlClient the CLI uses, and holds two instances of it: one bound to the resolved
default socket, one bound explicitly to the desktop app's socket. Everything interesting follows from that pair.
"Use Connector semantic tools first. Select device {device_id}, inspect the device state, connect, then capture a screenshot to verify the UI state."
Launching it
The wrapper does two jobs before exec: it guarantees a daemon, and it pins the SDK.
# unless CONNECTOR_MCP_USE_APP_SOCKET=1 CONNECTOR_CONTROL_SOCKET="$(python3 ensure-connectord.py --print-socket)" export CONNECTOR_CONTROL_SOCKET exec "$UV_BIN" run --with "mcp==1.27.0" \ python3 scripts/connector-mcp.py "$@"
The uv binary is searched for at ~/.local/bin/uv, then
/opt/homebrew/bin/uv, then on PATH — so the wrapper works from a systemd unit with a
minimal environment.
Transport flags
| Flag | Default |
|---|---|
--transport | stdio · also sse, streamable-http |
--host | 127.0.0.1 |
--port | 8765 |
--path | /mcp |
--mount-path | / |
The shipped systemd unit overrides the port to 8876 via
CONNECTOR_MCP_PORT, and binds to loopback only.
Which socket answers
This is the most consequential twenty lines in the file. The daemon is the default because it is always there. But
a stream launched by connectord is invisible to the desktop app's menubar and local session controls — so
anything visual has to prefer the app when the app is open. The routing is explicit, and it is commented in the source
with exactly that reasoning.
app_socket_available(): it is not enough for something to answer on the app
socket path. It must answer and not identify itself as a daemon, because a systemd unit that sets
CONNECTOR_CONTROL_SOCKET to the daemon path would otherwise fool the check.
The tool surface
| Tool | Routes via | Underlying method |
|---|---|---|
connector_get_context | — | reads CONNECTOR_MCP_CONTEXT.md / .json |
connector_rpc | routed_rpc | any method, raw JSON params |
connector_list_devices | rpc | devices.list |
connector_get_device | rpc | devices.get |
connector_resolve_device | local | scoring resolver, ambiguity allowed |
connector_diagnose_device | mixed | devices.get + app.state |
connector_select_device | rpc | devices.select |
connector_connect | visual | devices.connect |
connector_pair_device | rpc | devices.pair |
connector_rename_device | rpc | devices.rename |
connector_set_device_ssh_profile | rpc | devices.set_ssh_profile |
connector_set_device_desktop_scale | rpc | devices.set_desktop_scale |
connector_test_connection | rpc | devices.test_connection |
connector_start_host_runtime | rpc | devices.start_host |
connector_disconnect_session | session | session.disconnect |
connector_toggle_session_stats | session | session.toggle_stats |
connector_toggle_session_fullscreen | session | session.toggle_fullscreen |
connector_toggle_session_mouse | session | session.toggle_mouse |
connector_release_session_mouse | session | session.release_mouse |
connector_switch_session_display | session | session.switch_display |
connector_get_host_state | rpc | host.state |
connector_set_hosting_enabled | visual | host.set_enabled |
connector_set_host_credentials | visual | host.set_credentials |
connector_approve_pair | visual | host.approve_pair |
connector_revoke_trusted_client | visual | host.revoke_client |
connector_get_settings | rpc | settings.get |
connector_set_settings | rpc | settings.set — six optional fields |
connector_get_ui_tree | visual | ui.tree |
connector_activate_control | visual | ui.activate |
connector_set_control_value | visual | ui.set_value |
connector_capture_screenshot | visual | ui.screenshot → written to a temp dir |
connector_ssh_exec | rpc | ssh.exec |
connector_prepare_ssh_command | rpc | ssh.prepare + a shlex-quoted preview |
Resources
Eleven read-only connector:// URIs, all of which serialise through the same routing. Two are static
files; the rest are live RPC reads.
text/markdown.app.statedevices.listdevices.get, templatedhost.statesettings.getsession.stateui.treeactions.globalactions.device, templatedThe diagnostic tool
Of the thirty-three tools, this one is the argument for the whole design. It does not return a device record. It returns a judgement, assembled from the four independent readiness signals, together with the specific next tool call that would move the situation forward.
| Condition | summary |
|---|---|
| online + sshReady + hostReachable + clientTrusted | "Online, SSH ready, and remote desktop ready." |
| online + sshReady, but not both host bits | "Online and SSH ready; remote desktop still needs host runtime/trust." |
| online only | "Online in Tailscale, but SSH or host readiness is blocked." |
| not online | "Offline" |
{
"query": "zflow",
"resolved_device": {
"id": "n5XYmDgJcq11CNTRL",
"name": "zflow",
"ip": "100.64.0.13",
"online": true,
"host_reachable": false,
"ssh_ready": true,
"ssh_mode": "tailscale",
"trusted": false
},
"summary": "Online and SSH ready; remote desktop still needs host runtime/trust.",
"can_ssh": true,
"ssh_target": "[email protected]",
"can_start_remote_desktop": false,
"blockers": [
"Connector host runtime is not reachable."
],
"next_actions": [
"Run connector_start_host_runtime to start Connector/Sunshine over SSH, then re-test.",
"Use connector_ssh_exec for remote repair commands."
],
"resolution": {
"device_id": "n5XYmDgJcq11CNTRL",
"match": { "score": 1035, "reason": "exact match on zflow" }
}
}
The score of 1035 is not decorative: 1000 for the exact name match, +25 for sshReady,
+10 for online, no bonus for hostReachable because it is false. An agent reading this can
see both what matched and how confident the match was.
blockers and next_actions are built in the same pass and stay in sync. If the host is
unreachable but SSH is not ready, the tool does not suggest starting the host runtime — because it knows that
suggestion would fail.
Hosted routing
The recommended deployment, straight from CONNECTOR_MCP_CONTEXT.md, is a trusted home control node: a
machine that stays on the tailnet, runs the daemon, and publishes the MCP surface on loopback for a local agent
runtime to consume. The three deploy files encode exactly that ordering.
$ systemctl --user enable --now connectord.service connector-mcp-http.service $ systemctl --user list-dependencies connector-mcp-http.service connector-mcp-http.service ● └─connectord.service $ scripts/wait-for-port.py 127.0.0.1 8876 30 $ echo $? 0 # the drop-in that makes a consuming service wait for the bridge $ cat deploy/ian-brain-connector-order.conf [Unit] Wants=connector-mcp-http.service After=connector-mcp-http.service [Service] ExecStartPre=%h/Desktop/realconnector/scripts/wait-for-port.py 127.0.0.1 8876 30
This bridge has no authentication of its own. It binds 127.0.0.1 and inherits the daemon's
assumption that everything on the machine running as this user is already trusted. Exposing port 8876 beyond
loopback would hand an unauthenticated caller SSH execution on every machine in the tailnet.