# 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: -------------------------------------------------------------------------------- ``` 1 | node_modules 2 | secrets.json 3 | package-lock.json 4 | .env ``` -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- ```markdown 1 | # Spotify MCP Server 2 | 3 | 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. 4 | 5 | ## Features 6 | 7 | - Search for tracks on Spotify 8 | - View your Spotify profile 9 | - Create playlists 10 | - Add tracks to playlists 11 | - Get personalized music recommendations 12 | 13 | ## Tools Available 14 | 15 | | Tool Name | Description | 16 | | -------------------------- | -------------------------------------------------------- | 17 | | `set-spotify-credentials` | Set your Spotify authentication credentials | 18 | | `check-credentials-status` | Check if your credentials are valid and who is logged in | 19 | | `search-tracks` | Search for tracks by name, artist, or keywords | 20 | | `get-current-user` | Get your Spotify profile information | 21 | | `create-playlist` | Create a new playlist on your account | 22 | | `add-tracks-to-playlist` | Add tracks to an existing playlist | 23 | | `get-recommendations` | Get recommendations based on seed tracks | 24 | 25 | ## Setup Instructions 26 | 27 | ## Plug and Play - HTTP Spotify MCP Server 28 | 29 | 1. Go to [Claude AI](https://claude.ai/) 30 | 2. Click on Add Connectors 31 | 3. Click on Manage Connectors 32 | 4. Add custom connector 33 | 5. Add the https server link 34 | 6. Mail [email protected] to whitelist your spotify ID/mail 35 | 7. That's it. Your claude is ready to use Spotify. You will be prompted to sign-in using spotify OAuth. 36 | 37 | ## Run Spotify MCP locally 38 | 39 | ### 1. Prerequisites 40 | 41 | - Node.js v16 or higher 42 | - npm 43 | - A Spotify account 44 | - A registered Spotify Developer application 45 | 46 | ### 2. Create a Spotify Developer App 47 | 48 | 1. Go to [Spotify Developer Dashboard](https://developer.spotify.com/dashboard/) 49 | 2. Log in with your Spotify account 50 | 3. Click "Create an App" 51 | 4. Fill in the app name and description 52 | 5. Add `http://localhost:8888/callback` as a Redirect URI 53 | 6. Note your Client ID and Client Secret 54 | 55 | ### 3. Install the Project 56 | 57 | ```bash 58 | # Clone or download the project first 59 | cd spotify-mcp-server 60 | 61 | # Install dependencies 62 | npm install 63 | ``` 64 | 65 | ### 4. Get Your Spotify Tokens 66 | 67 | Edit the `spotify-auth.js` file to include your Client ID and Client Secret: 68 | 69 | ```javascript 70 | // Replace these with your Spotify app credentials 71 | const CLIENT_ID = "your_client_id_here"; 72 | const CLIENT_SECRET = "your_client_secret_here"; 73 | ``` 74 | 75 | Then run the authentication script: 76 | 77 | ```bash 78 | node spotify-auth.js 79 | ``` 80 | 81 | This will: 82 | 83 | 1. Open a URL in your browser 84 | 2. Prompt you to log in to Spotify 85 | 3. Ask for your permission to access your account 86 | 4. Save the tokens to `secrets.json` 87 | 88 | ### 5. Build the MCP Server 89 | 90 | ```bash 91 | npm run build 92 | ``` 93 | 94 | ### 6. Configure Claude Desktop 95 | 96 | Edit your Claude Desktop configuration file: 97 | 98 | - On macOS: `~/Library/Application Support/Claude/claude_desktop_config.json` 99 | - On Windows: `%APPDATA%\Claude\claude_desktop_config.json` 100 | 101 | Add the following configuration: 102 | 103 | ```json 104 | { 105 | "mcpServers": { 106 | "spotify": { 107 | "command": "node", 108 | "args": ["/full/path/to/spotify-mcp-server/build/spotify-mcp-server.js"] 109 | } 110 | } 111 | } 112 | ``` 113 | 114 | Replace `/full/path/to/spotify-mcp-server` with the actual path to your project directory. 115 | 116 | ### 7. Restart Claude Desktop 117 | 118 | Close and reopen Claude Desktop to load the new configuration. 119 | 120 | ## Usage 121 | 122 | When you start a conversation with Claude, you'll first need to set your Spotify credentials: 123 | 124 | 1. Look at your `secrets.json` file to get your credentials 125 | 2. Use the `set-spotify-credentials` tool to authenticate 126 | 3. Then use any of the other Spotify tools 127 | 128 | ## Example Prompts 129 | 130 | ### Setting Up Credentials 131 | 132 | ``` 133 | I want to connect to my Spotify account. Here are my credentials from secrets.json: 134 | 135 | Tool: set-spotify-credentials 136 | Parameters: 137 | { 138 | "clientId": "your_client_id", 139 | "clientSecret": "your_client_secret", 140 | "accessToken": "your_access_token", 141 | "refreshToken": "your_refresh_token" 142 | } 143 | ``` 144 | 145 | ### Basic Commands 146 | 147 | Check your account: 148 | 149 | ``` 150 | Can you check who I'm logged in as on Spotify? 151 | 152 | Tool: get-current-user 153 | Parameters: {} 154 | ``` 155 | 156 | Search for tracks: 157 | 158 | ``` 159 | Search for songs by Weekend 160 | 161 | Tool: search-tracks 162 | Parameters: 163 | { 164 | "query": "Taylor Swift", 165 | "limit": 5 166 | } 167 | ``` 168 | 169 | Create a playlist: 170 | 171 | ``` 172 | Create a new playlist called "My Pretty pretty girlfriend" 173 | 174 | Tool: create-playlist 175 | Parameters: 176 | { 177 | "name": "My Pretty pretty girlfriend", 178 | "description": "For my girlfriend. Created with Claude and the Spotify MCP server" 179 | } 180 | ``` 181 | 182 | ### Multi-Step Tasks 183 | 184 | Creating a playlist with songs: 185 | 186 | ``` 187 | 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. 188 | ``` 189 | 190 | Getting recommendations based on favorites: 191 | 192 | ``` 193 | 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? 194 | ``` 195 | 196 | ## Troubleshooting 197 | 198 | - **Error: No access token available**: You need to set your credentials first using the `set-spotify-credentials` tool 199 | - **Authentication failures**: Your tokens may have expired. Run the auth script again to get fresh tokens 200 | - **Invalid credentials**: Double check that you're using the correct Client ID and Client Secret 201 | 202 | ## Notes 203 | 204 | - The server stores credentials in memory only 205 | - You'll need to set credentials each time you start a new conversation 206 | - If Claude Desktop restarts, you'll need to set credentials again 207 | ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "Node16", 5 | "moduleResolution": "Node16", 6 | "outDir": "./build", 7 | "rootDir": "./mcp", 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "skipLibCheck": true, 11 | "forceConsistentCasingInFileNames": true 12 | }, 13 | "include": ["*.ts", "mcp/spotify-mcp.ts", "mcp/spotify-mcp-sse.ts"], 14 | "exclude": ["node_modules"] 15 | } 16 | ``` -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- ```dockerfile 1 | # Generated by https://smithery.ai. See: https://smithery.ai/docs/config#dockerfile 2 | FROM node:lts-alpine 3 | 4 | WORKDIR /app 5 | 6 | # Copy package.json and lock file if available 7 | COPY package.json package-lock.json* ./ 8 | 9 | # Install dependencies without running scripts 10 | RUN npm install --ignore-scripts 11 | 12 | # Copy source files 13 | COPY . . 14 | 15 | # Build the TypeScript code 16 | RUN npm run build 17 | 18 | CMD [ "node", "build/spotify-mcp-server.js" ] 19 | ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "name": "spotify-mcp-server", 3 | "version": "1.0.0", 4 | "description": "MCP server for Spotify API integration", 5 | "type": "module", 6 | "main": "build/spotify-mcp.js", 7 | "scripts": { 8 | "auth": "node spotify-auth.js", 9 | "build": "tsc", 10 | "start": "node build/spotify-mcp-http.js" 11 | }, 12 | "dependencies": { 13 | "@modelcontextprotocol/sdk": "^1.16.0", 14 | "@types/cors": "^2.8.18", 15 | "@types/express": "^5.0.1", 16 | "axios": "^1.6.2", 17 | "cors": "^2.8.5", 18 | "express": "^4.21.2", 19 | "node-fetch": "^3.3.2", 20 | "zod": "^3.24.2" 21 | }, 22 | "devDependencies": { 23 | "@types/node": "^22.14.0", 24 | "typescript": "^5.8.3" 25 | } 26 | } 27 | ``` -------------------------------------------------------------------------------- /smithery.yaml: -------------------------------------------------------------------------------- ```yaml 1 | # Smithery configuration file: https://smithery.ai/docs/config#smitheryyaml 2 | 3 | startCommand: 4 | type: stdio 5 | configSchema: 6 | # JSON Schema defining the configuration options for the MCP. 7 | type: object 8 | required: [] 9 | properties: 10 | clientId: 11 | type: string 12 | description: Spotify Client ID 13 | clientSecret: 14 | type: string 15 | description: Spotify Client Secret 16 | accessToken: 17 | type: string 18 | description: Spotify Access Token 19 | refreshToken: 20 | type: string 21 | description: Spotify Refresh Token 22 | commandFunction: 23 | # A JS function that produces the CLI command based on the given config to start the MCP on stdio. 24 | |- 25 | (config) => { 26 | const env = {}; 27 | if (config.clientId) { env.SPOTIFY_CLIENT_ID = config.clientId; } 28 | if (config.clientSecret) { env.SPOTIFY_CLIENT_SECRET = config.clientSecret; } 29 | if (config.accessToken) { env.SPOTIFY_ACCESS_TOKEN = config.accessToken; } 30 | if (config.refreshToken) { env.SPOTIFY_REFRESH_TOKEN = config.refreshToken; } 31 | return { 32 | command: "node", 33 | args: ["build/spotify-mcp-server.js"], 34 | env: env 35 | }; 36 | } 37 | exampleConfig: 38 | clientId: your-spotify-client-id 39 | clientSecret: your-spotify-client-secret 40 | accessToken: your-initial-access-token 41 | refreshToken: your-initial-refresh-token 42 | ``` -------------------------------------------------------------------------------- /spotify-auth.js: -------------------------------------------------------------------------------- ```javascript 1 | import express from "express"; 2 | import axios from "axios"; 3 | import fs from "fs"; 4 | 5 | // Replace these with your Spotify app credentials 6 | const CLIENT_ID = process.env.SPOTIFY_CLIENT_ID; 7 | const CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET; 8 | const REDIRECT_URI = process.env.SPOTIFY_REDIRECT_URI; 9 | 10 | const app = express(); 11 | const PORT = 8888; 12 | 13 | app.get("/login", (req, res) => { 14 | res.redirect( 15 | `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` 16 | ); 17 | }); 18 | 19 | app.get("/callback", async (req, res) => { 20 | const code = req.query.code; 21 | 22 | try { 23 | const tokenResponse = await axios({ 24 | method: "post", 25 | url: "https://accounts.spotify.com/api/token", 26 | params: { 27 | code: code, 28 | redirect_uri: REDIRECT_URI, 29 | grant_type: "authorization_code", 30 | }, 31 | headers: { 32 | Authorization: 33 | "Basic " + 34 | Buffer.from(CLIENT_ID + ":" + CLIENT_SECRET).toString("base64"), 35 | "Content-Type": "application/x-www-form-urlencoded", 36 | }, 37 | }); 38 | 39 | // Save tokens to secrets.json file 40 | const tokens = { 41 | clientId: CLIENT_ID, 42 | clientSecret: CLIENT_SECRET, 43 | accessToken: tokenResponse.data.access_token, 44 | refreshToken: tokenResponse.data.refresh_token, 45 | }; 46 | 47 | fs.writeFileSync("secrets.json", JSON.stringify(tokens, null, 2)); 48 | 49 | res.send("Authentication successful! Tokens saved to secrets.json"); 50 | 51 | setTimeout(() => { 52 | server.close(); 53 | console.log( 54 | "Tokens saved to secrets.json. You can now close this window." 55 | ); 56 | }, 3000); 57 | } catch (error) { 58 | res.send("Error during authentication: " + error.message); 59 | } 60 | }); 61 | 62 | const server = app.listen(PORT, () => { 63 | console.log( 64 | `Please open http://localhost:${PORT}/login in your browser to authenticate` 65 | ); 66 | }); 67 | ``` -------------------------------------------------------------------------------- /mcp/spotify-mcp.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 2 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 3 | import { z } from "zod"; 4 | import fetch from "node-fetch"; 5 | 6 | const server = new McpServer({ 7 | name: "SpotifyServer", 8 | version: "1.0.0", 9 | capabilities: { 10 | tools: {}, 11 | }, 12 | }); 13 | 14 | let spotifyAuthInfo = { 15 | accessToken: "", 16 | refreshToken: "", 17 | clientId: "", 18 | clientSecret: "", 19 | }; 20 | 21 | // Refresh token when needed 22 | async function getValidAccessToken() { 23 | if (!spotifyAuthInfo.accessToken || !spotifyAuthInfo.refreshToken) { 24 | throw new Error( 25 | "No access token available. Please set credentials first using the set-spotify-credentials tool." 26 | ); 27 | } 28 | 29 | try { 30 | // Try using current token 31 | const response = await fetch("https://api.spotify.com/v1/me", { 32 | headers: { 33 | Authorization: `Bearer ${spotifyAuthInfo.accessToken}`, 34 | }, 35 | }); 36 | 37 | // If token works, return it 38 | if (response.ok) { 39 | return spotifyAuthInfo.accessToken; 40 | } 41 | 42 | console.error("Access token expired, refreshing..."); 43 | 44 | // If token doesn't work, refresh it 45 | const refreshResponse = await fetch( 46 | "https://accounts.spotify.com/api/token", 47 | { 48 | method: "POST", 49 | headers: { 50 | "Content-Type": "application/x-www-form-urlencoded", 51 | Authorization: 52 | "Basic " + 53 | Buffer.from( 54 | spotifyAuthInfo.clientId + ":" + spotifyAuthInfo.clientSecret 55 | ).toString("base64"), 56 | }, 57 | body: new URLSearchParams({ 58 | grant_type: "refresh_token", 59 | refresh_token: spotifyAuthInfo.refreshToken, 60 | }), 61 | } 62 | ); 63 | 64 | const data = (await refreshResponse.json()) as any; 65 | 66 | if (data.access_token) { 67 | console.error("Successfully refreshed access token"); 68 | spotifyAuthInfo.accessToken = data.access_token; 69 | return spotifyAuthInfo.accessToken; 70 | } 71 | 72 | throw new Error("Failed to refresh access token"); 73 | } catch (error) { 74 | throw new Error( 75 | "Error with access token: " + 76 | (error instanceof Error ? error.message : String(error)) 77 | ); 78 | } 79 | } 80 | 81 | // Set credentials tool 82 | server.tool( 83 | "set-spotify-credentials", 84 | { 85 | clientId: z.string().describe("The Spotify Client ID"), 86 | clientSecret: z.string().describe("The Spotify Client Secret"), 87 | accessToken: z.string().describe("The Spotify Access Token"), 88 | refreshToken: z.string().describe("The Spotify Refresh Token"), 89 | }, 90 | async ({ clientId, clientSecret, accessToken, refreshToken }) => { 91 | spotifyAuthInfo.clientId = clientId; 92 | spotifyAuthInfo.clientSecret = clientSecret; 93 | spotifyAuthInfo.accessToken = accessToken; 94 | spotifyAuthInfo.refreshToken = refreshToken; 95 | 96 | return { 97 | content: [ 98 | { 99 | type: "text", 100 | text: "Spotify credentials set successfully. You can now use other Spotify tools.", 101 | }, 102 | ], 103 | }; 104 | } 105 | ); 106 | 107 | // Check credentials tool 108 | server.tool("check-credentials-status", {}, async () => { 109 | if ( 110 | !spotifyAuthInfo.accessToken || 111 | !spotifyAuthInfo.refreshToken || 112 | !spotifyAuthInfo.clientId || 113 | !spotifyAuthInfo.clientSecret 114 | ) { 115 | return { 116 | content: [ 117 | { 118 | type: "text", 119 | text: "Spotify credentials are not set. Please use the set-spotify-credentials tool.", 120 | }, 121 | ], 122 | }; 123 | } 124 | 125 | try { 126 | const accessToken = await getValidAccessToken(); 127 | 128 | const response = await fetch("https://api.spotify.com/v1/me", { 129 | headers: { 130 | Authorization: `Bearer ${accessToken}`, 131 | }, 132 | }); 133 | 134 | if (response.ok) { 135 | const userData = (await response.json()) as any; 136 | return { 137 | content: [ 138 | { 139 | type: "text", 140 | text: `Spotify credentials are valid.\nLogged in as: ${ 141 | userData.display_name 142 | } (${userData.email || "email not available"})`, 143 | }, 144 | ], 145 | }; 146 | } else { 147 | return { 148 | content: [ 149 | { 150 | type: "text", 151 | text: `Spotify credentials may be invalid. Status code: ${response.status}`, 152 | }, 153 | ], 154 | isError: true, 155 | }; 156 | } 157 | } catch (error) { 158 | return { 159 | content: [ 160 | { 161 | type: "text", 162 | text: `Error checking credentials: ${ 163 | error instanceof Error ? error.message : String(error) 164 | }`, 165 | }, 166 | ], 167 | isError: true, 168 | }; 169 | } 170 | }); 171 | 172 | // Search tracks 173 | server.tool( 174 | "search-tracks", 175 | { 176 | query: z.string().describe("Search query for tracks"), 177 | limit: z 178 | .number() 179 | .min(1) 180 | .max(50) 181 | .default(10) 182 | .describe("Number of results to return"), 183 | }, 184 | async ({ query, limit }) => { 185 | try { 186 | const accessToken = await getValidAccessToken(); 187 | 188 | const response = await fetch( 189 | `https://api.spotify.com/v1/search?q=${encodeURIComponent( 190 | query 191 | )}&type=track&limit=${limit}`, 192 | { 193 | headers: { 194 | Authorization: `Bearer ${accessToken}`, 195 | }, 196 | } 197 | ); 198 | 199 | const data = (await response.json()) as any; 200 | 201 | if (!response.ok) { 202 | return { 203 | content: [ 204 | { 205 | type: "text", 206 | text: `Error searching tracks: ${JSON.stringify(data)}`, 207 | }, 208 | ], 209 | isError: true, 210 | }; 211 | } 212 | 213 | const tracks = data.tracks.items.map((track: any) => ({ 214 | id: track.id, 215 | name: track.name, 216 | artist: track.artists.map((artist: any) => artist.name).join(", "), 217 | album: track.album.name, 218 | uri: track.uri, 219 | })); 220 | 221 | return { 222 | content: [ 223 | { 224 | type: "text", 225 | text: JSON.stringify(tracks, null, 2), 226 | }, 227 | ], 228 | }; 229 | } catch (error) { 230 | return { 231 | content: [ 232 | { 233 | type: "text", 234 | text: `Failed to search tracks: ${ 235 | error instanceof Error ? error.message : String(error) 236 | }`, 237 | }, 238 | ], 239 | isError: true, 240 | }; 241 | } 242 | } 243 | ); 244 | 245 | // Get current user 246 | server.tool("get-current-user", {}, async () => { 247 | try { 248 | const accessToken = await getValidAccessToken(); 249 | 250 | const response = await fetch("https://api.spotify.com/v1/me", { 251 | headers: { 252 | Authorization: `Bearer ${accessToken}`, 253 | }, 254 | }); 255 | 256 | const data = (await response.json()) as any; 257 | 258 | if (!response.ok) { 259 | return { 260 | content: [ 261 | { 262 | type: "text", 263 | text: `Error getting user profile: ${JSON.stringify(data)}`, 264 | }, 265 | ], 266 | isError: true, 267 | }; 268 | } 269 | 270 | return { 271 | content: [ 272 | { 273 | type: "text", 274 | text: JSON.stringify( 275 | { 276 | id: data.id, 277 | name: data.display_name, 278 | email: data.email, 279 | country: data.country, 280 | }, 281 | null, 282 | 2 283 | ), 284 | }, 285 | ], 286 | }; 287 | } catch (error) { 288 | return { 289 | content: [ 290 | { 291 | type: "text", 292 | text: `Failed to get user profile: ${ 293 | error instanceof Error ? error.message : String(error) 294 | }`, 295 | }, 296 | ], 297 | isError: true, 298 | }; 299 | } 300 | }); 301 | 302 | // Create playlist 303 | server.tool( 304 | "create-playlist", 305 | { 306 | name: z.string().describe("Name of the playlist"), 307 | description: z.string().optional().describe("Description of the playlist"), 308 | }, 309 | async ({ name, description = "" }) => { 310 | try { 311 | const accessToken = await getValidAccessToken(); 312 | 313 | // Get user ID 314 | const userResponse = await fetch("https://api.spotify.com/v1/me", { 315 | headers: { 316 | Authorization: `Bearer ${accessToken}`, 317 | }, 318 | }); 319 | 320 | const userData = (await userResponse.json()) as any; 321 | const userId = userData.id; 322 | 323 | // Create playlist 324 | const response = await fetch( 325 | `https://api.spotify.com/v1/users/${userId}/playlists`, 326 | { 327 | method: "POST", 328 | headers: { 329 | Authorization: `Bearer ${accessToken}`, 330 | "Content-Type": "application/json", 331 | }, 332 | body: JSON.stringify({ 333 | name, 334 | description, 335 | public: false, 336 | }), 337 | } 338 | ); 339 | 340 | const data = (await response.json()) as any; 341 | 342 | if (!response.ok) { 343 | return { 344 | content: [ 345 | { 346 | type: "text", 347 | text: `Error creating playlist: ${JSON.stringify(data)}`, 348 | }, 349 | ], 350 | isError: true, 351 | }; 352 | } 353 | 354 | return { 355 | content: [ 356 | { 357 | type: "text", 358 | text: `Playlist created successfully!\nName: ${data.name}\nID: ${data.id}\nURL: ${data.external_urls.spotify}`, 359 | }, 360 | ], 361 | }; 362 | } catch (error) { 363 | return { 364 | content: [ 365 | { 366 | type: "text", 367 | text: `Failed to create playlist: ${ 368 | error instanceof Error ? error.message : String(error) 369 | }`, 370 | }, 371 | ], 372 | isError: true, 373 | }; 374 | } 375 | } 376 | ); 377 | 378 | // Add tracks to playlist 379 | server.tool( 380 | "add-tracks-to-playlist", 381 | { 382 | playlistId: z.string().describe("The Spotify playlist ID"), 383 | trackUris: z 384 | .array(z.string()) 385 | .describe("Array of Spotify track URIs to add"), 386 | }, 387 | async ({ playlistId, trackUris }) => { 388 | try { 389 | const accessToken = await getValidAccessToken(); 390 | 391 | const response = await fetch( 392 | `https://api.spotify.com/v1/playlists/${playlistId}/tracks`, 393 | { 394 | method: "POST", 395 | headers: { 396 | Authorization: `Bearer ${accessToken}`, 397 | "Content-Type": "application/json", 398 | }, 399 | body: JSON.stringify({ 400 | uris: trackUris, 401 | }), 402 | } 403 | ); 404 | 405 | const data = (await response.json()) as any; 406 | 407 | if (!response.ok) { 408 | return { 409 | content: [ 410 | { 411 | type: "text", 412 | text: `Error adding tracks: ${JSON.stringify(data)}`, 413 | }, 414 | ], 415 | isError: true, 416 | }; 417 | } 418 | 419 | return { 420 | content: [ 421 | { 422 | type: "text", 423 | text: `Successfully added ${trackUris.length} track(s) to playlist!`, 424 | }, 425 | ], 426 | }; 427 | } catch (error) { 428 | return { 429 | content: [ 430 | { 431 | type: "text", 432 | text: `Failed to add tracks: ${ 433 | error instanceof Error ? error.message : String(error) 434 | }`, 435 | }, 436 | ], 437 | isError: true, 438 | }; 439 | } 440 | } 441 | ); 442 | 443 | // Get recommendations 444 | server.tool( 445 | "get-recommendations", 446 | { 447 | seedTracks: z 448 | .array(z.string()) 449 | .max(5) 450 | .describe("Spotify track IDs to use as seeds (max 5)"), 451 | limit: z 452 | .number() 453 | .min(1) 454 | .max(100) 455 | .default(20) 456 | .describe("Number of recommendations to return"), 457 | }, 458 | async ({ seedTracks, limit }) => { 459 | try { 460 | const accessToken = await getValidAccessToken(); 461 | 462 | if (seedTracks.length === 0) { 463 | return { 464 | content: [ 465 | { 466 | type: "text", 467 | text: "Error: At least one seed track is required", 468 | }, 469 | ], 470 | isError: true, 471 | }; 472 | } 473 | 474 | const response = await fetch( 475 | `https://api.spotify.com/v1/recommendations?seed_tracks=${seedTracks.join( 476 | "," 477 | )}&limit=${limit}`, 478 | { 479 | headers: { 480 | Authorization: `Bearer ${accessToken}`, 481 | }, 482 | } 483 | ); 484 | 485 | const data = (await response.json()) as any; 486 | 487 | if (!response.ok) { 488 | return { 489 | content: [ 490 | { 491 | type: "text", 492 | text: `Error getting recommendations: ${JSON.stringify(data)}`, 493 | }, 494 | ], 495 | isError: true, 496 | }; 497 | } 498 | 499 | const tracks = data.tracks.map((track: any) => ({ 500 | id: track.id, 501 | name: track.name, 502 | artist: track.artists.map((artist: any) => artist.name).join(", "), 503 | album: track.album.name, 504 | uri: track.uri, 505 | })); 506 | 507 | return { 508 | content: [ 509 | { 510 | type: "text", 511 | text: JSON.stringify(tracks, null, 2), 512 | }, 513 | ], 514 | }; 515 | } catch (error) { 516 | return { 517 | content: [ 518 | { 519 | type: "text", 520 | text: `Failed to get recommendations: ${ 521 | error instanceof Error ? error.message : String(error) 522 | }`, 523 | }, 524 | ], 525 | isError: true, 526 | }; 527 | } 528 | } 529 | ); 530 | 531 | // Start the server 532 | async function main() { 533 | const transport = new StdioServerTransport(); 534 | await server.connect(transport); 535 | console.error("Spotify MCP Server running on stdio"); 536 | console.error( 537 | "No credentials are pre-loaded. Users must set credentials with set-spotify-credentials tool." 538 | ); 539 | } 540 | 541 | main().catch((error) => { 542 | console.error("Fatal error:", error); 543 | process.exit(1); 544 | }); 545 | ``` -------------------------------------------------------------------------------- /mcp/spotify-mcp-sse.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 2 | import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; 3 | import { z } from "zod"; 4 | import fetch from "node-fetch"; 5 | import express from "express"; 6 | import cors from "cors"; 7 | 8 | const app = express(); 9 | app.use( 10 | cors({ 11 | origin: "*", 12 | methods: ["GET", "POST"], 13 | allowedHeaders: ["Content-Type"], 14 | }) 15 | ); 16 | 17 | const server = new McpServer({ 18 | name: "SpotifyServer", 19 | version: "1.0.0", 20 | capabilities: { 21 | tools: {}, 22 | }, 23 | }); 24 | 25 | const transports = {}; 26 | 27 | let spotifyAuthInfo = { 28 | accessToken: "", 29 | refreshToken: "", 30 | clientId: "", 31 | clientSecret: "", 32 | }; 33 | 34 | // Refresh token when needed 35 | async function getValidAccessToken() { 36 | if (!spotifyAuthInfo.accessToken || !spotifyAuthInfo.refreshToken) { 37 | throw new Error( 38 | "No access token available. Please set credentials first using the set-spotify-credentials tool." 39 | ); 40 | } 41 | 42 | try { 43 | // Try using current token 44 | const response = await fetch("https://api.spotify.com/v1/me", { 45 | headers: { 46 | Authorization: `Bearer ${spotifyAuthInfo.accessToken}`, 47 | }, 48 | }); 49 | 50 | // If token works, return it 51 | if (response.ok) { 52 | return spotifyAuthInfo.accessToken; 53 | } 54 | 55 | console.error("Access token expired, refreshing..."); 56 | 57 | // If token doesn't work, refresh it 58 | const refreshResponse = await fetch( 59 | "https://accounts.spotify.com/api/token", 60 | { 61 | method: "POST", 62 | headers: { 63 | "Content-Type": "application/x-www-form-urlencoded", 64 | Authorization: 65 | "Basic " + 66 | Buffer.from( 67 | spotifyAuthInfo.clientId + ":" + spotifyAuthInfo.clientSecret 68 | ).toString("base64"), 69 | }, 70 | body: new URLSearchParams({ 71 | grant_type: "refresh_token", 72 | refresh_token: spotifyAuthInfo.refreshToken, 73 | }), 74 | } 75 | ); 76 | 77 | const data = (await refreshResponse.json()) as any; 78 | 79 | if (data.access_token) { 80 | console.error("Successfully refreshed access token"); 81 | spotifyAuthInfo.accessToken = data.access_token; 82 | return spotifyAuthInfo.accessToken; 83 | } 84 | 85 | throw new Error("Failed to refresh access token"); 86 | } catch (error) { 87 | throw new Error( 88 | "Error with access token: " + 89 | (error instanceof Error ? error.message : String(error)) 90 | ); 91 | } 92 | } 93 | 94 | // Set credentials tool 95 | server.tool( 96 | "set-spotify-credentials", 97 | { 98 | clientId: z.string().describe("The Spotify Client ID"), 99 | clientSecret: z.string().describe("The Spotify Client Secret"), 100 | accessToken: z.string().describe("The Spotify Access Token"), 101 | refreshToken: z.string().describe("The Spotify Refresh Token"), 102 | }, 103 | async ({ clientId, clientSecret, accessToken, refreshToken }) => { 104 | spotifyAuthInfo.clientId = clientId; 105 | spotifyAuthInfo.clientSecret = clientSecret; 106 | spotifyAuthInfo.accessToken = accessToken; 107 | spotifyAuthInfo.refreshToken = refreshToken; 108 | 109 | return { 110 | content: [ 111 | { 112 | type: "text", 113 | text: "Spotify credentials set successfully. You can now use other Spotify tools.", 114 | }, 115 | ], 116 | }; 117 | } 118 | ); 119 | 120 | // Check credentials tool 121 | server.tool("check-credentials-status", {}, async () => { 122 | if ( 123 | !spotifyAuthInfo.accessToken || 124 | !spotifyAuthInfo.refreshToken || 125 | !spotifyAuthInfo.clientId || 126 | !spotifyAuthInfo.clientSecret 127 | ) { 128 | return { 129 | content: [ 130 | { 131 | type: "text", 132 | text: "Spotify credentials are not set. Please use the set-spotify-credentials tool.", 133 | }, 134 | ], 135 | }; 136 | } 137 | 138 | try { 139 | const accessToken = await getValidAccessToken(); 140 | 141 | const response = await fetch("https://api.spotify.com/v1/me", { 142 | headers: { 143 | Authorization: `Bearer ${accessToken}`, 144 | }, 145 | }); 146 | 147 | if (response.ok) { 148 | const userData = (await response.json()) as any; 149 | return { 150 | content: [ 151 | { 152 | type: "text", 153 | text: `Spotify credentials are valid.\nLogged in as: ${ 154 | userData.display_name 155 | } (${userData.email || "email not available"})`, 156 | }, 157 | ], 158 | }; 159 | } else { 160 | return { 161 | content: [ 162 | { 163 | type: "text", 164 | text: `Spotify credentials may be invalid. Status code: ${response.status}`, 165 | }, 166 | ], 167 | isError: true, 168 | }; 169 | } 170 | } catch (error) { 171 | return { 172 | content: [ 173 | { 174 | type: "text", 175 | text: `Error checking credentials: ${ 176 | error instanceof Error ? error.message : String(error) 177 | }`, 178 | }, 179 | ], 180 | isError: true, 181 | }; 182 | } 183 | }); 184 | 185 | // Search tracks 186 | server.tool( 187 | "search-tracks", 188 | { 189 | query: z.string().describe("Search query for tracks"), 190 | limit: z 191 | .number() 192 | .min(1) 193 | .max(50) 194 | .default(10) 195 | .describe("Number of results to return"), 196 | }, 197 | async ({ query, limit }) => { 198 | try { 199 | const accessToken = await getValidAccessToken(); 200 | 201 | const response = await fetch( 202 | `https://api.spotify.com/v1/search?q=${encodeURIComponent( 203 | query 204 | )}&type=track&limit=${limit}`, 205 | { 206 | headers: { 207 | Authorization: `Bearer ${accessToken}`, 208 | }, 209 | } 210 | ); 211 | 212 | const data = (await response.json()) as any; 213 | 214 | if (!response.ok) { 215 | return { 216 | content: [ 217 | { 218 | type: "text", 219 | text: `Error searching tracks: ${JSON.stringify(data)}`, 220 | }, 221 | ], 222 | isError: true, 223 | }; 224 | } 225 | 226 | const tracks = data.tracks.items.map((track: any) => ({ 227 | id: track.id, 228 | name: track.name, 229 | artist: track.artists.map((artist: any) => artist.name).join(", "), 230 | album: track.album.name, 231 | uri: track.uri, 232 | })); 233 | 234 | return { 235 | content: [ 236 | { 237 | type: "text", 238 | text: JSON.stringify(tracks, null, 2), 239 | }, 240 | ], 241 | }; 242 | } catch (error) { 243 | return { 244 | content: [ 245 | { 246 | type: "text", 247 | text: `Failed to search tracks: ${ 248 | error instanceof Error ? error.message : String(error) 249 | }`, 250 | }, 251 | ], 252 | isError: true, 253 | }; 254 | } 255 | } 256 | ); 257 | 258 | // Get current user 259 | server.tool("get-current-user", {}, async () => { 260 | try { 261 | const accessToken = await getValidAccessToken(); 262 | 263 | const response = await fetch("https://api.spotify.com/v1/me", { 264 | headers: { 265 | Authorization: `Bearer ${accessToken}`, 266 | }, 267 | }); 268 | 269 | const data = (await response.json()) as any; 270 | 271 | if (!response.ok) { 272 | return { 273 | content: [ 274 | { 275 | type: "text", 276 | text: `Error getting user profile: ${JSON.stringify(data)}`, 277 | }, 278 | ], 279 | isError: true, 280 | }; 281 | } 282 | 283 | return { 284 | content: [ 285 | { 286 | type: "text", 287 | text: JSON.stringify( 288 | { 289 | id: data.id, 290 | name: data.display_name, 291 | email: data.email, 292 | country: data.country, 293 | }, 294 | null, 295 | 2 296 | ), 297 | }, 298 | ], 299 | }; 300 | } catch (error) { 301 | return { 302 | content: [ 303 | { 304 | type: "text", 305 | text: `Failed to get user profile: ${ 306 | error instanceof Error ? error.message : String(error) 307 | }`, 308 | }, 309 | ], 310 | isError: true, 311 | }; 312 | } 313 | }); 314 | 315 | // Create playlist 316 | server.tool( 317 | "create-playlist", 318 | { 319 | name: z.string().describe("Name of the playlist"), 320 | description: z.string().optional().describe("Description of the playlist"), 321 | }, 322 | async ({ name, description = "" }) => { 323 | try { 324 | const accessToken = await getValidAccessToken(); 325 | 326 | // Get user ID 327 | const userResponse = await fetch("https://api.spotify.com/v1/me", { 328 | headers: { 329 | Authorization: `Bearer ${accessToken}`, 330 | }, 331 | }); 332 | 333 | const userData = (await userResponse.json()) as any; 334 | const userId = userData.id; 335 | 336 | // Create playlist 337 | const response = await fetch( 338 | `https://api.spotify.com/v1/users/${userId}/playlists`, 339 | { 340 | method: "POST", 341 | headers: { 342 | Authorization: `Bearer ${accessToken}`, 343 | "Content-Type": "application/json", 344 | }, 345 | body: JSON.stringify({ 346 | name, 347 | description, 348 | public: false, 349 | }), 350 | } 351 | ); 352 | 353 | const data = (await response.json()) as any; 354 | 355 | if (!response.ok) { 356 | return { 357 | content: [ 358 | { 359 | type: "text", 360 | text: `Error creating playlist: ${JSON.stringify(data)}`, 361 | }, 362 | ], 363 | isError: true, 364 | }; 365 | } 366 | 367 | return { 368 | content: [ 369 | { 370 | type: "text", 371 | text: `Playlist created successfully!\nName: ${data.name}\nID: ${data.id}\nURL: ${data.external_urls.spotify}`, 372 | }, 373 | ], 374 | }; 375 | } catch (error) { 376 | return { 377 | content: [ 378 | { 379 | type: "text", 380 | text: `Failed to create playlist: ${ 381 | error instanceof Error ? error.message : String(error) 382 | }`, 383 | }, 384 | ], 385 | isError: true, 386 | }; 387 | } 388 | } 389 | ); 390 | 391 | // Add tracks to playlist 392 | server.tool( 393 | "add-tracks-to-playlist", 394 | { 395 | playlistId: z.string().describe("The Spotify playlist ID"), 396 | trackUris: z 397 | .array(z.string()) 398 | .describe("Array of Spotify track URIs to add"), 399 | }, 400 | async ({ playlistId, trackUris }) => { 401 | try { 402 | const accessToken = await getValidAccessToken(); 403 | 404 | const response = await fetch( 405 | `https://api.spotify.com/v1/playlists/${playlistId}/tracks`, 406 | { 407 | method: "POST", 408 | headers: { 409 | Authorization: `Bearer ${accessToken}`, 410 | "Content-Type": "application/json", 411 | }, 412 | body: JSON.stringify({ 413 | uris: trackUris, 414 | }), 415 | } 416 | ); 417 | 418 | const data = (await response.json()) as any; 419 | 420 | if (!response.ok) { 421 | return { 422 | content: [ 423 | { 424 | type: "text", 425 | text: `Error adding tracks: ${JSON.stringify(data)}`, 426 | }, 427 | ], 428 | isError: true, 429 | }; 430 | } 431 | 432 | return { 433 | content: [ 434 | { 435 | type: "text", 436 | text: `Successfully added ${trackUris.length} track(s) to playlist!`, 437 | }, 438 | ], 439 | }; 440 | } catch (error) { 441 | return { 442 | content: [ 443 | { 444 | type: "text", 445 | text: `Failed to add tracks: ${ 446 | error instanceof Error ? error.message : String(error) 447 | }`, 448 | }, 449 | ], 450 | isError: true, 451 | }; 452 | } 453 | } 454 | ); 455 | 456 | // Get recommendations 457 | server.tool( 458 | "get-recommendations", 459 | { 460 | seedTracks: z 461 | .array(z.string()) 462 | .max(5) 463 | .describe("Spotify track IDs to use as seeds (max 5)"), 464 | limit: z 465 | .number() 466 | .min(1) 467 | .max(100) 468 | .default(20) 469 | .describe("Number of recommendations to return"), 470 | }, 471 | async ({ seedTracks, limit }) => { 472 | try { 473 | const accessToken = await getValidAccessToken(); 474 | 475 | if (seedTracks.length === 0) { 476 | return { 477 | content: [ 478 | { 479 | type: "text", 480 | text: "Error: At least one seed track is required", 481 | }, 482 | ], 483 | isError: true, 484 | }; 485 | } 486 | 487 | const response = await fetch( 488 | `https://api.spotify.com/v1/recommendations?seed_tracks=${seedTracks.join( 489 | "," 490 | )}&limit=${limit}`, 491 | { 492 | headers: { 493 | Authorization: `Bearer ${accessToken}`, 494 | }, 495 | } 496 | ); 497 | 498 | const data = (await response.json()) as any; 499 | 500 | if (!response.ok) { 501 | return { 502 | content: [ 503 | { 504 | type: "text", 505 | text: `Error getting recommendations: ${JSON.stringify(data)}`, 506 | }, 507 | ], 508 | isError: true, 509 | }; 510 | } 511 | 512 | const tracks = data.tracks.map((track: any) => ({ 513 | id: track.id, 514 | name: track.name, 515 | artist: track.artists.map((artist: any) => artist.name).join(", "), 516 | album: track.album.name, 517 | uri: track.uri, 518 | })); 519 | 520 | return { 521 | content: [ 522 | { 523 | type: "text", 524 | text: JSON.stringify(tracks, null, 2), 525 | }, 526 | ], 527 | }; 528 | } catch (error) { 529 | return { 530 | content: [ 531 | { 532 | type: "text", 533 | text: `Failed to get recommendations: ${ 534 | error instanceof Error ? error.message : String(error) 535 | }`, 536 | }, 537 | ], 538 | isError: true, 539 | }; 540 | } 541 | } 542 | ); 543 | 544 | let transport: SSEServerTransport | null = null; 545 | 546 | app.get("/sse", (req, res) => { 547 | transport = new SSEServerTransport("/messages", res); 548 | server.connect(transport); 549 | }); 550 | 551 | app.post("/messages", (req, res) => { 552 | if (transport) { 553 | transport.handlePostMessage(req, res); 554 | } else { 555 | res.status(503).send("No active transport"); 556 | } 557 | }); 558 | 559 | app.listen(3001, () => { 560 | console.log("Listening on port 3001"); 561 | }); 562 | ``` -------------------------------------------------------------------------------- /mcp/spotify-mcp-http.js: -------------------------------------------------------------------------------- ```javascript 1 | import express from "express"; 2 | import cors from "cors"; 3 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 4 | import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; 5 | import { z } from "zod"; 6 | import fetch from "node-fetch"; 7 | 8 | // GLOBAL credentials storage (shared across all requests) 9 | let globalSpotifyAuthInfo = { 10 | accessToken: "", 11 | refreshToken: "", 12 | clientId: "", 13 | clientSecret: "", 14 | }; 15 | 16 | // Helper function to create server instance 17 | function createSpotifyMcpServer() { 18 | const server = new McpServer({ 19 | name: "SpotifyServer", 20 | version: "1.0.0", 21 | capabilities: { 22 | tools: {}, 23 | }, 24 | }); 25 | 26 | // Refresh token when needed 27 | async function getValidAccessToken() { 28 | if ( 29 | !globalSpotifyAuthInfo.accessToken || 30 | !globalSpotifyAuthInfo.refreshToken 31 | ) { 32 | throw new Error( 33 | "No access token available. Please set credentials first using the set-spotify-credentials tool." 34 | ); 35 | } 36 | 37 | try { 38 | // Try using current token 39 | const response = await fetch("https://api.spotify.com/v1/me", { 40 | headers: { 41 | Authorization: `Bearer ${globalSpotifyAuthInfo.accessToken}`, 42 | }, 43 | }); 44 | 45 | // If token works, return it 46 | if (response.ok) { 47 | return globalSpotifyAuthInfo.accessToken; 48 | } 49 | 50 | console.log("Access token expired, refreshing..."); 51 | 52 | // If token doesn't work, refresh it 53 | const refreshResponse = await fetch( 54 | "https://accounts.spotify.com/api/token", 55 | { 56 | method: "POST", 57 | headers: { 58 | "Content-Type": "application/x-www-form-urlencoded", 59 | Authorization: 60 | "Basic " + 61 | Buffer.from( 62 | globalSpotifyAuthInfo.clientId + 63 | ":" + 64 | globalSpotifyAuthInfo.clientSecret 65 | ).toString("base64"), 66 | }, 67 | body: new URLSearchParams({ 68 | grant_type: "refresh_token", 69 | refresh_token: globalSpotifyAuthInfo.refreshToken, 70 | }), 71 | } 72 | ); 73 | 74 | const data = await refreshResponse.json(); 75 | 76 | if (data.access_token) { 77 | console.log("Successfully refreshed access token"); 78 | globalSpotifyAuthInfo.accessToken = data.access_token; 79 | return globalSpotifyAuthInfo.accessToken; 80 | } 81 | 82 | throw new Error("Failed to refresh access token"); 83 | } catch (error) { 84 | throw new Error("Error with access token: " + error.message); 85 | } 86 | } 87 | 88 | // Set credentials tool 89 | server.tool( 90 | "set-spotify-credentials", 91 | { 92 | clientId: z.string().describe("The Spotify Client ID"), 93 | clientSecret: z.string().describe("The Spotify Client Secret"), 94 | accessToken: z.string().describe("The Spotify Access Token"), 95 | refreshToken: z.string().describe("The Spotify Refresh Token"), 96 | }, 97 | async ({ clientId, clientSecret, accessToken, refreshToken }) => { 98 | globalSpotifyAuthInfo.clientId = clientId; 99 | globalSpotifyAuthInfo.clientSecret = clientSecret; 100 | globalSpotifyAuthInfo.accessToken = accessToken; 101 | globalSpotifyAuthInfo.refreshToken = refreshToken; 102 | 103 | return { 104 | content: [ 105 | { 106 | type: "text", 107 | text: "Spotify credentials set successfully. You can now use other Spotify tools.", 108 | }, 109 | ], 110 | }; 111 | } 112 | ); 113 | 114 | // Check credentials tool 115 | server.tool("check-credentials-status", {}, async () => { 116 | if ( 117 | !globalSpotifyAuthInfo.accessToken || 118 | !globalSpotifyAuthInfo.refreshToken || 119 | !globalSpotifyAuthInfo.clientId || 120 | !globalSpotifyAuthInfo.clientSecret 121 | ) { 122 | return { 123 | content: [ 124 | { 125 | type: "text", 126 | text: "Spotify credentials are not set. Please use the set-spotify-credentials tool.", 127 | }, 128 | ], 129 | }; 130 | } 131 | 132 | try { 133 | const accessToken = await getValidAccessToken(); 134 | 135 | const response = await fetch("https://api.spotify.com/v1/me", { 136 | headers: { 137 | Authorization: `Bearer ${accessToken}`, 138 | }, 139 | }); 140 | 141 | if (response.ok) { 142 | const userData = await response.json(); 143 | return { 144 | content: [ 145 | { 146 | type: "text", 147 | text: `Spotify credentials are valid.\nLogged in as: ${ 148 | userData.display_name 149 | } (${userData.email || "email not available"})`, 150 | }, 151 | ], 152 | }; 153 | } else { 154 | return { 155 | content: [ 156 | { 157 | type: "text", 158 | text: `Spotify credentials may be invalid. Status code: ${response.status}`, 159 | }, 160 | ], 161 | isError: true, 162 | }; 163 | } 164 | } catch (error) { 165 | return { 166 | content: [ 167 | { 168 | type: "text", 169 | text: `Error checking credentials: ${error.message}`, 170 | }, 171 | ], 172 | isError: true, 173 | }; 174 | } 175 | }); 176 | 177 | // Search tracks 178 | server.tool( 179 | "search-tracks", 180 | { 181 | query: z.string().describe("Search query for tracks"), 182 | limit: z 183 | .number() 184 | .min(1) 185 | .max(50) 186 | .default(10) 187 | .describe("Number of results to return"), 188 | }, 189 | async ({ query, limit }) => { 190 | try { 191 | const accessToken = await getValidAccessToken(); 192 | 193 | const response = await fetch( 194 | `https://api.spotify.com/v1/search?q=${encodeURIComponent( 195 | query 196 | )}&type=track&limit=${limit}`, 197 | { 198 | headers: { 199 | Authorization: `Bearer ${accessToken}`, 200 | }, 201 | } 202 | ); 203 | 204 | const data = await response.json(); 205 | 206 | if (!response.ok) { 207 | return { 208 | content: [ 209 | { 210 | type: "text", 211 | text: `Error searching tracks: ${JSON.stringify(data)}`, 212 | }, 213 | ], 214 | isError: true, 215 | }; 216 | } 217 | 218 | const tracks = data.tracks.items.map((track) => ({ 219 | id: track.id, 220 | name: track.name, 221 | artist: track.artists.map((artist) => artist.name).join(", "), 222 | album: track.album.name, 223 | uri: track.uri, 224 | })); 225 | 226 | return { 227 | content: [ 228 | { 229 | type: "text", 230 | text: JSON.stringify(tracks, null, 2), 231 | }, 232 | ], 233 | }; 234 | } catch (error) { 235 | return { 236 | content: [ 237 | { 238 | type: "text", 239 | text: `Failed to search tracks: ${error.message}`, 240 | }, 241 | ], 242 | isError: true, 243 | }; 244 | } 245 | } 246 | ); 247 | 248 | // Get current user 249 | server.tool("get-current-user", {}, async () => { 250 | try { 251 | const accessToken = await getValidAccessToken(); 252 | 253 | const response = await fetch("https://api.spotify.com/v1/me", { 254 | headers: { 255 | Authorization: `Bearer ${accessToken}`, 256 | }, 257 | }); 258 | 259 | const data = await response.json(); 260 | 261 | if (!response.ok) { 262 | return { 263 | content: [ 264 | { 265 | type: "text", 266 | text: `Error getting user profile: ${JSON.stringify(data)}`, 267 | }, 268 | ], 269 | isError: true, 270 | }; 271 | } 272 | 273 | return { 274 | content: [ 275 | { 276 | type: "text", 277 | text: JSON.stringify( 278 | { 279 | id: data.id, 280 | name: data.display_name, 281 | email: data.email, 282 | country: data.country, 283 | }, 284 | null, 285 | 2 286 | ), 287 | }, 288 | ], 289 | }; 290 | } catch (error) { 291 | return { 292 | content: [ 293 | { 294 | type: "text", 295 | text: `Failed to get user profile: ${error.message}`, 296 | }, 297 | ], 298 | isError: true, 299 | }; 300 | } 301 | }); 302 | 303 | // Create playlist 304 | server.tool( 305 | "create-playlist", 306 | { 307 | name: z.string().describe("Name of the playlist"), 308 | description: z 309 | .string() 310 | .optional() 311 | .describe("Description of the playlist"), 312 | }, 313 | async ({ name, description = "" }) => { 314 | try { 315 | const accessToken = await getValidAccessToken(); 316 | 317 | // Get user ID 318 | const userResponse = await fetch("https://api.spotify.com/v1/me", { 319 | headers: { 320 | Authorization: `Bearer ${accessToken}`, 321 | }, 322 | }); 323 | 324 | const userData = await userResponse.json(); 325 | const userId = userData.id; 326 | 327 | // Create playlist 328 | const response = await fetch( 329 | `https://api.spotify.com/v1/users/${userId}/playlists`, 330 | { 331 | method: "POST", 332 | headers: { 333 | Authorization: `Bearer ${accessToken}`, 334 | "Content-Type": "application/json", 335 | }, 336 | body: JSON.stringify({ 337 | name, 338 | description, 339 | public: false, 340 | }), 341 | } 342 | ); 343 | 344 | const data = await response.json(); 345 | 346 | if (!response.ok) { 347 | return { 348 | content: [ 349 | { 350 | type: "text", 351 | text: `Error creating playlist: ${JSON.stringify(data)}`, 352 | }, 353 | ], 354 | isError: true, 355 | }; 356 | } 357 | 358 | return { 359 | content: [ 360 | { 361 | type: "text", 362 | text: `Playlist created successfully!\nName: ${data.name}\nID: ${data.id}\nURL: ${data.external_urls.spotify}`, 363 | }, 364 | ], 365 | }; 366 | } catch (error) { 367 | return { 368 | content: [ 369 | { 370 | type: "text", 371 | text: `Failed to create playlist: ${error.message}`, 372 | }, 373 | ], 374 | isError: true, 375 | }; 376 | } 377 | } 378 | ); 379 | 380 | // Add tracks to playlist 381 | server.tool( 382 | "add-tracks-to-playlist", 383 | { 384 | playlistId: z.string().describe("The Spotify playlist ID"), 385 | trackUris: z 386 | .array(z.string()) 387 | .describe("Array of Spotify track URIs to add"), 388 | }, 389 | async ({ playlistId, trackUris }) => { 390 | try { 391 | const accessToken = await getValidAccessToken(); 392 | 393 | const response = await fetch( 394 | `https://api.spotify.com/v1/playlists/${playlistId}/tracks`, 395 | { 396 | method: "POST", 397 | headers: { 398 | Authorization: `Bearer ${accessToken}`, 399 | "Content-Type": "application/json", 400 | }, 401 | body: JSON.stringify({ 402 | uris: trackUris, 403 | }), 404 | } 405 | ); 406 | 407 | const data = await response.json(); 408 | 409 | if (!response.ok) { 410 | return { 411 | content: [ 412 | { 413 | type: "text", 414 | text: `Error adding tracks: ${JSON.stringify(data)}`, 415 | }, 416 | ], 417 | isError: true, 418 | }; 419 | } 420 | 421 | return { 422 | content: [ 423 | { 424 | type: "text", 425 | text: `Successfully added ${trackUris.length} track(s) to playlist!`, 426 | }, 427 | ], 428 | }; 429 | } catch (error) { 430 | return { 431 | content: [ 432 | { 433 | type: "text", 434 | text: `Failed to add tracks: ${error.message}`, 435 | }, 436 | ], 437 | isError: true, 438 | }; 439 | } 440 | } 441 | ); 442 | 443 | // Get recommendations 444 | server.tool( 445 | "get-recommendations", 446 | { 447 | seedTracks: z 448 | .array(z.string()) 449 | .max(5) 450 | .describe("Spotify track IDs to use as seeds (max 5)"), 451 | limit: z 452 | .number() 453 | .min(1) 454 | .max(100) 455 | .default(20) 456 | .describe("Number of recommendations to return"), 457 | }, 458 | async ({ seedTracks, limit }) => { 459 | try { 460 | const accessToken = await getValidAccessToken(); 461 | 462 | if (seedTracks.length === 0) { 463 | return { 464 | content: [ 465 | { 466 | type: "text", 467 | text: "Error: At least one seed track is required", 468 | }, 469 | ], 470 | isError: true, 471 | }; 472 | } 473 | 474 | const response = await fetch( 475 | `https://api.spotify.com/v1/recommendations?seed_tracks=${seedTracks.join( 476 | "," 477 | )}&limit=${limit}`, 478 | { 479 | headers: { 480 | Authorization: `Bearer ${accessToken}`, 481 | }, 482 | } 483 | ); 484 | 485 | const data = await response.json(); 486 | 487 | if (!response.ok) { 488 | return { 489 | content: [ 490 | { 491 | type: "text", 492 | text: `Error getting recommendations: ${JSON.stringify(data)}`, 493 | }, 494 | ], 495 | isError: true, 496 | }; 497 | } 498 | 499 | const tracks = data.tracks.map((track) => ({ 500 | id: track.id, 501 | name: track.name, 502 | artist: track.artists.map((artist) => artist.name).join(", "), 503 | album: track.album.name, 504 | uri: track.uri, 505 | })); 506 | 507 | return { 508 | content: [ 509 | { 510 | type: "text", 511 | text: JSON.stringify(tracks, null, 2), 512 | }, 513 | ], 514 | }; 515 | } catch (error) { 516 | return { 517 | content: [ 518 | { 519 | type: "text", 520 | text: `Failed to get recommendations: ${error.message}`, 521 | }, 522 | ], 523 | isError: true, 524 | }; 525 | } 526 | } 527 | ); 528 | 529 | return server; 530 | } 531 | 532 | // Create Express app 533 | const app = express(); 534 | app.use(express.json()); 535 | 536 | // CORS configuration 537 | app.use( 538 | cors({ 539 | origin: "*", // Configure this for production 540 | exposedHeaders: ["Mcp-Session-Id"], 541 | allowedHeaders: ["Content-Type", "mcp-session-id"], 542 | }) 543 | ); 544 | 545 | // Health check endpoint 546 | app.get("/health", (req, res) => { 547 | res.json({ status: "healthy", timestamp: new Date().toISOString() }); 548 | }); 549 | 550 | // MCP endpoint (stateless mode) 551 | app.post("/mcp", async (req, res) => { 552 | try { 553 | // Create new server instance for each request (stateless) 554 | const server = createSpotifyMcpServer(); 555 | const transport = new StreamableHTTPServerTransport({ 556 | sessionIdGenerator: undefined, // No session management 557 | }); 558 | 559 | // Clean up when request closes 560 | res.on("close", () => { 561 | console.log("Request closed, cleaning up"); 562 | transport.close(); 563 | server.close(); 564 | }); 565 | 566 | // Connect server to transport 567 | await server.connect(transport); 568 | 569 | // Handle the request 570 | await transport.handleRequest(req, res, req.body); 571 | } catch (error) { 572 | console.error("Error handling MCP request:", error); 573 | if (!res.headersSent) { 574 | res.status(500).json({ 575 | jsonrpc: "2.0", 576 | error: { 577 | code: -32603, 578 | message: "Internal server error", 579 | }, 580 | id: null, 581 | }); 582 | } 583 | } 584 | }); 585 | 586 | // Start the server 587 | const PORT = process.env.PORT || 8080; 588 | app.listen(PORT, () => { 589 | console.log(`Spotify MCP Server running on port ${PORT}`); 590 | console.log(`Health check: http://localhost:${PORT}/health`); 591 | console.log(`MCP endpoint: http://localhost:${PORT}/mcp`); 592 | console.log(`Mode: STATELESS`); 593 | }); 594 | ``` -------------------------------------------------------------------------------- /mcp/spotify-mcp-oauth-http.js: -------------------------------------------------------------------------------- ```javascript 1 | import express from "express"; 2 | import cors from "cors"; 3 | import { randomUUID } from "node:crypto"; 4 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 5 | import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; 6 | import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; 7 | import { z } from "zod"; 8 | import fetch from "node-fetch"; 9 | 10 | // Environment variables 11 | const SPOTIFY_CLIENT_ID = process.env.SPOTIFY_CLIENT_ID; 12 | const SPOTIFY_CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET; 13 | const SPOTIFY_REDIRECT_URI = 14 | process.env.SPOTIFY_REDIRECT_URI || "http://localhost:8080/callback/spotify"; 15 | 16 | if (!SPOTIFY_CLIENT_ID || !SPOTIFY_CLIENT_SECRET) { 17 | console.error( 18 | "Missing required environment variables: SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET" 19 | ); 20 | process.exit(1); 21 | } 22 | 23 | console.log("🎵 Spotify Config:"); 24 | console.log("- Client ID:", SPOTIFY_CLIENT_ID); 25 | console.log("- Redirect URI:", SPOTIFY_REDIRECT_URI); 26 | 27 | // Session storage 28 | const transports = new Map(); 29 | const sessionTokens = new Map(); 30 | const sessionActivity = new Map(); // Track last activity per session 31 | 32 | // Session cleanup configuration 33 | const INACTIVITY_TIMEOUT = 8 * 60 * 1000; // 8 minutes 34 | const CLEANUP_INTERVAL = 3 * 60 * 1000; // 3 minutes 35 | 36 | // Simple cleanup function 37 | function cleanupSession(sessionId) { 38 | console.log(`🧹 Cleaning up session: ${sessionId}`); 39 | 40 | const transport = transports.get(sessionId); 41 | if (transport) { 42 | try { 43 | transport.close(); 44 | } catch (error) { 45 | console.warn(`Warning closing transport:`, error.message); 46 | } 47 | transports.delete(sessionId); 48 | } 49 | 50 | sessionTokens.delete(sessionId); 51 | sessionActivity.delete(sessionId); 52 | } 53 | 54 | // Update session activity 55 | function updateSessionActivity(sessionId) { 56 | sessionActivity.set(sessionId, Date.now()); 57 | } 58 | 59 | // Cleanup inactive sessions 60 | function cleanupInactiveSessions() { 61 | const now = Date.now(); 62 | const inactiveSessions = []; 63 | 64 | for (const [sessionId, lastActivity] of sessionActivity.entries()) { 65 | if (now - lastActivity > INACTIVITY_TIMEOUT) { 66 | inactiveSessions.push(sessionId); 67 | } 68 | } 69 | 70 | if (inactiveSessions.length > 0) { 71 | console.log(`🧹 Cleaning up ${inactiveSessions.length} inactive sessions`); 72 | inactiveSessions.forEach(cleanupSession); 73 | } 74 | } 75 | 76 | // Start cleanup interval 77 | setInterval(() => { 78 | try { 79 | cleanupInactiveSessions(); 80 | } catch (error) { 81 | console.error('Error during inactive session cleanup:', error); 82 | } 83 | }, CLEANUP_INTERVAL); 84 | 85 | // Helper functions 86 | function isTokenExpired(tokens) { 87 | if (!tokens.expiresAt) return true; 88 | return Date.now() >= tokens.expiresAt; 89 | } 90 | 91 | function getSpotifyAuthUrl(sessionId) { 92 | const params = new URLSearchParams({ 93 | response_type: "code", 94 | client_id: SPOTIFY_CLIENT_ID, 95 | scope: 96 | "user-read-private user-read-email playlist-read-private playlist-modify-private playlist-modify-public", 97 | redirect_uri: SPOTIFY_REDIRECT_URI, 98 | state: sessionId, 99 | }); 100 | return `https://accounts.spotify.com/authorize?${params.toString()}`; 101 | } 102 | 103 | async function exchangeCodeForTokens(code) { 104 | const response = await fetch("https://accounts.spotify.com/api/token", { 105 | method: "POST", 106 | headers: { 107 | "Content-Type": "application/x-www-form-urlencoded", 108 | Authorization: 109 | "Basic " + 110 | Buffer.from(SPOTIFY_CLIENT_ID + ":" + SPOTIFY_CLIENT_SECRET).toString( 111 | "base64" 112 | ), 113 | }, 114 | body: new URLSearchParams({ 115 | grant_type: "authorization_code", 116 | code: code, 117 | redirect_uri: SPOTIFY_REDIRECT_URI, 118 | }), 119 | }); 120 | 121 | const data = await response.json(); 122 | 123 | if (!response.ok) { 124 | throw new Error( 125 | `Token exchange failed: ${data.error_description || data.error}` 126 | ); 127 | } 128 | 129 | return { 130 | accessToken: data.access_token, 131 | refreshToken: data.refresh_token, 132 | expiresAt: Date.now() + data.expires_in * 1000, 133 | }; 134 | } 135 | 136 | async function refreshSpotifyToken(tokens) { 137 | const response = await fetch("https://accounts.spotify.com/api/token", { 138 | method: "POST", 139 | headers: { 140 | "Content-Type": "application/x-www-form-urlencoded", 141 | Authorization: 142 | "Basic " + 143 | Buffer.from(SPOTIFY_CLIENT_ID + ":" + SPOTIFY_CLIENT_SECRET).toString( 144 | "base64" 145 | ), 146 | }, 147 | body: new URLSearchParams({ 148 | grant_type: "refresh_token", 149 | refresh_token: tokens.refreshToken, 150 | }), 151 | }); 152 | 153 | const data = await response.json(); 154 | 155 | if (!response.ok) { 156 | throw new Error( 157 | `Token refresh failed: ${data.error_description || data.error}` 158 | ); 159 | } 160 | 161 | return { 162 | accessToken: data.access_token, 163 | refreshToken: data.refresh_token || tokens.refreshToken, 164 | expiresAt: Date.now() + data.expires_in * 1000, 165 | }; 166 | } 167 | 168 | async function getValidAccessToken(sessionId) { 169 | let tokens = sessionTokens.get(sessionId); 170 | 171 | if (!tokens) { 172 | return null; 173 | } 174 | 175 | if (isTokenExpired(tokens)) { 176 | try { 177 | tokens = await refreshSpotifyToken(tokens); 178 | sessionTokens.set(sessionId, tokens); 179 | } catch (error) { 180 | console.log( 181 | `❌ Token refresh failed for session ${sessionId}:`, 182 | error.message 183 | ); 184 | sessionTokens.delete(sessionId); 185 | return null; 186 | } 187 | } 188 | 189 | return tokens.accessToken; 190 | } 191 | 192 | async function handleSpotifyTool(sessionId, apiCall) { 193 | const accessToken = await getValidAccessToken(sessionId); 194 | 195 | if (!accessToken) { 196 | console.log(`🔐 Auth required for session: ${sessionId}`); 197 | const baseUrl = 198 | process.env.SPOTIFY_REDIRECT_URI?.replace("/callback/spotify", "") || 199 | "http://localhost:8080"; 200 | const authUrl = `${baseUrl}/auth?session=${sessionId}`; 201 | 202 | return { 203 | content: [ 204 | { 205 | type: "text", 206 | text: `🎵 **Spotify Authentication Required** 207 | 208 | To use Spotify features, please visit: 209 | 210 | ${authUrl} 211 | 212 | This will redirect you to connect your Spotify account. After authentication, return here and try your request again.`, 213 | }, 214 | ], 215 | }; 216 | } 217 | 218 | try { 219 | console.log(`✅ Executing Spotify API call for session: ${sessionId}`); 220 | return await apiCall(accessToken); 221 | } catch (error) { 222 | if (error.response?.status === 401) { 223 | console.log(`❌ Token expired for session: ${sessionId}`); 224 | sessionTokens.delete(sessionId); 225 | const baseUrl = 226 | process.env.SPOTIFY_REDIRECT_URI?.replace("/callback/spotify", "") || 227 | "http://localhost:8080"; 228 | const authUrl = `${baseUrl}/auth?session=${sessionId}`; 229 | return { 230 | content: [ 231 | { 232 | type: "text", 233 | text: `🔐 **Spotify Authentication Expired** 234 | 235 | Your Spotify session has expired. Please visit: 236 | 237 | ${authUrl} 238 | 239 | After completing authentication, return here and try your request again.`, 240 | }, 241 | ], 242 | isError: true, 243 | }; 244 | } 245 | 246 | console.log( 247 | `❌ Spotify API error for session ${sessionId}:`, 248 | error.message 249 | ); 250 | return { 251 | content: [ 252 | { 253 | type: "text", 254 | text: `❌ **Spotify API Error** 255 | 256 | ${error.message}`, 257 | }, 258 | ], 259 | isError: true, 260 | }; 261 | } 262 | } 263 | 264 | // Create MCP server instance with session context 265 | function createSpotifyMcpServer(sessionId) { 266 | const server = new McpServer({ 267 | name: "SpotifyServer", 268 | version: "1.0.0", 269 | }); 270 | 271 | // Search tracks 272 | server.registerTool( 273 | "search-tracks", 274 | { 275 | title: "Search Spotify Tracks", 276 | description: "Search for tracks on Spotify", 277 | inputSchema: { 278 | query: z.string().describe("Search query for tracks"), 279 | limit: z 280 | .number() 281 | .min(1) 282 | .max(50) 283 | .default(10) 284 | .describe("Number of results to return"), 285 | }, 286 | }, 287 | async ({ query, limit }) => { 288 | return await handleSpotifyTool(sessionId, async (accessToken) => { 289 | const response = await fetch( 290 | `https://api.spotify.com/v1/search?q=${encodeURIComponent( 291 | query 292 | )}&type=track&limit=${limit}`, 293 | { 294 | headers: { 295 | Authorization: `Bearer ${accessToken}`, 296 | }, 297 | } 298 | ); 299 | 300 | const data = await response.json(); 301 | 302 | if (!response.ok) { 303 | throw new Error( 304 | `Spotify API error: ${data.error?.message || "Unknown error"}` 305 | ); 306 | } 307 | 308 | const tracks = data.tracks.items.map((track) => ({ 309 | id: track.id, 310 | name: track.name, 311 | artist: track.artists.map((artist) => artist.name).join(", "), 312 | album: track.album.name, 313 | uri: track.uri, 314 | })); 315 | 316 | return { 317 | content: [ 318 | { 319 | type: "text", 320 | text: JSON.stringify(tracks, null, 2), 321 | }, 322 | ], 323 | }; 324 | }); 325 | } 326 | ); 327 | 328 | // Get current user 329 | server.registerTool( 330 | "get-current-user", 331 | { 332 | title: "Get Current User", 333 | description: "Get current Spotify user information", 334 | }, 335 | async () => { 336 | return await handleSpotifyTool(sessionId, async (accessToken) => { 337 | const response = await fetch("https://api.spotify.com/v1/me", { 338 | headers: { 339 | Authorization: `Bearer ${accessToken}`, 340 | }, 341 | }); 342 | 343 | const data = await response.json(); 344 | 345 | if (!response.ok) { 346 | throw new Error( 347 | `Spotify API error: ${data.error?.message || "Unknown error"}` 348 | ); 349 | } 350 | 351 | return { 352 | content: [ 353 | { 354 | type: "text", 355 | text: JSON.stringify( 356 | { 357 | id: data.id, 358 | name: data.display_name, 359 | email: data.email, 360 | country: data.country, 361 | followers: data.followers?.total || 0, 362 | }, 363 | null, 364 | 2 365 | ), 366 | }, 367 | ], 368 | }; 369 | }); 370 | } 371 | ); 372 | 373 | // Create playlist 374 | server.registerTool( 375 | "create-playlist", 376 | { 377 | title: "Create Playlist", 378 | description: "Create a new playlist on Spotify", 379 | inputSchema: { 380 | name: z.string().describe("Name of the playlist"), 381 | description: z 382 | .string() 383 | .optional() 384 | .describe("Description of the playlist"), 385 | public: z 386 | .boolean() 387 | .default(false) 388 | .describe("Whether playlist should be public"), 389 | }, 390 | }, 391 | async ({ name, description = "", public: isPublic }) => { 392 | return await handleSpotifyTool(sessionId, async (accessToken) => { 393 | // Get user ID first 394 | const userResponse = await fetch("https://api.spotify.com/v1/me", { 395 | headers: { 396 | Authorization: `Bearer ${accessToken}`, 397 | }, 398 | }); 399 | 400 | const userData = await userResponse.json(); 401 | const userId = userData.id; 402 | 403 | // Create playlist 404 | const response = await fetch( 405 | `https://api.spotify.com/v1/users/${userId}/playlists`, 406 | { 407 | method: "POST", 408 | headers: { 409 | Authorization: `Bearer ${accessToken}`, 410 | "Content-Type": "application/json", 411 | }, 412 | body: JSON.stringify({ 413 | name, 414 | description, 415 | public: isPublic, 416 | }), 417 | } 418 | ); 419 | 420 | const data = await response.json(); 421 | 422 | if (!response.ok) { 423 | throw new Error( 424 | `Spotify API error: ${data.error?.message || "Unknown error"}` 425 | ); 426 | } 427 | 428 | return { 429 | content: [ 430 | { 431 | type: "text", 432 | text: `✅ Playlist created successfully!\n\n**${data.name}**\nID: ${data.id}\n🔗 [Open in Spotify](${data.external_urls.spotify})`, 433 | }, 434 | ], 435 | }; 436 | }); 437 | } 438 | ); 439 | 440 | // Add tracks to playlist 441 | server.registerTool( 442 | "add-tracks-to-playlist", 443 | { 444 | title: "Add Tracks to Playlist", 445 | description: "Add tracks to an existing playlist", 446 | inputSchema: { 447 | playlistId: z.string().describe("The Spotify playlist ID"), 448 | trackUris: z 449 | .array(z.string()) 450 | .describe("Array of Spotify track URIs to add"), 451 | }, 452 | }, 453 | async ({ playlistId, trackUris }) => { 454 | return await handleSpotifyTool(sessionId, async (accessToken) => { 455 | const response = await fetch( 456 | `https://api.spotify.com/v1/playlists/${playlistId}/tracks`, 457 | { 458 | method: "POST", 459 | headers: { 460 | Authorization: `Bearer ${accessToken}`, 461 | "Content-Type": "application/json", 462 | }, 463 | body: JSON.stringify({ 464 | uris: trackUris, 465 | }), 466 | } 467 | ); 468 | 469 | const data = await response.json(); 470 | 471 | if (!response.ok) { 472 | throw new Error( 473 | `Spotify API error: ${data.error?.message || "Unknown error"}` 474 | ); 475 | } 476 | 477 | return { 478 | content: [ 479 | { 480 | type: "text", 481 | text: `✅ Successfully added ${trackUris.length} track(s) to playlist!`, 482 | }, 483 | ], 484 | }; 485 | }); 486 | } 487 | ); 488 | 489 | // Get recommendations 490 | server.registerTool( 491 | "get-recommendations", 492 | { 493 | title: "Get Recommendations", 494 | description: "Get music recommendations based on seed tracks", 495 | inputSchema: { 496 | seedTracks: z 497 | .array(z.string()) 498 | .max(5) 499 | .describe("Spotify track IDs to use as seeds (max 5)"), 500 | limit: z 501 | .number() 502 | .min(1) 503 | .max(100) 504 | .default(20) 505 | .describe("Number of recommendations to return"), 506 | }, 507 | }, 508 | async ({ seedTracks, limit }) => { 509 | return await handleSpotifyTool(sessionId, async (accessToken) => { 510 | if (seedTracks.length === 0) { 511 | throw new Error("At least one seed track is required"); 512 | } 513 | 514 | const response = await fetch( 515 | `https://api.spotify.com/v1/recommendations?seed_tracks=${seedTracks.join( 516 | "," 517 | )}&limit=${limit}`, 518 | { 519 | headers: { 520 | Authorization: `Bearer ${accessToken}`, 521 | }, 522 | } 523 | ); 524 | 525 | const data = await response.json(); 526 | 527 | if (!response.ok) { 528 | throw new Error( 529 | `Spotify API error: ${data.error?.message || "Unknown error"}` 530 | ); 531 | } 532 | 533 | const tracks = data.tracks.map((track) => ({ 534 | id: track.id, 535 | name: track.name, 536 | artist: track.artists.map((artist) => artist.name).join(", "), 537 | album: track.album.name, 538 | uri: track.uri, 539 | })); 540 | 541 | return { 542 | content: [ 543 | { 544 | type: "text", 545 | text: JSON.stringify(tracks, null, 2), 546 | }, 547 | ], 548 | }; 549 | }); 550 | } 551 | ); 552 | 553 | return server; 554 | } 555 | 556 | // Express app setup 557 | const app = express(); 558 | app.use(express.json()); 559 | 560 | // CORS configuration 561 | app.use( 562 | cors({ 563 | origin: "*", 564 | exposedHeaders: ["Mcp-Session-Id"], 565 | allowedHeaders: ["Content-Type", "mcp-session-id"], 566 | }) 567 | ); 568 | 569 | // Health check 570 | app.get("/health", (req, res) => { 571 | res.json({ 572 | status: "healthy", 573 | timestamp: new Date().toISOString(), 574 | activeSessions: transports.size, 575 | }); 576 | }); 577 | 578 | // Simple auth landing page 579 | app.get("/auth", (req, res) => { 580 | const sessionId = req.query.session; 581 | 582 | if (!sessionId) { 583 | res.status(400).send("Missing session ID"); 584 | return; 585 | } 586 | 587 | const authUrl = getSpotifyAuthUrl(sessionId); 588 | 589 | res.send(` 590 | <html> 591 | <head> 592 | <title>Connect Spotify to Claude</title> 593 | <style> 594 | body { 595 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 596 | text-align: center; 597 | padding: 50px; 598 | background: linear-gradient(135deg, #667eea, #764ba2); 599 | color: white; 600 | margin: 0; 601 | } 602 | .container { 603 | background: rgba(0,0,0,0.1); 604 | padding: 40px; 605 | border-radius: 20px; 606 | backdrop-filter: blur(10px); 607 | display: inline-block; 608 | max-width: 500px; 609 | } 610 | .spotify-icon { font-size: 60px; margin-bottom: 20px; } 611 | h1 { margin: 20px 0; font-size: 28px; } 612 | p { font-size: 18px; line-height: 1.5; opacity: 0.9; } 613 | .auth-button { 614 | display: inline-block; 615 | background: #1db954; 616 | color: white; 617 | padding: 15px 30px; 618 | border-radius: 50px; 619 | text-decoration: none; 620 | font-size: 18px; 621 | font-weight: bold; 622 | margin: 20px 0; 623 | transition: all 0.3s ease; 624 | } 625 | .auth-button:hover { 626 | background: #1ed760; 627 | transform: translateY(-2px); 628 | } 629 | </style> 630 | </head> 631 | <body> 632 | <div class="container"> 633 | <div class="spotify-icon">🎵</div> 634 | <h1>Connect Spotify to Claude</h1> 635 | <p>Click the button below to connect your Spotify account and enable music features in Claude.</p> 636 | 637 | <a href="${authUrl}" class="auth-button">Connect Spotify Account</a> 638 | 639 | <p style="font-size: 14px; margin-top: 30px;"> 640 | After connecting, return to Claude to use Spotify features. 641 | </p> 642 | </div> 643 | </body> 644 | </html> 645 | `); 646 | }); 647 | 648 | // Spotify OAuth callback 649 | app.get("/callback/spotify", async (req, res) => { 650 | const { code, state: sessionId, error } = req.query; 651 | 652 | if (error) { 653 | console.log(`❌ OAuth error: ${error}`); 654 | res.status(400).send(`Authentication error: ${error}`); 655 | return; 656 | } 657 | 658 | if (!code || !sessionId) { 659 | console.log(`❌ OAuth callback missing code or sessionId`); 660 | res.status(400).send("Missing authorization code or session ID"); 661 | return; 662 | } 663 | 664 | console.log(`🔄 Processing OAuth callback for session: ${sessionId}`); 665 | 666 | try { 667 | const tokens = await exchangeCodeForTokens(code); 668 | sessionTokens.set(sessionId, tokens); 669 | console.log(`✅ OAuth successful for session: ${sessionId}`); 670 | 671 | res.send(` 672 | <html> 673 | <head> 674 | <title>Spotify Connected Successfully</title> 675 | <style> 676 | body { 677 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 678 | text-align: center; 679 | padding: 50px; 680 | background: linear-gradient(135deg, #1db954, #1ed760); 681 | color: white; 682 | margin: 0; 683 | } 684 | .container { 685 | background: rgba(0,0,0,0.1); 686 | padding: 40px; 687 | border-radius: 20px; 688 | backdrop-filter: blur(10px); 689 | display: inline-block; 690 | max-width: 500px; 691 | } 692 | .success-icon { font-size: 60px; margin-bottom: 20px; } 693 | h1 { margin: 20px 0; font-size: 28px; } 694 | p { font-size: 18px; line-height: 1.5; opacity: 0.9; } 695 | .instruction { 696 | background: rgba(255,255,255,0.1); 697 | padding: 20px; 698 | border-radius: 10px; 699 | margin-top: 30px; 700 | border: 1px solid rgba(255,255,255,0.2); 701 | } 702 | </style> 703 | </head> 704 | <body> 705 | <div class="container"> 706 | <div class="success-icon">🎵</div> 707 | <h1>Successfully Connected to Spotify!</h1> 708 | <p>Your Spotify account is now linked to Claude.</p> 709 | 710 | <div class="instruction"> 711 | <strong>Next Steps:</strong><br> 712 | 1. Return to your Claude conversation<br> 713 | 2. Try your Spotify request again<br> 714 | 3. This window can be closed 715 | </div> 716 | </div> 717 | </body> 718 | </html> 719 | `); 720 | } catch (error) { 721 | console.error( 722 | `❌ Token exchange failed for session ${sessionId}:`, 723 | error.message 724 | ); 725 | res.status(500).send(`Authentication error: ${error.message}`); 726 | } 727 | }); 728 | 729 | // MCP endpoint with session management 730 | app.post("/mcp", async (req, res) => { 731 | try { 732 | const sessionId = req.headers["mcp-session-id"]; 733 | let transport; 734 | 735 | if (sessionId && transports.has(sessionId)) { 736 | // Reuse existing transport and update activity 737 | transport = transports.get(sessionId); 738 | updateSessionActivity(sessionId); 739 | } else if (!sessionId && isInitializeRequest(req.body)) { 740 | // New initialization request 741 | const newSessionId = randomUUID(); 742 | 743 | transport = new StreamableHTTPServerTransport({ 744 | sessionIdGenerator: () => newSessionId, 745 | onsessioninitialized: (sessionId) => { 746 | console.log(`🎯 New MCP session initialized: ${sessionId}`); 747 | updateSessionActivity(sessionId); // Track initial activity 748 | }, 749 | }); 750 | 751 | // Clean up transport when closed 752 | transport.onclose = () => { 753 | if (transport.sessionId) { 754 | console.log(`🔌 MCP session closed: ${transport.sessionId}`); 755 | cleanupSession(transport.sessionId); 756 | } 757 | }; 758 | 759 | // Create and connect server with sessionId 760 | const server = createSpotifyMcpServer(newSessionId); 761 | await server.connect(transport); 762 | 763 | // Store transport 764 | transports.set(newSessionId, transport); 765 | } else { 766 | // Invalid request 767 | res.status(400).json({ 768 | jsonrpc: "2.0", 769 | error: { 770 | code: -32000, 771 | message: "Bad Request: No valid session ID provided", 772 | }, 773 | id: null, 774 | }); 775 | return; 776 | } 777 | 778 | // Handle the request 779 | await transport.handleRequest(req, res, req.body); 780 | } catch (error) { 781 | console.error(`❌ MCP request error:`, error.message); 782 | if (!res.headersSent) { 783 | res.status(500).json({ 784 | jsonrpc: "2.0", 785 | error: { 786 | code: -32603, 787 | message: "Internal server error", 788 | }, 789 | id: null, 790 | }); 791 | } 792 | } 793 | }); 794 | 795 | // Handle GET requests for server-to-client notifications via SSE 796 | app.get("/mcp", async (req, res) => { 797 | const sessionId = req.headers["mcp-session-id"]; 798 | if (!sessionId || !transports.has(sessionId)) { 799 | res.status(400).send("Invalid or missing session ID"); 800 | return; 801 | } 802 | 803 | const transport = transports.get(sessionId); 804 | updateSessionActivity(sessionId); // Update activity for SSE requests too 805 | await transport.handleRequest(req, res); 806 | }); 807 | 808 | // Handle DELETE requests for session termination 809 | app.delete("/mcp", async (req, res) => { 810 | const sessionId = req.headers["mcp-session-id"]; 811 | if (!sessionId || !transports.has(sessionId)) { 812 | res.status(400).send("Invalid or missing session ID"); 813 | return; 814 | } 815 | 816 | const transport = transports.get(sessionId); 817 | await transport.handleRequest(req, res); 818 | 819 | // Clean up on explicit delete 820 | cleanupSession(sessionId); 821 | }); 822 | 823 | // Start server 824 | const PORT = process.env.PORT || 8080; 825 | app.listen(PORT, () => { 826 | console.log(`🚀 Spotify OAuth MCP Server running on port ${PORT}`); 827 | console.log(`🔗 Health check: http://localhost:${PORT}/health`); 828 | console.log(`🔗 MCP endpoint: http://localhost:${PORT}/mcp`); 829 | console.log(`🔗 OAuth callback: http://localhost:${PORT}/callback/spotify`); 830 | console.log(`📊 Mode: STATEFUL with OAuth`); 831 | console.log( 832 | `⏰ Session cleanup: ${INACTIVITY_TIMEOUT / 60000} min inactivity timeout` 833 | ); 834 | }); 835 | ```