Claude based let claude create Playlist and choose your music via Spotify
Spotify-MCP
Claude based let claude create Playlist and choose your music via Spotify
Spotify MCP Setup Guide
For Cloudflare Workers + Claude MCP Integration
Last updated: March 8, 2026
What Works
All features working: Play, pause, skip, queue tracks, search, now playing, recently played, top tracks/artists, get recommendations, create playlists, add tracks to playlists, remove tracks, update playlists
âš ï¸ Critical: February 2026 Spotify API Change
Spotify renamed all playlist track management endpoints from
/tracksto/items. If you're getting 403 errors on add/remove/get playlist tracks, this is why. The code below has been updated with the fix.
Prerequisites
- Cloudflare account (free tier works)
- Node.js installed locally
- Wrangler CLI:
npm install -g wrangler - Spotify account (free or premium)
Part 1: Create Spotify Developer App
- Go to developer.spotify.com/dashboard
- Click "Create App"
- Fill in:
- App name: Whatever you want (e.g., "Saints Jukebox")
- App description: Anything
- Redirect URI:
https://YOUR-WORKER-NAME.YOUR-SUBDOMAIN.workers.dev/callback - Check "Web API"
- Save. Copy the Client ID.
- Click "View client secret" and copy that too.
- Go to "User Management" tab → Add your Spotify email address
Part 2: Create Cloudflare Worker
- In terminal, create project folder:
mkdir spotify-mcp && cd spotify-mcp
- Login to Cloudflare:
wrangler login
- Create
wrangler.tomlfile:
name = "spotify-mcp"
main = "src/index.js"
compatibility_date = "2024-01-01"
[[kv_namespaces]]
binding = "SPOTIFY_KV"
id = "YOUR_KV_ID_HERE"
- Create KV namespace:
wrangler kv:namespace create SPOTIFY_KV
Copy the ID it gives you into wrangler.toml
- Create src folder:
mkdir src
Then create src/index.js with the worker code (see Part 6).
Part 3: Set Environment Variables
Run these commands (replace with your actual values):
wrangler secret put SPOTIFY_CLIENT_ID
(paste your client ID when prompted)
wrangler secret put SPOTIFY_CLIENT_SECRET
(paste your client secret when prompted)
wrangler secret put SPOTIFY_REDIRECT_URI
(paste: https://YOUR-WORKER.workers.dev/callback)
Part 4: Deploy and Authorize
- Deploy the worker:
wrangler deploy
- Go to:
https://YOUR-WORKER.workers.dev/auth - Log in with your Spotify account
- Accept ALL permissions (playlists, playback, etc.)
- You'll be redirected to /callback — copy the refresh token shown
- Set the refresh token:
wrangler secret put SPOTIFY_REFRESH_TOKEN
(paste the refresh token)
- Redeploy:
wrangler deploy
Part 5: Connect to Claude
- In Claude.ai, go to Settings → Connectors
- Add MCP Server
- URL:
https://YOUR-WORKER.workers.dev/mcp - Give it a name
- Test it: "What's playing on Spotify?"
Troubleshooting
"Invalid access token" or 401 errors:
- Go to Cloudflare Dashboard → Workers → your worker → KV
- Delete the "access_token" key
- Try again (it will auto-refresh)
403 Forbidden on add_tracks / remove_tracks / get_playlist_tracks:
- FIXED! Spotify changed the endpoint from
/tracksto/itemsin February 2026 - Update all playlist item endpoints:
/playlists/{id}/tracks→/playlists/{id}/items - This applies to: add tracks, remove tracks, get playlist tracks, reorder items
Scopes missing:
- Go to spotify.com/account/apps → Remove access for your app
- Delete access_token and refresh_token_backup from KV
- Go through /auth again
- Make sure you see ALL permissions on the consent screen
- Get new refresh token and update the secret
Part 6: The Worker Code
Create src/index.js with this code:
// Spotify MCP Worker for Cloudflare
// Full OAuth + MCP implementation
const SCOPES = [
"user-read-playback-state",
"user-modify-playback-state",
"user-read-currently-playing",
"playlist-read-private",
"playlist-read-collaborative",
"playlist-modify-public",
"playlist-modify-private",
"user-read-recently-played",
"user-top-read"
].join(" ");
async function getAccessToken(env) {
// Check KV cache first
const cached = await env.SPOTIFY_KV.get("access_token");
if (cached) {
const data = JSON.parse(cached);
if (data.expires_at > Date.now() + 60000) {
return data.access_token;
}
}
// Refresh the token
const response = await fetch("https://accounts.spotify.com/api/token", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"Authorization": "Basic " + btoa(env.SPOTIFY_CLIENT_ID + ":" + env.SPOTIFY_CLIENT_SECRET)
},
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: env.SPOTIFY_REFRESH_TOKEN
})
});
const data = await response.json();
if (!response.ok) {
throw new Error(`Token refresh failed: ${JSON.stringify(data)}`);
}
// Cache it
await env.SPOTIFY_KV.put("access_token", JSON.stringify({
access_token: data.access_token,
expires_at: Date.now() + (data.expires_in * 1000)
}));
// If Spotify sent a new refresh token, back it up
if (data.refresh_token) {
await env.SPOTIFY_KV.put("refresh_token_backup", data.refresh_token);
}
return data.access_token;
}
async function spotifyFetch(env, endpoint, options = {}) {
const token = await getAccessToken(env);
const url = endpoint.startsWith("http") ? endpoint : `https://api.spotify.com/v1${endpoint}`;
const response = await fetch(url, {
...options,
headers: {
"Authorization": `Bearer ${token}`,
"Content-Type": "application/json",
...options.headers
}
});
if (response.status === 204) return { success: true };
const text = await response.text();
if (!response.ok) {
throw new Error(`Spotify API error (${response.status}): ${text}`);
}
return text ? JSON.parse(text) : { success: true };
}
const TOOLS = [
{ name: "spotify_now_playing", description: "Get currently playing track", inputSchema: { type: "object", properties: {} } },
{ name: "spotify_play", description: "Start/resume playback", inputSchema: { type: "object", properties: { track_uri: { type: "string" }, playlist_uri: { type: "string" }, device_id: { type: "string" } } } },
{ name: "spotify_pause", description: "Pause playback", inputSchema: { type: "object", properties: {} } },
{ name: "spotify_skip", description: "Skip track", inputSchema: { type: "object", properties: { direction: { type: "string", enum: ["next", "previous"], default: "next" } } } },
{ name: "spotify_queue", description: "Add track to queue", inputSchema: { type: "object", properties: { track_uri: { type: "string" } }, required: ["track_uri"] } },
{ name: "spotify_search", description: "Search Spotify", inputSchema: { type: "object", properties: { query: { type: "string" }, type: { type: "string", enum: ["track", "album", "artist", "playlist"], default: "track" }, limit: { type: "number", default: 10 } }, required: ["query"] } },
{ name: "spotify_recently_played", description: "Get recent tracks", inputSchema: { type: "object", properties: { limit: { type: "number", default: 20 } } } },
{ name: "spotify_top_tracks", description: "Get top tracks", inputSchema: { type: "object", properties: { time_range: { type: "string", enum: ["short_term", "medium_term", "long_term"], default: "medium_term" }, limit: { type: "number", default: 20 } } } },
{ name: "spotify_top_artists", description: "Get top artists", inputSchema: { type: "object", properties: { time_range: { type: "string", enum: ["short_term", "medium_term", "long_term"], default: "medium_term" }, limit: { type: "number", default: 20 } } } },
{ name: "spotify_get_recommendations", description: "Get recommendations", inputSchema: { type: "object", properties: { seed_tracks: { type: "array", items: { type: "string" } }, seed_artists: { type: "array", items: { type: "string" } }, seed_genres: { type: "array", items: { type: "string" } }, limit: { type: "number", default: 20 } } } },
{ name: "spotify_get_playlists", description: "Get user playlists", inputSchema: { type: "object", properties: { limit: { type: "number", default: 20 }, offset: { type: "number", default: 0 } } } },
{ name: "spotify_get_playlist_tracks", description: "Get playlist tracks", inputSchema: { type: "object", properties: { playlist_id: { type: "string" }, limit: { type: "number", default: 50 }, offset: { type: "number", default: 0 } }, required: ["playlist_id"] } },
{ name: "spotify_create_playlist", description: "Create playlist", inputSchema: { type: "object", properties: { name: { type: "string" }, description: { type: "string" }, public: { type: "boolean", default: false } }, required: ["name"] } },
{ name: "spotify_add_tracks", description: "Add tracks to playlist", inputSchema: { type: "object", properties: { playlist_id: { type: "string" }, track_uris: { type: "array", items: { type: "string" } }, position: { type: "number" } }, required: ["playlist_id", "track_uris"] } },
{ name: "spotify_remove_tracks", description: "Remove tracks from playlist", inputSchema: { type: "object", properties: { playlist_id: { type: "string" }, track_uris: { type: "array", items: { type: "string" } } }, required: ["playlist_id", "track_uris"] } },
{ name: "spotify_update_playlist", description: "Update playlist details", inputSchema: { type: "object", properties: { playlist_id: { type: "string" }, name: { type: "string" }, description: { type: "string" }, public: { type: "boolean" } }, required: ["playlist_id"] } }
];
async function handleTool(name, args, env) {
switch (name) {
case "spotify_now_playing": {
const data = await spotifyFetch(env, "/me/player/currently-playing");
if (!data || !data.item) return { playing: false };
return {
playing: data.is_playing,
track: data.item.name,
artist: data.item.artists.map(a => a.name).join(", "),
album: data.item.album.name,
progress_ms: data.progress_ms,
duration_ms: data.item.duration_ms,
uri: data.item.uri
};
}
case "spotify_play": {
const body = {};
if (args.track_uri) body.uris = [args.track_uri];
if (args.playlist_uri) body.context_uri = args.playlist_uri;
const endpoint = args.device_id ? `/me/player/play?device_id=${args.device_id}` : "/me/player/play";
await spotifyFetch(env, endpoint, { method: "PUT", body: JSON.stringify(body) });
return { success: true };
}
case "spotify_pause":
await spotifyFetch(env, "/me/player/pause", { method: "PUT" });
return { success: true };
case "spotify_skip":
const dir = args.direction === "previous" ? "previous" : "next";
await spotifyFetch(env, `/me/player/${dir}`, { method: "POST" });
return { success: true };
case "spotify_queue":
await spotifyFetch(env, `/me/player/queue?uri=${encodeURIComponent(args.track_uri)}`, { method: "POST" });
return { success: true };
case "spotify_search": {
const type = args.type || "track";
const limit = args.limit || 10;
const data = await spotifyFetch(env, `/search?q=${encodeURIComponent(args.query)}&type=${type}&limit=${limit}`);
const key = type + "s";
return data[key]?.items || [];
}
case "spotify_recently_played": {
const data = await spotifyFetch(env, `/me/player/recently-played?limit=${args.limit || 20}`);
return data.items.map(i => ({ track: i.track.name, artist: i.track.artists[0].name, played_at: i.played_at, uri: i.track.uri }));
}
case "spotify_top_tracks": {
const data = await spotifyFetch(env, `/me/top/tracks?time_range=${args.time_range || "medium_term"}&limit=${args.limit || 20}`);
return data.items.map(t => ({ name: t.name, artist: t.artists[0].name, uri: t.uri }));
}
case "spotify_top_artists": {
const data = await spotifyFetch(env, `/me/top/artists?time_range=${args.time_range || "medium_term"}&limit=${args.limit || 20}`);
return data.items.map(a => ({ name: a.name, genres: a.genres, uri: a.uri, id: a.id }));
}
case "spotify_get_recommendations": {
const params = new URLSearchParams();
if (args.seed_tracks?.length) params.set("seed_tracks", args.seed_tracks.join(","));
if (args.seed_artists?.length) params.set("seed_artists", args.seed_artists.join(","));
if (args.seed_genres?.length) params.set("seed_genres", args.seed_genres.join(","));
params.set("limit", args.limit || 20);
const data = await spotifyFetch(env, `/recommendations?${params}`);
return data.tracks.map(t => ({ name: t.name, artist: t.artists[0].name, uri: t.uri, id: t.id }));
}
case "spotify_get_playlists": {
const data = await spotifyFetch(env, `/me/playlists?limit=${args.limit || 20}&offset=${args.offset || 0}`);
return data.items.map(p => ({ name: p.name, id: p.id, tracks: p.tracks.total, uri: p.uri }));
}
case "spotify_get_playlist_tracks": {
const data = await spotifyFetch(env, `/playlists/${args.playlist_id}/items?limit=${args.limit || 50}&offset=${args.offset || 0}`);
return data.items.map(i => ({ track: i.track.name, artist: i.track.artists[0].name, uri: i.track.uri }));
}
case "spotify_create_playlist": {
const data = await spotifyFetch(env, "/me/playlists", {
method: "POST",
body: JSON.stringify({ name: args.name, description: args.description || "", public: args.public || false })
});
return { id: data.id, name: data.name, url: data.external_urls.spotify };
}
case "spotify_add_tracks": {
const uris = args.track_uris.join(",");
const url = `https://api.spotify.com/v1/playlists/${args.playlist_id}/items?uris=${encodeURIComponent(uris)}`;
const token = await getAccessToken(env);
const response = await fetch(url, {
method: "POST",
headers: { "Authorization": `Bearer ${token}`, "Content-Type": "application/json" }
});
const text = await response.text();
if (!response.ok) throw new Error(`Spotify API error (${response.status}): ${text}`);
return JSON.parse(text);
}
case "spotify_remove_tracks": {
await spotifyFetch(env, `/playlists/${args.playlist_id}/items`, {
method: "DELETE",
body: JSON.stringify({ tracks: args.track_uris.map(uri => ({ uri })) })
});
return { success: true };
}
case "spotify_update_playlist": {
const body = {};
if (args.name) body.name = args.name;
if (args.description !== undefined) body.description = args.description;
if (args.public !== undefined) body.public = args.public;
await spotifyFetch(env, `/playlists/${args.playlist_id}`, { method: "PUT", body: JSON.stringify(body) });
return { success: true };
}
default:
throw new Error(`Unknown tool: ${name}`);
}
}
async function handleMCP(request, env) {
const body = await request.json();
if (body.method === "initialize") {
return { jsonrpc: "2.0", id: body.id, result: { protocolVersion: "2025-03-26", capabilities: { tools: {} }, serverInfo: { name: "spotify-mcp", version: "1.0.0" } } };
}
if (body.method === "tools/list") {
return { jsonrpc: "2.0", id: body.id, result: { tools: TOOLS } };
}
if (body.method === "tools/call") {
try {
const result = await handleTool(body.params.name, body.params.arguments || {}, env);
return { jsonrpc: "2.0", id: body.id, result: { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] } };
} catch (error) {
return { jsonrpc: "2.0", id: body.id, error: { code: -32000, message: error.message } };
}
}
return { jsonrpc: "2.0", id: body.id, error: { code: -32601, message: "Method not found" } };
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
// Auth endpoint - initiates OAuth
if (url.pathname === "/auth") {
const authUrl = new URL("https://accounts.spotify.com/authorize");
authUrl.searchParams.set("client_id", env.SPOTIFY_CLIENT_ID);
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("redirect_uri", env.SPOTIFY_REDIRECT_URI);
authUrl.searchParams.set("scope", SCOPES);
return Response.redirect(authUrl.toString(), 302);
}
// Callback - exchanges code for tokens
if (url.pathname === "/callback") {
const code = url.searchParams.get("code");
if (!code) return new Response("No code provided", { status: 400 });
const response = await fetch("https://accounts.spotify.com/api/token", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"Authorization": "Basic " + btoa(env.SPOTIFY_CLIENT_ID + ":" + env.SPOTIFY_CLIENT_SECRET)
},
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: env.SPOTIFY_REDIRECT_URI
})
});
const data = await response.json();
if (!response.ok) return new Response(JSON.stringify(data), { status: 400 });
return new Response(`
<h1>Success!</h1>
<p>Copy this refresh token and set it as SPOTIFY_REFRESH_TOKEN:</p>
<pre style="background:#f0f0f0;padding:10px;word-break:break-all">${data.refresh_token}</pre>
<p>Run: wrangler secret put SPOTIFY_REFRESH_TOKEN</p>
`, { headers: { "Content-Type": "text/html" } });
}
// MCP endpoint
if (url.pathname === "/mcp") {
if (request.method === "OPTIONS") {
return new Response(null, { headers: { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "POST", "Access-Control-Allow-Headers": "*" } });
}
const result = await handleMCP(request, env);
return new Response(JSON.stringify(result), { headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } });
}
return new Response("Spotify MCP - Use /auth to authorize, /mcp for MCP calls", { status: 200 });
}
};
Built by Kay + Saint + Tyson after 72+ hours of debugging.
Update March 8, 2026: Jace identified the fix — Spotify changed /tracks to /items in February 2026. Confirmed working.