Skip to content

Commit a6bef72

Browse files
committed
fix(observability): forward /metrics from outer mux to chi router (MCP-3135)
The /metrics handler is registered on the httpapi chi router but the outer http.ServeMux only forwarded /api/, /events, and the health endpoints, so GET /metrics returned 404 even with observability.metrics.enabled=true. Extract the route forwarding into Server.registerHTTPHandlers and add a gated mux.Handle("/metrics", httpAPIServer) so the endpoint is reachable only when the Prometheus exporter is enabled. Covered by a unit test on the real outer mux and a full-binary e2e that scrapes /metrics for 200 + mcpproxy_uptime_seconds. Related #746
1 parent 51d7264 commit a6bef72

3 files changed

Lines changed: 170 additions & 10 deletions

File tree

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package server
2+
3+
import (
4+
"io"
5+
"net/http"
6+
"os"
7+
"path/filepath"
8+
"strconv"
9+
"testing"
10+
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
14+
"github.com/smart-mcp-proxy/mcpproxy-go/internal/testutil"
15+
)
16+
17+
// TestBinaryMetricsEndpoint is the MCP-3135 integration regression: boot the
18+
// real binary with observability.metrics.enabled=true and assert that GET
19+
// /metrics (at the root, NOT under /api) responds 200 with a scrapeable body.
20+
//
21+
// Before the routing fix the /metrics handler was registered on the httpapi chi
22+
// router but never forwarded by the outer mux, so this returned 404. The config
23+
// here intentionally has zero upstream servers, so the test needs no Node/npx
24+
// dependency — it exercises only the proxy's own HTTP listener wiring.
25+
func TestBinaryMetricsEndpoint(t *testing.T) {
26+
env := testutil.NewBinaryTestEnv(t)
27+
defer env.Cleanup()
28+
29+
// Overwrite the default config with a metrics-enabled, zero-upstream config
30+
// before the binary starts. The data dir was created next to the config by
31+
// NewBinaryTestEnv (tempDir/data).
32+
dataDir := filepath.Join(filepath.Dir(env.GetConfigPath()), "data")
33+
writeMetricsEnabledConfig(t, env.GetConfigPath(), env.GetPort(), dataDir)
34+
35+
env.Start()
36+
37+
resp, err := http.Get(env.GetBaseURL() + "/metrics")
38+
require.NoError(t, err)
39+
defer resp.Body.Close()
40+
41+
body, err := io.ReadAll(resp.Body)
42+
require.NoError(t, err)
43+
44+
assert.Equal(t, http.StatusOK, resp.StatusCode, "GET /metrics should be reachable when metrics enabled; body=%s", string(body))
45+
assert.Contains(t, string(body), "mcpproxy_uptime_seconds", "metrics body should expose the uptime gauge")
46+
}
47+
48+
func writeMetricsEnabledConfig(t *testing.T, configPath string, port int, dataDir string) {
49+
t.Helper()
50+
content := `{
51+
"listen": ":` + strconv.Itoa(port) + `",
52+
"data_dir": "` + dataDir + `",
53+
"api_key": "` + testutil.TestAPIKey + `",
54+
"enable_tray": false,
55+
"mcpServers": [],
56+
"quarantine_enabled": false,
57+
"docker_isolation": { "enabled": false },
58+
"observability": {
59+
"metrics": { "enabled": true }
60+
}
61+
}`
62+
require.NoError(t, os.WriteFile(configPath, []byte(content), 0600))
63+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package server
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/config"
13+
"github.com/smart-mcp-proxy/mcpproxy-go/internal/observability"
14+
)
15+
16+
// MCP-3135 regression: the /metrics handler is registered on the httpapi chi
17+
// router, but the OUTER http.ServeMux in startCustomHTTPServer must forward
18+
// /metrics to it. Before the fix the outer mux only forwarded /api/, /events,
19+
// and the health endpoints, so GET /metrics returned 404 even with metrics
20+
// enabled. These tests exercise the real outer mux via registerHTTPHandlers.
21+
22+
// sentinel stands in for the httpapi chi handler: it serves a scrapeable body
23+
// for any request that the outer mux actually forwards to it.
24+
func metricsSentinel() http.Handler {
25+
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
26+
w.WriteHeader(http.StatusOK)
27+
_, _ = w.Write([]byte("mcpproxy_uptime_seconds 1\n"))
28+
})
29+
}
30+
31+
func TestRegisterHTTPHandlers_MetricsRoutedWhenEnabled(t *testing.T) {
32+
cfg := config.DefaultConfig()
33+
cfg.Observability.Metrics = &config.MetricsExporterConfig{Enabled: true}
34+
obsCfg := buildObservabilityConfig(cfg)
35+
mgr, err := observability.NewManager(zap.NewNop().Sugar(), &obsCfg)
36+
require.NoError(t, err)
37+
require.NotNil(t, mgr.Metrics(), "metrics manager should exist when enabled")
38+
39+
s := &Server{logger: zap.NewNop(), observability: mgr}
40+
mux := http.NewServeMux()
41+
s.registerHTTPHandlers(mux, metricsSentinel())
42+
43+
rec := httptest.NewRecorder()
44+
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", http.NoBody))
45+
46+
// Before the fix this was 404 — the outer mux never forwarded /metrics.
47+
assert.Equal(t, http.StatusOK, rec.Code)
48+
assert.Contains(t, rec.Body.String(), "mcpproxy_uptime_seconds")
49+
}
50+
51+
func TestRegisterHTTPHandlers_MetricsNotRoutedWhenDisabled(t *testing.T) {
52+
// observability nil == metrics disabled (default). /metrics must stay 404.
53+
s := &Server{logger: zap.NewNop(), observability: nil}
54+
mux := http.NewServeMux()
55+
s.registerHTTPHandlers(mux, metricsSentinel())
56+
57+
rec := httptest.NewRecorder()
58+
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", http.NoBody))
59+
60+
assert.Equal(t, http.StatusNotFound, rec.Code, "/metrics must not be routed when metrics are disabled")
61+
}
62+
63+
// The fix must not regress the routes the outer mux already forwarded.
64+
func TestRegisterHTTPHandlers_ForwardsAPIAndHealth(t *testing.T) {
65+
s := &Server{logger: zap.NewNop(), observability: nil}
66+
mux := http.NewServeMux()
67+
s.registerHTTPHandlers(mux, metricsSentinel())
68+
69+
for _, path := range []string{"/api/v1/status", "/events", "/healthz", "/readyz", "/livez", "/ready", "/health"} {
70+
rec := httptest.NewRecorder()
71+
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, http.NoBody))
72+
assert.Equal(t, http.StatusOK, rec.Code, "outer mux should forward %s", path)
73+
}
74+
}

internal/server/server.go

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1705,6 +1705,36 @@ func (s *Server) profileMiddleware(next http.Handler) http.Handler {
17051705

17061706
// startCustomHTTPServer creates a custom HTTP server that handles MCP endpoints
17071707
// It supports both TCP (for browsers) and Unix socket/named pipe (for tray) listeners
1708+
// registerHTTPHandlers forwards the REST API, SSE events, health endpoints,
1709+
// and the observability /metrics endpoint from the outer http.ServeMux to the
1710+
// httpapi chi router (httpAPIServer).
1711+
//
1712+
// MCP-3135: /metrics is registered on the chi router (httpapi.setupRoutes), but
1713+
// the outer mux must explicitly forward it — otherwise GET /metrics returns 404
1714+
// even when metrics are enabled. The forward is gated on metrics actually being
1715+
// enabled so a disabled deployment keeps /metrics unrouted (404).
1716+
func (s *Server) registerHTTPHandlers(mux *http.ServeMux, httpAPIServer http.Handler) {
1717+
mux.Handle("/api/", httpAPIServer)
1718+
mux.Handle("/events", httpAPIServer)
1719+
1720+
// Mount health endpoints directly on main mux at root level
1721+
healthEndpoints := []string{"/healthz", "/readyz", "/livez", "/ready", "/health"}
1722+
for _, endpoint := range healthEndpoints {
1723+
mux.Handle(endpoint, httpAPIServer)
1724+
}
1725+
1726+
s.logger.Info("Registered REST API endpoints", zap.Strings("api_endpoints", []string{"/api/v1/*", "/events"}))
1727+
s.logger.Info("Registered health endpoints", zap.Strings("health_endpoints", healthEndpoints))
1728+
1729+
// MCP-32/MCP-3135: forward /metrics to the chi router only when the
1730+
// Prometheus exporter is enabled. Without this the handler registered in
1731+
// httpapi.setupRoutes is unreachable through the outer mux.
1732+
if s.observability != nil && s.observability.Metrics() != nil {
1733+
mux.Handle("/metrics", httpAPIServer)
1734+
s.logger.Info("Registered metrics endpoint", zap.String("endpoint", "/metrics"))
1735+
}
1736+
}
1737+
17081738
func (s *Server) startCustomHTTPServer(ctx context.Context, streamableServer *server.StreamableHTTPServer) error {
17091739
cfg := s.runtime.Config()
17101740
if cfg == nil {
@@ -1937,17 +1967,10 @@ func (s *Server) startCustomHTTPServer(ctx context.Context, streamableServer *se
19371967
}
19381968
// Wire server edition multi-user OAuth (no-op in personal edition)
19391969
wireServerEditionOAuth(s, httpAPIServer)
1940-
mux.Handle("/api/", httpAPIServer)
1941-
mux.Handle("/events", httpAPIServer)
19421970

1943-
// Mount health endpoints directly on main mux at root level
1944-
healthEndpoints := []string{"/healthz", "/readyz", "/livez", "/ready", "/health"}
1945-
for _, endpoint := range healthEndpoints {
1946-
mux.Handle(endpoint, httpAPIServer)
1947-
}
1948-
1949-
s.logger.Info("Registered REST API endpoints", zap.Strings("api_endpoints", []string{"/api/v1/*", "/events"}))
1950-
s.logger.Info("Registered health endpoints", zap.Strings("health_endpoints", healthEndpoints))
1971+
// Forward REST API, events, health, and (MCP-32) /metrics onto the outer
1972+
// mux. Extracted so the routing is unit-testable (MCP-3135 regression).
1973+
s.registerHTTPHandlers(mux, httpAPIServer)
19511974

19521975
// Debug / profiling endpoints (API-key gated). Block & mutex profiles
19531976
// default to off; we enable them when the route is hit so the running

0 commit comments

Comments
 (0)