Initial commit: Javis project

This commit is contained in:
Javis committed 2026-09-21 21:44:00 +02:00
commit 435c645f36
164 files changed
+35279

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "daily"
groups:
dependencies:
patterns:
- "*"
+37
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
node_modules
.vscode
.env
package-lock.json
spotify-config.json
build
dist
.DS_Store
+411
View File
@@ -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.
+149
View File
@@ -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"
}
}
}
}
]
}
+31
View File
@@ -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"
}
+304
View File
@@ -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,
];
+14
View File
@@ -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);
});
+27
View File
@@ -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);
});
+586
View File
@@ -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,
];
+294
View File
@@ -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,
];
+766
View File
@@ -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,
];
+53
View File
@@ -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;
}
+439
View File
@@ -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;
}
}
+15
View File
@@ -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"]
}
+171
View File
@@ -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);
}