Wire OBO SecretEnvVars into MCPServer and MCPRemoteProxy#5540
Conversation
The OBOHandler exposes three operator dispatch points, but SecretEnvVars was dispatched from VirtualMCPServer only. For a build with a registered OBO handler, an obo-typed MCPExternalAuthConfig referenced from an MCPServer or MCPRemoteProxy got its middleware wired into the RunConfig yet never received the env var(s) the handler asks for, so the OBO middleware could not read its credential at startup. Add AddExternalAuthConfigSecretEnvVars in controllerutil — the secret-env sibling of AddExternalAuthConfigOptions — and call it from MCPServer (both the deployment builder and the drift check) and MCPRemoteProxy. The dispatcher handles only the obo type, whose env var name is defined by the handler and is therefore identical across consumers; every other type keeps its existing per-consumer helper so pod env stays byte-identical and VirtualMCPServer is untouched. ErrEnterpriseRequired is treated as "no env vars" so builder and drift agree in handler-less builds (no hot-loop). Implements changes for issue #5537: - Add the shared obo-scoped secret-env dispatcher with unit tests covering every ExternalAuthType plus the registered-handler and inert cases - Inject OBO env vars in MCPServer builder + drift and MCPRemoteProxy - Add controller tests asserting the env var is present and does not drift Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fixed issues from code review (comment-only, no behavior change): - MEDIUM: Document the known builder/drift divergence on a genuine OBO handler error at the MCPServer drift site; the inert/handler-less path stays symmetric, and unifying the transient-error case is out of scope - LOW: Note in AddExternalAuthConfigSecretEnvVars that VirtualMCPServer's firstOBOSecretEnvVar keeps only the first env var, so the two are reconciled if multi-var handlers appear Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #5540 +/- ##
=======================================
Coverage 69.84% 69.85%
=======================================
Files 647 647
Lines 65796 65833 +37
=======================================
+ Hits 45957 45985 +28
- Misses 16494 16498 +4
- Partials 3345 3350 +5 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
tgrunnagle
left a comment
There was a problem hiding this comment.
Multi-agent review summary
Reviewed by 5 specialist passes (Go correctness, operator/reconciliation, OBO/auth, test coverage, general quality). Recommendation: COMMENT — no blocking issues. 0 HIGH · 2 MEDIUM · 3 LOW. The fix correctly closes #5537 for both MCPServer and MCPRemoteProxy, all five acceptance criteria are met, and the deliberate narrowing from the issue's literal proposal (obo-only dispatcher, VirtualMCPServer left unchanged) is sound and well documented.
What's strong
- Builder/drift symmetry is correct: identical call and position in both
MCPServerpaths; structural inMCPRemoteProxyvia the singlebuildEnvVarsForProxy. ErrEnterpriseRequired→(nil, nil)keeps stock builds inert and prevents a reconcile hot-loop.- Thorough tests: every
ExternalAuthType, nil-ref, missing-config, inert handler, verbatim forwarding, and genuine-error propagation; controller tests assert both injection and no-drift.
Findings (details in inline comments)
| # | Sev | Finding |
|---|---|---|
| F1 | MEDIUM | Builder/drift error-handling asymmetry can churn on a persistently-failing handler; the "self-resolves" comment is imprecise. |
| F2 | MEDIUM | AddExternalAuthConfigSecretEnvVars name implies full type coverage but only handles obo. |
| F3 | LOW | The obo-only boundary is guarded only by prose; the controller-level genuine-error divergence is untested. |
| F4 | LOW | firstOBOSecretEnvVar (vMCP) keeps only the first env var while this dispatcher forwards all — latent split if a handler ever returns 2+ vars. |
| F5 | LOW | Builder/proxy log the raw handler error, which per the error contract may include Secret names. |
Highest-value quick wins: F2 (rename) and F1 (comment wording). F3–F5 are reasonable follow-ups.
🤖 Generated with Claude Code
Addresses #5540 review comments: - MEDIUM tokenexchange.go (3423253267): rename AddExternalAuthConfigSecretEnvVars to AddOBOSecretEnvVars so the name reflects its obo-only scope and can't be misread as the full type coverage its sibling AddExternalAuthConfigOptions has - MEDIUM mcpserver_controller.go (3423253261): correct the drift-site comment — the builder/drift divergence on a genuine handler error persists every reconcile while the handler keeps erroring; it does not "self-resolve" for persistent errors - LOW mcpserver_controller.go (3423253288): log raw handler errors verbatim only for *obo.ValidationError (contractually safe); otherwise log a generic message pointing at the MCPExternalAuthConfig status, so transient errors don't leak Secret names. Shared logOBOSecretEnvVarError helper used by both controllers - LOW tokenexchange_test.go (3423253271): populate secret refs on the non-obo table rows so the "must stay empty" assertions are real regression guards, and add a controller test locking in the genuine-error builder/drift divergence Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses #5540 review comments: - LOW virtualmcpserver_deployment.go (3423253276): vMCP's firstOBOSecretEnvVar kept only the first handler env var while MCPServer/MCPRemoteProxy forward all, a latent split that would silently reproduce the class of bug #5537 fixed if a handler ever returned 2+ vars. Reconcile it now rather than deferring. Change getExternalAuthConfigSecretEnvVar to return a slice (getExternalAuthConfigSecretEnvVars) and have its obo arm forward every env var via OBOSecretEnvVars, removing firstOBOSecretEnvVar. vMCP keeps calling OBOSecretEnvVars directly (not the swallowing AddOBOSecretEnvVars wrapper) so it still propagates obo.ErrEnterpriseRequired per #5328 — its existing test and the wired-but-disabled contract are preserved. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary
The operator's
OBOHandlerextension point exposes three dispatch points —Validate,ApplyRunConfig, andSecretEnvVars.ValidateandApplyRunConfigare reached uniformly from everyMCPExternalAuthConfigconsumer, butSecretEnvVarswas dispatched from only one of the three (VirtualMCPServer). As a result, in a build with a registered (enterprise) OBO handler, anobo-typedMCPExternalAuthConfigreferenced from anMCPServerorMCPRemoteProxyhad its OBO middleware wired into the RunConfig but never received the env var(s) the handler'sSecretEnvVarsreturns — so the middleware could not read its credential at startup. (This is inert in stock builds: the default handler returnsobo.ErrEnterpriseRequired.)This PR closes that gap by giving the secret-env path a shared OBO dispatcher and wiring it into the two missing consumers.
AddExternalAuthConfigOptions) already has, so two of three consumers silently omitted the OBO env vars — a "every consumer must remember to call it" failure mode.controllerutil.AddOBOSecretEnvVarsand wired it into theMCPServerdeployment builder + drift check and intoMCPRemoteProxy'sbuildEnvVarsForProxy.VirtualMCPServer's obo arm was also reconciled to forward all handler env vars (it previously kept only the first).Closes #5537
Type of change
Test plan
task test)task lint-fix)Validation run: full
task operator-testpasses;golangci-lintis clean on all changed files. (One repo-wide pre-existing gosec finding incmd/thv/app/upgrade.gois unrelated and untouched.)Test coverage added/updated:
tokenexchange_test.go): everyExternalAuthType(nil ref, missing config, andtokenExchange/bearerToken/headerInjection/unauthenticated/embeddedAuthServer/awsSts/upstreamInjectall contribute no env vars here;obowith the default handler is inert), plus a registered-handler case asserting the handler's env vars are forwarded verbatim and a genuine-error case. The non-obo rows now seed real secret refs so the "must stay empty" assertions act as regression guards.TestMCPServerDeployment_OBOSecretEnvVarsandTestMCPRemoteProxyDeployment_OBOSecretEnvVarsassert the OBO env var is injected into the proxy container and that the freshly built deployment does not drift;TestMCPServerDeployment_OBOSecretEnvVars_GenuineErrorDivergencelocks in the documented builder/drift behavior on a genuine (non-enterprise) handler error.VirtualMCPServer: its obo dispatch test is updated for the slice return and still assertsobo.ErrEnterpriseRequiredis propagated (the wired-but-disabled contract from Wire OBO dispatch arms and reconciler branch; add integration tests #5328).Changes
cmd/thv-operator/pkg/controllerutil/tokenexchange.goAddOBOSecretEnvVars— resolves the ref, dispatches only theobotype throughOBOSecretEnvVars, swallowsobo.ErrEnterpriseRequiredto(nil, nil). Name carries the obo-only scope so it isn't mistaken for full type coverage.cmd/thv-operator/controllers/mcpserver_controller.goAddOBOSecretEnvVarsin bothdeploymentForMCPServer(builder) anddeploymentNeedsUpdate(drift check), at the same position, to keep the two symmetric. Add a sharedlogOBOSecretEnvVarErrorhelper that logs verbatim only for*obo.ValidationErrorand generically otherwise (avoids leaking Secret names).cmd/thv-operator/controllers/mcpremoteproxy_deployment.goAddOBOSecretEnvVarsinbuildEnvVarsForProxy, which feeds both builder and drift paths; log errors via the sharedlogOBOSecretEnvVarError.cmd/thv-operator/controllers/virtualmcpserver_deployment.gogetExternalAuthConfigSecretEnvVar→getExternalAuthConfigSecretEnvVars(slice return); its obo arm now forwards all handler env vars viaOBOSecretEnvVars(propagatingErrEnterpriseRequiredper #5328); removefirstOBOSecretEnvVar.cmd/thv-operator/pkg/controllerutil/tokenexchange_test.gocmd/thv-operator/controllers/mcpserver_externalauth_test.goMCPServer, and the genuine-error builder/drift divergence.cmd/thv-operator/controllers/mcpremoteproxy_deployment_test.goMCPRemoteProxy.cmd/thv-operator/controllers/virtualmcpserver_externalauth_test.goErrEnterpriseRequiredis still propagated.Does this introduce a user-facing change?
No upstream behavior change —
obois inert in stock builds. For out-of-tree builds with a registered OBO handler, anobo-typedMCPExternalAuthConfigreferenced from anMCPServerorMCPRemoteProxynow correctly injects the handler's secret env var(s) into the proxy container, matchingVirtualMCPServer.VirtualMCPServeritself now forwards all of a handler's env vars rather than only the first — a no-op for today's single-var handlers, but it removes a latent divergence.Implementation plan
Approved implementation plan (+ PR-review updates)
The issue's literal proposal was to lift
VirtualMCPServer's whole env-var switch into a shared dispatcher and route all three consumers through it. That is not safe: the consumers use different env var naming for the same non-obotypes —MCPServer/MCPRemoteProxyrely on the fixed nameTOOLHIVE_TOKEN_EXCHANGE_CLIENT_SECRETthat the runtime middleware hardcodes (pkg/oauthproto/tokenexchange/middleware.go), whileVirtualMCPServeruses unique per-config names. Routing them through vMCP's switch would break the runtime and violate the issue's own "byte-identical pod env per consumer" acceptance criterion.The dispatcher is therefore scoped to only the
obotype, whose env var name is handler-defined and identical across consumers. Every other type keeps its existing per-consumer helper. This scoping ("Option A") was explicitly approved before implementation.AddOBOSecretEnvVarsswallowsobo.ErrEnterpriseRequired(returnsnil, nil), so theMCPServerbuilder and drift check stay symmetric in handler-less builds and there is no reconcile hot-loop.Updated after PR review (#5540): five non-blocking findings (0 HIGH · 2 MEDIUM · 3 LOW) were addressed in two commits:
AddExternalAuthConfigSecretEnvVars→AddOBOSecretEnvVarsso the identifier reflects the obo-only scope.*obo.ValidationError; otherwise a generic message pointing at theMCPExternalAuthConfigstatus, so transient errors don't leak Secret names.VirtualMCPServerrather than deferring: it now forwards all obo env vars. It still callsOBOSecretEnvVarsdirectly (notAddOBOSecretEnvVars), because that wrapper swallowsErrEnterpriseRequiredand vMCP must propagate it per Wire OBO dispatch arms and reconciler branch; add integration tests #5328 (its test enforces the wired-but-disabled contract). So all three consumers now forward all obo env vars and differ only in the intentional, separately-testedErrEnterpriseRequiredhandling.All five acceptance criteria from the issue are met and verified by the tests above.
Special notes for reviewers
AddOBOSecretEnvVarshandles only theobotype by design; the doc comment explains why the other types must stay on their per-consumer helpers (consumer-specific env var names that must remain byte-identical). Please do not "complete" the switch by moving other types into it — that would reintroduce the runtime breakage described in the implementation plan.MCPServer, the dispatcher is called at the same position in bothdeploymentForMCPServeranddeploymentNeedsUpdate; inMCPRemoteProxythe singlebuildEnvVarsForProxyfeeds both paths. TheErrEnterpriseRequired-to-(nil, nil)swallow is what keeps the two sides computing identical env in handler-less builds. On a genuine handler error theMCPServerbuilder logs-and-continues while the drift check reports drift — a pre-existing pattern (token-exchange behaves the same), documented at the drift site and out of scope to unify here.VirtualMCPServerkeeps a separate dispatch on purpose. It callsOBOSecretEnvVarsdirectly rather thanAddOBOSecretEnvVars, because it must propagateErrEnterpriseRequired(the swallowing wrapper would mask the wired-but-disabled state, breaking the Wire OBO dispatch arms and reconciler branch; add integration tests #5328 contract its test enforces). All three consumers now forward every handler env var; only theErrEnterpriseRequiredhandling differs, and that difference is intentional and tested on both sides.