feat(mcp): derive env vars from the framework's real struct tags

Minor to v1.1.0. Make the whole environment-variable surface derive from the
indexed component-config tags instead of a hand-maintained list that drifts.
Also folds in the scaffold-symbol fixes staged as v1.0.1 (never tagged); the
generated scaffold compiles clean against einherjar v1.0.0.

internal/envspec (new):
- Parse index struct tags into env vars {name, module, struct, field, required,
  default}. One source of truth: ParseTag, ForModule, FindStruct, All, KnownNames.

internal/tools:
- get_config_env: list the real env vars a component config reads (or all).
- check_env: flag unknown EINHERJAR_* names, required vars missing for the
  composed modules, and dead vars (set for an uncomposed module).
- get_scaffold: .env.example is now DERIVED from the index — required vars
  uncommented with a dev value, defaulted vars commented with their default.
  The scaffold now composes logz.Config (Log logz.Config) so EINHERJAR_LOG_*
  are live and documented, not hardcoded/ignored (log format is env-driven).
- validate_snippet: inject the real env-var name set into the rules package.

internal/index/builtins (wire conventions):
- Route the incremental "compose a component later" flow to get_config_env /
  check_env; distinguish framework EINHERJAR_* from app-owned APP_* (JWT).
- Compose logz.Config in the config + Run() examples, to match the scaffold.

internal/rules:
- config.unknown-env-var (twelfth rule): reject an env:"EINHERJAR_*" struct tag
  the framework doesn't declare. No-op until the server injects the name set, so
  it never fires on incomplete knowledge.

Fixed (was v1.0.1): scaffold health hook + wire builtin used logz.Logger (real:
contracts/logging.Logger) and postgres.Component (hooks take Provider); env tags
were EINHERJAR_SERVER_ADDR / EINHERJAR_PG_DATABASE (real: _HOST/_PORT / _PG_NAME).

Tests: envspec unit tests; env tools against the real data/index.json; the rule.
Verified by generating the scaffold, building it against local einherjar v1.0.0
(exit 0), and runtime-loading the composed logz.Config (EINHERJAR_LOG_LEVEL=DEBUG
-> slog.LevelDebug, EINHERJAR_LOG_JSON=true). Version bumped to v1.1.0 (badge +
serverVersion). No dependency changes.
This commit is contained in:
2026-08-07 16:58:24 -06:00
parent a0b803cb40
commit 8c6cf2c27a
14 changed files with 1003 additions and 50 deletions
+179
View File
@@ -0,0 +1,179 @@
package tools
import (
"context"
"fmt"
"sort"
"strings"
"code.nochebuena.dev/einherjar/mcp/internal/envspec"
"code.nochebuena.dev/einherjar/mcp/internal/index"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
type checkEnvInput struct {
Env string `json:"env" jsonschema:"the .env or .env.example contents to check (KEY=value lines; comments and blanks ignored)."`
Modules []string `json:"modules,omitempty" jsonschema:"the framework modules whose component configs the app composes, e.g. [\"db-postgres\",\"web\"]. Their required vars must be present; omit to only validate that EINHERJAR_* names are real."`
}
type envFinding struct {
Severity string `json:"severity"` // error | warning | info
Kind string `json:"kind"` // unknown-var | missing-required | not-composed
Var string `json:"var"`
Message string `json:"message"`
Hint string `json:"hint,omitempty"`
}
type checkEnvOutput struct {
Findings []envFinding `json:"findings"`
OK bool `json:"ok"`
Summary string `json:"summary"`
}
func registerCheckEnv(s *mcp.Server, idx *index.Index) {
mcp.AddTool(s, &mcp.Tool{
Name: "check_env",
Description: "Check a .env / .env.example against the framework's real env vars. Flags EINHERJAR_* names that don't exist (invented or misspelled, e.g. EINHERJAR_PG_DATABASE), " +
"and — when you pass the modules the app composes — required vars that are missing, plus framework vars set for a module you don't compose (dead vars). " +
"This is the enforcement half of building a correct global config: get_scaffold emits an accurate .env.example, get_config_env lists the truth, and check_env catches drift in an existing file.",
}, func(ctx context.Context, req *mcp.CallToolRequest, args checkEnvInput) (*mcp.CallToolResult, checkEnvOutput, error) {
findings, err := checkEnvVars(idx, args.Env, args.Modules)
if err != nil {
return errorResult(err.Error()), checkEnvOutput{}, nil
}
ok := true
for _, f := range findings {
if f.Severity == "error" {
ok = false
}
}
out := checkEnvOutput{Findings: findings, OK: ok, Summary: summariseEnvCheck(findings, ok)}
return jsonText(out), out, nil
})
}
// checkEnvVars is the pure core of check_env: given the framework index, an env
// file body, and the modules the app composes, it returns the drift findings.
func checkEnvVars(idx *index.Index, env string, modules []string) ([]envFinding, error) {
present := parseEnvKeys(env)
known := envspec.KnownNames(idx)
// Expected vars from the composed modules, and the module each real var belongs to.
expected := map[string]envspec.Var{}
composed := map[string]bool{}
for _, mod := range modules {
mod = strings.TrimSpace(mod)
if mod == "" {
continue
}
if idx.FindModule(mod) == nil {
return nil, fmt.Errorf("module not found: %s", mod)
}
composed[mod] = true
for _, v := range envspec.ForModule(idx, mod) {
expected[v.Name] = v
}
}
varModule := map[string]string{}
for _, v := range envspec.All(idx) {
varModule[v.Name] = v.Module
}
var findings []envFinding
// 1. EINHERJAR_* present in the file that is not a real framework var.
for name := range present {
if !strings.HasPrefix(name, "EINHERJAR_") {
continue
}
if _, real := known[name]; real {
continue
}
findings = append(findings, envFinding{
Severity: "error", Kind: "unknown-var", Var: name,
Message: name + " is not a real Einherjar env var",
Hint: "Confirm the exact name with get_config_env; e.g. the db name is EINHERJAR_PG_NAME (not _DATABASE) and the server bind is EINHERJAR_SERVER_HOST/_PORT (not _ADDR).",
})
}
// 2. Required vars of a composed module that the file never sets.
for name, v := range expected {
if !v.Required {
continue
}
if _, ok := present[name]; !ok {
findings = append(findings, envFinding{
Severity: "error", Kind: "missing-required", Var: name,
Message: name + " is required by " + v.Module + "." + v.Struct + " but is absent from the file",
Hint: "Add " + name + "= to the file; a required var with no default fails config.Load() at boot.",
})
}
}
// 3. Real framework var set for a module the app doesn't compose → dead.
if len(composed) > 0 {
for name := range present {
mod, real := varModule[name]
if !real || composed[mod] {
continue
}
findings = append(findings, envFinding{
Severity: "info", Kind: "not-composed", Var: name,
Message: name + " belongs to module " + mod + ", which this app does not list as composed — it will be ignored",
Hint: "Either compose " + mod + "'s Config, or drop the var. (This is how EINHERJAR_SERVER_CORS_ORIGINS is dead unless you compose web.Config.)",
})
}
}
sort.Slice(findings, func(i, j int) bool {
if findings[i].Kind != findings[j].Kind {
return findings[i].Kind < findings[j].Kind
}
return findings[i].Var < findings[j].Var
})
if findings == nil {
findings = []envFinding{}
}
return findings, nil
}
// parseEnvKeys returns the set of assigned variable names in an env file body,
// tolerating `export KEY=`, leading whitespace, comments, and blank lines.
func parseEnvKeys(body string) map[string]bool {
keys := map[string]bool{}
for _, line := range strings.Split(body, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
line = strings.TrimPrefix(line, "export ")
eq := strings.IndexByte(line, '=')
if eq <= 0 {
continue
}
key := strings.TrimSpace(line[:eq])
if key != "" {
keys[key] = true
}
}
return keys
}
func summariseEnvCheck(fs []envFinding, ok bool) string {
if len(fs) == 0 {
return "No issues — every EINHERJAR_* name is real and all required vars are present."
}
var errs, infos int
for _, f := range fs {
if f.Severity == "error" {
errs++
} else {
infos++
}
}
status := "OK"
if !ok {
status = "FAILED"
}
return status + ": " + itoa(errs) + " error(s), " + itoa(infos) + " note(s)."
}
+160
View File
@@ -0,0 +1,160 @@
package tools
import (
"path/filepath"
"strings"
"testing"
"code.nochebuena.dev/einherjar/mcp/internal/index"
)
// loadRealIndex builds the index from the sibling framework checkout at test
// time, so the env tools are tested against the framework's actual struct tags.
// It deliberately does NOT read data/index.json: that file is only an empty
// placeholder in the repo (the real index is regenerated by cmd/indexer at
// image-build time). When the sibling modules aren't present — e.g. a CI that
// checks out only this module — the test skips rather than fails.
func loadRealIndex(t *testing.T) *index.Index {
t.Helper()
idx, err := index.Build(filepath.Join("..", "..", ".."))
if err != nil || idx == nil || len(idx.Modules) == 0 {
t.Skip("framework checkout not available; skipping index-derived test")
}
return idx
}
func TestRenderEnvExample(t *testing.T) {
idx := loadRealIndex(t)
got := renderEnvExample(idx, "myapp")
// Required, no default → uncommented with a runnable dev value.
mustContain := []string{
"APP_ENV=local",
"APP_CORS_ORIGINS=*",
"EINHERJAR_PG_HOST=localhost",
"EINHERJAR_PG_USER=postgres",
"EINHERJAR_PG_PASSWORD=postgres",
"EINHERJAR_PG_NAME=myapp",
// logz is composed → its vars (both defaulted) are documented, commented.
"# EINHERJAR_LOG_LEVEL=INFO",
"# EINHERJAR_LOG_JSON=false",
// Has a framework default → commented line documenting it.
"# EINHERJAR_SERVER_HOST=0.0.0.0",
"# EINHERJAR_SERVER_PORT=8080",
"# EINHERJAR_PG_PORT=5432",
"# EINHERJAR_PG_SSL_MODE=disable",
}
for _, s := range mustContain {
if !strings.Contains(got, s) {
t.Errorf(".env.example missing line: %q\n---\n%s", s, got)
}
}
// The historical drift names must never appear.
mustNotContain := []string{
"EINHERJAR_PG_DATABASE",
"EINHERJAR_SERVER_ADDR",
// CORS lives on web.Config, which the scaffold does not compose — so it
// must not be emitted (it would be a dead var).
"EINHERJAR_SERVER_CORS_ORIGINS",
}
for _, s := range mustNotContain {
if strings.Contains(got, s) {
t.Errorf(".env.example must not contain %q\n---\n%s", s, got)
}
}
// A required var must never be silently emitted as a commented line.
for _, line := range strings.Split(got, "\n") {
if strings.HasPrefix(strings.TrimSpace(line), "# EINHERJAR_PG_NAME") {
t.Errorf("required EINHERJAR_PG_NAME emitted commented: %q", line)
}
}
}
func TestCheckEnvUnknownVar(t *testing.T) {
idx := loadRealIndex(t)
env := "EINHERJAR_PG_DATABASE=x\nEINHERJAR_SERVER_ADDR=:8080\nAPP_ENV=local\n"
findings, err := checkEnvVars(idx, env, nil)
if err != nil {
t.Fatal(err)
}
unknown := map[string]bool{}
for _, f := range findings {
if f.Kind == "unknown-var" {
unknown[f.Var] = true
}
}
for _, want := range []string{"EINHERJAR_PG_DATABASE", "EINHERJAR_SERVER_ADDR"} {
if !unknown[want] {
t.Errorf("expected unknown-var finding for %s; got %+v", want, findings)
}
}
// APP_* must never be flagged.
for _, f := range findings {
if f.Var == "APP_ENV" {
t.Errorf("APP_ENV should never be flagged: %+v", f)
}
}
}
func TestCheckEnvMissingRequired(t *testing.T) {
idx := loadRealIndex(t)
// Compose db-postgres but set none of its required vars.
findings, err := checkEnvVars(idx, "APP_ENV=local\n", []string{"db-postgres"})
if err != nil {
t.Fatal(err)
}
missing := map[string]bool{}
for _, f := range findings {
if f.Kind == "missing-required" {
missing[f.Var] = true
}
}
for _, want := range []string{"EINHERJAR_PG_HOST", "EINHERJAR_PG_USER", "EINHERJAR_PG_PASSWORD", "EINHERJAR_PG_NAME"} {
if !missing[want] {
t.Errorf("expected missing-required for %s; got %+v", want, findings)
}
}
}
func TestCheckEnvNotComposed(t *testing.T) {
idx := loadRealIndex(t)
// A real MinIO var while only composing db-postgres → dead (not-composed).
env := "EINHERJAR_MINIO_ENDPOINT=x\nEINHERJAR_PG_HOST=h\nEINHERJAR_PG_USER=u\nEINHERJAR_PG_PASSWORD=p\nEINHERJAR_PG_NAME=n\n"
findings, err := checkEnvVars(idx, env, []string{"db-postgres"})
if err != nil {
t.Fatal(err)
}
var got bool
for _, f := range findings {
if f.Kind == "not-composed" && f.Var == "EINHERJAR_MINIO_ENDPOINT" {
got = true
}
}
if !got {
t.Errorf("expected not-composed for EINHERJAR_MINIO_ENDPOINT; got %+v", findings)
}
}
func TestCheckEnvClean(t *testing.T) {
idx := loadRealIndex(t)
// The scaffold's own output, checked against the modules it composes, is clean.
env := renderEnvExample(idx, "myapp")
findings, err := checkEnvVars(idx, env, []string{"core", "web", "db-postgres"})
if err != nil {
t.Fatal(err)
}
for _, f := range findings {
if f.Severity == "error" {
t.Errorf("scaffold .env.example should have no errors, got %+v", f)
}
}
}
func TestCheckEnvUnknownModule(t *testing.T) {
idx := loadRealIndex(t)
if _, err := checkEnvVars(idx, "", []string{"nope"}); err == nil {
t.Error("expected error for unknown module")
}
}
+60
View File
@@ -0,0 +1,60 @@
package tools
import (
"context"
"strings"
"code.nochebuena.dev/einherjar/mcp/internal/envspec"
"code.nochebuena.dev/einherjar/mcp/internal/index"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
type getConfigEnvInput struct {
Module string `json:"module,omitempty" jsonschema:"restrict to one module's env vars, e.g. db-postgres or web. Omit to return every framework env var."`
}
type getConfigEnvOutput struct {
Vars []envspec.Var `json:"vars"`
Count int `json:"count"`
Summary string `json:"summary"`
}
func registerGetConfigEnv(s *mcp.Server, idx *index.Index) {
mcp.AddTool(s, &mcp.Tool{
Name: "get_config_env",
Description: "List the REAL environment variables an Einherjar component config reads, derived from the framework's struct tags (never hand-maintained). " +
"Each entry gives the var name, the declaring module/struct/field, whether it is required, and its default. " +
"Use this before composing a global Config or writing a .env — it is the source of truth for which EINHERJAR_* vars exist. " +
"Pass a module (e.g. db-postgres, web, core, storage-minio) to scope it; omit to get all.",
}, func(ctx context.Context, req *mcp.CallToolRequest, args getConfigEnvInput) (*mcp.CallToolResult, getConfigEnvOutput, error) {
module := strings.TrimSpace(args.Module)
var vars []envspec.Var
if module == "" {
vars = envspec.All(idx)
} else {
if idx.FindModule(module) == nil {
return errorResult("module not found: " + module), getConfigEnvOutput{}, nil
}
vars = envspec.ForModule(idx, module)
}
if vars == nil {
vars = []envspec.Var{}
}
out := getConfigEnvOutput{Vars: vars, Count: len(vars), Summary: summariseEnv(vars, module)}
return jsonText(out), out, nil
})
}
func summariseEnv(vars []envspec.Var, module string) string {
var required int
for _, v := range vars {
if v.Required {
required++
}
}
scope := "the framework"
if module != "" {
scope = module
}
return itoa(len(vars)) + " env var(s) for " + scope + ", " + itoa(required) + " required."
}
+69 -30
View File
@@ -2,8 +2,10 @@ 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"
)
@@ -54,11 +56,11 @@ func registerGetScaffold(s *mcp.Server, idx *index.Index) {
{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)},
{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.",
"Every env var the config reads must also appear in .env.example, kept in lock-step. When a feature adds a var, update both.",
".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/<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.",
@@ -98,8 +100,6 @@ func main() {
const tplWire = `package wire
import (
"strings"
"github.com/google/uuid"
"code.nochebuena.dev/einherjar/core/launcher"
@@ -120,10 +120,11 @@ func Run() error {
return err
}
logger := logz.New(logz.Config{
JSON: !strings.EqualFold(cfg.AppEnv, "local"),
StaticArgs: []any{"service", "%%APP%%", "env", cfg.AppEnv},
})
// 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)
srv := server.New(logger, cfg.Server,
@@ -138,7 +139,7 @@ func Run() error {
lc := launcher.New(logger)
lc.Append(db, srv)
withHealth(lc, srv, logger, db)
withHealth(lc, srv)
// … one withFeature(lc, srv, …) call per feature in your domain.
return lc.Run()
@@ -151,14 +152,13 @@ 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) {
// withHealth registers a liveness endpoint — the simplest with<Feature> hook.
// A real feature hook takes its deps (logging.Logger, postgres.Provider, …) and
// wires repo→service→handler inside the closure; see get_example("wire").
func withHealth(lc launcher.Launcher, srv server.Server) {
lc.BeforeStart(func() error {
srv.Get("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
@@ -181,19 +181,21 @@ package config
import (
"github.com/caarlos0/env/v11"
"code.nochebuena.dev/einherjar/core/logz"
"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.
// the nested framework configs, populating their EINHERJAR_LOG_* / 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.
Log logz.Config // EINHERJAR_LOG_*
Server server.Config // EINHERJAR_SERVER_*
PG postgres.Config // EINHERJAR_PG_*
}
@@ -207,20 +209,57 @@ func Load() (Config, error) {
}
`
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.
// 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")
# ── App (APP_*) ────────────────────────────────────────────────────────────
APP_ENV=local
APP_CORS_ORIGINS=*
b.WriteString("# ── App (APP_*) ────────────────────────────────────────────────────────────\n")
b.WriteString("APP_ENV=local\n")
b.WriteString("APP_CORS_ORIGINS=*\n\n")
# ── Einherjar: HTTP server (EINHERJAR_SERVER_*) ───────────────────────────
EINHERJAR_SERVER_ADDR=:8080
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: PostgreSQL", envspec.FindStruct(idx, "db-postgres", "", "Config"), app)
return b.String()
}
# ── Einherjar: PostgreSQL (EINHERJAR_PG_*) ────────────────────────────────
EINHERJAR_PG_HOST=localhost
EINHERJAR_PG_PORT=5432
EINHERJAR_PG_USER=postgres
EINHERJAR_PG_PASSWORD=postgres
EINHERJAR_PG_DATABASE=%%APP%%
`
// 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")
}
+2
View File
@@ -20,6 +20,8 @@ func Register(s *mcp.Server, idx *index.Index) {
registerGetADR(s, idx)
registerGetExample(s, idx)
registerGetScaffold(s, idx)
registerGetConfigEnv(s, idx)
registerCheckEnv(s, idx)
registerValidateSnippet(s, idx)
registerGetCompliance(s, idx)
registerGetChangelog(s, idx)
+5 -1
View File
@@ -3,6 +3,7 @@ package tools
import (
"context"
"code.nochebuena.dev/einherjar/mcp/internal/envspec"
"code.nochebuena.dev/einherjar/mcp/internal/index"
"code.nochebuena.dev/einherjar/mcp/internal/rules"
"github.com/modelcontextprotocol/go-sdk/mcp"
@@ -17,7 +18,10 @@ type validateSnippetOutput struct {
Summary string `json:"summary"`
}
func registerValidateSnippet(s *mcp.Server, _ *index.Index) {
func registerValidateSnippet(s *mcp.Server, idx *index.Index) {
// Inject the real framework env-var names so config.unknown-env-var can reject
// invented/misspelled EINHERJAR_* tags instead of guessing.
rules.SetKnownEnvVars(envspec.KnownNames(idx))
mcp.AddTool(s, &mcp.Tool{
Name: "validate_snippet",
Description: "Validate a Go snippet against Einherjar wiring conventions: lifecycle setup, logger configuration, env-var handling, server registration. Findings are advisory, not a substitute for go vet or the project's tests.",