Skip to main content
Glama
iceener

Spotify Streamable MCP Server

by iceener
sdk.ts7.52 kB
/** * Spotify SDK client factory. * Provides user-authenticated Spotify API clients using the template's auth context. */ import { type AccessToken, ClientCredentialsStrategy, type IAuthStrategy, type IValidateResponses, type SdkConfiguration, SpotifyApi, } from '@spotify/web-api-ts-sdk'; import { config } from '../../config/env.js'; import { getTokenStore } from '../../shared/storage/singleton.js'; import type { ToolContext } from '../../shared/tools/types.js'; import { sharedLogger as logger } from '../../shared/utils/logger.js'; import { refreshSpotifyTokens } from './oauth.js'; // --------------------------------------------------------------------------- // Response Validator // --------------------------------------------------------------------------- const responseValidator: IValidateResponses = { async validateResponse(response: Response): Promise<void> { if (response.status === 204) { return; } if (response.ok) { return; } const body = await response.text().catch(() => ''); const error = new Error( `Spotify request failed: ${response.status} ${response.statusText}${ body ? ` - ${body}` : '' }`, ); (error as { status?: number }).status = response.status; throw error; }, }; /** * Custom deserializer that handles non-JSON responses gracefully. * The npm version of @spotify/web-api-ts-sdk doesn't catch JSON.parse errors, * but some Spotify endpoints (e.g., queue) return non-JSON responses. */ const responseDeserializer = { async deserialize<T>(response: Response): Promise<T> { const text = await response.text(); if (text.length > 0) { try { return JSON.parse(text) as T; } catch { // Non-JSON response (e.g., queue endpoint) - treat as success return null as T; } } return null as T; }, }; const sdkOptions = { responseValidator, deserializer: responseDeserializer } as const; // --------------------------------------------------------------------------- // App Client (Client Credentials - for non-user APIs like search) // --------------------------------------------------------------------------- let appClient: SpotifyApi | null = null; export function getSpotifyAppClient(): SpotifyApi { const clientId = config.SPOTIFY_CLIENT_ID || config.OAUTH_CLIENT_ID; const clientSecret = config.SPOTIFY_CLIENT_SECRET || config.OAUTH_CLIENT_SECRET; if (!clientId || !clientSecret) { throw new Error('Spotify client credentials are not configured'); } if (!appClient) { const strategy = new ClientCredentialsStrategy(clientId, clientSecret); appClient = new SpotifyApi(strategy, sdkOptions); } return appClient; } // --------------------------------------------------------------------------- // User Client (OAuth - for user-specific APIs) // --------------------------------------------------------------------------- /** * Get a Spotify API client for the authenticated user. * Uses the provider token from the tool context. */ export async function getSpotifyUserClient( context: ToolContext, ): Promise<SpotifyApi | null> { const clientId = config.SPOTIFY_CLIENT_ID || config.OAUTH_CLIENT_ID; if (!clientId) { throw new Error('Spotify client id is not configured'); } // Get provider token from context (set by auth middleware) const providerToken = context.providerToken || context.provider?.accessToken; if (!providerToken) { logger.info('spotify_sdk', { message: 'No provider token in context', sessionId: context.sessionId, hasProviderToken: !!context.providerToken, hasProvider: !!context.provider, }); return null; } // Build access token from context const accessToken: AccessToken = { access_token: providerToken, refresh_token: context.provider?.refreshToken || '', token_type: 'Bearer', expires_in: context.provider?.expiresAt ? Math.max(1, Math.round((context.provider.expiresAt - Date.now()) / 1000)) : 3600, expires: context.provider?.expiresAt || Date.now() + 3600 * 1000, }; const strategy = new ContextAuthStrategy(accessToken, context); return new SpotifyApi(strategy, sdkOptions); } // --------------------------------------------------------------------------- // Context-based Auth Strategy // --------------------------------------------------------------------------- /** * Auth strategy that uses tokens from the request context. * Handles token refresh automatically. */ class ContextAuthStrategy implements IAuthStrategy { private current: AccessToken; private context: ToolContext; constructor(initialToken: AccessToken, context: ToolContext) { this.current = initialToken; this.context = context; } public setConfiguration(_configuration: SdkConfiguration): void { // No-op: not needed for our context-based approach } public async getOrCreateAccessToken(): Promise<AccessToken> { const now = Date.now(); // Check if token is expired or about to expire if (this.current.expires && this.current.expires <= now) { return this.refreshToken(); } // Proactive refresh if within 30 seconds of expiry if (this.current.expires && this.current.expires - now < 30_000) { try { return await this.refreshToken(); } catch (error) { logger.warning('spotify_sdk', { message: 'Silent refresh failed, continuing with existing token', error: (error as Error).message, }); } } return this.current; } public async getAccessToken(): Promise<AccessToken | null> { return this.current; } public removeAccessToken(): void { // No-op: we don't persist tokens in this strategy } private async refreshToken(): Promise<AccessToken> { const refreshToken = this.current.refresh_token || this.context.provider?.refreshToken; if (!refreshToken) { throw new Error('No refresh token available'); } const refreshed = await refreshSpotifyTokens({ refreshToken }); const accessToken = refreshed.access_token?.trim(); if (!accessToken) { throw new Error('Spotify refresh payload missing access_token'); } const newRefreshToken = refreshed.refresh_token?.trim() || refreshToken; const expiresInSeconds = Number(refreshed.expires_in ?? 3600); const expiresAt = Date.now() + expiresInSeconds * 1000; // Update the token store if we have an RS token reference const rsToken = this.context.authHeaders?.authorization?.replace('Bearer ', ''); if (rsToken) { try { const store = getTokenStore(); const record = await store.getByRsAccess(rsToken); if (record) { // Update the token store with refreshed provider tokens await store.storeRsMapping( rsToken, { access_token: accessToken, refresh_token: newRefreshToken, expires_at: expiresAt, }, record.rs_refresh_token, ); } } catch (error) { logger.warning('spotify_sdk', { message: 'Failed to update token store after refresh', error: (error as Error).message, }); } } this.current = { access_token: accessToken, refresh_token: newRefreshToken, token_type: refreshed.token_type ?? 'Bearer', expires_in: expiresInSeconds, expires: expiresAt, }; return this.current; } }

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