Coverage for /Users/antonigmitruk/golf/src/golf/core/config.py: 0%

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

2

3 from pathlib import Path

4 from typing import Any

5

6 from pydantic import BaseModel, Field, field_validator

7 from pydantic_settings import BaseSettings, SettingsConfigDict

8 from rich.console import Console

9

10 console = Console()

11

12

13 class 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

32 class 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

39 class 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

101 def 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

134 def 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

154 def 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

196 def _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