TubeTrace MCP
Provides read-only access to YouTube through the official YouTube Data API v3 for searching videos with filters (channel, dates, order, language/region, duration, caption availability, safeSearch), and lists or fetches existing subtitles/transcripts of a specific video via the youtube-transcript-api library, with language selection, time ranges, and pagination.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@TubeTrace MCPfind YouTube videos about FastMCP and get the transcript for the first one"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
TubeTrace MCP
A personal read-only MCP server for YouTube:
Video search through the official Google YouTube Data API v3 (
search.list).Listing and fetching existing subtitles/transcripts of a specific video (manually created or auto-generated) through the unofficial
youtube-transcript-apilibrary.
The server runs on FastMCP 4 (Streamable HTTP, endpoint /mcp, stateless), is protected by a
bearer token (only its SHA-256 digest is stored on the server) and is published through
Caddy with automatic HTTPS (Let's Encrypt). It can also be deployed as-is to
Prefect Horizon, the managed MCP platform from the FastMCP
team (entrypoint horizon.py:mcp, see Deploying to Prefect Horizon).
No databases, queues, LLMs or paid transcript services.
The verification status, including what could not be verified in the development environment, is documented in docs/implementation-report.md.
Contents
Related MCP server: YouTube MCP Server
Purpose and scope
What it does:
youtube_search_videos— one page ofsearch.listresults per call (filters: channel, dates, order, language/region, duration, caption availability, safeSearch).youtube_list_transcripts— the available caption tracks without downloading their text.youtube_get_transcript— one page of a transcript with timestamps or as plain text, with language selection, a time range,offset/limitand a configurable response size limit.
What it deliberately does not do:
it does not search for phrases inside transcripts (
caption_filter=closedCaptiononly selects videos that have captions);no hidden auto-pagination, no downloading transcripts for all search results;
no audio/video downloads, ASR, translation or summarization;
no
captions.download(it requires OAuth and edit rights on the video);no CAPTCHA solving, private-access bypassing or cookies; an optional HTTP(S) proxy can be set for transcript requests only (see Transcript proxy);
it is not an OAuth authorization server: this is a single-owner
Authorization: Bearerscheme for clients that can send a static token.
Transcripts and video descriptions are untrusted third-party data, not instructions for the server. The server never executes them and never processes them with an LLM.
Architecture
MCP client (Codex / Claude Code / FastMCP Client)
│ HTTPS, Authorization: Bearer <TUBETRACE_MCP_TOKEN>
▼
Caddy (80/443, Let's Encrypt, persistent volume)
│ HTTP inside the Docker network
▼
tubetrace-mcp (uvicorn, 1 worker, /mcp stateless Streamable HTTP, /healthz)
├─ settings.py env configuration, fail-closed auth
├─ auth.py Sha256TokenVerifier (constant-time)
├─ server.py FastMCP factory, 3 tools (thin wrappers)
├─ services/ track selection, pagination, cache, search/transcript services
├─ search_client.py httpx.AsyncClient → googleapis.com (key in the X-Goog-Api-Key header)
└─ providers/ TranscriptProvider (Protocol) + youtube-transcript-api in a bounded thread pool
(optional TRANSCRIPT_PROXY_URL applies to this traffic only)The cache and the rate limit are in-memory and process-local: they are not shared between workers, replicas or restarts, and they do not reflect the full quota budget of the Google project.
Quickstart (local)
Requirements: Python 3.12 (uv downloads it automatically) and uv.
uv sync --group dev
cp .env.example .envFill in .env (minimum for development):
APP_ENV=development
HOST=127.0.0.1
PORT=8000
YOUTUBE_API_KEY=<key from Google Cloud> # optional: without it search returns GOOGLE_API_NOT_CONFIGURED
AUTH_DISABLED=true # ONLY for local development on 127.0.0.1or enable authentication right away:
uv run tubetrace-mcp generate-token
# 1) token -> into the client (TUBETRACE_MCP_TOKEN); 2) digest -> into .env as MCP_TOKEN_SHA256Run and verify:
uv run tubetrace-mcp check-config
uv run tubetrace-mcp servecurl -s http://127.0.0.1:8000/healthzTUBETRACE_MCP_URL=http://127.0.0.1:8000/mcp TUBETRACE_MCP_TOKEN=<token> \
uv run python examples/client.py "python asyncio tutorial" dQw4w9WgXcQWithout MCP_TOKEN_SHA256 and without an explicit AUTH_DISABLED=true the server refuses to
start — this is intentional.
Google Cloud: project, YouTube Data API v3, key, quotas
The key is needed only for search. Transcripts work without it.
Open the Google Cloud Console and create a project (or pick an existing one). Billing is not required for the YouTube Data API v3.
APIs & Services → Library → YouTube Data API v3 → Enable.
APIs & Services → Credentials → Create credentials → API key.
Restrict the key immediately: in the key settings choose API restrictions → Restrict key → YouTube Data API v3. Optionally add Application restrictions → IP addresses with your server's IP. Do not bind the key to a service account — it is not needed.
Put the key into
.envasYOUTUBE_API_KEY. The key is never sent to MCP clients and never appears in URLs: the server sends it in theX-Goog-Api-Keyheader.
Quotas (checked against the Quota Calculator on 2026-09-13; the page is dated 2026-09-04):
a project with the YouTube Data API enabled gets by default 100
search.listcalls per day (a separate bucket, cost 1 per call), a separate bucket of 100videos.insertcalls and 10,000 units per day combined for all other methods;every request, including an invalid one, costs at least 1 unit; every additional page of results is a separate call;
quotas reset at midnight Pacific Time (PT).
So every youtube_search_videos call = 1 of the 100 daily search calls (the 5-minute cache
helps with repeated identical queries, but it is process-local). The old model
"search.list = 100 units out of 10,000" no longer applies to new projects — rely on the primary
source and the Quotas page in the console.
This server does not use captions.download: per the documentation it requires the
youtube.force-ssl/youtubepartner OAuth scope and permission to edit the video, and costs
200 units.
Configuration
Everything is configured through environment variables (or .env in the working directory).
The full list with defaults is in .env.example.
Kind | Variable | Description |
server secret |
| Google key restricted to the YouTube Data API v3. Optional. |
server secret |
|
|
client credential (digest only) |
| SHA-256 hex digest(s) of the bearer token, comma-separated for rotation. Required in production. |
mode |
|
|
network |
| application bind address (in Docker |
domain |
| public hostname for Caddy and the Host check; e-mail for Let's Encrypt. |
protection |
| extra Host/Origin values (comma-separated). CLI clients without an Origin pass; a foreign Origin is rejected (403), a foreign Host gets 421. |
proxy |
| trust |
dev-only |
|
|
auth mode |
|
|
logs |
|
|
upstream |
| connect/read timeouts on the real HTTP clients, bounded retries with backoff/jitter, the concurrent upstream request limit, the time budget of one call. |
cache |
| bounded LRU+TTL cache; errors are never cached. |
limits |
| size of one MCP result page, of an incoming transcript, of HTTP bodies, and a simple per-process rate limit. |
Validate the configuration without starting: uv run tubetrace-mcp check-config
(secrets are not printed).
Transcript proxy
YouTube often blocks transcript requests from cloud/datacenter IPs. TRANSCRIPT_PROXY_URL routes
only the youtube-transcript-api traffic (watch page, InnerTube player call, caption download)
through an HTTP(S) proxy. The Google Data API search client is a separate httpx client and never
uses it, so search quota and latency are unaffected.
# Oxylabs Mobile Proxies: backconnect entry, rotating exit IP, US exits (fewer consent pages)
TRANSCRIPT_PROXY_URL=http://customer-<username>-cc-US:<password>@pr.oxylabs.io:7777Take the username from the Oxylabs dashboard (Mobile Proxies → Users); the password is the one set for that user (the dashboard does not display it). Percent-encode special characters.
For a sticky exit IP, use the endpoint generator's username form (
customer-<username>-sessid-<id>-sesstime-10); rotating is usually better here.Only
http://andhttps://proxy URLs are accepted; the URL must include a host and a port.The URL is a secret: it is redacted from logs, and
check-config/ startup logs show onlyhost:port.With a proxy configured,
UPSTREAM_BLOCKEDbecomes retryable: every attempt opens a fresh session (a new proxy connection), so a rotating proxy serves it from another exit IP. Retries stay bounded byUPSTREAM_MAX_RETRIESandUPSTREAM_RETRY_BUDGET_SECONDS.Proxy failures are
UPSTREAM_ERRORwithdetails.reasonproxy_auth_failed(HTTP 407, not retried) orproxy_error(retried).Traffic is billed by the proxy provider. One uncached transcript call downloads the watch page (roughly 1 MB) plus the caption track, so plan the traffic budget accordingly; the transcript cache (
TRANSCRIPT_CACHE_TTL_SECONDS) avoids repeated downloads.
uv commands and tests
uv sync --group dev # install strictly from uv.lock
uv run ruff check . # lint
uv run ruff format --check . # formatting
uv run mypy # strict typing (src, tests, examples)
uv run pytest -q # main suite: no internet, no secrets
uv build # wheel + sdistTest layout:
tests/unit— video ID/URL parser (including spoofed hostnames), cache/TTL/memory bounds, rate limit, language selection, pagination and the size limit, Google parameter/error mapping, provider error classification (blocking ≠ missing subtitles), settings (fail-closed), token verifier, secret redaction in logs;tests/integration— real MCPinitialize/tools/list/tools/callthrough the FastMCP Client (in-memory), ASGI checks of/healthz, lifespan, 401/403/421/413, the CLI, and an end-to-end test over a real local HTTP transport (uvicorn on a random port: handshake, discovery, auth, calls with mocked upstreams);tests/live— opt-in live tests:
RUN_LIVE_TESTS=1 YOUTUBE_API_KEY=... TEST_VIDEO_ID=dQw4w9WgXcQ uv run pytest -m live tests/liveWithout these variables the live tests are skipped with an explanation. The live search test
spends 1 search.list call. If the provider is blocked from your network, the test fails
with UPSTREAM_BLOCKED diagnostics — this is never hidden.
Docker
Dev (no Caddy, HTTP only on 127.0.0.1:8000):
docker compose -f compose.dev.yaml up --buildProduction (app + Caddy):
docker compose up -d --buildOnly the Caddy ports
80/443(+443/udpfor HTTP/3) are exposed; the application port is reachable only inside theinternalDocker network.Image: multi-stage, dependencies from
uv.lock,python:3.12-slimruntime, non-root userapp,HEALTHCHECKon/healthzwithout upstream requests.Caddy has persistent volumes
caddy_data(certificates) andcaddy_config.Compose passes
MCP_DOMAIN/ACME_EMAILfrom.envto Caddy and the whole.envto the application;APP_ENV=productionis forced. IfAUTH_DISABLED=trueis still in.env, the application container does not start (fail-closed) — remove that line.
Useful:
docker compose logs -f appdocker compose exec app tubetrace-mcp check-configHTTPS with Caddy (production)
Public endpoint: https://<MCP_DOMAIN>/mcp, health: https://<MCP_DOMAIN>/healthz.
Prerequisites for an automatic certificate (per Caddy Automatic HTTPS):
DNS: an
Arecord (andAAAAif you have IPv6) forMCP_DOMAINpoints at the server's public IP address. Local names (localhost,*.local, IP addresses) never get a public certificate.Ports:
80and443must be reachable from the internet (firewall/security group) — Caddy uses the HTTP-01 and TLS-ALPN-01 challenges.ACME_EMAILin.env— the Let's Encrypt contact.Do not delete the persistent volume
caddy_data: it holds certificates and keys; Caddy renews certificates ahead of time and redirects HTTP → HTTPS by itself.
Verification after docker compose up -d:
docker compose logs caddy | grep -iE "certificate|obtain|error"curl -sS -I https://<MCP_DOMAIN>/healthzopenssl s_client -connect <MCP_DOMAIN>:443 -servername <MCP_DOMAIN> </dev/null 2>/dev/null | openssl x509 -noout -issuer -datescurl -sS -X POST https://<MCP_DOMAIN>/mcp -H "Authorization: Bearer $TUBETRACE_MCP_TOKEN" \
-H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}'Timeout alignment: TOOL_TIMEOUT_SECONDS (45 s) < Caddy response_header_timeout (120 s) <
the client's tool_timeout_sec (Codex defaults to 60 s — keep the tool budget below 60 s).
Caddy limits request bodies to 1 MB, the application to MAX_REQUEST_BODY_BYTES.
A self-signed certificate for localhost is not a ready public HTTPS setup. Without a real
domain, certificate issuance has not been verified (see the report).
Platforms with managed HTTPS (their own load balancer/TLS): Caddy is not needed. Run the
image directly with APP_ENV=production, HOST=0.0.0.0, the platform's PORT,
MCP_DOMAIN=<public hostname> (for the Host check) and FORWARDED_ALLOW_IPS set to the
platform's proxy addresses (or * if the application port is reachable only through that proxy).
Deploying to Prefect Horizon
Prefect Horizon is the managed MCP platform built by the
FastMCP team: it clones a GitHub repository, installs the dependencies, imports a Python file
containing a FastMCP server, runs it as an HTTP MCP server at https://<name>.fastmcp.app/mcp
and puts its own OAuth gateway in front of it. Details were checked on 2026-09-14 against the
FastMCP guide and the
Horizon documentation (build system, compute model, gateway,
authentication, environment variables, limits).
What the repository provides for it:
horizon.py— the entrypoint (horizon.py:mcp): a module-level FastMCP object built with the same factory as the self-hosted server (same tools, schemas, error codes, caches, rate limit and logging). Horizon ignores theif __name__ == "__main__"block, the Dockerfile, Caddy andtubetrace-mcp serve; it runs the object itself.fastmcp.json— declares the entrypoint, Python 3.12 and the project (pyproject.toml+uv.lock, frozenuv sync; thedevgroup is not installed becausedefault-groups = []). The same file makesuv run fastmcp inspectanduv run fastmcp runwork without arguments locally.AUTH_MODE=platform— the explicit configuration for "Horizon authenticates callers" (see below). Without it the build fails with a clear configuration error instead of producing an unauthenticated server.A CI step that runs
fastmcp inspect horizon.py:mcpwith the Horizon configuration, i.e. the same inspection Horizon performs at build time..dockerignorekeeps the build inputs: Horizon builds its own Docker image from the repository (COPY . /app,uv sync --frozen --no-dev, thenfastmcp inspect /app/horizon.py), so the repository's.dockerignoreapplies to that build as well (covered by a test).
Steps:
Push the repository to GitHub (public or private).
Sign in at horizon.prefect.io with GitHub, create a hosted server from the repository and set the entrypoint to
horizon.py:mcp. Keep Horizon authentication enabled (the default): only signed-in members of your Horizon organisation, or Horizon API keys, can call the server.Before the first build, add the environment variables (Settings → Environment Variables; they are encrypted and available at build and run time):
Variable
Value
Why
APP_ENVproductionfail-closed validation
AUTH_MODEplatformHorizon's gateway authenticates callers; no in-process bearer token
YOUTUBE_API_KEYyour Google key
optional; only
youtube_search_videosneeds itLOG_FORMATtext(default)Horizon shows raw stdout/stderr as server logs;
textreads best there (JSON is shown as one raw line); secrets are redactedLOG_REQUESTSerrors(default on Horizon)only failed requests are logged; Traffic Logs already record every request.
alllogs successful ones tooDo not set
MCP_TOKEN_SHA256,AUTH_DISABLED,MCP_DOMAIN,HOSTorPORT: the first two are rejected in platform mode, the rest are owned by Horizon. Variable names starting withFASTMCP_CLOUD_orHORIZON_are reserved by the platform.Deploy. Horizon builds (dependency install →
fastmcp inspectof the entrypoint → artifact), publisheshttps://<name>.fastmcp.app/mcp, redeploys on every push tomainand builds preview deployments for pull requests. Test with the built-in Inspector or ChatMCP, then use the connection snippets Horizon shows for Claude Code, Cursor, Claude Desktop, etc. — the client authenticates through Horizon's OAuth, not withTUBETRACE_MCP_TOKEN.
Check locally what Horizon will see at build time:
APP_ENV=production AUTH_MODE=platform uv run fastmcp inspect horizon.py:mcp(If your local .env sets AUTH_DISABLED=true or a digest, also pass AUTH_DISABLED=false MCP_TOKEN_SHA256= — real environment variables override .env, and platform mode refuses an
ambiguous configuration on purpose.)
Alternative: keep the bearer token on Horizon. If you disable Horizon authentication for the
server (Developer/Enterprise plans), Horizon passes requests through unchanged and your server
owns authentication again: set AUTH_MODE=bearer (or leave it unset), MCP_TOKEN_SHA256 and
MCP_DOMAIN=<name>.fastmcp.app, and clients send Authorization: Bearer <token> as for the
self-hosted setup. Never disable Horizon authentication while AUTH_MODE=platform is set — the
server would be public.
Behavioural differences on Horizon (from the platform documentation):
Horizon runs the FastMCP object with its own HTTP settings: sessions are stateful and routed by the gateway (
mcp-session-id, 24 h TTL; the server itself keeps no per-session state), onlyPOST /mcpis forwarded (GET/DELETE /mcpanswer 405 at the gateway), and the Caddy layer, the strict Host/Origin guard,MAX_REQUEST_BODY_BYTESandMCP_JSON_RESPONSEfrom the self-hosted setup do not apply./healthzexists on the server but is not reachable through the gateway.Limits: 170 s per request end-to-end (
TOOL_TIMEOUT_SECONDS, 45 s, stays well below), 6 MB request/response, 1024 MB memory, ephemeral filesystem; compute starts on demand, so the first request after idling is slower (horizon.pykeeps import-time work small).The cache and rate limit remain process-local; Horizon may run more than one instance.
Horizon runs in AWS
us-east-1with shared egress addresses. On 2026-09-14 the unofficial transcript provider worked from there (listing and fetching a transcript succeeded), but YouTube blocks cloud IP ranges at its own discretion, soyoutube_list_transcriptsandyoutube_get_transcriptmay start returningUPSTREAM_BLOCKEDat any time whileyoutube_search_videos(official API) keeps working. This server does not bypass blocks; see Unofficial transcript provider, blocking and legal notes.Horizon injects
horizon-actor*headers with the verified caller identity; themcp_requestlog line shows them (user,actor,role, see Logs). The client IP is not available on Horizon: uvicorn only sees the Lambda Web Adapter on 127.0.0.1 and the gateway sends noX-Forwarded-For.
Authentication: client token and server digest
uv run tubetrace-mcp generate-tokencreates a token from 32 random bytes (secrets.token_urlsafe) and its SHA-256 digest.The client keeps the token (for example in the
TUBETRACE_MCP_TOKENvariable of a secrets manager or a shell profile with600permissions) and sendsAuthorization: Bearer <token>.The server stores only the digest in
MCP_TOKEN_SHA256and compares the digest of the presented token in constant time (hmac.compare_digest). The token is never stored or logged on the server.All MCP requests, including
initializeandtools/list, are protected. A missing or wrong token → HTTP 401 withWWW-Authenticate: Bearer. A token in the query string is never accepted.The client token is never forwarded to Google or YouTube.
Rotation: generate a new pair, set
MCP_TOKEN_SHA256=<old>,<new>, update the clients, then keep only the new digest. For a compromised token, remove its digest.Digest of an existing token:
TUBETRACE_MCP_TOKEN=... uv run tubetrace-mcp hash-token(or--stdin) so the token never appears in command arguments.
This is not an OAuth authorization server: clients that require OAuth discovery/login are not
supported. For development, AUTH_DISABLED=true works only with APP_ENV=development and only
when set explicitly.
If you need OAuth for clients, put the server behind a managed MCP gateway that provides it and
set AUTH_MODE=platform (see Deploying to Prefect Horizon):
the gateway authenticates callers and this process performs no token verification. The mode
is never inferred: it must be set explicitly, and setting MCP_TOKEN_SHA256 or AUTH_DISABLED
together with it is a startup error, so the configuration can never be ambiguous.
Connecting clients (Codex, Claude Code, FastMCP)
Codex
Format checked against the Codex MCP documentation
on 2026-09-13 (~/.codex/config.toml or a project-scoped .codex/config.toml). Example:
examples/codex-config.toml.
[mcp_servers.tubetrace]
url = "https://mcp.example.com/mcp"
bearer_token_env_var = "TUBETRACE_MCP_TOKEN"The difference between the variables: TUBETRACE_MCP_TOKEN is the token on the client
(Codex injects it into Authorization), MCP_TOKEN_SHA256 is the digest on the server. The
values differ and neither belongs in a repository. This project does not modify your real
~/.codex/config.toml.
Claude Code
Syntax checked against the Claude Code documentation on 2026-09-13:
claude mcp add --transport http tubetrace https://mcp.example.com/mcp --header "Authorization: Bearer ${TUBETRACE_MCP_TOKEN}"FastMCP Client (Python)
See examples/client.py:
from fastmcp import Client
from fastmcp.client.auth import BearerAuth
from fastmcp.client.transports import StreamableHttpTransport
transport = StreamableHttpTransport("https://mcp.example.com/mcp", auth=BearerAuth(token))
async with Client(transport) as client:
tools = await client.list_tools()
page = await client.call_tool("youtube_get_transcript", {"video": "dQw4w9WgXcQ"})
print(page.structured_content)MCP tools
All tools carry the annotations readOnlyHint=true, destructiveHint=false,
idempotentHint=true, openWorldHint=true, have typed input/output schemas and return
structured content (plus the same JSON in a text block for clients without structured
output support).
youtube_search_videos
Parameter | Value |
| non-empty string, ≤ 256 characters |
| 1–50, default 10 |
|
|
|
|
| RFC 3339 with a time zone, e.g. |
|
|
|
|
|
|
|
|
|
|
One call = one request GET https://www.googleapis.com/youtube/v3/search?part=snippet&type=video&q=....
{"query": "fastmcp streamable http", "max_results": 5, "order": "date", "caption_filter": "closedCaption"}Result: items[] (video_id, video_url, title, description, channel_id,
channel_title, published_at, thumbnail_url, live_broadcast_content),
next_page_token, prev_page_token, results_per_page, total_results_estimate +
total_results_note (Google returns an estimate, not a guaranteed count), region_code,
retrieved_at, provider, cache_hit. HTML entities in titles/descriptions are decoded;
invalid dates in the response do not break the page (published_at: null).
youtube_list_transcripts
{"video": "https://youtu.be/dQw4w9WgXcQ"}Accepts an 11-character ID or a URL from the allowlisted hosts youtube.com,
www/m/music.youtube.com, youtu.be, youtube-nocookie.com in the forms watch?v=,
youtu.be/, shorts/, embed/, live/, v/. The hostname is parsed with the standard URL
parser: youtube.com.evil.example, userinfo (user@), non-standard ports and schemes are
rejected (INVALID_VIDEO_INPUT). The given URL is never fetched and its redirects are never
followed — only the ID is extracted.
Returns tracks[] (language, language_code, is_generated, is_translatable),
default_selection_policy, retrieved_at, cache_hit. Track text is not downloaded.
youtube_get_transcript
Parameter | Value |
| ID or URL |
| codes in priority order, e.g. |
|
|
| time range (seconds) |
| ≥ 0 (default 0) — index within the matched segments |
| 1–500 (default 100) |
|
|
Track selection algorithm (deterministic, documented in default_selection_policy):
If
languagesis given: the list order outranksprefer_manual. For each code, tracks with an exact code match (case-insensitive) are considered first, then tracks with the same base language (en↔en-US). Inside a tier,prefer_manualchooses between the manual and the auto-generated track (falling back to the other kind if the preferred one is missing). If none of the languages is available —NO_MATCHING_TRANSCRIPTwith the list of available languages.If
languagesis omitted: the preferred kind (manual ifprefer_manual=true) and the first track in the provider's listing order are used. This is not necessarily the video's original language — the provider does not expose that information. The language actually used is returned inlanguage_code;selectionisrequested_languageordefault_policy.No translation is performed; the language is never switched silently.
Segment and page semantics:
indexis the position of the segment in the full transcript (0…total_segments-1) and never changes with filters.A time range keeps segments whose interval
[start, start+duration)intersects[start_seconds, end_seconds); zero-length segments are kept when their start lies in the range. Timings are not altered and text is never cut "at a phrase boundary".offset/limitare then applied to the matched segments (matched_segments).format=segments: every segment hasindex,text,start_seconds,duration_seconds,end_seconds,timestamp_url.format=text:textis only this page's text with lines separated by\n; segments are not duplicated.The
MAX_RESPONSE_BYTESlimit applies to the structured payload of the page. If a page is shortened,truncated_by_size_limit=trueandnext_offsetreflects the segments actually returned. An empty page with the samenext_offsetis impossible; if a single segment does not fit on its own, the explicit errorRESPONSE_TOO_LARGEis returned.Response:
video_id,video_url,language,language_code,is_generated,selection,provider,retrieved_at,cache_hit,format,total_segments,matched_segments,offset,returned_segments,next_offset,has_more,truncated_by_size_limit,start_seconds,end_seconds,segments|text.
Examples:
{"video": "dQw4w9WgXcQ", "languages": ["uk", "en"], "limit": 50}{"video": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", "start_seconds": 60, "end_seconds": 120, "format": "text"}Pagination and fetching a full transcript in a loop
The full transcript is downloaded from upstream once and cached (1 hour by default); the
following pages are served from the cache (cache_hit=true). The cache is an optimisation: if it
is gone, the page simply re-downloads the transcript.
offset, parts = 0, []
while True:
page = await client.call_tool(
"youtube_get_transcript",
{"video": video, "offset": offset, "limit": 500, "format": "text"},
raise_on_error=False,
)
data = page.structured_content
if page.is_error:
raise RuntimeError(data["error"])
parts.append(data["text"])
if not data["has_more"]:
break
offset = data["next_offset"]
full_text = "\n".join(parts)For search: pass next_page_token as page_token until it becomes null. Every page is a
separate search.list call and a separate quota unit.
Errors
Expected failures are returned as an MCP tool error (isError: true) with the payload:
{"error": {"code": "NO_MATCHING_TRANSCRIPT", "message": "...", "retryable": false, "details": {"requested": ["fr"], "available": [{"language_code": "en", "language": "English", "is_generated": false}]}}}Code | Meaning | retryable |
| invalid parameters (e.g. a date without a time zone, | no |
| neither an ID nor a URL from the allowlisted hosts | no |
| no | no |
| key invalid/forbidden, API not enabled | no |
| the project's daily quota is exhausted | no |
| no track in the requested language (see | no |
| subtitles are disabled for the video | no |
| video unavailable/private/age-restricted ( | no |
| transcript exceeds | no |
| a single segment does not fit into | no |
| local per-process limit ( | yes |
| the concurrent upstream request limit is exhausted | yes |
| YouTube blocked the provider (IP/request block, PO token) — not "no subtitles" | only with |
| 429 from Google/YouTube | yes |
| upstream timeout or the tool time budget | yes |
| other upstream/network/parsing failure ( | depends |
JSON-schema validation errors of the arguments (for example max_results: 51) are also returned
by FastMCP with isError: true, but with the pydantic validation text instead of a code. Protocol
and auth errors follow the MCP/HTTP rules (401/403/421/413, JSON-RPC error).
Logs are structured (JSON): request_id, tool, latency_ms, provider, cache_hit,
error_code, status. Authorization, the API key, full transcripts and .env are never logged;
redaction is also applied to exception text.
Logs
Everything the server writes goes to stderr in one format, chosen with LOG_FORMAT: text
(default: <time> <LEVEL> <logger> <message> key=value ...) or json (one object per line with
the same fields). FastMCP, uvicorn and MCP SDK lines are routed through the same formatter, and
secrets are redacted from every rendered line. logging_config.py and audit.py are shared
verbatim with rabotaua-mcp, so both servers log identically.
Each MCP request produces one mcp_request line (AuditMiddleware). LOG_REQUESTS chooses
which of them are logged at INFO: all (default when self-hosted) or errors (default on
Prefect Horizon): failed requests at WARNING, successful ones at DEBUG. Horizon's Traffic Logs
already record every request with the actor, client, method, tool, status, duration and
payloads, so the console keeps what they lack: failures with their server-side cause,
tracebacks and cold starts (server_started).
2026-09-26T19:29:59.565Z WARNING audit mcp_request method=tools/call tool=youtube_search_videos status=error error=GOOGLE_API_NOT_CONFIGURED latency_ms=5.9 user=agrynchuk@gmail.com actor=user role=admin client=ClaudeCode ua=Claude-User arguments=query retryable=false request_id=81be2f8d9e6127cfda1fbce579dc0abffield | source |
| Horizon gateway headers |
|
|
| Self-hosted only: the peer uvicorn resolved ( |
| Argument names; values only with |
| The W3C |
| Added by the tool with |
|
|
uvicorn access lines are dropped for loopback peers: on Horizon every request comes from the
Lambda Web Adapter on 127.0.0.1 (plus its GET / readiness probe), which says nothing about the
caller. Self-hosted access lines for real peers are kept, in the same format. uvicorn's INFO lines about
starting and stopping ("Application startup complete", logged under the name uvicorn.error
although they are not errors) are hidden too, with the MCP SDK's session-manager ones:
server_started and server_stopped mark an instance's lifecycle. Warnings and errors still
show, and LOG_LEVEL=DEBUG shows everything.
Troubleshooting
Symptom | What to check |
Server does not start: | set |
| remove |
| platform mode must be unambiguous: remove the digest / |
Horizon build fails at the inspect step with | set |
| set |
401 | the client token does not match the digest; verify with |
403 | a browser client with a foreign Origin; add it to |
421 | Host does not match |
413 | request body larger than |
|
|
| the API is not enabled in the project or the key is restricted to another API/IP |
| the 100 daily |
| YouTube blocks the server's IP (common for cloud/datacenter); set |
| wrong proxy username/password (percent-encode special characters) / the proxy host or port is unreachable; |
| YouTube changed something or soft-blocks; update |
Caddy does not obtain a certificate | DNS A/AAAA → this server? ports 80/443 open? |
The client "hangs" on a long call | align |
Unofficial transcript provider, blocking and legal notes
youtube-transcript-apiuses an undocumented part of YouTube: it is an unofficial way of retrieving already existing subtitles with no availability guarantee. The library's availability does not imply Google's approval.YouTube frequently blocks cloud provider IPs (
UPSTREAM_BLOCKED,IpBlocked/RequestBlocked, PO token requirement). The server returns a diagnostic error and does not use cookies/login, solve CAPTCHAs or retry endlessly. WithoutTRANSCRIPT_PROXY_URLtranscripts may be unavailable on some VPS hosts while search through the official API keeps working; with it, blocks depend on the reputation of the proxy's exit IPs, and the proxy provider's terms apply as well.Some videos have no subtitles (
TRANSCRIPTS_DISABLED) or only automatic ones.Before using this, review the YouTube Terms of Service, the YouTube API Services Terms and the rights to reuse subtitle text: a transcript is the video author's content.
Known limitations
One worker, an in-memory cache and rate limit — no shared state between processes/replicas (including several Horizon instances).
AUTH_MODE=platformtrusts the network path: it is only safe when the process cannot be reached except through the authenticating gateway. The server does not verify the gateway's identity headers.total_results_estimateis Google's estimate; the real pagination depth is smaller.The provider's track order does not guarantee the "original" language.
The on-the-wire response is roughly twice
MAX_RESPONSE_BYTES, because the structured content is duplicated in a text block for clients without structured output support.The FastMCP Client in its new negotiation mode (
mode="auto", not the standardinitialize) gets "Method not found" forping; standard clients (initializehandshake, such as Codex) ping normally — this is covered by a test.Request bodies are limited (1 MB in Caddy,
MAX_REQUEST_BODY_BYTESin the application), the number oflanguagesis capped at 10 andqueryat 256 characters.
License: MIT.
This server cannot be deployed
Maintenance
Related MCP Connectors
YouTube discovery, transcripts, library search, and monitors with API keys or OAuth.
YouTube video/channel search, suggestions, details, comments/replies, Shorts, transcripts, paging.
YouTube transcripts, search, channel/playlist listings and upload tracking for AI agents.
Search YouTube, read video metadata, and fetch transcripts with language preferences
Related MCP Servers
- AlicenseBqualityDmaintenanceEnables comprehensive YouTube data access including video details, playlists, channels, comments, search, and subtitle operations through the YouTube Media Downloader API.19MIT
- FlicenseNot gradedqualityBmaintenanceEnables searching and retrieving video transcripts, metadata, channel info, playlists, comments, trending videos, and engagement analytics from YouTube through natural language.18 npm-
- FlicenseNot gradedqualityBmaintenanceEnables searching and retrieving video transcripts, metadata, channel info, playlists, comments, trending videos, engagement analytics, chapters, SponsorBlock clean transcripts, and most-replayed heatmaps.18 npm-
- AlicenseAqualityBmaintenanceEnables read-only research on YouTube by exposing video metadata, transcripts, comments, search, channels, playlists, and trending data through four task-oriented tools.4MIT