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:"module-granularity: the framework modules whose component configs the app composes, e.g. [\"db-postgres\",\"web\"]. Every env var in the module counts as composed. Convenient but coarse — a module can declare more than one config struct."` Composes []string `json:"composes,omitempty" jsonschema:"struct-granularity (preferred when precise): the exact config structs the app composes, as module/subpackage/Struct selectors, e.g. [\"web/server/Config\",\"db-postgres/Config\",\"core/logz/Config\"]. Use module/Struct when the config is at the module root. This catches struct-level dead vars, e.g. flagging EINHERJAR_SERVER_CORS_ORIGINS (which lives on web.Config, not web/server/Config)."` } 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 declare what the app composes (via `modules` for coarse or `composes` for exact struct selectors) — required vars that are missing, plus framework vars that nothing composed reads (dead vars). " + "Prefer `composes` (e.g. web/server/Config) for precision; it catches struct-level dead vars that module granularity can't. " + "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, args.Composes) 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, the modules the app composes (coarse), and the exact config structs // it composes (precise selectors), it returns the drift findings. A var counts as // "composed" when it is declared by any listed module or any listed struct. func checkEnvVars(idx *index.Index, env string, modules, composes []string) ([]envFinding, error) { present := parseEnvKeys(env) known := envspec.KnownNames(idx) // The set of composed env vars (name -> Var), unioned from module-granularity // and struct-granularity selectors. Its keyset is the "what this app reads" set. expected := map[string]envspec.Var{} scoped := false 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) } scoped = true for _, v := range envspec.ForModule(idx, mod) { expected[v.Name] = v } } for _, sel := range composes { sel = strings.TrimSpace(sel) if sel == "" { continue } mod, sub, name, err := parseStructSelector(sel) if err != nil { return nil, err } if idx.FindModule(mod) == nil { return nil, fmt.Errorf("module not found in selector %q: %s", sel, mod) } vars := envspec.FindStruct(idx, mod, sub, name) if vars == nil { return nil, fmt.Errorf("struct not found for selector %q (want an existing module/subpackage/Struct)", sel) } scoped = true for _, v := range vars { 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. A real framework var set in the file that nothing composed reads → dead. // With struct selectors this catches struct-level dead vars (a var whose // module is partly composed but whose specific struct is not). if scoped { for name := range present { mod, real := varModule[name] if !real { continue } if _, composed := expected[name]; composed { continue } findings = append(findings, envFinding{ Severity: "info", Kind: "not-composed", Var: name, Message: name + " is a real " + mod + " var, but none of the composed configs read it — it will be ignored", Hint: "Compose the config that declares it, or drop the var. (E.g. EINHERJAR_SERVER_CORS_ORIGINS lives on web.Config; it is dead if you compose web/server/Config instead.)", }) } } 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 } // parseStructSelector splits a "module/Struct" or "module/subpackage/Struct" // selector. The struct name is always the last segment; a two-part selector // targets a module-root config (empty sub-package). func parseStructSelector(sel string) (module, subPackage, name string, err error) { parts := strings.Split(sel, "/") switch len(parts) { case 2: return parts[0], "", parts[1], nil case 3: return parts[0], parts[1], parts[2], nil default: return "", "", "", fmt.Errorf("invalid struct selector %q (want module/Struct or module/subpackage/Struct)", sel) } } // 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)." }