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;
}
}
export class ShortsManager implements MCPFunctionGroup {
private youtube: any;
constructor() {
this.youtube = google.youtube({
version: 'v3',
auth: process.env.YOUTUBE_API_KEY
});
}
@MCPFunction({
description: 'Create Short from video segment',
parameters: {
type: 'object',
properties: {
videoId: { type: 'string' },
startTime: { type: 'number' },
duration: { type: 'number' },
title: { type: 'string' },
effects: { type: 'array', items: { type: 'string' } }
},
required: ['videoId', 'startTime']
}
})
async createShort({
videoId,
startTime,
duration = 60,
title,
effects = []
}: {
videoId: string,
startTime: number,
duration?: number,
title?: string,
effects?: string[]
}): Promise<string> {
try {
const outputDir = path.join(process.cwd(), 'shorts');
await fs.mkdir(outputDir, { recursive: true });
const outputPath = path.join(outputDir, `${videoId}-short-${Date.now()}.mp4`);
await this.extractAndProcessSegment(
videoId,
startTime,
Math.min(duration, 60),
outputPath,
effects
);
const uploadedVideoId = await this.uploadShort(
outputPath,
title || `Short from ${videoId}`,
effects
);
await fs.unlink(outputPath);
return uploadedVideoId;
} catch (error) {
throw new Error(`Failed to create Short: ${error instanceof Error ? error.message : String(error)}`);
}
}
@MCPFunction({
description: 'Find optimal segments for Shorts',
parameters: {
type: 'object',
properties: {
videoId: { type: 'string' },
maxSegments: { type: 'number' }
},
required: ['videoId']
}
})
async findShortSegments({
videoId,
maxSegments = 3
}: {
videoId: string,
maxSegments?: number
}): Promise<any[]> {
try {
const video = await this.youtube.videos.list({
part: ['contentDetails', 'statistics'],
id: [videoId]
});
const markers = await this.getEngagementMarkers(videoId);
const segments = this.identifyInterestingSegments(markers, maxSegments);
return segments.map(segment => ({
startTime: segment.startTime,
duration: segment.duration,
confidence: segment.confidence,
suggestedEffects: this.suggestEffects(segment.type)
}));
} catch (error) {
throw new Error(`Failed to find segments: ${error instanceof Error ? error.message : String(error)}`);
}
}
// Private helper methods
private async extractAndProcessSegment(
videoId: string,
startTime: number,
duration: number,
outputPath: string,
effects: string[]
): Promise<void> {
return new Promise((resolve, reject) => {
const video = ytdl(videoId, { quality: 'highest' });
let command = ffmpeg(video)
.seekInput(startTime)
.duration(duration)
.size('1080x1920')
.videoFilter('scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:-1:-1');
effects.forEach(effect => {
switch (effect) {
case 'speedup':
command = command.videoFilters('setpts=0.5*PTS');
break;
case 'slowdown':
command = command.videoFilters('setpts=2*PTS');
break;
case 'fade':
command = command.videoFilters(`fade=in:0:30,fade=out:st=${duration-1}:d=1`);
break;
case 'mirror':
command = command.videoFilters('hflip');
break;
case 'blur-background':
command = command.complexFilter([
'[0:v]split[original][blur]',
'[blur]scale=1080:1920,boxblur=20:20[blurred]',
'[original]scale=1080:1920:force_original_aspect_ratio=decrease[scaled]',
'[blurred][scaled]overlay=(W-w)/2:(H-h)/2'
]);
break;
}
});
command
.outputOptions('-c:v', 'libx264')
.outputOptions('-c:a', 'aac')
.outputOptions('-movflags', '+faststart')
.toFormat('mp4')
.on('end', () => resolve())
.on('error', (err) => reject(err))
.save(outputPath);
});
}
private async uploadShort(filePath: string, title: string, effects: string[]): Promise<string> {
const fileSize = (await fs.stat(filePath)).size;
const res = await this.youtube.videos.insert({
part: ['snippet', 'status'],
requestBody: {
snippet: {
title,
description: `Created with effects: ${effects.join(', ')}`,
tags: ['Short'],
categoryId: '22'
},
status: {
privacyStatus: 'public',
selfDeclaredMadeForKids: false
}
},
media: {
body: fs.createReadStream(filePath)
}
});
return res.data.id;
}
private async getEngagementMarkers(videoId: string): Promise<any[]> {
const [analytics, comments] = await Promise.all([
this.youtube.videos.list({
part: ['statistics', 'topicDetails'],
id: [videoId]
}),
this.youtube.commentThreads.list({
part: ['snippet'],
videoId,
order: 'relevance',
maxResults: 100
})
]);
const markers: any[] = [];
comments.data.items.forEach(comment => {
const text = comment.snippet.topLevelComment.snippet.textOriginal;
const timestamp = this.extractTimestamp(text);
if (timestamp) {
markers.push({
time: timestamp,
type: 'comment',
engagement: parseInt(comment.snippet.topLevelComment.snippet.likeCount)
});
}
});
return markers;
}
private extractTimestamp(text: string): number | null {
const timePattern = /(\d+:)?(\d+):(\d+)/;
const match = text.match(timePattern);
if (match) {
const [hours, minutes, seconds] = match.slice(1).map(t => parseInt(t || '0'));
return hours * 3600 + minutes * 60 + seconds;
}
return null;
}
private identifyInterestingSegments(markers: any[], maxSegments: number): any[] {
const segments: any[] = [];
const windowSize = 60; // 60 seconds for Shorts
for (let i = 0; i < markers.length; i++) {
const segmentMarkers = markers.filter(m =>
m.time >= markers[i].time &&
m.time < markers[i].time + windowSize
);
if (segmentMarkers.length > 0) {
const engagement = segmentMarkers.reduce((sum, m) => sum + m.engagement, 0);
segments.push({
startTime: markers[i].time,
duration: windowSize,
markers: segmentMarkers,
engagement,
type: this.determineSegmentType(segmentMarkers),
confidence: this.calculateConfidence(segmentMarkers)
});
}
}
return segments
.sort((a, b) => b.engagement - a.engagement)
.slice(0, maxSegments);
}
private determineSegmentType(markers: any[]): string {
const types = markers.map(m => m.type);
const typeCount: Record<string, number> = {};
types.forEach(t => {
typeCount[t] = (typeCount[t] || 0) + 1;
});
return Object.entries(typeCount)
.sort(([,a], [,b]) => (b as number) - (a as number))[0][0];
}
private calculateConfidence(markers: any[]): number {
const factors = {
markerCount: Math.min(markers.length / 5, 1),
engagementSpread: this.calculateEngagementSpread(markers),
markerTypes: new Set(markers.map(m => m.type)).size / 3
};
return Object.values(factors)
.reduce((sum, val) => sum + val, 0) / Object.keys(factors).length;
}
private calculateEngagementSpread(markers: any[]): number {
const engagements = markers.map(m => m.engagement);
const max = Math.max(...engagements);
const min = Math.min(...engagements);
return 1 - ((max - min) / max);
}
private suggestEffects(segmentType: string): string[] {
const effects: string[] = [];
switch (segmentType) {
case 'action':
effects.push('speedup', 'fade');
break;
case 'highlight':
effects.push('slowdown', 'blur-background');
break;
case 'transition':
effects.push('fade');
break;
default:
effects.push('blur-background');
}
return effects;
}
}