Reorganize community environments - Move lean_proof_env, router_env, and philosophical_rlaif_env.py to environments/community/ - Add comprehensive README for community environments - This organizes community-contributed environments into a dedicated community folder for better maintainability and discoverability

This commit is contained in:
Shannon Sands 2025-05-23 13:31:13 +10:00
parent 945ea30c3a
commit e85a170c34
53 changed files with 85 additions and 0 deletions

View file

@ -0,0 +1,272 @@
<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)
- [Play / Create Operations](#play--create-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"_
## 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
- **Parameters**: None
- **Returns**: Object containing track name, artist, album, playback progress, duration, and playback state
- **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. **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 })`
### 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. **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()`
4. **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()`
5. **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 })`
6. **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"] })`
7. **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" })`
## 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://localhost: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://localhost: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
}
```
7. The server will automatically refresh the access token when needed, using the refresh token.
## 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.

View file

@ -0,0 +1,134 @@
{
"$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", // Not in recommended ruleset, turning on manually
"noForEach": "off", // forEach is too familiar to ban
"noUselessSwitchCase": "off", // Turned off due to developer preferences
"noUselessThisAlias": "off" // Turned off due to developer preferences
},
"correctness": {
"noUnusedImports": "warn", // Not in recommended ruleset, turning on manually
"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
},
"security": {
"noDangerouslySetInnerHtml": "off" // Covered by Conformance
},
"style": {
"useFragmentSyntax": "warn", // Not in recommended ruleset, turning on manually
"noYodaExpression": "warn", // Not in recommended ruleset, turning on manually
"useDefaultParameterLast": "warn", // Not in recommended ruleset, turning on manually
"useExponentiationOperator": "off", // Obscure and arguably not easily readable
"noUnusedTemplateLiteral": "off", // Stylistic opinion
"noUselessElse": "off" // Stylistic opinion
},
"suspicious": {
"noExplicitAny": "off" // We trust Vercelians to use any only when necessary
},
"nursery": {
"noStaticElementInteractions": "warn",
"noHeadImportInDocument": "warn",
"noDocumentImportInPage": "warn",
"noDuplicateElseIf": "warn",
"noIrregularWhitespace": "warn",
"useValidAutocomplete": "warn"
}
}
},
"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": false },
"overrides": [
// Playwright requires an object destructure, even if empty
// https://github.com/microsoft/playwright/issues/30007
{
"include": ["playwright/**"],
"linter": {
"rules": {
"correctness": {
"noEmptyPattern": "off"
}
}
}
}
]
}

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);
});

View file

@ -0,0 +1,23 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { playTools } from './play.js';
import { readTools } from './read.js';
const server = new McpServer({
name: 'spotify-controller',
version: '1.0.0',
});
[...readTools, ...playTools].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);
});

View file

@ -0,0 +1,371 @@
import { handleSpotifyRequest } from './utils.js';
import { z } from 'zod';
import type { SpotifyHandlerExtra, tool } from './types.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',
isError: true,
},
],
};
}
let spotifyUri = uri;
if (!spotifyUri && type && id) {
spotifyUri = `spotify:${type}:${id}`;
}
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})` : ''}`,
},
],
};
},
};
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;
await handleSpotifyRequest(async (spotifyApi) => {
await spotifyApi.player.pausePlayback(deviceId || '');
});
return {
content: [
{
type: 'text',
text: 'Playback paused',
},
],
};
},
};
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;
await handleSpotifyRequest(async (spotifyApi) => {
await spotifyApi.player.skipToNext(deviceId || '');
});
return {
content: [
{
type: 'text',
text: 'Skipped to next track',
},
],
};
},
};
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;
await handleSpotifyRequest(async (spotifyApi) => {
await spotifyApi.player.skipToPrevious(deviceId || '');
});
return {
content: [
{
type: 'text',
text: 'Skipped to previous track',
},
],
};
},
};
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;
const result = await handleSpotifyRequest(async (spotifyApi) => {
const me = await spotifyApi.currentUser.profile();
return await spotifyApi.playlists.createPlaylist(me.id, {
name,
description,
public: isPublic,
});
});
return {
content: [
{
type: 'text',
text: `Successfully created playlist "${name}"\nPlaylist ID: ${result.id}`,
},
],
};
},
};
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}`);
await handleSpotifyRequest(async (spotifyApi) => {
await spotifyApi.playlists.addItemsToPlaylist(
playlistId,
trackUris,
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;
await handleSpotifyRequest(async (spotifyApi) => {
await spotifyApi.player.startResumePlayback(deviceId || '');
});
return {
content: [
{
type: 'text',
text: 'Playback resumed',
},
],
};
},
};
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',
isError: true,
},
],
};
}
await handleSpotifyRequest(async (spotifyApi) => {
await spotifyApi.player.addItemToPlaybackQueue(
spotifyUri,
deviceId || '',
);
});
return {
content: [
{
type: 'text',
text: `Added item ${spotifyUri} to queue`,
},
],
};
},
};
export const playTools = [
playMusic,
pausePlayback,
skipToNext,
skipToPrevious,
createPlaylist,
addTracksToPlaylist,
resumePlayback,
addToQueue,
];

View file

@ -0,0 +1,366 @@
import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js';
import type { MaxInt } from '@spotify/web-api-ts-sdk';
import { z } from 'zod';
import type { SpotifyHandlerExtra, SpotifyTrack, tool } from './types.js';
import { formatDuration, handleSpotifyRequest } 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',
schema: {},
handler: async (args, extra: SpotifyHandlerExtra) => {
try {
const currentTrack = await handleSpotifyRequest(async (spotifyApi) => {
return await spotifyApi.player.getCurrentlyPlayingTrack();
});
if (!currentTrack || !currentTrack.item) {
return {
content: [
{
type: 'text',
text: 'Nothing is currently playing on Spotify',
},
],
};
}
const item = currentTrack.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(currentTrack.progress_ms || 0);
const isPlaying = currentTrack.is_playing;
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}`,
},
],
};
} 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;
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}`,
},
],
};
},
};
const getPlaylistTracks: tool<{
playlistId: z.ZodString;
limit: 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)'),
},
handler: async (args, extra: SpotifyHandlerExtra) => {
const { playlistId, limit = 50 } = args;
const playlistTracks = await handleSpotifyRequest(async (spotifyApi) => {
return await spotifyApi.playlists.getPlaylistItems(
playlistId,
undefined,
undefined,
limit as MaxInt<50>,
);
});
if ((playlistTracks.items?.length ?? 0) === 0) {
return {
content: [
{
type: 'text',
text: "This playlist doesn't have any tracks",
},
],
};
}
const formattedTracks = playlistTracks.items
.map((item, i) => {
const { track } = item;
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);
return `${i + 1}. "${track.name}" by ${artists} (${duration}) - ID: ${track.id}`;
}
return `${i + 1}. Unknown item`;
})
.join('\n');
return {
content: [
{
type: 'text',
text: `# Tracks in Playlist\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;
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);
return `${i + 1}. "${track.name}" by ${artists} (${duration}) - ID: ${track.id}`;
}
return `${i + 1}. Unknown item`;
})
.join('\n');
return {
content: [
{
type: 'text',
text: `# Recently Played Tracks\n\n${formattedHistory}`,
},
],
};
}
}
export const readTools = [
searchSpotify,
getNowPlaying,
getMyPlaylists,
getPlaylistTracks,
getRecentlyPlayed,
];

View file

@ -0,0 +1,47 @@
import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js';
import { 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;
}

View file

@ -0,0 +1,301 @@
import { SpotifyApi } from '@spotify/web-api-ts-sdk';
import crypto from 'node:crypto';
import fs from 'node:fs';
import http from 'node:http';
import path from 'node:path';
import { fileURLToPath, URL } from 'node:url';
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;
}
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;
export function createSpotifyApi(): SpotifyApi {
if (cachedSpotifyApi) {
return cachedSpotifyApi;
}
const config = loadSpotifyConfig();
if (config.accessToken && config.refreshToken) {
const accessToken = {
access_token: config.accessToken,
token_type: 'Bearer',
expires_in: 3600 * 24 * 30, // Default to 1 month
refresh_token: config.refreshToken,
};
cachedSpotifyApi = SpotifyApi.withAccessToken(config.clientId, accessToken);
return cachedSpotifyApi;
}
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 }> {
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,
};
}
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-modify-private',
'playlist-modify-public',
'user-library-read',
'user-library-modify',
'user-read-recently-played',
'user-modify-playback-state',
'user-read-playback-state',
'user-read-currently-playing'
];
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;
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 = 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;
}
}