247 lines
7.0 KiB
Python
247 lines
7.0 KiB
Python
"""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()
|