Skip to content

Commit d5aefbc

Browse files
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>
1 parent 1c3bf49 commit d5aefbc

2 files changed

Lines changed: 179 additions & 0 deletions

File tree

‎internal/server/server.go‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,14 @@ func initializeGroups(ctx context.Context, cfg ServerConfig, toolsMap map[string
377377
func hostCheck(allowedHosts map[string]struct{}) func(http.Handler) http.Handler {
378378
return func(next http.Handler) http.Handler {
379379
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
380+
// Skip host validation for health check probes. Container
381+
// orchestrators (Kubernetes, Docker, Cloud Run) typically hit
382+
// /healthz via the pod IP or localhost, which would otherwise
383+
// trip a strict AllowedHosts setting and break liveness probes.
384+
if r.URL.Path == "/healthz" {
385+
next.ServeHTTP(w, r)
386+
return
387+
}
380388
_, hasWildcard := allowedHosts["*"]
381389
hostname := r.Host
382390
if host, _, err := net.SplitHostPort(r.Host); err == nil {
@@ -563,6 +571,14 @@ func NewServer(ctx context.Context, cfg ServerConfig) (*Server, error) {
563571
_, _ = w.Write([]byte("🧰 Hello, World! 🧰"))
564572
})
565573

574+
// healthz endpoint for container orchestration health checks
575+
// (Kubernetes liveness/readiness probes, Docker HEALTHCHECK, etc.).
576+
// Returns 200 OK with a small JSON body so probes can rely on both
577+
// status code and payload.
578+
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
579+
render.JSON(w, r, map[string]string{"status": "ok"})
580+
})
581+
566582
return s, nil
567583
}
568584

‎internal/server/server_test.go‎

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,169 @@ func TestServe(t *testing.T) {
219219

220220
}
221221

222+
func TestHealthz(t *testing.T) {
223+
ctx, cancel := context.WithCancel(context.Background())
224+
defer cancel()
225+
226+
addr, port := "127.0.0.1", 0
227+
cfg := server.ServerConfig{
228+
Version: "0.0.0",
229+
Address: addr,
230+
Port: port,
231+
AllowedHosts: []string{"*"},
232+
}
233+
234+
otelShutdown, err := telemetry.SetupOTel(ctx, "0.0.0", "", false, "", "toolbox")
235+
if err != nil {
236+
t.Fatalf("unexpected error: %s", err)
237+
}
238+
defer func() {
239+
err := otelShutdown(ctx)
240+
if err != nil {
241+
t.Fatalf("unexpected error: %s", err)
242+
}
243+
}()
244+
245+
testLogger, err := log.NewStdLogger(os.Stdout, os.Stderr, "info")
246+
if err != nil {
247+
t.Fatalf("unexpected error: %s", err)
248+
}
249+
ctx = util.WithLogger(ctx, testLogger)
250+
251+
instrumentation, err := telemetry.CreateTelemetryInstrumentation(cfg.Version)
252+
if err != nil {
253+
t.Fatalf("unexpected error: %s", err)
254+
}
255+
ctx = util.WithInstrumentation(ctx, instrumentation)
256+
257+
s, err := server.NewServer(ctx, cfg)
258+
if err != nil {
259+
t.Fatalf("unable to initialize server: %v", err)
260+
}
261+
262+
err = s.Listen(ctx, "", "")
263+
if err != nil {
264+
t.Fatalf("unable to start server: %v", err)
265+
}
266+
267+
errCh := make(chan error)
268+
go func() {
269+
defer close(errCh)
270+
if serveErr := s.Serve(ctx); serveErr != nil {
271+
errCh <- serveErr
272+
}
273+
}()
274+
275+
url := fmt.Sprintf("http://%s/healthz", s.Addr())
276+
resp, err := http.Get(url)
277+
if err != nil {
278+
t.Fatalf("error when sending a request: %s", err)
279+
}
280+
defer resp.Body.Close()
281+
282+
if resp.StatusCode != http.StatusOK {
283+
t.Fatalf("expected status 200, got %d", resp.StatusCode)
284+
}
285+
286+
if ct := resp.Header.Get("Content-Type"); ct != "application/json" {
287+
t.Fatalf("expected Content-Type application/json, got %q", ct)
288+
}
289+
290+
raw, err := io.ReadAll(resp.Body)
291+
if err != nil {
292+
t.Fatalf("error reading from request body: %s", err)
293+
}
294+
295+
var body map[string]string
296+
if err := json.Unmarshal(raw, &body); err != nil {
297+
t.Fatalf("expected JSON body, got %q: %s", string(raw), err)
298+
}
299+
if body["status"] != "ok" {
300+
t.Fatalf(`expected {"status":"ok"}, got %q`, string(raw))
301+
}
302+
}
303+
304+
// TestHealthzBypassesHostCheck verifies that /healthz is reachable even when
305+
// AllowedHosts does not include the request host. Container probes (Kubernetes,
306+
// Docker, Cloud Run) commonly hit the endpoint via the pod IP or localhost,
307+
// so the strict host validation must not block them.
308+
func TestHealthzBypassesHostCheck(t *testing.T) {
309+
ctx, cancel := context.WithCancel(context.Background())
310+
defer cancel()
311+
312+
addr, port := "127.0.0.1", 0
313+
cfg := server.ServerConfig{
314+
Version: "0.0.0",
315+
Address: addr,
316+
Port: port,
317+
AllowedHosts: []string{"toolbox.example.com"},
318+
}
319+
320+
otelShutdown, err := telemetry.SetupOTel(ctx, "0.0.0", "", false, "", "toolbox")
321+
if err != nil {
322+
t.Fatalf("unexpected error: %s", err)
323+
}
324+
defer func() {
325+
err := otelShutdown(ctx)
326+
if err != nil {
327+
t.Fatalf("unexpected error: %s", err)
328+
}
329+
}()
330+
331+
testLogger, err := log.NewStdLogger(os.Stdout, os.Stderr, "info")
332+
if err != nil {
333+
t.Fatalf("unexpected error: %s", err)
334+
}
335+
ctx = util.WithLogger(ctx, testLogger)
336+
337+
instrumentation, err := telemetry.CreateTelemetryInstrumentation(cfg.Version)
338+
if err != nil {
339+
t.Fatalf("unexpected error: %s", err)
340+
}
341+
ctx = util.WithInstrumentation(ctx, instrumentation)
342+
343+
s, err := server.NewServer(ctx, cfg)
344+
if err != nil {
345+
t.Fatalf("unable to initialize server: %v", err)
346+
}
347+
348+
err = s.Listen(ctx, "", "")
349+
if err != nil {
350+
t.Fatalf("unable to start server: %v", err)
351+
}
352+
353+
errCh := make(chan error)
354+
go func() {
355+
defer close(errCh)
356+
if serveErr := s.Serve(ctx); serveErr != nil {
357+
errCh <- serveErr
358+
}
359+
}()
360+
361+
// Hit /healthz via the pod IP (127.0.0.1), which is not in AllowedHosts.
362+
url := fmt.Sprintf("http://%s/healthz", s.Addr())
363+
resp, err := http.Get(url)
364+
if err != nil {
365+
t.Fatalf("error when sending a request: %s", err)
366+
}
367+
defer resp.Body.Close()
368+
369+
if resp.StatusCode != http.StatusOK {
370+
t.Fatalf("expected /healthz to bypass host check and return 200, got %d", resp.StatusCode)
371+
}
372+
373+
// Sanity check: confirm the host check is still active for other paths.
374+
rootURL := fmt.Sprintf("http://%s/", s.Addr())
375+
rootResp, err := http.Get(rootURL)
376+
if err != nil {
377+
t.Fatalf("error when sending root request: %s", err)
378+
}
379+
defer rootResp.Body.Close()
380+
if rootResp.StatusCode != http.StatusForbidden {
381+
t.Fatalf("expected / to be blocked by host check (403), got %d", rootResp.StatusCode)
382+
}
383+
}
384+
222385
func TestUpdateServer(t *testing.T) {
223386
ctx, err := testutils.ContextWithNewLogger()
224387
if err != nil {

0 commit comments

Comments
 (0)