// Package config loads and validates the server configuration from the // environment. package config import ( "errors" "fmt" "log/slog" "net" "net/url" "os" "strconv" "strings" "time" ) // Supported MCP transports. const ( TransportStdio = "stdio" TransportHTTP = "http" ) // Defaults for optional settings. const ( DefaultAddr = "127.0.0.1:8080" DefaultPath = "/mcp" DefaultTimeout = 30 * time.Second DefaultLogLevel = slog.LevelInfo ) // Config holds the complete runtime configuration. type Config struct { // WikiJS connection. WikiJSURL string WikiJSAPIKey string // EnableEdit exposes the create_page and edit_page tools. EnableEdit bool // Timeout applies to a single WikiJS GraphQL request. Timeout time.Duration // Transport is either TransportStdio or TransportHTTP. Transport string // Addr is the listen address for the HTTP transport. Addr string // Path is the HTTP path that serves the MCP endpoint. Path string // Stateless disables MCP sessions on the HTTP transport. Stateless bool // AuthToken protects the HTTP transport with a bearer token. Empty means // no authentication. AuthToken string // LogLevel controls the stderr logger. LogLevel slog.Level } // Load reads the configuration from the environment and applies defaults. // Required values and transport settings are validated by [Config.Validate] so // that flags such as -version and -help work without credentials present. func Load() (Config, error) { cfg := Config{ WikiJSURL: strings.TrimSpace(os.Getenv("WIKIJS_URL")), WikiJSAPIKey: strings.TrimSpace(os.Getenv("WIKIJS_API_KEY")), Transport: strings.ToLower(strings.TrimSpace(os.Getenv("MCP_TRANSPORT"))), Addr: strings.TrimSpace(os.Getenv("MCP_ADDR")), Path: strings.TrimSpace(os.Getenv("MCP_PATH")), AuthToken: strings.TrimSpace(os.Getenv("MCP_AUTH_TOKEN")), Timeout: DefaultTimeout, LogLevel: DefaultLogLevel, } if cfg.Transport == "" { cfg.Transport = TransportStdio } if cfg.Addr == "" { cfg.Addr = DefaultAddr } if cfg.Path == "" { cfg.Path = DefaultPath } var err error if cfg.EnableEdit, err = boolEnv("WIKIJS_ENABLE_EDIT", false); err != nil { return Config{}, err } if cfg.Stateless, err = boolEnv("MCP_STATELESS", false); err != nil { return Config{}, err } if raw := strings.TrimSpace(os.Getenv("WIKIJS_TIMEOUT")); raw != "" { cfg.Timeout, err = time.ParseDuration(raw) if err != nil { return Config{}, fmt.Errorf("WIKIJS_TIMEOUT: %w", err) } } if raw := strings.TrimSpace(os.Getenv("LOG_LEVEL")); raw != "" { if cfg.LogLevel, err = ParseLogLevel(raw); err != nil { return Config{}, err } } return cfg, nil } // Validate checks required values and the settings that can be overridden by // command line flags. func (c *Config) Validate() error { if c.WikiJSURL == "" { return errors.New("WIKIJS_URL is required") } if c.WikiJSAPIKey == "" { return errors.New("WIKIJS_API_KEY is required") } u, err := url.Parse(c.WikiJSURL) if err != nil { return fmt.Errorf("WIKIJS_URL: %w", err) } if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { return fmt.Errorf("WIKIJS_URL must be an absolute http(s) URL, got %q", c.WikiJSURL) } if u.RawQuery != "" || u.Fragment != "" { return fmt.Errorf("WIKIJS_URL must not contain a query or fragment, got %q", c.WikiJSURL) } switch c.Transport { case TransportStdio, TransportHTTP: default: return fmt.Errorf("MCP_TRANSPORT must be %q or %q, got %q", TransportStdio, TransportHTTP, c.Transport) } if c.Addr == "" { return errors.New("MCP_ADDR must not be empty") } if c.Transport == TransportHTTP { host, port, err := net.SplitHostPort(c.Addr) if err != nil || port == "" { return fmt.Errorf("MCP_ADDR must be a host:port address, got %q", c.Addr) } if c.AuthToken == "" && !isLoopbackHost(host) { return fmt.Errorf("MCP_ADDR %q is not loopback; set MCP_AUTH_TOKEN or bind to 127.0.0.1", c.Addr) } } if !strings.HasPrefix(c.Path, "/") { return fmt.Errorf("MCP_PATH must start with %q, got %q", "/", c.Path) } if c.Path == "/healthz" { return errors.New(`MCP_PATH must not be "/healthz"`) } if c.Timeout <= 0 { return errors.New("WIKIJS_TIMEOUT must be positive") } return nil } func isLoopbackHost(host string) bool { if strings.EqualFold(host, "localhost") { return true } ip := net.ParseIP(host) return ip != nil && ip.IsLoopback() } func boolEnv(name string, fallback bool) (bool, error) { raw := strings.TrimSpace(os.Getenv(name)) if raw == "" { return fallback, nil } v, err := strconv.ParseBool(raw) if err != nil { return false, fmt.Errorf("%s must be a boolean: %w", name, err) } return v, nil } // ParseLogLevel turns a human-readable level name into a [slog.Level]. func ParseLogLevel(raw string) (slog.Level, error) { switch strings.ToLower(raw) { case "debug": return slog.LevelDebug, nil case "info": return slog.LevelInfo, nil case "warn", "warning": return slog.LevelWarn, nil case "error": return slog.LevelError, nil default: return 0, fmt.Errorf("LOG_LEVEL must be one of debug, info, warn, error; got %q", raw) } }