#!/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', ''] 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()