first commit

This commit is contained in:
kevinwatt
2025-02-11 02:40:43 +08:00
commit 47e67ae25a
6 changed files with 1542 additions and 0 deletions

37
README.md Normal file
View File

@@ -0,0 +1,37 @@
# YouTube MCP Server
Uses `yt-dlp` to download YouTube content and connects it to LLMs via [Model Context Protocol](https://modelcontextprotocol.io/introduction).
## Features
- Download YouTube subtitles (SRT format) for LLMs to read
- Download YouTube videos to your Downloads folder
- Integrates with Claude.ai and other MCP-compatible LLMs
## Installation
1. Install `yt-dlp` (Homebrew and WinGet both work great here)
2. Install this via [dive](https://github.com/OpenAgentPlatform/Dive):
```bash
mcp-installer install @kevinwatt/yt-dlp-mcp
```
## Usage Examples
Ask your LLM to:
- "Summarize the YouTube video <<URL>>"
- "Download this YouTube video: <<URL>>"
## Manual Start
If needed, you can start the server manually:
```bash
yt-dlp-mcp
```
## Requirements
- `yt-dlp` installed and in PATH
- Node.js 20+
- MCP-compatible LLM service

88
eslint.config.mjs Normal file
View File

@@ -0,0 +1,88 @@
import typescriptEslint from "@typescript-eslint/eslint-plugin";
import prettier from "eslint-plugin-prettier";
import tsParser from "@typescript-eslint/parser";
import path from "node:path";
import { fileURLToPath } from "node:url";
import js from "@eslint/js";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
allConfig: js.configs.all,
});
export default [
...compat.extends(
"eslint:recommended",
"prettier",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended",
),
{
files: ["./src/*.{ts,tsx}", "./test/*.{ts,tsx}"],
plugins: {
"@typescript-eslint": typescriptEslint,
prettier,
},
languageOptions: {
globals: {},
parser: tsParser,
ecmaVersion: 5,
sourceType: "script",
parserOptions: {
project: "./tsconfig.json",
},
},
rules: {
"prettier/prettier": "warn",
"spaced-comment": [
"error",
"always",
{
markers: ["/"],
},
],
"no-fallthrough": "error",
"@typescript-eslint/ban-ts-comment": "warn",
"@typescript-eslint/consistent-type-imports": [
"error",
{
prefer: "type-imports",
},
],
"@typescript-eslint/no-inferrable-types": [
"error",
{
ignoreParameters: false,
ignoreProperties: false,
},
],
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-unused-vars": [
"warn",
{
args: "after-used",
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
ignoreRestSiblings: true,
},
],
"@typescript-eslint/no-empty-function": ["error"],
"@typescript-eslint/restrict-template-expressions": "off",
},
},
];

1169
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "@kevinwatt/yt-dlp-mcp",
"version": "0.5.1",
"bin": {
"yt-dlp-mcp": "lib/index.mjs"
},
"description": "YouTube video download for MCP",
"main": "./lib/index.mjs",
"scripts": {
"prepare": "tsc && shx chmod +x ./lib/index.mjs"
},
"author": "Dewei Yen <k@funmula.com>",
"license": "MIT",
"type": "module",
"exports": {
".": {
"import": "./lib/index.mjs"
}
},
"dependencies": {
"@modelcontextprotocol/sdk": "0.7.0",
"rimraf": "^6.0.1",
"spawn-rx": "^4.0.0"
},
"devDependencies": {
"shx": "^0.3.4",
"ts-node": "^10.9.2",
"typescript": "^5.6.3"
}
}

190
src/index.mts Normal file
View File

@@ -0,0 +1,190 @@
#!/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 type { CallToolRequest } from "@modelcontextprotocol/sdk/types.js";
import * as os from "os";
import * as fs from "fs";
import * as path from "path";
import { spawnPromise } from "spawn-rx";
import { rimraf } from "rimraf";
const server = new Server(
{
name: "yt-dlp-mcp",
version: "0.5.1",
},
{
capabilities: {
tools: {},
},
}
);
/**
* Returns the list of available tools.
*/
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "download_youtube_srt",
description:
"Download YouTube subtitles in SRT format so that LLM can read them.",
inputSchema: {
type: "object",
properties: {
url: { type: "string", description: "URL of the YouTube video" },
},
required: ["url"],
},
},
{
name: "download_youtube_video",
description:
"Download YouTube video to the user's default Downloads folder (usually ~/Downloads).",
inputSchema: {
type: "object",
properties: {
url: { type: "string", description: "URL of the YouTube video" },
},
required: ["url"],
},
},
],
};
});
/**
* Downloads YouTube subtitles (SRT format) and returns the concatenated content.
* @param url The URL of the YouTube video.
* @returns Concatenated subtitles text.
*/
async function downloadSubtitles(url: string): Promise<string> {
// Create a temporary directory for subtitle downloads
const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "youtube-"));
// Use yt-dlp to download subtitles without downloading the video
await spawnPromise(
"yt-dlp",
[
"--write-sub",
"--write-auto-sub",
"--sub-lang",
"en",
"--skip-download",
"--sub-format",
"srt",
url,
],
{ cwd: tempDirectory, detached: true }
);
let subtitlesContent = "";
try {
const files = fs.readdirSync(tempDirectory);
for (const file of files) {
const filePath = path.join(tempDirectory, file);
const fileData = fs.readFileSync(filePath, "utf8");
subtitlesContent += `${file}\n====================\n${fileData}\n\n`;
}
} finally {
// Clean up the temporary directory after processing
rimraf.sync(tempDirectory);
}
return subtitlesContent;
}
/**
* Downloads a YouTube video to the user's default Downloads folder.
* @param url The URL of the YouTube video.
* @returns A success message.
*/
async function downloadVideo(url: string): Promise<string> {
// Determine the user's Downloads directory (works for Windows, macOS, and Linux by default)
const userDownloadsDir = path.join(os.homedir(), "Downloads");
// Use yt-dlp to download the video into the Downloads folder using a default filename template
await spawnPromise("yt-dlp", [
url,
"-o",
path.join(userDownloadsDir, "%(title)s.%(ext)s"),
]);
return `Video successfully downloaded to ${userDownloadsDir}`;
}
/**
* Handles tool execution requests.
*/
server.setRequestHandler(
CallToolRequestSchema,
async (request: CallToolRequest) => {
const toolName = request.params.name;
const args = request.params.arguments as { url: string };
if (toolName === "download_youtube_srt") {
try {
const subtitles = await downloadSubtitles(args.url);
return {
content: [
{
type: "text",
text: subtitles,
},
],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Error downloading subtitles: ${error}`,
},
],
isError: true,
};
}
} else if (toolName === "download_youtube_video") {
try {
const message = await downloadVideo(args.url);
return {
content: [
{
type: "text",
text: message,
},
],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Error downloading video: ${error}`,
},
],
isError: true,
};
}
} else {
throw new Error(`Unknown tool: ${toolName}`);
}
}
);
/**
* Starts the server using Stdio transport.
*/
async function startServer() {
const transport = new StdioServerTransport();
await server.connect(transport);
}
// Start the server and handle potential errors
startServer().catch(console.error);

28
tsconfig.json Normal file
View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"removeComments": false,
"preserveConstEnums": true,
"sourceMap": true,
"declaration": true,
"noImplicitAny": true,
"noImplicitReturns": true,
"strictNullChecks": true,
"noUnusedLocals": true,
"noImplicitThis": true,
"noUnusedParameters": true,
"module": "node16",
"moduleResolution": "node16",
"pretty": true,
"target": "es2015",
"outDir": "lib",
"lib": ["dom", "es2015"],
"esModuleInterop": true,
"allowJs": true
},
"formatCodeOptions": {
"indentSize": 2,
"tabSize": 2
},
"include": ["src/**/*.mts", "src/types/*.d.ts"],
"exclude": ["node_modules", "lib"]
}