# Directory Structure ``` ├── .DS_Store ├── .gitignore ├── build │ ├── spotify-mcp-sse.js │ └── spotify-mcp.js ├── Dockerfile ├── mcp │ ├── spotify-mcp-http.js │ ├── spotify-mcp-oauth-http.js │ ├── spotify-mcp-sse.ts │ └── spotify-mcp.ts ├── package.json ├── Readme.md ├── smithery.yaml ├── spotify-auth.js └── tsconfig.json ``` # Files -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` node_modules secrets.json package-lock.json .env ``` -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- ```markdown # Spotify MCP Server A simple Model Context Protocol (MCP) server that lets you interact with Spotify through Claude. This server enables Claude to search for songs, create playlists, get recommendations, and more using your Spotify account. ## Features - Search for tracks on Spotify - View your Spotify profile - Create playlists - Add tracks to playlists - Get personalized music recommendations ## Tools Available | Tool Name | Description | | -------------------------- | -------------------------------------------------------- | | `set-spotify-credentials` | Set your Spotify authentication credentials | | `check-credentials-status` | Check if your credentials are valid and who is logged in | | `search-tracks` | Search for tracks by name, artist, or keywords | | `get-current-user` | Get your Spotify profile information | | `create-playlist` | Create a new playlist on your account | | `add-tracks-to-playlist` | Add tracks to an existing playlist | | `get-recommendations` | Get recommendations based on seed tracks | ## Setup Instructions ## Plug and Play - HTTP Spotify MCP Server 1. Go to [Claude AI](https://claude.ai/) 2. Click on Add Connectors 3. Click on Manage Connectors 4. Add custom connector 5. Add the https server link 6. Mail [email protected] to whitelist your spotify ID/mail 7. That's it. Your claude is ready to use Spotify. You will be prompted to sign-in using spotify OAuth. ## Run Spotify MCP locally ### 1. Prerequisites - Node.js v16 or higher - npm - A Spotify account - A registered Spotify Developer application ### 2. Create a Spotify Developer App 1. Go to [Spotify Developer Dashboard](https://developer.spotify.com/dashboard/) 2. Log in with your Spotify account 3. Click "Create an App" 4. Fill in the app name and description 5. Add `http://localhost:8888/callback` as a Redirect URI 6. Note your Client ID and Client Secret ### 3. Install the Project ```bash # Clone or download the project first cd spotify-mcp-server # Install dependencies npm install ``` ### 4. Get Your Spotify Tokens Edit the `spotify-auth.js` file to include your Client ID and Client Secret: ```javascript // Replace these with your Spotify app credentials const CLIENT_ID = "your_client_id_here"; const CLIENT_SECRET = "your_client_secret_here"; ``` Then run the authentication script: ```bash node spotify-auth.js ``` This will: 1. Open a URL in your browser 2. Prompt you to log in to Spotify 3. Ask for your permission to access your account 4. Save the tokens to `secrets.json` ### 5. Build the MCP Server ```bash npm run build ``` ### 6. Configure Claude Desktop Edit your Claude Desktop configuration file: - On macOS: `~/Library/Application Support/Claude/claude_desktop_config.json` - On Windows: `%APPDATA%\Claude\claude_desktop_config.json` Add the following configuration: ```json { "mcpServers": { "spotify": { "command": "node", "args": ["/full/path/to/spotify-mcp-server/build/spotify-mcp-server.js"] } } } ``` Replace `/full/path/to/spotify-mcp-server` with the actual path to your project directory. ### 7. Restart Claude Desktop Close and reopen Claude Desktop to load the new configuration. ## Usage When you start a conversation with Claude, you'll first need to set your Spotify credentials: 1. Look at your `secrets.json` file to get your credentials 2. Use the `set-spotify-credentials` tool to authenticate 3. Then use any of the other Spotify tools ## Example Prompts ### Setting Up Credentials ``` I want to connect to my Spotify account. Here are my credentials from secrets.json: Tool: set-spotify-credentials Parameters: { "clientId": "your_client_id", "clientSecret": "your_client_secret", "accessToken": "your_access_token", "refreshToken": "your_refresh_token" } ``` ### Basic Commands Check your account: ``` Can you check who I'm logged in as on Spotify? Tool: get-current-user Parameters: {} ``` Search for tracks: ``` Search for songs by Weekend Tool: search-tracks Parameters: { "query": "Taylor Swift", "limit": 5 } ``` Create a playlist: ``` Create a new playlist called "My Pretty pretty girlfriend" Tool: create-playlist Parameters: { "name": "My Pretty pretty girlfriend", "description": "For my girlfriend. Created with Claude and the Spotify MCP server" } ``` ### Multi-Step Tasks Creating a playlist with songs: ``` I want to create a workout playlist with energetic songs. First, search for some high-energy songs. Then create a playlist called "Workout Mix" and add those songs to it. ``` Getting recommendations based on favorites: ``` I like the song "Blinding Lights" by The Weeknd. Can you search for it, then find similar songs, and create a playlist with those recommendations? ``` ## Troubleshooting - **Error: No access token available**: You need to set your credentials first using the `set-spotify-credentials` tool - **Authentication failures**: Your tokens may have expired. Run the auth script again to get fresh tokens - **Invalid credentials**: Double check that you're using the correct Client ID and Client Secret ## Notes - The server stores credentials in memory only - You'll need to set credentials each time you start a new conversation - If Claude Desktop restarts, you'll need to set credentials again ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json { "compilerOptions": { "target": "ES2022", "module": "Node16", "moduleResolution": "Node16", "outDir": "./build", "rootDir": "./mcp", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true }, "include": ["*.ts", "mcp/spotify-mcp.ts", "mcp/spotify-mcp-sse.ts"], "exclude": ["node_modules"] } ``` -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- ```dockerfile # Generated by https://smithery.ai. See: https://smithery.ai/docs/config#dockerfile FROM node:lts-alpine WORKDIR /app # Copy package.json and lock file if available COPY package.json package-lock.json* ./ # Install dependencies without running scripts RUN npm install --ignore-scripts # Copy source files COPY . . # Build the TypeScript code RUN npm run build CMD [ "node", "build/spotify-mcp-server.js" ] ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json { "name": "spotify-mcp-server", "version": "1.0.0", "description": "MCP server for Spotify API integration", "type": "module", "main": "build/spotify-mcp.js", "scripts": { "auth": "node spotify-auth.js", "build": "tsc", "start": "node build/spotify-mcp-http.js" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.16.0", "@types/cors": "^2.8.18", "@types/express": "^5.0.1", "axios": "^1.6.2", "cors": "^2.8.5", "express": "^4.21.2", "node-fetch": "^3.3.2", "zod": "^3.24.2" }, "devDependencies": { "@types/node": "^22.14.0", "typescript": "^5.8.3" } } ``` -------------------------------------------------------------------------------- /smithery.yaml: -------------------------------------------------------------------------------- ```yaml # Smithery configuration file: https://smithery.ai/docs/config#smitheryyaml startCommand: type: stdio configSchema: # JSON Schema defining the configuration options for the MCP. type: object required: [] properties: clientId: type: string description: Spotify Client ID clientSecret: type: string description: Spotify Client Secret accessToken: type: string description: Spotify Access Token refreshToken: type: string description: Spotify Refresh Token commandFunction: # A JS function that produces the CLI command based on the given config to start the MCP on stdio. |- (config) => { const env = {}; if (config.clientId) { env.SPOTIFY_CLIENT_ID = config.clientId; } if (config.clientSecret) { env.SPOTIFY_CLIENT_SECRET = config.clientSecret; } if (config.accessToken) { env.SPOTIFY_ACCESS_TOKEN = config.accessToken; } if (config.refreshToken) { env.SPOTIFY_REFRESH_TOKEN = config.refreshToken; } return { command: "node", args: ["build/spotify-mcp-server.js"], env: env }; } exampleConfig: clientId: your-spotify-client-id clientSecret: your-spotify-client-secret accessToken: your-initial-access-token refreshToken: your-initial-refresh-token ``` -------------------------------------------------------------------------------- /spotify-auth.js: -------------------------------------------------------------------------------- ```javascript import express from "express"; import axios from "axios"; import fs from "fs"; // Replace these with your Spotify app credentials const CLIENT_ID = process.env.SPOTIFY_CLIENT_ID; const CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET; const REDIRECT_URI = process.env.SPOTIFY_REDIRECT_URI; const app = express(); const PORT = 8888; app.get("/login", (req, res) => { res.redirect( `https://accounts.spotify.com/authorize?client_id=${CLIENT_ID}&response_type=code&redirect_uri=${REDIRECT_URI}&scope=user-read-private%20user-read-email%20playlist-read-private%20playlist-modify-private%20playlist-modify-public` ); }); app.get("/callback", async (req, res) => { const code = req.query.code; try { const tokenResponse = await axios({ method: "post", url: "https://accounts.spotify.com/api/token", params: { code: code, redirect_uri: REDIRECT_URI, grant_type: "authorization_code", }, headers: { Authorization: "Basic " + Buffer.from(CLIENT_ID + ":" + CLIENT_SECRET).toString("base64"), "Content-Type": "application/x-www-form-urlencoded", }, }); // Save tokens to secrets.json file const tokens = { clientId: CLIENT_ID, clientSecret: CLIENT_SECRET, accessToken: tokenResponse.data.access_token, refreshToken: tokenResponse.data.refresh_token, }; fs.writeFileSync("secrets.json", JSON.stringify(tokens, null, 2)); res.send("Authentication successful! Tokens saved to secrets.json"); setTimeout(() => { server.close(); console.log( "Tokens saved to secrets.json. You can now close this window." ); }, 3000); } catch (error) { res.send("Error during authentication: " + error.message); } }); const server = app.listen(PORT, () => { console.log( `Please open http://localhost:${PORT}/login in your browser to authenticate` ); }); ``` -------------------------------------------------------------------------------- /mcp/spotify-mcp.ts: -------------------------------------------------------------------------------- ```typescript import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; import fetch from "node-fetch"; const server = new McpServer({ name: "SpotifyServer", version: "1.0.0", capabilities: { tools: {}, }, }); let spotifyAuthInfo = { accessToken: "", refreshToken: "", clientId: "", clientSecret: "", }; // Refresh token when needed async function getValidAccessToken() { if (!spotifyAuthInfo.accessToken || !spotifyAuthInfo.refreshToken) { throw new Error( "No access token available. Please set credentials first using the set-spotify-credentials tool." ); } try { // Try using current token const response = await fetch("https://api.spotify.com/v1/me", { headers: { Authorization: `Bearer ${spotifyAuthInfo.accessToken}`, }, }); // If token works, return it if (response.ok) { return spotifyAuthInfo.accessToken; } console.error("Access token expired, refreshing..."); // If token doesn't work, refresh it const refreshResponse = await fetch( "https://accounts.spotify.com/api/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", Authorization: "Basic " + Buffer.from( spotifyAuthInfo.clientId + ":" + spotifyAuthInfo.clientSecret ).toString("base64"), }, body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: spotifyAuthInfo.refreshToken, }), } ); const data = (await refreshResponse.json()) as any; if (data.access_token) { console.error("Successfully refreshed access token"); spotifyAuthInfo.accessToken = data.access_token; return spotifyAuthInfo.accessToken; } throw new Error("Failed to refresh access token"); } catch (error) { throw new Error( "Error with access token: " + (error instanceof Error ? error.message : String(error)) ); } } // Set credentials tool server.tool( "set-spotify-credentials", { clientId: z.string().describe("The Spotify Client ID"), clientSecret: z.string().describe("The Spotify Client Secret"), accessToken: z.string().describe("The Spotify Access Token"), refreshToken: z.string().describe("The Spotify Refresh Token"), }, async ({ clientId, clientSecret, accessToken, refreshToken }) => { spotifyAuthInfo.clientId = clientId; spotifyAuthInfo.clientSecret = clientSecret; spotifyAuthInfo.accessToken = accessToken; spotifyAuthInfo.refreshToken = refreshToken; return { content: [ { type: "text", text: "Spotify credentials set successfully. You can now use other Spotify tools.", }, ], }; } ); // Check credentials tool server.tool("check-credentials-status", {}, async () => { if ( !spotifyAuthInfo.accessToken || !spotifyAuthInfo.refreshToken || !spotifyAuthInfo.clientId || !spotifyAuthInfo.clientSecret ) { return { content: [ { type: "text", text: "Spotify credentials are not set. Please use the set-spotify-credentials tool.", }, ], }; } try { const accessToken = await getValidAccessToken(); const response = await fetch("https://api.spotify.com/v1/me", { headers: { Authorization: `Bearer ${accessToken}`, }, }); if (response.ok) { const userData = (await response.json()) as any; return { content: [ { type: "text", text: `Spotify credentials are valid.\nLogged in as: ${ userData.display_name } (${userData.email || "email not available"})`, }, ], }; } else { return { content: [ { type: "text", text: `Spotify credentials may be invalid. Status code: ${response.status}`, }, ], isError: true, }; } } catch (error) { return { content: [ { type: "text", text: `Error checking credentials: ${ error instanceof Error ? error.message : String(error) }`, }, ], isError: true, }; } }); // Search tracks server.tool( "search-tracks", { query: z.string().describe("Search query for tracks"), limit: z .number() .min(1) .max(50) .default(10) .describe("Number of results to return"), }, async ({ query, limit }) => { try { const accessToken = await getValidAccessToken(); const response = await fetch( `https://api.spotify.com/v1/search?q=${encodeURIComponent( query )}&type=track&limit=${limit}`, { headers: { Authorization: `Bearer ${accessToken}`, }, } ); const data = (await response.json()) as any; if (!response.ok) { return { content: [ { type: "text", text: `Error searching tracks: ${JSON.stringify(data)}`, }, ], isError: true, }; } const tracks = data.tracks.items.map((track: any) => ({ id: track.id, name: track.name, artist: track.artists.map((artist: any) => artist.name).join(", "), album: track.album.name, uri: track.uri, })); return { content: [ { type: "text", text: JSON.stringify(tracks, null, 2), }, ], }; } catch (error) { return { content: [ { type: "text", text: `Failed to search tracks: ${ error instanceof Error ? error.message : String(error) }`, }, ], isError: true, }; } } ); // Get current user server.tool("get-current-user", {}, async () => { try { const accessToken = await getValidAccessToken(); const response = await fetch("https://api.spotify.com/v1/me", { headers: { Authorization: `Bearer ${accessToken}`, }, }); const data = (await response.json()) as any; if (!response.ok) { return { content: [ { type: "text", text: `Error getting user profile: ${JSON.stringify(data)}`, }, ], isError: true, }; } return { content: [ { type: "text", text: JSON.stringify( { id: data.id, name: data.display_name, email: data.email, country: data.country, }, null, 2 ), }, ], }; } catch (error) { return { content: [ { type: "text", text: `Failed to get user profile: ${ error instanceof Error ? error.message : String(error) }`, }, ], isError: true, }; } }); // Create playlist server.tool( "create-playlist", { name: z.string().describe("Name of the playlist"), description: z.string().optional().describe("Description of the playlist"), }, async ({ name, description = "" }) => { try { const accessToken = await getValidAccessToken(); // Get user ID const userResponse = await fetch("https://api.spotify.com/v1/me", { headers: { Authorization: `Bearer ${accessToken}`, }, }); const userData = (await userResponse.json()) as any; const userId = userData.id; // Create playlist const response = await fetch( `https://api.spotify.com/v1/users/${userId}/playlists`, { method: "POST", headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", }, body: JSON.stringify({ name, description, public: false, }), } ); const data = (await response.json()) as any; if (!response.ok) { return { content: [ { type: "text", text: `Error creating playlist: ${JSON.stringify(data)}`, }, ], isError: true, }; } return { content: [ { type: "text", text: `Playlist created successfully!\nName: ${data.name}\nID: ${data.id}\nURL: ${data.external_urls.spotify}`, }, ], }; } catch (error) { return { content: [ { type: "text", text: `Failed to create playlist: ${ error instanceof Error ? error.message : String(error) }`, }, ], isError: true, }; } } ); // Add tracks to playlist server.tool( "add-tracks-to-playlist", { playlistId: z.string().describe("The Spotify playlist ID"), trackUris: z .array(z.string()) .describe("Array of Spotify track URIs to add"), }, async ({ playlistId, trackUris }) => { try { const accessToken = await getValidAccessToken(); const response = await fetch( `https://api.spotify.com/v1/playlists/${playlistId}/tracks`, { method: "POST", headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", }, body: JSON.stringify({ uris: trackUris, }), } ); const data = (await response.json()) as any; if (!response.ok) { return { content: [ { type: "text", text: `Error adding tracks: ${JSON.stringify(data)}`, }, ], isError: true, }; } return { content: [ { type: "text", text: `Successfully added ${trackUris.length} track(s) to playlist!`, }, ], }; } catch (error) { return { content: [ { type: "text", text: `Failed to add tracks: ${ error instanceof Error ? error.message : String(error) }`, }, ], isError: true, }; } } ); // Get recommendations server.tool( "get-recommendations", { seedTracks: z .array(z.string()) .max(5) .describe("Spotify track IDs to use as seeds (max 5)"), limit: z .number() .min(1) .max(100) .default(20) .describe("Number of recommendations to return"), }, async ({ seedTracks, limit }) => { try { const accessToken = await getValidAccessToken(); if (seedTracks.length === 0) { return { content: [ { type: "text", text: "Error: At least one seed track is required", }, ], isError: true, }; } const response = await fetch( `https://api.spotify.com/v1/recommendations?seed_tracks=${seedTracks.join( "," )}&limit=${limit}`, { headers: { Authorization: `Bearer ${accessToken}`, }, } ); const data = (await response.json()) as any; if (!response.ok) { return { content: [ { type: "text", text: `Error getting recommendations: ${JSON.stringify(data)}`, }, ], isError: true, }; } const tracks = data.tracks.map((track: any) => ({ id: track.id, name: track.name, artist: track.artists.map((artist: any) => artist.name).join(", "), album: track.album.name, uri: track.uri, })); return { content: [ { type: "text", text: JSON.stringify(tracks, null, 2), }, ], }; } catch (error) { return { content: [ { type: "text", text: `Failed to get recommendations: ${ error instanceof Error ? error.message : String(error) }`, }, ], isError: true, }; } } ); // Start the server async function main() { const transport = new StdioServerTransport(); await server.connect(transport); console.error("Spotify MCP Server running on stdio"); console.error( "No credentials are pre-loaded. Users must set credentials with set-spotify-credentials tool." ); } main().catch((error) => { console.error("Fatal error:", error); process.exit(1); }); ``` -------------------------------------------------------------------------------- /mcp/spotify-mcp-sse.ts: -------------------------------------------------------------------------------- ```typescript import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; import { z } from "zod"; import fetch from "node-fetch"; import express from "express"; import cors from "cors"; const app = express(); app.use( cors({ origin: "*", methods: ["GET", "POST"], allowedHeaders: ["Content-Type"], }) ); const server = new McpServer({ name: "SpotifyServer", version: "1.0.0", capabilities: { tools: {}, }, }); const transports = {}; let spotifyAuthInfo = { accessToken: "", refreshToken: "", clientId: "", clientSecret: "", }; // Refresh token when needed async function getValidAccessToken() { if (!spotifyAuthInfo.accessToken || !spotifyAuthInfo.refreshToken) { throw new Error( "No access token available. Please set credentials first using the set-spotify-credentials tool." ); } try { // Try using current token const response = await fetch("https://api.spotify.com/v1/me", { headers: { Authorization: `Bearer ${spotifyAuthInfo.accessToken}`, }, }); // If token works, return it if (response.ok) { return spotifyAuthInfo.accessToken; } console.error("Access token expired, refreshing..."); // If token doesn't work, refresh it const refreshResponse = await fetch( "https://accounts.spotify.com/api/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", Authorization: "Basic " + Buffer.from( spotifyAuthInfo.clientId + ":" + spotifyAuthInfo.clientSecret ).toString("base64"), }, body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: spotifyAuthInfo.refreshToken, }), } ); const data = (await refreshResponse.json()) as any; if (data.access_token) { console.error("Successfully refreshed access token"); spotifyAuthInfo.accessToken = data.access_token; return spotifyAuthInfo.accessToken; } throw new Error("Failed to refresh access token"); } catch (error) { throw new Error( "Error with access token: " + (error instanceof Error ? error.message : String(error)) ); } } // Set credentials tool server.tool( "set-spotify-credentials", { clientId: z.string().describe("The Spotify Client ID"), clientSecret: z.string().describe("The Spotify Client Secret"), accessToken: z.string().describe("The Spotify Access Token"), refreshToken: z.string().describe("The Spotify Refresh Token"), }, async ({ clientId, clientSecret, accessToken, refreshToken }) => { spotifyAuthInfo.clientId = clientId; spotifyAuthInfo.clientSecret = clientSecret; spotifyAuthInfo.accessToken = accessToken; spotifyAuthInfo.refreshToken = refreshToken; return { content: [ { type: "text", text: "Spotify credentials set successfully. You can now use other Spotify tools.", }, ], }; } ); // Check credentials tool server.tool("check-credentials-status", {}, async () => { if ( !spotifyAuthInfo.accessToken || !spotifyAuthInfo.refreshToken || !spotifyAuthInfo.clientId || !spotifyAuthInfo.clientSecret ) { return { content: [ { type: "text", text: "Spotify credentials are not set. Please use the set-spotify-credentials tool.", }, ], }; } try { const accessToken = await getValidAccessToken(); const response = await fetch("https://api.spotify.com/v1/me", { headers: { Authorization: `Bearer ${accessToken}`, }, }); if (response.ok) { const userData = (await response.json()) as any; return { content: [ { type: "text", text: `Spotify credentials are valid.\nLogged in as: ${ userData.display_name } (${userData.email || "email not available"})`, }, ], }; } else { return { content: [ { type: "text", text: `Spotify credentials may be invalid. Status code: ${response.status}`, }, ], isError: true, }; } } catch (error) { return { content: [ { type: "text", text: `Error checking credentials: ${ error instanceof Error ? error.message : String(error) }`, }, ], isError: true, }; } }); // Search tracks server.tool( "search-tracks", { query: z.string().describe("Search query for tracks"), limit: z .number() .min(1) .max(50) .default(10) .describe("Number of results to return"), }, async ({ query, limit }) => { try { const accessToken = await getValidAccessToken(); const response = await fetch( `https://api.spotify.com/v1/search?q=${encodeURIComponent( query )}&type=track&limit=${limit}`, { headers: { Authorization: `Bearer ${accessToken}`, }, } ); const data = (await response.json()) as any; if (!response.ok) { return { content: [ { type: "text", text: `Error searching tracks: ${JSON.stringify(data)}`, }, ], isError: true, }; } const tracks = data.tracks.items.map((track: any) => ({ id: track.id, name: track.name, artist: track.artists.map((artist: any) => artist.name).join(", "), album: track.album.name, uri: track.uri, })); return { content: [ { type: "text", text: JSON.stringify(tracks, null, 2), }, ], }; } catch (error) { return { content: [ { type: "text", text: `Failed to search tracks: ${ error instanceof Error ? error.message : String(error) }`, }, ], isError: true, }; } } ); // Get current user server.tool("get-current-user", {}, async () => { try { const accessToken = await getValidAccessToken(); const response = await fetch("https://api.spotify.com/v1/me", { headers: { Authorization: `Bearer ${accessToken}`, }, }); const data = (await response.json()) as any; if (!response.ok) { return { content: [ { type: "text", text: `Error getting user profile: ${JSON.stringify(data)}`, }, ], isError: true, }; } return { content: [ { type: "text", text: JSON.stringify( { id: data.id, name: data.display_name, email: data.email, country: data.country, }, null, 2 ), }, ], }; } catch (error) { return { content: [ { type: "text", text: `Failed to get user profile: ${ error instanceof Error ? error.message : String(error) }`, }, ], isError: true, }; } }); // Create playlist server.tool( "create-playlist", { name: z.string().describe("Name of the playlist"), description: z.string().optional().describe("Description of the playlist"), }, async ({ name, description = "" }) => { try { const accessToken = await getValidAccessToken(); // Get user ID const userResponse = await fetch("https://api.spotify.com/v1/me", { headers: { Authorization: `Bearer ${accessToken}`, }, }); const userData = (await userResponse.json()) as any; const userId = userData.id; // Create playlist const response = await fetch( `https://api.spotify.com/v1/users/${userId}/playlists`, { method: "POST", headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", }, body: JSON.stringify({ name, description, public: false, }), } ); const data = (await response.json()) as any; if (!response.ok) { return { content: [ { type: "text", text: `Error creating playlist: ${JSON.stringify(data)}`, }, ], isError: true, }; } return { content: [ { type: "text", text: `Playlist created successfully!\nName: ${data.name}\nID: ${data.id}\nURL: ${data.external_urls.spotify}`, }, ], }; } catch (error) { return { content: [ { type: "text", text: `Failed to create playlist: ${ error instanceof Error ? error.message : String(error) }`, }, ], isError: true, }; } } ); // Add tracks to playlist server.tool( "add-tracks-to-playlist", { playlistId: z.string().describe("The Spotify playlist ID"), trackUris: z .array(z.string()) .describe("Array of Spotify track URIs to add"), }, async ({ playlistId, trackUris }) => { try { const accessToken = await getValidAccessToken(); const response = await fetch( `https://api.spotify.com/v1/playlists/${playlistId}/tracks`, { method: "POST", headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", }, body: JSON.stringify({ uris: trackUris, }), } ); const data = (await response.json()) as any; if (!response.ok) { return { content: [ { type: "text", text: `Error adding tracks: ${JSON.stringify(data)}`, }, ], isError: true, }; } return { content: [ { type: "text", text: `Successfully added ${trackUris.length} track(s) to playlist!`, }, ], }; } catch (error) { return { content: [ { type: "text", text: `Failed to add tracks: ${ error instanceof Error ? error.message : String(error) }`, }, ], isError: true, }; } } ); // Get recommendations server.tool( "get-recommendations", { seedTracks: z .array(z.string()) .max(5) .describe("Spotify track IDs to use as seeds (max 5)"), limit: z .number() .min(1) .max(100) .default(20) .describe("Number of recommendations to return"), }, async ({ seedTracks, limit }) => { try { const accessToken = await getValidAccessToken(); if (seedTracks.length === 0) { return { content: [ { type: "text", text: "Error: At least one seed track is required", }, ], isError: true, }; } const response = await fetch( `https://api.spotify.com/v1/recommendations?seed_tracks=${seedTracks.join( "," )}&limit=${limit}`, { headers: { Authorization: `Bearer ${accessToken}`, }, } ); const data = (await response.json()) as any; if (!response.ok) { return { content: [ { type: "text", text: `Error getting recommendations: ${JSON.stringify(data)}`, }, ], isError: true, }; } const tracks = data.tracks.map((track: any) => ({ id: track.id, name: track.name, artist: track.artists.map((artist: any) => artist.name).join(", "), album: track.album.name, uri: track.uri, })); return { content: [ { type: "text", text: JSON.stringify(tracks, null, 2), }, ], }; } catch (error) { return { content: [ { type: "text", text: `Failed to get recommendations: ${ error instanceof Error ? error.message : String(error) }`, }, ], isError: true, }; } } ); let transport: SSEServerTransport | null = null; app.get("/sse", (req, res) => { transport = new SSEServerTransport("/messages", res); server.connect(transport); }); app.post("/messages", (req, res) => { if (transport) { transport.handlePostMessage(req, res); } else { res.status(503).send("No active transport"); } }); app.listen(3001, () => { console.log("Listening on port 3001"); }); ``` -------------------------------------------------------------------------------- /mcp/spotify-mcp-http.js: -------------------------------------------------------------------------------- ```javascript import express from "express"; import cors from "cors"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { z } from "zod"; import fetch from "node-fetch"; // GLOBAL credentials storage (shared across all requests) let globalSpotifyAuthInfo = { accessToken: "", refreshToken: "", clientId: "", clientSecret: "", }; // Helper function to create server instance function createSpotifyMcpServer() { const server = new McpServer({ name: "SpotifyServer", version: "1.0.0", capabilities: { tools: {}, }, }); // Refresh token when needed async function getValidAccessToken() { if ( !globalSpotifyAuthInfo.accessToken || !globalSpotifyAuthInfo.refreshToken ) { throw new Error( "No access token available. Please set credentials first using the set-spotify-credentials tool." ); } try { // Try using current token const response = await fetch("https://api.spotify.com/v1/me", { headers: { Authorization: `Bearer ${globalSpotifyAuthInfo.accessToken}`, }, }); // If token works, return it if (response.ok) { return globalSpotifyAuthInfo.accessToken; } console.log("Access token expired, refreshing..."); // If token doesn't work, refresh it const refreshResponse = await fetch( "https://accounts.spotify.com/api/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", Authorization: "Basic " + Buffer.from( globalSpotifyAuthInfo.clientId + ":" + globalSpotifyAuthInfo.clientSecret ).toString("base64"), }, body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: globalSpotifyAuthInfo.refreshToken, }), } ); const data = await refreshResponse.json(); if (data.access_token) { console.log("Successfully refreshed access token"); globalSpotifyAuthInfo.accessToken = data.access_token; return globalSpotifyAuthInfo.accessToken; } throw new Error("Failed to refresh access token"); } catch (error) { throw new Error("Error with access token: " + error.message); } } // Set credentials tool server.tool( "set-spotify-credentials", { clientId: z.string().describe("The Spotify Client ID"), clientSecret: z.string().describe("The Spotify Client Secret"), accessToken: z.string().describe("The Spotify Access Token"), refreshToken: z.string().describe("The Spotify Refresh Token"), }, async ({ clientId, clientSecret, accessToken, refreshToken }) => { globalSpotifyAuthInfo.clientId = clientId; globalSpotifyAuthInfo.clientSecret = clientSecret; globalSpotifyAuthInfo.accessToken = accessToken; globalSpotifyAuthInfo.refreshToken = refreshToken; return { content: [ { type: "text", text: "Spotify credentials set successfully. You can now use other Spotify tools.", }, ], }; } ); // Check credentials tool server.tool("check-credentials-status", {}, async () => { if ( !globalSpotifyAuthInfo.accessToken || !globalSpotifyAuthInfo.refreshToken || !globalSpotifyAuthInfo.clientId || !globalSpotifyAuthInfo.clientSecret ) { return { content: [ { type: "text", text: "Spotify credentials are not set. Please use the set-spotify-credentials tool.", }, ], }; } try { const accessToken = await getValidAccessToken(); const response = await fetch("https://api.spotify.com/v1/me", { headers: { Authorization: `Bearer ${accessToken}`, }, }); if (response.ok) { const userData = await response.json(); return { content: [ { type: "text", text: `Spotify credentials are valid.\nLogged in as: ${ userData.display_name } (${userData.email || "email not available"})`, }, ], }; } else { return { content: [ { type: "text", text: `Spotify credentials may be invalid. Status code: ${response.status}`, }, ], isError: true, }; } } catch (error) { return { content: [ { type: "text", text: `Error checking credentials: ${error.message}`, }, ], isError: true, }; } }); // Search tracks server.tool( "search-tracks", { query: z.string().describe("Search query for tracks"), limit: z .number() .min(1) .max(50) .default(10) .describe("Number of results to return"), }, async ({ query, limit }) => { try { const accessToken = await getValidAccessToken(); const response = await fetch( `https://api.spotify.com/v1/search?q=${encodeURIComponent( query )}&type=track&limit=${limit}`, { headers: { Authorization: `Bearer ${accessToken}`, }, } ); const data = await response.json(); if (!response.ok) { return { content: [ { type: "text", text: `Error searching tracks: ${JSON.stringify(data)}`, }, ], isError: true, }; } const tracks = data.tracks.items.map((track) => ({ id: track.id, name: track.name, artist: track.artists.map((artist) => artist.name).join(", "), album: track.album.name, uri: track.uri, })); return { content: [ { type: "text", text: JSON.stringify(tracks, null, 2), }, ], }; } catch (error) { return { content: [ { type: "text", text: `Failed to search tracks: ${error.message}`, }, ], isError: true, }; } } ); // Get current user server.tool("get-current-user", {}, async () => { try { const accessToken = await getValidAccessToken(); const response = await fetch("https://api.spotify.com/v1/me", { headers: { Authorization: `Bearer ${accessToken}`, }, }); const data = await response.json(); if (!response.ok) { return { content: [ { type: "text", text: `Error getting user profile: ${JSON.stringify(data)}`, }, ], isError: true, }; } return { content: [ { type: "text", text: JSON.stringify( { id: data.id, name: data.display_name, email: data.email, country: data.country, }, null, 2 ), }, ], }; } catch (error) { return { content: [ { type: "text", text: `Failed to get user profile: ${error.message}`, }, ], isError: true, }; } }); // Create playlist server.tool( "create-playlist", { name: z.string().describe("Name of the playlist"), description: z .string() .optional() .describe("Description of the playlist"), }, async ({ name, description = "" }) => { try { const accessToken = await getValidAccessToken(); // Get user ID const userResponse = await fetch("https://api.spotify.com/v1/me", { headers: { Authorization: `Bearer ${accessToken}`, }, }); const userData = await userResponse.json(); const userId = userData.id; // Create playlist const response = await fetch( `https://api.spotify.com/v1/users/${userId}/playlists`, { method: "POST", headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", }, body: JSON.stringify({ name, description, public: false, }), } ); const data = await response.json(); if (!response.ok) { return { content: [ { type: "text", text: `Error creating playlist: ${JSON.stringify(data)}`, }, ], isError: true, }; } return { content: [ { type: "text", text: `Playlist created successfully!\nName: ${data.name}\nID: ${data.id}\nURL: ${data.external_urls.spotify}`, }, ], }; } catch (error) { return { content: [ { type: "text", text: `Failed to create playlist: ${error.message}`, }, ], isError: true, }; } } ); // Add tracks to playlist server.tool( "add-tracks-to-playlist", { playlistId: z.string().describe("The Spotify playlist ID"), trackUris: z .array(z.string()) .describe("Array of Spotify track URIs to add"), }, async ({ playlistId, trackUris }) => { try { const accessToken = await getValidAccessToken(); const response = await fetch( `https://api.spotify.com/v1/playlists/${playlistId}/tracks`, { method: "POST", headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", }, body: JSON.stringify({ uris: trackUris, }), } ); const data = await response.json(); if (!response.ok) { return { content: [ { type: "text", text: `Error adding tracks: ${JSON.stringify(data)}`, }, ], isError: true, }; } return { content: [ { type: "text", text: `Successfully added ${trackUris.length} track(s) to playlist!`, }, ], }; } catch (error) { return { content: [ { type: "text", text: `Failed to add tracks: ${error.message}`, }, ], isError: true, }; } } ); // Get recommendations server.tool( "get-recommendations", { seedTracks: z .array(z.string()) .max(5) .describe("Spotify track IDs to use as seeds (max 5)"), limit: z .number() .min(1) .max(100) .default(20) .describe("Number of recommendations to return"), }, async ({ seedTracks, limit }) => { try { const accessToken = await getValidAccessToken(); if (seedTracks.length === 0) { return { content: [ { type: "text", text: "Error: At least one seed track is required", }, ], isError: true, }; } const response = await fetch( `https://api.spotify.com/v1/recommendations?seed_tracks=${seedTracks.join( "," )}&limit=${limit}`, { headers: { Authorization: `Bearer ${accessToken}`, }, } ); const data = await response.json(); if (!response.ok) { return { content: [ { type: "text", text: `Error getting recommendations: ${JSON.stringify(data)}`, }, ], isError: true, }; } const tracks = data.tracks.map((track) => ({ id: track.id, name: track.name, artist: track.artists.map((artist) => artist.name).join(", "), album: track.album.name, uri: track.uri, })); return { content: [ { type: "text", text: JSON.stringify(tracks, null, 2), }, ], }; } catch (error) { return { content: [ { type: "text", text: `Failed to get recommendations: ${error.message}`, }, ], isError: true, }; } } ); return server; } // Create Express app const app = express(); app.use(express.json()); // CORS configuration app.use( cors({ origin: "*", // Configure this for production exposedHeaders: ["Mcp-Session-Id"], allowedHeaders: ["Content-Type", "mcp-session-id"], }) ); // Health check endpoint app.get("/health", (req, res) => { res.json({ status: "healthy", timestamp: new Date().toISOString() }); }); // MCP endpoint (stateless mode) app.post("/mcp", async (req, res) => { try { // Create new server instance for each request (stateless) const server = createSpotifyMcpServer(); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, // No session management }); // Clean up when request closes res.on("close", () => { console.log("Request closed, cleaning up"); transport.close(); server.close(); }); // Connect server to transport await server.connect(transport); // Handle the request await transport.handleRequest(req, res, req.body); } catch (error) { console.error("Error handling MCP request:", error); if (!res.headersSent) { res.status(500).json({ jsonrpc: "2.0", error: { code: -32603, message: "Internal server error", }, id: null, }); } } }); // Start the server const PORT = process.env.PORT || 8080; app.listen(PORT, () => { console.log(`Spotify MCP Server running on port ${PORT}`); console.log(`Health check: http://localhost:${PORT}/health`); console.log(`MCP endpoint: http://localhost:${PORT}/mcp`); console.log(`Mode: STATELESS`); }); ``` -------------------------------------------------------------------------------- /mcp/spotify-mcp-oauth-http.js: -------------------------------------------------------------------------------- ```javascript import express from "express"; import cors from "cors"; import { randomUUID } from "node:crypto"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; import fetch from "node-fetch"; // Environment variables const SPOTIFY_CLIENT_ID = process.env.SPOTIFY_CLIENT_ID; const SPOTIFY_CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET; const SPOTIFY_REDIRECT_URI = process.env.SPOTIFY_REDIRECT_URI || "http://localhost:8080/callback/spotify"; if (!SPOTIFY_CLIENT_ID || !SPOTIFY_CLIENT_SECRET) { console.error( "Missing required environment variables: SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET" ); process.exit(1); } console.log("🎵 Spotify Config:"); console.log("- Client ID:", SPOTIFY_CLIENT_ID); console.log("- Redirect URI:", SPOTIFY_REDIRECT_URI); // Session storage const transports = new Map(); const sessionTokens = new Map(); const sessionActivity = new Map(); // Track last activity per session // Session cleanup configuration const INACTIVITY_TIMEOUT = 8 * 60 * 1000; // 8 minutes const CLEANUP_INTERVAL = 3 * 60 * 1000; // 3 minutes // Simple cleanup function function cleanupSession(sessionId) { console.log(`🧹 Cleaning up session: ${sessionId}`); const transport = transports.get(sessionId); if (transport) { try { transport.close(); } catch (error) { console.warn(`Warning closing transport:`, error.message); } transports.delete(sessionId); } sessionTokens.delete(sessionId); sessionActivity.delete(sessionId); } // Update session activity function updateSessionActivity(sessionId) { sessionActivity.set(sessionId, Date.now()); } // Cleanup inactive sessions function cleanupInactiveSessions() { const now = Date.now(); const inactiveSessions = []; for (const [sessionId, lastActivity] of sessionActivity.entries()) { if (now - lastActivity > INACTIVITY_TIMEOUT) { inactiveSessions.push(sessionId); } } if (inactiveSessions.length > 0) { console.log(`🧹 Cleaning up ${inactiveSessions.length} inactive sessions`); inactiveSessions.forEach(cleanupSession); } } // Start cleanup interval setInterval(() => { try { cleanupInactiveSessions(); } catch (error) { console.error('Error during inactive session cleanup:', error); } }, CLEANUP_INTERVAL); // Helper functions function isTokenExpired(tokens) { if (!tokens.expiresAt) return true; return Date.now() >= tokens.expiresAt; } function getSpotifyAuthUrl(sessionId) { const params = new URLSearchParams({ response_type: "code", client_id: SPOTIFY_CLIENT_ID, scope: "user-read-private user-read-email playlist-read-private playlist-modify-private playlist-modify-public", redirect_uri: SPOTIFY_REDIRECT_URI, state: sessionId, }); return `https://accounts.spotify.com/authorize?${params.toString()}`; } async function exchangeCodeForTokens(code) { const response = await fetch("https://accounts.spotify.com/api/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", Authorization: "Basic " + Buffer.from(SPOTIFY_CLIENT_ID + ":" + SPOTIFY_CLIENT_SECRET).toString( "base64" ), }, body: new URLSearchParams({ grant_type: "authorization_code", code: code, redirect_uri: SPOTIFY_REDIRECT_URI, }), }); const data = await response.json(); if (!response.ok) { throw new Error( `Token exchange failed: ${data.error_description || data.error}` ); } return { accessToken: data.access_token, refreshToken: data.refresh_token, expiresAt: Date.now() + data.expires_in * 1000, }; } async function refreshSpotifyToken(tokens) { const response = await fetch("https://accounts.spotify.com/api/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", Authorization: "Basic " + Buffer.from(SPOTIFY_CLIENT_ID + ":" + SPOTIFY_CLIENT_SECRET).toString( "base64" ), }, body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: tokens.refreshToken, }), }); const data = await response.json(); if (!response.ok) { throw new Error( `Token refresh failed: ${data.error_description || data.error}` ); } return { accessToken: data.access_token, refreshToken: data.refresh_token || tokens.refreshToken, expiresAt: Date.now() + data.expires_in * 1000, }; } async function getValidAccessToken(sessionId) { let tokens = sessionTokens.get(sessionId); if (!tokens) { return null; } if (isTokenExpired(tokens)) { try { tokens = await refreshSpotifyToken(tokens); sessionTokens.set(sessionId, tokens); } catch (error) { console.log( `❌ Token refresh failed for session ${sessionId}:`, error.message ); sessionTokens.delete(sessionId); return null; } } return tokens.accessToken; } async function handleSpotifyTool(sessionId, apiCall) { const accessToken = await getValidAccessToken(sessionId); if (!accessToken) { console.log(`🔐 Auth required for session: ${sessionId}`); const baseUrl = process.env.SPOTIFY_REDIRECT_URI?.replace("/callback/spotify", "") || "http://localhost:8080"; const authUrl = `${baseUrl}/auth?session=${sessionId}`; return { content: [ { type: "text", text: `🎵 **Spotify Authentication Required** To use Spotify features, please visit: ${authUrl} This will redirect you to connect your Spotify account. After authentication, return here and try your request again.`, }, ], }; } try { console.log(`✅ Executing Spotify API call for session: ${sessionId}`); return await apiCall(accessToken); } catch (error) { if (error.response?.status === 401) { console.log(`❌ Token expired for session: ${sessionId}`); sessionTokens.delete(sessionId); const baseUrl = process.env.SPOTIFY_REDIRECT_URI?.replace("/callback/spotify", "") || "http://localhost:8080"; const authUrl = `${baseUrl}/auth?session=${sessionId}`; return { content: [ { type: "text", text: `🔐 **Spotify Authentication Expired** Your Spotify session has expired. Please visit: ${authUrl} After completing authentication, return here and try your request again.`, }, ], isError: true, }; } console.log( `❌ Spotify API error for session ${sessionId}:`, error.message ); return { content: [ { type: "text", text: `❌ **Spotify API Error** ${error.message}`, }, ], isError: true, }; } } // Create MCP server instance with session context function createSpotifyMcpServer(sessionId) { const server = new McpServer({ name: "SpotifyServer", version: "1.0.0", }); // Search tracks server.registerTool( "search-tracks", { title: "Search Spotify Tracks", description: "Search for tracks on Spotify", inputSchema: { query: z.string().describe("Search query for tracks"), limit: z .number() .min(1) .max(50) .default(10) .describe("Number of results to return"), }, }, async ({ query, limit }) => { return await handleSpotifyTool(sessionId, async (accessToken) => { const response = await fetch( `https://api.spotify.com/v1/search?q=${encodeURIComponent( query )}&type=track&limit=${limit}`, { headers: { Authorization: `Bearer ${accessToken}`, }, } ); const data = await response.json(); if (!response.ok) { throw new Error( `Spotify API error: ${data.error?.message || "Unknown error"}` ); } const tracks = data.tracks.items.map((track) => ({ id: track.id, name: track.name, artist: track.artists.map((artist) => artist.name).join(", "), album: track.album.name, uri: track.uri, })); return { content: [ { type: "text", text: JSON.stringify(tracks, null, 2), }, ], }; }); } ); // Get current user server.registerTool( "get-current-user", { title: "Get Current User", description: "Get current Spotify user information", }, async () => { return await handleSpotifyTool(sessionId, async (accessToken) => { const response = await fetch("https://api.spotify.com/v1/me", { headers: { Authorization: `Bearer ${accessToken}`, }, }); const data = await response.json(); if (!response.ok) { throw new Error( `Spotify API error: ${data.error?.message || "Unknown error"}` ); } return { content: [ { type: "text", text: JSON.stringify( { id: data.id, name: data.display_name, email: data.email, country: data.country, followers: data.followers?.total || 0, }, null, 2 ), }, ], }; }); } ); // Create playlist server.registerTool( "create-playlist", { title: "Create Playlist", description: "Create a new playlist on Spotify", inputSchema: { name: z.string().describe("Name of the playlist"), description: z .string() .optional() .describe("Description of the playlist"), public: z .boolean() .default(false) .describe("Whether playlist should be public"), }, }, async ({ name, description = "", public: isPublic }) => { return await handleSpotifyTool(sessionId, async (accessToken) => { // Get user ID first const userResponse = await fetch("https://api.spotify.com/v1/me", { headers: { Authorization: `Bearer ${accessToken}`, }, }); const userData = await userResponse.json(); const userId = userData.id; // Create playlist const response = await fetch( `https://api.spotify.com/v1/users/${userId}/playlists`, { method: "POST", headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", }, body: JSON.stringify({ name, description, public: isPublic, }), } ); const data = await response.json(); if (!response.ok) { throw new Error( `Spotify API error: ${data.error?.message || "Unknown error"}` ); } return { content: [ { type: "text", text: `✅ Playlist created successfully!\n\n**${data.name}**\nID: ${data.id}\n🔗 [Open in Spotify](${data.external_urls.spotify})`, }, ], }; }); } ); // Add tracks to playlist server.registerTool( "add-tracks-to-playlist", { title: "Add Tracks to Playlist", description: "Add tracks to an existing playlist", inputSchema: { playlistId: z.string().describe("The Spotify playlist ID"), trackUris: z .array(z.string()) .describe("Array of Spotify track URIs to add"), }, }, async ({ playlistId, trackUris }) => { return await handleSpotifyTool(sessionId, async (accessToken) => { const response = await fetch( `https://api.spotify.com/v1/playlists/${playlistId}/tracks`, { method: "POST", headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", }, body: JSON.stringify({ uris: trackUris, }), } ); const data = await response.json(); if (!response.ok) { throw new Error( `Spotify API error: ${data.error?.message || "Unknown error"}` ); } return { content: [ { type: "text", text: `✅ Successfully added ${trackUris.length} track(s) to playlist!`, }, ], }; }); } ); // Get recommendations server.registerTool( "get-recommendations", { title: "Get Recommendations", description: "Get music recommendations based on seed tracks", inputSchema: { seedTracks: z .array(z.string()) .max(5) .describe("Spotify track IDs to use as seeds (max 5)"), limit: z .number() .min(1) .max(100) .default(20) .describe("Number of recommendations to return"), }, }, async ({ seedTracks, limit }) => { return await handleSpotifyTool(sessionId, async (accessToken) => { if (seedTracks.length === 0) { throw new Error("At least one seed track is required"); } const response = await fetch( `https://api.spotify.com/v1/recommendations?seed_tracks=${seedTracks.join( "," )}&limit=${limit}`, { headers: { Authorization: `Bearer ${accessToken}`, }, } ); const data = await response.json(); if (!response.ok) { throw new Error( `Spotify API error: ${data.error?.message || "Unknown error"}` ); } const tracks = data.tracks.map((track) => ({ id: track.id, name: track.name, artist: track.artists.map((artist) => artist.name).join(", "), album: track.album.name, uri: track.uri, })); return { content: [ { type: "text", text: JSON.stringify(tracks, null, 2), }, ], }; }); } ); return server; } // Express app setup const app = express(); app.use(express.json()); // CORS configuration app.use( cors({ origin: "*", exposedHeaders: ["Mcp-Session-Id"], allowedHeaders: ["Content-Type", "mcp-session-id"], }) ); // Health check app.get("/health", (req, res) => { res.json({ status: "healthy", timestamp: new Date().toISOString(), activeSessions: transports.size, }); }); // Simple auth landing page app.get("/auth", (req, res) => { const sessionId = req.query.session; if (!sessionId) { res.status(400).send("Missing session ID"); return; } const authUrl = getSpotifyAuthUrl(sessionId); res.send(` <html> <head> <title>Connect Spotify to Claude</title> <style> body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; text-align: center; padding: 50px; background: linear-gradient(135deg, #667eea, #764ba2); color: white; margin: 0; } .container { background: rgba(0,0,0,0.1); padding: 40px; border-radius: 20px; backdrop-filter: blur(10px); display: inline-block; max-width: 500px; } .spotify-icon { font-size: 60px; margin-bottom: 20px; } h1 { margin: 20px 0; font-size: 28px; } p { font-size: 18px; line-height: 1.5; opacity: 0.9; } .auth-button { display: inline-block; background: #1db954; color: white; padding: 15px 30px; border-radius: 50px; text-decoration: none; font-size: 18px; font-weight: bold; margin: 20px 0; transition: all 0.3s ease; } .auth-button:hover { background: #1ed760; transform: translateY(-2px); } </style> </head> <body> <div class="container"> <div class="spotify-icon">🎵</div> <h1>Connect Spotify to Claude</h1> <p>Click the button below to connect your Spotify account and enable music features in Claude.</p> <a href="${authUrl}" class="auth-button">Connect Spotify Account</a> <p style="font-size: 14px; margin-top: 30px;"> After connecting, return to Claude to use Spotify features. </p> </div> </body> </html> `); }); // Spotify OAuth callback app.get("/callback/spotify", async (req, res) => { const { code, state: sessionId, error } = req.query; if (error) { console.log(`❌ OAuth error: ${error}`); res.status(400).send(`Authentication error: ${error}`); return; } if (!code || !sessionId) { console.log(`❌ OAuth callback missing code or sessionId`); res.status(400).send("Missing authorization code or session ID"); return; } console.log(`🔄 Processing OAuth callback for session: ${sessionId}`); try { const tokens = await exchangeCodeForTokens(code); sessionTokens.set(sessionId, tokens); console.log(`✅ OAuth successful for session: ${sessionId}`); res.send(` <html> <head> <title>Spotify Connected Successfully</title> <style> body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; text-align: center; padding: 50px; background: linear-gradient(135deg, #1db954, #1ed760); color: white; margin: 0; } .container { background: rgba(0,0,0,0.1); padding: 40px; border-radius: 20px; backdrop-filter: blur(10px); display: inline-block; max-width: 500px; } .success-icon { font-size: 60px; margin-bottom: 20px; } h1 { margin: 20px 0; font-size: 28px; } p { font-size: 18px; line-height: 1.5; opacity: 0.9; } .instruction { background: rgba(255,255,255,0.1); padding: 20px; border-radius: 10px; margin-top: 30px; border: 1px solid rgba(255,255,255,0.2); } </style> </head> <body> <div class="container"> <div class="success-icon">🎵</div> <h1>Successfully Connected to Spotify!</h1> <p>Your Spotify account is now linked to Claude.</p> <div class="instruction"> <strong>Next Steps:</strong><br> 1. Return to your Claude conversation<br> 2. Try your Spotify request again<br> 3. This window can be closed </div> </div> </body> </html> `); } catch (error) { console.error( `❌ Token exchange failed for session ${sessionId}:`, error.message ); res.status(500).send(`Authentication error: ${error.message}`); } }); // MCP endpoint with session management app.post("/mcp", async (req, res) => { try { const sessionId = req.headers["mcp-session-id"]; let transport; if (sessionId && transports.has(sessionId)) { // Reuse existing transport and update activity transport = transports.get(sessionId); updateSessionActivity(sessionId); } else if (!sessionId && isInitializeRequest(req.body)) { // New initialization request const newSessionId = randomUUID(); transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => newSessionId, onsessioninitialized: (sessionId) => { console.log(`🎯 New MCP session initialized: ${sessionId}`); updateSessionActivity(sessionId); // Track initial activity }, }); // Clean up transport when closed transport.onclose = () => { if (transport.sessionId) { console.log(`🔌 MCP session closed: ${transport.sessionId}`); cleanupSession(transport.sessionId); } }; // Create and connect server with sessionId const server = createSpotifyMcpServer(newSessionId); await server.connect(transport); // Store transport transports.set(newSessionId, transport); } else { // Invalid request res.status(400).json({ jsonrpc: "2.0", error: { code: -32000, message: "Bad Request: No valid session ID provided", }, id: null, }); return; } // Handle the request await transport.handleRequest(req, res, req.body); } catch (error) { console.error(`❌ MCP request error:`, error.message); if (!res.headersSent) { res.status(500).json({ jsonrpc: "2.0", error: { code: -32603, message: "Internal server error", }, id: null, }); } } }); // Handle GET requests for server-to-client notifications via SSE app.get("/mcp", async (req, res) => { const sessionId = req.headers["mcp-session-id"]; if (!sessionId || !transports.has(sessionId)) { res.status(400).send("Invalid or missing session ID"); return; } const transport = transports.get(sessionId); updateSessionActivity(sessionId); // Update activity for SSE requests too await transport.handleRequest(req, res); }); // Handle DELETE requests for session termination app.delete("/mcp", async (req, res) => { const sessionId = req.headers["mcp-session-id"]; if (!sessionId || !transports.has(sessionId)) { res.status(400).send("Invalid or missing session ID"); return; } const transport = transports.get(sessionId); await transport.handleRequest(req, res); // Clean up on explicit delete cleanupSession(sessionId); }); // Start server const PORT = process.env.PORT || 8080; app.listen(PORT, () => { console.log(`🚀 Spotify OAuth MCP Server running on port ${PORT}`); console.log(`🔗 Health check: http://localhost:${PORT}/health`); console.log(`🔗 MCP endpoint: http://localhost:${PORT}/mcp`); console.log(`🔗 OAuth callback: http://localhost:${PORT}/callback/spotify`); console.log(`📊 Mode: STATEFUL with OAuth`); console.log( `⏰ Session cleanup: ${INACTIVITY_TIMEOUT / 60000} min inactivity timeout` ); }); ```