From e23e86b06cc2abbfee03e5aaf59bb8fc105bda03 Mon Sep 17 00:00:00 2001 From: Rene Nochebuena Guerrero Date: Fri, 29 May 2026 14:09:06 -0600 Subject: [PATCH] feat(mcp): systemd socket activation and healthz under /mcp (v0.1.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .gitignore | 7 +++++++ CHANGELOG.md | 29 +++++++++++++++++++++++++++++ README.md | 31 +++++++++++++++++++++++++++++++ cmd/server/main.go | 30 +++++++++++++++++++++++++++--- go.mod | 5 ++++- go.sum | 2 ++ 6 files changed, 100 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 558eace..c6d5763 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,10 @@ Thumbs.db # ── Generated framework index (rebuilt by cmd/indexer; placeholder is committed) ─ data/index.json + +# ── Local deployment artifacts (systemd units, Hestia templates, deploy scripts) ─ +# Operator-specific by design. Different users will deploy on k8s, Fly.io, Render, +# Cloud Run, bare metal — our HestiaCP + systemd + unix-socket layout is one shape +# among many and shouldn't be presented as the canonical path in a public repo. +# The Dockerfile at the module root is the portable artifact. +/deploy/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 791009a..ba428f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,35 @@ This module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html --- +## [0.1.1] — 2026-05-29 + +Patch release. Two changes to `cmd/server` make the binary cleaner to run behind a unix socket on a reverse-proxied host, plus two repository-hygiene changes that follow from the same deployment exercise. + +### Added + +- **Systemd socket activation** in `cmd/server`. The binary inherits the listener from `LISTEN_FDS` via `github.com/coreos/go-systemd/v22/activation` when present, falling back transparently to TCP `-addr` binding otherwise. Startup log records `"mode":"socket-activated"` or `"mode":"tcp"`. Same binary, no flag or env var to toggle. + +### Changed + +- **Health probe path** moved from `/healthz` to `/healthz` (default `/mcp/healthz`). Lets a reverse proxy expose the entire MCP service through one location prefix. v0.1.0 consumers hitting the old `/healthz` route receive 404; update to `/mcp/healthz` (or whatever path matches your `-path` flag). +- **`README.md` deployment section** rewritten to be hosting-agnostic. Points at the `Dockerfile` and systemd socket activation as supported binary modes without prescribing one operator's setup. Adds the SSE-buffering caveat once: any reverse proxy must disable response buffering on the `/mcp` location, otherwise Server-Sent Events get batched and streamable MCP sessions break. +- **`/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. + +### Dependencies + +- **Added:** `github.com/coreos/go-systemd/v22 v22.7.0` — used by `cmd/server` to detect and use a systemd-passed listener. + +### Upgrade notes + +| If you… | Action | +|---|---| +| Consume the MCP service from an MCP client (Claude, Cursor, Zed, etc.) | None — `/mcp` is unchanged | +| Monitor the service via the healthz probe | Update the probe URL from `/healthz` to `/mcp/healthz` | +| Run the binary directly (no reverse proxy) | None — `-addr` TCP binding still works the same way | +| Run the binary under systemd with a socket unit | The same binary now picks up the inherited listener automatically | + +--- + ## [0.1.0] — 2026-05-29 Initial release. The `mcp` module hosts the **Einherjar Model Context Protocol server** — a remote, streamable-HTTP service that teaches AI assistants about every other module of the framework. diff --git a/README.md b/README.md index 35eee96..0014d31 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,37 @@ 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`](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//` + 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` diff --git a/cmd/server/main.go b/cmd/server/main.go index a21655d..99e45f6 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -9,6 +9,7 @@ import ( "flag" "fmt" "log/slog" + "net" "net/http" "os" @@ -16,6 +17,7 @@ import ( "code.nochebuena.dev/einherjar/mcp/internal/index" "code.nochebuena.dev/einherjar/mcp/internal/tools" + "github.com/coreos/go-systemd/v22/activation" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -50,17 +52,39 @@ func main() { mux := http.NewServeMux() mux.Handle(*path, handler) - mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { + mux.HandleFunc(*path+"/healthz", func(w http.ResponseWriter, _ *http.Request) { fmt.Fprintln(w, "ok") }) - log.Info("listening", "addr", *addr, "path", *path) - if err := http.ListenAndServe(*addr, mux); err != nil { + ln, mode, err := chooseListener(*addr) + if err != nil { + log.Error("listen", "err", err) + os.Exit(1) + } + log.Info("listening", "mode", mode, "addr", ln.Addr().String(), "path", *path) + + srv := &http.Server{Handler: mux} + if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed { log.Error("server", "err", err) os.Exit(1) } } +// chooseListener returns a socket-activated listener when systemd inherited +// one, falling back to a plain TCP listener on addr. The mode string is +// "socket-activated" or "tcp" for logging. +func chooseListener(addr string) (net.Listener, string, error) { + listeners, err := activation.Listeners() + if err == nil && len(listeners) > 0 { + return listeners[0], "socket-activated", nil + } + ln, err := net.Listen("tcp", addr) + if err != nil { + return nil, "", err + } + return ln, "tcp", nil +} + func envOr(key, def string) string { if v := os.Getenv(key); v != "" { return v diff --git a/go.mod b/go.mod index d2bb997..90f9677 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,10 @@ module code.nochebuena.dev/einherjar/mcp go 1.26 -require github.com/modelcontextprotocol/go-sdk v1.0.0 +require ( + github.com/coreos/go-systemd/v22 v22.7.0 + github.com/modelcontextprotocol/go-sdk v1.0.0 +) require ( github.com/google/jsonschema-go v0.3.0 // indirect diff --git a/go.sum b/go.sum index 89026b2..bd5ed1f 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA= +github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q=