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