Drawing 06

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.
constraint

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_items returns each entry’s playlistItemId next to its videoId. add_to_playlist(playlistId, videoId) inserts. remove_from_playlist deletes by playlistItemId. 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_playlist is 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_youtube takes getAuthUrl, authCode and projectIndex, 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_playlists had a mine flag. Its own error text offered mine=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_playlist defaults privacy to private. edit_playlist reads the playlist first and keeps any field the model left out; it refuses a call that sets none of the three fields. The description of delete_playlist starts 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 in state.json, and retries. If it can’t, get_project_status shows which projects hold tokens and switch_project overrides by projectIndex.

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.

YouTube MCP tool-call pathClaude Code, the MCP client, calls tools on the MCP server. The emphasised path runs from Claude Code to three tools: get_playlist_items with playlistId and maxResults, add_to_playlist with playlistId and videoId, and remove_from_playlist with playlistItemId. All three pass through ytCall, which on a 403 switches to the next project and retries, then reaches the YouTube Data API v3: 10,000 units a day per project, 50 per write, 1 per read. ytCall reads and writes local state in the .youtube-mcp folder: credentials.json, a token file per project, and state.json. Off the emphasised path, Claude Code also calls authenticate_youtube with getAuthUrl, authCode and projectIndex; the model passes the auth URL to the person in a browser and gets the code back.Claude Codethe modelMCP clientyou, in abrowserURL out,code backMCP server · 15 tools1 get_playlist_itemsplaylistId, maxResults2 add_to_playlistplaylistId, videoId3 remove_from_playlistplaylistItemIdytCall: on a 403,next project, retryauthenticate_youtubegetAuthUrl, authCode, projectIndexcall~/.youtube-mcp/credentials.jsontokens-{n}.jsonstate.jsonYouTubeData API v310,000 units/dayper projectwrite 50, read 1
Fig. 1 A move is three calls. Call 3 needs the playlistItemId that call 1 returned; the model carries it. Every OAuth-authenticated API call passes through ytCall, which rotates Google Cloud projects.
failure

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.

15tools registered in source
4commits, Mar 26 → Sep 1
30,000quota units a day, 3 projects

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.