Skip to content

Forward MCPServerEntry headerForward to vMCP outbound requests#5013

Draft
ChrisJBurns wants to merge 1 commit intomainfrom
cburns/fix-mcpserverentry-headerforward
Draft

Forward MCPServerEntry headerForward to vMCP outbound requests#5013
ChrisJBurns wants to merge 1 commit intomainfrom
cburns/fix-mcpserverentry-headerforward

Conversation

@ChrisJBurns
Copy link
Copy Markdown
Collaborator

Summary

Closes #4996

MCPServerEntry.spec.headerForward was accepted by the CRD but silently ignored when the entry was consumed as a static backend of VirtualMCPServer. Only MCPRemoteProxy honored the field, so vMCP requests to remoteUrl arrived without the configured headers — breaking use cases like GitHub Copilot's X-MCP-Toolsets multi-toolset selection.

Medium level
  • Runtime model: pkg/vmcp.Backend and BackendTarget gain a HeaderForward field; vmcpconfig.StaticBackendConfig gains the same so headers ride alongside auth/CA-bundle through the whole pipeline.
  • Operator ingestion: mcpServerEntryToBackend extracts headerForward. A new buildHeaderForwardMap mirrors buildCABundlePathMap and threads per-backend header config into convertBackendsToStaticBackends. Plaintext headers are copied verbatim; secret refs become only env-var identifiers — secret values never enter the ConfigMap.
  • Operator deployment: buildHeaderForwardEnvVarsForEntries emits one valueFrom.secretKeyRef env var per (entry, header), scoped by entry name to prevent collisions across entries in the same group.
  • vMCP runtime: a new headerForwardRoundTripper in pkg/vmcp/client sits between identityPropagatingRoundTripper (outer) and authRoundTripper (inner). Headers are resolved once at client creation via secrets.EnvironmentProvider (TOOLHIVE_SECRET_<ident>). Restricted headers (Host, hop-by-hop, X-Forwarded-*, etc.) are rejected via the existing middleware.RestrictedHeaders set — same list MCPRemoteProxy uses.
  • Health parity: the health monitor's BackendTarget construction now carries HeaderForward, CABundlePath, and CABundleData so health checks hit backends with the same TLS trust and header injection as list/call traffic.
  • Reconciler validation: MCPServerEntry gains a HeaderSecretRefsValidated condition (reusing MCPRemoteProxy's HeaderSecretNotFound reason) that flips the entry to Failed when a referenced Secret is missing.
  • Helper generalization: GenerateHeaderForwardSecretEnvVarName(proxyName, …) renamed to (ownerName, …) so both MCPRemoteProxy and MCPServerEntry share one source of truth.
Low level
File Change
pkg/vmcp/types.go Add HeaderForwardConfig type (+gendoc, +kubebuilder:object:generate); add HeaderForward field to Backend and BackendTarget.
pkg/vmcp/doc.go Add +groupName marker so HeaderForwardConfig renders in docs/operator/crd-api.md.
pkg/vmcp/registry.go BackendToTarget now copies HeaderForward.
pkg/vmcp/config/config.go StaticBackendConfig.HeaderForward field (*vmcp.HeaderForwardConfig).
pkg/vmcp/aggregator/discoverer.go discoverFromStaticConfig propagates HeaderForward.
pkg/vmcp/workloads/k8s.go mcpServerEntryToBackend populates backend.HeaderForward; new buildHeaderForwardFromEntry helper turns secret refs into identifiers via the shared helper.
pkg/vmcp/health/monitor.go Carry HeaderForward, CABundlePath, CABundleData into the health-check BackendTarget.
pkg/vmcp/client/header_forward.go New headerForwardRoundTripper; buildHeaderForwardTripper and resolveHeaderForward resolve headers once at factory time with restricted-header rejection.
pkg/vmcp/client/client.go Add secretsProvider field on httpBackendClient; insert tripper in chain between identity (outer) and auth (inner).
cmd/thv-operator/api/v1beta1/mcpserverentry_types.go Add ConditionTypeMCPServerEntryHeaderSecretRefsValidated and reason constants.
cmd/thv-operator/controllers/mcpserverentry_controller.go New validateHeaderForwardSecretRefs; wired into the reconcile fan-out.
cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig.go New buildHeaderForwardMap; threads through to config assembly.
cmd/thv-operator/controllers/virtualmcpserver_controller.go convertBackendsToStaticBackends accepts headerForwardMap and populates StaticBackendConfig.HeaderForward.
cmd/thv-operator/controllers/virtualmcpserver_deployment.go New buildHeaderForwardEnvVarsForEntries; called from buildEnvVarsForVmcp.
cmd/thv-operator/pkg/controllerutil/externalauth.go Rename proxyName parameter to ownerName; no behavioral change.
cmd/thv-operator/Taskfile.yml Add pkg/vmcp to controller-gen paths so HeaderForwardConfig gets a generated DeepCopy.
docs/operator/crd-api.md, deploy/charts/operator-crds/**, pkg/vmcp/zz_generated.deepcopy.go, pkg/vmcp/config/zz_generated.deepcopy.go Regenerated artefacts.

Type of change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change
  • Refactoring
  • Documentation
  • Other

Test plan

  • task lint-fix passes (0 issues)
  • task build passes
  • task test passes for all touched packages (48 packages across pkg/vmcp/... and cmd/thv-operator/...)
  • New unit tests:
    • TestBuildHeaderForwardFromEntry (+ secret-leak sentinel) in pkg/vmcp/workloads/
    • TestBuildHeaderForwardMap (+ per-entry identifier scoping, secret-leak sentinel) in cmd/thv-operator/controllers/
    • TestBuildHeaderForwardEnvVarsForEntries in cmd/thv-operator/controllers/
    • TestHeaderForwardRoundTripper_*, TestResolveHeaderForward_*, TestBuildHeaderForwardTripper_*, and an end-to-end httptest.Server test in pkg/vmcp/client/
    • New MCPServerEntry reconciler cases covering HeaderSecretsValid / HeaderSecretNotFound
  • task operator-generate, task operator-manifests, task crdref-gen run cleanly with no residual diff after commit

Special notes for reviewers

  • Size: ~15 prod files, ~560 prod lines — slightly over the 400-line / 10-file soft limit but this is one logical change (close the bug end-to-end); splitting would leave PR 1 without a user-visible fix for the reproducer.
  • Secret rotation: Matches MCPRemoteProxy parity — no Secret watch is added. Rotation requires a pod restart. A cross-CRD follow-up to add Secret watches for both resources is out of scope here.
  • RoundTripper ordering: Chain is trace → identity → headerForward → auth → http. The new tripper refuses to clobber an already-set header, so auth/identity/trace always win. Restricted-header rejection uses the existing pkg/transport/middleware.RestrictedHeaders set to keep parity.
  • Generated code: zz_generated.deepcopy.go updates and CRD YAML regeneration are bundled in the same commit. cmd/thv-operator/Taskfile.yml adds pkg/vmcp to the controller-gen paths (needed because HeaderForwardConfig lives at the pkg/vmcp root to keep pkg/vmcp/config from cycling back onto itself).
  • Implementation plan: an approved design doc was produced before this PR (design-mcpserverentry-headerforward.md). Not committed as it's a planning artefact, but happy to share if useful.

Generated with Claude Code

MCPServerEntry.spec.headerForward was accepted by the CRD but silently ignored
when the entry was consumed as a static backend of VirtualMCPServer. Only
MCPRemoteProxy honored the field, so vMCP requests to remoteUrl arrived without
the configured headers (e.g. GitHub Copilot's X-MCP-Toolsets).

Mirror the MCPRemoteProxy pattern so header values never enter the ConfigMap:

- Surface headerForward on pkg/vmcp.Backend, BackendTarget, and
  vmcpconfig.StaticBackendConfig so the runtime model carries per-backend
  header injection alongside auth and CA bundle.
- Extract headerForward in mcpServerEntryToBackend and threader the values
  through buildHeaderForwardMap and convertBackendsToStaticBackends to the
  ConfigMap. Plaintext is copied verbatim; Secret refs become only env-var
  identifiers via ctrlutil.GenerateHeaderForwardSecretEnvVarName.
- Emit one env var per (entry, header) on the vMCP Deployment with
  valueFrom.secretKeyRef so the Secret value is injected at pod start.
- Insert a new headerForwardRoundTripper in pkg/vmcp/client between
  identityPropagatingRoundTripper and authRoundTripper. Resolve headers once
  at client-factory time via secrets.EnvironmentProvider; reject restricted
  headers via the shared pkg/transport/middleware.RestrictedHeaders set.
- Validate referenced Secrets in MCPServerEntry's reconciler and surface the
  result through a new HeaderSecretRefsValidated condition, matching
  MCPRemoteProxy's HeaderSecretNotFound reason.
- Carry HeaderForward, CABundlePath, and CABundleData into the BackendTarget
  built by the health monitor so health checks hit backends with the same
  TLS trust and header injection as list/call.
- Generalize GenerateHeaderForwardSecretEnvVarName to accept an owner name
  rather than a proxy name; the existing MCPRemoteProxy caller is unchanged
  and MCPServerEntry uses the entry name as the disambiguator.

Closes #4996

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added the size/XL Extra large PR: 1000+ lines changed label Apr 22, 2026
Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

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 transformation

Alternative:

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
Copy link
Copy Markdown

codecov Bot commented Apr 22, 2026

Codecov Report

❌ Patch coverage is 78.01418% with 62 lines in your changes missing coverage. Please review.
✅ Project coverage is 69.09%. Comparing base (c717272) to head (8d8e6d4).
⚠️ Report is 9 commits behind head on main.

Files with missing lines Patch % Lines
pkg/vmcp/client/header_forward.go 69.62% 18 Missing and 6 partials ⚠️
...perator/controllers/virtualmcpserver_vmcpconfig.go 72.88% 10 Missing and 6 partials ⚠️
...-operator/controllers/mcpserverentry_controller.go 82.22% 6 Missing and 2 partials ⚠️
...perator/controllers/virtualmcpserver_deployment.go 82.97% 5 Missing and 3 partials ⚠️
...perator/controllers/virtualmcpserver_controller.go 0.00% 2 Missing and 1 partial ⚠️
pkg/vmcp/client/client.go 66.66% 2 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #5013      +/-   ##
==========================================
+ Coverage   68.98%   69.09%   +0.10%     
==========================================
  Files         552      555       +3     
  Lines       72996    73344     +348     
==========================================
+ Hits        50359    50679     +320     
+ Misses      19639    19633       -6     
- Partials     2998     3032      +34     

☔ View full report in Codecov by Sentry.
📢 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.

@ChrisJBurns ChrisJBurns marked this pull request as draft April 22, 2026 16:43
Copy link
Copy Markdown
Contributor

@jhrozek jhrozek left a comment

Choose a reason for hiding this comment

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

Four inline comments from a review pass. All non-blocking suggestions. See individual comments for details.

}
}

return envVars, nil
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.

buildHeaderForwardEnvVarsForEntries returns envVars without sorting, but the two sibling functions in this same file (discoverExternalAuthConfigSecrets at line 622, discoverInlineExternalAuthConfigSecrets at line 668) both sort by name and include a comment explaining why: containerNeedsUpdate uses reflect.DeepEqual on the env list, and unsorted env vars produce a continuous deployment update loop. PR #4783 fixed exactly this bug for the sibling functions.

With ≥2 entries declaring headerForward.addHeadersFromSecret, the order that listMCPServerEntriesAsMap + inner slice iteration produces isn't guaranteed stable across reconciles, so this function can trigger the same rollout loop.

Suggest adding the same sort before return envVars, nil, mirroring the existing comment:

sort.Slice(envVars, func(i, j int) bool {
    return envVars[i].Name < envVars[j].Name
})

backendName, canonical,
)
}
value, err := provider.GetSecret(nil, identifier) //nolint:staticcheck // provider ignores ctx
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.

provider.GetSecret(nil, identifier) passes a literal nil context.Context. The current EnvironmentProvider ignores ctx so this works today, but the secretsProvider field is typed secrets.Provider to allow substitution (Vault, 1Password, cloud KMS, etc.). Any provider that calls ctx.Err() or passes ctx into an outgoing HTTP/gRPC client will nil-deref on every request.

Suggest passing context.Background() here (resolution happens at client-factory construction time, so the caller's ctx isn't needed) and removing the //nolint:staticcheck suppression.

As a future improvement, it would be cleaner to thread a ctx context.Context parameter through buildHeaderForwardTripperresolveHeaderForward so any future cancellation-aware provider gets the caller's context.

// staticHeaderForwardFromEntry builds the ConfigMap-ready HeaderForwardConfig for
// a single MCPServerEntry. Returns nil when the entry declares no headers (e.g.,
// all AddHeadersFromSecret entries have nil ValueSecretRef).
func staticHeaderForwardFromEntry(entry *mcpv1beta1.MCPServerEntry) *vmcptypes.HeaderForwardConfig {
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.

staticHeaderForwardFromEntry here and buildHeaderForwardFromEntry in pkg/vmcp/workloads/k8s.go:598 are near-duplicates — same return type (*vmcp.HeaderForwardConfig), same logic (plaintext copy + secret refs → identifiers via GenerateHeaderForwardSecretEnvVarName), minor cosmetic differences (maps.Copy vs manual loop, plaintext == nil vs len(plaintext) == 0).

Both paths must produce byte-identical identifiers because the operator-side function writes to the ConfigMap and the runtime-side function writes to the vmcp.Backend registry that the runtime reads — divergence would produce a silent "header not forwarded" mismatch between what the ConfigMap declares and what the runtime looks up.

Consider extracting a single helper in cmd/thv-operator/pkg/controllerutil/ (next to GenerateHeaderForwardSecretEnvVarName, which both callers already import) so drift is impossible. Not blocking for this PR, but worth doing as a follow-up.

Comment thread pkg/vmcp/doc.go
//
// See individual subpackage documentation for detailed usage and examples.
//
// +groupName=toolhive.stacklok.dev
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.

Adding +groupName=toolhive.stacklok.dev here (necessary for controller-gen to emit DeepCopy for the new HeaderForwardConfig) creates a new ## toolhive.stacklok.dev/vmcp section in docs/operator/crd-api.md with ~30 blank lines before the single HeaderForwardConfig entry.

The sibling packages that set +groupName (pkg/audit, pkg/telemetry, pkg/vmcp/config, pkg/vmcp/auth/types) also set +versionName=<subgroup> and have short 4-line package comments. pkg/vmcp/doc.go has a 157-line package comment, which crd-ref-docs appears to render as group description with each blank-ish line becoming a blank markdown line.

Minimal fix: add // +versionName=vmcp to match the sibling convention and regenerate. If that doesn't clean up the whitespace, filtering the group in docs/operator/crd-ref-config.yaml is an alternative.

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.

MCPServerEntry.spec.headerForward is accepted by CRD but never sent with requests by VirtualMCPServer

2 participants