feat(mcp): derive env vars from the framework's real struct tags (#3)
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.
Reviewed-on: #3
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 #3.
This commit is contained in:
@@ -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)."
|
||||
}
|
||||
Reference in New Issue
Block a user