From 5e960119f88abbf53fbfe8e891f6d280a4055792 Mon Sep 17 00:00:00 2001 From: Cody Rigney Date: Fri, 5 Dec 2025 16:03:53 -0500 Subject: [PATCH] Use Docker Desktop FF for profiles. --- cmd/docker-mcp/commands/client.go | 15 ++--- cmd/docker-mcp/commands/feature.go | 46 +++++++++----- cmd/docker-mcp/commands/feature_test.go | 42 +++++++++++-- cmd/docker-mcp/commands/gateway.go | 23 ++----- cmd/docker-mcp/commands/root.go | 18 ++++-- pkg/desktop/features.go | 37 +++++++++++ pkg/features/features.go | 82 +++++++++++++++++++++++++ 7 files changed, 211 insertions(+), 52 deletions(-) create mode 100644 pkg/features/features.go diff --git a/cmd/docker-mcp/commands/client.go b/cmd/docker-mcp/commands/client.go index 5e1dff5d..3063bfda 100644 --- a/cmd/docker-mcp/commands/client.go +++ b/cmd/docker-mcp/commands/client.go @@ -12,18 +12,19 @@ import ( clientcli "github.com/docker/mcp-gateway/cmd/docker-mcp/client" "github.com/docker/mcp-gateway/pkg/client" + "github.com/docker/mcp-gateway/pkg/features" ) -func clientCommand(dockerCli command.Cli, cwd string) *cobra.Command { +func clientCommand(dockerCli command.Cli, cwd string, features features.Features) *cobra.Command { cfg := client.ReadConfig() cmd := &cobra.Command{ Use: fmt.Sprintf("client (Supported: %s)", strings.Join(client.GetSupportedMCPClients(*cfg), ", ")), Short: "Manage MCP clients", } cmd.AddCommand(listClientCommand(cwd, *cfg)) - cmd.AddCommand(connectClientCommand(dockerCli, cwd, *cfg)) + cmd.AddCommand(connectClientCommand(dockerCli, cwd, *cfg, features)) cmd.AddCommand(disconnectClientCommand(cwd, *cfg)) - cmd.AddCommand(manualClientCommand(dockerCli)) + cmd.AddCommand(manualClientCommand(features)) return cmd } @@ -46,7 +47,7 @@ func listClientCommand(cwd string, cfg client.Config) *cobra.Command { return cmd } -func connectClientCommand(dockerCli command.Cli, cwd string, cfg client.Config) *cobra.Command { +func connectClientCommand(dockerCli command.Cli, cwd string, cfg client.Config, features features.Features) *cobra.Command { var opts struct { Global bool Quiet bool @@ -63,7 +64,7 @@ func connectClientCommand(dockerCli command.Cli, cwd string, cfg client.Config) flags := cmd.Flags() addGlobalFlag(flags, &opts.Global) addQuietFlag(flags, &opts.Quiet) - if isWorkingSetsFeatureEnabled(dockerCli) { + if features.IsProfilesFeatureEnabled() { addWorkingSetFlag(flags, &opts.WorkingSet) } return cmd @@ -88,7 +89,7 @@ func disconnectClientCommand(cwd string, cfg client.Config) *cobra.Command { return cmd } -func manualClientCommand(dockerCli command.Cli) *cobra.Command { +func manualClientCommand(features features.Features) *cobra.Command { cmd := &cobra.Command{ Use: "manual-instructions", Short: "Display the manual instructions to connect the MCP client", @@ -100,7 +101,7 @@ func manualClientCommand(dockerCli command.Cli) *cobra.Command { } command := []string{"docker", "mcp", "gateway", "run"} - if isWorkingSetsFeatureEnabled(dockerCli) { + if features.IsProfilesFeatureEnabled() { gordonProfile, err := client.ReadGordonProfile() if err != nil { return fmt.Errorf("failed to read gordon profile: %w", err) diff --git a/cmd/docker-mcp/commands/feature.go b/cmd/docker-mcp/commands/feature.go index c12403e6..77629543 100644 --- a/cmd/docker-mcp/commands/feature.go +++ b/cmd/docker-mcp/commands/feature.go @@ -7,10 +7,12 @@ import ( "github.com/docker/cli/cli/command" "github.com/docker/cli/cli/config/configfile" "github.com/spf13/cobra" + + "github.com/docker/mcp-gateway/pkg/features" ) // featureCommand creates the `feature` command and its subcommands -func featureCommand(dockerCli command.Cli) *cobra.Command { +func featureCommand(dockerCli command.Cli, features features.Features) *cobra.Command { cmd := &cobra.Command{ Use: "feature", Short: "Manage experimental features", @@ -21,16 +23,16 @@ and control optional functionality that may change in future versions.`, } cmd.AddCommand( - featureEnableCommand(dockerCli), - featureDisableCommand(dockerCli), - featureListCommand(dockerCli), + featureEnableCommand(dockerCli, features), + featureDisableCommand(dockerCli, features), + featureListCommand(dockerCli, features), ) return cmd } // featureEnableCommand creates the `feature enable` command -func featureEnableCommand(dockerCli command.Cli) *cobra.Command { +func featureEnableCommand(dockerCli command.Cli, features features.Features) *cobra.Command { return &cobra.Command{ Use: "enable ", Short: "Enable an experimental feature", @@ -40,15 +42,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) - profiles Enable profile management (docker mcp profile ) - tool-name-prefix Prefix all tool names with server name to avoid conflicts`, + ` + notDockerDesktop(features, `profiles Enable profile management (docker mcp profile ) + `) + `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 profiles Enable profile management (docker mcp profile )\n tool-name-prefix Prefix all tool names with server name", featureName) + if !isKnownFeature(featureName, features) { + 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"+notDockerDesktop(features, " profiles Enable profile management (docker mcp profile )\n")+" tool-name-prefix Prefix all tool names with server name", featureName) } // Enable the feature @@ -105,7 +107,7 @@ Available features: } // featureDisableCommand creates the `feature disable` command -func featureDisableCommand(dockerCli command.Cli) *cobra.Command { +func featureDisableCommand(dockerCli command.Cli, features features.Features) *cobra.Command { return &cobra.Command{ Use: "disable ", Short: "Disable an experimental feature", @@ -115,7 +117,7 @@ func featureDisableCommand(dockerCli command.Cli) *cobra.Command { featureName := args[0] // Validate feature name - if !isKnownFeature(featureName) { + if !isKnownFeature(featureName, features) { return fmt.Errorf("unknown feature: %s", featureName) } @@ -138,7 +140,7 @@ func featureDisableCommand(dockerCli command.Cli) *cobra.Command { } // featureListCommand creates the `feature list` command -func featureListCommand(dockerCli command.Cli) *cobra.Command { +func featureListCommand(dockerCli command.Cli, features features.Features) *cobra.Command { return &cobra.Command{ Use: "ls", Aliases: []string{"list"}, @@ -151,7 +153,10 @@ func featureListCommand(dockerCli command.Cli) *cobra.Command { fmt.Println() // Show all known features - knownFeatures := []string{"oauth-interceptor", "mcp-oauth-dcr", "dynamic-tools", "profiles", "tool-name-prefix"} + knownFeatures := []string{"oauth-interceptor", "mcp-oauth-dcr", "dynamic-tools", "tool-name-prefix"} + if !features.IsRunningInDockerDesktop() { + knownFeatures = append(knownFeatures, "profiles") + } for _, feature := range knownFeatures { status := "disabled" if isFeatureEnabledFromCli(dockerCli, feature) { @@ -180,7 +185,7 @@ func featureListCommand(dockerCli command.Cli) *cobra.Command { if configFile.Features != nil { unknownFeatures := make([]string, 0) for feature := range configFile.Features { - if !isKnownFeature(feature) { + if !isKnownFeature(feature, features) { unknownFeatures = append(unknownFeatures, feature) } } @@ -235,14 +240,16 @@ func isFeatureEnabledFromConfig(configFile *configfile.ConfigFile, feature strin } // isKnownFeature checks if the feature name is valid -func isKnownFeature(feature string) bool { +func isKnownFeature(feature string, features features.Features) bool { knownFeatures := []string{ "oauth-interceptor", "mcp-oauth-dcr", "dynamic-tools", - "profiles", "tool-name-prefix", } + if !features.IsRunningInDockerDesktop() { + knownFeatures = append(knownFeatures, "profiles") + } for _, known := range knownFeatures { if feature == known { @@ -251,3 +258,10 @@ func isKnownFeature(feature string) bool { } return false } + +func notDockerDesktop(features features.Features, msg string) string { + if features.IsRunningInDockerDesktop() { + return "" + } + return msg +} diff --git a/cmd/docker-mcp/commands/feature_test.go b/cmd/docker-mcp/commands/feature_test.go index 7c65ee7a..c7298bf8 100644 --- a/cmd/docker-mcp/commands/feature_test.go +++ b/cmd/docker-mcp/commands/feature_test.go @@ -5,6 +5,8 @@ import ( "github.com/docker/cli/cli/config/configfile" "github.com/stretchr/testify/assert" + + "github.com/docker/mcp-gateway/pkg/features" ) func TestIsFeatureEnabledOAuthInterceptor(t *testing.T) { @@ -115,12 +117,40 @@ func TestIsFeatureEnabledMcpOAuthDcr(t *testing.T) { func TestIsKnownFeature(t *testing.T) { // Test valid features - assert.True(t, isKnownFeature("oauth-interceptor")) - assert.True(t, isKnownFeature("mcp-oauth-dcr")) - assert.True(t, isKnownFeature("dynamic-tools")) + assert.True(t, isKnownFeature("oauth-interceptor", &mockFeatures{})) + assert.True(t, isKnownFeature("mcp-oauth-dcr", &mockFeatures{})) + assert.True(t, isKnownFeature("dynamic-tools", &mockFeatures{})) // Test invalid features - assert.False(t, isKnownFeature("invalid-feature")) - assert.False(t, isKnownFeature("configured-catalogs")) // No longer supported - assert.False(t, isKnownFeature("")) + assert.False(t, isKnownFeature("invalid-feature", &mockFeatures{})) + assert.False(t, isKnownFeature("configured-catalogs", &mockFeatures{})) // No longer supported + assert.False(t, isKnownFeature("", &mockFeatures{})) + + // Test profiles feature - unknown in Docker Desktop, known in CE + assert.True(t, isKnownFeature("profiles", &mockFeatures{ + runningDockerDesktop: false, + })) + assert.False(t, isKnownFeature("profiles", &mockFeatures{ + runningDockerDesktop: true, + })) +} + +type mockFeatures struct { + initErr error + runningDockerDesktop bool + profilesEnabled bool +} + +var _ features.Features = &mockFeatures{} + +func (m *mockFeatures) InitError() error { + return m.initErr +} + +func (m *mockFeatures) IsRunningInDockerDesktop() bool { + return m.runningDockerDesktop +} + +func (m *mockFeatures) IsProfilesFeatureEnabled() bool { + return m.profilesEnabled } diff --git a/cmd/docker-mcp/commands/gateway.go b/cmd/docker-mcp/commands/gateway.go index 59dab333..1ef08641 100644 --- a/cmd/docker-mcp/commands/gateway.go +++ b/cmd/docker-mcp/commands/gateway.go @@ -12,10 +12,11 @@ import ( "github.com/docker/mcp-gateway/cmd/docker-mcp/catalog" catalogTypes "github.com/docker/mcp-gateway/pkg/catalog" "github.com/docker/mcp-gateway/pkg/docker" + "github.com/docker/mcp-gateway/pkg/features" "github.com/docker/mcp-gateway/pkg/gateway" ) -func gatewayCommand(docker docker.Client, dockerCli command.Cli) *cobra.Command { +func gatewayCommand(docker docker.Client, dockerCli command.Cli, features features.Features) *cobra.Command { cmd := &cobra.Command{ Use: "gateway", Short: "Manage the MCP Server gateway", @@ -57,7 +58,7 @@ func gatewayCommand(docker docker.Client, dockerCli command.Cli) *cobra.Command }, } } - if !isWorkingSetsFeatureEnabled(dockerCli) { + if !features.IsProfilesFeatureEnabled() { // Default these only if we aren't defaulting to profiles setLegacyDefaults(&options) } @@ -67,7 +68,7 @@ func gatewayCommand(docker docker.Client, dockerCli command.Cli) *cobra.Command Short: "Run the gateway", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - if isWorkingSetsFeatureEnabled(dockerCli) { + if features.IsProfilesFeatureEnabled() { if len(options.ServerNames) > 0 || enableAllServers || len(options.CatalogPath) > 0 || len(options.RegistryPath) > 0 || len(options.ConfigPath) > 0 || len(options.ToolsPath) > 0 || len(additionalCatalogs) > 0 || len(additionalRegistries) > 0 || len(additionalConfigs) > 0 || len(additionalToolsConfig) > 0 || @@ -178,7 +179,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)") - if isWorkingSetsFeatureEnabled(dockerCli) { + if features.IsProfilesFeatureEnabled() { runCmd.Flags().StringVar(&options.WorkingSet, "profile", "", "Profile 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)") @@ -355,20 +356,6 @@ func isToolNamePrefixFeatureEnabled(dockerCli command.Cli) bool { return value == "enabled" } -// isWorkingSetsFeatureEnabled checks if the profiles feature is enabled -func isWorkingSetsFeatureEnabled(dockerCli command.Cli) bool { - configFile := dockerCli.ConfigFile() - if configFile == nil || configFile.Features == nil { - return false - } - - value, exists := configFile.Features["profiles"] - if !exists { - return false - } - return value == "enabled" -} - func setLegacyDefaults(options *gateway.Config) { if os.Getenv("DOCKER_MCP_IN_CONTAINER") == "1" { if len(options.CatalogPath) == 0 { diff --git a/cmd/docker-mcp/commands/root.go b/cmd/docker-mcp/commands/root.go index e9d5a441..1fccbbc2 100644 --- a/cmd/docker-mcp/commands/root.go +++ b/cmd/docker-mcp/commands/root.go @@ -13,6 +13,7 @@ import ( "github.com/docker/mcp-gateway/pkg/db" "github.com/docker/mcp-gateway/pkg/desktop" "github.com/docker/mcp-gateway/pkg/docker" + "github.com/docker/mcp-gateway/pkg/features" "github.com/docker/mcp-gateway/pkg/migrate" ) @@ -36,6 +37,8 @@ Examples: func Root(ctx context.Context, cwd string, dockerCli command.Cli) *cobra.Command { dockerClient := docker.NewClient(dockerCli) + features := features.New(ctx, dockerCli) + cmd := &cobra.Command{ Use: "mcp [OPTIONS]", Short: "Manage MCP servers and clients", @@ -50,8 +53,13 @@ func Root(ctx context.Context, cwd string, dockerCli command.Cli) *cobra.Command return err } + // Check the feature initialization error here for clearer error messages for the user + if features.InitError() != nil { + return features.InitError() + } + if os.Getenv("DOCKER_MCP_IN_CONTAINER") != "1" { - if isWorkingSetsFeatureEnabled(dockerCli) { + if features.IsProfilesFeatureEnabled() { if isSubcommandOf(cmd, []string{"catalog-next", "catalog", "profile"}) { dao, err := db.New() if err != nil { @@ -84,15 +92,15 @@ func Root(ctx context.Context, cwd string, dockerCli command.Cli) *cobra.Command return []string{"--help"}, cobra.ShellCompDirectiveNoFileComp }) - if isWorkingSetsFeatureEnabled(dockerCli) { + if features.IsProfilesFeatureEnabled() { cmd.AddCommand(workingSetCommand()) cmd.AddCommand(catalogNextCommand()) } cmd.AddCommand(catalogCommand(dockerCli)) - cmd.AddCommand(clientCommand(dockerCli, cwd)) + cmd.AddCommand(clientCommand(dockerCli, cwd, features)) cmd.AddCommand(configCommand(dockerClient)) - cmd.AddCommand(featureCommand(dockerCli)) - cmd.AddCommand(gatewayCommand(dockerClient, dockerCli)) + cmd.AddCommand(featureCommand(dockerCli, features)) + cmd.AddCommand(gatewayCommand(dockerClient, dockerCli, features)) cmd.AddCommand(oauthCommand()) cmd.AddCommand(policyCommand()) cmd.AddCommand(registryCommand()) diff --git a/pkg/desktop/features.go b/pkg/desktop/features.go index f4839892..e6e6e1ae 100644 --- a/pkg/desktop/features.go +++ b/pkg/desktop/features.go @@ -13,6 +13,26 @@ type Feature struct { Enabled bool `json:"enabled"` } +func CheckProfilesFeatureIsEnabled(ctx context.Context) (bool, error) { + // Copied from https://github.com/docker/ai/commit/ae5c7d328f8aa42bc63d9398157a0673de9ffcf5 + // Save and restore working directory because pinata code might change it. + wd, err := os.Getwd() + if err != nil { + return false, err + } + defer func() { + _ = os.Chdir(wd) + }() + + features, err := getFeatures(ctx) + if err != nil { + //nolint:staticcheck + return false, errors.New("Docker Desktop is not running") + } + + return isFeatureEnabled("MCPWorkingSets", features), nil +} + // CheckFeatureIsEnabled verifies if a feature is enabled in either admin-settings.json or Docker Desktop settings. // settingName is the setting name (e.g. "enableDockerMCPToolkit", "enableDockerAI", etc.) // label is the human-readable name of the feature for error messages @@ -64,3 +84,20 @@ func getSettings(ctx context.Context) (any, error) { } return result, nil } + +func getFeatures(ctx context.Context) (map[string]Feature, error) { + var result map[string]Feature + if err := ClientBackend.Get(ctx, "/features", &result); err != nil { + return nil, err + } + return result, nil +} + +func isFeatureEnabled(featureName string, features map[string]Feature) bool { + for name, feature := range features { + if name == featureName && feature.Enabled { + return true + } + } + return false +} diff --git a/pkg/features/features.go b/pkg/features/features.go new file mode 100644 index 00000000..52f56846 --- /dev/null +++ b/pkg/features/features.go @@ -0,0 +1,82 @@ +package features + +import ( + "context" + "os" + + "github.com/docker/cli/cli/command" + + "github.com/docker/mcp-gateway/pkg/desktop" + "github.com/docker/mcp-gateway/pkg/docker" +) + +type Features interface { + InitError() error + IsProfilesFeatureEnabled() bool + IsRunningInDockerDesktop() bool +} + +type featuresImpl struct { + initErr error + runningDockerDesktop bool + profilesEnabled bool +} + +var _ Features = &featuresImpl{} + +func New(ctx context.Context, dockerCli command.Cli) (result Features) { + features := &featuresImpl{} + result = features + + features.runningDockerDesktop, features.initErr = isRunningInDockerDesktop(ctx, dockerCli) + if features.initErr != nil { + return + } + + features.profilesEnabled, features.initErr = readProfilesFeature(ctx, dockerCli, features.runningDockerDesktop) + return +} + +func (f *featuresImpl) InitError() error { + return f.initErr +} + +func (f *featuresImpl) IsProfilesFeatureEnabled() bool { + return f.profilesEnabled +} + +func (f *featuresImpl) IsRunningInDockerDesktop() bool { + return f.runningDockerDesktop +} + +func isRunningInDockerDesktop(ctx context.Context, dockerCli command.Cli) (bool, error) { + runningInDockerCE, err := docker.RunningInDockerCE(ctx, dockerCli) + if err != nil { + return false, err + } + + return !runningInDockerCE && os.Getenv("DOCKER_MCP_IN_CONTAINER") != "1", nil +} + +func readProfilesFeature(ctx context.Context, dockerCli command.Cli, runningDockerDesktop bool) (bool, error) { + if runningDockerDesktop { + // Check DD feature flag + return desktop.CheckProfilesFeatureIsEnabled(ctx) + } + + // Otherwise, check the profiles feature in Docker CE or in a container + return isProfilesCLIFeatureEnabled(dockerCli), nil +} + +func isProfilesCLIFeatureEnabled(dockerCli command.Cli) bool { + configFile := dockerCli.ConfigFile() + if configFile == nil || configFile.Features == nil { + return false + } + + value, exists := configFile.Features["profiles"] + if !exists { + return false + } + return value == "enabled" +}