diff --git a/cmd/docker-mcp/commands/client.go b/cmd/docker-mcp/commands/client.go index 04bea5a8..5e1dff5d 100644 --- a/cmd/docker-mcp/commands/client.go +++ b/cmd/docker-mcp/commands/client.go @@ -3,6 +3,7 @@ package commands import ( "encoding/json" "fmt" + "os" "strings" "github.com/docker/cli/cli/command" @@ -22,7 +23,7 @@ func clientCommand(dockerCli command.Cli, cwd string) *cobra.Command { cmd.AddCommand(listClientCommand(cwd, *cfg)) cmd.AddCommand(connectClientCommand(dockerCli, cwd, *cfg)) cmd.AddCommand(disconnectClientCommand(cwd, *cfg)) - cmd.AddCommand(manualClientCommand()) + cmd.AddCommand(manualClientCommand(dockerCli)) return cmd } @@ -87,7 +88,7 @@ func disconnectClientCommand(cwd string, cfg client.Config) *cobra.Command { return cmd } -func manualClientCommand() *cobra.Command { +func manualClientCommand(dockerCli command.Cli) *cobra.Command { cmd := &cobra.Command{ Use: "manual-instructions", Short: "Display the manual instructions to connect the MCP client", @@ -99,6 +100,16 @@ func manualClientCommand() *cobra.Command { } command := []string{"docker", "mcp", "gateway", "run"} + if isWorkingSetsFeatureEnabled(dockerCli) { + gordonProfile, err := client.ReadGordonProfile() + if err != nil { + return fmt.Errorf("failed to read gordon profile: %w", err) + } + if gordonProfile != "" { + command = append(command, "--profile", gordonProfile) + } + fmt.Fprintf(os.Stderr, "Deprecation notice: This command is deprecated and only used for Gordon in Docker Desktop. Please use `docker mcp profile manual-instructions ` instead.\n") + } if printAsJSON { buf, err := json.Marshal(command) if err != nil { diff --git a/cmd/docker-mcp/commands/workingset.go b/cmd/docker-mcp/commands/workingset.go index f30576cf..8f18a8b2 100644 --- a/cmd/docker-mcp/commands/workingset.go +++ b/cmd/docker-mcp/commands/workingset.go @@ -11,7 +11,6 @@ import ( "github.com/docker/mcp-gateway/pkg/db" "github.com/docker/mcp-gateway/pkg/oci" "github.com/docker/mcp-gateway/pkg/registryapi" - "github.com/docker/mcp-gateway/pkg/sliceutil" "github.com/docker/mcp-gateway/pkg/workingset" ) @@ -34,6 +33,7 @@ func workingSetCommand() *cobra.Command { cmd.AddCommand(workingsetServerCommand()) cmd.AddCommand(configWorkingSetCommand()) cmd.AddCommand(toolsWorkingSetCommand()) + cmd.AddCommand(manualInstructionsCommand()) return cmd } @@ -170,7 +170,7 @@ Profiles are decoupled from catalogs. Servers can be: flags.StringVar(&opts.Name, "name", "", "Name of the profile (required)") flags.StringVar(&opts.ID, "id", "", "ID of the profile (defaults to a slugified version of the name)") flags.StringArrayVar(&opts.Servers, "server", []string{}, "Server to include specified with a URI: https:// (MCP Registry reference) or docker:// (Docker Image reference) or catalog:// (Catalog reference). Can be specified multiple times.") - flags.StringArrayVar(&opts.Connect, "connect", []string{}, fmt.Sprintf("Clients to connect to: mcp-client (can be specified multiple times). Supported clients: %s", supportedClientsList(*cfg))) + flags.StringArrayVar(&opts.Connect, "connect", []string{}, fmt.Sprintf("Clients to connect to: mcp-client (can be specified multiple times). Supported clients: %s", client.GetSupportedMCPClients(*cfg))) _ = cmd.MarkFlagRequired("name") return cmd @@ -437,9 +437,24 @@ func removeServerCommand() *cobra.Command { return cmd } -func supportedClientsList(cfg client.Config) string { - // Gordon doesn't support profiles yet - return strings.Join(sliceutil.Filter(client.GetSupportedMCPClients(cfg), func(c string) bool { - return c != client.VendorGordon - }), " ") +func manualInstructionsCommand() *cobra.Command { + var format string + + cmd := &cobra.Command{ + Use: "manual-instructions ", + Short: "Display the manual instructions to connect an MCP client to a profile", + Args: cobra.ExactArgs(1), + Hidden: true, + RunE: func(cmd *cobra.Command, args []string) error { + supported := slices.Contains(workingset.SupportedFormats(), format) + if !supported { + return fmt.Errorf("unsupported format: %s", format) + } + return workingset.WriteManualInstructions(args[0], workingset.OutputFormat(format), cmd.OutOrStdout()) + }, + } + + flags := cmd.Flags() + flags.StringVar(&format, "format", string(workingset.OutputFormatHumanReadable), fmt.Sprintf("Supported: %s.", strings.Join(workingset.SupportedFormats(), ", "))) + return cmd } diff --git a/pkg/client/config.go b/pkg/client/config.go index 3cf7833c..dca7e857 100644 --- a/pkg/client/config.go +++ b/pkg/client/config.go @@ -201,11 +201,10 @@ func FindClientsByProfile(ctx context.Context, profileID string) map[string]any } } - // TODO: Add support for Gordon with flags - // gordonCfg := GetGordonSetup(ctx) - // if gordonCfg.WorkingSet == profileID { - // clients[VendorGordon] = gordonCfg - // } + gordonCfg := GetGordonSetup(ctx) + if gordonCfg.WorkingSet == profileID { + clients[VendorGordon] = gordonCfg + } codexCfg := GetCodexSetup(ctx) if codexCfg.WorkingSet == profileID { diff --git a/pkg/client/connect.go b/pkg/client/connect.go index 3e0fb52a..ffe5c6cc 100644 --- a/pkg/client/connect.go +++ b/pkg/client/connect.go @@ -26,11 +26,7 @@ func Connect(ctx context.Context, dao db.DAO, cwd string, config Config, vendor return err } } else if vendor == VendorGordon && global { - if workingSet != "" { - // Gordon doesn't support profiles yet - return fmt.Errorf("gordon cannot be connected to a profile") - } - if err := ConnectGordon(ctx); err != nil { + if err := ConnectGordon(ctx, workingSet); err != nil { return err } } else { diff --git a/pkg/client/gordon_hack.go b/pkg/client/gordon_hack.go index 61519a1b..53794d63 100644 --- a/pkg/client/gordon_hack.go +++ b/pkg/client/gordon_hack.go @@ -18,6 +18,12 @@ func GetGordonSetup(ctx context.Context) MCPClientCfg { IsInstalled: true, IsOsSupported: true, } + workingSet, err := ReadGordonProfile() + if err != nil { + result.Err = classifyError(err) + return result + } + result.WorkingSet = workingSet out, err := exec.CommandContext(ctx, "docker", "ai", "config", "get").Output() if err != nil { result.Err = classifyError(err) @@ -37,13 +43,22 @@ func GetGordonSetup(ctx context.Context) MCPClientCfg { if feature.Name == "MCP Catalog" && feature.Enabled { result.IsMCPCatalogConnected = true result.Cfg = &MCPJSONLists{STDIOServers: []MCPServerSTDIO{{Name: DockerMCPCatalog}}} + if workingSet != "" { + // Hacky way to make it say there is a profile attached + result.Cfg.STDIOServers[0].Args = append(result.Cfg.STDIOServers[0].Args, "--profile", workingSet) + } break } } return result } -func ConnectGordon(ctx context.Context) error { +func ConnectGordon(ctx context.Context, workingSet string) error { + if workingSet != "" { + if err := writeGordonProfile(workingSet); err != nil { + return err + } + } return exec.CommandContext(ctx, "docker", "ai", "config", "set-feature", "MCP Catalog", "true").Run() } diff --git a/pkg/client/profiles.go b/pkg/client/profiles.go new file mode 100644 index 00000000..bdf127b3 --- /dev/null +++ b/pkg/client/profiles.go @@ -0,0 +1,80 @@ +package client + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/docker/mcp-gateway/pkg/user" +) + +type FileConfig struct { + Profile string `json:"profile"` +} + +// Currently only used for Gordon. +type ProfilesFile = map[string]FileConfig + +func writeGordonProfile(workingSet string) error { + profilePath, err := getProfilePath() + if err != nil { + return err + } + + profiles, err := readProfile(profilePath) + if err != nil { + return err + } + profiles[VendorGordon] = FileConfig{Profile: workingSet} + return writeProfile(profilePath, profiles) +} + +func ReadGordonProfile() (string, error) { + profilePath, err := getProfilePath() + if err != nil { + return "", err + } + profiles, err := readProfile(profilePath) + if err != nil { + return "", err + } + if _, ok := profiles[VendorGordon]; ok { + return profiles[VendorGordon].Profile, nil + } + return "", nil +} + +func getProfilePath() (string, error) { + homeDir, err := user.HomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + return filepath.Join(homeDir, ".docker", "mcp", "profiles.json"), nil +} + +func readProfile(path string) (ProfilesFile, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return make(ProfilesFile), nil + } + if err != nil { + return nil, fmt.Errorf("failed to read profile file: %w", err) + } + var profiles ProfilesFile + if err := json.Unmarshal(data, &profiles); err != nil { + return nil, fmt.Errorf("failed to unmarshal profile file: %w", err) + } + return profiles, nil +} + +func writeProfile(path string, profiles ProfilesFile) error { + data, err := json.MarshalIndent(profiles, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal profiles: %w", err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("failed to create profile directory: %w", err) + } + return os.WriteFile(path, data, 0o644) +} diff --git a/pkg/workingset/create.go b/pkg/workingset/create.go index 5130a6aa..f1a61d03 100644 --- a/pkg/workingset/create.go +++ b/pkg/workingset/create.go @@ -87,9 +87,6 @@ func Create(ctx context.Context, dao db.DAO, registryClient registryapi.Client, func verifySupportedClients(cfg client.Config, clients []string) error { for _, c := range clients { - if c == client.VendorGordon { - return fmt.Errorf("gordon cannot be connected to a profile") - } if !client.IsSupportedMCPClient(cfg, c, true) { return fmt.Errorf("client %s is not supported. Supported clients: %s", c, strings.Join(client.GetSupportedMCPClients(cfg), ", ")) } diff --git a/pkg/workingset/manual_instructions.go b/pkg/workingset/manual_instructions.go new file mode 100644 index 00000000..7ff1eb30 --- /dev/null +++ b/pkg/workingset/manual_instructions.go @@ -0,0 +1,36 @@ +package workingset + +import ( + "encoding/json" + "fmt" + "io" + "strings" + + "gopkg.in/yaml.v3" +) + +func WriteManualInstructions(profileID string, format OutputFormat, output io.Writer) error { + if profileID == "" { + return fmt.Errorf("profile ID is required") + } + + command := []string{"docker", "mcp", "gateway", "run", "--profile", profileID} + + switch format { + case OutputFormatHumanReadable: + fmt.Fprint(output, strings.Join(command, " ")) + case OutputFormatJSON: + buf, err := json.Marshal(command) + if err != nil { + return err + } + _, _ = output.Write(buf) + case OutputFormatYAML: + buf, err := yaml.Marshal(command) + if err != nil { + return err + } + _, _ = output.Write(buf) + } + return nil +} diff --git a/pkg/workingset/manual_instructions_test.go b/pkg/workingset/manual_instructions_test.go new file mode 100644 index 00000000..76338318 --- /dev/null +++ b/pkg/workingset/manual_instructions_test.go @@ -0,0 +1,52 @@ +package workingset + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWriteManualInstructions_HumanReadable(t *testing.T) { + var buf bytes.Buffer + + err := WriteManualInstructions("my-profile", OutputFormatHumanReadable, &buf) + + require.NoError(t, err) + assert.Equal(t, "docker mcp gateway run --profile my-profile", buf.String()) +} + +func TestWriteManualInstructions_JSON(t *testing.T) { + var buf bytes.Buffer + + err := WriteManualInstructions("my-profile", OutputFormatJSON, &buf) + + require.NoError(t, err) + assert.JSONEq(t, `["docker","mcp","gateway","run","--profile","my-profile"]`, buf.String()) +} + +func TestWriteManualInstructions_YAML(t *testing.T) { + var buf bytes.Buffer + + err := WriteManualInstructions("my-profile", OutputFormatYAML, &buf) + + require.NoError(t, err) + expected := `- docker +- mcp +- gateway +- run +- --profile +- my-profile +` + assert.Equal(t, expected, buf.String()) +} + +func TestWriteManualInstructions_EmptyProfileID(t *testing.T) { + var buf bytes.Buffer + + err := WriteManualInstructions("", OutputFormatHumanReadable, &buf) + + require.Error(t, err) + assert.Equal(t, "profile ID is required", err.Error()) +}