Skip to content

Commit c328915

Browse files
committed
Thread per-backend headerForward through aggregator, workloads, and CLI
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.
1 parent 6b4b743 commit c328915

8 files changed

Lines changed: 481 additions & 6 deletions

File tree

pkg/vmcp/aggregator/discoverer.go

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"github.com/stacklok/toolhive/pkg/groups"
2222
"github.com/stacklok/toolhive/pkg/vmcp"
2323
"github.com/stacklok/toolhive/pkg/vmcp/config"
24+
"github.com/stacklok/toolhive/pkg/vmcp/headerforward/wirefmt"
2425
"github.com/stacklok/toolhive/pkg/vmcp/workloads"
2526
workloadsmgr "github.com/stacklok/toolhive/pkg/workloads"
2627
)
@@ -33,6 +34,15 @@ type backendDiscoverer struct {
3334
authConfig *config.OutgoingAuthConfig
3435
staticBackends []config.StaticBackendConfig // Pre-configured backends for static mode
3536
groupRef string // Group reference for static mode metadata
37+
38+
// headerForwardByBackend is keyed by the NORMALIZED backend name (the
39+
// suffix the operator emits in TOOLHIVE_HEADER_FORWARD_<entry>). The
40+
// canonical backend name from the static config is normalized on
41+
// lookup via wirefmt.NormalizeForEnvVar so the keying matches
42+
// the operator-side env-var encoding. Nil/empty when no entry declared
43+
// headerForward. Only populated in static mode — dynamic mode reads
44+
// headerForward directly from the MCPServerEntry CRD.
45+
headerForwardByBackend map[string]*vmcp.HeaderForwardConfig
3646
}
3747

3848
// NewUnifiedBackendDiscoverer creates a unified backend discoverer that works with both
@@ -55,17 +65,24 @@ func NewUnifiedBackendDiscoverer(
5565

5666
// NewUnifiedBackendDiscovererWithStaticBackends creates a backend discoverer for static mode
5767
// with pre-configured backends, eliminating the need for K8s API access.
68+
//
69+
// headerForwardByBackend carries per-backend HeaderForwardConfig (keyed by
70+
// backend name) constructed at startup from the operator-emitted env vars.
71+
// Pass nil when no entry in the group declares headerForward — the discoverer
72+
// will simply leave Backend.HeaderForward nil for every backend.
5873
func NewUnifiedBackendDiscovererWithStaticBackends(
5974
staticBackends []config.StaticBackendConfig,
6075
authConfig *config.OutgoingAuthConfig,
6176
groupRef string,
77+
headerForwardByBackend map[string]*vmcp.HeaderForwardConfig,
6278
) BackendDiscoverer {
6379
return &backendDiscoverer{
64-
workloadsManager: nil, // Not needed in static mode
65-
groupsManager: nil, // Not needed in static mode
66-
authConfig: authConfig,
67-
staticBackends: staticBackends,
68-
groupRef: groupRef,
80+
workloadsManager: nil, // Not needed in static mode
81+
groupsManager: nil, // Not needed in static mode
82+
authConfig: authConfig,
83+
staticBackends: staticBackends,
84+
groupRef: groupRef,
85+
headerForwardByBackend: headerForwardByBackend,
6986
}
7087
}
7188

@@ -272,6 +289,7 @@ func (d *backendDiscoverer) discoverFromStaticConfig() []vmcp.Backend {
272289
Type: vmcp.BackendType(staticBackend.Type),
273290
CABundlePath: staticBackend.CABundlePath,
274291
HealthStatus: vmcp.BackendHealthy, // Assume healthy, actual health check happens later
292+
HeaderForward: d.headerForwardByBackend[wirefmt.NormalizeForEnvVar(staticBackend.Name)],
275293
Metadata: staticBackend.Metadata,
276294
}
277295

pkg/vmcp/aggregator/discoverer_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1186,6 +1186,7 @@ func TestStaticBackendDiscoverer_EmptyBackendList(t *testing.T) {
11861186
[]config.StaticBackendConfig{}, // Empty slice, not nil
11871187
nil, // No auth config
11881188
"test-group",
1189+
nil, // No headerForward map
11891190
)
11901191

11911192
// This should return empty list without panicking
@@ -1281,6 +1282,7 @@ func TestStaticBackendDiscoverer_MetadataGroupOverride(t *testing.T) {
12811282
tt.staticBackends,
12821283
nil, // No auth config needed for this test
12831284
tt.groupRef,
1285+
nil, // No headerForward map for this test
12841286
)
12851287

12861288
backends, err := discoverer.Discover(ctx, tt.groupRef)
@@ -1400,6 +1402,7 @@ func TestStaticBackendDiscoverer_EntryBackendFields(t *testing.T) {
14001402
tt.staticBackends,
14011403
nil, // No auth config needed for this test
14021404
tt.groupRef,
1405+
nil, // No headerForward map for this test
14031406
)
14041407

14051408
backends, err := discoverer.Discover(ctx, tt.groupRef)
@@ -1466,6 +1469,7 @@ func TestBackendDiscoverer_Discover_DeterministicOrdering(t *testing.T) {
14661469
tc.staticBackends,
14671470
nil, // No auth config needed for this test
14681471
"test-group",
1472+
nil, // No headerForward map for this test
14691473
)
14701474

14711475
backends, err := discoverer.Discover(ctx, "test-group")

pkg/vmcp/cli/header_forward_env.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package cli
5+
6+
import (
7+
"encoding/json"
8+
"log/slog"
9+
"strings"
10+
11+
"github.com/stacklok/toolhive/pkg/vmcp"
12+
"github.com/stacklok/toolhive/pkg/vmcp/headerforward/wirefmt"
13+
)
14+
15+
// readHeaderForwardFromEnv reconstructs the per-backend
16+
// vmcp.HeaderForwardConfig map by walking environment variables emitted by
17+
// the operator on the vMCP pod.
18+
//
19+
// The operator emits one JSON-encoded manifest env var per backend named
20+
// "TOOLHIVE_HEADER_FORWARD_<NORMALIZED_ENTRY>". The JSON value carries
21+
// every configured header for that backend with original (un-normalized)
22+
// names preserved:
23+
//
24+
// {
25+
// "addPlaintextHeaders": {"X-MCP-Toolsets":"projects,issues"},
26+
// "addHeadersFromSecret": {"X-Api-Key":"HEADER_FORWARD_X_API_KEY_<entry>"}
27+
// }
28+
//
29+
// AddHeadersFromSecret values are secret IDENTIFIERS, not values. Secret
30+
// values resolve later inside resolveHeaderForward via
31+
// secrets.EnvironmentProvider, which reads TOOLHIVE_SECRET_<identifier>
32+
// env vars (delivered separately via valueFrom.secretKeyRef so the value
33+
// never enters the operator's view of the world).
34+
//
35+
// The map key is the normalized entry segment from the env-var suffix —
36+
// the SAME value the operator's GenerateHeaderForwardManifestEnvVarName
37+
// produces for that backend's name. Callers look up by passing the
38+
// original backend name through ctrlutil.NormalizeHeaderForEnvVar before
39+
// indexing.
40+
func readHeaderForwardFromEnv(envEntries []string) map[string]*vmcp.HeaderForwardConfig {
41+
out := make(map[string]*vmcp.HeaderForwardConfig)
42+
for _, entry := range envEntries {
43+
name, value, ok := strings.Cut(entry, "=")
44+
if !ok || !strings.HasPrefix(name, wirefmt.ManifestEnvVarPrefix) {
45+
continue
46+
}
47+
ownerSegment := strings.TrimPrefix(name, wirefmt.ManifestEnvVarPrefix)
48+
49+
var cfg vmcp.HeaderForwardConfig
50+
if err := json.Unmarshal([]byte(value), &cfg); err != nil {
51+
// A malformed manifest is a programmer error in the operator;
52+
// log and skip rather than fail the whole startup. The backend
53+
// will simply have no headerForward attached.
54+
slog.Warn("invalid headerForward manifest from env, skipping",
55+
"envvar", name, "error", err)
56+
continue
57+
}
58+
// wirefmt.NormalizeForEnvVar maps two distinct entry names with the
59+
// same uppercased+sanitized form to the same env-var suffix (e.g.
60+
// "github-copilot" and "github_copilot"). DNS-1123 forbids
61+
// underscores in entry names, so this collision is unreachable in
62+
// production today — but a future relaxation, a migration that
63+
// allows mixed casing, or a third-party producing manifests can all
64+
// land us here. Surface the collision loudly: the second config
65+
// silently overwriting the first would mask a misconfiguration that
66+
// is otherwise extremely hard to debug.
67+
if _, dup := out[ownerSegment]; dup {
68+
slog.Warn("duplicate headerForward manifest env var; later value overrides earlier",
69+
"envvar", name, "ownerSegment", ownerSegment)
70+
}
71+
out[ownerSegment] = &cfg
72+
}
73+
return out
74+
}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package cli
5+
6+
import (
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
12+
"github.com/stacklok/toolhive/pkg/vmcp"
13+
)
14+
15+
// TestReadHeaderForwardFromEnv covers reconstructing the per-backend
16+
// HeaderForwardConfig from the JSON-encoded TOOLHIVE_HEADER_FORWARD_<entry>
17+
// env vars the operator emits on the vMCP pod. Original header casing /
18+
// punctuation is preserved by the JSON value, so the runtime reconstructs
19+
// "X-MCP-Toolsets" rather than "X_MCP_TOOLSETS".
20+
//
21+
// The map is keyed by the normalized entry segment from the env-var
22+
// suffix; the discoverer normalizes Backend.Name through
23+
// ctrlutil.NormalizeHeaderForEnvVar before indexing.
24+
func TestReadHeaderForwardFromEnv(t *testing.T) {
25+
t.Parallel()
26+
27+
tests := []struct {
28+
name string
29+
env []string
30+
validate func(t *testing.T, m map[string]*vmcp.HeaderForwardConfig)
31+
}{
32+
{
33+
name: "no env vars yields empty map",
34+
env: []string{"HOME=/root", "PATH=/bin"},
35+
validate: func(t *testing.T, m map[string]*vmcp.HeaderForwardConfig) {
36+
t.Helper()
37+
assert.Empty(t, m)
38+
},
39+
},
40+
{
41+
name: "plaintext header decodes preserving original casing and hyphens",
42+
env: []string{
43+
`TOOLHIVE_HEADER_FORWARD_GITHUB_COPILOT={"addPlaintextHeaders":{"X-MCP-Toolsets":"projects,issues"}}`,
44+
"PATH=/bin",
45+
},
46+
validate: func(t *testing.T, m map[string]*vmcp.HeaderForwardConfig) {
47+
t.Helper()
48+
cfg := m["GITHUB_COPILOT"]
49+
require.NotNil(t, cfg)
50+
assert.Equal(t, map[string]string{"X-MCP-Toolsets": "projects,issues"}, cfg.AddPlaintextHeaders)
51+
assert.Empty(t, cfg.AddHeadersFromSecret)
52+
},
53+
},
54+
{
55+
name: "secret header decodes to identifier (value not in manifest)",
56+
env: []string{
57+
`TOOLHIVE_HEADER_FORWARD_STRIPE={"addHeadersFromSecret":{"X-Api-Key":"HEADER_FORWARD_X_API_KEY_STRIPE"}}`,
58+
// The secret-value env var carries the resolved value via
59+
// secretKeyRef; the walker must NOT treat it as a manifest.
60+
"TOOLHIVE_SECRET_HEADER_FORWARD_X_API_KEY_STRIPE=resolved-secret-value",
61+
},
62+
validate: func(t *testing.T, m map[string]*vmcp.HeaderForwardConfig) {
63+
t.Helper()
64+
assert.NotContains(t, m, "X_API_KEY_STRIPE", "secret env var must not be parsed as a manifest")
65+
cfg := m["STRIPE"]
66+
require.NotNil(t, cfg)
67+
assert.Empty(t, cfg.AddPlaintextHeaders)
68+
assert.Equal(t,
69+
map[string]string{"X-Api-Key": "HEADER_FORWARD_X_API_KEY_STRIPE"},
70+
cfg.AddHeadersFromSecret,
71+
)
72+
},
73+
},
74+
{
75+
name: "malformed manifest is skipped without failing other backends",
76+
env: []string{
77+
`TOOLHIVE_HEADER_FORWARD_BROKEN=not-json`,
78+
`TOOLHIVE_HEADER_FORWARD_DEMO={"addPlaintextHeaders":{"X-Trace":"ok"}}`,
79+
},
80+
validate: func(t *testing.T, m map[string]*vmcp.HeaderForwardConfig) {
81+
t.Helper()
82+
assert.NotContains(t, m, "BROKEN")
83+
cfg := m["DEMO"]
84+
require.NotNil(t, cfg)
85+
assert.Equal(t, map[string]string{"X-Trace": "ok"}, cfg.AddPlaintextHeaders)
86+
},
87+
},
88+
{
89+
name: "mixed plaintext + secret in one manifest scoped per backend",
90+
env: []string{
91+
`TOOLHIVE_HEADER_FORWARD_ALPHA={"addPlaintextHeaders":{"X-Trace":"alpha-trace"},"addHeadersFromSecret":{"X-Token":"HEADER_FORWARD_X_TOKEN_ALPHA"}}`,
92+
`TOOLHIVE_HEADER_FORWARD_BETA={"addPlaintextHeaders":{"X-Trace":"beta-trace"}}`,
93+
},
94+
validate: func(t *testing.T, m map[string]*vmcp.HeaderForwardConfig) {
95+
t.Helper()
96+
97+
alpha := m["ALPHA"]
98+
require.NotNil(t, alpha)
99+
assert.Equal(t, map[string]string{"X-Trace": "alpha-trace"}, alpha.AddPlaintextHeaders)
100+
assert.Equal(t,
101+
map[string]string{"X-Token": "HEADER_FORWARD_X_TOKEN_ALPHA"},
102+
alpha.AddHeadersFromSecret,
103+
)
104+
105+
beta := m["BETA"]
106+
require.NotNil(t, beta)
107+
assert.Equal(t, map[string]string{"X-Trace": "beta-trace"}, beta.AddPlaintextHeaders)
108+
assert.Empty(t, beta.AddHeadersFromSecret)
109+
},
110+
},
111+
{
112+
name: "malformed env entry without '=' is skipped",
113+
env: []string{
114+
"NO_EQUALS_HERE",
115+
`TOOLHIVE_HEADER_FORWARD_DEMO={"addPlaintextHeaders":{"X-Trace":"ok"}}`,
116+
},
117+
validate: func(t *testing.T, m map[string]*vmcp.HeaderForwardConfig) {
118+
t.Helper()
119+
cfg := m["DEMO"]
120+
require.NotNil(t, cfg)
121+
assert.Equal(t, map[string]string{"X-Trace": "ok"}, cfg.AddPlaintextHeaders)
122+
},
123+
},
124+
{
125+
// DNS-1123 forbids underscores in MCPServerEntry names today, so
126+
// real entries with the same NormalizeForEnvVar output cannot
127+
// coexist. We pass two manifests that share the same suffix
128+
// directly to verify the runtime keeps "last wins" behavior and
129+
// surfaces a warning rather than silently dropping the first.
130+
name: "duplicate manifest suffix retains last value",
131+
env: []string{
132+
`TOOLHIVE_HEADER_FORWARD_DUP={"addPlaintextHeaders":{"X-Trace":"first"}}`,
133+
`TOOLHIVE_HEADER_FORWARD_DUP={"addPlaintextHeaders":{"X-Trace":"second"}}`,
134+
},
135+
validate: func(t *testing.T, m map[string]*vmcp.HeaderForwardConfig) {
136+
t.Helper()
137+
cfg := m["DUP"]
138+
require.NotNil(t, cfg)
139+
assert.Equal(t, map[string]string{"X-Trace": "second"}, cfg.AddPlaintextHeaders,
140+
"duplicate manifest must keep the later value (last-write-wins)")
141+
},
142+
},
143+
}
144+
145+
for _, tt := range tests {
146+
t.Run(tt.name, func(t *testing.T) {
147+
t.Parallel()
148+
tt.validate(t, readHeaderForwardFromEnv(tt.env))
149+
})
150+
}
151+
}

pkg/vmcp/cli/serve.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -566,10 +566,21 @@ func discoverBackends(
566566
if len(cfg.Backends) > 0 {
567567
// Static mode: use pre-configured backends from config.
568568
slog.Info(fmt.Sprintf("Static mode: using %d pre-configured backends", len(cfg.Backends)))
569+
570+
// Reconstruct per-backend HeaderForwardConfig from env vars the
571+
// operator emitted on this pod. Plaintext header values are inline
572+
// in the JSON manifest; secret-backed headers carry only identifiers
573+
// here and resolve later via secrets.EnvironmentProvider at request
574+
// time. Map keys are the normalized entry segment from the env-var
575+
// suffix; the discoverer normalizes Backend.Name through
576+
// ctrlutil.NormalizeHeaderForEnvVar to look up the matching entry.
577+
// Returns an empty map when no entry in the group declared
578+
// headerForward — the common case.
569579
discoverer = aggregator.NewUnifiedBackendDiscovererWithStaticBackends(
570580
cfg.Backends,
571581
cfg.OutgoingAuth,
572582
cfg.Group,
583+
readHeaderForwardFromEnv(os.Environ()),
573584
)
574585
} else {
575586
// Dynamic mode: discover backends at runtime from the active workload manager (K8s or local).

pkg/vmcp/health/monitor.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -427,13 +427,19 @@ func (m *Monitor) performHealthCheck(ctx context.Context, backend *vmcp.Backend)
427427
return
428428
}
429429

430-
// Create BackendTarget from Backend
430+
// Create BackendTarget from Backend. Carry CA bundle and header-forward config
431+
// so health checks hit the backend with the same TLS trust and header injection
432+
// as list/call requests — otherwise a healthy-to-the-monitor backend could fail
433+
// for real traffic (or vice versa).
431434
target := &vmcp.BackendTarget{
432435
WorkloadID: backend.ID,
433436
WorkloadName: backend.Name,
434437
BaseURL: backend.BaseURL,
435438
TransportType: backend.TransportType,
439+
CABundlePath: backend.CABundlePath,
440+
CABundleData: backend.CABundleData,
436441
AuthConfig: backend.AuthConfig,
442+
HeaderForward: backend.HeaderForward,
437443
HealthStatus: vmcp.BackendUnknown, // Status is determined by the health check
438444
Metadata: backend.Metadata,
439445
}

0 commit comments

Comments
 (0)