Rene Nochebuena 8c6cf2c27a 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.
2026-08-07 16:58:24 -06:00

einherjar/mcp

version license go

Every warrior who knew the sagas had a skald nearby. This is yours.

code.nochebuena.dev/einherjar/mcp is the Einherjar Model Context Protocol server. It is a remote, streamable-HTTP service that teaches AI assistants about every other module of the framework: which package exposes which type, what each module promises via its compliance tests, the canonical wiring shape for a service, and whether a snippet of Go follows the conventions. Anyone who works in an Einherjar codebase can point their AI tools at one URL and get answers grounded in the actual source.


What Is Einherjar?

In Norse mythology, the Einherjar are the chosen warriors of Valhalla — selected not for glory, but to be ready for what comes after. They train. They prepare. They build the capability that others will rely on.

This framework is named for that purpose. Every module is a piece of that preparation: built carefully, documented for those who were never in the room, and designed to hold under pressure.


Commands

Command Purpose
cmd/server Streamable-HTTP MCP server. Embeds the framework index at build time and serves it over a single HTTP endpoint.
cmd/indexer Walks an Einherjar repository checkout and writes the framework index to data/index.json.

Tools

The server exposes thirteen tools to MCP-aware clients (Claude desktop, Claude Code, Cursor, Zed, and anything else that speaks MCP):

Tool Purpose
list_modules Enumerate every Einherjar module with its purpose and sub-packages
get_module Package doc, dependencies, sub-packages, key types, compliance counts; optional README
search_symbols Find a type, function, or interface by name, doc text, sub-package, module — or by a struct field/tag or an interface method
get_symbol Full signature, doc comment, and source location for one symbol — plus struct fields (with tags) and interface method sets
list_adrs List architectural decision records, optionally restricted to one module
get_adr Fetch a single ADR's markdown body
get_example Canonical usage snippet — pulled from module READMEs and from the synthetic wire conventions
get_scaffold The canonical minimum application scaffold as ready-to-write files — a clean main.go, internal/wire/wire.go, a composed internal/config, a health hook, and a .env.example derived from the real component-config tags. Use it when starting a new Einherjar service
get_config_env The real env vars a component config reads — name, declaring module/struct/field, required, and default — derived from the framework's struct tags. The source of truth for building a global config or a .env
check_env Check a .env / .env.example against the framework: flags EINHERJAR_* names that don't exist (e.g. EINHERJAR_PG_DATABASE), missing required vars for the modules you compose, and vars set for a module you don't compose
get_compliance Interface assertions and structural test names from a module's compliance_test.go
get_changelog Full CHANGELOG.md markdown for one module
validate_snippet Pattern-match a Go snippet against framework conventions; returns findings with severity, hint, and line

validate_snippet ships twelve wiring-convention rules: launcher.missing-run, launcher.no-components, launcher.run-error-discarded, logz.direct-env-read, web.server-not-appended, wire.hook-bad-signature, wire.hook-outside-beforestart, wire.route-specific-after-param, main.dirty, main.no-godotenv-autoload, config.raw-getenv, and config.unknown-env-var (rejects an env:"EINHERJAR_*" tag the framework doesn't declare).


Build Flow

                  build time                          runtime
   ┌──────────────────────────────┐      ┌──────────────────────────┐
   │ cmd/indexer ../              │      │ cmd/server               │
   │   walks every Einherjar      │      │   streamable-HTTP MCP    │
   │   module, parses Go pkgs,    │ ──▶  │   tools served from the  │
   │   reads READMEs + ADRs       │      │   embedded index.json    │
   │   ⇒ data/index.json (embed)  │      │                          │
   └──────────────────────────────┘      └──────────────────────────┘

The indexer is a separate command. It produces data/index.json which the server embeds via //go:embed, so the deployed binary is self-contained and reads nothing from disk at runtime.


Usage

Local run

# 1. Build the framework index from the sibling Einherjar modules
go run ./cmd/indexer ..

# 2. Build and run the server
go build -o bin/einherjar-mcp ./cmd/server
./bin/einherjar-mcp -addr :8080 -path /mcp

Container

# Build the image from the einherjar repo root so the indexer can walk every
# sibling module at image-build time.
docker build -f mcp/Dockerfile -t einherjar-mcp:0.1.0 .

docker run --rm -p 8080:8080 einherjar-mcp:0.1.0

Environment variables

Variable Default Effect
EINHERJAR_MCP_ADDR :8080 Listen address for the MCP server
EINHERJAR_MCP_PATH /mcp HTTP path served by the streamable-HTTP endpoint

Wiring Conventions (the synthetic wire module)

The MCP server ships a 15th, synthetic module called wire. It is not an Einherjar module — it documents the canonical application shape that uses Einherjar modules. The content lives at internal/index/builtins/README.md and is embedded at build time. AI assistants discover it via list_modules and read it via get_module and get_example the same way they read any real module.

The conventions captured: project layout (cmd/<app>/main.go, internal/wire/*.go, domain layout per feature), the fixed shape of Run(), the fixed shape of a with<Feature> hook (one lc.BeforeStart containing all construction and route registration), route-ordering rules for chi, the authz middleware helper, when to use skipPublicPaths vs skipMethodPath, and adapter patterns at the wire boundary.


Dependency Rules

contracts  (zero dependencies)
    ↑
  core, web, auth, …                  (every framework module)
    ↑
   mcp                                (reads framework source at index-time only)

mcp imports nothing from other Einherjar modules at compile time. The indexer parses the framework source on disk and writes a JSON blob; the server embeds that blob. This keeps mcp outside the framework dependency graph: it can index any version of einherjar without versioning itself in lock-step.


Verification

cd mcp/
go build ./...     # must compile clean
go vet ./...       # no warnings
go test ./...      # all tests pass
gofmt -l .         # no output

All four commands must produce clean output before a PR will be reviewed.


Deployment

The server is a single self-contained static binary. There is no canonical hosting shape — pick whichever matches the rest of your infrastructure. Two patterns cover most cases:

  • Container. The Dockerfile at the module root produces a distroless runtime image. Build from the einherjar repository root so the indexer can reach every sibling module at image-build time:

    docker build -f mcp/Dockerfile -t einherjar-mcp:0.1.0 .
    docker run --rm -p 8080:8080 einherjar-mcp:0.1.0
    
  • Systemd / socket-activated binary. The server detects an inherited listener via github.com/coreos/go-systemd/v22/activation and uses it when present, falling back to -addr TCP binding otherwise. Same binary works in both modes — no flag, no env var. Drop the binary into /opt/<somewhere>/ and write a .socket + .service pair that matches your conventions.

Whatever shape you pick, the public-facing reverse proxy must keep response buffering off on the /mcp location. Streamable MCP delivers tool results via Server-Sent Events; default nginx, Envoy, or Caddy buffering batches the stream and breaks Claude's session before the first event arrives. For nginx that means proxy_buffering off; proxy_cache off; proxy_request_buffering off; chunked_transfer_encoding on; plus an extended proxy_read_timeout for long-lived sessions.


Architecture Decisions

No ADRs at v0.1.0. The structural decisions in this release (synthetic wire module, go:embed of the index, build-time-not-runtime knowledge model, primitives not response shapes) are captured in the framework-wide memory and in this README.


A blade is sharper when the warrior knows its name. This is what tells them.

S
Description
No description provided
Readme AGPL-3.0
824 KiB
Languages
Go 99.6%
Dockerfile 0.4%