.
This commit is contained in:
3
src/aegis_gitea_mcp/__init__.py
Normal file
3
src/aegis_gitea_mcp/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""AegisGitea MCP - Security-first MCP server for self-hosted Gitea."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
171
src/aegis_gitea_mcp/audit.py
Normal file
171
src/aegis_gitea_mcp/audit.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""Audit logging system for MCP tool invocations."""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import structlog
|
||||
|
||||
from aegis_gitea_mcp.config import get_settings
|
||||
|
||||
|
||||
class AuditLogger:
|
||||
"""Audit logger for tracking all MCP tool invocations."""
|
||||
|
||||
def __init__(self, log_path: Optional[Path] = None) -> None:
|
||||
"""Initialize audit logger.
|
||||
|
||||
Args:
|
||||
log_path: Path to audit log file (defaults to config value)
|
||||
"""
|
||||
self.settings = get_settings()
|
||||
self.log_path = log_path or self.settings.audit_log_path
|
||||
|
||||
# Ensure log directory exists
|
||||
self.log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Configure structlog for audit logging
|
||||
structlog.configure(
|
||||
processors=[
|
||||
structlog.processors.TimeStamper(fmt="iso", utc=True),
|
||||
structlog.processors.dict_tracebacks,
|
||||
structlog.processors.JSONRenderer(),
|
||||
],
|
||||
wrapper_class=structlog.make_filtering_bound_logger(
|
||||
logging_level=self.settings.log_level
|
||||
),
|
||||
context_class=dict,
|
||||
logger_factory=structlog.PrintLoggerFactory(file=self._get_log_file()),
|
||||
cache_logger_on_first_use=True,
|
||||
)
|
||||
|
||||
self.logger = structlog.get_logger("audit")
|
||||
|
||||
def _get_log_file(self) -> Any:
|
||||
"""Get file handle for audit log."""
|
||||
return open(self.log_path, "a", encoding="utf-8")
|
||||
|
||||
def log_tool_invocation(
|
||||
self,
|
||||
tool_name: str,
|
||||
repository: Optional[str] = None,
|
||||
target: Optional[str] = None,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
correlation_id: Optional[str] = None,
|
||||
result_status: str = "pending",
|
||||
error: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Log an MCP tool invocation.
|
||||
|
||||
Args:
|
||||
tool_name: Name of the MCP tool being invoked
|
||||
repository: Repository identifier (owner/repo)
|
||||
target: Target path, commit hash, issue number, etc.
|
||||
params: Additional parameters passed to the tool
|
||||
correlation_id: Request correlation ID (auto-generated if not provided)
|
||||
result_status: Status of the invocation (pending, success, error)
|
||||
error: Error message if invocation failed
|
||||
|
||||
Returns:
|
||||
Correlation ID for this invocation
|
||||
"""
|
||||
if correlation_id is None:
|
||||
correlation_id = str(uuid.uuid4())
|
||||
|
||||
audit_entry = {
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"correlation_id": correlation_id,
|
||||
"tool_name": tool_name,
|
||||
"repository": repository,
|
||||
"target": target,
|
||||
"params": params or {},
|
||||
"result_status": result_status,
|
||||
}
|
||||
|
||||
if error:
|
||||
audit_entry["error"] = error
|
||||
|
||||
self.logger.info("tool_invocation", **audit_entry)
|
||||
return correlation_id
|
||||
|
||||
def log_access_denied(
|
||||
self,
|
||||
tool_name: str,
|
||||
repository: Optional[str] = None,
|
||||
reason: str = "unauthorized",
|
||||
correlation_id: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Log an access denial event.
|
||||
|
||||
Args:
|
||||
tool_name: Name of the tool that was denied access
|
||||
repository: Repository identifier that access was denied to
|
||||
reason: Reason for denial
|
||||
correlation_id: Request correlation ID
|
||||
|
||||
Returns:
|
||||
Correlation ID for this event
|
||||
"""
|
||||
if correlation_id is None:
|
||||
correlation_id = str(uuid.uuid4())
|
||||
|
||||
self.logger.warning(
|
||||
"access_denied",
|
||||
timestamp=datetime.now(timezone.utc).isoformat(),
|
||||
correlation_id=correlation_id,
|
||||
tool_name=tool_name,
|
||||
repository=repository,
|
||||
reason=reason,
|
||||
)
|
||||
return correlation_id
|
||||
|
||||
def log_security_event(
|
||||
self,
|
||||
event_type: str,
|
||||
description: str,
|
||||
severity: str = "medium",
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> str:
|
||||
"""Log a security-related event.
|
||||
|
||||
Args:
|
||||
event_type: Type of security event (e.g., rate_limit, invalid_input)
|
||||
description: Human-readable description of the event
|
||||
severity: Severity level (low, medium, high, critical)
|
||||
metadata: Additional metadata about the event
|
||||
|
||||
Returns:
|
||||
Correlation ID for this event
|
||||
"""
|
||||
correlation_id = str(uuid.uuid4())
|
||||
|
||||
self.logger.warning(
|
||||
"security_event",
|
||||
timestamp=datetime.now(timezone.utc).isoformat(),
|
||||
correlation_id=correlation_id,
|
||||
event_type=event_type,
|
||||
description=description,
|
||||
severity=severity,
|
||||
metadata=metadata or {},
|
||||
)
|
||||
return correlation_id
|
||||
|
||||
|
||||
# Global audit logger instance
|
||||
_audit_logger: Optional[AuditLogger] = None
|
||||
|
||||
|
||||
def get_audit_logger() -> AuditLogger:
|
||||
"""Get or create global audit logger instance."""
|
||||
global _audit_logger
|
||||
if _audit_logger is None:
|
||||
_audit_logger = AuditLogger()
|
||||
return _audit_logger
|
||||
|
||||
|
||||
def reset_audit_logger() -> None:
|
||||
"""Reset global audit logger instance (primarily for testing)."""
|
||||
global _audit_logger
|
||||
_audit_logger = None
|
||||
109
src/aegis_gitea_mcp/config.py
Normal file
109
src/aegis_gitea_mcp/config.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""Configuration management for AegisGitea MCP server."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import Field, HttpUrl, field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings loaded from environment variables."""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# Gitea configuration
|
||||
gitea_url: HttpUrl = Field(
|
||||
...,
|
||||
description="Base URL of the Gitea instance",
|
||||
)
|
||||
gitea_token: str = Field(
|
||||
...,
|
||||
description="Bot user access token for Gitea API",
|
||||
min_length=1,
|
||||
)
|
||||
|
||||
# MCP server configuration
|
||||
mcp_host: str = Field(
|
||||
default="0.0.0.0",
|
||||
description="Host to bind MCP server to",
|
||||
)
|
||||
mcp_port: int = Field(
|
||||
default=8080,
|
||||
description="Port to bind MCP server to",
|
||||
ge=1,
|
||||
le=65535,
|
||||
)
|
||||
|
||||
# Logging configuration
|
||||
log_level: str = Field(
|
||||
default="INFO",
|
||||
description="Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)",
|
||||
)
|
||||
audit_log_path: Path = Field(
|
||||
default=Path("/var/log/aegis-mcp/audit.log"),
|
||||
description="Path to audit log file",
|
||||
)
|
||||
|
||||
# Security configuration
|
||||
max_file_size_bytes: int = Field(
|
||||
default=1_048_576, # 1MB
|
||||
description="Maximum file size that can be read (in bytes)",
|
||||
ge=1,
|
||||
)
|
||||
request_timeout_seconds: int = Field(
|
||||
default=30,
|
||||
description="Timeout for Gitea API requests (in seconds)",
|
||||
ge=1,
|
||||
)
|
||||
rate_limit_per_minute: int = Field(
|
||||
default=60,
|
||||
description="Maximum number of requests per minute",
|
||||
ge=1,
|
||||
)
|
||||
|
||||
@field_validator("log_level")
|
||||
@classmethod
|
||||
def validate_log_level(cls, v: str) -> str:
|
||||
"""Validate log level is one of the allowed values."""
|
||||
allowed_levels = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
|
||||
v_upper = v.upper()
|
||||
if v_upper not in allowed_levels:
|
||||
raise ValueError(f"log_level must be one of {allowed_levels}")
|
||||
return v_upper
|
||||
|
||||
@field_validator("gitea_token")
|
||||
@classmethod
|
||||
def validate_token_not_empty(cls, v: str) -> str:
|
||||
"""Validate Gitea token is not empty or whitespace."""
|
||||
if not v.strip():
|
||||
raise ValueError("gitea_token cannot be empty or whitespace")
|
||||
return v.strip()
|
||||
|
||||
@property
|
||||
def gitea_base_url(self) -> str:
|
||||
"""Get Gitea base URL as string."""
|
||||
return str(self.gitea_url).rstrip("/")
|
||||
|
||||
|
||||
# Global settings instance
|
||||
_settings: Optional[Settings] = None
|
||||
|
||||
|
||||
def get_settings() -> Settings:
|
||||
"""Get or create global settings instance."""
|
||||
global _settings
|
||||
if _settings is None:
|
||||
_settings = Settings() # type: ignore
|
||||
return _settings
|
||||
|
||||
|
||||
def reset_settings() -> None:
|
||||
"""Reset global settings instance (primarily for testing)."""
|
||||
global _settings
|
||||
_settings = None
|
||||
381
src/aegis_gitea_mcp/gitea_client.py
Normal file
381
src/aegis_gitea_mcp/gitea_client.py
Normal file
@@ -0,0 +1,381 @@
|
||||
"""Gitea API client with bot user authentication."""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import httpx
|
||||
from httpx import AsyncClient, Response
|
||||
|
||||
from aegis_gitea_mcp.audit import get_audit_logger
|
||||
from aegis_gitea_mcp.config import get_settings
|
||||
|
||||
|
||||
class GiteaError(Exception):
|
||||
"""Base exception for Gitea API errors."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class GiteaAuthenticationError(GiteaError):
|
||||
"""Raised when authentication with Gitea fails."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class GiteaAuthorizationError(GiteaError):
|
||||
"""Raised when bot user lacks permission for an operation."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class GiteaNotFoundError(GiteaError):
|
||||
"""Raised when a requested resource is not found."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class GiteaClient:
|
||||
"""Client for interacting with Gitea API as a bot user."""
|
||||
|
||||
def __init__(self, base_url: Optional[str] = None, token: Optional[str] = None) -> None:
|
||||
"""Initialize Gitea client.
|
||||
|
||||
Args:
|
||||
base_url: Base URL of Gitea instance (defaults to config value)
|
||||
token: Bot user access token (defaults to config value)
|
||||
"""
|
||||
self.settings = get_settings()
|
||||
self.audit = get_audit_logger()
|
||||
|
||||
self.base_url = (base_url or self.settings.gitea_base_url).rstrip("/")
|
||||
self.token = token or self.settings.gitea_token
|
||||
|
||||
self.client: Optional[AsyncClient] = None
|
||||
|
||||
async def __aenter__(self) -> "GiteaClient":
|
||||
"""Async context manager entry."""
|
||||
self.client = AsyncClient(
|
||||
base_url=self.base_url,
|
||||
headers={
|
||||
"Authorization": f"token {self.token}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout=self.settings.request_timeout_seconds,
|
||||
follow_redirects=True,
|
||||
)
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args: Any) -> None:
|
||||
"""Async context manager exit."""
|
||||
if self.client:
|
||||
await self.client.aclose()
|
||||
|
||||
def _handle_response(self, response: Response, correlation_id: str) -> Dict[str, Any]:
|
||||
"""Handle Gitea API response and raise appropriate exceptions.
|
||||
|
||||
Args:
|
||||
response: HTTP response from Gitea
|
||||
correlation_id: Correlation ID for audit logging
|
||||
|
||||
Returns:
|
||||
Parsed JSON response
|
||||
|
||||
Raises:
|
||||
GiteaAuthenticationError: On 401 responses
|
||||
GiteaAuthorizationError: On 403 responses
|
||||
GiteaNotFoundError: On 404 responses
|
||||
GiteaError: On other error responses
|
||||
"""
|
||||
if response.status_code == 401:
|
||||
self.audit.log_security_event(
|
||||
event_type="authentication_failure",
|
||||
description="Gitea API returned 401 Unauthorized",
|
||||
severity="high",
|
||||
metadata={"correlation_id": correlation_id},
|
||||
)
|
||||
raise GiteaAuthenticationError("Authentication failed - check bot token")
|
||||
|
||||
if response.status_code == 403:
|
||||
self.audit.log_access_denied(
|
||||
tool_name="gitea_api",
|
||||
reason="Bot user lacks permission",
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
raise GiteaAuthorizationError("Bot user lacks permission for this operation")
|
||||
|
||||
if response.status_code == 404:
|
||||
raise GiteaNotFoundError("Resource not found")
|
||||
|
||||
if response.status_code >= 400:
|
||||
error_msg = f"Gitea API error: {response.status_code}"
|
||||
try:
|
||||
error_data = response.json()
|
||||
error_msg = f"{error_msg} - {error_data.get('message', '')}"
|
||||
except Exception:
|
||||
pass
|
||||
raise GiteaError(error_msg)
|
||||
|
||||
try:
|
||||
return response.json()
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
async def get_current_user(self) -> Dict[str, Any]:
|
||||
"""Get information about the current bot user.
|
||||
|
||||
Returns:
|
||||
User information dict
|
||||
|
||||
Raises:
|
||||
GiteaError: On API errors
|
||||
"""
|
||||
if not self.client:
|
||||
raise RuntimeError("Client not initialized - use async context manager")
|
||||
|
||||
correlation_id = self.audit.log_tool_invocation(
|
||||
tool_name="get_current_user",
|
||||
result_status="pending",
|
||||
)
|
||||
|
||||
try:
|
||||
response = await self.client.get("/api/v1/user")
|
||||
user_data = self._handle_response(response, correlation_id)
|
||||
|
||||
self.audit.log_tool_invocation(
|
||||
tool_name="get_current_user",
|
||||
correlation_id=correlation_id,
|
||||
result_status="success",
|
||||
)
|
||||
|
||||
return user_data
|
||||
|
||||
except Exception as e:
|
||||
self.audit.log_tool_invocation(
|
||||
tool_name="get_current_user",
|
||||
correlation_id=correlation_id,
|
||||
result_status="error",
|
||||
error=str(e),
|
||||
)
|
||||
raise
|
||||
|
||||
async def list_repositories(self) -> List[Dict[str, Any]]:
|
||||
"""List all repositories visible to the bot user.
|
||||
|
||||
Returns:
|
||||
List of repository information dicts
|
||||
|
||||
Raises:
|
||||
GiteaError: On API errors
|
||||
"""
|
||||
if not self.client:
|
||||
raise RuntimeError("Client not initialized - use async context manager")
|
||||
|
||||
correlation_id = self.audit.log_tool_invocation(
|
||||
tool_name="list_repositories",
|
||||
result_status="pending",
|
||||
)
|
||||
|
||||
try:
|
||||
response = await self.client.get("/api/v1/user/repos")
|
||||
repos_data = self._handle_response(response, correlation_id)
|
||||
|
||||
# Ensure we have a list
|
||||
repos = repos_data if isinstance(repos_data, list) else []
|
||||
|
||||
self.audit.log_tool_invocation(
|
||||
tool_name="list_repositories",
|
||||
correlation_id=correlation_id,
|
||||
result_status="success",
|
||||
params={"count": len(repos)},
|
||||
)
|
||||
|
||||
return repos
|
||||
|
||||
except Exception as e:
|
||||
self.audit.log_tool_invocation(
|
||||
tool_name="list_repositories",
|
||||
correlation_id=correlation_id,
|
||||
result_status="error",
|
||||
error=str(e),
|
||||
)
|
||||
raise
|
||||
|
||||
async def get_repository(self, owner: str, repo: str) -> Dict[str, Any]:
|
||||
"""Get information about a specific repository.
|
||||
|
||||
Args:
|
||||
owner: Repository owner username
|
||||
repo: Repository name
|
||||
|
||||
Returns:
|
||||
Repository information dict
|
||||
|
||||
Raises:
|
||||
GiteaNotFoundError: If repository doesn't exist or bot lacks access
|
||||
GiteaError: On other API errors
|
||||
"""
|
||||
if not self.client:
|
||||
raise RuntimeError("Client not initialized - use async context manager")
|
||||
|
||||
repo_id = f"{owner}/{repo}"
|
||||
correlation_id = self.audit.log_tool_invocation(
|
||||
tool_name="get_repository",
|
||||
repository=repo_id,
|
||||
result_status="pending",
|
||||
)
|
||||
|
||||
try:
|
||||
response = await self.client.get(f"/api/v1/repos/{owner}/{repo}")
|
||||
repo_data = self._handle_response(response, correlation_id)
|
||||
|
||||
self.audit.log_tool_invocation(
|
||||
tool_name="get_repository",
|
||||
repository=repo_id,
|
||||
correlation_id=correlation_id,
|
||||
result_status="success",
|
||||
)
|
||||
|
||||
return repo_data
|
||||
|
||||
except Exception as e:
|
||||
self.audit.log_tool_invocation(
|
||||
tool_name="get_repository",
|
||||
repository=repo_id,
|
||||
correlation_id=correlation_id,
|
||||
result_status="error",
|
||||
error=str(e),
|
||||
)
|
||||
raise
|
||||
|
||||
async def get_file_contents(
|
||||
self, owner: str, repo: str, filepath: str, ref: str = "main"
|
||||
) -> Dict[str, Any]:
|
||||
"""Get contents of a file in a repository.
|
||||
|
||||
Args:
|
||||
owner: Repository owner username
|
||||
repo: Repository name
|
||||
filepath: Path to file within repository
|
||||
ref: Branch, tag, or commit ref (defaults to 'main')
|
||||
|
||||
Returns:
|
||||
File contents dict with 'content', 'encoding', 'size', etc.
|
||||
|
||||
Raises:
|
||||
GiteaNotFoundError: If file doesn't exist
|
||||
GiteaError: On other API errors
|
||||
"""
|
||||
if not self.client:
|
||||
raise RuntimeError("Client not initialized - use async context manager")
|
||||
|
||||
repo_id = f"{owner}/{repo}"
|
||||
correlation_id = self.audit.log_tool_invocation(
|
||||
tool_name="get_file_contents",
|
||||
repository=repo_id,
|
||||
target=filepath,
|
||||
params={"ref": ref},
|
||||
result_status="pending",
|
||||
)
|
||||
|
||||
try:
|
||||
response = await self.client.get(
|
||||
f"/api/v1/repos/{owner}/{repo}/contents/{filepath}",
|
||||
params={"ref": ref},
|
||||
)
|
||||
file_data = self._handle_response(response, correlation_id)
|
||||
|
||||
# Check file size against limit
|
||||
file_size = file_data.get("size", 0)
|
||||
if file_size > self.settings.max_file_size_bytes:
|
||||
error_msg = (
|
||||
f"File size ({file_size} bytes) exceeds "
|
||||
f"limit ({self.settings.max_file_size_bytes} bytes)"
|
||||
)
|
||||
self.audit.log_security_event(
|
||||
event_type="file_size_limit_exceeded",
|
||||
description=error_msg,
|
||||
severity="low",
|
||||
metadata={
|
||||
"repository": repo_id,
|
||||
"filepath": filepath,
|
||||
"file_size": file_size,
|
||||
"limit": self.settings.max_file_size_bytes,
|
||||
},
|
||||
)
|
||||
raise GiteaError(error_msg)
|
||||
|
||||
self.audit.log_tool_invocation(
|
||||
tool_name="get_file_contents",
|
||||
repository=repo_id,
|
||||
target=filepath,
|
||||
correlation_id=correlation_id,
|
||||
result_status="success",
|
||||
params={"ref": ref, "size": file_size},
|
||||
)
|
||||
|
||||
return file_data
|
||||
|
||||
except Exception as e:
|
||||
self.audit.log_tool_invocation(
|
||||
tool_name="get_file_contents",
|
||||
repository=repo_id,
|
||||
target=filepath,
|
||||
correlation_id=correlation_id,
|
||||
result_status="error",
|
||||
error=str(e),
|
||||
)
|
||||
raise
|
||||
|
||||
async def get_tree(
|
||||
self, owner: str, repo: str, ref: str = "main", recursive: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""Get file tree for a repository.
|
||||
|
||||
Args:
|
||||
owner: Repository owner username
|
||||
repo: Repository name
|
||||
ref: Branch, tag, or commit ref (defaults to 'main')
|
||||
recursive: Whether to recursively fetch tree (default: False for safety)
|
||||
|
||||
Returns:
|
||||
Tree information dict
|
||||
|
||||
Raises:
|
||||
GiteaError: On API errors
|
||||
"""
|
||||
if not self.client:
|
||||
raise RuntimeError("Client not initialized - use async context manager")
|
||||
|
||||
repo_id = f"{owner}/{repo}"
|
||||
correlation_id = self.audit.log_tool_invocation(
|
||||
tool_name="get_tree",
|
||||
repository=repo_id,
|
||||
params={"ref": ref, "recursive": recursive},
|
||||
result_status="pending",
|
||||
)
|
||||
|
||||
try:
|
||||
response = await self.client.get(
|
||||
f"/api/v1/repos/{owner}/{repo}/git/trees/{ref}",
|
||||
params={"recursive": str(recursive).lower()},
|
||||
)
|
||||
tree_data = self._handle_response(response, correlation_id)
|
||||
|
||||
self.audit.log_tool_invocation(
|
||||
tool_name="get_tree",
|
||||
repository=repo_id,
|
||||
correlation_id=correlation_id,
|
||||
result_status="success",
|
||||
params={"ref": ref, "recursive": recursive, "count": len(tree_data.get("tree", []))},
|
||||
)
|
||||
|
||||
return tree_data
|
||||
|
||||
except Exception as e:
|
||||
self.audit.log_tool_invocation(
|
||||
tool_name="get_tree",
|
||||
repository=repo_id,
|
||||
correlation_id=correlation_id,
|
||||
result_status="error",
|
||||
error=str(e),
|
||||
)
|
||||
raise
|
||||
156
src/aegis_gitea_mcp/mcp_protocol.py
Normal file
156
src/aegis_gitea_mcp/mcp_protocol.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""MCP protocol implementation for AegisGitea."""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class MCPTool(BaseModel):
|
||||
"""MCP tool definition."""
|
||||
|
||||
name: str = Field(..., description="Unique tool identifier")
|
||||
description: str = Field(..., description="Human-readable tool description")
|
||||
input_schema: Dict[str, Any] = Field(..., description="JSON Schema for tool input")
|
||||
|
||||
|
||||
class MCPToolCallRequest(BaseModel):
|
||||
"""Request to invoke an MCP tool."""
|
||||
|
||||
tool: str = Field(..., description="Name of the tool to invoke")
|
||||
arguments: Dict[str, Any] = Field(default_factory=dict, description="Tool arguments")
|
||||
correlation_id: Optional[str] = Field(None, description="Request correlation ID")
|
||||
|
||||
|
||||
class MCPToolCallResponse(BaseModel):
|
||||
"""Response from an MCP tool invocation."""
|
||||
|
||||
success: bool = Field(..., description="Whether the tool call succeeded")
|
||||
result: Optional[Any] = Field(None, description="Tool result data")
|
||||
error: Optional[str] = Field(None, description="Error message if failed")
|
||||
correlation_id: str = Field(..., description="Request correlation ID")
|
||||
|
||||
|
||||
class MCPListToolsResponse(BaseModel):
|
||||
"""Response listing available MCP tools."""
|
||||
|
||||
tools: List[MCPTool] = Field(..., description="List of available tools")
|
||||
|
||||
|
||||
# Tool definitions for AegisGitea MCP
|
||||
|
||||
TOOL_LIST_REPOSITORIES = MCPTool(
|
||||
name="list_repositories",
|
||||
description="List all repositories visible to the AI bot user. "
|
||||
"Only repositories where the bot has explicit read access will be returned. "
|
||||
"This respects Gitea's dynamic authorization model.",
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": [],
|
||||
},
|
||||
)
|
||||
|
||||
TOOL_GET_REPOSITORY_INFO = MCPTool(
|
||||
name="get_repository_info",
|
||||
description="Get detailed information about a specific repository, "
|
||||
"including description, default branch, language, and metadata. "
|
||||
"Requires the bot user to have read access.",
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {
|
||||
"type": "string",
|
||||
"description": "Repository owner username or organization",
|
||||
},
|
||||
"repo": {
|
||||
"type": "string",
|
||||
"description": "Repository name",
|
||||
},
|
||||
},
|
||||
"required": ["owner", "repo"],
|
||||
},
|
||||
)
|
||||
|
||||
TOOL_GET_FILE_TREE = MCPTool(
|
||||
name="get_file_tree",
|
||||
description="Get the file tree structure for a repository at a specific ref. "
|
||||
"Returns a list of files and directories. "
|
||||
"Non-recursive by default for safety (max depth: 1 level).",
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {
|
||||
"type": "string",
|
||||
"description": "Repository owner username or organization",
|
||||
},
|
||||
"repo": {
|
||||
"type": "string",
|
||||
"description": "Repository name",
|
||||
},
|
||||
"ref": {
|
||||
"type": "string",
|
||||
"description": "Branch, tag, or commit SHA (defaults to 'main')",
|
||||
"default": "main",
|
||||
},
|
||||
"recursive": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to recursively fetch entire tree (use with caution)",
|
||||
"default": False,
|
||||
},
|
||||
},
|
||||
"required": ["owner", "repo"],
|
||||
},
|
||||
)
|
||||
|
||||
TOOL_GET_FILE_CONTENTS = MCPTool(
|
||||
name="get_file_contents",
|
||||
description="Read the contents of a specific file in a repository. "
|
||||
"File size is limited to 1MB by default for safety. "
|
||||
"Returns base64-encoded content for binary files.",
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {
|
||||
"type": "string",
|
||||
"description": "Repository owner username or organization",
|
||||
},
|
||||
"repo": {
|
||||
"type": "string",
|
||||
"description": "Repository name",
|
||||
},
|
||||
"filepath": {
|
||||
"type": "string",
|
||||
"description": "Path to file within repository (e.g., 'src/main.py')",
|
||||
},
|
||||
"ref": {
|
||||
"type": "string",
|
||||
"description": "Branch, tag, or commit SHA (defaults to 'main')",
|
||||
"default": "main",
|
||||
},
|
||||
},
|
||||
"required": ["owner", "repo", "filepath"],
|
||||
},
|
||||
)
|
||||
|
||||
# Registry of all available tools
|
||||
AVAILABLE_TOOLS: List[MCPTool] = [
|
||||
TOOL_LIST_REPOSITORIES,
|
||||
TOOL_GET_REPOSITORY_INFO,
|
||||
TOOL_GET_FILE_TREE,
|
||||
TOOL_GET_FILE_CONTENTS,
|
||||
]
|
||||
|
||||
|
||||
def get_tool_by_name(tool_name: str) -> Optional[MCPTool]:
|
||||
"""Get tool definition by name.
|
||||
|
||||
Args:
|
||||
tool_name: Name of the tool to retrieve
|
||||
|
||||
Returns:
|
||||
Tool definition or None if not found
|
||||
"""
|
||||
for tool in AVAILABLE_TOOLS:
|
||||
if tool.name == tool_name:
|
||||
return tool
|
||||
return None
|
||||
246
src/aegis_gitea_mcp/server.py
Normal file
246
src/aegis_gitea_mcp/server.py
Normal file
@@ -0,0 +1,246 @@
|
||||
"""Main MCP server implementation with FastAPI and SSE support."""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from pydantic import ValidationError
|
||||
|
||||
from aegis_gitea_mcp.audit import get_audit_logger
|
||||
from aegis_gitea_mcp.config import get_settings
|
||||
from aegis_gitea_mcp.gitea_client import GiteaClient
|
||||
from aegis_gitea_mcp.mcp_protocol import (
|
||||
AVAILABLE_TOOLS,
|
||||
MCPListToolsResponse,
|
||||
MCPToolCallRequest,
|
||||
MCPToolCallResponse,
|
||||
get_tool_by_name,
|
||||
)
|
||||
from aegis_gitea_mcp.tools.repository import (
|
||||
get_file_contents_tool,
|
||||
get_file_tree_tool,
|
||||
get_repository_info_tool,
|
||||
list_repositories_tool,
|
||||
)
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Initialize FastAPI app
|
||||
app = FastAPI(
|
||||
title="AegisGitea MCP Server",
|
||||
description="Security-first MCP server for controlled AI access to self-hosted Gitea",
|
||||
version="0.1.0",
|
||||
)
|
||||
|
||||
# Global settings and audit logger
|
||||
settings = get_settings()
|
||||
audit = get_audit_logger()
|
||||
|
||||
|
||||
# Tool dispatcher mapping
|
||||
TOOL_HANDLERS = {
|
||||
"list_repositories": list_repositories_tool,
|
||||
"get_repository_info": get_repository_info_tool,
|
||||
"get_file_tree": get_file_tree_tool,
|
||||
"get_file_contents": get_file_contents_tool,
|
||||
}
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event() -> None:
|
||||
"""Initialize server on startup."""
|
||||
logger.info(f"Starting AegisGitea MCP Server on {settings.mcp_host}:{settings.mcp_port}")
|
||||
logger.info(f"Connected to Gitea instance: {settings.gitea_base_url}")
|
||||
logger.info(f"Audit logging enabled: {settings.audit_log_path}")
|
||||
|
||||
# Test Gitea connection
|
||||
try:
|
||||
async with GiteaClient() as gitea:
|
||||
user = await gitea.get_current_user()
|
||||
logger.info(f"Authenticated as bot user: {user.get('login', 'unknown')}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect to Gitea: {e}")
|
||||
raise
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown_event() -> None:
|
||||
"""Cleanup on server shutdown."""
|
||||
logger.info("Shutting down AegisGitea MCP Server")
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root() -> Dict[str, Any]:
|
||||
"""Root endpoint with server information."""
|
||||
return {
|
||||
"name": "AegisGitea MCP Server",
|
||||
"version": "0.1.0",
|
||||
"status": "running",
|
||||
"mcp_version": "1.0",
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> Dict[str, str]:
|
||||
"""Health check endpoint."""
|
||||
return {"status": "healthy"}
|
||||
|
||||
|
||||
@app.get("/mcp/tools")
|
||||
async def list_tools() -> JSONResponse:
|
||||
"""List all available MCP tools.
|
||||
|
||||
Returns:
|
||||
JSON response with list of tool definitions
|
||||
"""
|
||||
response = MCPListToolsResponse(tools=AVAILABLE_TOOLS)
|
||||
return JSONResponse(content=response.model_dump())
|
||||
|
||||
|
||||
@app.post("/mcp/tool/call")
|
||||
async def call_tool(request: MCPToolCallRequest) -> JSONResponse:
|
||||
"""Execute an MCP tool call.
|
||||
|
||||
Args:
|
||||
request: Tool call request with tool name and arguments
|
||||
|
||||
Returns:
|
||||
JSON response with tool execution result
|
||||
"""
|
||||
correlation_id = request.correlation_id or audit.log_tool_invocation(
|
||||
tool_name=request.tool,
|
||||
params=request.arguments,
|
||||
)
|
||||
|
||||
try:
|
||||
# Validate tool exists
|
||||
tool_def = get_tool_by_name(request.tool)
|
||||
if not tool_def:
|
||||
error_msg = f"Tool '{request.tool}' not found"
|
||||
audit.log_tool_invocation(
|
||||
tool_name=request.tool,
|
||||
correlation_id=correlation_id,
|
||||
result_status="error",
|
||||
error=error_msg,
|
||||
)
|
||||
raise HTTPException(status_code=404, detail=error_msg)
|
||||
|
||||
# Get tool handler
|
||||
handler = TOOL_HANDLERS.get(request.tool)
|
||||
if not handler:
|
||||
error_msg = f"Tool '{request.tool}' has no handler implementation"
|
||||
audit.log_tool_invocation(
|
||||
tool_name=request.tool,
|
||||
correlation_id=correlation_id,
|
||||
result_status="error",
|
||||
error=error_msg,
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=error_msg)
|
||||
|
||||
# Execute tool with Gitea client
|
||||
async with GiteaClient() as gitea:
|
||||
result = await handler(gitea, request.arguments)
|
||||
|
||||
audit.log_tool_invocation(
|
||||
tool_name=request.tool,
|
||||
correlation_id=correlation_id,
|
||||
result_status="success",
|
||||
)
|
||||
|
||||
response = MCPToolCallResponse(
|
||||
success=True,
|
||||
result=result,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
return JSONResponse(content=response.model_dump())
|
||||
|
||||
except ValidationError as e:
|
||||
error_msg = f"Invalid arguments: {str(e)}"
|
||||
audit.log_tool_invocation(
|
||||
tool_name=request.tool,
|
||||
correlation_id=correlation_id,
|
||||
result_status="error",
|
||||
error=error_msg,
|
||||
)
|
||||
raise HTTPException(status_code=400, detail=error_msg)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
audit.log_tool_invocation(
|
||||
tool_name=request.tool,
|
||||
correlation_id=correlation_id,
|
||||
result_status="error",
|
||||
error=error_msg,
|
||||
)
|
||||
response = MCPToolCallResponse(
|
||||
success=False,
|
||||
error=error_msg,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
return JSONResponse(content=response.model_dump(), status_code=500)
|
||||
|
||||
|
||||
@app.get("/mcp/sse")
|
||||
async def sse_endpoint(request: Request) -> StreamingResponse:
|
||||
"""Server-Sent Events endpoint for MCP protocol.
|
||||
|
||||
This enables real-time communication with ChatGPT using SSE.
|
||||
|
||||
Returns:
|
||||
Streaming SSE response
|
||||
"""
|
||||
async def event_stream():
|
||||
"""Generate SSE events."""
|
||||
# Send initial connection event
|
||||
yield f"data: {{'event': 'connected', 'server': 'AegisGitea MCP', 'version': '0.1.0'}}\n\n"
|
||||
|
||||
# Keep connection alive
|
||||
try:
|
||||
while True:
|
||||
if await request.is_disconnected():
|
||||
break
|
||||
|
||||
# Heartbeat every 30 seconds
|
||||
yield f"data: {{'event': 'heartbeat'}}\n\n"
|
||||
|
||||
# Wait for next heartbeat (in production, this would handle actual events)
|
||||
import asyncio
|
||||
await asyncio.sleep(30)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"SSE stream error: {e}")
|
||||
|
||||
return StreamingResponse(
|
||||
event_stream(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Run the MCP server."""
|
||||
import uvicorn
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
uvicorn.run(
|
||||
"aegis_gitea_mcp.server:app",
|
||||
host=settings.mcp_host,
|
||||
port=settings.mcp_port,
|
||||
log_level=settings.log_level.lower(),
|
||||
reload=False,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
15
src/aegis_gitea_mcp/tools/__init__.py
Normal file
15
src/aegis_gitea_mcp/tools/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""MCP tool implementations for AegisGitea."""
|
||||
|
||||
from aegis_gitea_mcp.tools.repository import (
|
||||
get_file_contents_tool,
|
||||
get_file_tree_tool,
|
||||
get_repository_info_tool,
|
||||
list_repositories_tool,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"list_repositories_tool",
|
||||
"get_repository_info_tool",
|
||||
"get_file_tree_tool",
|
||||
"get_file_contents_tool",
|
||||
]
|
||||
189
src/aegis_gitea_mcp/tools/repository.py
Normal file
189
src/aegis_gitea_mcp/tools/repository.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""Repository-related MCP tool implementations."""
|
||||
|
||||
import base64
|
||||
from typing import Any, Dict
|
||||
|
||||
from aegis_gitea_mcp.gitea_client import GiteaClient, GiteaError
|
||||
|
||||
|
||||
async def list_repositories_tool(gitea: GiteaClient, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""List all repositories visible to the bot user.
|
||||
|
||||
Args:
|
||||
gitea: Initialized Gitea client
|
||||
arguments: Tool arguments (empty for this tool)
|
||||
|
||||
Returns:
|
||||
Dict containing list of repositories with metadata
|
||||
"""
|
||||
try:
|
||||
repos = await gitea.list_repositories()
|
||||
|
||||
# Transform to simplified format
|
||||
simplified_repos = [
|
||||
{
|
||||
"owner": repo.get("owner", {}).get("login", ""),
|
||||
"name": repo.get("name", ""),
|
||||
"full_name": repo.get("full_name", ""),
|
||||
"description": repo.get("description", ""),
|
||||
"private": repo.get("private", False),
|
||||
"default_branch": repo.get("default_branch", "main"),
|
||||
"language": repo.get("language", ""),
|
||||
"stars": repo.get("stars_count", 0),
|
||||
"url": repo.get("html_url", ""),
|
||||
}
|
||||
for repo in repos
|
||||
]
|
||||
|
||||
return {
|
||||
"repositories": simplified_repos,
|
||||
"count": len(simplified_repos),
|
||||
}
|
||||
|
||||
except GiteaError as e:
|
||||
raise Exception(f"Failed to list repositories: {str(e)}")
|
||||
|
||||
|
||||
async def get_repository_info_tool(
|
||||
gitea: GiteaClient, arguments: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""Get detailed information about a specific repository.
|
||||
|
||||
Args:
|
||||
gitea: Initialized Gitea client
|
||||
arguments: Tool arguments with 'owner' and 'repo'
|
||||
|
||||
Returns:
|
||||
Dict containing repository information
|
||||
"""
|
||||
owner = arguments.get("owner")
|
||||
repo = arguments.get("repo")
|
||||
|
||||
if not owner or not repo:
|
||||
raise ValueError("Both 'owner' and 'repo' arguments are required")
|
||||
|
||||
try:
|
||||
repo_data = await gitea.get_repository(owner, repo)
|
||||
|
||||
return {
|
||||
"owner": repo_data.get("owner", {}).get("login", ""),
|
||||
"name": repo_data.get("name", ""),
|
||||
"full_name": repo_data.get("full_name", ""),
|
||||
"description": repo_data.get("description", ""),
|
||||
"private": repo_data.get("private", False),
|
||||
"fork": repo_data.get("fork", False),
|
||||
"default_branch": repo_data.get("default_branch", "main"),
|
||||
"language": repo_data.get("language", ""),
|
||||
"stars": repo_data.get("stars_count", 0),
|
||||
"forks": repo_data.get("forks_count", 0),
|
||||
"open_issues": repo_data.get("open_issues_count", 0),
|
||||
"size": repo_data.get("size", 0),
|
||||
"created_at": repo_data.get("created_at", ""),
|
||||
"updated_at": repo_data.get("updated_at", ""),
|
||||
"url": repo_data.get("html_url", ""),
|
||||
"clone_url": repo_data.get("clone_url", ""),
|
||||
}
|
||||
|
||||
except GiteaError as e:
|
||||
raise Exception(f"Failed to get repository info: {str(e)}")
|
||||
|
||||
|
||||
async def get_file_tree_tool(gitea: GiteaClient, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get file tree for a repository.
|
||||
|
||||
Args:
|
||||
gitea: Initialized Gitea client
|
||||
arguments: Tool arguments with 'owner', 'repo', optional 'ref' and 'recursive'
|
||||
|
||||
Returns:
|
||||
Dict containing file tree structure
|
||||
"""
|
||||
owner = arguments.get("owner")
|
||||
repo = arguments.get("repo")
|
||||
ref = arguments.get("ref", "main")
|
||||
recursive = arguments.get("recursive", False)
|
||||
|
||||
if not owner or not repo:
|
||||
raise ValueError("Both 'owner' and 'repo' arguments are required")
|
||||
|
||||
try:
|
||||
tree_data = await gitea.get_tree(owner, repo, ref, recursive)
|
||||
|
||||
# Transform tree entries to simplified format
|
||||
tree_entries = tree_data.get("tree", [])
|
||||
simplified_tree = [
|
||||
{
|
||||
"path": entry.get("path", ""),
|
||||
"type": entry.get("type", ""), # 'blob' (file) or 'tree' (directory)
|
||||
"size": entry.get("size", 0),
|
||||
"sha": entry.get("sha", ""),
|
||||
}
|
||||
for entry in tree_entries
|
||||
]
|
||||
|
||||
return {
|
||||
"owner": owner,
|
||||
"repo": repo,
|
||||
"ref": ref,
|
||||
"tree": simplified_tree,
|
||||
"count": len(simplified_tree),
|
||||
}
|
||||
|
||||
except GiteaError as e:
|
||||
raise Exception(f"Failed to get file tree: {str(e)}")
|
||||
|
||||
|
||||
async def get_file_contents_tool(gitea: GiteaClient, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get contents of a file in a repository.
|
||||
|
||||
Args:
|
||||
gitea: Initialized Gitea client
|
||||
arguments: Tool arguments with 'owner', 'repo', 'filepath', optional 'ref'
|
||||
|
||||
Returns:
|
||||
Dict containing file contents and metadata
|
||||
"""
|
||||
owner = arguments.get("owner")
|
||||
repo = arguments.get("repo")
|
||||
filepath = arguments.get("filepath")
|
||||
ref = arguments.get("ref", "main")
|
||||
|
||||
if not owner or not repo or not filepath:
|
||||
raise ValueError("'owner', 'repo', and 'filepath' arguments are required")
|
||||
|
||||
try:
|
||||
file_data = await gitea.get_file_contents(owner, repo, filepath, ref)
|
||||
|
||||
# Content is base64-encoded by Gitea
|
||||
content_b64 = file_data.get("content", "")
|
||||
encoding = file_data.get("encoding", "base64")
|
||||
|
||||
# Decode if base64
|
||||
content = content_b64
|
||||
if encoding == "base64":
|
||||
try:
|
||||
content_bytes = base64.b64decode(content_b64)
|
||||
# Try to decode as UTF-8 text
|
||||
try:
|
||||
content = content_bytes.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
# If not text, keep as base64
|
||||
content = content_b64
|
||||
except Exception:
|
||||
# If decode fails, keep as-is
|
||||
pass
|
||||
|
||||
return {
|
||||
"owner": owner,
|
||||
"repo": repo,
|
||||
"filepath": filepath,
|
||||
"ref": ref,
|
||||
"content": content,
|
||||
"encoding": encoding,
|
||||
"size": file_data.get("size", 0),
|
||||
"sha": file_data.get("sha", ""),
|
||||
"url": file_data.get("html_url", ""),
|
||||
}
|
||||
|
||||
except GiteaError as e:
|
||||
raise Exception(f"Failed to get file contents: {str(e)}")
|
||||
Reference in New Issue
Block a user