Serve Prometheus metrics on a separate diagnostics listener - #6296
Serve Prometheus metrics on a separate diagnostics listener#6296amirejaz wants to merge 6 commits into
Conversation
Go's ServeMux resolves the most specific registered pattern first, so the explicitly registered /metrics always outranked the "/" catch-all that carries the proxy middleware chain. The endpoint was therefore reachable without authentication, body limits, rate limiting, or audit even on a fully OIDC-configured deployment. In Kubernetes it was also internet- reachable: the operator binds the proxy to 0.0.0.0 and the Service maps the proxy port. Bind metrics to a dedicated diagnostics listener instead. The runner no longer hands the Prometheus handler to the transport, which is what keeps the proxies from mounting /metrics on the application mux at all; they now return 404 there, mirroring how /health already guards itself. /health stays on the application listener because Kubernetes probes target it and it exposes no build information. The listener defaults to port 9464, the OpenTelemetry specification's Prometheus exporter default, and falls back to an available port when that one is taken. This matches the pattern the ToolHive operator already uses for its own metrics endpoint (--metrics-bind-address), as do etcd (--listen-metrics-urls) and controller-runtime. Part of #6271 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Release the listener on every exit from Runner.Run. Cleanup runs only via stopMCPServer, which the early error returns skip, as does the graceful container-exit branch that returns nil after the transport has stopped. Leaking there stranded a goroutine and a bound port; under the restart loop in workloads.Manager each attempt would strand another and push the next onto a different port, silently breaking the stable scrape target this change exists to provide. Set ReadTimeout, WriteTimeout, and MaxHeaderBytes to match the proxy and vMCP listeners. ReadHeaderTimeout alone does not bound a trickled request body, and this listener carries no middleware, so nothing else bounds a slow or abandoned client. Assert the reachability half of the split: with no handler supplied, /metrics must return 404 on the application listener and must not fall through to the backend. Warn in the package doc that everything served here is unauthenticated, so no pprof or other debug handler is ever added to this mux. Part of #6271 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR addresses a security gap where /metrics could bypass the proxy middleware chain (auth/body limits/rate limiting/audit) due to ServeMux longest-match behavior, by moving Prometheus metrics to a dedicated diagnostics listener and ensuring /metrics is never proxied to backends when disabled.
Changes:
- Add a dedicated diagnostics HTTP server (
pkg/diagnostics) to serve only/metricson a separate listener (default port 9464, with fallback when taken). - Update runners and proxies so transports no longer mount a Prometheus handler on the application mux, and return 404 for
/metricswhen no handler is supplied. - Update telemetry config/docs and adjust unit/e2e tests for the new diagnostics listener behavior.
Reviewed changes
Copilot reviewed 20 out of 21 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| test/e2e/telemetry_middleware_e2e_test.go | Updates best-effort metrics probing to prefer the diagnostics default port and use diagnostics.MetricsPath. |
| test/e2e/telemetry_metrics_validation_e2e_test.go | Updates metrics URL construction to target diagnostics listener (currently assumes default port). |
| test/e2e/osv_authz_test.go | Updates metrics URL construction to target diagnostics listener (currently assumes default port). |
| pkg/transport/proxy/transparent/transparent_test.go | Adds regression test ensuring /metrics is 404 and not proxied to backend when no handler is supplied. |
| pkg/transport/proxy/transparent/transparent_proxy.go | Returns 404 for /metrics when no Prometheus handler is provided; adds rationale comments. |
| pkg/transport/proxy/streamable/streamable_proxy.go | Returns 404 for /metrics when no Prometheus handler is provided. |
| pkg/transport/proxy/httpsse/http_proxy.go | Returns 404 for /metrics when no Prometheus handler is provided. |
| pkg/telemetry/middleware.go | Stops logging the application port for /metrics; keeps setting Prometheus handler on the runner. |
| pkg/telemetry/middleware_test.go | Updates expectations to avoid reading the application port during Prometheus handler wiring. |
| pkg/telemetry/config.go | Adds PrometheusPort and updates docs to reflect dedicated diagnostics listener behavior. |
| pkg/runner/runner.go | Starts/stops the diagnostics server from Run/Cleanup and stops passing handler to transport config. |
| pkg/runner/diagnostics.go | Implements runner start/stop logic for the diagnostics server and port selection behavior. |
| pkg/runner/diagnostics_test.go | Adds unit tests ensuring metrics bind on a separate port, host defaulting, and idempotent shutdown. |
| pkg/diagnostics/server.go | Introduces the diagnostics server implementation with timeouts and port fallback logic. |
| pkg/diagnostics/server_test.go | Adds unit tests for validation, serving only /metrics, and stop/start semantics. |
| docs/server/swagger.yaml | Documents prometheusPort and updates Prometheus metrics exposure description. |
| docs/server/swagger.json | Generated API docs update for the telemetry config fields/descriptions. |
| docs/server/docs.go | Generated API docs template update for the telemetry config fields/descriptions. |
| docs/observability.md | Documents diagnostics port behavior, port selection, and cardinality warning. |
| docs/cli/thv_run.md | Updates CLI docs text to reflect metrics being on a dedicated diagnostics port. |
| cmd/thv/app/run_flags.go | Updates flag help text to reflect metrics moving off the transport port. |
Files not reviewed (1)
- docs/server/docs.go: Generated file
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| host := parts[1][2:] // Remove "//" prefix | ||
| portAndPath := parts[2] | ||
|
|
||
| // Extract just the port (remove /sse#servername or /mcp part) | ||
| portParts := strings.Split(portAndPath, "/") | ||
| if len(portParts) < 1 { | ||
| return "", fmt.Errorf("invalid server URL format: %s", serverURL) | ||
| } | ||
| port := portParts[0] | ||
|
|
||
| metricsURL := fmt.Sprintf("http://%s:%s/metrics", host, port) | ||
| metricsURL := fmt.Sprintf("http://%s:%d%s", host, diagnostics.DefaultPort, diagnostics.MetricsPath) | ||
| return metricsURL, nil |
| host := parts[1][2:] // Remove "//" prefix | ||
| portAndPath := parts[2] | ||
|
|
||
| // Extract just the port (remove /sse#servername part) | ||
| portParts := strings.Split(portAndPath, "/") | ||
| if len(portParts) < 1 { | ||
| return "", fmt.Errorf("invalid server URL format: %s", serverURL) | ||
| } | ||
| port := portParts[0] | ||
|
|
||
| metricsURL := fmt.Sprintf("http://%s:%s/metrics", host, port) | ||
| metricsURL := fmt.Sprintf("http://%s:%d%s", host, diagnostics.DefaultPort, diagnostics.MetricsPath) | ||
|
|
||
| return metricsURL, nil |
Classify the new prometheusPort runtime field in the telemetry drift table. The operator drift contract requires every telemetry.Config leaf to be either mapped to a CRD path or ignored with a justification, and an unclassified field fails CI. Ignore it for now: the runtime default is deterministic so scrapers need no CRD knob, and exposing prometheus.port later should promote the entry into the mappings table. Retry the bind when the port is claimed between the availability check and the bind itself. FindOrUsePort only checks, so a lost race previously failed the whole workload over a diagnostics listener; each retry re-resolves, so a genuinely occupied port converges on an alternative. Cover the occupied-port case with a test. Correct the New doc comment, which still described port 0 as the expected default after the fixed default port was introduced. Record why the e2e metrics helpers assume the default port, and what would make them deterministic, since the listener can legitimately bind elsewhere. Part of #6271 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #6296 +/- ##
==========================================
+ Coverage 72.85% 72.92% +0.06%
==========================================
Files 743 744 +1
Lines 77681 78289 +608
==========================================
+ Hits 56596 57093 +497
- Misses 17118 17203 +85
- Partials 3967 3993 +26 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
The VirtualMCPServer CRD embeds pkg/vmcp/config, which holds a *telemetry.Config, so adding PrometheusPort to that struct changes the generated CRD schema and API docs. Operator CI regenerates and diffs these, so the stale checked-in output failed the Generate CRDs and Generate CRD Docs jobs. The change is additive: a new optional integer field plus the corrected description on enablePrometheusMetricsPath. Also tighten the drift justification. prometheusPort is intentionally absent from the shared MCPTelemetryConfig, but VirtualMCPServer does expose it inline, and the previous wording implied no CRD surface at all. Part of #6271 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Wouldn't a better fix have been to only route the relevant routes via the Gateway API kubernetes resource? in the end, service everything on the same interface is not really a problem, the problem is exposing it, and this is already a solved problem with how kubernetes exposes routes. Seems to me like this is more of a documentation issue than a functional one. |
|
You're right that the description oversells this, and that's my error to fix — the "bypasses the middleware chain" wording implies we restore auth, rate limiting and audit for On the substantive question though — I don't think routing alone covers it. Also worth noting we create a plain Your point does move the bar though: the justification is "port separation is enforceable and safe by default", not "fixes a bypass". If you don't think that earns a breaking change, better to settle it now before we build out the migration story. |
The comments and docs claimed that serving /metrics on the transport port left it outside authentication, rate limiting, and audit, which implied this change restores them. It does not: the diagnostics listener carries no middleware, and the operator binds it to 0.0.0.0, so the endpoint is just as unauthenticated on its own port and stays reachable from other pods. State the narrower claim that actually holds. NetworkPolicy matches on pods, ports, and protocols and cannot filter on HTTP path, so while /metrics shares the transport port no policy can permit MCP traffic while denying metrics scraping. On a separate port that becomes expressible. Route-level controls address north-south exposure only and leave pod-to-pod traffic untouched. This wording reaches users: pkg/telemetry.Config feeds the swagger output, the VirtualMCPServer CRD schema, and the CRD API reference, so the misleading claim was visible in kubectl explain. Add a NetworkPolicy example to the observability docs so the justification is actionable rather than asserted. No behaviour change. Part of #6271 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@amirejaz I wasn't talking about a NetworkPolicy. I agree it's not enough if we'd only leverage that. But production deployments of MCP servers tend to use either an HTTPRoute (or several) or an Ingress resource. We should document that MCP servers should expose only the paths needed instead of a blanket |
|
Agreed, and we have no guidance on that today — the docs cover how to reach a workload externally but never say to scope the route to specific paths. Worth deciding where it should live. The advice applies beyond metrics: And to check I've got you — you're not arguing against the separate port, just that path-scoped routes should be documented alongside it? |
|
Let's add the docs here just to keep it all in one place. and keep this change too. |
The operator creates a plain Service and leaves the Ingress or HTTPRoute to the deployment, and nothing told operators to scope it. A blanket "/" publishes every path on the transport port, including ones meant to stay internal, and silently publishes anything added to that mux later. List what the transport port serves and whether each path belongs on an external route, so the decision does not require reading the proxy source: the MCP endpoint per transport, OAuth discovery, the embedded authorization server routes when enabled, and /health which exists for in-cluster probes. Add an HTTPRoute example publishing only the MCP endpoint and RFC 9728 discovery. This is a separate control from the NetworkPolicy above: a policy governs which pods may connect, a route governs which paths are published. Requested by @JAORMX in review. Part of #6271 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Summary
An external good-faith security report against a
toolhive-doc-mcpdeploymentflagged the proxy diagnostics endpoints. Finding E of #6271:
/metricsis registeredas an explicit
ServeMuxpath, and since Go resolves the most specific pattern firstit always outranks the
/catch-all, so it shares the port that serves MCP traffic.In Kubernetes that port is the one deployments route publicly — the operator binds
the proxy to
0.0.0.0(mcpserver_runconfig.go) and the Service maps it — soenabling metrics put an unauthenticated endpoint on a publicly routable listener,
exposing tool names, MCP method names, client names, and traffic volumes.
What this changes, precisely (thanks @JAORMX for pushing on the original framing,
which overstated it): the fix does not authenticate, rate limit, or audit
/metrics. The diagnostics listener carries no middleware by design, and the operatorbinds it to
0.0.0.0, so the endpoint is just as unauthenticated on its own port andremains reachable from other pods.
What it does buy is control by port. Kubernetes
NetworkPolicymatches on pods,ports, and protocols and cannot filter on HTTP path, so while
/metricsshares thetransport port there is no way to express "allow MCP traffic, deny metrics scraping" —
any policy permitting MCP clients also permits scraping. On its own port that becomes
expressible, and the safe outcome no longer depends on every deployment getting its
route rules right. Route-level controls (Gateway API, Ingress path rules) close the
north-south half but leave pod-to-pod traffic untouched. This is why etcd
(
--listen-metrics-urls), controller-runtime (--metrics-bind-address), and theToolHive operator itself all put metrics on a separate port.
/metricsto a dedicated diagnostics listener (pkg/diagnostics). The runnerno longer passes the Prometheus handler to the transport — that omission is what
stops the proxies mounting
/metricson the application mux at all./metricson the application listener, mirroring how/healthalready guards itself, so the path is not silently proxied to the backend instead.
9464, the OpenTelemetry spec's Prometheus exporter default(
OTEL_EXPORTER_PROMETHEUS_PORT), falling back to an available port when taken./healthon the application listener: Kubernetes probes target it, and Drop build fingerprint from proxy /health response #6280already removed its build fingerprint.
/metricsis off by default at every layer, so this only affects deployments thatexplicitly enabled it.
Part of #6271
Type of change
Test plan
task test)task test-e2e)task lint-fix)New coverage:
pkg/diagnostics/server_test.go— construction validation, metrics served on itsown listener, only
/metricsserved (no catch-all), start-twice, idempotent stop.pkg/runner/diagnostics_test.go— the core property: with metrics enabled thelistener binds a port distinct from the application port; plus port resolution,
host defaulting, and nil-telemetry-config handling.
pkg/transport/proxy/transparent/transparent_test.go— the reachability half:with no handler supplied,
/metricsreturns 404 on the application listener anddoes not fall through to the backend.
All touched packages pass under
-race.go vet ./pkg/... ./test/e2e/...is clean.Two caveats, both pre-existing and unrelated:
task lint-fixreports onegosecG115 finding incmd/thv/app/upgrade.go:204,which this PR does not touch.
pkg/plugins/pluginsvccurrently failsTestValidateOCIRegistryHostandTestParseGitReference_RefAndSubdirlocally. Both reproduce identically on a cleanorigin/main(3c4dec3) and appear host/DNS-dependent, so they look like flakesworth a separate look — flagging in case CI shows them here.
Three E2E tests that scraped
/metricson the proxy port were updated to target thediagnostics port. I have not run
task test-e2elocally (needs a container runtime);please let CI exercise it.
API Compatibility
v1beta1API, OR theapi-break-allowedlabel is applied and the migration guidance is described above.The new
PrometheusPortfield is additive and lives onpkg/telemetry.Config, whichis not embedded in the operator CRDs.
Changes
pkg/diagnostics/server.go/metrics, with the rationale for why it must not share the app listener and a warning against adding debug handlers to itpkg/runner/diagnostics.gopkg/runner/runner.gotransportConfig.PrometheusHandler; release it on every exit fromRun; stop it inCleanuppkg/telemetry/config.goPrometheusPort; correct the now-wrong "served on the main transport port" docpkg/telemetry/middleware.go/metricspkg/transport/proxy/{transparent,streamable,httpsse}/metricswhen no handler is supplieddocs/observability.mdDoes this introduce a user-facing change?
Yes, two changes.
1.
/metricsmoves to a dedicated port. For deployments withenablePrometheusMetricsPathenabled,/metricsmoves off the transport port onto adedicated diagnostics port (default
9464). Scrape configs pointed at the transportport need updating, and the diagnostics port should be kept off any internet-facing
Service or Ingress and restricted with a
NetworkPolicy.This is a deliberate break: leaving the endpoint where it is means leaving it outside
the middleware chain. Worth a release note.
2.
/metricsis no longer proxied to the backend. Previously, when metrics weredisabled,
/metricsfell through to the catch-all and was forwarded to the backendMCP server. It now returns 404 on the application listener. A remote MCP server that
exposes its own
/metricsbehindthv proxywould no longer be reachable at thatpath. This mirrors how
/healthhas always been shadowed, and it is what stops thepath silently reaching the backend now that no handler is registered — but it is a
behaviour change worth calling out.
Special notes for reviewers
I self-reviewed this before opening it for review and fixed four things; the commit
history separates the original change from the hardening pass.
The one worth knowing about: the diagnostics listener leaked on most of
Run's exitpaths.
Cleanupis reached only viastopMCPServer, which the early error returnsskip, as does the graceful container-exit branch that returns
nil. Underworkloads.Manager's exponential-backoff restart loop, every failed attempt wouldhave stranded a goroutine and a bound port — and because
9464stayed held, the nextattempt would silently land on a different port, breaking the stable scrape target
this PR exists to provide, in exactly the crash-looping scenario where metrics matter
most. Fixed with an unconditional
defer(the stop is nil-safe and idempotent).Also fixed: missing
ReadTimeout/WriteTimeout/MaxHeaderBytes(this listener hasno middleware, so nothing else bounds a trickled body); a missing test for the 404
behaviour; and a package-doc warning against ever registering pprof here.
Three things I'd still like scrutiny on:
The port default. I chose a fixed
9464over an auto-assigned port soKubernetes scrapers have a predictable target — an arbitrary port would have broken
in-cluster scraping with no way to fix it until a CRD field lands. The trade-off is
that a second CLI workload on one machine falls back to a different port (logged at
startup). Alternative considered: authenticating
/metricsinstead of moving it.Rejected because ToolHive's OIDC middleware cannot validate Kubernetes
service-account tokens, so Prometheus could not scrape it; the TokenReview approach
(kube-rbac-proxy / controller-runtime
WithAuthenticationAndAuthorization) would bea Kubernetes-only auth path and more code.
Hard-failing
Runif the diagnostics port cannot bind.FindOrUsePortfallsback to an available port, so this needs a machine with no free ports — but there is
a TOCTOU window between its check and our bind, so in principle a transient race
could fail the workload over metrics. I kept the hard failure: the feature is
opt-in, the failure is loud at startup, and silently degraded observability is
exactly what Telemetry + diagnostics hardening: bound label length (OOM), overflow lock, tool/prompt cardinality, diagnostics bypass, /health fingerprint #6271 complains about. Happy to soften it to a warning if you disagree.
Dead capability left in place. The proxies still accept a
prometheusHandler,but nothing supplies one now. I left the plumbing rather than mix a dead-code
removal into a security fix — happy to strip it here or in a follow-up.
Follow-ups, not in scope here: vMCP (
pkg/vmcp/server/server.go:600registers its ownunauthenticated
/metrics), an operator CRD field and CLI flag for the port, aseparate bind host for diagnostics, and surfacing the resolved port in workload status
rather than only the startup log.
I also added the cardinality warning to
docs/observability.mdthat #6271 notes ismissing. Say the word if you'd rather that went in its own docs PR.
Generated with Claude Code