Skip to content

Commit 797afb6

Browse files
authored
Support profiles with Gordon and add profile manual-instructions. (#272)
1 parent 3c578eb commit 797afb6

File tree

9 files changed

+224
-23
lines changed

9 files changed

+224
-23
lines changed

cmd/docker-mcp/commands/client.go

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package commands
33
import (
44
"encoding/json"
55
"fmt"
6+
"os"
67
"strings"
78

89
"github.com/docker/cli/cli/command"
@@ -22,7 +23,7 @@ func clientCommand(dockerCli command.Cli, cwd string) *cobra.Command {
2223
cmd.AddCommand(listClientCommand(cwd, *cfg))
2324
cmd.AddCommand(connectClientCommand(dockerCli, cwd, *cfg))
2425
cmd.AddCommand(disconnectClientCommand(cwd, *cfg))
25-
cmd.AddCommand(manualClientCommand())
26+
cmd.AddCommand(manualClientCommand(dockerCli))
2627
return cmd
2728
}
2829

@@ -87,7 +88,7 @@ func disconnectClientCommand(cwd string, cfg client.Config) *cobra.Command {
8788
return cmd
8889
}
8990

90-
func manualClientCommand() *cobra.Command {
91+
func manualClientCommand(dockerCli command.Cli) *cobra.Command {
9192
cmd := &cobra.Command{
9293
Use: "manual-instructions",
9394
Short: "Display the manual instructions to connect the MCP client",
@@ -99,6 +100,16 @@ func manualClientCommand() *cobra.Command {
99100
}
100101

101102
command := []string{"docker", "mcp", "gateway", "run"}
103+
if isWorkingSetsFeatureEnabled(dockerCli) {
104+
gordonProfile, err := client.ReadGordonProfile()
105+
if err != nil {
106+
return fmt.Errorf("failed to read gordon profile: %w", err)
107+
}
108+
if gordonProfile != "" {
109+
command = append(command, "--profile", gordonProfile)
110+
}
111+
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")
112+
}
102113
if printAsJSON {
103114
buf, err := json.Marshal(command)
104115
if err != nil {

cmd/docker-mcp/commands/workingset.go

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import (
1111
"github.com/docker/mcp-gateway/pkg/db"
1212
"github.com/docker/mcp-gateway/pkg/oci"
1313
"github.com/docker/mcp-gateway/pkg/registryapi"
14-
"github.com/docker/mcp-gateway/pkg/sliceutil"
1514
"github.com/docker/mcp-gateway/pkg/workingset"
1615
)
1716

@@ -34,6 +33,7 @@ func workingSetCommand() *cobra.Command {
3433
cmd.AddCommand(workingsetServerCommand())
3534
cmd.AddCommand(configWorkingSetCommand())
3635
cmd.AddCommand(toolsWorkingSetCommand())
36+
cmd.AddCommand(manualInstructionsCommand())
3737
return cmd
3838
}
3939

@@ -170,7 +170,7 @@ Profiles are decoupled from catalogs. Servers can be:
170170
flags.StringVar(&opts.Name, "name", "", "Name of the profile (required)")
171171
flags.StringVar(&opts.ID, "id", "", "ID of the profile (defaults to a slugified version of the name)")
172172
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.")
173-
flags.StringArrayVar(&opts.Connect, "connect", []string{}, fmt.Sprintf("Clients to connect to: mcp-client (can be specified multiple times). Supported clients: %s", supportedClientsList(*cfg)))
173+
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)))
174174
_ = cmd.MarkFlagRequired("name")
175175

176176
return cmd
@@ -437,9 +437,24 @@ func removeServerCommand() *cobra.Command {
437437
return cmd
438438
}
439439

440-
func supportedClientsList(cfg client.Config) string {
441-
// Gordon doesn't support profiles yet
442-
return strings.Join(sliceutil.Filter(client.GetSupportedMCPClients(cfg), func(c string) bool {
443-
return c != client.VendorGordon
444-
}), " ")
440+
func manualInstructionsCommand() *cobra.Command {
441+
var format string
442+
443+
cmd := &cobra.Command{
444+
Use: "manual-instructions <profile-id>",
445+
Short: "Display the manual instructions to connect an MCP client to a profile",
446+
Args: cobra.ExactArgs(1),
447+
Hidden: true,
448+
RunE: func(cmd *cobra.Command, args []string) error {
449+
supported := slices.Contains(workingset.SupportedFormats(), format)
450+
if !supported {
451+
return fmt.Errorf("unsupported format: %s", format)
452+
}
453+
return workingset.WriteManualInstructions(args[0], workingset.OutputFormat(format), cmd.OutOrStdout())
454+
},
455+
}
456+
457+
flags := cmd.Flags()
458+
flags.StringVar(&format, "format", string(workingset.OutputFormatHumanReadable), fmt.Sprintf("Supported: %s.", strings.Join(workingset.SupportedFormats(), ", ")))
459+
return cmd
445460
}

pkg/client/config.go

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -201,11 +201,10 @@ func FindClientsByProfile(ctx context.Context, profileID string) map[string]any
201201
}
202202
}
203203

204-
// TODO: Add support for Gordon with flags
205-
// gordonCfg := GetGordonSetup(ctx)
206-
// if gordonCfg.WorkingSet == profileID {
207-
// clients[VendorGordon] = gordonCfg
208-
// }
204+
gordonCfg := GetGordonSetup(ctx)
205+
if gordonCfg.WorkingSet == profileID {
206+
clients[VendorGordon] = gordonCfg
207+
}
209208

210209
codexCfg := GetCodexSetup(ctx)
211210
if codexCfg.WorkingSet == profileID {

pkg/client/connect.go

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,7 @@ func Connect(ctx context.Context, dao db.DAO, cwd string, config Config, vendor
2626
return err
2727
}
2828
} else if vendor == VendorGordon && global {
29-
if workingSet != "" {
30-
// Gordon doesn't support profiles yet
31-
return fmt.Errorf("gordon cannot be connected to a profile")
32-
}
33-
if err := ConnectGordon(ctx); err != nil {
29+
if err := ConnectGordon(ctx, workingSet); err != nil {
3430
return err
3531
}
3632
} else {

pkg/client/gordon_hack.go

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ func GetGordonSetup(ctx context.Context) MCPClientCfg {
1818
IsInstalled: true,
1919
IsOsSupported: true,
2020
}
21+
workingSet, err := ReadGordonProfile()
22+
if err != nil {
23+
result.Err = classifyError(err)
24+
return result
25+
}
26+
result.WorkingSet = workingSet
2127
out, err := exec.CommandContext(ctx, "docker", "ai", "config", "get").Output()
2228
if err != nil {
2329
result.Err = classifyError(err)
@@ -37,13 +43,22 @@ func GetGordonSetup(ctx context.Context) MCPClientCfg {
3743
if feature.Name == "MCP Catalog" && feature.Enabled {
3844
result.IsMCPCatalogConnected = true
3945
result.Cfg = &MCPJSONLists{STDIOServers: []MCPServerSTDIO{{Name: DockerMCPCatalog}}}
46+
if workingSet != "" {
47+
// Hacky way to make it say there is a profile attached
48+
result.Cfg.STDIOServers[0].Args = append(result.Cfg.STDIOServers[0].Args, "--profile", workingSet)
49+
}
4050
break
4151
}
4252
}
4353
return result
4454
}
4555

46-
func ConnectGordon(ctx context.Context) error {
56+
func ConnectGordon(ctx context.Context, workingSet string) error {
57+
if workingSet != "" {
58+
if err := writeGordonProfile(workingSet); err != nil {
59+
return err
60+
}
61+
}
4762
return exec.CommandContext(ctx, "docker", "ai", "config", "set-feature", "MCP Catalog", "true").Run()
4863
}
4964

pkg/client/profiles.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package client
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
9+
"github.com/docker/mcp-gateway/pkg/user"
10+
)
11+
12+
type FileConfig struct {
13+
Profile string `json:"profile"`
14+
}
15+
16+
// Currently only used for Gordon.
17+
type ProfilesFile = map[string]FileConfig
18+
19+
func writeGordonProfile(workingSet string) error {
20+
profilePath, err := getProfilePath()
21+
if err != nil {
22+
return err
23+
}
24+
25+
profiles, err := readProfile(profilePath)
26+
if err != nil {
27+
return err
28+
}
29+
profiles[VendorGordon] = FileConfig{Profile: workingSet}
30+
return writeProfile(profilePath, profiles)
31+
}
32+
33+
func ReadGordonProfile() (string, error) {
34+
profilePath, err := getProfilePath()
35+
if err != nil {
36+
return "", err
37+
}
38+
profiles, err := readProfile(profilePath)
39+
if err != nil {
40+
return "", err
41+
}
42+
if _, ok := profiles[VendorGordon]; ok {
43+
return profiles[VendorGordon].Profile, nil
44+
}
45+
return "", nil
46+
}
47+
48+
func getProfilePath() (string, error) {
49+
homeDir, err := user.HomeDir()
50+
if err != nil {
51+
return "", fmt.Errorf("failed to get home directory: %w", err)
52+
}
53+
return filepath.Join(homeDir, ".docker", "mcp", "profiles.json"), nil
54+
}
55+
56+
func readProfile(path string) (ProfilesFile, error) {
57+
data, err := os.ReadFile(path)
58+
if os.IsNotExist(err) {
59+
return make(ProfilesFile), nil
60+
}
61+
if err != nil {
62+
return nil, fmt.Errorf("failed to read profile file: %w", err)
63+
}
64+
var profiles ProfilesFile
65+
if err := json.Unmarshal(data, &profiles); err != nil {
66+
return nil, fmt.Errorf("failed to unmarshal profile file: %w", err)
67+
}
68+
return profiles, nil
69+
}
70+
71+
func writeProfile(path string, profiles ProfilesFile) error {
72+
data, err := json.MarshalIndent(profiles, "", " ")
73+
if err != nil {
74+
return fmt.Errorf("failed to marshal profiles: %w", err)
75+
}
76+
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
77+
return fmt.Errorf("failed to create profile directory: %w", err)
78+
}
79+
return os.WriteFile(path, data, 0o644)
80+
}

pkg/workingset/create.go

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,6 @@ func Create(ctx context.Context, dao db.DAO, registryClient registryapi.Client,
8787

8888
func verifySupportedClients(cfg client.Config, clients []string) error {
8989
for _, c := range clients {
90-
if c == client.VendorGordon {
91-
return fmt.Errorf("gordon cannot be connected to a profile")
92-
}
9390
if !client.IsSupportedMCPClient(cfg, c, true) {
9491
return fmt.Errorf("client %s is not supported. Supported clients: %s", c, strings.Join(client.GetSupportedMCPClients(cfg), ", "))
9592
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package workingset
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"io"
7+
"strings"
8+
9+
"gopkg.in/yaml.v3"
10+
)
11+
12+
func WriteManualInstructions(profileID string, format OutputFormat, output io.Writer) error {
13+
if profileID == "" {
14+
return fmt.Errorf("profile ID is required")
15+
}
16+
17+
command := []string{"docker", "mcp", "gateway", "run", "--profile", profileID}
18+
19+
switch format {
20+
case OutputFormatHumanReadable:
21+
fmt.Fprint(output, strings.Join(command, " "))
22+
case OutputFormatJSON:
23+
buf, err := json.Marshal(command)
24+
if err != nil {
25+
return err
26+
}
27+
_, _ = output.Write(buf)
28+
case OutputFormatYAML:
29+
buf, err := yaml.Marshal(command)
30+
if err != nil {
31+
return err
32+
}
33+
_, _ = output.Write(buf)
34+
}
35+
return nil
36+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package workingset
2+
3+
import (
4+
"bytes"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
func TestWriteManualInstructions_HumanReadable(t *testing.T) {
12+
var buf bytes.Buffer
13+
14+
err := WriteManualInstructions("my-profile", OutputFormatHumanReadable, &buf)
15+
16+
require.NoError(t, err)
17+
assert.Equal(t, "docker mcp gateway run --profile my-profile", buf.String())
18+
}
19+
20+
func TestWriteManualInstructions_JSON(t *testing.T) {
21+
var buf bytes.Buffer
22+
23+
err := WriteManualInstructions("my-profile", OutputFormatJSON, &buf)
24+
25+
require.NoError(t, err)
26+
assert.JSONEq(t, `["docker","mcp","gateway","run","--profile","my-profile"]`, buf.String())
27+
}
28+
29+
func TestWriteManualInstructions_YAML(t *testing.T) {
30+
var buf bytes.Buffer
31+
32+
err := WriteManualInstructions("my-profile", OutputFormatYAML, &buf)
33+
34+
require.NoError(t, err)
35+
expected := `- docker
36+
- mcp
37+
- gateway
38+
- run
39+
- --profile
40+
- my-profile
41+
`
42+
assert.Equal(t, expected, buf.String())
43+
}
44+
45+
func TestWriteManualInstructions_EmptyProfileID(t *testing.T) {
46+
var buf bytes.Buffer
47+
48+
err := WriteManualInstructions("", OutputFormatHumanReadable, &buf)
49+
50+
require.Error(t, err)
51+
assert.Equal(t, "profile ID is required", err.Error())
52+
}

0 commit comments

Comments
 (0)