// Package config loads broker configuration from environment variables and // command-line flags, applies defaults, and validates the result. // // Precedence: flags win over environment variables. Empty environment values // are treated as unset, so operators can neutralise an exported variable // without needing to unset it. Required fields have no default and must be // supplied one way or the other. Validation is strict — the process must not // start with invalid config. package config import ( "errors" "flag" "fmt" "io" "net/url" "os" "path/filepath" "strconv" "time" ) // Default values for optional fields. Exposed so tests and callers can // reference them without re-hardcoding. const ( DefaultListen = ":8080" DefaultForgejoOAuthScopes = "read:user write:repository write:issue write:notification read:organization" DefaultForgejoMCPBinary = "forgejo-mcp" DefaultStorePath = "/data/broker.db" DefaultMaxSessions = 100 DefaultSessionIdleTimeout = 15 * time.Minute ) // Config holds fully resolved and validated broker configuration. type Config struct { PublicURL string Listen string ForgejoURL string ForgejoOAuthClientID string ForgejoOAuthClientSecret string ForgejoOAuthScopes string ForgejoMCPBinary string StorePath string MaxSessions int SessionIdleTimeout time.Duration Debug bool } // Load resolves configuration from environment variables and the provided // command-line arguments (typically os.Args[1:]), then validates the result. // out receives flag-package output (usage text on -h / parse errors). // // Returns flag.ErrHelp when the user requested help — callers should treat // that as a normal exit, not a configuration failure. func Load(args []string, out io.Writer) (*Config, error) { cfg, err := fromEnv() if err != nil { return nil, err } fs := flag.NewFlagSet("fjmcp-broker", flag.ContinueOnError) fs.SetOutput(out) cfg.bindFlags(fs) if err := fs.Parse(args); err != nil { return nil, err } if err := cfg.Validate(); err != nil { return nil, err } return cfg, nil } func fromEnv() (*Config, error) { cfg := &Config{ PublicURL: envOr("FJMCP_BROKER_PUBLIC_URL", ""), Listen: envOr("FJMCP_BROKER_LISTEN", DefaultListen), ForgejoURL: envOr("FORGEJO_URL", ""), ForgejoOAuthClientID: envOr("FORGEJO_OAUTH_CLIENT_ID", ""), ForgejoOAuthClientSecret: envOr("FORGEJO_OAUTH_CLIENT_SECRET", ""), ForgejoOAuthScopes: envOr("FORGEJO_OAUTH_SCOPES", DefaultForgejoOAuthScopes), ForgejoMCPBinary: envOr("FJMCP_BROKER_MCP_BINARY", DefaultForgejoMCPBinary), StorePath: envOr("FJMCP_BROKER_STORE", DefaultStorePath), MaxSessions: DefaultMaxSessions, SessionIdleTimeout: DefaultSessionIdleTimeout, } if v, ok := lookupNonEmpty("FJMCP_BROKER_MAX_SESSIONS"); ok { n, err := strconv.Atoi(v) if err != nil { return nil, fmt.Errorf("FJMCP_BROKER_MAX_SESSIONS: %w", err) } cfg.MaxSessions = n } if v, ok := lookupNonEmpty("FJMCP_BROKER_IDLE_TIMEOUT"); ok { d, err := time.ParseDuration(v) if err != nil { return nil, fmt.Errorf("FJMCP_BROKER_IDLE_TIMEOUT: %w", err) } cfg.SessionIdleTimeout = d } if v, ok := lookupNonEmpty("FJMCP_BROKER_DEBUG"); ok { b, err := strconv.ParseBool(v) if err != nil { return nil, fmt.Errorf("FJMCP_BROKER_DEBUG: %w", err) } cfg.Debug = b } return cfg, nil } func (c *Config) bindFlags(fs *flag.FlagSet) { fs.StringVar(&c.PublicURL, "public-url", c.PublicURL, "Public issuer URL, e.g. https://mcp.example.com (REQUIRED; env FJMCP_BROKER_PUBLIC_URL)") fs.StringVar(&c.Listen, "listen", c.Listen, "HTTP listen address (env FJMCP_BROKER_LISTEN)") fs.StringVar(&c.ForgejoURL, "forgejo-url", c.ForgejoURL, "Upstream Forgejo instance URL (REQUIRED; env FORGEJO_URL)") fs.StringVar(&c.ForgejoOAuthClientID, "forgejo-oauth-client-id", c.ForgejoOAuthClientID, "Forgejo OAuth client ID (REQUIRED; env FORGEJO_OAUTH_CLIENT_ID)") fs.StringVar(&c.ForgejoOAuthClientSecret, "forgejo-oauth-client-secret", c.ForgejoOAuthClientSecret, "Forgejo OAuth client secret (REQUIRED; env FORGEJO_OAUTH_CLIENT_SECRET)") fs.StringVar(&c.ForgejoOAuthScopes, "forgejo-oauth-scopes", c.ForgejoOAuthScopes, "Space-separated OAuth scopes requested from Forgejo (env FORGEJO_OAUTH_SCOPES)") fs.StringVar(&c.ForgejoMCPBinary, "forgejo-mcp-binary", c.ForgejoMCPBinary, "Path to the forgejo-mcp binary (env FJMCP_BROKER_MCP_BINARY)") fs.StringVar(&c.StorePath, "store-path", c.StorePath, "SQLite store file path (env FJMCP_BROKER_STORE)") fs.IntVar(&c.MaxSessions, "max-sessions", c.MaxSessions, "Maximum concurrent MCP sessions (env FJMCP_BROKER_MAX_SESSIONS)") fs.DurationVar(&c.SessionIdleTimeout, "session-idle-timeout", c.SessionIdleTimeout, "Idle timeout before a session subprocess is reaped (env FJMCP_BROKER_IDLE_TIMEOUT)") fs.BoolVar(&c.Debug, "debug", c.Debug, "Verbose logging (env FJMCP_BROKER_DEBUG)") } // Validate checks that required fields are present and all values are // internally consistent. Errors are aggregated so the operator sees every // problem at once. func (c *Config) Validate() error { var errs []error if c.PublicURL == "" { errs = append(errs, errors.New("public-url is required (--public-url or FJMCP_BROKER_PUBLIC_URL)")) } if c.ForgejoURL == "" { errs = append(errs, errors.New("forgejo-url is required (--forgejo-url or FORGEJO_URL)")) } if c.ForgejoOAuthClientID == "" { errs = append(errs, errors.New("forgejo-oauth-client-id is required (--forgejo-oauth-client-id or FORGEJO_OAUTH_CLIENT_ID)")) } if c.ForgejoOAuthClientSecret == "" { errs = append(errs, errors.New("forgejo-oauth-client-secret is required (--forgejo-oauth-client-secret or FORGEJO_OAUTH_CLIENT_SECRET)")) } if c.PublicURL != "" { if err := validateIssuerURL("public-url", c.PublicURL); err != nil { errs = append(errs, err) } } if c.ForgejoURL != "" { if err := validateIssuerURL("forgejo-url", c.ForgejoURL); err != nil { errs = append(errs, err) } } if c.StorePath == "" { errs = append(errs, errors.New("store-path must not be empty")) } else if err := checkStorePathWritable(c.StorePath); err != nil { errs = append(errs, fmt.Errorf("store-path: %w", err)) } if c.Listen == "" { errs = append(errs, errors.New("listen must not be empty")) } if c.MaxSessions <= 0 { errs = append(errs, fmt.Errorf("max-sessions must be > 0, got %d", c.MaxSessions)) } if c.SessionIdleTimeout <= 0 { errs = append(errs, fmt.Errorf("session-idle-timeout must be > 0, got %s", c.SessionIdleTimeout)) } return errors.Join(errs...) } // validateIssuerURL rejects malformed URLs, enforces http/https scheme, and // disallows plain http for non-loopback hosts. Publishing an http issuer for // a non-loopback host is a classic OAuth misconfiguration that undermines // the whole point of TLS on the AS. func validateIssuerURL(field, raw string) error { u, err := url.Parse(raw) if err != nil { return fmt.Errorf("%s: %w", field, err) } if u.Scheme != "http" && u.Scheme != "https" { return fmt.Errorf("%s: scheme must be http or https, got %q", field, u.Scheme) } if u.Host == "" { return fmt.Errorf("%s: missing host in %q", field, raw) } if u.Scheme == "http" && !isLoopback(u.Hostname()) { return fmt.Errorf("%s: http scheme only allowed for loopback hosts, got %q", field, u.Hostname()) } return nil } func isLoopback(host string) bool { switch host { case "localhost", "127.0.0.1", "::1": return true } return false } // checkStorePathWritable verifies the store's parent directory exists and // the current process can create files in it. The store file itself is not // created here — that is the storage layer's responsibility. func checkStorePathWritable(path string) error { abs, err := filepath.Abs(path) if err != nil { return fmt.Errorf("invalid path %q: %w", path, err) } dir := filepath.Dir(abs) info, err := os.Stat(dir) if err != nil { return fmt.Errorf("parent directory %q: %w", dir, err) } if !info.IsDir() { return fmt.Errorf("parent %q is not a directory", dir) } f, err := os.CreateTemp(dir, ".fjmcp-broker-probe-*") if err != nil { return fmt.Errorf("parent directory %q not writable: %w", dir, err) } name := f.Name() _ = f.Close() _ = os.Remove(name) return nil } func envOr(key, fallback string) string { if v, ok := lookupNonEmpty(key); ok { return v } return fallback } // lookupNonEmpty returns the env value and true only when the variable is // set AND non-empty. An exported-but-empty variable is treated as unset, // matching common shell usage. func lookupNonEmpty(key string) (string, bool) { v, ok := os.LookupEnv(key) if !ok || v == "" { return "", false } return v, true }