Skip to content
Merged
Show file tree
Hide file tree
Changes from 28 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
07b8134
Add workingset types and config read/write.
cmrigney Oct 6, 2025
bea14e9
Add test commands
cmrigney Oct 6, 2025
6107e0e
Use a sqlite database for working sets. Add import/export commands.
cmrigney Oct 17, 2025
2aee5bb
Vendor modules.
cmrigney Oct 17, 2025
56e6128
Refactor oci things to make it reusable.
cmrigney Oct 21, 2025
39a44db
Some small refactoring.
cmrigney Oct 21, 2025
75e0926
New working set commands: push, pull, show, ls
cmrigney Oct 21, 2025
4c1825d
Add json and yaml for list formats.
cmrigney Oct 21, 2025
ad2713f
Add create command. Use constant version for working set.
cmrigney Oct 22, 2025
adf3024
Fix push.
cmrigney Oct 22, 2025
c695554
Bare bones gateway with working sets.
cmrigney Oct 22, 2025
e2a40aa
add working set to client connect.
cmrigney Oct 22, 2025
0fc9e29
Add command to remove workingset (#199)
bobbyhouse Oct 23, 2025
8d82c6b
Alias remove to rm.
cmrigney Oct 23, 2025
ec3277d
Better message when working set list is empty.
cmrigney Oct 23, 2025
db6293d
Add message after importing or exporting working set.
cmrigney Oct 23, 2025
5b239c1
Add working sets feature flag.
cmrigney Oct 23, 2025
c30b905
Add validation to working sets.
cmrigney Oct 23, 2025
56a21c7
vendor modules.
cmrigney Oct 23, 2025
7ff0b87
Clean up todos.
cmrigney Oct 23, 2025
df5933f
Hide client connect working set behind feature.
cmrigney Oct 23, 2025
7527207
Add docs for working sets.
cmrigney Oct 23, 2025
de5b417
Merge branch 'main' into workingsets
cmrigney Oct 24, 2025
cbf129c
Fixes after merging main.
cmrigney Oct 24, 2025
092172d
Merge branch 'main' into workingsets
cmrigney Oct 27, 2025
19169da
Update docs.
cmrigney Oct 27, 2025
499fe4c
Fix most lint errors.
cmrigney Oct 27, 2025
8818624
Fix lint errors.
cmrigney Oct 28, 2025
20e5cd0
CR feedback.
cmrigney Oct 29, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
6 changes: 6 additions & 0 deletions cmd/docker-mcp/client/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,12 @@ func newMCPGatewayServer() *MCPServerSTDIO {
}
}

func newMcpGatewayServerWithWorkingSet(workingSet string) *MCPServerSTDIO {
server := newMCPGatewayServer()
server.Args = append(server.Args, "--working-set", workingSet)
return server
}

func GetUpdater(vendor string, global bool, cwd string, config Config) (Updater, error) {
if global {
cfg, ok := config.System[vendor]
Expand Down
12 changes: 9 additions & 3 deletions cmd/docker-mcp/client/connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import (
"github.com/docker/mcp-gateway/cmd/docker-mcp/hints"
)

func Connect(ctx context.Context, dockerCli command.Cli, cwd string, config Config, vendor string, global, quiet bool) error {
func Connect(ctx context.Context, dockerCli command.Cli, cwd string, config Config, vendor string, global, quiet bool, workingSet string) error {
if vendor == vendorCodex {
if !global {
return fmt.Errorf("codex only supports global configuration. Re-run with --global or -g")
Expand All @@ -26,8 +26,14 @@ func Connect(ctx context.Context, dockerCli command.Cli, cwd string, config Conf
if err != nil {
return err
}
if err := updater(DockerMCPCatalog, newMCPGatewayServer()); err != nil {
return err
if workingSet != "" {
if err := updater(DockerMCPCatalog, newMcpGatewayServerWithWorkingSet(workingSet)); err != nil {
return err
}
} else {
if err := updater(DockerMCPCatalog, newMCPGatewayServer()); err != nil {
return err
}
}
}
if quiet {
Expand Down
14 changes: 11 additions & 3 deletions cmd/docker-mcp/commands/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,20 +46,24 @@ func listClientCommand(cwd string, cfg client.Config) *cobra.Command {

func connectClientCommand(dockerCli command.Cli, cwd string, cfg client.Config) *cobra.Command {
var opts struct {
Global bool
Quiet bool
Global bool
Quiet bool
WorkingSet string
}
cmd := &cobra.Command{
Use: fmt.Sprintf("connect [OPTIONS] <mcp-client>\n\nSupported clients: %s", strings.Join(client.GetSupportedMCPClients(cfg), " ")),
Short: fmt.Sprintf("Connect the Docker MCP Toolkit to a client. Supported clients: %s", strings.Join(client.GetSupportedMCPClients(cfg), " ")),
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return client.Connect(cmd.Context(), dockerCli, cwd, cfg, args[0], opts.Global, opts.Quiet)
return client.Connect(cmd.Context(), dockerCli, cwd, cfg, args[0], opts.Global, opts.Quiet, opts.WorkingSet)
},
}
flags := cmd.Flags()
addGlobalFlag(flags, &opts.Global)
addQuietFlag(flags, &opts.Quiet)
if isWorkingSetsFeatureEnabled(dockerCli) {
addWorkingSetFlag(flags, &opts.WorkingSet)
}
return cmd
}

Expand Down Expand Up @@ -119,3 +123,7 @@ func addGlobalFlag(flags *pflag.FlagSet, p *bool) {
func addQuietFlag(flags *pflag.FlagSet, p *bool) {
flags.BoolVarP(p, "quiet", "q", false, "Only display errors.")
}

func addWorkingSetFlag(flags *pflag.FlagSet, p *string) {
flags.StringVarP(p, "working-set", "w", "", "Working set to use for client connection.")
}
13 changes: 11 additions & 2 deletions cmd/docker-mcp/commands/feature.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,15 @@ Available features:
oauth-interceptor Enable GitHub OAuth flow interception for automatic authentication
mcp-oauth-dcr Enable Dynamic Client Registration (DCR) for automatic OAuth client setup
dynamic-tools Enable internal MCP management tools (mcp-find, mcp-add, mcp-remove)
working-sets Enable working set management tools (docker mcp workingset <subcommand>)
tool-name-prefix Prefix all tool names with server name to avoid conflicts`,
Args: cobra.ExactArgs(1),
RunE: func(_ *cobra.Command, args []string) error {
featureName := args[0]

// Validate feature name
if !isKnownFeature(featureName) {
return fmt.Errorf("unknown feature: %s\n\nAvailable features:\n oauth-interceptor Enable GitHub OAuth flow interception\n mcp-oauth-dcr Enable Dynamic Client Registration for automatic OAuth setup\n dynamic-tools Enable internal MCP management tools\n tool-name-prefix Prefix all tool names with server name", featureName)
return fmt.Errorf("unknown feature: %s\n\nAvailable features:\n oauth-interceptor Enable GitHub OAuth flow interception\n mcp-oauth-dcr Enable Dynamic Client Registration for automatic OAuth setup\n dynamic-tools Enable internal MCP management tools\n working-sets Enable working set management tools (docker mcp workingset <subcommand>)\n tool-name-prefix Prefix all tool names with server name", featureName)
}

// Enable the feature
Expand Down Expand Up @@ -84,6 +85,11 @@ Available features:
fmt.Println(" - mcp-add: add MCP servers to the registry and reload configuration")
fmt.Println(" - mcp-remove: remove MCP servers from the registry and reload configuration")
fmt.Println("\nNo additional flags are needed - this applies to all gateway runs.")
case "working-sets":
fmt.Println("\nThis feature enables working set management tools.")
fmt.Println("When enabled, the cli provides commands for managing working sets:")
fmt.Println(" - docker mcp workingset <subcommand> ...")
fmt.Println("\nThis also enables the --working-set flag for the docker mcp gateway run command.")
case "tool-name-prefix":
fmt.Println("\nThis feature enables automatic prefixing of tool names with server names.")
fmt.Println("When enabled, all tools are automatically prefixed with their server name:")
Expand Down Expand Up @@ -145,7 +151,7 @@ func featureListCommand(dockerCli command.Cli) *cobra.Command {
fmt.Println()

// Show all known features
knownFeatures := []string{"oauth-interceptor", "mcp-oauth-dcr", "dynamic-tools", "tool-name-prefix"}
knownFeatures := []string{"oauth-interceptor", "mcp-oauth-dcr", "dynamic-tools", "working-sets", "tool-name-prefix"}
for _, feature := range knownFeatures {
status := "disabled"
if isFeatureEnabledFromCli(dockerCli, feature) {
Expand All @@ -162,6 +168,8 @@ func featureListCommand(dockerCli command.Cli) *cobra.Command {
fmt.Printf(" %-20s %s\n", "", "Enable Dynamic Client Registration (DCR) for automatic OAuth client setup")
case "dynamic-tools":
fmt.Printf(" %-20s %s\n", "", "Enable internal MCP management tools (mcp-find, mcp-add, mcp-remove)")
case "working-sets":
fmt.Printf(" %-20s %s\n", "", "Enable working set management tools (docker mcp workingset <subcommand>)")
case "tool-name-prefix":
fmt.Printf(" %-20s %s\n", "", "Prefix all tool names with server name to avoid conflicts")
}
Expand Down Expand Up @@ -233,6 +241,7 @@ func isKnownFeature(feature string) bool {
"oauth-interceptor",
"mcp-oauth-dcr",
"dynamic-tools",
"working-sets",
"tool-name-prefix",
}

Expand Down
26 changes: 26 additions & 0 deletions cmd/docker-mcp/commands/gateway.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,15 @@ func gatewayCommand(docker docker.Client, dockerCli command.Cli) *cobra.Command
options.MCPRegistryServers = mcpServers
}

if options.WorkingSet != "" {
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")
}
}

// Handle --enable-all-servers flag
if enableAllServers {
if len(options.ServerNames) > 0 {
Expand All @@ -148,6 +157,9 @@ 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)")
if isWorkingSetsFeatureEnabled(dockerCli) {
runCmd.Flags().StringVar(&options.WorkingSet, "working-set", "", "Working set ID 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")
Expand Down Expand Up @@ -321,3 +333,17 @@ func isToolNamePrefixFeatureEnabled(dockerCli command.Cli) bool {

return value == "enabled"
}

// isWorkingSetsFeatureEnabled checks if the working-sets feature is enabled
func isWorkingSetsFeatureEnabled(dockerCli command.Cli) bool {
configFile := dockerCli.ConfigFile()
if configFile == nil || configFile.Features == nil {
return false
}

value, exists := configFile.Features["working-sets"]
if !exists {
return false
}
return value == "enabled"
}
3 changes: 3 additions & 0 deletions cmd/docker-mcp/commands/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ func Root(ctx context.Context, cwd string, dockerCli command.Cli) *cobra.Command

dockerClient := docker.NewClient(dockerCli)

if isWorkingSetsFeatureEnabled(dockerCli) {
cmd.AddCommand(workingSetCommand())
}
cmd.AddCommand(catalogCommand(dockerCli))
cmd.AddCommand(clientCommand(dockerCli, cwd))
cmd.AddCommand(configCommand(dockerClient))
Expand Down
2 changes: 1 addition & 1 deletion cmd/docker-mcp/commands/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ func serverCommand(docker docker.Client, dockerCli command.Cli) *cobra.Command {
// OCI refs typically contain a registry/repository pattern with optional tag or digest
if strings.Contains(arg, "/") && (strings.Contains(arg, ":") || strings.Contains(arg, "@")) {
// Use OCI inspect for OCI references
return oci.InspectArtifact(arg)
return oci.InspectArtifact[oci.Catalog](arg, oci.MCPServerArtifactType)
}

// Use regular server inspect for server names
Expand Down
201 changes: 201 additions & 0 deletions cmd/docker-mcp/commands/workingset.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
package commands

import (
"fmt"
"slices"
"strings"

"github.com/spf13/cobra"

"github.com/docker/mcp-gateway/pkg/db"
"github.com/docker/mcp-gateway/pkg/workingset"
)

func workingSetCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "workingset",
Short: "Manage working sets",
}

cmd.AddCommand(exportWorkingSetCommand())
cmd.AddCommand(importWorkingSetCommand())
cmd.AddCommand(showWorkingSetCommand())
cmd.AddCommand(listWorkingSetsCommand())
cmd.AddCommand(pushWorkingSetCommand())
cmd.AddCommand(pullWorkingSetCommand())
cmd.AddCommand(createWorkingSetCommand())
cmd.AddCommand(removeWorkingSetCommand())
return cmd
}

func createWorkingSetCommand() *cobra.Command {
var opts struct {
ID string
Name string
Servers []string
}

cmd := &cobra.Command{
Use: "create --name <name> [--id <id>] --server <ref1> --server <ref2> ...",
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:
- MCP Registry references (e.g. http://registry.modelcontextprotocol.io/v0/servers/312e45a4-2216-4b21-b9a8-0f1a51425073)
- 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 --server docker://mcp/github:latest --server docker://mcp/slack:latest

# Create a working-set with MCP Registry references
docker mcp working-set create --name registry-servers --server http://registry.modelcontextprotocol.io/v0/servers/71de5a2a-6cfb-4250-a196-f93080ecc860

# Mix MCP Registry references and OCI references
docker mcp working-set create --name mixed --server http://registry.modelcontextprotocol.io/v0/servers/71de5a2a-6cfb-4250-a196-f93080ecc860 --server docker://mcp/github:latest`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
dao, err := db.New()
if err != nil {
return err
}
return workingset.Create(cmd.Context(), dao, opts.ID, opts.Name, opts.Servers)
},
}

flags := cmd.Flags()
flags.StringVar(&opts.Name, "name", "", "Name of the working set (required)")
flags.StringVar(&opts.ID, "id", "", "ID of the working set (defaults to a slugified version of the name)")
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")

return cmd
}

func listWorkingSetsCommand() *cobra.Command {
format := string(workingset.OutputFormatHumanReadable)

cmd := &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
Short: "List working sets",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
supported := slices.Contains(workingset.SupportedFormats(), format)
if !supported {
return fmt.Errorf("unsupported format: %s", format)
}
dao, err := db.New()
if err != nil {
return err
}
return workingset.List(cmd.Context(), dao, workingset.OutputFormat(format))
},
}

flags := cmd.Flags()
flags.StringVar(&format, "format", string(workingset.OutputFormatHumanReadable), fmt.Sprintf("Supported: %s.", strings.Join(workingset.SupportedFormats(), ", ")))

return cmd
}

func showWorkingSetCommand() *cobra.Command {
format := string(workingset.OutputFormatHumanReadable)

cmd := &cobra.Command{
Use: "show <working-set-id>",
Short: "Show working set",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
supported := slices.Contains(workingset.SupportedFormats(), format)
if !supported {
return fmt.Errorf("unsupported format: %s", format)
}
dao, err := db.New()
if err != nil {
return err
}
return workingset.Show(cmd.Context(), dao, args[0], workingset.OutputFormat(format))
},
}

flags := cmd.Flags()
flags.StringVar(&format, "format", string(workingset.OutputFormatHumanReadable), fmt.Sprintf("Supported: %s.", strings.Join(workingset.SupportedFormats(), ", ")))

return cmd
}

func pullWorkingSetCommand() *cobra.Command {
return &cobra.Command{
Use: "pull <oci-reference>",
Short: "Pull working set from OCI registry",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
dao, err := db.New()
if err != nil {
return err
}
return workingset.Pull(cmd.Context(), dao, args[0])
},
}
}

func pushWorkingSetCommand() *cobra.Command {
return &cobra.Command{
Use: "push <working-set-id> <oci-reference>",
Short: "Push working set to OCI registry",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
dao, err := db.New()
if err != nil {
return err
}
return workingset.Push(cmd.Context(), dao, args[0], args[1])
},
}
}

func exportWorkingSetCommand() *cobra.Command {
return &cobra.Command{
Use: "export <working-set-id> <output-file>",
Short: "Export working set to file",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
dao, err := db.New()
if err != nil {
return err
}
return workingset.Export(cmd.Context(), dao, args[0], args[1])
},
}
}

func importWorkingSetCommand() *cobra.Command {
return &cobra.Command{
Use: "import <input-file>",
Short: "Import working set from file",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
dao, err := db.New()
if err != nil {
return err
}
return workingset.Import(cmd.Context(), dao, args[0])
},
}
}

func removeWorkingSetCommand() *cobra.Command {
return &cobra.Command{
Use: "remove <working-set-id>",
Aliases: []string{"rm"},
Short: "Remove a working set",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
dao, err := db.New()
if err != nil {
return err
}
return workingset.Remove(cmd.Context(), dao, args[0])
},
}
}
Loading
Loading