// Command spark-mcp-go launches the Spark MCP Server. // // Phase 4 scope: wire config + slog + Gin + /healthz + /admin/* cluster CRUD // with audit log. MCP transport and Tool surface land in later phases. package main import ( "context" "errors" "log/slog" "net/http" "os" "os/signal" "syscall" "time" "github.com/gin-gonic/gin" "spark-mcp-go/internal/admin" "spark-mcp-go/internal/audit" "spark-mcp-go/internal/config" "spark-mcp-go/internal/logging" "spark-mcp-go/internal/storage" ) func main() { if err := run(); err != nil { // run() handles its own logging; this is the last-chance report. slog.Error("fatal", "err", err) os.Exit(1) } } func run() error { cfg, err := config.Load() if err != nil { return err } logger, shutdownLog, err := logging.New(logging.Config{ Level: cfg.LogLevel, Dir: cfg.LogDir, Format: cfg.LogFormat, LogToStd: true, }) if err != nil { return err } defer shutdownLog() slog.SetDefault(logger) if err := os.MkdirAll(cfg.LogDir+"/tools", 0o700); err != nil { return err } db, err := storage.Open(cfg.SQLitePath) if err != nil { return err } defer db.Close() logger.Info("storage.open", "path", cfg.SQLitePath) gin.SetMode(gin.ReleaseMode) r := gin.New() r.Use(gin.Recovery()) r.GET("/healthz", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true, "version": "0.0.0"}) }) admin.Mount(r, db.Clusters(), audit.NewRepo(db), cfg.AdminTokens) srv := &http.Server{ Addr: cfg.ListenAddr, Handler: r, ReadHeaderTimeout: 10 * time.Second, } errCh := make(chan error, 1) go func() { logger.Info("server.start", "addr", cfg.ListenAddr, "data_dir", cfg.DataDir) if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { errCh <- err } }() stop := make(chan os.Signal, 1) signal.Notify(stop, os.Interrupt, syscall.SIGTERM) select { case err := <-errCh: return err case sig := <-stop: logger.Info("server.shutdown", "signal", sig.String()) } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() return srv.Shutdown(ctx) }