This commit is contained in:
Javis committed 2026-10-04 15:31:44 +02:00
1 parent 0246cbb6ba
commit 0ca4b4bf7a
89 files changed
+7042 -2480

No files matched your search

-10
View File
@@ -1,10 +0,0 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "daily"
groups:
dependencies:
patterns:
- "*"
-37
View File
@@ -1,37 +0,0 @@
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
@@ -372,6 +372,14 @@ npm run auth
**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.
- Tokens are refreshed one minute before they expire, at most once even when several tool calls arrive together, and a rotated `refresh_token` returned by Spotify is saved.
- Spotify refresh tokens expire after **6 months**; the server then reports "refresh token expired or revoked — run `npm run auth`".
- `spotify-config.json` is rewritten atomically with owner-only permissions (0600 on Linux/macOS).
**Errors:** failed tool calls are returned with `isError: true` (MCP tool error), so
clients don't treat "Error …" texts as successful results. Every tool carries MCP
annotations (`readOnlyHint` for lookups, `destructiveHint` for removals and
playlist edits).
## Integrating with Claude Desktop, Cursor, and VsCode [Via Cline model extension](https://marketplace.visualstudio.com/items/?itemName=saoudrizwan.claude-dev)
+83 -5
View File
@@ -1,20 +1,98 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import type {
CallToolResult,
ToolAnnotations,
} from '@modelcontextprotocol/sdk/types.js';
import { albumTools } from './albums.js';
import { playTools } from './play.js';
import { playlistTools } from './playlist.js';
import { readTools } from './read.js';
import type { SpotifyHandlerExtra, tool } from './types.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);
},
);
const READ: ToolAnnotations = { readOnlyHint: true, openWorldHint: true };
const CONTROL: ToolAnnotations = {
readOnlyHint: false,
destructiveHint: false,
openWorldHint: true,
};
const REMOVE: ToolAnnotations = {
readOnlyHint: false,
destructiveHint: true,
openWorldHint: true,
};
const ANNOTATIONS: Record<string, ToolAnnotations> = {
searchSpotify: READ,
getNowPlaying: READ,
getMyPlaylists: READ,
getPlaylistTracks: READ,
getRecentlyPlayed: READ,
getUsersSavedTracks: READ,
getQueue: READ,
getAvailableDevices: READ,
getPlaylist: READ,
getAlbums: READ,
getAlbumTracks: READ,
checkUsersSavedAlbums: READ,
removeUsersSavedTracks: REMOVE,
removeTracksFromPlaylist: REMOVE,
reorderPlaylistItems: REMOVE,
saveOrRemoveAlbumForUser: REMOVE,
updatePlaylist: REMOVE,
};
/**
* Handlers report failures as text starting with "Error"; flag those (and
* thrown errors) with isError so clients don't treat them as success.
*/
async function runTool(
t: tool<any>,
args: unknown,
extra: SpotifyHandlerExtra,
): Promise<CallToolResult> {
try {
const result = await t.handler(args as never, extra);
const first = result.content[0]?.text ?? '';
return /^Error\b/.test(first) ? { ...result, isError: true } : result;
} catch (error) {
return {
content: [
{
type: 'text',
text: `Error in ${t.name}: ${
error instanceof Error ? error.message : String(error)
}`,
},
],
isError: true,
};
}
}
const allTools: tool<any>[] = [
...readTools,
...playTools,
...albumTools,
...playlistTools,
];
for (const t of allTools) {
server.registerTool(
t.name,
{
description: t.description,
inputSchema: t.schema,
annotations: ANNOTATIONS[t.name] ?? CONTROL,
},
((args: unknown, extra: SpotifyHandlerExtra) =>
runTool(t, args, extra)) as any,
);
}
async function main() {
const transport = new StdioServerTransport();
+21 -10
View File
@@ -1,6 +1,6 @@
import { z } from 'zod';
import type { SpotifyHandlerExtra, tool } from './types.js';
import { handleSpotifyRequest, spotifyFetch } from './utils.js';
import { handleSpotifyRequest, seg, spotifyFetch } from './utils.js';
const playMusic: tool<{
uri: z.ZodOptional<z.ZodString>;
@@ -43,6 +43,9 @@ const playMusic: tool<{
if (!spotifyUri && type && id) {
spotifyUri = `spotify:${type}:${id}`;
}
// A bare `uri` has no `type`: tracks/episodes must go in `uris`, while
// albums/artists/playlists are a `context_uri` (Spotify rejects the mix-up).
const kind = type ?? spotifyUri?.split(':')[1];
try {
await handleSpotifyRequest(async (spotifyApi) => {
@@ -53,7 +56,7 @@ const playMusic: tool<{
return;
}
if (type === 'track') {
if (kind === 'track' || kind === 'episode') {
await spotifyApi.player.startResumePlayback(device, undefined, [
spotifyUri,
]);
@@ -66,7 +69,7 @@ const playMusic: tool<{
content: [
{
type: 'text',
text: `Started playing ${type || 'music'} ${id ? `(ID: ${id})` : ''}`,
text: `Started playing ${kind || 'music'} ${id ? `(ID: ${id})` : spotifyUri ? `(${spotifyUri})` : ''}`,
},
],
};
@@ -226,7 +229,7 @@ const createPlaylist: tool<{
const playlist = await spotifyFetch<{
id: string;
external_urls: { spotify: string };
}>(`users/${me.id}/playlists`, {
}>(`users/${seg(me.id)}/playlists`, {
method: 'POST',
body: {
name,
@@ -292,7 +295,7 @@ const addTracksToPlaylist: tool<{
const trackUris = trackIds.map((id) => `spotify:track:${id}`);
// Hit /items directly: see spotifyFetch JSDoc for context.
await spotifyFetch(`playlists/${playlistId}/items`, {
await spotifyFetch(`playlists/${seg(playlistId)}/items`, {
method: 'POST',
body: {
uris: trackUris,
@@ -511,29 +514,37 @@ const adjustVolume: tool<{
const { adjustment, deviceId } = args;
try {
// First get the current playback state to find current volume
// Read the volume of the device we are about to change, not just the
// currently active one.
const playback = await handleSpotifyRequest(async (spotifyApi) => {
return await spotifyApi.player.getPlaybackState();
});
let device: typeof playback.device | undefined = playback?.device;
if (deviceId && device?.id !== deviceId) {
const available = await handleSpotifyRequest(async (spotifyApi) => {
return await spotifyApi.player.getAvailableDevices();
});
device = available?.devices?.find((d) => d.id === deviceId);
}
if (!playback?.device) {
if (!device) {
return {
content: [
{
type: 'text',
text: 'No active device found. Make sure Spotify is open and playing on a device.',
text: 'Error: No active device found. Make sure Spotify is open and playing on a device.',
},
],
};
}
const currentVolume = playback.device.volume_percent;
const currentVolume = device.volume_percent;
if (currentVolume === null || currentVolume === undefined) {
return {
content: [
{
type: 'text',
text: 'Unable to get current volume from device.',
text: 'Error: Unable to get current volume from device.',
},
],
};
+3 -3
View File
@@ -1,6 +1,6 @@
import { z } from 'zod';
import type { SpotifyHandlerExtra, tool } from './types.js';
import { handleSpotifyRequest, spotifyFetch } from './utils.js';
import { handleSpotifyRequest, seg, spotifyFetch } from './utils.js';
const getPlaylist: tool<{
playlistId: z.ZodString;
@@ -177,7 +177,7 @@ const removeTracksFromPlaylist: tool<{
// Hit /items directly: SDK targets the deprecated /tracks endpoint
// (see spotifyFetch JSDoc for context on the March 2026 migration).
await spotifyFetch(`playlists/${playlistId}/items`, {
await spotifyFetch(`playlists/${seg(playlistId)}/items`, {
method: 'DELETE',
body: {
items,
@@ -250,7 +250,7 @@ const reorderPlaylistItems: tool<{
try {
// Hit /items directly: see spotifyFetch JSDoc for context.
await spotifyFetch(`playlists/${playlistId}/items`, {
await spotifyFetch(`playlists/${seg(playlistId)}/items`, {
method: 'PUT',
body: {
range_start: rangeStart,
+9 -6
View File
@@ -4,6 +4,7 @@ import type { SpotifyHandlerExtra, SpotifyTrack, tool } from './types.js';
import {
formatDuration,
handleSpotifyRequest,
seg,
spotifyFetch,
} from './utils.js';
@@ -80,9 +81,11 @@ const searchSpotify: tool<{
} 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: ${
// Search results carry `tracks` (or `items`) totals at runtime.
const total = (playlist as any)?.tracks?.total ?? (playlist as any)?.items?.total;
return `${i + 1}. "${playlist?.name ?? 'Unknown Playlist'}"${
total !== undefined ? ` (${total} tracks)` : ''
} by ${playlist?.owner?.display_name ?? 'Unknown'} - ID: ${
playlist?.id
}`;
})
@@ -298,7 +301,7 @@ const getPlaylistTracks: tool<{
const playlistTracks = await spotifyFetch<{
items: PlaylistItemEntry[];
total: number;
}>(`playlists/${playlistId}/items`, {
}>(`playlists/${seg(playlistId)}/items`, {
query: { limit, offset },
});
@@ -459,7 +462,7 @@ const getUsersSavedTracks: tool<{
const formattedTracks = savedTracks.items
.map((item, i) => {
const track = item.track;
if (!track) return `${i + 1}. [Removed track]`;
if (!track) return `${offset + i + 1}. [Removed track]`;
if (isTrack(track)) {
const artists = track.artists.map((a) => a.name).join(', ');
@@ -468,7 +471,7 @@ const getUsersSavedTracks: tool<{
return `${offset + i + 1}. "${track.name}" by ${artists} (${duration}) - ID: ${track.id} - Added: ${addedDate}`;
}
return `${i + 1}. Unknown item`;
return `${offset + i + 1}. Unknown item`;
})
.join('\n');
+3
View File
@@ -14,6 +14,7 @@ export type tool<Args extends z.ZodRawShape> = {
name: string;
description: string;
schema: Args;
// Failure messages start with "Error": index.ts turns them into isError results.
handler: (
args: z.infer<z.ZodObject<Args>>,
extra: SpotifyHandlerExtra,
@@ -23,12 +24,14 @@ export type tool<Args extends z.ZodRawShape> = {
type: 'text';
text: string;
}>;
isError?: boolean;
}>
| {
content: Array<{
type: 'text';
text: string;
}>;
isError?: boolean;
};
};
+72 -42
View File
@@ -43,11 +43,57 @@ export function loadSpotifyConfig(): SpotifyConfig {
}
export function saveSpotifyConfig(config: SpotifyConfig): void {
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8');
// Holds the client secret and refresh token: owner-only, written atomically.
const tmp = `${CONFIG_FILE}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(config, null, 2), {
encoding: 'utf8',
mode: 0o600,
});
fs.renameSync(tmp, CONFIG_FILE);
try {
fs.chmodSync(CONFIG_FILE, 0o600);
} catch {
// Not supported on every filesystem (e.g. FAT); best effort.
}
}
/** One URL path segment (IDs come from the LLM). */
export function seg(value: string): string {
return encodeURIComponent(value);
}
let cachedSpotifyApi: SpotifyApi | null = null;
// Refresh slightly early so a token can't expire mid-request.
const EXPIRY_SKEW_MS = 60_000;
let refreshInFlight: Promise<SpotifyConfig> | null = null;
/**
* Return *config* with a valid access token, refreshing it at most once even
* when several tool calls arrive together.
*/
async function ensureFreshToken(config: SpotifyConfig): Promise<SpotifyConfig> {
if (!(config.accessToken && config.refreshToken)) return config;
if (config.expiresAt && config.expiresAt - EXPIRY_SKEW_MS > Date.now()) {
return config;
}
if (!refreshInFlight) {
refreshInFlight = (async () => {
const tokens = await refreshAccessToken(config);
config.accessToken = tokens.access_token;
// Spotify may rotate the refresh token; the old one can stop working.
if (tokens.refresh_token) config.refreshToken = tokens.refresh_token;
config.expiresAt = Date.now() + tokens.expires_in * 1000;
saveSpotifyConfig(config);
cachedSpotifyApi = null;
return config;
})().finally(() => {
refreshInFlight = null;
});
}
return refreshInFlight;
}
/**
* Direct Spotify Web API fetch helper.
* Used to bypass @spotify/web-api-ts-sdk methods that hit deprecated endpoints
@@ -65,19 +111,7 @@ export async function spotifyFetch<T = unknown>(
} = {},
): 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;
}
}
const config = await ensureFreshToken(loadSpotifyConfig());
if (!config.accessToken) {
throw new Error(
@@ -120,31 +154,19 @@ export async function spotifyFetch<T = unknown>(
}
export async function createSpotifyApi(): Promise<SpotifyApi> {
const config = loadSpotifyConfig();
let 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.',
);
}
try {
config = await ensureFreshToken(config);
} catch (error) {
console.error('Failed to refresh token:', error);
throw error instanceof Error && error.message.includes('npm run auth')
? error
: new Error(
'Failed to refresh access token. Please run "npm run auth" to re-authenticate.',
);
}
if (cachedSpotifyApi) {
@@ -152,12 +174,12 @@ export async function createSpotifyApi(): Promise<SpotifyApi> {
}
const accessToken = {
access_token: config.accessToken,
access_token: config.accessToken as string,
token_type: 'Bearer',
expires_in: Math.floor(
((config.expiresAt ?? now + 3600000) - now) / 1000,
),
refresh_token: config.refreshToken,
refresh_token: config.refreshToken as string,
};
cachedSpotifyApi = SpotifyApi.withAccessToken(config.clientId, accessToken);
@@ -229,7 +251,7 @@ async function exchangeCodeForToken(
async function refreshAccessToken(
config: SpotifyConfig,
): Promise<{ access_token: string; expires_in: number }> {
): Promise<{ access_token: string; expires_in: number; refresh_token?: string }> {
if (!config.refreshToken) {
throw new Error('No refresh token available');
}
@@ -252,6 +274,12 @@ async function refreshAccessToken(
if (!response.ok) {
const errorData = await response.text();
if (errorData.includes('invalid_grant')) {
// Refresh tokens expire after 6 months or when access is revoked.
throw new Error(
'Spotify refresh token expired or revoked. Run "npm run auth" to sign in again.',
);
}
throw new Error(`Failed to refresh access token: ${errorData}`);
}
@@ -259,6 +287,7 @@ async function refreshAccessToken(
return {
access_token: data.access_token,
expires_in: data.expires_in || 3600,
refresh_token: data.refresh_token,
};
}
@@ -412,9 +441,10 @@ export async function authorizeSpotify(): Promise<void> {
}
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')}`;
const totalSeconds = Math.round(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${String(seconds).padStart(2, '0')}`;
}
export async function handleSpotifyRequest<T>(
+1 -1
View File
@@ -53,7 +53,7 @@ server.stdout.on('data', (data) => {
if (pendingRequests.has(id)) {
const req = pendingRequests.get(id);
if (response.error) {
if (response.error || response.result?.isError) {
console.log(`❌ ${req.tool}: ERROR`);
results.push({ name: req.tool, success: false });
req.resolve();