config.go 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  1. // Package config loads and validates the server configuration from the
  2. // environment.
  3. package config
  4. import (
  5. "errors"
  6. "fmt"
  7. "log/slog"
  8. "net"
  9. "net/url"
  10. "os"
  11. "strconv"
  12. "strings"
  13. "time"
  14. )
  15. // Supported MCP transports.
  16. const (
  17. TransportStdio = "stdio"
  18. TransportHTTP = "http"
  19. )
  20. // Defaults for optional settings.
  21. const (
  22. DefaultAddr = "127.0.0.1:8080"
  23. DefaultPath = "/mcp"
  24. DefaultTimeout = 30 * time.Second
  25. DefaultLogLevel = slog.LevelInfo
  26. )
  27. // Config holds the complete runtime configuration.
  28. type Config struct {
  29. // WikiJS connection.
  30. WikiJSURL string
  31. WikiJSAPIKey string
  32. // EnableEdit exposes the create_page and edit_page tools.
  33. EnableEdit bool
  34. // Timeout applies to a single WikiJS GraphQL request.
  35. Timeout time.Duration
  36. // Transport is either TransportStdio or TransportHTTP.
  37. Transport string
  38. // Addr is the listen address for the HTTP transport.
  39. Addr string
  40. // Path is the HTTP path that serves the MCP endpoint.
  41. Path string
  42. // Stateless disables MCP sessions on the HTTP transport.
  43. Stateless bool
  44. // AuthToken protects the HTTP transport with a bearer token. Empty means
  45. // no authentication.
  46. AuthToken string
  47. // LogLevel controls the stderr logger.
  48. LogLevel slog.Level
  49. }
  50. // Load reads the configuration from the environment and applies defaults.
  51. // Required values and transport settings are validated by [Config.Validate] so
  52. // that flags such as -version and -help work without credentials present.
  53. func Load() (Config, error) {
  54. cfg := Config{
  55. WikiJSURL: strings.TrimSpace(os.Getenv("WIKIJS_URL")),
  56. WikiJSAPIKey: strings.TrimSpace(os.Getenv("WIKIJS_API_KEY")),
  57. Transport: strings.ToLower(strings.TrimSpace(os.Getenv("MCP_TRANSPORT"))),
  58. Addr: strings.TrimSpace(os.Getenv("MCP_ADDR")),
  59. Path: strings.TrimSpace(os.Getenv("MCP_PATH")),
  60. AuthToken: strings.TrimSpace(os.Getenv("MCP_AUTH_TOKEN")),
  61. Timeout: DefaultTimeout,
  62. LogLevel: DefaultLogLevel,
  63. }
  64. if cfg.Transport == "" {
  65. cfg.Transport = TransportStdio
  66. }
  67. if cfg.Addr == "" {
  68. cfg.Addr = DefaultAddr
  69. }
  70. if cfg.Path == "" {
  71. cfg.Path = DefaultPath
  72. }
  73. var err error
  74. if cfg.EnableEdit, err = boolEnv("WIKIJS_ENABLE_EDIT", false); err != nil {
  75. return Config{}, err
  76. }
  77. if cfg.Stateless, err = boolEnv("MCP_STATELESS", false); err != nil {
  78. return Config{}, err
  79. }
  80. if raw := strings.TrimSpace(os.Getenv("WIKIJS_TIMEOUT")); raw != "" {
  81. cfg.Timeout, err = time.ParseDuration(raw)
  82. if err != nil {
  83. return Config{}, fmt.Errorf("WIKIJS_TIMEOUT: %w", err)
  84. }
  85. }
  86. if raw := strings.TrimSpace(os.Getenv("LOG_LEVEL")); raw != "" {
  87. if cfg.LogLevel, err = ParseLogLevel(raw); err != nil {
  88. return Config{}, err
  89. }
  90. }
  91. return cfg, nil
  92. }
  93. // Validate checks required values and the settings that can be overridden by
  94. // command line flags.
  95. func (c *Config) Validate() error {
  96. if c.WikiJSURL == "" {
  97. return errors.New("WIKIJS_URL is required")
  98. }
  99. if c.WikiJSAPIKey == "" {
  100. return errors.New("WIKIJS_API_KEY is required")
  101. }
  102. u, err := url.Parse(c.WikiJSURL)
  103. if err != nil {
  104. return fmt.Errorf("WIKIJS_URL: %w", err)
  105. }
  106. if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
  107. return fmt.Errorf("WIKIJS_URL must be an absolute http(s) URL, got %q", c.WikiJSURL)
  108. }
  109. if u.RawQuery != "" || u.Fragment != "" {
  110. return fmt.Errorf("WIKIJS_URL must not contain a query or fragment, got %q", c.WikiJSURL)
  111. }
  112. switch c.Transport {
  113. case TransportStdio, TransportHTTP:
  114. default:
  115. return fmt.Errorf("MCP_TRANSPORT must be %q or %q, got %q", TransportStdio, TransportHTTP, c.Transport)
  116. }
  117. if c.Addr == "" {
  118. return errors.New("MCP_ADDR must not be empty")
  119. }
  120. if c.Transport == TransportHTTP {
  121. host, port, err := net.SplitHostPort(c.Addr)
  122. if err != nil || port == "" {
  123. return fmt.Errorf("MCP_ADDR must be a host:port address, got %q", c.Addr)
  124. }
  125. if c.AuthToken == "" && !isLoopbackHost(host) {
  126. return fmt.Errorf("MCP_ADDR %q is not loopback; set MCP_AUTH_TOKEN or bind to 127.0.0.1", c.Addr)
  127. }
  128. }
  129. if !strings.HasPrefix(c.Path, "/") {
  130. return fmt.Errorf("MCP_PATH must start with %q, got %q", "/", c.Path)
  131. }
  132. if c.Path == "/healthz" {
  133. return errors.New(`MCP_PATH must not be "/healthz"`)
  134. }
  135. if c.Timeout <= 0 {
  136. return errors.New("WIKIJS_TIMEOUT must be positive")
  137. }
  138. return nil
  139. }
  140. func isLoopbackHost(host string) bool {
  141. if strings.EqualFold(host, "localhost") {
  142. return true
  143. }
  144. ip := net.ParseIP(host)
  145. return ip != nil && ip.IsLoopback()
  146. }
  147. func boolEnv(name string, fallback bool) (bool, error) {
  148. raw := strings.TrimSpace(os.Getenv(name))
  149. if raw == "" {
  150. return fallback, nil
  151. }
  152. v, err := strconv.ParseBool(raw)
  153. if err != nil {
  154. return false, fmt.Errorf("%s must be a boolean: %w", name, err)
  155. }
  156. return v, nil
  157. }
  158. // ParseLogLevel turns a human-readable level name into a [slog.Level].
  159. func ParseLogLevel(raw string) (slog.Level, error) {
  160. switch strings.ToLower(raw) {
  161. case "debug":
  162. return slog.LevelDebug, nil
  163. case "info":
  164. return slog.LevelInfo, nil
  165. case "warn", "warning":
  166. return slog.LevelWarn, nil
  167. case "error":
  168. return slog.LevelError, nil
  169. default:
  170. return 0, fmt.Errorf("LOG_LEVEL must be one of debug, info, warn, error; got %q", raw)
  171. }
  172. }