generated from personal-projects/leo-claude-mktplace
Implemented MCP server core infrastructure with authentication and HTTP client: - Created auth.py for API token management - Loads GITEA_API_URL and GITEA_API_TOKEN from environment - Uses python-dotenv for .env file support - Validates required configuration on initialization - Provides authentication headers for API requests - Created client.py with base HTTP client - GiteaClient class using httpx AsyncClient - Async HTTP methods: get(), post(), patch(), delete() - Comprehensive error handling for HTTP status codes - Custom exception hierarchy for different error types - Configurable timeout (default 30s) - Updated server.py with MCP server setup - Initialized MCP server with StdioServerTransport - Integrated AuthConfig and GiteaClient - Registered placeholder tool handlers (list_repositories, create_issue, create_pull_request) - Added CLI with --help and --version options - Proper error handling for configuration failures - Updated pyproject.toml - Added console script entry point: gitea-mcp - Created comprehensive unit tests - test_auth.py: Tests for AuthConfig validation and headers - test_client.py: Tests for GiteaClient initialization and error handling All acceptance criteria met: - MCP server initializes with StdioServerTransport - Authentication loads from environment variables - Base HTTP client with auth headers implemented - Error handling for API connection failures - Server reports available tools (placeholders for future issues) Closes #2 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
217 lines
6.3 KiB
Python
217 lines
6.3 KiB
Python
"""HTTP client for Gitea API."""
|
|
|
|
import httpx
|
|
from typing import Any, Optional
|
|
from .auth import AuthConfig
|
|
|
|
|
|
class GiteaClientError(Exception):
|
|
"""Base exception for Gitea client errors."""
|
|
|
|
pass
|
|
|
|
|
|
class GiteaAuthError(GiteaClientError):
|
|
"""Authentication error."""
|
|
|
|
pass
|
|
|
|
|
|
class GiteaNotFoundError(GiteaClientError):
|
|
"""Resource not found error."""
|
|
|
|
pass
|
|
|
|
|
|
class GiteaServerError(GiteaClientError):
|
|
"""Server error."""
|
|
|
|
pass
|
|
|
|
|
|
class GiteaClient:
|
|
"""Async HTTP client for Gitea API."""
|
|
|
|
def __init__(self, config: AuthConfig, timeout: float = 30.0):
|
|
"""Initialize Gitea API client.
|
|
|
|
Args:
|
|
config: Authentication configuration.
|
|
timeout: Request timeout in seconds (default: 30.0).
|
|
"""
|
|
self.config = config
|
|
self.timeout = timeout
|
|
self._client: Optional[httpx.AsyncClient] = None
|
|
|
|
async def __aenter__(self):
|
|
"""Async context manager entry."""
|
|
self._client = httpx.AsyncClient(
|
|
base_url=self.config.api_url,
|
|
headers=self.config.get_auth_headers(),
|
|
timeout=self.timeout,
|
|
)
|
|
return self
|
|
|
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
"""Async context manager exit."""
|
|
if self._client:
|
|
await self._client.aclose()
|
|
|
|
def _handle_error(self, response: httpx.Response) -> None:
|
|
"""Handle HTTP error responses.
|
|
|
|
Args:
|
|
response: HTTP response object.
|
|
|
|
Raises:
|
|
GiteaAuthError: For 401/403 errors.
|
|
GiteaNotFoundError: For 404 errors.
|
|
GiteaServerError: For 500+ errors.
|
|
GiteaClientError: For other errors.
|
|
"""
|
|
status = response.status_code
|
|
|
|
if status == 401:
|
|
raise GiteaAuthError(
|
|
"Authentication failed. Please check your GITEA_API_TOKEN."
|
|
)
|
|
elif status == 403:
|
|
raise GiteaAuthError(
|
|
"Access forbidden. Your API token may not have required permissions."
|
|
)
|
|
elif status == 404:
|
|
raise GiteaNotFoundError(
|
|
f"Resource not found: {response.request.url}"
|
|
)
|
|
elif status >= 500:
|
|
raise GiteaServerError(
|
|
f"Gitea server error (HTTP {status}): {response.text}"
|
|
)
|
|
else:
|
|
raise GiteaClientError(
|
|
f"API request failed (HTTP {status}): {response.text}"
|
|
)
|
|
|
|
async def get(self, path: str, **kwargs) -> dict[str, Any]:
|
|
"""Make GET request to Gitea API.
|
|
|
|
Args:
|
|
path: API endpoint path (e.g., "/api/v1/repos/owner/repo").
|
|
**kwargs: Additional arguments for httpx request.
|
|
|
|
Returns:
|
|
dict: JSON response data.
|
|
|
|
Raises:
|
|
GiteaClientError: If request fails.
|
|
"""
|
|
if not self._client:
|
|
raise GiteaClientError(
|
|
"Client not initialized. Use 'async with' context manager."
|
|
)
|
|
|
|
try:
|
|
response = await self._client.get(path, **kwargs)
|
|
response.raise_for_status()
|
|
return response.json()
|
|
except httpx.HTTPStatusError:
|
|
self._handle_error(response)
|
|
except httpx.RequestError as e:
|
|
raise GiteaClientError(
|
|
f"Request failed: {e}"
|
|
) from e
|
|
|
|
async def post(self, path: str, json: Optional[dict[str, Any]] = None, **kwargs) -> dict[str, Any]:
|
|
"""Make POST request to Gitea API.
|
|
|
|
Args:
|
|
path: API endpoint path.
|
|
json: JSON data to send in request body.
|
|
**kwargs: Additional arguments for httpx request.
|
|
|
|
Returns:
|
|
dict: JSON response data.
|
|
|
|
Raises:
|
|
GiteaClientError: If request fails.
|
|
"""
|
|
if not self._client:
|
|
raise GiteaClientError(
|
|
"Client not initialized. Use 'async with' context manager."
|
|
)
|
|
|
|
try:
|
|
response = await self._client.post(path, json=json, **kwargs)
|
|
response.raise_for_status()
|
|
return response.json()
|
|
except httpx.HTTPStatusError:
|
|
self._handle_error(response)
|
|
except httpx.RequestError as e:
|
|
raise GiteaClientError(
|
|
f"Request failed: {e}"
|
|
) from e
|
|
|
|
async def patch(self, path: str, json: Optional[dict[str, Any]] = None, **kwargs) -> dict[str, Any]:
|
|
"""Make PATCH request to Gitea API.
|
|
|
|
Args:
|
|
path: API endpoint path.
|
|
json: JSON data to send in request body.
|
|
**kwargs: Additional arguments for httpx request.
|
|
|
|
Returns:
|
|
dict: JSON response data.
|
|
|
|
Raises:
|
|
GiteaClientError: If request fails.
|
|
"""
|
|
if not self._client:
|
|
raise GiteaClientError(
|
|
"Client not initialized. Use 'async with' context manager."
|
|
)
|
|
|
|
try:
|
|
response = await self._client.patch(path, json=json, **kwargs)
|
|
response.raise_for_status()
|
|
return response.json()
|
|
except httpx.HTTPStatusError:
|
|
self._handle_error(response)
|
|
except httpx.RequestError as e:
|
|
raise GiteaClientError(
|
|
f"Request failed: {e}"
|
|
) from e
|
|
|
|
async def delete(self, path: str, **kwargs) -> Optional[dict[str, Any]]:
|
|
"""Make DELETE request to Gitea API.
|
|
|
|
Args:
|
|
path: API endpoint path.
|
|
**kwargs: Additional arguments for httpx request.
|
|
|
|
Returns:
|
|
dict or None: JSON response data if available, None for 204 responses.
|
|
|
|
Raises:
|
|
GiteaClientError: If request fails.
|
|
"""
|
|
if not self._client:
|
|
raise GiteaClientError(
|
|
"Client not initialized. Use 'async with' context manager."
|
|
)
|
|
|
|
try:
|
|
response = await self._client.delete(path, **kwargs)
|
|
response.raise_for_status()
|
|
|
|
# DELETE requests may return 204 No Content
|
|
if response.status_code == 204:
|
|
return None
|
|
|
|
return response.json()
|
|
except httpx.HTTPStatusError:
|
|
self._handle_error(response)
|
|
except httpx.RequestError as e:
|
|
raise GiteaClientError(
|
|
f"Request failed: {e}"
|
|
) from e
|