Serve vMCP metrics on a separate diagnostics listener - #6368
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #6368 +/- ##
==========================================
- Coverage 77.82% 77.78% -0.05%
==========================================
Files 760 761 +1
Lines 72997 73044 +47
==========================================
+ Hits 56808 56815 +7
- Misses 16184 16224 +40
Partials 5 5 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
6d45011 to
c8ff688
Compare
c8ff688 to
07b71c6
Compare
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>
07b71c6 to
acafafb
Compare
jhrozek
left a comment
There was a problem hiding this comment.
Read through this one carefully — the port split and the reasoning in pkg/diagnostics both look right to me. Three things I'd want changed before it goes in, all small. A few smaller nits (the local 127.0.0.1 constant, the unguarded diagnosticsServer field vs the exported DiagnosticsAddress, and freePort's find-then-release race in the new test) I'm happy to leave as follow-ups.
| if h := s.transportPortMetricsHandler(); h != nil { | ||
| mux.Handle(diagnostics.MetricsPath, h) | ||
| } else { | ||
| mux.HandleFunc(diagnostics.MetricsPath, http.NotFound) |
There was a problem hiding this comment.
The three proxies all use diagnostics.NotServedHereHandler() here rather than a bare 404 (streamable_proxy.go:282, transparent_proxy.go:1334, http_proxy.go:300), and its doc comment spells out why:
A bare 404 is indistinguishable from a typo, and the failure is otherwise silent from the server side: an upgrade moves the endpoint and the operator sees only a Prometheus target going down.
vMCP would be the one place that doesn't get that, so once metricsOnTransportPort goes false an operator whose scraper is still pointed at the MCP port gets an empty 404 and has to know to grep the startup log. The handler puts that instruction in the body.
| mux.HandleFunc(diagnostics.MetricsPath, http.NotFound) | |
| mux.Handle(diagnostics.MetricsPath, diagnostics.NotServedHereHandler()) |
| } | ||
| }() | ||
|
|
||
| if err := s.startDiagnostics(); err != nil { |
There was a problem hiding this comment.
This runs after go s.httpServer.Serve(listener) and before close(s.ready), so if the diagnostics listener fails to bind we return from Start with the MCP listener still serving in a background goroutine and ready never closed — anything blocked on Ready() waits forever.
The runner avoids this by starting diagnostics before transport setup (runner.go:378), so there's nothing to unwind on that path. Could we move this call up above the net.Listen for parity? diagnostics.New's validation would then fail before anything is bound, and no cleanup code is needed.
Separate note: pkg/diagnostics argues that losing a port race shouldn't take down the whole workload (bind retries for exactly that reason). Turning an exhausted retry into a fatal vMCP startup error is a defensible call, but it's the opposite of what that comment assumes, so it'd be worth a line saying it's deliberate.
|
|
||
| During the migration window `/metrics` is *also* still served on the port carrying MCP | ||
| traffic, so an existing scrape configuration keeps working. Once you have moved a | ||
| scraper to the diagnostics port, set `metricsOnTransportPort: false` to confirm nothing |
There was a problem hiding this comment.
metricsOnTransportPort and prometheusPort are only reachable through the inline spec.config.telemetry path (which embeds telemetry.Config directly). Both are deliberately absent from MCPTelemetryConfig — the path this same doc labels "preferred" a few paragraphs up — with the reasoning written out in spectoconfig/telemetry_drift_test.go:74-82.
Since this section sits under both examples, a reader using telemetryConfigRef will try to set a field that doesn't exist. Worth one sentence saying which knob lives where, and that telemetryConfigRef users get 9464 plus the transport-port copy until the window closes.
Summary
Virtual MCP registers
/metricson the mux that serves MCP traffic — under a commentthat acknowledged it was unauthenticated. That's the vMCP half of Finding E in #6271;
#6296 moved the proxy half. Until both move, the endpoint still shares the port
deployments route publicly.
/metricson the dedicated diagnostics listener (pkg/diagnostics), so accesscan be governed by port.
NetworkPolicymatches on pods, ports, and protocols andcannot filter on HTTP path, so while
/metricsshares the MCP port there is no wayto express "allow MCP traffic, deny metrics scraping".
change on its own. While
metricsOnTransportPortis on (the default),/metricsstays reachable on the MCP port too and a deprecation warning names it. Once it is
off, the application mux 404s rather than letting
/metricsfall through to the/MCP handler.
Server.DiagnosticsAddress()so the resolved port is discoverable.What this does not do: it does not authenticate, rate limit, or audit
/metrics.The diagnostics listener carries no middleware, and the host is inherited from the MCP
listener (
0.0.0.0under the operator), so the endpoint stays reachable from otherpods. Restricting who can reach that port is what protects it — see the
NetworkPolicyexample in
docs/observability.md.Part of #6271
Type of change
Test plan
task test)task test-e2e)task lint-fix)New coverage in
pkg/vmcp/server/diagnostics_test.go: no listener without telemetry,metrics bound to a port distinct from the MCP port, configured port honoured, host
defaulting, idempotent stop.
TestServeHandlerRegistersMetricsWhenTelemetryEnabledisrenamed to
TestServeHandlerDoesNotServeMetricsand now asserts the 404, since its oldassertion described the behaviour this PR removes.
Three telemetry tests scraped
/metricsoff the MCP address and now go throughDiagnosticsAddress()— necessary rather than cosmetic, since the listener falls backto an available port when the configured one is taken, so the address cannot be
constructed.
Remaining local failures (
TestValidateOCIRegistryHost,TestParseGitReference_*) andlint findings (
cmd/thv/app/upgrade.go,pkg/vmcp/config/crd_cli_roundtrip_test.go)are pre-existing on
mainin files this PR does not touch.Does this introduce a user-facing change?
Not on merge.
/metricsbecomes available on the diagnostics port(
prometheusPort, default9464) while remaining on the MCP port, so existingscrape configurations keep working until the window closes.
When it does close, this is the half that matters more.
The blast radius is larger here than for the proxy half. Nothing shipped enables the
metrics path for MCPServer, but three vMCP artifacts do —
examples/vmcp-config.yaml,docs/operator/virtualmcpserver-api.md, anddocs/operator/virtualmcpserver-observability.md— so more deployments plausibly haveit on. The example and the observability guide are updated to point at the diagnostics
port.
This should ship in the same release as #6296 and #6370, so operators get one
migration event rather than the same feature moving twice. None of it has shipped —
v0.44.0was cut before #6296 merged.Special notes for reviewers
DiagnosticsAddress()is worth a look beyond vMCP: the resolved diagnostics portcurrently only appears in a startup log, which is why the E2E metrics helpers in #6296
hardcode
9464(Copilot flagged this, and those threads are still open). This accessoris the vMCP-side answer; the equivalent for the proxy path — surfacing it in workload
status — is still outstanding.
TestServeHandlerMetricsOnTransportPortpins both ends of the migration — default andexplicitly-on serve on the MCP port, opted-out 404s — so neither the window nor the
cutover can regress unnoticed.
Generated with Claude Code