#!/usr/bin/env python3 import sys import json import urllib.request import argparse def main(): parser = argparse.ArgumentParser(description="MCP Stdio-to-HTTP Bridge for ESP32-S3-RLCD-4.2") parser.add_argument("--ip", required=True, help="IP address of the ESP32 board (e.g. 192.168.1.123)") parser.add_argument("--port", type=int, default=80, help="Port the MCP server is listening on (default 80)") args = parser.parse_args() url = f"http://{args.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()