"""Main MCP server implementation with hardened security controls.""" from __future__ import annotations import asyncio import base64 import json import logging import urllib.parse import uuid from collections.abc import AsyncGenerator, Awaitable, Callable from typing import Any import httpx from fastapi import FastAPI, HTTPException, Request, Response from fastapi.responses import JSONResponse, PlainTextResponse, RedirectResponse, StreamingResponse from pydantic import BaseModel, Field, ValidationError from aegis_gitea_mcp.audit import get_audit_logger from aegis_gitea_mcp.automation import AutomationError, AutomationManager from aegis_gitea_mcp.config import get_settings from aegis_gitea_mcp.gitea_client import ( GiteaAuthenticationError, GiteaAuthorizationError, GiteaClient, ) from aegis_gitea_mcp.logging_utils import configure_logging from aegis_gitea_mcp.mcp_protocol import ( AVAILABLE_TOOLS, MCPListToolsResponse, MCPToolCallRequest, MCPToolCallResponse, get_tool_by_name, ) from aegis_gitea_mcp.oauth import get_oauth_validator from aegis_gitea_mcp.observability import get_metrics_registry, monotonic_seconds from aegis_gitea_mcp.policy import PolicyError, get_policy_engine from aegis_gitea_mcp.rate_limit import get_rate_limiter from aegis_gitea_mcp.request_context import ( clear_gitea_auth_context, get_gitea_user_scopes, get_gitea_user_token, set_gitea_user_login, set_gitea_user_scopes, set_gitea_user_token, set_request_id, ) from aegis_gitea_mcp.security import sanitize_data from aegis_gitea_mcp.tools.arguments import extract_repository, extract_target_path from aegis_gitea_mcp.tools.read_tools import ( compare_refs_tool, get_commit_diff_tool, get_issue_tool, get_pull_request_tool, list_commits_tool, list_issues_tool, list_labels_tool, list_pull_requests_tool, list_releases_tool, list_tags_tool, search_code_tool, ) from aegis_gitea_mcp.tools.repository import ( get_file_contents_tool, get_file_tree_tool, get_repository_info_tool, list_repositories_tool, ) from aegis_gitea_mcp.tools.write_tools import ( add_labels_tool, assign_issue_tool, create_issue_comment_tool, create_issue_tool, create_pr_comment_tool, update_issue_tool, ) logger = logging.getLogger(__name__) READ_SCOPE = "read:repository" WRITE_SCOPE = "write:repository" # Cache of tokens verified to have Gitea API scope. # Key: hash of token prefix, Value: monotonic expiry time. _api_scope_cache: dict[str, float] = {} _API_SCOPE_CACHE_TTL = 60 # seconds _REAUTH_GUIDANCE = ( "Your OAuth token lacks Gitea API scopes (e.g. read:repository). " "Revoke the authorization in Gitea (Settings > Applications > Authorized OAuth2 Applications) " "and in ChatGPT (Settings > Connected apps), then re-authorize." ) def _has_required_scope(required_scope: str, granted_scopes: set[str]) -> bool: """Return whether granted scopes satisfy the required MCP tool scope.""" normalized = {scope.strip().lower() for scope in granted_scopes if scope and scope.strip()} expanded = set(normalized) # Compatibility: broad repository scopes imply both read and write repository access. if "repository" in normalized or "repo" in normalized: expanded.update({READ_SCOPE, WRITE_SCOPE}) if "write:repo" in normalized: expanded.add(WRITE_SCOPE) if "read:repo" in normalized: expanded.add(READ_SCOPE) if WRITE_SCOPE in expanded: expanded.add(READ_SCOPE) return required_scope in expanded app = FastAPI( title="AegisGitea MCP Server", description="Security-first MCP server for controlled AI access to self-hosted Gitea", version="0.2.0", ) class AutomationWebhookRequest(BaseModel): """Request body for automation webhook ingestion.""" event_type: str = Field(..., min_length=1, max_length=128) payload: dict[str, Any] = Field(default_factory=dict) repository: str | None = Field(default=None) class AutomationJobRequest(BaseModel): """Request body for automation job execution.""" job_name: str = Field(..., min_length=1, max_length=128) owner: str = Field(..., min_length=1, max_length=100) repo: str = Field(..., min_length=1, max_length=100) finding_title: str | None = Field(default=None, max_length=256) finding_body: str | None = Field(default=None, max_length=10_000) ToolHandler = Callable[[GiteaClient, dict[str, Any]], Awaitable[dict[str, Any]]] TOOL_HANDLERS: dict[str, ToolHandler] = { # Baseline read tools "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, # Expanded read tools "search_code": search_code_tool, "list_commits": list_commits_tool, "get_commit_diff": get_commit_diff_tool, "compare_refs": compare_refs_tool, "list_issues": list_issues_tool, "get_issue": get_issue_tool, "list_pull_requests": list_pull_requests_tool, "get_pull_request": get_pull_request_tool, "list_labels": list_labels_tool, "list_tags": list_tags_tool, "list_releases": list_releases_tool, # Write-mode tools "create_issue": create_issue_tool, "update_issue": update_issue_tool, "create_issue_comment": create_issue_comment_tool, "create_pr_comment": create_pr_comment_tool, "add_labels": add_labels_tool, "assign_issue": assign_issue_tool, } def _oauth_metadata_url(request: Request) -> str: """Build absolute metadata URL for OAuth challenge responses.""" settings = get_settings() base_url = settings.public_base or str(request.base_url).rstrip("/") return f"{base_url}/.well-known/oauth-protected-resource" def _oauth_unauthorized_response( request: Request, message: str, scope: str = READ_SCOPE, ) -> JSONResponse: """Return RFC-compliant OAuth challenge response for protected MCP endpoints.""" metadata_url = _oauth_metadata_url(request) response = JSONResponse( status_code=401, content={ "error": "Authentication failed", "message": message, "request_id": getattr(request.state, "request_id", "-"), }, ) response.headers["WWW-Authenticate"] = ( f'Bearer resource_metadata="{metadata_url}", scope="{scope}"' ) return response @app.middleware("http") async def request_context_middleware( request: Request, call_next: Callable[[Request], Awaitable[Response]], ) -> Response: """Attach request correlation context and collect request metrics.""" request_id = request.headers.get("x-request-id") or str(uuid.uuid4()) set_request_id(request_id) request.state.request_id = request_id started_at = monotonic_seconds() status_code = 500 try: response = await call_next(request) status_code = response.status_code response.headers["X-Request-ID"] = request_id return response finally: duration = max(monotonic_seconds() - started_at, 0.0) logger.debug( "request_completed", extra={ "method": request.method, "path": request.url.path, "duration_seconds": duration, "status_code": status_code, }, ) metrics = get_metrics_registry() metrics.record_http_request(request.method, request.url.path, status_code) @app.middleware("http") async def authenticate_and_rate_limit( request: Request, call_next: Callable[[Request], Awaitable[Response]], ) -> Response: """Apply rate-limiting and authentication for MCP endpoints.""" clear_gitea_auth_context() settings = get_settings() if request.url.path in {"/", "/health"}: return await call_next(request) if request.url.path == "/metrics" and settings.metrics_enabled: # Metrics endpoint is intentionally left unauthenticated for pull-based scraping. return await call_next(request) # OAuth discovery and token endpoints must be public so ChatGPT can complete the flow. if request.url.path in { "/oauth/token", "/.well-known/oauth-protected-resource", "/.well-known/oauth-authorization-server", "/.well-known/openid-configuration", }: return await call_next(request) if not (request.url.path.startswith("/mcp/") or request.url.path.startswith("/automation/")): return await call_next(request) oauth_validator = get_oauth_validator() limiter = get_rate_limiter() client_ip = request.client.host if request.client else "unknown" user_agent = request.headers.get("user-agent", "unknown") auth_header = request.headers.get("authorization") access_token = oauth_validator.extract_bearer_token(auth_header) rate_limit = limiter.check(client_ip=client_ip, token=access_token) if not rate_limit.allowed: return JSONResponse( status_code=429, content={ "error": "Rate limit exceeded", "message": rate_limit.reason, "request_id": getattr(request.state, "request_id", "-"), }, ) # Mixed mode: tool discovery remains public to preserve MCP client compatibility. if request.url.path == "/mcp/tools": return await call_next(request) if not access_token: if request.url.path.startswith("/mcp/"): return _oauth_unauthorized_response( request, "Provide Authorization: Bearer .", scope=READ_SCOPE, ) return JSONResponse( status_code=401, content={ "error": "Authentication failed", "message": "Provide Authorization: Bearer .", "request_id": getattr(request.state, "request_id", "-"), }, ) is_valid, error_message, user_data = await oauth_validator.validate_oauth_token( access_token, client_ip, user_agent ) if not is_valid: if request.url.path.startswith("/mcp/"): return _oauth_unauthorized_response( request, error_message or "Invalid or expired OAuth token.", scope=READ_SCOPE, ) return JSONResponse( status_code=401, content={ "error": "Authentication failed", "message": error_message or "Invalid or expired OAuth token.", "request_id": getattr(request.state, "request_id", "-"), }, ) if user_data: set_gitea_user_token(access_token) login = str(user_data.get("login", "unknown")) set_gitea_user_login(login) observed_scopes: list[str] = list(user_data.get("scopes", [])) # Gitea's OIDC tokens only carry standard scopes (openid, profile, email), # not granular Gitea scopes like read:repository. When a token is # successfully validated the user has already authorized this OAuth app, # so we grant read:repository implicitly (and write:repository when # write_mode is enabled). The Gitea API itself still enforces per-repo # permissions on every call made with the user's token. effective_scopes: set[str] = set(observed_scopes) effective_scopes.add(READ_SCOPE) if settings.write_mode: effective_scopes.add(WRITE_SCOPE) set_gitea_user_scopes(effective_scopes) # Probe: verify the token actually works for Gitea's repository API. # Try both "token" and "Bearer" header formats since Gitea may # accept OAuth tokens differently depending on version/config. import hashlib import time as _time token_hash = hashlib.sha256(access_token.encode()).hexdigest()[:16] now = _time.monotonic() probe_result = "skip:cached" token_type = "jwt" if access_token.count(".") == 2 else "opaque" if token_hash not in _api_scope_cache or now >= _api_scope_cache[token_hash]: # JWT tokens (OIDC) are already cryptographically validated via JWKS above. # Gitea's OIDC access_tokens cannot access the REST API without additional # Gitea-specific scope configuration, so we skip the probe for them and # rely on per-call API errors for actual permission enforcement. if token_type == "jwt": probe_result = "skip:jwt" _api_scope_cache[token_hash] = now + _API_SCOPE_CACHE_TTL else: try: probe_status = None async with httpx.AsyncClient( timeout=settings.request_timeout_seconds ) as probe_client: probe_url = f"{settings.gitea_base_url}/api/v1/user" # Try "token" format first (Gitea PAT style) probe_resp = await probe_client.get( probe_url, headers={"Authorization": f"token {access_token}"}, ) probe_status = probe_resp.status_code # If "token" format fails, try "Bearer" (OAuth2 standard) if probe_status in (401, 403): probe_resp = await probe_client.get( probe_url, headers={"Authorization": f"Bearer {access_token}"}, ) probe_status = probe_resp.status_code if probe_status in (401, 403): probe_result = f"fail:{probe_status}" logger.warning( "oauth_token_lacks_api_scope", extra={ "status": probe_status, "login": login, "token_type": token_type, "scopes_observed": observed_scopes, }, ) message = ( "OAuth token is valid but lacks required Gitea API access. " "Re-authorize this OAuth app in Gitea and try again." ) if request.url.path.startswith("/mcp/"): return _oauth_unauthorized_response( request, message, scope=READ_SCOPE, ) return JSONResponse( status_code=401, content={ "error": "Authentication failed", "message": message, "request_id": getattr(request.state, "request_id", "-"), }, ) else: probe_result = "pass" _api_scope_cache[token_hash] = now + _API_SCOPE_CACHE_TTL except httpx.RequestError: probe_result = "skip:error" logger.debug("oauth_api_scope_probe_network_error") logger.info( "oauth_auth_summary", extra={ "token_type": token_type, "scopes_observed": observed_scopes, "scopes_effective": sorted(effective_scopes), "api_probe": probe_result, "login": login, }, ) return await call_next(request) @app.on_event("startup") async def startup_event() -> None: """Initialize server state on startup.""" settings = get_settings() configure_logging(settings.log_level) logger.info("server_starting") logger.info( "server_configuration", extra={ "host": settings.mcp_host, "port": settings.mcp_port, "gitea_url": settings.gitea_base_url, "auth_enabled": settings.auth_enabled, "write_mode": settings.write_mode, "metrics_enabled": settings.metrics_enabled, }, ) # Fail-fast policy parse errors at startup. try: _ = get_policy_engine() except PolicyError: logger.error("policy_load_failed") raise if settings.startup_validate_gitea and settings.environment != "test": discovery_url = f"{settings.gitea_base_url}/.well-known/openid-configuration" try: async with httpx.AsyncClient(timeout=settings.request_timeout_seconds) as client: response = await client.get(discovery_url, headers={"Accept": "application/json"}) except httpx.RequestError as exc: logger.error("gitea_oidc_discovery_request_failed") raise RuntimeError( "Startup validation failed: unable to reach Gitea OIDC discovery endpoint." ) from exc if response.status_code != 200: logger.error( "gitea_oidc_discovery_non_200", extra={"status_code": response.status_code} ) raise RuntimeError( "Startup validation failed: Gitea OIDC discovery endpoint returned non-200." ) logger.info("gitea_oidc_discovery_ready", extra={"issuer": settings.gitea_base_url}) @app.on_event("shutdown") async def shutdown_event() -> None: """Log server shutdown event.""" logger.info("server_stopping") @app.get("/") async def root() -> dict[str, Any]: """Root endpoint with server metadata.""" return { "name": "AegisGitea MCP Server", "version": "0.2.0", "status": "running", "mcp_version": "1.0", } @app.get("/health") async def health() -> dict[str, str]: """Health check endpoint.""" return {"status": "healthy"} @app.get("/.well-known/oauth-protected-resource") async def oauth_protected_resource_metadata(request: Request) -> JSONResponse: """OAuth 2.0 Protected Resource Metadata (RFC 9728). Required by the MCP Authorization spec so that OAuth clients (e.g. ChatGPT) can discover the authorization server that protects this resource. ChatGPT fetches this endpoint when it first connects to the MCP server via SSE. """ settings = get_settings() gitea_base = settings.gitea_base_url base_url = settings.public_base or str(request.base_url).rstrip("/") authorization_servers = [base_url] if gitea_base not in authorization_servers: authorization_servers.append(gitea_base) return JSONResponse( content={ "resource": gitea_base, "authorization_servers": authorization_servers, "bearer_methods_supported": ["header"], "scopes_supported": [READ_SCOPE, WRITE_SCOPE], "resource_documentation": str(settings.oauth_resource_documentation), } ) @app.get("/oauth/authorize") async def oauth_authorize_proxy(request: Request) -> RedirectResponse: """Proxy OAuth authorization to Gitea, replacing redirect_uri with our own callback. Clients (ChatGPT, Claude, etc.) send their own redirect_uri which Gitea doesn't know about. This endpoint intercepts the request, encodes the original redirect_uri and state into a new state parameter, and forwards the request to Gitea using the MCP server's own callback URI — the only URI that needs to be registered in Gitea. """ settings = get_settings() base_url = settings.public_base or str(request.base_url).rstrip("/") params = dict(request.query_params) client_redirect_uri = params.pop("redirect_uri", "") original_state = params.get("state", "") # Encode the client's redirect_uri + original state into a tamper-evident wrapper. # We simply base64-encode a JSON blob; Gitea will echo it back on the callback. proxy_state_data = {"redirect_uri": client_redirect_uri, "state": original_state} proxy_state = base64.urlsafe_b64encode(json.dumps(proxy_state_data).encode()).decode() params["state"] = proxy_state params["redirect_uri"] = f"{base_url}/oauth/callback" gitea_authorize_url = f"{settings.gitea_base_url}/login/oauth/authorize" redirect_url = f"{gitea_authorize_url}?{urllib.parse.urlencode(params)}" return RedirectResponse(url=redirect_url, status_code=302) @app.get("/oauth/callback") async def oauth_callback_proxy(request: Request) -> RedirectResponse: """Handle Gitea's OAuth callback and redirect to the original client redirect_uri.""" proxy_state = request.query_params.get("state", "") code = request.query_params.get("code", "") error = request.query_params.get("error", "") error_description = request.query_params.get("error_description", "") try: state_data = json.loads(base64.urlsafe_b64decode(proxy_state.encode())) client_redirect_uri = state_data["redirect_uri"] original_state = state_data["state"] except Exception as exc: raise HTTPException(status_code=400, detail="Invalid or missing state parameter") from exc if not client_redirect_uri: raise HTTPException(status_code=400, detail="No client redirect_uri in state") result_params: dict[str, str] = {} if error: result_params["error"] = error if error_description: result_params["error_description"] = error_description else: result_params["code"] = code if original_state: result_params["state"] = original_state redirect_url = f"{client_redirect_uri}?{urllib.parse.urlencode(result_params)}" return RedirectResponse(url=redirect_url, status_code=302) @app.get("/.well-known/oauth-authorization-server") async def oauth_authorization_server_metadata(request: Request) -> JSONResponse: """OAuth 2.0 Authorization Server Metadata (RFC 8414). Proxies Gitea's OAuth authorization server metadata so that ChatGPT can discover the authorize URL, token URL, and supported features directly from this server without needing to know the Gitea URL upfront. """ settings = get_settings() base_url = settings.public_base or str(request.base_url).rstrip("/") gitea_base = settings.gitea_base_url return JSONResponse( content={ "issuer": gitea_base, "authorization_endpoint": f"{base_url}/oauth/authorize", "token_endpoint": f"{base_url}/oauth/token", "response_types_supported": ["code"], "grant_types_supported": ["authorization_code"], "code_challenge_methods_supported": ["S256"], "scopes_supported": [READ_SCOPE, WRITE_SCOPE], "token_endpoint_auth_methods_supported": ["client_secret_post", "none"], } ) @app.get("/.well-known/openid-configuration") async def openid_configuration(request: Request) -> JSONResponse: """OpenID Provider metadata compatible with OAuth proxy token exchange.""" settings = get_settings() base_url = settings.public_base or str(request.base_url).rstrip("/") gitea_base = settings.gitea_base_url return JSONResponse( content={ "issuer": gitea_base, "authorization_endpoint": f"{base_url}/oauth/authorize", "token_endpoint": f"{base_url}/oauth/token", "userinfo_endpoint": f"{gitea_base}/login/oauth/userinfo", "jwks_uri": f"{gitea_base}/login/oauth/keys", "response_types_supported": ["code"], "grant_types_supported": ["authorization_code"], "code_challenge_methods_supported": ["S256"], "token_endpoint_auth_methods_supported": ["client_secret_post", "none"], "scopes_supported": [ READ_SCOPE, WRITE_SCOPE, "openid", "profile", "email", "groups", ], "subject_types_supported": ["public"], "id_token_signing_alg_values_supported": ["RS256"], } ) @app.post("/oauth/token") async def oauth_token_proxy(request: Request) -> JSONResponse: """Proxy OAuth2 token exchange to Gitea. ChatGPT sends the authorization code here after the user logs in to Gitea. This endpoint forwards the code to Gitea's token endpoint and returns the access_token to ChatGPT, completing the OAuth2 Authorization Code flow. """ settings = get_settings() try: form_data = await request.form() except Exception as exc: raise HTTPException(status_code=400, detail="Invalid request body") from exc grant_type = form_data.get("grant_type", "authorization_code") code = form_data.get("code") refresh_token = form_data.get("refresh_token") code_verifier = form_data.get("code_verifier", "") # ChatGPT sends the client_id and client_secret (that were configured in the GPT Action # settings) in the POST body. Use those directly; fall back to env vars if not provided. client_id = form_data.get("client_id") or settings.gitea_oauth_client_id client_secret = form_data.get("client_secret") or settings.gitea_oauth_client_secret # Gitea validates that redirect_uri in the token exchange matches the one used during # authorization. Because our /oauth/authorize proxy always forwards our own callback # URI to Gitea, we must use the same URI here — not the client's original redirect_uri. base_url = settings.public_base or str(request.base_url).rstrip("/") gitea_token_url = f"{settings.gitea_base_url}/login/oauth/access_token" if grant_type == "refresh_token": if not refresh_token: raise HTTPException(status_code=400, detail="Missing refresh_token") payload: dict[str, str] = { "client_id": client_id, "client_secret": client_secret, "grant_type": "refresh_token", "refresh_token": refresh_token, } else: if not code: raise HTTPException(status_code=400, detail="Missing authorization code") payload = { "client_id": client_id, "client_secret": client_secret, "code": code, "grant_type": "authorization_code", "redirect_uri": f"{base_url}/oauth/callback", } if code_verifier: payload["code_verifier"] = code_verifier try: async with httpx.AsyncClient(timeout=30) as client: response = await client.post( gitea_token_url, data=payload, headers={"Accept": "application/json"}, ) except httpx.RequestError as exc: logger.error("oauth_token_proxy_error", extra={"error": str(exc)}) raise HTTPException(status_code=502, detail="Failed to reach Gitea token endpoint") from exc if response.status_code != 200: logger.error( "oauth_token_exchange_failed", extra={"status": response.status_code, "body": response.text[:500]}, ) raise HTTPException( status_code=response.status_code, detail="Token exchange failed with Gitea", ) token_data = response.json() logger.info( "oauth_token_exchange_ok", extra={ "token_type": token_data.get("token_type"), "scope": token_data.get("scope", ""), "expires_in": token_data.get("expires_in"), }, ) return JSONResponse(content=token_data) @app.get("/metrics") async def metrics() -> PlainTextResponse: """Prometheus-compatible metrics endpoint.""" settings = get_settings() if not settings.metrics_enabled: raise HTTPException(status_code=404, detail="Metrics endpoint disabled") data = get_metrics_registry().render_prometheus() return PlainTextResponse(content=data, media_type="text/plain; version=0.0.4") @app.post("/automation/webhook") async def automation_webhook(request: AutomationWebhookRequest) -> JSONResponse: """Ingest policy-controlled automation webhooks.""" manager = AutomationManager() try: result = await manager.handle_webhook( event_type=request.event_type, payload=request.payload, repository=request.repository, ) return JSONResponse(content={"success": True, "result": result}) except AutomationError as exc: raise HTTPException(status_code=403, detail=str(exc)) from exc @app.post("/automation/jobs/run") async def automation_run_job(request: AutomationJobRequest) -> JSONResponse: """Execute a policy-controlled automation job for a repository.""" manager = AutomationManager() try: result = await manager.run_job( job_name=request.job_name, owner=request.owner, repo=request.repo, user_token=get_gitea_user_token(), finding_title=request.finding_title, finding_body=request.finding_body, ) return JSONResponse(content={"success": True, "result": result}) except AutomationError as exc: raise HTTPException(status_code=403, detail=str(exc)) from exc @app.get("/mcp/tools") async def list_tools() -> JSONResponse: """List all available MCP tools.""" response = MCPListToolsResponse(tools=AVAILABLE_TOOLS) return JSONResponse(content=response.model_dump(by_alias=True)) async def _execute_tool_call( tool_name: str, arguments: dict[str, Any], correlation_id: str ) -> dict[str, Any]: """Execute tool call with policy checks and standardized response sanitization.""" settings = get_settings() audit = get_audit_logger() metrics = get_metrics_registry() tool_def = get_tool_by_name(tool_name) if not tool_def: raise HTTPException(status_code=404, detail=f"Tool '{tool_name}' not found") required_scope = WRITE_SCOPE if tool_def.write_operation else READ_SCOPE granted_scopes = set(get_gitea_user_scopes()) if not _has_required_scope(required_scope, granted_scopes): audit.log_access_denied( tool_name=tool_name, reason=f"insufficient_scope:{required_scope}", correlation_id=correlation_id, ) raise HTTPException( status_code=403, detail=f"Insufficient scope. Required scope: {required_scope}", ) handler = TOOL_HANDLERS.get(tool_name) if not handler: raise HTTPException( status_code=500, detail=f"Tool '{tool_name}' has no handler implementation" ) repository = extract_repository(arguments) target_path = extract_target_path(arguments) decision = get_policy_engine().authorize( tool_name=tool_name, is_write=tool_def.write_operation, repository=repository, target_path=target_path, ) if not decision.allowed: audit.log_access_denied( tool_name=tool_name, repository=repository, reason=decision.reason, correlation_id=correlation_id, ) raise HTTPException(status_code=403, detail=f"Policy denied request: {decision.reason}") started_at = monotonic_seconds() status = "error" try: user_token = get_gitea_user_token() if not user_token: raise HTTPException(status_code=401, detail="Missing authenticated user token context") # In OAuth mode, Gitea OIDC access_tokens can't call the Gitea REST API # (they only carry OIDC scopes). If a service PAT is configured via # GITEA_TOKEN, use that for API calls while OIDC handles identity/authz. api_token = settings.gitea_token.strip() if settings.gitea_token.strip() else user_token async with GiteaClient(token=api_token) as gitea: result = await handler(gitea, arguments) if settings.secret_detection_mode != "off": # Security decision: sanitize outbound payloads to prevent accidental secret exfiltration. result = sanitize_data(result, mode=settings.secret_detection_mode) status = "success" return result finally: duration = max(monotonic_seconds() - started_at, 0.0) metrics.record_tool_call(tool_name, status, duration) @app.post("/mcp/tool/call") async def call_tool(request: MCPToolCallRequest) -> JSONResponse: """Execute an MCP tool call.""" settings = get_settings() audit = get_audit_logger() correlation_id = request.correlation_id or audit.log_tool_invocation( tool_name=request.tool, params=request.arguments, ) try: result = await _execute_tool_call(request.tool, request.arguments, correlation_id) audit.log_tool_invocation( tool_name=request.tool, correlation_id=correlation_id, result_status="success", ) return JSONResponse( content=MCPToolCallResponse( success=True, result=result, correlation_id=correlation_id, ).model_dump() ) except HTTPException as exc: audit.log_tool_invocation( tool_name=request.tool, correlation_id=correlation_id, result_status="error", error=str(exc.detail), ) raise except ValidationError as exc: error_message = "Invalid tool arguments" if settings.expose_error_details: error_message = f"{error_message}: {exc}" audit.log_tool_invocation( tool_name=request.tool, correlation_id=correlation_id, result_status="error", error="validation_error", ) raise HTTPException(status_code=400, detail=error_message) from exc except GiteaAuthorizationError as exc: audit.log_tool_invocation( tool_name=request.tool, correlation_id=correlation_id, result_status="error", error="gitea_authorization_error", ) logger.warning("gitea_authorization_error: %s", exc) return JSONResponse( status_code=403, content=MCPToolCallResponse( success=False, error=_REAUTH_GUIDANCE, correlation_id=correlation_id, ).model_dump(), ) except GiteaAuthenticationError as exc: audit.log_tool_invocation( tool_name=request.tool, correlation_id=correlation_id, result_status="error", error="gitea_authentication_error", ) logger.warning("gitea_authentication_error: %s", exc) return JSONResponse( status_code=401, content=MCPToolCallResponse( success=False, error="Gitea rejected the token. Please re-authenticate.", correlation_id=correlation_id, ).model_dump(), ) except Exception: # Security decision: do not leak stack traces or raw exception messages. error_message = "Internal server error" if settings.expose_error_details: error_message = "Internal server error (details hidden unless explicitly enabled)" audit.log_tool_invocation( tool_name=request.tool, correlation_id=correlation_id, result_status="error", error="internal_error", ) logger.exception("tool_execution_failed") return JSONResponse( status_code=500, content=MCPToolCallResponse( success=False, error=error_message, correlation_id=correlation_id, ).model_dump(), ) @app.get("/mcp/sse") async def sse_endpoint(request: Request) -> StreamingResponse: """Server-Sent Events endpoint for MCP transport.""" async def event_stream() -> AsyncGenerator[str, None]: yield ( "data: " + json.dumps( {"event": "connected", "server": "AegisGitea MCP", "version": "0.2.0"}, separators=(",", ":"), ) + "\n\n" ) try: while True: if await request.is_disconnected(): break yield 'data: {"event":"heartbeat"}\n\n' await asyncio.sleep(30) except Exception: logger.exception("sse_stream_error") return StreamingResponse( event_stream(), media_type="text/event-stream", headers={ "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no", }, ) @app.post("/mcp/sse") async def sse_message_handler(request: Request) -> JSONResponse: """Handle POST messages for MCP SSE transport.""" settings = get_settings() audit = get_audit_logger() try: body = await request.json() message_type = body.get("type") or body.get("method") message_id = body.get("id") if message_type == "initialize": return JSONResponse( content={ "jsonrpc": "2.0", "id": message_id, "result": { "protocolVersion": "2024-11-05", "capabilities": {"tools": {}}, "serverInfo": {"name": "AegisGitea MCP", "version": "0.2.0"}, }, } ) if message_type == "tools/list": response = MCPListToolsResponse(tools=AVAILABLE_TOOLS) return JSONResponse( content={ "jsonrpc": "2.0", "id": message_id, "result": response.model_dump(by_alias=True), } ) if message_type == "tools/call": tool_name = body.get("params", {}).get("name") tool_args = body.get("params", {}).get("arguments", {}) correlation_id = audit.log_tool_invocation(tool_name=tool_name, params=tool_args) try: result = await _execute_tool_call(str(tool_name), tool_args, correlation_id) audit.log_tool_invocation( tool_name=str(tool_name), correlation_id=correlation_id, result_status="success", ) return JSONResponse( content={ "jsonrpc": "2.0", "id": message_id, "result": {"content": [{"type": "text", "text": json.dumps(result)}]}, } ) except HTTPException as exc: audit.log_tool_invocation( tool_name=str(tool_name), correlation_id=correlation_id, result_status="error", error=str(exc.detail), ) return JSONResponse( content={ "jsonrpc": "2.0", "id": message_id, "error": {"code": -32000, "message": str(exc.detail)}, } ) except GiteaAuthorizationError as exc: audit.log_tool_invocation( tool_name=str(tool_name), correlation_id=correlation_id, result_status="error", error="gitea_authorization_error", ) logger.warning("gitea_authorization_error: %s", exc) return JSONResponse( content={ "jsonrpc": "2.0", "id": message_id, "error": {"code": -32000, "message": _REAUTH_GUIDANCE}, } ) except GiteaAuthenticationError as exc: audit.log_tool_invocation( tool_name=str(tool_name), correlation_id=correlation_id, result_status="error", error="gitea_authentication_error", ) logger.warning("gitea_authentication_error: %s", exc) return JSONResponse( content={ "jsonrpc": "2.0", "id": message_id, "error": { "code": -32000, "message": "Gitea rejected the token. Please re-authenticate.", }, } ) except Exception as exc: audit.log_tool_invocation( tool_name=str(tool_name), correlation_id=correlation_id, result_status="error", error=str(exc), ) message = "Internal server error" if settings.expose_error_details: message = str(exc) return JSONResponse( content={ "jsonrpc": "2.0", "id": message_id, "error": {"code": -32603, "message": message}, } ) if isinstance(message_type, str) and message_type.startswith("notifications/"): return JSONResponse(content={}) return JSONResponse( content={"jsonrpc": "2.0", "id": message_id, "result": {"acknowledged": True}} ) except Exception: logger.exception("sse_message_handler_error") message = "Invalid message format" if settings.expose_error_details: message = "Invalid message format (details hidden unless explicitly enabled)" return JSONResponse(status_code=400, content={"error": message}) 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()