Forward MCPServerEntry headerForward to vMCP outbound requests#5239
Conversation
There was a problem hiding this comment.
Large PR Detected
This PR exceeds 1000 lines of changes and requires justification before it can be reviewed.
How to unblock this PR:
Add a section to your PR description with the following format:
## Large PR Justification
[Explain why this PR must be large, such as:]
- Generated code that cannot be split
- Large refactoring that must be atomic
- Multiple related changes that would break if separated
- Migration or data transformationAlternative:
Consider splitting this PR into smaller, focused changes (< 1000 lines each) for easier review and reduced risk.
See our Contributing Guidelines for more details.
This review will be automatically dismissed once you add the justification section.
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #5239 +/- ##
==========================================
- Coverage 67.97% 67.95% -0.02%
==========================================
Files 612 615 +3
Lines 62723 63001 +278
==========================================
+ Hits 42633 42810 +177
- Misses 16908 16986 +78
- Partials 3182 3205 +23 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
The wirefmt package centralizes the env-var encoding shared between the operator (which emits TOOLHIVE_HEADER_FORWARD_<entry> manifests) and the vMCP runtime (which parses them). HeaderForwardConfig and the Backend / BackendTarget fields carry per-backend header forwarding state through the vMCP domain types.
Replace the local SecretEnvVarName helpers with the shared wirefmt encoder so the operator and vMCP runtime stay in lockstep on env-var naming.
Surfaced while wiring headerForward through the MCPRemoteProxy and MCPServerEntry controllers. Tightens the helper contracts so callers in the new code paths share the same lookup signature.
0fff334 to
fd1ea95
Compare
The headerForward field already exists on the MCPServerEntry CRD; this commit adds the reconciler validation that walks spec.headerForward.addHeadersFromSecret, confirms each referenced Secret exists in the namespace, and surfaces the result as a HeaderSecretRefsValidated status condition. Mirrors the validation MCPRemoteProxy already performs for its header Secret refs.
The VirtualMCPServer reconciler now renders the entry-side headerForward manifest into the vMCP pod env via the wirefmt encoding. Plaintext values land directly; Secret-backed values become valueFrom.secretKeyRef so the runtime never sees raw secret material in CRD or pod spec.
The HTTP client decorator injects per-backend headers (plaintext and Secret-resolved) on every outbound request: list, call, and health checks. Secret identifiers are resolved through the standard EnvironmentProvider, so the client never holds raw secret values.
The static-mode discoverer now keys headerForward by normalized backend name and stamps each Backend with its config at discovery time. The Kubernetes workload discoverer surfaces the same field on managed entries, and the health monitor forwards it through to client calls. vMCP startup ingests the operator-emitted TOOLHIVE_HEADER_FORWARD_* env vars and routes the resulting per-backend map through serve into the discoverer.
fd1ea95 to
c328915
Compare
Large PR justification has been provided. Thank you!
|
✅ Large PR justification has been provided. The size review has been dismissed and this PR can now proceed with normal review. |
Summary
Closes #4996.
MCPServerEntry.spec.headerForward.{addPlaintextHeaders,addHeadersFromSecret}was accepted by the CRD but never sent on outbound requests when the entry was consumed as a static backend of aVirtualMCPServer. OnlyMCPRemoteProxyforwarded the field — vMCP requests toremoteUrlarrived without the configured headers, breaking use cases like GitHub Copilot'sX-MCP-Toolsetsmulti-toolset selection.This PR fixes the bug without touching
pkg/vmcp/configby mirroringMCPRemoteProxy's existing pattern: the operator emits per-(entry, header) env vars on the vMCP pod (literal values for plaintext,valueFrom.secretKeyReffor secrets), and the vMCP runtime walks the well-known prefixes at startup to reconstructBackend.HeaderForwardin static mode.TOOLHIVE_OTEL_HEADER_*already establishes plaintext-header-via-env in this codebase, so the convention isn't new.Zero CRD/docs diff. Zero non-test changes under
pkg/vmcp/config/.Medium level
buildHeaderForwardEnvVarsForEntriesnow emits literal-value env vars foraddPlaintextHeadersalongside the existingvalueFrom.secretKeyRefenv vars foraddHeadersFromSecret. Header normalization is extracted into a privatenormalizeHeaderForEnvVarhelper that bothGenerateHeaderForwardSecretEnvVarNameand the newGenerateHeaderForwardPlaintextEnvVarNameshare, so the secret and plaintext branches can never diverge on a header that round-trips through one and not the other.readHeaderForwardFromEnv(pkg/vmcp/cli/header_forward_env.go) walksos.Environ()for theTOOLHIVE_HEADER_PLAINTEXT_*andTOOLHIVE_SECRET_HEADER_FORWARD_*prefixes at startup, parses each(entry, header)pair via the inverse of the operator's normalization, and buildsmap[backendName]*vmcp.HeaderForwardConfig. Stray env vars whose decoded entry segment doesn't match a known static backend are dropped.NewUnifiedBackendDiscovererWithStaticBackendsgains aheaderForwardByBackendparameter.discoverFromStaticConfigattaches the matching map entry toBackend.HeaderForwardby backend name.pkg/vmcp/client/header_forward.go): inserted betweenidentityPropagatingRoundTripper(outer) andauthRoundTripper(inner). Resolves plaintext + secret headers once at client-factory time viasecrets.EnvironmentProvider. Rejects restricted headers via the sharedpkg/transport/middleware.RestrictedHeadersset. Auth always wins over user-supplied headers because it runs after this tripper.BackendTargetconstruction inpkg/vmcp/health/monitor.go::performHealthChecknow carriesHeaderForward,CABundlePath, andCABundleDataso health probes hit backends with the same TLS trust and header injection as list/call traffic.HeaderSecretRefsValidatedcondition (reusingMCPRemoteProxy'sHeaderSecretNotFoundreason) flips the entry toFailedwhen a referenced Secret is missing.GenerateHeaderForwardSecretEnvVarNamenow takesownerNamerather thanproxyNameso bothMCPRemoteProxyandMCPServerEntryshare one helper.pkg/vmcp/workloads/k8s.go::mcpServerEntryToBackendis unchanged — it readsheaderForwarddirectly from the MCPServerEntry CRD at backend-construction time, so no env-var path is needed there.Low level
pkg/vmcp/types.goHeaderForwardConfig(no kubebuilder markers — runtime-only); addHeaderForwardfield onBackendandBackendTarget.pkg/vmcp/registry.goBackendToTargetcopiesHeaderForward.pkg/vmcp/health/monitor.goHeaderForward,CABundlePath,CABundleDatainto the health-checkBackendTarget.pkg/vmcp/client/header_forward.goheaderForwardRoundTripper,buildHeaderForwardTripper,resolveHeaderForward.pkg/vmcp/client/header_forward_test.gohttptest.Servertest.pkg/vmcp/client/client.gosecretsProviderfield.pkg/vmcp/cli/header_forward_env.goreadHeaderForwardFromEnvplus header-name suffix splitter.pkg/vmcp/cli/header_forward_env_test.gopkg/vmcp/cli/serve.gopkg/vmcp/aggregator/discoverer.goheaderForwardByBackendfield onbackendDiscoverer; constructor parameter; lookup indiscoverFromStaticConfig.pkg/vmcp/aggregator/discoverer_test.gonilfor new parameter at four existing call sites.cmd/thv-operator/api/v1beta1/mcpserverentry_types.goHeaderSecretRefsValidatedcondition + reasons.cmd/thv-operator/controllers/mcpserverentry_controller.govalidateHeaderForwardSecretRefswired into reconcile.cmd/thv-operator/controllers/virtualmcpserver_deployment.gobuildHeaderForwardEnvVarsForEntriesto emit plaintext env vars in sorted order alongside secret refs.cmd/thv-operator/pkg/controllerutil/externalauth.gonormalizeHeaderForEnvVar; addGenerateHeaderForwardPlaintextEnvVarName; renameproxyName→ownerNameso MCPRemoteProxy and MCPServerEntry share one helper.Type of change
Test plan
task buildpassestask testpasses for all touched packagesTestBuildHeaderForwardEnvVarsForEntries— operator-side plaintext + secret + mixed + sort-determinismTestReadHeaderForwardFromEnv— runtime-side env walk, stray-var drop, multi-underscore header splittingTestHeaderForwardRoundTripper_*,TestResolveHeaderForward_*,TestBuildHeaderForwardTripper_*, end-to-endhttptest.Servertest (cherry-picked from PR Forward MCPServerEntry headerForward to vMCP outbound requests #5013)HeaderSecretsValid/HeaderSecretNotFoundcases (cherry-picked from PR Forward MCPServerEntry headerForward to vMCP outbound requests #5013)task operator-manifests,task operator-generate,task crdref-genproduce zero diff underdeploy/charts/operator-crds/anddocs/operator/crd-api.mdpkg/vmcp/config/Special notes for reviewers
runtime.Configso future operator-resolved sidecar fields could ship via the ConfigMap without leaking into the CRD. After investigation, env vars are a better fit forheaderForwardspecifically —MCPRemoteProxyalready uses env vars for the single-backend version of the same problem, andTOOLHIVE_OTEL_HEADER_*already establishes plaintext-header-via-env in this codebase. The wrapper stays empty for a future field that genuinely benefits from YAML co-location with user-authored vMCP config.kubectl describe podoutput instead ofkubectl get configmap. Both are RBAC-gated under similar verbs. Truly sensitive values still ridevalueFrom.secretKeyRefand never enter the operator's view of the world. Plaintext header values are by definition non-secret — that's why the user chose plaintext.http.Header.Set(which canonicalizes regardless).DESIGN.mdduring development, removed before final commit since.claude/rules/pr-creation.mdkeeps planning artifacts out of the PR diff).Manual test walkthroughs
The two collapsible blocks below contain self-contained step-by-step guides
for verifying
MCPServerEntry.headerForwardend-to-end against a kindcluster — one per backend-discovery mode. Each guide stands alone:
prerequisites, setup, traffic generation, header-capture verification, and
cleanup. Identical work was used to validate this PR locally.
Static mode —
outgoingAuth.source: inlineThis walkthrough verifies that operator-emitted
headerForwardenv vars areparsed by vMCP at startup and that both plaintext and Secret-backed
headers are injected on every outbound MCP request.
Static mode is selected by
VirtualMCPServer.spec.outgoingAuth.source: inline.The operator pre-renders backends into the vMCP ConfigMap and emits
TOOLHIVE_HEADER_FORWARD_*env vars; vMCP reads them at startup.0. Prerequisites
cburns/headerforward-envvar).task kind-with-toolhive-operator-local(first time)task operator-deploy-localif the cluster is already up.kubectlpointing at the cluster (KUBECONFIG=./kconfig.yaml).(
.claude/worktrees/headerforward-envvarif you usedAgent's isolatedworktree, otherwise wherever you checked the branch out).
All commands below assume:
1. Create the test namespace and Secret
The Secret value
secret-from-k8s-secretis what we expect to see arrive atthe backend in the
X-Api-Keyheader.2. Deploy the header-capturing echo backend
This is a tiny Python HTTP server that:
initializeresponse so vMCP doesn't errorGET /headersreturning all captured headers as JSON3. Make the backend reachable from a non-blocked hostname
The operator validates
MCPServerEntry.spec.remoteUrlagainst aSSRF blocklist that rejects
cluster.local, RFC1918 ranges, loopback, etc.For testing we bind a fake public hostname (
echo-public.test) to echo-mcp'sClusterIP via CoreDNS.
Verify:
kubectl -n hf-test run dnscheck --rm -it --restart=Never \ --image=busybox -- nslookup echo-public.test # Should resolve to the ClusterIP from above.4. Apply the static-mode VirtualMCPServer
Wait until everything is
Ready:5. Verify the operator emitted the right env vars
This is the operator-side acceptance check: commit "Emit headerForward env vars
from VirtualMCPServer deployment" should produce:
TOOLHIVE_HEADER_FORWARD_<entry>env var with the JSON manifestTOOLHIVE_SECRET_HEADER_FORWARD_<header>_<entry>env var sourced from the SecretYou should see something like:
6. Verify vMCP started in static mode
Expected:
7. Reset the backend's capture, then trigger MCP traffic
8. Verify the captured headers
Pass criteria:
X-Trace-Id: kind-test-static(plaintext)X-MCP-Toolsets: projects,issues,pull_requests(plaintext)X-Api-Key: secret-from-k8s-secret(resolved fromtest-secret/token)9. Cleanup
Dynamic mode —
outgoingAuth.source: discoveredThis walkthrough verifies that vMCP's K8s workload discoverer reads
MCPServerEntry.spec.headerForwarddirectly from the API server at runtimeand that both plaintext and Secret-backed headers are injected on
every outbound MCP request.
Dynamic mode is selected by
VirtualMCPServer.spec.outgoingAuth.source: discovered.vMCP runs a controller-runtime watcher that reconciles backends into a
DynamicRegistry;pkg/vmcp/workloads/k8s.go::headerForwardFromEntryprojectsspec.headerForwardonto eachvmcp.Backend.0. Prerequisites
cburns/headerforward-envvar).task kind-with-toolhive-operator-local(first time)task operator-deploy-localif the cluster is already up.kubectlpointing at the cluster (KUBECONFIG=./kconfig.yaml)..claude/worktrees/headerforward-envvar).All commands below assume:
1. Create the test namespace and Secret
The Secret value
secret-from-k8s-secretis what we expect to see arrive atthe backend in the
X-Api-Keyheader.2. Deploy the header-capturing echo backend
This is a tiny Python HTTP server that:
initializeresponse so vMCP doesn't errorGET /headersreturning all captured headers as JSON3. Make the backend reachable from a non-blocked hostname
The operator validates
MCPServerEntry.spec.remoteUrlagainst a SSRFblocklist that rejects
cluster.local, RFC1918 ranges, loopback, etc. Fortesting we bind a fake public hostname (
echo-public.test) to echo-mcp'sClusterIP via CoreDNS.
Verify:
4. Apply the dynamic-mode VirtualMCPServer
The key differences from static mode:
outgoingAuth.source: discoveredinstead ofinlinesource: discovered)Wait until everything is
Ready:5. Verify vMCP started in dynamic mode
Expected (excerpt):
The "Successfully reconciled backend" line proves the K8s controller-runtime
watcher reconciled the
MCPServerEntryinto theDynamicRegistry. TheheaderForwardfield on the entry is projected onto the runtimevmcp.Backendat this step.6. Reset the backend's capture, then trigger MCP traffic
7. Verify the captured headers
Pass criteria:
X-Trace-Id: kind-test-dynamic(plaintext) — note the-dynamicsuffixproves it came from this entry, not the static one if you happen to be
running both side-by-side.
X-MCP-Toolsets: projects,issues,pull_requests(plaintext)X-Api-Key: secret-from-k8s-secret(resolved fromtest-secret/token)8. Bonus: verify runtime updates flow through
Dynamic mode's payoff is that headerForward changes are picked up without
restarting vMCP. To prove it:
You should see
updated-at-runtimein the values — proving the K8s watcherre-reconciled the entry, the
DynamicRegistrywas updated, and the nextoutbound request used the new value. (No pod restart was performed.)
9. Cleanup
Large PR Justification
Generated with Claude Code