Skip to content
Merged
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
2 changes: 1 addition & 1 deletion cmd/thv/app/run_flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ func AddRunFlags(cmd *cobra.Command, config *RunFlags) {
cmd.Flags().StringVar(&config.RuntimeImage, "runtime-image", "",
"Override the default base image for protocol schemes (e.g., golang:1.24-alpine, node:20-alpine, python:3.11-slim)")
cmd.Flags().StringArrayVar(&config.RuntimeAddPackages, "runtime-add-package", []string{},
"Add additional packages to install in the builder stage (can be repeated)")
"Add additional packages to install in the builder and runtime stages (can be repeated)")
cmd.Flags().StringVar(&config.VerifyImage, "image-verification", retriever.VerifyImageWarn,
fmt.Sprintf("Set image verification mode (%s, %s, %s)",
retriever.VerifyImageWarn, retriever.VerifyImageEnabled, retriever.VerifyImageDisabled))
Expand Down
2 changes: 1 addition & 1 deletion docs/arch/05-runconfig-and-permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ The complete `RunConfig` struct is defined in `pkg/runner/config.go`.
- Go: Default `golang:1.25-alpine`
- Node: Default `node:22-alpine`
- Python: Default `python:3.13-slim`
- `additional_packages`: Extra packages to install during build (e.g., build tools, libraries)
- `additional_packages`: Extra packages to install during the build and runtime stages (e.g., build tools, libraries)

**CLI usage:**
```bash
Expand Down
2 changes: 1 addition & 1 deletion docs/cli/thv_run.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions docs/runtime-version-customization.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ When you use protocol schemes like `thv run go://github.com/example/server`, Too
- **Node**: `node:22-alpine` (builder and runtime)
- **Python**: `python:3.13-slim` (builder and runtime)

You can customize these base images to use different versions or add additional build packages.
You can customize these base images to use different versions or add additional build and runtime packages.

## Use Cases

Expand Down Expand Up @@ -40,7 +40,7 @@ thv run uvx://mcp-server-sqlite --runtime-image python:3.11-slim

### `--runtime-add-package`

Add additional packages to install during the build stage. Can be repeated multiple times.
Add additional packages to install during the build and runtime stages. Can be repeated multiple times.

**Examples:**

Expand Down
4 changes: 2 additions & 2 deletions docs/server/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions docs/server/swagger.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions docs/server/swagger.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 6 additions & 2 deletions pkg/container/templates/go.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ RUN cat /tmp/custom-ca.crt >> /etc/ssl/certs/ca-certificates.crt && \
rm /tmp/custom-ca.crt
{{end}}

{{if .RuntimeConfig.AdditionalPackages}}
# Install build dependencies
RUN apk add --no-cache {{range .RuntimeConfig.AdditionalPackages}}{{.}} {{end}}
{{end}}

{{if .CACertContent}}
# Properly install the custom CA certificate using standard tools
Expand Down Expand Up @@ -81,8 +83,10 @@ RUN cat /tmp/custom-ca.crt >> /etc/ssl/certs/ca-certificates.crt && \
rm /tmp/custom-ca.crt
{{end}}

# Install only runtime dependencies
RUN apk add --no-cache ca-certificates
{{if .RuntimeConfig.AdditionalPackages}}
# Install runtime dependencies
RUN apk add --no-cache {{range .RuntimeConfig.AdditionalPackages}}{{.}} {{end}}
{{end}}

# Set working directory
WORKDIR /app
Expand Down
6 changes: 5 additions & 1 deletion pkg/container/templates/npx.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ RUN cat /tmp/custom-ca.crt >> /etc/ssl/certs/ca-certificates.crt && \
rm /tmp/custom-ca.crt
{{end}}

{{if .RuntimeConfig.AdditionalPackages}}
# Install build dependencies
RUN apk add --no-cache {{range .RuntimeConfig.AdditionalPackages}}{{.}} {{end}}
{{end}}

{{if .CACertContent}}
# Properly install the custom CA certificate using standard tools
Expand Down Expand Up @@ -68,8 +70,10 @@ RUN cat /tmp/custom-ca.crt >> /etc/ssl/certs/ca-certificates.crt && \
rm /tmp/custom-ca.crt
{{end}}

{{if .RuntimeConfig.AdditionalPackages}}
# Install runtime dependencies
RUN apk add --no-cache ca-certificates
RUN apk add --no-cache {{range .RuntimeConfig.AdditionalPackages}}{{.}} {{end}}
{{end}}

# Set working directory
WORKDIR /app
Expand Down
6 changes: 4 additions & 2 deletions pkg/container/templates/runtime_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,13 @@ var packageNamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._+\-]*$`)

// RuntimeConfig defines the base images and versions for a specific runtime
type RuntimeConfig struct {
// BuilderImage is the full image reference for the builder stage
// BuilderImage is the full image reference for the builder stage.
// An empty string signals "use the default for this transport type" during config merging.
// Examples: "golang:1.25-alpine", "node:22-alpine", "python:3.13-slim"
BuilderImage string `json:"builder_image" yaml:"builder_image"`

// AdditionalPackages lists extra packages to install in builder stage
// AdditionalPackages lists extra packages to install in the builder and
// runtime stages.
// Examples for Alpine: ["git", "make", "gcc"]
// Examples for Debian: ["git", "build-essential"]
AdditionalPackages []string `json:"additional_packages,omitempty" yaml:"additional_packages,omitempty"`
Expand Down
151 changes: 151 additions & 0 deletions pkg/container/templates/templates_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,157 @@ func TestGetDockerfileTemplate(t *testing.T) {
}
}

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

tests := []struct {
name string
transportType TransportType
runtimeConfig *RuntimeConfig
wantInRuntime string // string that must appear AFTER the second FROM
}{
{
name: "NPX runtime stage installs extra packages",
transportType: TransportTypeNPX,
runtimeConfig: &RuntimeConfig{
BuilderImage: "node:22-alpine",
AdditionalPackages: []string{"git", "ca-certificates", "curl"},
},
wantInRuntime: "curl",
},
{
name: "UVX runtime stage installs extra packages",
transportType: TransportTypeUVX,
runtimeConfig: &RuntimeConfig{
BuilderImage: "python:3.13-slim",
AdditionalPackages: []string{"ca-certificates", "git", "curl"},
},
wantInRuntime: "curl",
},
{
name: "GO runtime stage installs extra packages",
transportType: TransportTypeGO,
runtimeConfig: &RuntimeConfig{
BuilderImage: "golang:1.25-alpine",
AdditionalPackages: []string{"ca-certificates", "git", "curl"},
},
wantInRuntime: "curl",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

data := TemplateData{
MCPPackage: "test-package",
RuntimeConfig: tt.runtimeConfig,
}

result, err := GetDockerfileTemplate(tt.transportType, data)
if err != nil {
t.Fatalf("GetDockerfileTemplate() error = %v", err)
}

// Find the runtime stage (second FROM) and check that
// AdditionalPackages appear there, not just in the builder.
parts := strings.SplitN(result, "\nFROM ", 2)
if len(parts) < 2 {
t.Fatal("Dockerfile does not contain a second FROM (runtime stage)")
}
runtimeStage := parts[1]

if !strings.Contains(runtimeStage, tt.wantInRuntime) {
t.Errorf("runtime stage does not install %q.\nRuntime stage:\n%s", tt.wantInRuntime, runtimeStage)
}
})
}
}

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

tests := []struct {
name string
transportType TransportType
runtimeConfig *RuntimeConfig
}{
{
name: "NPX with empty packages",
transportType: TransportTypeNPX,
runtimeConfig: &RuntimeConfig{
BuilderImage: "node:22-alpine",
AdditionalPackages: []string{},
},
},
{
name: "GO with empty packages",
transportType: TransportTypeGO,
runtimeConfig: &RuntimeConfig{
BuilderImage: "golang:1.25-alpine",
AdditionalPackages: []string{},
},
},
{
name: "UVX with empty packages",
transportType: TransportTypeUVX,
runtimeConfig: &RuntimeConfig{
BuilderImage: "python:3.13-slim",
AdditionalPackages: []string{},
},
},
{
name: "NPX with nil packages",
transportType: TransportTypeNPX,
runtimeConfig: &RuntimeConfig{
BuilderImage: "node:22-alpine",
AdditionalPackages: nil,
},
},
{
name: "GO with nil packages",
transportType: TransportTypeGO,
runtimeConfig: &RuntimeConfig{
BuilderImage: "golang:1.25-alpine",
AdditionalPackages: nil,
},
},
{
name: "UVX with nil packages",
transportType: TransportTypeUVX,
runtimeConfig: &RuntimeConfig{
BuilderImage: "python:3.13-slim",
AdditionalPackages: nil,
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

data := TemplateData{
MCPPackage: "test-package",
RuntimeConfig: tt.runtimeConfig,
}

result, err := GetDockerfileTemplate(tt.transportType, data)
if err != nil {
t.Fatalf("GetDockerfileTemplate() error = %v", err)
}

// After "apk add --no-cache" or "apt-get install -y --no-install-recommends"
// there must be at least one package name (a word starting with [a-z]).
// If the next non-whitespace character isn't a letter, no packages were rendered.
noPackageAfterApk := regexp.MustCompile(`apk add --no-cache\s*([^a-z]|$)`)
noPackageAfterApt := regexp.MustCompile(`--no-install-recommends\s*([^a-z]|$)`)
if noPackageAfterApk.MatchString(result) || noPackageAfterApt.MatchString(result) {
t.Errorf("Dockerfile contains package install command with no packages.\nFull Dockerfile:\n%s", result)
}
})
}
}

func TestParseTransportType(t *testing.T) {
t.Parallel()
tests := []struct {
Expand Down
33 changes: 19 additions & 14 deletions pkg/container/templates/uvx.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,20 @@ RUN cat /tmp/custom-ca.crt >> /etc/ssl/certs/ca-certificates.crt && \

# Install build dependencies and uv package manager
{{if isDebian .RuntimeConfig.BuilderImage}}
RUN apt-get update && apt-get install -y --no-install-recommends \
{{range .RuntimeConfig.AdditionalPackages}}{{.}} {{end}} \
RUN apt-get update \
{{if .RuntimeConfig.AdditionalPackages}}&& apt-get install -y --no-install-recommends {{range .RuntimeConfig.AdditionalPackages}}{{.}} {{end}}{{end}} \
&& pip install --no-cache-dir uv \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
{{else if isAlpine .RuntimeConfig.BuilderImage}}
RUN apk add --no-cache {{range .RuntimeConfig.AdditionalPackages}}{{.}} {{end}} \
&& pip install --no-cache-dir uv
{{if .RuntimeConfig.AdditionalPackages}}RUN apk add --no-cache {{range .RuntimeConfig.AdditionalPackages}}{{.}} {{end}}&& \
pip install --no-cache-dir uv
{{else}}RUN pip install --no-cache-dir uv
{{end}}
{{else}}
# Default to Debian-based package manager
RUN apt-get update && apt-get install -y --no-install-recommends \
{{range .RuntimeConfig.AdditionalPackages}}{{.}} {{end}} \
RUN apt-get update \
{{if .RuntimeConfig.AdditionalPackages}}&& apt-get install -y --no-install-recommends {{range .RuntimeConfig.AdditionalPackages}}{{.}} {{end}}{{end}} \
&& pip install --no-cache-dir uv \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
Expand Down Expand Up @@ -90,18 +92,21 @@ RUN cat /tmp/custom-ca.crt >> /etc/ssl/certs/ca-certificates.crt && \
rm /tmp/custom-ca.crt
{{end}}

# Install only runtime dependencies
# Install runtime dependencies
{{if isDebian .RuntimeConfig.BuilderImage}}
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
RUN apt-get update \
{{if .RuntimeConfig.AdditionalPackages}}&& apt-get install -y --no-install-recommends {{range .RuntimeConfig.AdditionalPackages}}{{.}} {{end}}{{end}} \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
{{else if isAlpine .RuntimeConfig.BuilderImage}}
RUN apk add --no-cache ca-certificates
{{if .RuntimeConfig.AdditionalPackages}}RUN apk add --no-cache {{range .RuntimeConfig.AdditionalPackages}}{{.}} {{end}}
{{end}}
{{else}}
# Default to Debian-based package manager
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
RUN apt-get update \
{{if .RuntimeConfig.AdditionalPackages}}&& apt-get install -y --no-install-recommends {{range .RuntimeConfig.AdditionalPackages}}{{.}} {{end}}{{end}} \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
{{end}}

# Set working directory
Expand Down
Loading
Loading