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)." }