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
103 return Config{
104 Username: username,
105 Password: password,
106 BaseURL: baseURL,
107 HTTPAddr: httpAddr,
108 }, nil
109}
110
111// DefaultPath returns the default TOML configuration path.
112func DefaultPath() (string, error) {
113 if configHome := os.Getenv("XDG_CONFIG_HOME"); configHome != "" {
114 return filepath.Join(configHome, "cooked-mcp", "config.toml"), nil
115 }
116
117 home, err := userHomeDir()
118 if err != nil {
119 return "", fmt.Errorf("resolve default config path: %w", err)
120 }
121 if home == "" {
122 return "", fmt.Errorf("resolve default config path: home directory is empty")
123 }
124
125 return filepath.Join(home, ".config", "cooked-mcp", "config.toml"), nil
126}
127
128func loadFile(path string) (FileConfig, error) {
129 explicitPath := path != ""
130 if path == "" {
131 defaultPath, err := DefaultPath()
132 if err != nil {
133 return FileConfig{}, err
134 }
135 path = defaultPath
136 }
137
138 var fileConfig FileConfig
139 if _, err := os.Stat(path); err != nil {
140 if errors.Is(err, os.ErrNotExist) && !explicitPath {
141 return fileConfig, nil
142 }
143 if errors.Is(err, os.ErrNotExist) {
144 return FileConfig{}, fmt.Errorf("config file %q does not exist", path)
145 }
146 return FileConfig{}, fmt.Errorf("stat config file: %w", err)
147 }
148
149 if _, err := toml.DecodeFile(path, &fileConfig); err != nil {
150 return FileConfig{}, fmt.Errorf("decode config file: %w", err)
151 }
152
153 return fileConfig, nil
154}
155
156func requiredConfigValue(
157 ctx context.Context,
158 resolver *configvalue.Resolver,
159 envName, config, description string,
160) (string, error) {
161 if os.Getenv(envName) != "" || config != "" {
162 return configvalue.ResolveWithExplicitEnvOverride(ctx, resolver, envName, config, description)
163 }
164
165 return "", fmt.Errorf("missing %s configuration", description)
166}
167
168func baseURL(ctx context.Context, resolver *configvalue.Resolver, config string) (*url.URL, error) {
169 envBaseURL := os.Getenv("COOKED_BASE_URL")
170 baseURLString := envBaseURL
171 if envBaseURL == "" {
172 baseURLString = defaultBaseURL
173 }
174 if envBaseURL == "" && config != "" {
175 resolved, err := resolver.ResolveOrThrow(ctx, config, "Cooked base URL")
176 if err != nil {
177 return nil, err
178 }
179 baseURLString = resolved
180 }
181
182 parsed, err := url.Parse(baseURLString)
183 if err != nil {
184 return nil, fmt.Errorf("parse Cooked base URL: %w", err)
185 }
186
187 if parsed.Scheme != "http" && parsed.Scheme != "https" {
188 return nil, fmt.Errorf("cooked base URL must be an HTTP URL")
189 }
190 if parsed.Host == "" {
191 return nil, fmt.Errorf("cooked base URL must include a host")
192 }
193 if parsed.Scheme == "http" && !isLoopbackHost(parsed.Hostname()) {
194 return nil, fmt.Errorf("cooked base URL must use https unless it is localhost")
195 }
196
197 return parsed, nil
198}
199
200func isLoopbackHost(host string) bool {
201 if host == "localhost" {
202 return true
203 }
204
205 parsed := net.ParseIP(host)
206 return parsed != nil && parsed.IsLoopback()
207}