uiautomator2-mcp-server 0.1.3__py3-none-any.whl → 0.2.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.
u2mcp/tools/scrcpy.py ADDED
@@ -0,0 +1,142 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+
5
+ from anyio import create_task_group, move_on_after, open_process
6
+ from anyio.abc import AnyByteReceiveStream, Process
7
+ from anyio.streams.text import TextReceiveStream
8
+ from fastmcp.server.dependencies import get_context
9
+ from fastmcp.utilities.logging import get_logger
10
+
11
+ from ..background import get_background_task_group
12
+ from ..mcp import mcp
13
+
14
+ __all__ = ("start_scrcpy", "stop_scrcpy")
15
+
16
+ # Keep track of background scrcpy processes
17
+ _background_processes: dict[int, Process] = {}
18
+
19
+
20
+ @mcp.tool("start_scrcpy", tags={"screen:mirror"})
21
+ async def start_scrcpy(serial: str = "", timeout: float = 5.0) -> int:
22
+ """Startup scrcpy in background and returns process id.
23
+
24
+ scrcpy is an application mirrors Android devices (video and audio) connected via USB or TCP/IP and allows control using the computer's keyboard and mouse.
25
+
26
+ The scrcpy process will run in the background after successful startup.
27
+ Use stop_scrcpy to terminate the process.
28
+
29
+ Args:
30
+ serial (str): Android device serialno. If empty string, connects to the unique device if only one device is connected.
31
+ timeout (float): Seconds to wait for process to confirm startup.
32
+ If process is still running after this time, startup is considered successful.
33
+
34
+ Returns:
35
+ int: process id (pid)
36
+ """
37
+
38
+ logger = get_logger(f"{__name__}.start_scrcpy")
39
+ ctx = get_context()
40
+
41
+ scrcpy_path = os.environ.get("SCRCPY", "scrcpy.exe" if os.name == "nt" else "scrcpy")
42
+ command = [scrcpy_path]
43
+ if serial := serial.strip():
44
+ command.extend(["--serial", serial])
45
+
46
+ logger.info("start scrcpy: %s", command)
47
+
48
+ process = await open_process(command)
49
+ pid = process.pid
50
+
51
+ # Stream monitoring function
52
+ async def receive(name: str, stream: AnyByteReceiveStream):
53
+ logger.info("Starting to monitor %s for pid=%s", name, pid)
54
+ async for line in TextReceiveStream(stream):
55
+ logger.debug("Received %s line: %s", name, line.strip())
56
+ match name:
57
+ case "stdout":
58
+ await ctx.info(line)
59
+ case "stderr":
60
+ await ctx.error(line)
61
+ case _:
62
+ raise ValueError(f"Unknown stream name: {name}")
63
+ logger.info("Finished monitoring %s for pid=%s", name, pid)
64
+
65
+ # Monitor streams and auto-cleanup on process exit
66
+ async def monitor_streams():
67
+ if process.stdout is None:
68
+ raise RuntimeError("stdout is None")
69
+ if process.stderr is None:
70
+ raise RuntimeError("stderr is None")
71
+ try:
72
+ async with create_task_group() as inner_tg:
73
+ for name, handle in zip(("stdout", "stderr"), (process.stdout, process.stderr)):
74
+ inner_tg.start_soon(receive, name, handle)
75
+ finally:
76
+ # Cleanup only if we still own the process (i.e., not manually stopped)
77
+ if _background_processes.pop(pid, None) is process:
78
+ logger.info("scrcpy process exited naturally, cleaning up (pid=%s)", pid)
79
+ await process.aclose()
80
+ else:
81
+ logger.info("scrcpy process was manually stopped, skipping cleanup (pid=%s)", pid)
82
+
83
+ # Start monitoring immediately (before startup wait) to capture startup logs
84
+ tg = get_background_task_group()
85
+ if tg is None:
86
+ raise RuntimeError("Monitor task group not initialized - server not started?")
87
+ tg.start_soon(monitor_streams)
88
+
89
+ # Store process
90
+ _background_processes[pid] = process
91
+
92
+ # Startup phase: wait for process exit with timeout
93
+ with move_on_after(timeout) as timeout_scope:
94
+ await process.wait()
95
+
96
+ if timeout_scope.cancel_called:
97
+ # Timeout reached, process is still running - success!
98
+ logger.info("scrcpy started successfully in background (pid=%s)", pid)
99
+ else:
100
+ # Process exited before timeout - failure
101
+ _background_processes.pop(pid, None)
102
+ if process.returncode == 0:
103
+ raise RuntimeError(f"scrcpy closed during startup (pid={pid}, return code 0)")
104
+ else:
105
+ raise RuntimeError(f"scrcpy exited during startup wait with code: {process.returncode} (pid={pid})")
106
+
107
+ return pid
108
+
109
+
110
+ @mcp.tool("stop_scrcpy", tags={"screen:mirror"})
111
+ async def stop_scrcpy(pid: int, timeout: float = 5.0) -> None:
112
+ """Stop a running scrcpy process by pid.
113
+
114
+ scrcpy is an application mirrors Android devices (video and audio) connected via USB or TCP/IP and allows control using the computer's keyboard and mouse.
115
+
116
+ Args:
117
+ pid (int): Process id of the scrcpy process to stop
118
+ timeout (float): Seconds to wait for process to terminate.
119
+
120
+ Returns:
121
+ None
122
+ """
123
+ logger = get_logger(f"{__name__}.stop_scrcpy")
124
+
125
+ if pid not in _background_processes:
126
+ raise ValueError(f"No scrcpy process found with pid: {pid}")
127
+
128
+ process = _background_processes.pop(pid)
129
+ try:
130
+ process.kill()
131
+ # Wait for process to exit with timeout
132
+ with move_on_after(timeout) as timeout_scope:
133
+ await process.wait()
134
+ finally:
135
+ # Always close the process when manually stopping
136
+ # The monitor task will detect we popped the process and skip cleanup
137
+ await process.aclose()
138
+
139
+ if timeout_scope.cancel_called:
140
+ logger.warning("scrcpy process did not exit within %ss (pid=%s)", timeout, pid)
141
+ else:
142
+ logger.info("scrcpy process stopped (pid=%s)", pid)
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '0.1.3'
32
- __version_tuple__ = version_tuple = (0, 1, 3)
31
+ __version__ = version = '0.2.0'
32
+ __version_tuple__ = version_tuple = (0, 2, 0)
33
33
 
34
34
  __commit_id__ = commit_id = None