Skip to content

Commit 55b0861

Browse files
Dumbrisclaude
andauthored
fix(oauth): clean up orphaned tokens on server removal and startup (#288)
This fixes the issue where OAuth tokens accumulate in the database for servers that have been removed, causing unnecessary refresh attempts and confusing error messages like "OAuth token refresh failed - token expired too long ago". Changes: 1. RemoveServer() now calls ClearOAuthState() to remove OAuth tokens when a server is deleted 2. RemoveServer() notifies RefreshManager to stop tracking the removed server's token refresh schedule 3. Added CleanupOrphanedOAuthTokens() to storage manager that removes tokens for servers no longer in configuration 4. Startup now cleans up orphaned tokens before starting RefreshManager Key storage design notes: - Tokens use serverName_hash(serverName|serverURL) as storage key - This ensures multiple servers can share the same URL (different names) - URL changes for a server name are properly handled via ClearOAuthState which deletes all tokens with the serverName_ prefix Tested: Verified 7 orphaned tokens were cleaned up on startup Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 31ee931 commit 55b0861

4 files changed

Lines changed: 172 additions & 0 deletions

File tree

internal/runtime/lifecycle.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,24 @@ func (r *Runtime) StartBackgroundInitialization() {
3030
r.logger.Info("Update checker background process started")
3131
}
3232

33+
// Clean up orphaned OAuth tokens before starting the refresh manager
34+
// This removes tokens for servers that were deleted while mcpproxy was not running
35+
if r.storageManager != nil {
36+
r.mu.RLock()
37+
validServerNames := make([]string, 0, len(r.cfg.Servers))
38+
for _, server := range r.cfg.Servers {
39+
validServerNames = append(validServerNames, server.Name)
40+
}
41+
r.mu.RUnlock()
42+
43+
if deleted, err := r.storageManager.CleanupOrphanedOAuthTokens(validServerNames); err != nil {
44+
r.logger.Warn("Failed to cleanup orphaned OAuth tokens", zap.Error(err))
45+
} else if deleted > 0 {
46+
r.logger.Info("Cleaned up orphaned OAuth tokens during startup",
47+
zap.Int("deleted", deleted))
48+
}
49+
}
50+
3351
// Start proactive OAuth token refresh manager
3452
if r.refreshManager != nil {
3553
r.refreshManager.SetRuntime(r)

internal/server/server.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -922,6 +922,20 @@ func (s *Server) RemoveServer(ctx context.Context, serverName string) error {
922922
return fmt.Errorf("failed to remove server from storage: %w", err)
923923
}
924924

925+
// Clear OAuth state (tokens, client registration) for the removed server
926+
// This prevents orphaned tokens from accumulating in the database
927+
if err := storageManager.ClearOAuthState(serverName); err != nil {
928+
s.logger.Warn("Failed to clear OAuth state for removed server",
929+
zap.String("server", serverName),
930+
zap.Error(err))
931+
// Continue - this is cleanup, not critical for removal
932+
}
933+
934+
// Notify RefreshManager to stop tracking this server's token refresh
935+
if refreshManager := s.runtime.RefreshManager(); refreshManager != nil {
936+
refreshManager.OnTokenCleared(serverName)
937+
}
938+
925939
// Remove from search index
926940
if err := s.runtime.IndexManager().DeleteServerTools(serverName); err != nil {
927941
s.logger.Warn("Failed to remove server tools from index",

internal/storage/manager.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1383,3 +1383,68 @@ func (m *Manager) ClearOAuthState(serverName string) error {
13831383
}
13841384
return nil
13851385
}
1386+
1387+
// CleanupOrphanedOAuthTokens removes OAuth tokens for servers that no longer exist in the configuration.
1388+
// This should be called during startup to clean up tokens left behind when servers were removed
1389+
// while mcpproxy was not running, or from older versions that didn't clean up properly.
1390+
func (m *Manager) CleanupOrphanedOAuthTokens(validServerNames []string) (int, error) {
1391+
m.mu.Lock()
1392+
defer m.mu.Unlock()
1393+
1394+
if m.db == nil {
1395+
return 0, fmt.Errorf("storage not initialized")
1396+
}
1397+
1398+
// Create a set of valid server names for fast lookup
1399+
validServers := make(map[string]bool, len(validServerNames))
1400+
for _, name := range validServerNames {
1401+
validServers[name] = true
1402+
}
1403+
1404+
// Get all OAuth tokens
1405+
tokens, err := m.db.ListOAuthTokens()
1406+
if err != nil {
1407+
return 0, fmt.Errorf("failed to list OAuth tokens: %w", err)
1408+
}
1409+
1410+
// Find orphaned tokens (tokens whose server no longer exists)
1411+
var orphanedKeys []string
1412+
for _, token := range tokens {
1413+
serverName := token.GetServerName()
1414+
if serverName == "" {
1415+
// Token has no server name, consider it orphaned
1416+
orphanedKeys = append(orphanedKeys, token.ServerName)
1417+
continue
1418+
}
1419+
if !validServers[serverName] {
1420+
orphanedKeys = append(orphanedKeys, token.ServerName)
1421+
m.logger.Infow("Found orphaned OAuth token",
1422+
"storage_key", token.ServerName,
1423+
"display_name", serverName)
1424+
}
1425+
}
1426+
1427+
if len(orphanedKeys) == 0 {
1428+
m.logger.Debug("No orphaned OAuth tokens found")
1429+
return 0, nil
1430+
}
1431+
1432+
// Delete orphaned tokens
1433+
deleted := 0
1434+
for _, key := range orphanedKeys {
1435+
if err := m.db.DeleteOAuthToken(key); err != nil {
1436+
m.logger.Warnw("Failed to delete orphaned OAuth token",
1437+
"key", key,
1438+
"error", err)
1439+
continue
1440+
}
1441+
deleted++
1442+
}
1443+
1444+
m.logger.Infow("Cleaned up orphaned OAuth tokens",
1445+
"total_tokens", len(tokens),
1446+
"orphaned_found", len(orphanedKeys),
1447+
"deleted", deleted)
1448+
1449+
return deleted, nil
1450+
}

internal/storage/manager_oauth_test.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,81 @@ func TestBoltDB_GetOAuthClientCredentials_LegacyRecord(t *testing.T) {
101101
require.Equal(t, 0, gotPort, "Legacy records should return port 0")
102102
}
103103

104+
// TestManager_CleanupOrphanedOAuthTokens verifies that orphaned tokens are removed
105+
// while tokens for valid servers are preserved.
106+
func TestManager_CleanupOrphanedOAuthTokens(t *testing.T) {
107+
tmpDir, err := os.MkdirTemp("", "storage-cleanup-oauth-*")
108+
require.NoError(t, err)
109+
t.Cleanup(func() { _ = os.RemoveAll(tmpDir) })
110+
111+
mgr, err := storage.NewManager(tmpDir, zap.NewNop().Sugar())
112+
require.NoError(t, err)
113+
t.Cleanup(func() { _ = mgr.Close() })
114+
115+
// Create tokens for 4 servers: 2 valid, 2 orphaned
116+
validServer1 := "valid-server-1"
117+
validServer2 := "valid-server-2"
118+
orphanedServer1 := "orphaned-server-1"
119+
orphanedServer2 := "orphaned-server-2"
120+
121+
// Generate server keys with URLs
122+
validKey1 := oauth.GenerateServerKey(validServer1, "https://valid1.example.com")
123+
validKey2 := oauth.GenerateServerKey(validServer2, "https://valid2.example.com")
124+
orphanedKey1 := oauth.GenerateServerKey(orphanedServer1, "https://orphan1.example.com")
125+
orphanedKey2 := oauth.GenerateServerKey(orphanedServer2, "https://orphan2.example.com")
126+
127+
// Save tokens with DisplayName set (as PersistentTokenStore does)
128+
err = mgr.GetBoltDB().SaveOAuthToken(&storage.OAuthTokenRecord{
129+
ServerName: validKey1,
130+
DisplayName: validServer1,
131+
AccessToken: "valid-token-1",
132+
})
133+
require.NoError(t, err)
134+
135+
err = mgr.GetBoltDB().SaveOAuthToken(&storage.OAuthTokenRecord{
136+
ServerName: validKey2,
137+
DisplayName: validServer2,
138+
AccessToken: "valid-token-2",
139+
})
140+
require.NoError(t, err)
141+
142+
err = mgr.GetBoltDB().SaveOAuthToken(&storage.OAuthTokenRecord{
143+
ServerName: orphanedKey1,
144+
DisplayName: orphanedServer1,
145+
AccessToken: "orphan-token-1",
146+
})
147+
require.NoError(t, err)
148+
149+
err = mgr.GetBoltDB().SaveOAuthToken(&storage.OAuthTokenRecord{
150+
ServerName: orphanedKey2,
151+
DisplayName: orphanedServer2,
152+
AccessToken: "orphan-token-2",
153+
})
154+
require.NoError(t, err)
155+
156+
// Cleanup with only valid servers
157+
validServers := []string{validServer1, validServer2}
158+
deleted, err := mgr.CleanupOrphanedOAuthTokens(validServers)
159+
require.NoError(t, err)
160+
require.Equal(t, 2, deleted, "Should delete 2 orphaned tokens")
161+
162+
// Verify valid tokens still exist
163+
token1, err := mgr.GetBoltDB().GetOAuthToken(validKey1)
164+
require.NoError(t, err)
165+
require.Equal(t, "valid-token-1", token1.AccessToken)
166+
167+
token2, err := mgr.GetBoltDB().GetOAuthToken(validKey2)
168+
require.NoError(t, err)
169+
require.Equal(t, "valid-token-2", token2.AccessToken)
170+
171+
// Verify orphaned tokens are gone
172+
_, err = mgr.GetBoltDB().GetOAuthToken(orphanedKey1)
173+
require.Error(t, err, "Orphaned token 1 should be deleted")
174+
175+
_, err = mgr.GetBoltDB().GetOAuthToken(orphanedKey2)
176+
require.Error(t, err, "Orphaned token 2 should be deleted")
177+
}
178+
104179
// TestBoltDB_ClearOAuthClientCredentials_PreservesToken verifies that ClearOAuthClientCredentials
105180
// clears DCR fields but preserves token data (Spec 022).
106181
func TestBoltDB_ClearOAuthClientCredentials_PreservesToken(t *testing.T) {

0 commit comments

Comments
 (0)