You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Wire the embedded authorization server into the Kubernetes operator and add comprehensive test coverage — unit tests for the vMCP HTTP handler, CLI E2E tests for Mode A/Mode B routing and negative validation, and operator E2E tests verifying the AuthServerConfigValid status condition lifecycle. This is the final phase of RFC-0053 that closes the loop between the CRD API, the operator reconciler, the config converter, and observable runtime behavior.
Context
RFC-0053 adds an optional embedded OAuth/OIDC authorization server to vMCP. Phase 1 (#4140) established structural types and CRD fields. Phase 2 (#4141) mounted the AS HTTP routes on the vMCP mux. Phase 3 (#4142) added startup validation rules V-01 through V-07. Phase 4 (this task) completes the Kubernetes operator path: the reconciler resolves spec.authServerConfigRef, performs cross-resource validation, surfaces the AuthServerConfigValid status condition, and the config converter converts the referenced MCPExternalAuthConfig into the AuthServerConfig that vMCP reads at startup. This phase also delivers all deferred test coverage and documentation updates.
Parent epic: #4120 — vMCP: add embedded authorization server RFC document: docs/proposals/THV-0053-vmcp-embedded-authserver.md Dependencies: #4141 (Phase 2: Server wiring), #4142 (Phase 3: Startup validation) Blocks: nothing — this is the final phase. Full end-to-end token flow testing depends on RFC-0052 (#3924) for identity.UpstreamTokens population.
Acceptance Criteria
Operator Reconciler
cmd/thv-operator/controllers/virtualmcpserver_controller.gorunValidations() includes a new validateAuthServerConfigRef step that, when spec.authServerConfigRef is non-nil: fetches the referenced MCPExternalAuthConfig from the same namespace; verifies its spec.type is "embeddedAuthServer" (surfacing a Failed phase with descriptive message if not); verifies spec.embeddedAuthServer.issuer matches spec.incomingAuth.oidcConfig.inline.issuer (V-04 check, surfacing Failed phase with message containing "issuer" and "mismatch"); and verifies that the audience derived from spec.incomingAuth.oidcConfig.inline.audience is non-empty (defense-in-depth empty-audience guard)
A new ConditionTypeAuthServerConfigValid = "AuthServerConfigValid" constant is defined in cmd/thv-operator/api/v1alpha1/virtualmcpserver_types.go
On successful authServerConfigRef validation, statusManager.SetCondition(ConditionTypeAuthServerConfigValid, "AuthServerConfigValid", "Auth server config is valid", metav1.ConditionTrue) is called
On validation failure, statusManager.SetCondition(ConditionTypeAuthServerConfigValid, "AuthServerConfigInvalid", <descriptive message>, metav1.ConditionFalse) is called and the phase is set to Failed; reconciliation does not proceed to ensureAllResources
When spec.authServerConfigRef is nil (Mode A), validateAuthServerConfigRef is skipped entirely and no AuthServerConfigValid condition is set
Config Converter
cmd/thv-operator/pkg/vmcpconfig/converter.goConvert() includes a new convertAuthServerConfig step: when vmcp.Spec.AuthServerConfigRef != nil, fetches the MCPExternalAuthConfig, verifies type is "embeddedAuthServer", converts the EmbeddedAuthServerConfig to authserver.RunConfig, derives allowedAudiences from vmcp.Spec.IncomingAuth.OIDCConfig.Inline.Audience, sets config.AuthServer = &vmcpconfig.AuthServerConfig{RunConfig: runConfig}, and returns an error on any failure
When spec.authServerConfigRef is nil (Mode A), config.AuthServer remains nil (unchanged from the deep-copied spec config)
The converter derives allowedAudiences from spec.incomingAuth.oidcConfig.inline.audience and does NOT read any allowedAudiences field from MCPExternalAuthConfig (the CRD intentionally omits that field)
HTTP Handler Unit Tests
pkg/vmcp/server/server_test.go (or pkg/vmcp/server/handler_test.go) contains unit tests for the server Handler() method covering Mode A routing: GET /.well-known/oauth-protected-resource returns HTTP 200; GET /.well-known/openid-configuration returns HTTP 404; GET /.well-known/oauth-authorization-server returns HTTP 404; GET /.well-known/jwks.json returns HTTP 404; GET /oauth/token returns HTTP 404
Mode B routing tests: GET /.well-known/openid-configuration is served by the AS handler (HTTP 200 with JSON body); GET /.well-known/oauth-authorization-server is served by the AS handler (HTTP 200); GET /.well-known/jwks.json is served by the AS handler (HTTP 200); GET /oauth/authorize is served by the AS handler (not 404)
Both Mode A and Mode B: GET /.well-known/oauth-protected-resource returns HTTP 200 (explicit registration, not catch-all)
Both Mode A and Mode B: GET /mcp without a bearer token returns HTTP 401 when AuthMiddleware is configured
vMCP CLI E2E Tests
test/e2e/vmcp_authserver_test.go exists and is in package e2e_test; it joins the existing Ginkgo suite in test/e2e/e2e_suite_test.go automatically
Positive — Mode B startup: start vMCP with a Mode B YAML config (using OIDCMockServer as the upstream IDP); GET /.well-known/openid-configuration returns HTTP 200 with a JSON body containing a non-empty issuer field matching the configured AS issuer
Positive — Mode B 401: GET /mcp (or configured endpoint path) without a bearer token returns HTTP 401 (auth middleware active)
Positive — Mode A: start vMCP with no authServer block; GET /.well-known/openid-configuration returns HTTP 404; GET /.well-known/oauth-protected-resource returns HTTP 200
Negative — V-04 issuer mismatch: start vMCP with authServer.runConfig.issuer different from incomingAuth.oidc.issuer; process exits non-zero; stderr contains a string matching "issuer" and "mismatch" (or the V-04 message text)
Negative — V-01 upstream_inject without AS: start vMCP with an upstream_inject backend auth strategy but no authServer block; process exits non-zero; stderr contains a string referencing V-01 or "upstream_inject" and "authServer"
Negative — V-02 unknown provider: start vMCP with an upstream_inject strategy whose providerName does not match any upstream in authServer.runConfig.upstreams; process exits non-zero; stderr contains a string referencing the unknown provider name
Operator E2E Tests
test/e2e/thv-operator/virtualmcp/virtualmcp_authserver_test.go exists in package virtualmcp; it joins the existing Ginkgo suite in test/e2e/thv-operator/virtualmcp/suite_test.go
Test creates an MCPExternalAuthConfig with spec.type: embeddedAuthServer and a valid spec.embeddedAuthServer configuration (with an upstream provider pointing to an in-cluster mock OIDC server or a test fixture)
Test creates a VirtualMCPServer with spec.authServerConfigRef.name pointing to the above MCPExternalAuthConfig; spec.incomingAuth.oidcConfig.inline.issuer matches the embeddedAuthServer.issuer
Eventually assertion verifies the AuthServerConfigValid condition transitions to True (status ConditionTrue) within the test timeout
Deployment is created and becomes available (following the pattern in virtualmcp_external_auth_test.go)
Negative test: create a VirtualMCPServer with an issuer mismatch; Eventually assertion verifies the AuthServerConfigValid condition transitions to False with a message containing "issuer"; phase is Failed; no Deployment is created for this VirtualMCPServer
Documentation
docs/arch/09-operator-architecture.md is updated to document the new AuthServerConfigRef field on VirtualMCPServerSpec, the validateAuthServerConfigRef reconciliation step, and the AuthServerConfigValid status condition
docs/arch/02-core-concepts.md is updated to describe the Mode A / Mode B distinction for vMCP auth (embedded AS vs. external IDP)
A new vMCP auth server guide is added under docs/ (e.g., docs/vmcp-auth-server.md) covering: config walkthrough for both CLI YAML and Kubernetes CRD paths; example YAML snippets; explanation of the jwksAllowPrivateIP: true requirement for in-cluster loopback OIDC discovery; troubleshooting the self-referencing OIDC discovery setup
task docs is run to regenerate CRD reference documentation after any CRD changes
General
All new Go files include // SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. and // SPDX-License-Identifier: Apache-2.0 headers
task lint passes (or task lint-fix resolves all linting issues)
task test passes (unit tests)
All existing tests continue to pass (Mode A configs with nil AuthServer and nil AuthServerConfigRef must not be affected)
Technical Approach
Recommended Implementation
Work in four coordinated steps:
Step 1 — Reconciler validation (cmd/thv-operator/controllers/virtualmcpserver_controller.go): Add a validateAuthServerConfigRef method following the same structure as validateCompositeToolRefs and validateGroupRef. It is called inside runValidations() only when vmcp.Spec.AuthServerConfigRef != nil. The method uses r.Get to fetch the MCPExternalAuthConfig by name and namespace. After verifying spec.type == "embeddedAuthServer", perform the V-04 issuer consistency check (exact string comparison — no URL normalization). Use statusManager.SetCondition(ConditionTypeAuthServerConfigValid, ...) to surface results; on failure also call statusManager.SetPhase(VirtualMCPServerPhaseFailed) and statusManager.SetMessage(...). On validation failure, return (false, nil) from runValidations to stop reconciliation without requeueing (user must fix spec).
Step 2 — Converter step (cmd/thv-operator/pkg/vmcpconfig/converter.go): Add a convertAuthServerConfig private method that accepts ctx, vmcp, and the resolved MCPExternalAuthConfig. It converts EmbeddedAuthServerConfig fields to an authserver.RunConfig, derives allowedAudiences from vmcp.Spec.IncomingAuth.OIDCConfig.Inline.Audience, and returns *vmcpconfig.AuthServerConfig. Call this method from Convert() after the existing convertIncomingAuth block, gated on vmcp.Spec.AuthServerConfigRef != nil. The converter must also resolve the MCPExternalAuthConfig by calling c.k8sClient.Get (see existing convertBackendAuthConfig pattern for the Get call structure).
Step 3 — Unit tests: Add pkg/vmcp/server/server_test.go (or extend the existing file) with table-driven tests for Handler(). Construct a minimal Config with a mock AuthMiddleware (using httptest.NewRecorder and httptest.NewRequest). For Mode B tests, create an EmbeddedAuthServer via runner.NewEmbeddedAuthServer(ctx, runConfig) with a minimal dev-mode config (ephemeral keys, in-memory storage). After each test, defer as.Close(). Use httptest.NewServer(handler) to get a testable HTTP server. Assert HTTP status codes for each path.
Step 4 — E2E tests: For CLI E2E tests in test/e2e/vmcp_authserver_test.go, follow test/e2e/proxy_oauth_test.go exactly — Describe + BeforeEach/AfterEach, By() annotations, Eventually for async assertions. Start a OIDCMockServer as the upstream IDP and write YAML config files to t.TempDir(). For negative tests, start the vMCP binary and assert non-zero exit code and stderr content using exec.Command. For operator E2E tests in test/e2e/thv-operator/virtualmcp/virtualmcp_authserver_test.go, follow virtualmcp_external_auth_test.go — use k8sClient.Create, Eventually with k8sClient.Get to assert condition status.
Patterns & Frameworks
runValidations pattern: Follow the exact structure of validateCompositeToolRefs in virtualmcpserver_controller.go — return (false, nil) on user-fixable spec errors (to stop reconciliation without requeueing), return (false, err) for transient errors that should trigger requeue (e.g., API server unavailable)
Status condition surfacing: Use statusManager.SetCondition(conditionType, reason, message, status) directly (as in validateAndUpdatePodTemplateStatus) rather than a specialized helper, since AuthServerConfigValid does not warrant a dedicated method on StatusManager
Converter pattern: Follow convertBackendAuthConfig in converter.go — use c.k8sClient.Get with types.NamespacedName, check errors.IsNotFound separately from other errors, return fmt.Errorf with %w
omitempty and nil safety: All code paths gated on AuthServerConfigRef != nil; Mode A must pass zero new lines when the field is absent
Exact string match for V-04: Use == for issuer comparison; no URL normalization applied. Operators must use identical strings in both configs
SPDX headers: Every new Go file must start with // SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. and // SPDX-License-Identifier: Apache-2.0. Use task license-fix to add missing headers automatically
require.NoError(t, err) for test assertions: Use require from github.com/stretchr/testify/require rather than t.Fatal in unit tests; use Expect(...).To(Succeed()) in operator E2E tests (Gomega)
Ginkgo for E2E tests: Use Describe, BeforeAll/AfterAll (for Ordered suites), By() annotations, and Eventually(...).Within(...).ProbeEvery(...) for async assertions. Follow the existing operator E2E test structure in test/e2e/thv-operator/virtualmcp/
Code Pointers
cmd/thv-operator/controllers/virtualmcpserver_controller.go — runValidations() (line 276): add the new validateAuthServerConfigRef call here, after validateEmbeddingServerRef. Pattern for the new method: follow validateCompositeToolRefs (line 385) for structure; follow validateGroupRef (line 327) for the SetPhase/SetMessage/SetCondition call pattern
cmd/thv-operator/pkg/vmcpconfig/converter.go — Convert() (line 68): add the convertAuthServerConfig step after the convertIncomingAuth block (line 81). Pattern: follow convertBackendAuthConfig (line 283) for the k8sClient.Get call structure
cmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_types.go — EmbeddedAuthServerConfig struct (line 152): source of fields to map to authserver.RunConfig; note AllowedAudiences is absent by design (derived from the referencing VirtualMCPServer)
pkg/authserver/config.go — authserver.RunConfig: target type for the converter; key fields: Issuer string, AllowedAudiences []string, Upstreams []UpstreamRunConfig
test/e2e/proxy_oauth_test.go — reference pattern for CLI E2E tests: OIDCMockServer, process start/stop, Eventually, By() annotations
test/e2e/oidc_mock.go — OIDCMockServer: reusable mock OIDC server backed by Ory Fosite; use as the upstream IDP in Mode B E2E tests
test/e2e/thv-operator/virtualmcp/virtualmcp_external_auth_test.go — reference pattern for operator E2E tests: k8sClient.Create, Eventually with condition assertions, BeforeAll/AfterAll cleanup
test/e2e/thv-operator/virtualmcp/helpers.go — shared helpers for operator E2E tests: CreateMCPGroupAndWait, CreateMockHTTPServer; check what is available before writing new helpers
Component Interfaces
New constant in cmd/thv-operator/api/v1alpha1/virtualmcpserver_types.go:
// ConditionTypeAuthServerConfigValid indicates whether the authServerConfigRef// references a valid, compatible MCPExternalAuthConfig.constConditionTypeAuthServerConfigValid="AuthServerConfigValid"
New method on VirtualMCPServerReconciler in cmd/thv-operator/controllers/virtualmcpserver_controller.go:
// validateAuthServerConfigRef validates the MCPExternalAuthConfig referenced by// spec.authServerConfigRef. It verifies the type is "embeddedAuthServer", checks// issuer consistency with IncomingAuth.OIDC.Issuer (V-04), and verifies the// audience is non-empty (defense-in-depth guard).// Returns (true, nil) to continue, (false, nil) for spec errors (no requeue),// (false, err) for transient errors (triggers requeue).func (r*VirtualMCPServerReconciler) validateAuthServerConfigRef(
ctx context.Context,
vmcp*mcpv1alpha1.VirtualMCPServer,
statusManager virtualmcpserverstatus.StatusManager,
) (bool, error)
New private method on Converter in cmd/thv-operator/pkg/vmcpconfig/converter.go:
// convertAuthServerConfig resolves spec.authServerConfigRef to an AuthServerConfig.// It fetches the MCPExternalAuthConfig, verifies its type, converts EmbeddedAuthServerConfig// to authserver.RunConfig, and derives allowedAudiences from the VirtualMCPServer's// incoming auth audience.func (c*Converter) convertAuthServerConfig(
ctx context.Context,
vmcp*mcpv1alpha1.VirtualMCPServer,
) (*vmcpconfig.AuthServerConfig, error)
Key mapping from EmbeddedAuthServerConfig to authserver.RunConfig (in the converter):
// Derive allowedAudiences from the VirtualMCPServer incoming auth config.// Note: AllowedAudiences is NOT a field on EmbeddedAuthServerConfig in the CRD.// It is always derived here so the CRD does not need to duplicate the audience value.varallowedAudiences []stringifvmcp.Spec.IncomingAuth!=nil&&vmcp.Spec.IncomingAuth.OIDCConfig!=nil&&vmcp.Spec.IncomingAuth.OIDCConfig.Inline!=nil&&vmcp.Spec.IncomingAuth.OIDCConfig.Inline.Audience!="" {
allowedAudiences= []string{vmcp.Spec.IncomingAuth.OIDCConfig.Inline.Audience}
}
runConfig:=&authserver.RunConfig{
Issuer: embeddedCfg.Issuer,
AllowedAudiences: allowedAudiences,
// ... map SigningKeySecretRefs, HMACSecretRefs, TokenLifespans, Storage, Upstreams
}
Testing Strategy
Unit Tests — HTTP Handler (pkg/vmcp/server/server_test.go or pkg/vmcp/server/handler_test.go)
Use httptest.NewRecorder() and httptest.NewRequest() to test Handler() without starting a real server. Use httptest.NewServer for integration-style route tests.
Mode A (nil AuthServer): GET /.well-known/oauth-protected-resource → 200
Mode A: GET /.well-known/openid-configuration → 404
Mode A: GET /.well-known/oauth-authorization-server → 404
Mode A: GET /.well-known/jwks.json → 404
Mode A: GET /oauth/token → 404
Mode B (non-nil AuthServer from NewEmbeddedAuthServer with ephemeral dev config): GET /.well-known/openid-configuration → 200 with Content-Type: application/json
Mode B: GET /.well-known/jwks.json → 200
Mode B: GET /.well-known/oauth-protected-resource → 200 (still served in Mode B)
Both modes: GET /mcp without bearer token → 401 when AuthMiddleware is set to a simple "reject all" middleware
CLI E2E Tests (test/e2e/vmcp_authserver_test.go)
Follow test/e2e/proxy_oauth_test.go exactly. Use OIDCMockServer as the upstream IDP for Mode B. Write YAML config files to a temp dir. Start the vMCP binary with exec.Command.
Mode B positive: OIDC discovery returns 200 with valid JSON (non-empty issuer, non-empty jwks_uri)
Mode B positive: unauthenticated MCP request returns 401
Follow virtualmcp_external_auth_test.go. Use Ordered suite with BeforeAll/AfterAll for resource cleanup.
Positive: MCPExternalAuthConfig + VirtualMCPServer created; Eventually asserts AuthServerConfigValid condition is True; Eventually asserts Deployment exists and is available
Negative: VirtualMCPServer with issuer mismatch; Eventually asserts AuthServerConfigValid condition is False with message containing "issuer"; phase is Failed; no Deployment exists for this VirtualMCPServer
Edge Cases
spec.authServerConfigRef nil (Mode A): validateAuthServerConfigRef is skipped; Convert() does not set config.AuthServer; no AuthServerConfigValid condition appears
Referenced MCPExternalAuthConfig not found: reconciler returns a transient error (triggers requeue), phase is set to Failed
Referenced MCPExternalAuthConfig has wrong type (e.g., "tokenExchange"): reconciler surfaces AuthServerConfigValid=False with a descriptive type mismatch message; phase is Failed; no requeue (spec error)
spec.incomingAuth.oidcConfig.inline.audience is empty: converter sets allowedAudiences = nil (not an empty slice); reconciler audience guard logs a warning but does not fail (defense-in-depth, not blocking)
Issuer mismatch (V-04): exact string comparison; trailing slash difference ("https://as.example.com" vs "https://as.example.com/") must trigger the mismatch error
Out of Scope
upstream_inject outgoing auth strategy implementation (deferred to a follow-up RFC — the constant, config struct, and validation were added in Phase 3 but the actual token injection middleware is not implemented in RFC-0053)
Full end-to-end token flow via identity.UpstreamTokens — this requires RFC-0052 (Auth Server: multi-upstream provider support #3924) to be merged; the E2E tests here cover Mode A/Mode B routing and the AuthServerConfigValid condition, not the full token issuance and upstream token population chain
Hot-reload of AS configuration (requires pod restart; out of scope for this RFC)
Multi-upstream IDP support beyond what UpstreamRunConfig.Name already provides (defined by RFC-0052)
Description
Wire the embedded authorization server into the Kubernetes operator and add comprehensive test coverage — unit tests for the vMCP HTTP handler, CLI E2E tests for Mode A/Mode B routing and negative validation, and operator E2E tests verifying the
AuthServerConfigValidstatus condition lifecycle. This is the final phase of RFC-0053 that closes the loop between the CRD API, the operator reconciler, the config converter, and observable runtime behavior.Context
RFC-0053 adds an optional embedded OAuth/OIDC authorization server to vMCP. Phase 1 (#4140) established structural types and CRD fields. Phase 2 (#4141) mounted the AS HTTP routes on the vMCP mux. Phase 3 (#4142) added startup validation rules V-01 through V-07. Phase 4 (this task) completes the Kubernetes operator path: the reconciler resolves
spec.authServerConfigRef, performs cross-resource validation, surfaces theAuthServerConfigValidstatus condition, and the config converter converts the referencedMCPExternalAuthConfiginto theAuthServerConfigthat vMCP reads at startup. This phase also delivers all deferred test coverage and documentation updates.Parent epic: #4120 — vMCP: add embedded authorization server
RFC document:
docs/proposals/THV-0053-vmcp-embedded-authserver.mdDependencies: #4141 (Phase 2: Server wiring), #4142 (Phase 3: Startup validation)
Blocks: nothing — this is the final phase. Full end-to-end token flow testing depends on RFC-0052 (#3924) for
identity.UpstreamTokenspopulation.Acceptance Criteria
Operator Reconciler
cmd/thv-operator/controllers/virtualmcpserver_controller.gorunValidations()includes a newvalidateAuthServerConfigRefstep that, whenspec.authServerConfigRefis non-nil: fetches the referencedMCPExternalAuthConfigfrom the same namespace; verifies itsspec.typeis"embeddedAuthServer"(surfacing aFailedphase with descriptive message if not); verifiesspec.embeddedAuthServer.issuermatchesspec.incomingAuth.oidcConfig.inline.issuer(V-04 check, surfacingFailedphase with message containing"issuer"and"mismatch"); and verifies that the audience derived fromspec.incomingAuth.oidcConfig.inline.audienceis non-empty (defense-in-depth empty-audience guard)ConditionTypeAuthServerConfigValid = "AuthServerConfigValid"constant is defined incmd/thv-operator/api/v1alpha1/virtualmcpserver_types.goauthServerConfigRefvalidation,statusManager.SetCondition(ConditionTypeAuthServerConfigValid, "AuthServerConfigValid", "Auth server config is valid", metav1.ConditionTrue)is calledstatusManager.SetCondition(ConditionTypeAuthServerConfigValid, "AuthServerConfigInvalid", <descriptive message>, metav1.ConditionFalse)is called and the phase is set toFailed; reconciliation does not proceed toensureAllResourcesspec.authServerConfigRefis nil (Mode A),validateAuthServerConfigRefis skipped entirely and noAuthServerConfigValidcondition is setConfig Converter
cmd/thv-operator/pkg/vmcpconfig/converter.goConvert()includes a newconvertAuthServerConfigstep: whenvmcp.Spec.AuthServerConfigRef != nil, fetches theMCPExternalAuthConfig, verifies type is"embeddedAuthServer", converts theEmbeddedAuthServerConfigtoauthserver.RunConfig, derivesallowedAudiencesfromvmcp.Spec.IncomingAuth.OIDCConfig.Inline.Audience, setsconfig.AuthServer = &vmcpconfig.AuthServerConfig{RunConfig: runConfig}, and returns an error on any failurespec.authServerConfigRefis nil (Mode A),config.AuthServerremains nil (unchanged from the deep-copied spec config)allowedAudiencesfromspec.incomingAuth.oidcConfig.inline.audienceand does NOT read anyallowedAudiencesfield fromMCPExternalAuthConfig(the CRD intentionally omits that field)HTTP Handler Unit Tests
pkg/vmcp/server/server_test.go(orpkg/vmcp/server/handler_test.go) contains unit tests for the serverHandler()method covering Mode A routing:GET /.well-known/oauth-protected-resourcereturns HTTP 200;GET /.well-known/openid-configurationreturns HTTP 404;GET /.well-known/oauth-authorization-serverreturns HTTP 404;GET /.well-known/jwks.jsonreturns HTTP 404;GET /oauth/tokenreturns HTTP 404GET /.well-known/openid-configurationis served by the AS handler (HTTP 200 with JSON body);GET /.well-known/oauth-authorization-serveris served by the AS handler (HTTP 200);GET /.well-known/jwks.jsonis served by the AS handler (HTTP 200);GET /oauth/authorizeis served by the AS handler (not 404)GET /.well-known/oauth-protected-resourcereturns HTTP 200 (explicit registration, not catch-all)GET /mcpwithout a bearer token returns HTTP 401 whenAuthMiddlewareis configuredvMCP CLI E2E Tests
test/e2e/vmcp_authserver_test.goexists and is in packagee2e_test; it joins the existing Ginkgo suite intest/e2e/e2e_suite_test.goautomaticallyOIDCMockServeras the upstream IDP);GET /.well-known/openid-configurationreturns HTTP 200 with a JSON body containing a non-emptyissuerfield matching the configured AS issuerGET /mcp(or configured endpoint path) without a bearer token returns HTTP 401 (auth middleware active)authServerblock;GET /.well-known/openid-configurationreturns HTTP 404;GET /.well-known/oauth-protected-resourcereturns HTTP 200authServer.runConfig.issuerdifferent fromincomingAuth.oidc.issuer; process exits non-zero; stderr contains a string matching"issuer"and"mismatch"(or the V-04 message text)upstream_injectwithout AS: start vMCP with anupstream_injectbackend auth strategy but noauthServerblock; process exits non-zero; stderr contains a string referencing V-01 or"upstream_inject"and"authServer"upstream_injectstrategy whoseproviderNamedoes not match any upstream inauthServer.runConfig.upstreams; process exits non-zero; stderr contains a string referencing the unknown provider nameOperator E2E Tests
test/e2e/thv-operator/virtualmcp/virtualmcp_authserver_test.goexists in packagevirtualmcp; it joins the existing Ginkgo suite intest/e2e/thv-operator/virtualmcp/suite_test.goMCPExternalAuthConfigwithspec.type: embeddedAuthServerand a validspec.embeddedAuthServerconfiguration (with an upstream provider pointing to an in-cluster mock OIDC server or a test fixture)VirtualMCPServerwithspec.authServerConfigRef.namepointing to the aboveMCPExternalAuthConfig;spec.incomingAuth.oidcConfig.inline.issuermatches theembeddedAuthServer.issuerEventuallyassertion verifies theAuthServerConfigValidcondition transitions toTrue(statusConditionTrue) within the test timeoutvirtualmcp_external_auth_test.go)VirtualMCPServerwith an issuer mismatch;Eventuallyassertion verifies theAuthServerConfigValidcondition transitions toFalsewith a message containing"issuer"; phase isFailed; no Deployment is created for this VirtualMCPServerDocumentation
docs/arch/09-operator-architecture.mdis updated to document the newAuthServerConfigReffield onVirtualMCPServerSpec, thevalidateAuthServerConfigRefreconciliation step, and theAuthServerConfigValidstatus conditiondocs/arch/02-core-concepts.mdis updated to describe the Mode A / Mode B distinction for vMCP auth (embedded AS vs. external IDP)docs/(e.g.,docs/vmcp-auth-server.md) covering: config walkthrough for both CLI YAML and Kubernetes CRD paths; example YAML snippets; explanation of thejwksAllowPrivateIP: truerequirement for in-cluster loopback OIDC discovery; troubleshooting the self-referencing OIDC discovery setuptask docsis run to regenerate CRD reference documentation after any CRD changesGeneral
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.and// SPDX-License-Identifier: Apache-2.0headerstask lintpasses (ortask lint-fixresolves all linting issues)task testpasses (unit tests)AuthServerand nilAuthServerConfigRefmust not be affected)Technical Approach
Recommended Implementation
Work in four coordinated steps:
Step 1 — Reconciler validation (
cmd/thv-operator/controllers/virtualmcpserver_controller.go): Add avalidateAuthServerConfigRefmethod following the same structure asvalidateCompositeToolRefsandvalidateGroupRef. It is called insiderunValidations()only whenvmcp.Spec.AuthServerConfigRef != nil. The method usesr.Getto fetch theMCPExternalAuthConfigby name and namespace. After verifyingspec.type == "embeddedAuthServer", perform the V-04 issuer consistency check (exact string comparison — no URL normalization). UsestatusManager.SetCondition(ConditionTypeAuthServerConfigValid, ...)to surface results; on failure also callstatusManager.SetPhase(VirtualMCPServerPhaseFailed)andstatusManager.SetMessage(...). On validation failure, return(false, nil)fromrunValidationsto stop reconciliation without requeueing (user must fix spec).Step 2 — Converter step (
cmd/thv-operator/pkg/vmcpconfig/converter.go): Add aconvertAuthServerConfigprivate method that acceptsctx,vmcp, and the resolvedMCPExternalAuthConfig. It convertsEmbeddedAuthServerConfigfields to anauthserver.RunConfig, derivesallowedAudiencesfromvmcp.Spec.IncomingAuth.OIDCConfig.Inline.Audience, and returns*vmcpconfig.AuthServerConfig. Call this method fromConvert()after the existingconvertIncomingAuthblock, gated onvmcp.Spec.AuthServerConfigRef != nil. The converter must also resolve theMCPExternalAuthConfigby callingc.k8sClient.Get(see existingconvertBackendAuthConfigpattern for the Get call structure).Step 3 — Unit tests: Add
pkg/vmcp/server/server_test.go(or extend the existing file) with table-driven tests forHandler(). Construct a minimalConfigwith a mockAuthMiddleware(usinghttptest.NewRecorderandhttptest.NewRequest). For Mode B tests, create anEmbeddedAuthServerviarunner.NewEmbeddedAuthServer(ctx, runConfig)with a minimal dev-mode config (ephemeral keys, in-memory storage). After each test, deferas.Close(). Usehttptest.NewServer(handler)to get a testable HTTP server. Assert HTTP status codes for each path.Step 4 — E2E tests: For CLI E2E tests in
test/e2e/vmcp_authserver_test.go, followtest/e2e/proxy_oauth_test.goexactly —Describe+BeforeEach/AfterEach,By()annotations,Eventuallyfor async assertions. Start aOIDCMockServeras the upstream IDP and write YAML config files tot.TempDir(). For negative tests, start the vMCP binary and assert non-zero exit code and stderr content usingexec.Command. For operator E2E tests intest/e2e/thv-operator/virtualmcp/virtualmcp_authserver_test.go, followvirtualmcp_external_auth_test.go— usek8sClient.Create,Eventuallywithk8sClient.Getto assert condition status.Patterns & Frameworks
runValidationspattern: Follow the exact structure ofvalidateCompositeToolRefsinvirtualmcpserver_controller.go— return(false, nil)on user-fixable spec errors (to stop reconciliation without requeueing), return(false, err)for transient errors that should trigger requeue (e.g., API server unavailable)statusManager.SetCondition(conditionType, reason, message, status)directly (as invalidateAndUpdatePodTemplateStatus) rather than a specialized helper, sinceAuthServerConfigValiddoes not warrant a dedicated method onStatusManagerconvertBackendAuthConfiginconverter.go— usec.k8sClient.Getwithtypes.NamespacedName, checkerrors.IsNotFoundseparately from other errors, returnfmt.Errorfwith%womitemptyand nil safety: All code paths gated onAuthServerConfigRef != nil; Mode A must pass zero new lines when the field is absent==for issuer comparison; no URL normalization applied. Operators must use identical strings in both configs// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.and// SPDX-License-Identifier: Apache-2.0. Usetask license-fixto add missing headers automaticallyrequire.NoError(t, err)for test assertions: Userequirefromgithub.com/stretchr/testify/requirerather thant.Fatalin unit tests; useExpect(...).To(Succeed())in operator E2E tests (Gomega)Describe,BeforeAll/AfterAll(for Ordered suites),By()annotations, andEventually(...).Within(...).ProbeEvery(...)for async assertions. Follow the existing operator E2E test structure intest/e2e/thv-operator/virtualmcp/Code Pointers
cmd/thv-operator/controllers/virtualmcpserver_controller.go—runValidations()(line 276): add the newvalidateAuthServerConfigRefcall here, aftervalidateEmbeddingServerRef. Pattern for the new method: followvalidateCompositeToolRefs(line 385) for structure; followvalidateGroupRef(line 327) for theSetPhase/SetMessage/SetConditioncall patterncmd/thv-operator/api/v1alpha1/virtualmcpserver_types.go— condition type constants block (line 209): addConditionTypeAuthServerConfigValid = "AuthServerConfigValid"alongside existing constantscmd/thv-operator/pkg/vmcpconfig/converter.go—Convert()(line 68): add theconvertAuthServerConfigstep after theconvertIncomingAuthblock (line 81). Pattern: followconvertBackendAuthConfig(line 283) for thek8sClient.Getcall structurecmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_types.go—EmbeddedAuthServerConfigstruct (line 152): source of fields to map toauthserver.RunConfig; noteAllowedAudiencesis absent by design (derived from the referencing VirtualMCPServer)pkg/authserver/config.go—authserver.RunConfig: target type for the converter; key fields:Issuer string,AllowedAudiences []string,Upstreams []UpstreamRunConfigpkg/vmcp/server/server.go—Configstruct (line 83),Handler()method (line ~444): target for unit tests; theAuthServer *runner.EmbeddedAuthServerfield added in Phase 2 (Phase 2: Server wiring — mount embedded auth server routes on vMCP mux #4141) is what Mode B tests populatepkg/authserver/runner/embeddedauthserver.go—NewEmbeddedAuthServer(ctx, *authserver.RunConfig)andRegisterHandlers(mux)(added in Phase 2: Server wiring — mount embedded auth server routes on vMCP mux #4141): used to construct Mode B test fixturestest/e2e/proxy_oauth_test.go— reference pattern for CLI E2E tests:OIDCMockServer, process start/stop,Eventually,By()annotationstest/e2e/oidc_mock.go—OIDCMockServer: reusable mock OIDC server backed by Ory Fosite; use as the upstream IDP in Mode B E2E teststest/e2e/thv-operator/virtualmcp/virtualmcp_external_auth_test.go— reference pattern for operator E2E tests:k8sClient.Create,Eventuallywith condition assertions,BeforeAll/AfterAllcleanuptest/e2e/thv-operator/virtualmcp/helpers.go— shared helpers for operator E2E tests:CreateMCPGroupAndWait,CreateMockHTTPServer; check what is available before writing new helpersComponent Interfaces
New constant in
cmd/thv-operator/api/v1alpha1/virtualmcpserver_types.go:New method on
VirtualMCPServerReconcilerincmd/thv-operator/controllers/virtualmcpserver_controller.go:New private method on
Converterincmd/thv-operator/pkg/vmcpconfig/converter.go:Key mapping from
EmbeddedAuthServerConfigtoauthserver.RunConfig(in the converter):Testing Strategy
Unit Tests — HTTP Handler (
pkg/vmcp/server/server_test.goorpkg/vmcp/server/handler_test.go)Use
httptest.NewRecorder()andhttptest.NewRequest()to testHandler()without starting a real server. Usehttptest.NewServerfor integration-style route tests.AuthServer):GET /.well-known/oauth-protected-resource→ 200GET /.well-known/openid-configuration→ 404GET /.well-known/oauth-authorization-server→ 404GET /.well-known/jwks.json→ 404GET /oauth/token→ 404AuthServerfromNewEmbeddedAuthServerwith ephemeral dev config):GET /.well-known/openid-configuration→ 200 withContent-Type: application/jsonGET /.well-known/jwks.json→ 200GET /.well-known/oauth-protected-resource→ 200 (still served in Mode B)GET /mcpwithout bearer token → 401 whenAuthMiddlewareis set to a simple "reject all" middlewareCLI E2E Tests (
test/e2e/vmcp_authserver_test.go)Follow
test/e2e/proxy_oauth_test.goexactly. UseOIDCMockServeras the upstream IDP for Mode B. Write YAML config files to a temp dir. Start the vMCP binary withexec.Command.issuer, non-emptyjwks_uri)oauth-protected-resourcereturns 200upstream_inject/ AS required messageOperator E2E Tests (
test/e2e/thv-operator/virtualmcp/virtualmcp_authserver_test.go)Follow
virtualmcp_external_auth_test.go. UseOrderedsuite withBeforeAll/AfterAllfor resource cleanup.MCPExternalAuthConfig+VirtualMCPServercreated;EventuallyassertsAuthServerConfigValidcondition isTrue;Eventuallyasserts Deployment exists and is availableVirtualMCPServerwith issuer mismatch;EventuallyassertsAuthServerConfigValidcondition isFalsewith message containing"issuer"; phase isFailed; no Deployment exists for this VirtualMCPServerEdge Cases
spec.authServerConfigRefnil (Mode A):validateAuthServerConfigRefis skipped;Convert()does not setconfig.AuthServer; noAuthServerConfigValidcondition appearsMCPExternalAuthConfignot found: reconciler returns a transient error (triggers requeue), phase is set toFailedMCPExternalAuthConfighas wrong type (e.g.,"tokenExchange"): reconciler surfacesAuthServerConfigValid=Falsewith a descriptive type mismatch message; phase isFailed; no requeue (spec error)spec.incomingAuth.oidcConfig.inline.audienceis empty: converter setsallowedAudiences = nil(not an empty slice); reconciler audience guard logs a warning but does not fail (defense-in-depth, not blocking)"https://as.example.com"vs"https://as.example.com/") must trigger the mismatch errorOut of Scope
upstream_injectoutgoing auth strategy implementation (deferred to a follow-up RFC — the constant, config struct, and validation were added in Phase 3 but the actual token injection middleware is not implemented in RFC-0053)identity.UpstreamTokens— this requires RFC-0052 (Auth Server: multi-upstream provider support #3924) to be merged; the E2E tests here cover Mode A/Mode B routing and theAuthServerConfigValidcondition, not the full token issuance and upstream token population chainUpstreamRunConfig.Namealready provides (defined by RFC-0052)pkg/auth/upstreamswapmiddleware)References
docs/proposals/THV-0053-vmcp-embedded-authserver.mdcmd/thv-operator/controllers/virtualmcpserver_controller.gocmd/thv-operator/api/v1alpha1/virtualmcpserver_types.gocmd/thv-operator/pkg/vmcpconfig/converter.goEmbeddedAuthServerConfig(source type for converter):cmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_types.goauthserver.RunConfig(target type for converter):pkg/authserver/config.gopkg/vmcp/server/server.goEmbeddedAuthServer(for unit test fixtures):pkg/authserver/runner/embeddedauthserver.gotest/e2e/thv-operator/virtualmcp/virtualmcp_external_auth_test.gotest/e2e/proxy_oauth_test.gotest/e2e/oidc_mock.godocs/arch/09-operator-architecture.md,docs/arch/02-core-concepts.md