Skip to content

Commit 22926ee

Browse files
authored
Merge pull request #249 from docker/mcpt-220-add-client-information-to-profile
feat: Add clients to profile show output
2 parents d184055 + d576ffe commit 22926ee

17 files changed

+501
-19
lines changed

cmd/docker-mcp/commands/workingset.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@ func listWorkingSetsCommand() *cobra.Command {
205205

206206
func showWorkingSetCommand() *cobra.Command {
207207
format := string(workingset.OutputFormatHumanReadable)
208+
var showClients bool
208209

209210
cmd := &cobra.Command{
210211
Use: "show <profile-id>",
@@ -219,13 +220,13 @@ func showWorkingSetCommand() *cobra.Command {
219220
if err != nil {
220221
return err
221222
}
222-
return workingset.Show(cmd.Context(), dao, args[0], workingset.OutputFormat(format))
223+
return workingset.Show(cmd.Context(), dao, args[0], workingset.OutputFormat(format), showClients)
223224
},
224225
}
225226

226227
flags := cmd.Flags()
227228
flags.StringVar(&format, "format", string(workingset.OutputFormatHumanReadable), fmt.Sprintf("Supported: %s.", strings.Join(workingset.SupportedFormats(), ", ")))
228-
229+
flags.BoolVar(&showClients, "clients", false, "Include client information in output")
229230
return cmd
230231
}
231232

pkg/client/codex_handler.go

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,31 @@ func GetCodexSetup(ctx context.Context) MCPClientCfg {
5959
if mcpServers, ok := config["mcp_servers"].(map[string]any); ok {
6060
if dockerMCP, exists := mcpServers[DockerMCPCatalog]; exists && dockerMCP != nil {
6161
result.IsMCPCatalogConnected = true
62-
result.Cfg = &MCPJSONLists{STDIOServers: []MCPServerSTDIO{{Name: DockerMCPCatalog}}}
62+
63+
// Extract the server config to get the args and populate WorkingSet
64+
if serverConfigMap, ok := dockerMCP.(map[string]any); ok {
65+
serverConfig := MCPServerSTDIO{
66+
Name: DockerMCPCatalog,
67+
}
68+
69+
// Extract command
70+
if command, ok := serverConfigMap["command"].(string); ok {
71+
serverConfig.Command = command
72+
}
73+
74+
// Extract args
75+
if args, ok := serverConfigMap["args"].([]any); ok {
76+
for _, arg := range args {
77+
if argStr, ok := arg.(string); ok {
78+
serverConfig.Args = append(serverConfig.Args, argStr)
79+
}
80+
}
81+
}
82+
83+
// Use GetWorkingSet to extract the profile from args
84+
result.WorkingSet = serverConfig.GetWorkingSet()
85+
result.Cfg = &MCPJSONLists{STDIOServers: []MCPServerSTDIO{serverConfig}}
86+
}
6387
}
6488
}
6589

pkg/client/codex_handler_test.go

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
package client
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
// Test functions for Codex configuration handling
11+
12+
func TestReadCodexConfig_WithProfile(t *testing.T) {
13+
config := make(map[string]any)
14+
15+
config["mcp_servers"] = map[string]any{
16+
"MCP_DOCKER": map[string]any{
17+
"command": "docker",
18+
"args": []any{"mcp", "gateway", "run", "--profile", "test-profile"},
19+
},
20+
}
21+
22+
mcpServers := config["mcp_servers"].(map[string]any)
23+
dockerMCP := mcpServers[DockerMCPCatalog]
24+
require.NotNil(t, dockerMCP, "dockerMCP should not be nil")
25+
26+
serverConfigMap := dockerMCP.(map[string]any)
27+
serverConfig := MCPServerSTDIO{
28+
Name: DockerMCPCatalog,
29+
}
30+
31+
serverConfig.Command = serverConfigMap["command"].(string)
32+
33+
args := serverConfigMap["args"].([]any)
34+
for _, arg := range args {
35+
serverConfig.Args = append(serverConfig.Args, arg.(string))
36+
}
37+
38+
workingSet := serverConfig.GetWorkingSet()
39+
40+
assert.Equal(t, "test-profile", workingSet, "WorkingSet should be extracted from config args")
41+
assert.Equal(t, "docker", serverConfig.Command, "Command should be 'docker'")
42+
assert.Equal(t, []string{"mcp", "gateway", "run", "--profile", "test-profile"}, serverConfig.Args, "Args should include profile")
43+
}
44+
45+
func TestReadCodexConfig_WithoutProfile(t *testing.T) {
46+
config := make(map[string]any)
47+
config["mcp_servers"] = map[string]any{
48+
"MCP_DOCKER": map[string]any{
49+
"command": "docker",
50+
"args": []any{"mcp", "gateway", "run"},
51+
},
52+
}
53+
54+
mcpServers := config["mcp_servers"].(map[string]any)
55+
dockerMCP := mcpServers[DockerMCPCatalog]
56+
require.NotNil(t, dockerMCP, "dockerMCP should not be nil")
57+
58+
serverConfigMap := dockerMCP.(map[string]any)
59+
serverConfig := MCPServerSTDIO{
60+
Name: DockerMCPCatalog,
61+
}
62+
63+
serverConfig.Command = serverConfigMap["command"].(string)
64+
65+
args := serverConfigMap["args"].([]any)
66+
for _, arg := range args {
67+
serverConfig.Args = append(serverConfig.Args, arg.(string))
68+
}
69+
70+
workingSet := serverConfig.GetWorkingSet()
71+
72+
assert.Empty(t, workingSet, "WorkingSet should be empty when no profile in args")
73+
assert.Equal(t, "docker", serverConfig.Command, "Command should be 'docker'")
74+
assert.Equal(t, []string{"mcp", "gateway", "run"}, serverConfig.Args, "Args should not include profile")
75+
}
76+
77+
func TestConnectCodex_GeneratesCorrectConfig(t *testing.T) {
78+
testCases := []struct {
79+
name string
80+
workingSet string
81+
expectedWS string
82+
expectedArgs []string
83+
}{
84+
{
85+
name: "with profile",
86+
workingSet: "test-profile",
87+
expectedWS: "test-profile",
88+
expectedArgs: []string{"mcp", "gateway", "run", "--profile", "test-profile"},
89+
},
90+
{
91+
name: "without profile",
92+
workingSet: "",
93+
expectedWS: "",
94+
expectedArgs: []string{"mcp", "gateway", "run"},
95+
},
96+
}
97+
98+
for _, tc := range testCases {
99+
t.Run(tc.name, func(t *testing.T) {
100+
command := "docker"
101+
args := []string{"mcp", "gateway", "run"}
102+
if tc.workingSet != "" {
103+
args = append(args, "--profile", tc.workingSet)
104+
}
105+
106+
config := map[string]any{
107+
"mcp_servers": map[string]any{
108+
DockerMCPCatalog: MCPServerConfig{
109+
Command: command,
110+
Args: args,
111+
},
112+
},
113+
}
114+
115+
mcpServers := config["mcp_servers"].(map[string]any)
116+
dockerMCP := mcpServers[DockerMCPCatalog]
117+
require.NotNil(t, dockerMCP, "dockerMCP should not be nil")
118+
119+
serverCfg := dockerMCP.(MCPServerConfig)
120+
121+
serverConfig := MCPServerSTDIO{
122+
Name: DockerMCPCatalog,
123+
Command: serverCfg.Command,
124+
Args: serverCfg.Args,
125+
}
126+
127+
workingSet := serverConfig.GetWorkingSet()
128+
129+
assert.Equal(t, tc.expectedWS, workingSet, "WorkingSet should match expected")
130+
assert.Equal(t, tc.expectedArgs, serverConfig.Args, "Args should match expected")
131+
})
132+
}
133+
}
134+
135+
func TestCodexConfigExtraction(t *testing.T) {
136+
configAfterTomlUnmarshal := map[string]any{
137+
"mcp_servers": map[string]any{
138+
"MCP_DOCKER": map[string]any{
139+
"command": "docker",
140+
"args": []any{"mcp", "gateway", "run", "--profile", "my-profile"},
141+
},
142+
},
143+
}
144+
145+
mcpServers := configAfterTomlUnmarshal["mcp_servers"].(map[string]any)
146+
dockerMCP := mcpServers["MCP_DOCKER"]
147+
require.NotNil(t, dockerMCP, "dockerMCP should not be nil")
148+
149+
serverConfigMap := dockerMCP.(map[string]any)
150+
serverConfig := MCPServerSTDIO{Name: "MCP_DOCKER"}
151+
152+
serverConfig.Command = serverConfigMap["command"].(string)
153+
154+
args := serverConfigMap["args"].([]any)
155+
for _, arg := range args {
156+
serverConfig.Args = append(serverConfig.Args, arg.(string))
157+
}
158+
159+
workingSet := serverConfig.GetWorkingSet()
160+
161+
assert.Equal(t, "docker", serverConfig.Command)
162+
assert.Equal(t, []string{"mcp", "gateway", "run", "--profile", "my-profile"}, serverConfig.Args)
163+
assert.Equal(t, "my-profile", workingSet)
164+
}

pkg/client/config.go

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package client
22

33
import (
4+
"context"
45
_ "embed"
56
"errors"
67
"maps"
@@ -167,7 +168,7 @@ type MCPClientCfgBase struct {
167168
Icon string `json:"icon"`
168169
ConfigName string `json:"configName"`
169170
IsMCPCatalogConnected bool `json:"dockerMCPCatalogConnected"`
170-
WorkingSet string `json:"workingset"`
171+
WorkingSet string `json:"profile"`
171172
Err *CfgError `json:"error"`
172173

173174
Cfg *MCPJSONLists
@@ -184,3 +185,32 @@ func (c *MCPClientCfgBase) setParseResult(lists *MCPJSONLists, err error) {
184185
}
185186
c.Cfg = lists
186187
}
188+
189+
func FindClientsByProfile(ctx context.Context, profileID string) map[string]any {
190+
clients := make(map[string]any)
191+
cfg := ReadConfig()
192+
193+
for vendor, pathCfg := range cfg.System {
194+
processor, err := NewGlobalCfgProcessor(pathCfg)
195+
if err != nil {
196+
continue
197+
}
198+
clientCfg := processor.ParseConfig()
199+
if clientCfg.WorkingSet == profileID {
200+
clients[vendor] = clientCfg
201+
}
202+
}
203+
204+
// TODO: Add support for Gordon with flags
205+
// gordonCfg := GetGordonSetup(ctx)
206+
// if gordonCfg.WorkingSet == profileID {
207+
// clients[VendorGordon] = gordonCfg
208+
// }
209+
210+
codexCfg := GetCodexSetup(ctx)
211+
if codexCfg.WorkingSet == profileID {
212+
clients[VendorCodex] = codexCfg
213+
}
214+
215+
return clients
216+
}

pkg/client/config_test.go

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,119 @@ func getYQProcessor(t *testing.T, cfg any) yqProcessor {
287287
}
288288
}
289289

290+
func TestFindClientsByProfile(t *testing.T) {
291+
tests := []struct {
292+
name string
293+
profileID string
294+
mockConfigs map[string][]byte
295+
expectedVendors []string
296+
}{
297+
{
298+
name: "finds all clients with matching profile",
299+
profileID: "test-profile",
300+
mockConfigs: map[string][]byte{
301+
vendorCursor: readTestData(t, "find-profiles/cursor-with-profile.json"),
302+
vendorClaudeDesktop: readTestData(t, "find-profiles/claude-desktop-with-profile.json"),
303+
vendorZed: readTestData(t, "find-profiles/zed-with-profile.json"),
304+
VendorAmazonQ: readTestData(t, "find-profiles/amazon-q-with-profile.json"),
305+
vendorContinueDev: readTestData(t, "find-profiles/continue-with-profile.yml"),
306+
},
307+
expectedVendors: []string{vendorCursor, vendorClaudeDesktop, vendorZed, VendorAmazonQ, vendorContinueDev},
308+
},
309+
{
310+
name: "finds no clients when profile doesn't match",
311+
profileID: "non-existent-profile",
312+
mockConfigs: map[string][]byte{
313+
vendorCursor: readTestData(t, "find-profiles/cursor-with-profile.json"),
314+
vendorClaudeDesktop: readTestData(t, "find-profiles/claude-desktop-with-profile.json"),
315+
vendorZed: readTestData(t, "find-profiles/zed-without-profile.json"),
316+
VendorAmazonQ: readTestData(t, "find-profiles/amazon-q-without-profile.json"),
317+
vendorContinueDev: readTestData(t, "find-profiles/continue-without-profile.yml"),
318+
},
319+
expectedVendors: []string{},
320+
},
321+
{
322+
name: "finds clients without profile when searching for empty string",
323+
profileID: "",
324+
mockConfigs: map[string][]byte{
325+
vendorCursor: readTestData(t, "find-profiles/cursor-with-profile.json"),
326+
vendorClaudeDesktop: readTestData(t, "find-profiles/claude-desktop-without-profile.json"),
327+
vendorZed: readTestData(t, "find-profiles/zed-without-profile.json"),
328+
VendorAmazonQ: readTestData(t, "find-profiles/amazon-q-without-profile.json"),
329+
vendorContinueDev: readTestData(t, "find-profiles/continue-without-profile.yml"),
330+
},
331+
expectedVendors: []string{vendorClaudeDesktop, vendorZed, VendorAmazonQ, vendorContinueDev},
332+
},
333+
{
334+
name: "finds mix of clients with and without matching profile",
335+
profileID: "test-profile",
336+
mockConfigs: map[string][]byte{
337+
vendorCursor: readTestData(t, "find-profiles/cursor-with-profile.json"),
338+
vendorClaudeDesktop: readTestData(t, "find-profiles/claude-desktop-without-profile.json"),
339+
vendorZed: readTestData(t, "find-profiles/zed-with-profile.json"),
340+
VendorAmazonQ: readTestData(t, "find-profiles/amazon-q-without-profile.json"),
341+
vendorContinueDev: readTestData(t, "find-profiles/continue-with-profile.yml"),
342+
},
343+
expectedVendors: []string{vendorCursor, vendorZed, vendorContinueDev},
344+
},
345+
}
346+
347+
for _, tc := range tests {
348+
t.Run(tc.name, func(t *testing.T) {
349+
config := ReadConfig()
350+
351+
expectedMap := make(map[string]bool)
352+
for _, vendor := range tc.expectedVendors {
353+
expectedMap[vendor] = true
354+
}
355+
356+
foundClients := 0
357+
for vendor, configData := range tc.mockConfigs {
358+
pathCfg, ok := config.System[vendor]
359+
if !ok {
360+
continue
361+
}
362+
363+
processor, err := NewGlobalCfgProcessor(pathCfg)
364+
require.NoError(t, err)
365+
366+
lists, err := processor.p.Parse(configData)
367+
require.NoError(t, err)
368+
369+
clientCfg := MCPClientCfg{
370+
MCPClientCfgBase: MCPClientCfgBase{
371+
DisplayName: pathCfg.DisplayName,
372+
Source: pathCfg.Source,
373+
Icon: pathCfg.Icon,
374+
},
375+
IsInstalled: true,
376+
IsOsSupported: true,
377+
}
378+
clientCfg.setParseResult(lists, nil)
379+
380+
shouldMatch := expectedMap[vendor]
381+
382+
if shouldMatch {
383+
assert.Equal(t, tc.profileID, clientCfg.WorkingSet,
384+
"Expected vendor %s to have profile %s", vendor, tc.profileID)
385+
foundClients++
386+
} else {
387+
assert.NotEqual(t, tc.profileID, clientCfg.WorkingSet,
388+
"Expected vendor %s to NOT have profile %s", vendor, tc.profileID)
389+
}
390+
391+
if clientCfg.WorkingSet != "" || clientCfg.IsMCPCatalogConnected {
392+
assert.True(t, clientCfg.IsMCPCatalogConnected,
393+
"IsMCPCatalogConnected should be true when MCP_DOCKER is configured")
394+
}
395+
}
396+
397+
assert.Equal(t, len(tc.expectedVendors), foundClients,
398+
"Should find exactly %d clients with profile %s", len(tc.expectedVendors), tc.profileID)
399+
})
400+
}
401+
}
402+
290403
func TestIsSupportedMCPClient(t *testing.T) {
291404
config := ReadConfig()
292405

0 commit comments

Comments
 (0)