phonectl-mcp 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
phonectl/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """phonectl: an MCP server that drives a real iPhone over USB."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("phonectl-mcp")
7
+ except PackageNotFoundError: # running from a source tree that is not installed
8
+ __version__ = "0+unknown"
phonectl/cli.py ADDED
@@ -0,0 +1,342 @@
1
+ """`phonectl`: set up and check the resident server.
2
+
3
+ phonectl install-service run the server at login (launchd) and print client setup
4
+ phonectl uninstall-service stop it and remove the launchd job
5
+ phonectl restart restart it, e.g. after upgrading
6
+ phonectl doctor [--fix] check every link from the cable to the server, say what to do
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import asyncio
13
+ import os
14
+ import platform
15
+ import plistlib
16
+ import socket
17
+ import subprocess
18
+ import sys
19
+ import time
20
+ from pathlib import Path
21
+
22
+ LABEL = "io.github.jiefeng-ai.phonectl-mcp"
23
+ PLIST = Path.home() / "Library" / "LaunchAgents" / f"{LABEL}.plist"
24
+ LOG_DIR = Path.home() / "Library" / "Logs"
25
+ MIN_INPUT_IOS = 27 # taps, swipes and typing
26
+ MIN_IOS = 17 # the USB tunnel everything else goes through
27
+
28
+
29
+ def _use_service_env() -> None:
30
+ """doctor and restart must look where the service looks: take the PHONECTL_* settings
31
+ frozen into the launchd job over the ones in this shell."""
32
+ if PLIST.exists():
33
+ with PLIST.open("rb") as f:
34
+ env = plistlib.load(f).get("EnvironmentVariables", {})
35
+ os.environ.update({k: v for k, v in env.items() if k.startswith("PHONECTL_")})
36
+
37
+
38
+ def _server_settings():
39
+ from . import server # heavy imports, and reads PHONECTL_* on import
40
+
41
+ return server
42
+
43
+
44
+ def _domain() -> str:
45
+ return f"gui/{os.getuid()}"
46
+
47
+
48
+ def _launchctl(*args: str, check: bool = True) -> subprocess.CompletedProcess:
49
+ return subprocess.run(["launchctl", *args], capture_output=True, text=True, check=check)
50
+
51
+
52
+ def _listener(port: int) -> tuple[int, str] | None:
53
+ """(pid, command) of whatever listens on the port, if anything."""
54
+ r = subprocess.run(["lsof", "-nP", f"-iTCP:{port}", "-sTCP:LISTEN"], capture_output=True, text=True)
55
+ rows = r.stdout.splitlines()[1:]
56
+ return (int(rows[0].split()[1]), rows[0].split()[0]) if rows else None
57
+
58
+
59
+ def _job_pid() -> int | None:
60
+ r = _launchctl("print", f"{_domain()}/{LABEL}", check=False)
61
+ for line in r.stdout.splitlines():
62
+ key, _, value = line.strip().partition(" = ")
63
+ if key == "pid" and value.isdigit():
64
+ return int(value)
65
+ return None
66
+
67
+
68
+ def _wait_port(host: str, port: int, seconds: float = 10) -> bool:
69
+ for _ in range(int(seconds / 0.25)):
70
+ if _port_open(host, port):
71
+ return True
72
+ time.sleep(0.25)
73
+ return False
74
+
75
+
76
+ def _port_open(host: str, port: int) -> bool:
77
+ try:
78
+ with socket.create_connection((host, port), timeout=0.5):
79
+ return True
80
+ except OSError:
81
+ return False
82
+
83
+
84
+ def _client_help(url: str, token_path: Path) -> str:
85
+ return f"""
86
+ Connect a client (the URL and the token header are all it needs):
87
+
88
+ Claude Code:
89
+ claude mcp add --scope user --transport http phone {url} \\
90
+ --header "Authorization: Bearer $(cat {token_path})"
91
+
92
+ Any other MCP client with streamable HTTP:
93
+ url: {url}
94
+ header: Authorization: Bearer <contents of {token_path}>
95
+ """
96
+
97
+
98
+ def _stop_job() -> None:
99
+ """Unload the job and wait until it is gone: bootout returns before the old server has
100
+ exited, and a bootstrap issued meanwhile fails with an I/O error."""
101
+ _launchctl("bootout", f"{_domain()}/{LABEL}", check=False)
102
+ for _ in range(50):
103
+ if _launchctl("print", f"{_domain()}/{LABEL}", check=False).returncode != 0:
104
+ return
105
+ time.sleep(0.2)
106
+
107
+
108
+ def install_service(_args) -> int:
109
+ srv = _server_settings()
110
+ exe = Path(sys.executable).parent / "phonectl-mcp"
111
+ if not exe.exists():
112
+ print(f"phonectl-mcp not found next to {sys.executable}; install with `uv tool install phonectl-mcp`.")
113
+ return 1
114
+ if "/.cache/uv/" in str(exe) or "/archive-v" in str(exe):
115
+ print("This copy runs from uv's cache (uvx), which can be cleaned away under the service.\n"
116
+ "Install it for good first: uv tool install phonectl-mcp then run phonectl install-service")
117
+ return 1
118
+ srv._load_token(srv.TOKEN_PATH) # create it now, so the client setup below works right away
119
+ env = {k: v for k, v in os.environ.items() if k.startswith("PHONECTL_")}
120
+ if "PHONECTL_TOKEN_FILE" in env: # launchd starts in /, so a relative or ~ path would differ
121
+ env["PHONECTL_TOKEN_FILE"] = str(srv.TOKEN_PATH)
122
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
123
+ PLIST.parent.mkdir(parents=True, exist_ok=True)
124
+ job = {
125
+ "Label": LABEL,
126
+ # The script itself, not `uv run`: launchd stops a job by signalling this process, and a
127
+ # wrapper that does not pass the signal on leaves the old server holding the phone.
128
+ "ProgramArguments": [str(exe), "--http"],
129
+ # PHONECTL_* settings in the installing shell are kept for the service.
130
+ "EnvironmentVariables": {"PHONECTL_LOG": "INFO", "NO_COLOR": "1", **env},
131
+ "RunAtLoad": True,
132
+ "KeepAlive": True,
133
+ "ThrottleInterval": 5,
134
+ "StandardOutPath": str(LOG_DIR / "phonectl.log"),
135
+ "StandardErrorPath": str(LOG_DIR / "phonectl.error.log"),
136
+ }
137
+ # Check before touching the running job, so a failed install leaves it as it was.
138
+ held = _listener(srv.HTTP_PORT)
139
+ if held and held[0] != _job_pid():
140
+ print(f"Port {srv.HTTP_PORT} is already used by {held[1]} (pid {held[0]}). Stop it "
141
+ "(an old phonectl-mcp started by hand?) or choose another port with PHONECTL_PORT.")
142
+ return 1
143
+ _stop_job()
144
+ with PLIST.open("wb") as f:
145
+ plistlib.dump(job, f)
146
+ r = _launchctl("bootstrap", _domain(), str(PLIST), check=False)
147
+ if r.returncode != 0:
148
+ print(f"launchctl bootstrap failed: {r.stderr.strip()}")
149
+ return 1
150
+ if not _wait_port(srv.HTTP_HOST, srv.HTTP_PORT):
151
+ print(f"The job is installed but nothing listens on port {srv.HTTP_PORT} yet; "
152
+ f"see {LOG_DIR / 'phonectl.error.log'}")
153
+ return 1
154
+ url = f"http://{srv.HTTP_HOST}:{srv.HTTP_PORT}/mcp"
155
+ print(f"Server running at {url} (launchd job {LABEL}, starts at login).")
156
+ print(_client_help(url, srv.TOKEN_PATH))
157
+ print("Run `phonectl doctor` to check the phone link.")
158
+ return 0
159
+
160
+
161
+ def uninstall_service(_args) -> int:
162
+ _stop_job()
163
+ if PLIST.exists():
164
+ PLIST.unlink()
165
+ print(f"Removed {PLIST}.")
166
+ else:
167
+ print("No launchd job was installed.")
168
+ return 0
169
+
170
+
171
+ def restart(_args) -> int:
172
+ r = _launchctl("kickstart", "-k", f"{_domain()}/{LABEL}", check=False)
173
+ if r.returncode != 0:
174
+ print(f"Not running as a service ({r.stderr.strip() or r.returncode}); run `phonectl install-service`.")
175
+ return 1
176
+ _use_service_env()
177
+ srv = _server_settings()
178
+ time.sleep(1) # the old server is still closing its port right after kickstart
179
+ if not _wait_port(srv.HTTP_HOST, srv.HTTP_PORT):
180
+ print(f"Restarted, but nothing listens on port {srv.HTTP_PORT}; see {LOG_DIR / 'phonectl.error.log'}.")
181
+ return 1
182
+ print("Restarted.")
183
+ return 0
184
+
185
+
186
+ class _Report:
187
+ def __init__(self) -> None:
188
+ self.failed = False
189
+
190
+ def ok(self, msg: str) -> None:
191
+ print(f" ok {msg}")
192
+
193
+ def warn(self, msg: str, fix: str = "") -> None:
194
+ print(f" warn {msg}" + (f"\n -> {fix}" if fix else ""))
195
+
196
+ def fail(self, msg: str, fix: str) -> None:
197
+ self.failed = True
198
+ print(f" FAIL {msg}\n -> {fix}")
199
+
200
+
201
+ async def _server_status(srv, url: str) -> str:
202
+ from mcp.client.session import ClientSession
203
+ from mcp.client.streamable_http import streamable_http_client
204
+ from mcp.shared._httpx_utils import create_mcp_http_client
205
+
206
+ headers = {"Authorization": f"Bearer {srv.TOKEN_PATH.read_text().strip()}"}
207
+ async with create_mcp_http_client(headers=headers) as http:
208
+ async with streamable_http_client(url, http_client=http) as streams:
209
+ async with ClientSession(streams[0], streams[1]) as session:
210
+ await session.initialize()
211
+ res = await session.call_tool("status", {})
212
+ return "\n".join(c.text for c in res.content if c.type == "text")
213
+
214
+
215
+ async def _doctor(fix: bool) -> int:
216
+ from pymobiledevice3.lockdown import create_using_usbmux
217
+ from pymobiledevice3.services.mobile_image_mounter import MobileImageMounterService, auto_mount
218
+ from pymobiledevice3.usbmux import list_devices
219
+
220
+ import logging
221
+
222
+ _use_service_env()
223
+ srv = _server_settings()
224
+ # A dependency turns on INFO logging for everything on import; doctor prints its own report.
225
+ logging.getLogger().setLevel(logging.WARNING)
226
+ r = _Report()
227
+ print("Phone")
228
+ if platform.system() != "Darwin":
229
+ r.fail("not macOS", "phonectl needs macOS: the USB tunnel and the text recognition are macOS-only.")
230
+ return 1
231
+ try:
232
+ usb = [d for d in await list_devices() if d.connection_type == "USB"]
233
+ except Exception as e: # noqa: BLE001
234
+ r.fail(f"cannot list USB devices ({e!r})", "usbmuxd is part of macOS; restart the Mac if this persists.")
235
+ return 1
236
+ udid = os.environ.get("PHONECTL_UDID") or None
237
+ if udid:
238
+ usb = [d for d in usb if d.serial == udid]
239
+ if not usb:
240
+ r.fail("no iPhone on USB" + (f" with UDID {udid}" if udid else ""),
241
+ "Connect it with a data cable (not charge-only) and unlock it.")
242
+ return 1
243
+ r.ok(f"iPhone on USB ({len(usb)} found{', using the first' if len(usb) > 1 else ''})")
244
+ try:
245
+ ld = await create_using_usbmux(serial=usb[0].serial, autopair=False)
246
+ except Exception as e: # noqa: BLE001 - every failure here is reported, none is fatal to the Mac
247
+ r.fail(f"cannot talk to the phone ({e!r})", "Unlock the phone and replug the cable.")
248
+ return 1
249
+ try:
250
+ if not ld.paired:
251
+ r.fail("this Mac is not trusted by the phone",
252
+ "Unlock the phone, replug the cable and tap Trust (enter the passcode if asked).")
253
+ return 1
254
+ r.ok("Mac is trusted")
255
+ version = ld.product_version
256
+ name = ld.all_values.get("DeviceName") or "iPhone"
257
+ major = int(version.split(".")[0])
258
+ if major >= MIN_INPUT_IOS:
259
+ r.ok(f"{name}, iOS {version}")
260
+ elif major >= MIN_IOS:
261
+ r.warn(f"{name}, iOS {version}: taps, swipes and typing need iOS {MIN_INPUT_IOS}+ "
262
+ "(screenshots and apps may work; untested)",
263
+ f"Update the phone to iOS {MIN_INPUT_IOS} or later for input.")
264
+ else:
265
+ r.fail(f"{name}, iOS {version}: too old", f"phonectl needs iOS {MIN_IOS}+, and iOS {MIN_INPUT_IOS}+ for input.")
266
+ return 1
267
+ if await ld.get_developer_mode_status():
268
+ r.ok("Developer Mode is on")
269
+ else:
270
+ r.fail("Developer Mode is off",
271
+ "On the phone: Settings > Privacy & Security > Developer Mode, turn it on and restart. "
272
+ "If the switch is missing, open Xcode once with the phone connected.")
273
+ return 1
274
+ try:
275
+ async with MobileImageMounterService(lockdown=ld) as m:
276
+ mounted = await m.is_image_mounted("Personalized")
277
+ except Exception as e: # noqa: BLE001
278
+ r.fail(f"cannot check the developer disk image ({e!r})", "Unlock the phone and run doctor again.")
279
+ return 1
280
+ if mounted:
281
+ r.ok("developer disk image is mounted")
282
+ elif fix:
283
+ print(" mounting the developer disk image (downloads it the first time)...")
284
+ try:
285
+ await auto_mount(ld)
286
+ r.ok("developer disk image mounted")
287
+ except Exception as e: # noqa: BLE001
288
+ r.fail(f"mounting failed ({e!r})",
289
+ "Keep the phone unlocked and online, then retry; or open Xcode once with the phone connected.")
290
+ else:
291
+ r.fail("developer disk image is not mounted (screenshots need it)",
292
+ "Run `phonectl doctor --fix`, or open Xcode once with the phone connected.")
293
+ finally:
294
+ await ld.close()
295
+
296
+ print("Server")
297
+ url = f"http://{srv.HTTP_HOST}:{srv.HTTP_PORT}/mcp"
298
+ if PLIST.exists():
299
+ r.ok(f"launchd job installed ({PLIST.name})")
300
+ else:
301
+ r.warn("not installed as a service", "Run `phonectl install-service` so every client shares one server.")
302
+ if not _port_open(srv.HTTP_HOST, srv.HTTP_PORT):
303
+ r.fail(f"nothing listening on {url}",
304
+ f"Run `phonectl install-service` (or `phonectl restart`); see {LOG_DIR / 'phonectl.error.log'}.")
305
+ return 1
306
+ if not srv.TOKEN_PATH.exists():
307
+ r.fail(f"no token at {srv.TOKEN_PATH}", "Run `phonectl restart`; the server creates it on start.")
308
+ return 1
309
+ try:
310
+ status = await _server_status(srv, url)
311
+ except Exception as e: # noqa: BLE001
312
+ r.fail(f"the server at {url} did not answer ({e!r})",
313
+ "Check the token and that the port is not used by another program.")
314
+ return 1
315
+ if "phone: connected" in status:
316
+ r.ok(f"server at {url} reaches the phone")
317
+ else:
318
+ r.fail("the server is up but cannot reach the phone:\n " + status.replace("\n", "\n "),
319
+ "Replug the cable, unlock the phone, then `phonectl restart`.")
320
+ print("\nAll good." if not r.failed else "\nFix the FAIL lines above, then run `phonectl doctor` again.")
321
+ return 1 if r.failed else 0
322
+
323
+
324
+ def doctor(args) -> int:
325
+ return asyncio.run(_doctor(args.fix))
326
+
327
+
328
+ def main() -> None:
329
+ ap = argparse.ArgumentParser(prog="phonectl", description="Set up and check the phonectl MCP server.")
330
+ sub = ap.add_subparsers(dest="cmd", required=True)
331
+ sub.add_parser("install-service", help="run the server at login and print client setup").set_defaults(fn=install_service)
332
+ sub.add_parser("uninstall-service", help="stop the server and remove the launchd job").set_defaults(fn=uninstall_service)
333
+ sub.add_parser("restart", help="restart the server").set_defaults(fn=restart)
334
+ d = sub.add_parser("doctor", help="check the phone, the USB link and the server")
335
+ d.add_argument("--fix", action="store_true", help="mount the developer disk image if it is missing")
336
+ d.set_defaults(fn=doctor)
337
+ args = ap.parse_args()
338
+ sys.exit(args.fn(args))
339
+
340
+
341
+ if __name__ == "__main__":
342
+ main()