Skip to content

Commit e765d96

Browse files
authored
Clean up orphaned DCR entries when servers are removed from profiles (#411)
## Summary - **Bug:** When a server with OAuth (DCR) is added to a profile, a DCR entry is registered in Docker Desktop's secure secrets store. When that server is later removed from the profile, the DCR entry is never cleaned up. This causes stale OAuth entries to accumulate in `docker mcp oauth ls` and the Desktop UI OAuth tab. Similarly, `docker mcp oauth revoke` uses a catalog-based `IsRemoteOAuthServer()` check that misses community servers without `oauth.providers` metadata, so their DCR entries are never removed on revoke. - **Fix (profile removal):** After removing servers from a profile in `RemoveServers()`, check if each removed server still exists in any other profile. If not — and the user is not still authorized — delete its DCR entry via the Desktop API. - **Fix (revoke):** Replace the catalog-based `IsRemoteOAuthServer` check in `revokeDesktopMode()` with profile-aware cleanup using the same `CleanupOrphanedDCREntries()` logic. This correctly handles both catalog servers and community servers. - **Changed:** `pkg/workingset/server.go` — added and exported `CleanupOrphanedDCREntries()` called from `RemoveServers()`. `cmd/docker-mcp/oauth/revoke.go` — calls `CleanupOrphanedDCREntries()` after token revocation. Skips CE mode, logs warnings on failure without blocking. Related pinata change to use mcp CLI: docker/pinata#39417 ## Test plan - [x] Add an OAuth-enabled server to a profile (`docker mcp server add default --server <server>`) - [x] Verify it appears in `docker mcp oauth ls` - [x] Remove the server (`docker mcp server remove default <server-name>`) - [x] Verify the DCR entry is cleaned up — it should no longer appear in `docker mcp oauth ls` - [x] Add the same server to two profiles, remove from one — verify DCR entry is preserved - [x] Remove from the second profile — verify DCR entry is now cleaned up - [x] Authorize a server, then revoke (`docker mcp oauth revoke <server>`) — verify DCR entry is removed if not in any profile - [x] Authorize a server that is still in a profile, revoke — verify DCR entry is preserved (server still in profile) ``` # After adding sigma-remote to default profile ❯ docker mcp oauth ls gdrive | not authorized github | not authorized sigma-remote | not authorized # Authorize sigma-remote ❯ docker mcp oauth authorize sigma-remote Opening your browser for authentication. If it doesn't open automatically, please visit: ... # Confirm sigma-remote is authorized ❯ docker mcp oauth ls gdrive | not authorized github | not authorized sigma-remote | authorized # Remove sigma-remote from default profile ❯ docker mcp profile server remove default --name sigma-remote Removed 1 server(s) from profile default # Confirm sigma-remote still shows as authorized ❯ docker mcp oauth ls gdrive | not authorized github | not authorized sigma-remote | authorized # Revoke sigma-remote oauth. Note that is has already been removed from default profile ❯ docker mcp oauth revoke sigma-remote Revoking OAuth access for sigma-remote... OAuth access revoked for sigma-remote # Confirm sigma-remote no longer appears in `docker mcp oauth ls` ❯ docker mcp oauth ls gdrive | not authorized github | not authorized ```
1 parent fdb82a0 commit e765d96

File tree

4 files changed

+351
-21
lines changed

4 files changed

+351
-21
lines changed

cmd/docker-mcp/oauth/revoke.go

Lines changed: 10 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@ import (
44
"context"
55
"fmt"
66

7-
"github.com/docker/mcp-gateway/pkg/catalog"
7+
"github.com/docker/mcp-gateway/pkg/db"
88
"github.com/docker/mcp-gateway/pkg/desktop"
99
pkgoauth "github.com/docker/mcp-gateway/pkg/oauth"
10+
"github.com/docker/mcp-gateway/pkg/workingset"
1011
)
1112

1213
func Revoke(ctx context.Context, app string) error {
@@ -25,28 +26,21 @@ func Revoke(ctx context.Context, app string) error {
2526
func revokeDesktopMode(ctx context.Context, app string) error {
2627
client := desktop.NewAuthClient()
2728

28-
// Get catalog to check if this is a remote OAuth server
29-
catalogData, err := catalog.GetWithOptions(ctx, true, nil)
30-
if err != nil {
31-
return fmt.Errorf("failed to get catalog: %w", err)
32-
}
33-
34-
server, found := catalogData.Servers[app]
35-
isRemoteOAuth := found && server.IsRemoteOAuthServer()
36-
3729
// Revoke tokens
3830
if err := client.DeleteOAuthApp(ctx, app); err != nil {
3931
return fmt.Errorf("failed to revoke OAuth access: %w", err)
4032
}
4133

42-
// For remote OAuth servers, also delete DCR client
43-
if isRemoteOAuth {
44-
if err := client.DeleteDCRClient(ctx, app); err != nil {
45-
return fmt.Errorf("failed to remove DCR client: %w", err)
46-
}
34+
fmt.Printf("OAuth access revoked for %s\n", app)
35+
36+
// Clean up DCR entry if the server is not in any profile.
37+
// If the server is still in a profile, keep the DCR entry so it
38+
// remains visible in the OAuth UI for re-authorization.
39+
dao, err := db.New()
40+
if err == nil {
41+
workingset.CleanupOrphanedDCREntries(ctx, dao, []string{app})
4742
}
4843

49-
fmt.Printf("OAuth access revoked for %s\n", app)
5044
return nil
5145
}
5246

pkg/workingset/dcr_cleanup_test.go

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
package workingset
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
11+
"github.com/docker/mcp-gateway/pkg/catalog"
12+
"github.com/docker/mcp-gateway/pkg/db"
13+
"github.com/docker/mcp-gateway/pkg/desktop"
14+
)
15+
16+
// mockDCRClient tracks GetOAuthApp, GetDCRClient, and DeleteDCRClient calls for testing.
17+
type mockDCRClient struct {
18+
// registered holds the set of apps that have DCR entries
19+
registered map[string]bool
20+
// authorized holds the set of apps where the user is still authorized
21+
authorized map[string]bool
22+
// deleted tracks which apps had DeleteDCRClient called
23+
deleted []string
24+
}
25+
26+
func newMockDCRClient(apps ...string) *mockDCRClient {
27+
m := &mockDCRClient{
28+
registered: make(map[string]bool),
29+
authorized: make(map[string]bool),
30+
}
31+
for _, app := range apps {
32+
m.registered[app] = true
33+
}
34+
return m
35+
}
36+
37+
func (m *mockDCRClient) withAuthorized(apps ...string) *mockDCRClient {
38+
for _, app := range apps {
39+
m.authorized[app] = true
40+
}
41+
return m
42+
}
43+
44+
func (m *mockDCRClient) GetOAuthApp(_ context.Context, app string) (*desktop.OAuthApp, error) {
45+
if m.registered[app] {
46+
return &desktop.OAuthApp{
47+
App: app,
48+
Authorized: m.authorized[app],
49+
}, nil
50+
}
51+
return nil, fmt.Errorf("not found")
52+
}
53+
54+
func (m *mockDCRClient) GetDCRClient(_ context.Context, app string) (*desktop.DCRClient, error) {
55+
if m.registered[app] {
56+
return &desktop.DCRClient{ServerName: app, State: "registered"}, nil
57+
}
58+
return nil, fmt.Errorf("not found")
59+
}
60+
61+
func (m *mockDCRClient) DeleteDCRClient(_ context.Context, app string) error {
62+
delete(m.registered, app)
63+
m.deleted = append(m.deleted, app)
64+
return nil
65+
}
66+
67+
func TestCleanupOrphanedDCREntries_DeletesOrphanedAndRevoked(t *testing.T) {
68+
dao := setupTestDB(t)
69+
ctx := t.Context()
70+
71+
// Create a profile with one server
72+
err := dao.CreateWorkingSet(ctx, db.WorkingSet{
73+
ID: "profile-1",
74+
Name: "Profile 1",
75+
Servers: db.ServerList{
76+
{
77+
Type: "remote",
78+
Snapshot: &db.ServerSnapshot{
79+
Server: catalog.Server{Name: "server-a"},
80+
},
81+
},
82+
},
83+
Secrets: db.SecretMap{},
84+
})
85+
require.NoError(t, err)
86+
87+
// DCR entries exist for both; server-b is not authorized
88+
mock := newMockDCRClient("server-a", "server-b")
89+
90+
// Remove server-b (not in any profile, not authorized) → should be deleted
91+
doCleanupOrphanedDCREntries(ctx, dao, mock, []string{"server-b"})
92+
93+
assert.Equal(t, []string{"server-b"}, mock.deleted)
94+
assert.True(t, mock.registered["server-a"], "server-a should not be deleted")
95+
}
96+
97+
func TestCleanupOrphanedDCREntries_SkipsAuthorized(t *testing.T) {
98+
dao := setupTestDB(t)
99+
ctx := t.Context()
100+
101+
// Empty profile (server was removed)
102+
err := dao.CreateWorkingSet(ctx, db.WorkingSet{
103+
ID: "profile-1",
104+
Name: "Profile 1",
105+
Servers: db.ServerList{},
106+
Secrets: db.SecretMap{},
107+
})
108+
require.NoError(t, err)
109+
110+
// server-a has DCR entry and is still authorized
111+
mock := newMockDCRClient("server-a").withAuthorized("server-a")
112+
113+
// server-a is not in any profile but IS authorized → should NOT be deleted
114+
doCleanupOrphanedDCREntries(ctx, dao, mock, []string{"server-a"})
115+
116+
assert.Empty(t, mock.deleted)
117+
assert.True(t, mock.registered["server-a"])
118+
}
119+
120+
func TestCleanupOrphanedDCREntries_SkipsInUse(t *testing.T) {
121+
dao := setupTestDB(t)
122+
ctx := t.Context()
123+
124+
// Create two profiles, both containing server-a
125+
for _, id := range []string{"profile-1", "profile-2"} {
126+
err := dao.CreateWorkingSet(ctx, db.WorkingSet{
127+
ID: id,
128+
Name: id,
129+
Servers: db.ServerList{
130+
{
131+
Type: "remote",
132+
Snapshot: &db.ServerSnapshot{
133+
Server: catalog.Server{Name: "server-a"},
134+
},
135+
},
136+
},
137+
Secrets: db.SecretMap{},
138+
})
139+
require.NoError(t, err)
140+
}
141+
142+
mock := newMockDCRClient("server-a")
143+
144+
// server-a is still in profile-2, should NOT be deleted
145+
doCleanupOrphanedDCREntries(ctx, dao, mock, []string{"server-a"})
146+
147+
assert.Empty(t, mock.deleted)
148+
assert.True(t, mock.registered["server-a"])
149+
}
150+
151+
func TestCleanupOrphanedDCREntries_SkipsNoDCREntry(t *testing.T) {
152+
dao := setupTestDB(t)
153+
ctx := t.Context()
154+
155+
// Empty profile
156+
err := dao.CreateWorkingSet(ctx, db.WorkingSet{
157+
ID: "profile-1",
158+
Name: "Profile 1",
159+
Servers: db.ServerList{},
160+
Secrets: db.SecretMap{},
161+
})
162+
require.NoError(t, err)
163+
164+
// No DCR entries registered
165+
mock := newMockDCRClient()
166+
167+
// server-x has no DCR entry → GetDCRClient returns error → skip delete
168+
doCleanupOrphanedDCREntries(ctx, dao, mock, []string{"server-x"})
169+
170+
assert.Empty(t, mock.deleted)
171+
}
172+
173+
func TestCleanupOrphanedDCREntries_MultipleServers(t *testing.T) {
174+
dao := setupTestDB(t)
175+
ctx := t.Context()
176+
177+
// Profile with server-a only
178+
err := dao.CreateWorkingSet(ctx, db.WorkingSet{
179+
ID: "profile-1",
180+
Name: "Profile 1",
181+
Servers: db.ServerList{
182+
{
183+
Type: "remote",
184+
Snapshot: &db.ServerSnapshot{
185+
Server: catalog.Server{Name: "server-a"},
186+
},
187+
},
188+
},
189+
Secrets: db.SecretMap{},
190+
})
191+
require.NoError(t, err)
192+
193+
// DCR entries for all three; server-c is still authorized
194+
mock := newMockDCRClient("server-a", "server-b", "server-c").withAuthorized("server-c")
195+
196+
// Remove server-a (still in profile), server-b (revoked), server-c (authorized)
197+
doCleanupOrphanedDCREntries(ctx, dao, mock, []string{"server-a", "server-b", "server-c"})
198+
199+
// Only server-b should be deleted (server-a in profile, server-c authorized)
200+
assert.Equal(t, []string{"server-b"}, mock.deleted)
201+
assert.True(t, mock.registered["server-a"])
202+
assert.True(t, mock.registered["server-c"])
203+
}
204+
205+
func TestCleanupOrphanedDCREntries_ServerInDifferentProfile(t *testing.T) {
206+
dao := setupTestDB(t)
207+
ctx := t.Context()
208+
209+
// Profile 1 is now empty (server-a and server-b were already removed by RemoveServers)
210+
err := dao.CreateWorkingSet(ctx, db.WorkingSet{
211+
ID: "profile-1",
212+
Name: "Profile 1",
213+
Servers: db.ServerList{},
214+
Secrets: db.SecretMap{},
215+
})
216+
require.NoError(t, err)
217+
218+
// Profile 2 still has server-b
219+
err = dao.CreateWorkingSet(ctx, db.WorkingSet{
220+
ID: "profile-2",
221+
Name: "Profile 2",
222+
Servers: db.ServerList{
223+
{
224+
Type: "remote",
225+
Snapshot: &db.ServerSnapshot{
226+
Server: catalog.Server{Name: "server-b"},
227+
},
228+
},
229+
},
230+
Secrets: db.SecretMap{},
231+
})
232+
require.NoError(t, err)
233+
234+
mock := newMockDCRClient("server-a", "server-b")
235+
236+
// Cleanup after removing server-a and server-b from profile-1
237+
// server-b is still in profile-2, so only server-a should be deleted
238+
doCleanupOrphanedDCREntries(ctx, dao, mock, []string{"server-a", "server-b"})
239+
240+
assert.Equal(t, []string{"server-a"}, mock.deleted)
241+
assert.True(t, mock.registered["server-b"])
242+
}

pkg/workingset/server.go

Lines changed: 69 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ import (
1313

1414
"github.com/docker/mcp-gateway/cmd/docker-mcp/secret-management/formatting"
1515
"github.com/docker/mcp-gateway/pkg/db"
16+
"github.com/docker/mcp-gateway/pkg/desktop"
17+
"github.com/docker/mcp-gateway/pkg/log"
18+
"github.com/docker/mcp-gateway/pkg/oauth"
1619
"github.com/docker/mcp-gateway/pkg/oci"
1720
"github.com/docker/mcp-gateway/pkg/policy"
1821
policycli "github.com/docker/mcp-gateway/pkg/policy/cli"
@@ -117,17 +120,18 @@ func RemoveServers(ctx context.Context, dao db.DAO, id string, serverNames []str
117120
namesToRemove[name] = true
118121
}
119122

120-
originalCount := len(workingSet.Servers)
123+
removedNames := make([]string, 0)
121124
filtered := make([]Server, 0, len(workingSet.Servers))
122125
for _, server := range workingSet.Servers {
123126
// TODO: Remove when Snapshot is required
124-
if server.Snapshot == nil || !namesToRemove[server.Snapshot.Server.Name] {
127+
if server.Snapshot != nil && namesToRemove[server.Snapshot.Server.Name] {
128+
removedNames = append(removedNames, server.Snapshot.Server.Name)
129+
} else {
125130
filtered = append(filtered, server)
126131
}
127132
}
128133

129-
removedCount := originalCount - len(filtered)
130-
if removedCount == 0 {
134+
if len(removedNames) == 0 {
131135
return fmt.Errorf("no matching servers found to remove")
132136
}
133137

@@ -142,11 +146,71 @@ func RemoveServers(ctx context.Context, dao db.DAO, id string, serverNames []str
142146
return fmt.Errorf("failed to update profile: %w", err)
143147
}
144148

145-
fmt.Printf("Removed %d server(s) from profile %s\n", removedCount, id)
149+
fmt.Printf("Removed %d server(s) from profile %s\n", len(removedNames), id)
150+
151+
// Clean up DCR entries for removed servers not in any other profile
152+
cleanupDCREntriesFunc(ctx, dao, removedNames)
146153

147154
return nil
148155
}
149156

157+
// cleanupDCREntriesFunc is called by RemoveServers for DCR cleanup.
158+
// Tests can override this to verify the call without requiring Docker Desktop.
159+
var cleanupDCREntriesFunc = CleanupOrphanedDCREntries
160+
161+
// dcrClient abstracts the Desktop API operations needed for cleanup,
162+
// allowing tests to substitute a mock implementation.
163+
type dcrClient interface {
164+
GetOAuthApp(ctx context.Context, app string) (*desktop.OAuthApp, error)
165+
GetDCRClient(ctx context.Context, app string) (*desktop.DCRClient, error)
166+
DeleteDCRClient(ctx context.Context, app string) error
167+
}
168+
169+
// CleanupOrphanedDCREntries removes DCR entries for servers that no longer
170+
// exist in any profile. This prevents stale OAuth entries from accumulating.
171+
func CleanupOrphanedDCREntries(ctx context.Context, dao db.DAO, serverNames []string) {
172+
if oauth.IsCEMode() {
173+
return
174+
}
175+
doCleanupOrphanedDCREntries(ctx, dao, desktop.NewAuthClient(), serverNames)
176+
}
177+
178+
func doCleanupOrphanedDCREntries(ctx context.Context, dao db.DAO, client dcrClient, serverNames []string) {
179+
allSets, err := dao.ListWorkingSets(ctx)
180+
if err != nil {
181+
log.Logf("Warning: Failed to list profiles for DCR cleanup: %v", err)
182+
return
183+
}
184+
185+
// Build set of all server names still in use across all profiles
186+
inUse := make(map[string]bool)
187+
for _, ws := range allSets {
188+
for _, server := range ws.Servers {
189+
if server.Snapshot != nil && server.Snapshot.Server.Name != "" {
190+
inUse[server.Snapshot.Server.Name] = true
191+
}
192+
}
193+
}
194+
195+
for _, name := range serverNames {
196+
if inUse[name] {
197+
continue
198+
}
199+
// Only delete if a DCR entry actually exists
200+
if _, err := client.GetDCRClient(ctx, name); err != nil {
201+
continue
202+
}
203+
// Keep the DCR entry if the user is still authorized — they may
204+
// re-add the server to a profile without needing to re-authorize.
205+
if app, err := client.GetOAuthApp(ctx, name); err == nil && app.Authorized {
206+
continue
207+
}
208+
if err := client.DeleteDCRClient(ctx, name); err != nil {
209+
log.Logf("Warning: Failed to clean up DCR entry for %s: %v", name, err)
210+
}
211+
}
212+
}
213+
150214
type SearchResult struct {
151215
ID string `json:"id" yaml:"id"`
152216
Name string `json:"name" yaml:"name"`

0 commit comments

Comments
 (0)