import fetch from "node-fetch";
import { createHash, randomBytes } from "crypto";
import { URLSearchParams } from "url";
import open from "open";
import { createServer } from "http";
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
interface SpotifyTokens {
access_token: string;
refresh_token: string;
expires_at: number;
}
interface SpotifyConfig {
client_id: string;
client_secret: string;
}
export class SpotifyClient {
private config: SpotifyConfig | null = null;
private tokens: SpotifyTokens | null = null;
private readonly redirectUri = "http://127.0.0.1:3000/callback";
async authenticate(clientId: string, clientSecret: string): Promise<CallToolResult> {
this.config = { client_id: clientId, client_secret: clientSecret };
const codeVerifier = this.generateCodeVerifier();
const codeChallenge = this.generateCodeChallenge(codeVerifier);
const state = randomBytes(16).toString("hex");
const authUrl = new URL("https://accounts.spotify.com/authorize");
authUrl.searchParams.append("response_type", "code");
authUrl.searchParams.append("client_id", clientId);
authUrl.searchParams.append("scope", "user-read-private user-read-email playlist-modify-public playlist-modify-private");
authUrl.searchParams.append("redirect_uri", this.redirectUri);
authUrl.searchParams.append("state", state);
authUrl.searchParams.append("code_challenge_method", "S256");
authUrl.searchParams.append("code_challenge", codeChallenge);
return new Promise((resolve, reject) => {
const server = createServer((req, res) => {
const url = new URL(req.url!, `http://${req.headers.host}`);
if (url.pathname === "/callback") {
const code = url.searchParams.get("code");
const returnedState = url.searchParams.get("state");
const error = url.searchParams.get("error");
res.writeHead(200, { "Content-Type": "text/html" });
if (error) {
res.end(`<h1>Error: ${error}</h1><p>You can close this window.</p>`);
server.close();
reject(new Error(`Authentication failed: ${error}`));
return;
}
if (returnedState !== state) {
res.end("<h1>Error: Invalid state parameter</h1><p>You can close this window.</p>");
server.close();
reject(new Error("Invalid state parameter"));
return;
}
if (!code) {
res.end("<h1>Error: No authorization code received</h1><p>You can close this window.</p>");
server.close();
reject(new Error("No authorization code received"));
return;
}
res.end("<h1>Success!</h1><p>You can close this window and return to your application.</p>");
server.close();
this.exchangeCodeForTokens(code, codeVerifier)
.then((result) => resolve(result))
.catch(reject);
}
});
server.listen(3000, () => {
console.log("Opening browser for Spotify authentication...");
open(authUrl.toString());
});
server.on("error", (err) => {
reject(new Error(`Server error: ${err.message}`));
});
});
}
private async exchangeCodeForTokens(code: string, codeVerifier: string): Promise<CallToolResult> {
if (!this.config) {
throw new Error("Not configured. Call authenticate() first.");
}
const params = new URLSearchParams();
params.append("grant_type", "authorization_code");
params.append("code", code);
params.append("redirect_uri", this.redirectUri);
params.append("client_id", this.config.client_id);
params.append("code_verifier", codeVerifier);
const response = await fetch("https://accounts.spotify.com/api/token", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Basic ${Buffer.from(`${this.config.client_id}:${this.config.client_secret}`).toString("base64")}`,
},
body: params,
});
if (!response.ok) {
const error = await response.json();
throw new Error(`Token exchange failed: ${JSON.stringify(error)}`);
}
const data = await response.json() as any;
this.tokens = {
access_token: data.access_token,
refresh_token: data.refresh_token,
expires_at: Date.now() + (data.expires_in * 1000),
};
return {
content: [
{
type: "text" as const,
text: "Successfully authenticated with Spotify! You can now use authenticated endpoints.",
},
],
};
}
private async refreshTokens() {
if (!this.config || !this.tokens?.refresh_token) {
throw new Error("No refresh token available. Re-authenticate first.");
}
const params = new URLSearchParams();
params.append("grant_type", "refresh_token");
params.append("refresh_token", this.tokens.refresh_token);
const response = await fetch("https://accounts.spotify.com/api/token", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Basic ${Buffer.from(`${this.config.client_id}:${this.config.client_secret}`).toString("base64")}`,
},
body: params,
});
if (!response.ok) {
const error = await response.json();
throw new Error(`Token refresh failed: ${JSON.stringify(error)}`);
}
const data = await response.json() as any;
this.tokens = {
access_token: data.access_token,
refresh_token: data.refresh_token || this.tokens.refresh_token,
expires_at: Date.now() + (data.expires_in * 1000),
};
}
private async ensureValidToken() {
if (!this.tokens) {
throw new Error("Not authenticated. Call authenticate() first.");
}
if (Date.now() >= this.tokens.expires_at - 60000) {
await this.refreshTokens();
}
}
private generateCodeVerifier(): string {
return randomBytes(32).toString("base64url");
}
private generateCodeChallenge(verifier: string): string {
return createHash("sha256").update(verifier).digest("base64url");
}
async search(query: string, type: string, limit: number = 10): Promise<CallToolResult> {
// Check if we have credentials configured for client credentials flow
const clientId = process.env.SPOTIFY_CLIENT_ID;
const clientSecret = process.env.SPOTIFY_CLIENT_SECRET;
if (!this.config && clientId && clientSecret) {
this.config = { client_id: clientId, client_secret: clientSecret };
}
const params = new URLSearchParams();
params.append("q", query);
params.append("type", type);
params.append("limit", limit.toString());
const response = await fetch(`https://api.spotify.com/v1/search?${params}`, {
headers: {
Authorization: `Bearer ${await this.getPublicAccessToken()}`,
},
});
if (!response.ok) {
throw new Error(`Search failed: ${response.status} ${response.statusText}`);
}
const data = await response.json();
return {
content: [
{
type: "text" as const,
text: JSON.stringify(data, null, 2),
},
],
};
}
private async getPublicAccessToken(): Promise<string> {
if (!this.config) {
throw new Error("Not configured. Call authenticate() first.");
}
const params = new URLSearchParams();
params.append("grant_type", "client_credentials");
const response = await fetch("https://accounts.spotify.com/api/token", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Basic ${Buffer.from(`${this.config.client_id}:${this.config.client_secret}`).toString("base64")}`,
},
body: params,
});
if (!response.ok) {
throw new Error(`Failed to get access token: ${response.status}`);
}
const data = await response.json() as any;
return data.access_token;
}
async getUserProfile(): Promise<CallToolResult> {
await this.ensureValidToken();
const response = await fetch("https://api.spotify.com/v1/me", {
headers: {
Authorization: `Bearer ${this.tokens!.access_token}`,
},
});
if (!response.ok) {
throw new Error(`Failed to get user profile: ${response.status} ${response.statusText}`);
}
const data = await response.json();
return {
content: [
{
type: "text" as const,
text: JSON.stringify(data, null, 2),
},
],
};
}
async createPlaylist(name: string, description?: string, isPublic: boolean = false): Promise<CallToolResult> {
await this.ensureValidToken();
const profile = await fetch("https://api.spotify.com/v1/me", {
headers: {
Authorization: `Bearer ${this.tokens!.access_token}`,
},
});
if (!profile.ok) {
throw new Error(`Failed to get user profile: ${profile.status}`);
}
const userData = await profile.json() as any;
const userId = userData.id;
const response = await fetch(`https://api.spotify.com/v1/users/${userId}/playlists`, {
method: "POST",
headers: {
Authorization: `Bearer ${this.tokens!.access_token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
name,
description,
public: isPublic,
}),
});
if (!response.ok) {
throw new Error(`Failed to create playlist: ${response.status} ${response.statusText}`);
}
const data = await response.json();
return {
content: [
{
type: "text" as const,
text: JSON.stringify(data, null, 2),
},
],
};
}
async addTracksToPlaylist(playlistId: string, trackUris: string[]): Promise<CallToolResult> {
await this.ensureValidToken();
const response = await fetch(`https://api.spotify.com/v1/playlists/${playlistId}/tracks`, {
method: "POST",
headers: {
Authorization: `Bearer ${this.tokens!.access_token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
uris: trackUris,
}),
});
if (!response.ok) {
throw new Error(`Failed to add tracks to playlist: ${response.status} ${response.statusText}`);
}
const data = await response.json();
return {
content: [
{
type: "text" as const,
text: JSON.stringify(data, null, 2),
},
],
};
}
async getUserPlaylists(limit: number = 20): Promise<CallToolResult> {
await this.ensureValidToken();
const response = await fetch(`https://api.spotify.com/v1/me/playlists?limit=${limit}`, {
headers: {
Authorization: `Bearer ${this.tokens!.access_token}`,
},
});
if (!response.ok) {
throw new Error(`Failed to get user playlists: ${response.status} ${response.statusText}`);
}
const data = await response.json();
return {
content: [
{
type: "text" as const,
text: JSON.stringify(data, null, 2),
},
],
};
}
}