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