summarai-mcp
Version:
MCP server for YouTube video summarization and information retrieval using SummarAI API
658 lines (569 loc) • 21 kB
text/typescript
const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
const {
CallToolRequestSchema,
ListToolsRequestSchema,
} = require('@modelcontextprotocol/sdk/types.js');
const axios = require('axios');
// Get API configuration from environment variables
const API_KEY = process.env.API_KEY;
const YOUTUBE_VIDEO_SUMMARY_API_URL = process.env.YOUTUBE_VIDEO_SUMMARY_API_URL || 'https://summarai-sale-python-backend.onrender.com/api/youtube/summarize';
const YOUTUBE_VIDEO_INFO_API_URL = process.env.YOUTUBE_VIDEO_INFO_API_URL || 'https://summarai-sale-python-backend.onrender.com/api/youtube/info';
interface SummarizeVideoParams {
youtube_video_url: string;
custom_prompt?: string;
}
interface VideoInfoParams {
youtube_video_url: string;
}
interface ApiResponse {
success: boolean;
data?: {
video_id: string;
title: string;
transcript_length: number;
summary: string;
};
error?: string;
}
interface VideoInfoApiResponse {
success: boolean;
data?: {
video_id: string;
title: string;
transcript_available: boolean;
available_languages: string[];
};
error?: string;
}
interface Tool {
name: string;
description: string;
inputSchema: any;
}
interface CallToolResult {
content: Array<{
type: string;
text: string;
}>;
isError?: boolean;
}
interface TextContent {
type: 'text';
text: string;
}
class SummarAIMCPServer {
private server: any;
constructor() {
this.server = new Server(
{
name: 'summarai-mcp',
version: '1.0.8',
},
{
capabilities: {
tools: {},
},
}
);
this.setupToolHandlers();
// Error handling
this.server.onerror = (error: any) => console.error('[MCP Error]', error);
process.on('SIGINT', async () => {
await this.server.close();
process.exit(0);
});
}
private setupToolHandlers() {
// List available tools
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: 'summarize_youtube_video',
description: 'Summarize a YouTube video using SummarAI API. Provide a YouTube URL and optionally a custom prompt for the summarization.',
inputSchema: {
type: 'object',
properties: {
youtube_video_url: {
type: 'string',
description: 'The YouTube video URL to summarize (must be a valid YouTube URL)',
},
custom_prompt: {
type: 'string',
description: 'Optional custom prompt for the summarization. If not provided, a default prompt will be used.',
},
},
required: ['youtube_video_url'],
},
} as Tool,
{
name: 'get_youtube_video_info',
description: 'Get information about a YouTube video including title, video ID, transcript availability, and available languages.',
inputSchema: {
type: 'object',
properties: {
youtube_video_url: {
type: 'string',
description: 'The YouTube video URL to get information for (must be a valid YouTube URL)',
},
},
required: ['youtube_video_url'],
},
} as Tool,
],
};
});
// Handle tool calls
this.server.setRequestHandler(CallToolRequestSchema, async (request: any) => {
const { name, arguments: args } = request.params;
if (name === 'summarize_youtube_video') {
// Validate and convert args to proper type
if (!args || typeof args !== 'object') {
throw new Error('Invalid arguments provided');
}
const params = args as Record<string, unknown>;
// Validate required parameters
if (!params.youtube_video_url || typeof params.youtube_video_url !== 'string') {
throw new Error('youtube_video_url is required and must be a string');
}
const validatedParams: SummarizeVideoParams = {
youtube_video_url: params.youtube_video_url,
custom_prompt: typeof params.custom_prompt === 'string' ? params.custom_prompt : undefined,
};
return this.handleSummarizeVideo(validatedParams);
}
if (name === 'get_youtube_video_info') {
// Validate and convert args to proper type
if (!args || typeof args !== 'object') {
throw new Error('Invalid arguments provided');
}
const params = args as Record<string, unknown>;
// Validate required parameters
if (!params.youtube_video_url || typeof params.youtube_video_url !== 'string') {
throw new Error('youtube_video_url is required and must be a string');
}
const validatedParams: VideoInfoParams = {
youtube_video_url: params.youtube_video_url,
};
return this.handleGetVideoInfo(validatedParams);
}
throw new Error(`Unknown tool: ${name}`);
});
}
private extractYouTubeUrlFromRedirect(url: string): string {
try {
const urlObj = new URL(url);
// Check if it's a Google redirect URL
if (urlObj.hostname.includes('google.com') || urlObj.hostname.includes('google.')) {
const searchParams = urlObj.searchParams;
// Look for the 'url' parameter which contains the actual YouTube URL
if (searchParams.has('url')) {
let extractedUrl = decodeURIComponent(searchParams.get('url')!);
// Additional decoding in case of double encoding
if (extractedUrl.includes('%')) {
extractedUrl = decodeURIComponent(extractedUrl);
}
console.error(`[DEBUG] Extracted YouTube URL from Google redirect: ${extractedUrl}`);
return extractedUrl;
}
// Sometimes the URL might be in 'q' parameter
else if (searchParams.has('q')) {
let extractedUrl = decodeURIComponent(searchParams.get('q')!);
if (extractedUrl.includes('youtube.com') || extractedUrl.includes('youtu.be')) {
if (extractedUrl.includes('%')) {
extractedUrl = decodeURIComponent(extractedUrl);
}
console.error(`[DEBUG] Extracted YouTube URL from Google 'q' parameter: ${extractedUrl}`);
return extractedUrl;
}
}
// Check for other Google service redirects (like from Gmail, Google Docs, etc.)
else if (urlObj.pathname.includes('/url')) {
for (const param of ['target', 'dest']) {
if (searchParams.has(param)) {
let extractedUrl = decodeURIComponent(searchParams.get(param)!);
if (extractedUrl.includes('youtube.com') || extractedUrl.includes('youtu.be')) {
if (extractedUrl.includes('%')) {
extractedUrl = decodeURIComponent(extractedUrl);
}
console.error(`[DEBUG] Extracted YouTube URL from Google '${param}' parameter: ${extractedUrl}`);
return extractedUrl;
}
}
}
}
}
// Check for other common redirect patterns (shortened URLs)
else if (['t.co', 'bit.ly', 'tinyurl.com', 'short.link', 'ow.ly', 'is.gd'].some(domain => urlObj.hostname.includes(domain))) {
console.error(`[DEBUG] Detected shortened URL: ${url}`);
// For shortened URLs, we return as-is since we can't follow redirects in this context
// The backend API will handle the redirect following
return url;
}
// If it's not a redirect URL, return the original URL
return url;
} catch (error) {
console.error(`[DEBUG] Error extracting URL from redirect: ${error}`);
return url;
}
}
private extractYouTubeVideoId(url: string): string | null {
try {
console.error(`[DEBUG] Processing URL for video ID extraction: ${url}`);
// First, try to extract the actual YouTube URL from potential redirects
const actualUrl = this.extractYouTubeUrlFromRedirect(url);
console.error(`[DEBUG] URL after redirect extraction: ${actualUrl}`);
// Parse the URL to handle various formats
const urlObj = new URL(actualUrl);
// Handle youtube.com URLs
if (urlObj.hostname === 'www.youtube.com' || urlObj.hostname === 'youtube.com') {
// Standard YouTube URL: https://www.youtube.com/watch?v=VIDEO_ID
if (urlObj.pathname === '/watch' || urlObj.pathname.startsWith('/watch')) {
const videoId = urlObj.searchParams.get('v');
if (videoId && /^[a-zA-Z0-9_-]{11}$/.test(videoId)) {
console.error(`[DEBUG] Extracted video ID from youtube.com/watch: ${videoId}`);
return videoId;
}
}
// Handle youtube.com/embed URLs
else if (urlObj.pathname.startsWith('/embed/')) {
const pathParts = urlObj.pathname.split('/embed/');
if (pathParts.length > 1) {
let videoId = pathParts[1];
// Clean the video ID (remove any additional parameters)
videoId = videoId.replace(/[^a-zA-Z0-9_-].*/, '');
if (videoId.length === 11 && /^[a-zA-Z0-9_-]{11}$/.test(videoId)) {
console.error(`[DEBUG] Extracted video ID from youtube.com/embed: ${videoId}`);
return videoId;
}
}
}
// Handle youtube.com/v/ URLs
else if (urlObj.pathname.startsWith('/v/')) {
const pathParts = urlObj.pathname.split('/v/');
if (pathParts.length > 1) {
let videoId = pathParts[1];
videoId = videoId.replace(/[^a-zA-Z0-9_-].*/, '');
if (videoId.length === 11 && /^[a-zA-Z0-9_-]{11}$/.test(videoId)) {
console.error(`[DEBUG] Extracted video ID from youtube.com/v/: ${videoId}`);
return videoId;
}
}
}
}
// Handle youtu.be URLs
else if (urlObj.hostname === 'youtu.be') {
// Short YouTube URL: https://youtu.be/VIDEO_ID
let videoId = urlObj.pathname.substring(1); // Remove leading slash
// Clean the video ID (remove any additional parameters)
videoId = videoId.replace(/[^a-zA-Z0-9_-].*/, '');
if (videoId.length === 11 && /^[a-zA-Z0-9_-]{11}$/.test(videoId)) {
console.error(`[DEBUG] Extracted video ID from youtu.be: ${videoId}`);
return videoId;
}
}
// Fallback: try regex patterns for various YouTube URL formats
const patterns = [
/(?:v=|\/watch\?v=)([a-zA-Z0-9_-]{11})/, // Standard YouTube URL
/(?:youtu\.be\/)([a-zA-Z0-9_-]{11})/, // Shortened YouTube URL
/(?:embed\/)([a-zA-Z0-9_-]{11})/, // Embedded URLs
/(?:\/v\/)([a-zA-Z0-9_-]{11})/, // /v/ URLs
/(?:watch%3Fv%3D)([a-zA-Z0-9_-]{11})/, // URL encoded
/(?:&v=|%26v%3D)([a-zA-Z0-9_-]{11})/ // Additional parameter formats
];
for (const pattern of patterns) {
const match = actualUrl.match(pattern);
if (match && match[1]) {
const videoId = match[1];
console.error(`[DEBUG] Extracted video ID using regex pattern: ${videoId}`);
return videoId;
}
}
console.error(`[DEBUG] Could not extract video ID from URL: ${actualUrl}`);
return null;
} catch (error) {
console.error(`[DEBUG] Error extracting video ID: ${error}`);
return null;
}
}
private validateYouTubeUrl(url: string): { isValid: boolean; cleanUrl?: string; error?: string } {
console.error(`[DEBUG] Validating YouTube URL: ${url}`);
const videoId = this.extractYouTubeVideoId(url);
if (!videoId) {
console.error(`[DEBUG] Invalid or missing video ID`);
return {
isValid: false,
error: 'Invalid YouTube URL format. Please provide a valid YouTube video URL.'
};
}
// Return a clean, standard YouTube URL
const cleanUrl = `https://www.youtube.com/watch?v=${videoId}`;
console.error(`[DEBUG] Valid YouTube URL with video ID: ${videoId}`);
return {
isValid: true,
cleanUrl
};
}
private async handleSummarizeVideo(params: SummarizeVideoParams): Promise<CallToolResult> {
const { youtube_video_url, custom_prompt } = params;
// Validate API key
if (!API_KEY) {
return {
content: [
{
type: 'text',
text: 'Error: API_KEY environment variable is not set. Please configure your API key.',
} as TextContent,
],
isError: true,
};
}
// Validate and clean YouTube URL
const urlValidation = this.validateYouTubeUrl(youtube_video_url);
if (!urlValidation.isValid) {
return {
content: [
{
type: 'text',
text: `Error: ${urlValidation.error}`,
} as TextContent,
],
isError: true,
};
}
const cleanYouTubeUrl = urlValidation.cleanUrl!;
try {
// Prepare request data using the clean URL
const requestData = {
url: cleanYouTubeUrl,
custom_prompt: custom_prompt || 'Summarize this YouTube video transcript:',
};
// Make API call
const response = await axios.post(
YOUTUBE_VIDEO_SUMMARY_API_URL,
requestData,
{
headers: {
'Content-Type': 'application/json',
'x-api-key': API_KEY,
},
timeout: 120000, // 2 minutes timeout
}
);
const { data: responseData } = response;
if (!responseData.success) {
return {
content: [
{
type: 'text',
text: `Error: ${responseData.error || 'Failed to summarize video'}`,
} as TextContent,
],
isError: true,
};
}
if (!responseData.data) {
return {
content: [
{
type: 'text',
text: 'Error: No data received from the API',
} as TextContent,
],
isError: true,
};
}
// Format the successful response
const { video_id, title, transcript_length, summary } = responseData.data;
const resultText = `# YouTube Video Summary
**Video Title:** ${title}
**Video ID:** ${video_id}
**Transcript Length:** ${transcript_length} characters
## Summary
${summary}`;
return {
content: [
{
type: 'text',
text: resultText,
} as TextContent,
],
};
} catch (error) {
let errorMessage = 'Unknown error occurred';
if (axios.isAxiosError(error)) {
if (error.response) {
// Server responded with error status
const status = error.response.status;
const data = error.response.data;
if (status === 401) {
errorMessage = 'Authentication failed. Please check your API key.';
} else if (status === 400) {
errorMessage = `Bad request: ${data?.detail || data?.error || 'Invalid request parameters'}`;
} else if (status === 429) {
errorMessage = 'Rate limit exceeded. Please try again later.';
} else if (status >= 500) {
errorMessage = 'Server error. The service may be temporarily unavailable.';
} else {
errorMessage = `HTTP ${status}: ${data?.detail || data?.error || error.message}`;
}
} else if (error.request) {
// Network error
errorMessage = 'Network error: Unable to connect to the API. Please check your internet connection and API URL.';
} else {
errorMessage = `Request error: ${error.message}`;
}
} else if (error instanceof Error) {
errorMessage = error.message;
} else {
errorMessage = String(error);
}
return {
content: [
{
type: 'text',
text: `Error: ${errorMessage}`,
} as TextContent,
],
isError: true,
};
}
}
private async handleGetVideoInfo(params: VideoInfoParams): Promise<CallToolResult> {
const { youtube_video_url } = params;
// Validate API key
if (!API_KEY) {
return {
content: [
{
type: 'text',
text: 'Error: API_KEY environment variable is not set. Please configure your API key.',
} as TextContent,
],
isError: true,
};
}
// Validate and clean YouTube URL
const urlValidation = this.validateYouTubeUrl(youtube_video_url);
if (!urlValidation.isValid) {
return {
content: [
{
type: 'text',
text: `Error: ${urlValidation.error}`,
} as TextContent,
],
isError: true,
};
}
const cleanYouTubeUrl = urlValidation.cleanUrl!;
try {
// Prepare request data using the clean URL
const requestData = {
url: cleanYouTubeUrl,
};
// Make API call
const response = await axios.post(
YOUTUBE_VIDEO_INFO_API_URL,
requestData,
{
headers: {
'Content-Type': 'application/json',
'x-api-key': API_KEY,
},
timeout: 30000, // 30 seconds timeout
}
);
const { data: responseData } = response;
if (!responseData.success) {
return {
content: [
{
type: 'text',
text: `Error: ${responseData.error || 'Failed to get video information'}`,
} as TextContent,
],
isError: true,
};
}
if (!responseData.data) {
return {
content: [
{
type: 'text',
text: 'Error: No data received from the API',
} as TextContent,
],
isError: true,
};
}
// Format the successful response
const { video_id, title, transcript_available, available_languages } = responseData.data;
const languagesList = available_languages && available_languages.length > 0
? available_languages.join(', ')
: 'None';
const resultText = `# YouTube Video Information
**Video Title:** ${title}
**Video ID:** ${video_id}
**Transcript Available:** ${transcript_available ? 'Yes' : 'No'}
**Available Languages:** ${languagesList}
${transcript_available ?
'✅ This video has transcripts available and can be summarized.' :
'❌ This video does not have transcripts available and cannot be summarized.'}`;
return {
content: [
{
type: 'text',
text: resultText,
} as TextContent,
],
};
} catch (error) {
let errorMessage = 'Unknown error occurred';
if (axios.isAxiosError(error)) {
if (error.response) {
// Server responded with error status
const status = error.response.status;
const data = error.response.data;
if (status === 401) {
errorMessage = 'Authentication failed. Please check your API key.';
} else if (status === 400) {
errorMessage = `Bad request: ${data?.detail || data?.error || 'Invalid request parameters'}`;
} else if (status === 429) {
errorMessage = 'Rate limit exceeded. Please try again later.';
} else if (status >= 500) {
errorMessage = 'Server error. The service may be temporarily unavailable.';
} else {
errorMessage = `HTTP ${status}: ${data?.detail || data?.error || error.message}`;
}
} else if (error.request) {
// Network error
errorMessage = 'Network error: Unable to connect to the API. Please check your internet connection and API URL.';
} else {
errorMessage = `Request error: ${error.message}`;
}
} else if (error instanceof Error) {
errorMessage = error.message;
} else {
errorMessage = String(error);
}
return {
content: [
{
type: 'text',
text: `Error: ${errorMessage}`,
} as TextContent,
],
isError: true,
};
}
}
async run() {
const transport = new StdioServerTransport();
await this.server.connect(transport);
console.error('SummarAI MCP server running on stdio');
}
}
module.exports = { SummarAIMCPServer };