Skip to content

Commit c2c026b

Browse files
authored
Merge pull request #223 from docker/add-remove-server-to-from-profile
feat: Add ability to add/remove servers
2 parents 8897622 + 8c8bb57 commit c2c026b

File tree

5 files changed

+407
-1
lines changed

5 files changed

+407
-1
lines changed

cmd/docker-mcp/commands/workingset.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ func workingSetCommand() *cobra.Command {
2828
cmd.AddCommand(pullWorkingSetCommand())
2929
cmd.AddCommand(createWorkingSetCommand())
3030
cmd.AddCommand(removeWorkingSetCommand())
31+
cmd.AddCommand(workingsetServerCommand())
3132
cmd.AddCommand(configWorkingSetCommand())
3233
return cmd
3334
}
@@ -294,3 +295,76 @@ Use --workingset to show servers only from a specific working set.`,
294295

295296
return cmd
296297
}
298+
299+
func workingsetServerCommand() *cobra.Command {
300+
cmd := &cobra.Command{
301+
Use: "server",
302+
Short: "Manage servers in working sets",
303+
}
304+
305+
cmd.AddCommand(addServerCommand())
306+
cmd.AddCommand(removeServerCommand())
307+
308+
return cmd
309+
}
310+
311+
func addServerCommand() *cobra.Command {
312+
var servers []string
313+
314+
cmd := &cobra.Command{
315+
Use: "add <working-set-id> --server <ref1> --server <ref2> ...",
316+
Short: "Add MCP servers to a working set",
317+
Long: "Add MCP servers to a working set.",
318+
Example: ` # Add servers with OCI references
319+
docker mcp workingset server add my-working-set --server docker://mcp/github:latest --server docker://mcp/slack:latest
320+
321+
# Add servers with MCP Registry references
322+
docker mcp workingset server add my-working-set --server http://registry.modelcontextprotocol.io/v0/servers/71de5a2a-6cfb-4250-a196-f93080ecc860
323+
324+
# Mix MCP Registry references and OCI references
325+
docker mcp workingset server add my-working-set --server http://registry.modelcontextprotocol.io/v0/servers/71de5a2a-6cfb-4250-a196-f93080ecc860 --server docker://mcp/github:latest`,
326+
Args: cobra.ExactArgs(1),
327+
RunE: func(cmd *cobra.Command, args []string) error {
328+
dao, err := db.New()
329+
if err != nil {
330+
return err
331+
}
332+
registryClient := registryapi.NewClient()
333+
ociService := oci.NewService()
334+
return workingset.AddServers(cmd.Context(), dao, registryClient, ociService, args[0], servers)
335+
},
336+
}
337+
338+
flags := cmd.Flags()
339+
flags.StringArrayVar(&servers, "server", []string{}, "Server to include: MCP Registry reference or OCI reference with docker:// prefix (can be specified multiple times)")
340+
341+
return cmd
342+
}
343+
344+
func removeServerCommand() *cobra.Command {
345+
var names []string
346+
347+
cmd := &cobra.Command{
348+
Use: "remove <working-set-id> --name <name1> --name <name2> ...",
349+
Short: "Remove MCP servers from a working set",
350+
Long: "Remove MCP servers from a working set by server name.",
351+
Example: ` # Remove servers by name
352+
docker mcp workingset server remove my-working-set --name github --name slack
353+
354+
# Remove a single server
355+
docker mcp workingset server remove my-working-set --name github`,
356+
Args: cobra.ExactArgs(1),
357+
RunE: func(cmd *cobra.Command, args []string) error {
358+
dao, err := db.New()
359+
if err != nil {
360+
return err
361+
}
362+
return workingset.RemoveServers(cmd.Context(), dao, args[0], names)
363+
},
364+
}
365+
366+
flags := cmd.Flags()
367+
flags.StringArrayVar(&names, "name", []string{}, "Server name to remove (can be specified multiple times)")
368+
369+
return cmd
370+
}

pkg/workingset/server.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package workingset
2+
3+
import (
4+
"context"
5+
"database/sql"
6+
"errors"
7+
"fmt"
8+
9+
"github.com/docker/mcp-gateway/pkg/db"
10+
"github.com/docker/mcp-gateway/pkg/oci"
11+
"github.com/docker/mcp-gateway/pkg/registryapi"
12+
)
13+
14+
func AddServers(ctx context.Context, dao db.DAO, registryClient registryapi.Client, ociService oci.Service, id string, servers []string) error {
15+
if len(servers) == 0 {
16+
return fmt.Errorf("at least one server must be specified")
17+
}
18+
19+
dbWorkingSet, err := dao.GetWorkingSet(ctx, id)
20+
if err != nil {
21+
if errors.Is(err, sql.ErrNoRows) {
22+
return fmt.Errorf("working set %s not found", id)
23+
}
24+
return fmt.Errorf("failed to get working set: %w", err)
25+
}
26+
27+
workingSet := NewFromDb(dbWorkingSet)
28+
29+
newServers := make([]Server, len(servers))
30+
for i, server := range servers {
31+
s, err := resolveServerFromString(ctx, registryClient, ociService, server)
32+
if err != nil {
33+
return fmt.Errorf("invalid server value: %w", err)
34+
}
35+
newServers[i] = s
36+
}
37+
38+
workingSet.Servers = append(workingSet.Servers, newServers...)
39+
40+
if err := workingSet.Validate(); err != nil {
41+
return fmt.Errorf("invalid working set: %w", err)
42+
}
43+
44+
err = dao.UpdateWorkingSet(ctx, workingSet.ToDb())
45+
if err != nil {
46+
return fmt.Errorf("failed to update working set: %w", err)
47+
}
48+
49+
fmt.Printf("Added %d server(s) to working set %s\n", len(newServers), id)
50+
51+
return nil
52+
}
53+
54+
func RemoveServers(ctx context.Context, dao db.DAO, id string, serverNames []string) error {
55+
if len(serverNames) == 0 {
56+
return fmt.Errorf("at least one server must be specified")
57+
}
58+
59+
dbWorkingSet, err := dao.GetWorkingSet(ctx, id)
60+
if err != nil {
61+
if errors.Is(err, sql.ErrNoRows) {
62+
return fmt.Errorf("working set %s not found", id)
63+
}
64+
return fmt.Errorf("failed to get working set: %w", err)
65+
}
66+
67+
workingSet := NewFromDb(dbWorkingSet)
68+
69+
namesToRemove := make(map[string]bool)
70+
for _, name := range serverNames {
71+
namesToRemove[name] = true
72+
}
73+
74+
originalCount := len(workingSet.Servers)
75+
filtered := make([]Server, 0, len(workingSet.Servers))
76+
for _, server := range workingSet.Servers {
77+
// TODO: Remove when Snapshot is required
78+
if server.Snapshot == nil || !namesToRemove[server.Snapshot.Server.Name] {
79+
filtered = append(filtered, server)
80+
}
81+
}
82+
83+
removedCount := originalCount - len(filtered)
84+
if removedCount == 0 {
85+
return fmt.Errorf("no matching servers found to remove")
86+
}
87+
88+
workingSet.Servers = filtered
89+
90+
if err := workingSet.Validate(); err != nil {
91+
return fmt.Errorf("invalid working set: %w", err)
92+
}
93+
94+
err = dao.UpdateWorkingSet(ctx, workingSet.ToDb())
95+
if err != nil {
96+
return fmt.Errorf("failed to update working set: %w", err)
97+
}
98+
99+
fmt.Printf("Removed %d server(s) from working set %s\n", removedCount, id)
100+
101+
return nil
102+
}

pkg/workingset/server_test.go

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
package workingset
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
"github.com/stretchr/testify/require"
8+
9+
"github.com/docker/mcp-gateway/pkg/db"
10+
)
11+
12+
var oneServerError = "at least one server must be specified"
13+
14+
func TestAddOneServerToWorkingSet(t *testing.T) {
15+
dao := setupTestDB(t)
16+
ctx := t.Context()
17+
18+
err := dao.CreateWorkingSet(ctx, db.WorkingSet{
19+
ID: "test-set",
20+
Name: "Test Working Set",
21+
Servers: db.ServerList{},
22+
Secrets: db.SecretMap{},
23+
})
24+
require.NoError(t, err)
25+
26+
servers := []string{
27+
"docker://myimage:latest",
28+
}
29+
30+
err = AddServers(ctx, dao, getMockRegistryClient(), getMockOciService(), "test-set", servers)
31+
require.NoError(t, err)
32+
33+
dbSet, err := dao.GetWorkingSet(ctx, "test-set")
34+
require.NoError(t, err)
35+
require.NotNil(t, dbSet)
36+
assert.Equal(t, "My Image", dbSet.Servers[0].Snapshot.Server.Name)
37+
}
38+
39+
func TestAddMultipleServersToWorkingSet(t *testing.T) {
40+
dao := setupTestDB(t)
41+
ctx := t.Context()
42+
43+
err := dao.CreateWorkingSet(ctx, db.WorkingSet{
44+
ID: "test-set",
45+
Name: "Test Working Set",
46+
Servers: db.ServerList{},
47+
Secrets: db.SecretMap{},
48+
})
49+
require.NoError(t, err)
50+
51+
servers := []string{
52+
"docker://myimage:latest",
53+
"docker://anotherimage:v1.0",
54+
}
55+
56+
err = AddServers(ctx, dao, getMockRegistryClient(), getMockOciService(), "test-set", servers)
57+
require.NoError(t, err)
58+
59+
dbSet, err := dao.GetWorkingSet(ctx, "test-set")
60+
require.NoError(t, err)
61+
require.NotNil(t, dbSet)
62+
assert.Equal(t, "My Image", dbSet.Servers[0].Snapshot.Server.Name)
63+
assert.Equal(t, "Another Image", dbSet.Servers[1].Snapshot.Server.Name)
64+
}
65+
66+
func TestAddNoServersToWorkingSet(t *testing.T) {
67+
dao := setupTestDB(t)
68+
ctx := t.Context()
69+
70+
err := dao.CreateWorkingSet(ctx, db.WorkingSet{
71+
ID: "test-set",
72+
Name: "Test Working Set",
73+
Servers: db.ServerList{},
74+
Secrets: db.SecretMap{},
75+
})
76+
require.NoError(t, err)
77+
78+
servers := []string{}
79+
80+
err = AddServers(ctx, dao, getMockRegistryClient(), getMockOciService(), "test-set", servers)
81+
require.Error(t, err)
82+
assert.Contains(t, err.Error(), oneServerError)
83+
}
84+
85+
func TestRemoveOneServerFromWorkingSet(t *testing.T) {
86+
dao := setupTestDB(t)
87+
ctx := t.Context()
88+
89+
serverURI := "docker://myimage:latest"
90+
setID := "test-set"
91+
92+
err := Create(ctx, dao, getMockRegistryClient(), getMockOciService(), "test-set", "test-set", []string{
93+
serverURI,
94+
})
95+
require.NoError(t, err)
96+
97+
dbSet, err := dao.GetWorkingSet(ctx, setID)
98+
require.NoError(t, err)
99+
assert.Len(t, dbSet.Servers, 1)
100+
101+
err = RemoveServers(ctx, dao, setID, []string{
102+
"My Image",
103+
})
104+
require.NoError(t, err)
105+
106+
dbSet, err = dao.GetWorkingSet(ctx, setID)
107+
require.NoError(t, err)
108+
109+
assert.Empty(t, dbSet.Servers)
110+
}
111+
112+
func TestRemoveMultipleServersFromWorkingSet(t *testing.T) {
113+
dao := setupTestDB(t)
114+
ctx := t.Context()
115+
116+
workingSetID := "test-set"
117+
118+
servers := []string{
119+
"docker://myimage:latest",
120+
"docker://anotherimage:v1.0",
121+
}
122+
123+
err := Create(ctx, dao, getMockRegistryClient(), getMockOciService(), workingSetID, "My Test Set", servers)
124+
require.NoError(t, err)
125+
126+
dbSet, err := dao.GetWorkingSet(ctx, workingSetID)
127+
require.NoError(t, err)
128+
assert.Len(t, dbSet.Servers, 2)
129+
130+
err = RemoveServers(ctx, dao, workingSetID, []string{"My Image", "Another Image"})
131+
require.NoError(t, err)
132+
133+
dbSet, err = dao.GetWorkingSet(ctx, workingSetID)
134+
require.NoError(t, err)
135+
assert.Empty(t, dbSet.Servers)
136+
}
137+
138+
func TestRemoveOneOfManyServerFromWorkingSet(t *testing.T) {
139+
dao := setupTestDB(t)
140+
ctx := t.Context()
141+
142+
workingSetID := "test-set"
143+
144+
servers := []string{
145+
"docker://myimage:latest",
146+
"docker://anotherimage:v1.0",
147+
}
148+
149+
err := Create(ctx, dao, getMockRegistryClient(), getMockOciService(), workingSetID, "My Test Set", servers)
150+
require.NoError(t, err)
151+
152+
dbSet, err := dao.GetWorkingSet(ctx, workingSetID)
153+
require.NoError(t, err)
154+
assert.Len(t, dbSet.Servers, 2)
155+
156+
err = RemoveServers(ctx, dao, workingSetID, []string{"My Image"})
157+
require.NoError(t, err)
158+
159+
dbSet, err = dao.GetWorkingSet(ctx, workingSetID)
160+
require.NoError(t, err)
161+
assert.Len(t, dbSet.Servers, 1)
162+
assert.Equal(t, "Another Image", dbSet.Servers[0].Snapshot.Server.Name)
163+
}
164+
165+
func TestRemoveNoServersFromWorkingSet(t *testing.T) {
166+
dao := setupTestDB(t)
167+
ctx := t.Context()
168+
169+
workingSetID := "test-set"
170+
171+
servers := []string{
172+
"docker://myimage:latest",
173+
}
174+
175+
err := Create(ctx, dao, getMockRegistryClient(), getMockOciService(), workingSetID, "My Test Set", servers)
176+
require.NoError(t, err)
177+
178+
err = RemoveServers(ctx, dao, workingSetID, []string{})
179+
require.Error(t, err)
180+
assert.Contains(t, err.Error(), oneServerError)
181+
}

pkg/workingset/workingset.go

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,27 @@ func (workingSet WorkingSet) ToDb() db.WorkingSet {
150150
}
151151

152152
func (workingSet *WorkingSet) Validate() error {
153-
return validate.Get().Struct(workingSet)
153+
err := validate.Get().Struct(workingSet)
154+
if err != nil {
155+
return err
156+
}
157+
return workingSet.validateUniqueServerNames()
158+
}
159+
160+
func (workingSet *WorkingSet) validateUniqueServerNames() error {
161+
seen := make(map[string]bool)
162+
for _, server := range workingSet.Servers {
163+
// TODO: Update when Snapshot is required
164+
if server.Snapshot == nil {
165+
continue
166+
}
167+
name := server.Snapshot.Server.Name
168+
if seen[name] {
169+
return fmt.Errorf("duplicate server name %s", name)
170+
}
171+
seen[name] = true
172+
}
173+
return nil
154174
}
155175

156176
func (workingSet *WorkingSet) FindServer(serverName string) *Server {

0 commit comments

Comments
 (0)