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

1"""Configuration management for GolfMCP.""" 

2 

3from pathlib import Path 

4from typing import Any 

5 

6from pydantic import BaseModel, Field, field_validator 

7from pydantic_settings import BaseSettings, SettingsConfigDict 

8from rich.console import Console 

9 

10console = Console() 

11 

12 

13class AuthConfig(BaseModel): 

14 """Authentication configuration.""" 

15 

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)") 

21 

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 

30 

31 

32class DeployConfig(BaseModel): 

33 """Deployment configuration.""" 

34 

35 default: str = Field("vercel", description="Default deployment target") 

36 options: dict[str, Any] = Field(default_factory=dict, description="Target-specific options") 

37 

38 

39class Settings(BaseSettings): 

40 """GolfMCP application settings.""" 

41 

42 model_config = SettingsConfigDict( 

43 env_prefix="GOLF_", 

44 env_file=".env", 

45 env_file_encoding="utf-8", 

46 extra="ignore", 

47 ) 

48 

49 # Project metadata 

50 name: str = Field("GolfMCP Project", description="FastMCP instance name") 

51 description: str | None = Field(None, description="Project description") 

52 

53 # Build settings 

54 output_dir: str = Field("dist", description="Build artifact folder") 

55 

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 ) 

63 

64 # Auth settings 

65 auth: str | AuthConfig | None = Field(None, description="Authentication configuration or URI") 

66 

67 # Deploy settings 

68 deploy: DeployConfig = Field(default_factory=DeployConfig, description="Deployment configuration") 

69 

70 # Feature flags 

71 telemetry: bool = Field(True, description="Enable anonymous telemetry") 

72 

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") 

77 

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 ) 

84 

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") 

89 

90 # HTTP session behaviour 

91 stateless_http: bool = Field( 

92 False, 

93 description="Make Streamable-HTTP transport stateless (new session per request)", 

94 ) 

95 

96 # Metrics configuration 

97 metrics_enabled: bool = Field(False, description="Enable Prometheus metrics endpoint") 

98 metrics_path: str = Field("/metrics", description="Metrics endpoint path") 

99 

100 

101def find_config_path(start_path: Path | None = None) -> Path | None: 

102 """Find the golf config file by searching upwards from the given path. 

103 

104 Args: 

105 start_path: Path to start searching from (defaults to current directory) 

106 

107 Returns: 

108 Path to the config file if found, None otherwise 

109 """ 

110 if start_path is None: 

111 start_path = Path.cwd() 

112 

113 current = start_path.absolute() 

114 

115 # Don't search above the home directory 

116 home = Path.home().absolute() 

117 

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 

123 

124 # Fall back to TOML config 

125 toml_config = current / "golf.toml" 

126 if toml_config.exists(): 

127 return toml_config 

128 

129 current = current.parent 

130 

131 return None 

132 

133 

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. 

138 

139 This is the central project discovery function that should be used by all commands. 

140 

141 Args: 

142 start_path: Path to start searching from (defaults to current directory) 

143 

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 

152 

153 

154def load_settings(project_path: str | Path) -> Settings: 

155 """Load settings from a project directory. 

156 

157 Args: 

158 project_path: Path to the project directory 

159 

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) 

166 

167 # Create default settings 

168 settings = Settings() 

169 

170 # Check for .env file 

171 env_file = project_path / ".env" 

172 if env_file.exists(): 

173 settings = Settings(_env_file=env_file) 

174 

175 # Auto-enable OpenTelemetry if GOLF_API_KEY is present (from .env file) 

176 import os 

177 

178 if os.environ.get("GOLF_API_KEY"): 

179 settings.opentelemetry_enabled = True 

180 

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) 

185 

186 # No config file found, use defaults 

187 # Auto-enable OpenTelemetry if GOLF_API_KEY is present 

188 import os 

189 

190 if os.environ.get("GOLF_API_KEY"): 

191 settings.opentelemetry_enabled = True 

192 

193 return settings 

194 

195 

196def _load_json_settings(path: Path, settings: Settings) -> Settings: 

197 """Load settings from a JSON file.""" 

198 try: 

199 import json 

200 

201 with open(path) as f: 

202 config_data = json.load(f) 

203 

204 # Update settings from config data 

205 for key, value in config_data.items(): 

206 if hasattr(settings, key): 

207 setattr(settings, key, value) 

208 

209 # Auto-enable OpenTelemetry if GOLF_API_KEY is present and telemetry wasn't explicitly configured 

210 import os 

211 

212 if os.environ.get("GOLF_API_KEY") and "opentelemetry_enabled" not in config_data: 

213 settings.opentelemetry_enabled = True 

214 

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