feat(mcp): scaffold tool, config/env conventions, and scaffold-hygiene rules (#2)

Major release to v1.0.0, aligned with the v1.0.0 framework. The MCP documented
the wiring conventions but not the config half of a project, and its example
main.go omitted the godotenv autoload — so an assistant starting a service from
zero still hand-rolled main.go and the launcher, and got config wrong. This adds
a first-class scaffold, completes the config/.env.example conventions, and adds
rules that catch the "mess in main" pattern.

internal/tools:
- New get_scaffold: returns the canonical minimum application scaffold as
  ready-to-write files (main.go with godotenv autoload + wire.Run(), wire.go,
  a composed config.go, a health hook, .env.example), with import paths filled
  from a `module` argument. Registered in tools.go.

internal/rules:
- Three new validate_snippet rules, appended in scaffold_rules.go: main.dirty
  (launcher/components built in main instead of internal/wire),
  main.no-godotenv-autoload (a wire-convention main that never loads .env), and
  config.raw-getenv (an EINHERJAR_* var read via os.Getenv instead of composing
  the component Config; EINHERJAR_LOG_* stays with logz.direct-env-read).
- scaffold_rules_test.go — internal/rules had no tests; asserts each new rule
  fires and that a clean main is not flagged.

internal/index (builtins):
- The synthetic wire module gains a Config section (compose the framework's
  component configs, load with caarlos0/env, APP_* app fields / EINHERJAR_*
  framework fields) and a Config & .env.example discipline (every env var the
  config reads is documented in .env.example, kept in lock-step).
- main.go now shows the `_ "github.com/joho/godotenv/autoload"` blank import,
  previously omitted. The assembly file is renamed launcher.go -> wire.go.
- Migrations and seeding removed from the documented scaffold — developer
  choices, not framework conventions. Re-synced against iron-dough-api / pei-api.

Version:
- Badge and serverVersion const were stale at v0.1.0; both now v1.0.0.

Docs:
- README (eleven tools, eleven validation rules) and CHANGELOG updated.

No new dependencies. The wire conventions are embedded at build time
(//go:embed builtins/README.md) and the new tool and rules are compiled in, so a
deployment must be rebuilt to serve them; a server still running the v0.2.0
binary keeps serving the old conventions until redeployed.

Reviewed-on: #2
Co-authored-by: Rene Nochebuena Guerrero <rene@nochebuena.dev>
Co-committed-by: Rene Nochebuena Guerrero <rene@nochebuena.dev>
This commit was merged in pull request #2.
This commit is contained in:
2026-08-07 12:22:14 -06:00
committed by NOCHEBUENADEV
parent 13a186c60a
commit a0b803cb40
8 changed files with 624 additions and 85 deletions
+226
View File
@@ -0,0 +1,226 @@
package tools
import (
"context"
"strings"
"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: repl.Replace(tplEnvExample)},
},
Notes: []string{
"main.go contains ONLY the godotenv autoload blank import and wire.Run() — never construct components there.",
"Every env var the config reads must also appear in .env.example, kept in lock-step. When a feature adds a var, update both.",
"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 (
"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
}
logger := logz.New(logz.Config{
JSON: !strings.EqualFold(cfg.AppEnv, "local"),
StaticArgs: []any{"service", "%%APP%%", "env", cfg.AppEnv},
})
db := postgres.New(logger, cfg.PG)
srv := server.New(logger, cfg.Server,
server.WithMiddleware(
mw.RequestID(uuid.NewString),
mw.Recover(logger),
mw.CORS(cfg.CORSOrigins),
mw.RequestLogger(logger),
),
)
lc := launcher.New(logger)
lc.Append(db, srv)
withHealth(lc, srv, logger, db)
// … one withFeature(lc, srv, …) call per feature in your domain.
return lc.Run()
}
`
const tplHealth = `package wire
import (
"net/http"
"code.nochebuena.dev/einherjar/core/launcher"
"code.nochebuena.dev/einherjar/core/logz"
"code.nochebuena.dev/einherjar/db-postgres"
"code.nochebuena.dev/einherjar/web/server"
)
// withHealth registers a liveness endpoint. Grow it into a readiness check
// (ping db and other dependencies) as the service gains infrastructure.
func withHealth(lc launcher.Launcher, srv server.Server, logger logz.Logger, db postgres.Component) {
lc.BeforeStart(func() error {
srv.Get("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(` + "`" + `{"status":"ok"}` + "`" + `))
})
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/db-postgres"
"code.nochebuena.dev/einherjar/web/server"
)
// Config is the fully-resolved startup configuration. caarlos0/env recurses into
// the nested framework configs, populating their EINHERJAR_SERVER_* / EINHERJAR_PG_*
// tags from the environment next to the app-owned APP_* fields.
type Config struct {
AppEnv string ` + "`" + `env:"APP_ENV" envDefault:"local"` + "`" + `
CORSOrigins []string ` + "`" + `env:"APP_CORS_ORIGINS" envSeparator:","` + "`" + `
// Framework component configs — composed verbatim; their EINHERJAR_* tags
// load through this same env.Parse call.
Server server.Config // EINHERJAR_SERVER_*
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
}
`
const tplEnvExample = `# .env.example — copy to .env for local dev (godotenv autoloads it).
# Every variable the config package reads lives here, documented. Keep in sync.
# ── App (APP_*) ────────────────────────────────────────────────────────────
APP_ENV=local
APP_CORS_ORIGINS=*
# ── Einherjar: HTTP server (EINHERJAR_SERVER_*) ───────────────────────────
EINHERJAR_SERVER_ADDR=:8080
# ── Einherjar: PostgreSQL (EINHERJAR_PG_*) ────────────────────────────────
EINHERJAR_PG_HOST=localhost
EINHERJAR_PG_PORT=5432
EINHERJAR_PG_USER=postgres
EINHERJAR_PG_PASSWORD=postgres
EINHERJAR_PG_DATABASE=%%APP%%
`
+1
View File
@@ -19,6 +19,7 @@ func Register(s *mcp.Server, idx *index.Index) {
registerListADRs(s, idx)
registerGetADR(s, idx)
registerGetExample(s, idx)
registerGetScaffold(s, idx)
registerValidateSnippet(s, idx)
registerGetCompliance(s, idx)
registerGetChangelog(s, idx)