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"; // 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; } } export class PlaylistManager implements MCPFunctionGroup { private youtube: any; constructor() { this.youtube = google.youtube({ version: 'v3', auth: process.env.YOUTUBE_API_KEY }); } @MCPFunction({ description: 'Create intelligent playlist', parameters: { type: 'object', properties: { title: { type: 'string' }, sourceVideos: { type: 'array', items: { type: 'string' } }, duration: { type: 'number' }, tags: { type: 'array', items: { type: 'string' } } }, required: ['title', 'sourceVideos'] } }) async createSmartPlaylist({ title, sourceVideos, duration = 0, tags = [] }: { title: string, sourceVideos: string[], duration?: number, tags?: string[] }): Promise<any> { try { const playlist = await this.youtube.playlists.insert({ part: ['snippet', 'status'], requestBody: { snippet: { title, description: this.generateDescription(tags), }, status: { privacyStatus: 'private' } } }); const playlistId = playlist.data.id; const processedVideos = await this.processVideos(sourceVideos, duration); for (const video of processedVideos) { await this.youtube.playlistItems.insert({ part: ['snippet'], requestBody: { snippet: { playlistId, resourceId: { kind: 'youtube#video', videoId: video.id } } } }); } return { id: playlistId, videoCount: processedVideos.length, totalDuration: processedVideos.reduce((sum, v) => sum + v.duration, 0), tags: this.analyzePlaylistTags(processedVideos) }; } catch (error) { throw new Error(`Failed to create playlist: ${error instanceof Error ? error.message : String(error)}`); } } @MCPFunction({ description: 'Optimize playlist order', parameters: { type: 'object', properties: { playlistId: { type: 'string' }, optimizationType: { type: 'string', enum: ['engagement', 'duration', 'views', 'relevance'] } }, required: ['playlistId'] } }) async optimizePlaylist({ playlistId, optimizationType = 'engagement' }: { playlistId: string, optimizationType?: string }): Promise<any> { try { const items = await this.youtube.playlistItems.list({ part: ['snippet', 'contentDetails'], playlistId, maxResults: 50 }); const videos = await Promise.all( items.data.items.map(async (item: any) => { const videoId = item.contentDetails.videoId; const stats = await this.getVideoStats(videoId); return { ...item, stats }; }) ); const optimizedOrder = this.reorderVideos(videos, optimizationType); await this.updatePlaylistOrder(playlistId, optimizedOrder); return { originalOrder: items.data.items.map((i: any) => i.contentDetails.videoId), optimizedOrder: optimizedOrder.map((v: any) => v.contentDetails.videoId), metrics: this.calculateOptimizationMetrics(videos, optimizedOrder) }; } catch (error) { throw new Error(`Failed to optimize playlist: ${error instanceof Error ? error.message : String(error)}`); } } @MCPFunction({ description: 'Generate playlist suggestions', parameters: { type: 'object', properties: { sourcePlaylistId: { type: 'string' }, maxSuggestions: { type: 'number' } }, required: ['sourcePlaylistId'] } }) async suggestVideos({ sourcePlaylistId, maxSuggestions = 10 }: { sourcePlaylistId: string, maxSuggestions?: number }): Promise<any[]> { try { const items = await this.youtube.playlistItems.list({ part: ['contentDetails'], playlistId: sourcePlaylistId }); const sourceVideos = items.data.items.map((i: any) => i.contentDetails.videoId); const suggestions: any[] = []; for (const videoId of sourceVideos) { const related = await this.youtube.search.list({ part: ['snippet'], relatedToVideoId: videoId, type: ['video'], maxResults: 5 }); suggestions.push(...(await Promise.all( related.data.items .filter((item: any) => !sourceVideos.includes(item.id.videoId)) .map(async (item: any) => ({ videoId: item.id.videoId, title: item.snippet.title, relevanceScore: await this.calculateRelevance(videoId, item.id.videoId) })) ))); } return suggestions .sort((a, b) => b.relevanceScore - a.relevanceScore) .slice(0, maxSuggestions); } catch (error) { throw new Error(`Failed to suggest videos: ${error instanceof Error ? error.message : String(error)}`); } } // Private Helper Methods private async processVideos(videoIds: string[], targetDuration: number): Promise<any[]> { const videos = await Promise.all( videoIds.map(async (id) => { const details = await this.youtube.videos.list({ part: ['contentDetails', 'statistics', 'snippet'], id: [id] }); return details.data.items?.[0]; }) ); if (targetDuration > 0) { return this.selectVideosForDuration(videos, targetDuration); } return videos; } private selectVideosForDuration(videos: any[], targetDuration: number): any[] { const selected = []; let currentDuration = 0; for (const video of videos) { const duration = this.parseDuration(video.contentDetails.duration); if (currentDuration + duration <= targetDuration) { selected.push(video); currentDuration += duration; } } return selected; } private parseDuration(duration: string): number { const match = duration.match(/PT(\d+H)?(\d+M)?(\d+S)?/); let seconds = 0; if (match?.[1]) seconds += parseInt(match[1]) * 3600; if (match?.[2]) seconds += parseInt(match[2]) * 60; if (match?.[3]) seconds += parseInt(match[3]); return seconds; } private generateDescription(tags: string[]): string { if (tags.length === 0) return ''; return `Curated playlist featuring: ${tags.join(', ')}`; } private async getVideoStats(videoId: string): Promise<any> { const response = await this.youtube.videos.list({ part: ['statistics', 'contentDetails'], id: [videoId] }); return response.data.items?.[0]; } private reorderVideos(videos: any[], type: string): any[] { switch (type) { case 'engagement': return videos.sort((a, b) => { const aEngagement = (parseInt(a.stats.statistics.likeCount) + parseInt(a.stats.statistics.commentCount)) / parseInt(a.stats.statistics.viewCount); const bEngagement = (parseInt(b.stats.statistics.likeCount) + parseInt(b.stats.statistics.commentCount)) / parseInt(b.stats.statistics.viewCount); return bEngagement - aEngagement; }); case 'duration': return videos.sort((a, b) => { const aDuration = this.parseDuration(a.stats.contentDetails.duration); const bDuration = this.parseDuration(b.stats.contentDetails.duration); return aDuration - bDuration; }); case 'views': return videos.sort((a, b) => parseInt(b.stats.statistics.viewCount) - parseInt(a.stats.statistics.viewCount)); case 'relevance': return this.orderByRelevance(videos); default: return videos; } } private orderByRelevance(videos: any[]): any[] { const ordered = [...videos]; const titleWords = new Map<string, number>(); videos.forEach(video => { const words = video.stats.snippet.title.toLowerCase().split(/\s+/); words.forEach(word => { titleWords.set(word, (titleWords.get(word) || 0) + 1); }); }); ordered.forEach(video => { const words = video.stats.snippet.title.toLowerCase().split(/\s+/); video.relevanceScore = words.reduce((score, word) => score + (titleWords.get(word) || 0), 0); }); return ordered.sort((a, b) => b.relevanceScore - a.relevanceScore); } private async updatePlaylistOrder(playlistId: string, videos: any[]): Promise<void> { for (let i = 0; i < videos.length; i++) { await this.youtube.playlistItems.update({ part: ['snippet'], requestBody: { id: videos[i].id, snippet: { playlistId, position: i, resourceId: { kind: 'youtube#video', videoId: videos[i].contentDetails.videoId } } } }); } } private calculateOptimizationMetrics(original: any[], optimized: any[]): any { return { totalViews: optimized.reduce((sum, v) => sum + parseInt(v.stats.statistics.viewCount), 0), averageEngagement: optimized.reduce((sum, v) => { const engagement = (parseInt(v.stats.statistics.likeCount) + parseInt(v.stats.statistics.commentCount)) / parseInt(v.stats.statistics.viewCount); return sum + engagement; }, 0) / optimized.length, durationSpread: this.calculateDurationSpread(optimized), relevanceScore: this.calculateRelevanceScore(optimized) }; } private calculateDurationSpread(videos: any[]): number { const durations = videos.map(v => this.parseDuration(v.stats.contentDetails.duration)); const avg = durations.reduce((a, b) => a + b, 0) / durations.length; return Math.sqrt(durations.reduce((sq, n) => sq + Math.pow(n - avg, 2), 0) / durations.length); } private calculateRelevanceScore(videos: any[]): number { const totalRelevance = videos.reduce((score, video) => { return score + (video.relevanceScore || 0); }, 0); return totalRelevance / videos.length; } private async calculateRelevance(sourceId: string, targetId: string): Promise<number> { const [source, target] = await Promise.all([ this.youtube.videos.list({ part: ['snippet', 'topicDetails'], id: [sourceId] }), this.youtube.videos.list({ part: ['snippet', 'topicDetails'], id: [targetId] }) ]); let score = 0; const sourceVideo = source.data.items?.[0]; const targetVideo = target.data.items?.[0]; if (sourceVideo?.topicDetails?.topicIds && targetVideo?.topicDetails?.topicIds) { const commonTopics = sourceVideo.topicDetails.topicIds .filter((t: string) => targetVideo.topicDetails.topicIds.includes(t)); score += commonTopics.length * 0.3; } const sourceTags = new Set(sourceVideo?.snippet?.tags || []); const targetTags = new Set(targetVideo?.snippet?.tags || []); const commonTags = [...sourceTags].filter(t => targetTags.has(t)); score += commonTags.length * 0.2; const sourceWords = new Set((sourceVideo?.snippet?.title || '').toLowerCase().split(/\s+/)); const targetWords = new Set((targetVideo?.snippet?.title || '').toLowerCase().split(/\s+/)); const commonWords = [...sourceWords].filter(w => targetWords.has(w)); score += commonWords.length * 0.1; return Math.min(1, score); } private analyzePlaylistTags(videos: any[]): string[] { const tagFrequency = new Map<string, number>(); videos.forEach(video => { const tags = video.snippet.tags || []; tags.forEach((tag: string) => { tagFrequency.set(tag, (tagFrequency.get(tag) || 0) + 1); }); }); return [...tagFrequency.entries()] .sort((a, b) => b[1] - a[1]) .slice(0, 10) .map(([tag]) => tag); } }