youtube-studio-mcp
by jaimebg
README.md
# YouTube Studio MCP
An MCP server for auditing and improving a YouTube channel's discoverability.
It connects any MCP-capable AI agent to your own channel's data: catalog, Analytics API
metrics, retention curves, inbound search terms, and the impressions and click-through rate
that only a Studio CSV export exposes. It then ranks what is worth fixing by **recoverable
views** rather than by click-through rate — see [How underperformers are
ranked](#how-underperformers-are-ranked).
Eight tools: `auth_status`, `list_videos`, `get_video`, `query_analytics`,
`get_search_terms`, `get_retention_curve`, `import_studio_data`, and
`find_underperformers`.
Everything is read-only and local: the SQLite cache, your OAuth tokens, and your Studio
exports never leave your machine.
## Requirements
- Node.js ≥ 22
- A Google account that owns the YouTube channel
## Setup
### 1. Create a Google Cloud project and enable the APIs
1. Go to <https://console.cloud.google.com/> and create a project.
2. Enable **YouTube Data API v3** and **YouTube Analytics API**.
(Both are in active use: the Data API backs catalog sync and `list_videos`/
`get_video`, and the Analytics API backs `query_analytics`, `get_search_terms`,
and `get_retention_curve`. Google Cloud Console only lets you add a consent-screen
scope for an API you've enabled, so enable both before the next step.)
### 2. Configure the OAuth consent screen
1. Go to **APIs & Services → OAuth consent screen**.
2. Choose **External** and fill in the required fields.
3. Add these scopes:
- `https://www.googleapis.com/auth/yt-analytics.readonly`
- `https://www.googleapis.com/auth/youtube.readonly`
- `https://www.googleapis.com/auth/youtube.force-ssl`
> **Important — publish the app to Production.**
> While the app is in *Testing*, Google expires refresh tokens after **7 days**,
> so you would have to re-authenticate every week. Click **Publish app**.
> The app stays *unverified*, which is fine: you are the only user and you are
> accessing your own data. You will see an "unverified app" warning once —
> choose **Advanced → Go to (app name)**.
### 3. Create the OAuth client
1. **APIs & Services → Credentials → Create credentials → OAuth client ID**.
2. Application type: **Desktop app**.
3. Download the JSON.
### 4. Install and authenticate
```bash
npm install
npm run build
```
Save the downloaded OAuth client JSON as `credentials.json` in the server's
config directory (create the directory first if it doesn't exist):
```bash
# Linux/macOS — adjust the source filename to match what Google actually
# named your download (it starts with "client_secret_")
mkdir -p ~/.config/youtube-studio-mcp
mv ~/Downloads/client_secret_*.json ~/.config/youtube-studio-mcp/credentials.json
```
```powershell
# Windows (PowerShell) — same caveat about the source filename
New-Item -ItemType Directory -Force "$env:USERPROFILE\.config\youtube-studio-mcp" | Out-Null
Move-Item "$env:USERPROFILE\Downloads\client_secret_*.json" "$env:USERPROFILE\.config\youtube-studio-mcp\credentials.json"
```
Then run:
```bash
node dist/index.js auth
```
This opens your browser automatically. Authorize there, and the tokens are saved to
`<config dir>/tokens.json`. On Linux/macOS the file is written with owner-only
permissions (`chmod 600`); Windows has no equivalent file-permission bits, so
that step is a no-op there — rely on your user account's normal file
protections.
If no browser opens, the command also writes the authorization link to
`<config dir>/authorize-url.txt` — open that file and click the link. Do **not**
hand-copy the URL out of your terminal: it is ~520 characters, wraps across
several lines, and a truncated copy fails at Google with the misleading error
`Required parameter is missing: response_type` (the missing parameter is in the
part that got cut off, not in the request we build).
Set `YTMCP_HOME` to override the config directory (e.g. for a second channel
or a test setup). It replaces the whole `~/.config/youtube-studio-mcp`
path, so `credentials.json`, `tokens.json`, and the SQLite cache all move
with it.
### 5. Register the server with your AI agent
The server speaks standard MCP over stdio, so any MCP-capable client can run it. You need
one thing in every case: the **absolute path** to `dist/index.js` in this repo.
Most clients share the same JSON shape. Substitute your own path:
```json
{
"mcpServers": {
"youtube-studio": {
"command": "node",
"args": ["/absolute/path/to/youtube-studio-mcp/dist/index.js"]
}
}
}
```
| Agent | Where that JSON goes |
|---|---|
| **Claude Code** | `claude mcp add youtube-studio -- node /absolute/path/to/dist/index.js` |
| **Claude Desktop** | `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) · `%APPDATA%\Claude\claude_desktop_config.json` (Windows) |
| **Cursor** | `~/.cursor/mcp.json` for all projects, or `.cursor/mcp.json` inside one |
| **Windsurf** | `~/.codeium/windsurf/mcp_config.json` |
| **Cline** | the extension's `cline_mcp_settings.json`, via **MCP Servers → Configure** |
| **Continue** | `~/.continue/config.yaml` (or `config.json`) |
| **Gemini CLI** | `~/.gemini/settings.json` |
| **Zed** | `settings.json`, under `context_servers` |
Two clients use a different shape.
**VS Code / GitHub Copilot** — `.vscode/mcp.json`, keyed `servers`, not `mcpServers`:
```json
{
"servers": {
"youtube-studio": {
"type": "stdio",
"command": "node",
"args": ["/absolute/path/to/youtube-studio-mcp/dist/index.js"]
}
}
}
```
**OpenAI Codex CLI** — `~/.codex/config.toml`, TOML rather than JSON:
```toml
[mcp_servers.youtube-studio]
command = "node"
args = ["/absolute/path/to/youtube-studio-mcp/dist/index.js"]
```
Restart the agent after editing its config. Ask it to run `auth_status`: it should name
your channel and report remaining quota. If it reports `Authenticated: NO`, re-run
`node dist/index.js auth`.
If your client is not listed, look for "MCP" in its settings — the command and args above
are all any of them need. Config paths do move between releases, so check your client's
own docs if a path here does not exist.
### A note on agent behaviour
Every tool is annotated `readOnlyHint: true`, so agents that surface that hint will not
prompt for write confirmation. Nothing in this server modifies your channel — writing
metadata back is a later stage.
`list_videos` serves from the local cache unless asked to sync, and `find_underperformers`
and `import_studio_data` never touch the network at all. Only explicit syncs and Analytics
queries spend quota, which matters because agents explore: an agent that calls
`list_videos` twenty times costs nothing, while twenty catalog syncs would exhaust a day's
budget. `auth_status` reports what is left.
## Tools
| Tool | Purpose |
|---|---|
| `auth_status` | Connection state, channel identity, remaining quota, local cache size |
| `list_videos` | List and filter the catalog; `sync: true` refreshes from the API |
| `get_video` | Full cached metadata and statistics for one video |
| `query_analytics` | Escape hatch onto the Analytics API — arbitrary metrics, dimensions, filters |
| `get_search_terms` | The search queries that brought viewers in, cross-referenced against the video's metadata |
| `get_retention_curve` | Where viewers stop watching, as annotated drop-offs rather than raw points |
| `import_studio_data` | Import impressions and CTR from a Studio CSV export — the one metric the Analytics API does not expose. Reads a local file; no authentication or quota needed |
| `find_underperformers` | Rank the catalog by recoverable views — impressions times the gap to the channel's impression-weighted CTR baseline. Needs a Studio export imported first; reads local data only |
`list_videos` serves entirely from the local SQLite cache unless you pass
`sync: true` — plain reads (filtering by Shorts/long-form, view count,
publish date, title, or sorting) cost no quota. If nothing has been synced
yet, it tells you to call it again with `sync: true` instead of returning an
empty list.
`get_search_terms` and `get_retention_curve` cache their results per date
window (see [Quota](#quota) below); `query_analytics` does not cache and
always makes a live call. `get_search_terms` returns at most **25** rows —
Google caps the underlying report there, so a wider date range changes which
terms rank in the top 25 rather than how many rows come back.
## Quota
YouTube grants 10,000 units/day plus a separate 100 `search.list` calls/day.
The server tracks both and holds back a reserve (500 units, 10 search calls) so a
bulk operation cannot leave interactive tools unusable. **Quota resets at midnight
Pacific**, which is what `auth_status` reports.
A full catalog sync (`list_videos` with `sync: true`) makes one `channels.list`
call, then pages the uploads playlist (`playlistItems.list`, 50 videos per
page) and fetches video details in batches (`videos.list`, 50 IDs per call),
each call costing 1 unit. That works out to `1 + ceil(videos/50) + ceil(videos/50)`
units — about 5 units for a 100-video channel.
The YouTube **Analytics** API has its own per-project quota in Cloud Console, separate from
the Data API's 10,000 units. Analytics calls are recorded in the local ledger at zero unit cost, so
`auth_status` will not show them draining your Data API budget.
Search-term results and retention curves are cached per date window, because the underlying
reports return a ranked top-N over a range rather than per-day rows. A repeat call with the same
dates serves from the cache; pass `refresh: true` to re-query.
## Importing impressions and CTR
`impressions` and `impressionClickThroughRate` do not exist in the YouTube Analytics API —
they are Studio-only. To get them:
1. YouTube Studio → **Analytics** → **Advanced mode** (top right)
2. Make sure the **Impressions** and **Impressions click-through rate** columns are visible —
the export contains only the columns currently on screen
3. **Export** → **Comma-separated values (.csv)** — you get a zip containing three files
4. Unzip it, then run `import_studio_data` with the folder path
`import_studio_data` only reads a file from disk — it never calls the YouTube Data API or the
Analytics API, so it needs no authentication and costs no API quota.
The date window is read from the folder name (Studio names it like
`Contenido 2010-01-26_2026-08-09 Channel`). To override it, pass **both** `rangeStart` and
`rangeEnd` (`YYYY-MM-DD`) — supplying only one is rejected with a validation error rather than
silently falling back to the folder-name window, since that could land data under the wrong
dates with no warning. Both dates must be real calendar dates (`2026-13-45` is rejected, not
rolled over) and `rangeStart` must not be after `rangeEnd`.
**Impressions and CTR are a whole-range aggregate.** Of the three files in the export, only
the per-video table (`Datos de la tabla.csv` / `Table data.csv`) carries impressions and CTR,
and it reports one row per video summed over the entire date range — there is no daily CTR
anywhere in the export. The per-day file (`Datos del gráfico.csv` / `Chart data.csv`) and the
channel-totals file (`Totales.csv` / `Totals.csv`) carry views only. So a comparison across
time means importing several exports with different ranges, not slicing one.
`import_studio_data` accepts either the export folder or a specific CSV path. Point it at the
folder and it finds the table file automatically. Point it at one of the other two files
directly and the import is rejected outright: a CSV's header tells you unambiguously which of
the three reports it is (`reportType` is `table`, `chart`, or `totals` — see
`src/studio/csvSchemas.ts`), and only `table` carries anything this importer can store. The
chart file does have a video id, so a naive import would silently succeed while overwriting
impressions/CTR with `NULL` and views with the last day's figure instead of the range total;
the totals file has no video id at all. Both are rejected before anything is written, with a
message naming `Datos de la tabla.csv` / `Table data.csv` as the file to point at instead.
Rows for videos that are no longer public are stored and reported as unmatched; that is
expected, not an error.
## Shorts
A video counts as a Short only if it is **180 seconds or shorter** *and* was published on or
after **2020-09-14**, the day Shorts launched.
Duration alone is not enough. On a catalog of naturally short long-form video — music videos,
edits, trailers — a duration-only rule misclassifies wholesale. Live verification against a
dormant pre-2020 channel flagged roughly 70% of its catalog as Shorts, every one a false
positive: the channel's newest upload predated the Shorts launch by months, so not one of
them could have been genuine.
`find_underperformers` reads this flag through its `cohort` parameter. Passing `cohort:
'short'` or `cohort: 'long'` restricts the baseline to that half of the catalog, so Shorts
and long-form are compared only against their own kind. The default, `cohort: 'all'`, does
not do that segmenting — it pools both into a single blended baseline. On a catalog that is
one cohort already (this user's real case, entirely long-form) pooling is a no-op, but on a
mixed catalog the default blends two populations with different typical CTRs; pass `cohort`
explicitly to segment them. A wrong flag on a video would produce confident nonsense rather
than an obvious error, which is why the publish-date guard matters.
## How underperformers are ranked
`find_underperformers` ranks by **recoverable views**, not by click-through rate:
```
recoverable views = impressions x (baseline CTR - video CTR) / 100
```
That is an estimate of the views a video would have gained at the channel's own baseline —
the quantity worth acting on. Ranking on CTR alone is misleading, in three specific ways:
- **Impressions concentrate.** Most of a channel's impressions sit in a small fraction of its
videos, so "worst CTR" and "biggest opportunity" are close to disjoint sets. The video with
the ugliest ratio is often one almost nobody was shown.
- **Zero impressions produce a 0% CTR by division, not by performance.** Sorting ascending
puts every never-surfaced video at the top of the list of things to fix, which is exactly
backwards.
- **The highest CTR on a channel is usually a tiny denominator** — a handful of impressions
that happened to convert. It is noise presented as a triumph.
So two rules follow. Videos below an impressions floor are reported as **insufficient data**
and never ranked as poor performers. And the baseline is **impression-weighted**, because an
unweighted mean is dominated by low-traffic videos and describes almost none of the traffic
the channel actually gets.
Each opportunity is typed. `weak_metadata` means the metadata score is low enough that it is
the thing to fix first; `low_ctr` means the metadata is already sound and the thumbnail or
title framing is the lever.
## Development
```bash
npm test # unit tests, no network
npm run typecheck
npm run build
```
`npm run typecheck` runs two projects: `tsconfig.json` (`src/**`, the build)
and `tsconfig.test.json` (`src/**` + `test/**` + `vitest.config.ts`, `noEmit`
only). Run just the test project with `npm run typecheck:test`. Vitest itself
only strips types via esbuild and does not type-check, so `npm run typecheck`
is what actually catches a type error in a test file.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues