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