Skip to content

Commit ff25621

Browse files
authored
Add thv build subcommand for building containers without running them (#1273)
1 parent be0fce9 commit ff25621

File tree

5 files changed

+183
-8
lines changed

5 files changed

+183
-8
lines changed

cmd/thv/app/build.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package app
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/spf13/cobra"
7+
8+
"github.com/stacklok/toolhive/pkg/container/images"
9+
"github.com/stacklok/toolhive/pkg/logger"
10+
"github.com/stacklok/toolhive/pkg/runner"
11+
)
12+
13+
var buildCmd = &cobra.Command{
14+
Use: "build [flags] PROTOCOL",
15+
Short: "Build a container for an MCP server without running it",
16+
Long: `Build a container for an MCP server using a protocol scheme without running it.
17+
18+
ToolHive supports building containers from protocol schemes:
19+
20+
$ thv build uvx://package-name
21+
$ thv build npx://package-name
22+
$ thv build go://package-name
23+
$ thv build go://./local-path
24+
25+
Automatically generates a container that can run the specified package
26+
using either uvx (Python with uv package manager), npx (Node.js),
27+
or go (Golang). For Go, you can also specify local paths starting
28+
with './' or '../' to build local Go projects.
29+
30+
The container will be built and tagged locally, ready to be used with 'thv run'
31+
or other container tools. The built image name will be displayed upon successful completion.
32+
33+
Examples:
34+
$ thv build uvx://mcp-server-git
35+
$ thv build --tag my-custom-name:latest npx://@modelcontextprotocol/server-filesystem
36+
$ thv build go://./my-local-server`,
37+
Args: cobra.ExactArgs(1),
38+
RunE: buildCmdFunc,
39+
}
40+
41+
var buildFlags BuildFlags
42+
43+
// BuildFlags holds the configuration for building MCP server containers
44+
type BuildFlags struct {
45+
Tag string
46+
}
47+
48+
func init() {
49+
// Add build flags
50+
AddBuildFlags(buildCmd, &buildFlags)
51+
}
52+
53+
// AddBuildFlags adds all the build flags to a command
54+
func AddBuildFlags(cmd *cobra.Command, config *BuildFlags) {
55+
cmd.Flags().StringVarP(&config.Tag, "tag", "t", "", "Name and optionally a tag in the 'name:tag' format for the built image")
56+
}
57+
58+
func buildCmdFunc(cmd *cobra.Command, args []string) error {
59+
ctx := cmd.Context()
60+
protocolScheme := args[0]
61+
62+
// Validate that this is a protocol scheme
63+
if !runner.IsImageProtocolScheme(protocolScheme) {
64+
return fmt.Errorf("invalid protocol scheme: %s. Supported schemes are: uvx://, npx://, go://", protocolScheme)
65+
}
66+
67+
logger.Infof("Building container for protocol scheme: %s", protocolScheme)
68+
69+
// Create image manager for building
70+
imageManager := images.NewImageManager(ctx)
71+
72+
// Build the image using the new protocol handler with custom name
73+
imageName, err := runner.BuildFromProtocolSchemeWithName(ctx, imageManager, protocolScheme, "", buildFlags.Tag)
74+
if err != nil {
75+
return fmt.Errorf("failed to build container for %s: %v", protocolScheme, err)
76+
}
77+
78+
logger.Infof("Successfully built container image: %s", imageName)
79+
fmt.Printf("Container built successfully: %s\n", imageName)
80+
fmt.Printf("You can now run it with: thv run %s\n", imageName)
81+
82+
return nil
83+
}

cmd/thv/app/commands.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ func NewRootCmd(enableUpdates bool) *cobra.Command {
4141

4242
// Add subcommands
4343
rootCmd.AddCommand(runCmd)
44+
rootCmd.AddCommand(buildCmd)
4445
rootCmd.AddCommand(listCmd)
4546
rootCmd.AddCommand(stopCmd)
4647
rootCmd.AddCommand(rmCmd)

docs/cli/thv.md

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/cli/thv_build.md

Lines changed: 60 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/runner/protocol.go

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import (
88
"strings"
99
"time"
1010

11+
nameref "github.com/google/go-containerregistry/pkg/name"
12+
1113
"github.com/stacklok/toolhive/pkg/certs"
1214
"github.com/stacklok/toolhive/pkg/container/images"
1315
"github.com/stacklok/toolhive/pkg/container/templates"
@@ -29,6 +31,20 @@ func HandleProtocolScheme(
2931
imageManager images.ImageManager,
3032
serverOrImage string,
3133
caCertPath string,
34+
) (string, error) {
35+
return BuildFromProtocolSchemeWithName(ctx, imageManager, serverOrImage, caCertPath, "")
36+
}
37+
38+
// BuildFromProtocolSchemeWithName checks if the serverOrImage string contains a protocol scheme (uvx://, npx://, or go://)
39+
// and builds a Docker image for it if needed with a custom image name.
40+
// If imageName is empty, a default name will be generated.
41+
// Returns the Docker image name to use and any error encountered.
42+
func BuildFromProtocolSchemeWithName(
43+
ctx context.Context,
44+
imageManager images.ImageManager,
45+
serverOrImage string,
46+
caCertPath string,
47+
imageName string,
3248
) (string, error) {
3349
transportType, packageName, err := parseProtocolScheme(serverOrImage)
3450
if err != nil {
@@ -40,7 +56,7 @@ func HandleProtocolScheme(
4056
return "", err
4157
}
4258

43-
return buildImageFromTemplate(ctx, imageManager, transportType, packageName, templateData)
59+
return buildImageFromTemplateWithName(ctx, imageManager, transportType, packageName, templateData, imageName)
4460
}
4561

4662
// parseProtocolScheme extracts the transport type and package name from the protocol scheme.
@@ -243,13 +259,15 @@ func generateImageName(transportType templates.TransportType, packageName string
243259
tag))
244260
}
245261

246-
// buildImageFromTemplate builds a Docker image from the template data.
247-
func buildImageFromTemplate(
262+
// buildImageFromTemplateWithName builds a Docker image from the template data with a custom image name.
263+
// If imageName is empty, a default name will be generated.
264+
func buildImageFromTemplateWithName(
248265
ctx context.Context,
249266
imageManager images.ImageManager,
250267
transportType templates.TransportType,
251268
packageName string,
252269
templateData templates.TemplateData,
270+
imageName string,
253271
) (string, error) {
254272

255273
// Get the Dockerfile content
@@ -277,21 +295,33 @@ func buildImageFromTemplate(
277295
}
278296
defer caCertCleanup()
279297

280-
// Generate image name
281-
imageName := generateImageName(transportType, packageName)
298+
// Use provided image name or generate one
299+
finalImageName := imageName
300+
if finalImageName == "" {
301+
finalImageName = generateImageName(transportType, packageName)
302+
} else {
303+
// Validate the provided image name using go-containerregistry
304+
ref, err := nameref.ParseReference(finalImageName)
305+
if err != nil {
306+
return "", fmt.Errorf("invalid image name format '%s': %w", finalImageName, err)
307+
}
308+
// Use the normalized reference string
309+
finalImageName = ref.String()
310+
logger.Debugf("Using validated image name: %s", finalImageName)
311+
}
282312

283313
// Log the build process
284314
logger.Debugf("Building Docker image for %s package: %s", transportType, packageName)
285315
logger.Debugf("Using Dockerfile:\n%s", dockerfileContent)
286316

287317
// Build the Docker image
288318
logger.Infof("Building Docker image for %s package: %s", transportType, packageName)
289-
if err := imageManager.BuildImage(ctx, buildCtx.Dir, imageName); err != nil {
319+
if err := imageManager.BuildImage(ctx, buildCtx.Dir, finalImageName); err != nil {
290320
return "", fmt.Errorf("failed to build Docker image: %w", err)
291321
}
292-
logger.Infof("Successfully built Docker image: %s", imageName)
322+
logger.Infof("Successfully built Docker image: %s", finalImageName)
293323

294-
return imageName, nil
324+
return finalImageName, nil
295325
}
296326

297327
// Replace slashes with dashes to create a valid Docker image name. If there

0 commit comments

Comments
 (0)