clousd-mcp 0.1.0__tar.gz

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.
@@ -0,0 +1,57 @@
1
+ Metadata-Version: 2.4
2
+ Name: clousd-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server: give an AI agent a real Android phone in the cloud
5
+ License: MIT
6
+ Project-URL: Homepage, https://clousd.com/agents/
7
+ Keywords: mcp,android,cloud phone,ai agents,mobile automation
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: mcp>=1.2.0
11
+ Requires-Dist: clousd>=0.1.0
12
+
13
+ # clousd-mcp
14
+
15
+ An MCP server that gives any MCP-capable agent a real Android phone in the cloud: a device modelled on a
16
+ real phone, with Google Play, a network in the country you choose, and saved states to reset to between runs.
17
+
18
+ Early access: ask for a key at [clousd.com/agents](https://clousd.com/agents/#access).
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ pip install "git+https://github.com/Clousd-Android/clousd-sdk#subdirectory=python"
24
+ pip install "git+https://github.com/Clousd-Android/clousd-sdk#subdirectory=mcp"
25
+ ```
26
+
27
+ Any MCP client (Cursor, Windsurf, Zed, a desktop assistant or your own agent) - stdio server, one environment variable:
28
+
29
+ ```json
30
+ {
31
+ "mcpServers": {
32
+ "clousd": {
33
+ "command": "clousd-mcp",
34
+ "env": { "CLOUSD_API_KEY": "cl_live_..." }
35
+ }
36
+ }
37
+ }
38
+ ```
39
+
40
+ Clients that take a command line instead of JSON: `clousd-mcp` with `CLOUSD_API_KEY` in the environment.
41
+
42
+ ## Tools
43
+
44
+ Four tools, on purpose - agents choose better from a few large tools than from a dozen small ones:
45
+
46
+ | Tool | What it does |
47
+ |---|---|
48
+ | `devices` | list the phones this key can use; start or stop one |
49
+ | `observe` | the screen as an image plus every text on it |
50
+ | `act` | `tap`, `swipe`, `scroll`, `tap_text`, `wait_text`, `type`, `key`, `open_app`, `close_app`, `open_url`, `installed` |
51
+ | `snapshots` | list, save, restore (reset between runs) or clone a phone |
52
+
53
+ Screenshots come back 540 px wide (`CLOUSD_SHOT_WIDTH` to change); `tap` and `swipe` take coordinates in that image
54
+ and the server scales them to the phone. `tap_text` is usually the more reliable way to press a button. Typing works in
55
+ any language.
56
+
57
+ Built on the [`clousd`](../python) Python client. MIT license.
@@ -0,0 +1,45 @@
1
+ # clousd-mcp
2
+
3
+ An MCP server that gives any MCP-capable agent a real Android phone in the cloud: a device modelled on a
4
+ real phone, with Google Play, a network in the country you choose, and saved states to reset to between runs.
5
+
6
+ Early access: ask for a key at [clousd.com/agents](https://clousd.com/agents/#access).
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ pip install "git+https://github.com/Clousd-Android/clousd-sdk#subdirectory=python"
12
+ pip install "git+https://github.com/Clousd-Android/clousd-sdk#subdirectory=mcp"
13
+ ```
14
+
15
+ Any MCP client (Cursor, Windsurf, Zed, a desktop assistant or your own agent) - stdio server, one environment variable:
16
+
17
+ ```json
18
+ {
19
+ "mcpServers": {
20
+ "clousd": {
21
+ "command": "clousd-mcp",
22
+ "env": { "CLOUSD_API_KEY": "cl_live_..." }
23
+ }
24
+ }
25
+ }
26
+ ```
27
+
28
+ Clients that take a command line instead of JSON: `clousd-mcp` with `CLOUSD_API_KEY` in the environment.
29
+
30
+ ## Tools
31
+
32
+ Four tools, on purpose - agents choose better from a few large tools than from a dozen small ones:
33
+
34
+ | Tool | What it does |
35
+ |---|---|
36
+ | `devices` | list the phones this key can use; start or stop one |
37
+ | `observe` | the screen as an image plus every text on it |
38
+ | `act` | `tap`, `swipe`, `scroll`, `tap_text`, `wait_text`, `type`, `key`, `open_app`, `close_app`, `open_url`, `installed` |
39
+ | `snapshots` | list, save, restore (reset between runs) or clone a phone |
40
+
41
+ Screenshots come back 540 px wide (`CLOUSD_SHOT_WIDTH` to change); `tap` and `swipe` take coordinates in that image
42
+ and the server scales them to the phone. `tap_text` is usually the more reliable way to press a button. Typing works in
43
+ any language.
44
+
45
+ Built on the [`clousd`](../python) Python client. MIT license.
@@ -0,0 +1,2 @@
1
+ """MCP server for CLOUSD cloud phones."""
2
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from .server import main
2
+
3
+ main()
@@ -0,0 +1,224 @@
1
+ """MCP server for CLOUSD cloud phones: gives any MCP-capable agent a real Android phone to look at and act on.
2
+
3
+ Four tools, on purpose (agents choose better from a few large tools than from a dozen small ones):
4
+ devices - list the phones this key can use, start or stop one
5
+ observe - the screen as an image plus every text on it
6
+ act - tap, swipe, scroll, tap_text, wait_text, type, key, open_app, close_app, open_url, installed
7
+ snapshots - list, save, restore (reset between runs) or clone a phone
8
+
9
+ Screenshots come back `SHOT_WIDTH` px wide (540: ~1200 px long side on a 1080x2400 phone). tap/swipe take coordinates
10
+ in that screenshot and are scaled to the device here, so the model never converts pixels.
11
+
12
+ CLOUSD_API_KEY=cl_live_... clousd-mcp # stdio server
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ from typing import Dict, Optional
18
+
19
+ from clousd import Clousd, ClousdError
20
+
21
+ try: # mcp 2.x: FastMCP became MCPServer
22
+ from mcp.server.mcpserver import Image, MCPServer as _Server
23
+ except ImportError: # mcp 1.x
24
+ from mcp.server.fastmcp import FastMCP as _Server, Image
25
+
26
+ SHOT_WIDTH = int(os.environ.get("CLOUSD_SHOT_WIDTH", "540"))
27
+
28
+ mcp = _Server("clousd", instructions="Real Android phones in the cloud. Call observe before acting; prefer act "
29
+ "tap_text over coordinates; use snapshots restore to start each run from the same state.")
30
+ _client: Optional[Clousd] = None
31
+ _scale: Dict[str, float] = {} # device name → device pixels per screenshot pixel
32
+
33
+
34
+ def client() -> Clousd:
35
+ global _client
36
+ if _client is None:
37
+ _client = Clousd()
38
+ return _client
39
+
40
+
41
+ def _jpeg_w(b: bytes) -> int:
42
+ """Width of a JPEG from its SOF marker (the gateway shrinks by an integer factor, so it can differ from the
43
+ requested width: a 720 px screen stays 720 at w=540)."""
44
+ i = 2
45
+ while i + 9 < len(b):
46
+ if b[i] != 0xFF:
47
+ i += 1
48
+ continue
49
+ if 0xC0 <= b[i + 1] <= 0xC3:
50
+ return (b[i + 7] << 8) | b[i + 8]
51
+ i += 2 + ((b[i + 2] << 8) | b[i + 3])
52
+ return SHOT_WIDTH
53
+
54
+
55
+ def _learn_scale(device: str) -> float:
56
+ """Phone pixels per image pixel, from the real image width - never from the requested one."""
57
+ d = client().device(device)
58
+ try:
59
+ o = d.observe(width=SHOT_WIDTH, ui=False)
60
+ _scale[device] = o["screen"]["w"] / float(o["image"]["w"])
61
+ except ClousdError as e:
62
+ if e.status != 404:
63
+ raise
64
+ w, _ = d.size()
65
+ _scale[device] = w / float(_jpeg_w(d.screenshot(width=SHOT_WIDTH)))
66
+ return _scale[device]
67
+
68
+
69
+ def _px(device: str, v: Optional[float]) -> int:
70
+ if v is None:
71
+ raise ValueError("coordinates are required for this action")
72
+ scale = _scale.get(device) or _learn_scale(device)
73
+ return int(round(v * scale))
74
+
75
+
76
+ @mcp.tool()
77
+ def devices(op: str = "list", device: str = "") -> object:
78
+ """Phones this API key can use.
79
+ op=list (default): name, state (running / stopped / starting / error), model, Android, network of each phone.
80
+ op=start / op=stop with `device`: start a stopped phone (waits until it has booted, one to three minutes) or stop
81
+ one (a stopped phone keeps its state and costs nothing)."""
82
+ try:
83
+ if op == "list":
84
+ return [d.data for d in client().devices()]
85
+ if op in ("start", "stop") and device:
86
+ d = client().device(device)
87
+ return (d.start() if op == "start" else d.stop()).output or "ok"
88
+ return "error: op is list, start or stop (start/stop need device)"
89
+ except ClousdError as e:
90
+ return f"error: {e}"
91
+
92
+
93
+ def _elements(d: dict, scale: float, limit: int = 80) -> str:
94
+ """Interactive and labelled elements, centres in screenshot pixels: what the model can tap."""
95
+ rows = []
96
+ for n in d.get("ui", []):
97
+ label = n.get("text") or n.get("desc") or n.get("id", "").split("/")[-1]
98
+ if not label and not n.get("click"):
99
+ continue
100
+ b = n.get("b", [0, 0, 0, 0])
101
+ cx, cy = int((b[0] + b[2]) / 2 / scale), int((b[1] + b[3]) / 2 / scale)
102
+ flags = "".join(f for f, k in ((" tap", "click"), (" scroll", "scroll"), (" focused", "focused"), (" off", "disabled")) if n.get(k))
103
+ rows.append(f"- {label[:60]!r} {n.get('class', '')} at ({cx}, {cy}){flags}")
104
+ if len(rows) >= limit:
105
+ break
106
+ return "\n".join(rows)
107
+
108
+
109
+ @mcp.tool()
110
+ def observe(device: str, with_text: bool = True) -> list:
111
+ """Look at the phone: a fresh screenshot plus (with_text) the app on screen and its elements with centre
112
+ coordinates. Give tap/swipe coordinates in this image's pixels."""
113
+ d = client().device(device)
114
+ try:
115
+ o = d.observe(width=SHOT_WIDTH, ui=with_text)
116
+ except ClousdError as e:
117
+ if e.status != 404:
118
+ return [f"error: {e}"]
119
+ o = None # an older gateway without /observe: screenshot + texts the old way
120
+ if o is None:
121
+ try:
122
+ jpeg = d.screenshot(width=SHOT_WIDTH)
123
+ w, h = d.size()
124
+ except ClousdError as e:
125
+ return [f"error: {e}"]
126
+ iw = _jpeg_w(jpeg)
127
+ _scale[device] = w / float(iw)
128
+ note = f"Screen image {iw}x{int(h / _scale[device])} px (phone {w}x{h})."
129
+ if with_text:
130
+ try:
131
+ note += "\nTexts on screen: " + "; ".join(d.screen_text())
132
+ except ClousdError as e:
133
+ note += f"\nTexts on screen: unavailable ({e.message or e.error})"
134
+ return [Image(data=jpeg, format="jpeg"), note]
135
+ img, scr = o.get("image", {}), o.get("screen", {})
136
+ scale = scr.get("w", 1080) / float(img.get("w") or SHOT_WIDTH)
137
+ _scale[device] = scale
138
+ note = f"Screen image {img.get('w')}x{img.get('h')} px (phone {scr.get('w')}x{scr.get('h')}), observation #{o.get('seq')}."
139
+ if o.get("package"):
140
+ note += f"\nOn screen: {o.get('activity') or o.get('package')}"
141
+ if with_text:
142
+ if o.get("ui_error"):
143
+ note += f"\nElements: unavailable ({o['ui_error']})"
144
+ else:
145
+ note += "\nElements (centre in image pixels):\n" + _elements(o, scale)
146
+ return [Image(data=o["image_bytes"], format="jpeg"), note]
147
+
148
+
149
+ @mcp.tool()
150
+ def act(device: str, action: str, x: Optional[float] = None, y: Optional[float] = None,
151
+ x2: Optional[float] = None, y2: Optional[float] = None, text: str = "", ms: int = 300,
152
+ timeout: int = 30) -> str:
153
+ """Do one thing on the phone, then wait until the screen settles. action is one of:
154
+ tap (x, y) · swipe (x, y, x2, y2, ms) · scroll (text=down|up) · tap_text (text: tap the element whose text
155
+ contains it - usually more reliable than coordinates) · wait_text (text, timeout up to 120 s) · type (text into the
156
+ focused field, any language) · key (text=home|back|recents|enter|tab|del|menu|power|volume_up|volume_down) ·
157
+ open_app (text=package, e.g. com.android.chrome) · close_app (text=package) · open_url (text=https://…) ·
158
+ installed (list apps you can open). Coordinates are in the pixels of the last observe image."""
159
+ d = client().device(device)
160
+ try:
161
+ if action == "tap":
162
+ body = {"op": "tap", "x": _px(device, x), "y": _px(device, y)}
163
+ elif action == "swipe":
164
+ body = {"op": "swipe", "x": _px(device, x), "y": _px(device, y), "x2": _px(device, x2), "y2": _px(device, y2), "ms": ms}
165
+ elif action == "scroll":
166
+ body = {"op": "scroll", "text": text or "down"}
167
+ elif action in ("tap_text", "wait_text"):
168
+ body = {"op": action, "text": text}
169
+ if action == "wait_text":
170
+ body["ms"] = max(1, min(int(timeout), 120)) * 1000
171
+ elif action == "type":
172
+ body = {"op": "text", "text": text}
173
+ elif action == "key":
174
+ body = {"op": "key", "key": text}
175
+ elif action in ("open_app", "close_app"):
176
+ body = {"op": action, "package": text}
177
+ elif action == "open_url":
178
+ body = {"op": "url", "url": text}
179
+ elif action == "installed":
180
+ return ", ".join(d.installed())
181
+ else:
182
+ return "error: unknown action - see the tool description"
183
+ op = body.pop("op")
184
+ try:
185
+ r = d.act(op, settle=True, **body)
186
+ out = r.get("output") or "ok"
187
+ if r.get("changed") is False:
188
+ return out + " (the screen did not change - the action probably missed; observe again)"
189
+ return out + ("" if r.get("settled", True) else " (screen still changing)")
190
+ except ClousdError as e:
191
+ if e.status != 404:
192
+ raise
193
+ return d.action(op, **body).get("output") or "ok" # older gateway without /act
194
+ except (ClousdError, ValueError) as e:
195
+ return f"error: {e}"
196
+
197
+
198
+ @mcp.tool()
199
+ def snapshots(device: str, op: str = "list", snapshot_id: str = "", new_name: str = "") -> object:
200
+ """Saved states of a phone, for starting every run from the same point.
201
+ op=list (default): id, created, android, current, restorable.
202
+ op=save: save the current state. op=restore with snapshot_id: reset the phone to that state.
203
+ op=clone with snapshot_id and new_name (letters and digits, up to 16): a new phone from that state."""
204
+ d = client().device(device)
205
+ try:
206
+ if op == "list":
207
+ return d.snapshots()
208
+ if op == "save":
209
+ return d.save_snapshot().output or "saved"
210
+ if op == "restore" and snapshot_id:
211
+ return d.restore(snapshot_id).output or "restored"
212
+ if op == "clone" and snapshot_id and new_name:
213
+ return "created " + d.clone(snapshot_id, new_name).name
214
+ return "error: op is list, save, restore (snapshot_id) or clone (snapshot_id, new_name)"
215
+ except ClousdError as e:
216
+ return f"error: {e}"
217
+
218
+
219
+ def main() -> None:
220
+ mcp.run()
221
+
222
+
223
+ if __name__ == "__main__":
224
+ main()
@@ -0,0 +1,57 @@
1
+ Metadata-Version: 2.4
2
+ Name: clousd-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server: give an AI agent a real Android phone in the cloud
5
+ License: MIT
6
+ Project-URL: Homepage, https://clousd.com/agents/
7
+ Keywords: mcp,android,cloud phone,ai agents,mobile automation
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: mcp>=1.2.0
11
+ Requires-Dist: clousd>=0.1.0
12
+
13
+ # clousd-mcp
14
+
15
+ An MCP server that gives any MCP-capable agent a real Android phone in the cloud: a device modelled on a
16
+ real phone, with Google Play, a network in the country you choose, and saved states to reset to between runs.
17
+
18
+ Early access: ask for a key at [clousd.com/agents](https://clousd.com/agents/#access).
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ pip install "git+https://github.com/Clousd-Android/clousd-sdk#subdirectory=python"
24
+ pip install "git+https://github.com/Clousd-Android/clousd-sdk#subdirectory=mcp"
25
+ ```
26
+
27
+ Any MCP client (Cursor, Windsurf, Zed, a desktop assistant or your own agent) - stdio server, one environment variable:
28
+
29
+ ```json
30
+ {
31
+ "mcpServers": {
32
+ "clousd": {
33
+ "command": "clousd-mcp",
34
+ "env": { "CLOUSD_API_KEY": "cl_live_..." }
35
+ }
36
+ }
37
+ }
38
+ ```
39
+
40
+ Clients that take a command line instead of JSON: `clousd-mcp` with `CLOUSD_API_KEY` in the environment.
41
+
42
+ ## Tools
43
+
44
+ Four tools, on purpose - agents choose better from a few large tools than from a dozen small ones:
45
+
46
+ | Tool | What it does |
47
+ |---|---|
48
+ | `devices` | list the phones this key can use; start or stop one |
49
+ | `observe` | the screen as an image plus every text on it |
50
+ | `act` | `tap`, `swipe`, `scroll`, `tap_text`, `wait_text`, `type`, `key`, `open_app`, `close_app`, `open_url`, `installed` |
51
+ | `snapshots` | list, save, restore (reset between runs) or clone a phone |
52
+
53
+ Screenshots come back 540 px wide (`CLOUSD_SHOT_WIDTH` to change); `tap` and `swipe` take coordinates in that image
54
+ and the server scales them to the phone. `tap_text` is usually the more reliable way to press a button. Typing works in
55
+ any language.
56
+
57
+ Built on the [`clousd`](../python) Python client. MIT license.
@@ -0,0 +1,11 @@
1
+ README.md
2
+ pyproject.toml
3
+ clousd_mcp/__init__.py
4
+ clousd_mcp/__main__.py
5
+ clousd_mcp/server.py
6
+ clousd_mcp.egg-info/PKG-INFO
7
+ clousd_mcp.egg-info/SOURCES.txt
8
+ clousd_mcp.egg-info/dependency_links.txt
9
+ clousd_mcp.egg-info/entry_points.txt
10
+ clousd_mcp.egg-info/requires.txt
11
+ clousd_mcp.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ clousd-mcp = clousd_mcp.server:main
@@ -0,0 +1,2 @@
1
+ mcp>=1.2.0
2
+ clousd>=0.1.0
@@ -0,0 +1 @@
1
+ clousd_mcp
@@ -0,0 +1,22 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "clousd-mcp"
7
+ version = "0.1.0"
8
+ description = "MCP server: give an AI agent a real Android phone in the cloud"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ dependencies = ["mcp>=1.2.0", "clousd>=0.1.0"]
13
+ keywords = ["mcp", "android", "cloud phone", "ai agents", "mobile automation"]
14
+
15
+ [project.scripts]
16
+ clousd-mcp = "clousd_mcp.server:main"
17
+
18
+ [project.urls]
19
+ Homepage = "https://clousd.com/agents/"
20
+
21
+ [tool.setuptools]
22
+ packages = ["clousd_mcp"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+