init: add src/server.py
This commit is contained in:
902
src/server.py
Normal file
902
src/server.py
Normal 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)
|
||||
Reference in New Issue
Block a user