From cc9d0312e3b84a5ef3f68a4c7efad5891e3dbf35 Mon Sep 17 00:00:00 2001 From: chema Date: Sun, 29 Jun 2025 18:22:23 +0200 Subject: [PATCH] Add test_credentials.py for Penpot API credential verification and project listing - Introduced a new script, `test_credentials.py`, to verify Penpot API credentials and list associated projects. - The script loads environment variables, checks for required credentials, and attempts to authenticate with the Penpot API. - Added functionality to fetch and display project details and files, including error handling for authentication and project retrieval. - Updated `PenpotAPI` class to include a User-Agent header and improved error handling during profile retrieval. - Minor adjustments in import order across various modules for consistency. --- penpot_mcp/api/penpot_api.py | 29 ++++++++---- penpot_mcp/server/mcp_server.py | 5 ++- penpot_mcp/tools/penpot_tree.py | 2 +- penpot_mcp/utils/cache.py | 3 +- penpot_mcp/utils/http_server.py | 3 +- test_credentials.py | 80 +++++++++++++++++++++++++++++++++ tests/test_cache.py | 3 ++ tests/test_mcp_server.py | 5 ++- tests/test_penpot_tree.py | 14 +++--- 9 files changed, 122 insertions(+), 22 deletions(-) create mode 100755 test_credentials.py diff --git a/penpot_mcp/api/penpot_api.py b/penpot_mcp/api/penpot_api.py index 0a69eb0..e334c4f 100644 --- a/penpot_mcp/api/penpot_api.py +++ b/penpot_mcp/api/penpot_api.py @@ -31,7 +31,8 @@ class PenpotAPI: # based on the required content type (JSON vs Transit+JSON) self.session.headers.update({ "Accept": "application/json, application/transit+json", - "Content-Type": "application/json" + "Content-Type": "application/json", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" }) def set_access_token(self, token: str): @@ -64,7 +65,12 @@ class PenpotAPI: token = self.login_for_export(email, password) self.set_access_token(token) # Get profile ID after login - self.get_profile() + try: + self.get_profile() + except Exception as e: + if self.debug: + print(f"\nWarning: Could not get profile (may be blocked by Cloudflare): {e}") + # Continue without profile_id - most operations don't need it return token def get_profile(self) -> Dict[str, Any]: @@ -138,7 +144,8 @@ class PenpotAPI: # Set headers headers = { - "Content-Type": "application/transit+json" + "Content-Type": "application/transit+json", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" } response = login_session.post(url, json=payload, headers=headers) @@ -171,7 +178,7 @@ class PenpotAPI: # If we reached here, we couldn't find the token raise ValueError("Auth token not found in response cookies or JSON body") - def _make_authenticated_request(self, method: str, url: str, **kwargs) -> requests.Response: + def _make_authenticated_request(self, method: str, url: str, retry_auth: bool = True, **kwargs) -> requests.Response: """ Make an authenticated request, handling re-auth if needed. @@ -269,7 +276,11 @@ class PenpotAPI: except requests.HTTPError as e: # Handle authentication errors - if e.response.status_code in (401, 403) and self.email and self.password: + if e.response.status_code in (401, 403) and self.email and self.password and retry_auth: + # Special case: don't retry auth for get-profile to avoid infinite loops + if url.endswith('/get-profile'): + raise + if self.debug: print("\nAuthentication failed. Trying to re-login...") @@ -280,7 +291,7 @@ class PenpotAPI: headers['Authorization'] = f"Token {self.access_token}" combined_headers = {**self.session.headers, **headers} - # Retry the request with the new token + # Retry the request with the new token (but don't retry auth again) response = getattr(self.session, method)(url, headers=combined_headers, **kwargs) response.raise_for_status() return response @@ -500,7 +511,8 @@ class PenpotAPI: headers = { "Content-Type": "application/transit+json", - "Accept": "application/transit+json" + "Accept": "application/transit+json", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" } # Make the request @@ -557,7 +569,8 @@ class PenpotAPI: } headers = { "Content-Type": "application/transit+json", - "Accept": "*/*" + "Accept": "*/*", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" } if self.debug: print(f"\nFetching export resource: {url}") diff --git a/penpot_mcp/server/mcp_server.py b/penpot_mcp/server/mcp_server.py index 5e904af..94d1410 100644 --- a/penpot_mcp/server/mcp_server.py +++ b/penpot_mcp/server/mcp_server.py @@ -5,13 +5,14 @@ This module defines the MCP server with resources and tools for interacting with the Penpot design platform. """ +import argparse import hashlib import json import os import re -import argparse import sys -from typing import List, Optional, Dict +from typing import Dict, List, Optional + from mcp.server.fastmcp import FastMCP, Image from penpot_mcp.api.penpot_api import PenpotAPI diff --git a/penpot_mcp/tools/penpot_tree.py b/penpot_mcp/tools/penpot_tree.py index c9def51..d722404 100644 --- a/penpot_mcp/tools/penpot_tree.py +++ b/penpot_mcp/tools/penpot_tree.py @@ -6,7 +6,7 @@ a tree representation, which can be displayed or exported. """ import re -from typing import Any, Dict, Optional, Union, List +from typing import Any, Dict, List, Optional, Union from anytree import Node, RenderTree from anytree.exporter import DotExporter diff --git a/penpot_mcp/utils/cache.py b/penpot_mcp/utils/cache.py index f20a612..d079b07 100644 --- a/penpot_mcp/utils/cache.py +++ b/penpot_mcp/utils/cache.py @@ -3,7 +3,8 @@ Cache utilities for Penpot MCP server. """ import time -from typing import Optional, Dict, Any +from typing import Any, Dict, Optional + class MemoryCache: """In-memory cache implementation with TTL support.""" diff --git a/penpot_mcp/utils/http_server.py b/penpot_mcp/utils/http_server.py index b9f2538..09a5875 100644 --- a/penpot_mcp/utils/http_server.py +++ b/penpot_mcp/utils/http_server.py @@ -2,9 +2,10 @@ import io import json +import socketserver import threading from http.server import BaseHTTPRequestHandler, HTTPServer -import socketserver + class InMemoryImageHandler(BaseHTTPRequestHandler): """HTTP request handler for serving images stored in memory.""" diff --git a/test_credentials.py b/test_credentials.py new file mode 100755 index 0000000..6172661 --- /dev/null +++ b/test_credentials.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +""" +Test script to verify Penpot API credentials and list projects. +""" + +import os + +from dotenv import load_dotenv + +from penpot_mcp.api.penpot_api import PenpotAPI + + +def test_credentials(): + """Test Penpot API credentials and list projects.""" + load_dotenv() + + api_url = os.getenv("PENPOT_API_URL") + username = os.getenv("PENPOT_USERNAME") + password = os.getenv("PENPOT_PASSWORD") + + if not all([api_url, username, password]): + print("āŒ Missing credentials in .env file") + print("Required: PENPOT_API_URL, PENPOT_USERNAME, PENPOT_PASSWORD") + return False + + print(f"šŸ”— Testing connection to: {api_url}") + print(f"šŸ‘¤ Username: {username}") + + try: + api = PenpotAPI(api_url, debug=False, email=username, password=password) + + print("šŸ” Authenticating...") + token = api.login_with_password() + print("āœ… Authentication successful!") + + print("šŸ“ Fetching projects...") + projects = api.list_projects() + + if isinstance(projects, dict) and "error" in projects: + print(f"āŒ Failed to list projects: {projects['error']}") + return False + + print(f"āœ… Found {len(projects)} projects:") + for i, project in enumerate(projects, 1): + if isinstance(project, dict): + name = project.get('name', 'Unnamed') + project_id = project.get('id', 'N/A') + team_name = project.get('team-name', 'Unknown Team') + print(f" {i}. {name} (ID: {project_id}) - Team: {team_name}") + else: + print(f" {i}. {project}") + + # Test getting project files if we have a project + if projects and isinstance(projects[0], dict): + project_id = projects[0].get('id') + if project_id: + print(f"\nšŸ“„ Testing project files for project: {project_id}") + try: + files = api.get_project_files(project_id) + print(f"āœ… Found {len(files)} files:") + for j, file in enumerate(files[:3], 1): # Show first 3 files + if isinstance(file, dict): + print(f" {j}. {file.get('name', 'Unnamed')} (ID: {file.get('id', 'N/A')})") + else: + print(f" {j}. {file}") + if len(files) > 3: + print(f" ... and {len(files) - 3} more files") + except Exception as file_error: + print(f"āŒ Error getting files: {file_error}") + + return True + + except Exception as e: + print(f"āŒ Error: {e}") + return False + + +if __name__ == "__main__": + success = test_credentials() + exit(0 if success else 1) \ No newline at end of file diff --git a/tests/test_cache.py b/tests/test_cache.py index f90cde6..ef3f844 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -3,9 +3,12 @@ Tests for the memory caching functionality. """ import time + import pytest + from penpot_mcp.utils.cache import MemoryCache + @pytest.fixture def memory_cache(): """Create a MemoryCache instance with a short TTL for testing.""" diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index fd54a2a..9fc0b6d 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -1,12 +1,13 @@ """Tests for the MCP server module.""" +import hashlib import json import os -import hashlib from unittest.mock import MagicMock, mock_open, patch -import yaml import pytest +import yaml + from penpot_mcp.server.mcp_server import PenpotMCPServer, create_server diff --git a/tests/test_penpot_tree.py b/tests/test_penpot_tree.py index f989d3e..4dc7a46 100644 --- a/tests/test_penpot_tree.py +++ b/tests/test_penpot_tree.py @@ -7,14 +7,14 @@ import pytest from anytree import Node, RenderTree from penpot_mcp.tools.penpot_tree import ( - build_tree, - print_tree, - export_tree_to_dot, - find_page_containing_object, - find_object_in_tree, - convert_node_to_dict, + build_tree, + convert_node_to_dict, + export_tree_to_dot, + find_object_in_tree, + find_page_containing_object, get_object_subtree, - get_object_subtree_with_fields + get_object_subtree_with_fields, + print_tree, )