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
18 changes: 16 additions & 2 deletions cmd/thv/app/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"github.com/spf13/cobra"

"github.com/stacklok/toolhive/pkg/container/images"
"github.com/stacklok/toolhive/pkg/container/templates"
"github.com/stacklok/toolhive/pkg/runner"
)

Expand Down Expand Up @@ -62,6 +63,7 @@ type BuildFlags struct {
Tag string
Output string
DryRun bool
UVWith []string
}

func init() {
Expand All @@ -77,6 +79,9 @@ func AddBuildFlags(cmd *cobra.Command, config *BuildFlags) {
"(default builds an image instead of generating a Dockerfile)")
cmd.Flags().BoolVar(&config.DryRun, "dry-run", false, "Generate Dockerfile without building (stdout output unless -o is set) "+
"(default false)")
cmd.Flags().StringArrayVar(&config.UVWith, "uv-with", []string{},
"Additional PEP 508 requirement specifier passed to 'uv tool install --with' for uvx:// builds, "+
"e.g. --uv-with 'mcp<2' to constrain a transitive dependency (can be specified multiple times)")
}

func buildCmdFunc(cmd *cobra.Command, args []string) error {
Expand All @@ -92,13 +97,22 @@ func buildCmdFunc(cmd *cobra.Command, args []string) error {
buildArgs := parseCommandArguments(os.Args)
slog.Debug(fmt.Sprintf("Build args: %v", buildArgs)) // #nosec G706 -- buildArgs are CLI arguments we control

// Build runtime config override from flags (if any) and validate it early.
var runtimeOverride *templates.RuntimeConfig
if len(buildFlags.UVWith) > 0 {
runtimeOverride = &templates.RuntimeConfig{UVWith: buildFlags.UVWith}
if err := runtimeOverride.Validate(); err != nil {
return fmt.Errorf("invalid runtime configuration: %w", err)
}
}

// Create image manager (even for dry-run, we pass it but it won't be used)
imageManager := images.NewImageManager(ctx)

// If dry-run or output is specified, just generate the Dockerfile
if buildFlags.DryRun || buildFlags.Output != "" {
dockerfileContent, err := runner.BuildFromProtocolSchemeWithName(
ctx, imageManager, protocolScheme, "", buildFlags.Tag, buildArgs, nil, true)
ctx, imageManager, protocolScheme, "", buildFlags.Tag, buildArgs, runtimeOverride, true)
if err != nil {
return fmt.Errorf("failed to generate Dockerfile for %s: %w", protocolScheme, err)
}
Expand All @@ -121,7 +135,7 @@ func buildCmdFunc(cmd *cobra.Command, args []string) error {

// Build the image using the new protocol handler with custom name
imageName, err := runner.BuildFromProtocolSchemeWithName(
ctx, imageManager, protocolScheme, "", buildFlags.Tag, buildArgs, nil, false)
ctx, imageManager, protocolScheme, "", buildFlags.Tag, buildArgs, runtimeOverride, false)
if err != nil {
return fmt.Errorf("failed to build container for %s: %w", protocolScheme, err)
}
Expand Down
10 changes: 8 additions & 2 deletions cmd/thv/app/run_flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ type RunFlags struct {
// Runtime configuration
RuntimeImage string
RuntimeAddPackages []string
UVWith []string

// WebhookConfigs is a list of paths to webhook configuration files.
// Each file may define validating and/or mutating webhooks.
Expand Down Expand Up @@ -222,6 +223,9 @@ func AddRunFlags(cmd *cobra.Command, config *RunFlags) {
"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 and runtime stages (can be repeated)")
cmd.Flags().StringArrayVar(&config.UVWith, "uv-with", []string{},
"Additional PEP 508 requirement specifier passed to 'uv tool install --with' for uvx:// builds, "+
"e.g. --uv-with 'mcp<2' to constrain a transitive dependency (can be specified multiple times)")
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 Expand Up @@ -495,10 +499,11 @@ func handleImageResolution(
// Validation here is intentionally duplicated with configureRuntimeOptions
// so that invalid input is caught early before registry lookups.
var runtimeOverride *templates.RuntimeConfig
if runFlags.RuntimeImage != "" || len(runFlags.RuntimeAddPackages) > 0 {
if runFlags.RuntimeImage != "" || len(runFlags.RuntimeAddPackages) > 0 || len(runFlags.UVWith) > 0 {
runtimeOverride = &templates.RuntimeConfig{
BuilderImage: runFlags.RuntimeImage,
AdditionalPackages: runFlags.RuntimeAddPackages,
UVWith: runFlags.UVWith,
}
if err := runtimeOverride.Validate(); err != nil {
return "", nil, fmt.Errorf("invalid runtime configuration: %w", err)
Expand Down Expand Up @@ -630,13 +635,14 @@ func configureRemoteHeaderOptions(runFlags *RunFlags) ([]runner.RunConfigBuilder
// It validates the configuration to prevent shell injection when values
// are interpolated into Dockerfile templates.
func configureRuntimeOptions(runFlags *RunFlags) ([]runner.RunConfigBuilderOption, error) {
if runFlags.RuntimeImage == "" && len(runFlags.RuntimeAddPackages) == 0 {
if runFlags.RuntimeImage == "" && len(runFlags.RuntimeAddPackages) == 0 && len(runFlags.UVWith) == 0 {
return nil, nil
}

runtimeConfig := &templates.RuntimeConfig{
BuilderImage: runFlags.RuntimeImage,
AdditionalPackages: runFlags.RuntimeAddPackages,
UVWith: runFlags.UVWith,
}
if err := runtimeConfig.Validate(); err != nil {
return nil, fmt.Errorf("invalid runtime configuration: %w", err)
Expand Down
9 changes: 5 additions & 4 deletions docs/cli/thv_build.md

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

1 change: 1 addition & 0 deletions docs/cli/thv_run.md

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

8 changes: 8 additions & 0 deletions docs/server/docs.go

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

8 changes: 8 additions & 0 deletions docs/server/swagger.json

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

10 changes: 10 additions & 0 deletions docs/server/swagger.yaml

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

31 changes: 31 additions & 0 deletions pkg/container/templates/runtime_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ const maxPackageNameLength = 128
// dots, underscores, plus signs, or hyphens.
var packageNamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._+\-]*$`)

// uvWithPattern matches a safe subset of PEP 508 requirement specifiers for
// UVWith entries: a package name (optionally with extras) followed by version
// specifiers, e.g. "mcp<2", "mcp>=1.27,<2", "pkg[extra]==1.2.*", "foo~=1.4".
// The allowlist deliberately excludes quotes, backticks, dollar signs,
// semicolons, parentheses, and backslashes: entries are interpolated into a
// single-quoted shell word inside a Dockerfile RUN instruction, so anything
// that could close the quote or expand in shell context is rejected.
var uvWithPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+\[\],<>=!~* -]*$`)

// envKeyPattern matches valid environment variable names for RuntimeEnv.
// Must start with an uppercase letter, followed by uppercase letters, numbers, or underscores.
var envKeyPattern = regexp.MustCompile(`^[A-Z][A-Z0-9_]*$`)
Expand Down Expand Up @@ -54,6 +63,12 @@ type RuntimeConfig struct {
// Examples for Debian: ["git", "build-essential"]
AdditionalPackages []string `json:"additional_packages,omitempty" yaml:"additional_packages,omitempty"`

// UVWith lists additional PEP 508 requirement specifiers passed to
// `uv tool install --with` when building uvx:// packages. Use it to
// constrain transitive dependencies that the package itself leaves
// unbounded (e.g. "mcp<2"). Ignored by npx:// and go:// builds.
UVWith []string `json:"uv_with,omitempty" yaml:"uv_with,omitempty"`

// RuntimeEnv contains environment variables to inject into the Dockerfile's
// final runtime stage. Unlike BuildEnv (pkg/container/templates.TemplateData.BuildEnv),
// which only affects the builder stage, these variables are baked into the
Expand Down Expand Up @@ -100,6 +115,22 @@ func (rc *RuntimeConfig) Validate() error {
}
}

// Validate each UVWith entry against a strict allowlist so specifiers
// cannot escape the single-quoted --with argument in the uvx Dockerfile.
for _, spec := range rc.UVWith {
if len(spec) > maxPackageNameLength {
errs = append(errs, fmt.Errorf(
"uv_with specifier %q exceeds maximum length of %d characters",
spec, maxPackageNameLength,
))
} else if !uvWithPattern.MatchString(spec) {
errs = append(errs, fmt.Errorf(
"invalid uv_with specifier %q: must match %s",
spec, uvWithPattern.String(),
))
}
}

// Validate each RuntimeEnv entry to ensure keys and values are safe to
// interpolate into a Dockerfile ENV instruction.
for key, value := range rc.RuntimeEnv {
Expand Down
72 changes: 72 additions & 0 deletions pkg/container/templates/runtime_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -457,3 +457,75 @@ func TestRuntimeConfigValidate_MultipleErrorsWithRuntimeEnv(t *testing.T) {
assert.Contains(t, err.Error(), "is reserved and cannot be overridden")
assert.Contains(t, err.Error(), "contains potentially dangerous characters")
}

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

valid := []string{
"mcp<2",
"mcp>=1.27,<2",
"mcp==1.29.0",
"package[extra]==1.2.*",
"foo~=1.4",
"Foo_bar!=2.0",
"pkg >= 1.0, < 3",
}
for _, spec := range valid {
rc := &RuntimeConfig{UVWith: []string{spec}}
assert.NoError(t, rc.Validate(), "specifier %q should be valid", spec)
}
}

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

invalid := []string{
"", // empty
"mcp<2'; rm -rf /", // single quote escapes the --with argument
"mcp<2\" || true", // double quote
"mcp<2`id`", // backtick command substitution
"mcp<2$(id)", // dollar command substitution
"mcp<2;id", // command separator
"mcp<2|id", // pipe
"mcp<2&id", // background
"mcp<2\\", // backslash
"mcp<2\ninject", // newline breaks out of the RUN line
"-e evil", // leading dash could become a flag
strings.Repeat("a", 129), // over length bound
"pkg; python_version<'3.8'", // env markers need quotes, deliberately unsupported
}
for _, spec := range invalid {
rc := &RuntimeConfig{UVWith: []string{spec}}
assert.Error(t, rc.Validate(), "specifier %q should be rejected", spec)
}
}

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

rc := GetDefaultRuntimeConfig(TransportTypeUVX)
rc.UVWith = []string{"mcp<2", "other>=1,<4"}
data := TemplateData{
MCPPackage: "arxiv-mcp-server",
RuntimeConfig: &rc,
}
dockerfile, err := GetDockerfileTemplate(TransportTypeUVX, data)
require.NoError(t, err)
assert.Contains(t, dockerfile, `uv tool install --with 'mcp<2' --with 'other>=1,<4' "$package_spec"`,
"each UVWith specifier must be passed as a single-quoted --with argument")
}

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

rc := GetDefaultRuntimeConfig(TransportTypeUVX)
data := TemplateData{
MCPPackage: "arxiv-mcp-server",
RuntimeConfig: &rc,
}
dockerfile, err := GetDockerfileTemplate(TransportTypeUVX, data)
require.NoError(t, err)
assert.Contains(t, dockerfile, `uv tool install "$package_spec"`,
"no --with arguments should appear when UVWith is empty")
assert.NotContains(t, dockerfile, "--with")
}
2 changes: 1 addition & 1 deletion pkg/container/templates/uvx.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ ENV UV_TOOL_DIR=/opt/uv-tools \
RUN package="{{.MCPPackage}}"; \
# Replace @ with == for uv tool install (Python uses == for version pinning)
package_spec=$(echo "$package" | sed 's/@/==/'); \
uv tool install "$package_spec" && \
uv tool install {{range .RuntimeConfig.UVWith}}--with '{{.}}' {{end}}"$package_spec" && \
# List installed executables for debugging
ls -la /opt/uv-tools/bin/
{{end}}
Expand Down
3 changes: 3 additions & 0 deletions pkg/runner/protocol.go
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,9 @@ func mergeRuntimeConfig(transportType templates.TransportType, override *templat

merged.RuntimeEnv = mergeEnvMaps(defaults.RuntimeEnv, override.RuntimeEnv)

// UVWith has no defaults; the override's specifiers are used as-is.
merged.UVWith = override.UVWith

return merged
}

Expand Down
17 changes: 17 additions & 0 deletions pkg/runner/protocol_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -719,3 +719,20 @@ func TestLoadRuntimeConfig_UsesOverrideBuilderImage(t *testing.T) {
assert.Equal(t, customImage, got.BuilderImage)
assert.Equal(t, base.AdditionalPackages, got.AdditionalPackages)
}

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

got := mergeRuntimeConfig(templates.TransportTypeUVX, &templates.RuntimeConfig{
UVWith: []string{"mcp<2"},
})
if len(got.UVWith) != 1 || got.UVWith[0] != "mcp<2" {
t.Errorf("UVWith = %v, want [mcp<2]", got.UVWith)
}

// No override specifiers: merged config must not invent any.
got = mergeRuntimeConfig(templates.TransportTypeUVX, &templates.RuntimeConfig{})
if len(got.UVWith) != 0 {
t.Errorf("UVWith = %v, want empty", got.UVWith)
}
}
18 changes: 6 additions & 12 deletions test/e2e/protocol_builds_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -206,22 +206,16 @@ var _ = Describe("Protocol Builds E2E", Label("mcp", "mcp-protocol", "protocols"
})

It("should build and start successfully and provide arxiv tools [Serial]", func() {
// Quarantined 2026-07-28: arxiv-mcp-server declares an unbounded
// mcp>=1.27.0, so every fresh uvx build now resolves the Python
// mcp 2.0.0 major (released 2026-07-28T13:45Z) and crashes at
// import ('Server' object has no attribute 'list_prompts'),
// failing this test repo-wide for every PR. The official
// reference servers (mcp-server-fetch/time/git) are equally
// unbounded and equally broken, so there is no safe swap-in
// target. Unskip when upstream bounds or updates its mcp
// dependency, or when the uvx builder supports dependency
// constraints. See stacklok/toolhive#6108.
Skip("arxiv-mcp-server import-crashes under Python mcp 2.0.0; see #6108")

By("Starting the ArXiv MCP server using uvx:// protocol")
// --uv-with pins the transitive Python mcp SDK below 2.0:
// arxiv-mcp-server declares an unbounded mcp>=1.27.0 and
// import-crashes under the mcp 2.0.0 major (see #6108). The
// constraint also exercises the uvx builder's --uv-with
// plumbing end-to-end.
stdout, stderr := e2e.NewTHVCommand(config, "run",
"--name", serverName,
"--transport", "stdio",
"--uv-with", "mcp<2",
"uvx://arxiv-mcp-server").ExpectSuccess()

// The command should indicate success and show build process
Expand Down
Loading