feat(server): add /healthz endpoint for container health checks (#3060)

Fixes #2644

## Summary

Adds a `/healthz` endpoint to the Toolbox HTTP server so container
orchestrators (Kubernetes liveness/readiness probes, Docker
`HEALTHCHECK`, Cloud Run startup probes) have a dedicated, lightweight
path to hit. The response is `HTTP 200` with a JSON body of
`{"status":"ok"}`, so probes can check either the status code or the
payload depending on their configuration.

## Why a separate endpoint

The existing `/` handler is a landing page that returns a greeting
string. Reusing it for health checks is fine today but couples probe
behavior to a user-facing route, and the non-JSON body makes it awkward
for tooling that parses health responses. Giving probes their own path
follows the convention most Go services already use and keeps `/` free
to evolve as a human-facing entry point.

## Implementation

- Registered `r.Get("/healthz", ...)` in `internal/server/server.go`
right after the default `/` handler, so it inherits the same CORS and
host-check middleware already applied at the router level.
- Returns `Content-Type: application/json` with body `{"status":"ok"}`.
- No new dependencies.

## Testing

Added `TestHealthz` in `internal/server/server_test.go`. It follows the
same pattern as `TestServe`: spins up a real server on a free port,
sends a GET to `/healthz`, and verifies the status code, the
`Content-Type` header, and the JSON body. Runs on port `5004` to avoid
collisions with other tests in the package.

```
$ go test ./internal/server/ -run "TestServe|TestHealthz" -count=1
ok  	github.com/googleapis/mcp-toolbox/internal/server	1.283s

$ go test ./internal/server/ -count=1
ok  	github.com/googleapis/mcp-toolbox/internal/server	2.220s
```

Also verified `go vet ./internal/server/...` and `gofmt -l` are clean.

---------

Co-authored-by: Wenxin Du <117315983+duwenxin99@users.noreply.github.com>
This commit is contained in:
Saivedant Hava
2026-07-28 00:35:36 +05:30
committed by GitHub
parent 1c3bf492c7
commit d5aefbc9e9
2 changed files with 179 additions and 0 deletions
+16
View File
@@ -377,6 +377,14 @@ func initializeGroups(ctx context.Context, cfg ServerConfig, toolsMap map[string
func hostCheck(allowedHosts map[string]struct{}) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Skip host validation for health check probes. Container
// orchestrators (Kubernetes, Docker, Cloud Run) typically hit
// /healthz via the pod IP or localhost, which would otherwise
// trip a strict AllowedHosts setting and break liveness probes.
if r.URL.Path == "/healthz" {
next.ServeHTTP(w, r)
return
}
_, hasWildcard := allowedHosts["*"]
hostname := r.Host
if host, _, err := net.SplitHostPort(r.Host); err == nil {
@@ -563,6 +571,14 @@ func NewServer(ctx context.Context, cfg ServerConfig) (*Server, error) {
_, _ = w.Write([]byte("🧰 Hello, World! 🧰"))
})
// healthz endpoint for container orchestration health checks
// (Kubernetes liveness/readiness probes, Docker HEALTHCHECK, etc.).
// Returns 200 OK with a small JSON body so probes can rely on both
// status code and payload.
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
render.JSON(w, r, map[string]string{"status": "ok"})
})
return s, nil
}
+163
View File
@@ -219,6 +219,169 @@ func TestServe(t *testing.T) {
}
func TestHealthz(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
addr, port := "127.0.0.1", 0
cfg := server.ServerConfig{
Version: "0.0.0",
Address: addr,
Port: port,
AllowedHosts: []string{"*"},
}
otelShutdown, err := telemetry.SetupOTel(ctx, "0.0.0", "", false, "", "toolbox")
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
defer func() {
err := otelShutdown(ctx)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
}()
testLogger, err := log.NewStdLogger(os.Stdout, os.Stderr, "info")
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
ctx = util.WithLogger(ctx, testLogger)
instrumentation, err := telemetry.CreateTelemetryInstrumentation(cfg.Version)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
ctx = util.WithInstrumentation(ctx, instrumentation)
s, err := server.NewServer(ctx, cfg)
if err != nil {
t.Fatalf("unable to initialize server: %v", err)
}
err = s.Listen(ctx, "", "")
if err != nil {
t.Fatalf("unable to start server: %v", err)
}
errCh := make(chan error)
go func() {
defer close(errCh)
if serveErr := s.Serve(ctx); serveErr != nil {
errCh <- serveErr
}
}()
url := fmt.Sprintf("http://%s/healthz", s.Addr())
resp, err := http.Get(url)
if err != nil {
t.Fatalf("error when sending a request: %s", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected status 200, got %d", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); ct != "application/json" {
t.Fatalf("expected Content-Type application/json, got %q", ct)
}
raw, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("error reading from request body: %s", err)
}
var body map[string]string
if err := json.Unmarshal(raw, &body); err != nil {
t.Fatalf("expected JSON body, got %q: %s", string(raw), err)
}
if body["status"] != "ok" {
t.Fatalf(`expected {"status":"ok"}, got %q`, string(raw))
}
}
// TestHealthzBypassesHostCheck verifies that /healthz is reachable even when
// AllowedHosts does not include the request host. Container probes (Kubernetes,
// Docker, Cloud Run) commonly hit the endpoint via the pod IP or localhost,
// so the strict host validation must not block them.
func TestHealthzBypassesHostCheck(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
addr, port := "127.0.0.1", 0
cfg := server.ServerConfig{
Version: "0.0.0",
Address: addr,
Port: port,
AllowedHosts: []string{"toolbox.example.com"},
}
otelShutdown, err := telemetry.SetupOTel(ctx, "0.0.0", "", false, "", "toolbox")
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
defer func() {
err := otelShutdown(ctx)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
}()
testLogger, err := log.NewStdLogger(os.Stdout, os.Stderr, "info")
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
ctx = util.WithLogger(ctx, testLogger)
instrumentation, err := telemetry.CreateTelemetryInstrumentation(cfg.Version)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
ctx = util.WithInstrumentation(ctx, instrumentation)
s, err := server.NewServer(ctx, cfg)
if err != nil {
t.Fatalf("unable to initialize server: %v", err)
}
err = s.Listen(ctx, "", "")
if err != nil {
t.Fatalf("unable to start server: %v", err)
}
errCh := make(chan error)
go func() {
defer close(errCh)
if serveErr := s.Serve(ctx); serveErr != nil {
errCh <- serveErr
}
}()
// Hit /healthz via the pod IP (127.0.0.1), which is not in AllowedHosts.
url := fmt.Sprintf("http://%s/healthz", s.Addr())
resp, err := http.Get(url)
if err != nil {
t.Fatalf("error when sending a request: %s", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected /healthz to bypass host check and return 200, got %d", resp.StatusCode)
}
// Sanity check: confirm the host check is still active for other paths.
rootURL := fmt.Sprintf("http://%s/", s.Addr())
rootResp, err := http.Get(rootURL)
if err != nil {
t.Fatalf("error when sending root request: %s", err)
}
defer rootResp.Body.Close()
if rootResp.StatusCode != http.StatusForbidden {
t.Fatalf("expected / to be blocked by host check (403), got %d", rootResp.StatusCode)
}
}
func TestUpdateServer(t *testing.T) {
ctx, err := testutils.ContextWithNewLogger()
if err != nil {