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// Load reads and resolves configuration. Missing default config files are ignored.
64func Load(ctx context.Context, path string, resolver *configvalue.Resolver) (Config, error) {
65 fileConfig, err := loadFile(path)
66 if err != nil {
67 return Config{}, err
68 }
69
70 username, err := requiredConfigValue(
71 ctx,
72 resolver,
73 "COOKED_LOGIN_USERNAME",
74 fileConfig.User.Name,
75 "Cooked username",
76 )
77 if err != nil {
78 return Config{}, err
79 }
80
81 password, err := requiredConfigValue(
82 ctx,
83 resolver,
84 "COOKED_LOGIN_PASSWORD",
85 fileConfig.User.Password,
86 "Cooked password",
87 )
88 if err != nil {
89 return Config{}, err
90 }
91
92 baseURL, err := baseURL(ctx, resolver, fileConfig.Cooked.BaseURL)
93 if err != nil {
94 return Config{}, err
95 }
96 httpAddr := defaultHTTPAddr
97 if fileConfig.MCP.HTTPAddr != "" {
98 httpAddr = fileConfig.MCP.HTTPAddr
99 }
100 if envHTTPAddr := os.Getenv("COOKED_MCP_HTTP_ADDR"); envHTTPAddr != "" {
101 httpAddr = envHTTPAddr
102 }
103 httpToken, err := httpToken(ctx, resolver, fileConfig.MCP.HTTPToken)
104 if err != nil {
105 return Config{}, err
106 }
107 if err := validateHTTPListenerSafety(httpAddr, httpToken); err != nil {
108 return Config{}, err
109 }
110
111 return Config{
112 Username: username,
113 Password: password,
114 BaseURL: baseURL,
115 HTTPAddr: httpAddr,
116 HTTPToken: httpToken,
117 }, nil
118}
119
120// DefaultPath returns the default TOML configuration path.
121func DefaultPath() (string, error) {
122 if configHome := os.Getenv("XDG_CONFIG_HOME"); configHome != "" {
123 return filepath.Join(configHome, "cooked-mcp", "config.toml"), nil
124 }
125
126 home, err := userHomeDir()
127 if err != nil {
128 return "", fmt.Errorf("resolve default config path: %w", err)
129 }
130 if home == "" {
131 return "", fmt.Errorf("resolve default config path: home directory is empty")
132 }
133
134 return filepath.Join(home, ".config", "cooked-mcp", "config.toml"), nil
135}
136
137func loadFile(path string) (FileConfig, error) {
138 explicitPath := path != ""
139 if path == "" {
140 defaultPath, err := DefaultPath()
141 if err != nil {
142 return FileConfig{}, err
143 }
144 path = defaultPath
145 }
146
147 var fileConfig FileConfig
148 if _, err := os.Stat(path); err != nil {
149 if errors.Is(err, os.ErrNotExist) && !explicitPath {
150 return fileConfig, nil
151 }
152 if errors.Is(err, os.ErrNotExist) {
153 return FileConfig{}, fmt.Errorf("config file %q does not exist", path)
154 }
155 return FileConfig{}, fmt.Errorf("stat config file: %w", err)
156 }
157
158 if _, err := toml.DecodeFile(path, &fileConfig); err != nil {
159 return FileConfig{}, fmt.Errorf("decode config file: %w", err)
160 }
161
162 return fileConfig, nil
163}
164
165func requiredConfigValue(
166 ctx context.Context,
167 resolver *configvalue.Resolver,
168 envName, config, description string,
169) (string, error) {
170 if os.Getenv(envName) != "" || config != "" {
171 return configvalue.ResolveWithExplicitEnvOverride(ctx, resolver, envName, config, description)
172 }
173
174 return "", fmt.Errorf("missing %s configuration", description)
175}
176
177func baseURL(ctx context.Context, resolver *configvalue.Resolver, config string) (*url.URL, error) {
178 envBaseURL := os.Getenv("COOKED_BASE_URL")
179 baseURLString := envBaseURL
180 if envBaseURL == "" {
181 baseURLString = defaultBaseURL
182 }
183 if envBaseURL == "" && config != "" {
184 resolved, err := resolver.ResolveOrThrow(ctx, config, "Cooked base URL")
185 if err != nil {
186 return nil, err
187 }
188 baseURLString = resolved
189 }
190
191 parsed, err := url.Parse(baseURLString)
192 if err != nil {
193 return nil, fmt.Errorf("parse Cooked base URL: %w", err)
194 }
195
196 if parsed.Scheme != "http" && parsed.Scheme != "https" {
197 return nil, fmt.Errorf("cooked base URL must be an HTTP URL")
198 }
199 if parsed.Host == "" {
200 return nil, fmt.Errorf("cooked base URL must include a host")
201 }
202 if parsed.Scheme == "http" && !isLoopbackHost(parsed.Hostname()) {
203 return nil, fmt.Errorf("cooked base URL must use https unless it is localhost")
204 }
205
206 return parsed, nil
207}
208
209func httpToken(ctx context.Context, resolver *configvalue.Resolver, config *string) (string, error) {
210 if value, ok := os.LookupEnv("COOKED_MCP_HTTP_TOKEN"); ok {
211 if value == "" {
212 return "", fmt.Errorf("MCP HTTP token must not be empty")
213 }
214
215 return value, nil
216 }
217 if config == nil {
218 return "", nil
219 }
220
221 value, err := resolver.ResolveOrThrow(ctx, *config, "MCP HTTP token")
222 if err != nil {
223 return "", err
224 }
225 if value == "" {
226 return "", fmt.Errorf("MCP HTTP token must not be empty")
227 }
228
229 return value, nil
230}
231
232func validateHTTPListenerSafety(addr, token string) error {
233 host, _, err := net.SplitHostPort(addr)
234 if err != nil {
235 return fmt.Errorf("invalid MCP HTTP listen address: %w", err)
236 }
237 if isLoopbackHost(host) {
238 return nil
239 }
240 if token != "" {
241 return nil
242 }
243
244 return fmt.Errorf("MCP HTTP token is required for non-loopback HTTP listen addresses")
245}
246
247func isLoopbackHost(host string) bool {
248 if strings.EqualFold(host, "localhost") {
249 return true
250 }
251
252 parsed := net.ParseIP(host)
253 return parsed != nil && parsed.IsLoopback()
254}