Skip to main content
Glama
nazaryanenko

youtube-music-mcp

by nazaryanenko
README.md
# YouTube Playlist Generator MCP Server

A Model Context Protocol (MCP) server that enables AI applications to search for YouTube music videos and manage playlists using the official YouTube Data API v3.

## Features

- **Search Music Videos**: Find music videos on YouTube with customizable search parameters
- **Get Video Details**: Retrieve comprehensive information about specific videos
- **Get Playlist Items**: List videos from public playlists
- **Playlist Management**: Complete playlist management including creating, editing, and managing playlists (requires OAuth setup)

## Setup

### Prerequisites

- Node.js 18 or higher
- A Google Cloud Project with YouTube Data API v3 enabled
- YouTube Data API key
- OAuth 2.0 Client credentials (optional, required for playlist management features)

### Installation

1. Clone this repository:
```bash
git clone https://github.com/nazaryanenko/youtube-music-mcp
```

2. Install dependencies:
```bash
npm install
```

3. Get a YouTube Data API key:
   - Go to the [Google Cloud Console](https://console.cloud.google.com/)
   - Create a new project or select an existing one
   - Enable the YouTube Data API v3
   - Create credentials (API key)
   - Copy the API key

4. Set up environment variables:
```bash
# Create a .env file
echo "YOUTUBE_API_KEY=your_api_key_here" > .env
echo "YOUTUBE_CLIENT_ID=your_client_id_here" >> .env
echo "YOUTUBE_CLIENT_SECRET=your_client_secret_here" >> .env
```

5. Build the TypeScript code:
```bash
npm run build
```

### Running the Server

For development with auto-rebuild:
```bash
npm run dev
```

For production:
```bash
npm start
```

### Using with MCP Clients

This server uses the stdio transport, so it can be used with any MCP client that supports stdio.

#### Claude Desktop Configuration

Add to your Claude Desktop configuration file:

**macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
**Windows**: `%APPDATA%\Claude\claude_desktop_config.json`

```json
{
  "mcpServers": {
    "youtube-playlist-generator": {
      "command": "node",
      "args": ["/absolute/path/to/yt-playlist-generator/build/index.js"],
      "env": {
        "YOUTUBE_API_KEY": "your_youtube_api_key_here",
        "YOUTUBE_CLIENT_ID": "your_client_id_here",
        "YOUTUBE_CLIENT_SECRET": "your_client_secret_here"
      }
    }
  }
}
```

#### Visual Studio Code with GitHub Copilot Configuration

To use this MCP server with GitHub Copilot in Visual Studio Code, you need to configure it in your VS Code settings:

1. **Open VS Code Settings**: Press `Ctrl+,` (Windows/Linux) or `Cmd+,` (macOS)

2. **Search for MCP**: Type "mcp" in the search bar

3. **Add MCP Server Configuration**: Add the following to your VS Code settings JSON:

```json
{
  "github.copilot.chat.mcp.servers": {
    "youtube-playlist-generator": {
      "command": "node",
      "args": ["/absolute/path/to/yt-playlist-generator/build/index.js"],
      "env": {
        "YOUTUBE_API_KEY": "your_youtube_api_key_here",
        "YOUTUBE_CLIENT_ID": "your_client_id_here",
        "YOUTUBE_CLIENT_SECRET": "your_client_secret_here"
      }
    }
  }
}
```

**Alternative: Use Settings UI**

1. Go to **File > Preferences > Settings** (or use `Ctrl+,`)
2. Search for "GitHub Copilot MCP"
3. Click "Edit in settings.json" next to "Github › Copilot › Chat: Mcp Servers"
4. Add the server configuration as shown above

**Environment Variables Setup**

For security, you can also set environment variables system-wide instead of in the config:

**Windows (PowerShell)**:
```powershell
[Environment]::SetEnvironmentVariable("YOUTUBE_API_KEY", "your_api_key_here", "User")
[Environment]::SetEnvironmentVariable("YOUTUBE_CLIENT_ID", "your_client_id_here", "User")
[Environment]::SetEnvironmentVariable("YOUTUBE_CLIENT_SECRET", "your_client_secret_here", "User")
```

**Windows (Command Prompt)**:
```cmd
setx YOUTUBE_API_KEY "your_api_key_here"
setx YOUTUBE_CLIENT_ID "your_client_id_here"
setx YOUTUBE_CLIENT_SECRET "your_client_secret_here"
```

**macOS/Linux**:
```bash
export YOUTUBE_API_KEY="your_api_key_here"
export YOUTUBE_CLIENT_ID="your_client_id_here"
export YOUTUBE_CLIENT_SECRET="your_client_secret_here"
# Add to ~/.bashrc, ~/.zshrc, or ~/.profile to persist
echo 'export YOUTUBE_API_KEY="your_api_key_here"' >> ~/.bashrc
echo 'export YOUTUBE_CLIENT_ID="your_client_id_here"' >> ~/.bashrc
echo 'export YOUTUBE_CLIENT_SECRET="your_client_secret_here"' >> ~/.bashrc
```

Then remove the `env` section from the VS Code configuration:

```json
{
  "github.copilot.chat.mcp.servers": {
    "youtube-playlist-generator": {
      "command": "node",
      "args": ["/absolute/path/to/yt-playlist-generator/build/index.js"]
    }
  }
}
```

**Usage in VS Code**

Once configured, you can use the MCP server through GitHub Copilot Chat:

1. Open Copilot Chat (`Ctrl+Alt+I` or click the chat icon)
2. Use natural language to interact with YouTube:
   - "Search for music videos about jazz"
   - "Create a new playlist called 'My Favorites'"
   - "Add this video to my playlist"
   - "Show me details about this YouTube video"

## Available Tools

### Read-Only Tools (API Key Authentication)

#### search_music_videos
Search YouTube for music videos with specified criteria.

**Parameters:**
- `query` (string): Search query for music videos
- `maxResults` (number, 1-50, default: 10): Maximum number of results
- `order` (enum): Sort order - "relevance", "date", "rating", "viewCount", "title"

#### get_video_details
Get detailed information about a specific YouTube video.

**Parameters:**
- `videoId` (string): YouTube video ID

#### get_playlist_items
Retrieve videos from a public YouTube playlist.

**Parameters:**
- `playlistId` (string): YouTube playlist ID
- `maxResults` (number, 1-50, default: 25): Maximum number of items

### OAuth-Required Tools (Playlist Management)

#### authenticate_youtube
Authenticate with YouTube OAuth to enable playlist management features.

**Parameters:**
- `getAuthUrl` (boolean, default: false): Set to true to get the authorization URL
- `authCode` (string, optional): Authorization code from OAuth flow

#### get_auth_status
Check current OAuth authentication status.

**Parameters:** None

#### create_playlist
Create a new YouTube playlist (requires OAuth authentication).

**Parameters:**
- `title` (string): Playlist title
- `description` (string, optional): Playlist description
- `privacy` (enum): "private", "public", or "unlisted"

#### edit_playlist
Edit existing playlist information (requires OAuth authentication).

**Parameters:**
- `playlistId` (string): ID of the playlist to edit
- `title` (string, optional): New title for the playlist
- `description` (string, optional): New description for the playlist
- `privacy` (enum, optional): New privacy setting - "private", "public", or "unlisted"

**Note:** At least one field (title, description, or privacy) must be provided to update.

#### add_to_playlist
Add a video to an existing playlist (requires OAuth authentication).

**Parameters:**
- `playlistId` (string): Target playlist ID
- `videoId` (string): Video ID to add

#### list_playlists
List user's playlists (requires OAuth authentication).

**Parameters:**
- `maxResults` (number, 1-50, default: 25): Maximum number of playlists
- `mine` (boolean, default: true): List authenticated user's playlists

#### remove_from_playlist
Remove a video from a playlist (requires OAuth authentication).

**Parameters:**
- `playlistItemId` (string): ID of the playlist item to remove (get from get_playlist_items)

### Channel Subscription Tools (OAuth)

All subscription tools work under the existing `https://www.googleapis.com/auth/youtube` scope — no re-consent required. Every response includes a `_quotaCost` field; the running session total is also logged to stderr.

#### subscribe_to_channel
Subscribe the authenticated user to a channel. Idempotent: an already-subscribed channel returns `{ alreadySubscribed: true, subscriptionId, channelTitle }` instead of erroring.

**Parameters:**
- `channel` (string): channelId (UC...), `@handle`, channel URL, or channel name.

**Quota:** ~51 units on a new subscribe (50 insert + 1 duplicate lookup), 1–2 units when already subscribed.

#### unsubscribe_from_channel
Unsubscribe the authenticated user from a channel. If not subscribed, returns `{ success: false, reason: "not_subscribed" }` rather than throwing.

**Parameters:**
- `subscriptionId` (string, optional): preferred — skips the lookup.
- `channelId` (string, optional): used when subscriptionId is unknown.

At least one of the two must be supplied.

**Quota:** 50 units when subscriptionId is passed, ~51 units when only channelId is passed.

#### list_subscriptions
List the authenticated user's channel subscriptions. When called without `pageToken`, transparently auto-paginates up to 10 pages (500 subscriptions) and returns the merged list; pass an explicit `pageToken` to fetch a single page and drive pagination yourself.

**Parameters:**
- `maxResults` (number, 1–50, default: 50): page size.
- `pageToken` (string, optional): if supplied, only one page is returned.
- `order` (enum): `"alphabetical"` (default) | `"relevance"` | `"unread"`.

**Quota:** 1 unit per page.

#### get_channel_latest_videos
Get the latest uploads from a channel. Uses the uploads playlist instead of `search.list` — 1–2 units versus 100.

**Parameters:**
- `channel` (string): channelId (UC...), `@handle`, or channel name.
- `maxResults` (number, 1–50, default: 10).
- `publishedAfter` (ISO 8601 string, optional): client-side filter on publish date.
- `excludeShorts` (boolean, default: false): filter out videos shorter than 60 seconds. Adds 1 quota unit per 50 fetched.

**Quota:** ~2 units baseline (channels.list + playlistItems.list), +1 per additional 50 items when `excludeShorts` is on.

### Watch History Tools (Local Takeout Import)

The YouTube Data API stopped exposing watch history in August 2016 — the `relatedPlaylists.watchHistory` field was removed and the legacy `HL` playlist ID returns no items. Instead of pretending otherwise, this server imports data from a **Google Takeout** export into a local SQLite database and answers queries against that.

#### Getting a Takeout export

1. Go to [takeout.google.com](https://takeout.google.com/).
2. **Deselect all**, then select **YouTube and YouTube Music**.
3. Click **All YouTube data included** → uncheck everything except **history**.
4. Click **Multiple formats** → set **history** to **JSON** (default is HTML — the server refuses HTML with a clear error, since HTML parsing would be fragile).
5. Export. When ready, download the archive and locate `Takeout/YouTube and YouTube Music/history/watch-history.json`.

The DB file lives at `~/.yt-playlist-mcp-history.db` by default (override with `YOUTUBE_HISTORY_DB_PATH`).

#### check_history_api_availability
Runtime probe to prove the Data API still lacks watch-history access. Runs three checks and returns their raw evidence — never hard-codes the verdict. If Google ever restores access, `apiAvailable: true` will bubble up automatically.

**Parameters:** None.

Returns `{ apiAvailable, checkedAt, evidence[], conclusion, alternative }`. Evidence entries include `step`, `request`, `statusCode`, `result`.

#### import_watch_history
Streams the Takeout JSON file (safe for exports >100 MB) into SQLite. Deduplicates by `(videoId, watchedAt)`, so re-importing is idempotent — running it again after a fresh Takeout adds new events without touching existing rows.

**Parameters:**
- `filePath` (string): absolute or `~`-expandable path to `watch-history.json`.

Skips ad-driven watches (`details[].name` starting with `"From Google Ads"`) and marks deleted/removed videos separately. Returns:

```json
{
  "success": true,
  "imported": 12345,
  "adsExcluded": 87,
  "deletedVideos": 42,
  "skippedDuplicates": 0,
  "dateRange": { "from": "…", "to": "…" },
  "dbPath": "…",
  "filePath": "…"
}
```

On a repeat import of the same or an overlapping file, `skippedDuplicates` reports how many events were already in the DB (deduplicated by `(videoId, watchedAt)`).

**Errors** — distinct codes so the caller can give the right advice:

| `error` | Meaning |
| --- | --- |
| `file_not_found` | Path echoed back as `filePath` so you can see how `~` expanded. |
| `html_not_json` | Takeout gave HTML instead of JSON — re-export with format = JSON. |
| `unexpected_extension` | Not `.html`, not `.json` — probably wrong file. |
| `parse_error` | Broken JSON. Includes `bytePosition` when stream-json can locate the failure. |
| `db_locked` | SQLite reports `SQLITE_BUSY`/`SQLITE_LOCKED` — another process holds the DB. |

#### query_watch_history
Aggregate queries over the imported history.

**Parameters:**
- `groupBy` (enum): `"channel"` (default) | `"month"` | `"hour_of_day"` | `"weekday"`.
- `channel` (string, optional): exact-match channel filter.
- `from`, `to` (ISO 8601 strings, optional): inclusive/exclusive date bounds.
- `limit` (number, 1–1000, default 30).
- `timezone` (string, IANA, default `"Europe/Kyiv"`): governs `hour_of_day` / `weekday` bucketing; date arithmetic honours DST.

Rows marked `deleted` are excluded from aggregates. Returns `{ groupBy, timezone, totalEventsMatched, filters, groups[] }`.

#### watch_history_status
Fast probe: does the DB exist, how many events, source split (`youtube` vs `youtube_music`), date range, per-file import log. Use this before deciding whether to re-run `import_watch_history`.

**Parameters:** None.

### Watch-history analytics (pre-aggregated for LLM context)

All five analytics tools return a compact envelope — **≤ 50 KB per response** — with heavy trimming when needed. Raw events are only returned by `sample_watch_events`. Every tool accepts the shared filter set:

- `from` / `to` (ISO 8601): date-range bounds.
- `source`: `"youtube"` | `"youtube_music"` | `"all"` (default).
- `timezone` (IANA, default `"Europe/Kyiv"`): governs hour-of-day, weekday, month buckets.

Every response is wrapped as:

```json
{
  "meta": {
    "dbEvents": 12345,
    "dateRange": { "from": "2020-01-...", "to": "2026-07-..." },
    "filtered": 8901,
    "timezone": "Europe/Kyiv",
    "truncated": false
  },
  "data": { /* tool-specific */ }
}
```

#### get_watch_overview
One-call snapshot. Returns totals, source split, deleted / ad-excluded counts, month-by-month volume, hour-of-day distribution split into weekday/weekend, and the peak day. Start any exploration here.

#### get_channel_stats
Per-channel breakdown, grouped by **stable channelId** — a channel that renamed still lands in one row.

Fields per row: `channelId` (UC…, `null` for old Takeouts without a `/channel/` URL), `keyType` (`"id"` when grouped by UC, `"name"` fallback for URL-less rows), `channel` (freshest display name — pulled from the row with `MAX(watchedAt)`), `aliases[]` (older display names for the same channelId, if any), `channelUrl`, `watches`, `uniqueVideos`, `firstWatch`, `lastWatch`, `last90d`, `share`.

`sortBy: "watches"` (default) surfaces long-term favorites; `sortBy: "recent"` surfaces channels with the newest activity (fresh obsessions). The client derives "dead interests" (`watches ≥ 20` with old `lastWatch`) locally rather than asking for a separate endpoint.

**Migration note:** the underlying `watch_events.channelId` column is added and backfilled from `channelUrl` the first time the server opens an older DB (log line `[history migration] backfilled channelId for N/M rows`). Rows whose Takeout URL was an `@handle` (no UC) stay `NULL` and fall back to name-based grouping.

#### get_sessions_analysis
Sessions are contiguous runs of watches separated by gaps under `gapMinutes` (default 30). Returns:

- session medians (length in minutes, videos per session)
- `rabbitHoles`: top same-channel streaks of `≥ minStreak` consecutive videos within one session
- `channelCooccurrence`: top 20 channel pairs that show up in the same session, with `sessionsTogether ≥ 3` — raw material for topic clustering, which the client performs itself

All aggregation runs inside SQLite via `LAG()` window functions; the events table is never materialized in JS memory.

#### get_taste_divergence
Compares watching against a live pull of `videos.list?myRating=like` (up to 500 videos, cached in-process for 1 hour). Matching is **by channelId** for both directions — a rename doesn't break the join. Rows without a channelId (old Takeouts) fall back to name matching and are flagged with `keyType: "name-fallback"` (weaker match — draw softer conclusions from those).

Returns:

- `quietDominants`: heavily watched channels you have never liked (habit-consumption signal) — with `channelId`, `keyType`, `channel`, `watches`, `likes: 0`.
- `likedButAbandoned`: channels with likes but no watch activity in the last ~6 months (drifting-away signal) — with `channelId`, `keyType`, `channel`, `likes`, `lastWatch`.
- `likesCachedAt`: ISO timestamp of the current in-process cache — subtract from wall time for age.

Requires OAuth. Costs 1 quota unit per 50 liked videos fetched, once per hour.

#### sample_watch_events
The only tool that returns raw event rows (`title`, `channel`, `watchedAt`, `videoId`), newest-first. Optional exact-match `channel` filter. Hard limit: 100 rows — asking for more returns `error: "limit_too_high"` rather than a silent truncation.

## Quota Tracking

Every subscription tool returns `_quotaCost` — the estimated cost of that single call — and the server logs a running session total to stderr after each API call, e.g.:

```
[quota] +50 for subscriptions.insert (session total: 61 units)
```

The default YouTube Data API daily quota is **10 000 units**. Structured errors are returned for the following conditions:

| Condition | `error` value | Notes |
| --- | --- | --- |
| Daily quota exhausted | `quota_exceeded` | Resets at midnight Pacific Time. |
| Channel disabled subscriptions | `subscription_forbidden` | |
| Already subscribed (on insert) | Handled transparently as `alreadySubscribed: true`. | |
| OAuth token expired/invalid | `auth_expired` | Re-run `authenticate_youtube`. |
| Channel deleted / not found | `not_found` | |
| Not subscribed (on delete) | `success: false, reason: "not_subscribed"` | Not an MCP error. |

## OAuth Authentication Setup

For full playlist management capabilities, you need to set up OAuth 2.0:

### 1. Google Cloud Console Setup

1. Go to [Google Cloud Console](https://console.cloud.google.com/)
2. Create a new project or select existing one
3. Enable the YouTube Data API v3
4. Go to "Credentials" → "Create Credentials" → "OAuth 2.0 Client ID"
5. Choose "Desktop Application" as application type
6. Download the credentials or copy the Client ID and Client Secret

### 2. Environment Configuration

Add your OAuth credentials to the `.env` file:

```bash
YOUTUBE_API_KEY=your_api_key_here
YOUTUBE_CLIENT_ID=your_client_id_here
YOUTUBE_CLIENT_SECRET=your_client_secret_here
```

### 3. Authentication Flow

1. Use the `authenticate_youtube` tool with `getAuthUrl: true`
2. Open the provided URL in your browser
3. Sign in to your Google account and grant permissions
4. Copy the authorization code
5. Use `authenticate_youtube` tool again with the `authCode` parameter

### 4. Using Authenticated Features

Once authenticated, you can:
- Create new playlists
- Edit existing playlist information (title, description, privacy)
- Add videos to your playlists
- List your private playlists
- Remove videos from playlists

## API Limitations

- **Quota Limits**: YouTube API has daily quota limits (10,000 units/day by default)
- **Rate Limiting**: API calls are subject to rate limiting
- **OAuth Tokens**: Access tokens expire and need refresh (handled automatically)
- **Permissions**: OAuth scope determines available operations

## Development

### Building
```bash
npm run build
```

### stdio transport: never write to stdout

The server uses the MCP stdio transport — **stdout is reserved for JSON-RPC frames**. A single `console.log`, `process.stdout.write`, or a logger that defaults to stdout (pino/winston/etc. without explicit stderr config) will corrupt the message framing and the client will silently drop responses, making every tool appear to hang.

When adding new tools:
- Log to stderr only: `console.error(...)`.
- If you must use a logger, pin its destination stream to `process.stderr`.
- Verify with: `node build/index.js 2>/dev/null | head -c 200` — at idle this MUST print nothing.


### Development Mode
```bash
npm run dev
```

### Project Structure
```
src/
└── index.ts          # Main server implementation

build/                # Compiled JavaScript output
```

## Contributing

1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Test thoroughly
5. Submit a pull request

## License

MIT License - see LICENSE file for details

## Troubleshooting

### Common Issues

1. **"YOUTUBE_API_KEY environment variable is required"**
   - Make sure you've set the YOUTUBE_API_KEY environment variable
   - Verify the API key is valid and has YouTube Data API access

2. **"Quota exceeded" errors**
   - You've hit the daily API quota limit
   - Wait for quota reset or request quota increase

3. **"Playlist not found" errors**
   - Ensure the playlist ID is correct
   - Verify the playlist is public (private playlists require OAuth)

4. **Build errors**
   - Make sure you have Node.js 18+ installed
   - Run `npm install` to ensure all dependencies are installed
   - Check TypeScript compilation with `npm run build`

## Security Considerations

- Never commit API keys to version control
- Use environment variables for sensitive configuration
- Validate all user inputs
- Implement proper error handling and logging

---

*This project was created using GitHub Copilot*