Skip to content

Commit a4e4c1c

Browse files
ChrisJBurnsclaude
andauthored
Add vMCP MCPServerEntry static backend support (#4707)
* Add vMCP MCPServerEntry static backend support Enable vMCP static mode to parse and connect to MCPServerEntry-type backends from ConfigMap configuration, routing MCP traffic directly to remote servers without proxy pods. - Add BackendType constants (container, proxy, entry) to vmcp types - Extend StaticBackendConfig with Type and CABundlePath fields - Propagate entry backend fields through discoverer to Backend - Add custom CA bundle support to HTTP client transport (per-backend) - Add config validation for entry backend type and CA bundle paths - Add unit tests for all new functionality (19 test cases) Closes #4658 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Address review feedback: harden path validation and TLS config - Strengthen CA bundle path validation with null byte check, path traversal rejection, and absolute path requirement to match existing patterns in pkg/git/client.go and pkg/server/discovery/ - Clone TLS config instead of replacing it to preserve existing settings (e.g. NextProtos for HTTP/2 ALPN) from the cloned transport - Add backward-compatibility comments to unused BackendType constants - Add test cases for null bytes and relative paths in CA bundle path Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Remove duplicate CABundlePath from StaticBackendConfig Main gained a CABundlePath field (after Metadata) via #4698 while this branch added one (before Metadata, alongside Type). Remove the duplicate to fix the build. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Regenerate CRD manifests and API docs Update VirtualMCPServer CRD schema to include the new Type field on StaticBackendConfig. Regenerated with task operator-manifests and task crdref-gen. 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 96bee39 commit a4e4c1c

13 files changed

Lines changed: 552 additions & 33 deletions

File tree

deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -842,9 +842,9 @@ spec:
842842
properties:
843843
caBundlePath:
844844
description: |-
845-
CABundlePath is the file path to the CA certificate bundle for TLS verification.
846-
Set by the operator when an MCPServerEntry has a caBundleRef, pointing to the
847-
mounted ConfigMap volume path (e.g., /etc/toolhive/ca-bundles/<entry-name>/ca.crt).
845+
CABundlePath is the file path to a custom CA certificate bundle for TLS verification.
846+
Only valid when Type is "entry". The operator mounts CA bundles at
847+
/etc/toolhive/ca-bundles/<name>/ca.crt.
848848
type: string
849849
metadata:
850850
additionalProperties:
@@ -868,6 +868,14 @@ spec:
868868
- sse
869869
- streamable-http
870870
type: string
871+
type:
872+
description: |-
873+
Type is the backend workload type: "entry" for MCPServerEntry backends, or empty
874+
for container/proxy backends. Entry backends connect directly to remote MCP servers.
875+
enum:
876+
- entry
877+
- ""
878+
type: string
871879
url:
872880
description: URL is the backend's MCP server base URL.
873881
pattern: ^https?://

deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -845,9 +845,9 @@ spec:
845845
properties:
846846
caBundlePath:
847847
description: |-
848-
CABundlePath is the file path to the CA certificate bundle for TLS verification.
849-
Set by the operator when an MCPServerEntry has a caBundleRef, pointing to the
850-
mounted ConfigMap volume path (e.g., /etc/toolhive/ca-bundles/<entry-name>/ca.crt).
848+
CABundlePath is the file path to a custom CA certificate bundle for TLS verification.
849+
Only valid when Type is "entry". The operator mounts CA bundles at
850+
/etc/toolhive/ca-bundles/<name>/ca.crt.
851851
type: string
852852
metadata:
853853
additionalProperties:
@@ -871,6 +871,14 @@ spec:
871871
- sse
872872
- streamable-http
873873
type: string
874+
type:
875+
description: |-
876+
Type is the backend workload type: "entry" for MCPServerEntry backends, or empty
877+
for container/proxy backends. Entry backends connect directly to remote MCP servers.
878+
enum:
879+
- entry
880+
- ""
881+
type: string
874882
url:
875883
description: URL is the backend's MCP server base URL.
876884
pattern: ^https?://

docs/operator/crd-api.md

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/vmcp/aggregator/discoverer.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,8 @@ func (d *backendDiscoverer) discoverFromStaticConfig() []vmcp.Backend {
269269
Name: staticBackend.Name,
270270
BaseURL: staticBackend.URL,
271271
TransportType: staticBackend.Transport,
272+
Type: vmcp.BackendType(staticBackend.Type),
273+
CABundlePath: staticBackend.CABundlePath,
272274
HealthStatus: vmcp.BackendHealthy, // Assume healthy, actual health check happens later
273275
Metadata: staticBackend.Metadata,
274276
}

pkg/vmcp/aggregator/discoverer_test.go

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1309,6 +1309,116 @@ func TestStaticBackendDiscoverer_MetadataGroupOverride(t *testing.T) {
13091309
}
13101310
}
13111311

1312+
// TestStaticBackendDiscoverer_EntryBackendFields verifies that the Type and CABundlePath
1313+
// fields from StaticBackendConfig are correctly propagated to the vmcp.Backend.
1314+
func TestStaticBackendDiscoverer_EntryBackendFields(t *testing.T) {
1315+
t.Parallel()
1316+
1317+
tests := []struct {
1318+
name string
1319+
staticBackends []config.StaticBackendConfig
1320+
groupRef string
1321+
expectedType vmcp.BackendType
1322+
expectedCABundle string
1323+
expectedName string
1324+
expectedURL string
1325+
expectedTransport string
1326+
expectedMetaEnv string
1327+
checkOtherFields bool
1328+
}{
1329+
{
1330+
name: "entry backend with CA bundle",
1331+
staticBackends: []config.StaticBackendConfig{
1332+
{
1333+
Name: "entry-mcp",
1334+
URL: "https://mcp.internal:8443/mcp",
1335+
Transport: "streamable-http",
1336+
Type: "entry",
1337+
CABundlePath: "/some/path/ca.crt",
1338+
},
1339+
},
1340+
groupRef: "test-group",
1341+
expectedType: vmcp.BackendTypeEntry,
1342+
expectedCABundle: "/some/path/ca.crt",
1343+
},
1344+
{
1345+
name: "entry backend without CA bundle",
1346+
staticBackends: []config.StaticBackendConfig{
1347+
{
1348+
Name: "entry-no-ca",
1349+
URL: "https://mcp.external:443/mcp",
1350+
Transport: "streamable-http",
1351+
Type: "entry",
1352+
},
1353+
},
1354+
groupRef: "test-group",
1355+
expectedType: vmcp.BackendTypeEntry,
1356+
expectedCABundle: "",
1357+
},
1358+
{
1359+
name: "container backend has empty type",
1360+
staticBackends: []config.StaticBackendConfig{
1361+
{
1362+
Name: "container-backend",
1363+
URL: "http://localhost:8080/mcp",
1364+
Transport: "sse",
1365+
},
1366+
},
1367+
groupRef: "test-group",
1368+
expectedType: "",
1369+
expectedCABundle: "",
1370+
},
1371+
{
1372+
name: "entry backend preserves other fields",
1373+
staticBackends: []config.StaticBackendConfig{
1374+
{
1375+
Name: "full-entry",
1376+
URL: "https://mcp.internal:8443/mcp",
1377+
Transport: "streamable-http",
1378+
Type: "entry",
1379+
CABundlePath: "/etc/toolhive/ca-bundles/internal/ca.crt",
1380+
Metadata: map[string]string{"env": "prod"},
1381+
},
1382+
},
1383+
groupRef: "test-group",
1384+
expectedType: vmcp.BackendTypeEntry,
1385+
expectedCABundle: "/etc/toolhive/ca-bundles/internal/ca.crt",
1386+
expectedName: "full-entry",
1387+
expectedURL: "https://mcp.internal:8443/mcp",
1388+
expectedTransport: "streamable-http",
1389+
expectedMetaEnv: "prod",
1390+
checkOtherFields: true,
1391+
},
1392+
}
1393+
1394+
for _, tt := range tests {
1395+
t.Run(tt.name, func(t *testing.T) {
1396+
t.Parallel()
1397+
ctx := context.Background()
1398+
1399+
discoverer := NewUnifiedBackendDiscovererWithStaticBackends(
1400+
tt.staticBackends,
1401+
nil, // No auth config needed for this test
1402+
tt.groupRef,
1403+
)
1404+
1405+
backends, err := discoverer.Discover(ctx, tt.groupRef)
1406+
require.NoError(t, err)
1407+
require.Len(t, backends, 1)
1408+
1409+
assert.Equal(t, tt.expectedType, backends[0].Type)
1410+
assert.Equal(t, tt.expectedCABundle, backends[0].CABundlePath)
1411+
1412+
if tt.checkOtherFields {
1413+
assert.Equal(t, tt.expectedName, backends[0].Name)
1414+
assert.Equal(t, tt.expectedURL, backends[0].BaseURL)
1415+
assert.Equal(t, tt.expectedTransport, backends[0].TransportType)
1416+
assert.Equal(t, tt.expectedMetaEnv, backends[0].Metadata["env"])
1417+
}
1418+
})
1419+
}
1420+
}
1421+
13121422
// TestBackendDiscoverer_Discover_DeterministicOrdering tests that Discover returns backends
13131423
// in a deterministic order (sorted alphabetically by name) regardless of input order.
13141424
// This prevents non-deterministic ConfigMap content that would cause unnecessary

pkg/vmcp/client/client.go

Lines changed: 58 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,15 @@ package client
99

1010
import (
1111
"context"
12+
"crypto/tls"
13+
"crypto/x509"
1214
"errors"
1315
"fmt"
1416
"io"
1517
"log/slog"
1618
"net"
1719
"net/http"
20+
"os"
1821
"time"
1922

2023
"github.com/mark3labs/mcp-go/client"
@@ -87,25 +90,58 @@ func NewHTTPBackendClient(registry vmcpauth.OutgoingAuthRegistry) (vmcp.BackendC
8790
// environment-specific settings like TLS config or proxy overrides). Otherwise a transport
8891
// with the standard Go defaults is constructed, preserving proxy, dial timeout, HTTP/2, and
8992
// idle-connection settings that a zero-value &http.Transport{} would drop.
90-
func newBackendTransport() *http.Transport {
93+
//
94+
// If caBundlePath is non-empty, a custom TLS configuration is applied that trusts both
95+
// 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) {
98+
var t *http.Transport
9199
if dt, ok := http.DefaultTransport.(*http.Transport); ok {
92-
return dt.Clone()
93-
}
94-
// http.DefaultTransport has been replaced (e.g. in tests or by a third-party library).
95-
// Construct a transport with the same defaults as the Go standard library uses for
96-
// http.DefaultTransport so we don't silently drop proxy, timeout, or HTTP/2 settings.
97-
return &http.Transport{
98-
Proxy: http.ProxyFromEnvironment,
99-
DialContext: (&net.Dialer{
100-
Timeout: 30 * time.Second,
101-
KeepAlive: 30 * time.Second,
102-
}).DialContext,
103-
ForceAttemptHTTP2: true,
104-
MaxIdleConns: 100,
105-
IdleConnTimeout: 90 * time.Second,
106-
TLSHandshakeTimeout: 10 * time.Second,
107-
ExpectContinueTimeout: 1 * time.Second,
100+
t = dt.Clone()
101+
} else {
102+
// http.DefaultTransport has been replaced (e.g. in tests or by a third-party library).
103+
// Construct a transport with the same defaults as the Go standard library uses for
104+
// http.DefaultTransport so we don't silently drop proxy, timeout, or HTTP/2 settings.
105+
t = &http.Transport{
106+
Proxy: http.ProxyFromEnvironment,
107+
DialContext: (&net.Dialer{
108+
Timeout: 30 * time.Second,
109+
KeepAlive: 30 * time.Second,
110+
}).DialContext,
111+
ForceAttemptHTTP2: true,
112+
MaxIdleConns: 100,
113+
IdleConnTimeout: 90 * time.Second,
114+
TLSHandshakeTimeout: 10 * time.Second,
115+
ExpectContinueTimeout: 1 * time.Second,
116+
}
117+
}
118+
119+
if caBundlePath != "" {
120+
caCert, err := os.ReadFile(caBundlePath) //nolint:gosec // CA bundle path is validated by config validator (no path traversal)
121+
if err != nil {
122+
return nil, fmt.Errorf("failed to read CA bundle from %s: %w", caBundlePath, err)
123+
}
124+
125+
caCertPool, err := x509.SystemCertPool()
126+
if err != nil {
127+
// Fall back to empty pool if system certs can't be loaded
128+
caCertPool = x509.NewCertPool()
129+
}
130+
131+
if !caCertPool.AppendCertsFromPEM(caCert) {
132+
return nil, fmt.Errorf("failed to parse CA certificate from %s", caBundlePath)
133+
}
134+
135+
if t.TLSClientConfig == nil {
136+
t.TLSClientConfig = &tls.Config{}
137+
} else {
138+
t.TLSClientConfig = t.TLSClientConfig.Clone()
139+
}
140+
t.TLSClientConfig.RootCAs = caCertPool
141+
t.TLSClientConfig.MinVersion = tls.VersionTLS12
108142
}
143+
144+
return t, nil
109145
}
110146

111147
// roundTripperFunc is a function adapter for http.RoundTripper.
@@ -202,7 +238,11 @@ func (h *httpBackendClient) defaultClientFactory(ctx context.Context, target *vm
202238
//
203239
// Clone DefaultTransport per call so each client gets an isolated connection pool,
204240
// preventing stale keep-alive connections from one backend affecting others.
205-
var baseTransport http.RoundTripper = newBackendTransport()
241+
httpTransport, err := newBackendTransport(target.CABundlePath)
242+
if err != nil {
243+
return nil, fmt.Errorf("failed to create transport for backend %s: %w", target.WorkloadID, err)
244+
}
245+
var baseTransport http.RoundTripper = httpTransport
206246

207247
// Resolve authentication strategy ONCE at client creation time
208248
authStrategy, err := h.resolveAuthStrategy(target)

0 commit comments

Comments
 (0)