Serve Prometheus metrics on a separate diagnostics listener - #6296
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.
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>
|
Added in The part I think is most useful is a table of what actually lives on the transport port and whether each path belongs on an external route, so nobody has to read the proxy source to work it out: One thing worth a second opinion: this puts general route-scoping advice in a telemetry doc. That's what you asked for and it does keep it together, but it may want moving to a deployment/hardening page later — happy either way. |
Virtual MCP registered /metrics on the mux that serves MCP traffic, under a comment noting it was unauthenticated. That is the vMCP half of finding E in #6271; #6296 moved the proxy half. Bind it to the diagnostics listener, so access can be governed by port: NetworkPolicy matches on pods, ports, and protocols and cannot filter on HTTP path, so a shared port makes "allow MCP, deny scraping" unexpressible. This does not authenticate the endpoint; the diagnostics listener carries no middleware. Honour the same migration switch the proxy path uses, so this is not a breaking change on its own: while metricsOnTransportPort is on, /metrics stays reachable on the MCP port too, and a deprecation warning names it. That matters more here than for the proxy. Nothing shipped enables the metrics path for MCPServer, but three vMCP artifacts do -- examples/vmcp-config.yaml and two operator docs -- so more deployments plausibly have it on. Add Server.DiagnosticsAddress so the resolved port can be discovered programmatically. The listener falls back to an available port when the configured one is taken, so tests and callers cannot construct the address; the vMCP telemetry tests now scrape through it. Part of #6271 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Virtual MCP registered /metrics on the mux that serves MCP traffic, under a comment noting it was unauthenticated. That is the vMCP half of finding E in #6271; #6296 moved the proxy half. Bind it to the diagnostics listener, so access can be governed by port: NetworkPolicy matches on pods, ports, and protocols and cannot filter on HTTP path, so a shared port makes "allow MCP, deny scraping" unexpressible. This does not authenticate the endpoint; the diagnostics listener carries no middleware. Honour the same migration switch the proxy path uses, so this is not a breaking change on its own: while metricsOnTransportPort is on, /metrics stays reachable on the MCP port too, and a deprecation warning names it. That matters more here than for the proxy. Nothing shipped enables the metrics path for MCPServer, but three vMCP artifacts do -- examples/vmcp-config.yaml and two operator docs -- so more deployments plausibly have it on. Add Server.DiagnosticsAddress so the resolved port can be discovered programmatically. The listener falls back to an available port when the configured one is taken, so tests and callers cannot construct the address; the vMCP telemetry tests now scrape through it. Part of #6271 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Make the metrics endpoint move discoverable #6296 moved /metrics off the transport port. For a deployment that had enabled metrics, that upgrade is close to silent: Prometheus marks the target down with a 404, which says nothing about why, and the only clue on the server side was an INFO line among many. Give the move two signals. The 404 on the transport port now carries a body naming the diagnostics port and pointing at the docs, so curl answers the question directly; a bare 404 is indistinguishable from a typo. The startup line becomes a WARN stating the endpoint is not on the application port, which fires only when metrics are enabled -- exactly the deployments whose scrape configuration has to change. Neither prevents the break. They shorten the diagnosis from an investigation to a lookup, which is the part that can be fixed without shipping the shared-port exposure for another release. The status stays 404 rather than 410 Gone: the handler is also mounted on deployments that never served metrics there, where claiming the resource was removed would be untrue. Part of #6271 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Stop naming a port in the moved-metrics response The 404 body hardcoded DefaultPort. The diagnostics listener honours a configured prometheusPort and falls back to an available port when that one is taken, so the body was wrong for both of those supported configurations -- sending someone who is already lost to a second dead endpoint, which is worse than saying nothing. Name no port. Point at the startup log instead, which carries the resolved address and is the one source that is always correct, and share the log message as a constant so the text the body tells you to grep for cannot drift from the text that is logged. Passing the resolved address to the handler would be better still, but it is not available where the handler is registered: the proxies receive their /metrics handler as a positional constructor argument, so it would mean a new field on the shared transport config plus a fourth parameter on three public constructors and their call sites, for a message that is removed once the migration finishes. Cover the two configurations the body cannot name -- an explicit port and the occupied-port fallback -- by asserting the reported address is the one actually serving, and guard against a port creeping back into the body. Part of #6271 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
jhrozek's review on #6296 flagged two things. The "what lives on the transport port" table said /metrics always 404s there, contradicting the migration-window section a few paragraphs above that says it is served on both ports for now. Make the row conditional on the window rather than stating one state as if it were permanent. Extract the transport-port mount decision out of Run() into mountPrometheusHandlerOnTransportPort, and add unset/true/false coverage for it. The tri-state itself was already tested in pkg/telemetry, and the stacked PRs test CLI parsing and vMCP's mux, but nothing proved the resolved value actually reached transportConfig.PrometheusHandler for a standard workload -- a regression there would leave the switch resolving correctly while silently never mounting the transport-port copy. Part of #6271
* Restore transport-port metrics behind a migration switch #6296 moved /metrics to a diagnostics port. That is a breaking change for any deployment scraping the old location, and it has not shipped yet, so there is still room to give it a notice window rather than land it cold. Serve /metrics on both ports for now. The diagnostics listener always runs; the transport-port copy is controlled by MetricsOnTransportPort and defaults to on, so no existing scrape configuration breaks. Operators can move a scraper to the new port, verify it, and set the field to false to prove nothing else depended on the old one. Closing the window is a one-line change to DefaultMetricsOnTransportPort. The field is a pointer with no kubebuilder default, which is what makes the eventual flip work. RunConfig.TelemetryConfig is serialised and CRD defaults are materialised at admission, so a plain bool would be written into every workload created during the window and would survive the cutover unchanged -- the flip would silently move nobody. Nil means unset and is resolved at startup instead, with tests covering both the resolution and the round trip. Deployments that set the field explicitly are deliberately not moved by the flip. Part of #6271 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Point the deprecation notice at a tracking issue The notice said the transport-port copy would be removed but never said when, and a deprecation without a deadline is easy to ignore. Users also had nowhere to look for the plan. Reference #6384 from the startup warning, the field documentation, and the migration steps. A tracking issue rather than a version or date: the release that closes the window is not known yet and may move, and the issue can be revised where these strings cannot -- the field description ships to users through the CRD schema and kubectl explain. Also point DefaultMetricsOnTransportPort at it, since flipping that constant is not the whole job and the issue carries the cleanup list. Part of #6271 * Address review: fix contradictory table, cover the mount decision jhrozek's review on #6296 flagged two things. The "what lives on the transport port" table said /metrics always 404s there, contradicting the migration-window section a few paragraphs above that says it is served on both ports for now. Make the row conditional on the window rather than stating one state as if it were permanent. Extract the transport-port mount decision out of Run() into mountPrometheusHandlerOnTransportPort, and add unset/true/false coverage for it. The tri-state itself was already tested in pkg/telemetry, and the stacked PRs test CLI parsing and vMCP's mux, but nothing proved the resolved value actually reached transportConfig.PrometheusHandler for a standard workload -- a regression there would leave the switch resolving correctly while silently never mounting the transport-port copy. Part of #6271 --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Virtual MCP registered /metrics on the mux that serves MCP traffic, under a comment noting it was unauthenticated. That is the vMCP half of finding E in #6271; #6296 moved the proxy half. Bind it to the diagnostics listener, so access can be governed by port: NetworkPolicy matches on pods, ports, and protocols and cannot filter on HTTP path, so a shared port makes "allow MCP, deny scraping" unexpressible. This does not authenticate the endpoint; the diagnostics listener carries no middleware. Honour the same migration switch the proxy path uses, so this is not a breaking change on its own: while metricsOnTransportPort is on, /metrics stays reachable on the MCP port too, and a deprecation warning names it. That matters more here than for the proxy. Nothing shipped enables the metrics path for MCPServer, but three vMCP artifacts do -- examples/vmcp-config.yaml and two operator docs -- so more deployments plausibly have it on. Add Server.DiagnosticsAddress so the resolved port can be discovered programmatically. The listener falls back to an available port when the configured one is taken, so tests and callers cannot construct the address; the vMCP telemetry tests now scrape through it. Part of #6271 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Serve vMCP metrics on a separate diagnostics listener Virtual MCP registered /metrics on the mux that serves MCP traffic, under a comment noting it was unauthenticated. That is the vMCP half of finding E in #6271; #6296 moved the proxy half. Bind it to the diagnostics listener, so access can be governed by port: NetworkPolicy matches on pods, ports, and protocols and cannot filter on HTTP path, so a shared port makes "allow MCP, deny scraping" unexpressible. This does not authenticate the endpoint; the diagnostics listener carries no middleware. Honour the same migration switch the proxy path uses, so this is not a breaking change on its own: while metricsOnTransportPort is on, /metrics stays reachable on the MCP port too, and a deprecation warning names it. That matters more here than for the proxy. Nothing shipped enables the metrics path for MCPServer, but three vMCP artifacts do -- examples/vmcp-config.yaml and two operator docs -- so more deployments plausibly have it on. Add Server.DiagnosticsAddress so the resolved port can be discovered programmatically. The listener falls back to an available port when the configured one is taken, so tests and callers cannot construct the address; the vMCP telemetry tests now scrape through it. Part of #6271 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Point the vMCP deprecation notice at the tracking issue The vMCP startup warning and observability guide said the MCP-port copy would be removed but not when. Reference #6384, matching the proxy-side notice on the base branch. Part of #6271 * Address review: explanatory 404, start ordering, docs clarity Three findings from Jakub's review. Use diagnostics.NotServedHereHandler once metricsOnTransportPort is false, matching the three proxies. vMCP was the one place still returning a bare 404, which is indistinguishable from a typo and leaves an operator with a still-pointed-at-the-MCP-port scraper no way to learn where metrics went. Move startDiagnostics before the MCP listener is created, matching the runner's ordering. It previously ran after go s.httpServer.Serve(listener) and before close(s.ready), so a diagnostics failure returned an error from Start with the MCP listener already accepting connections in a background goroutine and Ready() never closed -- anything blocked on it would hang forever. Document that a failure here is fatal to Start, on purpose, matching the runner's choice for the same tradeoff: metrics are opt-in, so failing loudly beats silently shipping without the observability #6271 exists to provide. The reorder introduces its own narrower version of the problem it fixes: if diagnostics starts successfully but the subsequent MCP net.Listen fails, the diagnostics listener is now orphaned with nothing to stop it. Close that by stopping diagnostics on that failure path. Add a regression test forcing startDiagnostics to fail (an unresolvable host) and asserting Start returns before the MCP listener is created and before Ready() closes, plus a body assertion on the existing opted-out-metrics test so a regression to a bare 404 is caught here, not only in pkg/diagnostics. Clarify which telemetry config path reaches prometheusPort and metricsOnTransportPort. Both live on telemetry.Config, which only the inline (deprecated) spec.config.telemetry path embeds directly; the shared MCPTelemetryConfig used by the preferred telemetryConfigRef does not carry them. The doc section describing these knobs sat under both examples without saying so. Part of #6271 --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Restore transport-port metrics behind a migration switch #6296 moved /metrics to a diagnostics port. That is a breaking change for any deployment scraping the old location, and it has not shipped yet, so there is still room to give it a notice window rather than land it cold. Serve /metrics on both ports for now. The diagnostics listener always runs; the transport-port copy is controlled by MetricsOnTransportPort and defaults to on, so no existing scrape configuration breaks. Operators can move a scraper to the new port, verify it, and set the field to false to prove nothing else depended on the old one. Closing the window is a one-line change to DefaultMetricsOnTransportPort. The field is a pointer with no kubebuilder default, which is what makes the eventual flip work. RunConfig.TelemetryConfig is serialised and CRD defaults are materialised at admission, so a plain bool would be written into every workload created during the window and would survive the cutover unchanged -- the flip would silently move nobody. Nil means unset and is resolved at startup instead, with tests covering both the resolution and the round trip. Deployments that set the field explicitly are deliberately not moved by the flip. Part of #6271 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Point the deprecation notice at a tracking issue The notice said the transport-port copy would be removed but never said when, and a deprecation without a deadline is easy to ignore. Users also had nowhere to look for the plan. Reference #6384 from the startup warning, the field documentation, and the migration steps. A tracking issue rather than a version or date: the release that closes the window is not known yet and may move, and the issue can be revised where these strings cannot -- the field description ships to users through the CRD schema and kubectl explain. Also point DefaultMetricsOnTransportPort at it, since flipping that constant is not the whole job and the issue carries the cleanup list. Part of #6271 * Expose the metrics migration switch to CLI and operator #6370 added metricsOnTransportPort but left it reachable only by editing stored configuration, so nobody could actually use the migration window it exists for. Add --otel-metrics-on-transport-port and prometheus.metricsOnTransportPort on MCPTelemetryConfig, so an operator can move a scraper to the diagnostics port and then turn the old location off to prove nothing else depended on it. Both preserve the tri-state. The CLI flag is bound as a plain bool and read through resolveMetricsOnTransportPort, which uses Flags().Changed so an absent flag stays unset rather than resolving to false; the CRD field is an optional pointer with no default marker so nothing is materialised at admission. Either would otherwise pin the window's value into every workload created during it, and the cutover would move nobody. The runtime field is now mapped on both sides, so it moves from the drift table's runtime-only ignores into the mappings. Carried by a dedicated builder option rather than a further parameter on the two telemetry constructors: the field is transitional, and removing it later should touch one function instead of two long signatures. Part of #6271 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Point the migration switch surfaces at the tracking issue The CLI flag help and the MCPTelemetryConfig field description said the default would change but not when, and gave nowhere to look for the plan. Both are user-facing -- the flag through thv run --help and the field through kubectl explain -- so they need the reference as much as the startup warning does. Reference #6384 from both. See its sibling commit on the base branch for why a tracking issue rather than a version or date. Part of #6271 * Drop the now-stale runtime-only drift entry for metricsOnTransportPort Merging main duplicated the field across both drift tables: main still lists it in telemetryIgnoredOnRuntimeOnly with a justification saying it is deliberately absent from the CRD, while this branch already promoted it into telemetryFieldMappings by adding the CRD field. Remove the stale ignore entry; the mapping is the current truth. * Address review: real aliasing bug, doc trims, one duplicate helper Fix the pointer aliasing in NormalizeMCPTelemetryConfig. It assigned spec.Prometheus.MetricsOnTransportPort straight into config, and NormalizeTelemetryConfig's "Create a copy to avoid modifying the input" is only a shallow copy of the struct -- so a future write through the returned config's pointer would reach back into the CRD spec object. Nil still means unset, but a non-nil value is now cloned. Added a table case per state (unset/true/false) plus a dedicated regression test asserting the result does not alias the input and that mutating it leaves the input untouched. Point the CLI flag's default at telemetry.DefaultMetricsOnTransportPort instead of a hardcoded true, so `thv run --help` stops lying the moment the cutover flips the constant. Trim four doc comments back to what the reader actually needs: - Two exported field docs (telemetry.Config and the CRD's PrometheusConfig) drop the "why a pointer with no kubebuilder default" paragraph. Those comments become the OpenAPI description under kubectl explain and in swagger.json; a cluster admin configuring a scrape target doesn't need kubebuilder marker semantics, and the reasoning already lives at its canonical home on TestMetricsOnTransportPortNotPersistedWhenUnset. - WithMetricsOnTransportPort's doc drops the "why a separate option instead of a constructor parameter" paragraph, keeping only the actual calling contract (apply after telemetry config). - Two comments citing a review discussion by name (mountPrometheusHandler- OnTransportPort's extraction rationale, and a test doc naming jhrozek's #6370 comment) are reworded to describe the code on its own terms. Drop the duplicate ptr[T] helper in metrics_transport_port_test.go; the package already has boolPtr in run_flags_test.go. Two findings don't map to a line in this diff and are tracked separately rather than expanding this PR's scope: vMCP's own switch already exists on the unmerged #6368, not duplicated here (#6433 tracks landing order); and the operator doesn't yet expose a named port or a way to verify the transport-port copy is unused before disabling it (#6434). Part of #6271 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- 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