Skip to main content
Glama
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.