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.
- letitpeek_mcp-0.2.0/.gitignore +15 -0
- letitpeek_mcp-0.2.0/PKG-INFO +34 -0
- letitpeek_mcp-0.2.0/README.md +15 -0
- letitpeek_mcp-0.2.0/letitpeek_mcp/__init__.py +3 -0
- letitpeek_mcp-0.2.0/letitpeek_mcp/cli.py +90 -0
- letitpeek_mcp-0.2.0/letitpeek_mcp/client.py +120 -0
- letitpeek_mcp-0.2.0/letitpeek_mcp/discovery.py +55 -0
- letitpeek_mcp-0.2.0/letitpeek_mcp/server.py +169 -0
- letitpeek_mcp-0.2.0/pyproject.toml +35 -0
|
@@ -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,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"]
|