// Package config provides TOML configuration loading with environment // variable overrides for Metacircular services. // // Services define their own config struct embedding [Base], which provides // the standard sections (Server, Database, MCIAS, Log). Use [Load] to // parse a TOML file, apply environment overrides, set defaults, and // validate required fields. // // # Duration fields // // Timeout fields in [ServerConfig] use the [Duration] type rather than // [time.Duration] because go-toml v2 does not natively decode strings // (e.g., "30s") into time.Duration. Access the underlying value via // the embedded field: // // cfg.Server.ReadTimeout.Duration // time.Duration // // In TOML files, durations are written as Go duration strings: // // read_timeout = "30s" // idle_timeout = "2m" // // Environment variable overrides also use this format: // // MCR_SERVER_READ_TIMEOUT=30s package config import ( "fmt" "os" "reflect" "strings" "time" "github.com/pelletier/go-toml/v2" "git.wntrmute.dev/kyle/mcdsl/auth" ) // Base contains the configuration sections common to all Metacircular // services. Services embed this in their own config struct and add // service-specific sections. // // Example: // // type MyConfig struct { // config.Base // MyService MyServiceSection `toml:"my_service"` // } type Base struct { Server ServerConfig `toml:"server"` Database DatabaseConfig `toml:"database"` MCIAS auth.Config `toml:"mcias"` Log LogConfig `toml:"log"` } // ServerConfig holds TLS server settings. type ServerConfig struct { // ListenAddr is the HTTPS listen address (e.g., ":8443"). Required. ListenAddr string `toml:"listen_addr"` // GRPCAddr is the gRPC listen address (e.g., ":9443"). Optional; // gRPC is disabled if empty. GRPCAddr string `toml:"grpc_addr"` // TLSCert is the path to the TLS certificate file (PEM). Required. TLSCert string `toml:"tls_cert"` // TLSKey is the path to the TLS private key file (PEM). Required. TLSKey string `toml:"tls_key"` // ReadTimeout is the maximum duration for reading the entire request. // Defaults to 30s. ReadTimeout Duration `toml:"read_timeout"` // WriteTimeout is the maximum duration before timing out writes. // Defaults to 30s. WriteTimeout Duration `toml:"write_timeout"` // IdleTimeout is the maximum time to wait for the next request on // a keep-alive connection. Defaults to 120s. IdleTimeout Duration `toml:"idle_timeout"` // ShutdownTimeout is the maximum time to wait for in-flight requests // to drain during graceful shutdown. Defaults to 60s. ShutdownTimeout Duration `toml:"shutdown_timeout"` } // DatabaseConfig holds SQLite database settings. type DatabaseConfig struct { // Path is the path to the SQLite database file. Required. Path string `toml:"path"` } // LogConfig holds logging settings. type LogConfig struct { // Level is the log level (debug, info, warn, error). Defaults to "info". Level string `toml:"level"` } // WebConfig holds web UI server settings. This is not part of Base because // not all services have a web UI — services that do can add it to their // own config struct. type WebConfig struct { // ListenAddr is the web UI listen address (e.g., "127.0.0.1:8080"). ListenAddr string `toml:"listen_addr"` // GRPCAddr is the gRPC address of the API server that the web UI // connects to. GRPCAddr string `toml:"grpc_addr"` // CACert is an optional CA certificate for verifying the API server's // TLS certificate. CACert string `toml:"ca_cert"` } // Validator is an optional interface that config structs can implement // to add service-specific validation. If the config type implements // Validator, its Validate method is called after defaults and env // overrides are applied. type Validator interface { Validate() error } // Load reads a TOML config file at path, applies environment variable // overrides using envPrefix (e.g., "MCR" maps MCR_SERVER_LISTEN_ADDR to // Server.ListenAddr), sets defaults for unset optional fields, and // validates required fields. // // If T implements [Validator], its Validate method is called after all // other processing. func Load[T any](path string, envPrefix string) (*T, error) { data, err := os.ReadFile(path) //nolint:gosec // config path is operator-supplied if err != nil { return nil, fmt.Errorf("config: read %s: %w", path, err) } var cfg T if err := toml.Unmarshal(data, &cfg); err != nil { return nil, fmt.Errorf("config: parse %s: %w", path, err) } if envPrefix != "" { applyEnvToStruct(reflect.ValueOf(&cfg).Elem(), envPrefix) } applyPortEnv(&cfg) applyBaseDefaults(&cfg) if err := validateBase(&cfg); err != nil { return nil, err } if v, ok := any(&cfg).(Validator); ok { if err := v.Validate(); err != nil { return nil, fmt.Errorf("config: %w", err) } } return &cfg, nil } // applyBaseDefaults sets defaults on the embedded Base struct if present. func applyBaseDefaults(cfg any) { base := findBase(cfg) if base == nil { return } if base.Server.ReadTimeout.Duration == 0 { base.Server.ReadTimeout.Duration = 30 * time.Second } if base.Server.WriteTimeout.Duration == 0 { base.Server.WriteTimeout.Duration = 30 * time.Second } if base.Server.IdleTimeout.Duration == 0 { base.Server.IdleTimeout.Duration = 120 * time.Second } if base.Server.ShutdownTimeout.Duration == 0 { base.Server.ShutdownTimeout.Duration = 60 * time.Second } if base.Log.Level == "" { base.Log.Level = "info" } } // validateBase checks required fields on the embedded Base struct if present. func validateBase(cfg any) error { base := findBase(cfg) if base == nil { return nil } required := []struct { name string value string }{ {"server.listen_addr", base.Server.ListenAddr}, {"server.tls_cert", base.Server.TLSCert}, {"server.tls_key", base.Server.TLSKey}, } for _, r := range required { if r.value == "" { return fmt.Errorf("config: required field %q is missing", r.name) } } return nil } // findBase returns a pointer to the embedded Base struct, or nil if the // config type does not embed Base. func findBase(cfg any) *Base { v := reflect.ValueOf(cfg) if v.Kind() == reflect.Ptr { v = v.Elem() } if v.Kind() != reflect.Struct { return nil } // Check if cfg *is* a Base. if b, ok := v.Addr().Interface().(*Base); ok { return b } // Check embedded fields. t := v.Type() for i := range t.NumField() { field := t.Field(i) if field.Anonymous && field.Type == reflect.TypeOf(Base{}) { b, ok := v.Field(i).Addr().Interface().(*Base) if ok { return b } } } return nil } // applyPortEnv overrides ServerConfig.ListenAddr and ServerConfig.GRPCAddr // from $PORT and $PORT_GRPC respectively. These environment variables are // set by the MCP agent to assign authoritative port bindings, so they take // precedence over both TOML values and generic env overrides. func applyPortEnv(cfg any) { sc := findServerConfig(cfg) if sc == nil { return } if port, ok := os.LookupEnv("PORT"); ok { sc.ListenAddr = ":" + port } if port, ok := os.LookupEnv("PORT_GRPC"); ok { sc.GRPCAddr = ":" + port } } // findServerConfig returns a pointer to the ServerConfig in the config // struct. It first checks for an embedded Base (which contains Server), // then walks the struct tree via reflection to find any ServerConfig field // directly (e.g., the Metacrypt pattern where ServerConfig is embedded // without Base). func findServerConfig(cfg any) *ServerConfig { if base := findBase(cfg); base != nil { return &base.Server } return findServerConfigReflect(reflect.ValueOf(cfg)) } // findServerConfigReflect walks the struct tree to find a ServerConfig field. func findServerConfigReflect(v reflect.Value) *ServerConfig { if v.Kind() == reflect.Ptr { v = v.Elem() } if v.Kind() != reflect.Struct { return nil } scType := reflect.TypeOf(ServerConfig{}) t := v.Type() for i := range t.NumField() { field := t.Field(i) fv := v.Field(i) if field.Type == scType { sc, ok := fv.Addr().Interface().(*ServerConfig) if ok { return sc } } // Recurse into embedded or nested structs. if fv.Kind() == reflect.Struct && field.Type != scType { if sc := findServerConfigReflect(fv); sc != nil { return sc } } } return nil } // applyEnvToStruct recursively walks a struct and overrides field values // from environment variables. The env variable name is built from the // prefix and the toml tag: PREFIX_SECTION_FIELD (uppercased). // // Supported field types: string, time.Duration (as int64), []string // (comma-separated), bool, int. func applyEnvToStruct(v reflect.Value, prefix string) { if v.Kind() == reflect.Ptr { v = v.Elem() } t := v.Type() for i := range t.NumField() { field := t.Field(i) fv := v.Field(i) // For anonymous (embedded) fields, recurse with the same prefix. if field.Anonymous { applyEnvToStruct(fv, prefix) continue } tag := field.Tag.Get("toml") if tag == "" || tag == "-" { continue } envKey := prefix + "_" + strings.ToUpper(tag) // Handle Duration wrapper before generic struct recursion. if field.Type == reflect.TypeOf(Duration{}) { envVal, ok := os.LookupEnv(envKey) if ok { d, parseErr := time.ParseDuration(envVal) if parseErr == nil { fv.Set(reflect.ValueOf(Duration{d})) } } continue } if field.Type.Kind() == reflect.Struct { applyEnvToStruct(fv, envKey) continue } envVal, ok := os.LookupEnv(envKey) if !ok { continue } switch fv.Kind() { case reflect.String: fv.SetString(envVal) case reflect.Bool: fv.SetBool(envVal == "true" || envVal == "1") case reflect.Slice: if field.Type.Elem().Kind() == reflect.String { parts := strings.Split(envVal, ",") for j := range parts { parts[j] = strings.TrimSpace(parts[j]) } fv.Set(reflect.ValueOf(parts)) } } } }