Files
mcp-spotify/server.py
Christophe Vila 6ee9cde391 fix: read track from item["item"] key to match updated Spotify API response shape
Spotify's playlist_items endpoint now nests the track object under "item"
instead of "track" in each playlist entry. The old key is absent, causing
item.get("track") to return None for every entry and silently drop all tracks.

Also adds stderr debug traces at each component boundary (tool dispatch,
API page summary, per-item accept/skip) to make future silent-empty issues
diagnosable, and switches popularity to .get() since it is absent in the
new response shape.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 18:27:31 +02:00

185 lines
6.3 KiB
Python

import asyncio
import json
import sys
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import Tool, TextContent
import spotify_client
def _dbg(msg: str) -> None:
print(f"[spotify-mcp] {msg}", file=sys.stderr, flush=True)
server = Server("spotify-mcp")
@server.list_tools()
async def handle_list_tools() -> list[Tool]:
return [
Tool(
name="list_playlists",
description="List the authenticated user's Spotify playlists.",
inputSchema={"type": "object", "properties": {}, "required": []},
),
Tool(
name="get_playlist_tracks",
description=(
"Get all tracks in a Spotify playlist with full metadata: "
"name, artists, album, duration_ms, duration (mm:ss), popularity, URI, added_at."
),
inputSchema={
"type": "object",
"properties": {
"playlist_id": {
"type": "string",
"description": "Spotify playlist ID (from list_playlists)",
},
},
"required": ["playlist_id"],
},
),
Tool(
name="list_saved_tracks",
description="Get the user's liked/saved tracks from Spotify.",
inputSchema={
"type": "object",
"properties": {
"limit": {
"type": "integer",
"description": "Maximum number of tracks to return (default 50)",
},
},
"required": [],
},
),
Tool(
name="search_tracks",
description=(
"Search Spotify for tracks. "
"Returns track URIs that can be passed to add_tracks_to_playlist."
),
inputSchema={
"type": "object",
"properties": {
"query": {"type": "string", "description": "Search query"},
"limit": {
"type": "integer",
"description": "Maximum results to return (default 10, max 50)",
},
},
"required": ["query"],
},
),
Tool(
name="create_playlist",
description="Create a new Spotify playlist for the authenticated user.",
inputSchema={
"type": "object",
"properties": {
"name": {"type": "string", "description": "Playlist name"},
"description": {
"type": "string",
"description": "Playlist description (optional)",
},
"public": {
"type": "boolean",
"description": "Whether the playlist is public (default false)",
},
},
"required": ["name"],
},
),
Tool(
name="add_tracks_to_playlist",
description="Add tracks to a Spotify playlist by their URIs (e.g. spotify:track:...).",
inputSchema={
"type": "object",
"properties": {
"playlist_id": {
"type": "string",
"description": "Spotify playlist ID",
},
"track_uris": {
"type": "array",
"items": {"type": "string"},
"description": "List of Spotify track URIs",
},
},
"required": ["playlist_id", "track_uris"],
},
),
]
@server.call_tool()
async def handle_call_tool(name: str, arguments: dict | None) -> list[TextContent]:
arguments = arguments or {}
_dbg(f"tool={name!r} args={arguments!r}")
try:
if name == "list_playlists":
result = await asyncio.to_thread(spotify_client.list_playlists)
elif name == "get_playlist_tracks":
result = await asyncio.to_thread(
spotify_client.get_playlist_tracks, arguments["playlist_id"]
)
elif name == "list_saved_tracks":
result = await asyncio.to_thread(
spotify_client.list_saved_tracks, arguments.get("limit", 50)
)
elif name == "search_tracks":
result = await asyncio.to_thread(
spotify_client.search_tracks,
arguments["query"],
arguments.get("limit", 10),
)
elif name == "create_playlist":
result = await asyncio.to_thread(
spotify_client.create_playlist,
arguments["name"],
arguments.get("description", ""),
arguments.get("public", False),
)
elif name == "add_tracks_to_playlist":
result = await asyncio.to_thread(
spotify_client.add_tracks_to_playlist,
arguments["playlist_id"],
arguments["track_uris"],
)
else:
raise ValueError(f"Unknown tool: {name}")
except Exception as e:
_dbg(f"tool={name!r} raised {type(e).__name__}: {e}")
return [TextContent(type="text", text=f"Error: {e}")]
result_len = len(result) if isinstance(result, list) else "n/a"
_dbg(f"tool={name!r} returning {result_len} items")
return [TextContent(type="text", text=json.dumps(result, ensure_ascii=False, indent=2))]
async def _run_server() -> None:
async with stdio_server() as (read_stream, write_stream):
await server.run(
read_stream,
write_stream,
server.create_initialization_options(),
)
if __name__ == "__main__":
if "--auth" in sys.argv:
print("Authenticating with Spotify (a browser window will open)...")
sp = spotify_client.get_client()
sp.current_user() # verify the token actually works
print("Authentication successful! Token cached.")
sys.exit(0)
try:
spotify_client.get_client()
except Exception as e:
print(f"Startup error: {e}", file=sys.stderr)
sys.exit(1)
asyncio.run(_run_server())