Skip to content

Commit 831204f

Browse files
authored
Merge branch 'main' into use-status-file-for-pids
2 parents a698f83 + bc40edf commit 831204f

36 files changed

+692
-134
lines changed

.github/workflows/run-on-main-charts.yml

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,6 @@ on:
77
branches: [ main ]
88
paths:
99
- deploy/charts/**
10-
paths-ignore:
11-
- deploy/charts/**/CONTRIBUTING.md
12-
- deploy/charts/**/ci/**
13-
- deploy/charts/**/CLAUDE.md
1410

1511
jobs:
1612
publish-charts:

.github/workflows/run-on-pr-charts.yml

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,6 @@ on:
88
pull_request:
99
paths:
1010
- deploy/charts/**
11-
paths-ignore:
12-
- deploy/charts/**/CONTRIBUTING.md
13-
- deploy/charts/**/ci/**
14-
- deploy/charts/**/CLAUDE.md
1511

1612
jobs:
1713
spellcheck:

CLAUDE.md

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ ToolHive is a lightweight, secure manager for MCP (Model Context Protocol: https
1010
- **Kubernetes Operator (`thv-operator`)**: Manages MCP servers in Kubernetes clusters
1111
- **Proxy Runner (`thv-proxyrunner`)**: Handles proxy functionality for MCP server communication
1212

13-
The application acts as a thin client for Docker/Podman Unix socket API, providing container-based isolation for running MCP servers securely. It also builds on top of the MCP Specification: https://modelcontextprotocol.io/specification.
13+
The application acts as a thin client for Docker/Podman/Colima Unix socket API, providing container-based isolation for running MCP servers securely. It also builds on top of the MCP Specification: https://modelcontextprotocol.io/specification.
1414

1515
## Build and Development Commands
1616

@@ -90,7 +90,7 @@ The test framework uses Ginkgo and Gomega for BDD-style testing.
9090

9191
### Key Design Patterns
9292

93-
- **Factory Pattern**: Used extensively for creating runtime-specific implementations (Docker vs Kubernetes)
93+
- **Factory Pattern**: Used extensively for creating runtime-specific implementations (Docker/Colima/Podman vs Kubernetes)
9494
- **Interface Segregation**: Clean abstractions for container runtimes, transports, and storage
9595
- **Middleware Pattern**: HTTP middleware for auth, authz, telemetry
9696
- **Observer Pattern**: Event system for audit logging
@@ -131,6 +131,16 @@ The project uses `go.uber.org/mock` for generating mocks. Mock files are located
131131
- Supports environment variable overrides
132132
- Client configuration stored in `~/.toolhive/` or equivalent
133133

134+
### Container Runtime Configuration
135+
136+
ToolHive automatically detects available container runtimes in the following order: Podman, Colima, Docker. You can override the default socket paths using environment variables:
137+
138+
- `TOOLHIVE_PODMAN_SOCKET`: Custom Podman socket path
139+
- `TOOLHIVE_COLIMA_SOCKET`: Custom Colima socket path (default: `~/.colima/default/docker.sock`)
140+
- `TOOLHIVE_DOCKER_SOCKET`: Custom Docker socket path
141+
142+
**Colima Support**: Colima is fully supported as a Docker-compatible runtime. ToolHive will automatically detect Colima installations on macOS and Linux systems.
143+
134144
## Development Guidelines
135145

136146
### Code Organization
@@ -176,7 +186,7 @@ When working on the Kubernetes operator:
176186

177187
### Working with Containers
178188

179-
The container abstraction supports both Docker and Kubernetes runtimes. When adding container functionality:
189+
The container abstraction supports Docker, Colima, Podman, and Kubernetes runtimes. When adding container functionality:
180190
- Implement the interface in `pkg/container/runtime/types.go`
181191
- Add runtime-specific implementations in appropriate subdirectories
182192
- Use factory pattern for runtime selection

cmd/thv-operator/api/v1alpha1/mcpserver_types.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@ type MCPServerSpec struct {
1616
// +kubebuilder:default=stdio
1717
Transport string `json:"transport,omitempty"`
1818

19+
// ProxyMode is the proxy mode for stdio transport (sse or streamable-http)
20+
// This setting is only used when Transport is "stdio"
21+
// +kubebuilder:validation:Enum=sse;streamable-http
22+
// +kubebuilder:default=sse
23+
// +optional
24+
ProxyMode string `json:"proxyMode,omitempty"`
25+
1926
// Port is the port to expose the MCP server on
2027
// +kubebuilder:validation:Minimum=1
2128
// +kubebuilder:validation:Maximum=65535

cmd/thv-operator/controllers/mcpserver_controller.go

Lines changed: 66 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -71,13 +71,21 @@ var defaultRBACRules = []rbacv1.PolicyRule{
7171
Resources: []string{"pods/attach"},
7272
Verbs: []string{"create", "get"},
7373
},
74+
{
75+
APIGroups: []string{""},
76+
Resources: []string{"configmaps"},
77+
Verbs: []string{"get", "list", "watch"},
78+
},
7479
}
7580

7681
var ctxLogger = log.FromContext(context.Background())
7782

7883
// mcpContainerName is the name of the mcp container used in pod templates
7984
const mcpContainerName = "mcp"
8085

86+
// trueValue is the string value "true" used for environment variable comparisons
87+
const trueValue = "true"
88+
8189
// Authorization ConfigMap label constants
8290
const (
8391
// authzLabelKey is the label key for authorization configuration type
@@ -463,38 +471,51 @@ func (r *MCPServerReconciler) deploymentForMCPServer(ctx context.Context, m *mcp
463471

464472
// Prepare container args
465473
args := []string{"run", "--foreground=true"}
466-
args = append(args, fmt.Sprintf("--proxy-port=%d", m.Spec.Port))
467-
args = append(args, fmt.Sprintf("--name=%s", m.Name))
468-
args = append(args, fmt.Sprintf("--transport=%s", m.Spec.Transport))
469-
args = append(args, fmt.Sprintf("--host=%s", getProxyHost()))
470474

471-
if m.Spec.TargetPort != 0 {
472-
args = append(args, fmt.Sprintf("--target-port=%d", m.Spec.TargetPort))
473-
}
474-
475-
// Generate pod template patch for secrets and merge with user-provided patch
476-
477-
finalPodTemplateSpec := NewMCPServerPodTemplateSpecBuilder(m.Spec.PodTemplateSpec).
478-
WithServiceAccount(m.Spec.ServiceAccount).
479-
WithSecrets(m.Spec.Secrets).
480-
Build()
481-
// Add pod template patch if we have one
482-
if finalPodTemplateSpec != nil {
483-
podTemplatePatch, err := json.Marshal(finalPodTemplateSpec)
484-
if err != nil {
485-
logger.Errorf("Failed to marshal pod template spec: %v", err)
486-
} else {
487-
args = append(args, fmt.Sprintf("--k8s-pod-patch=%s", string(podTemplatePatch)))
475+
// Check if global ConfigMap mode is enabled via environment variable
476+
useConfigMap := os.Getenv("TOOLHIVE_USE_CONFIGMAP") == trueValue
477+
if useConfigMap {
478+
// Use the operator-created ConfigMap (format: {name}-runconfig)
479+
configMapName := fmt.Sprintf("%s-runconfig", m.Name)
480+
configMapRef := fmt.Sprintf("%s/%s", m.Namespace, configMapName)
481+
args = append(args, fmt.Sprintf("--from-configmap=%s", configMapRef))
482+
} else {
483+
// Use individual configuration flags (existing behavior)
484+
args = append(args, fmt.Sprintf("--proxy-port=%d", m.Spec.Port))
485+
args = append(args, fmt.Sprintf("--name=%s", m.Name))
486+
args = append(args, fmt.Sprintf("--transport=%s", m.Spec.Transport))
487+
args = append(args, fmt.Sprintf("--host=%s", getProxyHost()))
488+
if m.Spec.TargetPort != 0 {
489+
args = append(args, fmt.Sprintf("--target-port=%d", m.Spec.TargetPort))
490+
}
491+
}
492+
493+
// Add pod template patch and permission profile only if not using ConfigMap
494+
// When using ConfigMap, these are included in the runconfig.json
495+
if !useConfigMap {
496+
// Generate pod template patch for secrets and merge with user-provided patch
497+
finalPodTemplateSpec := NewMCPServerPodTemplateSpecBuilder(m.Spec.PodTemplateSpec).
498+
WithServiceAccount(m.Spec.ServiceAccount).
499+
WithSecrets(m.Spec.Secrets).
500+
Build()
501+
// Add pod template patch if we have one
502+
if finalPodTemplateSpec != nil {
503+
podTemplatePatch, err := json.Marshal(finalPodTemplateSpec)
504+
if err != nil {
505+
logger.Errorf("Failed to marshal pod template spec: %v", err)
506+
} else {
507+
args = append(args, fmt.Sprintf("--k8s-pod-patch=%s", string(podTemplatePatch)))
508+
}
488509
}
489-
}
490510

491-
// Add permission profile args
492-
if m.Spec.PermissionProfile != nil {
493-
switch m.Spec.PermissionProfile.Type {
494-
case mcpv1alpha1.PermissionProfileTypeBuiltin:
495-
args = append(args, fmt.Sprintf("--permission-profile=%s", m.Spec.PermissionProfile.Name))
496-
case mcpv1alpha1.PermissionProfileTypeConfigMap:
497-
args = append(args, fmt.Sprintf("--permission-profile-path=/etc/toolhive/profiles/%s", m.Spec.PermissionProfile.Key))
511+
// Add permission profile args
512+
if m.Spec.PermissionProfile != nil {
513+
switch m.Spec.PermissionProfile.Type {
514+
case mcpv1alpha1.PermissionProfileTypeBuiltin:
515+
args = append(args, fmt.Sprintf("--permission-profile=%s", m.Spec.PermissionProfile.Name))
516+
case mcpv1alpha1.PermissionProfileTypeConfigMap:
517+
args = append(args, fmt.Sprintf("--permission-profile-path=/etc/toolhive/profiles/%s", m.Spec.PermissionProfile.Key))
518+
}
498519
}
499520
}
500521

@@ -526,15 +547,18 @@ func (r *MCPServerReconciler) deploymentForMCPServer(ctx context.Context, m *mcp
526547
args = append(args, "--enable-audit")
527548
}
528549

529-
// Add environment variables as --env flags for the MCP server
530-
for _, e := range m.Spec.Env {
531-
args = append(args, fmt.Sprintf("--env=%s=%s", e.Name, e.Value))
532-
}
550+
// Add environment variables and tools filter only if not using ConfigMap
551+
if !useConfigMap {
552+
// Add environment variables as --env flags for the MCP server
553+
for _, e := range m.Spec.Env {
554+
args = append(args, fmt.Sprintf("--env=%s=%s", e.Name, e.Value))
555+
}
533556

534-
// Add tools filter args
535-
if len(m.Spec.ToolsFilter) > 0 {
536-
slices.Sort(m.Spec.ToolsFilter)
537-
args = append(args, fmt.Sprintf("--tools=%s", strings.Join(m.Spec.ToolsFilter, ",")))
557+
// Add tools filter args
558+
if len(m.Spec.ToolsFilter) > 0 {
559+
slices.Sort(m.Spec.ToolsFilter)
560+
args = append(args, fmt.Sprintf("--tools=%s", strings.Join(m.Spec.ToolsFilter, ",")))
561+
}
538562
}
539563

540564
// Add OpenTelemetry configuration args
@@ -550,11 +574,13 @@ func (r *MCPServerReconciler) deploymentForMCPServer(ctx context.Context, m *mcp
550574
}
551575
}
552576

553-
// Add the image
577+
// Always add the image as it's required by proxy runner command signature
578+
// When using ConfigMap, the image from ConfigMap takes precedence, but we still need
579+
// to provide this as a positional argument to satisfy the command requirements
554580
args = append(args, m.Spec.Image)
555581

556-
// Add additional args
557-
if len(m.Spec.Args) > 0 {
582+
// Add additional args only if not using ConfigMap
583+
if !useConfigMap && len(m.Spec.Args) > 0 {
558584
args = append(args, "--")
559585
args = append(args, m.Spec.Args...)
560586
}

cmd/thv-operator/controllers/mcpserver_runconfig.go

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -216,19 +216,38 @@ func (*MCPServerReconciler) createRunConfigFromMCPServer(m *mcpv1alpha1.MCPServe
216216
}
217217
}
218218

219-
// Use the RunConfigBuilder for operator context with full builder pattern
220-
config, err := runner.NewOperatorRunConfigBuilder().
219+
// Set proxy mode, defaulting to "sse" if not specified
220+
proxyMode := m.Spec.ProxyMode
221+
if proxyMode == "" {
222+
proxyMode = "sse" // Default to SSE for backward compatibility
223+
}
224+
225+
builder := runner.NewOperatorRunConfigBuilder().
221226
WithName(m.Name).
222227
WithImage(m.Spec.Image).
223228
WithCmdArgs(m.Spec.Args).
224229
WithTransportAndPorts(m.Spec.Transport, port, int(m.Spec.TargetPort)).
230+
WithProxyMode(transporttypes.ProxyMode(proxyMode)).
225231
WithHost(proxyHost).
226232
WithToolsFilter(m.Spec.ToolsFilter).
227233
WithEnvVars(envVars).
228234
WithVolumes(volumes).
229235
WithSecrets(secrets).
230-
WithK8sPodPatch(k8sPodPatch).
231-
BuildForOperator()
236+
WithK8sPodPatch(k8sPodPatch)
237+
238+
// Add permission profile if specified
239+
if m.Spec.PermissionProfile != nil {
240+
switch m.Spec.PermissionProfile.Type {
241+
case mcpv1alpha1.PermissionProfileTypeBuiltin:
242+
builder = builder.WithPermissionProfileNameOrPath(m.Spec.PermissionProfile.Name)
243+
case mcpv1alpha1.PermissionProfileTypeConfigMap:
244+
// For ConfigMap-based permission profiles, we store the path
245+
builder = builder.WithPermissionProfileNameOrPath(fmt.Sprintf("/etc/toolhive/profiles/%s", m.Spec.PermissionProfile.Key))
246+
}
247+
}
248+
249+
// Use the RunConfigBuilder for operator context with full builder pattern
250+
config, err := builder.BuildForOperator()
232251

233252
if err != nil {
234253
return nil, err

cmd/thv-operator/controllers/mcpserver_runconfig_test.go

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@ import (
2020
transporttypes "github.com/stacklok/toolhive/pkg/transport/types"
2121
)
2222

23+
const (
24+
testImage = "test-image:latest"
25+
stdioTransport = "stdio"
26+
sseProxyMode = "sse"
27+
streamableHTTPProxyMode = "streamable-http"
28+
)
29+
2330
func createRunConfigTestScheme() *runtime.Scheme {
2431
testScheme := runtime.NewScheme()
2532
_ = corev1.AddToScheme(testScheme)
@@ -35,7 +42,7 @@ func createTestMCPServerWithConfig(name, namespace, image string, envVars []mcpv
3542
},
3643
Spec: mcpv1alpha1.MCPServerSpec{
3744
Image: image,
38-
Transport: "stdio",
45+
Transport: stdioTransport,
3946
Port: 8080,
4047
Env: envVars,
4148
},
@@ -58,8 +65,8 @@ func TestCreateRunConfigFromMCPServer(t *testing.T) {
5865
Namespace: "test-ns",
5966
},
6067
Spec: mcpv1alpha1.MCPServerSpec{
61-
Image: "test-image:latest",
62-
Transport: "stdio",
68+
Image: testImage,
69+
Transport: stdioTransport,
6370
Port: 8080,
6471
},
6572
},
@@ -142,6 +149,50 @@ func TestCreateRunConfigFromMCPServer(t *testing.T) {
142149
config.Secrets[1] == "secret2,target=key2"
143150
},
144151
},
152+
{
153+
name: "proxy mode specified",
154+
mcpServer: &mcpv1alpha1.MCPServer{
155+
ObjectMeta: metav1.ObjectMeta{
156+
Name: "proxy-mode-server",
157+
Namespace: "test-ns",
158+
},
159+
Spec: mcpv1alpha1.MCPServerSpec{
160+
Image: testImage,
161+
Transport: stdioTransport,
162+
Port: 8080,
163+
ProxyMode: streamableHTTPProxyMode,
164+
},
165+
},
166+
expected: func(config *runner.RunConfig) bool {
167+
return config.Name == "proxy-mode-server" &&
168+
config.Image == testImage &&
169+
config.Transport == stdioTransport &&
170+
config.Port == 8080 &&
171+
config.ProxyMode == streamableHTTPProxyMode
172+
},
173+
},
174+
{
175+
name: "proxy mode defaults to sse when not specified",
176+
mcpServer: &mcpv1alpha1.MCPServer{
177+
ObjectMeta: metav1.ObjectMeta{
178+
Name: "default-proxy-mode-server",
179+
Namespace: "test-ns",
180+
},
181+
Spec: mcpv1alpha1.MCPServerSpec{
182+
Image: testImage,
183+
Transport: stdioTransport,
184+
Port: 8080,
185+
// ProxyMode not specified
186+
},
187+
},
188+
expected: func(config *runner.RunConfig) bool {
189+
return config.Name == "default-proxy-mode-server" &&
190+
config.Image == testImage &&
191+
config.Transport == stdioTransport &&
192+
config.Port == 8080 &&
193+
config.ProxyMode == sseProxyMode // Should default to sse
194+
},
195+
},
145196
{
146197
name: "comprehensive test with all fields",
147198
mcpServer: &mcpv1alpha1.MCPServer{
@@ -154,6 +205,7 @@ func TestCreateRunConfigFromMCPServer(t *testing.T) {
154205
Transport: "streamable-http",
155206
Port: 9090,
156207
TargetPort: 8080,
208+
ProxyMode: "streamable-http",
157209
Args: []string{"--comprehensive", "--test"},
158210
ToolsFilter: []string{"tool1", "tool2"},
159211
Env: []mcpv1alpha1.EnvVar{
@@ -177,6 +229,7 @@ func TestCreateRunConfigFromMCPServer(t *testing.T) {
177229
config.Transport == "streamable-http" &&
178230
config.Port == 9090 &&
179231
config.TargetPort == 8080 &&
232+
config.ProxyMode == streamableHTTPProxyMode &&
180233
len(config.CmdArgs) == 2 &&
181234
config.CmdArgs[0] == "--comprehensive" &&
182235
len(config.ToolsFilter) == 2 &&
@@ -1024,6 +1077,20 @@ func TestMCPServerModificationScenarios(t *testing.T) {
10241077
"Secrets": []string{"secret1,target=CUSTOM_ENV1", "secret2,target=key2"},
10251078
},
10261079
},
1080+
{
1081+
name: "Proxy mode change",
1082+
initialServer: func() *mcpv1alpha1.MCPServer {
1083+
server := createTestMCPServerWithConfig("proxy-test", "default", "test:v1", nil)
1084+
server.Spec.ProxyMode = sseProxyMode
1085+
return server
1086+
},
1087+
modifyServer: func(server *mcpv1alpha1.MCPServer) {
1088+
server.Spec.ProxyMode = streamableHTTPProxyMode
1089+
},
1090+
expectedChanges: map[string]interface{}{
1091+
"ProxyMode": transporttypes.ProxyModeStreamableHTTP,
1092+
},
1093+
},
10271094
}
10281095

10291096
for _, tt := range tests {

0 commit comments

Comments
 (0)