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
121 return Config{
122 Username: username,
123 Password: password,
124 BaseURL: baseURL,
125 HTTPAddr: httpAddr,
126 HTTPToken: httpToken,
127 }, nil
128}
129
130// DefaultPath returns the default TOML configuration path.
131func DefaultPath() (string, error) {
132 if configHome := os.Getenv("XDG_CONFIG_HOME"); configHome != "" {
133 return filepath.Join(configHome, "cooked-mcp", "config.toml"), nil
134 }
135
136 home, err := userHomeDir()
137 if err != nil {
138 return "", fmt.Errorf("resolve default config path: %w", err)
139 }
140 if home == "" {
141 return "", fmt.Errorf("resolve default config path: home directory is empty")
142 }
143
144 return filepath.Join(home, ".config", "cooked-mcp", "config.toml"), nil
145}
146
147func loadFile(path string) (FileConfig, error) {
148 explicitPath := path != ""
149 if path == "" {
150 defaultPath, err := DefaultPath()
151 if err != nil {
152 return FileConfig{}, err
153 }
154 path = defaultPath
155 }
156
157 var fileConfig FileConfig
158 if _, err := os.Stat(path); err != nil {
159 if errors.Is(err, os.ErrNotExist) && !explicitPath {
160 return fileConfig, nil
161 }
162 if errors.Is(err, os.ErrNotExist) {
163 return FileConfig{}, fmt.Errorf("config file %q does not exist", path)
164 }
165 return FileConfig{}, fmt.Errorf("stat config file: %w", err)
166 }
167
168 if _, err := toml.DecodeFile(path, &fileConfig); err != nil {
169 return FileConfig{}, fmt.Errorf("decode config file: %w", err)
170 }
171
172 return fileConfig, nil
173}
174
175func requiredConfigValue(
176 ctx context.Context,
177 resolver *configvalue.Resolver,
178 envName, config, description string,
179) (string, error) {
180 if os.Getenv(envName) != "" || config != "" {
181 return configvalue.ResolveWithExplicitEnvOverride(ctx, resolver, envName, config, description)
182 }
183
184 return "", fmt.Errorf("missing %s configuration", description)
185}
186
187func baseURL(ctx context.Context, resolver *configvalue.Resolver, config string) (*url.URL, error) {
188 envBaseURL := os.Getenv("COOKED_BASE_URL")
189 baseURLString := envBaseURL
190 if envBaseURL == "" {
191 baseURLString = defaultBaseURL
192 }
193 if envBaseURL == "" && config != "" {
194 resolved, err := resolver.ResolveOrThrow(ctx, config, "Cooked base URL")
195 if err != nil {
196 return nil, err
197 }
198 baseURLString = resolved
199 }
200
201 parsed, err := url.Parse(baseURLString)
202 if err != nil {
203 return nil, fmt.Errorf("parse Cooked base URL: %w", err)
204 }
205
206 if parsed.Scheme != "http" && parsed.Scheme != "https" {
207 return nil, fmt.Errorf("cooked base URL must be an HTTP URL")
208 }
209 if parsed.Host == "" {
210 return nil, fmt.Errorf("cooked base URL must include a host")
211 }
212 if parsed.Scheme == "http" && !isLoopbackHost(parsed.Hostname()) {
213 return nil, fmt.Errorf("cooked base URL must use https unless it is localhost")
214 }
215
216 return parsed, nil
217}
218
219func httpToken(ctx context.Context, resolver *configvalue.Resolver, config *string) (string, error) {
220 if value, ok := os.LookupEnv("COOKED_MCP_HTTP_TOKEN"); ok {
221 if value == "" {
222 return "", fmt.Errorf("MCP HTTP token must not be empty")
223 }
224
225 return value, nil
226 }
227 if config == nil {
228 return "", nil
229 }
230
231 value, err := resolver.ResolveOrThrow(ctx, *config, "MCP HTTP token")
232 if err != nil {
233 return "", err
234 }
235 if value == "" {
236 return "", fmt.Errorf("MCP HTTP token must not be empty")
237 }
238
239 return value, nil
240}
241
242func isLoopbackHost(host string) bool {
243 if strings.EqualFold(host, "localhost") {
244 return true
245 }
246
247 parsed := net.ParseIP(host)
248 return parsed != nil && parsed.IsLoopback()
249}