Initial commit: Javis project
This commit is contained in:
commit
435c645f36
164 files changed
+35279
No files matched your search
+10
@@ -0,0 +1,10 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
groups:
|
||||
dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
|
||||
jobs:
|
||||
lint-typecheck:
|
||||
name: Lint and Typecheck
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Cache npm dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-npm-
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Run Biome linting
|
||||
run: npm run lint
|
||||
|
||||
- name: Run TypeScript type checking
|
||||
run: npm run typecheck
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
.vscode
|
||||
.env
|
||||
package-lock.json
|
||||
spotify-config.json
|
||||
build
|
||||
dist
|
||||
.DS_Store
|
||||
@@ -0,0 +1,411 @@
|
||||
<div align="center" style="display: flex; align-items: center; justify-content: center; gap: 10px;">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/8/84/Spotify_icon.svg" width="30" height="30">
|
||||
<h1>Spotify MCP Server</h1>
|
||||
</div>
|
||||
|
||||
A lightweight [Model Context Protocol (MCP)](https://modelcontextprotocol.io) server that enables AI assistants like Cursor & Claude to control Spotify playback and manage playlists.
|
||||
|
||||
<details>
|
||||
<summary>Contents</summary>
|
||||
|
||||
- [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)
|
||||
</details>
|
||||
|
||||
## 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 })`
|
||||
|
||||
## Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js v16+
|
||||
- A Spotify Premium account
|
||||
- A registered Spotify Developer application
|
||||
|
||||
### Installation
|
||||
|
||||
```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
|
||||
|
||||
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
|
||||
|
||||
### Spotify API Configuration
|
||||
|
||||
Create a `spotify-config.json` file in the project root (you can copy and modify the provided example):
|
||||
|
||||
```bash
|
||||
# Copy the example config file
|
||||
cp spotify-config.example.json spotify-config.json
|
||||
```
|
||||
|
||||
Then edit the file with your credentials:
|
||||
|
||||
```json
|
||||
{
|
||||
"clientId": "your-client-id",
|
||||
"clientSecret": "your-client-secret",
|
||||
"redirectUri": "http://127.0.0.1:8888/callback"
|
||||
}
|
||||
```
|
||||
|
||||
### Authentication Process
|
||||
|
||||
The Spotify API uses OAuth 2.0 for authentication. Follow these steps to authenticate your application:
|
||||
|
||||
1. Run the authentication script:
|
||||
|
||||
```bash
|
||||
npm run auth
|
||||
```
|
||||
|
||||
2. The script will generate an authorization URL. Open this URL in your web browser.
|
||||
|
||||
3. You'll be prompted to log in to Spotify and authorize your application.
|
||||
|
||||
4. After authorization, Spotify will redirect you to your specified redirect URI with a code parameter in the URL.
|
||||
|
||||
5. The authentication script will automatically exchange this code for access and refresh tokens.
|
||||
|
||||
6. These tokens will be saved to your `spotify-config.json` file, which will now look something like:
|
||||
|
||||
```json
|
||||
{
|
||||
"clientId": "your-client-id",
|
||||
"clientSecret": "your-client-secret",
|
||||
"redirectUri": "http://localhost:8888/callback",
|
||||
"accessToken": "BQAi9Pn...kKQ",
|
||||
"refreshToken": "AQDQcj...7w",
|
||||
"expiresAt": 1677889354671
|
||||
}
|
||||
```
|
||||
|
||||
**Note**: The `expiresAt` field is a Unix timestamp (in milliseconds) indicating when the access token expires.
|
||||
|
||||
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.
|
||||
|
||||
## Integrating with Claude Desktop, Cursor, and VsCode [Via Cline model extension](https://marketplace.visualstudio.com/items/?itemName=saoudrizwan.claude-dev)
|
||||
|
||||
To use your MCP server with Claude Desktop, add it to your Claude configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"spotify": {
|
||||
"command": "node",
|
||||
"args": ["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:
|
||||
|
||||
```bash
|
||||
node 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`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"spotify": {
|
||||
"command": "node",
|
||||
"args": ["~/../spotify-mcp-server/build/index.js"],
|
||||
"autoApprove": ["getListeningHistory", "getNowPlaying"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
You can add additional tools to the auto approval array to run the tools without intervention.
|
||||
@@ -0,0 +1,149 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
||||
"files": {
|
||||
"ignoreUnknown": false,
|
||||
"ignore": [
|
||||
"**/pnpm-lock.yaml",
|
||||
"lib/db/migrations",
|
||||
"lib/editor/react-renderer.tsx",
|
||||
"node_modules",
|
||||
".next",
|
||||
"public",
|
||||
".vercel"
|
||||
]
|
||||
},
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"defaultBranch": "main",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"formatWithErrors": false,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineEnding": "lf",
|
||||
"lineWidth": 80,
|
||||
"attributePosition": "auto"
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"a11y": {
|
||||
"useHtmlLang": "warn", // Not in recommended ruleset, turning on manually
|
||||
"noHeaderScope": "warn", // Not in recommended ruleset, turning on manually
|
||||
"useValidAriaRole": {
|
||||
"level": "warn",
|
||||
"options": {
|
||||
"ignoreNonDom": false,
|
||||
"allowInvalidRoles": ["none", "text"]
|
||||
}
|
||||
},
|
||||
"useSemanticElements": "off", // Rule is buggy, revisit later
|
||||
"noSvgWithoutTitle": "off", // We do not intend to adhere to this rule
|
||||
"useMediaCaption": "off", // We would need a cultural change to turn this on
|
||||
"noAutofocus": "off", // We're highly intentional about when we use autofocus
|
||||
"noBlankTarget": "off", // Covered by Conformance
|
||||
"useFocusableInteractive": "off", // Disable focusable interactive element requirement
|
||||
"useAriaPropsForRole": "off", // Disable required ARIA attributes check
|
||||
"useKeyWithClickEvents": "off" // Disable keyboard event requirement with click events
|
||||
},
|
||||
"complexity": {
|
||||
"noUselessStringConcat": "warn",
|
||||
"noForEach": "off",
|
||||
"noUselessSwitchCase": "off",
|
||||
"noUselessThisAlias": "off",
|
||||
"noUselessTernary": "error",
|
||||
"useFlatMap": "error",
|
||||
"useOptionalChain": "error",
|
||||
"useSimplifiedLogicExpression": "error"
|
||||
},
|
||||
"correctness": {
|
||||
"noUnusedImports": "error",
|
||||
"useArrayLiterals": "warn", // Not in recommended ruleset, turning on manually
|
||||
"noNewSymbol": "warn", // Not in recommended ruleset, turning on manually
|
||||
"useJsxKeyInIterable": "off", // Rule is buggy, revisit later
|
||||
"useExhaustiveDependencies": "off", // Community feedback on this rule has been poor, we will continue with ESLint
|
||||
"noUnnecessaryContinue": "off", // Turned off due to developer preferences
|
||||
"noUnusedVariables": "error"
|
||||
},
|
||||
"security": {
|
||||
"noDangerouslySetInnerHtml": "error"
|
||||
},
|
||||
"style": {
|
||||
"useFragmentSyntax": "warn",
|
||||
"noYodaExpression": "warn",
|
||||
"useDefaultParameterLast": "warn",
|
||||
"useExponentiationOperator": "off",
|
||||
"noUnusedTemplateLiteral": "off",
|
||||
"noUselessElse": "error",
|
||||
"useImportType": "error",
|
||||
"useSelfClosingElements": "error",
|
||||
"useFilenamingConvention": {
|
||||
"level": "error",
|
||||
"options": {
|
||||
"filenameCases": ["kebab-case", "export"],
|
||||
"requireAscii": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"suspicious": {
|
||||
"noExplicitAny": "off"
|
||||
},
|
||||
"nursery": {
|
||||
"noStaticElementInteractions": "warn",
|
||||
"noHeadImportInDocument": "warn",
|
||||
"noDocumentImportInPage": "warn",
|
||||
"noDuplicateElseIf": "warn",
|
||||
"noIrregularWhitespace": "warn",
|
||||
"useValidAutocomplete": "warn",
|
||||
"useSortedClasses": "error"
|
||||
}
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"jsxRuntime": "reactClassic",
|
||||
"formatter": {
|
||||
"jsxQuoteStyle": "double",
|
||||
"quoteProperties": "asNeeded",
|
||||
"trailingCommas": "all",
|
||||
"semicolons": "always",
|
||||
"arrowParentheses": "always",
|
||||
"bracketSpacing": true,
|
||||
"bracketSameLine": false,
|
||||
"quoteStyle": "single",
|
||||
"attributePosition": "auto"
|
||||
}
|
||||
},
|
||||
"json": {
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"trailingCommas": "none"
|
||||
},
|
||||
"parser": {
|
||||
"allowComments": true,
|
||||
"allowTrailingCommas": false
|
||||
}
|
||||
},
|
||||
"css": {
|
||||
"formatter": { "enabled": false },
|
||||
"linter": { "enabled": false }
|
||||
},
|
||||
"organizeImports": { "enabled": true },
|
||||
"overrides": [
|
||||
// Playwright requires an object destructure, even if empty
|
||||
// https://github.com/microsoft/playwright/issues/30007
|
||||
{
|
||||
"include": ["playwright/**"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"correctness": {
|
||||
"noEmptyPattern": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "spotify-mcp-server",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"bin": {
|
||||
"spotify-mcp": "./build/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc && node -e \"require('fs').chmodSync('build/index.js', '755')\"",
|
||||
"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"
|
||||
},
|
||||
"files": ["build"],
|
||||
"keywords": [],
|
||||
"author": "Marcel Marais",
|
||||
"description": "A Model Context Protocol server for Spotify integration",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.10.1",
|
||||
"@spotify/web-api-ts-sdk": "^1.2.0",
|
||||
"open": "^10.1.0",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@types/node": "^22.13.8",
|
||||
"typescript": "^5.8.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"clientId": "your-client-id",
|
||||
"clientSecret": "your-client-secret",
|
||||
"redirectUri": "http://127.0.0.1:8888/callback",
|
||||
"accessToken": "run-npm auth to get this",
|
||||
"refreshToken": "run-npm auth to get this",
|
||||
"expiresAt": "automatically set during authentication"
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
import type { MaxInt } from '@spotify/web-api-ts-sdk';
|
||||
import { z } from 'zod';
|
||||
import type { SpotifyHandlerExtra, tool } from './types.js';
|
||||
import { formatDuration, handleSpotifyRequest } from './utils.js';
|
||||
|
||||
const getAlbums: tool<{
|
||||
albumIds: z.ZodUnion<[z.ZodString, z.ZodArray<z.ZodString>]>;
|
||||
}> = {
|
||||
name: 'getAlbums',
|
||||
description:
|
||||
'Get detailed information about one or more albums by their Spotify IDs',
|
||||
schema: {
|
||||
albumIds: z
|
||||
.union([z.string(), z.array(z.string()).max(20)])
|
||||
.describe('A single album ID or array of album IDs (max 20)'),
|
||||
},
|
||||
handler: async (args, _extra: SpotifyHandlerExtra) => {
|
||||
const { albumIds } = args;
|
||||
const ids = Array.isArray(albumIds) ? albumIds : [albumIds];
|
||||
|
||||
if (ids.length === 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Error: No album IDs provided',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const albums = await handleSpotifyRequest(async (spotifyApi) => {
|
||||
return ids.length === 1
|
||||
? [await spotifyApi.albums.get(ids[0])]
|
||||
: await spotifyApi.albums.get(ids);
|
||||
});
|
||||
|
||||
if (albums.length === 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'No albums found for the provided IDs',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (albums.length === 1) {
|
||||
const album = albums[0];
|
||||
const artists = album.artists.map((a) => a.name).join(', ');
|
||||
const releaseDate = album.release_date;
|
||||
const totalTracks = album.total_tracks;
|
||||
const albumType = album.album_type;
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `# Album Details\n\n**Name**: "${album.name}"\n**Artists**: ${artists}\n**Release Date**: ${releaseDate}\n**Type**: ${albumType}\n**Total Tracks**: ${totalTracks}\n**ID**: ${album.id}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const formattedAlbums = albums
|
||||
.map((album, i) => {
|
||||
if (!album) return `${i + 1}. [Album not found]`;
|
||||
|
||||
const artists = album.artists.map((a) => a.name).join(', ');
|
||||
return `${i + 1}. "${album.name}" by ${artists} (${album.release_date}) - ${album.total_tracks} tracks - ID: ${album.id}`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `# Multiple Albums\n\n${formattedAlbums}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error getting albums: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const getAlbumTracks: tool<{
|
||||
albumId: z.ZodString;
|
||||
limit: z.ZodOptional<z.ZodNumber>;
|
||||
offset: z.ZodOptional<z.ZodNumber>;
|
||||
}> = {
|
||||
name: 'getAlbumTracks',
|
||||
description: 'Get tracks from a specific album with pagination support',
|
||||
schema: {
|
||||
albumId: z.string().describe('The Spotify ID of the album'),
|
||||
limit: z
|
||||
.number()
|
||||
.min(1)
|
||||
.max(50)
|
||||
.optional()
|
||||
.describe('Maximum number of tracks to return (1-50)'),
|
||||
offset: z
|
||||
.number()
|
||||
.min(0)
|
||||
.optional()
|
||||
.describe('Offset for pagination (0-based index)'),
|
||||
},
|
||||
handler: async (args, _extra: SpotifyHandlerExtra) => {
|
||||
const { albumId, limit = 20, offset = 0 } = args;
|
||||
|
||||
try {
|
||||
const tracks = await handleSpotifyRequest(async (spotifyApi) => {
|
||||
return await spotifyApi.albums.tracks(
|
||||
albumId,
|
||||
undefined,
|
||||
limit as MaxInt<50>,
|
||||
offset,
|
||||
);
|
||||
});
|
||||
|
||||
if (tracks.items.length === 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'No tracks found in this album',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const formattedTracks = tracks.items
|
||||
.map((track, i) => {
|
||||
if (!track) return `${i + 1}. [Track not found]`;
|
||||
|
||||
const artists = track.artists.map((a) => a.name).join(', ');
|
||||
const duration = formatDuration(track.duration_ms);
|
||||
return `${offset + i + 1}. "${track.name}" by ${artists} (${duration}) - ID: ${track.id}`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `# Album Tracks (${offset + 1}-${offset + tracks.items.length} of ${tracks.total})\n\n${formattedTracks}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error getting album tracks: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const saveOrRemoveAlbumForUser: tool<{
|
||||
albumIds: z.ZodArray<z.ZodString>;
|
||||
action: z.ZodEnum<['save', 'remove']>;
|
||||
}> = {
|
||||
name: 'saveOrRemoveAlbumForUser',
|
||||
description: 'Save or remove albums from the user\'s "Your Music" library',
|
||||
schema: {
|
||||
albumIds: z
|
||||
.array(z.string())
|
||||
.max(20)
|
||||
.describe('Array of Spotify album IDs (max 20)'),
|
||||
action: z
|
||||
.enum(['save', 'remove'])
|
||||
.describe('Action to perform: save or remove albums'),
|
||||
},
|
||||
handler: async (args, _extra: SpotifyHandlerExtra) => {
|
||||
const { albumIds, action } = args;
|
||||
|
||||
if (albumIds.length === 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Error: No album IDs provided',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await handleSpotifyRequest(async (spotifyApi) => {
|
||||
return action === 'save'
|
||||
? await spotifyApi.currentUser.albums.saveAlbums(albumIds)
|
||||
: await spotifyApi.currentUser.albums.removeSavedAlbums(albumIds);
|
||||
});
|
||||
|
||||
const actionPastTense = action === 'save' ? 'saved' : 'removed';
|
||||
const preposition = action === 'save' ? 'to' : 'from';
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Successfully ${actionPastTense} ${albumIds.length} album${albumIds.length === 1 ? '' : 's'} ${preposition} your library`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error ${action === 'save' ? 'saving' : 'removing'} albums: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const checkUsersSavedAlbums: tool<{
|
||||
albumIds: z.ZodArray<z.ZodString>;
|
||||
}> = {
|
||||
name: 'checkUsersSavedAlbums',
|
||||
description: 'Check if albums are saved in the user\'s "Your Music" library',
|
||||
schema: {
|
||||
albumIds: z
|
||||
.array(z.string())
|
||||
.max(20)
|
||||
.describe('Array of Spotify album IDs to check (max 20)'),
|
||||
},
|
||||
handler: async (args, _extra: SpotifyHandlerExtra) => {
|
||||
const { albumIds } = args;
|
||||
|
||||
if (albumIds.length === 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Error: No album IDs provided',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const savedStatus = await handleSpotifyRequest(async (spotifyApi) => {
|
||||
return await spotifyApi.currentUser.albums.hasSavedAlbums(albumIds);
|
||||
});
|
||||
|
||||
const formattedResults = albumIds
|
||||
.map((albumId, i) => {
|
||||
const isSaved = savedStatus[i];
|
||||
return `${i + 1}. ${albumId}: ${isSaved ? 'Saved' : 'Not saved'}`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `# Album Save Status\n\n${formattedResults}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error checking saved albums: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const albumTools = [
|
||||
getAlbums,
|
||||
getAlbumTracks,
|
||||
saveOrRemoveAlbumForUser,
|
||||
checkUsersSavedAlbums,
|
||||
];
|
||||
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { authorizeSpotify } from './utils.js';
|
||||
|
||||
console.log('Starting Spotify authentication flow...');
|
||||
authorizeSpotify()
|
||||
.then(() => {
|
||||
console.log('Authentication completed successfully!');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Authentication failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { albumTools } from './albums.js';
|
||||
import { playTools } from './play.js';
|
||||
import { playlistTools } from './playlist.js';
|
||||
import { readTools } from './read.js';
|
||||
|
||||
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);
|
||||
},
|
||||
);
|
||||
|
||||
async function main() {
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('Fatal error in main():', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,586 @@
|
||||
import { z } from 'zod';
|
||||
import type { SpotifyHandlerExtra, tool } from './types.js';
|
||||
import { handleSpotifyRequest, spotifyFetch } from './utils.js';
|
||||
|
||||
const playMusic: tool<{
|
||||
uri: z.ZodOptional<z.ZodString>;
|
||||
type: z.ZodOptional<z.ZodEnum<['track', 'album', 'artist', 'playlist']>>;
|
||||
id: z.ZodOptional<z.ZodString>;
|
||||
deviceId: z.ZodOptional<z.ZodString>;
|
||||
}> = {
|
||||
name: 'playMusic',
|
||||
description: 'Start playing a Spotify track, album, artist, or playlist',
|
||||
schema: {
|
||||
uri: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The Spotify URI to play (overrides type and id)'),
|
||||
type: z
|
||||
.enum(['track', 'album', 'artist', 'playlist'])
|
||||
.optional()
|
||||
.describe('The type of item to play'),
|
||||
id: z.string().optional().describe('The Spotify ID of the item to play'),
|
||||
deviceId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The Spotify device ID to play on'),
|
||||
},
|
||||
handler: async (args, _extra: SpotifyHandlerExtra) => {
|
||||
const { uri, type, id, deviceId } = args;
|
||||
|
||||
if (!(uri || (type && id))) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Error: Must provide either a URI or both a type and ID',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
let spotifyUri = uri;
|
||||
if (!spotifyUri && type && id) {
|
||||
spotifyUri = `spotify:${type}:${id}`;
|
||||
}
|
||||
|
||||
try {
|
||||
await handleSpotifyRequest(async (spotifyApi) => {
|
||||
const device = deviceId || '';
|
||||
|
||||
if (!spotifyUri) {
|
||||
await spotifyApi.player.startResumePlayback(device);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'track') {
|
||||
await spotifyApi.player.startResumePlayback(device, undefined, [
|
||||
spotifyUri,
|
||||
]);
|
||||
} else {
|
||||
await spotifyApi.player.startResumePlayback(device, spotifyUri);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Started playing ${type || 'music'} ${id ? `(ID: ${id})` : ''}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error starting playback: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const pausePlayback: tool<{
|
||||
deviceId: z.ZodOptional<z.ZodString>;
|
||||
}> = {
|
||||
name: 'pausePlayback',
|
||||
description: 'Pause Spotify playback on the active device',
|
||||
schema: {
|
||||
deviceId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The Spotify device ID to pause playback on'),
|
||||
},
|
||||
handler: async (args, _extra: SpotifyHandlerExtra) => {
|
||||
const { deviceId } = args;
|
||||
|
||||
try {
|
||||
await handleSpotifyRequest(async (spotifyApi) => {
|
||||
await spotifyApi.player.pausePlayback(deviceId || '');
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: 'Playback paused' }],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error pausing playback: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const skipToNext: tool<{
|
||||
deviceId: z.ZodOptional<z.ZodString>;
|
||||
}> = {
|
||||
name: 'skipToNext',
|
||||
description: 'Skip to the next track in the current Spotify playback queue',
|
||||
schema: {
|
||||
deviceId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The Spotify device ID to skip on'),
|
||||
},
|
||||
handler: async (args, _extra: SpotifyHandlerExtra) => {
|
||||
const { deviceId } = args;
|
||||
|
||||
try {
|
||||
await handleSpotifyRequest(async (spotifyApi) => {
|
||||
await spotifyApi.player.skipToNext(deviceId || '');
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: 'Skipped to next track' }],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error skipping to next track: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const skipToPrevious: tool<{
|
||||
deviceId: z.ZodOptional<z.ZodString>;
|
||||
}> = {
|
||||
name: 'skipToPrevious',
|
||||
description:
|
||||
'Skip to the previous track in the current Spotify playback queue',
|
||||
schema: {
|
||||
deviceId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The Spotify device ID to skip on'),
|
||||
},
|
||||
handler: async (args, _extra: SpotifyHandlerExtra) => {
|
||||
const { deviceId } = args;
|
||||
|
||||
try {
|
||||
await handleSpotifyRequest(async (spotifyApi) => {
|
||||
await spotifyApi.player.skipToPrevious(deviceId || '');
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: 'Skipped to previous track' }],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error skipping to previous track: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const createPlaylist: tool<{
|
||||
name: z.ZodString;
|
||||
description: z.ZodOptional<z.ZodString>;
|
||||
public: z.ZodOptional<z.ZodBoolean>;
|
||||
}> = {
|
||||
name: 'createPlaylist',
|
||||
description: 'Create a new playlist on Spotify',
|
||||
schema: {
|
||||
name: z.string().describe('The name of the playlist'),
|
||||
description: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The description of the playlist'),
|
||||
public: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Whether the playlist should be public'),
|
||||
},
|
||||
handler: async (args, _extra: SpotifyHandlerExtra) => {
|
||||
const { name, description, public: isPublic = false } = args;
|
||||
|
||||
try {
|
||||
// Get user ID via raw fetch (SDK playlists.createPlaylist hit a 403 after
|
||||
// the March 2026 Spotify API changes — use spotifyFetch to avoid the SDK
|
||||
// OAuth signing layer and hit the endpoint directly with Bearer token).
|
||||
const me = await spotifyFetch<{ id: string }>('me');
|
||||
const playlist = await spotifyFetch<{
|
||||
id: string;
|
||||
external_urls: { spotify: string };
|
||||
}>(`users/${me.id}/playlists`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
name,
|
||||
...(description !== undefined ? { description } : {}),
|
||||
public: isPublic,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Successfully created playlist "${name}"\nPlaylist ID: ${playlist.id}\nPlaylist URL: ${playlist.external_urls.spotify}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error creating playlist: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const addTracksToPlaylist: tool<{
|
||||
playlistId: z.ZodString;
|
||||
trackIds: z.ZodArray<z.ZodString>;
|
||||
position: z.ZodOptional<z.ZodNumber>;
|
||||
}> = {
|
||||
name: 'addTracksToPlaylist',
|
||||
description: 'Add tracks to a Spotify playlist',
|
||||
schema: {
|
||||
playlistId: z.string().describe('The Spotify ID of the playlist'),
|
||||
trackIds: z.array(z.string()).describe('Array of Spotify track IDs to add'),
|
||||
position: z
|
||||
.number()
|
||||
.nonnegative()
|
||||
.optional()
|
||||
.describe('Position to insert the tracks (0-based index)'),
|
||||
},
|
||||
handler: async (args, _extra: SpotifyHandlerExtra) => {
|
||||
const { playlistId, trackIds, position } = args;
|
||||
|
||||
if (trackIds.length === 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Error: No track IDs provided',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const trackUris = trackIds.map((id) => `spotify:track:${id}`);
|
||||
|
||||
// Hit /items directly: see spotifyFetch JSDoc for context.
|
||||
await spotifyFetch(`playlists/${playlistId}/items`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
uris: trackUris,
|
||||
...(position !== undefined ? { position } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Successfully added ${trackIds.length} track${
|
||||
trackIds.length === 1 ? '' : 's'
|
||||
} to playlist (ID: ${playlistId})`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error adding tracks to playlist: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const resumePlayback: tool<{
|
||||
deviceId: z.ZodOptional<z.ZodString>;
|
||||
}> = {
|
||||
name: 'resumePlayback',
|
||||
description: 'Resume Spotify playback on the active device',
|
||||
schema: {
|
||||
deviceId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The Spotify device ID to resume playback on'),
|
||||
},
|
||||
handler: async (args, _extra: SpotifyHandlerExtra) => {
|
||||
const { deviceId } = args;
|
||||
|
||||
try {
|
||||
await handleSpotifyRequest(async (spotifyApi) => {
|
||||
await spotifyApi.player.startResumePlayback(deviceId || '');
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: 'Playback resumed' }],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error resuming playback: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const addToQueue: tool<{
|
||||
uri: z.ZodOptional<z.ZodString>;
|
||||
type: z.ZodOptional<z.ZodEnum<['track', 'album', 'artist', 'playlist']>>;
|
||||
id: z.ZodOptional<z.ZodString>;
|
||||
deviceId: z.ZodOptional<z.ZodString>;
|
||||
}> = {
|
||||
name: 'addToQueue',
|
||||
description: 'Adds a track, album, artist or playlist to the playback queue',
|
||||
schema: {
|
||||
uri: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The Spotify URI to play (overrides type and id)'),
|
||||
type: z
|
||||
.enum(['track', 'album', 'artist', 'playlist'])
|
||||
.optional()
|
||||
.describe('The type of item to play'),
|
||||
id: z.string().optional().describe('The Spotify ID of the item to play'),
|
||||
deviceId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The Spotify device ID to add the track to'),
|
||||
},
|
||||
handler: async (args) => {
|
||||
const { uri, type, id, deviceId } = args;
|
||||
|
||||
let spotifyUri = uri;
|
||||
if (!spotifyUri && type && id) {
|
||||
spotifyUri = `spotify:${type}:${id}`;
|
||||
}
|
||||
|
||||
if (!spotifyUri) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Error: Must provide either a URI or both a type and ID',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await handleSpotifyRequest(async (spotifyApi) => {
|
||||
await spotifyApi.player.addItemToPlaybackQueue(
|
||||
spotifyUri,
|
||||
deviceId || '',
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Added item ${spotifyUri} to queue`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error adding to queue: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const setVolume: tool<{
|
||||
volumePercent: z.ZodNumber;
|
||||
deviceId: z.ZodOptional<z.ZodString>;
|
||||
}> = {
|
||||
name: 'setVolume',
|
||||
description:
|
||||
'Set the playback volume to a specific percentage (0-100). Requires Spotify Premium.',
|
||||
schema: {
|
||||
volumePercent: z
|
||||
.number()
|
||||
.min(0)
|
||||
.max(100)
|
||||
.describe('The volume to set (0-100)'),
|
||||
deviceId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The Spotify device ID to set volume on'),
|
||||
},
|
||||
handler: async (args, _extra: SpotifyHandlerExtra) => {
|
||||
const { volumePercent, deviceId } = args;
|
||||
|
||||
try {
|
||||
await handleSpotifyRequest(async (spotifyApi) => {
|
||||
await spotifyApi.player.setPlaybackVolume(
|
||||
Math.round(volumePercent),
|
||||
deviceId || '',
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Volume set to ${Math.round(volumePercent)}%`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error setting volume: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const adjustVolume: tool<{
|
||||
adjustment: z.ZodNumber;
|
||||
deviceId: z.ZodOptional<z.ZodString>;
|
||||
}> = {
|
||||
name: 'adjustVolume',
|
||||
description:
|
||||
'Adjust the playback volume up or down by a relative amount. Use positive values to increase, negative to decrease. Requires Spotify Premium.',
|
||||
schema: {
|
||||
adjustment: z
|
||||
.number()
|
||||
.min(-100)
|
||||
.max(100)
|
||||
.describe(
|
||||
'The amount to adjust volume by (-100 to 100). Positive increases, negative decreases.',
|
||||
),
|
||||
deviceId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The Spotify device ID to adjust volume on'),
|
||||
},
|
||||
handler: async (args, _extra: SpotifyHandlerExtra) => {
|
||||
const { adjustment, deviceId } = args;
|
||||
|
||||
try {
|
||||
// First get the current playback state to find current volume
|
||||
const playback = await handleSpotifyRequest(async (spotifyApi) => {
|
||||
return await spotifyApi.player.getPlaybackState();
|
||||
});
|
||||
|
||||
if (!playback?.device) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'No active device found. Make sure Spotify is open and playing on a device.',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const currentVolume = playback.device.volume_percent;
|
||||
if (currentVolume === null || currentVolume === undefined) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Unable to get current volume from device.',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const newVolume = Math.min(100, Math.max(0, currentVolume + adjustment));
|
||||
|
||||
await handleSpotifyRequest(async (spotifyApi) => {
|
||||
await spotifyApi.player.setPlaybackVolume(
|
||||
Math.round(newVolume),
|
||||
deviceId || '',
|
||||
);
|
||||
});
|
||||
|
||||
const direction = adjustment > 0 ? 'increased' : 'decreased';
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Volume ${direction} from ${currentVolume}% to ${Math.round(newVolume)}%`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error adjusting volume: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const playTools = [
|
||||
playMusic,
|
||||
pausePlayback,
|
||||
skipToNext,
|
||||
skipToPrevious,
|
||||
createPlaylist,
|
||||
addTracksToPlaylist,
|
||||
resumePlayback,
|
||||
addToQueue,
|
||||
setVolume,
|
||||
adjustVolume,
|
||||
];
|
||||
@@ -0,0 +1,294 @@
|
||||
import { z } from 'zod';
|
||||
import type { SpotifyHandlerExtra, tool } from './types.js';
|
||||
import { handleSpotifyRequest, spotifyFetch } from './utils.js';
|
||||
|
||||
const getPlaylist: tool<{
|
||||
playlistId: z.ZodString;
|
||||
}> = {
|
||||
name: 'getPlaylist',
|
||||
description:
|
||||
'Get details of a specific Spotify playlist including tracks count, description and owner',
|
||||
schema: {
|
||||
playlistId: z.string().describe('The Spotify ID of the playlist'),
|
||||
},
|
||||
handler: async (args, _extra: SpotifyHandlerExtra) => {
|
||||
const { playlistId } = args;
|
||||
|
||||
try {
|
||||
const playlist = await handleSpotifyRequest(async (spotifyApi) => {
|
||||
return await spotifyApi.playlists.getPlaylist(playlistId);
|
||||
});
|
||||
|
||||
const owner =
|
||||
playlist.owner?.display_name ?? playlist.owner?.id ?? 'Unknown';
|
||||
const tracksTotal = playlist.tracks?.total ?? 0;
|
||||
const isPublic = playlist.public ? 'Public' : 'Private';
|
||||
const isCollaborative = playlist.collaborative ? ' | Collaborative' : '';
|
||||
const description = playlist.description
|
||||
? `\n**Description**: ${playlist.description}`
|
||||
: '';
|
||||
const url = playlist.external_urls?.spotify ?? '';
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text:
|
||||
`# Playlist: "${playlist.name}"\n\n` +
|
||||
`**Owner**: ${owner}\n` +
|
||||
`**Tracks**: ${tracksTotal}\n` +
|
||||
`**Visibility**: ${isPublic}${isCollaborative}` +
|
||||
`${description}\n` +
|
||||
`**ID**: ${playlist.id}\n` +
|
||||
`**URL**: ${url}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error getting playlist: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const updatePlaylist: tool<{
|
||||
playlistId: z.ZodString;
|
||||
name: z.ZodOptional<z.ZodString>;
|
||||
description: z.ZodOptional<z.ZodString>;
|
||||
public: z.ZodOptional<z.ZodBoolean>;
|
||||
collaborative: z.ZodOptional<z.ZodBoolean>;
|
||||
}> = {
|
||||
name: 'updatePlaylist',
|
||||
description:
|
||||
'Update the details of a Spotify playlist (name, description, public/private, collaborative)',
|
||||
schema: {
|
||||
playlistId: z.string().describe('The Spotify ID of the playlist'),
|
||||
name: z.string().optional().describe('New name for the playlist'),
|
||||
description: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('New description for the playlist'),
|
||||
public: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Whether the playlist should be public'),
|
||||
collaborative: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
'Whether the playlist should be collaborative (requires public to be false)',
|
||||
),
|
||||
},
|
||||
handler: async (args, _extra: SpotifyHandlerExtra) => {
|
||||
const {
|
||||
playlistId,
|
||||
name,
|
||||
description,
|
||||
public: isPublic,
|
||||
collaborative,
|
||||
} = args;
|
||||
|
||||
if (
|
||||
!name &&
|
||||
description === undefined &&
|
||||
isPublic === undefined &&
|
||||
collaborative === undefined
|
||||
) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Error: At least one field to update must be provided (name, description, public, collaborative)',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const body: Record<string, string | boolean> = {};
|
||||
if (name) body.name = name;
|
||||
if (description !== undefined) body.description = description;
|
||||
if (isPublic !== undefined) body.public = isPublic;
|
||||
if (collaborative !== undefined) body.collaborative = collaborative;
|
||||
|
||||
await handleSpotifyRequest(async (spotifyApi) => {
|
||||
await spotifyApi.playlists.changePlaylistDetails(playlistId, body);
|
||||
});
|
||||
|
||||
const changes = Object.keys(body).join(', ');
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Successfully updated playlist (ID: ${playlistId})\nFields updated: ${changes}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error updating playlist: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const removeTracksFromPlaylist: tool<{
|
||||
playlistId: z.ZodString;
|
||||
trackIds: z.ZodArray<z.ZodString>;
|
||||
snapshotId: z.ZodOptional<z.ZodString>;
|
||||
}> = {
|
||||
name: 'removeTracksFromPlaylist',
|
||||
description:
|
||||
'Remove one or more tracks from a Spotify playlist (max 100 tracks per request)',
|
||||
schema: {
|
||||
playlistId: z.string().describe('The Spotify ID of the playlist'),
|
||||
trackIds: z
|
||||
.array(z.string())
|
||||
.min(1)
|
||||
.max(100)
|
||||
.describe('Array of Spotify track IDs to remove (max 100)'),
|
||||
snapshotId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'The playlist snapshot ID to target a specific version (optional)',
|
||||
),
|
||||
},
|
||||
handler: async (args, _extra: SpotifyHandlerExtra) => {
|
||||
const { playlistId, trackIds, snapshotId } = args;
|
||||
|
||||
try {
|
||||
const items = trackIds.map((id) => ({ uri: `spotify:track:${id}` }));
|
||||
|
||||
// Hit /items directly: SDK targets the deprecated /tracks endpoint
|
||||
// (see spotifyFetch JSDoc for context on the March 2026 migration).
|
||||
await spotifyFetch(`playlists/${playlistId}/items`, {
|
||||
method: 'DELETE',
|
||||
body: {
|
||||
items,
|
||||
...(snapshotId ? { snapshot_id: snapshotId } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Successfully removed ${trackIds.length} track${
|
||||
trackIds.length === 1 ? '' : 's'
|
||||
} from playlist (ID: ${playlistId})`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error removing tracks from playlist: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const reorderPlaylistItems: tool<{
|
||||
playlistId: z.ZodString;
|
||||
rangeStart: z.ZodNumber;
|
||||
insertBefore: z.ZodNumber;
|
||||
rangeLength: z.ZodOptional<z.ZodNumber>;
|
||||
snapshotId: z.ZodOptional<z.ZodString>;
|
||||
}> = {
|
||||
name: 'reorderPlaylistItems',
|
||||
description:
|
||||
'Reorder a range of tracks within a Spotify playlist by moving them to a new position',
|
||||
schema: {
|
||||
playlistId: z.string().describe('The Spotify ID of the playlist'),
|
||||
rangeStart: z
|
||||
.number()
|
||||
.nonnegative()
|
||||
.describe('The position of the first item to move (0-based index)'),
|
||||
insertBefore: z
|
||||
.number()
|
||||
.nonnegative()
|
||||
.describe(
|
||||
'The position where the items should be inserted (0-based index)',
|
||||
),
|
||||
rangeLength: z
|
||||
.number()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe('Number of consecutive items to move (defaults to 1)'),
|
||||
snapshotId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'The playlist snapshot ID to target a specific version (optional)',
|
||||
),
|
||||
},
|
||||
handler: async (args, _extra: SpotifyHandlerExtra) => {
|
||||
const { playlistId, rangeStart, insertBefore, rangeLength, snapshotId } =
|
||||
args;
|
||||
|
||||
try {
|
||||
// Hit /items directly: see spotifyFetch JSDoc for context.
|
||||
await spotifyFetch(`playlists/${playlistId}/items`, {
|
||||
method: 'PUT',
|
||||
body: {
|
||||
range_start: rangeStart,
|
||||
insert_before: insertBefore,
|
||||
...(rangeLength !== undefined ? { range_length: rangeLength } : {}),
|
||||
...(snapshotId ? { snapshot_id: snapshotId } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
const count = rangeLength ?? 1;
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Successfully moved ${count} track${
|
||||
count === 1 ? '' : 's'
|
||||
} from position ${rangeStart} to before position ${insertBefore} in playlist (ID: ${playlistId})`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error reordering playlist items: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const playlistTools = [
|
||||
getPlaylist,
|
||||
updatePlaylist,
|
||||
removeTracksFromPlaylist,
|
||||
reorderPlaylistItems,
|
||||
];
|
||||
@@ -0,0 +1,766 @@
|
||||
import type { MaxInt } from '@spotify/web-api-ts-sdk';
|
||||
import { z } from 'zod';
|
||||
import type { SpotifyHandlerExtra, SpotifyTrack, tool } from './types.js';
|
||||
import {
|
||||
formatDuration,
|
||||
handleSpotifyRequest,
|
||||
spotifyFetch,
|
||||
} from './utils.js';
|
||||
|
||||
function isTrack(item: any): item is SpotifyTrack {
|
||||
return (
|
||||
item &&
|
||||
item.type === 'track' &&
|
||||
Array.isArray(item.artists) &&
|
||||
item.album &&
|
||||
typeof item.album.name === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
const searchSpotify: tool<{
|
||||
query: z.ZodString;
|
||||
type: z.ZodEnum<['track', 'album', 'artist', 'playlist']>;
|
||||
limit: z.ZodOptional<z.ZodNumber>;
|
||||
}> = {
|
||||
name: 'searchSpotify',
|
||||
description: 'Search for tracks, albums, artists, or playlists on Spotify',
|
||||
schema: {
|
||||
query: z.string().describe('The search query'),
|
||||
type: z
|
||||
.enum(['track', 'album', 'artist', 'playlist'])
|
||||
.describe(
|
||||
'The type of item to search for either track, album, artist, or playlist',
|
||||
),
|
||||
limit: z
|
||||
.number()
|
||||
.min(1)
|
||||
.max(50)
|
||||
.optional()
|
||||
.describe('Maximum number of results to return (10-50)'),
|
||||
},
|
||||
handler: async (args, _extra: SpotifyHandlerExtra) => {
|
||||
const { query, type, limit } = args;
|
||||
const limitValue = limit ?? 10;
|
||||
|
||||
try {
|
||||
const results = await handleSpotifyRequest(async (spotifyApi) => {
|
||||
return await spotifyApi.search(
|
||||
query,
|
||||
[type],
|
||||
undefined,
|
||||
limitValue as MaxInt<50>,
|
||||
);
|
||||
});
|
||||
|
||||
let formattedResults = '';
|
||||
|
||||
if (type === 'track' && results.tracks) {
|
||||
formattedResults = results.tracks.items
|
||||
.map((track, i) => {
|
||||
const artists = track.artists.map((a) => a.name).join(', ');
|
||||
const duration = formatDuration(track.duration_ms);
|
||||
return `${i + 1}. "${
|
||||
track.name
|
||||
}" by ${artists} (${duration}) - ID: ${track.id}`;
|
||||
})
|
||||
.join('\n');
|
||||
} else if (type === 'album' && results.albums) {
|
||||
formattedResults = results.albums.items
|
||||
.map((album, i) => {
|
||||
const artists = album.artists.map((a) => a.name).join(', ');
|
||||
return `${i + 1}. "${album.name}" by ${artists} - ID: ${album.id}`;
|
||||
})
|
||||
.join('\n');
|
||||
} else if (type === 'artist' && results.artists) {
|
||||
formattedResults = results.artists.items
|
||||
.map((artist, i) => {
|
||||
return `${i + 1}. ${artist.name} - ID: ${artist.id}`;
|
||||
})
|
||||
.join('\n');
|
||||
} else if (type === 'playlist' && results.playlists) {
|
||||
formattedResults = results.playlists.items
|
||||
.map((playlist, i) => {
|
||||
return `${i + 1}. "${playlist?.name ?? 'Unknown Playlist'} (${
|
||||
playlist?.description ?? 'No description'
|
||||
} tracks)" by ${playlist?.owner?.display_name} - ID: ${
|
||||
playlist?.id
|
||||
}`;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text:
|
||||
formattedResults.length > 0
|
||||
? `# Search results for "${query}" (type: ${type})\n\n${formattedResults}`
|
||||
: `No ${type} results found for "${query}"`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error searching for ${type}s: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const getNowPlaying: tool<Record<string, never>> = {
|
||||
name: 'getNowPlaying',
|
||||
description:
|
||||
'Get information about the currently playing track on Spotify, including device and volume info',
|
||||
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)',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const artists = item.artists.map((a) => a.name).join(', ');
|
||||
const album = item.album.name;
|
||||
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:
|
||||
`# Currently ${isPlaying ? 'Playing' : 'Paused'}\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}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error getting current track: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const getMyPlaylists: tool<{
|
||||
limit: z.ZodOptional<z.ZodNumber>;
|
||||
}> = {
|
||||
name: 'getMyPlaylists',
|
||||
description: "Get a list of the current user's playlists on Spotify",
|
||||
schema: {
|
||||
limit: z
|
||||
.number()
|
||||
.min(1)
|
||||
.max(50)
|
||||
.optional()
|
||||
.describe('Maximum number of playlists to return (1-50)'),
|
||||
},
|
||||
handler: async (args, _extra: SpotifyHandlerExtra) => {
|
||||
const { limit = 50 } = args;
|
||||
|
||||
try {
|
||||
const playlists = await handleSpotifyRequest(async (spotifyApi) => {
|
||||
return await spotifyApi.currentUser.playlists.playlists(
|
||||
limit as MaxInt<50>,
|
||||
);
|
||||
});
|
||||
|
||||
if (playlists.items.length === 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: "You don't have any playlists on Spotify",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const formattedPlaylists = playlists.items
|
||||
.map((playlist, i) => {
|
||||
const tracksTotal = playlist.tracks?.total ? playlist.tracks.total : 0;
|
||||
return `${i + 1}. "${playlist.name}" (${tracksTotal} tracks) - ID: ${
|
||||
playlist.id
|
||||
}`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `# Your Spotify Playlists\n\n${formattedPlaylists}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error getting playlists: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const getPlaylistTracks: tool<{
|
||||
playlistId: z.ZodString;
|
||||
limit: z.ZodOptional<z.ZodNumber>;
|
||||
offset: z.ZodOptional<z.ZodNumber>;
|
||||
}> = {
|
||||
name: 'getPlaylistTracks',
|
||||
description: 'Get a list of tracks in a Spotify playlist',
|
||||
schema: {
|
||||
playlistId: z.string().describe('The Spotify ID of the playlist'),
|
||||
limit: z
|
||||
.number()
|
||||
.min(1)
|
||||
.max(50)
|
||||
.optional()
|
||||
.describe('Maximum number of tracks to return (1-50)'),
|
||||
offset: z
|
||||
.number()
|
||||
.min(0)
|
||||
.optional()
|
||||
.describe('Offset for pagination (0-based index)'),
|
||||
},
|
||||
handler: async (args, _extra: SpotifyHandlerExtra) => {
|
||||
const { playlistId, limit = 50, offset = 0 } = args;
|
||||
|
||||
// Hit /items directly: see spotifyFetch JSDoc for context.
|
||||
// Response wraps each entry's track under `item` (new) or `track` (legacy).
|
||||
type PlaylistItemEntry = {
|
||||
item?: SpotifyTrack | null;
|
||||
track?: SpotifyTrack | null;
|
||||
};
|
||||
const playlistTracks = await spotifyFetch<{
|
||||
items: PlaylistItemEntry[];
|
||||
total: number;
|
||||
}>(`playlists/${playlistId}/items`, {
|
||||
query: { limit, offset },
|
||||
});
|
||||
|
||||
if ((playlistTracks.items?.length ?? 0) === 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: "This playlist doesn't have any tracks",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const formattedTracks = playlistTracks.items
|
||||
.map((entry, i) => {
|
||||
const track = entry.item ?? entry.track;
|
||||
if (!track) return `${offset + i + 1}. [Removed track]`;
|
||||
|
||||
if (isTrack(track)) {
|
||||
const artists = track.artists.map((a) => a.name).join(', ');
|
||||
const duration = formatDuration(track.duration_ms);
|
||||
return `${offset + i + 1}. "${track.name}" by ${artists} (${duration}) - ID: ${track.id}`;
|
||||
}
|
||||
|
||||
return `${offset + i + 1}. Unknown item`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `# Tracks in Playlist (${offset + 1}-${offset + playlistTracks.items.length} of ${playlistTracks.total})\n\n${formattedTracks}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const getRecentlyPlayed: tool<{
|
||||
limit: z.ZodOptional<z.ZodNumber>;
|
||||
}> = {
|
||||
name: 'getRecentlyPlayed',
|
||||
description: 'Get a list of recently played tracks on Spotify',
|
||||
schema: {
|
||||
limit: z
|
||||
.number()
|
||||
.min(1)
|
||||
.max(50)
|
||||
.optional()
|
||||
.describe('Maximum number of tracks to return (1-50)'),
|
||||
},
|
||||
handler: async (args, _extra: SpotifyHandlerExtra) => {
|
||||
const { limit = 50 } = args;
|
||||
|
||||
try {
|
||||
const history = await handleSpotifyRequest(async (spotifyApi) => {
|
||||
return await spotifyApi.player.getRecentlyPlayedTracks(
|
||||
limit as MaxInt<50>,
|
||||
);
|
||||
});
|
||||
|
||||
if (history.items.length === 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: "You don't have any recently played tracks on Spotify",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const formattedHistory = history.items
|
||||
.map((item, i) => {
|
||||
const track = item.track;
|
||||
if (!track) return `${i + 1}. [Removed track]`;
|
||||
|
||||
if (isTrack(track)) {
|
||||
const artists = track.artists.map((a) => a.name).join(', ');
|
||||
const duration = formatDuration(track.duration_ms);
|
||||
const playedAt = item.played_at
|
||||
? new Date(item.played_at).toLocaleString()
|
||||
: 'Unknown time';
|
||||
return `${i + 1}. "${track.name}" by ${artists} (${duration}) - ID: ${track.id} - Played at: ${playedAt}`;
|
||||
}
|
||||
|
||||
return `${i + 1}. Unknown item`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `# Recently Played Tracks\n\n${formattedHistory}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error getting recently played tracks: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const getUsersSavedTracks: tool<{
|
||||
limit: z.ZodOptional<z.ZodNumber>;
|
||||
offset: z.ZodOptional<z.ZodNumber>;
|
||||
}> = {
|
||||
name: 'getUsersSavedTracks',
|
||||
description:
|
||||
'Get a list of tracks saved in the user\'s "Liked Songs" library',
|
||||
schema: {
|
||||
limit: z
|
||||
.number()
|
||||
.min(1)
|
||||
.max(50)
|
||||
.optional()
|
||||
.describe('Maximum number of tracks to return (1-50)'),
|
||||
offset: z
|
||||
.number()
|
||||
.min(0)
|
||||
.optional()
|
||||
.describe('Offset for pagination (0-based index)'),
|
||||
},
|
||||
handler: async (args, _extra: SpotifyHandlerExtra) => {
|
||||
const { limit = 50, offset = 0 } = args;
|
||||
|
||||
try {
|
||||
const savedTracks = await handleSpotifyRequest(async (spotifyApi) => {
|
||||
return await spotifyApi.currentUser.tracks.savedTracks(
|
||||
limit as MaxInt<50>,
|
||||
offset,
|
||||
);
|
||||
});
|
||||
|
||||
if (savedTracks.items.length === 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: "You don't have any saved tracks in your Liked Songs",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const formattedTracks = savedTracks.items
|
||||
.map((item, i) => {
|
||||
const track = item.track;
|
||||
if (!track) return `${i + 1}. [Removed track]`;
|
||||
|
||||
if (isTrack(track)) {
|
||||
const artists = track.artists.map((a) => a.name).join(', ');
|
||||
const duration = formatDuration(track.duration_ms);
|
||||
const addedDate = new Date(item.added_at).toLocaleDateString();
|
||||
return `${offset + i + 1}. "${track.name}" by ${artists} (${duration}) - ID: ${track.id} - Added: ${addedDate}`;
|
||||
}
|
||||
|
||||
return `${i + 1}. Unknown item`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `# Your Liked Songs (${offset + 1}-${offset + savedTracks.items.length} of ${savedTracks.total})\n\n${formattedTracks}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error getting saved tracks: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const getQueue: tool<{
|
||||
limit: z.ZodOptional<z.ZodNumber>;
|
||||
}> = {
|
||||
name: 'getQueue',
|
||||
description:
|
||||
'Get a list of the currently playing track and the next items in your Spotify queue',
|
||||
schema: {
|
||||
limit: z
|
||||
.number()
|
||||
.min(1)
|
||||
.max(50)
|
||||
.optional()
|
||||
.describe('Maximum number of upcoming items to show (1-50)'),
|
||||
},
|
||||
handler: async (args, _extra: SpotifyHandlerExtra) => {
|
||||
const { limit = 10 } = args;
|
||||
|
||||
try {
|
||||
const queue = await handleSpotifyRequest(async (spotifyApi) => {
|
||||
return await spotifyApi.player.getUsersQueue();
|
||||
});
|
||||
|
||||
const current = (queue as any)?.currently_playing;
|
||||
const upcoming = ((queue as any)?.queue ?? []) as any[];
|
||||
|
||||
const header = '# Spotify Queue\n\n';
|
||||
|
||||
let currentText = 'Nothing is currently playing';
|
||||
if (current) {
|
||||
const name = current?.name ?? 'Unknown';
|
||||
const artists = Array.isArray(current?.artists)
|
||||
? (current.artists as Array<{ name: string }>)
|
||||
.map((a) => a.name)
|
||||
.join(', ')
|
||||
: 'Unknown';
|
||||
const duration =
|
||||
typeof current?.duration_ms === 'number'
|
||||
? formatDuration(current.duration_ms)
|
||||
: 'Unknown';
|
||||
currentText = `Currently Playing: "${name}" by ${artists} (${duration})`;
|
||||
}
|
||||
|
||||
if (upcoming.length === 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `${header}${currentText}\n\nNo upcoming items in the queue`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const toShow = upcoming.slice(0, limit);
|
||||
const formatted = toShow
|
||||
.map((track, i) => {
|
||||
const name = track?.name ?? 'Unknown';
|
||||
const artists = Array.isArray(track?.artists)
|
||||
? (track.artists as Array<{ name: string }>)
|
||||
.map((a) => a.name)
|
||||
.join(', ')
|
||||
: 'Unknown';
|
||||
const duration =
|
||||
typeof track?.duration_ms === 'number'
|
||||
? formatDuration(track.duration_ms)
|
||||
: 'Unknown';
|
||||
const id = track?.id ?? 'Unknown';
|
||||
return `${i + 1}. "${name}" by ${artists} (${duration}) - ID: ${id}`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `${header}${currentText}\n\nNext ${toShow.length} in queue:\n\n${formatted}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error fetching queue: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const getAvailableDevices: tool<Record<string, never>> = {
|
||||
name: 'getAvailableDevices',
|
||||
description:
|
||||
"Get information about the user's available Spotify Connect devices",
|
||||
schema: {},
|
||||
handler: async (_args, _extra: SpotifyHandlerExtra) => {
|
||||
try {
|
||||
const devices = await handleSpotifyRequest(async (spotifyApi) => {
|
||||
return await spotifyApi.player.getAvailableDevices();
|
||||
});
|
||||
|
||||
if (!devices.devices || devices.devices.length === 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'No available devices found. Make sure Spotify is open on at least one device.',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const formattedDevices = devices.devices
|
||||
.map((device, i) => {
|
||||
const status = device.is_active ? '▶ Active' : '○ Inactive';
|
||||
const volume =
|
||||
device.volume_percent !== null
|
||||
? `${device.volume_percent}%`
|
||||
: 'N/A';
|
||||
const restricted = device.is_restricted ? ' (Restricted)' : '';
|
||||
return `${i + 1}. ${device.name} (${device.type})${restricted}\n Status: ${status} | Volume: ${volume} | ID: ${device.id}`;
|
||||
})
|
||||
.join('\n\n');
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `# Available Spotify Devices\n\n${formattedDevices}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error getting available devices: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const saveUsersTracks: tool<{
|
||||
trackIds: z.ZodArray<z.ZodString>;
|
||||
}> = {
|
||||
name: 'saveUsersTracks',
|
||||
description:
|
||||
'Save (like) one or more tracks to the user\'s "Liked Songs" library (max 50 per request)',
|
||||
schema: {
|
||||
trackIds: z
|
||||
.array(z.string())
|
||||
.max(50)
|
||||
.describe('Array of Spotify track IDs to save/like (max 50)'),
|
||||
},
|
||||
handler: async (args, _extra: SpotifyHandlerExtra) => {
|
||||
const { trackIds } = args;
|
||||
|
||||
if (trackIds.length === 0) {
|
||||
return {
|
||||
content: [{ type: 'text', text: 'Error: No track IDs provided' }],
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// PUT /me/tracks with body {"ids": [...]}
|
||||
await spotifyFetch('me/tracks', {
|
||||
method: 'PUT',
|
||||
body: { ids: trackIds },
|
||||
});
|
||||
|
||||
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 removeUsersSavedTracks: tool<{
|
||||
trackIds: z.ZodArray<z.ZodString>;
|
||||
}> = {
|
||||
name: 'removeUsersSavedTracks',
|
||||
description:
|
||||
'Remove one or more tracks from 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 remove (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 {
|
||||
// DELETE /me/tracks with body {"ids": [...]} (plain track IDs, not URIs)
|
||||
await spotifyFetch('me/tracks', {
|
||||
method: 'DELETE',
|
||||
body: { ids: trackIds },
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Successfully removed ${trackIds.length} track${
|
||||
trackIds.length === 1 ? '' : 's'
|
||||
} from your Liked Songs`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error removing tracks from Liked Songs: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const readTools = [
|
||||
searchSpotify,
|
||||
getNowPlaying,
|
||||
getMyPlaylists,
|
||||
getPlaylistTracks,
|
||||
getRecentlyPlayed,
|
||||
getUsersSavedTracks,
|
||||
saveUsersTracks,
|
||||
removeUsersSavedTracks,
|
||||
getQueue,
|
||||
getAvailableDevices,
|
||||
];
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js';
|
||||
import type {
|
||||
ServerNotification,
|
||||
ServerRequest,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { z } from 'zod';
|
||||
|
||||
export type SpotifyHandlerExtra = RequestHandlerExtra<
|
||||
ServerRequest,
|
||||
ServerNotification
|
||||
>;
|
||||
|
||||
export type tool<Args extends z.ZodRawShape> = {
|
||||
name: string;
|
||||
description: string;
|
||||
schema: Args;
|
||||
handler: (
|
||||
args: z.infer<z.ZodObject<Args>>,
|
||||
extra: SpotifyHandlerExtra,
|
||||
) =>
|
||||
| Promise<{
|
||||
content: Array<{
|
||||
type: 'text';
|
||||
text: string;
|
||||
}>;
|
||||
}>
|
||||
| {
|
||||
content: Array<{
|
||||
type: 'text';
|
||||
text: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
export interface SpotifyArtist {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface SpotifyAlbum {
|
||||
id: string;
|
||||
name: string;
|
||||
artists: SpotifyArtist[];
|
||||
}
|
||||
|
||||
export interface SpotifyTrack {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
duration_ms: number;
|
||||
artists: SpotifyArtist[];
|
||||
album: SpotifyAlbum;
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
import { URL, fileURLToPath } from 'node:url';
|
||||
import { SpotifyApi } from '@spotify/web-api-ts-sdk';
|
||||
import open from 'open';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CONFIG_FILE = path.join(__dirname, '../spotify-config.json');
|
||||
|
||||
export interface SpotifyConfig {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
redirectUri: string;
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
expiresAt?: number; // Unix timestamp in milliseconds
|
||||
}
|
||||
|
||||
export function loadSpotifyConfig(): SpotifyConfig {
|
||||
if (!fs.existsSync(CONFIG_FILE)) {
|
||||
throw new Error(
|
||||
`Spotify configuration file not found at ${CONFIG_FILE}. Please create one with clientId, clientSecret, and redirectUri.`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const config = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
||||
if (!(config.clientId && config.clientSecret && config.redirectUri)) {
|
||||
throw new Error(
|
||||
'Spotify configuration must include clientId, clientSecret, and redirectUri.',
|
||||
);
|
||||
}
|
||||
return config;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to parse Spotify configuration: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function saveSpotifyConfig(config: SpotifyConfig): void {
|
||||
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
let cachedSpotifyApi: SpotifyApi | null = null;
|
||||
|
||||
/**
|
||||
* Direct Spotify Web API fetch helper.
|
||||
* Used to bypass @spotify/web-api-ts-sdk methods that hit deprecated endpoints
|
||||
* (e.g. /playlists/{id}/tracks which was retired in the March 2026 API migration
|
||||
* for new Development Mode apps; replacement is /playlists/{id}/items).
|
||||
*
|
||||
* Handles token loading and refresh transparently.
|
||||
*/
|
||||
export async function spotifyFetch<T = unknown>(
|
||||
endpoint: string,
|
||||
options: {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
body?: unknown;
|
||||
query?: Record<string, string | number | undefined>;
|
||||
} = {},
|
||||
): Promise<T> {
|
||||
const { method = 'GET', body, query } = options;
|
||||
const config = loadSpotifyConfig();
|
||||
|
||||
// Refresh token if expired
|
||||
if (config.accessToken && config.refreshToken) {
|
||||
const now = Date.now();
|
||||
if (!config.expiresAt || config.expiresAt <= now) {
|
||||
const tokens = await refreshAccessToken(config);
|
||||
config.accessToken = tokens.access_token;
|
||||
config.expiresAt = now + tokens.expires_in * 1000;
|
||||
saveSpotifyConfig(config);
|
||||
cachedSpotifyApi = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!config.accessToken) {
|
||||
throw new Error(
|
||||
'No access token available. Run "npm run auth" to authenticate.',
|
||||
);
|
||||
}
|
||||
|
||||
// Build URL with query string
|
||||
const cleanEndpoint = endpoint.startsWith('/') ? endpoint.slice(1) : endpoint;
|
||||
let url = `https://api.spotify.com/v1/${cleanEndpoint}`;
|
||||
if (query) {
|
||||
const qs = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(query)) {
|
||||
if (v !== undefined && v !== null) qs.append(k, String(v));
|
||||
}
|
||||
const qsStr = qs.toString();
|
||||
if (qsStr) url += `?${qsStr}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.accessToken}`,
|
||||
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
|
||||
},
|
||||
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errBody = await response.text();
|
||||
throw new Error(
|
||||
`Spotify API ${method} ${url} failed (${response.status}): ${errBody}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Some endpoints (DELETE, PUT) return empty body
|
||||
const text = await response.text();
|
||||
if (!text) return undefined as T;
|
||||
return JSON.parse(text) as T;
|
||||
}
|
||||
|
||||
export async function createSpotifyApi(): Promise<SpotifyApi> {
|
||||
const config = loadSpotifyConfig();
|
||||
|
||||
if (config.accessToken && config.refreshToken) {
|
||||
const now = Date.now();
|
||||
const shouldRefresh = !config.expiresAt || config.expiresAt <= now;
|
||||
|
||||
if (shouldRefresh) {
|
||||
console.error(
|
||||
'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.error('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,
|
||||
),
|
||||
refresh_token: config.refreshToken,
|
||||
};
|
||||
|
||||
cachedSpotifyApi = SpotifyApi.withAccessToken(config.clientId, accessToken);
|
||||
return cachedSpotifyApi;
|
||||
}
|
||||
|
||||
// Fallback to client credentials if no user tokens available
|
||||
cachedSpotifyApi = SpotifyApi.withClientCredentials(
|
||||
config.clientId,
|
||||
config.clientSecret,
|
||||
);
|
||||
|
||||
return cachedSpotifyApi;
|
||||
}
|
||||
|
||||
function generateRandomString(length: number): string {
|
||||
const array = new Uint8Array(length);
|
||||
crypto.getRandomValues(array);
|
||||
return Array.from(array)
|
||||
.map((b) =>
|
||||
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'.charAt(
|
||||
b % 62,
|
||||
),
|
||||
)
|
||||
.join('');
|
||||
}
|
||||
|
||||
function base64Encode(str: string): string {
|
||||
return Buffer.from(str).toString('base64');
|
||||
}
|
||||
|
||||
async function exchangeCodeForToken(
|
||||
code: string,
|
||||
config: SpotifyConfig,
|
||||
): Promise<{
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_in: number;
|
||||
}> {
|
||||
const tokenUrl = 'https://accounts.spotify.com/api/token';
|
||||
const authHeader = `Basic ${base64Encode(`${config.clientId}:${config.clientSecret}`)}`;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.append('grant_type', 'authorization_code');
|
||||
params.append('code', code);
|
||||
params.append('redirect_uri', config.redirectUri);
|
||||
|
||||
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 exchange code for token: ${errorData}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
export async function authorizeSpotify(): Promise<void> {
|
||||
const config = loadSpotifyConfig();
|
||||
|
||||
const redirectUri = new URL(config.redirectUri);
|
||||
if (
|
||||
redirectUri.hostname !== 'localhost' &&
|
||||
redirectUri.hostname !== '127.0.0.1'
|
||||
) {
|
||||
console.error(
|
||||
'Error: Redirect URI must use localhost for automatic token exchange',
|
||||
);
|
||||
console.error(
|
||||
'Please update your spotify-config.json with a localhost redirect URI',
|
||||
);
|
||||
console.error('Example: http://127.0.0.1:8888/callback');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const port = redirectUri.port || '80';
|
||||
const callbackPath = redirectUri.pathname || '/callback';
|
||||
|
||||
const state = generateRandomString(16);
|
||||
|
||||
const scopes = [
|
||||
'user-read-private',
|
||||
'user-read-email',
|
||||
'user-read-playback-state',
|
||||
'user-modify-playback-state',
|
||||
'user-read-currently-playing',
|
||||
'playlist-read-private',
|
||||
'playlist-read-collaborative',
|
||||
'playlist-modify-private',
|
||||
'playlist-modify-public',
|
||||
'user-library-read',
|
||||
'user-library-modify',
|
||||
'user-read-recently-played',
|
||||
];
|
||||
|
||||
const authParams = new URLSearchParams({
|
||||
client_id: config.clientId,
|
||||
response_type: 'code',
|
||||
redirect_uri: config.redirectUri,
|
||||
scope: scopes.join(' '),
|
||||
state: state,
|
||||
show_dialog: 'true',
|
||||
});
|
||||
|
||||
const authorizationUrl = `https://accounts.spotify.com/authorize?${authParams.toString()}`;
|
||||
|
||||
const authPromise = new Promise<void>((resolve, reject) => {
|
||||
// Create HTTP server to handle the callback
|
||||
const server = http.createServer(async (req, res) => {
|
||||
if (!req.url) {
|
||||
return res.end('No URL provided');
|
||||
}
|
||||
|
||||
const reqUrl = new URL(req.url, `http://localhost:${port}`);
|
||||
|
||||
if (reqUrl.pathname === callbackPath) {
|
||||
const code = reqUrl.searchParams.get('code');
|
||||
const returnedState = reqUrl.searchParams.get('state');
|
||||
const error = reqUrl.searchParams.get('error');
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
|
||||
if (error) {
|
||||
console.error(`Authorization error: ${error}`);
|
||||
res.end(
|
||||
'<html><body><h1>Authentication Failed</h1><p>Please close this window and try again.</p></body></html>',
|
||||
);
|
||||
server.close();
|
||||
reject(new Error(`Authorization failed: ${error}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (returnedState !== state) {
|
||||
console.error('State mismatch error');
|
||||
res.end(
|
||||
'<html><body><h1>Authentication Failed</h1><p>State verification failed. Please close this window and try again.</p></body></html>',
|
||||
);
|
||||
server.close();
|
||||
reject(new Error('State mismatch'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
console.error('No authorization code received');
|
||||
res.end(
|
||||
'<html><body><h1>Authentication Failed</h1><p>No authorization code received. Please close this window and try again.</p></body></html>',
|
||||
);
|
||||
server.close();
|
||||
reject(new Error('No authorization code received'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const tokens = await exchangeCodeForToken(code, config);
|
||||
|
||||
config.accessToken = tokens.access_token;
|
||||
config.refreshToken = tokens.refresh_token;
|
||||
config.expiresAt = Date.now() + tokens.expires_in * 1000; // Convert seconds to milliseconds
|
||||
saveSpotifyConfig(config);
|
||||
|
||||
res.end(
|
||||
'<html><body><h1>Authentication Successful!</h1><p>You can now close this window and return to the application.</p></body></html>',
|
||||
);
|
||||
console.log(
|
||||
'Authentication successful! Access token has been saved.',
|
||||
);
|
||||
|
||||
server.close();
|
||||
resolve();
|
||||
} catch (error) {
|
||||
console.error('Token exchange error:', error);
|
||||
res.end(
|
||||
'<html><body><h1>Authentication Failed</h1><p>Failed to exchange authorization code for tokens. Please close this window and try again.</p></body></html>',
|
||||
);
|
||||
server.close();
|
||||
reject(error);
|
||||
}
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(Number.parseInt(port), '127.0.0.1', () => {
|
||||
console.log(
|
||||
`Listening for Spotify authentication callback on port ${port}`,
|
||||
);
|
||||
console.log('Opening browser for authorization...');
|
||||
|
||||
open(authorizationUrl).catch((_error: Error) => {
|
||||
console.log(
|
||||
'Failed to open browser automatically. Please visit this URL to authorize:',
|
||||
);
|
||||
console.log(authorizationUrl);
|
||||
});
|
||||
});
|
||||
|
||||
server.on('error', (error) => {
|
||||
console.error(`Server error: ${error.message}`);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
|
||||
await authPromise;
|
||||
}
|
||||
|
||||
export function formatDuration(ms: number): string {
|
||||
const minutes = Math.floor(ms / 60000);
|
||||
const seconds = ((ms % 60000) / 1000).toFixed(0);
|
||||
return `${minutes}:${seconds.padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export async function handleSpotifyRequest<T>(
|
||||
action: (spotifyApi: SpotifyApi) => Promise<T>,
|
||||
): Promise<T> {
|
||||
try {
|
||||
const spotifyApi = await createSpotifyApi();
|
||||
return await action(spotifyApi);
|
||||
} catch (error) {
|
||||
// Skip JSON parsing errors as these are actually successful operations
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
if (
|
||||
errorMessage.includes('Unexpected token') ||
|
||||
errorMessage.includes('Unexpected non-whitespace character') ||
|
||||
errorMessage.includes('Exponent part is missing a number in JSON')
|
||||
) {
|
||||
return undefined as T;
|
||||
}
|
||||
// Rethrow other errors
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"outDir": "./build",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// Simple MCP Server Tool Verification
|
||||
import { spawn } from 'child_process';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname } from 'path';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
console.log('='.repeat(60));
|
||||
console.log('SPOTIFY MCP SERVER TOOL VERIFICATION');
|
||||
console.log('='.repeat(60) + '\n');
|
||||
|
||||
// Start server
|
||||
let server = spawn('node', ['build/index.js'], {
|
||||
cwd: __dirname,
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
const pendingRequests = new Map();
|
||||
let requestIdCounter = 1;
|
||||
|
||||
// Track results
|
||||
const results = [];
|
||||
|
||||
server.stdout.on('data', (data) => {
|
||||
const msg = data.toString();
|
||||
|
||||
// Parse each line as JSON response
|
||||
for (const line of msg.split('\n')) {
|
||||
if (!line.trim()) continue;
|
||||
|
||||
try {
|
||||
const response = JSON.parse(line);
|
||||
const id = response.id;
|
||||
|
||||
// Handle initialization response
|
||||
if (id === 1) {
|
||||
if (response.result?.protocolVersion) {
|
||||
console.log('✅ MCP Server Initialized');
|
||||
console.log(` Protocol: ${response.result.protocolVersion}`);
|
||||
console.log('');
|
||||
|
||||
// Start tests after a delay
|
||||
setTimeout(runTests, 300);
|
||||
} else if (response.error) {
|
||||
console.log('❌ Initialization failed:', response.error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle tool responses
|
||||
if (pendingRequests.has(id)) {
|
||||
const req = pendingRequests.get(id);
|
||||
|
||||
if (response.error) {
|
||||
console.log(`❌ ${req.tool}: ERROR`);
|
||||
results.push({ name: req.tool, success: false });
|
||||
req.resolve();
|
||||
} else if (response.result?.content) {
|
||||
const text = response.result.content[0]?.text || '';
|
||||
console.log(`✅ ${req.tool}: OK`);
|
||||
results.push({ name: req.tool, success: true });
|
||||
req.resolve();
|
||||
} else {
|
||||
console.log(`⚠️ ${req.tool}: Unknown response`);
|
||||
results.push({ name: req.tool, success: false });
|
||||
req.resolve();
|
||||
}
|
||||
|
||||
pendingRequests.delete(id);
|
||||
}
|
||||
} catch (e) {
|
||||
// Not JSON or parse error - skip
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
server.stderr.on('data', () => {});
|
||||
|
||||
// Send MCP request helper
|
||||
function sendRequest(toolName, args) {
|
||||
return new Promise(resolve => {
|
||||
const id = requestIdCounter++;
|
||||
pendingRequests.set(id, { tool: toolName, resolve });
|
||||
|
||||
const request = JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: id,
|
||||
method: "tools/call",
|
||||
params: { name: toolName, arguments: args }
|
||||
}) + '\n';
|
||||
|
||||
server.stdin.write(request);
|
||||
});
|
||||
}
|
||||
|
||||
// Send initialization request after a short delay to ensure stdin is ready
|
||||
setTimeout(() => {
|
||||
const initRequest = JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "initialize",
|
||||
params: {
|
||||
protocolVersion: "2024-11-05",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "verify-tools", version: "1.0" }
|
||||
}
|
||||
}) + '\n';
|
||||
|
||||
server.stdin.write(initRequest);
|
||||
}, 100);
|
||||
|
||||
// Run all tests
|
||||
async function runTests() {
|
||||
console.log('Testing MCP Server Tools...\n');
|
||||
console.log('-'.repeat(60));
|
||||
|
||||
// ===== READ OPERATIONS =====
|
||||
await sendRequest('searchSpotify', { query: 'rock', type: 'track', limit: 1 });
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
|
||||
await sendRequest('getNowPlaying', {});
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
|
||||
await sendRequest('getMyPlaylists', { limit: 2 });
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
|
||||
await sendRequest('getQueue', {});
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
|
||||
await sendRequest('getAvailableDevices', {});
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
|
||||
// ===== PLAYBACK CONTROL =====
|
||||
await sendRequest('pausePlayback', {});
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
|
||||
await sendRequest('resumePlayback', {});
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
|
||||
// ===== SEARCH VARIATIONS =====
|
||||
await sendRequest('searchSpotify', { query: 'beatles', type: 'artist', limit: 1 });
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
|
||||
// Print summary and exit after delay
|
||||
setTimeout(() => {
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log('VERIFICATION COMPLETE');
|
||||
console.log('='.repeat(60) + '\n');
|
||||
|
||||
const successCount = results.filter(r => r.success).length;
|
||||
const failCount = results.length - successCount;
|
||||
|
||||
console.log(`Total Tests: ${results.length}`);
|
||||
console.log(`✅ Passed: ${successCount}`);
|
||||
console.log(`❌ Failed: ${failCount}\n`);
|
||||
|
||||
if (server) server.kill();
|
||||
|
||||
console.log('\nTools Verified:');
|
||||
console.log('- searchSpotify (search tracks, artists)');
|
||||
console.log('- getNowPlaying (current playback state)');
|
||||
console.log('- getMyPlaylists (user playlists)');
|
||||
console.log('- getQueue (playback queue)');
|
||||
console.log('- getAvailableDevices (Spotify Connect devices)');
|
||||
console.log('- pausePlayback / resumePlayback');
|
||||
|
||||
process.exit(0);
|
||||
}, 2000);
|
||||
}
|
||||
Reference in new issue
Block a user