init: add src/server.py

This commit is contained in:
2026-04-05 17:19:32 +00:00
parent 7971ee1aad
commit 3ecc9c4cfc

902
src/server.py Normal file
View File

@@ -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/<name>.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' <div className={{styles.{class_name}}}>{text}</div>')
else:
child_elements.append(f' <div className={{styles.{class_name}}} />')
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 (
<div className={{`${{styles.container}} ${{className ?? ''}}`}}>
{chr(10).join(child_elements)}
</div>
);
}};
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)