Skip to content

Commit 955fcf1

Browse files
authored
Add support for baking build-time arguments in container ENTRYPOINTs (#2631)
Add build-time arguments support with secure template refactoring Add buildArgs parameter to container templates, allowing required subcommands to be baked into the ENTRYPOINT at build time. Runtime arguments passed via "--" are appended after build args. Key improvements: - Add MCPPackageClean field that strips version suffixes in Go code with tests - Simplify NPX to direct JSON array ENTRYPOINT (no shell wrapper needed) - Simplify UVX to use clean package variable instead of shell expansion - Add validation to reject single quotes in buildArgs (prevents shell injection) - Update thv build command to accept --build-arg flag - Add comprehensive test coverage for all transport types Template complexity reduction: - NPX: 9 lines of shell script → 2 lines of JSON array (78% reduction) - Version stripping: centralized, testable, handles scoped packages correctly - Prevents NPX from re-pulling packages when @latest is specified Security: Single quotes in buildArgs are validated and rejected to prevent command injection in UVX template's sh -c execution. NPX and GO use JSON arrays without shell interpretation and remain unaffected. --------- Signed-off-by: Dan Barr <[email protected]> Co-authored-by: Dan Barr <[email protected]>
1 parent 4d73318 commit 955fcf1

File tree

9 files changed

+421
-29
lines changed

9 files changed

+421
-29
lines changed

cmd/thv/app/build.go

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import (
1212
)
1313

1414
var buildCmd = &cobra.Command{
15-
Use: "build [flags] PROTOCOL",
15+
Use: "build [flags] PROTOCOL [-- ARGS...]",
1616
Short: "Build a container for an MCP server without running it",
1717
Long: `Build a container for an MCP server using a protocol scheme without running it.
1818
@@ -28,15 +28,28 @@ using either uvx (Python with uv package manager), npx (Node.js),
2828
or go (Golang). For Go, you can also specify local paths starting
2929
with './' or '../' to build local Go projects.
3030
31+
Build-time arguments can be baked into the container's ENTRYPOINT:
32+
33+
$ thv build npx://@launchdarkly/mcp-server -- start
34+
$ thv build uvx://package -- --transport stdio
35+
36+
These arguments become part of the container image and will always run,
37+
with runtime arguments (from 'thv run -- <args>') appending after them.
38+
3139
The container will be built and tagged locally, ready to be used with 'thv run'
3240
or other container tools. The built image name will be displayed upon successful completion.
3341
3442
Examples:
3543
$ thv build uvx://mcp-server-git
3644
$ thv build --tag my-custom-name:latest npx://@modelcontextprotocol/server-filesystem
37-
$ thv build go://./my-local-server`,
38-
Args: cobra.ExactArgs(1),
45+
$ thv build go://./my-local-server
46+
$ thv build npx://@launchdarkly/mcp-server -- start`,
47+
Args: cobra.MinimumNArgs(1),
3948
RunE: buildCmdFunc,
49+
// Ignore unknown flags to allow passing args after --
50+
FParseErrWhitelist: cobra.FParseErrWhitelist{
51+
UnknownFlags: true,
52+
},
4053
}
4154

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

85+
// Parse build arguments using os.Args to find everything after --
86+
buildArgs := parseCommandArguments(os.Args)
87+
logger.Debugf("Build args: %v", buildArgs)
88+
7289
// Create image manager (even for dry-run, we pass it but it won't be used)
7390
imageManager := images.NewImageManager(ctx)
7491

7592
// If dry-run or output is specified, just generate the Dockerfile
7693
if buildFlags.DryRun || buildFlags.Output != "" {
77-
dockerfileContent, err := runner.BuildFromProtocolSchemeWithName(ctx, imageManager, protocolScheme, "", buildFlags.Tag, true)
94+
dockerfileContent, err := runner.BuildFromProtocolSchemeWithName(
95+
ctx, imageManager, protocolScheme, "", buildFlags.Tag, buildArgs, true)
7896
if err != nil {
7997
return fmt.Errorf("failed to generate Dockerfile for %s: %v", protocolScheme, err)
8098
}
@@ -96,7 +114,7 @@ func buildCmdFunc(cmd *cobra.Command, args []string) error {
96114
logger.Infof("Building container for protocol scheme: %s", protocolScheme)
97115

98116
// Build the image using the new protocol handler with custom name
99-
imageName, err := runner.BuildFromProtocolSchemeWithName(ctx, imageManager, protocolScheme, "", buildFlags.Tag, false)
117+
imageName, err := runner.BuildFromProtocolSchemeWithName(ctx, imageManager, protocolScheme, "", buildFlags.Tag, buildArgs, false)
100118
if err != nil {
101119
return fmt.Errorf("failed to build container for %s: %v", protocolScheme, err)
102120
}

docs/cli/thv_build.md

Lines changed: 10 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/container/templates/go.tmpl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,4 +99,4 @@ COPY --from=builder --chown=appuser:appgroup /build/ /app/
9999
USER appuser
100100

101101
# Run the pre-built MCP server binary
102-
ENTRYPOINT ["/app/mcp-server"]
102+
ENTRYPOINT ["/app/mcp-server"{{range .BuildArgs}}, "{{.}}"{{end}}]

pkg/container/templates/npx.tmpl

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -95,11 +95,6 @@ ENV NODE_PATH=/app/node_modules \
9595
# Switch to non-root user
9696
USER appuser
9797

98-
# `MCPPackage` may include a version suffix (e.g., `[email protected]`), which we cannot use here.
99-
# Create a small wrapper script to handle this.
100-
RUN echo "#!/bin/sh" >> entrypoint.sh && \
101-
echo "exec npx {{.MCPPackage}} \"\$@\"" >> entrypoint.sh && \
102-
chmod +x entrypoint.sh
103-
104-
# Run the preinstalled MCP package directly using npx.
105-
ENTRYPOINT ["./entrypoint.sh"]
98+
# Run the preinstalled MCP package directly using npx
99+
# MCPPackageClean has version suffix already stripped (e.g., @org/[email protected] -> @org/package)
100+
ENTRYPOINT ["npx", "{{.MCPPackageClean}}"{{range .BuildArgs}}, "{{.}}"{{end}}]

pkg/container/templates/templates.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"bytes"
77
"embed"
88
"fmt"
9+
"regexp"
910
"text/template"
1011
)
1112

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

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

46+
// stripVersionSuffix removes version suffixes from package names.
47+
// It strips @version from the end of package names while preserving scoped package prefixes.
48+
// Examples:
49+
// - "@org/[email protected]" -> "@org/package"
50+
// - "[email protected]" -> "package"
51+
// - "@org/package" -> "@org/package" (no version, unchanged)
52+
// - "package" -> "package" (no version, unchanged)
53+
func stripVersionSuffix(pkg string) string {
54+
// Match @version at the end, where version doesn't contain @ or /
55+
// This preserves scoped packages like @org/package
56+
re := regexp.MustCompile(`@[^@/]*$`)
57+
return re.ReplaceAllString(pkg, "")
58+
}
59+
3760
// GetDockerfileTemplate returns the Dockerfile template for the specified transport type.
3861
func GetDockerfileTemplate(transportType TransportType, data TemplateData) (string, error) {
62+
// Populate MCPPackageClean with version-stripped package name
63+
data.MCPPackageClean = stripVersionSuffix(data.MCPPackage)
64+
3965
var templateName string
4066

4167
// Determine the template name based on the transport type

pkg/container/templates/templates_test.go

Lines changed: 118 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ func TestGetDockerfileTemplate(t *testing.T) {
3030
"package_spec=$(echo \"$package\" | sed 's/@/==/')",
3131
"uv tool install \"$package_spec\"",
3232
"COPY --from=builder --chown=appuser:appgroup /opt/uv-tools /opt/uv-tools",
33-
"ENTRYPOINT [\"sh\", \"-c\", \"package='example-package'; exec \\\"${package%%@*}\\\"\", \"--\"]",
33+
"ENTRYPOINT [\"sh\", \"-c\", \"exec 'example-package' \\\"$@\\\"\", \"--\"]",
3434
},
3535
wantMatches: []string{
3636
`FROM python:\d+\.\d+-slim AS builder`, // Match builder stage
@@ -56,7 +56,7 @@ func TestGetDockerfileTemplate(t *testing.T) {
5656
"package_spec=$(echo \"$package\" | sed 's/@/==/')",
5757
"uv tool install \"$package_spec\"",
5858
"COPY --from=builder --chown=appuser:appgroup /opt/uv-tools /opt/uv-tools",
59-
"ENTRYPOINT [\"sh\", \"-c\", \"package='example-package'; exec \\\"${package%%@*}\\\"\", \"--\"]",
59+
"ENTRYPOINT [\"sh\", \"-c\", \"exec 'example-package' \\\"$@\\\"\", \"--\"]",
6060
"Add custom CA certificate BEFORE any network operations",
6161
"COPY ca-cert.crt /tmp/custom-ca.crt",
6262
"cat /tmp/custom-ca.crt >> /etc/ssl/certs/ca-certificates.crt",
@@ -79,8 +79,7 @@ func TestGetDockerfileTemplate(t *testing.T) {
7979
"FROM node:",
8080
"npm install --save example-package",
8181
"COPY --from=builder --chown=appuser:appgroup /build/node_modules /app/node_modules",
82-
"echo \"exec npx example-package \\\"\\$@\\\"\" >> entrypoint.sh",
83-
"ENTRYPOINT [\"./entrypoint.sh\"]",
82+
`ENTRYPOINT ["npx", "example-package"]`,
8483
},
8584
wantMatches: []string{
8685
`FROM node:\d+-alpine AS builder`, // Match builder stage
@@ -102,8 +101,7 @@ func TestGetDockerfileTemplate(t *testing.T) {
102101
wantContains: []string{
103102
"FROM node:",
104103
"npm install --save example-package",
105-
"echo \"exec npx example-package \\\"\\$@\\\"\" >> entrypoint.sh",
106-
"ENTRYPOINT [\"./entrypoint.sh\"]",
104+
`ENTRYPOINT ["npx", "example-package"]`,
107105
"Add custom CA certificate BEFORE any network operations",
108106
"COPY ca-cert.crt /tmp/custom-ca.crt",
109107
"cat /tmp/custom-ca.crt >> /etc/ssl/certs/ca-certificates.crt",
@@ -216,6 +214,65 @@ func TestGetDockerfileTemplate(t *testing.T) {
216214
},
217215
wantErr: false,
218216
},
217+
{
218+
name: "NPX transport with BuildArgs",
219+
transportType: TransportTypeNPX,
220+
data: TemplateData{
221+
MCPPackage: "@launchdarkly/mcp-server",
222+
BuildArgs: []string{"start"},
223+
},
224+
wantContains: []string{
225+
"FROM node:",
226+
"npm install --save @launchdarkly/mcp-server",
227+
"COPY --from=builder --chown=appuser:appgroup /build/node_modules /app/node_modules",
228+
`ENTRYPOINT ["npx", "@launchdarkly/mcp-server", "start"]`,
229+
},
230+
wantMatches: []string{
231+
`FROM node:\d+-alpine AS builder`,
232+
`FROM node:\d+-alpine`,
233+
},
234+
wantNotContains: nil,
235+
wantErr: false,
236+
},
237+
{
238+
name: "UVX transport with BuildArgs",
239+
transportType: TransportTypeUVX,
240+
data: TemplateData{
241+
MCPPackage: "example-package",
242+
BuildArgs: []string{"--transport", "stdio"},
243+
},
244+
wantContains: []string{
245+
"FROM python:",
246+
"uv tool install \"$package_spec\"",
247+
"ENTRYPOINT [\"sh\", \"-c\", \"exec 'example-package' '--transport' 'stdio' \\\"$@\\\"\", \"--\"]",
248+
},
249+
wantMatches: []string{
250+
`FROM python:\d+\.\d+-slim AS builder`,
251+
`FROM python:\d+\.\d+-slim`,
252+
},
253+
wantNotContains: nil,
254+
wantErr: false,
255+
},
256+
{
257+
name: "GO transport with BuildArgs",
258+
transportType: TransportTypeGO,
259+
data: TemplateData{
260+
MCPPackage: "example-package",
261+
BuildArgs: []string{"serve", "--verbose"},
262+
},
263+
wantContains: []string{
264+
"FROM golang:",
265+
"go install \"$package\"",
266+
"FROM alpine:",
267+
"ENTRYPOINT [\"/app/mcp-server\", \"serve\", \"--verbose\"]",
268+
},
269+
wantMatches: []string{
270+
`FROM golang:\d+\.\d+-alpine AS builder`,
271+
`FROM alpine:\d+\.\d+`,
272+
},
273+
wantNotContains: nil,
274+
wantErr: false,
275+
},
219276
{
220277
name: "Unsupported transport",
221278
transportType: "unsupported",
@@ -318,3 +375,58 @@ func TestParseTransportType(t *testing.T) {
318375
})
319376
}
320377
}
378+
379+
func TestStripVersionSuffix(t *testing.T) {
380+
t.Parallel()
381+
tests := []struct {
382+
name string
383+
input string
384+
want string
385+
}{
386+
{
387+
name: "scoped package with version",
388+
input: "@launchdarkly/[email protected]",
389+
want: "@launchdarkly/mcp-server",
390+
},
391+
{
392+
name: "regular package with version",
393+
394+
want: "example-package",
395+
},
396+
{
397+
name: "scoped package without version",
398+
input: "@org/package",
399+
want: "@org/package",
400+
},
401+
{
402+
name: "regular package without version",
403+
input: "package",
404+
want: "package",
405+
},
406+
{
407+
name: "package with latest tag",
408+
input: "package@latest",
409+
want: "package",
410+
},
411+
{
412+
name: "scoped package with semver",
413+
input: "@scope/name@^1.2.3",
414+
want: "@scope/name",
415+
},
416+
{
417+
name: "package with prerelease version",
418+
419+
want: "package",
420+
},
421+
}
422+
423+
for _, tt := range tests {
424+
t.Run(tt.name, func(t *testing.T) {
425+
t.Parallel()
426+
got := stripVersionSuffix(tt.input)
427+
if got != tt.want {
428+
t.Errorf("stripVersionSuffix(%q) = %q, want %q", tt.input, got, tt.want)
429+
}
430+
})
431+
}
432+
}

pkg/container/templates/uvx.tmpl

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,6 @@ USER appuser
115115

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

0 commit comments

Comments
 (0)