YouTube MCP Server
import { MCPFunction, MCPFunctionGroup } from "@modelcontextprotocol/sdk";
import { YoutubeTranscript } from "youtube-transcript";
import * as ytdl from "ytdl-core";
import * as fs from "fs/promises";
import * as path from 'path';
import ffmpeg from 'fluent-ffmpeg';
// Utility functions
function safeGet<T>(obj: any, path: string, defaultValue?: T): T | undefined {
return path.split('.').reduce((acc, part) =>
acc && acc[part] !== undefined ? acc[part] : defaultValue, obj);
}
function safeParse(value: string | number | null | undefined, defaultValue = 0): number {
if (value === null || value === undefined) return defaultValue;
const parsed = Number(value);
return isNaN(parsed) ? defaultValue : parsed;
}
function safelyExecute<T>(fn: () => T): T | null {
try {
return fn();
} catch (error: unknown) {
console.error('Execution error:', error instanceof Error ? error.message : 'Unknown error');
return null;
}
}
// Define VideoFormat type
type VideoFormat = 'mp4' | 'mp3' | 'wav';
export class VideoDownloader implements MCPFunctionGroup {
@MCPFunction({
description: 'Download video in specified format',
parameters: {
type: 'object',
properties: {
videoId: { type: 'string' },
format: { type: 'string', enum: ['mp4', 'mp3', 'wav'] },
quality: { type: 'string', enum: ['highest', 'lowest', '1080p', '720p', '480p', '360p'] }
},
required: ['videoId', 'format']
}
})
async downloadVideo({
videoId,
format = 'mp4',
quality = 'highest'
}: {
videoId: string,
format?: VideoFormat,
quality?: string
}): Promise<string> {
try {
const info = await ytdl.getInfo(videoId);
const outputDir = path.join(process.cwd(), 'downloads');
await fs.mkdir(outputDir, { recursive: true });
const outputPath = path.join(
outputDir,
`${videoId}-${Date.now()}.${format}`
);
if (format === 'mp4') {
await this.downloadVideoFormat(info, outputPath, quality);
} else {
await this.downloadAudioFormat(info, outputPath, format);
}
return outputPath;
} catch (error) {
throw new Error(`Failed to download video: ${error instanceof Error ? error.message : String(error)}`);
}
}
@MCPFunction({
description: 'Extract video thumbnail',
parameters: {
type: 'object',
properties: {
videoId: { type: 'string' },
timestamp: { type: 'number' }
},
required: ['videoId']
}
})
async extractThumbnail({
videoId,
timestamp = 0
}: {
videoId: string,
timestamp?: number
}): Promise<string> {
try {
const outputDir = path.join(process.cwd(), 'thumbnails');
await fs.mkdir(outputDir, { recursive: true });
const outputPath = path.join(
outputDir,
`${videoId}-${timestamp}-${Date.now()}.jpg`
);
await this.extractFrameAtTimestamp(videoId, timestamp, outputPath);
return outputPath;
} catch (error) {
throw new Error(`Failed to extract thumbnail: ${error instanceof Error ? error.message : String(error)}`);
}
}
@MCPFunction({
description: 'Get video download options',
parameters: {
type: 'object',
properties: {
videoId: { type: 'string' }
},
required: ['videoId']
}
})
async getDownloadOptions({
videoId
}: {
videoId: string
}): Promise<any> {
try {
const info = await ytdl.getInfo(videoId);
const videoFormats = info.formats
.filter(f => f.container === 'mp4')
.map(format => ({
quality: `${format.height}p`,
fps: format.fps,
filesize: format.contentLength ? parseInt(format.contentLength) : null,
mimeType: format.mimeType
}))
.sort((a, b) => (b.quality ? parseInt(b.quality) : 0) - (a.quality ? parseInt(a.quality) : 0));
const audioFormats = info.formats
.filter(f => f.mimeType.includes('audio'))
.map(format => ({
audioQuality: format.audioBitrate,
mimeType: format.mimeType
}));
return {
videoDetails: {
title: info.videoDetails.title,
lengthSeconds: parseInt(info.videoDetails.lengthSeconds),
thumbnails: info.videoDetails.thumbnails
},
videoFormats,
audioFormats
};
} catch (error) {
throw new Error(`Failed to get download options: ${error instanceof Error ? error.message : String(error)}`);
}
}
private async downloadVideoFormat(info: ytdl.videoInfo, outputPath: string, quality: string): Promise<void> {
return new Promise((resolve, reject) => {
const format = this.getBestFormat(info, quality);
const video = ytdl(info.videoDetails.videoId, { format });
ffmpeg(video)
.toFormat('mp4')
.on('end', () => resolve())
.on('error', (err) => reject(err))
.save(outputPath);
});
}
private async downloadAudioFormat(info: ytdl.videoInfo, outputPath: string, format: string): Promise<void> {
return new Promise((resolve, reject) => {
const video = ytdl(info.videoDetails.videoId, {
quality: 'highestaudio',
filter: 'audioonly'
});
ffmpeg(video)
.toFormat(format)
.on('end', () => resolve())
.on('error', (err) => reject(err))
.save(outputPath);
});
}
private async extractFrameAtTimestamp(videoId: string, timestamp: number, outputPath: string): Promise<void> {
return new Promise((resolve, reject) => {
const video = ytdl(videoId);
ffmpeg(video)
.screenshots({
timestamps: [timestamp],
filename: path.basename(outputPath),
folder: path.dirname(outputPath)
})
.on('end', () => resolve())
.on('error', (err) => reject(err));
});
}
private getBestFormat(info: ytdl.videoInfo, quality: string): ytdl.videoFormat {
const formats = info.formats.filter(f => f.container === 'mp4');
if (quality === 'highest') {
return formats.sort((a, b) => (b.height || 0) - (a.height || 0))[0];
}
if (quality === 'lowest') {
return formats.sort((a, b) => (a.height || 0) - (b.height || 0))[0];
}
const targetHeight = parseInt(quality);
return formats
.sort((a, b) =>
Math.abs((a.height || 0) - targetHeight) - Math.abs((b.height || 0) - targetHeight)
)[0];
}
}