Skip to content

Support publishing additional container ports in thv run#3892

Open
Sanskarzz wants to merge 11 commits intostacklok:mainfrom
Sanskarzz:addports
Open

Support publishing additional container ports in thv run#3892
Sanskarzz wants to merge 11 commits intostacklok:mainfrom
Sanskarzz:addports

Conversation

@Sanskarzz
Copy link
Contributor

Fix: #3812

Summary

This PR adds support for the --publish / -p flag to the thv run command, enabling users to expose arbitrary container ports to the host, similar to docker run -p. It also fixes a bug in the Docker runtime client where explicitly requested host ports were being overwritten by random ports.

Key Changes

  • Added --publish flag to thv run.
  • Implemented networking.ParsePortSpec for robust port specification parsing.
  • Updated runtime.Setup to handle multiple port bindings efficiently.
  • Fixed pkg/container/docker/client.go to respect existing HostPort assignments in generatePortBindings.
  • Added unit tests for port parsing logic.

Signed-off-by: Sanskarzz <sanskar.gur@gmail.com>
@github-actions github-actions bot added the size/S Small PR: 100-299 lines changed label Feb 19, 2026
Signed-off-by: Sanskarzz <sanskar.gur@gmail.com>
@github-actions github-actions bot added size/S Small PR: 100-299 lines changed and removed size/S Small PR: 100-299 lines changed labels Feb 19, 2026
@codecov
Copy link

codecov bot commented Feb 19, 2026

Codecov Report

❌ Patch coverage is 44.44444% with 30 lines in your changes missing coverage. Please review.
✅ Project coverage is 67.98%. Comparing base (0657df9) to head (b386476).

Files with missing lines Patch % Lines
pkg/runtime/setup.go 0.00% 15 Missing ⚠️
pkg/networking/port.go 71.42% 3 Missing and 3 partials ⚠️
pkg/container/docker/client.go 61.53% 2 Missing and 3 partials ⚠️
pkg/runner/config_builder.go 0.00% 4 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3892      +/-   ##
==========================================
- Coverage   68.10%   67.98%   -0.13%     
==========================================
  Files         461      461              
  Lines       46700    46760      +60     
==========================================
- Hits        31804    31788      -16     
- Misses      12131    12177      +46     
- Partials     2765     2795      +30     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@github-actions github-actions bot added size/S Small PR: 100-299 lines changed and removed size/S Small PR: 100-299 lines changed labels Feb 19, 2026
Signed-off-by: Sanskarzz <sanskar.gur@gmail.com>
@github-actions github-actions bot added size/S Small PR: 100-299 lines changed and removed size/S Small PR: 100-299 lines changed labels Feb 19, 2026
Copy link
Contributor

@jerm-dro jerm-dro left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mostly LGTM to me. Can you manually verify this fixes the user's reported issue by running this locally? A series of screenshots or video will do.

Comment on lines +131 to +153
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)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: can you rewrite these assertions to use the require package (e.g.require.Equal or require.True)?

This will make the tests less nested and easier to read.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have rewritten the nested if statements into cleaner require assertions in pkg/networking/port_test.go as requested.

Comment on lines +1640 to +1654
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)
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this the fix for:

It also fixes a bug in the Docker runtime client where explicitly requested host ports were being overwritten by random ports.

?

If so, can you find a way to add a regression test and some unit tests here? The generatePortBindings function seems like it should be amenable to unit testing without any refactoring.

Copy link
Contributor Author

@Sanskarzz Sanskarzz Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added two new regression tests for generatePortBindings to client_helpers_test.go:

  • TestGeneratePortBindings_NonAuxiliaryKeepsExplicitHostPort: ensures explicit host ports (e.g., "9090") are kept instead of replaced with a random port.
  • TestGeneratePortBindings_NonAuxiliaryAssignsRandomPortForZero: ensures passing "0" correctly assigns a random available port.

Sanskarzz and others added 2 commits March 8, 2026 19:44
Signed-off-by: Sanskarzz <sanskar.gur@gmail.com>
@github-actions github-actions bot added size/S Small PR: 100-299 lines changed and removed size/S Small PR: 100-299 lines changed labels Mar 8, 2026
Signed-off-by: Sanskarzz <sanskar.gur@gmail.com>
@github-actions github-actions bot removed the size/S Small PR: 100-299 lines changed label Mar 8, 2026
@github-actions github-actions bot added the size/S Small PR: 100-299 lines changed label Mar 8, 2026
Signed-off-by: Sanskarzz <sanskar.gur@gmail.com>
@github-actions github-actions bot added size/S Small PR: 100-299 lines changed and removed size/S Small PR: 100-299 lines changed labels Mar 9, 2026
@Sanskarzz
Copy link
Contributor Author

Mostly LGTM to me. Can you manually verify this fixes the user's reported issue by running this locally? A series of screenshots or a video will do.

It looks like I need to create one or two MCP servers that are exposed on multiple ports. One of them is ghcr.io/manykarim/rf-mcp:latest, which was mentioned in the discussion. I have tested it earlier, but I’ll run it again locally and share screenshots shortly.

@jerm-dro, if you have a similar MCP server in your inventory that exposes multiple ports, could you please share it?

@Sanskarzz
Copy link
Contributor Author

Mostly LGTM to me. Can you manually verify this fixes the user's reported issue by running this locally? A series of screenshots or video will do.

Apologies for the late response
Here is a series of commands to verify this fix

sanskar@sanskar-HP-Laptop-15s-du1xxx:~/opensource/stacklok/toolhive$ ./bin/thv run --name manykarim-rf-mcp \
  --transport streamable-http \
  --target-port 8002 \
  --publish 8004:8001 \
  ghcr.io/manykarim/rf-mcp:latest \
  -- --host 0.0.0.0 --port 8002 & sleep 10 && docker ps | grep manykarim-rf-mcp && ./bin/thv status manykarim-rf-mcp 
[1] 192790
{"time":"2026-03-11T19:15:58+05:30","level":"WARN","msg":"MCP server has no provenance information set, skipping image verification","image":"ghcr.io/manykarim/rf-mcp:latest"}
{"time":"2026-03-11T19:15:58+05:30","level":"INFO","msg":"pulling image","image":"ghcr.io/manykarim/rf-mcp:latest"}
Successfully pulled ghcr.io/manykarim/rf-mcp:latest
{"time":"2026-03-11T19:16:02+05:30","level":"INFO","msg":"logging to","path":"/home/sanskar/.local/share/toolhive/logs/manykarim-rf-mcp.log"}
[1]+  Done                    ./bin/thv run --name manykarim-rf-mcp --transport streamable-http --target-port 8002 --publish 8004:8001 ghcr.io/manykarim/rf-mcp:latest -- --host 0.0.0.0 --port 8002
bc5271e828a5   ghcr.io/manykarim/rf-mcp:latest            "uv run --no-sync ro…"   6 seconds ago   Up 5 seconds                      8000/tcp, 127.0.0.1:8002->8002/tcp, 0.0.0.0:8004->8001/tcp, [::]:8004->8001/tcp                                 manykarim-rf-mcp
Name:        manykarim-rf-mcp
Status:      starting
Package:     
URL:         
Port:        0
Transport:   
Created:     2026-03-11 19:08:50
sanskar@sanskar-HP-Laptop-15s-du1xxx:~/opensource/stacklok/toolhive$ ./bin/thv status manykarim-rf-mcp 
Name:         manykarim-rf-mcp
Status:       running
Package:      ghcr.io/manykarim/rf-mcp:latest
URL:          http://127.0.0.1:52771/mcp
Port:         52771
Transport:    streamable-http
Proxy Mode:   streamable-http
Group:        default
Created:      2026-03-11 19:08:50
Uptime:       6m
sanskar@sanskar-HP-Laptop-15s-du1xxx:~/opensource/stacklok/toolhive$ docker ps | grep manykarim-rf-mcp
bc5271e828a5   ghcr.io/manykarim/rf-mcp:latest            "uv run --no-sync ro…"   6 minutes ago   Up 6 minutes                      8000/tcp, 127.0.0.1:8002->8002/tcp, 0.0.0.0:8004->8001/tcp, [::]:8004->8001/tcp                                 manykarim-rf-mcp
image

jerm-dro
jerm-dro previously approved these changes Mar 11, 2026
Copy link
Contributor

@jerm-dro jerm-dro left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for including the screenshots of the manual validation 😃


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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit, feel free to ignore: could also assert non-zero

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added assert.NotEqual(t, "0", out["8080/tcp"][0].HostPort) to the test in client_helpers_test.go.

Signed-off-by: Sanskarzz <sanskar.gur@gmail.com>
@github-actions github-actions bot added size/S Small PR: 100-299 lines changed and removed size/S Small PR: 100-299 lines changed labels Mar 13, 2026
@github-actions github-actions bot added size/S Small PR: 100-299 lines changed and removed size/S Small PR: 100-299 lines changed labels Mar 13, 2026
@github-actions github-actions bot added size/S Small PR: 100-299 lines changed and removed size/S Small PR: 100-299 lines changed labels Mar 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/S Small PR: 100-299 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support publishing additional container ports in thv run

2 participants