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// 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}