diff --git a/src/server.py b/src/server.py new file mode 100644 index 0000000..78e089a --- /dev/null +++ b/src/server.py @@ -0,0 +1,902 @@ +""" +Penpot MCP Server +Exposes Penpot design tools as MCP tools for Claude Code. +Enables: design from description → iterate → export → apply to frontend code. +""" + +import os +import uuid +import json +import base64 +import logging +import httpx +from mcp.server import Server +from mcp.server.sse import SseServerTransport +from mcp.types import Tool, TextContent, ImageContent +from starlette.applications import Starlette +from starlette.routing import Route, Mount +from starlette.requests import Request + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +PENPOT_BASE_URL = os.environ.get("PENPOT_BASE_URL", "http://192.168.87.30") +PENPOT_EMAIL = os.environ.get("PENPOT_EMAIL", "") +PENPOT_PASSWORD = os.environ.get("PENPOT_PASSWORD", "") +GITEA_BASE_URL = os.environ.get("GITEA_BASE_URL", "https://repo.adservio.us") +GITEA_TOKEN = os.environ.get("GITEA_TOKEN", "") + +# Project → Gitea repo mapping +PROJECT_REPOS = { + "aistocks": "MoserHome/AIstocks", + "resource-management": "MoserHome/resource-management", + "ollama-mcp": "ai_approver/ollama-mcp", + "claude-monitor": "MoserHome/claude-monitor", +} + +app = Server("penpot-mcp") + +# ── Penpot session ──────────────────────────────────────────────────────────── + +_session_token: str | None = None + +async def _get_token() -> str: + global _session_token + if _session_token: + return _session_token + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.post( + f"{PENPOT_BASE_URL}/api/rpc/command/login-with-password", + json={"email": PENPOT_EMAIL, "password": PENPOT_PASSWORD}, + headers=_HEADERS, + ) + resp.raise_for_status() + data = resp.json() + _session_token = data["authToken"] + logger.info("Authenticated with Penpot, profile: %s", data.get("email")) + return _session_token + + +_HEADERS = {"Accept": "application/json", "Content-Type": "application/json"} + +async def _penpot(command: str, payload: dict) -> dict: + """Call a Penpot RPC command with auth.""" + token = await _get_token() + headers = {**_HEADERS, "Authorization": f"Token {token}"} + async with httpx.AsyncClient(timeout=60) as client: + resp = await client.post( + f"{PENPOT_BASE_URL}/api/rpc/command/{command}", + json=payload, + headers=headers, + ) + if resp.status_code == 401: + global _session_token + _session_token = None + token = await _get_token() + headers["Authorization"] = f"Token {token}" + resp = await client.post( + f"{PENPOT_BASE_URL}/api/rpc/command/{command}", + json=payload, + headers=headers, + ) + resp.raise_for_status() + return resp.json() + + +async def _penpot_export(file_id: str, page_id: str, object_id: str, fmt: str = "png", scale: float = 2.0) -> bytes: + """Export a frame/shape as PNG or SVG bytes.""" + token = await _get_token() + params = { + "file-id": file_id, + "page-id": page_id, + "object-id": object_id, + "export-type": fmt, + "scale": scale, + } + async with httpx.AsyncClient(timeout=60) as client: + resp = await client.get( + f"{PENPOT_BASE_URL}/export", + params=params, + headers={"Authorization": f"Token {token}"}, + ) + resp.raise_for_status() + return resp.content + + +def _new_id() -> str: + return str(uuid.uuid4()) + + +# ── Gitea helpers ───────────────────────────────────────────────────────────── + +async def _gitea_put_file(repo: str, path: str, content: str, message: str, sha: str | None = None): + """Create or update a file in a Gitea repo.""" + encoded = base64.b64encode(content.encode()).decode() + payload: dict = {"message": message, "content": encoded} + if sha: + payload["sha"] = sha + + method = "PUT" if sha else "POST" + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.request( + method, + f"{GITEA_BASE_URL}/api/v1/repos/{repo}/contents/{path}", + json=payload, + headers={"Authorization": f"token {GITEA_TOKEN}"}, + ) + resp.raise_for_status() + return resp.json() + + +async def _gitea_get_sha(repo: str, path: str) -> str | None: + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.get( + f"{GITEA_BASE_URL}/api/v1/repos/{repo}/contents/{path}", + headers={"Authorization": f"token {GITEA_TOKEN}"}, + ) + if resp.status_code == 404: + return None + resp.raise_for_status() + return resp.json().get("sha") + + +# ── MCP tools ───────────────────────────────────────────────────────────────── + +@app.list_tools() +async def list_tools() -> list[Tool]: + return [ + Tool( + name="penpot_list_files", + description="List all Penpot design files for a project.", + inputSchema={ + "type": "object", + "properties": { + "project": {"type": "string", "description": "Project name (e.g. aistocks, resource-management)"}, + }, + "required": ["project"], + }, + ), + Tool( + name="penpot_create_file", + description="Create a new Penpot design file for a project. Returns file_id and page_id.", + inputSchema={ + "type": "object", + "properties": { + "project": {"type": "string", "description": "Project name"}, + "name": {"type": "string", "description": "Design file name (e.g. 'Dashboard', 'Login Screen')"}, + }, + "required": ["project", "name"], + }, + ), + Tool( + name="penpot_get_file", + description="Get the structure of an existing Penpot design file (pages, frames, shapes).", + inputSchema={ + "type": "object", + "properties": { + "file_id": {"type": "string"}, + }, + "required": ["file_id"], + }, + ), + Tool( + name="penpot_add_frame", + description=( + "Add a frame (artboard/screen) to a Penpot page. " + "A frame represents one screen or component. Returns frame_id." + ), + inputSchema={ + "type": "object", + "properties": { + "file_id": {"type": "string"}, + "page_id": {"type": "string"}, + "name": {"type": "string", "description": "Frame name (e.g. 'Dashboard - Desktop')"}, + "width": {"type": "number", "default": 1440}, + "height": {"type": "number", "default": 900}, + "x": {"type": "number", "default": 0}, + "y": {"type": "number", "default": 0}, + "background": {"type": "string", "description": "Hex background color", "default": "#FFFFFF"}, + }, + "required": ["file_id", "page_id", "name"], + }, + ), + Tool( + name="penpot_add_shape", + description=( + "Add a shape (rect, text, circle) inside a frame. " + "For text: set content field. For rects: set fill_color. Returns shape_id." + ), + inputSchema={ + "type": "object", + "properties": { + "file_id": {"type": "string"}, + "page_id": {"type": "string"}, + "frame_id": {"type": "string"}, + "shape_type": {"type": "string", "enum": ["rect", "text", "circle", "image"]}, + "name": {"type": "string"}, + "x": {"type": "number"}, + "y": {"type": "number"}, + "width": {"type": "number"}, + "height": {"type": "number"}, + "fill_color": {"type": "string", "description": "Hex fill color (e.g. #4F46E5)"}, + "fill_opacity": {"type": "number", "default": 1}, + "border_radius": {"type": "number", "default": 0}, + "content": {"type": "string", "description": "Text content (text shapes only)"}, + "font_size": {"type": "number", "default": 14}, + "font_weight": {"type": "string", "default": "400"}, + "text_color": {"type": "string", "description": "Text color hex", "default": "#000000"}, + "opacity": {"type": "number", "default": 1}, + }, + "required": ["file_id", "page_id", "frame_id", "shape_type", "name", "x", "y", "width", "height"], + }, + ), + Tool( + name="penpot_update_shape", + description="Update properties of an existing shape (color, size, position, text, etc.).", + inputSchema={ + "type": "object", + "properties": { + "file_id": {"type": "string"}, + "page_id": {"type": "string"}, + "shape_id": {"type": "string"}, + "props": { + "type": "object", + "description": "Properties to update: x, y, width, height, fill_color, content, font_size, border_radius, opacity", + }, + }, + "required": ["file_id", "page_id", "shape_id", "props"], + }, + ), + Tool( + name="penpot_delete_shape", + description="Delete a shape from a page.", + inputSchema={ + "type": "object", + "properties": { + "file_id": {"type": "string"}, + "page_id": {"type": "string"}, + "shape_id": {"type": "string"}, + }, + "required": ["file_id", "page_id", "shape_id"], + }, + ), + Tool( + name="penpot_screenshot", + description=( + "Export a frame as a PNG image so you can see the current design. " + "Returns a base64-encoded image." + ), + inputSchema={ + "type": "object", + "properties": { + "file_id": {"type": "string"}, + "page_id": {"type": "string"}, + "frame_id": {"type": "string"}, + "scale": {"type": "number", "default": 1.5, "description": "Export scale (1=1x, 2=2x)"}, + }, + "required": ["file_id", "page_id", "frame_id"], + }, + ), + Tool( + name="penpot_apply_color_theme", + description="Apply a color theme to a file (sets named color swatches in the library).", + inputSchema={ + "type": "object", + "properties": { + "file_id": {"type": "string"}, + "colors": { + "type": "object", + "description": "Color token map, e.g. {\"primary\": \"#4F46E5\", \"background\": \"#F9FAFB\"}", + }, + }, + "required": ["file_id", "colors"], + }, + ), + Tool( + name="penpot_export_design_tokens", + description="Export design tokens (colors, typography, spacing) from a file as JSON.", + inputSchema={ + "type": "object", + "properties": { + "file_id": {"type": "string"}, + }, + "required": ["file_id"], + }, + ), + Tool( + name="penpot_save_to_repo", + description=( + "Save the current design file structure to the project's git repo " + "under penpot/pages/.json. Commits to the current branch." + ), + inputSchema={ + "type": "object", + "properties": { + "file_id": {"type": "string"}, + "project": {"type": "string", "description": "Project name (e.g. aistocks)"}, + "page_name": {"type": "string", "description": "Page/screen name for the filename"}, + }, + "required": ["file_id", "project", "page_name"], + }, + ), + Tool( + name="penpot_apply_to_frontend", + description=( + "Generate a React component from a Penpot frame and write it to the project's frontend/src/components/. " + "Also updates Tailwind config with any new color tokens. Saves files to the repo." + ), + inputSchema={ + "type": "object", + "properties": { + "file_id": {"type": "string"}, + "page_id": {"type": "string"}, + "frame_id": {"type": "string"}, + "project": {"type": "string"}, + "component_name": {"type": "string", "description": "PascalCase component name (e.g. DashboardHeader)"}, + }, + "required": ["file_id", "page_id", "frame_id", "project", "component_name"], + }, + ), + ] + + +# ── Tool handlers ───────────────────────────────────────────────────────────── + +@app.call_tool() +async def call_tool(name: str, arguments: dict): + try: + if name == "penpot_list_files": + return await _list_files(arguments) + elif name == "penpot_create_file": + return await _create_file(arguments) + elif name == "penpot_get_file": + return await _get_file(arguments) + elif name == "penpot_add_frame": + return await _add_frame(arguments) + elif name == "penpot_add_shape": + return await _add_shape(arguments) + elif name == "penpot_update_shape": + return await _update_shape(arguments) + elif name == "penpot_delete_shape": + return await _delete_shape(arguments) + elif name == "penpot_screenshot": + return await _screenshot(arguments) + elif name == "penpot_apply_color_theme": + return await _apply_color_theme(arguments) + elif name == "penpot_export_design_tokens": + return await _export_design_tokens(arguments) + elif name == "penpot_save_to_repo": + return await _save_to_repo(arguments) + elif name == "penpot_apply_to_frontend": + return await _apply_to_frontend(arguments) + else: + return [TextContent(type="text", text=f"Unknown tool: {name}")] + except Exception as e: + logger.exception("Tool %s failed", name) + return [TextContent(type="text", text=f"Error: {e}")] + + +async def _list_files(args: dict): + profile = await _penpot("get-profile", {}) + teams = await _penpot("get-teams", {}) + project_name = args["project"].lower() + + results = [] + for team in teams: + projects = await _penpot("get-projects", {"teamId": team["id"]}) + for proj in projects: + if project_name in proj["name"].lower(): + files = await _penpot("get-project-files", {"projectId": proj["id"]}) + for f in files: + results.append({ + "file_id": f["id"], + "name": f["name"], + "project": proj["name"], + "modified_at": f.get("modifiedAt"), + }) + + if not results: + return [TextContent(type="text", text=f"No files found for project '{project_name}'. Use penpot_create_file to create one.")] + + text = f"Design files for '{project_name}':\n" + for r in results: + text += f" • {r['name']} (file_id: {r['file_id']}, modified: {r.get('modified_at', 'unknown')})\n" + return [TextContent(type="text", text=text)] + + +async def _create_file(args: dict): + profile = await _penpot("get-profile", {}) + teams = await _penpot("get-teams", {}) + project_name = args["project"].lower() + + # Find or use default team project + target_project_id = None + for team in teams: + projects = await _penpot("get-projects", {"teamId": team["id"]}) + for proj in projects: + if project_name in proj["name"].lower(): + target_project_id = proj["id"] + break + if target_project_id: + break + + if not target_project_id: + # Use the first team's default project + target_project_id = teams[0]["defaultProjectId"] if teams else None + + if not target_project_id: + return [TextContent(type="text", text="Could not find a project to create the file in.")] + + file_data = await _penpot("create-file", { + "projectId": target_project_id, + "name": args["name"], + "isShared": False, + "features": ["components/v2", "styles/v2"], + }) + + file_id = file_data["id"] + # Get the first page ID + file_detail = await _penpot("get-file", {"id": file_id, "features": ["components/v2"]}) + pages = file_detail.get("data", {}).get("pagesIndex", {}) + page_id = list(pages.keys())[0] if pages else None + + penpot_url = f"{PENPOT_BASE_URL}/workspace/{file_data.get('projectId', '')}/{file_id}" + + return [TextContent(type="text", text=( + f"Created design file '{args['name']}'\n" + f" file_id: {file_id}\n" + f" page_id: {page_id}\n" + f" View in Penpot: {penpot_url}\n" + ))] + + +async def _get_file(args: dict): + file_data = await _penpot("get-file", {"id": args["file_id"], "features": ["components/v2"]}) + data = file_data.get("data", {}) + pages = data.get("pagesIndex", {}) + + summary = {"file_id": args["file_id"], "name": file_data.get("name"), "pages": []} + for page_id, page in pages.items(): + objects = page.get("objects", {}) + frames = [{"id": k, "name": v.get("name"), "width": v.get("width"), "height": v.get("height")} + for k, v in objects.items() if v.get("type") == "frame" and v.get("parentId") == "00000000-0000-0000-0000-000000000000"] + summary["pages"].append({"page_id": page_id, "name": page.get("name"), "frames": frames, "shape_count": len(objects)}) + + return [TextContent(type="text", text=json.dumps(summary, indent=2))] + + +async def _add_frame(args: dict): + frame_id = _new_id() + x = args.get("x", 0) + y = args.get("y", 0) + width = args.get("width", 1440) + height = args.get("height", 900) + bg = args.get("background", "#FFFFFF") + + frame_obj = { + "id": frame_id, + "type": "frame", + "name": args["name"], + "x": x, "y": y, + "width": width, "height": height, + "rotation": 0, + "selrect": {"x": x, "y": y, "x1": x, "y1": y, "x2": x + width, "y2": y + height, "width": width, "height": height}, + "points": [{"x": x, "y": y}, {"x": x + width, "y": y}, {"x": x + width, "y": y + height}, {"x": x, "y": y + height}], + "parentId": "00000000-0000-0000-0000-000000000000", + "frameId": "00000000-0000-0000-0000-000000000000", + "fills": [{"fillColor": bg, "fillOpacity": 1}], + "strokes": [], + "opacity": 1, + "shapes": [], + "hideFillOnExport": False, + "showContent": True, + "clipContent": True, + } + + # Get current revision + file_data = await _penpot("get-file", {"id": args["file_id"], "features": ["components/v2"]}) + + await _penpot("update-file", { + "id": args["file_id"], + "revn": file_data.get("revn", 0), + "sessionId": _new_id(), + "changes": [{ + "type": "add-obj", + "id": frame_id, + "pageId": args["page_id"], + "frameId": "00000000-0000-0000-0000-000000000000", + "parentId": "00000000-0000-0000-0000-000000000000", + "obj": frame_obj, + }], + }) + + return [TextContent(type="text", text=( + f"Frame '{args['name']}' added\n" + f" frame_id: {frame_id}\n" + f" size: {width}×{height} at ({x}, {y})\n" + f" background: {bg}\n" + ))] + + +def _build_shape_obj(args: dict, shape_id: str, frame_id: str) -> dict: + shape_type = args["shape_type"] + x, y = args["x"], args["y"] + w, h = args["width"], args["height"] + fill_color = args.get("fill_color", "#E5E7EB") + fill_opacity = args.get("fill_opacity", 1) + border_radius = args.get("border_radius", 0) + + obj: dict = { + "id": shape_id, + "type": shape_type, + "name": args["name"], + "x": x, "y": y, + "width": w, "height": h, + "rotation": 0, + "selrect": {"x": x, "y": y, "x1": x, "y1": y, "x2": x + w, "y2": y + h, "width": w, "height": h}, + "points": [{"x": x, "y": y}, {"x": x + w, "y": y}, {"x": x + w, "y": y + h}, {"x": x, "y": y + h}], + "parentId": frame_id, + "frameId": frame_id, + "fills": [{"fillColor": fill_color, "fillOpacity": fill_opacity}], + "strokes": [], + "opacity": args.get("opacity", 1), + } + + if border_radius: + obj["rx"] = border_radius + obj["ry"] = border_radius + + if shape_type == "text": + text_color = args.get("text_color", "#000000") + font_size = args.get("font_size", 14) + font_weight = args.get("font_weight", "400") + content_text = args.get("content", args["name"]) + obj["fills"] = [] + obj["content"] = { + "type": "root", + "children": [{ + "type": "paragraph-set", + "children": [{ + "type": "paragraph", + "children": [{ + "text": content_text, + "fontFamily": "Work Sans", + "fontSize": str(font_size), + "fontWeight": font_weight, + "fillColor": text_color, + "fillOpacity": 1, + }], + }], + }], + } + + return obj + + +async def _add_shape(args: dict): + shape_id = _new_id() + obj = _build_shape_obj(args, shape_id, args["frame_id"]) + + file_data = await _penpot("get-file", {"id": args["file_id"], "features": ["components/v2"]}) + + await _penpot("update-file", { + "id": args["file_id"], + "revn": file_data.get("revn", 0), + "sessionId": _new_id(), + "changes": [{ + "type": "add-obj", + "id": shape_id, + "pageId": args["page_id"], + "frameId": args["frame_id"], + "parentId": args["frame_id"], + "obj": obj, + }], + }) + + return [TextContent(type="text", text=( + f"Shape '{args['name']}' ({args['shape_type']}) added\n" + f" shape_id: {shape_id}\n" + f" position: ({args['x']}, {args['y']}) size: {args['width']}×{args['height']}\n" + ))] + + +async def _update_shape(args: dict): + file_data = await _penpot("get-file", {"id": args["file_id"], "features": ["components/v2"]}) + data = file_data.get("data", {}) + pages = data.get("pagesIndex", {}) + page = pages.get(args["page_id"], {}) + existing = page.get("objects", {}).get(args["shape_id"], {}) + + props = args["props"] + operations = [] + + # Map friendly prop names to Penpot operations + simple_attrs = ["x", "y", "width", "height", "opacity", "rx", "ry"] + for attr in simple_attrs: + if attr in props: + operations.append({"type": "set", "attr": attr, "val": props[attr]}) + if "border_radius" in props: + operations.append({"type": "set", "attr": "rx", "val": props["border_radius"]}) + operations.append({"type": "set", "attr": "ry", "val": props["border_radius"]}) + if "fill_color" in props: + operations.append({"type": "set", "attr": "fills", "val": [{"fillColor": props["fill_color"], "fillOpacity": props.get("fill_opacity", 1)}]}) + if "content" in props and existing.get("type") == "text": + text_color = props.get("text_color", "#000000") + font_size = props.get("font_size", 14) + font_weight = props.get("font_weight", "400") + operations.append({"type": "set", "attr": "content", "val": { + "type": "root", + "children": [{"type": "paragraph-set", "children": [{"type": "paragraph", "children": [{ + "text": props["content"], "fontFamily": "Work Sans", + "fontSize": str(font_size), "fontWeight": font_weight, + "fillColor": text_color, "fillOpacity": 1, + }]}]}], + }}) + + await _penpot("update-file", { + "id": args["file_id"], + "revn": file_data.get("revn", 0), + "sessionId": _new_id(), + "changes": [{ + "type": "mod-obj", + "id": args["shape_id"], + "pageId": args["page_id"], + "operations": operations, + }], + }) + + return [TextContent(type="text", text=f"Shape {args['shape_id']} updated with {list(props.keys())}")] + + +async def _delete_shape(args: dict): + file_data = await _penpot("get-file", {"id": args["file_id"], "features": ["components/v2"]}) + + await _penpot("update-file", { + "id": args["file_id"], + "revn": file_data.get("revn", 0), + "sessionId": _new_id(), + "changes": [{ + "type": "del-obj", + "id": args["shape_id"], + "pageId": args["page_id"], + }], + }) + + return [TextContent(type="text", text=f"Shape {args['shape_id']} deleted.")] + + +async def _screenshot(args: dict): + try: + png_bytes = await _penpot_export( + args["file_id"], args["page_id"], args["frame_id"], + fmt="png", scale=args.get("scale", 1.5) + ) + b64 = base64.b64encode(png_bytes).decode() + return [ImageContent(type="image", data=b64, mimeType="image/png")] + except Exception as e: + return [TextContent(type="text", text=( + f"Screenshot export failed: {e}\n" + f"View the design directly in Penpot at: {PENPOT_BASE_URL}" + ))] + + +async def _apply_color_theme(args: dict): + file_data = await _penpot("get-file", {"id": args["file_id"], "features": ["components/v2"]}) + colors = args["colors"] + changes = [] + for name, hex_val in colors.items(): + color_id = _new_id() + changes.append({ + "type": "add-color", + "color": { + "id": color_id, + "name": name, + "color": hex_val, + "opacity": 1, + "path": "Theme", + }, + }) + + await _penpot("update-file", { + "id": args["file_id"], + "revn": file_data.get("revn", 0), + "sessionId": _new_id(), + "changes": changes, + }) + + return [TextContent(type="text", text=f"Color theme applied: {list(colors.keys())}")] + + +async def _export_design_tokens(args: dict): + file_data = await _penpot("get-file", {"id": args["file_id"], "features": ["components/v2"]}) + data = file_data.get("data", {}) + + tokens = { + "colors": {}, + "typography": {}, + } + + for color_id, color in data.get("colors", {}).items(): + tokens["colors"][color.get("name", color_id)] = { + "value": color.get("color", "#000000"), + "opacity": color.get("opacity", 1), + } + + for typo_id, typo in data.get("typographies", {}).items(): + tokens["typography"][typo.get("name", typo_id)] = { + "fontFamily": typo.get("fontFamily"), + "fontSize": typo.get("fontSize"), + "fontWeight": typo.get("fontWeight"), + "lineHeight": typo.get("lineHeight"), + } + + return [TextContent(type="text", text=json.dumps(tokens, indent=2))] + + +async def _save_to_repo(args: dict): + repo = PROJECT_REPOS.get(args["project"].lower()) + if not repo: + return [TextContent(type="text", text=f"Unknown project '{args['project']}'. Valid: {list(PROJECT_REPOS.keys())}")] + + file_data = await _penpot("get-file", {"id": args["file_id"], "features": ["components/v2"]}) + data = file_data.get("data", {}) + + page_name = args["page_name"].lower().replace(" ", "-") + path = f"penpot/pages/{page_name}.json" + content = json.dumps({ + "file_id": args["file_id"], + "file_name": file_data.get("name"), + "exported_at": __import__("datetime").datetime.utcnow().isoformat(), + "pages": {k: {"name": v.get("name"), "shapes": len(v.get("objects", {}))} for k, v in data.get("pagesIndex", {}).items()}, + "colors": data.get("colors", {}), + "typographies": data.get("typographies", {}), + }, indent=2) + + sha = await _gitea_get_sha(repo, path) + await _gitea_put_file(repo, path, content, f"penpot: save {page_name} design snapshot", sha) + + # Also save a README if it doesn't exist + readme_path = "penpot/README.md" + readme_sha = await _gitea_get_sha(repo, readme_path) + if not readme_sha: + readme = f"""# Penpot Design Files + +Design files for {args['project']} managed via the Penpot MCP workflow. + +## Workflow + +1. Describe UI changes to Claude +2. Claude creates/updates designs in Penpot +3. Review at: {PENPOT_BASE_URL} +4. Claude applies approved designs to `frontend/src/components/` + +## Files + +- `pages/` — Structured JSON snapshots of each screen (version-controlled) +- `tokens/` — Design tokens (colors, typography, spacing) +""" + await _gitea_put_file(repo, readme_path, readme, "penpot: add design README") + + return [TextContent(type="text", text=f"Design saved to {repo}/{path}")] + + +async def _apply_to_frontend(args: dict): + """Generate a React component from a Penpot frame and save it to the repo.""" + repo = PROJECT_REPOS.get(args["project"].lower()) + if not repo: + return [TextContent(type="text", text=f"Unknown project '{args['project']}'. Valid: {list(PROJECT_REPOS.keys())}")] + + file_data = await _penpot("get-file", {"id": args["file_id"], "features": ["components/v2"]}) + data = file_data.get("data", {}) + page = data.get("pagesIndex", {}).get(args["page_id"], {}) + objects = page.get("objects", {}) + + frame = objects.get(args["frame_id"], {}) + if not frame: + return [TextContent(type="text", text=f"Frame {args['frame_id']} not found in page.")] + + component_name = args["component_name"] + child_ids = frame.get("shapes", []) + children = [objects[cid] for cid in child_ids if cid in objects] + + # Generate React + CSS module from the frame's shapes + tsx, css = _generate_react_component(component_name, frame, children) + + tsx_path = f"frontend/src/components/{component_name}/{component_name}.tsx" + css_path = f"frontend/src/components/{component_name}/{component_name}.module.css" + + tsx_sha = await _gitea_get_sha(repo, tsx_path) + css_sha = await _gitea_get_sha(repo, css_path) + + await _gitea_put_file(repo, tsx_path, tsx, f"ui: apply Penpot design for {component_name}", tsx_sha) + await _gitea_put_file(repo, css_path, css, f"ui: apply Penpot CSS for {component_name}", css_sha) + + return [TextContent(type="text", text=( + f"Component '{component_name}' applied to frontend:\n" + f" {tsx_path}\n" + f" {css_path}\n\n" + f"Import with:\n" + f" import {component_name} from 'components/{component_name}/{component_name}';\n" + ))] + + +def _generate_react_component(name: str, frame: dict, children: list) -> tuple[str, str]: + """Generate TSX and CSS module from Penpot frame data.""" + css_rules = [f".container {{\n position: relative;\n width: {frame.get('width', 1440)}px;\n height: {frame.get('height', 900)}px;\n background: {frame.get('fills', [{}])[0].get('fillColor', '#fff') if frame.get('fills') else '#fff'};\n}}"] + child_elements = [] + + for shape in children: + sid = shape.get("id", "")[:8].replace("-", "") + stype = shape.get("type", "rect") + sx, sy = shape.get("x", 0), shape.get("y", 0) + sw, sh = shape.get("width", 0), shape.get("height", 0) + fill = shape.get("fills", [{}])[0].get("fillColor", "transparent") if shape.get("fills") else "transparent" + rx = shape.get("rx", 0) + class_name = f"shape_{sid}" + + css_rules.append( + f".{class_name} {{\n" + f" position: absolute;\n" + f" left: {sx}px;\n" + f" top: {sy}px;\n" + f" width: {sw}px;\n" + f" height: {sh}px;\n" + f" background: {fill};\n" + + (f" border-radius: {rx}px;\n" if rx else "") + + "}" + ) + + if stype == "text": + content_nodes = shape.get("content", {}).get("children", [{}])[0].get("children", [{}])[0].get("children", [{}]) + text = content_nodes[0].get("text", shape.get("name", "")) if content_nodes else shape.get("name", "") + font_size = content_nodes[0].get("fontSize", "14") if content_nodes else "14" + text_color = content_nodes[0].get("fillColor", "#000000") if content_nodes else "#000000" + css_rules[-1] = css_rules[-1].rstrip("}") + f" color: {text_color};\n font-size: {font_size}px;\n display: flex;\n align-items: center;\n}}" + child_elements.append(f'
{text}
') + else: + child_elements.append(f'
') + + tsx = f"""import React from 'react'; +import styles from './{name}.module.css'; + +interface {name}Props {{ + className?: string; +}} + +const {name}: React.FC<{name}Props> = ({{ className }}) => {{ + return ( +
+{chr(10).join(child_elements)} +
+ ); +}}; + +export default {name}; +""" + + css = "\n\n".join(css_rules) + "\n" + return tsx, css + + +# ── SSE server setup ────────────────────────────────────────────────────────── + +sse = SseServerTransport("/messages/") + +async def handle_sse(request: Request): + async with sse.connect_sse(request.scope, request.receive, request._send) as streams: + await app.run(streams[0], streams[1], app.create_initialization_options()) + +async def handle_messages(request: Request): + await sse.handle_post_message(request.scope, request.receive, request._send) + +starlette_app = Starlette( + routes=[ + Route("/sse", endpoint=handle_sse), + Mount("/messages/", app=handle_messages), + Route("/health", endpoint=lambda r: __import__("starlette.responses", fromlist=["JSONResponse"]).JSONResponse({"status": "ok", "service": "penpot-mcp"})), + ] +) + +if __name__ == "__main__": + import uvicorn + uvicorn.run(starlette_app, host="0.0.0.0", port=8080)