diff --git a/cmd/thv/app/build.go b/cmd/thv/app/build.go index 1fce82d4d..330d83735 100644 --- a/cmd/thv/app/build.go +++ b/cmd/thv/app/build.go @@ -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. @@ -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 -- ') 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 @@ -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) } @@ -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) } diff --git a/docs/cli/thv_build.md b/docs/cli/thv_build.md index 5316e77ee..23dfd87bb 100644 --- a/docs/cli/thv_build.md +++ b/docs/cli/thv_build.md @@ -29,6 +29,14 @@ 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 -- ') 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. @@ -36,9 +44,10 @@ Examples: $ thv build uvx://mcp-server-git $ thv build --tag my-custom-name:latest npx://@modelcontextprotocol/server-filesystem $ thv build go://./my-local-server + $ thv build npx://@launchdarkly/mcp-server -- start ``` -thv build [flags] PROTOCOL +thv build [flags] PROTOCOL [-- ARGS...] ``` ### Options diff --git a/pkg/container/templates/go.tmpl b/pkg/container/templates/go.tmpl index 678ce78bf..b55f0fa0b 100644 --- a/pkg/container/templates/go.tmpl +++ b/pkg/container/templates/go.tmpl @@ -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"] \ No newline at end of file +ENTRYPOINT ["/app/mcp-server"{{range .BuildArgs}}, "{{.}}"{{end}}] \ No newline at end of file diff --git a/pkg/container/templates/npx.tmpl b/pkg/container/templates/npx.tmpl index 98ea9a467..b7377baa8 100644 --- a/pkg/container/templates/npx.tmpl +++ b/pkg/container/templates/npx.tmpl @@ -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}}] diff --git a/pkg/container/templates/templates.go b/pkg/container/templates/templates.go index a460ae8fa..d9223042c 100644 --- a/pkg/container/templates/templates.go +++ b/pkg/container/templates/templates.go @@ -6,6 +6,7 @@ import ( "bytes" "embed" "fmt" + "regexp" "text/template" ) @@ -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 "-- " will be appended after these build args. + BuildArgs []string } // TransportType represents the type of transport to use. @@ -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 diff --git a/pkg/container/templates/templates_test.go b/pkg/container/templates/templates_test.go index 52623581a..f49836444 100644 --- a/pkg/container/templates/templates_test.go +++ b/pkg/container/templates/templates_test.go @@ -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 @@ -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", @@ -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 @@ -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", @@ -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", @@ -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) + } + }) + } +} diff --git a/pkg/container/templates/uvx.tmpl b/pkg/container/templates/uvx.tmpl index 3b9849392..78af10970 100644 --- a/pkg/container/templates/uvx.tmpl +++ b/pkg/container/templates/uvx.tmpl @@ -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%%@*}\"", "--"] \ No newline at end of file +# 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}} \"$@\"", "--"] \ No newline at end of file diff --git a/pkg/runner/protocol.go b/pkg/runner/protocol.go index 80046da94..1c62a2ded 100644 --- a/pkg/runner/protocol.go +++ b/pkg/runner/protocol.go @@ -32,12 +32,13 @@ func HandleProtocolScheme( serverOrImage string, caCertPath string, ) (string, error) { - return BuildFromProtocolSchemeWithName(ctx, imageManager, serverOrImage, caCertPath, "", false) + return BuildFromProtocolSchemeWithName(ctx, imageManager, serverOrImage, caCertPath, "", nil, false) } // BuildFromProtocolSchemeWithName checks if the serverOrImage string contains a protocol scheme (uvx://, npx://, or go://) // and builds a Docker image for it if needed with a custom image name. // If imageName is empty, a default name will be generated. +// buildArgs are baked into the container's ENTRYPOINT at build time (e.g., required subcommands). // If dryRun is true, returns the Dockerfile content instead of building the image. // Returns the Docker image name (or Dockerfile content if dryRun) and any error encountered. func BuildFromProtocolSchemeWithName( @@ -46,6 +47,7 @@ func BuildFromProtocolSchemeWithName( serverOrImage string, caCertPath string, imageName string, + buildArgs []string, dryRun bool, ) (string, error) { transportType, packageName, err := ParseProtocolScheme(serverOrImage) @@ -53,7 +55,7 @@ func BuildFromProtocolSchemeWithName( return "", err } - templateData, err := createTemplateData(transportType, packageName, caCertPath) + templateData, err := createTemplateData(transportType, packageName, caCertPath, buildArgs) if err != nil { return "", err } @@ -84,14 +86,35 @@ func ParseProtocolScheme(serverOrImage string) (templates.TransportType, string, return "", "", fmt.Errorf("unsupported protocol scheme: %s", serverOrImage) } -// createTemplateData creates the template data with optional CA certificate. -func createTemplateData(transportType templates.TransportType, packageName, caCertPath string) (templates.TemplateData, error) { +// validateBuildArgs ensures buildArgs don't contain single quotes which would break +// shell quoting in the UVX template. Single quotes cannot be escaped within single-quoted +// strings in shell, making them the only character that can enable command injection. +// NPX and GO use JSON array ENTRYPOINTs without shell interpretation, so they're safe. +func validateBuildArgs(buildArgs []string) error { + for _, arg := range buildArgs { + if strings.Contains(arg, "'") { + return fmt.Errorf("buildArg cannot contain single quotes: %s", arg) + } + } + return nil +} + +// createTemplateData creates the template data with optional CA certificate and build arguments. +func createTemplateData( + transportType templates.TransportType, packageName, caCertPath string, buildArgs []string, +) (templates.TemplateData, error) { + // Validate buildArgs to prevent shell injection in templates that use sh -c + if err := validateBuildArgs(buildArgs); err != nil { + return templates.TemplateData{}, err + } + // Check if this is a local path (for Go packages only) isLocalPath := transportType == templates.TransportTypeGO && isLocalGoPath(packageName) templateData := templates.TemplateData{ MCPPackage: packageName, IsLocalPath: isLocalPath, + BuildArgs: buildArgs, } if caCertPath != "" { diff --git a/pkg/runner/protocol_test.go b/pkg/runner/protocol_test.go index 7b8fc8b2d..d44e88296 100644 --- a/pkg/runner/protocol_test.go +++ b/pkg/runner/protocol_test.go @@ -1,6 +1,8 @@ package runner import ( + "context" + "strings" "testing" "github.com/stacklok/toolhive/pkg/container/templates" @@ -189,6 +191,7 @@ func TestTemplateDataWithLocalPath(t *testing.T) { expected: templates.TemplateData{ MCPPackage: "github.com/example/package", IsLocalPath: false, + BuildArgs: nil, }, }, { @@ -197,6 +200,7 @@ func TestTemplateDataWithLocalPath(t *testing.T) { expected: templates.TemplateData{ MCPPackage: "./cmd/server", IsLocalPath: true, + BuildArgs: nil, }, }, { @@ -205,6 +209,7 @@ func TestTemplateDataWithLocalPath(t *testing.T) { expected: templates.TemplateData{ MCPPackage: ".", IsLocalPath: true, + BuildArgs: nil, }, }, } @@ -218,6 +223,7 @@ func TestTemplateDataWithLocalPath(t *testing.T) { templateData := templates.TemplateData{ MCPPackage: tt.packageName, IsLocalPath: isLocalPath, + BuildArgs: nil, } if templateData.MCPPackage != tt.expected.MCPPackage { @@ -229,3 +235,207 @@ func TestTemplateDataWithLocalPath(t *testing.T) { }) } } + +func TestBuildFromProtocolSchemeWithNameDryRun(t *testing.T) { + t.Parallel() + tests := []struct { + name string + serverOrImage string + caCertPath string + buildArgs []string + wantContains []string + wantErr bool + }{ + { + name: "NPX with buildArgs in dry-run", + serverOrImage: "npx://@launchdarkly/mcp-server", + buildArgs: []string{"start"}, + wantContains: []string{ + `ENTRYPOINT ["npx", "@launchdarkly/mcp-server", "start"]`, + "FROM node:22-alpine", + }, + wantErr: false, + }, + { + name: "UVX with multiple buildArgs in dry-run", + serverOrImage: "uvx://example-package", + buildArgs: []string{"--transport", "stdio"}, + wantContains: []string{ + "example-package", + "--transport", + "stdio", + "FROM python:3.13-slim", + }, + wantErr: false, + }, + { + name: "GO with buildArgs in dry-run", + serverOrImage: "go://github.com/example/package", + buildArgs: []string{"serve"}, + wantContains: []string{ + `ENTRYPOINT ["/app/mcp-server", "serve"]`, + }, + wantErr: false, + }, + { + name: "NPX with buildArgs and invalid CA cert path", + serverOrImage: "npx://@launchdarkly/mcp-server", + caCertPath: "/nonexistent/ca-cert.crt", + buildArgs: []string{"start"}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ctx := context.Background() + + // Call BuildFromProtocolSchemeWithName with dry-run=true + dockerfileContent, err := BuildFromProtocolSchemeWithName( + ctx, nil, tt.serverOrImage, tt.caCertPath, "", tt.buildArgs, true) + + if (err != nil) != tt.wantErr { + t.Errorf("BuildFromProtocolSchemeWithName() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if err == nil { + for _, want := range tt.wantContains { + if !strings.Contains(dockerfileContent, want) { + t.Errorf("Dockerfile does not contain expected string %q", want) + } + } + } + }) + } +} + +func TestCreateTemplateData(t *testing.T) { + t.Parallel() + tests := []struct { + name string + transportType templates.TransportType + packageName string + caCertPath string + buildArgs []string + expected templates.TemplateData + wantErr bool + }{ + { + name: "NPX with buildArgs", + transportType: templates.TransportTypeNPX, + packageName: "@launchdarkly/mcp-server", + caCertPath: "", + buildArgs: []string{"start"}, + expected: templates.TemplateData{ + MCPPackage: "@launchdarkly/mcp-server", + IsLocalPath: false, + BuildArgs: []string{"start"}, + }, + wantErr: false, + }, + { + name: "UVX with multiple buildArgs", + transportType: templates.TransportTypeUVX, + packageName: "example-package", + caCertPath: "", + buildArgs: []string{"--transport", "stdio"}, + expected: templates.TemplateData{ + MCPPackage: "example-package", + IsLocalPath: false, + BuildArgs: []string{"--transport", "stdio"}, + }, + wantErr: false, + }, + { + name: "GO with buildArgs", + transportType: templates.TransportTypeGO, + packageName: "github.com/example/package", + caCertPath: "", + buildArgs: []string{"serve", "--verbose"}, + expected: templates.TemplateData{ + MCPPackage: "github.com/example/package", + IsLocalPath: false, + BuildArgs: []string{"serve", "--verbose"}, + }, + wantErr: false, + }, + { + name: "GO local path with buildArgs", + transportType: templates.TransportTypeGO, + packageName: "./cmd/server", + caCertPath: "", + buildArgs: []string{"--config", "config.yaml"}, + expected: templates.TemplateData{ + MCPPackage: "./cmd/server", + IsLocalPath: true, + BuildArgs: []string{"--config", "config.yaml"}, + }, + wantErr: false, + }, + { + name: "NPX without buildArgs", + transportType: templates.TransportTypeNPX, + packageName: "package-name", + caCertPath: "", + buildArgs: nil, + expected: templates.TemplateData{ + MCPPackage: "package-name", + IsLocalPath: false, + BuildArgs: nil, + }, + wantErr: false, + }, + { + name: "buildArgs with single quote should fail", + transportType: templates.TransportTypeUVX, + packageName: "example-package", + caCertPath: "", + buildArgs: []string{"--name", "test'arg"}, + expected: templates.TemplateData{}, + wantErr: true, + }, + { + name: "buildArgs with other special characters should succeed", + transportType: templates.TransportTypeNPX, + packageName: "example-package", + caCertPath: "", + buildArgs: []string{"--config", "file$with`special\"chars"}, + expected: templates.TemplateData{ + MCPPackage: "example-package", + IsLocalPath: false, + BuildArgs: []string{"--config", "file$with`special\"chars"}, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result, err := createTemplateData(tt.transportType, tt.packageName, tt.caCertPath, tt.buildArgs) + + if (err != nil) != tt.wantErr { + t.Errorf("createTemplateData() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if result.MCPPackage != tt.expected.MCPPackage { + t.Errorf("MCPPackage = %q, want %q", result.MCPPackage, tt.expected.MCPPackage) + } + if result.IsLocalPath != tt.expected.IsLocalPath { + t.Errorf("IsLocalPath = %v, want %v", result.IsLocalPath, tt.expected.IsLocalPath) + } + if len(result.BuildArgs) != len(tt.expected.BuildArgs) { + t.Errorf("BuildArgs length = %d, want %d", len(result.BuildArgs), len(tt.expected.BuildArgs)) + } else { + for i, arg := range result.BuildArgs { + if arg != tt.expected.BuildArgs[i] { + t.Errorf("BuildArgs[%d] = %q, want %q", i, arg, tt.expected.BuildArgs[i]) + } + } + } + }) + } +}