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:
1
penpot_mcp/utils/__init__.py
Normal file
1
penpot_mcp/utils/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Utility functions and helper modules for the Penpot MCP server."""
|
||||
83
penpot_mcp/utils/cache.py
Normal file
83
penpot_mcp/utils/cache.py
Normal 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
|
||||
25
penpot_mcp/utils/config.py
Normal file
25
penpot_mcp/utils/config.py
Normal 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')
|
||||
128
penpot_mcp/utils/http_server.py
Normal file
128
penpot_mcp/utils/http_server.py
Normal 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]
|
||||
Reference in New Issue
Block a user