1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
2//
3// SPDX-License-Identifier: LicenseRef-MutuaL-1.2
4
5// Package appconfig loads cooked-mcp configuration from TOML and environment variables.
6package appconfig
7
8import (
9 "context"
10 "errors"
11 "fmt"
12 "net"
13 "net/url"
14 "os"
15 "path/filepath"
16 "strings"
17
18 "github.com/BurntSushi/toml"
19
20 "git.secluded.site/cooked-mcp/internal/configvalue"
21)
22
23const (
24 defaultBaseURL = "https://cooked.wiki"
25 defaultHTTPAddr = "127.0.0.1:8123"
26)
27
28var userHomeDir = os.UserHomeDir
29
30// FileConfig is the TOML configuration shape.
31type FileConfig struct {
32 User UserConfig `toml:"user"`
33 Cooked CookedConfig `toml:"cooked"`
34 MCP MCPConfig `toml:"mcp"`
35}
36
37// UserConfig contains Cooked login credentials.
38type UserConfig struct {
39 Name string `toml:"name"`
40 Password string `toml:"password"`
41}
42
43// CookedConfig contains Cooked API configuration.
44type CookedConfig struct {
45 BaseURL string `toml:"base_url"`
46}
47
48// MCPConfig contains MCP transport configuration.
49type MCPConfig struct {
50 HTTPAddr string `toml:"http_addr"`
51 HTTPToken *string `toml:"http_token"`
52}
53
54// Config is fully resolved runtime configuration.
55type Config struct {
56 Username string
57 Password string
58 BaseURL *url.URL
59 HTTPAddr string
60 HTTPToken string
61}
62
63// LoadOptions contains explicit configuration overrides.
64type LoadOptions struct {
65 HTTPAddr string
66}
67
68// Load reads and resolves configuration. Missing default config files are ignored.
69func Load(
70 ctx context.Context,
71 path string,
72 resolver *configvalue.Resolver,
73 options LoadOptions,
74) (Config, error) {
75 fileConfig, err := loadFile(path)
76 if err != nil {
77 return Config{}, err
78 }
79
80 username, err := requiredConfigValue(
81 ctx,
82 resolver,
83 "COOKED_LOGIN_USERNAME",
84 fileConfig.User.Name,
85 "Cooked username",
86 )
87 if err != nil {
88 return Config{}, err
89 }
90
91 password, err := requiredConfigValue(
92 ctx,
93 resolver,
94 "COOKED_LOGIN_PASSWORD",
95 fileConfig.User.Password,
96 "Cooked password",
97 )
98 if err != nil {
99 return Config{}, err
100 }
101
102 baseURL, err := baseURL(ctx, resolver, fileConfig.Cooked.BaseURL)
103 if err != nil {
104 return Config{}, err
105 }
106 httpAddr := defaultHTTPAddr
107 if fileConfig.MCP.HTTPAddr != "" {
108 httpAddr = fileConfig.MCP.HTTPAddr
109 }
110 if envHTTPAddr := os.Getenv("COOKED_MCP_HTTP_ADDR"); envHTTPAddr != "" {
111 httpAddr = envHTTPAddr
112 }
113 if options.HTTPAddr != "" {
114 httpAddr = options.HTTPAddr
115 }
116 httpToken, err := httpToken(ctx, resolver, fileConfig.MCP.HTTPToken)
117 if err != nil {
118 return Config{}, err
119 }
120 if err := validateHTTPListenerSafety(httpAddr, httpToken); err != nil {
121 return Config{}, err
122 }
123
124 return Config{
125 Username: username,
126 Password: password,
127 BaseURL: baseURL,
128 HTTPAddr: httpAddr,
129 HTTPToken: httpToken,
130 }, nil
131}
132
133// DefaultPath returns the default TOML configuration path.
134func DefaultPath() (string, error) {
135 if configHome := os.Getenv("XDG_CONFIG_HOME"); configHome != "" {
136 return filepath.Join(configHome, "cooked-mcp", "config.toml"), nil
137 }
138
139 home, err := userHomeDir()
140 if err != nil {
141 return "", fmt.Errorf("resolve default config path: %w", err)
142 }
143 if home == "" {
144 return "", fmt.Errorf("resolve default config path: home directory is empty")
145 }
146
147 return filepath.Join(home, ".config", "cooked-mcp", "config.toml"), nil
148}
149
150func loadFile(path string) (FileConfig, error) {
151 explicitPath := path != ""
152 if path == "" {
153 defaultPath, err := DefaultPath()
154 if err != nil {
155 return FileConfig{}, err
156 }
157 path = defaultPath
158 }
159
160 var fileConfig FileConfig
161 if _, err := os.Stat(path); err != nil {
162 if errors.Is(err, os.ErrNotExist) && !explicitPath {
163 return fileConfig, nil
164 }
165 if errors.Is(err, os.ErrNotExist) {
166 return FileConfig{}, fmt.Errorf("config file %q does not exist", path)
167 }
168 return FileConfig{}, fmt.Errorf("stat config file: %w", err)
169 }
170
171 if _, err := toml.DecodeFile(path, &fileConfig); err != nil {
172 return FileConfig{}, fmt.Errorf("decode config file: %w", err)
173 }
174
175 return fileConfig, nil
176}
177
178func requiredConfigValue(
179 ctx context.Context,
180 resolver *configvalue.Resolver,
181 envName, config, description string,
182) (string, error) {
183 if os.Getenv(envName) != "" || config != "" {
184 return configvalue.ResolveWithExplicitEnvOverride(ctx, resolver, envName, config, description)
185 }
186
187 return "", fmt.Errorf("missing %s configuration", description)
188}
189
190func baseURL(ctx context.Context, resolver *configvalue.Resolver, config string) (*url.URL, error) {
191 envBaseURL := os.Getenv("COOKED_BASE_URL")
192 baseURLString := envBaseURL
193 if envBaseURL == "" {
194 baseURLString = defaultBaseURL
195 }
196 if envBaseURL == "" && config != "" {
197 resolved, err := resolver.ResolveOrThrow(ctx, config, "Cooked base URL")
198 if err != nil {
199 return nil, err
200 }
201 baseURLString = resolved
202 }
203
204 parsed, err := url.Parse(baseURLString)
205 if err != nil {
206 return nil, fmt.Errorf("parse Cooked base URL: %w", err)
207 }
208
209 if parsed.Scheme != "http" && parsed.Scheme != "https" {
210 return nil, fmt.Errorf("cooked base URL must be an HTTP URL")
211 }
212 if parsed.Host == "" {
213 return nil, fmt.Errorf("cooked base URL must include a host")
214 }
215 if parsed.Scheme == "http" && !isLoopbackHost(parsed.Hostname()) {
216 return nil, fmt.Errorf("cooked base URL must use https unless it is localhost")
217 }
218
219 return parsed, nil
220}
221
222func httpToken(ctx context.Context, resolver *configvalue.Resolver, config *string) (string, error) {
223 if value, ok := os.LookupEnv("COOKED_MCP_HTTP_TOKEN"); ok {
224 if value == "" {
225 return "", fmt.Errorf("MCP HTTP token must not be empty")
226 }
227
228 return value, nil
229 }
230 if config == nil {
231 return "", nil
232 }
233
234 value, err := resolver.ResolveOrThrow(ctx, *config, "MCP HTTP token")
235 if err != nil {
236 return "", err
237 }
238 if value == "" {
239 return "", fmt.Errorf("MCP HTTP token must not be empty")
240 }
241
242 return value, nil
243}
244
245func validateHTTPListenerSafety(addr, token string) error {
246 host, _, err := net.SplitHostPort(addr)
247 if err != nil {
248 return fmt.Errorf("invalid MCP HTTP listen address: %w", err)
249 }
250 if isLoopbackHost(host) {
251 return nil
252 }
253 if token != "" {
254 return nil
255 }
256
257 return fmt.Errorf("MCP HTTP token is required for non-loopback HTTP listen addresses")
258}
259
260func isLoopbackHost(host string) bool {
261 if strings.EqualFold(host, "localhost") {
262 return true
263 }
264
265 parsed := net.ParseIP(host)
266 return parsed != nil && parsed.IsLoopback()
267}