Skip to content

Commit 67bfd98

Browse files
ChrisJBurnsclaude
andauthored
Add vMCP MCPServerEntry dynamic mode reconciler (#4710)
* Add vMCP MCPServerEntry dynamic mode reconciler Enable vMCP dynamic mode to watch MCPServerEntry resources at runtime, automatically adding/removing them as backends without restart. - Add CABundleData []byte to Backend and BackendTarget for dynamic mode CA bundle support (fetched from K8s ConfigMaps, not volume-mounted) - Extend newBackendTransport to accept CA cert bytes alongside file path, with data taking precedence over path - Set Backend.Type = BackendTypeEntry in mcpServerEntryToBackend() - Add fetchCABundleData() to read CA PEM from ConfigMap via CABundleRef - Extend fetchBackendResource() to try MCPServerEntry as third type - Add MCPServerEntry watch with groupRef filtering in SetupWithManager() - Add MCPServerEntry to ExternalAuthConfig change handler - Add ConfigMap watch for CA bundle changes affecting entry backends Closes #4659 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Address review feedback on dynamic mode reconciler - F1: Make CA bundle fetch failure fatal — return nil to exclude backend when explicitly configured caBundleRef can't be loaded (matches auth config failure pattern, prevents silent TLS trust degradation) - F2: Add field index for ConfigMap→MCPServerEntry lookup via SetupIndexes() — replaces full List+filter with indexed cache query - F3: Restore source context in CA parse error messages (file path or "inline data") for operator debuggability - F4: Add table-driven tests for fetchCABundleData covering all 5 code paths (nil ref, not found, key missing, default key, explicit key) - F5: Extract MapAuthConfigToEntries() as exported method with 4 test cases covering group/auth config matching and filtering - F6: Update architecture docs (09-operator, 10-virtual-mcp) to document MCPServerEntry discovery, ConfigMap watching, and field-indexed lookup Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Add integration tests for MCPServerEntry reconciler and CA bundle Integration tests (Ginkgo+envtest) for BackendReconciler MCPServerEntry lifecycle: creation with matching groupRef adds backend to registry, mismatched groupRef excludes backend, deletion removes backend, and registry version increments on events. Unlike MCPServer/MCPRemoteProxy, MCPServerEntry uses Spec.RemoteURL directly so backends actually appear in the registry during envtest. Workload conversion tests verify BackendTypeEntry is set, CA bundle data is fetched from ConfigMap, missing ConfigMap causes fatal failure (returns nil), and empty Key defaults to "ca.crt". Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 1b8478f commit 67bfd98

12 files changed

Lines changed: 959 additions & 25 deletions

docs/arch/09-operator-architecture.md

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ MCPServer is the fundamental building block. All other CRDs either **organize**,
5757
│ │ │ │ CORE │ │ │ │
5858
│ │ │ │ MCPServer │ │ │ │
5959
│ │ │ │ MCPRemoteProxy │ │ │ │
60+
│ │ │ │ MCPServerEntry │ │ │ │
6061
│ │ │ └───────────────────┘ │ │ │
6162
│ │ └─────────────────────────┘ │ │
6263
│ └───────────────────────────────┘ │
@@ -71,7 +72,7 @@ MCPServer is the fundamental building block. All other CRDs either **organize**,
7172

7273
| Layer | CRDs | Purpose |
7374
|-------|------|---------|
74-
| **Core** | MCPServer, MCPRemoteProxy | Run or proxy MCP servers |
75+
| **Core** | MCPServer, MCPRemoteProxy, MCPServerEntry | Run, proxy, or declare MCP servers |
7576
| **Organization** | MCPGroup | Group related servers together |
7677
| **Aggregation** | VirtualMCPServer, VirtualMCPCompositeToolDefinition | Combine multiple servers into one endpoint |
7778
| **Discovery** | MCPRegistry | Help clients find available servers |
@@ -90,6 +91,7 @@ MCPServer is the fundamental building block. All other CRDs either **organize**,
9091

9192
| CRD | Purpose |
9293
|-----|---------|
94+
| **MCPServerEntry** | Zero-infrastructure declaration of a remote MCP endpoint |
9395
| **MCPGroup** | Logical grouping of workloads (status tracking only) |
9496
| **ToolConfig** | Tool filtering and renaming configuration |
9597
| **MCPExternalAuthConfig** | Token exchange / header injection configuration |
@@ -108,6 +110,10 @@ graph TB
108110
Registry[MCPRegistry<br/>Deployment: API server]
109111
end
110112
113+
subgraph "Zero-Infrastructure"
114+
Entry[MCPServerEntry<br/>No resources]
115+
end
116+
111117
subgraph "Logical Grouping"
112118
Group[MCPGroup<br/>No resources]
113119
end
@@ -134,6 +140,10 @@ graph TB
134140
Proxy -.->|externalAuthConfigRef| ExtAuth
135141
Proxy -.->|toolConfigRef| ToolCfg
136142
Proxy -.->|oidcConfigRef| OIDCCfg
143+
144+
Entry -->|groupRef| Group
145+
Entry -.->|externalAuthConfigRef| ExtAuth
146+
Entry -.->|caBundleRef| ConfigMap[ConfigMap<br/>CA bundle]
137147
```
138148

139149
### MCPServer
@@ -257,6 +267,24 @@ Defines a proxy for remote MCP servers with authentication, authorization, audit
257267

258268
**Controller**: `cmd/thv-operator/controllers/mcpremoteproxy_controller.go`
259269

270+
### MCPServerEntry
271+
272+
Declares a remote MCP endpoint as a zero-infrastructure catalog entry. Unlike MCPServer and MCPRemoteProxy, MCPServerEntry never creates a Deployment, Service, or Pod. vMCP connects directly to the declared remote URL.
273+
274+
**Key fields:**
275+
- `remoteURL` - URL of the remote MCP server (required)
276+
- `groupRef` - MCPGroup membership for discovery by VirtualMCPServer
277+
- `externalAuthConfigRef` - Token exchange for remote service authentication
278+
- `caBundleRef` - Reference to a ConfigMap containing CA certificate data for TLS verification
279+
280+
The MCPServerEntry controller is validation-only: it validates that referenced resources (groupRef, externalAuthConfigRef, caBundleRef ConfigMap) exist and updates status conditions accordingly. It never probes the remote URL or creates infrastructure.
281+
282+
MCPServerEntry backends are discovered by vMCP in both static mode (listed at startup) and dynamic mode (watched by the BackendReconciler). In dynamic mode, ConfigMap changes trigger re-reconciliation of affected MCPServerEntry backends via a field-indexed watch on `spec.caBundleRef.configMapRef.name`.
283+
284+
**Implementation**: `cmd/thv-operator/api/v1alpha1/mcpserverentry_types.go`
285+
286+
**Controller**: `cmd/thv-operator/controllers/mcpserverentry_controller.go`
287+
260288
### MCPGroup
261289

262290
Logically groups MCPServer resources together for organizational purposes.

docs/arch/10-virtual-mcp-architecture.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ graph TB
4141
B1[MCPServer]
4242
B2[MCPServer]
4343
B3[MCPRemoteProxy]
44+
B4[MCPServerEntry]
4445
end
4546
4647
Client[MCP Client] --> Server
@@ -54,9 +55,11 @@ graph TB
5455
BackendClient --> B1
5556
BackendClient --> B2
5657
BackendClient --> B3
58+
BackendClient --> B4
5759
Health --> B1
5860
Health --> B2
5961
Health --> B3
62+
Health --> B4
6063
6164
style Server fill:#90caf9
6265
style Aggregator fill:#81c784
@@ -85,17 +88,20 @@ graph LR
8588
Group -->|contains| S1[MCPServer]
8689
Group -->|contains| S2[MCPServer]
8790
Group -->|contains| R1[MCPRemoteProxy]
91+
Group -->|contains| E1[MCPServerEntry]
8892
8993
style vMCP fill:#90caf9
9094
style Group fill:#ba68c8
9195
```
9296

9397
**Discovery process:**
9498
1. VirtualMCPServer references an MCPGroup by name
95-
2. All MCPServers and MCPRemoteProxies in that group are discovered
99+
2. All MCPServers, MCPRemoteProxies, and MCPServerEntries in that group are discovered
96100
3. For each backend, URL, transport type, and auth config are extracted
97101
4. vMCP queries each backend for available tools, resources, and prompts
98102

103+
MCPServerEntry backends connect directly to remote MCP servers without deploying a proxy pod. They are zero-infrastructure catalog entries that declare a remote endpoint URL, optional external auth, and an optional CA bundle for TLS verification. CA bundle data is fetched from Kubernetes ConfigMaps at discovery time. In dynamic mode, the BackendReconciler watches ConfigMap changes and uses a field index on `spec.caBundleRef.configMapRef.name` to efficiently re-reconcile only the MCPServerEntry backends affected by a given ConfigMap update.
104+
99105
**Implementation**: `pkg/vmcp/aggregator/`
100106

101107
## Aggregation Pipeline

pkg/vmcp/client/client.go

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -93,8 +93,12 @@ func NewHTTPBackendClient(registry vmcpauth.OutgoingAuthRegistry) (vmcp.BackendC
9393
//
9494
// If caBundlePath is non-empty, a custom TLS configuration is applied that trusts both
9595
// the system root CAs and the certificate(s) in the specified file. This is used for
96-
// entry-type backends with self-signed or internal CA certificates.
97-
func newBackendTransport(caBundlePath string) (*http.Transport, error) {
96+
// entry-type backends with self-signed or internal CA certificates (static mode).
97+
//
98+
// If caBundleData is non-empty, the raw PEM bytes are used directly instead of reading
99+
// from a file. This is used in dynamic mode where CA bundles are fetched from K8s
100+
// ConfigMaps at discovery time. caBundleData takes precedence over caBundlePath.
101+
func newBackendTransport(caBundlePath string, caBundleData []byte) (*http.Transport, error) {
98102
var t *http.Transport
99103
if dt, ok := http.DefaultTransport.(*http.Transport); ok {
100104
t = dt.Clone()
@@ -116,20 +120,32 @@ func newBackendTransport(caBundlePath string) (*http.Transport, error) {
116120
}
117121
}
118122

119-
if caBundlePath != "" {
120-
caCert, err := os.ReadFile(caBundlePath) //nolint:gosec // CA bundle path is validated by config validator (no path traversal)
123+
// Resolve CA certificate PEM data: caBundleData takes precedence over caBundlePath
124+
var caPEM []byte
125+
switch {
126+
case len(caBundleData) > 0:
127+
caPEM = caBundleData
128+
case caBundlePath != "":
129+
var err error
130+
caPEM, err = os.ReadFile(caBundlePath) //nolint:gosec // CA bundle path is validated by config validator (no path traversal)
121131
if err != nil {
122132
return nil, fmt.Errorf("failed to read CA bundle from %s: %w", caBundlePath, err)
123133
}
134+
}
124135

136+
if len(caPEM) > 0 {
125137
caCertPool, err := x509.SystemCertPool()
126138
if err != nil {
127139
// Fall back to empty pool if system certs can't be loaded
128140
caCertPool = x509.NewCertPool()
129141
}
130142

131-
if !caCertPool.AppendCertsFromPEM(caCert) {
132-
return nil, fmt.Errorf("failed to parse CA certificate from %s", caBundlePath)
143+
if !caCertPool.AppendCertsFromPEM(caPEM) {
144+
source := "inline data"
145+
if len(caBundleData) == 0 && caBundlePath != "" {
146+
source = caBundlePath
147+
}
148+
return nil, fmt.Errorf("failed to parse CA certificate from %s", source)
133149
}
134150

135151
if t.TLSClientConfig == nil {
@@ -238,7 +254,7 @@ func (h *httpBackendClient) defaultClientFactory(ctx context.Context, target *vm
238254
//
239255
// Clone DefaultTransport per call so each client gets an isolated connection pool,
240256
// preventing stale keep-alive connections from one backend affecting others.
241-
httpTransport, err := newBackendTransport(target.CABundlePath)
257+
httpTransport, err := newBackendTransport(target.CABundlePath, target.CABundleData)
242258
if err != nil {
243259
return nil, fmt.Errorf("failed to create transport for backend %s: %w", target.WorkloadID, err)
244260
}

pkg/vmcp/client/client_test.go

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,9 +108,9 @@ func TestQueryHelpers_PartialCapabilities(t *testing.T) {
108108
func TestNewBackendTransport_IsolatesFromDefault(t *testing.T) {
109109
t.Parallel()
110110

111-
t1, err1 := newBackendTransport("")
111+
t1, err1 := newBackendTransport("", nil)
112112
require.NoError(t, err1)
113-
t2, err2 := newBackendTransport("")
113+
t2, err2 := newBackendTransport("", nil)
114114
require.NoError(t, err2)
115115

116116
// Each call must return a distinct transport — not the shared DefaultTransport.
@@ -147,6 +147,7 @@ func TestNewBackendTransport_CustomCA(t *testing.T) {
147147
tests := []struct {
148148
name string
149149
setupFile func(t *testing.T) string
150+
caBundleData []byte
150151
expectError bool
151152
errorContains string
152153
checkResult func(t *testing.T, tr *http.Transport)
@@ -207,14 +208,53 @@ func TestNewBackendTransport_CustomCA(t *testing.T) {
207208
expectError: true,
208209
errorContains: "failed to parse CA certificate",
209210
},
211+
{
212+
name: "valid CA data bytes applies custom TLS config",
213+
setupFile: func(t *testing.T) string {
214+
t.Helper()
215+
return "" // no file path
216+
},
217+
caBundleData: generateTestCACert(t),
218+
expectError: false,
219+
checkResult: func(t *testing.T, tr *http.Transport) {
220+
t.Helper()
221+
require.NotNil(t, tr.TLSClientConfig)
222+
assert.Equal(t, uint16(tls.VersionTLS12), tr.TLSClientConfig.MinVersion)
223+
assert.NotNil(t, tr.TLSClientConfig.RootCAs)
224+
},
225+
},
226+
{
227+
name: "invalid CA data bytes returns error",
228+
setupFile: func(t *testing.T) string {
229+
t.Helper()
230+
return "" // no file path
231+
},
232+
caBundleData: []byte("not-a-cert"),
233+
expectError: true,
234+
errorContains: "failed to parse CA certificate from inline data",
235+
},
236+
{
237+
name: "CA data takes precedence over file path",
238+
setupFile: func(t *testing.T) string {
239+
t.Helper()
240+
return "/nonexistent/path.crt" // file doesn't exist but shouldn't be read
241+
},
242+
caBundleData: generateTestCACert(t),
243+
expectError: false,
244+
checkResult: func(t *testing.T, tr *http.Transport) {
245+
t.Helper()
246+
require.NotNil(t, tr.TLSClientConfig)
247+
assert.NotNil(t, tr.TLSClientConfig.RootCAs)
248+
},
249+
},
210250
}
211251

212252
for _, tt := range tests {
213253
t.Run(tt.name, func(t *testing.T) {
214254
t.Parallel()
215255

216256
caPath := tt.setupFile(t)
217-
tr, err := newBackendTransport(caPath)
257+
tr, err := newBackendTransport(caPath, tt.caBundleData)
218258

219259
if tt.expectError {
220260
require.Error(t, err)

0 commit comments

Comments
 (0)