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