Spotify MCP Server

by marcelmarais
Verified
import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js'; import type { MaxInt } from '@spotify/web-api-ts-sdk'; import { z } from 'zod'; import type { SpotifyTrack, tool } from './types.js'; import { formatDuration, handleSpotifyRequest } from './utils.js'; function isTrack(item: any): item is SpotifyTrack { return ( item && item.type === 'track' && Array.isArray(item.artists) && item.album && typeof item.album.name === 'string' ); } const searchSpotify: tool<{ query: z.ZodString; type: z.ZodEnum<['track', 'album', 'artist', 'playlist']>; limit: z.ZodOptional<z.ZodNumber>; }> = { name: 'searchSpotify', description: 'Search for tracks, albums, artists, or playlists on Spotify', schema: { query: z.string().describe('The search query'), type: z .enum(['track', 'album', 'artist', 'playlist']) .describe( 'The type of item to search for either track, album, artist, or playlist', ), limit: z .number() .min(1) .max(50) .optional() .describe('Maximum number of results to return (10-50)'), }, handler: async (args, extra: RequestHandlerExtra) => { const { query, type, limit } = args; const limitValue = limit ?? 10; try { const results = await handleSpotifyRequest(async (spotifyApi) => { return await spotifyApi.search( query, [type], undefined, limitValue as MaxInt<50>, ); }); let formattedResults = ''; if (type === 'track' && results.tracks) { formattedResults = results.tracks.items .map((track, i) => { const artists = track.artists.map((a) => a.name).join(', '); const duration = formatDuration(track.duration_ms); return `${i + 1}. "${ track.name }" by ${artists} (${duration}) - ID: ${track.id}`; }) .join('\n'); } else if (type === 'album' && results.albums) { formattedResults = results.albums.items .map((album, i) => { const artists = album.artists.map((a) => a.name).join(', '); return `${i + 1}. "${album.name}" by ${artists} - ID: ${album.id}`; }) .join('\n'); } else if (type === 'artist' && results.artists) { formattedResults = results.artists.items .map((artist, i) => { return `${i + 1}. ${artist.name} - ID: ${artist.id}`; }) .join('\n'); } else if (type === 'playlist' && results.playlists) { formattedResults = results.playlists.items .map((playlist, i) => { return `${i + 1}. "${playlist?.name ?? 'Unknown Playlist'} (${ playlist?.description ?? 'No description' } tracks)" by ${playlist?.owner?.display_name} - ID: ${ playlist?.id }`; }) .join('\n'); } return { content: [ { type: 'text', text: formattedResults.length > 0 ? `# Search results for "${query}" (type: ${type})\n\n${formattedResults}` : `No ${type} results found for "${query}"`, }, ], }; } catch (error) { return { content: [ { type: 'text', text: `Error searching for ${type}s: ${ error instanceof Error ? error.message : String(error) }`, }, ], }; } }, }; const getNowPlaying: tool<Record<string, never>> = { name: 'getNowPlaying', description: 'Get information about the currently playing track on Spotify', schema: {}, handler: async (args, extra: RequestHandlerExtra) => { try { const currentTrack = await handleSpotifyRequest(async (spotifyApi) => { return await spotifyApi.player.getCurrentlyPlayingTrack(); }); if (!currentTrack || !currentTrack.item) { return { content: [ { type: 'text', text: 'Nothing is currently playing on Spotify', }, ], }; } const item = currentTrack.item; if (!isTrack(item)) { return { content: [ { type: 'text', text: 'Currently playing item is not a track (might be a podcast episode)', }, ], }; } const artists = item.artists.map((a) => a.name).join(', '); const album = item.album.name; const duration = formatDuration(item.duration_ms); const progress = formatDuration(currentTrack.progress_ms || 0); const isPlaying = currentTrack.is_playing; return { content: [ { type: 'text', text: `# Currently ${isPlaying ? 'Playing' : 'Paused'}\n\n` + `**Track**: "${item.name}"\n` + `**Artist**: ${artists}\n` + `**Album**: ${album}\n` + `**Progress**: ${progress} / ${duration}\n` + `**ID**: ${item.id}`, }, ], }; } catch (error) { return { content: [ { type: 'text', text: `Error getting current track: ${ error instanceof Error ? error.message : String(error) }`, }, ], }; } }, }; const getMyPlaylists: tool<{ limit: z.ZodOptional<z.ZodNumber>; }> = { name: 'getMyPlaylists', description: "Get a list of the current user's playlists on Spotify", schema: { limit: z .number() .min(1) .max(50) .optional() .describe('Maximum number of playlists to return (1-50)'), }, handler: async (args, extra: RequestHandlerExtra) => { const { limit = 50 } = args; const playlists = await handleSpotifyRequest(async (spotifyApi) => { return await spotifyApi.currentUser.playlists.playlists( limit as MaxInt<50>, ); }); if (playlists.items.length === 0) { return { content: [ { type: 'text', text: "You don't have any playlists on Spotify", }, ], }; } const formattedPlaylists = playlists.items .map((playlist, i) => { const tracksTotal = playlist.tracks?.total ? playlist.tracks.total : 0; return `${i + 1}. "${playlist.name}" (${tracksTotal} tracks) - ID: ${ playlist.id }`; }) .join('\n'); return { content: [ { type: 'text', text: `# Your Spotify Playlists\n\n${formattedPlaylists}`, }, ], }; }, }; const getPlaylistTracks: tool<{ playlistId: z.ZodString; limit: z.ZodOptional<z.ZodNumber>; }> = { name: 'getPlaylistTracks', description: 'Get a list of tracks in a Spotify playlist', schema: { playlistId: z.string().describe('The Spotify ID of the playlist'), limit: z .number() .min(1) .max(50) .optional() .describe('Maximum number of tracks to return (1-50)'), }, handler: async (args, extra: RequestHandlerExtra) => { const { playlistId, limit = 50 } = args; const playlistTracks = await handleSpotifyRequest(async (spotifyApi) => { return await spotifyApi.playlists.getPlaylistItems( playlistId, undefined, undefined, limit as MaxInt<50>, ); }); if ((playlistTracks.items?.length ?? 0) === 0) { return { content: [ { type: 'text', text: "This playlist doesn't have any tracks", }, ], }; } const formattedTracks = playlistTracks.items .map((item, i) => { const { track } = item; if (!track) return `${i + 1}. [Removed track]`; if (isTrack(track)) { const artists = track.artists.map((a) => a.name).join(', '); const duration = formatDuration(track.duration_ms); return `${i + 1}. "${track.name}" by ${artists} (${duration}) - ID: ${track.id}`; } return `${i + 1}. Unknown item`; }) .join('\n'); return { content: [ { type: 'text', text: `# Tracks in Playlist\n\n${formattedTracks}`, }, ], }; }, }; export const readTools = [ searchSpotify, getNowPlaying, getMyPlaylists, getPlaylistTracks, ];