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(
67 ctx,
68 resolver,
69 "COOKED_LOGIN_USERNAME",
70 fileConfig.User.Name,
71 "Cooked username",
72 )
73 if err != nil {
74 return Config{}, err
75 }
76
77 password, err := requiredConfigValue(
78 ctx,
79 resolver,
80 "COOKED_LOGIN_PASSWORD",
81 fileConfig.User.Password,
82 "Cooked password",
83 )
84 if err != nil {
85 return Config{}, err
86 }
87
88 baseURL, err := baseURL(ctx, resolver, fileConfig.Cooked.BaseURL)
89 if err != nil {
90 return Config{}, err
91 }
92
93 return Config{
94 Username: username,
95 Password: password,
96 BaseURL: baseURL,
97 }, nil
98}
99
100// DefaultPath returns the default TOML configuration path.
101func DefaultPath() (string, error) {
102 if configHome := os.Getenv("XDG_CONFIG_HOME"); configHome != "" {
103 return filepath.Join(configHome, "cooked-mcp", "config.toml"), nil
104 }
105
106 home, err := userHomeDir()
107 if err != nil {
108 return "", fmt.Errorf("resolve default config path: %w", err)
109 }
110 if home == "" {
111 return "", fmt.Errorf("resolve default config path: home directory is empty")
112 }
113
114 return filepath.Join(home, ".config", "cooked-mcp", "config.toml"), nil
115}
116
117func loadFile(path string) (FileConfig, error) {
118 explicitPath := path != ""
119 if path == "" {
120 defaultPath, err := DefaultPath()
121 if err != nil {
122 return FileConfig{}, err
123 }
124 path = defaultPath
125 }
126
127 var fileConfig FileConfig
128 if _, err := os.Stat(path); err != nil {
129 if errors.Is(err, os.ErrNotExist) && !explicitPath {
130 return fileConfig, nil
131 }
132 if errors.Is(err, os.ErrNotExist) {
133 return FileConfig{}, fmt.Errorf("config file %q does not exist", path)
134 }
135 return FileConfig{}, fmt.Errorf("stat config file: %w", err)
136 }
137
138 if _, err := toml.DecodeFile(path, &fileConfig); err != nil {
139 return FileConfig{}, fmt.Errorf("decode config file: %w", err)
140 }
141
142 return fileConfig, nil
143}
144
145func requiredConfigValue(
146 ctx context.Context,
147 resolver *configvalue.Resolver,
148 envName, config, description string,
149) (string, error) {
150 if os.Getenv(envName) != "" || config != "" {
151 return configvalue.ResolveWithExplicitEnvOverride(ctx, resolver, envName, config, description)
152 }
153
154 return "", fmt.Errorf("missing %s configuration", description)
155}
156
157func baseURL(ctx context.Context, resolver *configvalue.Resolver, config string) (*url.URL, error) {
158 envBaseURL := os.Getenv("COOKED_BASE_URL")
159 baseURLString := envBaseURL
160 if envBaseURL == "" {
161 baseURLString = defaultBaseURL
162 }
163 if envBaseURL == "" && config != "" {
164 resolved, err := resolver.ResolveOrThrow(ctx, config, "Cooked base URL")
165 if err != nil {
166 return nil, err
167 }
168 baseURLString = resolved
169 }
170
171 parsed, err := url.Parse(baseURLString)
172 if err != nil {
173 return nil, fmt.Errorf("parse Cooked base URL: %w", err)
174 }
175
176 if parsed.Scheme != "http" && parsed.Scheme != "https" {
177 return nil, fmt.Errorf("cooked base URL must be an HTTP URL")
178 }
179 if parsed.Host == "" {
180 return nil, fmt.Errorf("cooked base URL must include a host")
181 }
182 if parsed.Scheme == "http" && !isLoopbackHost(parsed.Hostname()) {
183 return nil, fmt.Errorf("cooked base URL must use https unless it is localhost")
184 }
185
186 return parsed, nil
187}
188
189func isLoopbackHost(host string) bool {
190 if host == "localhost" {
191 return true
192 }
193
194 parsed := net.ParseIP(host)
195 return parsed != nil && parsed.IsLoopback()
196}