refactor!: remove legacy adapters, policy engine and API-key auth
Removes the FastAPI server, hand-rolled MCP protocol, tool registry and tools, YAML policy, resource-type authz, API-key and OAuth proxy modules, automation, observability, cache, the old Gitea client and stdio adapter, the key-management scripts and their tests (ADR 0006).
This commit is contained in:
1 parent
c0b22c531e
commit
4ac9626eb5
41 files changed
-13441
No files matched your search
@@ -1,206 +0,0 @@
|
||||
"""Authentication module for MCP server API key validation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from aegis_gitea_mcp.audit import get_audit_logger
|
||||
from aegis_gitea_mcp.config import get_settings
|
||||
|
||||
|
||||
class AuthenticationError(Exception):
|
||||
"""Raised when authentication fails."""
|
||||
|
||||
|
||||
class APIKeyValidator:
|
||||
"""Validate API keys for MCP server access."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize API key validator state."""
|
||||
self.settings = get_settings()
|
||||
self.audit = get_audit_logger()
|
||||
self._failed_attempts: dict[str, list[datetime]] = {}
|
||||
|
||||
def _constant_time_compare(self, candidate: str, expected: str) -> bool:
|
||||
"""Compare API keys in constant time to mitigate timing attacks."""
|
||||
return hmac.compare_digest(candidate, expected)
|
||||
|
||||
def _check_rate_limit(self, identifier: str) -> bool:
|
||||
"""Check whether authentication failures exceed configured threshold."""
|
||||
now = datetime.now(timezone.utc)
|
||||
boundary = now.timestamp() - self.settings.auth_failure_window
|
||||
|
||||
if identifier in self._failed_attempts:
|
||||
self._failed_attempts[identifier] = [
|
||||
attempt
|
||||
for attempt in self._failed_attempts[identifier]
|
||||
if attempt.timestamp() > boundary
|
||||
]
|
||||
|
||||
return len(self._failed_attempts.get(identifier, [])) < self.settings.max_auth_failures
|
||||
|
||||
def _record_failed_attempt(self, identifier: str) -> None:
|
||||
"""Record a failed authentication attempt for rate limiting."""
|
||||
attempt_time = datetime.now(timezone.utc)
|
||||
self._failed_attempts.setdefault(identifier, []).append(attempt_time)
|
||||
|
||||
if len(self._failed_attempts[identifier]) >= self.settings.max_auth_failures:
|
||||
self.audit.log_security_event(
|
||||
event_type="auth_rate_limit_exceeded",
|
||||
description="Authentication failure threshold exceeded",
|
||||
severity="high",
|
||||
metadata={
|
||||
"identifier": identifier,
|
||||
"failure_count": len(self._failed_attempts[identifier]),
|
||||
"window_seconds": self.settings.auth_failure_window,
|
||||
},
|
||||
)
|
||||
|
||||
def validate_api_key(
|
||||
self,
|
||||
provided_key: str | None,
|
||||
client_ip: str,
|
||||
user_agent: str,
|
||||
) -> tuple[bool, str | None]:
|
||||
"""Validate an API key.
|
||||
|
||||
Args:
|
||||
provided_key: API key provided by client.
|
||||
client_ip: Request source IP address.
|
||||
user_agent: Request user agent.
|
||||
|
||||
Returns:
|
||||
Tuple of `(is_valid, error_message)`.
|
||||
"""
|
||||
if not self.settings.auth_enabled:
|
||||
# Security note: auth-disabled mode is explicit and should be monitored.
|
||||
self.audit.log_security_event(
|
||||
event_type="auth_disabled",
|
||||
description="Authentication disabled; request was allowed",
|
||||
severity="critical",
|
||||
metadata={"client_ip": client_ip},
|
||||
)
|
||||
return True, None
|
||||
|
||||
if not self._check_rate_limit(client_ip):
|
||||
self.audit.log_access_denied(
|
||||
tool_name="api_authentication",
|
||||
reason="rate_limit_exceeded",
|
||||
)
|
||||
return False, "Too many failed authentication attempts. Please try again later."
|
||||
|
||||
if not provided_key:
|
||||
self._record_failed_attempt(client_ip)
|
||||
self.audit.log_access_denied(
|
||||
tool_name="api_authentication",
|
||||
reason="missing_api_key",
|
||||
)
|
||||
return False, "Authorization header missing. Required: Authorization: Bearer <api-key>"
|
||||
|
||||
if len(provided_key) < 32:
|
||||
# Validation logic: reject short keys early to reduce brute force surface.
|
||||
self._record_failed_attempt(client_ip)
|
||||
self.audit.log_access_denied(
|
||||
tool_name="api_authentication",
|
||||
reason="invalid_key_format",
|
||||
)
|
||||
return False, "Invalid API key format"
|
||||
|
||||
valid_keys = self.settings.mcp_api_keys
|
||||
if not valid_keys:
|
||||
self.audit.log_security_event(
|
||||
event_type="no_api_keys_configured",
|
||||
description="No API keys configured while auth is enabled",
|
||||
severity="critical",
|
||||
metadata={"client_ip": client_ip},
|
||||
)
|
||||
return False, "Server configuration error: No API keys configured"
|
||||
|
||||
is_valid = any(
|
||||
self._constant_time_compare(provided_key, valid_key) for valid_key in valid_keys
|
||||
)
|
||||
|
||||
if is_valid:
|
||||
key_fingerprint = hashlib.sha256(provided_key.encode("utf-8")).hexdigest()[:12]
|
||||
self.audit.log_tool_invocation(
|
||||
tool_name="api_authentication",
|
||||
result_status="success",
|
||||
params={
|
||||
"client_ip": client_ip,
|
||||
"user_agent": user_agent,
|
||||
"key_fingerprint": key_fingerprint,
|
||||
},
|
||||
)
|
||||
return True, None
|
||||
|
||||
self._record_failed_attempt(client_ip)
|
||||
self.audit.log_access_denied(
|
||||
tool_name="api_authentication",
|
||||
reason="invalid_api_key",
|
||||
)
|
||||
self.audit.log_security_event(
|
||||
event_type="invalid_api_key_attempt",
|
||||
description="Invalid API key was presented",
|
||||
severity="medium",
|
||||
metadata={"client_ip": client_ip, "user_agent": user_agent},
|
||||
)
|
||||
return False, "Invalid API key"
|
||||
|
||||
def extract_bearer_token(self, authorization_header: str | None) -> str | None:
|
||||
"""Extract API token from `Authorization: Bearer <token>` header.
|
||||
|
||||
Security note:
|
||||
The scheme is case-sensitive by policy (`Bearer`) to prevent accepting
|
||||
ambiguous client implementations and to align strict API contracts.
|
||||
"""
|
||||
if not authorization_header:
|
||||
return None
|
||||
|
||||
parts = authorization_header.split(" ")
|
||||
if len(parts) != 2:
|
||||
return None
|
||||
|
||||
scheme, token = parts
|
||||
if scheme != "Bearer":
|
||||
return None
|
||||
if not token.strip():
|
||||
return None
|
||||
|
||||
return token.strip()
|
||||
|
||||
|
||||
def generate_api_key(length: int = 64) -> str:
|
||||
"""Generate a cryptographically secure API key.
|
||||
|
||||
Args:
|
||||
length: Length of key in characters.
|
||||
|
||||
Returns:
|
||||
Generated API key string.
|
||||
"""
|
||||
return secrets.token_hex(length // 2)
|
||||
|
||||
|
||||
def hash_api_key(api_key: str) -> str:
|
||||
"""Hash an API key for secure storage and comparison."""
|
||||
return hashlib.sha256(api_key.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
_validator: APIKeyValidator | None = None
|
||||
|
||||
|
||||
def get_validator() -> APIKeyValidator:
|
||||
"""Get or create global API key validator instance."""
|
||||
global _validator
|
||||
if _validator is None:
|
||||
_validator = APIKeyValidator()
|
||||
return _validator
|
||||
|
||||
|
||||
def reset_validator() -> None:
|
||||
"""Reset global API key validator instance (primarily for testing)."""
|
||||
global _validator
|
||||
_validator = None
|
||||
@@ -1,342 +0,0 @@
|
||||
"""Resource-type-aware authorization (fail-closed).
|
||||
|
||||
The public HTTP server runs in *service-PAT mode*: a privileged bot token makes
|
||||
the actual Gitea calls while a per-user OAuth identity decides what that user is
|
||||
allowed to reach. For repository-scoped calls the server verifies the user's
|
||||
collaborator permission on ``owner/repo``. This module closes the rest of the
|
||||
gap — the admin/user/org/misc surface that ``gitea_request`` can now reach — by
|
||||
classifying each call by *resource type* and enforcing a type-specific rule.
|
||||
|
||||
Every decision fails closed: if a call cannot be classified, or a required
|
||||
permission cannot be positively verified against Gitea, it is denied and audited.
|
||||
|
||||
Rules (enforced only in service-PAT mode; in pure-OAuth mode the user's own
|
||||
token already scopes every call at Gitea):
|
||||
|
||||
* ``repository`` — per-user collaborator permission (handled by the server's
|
||||
existing repository check; not re-implemented here).
|
||||
* ``org`` — the signed-in user must be a verified member of the target org.
|
||||
* ``user_self`` — token-owner-scoped endpoints (``/user``, ``/notifications``).
|
||||
Denied in service-PAT mode: the data belongs to the bot, not the caller.
|
||||
* ``user_owned`` — a resource owned by a named user/org (``/users/{name}``,
|
||||
``/packages/{owner}``). Allowed only when the owner is the caller, or the
|
||||
caller is a verified member of the owning org.
|
||||
* ``misc_global`` — instance-wide, read-only utility endpoints (markdown render,
|
||||
version, gitignore templates …). Reads allowed; writes fall to policy.
|
||||
* ``admin`` — default deny. Allowed only when the operator has opted in
|
||||
(``RAW_API_ALLOW_SENSITIVE``) *and* the signed-in user is a verified Gitea
|
||||
site administrator.
|
||||
* ``unknown`` — denied.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import urllib.parse
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
|
||||
import httpx
|
||||
|
||||
from aegis_gitea_mcp.audit import get_audit_logger
|
||||
from aegis_gitea_mcp.cache import BoundedTTLCache
|
||||
from aegis_gitea_mcp.config import get_settings
|
||||
from aegis_gitea_mcp.errors import ToolError
|
||||
from aegis_gitea_mcp.tools.arguments import (
|
||||
normalize_raw_endpoint,
|
||||
parse_raw_repository,
|
||||
raw_relative_segments,
|
||||
raw_request_is_write,
|
||||
)
|
||||
|
||||
|
||||
class ResourceType(str, Enum):
|
||||
"""Coarse resource classes used for authorization decisions."""
|
||||
|
||||
REPOSITORY = "repository"
|
||||
ORG = "org"
|
||||
USER_SELF = "user_self"
|
||||
USER_OWNED = "user_owned"
|
||||
MISC_GLOBAL = "misc_global"
|
||||
ADMIN = "admin"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResourceClass:
|
||||
"""Result of classifying a call by resource type."""
|
||||
|
||||
resource_type: ResourceType
|
||||
is_write: bool
|
||||
repository: str | None = None
|
||||
org: str | None = None
|
||||
owner: str | None = None
|
||||
|
||||
|
||||
# Instance-wide, read-only utility prefixes: not owned by any user/org.
|
||||
_MISC_GLOBAL_PREFIXES = frozenset(
|
||||
{
|
||||
"markdown",
|
||||
"markup",
|
||||
"version",
|
||||
"gitignore",
|
||||
"licenses",
|
||||
"label",
|
||||
"topics",
|
||||
"nodeinfo",
|
||||
"activitypub",
|
||||
"miscellaneous",
|
||||
"signing-key.gpg",
|
||||
"settings",
|
||||
}
|
||||
)
|
||||
|
||||
# Token-owner-scoped prefixes ("me"/"my" endpoints).
|
||||
_USER_SELF_PREFIXES = frozenset({"user", "notifications"})
|
||||
|
||||
|
||||
def classify_raw_endpoint(method: str, endpoint: str) -> ResourceClass:
|
||||
"""Classify a normalized raw ``/api/v1`` endpoint by resource type.
|
||||
|
||||
Args:
|
||||
method: HTTP method (used only to set the read/write flag).
|
||||
endpoint: A normalized ``/api/v1/...`` path.
|
||||
|
||||
Returns:
|
||||
The resource classification; ``UNKNOWN`` when nothing matches (deny).
|
||||
"""
|
||||
is_write = raw_request_is_write(method, endpoint)
|
||||
rel = raw_relative_segments(endpoint)
|
||||
if not rel:
|
||||
return ResourceClass(ResourceType.MISC_GLOBAL, is_write)
|
||||
|
||||
top = rel[0]
|
||||
|
||||
if top == "admin":
|
||||
return ResourceClass(ResourceType.ADMIN, is_write)
|
||||
|
||||
if top in {"repos", "repositories"}:
|
||||
repository = parse_raw_repository(endpoint)
|
||||
# repository is None for cross-repo endpoints (search/issues) — those
|
||||
# cannot be scoped to a single owner/repo and so fail closed downstream.
|
||||
return ResourceClass(ResourceType.REPOSITORY, is_write, repository=repository)
|
||||
|
||||
if top in {"orgs", "org"}:
|
||||
org = rel[1] if len(rel) >= 2 else None
|
||||
return ResourceClass(ResourceType.ORG, is_write, org=org)
|
||||
|
||||
if top == "users":
|
||||
owner = rel[1] if len(rel) >= 2 else None
|
||||
return ResourceClass(ResourceType.USER_OWNED, is_write, owner=owner)
|
||||
|
||||
if top == "packages":
|
||||
owner = rel[1] if len(rel) >= 2 else None
|
||||
return ResourceClass(ResourceType.USER_OWNED, is_write, owner=owner)
|
||||
|
||||
if top in _USER_SELF_PREFIXES:
|
||||
return ResourceClass(ResourceType.USER_SELF, is_write)
|
||||
|
||||
if top in _MISC_GLOBAL_PREFIXES:
|
||||
return ResourceClass(ResourceType.MISC_GLOBAL, is_write)
|
||||
|
||||
return ResourceClass(ResourceType.UNKNOWN, is_write)
|
||||
|
||||
|
||||
def classify_tool(tool_name: str, arguments: dict[str, object]) -> ResourceClass:
|
||||
"""Classify a dispatched tool call (typed tool or ``gitea_request``).
|
||||
|
||||
Repository-scoped typed tools are handled by the server's repository check,
|
||||
so this primarily classifies the non-repo surface that this module gates.
|
||||
"""
|
||||
if tool_name == "gitea_request":
|
||||
method = str(arguments.get("method", "GET"))
|
||||
path = str(arguments.get("path", ""))
|
||||
try:
|
||||
endpoint = normalize_raw_endpoint(path)
|
||||
except ValueError:
|
||||
return ResourceClass(ResourceType.UNKNOWN, is_write=True)
|
||||
return classify_raw_endpoint(method, endpoint)
|
||||
|
||||
if tool_name == "list_org_repositories":
|
||||
org = arguments.get("org")
|
||||
return ResourceClass(
|
||||
ResourceType.ORG, is_write=False, org=org if isinstance(org, str) else None
|
||||
)
|
||||
|
||||
if tool_name == "list_organizations":
|
||||
# Backed by /user/orgs: token-owner-scoped, not attributable to the caller
|
||||
# in service-PAT mode.
|
||||
return ResourceClass(ResourceType.USER_SELF, is_write=False)
|
||||
|
||||
# Any other non-repository tool is unrecognized for the purpose of this gate.
|
||||
return ResourceClass(ResourceType.UNKNOWN, is_write=False)
|
||||
|
||||
|
||||
# Bounded, short-TTL caches for positive verification results (fail-closed:
|
||||
# only successful checks are cached).
|
||||
_org_membership_cache: BoundedTTLCache[str, bool] | None = None
|
||||
_site_admin_cache: BoundedTTLCache[str, bool] | None = None
|
||||
|
||||
|
||||
def _get_org_membership_cache() -> BoundedTTLCache[str, bool]:
|
||||
global _org_membership_cache
|
||||
if _org_membership_cache is None:
|
||||
ttl = get_settings().repo_authz_cache_ttl_seconds
|
||||
_org_membership_cache = BoundedTTLCache(ttl_seconds=ttl, max_size=2048)
|
||||
return _org_membership_cache
|
||||
|
||||
|
||||
def _get_site_admin_cache() -> BoundedTTLCache[str, bool]:
|
||||
global _site_admin_cache
|
||||
if _site_admin_cache is None:
|
||||
ttl = get_settings().repo_authz_cache_ttl_seconds
|
||||
_site_admin_cache = BoundedTTLCache(ttl_seconds=ttl, max_size=2048)
|
||||
return _site_admin_cache
|
||||
|
||||
|
||||
def reset_authz_caches() -> None:
|
||||
"""Reset authorization caches (primarily for tests)."""
|
||||
global _org_membership_cache, _site_admin_cache
|
||||
_org_membership_cache = None
|
||||
_site_admin_cache = None
|
||||
|
||||
|
||||
async def _service_get(path: str) -> httpx.Response | None:
|
||||
"""GET ``path`` on Gitea with the service PAT; None on transport failure."""
|
||||
settings = get_settings()
|
||||
token = settings.gitea_token.strip()
|
||||
if not token:
|
||||
return None
|
||||
url = f"{settings.gitea_base_url}{path}"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=settings.request_timeout_seconds) as client:
|
||||
return await client.get(
|
||||
url,
|
||||
headers={"Authorization": f"token {token}", "Accept": "application/json"},
|
||||
)
|
||||
except httpx.RequestError:
|
||||
return None
|
||||
|
||||
|
||||
async def verify_org_membership(*, org: str, user_login: str) -> bool:
|
||||
"""Return True only if ``user_login`` is a verified member of ``org``.
|
||||
|
||||
Fails closed: any transport error, non-204 response, or missing identity
|
||||
yields False.
|
||||
"""
|
||||
if not org or not user_login or user_login == "unknown":
|
||||
return False
|
||||
cache_key = f"{org.lower()}:{user_login.lower()}"
|
||||
cache = _get_org_membership_cache()
|
||||
if cache.get(cache_key) is True:
|
||||
return True
|
||||
|
||||
encoded_org = urllib.parse.quote(org, safe="")
|
||||
encoded_user = urllib.parse.quote(user_login, safe="")
|
||||
response = await _service_get(f"/api/v1/orgs/{encoded_org}/members/{encoded_user}")
|
||||
if response is not None and response.status_code == 204:
|
||||
cache.set(cache_key, True)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def verify_site_admin(*, user_login: str) -> bool:
|
||||
"""Return True only if ``user_login`` is a verified Gitea site administrator.
|
||||
|
||||
Requires the service PAT to have admin visibility (so ``is_admin`` is
|
||||
returned). Fails closed on any error or when the flag is not positively True.
|
||||
"""
|
||||
if not user_login or user_login == "unknown":
|
||||
return False
|
||||
cache_key = user_login.lower()
|
||||
cache = _get_site_admin_cache()
|
||||
if cache.get(cache_key) is True:
|
||||
return True
|
||||
|
||||
encoded_user = urllib.parse.quote(user_login, safe="")
|
||||
response = await _service_get(f"/api/v1/users/{encoded_user}")
|
||||
if response is None or response.status_code != 200:
|
||||
return False
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError:
|
||||
return False
|
||||
if isinstance(payload, dict) and payload.get("is_admin") is True:
|
||||
cache.set(cache_key, True)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def authorize_non_repository_access(
|
||||
*,
|
||||
classification: ResourceClass,
|
||||
user_login: str,
|
||||
tool_name: str,
|
||||
correlation_id: str | None = None,
|
||||
) -> None:
|
||||
"""Enforce the resource-type rule for a non-repository call (service-PAT mode).
|
||||
|
||||
Raises:
|
||||
ToolError: with status 403 when the call is denied. The repository type
|
||||
is intentionally not handled here — the server's existing per-user
|
||||
collaborator check owns it.
|
||||
"""
|
||||
audit = get_audit_logger()
|
||||
settings = get_settings()
|
||||
login = (user_login or "").strip()
|
||||
|
||||
def _deny(reason: str) -> ToolError:
|
||||
audit.log_access_denied(
|
||||
tool_name=tool_name,
|
||||
repository=classification.repository,
|
||||
reason=f"resource_authz:{classification.resource_type.value}:{reason}",
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
return ToolError(
|
||||
f"Access denied for {classification.resource_type.value} resource: {reason}",
|
||||
status_code=403,
|
||||
)
|
||||
|
||||
rtype = classification.resource_type
|
||||
|
||||
if rtype == ResourceType.REPOSITORY:
|
||||
# Reached only when a repo-scoped path could not be parsed to owner/repo
|
||||
# (e.g. cross-repo search). Cannot verify per-user permission -> deny.
|
||||
raise _deny("repository could not be determined")
|
||||
|
||||
if rtype == ResourceType.ORG:
|
||||
if not classification.org:
|
||||
raise _deny("organization not specified")
|
||||
if await verify_org_membership(org=classification.org, user_login=login):
|
||||
return
|
||||
raise _deny("user is not a verified member of the organization")
|
||||
|
||||
if rtype == ResourceType.USER_OWNED:
|
||||
owner = (classification.owner or "").strip()
|
||||
if not owner:
|
||||
raise _deny("resource owner not specified")
|
||||
if owner.lower() == login.lower() and login:
|
||||
return
|
||||
# The owner may be an organization the caller belongs to.
|
||||
if await verify_org_membership(org=owner, user_login=login):
|
||||
return
|
||||
raise _deny("resource owner is neither the caller nor a member org")
|
||||
|
||||
if rtype == ResourceType.USER_SELF:
|
||||
# Token-owner-scoped data; in service-PAT mode the token is the bot's, so
|
||||
# the result cannot be attributed to the caller.
|
||||
raise _deny("token-owner-scoped endpoint is not available in service-PAT mode")
|
||||
|
||||
if rtype == ResourceType.MISC_GLOBAL:
|
||||
if not classification.is_write:
|
||||
return
|
||||
# Writes to global utility endpoints are not part of the safe surface.
|
||||
raise _deny("write to a global endpoint is not permitted")
|
||||
|
||||
if rtype == ResourceType.ADMIN:
|
||||
if not settings.raw_api_allow_sensitive:
|
||||
raise _deny("admin surface is disabled (set RAW_API_ALLOW_SENSITIVE=true to opt in)")
|
||||
if await verify_site_admin(user_login=login):
|
||||
return
|
||||
raise _deny("user is not a verified site administrator")
|
||||
|
||||
raise _deny("unclassified resource")
|
||||
@@ -1,229 +0,0 @@
|
||||
"""Automation workflows for webhooks and scheduled jobs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from aegis_gitea_mcp.audit import get_audit_logger
|
||||
from aegis_gitea_mcp.config import get_settings
|
||||
from aegis_gitea_mcp.gitea_client import GiteaClient
|
||||
from aegis_gitea_mcp.policy import get_policy_engine
|
||||
|
||||
|
||||
class AutomationError(RuntimeError):
|
||||
"""Raised when an automation action is denied or invalid."""
|
||||
|
||||
|
||||
def _parse_timestamp(value: str) -> datetime | None:
|
||||
"""Parse ISO8601 timestamp with best-effort normalization."""
|
||||
normalized = value.replace("Z", "+00:00")
|
||||
try:
|
||||
return datetime.fromisoformat(normalized)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
class AutomationManager:
|
||||
"""Policy-controlled automation manager."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize automation manager with runtime services."""
|
||||
self.settings = get_settings()
|
||||
self.audit = get_audit_logger()
|
||||
|
||||
async def handle_webhook(
|
||||
self,
|
||||
event_type: str,
|
||||
payload: dict[str, Any],
|
||||
repository: str | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Handle inbound webhook event.
|
||||
|
||||
Args:
|
||||
event_type: Event type identifier.
|
||||
payload: Event payload body.
|
||||
repository: Optional target repository (`owner/repo`).
|
||||
|
||||
Returns:
|
||||
Result summary for webhook processing.
|
||||
"""
|
||||
if not self.settings.automation_enabled:
|
||||
raise AutomationError("automation is disabled")
|
||||
|
||||
decision = get_policy_engine().authorize(
|
||||
tool_name="automation_webhook_ingest",
|
||||
is_write=False,
|
||||
repository=repository,
|
||||
)
|
||||
if not decision.allowed:
|
||||
raise AutomationError(f"policy denied webhook: {decision.reason}")
|
||||
|
||||
self.audit.log_tool_invocation(
|
||||
tool_name="automation_webhook_ingest",
|
||||
repository=repository,
|
||||
params={"event_type": event_type},
|
||||
result_status="success",
|
||||
)
|
||||
|
||||
# Safe default: treat webhook payload as data only.
|
||||
return {
|
||||
"status": "accepted",
|
||||
"event_type": event_type,
|
||||
"repository": repository,
|
||||
"keys": sorted(payload.keys()),
|
||||
}
|
||||
|
||||
async def run_job(
|
||||
self,
|
||||
job_name: str,
|
||||
owner: str,
|
||||
repo: str,
|
||||
user_token: str | None = None,
|
||||
finding_title: str | None = None,
|
||||
finding_body: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Run a named automation job for a repository.
|
||||
|
||||
Args:
|
||||
job_name: Job identifier.
|
||||
owner: Repository owner.
|
||||
repo: Repository name.
|
||||
|
||||
Returns:
|
||||
Job execution summary.
|
||||
"""
|
||||
if not self.settings.automation_enabled:
|
||||
raise AutomationError("automation is disabled")
|
||||
|
||||
repository = f"{owner}/{repo}"
|
||||
is_write = job_name == "auto_issue_creation"
|
||||
|
||||
decision = get_policy_engine().authorize(
|
||||
tool_name=f"automation_{job_name}",
|
||||
is_write=is_write,
|
||||
repository=repository,
|
||||
)
|
||||
if not decision.allowed:
|
||||
raise AutomationError(f"policy denied automation job: {decision.reason}")
|
||||
|
||||
if job_name == "dependency_hygiene_scan":
|
||||
return await self._dependency_hygiene_scan(owner, repo)
|
||||
if job_name == "stale_issue_detection":
|
||||
return await self._stale_issue_detection(owner, repo, user_token=user_token)
|
||||
if job_name == "auto_issue_creation":
|
||||
return await self._auto_issue_creation(
|
||||
owner,
|
||||
repo,
|
||||
user_token=user_token,
|
||||
finding_title=finding_title,
|
||||
finding_body=finding_body,
|
||||
)
|
||||
|
||||
raise AutomationError(f"unsupported automation job: {job_name}")
|
||||
|
||||
async def _dependency_hygiene_scan(self, owner: str, repo: str) -> dict[str, Any]:
|
||||
"""Run dependency hygiene scan placeholder workflow.
|
||||
|
||||
Security note:
|
||||
This job intentionally performs read-only checks and does not mutate
|
||||
repository state directly.
|
||||
"""
|
||||
repository = f"{owner}/{repo}"
|
||||
self.audit.log_tool_invocation(
|
||||
tool_name="automation_dependency_hygiene_scan",
|
||||
repository=repository,
|
||||
result_status="success",
|
||||
)
|
||||
|
||||
# Placeholder output for policy-controlled automation scaffold.
|
||||
return {
|
||||
"job": "dependency_hygiene_scan",
|
||||
"repository": repository,
|
||||
"status": "completed",
|
||||
"findings": [],
|
||||
}
|
||||
|
||||
async def _stale_issue_detection(
|
||||
self, owner: str, repo: str, user_token: str | None
|
||||
) -> dict[str, Any]:
|
||||
"""Detect stale issues using repository issue metadata."""
|
||||
repository = f"{owner}/{repo}"
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=self.settings.automation_stale_days)
|
||||
if not user_token:
|
||||
raise AutomationError("missing authenticated user token")
|
||||
|
||||
stale_issue_numbers: list[int] = []
|
||||
async with GiteaClient(token=user_token) as gitea:
|
||||
issues = await gitea.list_issues(
|
||||
owner,
|
||||
repo,
|
||||
state="open",
|
||||
page=1,
|
||||
limit=100,
|
||||
labels=None,
|
||||
)
|
||||
|
||||
for issue in issues:
|
||||
updated_at = issue.get("updated_at")
|
||||
if not isinstance(updated_at, str):
|
||||
continue
|
||||
parsed = _parse_timestamp(updated_at)
|
||||
if parsed and parsed < cutoff:
|
||||
number = issue.get("number")
|
||||
if isinstance(number, int):
|
||||
stale_issue_numbers.append(number)
|
||||
|
||||
self.audit.log_tool_invocation(
|
||||
tool_name="automation_stale_issue_detection",
|
||||
repository=repository,
|
||||
params={"stale_count": len(stale_issue_numbers)},
|
||||
result_status="success",
|
||||
)
|
||||
|
||||
return {
|
||||
"job": "stale_issue_detection",
|
||||
"repository": repository,
|
||||
"status": "completed",
|
||||
"stale_issue_numbers": stale_issue_numbers,
|
||||
"stale_count": len(stale_issue_numbers),
|
||||
}
|
||||
|
||||
async def _auto_issue_creation(
|
||||
self,
|
||||
owner: str,
|
||||
repo: str,
|
||||
user_token: str | None,
|
||||
finding_title: str | None,
|
||||
finding_body: str | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create issue from automation finding payload."""
|
||||
repository = f"{owner}/{repo}"
|
||||
title = finding_title or "Automated security finding"
|
||||
body = finding_body or "Automated finding created by Aegis automation workflow."
|
||||
if not user_token:
|
||||
raise AutomationError("missing authenticated user token")
|
||||
|
||||
async with GiteaClient(token=user_token) as gitea:
|
||||
issue = await gitea.create_issue(
|
||||
owner,
|
||||
repo,
|
||||
title=title,
|
||||
body=body,
|
||||
labels=["security", "automation"],
|
||||
assignees=None,
|
||||
)
|
||||
|
||||
issue_number = issue.get("number", 0) if isinstance(issue, dict) else 0
|
||||
self.audit.log_tool_invocation(
|
||||
tool_name="automation_auto_issue_creation",
|
||||
repository=repository,
|
||||
params={"issue_number": issue_number},
|
||||
result_status="success",
|
||||
)
|
||||
return {
|
||||
"job": "auto_issue_creation",
|
||||
"repository": repository,
|
||||
"status": "completed",
|
||||
"issue_number": issue_number,
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
"""Bounded, TTL-based in-memory caches with size eviction.
|
||||
|
||||
Provides a small dependency-free cache used by the auth middleware and the
|
||||
per-user authorization layer. Entries expire after a TTL and the cache is
|
||||
bounded by a maximum size to prevent unbounded memory growth from untrusted
|
||||
key cardinality (e.g. one entry per distinct token or per (user, repo) pair).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
K = TypeVar("K")
|
||||
V = TypeVar("V")
|
||||
|
||||
|
||||
class BoundedTTLCache(Generic[K, V]):
|
||||
"""A size-bounded cache whose entries expire after a fixed TTL.
|
||||
|
||||
Eviction is least-recently-inserted (FIFO) once ``max_size`` is reached.
|
||||
Expired entries are removed lazily on access and proactively when the
|
||||
cache is full, so the cache never exceeds ``max_size`` live entries.
|
||||
"""
|
||||
|
||||
def __init__(self, *, ttl_seconds: float, max_size: int = 1024) -> None:
|
||||
"""Initialize the cache with a TTL and maximum entry count."""
|
||||
if ttl_seconds <= 0:
|
||||
raise ValueError("ttl_seconds must be positive")
|
||||
if max_size <= 0:
|
||||
raise ValueError("max_size must be positive")
|
||||
self._ttl = float(ttl_seconds)
|
||||
self._max_size = int(max_size)
|
||||
self._store: OrderedDict[K, tuple[V, float]] = OrderedDict()
|
||||
|
||||
def get(self, key: K) -> V | None:
|
||||
"""Return the cached value for ``key`` or ``None`` if absent/expired."""
|
||||
entry = self._store.get(key)
|
||||
if entry is None:
|
||||
return None
|
||||
value, expiry = entry
|
||||
if time.monotonic() >= expiry:
|
||||
# Lazily evict expired entry.
|
||||
self._store.pop(key, None)
|
||||
return None
|
||||
return value
|
||||
|
||||
def set(self, key: K, value: V) -> None:
|
||||
"""Store ``value`` under ``key`` with the configured TTL."""
|
||||
now = time.monotonic()
|
||||
# Drop the existing entry so reinsertion refreshes ordering.
|
||||
self._store.pop(key, None)
|
||||
self._store[key] = (value, now + self._ttl)
|
||||
self._evict(now)
|
||||
|
||||
def _evict(self, now: float) -> None:
|
||||
"""Remove expired entries, then enforce the size bound (FIFO)."""
|
||||
expired = [key for key, (_, expiry) in self._store.items() if now >= expiry]
|
||||
for key in expired:
|
||||
self._store.pop(key, None)
|
||||
while len(self._store) > self._max_size:
|
||||
self._store.popitem(last=False)
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Remove all entries (primarily for tests)."""
|
||||
self._store.clear()
|
||||
|
||||
def __len__(self) -> int:
|
||||
"""Return the number of stored (not necessarily live) entries."""
|
||||
return len(self._store)
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,761 +0,0 @@
|
||||
"""MCP protocol models and tool registry."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class MCPTool(BaseModel):
|
||||
"""MCP tool definition."""
|
||||
|
||||
name: str = Field(..., description="Unique tool identifier")
|
||||
description: str = Field(..., description="Human-readable tool description")
|
||||
input_schema: dict[str, Any] = Field(
|
||||
...,
|
||||
alias="inputSchema",
|
||||
serialization_alias="inputSchema",
|
||||
description="JSON schema describing input arguments",
|
||||
)
|
||||
write_operation: bool = Field(default=False, description="Whether tool mutates data")
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
class MCPToolCallRequest(BaseModel):
|
||||
"""Request to invoke an MCP tool."""
|
||||
|
||||
tool: str = Field(..., description="Name of the tool to invoke")
|
||||
arguments: dict[str, Any] = Field(default_factory=dict, description="Tool argument payload")
|
||||
correlation_id: str | None = Field(default=None, description="Request correlation ID")
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class MCPToolCallResponse(BaseModel):
|
||||
"""Response returned from MCP tool invocation."""
|
||||
|
||||
success: bool = Field(..., description="Whether invocation succeeded")
|
||||
result: Any | None = Field(default=None, description="Tool result payload")
|
||||
error: str | None = Field(default=None, description="Error message for failed request")
|
||||
correlation_id: str = Field(..., description="Correlation ID for request tracing")
|
||||
|
||||
|
||||
class MCPListToolsResponse(BaseModel):
|
||||
"""Response listing available tools."""
|
||||
|
||||
tools: list[MCPTool] = Field(..., description="Available tool definitions")
|
||||
|
||||
|
||||
def _tool(
|
||||
name: str, description: str, schema: dict[str, Any], write_operation: bool = False
|
||||
) -> MCPTool:
|
||||
"""Construct tool metadata entry."""
|
||||
return MCPTool(
|
||||
name=name,
|
||||
description=description,
|
||||
inputSchema=schema,
|
||||
write_operation=write_operation,
|
||||
)
|
||||
|
||||
|
||||
AVAILABLE_TOOLS: list[MCPTool] = [
|
||||
_tool(
|
||||
"list_repositories",
|
||||
"List repositories visible to the authenticated Gitea API token.",
|
||||
{"type": "object", "properties": {}, "required": []},
|
||||
),
|
||||
_tool(
|
||||
"get_repository_info",
|
||||
"Get metadata for a repository.",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"owner": {"type": "string"}, "repo": {"type": "string"}},
|
||||
"required": ["owner", "repo"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
),
|
||||
_tool(
|
||||
"get_file_tree",
|
||||
"Get repository tree at a selected ref.",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string"},
|
||||
"repo": {"type": "string"},
|
||||
"ref": {"type": "string", "default": "main"},
|
||||
"recursive": {"type": "boolean", "default": False},
|
||||
},
|
||||
"required": ["owner", "repo"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
),
|
||||
_tool(
|
||||
"get_file_contents",
|
||||
"Read a repository file with size-limited content.",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string"},
|
||||
"repo": {"type": "string"},
|
||||
"filepath": {"type": "string"},
|
||||
"ref": {"type": "string", "default": "main"},
|
||||
},
|
||||
"required": ["owner", "repo", "filepath"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
),
|
||||
_tool(
|
||||
"search_code",
|
||||
"Search code in a repository.",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string"},
|
||||
"repo": {"type": "string"},
|
||||
"query": {"type": "string"},
|
||||
"ref": {"type": "string", "default": "main"},
|
||||
"page": {"type": "integer", "minimum": 1, "default": 1},
|
||||
"limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 25},
|
||||
},
|
||||
"required": ["owner", "repo", "query"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
),
|
||||
_tool(
|
||||
"list_commits",
|
||||
"List commits for a repository ref.",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string"},
|
||||
"repo": {"type": "string"},
|
||||
"ref": {"type": "string", "default": "main"},
|
||||
"page": {"type": "integer", "minimum": 1, "default": 1},
|
||||
"limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 25},
|
||||
},
|
||||
"required": ["owner", "repo"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
),
|
||||
_tool(
|
||||
"get_commit_diff",
|
||||
"Get commit metadata and file diffs.",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string"},
|
||||
"repo": {"type": "string"},
|
||||
"sha": {"type": "string"},
|
||||
},
|
||||
"required": ["owner", "repo", "sha"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
),
|
||||
_tool(
|
||||
"compare_refs",
|
||||
"Compare two repository refs.",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string"},
|
||||
"repo": {"type": "string"},
|
||||
"base": {"type": "string"},
|
||||
"head": {"type": "string"},
|
||||
},
|
||||
"required": ["owner", "repo", "base", "head"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
),
|
||||
_tool(
|
||||
"list_issues",
|
||||
"List repository issues.",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string"},
|
||||
"repo": {"type": "string"},
|
||||
"state": {"type": "string", "enum": ["open", "closed", "all"], "default": "open"},
|
||||
"page": {"type": "integer", "minimum": 1, "default": 1},
|
||||
"limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 25},
|
||||
"labels": {"type": "array", "items": {"type": "string"}, "default": []},
|
||||
},
|
||||
"required": ["owner", "repo"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
),
|
||||
_tool(
|
||||
"get_issue",
|
||||
"Get repository issue details.",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string"},
|
||||
"repo": {"type": "string"},
|
||||
"issue_number": {"type": "integer", "minimum": 1},
|
||||
},
|
||||
"required": ["owner", "repo", "issue_number"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
),
|
||||
_tool(
|
||||
"list_pull_requests",
|
||||
"List repository pull requests.",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string"},
|
||||
"repo": {"type": "string"},
|
||||
"state": {"type": "string", "enum": ["open", "closed", "all"], "default": "open"},
|
||||
"page": {"type": "integer", "minimum": 1, "default": 1},
|
||||
"limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 25},
|
||||
},
|
||||
"required": ["owner", "repo"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
),
|
||||
_tool(
|
||||
"get_pull_request",
|
||||
"Get pull request details.",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string"},
|
||||
"repo": {"type": "string"},
|
||||
"pull_number": {"type": "integer", "minimum": 1},
|
||||
},
|
||||
"required": ["owner", "repo", "pull_number"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
),
|
||||
_tool(
|
||||
"list_labels",
|
||||
"List labels defined on a repository.",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string"},
|
||||
"repo": {"type": "string"},
|
||||
"page": {"type": "integer", "minimum": 1, "default": 1},
|
||||
"limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 50},
|
||||
},
|
||||
"required": ["owner", "repo"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
),
|
||||
_tool(
|
||||
"list_tags",
|
||||
"List repository tags.",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string"},
|
||||
"repo": {"type": "string"},
|
||||
"page": {"type": "integer", "minimum": 1, "default": 1},
|
||||
"limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 50},
|
||||
},
|
||||
"required": ["owner", "repo"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
),
|
||||
_tool(
|
||||
"list_releases",
|
||||
"List repository releases.",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string"},
|
||||
"repo": {"type": "string"},
|
||||
"page": {"type": "integer", "minimum": 1, "default": 1},
|
||||
"limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 25},
|
||||
},
|
||||
"required": ["owner", "repo"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
),
|
||||
_tool(
|
||||
"list_pull_request_files",
|
||||
"List files changed in a pull request.",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string"},
|
||||
"repo": {"type": "string"},
|
||||
"pull_number": {"type": "integer", "minimum": 1},
|
||||
"page": {"type": "integer", "minimum": 1, "default": 1},
|
||||
"limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 50},
|
||||
},
|
||||
"required": ["owner", "repo", "pull_number"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
),
|
||||
_tool(
|
||||
"list_pull_request_commits",
|
||||
"List commits in a pull request.",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string"},
|
||||
"repo": {"type": "string"},
|
||||
"pull_number": {"type": "integer", "minimum": 1},
|
||||
"page": {"type": "integer", "minimum": 1, "default": 1},
|
||||
"limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 50},
|
||||
},
|
||||
"required": ["owner", "repo", "pull_number"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
),
|
||||
_tool(
|
||||
"list_issue_comments",
|
||||
"List comments on an issue or pull request.",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string"},
|
||||
"repo": {"type": "string"},
|
||||
"issue_number": {"type": "integer", "minimum": 1},
|
||||
"page": {"type": "integer", "minimum": 1, "default": 1},
|
||||
"limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 50},
|
||||
},
|
||||
"required": ["owner", "repo", "issue_number"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
),
|
||||
_tool(
|
||||
"list_branches",
|
||||
"List repository branches.",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string"},
|
||||
"repo": {"type": "string"},
|
||||
"page": {"type": "integer", "minimum": 1, "default": 1},
|
||||
"limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 50},
|
||||
},
|
||||
"required": ["owner", "repo"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
),
|
||||
_tool(
|
||||
"get_branch",
|
||||
"Get a single branch.",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string"},
|
||||
"repo": {"type": "string"},
|
||||
"branch": {"type": "string"},
|
||||
},
|
||||
"required": ["owner", "repo", "branch"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
),
|
||||
_tool(
|
||||
"get_release",
|
||||
"Get a release by id.",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string"},
|
||||
"repo": {"type": "string"},
|
||||
"release_id": {"type": "integer", "minimum": 1},
|
||||
},
|
||||
"required": ["owner", "repo", "release_id"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
),
|
||||
_tool(
|
||||
"get_latest_release",
|
||||
"Get the latest published release.",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"owner": {"type": "string"}, "repo": {"type": "string"}},
|
||||
"required": ["owner", "repo"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
),
|
||||
_tool(
|
||||
"list_milestones",
|
||||
"List repository milestones.",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string"},
|
||||
"repo": {"type": "string"},
|
||||
"state": {"type": "string", "enum": ["open", "closed", "all"], "default": "open"},
|
||||
"page": {"type": "integer", "minimum": 1, "default": 1},
|
||||
"limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 50},
|
||||
},
|
||||
"required": ["owner", "repo"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
),
|
||||
_tool(
|
||||
"get_commit_status",
|
||||
"Get the combined commit status for a ref or sha.",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string"},
|
||||
"repo": {"type": "string"},
|
||||
"sha": {"type": "string"},
|
||||
},
|
||||
"required": ["owner", "repo", "sha"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
),
|
||||
_tool(
|
||||
"list_org_repositories",
|
||||
"List repositories belonging to an organization.",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"org": {"type": "string"},
|
||||
"page": {"type": "integer", "minimum": 1, "default": 1},
|
||||
"limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 50},
|
||||
},
|
||||
"required": ["org"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
),
|
||||
_tool(
|
||||
"list_organizations",
|
||||
"List organizations the authenticated user belongs to.",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"page": {"type": "integer", "minimum": 1, "default": 1},
|
||||
"limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 50},
|
||||
},
|
||||
"required": [],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
),
|
||||
_tool(
|
||||
"get_repo_languages",
|
||||
"Get the language breakdown for a repository.",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"owner": {"type": "string"}, "repo": {"type": "string"}},
|
||||
"required": ["owner", "repo"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
),
|
||||
_tool(
|
||||
"list_repo_topics",
|
||||
"List the topics assigned to a repository.",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"owner": {"type": "string"}, "repo": {"type": "string"}},
|
||||
"required": ["owner", "repo"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
),
|
||||
_tool(
|
||||
"create_issue",
|
||||
"Create a repository issue (write-mode only).",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string"},
|
||||
"repo": {"type": "string"},
|
||||
"title": {"type": "string"},
|
||||
"body": {"type": "string", "default": ""},
|
||||
"labels": {"type": "array", "items": {"type": "string"}, "default": []},
|
||||
"assignees": {"type": "array", "items": {"type": "string"}, "default": []},
|
||||
"milestone": {
|
||||
"type": ["integer", "string"],
|
||||
"description": "Milestone id or title to assign the issue to",
|
||||
},
|
||||
},
|
||||
"required": ["owner", "repo", "title"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
write_operation=True,
|
||||
),
|
||||
_tool(
|
||||
"update_issue",
|
||||
"Update issue title/body/state/milestone (write-mode only).",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string"},
|
||||
"repo": {"type": "string"},
|
||||
"issue_number": {"type": "integer", "minimum": 1},
|
||||
"title": {"type": "string"},
|
||||
"body": {"type": "string"},
|
||||
"state": {"type": "string", "enum": ["open", "closed"]},
|
||||
"milestone": {
|
||||
"type": ["integer", "string"],
|
||||
"description": "Milestone id or title to assign; 0 clears the milestone",
|
||||
},
|
||||
},
|
||||
"required": ["owner", "repo", "issue_number"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
write_operation=True,
|
||||
),
|
||||
_tool(
|
||||
"create_issue_comment",
|
||||
"Create issue comment (write-mode only).",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string"},
|
||||
"repo": {"type": "string"},
|
||||
"issue_number": {"type": "integer", "minimum": 1},
|
||||
"body": {"type": "string"},
|
||||
},
|
||||
"required": ["owner", "repo", "issue_number", "body"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
write_operation=True,
|
||||
),
|
||||
_tool(
|
||||
"create_pr_comment",
|
||||
"Create pull request comment (write-mode only).",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string"},
|
||||
"repo": {"type": "string"},
|
||||
"pull_number": {"type": "integer", "minimum": 1},
|
||||
"body": {"type": "string"},
|
||||
},
|
||||
"required": ["owner", "repo", "pull_number", "body"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
write_operation=True,
|
||||
),
|
||||
_tool(
|
||||
"add_labels",
|
||||
"Add labels to an issue or PR (write-mode only).",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string"},
|
||||
"repo": {"type": "string"},
|
||||
"issue_number": {"type": "integer", "minimum": 1},
|
||||
"labels": {"type": "array", "items": {"type": "string"}, "minItems": 1},
|
||||
},
|
||||
"required": ["owner", "repo", "issue_number", "labels"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
write_operation=True,
|
||||
),
|
||||
_tool(
|
||||
"assign_issue",
|
||||
"Assign users to issue or PR (write-mode only).",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string"},
|
||||
"repo": {"type": "string"},
|
||||
"issue_number": {"type": "integer", "minimum": 1},
|
||||
"assignees": {"type": "array", "items": {"type": "string"}, "minItems": 1},
|
||||
},
|
||||
"required": ["owner", "repo", "issue_number", "assignees"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
write_operation=True,
|
||||
),
|
||||
_tool(
|
||||
"create_label",
|
||||
"Create a repository label (write-mode only).",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string"},
|
||||
"repo": {"type": "string"},
|
||||
"name": {"type": "string"},
|
||||
"color": {"type": "string", "description": "Hex color, e.g. #00aabb"},
|
||||
"description": {"type": "string", "default": ""},
|
||||
"exclusive": {"type": "boolean", "default": False},
|
||||
},
|
||||
"required": ["owner", "repo", "name", "color"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
write_operation=True,
|
||||
),
|
||||
_tool(
|
||||
"update_label",
|
||||
"Update an existing repository label, located by its current name (write-mode only).",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string"},
|
||||
"repo": {"type": "string"},
|
||||
"name": {"type": "string", "description": "Current label name"},
|
||||
"new_name": {"type": "string"},
|
||||
"color": {"type": "string", "description": "Hex color, e.g. #00aabb"},
|
||||
"description": {"type": "string"},
|
||||
},
|
||||
"required": ["owner", "repo", "name"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
write_operation=True,
|
||||
),
|
||||
_tool(
|
||||
"remove_labels",
|
||||
"Remove labels (by name) from an issue or pull request (write-mode only).",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string"},
|
||||
"repo": {"type": "string"},
|
||||
"issue_number": {"type": "integer", "minimum": 1},
|
||||
"labels": {"type": "array", "items": {"type": "string"}, "minItems": 1},
|
||||
},
|
||||
"required": ["owner", "repo", "issue_number", "labels"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
write_operation=True,
|
||||
),
|
||||
_tool(
|
||||
"create_pull_request",
|
||||
"Open a pull request from head into base (write-mode only).",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string"},
|
||||
"repo": {"type": "string"},
|
||||
"title": {"type": "string"},
|
||||
"head": {"type": "string", "description": "Source branch"},
|
||||
"base": {"type": "string", "description": "Target branch"},
|
||||
"body": {"type": "string", "default": ""},
|
||||
},
|
||||
"required": ["owner", "repo", "title", "head", "base"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
write_operation=True,
|
||||
),
|
||||
_tool(
|
||||
"create_release",
|
||||
"Create a release for a tag (write-mode only).",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string"},
|
||||
"repo": {"type": "string"},
|
||||
"tag_name": {"type": "string"},
|
||||
"name": {"type": "string", "default": ""},
|
||||
"body": {"type": "string", "default": ""},
|
||||
"draft": {"type": "boolean", "default": False},
|
||||
"prerelease": {"type": "boolean", "default": False},
|
||||
"target": {"type": "string", "description": "Target commitish/branch"},
|
||||
},
|
||||
"required": ["owner", "repo", "tag_name"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
write_operation=True,
|
||||
),
|
||||
_tool(
|
||||
"edit_release",
|
||||
"Edit an existing release (write-mode only).",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string"},
|
||||
"repo": {"type": "string"},
|
||||
"release_id": {"type": "integer", "minimum": 1},
|
||||
"name": {"type": "string"},
|
||||
"body": {"type": "string"},
|
||||
"draft": {"type": "boolean"},
|
||||
"prerelease": {"type": "boolean"},
|
||||
},
|
||||
"required": ["owner", "repo", "release_id"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
write_operation=True,
|
||||
),
|
||||
_tool(
|
||||
"create_branch",
|
||||
"Create a branch, optionally from an existing branch (write-mode only).",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string"},
|
||||
"repo": {"type": "string"},
|
||||
"new_branch_name": {"type": "string"},
|
||||
"old_branch_name": {"type": "string", "description": "Source branch (optional)"},
|
||||
},
|
||||
"required": ["owner", "repo", "new_branch_name"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
write_operation=True,
|
||||
),
|
||||
_tool(
|
||||
"create_milestone",
|
||||
"Create a repository milestone (write-mode only).",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string"},
|
||||
"repo": {"type": "string"},
|
||||
"title": {"type": "string"},
|
||||
"description": {"type": "string", "default": ""},
|
||||
"due_on": {"type": "string", "description": "ISO8601 due date (optional)"},
|
||||
},
|
||||
"required": ["owner", "repo", "title"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
write_operation=True,
|
||||
),
|
||||
_tool(
|
||||
"edit_issue_comment",
|
||||
"Edit an existing issue or PR comment (write-mode only).",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string"},
|
||||
"repo": {"type": "string"},
|
||||
"comment_id": {"type": "integer", "minimum": 1},
|
||||
"body": {"type": "string"},
|
||||
},
|
||||
"required": ["owner", "repo", "comment_id", "body"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
write_operation=True,
|
||||
),
|
||||
_tool(
|
||||
"gitea_request",
|
||||
(
|
||||
"Generic escape hatch that calls an arbitrary Gitea REST endpoint "
|
||||
"(method + path). Prefer the dedicated tools; use this only for "
|
||||
"endpoints they do not cover. Subject to policy, write-mode and the "
|
||||
"sensitive-path denylist. Methods other than GET/HEAD are writes and "
|
||||
"require write-mode plus a whitelisted repository."
|
||||
),
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"method": {
|
||||
"type": "string",
|
||||
"enum": ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"],
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Gitea REST path, e.g. /repos/{owner}/{repo}/pulls/1/merge",
|
||||
},
|
||||
"query": {"type": "object", "description": "Optional query-string parameters"},
|
||||
"body": {"type": "object", "description": "Optional JSON request body"},
|
||||
},
|
||||
"required": ["method", "path"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
# write_operation is intentionally False: a static flag cannot describe a
|
||||
# tool that is read OR write depending on the method. Setting it True
|
||||
# would force the central write-mode gate on GETs and break reads. The
|
||||
# handler is authoritative via its own per-method authorize() call.
|
||||
write_operation=False,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def get_tool_by_name(tool_name: str) -> MCPTool | None:
|
||||
"""Get tool definition by name."""
|
||||
for tool in AVAILABLE_TOOLS:
|
||||
if tool.name == tool_name:
|
||||
return tool
|
||||
return None
|
||||
@@ -1,386 +0,0 @@
|
||||
"""OAuth2/OIDC token validation for per-user Gitea authentication."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, cast
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
from jwt import InvalidTokenError
|
||||
from jwt.algorithms import RSAAlgorithm
|
||||
|
||||
from aegis_gitea_mcp.audit import get_audit_logger
|
||||
from aegis_gitea_mcp.config import get_settings
|
||||
|
||||
|
||||
class OAuthTokenValidationError(RuntimeError):
|
||||
"""Raised when a provided OAuth token cannot be trusted."""
|
||||
|
||||
def __init__(self, public_message: str, reason: str) -> None:
|
||||
"""Initialize validation error details."""
|
||||
super().__init__(public_message)
|
||||
self.public_message = public_message
|
||||
self.reason = reason
|
||||
|
||||
|
||||
class GiteaOAuthValidator:
|
||||
"""Validate per-user OAuth access tokens issued by Gitea."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize OAuth validator state and caches."""
|
||||
self.settings = get_settings()
|
||||
self.audit = get_audit_logger()
|
||||
self._failed_attempts: dict[str, list[datetime]] = {}
|
||||
self._discovery_cache: tuple[dict[str, Any], float] | None = None
|
||||
self._jwks_cache: dict[str, tuple[dict[str, Any], float]] = {}
|
||||
|
||||
@staticmethod
|
||||
def extract_bearer_token(authorization_header: str | None) -> str | None:
|
||||
"""Extract token from `Authorization: Bearer <token>` header."""
|
||||
if not authorization_header:
|
||||
return None
|
||||
scheme, separator, token = authorization_header.partition(" ")
|
||||
if separator != " " or scheme != "Bearer":
|
||||
return None
|
||||
stripped = token.strip()
|
||||
if not stripped or " " in stripped:
|
||||
return None
|
||||
return stripped
|
||||
|
||||
def _check_rate_limit(self, identifier: str) -> bool:
|
||||
"""Check whether authentication failures exceed configured threshold."""
|
||||
now = datetime.now(timezone.utc)
|
||||
boundary = now.timestamp() - self.settings.auth_failure_window
|
||||
|
||||
if identifier in self._failed_attempts:
|
||||
self._failed_attempts[identifier] = [
|
||||
attempt
|
||||
for attempt in self._failed_attempts[identifier]
|
||||
if attempt.timestamp() > boundary
|
||||
]
|
||||
|
||||
return len(self._failed_attempts.get(identifier, [])) < self.settings.max_auth_failures
|
||||
|
||||
def _record_failed_attempt(self, identifier: str) -> None:
|
||||
"""Record a failed authentication attempt for rate limiting."""
|
||||
attempt_time = datetime.now(timezone.utc)
|
||||
self._failed_attempts.setdefault(identifier, []).append(attempt_time)
|
||||
|
||||
if len(self._failed_attempts[identifier]) >= self.settings.max_auth_failures:
|
||||
self.audit.log_security_event(
|
||||
event_type="oauth_rate_limit_exceeded",
|
||||
description="OAuth authentication failure threshold exceeded",
|
||||
severity="high",
|
||||
metadata={
|
||||
"identifier": identifier,
|
||||
"failure_count": len(self._failed_attempts[identifier]),
|
||||
"window_seconds": self.settings.auth_failure_window,
|
||||
},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _looks_like_jwt(token: str) -> bool:
|
||||
"""Return True when token has JWT segment structure."""
|
||||
return token.count(".") == 2
|
||||
|
||||
@staticmethod
|
||||
def _normalize_scopes(raw: Any) -> set[str]:
|
||||
"""Normalize scope claim variations to a set."""
|
||||
normalized: set[str] = set()
|
||||
if isinstance(raw, str):
|
||||
normalized.update(scope for scope in raw.split(" ") if scope)
|
||||
elif isinstance(raw, list):
|
||||
normalized.update(str(scope).strip() for scope in raw if str(scope).strip())
|
||||
return normalized
|
||||
|
||||
def _extract_scopes(self, payload: dict[str, Any]) -> set[str]:
|
||||
"""Extract scopes from JWT or userinfo payload."""
|
||||
scopes = set()
|
||||
scopes.update(self._normalize_scopes(payload.get("scope")))
|
||||
scopes.update(self._normalize_scopes(payload.get("scopes")))
|
||||
scopes.update(self._normalize_scopes(payload.get("scp")))
|
||||
return scopes
|
||||
|
||||
async def _fetch_json_document(self, url: str) -> dict[str, Any]:
|
||||
"""Fetch a JSON document from a trusted OAuth endpoint."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.settings.request_timeout_seconds) as client:
|
||||
response = await client.get(url, headers={"Accept": "application/json"})
|
||||
except httpx.RequestError as exc:
|
||||
raise OAuthTokenValidationError(
|
||||
"Unable to validate OAuth token at this time.",
|
||||
"oauth_network_error",
|
||||
) from exc
|
||||
|
||||
if response.status_code != 200:
|
||||
raise OAuthTokenValidationError(
|
||||
"Unable to validate OAuth token at this time.",
|
||||
"oauth_metadata_unavailable",
|
||||
)
|
||||
|
||||
try:
|
||||
data = response.json()
|
||||
except ValueError as exc:
|
||||
raise OAuthTokenValidationError(
|
||||
"Unable to validate OAuth token at this time.",
|
||||
"oauth_metadata_invalid_json",
|
||||
) from exc
|
||||
|
||||
if not isinstance(data, dict):
|
||||
raise OAuthTokenValidationError(
|
||||
"Unable to validate OAuth token at this time.",
|
||||
"oauth_metadata_invalid_type",
|
||||
)
|
||||
return data
|
||||
|
||||
async def _get_discovery_document(self) -> dict[str, Any]:
|
||||
"""Get cached OIDC discovery metadata."""
|
||||
now = time.monotonic()
|
||||
if self._discovery_cache and now < self._discovery_cache[1]:
|
||||
return self._discovery_cache[0]
|
||||
|
||||
discovery_url = f"{self.settings.gitea_base_url}/.well-known/openid-configuration"
|
||||
discovery = await self._fetch_json_document(discovery_url)
|
||||
issuer = discovery.get("issuer")
|
||||
jwks_uri = discovery.get("jwks_uri")
|
||||
if not isinstance(issuer, str) or not issuer.strip():
|
||||
raise OAuthTokenValidationError(
|
||||
"Unable to validate OAuth token at this time.",
|
||||
"oauth_discovery_missing_issuer",
|
||||
)
|
||||
if not isinstance(jwks_uri, str) or not jwks_uri.strip():
|
||||
raise OAuthTokenValidationError(
|
||||
"Unable to validate OAuth token at this time.",
|
||||
"oauth_discovery_missing_jwks_uri",
|
||||
)
|
||||
|
||||
self._discovery_cache = (discovery, now + self.settings.oauth_cache_ttl_seconds)
|
||||
return discovery
|
||||
|
||||
async def _get_jwks(self, jwks_uri: str) -> dict[str, Any]:
|
||||
"""Get cached JWKS document."""
|
||||
now = time.monotonic()
|
||||
cached = self._jwks_cache.get(jwks_uri)
|
||||
if cached and now < cached[1]:
|
||||
return cached[0]
|
||||
|
||||
jwks = await self._fetch_json_document(jwks_uri)
|
||||
keys = jwks.get("keys")
|
||||
if not isinstance(keys, list) or not keys:
|
||||
raise OAuthTokenValidationError(
|
||||
"Unable to validate OAuth token at this time.",
|
||||
"oauth_jwks_missing_keys",
|
||||
)
|
||||
self._jwks_cache[jwks_uri] = (jwks, now + self.settings.oauth_cache_ttl_seconds)
|
||||
return jwks
|
||||
|
||||
def _acceptable_audiences(self) -> list[str]:
|
||||
"""Return the set of OIDC audiences this MCP server will accept.
|
||||
|
||||
Per the MCP authorization spec (RFC 8707 / RFC 9728) tokens are bound to
|
||||
the MCP server's canonical resource URL, so the configured public base is
|
||||
the primary accepted audience. The upstream Gitea OAuth client id is also
|
||||
accepted because Gitea — the actual token issuer behind this proxy —
|
||||
stamps ``aud`` with the client id rather than the MCP resource URL. An
|
||||
operator may add a further required audience via OAUTH_EXPECTED_AUDIENCE.
|
||||
"""
|
||||
audiences: list[str] = []
|
||||
canonical_resource = self.settings.public_base
|
||||
if canonical_resource:
|
||||
audiences.append(canonical_resource)
|
||||
gitea_client_id = self.settings.gitea_oauth_client_id.strip()
|
||||
if gitea_client_id:
|
||||
audiences.append(gitea_client_id)
|
||||
configured = self.settings.oauth_expected_audience.strip()
|
||||
if configured:
|
||||
audiences.append(configured)
|
||||
# Preserve order while removing duplicates.
|
||||
return list(dict.fromkeys(audiences))
|
||||
|
||||
async def _validate_jwt(self, token: str) -> dict[str, Any]:
|
||||
"""Validate JWT access token using OIDC discovery and JWKS."""
|
||||
discovery = await self._get_discovery_document()
|
||||
issuer = str(discovery["issuer"]).rstrip("/")
|
||||
jwks_uri = str(discovery["jwks_uri"])
|
||||
jwks = await self._get_jwks(jwks_uri)
|
||||
|
||||
try:
|
||||
header = jwt.get_unverified_header(token)
|
||||
except InvalidTokenError as exc:
|
||||
raise OAuthTokenValidationError(
|
||||
"Invalid or expired OAuth token.", "oauth_jwt_header"
|
||||
) from exc
|
||||
|
||||
algorithm = header.get("alg")
|
||||
key_id = header.get("kid")
|
||||
if algorithm != "RS256":
|
||||
raise OAuthTokenValidationError("Invalid or expired OAuth token.", "oauth_jwt_alg")
|
||||
if not isinstance(key_id, str) or not key_id.strip():
|
||||
raise OAuthTokenValidationError("Invalid or expired OAuth token.", "oauth_jwt_kid")
|
||||
|
||||
matching_key = None
|
||||
for key in jwks.get("keys", []):
|
||||
if isinstance(key, dict) and key.get("kid") == key_id:
|
||||
matching_key = key
|
||||
break
|
||||
if matching_key is None:
|
||||
raise OAuthTokenValidationError(
|
||||
"Invalid or expired OAuth token.", "oauth_jwt_key_not_found"
|
||||
)
|
||||
|
||||
try:
|
||||
public_key = RSAAlgorithm.from_jwk(json.dumps(matching_key))
|
||||
except Exception as exc:
|
||||
raise OAuthTokenValidationError(
|
||||
"Unable to validate OAuth token at this time.",
|
||||
"oauth_jwt_invalid_jwk",
|
||||
) from exc
|
||||
|
||||
accepted_audiences = self._acceptable_audiences()
|
||||
|
||||
decode_options = cast(Any, {"verify_aud": bool(accepted_audiences)})
|
||||
try:
|
||||
claims = jwt.decode(
|
||||
token,
|
||||
key=cast(Any, public_key),
|
||||
algorithms=["RS256"],
|
||||
issuer=issuer,
|
||||
audience=accepted_audiences or None,
|
||||
options=decode_options,
|
||||
)
|
||||
except InvalidTokenError as exc:
|
||||
raise OAuthTokenValidationError(
|
||||
"Invalid or expired OAuth token.", "oauth_jwt_invalid"
|
||||
) from exc
|
||||
|
||||
if not isinstance(claims, dict):
|
||||
raise OAuthTokenValidationError("Invalid or expired OAuth token.", "oauth_jwt_claims")
|
||||
|
||||
scopes = self._extract_scopes(claims)
|
||||
login = (
|
||||
str(claims.get("preferred_username", "")).strip()
|
||||
or str(claims.get("name", "")).strip()
|
||||
or str(claims.get("sub", "unknown")).strip()
|
||||
)
|
||||
subject = str(claims.get("sub", login)).strip() or "unknown"
|
||||
return {
|
||||
"login": login,
|
||||
"subject": subject,
|
||||
"scopes": sorted(scopes),
|
||||
}
|
||||
|
||||
async def _validate_userinfo(self, token: str) -> dict[str, Any]:
|
||||
"""Validate token via Gitea userinfo endpoint (opaque token fallback)."""
|
||||
userinfo_url = f"{self.settings.gitea_base_url}/login/oauth/userinfo"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.settings.request_timeout_seconds) as client:
|
||||
response = await client.get(
|
||||
userinfo_url,
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
)
|
||||
except httpx.RequestError as exc:
|
||||
raise OAuthTokenValidationError(
|
||||
"Unable to validate OAuth token at this time.",
|
||||
"oauth_userinfo_network",
|
||||
) from exc
|
||||
|
||||
if response.status_code in {401, 403}:
|
||||
raise OAuthTokenValidationError(
|
||||
"Invalid or expired OAuth token.", "oauth_userinfo_denied"
|
||||
)
|
||||
if response.status_code != 200:
|
||||
raise OAuthTokenValidationError(
|
||||
"Unable to validate OAuth token at this time.",
|
||||
"oauth_userinfo_unavailable",
|
||||
)
|
||||
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError as exc:
|
||||
raise OAuthTokenValidationError(
|
||||
"Invalid or expired OAuth token.", "oauth_userinfo_json"
|
||||
) from exc
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
raise OAuthTokenValidationError(
|
||||
"Invalid or expired OAuth token.", "oauth_userinfo_type"
|
||||
)
|
||||
|
||||
scopes = self._extract_scopes(payload)
|
||||
login = (
|
||||
str(payload.get("preferred_username", "")).strip()
|
||||
or str(payload.get("login", "")).strip()
|
||||
or str(payload.get("name", "")).strip()
|
||||
or str(payload.get("sub", "unknown")).strip()
|
||||
)
|
||||
subject = str(payload.get("sub", login)).strip() or "unknown"
|
||||
return {
|
||||
"login": login,
|
||||
"subject": subject,
|
||||
"scopes": sorted(scopes),
|
||||
}
|
||||
|
||||
async def validate_oauth_token(
|
||||
self,
|
||||
token: str | None,
|
||||
client_ip: str,
|
||||
user_agent: str,
|
||||
) -> tuple[bool, str | None, dict[str, Any] | None]:
|
||||
"""Validate an incoming OAuth token and return principal context."""
|
||||
if not self._check_rate_limit(client_ip):
|
||||
return False, "Too many failed authentication attempts. Try again later.", None
|
||||
|
||||
if not token:
|
||||
self._record_failed_attempt(client_ip)
|
||||
return False, "Authorization header missing or empty.", None
|
||||
|
||||
try:
|
||||
if self._looks_like_jwt(token):
|
||||
try:
|
||||
principal = await self._validate_jwt(token)
|
||||
except OAuthTokenValidationError:
|
||||
# Some providers issue opaque access tokens; verify those via userinfo.
|
||||
principal = await self._validate_userinfo(token)
|
||||
else:
|
||||
principal = await self._validate_userinfo(token)
|
||||
except OAuthTokenValidationError as exc:
|
||||
self._record_failed_attempt(client_ip)
|
||||
self.audit.log_access_denied(
|
||||
tool_name="oauth_authentication",
|
||||
reason=exc.reason,
|
||||
)
|
||||
return False, exc.public_message, None
|
||||
|
||||
self.audit.log_tool_invocation(
|
||||
tool_name="oauth_authentication",
|
||||
result_status="success",
|
||||
params={
|
||||
"client_ip": client_ip,
|
||||
"user_agent": user_agent,
|
||||
"gitea_user": principal.get("login", "unknown"),
|
||||
},
|
||||
)
|
||||
return True, None, principal
|
||||
|
||||
|
||||
_oauth_validator: GiteaOAuthValidator | None = None
|
||||
|
||||
|
||||
def get_oauth_validator() -> GiteaOAuthValidator:
|
||||
"""Get or create the global OAuth validator instance."""
|
||||
global _oauth_validator
|
||||
if _oauth_validator is None:
|
||||
_oauth_validator = GiteaOAuthValidator()
|
||||
return _oauth_validator
|
||||
|
||||
|
||||
def reset_oauth_validator() -> None:
|
||||
"""Reset the global OAuth validator instance (primarily for testing)."""
|
||||
global _oauth_validator
|
||||
_oauth_validator = None
|
||||
@@ -1,401 +0,0 @@
|
||||
"""OAuth proxy helpers for signed state, redirect validation, and DCR storage."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from fnmatch import fnmatchcase
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import ParseResult, urlparse, urlunparse
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
_CLAUDE_CALLBACK_URIS = {
|
||||
"https://claude.ai/api/mcp/auth_callback",
|
||||
"https://claude.com/api/mcp/auth_callback",
|
||||
}
|
||||
_LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1"}
|
||||
_SUPPORTED_TOKEN_ENDPOINT_AUTH_METHODS = {"none", "client_secret_post"}
|
||||
_SUPPORTED_GRANT_TYPES = {"authorization_code", "refresh_token"}
|
||||
_SUPPORTED_RESPONSE_TYPES = {"code"}
|
||||
|
||||
|
||||
class OAuthRegistrationRequest(BaseModel):
|
||||
"""Incoming RFC 7591 client registration request."""
|
||||
|
||||
client_name: str | None = Field(default=None, max_length=200)
|
||||
redirect_uris: list[str] = Field(..., min_length=1)
|
||||
grant_types: list[str] = Field(default_factory=lambda: ["authorization_code", "refresh_token"])
|
||||
response_types: list[str] = Field(default_factory=lambda: ["code"])
|
||||
token_endpoint_auth_method: str = Field(default="none", max_length=64)
|
||||
scope: str | None = Field(default=None, max_length=512)
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@field_validator("redirect_uris")
|
||||
@classmethod
|
||||
def validate_redirect_uris(cls, value: list[str]) -> list[str]:
|
||||
"""Normalize and validate redirect URIs."""
|
||||
uris = [uri.strip() for uri in value if isinstance(uri, str) and uri.strip()]
|
||||
if not uris:
|
||||
raise ValueError("redirect_uris must contain at least one non-empty URI")
|
||||
return uris
|
||||
|
||||
@field_validator("grant_types")
|
||||
@classmethod
|
||||
def validate_grant_types(cls, value: list[str]) -> list[str]:
|
||||
"""Restrict supported grant types to authorization code and refresh token."""
|
||||
normalized = [item.strip() for item in value if item.strip()]
|
||||
if not normalized:
|
||||
raise ValueError("grant_types must not be empty")
|
||||
if any(item not in _SUPPORTED_GRANT_TYPES for item in normalized):
|
||||
raise ValueError("Unsupported grant_types requested")
|
||||
return normalized
|
||||
|
||||
@field_validator("response_types")
|
||||
@classmethod
|
||||
def validate_response_types(cls, value: list[str]) -> list[str]:
|
||||
"""Restrict supported response types to authorization code."""
|
||||
normalized = [item.strip() for item in value if item.strip()]
|
||||
if not normalized:
|
||||
raise ValueError("response_types must not be empty")
|
||||
if any(item not in _SUPPORTED_RESPONSE_TYPES for item in normalized):
|
||||
raise ValueError("Unsupported response_types requested")
|
||||
return normalized
|
||||
|
||||
@field_validator("token_endpoint_auth_method")
|
||||
@classmethod
|
||||
def validate_token_endpoint_auth_method(cls, value: str) -> str:
|
||||
"""Restrict token endpoint auth methods to the supported subset."""
|
||||
normalized = value.strip().lower()
|
||||
if normalized not in _SUPPORTED_TOKEN_ENDPOINT_AUTH_METHODS:
|
||||
raise ValueError("Unsupported token_endpoint_auth_method requested")
|
||||
return normalized
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_pkce_ready(self) -> OAuthRegistrationRequest:
|
||||
"""Ensure the request is usable for PKCE-based authorization code flow."""
|
||||
if "authorization_code" not in self.grant_types:
|
||||
raise ValueError("authorization_code grant is required")
|
||||
if "code" not in self.response_types:
|
||||
raise ValueError("code response type is required")
|
||||
return self
|
||||
|
||||
|
||||
class OAuthClientRecord(BaseModel):
|
||||
"""Persisted OAuth client registration record."""
|
||||
|
||||
client_id: str
|
||||
client_name: str | None = None
|
||||
redirect_uris: list[str]
|
||||
grant_types: list[str]
|
||||
response_types: list[str]
|
||||
token_endpoint_auth_method: str
|
||||
client_id_issued_at: int
|
||||
client_secret_expires_at: int = 0
|
||||
client_secret_hash: str | None = None
|
||||
scope: str | None = None
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
def _canonicalize_url(value: str) -> str:
|
||||
"""Normalize a URL for comparison."""
|
||||
parsed = urlparse(value.strip())
|
||||
if not parsed.scheme or not parsed.netloc:
|
||||
return ""
|
||||
|
||||
normalized = ParseResult(
|
||||
scheme=parsed.scheme.lower(),
|
||||
netloc=parsed.netloc.lower(),
|
||||
path=parsed.path or "/",
|
||||
params=parsed.params,
|
||||
query=parsed.query,
|
||||
fragment="",
|
||||
)
|
||||
return urlunparse(normalized).rstrip("/")
|
||||
|
||||
|
||||
def is_loopback_redirect_uri(redirect_uri: str) -> bool:
|
||||
"""Return whether a redirect URI uses a loopback host."""
|
||||
parsed = urlparse(redirect_uri.strip())
|
||||
if parsed.scheme != "http":
|
||||
return False
|
||||
host = (parsed.hostname or "").lower()
|
||||
return host in _LOOPBACK_HOSTS
|
||||
|
||||
|
||||
def is_claude_redirect_uri(redirect_uri: str) -> bool:
|
||||
"""Return whether a redirect URI is a built-in Claude callback URL."""
|
||||
return _canonicalize_url(redirect_uri) in _CLAUDE_CALLBACK_URIS
|
||||
|
||||
|
||||
def is_redirect_uri_allowed(redirect_uri: str, allowlist: list[str]) -> bool:
|
||||
"""Return whether a redirect URI is allowed by policy."""
|
||||
normalized = _canonicalize_url(redirect_uri)
|
||||
if not normalized:
|
||||
return False
|
||||
|
||||
if is_loopback_redirect_uri(redirect_uri) or is_claude_redirect_uri(redirect_uri):
|
||||
return True
|
||||
|
||||
for pattern in allowlist:
|
||||
candidate = pattern.strip()
|
||||
if not candidate:
|
||||
continue
|
||||
if fnmatchcase(normalized, _canonicalize_url(candidate) or candidate):
|
||||
return True
|
||||
if fnmatchcase(redirect_uri.strip(), candidate):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def is_origin_allowed(origin: str, request_base: str, public_base: str | None) -> bool:
|
||||
"""Return whether a browser Origin is allowed for MCP transport requests."""
|
||||
normalized_origin = _canonicalize_url(origin)
|
||||
if not normalized_origin:
|
||||
return False
|
||||
|
||||
expected_bases = [request_base.rstrip("/")]
|
||||
if public_base:
|
||||
expected_bases.append(public_base.rstrip("/"))
|
||||
return normalized_origin in expected_bases
|
||||
|
||||
|
||||
def encode_proxy_state(
|
||||
secret: str,
|
||||
redirect_uri: str,
|
||||
original_state: str,
|
||||
*,
|
||||
ttl_seconds: int = 600,
|
||||
) -> str:
|
||||
"""Create a signed OAuth state wrapper for the proxy callback round-trip."""
|
||||
payload = {
|
||||
"redirect_uri": redirect_uri,
|
||||
"state": original_state,
|
||||
"issued_at": int(time.time()),
|
||||
"nonce": secrets.token_urlsafe(16),
|
||||
"ttl_seconds": ttl_seconds,
|
||||
}
|
||||
canonical_payload = json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
||||
signature = hmac.new(secret.encode("utf-8"), canonical_payload.encode("utf-8"), hashlib.sha256)
|
||||
envelope = {
|
||||
"payload": payload,
|
||||
"signature": signature.hexdigest(),
|
||||
}
|
||||
return base64.urlsafe_b64encode(
|
||||
json.dumps(envelope, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
).decode("ascii")
|
||||
|
||||
|
||||
def decode_proxy_state(secret: str, encoded_state: str) -> dict[str, str]:
|
||||
"""Verify and unpack a signed OAuth state wrapper."""
|
||||
try:
|
||||
raw = base64.urlsafe_b64decode(encoded_state.encode("ascii"))
|
||||
envelope = json.loads(raw)
|
||||
except Exception as exc: # pragma: no cover - guarded by tests
|
||||
raise ValueError("Invalid or missing state parameter") from exc
|
||||
|
||||
if not isinstance(envelope, dict):
|
||||
raise ValueError("Invalid or missing state parameter")
|
||||
|
||||
payload = envelope.get("payload")
|
||||
signature = envelope.get("signature")
|
||||
if not isinstance(payload, dict) or not isinstance(signature, str):
|
||||
raise ValueError("Invalid or missing state parameter")
|
||||
|
||||
canonical_payload = json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
||||
expected_signature = hmac.new(
|
||||
secret.encode("utf-8"), canonical_payload.encode("utf-8"), hashlib.sha256
|
||||
).hexdigest()
|
||||
if not hmac.compare_digest(signature, expected_signature):
|
||||
raise ValueError("Invalid or missing state parameter")
|
||||
|
||||
issued_at = payload.get("issued_at")
|
||||
ttl_seconds = payload.get("ttl_seconds")
|
||||
now = int(time.time())
|
||||
if not isinstance(issued_at, int) or not isinstance(ttl_seconds, int):
|
||||
raise ValueError("Invalid or missing state parameter")
|
||||
if issued_at > now or now - issued_at > max(ttl_seconds, 1):
|
||||
raise ValueError("Invalid or missing state parameter")
|
||||
|
||||
redirect_uri = payload.get("redirect_uri")
|
||||
if not isinstance(redirect_uri, str) or not redirect_uri.strip():
|
||||
raise ValueError("Invalid or missing state parameter")
|
||||
|
||||
original_state = payload.get("state")
|
||||
if not isinstance(original_state, str):
|
||||
raise ValueError("Invalid or missing state parameter")
|
||||
|
||||
return {"redirect_uri": redirect_uri, "state": original_state}
|
||||
|
||||
|
||||
class OAuthClientRegistry:
|
||||
"""Persisted OAuth client registry for dynamic client registration."""
|
||||
|
||||
def __init__(self, storage_path: Path) -> None:
|
||||
"""Initialize registry storage.
|
||||
|
||||
The storage directory is created lazily at write time (see ``_persist``)
|
||||
rather than here, so that read-only operations — the OAuth ``authorize``
|
||||
and ``token`` proxies only ever *read* the registry — never require a
|
||||
writable filesystem. This keeps those endpoints working on hardened,
|
||||
read-only container roots where only an explicit data volume is writable.
|
||||
"""
|
||||
self.storage_path = storage_path
|
||||
self._clients: dict[str, OAuthClientRecord] = {}
|
||||
self._loaded = False
|
||||
|
||||
@staticmethod
|
||||
def _hash_secret(secret: str) -> str:
|
||||
"""Hash client secrets before persistence."""
|
||||
return hashlib.sha256(secret.encode("utf-8")).hexdigest()
|
||||
|
||||
def _load(self) -> None:
|
||||
"""Load persisted registrations from disk once."""
|
||||
if self._loaded:
|
||||
return
|
||||
self._loaded = True
|
||||
if not self.storage_path.exists():
|
||||
self._clients = {}
|
||||
return
|
||||
|
||||
raw = json.loads(self.storage_path.read_text(encoding="utf-8"))
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("Persisted DCR storage must be a JSON object")
|
||||
|
||||
clients: dict[str, OAuthClientRecord] = {}
|
||||
for client_id, payload in raw.items():
|
||||
if not isinstance(client_id, str):
|
||||
raise ValueError("Persisted client id must be a string")
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError(f"Persisted client record for {client_id} must be a mapping")
|
||||
record = OAuthClientRecord.model_validate({"client_id": client_id, **payload})
|
||||
clients[client_id] = record
|
||||
|
||||
self._clients = clients
|
||||
|
||||
def _persist(self) -> None:
|
||||
"""Write registrations atomically.
|
||||
|
||||
Dynamic client registration is the only operation that writes to disk, so
|
||||
the storage directory is created here (not at construction time). This
|
||||
requires the configured ``DCR_STORAGE_PATH`` to live on a writable,
|
||||
persistent volume — see the deployment notes in the README.
|
||||
"""
|
||||
self.storage_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = {
|
||||
client_id: record.model_dump(mode="json", exclude={"client_id"})
|
||||
for client_id, record in self._clients.items()
|
||||
}
|
||||
tmp_path = self.storage_path.with_suffix(self.storage_path.suffix + ".tmp")
|
||||
tmp_path.write_text(json.dumps(payload, sort_keys=True, indent=2), encoding="utf-8")
|
||||
# Registration records hold client-secret hashes and metadata; restrict to the
|
||||
# owning user before the atomic replace so the file is never briefly world-readable.
|
||||
# chmod is a no-op on platforms without POSIX permission bits (e.g. Windows).
|
||||
try:
|
||||
os.chmod(tmp_path, 0o600)
|
||||
except (OSError, NotImplementedError):
|
||||
pass
|
||||
tmp_path.replace(self.storage_path)
|
||||
|
||||
def get(self, client_id: str) -> OAuthClientRecord | None:
|
||||
"""Look up a registered client by identifier."""
|
||||
self._load()
|
||||
return self._clients.get(client_id)
|
||||
|
||||
def is_known_client(
|
||||
self,
|
||||
client_id: str,
|
||||
*,
|
||||
fallback_client_id: str = "",
|
||||
fallback_client_secret: str = "",
|
||||
) -> bool:
|
||||
"""Return whether a client is recognized by the registry or environment."""
|
||||
if not client_id.strip():
|
||||
return False
|
||||
if fallback_client_id.strip() and client_id == fallback_client_id.strip():
|
||||
return True
|
||||
return self.get(client_id) is not None
|
||||
|
||||
def validate_client_secret(
|
||||
self,
|
||||
client_id: str,
|
||||
client_secret: str | None,
|
||||
*,
|
||||
fallback_client_id: str = "",
|
||||
fallback_client_secret: str = "",
|
||||
) -> bool:
|
||||
"""Validate a client identifier and optional secret."""
|
||||
if fallback_client_id.strip() and client_id == fallback_client_id.strip():
|
||||
if not fallback_client_secret.strip():
|
||||
return True
|
||||
if not client_secret:
|
||||
return False
|
||||
return hmac.compare_digest(
|
||||
self._hash_secret(client_secret), self._hash_secret(fallback_client_secret.strip())
|
||||
)
|
||||
|
||||
record = self.get(client_id)
|
||||
if record is None:
|
||||
return False
|
||||
|
||||
if record.client_secret_hash is None:
|
||||
return True
|
||||
if not client_secret:
|
||||
return False
|
||||
return hmac.compare_digest(self._hash_secret(client_secret), record.client_secret_hash)
|
||||
|
||||
def register(self, request: OAuthRegistrationRequest) -> dict[str, Any]:
|
||||
"""Persist a new OAuth client registration and return its public metadata."""
|
||||
self._load()
|
||||
client_id = secrets.token_urlsafe(24)
|
||||
client_secret: str | None = None
|
||||
client_secret_hash: str | None = None
|
||||
|
||||
if request.token_endpoint_auth_method != "none":
|
||||
client_secret = secrets.token_urlsafe(32)
|
||||
client_secret_hash = self._hash_secret(client_secret)
|
||||
|
||||
record = OAuthClientRecord(
|
||||
client_id=client_id,
|
||||
client_name=request.client_name,
|
||||
redirect_uris=list(request.redirect_uris),
|
||||
grant_types=list(request.grant_types),
|
||||
response_types=list(request.response_types),
|
||||
token_endpoint_auth_method=request.token_endpoint_auth_method,
|
||||
client_id_issued_at=int(time.time()),
|
||||
client_secret_hash=client_secret_hash,
|
||||
scope=request.scope,
|
||||
)
|
||||
self._clients[client_id] = record
|
||||
self._persist()
|
||||
|
||||
response: dict[str, Any] = record.model_dump(exclude={"client_secret_hash"})
|
||||
if client_secret is not None:
|
||||
response["client_secret"] = client_secret
|
||||
response["client_secret_expires_at"] = 0
|
||||
return response
|
||||
|
||||
|
||||
_oauth_client_registry: OAuthClientRegistry | None = None
|
||||
|
||||
|
||||
def get_oauth_client_registry(storage_path: Path) -> OAuthClientRegistry:
|
||||
"""Get or create the global OAuth client registry."""
|
||||
global _oauth_client_registry
|
||||
if _oauth_client_registry is None or _oauth_client_registry.storage_path != storage_path:
|
||||
_oauth_client_registry = OAuthClientRegistry(storage_path)
|
||||
return _oauth_client_registry
|
||||
|
||||
|
||||
def reset_oauth_client_registry() -> None:
|
||||
"""Reset the global OAuth client registry (primarily for tests)."""
|
||||
global _oauth_client_registry
|
||||
_oauth_client_registry = None
|
||||
@@ -1,98 +0,0 @@
|
||||
"""Observability primitives: metrics and lightweight instrumentation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from threading import Lock
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolTiming:
|
||||
"""Aggregated tool timing stats."""
|
||||
|
||||
count: int
|
||||
total_seconds: float
|
||||
|
||||
|
||||
class MetricsRegistry:
|
||||
"""In-process Prometheus-compatible metrics storage."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize empty metrics state."""
|
||||
self._lock = Lock()
|
||||
self._http_requests_total: defaultdict[tuple[str, str, str], int] = defaultdict(int)
|
||||
self._tool_calls_total: defaultdict[tuple[str, str], int] = defaultdict(int)
|
||||
self._tool_duration_seconds: defaultdict[str, float] = defaultdict(float)
|
||||
self._tool_duration_count: defaultdict[str, int] = defaultdict(int)
|
||||
|
||||
def record_http_request(self, method: str, path: str, status_code: int) -> None:
|
||||
"""Record completed HTTP request metric."""
|
||||
with self._lock:
|
||||
self._http_requests_total[(method, path, str(status_code))] += 1
|
||||
|
||||
def record_tool_call(self, tool_name: str, status: str, duration_seconds: float) -> None:
|
||||
"""Record tool invocation counters and duration aggregates."""
|
||||
with self._lock:
|
||||
self._tool_calls_total[(tool_name, status)] += 1
|
||||
self._tool_duration_seconds[tool_name] += max(duration_seconds, 0.0)
|
||||
self._tool_duration_count[tool_name] += 1
|
||||
|
||||
def render_prometheus(self) -> str:
|
||||
"""Render metrics in Prometheus exposition format."""
|
||||
lines: list[str] = []
|
||||
|
||||
lines.append("# HELP aegis_http_requests_total Total HTTP requests")
|
||||
lines.append("# TYPE aegis_http_requests_total counter")
|
||||
with self._lock:
|
||||
for (method, path, status), count in sorted(self._http_requests_total.items()):
|
||||
lines.append(
|
||||
"aegis_http_requests_total"
|
||||
f'{{method="{method}",path="{path}",status="{status}"}} {count}'
|
||||
)
|
||||
|
||||
lines.append("# HELP aegis_tool_calls_total Total MCP tool calls")
|
||||
lines.append("# TYPE aegis_tool_calls_total counter")
|
||||
for (tool_name, status), count in sorted(self._tool_calls_total.items()):
|
||||
lines.append(
|
||||
f'aegis_tool_calls_total{{tool="{tool_name}",status="{status}"}} {count}'
|
||||
)
|
||||
|
||||
lines.append(
|
||||
"# HELP aegis_tool_duration_seconds_sum Sum of MCP tool call duration seconds"
|
||||
)
|
||||
lines.append("# TYPE aegis_tool_duration_seconds_sum counter")
|
||||
for tool_name, total in sorted(self._tool_duration_seconds.items()):
|
||||
lines.append(f'aegis_tool_duration_seconds_sum{{tool="{tool_name}"}} {total:.6f}')
|
||||
|
||||
lines.append(
|
||||
"# HELP aegis_tool_duration_seconds_count MCP tool call duration sample count"
|
||||
)
|
||||
lines.append("# TYPE aegis_tool_duration_seconds_count counter")
|
||||
for tool_name, count in sorted(self._tool_duration_count.items()):
|
||||
lines.append(f'aegis_tool_duration_seconds_count{{tool="{tool_name}"}} {count}')
|
||||
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
_metrics_registry: MetricsRegistry | None = None
|
||||
|
||||
|
||||
def get_metrics_registry() -> MetricsRegistry:
|
||||
"""Get global metrics registry."""
|
||||
global _metrics_registry
|
||||
if _metrics_registry is None:
|
||||
_metrics_registry = MetricsRegistry()
|
||||
return _metrics_registry
|
||||
|
||||
|
||||
def reset_metrics_registry() -> None:
|
||||
"""Reset global metrics registry for tests."""
|
||||
global _metrics_registry
|
||||
_metrics_registry = None
|
||||
|
||||
|
||||
def monotonic_seconds() -> float:
|
||||
"""Expose monotonic timer for deterministic instrumentation."""
|
||||
return time.monotonic()
|
||||
@@ -1,265 +0,0 @@
|
||||
"""Policy engine for tool authorization decisions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from fnmatch import fnmatch
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml # type: ignore[import-untyped]
|
||||
|
||||
from aegis_gitea_mcp.config import get_settings
|
||||
|
||||
|
||||
class PolicyError(Exception):
|
||||
"""Raised when policy loading or validation fails."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PolicyDecision:
|
||||
"""Authorization result for a policy check."""
|
||||
|
||||
allowed: bool
|
||||
reason: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuleSet:
|
||||
"""Allow/Deny rules for tools."""
|
||||
|
||||
allow: set[str] = field(default_factory=set)
|
||||
deny: set[str] = field(default_factory=set)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PathRules:
|
||||
"""Allow/Deny rules for target file paths."""
|
||||
|
||||
allow: tuple[str, ...] = ()
|
||||
deny: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RepositoryPolicy:
|
||||
"""Repository-scoped policy rules."""
|
||||
|
||||
tools: RuleSet = field(default_factory=RuleSet)
|
||||
paths: PathRules = field(default_factory=PathRules)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PolicyConfig:
|
||||
"""Parsed policy configuration."""
|
||||
|
||||
default_read: str = "allow"
|
||||
default_write: str = "deny"
|
||||
tools: RuleSet = field(default_factory=RuleSet)
|
||||
repositories: dict[str, RepositoryPolicy] = field(default_factory=dict)
|
||||
|
||||
|
||||
class PolicyEngine:
|
||||
"""Evaluates authorization decisions for MCP tools."""
|
||||
|
||||
def __init__(self, config: PolicyConfig) -> None:
|
||||
"""Initialize policy engine with prevalidated config."""
|
||||
self.config = config
|
||||
self.settings = get_settings()
|
||||
|
||||
@classmethod
|
||||
def from_yaml_file(cls, policy_path: Path) -> PolicyEngine:
|
||||
"""Build a policy engine from YAML policy file.
|
||||
|
||||
Args:
|
||||
policy_path: Path to policy YAML file.
|
||||
|
||||
Returns:
|
||||
Initialized policy engine.
|
||||
|
||||
Raises:
|
||||
PolicyError: If file is malformed or violates policy schema.
|
||||
"""
|
||||
if not policy_path.exists():
|
||||
# Secure default for writes, backwards-compatible allow for reads.
|
||||
return cls(PolicyConfig())
|
||||
|
||||
try:
|
||||
raw = yaml.safe_load(policy_path.read_text(encoding="utf-8"))
|
||||
except Exception as exc:
|
||||
raise PolicyError(f"Failed to parse policy YAML: {exc}") from exc
|
||||
|
||||
if raw is None:
|
||||
return cls(PolicyConfig())
|
||||
|
||||
if not isinstance(raw, dict):
|
||||
raise PolicyError("Policy root must be a mapping")
|
||||
|
||||
defaults = raw.get("defaults", {})
|
||||
if defaults and not isinstance(defaults, dict):
|
||||
raise PolicyError("defaults must be a mapping")
|
||||
|
||||
default_read = str(defaults.get("read", "allow")).lower()
|
||||
default_write = str(defaults.get("write", "deny")).lower()
|
||||
if default_read not in {"allow", "deny"}:
|
||||
raise PolicyError("defaults.read must be 'allow' or 'deny'")
|
||||
if default_write not in {"allow", "deny"}:
|
||||
raise PolicyError("defaults.write must be 'allow' or 'deny'")
|
||||
|
||||
global_tools = cls._parse_tool_rules(raw.get("tools", {}), "tools")
|
||||
|
||||
repositories_raw = raw.get("repositories", {})
|
||||
if repositories_raw is None:
|
||||
repositories_raw = {}
|
||||
if not isinstance(repositories_raw, dict):
|
||||
raise PolicyError("repositories must be a mapping")
|
||||
|
||||
repositories: dict[str, RepositoryPolicy] = {}
|
||||
for repo_name, repo_payload in repositories_raw.items():
|
||||
if not isinstance(repo_name, str) or "/" not in repo_name:
|
||||
raise PolicyError("Repository keys must be in 'owner/repo' format")
|
||||
if not isinstance(repo_payload, dict):
|
||||
raise PolicyError(f"Repository policy for {repo_name} must be a mapping")
|
||||
|
||||
tool_rules = cls._parse_tool_rules(
|
||||
repo_payload.get("tools", {}),
|
||||
f"repositories.{repo_name}.tools",
|
||||
)
|
||||
|
||||
path_payload = repo_payload.get("paths", {})
|
||||
if path_payload and not isinstance(path_payload, dict):
|
||||
raise PolicyError(f"repositories.{repo_name}.paths must be a mapping")
|
||||
|
||||
allow_paths = cls._parse_path_list(path_payload.get("allow", []), "allow")
|
||||
deny_paths = cls._parse_path_list(path_payload.get("deny", []), "deny")
|
||||
|
||||
repositories[repo_name] = RepositoryPolicy(
|
||||
tools=tool_rules,
|
||||
paths=PathRules(allow=allow_paths, deny=deny_paths),
|
||||
)
|
||||
|
||||
return cls(
|
||||
PolicyConfig(
|
||||
default_read=default_read,
|
||||
default_write=default_write,
|
||||
tools=global_tools,
|
||||
repositories=repositories,
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _parse_tool_rules(raw_rules: Any, location: str) -> RuleSet:
|
||||
"""Parse tool allow/deny mapping from raw payload."""
|
||||
if not raw_rules:
|
||||
return RuleSet()
|
||||
if not isinstance(raw_rules, dict):
|
||||
raise PolicyError(f"{location} must be a mapping")
|
||||
|
||||
allow = raw_rules.get("allow", [])
|
||||
deny = raw_rules.get("deny", [])
|
||||
|
||||
if not isinstance(allow, list) or not all(isinstance(item, str) for item in allow):
|
||||
raise PolicyError(f"{location}.allow must be a list of strings")
|
||||
if not isinstance(deny, list) or not all(isinstance(item, str) for item in deny):
|
||||
raise PolicyError(f"{location}.deny must be a list of strings")
|
||||
|
||||
return RuleSet(allow=set(allow), deny=set(deny))
|
||||
|
||||
@staticmethod
|
||||
def _parse_path_list(raw_paths: Any, label: str) -> tuple[str, ...]:
|
||||
"""Parse path allow/deny list."""
|
||||
if raw_paths is None:
|
||||
return ()
|
||||
if not isinstance(raw_paths, list) or not all(isinstance(item, str) for item in raw_paths):
|
||||
raise PolicyError(f"paths.{label} must be a list of strings")
|
||||
return tuple(raw_paths)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_target_path(path: str) -> str:
|
||||
"""Normalize path before policy matching.
|
||||
|
||||
Security note:
|
||||
Path normalization blocks traversal attempts before fnmatch
|
||||
comparisons are executed.
|
||||
"""
|
||||
normalized = path.replace("\\", "/").lstrip("/")
|
||||
parts = [part for part in normalized.split("/") if part and part != "."]
|
||||
if any(part == ".." for part in parts):
|
||||
raise PolicyError("Target path contains traversal sequence '..'")
|
||||
return "/".join(parts)
|
||||
|
||||
def authorize(
|
||||
self,
|
||||
tool_name: str,
|
||||
is_write: bool,
|
||||
repository: str | None = None,
|
||||
target_path: str | None = None,
|
||||
) -> PolicyDecision:
|
||||
"""Evaluate whether a tool call is authorized by policy.
|
||||
|
||||
Args:
|
||||
tool_name: Invoked MCP tool name.
|
||||
is_write: Whether the tool mutates data.
|
||||
repository: Optional `owner/repo` target repository.
|
||||
target_path: Optional file path target.
|
||||
|
||||
Returns:
|
||||
Policy decision indicating allow/deny and reason.
|
||||
"""
|
||||
if tool_name in self.config.tools.deny:
|
||||
return PolicyDecision(False, "tool denied by global policy")
|
||||
|
||||
if self.config.tools.allow and tool_name not in self.config.tools.allow:
|
||||
return PolicyDecision(False, "tool not allowed by global policy")
|
||||
|
||||
if is_write:
|
||||
if not self.settings.write_mode:
|
||||
return PolicyDecision(False, "write mode is disabled")
|
||||
|
||||
if not repository:
|
||||
return PolicyDecision(False, "write operation requires a repository target")
|
||||
|
||||
if (
|
||||
not self.settings.write_allow_all_token_repos
|
||||
and repository not in self.settings.write_repository_whitelist
|
||||
):
|
||||
return PolicyDecision(False, "repository is not in write-mode whitelist")
|
||||
|
||||
repo_policy = self.config.repositories.get(repository) if repository else None
|
||||
|
||||
if repo_policy:
|
||||
if tool_name in repo_policy.tools.deny:
|
||||
return PolicyDecision(False, "tool denied for repository")
|
||||
if repo_policy.tools.allow and tool_name not in repo_policy.tools.allow:
|
||||
return PolicyDecision(False, "tool not allowed for repository")
|
||||
|
||||
if target_path:
|
||||
normalized_path = self._normalize_target_path(target_path)
|
||||
if repo_policy.paths.deny and any(
|
||||
fnmatch(normalized_path, pattern) for pattern in repo_policy.paths.deny
|
||||
):
|
||||
return PolicyDecision(False, "path denied by repository policy")
|
||||
if repo_policy.paths.allow and not any(
|
||||
fnmatch(normalized_path, pattern) for pattern in repo_policy.paths.allow
|
||||
):
|
||||
return PolicyDecision(False, "path not allowed by repository policy")
|
||||
|
||||
default_behavior = self.config.default_write if is_write else self.config.default_read
|
||||
return PolicyDecision(default_behavior == "allow", "default policy decision")
|
||||
|
||||
|
||||
_policy_engine: PolicyEngine | None = None
|
||||
|
||||
|
||||
def get_policy_engine() -> PolicyEngine:
|
||||
"""Get or create global policy engine instance."""
|
||||
global _policy_engine
|
||||
if _policy_engine is None:
|
||||
settings = get_settings()
|
||||
_policy_engine = PolicyEngine.from_yaml_file(settings.policy_file_path)
|
||||
return _policy_engine
|
||||
|
||||
|
||||
def reset_policy_engine() -> None:
|
||||
"""Reset global policy engine (mainly for tests)."""
|
||||
global _policy_engine
|
||||
_policy_engine = None
|
||||
@@ -1,151 +0,0 @@
|
||||
"""Shared, transport-agnostic tool registry.
|
||||
|
||||
This module is the single source of truth that maps each MCP tool name to its
|
||||
async handler. Both transport adapters consume it:
|
||||
|
||||
* the HTTP/OAuth server (``server.py``), and
|
||||
* the local stdio adapter (``stdio_app.py``).
|
||||
|
||||
Tool *definitions* (name, description, JSON schema, read/write flag) live in
|
||||
``mcp_protocol.AVAILABLE_TOOLS``; this module binds those names to callables and
|
||||
exposes lookup helpers so neither adapter duplicates the tool list. It imports
|
||||
only core modules and never the web stack, keeping the core importable without
|
||||
FastAPI installed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from aegis_gitea_mcp.gitea_client import GiteaClient
|
||||
from aegis_gitea_mcp.mcp_protocol import (
|
||||
AVAILABLE_TOOLS,
|
||||
MCPTool,
|
||||
get_tool_by_name,
|
||||
)
|
||||
from aegis_gitea_mcp.tools.raw_tools import raw_api_request_tool
|
||||
from aegis_gitea_mcp.tools.read_tools import (
|
||||
compare_refs_tool,
|
||||
get_branch_tool,
|
||||
get_commit_diff_tool,
|
||||
get_commit_status_tool,
|
||||
get_issue_tool,
|
||||
get_latest_release_tool,
|
||||
get_pull_request_tool,
|
||||
get_release_tool,
|
||||
get_repo_languages_tool,
|
||||
list_branches_tool,
|
||||
list_commits_tool,
|
||||
list_issue_comments_tool,
|
||||
list_issues_tool,
|
||||
list_labels_tool,
|
||||
list_milestones_tool,
|
||||
list_org_repositories_tool,
|
||||
list_organizations_tool,
|
||||
list_pull_request_commits_tool,
|
||||
list_pull_request_files_tool,
|
||||
list_pull_requests_tool,
|
||||
list_releases_tool,
|
||||
list_repo_topics_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_branch_tool,
|
||||
create_issue_comment_tool,
|
||||
create_issue_tool,
|
||||
create_label_tool,
|
||||
create_milestone_tool,
|
||||
create_pr_comment_tool,
|
||||
create_pull_request_tool,
|
||||
create_release_tool,
|
||||
edit_issue_comment_tool,
|
||||
edit_release_tool,
|
||||
remove_labels_tool,
|
||||
update_issue_tool,
|
||||
update_label_tool,
|
||||
)
|
||||
|
||||
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,
|
||||
"list_pull_request_files": list_pull_request_files_tool,
|
||||
"list_pull_request_commits": list_pull_request_commits_tool,
|
||||
"list_issue_comments": list_issue_comments_tool,
|
||||
"list_branches": list_branches_tool,
|
||||
"get_branch": get_branch_tool,
|
||||
"get_release": get_release_tool,
|
||||
"get_latest_release": get_latest_release_tool,
|
||||
"list_milestones": list_milestones_tool,
|
||||
"get_commit_status": get_commit_status_tool,
|
||||
"list_org_repositories": list_org_repositories_tool,
|
||||
"list_organizations": list_organizations_tool,
|
||||
"get_repo_languages": get_repo_languages_tool,
|
||||
"list_repo_topics": list_repo_topics_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,
|
||||
"create_label": create_label_tool,
|
||||
"update_label": update_label_tool,
|
||||
"remove_labels": remove_labels_tool,
|
||||
"create_pull_request": create_pull_request_tool,
|
||||
"create_release": create_release_tool,
|
||||
"edit_release": edit_release_tool,
|
||||
"create_branch": create_branch_tool,
|
||||
"create_milestone": create_milestone_tool,
|
||||
"edit_issue_comment": edit_issue_comment_tool,
|
||||
# Generic raw API dispatch (escape hatch). Registered as a read tool so GETs
|
||||
# work without write-mode; the handler authorizes writes per-method itself.
|
||||
"gitea_request": raw_api_request_tool,
|
||||
}
|
||||
|
||||
|
||||
def get_tool_handler(tool_name: str) -> ToolHandler | None:
|
||||
"""Return the async handler bound to a tool name, or None if unknown."""
|
||||
return TOOL_HANDLERS.get(tool_name)
|
||||
|
||||
|
||||
def list_tool_definitions() -> list[MCPTool]:
|
||||
"""Return all registered tool definitions (name, schema, read/write flag)."""
|
||||
return list(AVAILABLE_TOOLS)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AVAILABLE_TOOLS",
|
||||
"MCPTool",
|
||||
"ToolHandler",
|
||||
"TOOL_HANDLERS",
|
||||
"get_tool_by_name",
|
||||
"get_tool_handler",
|
||||
"list_tool_definitions",
|
||||
]
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,34 +0,0 @@
|
||||
"""Guarded console-script entry point for the HTTP/OAuth server.
|
||||
|
||||
The HTTP server (``aegis_gitea_mcp.server``) imports FastAPI/uvicorn at module
|
||||
load. Those live in the optional ``[server]`` extra, so a default (local-only)
|
||||
install would crash with a bare ``ModuleNotFoundError`` traceback if the
|
||||
``aegis-gitea-mcp-server`` script were invoked. This thin wrapper imports nothing
|
||||
from the web stack at module scope and degrades to an actionable message.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Run the HTTP server, or explain how to install the web stack."""
|
||||
try:
|
||||
import fastapi # noqa: F401
|
||||
import uvicorn # noqa: F401
|
||||
except ModuleNotFoundError as exc:
|
||||
print(
|
||||
"aegis-gitea-mcp-server requires the web stack, which is not installed.\n"
|
||||
"Install it with: pip install 'aegis-gitea-mcp[server]'",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(1) from exc
|
||||
|
||||
from aegis_gitea_mcp.server import main as server_main
|
||||
|
||||
server_main()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,267 +0,0 @@
|
||||
"""Local stdio transport adapter (``aegis-gitea-mcp``).
|
||||
|
||||
This is the second transport for the shared core: a single-user, local MCP
|
||||
server spoken over stdio using the official ``mcp`` SDK. It is meant to be run
|
||||
like ``uvx aegis-gitea-mcp`` and wired into Claude Desktop / Claude Code, mirror-
|
||||
ing the ergonomics of other local MCP servers.
|
||||
|
||||
Trust model
|
||||
-----------
|
||||
The local operator owns the Gitea Personal Access Token supplied via
|
||||
``GITEA_TOKEN``; there is no per-user OAuth. At startup the adapter resolves the
|
||||
PAT owner (``GET /user``) and pins the request context to that single login.
|
||||
Because the caller *is* the token owner, the per-user repository-permission
|
||||
probe used by the public HTTP server is intentionally skipped — but the policy
|
||||
engine, ``WRITE_MODE`` gate, secret sanitization and the tamper-evident audit
|
||||
log all run exactly as they do on the server. The same tools (including
|
||||
``gitea_request``) are served from the shared :mod:`aegis_gitea_mcp.registry`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from aegis_gitea_mcp.errors import ToolError
|
||||
|
||||
|
||||
class StdioConfigError(RuntimeError):
|
||||
"""Raised when the local environment is missing required configuration."""
|
||||
|
||||
|
||||
def _default_audit_log_path() -> Path:
|
||||
"""Return a writable per-user audit-log path for local runs.
|
||||
|
||||
The server's container default (``/var/log/aegis-mcp/audit.log``) is not
|
||||
writable on a typical workstation, so fall back to an OS-appropriate user
|
||||
state directory.
|
||||
"""
|
||||
if sys.platform == "win32":
|
||||
base = os.environ.get("LOCALAPPDATA") or str(Path.home() / "AppData" / "Local")
|
||||
return Path(base) / "aegis-gitea-mcp" / "audit.log"
|
||||
xdg_state = os.environ.get("XDG_STATE_HOME")
|
||||
base_dir = Path(xdg_state) if xdg_state else (Path.home() / ".local" / "state")
|
||||
return base_dir / "aegis-gitea-mcp" / "audit.log"
|
||||
|
||||
|
||||
def _bootstrap_env() -> None:
|
||||
"""Apply local-mode defaults to the environment before settings load.
|
||||
|
||||
Local mode has no OAuth and no API-key gate (the operator is the trusted PAT
|
||||
owner), and writes its audit log to a per-user path when one is not set. User
|
||||
overrides via real env vars or ``.env`` always win for everything else.
|
||||
"""
|
||||
# python-dotenv: load a local .env so GITEA_URL/GITEA_TOKEN can live there.
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
except Exception: # pragma: no cover - dotenv is a core dep, defensive only
|
||||
pass
|
||||
|
||||
# Local mode is single-user PAT auth: force OAuth off and disable the API-key
|
||||
# requirement so the server's API-key/OAuth config validation does not apply.
|
||||
os.environ["OAUTH_MODE"] = "false"
|
||||
os.environ.setdefault("AUTH_ENABLED", "false")
|
||||
os.environ.setdefault("STARTUP_VALIDATE_GITEA", "false")
|
||||
|
||||
if not os.environ.get("AUDIT_LOG_PATH", "").strip():
|
||||
os.environ["AUDIT_LOG_PATH"] = str(_default_audit_log_path())
|
||||
|
||||
|
||||
def _check_required_env() -> None:
|
||||
"""Fail with an actionable message when required env vars are missing."""
|
||||
missing = [
|
||||
name for name in ("GITEA_URL", "GITEA_TOKEN") if not os.environ.get(name, "").strip()
|
||||
]
|
||||
if missing:
|
||||
raise StdioConfigError(
|
||||
"Missing required environment variable(s): "
|
||||
+ ", ".join(missing)
|
||||
+ ".\nSet them in your environment or a local .env file, e.g.:\n"
|
||||
" GITEA_URL=https://gitea.example.com\n"
|
||||
" GITEA_TOKEN=<a Gitea personal access token>\n"
|
||||
)
|
||||
|
||||
|
||||
# The PAT owner login, resolved once at startup and pinned onto every dispatch.
|
||||
_owner_login: str | None = None
|
||||
|
||||
|
||||
async def _resolve_owner_login() -> str:
|
||||
"""Resolve and cache the Gitea login that owns the configured PAT."""
|
||||
from aegis_gitea_mcp.config import get_settings
|
||||
from aegis_gitea_mcp.gitea_client import GiteaClient
|
||||
|
||||
settings = get_settings()
|
||||
async with GiteaClient(token=settings.gitea_token) as gitea:
|
||||
user = await gitea.get_current_user()
|
||||
login = str(user.get("login", "")).strip()
|
||||
if not login:
|
||||
raise StdioConfigError(
|
||||
"Could not resolve the Gitea user for the supplied GITEA_TOKEN. "
|
||||
"Verify the token is valid and has API access."
|
||||
)
|
||||
return login
|
||||
|
||||
|
||||
async def _dispatch(tool_name: str, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Execute a tool with the same policy/audit/sanitize guarantees as the server.
|
||||
|
||||
The per-user repository-permission probe is intentionally omitted: the local
|
||||
operator is the PAT owner. Everything else — policy engine, ``WRITE_MODE``,
|
||||
the ``gitea_request`` per-method authorization, secret sanitization and audit
|
||||
logging — runs identically to the HTTP adapter.
|
||||
"""
|
||||
from aegis_gitea_mcp.audit import get_audit_logger
|
||||
from aegis_gitea_mcp.config import get_settings
|
||||
from aegis_gitea_mcp.gitea_client import GiteaClient
|
||||
from aegis_gitea_mcp.policy import get_policy_engine
|
||||
from aegis_gitea_mcp.registry import get_tool_by_name, get_tool_handler
|
||||
from aegis_gitea_mcp.request_context import set_gitea_user_login
|
||||
from aegis_gitea_mcp.security import sanitize_data
|
||||
from aegis_gitea_mcp.tools.arguments import extract_repository, extract_target_path
|
||||
|
||||
# Pin identity to the trusted PAT owner for every call (e.g. list_repositories
|
||||
# scopes its results to this login in service-PAT mode).
|
||||
if _owner_login:
|
||||
set_gitea_user_login(_owner_login)
|
||||
|
||||
settings = get_settings()
|
||||
audit = get_audit_logger()
|
||||
|
||||
tool_def = get_tool_by_name(tool_name)
|
||||
if tool_def is None:
|
||||
raise ToolError(f"Tool '{tool_name}' not found", status_code=404)
|
||||
handler = get_tool_handler(tool_name)
|
||||
if handler is None:
|
||||
raise ToolError(f"Tool '{tool_name}' has no handler implementation", status_code=500)
|
||||
|
||||
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)
|
||||
raise ToolError(f"Policy denied request: {decision.reason}", status_code=403)
|
||||
|
||||
correlation_id = audit.log_tool_invocation(tool_name=tool_name, params=arguments)
|
||||
async with GiteaClient(token=settings.gitea_token) as gitea:
|
||||
result = await handler(gitea, arguments)
|
||||
|
||||
if settings.secret_detection_mode != "off":
|
||||
result = sanitize_data(result, mode=settings.secret_detection_mode)
|
||||
|
||||
audit.log_tool_invocation(
|
||||
tool_name=tool_name, correlation_id=correlation_id, result_status="success"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _configure_stderr_logging() -> None:
|
||||
"""Pin all logging to stderr so the stdout JSON-RPC channel stays clean.
|
||||
|
||||
The stdio MCP transport speaks JSON-RPC over stdout; a single stray log line
|
||||
on stdout corrupts the stream and breaks the client. ``configure_logging``
|
||||
already targets stderr, but we additionally rewrite any handler that points
|
||||
at stdout (e.g. a library that called ``basicConfig``) so nothing can leak.
|
||||
"""
|
||||
from aegis_gitea_mcp.config import get_settings
|
||||
from aegis_gitea_mcp.logging_utils import configure_logging
|
||||
|
||||
configure_logging(get_settings().log_level)
|
||||
root = logging.getLogger()
|
||||
for handler in root.handlers:
|
||||
if isinstance(handler, logging.StreamHandler) and handler.stream is sys.stdout:
|
||||
handler.setStream(sys.stderr)
|
||||
|
||||
|
||||
def build_server() -> Any:
|
||||
"""Build (but do not run) the stdio MCP ``Server`` from the shared registry.
|
||||
|
||||
Kept separate from :func:`_serve` so it can be driven in-process by tests
|
||||
over an in-memory transport without opening real stdio streams.
|
||||
"""
|
||||
import mcp.types as mcp_types
|
||||
from mcp.server import Server
|
||||
|
||||
from aegis_gitea_mcp.registry import list_tool_definitions
|
||||
|
||||
server: Any = Server("aegis-gitea-mcp")
|
||||
|
||||
@server.list_tools()
|
||||
async def list_tools() -> list[mcp_types.Tool]:
|
||||
return [
|
||||
mcp_types.Tool(
|
||||
name=tool.name,
|
||||
description=tool.description,
|
||||
inputSchema=tool.input_schema,
|
||||
)
|
||||
for tool in list_tool_definitions()
|
||||
]
|
||||
|
||||
@server.call_tool()
|
||||
async def call_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
# Returning a dict yields structured content plus a JSON text block.
|
||||
return await _dispatch(name, arguments)
|
||||
|
||||
return server
|
||||
|
||||
|
||||
async def _serve() -> None:
|
||||
"""Resolve identity and serve the stdio MCP server over real stdin/stdout."""
|
||||
from mcp.server.stdio import stdio_server
|
||||
|
||||
from aegis_gitea_mcp.config import get_settings
|
||||
from aegis_gitea_mcp.policy import get_policy_engine
|
||||
|
||||
# Fail fast on bad settings/policy before opening the transport.
|
||||
get_settings()
|
||||
get_policy_engine()
|
||||
|
||||
global _owner_login
|
||||
_owner_login = await _resolve_owner_login()
|
||||
|
||||
server = build_server()
|
||||
async with stdio_server() as (read_stream, write_stream):
|
||||
await server.run(read_stream, write_stream, server.create_initialization_options())
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Console-script entry point for the local stdio MCP server."""
|
||||
_bootstrap_env()
|
||||
try:
|
||||
_check_required_env()
|
||||
except StdioConfigError as exc:
|
||||
print(f"aegis-gitea-mcp: {exc}", file=sys.stderr)
|
||||
raise SystemExit(2) from exc
|
||||
|
||||
try:
|
||||
from aegis_gitea_mcp.config import get_settings
|
||||
|
||||
get_settings()
|
||||
except Exception as exc: # pydantic ValidationError or PolicyError
|
||||
print(f"aegis-gitea-mcp: invalid configuration: {exc}", file=sys.stderr)
|
||||
raise SystemExit(2) from exc
|
||||
|
||||
# Keep stdout reserved for the JSON-RPC stream; all logs go to stderr.
|
||||
_configure_stderr_logging()
|
||||
|
||||
try:
|
||||
asyncio.run(_serve())
|
||||
except StdioConfigError as exc:
|
||||
print(f"aegis-gitea-mcp: {exc}", file=sys.stderr)
|
||||
raise SystemExit(2) from exc
|
||||
except KeyboardInterrupt: # pragma: no cover - interactive shutdown
|
||||
pass
|
||||
|
||||
|
||||
__all__ = ["main", "StdioConfigError"]
|
||||
@@ -1,53 +0,0 @@
|
||||
"""MCP tool implementation exports."""
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"list_repositories_tool",
|
||||
"get_repository_info_tool",
|
||||
"get_file_tree_tool",
|
||||
"get_file_contents_tool",
|
||||
"search_code_tool",
|
||||
"list_commits_tool",
|
||||
"get_commit_diff_tool",
|
||||
"compare_refs_tool",
|
||||
"list_issues_tool",
|
||||
"get_issue_tool",
|
||||
"list_pull_requests_tool",
|
||||
"get_pull_request_tool",
|
||||
"list_labels_tool",
|
||||
"list_tags_tool",
|
||||
"list_releases_tool",
|
||||
"create_issue_tool",
|
||||
"update_issue_tool",
|
||||
"create_issue_comment_tool",
|
||||
"create_pr_comment_tool",
|
||||
"add_labels_tool",
|
||||
"assign_issue_tool",
|
||||
]
|
||||
@@ -1,689 +0,0 @@
|
||||
"""Pydantic argument models for MCP tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Annotated, Any, Literal
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from pydantic import (
|
||||
AfterValidator,
|
||||
BaseModel,
|
||||
BeforeValidator,
|
||||
ConfigDict,
|
||||
Field,
|
||||
field_validator,
|
||||
model_validator,
|
||||
)
|
||||
|
||||
_REPO_PART_PATTERN = r"^[A-Za-z0-9._-]{1,100}$"
|
||||
|
||||
|
||||
def _validate_git_ref(value: str) -> str:
|
||||
"""Validate a git ref-like value (ref/sha/base/head) against traversal.
|
||||
|
||||
Refs that legitimately contain ``/`` (e.g. ``feature/foo``, ``release/1.0``)
|
||||
are preserved; only traversal and unsafe URL-path characters are rejected.
|
||||
|
||||
Args:
|
||||
value: Candidate ref, sha, base, or head value.
|
||||
|
||||
Returns:
|
||||
The unchanged value when it is safe.
|
||||
|
||||
Raises:
|
||||
ValueError: When the value could escape the intended repository path.
|
||||
"""
|
||||
# Security decision: block path traversal and absolute references.
|
||||
if ".." in value.split("/"):
|
||||
raise ValueError("ref must not contain '..' path segments")
|
||||
if value.startswith("/"):
|
||||
raise ValueError("ref must not start with '/'")
|
||||
if "\\" in value:
|
||||
raise ValueError("ref must not contain backslashes")
|
||||
if "\x00" in value:
|
||||
raise ValueError("ref must not contain null bytes")
|
||||
if any(ord(char) < 0x20 or ord(char) == 0x7F for char in value):
|
||||
raise ValueError("ref must not contain control characters")
|
||||
if any(char.isspace() for char in value):
|
||||
raise ValueError("ref must not contain whitespace")
|
||||
if "?" in value or "#" in value:
|
||||
raise ValueError("ref must not contain '?' or '#'")
|
||||
return value
|
||||
|
||||
|
||||
GitRef = Annotated[str, AfterValidator(_validate_git_ref)]
|
||||
|
||||
|
||||
def _validate_milestone(value: object) -> int | str:
|
||||
"""Validate a milestone reference supplied as a numeric id or a title.
|
||||
|
||||
An integer is treated as a milestone id (``0`` clears the milestone on
|
||||
update); a string is treated as a milestone title to resolve. Runs as a
|
||||
``BeforeValidator`` so ``bool`` (a subclass of ``int`` that Pydantic would
|
||||
otherwise coerce to ``1``/``0``) is rejected on the raw input.
|
||||
"""
|
||||
if isinstance(value, bool):
|
||||
raise ValueError("milestone must be a milestone id or title")
|
||||
if isinstance(value, int):
|
||||
if value < 0:
|
||||
raise ValueError("milestone id must be >= 0")
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
title = value.strip()
|
||||
if not title:
|
||||
raise ValueError("milestone title must not be empty")
|
||||
if len(title) > 256:
|
||||
raise ValueError("milestone title must not exceed 256 characters")
|
||||
return title
|
||||
raise ValueError("milestone must be a milestone id or title")
|
||||
|
||||
|
||||
MilestoneRef = Annotated[int | str, BeforeValidator(_validate_milestone)]
|
||||
|
||||
|
||||
class StrictBaseModel(BaseModel):
|
||||
"""Strict model base that rejects unexpected fields."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class ListRepositoriesArgs(StrictBaseModel):
|
||||
"""Arguments for list_repositories tool."""
|
||||
|
||||
|
||||
class RepositoryArgs(StrictBaseModel):
|
||||
"""Common repository locator arguments."""
|
||||
|
||||
owner: str = Field(..., pattern=_REPO_PART_PATTERN)
|
||||
repo: str = Field(..., pattern=_REPO_PART_PATTERN)
|
||||
|
||||
|
||||
class FileTreeArgs(RepositoryArgs):
|
||||
"""Arguments for get_file_tree."""
|
||||
|
||||
ref: GitRef = Field(default="main", min_length=1, max_length=200)
|
||||
recursive: bool = Field(default=False)
|
||||
|
||||
|
||||
class FileContentsArgs(RepositoryArgs):
|
||||
"""Arguments for get_file_contents."""
|
||||
|
||||
filepath: str = Field(..., min_length=1, max_length=1024)
|
||||
ref: GitRef = Field(default="main", min_length=1, max_length=200)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_filepath(self) -> FileContentsArgs:
|
||||
"""Validate path safety constraints."""
|
||||
normalized = self.filepath.replace("\\", "/")
|
||||
# Security decision: block traversal and absolute paths.
|
||||
if normalized.startswith("/") or ".." in normalized.split("/"):
|
||||
raise ValueError("filepath must be a relative path without traversal")
|
||||
if "\x00" in normalized:
|
||||
raise ValueError("filepath cannot contain null bytes")
|
||||
return self
|
||||
|
||||
|
||||
class SearchCodeArgs(RepositoryArgs):
|
||||
"""Arguments for search_code."""
|
||||
|
||||
query: str = Field(..., min_length=1, max_length=256)
|
||||
ref: GitRef = Field(default="main", min_length=1, max_length=200)
|
||||
page: int = Field(default=1, ge=1, le=10_000)
|
||||
limit: int = Field(default=25, ge=1, le=100)
|
||||
|
||||
|
||||
class ListCommitsArgs(RepositoryArgs):
|
||||
"""Arguments for list_commits."""
|
||||
|
||||
ref: GitRef = Field(default="main", min_length=1, max_length=200)
|
||||
page: int = Field(default=1, ge=1, le=10_000)
|
||||
limit: int = Field(default=25, ge=1, le=100)
|
||||
|
||||
|
||||
class CommitDiffArgs(RepositoryArgs):
|
||||
"""Arguments for get_commit_diff."""
|
||||
|
||||
sha: GitRef = Field(..., min_length=7, max_length=64)
|
||||
|
||||
|
||||
class CompareRefsArgs(RepositoryArgs):
|
||||
"""Arguments for compare_refs."""
|
||||
|
||||
base: GitRef = Field(..., min_length=1, max_length=200)
|
||||
head: GitRef = Field(..., min_length=1, max_length=200)
|
||||
|
||||
|
||||
class ListIssuesArgs(RepositoryArgs):
|
||||
"""Arguments for list_issues."""
|
||||
|
||||
state: Literal["open", "closed", "all"] = Field(default="open")
|
||||
page: int = Field(default=1, ge=1, le=10_000)
|
||||
limit: int = Field(default=25, ge=1, le=100)
|
||||
labels: list[str] = Field(default_factory=list, max_length=20)
|
||||
|
||||
|
||||
class IssueArgs(RepositoryArgs):
|
||||
"""Arguments for get_issue."""
|
||||
|
||||
issue_number: int = Field(..., ge=1)
|
||||
|
||||
|
||||
class ListPullRequestsArgs(RepositoryArgs):
|
||||
"""Arguments for list_pull_requests."""
|
||||
|
||||
state: Literal["open", "closed", "all"] = Field(default="open")
|
||||
page: int = Field(default=1, ge=1, le=10_000)
|
||||
limit: int = Field(default=25, ge=1, le=100)
|
||||
|
||||
|
||||
class PullRequestArgs(RepositoryArgs):
|
||||
"""Arguments for get_pull_request."""
|
||||
|
||||
pull_number: int = Field(..., ge=1)
|
||||
|
||||
|
||||
class ListLabelsArgs(RepositoryArgs):
|
||||
"""Arguments for list_labels."""
|
||||
|
||||
page: int = Field(default=1, ge=1, le=10_000)
|
||||
limit: int = Field(default=50, ge=1, le=100)
|
||||
|
||||
|
||||
class ListTagsArgs(RepositoryArgs):
|
||||
"""Arguments for list_tags."""
|
||||
|
||||
page: int = Field(default=1, ge=1, le=10_000)
|
||||
limit: int = Field(default=50, ge=1, le=100)
|
||||
|
||||
|
||||
class ListReleasesArgs(RepositoryArgs):
|
||||
"""Arguments for list_releases."""
|
||||
|
||||
page: int = Field(default=1, ge=1, le=10_000)
|
||||
limit: int = Field(default=25, ge=1, le=100)
|
||||
|
||||
|
||||
class CreateIssueArgs(RepositoryArgs):
|
||||
"""Arguments for create_issue."""
|
||||
|
||||
title: str = Field(..., min_length=1, max_length=256)
|
||||
body: str = Field(default="", max_length=20_000)
|
||||
labels: list[str] = Field(default_factory=list, max_length=20)
|
||||
assignees: list[str] = Field(default_factory=list, max_length=20)
|
||||
milestone: MilestoneRef | None = Field(
|
||||
default=None, description="Milestone id or title to assign the issue to"
|
||||
)
|
||||
|
||||
|
||||
class UpdateIssueArgs(RepositoryArgs):
|
||||
"""Arguments for update_issue."""
|
||||
|
||||
issue_number: int = Field(..., ge=1)
|
||||
title: str | None = Field(default=None, min_length=1, max_length=256)
|
||||
body: str | None = Field(default=None, max_length=20_000)
|
||||
state: Literal["open", "closed"] | None = Field(default=None)
|
||||
milestone: MilestoneRef | None = Field(
|
||||
default=None, description="Milestone id or title to assign; 0 clears the milestone"
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_change(self) -> UpdateIssueArgs:
|
||||
"""Require at least one mutable field in update payload."""
|
||||
if (
|
||||
self.title is None
|
||||
and self.body is None
|
||||
and self.state is None
|
||||
and self.milestone is None
|
||||
):
|
||||
raise ValueError("At least one of title, body, state, or milestone must be provided")
|
||||
return self
|
||||
|
||||
|
||||
class CreateIssueCommentArgs(RepositoryArgs):
|
||||
"""Arguments for create_issue_comment."""
|
||||
|
||||
issue_number: int = Field(..., ge=1)
|
||||
body: str = Field(..., min_length=1, max_length=10_000)
|
||||
|
||||
|
||||
class CreatePrCommentArgs(RepositoryArgs):
|
||||
"""Arguments for create_pr_comment."""
|
||||
|
||||
pull_number: int = Field(..., ge=1)
|
||||
body: str = Field(..., min_length=1, max_length=10_000)
|
||||
|
||||
|
||||
class AddLabelsArgs(RepositoryArgs):
|
||||
"""Arguments for add_labels."""
|
||||
|
||||
issue_number: int = Field(..., ge=1)
|
||||
labels: list[str] = Field(..., min_length=1, max_length=20)
|
||||
|
||||
|
||||
class AssignIssueArgs(RepositoryArgs):
|
||||
"""Arguments for assign_issue."""
|
||||
|
||||
issue_number: int = Field(..., ge=1)
|
||||
assignees: list[str] = Field(..., min_length=1, max_length=20)
|
||||
|
||||
|
||||
class CreateLabelArgs(RepositoryArgs):
|
||||
"""Arguments for create_label."""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=50)
|
||||
# Gitea requires a hex color; accept it with or without a leading '#'.
|
||||
color: str = Field(..., pattern=r"^#?[0-9A-Fa-f]{6}$")
|
||||
description: str = Field(default="", max_length=1000)
|
||||
exclusive: bool = Field(default=False)
|
||||
|
||||
|
||||
class UpdateLabelArgs(RepositoryArgs):
|
||||
"""Arguments for update_label (located by current name)."""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=50)
|
||||
new_name: str | None = Field(default=None, min_length=1, max_length=50)
|
||||
color: str | None = Field(default=None, pattern=r"^#?[0-9A-Fa-f]{6}$")
|
||||
description: str | None = Field(default=None, max_length=1000)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_change(self) -> UpdateLabelArgs:
|
||||
"""Require at least one mutable field in the update payload."""
|
||||
if self.new_name is None and self.color is None and self.description is None:
|
||||
raise ValueError("At least one of new_name, color, or description must be provided")
|
||||
return self
|
||||
|
||||
|
||||
class RemoveLabelsArgs(RepositoryArgs):
|
||||
"""Arguments for remove_labels."""
|
||||
|
||||
issue_number: int = Field(..., ge=1)
|
||||
labels: list[str] = Field(..., min_length=1, max_length=20)
|
||||
|
||||
|
||||
class CreatePullRequestArgs(RepositoryArgs):
|
||||
"""Arguments for create_pull_request."""
|
||||
|
||||
title: str = Field(..., min_length=1, max_length=256)
|
||||
head: GitRef = Field(..., min_length=1, max_length=200)
|
||||
base: GitRef = Field(..., min_length=1, max_length=200)
|
||||
body: str = Field(default="", max_length=20_000)
|
||||
|
||||
|
||||
class CreateReleaseArgs(RepositoryArgs):
|
||||
"""Arguments for create_release."""
|
||||
|
||||
tag_name: GitRef = Field(..., min_length=1, max_length=200)
|
||||
name: str = Field(default="", max_length=256)
|
||||
body: str = Field(default="", max_length=20_000)
|
||||
draft: bool = Field(default=False)
|
||||
prerelease: bool = Field(default=False)
|
||||
target: str | None = Field(default=None, min_length=1, max_length=200)
|
||||
|
||||
|
||||
class EditReleaseArgs(RepositoryArgs):
|
||||
"""Arguments for edit_release."""
|
||||
|
||||
release_id: int = Field(..., ge=1)
|
||||
name: str | None = Field(default=None, max_length=256)
|
||||
body: str | None = Field(default=None, max_length=20_000)
|
||||
draft: bool | None = Field(default=None)
|
||||
prerelease: bool | None = Field(default=None)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_change(self) -> EditReleaseArgs:
|
||||
"""Require at least one mutable field in the update payload."""
|
||||
if (
|
||||
self.name is None
|
||||
and self.body is None
|
||||
and self.draft is None
|
||||
and self.prerelease is None
|
||||
):
|
||||
raise ValueError("At least one of name, body, draft, or prerelease must be provided")
|
||||
return self
|
||||
|
||||
|
||||
class CreateBranchArgs(RepositoryArgs):
|
||||
"""Arguments for create_branch."""
|
||||
|
||||
new_branch_name: GitRef = Field(..., min_length=1, max_length=200)
|
||||
old_branch_name: str | None = Field(default=None, min_length=1, max_length=200)
|
||||
|
||||
|
||||
class CreateMilestoneArgs(RepositoryArgs):
|
||||
"""Arguments for create_milestone."""
|
||||
|
||||
title: str = Field(..., min_length=1, max_length=256)
|
||||
description: str = Field(default="", max_length=10_000)
|
||||
due_on: str | None = Field(default=None, max_length=64)
|
||||
|
||||
|
||||
class EditIssueCommentArgs(RepositoryArgs):
|
||||
"""Arguments for edit_issue_comment."""
|
||||
|
||||
comment_id: int = Field(..., ge=1)
|
||||
body: str = Field(..., min_length=1, max_length=10_000)
|
||||
|
||||
|
||||
class ListPullRequestFilesArgs(RepositoryArgs):
|
||||
"""Arguments for list_pull_request_files."""
|
||||
|
||||
pull_number: int = Field(..., ge=1)
|
||||
page: int = Field(default=1, ge=1, le=10_000)
|
||||
limit: int = Field(default=50, ge=1, le=100)
|
||||
|
||||
|
||||
class ListPullRequestCommitsArgs(RepositoryArgs):
|
||||
"""Arguments for list_pull_request_commits."""
|
||||
|
||||
pull_number: int = Field(..., ge=1)
|
||||
page: int = Field(default=1, ge=1, le=10_000)
|
||||
limit: int = Field(default=50, ge=1, le=100)
|
||||
|
||||
|
||||
class ListIssueCommentsArgs(RepositoryArgs):
|
||||
"""Arguments for list_issue_comments."""
|
||||
|
||||
issue_number: int = Field(..., ge=1)
|
||||
page: int = Field(default=1, ge=1, le=10_000)
|
||||
limit: int = Field(default=50, ge=1, le=100)
|
||||
|
||||
|
||||
class ListBranchesArgs(RepositoryArgs):
|
||||
"""Arguments for list_branches."""
|
||||
|
||||
page: int = Field(default=1, ge=1, le=10_000)
|
||||
limit: int = Field(default=50, ge=1, le=100)
|
||||
|
||||
|
||||
class GetBranchArgs(RepositoryArgs):
|
||||
"""Arguments for get_branch."""
|
||||
|
||||
branch: GitRef = Field(..., min_length=1, max_length=200)
|
||||
|
||||
|
||||
class GetReleaseArgs(RepositoryArgs):
|
||||
"""Arguments for get_release."""
|
||||
|
||||
release_id: int = Field(..., ge=1)
|
||||
|
||||
|
||||
class LatestReleaseArgs(RepositoryArgs):
|
||||
"""Arguments for get_latest_release."""
|
||||
|
||||
|
||||
class ListMilestonesArgs(RepositoryArgs):
|
||||
"""Arguments for list_milestones."""
|
||||
|
||||
state: Literal["open", "closed", "all"] = Field(default="open")
|
||||
page: int = Field(default=1, ge=1, le=10_000)
|
||||
limit: int = Field(default=50, ge=1, le=100)
|
||||
|
||||
|
||||
class CommitStatusArgs(RepositoryArgs):
|
||||
"""Arguments for get_commit_status."""
|
||||
|
||||
sha: GitRef = Field(..., min_length=1, max_length=64)
|
||||
|
||||
|
||||
class ListOrgRepositoriesArgs(StrictBaseModel):
|
||||
"""Arguments for list_org_repositories."""
|
||||
|
||||
org: str = Field(..., pattern=_REPO_PART_PATTERN)
|
||||
page: int = Field(default=1, ge=1, le=10_000)
|
||||
limit: int = Field(default=50, ge=1, le=100)
|
||||
|
||||
|
||||
class ListOrganizationsArgs(StrictBaseModel):
|
||||
"""Arguments for list_organizations."""
|
||||
|
||||
page: int = Field(default=1, ge=1, le=10_000)
|
||||
limit: int = Field(default=50, ge=1, le=100)
|
||||
|
||||
|
||||
class RepoLanguagesArgs(RepositoryArgs):
|
||||
"""Arguments for get_repo_languages."""
|
||||
|
||||
|
||||
class RepoTopicsArgs(RepositoryArgs):
|
||||
"""Arguments for list_repo_topics."""
|
||||
|
||||
|
||||
# --- Raw API dispatch (gitea_request escape hatch) -------------------------
|
||||
|
||||
# HTTP methods the generic dispatch tool accepts. Everything outside GET/HEAD is
|
||||
# treated as a write so the policy/write-mode gate applies.
|
||||
RAW_API_METHODS = ("GET", "HEAD", "POST", "PUT", "PATCH", "DELETE")
|
||||
_RAW_WRITE_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
|
||||
|
||||
# Path segments/subpaths blocked for *every* method unless explicitly overridden
|
||||
# via RAW_API_ALLOW_SENSITIVE. A GET on these already leaks credentials or
|
||||
# privileged configuration, so they are denied independently of policy.yaml.
|
||||
_RAW_SENSITIVE_SEGMENTS = frozenset({"admin", "tokens", "secrets", "hooks", "keys", "gpg_keys"})
|
||||
_RAW_SENSITIVE_SUBPATHS = ("applications/oauth2", "actions/runners/registration-token")
|
||||
|
||||
# Endpoints under /repos/ that are not scoped to a single repository.
|
||||
_RAW_CROSS_REPO_OWNERS = frozenset({"search", "issues"})
|
||||
|
||||
# Resources whose trailing segments form a file path target for policy checks.
|
||||
_RAW_FILE_RESOURCES = frozenset({"contents", "raw", "media"})
|
||||
|
||||
# Known top-level segments of the Gitea ``/api/v1`` surface. A raw request whose
|
||||
# first path segment is not in this set is rejected (fail closed): we never pass
|
||||
# an unrecognized path straight through to Gitea.
|
||||
KNOWN_API_PREFIXES = frozenset(
|
||||
{
|
||||
"activitypub",
|
||||
"admin",
|
||||
"gitignore",
|
||||
"issues",
|
||||
"label",
|
||||
"licenses",
|
||||
"markdown",
|
||||
"markup",
|
||||
"miscellaneous",
|
||||
"nodeinfo",
|
||||
"notifications",
|
||||
"org",
|
||||
"orgs",
|
||||
"packages",
|
||||
"repos",
|
||||
"repositories",
|
||||
"settings",
|
||||
"signing-key.gpg",
|
||||
"teams",
|
||||
"topics",
|
||||
"user",
|
||||
"users",
|
||||
"version",
|
||||
}
|
||||
)
|
||||
|
||||
# Override table: provably side-effect-free POSTs that may be treated as reads so
|
||||
# they do not needlessly require WRITE_MODE. This table may ONLY ever DOWNGRADE a
|
||||
# write to a read for endpoints that render content and mutate nothing — never
|
||||
# the reverse. Keyed by the final path segment of the endpoint.
|
||||
_RAW_READ_ONLY_POST_LEAVES = frozenset({"markdown", "markup", "raw"})
|
||||
|
||||
|
||||
def raw_is_known_api_path(endpoint: str) -> bool:
|
||||
"""Return whether the endpoint's top segment is a known Gitea API prefix."""
|
||||
return raw_top_segment(endpoint) in KNOWN_API_PREFIXES
|
||||
|
||||
|
||||
def raw_request_is_write(method: str, endpoint: str) -> bool:
|
||||
"""Classify a raw request as read or write from its method and path.
|
||||
|
||||
``GET``/``HEAD`` are reads; every other method is a write — except for the
|
||||
small, explicit override table of render-only POSTs (e.g. markdown/markup),
|
||||
which are reads. The override can only make a request *more* permissive for
|
||||
provably side-effect-free endpoints; it never reclassifies a mutating call as
|
||||
a read, so a misclassified write cannot slip past the write-mode gate.
|
||||
"""
|
||||
upper = method.upper()
|
||||
if upper in {"GET", "HEAD"}:
|
||||
return False
|
||||
if upper == "POST":
|
||||
rel = _raw_relative_segments(endpoint)
|
||||
if rel and rel[-1] in _RAW_READ_ONLY_POST_LEAVES:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def normalize_raw_endpoint(path: str) -> str:
|
||||
"""Normalize a raw API path into an ``/api/v1``-prefixed endpoint.
|
||||
|
||||
Accepts a bare path (``/repos/o/r``), an already-prefixed path
|
||||
(``/api/v1/repos/o/r``), or a full URL (the scheme/host and any query string
|
||||
are stripped — the separate ``query`` argument carries query parameters).
|
||||
|
||||
Raises:
|
||||
ValueError: When the path contains a ``..`` traversal segment.
|
||||
"""
|
||||
candidate = path.strip()
|
||||
split = urlsplit(candidate)
|
||||
# When a full URL is supplied, keep only its path component.
|
||||
raw_path = split.path if (split.scheme or split.netloc) else candidate
|
||||
# Drop any query/fragment a caller may have inlined into the path string.
|
||||
raw_path = raw_path.split("?", 1)[0].split("#", 1)[0]
|
||||
raw_path = raw_path.replace("\\", "/")
|
||||
segments = [seg for seg in raw_path.split("/") if seg and seg != "."]
|
||||
if any(seg == ".." for seg in segments):
|
||||
raise ValueError("path must not contain '..' traversal segments")
|
||||
rel_segments = segments[2:] if segments[:2] == ["api", "v1"] else segments
|
||||
if not rel_segments:
|
||||
return "/api/v1"
|
||||
return "/api/v1/" + "/".join(rel_segments)
|
||||
|
||||
|
||||
def _raw_relative_segments(endpoint: str) -> list[str]:
|
||||
"""Return the endpoint segments after the ``/api/v1`` prefix."""
|
||||
segments = [seg for seg in endpoint.split("/") if seg]
|
||||
return segments[2:] if segments[:2] == ["api", "v1"] else segments
|
||||
|
||||
|
||||
def raw_relative_segments(endpoint: str) -> list[str]:
|
||||
"""Return the endpoint path segments after the ``/api/v1`` prefix (public)."""
|
||||
return _raw_relative_segments(endpoint)
|
||||
|
||||
|
||||
def raw_top_segment(endpoint: str) -> str:
|
||||
"""Return the first path segment after ``/api/v1`` for coarse policy grouping."""
|
||||
rel = _raw_relative_segments(endpoint)
|
||||
return rel[0] if rel else ""
|
||||
|
||||
|
||||
def raw_method_is_write(method: str) -> bool:
|
||||
"""Return whether an HTTP method mutates state."""
|
||||
return method.upper() in _RAW_WRITE_METHODS
|
||||
|
||||
|
||||
def raw_is_sensitive(endpoint: str) -> bool:
|
||||
"""Return whether an endpoint touches an admin/credential surface."""
|
||||
rel = _raw_relative_segments(endpoint)
|
||||
if any(seg in _RAW_SENSITIVE_SEGMENTS for seg in rel):
|
||||
return True
|
||||
joined = "/".join(rel)
|
||||
return any(sub in joined for sub in _RAW_SENSITIVE_SUBPATHS)
|
||||
|
||||
|
||||
def _raw_repo_segments(endpoint: str) -> list[str] | None:
|
||||
"""Return ``[owner, repo, *rest]`` for a single-repository endpoint, else None."""
|
||||
rel = _raw_relative_segments(endpoint)
|
||||
if len(rel) < 3 or rel[0] != "repos":
|
||||
return None
|
||||
owner, repo = rel[1], rel[2]
|
||||
if owner in _RAW_CROSS_REPO_OWNERS:
|
||||
return None
|
||||
if not (re.match(_REPO_PART_PATTERN, owner) and re.match(_REPO_PART_PATTERN, repo)):
|
||||
return None
|
||||
return [owner, repo, *rel[3:]]
|
||||
|
||||
|
||||
def parse_raw_repository(endpoint: str) -> str | None:
|
||||
"""Parse ``owner/repo`` from a repo-scoped endpoint; None for cross-repo paths."""
|
||||
repo_segments = _raw_repo_segments(endpoint)
|
||||
if repo_segments is None:
|
||||
return None
|
||||
return f"{repo_segments[0]}/{repo_segments[1]}"
|
||||
|
||||
|
||||
def parse_raw_target_path(endpoint: str) -> str | None:
|
||||
"""Parse a file-path target from ``contents``/``raw``/``media`` endpoints."""
|
||||
repo_segments = _raw_repo_segments(endpoint)
|
||||
if repo_segments is None or len(repo_segments) < 4:
|
||||
return None
|
||||
if repo_segments[2] not in _RAW_FILE_RESOURCES:
|
||||
return None
|
||||
file_path = "/".join(repo_segments[3:])
|
||||
return file_path or None
|
||||
|
||||
|
||||
class RawApiRequestArgs(StrictBaseModel):
|
||||
"""Arguments for the generic ``gitea_request`` escape-hatch tool."""
|
||||
|
||||
method: Literal["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"] = Field(
|
||||
..., description="HTTP method"
|
||||
)
|
||||
path: str = Field(..., min_length=1, max_length=2048, description="Gitea REST path")
|
||||
query: dict[str, Any] | None = Field(
|
||||
default=None, description="Optional query-string parameters"
|
||||
)
|
||||
body: dict[str, Any] | None = Field(default=None, description="Optional JSON request body")
|
||||
|
||||
@field_validator("method", mode="before")
|
||||
@classmethod
|
||||
def _normalize_method(cls, value: object) -> object:
|
||||
"""Uppercase the method before enum validation so 'get' is accepted."""
|
||||
if isinstance(value, str):
|
||||
return value.strip().upper()
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_path(self) -> RawApiRequestArgs:
|
||||
"""Reject path traversal up front so the handler sees a clean endpoint."""
|
||||
normalize_raw_endpoint(self.path)
|
||||
return self
|
||||
|
||||
|
||||
def extract_repository(arguments: dict[str, object]) -> str | None:
|
||||
"""Extract `owner/repo` from raw argument mapping.
|
||||
|
||||
Args:
|
||||
arguments: Raw tool arguments.
|
||||
|
||||
Returns:
|
||||
`owner/repo` or None when arguments are incomplete.
|
||||
"""
|
||||
owner = arguments.get("owner")
|
||||
repo = arguments.get("repo")
|
||||
if isinstance(owner, str) and isinstance(repo, str) and owner and repo:
|
||||
return f"{owner}/{repo}"
|
||||
# Raw API dispatch: derive the repository from the request path so the central
|
||||
# policy gate and the service-PAT per-user permission check evaluate the real
|
||||
# target instead of treating every raw call as repo-less.
|
||||
path = arguments.get("path")
|
||||
method = arguments.get("method")
|
||||
if isinstance(path, str) and isinstance(method, str):
|
||||
try:
|
||||
return parse_raw_repository(normalize_raw_endpoint(path))
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def extract_target_path(arguments: dict[str, object]) -> str | None:
|
||||
"""Extract optional target path argument for policy path checks."""
|
||||
filepath = arguments.get("filepath")
|
||||
if isinstance(filepath, str) and filepath:
|
||||
return filepath
|
||||
# Raw API dispatch: expose the file path embedded in contents/raw/media
|
||||
# endpoints so repository path allow/deny rules still apply to raw calls.
|
||||
path = arguments.get("path")
|
||||
method = arguments.get("method")
|
||||
if isinstance(path, str) and isinstance(method, str):
|
||||
try:
|
||||
return parse_raw_target_path(normalize_raw_endpoint(path))
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
@@ -1,141 +0,0 @@
|
||||
"""Generic raw Gitea REST dispatch tool (escape hatch).
|
||||
|
||||
``gitea_request`` exposes the long tail of the Gitea API that the curated, typed
|
||||
tools do not cover. A single tool surface would normally collapse the
|
||||
granularity of ``policy.yaml``, so this handler re-derives a coarse virtual tool
|
||||
name (``gitea_request:<METHOD>:<top-segment>``) and the target repository/path
|
||||
from each request and runs them back through the policy engine. That reuses the
|
||||
existing write-mode + write-whitelist enforcement and keeps per-method/per-repo
|
||||
policy control intact behind the single tool.
|
||||
|
||||
Two layers of authorization apply:
|
||||
|
||||
* The central dispatch gate in ``server.py`` allows/denies the registered
|
||||
``gitea_request`` name and, in service-PAT mode, verifies the signed-in user's
|
||||
permission on the parsed repository.
|
||||
* This handler then authorizes the fine-grained virtual tool name and enforces a
|
||||
built-in admin/credential denylist that ``policy.yaml`` cannot re-open.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from aegis_gitea_mcp.audit import get_audit_logger
|
||||
from aegis_gitea_mcp.config import get_settings
|
||||
from aegis_gitea_mcp.errors import ToolError
|
||||
from aegis_gitea_mcp.gitea_client import (
|
||||
GiteaAuthenticationError,
|
||||
GiteaAuthorizationError,
|
||||
GiteaClient,
|
||||
GiteaError,
|
||||
)
|
||||
from aegis_gitea_mcp.policy import get_policy_engine
|
||||
from aegis_gitea_mcp.response_limits import limit_items, limit_text
|
||||
from aegis_gitea_mcp.tools.arguments import (
|
||||
RawApiRequestArgs,
|
||||
normalize_raw_endpoint,
|
||||
parse_raw_repository,
|
||||
parse_raw_target_path,
|
||||
raw_is_known_api_path,
|
||||
raw_is_sensitive,
|
||||
raw_request_is_write,
|
||||
raw_top_segment,
|
||||
)
|
||||
|
||||
|
||||
def _bound_response(data: Any) -> dict[str, Any]:
|
||||
"""Bound a raw response into stable, size-limited envelope fields."""
|
||||
if isinstance(data, list):
|
||||
bounded, omitted = limit_items(list(data))
|
||||
return {"data": bounded, "count": len(bounded), "omitted": omitted}
|
||||
if isinstance(data, dict):
|
||||
serialized = json.dumps(data, ensure_ascii=False, default=str)
|
||||
capped = limit_text(serialized)
|
||||
if len(capped) < len(serialized):
|
||||
# Oversized dict: return a truncated JSON string instead of the object.
|
||||
return {"data": capped, "truncated": True}
|
||||
return {"data": data, "truncated": False}
|
||||
if isinstance(data, str):
|
||||
return {"data": limit_text(data)}
|
||||
return {"data": data}
|
||||
|
||||
|
||||
async def raw_api_request_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Dispatch an arbitrary Gitea REST endpoint subject to policy and denylists."""
|
||||
settings = get_settings()
|
||||
audit = get_audit_logger()
|
||||
|
||||
if not settings.raw_api_enabled:
|
||||
raise ToolError(
|
||||
"Raw API dispatch is disabled (set RAW_API_ENABLED=true to enable).",
|
||||
status_code=403,
|
||||
)
|
||||
|
||||
parsed = RawApiRequestArgs.model_validate(arguments)
|
||||
method = parsed.method
|
||||
endpoint = normalize_raw_endpoint(parsed.path)
|
||||
|
||||
# Fail closed on paths that do not match a known Gitea API prefix: an
|
||||
# unrecognized path is never passed straight through to the backend.
|
||||
if not raw_is_known_api_path(endpoint):
|
||||
audit.log_access_denied(tool_name="gitea_request", reason="raw_unknown_path_denied")
|
||||
raise ToolError(
|
||||
"Endpoint does not match a known Gitea API route prefix.",
|
||||
status_code=403,
|
||||
)
|
||||
|
||||
# Deterministic read/write classification (override-aware): a non-GET/HEAD
|
||||
# method is a write unless it is in the explicit render-only override table,
|
||||
# so a mutating call can never be misclassified as a read and slip past the
|
||||
# write-mode gate.
|
||||
is_write = raw_request_is_write(method, endpoint)
|
||||
|
||||
# Admin/credential denylist applies to every method and cannot be re-opened
|
||||
# from policy.yaml — only RAW_API_ALLOW_SENSITIVE overrides it.
|
||||
if raw_is_sensitive(endpoint) and not settings.raw_api_allow_sensitive:
|
||||
audit.log_access_denied(tool_name="gitea_request", reason="raw_sensitive_path_denied")
|
||||
raise ToolError(
|
||||
"Endpoint targets an admin/credential surface blocked by the raw-API "
|
||||
"sensitive-path denylist.",
|
||||
status_code=403,
|
||||
)
|
||||
|
||||
repository = parse_raw_repository(endpoint)
|
||||
target_path = parse_raw_target_path(endpoint)
|
||||
|
||||
# Coarse, stable virtual tool name so policy.yaml can allow/deny by method +
|
||||
# top-level path segment (policy matches tool names by exact set membership).
|
||||
policy_tool_name = f"gitea_request:{method}:{raw_top_segment(endpoint)}"
|
||||
decision = get_policy_engine().authorize(
|
||||
tool_name=policy_tool_name,
|
||||
is_write=is_write,
|
||||
repository=repository,
|
||||
target_path=target_path,
|
||||
)
|
||||
if not decision.allowed:
|
||||
audit.log_access_denied(
|
||||
tool_name=policy_tool_name,
|
||||
repository=repository,
|
||||
reason=decision.reason,
|
||||
)
|
||||
raise ToolError(f"Policy denied raw request: {decision.reason}", status_code=403)
|
||||
|
||||
try:
|
||||
data = await gitea.raw_request(method, endpoint, params=parsed.query, json_body=parsed.body)
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
# Let auth/authz failures surface so the server returns actionable
|
||||
# re-authorization guidance instead of a generic internal error.
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Raw API request failed: {exc}") from exc
|
||||
|
||||
envelope: dict[str, Any] = {
|
||||
"method": method,
|
||||
"path": endpoint,
|
||||
"write": is_write,
|
||||
"repository": repository,
|
||||
}
|
||||
envelope.update(_bound_response(data))
|
||||
return envelope
|
||||
@@ -1,850 +0,0 @@
|
||||
"""Extended read-only MCP tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from aegis_gitea_mcp.gitea_client import (
|
||||
GiteaAuthenticationError,
|
||||
GiteaAuthorizationError,
|
||||
GiteaClient,
|
||||
GiteaError,
|
||||
)
|
||||
from aegis_gitea_mcp.logging_utils import log_event, log_nullable_field
|
||||
from aegis_gitea_mcp.response_limits import limit_items, limit_text
|
||||
from aegis_gitea_mcp.tools.arguments import (
|
||||
CommitDiffArgs,
|
||||
CommitStatusArgs,
|
||||
CompareRefsArgs,
|
||||
GetBranchArgs,
|
||||
GetReleaseArgs,
|
||||
IssueArgs,
|
||||
LatestReleaseArgs,
|
||||
ListBranchesArgs,
|
||||
ListCommitsArgs,
|
||||
ListIssueCommentsArgs,
|
||||
ListIssuesArgs,
|
||||
ListLabelsArgs,
|
||||
ListMilestonesArgs,
|
||||
ListOrganizationsArgs,
|
||||
ListOrgRepositoriesArgs,
|
||||
ListPullRequestCommitsArgs,
|
||||
ListPullRequestFilesArgs,
|
||||
ListPullRequestsArgs,
|
||||
ListReleasesArgs,
|
||||
ListTagsArgs,
|
||||
PullRequestArgs,
|
||||
RepoLanguagesArgs,
|
||||
RepoTopicsArgs,
|
||||
SearchCodeArgs,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def search_code_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Search repository code and return bounded result snippets."""
|
||||
parsed = SearchCodeArgs.model_validate(arguments)
|
||||
try:
|
||||
raw = await gitea.search_code(
|
||||
parsed.owner,
|
||||
parsed.repo,
|
||||
parsed.query,
|
||||
ref=parsed.ref,
|
||||
page=parsed.page,
|
||||
limit=parsed.limit,
|
||||
)
|
||||
hits_raw = raw.get("data", raw.get("hits", [])) if isinstance(raw, dict) else []
|
||||
if not isinstance(hits_raw, list):
|
||||
hits_raw = []
|
||||
|
||||
normalized_hits = []
|
||||
for item in hits_raw:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
snippet = str(item.get("content", item.get("snippet", "")))
|
||||
normalized_hits.append(
|
||||
{
|
||||
"path": item.get("filename", item.get("path", "")),
|
||||
"sha": item.get("sha", ""),
|
||||
"ref": parsed.ref,
|
||||
"snippet": limit_text(snippet),
|
||||
"score": item.get("score", 0),
|
||||
}
|
||||
)
|
||||
|
||||
bounded, omitted = limit_items(normalized_hits, configured_limit=parsed.limit)
|
||||
return {
|
||||
"owner": parsed.owner,
|
||||
"repo": parsed.repo,
|
||||
"query": parsed.query,
|
||||
"ref": parsed.ref,
|
||||
"results": bounded,
|
||||
"count": len(bounded),
|
||||
"omitted": omitted,
|
||||
}
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
# Let auth/authz failures surface so the server returns actionable
|
||||
# re-authorization guidance instead of a generic internal error.
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to search code: {exc}") from exc
|
||||
|
||||
|
||||
async def list_commits_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
"""List commits for a repository reference."""
|
||||
parsed = ListCommitsArgs.model_validate(arguments)
|
||||
try:
|
||||
commits = await gitea.list_commits(
|
||||
parsed.owner,
|
||||
parsed.repo,
|
||||
ref=parsed.ref,
|
||||
page=parsed.page,
|
||||
limit=parsed.limit,
|
||||
)
|
||||
normalized = [
|
||||
{
|
||||
"sha": commit.get("sha", ""),
|
||||
"message": limit_text(str(commit.get("commit", {}).get("message", ""))),
|
||||
"author": commit.get("author", {}).get("login", ""),
|
||||
"created": commit.get("commit", {}).get("author", {}).get("date", ""),
|
||||
"url": commit.get("html_url", ""),
|
||||
}
|
||||
for commit in commits
|
||||
if isinstance(commit, dict)
|
||||
]
|
||||
bounded, omitted = limit_items(normalized, configured_limit=parsed.limit)
|
||||
return {
|
||||
"owner": parsed.owner,
|
||||
"repo": parsed.repo,
|
||||
"ref": parsed.ref,
|
||||
"commits": bounded,
|
||||
"count": len(bounded),
|
||||
"omitted": omitted,
|
||||
}
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
# Let auth/authz failures surface so the server returns actionable
|
||||
# re-authorization guidance instead of a generic internal error.
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to list commits: {exc}") from exc
|
||||
|
||||
|
||||
async def get_commit_diff_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return commit-level file diff metadata."""
|
||||
parsed = CommitDiffArgs.model_validate(arguments)
|
||||
try:
|
||||
commit = await gitea.get_commit_diff(parsed.owner, parsed.repo, parsed.sha)
|
||||
files = commit.get("files", []) if isinstance(commit, dict) else []
|
||||
normalized_files = []
|
||||
if isinstance(files, list):
|
||||
for item in files:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
normalized_files.append(
|
||||
{
|
||||
"filename": item.get("filename", ""),
|
||||
"status": item.get("status", ""),
|
||||
"additions": item.get("additions", 0),
|
||||
"deletions": item.get("deletions", 0),
|
||||
"changes": item.get("changes", 0),
|
||||
"patch": limit_text(str(item.get("patch", ""))),
|
||||
}
|
||||
)
|
||||
bounded, omitted = limit_items(normalized_files)
|
||||
|
||||
return {
|
||||
"owner": parsed.owner,
|
||||
"repo": parsed.repo,
|
||||
"sha": parsed.sha,
|
||||
"message": limit_text(
|
||||
str(commit.get("message", commit.get("commit", {}).get("message", "")))
|
||||
),
|
||||
"files": bounded,
|
||||
"count": len(bounded),
|
||||
"omitted": omitted,
|
||||
}
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
# Let auth/authz failures surface so the server returns actionable
|
||||
# re-authorization guidance instead of a generic internal error.
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to get commit diff: {exc}") from exc
|
||||
|
||||
|
||||
async def compare_refs_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Compare two refs and return bounded commit/file changes."""
|
||||
parsed = CompareRefsArgs.model_validate(arguments)
|
||||
try:
|
||||
comparison = await gitea.compare_refs(parsed.owner, parsed.repo, parsed.base, parsed.head)
|
||||
commits_raw = comparison.get("commits", []) if isinstance(comparison, dict) else []
|
||||
files_raw = comparison.get("files", []) if isinstance(comparison, dict) else []
|
||||
|
||||
commits = [
|
||||
{
|
||||
"sha": commit.get("sha", ""),
|
||||
"message": limit_text(str(commit.get("commit", {}).get("message", ""))),
|
||||
}
|
||||
for commit in commits_raw
|
||||
if isinstance(commit, dict)
|
||||
]
|
||||
commit_items, commit_omitted = limit_items(commits)
|
||||
|
||||
files = [
|
||||
{
|
||||
"filename": item.get("filename", ""),
|
||||
"status": item.get("status", ""),
|
||||
"additions": item.get("additions", 0),
|
||||
"deletions": item.get("deletions", 0),
|
||||
}
|
||||
for item in files_raw
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
file_items, file_omitted = limit_items(files)
|
||||
|
||||
return {
|
||||
"owner": parsed.owner,
|
||||
"repo": parsed.repo,
|
||||
"base": parsed.base,
|
||||
"head": parsed.head,
|
||||
"commits": commit_items,
|
||||
"files": file_items,
|
||||
"commit_count": len(commit_items),
|
||||
"file_count": len(file_items),
|
||||
"omitted_commits": commit_omitted,
|
||||
"omitted_files": file_omitted,
|
||||
}
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
# Let auth/authz failures surface so the server returns actionable
|
||||
# re-authorization guidance instead of a generic internal error.
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to compare refs: {exc}") from exc
|
||||
|
||||
|
||||
async def list_issues_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
"""List issues for repository."""
|
||||
parsed = ListIssuesArgs.model_validate(arguments)
|
||||
try:
|
||||
issues = await gitea.list_issues(
|
||||
parsed.owner,
|
||||
parsed.repo,
|
||||
state=parsed.state,
|
||||
page=parsed.page,
|
||||
limit=parsed.limit,
|
||||
labels=parsed.labels,
|
||||
)
|
||||
normalized = [
|
||||
{
|
||||
"number": issue.get("number", 0),
|
||||
"title": limit_text(str(issue.get("title", ""))),
|
||||
"state": issue.get("state", ""),
|
||||
"author": issue.get("user", {}).get("login", ""),
|
||||
"labels": [label.get("name", "") for label in issue.get("labels", [])],
|
||||
"created_at": issue.get("created_at", ""),
|
||||
"updated_at": issue.get("updated_at", ""),
|
||||
"url": issue.get("html_url", ""),
|
||||
}
|
||||
for issue in issues
|
||||
if isinstance(issue, dict)
|
||||
]
|
||||
bounded, omitted = limit_items(normalized, configured_limit=parsed.limit)
|
||||
return {
|
||||
"owner": parsed.owner,
|
||||
"repo": parsed.repo,
|
||||
"state": parsed.state,
|
||||
"issues": bounded,
|
||||
"count": len(bounded),
|
||||
"omitted": omitted,
|
||||
}
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
# Let auth/authz failures surface so the server returns actionable
|
||||
# re-authorization guidance instead of a generic internal error.
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to list issues: {exc}") from exc
|
||||
|
||||
|
||||
async def get_issue_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Get issue details."""
|
||||
parsed = IssueArgs.model_validate(arguments)
|
||||
log_event(
|
||||
logger,
|
||||
logging.DEBUG,
|
||||
"get_issue.start",
|
||||
owner=parsed.owner,
|
||||
repo=parsed.repo,
|
||||
issue_number=parsed.issue_number,
|
||||
)
|
||||
try:
|
||||
issue = await gitea.get_issue(parsed.owner, parsed.repo, parsed.issue_number)
|
||||
log_event(
|
||||
logger,
|
||||
logging.DEBUG,
|
||||
"get_issue.payload_shape",
|
||||
top_level_keys=sorted(issue.keys()) if issue else None,
|
||||
)
|
||||
# Surface nullable collections that previously broke parsing (see #13).
|
||||
log_nullable_field(logger, "get_issue.field_check", "labels", issue.get("labels"))
|
||||
log_nullable_field(logger, "get_issue.field_check", "assignees", issue.get("assignees"))
|
||||
log_nullable_field(logger, "get_issue.field_check", "user", issue.get("user"))
|
||||
return {
|
||||
"number": issue.get("number", 0),
|
||||
"title": limit_text(str(issue.get("title", ""))),
|
||||
"body": limit_text(str(issue.get("body", ""))),
|
||||
"state": issue.get("state", ""),
|
||||
"author": (issue.get("user") or {}).get("login", ""),
|
||||
"labels": [
|
||||
label.get("name", "")
|
||||
for label in (issue.get("labels") or [])
|
||||
if isinstance(label, dict)
|
||||
],
|
||||
"assignees": [
|
||||
assignee.get("login", "")
|
||||
for assignee in (issue.get("assignees") or [])
|
||||
if isinstance(assignee, dict)
|
||||
],
|
||||
"created_at": issue.get("created_at", ""),
|
||||
"updated_at": issue.get("updated_at", ""),
|
||||
"url": issue.get("html_url", ""),
|
||||
}
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
# Let auth/authz failures surface so the server returns actionable
|
||||
# re-authorization guidance instead of a generic internal error.
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to get issue: {exc}") from exc
|
||||
|
||||
|
||||
async def list_pull_requests_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
"""List pull requests."""
|
||||
parsed = ListPullRequestsArgs.model_validate(arguments)
|
||||
try:
|
||||
pull_requests = await gitea.list_pull_requests(
|
||||
parsed.owner,
|
||||
parsed.repo,
|
||||
state=parsed.state,
|
||||
page=parsed.page,
|
||||
limit=parsed.limit,
|
||||
)
|
||||
normalized = [
|
||||
{
|
||||
"number": pull.get("number", 0),
|
||||
"title": limit_text(str(pull.get("title", ""))),
|
||||
"state": pull.get("state", ""),
|
||||
"author": pull.get("user", {}).get("login", ""),
|
||||
"draft": pull.get("draft", False),
|
||||
"mergeable": pull.get("mergeable", False),
|
||||
"created_at": pull.get("created_at", ""),
|
||||
"updated_at": pull.get("updated_at", ""),
|
||||
"url": pull.get("html_url", ""),
|
||||
}
|
||||
for pull in pull_requests
|
||||
if isinstance(pull, dict)
|
||||
]
|
||||
bounded, omitted = limit_items(normalized, configured_limit=parsed.limit)
|
||||
return {
|
||||
"owner": parsed.owner,
|
||||
"repo": parsed.repo,
|
||||
"state": parsed.state,
|
||||
"pull_requests": bounded,
|
||||
"count": len(bounded),
|
||||
"omitted": omitted,
|
||||
}
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
# Let auth/authz failures surface so the server returns actionable
|
||||
# re-authorization guidance instead of a generic internal error.
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to list pull requests: {exc}") from exc
|
||||
|
||||
|
||||
async def get_pull_request_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Get pull request details."""
|
||||
parsed = PullRequestArgs.model_validate(arguments)
|
||||
try:
|
||||
pull = await gitea.get_pull_request(parsed.owner, parsed.repo, parsed.pull_number)
|
||||
return {
|
||||
"number": pull.get("number", 0),
|
||||
"title": limit_text(str(pull.get("title", ""))),
|
||||
"body": limit_text(str(pull.get("body", ""))),
|
||||
"state": pull.get("state", ""),
|
||||
"draft": pull.get("draft", False),
|
||||
"mergeable": pull.get("mergeable", False),
|
||||
"author": pull.get("user", {}).get("login", ""),
|
||||
"base": pull.get("base", {}).get("ref", ""),
|
||||
"head": pull.get("head", {}).get("ref", ""),
|
||||
"created_at": pull.get("created_at", ""),
|
||||
"updated_at": pull.get("updated_at", ""),
|
||||
"url": pull.get("html_url", ""),
|
||||
}
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
# Let auth/authz failures surface so the server returns actionable
|
||||
# re-authorization guidance instead of a generic internal error.
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to get pull request: {exc}") from exc
|
||||
|
||||
|
||||
async def list_labels_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
"""List labels configured on repository."""
|
||||
parsed = ListLabelsArgs.model_validate(arguments)
|
||||
try:
|
||||
labels = await gitea.list_labels(
|
||||
parsed.owner, parsed.repo, page=parsed.page, limit=parsed.limit
|
||||
)
|
||||
normalized = [
|
||||
{
|
||||
"id": label.get("id", 0),
|
||||
"name": label.get("name", ""),
|
||||
"color": label.get("color", ""),
|
||||
"description": limit_text(str(label.get("description", ""))),
|
||||
}
|
||||
for label in labels
|
||||
if isinstance(label, dict)
|
||||
]
|
||||
bounded, omitted = limit_items(normalized, configured_limit=parsed.limit)
|
||||
return {
|
||||
"owner": parsed.owner,
|
||||
"repo": parsed.repo,
|
||||
"labels": bounded,
|
||||
"count": len(bounded),
|
||||
"omitted": omitted,
|
||||
}
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
# Let auth/authz failures surface so the server returns actionable
|
||||
# re-authorization guidance instead of a generic internal error.
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to list labels: {exc}") from exc
|
||||
|
||||
|
||||
async def list_tags_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
"""List repository tags."""
|
||||
parsed = ListTagsArgs.model_validate(arguments)
|
||||
try:
|
||||
tags = await gitea.list_tags(
|
||||
parsed.owner, parsed.repo, page=parsed.page, limit=parsed.limit
|
||||
)
|
||||
normalized = [
|
||||
{
|
||||
"name": tag.get("name", ""),
|
||||
"commit": tag.get("commit", {}).get("sha", ""),
|
||||
"zipball_url": tag.get("zipball_url", ""),
|
||||
"tarball_url": tag.get("tarball_url", ""),
|
||||
}
|
||||
for tag in tags
|
||||
if isinstance(tag, dict)
|
||||
]
|
||||
bounded, omitted = limit_items(normalized, configured_limit=parsed.limit)
|
||||
return {
|
||||
"owner": parsed.owner,
|
||||
"repo": parsed.repo,
|
||||
"tags": bounded,
|
||||
"count": len(bounded),
|
||||
"omitted": omitted,
|
||||
}
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
# Let auth/authz failures surface so the server returns actionable
|
||||
# re-authorization guidance instead of a generic internal error.
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to list tags: {exc}") from exc
|
||||
|
||||
|
||||
async def list_releases_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
"""List repository releases."""
|
||||
parsed = ListReleasesArgs.model_validate(arguments)
|
||||
try:
|
||||
releases = await gitea.list_releases(
|
||||
parsed.owner,
|
||||
parsed.repo,
|
||||
page=parsed.page,
|
||||
limit=parsed.limit,
|
||||
)
|
||||
normalized = [
|
||||
{
|
||||
"id": release.get("id", 0),
|
||||
"tag_name": release.get("tag_name", ""),
|
||||
"name": limit_text(str(release.get("name", ""))),
|
||||
"draft": release.get("draft", False),
|
||||
"prerelease": release.get("prerelease", False),
|
||||
"body": limit_text(str(release.get("body", ""))),
|
||||
"created_at": release.get("created_at", ""),
|
||||
"published_at": release.get("published_at", ""),
|
||||
"url": release.get("html_url", ""),
|
||||
}
|
||||
for release in releases
|
||||
if isinstance(release, dict)
|
||||
]
|
||||
bounded, omitted = limit_items(normalized, configured_limit=parsed.limit)
|
||||
return {
|
||||
"owner": parsed.owner,
|
||||
"repo": parsed.repo,
|
||||
"releases": bounded,
|
||||
"count": len(bounded),
|
||||
"omitted": omitted,
|
||||
}
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
# Let auth/authz failures surface so the server returns actionable
|
||||
# re-authorization guidance instead of a generic internal error.
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to list releases: {exc}") from exc
|
||||
|
||||
|
||||
async def list_pull_request_files_tool(
|
||||
gitea: GiteaClient, arguments: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""List files changed in a pull request."""
|
||||
parsed = ListPullRequestFilesArgs.model_validate(arguments)
|
||||
try:
|
||||
files = await gitea.list_pull_request_files(
|
||||
parsed.owner, parsed.repo, parsed.pull_number, page=parsed.page, limit=parsed.limit
|
||||
)
|
||||
normalized = [
|
||||
{
|
||||
"filename": item.get("filename", ""),
|
||||
"status": item.get("status", ""),
|
||||
"additions": item.get("additions", 0),
|
||||
"deletions": item.get("deletions", 0),
|
||||
"changes": item.get("changes", 0),
|
||||
}
|
||||
for item in files
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
bounded, omitted = limit_items(normalized, configured_limit=parsed.limit)
|
||||
return {
|
||||
"owner": parsed.owner,
|
||||
"repo": parsed.repo,
|
||||
"pull_number": parsed.pull_number,
|
||||
"files": bounded,
|
||||
"count": len(bounded),
|
||||
"omitted": omitted,
|
||||
}
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to list pull request files: {exc}") from exc
|
||||
|
||||
|
||||
async def list_pull_request_commits_tool(
|
||||
gitea: GiteaClient, arguments: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""List commits in a pull request."""
|
||||
parsed = ListPullRequestCommitsArgs.model_validate(arguments)
|
||||
try:
|
||||
commits = await gitea.list_pull_request_commits(
|
||||
parsed.owner, parsed.repo, parsed.pull_number, page=parsed.page, limit=parsed.limit
|
||||
)
|
||||
normalized = [
|
||||
{
|
||||
"sha": commit.get("sha", ""),
|
||||
"message": limit_text(str(commit.get("commit", {}).get("message", ""))),
|
||||
"author": (
|
||||
commit.get("author", {}).get("login", "")
|
||||
if isinstance(commit.get("author"), dict)
|
||||
else ""
|
||||
),
|
||||
}
|
||||
for commit in commits
|
||||
if isinstance(commit, dict)
|
||||
]
|
||||
bounded, omitted = limit_items(normalized, configured_limit=parsed.limit)
|
||||
return {
|
||||
"owner": parsed.owner,
|
||||
"repo": parsed.repo,
|
||||
"pull_number": parsed.pull_number,
|
||||
"commits": bounded,
|
||||
"count": len(bounded),
|
||||
"omitted": omitted,
|
||||
}
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to list pull request commits: {exc}") from exc
|
||||
|
||||
|
||||
async def list_issue_comments_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
"""List comments on an issue or pull request."""
|
||||
parsed = ListIssueCommentsArgs.model_validate(arguments)
|
||||
try:
|
||||
comments = await gitea.list_issue_comments(
|
||||
parsed.owner, parsed.repo, parsed.issue_number, page=parsed.page, limit=parsed.limit
|
||||
)
|
||||
normalized = [
|
||||
{
|
||||
"id": comment.get("id", 0),
|
||||
"author": (
|
||||
comment.get("user", {}).get("login", "")
|
||||
if isinstance(comment.get("user"), dict)
|
||||
else ""
|
||||
),
|
||||
"body": limit_text(str(comment.get("body", ""))),
|
||||
"created_at": comment.get("created_at", ""),
|
||||
"updated_at": comment.get("updated_at", ""),
|
||||
"url": comment.get("html_url", ""),
|
||||
}
|
||||
for comment in comments
|
||||
if isinstance(comment, dict)
|
||||
]
|
||||
bounded, omitted = limit_items(normalized, configured_limit=parsed.limit)
|
||||
return {
|
||||
"owner": parsed.owner,
|
||||
"repo": parsed.repo,
|
||||
"issue_number": parsed.issue_number,
|
||||
"comments": bounded,
|
||||
"count": len(bounded),
|
||||
"omitted": omitted,
|
||||
}
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to list issue comments: {exc}") from exc
|
||||
|
||||
|
||||
def _normalize_branch(branch: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Normalize a Gitea branch payload."""
|
||||
commit = branch.get("commit", {}) if isinstance(branch.get("commit"), dict) else {}
|
||||
return {
|
||||
"name": branch.get("name", ""),
|
||||
"protected": branch.get("protected", False),
|
||||
"commit": commit.get("id", ""),
|
||||
}
|
||||
|
||||
|
||||
async def list_branches_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
"""List repository branches."""
|
||||
parsed = ListBranchesArgs.model_validate(arguments)
|
||||
try:
|
||||
branches = await gitea.list_branches(
|
||||
parsed.owner, parsed.repo, page=parsed.page, limit=parsed.limit
|
||||
)
|
||||
normalized = [_normalize_branch(b) for b in branches if isinstance(b, dict)]
|
||||
bounded, omitted = limit_items(normalized, configured_limit=parsed.limit)
|
||||
return {
|
||||
"owner": parsed.owner,
|
||||
"repo": parsed.repo,
|
||||
"branches": bounded,
|
||||
"count": len(bounded),
|
||||
"omitted": omitted,
|
||||
}
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to list branches: {exc}") from exc
|
||||
|
||||
|
||||
async def get_branch_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Get a single branch."""
|
||||
parsed = GetBranchArgs.model_validate(arguments)
|
||||
try:
|
||||
branch = await gitea.get_branch(parsed.owner, parsed.repo, parsed.branch)
|
||||
result = _normalize_branch(branch)
|
||||
result.update({"owner": parsed.owner, "repo": parsed.repo})
|
||||
return result
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to get branch: {exc}") from exc
|
||||
|
||||
|
||||
def _normalize_release(release: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Normalize a Gitea release payload."""
|
||||
return {
|
||||
"id": release.get("id", 0),
|
||||
"tag_name": release.get("tag_name", ""),
|
||||
"name": limit_text(str(release.get("name", ""))),
|
||||
"draft": release.get("draft", False),
|
||||
"prerelease": release.get("prerelease", False),
|
||||
"body": limit_text(str(release.get("body", ""))),
|
||||
"created_at": release.get("created_at", ""),
|
||||
"published_at": release.get("published_at", ""),
|
||||
"url": release.get("html_url", ""),
|
||||
}
|
||||
|
||||
|
||||
async def get_release_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Get a release by id."""
|
||||
parsed = GetReleaseArgs.model_validate(arguments)
|
||||
try:
|
||||
release = await gitea.get_release(parsed.owner, parsed.repo, parsed.release_id)
|
||||
return _normalize_release(release)
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to get release: {exc}") from exc
|
||||
|
||||
|
||||
async def get_latest_release_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Get the latest published release."""
|
||||
parsed = LatestReleaseArgs.model_validate(arguments)
|
||||
try:
|
||||
release = await gitea.get_latest_release(parsed.owner, parsed.repo)
|
||||
return _normalize_release(release)
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to get latest release: {exc}") from exc
|
||||
|
||||
|
||||
async def list_milestones_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
"""List repository milestones."""
|
||||
parsed = ListMilestonesArgs.model_validate(arguments)
|
||||
try:
|
||||
milestones = await gitea.list_milestones(
|
||||
parsed.owner, parsed.repo, state=parsed.state, page=parsed.page, limit=parsed.limit
|
||||
)
|
||||
normalized = [
|
||||
{
|
||||
"id": m.get("id", 0),
|
||||
"title": limit_text(str(m.get("title", ""))),
|
||||
"state": m.get("state", ""),
|
||||
"open_issues": m.get("open_issues", 0),
|
||||
"closed_issues": m.get("closed_issues", 0),
|
||||
"due_on": m.get("due_on", ""),
|
||||
}
|
||||
for m in milestones
|
||||
if isinstance(m, dict)
|
||||
]
|
||||
bounded, omitted = limit_items(normalized, configured_limit=parsed.limit)
|
||||
return {
|
||||
"owner": parsed.owner,
|
||||
"repo": parsed.repo,
|
||||
"state": parsed.state,
|
||||
"milestones": bounded,
|
||||
"count": len(bounded),
|
||||
"omitted": omitted,
|
||||
}
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to list milestones: {exc}") from exc
|
||||
|
||||
|
||||
async def get_commit_status_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Get the combined commit status for a ref/sha."""
|
||||
parsed = CommitStatusArgs.model_validate(arguments)
|
||||
try:
|
||||
status = await gitea.get_commit_status(parsed.owner, parsed.repo, parsed.sha)
|
||||
statuses_raw = status.get("statuses", []) if isinstance(status, dict) else []
|
||||
statuses = [
|
||||
{
|
||||
"context": s.get("context", ""),
|
||||
"state": s.get("status", s.get("state", "")),
|
||||
"target_url": s.get("target_url", ""),
|
||||
}
|
||||
for s in statuses_raw
|
||||
if isinstance(s, dict)
|
||||
]
|
||||
bounded, omitted = limit_items(statuses)
|
||||
return {
|
||||
"owner": parsed.owner,
|
||||
"repo": parsed.repo,
|
||||
"sha": parsed.sha,
|
||||
"state": status.get("state", "") if isinstance(status, dict) else "",
|
||||
"statuses": bounded,
|
||||
"omitted": omitted,
|
||||
}
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to get commit status: {exc}") from exc
|
||||
|
||||
|
||||
def _normalize_repo_summary(repo: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Normalize a repository payload to a compact summary."""
|
||||
owner = repo.get("owner", {})
|
||||
return {
|
||||
"owner": owner.get("login", "") if isinstance(owner, dict) else "",
|
||||
"name": repo.get("name", ""),
|
||||
"full_name": repo.get("full_name", ""),
|
||||
"private": repo.get("private", False),
|
||||
"description": limit_text(str(repo.get("description", ""))),
|
||||
"url": repo.get("html_url", ""),
|
||||
}
|
||||
|
||||
|
||||
async def list_org_repositories_tool(
|
||||
gitea: GiteaClient, arguments: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""List repositories belonging to an organization."""
|
||||
parsed = ListOrgRepositoriesArgs.model_validate(arguments)
|
||||
try:
|
||||
repos = await gitea.list_org_repositories(parsed.org, page=parsed.page, limit=parsed.limit)
|
||||
normalized = [_normalize_repo_summary(r) for r in repos if isinstance(r, dict)]
|
||||
bounded, omitted = limit_items(normalized, configured_limit=parsed.limit)
|
||||
return {
|
||||
"org": parsed.org,
|
||||
"repositories": bounded,
|
||||
"count": len(bounded),
|
||||
"omitted": omitted,
|
||||
}
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to list org repositories: {exc}") from exc
|
||||
|
||||
|
||||
async def list_organizations_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
"""List organizations the authenticated user belongs to."""
|
||||
parsed = ListOrganizationsArgs.model_validate(arguments)
|
||||
try:
|
||||
orgs = await gitea.list_organizations(page=parsed.page, limit=parsed.limit)
|
||||
normalized = [
|
||||
{
|
||||
"id": org.get("id", 0),
|
||||
"name": org.get("username", org.get("name", "")),
|
||||
"full_name": org.get("full_name", ""),
|
||||
"description": limit_text(str(org.get("description", ""))),
|
||||
}
|
||||
for org in orgs
|
||||
if isinstance(org, dict)
|
||||
]
|
||||
bounded, omitted = limit_items(normalized, configured_limit=parsed.limit)
|
||||
return {
|
||||
"organizations": bounded,
|
||||
"count": len(bounded),
|
||||
"omitted": omitted,
|
||||
}
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to list organizations: {exc}") from exc
|
||||
|
||||
|
||||
async def get_repo_languages_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Get the language breakdown for a repository."""
|
||||
parsed = RepoLanguagesArgs.model_validate(arguments)
|
||||
try:
|
||||
languages = await gitea.get_repo_languages(parsed.owner, parsed.repo)
|
||||
cleaned = {str(name): value for name, value in languages.items() if isinstance(name, str)}
|
||||
return {
|
||||
"owner": parsed.owner,
|
||||
"repo": parsed.repo,
|
||||
"languages": cleaned,
|
||||
}
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to get repository languages: {exc}") from exc
|
||||
|
||||
|
||||
async def list_repo_topics_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
"""List the topics assigned to a repository."""
|
||||
parsed = RepoTopicsArgs.model_validate(arguments)
|
||||
try:
|
||||
topics = await gitea.list_repo_topics(parsed.owner, parsed.repo)
|
||||
bounded, omitted = limit_items([{"topic": t} for t in topics])
|
||||
return {
|
||||
"owner": parsed.owner,
|
||||
"repo": parsed.repo,
|
||||
"topics": [entry["topic"] for entry in bounded],
|
||||
"count": len(bounded),
|
||||
"omitted": omitted,
|
||||
}
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to list repository topics: {exc}") from exc
|
||||
@@ -1,190 +0,0 @@
|
||||
"""Repository-related MCP tool implementations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
from typing import Any
|
||||
|
||||
from aegis_gitea_mcp.config import get_settings
|
||||
from aegis_gitea_mcp.gitea_client import (
|
||||
GiteaAuthenticationError,
|
||||
GiteaAuthorizationError,
|
||||
GiteaClient,
|
||||
GiteaError,
|
||||
)
|
||||
from aegis_gitea_mcp.request_context import get_gitea_user_login
|
||||
from aegis_gitea_mcp.response_limits import limit_items, limit_text
|
||||
from aegis_gitea_mcp.security import sanitize_untrusted_text
|
||||
from aegis_gitea_mcp.tools.arguments import (
|
||||
FileContentsArgs,
|
||||
FileTreeArgs,
|
||||
ListRepositoriesArgs,
|
||||
RepositoryArgs,
|
||||
)
|
||||
|
||||
|
||||
async def list_repositories_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
"""List repositories visible to the active Gitea API token.
|
||||
|
||||
Args:
|
||||
gitea: Initialized Gitea client.
|
||||
arguments: Tool arguments.
|
||||
|
||||
Returns:
|
||||
Response payload with bounded repository list.
|
||||
"""
|
||||
ListRepositoriesArgs.model_validate(arguments)
|
||||
settings = get_settings()
|
||||
login = get_gitea_user_login()
|
||||
try:
|
||||
# In service-PAT mode the API token is the bot's, so scope the listing to
|
||||
# the authenticated user's own repositories. In pure-OAuth mode the API
|
||||
# token already belongs to the user, so Gitea scopes /user/repos for us.
|
||||
if settings.gitea_token.strip() and login and login != "unknown":
|
||||
repositories = await gitea.list_user_repositories(login)
|
||||
else:
|
||||
repositories = await gitea.list_repositories()
|
||||
simplified = [
|
||||
{
|
||||
"owner": repo.get("owner", {}).get("login", ""),
|
||||
"name": repo.get("name", ""),
|
||||
"full_name": repo.get("full_name", ""),
|
||||
"description": repo.get("description", ""),
|
||||
"private": repo.get("private", False),
|
||||
"default_branch": repo.get("default_branch", "main"),
|
||||
"language": repo.get("language", ""),
|
||||
"stars": repo.get("stars_count", 0),
|
||||
"url": repo.get("html_url", ""),
|
||||
}
|
||||
for repo in repositories
|
||||
]
|
||||
|
||||
bounded, omitted = limit_items(simplified)
|
||||
return {
|
||||
"repositories": bounded,
|
||||
"count": len(bounded),
|
||||
"omitted": omitted,
|
||||
}
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
# Let auth/authz failures surface so the server returns actionable
|
||||
# re-authorization guidance instead of a generic internal error.
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to list repositories: {exc}") from exc
|
||||
|
||||
|
||||
async def get_repository_info_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Get detailed metadata for a repository."""
|
||||
parsed = RepositoryArgs.model_validate(arguments)
|
||||
try:
|
||||
repo_data = await gitea.get_repository(parsed.owner, parsed.repo)
|
||||
return {
|
||||
"owner": repo_data.get("owner", {}).get("login", ""),
|
||||
"name": repo_data.get("name", ""),
|
||||
"full_name": repo_data.get("full_name", ""),
|
||||
"description": repo_data.get("description", ""),
|
||||
"private": repo_data.get("private", False),
|
||||
"fork": repo_data.get("fork", False),
|
||||
"default_branch": repo_data.get("default_branch", "main"),
|
||||
"language": repo_data.get("language", ""),
|
||||
"stars": repo_data.get("stars_count", 0),
|
||||
"forks": repo_data.get("forks_count", 0),
|
||||
"open_issues": repo_data.get("open_issues_count", 0),
|
||||
"size": repo_data.get("size", 0),
|
||||
"created_at": repo_data.get("created_at", ""),
|
||||
"updated_at": repo_data.get("updated_at", ""),
|
||||
"url": repo_data.get("html_url", ""),
|
||||
"clone_url": repo_data.get("clone_url", ""),
|
||||
}
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
# Let auth/authz failures surface so the server returns actionable
|
||||
# re-authorization guidance instead of a generic internal error.
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to get repository info: {exc}") from exc
|
||||
|
||||
|
||||
async def get_file_tree_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Get repository file tree at selected ref."""
|
||||
parsed = FileTreeArgs.model_validate(arguments)
|
||||
try:
|
||||
tree_data = await gitea.get_tree(parsed.owner, parsed.repo, parsed.ref, parsed.recursive)
|
||||
tree_entries = tree_data.get("tree", [])
|
||||
simplified = [
|
||||
{
|
||||
"path": entry.get("path", ""),
|
||||
"type": entry.get("type", ""),
|
||||
"size": entry.get("size", 0),
|
||||
"sha": entry.get("sha", ""),
|
||||
}
|
||||
for entry in tree_entries
|
||||
]
|
||||
bounded, omitted = limit_items(simplified)
|
||||
|
||||
return {
|
||||
"owner": parsed.owner,
|
||||
"repo": parsed.repo,
|
||||
"ref": parsed.ref,
|
||||
"recursive": parsed.recursive,
|
||||
"tree": bounded,
|
||||
"count": len(bounded),
|
||||
"omitted": omitted,
|
||||
}
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
# Let auth/authz failures surface so the server returns actionable
|
||||
# re-authorization guidance instead of a generic internal error.
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to get file tree: {exc}") from exc
|
||||
|
||||
|
||||
async def get_file_contents_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Read file contents from a repository ref.
|
||||
|
||||
Security notes:
|
||||
- Repository content is treated as untrusted data and never executed.
|
||||
- Text output is truncated to configured limits to reduce prompt-stuffing risk.
|
||||
"""
|
||||
parsed = FileContentsArgs.model_validate(arguments)
|
||||
try:
|
||||
file_data = await gitea.get_file_contents(
|
||||
parsed.owner, parsed.repo, parsed.filepath, parsed.ref
|
||||
)
|
||||
|
||||
content_b64 = file_data.get("content", "")
|
||||
encoding = file_data.get("encoding", "base64")
|
||||
content = str(content_b64)
|
||||
|
||||
if encoding == "base64":
|
||||
try:
|
||||
decoded_bytes = base64.b64decode(content_b64)
|
||||
try:
|
||||
content = decoded_bytes.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
# Edge case: binary files should remain encoded instead of forcing invalid text.
|
||||
content = str(content_b64)
|
||||
except (binascii.Error, ValueError):
|
||||
content = str(content_b64)
|
||||
|
||||
# Validation logic: keep untrusted content bounded before returning it to LLM clients.
|
||||
content = sanitize_untrusted_text(content, max_chars=200_000)
|
||||
content = limit_text(content)
|
||||
|
||||
return {
|
||||
"owner": parsed.owner,
|
||||
"repo": parsed.repo,
|
||||
"filepath": parsed.filepath,
|
||||
"ref": parsed.ref,
|
||||
"content": content,
|
||||
"encoding": encoding,
|
||||
"size": file_data.get("size", 0),
|
||||
"sha": file_data.get("sha", ""),
|
||||
"url": file_data.get("html_url", ""),
|
||||
}
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
# Let auth/authz failures surface so the server returns actionable
|
||||
# re-authorization guidance instead of a generic internal error.
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to get file contents: {exc}") from exc
|
||||
@@ -1,413 +0,0 @@
|
||||
"""Write-mode MCP tool implementations (disabled by default)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from aegis_gitea_mcp.gitea_client import (
|
||||
GiteaAuthenticationError,
|
||||
GiteaAuthorizationError,
|
||||
GiteaClient,
|
||||
GiteaError,
|
||||
)
|
||||
from aegis_gitea_mcp.response_limits import limit_text
|
||||
from aegis_gitea_mcp.tools.arguments import (
|
||||
AddLabelsArgs,
|
||||
AssignIssueArgs,
|
||||
CreateBranchArgs,
|
||||
CreateIssueArgs,
|
||||
CreateIssueCommentArgs,
|
||||
CreateLabelArgs,
|
||||
CreateMilestoneArgs,
|
||||
CreatePrCommentArgs,
|
||||
CreatePullRequestArgs,
|
||||
CreateReleaseArgs,
|
||||
EditIssueCommentArgs,
|
||||
EditReleaseArgs,
|
||||
RemoveLabelsArgs,
|
||||
UpdateIssueArgs,
|
||||
UpdateLabelArgs,
|
||||
)
|
||||
|
||||
|
||||
def _milestone_title(issue: dict[str, Any]) -> str:
|
||||
"""Extract the milestone title from an issue payload, or '' if unset."""
|
||||
milestone = issue.get("milestone")
|
||||
if isinstance(milestone, dict):
|
||||
return limit_text(str(milestone.get("title", "")))
|
||||
return ""
|
||||
|
||||
|
||||
async def create_label_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Create a repository label in write mode."""
|
||||
parsed = CreateLabelArgs.model_validate(arguments)
|
||||
# Gitea expects the color with a leading '#'; normalize either form.
|
||||
color = parsed.color if parsed.color.startswith("#") else f"#{parsed.color}"
|
||||
try:
|
||||
label = await gitea.create_label(
|
||||
parsed.owner,
|
||||
parsed.repo,
|
||||
name=parsed.name,
|
||||
color=color,
|
||||
description=parsed.description,
|
||||
exclusive=parsed.exclusive,
|
||||
)
|
||||
return {
|
||||
"id": label.get("id", 0),
|
||||
"name": limit_text(str(label.get("name", ""))),
|
||||
"color": label.get("color", ""),
|
||||
"description": limit_text(str(label.get("description", ""))),
|
||||
"url": label.get("url", ""),
|
||||
}
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
# Let auth/authz failures surface so the server returns actionable
|
||||
# re-authorization guidance instead of a generic internal error.
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to create label: {exc}") from exc
|
||||
|
||||
|
||||
async def update_label_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Update an existing repository label (located by current name)."""
|
||||
parsed = UpdateLabelArgs.model_validate(arguments)
|
||||
color = parsed.color
|
||||
if color is not None and not color.startswith("#"):
|
||||
color = f"#{color}"
|
||||
try:
|
||||
label = await gitea.update_label(
|
||||
parsed.owner,
|
||||
parsed.repo,
|
||||
name=parsed.name,
|
||||
new_name=parsed.new_name,
|
||||
color=color,
|
||||
description=parsed.description,
|
||||
)
|
||||
return {
|
||||
"id": label.get("id", 0),
|
||||
"name": limit_text(str(label.get("name", ""))),
|
||||
"color": label.get("color", ""),
|
||||
"description": limit_text(str(label.get("description", ""))),
|
||||
"url": label.get("url", ""),
|
||||
}
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to update label: {exc}") from exc
|
||||
|
||||
|
||||
async def remove_labels_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Remove labels (by name) from an issue or pull request."""
|
||||
parsed = RemoveLabelsArgs.model_validate(arguments)
|
||||
try:
|
||||
result = await gitea.remove_labels(
|
||||
parsed.owner, parsed.repo, parsed.issue_number, parsed.labels
|
||||
)
|
||||
remaining: list[str] = []
|
||||
if isinstance(result, list):
|
||||
remaining = [label.get("name", "") for label in result if isinstance(label, dict)]
|
||||
return {
|
||||
"issue_number": parsed.issue_number,
|
||||
"removed": parsed.labels,
|
||||
"remaining_labels": remaining,
|
||||
}
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to remove labels: {exc}") from exc
|
||||
|
||||
|
||||
async def create_issue_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Create a new issue in write mode."""
|
||||
parsed = CreateIssueArgs.model_validate(arguments)
|
||||
try:
|
||||
issue = await gitea.create_issue(
|
||||
parsed.owner,
|
||||
parsed.repo,
|
||||
title=parsed.title,
|
||||
body=parsed.body,
|
||||
labels=parsed.labels,
|
||||
assignees=parsed.assignees,
|
||||
milestone=parsed.milestone,
|
||||
)
|
||||
return {
|
||||
"number": issue.get("number", 0),
|
||||
"title": limit_text(str(issue.get("title", ""))),
|
||||
"state": issue.get("state", ""),
|
||||
"milestone": _milestone_title(issue),
|
||||
"url": issue.get("html_url", ""),
|
||||
}
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
# Let auth/authz failures surface so the server returns actionable
|
||||
# re-authorization guidance instead of a generic internal error.
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to create issue: {exc}") from exc
|
||||
|
||||
|
||||
async def update_issue_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Update issue fields in write mode."""
|
||||
parsed = UpdateIssueArgs.model_validate(arguments)
|
||||
try:
|
||||
issue = await gitea.update_issue(
|
||||
parsed.owner,
|
||||
parsed.repo,
|
||||
parsed.issue_number,
|
||||
title=parsed.title,
|
||||
body=parsed.body,
|
||||
state=parsed.state,
|
||||
milestone=parsed.milestone,
|
||||
)
|
||||
return {
|
||||
"number": issue.get("number", parsed.issue_number),
|
||||
"title": limit_text(str(issue.get("title", ""))),
|
||||
"state": issue.get("state", ""),
|
||||
"milestone": _milestone_title(issue),
|
||||
"url": issue.get("html_url", ""),
|
||||
}
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
# Let auth/authz failures surface so the server returns actionable
|
||||
# re-authorization guidance instead of a generic internal error.
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to update issue: {exc}") from exc
|
||||
|
||||
|
||||
async def create_issue_comment_tool(
|
||||
gitea: GiteaClient, arguments: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Create issue comment in write mode."""
|
||||
parsed = CreateIssueCommentArgs.model_validate(arguments)
|
||||
try:
|
||||
comment = await gitea.create_issue_comment(
|
||||
parsed.owner,
|
||||
parsed.repo,
|
||||
parsed.issue_number,
|
||||
parsed.body,
|
||||
)
|
||||
return {
|
||||
"id": comment.get("id", 0),
|
||||
"issue_number": parsed.issue_number,
|
||||
"body": limit_text(str(comment.get("body", ""))),
|
||||
"url": comment.get("html_url", ""),
|
||||
}
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
# Let auth/authz failures surface so the server returns actionable
|
||||
# re-authorization guidance instead of a generic internal error.
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to create issue comment: {exc}") from exc
|
||||
|
||||
|
||||
async def create_pr_comment_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Create PR discussion comment in write mode."""
|
||||
parsed = CreatePrCommentArgs.model_validate(arguments)
|
||||
try:
|
||||
comment = await gitea.create_pr_comment(
|
||||
parsed.owner,
|
||||
parsed.repo,
|
||||
parsed.pull_number,
|
||||
parsed.body,
|
||||
)
|
||||
return {
|
||||
"id": comment.get("id", 0),
|
||||
"pull_number": parsed.pull_number,
|
||||
"body": limit_text(str(comment.get("body", ""))),
|
||||
"url": comment.get("html_url", ""),
|
||||
}
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
# Let auth/authz failures surface so the server returns actionable
|
||||
# re-authorization guidance instead of a generic internal error.
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to create PR comment: {exc}") from exc
|
||||
|
||||
|
||||
async def add_labels_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Add labels to an issue or pull request."""
|
||||
parsed = AddLabelsArgs.model_validate(arguments)
|
||||
try:
|
||||
result = await gitea.add_labels(
|
||||
parsed.owner, parsed.repo, parsed.issue_number, parsed.labels
|
||||
)
|
||||
label_names = []
|
||||
if isinstance(result, dict):
|
||||
label_names = [label.get("name", "") for label in result.get("labels", [])]
|
||||
return {
|
||||
"issue_number": parsed.issue_number,
|
||||
"labels": label_names or parsed.labels,
|
||||
}
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
# Let auth/authz failures surface so the server returns actionable
|
||||
# re-authorization guidance instead of a generic internal error.
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to add labels: {exc}") from exc
|
||||
|
||||
|
||||
async def assign_issue_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Assign users to an issue or pull request."""
|
||||
parsed = AssignIssueArgs.model_validate(arguments)
|
||||
try:
|
||||
result = await gitea.assign_issue(
|
||||
parsed.owner,
|
||||
parsed.repo,
|
||||
parsed.issue_number,
|
||||
parsed.assignees,
|
||||
)
|
||||
assignees = []
|
||||
if isinstance(result, dict):
|
||||
assignees = [assignee.get("login", "") for assignee in result.get("assignees", [])]
|
||||
return {
|
||||
"issue_number": parsed.issue_number,
|
||||
"assignees": assignees or parsed.assignees,
|
||||
}
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
# Let auth/authz failures surface so the server returns actionable
|
||||
# re-authorization guidance instead of a generic internal error.
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to assign issue: {exc}") from exc
|
||||
|
||||
|
||||
async def create_pull_request_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Open a pull request in write mode."""
|
||||
parsed = CreatePullRequestArgs.model_validate(arguments)
|
||||
try:
|
||||
pull = await gitea.create_pull_request(
|
||||
parsed.owner,
|
||||
parsed.repo,
|
||||
title=parsed.title,
|
||||
head=parsed.head,
|
||||
base=parsed.base,
|
||||
body=parsed.body,
|
||||
)
|
||||
return {
|
||||
"number": pull.get("number", 0),
|
||||
"title": limit_text(str(pull.get("title", ""))),
|
||||
"state": pull.get("state", ""),
|
||||
"url": pull.get("html_url", ""),
|
||||
}
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to create pull request: {exc}") from exc
|
||||
|
||||
|
||||
async def create_release_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Create a release in write mode."""
|
||||
parsed = CreateReleaseArgs.model_validate(arguments)
|
||||
try:
|
||||
release = await gitea.create_release(
|
||||
parsed.owner,
|
||||
parsed.repo,
|
||||
tag_name=parsed.tag_name,
|
||||
name=parsed.name,
|
||||
body=parsed.body,
|
||||
draft=parsed.draft,
|
||||
prerelease=parsed.prerelease,
|
||||
target=parsed.target,
|
||||
)
|
||||
return {
|
||||
"id": release.get("id", 0),
|
||||
"tag_name": release.get("tag_name", ""),
|
||||
"name": limit_text(str(release.get("name", ""))),
|
||||
"draft": release.get("draft", False),
|
||||
"prerelease": release.get("prerelease", False),
|
||||
"url": release.get("html_url", ""),
|
||||
}
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to create release: {exc}") from exc
|
||||
|
||||
|
||||
async def edit_release_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Edit an existing release in write mode."""
|
||||
parsed = EditReleaseArgs.model_validate(arguments)
|
||||
try:
|
||||
release = await gitea.edit_release(
|
||||
parsed.owner,
|
||||
parsed.repo,
|
||||
parsed.release_id,
|
||||
name=parsed.name,
|
||||
body=parsed.body,
|
||||
draft=parsed.draft,
|
||||
prerelease=parsed.prerelease,
|
||||
)
|
||||
return {
|
||||
"id": release.get("id", parsed.release_id),
|
||||
"tag_name": release.get("tag_name", ""),
|
||||
"name": limit_text(str(release.get("name", ""))),
|
||||
"draft": release.get("draft", False),
|
||||
"prerelease": release.get("prerelease", False),
|
||||
"url": release.get("html_url", ""),
|
||||
}
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to edit release: {exc}") from exc
|
||||
|
||||
|
||||
async def create_branch_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Create a branch in write mode."""
|
||||
parsed = CreateBranchArgs.model_validate(arguments)
|
||||
try:
|
||||
branch = await gitea.create_branch(
|
||||
parsed.owner,
|
||||
parsed.repo,
|
||||
new_branch_name=parsed.new_branch_name,
|
||||
old_branch_name=parsed.old_branch_name,
|
||||
)
|
||||
commit = branch.get("commit", {}) if isinstance(branch, dict) else {}
|
||||
return {
|
||||
"name": branch.get("name", parsed.new_branch_name),
|
||||
"commit": commit.get("id", "") if isinstance(commit, dict) else "",
|
||||
}
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to create branch: {exc}") from exc
|
||||
|
||||
|
||||
async def create_milestone_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Create a milestone in write mode."""
|
||||
parsed = CreateMilestoneArgs.model_validate(arguments)
|
||||
try:
|
||||
milestone = await gitea.create_milestone(
|
||||
parsed.owner,
|
||||
parsed.repo,
|
||||
title=parsed.title,
|
||||
description=parsed.description,
|
||||
due_on=parsed.due_on,
|
||||
)
|
||||
return {
|
||||
"id": milestone.get("id", 0),
|
||||
"title": limit_text(str(milestone.get("title", ""))),
|
||||
"state": milestone.get("state", ""),
|
||||
"url": milestone.get("html_url", ""),
|
||||
}
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to create milestone: {exc}") from exc
|
||||
|
||||
|
||||
async def edit_issue_comment_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Edit an existing issue or PR comment in write mode."""
|
||||
parsed = EditIssueCommentArgs.model_validate(arguments)
|
||||
try:
|
||||
comment = await gitea.edit_issue_comment(
|
||||
parsed.owner,
|
||||
parsed.repo,
|
||||
parsed.comment_id,
|
||||
parsed.body,
|
||||
)
|
||||
return {
|
||||
"id": comment.get("id", parsed.comment_id),
|
||||
"body": limit_text(str(comment.get("body", ""))),
|
||||
"url": comment.get("html_url", ""),
|
||||
}
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to edit comment: {exc}") from exc
|
||||
Reference in new issue
Block a user