Skip to content

Serve Prometheus metrics on a separate diagnostics listener - #6296

Merged
amirejaz merged 6 commits into
mainfrom
metrics-separate-listener
Aug 19, 2026
Merged

Serve Prometheus metrics on a separate diagnostics listener#6296
amirejaz merged 6 commits into
mainfrom
metrics-separate-listener

Conversation

@amirejaz

@amirejaz amirejaz commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Summary

An external good-faith security report against a toolhive-doc-mcp deployment
flagged the proxy diagnostics endpoints. Finding E of #6271: /metrics is registered
as an explicit ServeMux path, and since Go resolves the most specific pattern first
it 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 — so
enabling 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 operator
binds it to 0.0.0.0, so the endpoint is just as unauthenticated on its own port and
remains reachable from other pods.

What it does buy is control by port. Kubernetes NetworkPolicy matches on pods,
ports, and protocols and cannot filter on HTTP path, so while /metrics shares the
transport 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 the
ToolHive operator itself all put metrics on a separate port.

  • Bind /metrics to a dedicated diagnostics listener (pkg/diagnostics). The runner
    no longer passes the Prometheus handler to the transport — that omission is what
    stops the proxies mounting /metrics on the application mux at all.
  • Return 404 for /metrics on the application listener, mirroring how /health
    already guards itself, so the path is not silently proxied to the backend instead.
  • Default to port 9464, the OpenTelemetry spec's Prometheus exporter default
    (OTEL_EXPORTER_PROMETHEUS_PORT), falling back to an available port when taken.
  • Leave /health on the application listener: Kubernetes probes target it, and Drop build fingerprint from proxy /health response #6280
    already removed its build fingerprint.

/metrics is off by default at every layer, so this only affects deployments that
explicitly enabled it.

Part of #6271

Type of change

  • Bug fix
  • New feature
  • Refactoring (no behavior change)
  • Dependency update
  • Documentation
  • Other (describe):

Test plan

  • Unit tests (task test)
  • E2E tests (task test-e2e)
  • Linting (task lint-fix)
  • Manual testing (describe below)

New coverage:

  • pkg/diagnostics/server_test.go — construction validation, metrics served on its
    own listener, only /metrics served (no catch-all), start-twice, idempotent stop.
  • pkg/runner/diagnostics_test.go — the core property: with metrics enabled the
    listener 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, /metrics returns 404 on the application listener and
    does 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-fix reports one gosec G115 finding in cmd/thv/app/upgrade.go:204,
    which this PR does not touch.
  • pkg/plugins/pluginsvc currently fails TestValidateOCIRegistryHost and
    TestParseGitReference_RefAndSubdir locally. Both reproduce identically on a clean
    origin/main (3c4dec3) and appear host/DNS-dependent, so they look like flakes
    worth a separate look — flagging in case CI shows them here.

Three E2E tests that scraped /metrics on the proxy port were updated to target the
diagnostics port. I have not run task test-e2e locally (needs a container runtime);
please let CI exercise it.

API Compatibility

  • This PR does not break the v1beta1 API, OR the api-break-allowed label is applied and the migration guidance is described above.

The new PrometheusPort field is additive and lives on pkg/telemetry.Config, which
is not embedded in the operator CRDs.

Changes

File Change
pkg/diagnostics/server.go New: diagnostics listener serving /metrics, with the rationale for why it must not share the app listener and a warning against adding debug handlers to it
pkg/runner/diagnostics.go New: runner start/stop plus port resolution
pkg/runner/runner.go Start the diagnostics listener instead of setting transportConfig.PrometheusHandler; release it on every exit from Run; stop it in Cleanup
pkg/telemetry/config.go Add PrometheusPort; correct the now-wrong "served on the main transport port" doc
pkg/telemetry/middleware.go Drop the startup log that reported the application port for /metrics
pkg/transport/proxy/{transparent,streamable,httpsse} 404 /metrics when no handler is supplied
docs/observability.md Document the diagnostics port, port selection, and a cardinality warning

Does this introduce a user-facing change?

Yes, two changes.

1. /metrics moves to a dedicated port. For deployments with
enablePrometheusMetricsPath enabled, /metrics moves off the transport port onto a
dedicated diagnostics port (default 9464). Scrape configs pointed at the transport
port 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. /metrics is no longer proxied to the backend. Previously, when metrics were
disabled, /metrics fell through to the catch-all and was forwarded to the backend
MCP server. It now returns 404 on the application listener. A remote MCP server that
exposes its own /metrics behind thv proxy would no longer be reachable at that
path. This mirrors how /health has always been shadowed, and it is what stops the
path 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 exit
paths. Cleanup is reached only via stopMCPServer, which the early error returns
skip, as does the graceful container-exit branch that returns nil. Under
workloads.Manager's exponential-backoff restart loop, every failed attempt would
have stranded a goroutine and a bound port — and because 9464 stayed held, the next
attempt 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 has
no 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:

  1. The port default. I chose a fixed 9464 over an auto-assigned port so
    Kubernetes 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 /metrics instead 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 be
    a Kubernetes-only auth path and more code.

  2. Hard-failing Run if the diagnostics port cannot bind. FindOrUsePort falls
    back 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.

  3. 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:600 registers its own
unauthenticated /metrics), an operator CRD field and CLI flag for the port, a
separate 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.md that #6271 notes is
missing. Say the word if you'd rather that went in its own docs PR.

Generated with Claude Code

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>
@github-actions github-actions Bot added the size/L Large PR: 600-999 lines changed label Aug 13, 2026
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>
@github-actions github-actions Bot added size/L Large PR: 600-999 lines changed and removed size/L Large PR: 600-999 lines changed labels Aug 13, 2026
@amirejaz
amirejaz requested a lite review from Copilot August 13, 2026 00:52

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 /metrics on 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 /metrics when 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.

Comment thread pkg/diagnostics/server.go Outdated
Comment thread pkg/diagnostics/server.go Outdated
Comment thread test/e2e/telemetry_metrics_validation_e2e_test.go
Comment thread test/e2e/osv_authz_test.go
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>
@amirejaz
amirejaz requested a review from tgrunnagle as a code owner August 13, 2026 01:09
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/L Large PR: 600-999 lines changed labels Aug 13, 2026
@codecov

codecov Bot commented Aug 13, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 74.01575% with 33 lines in your changes missing coverage. Please review.
✅ Project coverage is 72.92%. Comparing base (3c4dec3) to head (edfa2e0).
⚠️ Report is 13 commits behind head on main.

Files with missing lines Patch % Lines
pkg/diagnostics/server.go 78.31% 13 Missing and 5 partials ⚠️
pkg/runner/runner.go 27.27% 5 Missing and 3 partials ⚠️
pkg/runner/diagnostics.go 76.00% 3 Missing and 3 partials ⚠️
pkg/transport/proxy/httpsse/http_proxy.go 50.00% 0 Missing and 1 partial ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

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>
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Aug 13, 2026
@JAORMX

JAORMX commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

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.

@amirejaz

Copy link
Copy Markdown
Contributor Author

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 /metrics, and we don't. The diagnostics listener has no middleware, and the operator binds it to 0.0.0.0, so /metrics stays unauthenticated and in-cluster reachable, just on 9464. The only real delta is that it's off the port deployments route publicly.

On the substantive question though — I don't think routing alone covers it. NetworkPolicy is L3/L4 and can't filter on path, so while /metrics shares the application port there's no way to allow MCP traffic and deny scraping. A separate port gives you that primitive; Gateway API only governs what reaches the gateway, not pod-to-pod traffic. Same reason etcd and controller-runtime put metrics on their own ports. So routing plus docs closes the north-south half and leaves east-west unaddressed.

Also worth noting we create a plain corev1.Service for MCPServer today — no Ingress or HTTPRoute — so route-scoping is either new operator work or docs-and-hope.

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>
@github-actions github-actions Bot removed the size/XL Extra large PR: 1000+ lines changed label Aug 14, 2026
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Aug 14, 2026
@JAORMX

JAORMX commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

@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 / which is what a simple non-production grade deployment would do. That's what I meant.

@amirejaz

Copy link
Copy Markdown
Contributor Author

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: /health and the .well-known endpoints stay on the transport port by design, and anything added to that mux later lands there too. So it's really "expose only the paths you need, not /" as general deployment guidance rather than something metrics-specific — happy to add a short section here, or do it separately if you'd rather not mix it into this change.

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?

@JAORMX

JAORMX commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

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>
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Aug 14, 2026
@amirejaz

Copy link
Copy Markdown
Contributor Author

Added in edfa2e003 — new "Scope external routes to the paths you need" section in docs/observability.md, right after the NetworkPolicy example, with a note that the two are separate controls (a policy governs which pods may connect, a route governs which paths are published).

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: /mcp for streamable-http, /sse + /messages for sse, /.well-known/oauth-protected-resource for RFC 9728 discovery, the embedded auth server routes only when that's enabled, and /health marked as internal since probes reach it in-cluster. Plus an HTTPRoute example publishing only the MCP endpoint and discovery, and a note that Ingress needs pathType: Exact/Prefix rather than a single / prefix.

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.

@amirejaz
amirejaz merged commit 7b72d4b into main Aug 19, 2026
49 checks passed
@amirejaz
amirejaz deleted the metrics-separate-listener branch August 19, 2026 01:33
amirejaz added a commit that referenced this pull request Aug 19, 2026
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>
amirejaz added a commit that referenced this pull request Aug 19, 2026
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>
jhrozek pushed a commit that referenced this pull request Aug 19, 2026
* 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>
amirejaz added a commit that referenced this pull request Aug 20, 2026
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
amirejaz added a commit that referenced this pull request Aug 20, 2026
* 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>
amirejaz added a commit that referenced this pull request Aug 20, 2026
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>
amirejaz added a commit that referenced this pull request Aug 25, 2026
* 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>
amirejaz added a commit that referenced this pull request Aug 26, 2026
* 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>
@github-actions github-actions Bot mentioned this pull request Aug 26, 2026
2 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/XL Extra large PR: 1000+ lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants