Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 29 additions & 7 deletions pkg/container/docker/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -256,10 +256,28 @@ func (c *Client) DeployWorkload(
networkName = ""
}

// only remap if is not an auxiliary tool
newPortBindings, hostPort, err := generatePortBindings(labels, options.PortBindings)
if err != nil {
return 0, fmt.Errorf("failed to generate port bindings: %v", err)
// Handle port binding generation differently for host vs bridge networking
var hostPort int
var newPortBindings map[string][]runtime.PortBinding
if permissionConfig.NetworkMode == "host" {
// For host networking, the container binds directly to the host port
// The port was set in MCP_PORT environment variable by the transport layer
mcpPort, ok := envVars["MCP_PORT"]
if !ok {
return 0, fmt.Errorf("MCP_PORT not found in environment variables for host networking")
}
hostPort, err = strconv.Atoi(mcpPort)
if err != nil {
return 0, fmt.Errorf("failed to parse MCP_PORT for host networking: %v", err)
}
newPortBindings = options.PortBindings // Use as-is (should be empty for host networking)
logger.Infof("Host networking: container listening on port %d (from MCP_PORT), will return this port", hostPort)
} else {
// For bridge/default networking, generate random port mappings
newPortBindings, hostPort, err = generatePortBindings(labels, options.PortBindings)
if err != nil {
return 0, fmt.Errorf("failed to generate port bindings: %v", err)
}
}

// Add a label to the MCP server indicating network isolation.
Expand Down Expand Up @@ -1425,9 +1443,13 @@ func (c *Client) createMcpContainer(ctx context.Context, name string, networkNam
return NewContainerError(err, "", err.Error())
}

// Setup port bindings
if err := setupPortBindings(hostConfig, portBindings); err != nil {
return NewContainerError(err, "", err.Error())
// Setup port bindings only for non-host networking
// Host networking mode is incompatible with port bindings as the container
// uses the host's network stack directly
if permissionConfig.NetworkMode != "host" {
if err := setupPortBindings(hostConfig, portBindings); err != nil {
return NewContainerError(err, "", err.Error())
}
}

// create mcp container
Expand Down
139 changes: 139 additions & 0 deletions pkg/container/docker/client_deploy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -308,3 +308,142 @@ func TestDeployWorkload_UnsupportedTransport_PropagatesError(t *testing.T) {
require.Error(t, err)
assert.Contains(t, err.Error(), "unsupported transport type")
}

func TestDeployWorkload_HostNetworking_UsesMCPPortAndSkipsExternalNetwork(t *testing.T) {
t.Parallel()

fops := &fakeDeployOps{}
c := newClientWithOps(fops)

opts := runtime.NewDeployWorkloadOptions()
opts.ExposedPorts = map[string]struct{}{"8080/tcp": {}}
// For host networking, port bindings should be empty (no mapping needed)
opts.PortBindings = map[string][]runtime.PortBinding{}

labels := map[string]string{}
envVars := map[string]string{
"MCP_PORT": "9876", // This port should be used as the host port
"EXISTING": "value",
}

// Create permission profile with host network mode
profile := &permissions.Profile{
Network: &permissions.NetworkPermissions{
Mode: "host",
},
}

hostPort, err := c.DeployWorkload(
t.Context(),
"ghcr.io/example/mcp:latest",
"hostnet",
[]string{"serve"},
envVars,
labels,
profile,
"sse",
opts,
false, // no network isolation (typical for host mode)
)
require.NoError(t, err)

// Verify host port comes from MCP_PORT environment variable
assert.Equal(t, 9876, hostPort)

// Verify external network creation was skipped
assert.False(t, fops.externalNetworksCalled, "external network creation should be skipped for host networking")

// Verify no auxiliary containers were created
assert.False(t, fops.dnsCalled, "DNS container should not be created for host networking")
assert.False(t, fops.egressCalled, "egress container should not be created for host networking")
assert.False(t, fops.ingressCalled, "ingress container should not be created for host networking")

// Verify MCP container was created with correct network mode
require.True(t, fops.mcpCalled)
require.NotNil(t, fops.mcpPermissionCfg)
assert.Equal(t, "host", fops.mcpPermissionCfg.NetworkMode)

// Port bindings should be empty for host networking
assert.Empty(t, fops.mcpPortBindings)

// Environment should include MCP_PORT
assert.Equal(t, "9876", fops.mcpEnvVars["MCP_PORT"])
}

func TestDeployWorkload_HostNetworking_ErrorsIfMCPPortMissing(t *testing.T) {
t.Parallel()

fops := &fakeDeployOps{}
c := newClientWithOps(fops)

opts := runtime.NewDeployWorkloadOptions()
opts.ExposedPorts = map[string]struct{}{"8080/tcp": {}}
opts.PortBindings = map[string][]runtime.PortBinding{}

envVars := map[string]string{
// MCP_PORT is missing
"EXISTING": "value",
}

profile := &permissions.Profile{
Network: &permissions.NetworkPermissions{
Mode: "host",
},
}

_, err := c.DeployWorkload(
t.Context(),
"ghcr.io/example/mcp:latest",
"hostnet-no-port",
[]string{"serve"},
envVars,
map[string]string{},
profile,
"sse",
opts,
false,
)

// Should error when MCP_PORT is missing for host networking
require.Error(t, err)
assert.Contains(t, err.Error(), "MCP_PORT not found in environment variables for host networking")
}

func TestDeployWorkload_HostNetworking_ErrorsIfMCPPortInvalid(t *testing.T) {
t.Parallel()

fops := &fakeDeployOps{}
c := newClientWithOps(fops)

opts := runtime.NewDeployWorkloadOptions()
opts.ExposedPorts = map[string]struct{}{"8080/tcp": {}}
opts.PortBindings = map[string][]runtime.PortBinding{}

envVars := map[string]string{
"MCP_PORT": "not-a-number", // Invalid port value
"EXISTING": "value",
}

profile := &permissions.Profile{
Network: &permissions.NetworkPermissions{
Mode: "host",
},
}

_, err := c.DeployWorkload(
t.Context(),
"ghcr.io/example/mcp:latest",
"hostnet-bad-port",
[]string{"serve"},
envVars,
map[string]string{},
profile,
"sse",
opts,
false,
)

// Should error when MCP_PORT is not a valid number
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to parse MCP_PORT for host networking")
}
57 changes: 36 additions & 21 deletions pkg/transport/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,17 @@ func (t *HTTPTransport) Setup(
}
envVars["MCP_TRANSPORT"] = env

// Check if we're using host networking mode
isHostNetworking := permissionProfile.Network != nil && permissionProfile.Network.Mode == "host"

if isHostNetworking {
// For host networking, both proxy and container ports are directly accessible on the host.
// No Docker port mapping is needed - both ports can coexist on 127.0.0.1
// The runner has already set proxyPort and targetPort to different values.
logger.Infof("Host networking detected, proxy on port %d will forward to container on port %d",
t.proxyPort, t.targetPort)
}

// Use the target port for the container's environment variables
envVars["MCP_PORT"] = fmt.Sprintf("%d", t.targetPort)
envVars["FASTMCP_PORT"] = fmt.Sprintf("%d", t.targetPort)
Expand All @@ -186,28 +197,32 @@ func (t *HTTPTransport) Setup(
containerOptions.K8sPodTemplatePatch = k8sPodTemplatePatch
containerOptions.IgnoreConfig = ignoreConfig

// Expose the target port in the container
containerPortStr := fmt.Sprintf("%d/tcp", t.targetPort)
containerOptions.ExposedPorts[containerPortStr] = struct{}{}

// Create host port bindings (configurable through the --host flag)
portBindings := []rt.PortBinding{
{
HostIP: t.host,
HostPort: fmt.Sprintf("%d", t.targetPort),
},
}
// Only set up port bindings and exposed ports for non-host networking
// Host networking doesn't support port bindings as containers use the host's network stack directly
if !isHostNetworking {
// Expose the target port in the container
containerPortStr := fmt.Sprintf("%d/tcp", t.targetPort)
containerOptions.ExposedPorts[containerPortStr] = struct{}{}

// Create host port bindings (configurable through the --host flag)
portBindings := []rt.PortBinding{
{
HostIP: t.host,
HostPort: fmt.Sprintf("%d", t.targetPort),
},
}

// Check if IPv6 is available and add IPv6 localhost binding (commented out for now)
//if networking.IsIPv6Available() {
// portBindings = append(portBindings, rt.PortBinding{
// HostIP: "::1", // IPv6 localhost
// HostPort: fmt.Sprintf("%d", t.targetPort),
// })
//}
// Check if IPv6 is available and add IPv6 localhost binding (commented out for now)
//if networking.IsIPv6Available() {
// portBindings = append(portBindings, rt.PortBinding{
// HostIP: "::1", // IPv6 localhost
// HostPort: fmt.Sprintf("%d", t.targetPort),
// })
//}

// Set the port bindings
containerOptions.PortBindings[containerPortStr] = portBindings
// Set the port bindings
containerOptions.PortBindings[containerPortStr] = portBindings
}

// For SSE transport, we don't need to attach stdio
containerOptions.AttachStdio = false
Expand All @@ -229,7 +244,7 @@ func (t *HTTPTransport) Setup(
if err != nil {
return fmt.Errorf("failed to create container: %v", err)
}
logger.Infof("Container created: %s", containerName)
logger.Infof("Container created: %s, exposedPort returned: %d", containerName, exposedPort)

if (t.Mode() == types.TransportTypeSSE || t.Mode() == types.TransportTypeStreamableHTTP) && rt.IsKubernetesRuntime() {
// If the SSEHeadlessServiceName is set, use it as the target host
Expand Down
Loading
Loading