├── .gitignore ├── .python-version ├── README.md ├── media └── take5.mp4 ├── pyproject.toml ├── src └── spotify_mcp │ ├── __init__.py │ ├── server.py │ ├── spotify_api.py │ └── utils.py └── uv.lock /.gitignore: -------------------------------------------------------------------------------- 1 | .cache 2 | .DS_Store 3 | __pycache__/ 4 | .env 5 | .idea/ 6 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.12 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # spotify-mcp MCP server 2 | 3 | MCP project to connect Claude with Spotify. Built on top of [spotipy-dev's API](https://github.com/spotipy-dev/spotipy/tree/2.24.0). 4 | 5 | ## Features 6 | 7 | - Start, pause, and skip playback 8 | - Search for tracks/albums/artists/playlists 9 | - Get info about a track/album/artist/playlist 10 | - Manage the Spotify queue 11 | - Manage, create, and update playlists 12 | 13 | ## Demo 14 | 15 | Make sure to turn on audio 16 | 17 |
18 | 19 | Video 20 | 21 | https://github.com/user-attachments/assets/20ee1f92-f3e3-4dfa-b945-ca57bc1e0894 22 |
23 | 24 | ## Configuration 25 | 26 | ### Getting Spotify API Keys 27 | 28 | Create an account on [developer.spotify.com](https://developer.spotify.com/). Navigate to [the dashboard](https://developer.spotify.com/dashboard). 29 | Create an app with redirect_uri as http://127.0.0.1:8080/callback. 30 | You can choose any port you want but you must use http and an explicit loopback address (IPv4 or IPv6). 31 | 32 | See [here](https://developer.spotify.com/documentation/web-api/concepts/redirect_uri) for more info/troubleshooting. 33 | You may have to restart your MCP environment (e.g. Claude Desktop) once or twice before it works. 34 | 35 | ### Run this project locally 36 | 37 | This project is not yet set up for ephemeral environments (e.g. `uvx` usage). 38 | Run this project locally by cloning this repo 39 | 40 | ```bash 41 | git clone https://github.com/varunneal/spotify-mcp.git 42 | ``` 43 | 44 | Add this tool as a mcp server. 45 | 46 | Claude Desktop on MacOS: `~/Library/Application\ Support/Claude/claude_desktop_config.json` 47 | 48 | Claude Desktop on Windows: `%APPDATA%/Claude/claude_desktop_config.json` 49 | 50 | ```json 51 | "spotify": { 52 | "command": "uv", 53 | "args": [ 54 | "--directory", 55 | "/path/to/spotify_mcp", 56 | "run", 57 | "spotify-mcp" 58 | ], 59 | "env": { 60 | "SPOTIFY_CLIENT_ID": YOUR_CLIENT_ID, 61 | "SPOTIFY_CLIENT_SECRET": YOUR_CLIENT_SECRET, 62 | "SPOTIFY_REDIRECT_URI": "http://127.0.0.1:8080/callback" 63 | } 64 | } 65 | ``` 66 | 67 | ### Troubleshooting 68 | 69 | Please open an issue if you can't get this MCP working. Here are some tips: 70 | 71 | 1. Make sure `uv` is updated. I recommend version `>=0.54`. 72 | 2. Make sure claude has execution permisisons for the project: `chmod -R 755`. 73 | 3. Ensure you have Spotify premium (needed for running developer API). 74 | 75 | This MCP will emit logs to std err (as specified in the MCP) spec. On Mac the Claude Desktop app should emit these logs 76 | to `~/Library/Logs/Claude`. 77 | On other platforms [you can find logs here](https://modelcontextprotocol.io/quickstart/user#getting-logs-from-claude-for-desktop). 78 | 79 | 80 | You can launch the MCP Inspector via [`npm`](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm) with this command: 81 | 82 | ```bash 83 | npx @modelcontextprotocol/inspector uv --directory /path/to/spotify_mcp run spotify-mcp 84 | ``` 85 | 86 | Upon launching, the Inspector will display a URL that you can access in your browser to begin debugging. 87 | 88 | ## TODO 89 | 90 | Unfortunately, a bunch of cool features have [now been deprecated](https://techcrunch.com/2024/11/27/spotify-cuts-developer-access-to-several-of-its-recommendation-features/) 91 | from the Spotify API. Most new features will be relatively minor or for the health of the project: 92 | 93 | - tests. 94 | - adding API support for managing playlists. 95 | - adding API support for paginated search results/playlists/albums. 96 | 97 | PRs appreciated! Thanks to @jamiew, @davidpadbury, @manncodes, @hyuma7, @aanurraj, and others for contributions. 98 | 99 | ## Deployment 100 | 101 | (todo) 102 | 103 | ### Building and Publishing 104 | 105 | To prepare the package for distribution: 106 | 107 | 1. Sync dependencies and update lockfile: 108 | 109 | ```bash 110 | uv sync 111 | ``` 112 | 113 | 2. Build package distributions: 114 | 115 | ```bash 116 | uv build 117 | ``` 118 | 119 | This will create source and wheel distributions in the `dist/` directory. 120 | 121 | 3. Publish to PyPI: 122 | 123 | ```bash 124 | uv publish 125 | ``` 126 | 127 | Note: You'll need to set PyPI credentials via environment variables or command flags: 128 | 129 | - Token: `--token` or `UV_PUBLISH_TOKEN` 130 | - Or username/password: `--username`/`UV_PUBLISH_USERNAME` and `--password`/`UV_PUBLISH_PASSWORD` 131 | -------------------------------------------------------------------------------- /media/take5.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/varunneal/spotify-mcp/701b4682e158755e90ffae6a1d3d14c2af8c3063/media/take5.mp4 -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "spotify-mcp" 3 | version = "0.2.0" 4 | description = "MCP spotify project" 5 | readme = "README.md" 6 | requires-python = ">=3.12" 7 | dependencies = [ 8 | "mcp==1.3.0", 9 | "python-dotenv>=1.0.1", 10 | "spotipy==2.24.0", 11 | ] 12 | [[project.authors]] 13 | name = "Varun Srivastava" 14 | email = "varun.neal@berkeley.edu" 15 | 16 | [build-system] 17 | requires = [ "hatchling",] 18 | build-backend = "hatchling.build" 19 | 20 | [dependency-groups] 21 | dev = [ 22 | ] 23 | 24 | [tool.uv.sources] 25 | spotify-mcp = { workspace = true } 26 | 27 | [project.scripts] 28 | spotify-mcp = "spotify_mcp:main" 29 | -------------------------------------------------------------------------------- /src/spotify_mcp/__init__.py: -------------------------------------------------------------------------------- 1 | from . import server 2 | import asyncio 3 | 4 | def main(): 5 | """Main entry point for the package.""" 6 | asyncio.run(server.main()) 7 | 8 | # Optionally expose other important items at package level 9 | __all__ = ['main', 'server'] 10 | 11 | -------------------------------------------------------------------------------- /src/spotify_mcp/server.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import base64 3 | import os 4 | import logging 5 | import sys 6 | from enum import Enum 7 | import json 8 | from typing import List, Optional, Tuple 9 | from datetime import datetime 10 | from pathlib import Path 11 | 12 | import mcp.types as types 13 | from mcp.server import NotificationOptions, Server # , stdio_server 14 | import mcp.server.stdio 15 | from pydantic import BaseModel, Field, AnyUrl 16 | from spotipy import SpotifyException 17 | 18 | from . import spotify_api 19 | from .utils import normalize_redirect_uri 20 | 21 | 22 | def setup_logger(): 23 | class Logger: 24 | def info(self, message): 25 | print(f"[INFO] {message}", file=sys.stderr) 26 | 27 | def error(self, message): 28 | print(f"[ERROR] {message}", file=sys.stderr) 29 | 30 | return Logger() 31 | 32 | 33 | logger = setup_logger() 34 | # Normalize the redirect URI to meet Spotify's requirements 35 | if spotify_api.REDIRECT_URI: 36 | spotify_api.REDIRECT_URI = normalize_redirect_uri(spotify_api.REDIRECT_URI) 37 | spotify_client = spotify_api.Client(logger) 38 | 39 | server = Server("spotify-mcp") 40 | 41 | 42 | # options = 43 | class ToolModel(BaseModel): 44 | @classmethod 45 | def as_tool(cls): 46 | return types.Tool( 47 | name="Spotify" + cls.__name__, 48 | description=cls.__doc__, 49 | inputSchema=cls.model_json_schema() 50 | ) 51 | 52 | 53 | class Playback(ToolModel): 54 | """Manages the current playback with the following actions: 55 | - get: Get information about user's current track. 56 | - start: Starts playing new item or resumes current playback if called with no uri. 57 | - pause: Pauses current playback. 58 | - skip: Skips current track. 59 | """ 60 | action: str = Field(description="Action to perform: 'get', 'start', 'pause' or 'skip'.") 61 | spotify_uri: Optional[str] = Field(default=None, description="Spotify uri of item to play for 'start' action. " + 62 | "If omitted, resumes current playback.") 63 | num_skips: Optional[int] = Field(default=1, description="Number of tracks to skip for `skip` action.") 64 | 65 | 66 | class Queue(ToolModel): 67 | """Manage the playback queue - get the queue or add tracks.""" 68 | action: str = Field(description="Action to perform: 'add' or 'get'.") 69 | track_id: Optional[str] = Field(default=None, description="Track ID to add to queue (required for add action)") 70 | 71 | 72 | class GetInfo(ToolModel): 73 | """Get detailed information about a Spotify item (track, album, artist, or playlist).""" 74 | item_uri: str = Field(description="URI of the item to get information about. " + 75 | "If 'playlist' or 'album', returns its tracks. " + 76 | "If 'artist', returns albums and top tracks.") 77 | 78 | 79 | class Search(ToolModel): 80 | """Search for tracks, albums, artists, or playlists on Spotify.""" 81 | query: str = Field(description="query term") 82 | qtype: Optional[str] = Field(default="track", 83 | description="Type of items to search for (track, album, artist, playlist, " + 84 | "or comma-separated combination)") 85 | limit: Optional[int] = Field(default=10, description="Maximum number of items to return") 86 | 87 | 88 | class Playlist(ToolModel): 89 | """Manage Spotify playlists. 90 | - get: Get a list of user's playlists. 91 | - get_tracks: Get tracks in a specific playlist. 92 | - add_tracks: Add tracks to a specific playlist. 93 | - remove_tracks: Remove tracks from a specific playlist. 94 | - change_details: Change details of a specific playlist. 95 | """ 96 | action: str = Field( 97 | description="Action to perform: 'get', 'get_tracks', 'add_tracks', 'remove_tracks', 'change_details'.") 98 | playlist_id: Optional[str] = Field(default=None, description="ID of the playlist to manage.") 99 | track_ids: Optional[List[str]] = Field(default=None, description="List of track IDs to add/remove.") 100 | name: Optional[str] = Field(default=None, description="New name for the playlist.") 101 | description: Optional[str] = Field(default=None, description="New description for the playlist.") 102 | 103 | 104 | @server.list_prompts() 105 | async def handle_list_prompts() -> list[types.Prompt]: 106 | return [] 107 | 108 | 109 | @server.list_resources() 110 | async def handle_list_resources() -> list[types.Resource]: 111 | return [] 112 | 113 | 114 | @server.list_tools() 115 | async def handle_list_tools() -> list[types.Tool]: 116 | """List available tools.""" 117 | logger.info("Listing available tools") 118 | # await server.request_context.session.send_notification("are you recieving this notification?") 119 | tools = [ 120 | Playback.as_tool(), 121 | Search.as_tool(), 122 | Queue.as_tool(), 123 | GetInfo.as_tool(), 124 | Playlist.as_tool(), 125 | ] 126 | logger.info(f"Available tools: {[tool.name for tool in tools]}") 127 | return tools 128 | 129 | 130 | @server.call_tool() 131 | async def handle_call_tool( 132 | name: str, arguments: dict | None 133 | ) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]: 134 | """Handle tool execution requests.""" 135 | logger.info(f"Tool called: {name} with arguments: {arguments}") 136 | assert name[:7] == "Spotify", f"Unknown tool: {name}" 137 | try: 138 | match name[7:]: 139 | case "Playback": 140 | action = arguments.get("action") 141 | match action: 142 | case "get": 143 | logger.info("Attempting to get current track") 144 | curr_track = spotify_client.get_current_track() 145 | if curr_track: 146 | logger.info(f"Current track retrieved: {curr_track.get('name', 'Unknown')}") 147 | return [types.TextContent( 148 | type="text", 149 | text=json.dumps(curr_track, indent=2) 150 | )] 151 | logger.info("No track currently playing") 152 | return [types.TextContent( 153 | type="text", 154 | text="No track playing." 155 | )] 156 | case "start": 157 | logger.info(f"Starting playback with arguments: {arguments}") 158 | spotify_client.start_playback(spotify_uri=arguments.get("spotify_uri")) 159 | logger.info("Playback started successfully") 160 | return [types.TextContent( 161 | type="text", 162 | text="Playback starting." 163 | )] 164 | case "pause": 165 | logger.info("Attempting to pause playback") 166 | spotify_client.pause_playback() 167 | logger.info("Playback paused successfully") 168 | return [types.TextContent( 169 | type="text", 170 | text="Playback paused." 171 | )] 172 | case "skip": 173 | num_skips = int(arguments.get("num_skips", 1)) 174 | logger.info(f"Skipping {num_skips} tracks.") 175 | spotify_client.skip_track(n=num_skips) 176 | return [types.TextContent( 177 | type="text", 178 | text="Skipped to next track." 179 | )] 180 | 181 | case "Search": 182 | logger.info(f"Performing search with arguments: {arguments}") 183 | search_results = spotify_client.search( 184 | query=arguments.get("query", ""), 185 | qtype=arguments.get("qtype", "track"), 186 | limit=arguments.get("limit", 10) 187 | ) 188 | logger.info("Search completed successfully.") 189 | return [types.TextContent( 190 | type="text", 191 | text=json.dumps(search_results, indent=2) 192 | )] 193 | 194 | case "Queue": 195 | logger.info(f"Queue operation with arguments: {arguments}") 196 | action = arguments.get("action") 197 | 198 | match action: 199 | case "add": 200 | track_id = arguments.get("track_id") 201 | if not track_id: 202 | logger.error("track_id is required for add to queue.") 203 | return [types.TextContent( 204 | type="text", 205 | text="track_id is required for add action" 206 | )] 207 | spotify_client.add_to_queue(track_id) 208 | return [types.TextContent( 209 | type="text", 210 | text=f"Track added to queue." 211 | )] 212 | 213 | case "get": 214 | queue = spotify_client.get_queue() 215 | return [types.TextContent( 216 | type="text", 217 | text=json.dumps(queue, indent=2) 218 | )] 219 | 220 | case _: 221 | return [types.TextContent( 222 | type="text", 223 | text=f"Unknown queue action: {action}. Supported actions are: add, remove, and get." 224 | )] 225 | 226 | case "GetInfo": 227 | logger.info(f"Getting item info with arguments: {arguments}") 228 | item_info = spotify_client.get_info( 229 | item_uri=arguments.get("item_uri") 230 | ) 231 | return [types.TextContent( 232 | type="text", 233 | text=json.dumps(item_info, indent=2) 234 | )] 235 | 236 | case "Playlist": 237 | logger.info(f"Playlist operation with arguments: {arguments}") 238 | action = arguments.get("action") 239 | match action: 240 | case "get": 241 | logger.info(f"Getting current user's playlists with arguments: {arguments}") 242 | playlists = spotify_client.get_current_user_playlists() 243 | return [types.TextContent( 244 | type="text", 245 | text=json.dumps(playlists, indent=2) 246 | )] 247 | case "get_tracks": 248 | logger.info(f"Getting tracks in playlist with arguments: {arguments}") 249 | if not arguments.get("playlist_id"): 250 | logger.error("playlist_id is required for get_tracks action.") 251 | return [types.TextContent( 252 | type="text", 253 | text="playlist_id is required for get_tracks action." 254 | )] 255 | tracks = spotify_client.get_playlist_tracks(arguments.get("playlist_id")) 256 | return [types.TextContent( 257 | type="text", 258 | text=json.dumps(tracks, indent=2) 259 | )] 260 | case "add_tracks": 261 | logger.info(f"Adding tracks to playlist with arguments: {arguments}") 262 | track_ids = arguments.get("track_ids") 263 | if isinstance(track_ids, str): 264 | try: 265 | track_ids = json.loads(track_ids) # Convert JSON string to Python list 266 | except json.JSONDecodeError: 267 | logger.error("track_ids must be a list or a valid JSON array.") 268 | return [types.TextContent( 269 | type="text", 270 | text="Error: track_ids must be a list or a valid JSON array." 271 | )] 272 | 273 | spotify_client.add_tracks_to_playlist( 274 | playlist_id=arguments.get("playlist_id"), 275 | track_ids=track_ids 276 | ) 277 | return [types.TextContent( 278 | type="text", 279 | text="Tracks added to playlist." 280 | )] 281 | case "remove_tracks": 282 | logger.info(f"Removing tracks from playlist with arguments: {arguments}") 283 | track_ids = arguments.get("track_ids") 284 | if isinstance(track_ids, str): 285 | try: 286 | track_ids = json.loads(track_ids) # Convert JSON string to Python list 287 | except json.JSONDecodeError: 288 | logger.error("track_ids must be a list or a valid JSON array.") 289 | return [types.TextContent( 290 | type="text", 291 | text="Error: track_ids must be a list or a valid JSON array." 292 | )] 293 | 294 | spotify_client.remove_tracks_from_playlist( 295 | playlist_id=arguments.get("playlist_id"), 296 | track_ids=track_ids 297 | ) 298 | return [types.TextContent( 299 | type="text", 300 | text="Tracks removed from playlist." 301 | )] 302 | 303 | case "change_details": 304 | logger.info(f"Changing playlist details with arguments: {arguments}") 305 | if not arguments.get("playlist_id"): 306 | logger.error("playlist_id is required for change_details action.") 307 | return [types.TextContent( 308 | type="text", 309 | text="playlist_id is required for change_details action." 310 | )] 311 | if not arguments.get("name") and not arguments.get("description"): 312 | logger.error("At least one of name, description or public is required.") 313 | return [types.TextContent( 314 | type="text", 315 | text="At least one of name, description, public, or collaborative is required." 316 | )] 317 | 318 | spotify_client.change_playlist_details( 319 | playlist_id=arguments.get("playlist_id"), 320 | name=arguments.get("name"), 321 | description=arguments.get("description") 322 | ) 323 | return [types.TextContent( 324 | type="text", 325 | text="Playlist details changed." 326 | )] 327 | 328 | case _: 329 | return [types.TextContent( 330 | type="text", 331 | text=f"Unknown playlist action: {action}." 332 | "Supported actions are: get, get_tracks, add_tracks, remove_tracks, change_details." 333 | )] 334 | case _: 335 | error_msg = f"Unknown tool: {name}" 336 | logger.error(error_msg) 337 | return [types.TextContent( 338 | type="text", 339 | text=error_msg 340 | )] 341 | except SpotifyException as se: 342 | error_msg = f"Spotify Client error occurred: {str(se)}" 343 | logger.error(error_msg) 344 | return [types.TextContent( 345 | type="text", 346 | text=f"An error occurred with the Spotify Client: {str(se)}" 347 | )] 348 | except Exception as e: 349 | error_msg = f"Unexpected error occurred: {str(e)}" 350 | logger.error(error_msg) 351 | return [types.TextContent( 352 | type="text", 353 | text=error_msg 354 | )] 355 | 356 | 357 | async def main(): 358 | try: 359 | async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): 360 | await server.run( 361 | read_stream, 362 | write_stream, 363 | server.create_initialization_options() 364 | ) 365 | except Exception as e: 366 | logger.error(f"Server error occurred: {str(e)}") 367 | raise 368 | -------------------------------------------------------------------------------- /src/spotify_mcp/spotify_api.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | from typing import Optional, Dict, List 4 | 5 | import spotipy 6 | from dotenv import load_dotenv 7 | from spotipy.cache_handler import CacheFileHandler 8 | from spotipy.oauth2 import SpotifyOAuth 9 | 10 | from . import utils 11 | 12 | load_dotenv() 13 | 14 | CLIENT_ID = os.getenv("SPOTIFY_CLIENT_ID") 15 | CLIENT_SECRET = os.getenv("SPOTIFY_CLIENT_SECRET") 16 | REDIRECT_URI = os.getenv("SPOTIFY_REDIRECT_URI") 17 | 18 | # Normalize the redirect URI to meet Spotify's requirements 19 | if REDIRECT_URI: 20 | REDIRECT_URI = utils.normalize_redirect_uri(REDIRECT_URI) 21 | 22 | SCOPES = ["user-read-currently-playing", "user-read-playback-state", "user-read-currently-playing", # spotify connect 23 | "app-remote-control", "streaming", # playback 24 | "playlist-read-private", "playlist-read-collaborative", "playlist-modify-private", "playlist-modify-public", 25 | # playlists 26 | "user-read-playback-position", "user-top-read", "user-read-recently-played", # listening history 27 | "user-library-modify", "user-library-read", # library 28 | ] 29 | 30 | 31 | class Client: 32 | def __init__(self, logger: logging.Logger): 33 | """Initialize Spotify client with necessary permissions""" 34 | self.logger = logger 35 | 36 | scope = "user-library-read,user-read-playback-state,user-modify-playback-state,user-read-currently-playing,playlist-read-private,playlist-read-collaborative,playlist-modify-private,playlist-modify-public" 37 | 38 | try: 39 | self.sp = spotipy.Spotify(auth_manager=SpotifyOAuth( 40 | scope=scope, 41 | client_id=CLIENT_ID, 42 | client_secret=CLIENT_SECRET, 43 | redirect_uri=REDIRECT_URI)) 44 | 45 | self.auth_manager: SpotifyOAuth = self.sp.auth_manager 46 | self.cache_handler: CacheFileHandler = self.auth_manager.cache_handler 47 | except Exception as e: 48 | self.logger.error(f"Failed to initialize Spotify client: {str(e)}") 49 | raise 50 | 51 | self.username = None 52 | 53 | @utils.validate 54 | def set_username(self, device=None): 55 | self.username = self.sp.current_user()['display_name'] 56 | 57 | @utils.validate 58 | def search(self, query: str, qtype: str = 'track', limit=10, device=None): 59 | """ 60 | Searches based of query term. 61 | - query: query term 62 | - qtype: the types of items to return. One or more of 'artist', 'album', 'track', 'playlist'. 63 | If multiple types are desired, pass in a comma separated string; e.g. 'track,album' 64 | - limit: max # items to return 65 | """ 66 | if self.username is None: 67 | self.set_username() 68 | results = self.sp.search(q=query, limit=limit, type=qtype) 69 | if not results: 70 | raise ValueError("No search results found.") 71 | return utils.parse_search_results(results, qtype, self.username) 72 | 73 | def recommendations(self, artists: Optional[List] = None, tracks: Optional[List] = None, limit=20): 74 | # doesnt work 75 | recs = self.sp.recommendations(seed_artists=artists, seed_tracks=tracks, limit=limit) 76 | return recs 77 | 78 | def get_info(self, item_uri: str) -> dict: 79 | """ 80 | Returns more info about item. 81 | - item_uri: uri. Looks like 'spotify:track:xxxxxx', 'spotify:album:xxxxxx', etc. 82 | """ 83 | _, qtype, item_id = item_uri.split(":") 84 | match qtype: 85 | case 'track': 86 | return utils.parse_track(self.sp.track(item_id), detailed=True) 87 | case 'album': 88 | album_info = utils.parse_album(self.sp.album(item_id), detailed=True) 89 | return album_info 90 | case 'artist': 91 | artist_info = utils.parse_artist(self.sp.artist(item_id), detailed=True) 92 | albums = self.sp.artist_albums(item_id) 93 | top_tracks = self.sp.artist_top_tracks(item_id)['tracks'] 94 | albums_and_tracks = { 95 | 'albums': albums, 96 | 'tracks': {'items': top_tracks} 97 | } 98 | parsed_info = utils.parse_search_results(albums_and_tracks, qtype="album,track") 99 | artist_info['top_tracks'] = parsed_info['tracks'] 100 | artist_info['albums'] = parsed_info['albums'] 101 | 102 | return artist_info 103 | case 'playlist': 104 | if self.username is None: 105 | self.set_username() 106 | playlist = self.sp.playlist(item_id) 107 | self.logger.info(f"playlist info is {playlist}") 108 | playlist_info = utils.parse_playlist(playlist, self.username, detailed=True) 109 | 110 | return playlist_info 111 | 112 | raise ValueError(f"Unknown qtype {qtype}") 113 | 114 | def get_current_track(self) -> Optional[Dict]: 115 | """Get information about the currently playing track""" 116 | try: 117 | # current_playback vs current_user_playing_track? 118 | current = self.sp.current_user_playing_track() 119 | if not current: 120 | self.logger.info("No playback session found") 121 | return None 122 | if current.get('currently_playing_type') != 'track': 123 | self.logger.info("Current playback is not a track") 124 | return None 125 | 126 | track_info = utils.parse_track(current['item']) 127 | if 'is_playing' in current: 128 | track_info['is_playing'] = current['is_playing'] 129 | 130 | self.logger.info( 131 | f"Current track: {track_info.get('name', 'Unknown')} by {track_info.get('artist', 'Unknown')}") 132 | return track_info 133 | except Exception as e: 134 | self.logger.error("Error getting current track info.") 135 | raise 136 | 137 | @utils.validate 138 | def start_playback(self, spotify_uri=None, device=None): 139 | """ 140 | Starts spotify playback of uri. If spotify_uri is omitted, resumes current playback. 141 | - spotify_uri: ID of resource to play, or None. Typically looks like 'spotify:track:xxxxxx' or 'spotify:album:xxxxxx'. 142 | """ 143 | try: 144 | self.logger.info(f"Starting playback for spotify_uri: {spotify_uri} on {device}") 145 | if not spotify_uri: 146 | if self.is_track_playing(): 147 | self.logger.info("No track_id provided and playback already active.") 148 | return 149 | if not self.get_current_track(): 150 | raise ValueError("No track_id provided and no current playback to resume.") 151 | 152 | if spotify_uri is not None: 153 | if spotify_uri.startswith('spotify:track:'): 154 | uris = [spotify_uri] 155 | context_uri = None 156 | else: 157 | uris = None 158 | context_uri = spotify_uri 159 | else: 160 | uris = None 161 | context_uri = None 162 | 163 | device_id = device.get('id') if device else None 164 | 165 | self.logger.info(f"Starting playback of on {device}: context_uri={context_uri}, uris={uris}") 166 | result = self.sp.start_playback(uris=uris, context_uri=context_uri, device_id=device_id) 167 | self.logger.info(f"Playback result: {result}") 168 | return result 169 | except Exception as e: 170 | self.logger.error(f"Error starting playback: {str(e)}.") 171 | raise 172 | 173 | @utils.validate 174 | def pause_playback(self, device=None): 175 | """Pauses playback.""" 176 | playback = self.sp.current_playback() 177 | if playback and playback.get('is_playing'): 178 | self.sp.pause_playback(device.get('id') if device else None) 179 | 180 | @utils.validate 181 | def add_to_queue(self, track_id: str, device=None): 182 | """ 183 | Adds track to queue. 184 | - track_id: ID of track to play. 185 | """ 186 | self.sp.add_to_queue(track_id, device.get('id') if device else None) 187 | 188 | @utils.validate 189 | def get_queue(self, device=None): 190 | """Returns the current queue of tracks.""" 191 | queue_info = self.sp.queue() 192 | queue_info['currently_playing'] = self.get_current_track() 193 | 194 | queue_info['queue'] = [utils.parse_track(track) for track in queue_info.pop('queue')] 195 | 196 | return queue_info 197 | 198 | def get_liked_songs(self): 199 | # todo 200 | results = self.sp.current_user_saved_tracks() 201 | for idx, item in enumerate(results['items']): 202 | track = item['track'] 203 | print(idx, track['artists'][0]['name'], " – ", track['name']) 204 | 205 | def is_track_playing(self) -> bool: 206 | """Returns if a track is actively playing.""" 207 | curr_track = self.get_current_track() 208 | if not curr_track: 209 | return False 210 | if curr_track.get('is_playing'): 211 | return True 212 | return False 213 | 214 | def get_current_user_playlists(self, limit=50) -> List[Dict]: 215 | """ 216 | Get current user's playlists. 217 | - limit: Max number of playlists to return. 218 | """ 219 | playlists = self.sp.current_user_playlists() 220 | if not playlists: 221 | raise ValueError("No playlists found.") 222 | return [utils.parse_playlist(playlist, self.username) for playlist in playlists['items']] 223 | 224 | @utils.ensure_username 225 | def get_playlist_tracks(self, playlist_id: str, limit=50) -> List[Dict]: 226 | """ 227 | Get tracks from a playlist. 228 | - playlist_id: ID of the playlist to get tracks from. 229 | - limit: Max number of tracks to return. 230 | """ 231 | playlist = self.sp.playlist(playlist_id) 232 | if not playlist: 233 | raise ValueError("No playlist found.") 234 | return utils.parse_tracks(playlist['tracks']['items']) 235 | 236 | @utils.ensure_username 237 | def add_tracks_to_playlist(self, playlist_id: str, track_ids: List[str], position: Optional[int] = None): 238 | """ 239 | Add tracks to a playlist. 240 | - playlist_id: ID of the playlist to modify. 241 | - track_ids: List of track IDs to add. 242 | - position: Position to insert the tracks at (optional). 243 | """ 244 | if not playlist_id: 245 | raise ValueError("No playlist ID provided.") 246 | if not track_ids: 247 | raise ValueError("No track IDs provided.") 248 | 249 | try: 250 | response = self.sp.playlist_add_items(playlist_id, track_ids, position=position) 251 | self.logger.info(f"Response from adding tracks: {track_ids} to playlist {playlist_id}: {response}") 252 | except Exception as e: 253 | self.logger.error(f"Error adding tracks to playlist: {str(e)}") 254 | 255 | @utils.ensure_username 256 | def remove_tracks_from_playlist(self, playlist_id: str, track_ids: List[str]): 257 | """ 258 | Remove tracks from a playlist. 259 | - playlist_id: ID of the playlist to modify. 260 | - track_ids: List of track IDs to remove. 261 | """ 262 | if not playlist_id: 263 | raise ValueError("No playlist ID provided.") 264 | if not track_ids: 265 | raise ValueError("No track IDs provided.") 266 | 267 | try: 268 | response = self.sp.playlist_remove_all_occurrences_of_items(playlist_id, track_ids) 269 | self.logger.info(f"Response from removing tracks: {track_ids} from playlist {playlist_id}: {response}") 270 | except Exception as e: 271 | self.logger.error(f"Error removing tracks from playlist: {str(e)}") 272 | 273 | @utils.ensure_username 274 | def change_playlist_details(self, playlist_id: str, name: Optional[str] = None, description: Optional[str] = None): 275 | """ 276 | Change playlist details. 277 | - playlist_id: ID of the playlist to modify. 278 | - name: New name for the playlist. 279 | - public: Whether the playlist should be public. 280 | - description: New description for the playlist. 281 | """ 282 | if not playlist_id: 283 | raise ValueError("No playlist ID provided.") 284 | 285 | try: 286 | response = self.sp.playlist_change_details(playlist_id, name=name, description=description) 287 | self.logger.info(f"Response from changing playlist details: {response}") 288 | except Exception as e: 289 | self.logger.error(f"Error changing playlist details: {str(e)}") 290 | 291 | def get_devices(self) -> dict: 292 | return self.sp.devices()['devices'] 293 | 294 | def is_active_device(self): 295 | return any([device.get('is_active') for device in self.get_devices()]) 296 | 297 | def _get_candidate_device(self): 298 | devices = self.get_devices() 299 | if not devices: 300 | raise ConnectionError("No active device. Is Spotify open?") 301 | for device in devices: 302 | if device.get('is_active'): 303 | return device 304 | self.logger.info(f"No active device, assigning {devices[0]['name']}.") 305 | return devices[0] 306 | 307 | def auth_ok(self) -> bool: 308 | try: 309 | token = self.cache_handler.get_cached_token() 310 | if token is None: 311 | self.logger.info("Auth check result: no token exists") 312 | return False 313 | 314 | is_expired = self.auth_manager.is_token_expired(token) 315 | self.logger.info(f"Auth check result: {'valid' if not is_expired else 'expired'}") 316 | return not is_expired # Return True if token is NOT expired 317 | except Exception as e: 318 | self.logger.error(f"Error checking auth status: {str(e)}") 319 | return False # Return False on error rather than raising 320 | 321 | def auth_refresh(self): 322 | self.auth_manager.validate_token(self.cache_handler.get_cached_token()) 323 | 324 | def skip_track(self, n=1): 325 | # todo: Better error handling 326 | for _ in range(n): 327 | self.sp.next_track() 328 | 329 | def previous_track(self): 330 | self.sp.previous_track() 331 | 332 | def seek_to_position(self, position_ms): 333 | self.sp.seek_track(position_ms=position_ms) 334 | 335 | def set_volume(self, volume_percent): 336 | self.sp.volume(volume_percent) 337 | -------------------------------------------------------------------------------- /src/spotify_mcp/utils.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | from typing import Optional, Dict 3 | import functools 4 | from typing import Callable, TypeVar 5 | from typing import Optional, Dict 6 | from urllib.parse import quote, urlparse, urlunparse 7 | 8 | from requests import RequestException 9 | 10 | T = TypeVar('T') 11 | 12 | 13 | def normalize_redirect_uri(url: str) -> str: 14 | if not url: 15 | return url 16 | 17 | parsed = urlparse(url) 18 | 19 | # Convert localhost to 127.0.0.1 20 | if parsed.netloc == 'localhost' or parsed.netloc.startswith('localhost:'): 21 | port = '' 22 | if ':' in parsed.netloc: 23 | port = ':' + parsed.netloc.split(':')[1] 24 | parsed = parsed._replace(netloc=f'127.0.0.1{port}') 25 | 26 | return urlunparse(parsed) 27 | 28 | def parse_track(track_item: dict, detailed=False) -> Optional[dict]: 29 | if not track_item: 30 | return None 31 | narrowed_item = { 32 | 'name': track_item['name'], 33 | 'id': track_item['id'], 34 | } 35 | 36 | if 'is_playing' in track_item: 37 | narrowed_item['is_playing'] = track_item['is_playing'] 38 | 39 | if detailed: 40 | narrowed_item['album'] = parse_album(track_item.get('album')) 41 | for k in ['track_number', 'duration_ms']: 42 | narrowed_item[k] = track_item.get(k) 43 | 44 | if not track_item.get('is_playable', True): 45 | narrowed_item['is_playable'] = False 46 | 47 | artists = [a['name'] for a in track_item['artists']] 48 | if detailed: 49 | artists = [parse_artist(a) for a in track_item['artists']] 50 | 51 | if len(artists) == 1: 52 | narrowed_item['artist'] = artists[0] 53 | else: 54 | narrowed_item['artists'] = artists 55 | 56 | return narrowed_item 57 | 58 | 59 | def parse_artist(artist_item: dict, detailed=False) -> Optional[dict]: 60 | if not artist_item: 61 | return None 62 | narrowed_item = { 63 | 'name': artist_item['name'], 64 | 'id': artist_item['id'], 65 | } 66 | if detailed: 67 | narrowed_item['genres'] = artist_item.get('genres') 68 | 69 | return narrowed_item 70 | 71 | 72 | def parse_playlist(playlist_item: dict, username, detailed=False) -> Optional[dict]: 73 | if not playlist_item: 74 | return None 75 | narrowed_item = { 76 | 'name': playlist_item['name'], 77 | 'id': playlist_item['id'], 78 | 'owner': playlist_item['owner']['display_name'], 79 | 'user_is_owner': playlist_item['owner']['display_name'] == username, 80 | 'total_tracks': playlist_item['tracks']['total'], 81 | } 82 | if detailed: 83 | narrowed_item['description'] = playlist_item.get('description') 84 | tracks = [] 85 | for t in playlist_item['tracks']['items']: 86 | tracks.append(parse_track(t['track'])) 87 | narrowed_item['tracks'] = tracks 88 | 89 | return narrowed_item 90 | 91 | 92 | def parse_album(album_item: dict, detailed=False) -> dict: 93 | narrowed_item = { 94 | 'name': album_item['name'], 95 | 'id': album_item['id'], 96 | } 97 | 98 | artists = [a['name'] for a in album_item['artists']] 99 | 100 | if detailed: 101 | tracks = [] 102 | for t in album_item['tracks']['items']: 103 | tracks.append(parse_track(t)) 104 | narrowed_item["tracks"] = tracks 105 | artists = [parse_artist(a) for a in album_item['artists']] 106 | 107 | for k in ['total_tracks', 'release_date', 'genres']: 108 | narrowed_item[k] = album_item.get(k) 109 | 110 | if len(artists) == 1: 111 | narrowed_item['artist'] = artists[0] 112 | else: 113 | narrowed_item['artists'] = artists 114 | 115 | return narrowed_item 116 | 117 | 118 | def parse_search_results(results: Dict, qtype: str, username: Optional[str] = None): 119 | _results = defaultdict(list) 120 | # potential 121 | # if username: 122 | # _results['User Spotify URI'] = username 123 | 124 | for q in qtype.split(","): 125 | match q: 126 | case "track": 127 | for idx, item in enumerate(results['tracks']['items']): 128 | if not item: continue 129 | _results['tracks'].append(parse_track(item)) 130 | case "artist": 131 | for idx, item in enumerate(results['artists']['items']): 132 | if not item: continue 133 | _results['artists'].append(parse_artist(item)) 134 | case "playlist": 135 | for idx, item in enumerate(results['playlists']['items']): 136 | if not item: continue 137 | _results['playlists'].append(parse_playlist(item, username)) 138 | case "album": 139 | for idx, item in enumerate(results['albums']['items']): 140 | if not item: continue 141 | _results['albums'].append(parse_album(item)) 142 | case _: 143 | raise ValueError(f"Unknown qtype {qtype}") 144 | 145 | return dict(_results) 146 | 147 | def parse_tracks(items: Dict) -> list: 148 | """ 149 | Parse a list of track items and return a list of parsed tracks. 150 | 151 | Args: 152 | items: List of track items 153 | Returns: 154 | List of parsed tracks 155 | """ 156 | tracks = [] 157 | for idx, item in enumerate(items): 158 | if not item: 159 | continue 160 | tracks.append(parse_track(item['track'])) 161 | return tracks 162 | 163 | 164 | def build_search_query(base_query: str, 165 | artist: Optional[str] = None, 166 | track: Optional[str] = None, 167 | album: Optional[str] = None, 168 | year: Optional[str] = None, 169 | year_range: Optional[tuple[int, int]] = None, 170 | # upc: Optional[str] = None, 171 | # isrc: Optional[str] = None, 172 | genre: Optional[str] = None, 173 | is_hipster: bool = False, 174 | is_new: bool = False 175 | ) -> str: 176 | """ 177 | Build a search query string with optional filters. 178 | 179 | Args: 180 | base_query: Base search term 181 | artist: Artist name filter 182 | track: Track name filter 183 | album: Album name filter 184 | year: Specific year filter 185 | year_range: Tuple of (start_year, end_year) for year range filter 186 | genre: Genre filter 187 | is_hipster: Filter for lowest 10% popularity albums 188 | is_new: Filter for albums released in past two weeks 189 | 190 | Returns: 191 | Encoded query string with applied filters 192 | """ 193 | filters = [] 194 | 195 | if artist: 196 | filters.append(f"artist:{artist}") 197 | if track: 198 | filters.append(f"track:{track}") 199 | if album: 200 | filters.append(f"album:{album}") 201 | if year: 202 | filters.append(f"year:{year}") 203 | if year_range: 204 | filters.append(f"year:{year_range[0]}-{year_range[1]}") 205 | if genre: 206 | filters.append(f"genre:{genre}") 207 | if is_hipster: 208 | filters.append("tag:hipster") 209 | if is_new: 210 | filters.append("tag:new") 211 | 212 | query_parts = [base_query] + filters 213 | return quote(" ".join(query_parts)) 214 | 215 | 216 | def validate(func: Callable[..., T]) -> Callable[..., T]: 217 | """ 218 | Decorator for Spotify API methods that handles authentication and device validation. 219 | - Checks and refreshes authentication if needed 220 | - Validates active device and retries with candidate device if needed 221 | """ 222 | 223 | @functools.wraps(func) 224 | def wrapper(self, *args, **kwargs): 225 | # Handle authentication 226 | if not self.auth_ok(): 227 | self.auth_refresh() 228 | 229 | # Handle device validation 230 | if not self.is_active_device(): 231 | kwargs['device'] = self._get_candidate_device() 232 | 233 | # TODO: try-except RequestException 234 | return func(self, *args, **kwargs) 235 | 236 | return wrapper 237 | 238 | def ensure_username(func): 239 | """ 240 | Decorator to ensure that the username is set before calling the function. 241 | """ 242 | @functools.wraps(func) 243 | def wrapper(self, *args, **kwargs): 244 | if self.username is None: 245 | self.set_username() 246 | return func(self, *args, **kwargs) 247 | return wrapper 248 | -------------------------------------------------------------------------------- /uv.lock: -------------------------------------------------------------------------------- 1 | version = 1 2 | requires-python = ">=3.12" 3 | 4 | [[package]] 5 | name = "annotated-types" 6 | version = "0.7.0" 7 | source = { registry = "https://pypi.org/simple" } 8 | sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } 9 | wheels = [ 10 | { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, 11 | ] 12 | 13 | [[package]] 14 | name = "anyio" 15 | version = "4.6.2.post1" 16 | source = { registry = "https://pypi.org/simple" } 17 | dependencies = [ 18 | { name = "idna" }, 19 | { name = "sniffio" }, 20 | ] 21 | sdist = { url = "https://files.pythonhosted.org/packages/9f/09/45b9b7a6d4e45c6bcb5bf61d19e3ab87df68e0601fa8c5293de3542546cc/anyio-4.6.2.post1.tar.gz", hash = "sha256:4c8bc31ccdb51c7f7bd251f51c609e038d63e34219b44aa86e47576389880b4c", size = 173422 } 22 | wheels = [ 23 | { url = "https://files.pythonhosted.org/packages/e4/f5/f2b75d2fc6f1a260f340f0e7c6a060f4dd2961cc16884ed851b0d18da06a/anyio-4.6.2.post1-py3-none-any.whl", hash = "sha256:6d170c36fba3bdd840c73d3868c1e777e33676a69c3a72cf0a0d5d6d8009b61d", size = 90377 }, 24 | ] 25 | 26 | [[package]] 27 | name = "certifi" 28 | version = "2024.8.30" 29 | source = { registry = "https://pypi.org/simple" } 30 | sdist = { url = "https://files.pythonhosted.org/packages/b0/ee/9b19140fe824b367c04c5e1b369942dd754c4c5462d5674002f75c4dedc1/certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9", size = 168507 } 31 | wheels = [ 32 | { url = "https://files.pythonhosted.org/packages/12/90/3c9ff0512038035f59d279fddeb79f5f1eccd8859f06d6163c58798b9487/certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", size = 167321 }, 33 | ] 34 | 35 | [[package]] 36 | name = "charset-normalizer" 37 | version = "3.4.0" 38 | source = { registry = "https://pypi.org/simple" } 39 | sdist = { url = "https://files.pythonhosted.org/packages/f2/4f/e1808dc01273379acc506d18f1504eb2d299bd4131743b9fc54d7be4df1e/charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e", size = 106620 } 40 | wheels = [ 41 | { url = "https://files.pythonhosted.org/packages/d3/0b/4b7a70987abf9b8196845806198975b6aab4ce016632f817ad758a5aa056/charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6", size = 194445 }, 42 | { url = "https://files.pythonhosted.org/packages/50/89/354cc56cf4dd2449715bc9a0f54f3aef3dc700d2d62d1fa5bbea53b13426/charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf", size = 125275 }, 43 | { url = "https://files.pythonhosted.org/packages/fa/44/b730e2a2580110ced837ac083d8ad222343c96bb6b66e9e4e706e4d0b6df/charset_normalizer-3.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db", size = 119020 }, 44 | { url = "https://files.pythonhosted.org/packages/9d/e4/9263b8240ed9472a2ae7ddc3e516e71ef46617fe40eaa51221ccd4ad9a27/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1", size = 139128 }, 45 | { url = "https://files.pythonhosted.org/packages/6b/e3/9f73e779315a54334240353eaea75854a9a690f3f580e4bd85d977cb2204/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03", size = 149277 }, 46 | { url = "https://files.pythonhosted.org/packages/1a/cf/f1f50c2f295312edb8a548d3fa56a5c923b146cd3f24114d5adb7e7be558/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284", size = 142174 }, 47 | { url = "https://files.pythonhosted.org/packages/16/92/92a76dc2ff3a12e69ba94e7e05168d37d0345fa08c87e1fe24d0c2a42223/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15", size = 143838 }, 48 | { url = "https://files.pythonhosted.org/packages/a4/01/2117ff2b1dfc61695daf2babe4a874bca328489afa85952440b59819e9d7/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8", size = 146149 }, 49 | { url = "https://files.pythonhosted.org/packages/f6/9b/93a332b8d25b347f6839ca0a61b7f0287b0930216994e8bf67a75d050255/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2", size = 140043 }, 50 | { url = "https://files.pythonhosted.org/packages/ab/f6/7ac4a01adcdecbc7a7587767c776d53d369b8b971382b91211489535acf0/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719", size = 148229 }, 51 | { url = "https://files.pythonhosted.org/packages/9d/be/5708ad18161dee7dc6a0f7e6cf3a88ea6279c3e8484844c0590e50e803ef/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631", size = 151556 }, 52 | { url = "https://files.pythonhosted.org/packages/5a/bb/3d8bc22bacb9eb89785e83e6723f9888265f3a0de3b9ce724d66bd49884e/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b", size = 149772 }, 53 | { url = "https://files.pythonhosted.org/packages/f7/fa/d3fc622de05a86f30beea5fc4e9ac46aead4731e73fd9055496732bcc0a4/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565", size = 144800 }, 54 | { url = "https://files.pythonhosted.org/packages/9a/65/bdb9bc496d7d190d725e96816e20e2ae3a6fa42a5cac99c3c3d6ff884118/charset_normalizer-3.4.0-cp312-cp312-win32.whl", hash = "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7", size = 94836 }, 55 | { url = "https://files.pythonhosted.org/packages/3e/67/7b72b69d25b89c0b3cea583ee372c43aa24df15f0e0f8d3982c57804984b/charset_normalizer-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9", size = 102187 }, 56 | { url = "https://files.pythonhosted.org/packages/f3/89/68a4c86f1a0002810a27f12e9a7b22feb198c59b2f05231349fbce5c06f4/charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114", size = 194617 }, 57 | { url = "https://files.pythonhosted.org/packages/4f/cd/8947fe425e2ab0aa57aceb7807af13a0e4162cd21eee42ef5b053447edf5/charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed", size = 125310 }, 58 | { url = "https://files.pythonhosted.org/packages/5b/f0/b5263e8668a4ee9becc2b451ed909e9c27058337fda5b8c49588183c267a/charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250", size = 119126 }, 59 | { url = "https://files.pythonhosted.org/packages/ff/6e/e445afe4f7fda27a533f3234b627b3e515a1b9429bc981c9a5e2aa5d97b6/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920", size = 139342 }, 60 | { url = "https://files.pythonhosted.org/packages/a1/b2/4af9993b532d93270538ad4926c8e37dc29f2111c36f9c629840c57cd9b3/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64", size = 149383 }, 61 | { url = "https://files.pythonhosted.org/packages/fb/6f/4e78c3b97686b871db9be6f31d64e9264e889f8c9d7ab33c771f847f79b7/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23", size = 142214 }, 62 | { url = "https://files.pythonhosted.org/packages/2b/c9/1c8fe3ce05d30c87eff498592c89015b19fade13df42850aafae09e94f35/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc", size = 144104 }, 63 | { url = "https://files.pythonhosted.org/packages/ee/68/efad5dcb306bf37db7db338338e7bb8ebd8cf38ee5bbd5ceaaaa46f257e6/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d", size = 146255 }, 64 | { url = "https://files.pythonhosted.org/packages/0c/75/1ed813c3ffd200b1f3e71121c95da3f79e6d2a96120163443b3ad1057505/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88", size = 140251 }, 65 | { url = "https://files.pythonhosted.org/packages/7d/0d/6f32255c1979653b448d3c709583557a4d24ff97ac4f3a5be156b2e6a210/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90", size = 148474 }, 66 | { url = "https://files.pythonhosted.org/packages/ac/a0/c1b5298de4670d997101fef95b97ac440e8c8d8b4efa5a4d1ef44af82f0d/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b", size = 151849 }, 67 | { url = "https://files.pythonhosted.org/packages/04/4f/b3961ba0c664989ba63e30595a3ed0875d6790ff26671e2aae2fdc28a399/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d", size = 149781 }, 68 | { url = "https://files.pythonhosted.org/packages/d8/90/6af4cd042066a4adad58ae25648a12c09c879efa4849c705719ba1b23d8c/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482", size = 144970 }, 69 | { url = "https://files.pythonhosted.org/packages/cc/67/e5e7e0cbfefc4ca79025238b43cdf8a2037854195b37d6417f3d0895c4c2/charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67", size = 94973 }, 70 | { url = "https://files.pythonhosted.org/packages/65/97/fc9bbc54ee13d33dc54a7fcf17b26368b18505500fc01e228c27b5222d80/charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b", size = 102308 }, 71 | { url = "https://files.pythonhosted.org/packages/bf/9b/08c0432272d77b04803958a4598a51e2a4b51c06640af8b8f0f908c18bf2/charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079", size = 49446 }, 72 | ] 73 | 74 | [[package]] 75 | name = "click" 76 | version = "8.1.7" 77 | source = { registry = "https://pypi.org/simple" } 78 | dependencies = [ 79 | { name = "colorama", marker = "platform_system == 'Windows'" }, 80 | ] 81 | sdist = { url = "https://files.pythonhosted.org/packages/96/d3/f04c7bfcf5c1862a2a5b845c6b2b360488cf47af55dfa79c98f6a6bf98b5/click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de", size = 336121 } 82 | wheels = [ 83 | { url = "https://files.pythonhosted.org/packages/00/2e/d53fa4befbf2cfa713304affc7ca780ce4fc1fd8710527771b58311a3229/click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", size = 97941 }, 84 | ] 85 | 86 | [[package]] 87 | name = "colorama" 88 | version = "0.4.6" 89 | source = { registry = "https://pypi.org/simple" } 90 | sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } 91 | wheels = [ 92 | { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, 93 | ] 94 | 95 | [[package]] 96 | name = "h11" 97 | version = "0.14.0" 98 | source = { registry = "https://pypi.org/simple" } 99 | sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 } 100 | wheels = [ 101 | { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 }, 102 | ] 103 | 104 | [[package]] 105 | name = "httpcore" 106 | version = "1.0.7" 107 | source = { registry = "https://pypi.org/simple" } 108 | dependencies = [ 109 | { name = "certifi" }, 110 | { name = "h11" }, 111 | ] 112 | sdist = { url = "https://files.pythonhosted.org/packages/6a/41/d7d0a89eb493922c37d343b607bc1b5da7f5be7e383740b4753ad8943e90/httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c", size = 85196 } 113 | wheels = [ 114 | { url = "https://files.pythonhosted.org/packages/87/f5/72347bc88306acb359581ac4d52f23c0ef445b57157adedb9aee0cd689d2/httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd", size = 78551 }, 115 | ] 116 | 117 | [[package]] 118 | name = "httpx" 119 | version = "0.27.2" 120 | source = { registry = "https://pypi.org/simple" } 121 | dependencies = [ 122 | { name = "anyio" }, 123 | { name = "certifi" }, 124 | { name = "httpcore" }, 125 | { name = "idna" }, 126 | { name = "sniffio" }, 127 | ] 128 | sdist = { url = "https://files.pythonhosted.org/packages/78/82/08f8c936781f67d9e6b9eeb8a0c8b4e406136ea4c3d1f89a5db71d42e0e6/httpx-0.27.2.tar.gz", hash = "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2", size = 144189 } 129 | wheels = [ 130 | { url = "https://files.pythonhosted.org/packages/56/95/9377bcb415797e44274b51d46e3249eba641711cf3348050f76ee7b15ffc/httpx-0.27.2-py3-none-any.whl", hash = "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0", size = 76395 }, 131 | ] 132 | 133 | [[package]] 134 | name = "httpx-sse" 135 | version = "0.4.0" 136 | source = { registry = "https://pypi.org/simple" } 137 | sdist = { url = "https://files.pythonhosted.org/packages/4c/60/8f4281fa9bbf3c8034fd54c0e7412e66edbab6bc74c4996bd616f8d0406e/httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", size = 12624 } 138 | wheels = [ 139 | { url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819 }, 140 | ] 141 | 142 | [[package]] 143 | name = "idna" 144 | version = "3.10" 145 | source = { registry = "https://pypi.org/simple" } 146 | sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } 147 | wheels = [ 148 | { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, 149 | ] 150 | 151 | [[package]] 152 | name = "mcp" 153 | version = "1.3.0" 154 | source = { registry = "https://pypi.org/simple" } 155 | dependencies = [ 156 | { name = "anyio" }, 157 | { name = "httpx" }, 158 | { name = "httpx-sse" }, 159 | { name = "pydantic" }, 160 | { name = "pydantic-settings" }, 161 | { name = "sse-starlette" }, 162 | { name = "starlette" }, 163 | { name = "uvicorn" }, 164 | ] 165 | sdist = { url = "https://files.pythonhosted.org/packages/6b/b6/81e5f2490290351fc97bf46c24ff935128cb7d34d68e3987b522f26f7ada/mcp-1.3.0.tar.gz", hash = "sha256:f409ae4482ce9d53e7ac03f3f7808bcab735bdfc0fba937453782efb43882d45", size = 150235 } 166 | wheels = [ 167 | { url = "https://files.pythonhosted.org/packages/d0/d2/a9e87b506b2094f5aa9becc1af5178842701b27217fa43877353da2577e3/mcp-1.3.0-py3-none-any.whl", hash = "sha256:2829d67ce339a249f803f22eba5e90385eafcac45c94b00cab6cef7e8f217211", size = 70672 }, 168 | ] 169 | 170 | [[package]] 171 | name = "pydantic" 172 | version = "2.10.2" 173 | source = { registry = "https://pypi.org/simple" } 174 | dependencies = [ 175 | { name = "annotated-types" }, 176 | { name = "pydantic-core" }, 177 | { name = "typing-extensions" }, 178 | ] 179 | sdist = { url = "https://files.pythonhosted.org/packages/41/86/a03390cb12cf64e2a8df07c267f3eb8d5035e0f9a04bb20fb79403d2a00e/pydantic-2.10.2.tar.gz", hash = "sha256:2bc2d7f17232e0841cbba4641e65ba1eb6fafb3a08de3a091ff3ce14a197c4fa", size = 785401 } 180 | wheels = [ 181 | { url = "https://files.pythonhosted.org/packages/d5/74/da832196702d0c56eb86b75bfa346db9238617e29b0b7ee3b8b4eccfe654/pydantic-2.10.2-py3-none-any.whl", hash = "sha256:cfb96e45951117c3024e6b67b25cdc33a3cb7b2fa62e239f7af1378358a1d99e", size = 456364 }, 182 | ] 183 | 184 | [[package]] 185 | name = "pydantic-core" 186 | version = "2.27.1" 187 | source = { registry = "https://pypi.org/simple" } 188 | dependencies = [ 189 | { name = "typing-extensions" }, 190 | ] 191 | sdist = { url = "https://files.pythonhosted.org/packages/a6/9f/7de1f19b6aea45aeb441838782d68352e71bfa98ee6fa048d5041991b33e/pydantic_core-2.27.1.tar.gz", hash = "sha256:62a763352879b84aa31058fc931884055fd75089cccbd9d58bb6afd01141b235", size = 412785 } 192 | wheels = [ 193 | { url = "https://files.pythonhosted.org/packages/be/51/2e9b3788feb2aebff2aa9dfbf060ec739b38c05c46847601134cc1fed2ea/pydantic_core-2.27.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9cbd94fc661d2bab2bc702cddd2d3370bbdcc4cd0f8f57488a81bcce90c7a54f", size = 1895239 }, 194 | { url = "https://files.pythonhosted.org/packages/7b/9e/f8063952e4a7d0127f5d1181addef9377505dcce3be224263b25c4f0bfd9/pydantic_core-2.27.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5f8c4718cd44ec1580e180cb739713ecda2bdee1341084c1467802a417fe0f02", size = 1805070 }, 195 | { url = "https://files.pythonhosted.org/packages/2c/9d/e1d6c4561d262b52e41b17a7ef8301e2ba80b61e32e94520271029feb5d8/pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:15aae984e46de8d376df515f00450d1522077254ef6b7ce189b38ecee7c9677c", size = 1828096 }, 196 | { url = "https://files.pythonhosted.org/packages/be/65/80ff46de4266560baa4332ae3181fffc4488ea7d37282da1a62d10ab89a4/pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ba5e3963344ff25fc8c40da90f44b0afca8cfd89d12964feb79ac1411a260ac", size = 1857708 }, 197 | { url = "https://files.pythonhosted.org/packages/d5/ca/3370074ad758b04d9562b12ecdb088597f4d9d13893a48a583fb47682cdf/pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:992cea5f4f3b29d6b4f7f1726ed8ee46c8331c6b4eed6db5b40134c6fe1768bb", size = 2037751 }, 198 | { url = "https://files.pythonhosted.org/packages/b1/e2/4ab72d93367194317b99d051947c071aef6e3eb95f7553eaa4208ecf9ba4/pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0325336f348dbee6550d129b1627cb8f5351a9dc91aad141ffb96d4937bd9529", size = 2733863 }, 199 | { url = "https://files.pythonhosted.org/packages/8a/c6/8ae0831bf77f356bb73127ce5a95fe115b10f820ea480abbd72d3cc7ccf3/pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7597c07fbd11515f654d6ece3d0e4e5093edc30a436c63142d9a4b8e22f19c35", size = 2161161 }, 200 | { url = "https://files.pythonhosted.org/packages/f1/f4/b2fe73241da2429400fc27ddeaa43e35562f96cf5b67499b2de52b528cad/pydantic_core-2.27.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3bbd5d8cc692616d5ef6fbbbd50dbec142c7e6ad9beb66b78a96e9c16729b089", size = 1993294 }, 201 | { url = "https://files.pythonhosted.org/packages/77/29/4bb008823a7f4cc05828198153f9753b3bd4c104d93b8e0b1bfe4e187540/pydantic_core-2.27.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:dc61505e73298a84a2f317255fcc72b710b72980f3a1f670447a21efc88f8381", size = 2001468 }, 202 | { url = "https://files.pythonhosted.org/packages/f2/a9/0eaceeba41b9fad851a4107e0cf999a34ae8f0d0d1f829e2574f3d8897b0/pydantic_core-2.27.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:e1f735dc43da318cad19b4173dd1ffce1d84aafd6c9b782b3abc04a0d5a6f5bb", size = 2091413 }, 203 | { url = "https://files.pythonhosted.org/packages/d8/36/eb8697729725bc610fd73940f0d860d791dc2ad557faaefcbb3edbd2b349/pydantic_core-2.27.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:f4e5658dbffe8843a0f12366a4c2d1c316dbe09bb4dfbdc9d2d9cd6031de8aae", size = 2154735 }, 204 | { url = "https://files.pythonhosted.org/packages/52/e5/4f0fbd5c5995cc70d3afed1b5c754055bb67908f55b5cb8000f7112749bf/pydantic_core-2.27.1-cp312-none-win32.whl", hash = "sha256:672ebbe820bb37988c4d136eca2652ee114992d5d41c7e4858cdd90ea94ffe5c", size = 1833633 }, 205 | { url = "https://files.pythonhosted.org/packages/ee/f2/c61486eee27cae5ac781305658779b4a6b45f9cc9d02c90cb21b940e82cc/pydantic_core-2.27.1-cp312-none-win_amd64.whl", hash = "sha256:66ff044fd0bb1768688aecbe28b6190f6e799349221fb0de0e6f4048eca14c16", size = 1986973 }, 206 | { url = "https://files.pythonhosted.org/packages/df/a6/e3f12ff25f250b02f7c51be89a294689d175ac76e1096c32bf278f29ca1e/pydantic_core-2.27.1-cp312-none-win_arm64.whl", hash = "sha256:9a3b0793b1bbfd4146304e23d90045f2a9b5fd5823aa682665fbdaf2a6c28f3e", size = 1883215 }, 207 | { url = "https://files.pythonhosted.org/packages/0f/d6/91cb99a3c59d7b072bded9959fbeab0a9613d5a4935773c0801f1764c156/pydantic_core-2.27.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f216dbce0e60e4d03e0c4353c7023b202d95cbaeff12e5fd2e82ea0a66905073", size = 1895033 }, 208 | { url = "https://files.pythonhosted.org/packages/07/42/d35033f81a28b27dedcade9e967e8a40981a765795c9ebae2045bcef05d3/pydantic_core-2.27.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a2e02889071850bbfd36b56fd6bc98945e23670773bc7a76657e90e6b6603c08", size = 1807542 }, 209 | { url = "https://files.pythonhosted.org/packages/41/c2/491b59e222ec7e72236e512108ecad532c7f4391a14e971c963f624f7569/pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42b0e23f119b2b456d07ca91b307ae167cc3f6c846a7b169fca5326e32fdc6cf", size = 1827854 }, 210 | { url = "https://files.pythonhosted.org/packages/e3/f3/363652651779113189cefdbbb619b7b07b7a67ebb6840325117cc8cc3460/pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:764be71193f87d460a03f1f7385a82e226639732214b402f9aa61f0d025f0737", size = 1857389 }, 211 | { url = "https://files.pythonhosted.org/packages/5f/97/be804aed6b479af5a945daec7538d8bf358d668bdadde4c7888a2506bdfb/pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c00666a3bd2f84920a4e94434f5974d7bbc57e461318d6bb34ce9cdbbc1f6b2", size = 2037934 }, 212 | { url = "https://files.pythonhosted.org/packages/42/01/295f0bd4abf58902917e342ddfe5f76cf66ffabfc57c2e23c7681a1a1197/pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3ccaa88b24eebc0f849ce0a4d09e8a408ec5a94afff395eb69baf868f5183107", size = 2735176 }, 213 | { url = "https://files.pythonhosted.org/packages/9d/a0/cd8e9c940ead89cc37812a1a9f310fef59ba2f0b22b4e417d84ab09fa970/pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c65af9088ac534313e1963443d0ec360bb2b9cba6c2909478d22c2e363d98a51", size = 2160720 }, 214 | { url = "https://files.pythonhosted.org/packages/73/ae/9d0980e286627e0aeca4c352a60bd760331622c12d576e5ea4441ac7e15e/pydantic_core-2.27.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:206b5cf6f0c513baffaeae7bd817717140770c74528f3e4c3e1cec7871ddd61a", size = 1992972 }, 215 | { url = "https://files.pythonhosted.org/packages/bf/ba/ae4480bc0292d54b85cfb954e9d6bd226982949f8316338677d56541b85f/pydantic_core-2.27.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:062f60e512fc7fff8b8a9d680ff0ddaaef0193dba9fa83e679c0c5f5fbd018bc", size = 2001477 }, 216 | { url = "https://files.pythonhosted.org/packages/55/b7/e26adf48c2f943092ce54ae14c3c08d0d221ad34ce80b18a50de8ed2cba8/pydantic_core-2.27.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:a0697803ed7d4af5e4c1adf1670af078f8fcab7a86350e969f454daf598c4960", size = 2091186 }, 217 | { url = "https://files.pythonhosted.org/packages/ba/cc/8491fff5b608b3862eb36e7d29d36a1af1c945463ca4c5040bf46cc73f40/pydantic_core-2.27.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:58ca98a950171f3151c603aeea9303ef6c235f692fe555e883591103da709b23", size = 2154429 }, 218 | { url = "https://files.pythonhosted.org/packages/78/d8/c080592d80edd3441ab7f88f865f51dae94a157fc64283c680e9f32cf6da/pydantic_core-2.27.1-cp313-none-win32.whl", hash = "sha256:8065914ff79f7eab1599bd80406681f0ad08f8e47c880f17b416c9f8f7a26d05", size = 1833713 }, 219 | { url = "https://files.pythonhosted.org/packages/83/84/5ab82a9ee2538ac95a66e51f6838d6aba6e0a03a42aa185ad2fe404a4e8f/pydantic_core-2.27.1-cp313-none-win_amd64.whl", hash = "sha256:ba630d5e3db74c79300d9a5bdaaf6200172b107f263c98a0539eeecb857b2337", size = 1987897 }, 220 | { url = "https://files.pythonhosted.org/packages/df/c3/b15fb833926d91d982fde29c0624c9f225da743c7af801dace0d4e187e71/pydantic_core-2.27.1-cp313-none-win_arm64.whl", hash = "sha256:45cf8588c066860b623cd11c4ba687f8d7175d5f7ef65f7129df8a394c502de5", size = 1882983 }, 221 | ] 222 | 223 | [[package]] 224 | name = "pydantic-settings" 225 | version = "2.8.1" 226 | source = { registry = "https://pypi.org/simple" } 227 | dependencies = [ 228 | { name = "pydantic" }, 229 | { name = "python-dotenv" }, 230 | ] 231 | sdist = { url = "https://files.pythonhosted.org/packages/88/82/c79424d7d8c29b994fb01d277da57b0a9b09cc03c3ff875f9bd8a86b2145/pydantic_settings-2.8.1.tar.gz", hash = "sha256:d5c663dfbe9db9d5e1c646b2e161da12f0d734d422ee56f567d0ea2cee4e8585", size = 83550 } 232 | wheels = [ 233 | { url = "https://files.pythonhosted.org/packages/0b/53/a64f03044927dc47aafe029c42a5b7aabc38dfb813475e0e1bf71c4a59d0/pydantic_settings-2.8.1-py3-none-any.whl", hash = "sha256:81942d5ac3d905f7f3ee1a70df5dfb62d5569c12f51a5a647defc1c3d9ee2e9c", size = 30839 }, 234 | ] 235 | 236 | [[package]] 237 | name = "python-dotenv" 238 | version = "1.0.1" 239 | source = { registry = "https://pypi.org/simple" } 240 | sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115 } 241 | wheels = [ 242 | { url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863 }, 243 | ] 244 | 245 | [[package]] 246 | name = "redis" 247 | version = "5.2.0" 248 | source = { registry = "https://pypi.org/simple" } 249 | sdist = { url = "https://files.pythonhosted.org/packages/53/17/2f4a87ffa4cd93714cf52edfa3ea94589e9de65f71e9f99cbcfa84347a53/redis-5.2.0.tar.gz", hash = "sha256:0b1087665a771b1ff2e003aa5bdd354f15a70c9e25d5a7dbf9c722c16528a7b0", size = 4607878 } 250 | wheels = [ 251 | { url = "https://files.pythonhosted.org/packages/12/f5/ffa560ecc4bafbf25f7961c3d6f50d627a90186352e27e7d0ba5b1f6d87d/redis-5.2.0-py3-none-any.whl", hash = "sha256:ae174f2bb3b1bf2b09d54bf3e51fbc1469cf6c10aa03e21141f51969801a7897", size = 261428 }, 252 | ] 253 | 254 | [[package]] 255 | name = "requests" 256 | version = "2.32.3" 257 | source = { registry = "https://pypi.org/simple" } 258 | dependencies = [ 259 | { name = "certifi" }, 260 | { name = "charset-normalizer" }, 261 | { name = "idna" }, 262 | { name = "urllib3" }, 263 | ] 264 | sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 } 265 | wheels = [ 266 | { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, 267 | ] 268 | 269 | [[package]] 270 | name = "sniffio" 271 | version = "1.3.1" 272 | source = { registry = "https://pypi.org/simple" } 273 | sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } 274 | wheels = [ 275 | { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, 276 | ] 277 | 278 | [[package]] 279 | name = "spotify-mcp" 280 | version = "0.2.0" 281 | source = { editable = "." } 282 | dependencies = [ 283 | { name = "mcp" }, 284 | { name = "python-dotenv" }, 285 | { name = "spotipy" }, 286 | ] 287 | 288 | [package.metadata] 289 | requires-dist = [ 290 | { name = "mcp", specifier = "==1.3.0" }, 291 | { name = "python-dotenv", specifier = ">=1.0.1" }, 292 | { name = "spotipy", specifier = "==2.24.0" }, 293 | ] 294 | 295 | [package.metadata.requires-dev] 296 | dev = [] 297 | 298 | [[package]] 299 | name = "spotipy" 300 | version = "2.24.0" 301 | source = { registry = "https://pypi.org/simple" } 302 | dependencies = [ 303 | { name = "redis" }, 304 | { name = "requests" }, 305 | { name = "urllib3" }, 306 | ] 307 | sdist = { url = "https://files.pythonhosted.org/packages/9b/10/145a649207a9d4067846045cca71262b8ea2140d1df3fa78581fbf1b9ec0/spotipy-2.24.0.tar.gz", hash = "sha256:396af81e642086551af157270cdfe742c1739405871ba9dac1fa651b8649ef0d", size = 41708 } 308 | wheels = [ 309 | { url = "https://files.pythonhosted.org/packages/ea/35/304e456a471128aa4a776243558f43aee3444731ef8fc9bc8c351fddfdd8/spotipy-2.24.0-py3-none-any.whl", hash = "sha256:c5aa7338c624a05a8a80dcf9c6761ded3d6bc2bc5df5f22d9398a895b11bd2ae", size = 30160 }, 310 | ] 311 | 312 | [[package]] 313 | name = "sse-starlette" 314 | version = "2.1.3" 315 | source = { registry = "https://pypi.org/simple" } 316 | dependencies = [ 317 | { name = "anyio" }, 318 | { name = "starlette" }, 319 | { name = "uvicorn" }, 320 | ] 321 | sdist = { url = "https://files.pythonhosted.org/packages/72/fc/56ab9f116b2133521f532fce8d03194cf04dcac25f583cf3d839be4c0496/sse_starlette-2.1.3.tar.gz", hash = "sha256:9cd27eb35319e1414e3d2558ee7414487f9529ce3b3cf9b21434fd110e017169", size = 19678 } 322 | wheels = [ 323 | { url = "https://files.pythonhosted.org/packages/52/aa/36b271bc4fa1d2796311ee7c7283a3a1c348bad426d37293609ca4300eef/sse_starlette-2.1.3-py3-none-any.whl", hash = "sha256:8ec846438b4665b9e8c560fcdea6bc8081a3abf7942faa95e5a744999d219772", size = 9383 }, 324 | ] 325 | 326 | [[package]] 327 | name = "starlette" 328 | version = "0.41.3" 329 | source = { registry = "https://pypi.org/simple" } 330 | dependencies = [ 331 | { name = "anyio" }, 332 | ] 333 | sdist = { url = "https://files.pythonhosted.org/packages/1a/4c/9b5764bd22eec91c4039ef4c55334e9187085da2d8a2df7bd570869aae18/starlette-0.41.3.tar.gz", hash = "sha256:0e4ab3d16522a255be6b28260b938eae2482f98ce5cc934cb08dce8dc3ba5835", size = 2574159 } 334 | wheels = [ 335 | { url = "https://files.pythonhosted.org/packages/96/00/2b325970b3060c7cecebab6d295afe763365822b1306a12eeab198f74323/starlette-0.41.3-py3-none-any.whl", hash = "sha256:44cedb2b7c77a9de33a8b74b2b90e9f50d11fcf25d8270ea525ad71a25374ff7", size = 73225 }, 336 | ] 337 | 338 | [[package]] 339 | name = "typing-extensions" 340 | version = "4.12.2" 341 | source = { registry = "https://pypi.org/simple" } 342 | sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } 343 | wheels = [ 344 | { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, 345 | ] 346 | 347 | [[package]] 348 | name = "urllib3" 349 | version = "2.2.3" 350 | source = { registry = "https://pypi.org/simple" } 351 | sdist = { url = "https://files.pythonhosted.org/packages/ed/63/22ba4ebfe7430b76388e7cd448d5478814d3032121827c12a2cc287e2260/urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9", size = 300677 } 352 | wheels = [ 353 | { url = "https://files.pythonhosted.org/packages/ce/d9/5f4c13cecde62396b0d3fe530a50ccea91e7dfc1ccf0e09c228841bb5ba8/urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", size = 126338 }, 354 | ] 355 | 356 | [[package]] 357 | name = "uvicorn" 358 | version = "0.32.1" 359 | source = { registry = "https://pypi.org/simple" } 360 | dependencies = [ 361 | { name = "click" }, 362 | { name = "h11" }, 363 | ] 364 | sdist = { url = "https://files.pythonhosted.org/packages/6a/3c/21dba3e7d76138725ef307e3d7ddd29b763119b3aa459d02cc05fefcff75/uvicorn-0.32.1.tar.gz", hash = "sha256:ee9519c246a72b1c084cea8d3b44ed6026e78a4a309cbedae9c37e4cb9fbb175", size = 77630 } 365 | wheels = [ 366 | { url = "https://files.pythonhosted.org/packages/50/c1/2d27b0a15826c2b71dcf6e2f5402181ef85acf439617bb2f1453125ce1f3/uvicorn-0.32.1-py3-none-any.whl", hash = "sha256:82ad92fd58da0d12af7482ecdb5f2470a04c9c9a53ced65b9bbb4a205377602e", size = 63828 }, 367 | ] 368 | --------------------------------------------------------------------------------