Skip to content

Commit 75491ec

Browse files
yroblataskbot
andauthored
feat: add support for streamable http clients (#804)
Co-authored-by: taskbot <[email protected]>
1 parent e25c2c0 commit 75491ec

30 files changed

+658
-134
lines changed

cmd/thv-operator/README.md

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ flowchart LR
2929
subgraph namespace[namespace: toolhive-system]
3030
operator["POD: Operator"]
3131
sse
32+
streamable-http
3233
stdio
3334
end
3435
@@ -161,17 +162,17 @@ kubectl describe mcpserver <name>
161162
### MCPServer Spec
162163

163164
| Field | Description | Required | Default |
164-
|---------------------|-------------------------------------------------|----------|---------|
165-
| `image` | Container image for the MCP server | Yes | - |
166-
| `transport` | Transport method (stdio or sse) | No | stdio |
167-
| `port` | Port to expose the MCP server on | No | 8080 |
168-
| `targetPort` | Port that MCP server listens to | No | - |
169-
| `args` | Additional arguments to pass to the MCP server | No | - |
170-
| `env` | Environment variables to set in the container | No | - |
171-
| `volumes` | Volumes to mount in the container | No | - |
172-
| `resources` | Resource requirements for the container | No | - |
173-
| `secrets` | References to secrets to mount in the container | No | - |
174-
| `permissionProfile` | Permission profile configuration | No | - |
165+
|---------------------|--------------------------------------------------|----------|---------|
166+
| `image` | Container image for the MCP server | Yes | - |
167+
| `transport` | Transport method (stdio, streamable-http or sse) | No | stdio |
168+
| `port` | Port to expose the MCP server on | No | 8080 |
169+
| `targetPort` | Port that MCP server listens to | No | - |
170+
| `args` | Additional arguments to pass to the MCP server | No | - |
171+
| `env` | Environment variables to set in the container | No | - |
172+
| `volumes` | Volumes to mount in the container | No | - |
173+
| `resources` | Resource requirements for the container | No | - |
174+
| `secrets` | References to secrets to mount in the container | No | - |
175+
| `permissionProfile` | Permission profile configuration | No | - |
175176

176177
### Permission Profiles
177178

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ type MCPServerSpec struct {
1111
// +kubebuilder:validation:Required
1212
Image string `json:"image"`
1313

14-
// Transport is the transport method for the MCP server (stdio or sse)
15-
// +kubebuilder:validation:Enum=stdio;sse
14+
// Transport is the transport method for the MCP server (stdio, streamable-http or sse)
15+
// +kubebuilder:validation:Enum=stdio;streamable-http;sse
1616
// +kubebuilder:default=stdio
1717
Transport string `json:"transport,omitempty"`
1818

cmd/thv/app/config.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -313,8 +313,10 @@ func addRunningMCPsToClient(ctx context.Context, clientName string) error {
313313
continue // Skip if we can't get the port
314314
}
315315

316+
transportType := labels.GetTransportType(c.Labels)
317+
316318
// Generate URL for the MCP server
317-
url := client.GenerateMCPServerURL(transport.LocalhostIPv4, port, name)
319+
url := client.GenerateMCPServerURL(transportType, transport.LocalhostIPv4, port, name)
318320

319321
// Update each configuration file
320322
for _, clientConfig := range clientConfigs {

cmd/thv/app/inspector.go

Lines changed: 70 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,48 @@ var (
5050
inspectorImage = "npx://@modelcontextprotocol/inspector@latest"
5151
)
5252

53+
func buildInspectorContainerOptions(uiPortStr string, mcpPortStr string) *runtime.DeployWorkloadOptions {
54+
return &runtime.DeployWorkloadOptions{
55+
ExposedPorts: map[string]struct{}{
56+
uiPortStr + "/tcp": {},
57+
mcpPortStr + "/tcp": {},
58+
},
59+
PortBindings: map[string][]runtime.PortBinding{
60+
uiPortStr + "/tcp": {
61+
{HostIP: "127.0.0.1", HostPort: uiPortStr},
62+
},
63+
mcpPortStr + "/tcp": {
64+
{HostIP: "127.0.0.1", HostPort: mcpPortStr},
65+
},
66+
},
67+
AttachStdio: false,
68+
}
69+
}
70+
71+
func waitForInspectorReady(ctx context.Context, port int, statusChan chan bool) {
72+
go func() {
73+
url := fmt.Sprintf("http://localhost:%d", port)
74+
for {
75+
resp, err := http.Get(url) //nolint:gosec
76+
if err == nil && resp.StatusCode == 200 {
77+
_ = resp.Body.Close()
78+
statusChan <- true
79+
return
80+
}
81+
if resp != nil {
82+
_ = resp.Body.Close()
83+
}
84+
select {
85+
case <-ctx.Done():
86+
return
87+
default:
88+
logger.Info("Waiting for MCP Inspector to be ready...")
89+
time.Sleep(3 * time.Second)
90+
}
91+
}
92+
}()
93+
}
94+
5395
func inspectorCmdFunc(cmd *cobra.Command, args []string) error {
5496
ctx := cmd.Context()
5597

@@ -61,7 +103,7 @@ func inspectorCmdFunc(cmd *cobra.Command, args []string) error {
61103
serverName := args[0]
62104

63105
// find the port of the server if it is running / exists
64-
serverPort, err := getServerPort(ctx, serverName)
106+
serverPort, transportType, err := getServerPortAndTransport(ctx, serverName)
65107
if err != nil {
66108
return fmt.Errorf("failed to find server: %v", err)
67109
}
@@ -72,31 +114,11 @@ func inspectorCmdFunc(cmd *cobra.Command, args []string) error {
72114
return fmt.Errorf("failed to handle protocol scheme: %v", err)
73115
}
74116

75-
inspectorUIPortStr := strconv.Itoa(inspectorUIPort)
76-
inspectorMCPProxyPortStr := strconv.Itoa(inspectorMCPProxyPort)
77-
78117
// Setup container options with the required port configuration
79-
options := &runtime.DeployWorkloadOptions{
80-
ExposedPorts: map[string]struct{}{
81-
inspectorUIPortStr + "/tcp": {},
82-
inspectorMCPProxyPortStr + "/tcp": {},
83-
},
84-
PortBindings: map[string][]runtime.PortBinding{
85-
inspectorUIPortStr + "/tcp": {
86-
{
87-
HostIP: "127.0.0.1",
88-
HostPort: inspectorUIPortStr,
89-
},
90-
},
91-
inspectorMCPProxyPortStr + "/tcp": {
92-
{
93-
HostIP: "127.0.0.1",
94-
HostPort: inspectorMCPProxyPortStr,
95-
},
96-
},
97-
},
98-
AttachStdio: false,
99-
}
118+
uiPortStr := strconv.Itoa(inspectorUIPort)
119+
mcpPortStr := strconv.Itoa(inspectorMCPProxyPort)
120+
121+
options := buildInspectorContainerOptions(uiPortStr, mcpPortStr)
100122

101123
// Create container runtime
102124
rt, err := container.NewFactory().Create(ctx)
@@ -124,54 +146,43 @@ func inspectorCmdFunc(cmd *cobra.Command, args []string) error {
124146

125147
// Monitor inspector readiness by checking HTTP response
126148
statusChan := make(chan bool, 1)
127-
go func() {
128-
inspectorURL := fmt.Sprintf("http://localhost:%d", inspectorUIPort)
129-
for {
130-
resp, err := http.Get(inspectorURL) //nolint:gosec // URL is constructed from trusted local port
131-
if err == nil && resp.StatusCode == 200 {
132-
_ = resp.Body.Close()
133-
statusChan <- true
134-
return
135-
}
136-
if resp != nil {
137-
_ = resp.Body.Close()
138-
}
139-
// Small delay before checking again
140-
select {
141-
case <-ctx.Done():
142-
return
143-
default:
144-
logger.Info("Waiting for MCP Inspector to be ready...")
145-
time.Sleep(3 * time.Second)
146-
}
147-
}
148-
}()
149+
waitForInspectorReady(ctx, inspectorUIPort, statusChan)
149150

150151
// Wait for container to be running or context to be cancelled
151152
select {
152153
case <-statusChan:
153154
logger.Infof("Connected to MCP server: %s", serverName)
155+
156+
var suffix string
157+
var transportTypeStr string
158+
if transportType == types.TransportTypeSSE || transportType == types.TransportTypeStdio {
159+
suffix = "sse"
160+
transportTypeStr = transportType.String()
161+
} else {
162+
suffix = "mcp/"
163+
transportTypeStr = "streamable-http"
164+
}
154165
inspectorURL := fmt.Sprintf(
155-
"http://localhost:%d?transport=sse&serverUrl=http://host.docker.internal:%d/sse",
156-
inspectorUIPort, serverPort)
166+
"http://localhost:%d?transport=%s&serverUrl=http://host.docker.internal:%d/%s",
167+
inspectorUIPort, transportTypeStr, serverPort, suffix)
157168
logger.Infof("Inspector UI is now available at %s", inspectorURL)
158169
return nil
159170
case <-ctx.Done():
160171
return fmt.Errorf("context cancelled while waiting for container to start")
161172
}
162173
}
163174

164-
func getServerPort(ctx context.Context, serverName string) (int, error) {
175+
func getServerPortAndTransport(ctx context.Context, serverName string) (int, types.TransportType, error) {
165176
// Instantiate the container manager
166177
manager, err := workloads.NewManager(ctx)
167178
if err != nil {
168-
return 0, fmt.Errorf("failed to create container manager: %v", err)
179+
return 0, types.TransportTypeSSE, fmt.Errorf("failed to create container manager: %v", err)
169180
}
170181

171182
// Get list of all containers
172183
containers, err := manager.ListWorkloads(ctx, true)
173184
if err != nil {
174-
return 0, fmt.Errorf("failed to list containers: %v", err)
185+
return 0, types.TransportTypeSSE, fmt.Errorf("failed to list containers: %v", err)
175186
}
176187

177188
for _, c := range containers {
@@ -180,12 +191,15 @@ func getServerPort(ctx context.Context, serverName string) (int, error) {
180191
if name == serverName {
181192
// Get port from labels
182193
port := c.Port
183-
// Generate URL for the MCP server
184-
if port > 0 {
185-
return port, nil
194+
if port <= 0 {
195+
return 0, types.TransportTypeSSE, fmt.Errorf("server %s does not have a valid port", serverName)
186196
}
197+
198+
// now get the transport type from labels
199+
transportType := c.TransportType
200+
return port, transportType, nil
187201
}
188202
}
189203

190-
return 0, fmt.Errorf("server with name %s not found", serverName)
204+
return 0, types.TransportTypeSSE, fmt.Errorf("server with name %s not found", serverName)
191205
}

cmd/thv/app/registry.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ func printTextServerInfo(name string, server *registry.Server) {
155155
fmt.Printf("Image: %s\n", server.Image)
156156
fmt.Printf("Description: %s\n", server.Description)
157157
fmt.Printf("Transport: %s\n", server.Transport)
158-
if server.Transport == "sse" && server.TargetPort > 0 {
158+
if (server.Transport == "sse" || server.Transport == "streamable-http") && server.TargetPort > 0 {
159159
fmt.Printf("Target Port: %d\n", server.TargetPort)
160160
}
161161
fmt.Printf("Repository URL: %s\n", server.RepositoryURL)

cmd/thv/app/run.go

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"github.com/stacklok/toolhive/pkg/runner"
2323
"github.com/stacklok/toolhive/pkg/secrets"
2424
"github.com/stacklok/toolhive/pkg/transport"
25+
"github.com/stacklok/toolhive/pkg/transport/types"
2526
"github.com/stacklok/toolhive/pkg/workloads"
2627
)
2728

@@ -100,16 +101,17 @@ var (
100101
)
101102

102103
func init() {
103-
runCmd.Flags().StringVar(&runTransport, "transport", "stdio", "Transport mode (sse or stdio)")
104+
runCmd.Flags().StringVar(&runTransport, "transport", "stdio", "Transport mode (sse, streamable-http or stdio)")
104105
runCmd.Flags().StringVar(&runName, "name", "", "Name of the MCP server (auto-generated from image if not provided)")
105106
runCmd.Flags().StringVar(&runHost, "host", transport.LocalhostIPv4, "Host for the HTTP proxy to listen on (IP or hostname)")
106107
runCmd.Flags().IntVar(&runPort, "port", 0, "Port for the HTTP proxy to listen on (host port)")
107-
runCmd.Flags().IntVar(&runTargetPort, "target-port", 0, "Port for the container to expose (only applicable to SSE transport)")
108+
runCmd.Flags().IntVar(&runTargetPort, "target-port", 0,
109+
"Port for the container to expose (only applicable to SSE or Streamable HTTP transport)")
108110
runCmd.Flags().StringVar(
109111
&runTargetHost,
110112
"target-host",
111113
transport.LocalhostIPv4,
112-
"Host to forward traffic to (only applicable to SSE transport)")
114+
"Host to forward traffic to (only applicable to SSE or Streamable HTTP transport)")
113115
runCmd.Flags().StringVar(
114116
&runPermissionProfile,
115117
"permission-profile",
@@ -433,8 +435,9 @@ func applyRegistrySettings(
433435
runTransport, server.Transport)
434436
}
435437

436-
// Use registry target port if not overridden and transport is SSE
437-
if !cmd.Flags().Changed("target-port") && server.Transport == "sse" && server.TargetPort > 0 {
438+
// Use registry target port if not overridden and transport is SSE or Streamable HTTP
439+
if !cmd.Flags().Changed("target-port") && (server.Transport == types.TransportTypeSSE.String() ||
440+
server.Transport == types.TransportTypeStreamableHTTP.String()) && server.TargetPort > 0 {
438441
logDebug(debugMode, "Using registry target port: %d", server.TargetPort)
439442
runTargetPort = server.TargetPort
440443
}

deploy/charts/operator-crds/Chart.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@ apiVersion: v2
22
name: toolhive-operator-crds
33
description: A Helm chart for installing the ToolHive Operator CRDs into Kubernetes.
44
type: application
5-
version: 0.0.6
5+
version: 0.0.7
66
appVersion: "0.0.1"

deploy/charts/operator-crds/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11

22
# ToolHive Operator CRDs Helm Chart
33

4-
![Version: 0.0.6](https://img.shields.io/badge/Version-0.0.6-informational?style=flat-square)
4+
![Version: 0.0.7](https://img.shields.io/badge/Version-0.0.7-informational?style=flat-square)
55
![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square)
66

77
A Helm chart for installing the ToolHive Operator CRDs into Kubernetes.

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8316,9 +8316,10 @@ spec:
83168316
transport:
83178317
default: stdio
83188318
description: Transport is the transport method for the MCP server
8319-
(stdio or sse)
8319+
(stdio, streamable-http or sse)
83208320
enum:
83218321
- stdio
8322+
- streamable-http
83228323
- sse
83238324
type: string
83248325
volumes:

docs/cli/thv_run.md

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

0 commit comments

Comments
 (0)