From 6b5f1851f8bc131b3fa828b5f39e83c6e0efbf29 Mon Sep 17 00:00:00 2001 From: Bobby House Date: Mon, 20 Oct 2025 11:19:26 -0700 Subject: [PATCH 1/4] wip --- cmd/docker-mcp/commands/root.go | 1 + cmd/docker-mcp/commands/working-set.go | 106 +++++++++++++++++++++ cmd/docker-mcp/working-set/config.go | 50 ++++++++++ cmd/docker-mcp/working-set/create.go | 42 +++++++++ cmd/docker-mcp/working-set/list.go | 123 +++++++++++++++++++++++++ cmd/docker-mcp/working-set/show.go | 53 +++++++++++ pkg/config/readwrite.go | 13 +++ 7 files changed, 388 insertions(+) create mode 100644 cmd/docker-mcp/commands/working-set.go create mode 100644 cmd/docker-mcp/working-set/config.go create mode 100644 cmd/docker-mcp/working-set/create.go create mode 100644 cmd/docker-mcp/working-set/list.go create mode 100644 cmd/docker-mcp/working-set/show.go 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..2c3abb83 --- /dev/null +++ b/cmd/docker-mcp/commands/working-set.go @@ -0,0 +1,106 @@ +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.`, + Example: ` # Create a working-set with multiple servers + docker mcp working-set create --name dev-tools --description "Development tools" --server github --server slack + + # Create a working-set with a single server + docker mcp working-set create --name docker-only --server docker`, + 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 in the working-set (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..c26ab298 --- /dev/null +++ b/cmd/docker-mcp/working-set/config.go @@ -0,0 +1,50 @@ +package workingset + +import ( + "encoding/json" + + "github.com/docker/mcp-gateway/pkg/config" +) + +type Config struct { + WorkingSets map[string]WorkingSet `json:"workingSets"` +} + +type WorkingSet struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Servers []string `json:"servers"` +} + +func ReadConfig() (*Config, error) { + buf, err := config.ReadWorkingSets() + 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]WorkingSet{} + } + + return &result, nil +} + +func WriteConfig(cfg *Config) error { + if cfg.WorkingSets == nil { + cfg.WorkingSets = map[string]WorkingSet{} + } + + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + + return config.WriteWorkingSets(data) +} diff --git a/cmd/docker-mcp/working-set/create.go b/cmd/docker-mcp/working-set/create.go new file mode 100644 index 00000000..a029ebb8 --- /dev/null +++ b/cmd/docker-mcp/working-set/create.go @@ -0,0 +1,42 @@ +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 new working-set + cfg.WorkingSets[name] = WorkingSet{ + Name: name, + Description: description, + Servers: servers, + } + + // 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..8f1126c4 --- /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, ", ") +} + +// WorkingSetMetadata contains summary info about a working-set (excluding full server list) +type WorkingSetMetadata struct { + Description string `json:"description,omitempty" yaml:"description,omitempty"` + ServerCount int `json:"serverCount" yaml:"serverCount"` +} + +type ListOutput struct { + WorkingSets map[string]WorkingSetMetadata `json:"workingSets" yaml:"workingSets"` +} + +func List(format Format) error { + cfg, err := ReadConfig() + if err != nil { + return fmt.Errorf("failed to read working-sets config: %w", err) + } + + switch format { + case JSON: + output := buildListOutput(cfg.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(cfg.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(cfg.WorkingSets) + } + + return nil +} + +func buildListOutput(workingSets map[string]WorkingSet) ListOutput { + output := ListOutput{ + WorkingSets: make(map[string]WorkingSetMetadata), + } + + for name, ws := range workingSets { + output.WorkingSets[name] = WorkingSetMetadata{ + 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..de5fc823 --- /dev/null +++ b/cmd/docker-mcp/working-set/show.go @@ -0,0 +1,53 @@ +package workingset + +import ( + "encoding/json" + "fmt" + + "gopkg.in/yaml.v3" +) + +func Show(name string, format Format) error { + cfg, err := ReadConfig() + if err != nil { + return fmt.Errorf("failed to read working-sets config: %w", err) + } + + ws, exists := cfg.WorkingSets[name] + if !exists { + return fmt.Errorf("working-set %q not found", name) + } + + 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..29507190 100644 --- a/pkg/config/readwrite.go +++ b/pkg/config/readwrite.go @@ -32,6 +32,15 @@ func ReadCatalog() ([]byte, error) { return readFileOrEmpty(path) } +func ReadWorkingSets() ([]byte, error) { + path, err := FilePath("working-sets.json") + 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 +66,10 @@ func WriteCatalog(content []byte) error { return writeConfigFile("catalog.json", content) } +func WriteWorkingSets(content []byte) error { + return writeConfigFile("working-sets.json", content) +} + func WriteCatalogFile(name string, content []byte) error { return writeConfigFile(catalogFilename(name), content) } From e92a38cc4a01688940bcd9c286c4eb265c843642 Mon Sep 17 00:00:00 2001 From: Bobby House Date: Mon, 20 Oct 2025 11:50:11 -0700 Subject: [PATCH 2/4] wip --- cmd/docker-mcp/commands/gateway.go | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/cmd/docker-mcp/commands/gateway.go b/cmd/docker-mcp/commands/gateway.go index 5ea12579..2551c7f5 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,31 @@ 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 config + wsCfg, err := workingset.ReadConfig() + if err != nil { + return fmt.Errorf("failed to read working-sets config: %w", err) + } + + // Get the working-set + ws, exists := wsCfg.WorkingSets[workingSetName] + if !exists { + return fmt.Errorf("working-set %q not found", workingSetName) + } + + // Set server names from the working-set + options.ServerNames = ws.Servers + } + // Handle --enable-all-servers flag if enableAllServers { if len(options.ServerNames) > 0 { @@ -150,6 +177,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") From 7498e1113c0286d76878e96b7a50c08fb77ff08e Mon Sep 17 00:00:00 2001 From: Bobby House Date: Mon, 20 Oct 2025 11:57:08 -0700 Subject: [PATCH 3/4] wip --- cmd/docker-mcp/commands/gateway.go | 12 ++--- cmd/docker-mcp/working-set/config.go | 68 ++++++++++++++++++++++++++-- cmd/docker-mcp/working-set/create.go | 18 +++++++- cmd/docker-mcp/working-set/list.go | 20 ++++---- cmd/docker-mcp/working-set/show.go | 15 +++--- pkg/config/readwrite.go | 34 ++++++++++++-- 6 files changed, 129 insertions(+), 38 deletions(-) diff --git a/cmd/docker-mcp/commands/gateway.go b/cmd/docker-mcp/commands/gateway.go index 2551c7f5..304ddcd0 100644 --- a/cmd/docker-mcp/commands/gateway.go +++ b/cmd/docker-mcp/commands/gateway.go @@ -136,16 +136,10 @@ func gatewayCommand(docker docker.Client, dockerCli command.Cli) *cobra.Command return fmt.Errorf("cannot use --working-set with --enable-all-servers flag") } - // Read working-set config - wsCfg, err := workingset.ReadConfig() + // Read working-set file + ws, err := workingset.ReadWorkingSetFile(workingSetName) if err != nil { - return fmt.Errorf("failed to read working-sets config: %w", err) - } - - // Get the working-set - ws, exists := wsCfg.WorkingSets[workingSetName] - if !exists { - return fmt.Errorf("working-set %q not found", workingSetName) + return fmt.Errorf("failed to read working-set %q: %w", workingSetName, err) } // Set server names from the working-set diff --git a/cmd/docker-mcp/working-set/config.go b/cmd/docker-mcp/working-set/config.go index c26ab298..702c974e 100644 --- a/cmd/docker-mcp/working-set/config.go +++ b/cmd/docker-mcp/working-set/config.go @@ -2,12 +2,18 @@ package workingset import ( "encoding/json" + "os" + "path/filepath" "github.com/docker/mcp-gateway/pkg/config" ) type Config struct { - WorkingSets map[string]WorkingSet `json:"workingSets"` + WorkingSets map[string]WorkingSetMetadata `json:"workingSets"` +} + +type WorkingSetMetadata struct { + DisplayName string `json:"displayName"` } type WorkingSet struct { @@ -17,7 +23,7 @@ type WorkingSet struct { } func ReadConfig() (*Config, error) { - buf, err := config.ReadWorkingSets() + buf, err := config.ReadWorkingSetConfig() if err != nil { return nil, err } @@ -30,7 +36,7 @@ func ReadConfig() (*Config, error) { } if result.WorkingSets == nil { - result.WorkingSets = map[string]WorkingSet{} + result.WorkingSets = map[string]WorkingSetMetadata{} } return &result, nil @@ -38,7 +44,7 @@ func ReadConfig() (*Config, error) { func WriteConfig(cfg *Config) error { if cfg.WorkingSets == nil { - cfg.WorkingSets = map[string]WorkingSet{} + cfg.WorkingSets = map[string]WorkingSetMetadata{} } data, err := json.MarshalIndent(cfg, "", " ") @@ -46,5 +52,57 @@ func WriteConfig(cfg *Config) error { return err } - return config.WriteWorkingSets(data) + 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 index a029ebb8..350638c5 100644 --- a/cmd/docker-mcp/working-set/create.go +++ b/cmd/docker-mcp/working-set/create.go @@ -25,13 +25,27 @@ func Create(name, description string, servers []string) error { return fmt.Errorf("working-set %q already exists", name) } - // Create new working-set - cfg.WorkingSets[name] = WorkingSet{ + // 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) diff --git a/cmd/docker-mcp/working-set/list.go b/cmd/docker-mcp/working-set/list.go index 8f1126c4..490fb3d8 100644 --- a/cmd/docker-mcp/working-set/list.go +++ b/cmd/docker-mcp/working-set/list.go @@ -46,39 +46,39 @@ func SupportedFormats() string { return strings.Join(quoted, ", ") } -// WorkingSetMetadata contains summary info about a working-set (excluding full server list) -type WorkingSetMetadata struct { +// 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]WorkingSetMetadata `json:"workingSets" yaml:"workingSets"` + WorkingSets map[string]WorkingSetListMetadata `json:"workingSets" yaml:"workingSets"` } func List(format Format) error { - cfg, err := ReadConfig() + workingSets, err := ListWorkingSets() if err != nil { - return fmt.Errorf("failed to read working-sets config: %w", err) + return fmt.Errorf("failed to read working-sets: %w", err) } switch format { case JSON: - output := buildListOutput(cfg.WorkingSets) + 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(cfg.WorkingSets) + 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(cfg.WorkingSets) + humanPrintWorkingSetsList(workingSets) } return nil @@ -86,11 +86,11 @@ func List(format Format) error { func buildListOutput(workingSets map[string]WorkingSet) ListOutput { output := ListOutput{ - WorkingSets: make(map[string]WorkingSetMetadata), + WorkingSets: make(map[string]WorkingSetListMetadata), } for name, ws := range workingSets { - output.WorkingSets[name] = WorkingSetMetadata{ + output.WorkingSets[name] = WorkingSetListMetadata{ Description: ws.Description, ServerCount: len(ws.Servers), } diff --git a/cmd/docker-mcp/working-set/show.go b/cmd/docker-mcp/working-set/show.go index de5fc823..eec42a43 100644 --- a/cmd/docker-mcp/working-set/show.go +++ b/cmd/docker-mcp/working-set/show.go @@ -3,19 +3,18 @@ package workingset import ( "encoding/json" "fmt" + "os" "gopkg.in/yaml.v3" ) func Show(name string, format Format) error { - cfg, err := ReadConfig() + ws, err := ReadWorkingSetFile(name) if err != nil { - return fmt.Errorf("failed to read working-sets config: %w", err) - } - - ws, exists := cfg.WorkingSets[name] - if !exists { - return fmt.Errorf("working-set %q not found", name) + 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 { @@ -32,7 +31,7 @@ func Show(name string, format Format) error { } fmt.Print(string(data)) default: - humanPrintWorkingSet(name, ws) + humanPrintWorkingSet(name, *ws) } return nil diff --git a/pkg/config/readwrite.go b/pkg/config/readwrite.go index 29507190..bac2a71b 100644 --- a/pkg/config/readwrite.go +++ b/pkg/config/readwrite.go @@ -32,8 +32,17 @@ func ReadCatalog() ([]byte, error) { return readFileOrEmpty(path) } -func ReadWorkingSets() ([]byte, error) { - path, err := FilePath("working-sets.json") +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 } @@ -66,8 +75,12 @@ func WriteCatalog(content []byte) error { return writeConfigFile("catalog.json", content) } -func WriteWorkingSets(content []byte) error { - return writeConfigFile("working-sets.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 { @@ -83,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 { @@ -144,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 { From 398338e08829ecc75ee063972f2619f77d4cc486 Mon Sep 17 00:00:00 2001 From: Bobby House Date: Tue, 21 Oct 2025 09:07:15 -0700 Subject: [PATCH 4/4] wip --- cmd/docker-mcp/commands/gateway.go | 2 ++ cmd/docker-mcp/commands/working-set.go | 20 +++++++++++++------- cmd/docker-mcp/working-set/list.go | 4 ++-- pkg/oci/self_contained.go | 16 +++++++++++----- 4 files changed, 28 insertions(+), 14 deletions(-) diff --git a/cmd/docker-mcp/commands/gateway.go b/cmd/docker-mcp/commands/gateway.go index 304ddcd0..ba641717 100644 --- a/cmd/docker-mcp/commands/gateway.go +++ b/cmd/docker-mcp/commands/gateway.go @@ -143,6 +143,8 @@ func gatewayCommand(docker docker.Client, dockerCli command.Cli) *cobra.Command } // 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 } diff --git a/cmd/docker-mcp/commands/working-set.go b/cmd/docker-mcp/commands/working-set.go index 2c3abb83..6fd239c6 100644 --- a/cmd/docker-mcp/commands/working-set.go +++ b/cmd/docker-mcp/commands/working-set.go @@ -28,15 +28,21 @@ func createWorkingSetCommand() *cobra.Command { Servers []string } cmd := &cobra.Command{ - Use: "create --name [--description ] --server --server ...", + 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.`, - Example: ` # Create a working-set with multiple servers - docker mcp working-set create --name dev-tools --description "Development tools" --server github --server slack +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 a single server - docker mcp working-set create --name docker-only --server docker`, + # 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) }, @@ -44,7 +50,7 @@ A working-set allows you to organize and manage related servers as a single unit 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 in the working-set (can be specified multiple times)") + 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") diff --git a/cmd/docker-mcp/working-set/list.go b/cmd/docker-mcp/working-set/list.go index 490fb3d8..1922c2ee 100644 --- a/cmd/docker-mcp/working-set/list.go +++ b/cmd/docker-mcp/working-set/list.go @@ -48,8 +48,8 @@ func SupportedFormats() string { // 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"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + ServerCount int `json:"serverCount" yaml:"serverCount"` } type ListOutput struct { 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