Files
mcp_screen/mcp_bridge.py
T

301 lines
12 KiB
Python
Executable File

#!/usr/bin/env python3
import sys
import json
import urllib.request
import argparse
def discover_screen():
import socket
sys.stderr.write("Searching for ESP32 Screen via UDP broadcast on port 5000...\n")
sys.stderr.flush()
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
s.settimeout(1.5)
broadcast_ips = ['255.255.255.255', '<broadcast>']
try:
hostname = socket.gethostname()
ip_list = socket.gethostbyname_ex(hostname)[2]
for ip in ip_list:
if not ip.startswith('127.'):
parts = ip.split('.')
if len(parts) == 4:
subnet_bcast = f"{parts[0]}.{parts[1]}.{parts[2]}.255"
if subnet_bcast not in broadcast_ips:
broadcast_ips.append(subnet_bcast)
except:
pass
for target_ip in broadcast_ips:
try:
s.sendto(b"DISCOVER_SCREEN", (target_ip, 5000))
except:
pass
try:
data, addr = s.recvfrom(128)
if data == b"SCREEN_IP_80":
sys.stderr.write(f"Auto-discovered ESP32 Screen at IP: {addr[0]} via UDP\n")
sys.stderr.flush()
return addr[0]
except:
pass
finally:
s.close()
return None
def scan_subnet():
import socket
import concurrent.futures
try:
hostname = socket.gethostname()
local_ips = [ip for ip in socket.gethostbyname_ex(hostname)[2] if not ip.startswith('127.')]
if not local_ips:
return None
local_ip = local_ips[0]
except Exception as e:
sys.stderr.write(f"Subnet lookup error: {e}\n")
sys.stderr.flush()
return None
parts = local_ip.split('.')
if len(parts) == 4:
base_ip = f"{parts[0]}.{parts[1]}.{parts[2]}."
else:
return None
sys.stderr.write(f"Scanning local subnet {base_ip}1 to {base_ip}254 on port 80...\n")
sys.stderr.flush()
def check_ip(ip_suffix):
target_ip = f"{base_ip}{ip_suffix}"
url = f"http://{target_ip}:80/api/mcp"
payload = {
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list"
}
req_data = json.dumps(payload).encode('utf-8')
req = urllib.request.Request(
url,
data=req_data,
headers={"Content-Type": "application/json"},
method="POST"
)
try:
with urllib.request.urlopen(req, timeout=2.0) as response:
resp_data = json.loads(response.read().decode('utf-8'))
tools = resp_data.get("result", {}).get("tools", [])
if any(t.get("name") == "clear_screen" for t in tools):
return target_ip
except:
pass
return None
with concurrent.futures.ThreadPoolExecutor(max_workers=50) as executor:
futures = [executor.submit(check_ip, i) for i in range(1, 255)]
for fut in concurrent.futures.as_completed(futures):
res = fut.result()
if res:
executor.shutdown(wait=False)
sys.stderr.write(f"Auto-discovered ESP32 Screen at IP: {res} via subnet scan\n")
sys.stderr.flush()
return res
return None
def main():
parser = argparse.ArgumentParser(description="MCP Stdio-to-HTTP Bridge for ESP32-S3-RLCD-4.2")
parser.add_argument("--ip", help="IP address of the ESP32 board. If omitted, auto-discovery is performed.")
parser.add_argument("--port", type=int, default=80, help="Port the MCP server is listening on (default 80)")
args = parser.parse_args()
ip = args.ip
if not ip:
ip = discover_screen()
if not ip:
sys.stderr.write("UDP broadcast discovery timed out. Initializing subnet sweep...\n")
sys.stderr.flush()
ip = scan_subnet()
if not ip:
sys.stderr.write("Subnet sweep failed. Falling back to hostname: 'esp32screen.local'\n")
sys.stderr.flush()
ip = "esp32screen.local"
url = f"http://{ip}:{args.port}/api/mcp"
sys.stderr.write(f"ESP32 MCP Stdio-to-HTTP Bridge started. Routing stdio to {url}\n")
sys.stderr.flush()
# Sits in loop reading stdio requests from LLM client and routing them to ESP32
while True:
try:
line = sys.stdin.readline()
if not line:
break
# Parse request to ensure it's valid JSON
req_data = json.loads(line.strip())
# Intercept draw_image tool and pre-process the image on the host PC
if req_data.get("method") == "tools/call" and req_data.get("params", {}).get("name") == "draw_image":
try:
args = req_data.get("params", {}).get("arguments", {})
image_base64 = args.get("image_base64")
if not image_base64:
raise ValueError("Missing image_base64 parameter")
# Strip data URI prefix if present
if image_base64.startswith("data:"):
if ";base64," in image_base64:
image_base64 = image_base64.split(";base64,")[1]
import base64
import io
from PIL import Image
img_bytes = base64.b64decode(image_base64)
img = Image.open(io.BytesIO(img_bytes))
# Convert to grayscale
img = img.convert("L")
# Resize to fit screen dimensions (400x300 max)
max_w = int(args.get("maxWidth", 400))
max_h = int(args.get("maxHeight", 300))
# Prevent going over physical screen bounds
max_w = min(max_w, 400)
max_h = min(max_h, 300)
img.thumbnail((max_w, max_h))
# Convert to 1-bit monochrome (with optional Floyd-Steinberg dithering)
dither = args.get("dither", True)
img_1bit = img.convert("1", dither=Image.FLOYDSTEINBERG if dither else Image.NONE)
# Save as binary PBM (P4 format) using PPM format writer on mode 1
pbm_io = io.BytesIO()
img_1bit.save(pbm_io, format="PPM")
pbm_data = pbm_io.getvalue()
# Encode processed PBM to base64
pbm_b64 = base64.b64encode(pbm_data).decode("utf-8")
# Substitute arguments for ESP32
new_args = {
"pbm_base64": pbm_b64,
"x": args.get("x", 0),
"y": args.get("y", 0)
}
req_data["params"]["arguments"] = new_args
except Exception as e:
# Return error response directly to client without contacting ESP32
err_resp = {
"jsonrpc": "2.0",
"error": {
"code": -32602,
"message": f"Bridge image preprocessing failed: {str(e)}"
},
"id": req_data.get("id")
}
sys.stdout.write(json.dumps(err_resp) + "\n")
sys.stdout.flush()
continue
# Forward the JSON-RPC request to the ESP32 board via HTTP POST
req = urllib.request.Request(
url,
data=json.dumps(req_data).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST"
)
with urllib.request.urlopen(req, timeout=10.0) as response:
resp_data = response.read().decode("utf-8")
try:
resp_json = json.loads(resp_data)
result = resp_json.get("result", {})
content_list = result.get("content", [])
if len(content_list) > 0 and content_list[0].get("type") == "text":
text_val = content_list[0].get("text", "")
if text_val.startswith("__PBM_BASE64__:"):
b64_pbm = text_val.split(":", 1)[1]
import base64
import subprocess
import os
pbm_bytes = base64.b64decode(b64_pbm)
with open("temp_mcp.pbm", "wb") as pf:
pf.write(pbm_bytes)
# Convert PBM to PNG on macOS using standard sips tool
subprocess.run(["sips", "-s", "format", "png", "temp_mcp.pbm", "--out", "temp_mcp.png"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if os.path.exists("temp_mcp.png"):
with open("temp_mcp.png", "rb") as pngf:
png_bytes = pngf.read()
b64_png = base64.b64encode(png_bytes).decode("utf-8")
# Replace standard text content with MCP image schema
content_list[0] = {
"type": "image",
"data": b64_png,
"mimeType": "image/png"
}
# Cleanup temp files
try:
os.remove("temp_mcp.pbm")
os.remove("temp_mcp.png")
except:
pass
else:
content_list[0] = {
"type": "text",
"text": "Screenshot captured, but PNG conversion failed on host PC."
}
resp_data = json.dumps(resp_json)
except Exception as ex:
sys.stderr.write(f"Bridge image conversion error: {ex}\n")
sys.stderr.flush()
# Write the response back to stdio for the LLM client
sys.stdout.write(resp_data + "\n")
sys.stdout.flush()
except urllib.error.URLError as e:
# Send standard JSON-RPC internal error response
err_resp = {
"jsonrpc": "2.0",
"error": {
"code": -32000,
"message": f"Bridge failed to reach ESP32: {e.reason}"
},
"id": req_data.get("id") if "req_data" in locals() else None
}
sys.stdout.write(json.dumps(err_resp) + "\n")
sys.stdout.flush()
sys.stderr.write(f"Bridge error: Failed to connect to ESP32 at {url}: {e}\n")
sys.stderr.flush()
except Exception as e:
err_resp = {
"jsonrpc": "2.0",
"error": {
"code": -32603,
"message": f"Bridge Internal Error: {str(e)}"
},
"id": req_data.get("id") if "req_data" in locals() else None
}
sys.stdout.write(json.dumps(err_resp) + "\n")
sys.stdout.flush()
sys.stderr.write(f"Bridge error: {e}\n")
sys.stderr.flush()
if __name__ == "__main__":
main()