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 +8 -0
- phonectl/cli.py +342 -0
- phonectl/device.py +417 -0
- phonectl/keys.py +33 -0
- phonectl/ocr.py +43 -0
- phonectl/server.py +322 -0
- phonectl_mcp-0.1.0.dist-info/METADATA +143 -0
- phonectl_mcp-0.1.0.dist-info/RECORD +11 -0
- phonectl_mcp-0.1.0.dist-info/WHEEL +4 -0
- phonectl_mcp-0.1.0.dist-info/entry_points.txt +3 -0
- phonectl_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
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()
|