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
28 changes: 23 additions & 5 deletions cmd/thv/app/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import (
)

var buildCmd = &cobra.Command{
Use: "build [flags] PROTOCOL",
Use: "build [flags] PROTOCOL [-- ARGS...]",
Short: "Build a container for an MCP server without running it",
Long: `Build a container for an MCP server using a protocol scheme without running it.

Expand All @@ -28,15 +28,28 @@ using either uvx (Python with uv package manager), npx (Node.js),
or go (Golang). For Go, you can also specify local paths starting
with './' or '../' to build local Go projects.

Build-time arguments can be baked into the container's ENTRYPOINT:

$ thv build npx://@launchdarkly/mcp-server -- start
$ thv build uvx://package -- --transport stdio

These arguments become part of the container image and will always run,
with runtime arguments (from 'thv run -- <args>') appending after them.

The container will be built and tagged locally, ready to be used with 'thv run'
or other container tools. The built image name will be displayed upon successful completion.

Examples:
$ thv build uvx://mcp-server-git
$ thv build --tag my-custom-name:latest npx://@modelcontextprotocol/server-filesystem
$ thv build go://./my-local-server`,
Args: cobra.ExactArgs(1),
$ thv build go://./my-local-server
$ thv build npx://@launchdarkly/mcp-server -- start`,
Args: cobra.MinimumNArgs(1),
RunE: buildCmdFunc,
// Ignore unknown flags to allow passing args after --
FParseErrWhitelist: cobra.FParseErrWhitelist{
UnknownFlags: true,
},
}

var buildFlags BuildFlags
Expand Down Expand Up @@ -69,12 +82,17 @@ func buildCmdFunc(cmd *cobra.Command, args []string) error {
return fmt.Errorf("invalid protocol scheme: %s. Supported schemes are: uvx://, npx://, go://", protocolScheme)
}

// Parse build arguments using os.Args to find everything after --
buildArgs := parseCommandArguments(os.Args)
logger.Debugf("Build args: %v", buildArgs)

// 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, true)
dockerfileContent, err := runner.BuildFromProtocolSchemeWithName(
ctx, imageManager, protocolScheme, "", buildFlags.Tag, buildArgs, true)
if err != nil {
return fmt.Errorf("failed to generate Dockerfile for %s: %v", protocolScheme, err)
}
Expand All @@ -96,7 +114,7 @@ func buildCmdFunc(cmd *cobra.Command, args []string) error {
logger.Infof("Building container for protocol scheme: %s", protocolScheme)

// Build the image using the new protocol handler with custom name
imageName, err := runner.BuildFromProtocolSchemeWithName(ctx, imageManager, protocolScheme, "", buildFlags.Tag, false)
imageName, err := runner.BuildFromProtocolSchemeWithName(ctx, imageManager, protocolScheme, "", buildFlags.Tag, buildArgs, false)
if err != nil {
return fmt.Errorf("failed to build container for %s: %v", protocolScheme, err)
}
Expand Down
11 changes: 10 additions & 1 deletion docs/cli/thv_build.md

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

2 changes: 1 addition & 1 deletion pkg/container/templates/go.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -99,4 +99,4 @@ COPY --from=builder --chown=appuser:appgroup /build/ /app/
USER appuser

# Run the pre-built MCP server binary
ENTRYPOINT ["/app/mcp-server"]
ENTRYPOINT ["/app/mcp-server"{{range .BuildArgs}}, "{{.}}"{{end}}]
11 changes: 3 additions & 8 deletions pkg/container/templates/npx.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -95,11 +95,6 @@ ENV NODE_PATH=/app/node_modules \
# Switch to non-root user
USER appuser

# `MCPPackage` may include a version suffix (e.g., `package@1.2.3`), which we cannot use here.
# Create a small wrapper script to handle this.
RUN echo "#!/bin/sh" >> entrypoint.sh && \
echo "exec npx {{.MCPPackage}} \"\$@\"" >> entrypoint.sh && \
chmod +x entrypoint.sh

# Run the preinstalled MCP package directly using npx.
ENTRYPOINT ["./entrypoint.sh"]
# Run the preinstalled MCP package directly using npx
# MCPPackageClean has version suffix already stripped (e.g., @org/package@1.2.3 -> @org/package)
ENTRYPOINT ["npx", "{{.MCPPackageClean}}"{{range .BuildArgs}}, "{{.}}"{{end}}]
26 changes: 26 additions & 0 deletions pkg/container/templates/templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"bytes"
"embed"
"fmt"
"regexp"
"text/template"
)

Expand All @@ -16,10 +17,18 @@ var templateFS embed.FS
type TemplateData struct {
// MCPPackage is the name of the MCP package to run.
MCPPackage string
// MCPPackageClean is the package name with version suffix removed.
// For example: "@org/package@1.2.3" becomes "@org/package", "package@1.0.0" becomes "package"
// This field is automatically populated by GetDockerfileTemplate.
MCPPackageClean string
// CACertContent is the content of the custom CA certificate to include in the image.
CACertContent string
// IsLocalPath indicates if the MCPPackage is a local path that should be copied into the container.
IsLocalPath bool
// BuildArgs are the arguments to bake into the container's ENTRYPOINT at build time.
// These are typically required subcommands (e.g., "start") that must always be present.
// Runtime arguments passed via "-- <args>" will be appended after these build args.
BuildArgs []string
}

// TransportType represents the type of transport to use.
Expand All @@ -34,8 +43,25 @@ const (
TransportTypeGO TransportType = "go"
)

// stripVersionSuffix removes version suffixes from package names.
// It strips @version from the end of package names while preserving scoped package prefixes.
// Examples:
// - "@org/package@1.2.3" -> "@org/package"
// - "package@1.0.0" -> "package"
// - "@org/package" -> "@org/package" (no version, unchanged)
// - "package" -> "package" (no version, unchanged)
func stripVersionSuffix(pkg string) string {
// Match @version at the end, where version doesn't contain @ or /
// This preserves scoped packages like @org/package
re := regexp.MustCompile(`@[^@/]*$`)
return re.ReplaceAllString(pkg, "")
}

// GetDockerfileTemplate returns the Dockerfile template for the specified transport type.
func GetDockerfileTemplate(transportType TransportType, data TemplateData) (string, error) {
// Populate MCPPackageClean with version-stripped package name
data.MCPPackageClean = stripVersionSuffix(data.MCPPackage)

var templateName string

// Determine the template name based on the transport type
Expand Down
124 changes: 118 additions & 6 deletions pkg/container/templates/templates_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ func TestGetDockerfileTemplate(t *testing.T) {
"package_spec=$(echo \"$package\" | sed 's/@/==/')",
"uv tool install \"$package_spec\"",
"COPY --from=builder --chown=appuser:appgroup /opt/uv-tools /opt/uv-tools",
"ENTRYPOINT [\"sh\", \"-c\", \"package='example-package'; exec \\\"${package%%@*}\\\"\", \"--\"]",
"ENTRYPOINT [\"sh\", \"-c\", \"exec 'example-package' \\\"$@\\\"\", \"--\"]",
},
wantMatches: []string{
`FROM python:\d+\.\d+-slim AS builder`, // Match builder stage
Expand All @@ -56,7 +56,7 @@ func TestGetDockerfileTemplate(t *testing.T) {
"package_spec=$(echo \"$package\" | sed 's/@/==/')",
"uv tool install \"$package_spec\"",
"COPY --from=builder --chown=appuser:appgroup /opt/uv-tools /opt/uv-tools",
"ENTRYPOINT [\"sh\", \"-c\", \"package='example-package'; exec \\\"${package%%@*}\\\"\", \"--\"]",
"ENTRYPOINT [\"sh\", \"-c\", \"exec 'example-package' \\\"$@\\\"\", \"--\"]",
"Add custom CA certificate BEFORE any network operations",
"COPY ca-cert.crt /tmp/custom-ca.crt",
"cat /tmp/custom-ca.crt >> /etc/ssl/certs/ca-certificates.crt",
Expand All @@ -79,8 +79,7 @@ func TestGetDockerfileTemplate(t *testing.T) {
"FROM node:",
"npm install --save example-package",
"COPY --from=builder --chown=appuser:appgroup /build/node_modules /app/node_modules",
"echo \"exec npx example-package \\\"\\$@\\\"\" >> entrypoint.sh",
"ENTRYPOINT [\"./entrypoint.sh\"]",
`ENTRYPOINT ["npx", "example-package"]`,
},
wantMatches: []string{
`FROM node:\d+-alpine AS builder`, // Match builder stage
Expand All @@ -102,8 +101,7 @@ func TestGetDockerfileTemplate(t *testing.T) {
wantContains: []string{
"FROM node:",
"npm install --save example-package",
"echo \"exec npx example-package \\\"\\$@\\\"\" >> entrypoint.sh",
"ENTRYPOINT [\"./entrypoint.sh\"]",
`ENTRYPOINT ["npx", "example-package"]`,
"Add custom CA certificate BEFORE any network operations",
"COPY ca-cert.crt /tmp/custom-ca.crt",
"cat /tmp/custom-ca.crt >> /etc/ssl/certs/ca-certificates.crt",
Expand Down Expand Up @@ -216,6 +214,65 @@ func TestGetDockerfileTemplate(t *testing.T) {
},
wantErr: false,
},
{
name: "NPX transport with BuildArgs",
transportType: TransportTypeNPX,
data: TemplateData{
MCPPackage: "@launchdarkly/mcp-server",
BuildArgs: []string{"start"},
},
wantContains: []string{
"FROM node:",
"npm install --save @launchdarkly/mcp-server",
"COPY --from=builder --chown=appuser:appgroup /build/node_modules /app/node_modules",
`ENTRYPOINT ["npx", "@launchdarkly/mcp-server", "start"]`,
},
wantMatches: []string{
`FROM node:\d+-alpine AS builder`,
`FROM node:\d+-alpine`,
},
wantNotContains: nil,
wantErr: false,
},
{
name: "UVX transport with BuildArgs",
transportType: TransportTypeUVX,
data: TemplateData{
MCPPackage: "example-package",
BuildArgs: []string{"--transport", "stdio"},
},
wantContains: []string{
"FROM python:",
"uv tool install \"$package_spec\"",
"ENTRYPOINT [\"sh\", \"-c\", \"exec 'example-package' '--transport' 'stdio' \\\"$@\\\"\", \"--\"]",
},
wantMatches: []string{
`FROM python:\d+\.\d+-slim AS builder`,
`FROM python:\d+\.\d+-slim`,
},
wantNotContains: nil,
wantErr: false,
},
{
name: "GO transport with BuildArgs",
transportType: TransportTypeGO,
data: TemplateData{
MCPPackage: "example-package",
BuildArgs: []string{"serve", "--verbose"},
},
wantContains: []string{
"FROM golang:",
"go install \"$package\"",
"FROM alpine:",
"ENTRYPOINT [\"/app/mcp-server\", \"serve\", \"--verbose\"]",
},
wantMatches: []string{
`FROM golang:\d+\.\d+-alpine AS builder`,
`FROM alpine:\d+\.\d+`,
},
wantNotContains: nil,
wantErr: false,
},
{
name: "Unsupported transport",
transportType: "unsupported",
Expand Down Expand Up @@ -318,3 +375,58 @@ func TestParseTransportType(t *testing.T) {
})
}
}

func TestStripVersionSuffix(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
want string
}{
{
name: "scoped package with version",
input: "@launchdarkly/mcp-server@1.2.3",
want: "@launchdarkly/mcp-server",
},
{
name: "regular package with version",
input: "example-package@1.0.0",
want: "example-package",
},
{
name: "scoped package without version",
input: "@org/package",
want: "@org/package",
},
{
name: "regular package without version",
input: "package",
want: "package",
},
{
name: "package with latest tag",
input: "package@latest",
want: "package",
},
{
name: "scoped package with semver",
input: "@scope/name@^1.2.3",
want: "@scope/name",
},
{
name: "package with prerelease version",
input: "package@1.0.0-beta.1",
want: "package",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := stripVersionSuffix(tt.input)
if got != tt.want {
t.Errorf("stripVersionSuffix(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
7 changes: 3 additions & 4 deletions pkg/container/templates/uvx.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,6 @@ USER appuser

# Run the pre-installed MCP package
# uv tool install puts the correct executable in the bin directory
# We use sh -c to allow the package name to be resolved from PATH
# Strip version specifier (if present) from package name for execution
# Handles format like package@version
ENTRYPOINT ["sh", "-c", "package='{{.MCPPackage}}'; exec \"${package%%@*}\"", "--"]
# MCPPackageClean has version suffix already stripped (e.g., package@1.2.3 -> package)
# BuildArgs use single quotes for safety - prevents shell injection
ENTRYPOINT ["sh", "-c", "exec '{{.MCPPackageClean}}'{{range .BuildArgs}} '{{.}}'{{end}} \"$@\"", "--"]
Loading
Loading