YouTube MCP
An MCP server that gives Claude 15 tools for managing YouTube playlists, with OAuth and quota rotation across Google Cloud projects.
Context
My YouTube playlists had outgrown the web UI: more than 150 videos in poorly organized playlists. Bulk changes by hand were slow and easy to get wrong.
I wanted to describe a reorganization in plain language and have Claude Code carry it out. So I wrote an MCP server that exposes the YouTube Data API v3 as tools the model can call. The first public commit is from 2026-03-26. I built it with Claude; two of the four commits carry a Claude co-author trailer.
Problem
The user of this API is a language model. Per tool, it sees a name, a description and an argument schema. It fills arguments from whatever is in its context. Each place the YouTube API wants a value the model doesn’t hold is a place for a wrong call.
Three gaps mattered:
- IDs. Removing a video from a playlist takes the playlist item ID, which identifies that one entry. After a search, the model holds video IDs.
- Auth. Public reads work with an API key. Writes, and reads of my own account, need OAuth 2.0, which needs a person in a browser. The model has to hand off and pick up again.
- Quota. Each Google Cloud project gets 10,000 units a day. A write costs 50, a search 100, a read 1.
Moving a video between playlists is two writes, 100 units. One project’s daily quota covers 100 moves at most, before a single read.
What I built
Fifteen tools in one file, src/index.ts, 621 lines. Five write, six read, four handle auth and projects. Arguments are Zod schemas with a one-line description each. The decisions that matter live in those descriptions and in the gaps between tools.
- No move tool. A move is three calls (Fig. 1).
get_playlist_itemsreturns each entry’splaylistItemIdnext to itsvideoId.add_to_playlist(playlistId, videoId)inserts.remove_from_playlistdeletes byplaylistItemId. The README lists moving as a feature; the model composes it. Each write tool makes exactly one API write, so the call log is also the quota bill. - Name the wrong value. The one argument of
remove_from_playlistis described as “Playlist item ID (from get_playlist_items, not the video ID)”. It rules out the ID the model already holds and names the tool that returns the right one. The first version said “not the video ID” and stopped there. - Put the sequence in the description.
authenticate_youtubetakesgetAuthUrl,authCodeandprojectIndex, and is meant to be called twice. Its description says so: “Use getAuthUrl=true to get the URL, then call again with authCode.” The first call returns the URL and five numbered steps for the person. They sign in, land on a localhost page that doesn’t load, and paste the redirect URL or its code into the chat. The model calls again with the code, inside its lifetime of about 60 seconds. The first version’s description said what the tool was for, not how to call it. - Remove dead arguments. The first
list_playlistshad amineflag. Its own error text offeredmine=false, then admitted that path “would need additional implementation”. The current version has no such flag. - Hide what the model shouldn’t need.
search_music_videos(query, maxResults, order)appends “music video” to the query and pins YouTube’s Music category, ID 10. The model never sees a category ID. - Safe defaults, explicit danger.
create_playlistdefaultsprivacytoprivate.edit_playlistreads the playlist first and keeps any field the model left out; it refuses a call that sets none of the three fields. The description ofdelete_playliststarts with “Permanently”. - Make quota invisible, then inspectable. Nine tools send their API calls through one wrapper,
ytCall. On a quota error it moves to the next project with a saved token, records the choice instate.json, and retries. If it can’t,get_project_statusshows which projects hold tokens andswitch_projectoverrides byprojectIndex.
Tokens persist per project under ~/.youtube-mcp/, so each project authenticates once. The first commit held them in memory; its README said to re-authenticate after every restart. The second, the same day, wrote them to disk.
The first public commit, on 2026-03-26, talks to one Google Cloud project and returns a quota error like any other error. A private working note dated 2026-03-29 already describes three projects and 30,000 units a day. That build sat uncommitted for five months, so the public repo showed the one-project server until I pushed 4b095f1 on 2026-09-01. The lesson my working notes record: “plan quota before features.”
How I knew it worked
There are no automated tests. package.json has a test script that runs Jest, but Jest isn’t a dependency and the repo has no test files.
My private working notes log one reorganization as usage, on 2026-03-27: a 156-video playlist split into five topic playlists, three smaller ones folded into others, one created, outdated videos removed. They record the outcome, not the calls. On 2026-07-28 they mark the server complete and in daily use.
On 2026-10-01 the repo had two stars, no issues, and no releases or tags.
What I’d change
Page. get_playlist_items and the two playlist listers stop at 50 results and take no page token. On a 156-video playlist the model gets 50 items and a totalResults of 156. It can see the list is short. It can’t ask for the rest. I don’t know how the March session got past the 50-item cap.
Treat a 403 as a 403. ytCall counts every 403 as quota. A 403 for any other reason walks the server through every project with a token, leaves it on a different one, and only then reaches the model. I’d match the quota reason and pass everything else through.
Rotate everything. search_music_videos, the most expensive call at 100 units, uses the API-key client and skips ytCall. So does get_video_details. Neither rotates.
Errors that name the next call. In the first version, six of eight auth errors told the model to call authenticate_youtube with getAuthUrl=true. In the current version, one names the tool. None sets isError, in either version, so the model receives a failed call as an ordinary result.
Fix the human docs. The README still lists 13 tools and the old single token file. It also swaps list_playlists and list_channel_playlists. The model reads the schemas and isn’t misled; a person reading the README is. The names invite the swap. I’d rename list_playlists to list_my_playlists.
Links
- Source on GitHub: the server and its README.
- Authentication runbook: OAuth, re-authentication, adding a project.
- Commit 4b095f1: the multi-project rotation rework.