From ea09ace57270f97a7538b2643476dfa32c928dd4 Mon Sep 17 00:00:00 2001 From: Sanskarzz Date: Thu, 19 Feb 2026 16:56:46 +0530 Subject: [PATCH 1/7] ref: support multi port like docker Signed-off-by: Sanskarzz --- cmd/thv/app/run_flags.go | 4 ++ pkg/container/docker/client.go | 26 +++++++----- pkg/networking/port.go | 44 ++++++++++++++++++++ pkg/networking/port_test.go | 74 ++++++++++++++++++++++++++++++++++ pkg/runner/config.go | 3 ++ pkg/runner/config_builder.go | 8 ++++ pkg/runner/runner.go | 1 + pkg/runtime/setup.go | 30 +++++++++++++- 8 files changed, 180 insertions(+), 10 deletions(-) diff --git a/cmd/thv/app/run_flags.go b/cmd/thv/app/run_flags.go index ee78ffa685..0c294c815f 100644 --- a/cmd/thv/app/run_flags.go +++ b/cmd/thv/app/run_flags.go @@ -45,6 +45,7 @@ type RunFlags struct { ProxyPort int TargetPort int TargetHost string + Publish []string // Server configuration Name string @@ -154,6 +155,8 @@ func AddRunFlags(cmd *cobra.Command, config *RunFlags) { "target-host", transport.LocalhostIPv4, "Host to forward traffic to (only applicable to SSE or Streamable HTTP transport)") + cmd.Flags().StringArrayVarP(&config.Publish, "publish", "p", []string{}, + "Publish a container's port(s) to the host (format: hostPort:containerPort)") cmd.Flags().StringVar( &config.PermissionProfile, "permission-profile", @@ -578,6 +581,7 @@ func buildRunnerConfig( LoadGlobal: runFlags.IgnoreGlobally, PrintOverlays: runFlags.PrintOverlays, }), + runner.WithPublish(runFlags.Publish), } // Load tools override configuration diff --git a/pkg/container/docker/client.go b/pkg/container/docker/client.go index 1c86b47836..2c6aa14ace 100644 --- a/pkg/container/docker/client.go +++ b/pkg/container/docker/client.go @@ -1619,7 +1619,7 @@ func generatePortBindings(labels map[string]string, portBindings map[string][]runtime.PortBinding) (map[string][]runtime.PortBinding, int, error) { var hostPort int // check if we need to map to a random port of not - if _, ok := labels["toolhive-auxiliary"]; ok && labels["toolhive-auxiliary"] == "true" { + if _, ok := labels[ToolhiveAuxiliaryWorkloadLabel]; ok && labels[ToolhiveAuxiliaryWorkloadLabel] == LabelValueTrue { // find first port var err error for _, bindings := range portBindings { @@ -1633,17 +1633,25 @@ func generatePortBindings(labels map[string]string, } } } else { - // bind to a random host port - hostPort = networking.FindAvailable() - if hostPort == 0 { - return nil, 0, fmt.Errorf("could not find an available port") - } - // first port binding needs to map to the host port + // For consistency, we only use FindAvailable for the primary port if it's not already set for key, bindings := range portBindings { if len(bindings) > 0 { - bindings[0].HostPort = fmt.Sprintf("%d", hostPort) - portBindings[key] = bindings + hostPortStr := bindings[0].HostPort + if hostPortStr == "" || hostPortStr == "0" { + hostPort = networking.FindAvailable() + if hostPort == 0 { + return nil, 0, fmt.Errorf("could not find an available port") + } + bindings[0].HostPort = fmt.Sprintf("%d", hostPort) + portBindings[key] = bindings + } else { + var err error + hostPort, err = strconv.Atoi(hostPortStr) + if err != nil { + return nil, 0, fmt.Errorf("failed to convert host port %s to int: %w", hostPortStr, err) + } + } break } } diff --git a/pkg/networking/port.go b/pkg/networking/port.go index ce1092fb7d..17b2b0204a 100644 --- a/pkg/networking/port.go +++ b/pkg/networking/port.go @@ -11,6 +11,8 @@ import ( "log/slog" "math/big" "net" + "strconv" + "strings" ) const ( @@ -177,3 +179,45 @@ func ValidateCallbackPort(callbackPort int, clientID string) error { func IsPreRegisteredClient(clientID string) bool { return clientID != "" } + +// ParsePortSpec parses a port specification string in the format "hostPort:containerPort" or just "containerPort". +// Returns the host port string and container port integer. +// If only a container port is provided, a random available host port is selected. +func ParsePortSpec(portSpec string) (string, int, error) { + slog.Debug("Parsing port spec", "spec", portSpec) + // Check if it's in host:container format + if strings.Contains(portSpec, ":") { + parts := strings.Split(portSpec, ":") + if len(parts) != 2 { + return "", 0, fmt.Errorf("invalid port specification: %s (expected 'hostPort:containerPort')", portSpec) + } + + hostPortStr := parts[0] + containerPortStr := parts[1] + + // Verify host port is a valid integer (or empty string if we supported random host port with :, but here we expect explicit) + if _, err := strconv.Atoi(hostPortStr); err != nil { + return "", 0, fmt.Errorf("invalid host port in spec '%s': %w", portSpec, err) + } + + containerPort, err := strconv.Atoi(containerPortStr) + if err != nil { + return "", 0, fmt.Errorf("invalid container port in spec '%s': %w", portSpec, err) + } + + return hostPortStr, containerPort, nil + } + + // Try parsing as just container port + containerPort, err := strconv.Atoi(portSpec) + if err == nil { + // Find a random available host port + hostPort := FindAvailable() + if hostPort == 0 { + return "", 0, fmt.Errorf("could not find an available port for container port %d", containerPort) + } + return fmt.Sprintf("%d", hostPort), containerPort, nil + } + + return "", 0, fmt.Errorf("invalid port specification: %s (expected 'hostPort:containerPort' or 'containerPort')", portSpec) +} diff --git a/pkg/networking/port_test.go b/pkg/networking/port_test.go index 7a816701b2..404e3f42b5 100644 --- a/pkg/networking/port_test.go +++ b/pkg/networking/port_test.go @@ -80,3 +80,77 @@ func TestValidateCallbackPort(t *testing.T) { }) } } + +func TestParsePortSpec(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + portSpec string + expectedHostPort string + expectedContainer int + wantError bool + }{ + { + name: "host:container", + portSpec: "8003:8001", + expectedHostPort: "8003", + expectedContainer: 8001, + wantError: false, + }, + { + name: "container only", + portSpec: "8001", + expectedHostPort: "", // Random + expectedContainer: 8001, + wantError: false, + }, + { + name: "invalid format", + portSpec: "invalid", + expectedHostPort: "", + expectedContainer: 0, + wantError: true, + }, + { + name: "invalid host port", + portSpec: "abc:8001", + expectedHostPort: "", + expectedContainer: 0, + wantError: true, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + hostPort, containerPort, err := networking.ParsePortSpec(tt.portSpec) + + if tt.wantError { + if err == nil { + t.Errorf("ParsePortSpec(%s) expected error but got nil", tt.portSpec) + } + return + } + + if err != nil { + t.Errorf("ParsePortSpec(%s) unexpected error: %v", tt.portSpec, err) + return + } + + if tt.expectedHostPort != "" && hostPort != tt.expectedHostPort { + t.Errorf("ParsePortSpec(%s) hostPort = %s, want %s", tt.portSpec, hostPort, tt.expectedHostPort) + } + + if tt.expectedHostPort == "" && hostPort == "" { + t.Errorf("ParsePortSpec(%s) hostPort is empty, want random port", tt.portSpec) + } + + if containerPort != tt.expectedContainer { + t.Errorf("ParsePortSpec(%s) containerPort = %d, want %d", tt.portSpec, containerPort, tt.expectedContainer) + } + }) + } +} diff --git a/pkg/runner/config.go b/pkg/runner/config.go index c7537e9fc7..daf1d0a450 100644 --- a/pkg/runner/config.go +++ b/pkg/runner/config.go @@ -83,6 +83,9 @@ type RunConfig struct { // TargetHost is the host to forward traffic to (only applicable to SSE transport) TargetHost string `json:"target_host,omitempty" yaml:"target_host,omitempty"` + // Publish lists ports to publish to the host in format "hostPort:containerPort" + Publish []string `json:"publish,omitempty" yaml:"publish,omitempty"` + // PermissionProfileNameOrPath is the name or path of the permission profile PermissionProfileNameOrPath string `json:"permission_profile_name_or_path,omitempty" yaml:"permission_profile_name_or_path,omitempty"` //nolint:lll diff --git a/pkg/runner/config_builder.go b/pkg/runner/config_builder.go index d37cc01c20..bba56c4ef6 100644 --- a/pkg/runner/config_builder.go +++ b/pkg/runner/config_builder.go @@ -162,6 +162,14 @@ func WithTargetHost(targetHost string) RunConfigBuilderOption { } } +// WithPublish sets the published ports +func WithPublish(publish []string) RunConfigBuilderOption { + return func(b *runConfigBuilder) error { + b.config.Publish = publish + return nil + } +} + // WithDebug sets debug mode func WithDebug(debug bool) RunConfigBuilderOption { return func(b *runConfigBuilder) error { diff --git a/pkg/runner/runner.go b/pkg/runner/runner.go index 214ae8f6ca..a89a07b783 100644 --- a/pkg/runner/runner.go +++ b/pkg/runner/runner.go @@ -297,6 +297,7 @@ func (r *Runner) Run(ctx context.Context) error { r.Config.Host, r.Config.TargetPort, r.Config.TargetHost, + r.Config.Publish, ) if err != nil { return fmt.Errorf("failed to set up workload: %w", err) diff --git a/pkg/runtime/setup.go b/pkg/runtime/setup.go index 93c743f91a..822352d9b2 100644 --- a/pkg/runtime/setup.go +++ b/pkg/runtime/setup.go @@ -12,6 +12,7 @@ import ( rt "github.com/stacklok/toolhive/pkg/container/runtime" "github.com/stacklok/toolhive/pkg/ignore" "github.com/stacklok/toolhive/pkg/logger" + "github.com/stacklok/toolhive/pkg/networking" "github.com/stacklok/toolhive/pkg/permissions" "github.com/stacklok/toolhive/pkg/transport/types" ) @@ -50,6 +51,7 @@ func Setup( host string, targetPort int, targetHost string, + publishedPorts []string, ) (*SetupResult, error) { // Add transport-specific environment variables env, ok := transportEnvMap[transportType] @@ -74,6 +76,26 @@ func Setup( containerOptions.K8sPodTemplatePatch = k8sPodTemplatePatch containerOptions.IgnoreConfig = ignoreConfig + // Process published ports + for _, portSpec := range publishedPorts { + hostPort, containerPort, err := networking.ParsePortSpec(portSpec) + if err != nil { + return nil, fmt.Errorf("failed to parse published port '%s': %w", portSpec, err) + } + + // Add to exposed ports + containerPortStr := fmt.Sprintf("%d/tcp", containerPort) + containerOptions.ExposedPorts[containerPortStr] = struct{}{} + + // Add to port bindings + // Check if we already have bindings for this port + bindings := containerOptions.PortBindings[containerPortStr] + bindings = append(bindings, rt.PortBinding{ + HostPort: hostPort, + }) + containerOptions.PortBindings[containerPortStr] = bindings + } + if transportType == types.TransportTypeStdio { containerOptions.AttachStdio = true } else { @@ -90,7 +112,13 @@ func Setup( } // Set the port bindings - containerOptions.PortBindings[containerPortStr] = portBindings + // Note: if the user explicitly publishes the target port using --publish, + // we append the default transport binding to the list of bindings for that port. + if _, ok := containerOptions.PortBindings[containerPortStr]; ok { + containerOptions.PortBindings[containerPortStr] = append(containerOptions.PortBindings[containerPortStr], portBindings...) + } else { + containerOptions.PortBindings[containerPortStr] = portBindings + } } // Create the container From bc0747ef99794eae6805683cc0f2cd946cefaae1 Mon Sep 17 00:00:00 2001 From: Sanskarzz Date: Thu, 19 Feb 2026 17:22:52 +0530 Subject: [PATCH 2/7] fix: CI error removed unwanted package from setup.go Signed-off-by: Sanskarzz --- pkg/runtime/setup.go | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/runtime/setup.go b/pkg/runtime/setup.go index dc8630af8c..668cbd37b3 100644 --- a/pkg/runtime/setup.go +++ b/pkg/runtime/setup.go @@ -12,7 +12,6 @@ import ( rt "github.com/stacklok/toolhive/pkg/container/runtime" "github.com/stacklok/toolhive/pkg/ignore" - "github.com/stacklok/toolhive/pkg/logger" "github.com/stacklok/toolhive/pkg/networking" "github.com/stacklok/toolhive/pkg/permissions" "github.com/stacklok/toolhive/pkg/transport/types" From 45115fd108a60c3d66ef51e77ccaa5d7a98b82e0 Mon Sep 17 00:00:00 2001 From: Sanskarzz Date: Thu, 19 Feb 2026 19:03:37 +0530 Subject: [PATCH 3/7] fix: add docs Signed-off-by: Sanskarzz --- docs/cli/thv_run.md | 1 + docs/server/docs.go | 8 ++++++++ docs/server/swagger.json | 8 ++++++++ docs/server/swagger.yaml | 6 ++++++ 4 files changed, 23 insertions(+) diff --git a/docs/cli/thv_run.md b/docs/cli/thv_run.md index 21d1cb0de8..3eac04527c 100644 --- a/docs/cli/thv_run.md +++ b/docs/cli/thv_run.md @@ -155,6 +155,7 @@ thv run [flags] SERVER_OR_IMAGE_OR_PROTOCOL [-- ARGS...] --print-resolved-overlays Debug: show resolved container paths for tmpfs overlays (default false) --proxy-mode string Proxy mode for stdio (streamable-http or sse (deprecated, will be removed)) (default "streamable-http") --proxy-port int Port for the HTTP proxy to listen on (host port) + -p, --publish stringArray Publish a container's port(s) to the host (format: hostPort:containerPort) --remote-auth Enable OAuth/OIDC authentication to remote MCP server (default false) --remote-auth-authorize-url string OAuth authorization endpoint URL (alternative to --remote-auth-issuer for non-OIDC OAuth) --remote-auth-bearer-token string Bearer token for remote server authentication (alternative to OAuth) diff --git a/docs/server/docs.go b/docs/server/docs.go index ca955c3c3d..332dee1547 100644 --- a/docs/server/docs.go +++ b/docs/server/docs.go @@ -1397,6 +1397,14 @@ const docTemplate = `{ "proxy_mode": { "$ref": "#/components/schemas/types.ProxyMode" }, + "publish": { + "description": "Publish lists ports to publish to the host in format \"hostPort:containerPort\"", + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, "remote_auth_config": { "$ref": "#/components/schemas/remote.Config" }, diff --git a/docs/server/swagger.json b/docs/server/swagger.json index 793b0ccb4b..b8d207c16c 100644 --- a/docs/server/swagger.json +++ b/docs/server/swagger.json @@ -1390,6 +1390,14 @@ "proxy_mode": { "$ref": "#/components/schemas/types.ProxyMode" }, + "publish": { + "description": "Publish lists ports to publish to the host in format \"hostPort:containerPort\"", + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, "remote_auth_config": { "$ref": "#/components/schemas/remote.Config" }, diff --git a/docs/server/swagger.yaml b/docs/server/swagger.yaml index 3ca634e4b2..d499932b43 100644 --- a/docs/server/swagger.yaml +++ b/docs/server/swagger.yaml @@ -1309,6 +1309,12 @@ components: type: integer proxy_mode: $ref: '#/components/schemas/types.ProxyMode' + publish: + description: Publish lists ports to publish to the host in format "hostPort:containerPort" + items: + type: string + type: array + uniqueItems: false remote_auth_config: $ref: '#/components/schemas/remote.Config' remote_url: From e2d12a7ba08e3c72cde3416d054795f85c707b2a Mon Sep 17 00:00:00 2001 From: Sanskarzz Date: Sun, 8 Mar 2026 19:44:17 +0530 Subject: [PATCH 4/7] fix: used require assertions Signed-off-by: Sanskarzz --- pkg/container/docker/client_helpers_test.go | 36 +++++++++++++++++++++ pkg/networking/port_test.go | 36 ++++++++------------- 2 files changed, 49 insertions(+), 23 deletions(-) diff --git a/pkg/container/docker/client_helpers_test.go b/pkg/container/docker/client_helpers_test.go index 477f8fce4c..a8f98c6b21 100644 --- a/pkg/container/docker/client_helpers_test.go +++ b/pkg/container/docker/client_helpers_test.go @@ -118,6 +118,42 @@ func TestGeneratePortBindings_NonAuxiliaryAssignsRandomPortAndMutatesFirstBindin assert.Equal(t, 1, countMatches, "expected exactly one first binding to be updated to hostPort=%s", expected) } +func TestGeneratePortBindings_NonAuxiliaryKeepsExplicitHostPort(t *testing.T) { + t.Parallel() + + labels := map[string]string{} // not auxiliary + in := map[string][]runtime.PortBinding{ + "8080/tcp": { + {HostIP: "", HostPort: "9090"}, + }, + } + out, hostPort, err := generatePortBindings(labels, in) + require.NoError(t, err) + require.Equal(t, 9090, hostPort) + + require.Contains(t, out, "8080/tcp") + require.Len(t, out["8080/tcp"], 1) + assert.Equal(t, "9090", out["8080/tcp"][0].HostPort) +} + +func TestGeneratePortBindings_NonAuxiliaryAssignsRandomPortForZero(t *testing.T) { + t.Parallel() + + labels := map[string]string{} // not auxiliary + in := map[string][]runtime.PortBinding{ + "8080/tcp": { + {HostIP: "", HostPort: "0"}, + }, + } + out, hostPort, err := generatePortBindings(labels, in) + require.NoError(t, err) + require.NotZero(t, hostPort) + + require.Contains(t, out, "8080/tcp") + require.Len(t, out["8080/tcp"], 1) + assert.Equal(t, fmt.Sprintf("%d", hostPort), out["8080/tcp"][0].HostPort) +} + func TestAddEgressEnvVars_SetsAll(t *testing.T) { t.Parallel() diff --git a/pkg/networking/port_test.go b/pkg/networking/port_test.go index 404e3f42b5..cee3777177 100644 --- a/pkg/networking/port_test.go +++ b/pkg/networking/port_test.go @@ -6,6 +6,8 @@ package networking_test import ( "testing" + "github.com/stretchr/testify/require" + "github.com/stacklok/toolhive/pkg/networking" ) @@ -67,15 +69,12 @@ func TestValidateCallbackPort(t *testing.T) { err := networking.ValidateCallbackPort(tt.port, tt.clientID) if tt.wantError { - if err == nil { - t.Errorf("ValidateCallbackPort() expected error but got nil") - } else if tt.errorMsg != "" && err.Error() != tt.errorMsg { - t.Errorf("ValidateCallbackPort() error = %v, want %v", err.Error(), tt.errorMsg) + require.Error(t, err) + if tt.errorMsg != "" { + require.EqualError(t, err, tt.errorMsg) } } else { - if err != nil { - t.Errorf("ValidateCallbackPort() unexpected error = %v", err) - } + require.NoError(t, err) } }) } @@ -129,28 +128,19 @@ func TestParsePortSpec(t *testing.T) { hostPort, containerPort, err := networking.ParsePortSpec(tt.portSpec) if tt.wantError { - if err == nil { - t.Errorf("ParsePortSpec(%s) expected error but got nil", tt.portSpec) - } + require.Error(t, err, "ParsePortSpec(%s) expected error", tt.portSpec) return } - if err != nil { - t.Errorf("ParsePortSpec(%s) unexpected error: %v", tt.portSpec, err) - return - } + require.NoError(t, err, "ParsePortSpec(%s) unexpected error", tt.portSpec) - if tt.expectedHostPort != "" && hostPort != tt.expectedHostPort { - t.Errorf("ParsePortSpec(%s) hostPort = %s, want %s", tt.portSpec, hostPort, tt.expectedHostPort) - } - - if tt.expectedHostPort == "" && hostPort == "" { - t.Errorf("ParsePortSpec(%s) hostPort is empty, want random port", tt.portSpec) + if tt.expectedHostPort != "" { + require.Equal(t, tt.expectedHostPort, hostPort, "ParsePortSpec(%s) unexpected host port", tt.portSpec) + } else { + require.NotEmpty(t, hostPort, "ParsePortSpec(%s) hostPort is empty, want random port", tt.portSpec) } - if containerPort != tt.expectedContainer { - t.Errorf("ParsePortSpec(%s) containerPort = %d, want %d", tt.portSpec, containerPort, tt.expectedContainer) - } + require.Equal(t, tt.expectedContainer, containerPort, "ParsePortSpec(%s) unexpected container port", tt.portSpec) }) } } From b6bf64aff5abc5d7ab88c5114caf2e892b78b157 Mon Sep 17 00:00:00 2001 From: Sanskarzz Date: Sun, 8 Mar 2026 20:03:41 +0530 Subject: [PATCH 5/7] fix: conflict Signed-off-by: Sanskarzz --- pkg/networking/port.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/networking/port.go b/pkg/networking/port.go index 33c133da52..54092cc123 100644 --- a/pkg/networking/port.go +++ b/pkg/networking/port.go @@ -222,6 +222,8 @@ func ParsePortSpec(portSpec string) (string, int, error) { } return "", 0, fmt.Errorf("invalid port specification: %s (expected 'hostPort:containerPort' or 'containerPort')", portSpec) +} + // GetProcessOnPort returns the PID of the process listening on the given TCP port. // Returns 0 if the port is free or if the holder cannot be determined. // Uses gopsutil which provides cross-platform support (Linux: /proc, Windows: GetExtendedTcpTable, From bab773576add29f85de2fb58e1d755fea3e7dad4 Mon Sep 17 00:00:00 2001 From: Sanskarzz Date: Mon, 9 Mar 2026 12:39:50 +0530 Subject: [PATCH 6/7] fix: CI Signed-off-by: Sanskarzz --- pkg/runtime/setup.go | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/runtime/setup.go b/pkg/runtime/setup.go index 597268b69c..3a67b53bc0 100644 --- a/pkg/runtime/setup.go +++ b/pkg/runtime/setup.go @@ -14,7 +14,6 @@ import ( rt "github.com/stacklok/toolhive/pkg/container/runtime" "github.com/stacklok/toolhive/pkg/ignore" "github.com/stacklok/toolhive/pkg/networking" - "github.com/stacklok/toolhive/pkg/permissions" "github.com/stacklok/toolhive/pkg/transport/types" ) From fbc81c3de8ea1fc772f627c87d43dd3dffe962c9 Mon Sep 17 00:00:00 2001 From: Sanskarzz Date: Fri, 13 Mar 2026 11:25:44 +0530 Subject: [PATCH 7/7] fix: assert non-zero Signed-off-by: Sanskarzz --- pkg/container/docker/client_helpers_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/container/docker/client_helpers_test.go b/pkg/container/docker/client_helpers_test.go index a8f98c6b21..7a5de202a1 100644 --- a/pkg/container/docker/client_helpers_test.go +++ b/pkg/container/docker/client_helpers_test.go @@ -151,6 +151,7 @@ func TestGeneratePortBindings_NonAuxiliaryAssignsRandomPortForZero(t *testing.T) require.Contains(t, out, "8080/tcp") require.Len(t, out["8080/tcp"], 1) + assert.NotEqual(t, "0", out["8080/tcp"][0].HostPort) assert.Equal(t, fmt.Sprintf("%d", hostPort), out["8080/tcp"][0].HostPort) }