Add CLAUDE.md for project guidance and enhance get_object_subtree_with_fields to handle circular references
- Introduced CLAUDE.md to provide comprehensive guidance on the Penpot MCP Server, including project overview, key commands, architecture, and common workflows. - Enhanced the `get_object_subtree_with_fields` function to track visited nodes and handle circular references, ensuring robust tree structure retrieval. - Added tests for circular reference handling in `test_penpot_tree.py` to validate new functionality.
This commit is contained in:
118
CLAUDE.md
Normal file
118
CLAUDE.md
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Penpot MCP Server is a Python-based Model Context Protocol (MCP) server that bridges AI language models with Penpot, an open-source design platform. It enables programmatic interaction with design files through a well-structured API.
|
||||||
|
|
||||||
|
## Key Commands
|
||||||
|
|
||||||
|
### Development Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies (recommended)
|
||||||
|
uv sync --extra dev
|
||||||
|
|
||||||
|
# Run the MCP server
|
||||||
|
uv run penpot-mcp
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
uv run pytest
|
||||||
|
uv run pytest --cov=penpot_mcp tests/ # with coverage
|
||||||
|
|
||||||
|
# Lint and fix code
|
||||||
|
uv run python lint.py # check issues
|
||||||
|
uv run python lint.py --autofix # auto-fix issues
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running the Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Default stdio mode (for Claude Desktop/Cursor)
|
||||||
|
make mcp-server
|
||||||
|
|
||||||
|
# SSE mode (for debugging with inspector)
|
||||||
|
make mcp-server-sse
|
||||||
|
|
||||||
|
# Launch MCP inspector (requires SSE mode)
|
||||||
|
make mcp-inspector
|
||||||
|
```
|
||||||
|
|
||||||
|
### CLI Tools
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate tree visualization
|
||||||
|
penpot-tree path/to/penpot_file.json
|
||||||
|
|
||||||
|
# Validate Penpot file
|
||||||
|
penpot-validate path/to/penpot_file.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
### Core Components
|
||||||
|
|
||||||
|
1. **MCP Server** (`penpot_mcp/server/mcp_server.py`)
|
||||||
|
- Built on FastMCP framework
|
||||||
|
- Implements resources and tools for Penpot interaction
|
||||||
|
- Memory cache with 10-minute TTL
|
||||||
|
- Supports stdio (default) and SSE modes
|
||||||
|
|
||||||
|
2. **API Client** (`penpot_mcp/api/penpot_api.py`)
|
||||||
|
- REST client for Penpot platform
|
||||||
|
- Transit+JSON format handling
|
||||||
|
- Cookie-based authentication with auto-refresh
|
||||||
|
- Lazy authentication pattern
|
||||||
|
|
||||||
|
3. **Key Design Patterns**
|
||||||
|
- **Authentication**: Cookie-based with automatic re-authentication on 401/403
|
||||||
|
- **Caching**: In-memory file cache to reduce API calls
|
||||||
|
- **Resource/Tool Duality**: Resources can be exposed as tools via RESOURCES_AS_TOOLS config
|
||||||
|
- **Transit Format**: Special handling for UUIDs (`~u` prefix) and keywords (`~:` prefix)
|
||||||
|
|
||||||
|
### Available Tools/Functions
|
||||||
|
|
||||||
|
- `list_projects`: Get all Penpot projects
|
||||||
|
- `get_project_files`: List files in a project
|
||||||
|
- `get_file`: Retrieve and cache file data
|
||||||
|
- `search_object`: Search design objects by name (regex)
|
||||||
|
- `get_object_tree`: Get filtered object tree with screenshot
|
||||||
|
- `export_object`: Export design objects as images
|
||||||
|
- `penpot_tree_schema`: Get schema for object tree fields
|
||||||
|
|
||||||
|
### Environment Configuration
|
||||||
|
|
||||||
|
Create a `.env` file with:
|
||||||
|
```
|
||||||
|
PENPOT_API_URL=https://design.penpot.app/api
|
||||||
|
PENPOT_USERNAME=your_username
|
||||||
|
PENPOT_PASSWORD=your_password
|
||||||
|
ENABLE_HTTP_SERVER=true # for image serving
|
||||||
|
RESOURCES_AS_TOOLS=false # MCP resource mode
|
||||||
|
DEBUG=true # debug logging
|
||||||
|
```
|
||||||
|
|
||||||
|
### Working with the Codebase
|
||||||
|
|
||||||
|
1. **Adding New Tools**: Decorate functions with `@self.mcp.tool()` in mcp_server.py
|
||||||
|
2. **API Extensions**: Add methods to PenpotAPI class following existing patterns
|
||||||
|
3. **Error Handling**: Always check for `"error"` keys in API responses
|
||||||
|
4. **Testing**: Use `test_mode=True` when creating server instances in tests
|
||||||
|
5. **Transit Format**: Remember to handle Transit+JSON when working with raw API
|
||||||
|
|
||||||
|
### Common Workflow for Code Generation
|
||||||
|
|
||||||
|
1. List projects → Find target project
|
||||||
|
2. Get project files → Locate design file
|
||||||
|
3. Search for component → Find specific element
|
||||||
|
4. Get tree schema → Understand available fields
|
||||||
|
5. Get object tree → Retrieve structure with screenshot
|
||||||
|
6. Export if needed → Get rendered component image
|
||||||
|
|
||||||
|
### Testing Patterns
|
||||||
|
|
||||||
|
- Mock fixtures in `tests/conftest.py`
|
||||||
|
- Test both stdio and SSE modes
|
||||||
|
- Verify Transit format conversions
|
||||||
|
- Check cache behavior and expiration
|
||||||
@@ -423,11 +423,27 @@ def get_object_subtree_with_fields(file_data: Dict[str, Any], object_id: str,
|
|||||||
if object_id not in objects_dict:
|
if object_id not in objects_dict:
|
||||||
return {"error": f"Object {object_id} not found in page {page_id}"}
|
return {"error": f"Object {object_id} not found in page {page_id}"}
|
||||||
|
|
||||||
|
# Track visited nodes to prevent infinite loops
|
||||||
|
visited = set()
|
||||||
|
|
||||||
# Function to recursively build the filtered object tree
|
# Function to recursively build the filtered object tree
|
||||||
def build_filtered_object_tree(obj_id: str, current_depth: int = 0):
|
def build_filtered_object_tree(obj_id: str, current_depth: int = 0):
|
||||||
if obj_id not in objects_dict:
|
if obj_id not in objects_dict:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Check for circular reference
|
||||||
|
if obj_id in visited:
|
||||||
|
# Return a placeholder to indicate circular reference
|
||||||
|
return {
|
||||||
|
'id': obj_id,
|
||||||
|
'name': objects_dict[obj_id].get('name', 'Unnamed'),
|
||||||
|
'type': objects_dict[obj_id].get('type', 'unknown'),
|
||||||
|
'_circular_reference': True
|
||||||
|
}
|
||||||
|
|
||||||
|
# Mark this object as visited
|
||||||
|
visited.add(obj_id)
|
||||||
|
|
||||||
obj_data = objects_dict[obj_id]
|
obj_data = objects_dict[obj_id]
|
||||||
|
|
||||||
# Create a new dict with only the requested fields or all fields if None
|
# Create a new dict with only the requested fields or all fields if None
|
||||||
@@ -441,6 +457,8 @@ def get_object_subtree_with_fields(file_data: Dict[str, Any], object_id: str,
|
|||||||
|
|
||||||
# If depth limit reached, don't process children
|
# If depth limit reached, don't process children
|
||||||
if depth != -1 and current_depth >= depth:
|
if depth != -1 and current_depth >= depth:
|
||||||
|
# Remove from visited before returning
|
||||||
|
visited.remove(obj_id)
|
||||||
return filtered_obj
|
return filtered_obj
|
||||||
|
|
||||||
# Find all children of this object
|
# Find all children of this object
|
||||||
@@ -454,6 +472,9 @@ def get_object_subtree_with_fields(file_data: Dict[str, Any], object_id: str,
|
|||||||
# Add children field only if we have children
|
# Add children field only if we have children
|
||||||
if children:
|
if children:
|
||||||
filtered_obj['children'] = children
|
filtered_obj['children'] = children
|
||||||
|
|
||||||
|
# Remove from visited after processing
|
||||||
|
visited.remove(obj_id)
|
||||||
|
|
||||||
return filtered_obj
|
return filtered_obj
|
||||||
|
|
||||||
|
|||||||
@@ -1087,4 +1087,61 @@ def test_get_object_subtree_with_fields_root_frame():
|
|||||||
assert result['tree']['type'] == 'frame'
|
assert result['tree']['type'] == 'frame'
|
||||||
assert 'children' in result['tree']
|
assert 'children' in result['tree']
|
||||||
assert len(result['tree']['children']) == 1
|
assert len(result['tree']['children']) == 1
|
||||||
assert result['tree']['children'][0]['name'] == 'Main Container'
|
assert result['tree']['children'][0]['name'] == 'Main Container'
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_object_subtree_with_fields_circular_reference():
|
||||||
|
"""Test handling of circular references in object tree."""
|
||||||
|
file_data = {
|
||||||
|
'data': {
|
||||||
|
'pagesIndex': {
|
||||||
|
'page1': {
|
||||||
|
'name': 'Test Page',
|
||||||
|
'objects': {
|
||||||
|
# Object A references B as parent
|
||||||
|
'object-a': {
|
||||||
|
'type': 'frame',
|
||||||
|
'name': 'Object A',
|
||||||
|
'parentId': 'object-b'
|
||||||
|
},
|
||||||
|
# Object B references A as parent (circular)
|
||||||
|
'object-b': {
|
||||||
|
'type': 'frame',
|
||||||
|
'name': 'Object B',
|
||||||
|
'parentId': 'object-a'
|
||||||
|
},
|
||||||
|
# Object C references itself as parent
|
||||||
|
'object-c': {
|
||||||
|
'type': 'frame',
|
||||||
|
'name': 'Object C',
|
||||||
|
'parentId': 'object-c'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test getting object A - should handle circular reference with B
|
||||||
|
result = get_object_subtree_with_fields(file_data, 'object-a')
|
||||||
|
assert 'error' not in result
|
||||||
|
assert result['tree']['id'] == 'object-a'
|
||||||
|
assert 'children' in result['tree']
|
||||||
|
# Check that object-b appears as a child
|
||||||
|
assert len(result['tree']['children']) == 1
|
||||||
|
assert result['tree']['children'][0]['id'] == 'object-b'
|
||||||
|
# The circular reference appears when object-a appears again as a child of object-b
|
||||||
|
assert 'children' in result['tree']['children'][0]
|
||||||
|
assert len(result['tree']['children'][0]['children']) == 1
|
||||||
|
assert result['tree']['children'][0]['children'][0]['id'] == 'object-a'
|
||||||
|
assert result['tree']['children'][0]['children'][0]['_circular_reference'] == True
|
||||||
|
|
||||||
|
# Test getting object C - should handle self-reference
|
||||||
|
result = get_object_subtree_with_fields(file_data, 'object-c')
|
||||||
|
assert 'error' not in result
|
||||||
|
assert result['tree']['id'] == 'object-c'
|
||||||
|
assert 'children' in result['tree']
|
||||||
|
# Check that object-c appears as its own child with circular reference marker
|
||||||
|
assert len(result['tree']['children']) == 1
|
||||||
|
assert result['tree']['children'][0]['id'] == 'object-c'
|
||||||
|
assert result['tree']['children'][0]['_circular_reference'] == True
|
||||||
Reference in New Issue
Block a user