diff --git a/cmd/docker-mcp/commands/gateway.go b/cmd/docker-mcp/commands/gateway.go index 5ea12579..ba641717 100644 --- a/cmd/docker-mcp/commands/gateway.go +++ b/cmd/docker-mcp/commands/gateway.go @@ -10,6 +10,7 @@ import ( "github.com/spf13/cobra" "github.com/docker/mcp-gateway/cmd/docker-mcp/catalog" + workingset "github.com/docker/mcp-gateway/cmd/docker-mcp/working-set" catalogTypes "github.com/docker/mcp-gateway/pkg/catalog" "github.com/docker/mcp-gateway/pkg/docker" "github.com/docker/mcp-gateway/pkg/gateway" @@ -29,6 +30,7 @@ func gatewayCommand(docker docker.Client, dockerCli command.Cli) *cobra.Command var additionalToolsConfig []string var mcpRegistryUrls []string var enableAllServers bool + var workingSetName string if os.Getenv("DOCKER_MCP_IN_CONTAINER") == "1" { // In-container. // Note: The catalog URL will be updated after checking the feature flag in RunE @@ -125,6 +127,27 @@ func gatewayCommand(docker docker.Client, dockerCli command.Cli) *cobra.Command options.MCPRegistryServers = mcpServers } + // Handle --working-set flag + if workingSetName != "" { + if len(options.ServerNames) > 0 { + return fmt.Errorf("cannot use --working-set with --servers flag") + } + if enableAllServers { + return fmt.Errorf("cannot use --working-set with --enable-all-servers flag") + } + + // Read working-set file + ws, err := workingset.ReadWorkingSetFile(workingSetName) + if err != nil { + return fmt.Errorf("failed to read working-set %q: %w", workingSetName, err) + } + + // Set server names from the working-set + // These are treated as server references (can be OCI image refs or catalog names) + // and will go through the self-contained catalog resolution + options.ServerNames = ws.Servers + } + // Handle --enable-all-servers flag if enableAllServers { if len(options.ServerNames) > 0 { @@ -150,6 +173,7 @@ func gatewayCommand(docker docker.Client, dockerCli command.Cli) *cobra.Command } runCmd.Flags().StringSliceVar(&options.ServerNames, "servers", nil, "Names of the servers to enable (if non empty, ignore --registry flag)") + runCmd.Flags().StringVar(&workingSetName, "working-set", "", "Name of the working-set to use (mutually exclusive with --servers and --enable-all-servers)") runCmd.Flags().BoolVar(&enableAllServers, "enable-all-servers", false, "Enable all servers in the catalog (instead of using individual --servers options)") runCmd.Flags().StringSliceVar(&options.CatalogPath, "catalog", options.CatalogPath, "Paths to docker catalogs (absolute or relative to ~/.docker/mcp/catalogs/)") runCmd.Flags().StringSliceVar(&additionalCatalogs, "additional-catalog", nil, "Additional catalog paths to append to the default catalogs") diff --git a/cmd/docker-mcp/commands/root.go b/cmd/docker-mcp/commands/root.go index 52be2c6d..6cda26d8 100644 --- a/cmd/docker-mcp/commands/root.go +++ b/cmd/docker-mcp/commands/root.go @@ -83,6 +83,7 @@ func Root(ctx context.Context, cwd string, dockerCli command.Cli) *cobra.Command cmd.AddCommand(serverCommand(dockerClient, dockerCli)) cmd.AddCommand(toolsCommand(dockerClient)) cmd.AddCommand(versionCommand()) + cmd.AddCommand(workingSetCommand()) if os.Getenv("DOCKER_MCP_SHOW_HIDDEN") == "1" { unhideHiddenCommands(cmd) diff --git a/cmd/docker-mcp/commands/working-set.go b/cmd/docker-mcp/commands/working-set.go new file mode 100644 index 00000000..6fd239c6 --- /dev/null +++ b/cmd/docker-mcp/commands/working-set.go @@ -0,0 +1,112 @@ +package commands + +import ( + "fmt" + + "github.com/spf13/cobra" + + workingset "github.com/docker/mcp-gateway/cmd/docker-mcp/working-set" +) + +func workingSetCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "working-set", + Aliases: []string{"ws"}, + Short: "Manage MCP server working-sets", + Long: `Manage working-sets of MCP servers for organizing and grouping servers together.`, + } + cmd.AddCommand(createWorkingSetCommand()) + cmd.AddCommand(listWorkingSetCommand()) + cmd.AddCommand(showWorkingSetCommand()) + return cmd +} + +func createWorkingSetCommand() *cobra.Command { + var opts struct { + Name string + Description string + Servers []string + } + cmd := &cobra.Command{ + Use: "create --name [--description ] --server --server ...", + Short: "Create a new working-set of MCP servers", + Long: `Create a new working-set that groups multiple MCP servers together. +A working-set allows you to organize and manage related servers as a single unit. +Working-sets are decoupled from catalogs. Servers can be: + - Catalog server names (e.g., "github", "docker") + - OCI image references with docker:// prefix (e.g., "docker://mcp/github:latest")`, + Example: ` # Create a working-set with multiple servers (OCI references) + docker mcp working-set create --name dev-tools --description "Development tools" --server docker://mcp/github:latest --server docker://mcp/slack:latest + + # Create a working-set with catalog server names + docker mcp working-set create --name catalog-servers --server github --server docker + + # Mix catalog names and OCI references + docker mcp working-set create --name mixed --server github --server docker://custom/server:v1`, + RunE: func(cmd *cobra.Command, args []string) error { + return workingset.Create(opts.Name, opts.Description, opts.Servers) + }, + } + flags := cmd.Flags() + flags.StringVar(&opts.Name, "name", "", "Name of the working-set (required)") + flags.StringVar(&opts.Description, "description", "", "Description of the working-set") + flags.StringArrayVar(&opts.Servers, "server", []string{}, "Server to include: catalog name or OCI reference with docker:// prefix (can be specified multiple times)") + + _ = cmd.MarkFlagRequired("name") + _ = cmd.MarkFlagRequired("server") + + return cmd +} + +func listWorkingSetCommand() *cobra.Command { + var opts struct { + Format workingset.Format + } + cmd := &cobra.Command{ + Use: "ls", + Aliases: []string{"list"}, + Short: "List all working-sets", + Long: `List all configured working-sets and their associated servers.`, + Example: ` # List all working-sets in human-readable format + docker mcp working-set ls + + # List working-sets in JSON format + docker mcp working-set ls --format json + + # List working-sets in YAML format + docker mcp working-set ls --format yaml`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return workingset.List(opts.Format) + }, + } + flags := cmd.Flags() + flags.Var(&opts.Format, "format", fmt.Sprintf("Output format. Supported: %s.", workingset.SupportedFormats())) + return cmd +} + +func showWorkingSetCommand() *cobra.Command { + var opts struct { + Format workingset.Format + } + cmd := &cobra.Command{ + Use: "show ", + Short: "Display working-set details", + Long: `Display the details of a specific working-set including all its servers.`, + Example: ` # Show a working-set in human-readable format + docker mcp working-set show my-dev-tools + + # Show a working-set in JSON format + docker mcp working-set show my-dev-tools --format json + + # Show a working-set in YAML format + docker mcp working-set show my-dev-tools --format yaml`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return workingset.Show(args[0], opts.Format) + }, + } + flags := cmd.Flags() + flags.Var(&opts.Format, "format", fmt.Sprintf("Output format. Supported: %s.", workingset.SupportedFormats())) + return cmd +} diff --git a/cmd/docker-mcp/working-set/config.go b/cmd/docker-mcp/working-set/config.go new file mode 100644 index 00000000..702c974e --- /dev/null +++ b/cmd/docker-mcp/working-set/config.go @@ -0,0 +1,108 @@ +package workingset + +import ( + "encoding/json" + "os" + "path/filepath" + + "github.com/docker/mcp-gateway/pkg/config" +) + +type Config struct { + WorkingSets map[string]WorkingSetMetadata `json:"workingSets"` +} + +type WorkingSetMetadata struct { + DisplayName string `json:"displayName"` +} + +type WorkingSet struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Servers []string `json:"servers"` +} + +func ReadConfig() (*Config, error) { + buf, err := config.ReadWorkingSetConfig() + if err != nil { + return nil, err + } + + var result Config + if len(buf) > 0 { + if err := json.Unmarshal(buf, &result); err != nil { + return nil, err + } + } + + if result.WorkingSets == nil { + result.WorkingSets = map[string]WorkingSetMetadata{} + } + + return &result, nil +} + +func WriteConfig(cfg *Config) error { + if cfg.WorkingSets == nil { + cfg.WorkingSets = map[string]WorkingSetMetadata{} + } + + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + + return config.WriteWorkingSetConfig(data) +} + +func ReadWorkingSetFile(name string) (*WorkingSet, error) { + buf, err := config.ReadWorkingSetFile(name) + if err != nil { + return nil, err + } + + if len(buf) == 0 { + return nil, os.ErrNotExist + } + + var ws WorkingSet + if err := json.Unmarshal(buf, &ws); err != nil { + return nil, err + } + + return &ws, nil +} + +func WriteWorkingSetFile(name string, ws *WorkingSet) error { + data, err := json.MarshalIndent(ws, "", " ") + if err != nil { + return err + } + + return config.WriteWorkingSetFile(name, data) +} + +func ListWorkingSets() (map[string]WorkingSet, error) { + cfg, err := ReadConfig() + if err != nil { + return nil, err + } + + workingSets := make(map[string]WorkingSet) + for name := range cfg.WorkingSets { + ws, err := ReadWorkingSetFile(name) + if err != nil { + if os.IsNotExist(err) { + continue // Skip missing files + } + return nil, err + } + workingSets[name] = *ws + } + + return workingSets, nil +} + +func WorkingSetFilePath(name string) (string, error) { + return config.FilePath(filepath.Join("working-sets", name+".json")) +} diff --git a/cmd/docker-mcp/working-set/create.go b/cmd/docker-mcp/working-set/create.go new file mode 100644 index 00000000..350638c5 --- /dev/null +++ b/cmd/docker-mcp/working-set/create.go @@ -0,0 +1,56 @@ +package workingset + +import ( + "fmt" +) + +func Create(name, description string, servers []string) error { + // Validate input + if name == "" { + return fmt.Errorf("working-set name cannot be empty") + } + + if len(servers) == 0 { + return fmt.Errorf("at least one server must be specified") + } + + // Read existing config + cfg, err := ReadConfig() + if err != nil { + return fmt.Errorf("failed to read working-sets config: %w", err) + } + + // Check if working-set already exists + if _, exists := cfg.WorkingSets[name]; exists { + return fmt.Errorf("working-set %q already exists", name) + } + + // Create working-set object + ws := &WorkingSet{ + Name: name, + Description: description, + Servers: servers, + } + + // Write working-set file + if err := WriteWorkingSetFile(name, ws); err != nil { + return fmt.Errorf("failed to write working-set file: %w", err) + } + + // Update config with metadata + displayName := name + if description != "" { + displayName = description + } + cfg.WorkingSets[name] = WorkingSetMetadata{ + DisplayName: displayName, + } + + // Write config + if err := WriteConfig(cfg); err != nil { + return fmt.Errorf("failed to write working-sets config: %w", err) + } + + fmt.Printf("Created working-set %q with %d server(s)\n", name, len(servers)) + return nil +} diff --git a/cmd/docker-mcp/working-set/list.go b/cmd/docker-mcp/working-set/list.go new file mode 100644 index 00000000..1922c2ee --- /dev/null +++ b/cmd/docker-mcp/working-set/list.go @@ -0,0 +1,123 @@ +package workingset + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + "gopkg.in/yaml.v3" +) + +type Format string + +const ( + JSON Format = "json" + YAML Format = "yaml" +) + +var supportedFormats = []Format{JSON, YAML} + +func (e *Format) String() string { + return string(*e) +} + +func (e *Format) Set(v string) error { + actual := Format(v) + for _, allowed := range supportedFormats { + if allowed == actual { + *e = actual + return nil + } + } + return fmt.Errorf("must be one of %s", SupportedFormats()) +} + +// Type is only used in help text +func (e *Format) Type() string { + return "format" +} + +func SupportedFormats() string { + var quoted []string + for _, v := range supportedFormats { + quoted = append(quoted, "\""+string(v)+"\"") + } + return strings.Join(quoted, ", ") +} + +// WorkingSetListMetadata contains summary info about a working-set (excluding full server list) +type WorkingSetListMetadata struct { + Description string `json:"description,omitempty" yaml:"description,omitempty"` + ServerCount int `json:"serverCount" yaml:"serverCount"` +} + +type ListOutput struct { + WorkingSets map[string]WorkingSetListMetadata `json:"workingSets" yaml:"workingSets"` +} + +func List(format Format) error { + workingSets, err := ListWorkingSets() + if err != nil { + return fmt.Errorf("failed to read working-sets: %w", err) + } + + switch format { + case JSON: + output := buildListOutput(workingSets) + data, err := json.MarshalIndent(output, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal to JSON: %w", err) + } + fmt.Println(string(data)) + case YAML: + output := buildListOutput(workingSets) + data, err := yaml.Marshal(output) + if err != nil { + return fmt.Errorf("failed to marshal to YAML: %w", err) + } + fmt.Print(string(data)) + default: + humanPrintWorkingSetsList(workingSets) + } + + return nil +} + +func buildListOutput(workingSets map[string]WorkingSet) ListOutput { + output := ListOutput{ + WorkingSets: make(map[string]WorkingSetListMetadata), + } + + for name, ws := range workingSets { + output.WorkingSets[name] = WorkingSetListMetadata{ + Description: ws.Description, + ServerCount: len(ws.Servers), + } + } + + return output +} + +func humanPrintWorkingSetsList(workingSets map[string]WorkingSet) { + if len(workingSets) == 0 { + fmt.Println("No working-sets configured.") + return + } + + // Sort by name for consistent output + names := make([]string, 0, len(workingSets)) + for name := range workingSets { + names = append(names, name) + } + sort.Strings(names) + + for _, name := range names { + ws := workingSets[name] + if ws.Description != "" { + fmt.Printf("%s: %s\n", name, ws.Description) + } else { + fmt.Println(name) + } + } +} diff --git a/cmd/docker-mcp/working-set/show.go b/cmd/docker-mcp/working-set/show.go new file mode 100644 index 00000000..eec42a43 --- /dev/null +++ b/cmd/docker-mcp/working-set/show.go @@ -0,0 +1,52 @@ +package workingset + +import ( + "encoding/json" + "fmt" + "os" + + "gopkg.in/yaml.v3" +) + +func Show(name string, format Format) error { + ws, err := ReadWorkingSetFile(name) + if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("working-set %q not found", name) + } + return fmt.Errorf("failed to read working-set: %w", err) + } + + switch format { + case JSON: + data, err := json.MarshalIndent(ws, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal to JSON: %w", err) + } + fmt.Println(string(data)) + case YAML: + data, err := yaml.Marshal(ws) + if err != nil { + return fmt.Errorf("failed to marshal to YAML: %w", err) + } + fmt.Print(string(data)) + default: + humanPrintWorkingSet(name, *ws) + } + + return nil +} + +func humanPrintWorkingSet(name string, ws WorkingSet) { + fmt.Println() + fmt.Printf(" \033[1m%s\033[0m\n", name) + if ws.Description != "" { + fmt.Printf(" Description: %s\n", ws.Description) + } + fmt.Println() + fmt.Println(" Servers:") + for _, server := range ws.Servers { + fmt.Printf(" - %s\n", server) + } + fmt.Println() +} diff --git a/pkg/config/readwrite.go b/pkg/config/readwrite.go index feed5ae0..bac2a71b 100644 --- a/pkg/config/readwrite.go +++ b/pkg/config/readwrite.go @@ -32,6 +32,24 @@ func ReadCatalog() ([]byte, error) { return readFileOrEmpty(path) } +func ReadWorkingSetConfig() ([]byte, error) { + path, err := FilePath("working-set-config.json") + if err != nil { + return nil, err + } + + return readFileOrEmpty(path) +} + +func ReadWorkingSetFile(name string) ([]byte, error) { + path, err := FilePath(workingSetFilename(name)) + if err != nil { + return nil, err + } + + return readFileOrEmpty(path) +} + func ReadCatalogFile(name string) ([]byte, error) { path, err := FilePath(catalogFilename(name)) if err != nil { @@ -57,6 +75,14 @@ func WriteCatalog(content []byte) error { return writeConfigFile("catalog.json", content) } +func WriteWorkingSetConfig(content []byte) error { + return writeConfigFile("working-set-config.json", content) +} + +func WriteWorkingSetFile(name string, content []byte) error { + return writeConfigFile(workingSetFilename(name), content) +} + func WriteCatalogFile(name string, content []byte) error { return writeConfigFile(catalogFilename(name), content) } @@ -70,6 +96,15 @@ func RemoveCatalogFile(name string) error { return os.Remove(path) } +func RemoveWorkingSetFile(name string) error { + path, err := FilePath(workingSetFilename(name)) + if err != nil { + return err + } + + return os.Remove(path) +} + func ReadConfigFile(ctx context.Context, docker docker.Client, name string) ([]byte, error) { path, err := FilePath(name) if err != nil { @@ -131,6 +166,10 @@ func catalogFilename(name string) string { return filepath.Join("catalogs", sanitizeFilename(name)+".yaml") } +func workingSetFilename(name string) string { + return filepath.Join("working-sets", sanitizeFilename(name)+".json") +} + func readFileOrEmpty(path string) ([]byte, error) { buf, err := os.ReadFile(path) if err != nil { diff --git a/pkg/oci/self_contained.go b/pkg/oci/self_contained.go index 7e3284b7..113efd9b 100644 --- a/pkg/oci/self_contained.go +++ b/pkg/oci/self_contained.go @@ -30,13 +30,19 @@ func SelfContainedCatalog(ctx context.Context, dockerClient docker.Client, serve } metadataLabel, exists := inspect.Config.Labels["io.docker.server.metadata"] - if !exists { - return catalog.Catalog{}, nil, fmt.Errorf("server name %s looks like an OCI ref but is missing the io.docker.server.metadata label", serverName) - } var server catalog.Server - if err := yaml.Unmarshal([]byte(metadataLabel), &server); err != nil { - return catalog.Catalog{}, nil, fmt.Errorf("failed to parse metadata label for %s: %w", serverName, err) + if exists { + // If metadata label exists, parse it for full server configuration + if err := yaml.Unmarshal([]byte(metadataLabel), &server); err != nil { + return catalog.Catalog{}, nil, fmt.Errorf("failed to parse metadata label for %s: %w", serverName, err) + } + } else { + // If no metadata label, create a minimal server entry with just the image + // This allows plain MCP server images to work without requiring the label + server = catalog.Server{ + Type: "server", + } } server.Image = ociRef