Files
AegisGitea-MCP/src/aegis_gitea_mcp/server.py
latte 71c993e4cd
Some checks failed
docker / lint (push) Failing after 21s
docker / test (push) Failing after 17s
lint / lint (push) Failing after 22s
test / test (push) Failing after 17s
docker / docker-test (push) Has been skipped
docker / docker-publish (push) Has been skipped
Use GITEA_TOKEN as service PAT for API calls in OAuth mode
Gitea OIDC access_tokens only carry OIDC scopes and cannot call the
Gitea REST API. Fall back to GITEA_TOKEN (service PAT) for actual tool
execution when configured, while OIDC still handles user identity.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 17:06:28 +00:00

1141 lines
42 KiB
Python

"""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 <token>.",
scope=READ_SCOPE,
)
return JSONResponse(
status_code=401,
content={
"error": "Authentication failed",
"message": "Provide Authorization: Bearer <token>.",
"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", "<not returned>"),
"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()