Skip to main content
Glama
iceener

Spotify Streamable MCP Server

by iceener
refresh.ts8.39 kB
/** * Proactive token refresh utilities. * * This module provides token refresh functionality that can be used * during tool execution to ensure tokens are fresh before making API calls. */ import type { ProviderTokens, TokenStore } from '../storage/interface.js'; import { sharedLogger as logger } from '../utils/logger.js'; /** Provider configuration for token refresh */ export interface ProviderRefreshConfig { clientId: string; clientSecret: string; accountsUrl: string; tokenEndpointPath?: string; } /** Token refresh result */ export interface RefreshResult { success: boolean; tokens?: ProviderTokens; error?: string; } /** * Base64 encode a string (works in both Node.js and Workers). */ function base64Encode(input: string): string { if (typeof Buffer !== 'undefined') { return Buffer.from(input, 'utf8').toString('base64'); } return btoa(input); } /** * Refresh provider token using refresh_token grant. * * @param refreshToken - The provider refresh token * @param config - Provider configuration * @returns New provider tokens or error */ export async function refreshProviderToken( refreshToken: string, config: ProviderRefreshConfig, ): Promise<RefreshResult> { const tokenEndpointPath = config.tokenEndpointPath || '/api/token'; const tokenUrl = new URL(tokenEndpointPath, config.accountsUrl).toString(); const form = new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refreshToken, }); const basic = base64Encode(`${config.clientId}:${config.clientSecret}`); logger.debug('oauth_refresh', { message: 'Refreshing provider token', tokenUrl, }); try { const resp = await fetch(tokenUrl, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', authorization: `Basic ${basic}`, }, body: form.toString(), }); if (!resp.ok) { const text = await resp.text().catch(() => ''); logger.error('oauth_refresh', { message: 'Provider refresh failed', status: resp.status, body: text.substring(0, 200), }); return { success: false, error: `Provider returned ${resp.status}: ${text.substring(0, 100)}`, }; } const data = (await resp.json()) as { access_token?: string; refresh_token?: string; expires_in?: number | string; scope?: string; }; const accessToken = String(data.access_token || ''); if (!accessToken) { return { success: false, error: 'No access_token in provider response', }; } logger.info('oauth_refresh', { message: 'Provider token refreshed', hasNewRefreshToken: !!data.refresh_token, }); return { success: true, tokens: { access_token: accessToken, refresh_token: data.refresh_token ?? refreshToken, // Some providers don't rotate expires_at: Date.now() + Number(data.expires_in ?? 3600) * 1000, scopes: String(data.scope || '') .split(/\s+/) .filter(Boolean), }, }; } catch (error) { logger.error('oauth_refresh', { message: 'Token refresh network error', error: (error as Error).message, }); return { success: false, error: `Network error: ${(error as Error).message}`, }; } } /** Token expiry check thresholds */ const EXPIRY_BUFFER_MS = 60_000; // 1 minute buffer /** Refresh throttle to prevent redundant KV writes */ const REFRESH_COOLDOWN_MS = 30_000; // 30 seconds const recentlyRefreshed = new Map<string, number>(); /** * Check if a token was recently refreshed (throttle). * Prevents concurrent/repeated refreshes from causing redundant KV writes. */ function shouldSkipRefresh(rsToken: string): boolean { const lastRefresh = recentlyRefreshed.get(rsToken); if (lastRefresh && Date.now() - lastRefresh < REFRESH_COOLDOWN_MS) { return true; } return false; } /** * Mark a token as recently refreshed (only call on SUCCESS). */ function markRefreshed(rsToken: string): void { recentlyRefreshed.set(rsToken, Date.now()); // Cleanup old entries to prevent memory leak if (recentlyRefreshed.size > 1000) { const now = Date.now(); for (const [key, timestamp] of recentlyRefreshed) { if (now - timestamp > REFRESH_COOLDOWN_MS) { recentlyRefreshed.delete(key); } } } } /** * Check if a token is expired or will expire soon. * * @param expiresAt - Token expiry timestamp (ms) * @param bufferMs - Buffer time before expiry to consider "near expiry" * @returns true if token is expired or expiring within buffer */ export function isTokenExpiredOrExpiring( expiresAt: number | undefined, bufferMs = EXPIRY_BUFFER_MS, ): boolean { if (!expiresAt) return false; // No expiry = assume valid return Date.now() >= expiresAt - bufferMs; } /** * Proactively refresh token if near expiry. * * This should be called before tool execution to ensure fresh tokens. * Updates the token store with new tokens if refresh succeeds. * * @param rsAccessToken - The RS access token to check * @param tokenStore - Token storage * @param providerConfig - Provider configuration for refresh * @returns Refreshed provider access token, or original if refresh not needed/failed */ export async function ensureFreshToken( rsAccessToken: string, tokenStore: TokenStore, providerConfig: ProviderRefreshConfig | undefined, ): Promise<{ accessToken: string; wasRefreshed: boolean }> { const record = await tokenStore.getByRsAccess(rsAccessToken); if (!record?.provider?.access_token) { return { accessToken: '', wasRefreshed: false }; } // Check if token is near expiry if (!isTokenExpiredOrExpiring(record.provider.expires_at)) { return { accessToken: record.provider.access_token, wasRefreshed: false }; } // Throttle: skip if this token was recently refreshed // Prevents concurrent requests from triggering multiple refreshes if (shouldSkipRefresh(rsAccessToken)) { logger.debug('oauth_refresh', { message: 'Token refresh throttled (recently refreshed)', }); return { accessToken: record.provider.access_token, wasRefreshed: false }; } logger.info('oauth_refresh', { message: 'Token near expiry, attempting refresh', expiresAt: record.provider.expires_at, now: Date.now(), }); // Need refresh - check we have what we need if (!record.provider.refresh_token) { logger.warning('oauth_refresh', { message: 'Token near expiry but no refresh token available', }); return { accessToken: record.provider.access_token, wasRefreshed: false }; } if (!providerConfig) { logger.warning('oauth_refresh', { message: 'Token near expiry but no provider config for refresh', }); return { accessToken: record.provider.access_token, wasRefreshed: false }; } // Attempt refresh const result = await refreshProviderToken( record.provider.refresh_token, providerConfig, ); if (!result.success || !result.tokens) { logger.error('oauth_refresh', { message: 'Token refresh failed, using existing token', error: result.error, }); return { accessToken: record.provider.access_token, wasRefreshed: false }; } // Determine if RS access token should rotate // Only rotate when provider refresh_token changed (security trade-off for KV quota) const providerRefreshRotated = result.tokens.refresh_token !== record.provider.refresh_token; const newRsAccess = providerRefreshRotated ? undefined : record.rs_access_token; // Update token store with new tokens try { await tokenStore.updateByRsRefresh( record.rs_refresh_token, result.tokens, newRsAccess, ); // Mark as refreshed ONLY on success (prevents lockout on failure) markRefreshed(rsAccessToken); logger.info('oauth_refresh', { message: 'Token store updated with refreshed tokens', rsAccessRotated: providerRefreshRotated, }); return { accessToken: result.tokens.access_token, wasRefreshed: true }; } catch (error) { logger.error('oauth_refresh', { message: 'Failed to update token store', error: (error as Error).message, }); // Return new token even if store update failed return { accessToken: result.tokens.access_token, wasRefreshed: true }; } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/iceener/spotify-streamable-mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server