diff --git a/PROJECT_GUIDE.md b/PROJECT_GUIDE.md new file mode 100644 index 0000000..185c8eb --- /dev/null +++ b/PROJECT_GUIDE.md @@ -0,0 +1,265 @@ +# Spotify MCP Server - Refactored Project Guide + +## 📋 Overview + +Your Spotify MCP Server project has been **refactored to be a production-ready MCP server package** instead of a collection of scripts. This means it's now properly designed to be consumed by AI assistants like Claude Desktop, Cursor, and VS Code. + +## 🎯 What Changed + +### Before (Mixed Structure) +``` +spotify-mcp-server/ +├── src/ # MCP server code +├── build/ # Compiled server +├── *.mjs files # Random test scripts in root +└── README.md +``` + +### After (Clean Structure) +``` +spotify-mcp-server/ +├── src/ # Core MCP server code +├── build/ # Compiled server +├── examples/ # Organized example scripts +├── README.md # MCP server documentation +├── REFACTORING.md # Detailed refactoring info +└── package.json # Production-ready metadata +``` + +## 📦 Key Improvements + +### 1. **Package Configuration** +✅ `package.json` now includes: +- Proper description emphasizing MCP purpose +- License, repository, and bugs fields +- Correct `main` entry point +- Better scripts: `build`, `dev`, `auth`, `typecheck` +- Appropriate `files` array (excludes examples) +- Required Node.js version specification + +### 2. **Code Organization** +✅ Test scripts moved to `examples/`: +- `test-now-playing.mjs` +- `build-and-play-playlist.mjs` +- `add-songs-to-playlist.mjs` +- `get-music-statistics.mjs` +- `check-playback-status.mjs` +- `enable-shuffle-v2.mjs` +- `skip-next.mjs` +- And more... + +### 3. **Documentation** +✅ Two-tier documentation: +- **README.md**: MCP server setup and usage +- **examples/README.md**: Guide to example scripts +- **REFACTORING.md**: This refactoring summary + +### 4. **Token Management** +✅ Enhanced `src/utils.ts` with: +- Automatic token refresh on expiration +- Better error handling +- No instance caching (ensures fresh tokens) + +## 🚀 How to Use + +### For Integration with AI Assistants + +#### Claude Desktop +1. Build the server: `npm run build` +2. Add to `~/.config/claude/config.json`: +```json +{ + "mcpServers": { + "spotify": { + "command": "node", + "args": ["/path/to/spotify-mcp-server/build/index.js"] + } + } +} +``` +3. Restart Claude Desktop + +#### Cursor +1. Build: `npm run build` +2. Open Cursor Settings (Cmd + Shift + J) +3. Go to MCP tab +4. Add: `node /path/to/spotify-mcp-server/build/index.js` + +#### VS Code with Cline +1. Build: `npm run build` +2. Create/edit `cline_mcp_settings.json`: +```json +{ + "mcpServers": { + "spotify": { + "command": "node", + "args": ["/path/to/spotify-mcp-server/build/index.js"], + "autoApprove": ["getNowPlaying", "searchSpotify"] + } + } +} +``` + +### For Testing/Development + +#### Run Examples +```bash +# Get now playing +node examples/test-now-playing.mjs + +# Build and play a playlist +node examples/build-and-play-playlist.mjs + +# Get music statistics +node examples/get-music-statistics.mjs + +# And more... +``` + +#### Development Workflow +```bash +# Watch for TypeScript changes +npm run dev + +# Type check +npm run typecheck + +# Lint +npm run lint + +# Lint with fixes +npm run lint:fix +``` + +## 📂 File Organization + +### Core MCP Server (src/) +These files are compiled and distributed: +- `index.ts` - MCP server entry point +- `read.ts` - Search, get playlists, etc. +- `play.ts` - Playback control +- `albums.ts` - Album operations +- `auth.ts` - Authentication logic +- `utils.ts` - Utilities + token refresh +- `types.ts` - TypeScript types + +### Build Output (build/) +Generated JavaScript - do NOT edit directly + +### Examples (examples/) +For learning and testing - NOT distributed: +- Each script demonstrates a use case +- Can be adapted for your needs +- Run independently with Node.js + +### Configuration +- `.gitignore` - Excludes sensitive files +- `package.json` - Project metadata +- `tsconfig.json` - TypeScript config +- `biome.jsonc` - Code formatting rules + +## 🎮 Available Tools + +The MCP server exposes these tools to AI assistants: + +### Read Tools +- `searchSpotify` - Search tracks, albums, artists, playlists +- `getNowPlaying` - Get current track +- `getMyPlaylists` - Get user's playlists +- `getPlaylistTracks` - Get tracks in playlist +- `getRecentlyPlayed` - Get recently played +- `getUsersSavedTracks` - Get liked songs +- `getQueue` - Get playback queue + +### Play Tools +- `playMusic` - Play track/album/artist/playlist +- `pausePlayback` - Pause +- `resumePlayback` - Resume +- `skipToNext` - Next track +- `skipToPrevious` - Previous track +- `addToQueue` - Add to queue + +### Playlist Tools +- `createPlaylist` - Create new playlist +- `addTracksToPlaylist` - Add tracks + +### Album Tools +- `getAlbums` - Get album info +- `getAlbumTracks` - Get album tracks +- `saveOrRemoveAlbumForUser` - Save/remove +- `checkUsersSavedAlbums` - Check if saved + +## 🔐 Security + +✅ Important security measures: +- `spotify-config.json` is git-ignored (contains secrets) +- Use `spotify-config.example.json` as template +- Tokens are automatically refreshed +- No credentials in distributed package + +## 📊 Project Status + +- ✅ MCP server ready +- ✅ Production-ready package +- ✅ Example scripts included +- ✅ Comprehensive documentation +- ✅ Proper project structure +- ✅ Token refresh working +- ✅ TypeScript strict mode +- ✅ Linting configured + +## 🎓 Learning Resources + +1. **README.md** - MCP server overview and setup +2. **examples/README.md** - How to run example scripts +3. **REFACTORING.md** - Detailed refactoring info +4. **examples/*.mjs** - Working code examples + +## 🤔 Common Questions + +### Q: Can I still run the test scripts? +**A:** Yes! They're in `examples/` folder. Run them with: +```bash +node examples/script-name.mjs +``` + +### Q: How do I integrate with Claude Desktop? +**A:** Build the project and add to Claude's config. See README.md for details. + +### Q: Where are my Spotify credentials? +**A:** In `spotify-config.json` (git-ignored for security). + +### Q: Can I publish this to npm? +**A:** Yes! The package.json is properly configured. The `files` field ensures only necessary files are included. + +### Q: How do I develop/modify the code? +**A:** Edit files in `src/`, then run: +```bash +npm run dev # Watch mode +npm run build # Compile +npm run lint:fix # Fix formatting +``` + +## 📞 Next Steps + +1. **Verify the build**: `npm run build` (should complete without errors) +2. **Test an example**: `node examples/test-now-playing.mjs` +3. **Integrate**: Add to your MCP client (Claude, Cursor, etc.) +4. **Customize**: Modify tools in `src/` as needed + +## ✨ Benefits + +- ✅ **Clear Purpose**: This is an MCP server package, not a CLI tool +- ✅ **Professional**: Follows npm package best practices +- ✅ **Maintainable**: Well-organized, easy to modify +- ✅ **Documented**: Comprehensive guides included +- ✅ **Production Ready**: Proper error handling and security +- ✅ **User Friendly**: Easy setup and integration + +--- + +For more details, see: +- `README.md` - Project overview and setup +- `examples/README.md` - Example scripts guide +- `REFACTORING.md` - Detailed changes made + diff --git a/README.md b/README.md index e9ceb83..08256ff 100644 --- a/README.md +++ b/README.md @@ -1,411 +1,364 @@ -
- -

Spotify MCP Server

-
- -A lightweight [Model Context Protocol (MCP)](https://modelcontextprotocol.io) server that enables AI assistants like Cursor & Claude to control Spotify playback and manage playlists. - -
-Contents - -- [Example Interactions](#example-interactions) -- [Tools](#tools) - - [Read Operations](#read-operations) - - [Album Operations](#album-operations) - - [Play / Create Operations](#play--create-operations) - - [Playlist Operations](#playlist-operations) -- [Setup](#setup) - - [Prerequisites](#prerequisites) - - [Installation](#installation) - - [Creating a Spotify Developer Application](#creating-a-spotify-developer-application) - - [Spotify API Configuration](#spotify-api-configuration) - - [Authentication Process](#authentication-process) -- [Integrating with Claude Desktop, Cursor, and VsCode (Cline)](#integrating-with-claude-desktop-and-cursor) -
- -## Example Interactions - -- _"Play Elvis's first song"_ -- _"Create a Taylor Swift / Slipknot fusion playlist"_ -- _"Copy all the techno tracks from my workout playlist to my work playlist"_ -- _"Turn the volume down a bit"_ - -## Tools - -### Read Operations - -1. **searchSpotify** - - - **Description**: Search for tracks, albums, artists, or playlists on Spotify - - **Parameters**: - - `query` (string): The search term - - `type` (string): Type of item to search for (track, album, artist, playlist) - - `limit` (number, optional): Maximum number of results to return (10-50) - - **Returns**: List of matching items with their IDs, names, and additional details - - **Example**: `searchSpotify("bohemian rhapsody", "track", 20)` - -2. **getNowPlaying** - - - **Description**: Get information about the currently playing track on Spotify, including device and volume info - - **Parameters**: None - - **Returns**: Object containing track name, artist, album, playback progress, duration, playback state, device info, volume, and shuffle/repeat status - - **Example**: `getNowPlaying()` - -3. **getMyPlaylists** - - - **Description**: Get a list of the current user's playlists on Spotify - - **Parameters**: - - `limit` (number, optional): Maximum number of playlists to return (default: 20) - - `offset` (number, optional): Index of the first playlist to return (default: 0) - - **Returns**: Array of playlists with their IDs, names, track counts, and public status - - **Example**: `getMyPlaylists(10, 0)` - -4. **getPlaylistTracks** - - - **Description**: Get a list of tracks in a specific Spotify playlist - - **Parameters**: - - `playlistId` (string): The Spotify ID of the playlist - - `limit` (number, optional): Maximum number of tracks to return (default: 100) - - `offset` (number, optional): Index of the first track to return (default: 0) - - **Returns**: Array of tracks with their IDs, names, artists, album, duration, and added date - - **Example**: `getPlaylistTracks("37i9dQZEVXcJZyENOWUFo7")` - -5. **getRecentlyPlayed** - - - **Description**: Retrieves a list of recently played tracks from Spotify. - - **Parameters**: - - `limit` (number, optional): A number specifying the maximum number of tracks to return. - - **Returns**: If tracks are found it returns a formatted list of recently played tracks else a message stating: "You don't have any recently played tracks on Spotify". - - **Example**: `getRecentlyPlayed({ limit: 10 })` - -6. **getUsersSavedTracks** - - - **Description**: Get a list of tracks saved in the user's "Liked Songs" library - - **Parameters**: - - `limit` (number, optional): Maximum number of tracks to return (1-50, default: 50) - - `offset` (number, optional): Offset for pagination (0-based index, default: 0) - - **Returns**: Formatted list of saved tracks with track names, artists, duration, track IDs, and when they were added to Liked Songs. Shows pagination info (e.g., "1-20 of 150"). - - **Example**: `getUsersSavedTracks({ limit: 20, offset: 0 })` - -7. **getQueue** - - - **Description**: Get the currently playing track and upcoming items in the Spotify queue - - **Parameters**: - - `limit` (number, optional): Maximum number of upcoming items to show (1-50, default: 10) - - **Returns**: Currently playing track and list of upcoming tracks in the queue - - **Example**: `getQueue({ limit: 20 })` - -8. **getAvailableDevices** - - - **Description**: Get information about the user's available Spotify Connect devices - - **Parameters**: None - - **Returns**: List of available devices with name, type, active status, volume, and device ID - - **Example**: `getAvailableDevices()` - -9. **removeUsersSavedTracks** - - - **Description**: Remove one or more tracks from the user's "Liked Songs" library (max 40 per request) - - **Parameters**: - - `trackIds` (array): Array of Spotify track IDs to remove (max 40) - - **Returns**: Success confirmation message - - **Example**: `removeUsersSavedTracks({ trackIds: ["4iV5W9uYEdYUVa79Axb7Rh", "1301WleyT98MSxVHPZCA6M"] })` - - -### Play / Create Operations - -1. **playMusic** - - - **Description**: Start playing a track, album, artist, or playlist on Spotify - - **Parameters**: - - `uri` (string, optional): Spotify URI of the item to play (overrides type and id) - - `type` (string, optional): Type of item to play (track, album, artist, playlist) - - `id` (string, optional): Spotify ID of the item to play - - `deviceId` (string, optional): ID of the device to play on - - **Returns**: Success status - - **Example**: `playMusic({ uri: "spotify:track:6rqhFgbbKwnb9MLmUQDhG6" })` - - **Alternative**: `playMusic({ type: "track", id: "6rqhFgbbKwnb9MLmUQDhG6" })` - -2. **pausePlayback** - - - **Description**: Pause the currently playing track on Spotify - - **Parameters**: - - `deviceId` (string, optional): ID of the device to pause - - **Returns**: Success status - - **Example**: `pausePlayback()` - -3. **resumePlayback** - - - **Description**: Resume Spotify playback on the active device - - **Parameters**: - - `deviceId` (string, optional): ID of the device to resume playback on - - **Returns**: Success status - - **Example**: `resumePlayback()` - -4. **skipToNext** - - - **Description**: Skip to the next track in the current playback queue - - **Parameters**: - - `deviceId` (string, optional): ID of the device - - **Returns**: Success status - - **Example**: `skipToNext()` - -5. **skipToPrevious** - - - **Description**: Skip to the previous track in the current playback queue - - **Parameters**: - - `deviceId` (string, optional): ID of the device - - **Returns**: Success status - - **Example**: `skipToPrevious()` - -6. **createPlaylist** - - - **Description**: Create a new playlist on Spotify - - **Parameters**: - - `name` (string): Name for the new playlist - - `description` (string, optional): Description for the playlist - - `public` (boolean, optional): Whether the playlist should be public (default: false) - - **Returns**: Object with the new playlist's ID and URL - - **Example**: `createPlaylist({ name: "Workout Mix", description: "Songs to get pumped up", public: false })` - -7. **addTracksToPlaylist** - - - **Description**: Add tracks to an existing Spotify playlist - - **Parameters**: - - `playlistId` (string): ID of the playlist - - `trackUris` (array): Array of track URIs or IDs to add - - `position` (number, optional): Position to insert tracks - - **Returns**: Success status and snapshot ID - - **Example**: `addTracksToPlaylist({ playlistId: "3cEYpjA9oz9GiPac4AsH4n", trackUris: ["spotify:track:4iV5W9uYEdYUVa79Axb7Rh"] })` - -8. **addToQueue** - - - **Description**: Adds a track, album, artist or playlist to the current playback queue - - **Parameters**: - - `uri` (string, optional): Spotify URI of the item to add to queue (overrides type and id) - - `type` (string, optional): Type of item to queue (track, album, artist, playlist) - - `id` (string, optional): Spotify ID of the item to queue - - `deviceId` (string, optional): ID of the device to queue on - - **Returns**: Success status - - **Example**: `addToQueue({ uri: "spotify:track:6rqhFgbbKwnb9MLmUQDhG6" })` - - **Alternative**: `addToQueue({ type: "track", id: "6rqhFgbbKwnb9MLmUQDhG6" })` - -9. **setVolume** - - - **Description**: Set the playback volume to a specific percentage (requires Spotify Premium) - - **Parameters**: - - `volumePercent` (number): The volume to set (0-100) - - `deviceId` (string, optional): ID of the device to set volume on - - **Returns**: Success status with the new volume level - - **Example**: `setVolume({ volumePercent: 50 })` - -10. **adjustVolume** - - - **Description**: Adjust the playback volume up or down by a relative amount (requires Spotify Premium) - - **Parameters**: - - `adjustment` (number): The amount to adjust volume by (-100 to 100). Positive values increase volume, negative values decrease it. - - `deviceId` (string, optional): ID of the device to adjust volume on - - **Returns**: Success status showing the volume change (e.g., "Volume increased from 50% to 60%") - - **Example**: `adjustVolume({ adjustment: 10 })` (increase by 10%) - - **Example**: `adjustVolume({ adjustment: -20 })` (decrease by 20%) - - -### Album Operations - -1. **getAlbums** - - - **Description**: Get detailed information about one or more albums by their Spotify IDs - - **Parameters**: - - `albumIds` (string|array): A single album ID or array of album IDs (max 20) - - **Returns**: Album details including name, artists, release date, type, total tracks, and ID. For single album returns detailed view, for multiple albums returns summary list. - - **Example**: `getAlbums("4aawyAB9vmqN3uQ7FjRGTy")` or `getAlbums(["4aawyAB9vmqN3uQ7FjRGTy", "1DFixLWuPkv3KT3TnV35m3"])` - -2. **getAlbumTracks** - - - **Description**: Get tracks from a specific album with pagination support - - **Parameters**: - - `albumId` (string): The Spotify ID of the album - - `limit` (number, optional): Maximum number of tracks to return (1-50) - - `offset` (number, optional): Offset for pagination (0-based index) - - **Returns**: List of tracks from the album with track names, artists, duration, and IDs. Shows pagination info. - - **Example**: `getAlbumTracks("4aawyAB9vmqN3uQ7FjRGTy", 10, 0)` - -3. **saveOrRemoveAlbumForUser** - - - **Description**: Save or remove albums from the user's "Your Music" library - - **Parameters**: - - `albumIds` (array): Array of Spotify album IDs (max 20) - - `action` (string): Action to perform: "save" or "remove" - - **Returns**: Success status with confirmation message - - **Example**: `saveOrRemoveAlbumForUser(["4aawyAB9vmqN3uQ7FjRGTy"], "save")` - -4. **checkUsersSavedAlbums** - - - **Description**: Check if albums are saved in the user's "Your Music" library - - **Parameters**: - - `albumIds` (array): Array of Spotify album IDs to check (max 20) - - **Returns**: Status of each album (saved or not saved) - - **Example**: `checkUsersSavedAlbums(["4aawyAB9vmqN3uQ7FjRGTy", "1DFixLWuPkv3KT3TnV35m3"])` - -### Playlist Operations - -1. **getPlaylist** - - - **Description**: Get details of a specific Spotify playlist including tracks count, description and owner - - **Parameters**: - - `playlistId` (string): The Spotify ID of the playlist - - **Returns**: Playlist name, owner, track count, visibility, description, ID, and URL - - **Example**: `getPlaylist({ playlistId: "37i9dQZEVXcJZyENOWUFo7" })` - -2. **updatePlaylist** - - - **Description**: Update the details of a Spotify playlist (name, description, public/private, collaborative) - - **Parameters**: - - `playlistId` (string): The Spotify ID of the playlist - - `name` (string, optional): New name for the playlist - - `description` (string, optional): New description for the playlist - - `public` (boolean, optional): Whether the playlist should be public - - `collaborative` (boolean, optional): Whether the playlist should be collaborative (requires public to be false) - - **Returns**: Success confirmation with list of updated fields - - **Example**: `updatePlaylist({ playlistId: "3cEYpjA9oz9GiPac4AsH4n", name: "New Name", public: true })` - -3. **removeTracksFromPlaylist** - - - **Description**: Remove one or more tracks from a Spotify playlist (max 100 tracks per request) - - **Parameters**: - - `playlistId` (string): The Spotify ID of the playlist - - `trackIds` (array): Array of Spotify track IDs to remove (max 100) - - `snapshotId` (string, optional): The playlist snapshot ID to target a specific version - - **Returns**: Success confirmation with the number of tracks removed - - **Example**: `removeTracksFromPlaylist({ playlistId: "3cEYpjA9oz9GiPac4AsH4n", trackIds: ["4iV5W9uYEdYUVa79Axb7Rh"] })` - -4. **reorderPlaylistItems** - - - **Description**: Reorder a range of tracks within a Spotify playlist by moving them to a new position - - **Parameters**: - - `playlistId` (string): The Spotify ID of the playlist - - `rangeStart` (number): The position of the first item to move (0-based index) - - `insertBefore` (number): The position where the items should be inserted (0-based index) - - `rangeLength` (number, optional): Number of consecutive items to move (defaults to 1) - - `snapshotId` (string, optional): The playlist snapshot ID to target a specific version - - **Returns**: Success confirmation with the move details - - **Example**: `reorderPlaylistItems({ playlistId: "3cEYpjA9oz9GiPac4AsH4n", rangeStart: 2, insertBefore: 0 })` +![Spotify logo](https://upload.wikimedia.org/wikipedia/commons/8/84/Spotify_icon.svg) -## Setup - -### Prerequisites +# Spotify MCP Server -- Node.js v16+ -- A Spotify Premium account -- A registered Spotify Developer application +A lightweight [Model Context Protocol (MCP)](https://modelcontextprotocol.io) server for Spotify playback control, library access, album lookup, and playlist management. -### Installation +The current server implementation is a **stdio MCP server** named `spotify-controller` that registers **31 tools** from the source files in `src/`: + +- `src/read.ts` — 11 read/library tools +- `src/play.ts` — 12 playback + creation tools +- `src/albums.ts` — 4 album tools +- `src/playlist.ts` — 4 playlist tools + +It is intended to be used by MCP-compatible clients such as Claude Desktop, Cursor, VS Code extensions, and custom MCP hosts. + +## Contents + +- [What the current server does](#what-the-current-server-does) +- [Quick start](#quick-start) +- [Spotify app setup](#spotify-app-setup) +- [Configuration and authentication](#configuration-and-authentication) +- [Tool reference](#tool-reference) + - [Read and library tools](#read-and-library-tools) + - [Playback and creation tools](#playback-and-creation-tools) + - [Album tools](#album-tools) + - [Playlist tools](#playlist-tools) +- [MCP client integration](#mcp-client-integration) +- [Examples](#examples) +- [Development](#development) +- [Project structure](#project-structure) +- [Security notes](#security-notes) + +## What the current server does + +The source code currently exposes tools for: + +- Searching Spotify for tracks, albums, artists, and playlists +- Inspecting the current playback state, queue, devices, playlists, albums, and library +- Playing tracks, albums, artists, and playlists +- Pausing, resuming, skipping, queueing, and controlling volume/shuffle/repeat +- Creating playlists and adding/removing/reordering tracks +- Saving or removing tracks and albums from the user's library + +Implementation details verified from `src/index.ts` and `src/utils.ts`: + +- Transport: `StdioServerTransport` +- Server name: `spotify-controller` +- Version: `1.0.0` +- Tool calls are logged to `stderr`, not `stdout` +- Sensitive fields such as tokens and secrets are redacted in logs +- Access tokens are automatically refreshed when expired + +## Quick start + +### 1. Install dependencies ```bash -git clone https://github.com/marcelmarais/spotify-mcp-server.git -cd spotify-mcp-server npm install -npm run build ``` -### Creating a Spotify Developer Application +### 2. Create `spotify-config.json` -1. Go to the [Spotify Developer Dashboard](https://developer.spotify.com/dashboard/) -2. Log in with your Spotify account -3. Click the "Create an App" button -4. Fill in the app name and description -5. Accept the Terms of Service and click "Create" -6. In your new app's dashboard, you'll see your **Client ID** -7. Click "Show Client Secret" to reveal your **Client Secret** -8. Click "Edit Settings" and add a Redirect URI (e.g., `http://127.0.0.1:8888/callback`) -9. Save your changes +```bash +cp spotify-config.example.json spotify-config.json +``` -### Spotify API Configuration +Then edit `spotify-config.json` with your Spotify app credentials. -Create a `spotify-config.json` file in the project root (you can copy and modify the provided example): +### 3. Authenticate ```bash -# Copy the example config file -cp spotify-config.example.json spotify-config.json +npm run auth ``` -Then edit the file with your credentials: +This script builds the project first, opens the Spotify authorization flow, and writes tokens back into `spotify-config.json`. -```json -{ - "clientId": "your-client-id", - "clientSecret": "your-client-secret", - "redirectUri": "http://127.0.0.1:8888/callback" -} +### 4. Build the server + +```bash +npm run build ``` -### Authentication Process +### 5. Run or integrate the MCP server -The Spotify API uses OAuth 2.0 for authentication. Follow these steps to authenticate your application: +```bash +npm start +``` -1. Run the authentication script: +Or point your MCP client at: -```bash -npm run auth +```text +/absolute/path/to/spotify-mcp-server/build/index.js +``` + +## Spotify app setup + +1. Go to the [Spotify Developer Dashboard](https://developer.spotify.com/dashboard/) +2. Create an app +3. Copy the app's **Client ID** and **Client Secret** +4. Add a redirect URI such as: + +```text +http://127.0.0.1:8888/callback ``` -2. The script will generate an authorization URL. Open this URL in your web browser. +Important: the authentication helper in `src/utils.ts` requires the redirect host to be `localhost` or `127.0.0.1`. -3. You'll be prompted to log in to Spotify and authorize your application. +## Configuration and authentication -4. After authorization, Spotify will redirect you to your specified redirect URI with a code parameter in the URL. +The server reads configuration from the repository root file: -5. The authentication script will automatically exchange this code for access and refresh tokens. +```text +spotify-config.json +``` + +Minimum required fields before authentication: + +```json +{ + "clientId": "your-client-id", + "clientSecret": "your-client-secret", + "redirectUri": "http://127.0.0.1:8888/callback" +} +``` -6. These tokens will be saved to your `spotify-config.json` file, which will now look something like: +After `npm run auth`, the file will also contain token fields like these: ```json { "clientId": "your-client-id", "clientSecret": "your-client-secret", - "redirectUri": "http://localhost:8888/callback", - "accessToken": "BQAi9Pn...kKQ", - "refreshToken": "AQDQcj...7w", - "expiresAt": 1677889354671 + "redirectUri": "http://127.0.0.1:8888/callback", + "accessToken": "...", + "refreshToken": "...", + "expiresAt": 1760000000000 } ``` -**Note**: The `expiresAt` field is a Unix timestamp (in milliseconds) indicating when the access token expires. +Notes based on the current source: + +- If the token is expired, the server refreshes it automatically. +- If refresh fails, you need to run `npm run auth` again. +- If no user token is present, the utility falls back to client-credentials auth. Some catalog operations can still work, but user playback, playlist, and library tools require user authorization. + +## Tool reference -7. **Automatic Token Refresh**: The server will automatically refresh the access token when it expires (typically after 1 hour). The refresh happens transparently using the `refreshToken`, so you don't need to re-authenticate manually. If the refresh fails, you'll need to run `npm run auth` again to re-authenticate. +## Read and library tools -## Integrating with Claude Desktop, Cursor, and VsCode [Via Cline model extension](https://marketplace.visualstudio.com/items/?itemName=saoudrizwan.claude-dev) +Defined in `src/read.ts`. -To use your MCP server with Claude Desktop, add it to your Claude configuration: +| Tool | Purpose | Key parameters | +| --- | --- | --- | +| `searchSpotify` | Search tracks, albums, artists, or playlists | `query`, `type`, `limit?` | +| `getNowPlaying` | Show current track, device, volume, shuffle, and repeat status | none | +| `getMyPlaylists` | List the current user's playlists | `limit?` | +| `getPlaylistTracks` | List tracks in a playlist with pagination | `playlistId`, `limit?`, `offset?` | +| `getRecentlyPlayed` | Show recently played tracks | `limit?` | +| `getUsersSavedTracks` | List tracks from Liked Songs | `limit?`, `offset?` | +| `saveUsersSavedTracks` | Save one or more tracks to Liked Songs | `trackIds` (max 40) | +| `saveNowPlayingToLikedSongs` | Save the currently playing track to Liked Songs | none | +| `removeUsersSavedTracks` | Remove one or more tracks from Liked Songs | `trackIds` (max 40) | +| `getQueue` | Show the currently playing item and upcoming queue | `limit?` | +| `getAvailableDevices` | List available Spotify Connect devices | none | + +Behavior notes from the source: + +- `searchSpotify` supports `track`, `album`, `artist`, and `playlist`. +- `getQueue` defaults to showing up to 10 upcoming items. +- `getPlaylistTracks` and `getUsersSavedTracks` support pagination via `offset`. +- `saveUsersSavedTracks` and `removeUsersSavedTracks` call the Spotify Web API directly and cap requests at 40 IDs. + +## Playback and creation tools + +Defined in `src/play.ts`. + +| Tool | Purpose | Key parameters | +| --- | --- | --- | +| `playMusic` | Start playback for a track, album, artist, or playlist | `uri?` or `type` + `id`, `deviceId?` | +| `pausePlayback` | Pause playback | `deviceId?` | +| `resumePlayback` | Resume playback | `deviceId?` | +| `skipToNext` | Skip forward and return updated track info | `deviceId?` | +| `skipToPrevious` | Skip backward and return updated track info | `deviceId?` | +| `createPlaylist` | Create a new playlist | `name`, `description?`, `public?` | +| `addTracksToPlaylist` | Add tracks to a playlist | `playlistId`, `trackIds`, `position?` | +| `addToQueue` | Add an item to the playback queue | `uri?` or `type` + `id`, `deviceId?` | +| `setVolume` | Set exact playback volume | `volumePercent`, `deviceId?` | +| `adjustVolume` | Change volume relative to the current level | `adjustment`, `deviceId?` | +| `setShuffle` | Enable or disable shuffle | `state`, `deviceId?` | +| `setRepeat` | Set repeat mode | `state`, `deviceId?` | + +Behavior notes from the source: + +- `playMusic` requires either a full Spotify `uri` or both `type` and `id`. +- When `playMusic` starts an album, it first tries to turn shuffle off so album playback stays in order. +- `skipToNext` and `skipToPrevious` wait briefly, then fetch the new playback state and return detailed track/device info. +- `setVolume`, `adjustVolume`, `setShuffle`, and `setRepeat` are marked as requiring Spotify Premium. +- `adjustVolume` reads the current playback device first, then clamps the new volume to the `0-100` range. + +## Album tools + +Defined in `src/albums.ts`. + +| Tool | Purpose | Key parameters | +| --- | --- | --- | +| `getAlbums` | Get one or more albums by Spotify ID | `albumIds` (string or string[]) | +| `getAlbumTracks` | List tracks for an album | `albumId`, `limit?`, `offset?` | +| `saveOrRemoveAlbumForUser` | Save or remove albums from the user's library | `albumIds`, `action` | +| `checkUsersSavedAlbums` | Check whether albums are saved | `albumIds` | + +Behavior notes from the source: + +- `getAlbums` accepts either a single album ID or up to 20 IDs. +- `saveOrRemoveAlbumForUser` accepts `action: "save" | "remove"`. +- Album save/check operations are capped at 20 IDs per request. + +## Playlist tools + +Defined in `src/playlist.ts`. + +| Tool | Purpose | Key parameters | +| --- | --- | --- | +| `getPlaylist` | Get playlist metadata, owner, description, and URL | `playlistId` | +| `updatePlaylist` | Update playlist name, description, visibility, or collaboration mode | `playlistId`, `name?`, `description?`, `public?`, `collaborative?` | +| `removeTracksFromPlaylist` | Remove one or more tracks from a playlist | `playlistId`, `trackIds`, `snapshotId?` | +| `reorderPlaylistItems` | Move tracks within a playlist | `playlistId`, `rangeStart`, `insertBefore`, `rangeLength?`, `snapshotId?` | + +Behavior notes from the source: + +- `updatePlaylist` returns an error if no updatable fields are provided. +- `removeTracksFromPlaylist` supports up to 100 track IDs per request. +- `reorderPlaylistItems` uses zero-based positions for both `rangeStart` and `insertBefore`. + +## MCP client integration + +The server runs over stdio, so MCP clients should invoke the built entrypoint with Node. + +### Claude Desktop + +Example server definition: ```json { "mcpServers": { "spotify": { "command": "node", - "args": ["spotify-mcp-server/build/index.js"] + "args": ["/absolute/path/to/spotify-mcp-server/build/index.js"] } } } ``` -For Cursor, go to the MCP tab in `Cursor Settings` (command + shift + J). Add a server with this command: +### Cursor + +Example command: ```bash -node path/to/spotify-mcp-server/build/index.js +node /absolute/path/to/spotify-mcp-server/build/index.js ``` -To set up your MCP correctly with Cline ensure you have the following file configuration set `cline_mcp_settings.json`: +### VS Code / Cline-style config ```json { "mcpServers": { "spotify": { "command": "node", - "args": ["~/../spotify-mcp-server/build/index.js"], - "autoApprove": ["getListeningHistory", "getNowPlaying"] + "args": ["/absolute/path/to/spotify-mcp-server/build/index.js"], + "autoApprove": ["getNowPlaying", "searchSpotify"] } } } ``` -You can add additional tools to the auto approval array to run the tools without intervention. +## Examples + +The `examples/` directory contains standalone scripts that use the compiled `build/` output directly, so build first: + +```bash +npm run build +``` + +Current example files: + +- `examples/test-now-playing.mjs` +- `examples/build-and-play-playlist.mjs` +- `examples/add-songs-to-playlist.mjs` +- `examples/get-music-statistics.mjs` +- `examples/check-playback-status.mjs` +- `examples/enable-shuffle-v2.mjs` +- `examples/skip-next.mjs` +- `examples/next-and-shuffle.mjs` + +There is also a `examples/run-skip.mjs` file in the repository, but it is currently empty. + +For commands and walkthroughs, see `examples/README.md`. + +## Development + +Available npm scripts from `package.json`: + +```bash +npm run build +npm run dev +npm run auth +npm run typecheck +npm run lint +npm run lint:fix +npm start +npm run token:status +npm run token:check +npm run token:refresh +npm run token:help +``` + +What they do: + +- `npm run build` — compile TypeScript to `build/` +- `npm run dev` — watch/compile with TypeScript +- `npm run auth` — compile and run the browser-based Spotify auth flow +- `npm run typecheck` — run TypeScript without emitting files +- `npm run lint` / `npm run lint:fix` — run Biome checks +- `npm start` — start the built MCP server +- `npm run token:status`, `token:check`, `token:refresh`, `token:help` — token utility commands exposed by `package.json` via `build/token-cli.js` + +## Project structure + +```text +spotify-mcp-server/ +├── src/ +│ ├── index.ts # MCP server entry point and tool registration +│ ├── read.ts # Search, playback state, library, queue, devices +│ ├── play.ts # Playback controls, playlist creation, queue, volume +│ ├── albums.ts # Album lookup and save/remove operations +│ ├── playlist.ts # Playlist metadata/update/remove/reorder operations +│ ├── auth.ts # CLI entrypoint for authentication +│ ├── utils.ts # Spotify config loading, auth, token refresh, helpers +│ └── types.ts # Shared TypeScript types +├── examples/ # Standalone example scripts +├── build/ # Compiled JavaScript output +├── spotify-config.example.json +├── package.json +└── README.md +``` + +## Security notes + +- `spotify-config.json` contains your client secret and user tokens. +- The repository's `.gitignore` excludes `spotify-config.json`. +- Do not commit real Spotify credentials or tokens. +- The server logs redact sensitive-looking fields before writing diagnostic data. + +## Example interactions + +- _"What's playing right now?"_ +- _"Show my available Spotify devices."_ +- _"Play this album on my MacBook and turn shuffle off."_ +- _"Add these tracks to my workout playlist."_ +- _"Save the current song to my Liked Songs."_ +- _"Move the first two songs in this playlist to the end."_ + +## License + +MIT + diff --git a/package.json b/package.json index 03932d4..c08c267 100644 --- a/package.json +++ b/package.json @@ -2,21 +2,40 @@ "type": "module", "name": "spotify-mcp-server", "version": "1.0.0", - "main": "index.js", + "description": "A Model Context Protocol server for Spotify integration. Exposes Spotify playback control, playlist management, and music discovery as MCP tools.", + "author": "Marcel Marais", + "license": "MIT", + "main": "build/index.js", "bin": { - "spotify-mcp": "./build/index.js" + "spotify-mcp": "build/index.js" }, "scripts": { - "build": "tsc && node -e \"require('fs').chmodSync('build/index.js', '755')\"", + "build": "tsc && node -e \"import('fs').then(fs => fs.chmodSync('build/index.js', '755'))\"", + "dev": "tsc --watch", "auth": "tsc && node build/auth.js", "lint": "biome check --diagnostic-level=warn --error-on-warnings", "lint:fix": "biome check --write --unsafe --organize-imports-enabled=true", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "start": "node build/index.js", + "token:status": "tsc && node build/token-cli.js status", + "token:check": "tsc && node build/token-cli.js check", + "token:refresh": "tsc && node build/token-cli.js refresh", + "token:help": "tsc && node build/token-cli.js help" }, - "files": ["build"], - "keywords": [], - "author": "Marcel Marais", - "description": "A Model Context Protocol server for Spotify integration", + "files": [ + "build", + "README.md", + "LICENSE" + ], + "keywords": [ + "mcp", + "spotify", + "model-context-protocol", + "ai", + "music", + "playback-control", + "playlist-management" + ], "dependencies": { "@modelcontextprotocol/sdk": "^1.10.1", "@spotify/web-api-ts-sdk": "^1.2.0", @@ -27,5 +46,15 @@ "@biomejs/biome": "^1.9.4", "@types/node": "^22.13.8", "typescript": "^5.8.2" + }, + "engines": { + "node": ">=16.0.0" + }, + "repository": { + "type": "git", + "url": "https://github.com/marcelmarais/spotify-mcp-server" + }, + "bugs": { + "url": "https://github.com/marcelmarais/spotify-mcp-server/issues" } } diff --git a/src/index.ts b/src/index.ts index 668f719..540363c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,24 +4,190 @@ import { albumTools } from './albums.js'; import { playTools } from './play.js'; import { playlistTools } from './playlist.js'; import { readTools } from './read.js'; +import type { SpotifyHandlerExtra } from './types.js'; + +type LogLevel = 'INFO' | 'ERROR'; + +const SENSITIVE_KEY_PATTERN = + /token|secret|password|authorization|accesskey|refreshkey|clientsecret/i; + +let invocationCounter = 0; + +function nextInvocationId(): string { + invocationCounter += 1; + return `req-${Date.now()}-${invocationCounter}`; +} + +function sanitizeForLog(value: unknown, depth = 0): unknown { + if (depth > 3) return '[Truncated]'; + if (value === null || value === undefined) return value; + + if (typeof value === 'string') { + return value.length > 120 ? `${value.slice(0, 117)}...` : value; + } + + if (typeof value === 'number' || typeof value === 'boolean') return value; + + if (Array.isArray(value)) { + const limited = value.slice(0, 10).map((v) => sanitizeForLog(v, depth + 1)); + if (value.length > 10) limited.push(`[+${value.length - 10} more items]`); + return limited; + } + + if (typeof value === 'object') { + const obj = value as Record; + const entries = Object.entries(obj).slice(0, 20); + const out: Record = {}; + + for (const [key, nestedValue] of entries) { + if (SENSITIVE_KEY_PATTERN.test(key)) { + out[key] = '[REDACTED]'; + } else { + out[key] = sanitizeForLog(nestedValue, depth + 1); + } + } + + const keyCount = Object.keys(obj).length; + if (keyCount > 20) { + out.__truncatedKeys = `[+${keyCount - 20} more keys]`; + } + + return out; + } + + return `[Unsupported:${typeof value}]`; +} + +function summarizeToolResponse(result: unknown): Record { + const safeResult = sanitizeForLog(result) as + | Record + | unknown; + const content = + safeResult && typeof safeResult === 'object' + ? (safeResult as { content?: unknown }).content + : undefined; + + if (!Array.isArray(content)) { + return { + hasContentArray: false, + responsePreview: safeResult, + }; + } + + const first = content[0] as { type?: unknown; text?: unknown } | undefined; + const text = typeof first?.text === 'string' ? first.text : ''; + + return { + hasContentArray: true, + contentItems: content.length, + firstContentType: first?.type, + firstTextLength: text.length, + firstTextPreview: text.length > 140 ? `${text.slice(0, 137)}...` : text, + }; +} + +function log(level: LogLevel, message: string, meta?: Record) { + const timestamp = new Date().toISOString(); + const suffix = meta ? ` ${JSON.stringify(meta)}` : ''; + // MCP over stdio must keep stdout clean, so logs go to stderr. + process.stderr.write(`[${timestamp}] [${level}] ${message}${suffix}\n`); +} const server = new McpServer({ name: 'spotify-controller', version: '1.0.0', }); -[...readTools, ...playTools, ...albumTools, ...playlistTools].forEach( - (tool) => { - server.tool(tool.name, tool.description, tool.schema, tool.handler); - }, -); +const allTools = [...readTools, ...playTools, ...albumTools, ...playlistTools]; + +[...allTools].forEach((tool) => { + server.tool( + tool.name, + tool.description, + tool.schema, + async (args: any, extra: SpotifyHandlerExtra) => { + const invocationId = nextInvocationId(); + const startedAt = Date.now(); + const argKeys = args ? Object.keys(args) : []; + const safeArgs = sanitizeForLog(args); + const requestMeta = sanitizeForLog(extra); + + log('INFO', 'Tool call started', { + invocationId, + tool: tool.name, + argKeys, + args: safeArgs, + requestMeta, + }); + + try { + const result = await tool.handler(args, extra); + const durationMs = Date.now() - startedAt; + const responseSummary = summarizeToolResponse(result); + log('INFO', 'Tool call completed', { + invocationId, + tool: tool.name, + durationMs, + response: responseSummary, + }); + return result; + } catch (error) { + const durationMs = Date.now() - startedAt; + const message = error instanceof Error ? error.message : String(error); + const errorName = error instanceof Error ? error.name : 'UnknownError'; + const errorStack = + error instanceof Error && error.stack + ? error.stack.split('\n').slice(0, 4).join('\n') + : undefined; + log('ERROR', 'Tool call failed', { + invocationId, + tool: tool.name, + durationMs, + errorName, + error: message, + errorStack, + }); + throw error; + } + }, + ); +}); async function main() { + log('INFO', 'Starting Spotify MCP server', { + serverName: 'spotify-controller', + version: '1.0.0', + toolCount: allTools.length, + }); + const transport = new StdioServerTransport(); await server.connect(transport); + + log('INFO', 'Spotify MCP server connected to stdio transport'); } +process.on('SIGINT', () => { + log('INFO', 'Received SIGINT, exiting'); + process.exit(0); +}); + +process.on('SIGTERM', () => { + log('INFO', 'Received SIGTERM, exiting'); + process.exit(0); +}); + +process.on('unhandledRejection', (reason) => { + const message = reason instanceof Error ? reason.message : String(reason); + log('ERROR', 'Unhandled promise rejection', { error: message }); +}); + +process.on('uncaughtException', (error) => { + log('ERROR', 'Uncaught exception', { error: error.message }); + process.exit(1); +}); + main().catch((error) => { - console.error('Fatal error in main():', error); + const message = error instanceof Error ? error.message : String(error); + log('ERROR', 'Fatal error in main()', { error: message }); process.exit(1); }); diff --git a/src/play.ts b/src/play.ts index 641ddd1..50a09fe 100644 --- a/src/play.ts +++ b/src/play.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; import type { SpotifyHandlerExtra, tool } from './types.js'; -import { handleSpotifyRequest } from './utils.js'; +import { formatDuration, handleSpotifyRequest } from './utils.js'; const playMusic: tool<{ uri: z.ZodOptional; @@ -48,6 +48,15 @@ const playMusic: tool<{ await handleSpotifyRequest(async (spotifyApi) => { const device = deviceId || ''; + // When playing an album or playlist, turn off shuffle so it plays in order + if (type === 'album') { + try { + await spotifyApi.player.togglePlaybackShuffle(false, device); + } catch (_) { + // Ignore shuffle toggle errors (e.g. no active device yet) + } + } + if (!spotifyUri) { await spotifyApi.player.startResumePlayback(device); return; @@ -66,7 +75,7 @@ const playMusic: tool<{ content: [ { type: 'text', - text: `Started playing ${type || 'music'} ${id ? `(ID: ${id})` : ''}`, + text: `Started playing ${type || 'music'} ${id ? `(ID: ${id})` : ''}${type === 'album' ? ' (shuffle turned off)' : ''}`, }, ], }; @@ -106,7 +115,8 @@ const skipToNext: tool<{ deviceId: z.ZodOptional; }> = { name: 'skipToNext', - description: 'Skip to the next track in the current Spotify playback queue', + description: + 'Skip to the next track in the current Spotify playback queue and display the new track info', schema: { deviceId: z .string() @@ -116,18 +126,82 @@ const skipToNext: tool<{ handler: async (args, _extra: SpotifyHandlerExtra) => { const { deviceId } = args; - await handleSpotifyRequest(async (spotifyApi) => { - await spotifyApi.player.skipToNext(deviceId || ''); - }); + try { + // Skip to next track + await handleSpotifyRequest(async (spotifyApi) => { + await spotifyApi.player.skipToNext(deviceId || ''); + }); - return { - content: [ - { - type: 'text', - text: 'Skipped to next track', - }, - ], - }; + // Add a small delay to let Spotify catch up + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Fetch and display the now-playing track + const playback = await handleSpotifyRequest(async (spotifyApi) => { + return await spotifyApi.player.getPlaybackState(); + }); + + if (!playback?.item) { + return { + content: [ + { + type: 'text', + text: 'Skipped to next track\n\nNo track is currently playing', + }, + ], + }; + } + + const item = playback.item as any; + + // Check if it's a track + if (item.type === 'track' && item.artists && item.album) { + const artists = item.artists.map((a: any) => a.name).join(', '); + const album = item.album?.name || 'Unknown'; + const duration = formatDuration(item.duration_ms); + const progress = formatDuration(playback.progress_ms || 0); + + const device = playback.device; + const deviceInfo = device + ? `${device.name} (${device.type})` + : 'Unknown device'; + const volume = + device?.volume_percent !== null && + device?.volume_percent !== undefined + ? `${device.volume_percent}%` + : 'N/A'; + const shuffle = playback.shuffle_state ? 'On' : 'Off'; + const repeat = playback.repeat_state || 'off'; + + return { + content: [ + { + type: 'text', + text: `# Skipped to next track\n\n**Track**: "${item.name}"\n**Artist**: ${artists}\n**Album**: ${album}\n**Progress**: ${progress} / ${duration}\n**ID**: ${item.id}\n\n**Device**: ${deviceInfo}\n**Volume**: ${volume}\n**Shuffle**: ${shuffle} | **Repeat**: ${repeat}`, + }, + ], + }; + } + + return { + content: [ + { + type: 'text', + text: 'Skipped to next track\n\nCurrently playing item is not a track (might be a podcast episode)', + }, + ], + }; + } catch (error) { + return { + content: [ + { + type: 'text', + text: `Error skipping to next track: ${ + error instanceof Error ? error.message : String(error) + }`, + }, + ], + }; + } }, }; @@ -136,7 +210,7 @@ const skipToPrevious: tool<{ }> = { name: 'skipToPrevious', description: - 'Skip to the previous track in the current Spotify playback queue', + 'Skip to the previous track in the current Spotify playback queue and display the new track info', schema: { deviceId: z .string() @@ -146,18 +220,83 @@ const skipToPrevious: tool<{ handler: async (args, _extra: SpotifyHandlerExtra) => { const { deviceId } = args; - await handleSpotifyRequest(async (spotifyApi) => { - await spotifyApi.player.skipToPrevious(deviceId || ''); - }); + try { + // Skip to previous track + await handleSpotifyRequest(async (spotifyApi) => { + await spotifyApi.player.skipToPrevious(deviceId || ''); + }); - return { - content: [ - { - type: 'text', - text: 'Skipped to previous track', - }, - ], - }; + // Add a small delay to let Spotify catch up + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Fetch and display the now-playing track + const playback = await handleSpotifyRequest(async (spotifyApi) => { + return await spotifyApi.player.getPlaybackState(); + }); + + if (!playback?.item) { + return { + content: [ + { + type: 'text', + text: 'Skipped to previous track\n\nNo track is currently playing', + }, + ], + }; + } + + const item = playback.item as any; + + // Check if it's a track + if (item.type === 'track' && item.artists && item.album) { + const artists = item.artists.map((a: any) => a.name).join(', '); + const album = item.album?.name || 'Unknown'; + const duration = formatDuration(item.duration_ms); + const progress = formatDuration(playback.progress_ms || 0); + const _isPlaying = playback.is_playing; + + const device = playback.device; + const deviceInfo = device + ? `${device.name} (${device.type})` + : 'Unknown device'; + const volume = + device?.volume_percent !== null && + device?.volume_percent !== undefined + ? `${device.volume_percent}%` + : 'N/A'; + const shuffle = playback.shuffle_state ? 'On' : 'Off'; + const repeat = playback.repeat_state || 'off'; + + return { + content: [ + { + type: 'text', + text: `# Skipped to previous track\n\n**Track**: "${item.name}"\n**Artist**: ${artists}\n**Album**: ${album}\n**Progress**: ${progress} / ${duration}\n**ID**: ${item.id}\n\n**Device**: ${deviceInfo}\n**Volume**: ${volume}\n**Shuffle**: ${shuffle} | **Repeat**: ${repeat}`, + }, + ], + }; + } + + return { + content: [ + { + type: 'text', + text: 'Skipped to previous track\n\nCurrently playing item is not a track (might be a podcast episode)', + }, + ], + }; + } catch (error) { + return { + content: [ + { + type: 'text', + text: `Error skipping to previous track: ${ + error instanceof Error ? error.message : String(error) + }`, + }, + ], + }; + } }, }; @@ -496,6 +635,105 @@ const adjustVolume: tool<{ }, }; +const setShuffle: tool<{ + state: z.ZodBoolean; + deviceId: z.ZodOptional; +}> = { + name: 'setShuffle', + description: + 'Turn shuffle on or off for the current playback. Requires Spotify Premium.', + schema: { + state: z.boolean().describe('true to turn shuffle on, false to turn it off'), + deviceId: z + .string() + .optional() + .describe('The Spotify device ID to set shuffle on'), + }, + handler: async (args, _extra: SpotifyHandlerExtra) => { + const { state, deviceId } = args; + + try { + await handleSpotifyRequest(async (spotifyApi) => { + await spotifyApi.player.togglePlaybackShuffle(state, deviceId || ''); + }); + + return { + content: [ + { + type: 'text', + text: `Shuffle turned ${state ? 'on' : 'off'}`, + }, + ], + }; + } catch (error) { + return { + content: [ + { + type: 'text', + text: `Error setting shuffle: ${ + error instanceof Error ? error.message : String(error) + }`, + }, + ], + }; + } + }, +}; + +const setRepeat: tool<{ + state: z.ZodEnum<['off', 'track', 'context']>; + deviceId: z.ZodOptional; +}> = { + name: 'setRepeat', + description: + 'Set the repeat mode for playback. "off" disables repeat, "track" repeats the current track, "context" repeats the current album/playlist. Requires Spotify Premium.', + schema: { + state: z + .enum(['off', 'track', 'context']) + .describe('Repeat mode: "off", "track", or "context" (album/playlist)'), + deviceId: z + .string() + .optional() + .describe('The Spotify device ID to set repeat on'), + }, + handler: async (args, _extra: SpotifyHandlerExtra) => { + const { state, deviceId } = args; + + try { + await handleSpotifyRequest(async (spotifyApi) => { + await spotifyApi.player.setRepeatMode(state, deviceId || ''); + }); + + const modeDescription = + state === 'off' + ? 'off' + : state === 'track' + ? 'on (current track)' + : 'on (album/playlist)'; + + return { + content: [ + { + type: 'text', + text: `Repeat set to ${modeDescription}`, + }, + ], + }; + } catch (error) { + return { + content: [ + { + type: 'text', + text: `Error setting repeat: ${ + error instanceof Error ? error.message : String(error) + }`, + }, + ], + }; + } + }, +}; + export const playTools = [ playMusic, pausePlayback, @@ -507,4 +745,6 @@ export const playTools = [ addToQueue, setVolume, adjustVolume, + setShuffle, + setRepeat, ]; diff --git a/src/read.ts b/src/read.ts index 776ff4a..a7fa99f 100644 --- a/src/read.ts +++ b/src/read.ts @@ -605,6 +605,150 @@ const getAvailableDevices: tool> = { }, }; +const saveUsersSavedTracks: tool<{ + trackIds: z.ZodArray; +}> = { + name: 'saveUsersSavedTracks', + description: + 'Save one or more tracks to the user\'s "Liked Songs" library (max 40 per request)', + schema: { + trackIds: z + .array(z.string()) + .max(40) + .describe('Array of Spotify track IDs to save (max 40)'), + }, + handler: async (args, _extra: SpotifyHandlerExtra) => { + const { trackIds } = args; + + if (trackIds.length === 0) { + return { + content: [{ type: 'text', text: 'Error: No track IDs provided' }], + }; + } + + try { + // Ensure token is fresh (handles auto-refresh if needed) + await createSpotifyApi(); + const config = loadSpotifyConfig(); + + const uris = trackIds.map((id) => `spotify:track:${id}`).join(','); + const response = await fetch( + `https://api.spotify.com/v1/me/library?uris=${encodeURIComponent(uris)}`, + { + method: 'PUT', + headers: { + Authorization: `Bearer ${config.accessToken}`, + }, + }, + ); + + if (!response.ok) { + const errorData = await response.text(); + throw new Error(`Spotify API error ${response.status}: ${errorData}`); + } + + return { + content: [ + { + type: 'text', + text: `Successfully saved ${trackIds.length} track${trackIds.length === 1 ? '' : 's'} to your Liked Songs`, + }, + ], + }; + } catch (error) { + return { + content: [ + { + type: 'text', + text: `Error saving tracks to Liked Songs: ${ + error instanceof Error ? error.message : String(error) + }`, + }, + ], + }; + } + }, +}; + +const saveNowPlayingToLikedSongs: tool> = { + name: 'saveNowPlayingToLikedSongs', + description: + 'Save the currently playing track to the user\'s "Liked Songs" library', + schema: {}, + handler: async (_args, _extra: SpotifyHandlerExtra) => { + try { + const playback = await handleSpotifyRequest(async (spotifyApi) => { + return await spotifyApi.player.getPlaybackState(); + }); + + if (!playback?.item) { + return { + content: [ + { + type: 'text', + text: 'Nothing is currently playing on Spotify', + }, + ], + }; + } + + const item = playback.item; + if (!isTrack(item)) { + return { + content: [ + { + type: 'text', + text: 'Currently playing item is not a track (might be a podcast episode)', + }, + ], + }; + } + + // Ensure token is fresh (handles auto-refresh if needed) + await createSpotifyApi(); + const config = loadSpotifyConfig(); + const uri = `spotify:track:${item.id}`; + + const response = await fetch( + `https://api.spotify.com/v1/me/library?uris=${encodeURIComponent(uri)}`, + { + method: 'PUT', + headers: { + Authorization: `Bearer ${config.accessToken}`, + }, + }, + ); + + if (!response.ok) { + const errorData = await response.text(); + throw new Error(`Spotify API error ${response.status}: ${errorData}`); + } + + return { + content: [ + { + type: 'text', + text: `Saved now playing track to Liked Songs: "${item.name}" by ${item.artists + .map((a) => a.name) + .join(', ')}`, + }, + ], + }; + } catch (error) { + return { + content: [ + { + type: 'text', + text: `Error saving currently playing track: ${ + error instanceof Error ? error.message : String(error) + }`, + }, + ], + }; + } + }, +}; + const removeUsersSavedTracks: tool<{ trackIds: z.ZodArray; }> = { @@ -677,6 +821,8 @@ export const readTools = [ getPlaylistTracks, getRecentlyPlayed, getUsersSavedTracks, + saveUsersSavedTracks, + saveNowPlayingToLikedSongs, removeUsersSavedTracks, getQueue, getAvailableDevices, diff --git a/src/utils.ts b/src/utils.ts index 65c405a..87a4607 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -15,7 +15,7 @@ export interface SpotifyConfig { redirectUri: string; accessToken?: string; refreshToken?: string; - expiresAt?: number; // Unix timestamp in milliseconds + expiresAt?: number; } export function loadSpotifyConfig(): SpotifyConfig { @@ -48,44 +48,14 @@ export function saveSpotifyConfig(config: SpotifyConfig): void { let cachedSpotifyApi: SpotifyApi | null = null; -export async function createSpotifyApi(): Promise { +export function createSpotifyApi(): SpotifyApi { const config = loadSpotifyConfig(); if (config.accessToken && config.refreshToken) { - const now = Date.now(); - const shouldRefresh = !config.expiresAt || config.expiresAt <= now; - - if (shouldRefresh) { - console.log( - 'Access token expired or missing expiration time, refreshing...', - ); - try { - const tokens = await refreshAccessToken(config); - config.accessToken = tokens.access_token; - config.expiresAt = now + tokens.expires_in * 1000; // Convert seconds to milliseconds - saveSpotifyConfig(config); - console.log('Access token refreshed successfully'); - - // Clear cached API instance to force recreation with new token - cachedSpotifyApi = null; - } catch (error) { - console.error('Failed to refresh token:', error); - throw new Error( - 'Failed to refresh access token. Please run "npm run auth" to re-authenticate.', - ); - } - } - - if (cachedSpotifyApi) { - return cachedSpotifyApi; - } - const accessToken = { access_token: config.accessToken, token_type: 'Bearer', - expires_in: Math.floor( - ((config.expiresAt ?? now + 3600000) - now) / 1000, - ), + expires_in: 3600 * 24 * 30, // Default to 1 month refresh_token: config.refreshToken, }; @@ -93,8 +63,7 @@ export async function createSpotifyApi(): Promise { return cachedSpotifyApi; } - // Fallback to client credentials if no user tokens available - cachedSpotifyApi = SpotifyApi.withClientCredentials( + const spotifyApi = SpotifyApi.withClientCredentials( config.clientId, config.clientSecret, ); @@ -121,11 +90,7 @@ function base64Encode(str: string): string { async function exchangeCodeForToken( code: string, config: SpotifyConfig, -): Promise<{ - access_token: string; - refresh_token: string; - expires_in: number; -}> { +): Promise<{ access_token: string; refresh_token: string }> { const tokenUrl = 'https://accounts.spotify.com/api/token'; const authHeader = `Basic ${base64Encode(`${config.clientId}:${config.clientSecret}`)}`; @@ -152,42 +117,6 @@ async function exchangeCodeForToken( return { access_token: data.access_token, refresh_token: data.refresh_token, - expires_in: data.expires_in || 3600, - }; -} - -async function refreshAccessToken( - config: SpotifyConfig, -): Promise<{ access_token: string; expires_in: number }> { - if (!config.refreshToken) { - throw new Error('No refresh token available'); - } - - const tokenUrl = 'https://accounts.spotify.com/api/token'; - const authHeader = `Basic ${base64Encode(`${config.clientId}:${config.clientSecret}`)}`; - - const params = new URLSearchParams(); - params.append('grant_type', 'refresh_token'); - params.append('refresh_token', config.refreshToken); - - const response = await fetch(tokenUrl, { - method: 'POST', - headers: { - Authorization: authHeader, - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: params, - }); - - if (!response.ok) { - const errorData = await response.text(); - throw new Error(`Failed to refresh access token: ${errorData}`); - } - - const data = await response.json(); - return { - access_token: data.access_token, - expires_in: data.expires_in || 3600, }; } @@ -237,7 +166,7 @@ export async function authorizeSpotify(): Promise { redirect_uri: config.redirectUri, scope: scopes.join(' '), state: state, - show_dialog: 'true', + show_dialog: 'false', }); const authorizationUrl = `https://accounts.spotify.com/authorize?${authParams.toString()}`; @@ -293,7 +222,7 @@ export async function authorizeSpotify(): Promise { config.accessToken = tokens.access_token; config.refreshToken = tokens.refresh_token; - config.expiresAt = Date.now() + tokens.expires_in * 1000; // Convert seconds to milliseconds + config.expiresAt = Date.now() + 3600 * 1000; // Default to 1 hour saveSpotifyConfig(config); res.end( @@ -352,7 +281,7 @@ export async function handleSpotifyRequest( action: (spotifyApi: SpotifyApi) => Promise, ): Promise { try { - const spotifyApi = await createSpotifyApi(); + const spotifyApi = createSpotifyApi(); return await action(spotifyApi); } catch (error) { // Skip JSON parsing errors as these are actually successful operations