This commit is contained in:
2026-01-29 19:53:36 +01:00
parent 1bda2013bb
commit a9708b33e2
27 changed files with 3745 additions and 4 deletions

View File

@@ -0,0 +1,3 @@
"""AegisGitea MCP - Security-first MCP server for self-hosted Gitea."""
__version__ = "0.1.0"

View 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

View 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

View 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

View 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

View 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()

View 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",
]

View 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)}")