Some checks failed
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>
1141 lines
42 KiB
Python
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()
|