feat: new settings, CLI skeleton and interface skeletons
Adds the pydantic-settings Settings with validated GITEA_URL, OAuth scopes and toolsets, the argparse CLI skeleton, catalog.model, identity and GiteaAPIError. Test fixtures now isolate env, cwd and state paths in tmp.
This commit is contained in:
1 parent
4ac9626eb5
commit
b82e2866cb
24 files changed
+1512
-605
No files matched your search
+290
-360
@@ -1,15 +1,106 @@
|
||||
"""Configuration management for AegisGitea MCP server."""
|
||||
"""Configuration management for AegisGitea MCP.
|
||||
|
||||
All settings come from environment variables (case-insensitive) or a ``.env``
|
||||
file in the working directory. Secrets are ``SecretStr`` so they never appear in
|
||||
``repr()`` or model dumps. Requirements that depend on how the server is started
|
||||
(for example ``GITEA_TOKEN`` for stdio) are checked at serve time, not on import.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any, Literal
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from pydantic import Field, HttpUrl, field_validator, model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from pydantic import Field, SecretStr, field_validator
|
||||
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
|
||||
|
||||
_ALLOWED_LOG_LEVELS = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
|
||||
_ALLOWED_SECRET_MODES = {"off", "mask", "block"}
|
||||
_ALLOWED_ENVIRONMENTS = {"development", "staging", "production", "test"}
|
||||
ALLOWED_LOG_LEVELS = frozenset({"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"})
|
||||
|
||||
#: Tool domains accepted by ``TOOLSETS`` (plus the ``all`` shortcut).
|
||||
TOOL_DOMAINS = frozenset(
|
||||
{
|
||||
"admin",
|
||||
"repo",
|
||||
"issue",
|
||||
"pull",
|
||||
"release",
|
||||
"wiki",
|
||||
"org",
|
||||
"team",
|
||||
"user",
|
||||
"actions",
|
||||
"hook",
|
||||
"package",
|
||||
"notification",
|
||||
"misc",
|
||||
}
|
||||
)
|
||||
|
||||
#: Granular Gitea OAuth scopes. Gitea grants full access for unknown scope
|
||||
#: strings, so anything outside this set is rejected up front.
|
||||
GITEA_SCOPE_CATEGORIES = (
|
||||
"activitypub",
|
||||
"admin",
|
||||
"issue",
|
||||
"misc",
|
||||
"notification",
|
||||
"organization",
|
||||
"package",
|
||||
"repository",
|
||||
"user",
|
||||
)
|
||||
GITEA_SCOPES = frozenset(
|
||||
f"{access}:{category}" for access in ("read", "write") for category in GITEA_SCOPE_CATEGORIES
|
||||
)
|
||||
|
||||
DEFAULT_OAUTH_SCOPES = ",".join(f"write:{category}" for category in GITEA_SCOPE_CATEGORIES)
|
||||
|
||||
_MIN_SECRET_CHARS = 32
|
||||
_LOOPBACK_NAMES = frozenset({"localhost"})
|
||||
|
||||
|
||||
def default_data_dir() -> Path:
|
||||
"""Return the default state directory (``~/.local/state/aegis-gitea-mcp``)."""
|
||||
return Path("~/.local/state/aegis-gitea-mcp").expanduser()
|
||||
|
||||
|
||||
def is_loopback_host(host: str) -> bool:
|
||||
"""Return True when ``host`` is localhost or a loopback IP (127.0.0.0/8, ::1)."""
|
||||
candidate = host.strip().strip("[]").lower()
|
||||
if candidate in _LOOPBACK_NAMES:
|
||||
return True
|
||||
try:
|
||||
return ipaddress.ip_address(candidate).is_loopback
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _split_list(value: Any) -> Any:
|
||||
"""Split a comma or whitespace separated string into a list of stripped items."""
|
||||
if isinstance(value, str):
|
||||
return [item for item in value.replace(",", " ").split() if item]
|
||||
return value
|
||||
|
||||
|
||||
def _normalize_http_url(value: str, *, field: str) -> str:
|
||||
"""Validate an http(s) URL and return it without query, fragment or trailing slash."""
|
||||
candidate = value.strip()
|
||||
parts = urlsplit(candidate)
|
||||
if parts.scheme not in {"http", "https"}:
|
||||
raise ValueError(f"{field} must start with http:// or https://")
|
||||
if not parts.hostname:
|
||||
raise ValueError(f"{field} must include a host")
|
||||
if parts.username is not None or parts.password is not None:
|
||||
raise ValueError(f"{field} must not contain credentials (user:password@)")
|
||||
if parts.query or parts.fragment or "?" in candidate or "#" in candidate:
|
||||
raise ValueError(f"{field} must not contain a query string or fragment")
|
||||
try:
|
||||
parts.port # noqa: B018 (accessing .port validates it)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"{field} has an invalid port") from exc
|
||||
return candidate.rstrip("/")
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
@@ -20,392 +111,231 @@ class Settings(BaseSettings):
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
env_parse_none_str="null",
|
||||
)
|
||||
|
||||
# Runtime environment
|
||||
environment: str = Field(
|
||||
default="production",
|
||||
description="Runtime environment name",
|
||||
)
|
||||
|
||||
# Gitea configuration
|
||||
gitea_url: HttpUrl = Field(..., description="Base URL of the Gitea instance")
|
||||
gitea_token: str = Field(
|
||||
default="",
|
||||
description=("Deprecated shared bot token. Not used for MCP tool execution in OAuth mode."),
|
||||
)
|
||||
|
||||
# MCP server configuration
|
||||
mcp_host: str = Field(
|
||||
default="127.0.0.1",
|
||||
description="Host interface to bind MCP server to",
|
||||
)
|
||||
mcp_port: int = Field(default=8080, description="Port to bind MCP server to", ge=1, le=65535)
|
||||
allow_insecure_bind: bool = Field(
|
||||
default=False,
|
||||
description="Allow binding to 0.0.0.0 (disabled by default for local hardening)",
|
||||
)
|
||||
public_base_url: HttpUrl | None = Field(
|
||||
# Gitea
|
||||
gitea_url: str = Field(..., description="Base URL of the Gitea instance")
|
||||
gitea_token: SecretStr | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Public externally-reachable base URL for this MCP server. "
|
||||
"When set, OAuth metadata endpoints use this URL for absolute links."
|
||||
),
|
||||
description="Gitea token for stdio and admin-token mode (checked at serve time)",
|
||||
)
|
||||
|
||||
# Logging and observability
|
||||
log_level: str = Field(default="INFO", description="Application logging level")
|
||||
audit_log_path: Path = Field(
|
||||
default=Path("/var/log/aegis-mcp/audit.log"),
|
||||
description="Path to tamper-evident audit log file",
|
||||
)
|
||||
metrics_enabled: bool = Field(default=True, description="Enable Prometheus metrics endpoint")
|
||||
expose_error_details: bool = Field(
|
||||
default=False,
|
||||
description="Return internal error details in API responses (disabled by default)",
|
||||
)
|
||||
startup_validate_gitea: bool = Field(
|
||||
default=True,
|
||||
description="Validate Gitea connectivity during startup",
|
||||
# MCP transport
|
||||
mcp_transport: Literal["stdio", "http"] = Field(default="stdio")
|
||||
mcp_host: str = Field(default="127.0.0.1", min_length=1)
|
||||
mcp_port: int = Field(default=8080, ge=1, le=65535)
|
||||
mcp_path: str = Field(default="/mcp")
|
||||
mcp_bearer_token: SecretStr | None = Field(default=None)
|
||||
public_base_url: str | None = Field(default=None)
|
||||
allowed_hosts: Annotated[list[str], NoDecode] = Field(default_factory=list)
|
||||
|
||||
# Per-user OAuth (HTTP only)
|
||||
oauth_enabled: bool = Field(default=False)
|
||||
gitea_oauth_client_id: str | None = Field(default=None)
|
||||
gitea_oauth_client_secret: SecretStr | None = Field(default=None)
|
||||
token_encryption_key: SecretStr | None = Field(default=None)
|
||||
oauth_scopes: Annotated[list[str], NoDecode] = Field(
|
||||
default_factory=lambda: DEFAULT_OAUTH_SCOPES.split(",")
|
||||
)
|
||||
|
||||
# Security limits
|
||||
max_file_size_bytes: int = Field(
|
||||
default=1_048_576,
|
||||
description="Maximum file size that can be read (bytes)",
|
||||
ge=1,
|
||||
)
|
||||
request_timeout_seconds: int = Field(
|
||||
default=30,
|
||||
description="Timeout for Gitea API requests (seconds)",
|
||||
ge=1,
|
||||
)
|
||||
rate_limit_per_minute: int = Field(
|
||||
default=60,
|
||||
description="Maximum requests per minute for a single IP",
|
||||
ge=1,
|
||||
)
|
||||
token_rate_limit_per_minute: int = Field(
|
||||
default=120,
|
||||
description="Maximum requests per minute per authenticated token",
|
||||
ge=1,
|
||||
)
|
||||
max_tool_response_items: int = Field(
|
||||
default=200,
|
||||
description="Maximum list items returned by a tool response",
|
||||
ge=1,
|
||||
)
|
||||
max_tool_response_chars: int = Field(
|
||||
default=20_000,
|
||||
description="Maximum characters returned in text fields",
|
||||
ge=1,
|
||||
)
|
||||
secret_detection_mode: str = Field(
|
||||
default="mask",
|
||||
description="Secret detection mode: off, mask, or block",
|
||||
)
|
||||
# State
|
||||
data_dir: Path = Field(default_factory=default_data_dir)
|
||||
audit_log_path: Path | None = Field(default=None)
|
||||
|
||||
# OAuth2 configuration (for per-client Gitea authentication)
|
||||
oauth_mode: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"Enable per-user OAuth2 authentication mode. "
|
||||
"When true, each client user authenticates with their own Gitea account. "
|
||||
"GITEA_TOKEN and MCP_API_KEYS are not required in this mode."
|
||||
),
|
||||
)
|
||||
gitea_oauth_client_id: str = Field(
|
||||
default="",
|
||||
description="Gitea OAuth2 application client ID (required when oauth_mode=true)",
|
||||
)
|
||||
gitea_oauth_client_secret: str = Field(
|
||||
default="",
|
||||
description="Gitea OAuth2 application client secret (required when oauth_mode=true)",
|
||||
)
|
||||
oauth_expected_audience: str = Field(
|
||||
default="",
|
||||
description=(
|
||||
"Additional expected OIDC audience for access tokens. The canonical MCP "
|
||||
"resource URL and the Gitea OAuth client id are always accepted; set this "
|
||||
"to require an extra audience value."
|
||||
),
|
||||
)
|
||||
oauth_cache_ttl_seconds: int = Field(
|
||||
default=300,
|
||||
description="OIDC discovery/JWKS cache TTL in seconds",
|
||||
ge=30,
|
||||
)
|
||||
oauth_resource_documentation: str = Field(
|
||||
default="https://hiddenden.cafe/docs/mcp-gitea",
|
||||
description="Public documentation URL for OAuth-protected MCP resource behavior",
|
||||
)
|
||||
oauth_state_secret: str = Field(
|
||||
default="",
|
||||
description=(
|
||||
"Server secret used to HMAC-sign the OAuth proxy state parameter. "
|
||||
"Required when OAUTH_MODE=true so callback state is tamper-evident."
|
||||
),
|
||||
)
|
||||
oauth_redirect_allowlist_raw: str = Field(
|
||||
default="",
|
||||
description=(
|
||||
"Comma-separated additional allowed client redirect URIs for the OAuth "
|
||||
"callback proxy. Claude's callback URLs and loopback URIs are always allowed."
|
||||
),
|
||||
alias="OAUTH_REDIRECT_ALLOWLIST",
|
||||
)
|
||||
dcr_enabled: bool = Field(
|
||||
default=True,
|
||||
description=(
|
||||
"Enable RFC 7591 Dynamic Client Registration at /register. Claude's "
|
||||
"connectors register dynamically; disable to require manual client_id/secret."
|
||||
),
|
||||
)
|
||||
dcr_storage_path: Path = Field(
|
||||
default=Path("/var/lib/aegis-mcp/dcr_clients.json"),
|
||||
description="Path to the JSON file that persists dynamically registered clients",
|
||||
)
|
||||
repo_authz_cache_ttl_seconds: int = Field(
|
||||
default=60,
|
||||
description="TTL (seconds) for cached per-user repository permission decisions",
|
||||
ge=1,
|
||||
)
|
||||
# Tool exposure
|
||||
read_only: bool = Field(default=False)
|
||||
toolsets: Annotated[list[str], NoDecode] = Field(default_factory=lambda: ["all"])
|
||||
disabled_tools: Annotated[list[str], NoDecode] = Field(default_factory=list)
|
||||
|
||||
# Authentication configuration
|
||||
auth_enabled: bool = Field(
|
||||
default=True,
|
||||
description="Enable API key authentication (disable only in controlled testing)",
|
||||
)
|
||||
mcp_api_keys_raw: str = Field(
|
||||
default="",
|
||||
description="Comma-separated API keys for MCP access",
|
||||
alias="MCP_API_KEYS",
|
||||
)
|
||||
max_auth_failures: int = Field(
|
||||
default=5,
|
||||
description="Maximum authentication failures before auth rate limiting",
|
||||
ge=1,
|
||||
)
|
||||
auth_failure_window: int = Field(
|
||||
default=300,
|
||||
description="Time window for counting auth failures (seconds)",
|
||||
ge=1,
|
||||
)
|
||||
# Limits and safety
|
||||
request_timeout_seconds: int = Field(default=30, ge=1, le=600)
|
||||
max_tool_response_items: int = Field(default=200, ge=1)
|
||||
max_tool_response_chars: int = Field(default=60_000, ge=1)
|
||||
max_binary_bytes: int = Field(default=5_242_880, ge=1)
|
||||
secret_detection_mode: Literal["off", "mask", "block"] = Field(default="mask")
|
||||
log_level: str = Field(default="INFO")
|
||||
expose_error_details: bool = Field(default=False)
|
||||
rate_limit_per_minute: int = Field(default=120, ge=1)
|
||||
|
||||
# Policy and write-mode configuration
|
||||
policy_file_path: Path = Field(
|
||||
default=Path("policy.yaml"),
|
||||
description="Path to YAML authorization policy file",
|
||||
)
|
||||
write_mode: bool = Field(default=False, description="Enable write-capable tools")
|
||||
write_repository_whitelist_raw: str = Field(
|
||||
default="",
|
||||
description="Comma-separated repository whitelist for write mode (owner/repo)",
|
||||
alias="WRITE_REPOSITORY_WHITELIST",
|
||||
)
|
||||
write_allow_all_token_repos: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"Allow write-mode operations on any repository the token can access. "
|
||||
"Disabled by default."
|
||||
),
|
||||
)
|
||||
# Raw API dispatch (gitea_request escape hatch)
|
||||
raw_api_enabled: bool = Field(
|
||||
default=True,
|
||||
description="Enable the generic gitea_request raw API dispatch tool",
|
||||
)
|
||||
raw_api_allow_sensitive: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"Allow gitea_request to reach admin/credential endpoints "
|
||||
"(/admin, *tokens*, *secrets*, *hooks*, *keys*, applications/oauth2, "
|
||||
"runner registration tokens). Disabled by default."
|
||||
),
|
||||
)
|
||||
automation_enabled: bool = Field(
|
||||
default=False,
|
||||
description="Enable automation endpoints and workflows",
|
||||
)
|
||||
automation_scheduler_enabled: bool = Field(
|
||||
default=False,
|
||||
description="Enable built-in scheduled job loop",
|
||||
)
|
||||
automation_stale_days: int = Field(
|
||||
default=30,
|
||||
description="Number of days before an issue is considered stale",
|
||||
ge=1,
|
||||
)
|
||||
# Validators
|
||||
|
||||
@field_validator("environment")
|
||||
@field_validator(
|
||||
"gitea_token",
|
||||
"mcp_bearer_token",
|
||||
"public_base_url",
|
||||
"gitea_oauth_client_id",
|
||||
"gitea_oauth_client_secret",
|
||||
"token_encryption_key",
|
||||
"audit_log_path",
|
||||
mode="before",
|
||||
)
|
||||
@classmethod
|
||||
def validate_environment(cls, value: str) -> str:
|
||||
"""Validate deployment environment name."""
|
||||
normalized = value.strip().lower()
|
||||
if normalized not in _ALLOWED_ENVIRONMENTS:
|
||||
raise ValueError(f"environment must be one of {_ALLOWED_ENVIRONMENTS}")
|
||||
return normalized
|
||||
|
||||
@field_validator("log_level")
|
||||
@classmethod
|
||||
def validate_log_level(cls, value: str) -> str:
|
||||
"""Validate log level is one of the allowed values."""
|
||||
normalized = value.upper()
|
||||
if normalized not in _ALLOWED_LOG_LEVELS:
|
||||
raise ValueError(f"log_level must be one of {_ALLOWED_LOG_LEVELS}")
|
||||
return normalized
|
||||
|
||||
@field_validator("public_base_url", mode="before")
|
||||
@classmethod
|
||||
def normalize_public_base_url(cls, value: object) -> object:
|
||||
"""Treat empty PUBLIC_BASE_URL as unset."""
|
||||
def _blank_is_none(cls, value: Any) -> Any:
|
||||
"""Treat an empty or whitespace-only value (``KEY=`` in .env) as unset."""
|
||||
if isinstance(value, str) and not value.strip():
|
||||
return None
|
||||
return value
|
||||
|
||||
@field_validator("gitea_token")
|
||||
@field_validator("gitea_url")
|
||||
@classmethod
|
||||
def validate_token_not_empty(cls, value: str) -> str:
|
||||
"""Validate Gitea token is trimmed (empty string allowed for oauth_mode)."""
|
||||
cleaned = value.strip()
|
||||
if value and not cleaned:
|
||||
raise ValueError("gitea_token cannot be whitespace-only")
|
||||
return cleaned
|
||||
|
||||
@field_validator("secret_detection_mode")
|
||||
@classmethod
|
||||
def validate_secret_detection_mode(cls, value: str) -> str:
|
||||
"""Validate secret detection behavior setting."""
|
||||
normalized = value.lower().strip()
|
||||
if normalized not in _ALLOWED_SECRET_MODES:
|
||||
raise ValueError(f"secret_detection_mode must be one of {_ALLOWED_SECRET_MODES}")
|
||||
def _validate_gitea_url(cls, value: str) -> str:
|
||||
"""Normalize the Gitea base URL; a trailing ``/api/v1`` is accepted and removed."""
|
||||
normalized = _normalize_http_url(value, field="GITEA_URL")
|
||||
if normalized.endswith("/api/v1"):
|
||||
normalized = normalized[: -len("/api/v1")].rstrip("/")
|
||||
if not urlsplit(normalized).hostname: # pragma: no cover - defensive
|
||||
raise ValueError("GITEA_URL must include a host")
|
||||
return normalized
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_security_constraints(self) -> Settings:
|
||||
"""Validate cross-field security constraints."""
|
||||
parsed_keys: list[str] = []
|
||||
if self.mcp_api_keys_raw.strip():
|
||||
parsed_keys = [
|
||||
value.strip() for value in self.mcp_api_keys_raw.split(",") if value.strip()
|
||||
]
|
||||
|
||||
object.__setattr__(self, "_mcp_api_keys", parsed_keys)
|
||||
|
||||
write_repositories: list[str] = []
|
||||
if self.write_repository_whitelist_raw.strip():
|
||||
write_repositories = [
|
||||
value.strip()
|
||||
for value in self.write_repository_whitelist_raw.split(",")
|
||||
if value.strip()
|
||||
]
|
||||
|
||||
for repository in write_repositories:
|
||||
if "/" not in repository:
|
||||
raise ValueError("WRITE_REPOSITORY_WHITELIST entries must be in owner/repo format")
|
||||
|
||||
object.__setattr__(self, "_write_repository_whitelist", write_repositories)
|
||||
|
||||
# Security decision: binding all interfaces requires explicit opt-in.
|
||||
if self.mcp_host == "0.0.0.0" and not self.allow_insecure_bind:
|
||||
raise ValueError(
|
||||
"Binding to 0.0.0.0 is blocked by default. "
|
||||
"Set ALLOW_INSECURE_BIND=true to explicitly permit this."
|
||||
)
|
||||
|
||||
extra_redirect_uris: list[str] = []
|
||||
if self.oauth_redirect_allowlist_raw.strip():
|
||||
extra_redirect_uris = [
|
||||
value.strip()
|
||||
for value in self.oauth_redirect_allowlist_raw.split(",")
|
||||
if value.strip()
|
||||
]
|
||||
object.__setattr__(self, "_oauth_redirect_allowlist", extra_redirect_uris)
|
||||
|
||||
if self.oauth_mode:
|
||||
# In OAuth mode, per-user Gitea tokens are used; no shared bot token or API keys needed.
|
||||
if not self.gitea_oauth_client_id.strip():
|
||||
raise ValueError("GITEA_OAUTH_CLIENT_ID is required when OAUTH_MODE=true.")
|
||||
if not self.gitea_oauth_client_secret.strip():
|
||||
raise ValueError("GITEA_OAUTH_CLIENT_SECRET is required when OAUTH_MODE=true.")
|
||||
# The proxy state parameter carries the client's redirect_uri across the Gitea
|
||||
# round-trip; it must be HMAC-signed, which requires a server-held secret.
|
||||
if not self.oauth_state_secret.strip():
|
||||
raise ValueError(
|
||||
"OAUTH_STATE_SECRET is required when OAUTH_MODE=true so the OAuth "
|
||||
"proxy state parameter can be HMAC-signed and verified."
|
||||
)
|
||||
# A short secret weakens the HMAC; require the same 32-char floor as API keys.
|
||||
if len(self.oauth_state_secret.strip()) < 32:
|
||||
raise ValueError(
|
||||
"OAUTH_STATE_SECRET must be at least 32 characters long "
|
||||
"(e.g. `openssl rand -hex 32`)."
|
||||
)
|
||||
else:
|
||||
# Standard API key mode: require bot token and at least one API key.
|
||||
if not self.gitea_token.strip():
|
||||
raise ValueError("GITEA_TOKEN is required unless OAUTH_MODE=true.")
|
||||
if self.auth_enabled and not parsed_keys:
|
||||
raise ValueError(
|
||||
"At least one API key must be configured when auth_enabled=True. "
|
||||
"Set MCP_API_KEYS or disable auth explicitly for controlled testing."
|
||||
)
|
||||
|
||||
# Enforce minimum key length to reduce brute-force success probability.
|
||||
for key in parsed_keys:
|
||||
if len(key) < 32:
|
||||
raise ValueError("API keys must be at least 32 characters long")
|
||||
|
||||
if self.write_mode and not self.write_allow_all_token_repos and not write_repositories:
|
||||
raise ValueError(
|
||||
"WRITE_MODE=true requires WRITE_REPOSITORY_WHITELIST to be configured "
|
||||
"unless WRITE_ALLOW_ALL_TOKEN_REPOS=true"
|
||||
)
|
||||
|
||||
return self
|
||||
|
||||
@property
|
||||
def mcp_api_keys(self) -> list[str]:
|
||||
"""Get parsed list of API keys."""
|
||||
return list(getattr(self, "_mcp_api_keys", []))
|
||||
|
||||
@property
|
||||
def write_repository_whitelist(self) -> list[str]:
|
||||
"""Get parsed list of repositories allowed for write-mode operations."""
|
||||
return list(getattr(self, "_write_repository_whitelist", []))
|
||||
|
||||
@property
|
||||
def oauth_redirect_allowlist(self) -> list[str]:
|
||||
"""Get parsed list of additional allowed client redirect URIs."""
|
||||
return list(getattr(self, "_oauth_redirect_allowlist", []))
|
||||
|
||||
@property
|
||||
def gitea_base_url(self) -> str:
|
||||
"""Get Gitea base URL as normalized string."""
|
||||
return str(self.gitea_url).rstrip("/")
|
||||
|
||||
@property
|
||||
def public_base(self) -> str | None:
|
||||
"""Get normalized public base URL when explicitly configured."""
|
||||
if self.public_base_url is None:
|
||||
@field_validator("public_base_url")
|
||||
@classmethod
|
||||
def _validate_public_base_url(cls, value: str | None) -> str | None:
|
||||
"""Require https for the public URL unless it points at a loopback host."""
|
||||
if value is None:
|
||||
return None
|
||||
return str(self.public_base_url).rstrip("/")
|
||||
normalized = _normalize_http_url(value, field="PUBLIC_BASE_URL")
|
||||
parts = urlsplit(normalized)
|
||||
if parts.scheme != "https" and not is_loopback_host(parts.hostname or ""):
|
||||
raise ValueError("PUBLIC_BASE_URL must use https unless the host is loopback")
|
||||
return normalized
|
||||
|
||||
@field_validator("mcp_path")
|
||||
@classmethod
|
||||
def _validate_mcp_path(cls, value: str) -> str:
|
||||
"""Require an absolute path without query or trailing slash."""
|
||||
candidate = value.strip()
|
||||
if not candidate.startswith("/") or candidate.startswith("//"):
|
||||
raise ValueError("MCP_PATH must start with a single '/'")
|
||||
if "?" in candidate or "#" in candidate or " " in candidate:
|
||||
raise ValueError("MCP_PATH must be a plain path")
|
||||
candidate = candidate.rstrip("/")
|
||||
if not candidate:
|
||||
raise ValueError("MCP_PATH must not be '/'")
|
||||
return candidate
|
||||
|
||||
@field_validator("mcp_bearer_token", "token_encryption_key")
|
||||
@classmethod
|
||||
def _validate_min_secret_length(cls, value: SecretStr | None, info: Any) -> SecretStr | None:
|
||||
"""Reject short shared secrets."""
|
||||
if value is not None and len(value.get_secret_value()) < _MIN_SECRET_CHARS:
|
||||
raise ValueError(
|
||||
f"{info.field_name.upper()} must be at least {_MIN_SECRET_CHARS} chars"
|
||||
)
|
||||
return value
|
||||
|
||||
@field_validator("allowed_hosts", "oauth_scopes", "toolsets", "disabled_tools", mode="before")
|
||||
@classmethod
|
||||
def _parse_list(cls, value: Any) -> Any:
|
||||
"""Accept comma or whitespace separated strings for list settings."""
|
||||
return _split_list(value)
|
||||
|
||||
@field_validator("oauth_scopes")
|
||||
@classmethod
|
||||
def _validate_oauth_scopes(cls, value: list[str]) -> list[str]:
|
||||
"""Accept only granular Gitea scopes (unknown scopes make Gitea grant full access)."""
|
||||
unknown = sorted({scope for scope in value if scope not in GITEA_SCOPES})
|
||||
if unknown:
|
||||
raise ValueError(
|
||||
f"OAUTH_SCOPES contains unknown scope(s): {', '.join(unknown)}. "
|
||||
"Gitea grants full access for unknown scopes, so only granular "
|
||||
"read:/write: scopes are accepted"
|
||||
)
|
||||
if not value:
|
||||
raise ValueError("OAUTH_SCOPES must not be empty")
|
||||
return list(dict.fromkeys(value))
|
||||
|
||||
@field_validator("toolsets")
|
||||
@classmethod
|
||||
def _validate_toolsets(cls, value: list[str]) -> list[str]:
|
||||
"""Accept known tool domains and ``all``."""
|
||||
lowered = [item.lower() for item in value]
|
||||
unknown = sorted({item for item in lowered if item != "all" and item not in TOOL_DOMAINS})
|
||||
if unknown:
|
||||
allowed = ", ".join(sorted(TOOL_DOMAINS | {"all"}))
|
||||
raise ValueError(
|
||||
f"TOOLSETS contains unknown domain(s): {', '.join(unknown)}; use {allowed}"
|
||||
)
|
||||
if not lowered:
|
||||
raise ValueError("TOOLSETS must not be empty (use 'all')")
|
||||
return list(dict.fromkeys(lowered))
|
||||
|
||||
@field_validator("disabled_tools")
|
||||
@classmethod
|
||||
def _normalize_disabled_tools(cls, value: list[str]) -> list[str]:
|
||||
"""Lowercase tool names and drop duplicates."""
|
||||
return list(dict.fromkeys(item.lower() for item in value))
|
||||
|
||||
@field_validator("allowed_hosts")
|
||||
@classmethod
|
||||
def _normalize_allowed_hosts(cls, value: list[str]) -> list[str]:
|
||||
"""Drop duplicates, keep order."""
|
||||
return list(dict.fromkeys(value))
|
||||
|
||||
@field_validator("log_level")
|
||||
@classmethod
|
||||
def _validate_log_level(cls, value: str) -> str:
|
||||
"""Normalize and validate the logging level."""
|
||||
upper = value.strip().upper()
|
||||
if upper not in ALLOWED_LOG_LEVELS:
|
||||
raise ValueError(f"LOG_LEVEL must be one of {sorted(ALLOWED_LOG_LEVELS)}")
|
||||
return upper
|
||||
|
||||
@field_validator("data_dir", "audit_log_path")
|
||||
@classmethod
|
||||
def _expand_user(cls, value: Path | None) -> Path | None:
|
||||
"""Expand a leading ``~`` in paths."""
|
||||
return value.expanduser() if value is not None else None
|
||||
|
||||
# Derived values and helpers
|
||||
|
||||
@property
|
||||
def gitea_api_url(self) -> str:
|
||||
"""Base URL of the Gitea REST API (``<GITEA_URL>/api/v1``)."""
|
||||
return f"{self.gitea_url}/api/v1"
|
||||
|
||||
@property
|
||||
def resolved_audit_log_path(self) -> Path:
|
||||
"""Audit log path, defaulting to ``DATA_DIR/audit.log``."""
|
||||
return self.audit_log_path or (self.data_dir / "audit.log")
|
||||
|
||||
def is_loopback_bind(self) -> bool:
|
||||
"""Return True when ``MCP_HOST`` is a loopback address (127.0.0.0/8, ::1, localhost)."""
|
||||
return is_loopback_host(self.mcp_host)
|
||||
|
||||
def oauth_config_errors(self) -> list[str]:
|
||||
"""List missing settings required by OAuth mode (empty when OAuth is off or complete)."""
|
||||
if not self.oauth_enabled:
|
||||
return []
|
||||
errors: list[str] = []
|
||||
if not self.gitea_oauth_client_id:
|
||||
errors.append("GITEA_OAUTH_CLIENT_ID is required when OAUTH_ENABLED=true")
|
||||
if self.gitea_oauth_client_secret is None:
|
||||
errors.append("GITEA_OAUTH_CLIENT_SECRET is required when OAUTH_ENABLED=true")
|
||||
if self.token_encryption_key is None:
|
||||
errors.append("TOKEN_ENCRYPTION_KEY is required when OAUTH_ENABLED=true")
|
||||
if not self.public_base_url:
|
||||
errors.append("PUBLIC_BASE_URL is required when OAUTH_ENABLED=true")
|
||||
return errors
|
||||
|
||||
def http_auth_configured(self) -> bool:
|
||||
"""Return True when the HTTP endpoint has a bearer token or a complete OAuth setup."""
|
||||
if self.mcp_bearer_token is not None:
|
||||
return True
|
||||
return self.oauth_enabled and not self.oauth_config_errors()
|
||||
|
||||
|
||||
_settings: Settings | None = None
|
||||
|
||||
|
||||
def get_settings() -> Settings:
|
||||
"""Get or create global settings instance."""
|
||||
"""Get or create the global settings instance."""
|
||||
global _settings
|
||||
if _settings is None:
|
||||
# Mypy limitation: BaseSettings loads from environment dynamically.
|
||||
_settings = Settings() # type: ignore[call-arg]
|
||||
return _settings
|
||||
|
||||
|
||||
def reset_settings() -> None:
|
||||
"""Reset global settings instance (primarily for testing)."""
|
||||
"""Reset the global settings instance (primarily for tests)."""
|
||||
global _settings
|
||||
_settings = None
|
||||
Reference in new issue
Block a user