config.go

  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}