Initial commit: Penpot MCP Server - Complete AI-powered design workflow automation with MCP protocol, Penpot API integration, Claude AI support, CLI tools, and comprehensive documentation

This commit is contained in:
Chema
2025-05-26 19:16:46 +02:00
commit 85e658d2cd
42 changed files with 8159 additions and 0 deletions

View File

@@ -0,0 +1 @@
"""Utility functions and helper modules for the Penpot MCP server."""

83
penpot_mcp/utils/cache.py Normal file
View File

@@ -0,0 +1,83 @@
"""
Cache utilities for Penpot MCP server.
"""
import time
from typing import Optional, Dict, Any
class MemoryCache:
"""In-memory cache implementation with TTL support."""
def __init__(self, ttl_seconds: int = 600):
"""
Initialize the memory cache.
Args:
ttl_seconds: Time to live in seconds (default 10 minutes)
"""
self.ttl_seconds = ttl_seconds
self._cache: Dict[str, Dict[str, Any]] = {}
def get(self, file_id: str) -> Optional[Dict[str, Any]]:
"""
Get a file from cache if it exists and is not expired.
Args:
file_id: The ID of the file to retrieve
Returns:
The cached file data or None if not found/expired
"""
if file_id not in self._cache:
return None
cache_data = self._cache[file_id]
# Check if cache is expired
if time.time() - cache_data['timestamp'] > self.ttl_seconds:
del self._cache[file_id] # Remove expired cache
return None
return cache_data['data']
def set(self, file_id: str, data: Dict[str, Any]) -> None:
"""
Store a file in cache.
Args:
file_id: The ID of the file to cache
data: The file data to cache
"""
self._cache[file_id] = {
'timestamp': time.time(),
'data': data
}
def clear(self) -> None:
"""Clear all cached files."""
self._cache.clear()
def get_all_cached_files(self) -> Dict[str, Dict[str, Any]]:
"""
Get all valid cached files.
Returns:
Dictionary mapping file IDs to their cached data
"""
result = {}
current_time = time.time()
# Create a list of expired keys to remove
expired_keys = []
for file_id, cache_data in self._cache.items():
if current_time - cache_data['timestamp'] <= self.ttl_seconds:
result[file_id] = cache_data['data']
else:
expired_keys.append(file_id)
# Remove expired entries
for key in expired_keys:
del self._cache[key]
return result

View File

@@ -0,0 +1,25 @@
"""Configuration module for the Penpot MCP server."""
import os
from dotenv import find_dotenv, load_dotenv
# Load environment variables
load_dotenv(find_dotenv())
# Server configuration
PORT = int(os.environ.get('PORT', 5000))
DEBUG = os.environ.get('DEBUG', 'true').lower() == 'true'
RESOURCES_AS_TOOLS = os.environ.get('RESOURCES_AS_TOOLS', 'true').lower() == 'true'
# HTTP server for exported images
ENABLE_HTTP_SERVER = os.environ.get('ENABLE_HTTP_SERVER', 'true').lower() == 'true'
HTTP_SERVER_HOST = os.environ.get('HTTP_SERVER_HOST', 'localhost')
HTTP_SERVER_PORT = int(os.environ.get('HTTP_SERVER_PORT', 0))
# Penpot API configuration
PENPOT_API_URL = os.environ.get('PENPOT_API_URL', 'https://design.penpot.app/api')
PENPOT_USERNAME = os.environ.get('PENPOT_USERNAME')
PENPOT_PASSWORD = os.environ.get('PENPOT_PASSWORD')
RESOURCES_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'resources')

View File

@@ -0,0 +1,128 @@
"""HTTP server module for serving exported images from memory."""
import io
import json
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
import socketserver
class InMemoryImageHandler(BaseHTTPRequestHandler):
"""HTTP request handler for serving images stored in memory."""
# Class variable to store images
images = {}
def do_GET(self):
"""Handle GET requests."""
# Remove query parameters if any
path = self.path.split('?', 1)[0]
path = path.split('#', 1)[0]
# Extract image ID from path
# Expected path format: /images/{image_id}.{format}
parts = path.split('/')
if len(parts) == 3 and parts[1] == 'images':
# Extract image_id by removing the file extension if present
image_id_with_ext = parts[2]
image_id = image_id_with_ext.split('.')[0]
if image_id in self.images:
img_data = self.images[image_id]['data']
img_format = self.images[image_id]['format']
# Set content type based on format
content_type = f"image/{img_format}"
if img_format == 'svg':
content_type = 'image/svg+xml'
self.send_response(200)
self.send_header('Content-type', content_type)
self.send_header('Content-length', len(img_data))
self.end_headers()
self.wfile.write(img_data)
return
# Return 404 if image not found
self.send_response(404)
self.send_header('Content-type', 'application/json')
self.end_headers()
response = {'error': 'Image not found'}
self.wfile.write(json.dumps(response).encode())
class ImageServer:
"""Server for in-memory images."""
def __init__(self, host='localhost', port=0):
"""Initialize the HTTP server.
Args:
host: Host address to listen on
port: Port to listen on (0 means use a random available port)
"""
self.host = host
self.port = port
self.server = None
self.server_thread = None
self.is_running = False
self.base_url = None
def start(self):
"""Start the HTTP server in a background thread.
Returns:
Base URL of the server with actual port used
"""
if self.is_running:
return self.base_url
# Create TCP server with address reuse enabled
class ReuseAddressTCPServer(socketserver.TCPServer):
allow_reuse_address = True
self.server = ReuseAddressTCPServer((self.host, self.port), InMemoryImageHandler)
# Get the actual port that was assigned
self.port = self.server.socket.getsockname()[1]
self.base_url = f"http://{self.host}:{self.port}"
# Start server in a separate thread
self.server_thread = threading.Thread(target=self.server.serve_forever)
self.server_thread.daemon = True # Don't keep process running if main thread exits
self.server_thread.start()
self.is_running = True
print(f"Image server started at {self.base_url}")
return self.base_url
def stop(self):
"""Stop the HTTP server."""
if not self.is_running:
return
self.server.shutdown()
self.server.server_close()
self.is_running = False
print("Image server stopped")
def add_image(self, image_id, image_data, image_format='png'):
"""Add image to in-memory storage.
Args:
image_id: Unique identifier for the image
image_data: Binary image data
image_format: Image format (png, jpg, etc.)
Returns:
URL to access the image
"""
InMemoryImageHandler.images[image_id] = {
'data': image_data,
'format': image_format
}
return f"{self.base_url}/images/{image_id}.{image_format}"
def remove_image(self, image_id):
"""Remove image from in-memory storage."""
if image_id in InMemoryImageHandler.images:
del InMemoryImageHandler.images[image_id]