diff --git a/cmd/thv-operator/controllers/mcpserver_controller.go b/cmd/thv-operator/controllers/mcpserver_controller.go index 2db17e9fb..99660d85f 100644 --- a/cmd/thv-operator/controllers/mcpserver_controller.go +++ b/cmd/thv-operator/controllers/mcpserver_controller.go @@ -394,6 +394,7 @@ func (r *MCPServerReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( host := fmt.Sprintf("%s.%s.svc.cluster.local", serviceName, mcpServer.Namespace) mcpServer.Status.URL = transport.GenerateMCPServerURL( mcpServer.Spec.Transport, + mcpServer.Spec.ProxyMode, host, int(mcpServer.GetProxyPort()), mcpServer.Name, diff --git a/pkg/runner/runner.go b/pkg/runner/runner.go index 0f98b893d..572818e48 100644 --- a/pkg/runner/runner.go +++ b/pkg/runner/runner.go @@ -282,6 +282,7 @@ func (r *Runner) Run(ctx context.Context) error { transportType := labels.GetTransportType(r.Config.ContainerLabels) serverURL := transport.GenerateMCPServerURL( transportType, + string(r.Config.ProxyMode), "localhost", r.Config.Port, r.Config.ContainerName, diff --git a/pkg/transport/url.go b/pkg/transport/url.go index 6a1584af3..4502ea159 100644 --- a/pkg/transport/url.go +++ b/pkg/transport/url.go @@ -15,11 +15,33 @@ import ( // If remoteURL is provided, the remote server's path will be used as the path of the proxy. // For SSE/STDIO transports, a "#" fragment is appended. // For StreamableHTTP, no fragment is appended. -func GenerateMCPServerURL(transportType string, host string, port int, containerName, remoteURL string) string { +func GenerateMCPServerURL(transportType string, proxyMode string, host string, port int, containerName, remoteURL string) string { base := fmt.Sprintf("http://%s:%d", host, port) - isSSE := transportType == types.TransportTypeSSE.String() || transportType == types.TransportTypeStdio.String() - isStreamable := transportType == types.TransportTypeStreamableHTTP.String() + var isSSE, isStreamable bool + + if transportType == types.TransportTypeStdio.String() { + // For stdio, the proxy mode determines the HTTP endpoint + // Default to streamable-http if proxyMode is empty (matches CRD default) + effectiveProxyMode := proxyMode + if effectiveProxyMode == "" { + effectiveProxyMode = types.ProxyModeStreamableHTTP.String() + } + + // Map proxy mode to endpoint type + if effectiveProxyMode == types.ProxyModeSSE.String() { + isSSE = true + } else { + // streamable-http or any other value + isStreamable = true + } + } else if transportType == types.TransportTypeSSE.String() { + // Native SSE transport + isSSE = true + } else if transportType == types.TransportTypeStreamableHTTP.String() { + // Native streamable-http transport + isStreamable = true + } // ---- Remote path case ---- if remoteURL != "" { diff --git a/pkg/transport/url_test.go b/pkg/transport/url_test.go index 3ed4b7562..635141bcb 100644 --- a/pkg/transport/url_test.go +++ b/pkg/transport/url_test.go @@ -14,6 +14,7 @@ func TestGenerateMCPServerURL(t *testing.T) { tests := []struct { name string transportType string + proxyMode string host string port int containerName string @@ -21,8 +22,19 @@ func TestGenerateMCPServerURL(t *testing.T) { expected string }{ { - name: "SSE transport", - transportType: types.TransportTypeSSE.String(), + name: "STDIO transport with streamable-http proxy", + transportType: types.TransportTypeStdio.String(), + proxyMode: "streamable-http", + host: "localhost", + port: 12345, + containerName: "test-container", + targetURI: "", + expected: "http://localhost:12345/" + streamable.HTTPStreamableHTTPEndpoint, + }, + { + name: "STDIO transport with sse proxy", + transportType: types.TransportTypeStdio.String(), + proxyMode: "sse", host: "localhost", port: 12345, containerName: "test-container", @@ -30,8 +42,20 @@ func TestGenerateMCPServerURL(t *testing.T) { expected: "http://localhost:12345" + ssecommon.HTTPSSEEndpoint + "#test-container", }, { - name: "STDIO transport (uses SSE proxy)", + name: "STDIO transport with empty proxyMode (defaults to streamable-http)", transportType: types.TransportTypeStdio.String(), + + proxyMode: "", + host: "localhost", + port: 12345, + containerName: "test-container", + targetURI: "", + expected: "http://localhost:12345/" + streamable.HTTPStreamableHTTPEndpoint, + }, + { + name: "SSE transport", + transportType: types.TransportTypeSSE.String(), + proxyMode: "", host: "localhost", port: 12345, containerName: "test-container", @@ -41,6 +65,7 @@ func TestGenerateMCPServerURL(t *testing.T) { { name: "Streamable HTTP transport", transportType: types.TransportTypeStreamableHTTP.String(), + proxyMode: "", host: "localhost", port: 12345, containerName: "test-container", @@ -50,6 +75,7 @@ func TestGenerateMCPServerURL(t *testing.T) { { name: "Unsupported transport type", transportType: "unsupported", + proxyMode: "", host: "localhost", port: 12345, containerName: "test-container", @@ -59,6 +85,7 @@ func TestGenerateMCPServerURL(t *testing.T) { { name: "SSE transport with targetURI path", transportType: types.TransportTypeSSE.String(), + proxyMode: "", host: "localhost", port: 12345, containerName: "test-container", @@ -68,6 +95,7 @@ func TestGenerateMCPServerURL(t *testing.T) { { name: "SSE transport with targetURI domain only", transportType: types.TransportTypeSSE.String(), + proxyMode: "", host: "localhost", port: 12345, containerName: "test-container", @@ -77,16 +105,17 @@ func TestGenerateMCPServerURL(t *testing.T) { { name: "SSE transport with targetURI root path", transportType: types.TransportTypeSSE.String(), + proxyMode: "", host: "localhost", port: 12345, containerName: "test-container", targetURI: "http://example.com/", expected: "http://localhost:12345#test-container", }, - // Major targetURI test cases - Streamable HTTP transport { name: "Streamable HTTP transport with targetURI path", transportType: types.TransportTypeStreamableHTTP.String(), + proxyMode: "", host: "localhost", port: 12345, containerName: "test-container", @@ -96,6 +125,7 @@ func TestGenerateMCPServerURL(t *testing.T) { { name: "Streamable HTTP transport with targetURI domain only", transportType: types.TransportTypeStreamableHTTP.String(), + proxyMode: "", host: "localhost", port: 12345, containerName: "test-container", @@ -105,18 +135,39 @@ func TestGenerateMCPServerURL(t *testing.T) { { name: "Streamable HTTP transport with targetURI root path", transportType: types.TransportTypeStreamableHTTP.String(), + proxyMode: "", host: "localhost", port: 12345, containerName: "test-container", targetURI: "http://remote-server.com/", expected: "http://localhost:12345", }, + { + name: "STDIO with streamable-http proxy and targetURI", + transportType: types.TransportTypeStdio.String(), + proxyMode: "streamable-http", + host: "localhost", + port: 12345, + containerName: "test-container", + targetURI: "http://remote.com/api", + expected: "http://localhost:12345/api", + }, + { + name: "STDIO with sse proxy and targetURI", + transportType: types.TransportTypeStdio.String(), + proxyMode: "sse", + host: "localhost", + port: 12345, + containerName: "test-container", + targetURI: "http://remote.com/api", + expected: "http://localhost:12345/api#test-container", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - url := GenerateMCPServerURL(tt.transportType, tt.host, tt.port, tt.containerName, tt.targetURI) + url := GenerateMCPServerURL(tt.transportType, tt.proxyMode, tt.host, tt.port, tt.containerName, tt.targetURI) if url != tt.expected { t.Errorf("GenerateMCPServerURL() = %v, want %v", url, tt.expected) } diff --git a/pkg/vmcp/workloads/k8s.go b/pkg/vmcp/workloads/k8s.go index 411338ac4..20feb6e59 100644 --- a/pkg/vmcp/workloads/k8s.go +++ b/pkg/vmcp/workloads/k8s.go @@ -204,7 +204,14 @@ func (d *k8sDiscoverer) mcpServerToBackend(ctx context.Context, mcpServer *mcpv1 port = int(mcpServer.Spec.Port) // Fallback to deprecated Port field } if port > 0 { - url = transport.GenerateMCPServerURL(mcpServer.Spec.Transport, transport.LocalhostIPv4, port, mcpServer.Name, "") + url = transport.GenerateMCPServerURL( + mcpServer.Spec.Transport, + mcpServer.Spec.ProxyMode, + transport.LocalhostIPv4, + port, + mcpServer.Name, + "", + ) } } @@ -370,7 +377,7 @@ func (d *k8sDiscoverer) mcpRemoteProxyToBackend(ctx context.Context, proxy *mcpv if url == "" { port := int(proxy.GetProxyPort()) if port > 0 { - url = transport.GenerateMCPServerURL(proxy.Spec.Transport, transport.LocalhostIPv4, port, proxy.Name, "") + url = transport.GenerateMCPServerURL(proxy.Spec.Transport, "", transport.LocalhostIPv4, port, proxy.Name, "") } } diff --git a/pkg/workloads/manager.go b/pkg/workloads/manager.go index 8e33ba67d..0746ef492 100644 --- a/pkg/workloads/manager.go +++ b/pkg/workloads/manager.go @@ -1476,6 +1476,7 @@ func (d *DefaultManager) getRemoteWorkloadsFromState( if runConfig.Port > 0 { proxyURL = transport.GenerateMCPServerURL( transportType.String(), + string(runConfig.ProxyMode), transport.LocalhostIPv4, runConfig.Port, name, diff --git a/pkg/workloads/statuses/file_status.go b/pkg/workloads/statuses/file_status.go index 73606e02c..38aabd0d4 100644 --- a/pkg/workloads/statuses/file_status.go +++ b/pkg/workloads/statuses/file_status.go @@ -146,6 +146,7 @@ func (f *fileStatusManager) populateRemoteWorkloadData(ctx context.Context, work if config.Port > 0 { proxyURL = transport.GenerateMCPServerURL( transportType.String(), + config.ProxyMode, transport.LocalhostIPv4, config.Port, workload.Name, diff --git a/pkg/workloads/types/types.go b/pkg/workloads/types/types.go index dd112c389..e5891225c 100644 --- a/pkg/workloads/types/types.go +++ b/pkg/workloads/types/types.go @@ -66,18 +66,23 @@ func WorkloadFromContainerInfo(container *runtime.ContainerInfo) (core.Workload, // check if we have the label for transport type (toolhive-transport) transportType := labels.GetTransportType(container.Labels) - // Generate URL for the MCP server - url := "" - if port > 0 { - url = transport.GenerateMCPServerURL(transportType, transport.LocalhostIPv4, port, name, "") - } - tType, err := types.ParseTransportType(transportType) if err != nil { // If we can't parse the transport type, default to SSE. tType = types.TransportTypeSSE } + ctx := context.Background() + runConfig, err := loadRunConfigFields(ctx, name) + if err != nil { + return core.Workload{}, err + } + + // Generate URL for the MCP server + url := "" + if port > 0 { + url = transport.GenerateMCPServerURL(transportType, runConfig.ProxyMode, transport.LocalhostIPv4, port, name, "") + } // Filter out standard ToolHive labels to show only user-defined labels userLabels := make(map[string]string) for key, value := range container.Labels { @@ -86,12 +91,6 @@ func WorkloadFromContainerInfo(container *runtime.ContainerInfo) (core.Workload, } } - ctx := context.Background() - runConfig, err := loadRunConfigFields(ctx, name) - if err != nil { - return core.Workload{}, err - } - // Calculate the effective proxy mode that clients should use effectiveProxyMode := GetEffectiveProxyMode(tType, runConfig.ProxyMode)