Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions internal/oauth/persistent_token_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,17 @@ func (p *PersistentTokenStore) GetToken(ctx context.Context) (*client.Token, err
return nil, transport.ErrNoToken
}

// DCR (Dynamic Client Registration) creates a minimal record with only
// client credentials but no access token. Treat these as "no token" to
// prevent scanForNewTokens() from triggering reconnect loops (issue #305).
if record.AccessToken == "" {
p.logger.Debug("⏳ OAuth record exists but has no access token (DCR-only), treating as no token",
zap.String("server_name", p.serverName),
zap.String("server_key", p.serverKey),
zap.Bool("has_client_id", record.ClientID != ""))
return nil, transport.ErrNoToken
}

now := time.Now()
timeUntilExpiry := record.ExpiresAt.Sub(now)
isExpired := now.After(record.ExpiresAt)
Expand Down
32 changes: 32 additions & 0 deletions internal/oauth/persistent_token_store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -480,3 +480,35 @@ func TestPersistentTokenStoreSameNameDifferentURL(t *testing.T) {
t.Errorf("Server2 token should still exist: got %s, want token-for-server2-url", retrievedToken2Again.AccessToken)
}
}

// TestGetToken_DCROnlyRecord_ReturnsError reproduces GitHub issue #305:
// After DCR saves client credentials (but no access token), GetToken() should
// return ErrNoToken — not a non-nil token with empty AccessToken. Returning a
// non-nil token causes scanForNewTokens() to trigger reconnect loops.
func TestGetToken_DCROnlyRecord_ReturnsError(t *testing.T) {
tmpDir := t.TempDir()
logger := zap.NewNop().Sugar()
db, err := storage.NewBoltDB(tmpDir, logger)
if err != nil {
t.Fatalf("Failed to create BoltDB: %v", err)
}
defer db.Close()

serverName := "oauth-server"
serverURL := "https://oauth.example.com/mcp"
serverKey := GenerateServerKey(serverName, serverURL)

// Simulate DCR saving only client credentials (no access token yet)
err = db.UpdateOAuthClientCredentials(serverKey, "dcr-client-id", "dcr-secret", 12345)
if err != nil {
t.Fatalf("Failed to save DCR credentials: %v", err)
}

// GetToken should return an error for DCR-only records (no access token)
tokenStore := NewPersistentTokenStore(serverName, serverURL, db)
tok, err := tokenStore.GetToken(context.Background())
if err == nil {
t.Errorf("GetToken() should return error for DCR-only record (no access token), got token with AccessToken=%q ExpiresAt=%v",
tok.AccessToken, tok.ExpiresAt)
}
}