Coverage for /Users/antonigmitruk/golf/src/golf/core/config.py: 0%
96 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-08-16 18:46 +0200
« prev ^ index » next coverage.py v7.6.12, created at 2025-08-16 18:46 +0200
1"""Configuration management for GolfMCP."""
3from pathlib import Path
4from typing import Any
6from pydantic import BaseModel, Field, field_validator
7from pydantic_settings import BaseSettings, SettingsConfigDict
8from rich.console import Console
10console = Console()
13class AuthConfig(BaseModel):
14 """Authentication configuration."""
16 provider: str = Field(..., description="Authentication provider (e.g., 'jwks', 'google', 'github')")
17 scopes: list[str] = Field(default_factory=list, description="Required OAuth scopes")
18 client_id_env: str | None = Field(None, description="Environment variable name for client ID")
19 client_secret_env: str | None = Field(None, description="Environment variable name for client secret")
20 redirect_uri: str | None = Field(None, description="OAuth redirect URI (defaults to localhost callback)")
22 @field_validator("provider")
23 @classmethod
24 def validate_provider(cls, value: str) -> str:
25 """Validate the provider value."""
26 valid_providers = {"jwks", "google", "github", "custom"}
27 if value not in valid_providers and not value.startswith("custom:"):
28 raise ValueError(f"Invalid provider '{value}'. Must be one of {valid_providers} or start with 'custom:'")
29 return value
32class DeployConfig(BaseModel):
33 """Deployment configuration."""
35 default: str = Field("vercel", description="Default deployment target")
36 options: dict[str, Any] = Field(default_factory=dict, description="Target-specific options")
39class Settings(BaseSettings):
40 """GolfMCP application settings."""
42 model_config = SettingsConfigDict(
43 env_prefix="GOLF_",
44 env_file=".env",
45 env_file_encoding="utf-8",
46 extra="ignore",
47 )
49 # Project metadata
50 name: str = Field("GolfMCP Project", description="FastMCP instance name")
51 description: str | None = Field(None, description="Project description")
53 # Build settings
54 output_dir: str = Field("dist", description="Build artifact folder")
56 # Server settings
57 host: str = Field("localhost", description="Server host")
58 port: int = Field(3000, description="Server port")
59 transport: str = Field(
60 "streamable-http",
61 description="Transport protocol (streamable-http, sse, stdio)",
62 )
64 # Auth settings
65 auth: str | AuthConfig | None = Field(None, description="Authentication configuration or URI")
67 # Deploy settings
68 deploy: DeployConfig = Field(default_factory=DeployConfig, description="Deployment configuration")
70 # Feature flags
71 telemetry: bool = Field(True, description="Enable anonymous telemetry")
73 # Project paths
74 tools_dir: str = Field("tools", description="Directory containing tools")
75 resources_dir: str = Field("resources", description="Directory containing resources")
76 prompts_dir: str = Field("prompts", description="Directory containing prompts")
78 # OpenTelemetry config
79 opentelemetry_enabled: bool = Field(False, description="Enable OpenTelemetry tracing")
80 opentelemetry_default_exporter: str = Field("console", description="Default OpenTelemetry exporter type")
81 detailed_tracing: bool = Field(
82 False, description="Enable detailed tracing with input/output capture (may contain sensitive data)"
83 )
85 # Health check configuration
86 health_check_enabled: bool = Field(False, description="Enable health check endpoint")
87 health_check_path: str = Field("/health", description="Health check endpoint path")
88 health_check_response: str = Field("OK", description="Health check response text")
90 # HTTP session behaviour
91 stateless_http: bool = Field(
92 False,
93 description="Make Streamable-HTTP transport stateless (new session per request)",
94 )
96 # Metrics configuration
97 metrics_enabled: bool = Field(False, description="Enable Prometheus metrics endpoint")
98 metrics_path: str = Field("/metrics", description="Metrics endpoint path")
101def find_config_path(start_path: Path | None = None) -> Path | None:
102 """Find the golf config file by searching upwards from the given path.
104 Args:
105 start_path: Path to start searching from (defaults to current directory)
107 Returns:
108 Path to the config file if found, None otherwise
109 """
110 if start_path is None:
111 start_path = Path.cwd()
113 current = start_path.absolute()
115 # Don't search above the home directory
116 home = Path.home().absolute()
118 while current != current.parent and current != home:
119 # Check for JSON config first (preferred)
120 json_config = current / "golf.json"
121 if json_config.exists():
122 return json_config
124 # Fall back to TOML config
125 toml_config = current / "golf.toml"
126 if toml_config.exists():
127 return toml_config
129 current = current.parent
131 return None
134def find_project_root(
135 start_path: Path | None = None,
136) -> tuple[Path | None, Path | None]:
137 """Find a GolfMCP project root by searching for a config file.
139 This is the central project discovery function that should be used by all commands.
141 Args:
142 start_path: Path to start searching from (defaults to current directory)
144 Returns:
145 Tuple of (project_root, config_path) if a project is found, or
146 (None, None) if not
147 """
148 config_path = find_config_path(start_path)
149 if config_path:
150 return config_path.parent, config_path
151 return None, None
154def load_settings(project_path: str | Path) -> Settings:
155 """Load settings from a project directory.
157 Args:
158 project_path: Path to the project directory
160 Returns:
161 Settings object with values loaded from config files
162 """
163 # Convert to Path if needed
164 if isinstance(project_path, str):
165 project_path = Path(project_path)
167 # Create default settings
168 settings = Settings()
170 # Check for .env file
171 env_file = project_path / ".env"
172 if env_file.exists():
173 settings = Settings(_env_file=env_file)
175 # Auto-enable OpenTelemetry if GOLF_API_KEY is present (from .env file)
176 import os
178 if os.environ.get("GOLF_API_KEY"):
179 settings.opentelemetry_enabled = True
181 # Try to load JSON config file first
182 json_config_path = project_path / "golf.json"
183 if json_config_path.exists():
184 return _load_json_settings(json_config_path, settings)
186 # No config file found, use defaults
187 # Auto-enable OpenTelemetry if GOLF_API_KEY is present
188 import os
190 if os.environ.get("GOLF_API_KEY"):
191 settings.opentelemetry_enabled = True
193 return settings
196def _load_json_settings(path: Path, settings: Settings) -> Settings:
197 """Load settings from a JSON file."""
198 try:
199 import json
201 with open(path) as f:
202 config_data = json.load(f)
204 # Update settings from config data
205 for key, value in config_data.items():
206 if hasattr(settings, key):
207 setattr(settings, key, value)
209 # Auto-enable OpenTelemetry if GOLF_API_KEY is present and telemetry wasn't explicitly configured
210 import os
212 if os.environ.get("GOLF_API_KEY") and "opentelemetry_enabled" not in config_data:
213 settings.opentelemetry_enabled = True
215 return settings
216 except Exception as e:
217 console.print(f"[bold red]Error loading JSON config from {path}: {e}[/bold red]")
218 return settings