YouTube MCP Server
A single-process MCP server that exposes public YouTube data —
transcripts, search, video metadata and statistics, comments, channels, categories — as tools an LLM
can call. It speaks stdio: any local agent (Claude Desktop, an editor plugin, a script) launches
youtube-mcp as a subprocess and gets the tools over stdin/stdout. No network exposure, no auth,
no account — just a YouTube Data API key.
One Python process: FastMCP server, YouTube Data API client, transcript fetching and cache all live in the same application. No sidecar, no second service, no Redis.
- 11 tools, all namespaced
youtube_*, read-only public data. - stdio by default. HTTP is available for a shared/deployed instance — see Optional: HTTP mode / container.
- API-key only. No OAuth, no uploads, no channel management.
- Dislike counts do not exist. YouTube made them private in December 2021; no endpoint returns them, and this server will not estimate them.
Quick start
git clone <this repo> && cd youtube-mcp
uv sync # creates .venv from uv.lock
export YOUTUBE_API_KEY=... # required — the server fails fast without it
uv run youtube-mcp # speaks MCP on stdin/stdout
Point an MCP client at it:
{
"mcpServers": {
"youtube": {
"command": "uv",
"args": ["run", "--directory", "/path/to/youtube-mcp", "youtube-mcp"],
"env": { "YOUTUBE_API_KEY": "your-key-here" }
}
}
}
The console script dispatches on MCP_TRANSPORT, which defaults to stdio:
uv run youtube-mcp # stdio (default)
MCP_TRANSPORT=http uv run youtube-mcp # streamable HTTP on 0.0.0.0:8088
The cache is a local SQLite file (DATABASE_PATH, default cache.db in the working directory).
It is ephemeral by design: deleting it costs quota, never correctness.
Read this first: YouTube blocks cloud IPs
Transcripts are not available through the official API — captions.download requires OAuth consent
from the video's owner. Anything fetching arbitrary transcripts is scraping YouTube's internal
timedtext endpoint, and YouTube blocks most known cloud-provider IP ranges (AWS, GCP, Azure).
Symptoms are RequestBlocked / IpBlocked, surfaced by this server as TRANSCRIPT_IP_BLOCKED
(retryable, transient).
Transcript fetching therefore needs a residential/clean IP. From a cloud VM it will fail until a proxy is configured — budget for a rotating residential proxy before moving this to a VPS, not after. The server ships with no proxy configured.
Proxy configuration is exposed through environment variables, unset by default:
| Variables | Behaviour |
|---|---|
WEBSHARE_PROXY_USERNAME / WEBSHARE_PROXY_PASSWORD |
Webshare rotating proxy, biased to filter_ip_locations=["pt","es"] to limit added latency |
HTTP_PROXY / HTTPS_PROXY |
Generic proxy alternative |
Neither guarantees success — that caveat is the upstream library's, not a hedge. Data API calls
(youtube_search_videos and friends) use the official API and are not affected by this; only
transcripts are.
Tools
| Tool | What it does | Quota bucket |
|---|---|---|
youtube_get_transcript |
Caption text for one video; timestamps off by default | none (scrape, not Data API) |
youtube_get_timestamped_transcript |
Same, as {text, start, duration} segments for chaptering/deep links |
none |
youtube_list_transcript_languages |
Caption tracks available for a video, and whether each is auto-generated | none |
youtube_search_in_transcript |
Case-insensitive substring search inside a transcript, server-side | none |
youtube_search_videos |
Search by keyword | scarce: 100 calls/day, own bucket |
youtube_get_video |
Video metadata by ID — snippet, statistics, duration, status | shared (10,000/day) |
youtube_get_video_stats |
View/like/comment counts for up to 50 IDs | own 10,000/day bucket via videos:batchGetStats |
youtube_get_comments |
Top-level comments, time or relevance order, pageable |
shared (10,000/day) |
youtube_get_channel |
Channel by ID or @handle, plus its uploads playlist ID |
shared (10,000/day) |
youtube_list_channel_videos |
Channel uploads, newest first, via the uploads playlist | shared (10,000/day) |
youtube_list_categories |
Video categories, optionally per region | shared (10,000/day) |
Notes that the tool descriptions also carry, because the model is the main consumer:
- No dislikes anywhere. Not on videos, not on comments, not on
batchGetStats. Tool descriptions say so explicitly so the model stops asking. - Comments are top-level only in this version. Replies are not returned — and neither are their counts. Only top-level comments come back, so there is no reply text and no reply count to present; never imply a reply was read.
- Transcripts are unavailable for some videos, and that is normal. Captions disabled by the
uploader (
TRANSCRIPT_DISABLED), no track in the requested language (TRANSCRIPT_NOT_FOUND), age-restricted due to broken upstream cookie auth inyoutube-transcript-api1.2.4 (TRANSCRIPT_AGE_RESTRICTED), or YouTube demanding a PO token (TRANSCRIPT_UPSTREAM_ERROR). None of these are outages; onlyTRANSCRIPT_IP_BLOCKEDandTRANSCRIPT_UPSTREAM_ERRORare worth retrying. youtube_list_channel_videosdeliberately resolves the channel's uploads playlist and pagesplaylistItemsinstead of searching — 2 units from the large shared pool rather than 100/day.youtube_search_videosis the scarce, deliberate operation.- Transcripts are truncated at
RESPONSE_LIMITby cumulative characters (both variants) and returnnext_cursor; an uncapped 3-hour transcript would blow the model's context window.
Quota
Three buckets, because Google counts them separately:
| Bucket | Budget | Consumed by |
|---|---|---|
search |
100 calls/day | search.list, one unit per call — including each additional page |
stats |
10,000 calls/day | videos:batchGetStats only |
shared |
10,000 units/day | videos.list, channels.list, playlistItems.list, commentThreads.list, videoCategories.list |
- Quotas reset at midnight Pacific Time (
America/Los_Angeles, DST-aware). Not midnight UTC, not midnight local. A403 quotaExceededmeans done for the day — the error message says "resets midnight Pacific", and the model is told not to retry until then. youtube_get_video_statsusesvideos:batchGetStatsspecifically because that method has its own 10,000/day bucket and returns up to 50 videos per call. Pulling counts throughyoutube_get_videowould burn the shared pool instead.- The server keeps its own per-bucket counter and warns in the log at 10% remaining. It is
approximate — the authoritative accounting is Google's, and counters reset to zero on restart,
deliberately, so a restart can never over-count. The API does not tell you which bucket tripped,
which is why we count locally; the
/healthroute (HTTP mode only) exposes this accounting asquota_remaining_approximate. - A local refusal (
QuotaExceeded) happens before the HTTP call, so a rejected call costs nothing.
Do not rotate API keys to get more quota. Creating multiple Google Cloud projects for the same API service or use case to acquire more quota than your project was assigned is prohibited by YouTube's Developer Policies; Google has issued warnings to developers doing it. Keys within one project share a quota, so rotation requires separate projects — which is exactly the prohibited pattern. This server does not implement rotation and will not accept a key list.
The legitimate path for more quota is the
YouTube API audit / quota extension form.
Until then: cache aggressively, treat youtube_search_videos as scarce, and prefer
youtube_list_channel_videos / youtube_search_in_transcript.
Configuration
Read once at startup with pydantic-settings (.env is honoured if present). Environment variable
names map case-insensitively onto field names. The server fails fast: a missing YOUTUBE_API_KEY
raises at startup, so a misconfigured launch dies immediately rather than on the first tool call.
| Variable | Default | Notes |
|---|---|---|
YOUTUBE_API_KEY |
(required) | Single key. Parsed as a SecretStr, so it never appears in logs, reprs or tracebacks. |
YOUTUBE_TRANSCRIPT_LANG |
en |
Default transcript language when a tool call does not name one. |
MCP_TRANSPORT |
stdio |
stdio or http. Selects the transport in the youtube-mcp console script. |
MCP_HOST |
0.0.0.0 |
HTTP listen address (HTTP mode only). |
MCP_PORT |
8088 |
HTTP listen port (HTTP mode only). |
FASTMCP_STATELESS_HTTP |
true |
See HTTP mode — leave it true for replicas. |
RESPONSE_LIMIT |
50000 |
Transcript truncation threshold, in characters. Cumulative-character policy, same for both variants. |
CACHE_TTL_SECONDS |
3600 |
TTL for cached video statistics, in seconds. 0 means never expires, not "zero seconds" — see Caching. |
DATABASE_PATH |
cache.db |
SQLite cache file. Relative by default — set an absolute path in a container. |
WEBSHARE_PROXY_USERNAME / WEBSHARE_PROXY_PASSWORD |
unset | Webshare proxy for transcript fetching; password is a SecretStr. |
HTTP_PROXY / HTTPS_PROXY |
unset | Generic proxy alternative. |
LOG_LEVEL |
INFO |
Level for the server and application loggers. |
Never commit a real API key or proxy credential to this repository.
Caching
SQLite via aiosqlite — one file, no extra service. Redis is not worth a tenant for this.
| Cached | Key | TTL |
|---|---|---|
| Transcripts | transcript:<video_id>:<lang> (and …:styled) |
forever — a published transcript does not change |
| Transcript track lists | transcript_tracks:<video_id> |
forever |
| Channel → uploads playlist | uploads_playlist:<channel_id> |
forever — it never changes |
| Video stats | video_stats:<video_id> |
CACHE_TTL_SECONDS (default 3600s; 0 = never expires) — view counts move |
Why the split: transcripts and playlist mappings are immutable — a published transcript is
published, and a channel's uploads playlist ID is assigned once — so re-fetching them can only ever
return the same bytes. Expiring them would buy nothing and cost quota. Video statistics are the one
genuinely mutable thing here, so they are the one entry with a real TTL, and that TTL is the
operator's CACHE_TTL_SECONDS (the tool description quotes the value in force).
Transcript and stats cache hits cost no quota at all, which is the point: caching is the primary quota strategy, not an optimisation. Cache values are JSON, expiry is per entry, and expired rows are deleted on read. Nothing depends on the cache surviving a restart — a cold cache costs quota, not correctness.
Development
uv sync # dev dependencies included
uv run pytest # full suite, offline
uv run python -c "from youtube_mcp.server import create_app; print('importable')"
The suite is fully offline: Data API responses come from recorded JSON fixtures in
tests/fixtures/, and transcript fetching is stubbed. No test hits the live API, so a test run costs
no quota and works with the network unplugged. Coverage is on by default through pyproject.toml
(--cov=youtube_mcp); run uv run pytest --cov-report=html for the browsable report.
CI / development tasks
The repo ships a root Taskfile.yaml (go-task) so the gates are one command
each instead of remembered incantations. Run it as task <name> if go-task is installed, or through
uv — which works anywhere uv exists and needs no separate install:
uvx --from go-task-bin task <name> # go-task has no PyPI package called `go-task`
uvx --from go-task-bin task --list # what is available
| Task | What it runs |
|---|---|
install |
uv sync — dev dependencies included |
run |
uv run youtube-mcp — stdio server for a local agent; task run MCP_TRANSPORT=http for the HTTP transport |
lint |
ruff check . — the rule set is in [tool.ruff.lint] |
fmt / fmt-check |
ruff format . / ruff format --check . (the latter never mutates) |
typecheck |
mypy, strict on src/youtube_mcp |
test |
uv run pytest — the offline suite |
coverage |
same suite with --cov-report=term-missing named explicitly |
lock-check |
uv lock --check — fails if uv.lock drifted from pyproject.toml |
check |
lint + fmt-check + lock-check + test — the local gate |
docker-build |
builds youtube-mcp:<tag>, where <tag> is git describe --tags --always (a bare SHA on an untagged checkout, dev if that fails too) |
default |
task -l — what a bare task runs |
check is the only gate; it is docker-free, so it stays fast and works offline. docker-build is
separate because docker is the one slow, network-touching action here.
Layout:
src/youtube_mcp/
├── server.py # FastMCP instance, tool registration, create_app factory, /health
├── config.py # pydantic-settings, one model, read once
├── cache.py # aiosqlite TTL cache
├── youtube/ # client.py (httpx), quota.py (per-bucket counters), models.py
├── transcript/ # fetch.py (library wrapper + thread offload), errors.py (taxonomy)
└── tools/ # data.py, transcripts.py, errors.py — one module per tool group
tests/ # pytest + recorded fixtures
deploy/ # Dockerfile
Taskfile.yaml # the development tasks documented above
Manual smoke test
Needs a real API key and, for the transcript cases, a residential IP.
export YOUTUBE_API_KEY=...
uv run youtube-mcp # stdio — point an MCP client at it
# or, in HTTP mode:
MCP_TRANSPORT=http uv run youtube-mcp &
curl -s localhost:8088/health | jq
Then, with an MCP client pointed at http://localhost:8088/mcp (the MCP Inspector works the same
way), or straight from the CLI:
uv run fastmcp list http://localhost:8088/mcp --transport http # should list all 11 tools
For stdio, the same call is uv run fastmcp list --command "env YOUTUBE_API_KEY=$YOUTUBE_API_KEY uv run youtube-mcp",
or drive the server from any client that already speaks it. The env prefix is required: the MCP
SDK spawns stdio children with a sanitized environment, so an exported YOUTUBE_API_KEY never
reaches the server and it would fail fast on the key check. Clients configured with JSON pass env
themselves and need no wrapper.
- Known captions video —
youtube_get_transcriptwithdQw4w9WgXcQ. Expect text, alanguage_code, andtruncated: false. Repeat the call: it should be served from cache and cost nothing. - Captions-disabled video — call
youtube_get_transcripton a video with captions turned off. Expect theTRANSCRIPT_DISABLEDerror path: one line, no traceback, and no retry hint (it is not retryable). This is normal, not an outage. - Stats —
youtube_get_video_statswith two IDs including one bogus ID. Expect the real video's counts plus the bogus ID infailed_video_ids— partial success, not an error. - Quota path — exhaust or simulate the search bucket, then call
youtube_search_videos. The error must surfacequotaExceededand mention "resets midnight Pacific". If it does not, the model will keep retrying and burn the day's budget. - IP-block check (only relevant from a cloud host) — a transcript call from a blocked IP should
return
TRANSCRIPT_IP_BLOCKED, retryable, mentioning the proxy.
Optional: HTTP mode / container
Only needed when the server is shared by several clients or deployed as a long-lived process. The tool surface is identical in both transports; nothing is HTTP-only.
MCP_TRANSPORT=http uv run youtube-mcp
# or, equivalently, uvicorn against the factory:
uv run uvicorn youtube_mcp.server:create_app --factory --host 0.0.0.0 --port 8088
- MCP endpoint:
http://<host>:8088/mcp(streamable HTTP). The app is a factory with no module-levelappobject, and there is deliberately no import-time settings read — soimport youtube_mcp.serverworks without an API key, which is what makes the factory testable. - Health probe:
GET /healthreturns{"status": "ok", "server", "version", "quota_remaining_approximate"}. No auth, no external calls, so it is safe as both a liveness and a readiness probe. stateless_http=True(the default) is what makes replicas work. The stateful transport keeps sessions in server memory, which breaks across replicas, and sticky sessions do not reliably fix it: most MCP clients usefetch()internally and never forwardSet-Cookie. Stateless HTTP makes each request independent, so any replica can serve any request. SetFASTMCP_STATELESS_HTTP=falseonly for a single-replica debugging session.- Reverse proxies: SSE streaming still needs
proxy_buffering off; proxy_cache off;proxy_http_version 1.1,Connection '', and generous read/send timeouts (the docs use 300s). Stateless mode removes the session-affinity problem but does not remove streaming buffering concerns — a buffering proxy will still stall a long tool call. - No auth. Run it on localhost, or behind whatever network boundary you already trust. If it is
ever exposed more broadly, add a FastMCP
StaticTokenVerifieron the/mcproute.
Container
Build from the repository root, where the .dockerignore and sources are:
docker build -f deploy/Dockerfile -t youtube-mcp:dev .
Two stages: a builder that runs uv sync --frozen --no-dev --compile-bytecode into /app/.venv,
and a python:3.13-slim runtime that copies only the venv (/app/.venv) and the package source
(/app/src). No uv, no build tooling, no test dependencies in the final image. The image runs as a
non-root user (app, uid/gid 10001, overridable at build time via APP_UID/APP_GID).
The entrypoint is the youtube-mcp console script, which dispatches on MCP_TRANSPORT. The image
sets no transport of its own, so it defaults to stdio — the same default as a local run. Run it
with -i and the agent on the other end drives it over stdin/stdout; with YOUTUBE_API_KEY set and
no piped stdin the server reads EOF and exits 0 immediately (correct MCP stdio behaviour, not a
crash), while a keyless run fails fast with exit 1 before EOF matters. Set
MCP_TRANSPORT=http to serve it as a long-running HTTP service instead (/health answers):
# stdio (default in the image) — `-i` keeps stdin open; EOF ends the server
# (an MCP client launches it this way; see the JSON config above)
docker run -i --rm -e YOUTUBE_API_KEY=... youtube-mcp:dev
# HTTP — explicit `MCP_TRANSPORT=http`, long-running, /health on the published port
docker run --rm -p 8088:8088 -e YOUTUBE_API_KEY=... -e MCP_TRANSPORT=http youtube-mcp:dev
docker run --rm -p 9000:9000 -e YOUTUBE_API_KEY=... -e MCP_TRANSPORT=http -e MCP_PORT=9000 youtube-mcp:dev
DATABASE_PATH=/data/cache.db is the image default, and /data exists and is writable by the
non-root user. No volume is needed: the cache is ephemeral by design and a cold cache costs
quota, not correctness. To keep it across restarts, point DATABASE_PATH at a mounted volume (or
mount one at /data) — that is the persistent variant, and it is the only reason to add a volume.
A local build of this Dockerfile measured ~212 MB on disk / ~78 MB compressed (docker save | gzip -1), against a ~42.6 MB python:3.13-slim base. Treat that as indicative, not a budget —
CI measures the real number.
Non-goals for v1
Do not build these without being asked — explicitly out of scope:
- Video upload, editing or deletion.
- Caption upload or management (needs OAuth and ownership).
- Analytics API, Reporting API, or any channel-owner data.
- OAuth of any kind.
- Multi-key quota rotation (a policy violation — see Quota).
- Dislike counts.
- Downloading video or audio media.
- Live chat retrieval.
- Any UI. This is a tool provider.
Decisions
Answered, for the record (previously open questions):
- Transport. stdio is the default for a local agent; HTTP is the opt-in for shared/deployed use.
The container image keeps that same stdio default and takes HTTP as an explicit
MCP_TRANSPORT=http. - Auth. None. This is a local stdio server; HTTP mode is expected to sit behind a trusted boundary.
- Cache persistence. Ephemeral. No volume in the container; set
DATABASE_PATHto a mounted path if you want the warm-cache variant. - Proxy. Unconfigured — correct for a residential/clean IP. Configure Webshare or a generic proxy if the server ever runs from a cloud host, or transcripts will be blocked.
References
- FastMCP docs — https://gofastmcp.com
- YouTube Data API errors — https://developers.google.com/youtube/v3/docs/errors
- Quota costs — https://developers.google.com/youtube/v3/determine_quota_cost
videos:batchGetStats— https://developers.google.com/youtube/v3/docs/videos/batchGetStats- Developer Policies (quota) — https://developers.google.com/youtube/terms/developer-policies-guide
youtube-transcript-api— https://github.com/jdepoix/youtube-transcript-api
Metadata
Release files for dreadster3-youtube-mcp 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| dreadster3_youtube_mcp-0.1.0.tar.gz | 198.2 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| dreadster3_youtube_mcp-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 250.5 kB
Release files / dreadster3_youtube_mcp-0.1.0.tar.gz
| Download URL | dreadster3_youtube_mcp-0.1.0.tar.gz |
|---|---|
| Size | 198.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
bf4ccf66342be2b73f3608b669dd247324d33e6a6afac50e852cf578312ffa23
|
|
BLAKE2b-256 checksum How to use checksums |
278b54920b29cdfd9835f51735361b74640ab63d7c7a9f3b8068724a3cb89b65
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 4, 2026.
Transparency logRelease files / dreadster3_youtube_mcp-0.1.0-py3-none-any.whl
| Download URL | dreadster3_youtube_mcp-0.1.0-py3-none-any.whl |
|---|---|
| Size | 52.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
43b6dc429b46fc2614035f71ed9c8fac1cef091ee47dc0da3b9cbdb1791f8476
|
|
BLAKE2b-256 checksum How to use checksums |
6a074b2b75472aaafa3cc7c01100d431c07b65215041722001b0c07d14e6eafa
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 4, 2026.
Transparency log