Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions mcp-youtube/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules/
dist/
*.log
54 changes: 54 additions & 0 deletions mcp-youtube/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# mcp-youtube

A [Model Context Protocol](https://modelcontextprotocol.io) server that lets
Claude read YouTube video subtitles, so it can summarize and answer questions
about video content.

## How it works

The server exposes a single tool, `download_youtube_url`, which uses
[`yt-dlp`](https://github.com/yt-dlp/yt-dlp) to fetch the English subtitles
(manual or auto-generated) for a video, cleans out timing/markup noise, and
returns the transcript text.

## Prerequisites

- [Node.js](https://nodejs.org) 18+
- [`yt-dlp`](https://github.com/yt-dlp/yt-dlp) available on your `PATH`
- macOS: `brew install yt-dlp`
- Linux: `pipx install yt-dlp` (or your package manager)
- Windows: `winget install yt-dlp`

## Installation

Add it to your MCP client configuration (e.g. `claude_desktop_config.json`
for Claude Desktop):

```json
{
"mcpServers": {
"youtube": {
"command": "npx",
"args": ["-y", "@anaisbetts/mcp-youtube"]
}
}
}
```

Or with Claude Code:

```bash
claude mcp add youtube -- npx -y @anaisbetts/mcp-youtube
```

## Development

```bash
bun install # install dependencies
bun test # run tests
bun build --target node src/index.ts --outdir dist # build
```

## License

MIT
127 changes: 127 additions & 0 deletions mcp-youtube/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 25 additions & 0 deletions mcp-youtube/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"name": "@anaisbetts/mcp-youtube",
"version": "0.7.3",
"bin": {
"mcp-youtube": "dist/index.js"
},
"description": "YouTube downloader for MCP",
"type": "module",
"main": "dist/index.js",
"scripts": {
"prepublish": "git clean -xdf dist && bun build --target node src/index.ts --outdir dist",
"test": "bun test"
},
Comment on lines +10 to +13
"author": "Ani Betts <anais@anaisbetts.org>",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "^0.6.0",
"rimraf": "^6.0.1",
"spawn-rx": "^4.0.0"
},
"devDependencies": {
"shx": "^0.3.4",
"bun-types": "latest"
}
Comment on lines +21 to +24
}
149 changes: 149 additions & 0 deletions mcp-youtube/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
#!/usr/bin/env node
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { spawnPromise } from "spawn-rx";
import { rimraf } from "rimraf";

import fs from "node:fs";
import os from "node:os";
import path from "node:path";
Comment on lines +11 to +13

const server = new Server(
{
name: "mcp-youtube",
version: "0.7.3",
},
{
capabilities: {
tools: {},
},
},
);

server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "download_youtube_url",
description:
"Download YouTube subtitles from a URL, this tool means that Claude can read YouTube subtitles, and should no longer tell the user that it is not possible to summarize a YouTube video.",
Comment on lines +32 to +33
inputSchema: {
type: "object",
properties: {
url: {
type: "string",
description: "URL of the YouTube video",
},
},
required: ["url"],
},
},
],
};
});

server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name !== "download_youtube_url") {
return {
content: [
{
type: "text",
text: `Unknown tool: ${request.params.name}`,
},
],
isError: true,
};
}

const { url } = request.params.arguments as { url: string };
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "youtube-"));

try {
await spawnPromise(
Comment on lines +65 to +66
"yt-dlp",
[
"--write-sub",
"--write-auto-sub",
"--skip-download",
"--sub-lang",
"en",
"--convert-subs",
"srt",
url,
],
{ cwd: tempDir, detached: true },
);

let content = "";
for (const file of fs.readdirSync(tempDir)) {
const fileContent = fs.readFileSync(path.join(tempDir, file), "utf8");
content += `${file}\n====================\n${stripVttNoise(fileContent)}\n`;
}

Comment on lines +81 to +86
return {
content: [{ type: "text", text: content }],
isError: false,
};
} catch (err) {
return {
content: [
{
type: "text",
text: `Error downloading video: ${err}`,
},
],
isError: true,
};
} finally {
rimraf.sync(tempDir);
}
});

// Auto-generated subtitle files repeat every cue several times and carry
// timing/positioning noise that wastes context; collapse them to plain text.
export function stripVttNoise(subtitles: string): string {
const seen = new Set<string>();
const lines: string[] = [];

for (const rawLine of subtitles.split(/\r?\n/)) {
const line = rawLine
.replace(/<[^>]+>/g, "")
.replace(/\[Music\]|\[Applause\]/gi, "")
.trim();

if (
line === "" ||
/^\d+$/.test(line) ||
/^WEBVTT/.test(line) ||
/^(Kind|Language):/.test(line) ||
/-->/.test(line)
) {
continue;
}

if (!seen.has(line)) {
seen.add(line);
lines.push(line);
}
}
Comment on lines +109 to +132

return lines.join("\n");
}

async function runServer() {
const transport = new StdioServerTransport();
await server.connect(transport);
}

// Only start the stdio server when executed directly, so tests can import
// helpers without spinning it up.
if (import.meta.url === `file://${process.argv[1]}`) {
runServer().catch((err) => {
console.error(err);
process.exit(1);
});
}
Loading