diff --git a/config/config.go b/config/config.go index b28ff58..14772de 100644 --- a/config/config.go +++ b/config/config.go @@ -144,6 +144,8 @@ func Load[T any](path string, envPrefix string) (*T, error) { applyEnvToStruct(reflect.ValueOf(&cfg).Elem(), envPrefix) } + applyPortEnv(&cfg) + applyBaseDefaults(&cfg) if err := validateBase(&cfg); err != nil { @@ -239,6 +241,70 @@ func findBase(cfg any) *Base { 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). diff --git a/config/config_test.go b/config/config_test.go index 7f41232..fbe1356 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -401,3 +401,109 @@ func TestEmptyEnvPrefix(t *testing.T) { t.Fatalf("ListenAddr = %q, want %q", cfg.Server.ListenAddr, ":8443") } } + +// directServerConfig embeds ServerConfig without Base (Metacrypt pattern). +type directServerConfig struct { + Server ServerConfig `toml:"server"` + Extra string `toml:"extra"` +} + +func TestPortEnvOverridesListenAddr(t *testing.T) { + path := writeTOML(t, minimalTOML) + t.Setenv("PORT", "9999") + + cfg, err := Load[testConfig](path, "TEST") + if err != nil { + t.Fatalf("Load: %v", err) + } + + if cfg.Server.ListenAddr != ":9999" { + t.Fatalf("ListenAddr = %q, want %q", cfg.Server.ListenAddr, ":9999") + } +} + +func TestPortGRPCEnvOverridesGRPCAddr(t *testing.T) { + path := writeTOML(t, minimalTOML) + t.Setenv("PORT_GRPC", "9998") + + cfg, err := Load[testConfig](path, "TEST") + if err != nil { + t.Fatalf("Load: %v", err) + } + + if cfg.Server.GRPCAddr != ":9998" { + t.Fatalf("GRPCAddr = %q, want %q", cfg.Server.GRPCAddr, ":9998") + } +} + +func TestPortEnvOverridesTOMLValue(t *testing.T) { + // fullTOML sets listen_addr = ":8443" and grpc_addr = ":9443". + path := writeTOML(t, fullTOML) + t.Setenv("PORT", "9999") + + cfg, err := Load[testConfig](path, "TEST") + if err != nil { + t.Fatalf("Load: %v", err) + } + + if cfg.Server.ListenAddr != ":9999" { + t.Fatalf("ListenAddr = %q, want %q ($PORT should override TOML)", cfg.Server.ListenAddr, ":9999") + } +} + +func TestPortEnvOverridesGenericEnv(t *testing.T) { + path := writeTOML(t, minimalTOML) + t.Setenv("TEST_SERVER_LISTEN_ADDR", ":7777") + t.Setenv("PORT", "9999") + + cfg, err := Load[testConfig](path, "TEST") + if err != nil { + t.Fatalf("Load: %v", err) + } + + if cfg.Server.ListenAddr != ":9999" { + t.Fatalf("ListenAddr = %q, want %q ($PORT should override generic env)", cfg.Server.ListenAddr, ":9999") + } +} + +func TestNoPortEnvNoChange(t *testing.T) { + path := writeTOML(t, minimalTOML) + + cfg, err := Load[testConfig](path, "TEST") + if err != nil { + t.Fatalf("Load: %v", err) + } + + // minimalTOML sets listen_addr = ":8443", no $PORT set. + if cfg.Server.ListenAddr != ":8443" { + t.Fatalf("ListenAddr = %q, want %q (TOML value preserved without $PORT)", cfg.Server.ListenAddr, ":8443") + } +} + +func TestPortEnvDirectServerConfig(t *testing.T) { + // Test the Metacrypt pattern: ServerConfig embedded without Base. + toml := ` +[server] +listen_addr = ":8443" +tls_cert = "/tmp/cert.pem" +tls_key = "/tmp/key.pem" +grpc_addr = ":9443" + +extra = "value" +` + path := writeTOML(t, toml) + t.Setenv("PORT", "5555") + t.Setenv("PORT_GRPC", "5556") + + cfg, err := Load[directServerConfig](path, "TEST") + if err != nil { + t.Fatalf("Load: %v", err) + } + + if cfg.Server.ListenAddr != ":5555" { + t.Fatalf("ListenAddr = %q, want %q ($PORT on direct ServerConfig)", cfg.Server.ListenAddr, ":5555") + } + if cfg.Server.GRPCAddr != ":5556" { + t.Fatalf("GRPCAddr = %q, want %q ($PORT_GRPC on direct ServerConfig)", cfg.Server.GRPCAddr, ":5556") + } +}