Files
mcp/internal/index/builtins
Rene Nochebuena cc62906c6f feat(mcp): initial implementation — MCP server, framework indexer, 10 tools, 8 validation rules (v0.1.0)
Introduces code.nochebuena.dev/einherjar/mcp — the Einherjar Model Context Protocol
server. A remote, streamable-HTTP service that teaches AI assistants about every
other module of the framework: which package exposes which type, what each module
guarantees through its compliance tests, the canonical wiring shape for a service,
and whether a Go snippet follows the conventions. Indexes the framework on disk at
build time and ships a self-contained binary via go:embed; imports nothing from
other einherjar/* modules at compile time.

server (cmd/server):
- Streamable-HTTP MCP server built on github.com/modelcontextprotocol/go-sdk v1.0.0
- mcp.NewServer + mcp.NewStreamableHTTPHandler, served via net/http on EINHERJAR_MCP_ADDR
  (default :8080) and EINHERJAR_MCP_PATH (default /mcp)
- /healthz liveness endpoint; structured JSON logging via log/slog
- Loads the embedded data/index.json once at startup; in-memory for the process lifetime

indexer (cmd/indexer):
- Walks an Einherjar repository checkout (default ../), parses every sibling
  module's go.mod, README.md, CHANGELOG.md, docs/adr/ADR-*.md, doc.go package
  comments, every exported type/interface/func/method/const/var (via go/doc on
  go/parser ASTs), and compliance_test.go
- Captures module dependency edges by regex over each go.mod's require lines
  (einherjar/* paths only; self-reference filtered)
- Appends a synthetic "wire" module documenting canonical application wiring
  conventions, authored at internal/index/builtins/README.md and embedded via
  go:embed; participates in list_modules / get_module / get_example like a real module

internal/index:
- Schema einherjar.mcp/index/v1; types: Index, Module, SubPackage, Symbol, ADR,
  Example, Compliance, InterfaceAssert, ComplianceTest
- Build(repoRoot) → *Index walks the repo; BuildBuiltins() returns the synthetic
  wire module from the embedded markdown
- Load([]byte) → *Index validates the schema version on read
- FindModule, SearchSymbols helpers used by tools

internal/tools (10 tools):
- list_modules — enumerate every module with purpose + sub-packages
- get_module — package doc, dependencies, sub-packages, key symbols, ADRs,
  compliance counts; optional embedded README
- search_symbols — full-text across name, doc, sub-package, module; filterable by
  module and kind
- get_symbol — full signature, doc comment, source file:line for one symbol
- list_adrs — list ADRs across the framework or within one module
- get_adr — fetch one ADR's markdown body
- get_example — canonical usage snippets extracted from module READMEs and from
  the synthetic wire conventions
- get_compliance — interface assertions (var _ Iface = impl) 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

internal/rules (8 rules, registered via init() against a single registered slice):
- launcher.missing-run — launcher constructed but Run() never called
- launcher.no-components — launcher.New() called without any .Append(...)
- launcher.run-error-discarded — lc.Run() invoked as an ExprStmt (return ignored)
- logz.direct-env-read — os.Getenv("EINHERJAR_LOG_*") bypassing logz config
- web.server-not-appended — web/server constructed but not added to the launcher
- wire.hook-bad-signature — with<Feature>(...) first param is not launcher.Launcher
- wire.hook-outside-beforestart — repo/service/handler construction or route
  registration at the top level of a hook (outside lc.BeforeStart)
- wire.route-specific-after-param — /users/{id} registered before a sibling
  /users/me of the same length and method (chi would shadow the literal route)

Synthetic wire module (internal/index/builtins/README.md):
- Project layout (cmd/<app>/main.go + internal/wire/*.go + per-feature domain dirs)
- Canonical Run() shape: config → logger → infra (db, cache, pool, mc, srv) → cross-
  cutting (validator, permission provider) → launcher.New → lc.Append(infra...) →
  withMigrations / withSuperAdminSeed / withHealth / withFeature hooks → return lc.Run()
- Canonical with<Feature> hook shape: signature (launcher.Launcher first, server.Server
  second, deps last), single lc.BeforeStart closure containing all construction +
  route registration
- chi route ordering, srv.With(authz(...)) authorization, middleware helpers
  (authz / skipPublicPaths / skipMethodPath), tokenSignerAdapter pattern showing
  that the framework exposes Signer.Sign as a primitive and the application owns
  the access/refresh response shape

Packaging:
- Multi-stage Dockerfile that builds from the einherjar repository root
  (docker build -f mcp/Dockerfile .) so cmd/indexer can walk every sibling module
  at image-build time; runtime layer is gcr.io/distroless/static-debian12:nonroot
- 86-byte placeholder data/index.json committed once with `git add -f`; subsequent
  indexer runs overwrite it locally but the file is .gitignored
- .gitea/CODEOWNERS and pull_request_template.md mirror the sibling layout

Design notes:
- mcp depends on nothing in einherjar/* — it reads the framework via the filesystem
  at index time. This keeps mcp outside the framework dependency graph and lets it
  index any version of einherjar without versioning itself in lock-step.
- All structured-output tool responses initialise empty slices ([]Type{}) rather
  than relying on Go's nil-marshals-to-null default, so the SDK's JSON-schema
  output validator never rejects a tools/call result.
2026-05-29 18:12:45 +00:00
..

Wiring Conventions

Forging a service is mostly wiring. Do it the same way every time.

This is not an Einherjar module — it is the canonical application shape that uses Einherjar modules. Apps live in their own repository with an internal/wire/ package that mirrors this template. The conventions here are distilled from a production service that has shipped on the predecessor micro-libs (code.nochebuena.dev/go/*) and have been re-mapped to the einherjar import paths.

Project layout

cmd/<app>/main.go              one-line entrypoint that calls wire.Run()
internal/wire/launcher.go      Run() — builds infra and registers feature hooks
internal/wire/<feature>.go     one file per feature, hosts a with<Feature> hook
internal/wire/middleware.go    authz, skipPublicPaths, skipMethodPath helpers
internal/wire/migrations.go    withMigrations hook
internal/wire/seed.go          withSuperAdminSeed and other startup seeds
internal/<feature>/dto/        request/response DTOs
internal/<feature>/handler/    HTTP handlers
internal/<feature>/repository/ data access
internal/<feature>/service/    domain logic

cmd/<app>/main.go must contain nothing but the call to wire.Run() and an os.Exit(1) on error. Everything else lives in internal/wire/.

Run

The application entry point. The order below is load-bearing: configuration first, observability second, infrastructure third, cross-cutting helpers fourth, then the launcher with every component appended, then feature hooks, then lc.Run().

package wire

import (
	"strings"

	"github.com/google/uuid"

	authjwt "code.nochebuena.dev/einherjar/auth-jwt"
	"code.nochebuena.dev/einherjar/auth/authmw"
	"code.nochebuena.dev/einherjar/auth/rbac"
	"code.nochebuena.dev/einherjar/cache-valkey"
	"code.nochebuena.dev/einherjar/core/launcher"
	"code.nochebuena.dev/einherjar/core/logz"
	"code.nochebuena.dev/einherjar/core/valid"
	"code.nochebuena.dev/einherjar/db-postgres"
	"code.nochebuena.dev/einherjar/storage-minio"
	"code.nochebuena.dev/einherjar/web/mw"
	"code.nochebuena.dev/einherjar/web/server"
	"code.nochebuena.dev/einherjar/worker"

	"myapp/internal/config"
)

func Run() error {
	cfg, err := config.Load()
	if err != nil {
		return err
	}

	logger := logz.New(logz.Config{
		JSON:       !strings.EqualFold(cfg.AppEnv, "local"),
		StaticArgs: []any{"service", "myapp", "env", cfg.AppEnv},
	})

	signer := authjwt.NewHMACSigner([]byte(cfg.JWT.Secret))

	publicPaths := []string{
		"/health",
		"/api/v1/auth/login",
		"/api/v1/auth/refresh",
	}

	db    := postgres.New(logger, cfg.PG)
	cache := valkey.New(logger, cfg.VK)
	pool  := worker.New(logger, cfg.Worker)
	mc    := minio.New(logger, cfg.MinIO)
	srv   := server.New(logger, cfg.Server,
		server.WithMiddleware(
			mw.RequestID(uuid.NewString),
			mw.Recover(logger),
			mw.CORS(cfg.CORSOrigins),
			mw.RequestLogger(logger),
			authjwt.AuthMiddleware(logger, signer, publicPaths),
			authmw.EnrichmentMiddleware(logger, &claimsEnricher{}),
		),
	)

	v        := valid.New(valid.WithMessageProvider(valid.SpanishMessages))
	provider := rbac.NewClaimsPermissionProvider("masks", claimsFromCtx)

	lc := launcher.New(logger)
	lc.Append(db, cache, pool, mc, srv)

	withMigrations(lc, logger, cfg)
	withSuperAdminSeed(lc, db, logger, cfg)

	withHealth(lc, srv, logger, db, cache, mc)
	withUsers(lc, srv, db, logger, provider, v)
	// … one withFeature(...) call per feature in your domain.

	return lc.Run()
}

Feature hook

One file per feature in internal/wire/. The function signature is fixed: launcher.Launcher first, server.Server second when registering routes, deps last. The body is one call to lc.BeforeStart. Everything else — repository construction, service construction, handler construction, route registration — lives inside the closure.

package wire

import (
	"code.nochebuena.dev/einherjar/contracts/security"
	"code.nochebuena.dev/einherjar/core/launcher"
	"code.nochebuena.dev/einherjar/core/logz"
	"code.nochebuena.dev/einherjar/core/valid"
	"code.nochebuena.dev/einherjar/db-postgres"
	"code.nochebuena.dev/einherjar/web/server"

	"myapp/internal/domains"
	userhandler "myapp/internal/user/handler"
	userrepo    "myapp/internal/user/repository"
	usersvc     "myapp/internal/user/service"
)

func withUsers(
	lc       launcher.Launcher,
	srv      server.Server,
	db       postgres.Component,
	logger   logz.Logger,
	provider security.PermissionProvider,
	v        valid.Validator,
) {
	lc.BeforeStart(func() error {
		repo := userrepo.New(db)
		uow  := postgres.NewUnitOfWork(logger, db)
		svc  := usersvc.New(repo, uow)
		h    := userhandler.New(svc, v)

		// Literal-segment routes register BEFORE parametrised siblings.
		// chi matches the first registered route that fits; if /users/{id}
		// came first, "me" would bind to {id} and /users/me/password would
		// never be reached.
		srv.Put("/api/v1/users/me/password", h.ChangeOwnPassword)

		srv.With(authz(provider, domains.ResourceUsers, domains.GrantReadUser)).
			Get("/api/v1/users", h.ListUsers)
		srv.With(authz(provider, domains.ResourceUsers, domains.GrantCreateUser)).
			Post("/api/v1/users", h.CreateUser)
		srv.With(authz(provider, domains.ResourceUsers, domains.GrantUpdateUser)).
			Put("/api/v1/users/{user_id}", h.UpdateUser)
		srv.With(authz(provider, domains.ResourceUsers, domains.GrantDeleteUser)).
			Delete("/api/v1/users/{user_id}", h.DeleteUser)

		return nil
	})
}

Route ordering

chi matches paths in registration order. Always register literal-segment routes before parametrised-segment routes that share the same prefix.

Correct:

srv.Put("/api/v1/users/me/password", h.ChangeOwnPassword)
srv.Put("/api/v1/users/{user_id}",   h.UpdateUser)

Wrong — chi binds me to {user_id} and the literal route is unreachable:

srv.Put("/api/v1/users/{user_id}",   h.UpdateUser)
srv.Put("/api/v1/users/me/password", h.ChangeOwnPassword)

Authorization

Every protected route registers with .With(authz(provider, resource, grant)):

srv.With(authz(provider, domains.ResourceUsers, domains.GrantReadUser)).
    Get("/api/v1/users", h.ListUsers)

Resource constants and grant bits live in internal/domains/. Routes that the caller owns (/me/...) intentionally skip authz — they are reachable to any authenticated user.

Middleware helpers

These belong in internal/wire/middleware.go and are used across every feature hook.

// authz returns a per-route authorization middleware that checks one bit.
func authz(p security.PermissionProvider, resource string, bit int) func(http.Handler) http.Handler {
	return authmw.AuthzMiddleware(nil, p, resource, security.Permission(bit))
}

// skipPublicPaths wraps mw so it is bypassed for any path that matches publicPaths.
// Use this for middleware that must not run on unauthenticated endpoints
// (e.g. EnrichmentMiddleware).
func skipPublicPaths(publicPaths []string, mw func(http.Handler) http.Handler) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		inner := mw(next)
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			for _, p := range publicPaths {
				if matched, _ := path.Match(p, r.URL.Path); matched {
					next.ServeHTTP(w, r)
					return
				}
			}
			inner.ServeHTTP(w, r)
		})
	}
}

// skipMethodPath bypasses mw only when BOTH method and path match. Use this
// to expose ONE method on an otherwise-authenticated path (e.g. GET
// /api/v1/config public while PUT is not). Adding such a path to
// publicPaths would silently strip identity from context on the protected
// methods, breaking authz().
func skipMethodPath(method, pathPattern string, mw func(http.Handler) http.Handler) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		inner := mw(next)
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			if r.Method == method {
				if matched, _ := path.Match(pathPattern, r.URL.Path); matched {
					next.ServeHTTP(w, r)
					return
				}
			}
			inner.ServeHTTP(w, r)
		})
	}
}

Adapters at the wire boundary

When a framework type does not match a service-layer port, write a small typed adapter in internal/wire/. Always compile-time assert with var _ TargetIface = (*adapter)(nil).

The framework intentionally exposes only Signer.Sign(claims) (string, error)the framework gives you a signing primitive; the access/refresh strategy, claim layout, and response shape are application concerns. A "helper" that returned a fixed {access, refresh, type, expiresIn} struct would silently decide for every app whether refresh tokens exist, what fields to expose, and what casing to use. Those are wire-format choices the app owns.

import (
	"time"

	"github.com/golang-jwt/jwt/v5"
	"github.com/google/uuid"

	authjwt "code.nochebuena.dev/einherjar/auth-jwt"
)

type tokenSignerAdapter struct {
	signer authjwt.Signer
	cfg    authjwt.TokenConfig
}

var _ authsvc.TokenSigner = (*tokenSignerAdapter)(nil)

func (a *tokenSignerAdapter) IssueTokenPair(subject string, custom map[string]any) (authdto.TokenPairResponse, error) {
	now := time.Now()

	access := jwt.MapClaims{
		"sub": subject,
		"iss": a.cfg.Issuer,
		"iat": now.Unix(),
		"exp": now.Add(a.cfg.AccessTTL).Unix(),
	}
	for k, v := range custom {
		access[k] = v
	}
	accessToken, err := a.signer.Sign(access)
	if err != nil {
		return authdto.TokenPairResponse{}, err
	}

	refreshToken, err := a.signer.Sign(jwt.MapClaims{
		"sub": subject,
		"jti": uuid.NewString(),
		"iat": now.Unix(),
		"exp": now.Add(a.cfg.RefreshTTL).Unix(),
	})
	if err != nil {
		return authdto.TokenPairResponse{}, err
	}

	return authdto.TokenPairResponse{
		AccessToken:  accessToken,
		RefreshToken: refreshToken,
		TokenType:    "Bearer",
		ExpiresIn:    int(a.cfg.AccessTTL.Seconds()),
	}, nil
}

Migrations and seeds

Migrations and seeds register as BeforeStart hooks too. They run after all components have initialised but before any of them have started, so the database is reachable and the server is not yet accepting traffic.

func withMigrations(lc launcher.Launcher, logger logz.Logger, cfg config.Config) {
	lc.BeforeStart(func() error {
		if err := migrations.RunMigrations(context.Background(), logger, cfg); err != nil {
			logger.Error("migrations: failed to apply", err)
			return err
		}
		return nil
	})
}

Seeds must be idempotent: count first, only mutate when needed, log the skip when nothing was done.