Files
mcp/README.md

203 lines
8.1 KiB
Markdown
Raw Permalink Normal View History

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
# einherjar/mcp
[![version](https://img.shields.io/badge/version-v0.1.0-5C4EE5?style=flat-square)](https://code.nochebuena.dev/einherjar/mcp)
[![license](https://img.shields.io/badge/license-AGPL--3.0-22863A?style=flat-square)](LICENSE)
[![go](https://img.shields.io/badge/Go-1.26+-00ADD8?style=flat-square&logo=go&logoColor=white)](https://go.dev)
> 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 **ten** 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, or module |
| `get_symbol` | Full signature, doc comment, and source location for one symbol |
| `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_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 **eight** wiring-convention rules at v0.1.0:
`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`, and `wire.route-specific-after-param`.
---
## 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
```bash
# 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
```bash
# 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
```bash
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.
---
feat(mcp): systemd socket activation and healthz under /mcp (v0.1.1) Patch release. Two changes to cmd/server, both motivated by running the service behind a unix socket on a reverse-proxied host: the binary now inherits a systemd-passed listener when present, and the healthz handler moves under the same path prefix as the MCP endpoint so a single proxy location forwards both. Bundled with two repository-hygiene changes. cmd/server: - chooseListener (new) — picks a listener at startup. When systemd has passed a LISTEN_FDS fd via github.com/coreos/go-systemd/v22/activation, the binary uses the inherited listener; otherwise it binds TCP at -addr as before. The startup log records "mode":"socket-activated" or "mode":"tcp" so operators can confirm which path is live. Same binary works for local dev and for systemd-managed deployment with no flags or env vars to toggle. - Health probe path is now derived from -path. With the default -path /mcp the probe is served at /mcp/healthz; the legacy /healthz route is no longer registered. A reverse proxy can now route the whole MCP service through a single "/mcp" location prefix instead of maintaining a second forward for /healthz. Consumers of v0.1.0 that hit /healthz directly must switch to /mcp/healthz. Dependencies: - github.com/coreos/go-systemd/v22 v22.7.0 — listener inheritance via LISTEN_FDS. Loaded only by cmd/server. Docs: - README.md "Deployment" section rewritten to be hosting-agnostic. The v0.1.0 draft prescribed a specific systemd-on-HestiaCP layout; the new text points at the Dockerfile and at systemd socket activation as a supported binary mode without dictating one operator's setup. Adds an explicit note that any reverse proxy must disable response buffering on the /mcp location — streamable MCP delivers tool results via Server-Sent Events and default proxy buffering breaks the stream. Repository hygiene: - /deploy/ is now .gitignored. Local deployment artefacts (systemd units, reverse-proxy templates, per-release scripts) are operator-specific by design and live outside the public repository. The Dockerfile at the module root remains the only portable, public-facing build artefact. No tool surface, no validation rules, no index schema, and no behaviour of the indexer changed. Operators upgrading from v0.1.0 must update their health-probe URL to /mcp/healthz (or whichever path matches their -path flag); MCP-protocol clients (Claude, Cursor, Zed, etc.) need no changes.
2026-05-29 14:09:06 -06:00
## 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`](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:
```bash
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.
---
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
## 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.*