Skill: brackyt/playlistable (version 1.0.0)

--- name: playlistable-mcp description: Create AI-powered Spotify playlists and discover music via Playlistable MCP. Use when the user wants to generate playlists from a mood/prompt, search songs or artists, get personalized playlist suggestions, or manage their playlists. Requires OAuth authentication via https://mcp.playlistable.io/authorize. Supports PLAYLISTABLE_API_KEY env var or config/auth.json. metadata: {"clawdbot":{"emoji":"🎵","requires":{"bins":["node"],"env":["PLAYLISTABLE_API_KEY"]},"primaryEnv":"PLAYLISTABLE_API_KEY"}} --- # Playlistable MCP Create AI-powered Spotify playlists and discover music using the Playlistable MCP server. ## Authentication Needs a Playlistable API key (`PLAYLISTABLE_API_KEY` env var or `config/auth.json`). To get one: ```bash node {baseDir}/scripts/auth.mjs ``` Fully automatic — starts a local HTTP server, opens browser for Spotify OAuth, catches the redirect, exchanges the code for an API key via PKCE, and saves it to `{baseDir}/config/auth.json`. No manual copy-paste needed. If the key is already saved, scripts read it automatically from `config/auth.json`. ## How it works The MCP server at `https://mcp.playlistable.io` exposes tools via Streamable HTTP transport. The `mcp-call.mjs` script sends JSON-RPC requests directly — no MCP SDK needed. ### Common workflows **Generate a playlist:** User describes a mood → `generate_playlist` creates an async Spotify playlist → returns playlist URL immediately. Tracks appear in the background. **Browse playlists:** `get_playlists` lists all user playlists. `get_playlist` gets details + tracks for a specific one. **Edit a playlist:** `edit_playlist` adds or removes songs by Spotify track ID. **Search music:** `search_songs` and `search_artists` search Spotify directly. **Get suggestions:** `playlist_suggestions` returns 6 AI-generated mood suggestions based on the user's listening history and time of day. ## Scripts ### Authenticate ```bash node {baseDir}/scripts/auth.mjs ``` ### Call MCP tools ```bash node {baseDir}/scripts/mcp-call.mjs <tool> [json-params] node {baseDir}/scripts/mcp-call.mjs --list-tools ``` **Examples:** ```bash # Generate a playlist node {baseDir}/scripts/mcp-call.mjs generate_playlist '{"mood": "chill lo-fi for studying"}' # Get personalized suggestions node {baseDir}/scripts/mcp-call.mjs playlist_suggestions '{"userHour": 22}' # List playlists node {baseDir}/scripts/mcp-call.mjs get_playlists # Get playlist details node {baseDir}/scripts/mcp-call.mjs get_playlist '{"id": "PLAYLIST_ID"}' # Edit playlist songs node {baseDir}/scripts/mcp-call.mjs edit_playlist '{"id": "PLAYLIST_ID", "addedSongs": ["4iV5W9uYEdYUVa79Axb7Rh"], "removedSongs": []}' # Delete playlist node {baseDir}/scripts/mcp-call.mjs delete_playlist '{"id": "PLAYLIST_ID"}' # Search songs node {baseDir}/scripts/mcp-call.mjs search_songs '{"query": "Blinding Lights", "limit": 5}' # Search artists node {baseDir}/scripts/mcp-call.mjs search_artists '{"query": "The Weeknd", "limit": 5}' # List all available tools node {baseDir}/scripts/mcp-call.mjs --list-tools ``` ## Available MCP Tools | Tool | Description | Key params | |------|-------------|------------| | `generate_playlist` | Create a playlist from a mood/prompt | `mood` (string, required) | | `get_playlist` | Get playlist details + tracks | `id` (string, required) | | `get_playlists` | List user's playlists (paginated) | `lastDocId` (string, optional) | | `edit_playlist` | Add/remove songs by Spotify track ID | `id`, `addedSongs`, `removedSongs` | | `delete_playlist` | Delete a playlist | `id` (string, required) | | `playlist_suggestions` | Get 6 AI mood suggestions | `userHour` (0-23, optional) | | `search_songs` | Search Spotify tracks | `query`, `limit` (1-10) | | `search_artists` | Search Spotify artists | `query`, `limit` (1-10) | See [references/api_reference.md](references/api_reference.md) for full parameter details, response formats, and error handling. ## Playlist Generation Flow (important) Playlist generation takes ~30 seconds. Always follow this flow: 1. Call `generate_playlist` → returns immediately with `id` and Spotify URL 2. **Share the Spotify URL with the user right away** so they have it 3. Wait ~15s, then poll `get_playlist` every 10 seconds until `status === "ready"` 4. Once ready, display the track list to the user Use the `--wait` flag to handle this automatically: ```bash node {baseDir}/scripts/mcp-call.mjs generate_playlist '{"mood": "..."}' --wait ``` This generates, polls until ready (~30s), then prints the full playlist with tracks. ## Notes - Free users get a limited "teaser" playlist. Paid users get full playlists. - `playlist_suggestions` is time-aware — pass `userHour` for better results (morning workout vs late-night chill). - Songs are identified by **Spotify track IDs** (e.g., `4iV5W9uYEdYUVa79Axb7Rh`). Use `search_songs` to find IDs.

path: references/api_reference.md
content: # Playlistable MCP — API Reference

Base URL: `https://mcp.playlistable.io`
Transport: Streamable HTTP (POST `/` with JSON-RPC body)
Auth: `Authorization: Bearer sk_mcp_...` or `x-[REDACTED_SECRET]`

---

## generate_playlist

Create a playlist from a mood description. Returns immediately — tracks are generated asynchronously in the background.

**Parameters:**

| Name | Type | Required | Description |
|------|------|----------|-------------|
| `mood` | string | yes | Mood or prompt describing the playlist (1-500 chars) |

**Example:**
```json
{ "mood": "energetic workout bangers" }
```

**Response:**
```json
{
  "id": "abc123",
  "name": "energetic workout bangers",
  "spotifyId": "37i9dQZF1...",
  "url": "https://open.spotify.com/playlist/37i9dQZF1...",
  "playlistableUrl": "https://app.playlistable.io/playlist/abc123",
  "status": "pending",
  "visibility": "public",
  "createdAt": "2026-02-27T..."
}
```

**Notes:**
- Status starts as `"pending"` while tracks are being curated
- Use `get_playlist` to poll until status becomes `"ready"`
- Playlists are created as public on Spotify
- Free users get a teaser playlist (limited tracks); paid users get the full result

---

## get_playlist

Get full details of a playlist including tracks.

**Parameters:**

| Name | Type | Required | Description |
|------|------|----------|-------------|
| `id` | string | yes | Playlistable playlist ID |

**Example:**
```json
{ "id": "abc123" }
```

**Response:** Full playlist object with `tracks` array containing track name, artist, Spotify URL, album art, etc.

---

## get_playlists

List the authenticated user's playlists. Returns up to 10 per request.

**Parameters:**

| Name | Type | Required | Description |
|------|------|----------|-------------|
| `lastDocId` | string | no | ID of last playlist from previous page (for pagination) |

**Example:**
```json
{}
```

**Response:** Array of playlist objects (without full track lists).

---

## edit_playlist

Add or remove songs from a playlist using Spotify track IDs.

**Parameters:**

| Name | Type | Required | Description |
|------|------|----------|-------------|
| `id` | string | yes | Playlistable playlist ID |
| `addedSongs` | string or string[] | no | Spotify track ID(s) to add |
| `removedSongs` | string or string[] | no | Spotify track ID(s) to remove |

**Example:**
```json
{
  "id": "abc123",
  "addedSongs": ["4iV5W9uYEdYUVa79Axb7Rh", "7qiZfU4dY1lWllzX7mPBI3"],
  "removedSongs": ["1301WleyT98MSxVHPZCA6M"]
}
```

**Notes:** Use `search_songs` to find Spotify track IDs.

---

## delete_playlist

Delete a playlist by ID. Removes it from both Playlistable and Spotify.

**Parameters:**

| Name | Type | Required | Description |
|------|------|----------|-------------|
| `id` | string | yes | Playlistable playlist ID |

**Example:**
```json
{ "id": "abc123" }
```

---

## playlist_suggestions

Get 6 AI-generated playlist mood suggestions personalized to the user's listening history and time of day.

**Parameters:**

| Name | Type | Required | Description |
|------|------|----------|-------------|
| `userHour` | number (0-23) | no | User's local hour for time-aware suggestions (0 = midnight, 23 = 11pm). Defaults to server time. |

**Example:**
```json
{ "userHour": 8 }
```

**Response:** Array of 6 mood strings, e.g.:
```json
[
  "energetic morning workout",
  "upbeat commute pop",
  "focused deep work ambient",
  "feel-good indie for coffee",
  "motivational hip-hop",
  "chill acoustic wake-up"
]
```

**Notes:**
- Morning hours (6-11) → energetic/focus moods
- Afternoon (12-17) → productive/upbeat moods
- Evening (18-22) → relaxing/social moods
- Night (23-5) → chill/sleep moods

---

## search_songs

Search Spotify tracks by query.

**Parameters:**

| Name | Type | Required | Description |
|------|------|----------|-------------|
| `query` | string | yes | Search query (1-300 chars) |
| `limit` | number | no | Results to return (1-10, default 10) |

**Example:**
```json
{ "query": "Blinding Lights", "limit": 5 }
```

**Response:**
```json
[
  {
    "id": "0VjIjW4GlUZAMYd2vXMi3b",
    "name": "Blinding Lights",
    "artist": "The Weeknd",
    "image": "https://i.scdn.co/image/...",
    "url": "https://open.spotify.com/track/0VjIjW4GlUZAMYd2vXMi3b"
  }
]
```

---

## search_artists

Search Spotify artists by query.

**Parameters:**

| Name | Type | Required | Description |
|------|------|----------|-------------|
| `query` | string | yes | Search query (1-300 chars) |
| `limit` | number | no | Results to return (1-10, default 10) |

**Example:**
```json
{ "query": "The Weeknd", "limit": 3 }
```

**Response:**
```json
[
  {
    "id": "1Xyo4u8uXC1ZmMpatF05PJ",
    "name": "The Weeknd",
    "image": "https://i.scdn.co/image/...",
    "url": "https://open.spotify.com/artist/1Xyo4u8uXC1ZmMpatF05PJ",
    "genres": ["canadian contemporary r&b", "canadian pop", "pop"]
  }
]
```

---

## Authentication Endpoints

These are REST endpoints, not MCP tools — used internally by `auth.mjs`.

| Endpoint | Method | Description |
|----------|--------|-------------|
| `/authorize?session_id={id}` | GET | Start OAuth flow (creates auth session) |
| `/login?session_id={id}` | GET | Interactive Spotify login page |
| `/callback` | GET | OAuth callback (handles Spotify redirect) |
| `/oauth/token` | POST | Exchange authorization code for API key |
| `/session?session_id={id}` | GET | Check auth session status |
| `/.well-known/oauth-authorization-server` | GET | OAuth server metadata |

**API Key format:** `sk_mcp_{base64url-encoded-32-bytes}`

**Auth headers (any of):**
- `Authorization: Bearer sk_mcp_...`
- `x-[REDACTED_SECRET]`

---

## Error Handling

| Scenario | What happens |
|----------|-------------|
| Token expired / invalid | 401 response — re-run `scripts/auth.mjs` |
| Free tier quota exceeded | Error text includes `purchase_url` |
| Missing Spotify token | "Missing Spotify token" — user needs to re-authenticate |
| Playlist not found | 404-style error in content text |


path: scripts/auth.mjs
content: #!/usr/bin/env node

/**
 * Authenticate with Playlistable MCP via Spotify OAuth.
 *
 * Usage:
 *   node auth.mjs
 *
 * Starts a local HTTP server, opens browser for Spotify login,
 * catches the OAuth redirect, exchanges the code for an API key,
 * and saves it to config/auth.json — fully automatic.
 */

import { randomBytes, createHash } from "crypto";
import { createServer } from "http";
import { existsSync, mkdirSync, writeFileSync, readFileSync } from "fs";
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";
import { exec } from "child_process";

const __dirname = dirname(fileURLToPath(import.meta.url));
const CONFIG_DIR = resolve(__dirname, "..", "config");
const CONFIG_PATH = resolve(CONFIG_DIR, "auth.json");
const MCP_URL = "https://mcp.playlistable.io";

// Check if already authenticated
if (existsSync(CONFIG_PATH)) {
  try {
    const existing = JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
    if (existing.api_key) {
      console.log("Already authenticated.");
      console.log(`API key: ${existing.api_key.slice(0, 12)}...${existing.api_key.slice(-6)}`);
      console.log(`\nTo re-authenticate, delete ${CONFIG_PATH} and run again.`);
      process.exit(0);
    }
  } catch {
    // corrupt file, proceed with auth
  }
}

// PKCE: generate code_verifier and code_challenge (S256)
const codeVerifier = randomBytes(32).toString("base64url");
const codeChallenge = createHash("sha256")
  .update(codeVerifier)
  .digest("base64")
  .replace(/\+/g, "-")
  .replace(/\//g, "_")
  .replace(/=+$/, "");

const sessionId = randomBytes(16).toString("hex");

// 1. Start a local HTTP server to catch the OAuth redirect
const server = createServer(async (req, res) => {
  const url = new URL(req.url, `http://localhost`);

  if (!url.pathname.startsWith("/callback")) {
    res.writeHead(404);
    res.end("Not found");
    return;
  }

  const code = url.searchParams.get("code");

  if (!code) {
    const error = url.searchParams.get("error") || "unknown";
    res.writeHead(400, { "Content-Type": "text/html" });
    res.end(`<h1>Auth failed</h1><p>${error}</p>`);
    console.error(`\nAuth failed: ${error}`);
    server.close();
    process.exit(1);
  }

  // 3. Exchange code for API key
  try {
    const tokenResp = await fetch(`${MCP_URL}/oauth/token`, {
      method: "POST",
      headers: { "Content-Type": "application/x-www-form-urlencoded" },
      body: new URLSearchParams({
        grant_type: "authorization_code",
        code,
        redirect_uri: redirectUri,
        code_verifier: codeVerifier,
      }).toString(),
    });

    if (!tokenResp.ok) {
      const err = await tokenResp.text().catch(() => "");
      throw new Error(`Token exchange failed (${tokenResp.status}): ${err}`);
    }

    const { access_token } = await tokenResp.json();

    // Save to config
    if (!existsSync(CONFIG_DIR)) {
      mkdirSync(CONFIG_DIR, { recursive: true });
    }
    writeFileSync(
      CONFIG_PATH,
      JSON.stringify({ [REDACTED_SECRET], created_at: new Date().toISOString() }, null, 2)
    );

    res.writeHead(200, { "Content-Type": "text/html" });
    res.end(`<!DOCTYPE html><html><body style="font-family:system-ui;text-align:center;padding:60px">
      <h1>Connected!</h1><p>API key saved. You can close this window.</p>
    </body></html>`);

    console.log("\nAuthentication successful!");
    console.log(`API key saved to: ${CONFIG_PATH}`);
    console.log(`\nYou can now use: node scripts/mcp-call.mjs <tool> [params]`);
  } catch (err) {
    res.writeHead(500, { "Content-Type": "text/html" });
    res.end(`<h1>Error</h1><p>${err.message}</p>`);
    console.error(`\nToken exchange error: ${err.message}`);
  }

  server.close();
  process.exit(0);
});

// Listen on a random available port
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const port = server.address().port;
const redirectUri = `http://localhost:${port}/callback`;

// 2. Register a dynamic OAuth client with our local redirect URI
const registerResp = await fetch(`${MCP_URL}/register`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    client_name: "OpenClaw CLI",
    redirect_uris: [redirectUri],
    token_endpoint_auth_method: "none",
    grant_types: ["authorization_code"],
    response_types: ["code"],
  }),
});

if (!registerResp.ok) {
  console.error(`Client registration failed (${registerResp.status})`);
  server.close();
  process.exit(1);
}

const { client_id } = await registerResp.json();

// Build authorize URL with PKCE
const authUrl = new URL(`${MCP_URL}/authorize`);
authUrl.searchParams.set("session_id", sessionId);
authUrl.searchParams.set("client_id", client_id);
authUrl.searchParams.set("redirect_uri", redirectUri);
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("code_challenge", codeChallenge);
authUrl.searchParams.set("code_challenge_method", "S256");

console.log("Opening browser for Spotify authorization...");
console.log(`\nIf the browser doesn't open, visit:\n${authUrl.toString()}\n`);
console.log(`Listening on http://localhost:${port} for callback...`);

// Open browser
const openCmd =
  process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
exec(`${openCmd} "${authUrl.toString()}"`);

// Timeout after 5 minutes
setTimeout(() => {
  console.error("\nAuthentication timed out after 5 minutes.");
  server.close();
  process.exit(1);
}, 5 * 60 * 1000);


path: scripts/mcp-call.mjs
content: #!/usr/bin/env node

/**
 * Call Playlistable MCP tools directly via Streamable HTTP transport.
 *
 * Usage:
 *   node mcp-call.mjs <tool-name> [json-params]
 *   node mcp-call.mjs --list-tools
 *
 * Examples:
 *   node mcp-call.mjs generate_playlist '{"mood": "chill lo-fi for studying"}'
 *   node mcp-call.mjs search_songs '{"query": "Blinding Lights", "limit": 5}'
 *   node mcp-call.mjs get_playlists
 *   node mcp-call.mjs playlist_suggestions '{"userHour": 22}'
 */

import { readFileSync } from "fs";
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";

const __dirname = dirname(fileURLToPath(import.meta.url));
const MCP_URL = "https://mcp.playlistable.io";

function usage() {
  console.error("Usage: mcp-call.mjs <tool-name> [json-params]");
  console.error("       mcp-call.mjs --list-tools");
  console.error("");
  console.error("Tools: generate_playlist, get_playlist, get_playlists,");
  console.error("       edit_playlist, delete_playlist, playlist_suggestions,");
  console.error("       search_songs, search_artists");
  console.error("");
  console.error('Example: mcp-call.mjs generate_playlist \'{"mood": "chill vibes"}\'');
  process.exit(2);
}

function loadApiKey() {
  // 1. Environment variable
  const envKey = (process.env.PLAYLISTABLE_API_KEY ?? "").trim();
  if (envKey) return envKey;

  // 2. config/auth.json
  try {
    const configPath = resolve(__dirname, "..", "config", "auth.json");
    const config = JSON.parse(readFileSync(configPath, "utf-8"));
    if (config.api_key) return config.api_key;
    if (config.access_token) return config.access_token;
  } catch {
    // not found
  }

  console.error("No API key found. Set PLAYLISTABLE_API_KEY or run: node scripts/auth.mjs");
  process.exit(1);
}

async function callMcp(method, params, apiKey) {
  const resp = await fetch(MCP_URL, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Accept: "application/json, text/event-stream",
      Authorization: `Bearer ${apiKey}`,
    },
    body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
  });

  if (!resp.ok) {
    const text = await resp.text().catch(() => "");
    throw new Error(`MCP request failed (${resp.status}): ${text}`);
  }

  const contentType = resp.headers.get("content-type") || "";

  // Handle SSE response
  if (contentType.includes("text/event-stream")) {
    const text = await resp.text();
    for (const line of text.split("\n")) {
      if (line.startsWith("data: ")) {
        try {
          return JSON.parse(line.slice(6));
        } catch {
          // keep scanning
        }
      }
    }
    throw new Error("No valid JSON-RPC response in SSE stream");
  }

  return await resp.json();
}

// --- Main ---

const args = process.argv.slice(2);
if (args.length === 0 || args[0] === "-h" || args[0] === "--help") usage();

const [REDACTED_SECRET]);

// List tools
if (args[0] === "--list-tools") {
  const result = await callMcp("tools/list", {}, apiKey);
  const tools = result?.result?.tools ?? [];
  if (!tools.length) {
    console.log("No tools returned.");
    process.exit(0);
  }
  console.log(`${tools.length} tools:\n`);
  for (const t of tools) {
    console.log(`  ${t.name}`);
    if (t.description) console.log(`    ${t.description}`);
    const props = t.inputSchema?.properties;
    if (props) {
      for (const [k, v] of Object.entries(props)) {
        const req = t.inputSchema?.required?.includes(k) ? " (required)" : "";
        console.log(`    - ${k}: ${v.type ?? "any"}${req}`);
      }
    }
    console.log();
  }
  process.exit(0);
}

// Call a tool
const toolName = args[0];
const waitFlag = args.includes("--wait");
let params = {};

const paramsArg = args[1] && !args[1].startsWith("--") ? args[1] : null;
if (paramsArg) {
  try {
    params = JSON.parse(paramsArg);
  } catch (e) {
    console.error(`Invalid JSON params: ${e.message}`);
    process.exit(1);
  }
}

function extractContent(result) {
  if (result.error) {
    console.error(`Error: ${result.error.message || JSON.stringify(result.error)}`);
    process.exit(1);
  }
  const content = result.result?.content;
  if (Array.isArray(content)) {
    for (const block of content) {
      if (block.type === "text") {
        try {
          return JSON.parse(block.text);
        } catch {
          return block.text;
        }
      }
    }
  }
  return result;
}

try {
  const result = await callMcp("tools/call", { name: toolName, arguments: params }, apiKey);
  const data = extractContent(result);

  // --wait: poll get_playlist until status === "ready"
  if (waitFlag && toolName === "generate_playlist" && data?.id) {
    console.error(`Playlist created: ${data.playlistableUrl || data.url}`);
    console.error(`Spotify: ${data.url}`);
    console.error(`Waiting for tracks to generate...`);

    const INITIAL_WAIT = 15000;
    const POLL_INTERVAL = 10000;
    const TIMEOUT = 3 * 60 * 1000;
    const start = Date.now();

    await new Promise((r) => setTimeout(r, INITIAL_WAIT));

    while (Date.now() - start < TIMEOUT) {
      await new Promise((r) => setTimeout(r, POLL_INTERVAL));
      const poll = await callMcp("tools/call", { name: "get_playlist", arguments: { id: data.id } }, apiKey);
      const playlist = extractContent(poll);
      if (playlist?.status === "ready") {
        console.log(JSON.stringify(playlist, null, 2));
        process.exit(0);
      }
      console.error(`Still generating... (${Math.round((Date.now() - start) / 1000)}s)`);
    }

    console.error("Timed out waiting for playlist. Check manually with get_playlist.");
    process.exit(1);
  }

  console.log(typeof data === "string" ? data : JSON.stringify(data, null, 2));
} catch (err) {
  console.error(err.message);
  process.exit(1);
}


Security scan verdicts — ClawScan: suspicious | static analysis: suspicious | VirusTotal: clean | SkillSpector: clean (LOW)