Skip to main content
Glama

MCP Claude Spotify

Mozilla Public License 2.0
16
  • Apple
  • Linux
index.js51 kB
import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import dotenv from "dotenv"; import { z } from "zod"; import express from "express"; import axios from "axios"; import querystring from "querystring"; import open from "open"; import net from "net"; import fs from "fs"; import path from "path"; import os from "os"; import { promisify } from "util"; import { exec } from "child_process"; import { ServerAlreadyRunningError } from "./errors.js"; dotenv.config(); const execAsync = promisify(exec); const SPOTIFY_API_BASE = "https://api.spotify.com/v1"; const SPOTIFY_AUTH_BASE = "https://accounts.spotify.com"; const PORT = 8888; const REDIRECT_URI = `http://127.0.0.1:${PORT}/callback`; const CLIENT_ID = process.env.SPOTIFY_CLIENT_ID; const CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET; const TOKEN_DIR = path.join(os.homedir(), '.spotify-mcp'); const TOKEN_PATH = path.join(TOKEN_DIR, 'tokens.json'); /** * Check if a port is already in use * * @param {number} port - The port to check * @returns {Promise<boolean>} - True if the port is in use, false otherwise */ function isPortInUse(port) { return new Promise((resolve) => { const server = net.createServer() .once('error', (err) => { if (err.code === 'EADDRINUSE') { resolve(true); } else { resolve(false); } }) .once('listening', () => { server.close(); resolve(false); }) .listen(port); }); } let accessToken = null; let refreshToken = null; let tokenExpirationTime = 0; let authServer = null; /** * Ensures the token storage directory exists */ function ensureTokenDirExists() { try { if (!fs.existsSync(TOKEN_DIR)) { fs.mkdirSync(TOKEN_DIR, { recursive: true }); } } catch (error) { console.error(`Error creating token directory: ${error}`); } } /** * Saves the tokens to a file * This allows tokens to be shared between different instances of the application */ function saveTokens() { try { console.error(`Attempting to save tokens to ${TOKEN_PATH}`); ensureTokenDirExists(); const tokenData = { accessToken, refreshToken, tokenExpirationTime }; console.error(`Token data to save: { accessToken: ${accessToken ? '***token exists***' : 'null'}, refreshToken: ${refreshToken ? '***token exists***' : 'null'}, tokenExpirationTime: ${tokenExpirationTime} }`); fs.writeFileSync(TOKEN_PATH, JSON.stringify(tokenData, null, 2)); console.error(`Tokens successfully saved to ${TOKEN_PATH}`); } catch (error) { console.error(`Error saving tokens: ${error}`); if (error instanceof Error && error.stack) { console.error(error.stack); } } } /** * Loads tokens from the tokens file * Returns true if tokens were successfully loaded, false otherwise */ function loadTokens() { try { console.error(`Attempting to load tokens from ${TOKEN_PATH}`); if (!fs.existsSync(TOKEN_PATH)) { console.error(`Token file does not exist: ${TOKEN_PATH}`); return false; } const rawData = fs.readFileSync(TOKEN_PATH, 'utf-8'); console.error(`Raw token data loaded (${rawData.length} bytes)`); if (rawData.trim() === '') { console.error(`Token file is empty`); return false; } const tokenData = JSON.parse(rawData); if (!tokenData.accessToken || !tokenData.refreshToken) { console.error(`Token data is incomplete in file`); return false; } accessToken = tokenData.accessToken; refreshToken = tokenData.refreshToken; tokenExpirationTime = tokenData.tokenExpirationTime; console.error(`Tokens loaded successfully: { accessToken: ***token masked***, refreshToken: ***token masked***, tokenExpirationTime: ${tokenExpirationTime} (expires ${new Date(tokenExpirationTime).toISOString()}) }`); return true; } catch (error) { console.error(`Error loading tokens: ${error}`); if (error instanceof Error && error.stack) { console.error(error.stack); } return false; } } const tokensLoaded = loadTokens(); console.error(tokensLoaded ? `Tokens loaded successfully from ${TOKEN_PATH}` : `No tokens found at ${TOKEN_PATH}, will need to authenticate`); /** * Checks if a port is in use * * @param {number} port - The port number to check * @returns {Promise<boolean>} - True if the port is in use, false otherwise */ // Second isPortInUse definition removed to fix duplicate function error const SearchSchema = z.object({ query: z.string(), type: z.enum(["track", "album", "artist", "playlist"]).default("track"), limit: z.number().min(1).max(50).default(10), }); const PlayTrackSchema = z.object({ trackId: z.string(), deviceId: z.string().optional(), }); const CreatePlaylistSchema = z.object({ name: z.string(), description: z.string().optional(), public: z.boolean().default(false), }); const AddTracksSchema = z.object({ playlistId: z.string(), trackIds: z.array(z.string()), }); const GetRecommendationsSchema = z.object({ seedTracks: z.array(z.string()).optional(), seedArtists: z.array(z.string()).optional(), seedGenres: z.array(z.string()).optional(), limit: z.number().min(1).max(100).default(20), }); const GetTopTracksSchema = z.object({ limit: z.number().min(1).max(50).default(20), offset: z.number().min(0).default(0), time_range: z.enum(["short_term", "medium_term", "long_term"]).default("medium_term"), }); const server = new Server({ name: "spotify-mcp", version: "1.0.0", }, { capabilities: { tools: {}, }, }); /** * Ensures a valid access token is available * * This function checks if the current access token is valid, and if not, * attempts to refresh it using the refresh token. If refresh fails or no * refresh token is available, it returns null indicating authentication * is required. * * @returns {Promise<string|null>} The valid access token or null if authentication is needed */ async function ensureToken() { const now = Date.now(); if (!accessToken && !refreshToken) { loadTokens(); } if (accessToken && now < tokenExpirationTime - 60000) { console.error(`Using existing valid token, expires in ${Math.floor((tokenExpirationTime - now) / 1000)} seconds`); return accessToken; } if (refreshToken) { try { const response = await axios.post(`${SPOTIFY_AUTH_BASE}/api/token`, querystring.stringify({ grant_type: "refresh_token", refresh_token: refreshToken, }), { headers: { "Content-Type": "application/x-www-form-urlencoded", Authorization: `Basic ${Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64")}`, }, }); accessToken = response.data.access_token; tokenExpirationTime = now + response.data.expires_in * 1000; if (response.data.refresh_token) { refreshToken = response.data.refresh_token; } saveTokens(); console.error(`Token refreshed successfully, new token expires in ${response.data.expires_in} seconds`); return accessToken; } catch (error) { console.error("Error refreshing token:", error.message); accessToken = null; refreshToken = null; tokenExpirationTime = 0; saveTokens(); } } return null; } /** * Makes an authenticated request to the Spotify API * * Handles token management and formats requests/responses appropriately. * Throws appropriate errors when authentication fails or API requests fail. * * @param {string} endpoint - The Spotify API endpoint (e.g., "/me/playlists") * @param {string} method - HTTP method (GET, POST, PUT, DELETE) * @param {any} data - Optional request body data for POST/PUT requests * @returns {Promise<any>} The API response data * @throws {Error} If authentication is missing or API request fails */ async function spotifyApiRequest(endpoint, method = "GET", data = null) { console.error(`Starting API request to ${endpoint}`); if (!accessToken || !refreshToken) { console.error(`No tokens in memory, trying to load from file...`); try { if (fs.existsSync(TOKEN_PATH)) { const fileContent = fs.readFileSync(TOKEN_PATH, 'utf8'); console.error(`Read ${fileContent.length} bytes from token file`); if (fileContent.trim() !== '') { const tokenData = JSON.parse(fileContent); accessToken = tokenData.accessToken; refreshToken = tokenData.refreshToken; tokenExpirationTime = tokenData.tokenExpirationTime; console.error(`Tokens loaded successfully`); } else { console.error(`Token file is empty, cannot load tokens`); } } else { console.error(`Token file does not exist: ${TOKEN_PATH}`); } } catch (err) { console.error(`Error loading tokens from file: ${err}`); } } if (!accessToken) { console.error(`No access token available for request to ${endpoint}`); throw new Error("Not authenticated. Please authorize the app first."); } const now = Date.now(); if (now >= tokenExpirationTime - 60000) { if (refreshToken) { try { console.error(`Token expired, attempting to refresh...`); const response = await axios.post(`${SPOTIFY_AUTH_BASE}/api/token`, querystring.stringify({ grant_type: "refresh_token", refresh_token: refreshToken, }), { headers: { "Content-Type": "application/x-www-form-urlencoded", Authorization: `Basic ${Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64")}`, }, }); accessToken = response.data.access_token; tokenExpirationTime = now + response.data.expires_in * 1000; if (response.data.refresh_token) { refreshToken = response.data.refresh_token; } console.error(`Token refreshed successfully, expires at ${new Date(tokenExpirationTime).toISOString()}`); try { if (!fs.existsSync(TOKEN_DIR)) { fs.mkdirSync(TOKEN_DIR, { recursive: true }); } const tokenData = JSON.stringify({ accessToken, refreshToken, tokenExpirationTime }, null, 2); fs.writeFileSync(TOKEN_PATH, tokenData); console.error(`Refreshed tokens saved to file`); } catch (saveError) { console.error(`Failed to save refreshed tokens: ${saveError}`); } } catch (refreshError) { console.error(`Failed to refresh token: ${refreshError}`); accessToken = null; refreshToken = null; tokenExpirationTime = 0; throw new Error("Authentication expired. Please authenticate again."); } } else { console.error(`Token expired but no refresh token available`); accessToken = null; tokenExpirationTime = 0; throw new Error("Authentication expired. Please authenticate again."); } } console.error(`Making authenticated request to ${endpoint}`); try { const response = await axios({ method, url: `${SPOTIFY_API_BASE}${endpoint}`, headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", }, data: data ? data : undefined, }); console.error(`Request to ${endpoint} succeeded`); return response.data; } catch (error) { console.error(`Spotify API error: ${error.message}`); if (error.response) { console.error(`Status: ${error.response.status}`); console.error(`Data:`, error.response.data); if (error.response.status === 401) { console.error(`Token appears to be invalid, clearing tokens`); accessToken = null; refreshToken = null; tokenExpirationTime = 0; throw new Error("Authorization expired. Please authenticate again."); } } throw new Error(`Spotify API error: ${error.message}`); } } /** * Starts an authentication server for Spotify OAuth flow * * Creates an Express server to handle the OAuth authentication flow with Spotify. * Provides login and callback endpoints, handles the exchange of authorization code * for access and refresh tokens, and opens the browser for the user to authenticate. * * Handles the case where the server is already running by checking if the port * is already in use. If it is, it attempts to use the existing server by * opening the login page directly. * * @returns {Promise<void>} Resolves when authentication is successful, rejects on failure */ async function startAuthServer() { if (authServer) { console.error("Auth server is already running, opening login page"); await open(`http://127.0.0.1:${PORT}/login`); return Promise.resolve(); } const portInUse = await isPortInUse(PORT); if (portInUse) { console.error(`Port ${PORT} is already in use, attempting to use existing server`); console.error(`Attempting to kill any process on port ${PORT}...`); try { if (process.platform === 'win32') { await execAsync(`FOR /F "tokens=5" %P IN ('netstat -ano ^| findstr :${PORT} ^| findstr LISTENING') DO taskkill /F /PID %P`); } else { await execAsync(`lsof -i:${PORT} -t | xargs kill -9`); } console.error(`Successfully killed process on port ${PORT}`); await new Promise(resolve => setTimeout(resolve, 1000)); const stillInUse = await isPortInUse(PORT); if (stillInUse) { console.error(`Port ${PORT} is still in use after kill attempt`); throw new ServerAlreadyRunningError(PORT); } } catch (killError) { console.error(`Failed to kill process on port ${PORT}: ${killError}`); try { await open(`http://127.0.0.1:${PORT}/login`); return Promise.resolve(); } catch (error) { throw new ServerAlreadyRunningError(PORT); } } } return new Promise((resolve, reject) => { const app = express(); // Login endpoint redirects to Spotify authorization page app.get("/login", (req, res) => { const scopes = [ "user-read-private", "user-read-email", "user-read-playback-state", "user-modify-playback-state", "user-read-currently-playing", "playlist-read-private", "playlist-modify-private", "playlist-modify-public", "user-library-read", "user-top-read", ]; res.redirect(`${SPOTIFY_AUTH_BASE}/authorize?${querystring.stringify({ response_type: "code", client_id: CLIENT_ID, scope: scopes.join(" "), redirect_uri: REDIRECT_URI, })}`); }); app.get("/callback", async (req, res) => { const code = req.query.code || null; if (!code) { res.send("Authentication failed: No code provided"); reject(new Error("Authentication failed: No code provided")); return; } try { console.error(`Received authorization code, exchanging for tokens...`); const response = await axios.post(`${SPOTIFY_AUTH_BASE}/api/token`, querystring.stringify({ code: code, redirect_uri: REDIRECT_URI, grant_type: "authorization_code", }), { headers: { "Content-Type": "application/x-www-form-urlencoded", Authorization: `Basic ${Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64")}`, }, }); console.error(`Token exchange successful, got access_token and refresh_token`); accessToken = response.data.access_token; refreshToken = response.data.refresh_token; tokenExpirationTime = Date.now() + response.data.expires_in * 1000; try { if (!fs.existsSync(TOKEN_DIR)) { console.error(`Creating token directory: ${TOKEN_DIR}`); fs.mkdirSync(TOKEN_DIR, { recursive: true }); } } catch (dirError) { console.error(`CRITICAL ERROR creating token directory: ${dirError}`); } try { const tokenData = JSON.stringify({ accessToken, refreshToken, tokenExpirationTime }, null, 2); console.error(`Writing ${tokenData.length} bytes to ${TOKEN_PATH}`); fs.writeFileSync(TOKEN_PATH, tokenData); if (fs.existsSync(TOKEN_PATH)) { const stats = fs.statSync(TOKEN_PATH); console.error(`Token file successfully written: ${stats.size} bytes`); } else { console.error(`CRITICAL ERROR: Token file not found after writing`); } } catch (fileError) { console.error(`CRITICAL ERROR writing token file: ${fileError}`); } try { console.error(`Verifying tokens with a test API call...`); const verifyResponse = await axios({ method: 'GET', url: `${SPOTIFY_API_BASE}/me`, headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", }, }); console.error(`Token verification successful! Authenticated as: ${verifyResponse.data.display_name}`); } catch (verifyError) { console.error(`Token verification failed: ${verifyError}`); } res.send("Authentication successful! You can close this window now."); resolve(); } catch (error) { console.error("Error getting tokens:", error.message); if (error.response) { console.error("Error response:", error.response.data); } res.send("Authentication failed: " + error.message); reject(error); } }); try { authServer = app.listen(PORT, () => { console.error(`Auth server listening at http://127.0.0.1:${PORT}`); open(`http://127.0.0.1:${PORT}/login`); }); // Handle server errors authServer.on('error', (error) => { if (error.code === 'EADDRINUSE') { console.error(`Port ${PORT} is already in use`); reject(new ServerAlreadyRunningError(PORT)); } else { console.error(`Server error: ${error.message}`); reject(error); } }); // Clean up server when process is about to exit process.on('beforeExit', () => { if (authServer) { console.error('Closing auth server'); authServer.close(); authServer = null; } }); } catch (error) { console.error(`Error starting auth server: ${error.message}`); reject(error); } }); } server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: "auth-spotify", description: "Authenticate with Spotify", inputSchema: { type: "object", properties: {}, }, }, { name: "search-spotify", description: "Search for tracks, albums, artists, or playlists on Spotify", inputSchema: { type: "object", properties: { query: { type: "string", description: "Search query", }, type: { type: "string", enum: ["track", "album", "artist", "playlist"], description: "Type of item to search for (default: track)", }, limit: { type: "number", description: "Maximum number of results to return (1-50, default: 10)", }, }, required: ["query"], }, }, { name: "get-current-playback", description: "Get information about the user's current playback state", inputSchema: { type: "object", properties: {}, }, }, { name: "play-track", description: "Play a specific track on an active device", inputSchema: { type: "object", properties: { trackId: { type: "string", description: "Spotify ID of the track to play", }, deviceId: { type: "string", description: "Spotify ID of the device to play on (optional)", }, }, required: ["trackId"], }, }, { name: "pause-playback", description: "Pause the user's playback", inputSchema: { type: "object", properties: {}, }, }, { name: "next-track", description: "Skip to the next track", inputSchema: { type: "object", properties: {}, }, }, { name: "previous-track", description: "Skip to the previous track", inputSchema: { type: "object", properties: {}, }, }, { name: "get-user-playlists", description: "Get a list of the user's playlists", inputSchema: { type: "object", properties: {}, }, }, { name: "create-playlist", description: "Create a new playlist for the current user", inputSchema: { type: "object", properties: { name: { type: "string", description: "Name of the playlist", }, description: { type: "string", description: "Description of the playlist (optional)", }, public: { type: "boolean", description: "Whether the playlist should be public (default: false)", }, }, required: ["name"], }, }, { name: "add-tracks-to-playlist", description: "Add tracks to a playlist", inputSchema: { type: "object", properties: { playlistId: { type: "string", description: "Spotify ID of the playlist", }, trackIds: { type: "array", items: { type: "string", }, description: "Array of Spotify track IDs to add", }, }, required: ["playlistId", "trackIds"], }, }, { name: "get-recommendations", description: "Get track recommendations based on seeds", inputSchema: { type: "object", properties: { seedTracks: { type: "array", items: { type: "string", }, description: "Array of Spotify track IDs to use as seeds (optional)", }, seedArtists: { type: "array", items: { type: "string", }, description: "Array of Spotify artist IDs to use as seeds (optional)", }, seedGenres: { type: "array", items: { type: "string", }, description: "Array of genre names to use as seeds (optional)", }, limit: { type: "number", description: "Maximum number of tracks to return (1-100, default: 20)", }, }, }, }, { name: "get-top-tracks", description: "Get the user's top played tracks over a specified time range", inputSchema: { type: "object", properties: { limit: { type: "number", description: "The number of tracks to return (1-50, default: 20)", }, offset: { type: "number", description: "The index of the first track to return (default: 0)", }, time_range: { type: "string", enum: ["short_term", "medium_term", "long_term"], description: "Over what time frame the affinities are computed. short_term = ~4 weeks, medium_term = ~6 months, long_term = several years (default: medium_term)", } } } }, ], }; }); server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; try { if (name === "auth-spotify") { try { console.error(`Checking current authentication status...`); try { if (accessToken) { console.error(`We have an access token in memory, testing it...`); try { const testResponse = await axios({ method: 'GET', url: `${SPOTIFY_API_BASE}/me`, headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", }, }); console.error(`Current token is valid! Already authenticated as: ${testResponse.data.display_name}`); return { content: [ { type: "text", text: `Already authenticated with Spotify as ${testResponse.data.display_name}!`, }, ], }; } catch (testError) { console.error(`Current token is invalid, proceeding with authentication flow`); } } else { console.error(`No access token in memory, checking token file...`); try { if (fs.existsSync(TOKEN_PATH)) { console.error(`Token file exists, attempting to load...`); const fileContent = fs.readFileSync(TOKEN_PATH, 'utf8'); if (fileContent && fileContent.trim() !== '') { console.error(`Found token file with content, parsing...`); const tokenData = JSON.parse(fileContent); accessToken = tokenData.accessToken; refreshToken = tokenData.refreshToken; tokenExpirationTime = tokenData.tokenExpirationTime; try { console.error(`Testing loaded token...`); const testResponse = await axios({ method: 'GET', url: `${SPOTIFY_API_BASE}/me`, headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", }, }); console.error(`Loaded token is valid! Authenticated as: ${testResponse.data.display_name}`); return { content: [ { type: "text", text: `Already authenticated with Spotify as ${testResponse.data.display_name}!`, }, ], }; } catch (loadedTokenError) { console.error(`Loaded token is invalid, continuing with auth flow...`); } } } } catch (fileError) { console.error(`Error handling token file: ${fileError}`); } } } catch (testError) { console.error(`Error testing current authentication: ${testError}`); } console.error('Starting authentication process...'); await startAuthServer(); if (!accessToken || !refreshToken) { throw new Error("Authentication failed: No tokens received"); } console.error(`Authentication successful, received tokens`); try { console.error(`Testing new tokens...`); const testResponse = await axios({ method: 'GET', url: `${SPOTIFY_API_BASE}/me`, headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", }, }); console.error(`New tokens are valid! Authenticated as: ${testResponse.data.display_name}`); return { content: [ { type: "text", text: `Successfully authenticated with Spotify as ${testResponse.data.display_name}!`, }, ], }; } catch (newTokenError) { console.error(`New tokens failed verification: ${newTokenError}`); throw new Error("Authentication succeeded but tokens are invalid"); } } catch (error) { if (error instanceof ServerAlreadyRunningError) { console.error(`Server already running error: ${error.message}`); try { if (accessToken) { const testResponse = await axios({ method: 'GET', url: `${SPOTIFY_API_BASE}/me`, headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", }, }); return { content: [ { type: "text", text: `Successfully authenticated with Spotify as ${testResponse.data.display_name}!`, }, ], }; } } catch (testError) { } return { content: [ { type: "text", text: `Another instance is already running on port ${error.port}. If you're having authentication issues, please restart Claude or close any other applications using port ${error.port}.`, }, ], }; } console.error(`Authentication error: ${error.message}`); return { content: [ { type: "text", text: `Authentication failed: ${error.message}`, }, ], }; } } if (name === "search-spotify") { const { query, type, limit } = SearchSchema.parse(args); const results = await spotifyApiRequest(`/search?${querystring.stringify({ q: query, type, limit, })}`); let formattedResults = ""; if (type === "track" && results.tracks) { formattedResults = results.tracks.items .map((track) => ` Track: ${track.name} Artist: ${track.artists.map((a) => a.name).join(", ")} Album: ${track.album.name} ID: ${track.id} Duration: ${Math.floor(track.duration_ms / 1000 / 60)}:${(Math.floor(track.duration_ms / 1000) % 60) .toString() .padStart(2, "0")} URL: ${track.external_urls.spotify} ---`) .join("\n"); } else if (type === "album" && results.albums) { formattedResults = results.albums.items .map((album) => ` Album: ${album.name} Artist: ${album.artists.map((a) => a.name).join(", ")} ID: ${album.id} Release Date: ${album.release_date} Tracks: ${album.total_tracks} URL: ${album.external_urls.spotify} ---`) .join("\n"); } else if (type === "artist" && results.artists) { formattedResults = results.artists.items .map((artist) => ` Artist: ${artist.name} ID: ${artist.id} Popularity: ${artist.popularity}/100 Followers: ${artist.followers?.total || "N/A"} Genres: ${artist.genres?.join(", ") || "None"} URL: ${artist.external_urls.spotify} ---`) .join("\n"); } else if (type === "playlist" && results.playlists) { formattedResults = results.playlists.items .map((playlist) => ` Playlist: ${playlist.name} Creator: ${playlist.owner.display_name} ID: ${playlist.id} Tracks: ${playlist.tracks.total} Description: ${playlist.description || "None"} URL: ${playlist.external_urls.spotify} ---`) .join("\n"); } return { content: [ { type: "text", text: formattedResults || `No ${type}s found matching your search.`, }, ], }; } if (name === "get-current-playback") { const playback = await spotifyApiRequest("/me/player"); if (!playback) { return { content: [ { type: "text", text: "No active playback found. Make sure you have an active Spotify session.", }, ], }; } let responseText = ""; if (playback.item) { responseText = ` Currently ${playback.is_playing ? "Playing" : "Paused"}: Track: ${playback.item.name} Artist: ${playback.item.artists.map((a) => a.name).join(", ")} Album: ${playback.item.album.name} Progress: ${Math.floor(playback.progress_ms / 1000 / 60)}:${(Math.floor(playback.progress_ms / 1000) % 60) .toString() .padStart(2, "0")} / ${Math.floor(playback.item.duration_ms / 1000 / 60)}:${(Math.floor(playback.item.duration_ms / 1000) % 60) .toString() .padStart(2, "0")} Device: ${playback.device.name} Volume: ${playback.device.volume_percent}% Shuffle: ${playback.shuffle_state ? "On" : "Off"} Repeat: ${playback.repeat_state === "off" ? "Off" : playback.repeat_state === "context" ? "Context" : "Track"}`; } else { responseText = ` No track currently playing. Device: ${playback.device.name} Volume: ${playback.device.volume_percent}% Shuffle: ${playback.shuffle_state ? "On" : "Off"} Repeat: ${playback.repeat_state === "off" ? "Off" : playback.repeat_state === "context" ? "Context" : "Track"}`; } return { content: [ { type: "text", text: responseText, }, ], }; } if (name === "play-track") { const { trackId, deviceId } = PlayTrackSchema.parse(args); const endpoint = deviceId ? `/me/player/play?device_id=${deviceId}` : "/me/player/play"; await spotifyApiRequest(endpoint, "PUT", { uris: [`spotify:track:${trackId}`], }); return { content: [ { type: "text", text: `Started playing track with ID: ${trackId}`, }, ], }; } if (name === "pause-playback") { await spotifyApiRequest("/me/player/pause", "PUT"); return { content: [ { type: "text", text: "Playback paused.", }, ], }; } if (name === "next-track") { await spotifyApiRequest("/me/player/next", "POST"); return { content: [ { type: "text", text: "Skipped to next track.", }, ], }; } if (name === "previous-track") { await spotifyApiRequest("/me/player/previous", "POST"); return { content: [ { type: "text", text: "Skipped to previous track.", }, ], }; } if (name === "get-user-playlists") { const playlists = await spotifyApiRequest("/me/playlists"); if (playlists.items.length === 0) { return { content: [ { type: "text", text: "You don't have any playlists.", }, ], }; } const formattedPlaylists = playlists.items .map((playlist) => ` Name: ${playlist.name} ID: ${playlist.id} Owner: ${playlist.owner.display_name} Tracks: ${playlist.tracks.total} Public: ${playlist.public ? "Yes" : "No"} URL: ${playlist.external_urls.spotify} ---`) .join("\n"); return { content: [ { type: "text", text: `Your playlists:\n${formattedPlaylists}`, }, ], }; } if (name === "create-playlist") { const { name, description, public: isPublic } = CreatePlaylistSchema.parse(args); const userInfo = await spotifyApiRequest("/me"); const userId = userInfo.id; const playlist = await spotifyApiRequest(`/users/${userId}/playlists`, "POST", { name, description, public: isPublic, }); return { content: [ { type: "text", text: `Playlist created successfully: Name: ${playlist.name} ID: ${playlist.id} URL: ${playlist.external_urls.spotify}`, }, ], }; } if (name === "add-tracks-to-playlist") { const { playlistId, trackIds } = AddTracksSchema.parse(args); const uris = trackIds.map((id) => `spotify:track:${id}`); await spotifyApiRequest(`/playlists/${playlistId}/tracks`, "POST", { uris, }); return { content: [ { type: "text", text: `Added ${trackIds.length} tracks to playlist with ID: ${playlistId}`, }, ], }; } if (name === "get-recommendations") { const { seedTracks, seedArtists, seedGenres, limit } = GetRecommendationsSchema.parse(args); if (!seedTracks && !seedArtists && !seedGenres) { throw new Error("At least one seed (tracks, artists, or genres) must be provided"); } const params = new URLSearchParams(); if (limit) params.append("limit", limit.toString()); if (seedTracks) params.append("seed_tracks", seedTracks.join(",")); if (seedArtists) params.append("seed_artists", seedArtists.join(",")); if (seedGenres) params.append("seed_genres", seedGenres.join(",")); const recommendations = await spotifyApiRequest(`/recommendations?${params}`); const formattedRecommendations = recommendations.tracks .map((track) => ` Track: ${track.name} Artist: ${track.artists.map((a) => a.name).join(", ")} Album: ${track.album.name} ID: ${track.id} Duration: ${Math.floor(track.duration_ms / 1000 / 60)}:${(Math.floor(track.duration_ms / 1000) % 60) .toString() .padStart(2, "0")} URL: ${track.external_urls.spotify} ---`) .join("\n"); return { content: [ { type: "text", text: recommendations.tracks.length > 0 ? `Recommended tracks:\n${formattedRecommendations}` : "No recommendations found.", }, ], }; } if (name === "get-top-tracks") { const { limit, offset, time_range } = GetTopTracksSchema.parse(args); const params = new URLSearchParams(); params.append("limit", limit.toString()); params.append("offset", offset.toString()); params.append("time_range", time_range); const topTracks = await spotifyApiRequest(`/me/top/tracks?${params}`); const formattedTracks = topTracks.items .map((track) => ` Track: ${track.name} Artist: ${track.artists.map((a) => a.name).join(", ")} Album: ${track.album.name} ID: ${track.id} Duration: ${Math.floor(track.duration_ms / 1000 / 60)}:${(Math.floor(track.duration_ms / 1000) % 60) .toString() .padStart(2, "0")} URL: ${track.external_urls.spotify} ---`) .join("\n"); return { content: [ { type: "text", text: topTracks.items.length > 0 ? `Your top tracks:\n${formattedTracks}` : "No top tracks found for the specified time range.", }, ], }; } throw new Error(`Unknown tool: ${name}`); } catch (error) { if (error instanceof z.ZodError) { throw new Error(`Invalid arguments: ${error.errors .map((e) => `${e.path.join(".")}: ${e.message}`) .join(", ")}`); } throw error; } }); /** * Main application entry point * * Initializes the MCP server and connects it to the stdio transport. * This allows the MCP server to communicate with Claude Desktop. */ async function main() { const transport = new StdioServerTransport(); try { await server.connect(transport); console.error("Spotify MCP Server running on stdio"); // Set up clean shutdown handlers setupCleanupHandlers(); } catch (error) { console.error("Error connecting to transport:", error); throw error; } } /** * Sets up handlers for graceful shutdown and debug signals * * This ensures that the HTTP server is properly closed when * the process is terminated, preventing port conflicts on restart. * Also sets up a SIGUSR1 handler for manually reloading tokens. */ function setupCleanupHandlers() { // Handle process termination process.on('SIGINT', cleanupAndExit); process.on('SIGTERM', cleanupAndExit); process.on('exit', cleanup); // Handle uncaught exceptions process.on('uncaughtException', (error) => { console.error('Uncaught exception:', error); cleanupAndExit(1); }); process.on('SIGUSR1', () => { console.error('SIGUSR1 received - Forcing token reload'); const loaded = loadTokens(); console.error(`Token reload result: ${loaded ? 'SUCCESS' : 'FAILED'}`); console.error(`Current token state: accessToken: ${accessToken ? '***exists***' : 'null'} refreshToken: ${refreshToken ? '***exists***' : 'null'} tokenExpirationTime: ${tokenExpirationTime} ${tokenExpirationTime > 0 ? `(expires: ${new Date(tokenExpirationTime).toISOString()})` : ''} `); }); } /** * Performs cleanup tasks before exiting */ function cleanup() { if (authServer) { console.error('Closing auth server'); authServer.close(); authServer = null; } } /** * Cleans up resources and exits the process * * @param {number} exitCode - The exit code to use (default: 0) */ function cleanupAndExit(exitCode = 0) { console.error('Shutting down...'); cleanup(); process.exit(exitCode); } // Start the application and handle any fatal errors main().catch((error) => { console.error("Fatal error in main():", error); cleanupAndExit(1); });

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/imprvhub/mcp-claude-spotify'

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