package tools import ( "context" "fmt" "strings" "code.nochebuena.dev/einherjar/mcp/internal/envspec" "code.nochebuena.dev/einherjar/mcp/internal/index" "github.com/modelcontextprotocol/go-sdk/mcp" ) type getScaffoldInput struct { Module string `json:"module,omitempty" jsonschema:"the go.mod module path of the new app, e.g. code.nochebuena.dev/org/myapp; used to fill import paths. Defaults to 'myapp'."` Service string `json:"service,omitempty" jsonschema:"short service name for the cmd/ dir and the logz service tag, e.g. myapp. Defaults to the last path segment of module."` } type scaffoldFile struct { Path string `json:"path"` Content string `json:"content"` } type getScaffoldOutput struct { Layout string `json:"layout"` Files []scaffoldFile `json:"files"` Notes []string `json:"notes"` } func registerGetScaffold(s *mcp.Server, idx *index.Index) { mcp.AddTool(s, &mcp.Tool{ Name: "get_scaffold", Description: "Return the canonical MINIMUM Einherjar application scaffold as ready-to-write files: " + "a clean main.go (godotenv autoload + wire.Run), internal/wire/wire.go (the launcher assembly), " + "internal/config/config.go (one Config composing the framework's component configs via caarlos0/env), " + "a health feature hook, and .env.example. This is the one opinionated starting point — call it when " + "creating a new Einherjar service so main.go and the launcher stay clean instead of being hand-rolled. " + "Migrations and seeding are deliberately excluded — those are the developer's choice, not a framework convention.", }, func(ctx context.Context, req *mcp.CallToolRequest, args getScaffoldInput) (*mcp.CallToolResult, getScaffoldOutput, error) { module := strings.TrimSpace(args.Module) if module == "" { module = "myapp" } service := strings.TrimSpace(args.Service) if service == "" { service = module if i := strings.LastIndex(module, "/"); i >= 0 { service = module[i+1:] } } repl := strings.NewReplacer("%%MODULE%%", module, "%%APP%%", service) out := getScaffoldOutput{ Layout: scaffoldLayout, Files: []scaffoldFile{ {Path: "cmd/" + service + "/main.go", Content: repl.Replace(tplMain)}, {Path: "internal/wire/wire.go", Content: repl.Replace(tplWire)}, {Path: "internal/wire/health.go", Content: repl.Replace(tplHealth)}, {Path: "internal/config/config.go", Content: repl.Replace(tplConfig)}, {Path: ".env.example", Content: renderEnvExample(idx, service)}, }, Notes: []string{ "main.go contains ONLY the godotenv autoload blank import and wire.Run() — never construct components there.", ".env.example is DERIVED from the framework's real component-config tags (web/server + db-postgres), so the EINHERJAR_* names and defaults are always correct. When you compose another component, add its section — get_config_env(module) lists the exact vars, and check_env catches drift.", "App-owned config fields use the APP_* prefix; framework component configs load their own EINHERJAR_* tags through the same env.Parse.", "Add one internal/wire/.go per feature (a with hook) plus its internal//{dto,handler,repository,service} layers; call validate_snippet / get_example(\"wire\") for the hook shape.", "Migrations and seeding are the developer's choice — not part of this scaffold.", }, } return jsonText(out), out, nil }) } const scaffoldLayout = `cmd/%%APP%%/main.go godotenv autoload + wire.Run() internal/wire/wire.go Run() — config, infra, feature hooks internal/wire/.go one file per feature (with hook) internal/wire/middleware.go authz / skip helpers (add when you add authz'd routes) internal/config/config.go Config composing framework component configs .env.example every env var the config reads, in sync internal//{dto,handler,repository,service}/` const tplMain = `package main import ( "fmt" "os" _ "github.com/joho/godotenv/autoload" "%%MODULE%%/internal/wire" ) func main() { if err := wire.Run(); err != nil { fmt.Fprintln(os.Stderr, "fatal:", err) os.Exit(1) } } ` const tplWire = `package wire import ( "net/http" "strings" "github.com/google/uuid" "code.nochebuena.dev/einherjar/core/launcher" "code.nochebuena.dev/einherjar/core/logz" "code.nochebuena.dev/einherjar/db-postgres" "code.nochebuena.dev/einherjar/web/mw" "code.nochebuena.dev/einherjar/web/server" "%%MODULE%%/internal/config" ) // Run loads configuration, builds infrastructure, registers feature hooks, and // blocks until shutdown. Order is load-bearing: config, logger, infra, launcher // with every component appended, then feature hooks, then lc.Run(). func Run() error { cfg, err := config.Load() if err != nil { return err } // logz.Config is composed in config.Config, so EINHERJAR_LOG_LEVEL / _JSON load // from the environment; StaticArgs are set here in code (they carry no env tag). logCfg := cfg.Log logCfg.StaticArgs = []any{"service", "%%APP%%", "env", cfg.AppEnv} logger := logz.New(logCfg) db := postgres.New(logger, cfg.PG) // CORS: allow-all in local dev; explicit origins elsewhere. Origins come from the // framework's own EINHERJAR_SERVER_CORS_ORIGINS (cfg.Server.CORSOrigins). mw.CORS // panics on "*", so never set that var to a wildcard outside local. var corsMW func(http.Handler) http.Handler if strings.EqualFold(cfg.AppEnv, "local") { corsMW = mw.CORSAllowAll() } else { corsMW = mw.CORS(cfg.Server.CORSOrigins) } srv := server.New(logger, cfg.Server, server.WithMiddleware( // Same stack and order as web.New — Recover outermost, time-ordered // request ID, then logging. The only deliberate difference is the // env-gated allow-all CORS (corsMW) above, which web.New does not offer. mw.Recover(logger), mw.RequestID(newRequestID), mw.RequestLogger(logger), corsMW, ), ) lc := launcher.New(logger, cfg.Launcher) lc.Append(db, srv) withHealth(lc, srv, logger, cfg.Health, db) // … one withFeature(lc, srv, …) call per feature in your domain. return lc.Run() } // newRequestID returns a time-ordered UUID v7 (falling back to v4 on error), // matching web.New's request-ID generator. func newRequestID() string { id, err := uuid.NewV7() if err != nil { return uuid.NewString() } return id.String() } ` const tplHealth = `package wire import ( "code.nochebuena.dev/einherjar/contracts/logging" "code.nochebuena.dev/einherjar/contracts/observability" "code.nochebuena.dev/einherjar/core/launcher" "code.nochebuena.dev/einherjar/web/health" "code.nochebuena.dev/einherjar/web/server" ) // withHealth wires a concurrent health endpoint from the app's Checkable components // (db, cache, …). health.NewHandlerWithConfig honors EINHERJAR_HEALTH_CHECK_TIMEOUT // through cfg. Add more with hooks the same way; see get_example("wire"). func withHealth(lc launcher.Launcher, srv server.Server, logger logging.Logger, cfg health.Config, checks ...observability.Checkable) { lc.BeforeStart(func() error { srv.Get("/health", health.NewHandlerWithConfig(logger, cfg, checks...).ServeHTTP) return nil }) } ` const tplConfig = `// Package config loads %%APP%%'s startup configuration from the environment. // // Einherjar component configs (server.Config, postgres.Config, …) are composed // verbatim as nested fields so their EINHERJAR_* env tags load alongside the // app-owned APP_* fields through a single caarlos0/env parse. Every env var // declared here must also be documented in .env.example, kept in lock-step. package config import ( "github.com/caarlos0/env/v11" "code.nochebuena.dev/einherjar/core/launcher" "code.nochebuena.dev/einherjar/core/logz" "code.nochebuena.dev/einherjar/db-postgres" "code.nochebuena.dev/einherjar/web/health" "code.nochebuena.dev/einherjar/web/server" ) // Config is the fully-resolved startup configuration. caarlos0/env recurses into // the nested framework configs, populating their EINHERJAR_* tags from the // environment next to the app-owned APP_* fields. type Config struct { AppEnv string ` + "`" + `env:"APP_ENV" envDefault:"local"` + "`" + ` // Framework component configs — composed verbatim; their EINHERJAR_* tags // load through this same env.Parse call. Launcher launcher.Config // EINHERJAR_COMPONENT_STOP_TIMEOUT Log logz.Config // EINHERJAR_LOG_* Server server.Config // EINHERJAR_SERVER_* (incl. EINHERJAR_SERVER_CORS_ORIGINS) Health health.Config // EINHERJAR_HEALTH_CHECK_TIMEOUT PG postgres.Config // EINHERJAR_PG_* } func Load() (Config, error) { var cfg Config if err := env.Parse(&cfg); err != nil { return Config{}, err } return cfg, nil } ` // renderEnvExample builds .env.example from the framework's real component-config // tags rather than a hand-maintained string, so the EINHERJAR_* names and defaults // can never drift from the modules the scaffold composes (web/server + db-postgres). // Required vars (no default) are emitted uncommented with a runnable dev value; // vars that carry a framework default are emitted commented — documented, inert // until overridden. func renderEnvExample(idx *index.Index, app string) string { var b strings.Builder b.WriteString("# .env.example — copy to .env for local dev (godotenv autoloads it).\n") b.WriteString("# Derived from the framework's real config tags. Required vars are uncommented;\n") b.WriteString("# a commented line documents that var's framework default — uncomment to override.\n\n") b.WriteString("# ── App (APP_*) ────────────────────────────────────────────────────────────\n") b.WriteString("APP_ENV=local\n") b.WriteString("# CORS: local uses mw.CORSAllowAll(); non-local reads EINHERJAR_SERVER_CORS_ORIGINS below.\n\n") writeEnvSection(&b, "Einherjar: launcher", envspec.FindStruct(idx, "core", "launcher", "Config"), app) writeEnvSection(&b, "Einherjar: logging", envspec.FindStruct(idx, "core", "logz", "Config"), app) writeEnvSection(&b, "Einherjar: HTTP server", envspec.FindStruct(idx, "web", "server", "Config"), app) writeEnvSection(&b, "Einherjar: health", envspec.FindStruct(idx, "web", "health", "Config"), app) writeEnvSection(&b, "Einherjar: PostgreSQL", envspec.FindStruct(idx, "db-postgres", "", "Config"), app) return b.String() } // envDevDefaults gives required vars (which carry no framework default) a value // that runs against the local devtools stack out of the box. var envDevDefaults = map[string]string{ "EINHERJAR_PG_HOST": "localhost", "EINHERJAR_PG_USER": "postgres", "EINHERJAR_PG_PASSWORD": "postgres", } func envDevValue(v envspec.Var, app string) string { if v.Name == "EINHERJAR_PG_NAME" { return app } return envDevDefaults[v.Name] } func writeEnvSection(b *strings.Builder, title string, vars []envspec.Var, app string) { if len(vars) == 0 { return } fmt.Fprintf(b, "# ── %s ──────────────────────────────────────\n", title) for _, v := range vars { switch { case v.HasDefault: fmt.Fprintf(b, "# %s=%s\n", v.Name, v.Default) case v.Required: fmt.Fprintf(b, "%s=%s\n", v.Name, envDevValue(v, app)) default: fmt.Fprintf(b, "# %s=%s\n", v.Name, envDevValue(v, app)) } } b.WriteString("\n") }