Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions cmd/docker-mcp/commands/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package commands
import (
"encoding/json"
"fmt"
"os"
"strings"

"github.com/docker/cli/cli/command"
Expand All @@ -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
}

Expand Down Expand Up @@ -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",
Expand All @@ -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 <profile-id>` instead.\n")
}
if printAsJSON {
buf, err := json.Marshal(command)
if err != nil {
Expand Down
29 changes: 22 additions & 7 deletions cmd/docker-mcp/commands/workingset.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -34,6 +33,7 @@ func workingSetCommand() *cobra.Command {
cmd.AddCommand(workingsetServerCommand())
cmd.AddCommand(configWorkingSetCommand())
cmd.AddCommand(toolsWorkingSetCommand())
cmd.AddCommand(manualInstructionsCommand())
return cmd
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 <profile-id>",
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
}
9 changes: 4 additions & 5 deletions pkg/client/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 1 addition & 5 deletions pkg/client/connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
17 changes: 16 additions & 1 deletion pkg/client/gordon_hack.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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()
}

Expand Down
80 changes: 80 additions & 0 deletions pkg/client/profiles.go
Original file line number Diff line number Diff line change
@@ -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)
}
3 changes: 0 additions & 3 deletions pkg/workingset/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -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), ", "))
}
Expand Down
36 changes: 36 additions & 0 deletions pkg/workingset/manual_instructions.go
Original file line number Diff line number Diff line change
@@ -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
}
52 changes: 52 additions & 0 deletions pkg/workingset/manual_instructions_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
Loading