From 4ac9626eb534555f0078f6ed118533891e046aa1 Mon Sep 17 00:00:00 2001 From: latte Date: Wed, 14 Oct 2026 15:40:37 +0200 Subject: [PATCH] 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). --- policy.yaml | 23 - scripts/check_key_age.py | 140 -- scripts/generate_api_key.py | 113 -- scripts/rotate_api_key.py | 171 --- src/aegis_gitea_mcp/auth.py | 206 --- src/aegis_gitea_mcp/authz.py | 342 ----- src/aegis_gitea_mcp/automation.py | 229 ---- src/aegis_gitea_mcp/cache.py | 71 - src/aegis_gitea_mcp/gitea_client.py | 1263 ------------------ src/aegis_gitea_mcp/mcp_protocol.py | 761 ----------- src/aegis_gitea_mcp/oauth.py | 386 ------ src/aegis_gitea_mcp/oauth_flow.py | 401 ------ src/aegis_gitea_mcp/observability.py | 98 -- src/aegis_gitea_mcp/policy.py | 265 ---- src/aegis_gitea_mcp/registry.py | 151 --- src/aegis_gitea_mcp/server.py | 1513 ---------------------- src/aegis_gitea_mcp/server_entry.py | 34 - src/aegis_gitea_mcp/stdio_app.py | 267 ---- src/aegis_gitea_mcp/tools/__init__.py | 53 - src/aegis_gitea_mcp/tools/arguments.py | 689 ---------- src/aegis_gitea_mcp/tools/raw_tools.py | 141 -- src/aegis_gitea_mcp/tools/read_tools.py | 850 ------------ src/aegis_gitea_mcp/tools/repository.py | 190 --- src/aegis_gitea_mcp/tools/write_tools.py | 413 ------ tests/test_auth.py | 208 --- tests/test_authz.py | 261 ---- tests/test_automation.py | 155 --- tests/test_automation_manager.py | 140 -- tests/test_cache.py | 72 - tests/test_classifier.py | 128 -- tests/test_core_boundary.py | 63 - tests/test_gitea_client.py | 413 ------ tests/test_integration.py | 121 -- tests/test_oauth.py | 640 --------- tests/test_oauth_oidc.py | 215 --- tests/test_policy.py | 148 --- tests/test_raw_api.py | 321 ----- tests/test_repository_tools.py | 172 --- tests/test_server.py | 862 ------------ tests/test_stdio_app.py | 210 --- tests/test_tools_extended.py | 542 -------- 41 files changed, 13441 deletions(-) delete mode 100644 policy.yaml delete mode 100755 scripts/check_key_age.py delete mode 100755 scripts/generate_api_key.py delete mode 100755 scripts/rotate_api_key.py delete mode 100644 src/aegis_gitea_mcp/auth.py delete mode 100644 src/aegis_gitea_mcp/authz.py delete mode 100644 src/aegis_gitea_mcp/automation.py delete mode 100644 src/aegis_gitea_mcp/cache.py delete mode 100644 src/aegis_gitea_mcp/gitea_client.py delete mode 100644 src/aegis_gitea_mcp/mcp_protocol.py delete mode 100644 src/aegis_gitea_mcp/oauth.py delete mode 100644 src/aegis_gitea_mcp/oauth_flow.py delete mode 100644 src/aegis_gitea_mcp/observability.py delete mode 100644 src/aegis_gitea_mcp/policy.py delete mode 100644 src/aegis_gitea_mcp/registry.py delete mode 100644 src/aegis_gitea_mcp/server.py delete mode 100644 src/aegis_gitea_mcp/server_entry.py delete mode 100644 src/aegis_gitea_mcp/stdio_app.py delete mode 100644 src/aegis_gitea_mcp/tools/__init__.py delete mode 100644 src/aegis_gitea_mcp/tools/arguments.py delete mode 100644 src/aegis_gitea_mcp/tools/raw_tools.py delete mode 100644 src/aegis_gitea_mcp/tools/read_tools.py delete mode 100644 src/aegis_gitea_mcp/tools/repository.py delete mode 100644 src/aegis_gitea_mcp/tools/write_tools.py delete mode 100644 tests/test_auth.py delete mode 100644 tests/test_authz.py delete mode 100644 tests/test_automation.py delete mode 100644 tests/test_automation_manager.py delete mode 100644 tests/test_cache.py delete mode 100644 tests/test_classifier.py delete mode 100644 tests/test_core_boundary.py delete mode 100644 tests/test_gitea_client.py delete mode 100644 tests/test_integration.py delete mode 100644 tests/test_oauth.py delete mode 100644 tests/test_oauth_oidc.py delete mode 100644 tests/test_policy.py delete mode 100644 tests/test_raw_api.py delete mode 100644 tests/test_repository_tools.py delete mode 100644 tests/test_server.py delete mode 100644 tests/test_stdio_app.py delete mode 100644 tests/test_tools_extended.py diff --git a/policy.yaml b/policy.yaml deleted file mode 100644 index 9e1028d..0000000 --- a/policy.yaml +++ /dev/null @@ -1,23 +0,0 @@ -defaults: - read: allow - write: deny - -tools: - deny: [] - # The generic `gitea_request` tool authorizes each call under a coarse virtual - # tool name of the form `gitea_request::`, e.g. - # `gitea_request:GET:repos` or `gitea_request:DELETE:repos`. To keep raw - # dispatch read-only while still allowing GETs, deny the write methods here: - # - # deny: - # - gitea_request:POST:repos - # - gitea_request:PUT:repos - # - gitea_request:PATCH:repos - # - gitea_request:DELETE:repos - # - # NOTE: The admin/credential denylist (/admin, *tokens*, *secrets*, *hooks*, - # *keys*, applications/oauth2, runner registration tokens) is enforced in the - # handler independently of this file and is NOT configured here. It can only be - # overridden by setting RAW_API_ALLOW_SENSITIVE=true. - -repositories: {} diff --git a/scripts/check_key_age.py b/scripts/check_key_age.py deleted file mode 100755 index 5200b8d..0000000 --- a/scripts/check_key_age.py +++ /dev/null @@ -1,140 +0,0 @@ -#!/usr/bin/env python3 -"""Check API key age and alert if rotation needed.""" - -import re -import sys -from datetime import datetime, timezone -from pathlib import Path - - -def main() -> None: - """Check API key age from keys/ metadata directory.""" - print("=" * 70) - print("AegisGitea MCP - API Key Age Check") - print("=" * 70) - print() - - # Check keys directory - keys_dir = Path(__file__).parent.parent / "keys" - - if not keys_dir.exists(): - print("⚠️ No keys/ directory found") - print() - print(" Key metadata is not being tracked.") - print(" Run scripts/generate_api_key.py and save metadata to track key age.") - print() - sys.exit(0) - - # Find all key metadata files - metadata_files = list(keys_dir.glob("key-*.txt")) - - if not metadata_files: - print("⚠️ No key metadata files found in keys/") - print() - print(" Run scripts/generate_api_key.py and save metadata to track key age.") - print() - sys.exit(0) - - print(f"Found {len(metadata_files)} key metadata file(s)") - print() - print("-" * 70) - print() - - now = datetime.now(timezone.utc) - warnings = [] - critical = [] - - for metadata_file in sorted(metadata_files): - with open(metadata_file, "r", encoding="utf-8") as f: - content = f.read() - - # Extract metadata - key_id_match = re.search(r"Key ID:\s+([^\n]+)", content) - created_match = re.search(r"Created:\s+([^\n]+)", content) - expires_match = re.search(r"Expires:\s+([^\n]+)", content) - - if not all([key_id_match, created_match, expires_match]): - print(f"⚠️ Could not parse: {metadata_file.name}") - continue - - key_id = key_id_match.group(1).strip() - created_str = created_match.group(1).strip() - expires_str = expires_match.group(1).strip() - - # Parse dates - try: - created_at = datetime.fromisoformat(created_str.replace("Z", "+00:00")) - expires_at = datetime.fromisoformat(expires_str.replace("Z", "+00:00")) - except ValueError as e: - print(f"⚠️ Could not parse dates in: {metadata_file.name}") - print(f" Error: {e}") - continue - - # Calculate age and time to expiration - age_days = (now - created_at).days - days_to_expiration = (expires_at - now).days - - # Status - if days_to_expiration < 0: - status = "❌ EXPIRED" - critical.append(key_id) - elif days_to_expiration <= 7: - status = "⚠️ CRITICAL" - critical.append(key_id) - elif days_to_expiration <= 14: - status = "⚠️ WARNING" - warnings.append(key_id) - else: - status = "✓ OK" - - print(f"Key: {key_id}") - print(f" Created: {created_at.strftime('%Y-%m-%d')} ({age_days} days ago)") - print(f" Expires: {expires_at.strftime('%Y-%m-%d')} (in {days_to_expiration} days)") - print(f" Status: {status}") - print() - - print("-" * 70) - print() - - # Summary - if critical: - print("❌ CRITICAL: {} key(s) require immediate rotation!".format(len(critical))) - print() - print(" Keys:") - for key_id in critical: - print(f" • {key_id}") - print() - print(" Action: Run scripts/rotate_api_key.py NOW") - print() - sys.exit(2) - elif warnings: - print("⚠️ WARNING: {} key(s) will expire soon".format(len(warnings))) - print() - print(" Keys:") - for key_id in warnings: - print(f" • {key_id}") - print() - print(" Action: Schedule key rotation in the next few days") - print(" Run: scripts/rotate_api_key.py") - print() - sys.exit(1) - else: - print("✓ All keys are valid and within rotation schedule") - print() - print(" Next check: Run this script again in 1 week") - print() - sys.exit(0) - - -if __name__ == "__main__": - try: - main() - except KeyboardInterrupt: - print("\n\nOperation cancelled.") - sys.exit(1) - except Exception as e: - print(f"\n❌ Error: {e}", file=sys.stderr) - import traceback - - traceback.print_exc() - sys.exit(1) diff --git a/scripts/generate_api_key.py b/scripts/generate_api_key.py deleted file mode 100755 index 0d032a4..0000000 --- a/scripts/generate_api_key.py +++ /dev/null @@ -1,113 +0,0 @@ -#!/usr/bin/env python3 -"""Generate a cryptographically secure API key for AegisGitea MCP.""" - -import sys -from datetime import datetime, timedelta, timezone -from pathlib import Path - -# Add src to path for imports -sys.path.insert(0, str(Path(__file__).parent.parent / "src")) - -from aegis_gitea_mcp.auth import generate_api_key - - -def main() -> None: - """Generate and display a new API key.""" - print("=" * 70) - print("AegisGitea MCP - API Key Generator") - print("=" * 70) - print() - - # Get optional description - description = input("Enter description for this key (e.g., 'Claude Code'): ").strip() - if not description: - description = "Generated key" - - # Generate key - api_key = generate_api_key(length=64) - - # Calculate expiration date (90 days from now) - created_at = datetime.now(timezone.utc) - expires_at = created_at + timedelta(days=90) - - print() - print("✓ API Key Generated Successfully!") - print() - print("-" * 70) - print(f"Description: {description}") - print(f"Created: {created_at.strftime('%Y-%m-%d %H:%M:%S UTC')}") - print(f"Expires: {expires_at.strftime('%Y-%m-%d %H:%M:%S UTC')} (90 days)") - print("-" * 70) - print() - print("API KEY:") - print(f" {api_key}") - print() - print("-" * 70) - print() - print("📋 Next Steps:") - print() - print("1. Add this key to your .env file:") - print() - print(f" MCP_API_KEYS={api_key}") - print() - print(" (If you have multiple keys, separate them with commas)") - print() - print("2. Restart the MCP server:") - print() - print(" docker-compose restart aegis-mcp") - print() - print("3. Configure your MCP client:") - print() - print(" - Add the server in your MCP client settings") - print(" - Add custom header:") - print(f" Authorization: Bearer {api_key}") - print() - print("4. Test the connection:") - print() - print(" Ask the client: 'List my Gitea repositories'") - print() - print("-" * 70) - print() - print("⚠️ IMPORTANT:") - print() - print(" • Store this key securely - it won't be shown again") - print(" • This key should be rotated in 90 days") - print(" • Set a reminder to rotate before expiration") - print(" • Never commit this key to version control") - print() - print("=" * 70) - - # Offer to save metadata to file - save = input("\nSave key metadata to keys/ directory? [y/N]: ").strip().lower() - if save == "y": - keys_dir = Path(__file__).parent.parent / "keys" - keys_dir.mkdir(exist_ok=True) - - # Create metadata file - key_id = api_key[:12] - metadata_file = keys_dir / f"key-{key_id}-{created_at.strftime('%Y%m%d')}.txt" - - with open(metadata_file, "w", encoding="utf-8") as f: - f.write(f"API Key Metadata\n") - f.write(f"================\n\n") - f.write(f"Key ID: {key_id}...\n") - f.write(f"Description: {description}\n") - f.write(f"Created: {created_at.isoformat()}\n") - f.write(f"Expires: {expires_at.isoformat()}\n") - f.write(f"\n") - f.write(f"NOTE: The actual API key is NOT stored in this file for security.\n") - f.write(f" Only metadata is saved for reference.\n") - - print(f"\n✓ Metadata saved to: {metadata_file}") - print(f"\n (The actual key is NOT saved - only you have it)") - - -if __name__ == "__main__": - try: - main() - except KeyboardInterrupt: - print("\n\nOperation cancelled.") - sys.exit(1) - except Exception as e: - print(f"\n❌ Error: {e}", file=sys.stderr) - sys.exit(1) diff --git a/scripts/rotate_api_key.py b/scripts/rotate_api_key.py deleted file mode 100755 index 8167530..0000000 --- a/scripts/rotate_api_key.py +++ /dev/null @@ -1,171 +0,0 @@ -#!/usr/bin/env python3 -"""Rotate API key for AegisGitea MCP.""" - -import re -import sys -from datetime import datetime, timedelta, timezone -from pathlib import Path - -# Add src to path for imports -sys.path.insert(0, str(Path(__file__).parent.parent / "src")) - -from aegis_gitea_mcp.auth import generate_api_key - - -def main() -> None: - """Rotate API key in .env file.""" - print("=" * 70) - print("AegisGitea MCP - API Key Rotation") - print("=" * 70) - print() - - # Find .env file - env_file = Path(__file__).parent.parent / ".env" - - if not env_file.exists(): - print("❌ Error: .env file not found") - print(f" Expected location: {env_file}") - print() - print(" Please create .env from .env.example first") - sys.exit(1) - - # Read current .env - with open(env_file, "r", encoding="utf-8") as f: - env_content = f.read() - - # Check if MCP_API_KEYS exists - if "MCP_API_KEYS" not in env_content: - print("❌ Error: MCP_API_KEYS not found in .env file") - print() - print(" Please add MCP_API_KEYS to your .env file first") - sys.exit(1) - - # Extract current key - match = re.search(r"MCP_API_KEYS=([^\n]+)", env_content) - if not match: - print("❌ Error: Could not parse MCP_API_KEYS from .env") - sys.exit(1) - - current_keys = match.group(1).strip() - key_list = [k.strip() for k in current_keys.split(",") if k.strip()] - - print(f"Found {len(key_list)} existing key(s)") - print() - - # Show current keys (first 8 chars only) - for i, key in enumerate(key_list, 1): - key_hint = f"{key[:8]}...{key[-4:]}" if len(key) >= 12 else "invalid" - print(f" {i}. {key_hint}") - - print() - print("-" * 70) - print() - - # Generate new key - new_key = generate_api_key(length=64) - created_at = datetime.now(timezone.utc) - expires_at = created_at + timedelta(days=90) - - print("✓ New API key generated!") - print() - print(f"Created: {created_at.strftime('%Y-%m-%d %H:%M:%S UTC')}") - print(f"Expires: {expires_at.strftime('%Y-%m-%d %H:%M:%S UTC')} (90 days)") - print() - print("-" * 70) - print() - - # Ask what to do with old keys - print("Rotation Strategy:") - print() - print(" 1. Replace all keys with new key (recommended)") - print(" 2. Add new key, keep old keys (grace period)") - print(" 3. Cancel") - print() - - choice = input("Choose option [1/2/3]: ").strip() - - if choice == "3": - print("\nRotation cancelled.") - sys.exit(0) - elif choice == "2": - # Add new key to list - key_list.append(new_key) - new_keys_str = ",".join(key_list) - print("\n✓ New key will be added (total: {} keys)".format(len(key_list))) - print("\n⚠️ IMPORTANT: Remove old keys manually after updating the client config") - elif choice == "1": - # Replace with only new key - new_keys_str = new_key - print("\n✓ All old keys will be replaced with new key") - else: - print("\n❌ Invalid choice. Operation cancelled.") - sys.exit(1) - - print() - print("-" * 70) - print() - - # Update .env file - new_env_content = re.sub(r"MCP_API_KEYS=([^\n]+)", f"MCP_API_KEYS={new_keys_str}", env_content) - - # Backup old .env - backup_file = env_file.with_suffix(f".env.backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}") - with open(backup_file, "w", encoding="utf-8") as f: - f.write(env_content) - - print(f"✓ Backed up old .env to: {backup_file.name}") - - # Write new .env - with open(env_file, "w", encoding="utf-8") as f: - f.write(new_env_content) - - print(f"✓ Updated .env file with new key(s)") - print() - print("-" * 70) - print() - print("📋 Next Steps:") - print() - print("1. Restart the MCP server:") - print() - print(" docker-compose restart aegis-mcp") - print() - print("2. Update your MCP client configuration:") - print() - print(" - Update the MCP server entry in your client") - print(" - Update Authorization header:") - print(f" Authorization: Bearer {new_key}") - print() - print("3. Test the connection:") - print() - print(" Ask the client: 'List my Gitea repositories'") - print() - print("4. If using grace period (option 2):") - print() - print(" - After confirming the client works with the new key") - print(" - Manually remove old keys from .env") - print(" - Restart server again") - print() - print("-" * 70) - print() - print("⚠️ IMPORTANT:") - print() - print(f" • New API Key: {new_key}") - print(" • Store this securely - it won't be shown again") - print(" • Set a reminder to rotate in 90 days") - print(" • Old .env backed up to:", backup_file.name) - print() - print("=" * 70) - - -if __name__ == "__main__": - try: - main() - except KeyboardInterrupt: - print("\n\nOperation cancelled.") - sys.exit(1) - except Exception as e: - print(f"\n❌ Error: {e}", file=sys.stderr) - import traceback - - traceback.print_exc() - sys.exit(1) diff --git a/src/aegis_gitea_mcp/auth.py b/src/aegis_gitea_mcp/auth.py deleted file mode 100644 index 48a0956..0000000 --- a/src/aegis_gitea_mcp/auth.py +++ /dev/null @@ -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 " - - 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 ` 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 diff --git a/src/aegis_gitea_mcp/authz.py b/src/aegis_gitea_mcp/authz.py deleted file mode 100644 index ff82442..0000000 --- a/src/aegis_gitea_mcp/authz.py +++ /dev/null @@ -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") diff --git a/src/aegis_gitea_mcp/automation.py b/src/aegis_gitea_mcp/automation.py deleted file mode 100644 index 91e528f..0000000 --- a/src/aegis_gitea_mcp/automation.py +++ /dev/null @@ -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, - } diff --git a/src/aegis_gitea_mcp/cache.py b/src/aegis_gitea_mcp/cache.py deleted file mode 100644 index 53540bc..0000000 --- a/src/aegis_gitea_mcp/cache.py +++ /dev/null @@ -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) diff --git a/src/aegis_gitea_mcp/gitea_client.py b/src/aegis_gitea_mcp/gitea_client.py deleted file mode 100644 index 84d2eed..0000000 --- a/src/aegis_gitea_mcp/gitea_client.py +++ /dev/null @@ -1,1263 +0,0 @@ -"""Gitea API client with hardened request handling.""" - -from __future__ import annotations - -from typing import Any -from urllib.parse import quote - -from httpx import AsyncClient, Response - -from aegis_gitea_mcp.audit import get_audit_logger -from aegis_gitea_mcp.config import get_settings - - -class GiteaError(Exception): - """Base exception for Gitea API errors.""" - - -class GiteaAuthenticationError(GiteaError): - """Raised when authentication with Gitea fails.""" - - -class GiteaAuthorizationError(GiteaError): - """Raised when the authenticated user lacks permission for an operation.""" - - -class GiteaNotFoundError(GiteaError): - """Raised when requested resource is not found.""" - - -class GiteaClient: - """Client for interacting with Gitea API as the authenticated end-user.""" - - def __init__(self, token: str, base_url: str | None = None) -> None: - """Initialize Gitea client. - - Args: - token: OAuth access token for the authenticated user. - base_url: Optional base URL override. - """ - self.settings = get_settings() - self.audit = get_audit_logger() - self.base_url = (base_url or self.settings.gitea_base_url).rstrip("/") - self.token = token.strip() - if not self.token: - raise ValueError("GiteaClient requires a non-empty per-user OAuth token") - self.client: AsyncClient | None = None - - async def __aenter__(self) -> GiteaClient: - """Create async HTTP client context.""" - self.client = AsyncClient( - base_url=self.base_url, - headers={ - "Authorization": f"Bearer {self.token}", - "Content-Type": "application/json", - }, - timeout=self.settings.request_timeout_seconds, - follow_redirects=True, - ) - return self - - async def __aexit__(self, *args: Any) -> None: - """Close async HTTP client context.""" - if self.client: - await self.client.aclose() - - def _ensure_client(self) -> AsyncClient: - """Return initialized HTTP client. - - Raises: - RuntimeError: If called outside async context manager. - """ - if not self.client: - raise RuntimeError("Client not initialized - use async context manager") - return self.client - - def _handle_response(self, response: Response, correlation_id: str) -> Any: - """Handle HTTP response and map to domain exceptions.""" - if response.status_code == 401: - self.audit.log_security_event( - event_type="authentication_failure", - description="Gitea API returned 401 Unauthorized", - severity="high", - metadata={"correlation_id": correlation_id}, - ) - raise GiteaAuthenticationError("Authentication failed - user token rejected") - - if response.status_code == 403: - self.audit.log_access_denied( - tool_name="gitea_api", - reason="authenticated user lacks permission", - correlation_id=correlation_id, - ) - raise GiteaAuthorizationError("Authenticated user lacks permission for this operation") - - if response.status_code == 404: - raise GiteaNotFoundError("Resource not found") - - if response.status_code >= 400: - error_msg = f"Gitea API error: {response.status_code}" - try: - error_data = response.json() - message = error_data.get("message") if isinstance(error_data, dict) else None - if message: - error_msg = f"{error_msg} - {message}" - except Exception: - pass - raise GiteaError(error_msg) - - try: - return response.json() - except Exception: - return {} - - async def _request( - self, - method: str, - endpoint: str, - *, - correlation_id: str, - params: dict[str, Any] | None = None, - json_body: dict[str, Any] | None = None, - ) -> Any: - """Execute a request to Gitea API with shared error handling.""" - client = self._ensure_client() - response = await client.request(method=method, url=endpoint, params=params, json=json_body) - return self._handle_response(response, correlation_id) - - async def get_current_user(self) -> dict[str, Any]: - """Get current authenticated user profile.""" - correlation_id = self.audit.log_tool_invocation( - tool_name="get_current_user", - result_status="pending", - ) - try: - result = await self._request("GET", "/api/v1/user", correlation_id=correlation_id) - self.audit.log_tool_invocation( - tool_name="get_current_user", - correlation_id=correlation_id, - result_status="success", - ) - return result if isinstance(result, dict) else {} - except Exception as exc: - self.audit.log_tool_invocation( - tool_name="get_current_user", - correlation_id=correlation_id, - result_status="error", - error=str(exc), - ) - raise - - async def raw_request( - self, - method: str, - endpoint: str, - *, - params: dict[str, Any] | None = None, - json_body: dict[str, Any] | None = None, - ) -> Any: - """Dispatch an arbitrary Gitea REST request for the ``gitea_request`` tool. - - Only the method and normalized endpoint are audited; the request body is - never logged so secrets embedded in payloads are not persisted. - """ - correlation_id = self.audit.log_tool_invocation( - tool_name="gitea_request", - params={"method": method, "path": endpoint}, - result_status="pending", - ) - try: - result = await self._request( - method, - endpoint, - correlation_id=correlation_id, - params=params, - json_body=json_body, - ) - self.audit.log_tool_invocation( - tool_name="gitea_request", - correlation_id=correlation_id, - result_status="success", - params={"method": method, "path": endpoint}, - ) - return result - except Exception as exc: - self.audit.log_tool_invocation( - tool_name="gitea_request", - correlation_id=correlation_id, - result_status="error", - params={"method": method, "path": endpoint}, - error=str(exc), - ) - raise - - async def list_repositories(self) -> list[dict[str, Any]]: - """List repositories visible to the authenticated user.""" - correlation_id = self.audit.log_tool_invocation( - tool_name="list_repositories", - result_status="pending", - ) - try: - result = await self._request("GET", "/api/v1/user/repos", correlation_id=correlation_id) - repositories = result if isinstance(result, list) else [] - self.audit.log_tool_invocation( - tool_name="list_repositories", - correlation_id=correlation_id, - result_status="success", - params={"count": len(repositories)}, - ) - return repositories - except Exception as exc: - self.audit.log_tool_invocation( - tool_name="list_repositories", - correlation_id=correlation_id, - result_status="error", - error=str(exc), - ) - raise - - async def list_user_repositories(self, login: str) -> list[dict[str, Any]]: - """List repositories the given user owns or contributes to. - - Used in service-PAT mode so ``list_repositories`` returns repositories - scoped to the authenticated user instead of everything the service token - can see. Resolves the user id, then queries Gitea's repo search with the - ``uid`` filter. Visibility of private repos still depends on what the - service token itself can see. - """ - correlation_id = self.audit.log_tool_invocation( - tool_name="list_user_repositories", - result_status="pending", - ) - try: - user = await self._request( - "GET", - f"/api/v1/users/{quote(login, safe='')}", - correlation_id=correlation_id, - ) - uid = user.get("id") if isinstance(user, dict) else None - if not isinstance(uid, int): - return [] - - result = await self._request( - "GET", - "/api/v1/repos/search", - params={"uid": uid, "limit": 50, "page": 1}, - correlation_id=correlation_id, - ) - repositories: list[dict[str, Any]] = [] - if isinstance(result, dict): - data = result.get("data", []) - if isinstance(data, list): - repositories = [item for item in data if isinstance(item, dict)] - - self.audit.log_tool_invocation( - tool_name="list_user_repositories", - correlation_id=correlation_id, - result_status="success", - params={"login": login, "count": len(repositories)}, - ) - return repositories - except Exception as exc: - self.audit.log_tool_invocation( - tool_name="list_user_repositories", - correlation_id=correlation_id, - result_status="error", - error=str(exc), - ) - raise - - async def get_repository(self, owner: str, repo: str) -> dict[str, Any]: - """Get repository metadata.""" - repo_id = f"{owner}/{repo}" - enc_owner = quote(owner, safe="") - enc_repo = quote(repo, safe="") - correlation_id = self.audit.log_tool_invocation( - tool_name="get_repository", - repository=repo_id, - result_status="pending", - ) - try: - result = await self._request( - "GET", - f"/api/v1/repos/{enc_owner}/{enc_repo}", - correlation_id=correlation_id, - ) - self.audit.log_tool_invocation( - tool_name="get_repository", - repository=repo_id, - correlation_id=correlation_id, - result_status="success", - ) - return result if isinstance(result, dict) else {} - except Exception as exc: - self.audit.log_tool_invocation( - tool_name="get_repository", - repository=repo_id, - correlation_id=correlation_id, - result_status="error", - error=str(exc), - ) - raise - - async def get_file_contents( - self, - owner: str, - repo: str, - filepath: str, - ref: str = "main", - ) -> dict[str, Any]: - """Get file contents from a repository.""" - repo_id = f"{owner}/{repo}" - enc_owner = quote(owner, safe="") - enc_repo = quote(repo, safe="") - enc_filepath = quote(filepath, safe="/") - correlation_id = self.audit.log_tool_invocation( - tool_name="get_file_contents", - repository=repo_id, - target=filepath, - params={"ref": ref}, - result_status="pending", - ) - try: - result = await self._request( - "GET", - f"/api/v1/repos/{enc_owner}/{enc_repo}/contents/{enc_filepath}", - params={"ref": ref}, - correlation_id=correlation_id, - ) - - if not isinstance(result, dict): - raise GiteaError("Unexpected response type for file contents") - - file_size = int(result.get("size", 0)) - if file_size > self.settings.max_file_size_bytes: - error_msg = ( - f"File size ({file_size} bytes) exceeds limit " - f"({self.settings.max_file_size_bytes} bytes)" - ) - self.audit.log_security_event( - event_type="file_size_limit_exceeded", - description=error_msg, - severity="low", - metadata={ - "repository": repo_id, - "filepath": filepath, - "file_size": file_size, - "limit": self.settings.max_file_size_bytes, - }, - ) - raise GiteaError(error_msg) - - self.audit.log_tool_invocation( - tool_name="get_file_contents", - repository=repo_id, - target=filepath, - correlation_id=correlation_id, - result_status="success", - params={"ref": ref, "size": file_size}, - ) - return result - except Exception as exc: - self.audit.log_tool_invocation( - tool_name="get_file_contents", - repository=repo_id, - target=filepath, - correlation_id=correlation_id, - result_status="error", - error=str(exc), - ) - raise - - async def get_tree( - self, - owner: str, - repo: str, - ref: str = "main", - recursive: bool = False, - ) -> dict[str, Any]: - """Get repository tree at given ref.""" - repo_id = f"{owner}/{repo}" - enc_owner = quote(owner, safe="") - enc_repo = quote(repo, safe="") - enc_ref = quote(ref, safe="/") - correlation_id = self.audit.log_tool_invocation( - tool_name="get_tree", - repository=repo_id, - params={"ref": ref, "recursive": recursive}, - result_status="pending", - ) - try: - result = await self._request( - "GET", - f"/api/v1/repos/{enc_owner}/{enc_repo}/git/trees/{enc_ref}", - params={"recursive": str(recursive).lower()}, - correlation_id=correlation_id, - ) - tree_data = result if isinstance(result, dict) else {} - self.audit.log_tool_invocation( - tool_name="get_tree", - repository=repo_id, - correlation_id=correlation_id, - result_status="success", - params={ - "ref": ref, - "recursive": recursive, - "count": len(tree_data.get("tree", [])), - }, - ) - return tree_data - except Exception as exc: - self.audit.log_tool_invocation( - tool_name="get_tree", - repository=repo_id, - correlation_id=correlation_id, - result_status="error", - error=str(exc), - ) - raise - - async def search_code( - self, - owner: str, - repo: str, - query: str, - *, - ref: str, - page: int, - limit: int, - ) -> dict[str, Any]: - """Search repository code by query.""" - correlation_id = self.audit.log_tool_invocation( - tool_name="search_code", - repository=f"{owner}/{repo}", - params={"query": query, "ref": ref, "page": page, "limit": limit}, - result_status="pending", - ) - try: - result = await self._request( - "GET", - f"/api/v1/repos/{quote(owner, safe='')}/{quote(repo, safe='')}/search", - params={"q": query, "page": page, "limit": limit, "ref": ref}, - correlation_id=correlation_id, - ) - self.audit.log_tool_invocation( - tool_name="search_code", - repository=f"{owner}/{repo}", - correlation_id=correlation_id, - result_status="success", - ) - return result if isinstance(result, dict) else {} - except Exception as exc: - self.audit.log_tool_invocation( - tool_name="search_code", - repository=f"{owner}/{repo}", - correlation_id=correlation_id, - result_status="error", - error=str(exc), - ) - raise - - async def list_commits( - self, - owner: str, - repo: str, - *, - ref: str, - page: int, - limit: int, - ) -> list[dict[str, Any]]: - """List commits for a repository ref.""" - result = await self._request( - "GET", - f"/api/v1/repos/{quote(owner, safe='')}/{quote(repo, safe='')}/commits", - params={"sha": ref, "page": page, "limit": limit}, - correlation_id=str( - self.audit.log_tool_invocation(tool_name="list_commits", result_status="pending") - ), - ) - return result if isinstance(result, list) else [] - - async def get_commit_diff(self, owner: str, repo: str, sha: str) -> dict[str, Any]: - """Get detailed commit including changed files and patch metadata.""" - enc_owner = quote(owner, safe="") - enc_repo = quote(repo, safe="") - enc_sha = quote(sha, safe="/") - result = await self._request( - "GET", - f"/api/v1/repos/{enc_owner}/{enc_repo}/git/commits/{enc_sha}", - correlation_id=str( - self.audit.log_tool_invocation(tool_name="get_commit_diff", result_status="pending") - ), - ) - return result if isinstance(result, dict) else {} - - async def compare_refs(self, owner: str, repo: str, base: str, head: str) -> dict[str, Any]: - """Compare two refs and return commit/file deltas.""" - enc_owner = quote(owner, safe="") - enc_repo = quote(repo, safe="") - enc_base = quote(base, safe="/") - enc_head = quote(head, safe="/") - result = await self._request( - "GET", - f"/api/v1/repos/{enc_owner}/{enc_repo}/compare/{enc_base}...{enc_head}", - correlation_id=str( - self.audit.log_tool_invocation(tool_name="compare_refs", result_status="pending") - ), - ) - return result if isinstance(result, dict) else {} - - async def list_issues( - self, - owner: str, - repo: str, - *, - state: str, - page: int, - limit: int, - labels: list[str] | None = None, - ) -> list[dict[str, Any]]: - """List repository issues.""" - params: dict[str, Any] = {"state": state, "page": page, "limit": limit} - if labels: - params["labels"] = ",".join(labels) - - result = await self._request( - "GET", - f"/api/v1/repos/{quote(owner, safe='')}/{quote(repo, safe='')}/issues", - params=params, - correlation_id=str( - self.audit.log_tool_invocation(tool_name="list_issues", result_status="pending") - ), - ) - return result if isinstance(result, list) else [] - - async def get_issue(self, owner: str, repo: str, index: int) -> dict[str, Any]: - """Get issue details.""" - result = await self._request( - "GET", - f"/api/v1/repos/{quote(owner, safe='')}/{quote(repo, safe='')}/issues/{index}", - correlation_id=str( - self.audit.log_tool_invocation(tool_name="get_issue", result_status="pending") - ), - ) - return result if isinstance(result, dict) else {} - - async def list_pull_requests( - self, - owner: str, - repo: str, - *, - state: str, - page: int, - limit: int, - ) -> list[dict[str, Any]]: - """List pull requests for repository.""" - result = await self._request( - "GET", - f"/api/v1/repos/{quote(owner, safe='')}/{quote(repo, safe='')}/pulls", - params={"state": state, "page": page, "limit": limit}, - correlation_id=str( - self.audit.log_tool_invocation( - tool_name="list_pull_requests", result_status="pending" - ) - ), - ) - return result if isinstance(result, list) else [] - - async def get_pull_request(self, owner: str, repo: str, index: int) -> dict[str, Any]: - """Get a single pull request.""" - result = await self._request( - "GET", - f"/api/v1/repos/{quote(owner, safe='')}/{quote(repo, safe='')}/pulls/{index}", - correlation_id=str( - self.audit.log_tool_invocation( - tool_name="get_pull_request", result_status="pending" - ) - ), - ) - return result if isinstance(result, dict) else {} - - async def list_labels( - self, owner: str, repo: str, *, page: int, limit: int - ) -> list[dict[str, Any]]: - """List repository labels.""" - result = await self._request( - "GET", - f"/api/v1/repos/{quote(owner, safe='')}/{quote(repo, safe='')}/labels", - params={"page": page, "limit": limit}, - correlation_id=str( - self.audit.log_tool_invocation(tool_name="list_labels", result_status="pending") - ), - ) - return result if isinstance(result, list) else [] - - async def list_tags( - self, owner: str, repo: str, *, page: int, limit: int - ) -> list[dict[str, Any]]: - """List repository tags.""" - result = await self._request( - "GET", - f"/api/v1/repos/{quote(owner, safe='')}/{quote(repo, safe='')}/tags", - params={"page": page, "limit": limit}, - correlation_id=str( - self.audit.log_tool_invocation(tool_name="list_tags", result_status="pending") - ), - ) - return result if isinstance(result, list) else [] - - async def list_releases( - self, - owner: str, - repo: str, - *, - page: int, - limit: int, - ) -> list[dict[str, Any]]: - """List repository releases.""" - result = await self._request( - "GET", - f"/api/v1/repos/{quote(owner, safe='')}/{quote(repo, safe='')}/releases", - params={"page": page, "limit": limit}, - correlation_id=str( - self.audit.log_tool_invocation(tool_name="list_releases", result_status="pending") - ), - ) - return result if isinstance(result, list) else [] - - async def _resolve_label_ids( - self, owner: str, repo: str, names: list[str], *, correlation_id: str - ) -> list[int]: - """Resolve label names to Gitea label ids for a repository. - - Gitea's issue/label APIs require numeric label ids, not names. This maps - the caller-supplied names (case-insensitive) to ids and raises a clear - error for any name that does not exist in the repository. - """ - existing = await self._request( - "GET", - f"/api/v1/repos/{quote(owner, safe='')}/{quote(repo, safe='')}/labels", - params={"limit": 100}, - correlation_id=correlation_id, - ) - by_name: dict[str, int] = {} - if isinstance(existing, list): - for item in existing: - if isinstance(item, dict): - label_name = str(item.get("name", "")) - label_id = item.get("id") - if label_name and isinstance(label_id, int): - by_name[label_name.lower()] = label_id - - ids: list[int] = [] - unknown: list[str] = [] - for name in names: - match = by_name.get(name.strip().lower()) - if match is None: - unknown.append(name) - else: - ids.append(match) - if unknown: - raise GiteaError( - f"Unknown label(s) for {owner}/{repo}: {', '.join(unknown)}. " - "Create them first with create_label." - ) - return ids - - async def _resolve_milestone_id( - self, owner: str, repo: str, milestone: int | str, *, correlation_id: str - ) -> int: - """Resolve a milestone id or title to a numeric milestone id. - - Gitea's issue API requires a numeric milestone id. An integer is used - as-is (``0`` clears the milestone); a string is resolved - case-insensitively against the repository's milestones (open or closed) - and raises a clear error when no title matches. - """ - if isinstance(milestone, int): - return milestone - title = milestone.strip() - existing = await self._request( - "GET", - f"/api/v1/repos/{quote(owner, safe='')}/{quote(repo, safe='')}/milestones", - params={"state": "all", "limit": 100}, - correlation_id=correlation_id, - ) - by_title: dict[str, int] = {} - if isinstance(existing, list): - for item in existing: - if isinstance(item, dict): - m_title = str(item.get("title", "")) - m_id = item.get("id") - if m_title and isinstance(m_id, int): - by_title[m_title.lower()] = m_id - match = by_title.get(title.lower()) - if match is None: - raise GiteaError( - f"Unknown milestone for {owner}/{repo}: {title}. " - "Create it first with create_milestone." - ) - return match - - async def create_issue( - self, - owner: str, - repo: str, - *, - title: str, - body: str, - labels: list[str] | None = None, - assignees: list[str] | None = None, - milestone: int | str | None = None, - ) -> dict[str, Any]: - """Create repository issue.""" - correlation_id = str( - self.audit.log_tool_invocation(tool_name="create_issue", result_status="pending") - ) - payload: dict[str, Any] = {"title": title, "body": body} - if labels: - payload["labels"] = await self._resolve_label_ids( - owner, repo, labels, correlation_id=correlation_id - ) - if assignees: - payload["assignees"] = assignees - if milestone is not None: - payload["milestone"] = await self._resolve_milestone_id( - owner, repo, milestone, correlation_id=correlation_id - ) - result = await self._request( - "POST", - f"/api/v1/repos/{quote(owner, safe='')}/{quote(repo, safe='')}/issues", - json_body=payload, - correlation_id=correlation_id, - ) - return result if isinstance(result, dict) else {} - - async def update_issue( - self, - owner: str, - repo: str, - index: int, - *, - title: str | None = None, - body: str | None = None, - state: str | None = None, - milestone: int | str | None = None, - ) -> dict[str, Any]: - """Update issue fields.""" - correlation_id = str( - self.audit.log_tool_invocation(tool_name="update_issue", result_status="pending") - ) - payload: dict[str, Any] = {} - if title is not None: - payload["title"] = title - if body is not None: - payload["body"] = body - if state is not None: - payload["state"] = state - if milestone is not None: - payload["milestone"] = await self._resolve_milestone_id( - owner, repo, milestone, correlation_id=correlation_id - ) - result = await self._request( - "PATCH", - f"/api/v1/repos/{quote(owner, safe='')}/{quote(repo, safe='')}/issues/{index}", - json_body=payload, - correlation_id=correlation_id, - ) - return result if isinstance(result, dict) else {} - - async def create_issue_comment( - self, owner: str, repo: str, index: int, body: str - ) -> dict[str, Any]: - """Create a comment on issue (and PR discussion if issue index refers to PR).""" - result = await self._request( - "POST", - f"/api/v1/repos/{quote(owner, safe='')}/{quote(repo, safe='')}/issues/{index}/comments", - json_body={"body": body}, - correlation_id=str( - self.audit.log_tool_invocation( - tool_name="create_issue_comment", result_status="pending" - ) - ), - ) - return result if isinstance(result, dict) else {} - - async def create_pr_comment( - self, owner: str, repo: str, index: int, body: str - ) -> dict[str, Any]: - """Create PR discussion comment.""" - result = await self._request( - "POST", - f"/api/v1/repos/{quote(owner, safe='')}/{quote(repo, safe='')}/issues/{index}/comments", - json_body={"body": body}, - correlation_id=str( - self.audit.log_tool_invocation( - tool_name="create_pr_comment", result_status="pending" - ) - ), - ) - return result if isinstance(result, dict) else {} - - async def create_label( - self, - owner: str, - repo: str, - *, - name: str, - color: str, - description: str = "", - exclusive: bool = False, - ) -> dict[str, Any]: - """Create a repository label.""" - payload: dict[str, Any] = { - "name": name, - "color": color, - "description": description, - "exclusive": exclusive, - } - result = await self._request( - "POST", - f"/api/v1/repos/{quote(owner, safe='')}/{quote(repo, safe='')}/labels", - json_body=payload, - correlation_id=str( - self.audit.log_tool_invocation(tool_name="create_label", result_status="pending") - ), - ) - return result if isinstance(result, dict) else {} - - async def add_labels( - self, - owner: str, - repo: str, - index: int, - labels: list[str], - ) -> dict[str, Any]: - """Add labels to issue/PR by label name.""" - correlation_id = str( - self.audit.log_tool_invocation(tool_name="add_labels", result_status="pending") - ) - label_ids = await self._resolve_label_ids( - owner, repo, labels, correlation_id=correlation_id - ) - result = await self._request( - "POST", - f"/api/v1/repos/{quote(owner, safe='')}/{quote(repo, safe='')}/issues/{index}/labels", - json_body={"labels": label_ids}, - correlation_id=correlation_id, - ) - return result if isinstance(result, dict) else {} - - async def remove_labels( - self, - owner: str, - repo: str, - index: int, - labels: list[str], - ) -> list[dict[str, Any]]: - """Remove the given labels (by name) from an issue/PR. - - Returns the issue's remaining labels. - """ - correlation_id = str( - self.audit.log_tool_invocation(tool_name="remove_labels", result_status="pending") - ) - label_ids = await self._resolve_label_ids( - owner, repo, labels, correlation_id=correlation_id - ) - owner_q = quote(owner, safe="") - repo_q = quote(repo, safe="") - for label_id in label_ids: - await self._request( - "DELETE", - f"/api/v1/repos/{owner_q}/{repo_q}/issues/{index}/labels/{label_id}", - correlation_id=correlation_id, - ) - result = await self._request( - "GET", - f"/api/v1/repos/{owner_q}/{repo_q}/issues/{index}/labels", - correlation_id=correlation_id, - ) - return result if isinstance(result, list) else [] - - async def update_label( - self, - owner: str, - repo: str, - *, - name: str, - new_name: str | None = None, - color: str | None = None, - description: str | None = None, - ) -> dict[str, Any]: - """Update an existing label, located by its current name.""" - correlation_id = str( - self.audit.log_tool_invocation(tool_name="update_label", result_status="pending") - ) - label_ids = await self._resolve_label_ids( - owner, repo, [name], correlation_id=correlation_id - ) - payload: dict[str, Any] = {} - if new_name is not None: - payload["name"] = new_name - if color is not None: - payload["color"] = color - if description is not None: - payload["description"] = description - result = await self._request( - "PATCH", - f"/api/v1/repos/{quote(owner, safe='')}/{quote(repo, safe='')}/labels/{label_ids[0]}", - json_body=payload, - correlation_id=correlation_id, - ) - return result if isinstance(result, dict) else {} - - async def assign_issue( - self, - owner: str, - repo: str, - index: int, - assignees: list[str], - ) -> dict[str, Any]: - """Assign users to issue/PR.""" - result = await self._request( - "POST", - f"/api/v1/repos/{quote(owner, safe='')}/{quote(repo, safe='')}/issues/{index}/assignees", - json_body={"assignees": assignees}, - correlation_id=str( - self.audit.log_tool_invocation(tool_name="assign_issue", result_status="pending") - ), - ) - return result if isinstance(result, dict) else {} - - async def create_pull_request( - self, - owner: str, - repo: str, - *, - title: str, - head: str, - base: str, - body: str = "", - ) -> dict[str, Any]: - """Open a pull request from head into base.""" - result = await self._request( - "POST", - f"/api/v1/repos/{quote(owner, safe='')}/{quote(repo, safe='')}/pulls", - json_body={"title": title, "head": head, "base": base, "body": body}, - correlation_id=str( - self.audit.log_tool_invocation( - tool_name="create_pull_request", result_status="pending" - ) - ), - ) - return result if isinstance(result, dict) else {} - - async def create_release( - self, - owner: str, - repo: str, - *, - tag_name: str, - name: str = "", - body: str = "", - draft: bool = False, - prerelease: bool = False, - target: str | None = None, - ) -> dict[str, Any]: - """Create a release for an existing or new tag.""" - payload: dict[str, Any] = { - "tag_name": tag_name, - "name": name or tag_name, - "body": body, - "draft": draft, - "prerelease": prerelease, - } - if target: - payload["target_commitish"] = target - result = await self._request( - "POST", - f"/api/v1/repos/{quote(owner, safe='')}/{quote(repo, safe='')}/releases", - json_body=payload, - correlation_id=str( - self.audit.log_tool_invocation(tool_name="create_release", result_status="pending") - ), - ) - return result if isinstance(result, dict) else {} - - async def edit_release( - self, - owner: str, - repo: str, - release_id: int, - *, - name: str | None = None, - body: str | None = None, - draft: bool | None = None, - prerelease: bool | None = None, - ) -> dict[str, Any]: - """Edit fields of an existing release.""" - payload: dict[str, Any] = {} - if name is not None: - payload["name"] = name - if body is not None: - payload["body"] = body - if draft is not None: - payload["draft"] = draft - if prerelease is not None: - payload["prerelease"] = prerelease - result = await self._request( - "PATCH", - f"/api/v1/repos/{quote(owner, safe='')}/{quote(repo, safe='')}/releases/{release_id}", - json_body=payload, - correlation_id=str( - self.audit.log_tool_invocation(tool_name="edit_release", result_status="pending") - ), - ) - return result if isinstance(result, dict) else {} - - async def create_branch( - self, - owner: str, - repo: str, - *, - new_branch_name: str, - old_branch_name: str | None = None, - ) -> dict[str, Any]: - """Create a branch, optionally from a specific existing branch.""" - payload: dict[str, Any] = {"new_branch_name": new_branch_name} - if old_branch_name: - payload["old_branch_name"] = old_branch_name - result = await self._request( - "POST", - f"/api/v1/repos/{quote(owner, safe='')}/{quote(repo, safe='')}/branches", - json_body=payload, - correlation_id=str( - self.audit.log_tool_invocation(tool_name="create_branch", result_status="pending") - ), - ) - return result if isinstance(result, dict) else {} - - async def create_milestone( - self, - owner: str, - repo: str, - *, - title: str, - description: str = "", - due_on: str | None = None, - ) -> dict[str, Any]: - """Create a repository milestone.""" - payload: dict[str, Any] = {"title": title, "description": description} - if due_on: - payload["due_on"] = due_on - result = await self._request( - "POST", - f"/api/v1/repos/{quote(owner, safe='')}/{quote(repo, safe='')}/milestones", - json_body=payload, - correlation_id=str( - self.audit.log_tool_invocation( - tool_name="create_milestone", result_status="pending" - ) - ), - ) - return result if isinstance(result, dict) else {} - - async def edit_issue_comment( - self, owner: str, repo: str, comment_id: int, body: str - ) -> dict[str, Any]: - """Edit an existing issue or PR comment.""" - result = await self._request( - "PATCH", - f"/api/v1/repos/{quote(owner, safe='')}/{quote(repo, safe='')}/issues/comments/{comment_id}", - json_body={"body": body}, - correlation_id=str( - self.audit.log_tool_invocation( - tool_name="edit_issue_comment", result_status="pending" - ) - ), - ) - return result if isinstance(result, dict) else {} - - async def list_pull_request_files( - self, owner: str, repo: str, index: int, *, page: int, limit: int - ) -> list[dict[str, Any]]: - """List files changed in a pull request.""" - result = await self._request( - "GET", - f"/api/v1/repos/{quote(owner, safe='')}/{quote(repo, safe='')}/pulls/{index}/files", - params={"page": page, "limit": limit}, - correlation_id=str( - self.audit.log_tool_invocation( - tool_name="list_pull_request_files", result_status="pending" - ) - ), - ) - return result if isinstance(result, list) else [] - - async def list_pull_request_commits( - self, owner: str, repo: str, index: int, *, page: int, limit: int - ) -> list[dict[str, Any]]: - """List commits in a pull request.""" - result = await self._request( - "GET", - f"/api/v1/repos/{quote(owner, safe='')}/{quote(repo, safe='')}/pulls/{index}/commits", - params={"page": page, "limit": limit}, - correlation_id=str( - self.audit.log_tool_invocation( - tool_name="list_pull_request_commits", result_status="pending" - ) - ), - ) - return result if isinstance(result, list) else [] - - async def list_issue_comments( - self, owner: str, repo: str, index: int, *, page: int, limit: int - ) -> list[dict[str, Any]]: - """List comments on an issue or pull request.""" - result = await self._request( - "GET", - f"/api/v1/repos/{quote(owner, safe='')}/{quote(repo, safe='')}/issues/{index}/comments", - params={"page": page, "limit": limit}, - correlation_id=str( - self.audit.log_tool_invocation( - tool_name="list_issue_comments", result_status="pending" - ) - ), - ) - return result if isinstance(result, list) else [] - - async def list_branches( - self, owner: str, repo: str, *, page: int, limit: int - ) -> list[dict[str, Any]]: - """List repository branches.""" - result = await self._request( - "GET", - f"/api/v1/repos/{quote(owner, safe='')}/{quote(repo, safe='')}/branches", - params={"page": page, "limit": limit}, - correlation_id=str( - self.audit.log_tool_invocation(tool_name="list_branches", result_status="pending") - ), - ) - return result if isinstance(result, list) else [] - - async def get_branch(self, owner: str, repo: str, branch: str) -> dict[str, Any]: - """Get a single branch.""" - result = await self._request( - "GET", - f"/api/v1/repos/{quote(owner, safe='')}/{quote(repo, safe='')}/branches/{quote(branch, safe='/')}", - correlation_id=str( - self.audit.log_tool_invocation(tool_name="get_branch", result_status="pending") - ), - ) - return result if isinstance(result, dict) else {} - - async def get_release(self, owner: str, repo: str, release_id: int) -> dict[str, Any]: - """Get a release by id.""" - result = await self._request( - "GET", - f"/api/v1/repos/{quote(owner, safe='')}/{quote(repo, safe='')}/releases/{release_id}", - correlation_id=str( - self.audit.log_tool_invocation(tool_name="get_release", result_status="pending") - ), - ) - return result if isinstance(result, dict) else {} - - async def get_latest_release(self, owner: str, repo: str) -> dict[str, Any]: - """Get the latest published release.""" - result = await self._request( - "GET", - f"/api/v1/repos/{quote(owner, safe='')}/{quote(repo, safe='')}/releases/latest", - correlation_id=str( - self.audit.log_tool_invocation( - tool_name="get_latest_release", result_status="pending" - ) - ), - ) - return result if isinstance(result, dict) else {} - - async def list_milestones( - self, owner: str, repo: str, *, state: str, page: int, limit: int - ) -> list[dict[str, Any]]: - """List repository milestones.""" - result = await self._request( - "GET", - f"/api/v1/repos/{quote(owner, safe='')}/{quote(repo, safe='')}/milestones", - params={"state": state, "page": page, "limit": limit}, - correlation_id=str( - self.audit.log_tool_invocation(tool_name="list_milestones", result_status="pending") - ), - ) - return result if isinstance(result, list) else [] - - async def get_commit_status(self, owner: str, repo: str, sha: str) -> dict[str, Any]: - """Get the combined commit status for a ref/sha.""" - result = await self._request( - "GET", - f"/api/v1/repos/{quote(owner, safe='')}/{quote(repo, safe='')}/commits/{quote(sha, safe='/')}/status", - correlation_id=str( - self.audit.log_tool_invocation( - tool_name="get_commit_status", result_status="pending" - ) - ), - ) - return result if isinstance(result, dict) else {} - - async def list_org_repositories( - self, org: str, *, page: int, limit: int - ) -> list[dict[str, Any]]: - """List repositories belonging to an organization.""" - result = await self._request( - "GET", - f"/api/v1/orgs/{quote(org, safe='')}/repos", - params={"page": page, "limit": limit}, - correlation_id=str( - self.audit.log_tool_invocation( - tool_name="list_org_repositories", result_status="pending" - ) - ), - ) - return result if isinstance(result, list) else [] - - async def list_organizations(self, *, page: int, limit: int) -> list[dict[str, Any]]: - """List organizations the authenticated user belongs to.""" - result = await self._request( - "GET", - "/api/v1/user/orgs", - params={"page": page, "limit": limit}, - correlation_id=str( - self.audit.log_tool_invocation( - tool_name="list_organizations", result_status="pending" - ) - ), - ) - return result if isinstance(result, list) else [] - - async def get_repo_languages(self, owner: str, repo: str) -> dict[str, Any]: - """Get the language breakdown for a repository.""" - result = await self._request( - "GET", - f"/api/v1/repos/{quote(owner, safe='')}/{quote(repo, safe='')}/languages", - correlation_id=str( - self.audit.log_tool_invocation( - tool_name="get_repo_languages", result_status="pending" - ) - ), - ) - return result if isinstance(result, dict) else {} - - async def list_repo_topics(self, owner: str, repo: str) -> list[str]: - """List the topics assigned to a repository.""" - result = await self._request( - "GET", - f"/api/v1/repos/{quote(owner, safe='')}/{quote(repo, safe='')}/topics", - correlation_id=str( - self.audit.log_tool_invocation( - tool_name="list_repo_topics", result_status="pending" - ) - ), - ) - if isinstance(result, dict): - topics = result.get("topics", []) - return [str(topic) for topic in topics] if isinstance(topics, list) else [] - return [] diff --git a/src/aegis_gitea_mcp/mcp_protocol.py b/src/aegis_gitea_mcp/mcp_protocol.py deleted file mode 100644 index 8277bd3..0000000 --- a/src/aegis_gitea_mcp/mcp_protocol.py +++ /dev/null @@ -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 diff --git a/src/aegis_gitea_mcp/oauth.py b/src/aegis_gitea_mcp/oauth.py deleted file mode 100644 index 62faba5..0000000 --- a/src/aegis_gitea_mcp/oauth.py +++ /dev/null @@ -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 ` 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 diff --git a/src/aegis_gitea_mcp/oauth_flow.py b/src/aegis_gitea_mcp/oauth_flow.py deleted file mode 100644 index a98714d..0000000 --- a/src/aegis_gitea_mcp/oauth_flow.py +++ /dev/null @@ -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 diff --git a/src/aegis_gitea_mcp/observability.py b/src/aegis_gitea_mcp/observability.py deleted file mode 100644 index fde4f64..0000000 --- a/src/aegis_gitea_mcp/observability.py +++ /dev/null @@ -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() diff --git a/src/aegis_gitea_mcp/policy.py b/src/aegis_gitea_mcp/policy.py deleted file mode 100644 index 22b8fdd..0000000 --- a/src/aegis_gitea_mcp/policy.py +++ /dev/null @@ -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 diff --git a/src/aegis_gitea_mcp/registry.py b/src/aegis_gitea_mcp/registry.py deleted file mode 100644 index 583f7f5..0000000 --- a/src/aegis_gitea_mcp/registry.py +++ /dev/null @@ -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", -] diff --git a/src/aegis_gitea_mcp/server.py b/src/aegis_gitea_mcp/server.py deleted file mode 100644 index b266580..0000000 --- a/src/aegis_gitea_mcp/server.py +++ /dev/null @@ -1,1513 +0,0 @@ -"""Main MCP server implementation with hardened security controls.""" - -from __future__ import annotations - -import asyncio -import hashlib -import json -import logging -import time -import urllib.parse -import uuid -from collections.abc import AsyncGenerator, Awaitable, Callable -from contextlib import asynccontextmanager -from typing import Any - -import httpx -from fastapi import FastAPI, HTTPException, Request, Response -from fastapi.responses import JSONResponse, PlainTextResponse, RedirectResponse, StreamingResponse -from pydantic import BaseModel, Field, ValidationError - -from aegis_gitea_mcp.audit import get_audit_logger -from aegis_gitea_mcp.authz import authorize_non_repository_access, classify_tool -from aegis_gitea_mcp.automation import AutomationError, AutomationManager -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.gitea_client import ( - GiteaAuthenticationError, - GiteaAuthorizationError, - GiteaClient, - GiteaNotFoundError, -) -from aegis_gitea_mcp.logging_utils import configure_logging -from aegis_gitea_mcp.mcp_protocol import ( - AVAILABLE_TOOLS, - MCPListToolsResponse, - MCPToolCallRequest, - MCPToolCallResponse, - get_tool_by_name, -) -from aegis_gitea_mcp.oauth import get_oauth_validator -from aegis_gitea_mcp.oauth_flow import ( - OAuthRegistrationRequest, - decode_proxy_state, - encode_proxy_state, - get_oauth_client_registry, - is_origin_allowed, - is_redirect_uri_allowed, -) -from aegis_gitea_mcp.observability import get_metrics_registry, monotonic_seconds -from aegis_gitea_mcp.policy import PolicyError, get_policy_engine -from aegis_gitea_mcp.rate_limit import get_rate_limiter -from aegis_gitea_mcp.registry import TOOL_HANDLERS -from aegis_gitea_mcp.request_context import ( - clear_gitea_auth_context, - get_gitea_user_login, - get_gitea_user_scopes, - get_gitea_user_token, - set_gitea_user_login, - set_gitea_user_scopes, - set_gitea_user_token, - set_request_id, -) -from aegis_gitea_mcp.security import sanitize_data -from aegis_gitea_mcp.tools.arguments import extract_repository, extract_target_path - -logger = logging.getLogger(__name__) - -READ_SCOPE = "read:repository" -WRITE_SCOPE = "write:repository" - -# Cache of tokens verified to have Gitea API scope. -# Key: hash of token prefix, Value: sentinel marking the token as probe-verified. -# Bounded by size and TTL so untrusted token cardinality cannot grow it without limit. -_API_SCOPE_CACHE_TTL = 60 # seconds -_api_scope_cache: BoundedTTLCache[str, bool] = BoundedTTLCache( - ttl_seconds=_API_SCOPE_CACHE_TTL, max_size=4096 -) - -_REAUTH_GUIDANCE = ( - "Your OAuth token lacks Gitea API scopes (e.g. read:repository). " - "Revoke the authorization in Gitea (Settings > Applications > Authorized OAuth2 Applications) " - "and in your client, then re-authorize." -) - -_NOT_FOUND_MESSAGE = "Resource not found in Gitea (it may not exist or be inaccessible)." - - -def _find_not_found(exc: BaseException) -> GiteaNotFoundError | None: - """Return the GiteaNotFoundError in an exception's cause chain, if any. - - Tool handlers wrap backend ``GiteaError`` (including ``GiteaNotFoundError``) - in ``RuntimeError`` before it reaches the request layer, so a not-found - condition is preserved only via ``__cause__``. Walking the chain lets the - server return an actionable "not found" instead of an opaque internal error. - """ - seen: set[int] = set() - current: BaseException | None = exc - while current is not None and id(current) not in seen: - if isinstance(current, GiteaNotFoundError): - return current - seen.add(id(current)) - current = current.__cause__ - return None - - -def _masked_internal_error(exc: BaseException, expose_details: bool) -> str: - """Build a non-sensitive internal-error message. - - The exception *type* name (e.g. ``TypeError``) carries no secrets or stack - detail, so it is always included to make masked failures diagnosable - client-side. The exception message is added only when explicitly enabled. - """ - if expose_details: - return f"Internal server error: {exc}" - return f"Internal server error ({type(exc).__name__})" - - -_repo_authz_cache: BoundedTTLCache[str, bool] | None = None - - -def _get_repo_authz_cache() -> BoundedTTLCache[str, bool]: - """Get the bounded cache for per-user repository permission checks.""" - global _repo_authz_cache - settings = get_settings() - if _repo_authz_cache is None: - _repo_authz_cache = BoundedTTLCache( - ttl_seconds=settings.repo_authz_cache_ttl_seconds, - max_size=2048, - ) - return _repo_authz_cache - - -def reset_repo_authz_cache() -> None: - """Reset the repository authorization cache (primarily for tests).""" - global _repo_authz_cache - _repo_authz_cache = None - - -def _repo_authz_cache_key(login: str, repository: str, required_scope: str) -> str: - """Build a bounded cache key for a user/repository permission check.""" - normalized_login = login.strip().lower() - return f"{normalized_login}:{repository.lower()}:{required_scope}" - - -def _is_mcp_transport_path(path: str) -> bool: - """Return whether a request targets the MCP transport surface.""" - return path in {"/mcp", "/mcp/sse"} or path.startswith("/mcp/") - - -def _has_required_scope(required_scope: str, granted_scopes: set[str]) -> bool: - """Return whether granted scopes satisfy the required MCP tool scope.""" - normalized = {scope.strip().lower() for scope in granted_scopes if scope and scope.strip()} - expanded = set(normalized) - - # Compatibility: broad repository scopes imply both read and write repository access. - if "repository" in normalized or "repo" in normalized: - expanded.update({READ_SCOPE, WRITE_SCOPE}) - if "write:repo" in normalized: - expanded.add(WRITE_SCOPE) - if "read:repo" in normalized: - expanded.add(READ_SCOPE) - if WRITE_SCOPE in expanded: - expanded.add(READ_SCOPE) - - return required_scope in expanded - - -def _repo_permission_satisfied(permission: dict[str, Any], required_scope: str) -> bool: - """Return whether a repository permission payload satisfies the requested scope.""" - permission_name = str(permission.get("permission", "")).lower().strip() - if permission_name in {"admin", "owner"}: - return True - if required_scope == WRITE_SCOPE and permission_name == "write": - return True - if required_scope == READ_SCOPE and permission_name in {"read", "write"}: - return True - - nested_permissions = permission.get("permissions") - if isinstance(nested_permissions, dict): - return _repo_permission_satisfied(nested_permissions, required_scope) - - if required_scope == WRITE_SCOPE: - return bool(permission.get("push") or permission.get("admin")) - return bool(permission.get("pull") or permission.get("push") or permission.get("admin")) - - -async def _verify_user_repository_access( - *, - repository: str, - required_scope: str, - user_login: str, - correlation_id: str, - tool_name: str, -) -> None: - """Verify the authenticated user can access the target repository before PAT fallback.""" - settings = get_settings() - audit = get_audit_logger() - - service_token = settings.gitea_token.strip() - if not service_token: - raise HTTPException(status_code=500, detail="Repository authorization misconfigured") - - if not user_login.strip() or user_login == "unknown": - audit.log_access_denied( - tool_name=tool_name, - repository=repository, - reason="repository_permission_missing_user", - correlation_id=correlation_id, - ) - raise HTTPException( - status_code=403, - detail="Unable to verify repository permission for this user.", - ) - - cache_key = _repo_authz_cache_key(user_login, repository, required_scope) - cached = _get_repo_authz_cache().get(cache_key) - if cached is True: - return - - owner, repo = repository.split("/", 1) - encoded_owner = urllib.parse.quote(owner, safe="") - encoded_repo = urllib.parse.quote(repo, safe="") - encoded_user = urllib.parse.quote(user_login, safe="") - permission_url = ( - f"{settings.gitea_base_url}/api/v1/repos/{encoded_owner}/{encoded_repo}" - f"/collaborators/{encoded_user}/permission" - ) - - try: - async with httpx.AsyncClient(timeout=settings.request_timeout_seconds) as client: - response = await client.get( - permission_url, - headers={"Authorization": f"token {service_token}", "Accept": "application/json"}, - ) - except httpx.RequestError as exc: - audit.log_access_denied( - tool_name=tool_name, - repository=repository, - reason="repository_permission_probe_failed", - correlation_id=correlation_id, - ) - raise HTTPException( - status_code=403, - detail="Unable to verify repository permission for this user.", - ) from exc - - if response.status_code != 200: - audit.log_access_denied( - tool_name=tool_name, - repository=repository, - reason=f"repository_permission_probe:{response.status_code}", - correlation_id=correlation_id, - ) - raise HTTPException( - status_code=403, - detail="User does not have permission for the requested repository.", - ) - - try: - permission_payload = response.json() - except ValueError as exc: - audit.log_access_denied( - tool_name=tool_name, - repository=repository, - reason="repository_permission_invalid_json", - correlation_id=correlation_id, - ) - raise HTTPException( - status_code=403, - detail="Unable to verify repository permission for this user.", - ) from exc - - if isinstance(permission_payload, dict) and _repo_permission_satisfied( - permission_payload, required_scope - ): - _get_repo_authz_cache().set(cache_key, True) - return - - audit.log_access_denied( - tool_name=tool_name, - repository=repository, - reason="repository_permission_denied", - correlation_id=correlation_id, - ) - raise HTTPException( - status_code=403, - detail="User does not have permission for the requested repository.", - ) - - -@asynccontextmanager -async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]: - """Run startup and shutdown hooks via the FastAPI lifespan protocol.""" - await startup_event() - try: - yield - finally: - await shutdown_event() - - -app = FastAPI( - title="AegisGitea MCP Server", - description="Security-first MCP server for controlled AI access to self-hosted Gitea", - version="0.2.0", - lifespan=lifespan, -) - - -class AutomationWebhookRequest(BaseModel): - """Request body for automation webhook ingestion.""" - - event_type: str = Field(..., min_length=1, max_length=128) - payload: dict[str, Any] = Field(default_factory=dict) - repository: str | None = Field(default=None) - - -class AutomationJobRequest(BaseModel): - """Request body for automation job execution.""" - - job_name: str = Field(..., min_length=1, max_length=128) - owner: str = Field(..., min_length=1, max_length=100) - repo: str = Field(..., min_length=1, max_length=100) - finding_title: str | None = Field(default=None, max_length=256) - finding_body: str | None = Field(default=None, max_length=10_000) - - -def _oauth_metadata_url(request: Request) -> str: - """Build absolute metadata URL for OAuth challenge responses.""" - settings = get_settings() - base_url = settings.public_base or str(request.base_url).rstrip("/") - return f"{base_url}/.well-known/oauth-protected-resource" - - -def _oauth_unauthorized_response( - request: Request, - message: str, - scope: str = READ_SCOPE, -) -> JSONResponse: - """Return RFC-compliant OAuth challenge response for protected MCP endpoints.""" - metadata_url = _oauth_metadata_url(request) - response = JSONResponse( - status_code=401, - content={ - "error": "Authentication failed", - "message": message, - "request_id": getattr(request.state, "request_id", "-"), - }, - ) - response.headers["WWW-Authenticate"] = ( - f'Bearer resource_metadata="{metadata_url}", scope="{scope}"' - ) - return response - - -@app.middleware("http") -async def request_context_middleware( - request: Request, - call_next: Callable[[Request], Awaitable[Response]], -) -> Response: - """Attach request correlation context and collect request metrics.""" - request_id = request.headers.get("x-request-id") or str(uuid.uuid4()) - set_request_id(request_id) - request.state.request_id = request_id - - started_at = monotonic_seconds() - status_code = 500 - - try: - response = await call_next(request) - status_code = response.status_code - response.headers["X-Request-ID"] = request_id - return response - finally: - duration = max(monotonic_seconds() - started_at, 0.0) - logger.debug( - "request_completed", - extra={ - "method": request.method, - "path": request.url.path, - "duration_seconds": duration, - "status_code": status_code, - }, - ) - metrics = get_metrics_registry() - metrics.record_http_request(request.method, request.url.path, status_code) - - -def _cors_headers(origin: str) -> dict[str, str]: - """Build strict CORS headers for a validated browser origin.""" - return { - "Access-Control-Allow-Origin": origin, - "Access-Control-Allow-Credentials": "true", - "Access-Control-Allow-Methods": "GET,POST,OPTIONS", - "Access-Control-Allow-Headers": "Authorization,Content-Type,MCP-Protocol-Version,X-Request-ID", - "Access-Control-Expose-Headers": "X-Request-ID,WWW-Authenticate", - "Vary": "Origin", - } - - -@app.middleware("http") -async def strict_origin_and_cors_middleware( - request: Request, - call_next: Callable[[Request], Awaitable[Response]], -) -> Response: - """Enforce strict browser origins for MCP transport requests.""" - if request.url.path not in {"/mcp", "/mcp/sse"}: - return await call_next(request) - - settings = get_settings() - origin = request.headers.get("origin") - expected_base = settings.public_base or str(request.base_url).rstrip("/") - - if origin and not is_origin_allowed(origin, expected_base, settings.public_base): - return JSONResponse( - status_code=403, - content={ - "error": "Origin not allowed", - "message": "The request origin is not allowed for this MCP transport.", - "request_id": getattr(request.state, "request_id", "-"), - }, - ) - - if request.method == "OPTIONS": - response = Response(status_code=204) - else: - response = await call_next(request) - - if origin and is_origin_allowed(origin, expected_base, settings.public_base): - for header, value in _cors_headers(origin).items(): - response.headers[header] = value - - return response - - -def _oauth_invalid_client_response() -> JSONResponse: - """Return an RFC 6749 invalid_client error for token endpoint failures.""" - response = JSONResponse(status_code=401, content={"error": "invalid_client"}) - response.headers["WWW-Authenticate"] = 'Basic realm="oauth"' - return response - - -def _jsonrpc_error(message_id: Any, code: int, message: str) -> JSONResponse: - """Build a JSON-RPC error response envelope.""" - return JSONResponse( - content={"jsonrpc": "2.0", "id": message_id, "error": {"code": code, "message": message}} - ) - - -@app.middleware("http") -async def authenticate_and_rate_limit( - request: Request, - call_next: Callable[[Request], Awaitable[Response]], -) -> Response: - """Apply rate-limiting and authentication for MCP endpoints.""" - clear_gitea_auth_context() - settings = get_settings() - - if request.url.path in {"/", "/health"}: - return await call_next(request) - - if request.method == "OPTIONS" and request.url.path in {"/mcp", "/mcp/sse"}: - return await call_next(request) - - if request.url.path == "/metrics" and settings.metrics_enabled: - # Metrics endpoint is intentionally left unauthenticated for pull-based scraping. - return await call_next(request) - - # OAuth discovery and token endpoints must be public so MCP clients can complete the flow. - if request.url.path in { - "/oauth/token", - "/.well-known/oauth-protected-resource", - "/.well-known/oauth-authorization-server", - "/.well-known/openid-configuration", - }: - return await call_next(request) - - if not ( - request.url.path in {"/mcp/tools"} - or _is_mcp_transport_path(request.url.path) - or request.url.path.startswith("/automation/") - ): - return await call_next(request) - - oauth_validator = get_oauth_validator() - limiter = get_rate_limiter() - - client_ip = request.client.host if request.client else "unknown" - user_agent = request.headers.get("user-agent", "unknown") - - auth_header = request.headers.get("authorization") - access_token = oauth_validator.extract_bearer_token(auth_header) - - rate_limit = limiter.check(client_ip=client_ip, token=access_token) - if not rate_limit.allowed: - return JSONResponse( - status_code=429, - content={ - "error": "Rate limit exceeded", - "message": rate_limit.reason, - "request_id": getattr(request.state, "request_id", "-"), - }, - ) - - # Mixed mode: tool discovery remains public to preserve MCP client compatibility. - if request.url.path == "/mcp/tools": - return await call_next(request) - - if not access_token: - if _is_mcp_transport_path(request.url.path): - return _oauth_unauthorized_response( - request, - "Provide Authorization: Bearer .", - scope=READ_SCOPE, - ) - return JSONResponse( - status_code=401, - content={ - "error": "Authentication failed", - "message": "Provide Authorization: Bearer .", - "request_id": getattr(request.state, "request_id", "-"), - }, - ) - - is_valid, error_message, user_data = await oauth_validator.validate_oauth_token( - access_token, client_ip, user_agent - ) - if not is_valid: - if _is_mcp_transport_path(request.url.path): - return _oauth_unauthorized_response( - request, - error_message or "Invalid or expired OAuth token.", - scope=READ_SCOPE, - ) - return JSONResponse( - status_code=401, - content={ - "error": "Authentication failed", - "message": error_message or "Invalid or expired OAuth token.", - "request_id": getattr(request.state, "request_id", "-"), - }, - ) - - if user_data: - set_gitea_user_token(access_token) - login = str(user_data.get("login", "unknown")) - set_gitea_user_login(login) - - observed_scopes: list[str] = list(user_data.get("scopes", [])) - - # Gitea's OIDC tokens only carry standard scopes (openid, profile, email), - # not granular Gitea scopes like read:repository. When a token is - # successfully validated the user has already authorized this OAuth app, - # so we grant read:repository implicitly (and write:repository when - # write_mode is enabled). The Gitea API itself still enforces per-repo - # permissions on every call made with the user's token. - effective_scopes: set[str] = set(observed_scopes) - effective_scopes.add(READ_SCOPE) - if settings.write_mode: - effective_scopes.add(WRITE_SCOPE) - set_gitea_user_scopes(effective_scopes) - - # Probe: verify the token actually works for Gitea's repository API. - # Try both "token" and "Bearer" header formats since Gitea may - # accept OAuth tokens differently depending on version/config. - token_hash = hashlib.sha256(access_token.encode()).hexdigest()[:16] - probe_result = "skip:cached" - token_type = "jwt" if access_token.count(".") == 2 else "opaque" - - if _api_scope_cache.get(token_hash) is None: - # JWT tokens (OIDC) are already cryptographically validated via JWKS above. - # Gitea's OIDC access_tokens cannot access the REST API without additional - # Gitea-specific scope configuration, so we skip the probe for them and - # rely on per-call API errors for actual permission enforcement. - if token_type == "jwt": - probe_result = "skip:jwt" - _api_scope_cache.set(token_hash, True) - else: - try: - probe_status = None - async with httpx.AsyncClient( - timeout=settings.request_timeout_seconds - ) as probe_client: - probe_url = f"{settings.gitea_base_url}/api/v1/user" - # Try "token" format first (Gitea PAT style) - probe_resp = await probe_client.get( - probe_url, - headers={"Authorization": f"token {access_token}"}, - ) - probe_status = probe_resp.status_code - # If "token" format fails, try "Bearer" (OAuth2 standard) - if probe_status in (401, 403): - probe_resp = await probe_client.get( - probe_url, - headers={"Authorization": f"Bearer {access_token}"}, - ) - probe_status = probe_resp.status_code - - if probe_status in (401, 403): - probe_result = f"fail:{probe_status}" - logger.warning( - "oauth_token_lacks_api_scope", - extra={ - "status": probe_status, - "login": login, - "token_type": token_type, - "scopes_observed": observed_scopes, - }, - ) - message = ( - "OAuth token is valid but lacks required Gitea API access. " - "Re-authorize this OAuth app in Gitea and try again." - ) - if _is_mcp_transport_path(request.url.path): - return _oauth_unauthorized_response( - request, - message, - scope=READ_SCOPE, - ) - return JSONResponse( - status_code=401, - content={ - "error": "Authentication failed", - "message": message, - "request_id": getattr(request.state, "request_id", "-"), - }, - ) - else: - probe_result = "pass" - _api_scope_cache.set(token_hash, True) - except httpx.RequestError: - probe_result = "skip:error" - logger.debug("oauth_api_scope_probe_network_error") - - logger.info( - "oauth_auth_summary", - extra={ - "token_type": token_type, - "scopes_observed": observed_scopes, - "scopes_effective": sorted(effective_scopes), - "api_probe": probe_result, - "login": login, - }, - ) - - return await call_next(request) - - -async def startup_event() -> None: - """Initialize server state on startup.""" - settings = get_settings() - configure_logging(settings.log_level) - - logger.info("server_starting") - logger.info( - "server_configuration", - extra={ - "host": settings.mcp_host, - "port": settings.mcp_port, - "gitea_url": settings.gitea_base_url, - "auth_enabled": settings.auth_enabled, - "write_mode": settings.write_mode, - "metrics_enabled": settings.metrics_enabled, - }, - ) - - # Fail-fast policy parse errors at startup. - try: - _ = get_policy_engine() - except PolicyError: - logger.error("policy_load_failed") - raise - - if settings.startup_validate_gitea and settings.environment != "test": - discovery_url = f"{settings.gitea_base_url}/.well-known/openid-configuration" - try: - async with httpx.AsyncClient(timeout=settings.request_timeout_seconds) as client: - response = await client.get(discovery_url, headers={"Accept": "application/json"}) - except httpx.RequestError as exc: - logger.error("gitea_oidc_discovery_request_failed") - raise RuntimeError( - "Startup validation failed: unable to reach Gitea OIDC discovery endpoint." - ) from exc - - if response.status_code != 200: - logger.error( - "gitea_oidc_discovery_non_200", extra={"status_code": response.status_code} - ) - raise RuntimeError( - "Startup validation failed: Gitea OIDC discovery endpoint returned non-200." - ) - - logger.info("gitea_oidc_discovery_ready", extra={"issuer": settings.gitea_base_url}) - - -async def shutdown_event() -> None: - """Log server shutdown event.""" - logger.info("server_stopping") - - -@app.get("/") -async def root() -> dict[str, Any]: - """Root endpoint with server metadata.""" - return { - "name": "AegisGitea MCP Server", - "version": "0.2.0", - "status": "running", - "mcp_version": "1.0", - } - - -@app.get("/health") -async def health() -> dict[str, str]: - """Health check endpoint.""" - return {"status": "healthy"} - - -@app.get("/.well-known/oauth-protected-resource") -async def oauth_protected_resource_metadata(request: Request) -> JSONResponse: - """OAuth 2.0 Protected Resource Metadata (RFC 9728). - - Required by the MCP Authorization spec so that OAuth clients (Claude's - connector infrastructure) can discover the authorization server that - protects this resource. Claude fetches this endpoint when it first connects. - - The ``resource`` value MUST be THIS server's own canonical public URL: the - MCP client verifies that the resource identifier matches the origin it - derived the MCP server URL from (RFC 9728 / RFC 8707). Returning the upstream - Gitea URL here would fail that check. - """ - settings = get_settings() - gitea_base = settings.gitea_base_url - base_url = settings.public_base or str(request.base_url).rstrip("/") - authorization_servers = [base_url] - if gitea_base not in authorization_servers: - authorization_servers.append(gitea_base) - - return JSONResponse( - content={ - "resource": base_url, - "authorization_servers": authorization_servers, - "bearer_methods_supported": ["header"], - "scopes_supported": [READ_SCOPE, WRITE_SCOPE], - "resource_documentation": str(settings.oauth_resource_documentation), - } - ) - - -@app.get("/oauth/authorize") -async def oauth_authorize_proxy(request: Request) -> RedirectResponse: - """Proxy OAuth authorization to Gitea, replacing redirect_uri with our own callback. - - Clients (Claude, Claude Code, Cowork, etc.) send their own redirect_uri which Gitea doesn't know - about. This endpoint intercepts the request, encodes the original redirect_uri and - state into a new state parameter, and forwards the request to Gitea using the MCP - server's own callback URI — the only URI that needs to be registered in Gitea. - """ - settings = get_settings() - base_url = settings.public_base or str(request.base_url).rstrip("/") - registry = get_oauth_client_registry(settings.dcr_storage_path) - - params = dict(request.query_params) - client_redirect_uri = params.pop("redirect_uri", "").strip() - client_id = params.get("client_id", "").strip() or settings.gitea_oauth_client_id.strip() - original_state = params.get("state", "") - params.pop("client_secret", None) - - if not client_id: - raise HTTPException(status_code=400, detail="Missing client_id") - if not registry.is_known_client( - client_id, - fallback_client_id=settings.gitea_oauth_client_id, - ): - raise HTTPException(status_code=401, detail="invalid_client") - - if not client_redirect_uri: - raise HTTPException(status_code=400, detail="Missing redirect_uri") - if not is_redirect_uri_allowed(client_redirect_uri, settings.oauth_redirect_allowlist): - raise HTTPException(status_code=400, detail="redirect_uri is not allowed") - - code_challenge = params.get("code_challenge", "").strip() - code_challenge_method = params.get("code_challenge_method", "S256").strip().upper() - if not code_challenge: - raise HTTPException(status_code=400, detail="PKCE code_challenge is required") - if code_challenge_method != "S256": - raise HTTPException(status_code=400, detail="PKCE code_challenge_method must be S256") - - proxy_state = encode_proxy_state( - settings.oauth_state_secret, - client_redirect_uri, - original_state, - ttl_seconds=600, - ) - - params["client_id"] = settings.gitea_oauth_client_id - params["state"] = proxy_state - params["code_challenge"] = code_challenge - params["code_challenge_method"] = "S256" - params["redirect_uri"] = f"{base_url}/oauth/callback" - - gitea_authorize_url = f"{settings.gitea_base_url}/login/oauth/authorize" - redirect_url = f"{gitea_authorize_url}?{urllib.parse.urlencode(params)}" - return RedirectResponse(url=redirect_url, status_code=302) - - -@app.get("/oauth/callback") -async def oauth_callback_proxy(request: Request) -> RedirectResponse: - """Handle Gitea's OAuth callback and redirect to the original client redirect_uri.""" - proxy_state = request.query_params.get("state", "") - code = request.query_params.get("code", "") - error = request.query_params.get("error", "") - error_description = request.query_params.get("error_description", "") - - try: - state_data = decode_proxy_state(get_settings().oauth_state_secret, proxy_state) - client_redirect_uri = state_data["redirect_uri"] - original_state = state_data["state"] - except ValueError as exc: - raise HTTPException(status_code=400, detail="Invalid or missing state parameter") from exc - - settings = get_settings() - if not client_redirect_uri: - raise HTTPException(status_code=400, detail="No client redirect_uri in state") - if not is_redirect_uri_allowed(client_redirect_uri, settings.oauth_redirect_allowlist): - raise HTTPException(status_code=400, detail="redirect_uri is not allowed") - - result_params: dict[str, str] = {} - if error: - result_params["error"] = error - if error_description: - result_params["error_description"] = error_description - else: - result_params["code"] = code - if original_state: - result_params["state"] = original_state - - redirect_url = f"{client_redirect_uri}?{urllib.parse.urlencode(result_params)}" - return RedirectResponse(url=redirect_url, status_code=302) - - -@app.get("/.well-known/oauth-authorization-server") -async def oauth_authorization_server_metadata(request: Request) -> JSONResponse: - """OAuth 2.0 Authorization Server Metadata (RFC 8414). - - Advertises this server's OAuth proxy endpoints so that Claude's connector - infrastructure can discover the authorize URL, token URL, and dynamic client - registration endpoint directly from this server without knowing the Gitea URL - upfront. The authorize/token endpoints are this server's proxy routes because - Gitea does not know Claude's redirect_uri. - """ - settings = get_settings() - base_url = settings.public_base or str(request.base_url).rstrip("/") - gitea_base = settings.gitea_base_url - - metadata: dict[str, Any] = { - "issuer": gitea_base, - "authorization_endpoint": f"{base_url}/oauth/authorize", - "token_endpoint": f"{base_url}/oauth/token", - "response_types_supported": ["code"], - "grant_types_supported": ["authorization_code", "refresh_token"], - "code_challenge_methods_supported": ["S256"], - "scopes_supported": [READ_SCOPE, WRITE_SCOPE], - "token_endpoint_auth_methods_supported": ["client_secret_post", "none"], - } - if settings.dcr_enabled: - # RFC 7591 dynamic client registration endpoint (Claude registers here). - metadata["registration_endpoint"] = f"{base_url}/register" - - return JSONResponse(content=metadata) - - -@app.get("/.well-known/openid-configuration") -async def openid_configuration(request: Request) -> JSONResponse: - """OpenID Provider metadata compatible with OAuth proxy token exchange.""" - settings = get_settings() - base_url = settings.public_base or str(request.base_url).rstrip("/") - gitea_base = settings.gitea_base_url - - return JSONResponse( - content={ - "issuer": gitea_base, - "authorization_endpoint": f"{base_url}/oauth/authorize", - "token_endpoint": f"{base_url}/oauth/token", - "userinfo_endpoint": f"{gitea_base}/login/oauth/userinfo", - "jwks_uri": f"{gitea_base}/login/oauth/keys", - "response_types_supported": ["code"], - "grant_types_supported": ["authorization_code"], - "code_challenge_methods_supported": ["S256"], - "token_endpoint_auth_methods_supported": ["client_secret_post", "none"], - "scopes_supported": [ - READ_SCOPE, - WRITE_SCOPE, - "openid", - "profile", - "email", - "groups", - ], - "subject_types_supported": ["public"], - "id_token_signing_alg_values_supported": ["RS256"], - } - ) - - -@app.post("/register") -async def oauth_dynamic_client_registration(request: Request) -> JSONResponse: - """Persist a new OAuth client registration for Claude and similar MCP clients.""" - settings = get_settings() - if not settings.dcr_enabled: - raise HTTPException(status_code=404, detail="Dynamic client registration is disabled") - - content_type = request.headers.get("content-type", "").split(";", 1)[0].strip().lower() - if content_type != "application/json": - raise HTTPException(status_code=415, detail="Content-Type must be application/json") - - registry = get_oauth_client_registry(settings.dcr_storage_path) - - try: - payload = await request.json() - registration_request = OAuthRegistrationRequest.model_validate(payload) - except ValidationError as exc: - raise HTTPException(status_code=400, detail="Invalid registration payload") from exc - except Exception as exc: - raise HTTPException(status_code=400, detail="Invalid registration payload") from exc - - for redirect_uri in registration_request.redirect_uris: - if not is_redirect_uri_allowed(redirect_uri, settings.oauth_redirect_allowlist): - raise HTTPException(status_code=400, detail="redirect_uri is not allowed") - - response = registry.register(registration_request) - response["client_id_issued_at"] = int(time.time()) - response["client_secret_expires_at"] = 0 - return JSONResponse(content=response) - - -@app.post("/oauth/token") -async def oauth_token_proxy(request: Request) -> JSONResponse: - """Proxy OAuth2 token exchange to Gitea. - - The client sends the authorization code here after the user logs in to Gitea. - This endpoint forwards the code to Gitea's token endpoint and returns the - access_token to the client, completing the OAuth2 Authorization Code flow. - """ - settings = get_settings() - registry = get_oauth_client_registry(settings.dcr_storage_path) - - try: - form_data = await request.form() - except Exception as exc: - raise HTTPException(status_code=400, detail="Invalid request body") from exc - - def _field(name: str, default: str = "") -> str: - """Read a string form field, ignoring uploaded-file parts.""" - value = form_data.get(name, default) - return value if isinstance(value, str) else default - - grant_type = _field("grant_type", "authorization_code") - code = _field("code") - refresh_token = _field("refresh_token") - code_verifier = _field("code_verifier") - # The MCP client (Claude) sends client_id and, for confidential clients, client_secret - # in the POST body. Use those directly; fall back to env vars if not provided. - client_id = _field("client_id") or settings.gitea_oauth_client_id - client_secret = _field("client_secret") or settings.gitea_oauth_client_secret - - # Gitea validates that redirect_uri in the token exchange matches the one used during - # authorization. Because our /oauth/authorize proxy always forwards our own callback - # URI to Gitea, we must use the same URI here — not the client's original redirect_uri. - base_url = settings.public_base or str(request.base_url).rstrip("/") - - if not client_id: - return _oauth_invalid_client_response() - if not registry.validate_client_secret( - client_id, - client_secret or None, - fallback_client_id=settings.gitea_oauth_client_id, - fallback_client_secret=settings.gitea_oauth_client_secret, - ): - return _oauth_invalid_client_response() - - gitea_token_url = f"{settings.gitea_base_url}/login/oauth/access_token" - upstream_client_id = settings.gitea_oauth_client_id - upstream_client_secret = settings.gitea_oauth_client_secret - - if grant_type == "refresh_token": - if not refresh_token: - raise HTTPException(status_code=400, detail="Missing refresh_token") - payload: dict[str, str] = { - "client_id": upstream_client_id, - "client_secret": upstream_client_secret, - "grant_type": "refresh_token", - "refresh_token": refresh_token, - } - else: - if not code: - raise HTTPException(status_code=400, detail="Missing authorization code") - if not code_verifier: - raise HTTPException(status_code=400, detail="Missing code_verifier") - payload = { - "client_id": upstream_client_id, - "client_secret": upstream_client_secret, - "code": code, - "grant_type": "authorization_code", - "redirect_uri": f"{base_url}/oauth/callback", - } - payload["code_verifier"] = code_verifier - - try: - async with httpx.AsyncClient(timeout=settings.request_timeout_seconds) as client: - response = await client.post( - gitea_token_url, - data=payload, - headers={"Accept": "application/json"}, - ) - except httpx.RequestError as exc: - logger.error("oauth_token_proxy_error", extra={"error": str(exc)}) - raise HTTPException(status_code=502, detail="Failed to reach Gitea token endpoint") from exc - - if response.status_code != 200: - logger.error( - "oauth_token_exchange_failed", - extra={"status": response.status_code, "body": response.text[:500]}, - ) - raise HTTPException( - status_code=response.status_code, - detail="Token exchange failed with Gitea", - ) - - token_data = response.json() - logger.info( - "oauth_token_exchange_ok", - extra={ - "token_type": token_data.get("token_type"), - "scope": token_data.get("scope", ""), - "expires_in": token_data.get("expires_in"), - }, - ) - return JSONResponse(content=token_data) - - -@app.get("/metrics") -async def metrics() -> PlainTextResponse: - """Prometheus-compatible metrics endpoint.""" - settings = get_settings() - if not settings.metrics_enabled: - raise HTTPException(status_code=404, detail="Metrics endpoint disabled") - data = get_metrics_registry().render_prometheus() - return PlainTextResponse(content=data, media_type="text/plain; version=0.0.4") - - -@app.post("/automation/webhook") -async def automation_webhook(request: AutomationWebhookRequest) -> JSONResponse: - """Ingest policy-controlled automation webhooks.""" - manager = AutomationManager() - try: - result = await manager.handle_webhook( - event_type=request.event_type, - payload=request.payload, - repository=request.repository, - ) - return JSONResponse(content={"success": True, "result": result}) - except AutomationError as exc: - raise HTTPException(status_code=403, detail=str(exc)) from exc - - -@app.post("/automation/jobs/run") -async def automation_run_job(request: AutomationJobRequest) -> JSONResponse: - """Execute a policy-controlled automation job for a repository.""" - manager = AutomationManager() - try: - result = await manager.run_job( - job_name=request.job_name, - owner=request.owner, - repo=request.repo, - user_token=get_gitea_user_token(), - finding_title=request.finding_title, - finding_body=request.finding_body, - ) - return JSONResponse(content={"success": True, "result": result}) - except AutomationError as exc: - raise HTTPException(status_code=403, detail=str(exc)) from exc - - -@app.get("/mcp/tools") -async def list_tools() -> JSONResponse: - """List all available MCP tools.""" - response = MCPListToolsResponse(tools=AVAILABLE_TOOLS) - return JSONResponse(content=response.model_dump(by_alias=True)) - - -async def _execute_tool_call( - tool_name: str, arguments: dict[str, Any], correlation_id: str -) -> dict[str, Any]: - """Execute tool call with policy checks and standardized response sanitization.""" - settings = get_settings() - audit = get_audit_logger() - metrics = get_metrics_registry() - - tool_def = get_tool_by_name(tool_name) - if not tool_def: - raise HTTPException(status_code=404, detail=f"Tool '{tool_name}' not found") - - required_scope = WRITE_SCOPE if tool_def.write_operation else READ_SCOPE - granted_scopes = set(get_gitea_user_scopes()) - if not _has_required_scope(required_scope, granted_scopes): - audit.log_access_denied( - tool_name=tool_name, - reason=f"insufficient_scope:{required_scope}", - correlation_id=correlation_id, - ) - raise HTTPException( - status_code=403, - detail=f"Insufficient scope. Required scope: {required_scope}", - ) - - handler = TOOL_HANDLERS.get(tool_name) - if not handler: - raise HTTPException( - status_code=500, detail=f"Tool '{tool_name}' has no handler implementation" - ) - - repository = extract_repository(arguments) - target_path = extract_target_path(arguments) - decision = get_policy_engine().authorize( - tool_name=tool_name, - is_write=tool_def.write_operation, - repository=repository, - target_path=target_path, - ) - if not decision.allowed: - audit.log_access_denied( - tool_name=tool_name, - repository=repository, - reason=decision.reason, - correlation_id=correlation_id, - ) - raise HTTPException(status_code=403, detail=f"Policy denied request: {decision.reason}") - - started_at = monotonic_seconds() - status = "error" - - try: - user_token = get_gitea_user_token() - if not user_token: - raise HTTPException(status_code=401, detail="Missing authenticated user token context") - - if settings.gitea_token.strip(): - user_login = get_gitea_user_login() or "" - if repository: - # Repository-scoped: verify the signed-in user's collaborator - # permission before the privileged service PAT is used. - await _verify_user_repository_access( - repository=repository, - required_scope=required_scope, - user_login=user_login, - correlation_id=correlation_id, - tool_name=tool_name, - ) - elif tool_name == "list_repositories": - # Not repo-scoped; the handler scopes it to the authenticated - # user's own repositories. - pass - else: - # Non-repository call (org/user/admin/misc, incl. gitea_request): - # classify by resource type and enforce the fail-closed rule. - classification = classify_tool(tool_name, arguments) - try: - await authorize_non_repository_access( - classification=classification, - user_login=user_login, - tool_name=tool_name, - correlation_id=correlation_id, - ) - except ToolError as exc: - raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc - - # In OAuth mode, Gitea OIDC access_tokens can't call the Gitea REST API - # (they only carry OIDC scopes). If a service PAT is configured via - # GITEA_TOKEN, use that for API calls while OIDC handles identity/authz. - api_token = settings.gitea_token.strip() if settings.gitea_token.strip() else user_token - - async with GiteaClient(token=api_token) as gitea: - try: - result = await handler(gitea, arguments) - except ToolError as exc: - # Core handlers raise the transport-agnostic ToolError; the HTTP - # adapter maps it to the matching HTTPException so existing - # status codes and audit/error envelopes are preserved. - raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc - - if settings.secret_detection_mode != "off": - # Security decision: sanitize outbound payloads to prevent accidental secret exfiltration. - result = sanitize_data(result, mode=settings.secret_detection_mode) - - status = "success" - return result - finally: - duration = max(monotonic_seconds() - started_at, 0.0) - metrics.record_tool_call(tool_name, status, duration) - - -@app.post("/mcp/tool/call") -async def call_tool(request: MCPToolCallRequest) -> JSONResponse: - """Execute an MCP tool call.""" - settings = get_settings() - audit = get_audit_logger() - - correlation_id = request.correlation_id or audit.log_tool_invocation( - tool_name=request.tool, - params=request.arguments, - ) - - try: - result = await _execute_tool_call(request.tool, request.arguments, correlation_id) - audit.log_tool_invocation( - tool_name=request.tool, - correlation_id=correlation_id, - result_status="success", - ) - return JSONResponse( - content=MCPToolCallResponse( - success=True, - result=result, - correlation_id=correlation_id, - ).model_dump() - ) - - except HTTPException as exc: - audit.log_tool_invocation( - tool_name=request.tool, - correlation_id=correlation_id, - result_status="error", - error=str(exc.detail), - ) - raise - - except ValidationError as exc: - error_message = "Invalid tool arguments" - if settings.expose_error_details: - error_message = f"{error_message}: {exc}" - - audit.log_tool_invocation( - tool_name=request.tool, - correlation_id=correlation_id, - result_status="error", - error="validation_error", - ) - raise HTTPException(status_code=400, detail=error_message) from exc - - except GiteaAuthorizationError as exc: - audit.log_tool_invocation( - tool_name=request.tool, - correlation_id=correlation_id, - result_status="error", - error="gitea_authorization_error", - ) - logger.warning("gitea_authorization_error: %s", exc) - return JSONResponse( - status_code=403, - content=MCPToolCallResponse( - success=False, - error=_REAUTH_GUIDANCE, - correlation_id=correlation_id, - ).model_dump(), - ) - - except GiteaAuthenticationError as exc: - audit.log_tool_invocation( - tool_name=request.tool, - correlation_id=correlation_id, - result_status="error", - error="gitea_authentication_error", - ) - logger.warning("gitea_authentication_error: %s", exc) - return JSONResponse( - status_code=401, - content=MCPToolCallResponse( - success=False, - error="Gitea rejected the token. Please re-authenticate.", - correlation_id=correlation_id, - ).model_dump(), - ) - - except Exception as exc: - if _find_not_found(exc) is not None: - audit.log_tool_invocation( - tool_name=request.tool, - correlation_id=correlation_id, - result_status="error", - error="gitea_not_found", - ) - return JSONResponse( - status_code=404, - content=MCPToolCallResponse( - success=False, - error=_NOT_FOUND_MESSAGE, - correlation_id=correlation_id, - ).model_dump(), - ) - - # Security decision: do not leak stack traces or raw exception messages; - # the exception type name alone is safe and aids diagnosis. - audit.log_tool_invocation( - tool_name=request.tool, - correlation_id=correlation_id, - result_status="error", - error="internal_error", - ) - logger.exception("tool_execution_failed") - return JSONResponse( - status_code=500, - content=MCPToolCallResponse( - success=False, - error=_masked_internal_error(exc, settings.expose_error_details), - correlation_id=correlation_id, - ).model_dump(), - ) - - -@app.get("/mcp") -@app.get("/mcp/sse") -async def sse_endpoint(request: Request) -> StreamingResponse: - """Server-Sent Events endpoint for MCP transport.""" - - async def event_stream() -> AsyncGenerator[str, None]: - yield ( - "data: " - + json.dumps( - {"event": "connected", "server": "AegisGitea MCP", "version": "0.2.0"}, - separators=(",", ":"), - ) - + "\n\n" - ) - - try: - while True: - if await request.is_disconnected(): - break - yield 'data: {"event":"heartbeat"}\n\n' - await asyncio.sleep(30) - except Exception: - logger.exception("sse_stream_error") - - return StreamingResponse( - event_stream(), - media_type="text/event-stream", - headers={ - "Cache-Control": "no-cache", - "Connection": "keep-alive", - "X-Accel-Buffering": "no", - }, - ) - - -@app.post("/mcp") -@app.post("/mcp/sse") -async def sse_message_handler(request: Request) -> JSONResponse: - """Handle POST messages for MCP SSE transport.""" - settings = get_settings() - audit = get_audit_logger() - - try: - body = await request.json() - message_type = body.get("type") or body.get("method") - message_id = body.get("id") - - if message_type == "initialize": - return JSONResponse( - content={ - "jsonrpc": "2.0", - "id": message_id, - "result": { - "protocolVersion": "2024-11-05", - "capabilities": {"tools": {}}, - "serverInfo": {"name": "AegisGitea MCP", "version": "0.2.0"}, - }, - } - ) - - if message_type == "tools/list": - response = MCPListToolsResponse(tools=AVAILABLE_TOOLS) - return JSONResponse( - content={ - "jsonrpc": "2.0", - "id": message_id, - "result": response.model_dump(by_alias=True), - } - ) - - if message_type == "tools/call": - tool_name = body.get("params", {}).get("name") - tool_args = body.get("params", {}).get("arguments", {}) - - correlation_id = audit.log_tool_invocation(tool_name=tool_name, params=tool_args) - try: - result = await _execute_tool_call(str(tool_name), tool_args, correlation_id) - audit.log_tool_invocation( - tool_name=str(tool_name), - correlation_id=correlation_id, - result_status="success", - ) - return JSONResponse( - content={ - "jsonrpc": "2.0", - "id": message_id, - "result": {"content": [{"type": "text", "text": json.dumps(result)}]}, - } - ) - except HTTPException as exc: - audit.log_tool_invocation( - tool_name=str(tool_name), - correlation_id=correlation_id, - result_status="error", - error=str(exc.detail), - ) - return JSONResponse( - content={ - "jsonrpc": "2.0", - "id": message_id, - "error": {"code": -32000, "message": str(exc.detail)}, - } - ) - except GiteaAuthorizationError as exc: - audit.log_tool_invocation( - tool_name=str(tool_name), - correlation_id=correlation_id, - result_status="error", - error="gitea_authorization_error", - ) - logger.warning("gitea_authorization_error: %s", exc) - return JSONResponse( - content={ - "jsonrpc": "2.0", - "id": message_id, - "error": {"code": -32000, "message": _REAUTH_GUIDANCE}, - } - ) - except GiteaAuthenticationError as exc: - audit.log_tool_invocation( - tool_name=str(tool_name), - correlation_id=correlation_id, - result_status="error", - error="gitea_authentication_error", - ) - logger.warning("gitea_authentication_error: %s", exc) - return JSONResponse( - content={ - "jsonrpc": "2.0", - "id": message_id, - "error": { - "code": -32000, - "message": "Gitea rejected the token. Please re-authenticate.", - }, - } - ) - except Exception as exc: - audit.log_tool_invocation( - tool_name=str(tool_name), - correlation_id=correlation_id, - result_status="error", - error=str(exc), - ) - if _find_not_found(exc) is not None: - # -32000 (application error), matching the auth-error envelope. - return JSONResponse( - content={ - "jsonrpc": "2.0", - "id": message_id, - "error": {"code": -32000, "message": _NOT_FOUND_MESSAGE}, - } - ) - return JSONResponse( - content={ - "jsonrpc": "2.0", - "id": message_id, - "error": { - "code": -32603, - "message": _masked_internal_error(exc, settings.expose_error_details), - }, - } - ) - - if isinstance(message_type, str) and message_type.startswith("notifications/"): - return JSONResponse(content={}) - - return JSONResponse( - content={"jsonrpc": "2.0", "id": message_id, "result": {"acknowledged": True}} - ) - - except Exception: - logger.exception("sse_message_handler_error") - message = "Invalid message format" - if settings.expose_error_details: - message = "Invalid message format (details hidden unless explicitly enabled)" - return JSONResponse(status_code=400, content={"error": message}) - - -def main() -> None: - """Run the MCP server.""" - import uvicorn - - settings = get_settings() - - uvicorn.run( - "aegis_gitea_mcp.server:app", - host=settings.mcp_host, - port=settings.mcp_port, - log_level=settings.log_level.lower(), - reload=False, - ) - - -if __name__ == "__main__": - main() diff --git a/src/aegis_gitea_mcp/server_entry.py b/src/aegis_gitea_mcp/server_entry.py deleted file mode 100644 index 6e07202..0000000 --- a/src/aegis_gitea_mcp/server_entry.py +++ /dev/null @@ -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() diff --git a/src/aegis_gitea_mcp/stdio_app.py b/src/aegis_gitea_mcp/stdio_app.py deleted file mode 100644 index be73b8a..0000000 --- a/src/aegis_gitea_mcp/stdio_app.py +++ /dev/null @@ -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=\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"] diff --git a/src/aegis_gitea_mcp/tools/__init__.py b/src/aegis_gitea_mcp/tools/__init__.py deleted file mode 100644 index b7be0b7..0000000 --- a/src/aegis_gitea_mcp/tools/__init__.py +++ /dev/null @@ -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", -] diff --git a/src/aegis_gitea_mcp/tools/arguments.py b/src/aegis_gitea_mcp/tools/arguments.py deleted file mode 100644 index b5f3f29..0000000 --- a/src/aegis_gitea_mcp/tools/arguments.py +++ /dev/null @@ -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 diff --git a/src/aegis_gitea_mcp/tools/raw_tools.py b/src/aegis_gitea_mcp/tools/raw_tools.py deleted file mode 100644 index 94c7fbd..0000000 --- a/src/aegis_gitea_mcp/tools/raw_tools.py +++ /dev/null @@ -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::``) 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 diff --git a/src/aegis_gitea_mcp/tools/read_tools.py b/src/aegis_gitea_mcp/tools/read_tools.py deleted file mode 100644 index ec6c887..0000000 --- a/src/aegis_gitea_mcp/tools/read_tools.py +++ /dev/null @@ -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 diff --git a/src/aegis_gitea_mcp/tools/repository.py b/src/aegis_gitea_mcp/tools/repository.py deleted file mode 100644 index f160fd8..0000000 --- a/src/aegis_gitea_mcp/tools/repository.py +++ /dev/null @@ -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 diff --git a/src/aegis_gitea_mcp/tools/write_tools.py b/src/aegis_gitea_mcp/tools/write_tools.py deleted file mode 100644 index 686fd5b..0000000 --- a/src/aegis_gitea_mcp/tools/write_tools.py +++ /dev/null @@ -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 diff --git a/tests/test_auth.py b/tests/test_auth.py deleted file mode 100644 index 2288626..0000000 --- a/tests/test_auth.py +++ /dev/null @@ -1,208 +0,0 @@ -"""Tests for authentication module.""" - -import pytest - -from aegis_gitea_mcp.auth import ( - APIKeyValidator, - generate_api_key, - get_validator, - hash_api_key, - reset_validator, -) -from aegis_gitea_mcp.config import reset_settings - - -@pytest.fixture(autouse=True) -def reset_auth(): - """Reset authentication state between tests.""" - reset_validator() - reset_settings() - yield - reset_validator() - reset_settings() - - -@pytest.fixture -def mock_env_with_key(monkeypatch): - """Set up environment with valid API key.""" - monkeypatch.setenv("GITEA_URL", "https://gitea.example.com") - monkeypatch.setenv("GITEA_TOKEN", "test-token-12345") - monkeypatch.setenv("AUTH_ENABLED", "true") - monkeypatch.setenv("MCP_API_KEYS", "a" * 64) # 64-char key - - -@pytest.fixture -def validator(mock_env_with_key): - """Create API key validator instance.""" - return APIKeyValidator() - - -def test_generate_api_key(): - """Test API key generation.""" - key = generate_api_key(length=64) - - assert len(key) == 64 - assert all(c in "0123456789abcdef" for c in key) - - -def test_generate_api_key_custom_length(): - """Test API key generation with custom length.""" - key = generate_api_key(length=128) - - assert len(key) == 128 - - -def test_hash_api_key(): - """Test API key hashing.""" - key = "test-key-12345" - hashed = hash_api_key(key) - - assert len(hashed) == 64 # SHA256 produces 64-char hex string - assert hashed == hash_api_key(key) # Deterministic - - -def test_validator_singleton(mock_env_with_key): - """Test that get_validator returns same instance.""" - validator1 = get_validator() - validator2 = get_validator() - - assert validator1 is validator2 - - -def test_constant_time_compare(validator): - """Test constant-time string comparison.""" - # Same strings - assert validator._constant_time_compare("test", "test") - - # Different strings - assert not validator._constant_time_compare("test", "fail") - - # Different lengths - assert not validator._constant_time_compare("test", "testing") - - -def test_extract_bearer_token(validator): - """Test bearer token extraction from Authorization header.""" - # Valid bearer token - token = validator.extract_bearer_token("Bearer abc123") - assert token == "abc123" - - # No header - token = validator.extract_bearer_token(None) - assert token is None - - # Wrong format - token = validator.extract_bearer_token("abc123") - assert token is None - - # Wrong scheme - token = validator.extract_bearer_token("Basic abc123") - assert token is None - - # Too many parts - token = validator.extract_bearer_token("Bearer abc 123") - assert token is None - - -def test_validate_api_key_missing(validator): - """Test validation with missing API key.""" - is_valid, error = validator.validate_api_key(None, "127.0.0.1", "test-agent") - - assert not is_valid - assert "Authorization header missing" in error - - -def test_validate_api_key_too_short(validator): - """Test validation with too-short API key.""" - is_valid, error = validator.validate_api_key("short", "127.0.0.1", "test-agent") - - assert not is_valid - assert "Invalid API key format" in error - - -def test_validate_api_key_invalid(validator, mock_env_with_key): - """Test validation with invalid API key.""" - is_valid, error = validator.validate_api_key("b" * 64, "127.0.0.1", "test-agent") - - assert not is_valid - assert "Invalid API key" in error - - -def test_validate_api_key_valid(validator, mock_env_with_key): - """Test validation with valid API key.""" - is_valid, error = validator.validate_api_key("a" * 64, "127.0.0.1", "test-agent") - - assert is_valid - assert error is None - - -def test_rate_limiting(validator): - """Test rate limiting after multiple failures.""" - client_ip = "192.168.1.1" - - # First 5 attempts should be allowed - for _ in range(5): - is_valid, error = validator.validate_api_key("b" * 64, client_ip, "test-agent") - assert not is_valid - - # 6th attempt should be rate limited - is_valid, error = validator.validate_api_key("b" * 64, client_ip, "test-agent") - assert not is_valid - assert "Too many failed" in error - - -def test_rate_limiting_per_ip(validator): - """Test that rate limiting is per IP address.""" - ip1 = "192.168.1.1" - ip2 = "192.168.1.2" - - # Fail 5 times from IP1 - for _ in range(5): - validator.validate_api_key("b" * 64, ip1, "test-agent") - - # IP1 should be rate limited - is_valid, error = validator.validate_api_key("b" * 64, ip1, "test-agent") - assert "Too many failed" in error - - # IP2 should still work - is_valid, error = validator.validate_api_key("b" * 64, ip2, "test-agent") - assert "Invalid API key" in error # Wrong key, but not rate limited - - -def test_auth_disabled(monkeypatch): - """Test that authentication can be disabled.""" - monkeypatch.setenv("GITEA_URL", "https://gitea.example.com") - monkeypatch.setenv("GITEA_TOKEN", "test-token-12345") - monkeypatch.setenv("AUTH_ENABLED", "false") - monkeypatch.setenv("MCP_API_KEYS", "") # No keys needed when disabled - - validator = APIKeyValidator() - - # Should allow access without key - is_valid, error = validator.validate_api_key(None, "127.0.0.1", "test-agent") - assert is_valid - assert error is None - - -def test_multiple_keys(monkeypatch): - """Test validation with multiple API keys.""" - monkeypatch.setenv("GITEA_URL", "https://gitea.example.com") - monkeypatch.setenv("GITEA_TOKEN", "test-token-12345") - monkeypatch.setenv("AUTH_ENABLED", "true") - monkeypatch.setenv("MCP_API_KEYS", f"{'a' * 64},{'b' * 64},{'c' * 64}") - - validator = APIKeyValidator() - - # All three keys should work - is_valid, _ = validator.validate_api_key("a" * 64, "127.0.0.1", "test-agent") - assert is_valid - - is_valid, _ = validator.validate_api_key("b" * 64, "127.0.0.1", "test-agent") - assert is_valid - - is_valid, _ = validator.validate_api_key("c" * 64, "127.0.0.1", "test-agent") - assert is_valid - - # Invalid key should fail - is_valid, _ = validator.validate_api_key("d" * 64, "127.0.0.1", "test-agent") - assert not is_valid diff --git a/tests/test_authz.py b/tests/test_authz.py deleted file mode 100644 index e5ca920..0000000 --- a/tests/test_authz.py +++ /dev/null @@ -1,261 +0,0 @@ -"""Tests for resource-type-aware authorization (fail-closed).""" - -from __future__ import annotations - -from pathlib import Path -from typing import Any -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from aegis_gitea_mcp import authz -from aegis_gitea_mcp.authz import ( - ResourceClass, - ResourceType, - authorize_non_repository_access, - classify_raw_endpoint, - classify_tool, - verify_org_membership, - verify_site_admin, -) -from aegis_gitea_mcp.config import reset_settings -from aegis_gitea_mcp.errors import ToolError -from aegis_gitea_mcp.tools.arguments import normalize_raw_endpoint - - -@pytest.fixture -def authz_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - """Service-PAT-mode settings used by the authorization layer.""" - reset_settings() - monkeypatch.setenv("GITEA_URL", "https://gitea.example.com") - monkeypatch.setenv("GITEA_TOKEN", "service-pat-token") - monkeypatch.setenv("MCP_API_KEYS", "a" * 64) - monkeypatch.setenv("ENVIRONMENT", "test") - monkeypatch.setenv("POLICY_FILE_PATH", str(tmp_path / "missing-policy.yaml")) - - -def _endpoint(path: str) -> str: - return normalize_raw_endpoint(path) - - -# --- Classification --------------------------------------------------------- - - -@pytest.mark.parametrize( - ("method", "path", "rtype", "ident_field", "ident_value"), - [ - ("GET", "/repos/acme/app/pulls/1", ResourceType.REPOSITORY, "repository", "acme/app"), - ("GET", "/repos/issues/search", ResourceType.REPOSITORY, "repository", None), - ("GET", "/orgs/acme/repos", ResourceType.ORG, "org", "acme"), - ("GET", "/users/bob/repos", ResourceType.USER_OWNED, "owner", "bob"), - ("GET", "/packages/bob/pypi", ResourceType.USER_OWNED, "owner", "bob"), - ("GET", "/user/repos", ResourceType.USER_SELF, "repository", None), - ("GET", "/notifications", ResourceType.USER_SELF, "repository", None), - ("GET", "/markdown", ResourceType.MISC_GLOBAL, "repository", None), - ("GET", "/version", ResourceType.MISC_GLOBAL, "repository", None), - ("DELETE", "/admin/users/bob", ResourceType.ADMIN, "repository", None), - ], -) -def test_classify_raw_endpoint( - method: str, path: str, rtype: ResourceType, ident_field: str, ident_value: str | None -) -> None: - result = classify_raw_endpoint(method, _endpoint(path)) - assert result.resource_type is rtype - assert getattr(result, ident_field) == ident_value - - -def test_classify_tool_maps_typed_tools() -> None: - assert classify_tool("list_org_repositories", {"org": "acme"}).resource_type is ResourceType.ORG - assert classify_tool("list_org_repositories", {"org": "acme"}).org == "acme" - assert classify_tool("list_organizations", {}).resource_type is ResourceType.USER_SELF - # An unrecognized non-repo tool is UNKNOWN (deny). - assert classify_tool("something_new", {}).resource_type is ResourceType.UNKNOWN - - -def test_classify_tool_gitea_request_uses_path() -> None: - cls = classify_tool("gitea_request", {"method": "GET", "path": "/orgs/acme/repos"}) - assert cls.resource_type is ResourceType.ORG - assert cls.org == "acme" - - -def test_classify_tool_gitea_request_traversal_is_unknown() -> None: - cls = classify_tool("gitea_request", {"method": "GET", "path": "/repos/../../admin"}) - assert cls.resource_type is ResourceType.UNKNOWN - - -# --- Decision matrix (verification mocked) ---------------------------------- - - -async def test_org_member_allowed(authz_env: None, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(authz, "verify_org_membership", AsyncMock(return_value=True)) - cls = ResourceClass(ResourceType.ORG, is_write=False, org="acme") - await authorize_non_repository_access( - classification=cls, user_login="alice", tool_name="gitea_request" - ) - - -async def test_org_nonmember_denied(authz_env: None, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(authz, "verify_org_membership", AsyncMock(return_value=False)) - cls = ResourceClass(ResourceType.ORG, is_write=False, org="acme") - with pytest.raises(ToolError) as exc_info: - await authorize_non_repository_access( - classification=cls, user_login="alice", tool_name="gitea_request" - ) - assert exc_info.value.status_code == 403 - - -async def test_user_owned_self_allowed(authz_env: None, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(authz, "verify_org_membership", AsyncMock(return_value=False)) - cls = ResourceClass(ResourceType.USER_OWNED, is_write=False, owner="alice") - await authorize_non_repository_access( - classification=cls, user_login="alice", tool_name="gitea_request" - ) - - -async def test_user_owned_member_org_allowed( - authz_env: None, monkeypatch: pytest.MonkeyPatch -) -> None: - monkeypatch.setattr(authz, "verify_org_membership", AsyncMock(return_value=True)) - cls = ResourceClass(ResourceType.USER_OWNED, is_write=False, owner="acme") - await authorize_non_repository_access( - classification=cls, user_login="alice", tool_name="gitea_request" - ) - - -async def test_user_owned_other_denied(authz_env: None, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(authz, "verify_org_membership", AsyncMock(return_value=False)) - cls = ResourceClass(ResourceType.USER_OWNED, is_write=False, owner="bob") - with pytest.raises(ToolError): - await authorize_non_repository_access( - classification=cls, user_login="alice", tool_name="gitea_request" - ) - - -async def test_user_self_denied_in_service_pat_mode(authz_env: None) -> None: - cls = ResourceClass(ResourceType.USER_SELF, is_write=False) - with pytest.raises(ToolError) as exc_info: - await authorize_non_repository_access( - classification=cls, user_login="alice", tool_name="gitea_request" - ) - assert "token-owner-scoped" in str(exc_info.value.detail) - - -async def test_misc_global_read_allowed_write_denied(authz_env: None) -> None: - read_cls = ResourceClass(ResourceType.MISC_GLOBAL, is_write=False) - await authorize_non_repository_access( - classification=read_cls, user_login="alice", tool_name="gitea_request" - ) - write_cls = ResourceClass(ResourceType.MISC_GLOBAL, is_write=True) - with pytest.raises(ToolError): - await authorize_non_repository_access( - classification=write_cls, user_login="alice", tool_name="gitea_request" - ) - - -async def test_admin_denied_without_opt_in(authz_env: None) -> None: - cls = ResourceClass(ResourceType.ADMIN, is_write=True) - with pytest.raises(ToolError) as exc_info: - await authorize_non_repository_access( - classification=cls, user_login="alice", tool_name="gitea_request" - ) - assert "RAW_API_ALLOW_SENSITIVE" in str(exc_info.value.detail) - - -async def test_admin_allowed_only_for_site_admin_with_opt_in( - authz_env: None, monkeypatch: pytest.MonkeyPatch -) -> None: - monkeypatch.setenv("RAW_API_ALLOW_SENSITIVE", "true") - reset_settings() - - monkeypatch.setattr(authz, "verify_site_admin", AsyncMock(return_value=True)) - cls = ResourceClass(ResourceType.ADMIN, is_write=True) - await authorize_non_repository_access( - classification=cls, user_login="root", tool_name="gitea_request" - ) - - monkeypatch.setattr(authz, "verify_site_admin", AsyncMock(return_value=False)) - with pytest.raises(ToolError): - await authorize_non_repository_access( - classification=cls, user_login="alice", tool_name="gitea_request" - ) - - -async def test_unknown_resource_denied(authz_env: None) -> None: - cls = ResourceClass(ResourceType.UNKNOWN, is_write=False) - with pytest.raises(ToolError): - await authorize_non_repository_access( - classification=cls, user_login="alice", tool_name="gitea_request" - ) - - -async def test_repository_without_target_denied(authz_env: None) -> None: - """A repo-typed call that could not be scoped to owner/repo fails closed.""" - cls = ResourceClass(ResourceType.REPOSITORY, is_write=False, repository=None) - with pytest.raises(ToolError): - await authorize_non_repository_access( - classification=cls, user_login="alice", tool_name="gitea_request" - ) - - -# --- Gitea verification helpers (fail-closed) ------------------------------- - - -def _patch_service_response(status_code: int, json_value: object = None) -> Any: - response = MagicMock() - response.status_code = status_code - response.json.return_value = json_value - return response - - -def _patched_client(response: object) -> Any: - patcher = patch("aegis_gitea_mcp.authz.httpx.AsyncClient") - mock_client_cls = patcher.start() - mock_client = AsyncMock() - mock_client.get = AsyncMock(return_value=response) - mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client) - mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False) - return patcher - - -async def test_verify_org_membership_204_true(authz_env: None) -> None: - patcher = _patched_client(_patch_service_response(204)) - try: - assert await verify_org_membership(org="acme", user_login="alice") is True - finally: - patcher.stop() - - -async def test_verify_org_membership_404_false(authz_env: None) -> None: - patcher = _patched_client(_patch_service_response(404)) - try: - assert await verify_org_membership(org="acme", user_login="alice") is False - finally: - patcher.stop() - - -async def test_verify_org_membership_unknown_user_false(authz_env: None) -> None: - assert await verify_org_membership(org="acme", user_login="unknown") is False - - -async def test_verify_site_admin_true_only_when_flag_set(authz_env: None) -> None: - patcher = _patched_client(_patch_service_response(200, {"is_admin": True})) - try: - assert await verify_site_admin(user_login="root") is True - finally: - patcher.stop() - - -async def test_verify_site_admin_false_when_flag_absent(authz_env: None) -> None: - patcher = _patched_client(_patch_service_response(200, {"is_admin": False})) - try: - assert await verify_site_admin(user_login="alice") is False - finally: - patcher.stop() - - -async def test_verify_site_admin_non_200_false(authz_env: None) -> None: - patcher = _patched_client(_patch_service_response(403, {})) - try: - assert await verify_site_admin(user_login="alice") is False - finally: - patcher.stop() diff --git a/tests/test_automation.py b/tests/test_automation.py deleted file mode 100644 index f6c908f..0000000 --- a/tests/test_automation.py +++ /dev/null @@ -1,155 +0,0 @@ -"""Tests for automation endpoints and controls.""" - -from pathlib import Path - -import pytest -from fastapi.testclient import TestClient - - -@pytest.fixture -def allow_oauth(monkeypatch: pytest.MonkeyPatch) -> None: - """Mock OAuth validation to return a deterministic authenticated principal.""" - - async def _validate(_self, token, _ip, _ua): - if token == "a" * 64: - return True, None, {"login": "automation-user", "scopes": ["read:repository"]} - return False, "Invalid or expired OAuth token.", None - - monkeypatch.setattr( - "aegis_gitea_mcp.oauth.GiteaOAuthValidator.validate_oauth_token", - _validate, - ) - - -def _set_base_env( - monkeypatch: pytest.MonkeyPatch, automation_enabled: bool, policy_path: Path -) -> None: - monkeypatch.setenv("GITEA_URL", "https://gitea.example.com") - monkeypatch.setenv("GITEA_TOKEN", "test-token") - monkeypatch.setenv("MCP_API_KEYS", "a" * 64) - monkeypatch.setenv("AUTH_ENABLED", "true") - monkeypatch.setenv("ENVIRONMENT", "test") - monkeypatch.setenv("STARTUP_VALIDATE_GITEA", "false") - monkeypatch.setenv("AUTOMATION_ENABLED", "true" if automation_enabled else "false") - monkeypatch.setenv("POLICY_FILE_PATH", str(policy_path)) - - -def _yaml_with_trailing_newline(content: str) -> str: - return content.strip() + "\n" - - -def test_automation_job_denied_when_disabled( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, allow_oauth: None -) -> None: - """Automation endpoints should deny requests when automation mode is disabled.""" - policy_path = tmp_path / "policy.yaml" - policy_path.write_text("defaults:\n read: allow\n write: deny\n", encoding="utf-8") - _set_base_env(monkeypatch, automation_enabled=False, policy_path=policy_path) - - from aegis_gitea_mcp.server import app - - client = TestClient(app) - response = client.post( - "/automation/jobs/run", - headers={"Authorization": f"Bearer {'a' * 64}"}, - json={"job_name": "dependency_hygiene_scan", "owner": "acme", "repo": "app"}, - ) - - assert response.status_code == 403 - assert "disabled" in response.json()["detail"] - - -def test_automation_job_executes_when_enabled( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, allow_oauth: None -) -> None: - """Dependency scan job should execute when automation is enabled and policy allows it.""" - policy_path = tmp_path / "policy.yaml" - policy_path.write_text( - _yaml_with_trailing_newline(""" -defaults: - read: allow - write: deny -tools: - allow: - - automation_dependency_hygiene_scan - - automation_webhook_ingest -"""), - encoding="utf-8", - ) - _set_base_env(monkeypatch, automation_enabled=True, policy_path=policy_path) - - from aegis_gitea_mcp.server import app - - client = TestClient(app) - response = client.post( - "/automation/jobs/run", - headers={"Authorization": f"Bearer {'a' * 64}"}, - json={"job_name": "dependency_hygiene_scan", "owner": "acme", "repo": "app"}, - ) - - assert response.status_code == 200 - payload = response.json() - assert payload["success"] is True - assert payload["result"]["job"] == "dependency_hygiene_scan" - - -def test_automation_webhook_policy_denied( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, allow_oauth: None -) -> None: - """Webhook ingestion must respect policy deny rules.""" - policy_path = tmp_path / "policy.yaml" - policy_path.write_text( - _yaml_with_trailing_newline(""" -defaults: - read: allow - write: deny -tools: - deny: - - automation_webhook_ingest -"""), - encoding="utf-8", - ) - _set_base_env(monkeypatch, automation_enabled=True, policy_path=policy_path) - - from aegis_gitea_mcp.server import app - - client = TestClient(app) - response = client.post( - "/automation/webhook", - headers={"Authorization": f"Bearer {'a' * 64}"}, - json={"event_type": "scan.completed", "payload": {"status": "ok"}}, - ) - - assert response.status_code == 403 - assert "policy denied" in response.json()["detail"].lower() - - -def test_auto_issue_creation_denied_without_write_mode( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, allow_oauth: None -) -> None: - """Auto issue creation job should be denied unless write mode is enabled.""" - policy_path = tmp_path / "policy.yaml" - policy_path.write_text( - _yaml_with_trailing_newline(""" -defaults: - read: allow - write: allow -tools: - allow: - - automation_auto_issue_creation -"""), - encoding="utf-8", - ) - _set_base_env(monkeypatch, automation_enabled=True, policy_path=policy_path) - - from aegis_gitea_mcp.server import app - - client = TestClient(app) - response = client.post( - "/automation/jobs/run", - headers={"Authorization": f"Bearer {'a' * 64}"}, - json={"job_name": "auto_issue_creation", "owner": "acme", "repo": "app"}, - ) - - assert response.status_code == 403 - assert "write mode is disabled" in response.json()["detail"].lower() diff --git a/tests/test_automation_manager.py b/tests/test_automation_manager.py deleted file mode 100644 index 0d9bedf..0000000 --- a/tests/test_automation_manager.py +++ /dev/null @@ -1,140 +0,0 @@ -"""Unit tests for automation manager job paths.""" - -from __future__ import annotations - -from pathlib import Path - -import pytest - -from aegis_gitea_mcp.automation import AutomationError, AutomationManager -from aegis_gitea_mcp.config import reset_settings -from aegis_gitea_mcp.policy import reset_policy_engine - - -class StubAutomationGiteaClient: - """Async context manager stub for automation jobs.""" - - def __init__(self, token: str, issues: list[dict] | None = None) -> None: - self.token = token - self._issues = issues or [] - - async def __aenter__(self): - return self - - async def __aexit__(self, *_args): - return None - - async def list_issues(self, owner, repo, *, state, page, limit, labels=None): - return self._issues - - async def create_issue(self, owner, repo, *, title, body, labels=None, assignees=None): - return {"number": 77, "title": title, "body": body} - - -@pytest.fixture(autouse=True) -def reset_globals() -> None: - """Reset singleton state between tests.""" - reset_settings() - reset_policy_engine() - yield - reset_settings() - reset_policy_engine() - - -@pytest.fixture -def automation_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: - """Set environment for automation manager tests.""" - policy_path = tmp_path / "policy.yaml" - policy_path.write_text( - ( - "defaults:\n" - " read: allow\n" - " write: allow\n" - "tools:\n" - " allow:\n" - " - automation_stale_issue_detection\n" - " - automation_auto_issue_creation\n" - ), - encoding="utf-8", - ) - - monkeypatch.setenv("GITEA_URL", "https://gitea.example.com") - monkeypatch.setenv("GITEA_TOKEN", "legacy-token") - monkeypatch.setenv("MCP_API_KEYS", "a" * 64) - monkeypatch.setenv("ENVIRONMENT", "test") - monkeypatch.setenv("AUTOMATION_ENABLED", "true") - monkeypatch.setenv("POLICY_FILE_PATH", str(policy_path)) - monkeypatch.setenv("WRITE_MODE", "true") - monkeypatch.setenv("WRITE_REPOSITORY_WHITELIST", "acme/demo") - monkeypatch.setenv("AUTOMATION_STALE_DAYS", "30") - return policy_path - - -@pytest.mark.asyncio -async def test_stale_issue_detection_job_finds_old_issues( - automation_env: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Stale issue detection returns issue numbers older than cutoff.""" - - issues = [ - {"number": 1, "updated_at": "2020-01-01T00:00:00Z"}, - {"number": 2, "updated_at": "2999-01-01T00:00:00Z"}, - ] - - monkeypatch.setattr( - "aegis_gitea_mcp.automation.GiteaClient", - lambda token: StubAutomationGiteaClient(token=token, issues=issues), - ) - - manager = AutomationManager() - result = await manager.run_job( - job_name="stale_issue_detection", - owner="acme", - repo="demo", - user_token="user-token", - ) - - assert result["stale_issue_numbers"] == [1] - assert result["stale_count"] == 1 - - -@pytest.mark.asyncio -async def test_auto_issue_creation_requires_token( - automation_env: Path, -) -> None: - """Auto-issue creation is denied when no user token is provided.""" - manager = AutomationManager() - - with pytest.raises(AutomationError, match="missing authenticated user token"): - await manager.run_job( - job_name="auto_issue_creation", - owner="acme", - repo="demo", - user_token=None, - ) - - -@pytest.mark.asyncio -async def test_auto_issue_creation_job_success( - automation_env: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Auto-issue creation succeeds with write mode + scope + token.""" - monkeypatch.setattr( - "aegis_gitea_mcp.automation.GiteaClient", - lambda token: StubAutomationGiteaClient(token=token), - ) - - manager = AutomationManager() - result = await manager.run_job( - job_name="auto_issue_creation", - owner="acme", - repo="demo", - user_token="user-token", - finding_title="Security finding", - finding_body="Details", - ) - - assert result["job"] == "auto_issue_creation" - assert result["issue_number"] == 77 diff --git a/tests/test_cache.py b/tests/test_cache.py deleted file mode 100644 index 2aaf139..0000000 --- a/tests/test_cache.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Tests for the bounded TTL cache utility.""" - -from __future__ import annotations - -import time - -import pytest - -from aegis_gitea_mcp.cache import BoundedTTLCache - - -def test_set_and_get_returns_value() -> None: - """A stored value is returned before it expires.""" - cache: BoundedTTLCache[str, int] = BoundedTTLCache(ttl_seconds=60, max_size=8) - cache.set("a", 1) - assert cache.get("a") == 1 - - -def test_missing_key_returns_none() -> None: - """An unknown key returns None.""" - cache: BoundedTTLCache[str, int] = BoundedTTLCache(ttl_seconds=60) - assert cache.get("missing") is None - - -def test_entry_expires_after_ttl() -> None: - """An entry is evicted once its TTL elapses.""" - cache: BoundedTTLCache[str, int] = BoundedTTLCache(ttl_seconds=0.05, max_size=8) - cache.set("a", 1) - assert cache.get("a") == 1 - time.sleep(0.06) - assert cache.get("a") is None - - -def test_size_bound_evicts_oldest() -> None: - """The cache never exceeds max_size; oldest entries are evicted first.""" - cache: BoundedTTLCache[int, int] = BoundedTTLCache(ttl_seconds=60, max_size=3) - for i in range(5): - cache.set(i, i) - assert len(cache) == 3 - # 0 and 1 were evicted; 2, 3, 4 remain. - assert cache.get(0) is None - assert cache.get(1) is None - assert cache.get(4) == 4 - - -def test_reinsert_refreshes_recency() -> None: - """Re-setting a key refreshes its position so it is not evicted first.""" - cache: BoundedTTLCache[str, int] = BoundedTTLCache(ttl_seconds=60, max_size=2) - cache.set("a", 1) - cache.set("b", 2) - cache.set("a", 3) # refresh "a" - cache.set("c", 4) # should evict "b", the oldest - assert cache.get("b") is None - assert cache.get("a") == 3 - assert cache.get("c") == 4 - - -def test_clear_empties_cache() -> None: - """clear() removes all entries.""" - cache: BoundedTTLCache[str, int] = BoundedTTLCache(ttl_seconds=60) - cache.set("a", 1) - cache.clear() - assert cache.get("a") is None - assert len(cache) == 0 - - -def test_invalid_constructor_args() -> None: - """Non-positive TTL or size is rejected.""" - with pytest.raises(ValueError): - BoundedTTLCache(ttl_seconds=0) - with pytest.raises(ValueError): - BoundedTTLCache(ttl_seconds=60, max_size=0) diff --git a/tests/test_classifier.py b/tests/test_classifier.py deleted file mode 100644 index 1470817..0000000 --- a/tests/test_classifier.py +++ /dev/null @@ -1,128 +0,0 @@ -"""Tests for the gitea_request read/write classifier and known-path gate.""" - -from __future__ import annotations - -from pathlib import Path -from typing import Any - -import pytest - -from aegis_gitea_mcp.config import reset_settings -from aegis_gitea_mcp.errors import ToolError -from aegis_gitea_mcp.tools.arguments import ( - normalize_raw_endpoint, - raw_is_known_api_path, - raw_request_is_write, -) -from aegis_gitea_mcp.tools.raw_tools import raw_api_request_tool - - -@pytest.fixture -def raw_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - """API-key-mode settings with default policy (read allow, write deny).""" - reset_settings() - monkeypatch.setenv("GITEA_URL", "https://gitea.example.com") - monkeypatch.setenv("GITEA_TOKEN", "test-token") - monkeypatch.setenv("MCP_API_KEYS", "a" * 64) - monkeypatch.setenv("ENVIRONMENT", "test") - monkeypatch.setenv("POLICY_FILE_PATH", str(tmp_path / "missing-policy.yaml")) - - -class StubRawGitea: - """Stub Gitea client capturing raw_request calls.""" - - def __init__(self, response: Any = None) -> None: - self._response: Any = {"ok": True} if response is None else response - self.calls: list[dict[str, Any]] = [] - - async def raw_request( - self, - method: str, - endpoint: str, - *, - params: dict[str, Any] | None = None, - json_body: dict[str, Any] | None = None, - ) -> Any: - self.calls.append({"method": method, "endpoint": endpoint}) - return self._response - - -# --- Pure classifier -------------------------------------------------------- - - -@pytest.mark.parametrize( - ("method", "path", "expected_write"), - [ - ("GET", "/repos/o/r/issues", False), - ("HEAD", "/repos/o/r", False), - ("POST", "/repos/o/r/issues", True), - ("PUT", "/repos/o/r/pulls/1/merge", True), - ("PATCH", "/repos/o/r/issues/1", True), - ("DELETE", "/repos/o/r/issues/1", True), - # Render-only overrides are reads even though they are POSTs. - ("POST", "/markdown", False), - ("POST", "/markdown/raw", False), - ("POST", "/repos/o/r/markup", False), - ], -) -def test_raw_request_is_write(method: str, path: str, expected_write: bool) -> None: - endpoint = normalize_raw_endpoint(path) - assert raw_request_is_write(method, endpoint) is expected_write - - -def test_override_never_upgrades_a_mutating_post() -> None: - """A normal mutating POST is never reclassified as a read.""" - endpoint = normalize_raw_endpoint("/repos/o/r/issues") - assert raw_request_is_write("POST", endpoint) is True - - -@pytest.mark.parametrize( - ("path", "known"), - [ - ("/repos/o/r", True), - ("/orgs/acme/repos", True), - ("/admin/users", True), - ("/user/repos", True), - ("/markdown", True), - ("/version", True), - ("/definitely/not/a/real/prefix", False), - ("/wibble", False), - ], -) -def test_raw_is_known_api_path(path: str, known: bool) -> None: - assert raw_is_known_api_path(normalize_raw_endpoint(path)) is known - - -# --- Handler: unknown path is denied before any network call ---------------- - - -async def test_unknown_prefix_denied_before_network(raw_env: None) -> None: - stub = StubRawGitea() - with pytest.raises(ToolError) as exc_info: - await raw_api_request_tool(stub, {"method": "GET", "path": "/wibble/wobble"}) - assert exc_info.value.status_code == 403 - assert "known Gitea API route prefix" in str(exc_info.value.detail) - assert stub.calls == [] - - -# --- Write-mode bypass: a write that "looks like a read" is still a write ---- - - -async def test_write_method_denied_with_write_mode_off_even_on_readish_path( - raw_env: None, -) -> None: - """A POST to a known repo path is a write and is denied while write-mode is off.""" - stub = StubRawGitea() - with pytest.raises(ToolError) as exc_info: - await raw_api_request_tool(stub, {"method": "POST", "path": "/repos/acme/app/issues"}) - assert exc_info.value.status_code == 403 - assert "write mode is disabled" in str(exc_info.value.detail) - assert stub.calls == [] - - -async def test_render_only_post_allowed_as_read_without_write_mode(raw_env: None) -> None: - """A markdown-render POST is classified read and proceeds with write-mode off.""" - stub = StubRawGitea({"rendered": "

hi

"}) - result = await raw_api_request_tool(stub, {"method": "POST", "path": "/markdown"}) - assert result["write"] is False - assert stub.calls and stub.calls[0]["endpoint"] == "/api/v1/markdown" diff --git a/tests/test_core_boundary.py b/tests/test_core_boundary.py deleted file mode 100644 index 14d73a6..0000000 --- a/tests/test_core_boundary.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Lock the transport-agnostic core boundary. - -The core (tool registry, Gitea client, policy, audit, config, request context, -tools) must import cleanly without dragging in the web stack. If a stray -``import fastapi`` creeps back into a core module, the local stdio package would -gain a needless heavy dependency and the ``[server]`` extra split would leak. - -The check runs in a subprocess because, within the pytest process, FastAPI is -already imported by the server tests — so ``'fastapi' in sys.modules`` would be -true regardless. A clean interpreter is the only reliable probe. -""" - -from __future__ import annotations - -import os -import subprocess -import sys -from pathlib import Path - -_SRC = Path(__file__).resolve().parents[1] / "src" - -# Core modules that must stay free of the web stack. -_CORE_MODULES = [ - "aegis_gitea_mcp.registry", - "aegis_gitea_mcp.gitea_client", - "aegis_gitea_mcp.policy", - "aegis_gitea_mcp.audit", - "aegis_gitea_mcp.config", - "aegis_gitea_mcp.request_context", - "aegis_gitea_mcp.response_limits", - "aegis_gitea_mcp.security", - "aegis_gitea_mcp.cache", - "aegis_gitea_mcp.logging_utils", - "aegis_gitea_mcp.mcp_protocol", - "aegis_gitea_mcp.errors", - "aegis_gitea_mcp.tools.raw_tools", - "aegis_gitea_mcp.tools.read_tools", - "aegis_gitea_mcp.tools.write_tools", - "aegis_gitea_mcp.tools.repository", - "aegis_gitea_mcp.tools.arguments", -] - - -def test_core_does_not_import_fastapi() -> None: - """Importing the core in a clean interpreter must not import FastAPI.""" - imports = "\n".join(f"import {module}" for module in _CORE_MODULES) - program = ( - f"import sys\n{imports}\n" - "leaked = [m for m in ('fastapi', 'uvicorn', 'starlette') if m in sys.modules]\n" - "assert not leaked, f'core leaked web stack: {leaked}'\n" - "print('ok')\n" - ) - env = dict(os.environ) - env["PYTHONPATH"] = str(_SRC) - result = subprocess.run( - [sys.executable, "-c", program], - env=env, - capture_output=True, - text=True, - ) - detail = f"core import boundary violated.\nstdout: {result.stdout}\nstderr: {result.stderr}" - assert result.returncode == 0, detail - assert "ok" in result.stdout diff --git a/tests/test_gitea_client.py b/tests/test_gitea_client.py deleted file mode 100644 index 279a20a..0000000 --- a/tests/test_gitea_client.py +++ /dev/null @@ -1,413 +0,0 @@ -"""Unit tests for Gitea client request behavior.""" - -from __future__ import annotations - -from unittest.mock import AsyncMock, patch - -import pytest -from httpx import Client, Request, Response -from pydantic import ValidationError - -from aegis_gitea_mcp.config import reset_settings -from aegis_gitea_mcp.gitea_client import ( - GiteaAuthenticationError, - GiteaAuthorizationError, - GiteaClient, - GiteaError, - GiteaNotFoundError, -) -from aegis_gitea_mcp.tools.arguments import ( - CommitDiffArgs, - CompareRefsArgs, - FileTreeArgs, -) - - -@pytest.fixture(autouse=True) -def gitea_env(monkeypatch: pytest.MonkeyPatch) -> None: - """Provide minimal environment for client initialization.""" - reset_settings() - monkeypatch.setenv("GITEA_URL", "https://gitea.example.com") - monkeypatch.setenv("GITEA_TOKEN", "legacy-token") - monkeypatch.setenv("MCP_API_KEYS", "a" * 64) - monkeypatch.setenv("ENVIRONMENT", "test") - yield - reset_settings() - - -@pytest.mark.asyncio -async def test_client_context_uses_bearer_header() -> None: - """HTTP client is created with bearer token and closed on exit.""" - with patch("aegis_gitea_mcp.gitea_client.AsyncClient") as mock_async_client: - mock_instance = AsyncMock() - mock_async_client.return_value = mock_instance - - async with GiteaClient(token="user-oauth-token"): - pass - - _, kwargs = mock_async_client.call_args - assert kwargs["headers"]["Authorization"] == "Bearer user-oauth-token" - mock_instance.aclose.assert_awaited_once() - - -def test_client_requires_non_empty_token() -> None: - """Client construction fails when token is missing.""" - with pytest.raises(ValueError, match="non-empty"): - GiteaClient(token=" ") - - -def test_handle_response_maps_error_codes() -> None: - """HTTP status codes map to explicit domain exceptions.""" - client = GiteaClient(token="user-token") - request = Request("GET", "https://gitea.example.com/api/v1/user") - - with pytest.raises(GiteaAuthenticationError): - client._handle_response(Response(401, request=request), correlation_id="c1") - - with pytest.raises(GiteaAuthorizationError): - client._handle_response(Response(403, request=request), correlation_id="c2") - - with pytest.raises(GiteaNotFoundError): - client._handle_response(Response(404, request=request), correlation_id="c3") - - with pytest.raises(GiteaError, match="boom"): - client._handle_response( - Response(500, request=request, json={"message": "boom"}), - correlation_id="c4", - ) - - assert client._handle_response(Response(200, request=request, json={"ok": True}), "c5") == { - "ok": True - } - - -@pytest.mark.asyncio -async def test_public_methods_delegate_to_request_and_normalize() -> None: - """Wrapper methods call shared request logic and normalize return types.""" - client = GiteaClient(token="user-token") - - async def fake_request(method: str, endpoint: str, **kwargs): - if endpoint == "/api/v1/user": - return {"login": "alice"} - if endpoint == "/api/v1/user/repos": - return [{"name": "repo"}] - if endpoint == "/api/v1/repos/acme/demo": - return {"name": "demo"} - if endpoint == "/api/v1/repos/acme/demo/contents/README.md": - return {"size": 8, "content": "aGVsbG8=", "encoding": "base64"} - if endpoint == "/api/v1/repos/acme/demo/git/trees/main": - return {"tree": [{"path": "README.md"}]} - if endpoint == "/api/v1/repos/acme/demo/search": - return {"hits": []} - if endpoint == "/api/v1/repos/acme/demo/commits": - return [{"sha": "abc"}] - if endpoint == "/api/v1/repos/acme/demo/git/commits/abc": - return {"sha": "abc"} - if endpoint == "/api/v1/repos/acme/demo/compare/main...feature": - return {"total_commits": 1} - if endpoint == "/api/v1/repos/acme/demo/issues": - if method == "GET": - return [{"number": 1}] - return {"number": 12} - if endpoint == "/api/v1/repos/acme/demo/issues/1": - if method == "GET": - return {"number": 1} - return {"number": 1, "state": "closed"} - if endpoint == "/api/v1/repos/acme/demo/pulls": - return [{"number": 2}] - if endpoint == "/api/v1/repos/acme/demo/pulls/2": - return {"number": 2} - if endpoint == "/api/v1/repos/acme/demo/labels": - return [{"id": 1, "name": "bug"}] - if endpoint == "/api/v1/repos/acme/demo/tags": - return [{"name": "v1"}] - if endpoint == "/api/v1/repos/acme/demo/releases": - return [{"id": 1}] - if endpoint == "/api/v1/repos/acme/demo/issues/1/comments": - return {"id": 9} - if endpoint == "/api/v1/repos/acme/demo/issues/1/labels": - return {"labels": [{"name": "bug"}]} - if endpoint == "/api/v1/repos/acme/demo/issues/1/assignees": - return {"assignees": [{"login": "alice"}]} - return {} - - client._request = AsyncMock(side_effect=fake_request) # type: ignore[method-assign] - - assert (await client.get_current_user())["login"] == "alice" - assert len(await client.list_repositories()) == 1 - assert (await client.get_repository("acme", "demo"))["name"] == "demo" - assert (await client.get_file_contents("acme", "demo", "README.md"))["size"] == 8 - assert len((await client.get_tree("acme", "demo"))["tree"]) == 1 - assert isinstance( - await client.search_code("acme", "demo", "needle", ref="main", page=1, limit=5), dict - ) - assert len(await client.list_commits("acme", "demo", ref="main", page=1, limit=5)) == 1 - assert (await client.get_commit_diff("acme", "demo", "abc"))["sha"] == "abc" - assert isinstance(await client.compare_refs("acme", "demo", "main", "feature"), dict) - assert len(await client.list_issues("acme", "demo", state="open", page=1, limit=10)) == 1 - assert (await client.get_issue("acme", "demo", 1))["number"] == 1 - assert len(await client.list_pull_requests("acme", "demo", state="open", page=1, limit=10)) == 1 - assert (await client.get_pull_request("acme", "demo", 2))["number"] == 2 - assert len(await client.list_labels("acme", "demo", page=1, limit=10)) == 1 - assert len(await client.list_tags("acme", "demo", page=1, limit=10)) == 1 - assert len(await client.list_releases("acme", "demo", page=1, limit=10)) == 1 - assert (await client.create_issue("acme", "demo", title="Hi", body="Body"))["number"] == 12 - assert (await client.update_issue("acme", "demo", 1, state="closed"))["state"] == "closed" - assert (await client.create_issue_comment("acme", "demo", 1, "comment"))["id"] == 9 - assert (await client.create_pr_comment("acme", "demo", 1, "comment"))["id"] == 9 - assert isinstance(await client.add_labels("acme", "demo", 1, ["bug"]), dict) - assert isinstance(await client.assign_issue("acme", "demo", 1, ["alice"]), dict) - - -@pytest.mark.asyncio -async def test_get_file_contents_blocks_oversized_payload(monkeypatch: pytest.MonkeyPatch) -> None: - """File size limits are enforced before returning content.""" - monkeypatch.setenv("MAX_FILE_SIZE_BYTES", "5") - reset_settings() - client = GiteaClient(token="user-token") - - client._request = AsyncMock( # type: ignore[method-assign] - return_value={"size": 50, "content": "x", "encoding": "base64"} - ) - - with pytest.raises(GiteaError, match="exceeds limit"): - await client.get_file_contents("acme", "demo", "big.bin") - - -_MALICIOUS_REFS = [ - "../../../x/y", - "..", - "/etc/passwd", - "a\x00b", - "a?b", - "a#b", -] - - -@pytest.mark.parametrize("value", _MALICIOUS_REFS) -def test_file_tree_args_reject_traversal_ref(value: str) -> None: - """Layer 1: FileTreeArgs.ref rejects traversal/unsafe values.""" - with pytest.raises(ValidationError): - FileTreeArgs(owner="o", repo="r", ref=value) - - -@pytest.mark.parametrize("value", _MALICIOUS_REFS) -def test_commit_diff_args_reject_traversal_sha(value: str) -> None: - """Layer 1: CommitDiffArgs.sha rejects traversal/unsafe values.""" - # ".." is shorter than the 7-char min_length; still rejected (length or ref check). - with pytest.raises(ValidationError): - CommitDiffArgs(owner="o", repo="r", sha=value) - - -@pytest.mark.parametrize("value", _MALICIOUS_REFS) -def test_compare_refs_args_reject_traversal_base(value: str) -> None: - """Layer 1: CompareRefsArgs.base rejects traversal/unsafe values.""" - with pytest.raises(ValidationError): - CompareRefsArgs(owner="o", repo="r", base=value, head="main") - - -@pytest.mark.parametrize("value", _MALICIOUS_REFS) -def test_compare_refs_args_reject_traversal_head(value: str) -> None: - """Layer 1: CompareRefsArgs.head rejects traversal/unsafe values.""" - with pytest.raises(ValidationError): - CompareRefsArgs(owner="o", repo="r", base="main", head=value) - - -@pytest.mark.asyncio -async def test_list_user_repositories_scopes_by_uid() -> None: - """User-scoped listing resolves the uid and filters repo search by it.""" - client = GiteaClient(token="service-pat") - captured: dict = {} - - async def fake_request(method: str, endpoint: str, **kwargs): - if endpoint == "/api/v1/users/alice": - return {"id": 7, "login": "alice"} - if endpoint == "/api/v1/repos/search": - captured["params"] = kwargs.get("params") - return {"ok": True, "data": [{"full_name": "alice/demo"}, "not-a-dict"]} - return {} - - client._request = AsyncMock(side_effect=fake_request) # type: ignore[method-assign] - - repos = await client.list_user_repositories("alice") - assert captured["params"]["uid"] == 7 - assert repos == [{"full_name": "alice/demo"}] - - -@pytest.mark.asyncio -async def test_list_user_repositories_unknown_user_returns_empty() -> None: - """A user that cannot be resolved yields an empty list, not an error.""" - client = GiteaClient(token="service-pat") - - async def fake_request(method: str, endpoint: str, **kwargs): - return {} # no id field - - client._request = AsyncMock(side_effect=fake_request) # type: ignore[method-assign] - assert await client.list_user_repositories("ghost") == [] - - -@pytest.mark.asyncio -async def test_resolve_label_ids_maps_names_case_insensitively() -> None: - """Label names are resolved to ids regardless of case.""" - client = GiteaClient(token="user-token") - - async def fake_request(method: str, endpoint: str, **kwargs): - return [{"id": 3, "name": "Bug"}, {"id": 9, "name": "wontfix"}] - - client._request = AsyncMock(side_effect=fake_request) # type: ignore[method-assign] - ids = await client._resolve_label_ids("o", "r", ["bug", "WONTFIX"], correlation_id="c") - assert ids == [3, 9] - - -@pytest.mark.asyncio -async def test_resolve_label_ids_rejects_unknown_label() -> None: - """An unknown label name raises a clear error instead of a silent failure.""" - client = GiteaClient(token="user-token") - - async def fake_request(method: str, endpoint: str, **kwargs): - return [{"id": 3, "name": "bug"}] - - client._request = AsyncMock(side_effect=fake_request) # type: ignore[method-assign] - with pytest.raises(GiteaError, match="Unknown label"): - await client._resolve_label_ids("o", "r", ["ghost"], correlation_id="c") - - -@pytest.mark.asyncio -async def test_resolve_milestone_id_passes_through_integer() -> None: - """An integer milestone reference is used as a Gitea milestone id as-is.""" - client = GiteaClient(token="user-token") - client._request = AsyncMock() # type: ignore[method-assign] - assert await client._resolve_milestone_id("o", "r", 7, correlation_id="c") == 7 - # Integer ids must not trigger a milestone lookup. - client._request.assert_not_called() - - -@pytest.mark.asyncio -async def test_resolve_milestone_id_maps_title_case_insensitively() -> None: - """A milestone title is resolved to its id regardless of case.""" - client = GiteaClient(token="user-token") - - async def fake_request(method: str, endpoint: str, **kwargs): - return [{"id": 11, "title": "Sprint 1"}, {"id": 12, "title": "Backlog"}] - - client._request = AsyncMock(side_effect=fake_request) # type: ignore[method-assign] - resolved = await client._resolve_milestone_id("o", "r", "sprint 1", correlation_id="c") - assert resolved == 11 - - -@pytest.mark.asyncio -async def test_resolve_milestone_id_rejects_unknown_title() -> None: - """An unknown milestone title raises a clear error.""" - client = GiteaClient(token="user-token") - - async def fake_request(method: str, endpoint: str, **kwargs): - return [{"id": 11, "title": "Sprint 1"}] - - client._request = AsyncMock(side_effect=fake_request) # type: ignore[method-assign] - with pytest.raises(GiteaError, match="Unknown milestone"): - await client._resolve_milestone_id("o", "r", "Sprint 2", correlation_id="c") - - -@pytest.mark.asyncio -async def test_create_issue_resolves_milestone_title() -> None: - """create_issue resolves a milestone title to an id in the POST payload.""" - client = GiteaClient(token="user-token") - captured: dict = {} - - async def fake_request(method: str, endpoint: str, **kwargs): - if endpoint.endswith("/milestones") and method == "GET": - return [{"id": 11, "title": "Sprint 1"}] - if endpoint.endswith("/issues") and method == "POST": - captured["payload"] = kwargs.get("json_body") - return {"number": 1, "title": "Issue", "state": "open"} - return {} - - client._request = AsyncMock(side_effect=fake_request) # type: ignore[method-assign] - await client.create_issue("o", "r", title="Issue", body="", milestone="Sprint 1") - assert captured["payload"]["milestone"] == 11 - - -@pytest.mark.asyncio -async def test_update_issue_clears_milestone_with_zero() -> None: - """update_issue forwards milestone id 0 verbatim to clear the milestone.""" - client = GiteaClient(token="user-token") - captured: dict = {} - - async def fake_request(method: str, endpoint: str, **kwargs): - captured["payload"] = kwargs.get("json_body") - return {"number": 1, "title": "Issue", "state": "open"} - - client._request = AsyncMock(side_effect=fake_request) # type: ignore[method-assign] - await client.update_issue("o", "r", 1, milestone=0) - assert captured["payload"]["milestone"] == 0 - - -@pytest.mark.asyncio -async def test_add_labels_resolves_names_to_ids() -> None: - """add_labels translates names to ids before POSTing to Gitea.""" - client = GiteaClient(token="user-token") - captured: dict = {} - - async def fake_request(method: str, endpoint: str, **kwargs): - if endpoint.endswith("/labels") and method == "GET": - return [{"id": 42, "name": "bug"}] - if endpoint.endswith("/issues/1/labels") and method == "POST": - captured["body"] = kwargs.get("json_body") - return {"labels": [{"name": "bug"}]} - return {} - - client._request = AsyncMock(side_effect=fake_request) # type: ignore[method-assign] - await client.add_labels("o", "r", 1, ["bug"]) - assert captured["body"] == {"labels": [42]} - - -def test_git_refs_allow_slash_containing_refs() -> None: - """Legitimate refs that contain '/' validate successfully.""" - tree = FileTreeArgs(owner="o", repo="r", ref="feature/foo") - assert tree.ref == "feature/foo" - - compare = CompareRefsArgs(owner="o", repo="r", base="release/1.0", head="main") - assert compare.base == "release/1.0" - assert compare.head == "main" - - -def test_git_ref_length_bounds_still_enforced() -> None: - """Field min/max length still applies alongside the AfterValidator.""" - with pytest.raises(ValidationError): - FileTreeArgs(owner="o", repo="r", ref="") - with pytest.raises(ValidationError): - FileTreeArgs(owner="o", repo="r", ref="a" * 201) - with pytest.raises(ValidationError): - # sha below 7-char minimum - CommitDiffArgs(owner="o", repo="r", sha="abc") - - -def test_layer2_encoding_confines_unsafe_chars_to_path_segment() -> None: - """Layer 2 defense-in-depth: quote(..., safe='/') confines unsafe chars. - - A sha containing ``?``, ``#``, whitespace, or a backslash that hypothetically - bypassed Layer 1 is percent-encoded so it cannot split off a query string, - fragment, or extra path component -- it stays inside the single ref segment - under the declared ``owner/repo``. (Note: ``..`` collapse is closed by Layer 1 - validation, since ``quote`` intentionally leaves ``.`` and ``/`` literal to - preserve refs like ``feature/foo``.) - """ - from urllib.parse import quote - - owner, repo = "alice", "repoA" - prefix = f"/api/v1/repos/{owner}/{repo}/git/commits/" - - for malicious_sha in ["abc?injected=1", "abc#frag", "abc def", "abc\\..\\evil"]: - endpoint = ( - f"/api/v1/repos/{quote(owner, safe='')}/{quote(repo, safe='')}" - f"/git/commits/{quote(malicious_sha, safe='/')}" - ) - with Client(base_url="https://gitea.example.com") as http_client: - request = http_client.build_request("GET", endpoint) - - # Stays scoped to declared repo, no query/fragment broke out. - assert request.url.path.startswith(prefix) - assert request.url.query == b"" - assert request.url.fragment == "" - # Exactly one ref segment after the prefix (no path splitting). - assert "/" not in request.url.path[len(prefix) :] diff --git a/tests/test_integration.py b/tests/test_integration.py deleted file mode 100644 index 4938353..0000000 --- a/tests/test_integration.py +++ /dev/null @@ -1,121 +0,0 @@ -"""Integration tests for end-to-end MCP authentication behavior.""" - -from __future__ import annotations - -from unittest.mock import patch - -import pytest -from fastapi.testclient import TestClient - -from aegis_gitea_mcp.config import reset_settings -from aegis_gitea_mcp.oauth import reset_oauth_validator - - -@pytest.fixture(autouse=True) -def reset_state() -> None: - """Reset global state between tests.""" - reset_settings() - reset_oauth_validator() - yield - reset_settings() - reset_oauth_validator() - - -@pytest.fixture -def full_env(monkeypatch: pytest.MonkeyPatch) -> None: - """Set OAuth-enabled environment for integration tests.""" - monkeypatch.setenv("GITEA_URL", "https://gitea.example.com") - monkeypatch.setenv("OAUTH_MODE", "true") - monkeypatch.setenv("GITEA_OAUTH_CLIENT_ID", "test-client-id") - monkeypatch.setenv("GITEA_OAUTH_CLIENT_SECRET", "test-client-secret") - monkeypatch.setenv("OAUTH_STATE_SECRET", "test-state-secret-0123456789abcdef") - monkeypatch.setenv("ENVIRONMENT", "test") - monkeypatch.setenv("MCP_HOST", "127.0.0.1") - monkeypatch.setenv("MCP_PORT", "8080") - monkeypatch.setenv("LOG_LEVEL", "INFO") - monkeypatch.setenv("STARTUP_VALIDATE_GITEA", "false") - - -@pytest.fixture -def client(full_env: None, monkeypatch: pytest.MonkeyPatch) -> TestClient: - """Create test client with deterministic OAuth behavior.""" - - async def _validate(_self, token: str | None, _ip: str, _ua: str): - if token == "valid-read-token": - return True, None, {"login": "alice", "scopes": ["read:repository"]} - return False, "Invalid or expired OAuth token.", None - - monkeypatch.setattr( - "aegis_gitea_mcp.oauth.GiteaOAuthValidator.validate_oauth_token", - _validate, - ) - - from aegis_gitea_mcp.server import app - - return TestClient(app) - - -def test_no_token_returns_401_with_www_authenticate(client: TestClient) -> None: - """Missing bearer token is rejected with OAuth challenge metadata.""" - response = client.post( - "/mcp/tool/call", - json={"tool": "list_repositories", "arguments": {}}, - ) - - assert response.status_code == 401 - assert "WWW-Authenticate" in response.headers - assert "resource_metadata=" in response.headers["WWW-Authenticate"] - - -def test_invalid_token_returns_401(client: TestClient) -> None: - """Invalid OAuth token is rejected.""" - response = client.post( - "/mcp/tool/call", - headers={"Authorization": "Bearer invalid-token"}, - json={"tool": "list_repositories", "arguments": {}}, - ) - - assert response.status_code == 401 - - -def test_valid_token_executes_tool(client: TestClient) -> None: - """Valid OAuth token allows tool execution.""" - with patch("aegis_gitea_mcp.gitea_client.GiteaClient.list_repositories") as mock_list_repos: - mock_list_repos.return_value = [{"id": 1, "name": "repo-one", "owner": {"login": "alice"}}] - - response = client.post( - "/mcp/tool/call", - headers={"Authorization": "Bearer valid-read-token"}, - json={"tool": "list_repositories", "arguments": {}}, - ) - - assert response.status_code == 200 - payload = response.json() - assert payload["success"] is True - assert "result" in payload - - -def test_write_scope_enforcement_returns_403(client: TestClient) -> None: - """Write tool calls are denied when token lacks write scope.""" - response = client.post( - "/mcp/tool/call", - headers={"Authorization": "Bearer valid-read-token"}, - json={ - "tool": "create_issue", - "arguments": {"owner": "acme", "repo": "demo", "title": "Needs write scope"}, - }, - ) - - assert response.status_code == 403 - assert "required scope: write:repository" in response.json()["detail"].lower() - - -def test_error_responses_include_helpful_messages(client: TestClient) -> None: - """Auth failures include actionable guidance.""" - response = client.post( - "/mcp/tool/call", - json={"tool": "list_repositories", "arguments": {}}, - ) - assert response.status_code == 401 - data = response.json() - assert "Provide Authorization" in data["message"] diff --git a/tests/test_oauth.py b/tests/test_oauth.py deleted file mode 100644 index 4c46a50..0000000 --- a/tests/test_oauth.py +++ /dev/null @@ -1,640 +0,0 @@ -"""Tests for OAuth2 per-user Gitea authentication.""" - -from __future__ import annotations - -import asyncio -import os -import stat -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -from fastapi.testclient import TestClient - -from aegis_gitea_mcp.config import reset_settings -from aegis_gitea_mcp.oauth import GiteaOAuthValidator, get_oauth_validator, reset_oauth_validator -from aegis_gitea_mcp.oauth_flow import OAuthClientRegistry, OAuthRegistrationRequest -from aegis_gitea_mcp.request_context import ( - get_gitea_user_login, - get_gitea_user_token, - set_gitea_user_login, - set_gitea_user_token, -) - -# --------------------------------------------------------------------------- -# Fixtures -# --------------------------------------------------------------------------- - - -@pytest.fixture(autouse=True) -def reset_state(): - """Reset global state between tests.""" - reset_settings() - reset_oauth_validator() - yield - reset_settings() - reset_oauth_validator() - - -@pytest.fixture -def mock_env_oauth(monkeypatch): - """Environment for OAuth mode tests.""" - monkeypatch.setenv("GITEA_URL", "https://gitea.example.com") - monkeypatch.setenv("ENVIRONMENT", "test") - monkeypatch.setenv("OAUTH_MODE", "true") - monkeypatch.setenv("GITEA_OAUTH_CLIENT_ID", "test-client-id") - monkeypatch.setenv("GITEA_OAUTH_CLIENT_SECRET", "test-client-secret") - monkeypatch.setenv("OAUTH_STATE_SECRET", "test-state-secret-0123456789abcdef") - monkeypatch.setenv("STARTUP_VALIDATE_GITEA", "false") - - -@pytest.fixture -def oauth_validator(mock_env_oauth): - """Create GiteaOAuthValidator instance in OAuth mode.""" - return GiteaOAuthValidator() - - -@pytest.fixture -def oauth_client(mock_env_oauth): - """Create FastAPI test client in OAuth mode.""" - from aegis_gitea_mcp.server import app - - return TestClient(app, raise_server_exceptions=False) - - -def _register_public_client(oauth_client: TestClient, redirect_uri: str) -> dict[str, str]: - """Register a public OAuth client for test flows.""" - response = oauth_client.post( - "/register", - json={ - "client_name": "pytest-client", - "redirect_uris": [redirect_uri], - "token_endpoint_auth_method": "none", - "grant_types": ["authorization_code", "refresh_token"], - "response_types": ["code"], - }, - ) - assert response.status_code == 200 - payload = response.json() - assert "client_id" in payload - return payload - - -# --------------------------------------------------------------------------- -# GiteaOAuthValidator unit tests -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_validate_oauth_token_success(oauth_validator): - """Valid Gitea OAuth token returns is_valid=True and user_data.""" - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = {"login": "testuser", "id": 42} - - with patch("aegis_gitea_mcp.oauth.httpx.AsyncClient") as mock_client_cls: - mock_client = AsyncMock() - mock_client.get = AsyncMock(return_value=mock_response) - mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client) - mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False) - - is_valid, error, user_data = await oauth_validator.validate_oauth_token( - "valid-gitea-token", "127.0.0.1", "TestAgent/1.0" - ) - - assert is_valid is True - assert error is None - assert user_data is not None - assert user_data["login"] == "testuser" - - -@pytest.mark.asyncio -async def test_validate_oauth_token_invalid_401(oauth_validator): - """Gitea returning 401 results in is_valid=False.""" - mock_response = MagicMock() - mock_response.status_code = 401 - - with patch("aegis_gitea_mcp.oauth.httpx.AsyncClient") as mock_client_cls: - mock_client = AsyncMock() - mock_client.get = AsyncMock(return_value=mock_response) - mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client) - mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False) - - is_valid, error, user_data = await oauth_validator.validate_oauth_token( - "expired-token", "127.0.0.1", "TestAgent/1.0" - ) - - assert is_valid is False - assert error is not None - assert user_data is None - - -@pytest.mark.asyncio -async def test_validate_oauth_token_missing_token(oauth_validator): - """Missing token results in is_valid=False.""" - is_valid, error, user_data = await oauth_validator.validate_oauth_token( - None, "127.0.0.1", "TestAgent/1.0" - ) - - assert is_valid is False - assert error is not None - assert user_data is None - - -@pytest.mark.asyncio -async def test_validate_oauth_token_network_error(oauth_validator): - """Network error results in is_valid=False with informative message.""" - import httpx - - with patch("aegis_gitea_mcp.oauth.httpx.AsyncClient") as mock_client_cls: - mock_client = AsyncMock() - mock_client.get = AsyncMock( - side_effect=httpx.RequestError("Connection refused", request=MagicMock()) - ) - mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client) - mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False) - - is_valid, error, user_data = await oauth_validator.validate_oauth_token( - "some-token", "127.0.0.1", "TestAgent/1.0" - ) - - assert is_valid is False - assert error is not None - assert "unable to validate oauth token" in error.lower() - assert user_data is None - - -@pytest.mark.asyncio -async def test_validate_oauth_token_rate_limit(oauth_validator): - """Exceeding failure threshold triggers rate limiting.""" - mock_response = MagicMock() - mock_response.status_code = 401 - - with patch("aegis_gitea_mcp.oauth.httpx.AsyncClient") as mock_client_cls: - mock_client = AsyncMock() - mock_client.get = AsyncMock(return_value=mock_response) - mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client) - mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False) - - # Exhaust failures (default MAX_AUTH_FAILURES=5) - for _ in range(5): - await oauth_validator.validate_oauth_token("bad-token", "10.0.0.1", "Agent") - - # Next attempt should be rate-limited - is_valid, error, user_data = await oauth_validator.validate_oauth_token( - "bad-token", "10.0.0.1", "Agent" - ) - - assert is_valid is False - assert error is not None - assert "too many" in error.lower() - - -# --------------------------------------------------------------------------- -# Singleton tests -# --------------------------------------------------------------------------- - - -def test_get_oauth_validator_singleton(mock_env_oauth): - """get_oauth_validator returns the same instance on repeated calls.""" - v1 = get_oauth_validator() - v2 = get_oauth_validator() - assert v1 is v2 - - -def test_reset_oauth_validator(mock_env_oauth): - """reset_oauth_validator creates a fresh instance after reset.""" - v1 = get_oauth_validator() - reset_oauth_validator() - v2 = get_oauth_validator() - assert v1 is not v2 - - -# --------------------------------------------------------------------------- -# ContextVar isolation tests -# --------------------------------------------------------------------------- - - -def test_context_var_token_isolation(): - """ContextVar values do not leak between coroutines.""" - results = {} - - async def task_a(): - set_gitea_user_token("token-for-a") - await asyncio.sleep(0) - results["a"] = get_gitea_user_token() - - async def task_b(): - # task_b never sets the token; should see None (default) - await asyncio.sleep(0) - results["b"] = get_gitea_user_token() - - async def run(): - await asyncio.gather(task_a(), task_b()) - - asyncio.run(run()) - - assert results["a"] == "token-for-a" - assert results["b"] is None # ContextVar isolation: task_b sees default - - -def test_context_var_login_set_and_get(): - """set_gitea_user_login / get_gitea_user_login work correctly.""" - set_gitea_user_login("alice") - assert get_gitea_user_login() == "alice" - - -# --------------------------------------------------------------------------- -# /oauth/token proxy endpoint tests -# --------------------------------------------------------------------------- - - -def test_oauth_token_endpoint_available_when_oauth_mode_false(monkeypatch): - """POST /oauth/token remains available regardless of OAUTH_MODE.""" - monkeypatch.setenv("GITEA_URL", "https://gitea.example.com") - monkeypatch.setenv("GITEA_TOKEN", "test-token-12345") - monkeypatch.setenv("ENVIRONMENT", "test") - monkeypatch.setenv("MCP_API_KEYS", "a" * 64) - monkeypatch.setenv("OAUTH_MODE", "false") - monkeypatch.setenv("STARTUP_VALIDATE_GITEA", "false") - - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = {"access_token": "token"} - - from aegis_gitea_mcp.server import app - - with patch("aegis_gitea_mcp.server.httpx.AsyncClient") as mock_client_cls: - mock_client = AsyncMock() - mock_client.post = AsyncMock(return_value=mock_response) - mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client) - mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False) - - with TestClient(app, raise_server_exceptions=False) as client: - registration = client.post( - "/register", - json={ - "client_name": "pytest-client", - "redirect_uris": ["http://127.0.0.1:8080/callback"], - "token_endpoint_auth_method": "none", - "grant_types": ["authorization_code"], - "response_types": ["code"], - }, - ) - assert registration.status_code == 200 - client_id = registration.json()["client_id"] - response = client.post( - "/oauth/token", - data={"client_id": client_id, "code": "abc123", "code_verifier": "pkce"}, - ) - - assert response.status_code == 200 - - -def test_oauth_token_endpoint_missing_code(oauth_client): - """POST /oauth/token without a code returns 400.""" - client_data = _register_public_client(oauth_client, "http://127.0.0.1:8080/callback") - response = oauth_client.post( - "/oauth/token", - data={"client_id": client_data["client_id"], "code_verifier": "pkce"}, - ) - assert response.status_code == 400 - - -def test_oauth_token_endpoint_proxy_success(oauth_client): - """POST /oauth/token proxies successfully to Gitea and returns access_token.""" - client_data = _register_public_client(oauth_client, "http://127.0.0.1:8080/callback") - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = { - "access_token": "gitea-access-token-xyz", - "token_type": "bearer", - } - - with patch("aegis_gitea_mcp.server.httpx.AsyncClient") as mock_client_cls: - mock_client = AsyncMock() - mock_client.post = AsyncMock(return_value=mock_response) - mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client) - mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False) - - response = oauth_client.post( - "/oauth/token", - data={ - "client_id": client_data["client_id"], - "code": "auth-code-123", - "code_verifier": "pkce-verifier", - }, - ) - - assert response.status_code == 200 - body = response.json() - assert body["access_token"] == "gitea-access-token-xyz" - - -def test_oauth_token_endpoint_gitea_error(oauth_client): - """POST /oauth/token propagates Gitea error status.""" - client_data = _register_public_client(oauth_client, "http://127.0.0.1:8080/callback") - mock_response = MagicMock() - mock_response.status_code = 400 - mock_response.json.return_value = {"error": "invalid_grant"} - - with patch("aegis_gitea_mcp.server.httpx.AsyncClient") as mock_client_cls: - mock_client = AsyncMock() - mock_client.post = AsyncMock(return_value=mock_response) - mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client) - mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False) - - response = oauth_client.post( - "/oauth/token", - data={ - "client_id": client_data["client_id"], - "code": "bad-code", - "code_verifier": "pkce-verifier", - }, - ) - - assert response.status_code == 400 - - -def test_oauth_authorize_and_callback_round_trip(oauth_client): - """OAuth authorize/callback round-trip preserves the original redirect URI and state.""" - client_data = _register_public_client(oauth_client, "http://127.0.0.1:8080/callback") - - authorize_response = oauth_client.get( - "/oauth/authorize", - params={ - "client_id": client_data["client_id"], - "redirect_uri": "http://127.0.0.1:8080/callback", - "state": "original-state", - "code_challenge": "pkce-challenge", - "code_challenge_method": "S256", - }, - follow_redirects=False, - ) - - assert authorize_response.status_code == 302 - location = authorize_response.headers["location"] - assert "state=" in location - assert "redirect_uri=http%3A%2F%2F127.0.0.1%3A8080%2Fcallback" not in location - - from urllib.parse import parse_qs, urlparse - - parsed = urlparse(location) - query = parse_qs(parsed.query) - proxy_state = query["state"][0] - - callback_response = oauth_client.get( - "/oauth/callback", - params={"state": proxy_state, "code": "auth-code-123"}, - follow_redirects=False, - ) - - assert callback_response.status_code == 302 - callback_location = callback_response.headers["location"] - assert callback_location.startswith("http://127.0.0.1:8080/callback?") - assert "code=auth-code-123" in callback_location - assert "state=original-state" in callback_location - - -def test_oauth_callback_rejects_tampered_state(oauth_client): - """OAuth callback rejects modified signed proxy state.""" - client_data = _register_public_client(oauth_client, "http://127.0.0.1:8080/callback") - authorize_response = oauth_client.get( - "/oauth/authorize", - params={ - "client_id": client_data["client_id"], - "redirect_uri": "http://127.0.0.1:8080/callback", - "state": "original-state", - "code_challenge": "pkce-challenge", - "code_challenge_method": "S256", - }, - follow_redirects=False, - ) - from urllib.parse import parse_qs, urlparse - - proxy_state = parse_qs(urlparse(authorize_response.headers["location"]).query)["state"][0] - tampered_state = proxy_state[:-1] + ("A" if proxy_state[-1] != "A" else "B") - - callback_response = oauth_client.get( - "/oauth/callback", - params={"state": tampered_state, "code": "auth-code-123"}, - ) - - assert callback_response.status_code == 400 - - -@pytest.mark.parametrize( - "redirect_uri", - [ - "https://claude.ai/api/mcp/auth_callback", - "https://claude.com/api/mcp/auth_callback", - ], -) -def test_dcr_accepts_default_claude_callbacks(oauth_client, redirect_uri): - """Claude's hosted connector callback URLs are allowed by default.""" - response = oauth_client.post( - "/register", - json={ - "client_name": "claude-client", - "redirect_uris": [redirect_uri], - "token_endpoint_auth_method": "none", - "grant_types": ["authorization_code", "refresh_token"], - "response_types": ["code"], - }, - ) - - assert response.status_code == 200 - - -def test_oauth_authorize_rejects_unknown_client(oauth_client): - """OAuth authorize returns invalid_client for unregistered client IDs.""" - response = oauth_client.get( - "/oauth/authorize", - params={ - "client_id": "unknown-client", - "redirect_uri": "http://127.0.0.1:8080/callback", - "state": "x", - "code_challenge": "pkce-challenge", - "code_challenge_method": "S256", - }, - ) - - assert response.status_code == 401 - assert response.json()["detail"] == "invalid_client" - - -def test_oauth_token_rejects_unknown_dcr_client(oauth_client): - """Unknown dynamic clients receive RFC 6749 invalid_client from token endpoint.""" - response = oauth_client.post( - "/oauth/token", - data={ - "client_id": "deleted-or-unknown-client", - "code": "auth-code-123", - "code_verifier": "pkce-verifier", - }, - ) - - assert response.status_code == 401 - assert response.json() == {"error": "invalid_client"} - - -def test_oauth_authorize_requires_pkce_s256(oauth_client): - """Authorization endpoint enforces PKCE S256 for public clients.""" - client_data = _register_public_client(oauth_client, "http://127.0.0.1:8080/callback") - missing_challenge = oauth_client.get( - "/oauth/authorize", - params={ - "client_id": client_data["client_id"], - "redirect_uri": "http://127.0.0.1:8080/callback", - "state": "x", - }, - ) - plain_method = oauth_client.get( - "/oauth/authorize", - params={ - "client_id": client_data["client_id"], - "redirect_uri": "http://127.0.0.1:8080/callback", - "state": "x", - "code_challenge": "pkce-challenge", - "code_challenge_method": "plain", - }, - ) - - assert missing_challenge.status_code == 400 - assert plain_method.status_code == 400 - - -def test_register_rejects_foreign_redirect_uri(oauth_client): - """DCR rejects redirect URIs outside the allowlist and loopback/Claude patterns.""" - response = oauth_client.post( - "/register", - json={ - "client_name": "pytest-client", - "redirect_uris": ["https://evil.example.com/callback"], - "token_endpoint_auth_method": "none", - "grant_types": ["authorization_code"], - "response_types": ["code"], - }, - ) - - assert response.status_code == 400 - - -def test_dcr_registry_persists_registered_clients(tmp_path): - """Registered OAuth clients survive registry reloads.""" - storage_path = tmp_path / "dcr_clients.json" - registry = OAuthClientRegistry(storage_path) - request = OAuthRegistrationRequest.model_validate( - { - "client_name": "persisted-client", - "redirect_uris": ["http://127.0.0.1:8080/callback"], - "token_endpoint_auth_method": "none", - "grant_types": ["authorization_code"], - "response_types": ["code"], - } - ) - - response = registry.register(request) - reloaded = OAuthClientRegistry(storage_path) - - assert reloaded.get(response["client_id"]) is not None - - -@pytest.mark.skipif( - os.name != "posix", reason="POSIX permission bits are not enforced on this platform" -) -def test_dcr_storage_is_written_owner_only(tmp_path): - """The persisted DCR store must not be readable beyond its owner (0o600).""" - storage_path = tmp_path / "dcr_clients.json" - registry = OAuthClientRegistry(storage_path) - request = OAuthRegistrationRequest.model_validate( - { - "client_name": "perm-client", - "redirect_uris": ["http://127.0.0.1:8080/callback"], - "token_endpoint_auth_method": "none", - "grant_types": ["authorization_code"], - "response_types": ["code"], - } - ) - - registry.register(request) - - mode = stat.S_IMODE(os.stat(storage_path).st_mode) - assert mode == 0o600 - - -# --------------------------------------------------------------------------- -# Config validation tests -# --------------------------------------------------------------------------- - - -def test_config_oauth_mode_requires_client_id(monkeypatch): - """OAUTH_MODE=true without client_id raises ValueError.""" - monkeypatch.setenv("GITEA_URL", "https://gitea.example.com") - monkeypatch.setenv("OAUTH_MODE", "true") - monkeypatch.setenv("GITEA_OAUTH_CLIENT_ID", "") - monkeypatch.setenv("GITEA_OAUTH_CLIENT_SECRET", "some-secret") - - from aegis_gitea_mcp.config import Settings - - with pytest.raises(Exception, match="GITEA_OAUTH_CLIENT_ID"): - Settings() # type: ignore[call-arg] - - -def test_config_oauth_mode_requires_client_secret(monkeypatch): - """OAUTH_MODE=true without client_secret raises ValueError.""" - monkeypatch.setenv("GITEA_URL", "https://gitea.example.com") - monkeypatch.setenv("OAUTH_MODE", "true") - monkeypatch.setenv("GITEA_OAUTH_CLIENT_ID", "some-id") - monkeypatch.setenv("GITEA_OAUTH_CLIENT_SECRET", "") - - from aegis_gitea_mcp.config import Settings - - with pytest.raises(Exception, match="GITEA_OAUTH_CLIENT_SECRET"): - Settings() # type: ignore[call-arg] - - -def test_config_standard_mode_requires_gitea_token(monkeypatch): - """Standard mode without GITEA_TOKEN raises ValueError.""" - monkeypatch.setenv("GITEA_URL", "https://gitea.example.com") - monkeypatch.setenv("OAUTH_MODE", "false") - monkeypatch.setenv("GITEA_TOKEN", "") - monkeypatch.setenv("MCP_API_KEYS", "a" * 64) - - from aegis_gitea_mcp.config import Settings - - with pytest.raises(Exception, match="GITEA_TOKEN"): - Settings() # type: ignore[call-arg] - - -# --------------------------------------------------------------------------- -# Server middleware: OAuth mode authentication -# --------------------------------------------------------------------------- - - -def test_mcp_tool_call_requires_valid_gitea_token(oauth_client): - """POST /mcp/tool/call with an invalid Gitea token returns 401.""" - mock_response = MagicMock() - mock_response.status_code = 401 - - with patch("aegis_gitea_mcp.oauth.httpx.AsyncClient") as mock_client_cls: - mock_client = AsyncMock() - mock_client.get = AsyncMock(return_value=mock_response) - mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client) - mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False) - - response = oauth_client.post( - "/mcp/tool/call", - json={"tool": "list_repositories", "arguments": {}}, - headers={"Authorization": "Bearer invalid-token"}, - ) - - assert response.status_code == 401 - - -def test_mcp_tool_call_no_token_returns_401(oauth_client): - """POST /mcp/tool/call without Authorization header returns 401.""" - response = oauth_client.post( - "/mcp/tool/call", - json={"tool": "list_repositories", "arguments": {}}, - ) - assert response.status_code == 401 diff --git a/tests/test_oauth_oidc.py b/tests/test_oauth_oidc.py deleted file mode 100644 index 597c7b8..0000000 --- a/tests/test_oauth_oidc.py +++ /dev/null @@ -1,215 +0,0 @@ -"""OIDC/JWKS-focused OAuth validator tests.""" - -from __future__ import annotations - -import json -import time -from unittest.mock import AsyncMock, MagicMock, patch - -import jwt -import pytest -from cryptography.hazmat.primitives.asymmetric import rsa -from jwt.algorithms import RSAAlgorithm - -from aegis_gitea_mcp.config import reset_settings -from aegis_gitea_mcp.oauth import GiteaOAuthValidator, reset_oauth_validator - - -@pytest.fixture(autouse=True) -def reset_state(monkeypatch: pytest.MonkeyPatch) -> None: - """Reset state and configure OAuth validation environment.""" - reset_settings() - reset_oauth_validator() - monkeypatch.setenv("GITEA_URL", "https://gitea.example.com") - monkeypatch.setenv("ENVIRONMENT", "test") - monkeypatch.setenv("OAUTH_MODE", "true") - monkeypatch.setenv("GITEA_OAUTH_CLIENT_ID", "test-client-id") - monkeypatch.setenv("GITEA_OAUTH_CLIENT_SECRET", "test-client-secret") - monkeypatch.setenv("OAUTH_STATE_SECRET", "test-state-secret-0123456789abcdef") - monkeypatch.setenv("OAUTH_CACHE_TTL_SECONDS", "600") - yield - reset_settings() - reset_oauth_validator() - - -def _build_jwt_fixture(aud: str = "test-client-id") -> tuple[str, dict[str, object]]: - """Generate RS256 access token and matching JWKS payload.""" - private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) - public_key = private_key.public_key() - jwk = json.loads(RSAAlgorithm.to_jwk(public_key)) - jwk["kid"] = "kid-123" - - now = int(time.time()) - token = jwt.encode( - { - "sub": "user-1", - "preferred_username": "alice", - "scope": "read:repository write:repository", - "aud": aud, - "iss": "https://gitea.example.com", - "iat": now, - "exp": now + 3600, - }, - private_key, - algorithm="RS256", - headers={"kid": "kid-123"}, - ) - return token, {"keys": [jwk]} - - -async def _validate_with_jwks( - validator: GiteaOAuthValidator, token: str, jwks: dict[str, object] -) -> tuple[bool, str | None, dict[str, object] | None]: - """Drive a JWT validation with mocked discovery + JWKS responses.""" - discovery_response = MagicMock() - discovery_response.status_code = 200 - discovery_response.json.return_value = { - "issuer": "https://gitea.example.com", - "jwks_uri": "https://gitea.example.com/login/oauth/keys", - } - jwks_response = MagicMock() - jwks_response.status_code = 200 - jwks_response.json.return_value = jwks - - with patch("aegis_gitea_mcp.oauth.httpx.AsyncClient") as mock_client_cls: - mock_client = AsyncMock() - mock_client.get = AsyncMock(side_effect=[discovery_response, jwks_response]) - mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client) - mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False) - return await validator.validate_oauth_token(token, "127.0.0.1", "TestAgent") - - -def test_acceptable_audiences_includes_resource_and_client_id( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """The canonical MCP resource and the Gitea client id are accepted audiences.""" - monkeypatch.setenv("PUBLIC_BASE_URL", "https://mcp.example.com") - reset_settings() - reset_oauth_validator() - audiences = GiteaOAuthValidator()._acceptable_audiences() - assert "https://mcp.example.com" in audiences - assert "test-client-id" in audiences - - -@pytest.mark.asyncio -async def test_jwt_with_canonical_resource_audience_is_accepted( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """A token whose aud is the canonical MCP resource URL validates (P4).""" - monkeypatch.setenv("PUBLIC_BASE_URL", "https://mcp.example.com") - reset_settings() - reset_oauth_validator() - token, jwks = _build_jwt_fixture(aud="https://mcp.example.com") - valid, error, principal = await _validate_with_jwks(GiteaOAuthValidator(), token, jwks) - assert valid is True - assert error is None - assert principal is not None - - -@pytest.mark.asyncio -async def test_jwt_with_foreign_audience_is_rejected() -> None: - """A token minted for a different audience is rejected (audience binding).""" - token, jwks = _build_jwt_fixture(aud="some-other-service") - # Foreign-audience JWT fails JWT validation, then falls back to userinfo, which - # is not mocked here and raises a network error -> overall failure. - with patch("aegis_gitea_mcp.oauth.GiteaOAuthValidator._validate_userinfo") as mock_userinfo: - from aegis_gitea_mcp.oauth import OAuthTokenValidationError - - mock_userinfo.side_effect = OAuthTokenValidationError("Invalid", "userinfo_denied") - valid, _error, principal = await _validate_with_jwks(GiteaOAuthValidator(), token, jwks) - assert valid is False - assert principal is None - - -@pytest.mark.asyncio -async def test_validate_oauth_token_with_oidc_jwt_and_cache() -> None: - """JWT token validation uses discovery + JWKS and caches both documents.""" - token, jwks = _build_jwt_fixture() - validator = GiteaOAuthValidator() - - discovery_response = MagicMock() - discovery_response.status_code = 200 - discovery_response.json.return_value = { - "issuer": "https://gitea.example.com", - "jwks_uri": "https://gitea.example.com/login/oauth/keys", - } - - jwks_response = MagicMock() - jwks_response.status_code = 200 - jwks_response.json.return_value = jwks - - with patch("aegis_gitea_mcp.oauth.httpx.AsyncClient") as mock_client_cls: - mock_client = AsyncMock() - mock_client.get = AsyncMock(side_effect=[discovery_response, jwks_response]) - mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client) - mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False) - - valid1, error1, principal1 = await validator.validate_oauth_token( - token, "127.0.0.1", "TestAgent" - ) - valid2, error2, principal2 = await validator.validate_oauth_token( - token, "127.0.0.1", "TestAgent" - ) - - assert valid1 is True - assert error1 is None - assert principal1 is not None - assert principal1["login"] == "alice" - assert "write:repository" in principal1["scopes"] - - assert valid2 is True - assert error2 is None - assert principal2 is not None - # Discovery + JWKS fetched once each because of cache. - assert mock_client.get.await_count == 2 - - -@pytest.mark.asyncio -async def test_invalid_jwt_falls_back_and_fails_userinfo() -> None: - """Invalid JWT returns auth failure when userinfo fallback rejects token.""" - validator = GiteaOAuthValidator() - - # JWT-shaped token with invalid signature/header. - bad_token = "abc.def.ghi" - - discovery_response = MagicMock() - discovery_response.status_code = 200 - discovery_response.json.return_value = { - "issuer": "https://gitea.example.com", - "jwks_uri": "https://gitea.example.com/login/oauth/keys", - } - - jwks_response = MagicMock() - jwks_response.status_code = 200 - jwks_response.json.return_value = { - "keys": [{"kid": "missing", "kty": "RSA", "n": "x", "e": "AQAB"}] - } - - userinfo_denied = MagicMock() - userinfo_denied.status_code = 401 - - with patch("aegis_gitea_mcp.oauth.httpx.AsyncClient") as mock_client_cls: - mock_client = AsyncMock() - mock_client.get = AsyncMock( - side_effect=[discovery_response, jwks_response, userinfo_denied] - ) - mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client) - mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False) - - is_valid, error, principal = await validator.validate_oauth_token( - bad_token, - "127.0.0.1", - "TestAgent", - ) - - assert is_valid is False - assert principal is None - assert error is not None - - -def test_extract_bearer_token_strict_parsing() -> None: - """Bearer extraction accepts only strict `Bearer ` format.""" - assert GiteaOAuthValidator.extract_bearer_token("Bearer abc123") == "abc123" - assert GiteaOAuthValidator.extract_bearer_token("bearer abc123") is None - assert GiteaOAuthValidator.extract_bearer_token("Bearer ") is None - assert GiteaOAuthValidator.extract_bearer_token("Basic abc") is None diff --git a/tests/test_policy.py b/tests/test_policy.py deleted file mode 100644 index ce7b71e..0000000 --- a/tests/test_policy.py +++ /dev/null @@ -1,148 +0,0 @@ -"""Tests for YAML policy engine.""" - -from pathlib import Path - -import pytest - -from aegis_gitea_mcp.config import get_settings, reset_settings -from aegis_gitea_mcp.policy import PolicyEngine, PolicyError - - -def _set_base_env(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("GITEA_URL", "https://gitea.example.com") - monkeypatch.setenv("GITEA_TOKEN", "token-12345") - monkeypatch.setenv("MCP_API_KEYS", "a" * 64) - - -def _yaml_with_trailing_newline(content: str) -> str: - return content.strip() + "\n" - - -def test_default_policy_allows_read_and_denies_write( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path -) -> None: - """Default policy should allow reads and deny writes when write mode is disabled.""" - _set_base_env(monkeypatch) - reset_settings() - _ = get_settings() - - engine = PolicyEngine.from_yaml_file(tmp_path / "does-not-exist.yaml") - read_decision = engine.authorize("list_repositories", is_write=False) - write_decision = engine.authorize("create_issue", is_write=True, repository="owner/repo") - - assert read_decision.allowed - assert not write_decision.allowed - - -def test_policy_global_deny(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - """Global deny should reject matching tool names.""" - _set_base_env(monkeypatch) - policy_path = tmp_path / "policy.yaml" - policy_path.write_text( - _yaml_with_trailing_newline(""" -defaults: - read: allow - write: deny -tools: - deny: - - list_repositories -"""), - encoding="utf-8", - ) - - reset_settings() - _ = get_settings() - engine = PolicyEngine.from_yaml_file(policy_path) - - decision = engine.authorize("list_repositories", is_write=False) - assert not decision.allowed - assert "denied" in decision.reason - - -def test_repository_path_restriction(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - """Repository path allow-list should block unknown paths.""" - _set_base_env(monkeypatch) - policy_path = tmp_path / "policy.yaml" - policy_path.write_text( - _yaml_with_trailing_newline(""" -repositories: - acme/app: - tools: - allow: - - get_file_contents - paths: - allow: - - src/* -"""), - encoding="utf-8", - ) - - reset_settings() - _ = get_settings() - engine = PolicyEngine.from_yaml_file(policy_path) - - allowed = engine.authorize( - "get_file_contents", - is_write=False, - repository="acme/app", - target_path="src/main.py", - ) - denied = engine.authorize( - "get_file_contents", - is_write=False, - repository="acme/app", - target_path="docs/readme.md", - ) - - assert allowed.allowed - assert not denied.allowed - - -def test_invalid_policy_structure(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - """Invalid policy YAML should raise PolicyError.""" - _set_base_env(monkeypatch) - policy_path = tmp_path / "policy.yaml" - policy_path.write_text("repositories: []\n", encoding="utf-8") - - reset_settings() - _ = get_settings() - - with pytest.raises(PolicyError): - PolicyEngine.from_yaml_file(policy_path) - - -def test_write_mode_repository_whitelist(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - """Write mode should require repo whitelist and honor configured repository entries.""" - _set_base_env(monkeypatch) - monkeypatch.setenv("WRITE_MODE", "true") - monkeypatch.setenv("WRITE_REPOSITORY_WHITELIST", "acme/app") - - policy_path = tmp_path / "policy.yaml" - policy_path.write_text("defaults:\n write: allow\n", encoding="utf-8") - - reset_settings() - _ = get_settings() - engine = PolicyEngine.from_yaml_file(policy_path) - - allowed = engine.authorize("create_issue", is_write=True, repository="acme/app") - denied = engine.authorize("create_issue", is_write=True, repository="acme/other") - - assert allowed.allowed - assert denied.allowed is False - - -def test_write_mode_allow_all_token_repos(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - """Write mode can be configured to allow all repos accessible to token.""" - _set_base_env(monkeypatch) - monkeypatch.setenv("WRITE_MODE", "true") - monkeypatch.setenv("WRITE_ALLOW_ALL_TOKEN_REPOS", "true") - - policy_path = tmp_path / "policy.yaml" - policy_path.write_text("defaults:\n write: allow\n", encoding="utf-8") - - reset_settings() - _ = get_settings() - engine = PolicyEngine.from_yaml_file(policy_path) - - decision = engine.authorize("create_issue", is_write=True, repository="acme/any-repo") - assert decision.allowed diff --git a/tests/test_raw_api.py b/tests/test_raw_api.py deleted file mode 100644 index 88b623c..0000000 --- a/tests/test_raw_api.py +++ /dev/null @@ -1,321 +0,0 @@ -"""Tests for the generic gitea_request raw API dispatch tool.""" - -from __future__ import annotations - -from pathlib import Path -from typing import Any - -import pytest -from pydantic import ValidationError - -from aegis_gitea_mcp.config import reset_settings -from aegis_gitea_mcp.errors import ToolError -from aegis_gitea_mcp.tools.arguments import ( - extract_repository, - extract_target_path, - normalize_raw_endpoint, - parse_raw_repository, - parse_raw_target_path, - raw_is_sensitive, - raw_top_segment, -) -from aegis_gitea_mcp.tools.raw_tools import raw_api_request_tool - - -@pytest.fixture -def raw_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - """Minimal API-key-mode settings with policy that allows reads, denies writes.""" - reset_settings() - monkeypatch.setenv("GITEA_URL", "https://gitea.example.com") - monkeypatch.setenv("GITEA_TOKEN", "test-token") - monkeypatch.setenv("MCP_API_KEYS", "a" * 64) - monkeypatch.setenv("ENVIRONMENT", "test") - # Point at a non-existent policy file so the default config applies - # (read: allow, write: deny) and tests do not depend on the repo policy.yaml. - monkeypatch.setenv("POLICY_FILE_PATH", str(tmp_path / "missing-policy.yaml")) - - -class StubRawGitea: - """Stub Gitea client capturing raw_request calls.""" - - def __init__(self, response: Any = None) -> None: - self._response: Any = {"ok": True} if response is None else response - self.calls: list[dict[str, Any]] = [] - - async def raw_request( - self, - method: str, - endpoint: str, - *, - params: dict[str, Any] | None = None, - json_body: dict[str, Any] | None = None, - ) -> Any: - self.calls.append( - {"method": method, "endpoint": endpoint, "params": params, "json_body": json_body} - ) - return self._response - - -# --- Handler behavior ------------------------------------------------------ - - -async def test_get_repo_endpoint_allowed_and_parses_repository(raw_env: None) -> None: - """A GET on a repo endpoint is allowed and parses owner/repo from the path.""" - stub = StubRawGitea({"number": 1}) - result = await raw_api_request_tool(stub, {"method": "GET", "path": "/repos/acme/app/pulls/1"}) - - assert result["method"] == "GET" - assert result["path"] == "/api/v1/repos/acme/app/pulls/1" - assert result["write"] is False - assert result["repository"] == "acme/app" - assert result["data"] == {"number": 1} - assert stub.calls[0]["endpoint"] == "/api/v1/repos/acme/app/pulls/1" - - -async def test_lowercase_method_is_normalized(raw_env: None) -> None: - """A lowercase method is uppercased and accepted.""" - stub = StubRawGitea([{"id": 1}]) - result = await raw_api_request_tool(stub, {"method": "get", "path": "/repos/acme/app/issues"}) - assert result["method"] == "GET" - assert result["count"] == 1 - - -async def test_delete_denied_when_write_mode_off(raw_env: None) -> None: - """A write method is denied (no network call) while write-mode is disabled.""" - stub = StubRawGitea() - with pytest.raises(ToolError) as exc_info: - await raw_api_request_tool(stub, {"method": "DELETE", "path": "/repos/acme/app/issues/1"}) - - assert exc_info.value.status_code == 403 - assert "write mode is disabled" in str(exc_info.value.detail) - assert stub.calls == [] - - -async def test_write_allowed_with_write_mode_and_whitelist( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path -) -> None: - """A write succeeds only when write-mode is on, the repo is whitelisted, and policy allows.""" - policy_file = tmp_path / "policy.yaml" - policy_file.write_text("defaults:\n read: allow\n write: allow\n", encoding="utf-8") - - reset_settings() - monkeypatch.setenv("GITEA_URL", "https://gitea.example.com") - monkeypatch.setenv("GITEA_TOKEN", "test-token") - monkeypatch.setenv("MCP_API_KEYS", "a" * 64) - monkeypatch.setenv("ENVIRONMENT", "test") - monkeypatch.setenv("POLICY_FILE_PATH", str(policy_file)) - monkeypatch.setenv("WRITE_MODE", "true") - monkeypatch.setenv("WRITE_REPOSITORY_WHITELIST", "acme/app") - - stub = StubRawGitea({"merged": True}) - result = await raw_api_request_tool( - stub, - {"method": "PUT", "path": "/repos/acme/app/pulls/1/merge", "body": {"Do": "merge"}}, - ) - - assert result["write"] is True - assert result["repository"] == "acme/app" - assert stub.calls[0]["json_body"] == {"Do": "merge"} - - -async def test_write_denied_for_repo_outside_whitelist( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path -) -> None: - """A write on a repo not in the whitelist is denied even with write-mode on.""" - policy_file = tmp_path / "policy.yaml" - policy_file.write_text("defaults:\n read: allow\n write: allow\n", encoding="utf-8") - - reset_settings() - monkeypatch.setenv("GITEA_URL", "https://gitea.example.com") - monkeypatch.setenv("GITEA_TOKEN", "test-token") - monkeypatch.setenv("MCP_API_KEYS", "a" * 64) - monkeypatch.setenv("ENVIRONMENT", "test") - monkeypatch.setenv("POLICY_FILE_PATH", str(policy_file)) - monkeypatch.setenv("WRITE_MODE", "true") - monkeypatch.setenv("WRITE_REPOSITORY_WHITELIST", "acme/other") - - stub = StubRawGitea() - with pytest.raises(ToolError) as exc_info: - await raw_api_request_tool(stub, {"method": "POST", "path": "/repos/acme/app/issues"}) - - assert exc_info.value.status_code == 403 - assert "whitelist" in str(exc_info.value.detail) - assert stub.calls == [] - - -async def test_non_repository_write_denied(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - """A write that targets no repository is denied (secure default).""" - policy_file = tmp_path / "policy.yaml" - policy_file.write_text("defaults:\n read: allow\n write: allow\n", encoding="utf-8") - - reset_settings() - monkeypatch.setenv("GITEA_URL", "https://gitea.example.com") - monkeypatch.setenv("GITEA_TOKEN", "test-token") - monkeypatch.setenv("MCP_API_KEYS", "a" * 64) - monkeypatch.setenv("ENVIRONMENT", "test") - monkeypatch.setenv("POLICY_FILE_PATH", str(policy_file)) - monkeypatch.setenv("WRITE_MODE", "true") - monkeypatch.setenv("WRITE_REPOSITORY_WHITELIST", "acme/app") - - stub = StubRawGitea() - with pytest.raises(ToolError) as exc_info: - await raw_api_request_tool(stub, {"method": "POST", "path": "/user/repos"}) - - assert exc_info.value.status_code == 403 - assert "repository target" in str(exc_info.value.detail) - assert stub.calls == [] - - -@pytest.mark.parametrize( - "path", - ["/admin/users", "/users/bob/tokens", "/repos/acme/app/hooks", "/user/keys"], -) -async def test_sensitive_paths_denied_on_get(raw_env: None, path: str) -> None: - """Admin/credential surfaces are denied for every method, including GET.""" - stub = StubRawGitea() - with pytest.raises(ToolError) as exc_info: - await raw_api_request_tool(stub, {"method": "GET", "path": path}) - - assert exc_info.value.status_code == 403 - assert "sensitive-path denylist" in str(exc_info.value.detail) - assert stub.calls == [] - - -async def test_sensitive_path_allowed_with_override( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path -) -> None: - """RAW_API_ALLOW_SENSITIVE bypasses the admin/credential denylist.""" - reset_settings() - monkeypatch.setenv("GITEA_URL", "https://gitea.example.com") - monkeypatch.setenv("GITEA_TOKEN", "test-token") - monkeypatch.setenv("MCP_API_KEYS", "a" * 64) - monkeypatch.setenv("ENVIRONMENT", "test") - monkeypatch.setenv("POLICY_FILE_PATH", str(tmp_path / "missing.yaml")) - monkeypatch.setenv("RAW_API_ALLOW_SENSITIVE", "true") - - stub = StubRawGitea([{"id": 1}]) - result = await raw_api_request_tool(stub, {"method": "GET", "path": "/admin/users"}) - assert result["data"] == [{"id": 1}] - assert stub.calls[0]["endpoint"] == "/api/v1/admin/users" - - -async def test_cross_repo_search_not_treated_as_repository(raw_env: None) -> None: - """/repos/issues/search is a cross-repo endpoint, so repository is None.""" - stub = StubRawGitea([{"id": 1}]) - result = await raw_api_request_tool( - stub, {"method": "GET", "path": "/repos/issues/search", "query": {"q": "bug"}} - ) - assert result["repository"] is None - assert result["count"] == 1 - assert stub.calls[0]["params"] == {"q": "bug"} - - -async def test_unknown_method_rejected_before_network(raw_env: None) -> None: - """An unknown HTTP method is rejected during validation before any network call.""" - stub = StubRawGitea() - with pytest.raises(ValidationError): - await raw_api_request_tool(stub, {"method": "OPTIONS", "path": "/repos/acme/app"}) - assert stub.calls == [] - - -async def test_path_traversal_rejected(raw_env: None) -> None: - """A path containing '..' is rejected during validation.""" - stub = StubRawGitea() - with pytest.raises(ValidationError): - await raw_api_request_tool( - stub, {"method": "GET", "path": "/repos/acme/app/../../admin/users"} - ) - assert stub.calls == [] - - -async def test_full_url_is_reduced_to_path(raw_env: None) -> None: - """A full URL is reduced to just the API path.""" - stub = StubRawGitea({"name": "app"}) - result = await raw_api_request_tool( - stub, - { - "method": "GET", - "path": "https://gitea.example.com/api/v1/repos/acme/app/contents/src/app.py?ref=main", - }, - ) - assert result["path"] == "/api/v1/repos/acme/app/contents/src/app.py" - assert result["repository"] == "acme/app" - - -async def test_raw_api_disabled(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - """The killswitch disables every dispatch.""" - reset_settings() - monkeypatch.setenv("GITEA_URL", "https://gitea.example.com") - monkeypatch.setenv("GITEA_TOKEN", "test-token") - monkeypatch.setenv("MCP_API_KEYS", "a" * 64) - monkeypatch.setenv("ENVIRONMENT", "test") - monkeypatch.setenv("POLICY_FILE_PATH", str(tmp_path / "missing.yaml")) - monkeypatch.setenv("RAW_API_ENABLED", "false") - - stub = StubRawGitea() - with pytest.raises(ToolError) as exc_info: - await raw_api_request_tool(stub, {"method": "GET", "path": "/repos/acme/app"}) - assert exc_info.value.status_code == 403 - assert "disabled" in str(exc_info.value.detail) - assert stub.calls == [] - - -async def test_large_dict_response_is_truncated(raw_env: None) -> None: - """An oversized object response is returned as a truncated JSON string.""" - big = {"blob": "x" * 50_000} - stub = StubRawGitea(big) - result = await raw_api_request_tool(stub, {"method": "GET", "path": "/repos/acme/app"}) - assert result["truncated"] is True - assert isinstance(result["data"], str) - - -# --- Path parsing helpers -------------------------------------------------- - - -@pytest.mark.parametrize( - ("path", "expected"), - [ - ("/repos/acme/app", "/api/v1/repos/acme/app"), - ("repos/acme/app", "/api/v1/repos/acme/app"), - ("/api/v1/repos/acme/app", "/api/v1/repos/acme/app"), - ("/", "/api/v1"), - ("", "/api/v1"), - ], -) -def test_normalize_raw_endpoint(path: str, expected: str) -> None: - assert normalize_raw_endpoint(path) == expected - - -def test_normalize_raw_endpoint_rejects_traversal() -> None: - with pytest.raises(ValueError): - normalize_raw_endpoint("/repos/acme/../admin") - - -def test_parse_raw_repository_variants() -> None: - assert parse_raw_repository("/api/v1/repos/acme/app/pulls/1") == "acme/app" - assert parse_raw_repository("/api/v1/repos/search") is None - assert parse_raw_repository("/api/v1/repos/issues/search") is None - assert parse_raw_repository("/api/v1/user/repos") is None - - -def test_parse_raw_target_path() -> None: - assert parse_raw_target_path("/api/v1/repos/acme/app/contents/src/app.py") == "src/app.py" - assert parse_raw_target_path("/api/v1/repos/acme/app/raw/README.md") == "README.md" - assert parse_raw_target_path("/api/v1/repos/acme/app/pulls/1") is None - - -def test_raw_top_segment_and_sensitivity() -> None: - assert raw_top_segment("/api/v1/repos/acme/app") == "repos" - assert raw_top_segment("/api/v1") == "" - assert raw_is_sensitive("/api/v1/repos/acme/app/hooks") is True - assert raw_is_sensitive("/api/v1/user/applications/oauth2") is True - assert raw_is_sensitive("/api/v1/repos/acme/app/pulls") is False - - -def test_extractors_are_raw_aware() -> None: - raw_args = {"method": "GET", "path": "/repos/acme/app/contents/src/app.py"} - assert extract_repository(raw_args) == "acme/app" - assert extract_target_path(raw_args) == "src/app.py" - # Malformed raw path must not raise from the extractors. - assert extract_repository({"method": "GET", "path": "/repos/acme/../x"}) is None - assert extract_target_path({"method": "GET", "path": "/repos/acme/../x"}) is None diff --git a/tests/test_repository_tools.py b/tests/test_repository_tools.py deleted file mode 100644 index d1d0a90..0000000 --- a/tests/test_repository_tools.py +++ /dev/null @@ -1,172 +0,0 @@ -"""Tests for repository-focused tool handlers.""" - -from __future__ import annotations - -import pytest - -from aegis_gitea_mcp.config import reset_settings -from aegis_gitea_mcp.gitea_client import ( - GiteaAuthenticationError, - GiteaAuthorizationError, - GiteaError, -) -from aegis_gitea_mcp.request_context import clear_gitea_auth_context, set_gitea_user_login -from aegis_gitea_mcp.tools.repository import ( - get_file_contents_tool, - get_file_tree_tool, - get_repository_info_tool, - list_repositories_tool, -) - - -@pytest.fixture(autouse=True) -def repository_tool_env(monkeypatch: pytest.MonkeyPatch) -> None: - """Provide minimal settings needed by response limit helpers.""" - reset_settings() - monkeypatch.setenv("GITEA_URL", "https://gitea.example.com") - monkeypatch.setenv("GITEA_TOKEN", "legacy-token") - monkeypatch.setenv("MCP_API_KEYS", "a" * 64) - monkeypatch.setenv("ENVIRONMENT", "test") - yield - reset_settings() - - -class RepoStub: - """Stub Gitea client for repository tools.""" - - async def list_repositories(self): - return [{"name": "demo", "owner": {"login": "acme"}, "full_name": "acme/demo"}] - - async def get_repository(self, owner, repo): - return {"name": repo, "owner": {"login": owner}, "full_name": f"{owner}/{repo}"} - - async def get_tree(self, owner, repo, ref, recursive): - return {"tree": [{"path": "README.md", "type": "blob", "size": 11, "sha": "abc"}]} - - async def get_file_contents(self, owner, repo, filepath, ref): - return { - "content": "SGVsbG8gV29ybGQ=", - "encoding": "base64", - "size": 11, - "sha": "abc", - "html_url": f"https://example/{owner}/{repo}/{filepath}", - } - - -class RepoErrorStub(RepoStub): - """Stub that raises backend errors.""" - - async def list_repositories(self): - raise GiteaError("backend down") - - -@pytest.mark.asyncio -async def test_list_repositories_tool_success() -> None: - """Repository listing tool normalizes output shape.""" - result = await list_repositories_tool(RepoStub(), {}) - assert result["count"] == 1 - assert result["repositories"][0]["owner"] == "acme" - - -@pytest.mark.asyncio -async def test_list_repositories_tool_failure_mode() -> None: - """Repository listing tool wraps backend errors.""" - with pytest.raises(RuntimeError, match="Failed to list repositories"): - await list_repositories_tool(RepoErrorStub(), {}) - - -@pytest.mark.parametrize("auth_error", [GiteaAuthenticationError, GiteaAuthorizationError]) -@pytest.mark.asyncio -async def test_tool_propagates_auth_errors_unwrapped(auth_error: type[GiteaError]) -> None: - """Auth/authz failures must surface as-is, not masked behind RuntimeError. - - The server maps these to actionable re-authorization guidance; wrapping them - in RuntimeError would hide that and return a generic internal error instead. - """ - - class AuthErrorStub(RepoStub): - async def list_repositories(self): - raise auth_error("token rejected") - - with pytest.raises(auth_error): - await list_repositories_tool(AuthErrorStub(), {}) - - -class UserScopedStub(RepoStub): - """Stub exposing the per-user listing path used in service-PAT mode.""" - - async def list_user_repositories(self, login): - return [{"name": "mine", "owner": {"login": login}, "full_name": f"{login}/mine"}] - - async def list_repositories(self): - raise AssertionError("PAT mode with a known user must use the user-scoped listing") - - -@pytest.mark.asyncio -async def test_list_repositories_tool_scopes_to_user_in_pat_mode() -> None: - """With a service PAT (GITEA_TOKEN) and a known user, listing is user-scoped.""" - set_gitea_user_login("alice") - try: - result = await list_repositories_tool(UserScopedStub(), {}) - finally: - clear_gitea_auth_context() - assert result["count"] == 1 - assert result["repositories"][0]["full_name"] == "alice/mine" - - -@pytest.mark.asyncio -async def test_get_repository_info_tool_success() -> None: - """Repository info tool returns normalized metadata.""" - result = await get_repository_info_tool(RepoStub(), {"owner": "acme", "repo": "demo"}) - assert result["full_name"] == "acme/demo" - - -@pytest.mark.asyncio -async def test_get_file_tree_tool_success() -> None: - """File tree tool returns bounded tree entries.""" - result = await get_file_tree_tool( - RepoStub(), - {"owner": "acme", "repo": "demo", "ref": "main", "recursive": False}, - ) - assert result["count"] == 1 - assert result["tree"][0]["path"] == "README.md" - - -@pytest.mark.asyncio -async def test_get_file_contents_tool_decodes_base64() -> None: - """File contents tool decodes UTF-8 base64 payloads.""" - result = await get_file_contents_tool( - RepoStub(), - {"owner": "acme", "repo": "demo", "filepath": "README.md", "ref": "main"}, - ) - assert result["content"] == "Hello World" - - -@pytest.mark.asyncio -async def test_get_file_contents_tool_handles_invalid_base64() -> None: - """Invalid base64 payloads are returned safely without crashing.""" - - class InvalidBase64Stub(RepoStub): - async def get_file_contents(self, owner, repo, filepath, ref): - return {"content": "%%%not-base64%%%", "encoding": "base64", "size": 4, "sha": "abc"} - - result = await get_file_contents_tool( - InvalidBase64Stub(), - {"owner": "acme", "repo": "demo", "filepath": "README.md", "ref": "main"}, - ) - assert result["content"] == "%%%not-base64%%%" - - -@pytest.mark.asyncio -async def test_get_file_contents_tool_failure_mode() -> None: - """File contents tool wraps backend failures.""" - - class ErrorFileStub(RepoStub): - async def get_file_contents(self, owner, repo, filepath, ref): - raise GiteaError("boom") - - with pytest.raises(RuntimeError, match="Failed to get file contents"): - await get_file_contents_tool( - ErrorFileStub(), - {"owner": "acme", "repo": "demo", "filepath": "README.md", "ref": "main"}, - ) diff --git a/tests/test_server.py b/tests/test_server.py deleted file mode 100644 index 2b2e901..0000000 --- a/tests/test_server.py +++ /dev/null @@ -1,862 +0,0 @@ -"""Tests for MCP server endpoints.""" - -from __future__ import annotations - -from unittest.mock import AsyncMock, MagicMock, patch - -import httpx -import pytest -from fastapi.testclient import TestClient - -from aegis_gitea_mcp.config import reset_settings -from aegis_gitea_mcp.oauth import reset_oauth_validator - - -@pytest.fixture(autouse=True) -def reset_state() -> None: - """Reset global state between tests.""" - reset_settings() - reset_oauth_validator() - yield - reset_settings() - reset_oauth_validator() - - -@pytest.fixture -def oauth_env(monkeypatch: pytest.MonkeyPatch) -> None: - """Set OAuth-first environment for server tests.""" - monkeypatch.setenv("GITEA_URL", "https://gitea.example.com") - monkeypatch.setenv("OAUTH_MODE", "true") - monkeypatch.setenv("GITEA_OAUTH_CLIENT_ID", "test-client-id") - monkeypatch.setenv("GITEA_OAUTH_CLIENT_SECRET", "test-client-secret") - monkeypatch.setenv("OAUTH_STATE_SECRET", "test-state-secret-0123456789abcdef") - monkeypatch.setenv("ENVIRONMENT", "test") - monkeypatch.setenv("STARTUP_VALIDATE_GITEA", "false") - monkeypatch.setenv("WRITE_MODE", "false") - monkeypatch.setenv("PUBLIC_BASE_URL", "") - - -@pytest.fixture -def mock_oauth_validation(monkeypatch: pytest.MonkeyPatch) -> None: - """Mock OAuth validator outcomes by token value.""" - - async def _validate(_self, token: str | None, _ip: str, _ua: str): - if token == "valid-read": - return True, None, {"login": "alice", "scopes": ["read:repository"]} - if token == "valid-write": - return ( - True, - None, - { - "login": "alice", - "scopes": ["read:repository", "write:repository"], - }, - ) - return False, "Invalid or expired OAuth token.", None - - monkeypatch.setattr( - "aegis_gitea_mcp.oauth.GiteaOAuthValidator.validate_oauth_token", - _validate, - ) - - -@pytest.fixture -def client(oauth_env: None, mock_oauth_validation: None) -> TestClient: - """Create FastAPI test client.""" - from aegis_gitea_mcp.server import app - - return TestClient(app) - - -def test_root_endpoint(client: TestClient) -> None: - """Root endpoint returns server metadata.""" - response = client.get("/") - assert response.status_code == 200 - data = response.json() - assert data["name"] == "AegisGitea MCP Server" - assert data["status"] == "running" - - -def test_health_endpoint(client: TestClient) -> None: - """Health endpoint does not require auth.""" - response = client.get("/health") - assert response.status_code == 200 - assert response.json()["status"] == "healthy" - - -def test_oauth_protected_resource_metadata(client: TestClient) -> None: - """PRM advertises THIS server's canonical URL as the protected resource.""" - response = client.get("/.well-known/oauth-protected-resource") - - assert response.status_code == 200 - data = response.json() - # RFC 9728/8707: the resource identifier is the MCP server's own URL, not Gitea's. - assert data["resource"] == "http://testserver" - assert data["authorization_servers"] == [ - "http://testserver", - "https://gitea.example.com", - ] - assert data["bearer_methods_supported"] == ["header"] - assert data["scopes_supported"] == ["read:repository", "write:repository"] - assert "resource_documentation" in data - - -def test_oauth_authorization_server_metadata(client: TestClient) -> None: - """Auth server metadata advertises this server's proxy OAuth endpoints.""" - response = client.get("/.well-known/oauth-authorization-server") - assert response.status_code == 200 - payload = response.json() - # Claude must be sent to our proxy authorize endpoint (Gitea does not know - # Claude's redirect_uri), so the endpoint lives on this server. - assert payload["authorization_endpoint"] == "http://testserver/oauth/authorize" - assert payload["token_endpoint"] == "http://testserver/oauth/token" - assert payload["registration_endpoint"] == "http://testserver/register" - assert payload["scopes_supported"] == ["read:repository", "write:repository"] - - -def test_openid_configuration_metadata(client: TestClient) -> None: - """OpenID metadata is exposed for clients expecting OIDC discovery.""" - response = client.get("/.well-known/openid-configuration") - assert response.status_code == 200 - payload = response.json() - assert payload["issuer"] == "https://gitea.example.com" - assert payload["authorization_endpoint"] == "http://testserver/oauth/authorize" - assert payload["token_endpoint"] == "http://testserver/oauth/token" - assert payload["userinfo_endpoint"].endswith("/login/oauth/userinfo") - assert payload["jwks_uri"].endswith("/login/oauth/keys") - assert "read:repository" in payload["scopes_supported"] - assert "write:repository" in payload["scopes_supported"] - - -def test_oauth_metadata_uses_public_base_url(monkeypatch: pytest.MonkeyPatch) -> None: - """Public base URL is used for externally advertised OAuth metadata links.""" - monkeypatch.setenv("GITEA_URL", "https://gitea.example.com") - monkeypatch.setenv("OAUTH_MODE", "true") - monkeypatch.setenv("GITEA_OAUTH_CLIENT_ID", "test-client-id") - monkeypatch.setenv("GITEA_OAUTH_CLIENT_SECRET", "test-client-secret") - monkeypatch.setenv("OAUTH_STATE_SECRET", "test-state-secret-0123456789abcdef") - monkeypatch.setenv("PUBLIC_BASE_URL", "https://mcp.example.com") - monkeypatch.setenv("ENVIRONMENT", "test") - monkeypatch.setenv("STARTUP_VALIDATE_GITEA", "false") - - from aegis_gitea_mcp.server import app - - client = TestClient(app) - metadata_response = client.get("/.well-known/oauth-authorization-server") - assert metadata_response.status_code == 200 - payload = metadata_response.json() - assert payload["token_endpoint"] == "https://mcp.example.com/oauth/token" - - openid_response = client.get("/.well-known/openid-configuration") - assert openid_response.status_code == 200 - openid_payload = openid_response.json() - assert openid_payload["token_endpoint"] == "https://mcp.example.com/oauth/token" - - protected_response = client.get("/.well-known/oauth-protected-resource") - assert protected_response.status_code == 200 - protected_payload = protected_response.json() - # P4: the protected resource identifier must equal this server's public base. - assert protected_payload["resource"] == "https://mcp.example.com" - assert protected_payload["authorization_servers"] == [ - "https://mcp.example.com", - "https://gitea.example.com", - ] - - challenge_response = client.post( - "/mcp/tool/call", - json={"tool": "list_repositories", "arguments": {}}, - ) - assert challenge_response.status_code == 401 - challenge = challenge_response.headers["WWW-Authenticate"] - assert ( - 'resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"' - in challenge - ) - - -def test_mcp_streamable_http_path_works(client: TestClient) -> None: - """The spec path /mcp exposes the same transport behavior as the SSE alias.""" - response = client.post( - "/mcp", - headers={"Authorization": "Bearer valid-read"}, - json={"jsonrpc": "2.0", "id": "init-1", "method": "initialize"}, - ) - - assert response.status_code == 200 - payload = response.json() - assert payload["result"]["protocolVersion"] == "2024-11-05" - - -def test_mcp_preflight_allows_same_origin(client: TestClient) -> None: - """Same-origin preflight requests to /mcp return strict CORS headers.""" - response = client.options( - "/mcp", - headers={ - "Origin": "http://testserver", - "Access-Control-Request-Method": "POST", - "Access-Control-Request-Headers": "authorization,content-type", - }, - ) - - assert response.status_code == 204 - assert response.headers["Access-Control-Allow-Origin"] == "http://testserver" - - -def test_mcp_preflight_rejects_cross_origin(client: TestClient) -> None: - """Cross-origin browser requests to /mcp are denied.""" - response = client.options( - "/mcp", - headers={ - "Origin": "https://evil.example.com", - "Access-Control-Request-Method": "POST", - }, - ) - - assert response.status_code == 403 - - -def test_service_pat_requests_verify_user_repo_access_before_execution( - oauth_env: None, mock_oauth_validation: None, monkeypatch: pytest.MonkeyPatch -) -> None: - """Service PAT fallback checks the user's repository permission before executing tools.""" - from aegis_gitea_mcp import server - - monkeypatch.setenv("GITEA_TOKEN", "service-pat-token") - server._api_scope_cache.clear() - server.reset_repo_authz_cache() - - probe_response = MagicMock() - probe_response.status_code = 200 - - repo_response = MagicMock() - repo_response.status_code = 403 - - with patch("aegis_gitea_mcp.server.httpx.AsyncClient") as mock_client_cls: - mock_client = AsyncMock() - mock_client.get = AsyncMock(side_effect=[probe_response, repo_response]) - mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client) - mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False) - - from aegis_gitea_mcp.server import app - - client = TestClient(app, raise_server_exceptions=False) - response = client.post( - "/mcp/tool/call", - headers={"Authorization": "Bearer valid-read"}, - json={ - "tool": "get_repository_info", - "arguments": {"owner": "acme", "repo": "demo"}, - }, - ) - - assert response.status_code == 403 - assert "permission" in response.json()["detail"].lower() - - -@pytest.mark.asyncio -async def test_service_pat_repo_authz_allows_user_with_read_permission( - oauth_env: None, monkeypatch: pytest.MonkeyPatch -) -> None: - """Read-level collaborator permission allows service PAT execution to proceed.""" - from aegis_gitea_mcp import server - - monkeypatch.setenv("GITEA_TOKEN", "service-pat-token") - server.reset_repo_authz_cache() - - permission_response = MagicMock() - permission_response.status_code = 200 - permission_response.json.return_value = {"permission": "read"} - - with patch("aegis_gitea_mcp.server.httpx.AsyncClient") as mock_client_cls: - mock_client = AsyncMock() - mock_client.get = AsyncMock(return_value=permission_response) - mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client) - mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False) - - await server._verify_user_repository_access( - repository="acme/demo", - required_scope=server.READ_SCOPE, - user_login="alice", - correlation_id="corr-1", - tool_name="get_repository_info", - ) - - mock_client.get.assert_awaited_once() - requested_url = mock_client.get.await_args.args[0] - requested_headers = mock_client.get.await_args.kwargs["headers"] - assert requested_url.endswith("/api/v1/repos/acme/demo/collaborators/alice/permission") - assert requested_headers["Authorization"] == "token service-pat-token" - - -@pytest.mark.asyncio -async def test_service_pat_repo_authz_denies_read_user_for_write_tool( - oauth_env: None, monkeypatch: pytest.MonkeyPatch -) -> None: - """Read permission is insufficient for write tools in service PAT mode.""" - from fastapi import HTTPException - - from aegis_gitea_mcp import server - - monkeypatch.setenv("GITEA_TOKEN", "service-pat-token") - server.reset_repo_authz_cache() - - permission_response = MagicMock() - permission_response.status_code = 200 - permission_response.json.return_value = {"permission": "read"} - - with patch("aegis_gitea_mcp.server.httpx.AsyncClient") as mock_client_cls: - mock_client = AsyncMock() - mock_client.get = AsyncMock(return_value=permission_response) - mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client) - mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False) - - with pytest.raises(HTTPException) as exc_info: - await server._verify_user_repository_access( - repository="acme/demo", - required_scope=server.WRITE_SCOPE, - user_login="alice", - correlation_id="corr-1", - tool_name="create_issue", - ) - - assert exc_info.value.status_code == 403 - - -@pytest.mark.asyncio -async def test_service_pat_repo_authz_cache_hit_and_expiry( - oauth_env: None, monkeypatch: pytest.MonkeyPatch -) -> None: - """Repository permission decisions are cached briefly and rechecked after expiry.""" - from aegis_gitea_mcp import cache as cache_module - from aegis_gitea_mcp import server - - monkeypatch.setenv("GITEA_TOKEN", "service-pat-token") - monkeypatch.setenv("REPO_AUTHZ_CACHE_TTL_SECONDS", "1") - server.reset_repo_authz_cache() - - now = 1000.0 - monkeypatch.setattr(cache_module.time, "monotonic", lambda: now) - - permission_response = MagicMock() - permission_response.status_code = 200 - permission_response.json.return_value = {"permission": "read"} - - with patch("aegis_gitea_mcp.server.httpx.AsyncClient") as mock_client_cls: - mock_client = AsyncMock() - mock_client.get = AsyncMock(return_value=permission_response) - mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client) - mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False) - - for _ in range(2): - await server._verify_user_repository_access( - repository="acme/demo", - required_scope=server.READ_SCOPE, - user_login="alice", - correlation_id="corr-1", - tool_name="get_repository_info", - ) - assert mock_client.get.await_count == 1 - - now = 1002.0 - await server._verify_user_repository_access( - repository="acme/demo", - required_scope=server.READ_SCOPE, - user_login="alice", - correlation_id="corr-1", - tool_name="get_repository_info", - ) - - assert mock_client.get.await_count == 2 - - -def test_scope_compatibility_write_implies_read() -> None: - """write:repository grants read-level access for read tools.""" - from aegis_gitea_mcp.server import READ_SCOPE, _has_required_scope - - assert _has_required_scope(READ_SCOPE, {"write:repository"}) - - -def test_scope_compatibility_repository_aliases() -> None: - """Legacy/broad repository scopes satisfy MCP read/write requirements.""" - from aegis_gitea_mcp.server import READ_SCOPE, WRITE_SCOPE, _has_required_scope - - assert _has_required_scope(READ_SCOPE, {"repository"}) - assert _has_required_scope(WRITE_SCOPE, {"repository"}) - assert _has_required_scope(WRITE_SCOPE, {"repo"}) - - -def test_list_tools_without_auth(client: TestClient) -> None: - """Tool listing remains discoverable without auth.""" - response = client.get("/mcp/tools") - assert response.status_code == 200 - assert "tools" in response.json() - - -def test_call_tool_without_auth_returns_challenge(client: TestClient) -> None: - """Tool calls without bearer token return 401 + WWW-Authenticate challenge.""" - response = client.post("/mcp/tool/call", json={"tool": "list_repositories", "arguments": {}}) - - assert response.status_code == 401 - assert "WWW-Authenticate" in response.headers - challenge = response.headers["WWW-Authenticate"] - assert 'resource_metadata="http://testserver/.well-known/oauth-protected-resource"' in challenge - assert 'scope="read:repository"' in challenge - - -def test_call_tool_invalid_token_returns_challenge(client: TestClient) -> None: - """Invalid bearer token returns 401 + WWW-Authenticate challenge.""" - response = client.post( - "/mcp/tool/call", - headers={"Authorization": "Bearer invalid-token"}, - json={"tool": "list_repositories", "arguments": {}}, - ) - - assert response.status_code == 401 - assert "WWW-Authenticate" in response.headers - - -def test_sse_tools_list_returns_camel_case_schema(client: TestClient) -> None: - """SSE tools/list returns MCP-compatible camelCase inputSchema.""" - response = client.post( - "/mcp/sse", - headers={"Authorization": "Bearer valid-read"}, - json={"jsonrpc": "2.0", "id": "1", "method": "tools/list"}, - ) - - assert response.status_code == 200 - data = response.json() - assert "result" in data - assert "tools" in data["result"] - assert "inputSchema" in data["result"]["tools"][0] - - -def test_sse_initialize_message(client: TestClient) -> None: - """SSE initialize message returns protocol and server metadata.""" - response = client.post( - "/mcp/sse", - headers={"Authorization": "Bearer valid-read"}, - json={"jsonrpc": "2.0", "id": "init-1", "method": "initialize"}, - ) - - assert response.status_code == 200 - payload = response.json() - assert payload["result"]["protocolVersion"] == "2024-11-05" - assert payload["result"]["serverInfo"]["name"] == "AegisGitea MCP" - - -def test_sse_tools_call_success_response( - client: TestClient, monkeypatch: pytest.MonkeyPatch -) -> None: - """SSE tools/call wraps successful tool output in text content.""" - - async def _fake_execute(tool_name: str, arguments: dict, correlation_id: str) -> dict: - assert tool_name == "list_repositories" - assert isinstance(arguments, dict) - assert correlation_id - return {"ok": True} - - monkeypatch.setattr("aegis_gitea_mcp.server._execute_tool_call", _fake_execute) - - response = client.post( - "/mcp/sse", - headers={"Authorization": "Bearer valid-read"}, - json={ - "jsonrpc": "2.0", - "id": "call-1", - "method": "tools/call", - "params": {"name": "list_repositories", "arguments": {}}, - }, - ) - - assert response.status_code == 200 - assert '"ok": true' in response.json()["result"]["content"][0]["text"].lower() - - -def test_sse_tools_call_http_exception(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: - """SSE tools/call maps HTTPException to JSON-RPC error envelope.""" - - async def _fake_execute(_tool_name: str, _arguments: dict, _correlation_id: str) -> dict: - from fastapi import HTTPException - - raise HTTPException(status_code=403, detail="Insufficient scope") - - monkeypatch.setattr("aegis_gitea_mcp.server._execute_tool_call", _fake_execute) - - response = client.post( - "/mcp/sse", - headers={"Authorization": "Bearer valid-read"}, - json={ - "jsonrpc": "2.0", - "id": "call-2", - "method": "tools/call", - "params": {"name": "create_issue", "arguments": {}}, - }, - ) - - assert response.status_code == 200 - body = response.json() - assert body["error"]["code"] == -32000 - assert "insufficient scope" in body["error"]["message"].lower() - - -def test_tool_call_not_found_maps_to_404( - client: TestClient, monkeypatch: pytest.MonkeyPatch -) -> None: - """A GiteaNotFoundError wrapped in RuntimeError surfaces as a clear 404.""" - from aegis_gitea_mcp.gitea_client import GiteaNotFoundError - - async def _fake_execute(_tool: str, _args: dict, _cid: str) -> dict: - try: - raise GiteaNotFoundError("Resource not found") - except GiteaNotFoundError as exc: - raise RuntimeError("Failed to get issue: Resource not found") from exc - - monkeypatch.setattr("aegis_gitea_mcp.server._execute_tool_call", _fake_execute) - - response = client.post( - "/mcp/tool/call", - headers={"Authorization": "Bearer valid-read"}, - json={"tool": "get_issue", "arguments": {"owner": "a", "repo": "b", "issue_number": 1}}, - ) - - assert response.status_code == 404 - assert "not found" in response.json()["error"].lower() - - -def test_tool_call_internal_error_includes_exception_type( - client: TestClient, monkeypatch: pytest.MonkeyPatch -) -> None: - """Masked internal errors name the exception type (safe) but never the message.""" - - async def _fake_execute(_tool: str, _args: dict, _cid: str) -> dict: - raise TypeError("'NoneType' object is not iterable") - - monkeypatch.setattr("aegis_gitea_mcp.server._execute_tool_call", _fake_execute) - - response = client.post( - "/mcp/tool/call", - headers={"Authorization": "Bearer valid-read"}, - json={"tool": "get_issue", "arguments": {"owner": "a", "repo": "b", "issue_number": 1}}, - ) - - assert response.status_code == 500 - error = response.json()["error"] - assert "TypeError" in error - assert "NoneType" not in error - - -def test_sse_tools_call_not_found_returns_jsonrpc_error( - client: TestClient, monkeypatch: pytest.MonkeyPatch -) -> None: - """A wrapped GiteaNotFoundError in the SSE path returns -32000 with a clear message.""" - from aegis_gitea_mcp.gitea_client import GiteaNotFoundError - - async def _fake_execute(_tool: str, _args: dict, _cid: str) -> dict: - try: - raise GiteaNotFoundError("Resource not found") - except GiteaNotFoundError as exc: - raise RuntimeError("Failed to get issue: Resource not found") from exc - - monkeypatch.setattr("aegis_gitea_mcp.server._execute_tool_call", _fake_execute) - - response = client.post( - "/mcp/sse", - headers={"Authorization": "Bearer valid-read"}, - json={ - "jsonrpc": "2.0", - "id": "nf-1", - "method": "tools/call", - "params": { - "name": "get_issue", - "arguments": {"owner": "a", "repo": "b", "issue_number": 1}, - }, - }, - ) - - assert response.status_code == 200 - body = response.json() - assert body["error"]["code"] == -32000 - assert "not found" in body["error"]["message"].lower() - - -def test_call_nonexistent_tool(client: TestClient) -> None: - """Unknown tools return 404 after successful auth.""" - response = client.post( - "/mcp/tool/call", - headers={"Authorization": "Bearer valid-read"}, - json={"tool": "nonexistent_tool", "arguments": {}}, - ) - - assert response.status_code == 404 - assert "not found" in response.json()["detail"].lower() - - -def test_write_scope_enforced_before_policy(client: TestClient) -> None: - """Write tools require write:repository scope.""" - response = client.post( - "/mcp/tool/call", - headers={"Authorization": "Bearer valid-read"}, - json={ - "tool": "create_issue", - "arguments": {"owner": "acme", "repo": "demo", "title": "test"}, - }, - ) - - assert response.status_code == 403 - assert "required scope: write:repository" in response.json()["detail"].lower() - - -def test_write_tool_denied_by_default_policy(client: TestClient) -> None: - """Even with write scope, write mode stays denied by default policy.""" - response = client.post( - "/mcp/tool/call", - headers={"Authorization": "Bearer valid-write"}, - json={ - "tool": "create_issue", - "arguments": {"owner": "acme", "repo": "demo", "title": "test"}, - }, - ) - - assert response.status_code == 403 - assert "write mode is disabled" in response.json()["detail"].lower() - - -@pytest.mark.asyncio -async def test_startup_event_fails_when_discovery_unreachable( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Startup validation fails with clear guidance if OIDC discovery is unreachable.""" - monkeypatch.setenv("GITEA_URL", "https://gitea.example.com") - monkeypatch.setenv("ENVIRONMENT", "production") - monkeypatch.setenv("OAUTH_MODE", "true") - monkeypatch.setenv("GITEA_OAUTH_CLIENT_ID", "test-client-id") - monkeypatch.setenv("GITEA_OAUTH_CLIENT_SECRET", "test-client-secret") - monkeypatch.setenv("OAUTH_STATE_SECRET", "test-state-secret-0123456789abcdef") - monkeypatch.setenv("STARTUP_VALIDATE_GITEA", "true") - - from aegis_gitea_mcp import server - - with patch("aegis_gitea_mcp.server.httpx.AsyncClient") as mock_client_cls: - mock_client = AsyncMock() - mock_client.get = AsyncMock( - side_effect=httpx.RequestError("connect failed", request=MagicMock()) - ) - mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client) - mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False) - - with pytest.raises( - RuntimeError, - match="unable to reach Gitea OIDC discovery endpoint", - ): - await server.startup_event() - - -@pytest.mark.asyncio -async def test_startup_event_succeeds_when_discovery_ready( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Startup validation succeeds when OIDC discovery returns HTTP 200.""" - monkeypatch.setenv("GITEA_URL", "https://gitea.example.com") - monkeypatch.setenv("ENVIRONMENT", "production") - monkeypatch.setenv("OAUTH_MODE", "true") - monkeypatch.setenv("GITEA_OAUTH_CLIENT_ID", "test-client-id") - monkeypatch.setenv("GITEA_OAUTH_CLIENT_SECRET", "test-client-secret") - monkeypatch.setenv("OAUTH_STATE_SECRET", "test-state-secret-0123456789abcdef") - monkeypatch.setenv("STARTUP_VALIDATE_GITEA", "true") - - from aegis_gitea_mcp import server - - mock_response = MagicMock() - mock_response.status_code = 200 - - with patch("aegis_gitea_mcp.server.httpx.AsyncClient") as mock_client_cls: - mock_client = AsyncMock() - mock_client.get = AsyncMock(return_value=mock_response) - mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client) - mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False) - - await server.startup_event() - - -def test_probe_scopeless_token_returns_401( - oauth_env: None, mock_oauth_validation: None, monkeypatch: pytest.MonkeyPatch -) -> None: - """Middleware returns 401 with re-auth guidance when Gitea probe returns 403.""" - from aegis_gitea_mcp import server - - server._api_scope_cache.clear() - - mock_probe_response = MagicMock() - mock_probe_response.status_code = 403 - - with patch("aegis_gitea_mcp.server.httpx.AsyncClient") as mock_client_cls: - mock_client = AsyncMock() - mock_client.get = AsyncMock(return_value=mock_probe_response) - mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client) - mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False) - - from aegis_gitea_mcp.server import app - - client = TestClient(app, raise_server_exceptions=False) - response = client.post( - "/mcp/tool/call", - headers={"Authorization": "Bearer valid-read"}, - json={"tool": "list_repositories", "arguments": {}}, - ) - - assert response.status_code == 401 - assert "WWW-Authenticate" in response.headers - - -def test_probe_valid_token_proceeds( - oauth_env: None, mock_oauth_validation: None, monkeypatch: pytest.MonkeyPatch -) -> None: - """Middleware allows request through when Gitea probe returns 200.""" - from aegis_gitea_mcp import server - - server._api_scope_cache.clear() - - mock_probe_response = MagicMock() - mock_probe_response.status_code = 200 - - async def _fake_execute(tool_name: str, arguments: dict, correlation_id: str) -> dict: - return {"ok": True} - - monkeypatch.setattr("aegis_gitea_mcp.server._execute_tool_call", _fake_execute) - - with patch("aegis_gitea_mcp.server.httpx.AsyncClient") as mock_client_cls: - mock_client = AsyncMock() - mock_client.get = AsyncMock(return_value=mock_probe_response) - mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client) - mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False) - - from aegis_gitea_mcp.server import app - - client = TestClient(app, raise_server_exceptions=False) - response = client.post( - "/mcp/tool/call", - headers={"Authorization": "Bearer valid-read"}, - json={"tool": "list_repositories", "arguments": {}}, - ) - - assert response.status_code == 200 - assert response.json()["success"] is True - - -def test_gitea_authorization_error_returns_403( - oauth_env: None, mock_oauth_validation: None, monkeypatch: pytest.MonkeyPatch -) -> None: - """GiteaAuthorizationError from tool handler returns 403 with re-auth guidance.""" - from aegis_gitea_mcp import server - from aegis_gitea_mcp.gitea_client import GiteaAuthorizationError - - server._api_scope_cache.clear() - - async def _fake_execute(tool_name: str, arguments: dict, correlation_id: str) -> dict: - raise GiteaAuthorizationError("403 Forbidden") - - monkeypatch.setattr("aegis_gitea_mcp.server._execute_tool_call", _fake_execute) - - mock_probe_response = MagicMock() - mock_probe_response.status_code = 200 - - with patch("aegis_gitea_mcp.server.httpx.AsyncClient") as mock_client_cls: - mock_client = AsyncMock() - mock_client.get = AsyncMock(return_value=mock_probe_response) - mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client) - mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False) - - from aegis_gitea_mcp.server import app - - client = TestClient(app, raise_server_exceptions=False) - response = client.post( - "/mcp/tool/call", - headers={"Authorization": "Bearer valid-read"}, - json={"tool": "list_repositories", "arguments": {}}, - ) - - assert response.status_code == 403 - body = response.json() - assert body["success"] is False - assert "re-authorize" in body["error"].lower() - - -def test_gitea_authentication_error_returns_401( - oauth_env: None, mock_oauth_validation: None, monkeypatch: pytest.MonkeyPatch -) -> None: - """GiteaAuthenticationError from tool handler returns 401.""" - from aegis_gitea_mcp import server - from aegis_gitea_mcp.gitea_client import GiteaAuthenticationError - - server._api_scope_cache.clear() - - async def _fake_execute(tool_name: str, arguments: dict, correlation_id: str) -> dict: - raise GiteaAuthenticationError("401 Unauthorized") - - monkeypatch.setattr("aegis_gitea_mcp.server._execute_tool_call", _fake_execute) - - mock_probe_response = MagicMock() - mock_probe_response.status_code = 200 - - with patch("aegis_gitea_mcp.server.httpx.AsyncClient") as mock_client_cls: - mock_client = AsyncMock() - mock_client.get = AsyncMock(return_value=mock_probe_response) - mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client) - mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False) - - from aegis_gitea_mcp.server import app - - client = TestClient(app, raise_server_exceptions=False) - response = client.post( - "/mcp/tool/call", - headers={"Authorization": "Bearer valid-read"}, - json={"tool": "list_repositories", "arguments": {}}, - ) - - assert response.status_code == 401 - body = response.json() - assert body["success"] is False - assert "re-authenticate" in body["error"].lower() - - -def test_sse_gitea_authorization_error_returns_jsonrpc_error( - oauth_env: None, mock_oauth_validation: None, monkeypatch: pytest.MonkeyPatch -) -> None: - """GiteaAuthorizationError in SSE handler returns JSON-RPC -32000 with guidance.""" - from aegis_gitea_mcp import server - from aegis_gitea_mcp.gitea_client import GiteaAuthorizationError - - server._api_scope_cache.clear() - - async def _fake_execute(tool_name: str, arguments: dict, correlation_id: str) -> dict: - raise GiteaAuthorizationError("403 Forbidden") - - monkeypatch.setattr("aegis_gitea_mcp.server._execute_tool_call", _fake_execute) - - mock_probe_response = MagicMock() - mock_probe_response.status_code = 200 - - with patch("aegis_gitea_mcp.server.httpx.AsyncClient") as mock_client_cls: - mock_client = AsyncMock() - mock_client.get = AsyncMock(return_value=mock_probe_response) - mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client) - mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False) - - from aegis_gitea_mcp.server import app - - client = TestClient(app, raise_server_exceptions=False) - response = client.post( - "/mcp/sse", - headers={"Authorization": "Bearer valid-read"}, - json={ - "jsonrpc": "2.0", - "id": "err-1", - "method": "tools/call", - "params": {"name": "list_repositories", "arguments": {}}, - }, - ) - - assert response.status_code == 200 - body = response.json() - assert body["error"]["code"] == -32000 - assert "re-authorize" in body["error"]["message"].lower() diff --git a/tests/test_stdio_app.py b/tests/test_stdio_app.py deleted file mode 100644 index 601b84a..0000000 --- a/tests/test_stdio_app.py +++ /dev/null @@ -1,210 +0,0 @@ -"""Tests for the local stdio transport adapter.""" - -from __future__ import annotations - -from pathlib import Path -from unittest.mock import AsyncMock, patch - -import pytest - -from aegis_gitea_mcp import stdio_app -from aegis_gitea_mcp.config import reset_settings -from aegis_gitea_mcp.errors import ToolError -from aegis_gitea_mcp.request_context import get_gitea_user_login - - -@pytest.fixture -def stdio_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - """Local-mode settings: PAT auth, no OAuth, no API-key requirement.""" - reset_settings() - monkeypatch.setenv("GITEA_URL", "https://gitea.example.com") - monkeypatch.setenv("GITEA_TOKEN", "local-pat-token") - monkeypatch.setenv("AUTH_ENABLED", "false") - monkeypatch.setenv("ENVIRONMENT", "test") - monkeypatch.setenv("POLICY_FILE_PATH", str(tmp_path / "missing-policy.yaml")) - monkeypatch.setattr(stdio_app, "_owner_login", None) - - -def _patch_gitea_client(**methods: object) -> object: - """Patch GiteaClient with an async-context-manager mock exposing methods.""" - patcher = patch("aegis_gitea_mcp.gitea_client.GiteaClient") - cls = patcher.start() - instance = AsyncMock() - for name, value in methods.items(): - setattr(instance, name, AsyncMock(return_value=value)) - cls.return_value.__aenter__ = AsyncMock(return_value=instance) - cls.return_value.__aexit__ = AsyncMock(return_value=False) - return patcher - - -# --- Environment bootstrap -------------------------------------------------- - - -def test_bootstrap_forces_local_defaults(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("OAUTH_MODE", raising=False) - monkeypatch.delenv("AUTH_ENABLED", raising=False) - monkeypatch.delenv("AUDIT_LOG_PATH", raising=False) - stdio_app._bootstrap_env() - import os - - assert os.environ["OAUTH_MODE"] == "false" - assert os.environ["AUTH_ENABLED"] == "false" - assert os.environ["AUDIT_LOG_PATH"].endswith("audit.log") - - -def test_check_required_env_raises_when_missing(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("GITEA_URL", raising=False) - monkeypatch.delenv("GITEA_TOKEN", raising=False) - with pytest.raises(stdio_app.StdioConfigError) as exc_info: - stdio_app._check_required_env() - assert "GITEA_URL" in str(exc_info.value) - assert "GITEA_TOKEN" in str(exc_info.value) - - -def test_check_required_env_passes_when_present(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("GITEA_URL", "https://gitea.example.com") - monkeypatch.setenv("GITEA_TOKEN", "tok") - stdio_app._check_required_env() # no raise - - -def test_default_audit_log_path_is_user_scoped() -> None: - path = stdio_app._default_audit_log_path() - assert path.name == "audit.log" - assert "aegis-gitea-mcp" in str(path) - - -def test_configure_stderr_logging_keeps_stdout_clean(stdio_env: None) -> None: - """No log handler may target stdout (it is the JSON-RPC channel).""" - import logging - import sys - - # Simulate a library that attached a stdout handler. - root = logging.getLogger() - root.addHandler(logging.StreamHandler(sys.stdout)) - stdio_app._configure_stderr_logging() - for handler in logging.getLogger().handlers: - assert getattr(handler, "stream", sys.stderr) is not sys.stdout - - -def test_main_exits_when_env_missing(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("GITEA_URL", raising=False) - monkeypatch.delenv("GITEA_TOKEN", raising=False) - with pytest.raises(SystemExit) as exc_info: - stdio_app.main() - assert exc_info.value.code == 2 - - -# --- Owner resolution ------------------------------------------------------- - - -async def test_resolve_owner_login(stdio_env: None) -> None: - patcher = _patch_gitea_client(get_current_user={"login": "alice"}) - try: - login = await stdio_app._resolve_owner_login() - finally: - patcher.stop() - assert login == "alice" - - -async def test_resolve_owner_login_empty_raises(stdio_env: None) -> None: - patcher = _patch_gitea_client(get_current_user={"login": ""}) - try: - with pytest.raises(stdio_app.StdioConfigError): - await stdio_app._resolve_owner_login() - finally: - patcher.stop() - - -# --- Dispatch (shared registry + policy + audit) ---------------------------- - - -async def test_dispatch_unknown_tool(stdio_env: None) -> None: - with pytest.raises(ToolError) as exc_info: - await stdio_app._dispatch("nope_not_a_tool", {}) - assert exc_info.value.status_code == 404 - - -async def test_dispatch_policy_denies_write_without_write_mode(stdio_env: None) -> None: - """A write tool is denied by policy/WRITE_MODE before any network call.""" - with pytest.raises(ToolError) as exc_info: - await stdio_app._dispatch("create_issue", {"owner": "acme", "repo": "app", "title": "x"}) - assert exc_info.value.status_code == 403 - assert "write mode is disabled" in str(exc_info.value.detail) - - -async def test_dispatch_pins_owner_login_and_returns( - stdio_env: None, monkeypatch: pytest.MonkeyPatch -) -> None: - """Dispatch pins request context to the PAT owner and runs the shared handler.""" - monkeypatch.setattr(stdio_app, "_owner_login", "alice") - patcher = _patch_gitea_client( - get_repository={ - "owner": {"login": "acme"}, - "name": "app", - "full_name": "acme/app", - } - ) - try: - result = await stdio_app._dispatch("get_repository_info", {"owner": "acme", "repo": "app"}) - finally: - patcher.stop() - assert result["name"] == "app" - # The dispatch pinned the trusted PAT owner onto the request context. - assert get_gitea_user_login() == "alice" - - -# --- End-to-end over the MCP protocol (in-memory transport) ----------------- - - -async def test_build_server_exposes_registry_tools(stdio_env: None) -> None: - """build_server() wires every registry tool, including gitea_request.""" - from mcp.shared.memory import create_connected_server_and_client_session - - server = stdio_app.build_server() - async with create_connected_server_and_client_session(server) as client: - await client.initialize() - listed = await client.list_tools() - names = {t.name for t in listed.tools} - assert "get_repository_info" in names - assert "gitea_request" in names - assert len(names) >= 40 - - -async def test_stdio_tool_call_round_trip(stdio_env: None, monkeypatch: pytest.MonkeyPatch) -> None: - """A tools/call over the MCP protocol dispatches through the shared core.""" - from mcp.shared.memory import create_connected_server_and_client_session - - monkeypatch.setattr(stdio_app, "_owner_login", "alice") - patcher = _patch_gitea_client( - get_repository={"owner": {"login": "acme"}, "name": "app", "full_name": "acme/app"} - ) - try: - server = stdio_app.build_server() - async with create_connected_server_and_client_session(server) as client: - await client.initialize() - result = await client.call_tool("get_repository_info", {"owner": "acme", "repo": "app"}) - finally: - patcher.stop() - - assert result.isError is False - text = "".join(getattr(block, "text", "") for block in result.content) - assert "app" in text - - -async def test_stdio_tool_call_policy_denial_is_reported( - stdio_env: None, monkeypatch: pytest.MonkeyPatch -) -> None: - """A write tool denied by WRITE_MODE surfaces as an MCP error, not a crash.""" - from mcp.shared.memory import create_connected_server_and_client_session - - monkeypatch.setattr(stdio_app, "_owner_login", "alice") - server = stdio_app.build_server() - async with create_connected_server_and_client_session(server) as client: - await client.initialize() - result = await client.call_tool( - "create_issue", {"owner": "acme", "repo": "app", "title": "x"} - ) - - assert result.isError is True - text = "".join(getattr(block, "text", "") for block in result.content) - assert "write mode is disabled" in text diff --git a/tests/test_tools_extended.py b/tests/test_tools_extended.py deleted file mode 100644 index 4c93b3d..0000000 --- a/tests/test_tools_extended.py +++ /dev/null @@ -1,542 +0,0 @@ -"""Tests for expanded read/write MCP tool handlers.""" - -import pytest - -from aegis_gitea_mcp.config import reset_settings -from aegis_gitea_mcp.gitea_client import GiteaAuthenticationError, GiteaError -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.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, -) - - -@pytest.fixture(autouse=True) -def tool_env(monkeypatch: pytest.MonkeyPatch) -> None: - """Provide minimal settings environment for response limit helpers.""" - reset_settings() - monkeypatch.setenv("GITEA_URL", "https://gitea.example.com") - monkeypatch.setenv("GITEA_TOKEN", "test-token") - monkeypatch.setenv("MCP_API_KEYS", "a" * 64) - monkeypatch.setenv("ENVIRONMENT", "test") - - -class StubGitea: - """Stubbed Gitea client for tool unit tests.""" - - async def search_code(self, owner, repo, query, *, ref, page, limit): - return {"hits": [{"path": "src/main.py", "snippet": "match text", "score": 1.0}]} - - async def list_commits(self, owner, repo, *, ref, page, limit): - return [{"sha": "abc1234", "commit": {"message": "Fix bug", "author": {"date": "now"}}}] - - async def get_commit_diff(self, owner, repo, sha): - return { - "commit": {"message": "Fix bug"}, - "files": [{"filename": "a.py", "status": "modified"}], - } - - async def compare_refs(self, owner, repo, base, head): - return { - "commits": [{"sha": "abc", "commit": {"message": "Msg"}}], - "files": [{"filename": "a.py", "status": "modified"}], - } - - async def list_issues(self, owner, repo, *, state, page, limit, labels=None): - return [{"number": 1, "title": "Issue", "state": "open", "labels": []}] - - async def get_issue(self, owner, repo, index): - return {"number": index, "title": "Issue", "body": "Body", "state": "open", "labels": []} - - async def list_pull_requests(self, owner, repo, *, state, page, limit): - return [{"number": 1, "title": "PR", "state": "open"}] - - async def get_pull_request(self, owner, repo, index): - return {"number": index, "title": "PR", "body": "Body", "state": "open"} - - async def list_labels(self, owner, repo, *, page, limit): - return [{"id": 1, "name": "bug", "color": "ff0000", "description": "desc"}] - - async def list_tags(self, owner, repo, *, page, limit): - return [{"name": "v1.0.0", "commit": {"sha": "abc"}}] - - async def list_releases(self, owner, repo, *, page, limit): - return [{"id": 1, "tag_name": "v1.0.0", "name": "release"}] - - async def list_pull_request_files(self, owner, repo, index, *, page, limit): - return [{"filename": "a.py", "status": "modified", "additions": 1, "deletions": 0}] - - async def list_pull_request_commits(self, owner, repo, index, *, page, limit): - return [{"sha": "abc", "commit": {"message": "m"}, "author": {"login": "alice"}}] - - async def list_issue_comments(self, owner, repo, index, *, page, limit): - return [{"id": 1, "body": "hi", "user": {"login": "alice"}}] - - async def list_branches(self, owner, repo, *, page, limit): - return [{"name": "main", "protected": True, "commit": {"id": "abc"}}] - - async def get_branch(self, owner, repo, branch): - return {"name": branch, "protected": False, "commit": {"id": "abc"}} - - async def get_release(self, owner, repo, release_id): - return {"id": release_id, "tag_name": "v1", "name": "rel"} - - async def get_latest_release(self, owner, repo): - return {"id": 1, "tag_name": "v1", "name": "rel"} - - async def list_milestones(self, owner, repo, *, state, page, limit): - return [{"id": 1, "title": "M", "state": state}] - - async def get_commit_status(self, owner, repo, sha): - return {"state": "success", "statuses": [{"context": "ci", "status": "success"}]} - - async def list_org_repositories(self, org, *, page, limit): - return [{"name": "r", "owner": {"login": org}, "full_name": f"{org}/r"}] - - async def list_organizations(self, *, page, limit): - return [{"id": 1, "username": "acme", "description": "d"}] - - async def get_repo_languages(self, owner, repo): - return {"Python": 100, "HTML": 5} - - async def list_repo_topics(self, owner, repo): - return ["python", "mcp"] - - async def create_issue( - self, owner, repo, *, title, body, labels=None, assignees=None, milestone=None - ): - result = {"number": 1, "title": title, "state": "open"} - if milestone is not None: - result["milestone"] = {"id": 4, "title": str(milestone)} - return result - - async def update_issue( - self, owner, repo, index, *, title=None, body=None, state=None, milestone=None - ): - result = {"number": index, "title": title or "Issue", "state": state or "open"} - if milestone is not None: - result["milestone"] = {"id": 4, "title": str(milestone)} - return result - - async def create_issue_comment(self, owner, repo, index, body): - return {"id": 1, "body": body} - - async def create_pr_comment(self, owner, repo, index, body): - return {"id": 2, "body": body} - - async def add_labels(self, owner, repo, index, labels): - return {"labels": [{"name": label} for label in labels]} - - async def assign_issue(self, owner, repo, index, assignees): - return {"assignees": [{"login": user} for user in assignees]} - - async def create_label(self, owner, repo, *, name, color, description="", exclusive=False): - return {"id": 5, "name": name, "color": color, "description": description, "url": "u"} - - async def update_label(self, owner, repo, *, name, new_name=None, color=None, description=None): - return { - "id": 5, - "name": new_name or name, - "color": color or "#ffffff", - "description": description or "", - } - - async def remove_labels(self, owner, repo, index, labels): - return [] - - async def create_pull_request(self, owner, repo, *, title, head, base, body=""): - return {"number": 7, "title": title, "state": "open"} - - async def create_release( - self, owner, repo, *, tag_name, name="", body="", draft=False, prerelease=False, target=None - ): - return {"id": 3, "tag_name": tag_name, "name": name or tag_name} - - async def edit_release( - self, owner, repo, release_id, *, name=None, body=None, draft=None, prerelease=None - ): - return {"id": release_id, "tag_name": "v1", "name": name or "rel"} - - async def create_branch(self, owner, repo, *, new_branch_name, old_branch_name=None): - return {"name": new_branch_name, "commit": {"id": "abc"}} - - async def create_milestone(self, owner, repo, *, title, description="", due_on=None): - return {"id": 4, "title": title, "state": "open"} - - async def edit_issue_comment(self, owner, repo, comment_id, body): - return {"id": comment_id, "body": body} - - -class ErrorGitea(StubGitea): - """Stub that raises backend errors for failure-mode coverage.""" - - async def list_commits(self, owner, repo, *, ref, page, limit): - raise GiteaError("backend failure") - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "tool,args,expected_key", - [ - (search_code_tool, {"owner": "acme", "repo": "app", "query": "foo"}, "results"), - (list_commits_tool, {"owner": "acme", "repo": "app"}, "commits"), - (get_commit_diff_tool, {"owner": "acme", "repo": "app", "sha": "abc1234"}, "files"), - ( - compare_refs_tool, - {"owner": "acme", "repo": "app", "base": "main", "head": "feature"}, - "commits", - ), - (list_issues_tool, {"owner": "acme", "repo": "app"}, "issues"), - (get_issue_tool, {"owner": "acme", "repo": "app", "issue_number": 1}, "title"), - (list_pull_requests_tool, {"owner": "acme", "repo": "app"}, "pull_requests"), - (get_pull_request_tool, {"owner": "acme", "repo": "app", "pull_number": 1}, "title"), - (list_labels_tool, {"owner": "acme", "repo": "app"}, "labels"), - (list_tags_tool, {"owner": "acme", "repo": "app"}, "tags"), - (list_releases_tool, {"owner": "acme", "repo": "app"}, "releases"), - ( - list_pull_request_files_tool, - {"owner": "acme", "repo": "app", "pull_number": 1}, - "files", - ), - ( - list_pull_request_commits_tool, - {"owner": "acme", "repo": "app", "pull_number": 1}, - "commits", - ), - ( - list_issue_comments_tool, - {"owner": "acme", "repo": "app", "issue_number": 1}, - "comments", - ), - (list_branches_tool, {"owner": "acme", "repo": "app"}, "branches"), - (get_branch_tool, {"owner": "acme", "repo": "app", "branch": "main"}, "name"), - (get_release_tool, {"owner": "acme", "repo": "app", "release_id": 1}, "tag_name"), - (get_latest_release_tool, {"owner": "acme", "repo": "app"}, "tag_name"), - (list_milestones_tool, {"owner": "acme", "repo": "app"}, "milestones"), - ( - get_commit_status_tool, - {"owner": "acme", "repo": "app", "sha": "abc1234"}, - "state", - ), - (list_org_repositories_tool, {"org": "acme"}, "repositories"), - (list_organizations_tool, {}, "organizations"), - (get_repo_languages_tool, {"owner": "acme", "repo": "app"}, "languages"), - (list_repo_topics_tool, {"owner": "acme", "repo": "app"}, "topics"), - ], -) -async def test_extended_read_tools_success(tool, args, expected_key): - """Each expanded read tool should return expected top-level keys.""" - result = await tool(StubGitea(), args) - assert expected_key in result - - -@pytest.mark.asyncio -async def test_extended_read_tools_failure_mode() -> None: - """Expanded read tools should wrap backend failures.""" - with pytest.raises(RuntimeError): - await list_commits_tool(ErrorGitea(), {"owner": "acme", "repo": "app"}) - - -@pytest.mark.asyncio -async def test_get_issue_tolerates_null_collections() -> None: - """Regression for #13: Gitea may return null for labels/assignees/user. - - `.get(key, [])` returns None when the key is present with a null value, so - iterating the result raised `'NoneType' object is not iterable`. - """ - - class NullFieldsGitea(StubGitea): - async def get_issue(self, owner, repo, index): - return { - "number": index, - "title": "Issue", - "body": "Body", - "state": "open", - "user": None, - "labels": None, - "assignees": None, - } - - result = await get_issue_tool( - NullFieldsGitea(), {"owner": "acme", "repo": "app", "issue_number": 1} - ) - assert result["author"] == "" - assert result["labels"] == [] - assert result["assignees"] == [] - - -@pytest.mark.asyncio -async def test_get_issue_skips_non_dict_collection_elements() -> None: - """Defense-in-depth for #27: tolerate non-dict entries inside labels/assignees. - - A stray null/non-object element would otherwise raise AttributeError when - `.get()` is called on it, surfacing as an opaque internal error. - """ - - class MalformedElementsGitea(StubGitea): - async def get_issue(self, owner, repo, index): - return { - "number": index, - "title": "Issue", - "body": "Body", - "state": "open", - "user": {"login": "alice"}, - "labels": [{"name": "bug"}, None, "weird"], - "assignees": [{"login": "bob"}, None, 42], - } - - result = await get_issue_tool( - MalformedElementsGitea(), {"owner": "acme", "repo": "app", "issue_number": 1} - ) - assert result["labels"] == ["bug"] - assert result["assignees"] == ["bob"] - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "tool,args,expected_key", - [ - (create_issue_tool, {"owner": "acme", "repo": "app", "title": "Issue"}, "number"), - ( - update_issue_tool, - {"owner": "acme", "repo": "app", "issue_number": 1, "title": "Updated"}, - "number", - ), - ( - create_issue_comment_tool, - {"owner": "acme", "repo": "app", "issue_number": 1, "body": "comment"}, - "id", - ), - ( - create_pr_comment_tool, - {"owner": "acme", "repo": "app", "pull_number": 1, "body": "comment"}, - "id", - ), - ( - add_labels_tool, - {"owner": "acme", "repo": "app", "issue_number": 1, "labels": ["bug"]}, - "labels", - ), - ( - assign_issue_tool, - {"owner": "acme", "repo": "app", "issue_number": 1, "assignees": ["alice"]}, - "assignees", - ), - ( - create_label_tool, - {"owner": "acme", "repo": "app", "name": "bug", "color": "#ff0000"}, - "id", - ), - ( - update_label_tool, - {"owner": "acme", "repo": "app", "name": "bug", "new_name": "defect"}, - "id", - ), - ( - remove_labels_tool, - {"owner": "acme", "repo": "app", "issue_number": 1, "labels": ["bug"]}, - "removed", - ), - ( - create_pull_request_tool, - {"owner": "acme", "repo": "app", "title": "PR", "head": "feature", "base": "main"}, - "number", - ), - ( - create_release_tool, - {"owner": "acme", "repo": "app", "tag_name": "v1.0.0"}, - "id", - ), - ( - edit_release_tool, - {"owner": "acme", "repo": "app", "release_id": 3, "name": "x"}, - "id", - ), - ( - create_branch_tool, - {"owner": "acme", "repo": "app", "new_branch_name": "feature/x"}, - "name", - ), - ( - create_milestone_tool, - {"owner": "acme", "repo": "app", "title": "M1"}, - "id", - ), - ( - edit_issue_comment_tool, - {"owner": "acme", "repo": "app", "comment_id": 5, "body": "edited"}, - "id", - ), - ], -) -async def test_write_tools_success(tool, args, expected_key): - """Write tools should normalize successful backend responses.""" - result = await tool(StubGitea(), args) - assert expected_key in result - - -@pytest.mark.asyncio -async def test_create_label_normalizes_color_without_hash() -> None: - """A hex color without a leading '#' is normalized before hitting Gitea.""" - captured: dict = {} - - class CaptureStub(StubGitea): - async def create_label(self, owner, repo, *, name, color, description="", exclusive=False): - captured["color"] = color - return {"id": 7, "name": name, "color": color} - - result = await create_label_tool( - CaptureStub(), - {"owner": "acme", "repo": "app", "name": "bug", "color": "ff0000"}, - ) - assert captured["color"] == "#ff0000" - assert result["id"] == 7 - - -def test_create_label_args_reject_invalid_color() -> None: - """Non-hex color values are rejected at the argument layer.""" - import pydantic - - from aegis_gitea_mcp.tools.arguments import CreateLabelArgs - - with pytest.raises(pydantic.ValidationError): - CreateLabelArgs(owner="o", repo="r", name="bug", color="red") - - -@pytest.mark.asyncio -async def test_create_issue_returns_assigned_milestone_title() -> None: - """create_issue surfaces the assigned milestone title in its response.""" - result = await create_issue_tool( - StubGitea(), - {"owner": "acme", "repo": "app", "title": "Issue", "milestone": "Sprint 1"}, - ) - assert result["milestone"] == "Sprint 1" - - -@pytest.mark.asyncio -async def test_update_issue_accepts_milestone_only() -> None: - """update_issue may change only the milestone (no title/body/state needed).""" - result = await update_issue_tool( - StubGitea(), - {"owner": "acme", "repo": "app", "issue_number": 1, "milestone": 4}, - ) - assert result["milestone"] == "4" - - -def test_update_issue_args_require_a_changed_field() -> None: - """An update with no mutable field (incl. milestone) is rejected.""" - import pydantic - - from aegis_gitea_mcp.tools.arguments import UpdateIssueArgs - - with pytest.raises(pydantic.ValidationError): - UpdateIssueArgs(owner="o", repo="r", issue_number=1) - # Supplying only a milestone satisfies the change requirement. - assert UpdateIssueArgs(owner="o", repo="r", issue_number=1, milestone=0).milestone == 0 - - -def test_issue_args_reject_boolean_milestone() -> None: - """A boolean is rejected as a milestone reference (it subclasses int).""" - import pydantic - - from aegis_gitea_mcp.tools.arguments import CreateIssueArgs - - with pytest.raises(pydantic.ValidationError): - CreateIssueArgs(owner="o", repo="r", title="x", milestone=True) - - -# (tool, valid_args) for every write tool, used to exercise error branches. -WRITE_TOOL_ERROR_CASES = [ - (create_issue_tool, {"owner": "acme", "repo": "app", "title": "Issue"}), - (update_issue_tool, {"owner": "acme", "repo": "app", "issue_number": 1, "title": "x"}), - (create_issue_comment_tool, {"owner": "acme", "repo": "app", "issue_number": 1, "body": "c"}), - (create_pr_comment_tool, {"owner": "acme", "repo": "app", "pull_number": 1, "body": "c"}), - (add_labels_tool, {"owner": "acme", "repo": "app", "issue_number": 1, "labels": ["bug"]}), - (assign_issue_tool, {"owner": "acme", "repo": "app", "issue_number": 1, "assignees": ["al"]}), - (create_label_tool, {"owner": "acme", "repo": "app", "name": "bug", "color": "#ff0000"}), - (update_label_tool, {"owner": "acme", "repo": "app", "name": "bug", "new_name": "defect"}), - (remove_labels_tool, {"owner": "acme", "repo": "app", "issue_number": 1, "labels": ["bug"]}), - ( - create_pull_request_tool, - {"owner": "acme", "repo": "app", "title": "PR", "head": "feature", "base": "main"}, - ), - (create_release_tool, {"owner": "acme", "repo": "app", "tag_name": "v1.0.0"}), - (edit_release_tool, {"owner": "acme", "repo": "app", "release_id": 3, "name": "x"}), - (create_branch_tool, {"owner": "acme", "repo": "app", "new_branch_name": "feature/x"}), - (create_milestone_tool, {"owner": "acme", "repo": "app", "title": "M1"}), - (edit_issue_comment_tool, {"owner": "acme", "repo": "app", "comment_id": 5, "body": "e"}), -] - - -class _WriteBackendErrorGitea: - """Stub whose every method raises a generic Gitea backend error.""" - - def __getattr__(self, name: str): - async def _raise(*args: object, **kwargs: object) -> object: - raise GiteaError("backend failure") - - return _raise - - -class _WriteAuthErrorGitea: - """Stub whose every method raises an authentication error.""" - - def __getattr__(self, name: str): - async def _raise(*args: object, **kwargs: object) -> object: - raise GiteaAuthenticationError("token expired") - - return _raise - - -@pytest.mark.asyncio -@pytest.mark.parametrize("tool,args", WRITE_TOOL_ERROR_CASES) -async def test_write_tools_wrap_backend_errors(tool, args) -> None: - """Every write tool wraps a backend GiteaError as a RuntimeError.""" - with pytest.raises(RuntimeError): - await tool(_WriteBackendErrorGitea(), args) - - -@pytest.mark.asyncio -@pytest.mark.parametrize("tool,args", WRITE_TOOL_ERROR_CASES) -async def test_write_tools_propagate_auth_errors(tool, args) -> None: - """Every write tool lets auth failures surface for re-authorization.""" - with pytest.raises(GiteaAuthenticationError): - await tool(_WriteAuthErrorGitea(), args)