Skip to content

Commit 51d7264

Browse files
fix(observability): keep /readyz controller-backed when health disabled (MCP-32)
CodexReviewer finding: passing a non-nil observability manager made the HTTP API skip its controller-backed health block entirely, so /readyz (and the /livez,/health aliases) either 404'd or fell back to the observability health manager's vacuous always-ready handler — a config-gated feature must be a no-op for readiness when disabled. - httpapi: register /metrics independently of the health endpoints; only let the observability health manager own /healthz+/readyz when it is actually enabled, otherwise keep the controller-backed handlers authoritative. - server: disable the observability health manager in buildObservabilityConfig (out of scope for MCP-32; its readiness is vacuous) so controller readiness stays authoritative even when metrics are enabled. - tests: regression test that with metrics ON / health OFF, /readyz reflects controller readiness (503 when not ready) and the liveness aliases + /metrics are all served; assert buildObservabilityConfig keeps health off. Related MCP-32 Co-Authored-By: Paperclip <noreply@paperclip.ing>
1 parent 09db261 commit 51d7264

4 files changed

Lines changed: 87 additions & 6 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package httpapi
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
"go.uber.org/zap"
11+
12+
"github.com/smart-mcp-proxy/mcpproxy-go/internal/observability"
13+
)
14+
15+
// notReadyController reports IsReady()==false so we can distinguish the
16+
// controller-backed readiness handler (503) from the observability health
17+
// manager's vacuous always-ready handler (200).
18+
type notReadyController struct{ MockServerController }
19+
20+
func (m *notReadyController) IsReady() bool { return false }
21+
22+
// MCP-32 regression: enabling the metrics exporter must NOT take over /readyz.
23+
// When the observability health manager is not active, /readyz must keep
24+
// reflecting controller readiness (a config-gated feature is a no-op for
25+
// readiness). Previously passing a non-nil observability manager skipped the
26+
// controller health block entirely.
27+
func TestReadyz_StaysControllerBackedWhenObservabilityHasNoHealth(t *testing.T) {
28+
obsCfg := observability.Config{
29+
Metrics: observability.MetricsConfig{Enabled: true},
30+
Health: observability.HealthConfig{Enabled: false},
31+
Tracing: observability.TracingConfig{Enabled: false},
32+
}
33+
mgr, err := observability.NewManager(zap.NewNop().Sugar(), &obsCfg)
34+
require.NoError(t, err)
35+
require.NotNil(t, mgr.Metrics())
36+
require.Nil(t, mgr.Health())
37+
38+
srv := NewServer(&notReadyController{}, zap.NewNop().Sugar(), mgr)
39+
40+
// /readyz reflects the controller (not ready -> 503), not a vacuous 200.
41+
rec := httptest.NewRecorder()
42+
srv.router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/readyz", http.NoBody))
43+
assert.Equal(t, http.StatusServiceUnavailable, rec.Code)
44+
assert.Contains(t, rec.Body.String(), `"ready":false`)
45+
46+
// Liveness aliases remain registered too.
47+
for _, path := range []string{"/healthz", "/livez", "/health"} {
48+
r := httptest.NewRecorder()
49+
srv.router.ServeHTTP(r, httptest.NewRequest(http.MethodGet, path, http.NoBody))
50+
assert.Equalf(t, http.StatusOK, r.Code, "liveness endpoint %s should be registered", path)
51+
}
52+
53+
// And the metrics exporter is still served.
54+
rm := httptest.NewRecorder()
55+
srv.router.ServeHTTP(rm, httptest.NewRequest(http.MethodGet, "/metrics", http.NoBody))
56+
assert.Equal(t, http.StatusOK, rm.Code)
57+
assert.Contains(t, rm.Body.String(), "mcpproxy_uptime_seconds")
58+
}

internal/httpapi/server.go

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -560,17 +560,23 @@ func (s *Server) setupRoutes() {
560560
_, _ = w.Write([]byte(`{"ready":false}`))
561561
}
562562

563-
// Observability endpoints (registered first to avoid conflicts)
563+
// Observability /metrics endpoint (MCP-32). Independent of the health
564+
// endpoints below: enabling metrics must not change readiness semantics.
564565
if s.observability != nil {
565-
if health := s.observability.Health(); health != nil {
566-
s.router.Get("/healthz", health.HealthzHandler())
567-
s.router.Get("/readyz", health.ReadyzHandler())
568-
}
569566
if metrics := s.observability.Metrics(); metrics != nil {
570567
s.router.Handle("/metrics", metrics.Handler())
571568
}
569+
}
570+
571+
// Health and readiness endpoints. The observability health manager only
572+
// takes over when it is actually enabled; otherwise the controller-backed
573+
// handlers remain authoritative. A config-gated feature (metrics/tracing)
574+
// must be a no-op for readiness when health is not enabled (MCP-32).
575+
if s.observability != nil && s.observability.Health() != nil {
576+
health := s.observability.Health()
577+
s.router.Get("/healthz", health.HealthzHandler())
578+
s.router.Get("/readyz", health.ReadyzHandler())
572579
} else {
573-
// Register custom health endpoints only if observability is not available
574580
for _, path := range []string{"/livez", "/healthz", "/health"} {
575581
s.router.Get(path, livenessHandler)
576582
}

internal/server/observability_config_test.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,19 @@ func TestBuildObservabilityConfig_DefaultConfigOff(t *testing.T) {
3131
assert.False(t, out.Tracing.Enabled)
3232
}
3333

34+
// MCP-32 regression: the observability health manager is never wired (its
35+
// readiness is vacuous), so /readyz stays controller-backed even with metrics on.
36+
func TestBuildObservabilityConfig_HealthAlwaysDisabled(t *testing.T) {
37+
off := buildObservabilityConfig(config.DefaultConfig())
38+
assert.False(t, off.Health.Enabled, "health manager must stay off (metrics off)")
39+
40+
cfg := config.DefaultConfig()
41+
cfg.Observability.Metrics = &config.MetricsExporterConfig{Enabled: true}
42+
on := buildObservabilityConfig(cfg)
43+
assert.True(t, on.Metrics.Enabled)
44+
assert.False(t, on.Health.Enabled, "health manager must stay off even with metrics on")
45+
}
46+
3447
func TestBuildObservabilityConfig_EnablesAndMapsTracing(t *testing.T) {
3548
cfg := config.DefaultConfig()
3649
cfg.Observability.Metrics = &config.MetricsExporterConfig{Enabled: true}

internal/server/server.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,10 @@ func buildObservabilityConfig(cfg *config.Config) observability.Config {
101101
// DefaultConfig enables metrics; flip to opt-in per the MCP-32 directive.
102102
out.Metrics.Enabled = false
103103
out.Tracing.Enabled = false
104+
// The observability package's health manager is out of scope for MCP-32 and
105+
// its readiness is vacuous (no registered checkers). Keep the existing
106+
// controller-backed /healthz and /readyz authoritative in all cases.
107+
out.Health.Enabled = false
104108

105109
obs := cfg.Observability
106110
if obs == nil {

0 commit comments

Comments
 (0)