feat(cmd/broker): wire config → log → store → httpserver (forgejo-mcp-broker-t37)

Final phase-1 step: the broker now starts. run() parses config, opens
the store, builds the httpserver, and blocks on signal.NotifyContext
until SIGTERM/SIGINT fires, at which point it drains through
httpserver.Run's graceful-shutdown path and closes the store.

--version is handled before config.Load so operators can inspect a
binary without providing the rest of the config. flag.ErrHelp is passed
through so -h exits 0. Config failure exits 2; runtime failure exits 1.

Integration tests build the binary once in TestMain and exercise three
acceptance scenarios against it:
- --version: prints build info, exits 0
- no config: exits nonzero with stderr listing every missing field
- full startup: /healthz returns 200 with correct JSON; SIGTERM triggers
  clean exit within 2s

Closes forgejo-mcp-broker-t37. Phase 1 complete.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-04-24 17:29:37 +02:00
commit 09fcdc5af4
3 changed files with 265 additions and 18 deletions

View file

@ -1,31 +1,96 @@
// Command fjmcp-broker is an OAuth 2.1 authorization server and MCP session
// broker that fronts forgejo-mcp. See ../../README.md and ../../docs/ for the
// design.
// broker that fronts forgejo-mcp. See ../../README.md and ../../docs/ for
// the design.
package main
import (
"context"
"errors"
"flag"
"fmt"
"io"
"os"
"os/signal"
"syscall"
"kode.naiv.no/olemd/forgejo-mcp-broker/internal/buildinfo"
"kode.naiv.no/olemd/forgejo-mcp-broker/internal/config"
"kode.naiv.no/olemd/forgejo-mcp-broker/internal/httpserver"
brokerlog "kode.naiv.no/olemd/forgejo-mcp-broker/internal/log"
"kode.naiv.no/olemd/forgejo-mcp-broker/internal/store"
)
// Exit codes follow the usual convention: 0 success, 2 config/usage, 1 runtime.
const (
exitSuccess = 0
exitRuntime = 1
exitConfig = 2
)
func main() {
var showVersion bool
flag.BoolVar(&showVersion, "version", false, "print build info and exit")
flag.Parse()
os.Exit(run(os.Args[1:], os.Stderr))
}
if showVersion {
fmt.Printf("fjmcp-broker %s (rev %s, built %s)\n",
buildinfo.Version, buildinfo.GitRevision, buildinfo.BuildDate)
return
// run is the testable entry point. Parses config, wires dependencies, and
// blocks until the HTTP server exits or a shutdown signal arrives. Returns
// an OS exit code.
func run(args []string, out io.Writer) int {
// --version is handled before config.Load so operators can inspect a
// binary without providing the rest of the required config.
for _, a := range args {
if a == "--version" || a == "-version" {
fmt.Fprintf(out, "fjmcp-broker %s (rev %s, built %s)\n",
buildinfo.Version, buildinfo.GitRevision, buildinfo.BuildDate)
return exitSuccess
}
}
// Full startup wiring (config → log → store → httpserver) lands in
// forgejo-mcp-broker-t37. Until then this binary only serves --version
// so the bootstrap acceptance criteria can be exercised.
fmt.Fprintln(os.Stderr, "fjmcp-broker: runtime wiring not yet implemented (phase 1 in progress)")
fmt.Fprintln(os.Stderr, "Use --version to print build info.")
os.Exit(2)
cfg, err := config.Load(args, out)
switch {
case errors.Is(err, flag.ErrHelp):
return exitSuccess
case err != nil:
fmt.Fprintln(out, "fjmcp-broker: config error:")
fmt.Fprintln(out, err.Error())
return exitConfig
}
logger := brokerlog.New(out, cfg.Debug)
logger.Info("starting broker",
"listen", cfg.Listen,
"public_url", cfg.PublicURL,
"forgejo_url", cfg.ForgejoURL,
"store_path", cfg.StorePath,
"max_sessions", cfg.MaxSessions,
"idle_timeout", cfg.SessionIdleTimeout.String(),
)
// Signal handling is owned here; the HTTP server just responds to ctx
// cancellation. This keeps internal/httpserver free of signal coupling
// and makes it testable without any OS-level wiring.
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
st, err := store.Open(ctx, cfg.StorePath)
if err != nil {
logger.Error("open store", "err", err.Error())
return exitRuntime
}
defer func() {
if err := st.Close(); err != nil {
logger.Error("close store", "err", err.Error())
}
}()
srv := &httpserver.Server{
Addr: cfg.Listen,
Log: logger,
Store: st,
}
if err := srv.Run(ctx); err != nil {
logger.Error("server exit", "err", err.Error())
return exitRuntime
}
logger.Info("broker stopped")
return exitSuccess
}