letitpeek-mcp 0.2.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,15 @@
1
+ # Android
2
+ android/.gradle/
3
+ android/build/
4
+ android/app/build/
5
+ android/local.properties
6
+ android/.idea/
7
+ *.iml
8
+ *.apk
9
+ *.keystore
10
+ # Python
11
+ __pycache__/
12
+ *.egg-info/
13
+ .venv/
14
+ dist/
15
+ android/.kotlin/
@@ -0,0 +1,34 @@
1
+ Metadata-Version: 2.5
2
+ Name: letitpeek-mcp
3
+ Version: 0.2.0
4
+ Summary: MCP stdio bridge and CLI for the LetItPeek Android camera server
5
+ Project-URL: Homepage, https://gitlab.com/n-group4622738/letitpeek
6
+ Project-URL: Repository, https://gitlab.com/n-group4622738/letitpeek
7
+ Author: nickzam
8
+ License: MIT
9
+ Keywords: ai-agent,android,camera,mcp
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Multimedia :: Graphics :: Capture :: Digital Camera
14
+ Requires-Python: >=3.10
15
+ Requires-Dist: httpx>=0.27
16
+ Requires-Dist: mcp<3,>=2.0
17
+ Requires-Dist: zeroconf>=0.130
18
+ Description-Content-Type: text/markdown
19
+
20
+ # letitpeek-mcp
21
+
22
+ MCP (stdio) bridge and CLI for the [LetItPeek](..) Android camera server.
23
+
24
+ ```bash
25
+ # MCP server (stdio) — add to Claude Desktop / Cursor / any MCP client
26
+ uvx letitpeek-mcp \
27
+ --url http://192.168.1.23:8420 --token <token>
28
+
29
+ # CLI
30
+ export LETITPEEK_URL=http://192.168.1.23:8420 LETITPEEK_TOKEN=<token>
31
+ uvx --from letitpeek-mcp letitpeek capture -o shot.jpg
32
+ ```
33
+
34
+ Both accept `--discover` / `letitpeek discover` to find the phone via mDNS.
@@ -0,0 +1,15 @@
1
+ # letitpeek-mcp
2
+
3
+ MCP (stdio) bridge and CLI for the [LetItPeek](..) Android camera server.
4
+
5
+ ```bash
6
+ # MCP server (stdio) — add to Claude Desktop / Cursor / any MCP client
7
+ uvx letitpeek-mcp \
8
+ --url http://192.168.1.23:8420 --token <token>
9
+
10
+ # CLI
11
+ export LETITPEEK_URL=http://192.168.1.23:8420 LETITPEEK_TOKEN=<token>
12
+ uvx --from letitpeek-mcp letitpeek capture -o shot.jpg
13
+ ```
14
+
15
+ Both accept `--discover` / `letitpeek discover` to find the phone via mDNS.
@@ -0,0 +1,3 @@
1
+ """LetItPeek MCP bridge."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,90 @@
1
+ """`letitpeek` command-line client for shell-driven agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+
9
+ from .client import LetItPeekClient, LetItPeekError
10
+ from .discovery import discover
11
+
12
+
13
+ def main() -> None:
14
+ p = argparse.ArgumentParser(prog="letitpeek", description="Control a LetItPeek phone camera")
15
+ p.add_argument("--url", help="Phone base URL (or $LETITPEEK_URL)")
16
+ p.add_argument("--token", help="Bearer token (or $LETITPEEK_TOKEN)")
17
+ sub = p.add_subparsers(dest="cmd", required=True)
18
+
19
+ sub.add_parser("discover", help="Find phones on the LAN via mDNS")
20
+ sub.add_parser("status")
21
+ sub.add_parser("cameras")
22
+ sub.add_parser("settings")
23
+
24
+ c = sub.add_parser("capture", help="Take a photo")
25
+ c.add_argument("-o", "--output", default="shot.jpg")
26
+ c.add_argument("--quality", type=int)
27
+ c.add_argument("--max-width", type=int)
28
+ c.add_argument("--camera")
29
+ c.add_argument("--delay-ms", type=int)
30
+
31
+ pv = sub.add_parser("preview", help="Save the latest low-res frame")
32
+ pv.add_argument("-o", "--output", default="preview.jpg")
33
+
34
+ w = sub.add_parser("wait", help="Block until the scene changes")
35
+ w.add_argument("--threshold", type=float)
36
+ w.add_argument("--timeout-ms", type=int)
37
+
38
+ z = sub.add_parser("zoom")
39
+ z.add_argument("ratio", type=float)
40
+ f = sub.add_parser("focus")
41
+ f.add_argument("x", type=float)
42
+ f.add_argument("y", type=float)
43
+ f.add_argument("--lock", action="store_true")
44
+ t = sub.add_parser("torch")
45
+ t.add_argument("state", choices=["on", "off"])
46
+ s = sub.add_parser("set", help="PATCH /v1/settings with a JSON object")
47
+ s.add_argument("json_body")
48
+
49
+ a = p.parse_args()
50
+
51
+ if a.cmd == "discover":
52
+ for d in discover():
53
+ print(f"{d.name}\t{d.url}")
54
+ return
55
+
56
+ cl = LetItPeekClient.from_env(a.url, a.token)
57
+ try:
58
+ if a.cmd == "status":
59
+ print(json.dumps(cl.status(), indent=2))
60
+ elif a.cmd == "cameras":
61
+ print(json.dumps(cl.cameras(), indent=2))
62
+ elif a.cmd == "settings":
63
+ print(json.dumps(cl.settings(), indent=2))
64
+ elif a.cmd == "capture":
65
+ shot = cl.capture(quality=a.quality, max_width=a.max_width, camera=a.camera, delay_ms=a.delay_ms)
66
+ with open(a.output, "wb") as fh:
67
+ fh.write(shot.jpeg)
68
+ print(f"{a.output}: {shot.width}x{shot.height}, {len(shot.jpeg)} bytes, {shot.capture_ms} ms")
69
+ elif a.cmd == "preview":
70
+ data = cl.preview()
71
+ with open(a.output, "wb") as fh:
72
+ fh.write(data)
73
+ print(f"{a.output}: {len(data)} bytes")
74
+ elif a.cmd == "wait":
75
+ print(json.dumps(cl.wait_for_change(threshold=a.threshold, timeout_ms=a.timeout_ms)))
76
+ elif a.cmd == "zoom":
77
+ print(json.dumps(cl.zoom(a.ratio)))
78
+ elif a.cmd == "focus":
79
+ mode = "lock" if a.lock else "point"
80
+ print(json.dumps(cl.patch_settings(focus={"mode": mode, "x": a.x, "y": a.y})))
81
+ elif a.cmd == "torch":
82
+ print(json.dumps(cl.torch(a.state == "on")))
83
+ elif a.cmd == "set":
84
+ print(json.dumps(cl.patch_settings(**json.loads(a.json_body))))
85
+ except LetItPeekError as e:
86
+ sys.exit(f"error: {e}")
87
+
88
+
89
+ if __name__ == "__main__":
90
+ main()
@@ -0,0 +1,120 @@
1
+ """Thin HTTP client for the LetItPeek REST API (/v1)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+ import httpx
10
+
11
+
12
+ class LetItPeekError(RuntimeError):
13
+ def __init__(self, code: str, message: str, status: int):
14
+ super().__init__(f"{code}: {message} (HTTP {status})")
15
+ self.code = code
16
+ self.status = status
17
+
18
+
19
+ @dataclass
20
+ class Capture:
21
+ jpeg: bytes
22
+ width: int
23
+ height: int
24
+ timestamp_ms: int
25
+ capture_ms: int
26
+
27
+
28
+ class LetItPeekClient:
29
+ def __init__(self, url: str, token: str, timeout: float = 30.0):
30
+ self.url = url.rstrip("/")
31
+ self.token = token
32
+ self._http = httpx.Client(
33
+ base_url=self.url,
34
+ headers={"Authorization": f"Bearer {token}"},
35
+ timeout=timeout,
36
+ )
37
+
38
+ @classmethod
39
+ def from_env(cls, url: str | None = None, token: str | None = None) -> "LetItPeekClient":
40
+ url = url or os.environ.get("LETITPEEK_URL")
41
+ token = token or os.environ.get("LETITPEEK_TOKEN")
42
+ if not url or not token:
43
+ raise SystemExit(
44
+ "LetItPeek URL/token missing. Pass --url/--token or set LETITPEEK_URL / LETITPEEK_TOKEN "
45
+ "(shown in the app's pairing card)."
46
+ )
47
+ return cls(url, token)
48
+
49
+ def _check(self, r: httpx.Response) -> httpx.Response:
50
+ if r.is_success:
51
+ return r
52
+ try:
53
+ err = r.json()["error"]
54
+ raise LetItPeekError(err["code"], err["message"], r.status_code)
55
+ except (ValueError, KeyError):
56
+ raise LetItPeekError("http_error", r.text[:200], r.status_code)
57
+
58
+ def health(self) -> bool:
59
+ return self._http.get("/v1/health").is_success
60
+
61
+ def status(self) -> dict[str, Any]:
62
+ return self._check(self._http.get("/v1/status")).json()
63
+
64
+ def cameras(self) -> list[dict[str, Any]]:
65
+ return self._check(self._http.get("/v1/cameras")).json()
66
+
67
+ def settings(self) -> dict[str, Any]:
68
+ return self._check(self._http.get("/v1/settings")).json()
69
+
70
+ def patch_settings(self, **fields: Any) -> dict[str, Any]:
71
+ body = {k: v for k, v in fields.items() if v is not None}
72
+ return self._check(self._http.patch("/v1/settings", json=body)).json()
73
+
74
+ def capture(
75
+ self,
76
+ quality: int | None = None,
77
+ max_width: int | None = None,
78
+ camera: str | None = None,
79
+ delay_ms: int | None = None,
80
+ ) -> Capture:
81
+ params: dict[str, Any] = {}
82
+ if quality is not None:
83
+ params["quality"] = quality
84
+ if max_width is not None:
85
+ params["max_width"] = max_width
86
+ if camera is not None:
87
+ params["camera"] = camera
88
+ if delay_ms is not None:
89
+ params["delay_ms"] = delay_ms
90
+ r = self._check(self._http.get("/v1/capture", params=params))
91
+ h = r.headers
92
+ return Capture(
93
+ jpeg=r.content,
94
+ width=int(h.get("X-Capture-Width", 0)),
95
+ height=int(h.get("X-Capture-Height", 0)),
96
+ timestamp_ms=int(h.get("X-Capture-Timestamp", 0)),
97
+ capture_ms=int(h.get("X-Capture-Ms", 0)),
98
+ )
99
+
100
+ def wait_for_change(self, threshold: float | None = None, timeout_ms: int | None = None) -> dict[str, Any]:
101
+ params: dict[str, Any] = {}
102
+ if threshold is not None:
103
+ params["threshold"] = threshold
104
+ if timeout_ms is not None:
105
+ params["timeout_ms"] = timeout_ms
106
+ read_timeout = ((timeout_ms or 30_000) / 1000) + 15
107
+ r = self._http.get("/v1/wait_for_change", params=params, timeout=httpx.Timeout(10, read=read_timeout))
108
+ return self._check(r).json()
109
+
110
+ def preview(self) -> bytes:
111
+ return self._check(self._http.get("/v1/preview.jpg")).content
112
+
113
+ def focus(self, x: float, y: float) -> dict[str, Any]:
114
+ return self._check(self._http.post("/v1/focus", json={"x": x, "y": y})).json()
115
+
116
+ def zoom(self, ratio: float) -> dict[str, Any]:
117
+ return self._check(self._http.post("/v1/zoom", json={"ratio": ratio})).json()
118
+
119
+ def torch(self, on: bool) -> dict[str, Any]:
120
+ return self._check(self._http.post("/v1/torch", json={"on": on})).json()
@@ -0,0 +1,55 @@
1
+ """mDNS discovery of LetItPeek phones on the LAN (_letitpeek._tcp)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import socket
6
+ import time
7
+ from dataclasses import dataclass
8
+
9
+ from zeroconf import ServiceBrowser, ServiceListener, Zeroconf
10
+
11
+ SERVICE_TYPE = "_letitpeek._tcp.local."
12
+
13
+
14
+ @dataclass
15
+ class Found:
16
+ name: str
17
+ host: str
18
+ port: int
19
+
20
+ @property
21
+ def url(self) -> str:
22
+ return f"http://{self.host}:{self.port}"
23
+
24
+
25
+ class _Listener(ServiceListener):
26
+ def __init__(self) -> None:
27
+ self.found: list[Found] = []
28
+
29
+ def add_service(self, zc: Zeroconf, type_: str, name: str) -> None:
30
+ info = zc.get_service_info(type_, name, timeout=2000)
31
+ if not info:
32
+ return
33
+ addrs = info.parsed_addresses(version=socket.AF_INET) if hasattr(info, "parsed_addresses") else []
34
+ if not addrs:
35
+ return
36
+ self.found.append(Found(name=name.removesuffix("." + SERVICE_TYPE), host=addrs[0], port=info.port or 0))
37
+
38
+ def update_service(self, zc: Zeroconf, type_: str, name: str) -> None: # pragma: no cover
39
+ pass
40
+
41
+ def remove_service(self, zc: Zeroconf, type_: str, name: str) -> None: # pragma: no cover
42
+ pass
43
+
44
+
45
+ def discover(timeout: float = 3.0) -> list[Found]:
46
+ zc = Zeroconf()
47
+ listener = _Listener()
48
+ try:
49
+ ServiceBrowser(zc, SERVICE_TYPE, listener)
50
+ deadline = time.time() + timeout
51
+ while time.time() < deadline:
52
+ time.sleep(0.1)
53
+ finally:
54
+ zc.close()
55
+ return listener.found
@@ -0,0 +1,169 @@
1
+ """MCP stdio server exposing a LetItPeek phone camera as tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from typing import Any
9
+
10
+ from mcp.server.mcpserver import Image, MCPServer
11
+
12
+ from .client import LetItPeekClient
13
+ from .discovery import discover
14
+
15
+ MCP_DEFAULT_MAX_WIDTH = 1600
16
+ MCP_DEFAULT_QUALITY = 80
17
+
18
+ _client: LetItPeekClient | None = None
19
+
20
+
21
+ def client() -> LetItPeekClient:
22
+ assert _client is not None, "client not initialised"
23
+ return _client
24
+
25
+
26
+ mcp = MCPServer(
27
+ "letitpeek",
28
+ instructions=(
29
+ "A phone camera pointed at the physical world (a monitor, a dev board, an instrument). "
30
+ "Use capture_image to look. If text is blurry, call focus_at on the region of interest and "
31
+ "increase max_width. Use set_zoom to get closer. get_preview is fast but low quality."
32
+ ),
33
+ )
34
+
35
+
36
+ def _meta(prefix: str, extra: dict[str, Any]) -> str:
37
+ return prefix + " " + json.dumps(extra, separators=(",", ":"))
38
+
39
+
40
+ @mcp.tool()
41
+ def camera_status() -> str:
42
+ """Phone/camera status: device, battery, thermal state, IP, current settings, available cameras."""
43
+ return json.dumps(client().status(), indent=2)
44
+
45
+
46
+ @mcp.tool()
47
+ def list_cameras() -> str:
48
+ """List camera ids with facing, zoom range, torch availability and exposure range."""
49
+ return json.dumps(client().cameras(), indent=2)
50
+
51
+
52
+ @mcp.tool(structured_output=False)
53
+ def capture_image(
54
+ max_width: int = MCP_DEFAULT_MAX_WIDTH,
55
+ quality: int = MCP_DEFAULT_QUALITY,
56
+ camera: str | None = None,
57
+ delay_ms: int | None = None,
58
+ ) -> list[Any]:
59
+ """Take a high-quality photo with the phone camera and return it.
60
+
61
+ Use max_width=2560 or more when reading small text on a screen. `camera` is "back", "front"
62
+ or a camera id from list_cameras. `delay_ms` waits before capturing (e.g. after changing settings).
63
+ """
64
+ shot = client().capture(quality=quality, max_width=max_width, camera=camera, delay_ms=delay_ms)
65
+ return [
66
+ Image(data=shot.jpeg, format="jpeg"),
67
+ _meta("captured", {"w": shot.width, "h": shot.height, "ts": shot.timestamp_ms, "capture_ms": shot.capture_ms}),
68
+ ]
69
+
70
+
71
+ @mcp.tool(structured_output=False)
72
+ def get_preview() -> list[Any]:
73
+ """Return the latest low-resolution live frame instantly (no capture latency). Good for checking framing."""
74
+ return [Image(data=client().preview(), format="jpeg"), "preview frame (low resolution)"]
75
+
76
+
77
+ @mcp.tool(structured_output=False)
78
+ def wait_for_change(
79
+ threshold: float = 0.04,
80
+ timeout_ms: int = 30000,
81
+ capture: bool = True,
82
+ max_width: int = MCP_DEFAULT_MAX_WIDTH,
83
+ ) -> list[Any]:
84
+ """Block until the scene visibly changes (screen updates, LED turns on...) or the timeout elapses.
85
+
86
+ threshold is the mean grayscale difference (0..1) that counts as a change. Returns the detection
87
+ result and, when capture=True, a fresh photo.
88
+ """
89
+ r = client().wait_for_change(threshold=threshold, timeout_ms=timeout_ms)
90
+ out: list[Any] = [_meta("change", r)]
91
+ if capture:
92
+ shot = client().capture(quality=MCP_DEFAULT_QUALITY, max_width=max_width)
93
+ out.append(Image(data=shot.jpeg, format="jpeg"))
94
+ return out
95
+
96
+
97
+ @mcp.tool()
98
+ def set_zoom(ratio: float) -> str:
99
+ """Set optical/digital zoom ratio (1.0 = no zoom). See list_cameras for the supported range."""
100
+ return json.dumps(client().zoom(ratio))
101
+
102
+
103
+ @mcp.tool()
104
+ def focus_at(x: float, y: float, lock: bool = False) -> str:
105
+ """Focus and meter on a point given in normalised image coordinates (x, y in 0..1, origin top-left).
106
+
107
+ With lock=True the focus/exposure stays fixed on that point until changed.
108
+ """
109
+ mode = "lock" if lock else "point"
110
+ return json.dumps(client().patch_settings(focus={"mode": mode, "x": x, "y": y}))
111
+
112
+
113
+ @mcp.tool()
114
+ def set_torch(on: bool) -> str:
115
+ """Turn the phone's flashlight on or off (useful for dark electronics)."""
116
+ return json.dumps(client().torch(on))
117
+
118
+
119
+ @mcp.tool()
120
+ def set_exposure(index: int, lock: bool | None = None) -> str:
121
+ """Set exposure compensation index (negative = darker, helps with bright monitors). Optionally lock AE."""
122
+ return json.dumps(client().patch_settings(exposureIndex=index, aeLock=lock))
123
+
124
+
125
+ @mcp.tool()
126
+ def select_camera(camera: str) -> str:
127
+ """Switch camera: "back", "front", or an id from list_cameras."""
128
+ return json.dumps(client().patch_settings(camera=camera))
129
+
130
+
131
+ @mcp.tool()
132
+ def set_camera_settings(
133
+ resolution: str | None = None,
134
+ flicker: str | None = None,
135
+ rotate: int | None = None,
136
+ focus_auto: bool | None = None,
137
+ ) -> str:
138
+ """Adjust capture settings.
139
+
140
+ resolution: "max" | "1080p" | "720p" | "480p". flicker: "auto" | "50hz" | "60hz" | "off" (anti-banding
141
+ for screens). rotate: extra rotation 0/90/180/270 if the phone is mounted sideways. focus_auto=True
142
+ returns to continuous autofocus.
143
+ """
144
+ focus = {"mode": "auto"} if focus_auto else None
145
+ return json.dumps(client().patch_settings(resolution=resolution, flicker=flicker, rotate=rotate, focus=focus))
146
+
147
+
148
+ def main() -> None:
149
+ p = argparse.ArgumentParser(description="LetItPeek MCP stdio bridge")
150
+ p.add_argument("--url", help="Phone base URL, e.g. http://192.168.1.23:8420 (or $LETITPEEK_URL)")
151
+ p.add_argument("--token", help="Bearer token from the app (or $LETITPEEK_TOKEN)")
152
+ p.add_argument("--discover", action="store_true", help="Find the phone via mDNS instead of --url")
153
+ args = p.parse_args()
154
+
155
+ url = args.url
156
+ if args.discover and not url:
157
+ found = discover()
158
+ if not found:
159
+ sys.exit("No LetItPeek phone found on the LAN via mDNS")
160
+ url = found[0].url
161
+ print(f"discovered {found[0].name} at {url}", file=sys.stderr)
162
+
163
+ global _client
164
+ _client = LetItPeekClient.from_env(url, args.token)
165
+ mcp.run(transport="stdio")
166
+
167
+
168
+ if __name__ == "__main__":
169
+ main()
@@ -0,0 +1,35 @@
1
+ [project]
2
+ name = "letitpeek-mcp"
3
+ version = "0.2.0"
4
+ description = "MCP stdio bridge and CLI for the LetItPeek Android camera server"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = { text = "MIT" }
8
+ authors = [{ name = "nickzam" }]
9
+ keywords = ["mcp", "camera", "android", "ai-agent"]
10
+ classifiers = [
11
+ "Programming Language :: Python :: 3",
12
+ "License :: OSI Approved :: MIT License",
13
+ "Operating System :: OS Independent",
14
+ "Topic :: Multimedia :: Graphics :: Capture :: Digital Camera",
15
+ ]
16
+ dependencies = [
17
+ "mcp>=2.0,<3",
18
+ "httpx>=0.27",
19
+ "zeroconf>=0.130",
20
+ ]
21
+
22
+ [project.urls]
23
+ Homepage = "https://gitlab.com/n-group4622738/letitpeek"
24
+ Repository = "https://gitlab.com/n-group4622738/letitpeek"
25
+
26
+ [project.scripts]
27
+ letitpeek-mcp = "letitpeek_mcp.server:main"
28
+ letitpeek = "letitpeek_mcp.cli:main"
29
+
30
+ [build-system]
31
+ requires = ["hatchling"]
32
+ build-backend = "hatchling.build"
33
+
34
+ [tool.hatch.build.targets.wheel]
35
+ packages = ["letitpeek_mcp"]