Skip to main content
Glama

YouTube MCP Server

youtube_mcp_server.py66.5 kB
#!/usr/bin/env python3 """ YouTube MCP Server A Model Context Protocol server that provides access to YouTube data via the YouTube Data API v3. Provides tools for getting video details, playlist information, and playlist items. """ import os import re from pathlib import Path from typing import Any, Dict, List, Optional from urllib.parse import parse_qs, urlparse import httpx from mcp.server.fastmcp import FastMCP # YouTube transcript API imports (optional dependency) try: from youtube_transcript_api import YouTubeTranscriptApi from youtube_transcript_api._errors import TranscriptsDisabled, NoTranscriptFound TRANSCRIPT_API_AVAILABLE = True except ImportError: TRANSCRIPT_API_AVAILABLE = False YouTubeTranscriptApi = None TranscriptsDisabled = None NoTranscriptFound = None # Initialize the MCP server mcp = FastMCP("YouTube Data Server") # YouTube API configuration YOUTUBE_API_BASE = "https://www.googleapis.com/youtube/v3" # Load API key from credentials file def load_api_key() -> str: """Load YouTube API key from credentials.yml file.""" script_dir = Path(__file__).parent credentials_file = script_dir / "credentials.yml" try: with open(credentials_file, 'r') as f: content = f.read() # Simple parsing for youtube_api_key: "value" for line in content.split('\n'): if line.strip().startswith('youtube_api_key:'): # Extract the value between quotes key_part = line.split('youtube_api_key:')[1].strip() if key_part.startswith('"') and key_part.endswith('"'): return key_part[1:-1] # Remove quotes else: return key_part.strip('\"\'') raise ValueError("youtube_api_key not found in credentials.yml") except FileNotFoundError: raise ValueError("credentials.yml file not found. Please create it with your YouTube API key.") except Exception as e: raise ValueError(f"Error reading credentials.yml: {str(e)}") API_KEY = load_api_key() def get_video_id_from_url(url: str) -> Optional[str]: """ Extract video ID from various YouTube URL formats. Supports: - https://www.youtube.com/watch?v=VIDEO_ID - https://youtu.be/VIDEO_ID - https://youtube.com/watch?v=VIDEO_ID - https://m.youtube.com/watch?v=VIDEO_ID """ if not url: return None # Handle youtu.be format if "youtu.be/" in url: return url.split("youtu.be/")[-1].split("?")[0].split("&")[0] # Handle youtube.com format parsed = urlparse(url) if parsed.hostname in ["www.youtube.com", "youtube.com", "m.youtube.com"]: query_params = parse_qs(parsed.query) return query_params.get("v", [None])[0] # If it's already just an ID (11 characters, alphanumeric + - and _) if re.match(r'^[a-zA-Z0-9_-]{11}$', url): return url return None def get_playlist_id_from_url(url: str) -> Optional[str]: """ Extract playlist ID from YouTube URL formats. Supports: - https://www.youtube.com/playlist?list=PLAYLIST_ID - https://youtube.com/playlist?list=PLAYLIST_ID """ if not url: return None parsed = urlparse(url) if parsed.hostname in ["www.youtube.com", "youtube.com", "m.youtube.com"]: query_params = parse_qs(parsed.query) return query_params.get("list", [None])[0] # If it's already just an ID if re.match(r'^[a-zA-Z0-9_-]+$', url): return url return None def get_channel_id_from_url(url: str) -> Optional[str]: """ Extract channel ID from YouTube channel URL formats. Supports: - https://www.youtube.com/channel/CHANNEL_ID - https://www.youtube.com/c/channelname - https://www.youtube.com/@username - https://youtube.com/user/username - @username (direct format) """ if not url: return None # Handle direct @username format if url.startswith('@'): return url[1:] # Remove the @ symbol parsed = urlparse(url) if parsed.hostname in ["www.youtube.com", "youtube.com", "m.youtube.com"]: path = parsed.path # Handle /channel/CHANNEL_ID format if "/channel/" in path: return path.split("/channel/")[1].split("/")[0] # Handle /c/channelname format (custom URL) elif "/c/" in path: return path.split("/c/")[1].split("/")[0] # Handle /@username format elif "/@" in path: return path.split("/@")[1].split("/")[0] # Handle /user/username format (legacy) elif "/user/" in path: return path.split("/user/")[1].split("/")[0] # If it's already a channel ID (starts with UC and 22 chars after UC) if re.match(r'^UC[a-zA-Z0-9_-]{22}$', url): return url # If it's a username or custom name if re.match(r'^[a-zA-Z0-9_-]+$', url): return url return None async def make_youtube_api_request(endpoint: str, params: Dict[str, Any]) -> Dict[str, Any]: """Make a request to the YouTube Data API v3.""" if not API_KEY: raise ValueError("YOUTUBE_API_KEY environment variable is required") params["key"] = API_KEY async with httpx.AsyncClient() as client: try: response = await client.get(f"{YOUTUBE_API_BASE}/{endpoint}", params=params) response.raise_for_status() return response.json() except httpx.HTTPStatusError as e: if e.response.status_code == 403: error_data = e.response.json() if e.response.headers.get("content-type", "").startswith("application/json") else {} error_message = error_data.get("error", {}).get("message", "API quota exceeded or invalid key") raise ValueError(f"YouTube API error (403): {error_message}") elif e.response.status_code == 404: raise ValueError("YouTube resource not found (404)") else: raise ValueError(f"YouTube API error ({e.response.status_code}): {e.response.text}") except httpx.RequestError as e: raise ValueError(f"Network error connecting to YouTube API: {str(e)}") @mcp.tool() async def get_video_details(video_input: str) -> str: """ Get detailed information about a YouTube video. Args: video_input: YouTube video URL or video ID Returns: Formatted string with video details including title, description, statistics, etc. """ # Extract video ID from URL or use as-is if it's already an ID video_id = get_video_id_from_url(video_input) if not video_id: return f"Error: Could not extract video ID from '{video_input}'. Please provide a valid YouTube URL or 11-character video ID." try: # Get video details data = await make_youtube_api_request("videos", { "part": "snippet,statistics,contentDetails,status", "id": video_id }) if not data.get("items"): return f"Error: Video with ID '{video_id}' not found or is not accessible." video = data["items"][0] snippet = video.get("snippet", {}) statistics = video.get("statistics", {}) content_details = video.get("contentDetails", {}) status = video.get("status", {}) # Format duration (convert from ISO 8601 format) duration = content_details.get("duration", "Unknown") if duration.startswith("PT"): # Simple parsing for common formats like PT4M13S duration = duration.replace("PT", "").replace("H", "h ").replace("M", "m ").replace("S", "s") # Build formatted response result = f"""YouTube Video Details: Title: {snippet.get('title', 'Unknown')} Channel: {snippet.get('channelTitle', 'Unknown')} Published: {snippet.get('publishedAt', 'Unknown')[:10]} Duration: {duration} Statistics: - Views: {int(statistics.get('viewCount', 0)):,} - Likes: {int(statistics.get('likeCount', 0)):,} - Comments: {int(statistics.get('commentCount', 0)):,} Status: {status.get('privacyStatus', 'Unknown').title()} License: {status.get('license', 'Unknown')} Description: {snippet.get('description', 'No description available')[:500]}{'...' if len(snippet.get('description', '')) > 500 else ''} Video ID: {video_id} Video URL: https://www.youtube.com/watch?v={video_id} """ return result except Exception as e: return f"Error fetching video details: {str(e)}" @mcp.tool() async def get_playlist_details(playlist_input: str) -> str: """ Get information about a YouTube playlist. Args: playlist_input: YouTube playlist URL or playlist ID Returns: Formatted string with playlist details including title, description, video count, etc. """ # Extract playlist ID from URL or use as-is if it's already an ID playlist_id = get_playlist_id_from_url(playlist_input) if not playlist_id: return f"Error: Could not extract playlist ID from '{playlist_input}'. Please provide a valid YouTube playlist URL or playlist ID." try: # Get playlist details data = await make_youtube_api_request("playlists", { "part": "snippet,status,contentDetails", "id": playlist_id }) if not data.get("items"): return f"Error: Playlist with ID '{playlist_id}' not found or is not accessible." playlist = data["items"][0] snippet = playlist.get("snippet", {}) status = playlist.get("status", {}) content_details = playlist.get("contentDetails", {}) result = f"""YouTube Playlist Details: Title: {snippet.get('title', 'Unknown')} Channel: {snippet.get('channelTitle', 'Unknown')} Created: {snippet.get('publishedAt', 'Unknown')[:10]} Video Count: {content_details.get('itemCount', 'Unknown')} Privacy Status: {status.get('privacyStatus', 'Unknown').title()} Description: {snippet.get('description', 'No description available')[:500]}{'...' if len(snippet.get('description', '')) > 500 else ''} Playlist ID: {playlist_id} Playlist URL: https://www.youtube.com/playlist?list={playlist_id} """ return result except Exception as e: return f"Error fetching playlist details: {str(e)}" @mcp.tool() async def get_playlist_items(playlist_input: str, max_results: int = 10) -> str: """ Get videos from a YouTube playlist. Args: playlist_input: YouTube playlist URL or playlist ID max_results: Maximum number of videos to return (default: 10, max: 50) Returns: Formatted string with list of videos in the playlist """ # Extract playlist ID from URL or use as-is if it's already an ID playlist_id = get_playlist_id_from_url(playlist_input) if not playlist_id: return f"Error: Could not extract playlist ID from '{playlist_input}'. Please provide a valid YouTube playlist URL or playlist ID." # Validate max_results max_results = max(1, min(50, max_results)) try: # Get playlist items data = await make_youtube_api_request("playlistItems", { "part": "snippet,contentDetails", "playlistId": playlist_id, "maxResults": max_results }) if not data.get("items"): return f"Error: Playlist with ID '{playlist_id}' not found, is empty, or is not accessible." items = data["items"] total_results = data.get("pageInfo", {}).get("totalResults", len(items)) result = f"""YouTube Playlist Items: Playlist ID: {playlist_id} Total Videos: {total_results} Showing: {len(items)} videos Videos: """ for i, item in enumerate(items, 1): snippet = item.get("snippet", {}) video_id = snippet.get("resourceId", {}).get("videoId", "Unknown") result += f""" {i}. {snippet.get('title', 'Unknown Title')} Channel: {snippet.get('videoOwnerChannelTitle', snippet.get('channelTitle', 'Unknown'))} Published: {snippet.get('publishedAt', 'Unknown')[:10]} Video ID: {video_id} URL: https://www.youtube.com/watch?v={video_id} """ if total_results > len(items): result += f"\n... and {total_results - len(items)} more videos" return result except Exception as e: return f"Error fetching playlist items: {str(e)}" @mcp.tool() async def get_channel_details(channel_input: str) -> str: """ Get detailed information about a YouTube channel. Args: channel_input: YouTube channel URL, channel ID, or @username Returns: Formatted string with channel details including name, subscribers, videos, etc. """ # Extract channel ID from URL or use as-is channel_id = get_channel_id_from_url(channel_input) if not channel_id: return f"Error: Could not extract channel ID from '{channel_input}'. Please provide a valid YouTube channel URL, channel ID, or @username." try: # Try to get channel details by ID first try: data = await make_youtube_api_request("channels", { "part": "snippet,statistics,contentDetails,brandingSettings", "id": channel_id }) except: # If ID fails, try as username (for @username format or custom URLs) data = await make_youtube_api_request("channels", { "part": "snippet,statistics,contentDetails,brandingSettings", "forUsername": channel_id }) if not data.get("items"): return f"Error: Channel '{channel_id}' not found or is not accessible." channel = data["items"][0] snippet = channel.get("snippet", {}) statistics = channel.get("statistics", {}) branding = channel.get("brandingSettings", {}).get("channel", {}) # Format subscriber count subs = int(statistics.get("subscriberCount", 0)) if subs >= 1000000: sub_display = f"{subs/1000000:.1f}M" elif subs >= 1000: sub_display = f"{subs/1000:.1f}K" else: sub_display = f"{subs:,}" # Format view count views = int(statistics.get("viewCount", 0)) if views >= 1000000000: view_display = f"{views/1000000000:.1f}B" elif views >= 1000000: view_display = f"{views/1000000:.1f}M" elif views >= 1000: view_display = f"{views/1000:.1f}K" else: view_display = f"{views:,}" result = f"""YouTube Channel Details: Name: {snippet.get('title', 'Unknown')} Handle: @{snippet.get('customUrl', 'N/A')} Created: {snippet.get('publishedAt', 'Unknown')[:10]} Statistics: - Subscribers: {sub_display} - Total Videos: {int(statistics.get('videoCount', 0)):,} - Total Views: {view_display} Description: {snippet.get('description', 'No description available')[:500]}{'...' if len(snippet.get('description', '')) > 500 else ''} Channel ID: {channel['id']} Channel URL: https://www.youtube.com/channel/{channel['id']} """ return result except Exception as e: return f"Error fetching channel details: {str(e)}" @mcp.tool() async def get_video_categories(region_code: str = "US") -> str: """ Get list of YouTube video categories for a specific region. Args: region_code: Country code (US, GB, CA, etc.) - default: US Returns: Formatted string with available video categories """ try: # Get video categories data = await make_youtube_api_request("videoCategories", { "part": "snippet", "regionCode": region_code }) if not data.get("items"): return f"No video categories found for region: {region_code}" categories = data["items"] result = f"""YouTube Video Categories - {region_code}: Total Categories: {len(categories)} Categories: """ for category in categories: snippet = category.get("snippet", {}) category_id = category.get("id", "Unknown") title = snippet.get("title", "Unknown") # Check if category is assignable (can be used when uploading) assignable = snippet.get("assignable", True) status = "✅ Assignable" if assignable else "❌ Not assignable" result += f""" {category_id}: {title} ({status})""" result += f""" Note: Assignable categories can be used when uploading videos. Non-assignable categories are for YouTube's internal classification. """ return result except Exception as e: return f"Error fetching video categories: {str(e)}" @mcp.tool() async def get_channel_videos(channel_input: str, max_results: int = 10) -> str: """ Get recent videos from a YouTube channel. Args: channel_input: YouTube channel URL, channel ID, or @username max_results: Maximum number of videos to return (default: 10, max: 50) Returns: Formatted string with list of recent videos from the channel """ # Extract channel ID from URL or use as-is channel_id = get_channel_id_from_url(channel_input) if not channel_id: return f"Error: Could not extract channel ID from '{channel_input}'. Please provide a valid YouTube channel URL, channel ID, or @username." # Validate max_results max_results = max(1, min(50, max_results)) try: # First, get the actual channel ID if we have a username or custom URL try: # Try to get channel details to resolve the actual channel ID channel_data = await make_youtube_api_request("channels", { "part": "id,snippet", "id": channel_id }) if not channel_data.get("items"): # Try as username if ID lookup failed channel_data = await make_youtube_api_request("channels", { "part": "id,snippet", "forUsername": channel_id }) if not channel_data.get("items"): return f"Error: Channel '{channel_id}' not found or is not accessible." actual_channel_id = channel_data["items"][0]["id"] channel_title = channel_data["items"][0]["snippet"]["title"] except Exception: return f"Error: Could not resolve channel '{channel_id}'. Please check the channel exists and is accessible." # Get recent videos from the channel using search search_data = await make_youtube_api_request("search", { "part": "id,snippet", "channelId": actual_channel_id, "type": "video", "order": "date", "maxResults": max_results }) if not search_data.get("items"): return f"No videos found for channel '{channel_title}' or channel has no public videos." videos = search_data["items"] total_results = search_data.get("pageInfo", {}).get("totalResults", len(videos)) result = f"""Recent Videos from YouTube Channel: Channel: {channel_title} Channel ID: {actual_channel_id} Showing: {len(videos)} of {total_results} videos Recent Videos: """ for i, video in enumerate(videos, 1): snippet = video.get("snippet", {}) video_id = video.get("id", {}).get("videoId", "Unknown") # Format publish date published = snippet.get("publishedAt", "Unknown") if published != "Unknown": published = published[:10] # Just the date part result += f""" {i}. {snippet.get('title', 'Unknown Title')} Published: {published} Description: {snippet.get('description', 'No description')[:100]}{'...' if len(snippet.get('description', '')) > 100 else ''} Video ID: {video_id} URL: https://www.youtube.com/watch?v={video_id} """ if total_results > len(videos): result += f"\n... and {total_results - len(videos)} more videos available" result += f"\nChannel URL: https://www.youtube.com/channel/{actual_channel_id}" return result except Exception as e: return f"Error fetching channel videos: {str(e)}" @mcp.tool() async def search_videos(query: str, max_results: int = 10, order: str = "relevance") -> str: """ Search YouTube for videos by keywords. Args: query: Search keywords/terms max_results: Maximum number of results to return (default: 10, max: 50) order: Sort order - relevance, date, rating, viewCount, title (default: relevance) Returns: Formatted string with search results including video details """ if not query or query.strip() == "": return "Error: Search query cannot be empty. Please provide keywords to search for." # Validate max_results max_results = max(1, min(50, max_results)) # Validate order parameter valid_orders = ["relevance", "date", "rating", "viewCount", "title"] if order not in valid_orders: order = "relevance" try: # Search for videos search_data = await make_youtube_api_request("search", { "part": "id,snippet", "q": query.strip(), "type": "video", "order": order, "maxResults": max_results, "safeSearch": "moderate" # Filter out inappropriate content }) if not search_data.get("items"): return f"No videos found for search query: '{query}'. Try different keywords or check spelling." videos = search_data["items"] total_results = search_data.get("pageInfo", {}).get("totalResults", len(videos)) # Get additional video details (views, duration, etc.) for the found videos video_ids = [video.get("id", {}).get("videoId") for video in videos if video.get("id", {}).get("videoId")] video_details = {} if video_ids: try: details_data = await make_youtube_api_request("videos", { "part": "contentDetails,statistics", "id": ",".join(video_ids) }) for video in details_data.get("items", []): video_details[video["id"]] = { "duration": video.get("contentDetails", {}).get("duration", "Unknown"), "viewCount": video.get("statistics", {}).get("viewCount", "0"), "likeCount": video.get("statistics", {}).get("likeCount", "0") } except: # If additional details fail, continue with basic search results pass result = f"""YouTube Video Search Results: Query: "{query}" Sort Order: {order.title()} Showing: {len(videos)} of {total_results:,} results Videos: """ for i, video in enumerate(videos, 1): snippet = video.get("snippet", {}) video_id = video.get("id", {}).get("videoId", "Unknown") # Format publish date published = snippet.get("publishedAt", "Unknown") if published != "Unknown": published = published[:10] # Just the date part # Get additional details if available details = video_details.get(video_id, {}) duration = details.get("duration", "Unknown") view_count = int(details.get("viewCount", 0)) # Format duration (convert from ISO 8601 format) if duration.startswith("PT"): duration = duration.replace("PT", "").replace("H", "h ").replace("M", "m ").replace("S", "s") # Format view count if view_count >= 1000000000: view_display = f"{view_count/1000000000:.1f}B views" elif view_count >= 1000000: view_display = f"{view_count/1000000:.1f}M views" elif view_count >= 1000: view_display = f"{view_count/1000:.1f}K views" else: view_display = f"{view_count:,} views" if view_count > 0 else "Views: N/A" result += f""" {i}. {snippet.get('title', 'Unknown Title')} Channel: {snippet.get('channelTitle', 'Unknown')} Published: {published} Duration: {duration} {view_display} Description: {snippet.get('description', 'No description')[:150]}{'...' if len(snippet.get('description', '')) > 150 else ''} Video ID: {video_id} URL: https://www.youtube.com/watch?v={video_id} """ if total_results > len(videos): result += f"\n... and {total_results - len(videos):,} more results available" result += f"\n\nSearch Tips:\n- Try different keywords for more results\n- Use order='date' for newest videos\n- Use order='viewCount' for most popular videos" return result except Exception as e: return f"Error searching videos: {str(e)}" @mcp.tool() async def get_trending_videos(region_code: str = "US", max_results: int = 10) -> str: """ Get trending videos from YouTube for a specific region. Args: region_code: Country code (US, GB, CA, etc.) - default: US max_results: Maximum number of videos to return (default: 10, max: 50) Returns: Formatted string with trending videos and their details """ # Validate max_results max_results = max(1, min(50, max_results)) try: # Get trending videos (most popular) trending_data = await make_youtube_api_request("videos", { "part": "snippet,statistics,contentDetails", "chart": "mostPopular", "regionCode": region_code, "maxResults": max_results }) if not trending_data.get("items"): return f"No trending videos found for region: {region_code}" videos = trending_data["items"] result = f"""Trending YouTube Videos - {region_code}: Showing: {len(videos)} trending videos Videos: """ for i, video in enumerate(videos, 1): snippet = video.get("snippet", {}) statistics = video.get("statistics", {}) content_details = video.get("contentDetails", {}) video_id = video.get("id", "Unknown") # Format duration (convert from ISO 8601 format) duration = content_details.get("duration", "Unknown") if duration.startswith("PT"): duration = duration.replace("PT", "").replace("H", "h ").replace("M", "m ").replace("S", "s") # Format view count view_count = int(statistics.get("viewCount", 0)) if view_count >= 1000000000: view_display = f"{view_count/1000000000:.1f}B views" elif view_count >= 1000000: view_display = f"{view_count/1000000:.1f}M views" elif view_count >= 1000: view_display = f"{view_count/1000:.1f}K views" else: view_display = f"{view_count:,} views" # Format like count like_count = int(statistics.get("likeCount", 0)) if like_count >= 1000000: like_display = f"{like_count/1000000:.1f}M likes" elif like_count >= 1000: like_display = f"{like_count/1000:.1f}K likes" else: like_display = f"{like_count:,} likes" # Format publish date published = snippet.get("publishedAt", "Unknown") if published != "Unknown": published = published[:10] # Just the date part result += f""" {i}. {snippet.get('title', 'Unknown Title')} Channel: {snippet.get('channelTitle', 'Unknown')} Published: {published} Duration: {duration} {view_display} | {like_display} Description: {snippet.get('description', 'No description')[:150]}{'...' if len(snippet.get('description', '')) > 150 else ''} Video ID: {video_id} URL: https://www.youtube.com/watch?v={video_id} """ result += f"\n\nNote: Trending videos are updated regularly and vary by region." return result except Exception as e: return f"Error fetching trending videos: {str(e)}" @mcp.tool() async def get_video_comments(video_input: str, max_results: int = 10, order: str = "relevance") -> str: """ Get comments from a YouTube video. Args: video_input: YouTube video URL or video ID max_results: Maximum number of comments to return (default: 10, max: 50) order: Sort order - time, relevance (default: relevance) Returns: Formatted string with video comments """ # Extract video ID from URL or use as-is if it's already an ID video_id = get_video_id_from_url(video_input) if not video_id: return f"Error: Could not extract video ID from '{video_input}'. Please provide a valid YouTube URL or 11-character video ID." # Validate max_results max_results = max(1, min(50, max_results)) # Validate order parameter valid_orders = ["time", "relevance"] if order not in valid_orders: order = "relevance" try: # Get video comments comments_data = await make_youtube_api_request("commentThreads", { "part": "snippet,replies", "videoId": video_id, "order": order, "maxResults": max_results, "textFormat": "plainText" # Get plain text instead of HTML }) if not comments_data.get("items"): return f"No comments found for video '{video_id}'. Comments may be disabled or the video may not exist." comments = comments_data["items"] total_results = comments_data.get("pageInfo", {}).get("totalResults", len(comments)) # Get basic video info for context try: video_data = await make_youtube_api_request("videos", { "part": "snippet", "id": video_id }) video_title = video_data["items"][0]["snippet"]["title"] if video_data.get("items") else "Unknown Video" except: video_title = "Unknown Video" result = f"""YouTube Video Comments: Video: {video_title} Video ID: {video_id} Sort Order: {order.title()} Showing: {len(comments)} of {total_results:,} comments Comments: """ for i, comment_thread in enumerate(comments, 1): top_comment = comment_thread.get("snippet", {}).get("topLevelComment", {}) comment_snippet = top_comment.get("snippet", {}) # Get comment details author = comment_snippet.get("authorDisplayName", "Unknown") comment_text = comment_snippet.get("textDisplay", "No text") like_count = int(comment_snippet.get("likeCount", 0)) published = comment_snippet.get("publishedAt", "Unknown") # Format publish date if published != "Unknown": published = published[:10] # Just the date part # Format like count if like_count >= 1000: like_display = f"{like_count/1000:.1f}K likes" else: like_display = f"{like_count:,} likes" if like_count > 0 else "No likes" # Truncate long comments if len(comment_text) > 200: comment_text = comment_text[:200] + "..." result += f""" {i}. {author} Posted: {published} {like_display} Comment: {comment_text} """ # Check for replies replies = comment_thread.get("replies", {}) reply_count = replies.get("totalReplyCount", 0) if reply_count > 0: result += f"\n 📝 {reply_count} repl{'y' if reply_count == 1 else 'ies'}" result += "\n" if total_results > len(comments): result += f"\n... and {total_results - len(comments):,} more comments available" result += f"\n\nNote: Comments are sorted by {order}. Some comments may be filtered by YouTube." return result except Exception as e: # Handle specific API errors if "commentsDisabled" in str(e) or "disabled" in str(e).lower(): return f"Comments are disabled for video '{video_id}'." elif "quotaExceeded" in str(e): return "Error: YouTube API quota exceeded. Please try again later." else: return f"Error fetching video comments: {str(e)}" @mcp.tool() async def analyze_video_engagement(video_input: str) -> str: """ Analyze video engagement metrics and provide insights. Args: video_input: YouTube video URL or video ID Returns: Formatted string with engagement analysis and insights """ # Extract video ID from URL or use as-is if it's already an ID video_id = get_video_id_from_url(video_input) if not video_id: return f"Error: Could not extract video ID from '{video_input}'. Please provide a valid YouTube URL or 11-character video ID." try: # Get comprehensive video data video_data = await make_youtube_api_request("videos", { "part": "snippet,statistics,contentDetails", "id": video_id }) if not video_data.get("items"): return f"Error: Video with ID '{video_id}' not found or is not accessible." video = video_data["items"][0] snippet = video.get("snippet", {}) statistics = video.get("statistics", {}) content_details = video.get("contentDetails", {}) # Extract metrics title = snippet.get("title", "Unknown Title") channel = snippet.get("channelTitle", "Unknown Channel") published = snippet.get("publishedAt", "Unknown") view_count = int(statistics.get("viewCount", 0)) like_count = int(statistics.get("likeCount", 0)) comment_count = int(statistics.get("commentCount", 0)) # Calculate engagement metrics if view_count > 0: like_rate = (like_count / view_count) * 100 comment_rate = (comment_count / view_count) * 100 engagement_rate = like_rate + comment_rate else: like_rate = comment_rate = engagement_rate = 0 # Calculate video age in days video_age_days = "Unknown" if published != "Unknown": from datetime import datetime try: pub_date = datetime.fromisoformat(published.replace('Z', '+00:00')) current_date = datetime.now(pub_date.tzinfo) video_age_days = (current_date - pub_date).days except: video_age_days = "Unknown" # Calculate average views per day if isinstance(video_age_days, int) and video_age_days > 0: avg_views_per_day = view_count / video_age_days else: avg_views_per_day = "Unknown" # Format duration duration = content_details.get("duration", "Unknown") if duration.startswith("PT"): duration = duration.replace("PT", "").replace("H", "h ").replace("M", "m ").replace("S", "s") # Engagement benchmarks (rough industry averages) def get_engagement_assessment(rate): if rate >= 8.0: return "🔥 Exceptional (8%+)" elif rate >= 4.0: return "⭐ Excellent (4-8%)" elif rate >= 2.0: return "✅ Good (2-4%)" elif rate >= 1.0: return "📊 Average (1-2%)" else: return "📉 Below Average (<1%)" # Format numbers for display def format_number(num): if isinstance(num, int): if num >= 1000000000: return f"{num/1000000000:.1f}B" elif num >= 1000000: return f"{num/1000000:.1f}M" elif num >= 1000: return f"{num/1000:.1f}K" else: return f"{num:,}" return str(num) result = f"""YouTube Video Engagement Analysis: Video: {title} Channel: {channel} Published: {published[:10] if published != "Unknown" else "Unknown"} Duration: {duration} 📊 Core Metrics: - Views: {format_number(view_count)} - Likes: {format_number(like_count)} - Comments: {format_number(comment_count)} 🎯 Engagement Rates: - Like Rate: {like_rate:.2f}% ({like_count:,} likes per 100 views) - Comment Rate: {comment_rate:.2f}% ({comment_count:,} comments per 100 views) - Total Engagement Rate: {engagement_rate:.2f}% 📈 Performance Assessment: - Overall Engagement: {get_engagement_assessment(engagement_rate)} """ # Add time-based analysis if available if isinstance(video_age_days, int): result += f""" ⏰ Time Analysis: - Video Age: {video_age_days} days - Average Views/Day: {format_number(int(avg_views_per_day)) if isinstance(avg_views_per_day, (int, float)) else avg_views_per_day} """ # Add engagement insights result += f""" 🔍 Insights: """ if engagement_rate >= 4.0: result += "- This video has excellent engagement! The audience is highly responsive.\n" elif engagement_rate >= 2.0: result += "- Good engagement levels indicate the content resonates with viewers.\n" else: result += "- Engagement could be improved. Consider more interactive content or better thumbnails.\n" if like_rate > comment_rate * 5: result += "- High like-to-comment ratio suggests easy-to-consume content.\n" elif comment_rate > like_rate: result += "- High comment rate indicates the content sparks discussion.\n" if isinstance(video_age_days, int) and video_age_days < 7 and view_count > 10000: result += "- Strong early performance - video is gaining momentum quickly.\n" result += f""" Video ID: {video_id} URL: https://www.youtube.com/watch?v={video_id} Note: Engagement benchmarks are based on general industry averages and may vary by niche.""" return result except Exception as e: return f"Error analyzing video engagement: {str(e)}" @mcp.tool() async def get_channel_playlists(channel_input: str, max_results: int = 10) -> str: """ Get playlists from a YouTube channel. Args: channel_input: YouTube channel URL, channel ID, or @username max_results: Maximum number of playlists to return (default: 10, max: 50) Returns: Formatted string with channel playlists and their details """ # Extract channel ID from URL or use as-is channel_id = get_channel_id_from_url(channel_input) if not channel_id: return f"Error: Could not extract channel ID from '{channel_input}'. Please provide a valid YouTube channel URL, channel ID, or @username." # Validate max_results max_results = max(1, min(50, max_results)) try: # First, resolve the actual channel ID if we have a username or custom URL try: # Try to get channel details to resolve the actual channel ID channel_data = await make_youtube_api_request("channels", { "part": "id,snippet", "id": channel_id }) if not channel_data.get("items"): # Try as username if ID lookup failed channel_data = await make_youtube_api_request("channels", { "part": "id,snippet", "forUsername": channel_id }) if not channel_data.get("items"): return f"Error: Channel '{channel_id}' not found or is not accessible." actual_channel_id = channel_data["items"][0]["id"] channel_title = channel_data["items"][0]["snippet"]["title"] except Exception: return f"Error: Could not resolve channel '{channel_id}'. Please check the channel exists and is accessible." # Get playlists from the channel playlists_data = await make_youtube_api_request("playlists", { "part": "snippet,contentDetails", "channelId": actual_channel_id, "maxResults": max_results }) if not playlists_data.get("items"): return f"No public playlists found for channel '{channel_title}'. The channel may not have created any public playlists yet." playlists = playlists_data["items"] total_results = playlists_data.get("pageInfo", {}).get("totalResults", len(playlists)) result = f"""YouTube Channel Playlists: Channel: {channel_title} Channel ID: {actual_channel_id} Total Playlists: {total_results} Showing: {len(playlists)} playlists Playlists: """ for i, playlist in enumerate(playlists, 1): snippet = playlist.get("snippet", {}) content_details = playlist.get("contentDetails", {}) playlist_id = playlist.get("id", "Unknown") # Get playlist details title = snippet.get("title", "Unknown Title") description = snippet.get("description", "No description") published = snippet.get("publishedAt", "Unknown") video_count = content_details.get("itemCount", "Unknown") # Format publish date if published != "Unknown": published = published[:10] # Just the date part # Truncate long descriptions if len(description) > 150: description = description[:150] + "..." result += f""" {i}. {title} Created: {published} Videos: {video_count} Description: {description} Playlist ID: {playlist_id} URL: https://www.youtube.com/playlist?list={playlist_id} """ if total_results > len(playlists): result += f"\n... and {total_results - len(playlists)} more playlists available" result += f"\n\nChannel URL: https://www.youtube.com/channel/{actual_channel_id}" result += f"\n\nNote: Only public playlists are shown. Private playlists are not accessible via the API." return result except Exception as e: return f"Error fetching channel playlists: {str(e)}" @mcp.tool() async def get_video_caption_info(video_input: str, language: str = "en") -> str: """ Get available caption/transcript information from a YouTube video. Args: video_input: YouTube video URL or video ID language: Language code for captions (default: en for English) Returns: Formatted string with available caption information """ # Extract video ID from URL or use as-is if it's already an ID video_id = get_video_id_from_url(video_input) if not video_id: return f"Error: Could not extract video ID from '{video_input}'. Please provide a valid YouTube URL or 11-character video ID." try: # Get available captions for the video captions_data = await make_youtube_api_request("captions", { "part": "snippet", "videoId": video_id }) if not captions_data.get("items"): return f"No captions/transcripts available for video '{video_id}'. The video may not have captions enabled or may not exist." captions = captions_data["items"] # Find the requested language or fall back to available options target_caption = None available_languages = [] for caption in captions: snippet = caption.get("snippet", {}) lang = snippet.get("language", "unknown") available_languages.append(lang) if lang == language: target_caption = caption break # If target language not found, try to use the first available if not target_caption and captions: target_caption = captions[0] language = target_caption.get("snippet", {}).get("language", "unknown") if not target_caption: return f"No suitable captions found for video '{video_id}'. Available languages: {', '.join(available_languages)}" # Get basic video info for context try: video_data = await make_youtube_api_request("videos", { "part": "snippet", "id": video_id }) video_title = video_data["items"][0]["snippet"]["title"] if video_data.get("items") else "Unknown Video" except: video_title = "Unknown Video" caption_id = target_caption.get("id", "") caption_snippet = target_caption.get("snippet", {}) result = f"""YouTube Video Transcripts: Video: {video_title} Video ID: {video_id} Language: {language.upper()} Caption Type: {caption_snippet.get('trackKind', 'Unknown')} Auto-Generated: {'Yes' if caption_snippet.get('isAutoSynced') else 'No'} Available Languages: {', '.join(available_languages)} Note: This function identifies available transcripts. Due to YouTube API limitations, the actual transcript content requires additional API calls that may not be available in all regions or for all videos. To access full transcripts: 1. Use the caption ID: {caption_id} 2. Make a request to the captions download endpoint 3. Parse the returned transcript format (usually SRT or VTT) Caption ID: {caption_id} Video URL: https://www.youtube.com/watch?v={video_id} Tip: Many videos have auto-generated captions in multiple languages. Manually created captions are typically more accurate than auto-generated ones.""" return result except Exception as e: # Handle specific API errors if "quotaExceeded" in str(e): return "Error: YouTube API quota exceeded. Please try again later." elif "forbidden" in str(e).lower(): return f"Error: Access to captions for video '{video_id}' is restricted." else: return f"Error fetching video caption info: {str(e)}" @mcp.tool() async def evaluate_video_for_knowledge_base(video_input: str) -> str: """ Analyze video metadata to help decide if video is worth adding to knowledge base. This function provides a quick evaluation based on video metadata (title, duration, views, captions availability) to help with knowledge base curation decisions. Note: This analysis is metadata-only and does not download actual transcript content. Args: video_input: YouTube video URL or video ID Returns: Formatted string with metadata analysis, quality assessment, and recommendation """ # Extract video ID from URL or use as-is if it's already an ID video_id = get_video_id_from_url(video_input) if not video_id: return f"Error: Could not extract video ID from '{video_input}'. Please provide a valid YouTube URL or 11-character video ID." try: # Get video details for context video_data = await make_youtube_api_request("videos", { "part": "snippet,statistics,contentDetails", "id": video_id }) if not video_data.get("items"): return f"Error: Video with ID '{video_id}' not found or is not accessible." video = video_data["items"][0] snippet = video.get("snippet", {}) statistics = video.get("statistics", {}) content_details = video.get("contentDetails", {}) video_title = snippet.get("title", "Unknown Title") channel_title = snippet.get("channelTitle", "Unknown Channel") duration = content_details.get("duration", "Unknown") view_count = int(statistics.get("viewCount", 0)) # Initialize recommendation score (will be incremented throughout analysis) recommendation_score = 0 # Calculate video age for freshness analysis video_age_days = None published_date = snippet.get("publishedAt", "Unknown") if published_date != "Unknown": from datetime import datetime try: pub_date = datetime.fromisoformat(published_date.replace('Z', '+00:00')) current_date = datetime.now(pub_date.tzinfo) video_age_days = (current_date - pub_date).days except: video_age_days = None # Format duration if duration.startswith("PT"): duration = duration.replace("PT", "").replace("H", "h ").replace("M", "m ").replace("S", "s") # Get available captions for quality assessment captions_data = await make_youtube_api_request("captions", { "part": "snippet", "videoId": video_id }) has_captions = bool(captions_data.get("items")) is_auto_generated = True if has_captions: # Check if captions are manually created (higher quality indicator) captions = captions_data["items"] for caption in captions: caption_snippet = caption.get("snippet", {}) if not caption_snippet.get("isAutoSynced", True): is_auto_generated = False break # Initialize analysis variables content_type = "Unknown" quality_indicators = [] # Analyze title for content type indicators title_lower = video_title.lower() if any(word in title_lower for word in ["tutorial", "how to", "guide", "learn"]): content_type = "Tutorial/Educational" recommendation_score += 2 elif any(word in title_lower for word in ["review", "analysis", "deep dive"]): content_type = "Analysis/Review" recommendation_score += 2 elif any(word in title_lower for word in ["introduction", "overview", "basics"]): content_type = "Introductory" recommendation_score += 1 elif any(word in title_lower for word in ["news", "update", "announcement"]): content_type = "News/Updates" recommendation_score += 1 # Calculate freshness bonus based on age freshness_bonus = 0 age_assessment = "Unknown" if video_age_days is not None: if video_age_days <= 183: # 0-6 months freshness_bonus = 3 age_assessment = "Very Recent" elif video_age_days <= 365: # 6-12 months freshness_bonus = 2 age_assessment = "Recent" elif video_age_days <= 730: # 1-2 years freshness_bonus = 1 age_assessment = "Moderate Age" elif video_age_days <= 1095: # 2-3 years freshness_bonus = 0 age_assessment = "Older Content" else: # 3+ years freshness_bonus = -1 age_assessment = "Aging Content" # Tech volatility detection for extra freshness weighting high_volatility_topics = ["react", "vue", "angular", "aws", "docker", "kubernetes", "ai", "ml", "machine learning", "next.js", "typescript"] is_high_volatility = any(topic in title_lower for topic in high_volatility_topics) # Apply tech volatility bonus to recent content tech_bonus = 0 if is_high_volatility and freshness_bonus > 0: tech_bonus = 2 freshness_bonus += tech_bonus # Apply freshness bonus to recommendation score recommendation_score += freshness_bonus # Quality indicators based on metadata if view_count > 100000: quality_indicators.append("High view count (popular content)") recommendation_score += 1 if has_captions and not is_auto_generated: quality_indicators.append("Manual captions (higher quality)") recommendation_score += 1 elif has_captions: quality_indicators.append("Auto-generated captions available") # Duration analysis if "m" in duration: try: duration_parts = duration.replace("h", " h ").replace("m", " m ").split() minutes = 0 for i, part in enumerate(duration_parts): if "h" in part: minutes += int(duration_parts[i-1]) * 60 elif "m" in part: minutes += int(duration_parts[i-1]) if 10 <= minutes <= 60: quality_indicators.append("Good length for in-depth content (10-60 min)") recommendation_score += 1 elif minutes > 60: quality_indicators.append("Long-form content (comprehensive)") recommendation_score += 1 elif minutes >= 5: quality_indicators.append("Moderate length content") except: pass # Generate recommendation if recommendation_score >= 4: recommendation = "🟢 HIGHLY RECOMMENDED - Strong indicators of valuable content" elif recommendation_score >= 2: recommendation = "🟡 MODERATELY RECOMMENDED - Some positive indicators" else: recommendation = "🔴 LIMITED RECOMMENDATION - Few quality indicators" result = f"""Video Knowledge Base Evaluation: Video: {video_title} Channel: {channel_title} Duration: {duration} Views: {view_count:,} Content Type: {content_type} Captions Available: {'Yes' if has_captions else 'No'} {'(Manual)' if has_captions and not is_auto_generated else '(Auto-generated)' if has_captions else ''} 📊 Quality Indicators: """ # Add quality indicators if quality_indicators: for indicator in quality_indicators: result += f"• {indicator}\n" else: result += "• Limited quality indicators detected\n" # Add freshness analysis section if video_age_days is not None: result += "\n⏰ Content Freshness Analysis:\n" result += f"• Video Age: {video_age_days} days ({age_assessment})\n" if is_high_volatility: result += "• High-Volatility Tech Topic: Extra freshness priority applied\n" if freshness_bonus > 0: total_bonus = freshness_bonus if tech_bonus > 0: result += f"• Freshness Bonus: +{freshness_bonus} points ({freshness_bonus - tech_bonus} base + {tech_bonus} tech volatility)\n" else: result += f"• Freshness Bonus: +{freshness_bonus} points for recent content\n" elif freshness_bonus < 0: result += f"• Age Penalty: {freshness_bonus} point for older content\n" result += f""" 🎯 Knowledge Base Recommendation: {recommendation} Reasoning: • Content appears to be {content_type.lower()} • Video has {view_count:,} views indicating {'strong' if view_count > 100000 else 'moderate'} audience interest • {'Manual captions suggest higher content quality' if has_captions and not is_auto_generated else 'Auto-generated captions available' if has_captions else 'No captions available'} • Duration ({duration}) is {'appropriate' if recommendation_score > 2 else 'variable'} for learning content 💡 Decision Support: {'This video shows strong metadata indicators for knowledge base inclusion. Consider adding it for comprehensive coverage.' if recommendation_score >= 4 else 'Video shows some positive indicators. Review the content to determine if it meets your knowledge base standards.' if recommendation_score >= 2 else 'Limited metadata indicators suggest this may not be optimal for knowledge base inclusion unless it covers a specific niche topic you need.'} Video URL: https://www.youtube.com/watch?v={video_id} Note: This evaluation is based on video metadata only. Your YouTube Agent app can provide deeper transcript-based analysis when needed.""" return result except Exception as e: return f"Error evaluating video for knowledge base: {str(e)}" @mcp.tool() async def get_video_transcript(video_input: str, language: str = "en") -> str: """ Extract actual transcript content from a YouTube video. Args: video_input: YouTube video URL or video ID language: Language code for transcript (default: en) Returns: Formatted string with full transcript content """ video_id = get_video_id_from_url(video_input) if not video_id: return f"Error: Could not extract video ID from '{video_input}'. Please provide a valid YouTube URL or 11-character video ID." # Check library availability and provide installation guidance if not TRANSCRIPT_API_AVAILABLE: return f"""YouTube Video Transcript - Installation Required: Video ID: {video_id} Status: ❌ Missing Dependency The 'youtube-transcript-api' library is required for transcript extraction. 🔧 INSTALLATION COMMAND: pip install youtube-transcript-api After installation: 1. Restart Claude Desktop completely 2. Test this function again Alternative: Use get_video_caption_info() for caption metadata only. Video URL: https://www.youtube.com/watch?v={video_id} Note: Once installed, this function will extract full transcript content with timestamps.""" # Library is available - proceed with transcript extraction try: # Get video title for context try: video_data = await make_youtube_api_request("videos", { "part": "snippet", "id": video_id }) video_title = video_data["items"][0]["snippet"]["title"] if video_data.get("items") else "Unknown Video" except: video_title = "Unknown Video" # Try to get transcript in requested language transcript = None try: transcript = YouTubeTranscriptApi.get_transcript(video_id, languages=[language]) except: # Fallback to English try: transcript_list = YouTubeTranscriptApi.list_transcripts(video_id) transcript = transcript_list.find_transcript(['en']).fetch() language = 'en' except: # Try any available transcript try: available_transcripts = YouTubeTranscriptApi.list_transcripts(video_id) first_transcript = next(iter(available_transcripts)) transcript = first_transcript.fetch() language = first_transcript.language_code except: return f"""YouTube Video Transcript - No Transcripts Available: Video: {video_title} Video ID: {video_id} ❌ No transcripts found for this video. Possible reasons: • Video owner has disabled captions • Video is too new (captions not yet generated) • Video is restricted in your region • Video is private or deleted Try: Use get_video_caption_info() to check caption availability. Video URL: https://www.youtube.com/watch?v={video_id}""" if not transcript: return f"No transcript content extracted for video '{video_id}'." # Format transcript content formatted_segments = [] for entry in transcript: timestamp = f"[{int(entry['start']//60):02d}:{int(entry['start']%60):02d}]" formatted_segments.append(f"{timestamp} {entry['text']}") full_text = " ".join([entry['text'] for entry in transcript]) # Calculate statistics word_count = len(full_text.split()) duration_minutes = int(transcript[-1]['start']//60) if transcript else 0 # Build comprehensive response result = f"""YouTube Video Transcript: Video: {video_title} Video ID: {video_id} Language: {language.upper()} Duration: ~{duration_minutes} minutes Segments: {len(transcript)} Word Count: ~{word_count} words 📝 Full Transcript: {full_text} ⏰ Timestamped Segments (First 10): {chr(10).join(formatted_segments[:10])} {'... and ' + str(len(formatted_segments) - 10) + ' more segments' if len(formatted_segments) > 10 else ''} Video URL: https://www.youtube.com/watch?v={video_id} ✅ Transcript successfully extracted using youtube-transcript-api. Note: Quality depends on YouTube's automatic or manual captions.""" return result except Exception as e: # Comprehensive error handling error_message = str(e).lower() if "transcriptsdisabled" in error_message or "disabled" in error_message: return f"""YouTube Video Transcript - Transcripts Disabled: Video: {video_title} Video ID: {video_id} ❌ Transcripts are disabled for this video. The video owner has disabled captions/transcripts. Alternatives: • Try get_video_caption_info() for basic caption metadata • Use get_video_details() for video information • Look for similar videos with transcripts enabled Video URL: https://www.youtube.com/watch?v={video_id}""" elif "quota" in error_message: return f"❌ YouTube API quota exceeded. Please try again later." elif "forbidden" in error_message: return f"❌ Access to video '{video_id}' is restricted or private." else: return f"Error extracting transcript for video '{video_id}': {str(e)}" # Add a resource for server information @mcp.resource("youtube://server/info") def get_server_info() -> str: """Get information about this YouTube MCP server.""" return """YouTube MCP Server This server provides access to YouTube data via the YouTube Data API v3. Available Tools: 1. get_video_details(video_input) - Get detailed information about a YouTube video 2. get_playlist_details(playlist_input) - Get information about a YouTube playlist 3. get_playlist_items(playlist_input, max_results) - Get videos from a playlist 4. get_channel_details(channel_input) - Get detailed information about a YouTube channel 5. get_video_categories(region_code) - Get list of YouTube video categories for a region 6. get_channel_videos(channel_input, max_results) - Get recent videos from a YouTube channel 7. search_videos(query, max_results, order) - Search YouTube for videos by keywords 8. get_trending_videos(region_code, max_results) - Get trending videos from YouTube for a specific region 9. get_video_comments(video_input, max_results, order) - Get comments from a YouTube video 10. analyze_video_engagement(video_input) - Analyze video engagement metrics and provide insights 11. get_channel_playlists(channel_input, max_results) - Get playlists from a YouTube channel 12. get_video_caption_info(video_input, language) - Get available caption/transcript information 13. evaluate_video_for_knowledge_base(video_input) - Analyze video metadata to help decide if worth adding to knowledge base 14. get_video_transcript(video_input, language) - Extract actual transcript content from YouTube videos Supported URL formats: - Videos: https://www.youtube.com/watch?v=VIDEO_ID or https://youtu.be/VIDEO_ID - Playlists: https://www.youtube.com/playlist?list=PLAYLIST_ID - Channels: https://www.youtube.com/channel/CHANNEL_ID or https://www.youtube.com/@username You can also use video IDs, playlist IDs, and channel IDs directly. Environment Requirements: - YOUTUBE_API_KEY environment variable must be set with a valid YouTube Data API v3 key API Quota Usage (per call): - get_video_details: 1 unit - get_playlist_details: 1 unit - get_playlist_items: 1 unit - get_channel_details: 1 unit - get_video_categories: 1 unit - get_channel_videos: 101 units (1 for channel lookup + 100 for search) - search_videos: 101 units (100 for search + 1 for additional details) - get_trending_videos: 1 unit - get_video_comments: 1 unit - analyze_video_engagement: 1 unit (reuses video details) - get_channel_playlists: 1 unit - get_video_caption_info: 50 units (captions API) - evaluate_video_for_knowledge_base: 51 units (1 for video details + 50 for captions) - get_video_transcript: 1 unit (for video title + external transcript API) Daily Quota Limit: 10,000 units (default) High-usage functions: search_videos (101), get_channel_videos (101), get_video_caption_info (50), evaluate_video_for_knowledge_base (51) Note: Monitor your quota usage carefully. Consider caching results for frequently accessed data. """ if __name__ == "__main__": # For MCP protocol, we can't print to stdout - it must only contain JSON # The API key check will happen when tools are called mcp.run()

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/dannySubsense/youtube-mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server