Skip to content

Commit aa873b5

Browse files
cmrigneybobbyhouse
andauthored
Add basic working set support (#204)
* Add workingset types and config read/write. * Add test commands * Use a sqlite database for working sets. Add import/export commands. * Vendor modules. * Refactor oci things to make it reusable. * Some small refactoring. * New working set commands: push, pull, show, ls * Add json and yaml for list formats. * Add create command. Use constant version for working set. * Fix push. * Bare bones gateway with working sets. * add working set to client connect. * Add command to remove workingset (#199) * Alias remove to rm. * Better message when working set list is empty. * Add message after importing or exporting working set. * Add working sets feature flag. * Add validation to working sets. * vendor modules. * Clean up todos. * Hide client connect working set behind feature. * Add docs for working sets. * Fixes after merging main. * Update docs. * Fix most lint errors. * Fix lint errors. * CR feedback. --------- Co-authored-by: Bobby <[email protected]>
1 parent 80583bc commit aa873b5

File tree

1,737 files changed

+6240549
-1121
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

1,737 files changed

+6240549
-1121
lines changed

cmd/docker-mcp/client/config.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,12 @@ func newMCPGatewayServer() *MCPServerSTDIO {
102102
}
103103
}
104104

105+
func newMcpGatewayServerWithWorkingSet(workingSet string) *MCPServerSTDIO {
106+
server := newMCPGatewayServer()
107+
server.Args = append(server.Args, "--working-set", workingSet)
108+
return server
109+
}
110+
105111
func GetUpdater(vendor string, global bool, cwd string, config Config) (Updater, error) {
106112
if global {
107113
cfg, ok := config.System[vendor]

cmd/docker-mcp/client/connect.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import (
99
"github.com/docker/mcp-gateway/cmd/docker-mcp/hints"
1010
)
1111

12-
func Connect(ctx context.Context, dockerCli command.Cli, cwd string, config Config, vendor string, global, quiet bool) error {
12+
func Connect(ctx context.Context, dockerCli command.Cli, cwd string, config Config, vendor string, global, quiet bool, workingSet string) error {
1313
if vendor == vendorCodex {
1414
if !global {
1515
return fmt.Errorf("codex only supports global configuration. Re-run with --global or -g")
@@ -26,8 +26,14 @@ func Connect(ctx context.Context, dockerCli command.Cli, cwd string, config Conf
2626
if err != nil {
2727
return err
2828
}
29-
if err := updater(DockerMCPCatalog, newMCPGatewayServer()); err != nil {
30-
return err
29+
if workingSet != "" {
30+
if err := updater(DockerMCPCatalog, newMcpGatewayServerWithWorkingSet(workingSet)); err != nil {
31+
return err
32+
}
33+
} else {
34+
if err := updater(DockerMCPCatalog, newMCPGatewayServer()); err != nil {
35+
return err
36+
}
3137
}
3238
}
3339
if quiet {

cmd/docker-mcp/commands/client.go

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,20 +46,24 @@ func listClientCommand(cwd string, cfg client.Config) *cobra.Command {
4646

4747
func connectClientCommand(dockerCli command.Cli, cwd string, cfg client.Config) *cobra.Command {
4848
var opts struct {
49-
Global bool
50-
Quiet bool
49+
Global bool
50+
Quiet bool
51+
WorkingSet string
5152
}
5253
cmd := &cobra.Command{
5354
Use: fmt.Sprintf("connect [OPTIONS] <mcp-client>\n\nSupported clients: %s", strings.Join(client.GetSupportedMCPClients(cfg), " ")),
5455
Short: fmt.Sprintf("Connect the Docker MCP Toolkit to a client. Supported clients: %s", strings.Join(client.GetSupportedMCPClients(cfg), " ")),
5556
Args: cobra.ExactArgs(1),
5657
RunE: func(cmd *cobra.Command, args []string) error {
57-
return client.Connect(cmd.Context(), dockerCli, cwd, cfg, args[0], opts.Global, opts.Quiet)
58+
return client.Connect(cmd.Context(), dockerCli, cwd, cfg, args[0], opts.Global, opts.Quiet, opts.WorkingSet)
5859
},
5960
}
6061
flags := cmd.Flags()
6162
addGlobalFlag(flags, &opts.Global)
6263
addQuietFlag(flags, &opts.Quiet)
64+
if isWorkingSetsFeatureEnabled(dockerCli) {
65+
addWorkingSetFlag(flags, &opts.WorkingSet)
66+
}
6367
return cmd
6468
}
6569

@@ -119,3 +123,7 @@ func addGlobalFlag(flags *pflag.FlagSet, p *bool) {
119123
func addQuietFlag(flags *pflag.FlagSet, p *bool) {
120124
flags.BoolVarP(p, "quiet", "q", false, "Only display errors.")
121125
}
126+
127+
func addWorkingSetFlag(flags *pflag.FlagSet, p *string) {
128+
flags.StringVarP(p, "working-set", "w", "", "Working set to use for client connection.")
129+
}

cmd/docker-mcp/commands/feature.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,14 +40,15 @@ Available features:
4040
oauth-interceptor Enable GitHub OAuth flow interception for automatic authentication
4141
mcp-oauth-dcr Enable Dynamic Client Registration (DCR) for automatic OAuth client setup
4242
dynamic-tools Enable internal MCP management tools (mcp-find, mcp-add, mcp-remove)
43+
working-sets Enable working set management tools (docker mcp workingset <subcommand>)
4344
tool-name-prefix Prefix all tool names with server name to avoid conflicts`,
4445
Args: cobra.ExactArgs(1),
4546
RunE: func(_ *cobra.Command, args []string) error {
4647
featureName := args[0]
4748

4849
// Validate feature name
4950
if !isKnownFeature(featureName) {
50-
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)
51+
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)
5152
}
5253

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

147153
// Show all known features
148-
knownFeatures := []string{"oauth-interceptor", "mcp-oauth-dcr", "dynamic-tools", "tool-name-prefix"}
154+
knownFeatures := []string{"oauth-interceptor", "mcp-oauth-dcr", "dynamic-tools", "working-sets", "tool-name-prefix"}
149155
for _, feature := range knownFeatures {
150156
status := "disabled"
151157
if isFeatureEnabledFromCli(dockerCli, feature) {
@@ -162,6 +168,8 @@ func featureListCommand(dockerCli command.Cli) *cobra.Command {
162168
fmt.Printf(" %-20s %s\n", "", "Enable Dynamic Client Registration (DCR) for automatic OAuth client setup")
163169
case "dynamic-tools":
164170
fmt.Printf(" %-20s %s\n", "", "Enable internal MCP management tools (mcp-find, mcp-add, mcp-remove)")
171+
case "working-sets":
172+
fmt.Printf(" %-20s %s\n", "", "Enable working set management tools (docker mcp workingset <subcommand>)")
165173
case "tool-name-prefix":
166174
fmt.Printf(" %-20s %s\n", "", "Prefix all tool names with server name to avoid conflicts")
167175
}
@@ -233,6 +241,7 @@ func isKnownFeature(feature string) bool {
233241
"oauth-interceptor",
234242
"mcp-oauth-dcr",
235243
"dynamic-tools",
244+
"working-sets",
236245
"tool-name-prefix",
237246
}
238247

cmd/docker-mcp/commands/gateway.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,15 @@ func gatewayCommand(docker docker.Client, dockerCli command.Cli) *cobra.Command
123123
options.MCPRegistryServers = mcpServers
124124
}
125125

126+
if options.WorkingSet != "" {
127+
if len(options.ServerNames) > 0 {
128+
return fmt.Errorf("cannot use --working-set with --servers flag")
129+
}
130+
if enableAllServers {
131+
return fmt.Errorf("cannot use --working-set with --enable-all-servers flag")
132+
}
133+
}
134+
126135
// Handle --enable-all-servers flag
127136
if enableAllServers {
128137
if len(options.ServerNames) > 0 {
@@ -148,6 +157,9 @@ func gatewayCommand(docker docker.Client, dockerCli command.Cli) *cobra.Command
148157
}
149158

150159
runCmd.Flags().StringSliceVar(&options.ServerNames, "servers", nil, "Names of the servers to enable (if non empty, ignore --registry flag)")
160+
if isWorkingSetsFeatureEnabled(dockerCli) {
161+
runCmd.Flags().StringVar(&options.WorkingSet, "working-set", "", "Working set ID to use (mutually exclusive with --servers and --enable-all-servers)")
162+
}
151163
runCmd.Flags().BoolVar(&enableAllServers, "enable-all-servers", false, "Enable all servers in the catalog (instead of using individual --servers options)")
152164
runCmd.Flags().StringSliceVar(&options.CatalogPath, "catalog", options.CatalogPath, "Paths to docker catalogs (absolute or relative to ~/.docker/mcp/catalogs/)")
153165
runCmd.Flags().StringSliceVar(&additionalCatalogs, "additional-catalog", nil, "Additional catalog paths to append to the default catalogs")
@@ -321,3 +333,17 @@ func isToolNamePrefixFeatureEnabled(dockerCli command.Cli) bool {
321333

322334
return value == "enabled"
323335
}
336+
337+
// isWorkingSetsFeatureEnabled checks if the working-sets feature is enabled
338+
func isWorkingSetsFeatureEnabled(dockerCli command.Cli) bool {
339+
configFile := dockerCli.ConfigFile()
340+
if configFile == nil || configFile.Features == nil {
341+
return false
342+
}
343+
344+
value, exists := configFile.Features["working-sets"]
345+
if !exists {
346+
return false
347+
}
348+
return value == "enabled"
349+
}

cmd/docker-mcp/commands/root.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@ func Root(ctx context.Context, cwd string, dockerCli command.Cli) *cobra.Command
7070

7171
dockerClient := docker.NewClient(dockerCli)
7272

73+
if isWorkingSetsFeatureEnabled(dockerCli) {
74+
cmd.AddCommand(workingSetCommand())
75+
}
7376
cmd.AddCommand(catalogCommand(dockerCli))
7477
cmd.AddCommand(clientCommand(dockerCli, cwd))
7578
cmd.AddCommand(configCommand(dockerClient))

cmd/docker-mcp/commands/server.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ func serverCommand(docker docker.Client, dockerCli command.Cli) *cobra.Command {
8989
// OCI refs typically contain a registry/repository pattern with optional tag or digest
9090
if strings.Contains(arg, "/") && (strings.Contains(arg, ":") || strings.Contains(arg, "@")) {
9191
// Use OCI inspect for OCI references
92-
return oci.InspectArtifact(arg)
92+
return oci.InspectArtifact[oci.Catalog](arg, oci.MCPServerArtifactType)
9393
}
9494

9595
// Use regular server inspect for server names
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
package commands
2+
3+
import (
4+
"fmt"
5+
"slices"
6+
"strings"
7+
8+
"github.com/spf13/cobra"
9+
10+
"github.com/docker/mcp-gateway/pkg/db"
11+
"github.com/docker/mcp-gateway/pkg/workingset"
12+
)
13+
14+
func workingSetCommand() *cobra.Command {
15+
cmd := &cobra.Command{
16+
Use: "workingset",
17+
Short: "Manage working sets",
18+
}
19+
20+
cmd.AddCommand(exportWorkingSetCommand())
21+
cmd.AddCommand(importWorkingSetCommand())
22+
cmd.AddCommand(showWorkingSetCommand())
23+
cmd.AddCommand(listWorkingSetsCommand())
24+
cmd.AddCommand(pushWorkingSetCommand())
25+
cmd.AddCommand(pullWorkingSetCommand())
26+
cmd.AddCommand(createWorkingSetCommand())
27+
cmd.AddCommand(removeWorkingSetCommand())
28+
return cmd
29+
}
30+
31+
func createWorkingSetCommand() *cobra.Command {
32+
var opts struct {
33+
ID string
34+
Name string
35+
Servers []string
36+
}
37+
38+
cmd := &cobra.Command{
39+
Use: "create --name <name> [--id <id>] --server <ref1> --server <ref2> ...",
40+
Short: "Create a new working set of MCP servers",
41+
Long: `Create a new working set that groups multiple MCP servers together.
42+
A working set allows you to organize and manage related servers as a single unit.
43+
Working sets are decoupled from catalogs. Servers can be:
44+
- MCP Registry references (e.g. http://registry.modelcontextprotocol.io/v0/servers/312e45a4-2216-4b21-b9a8-0f1a51425073)
45+
- OCI image references with docker:// prefix (e.g., "docker://mcp/github:latest")`,
46+
Example: ` # Create a working-set with multiple servers (OCI references)
47+
docker mcp working-set create --name dev-tools --server docker://mcp/github:latest --server docker://mcp/slack:latest
48+
49+
# Create a working-set with MCP Registry references
50+
docker mcp working-set create --name registry-servers --server http://registry.modelcontextprotocol.io/v0/servers/71de5a2a-6cfb-4250-a196-f93080ecc860
51+
52+
# Mix MCP Registry references and OCI references
53+
docker mcp working-set create --name mixed --server http://registry.modelcontextprotocol.io/v0/servers/71de5a2a-6cfb-4250-a196-f93080ecc860 --server docker://mcp/github:latest`,
54+
Args: cobra.NoArgs,
55+
RunE: func(cmd *cobra.Command, _ []string) error {
56+
dao, err := db.New()
57+
if err != nil {
58+
return err
59+
}
60+
return workingset.Create(cmd.Context(), dao, opts.ID, opts.Name, opts.Servers)
61+
},
62+
}
63+
64+
flags := cmd.Flags()
65+
flags.StringVar(&opts.Name, "name", "", "Name of the working set (required)")
66+
flags.StringVar(&opts.ID, "id", "", "ID of the working set (defaults to a slugified version of the name)")
67+
flags.StringArrayVar(&opts.Servers, "server", []string{}, "Server to include: catalog name or OCI reference with docker:// prefix (can be specified multiple times)")
68+
69+
_ = cmd.MarkFlagRequired("name")
70+
71+
return cmd
72+
}
73+
74+
func listWorkingSetsCommand() *cobra.Command {
75+
format := string(workingset.OutputFormatHumanReadable)
76+
77+
cmd := &cobra.Command{
78+
Use: "list",
79+
Aliases: []string{"ls"},
80+
Short: "List working sets",
81+
Args: cobra.NoArgs,
82+
RunE: func(cmd *cobra.Command, _ []string) error {
83+
supported := slices.Contains(workingset.SupportedFormats(), format)
84+
if !supported {
85+
return fmt.Errorf("unsupported format: %s", format)
86+
}
87+
dao, err := db.New()
88+
if err != nil {
89+
return err
90+
}
91+
return workingset.List(cmd.Context(), dao, workingset.OutputFormat(format))
92+
},
93+
}
94+
95+
flags := cmd.Flags()
96+
flags.StringVar(&format, "format", string(workingset.OutputFormatHumanReadable), fmt.Sprintf("Supported: %s.", strings.Join(workingset.SupportedFormats(), ", ")))
97+
98+
return cmd
99+
}
100+
101+
func showWorkingSetCommand() *cobra.Command {
102+
format := string(workingset.OutputFormatHumanReadable)
103+
104+
cmd := &cobra.Command{
105+
Use: "show <working-set-id>",
106+
Short: "Show working set",
107+
Args: cobra.ExactArgs(1),
108+
RunE: func(cmd *cobra.Command, args []string) error {
109+
supported := slices.Contains(workingset.SupportedFormats(), format)
110+
if !supported {
111+
return fmt.Errorf("unsupported format: %s", format)
112+
}
113+
dao, err := db.New()
114+
if err != nil {
115+
return err
116+
}
117+
return workingset.Show(cmd.Context(), dao, args[0], workingset.OutputFormat(format))
118+
},
119+
}
120+
121+
flags := cmd.Flags()
122+
flags.StringVar(&format, "format", string(workingset.OutputFormatHumanReadable), fmt.Sprintf("Supported: %s.", strings.Join(workingset.SupportedFormats(), ", ")))
123+
124+
return cmd
125+
}
126+
127+
func pullWorkingSetCommand() *cobra.Command {
128+
return &cobra.Command{
129+
Use: "pull <oci-reference>",
130+
Short: "Pull working set from OCI registry",
131+
Args: cobra.ExactArgs(1),
132+
RunE: func(cmd *cobra.Command, args []string) error {
133+
dao, err := db.New()
134+
if err != nil {
135+
return err
136+
}
137+
return workingset.Pull(cmd.Context(), dao, args[0])
138+
},
139+
}
140+
}
141+
142+
func pushWorkingSetCommand() *cobra.Command {
143+
return &cobra.Command{
144+
Use: "push <working-set-id> <oci-reference>",
145+
Short: "Push working set to OCI registry",
146+
Args: cobra.ExactArgs(2),
147+
RunE: func(cmd *cobra.Command, args []string) error {
148+
dao, err := db.New()
149+
if err != nil {
150+
return err
151+
}
152+
return workingset.Push(cmd.Context(), dao, args[0], args[1])
153+
},
154+
}
155+
}
156+
157+
func exportWorkingSetCommand() *cobra.Command {
158+
return &cobra.Command{
159+
Use: "export <working-set-id> <output-file>",
160+
Short: "Export working set to file",
161+
Args: cobra.ExactArgs(2),
162+
RunE: func(cmd *cobra.Command, args []string) error {
163+
dao, err := db.New()
164+
if err != nil {
165+
return err
166+
}
167+
return workingset.Export(cmd.Context(), dao, args[0], args[1])
168+
},
169+
}
170+
}
171+
172+
func importWorkingSetCommand() *cobra.Command {
173+
return &cobra.Command{
174+
Use: "import <input-file>",
175+
Short: "Import working set from file",
176+
Args: cobra.ExactArgs(1),
177+
RunE: func(cmd *cobra.Command, args []string) error {
178+
dao, err := db.New()
179+
if err != nil {
180+
return err
181+
}
182+
return workingset.Import(cmd.Context(), dao, args[0])
183+
},
184+
}
185+
}
186+
187+
func removeWorkingSetCommand() *cobra.Command {
188+
return &cobra.Command{
189+
Use: "remove <working-set-id>",
190+
Aliases: []string{"rm"},
191+
Short: "Remove a working set",
192+
Args: cobra.ExactArgs(1),
193+
RunE: func(cmd *cobra.Command, args []string) error {
194+
dao, err := db.New()
195+
if err != nil {
196+
return err
197+
}
198+
return workingset.Remove(cmd.Context(), dao, args[0])
199+
},
200+
}
201+
}

0 commit comments

Comments
 (0)