yts-mcp
by josetonyp
README.md
# yts — YouTube Transcript MCP Server
Minimal MCP server that downloads YouTube video transcripts via the `yt-dlp` CLI.
Runs over stdio (for Claude Code / IDE integrations) or SSE (for Docker deploys
and remote clients).
---
## Tools
| Tool | Description |
|------|-------------|
| `get_transcript(url, language=None, auto_subs=True, format="text")` | Download subs for a video. `language=None` picks the original-audio track (`-orig`) with English fallback. `format` ∈ `text` (default) / `cues` / `both` / `vtt`. |
| `list_subtitles(url)` | List which subtitle and auto-caption languages the video offers. |
| `get_video_info(url)` | Return metadata (title, channel, duration, description, tags). |
| `set_cookies(cookies_text)` | Load Netscape-format cookies for this process. Bypasses rate-limit / captcha errors. |
| `clear_cookies()` | Discard the in-memory cookies. Idempotent. |
| `check_cookies(url=None)` | Probe a random video from the built-in list (or an explicit `url`) to verify cookies work. Never raises — returns `{ok, ...}`. |
| `health()` | Return `yt-dlp` version + whether cookies are loaded. |
All success responses are `dict`. Failures surface as MCP protocol errors
(`isError: true`), so clients see red error UI rather than a "success" wrapping
a `{"error": "..."}` payload.
---
## Bypassing "This video is not available" / captcha errors
YouTube's bot detection rate-limits guest sessions to ~300 videos/hour per IP.
When it triggers, yt-dlp masks the real reason as *"This video is not available"*.
The fix is authenticated cookies from a signed-in browser:
1. In your browser, sign in to YouTube.
2. Install a cookies-export extension (e.g. *"Get cookies.txt LOCALLY"* — Chrome/Firefox).
3. Export cookies for `youtube.com` — the extension gives you Netscape-format text.
4. Call `set_cookies(cookies_text=<the exported text>)` on the running server (via MCP Inspector, Claude Code, etc.).
5. All subsequent tool calls will run with `--cookies` injected automatically.
Cookies live only in the server process (chmod-600 tempfile). They **do not**
survive `docker compose restart` — re-inject after each restart. Call
`clear_cookies()` any time to drop them.
---
## Requirements
- Python 3.10+
- `yt-dlp` on PATH (installed automatically by `setup.sh` inside the venv, or
by the Docker image)
- `ffmpeg` — optional but recommended (handles subtitle format conversion)
- **A JS runtime** (`node`, `deno`, `bun`, or `quickjs`) — strongly recommended.
YouTube protects many videos with a JS `n` challenge; without a runtime,
yt-dlp masks the failure as *"This video is not available"*. The Docker
image installs `nodejs` by default. Locally, install one you already have
(`brew install node` on macOS).
---
## Quick start (Docker Compose)
Every push to `main` publishes a multi-arch image to Docker Hub:
`josetonyp/yts-mcp:latest` (see the *CI / published image* section below).
If you just want to run the server without building:
```bash
docker compose pull # fetch the pre-built image
docker compose up -d # start it
curl -N http://localhost:8765/sse # verify the SSE endpoint responds
docker compose logs -f yts-mcp # follow the yt-dlp trace log
docker compose down # stop & remove
```
To build locally from your working tree instead (dev flow):
```bash
docker compose up -d --build
```
Point at a different published tag (e.g. a specific commit) with the
`YTS_IMAGE` env var:
```bash
YTS_IMAGE=josetonyp/yts-mcp:sha-abc1234 docker compose up -d
```
Then register with your MCP client:
```json
{
"mcpServers": {
"yts": { "url": "http://127.0.0.1:8765/sse" }
}
}
```
---
## Docker CLI (no Compose)
If you don't want to use Compose, the same server runs via `docker build` +
`docker run`:
```bash
# 1. Build the image (tag it 'yts-mcp' for convenience).
docker build -t yts-mcp .
# 2. Run it. --rm auto-removes on stop; -d detaches; -p publishes the SSE port.
docker run -d --rm \
--name yts-mcp \
-p 8765:8765 \
yts-mcp
# 3. Verify.
curl -N http://localhost:8765/sse
docker logs -f yts-mcp # stream yt-dlp command log
```
### Common variants
```bash
# Run in the foreground (Ctrl+C to stop) — useful for one-off debugging:
docker run --rm -p 8765:8765 yts-mcp
# Bind to a different host port:
docker run -d --rm --name yts-mcp -p 9000:8765 yts-mcp
# Tighter timeout (default is 120s per yt-dlp call):
docker run -d --rm --name yts-mcp -p 8765:8765 \
-e YTDLP_TIMEOUT=60 \
yts-mcp
# Verbose logs (adds yt-dlp stderr on successful calls):
docker run -d --rm --name yts-mcp -p 8765:8765 \
-e YTS_LOG_LEVEL=DEBUG \
yts-mcp
# Pin a specific yt-dlp release at build time:
docker build --build-arg YTDLP_VERSION=2025.09.05 -t yts-mcp:pinned .
docker run -d --rm --name yts-mcp -p 8765:8765 yts-mcp:pinned
# Stop, restart, remove:
docker stop yts-mcp
docker start yts-mcp
docker rm -f yts-mcp
```
### Upgrading `yt-dlp`
YouTube changes their internals often; `yt-dlp` ships fixes weekly.
```bash
# With Compose:
docker compose build --no-cache --build-arg YTDLP_VERSION=latest
docker compose up -d
# With plain Docker CLI:
docker build --no-cache --build-arg YTDLP_VERSION=latest -t yts-mcp .
docker rm -f yts-mcp
docker run -d --rm --name yts-mcp -p 8765:8765 yts-mcp
```
Verify with the `health` tool from your MCP client, or:
```bash
docker exec yts-mcp yt-dlp --version
```
---
## CI / published image
`.github/workflows/docker.yml` builds a multi-arch (`amd64` + `arm64`)
image and pushes it to **Docker Hub**.
**Image URL:** [`josetonyp/yts-mcp`](https://hub.docker.com/r/josetonyp/yts-mcp)
**Tags produced per push:**
| Tag | Meaning |
|-----|---------|
| `latest` | Default branch (`main`/`master`) — always the newest passing build |
| `main`, `<branch>` | Every pushed branch keeps a tag |
| `sha-<short>` | Every commit — for reproducible pins |
| `1.2.3`, `1.2`, `1` | Git tags matching `v*` (semver) |
**Triggers:**
- Push to `main`/`master` → build **and push**
- Tag `v*` → build **and push** semver tags
- Pull request → build only (validates the Dockerfile), no push
- `workflow_dispatch` → manual runs from the Actions tab
### First-time setup
1. **Create a Docker Hub access token**:
<https://hub.docker.com/settings/security> → *New Access Token*
→ scope `Read, Write, Delete`. Copy it — shown once.
2. **Create the repo on Docker Hub** ahead of first push
(recommended so it starts *Public*):
<https://hub.docker.com/repository/create> → name `yts-mcp` → **Public**.
Skipping this makes the first push auto-create it *Private*, and
public consumers get `denied` errors.
3. **Add two GitHub repository secrets**:
Settings → Secrets and variables → Actions → *New repository secret*.
- `DOCKERHUB_USERNAME` = `josetonyp`
- `DOCKERHUB_TOKEN` = the token from step 1
4. **Init the repo and push to GitHub**:
```bash
git init && git add . && git commit -m "initial"
git remote add origin git@github.com:<owner>/<repo>.git
git push -u origin main
```
5. Wait ~3-5 min for the multi-arch build. Then:
```bash
docker compose pull
docker compose up -d
```
---
## Environment variables
| Var | Default | Purpose |
|-----|---------|---------|
| `MCP_HOST` | `127.0.0.1` (local) / `0.0.0.0` (Docker) | SSE bind address |
| `MCP_PORT` | `8765` | SSE bind port |
| `MCP_TRANSPORT` | *(unset)* | Set to `sse` to force SSE without the `--sse` flag |
| `YTDLP_BIN` | *(auto-detect: venv sibling → PATH)* | Override the yt-dlp binary path |
| `YTDLP_TIMEOUT` | `120` | Max seconds a single `yt-dlp` invocation may run |
| `YTS_LOG_LEVEL` | `INFO` | `DEBUG`/`INFO`/`WARNING`/`ERROR` on stderr |
Pass these to `docker run` with `-e VAR=value` or set them in
`docker-compose.yml` under `environment:`.
---
## Local (stdio) setup
```bash
bash setup.sh
```
The script creates `.venv/`, installs `mcp` and `yt-dlp`, and prints an
`.mcp.json` snippet like:
```json
{
"mcpServers": {
"yts": {
"type": "stdio",
"command": "/absolute/path/to/yts/.venv/bin/python3",
"args": ["/absolute/path/to/yts/src/server.py"]
}
}
}
```
---
## Local (SSE) dev mode
```bash
bash dev.sh # binds 127.0.0.1:8765
bash dev.sh --port 8888 # custom port
```
---
## File layout
```
yts/
├── src/
│ ├── server.py ← FastMCP init, tool registration, composition root
│ ├── cookie_store.py ← CookieStore + InvalidCookiesError (standalone)
│ └── transcript/ ← yt-dlp adapter package — one class per file
│ ├── __init__.py ← public API re-exports
│ ├── cue.py ← Cue (frozen dataclass)
│ ├── transcript.py ← Transcript (dataclass + to_dict)
│ ├── video_info.py ← VideoInfo (dataclass + from_yt_json)
│ ├── probe_videos.py ← PROBE_VIDEOS list used by check_cookies
│ ├── ytdlp_client.py ← YtDlpClient + 4 error classes
│ └── parsers/ ← subtitle-format parsers
│ ├── __init__.py
│ └── vtt_parser.py ← VttParser (WebVTT → cues, stateless)
├── .github/
│ └── workflows/
│ └── docker.yml ← CI: build+push multi-arch image to GHCR
├── requirements.txt ← mcp + minimal HTTP deps
├── Dockerfile ← Python 3.12-slim + ffmpeg + nodejs + yt-dlp (pip)
├── docker-compose.yml ← Uses Docker Hub image; falls back to local build
├── setup.sh ← Local venv + smoke test
├── dev.sh ← Launch SSE dev server
├── ARCHITECTURE.md ← Design decisions and layer contracts
└── README.md
```
See `ARCHITECTURE.md` for the design rationale.