diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0633f5d --- /dev/null +++ b/CLAUDE.md @@ -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 diff --git a/penpot_mcp/tools/penpot_tree.py b/penpot_mcp/tools/penpot_tree.py index 4d3bd56..c9def51 100644 --- a/penpot_mcp/tools/penpot_tree.py +++ b/penpot_mcp/tools/penpot_tree.py @@ -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: 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 def build_filtered_object_tree(obj_id: str, current_depth: int = 0): if obj_id not in objects_dict: 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] # 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 != -1 and current_depth >= depth: + # Remove from visited before returning + visited.remove(obj_id) return filtered_obj # 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 if children: filtered_obj['children'] = children + + # Remove from visited after processing + visited.remove(obj_id) return filtered_obj diff --git a/tests/test_penpot_tree.py b/tests/test_penpot_tree.py index a476901..f989d3e 100644 --- a/tests/test_penpot_tree.py +++ b/tests/test_penpot_tree.py @@ -1087,4 +1087,61 @@ def test_get_object_subtree_with_fields_root_frame(): assert result['tree']['type'] == 'frame' assert 'children' in result['tree'] assert len(result['tree']['children']) == 1 - assert result['tree']['children'][0]['name'] == 'Main Container' \ No newline at end of file + 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 \ No newline at end of file