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