2026-08-07 12:22:14 -06:00
package tools
import (
"context"
2026-08-07 17:00:02 -06:00
"fmt"
2026-08-07 12:22:14 -06:00
"strings"
2026-08-07 17:00:02 -06:00
"code.nochebuena.dev/einherjar/mcp/internal/envspec"
2026-08-07 12:22:14 -06:00
"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 )},
2026-08-07 17:00:02 -06:00
{ Path : ".env.example" , Content : renderEnvExample ( idx , service )},
2026-08-07 12:22:14 -06:00
},
Notes : [] string {
"main.go contains ONLY the godotenv autoload blank import and wire.Run() — never construct components there." ,
2026-08-07 17:00:02 -06:00
".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." ,
2026-08-07 12:22:14 -06:00
"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/<feature>.go per feature (a with<Feature> hook) plus its internal/<feature>/{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/<feature>.go one file per feature (with<Feature> 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/<feature>/{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 (
2026-08-08 00:55:39 -06:00
"net/http"
"strings"
2026-08-07 12:22:14 -06:00
"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
}
2026-08-07 17:00:02 -06:00
// 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)
2026-08-07 12:22:14 -06:00
db := postgres.New(logger, cfg.PG)
2026-08-08 00:55:39 -06:00
2026-08-08 02:29:16 -06:00
// 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.
2026-08-08 00:55:39 -06:00
var corsMW func(http.Handler) http.Handler
if strings.EqualFold(cfg.AppEnv, "local") {
corsMW = mw.CORSAllowAll()
} else {
2026-08-08 02:29:16 -06:00
corsMW = mw.CORS(cfg.Server.CORSOrigins)
2026-08-08 00:55:39 -06:00
}
2026-08-07 12:22:14 -06:00
srv := server.New(logger, cfg.Server,
server.WithMiddleware(
2026-08-08 21:56:27 -06:00
// 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.
2026-08-07 12:22:14 -06:00
mw.Recover(logger),
2026-08-08 21:56:27 -06:00
mw.RequestID(newRequestID),
2026-08-07 12:22:14 -06:00
mw.RequestLogger(logger),
2026-08-08 21:56:27 -06:00
corsMW,
2026-08-07 12:22:14 -06:00
),
)
2026-08-08 00:55:39 -06:00
lc := launcher.New(logger, cfg.Launcher)
2026-08-07 12:22:14 -06:00
lc.Append(db, srv)
2026-08-08 00:55:39 -06:00
withHealth(lc, srv, logger, cfg.Health, db)
2026-08-07 12:22:14 -06:00
// … one withFeature(lc, srv, …) call per feature in your domain.
return lc.Run()
}
2026-08-08 21:56:27 -06:00
// 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()
}
2026-08-07 12:22:14 -06:00
`
const tplHealth = `package wire
import (
2026-08-08 00:55:39 -06:00
"code.nochebuena.dev/einherjar/contracts/logging"
"code.nochebuena.dev/einherjar/contracts/observability"
2026-08-07 12:22:14 -06:00
"code.nochebuena.dev/einherjar/core/launcher"
2026-08-08 00:55:39 -06:00
"code.nochebuena.dev/einherjar/web/health"
2026-08-07 12:22:14 -06:00
"code.nochebuena.dev/einherjar/web/server"
)
2026-08-08 00:55:39 -06:00
// 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<Feature> 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) {
2026-08-07 12:22:14 -06:00
lc.BeforeStart(func() error {
2026-08-08 00:55:39 -06:00
srv.Get("/health", health.NewHandlerWithConfig(logger, cfg, checks...).ServeHTTP)
2026-08-07 12:22:14 -06:00
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"
2026-08-08 00:55:39 -06:00
"code.nochebuena.dev/einherjar/core/launcher"
2026-08-07 17:00:02 -06:00
"code.nochebuena.dev/einherjar/core/logz"
2026-08-07 12:22:14 -06:00
"code.nochebuena.dev/einherjar/db-postgres"
2026-08-08 00:55:39 -06:00
"code.nochebuena.dev/einherjar/web/health"
2026-08-07 12:22:14 -06:00
"code.nochebuena.dev/einherjar/web/server"
)
// Config is the fully-resolved startup configuration. caarlos0/env recurses into
2026-08-08 00:55:39 -06:00
// the nested framework configs, populating their EINHERJAR_* tags from the
// environment next to the app-owned APP_* fields.
2026-08-07 12:22:14 -06:00
type Config struct {
2026-08-08 02:29:16 -06:00
AppEnv string ` + "`" + `env:"APP_ENV" envDefault:"local"` + "`" + `
2026-08-07 12:22:14 -06:00
// Framework component configs — composed verbatim; their EINHERJAR_* tags
// load through this same env.Parse call.
2026-08-08 00:55:39 -06:00
Launcher launcher.Config // EINHERJAR_COMPONENT_STOP_TIMEOUT
Log logz.Config // EINHERJAR_LOG_*
2026-08-08 02:29:16 -06:00
Server server.Config // EINHERJAR_SERVER_* (incl. EINHERJAR_SERVER_CORS_ORIGINS)
2026-08-08 00:55:39 -06:00
Health health.Config // EINHERJAR_HEALTH_CHECK_TIMEOUT
PG postgres.Config // EINHERJAR_PG_*
2026-08-07 12:22:14 -06:00
}
func Load() (Config, error) {
var cfg Config
if err := env.Parse(&cfg); err != nil {
return Config{}, err
}
return cfg, nil
}
`
2026-08-07 17:00:02 -06:00
// 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" )
2026-08-08 02:29:16 -06:00
b . WriteString ( "# CORS: local uses mw.CORSAllowAll(); non-local reads EINHERJAR_SERVER_CORS_ORIGINS below.\n\n" )
2026-08-07 17:00:02 -06:00
2026-08-08 00:55:39 -06:00
writeEnvSection ( & b , "Einherjar: launcher" , envspec . FindStruct ( idx , "core" , "launcher" , "Config" ), app )
2026-08-07 17:00:02 -06:00
writeEnvSection ( & b , "Einherjar: logging" , envspec . FindStruct ( idx , "core" , "logz" , "Config" ), app )
writeEnvSection ( & b , "Einherjar: HTTP server" , envspec . FindStruct ( idx , "web" , "server" , "Config" ), app )
2026-08-08 00:55:39 -06:00
writeEnvSection ( & b , "Einherjar: health" , envspec . FindStruct ( idx , "web" , "health" , "Config" ), app )
2026-08-07 17:00:02 -06:00
writeEnvSection ( & b , "Einherjar: PostgreSQL" , envspec . FindStruct ( idx , "db-postgres" , "" , "Config" ), app )
return b . String ()
}
2026-08-07 12:22:14 -06:00
2026-08-07 17:00:02 -06:00
// 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" ,
}
2026-08-07 12:22:14 -06:00
2026-08-07 17:00:02 -06:00
func envDevValue ( v envspec . Var , app string ) string {
if v . Name == "EINHERJAR_PG_NAME" {
return app
}
return envDevDefaults [ v . Name ]
}
2026-08-07 12:22:14 -06:00
2026-08-07 17:00:02 -06:00
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" )
}