diff --git a/packages/mcp-connectors/src/connectors/youtube.spec.ts b/packages/mcp-connectors/src/connectors/youtube.spec.ts new file mode 100644 index 00000000..233b9547 --- /dev/null +++ b/packages/mcp-connectors/src/connectors/youtube.spec.ts @@ -0,0 +1,699 @@ +import type { MCPToolDefinition } from '@stackone/mcp-config-types'; +import { http, HttpResponse } from 'msw'; +import { setupServer } from 'msw/node'; +import { afterAll, afterEach, beforeAll, describe, expect, it, type vi } from 'vitest'; +import { createMockConnectorContext } from '../__mocks__/context'; +import { YouTubeConnectorConfig } from './youtube'; + +const server = setupServer(); + +beforeAll(() => server.listen()); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +const mockVideoSearchResult = { + kind: 'youtube#searchResult', + etag: 'test-etag', + id: { + kind: 'youtube#video', + videoId: 'dQw4w9WgXcQ', + }, + snippet: { + publishedAt: '2023-12-25T10:00:00Z', + channelId: 'UCuAXFkgsw1L7xaCfnd5JJOw', + title: 'Never Gonna Give You Up', + description: 'The official video for Rick Astley - Never Gonna Give You Up', + thumbnails: { + default: { + url: 'https://i.ytimg.com/vi/dQw4w9WgXcQ/default.jpg', + width: 120, + height: 90, + }, + medium: { + url: 'https://i.ytimg.com/vi/dQw4w9WgXcQ/mqdefault.jpg', + width: 320, + height: 180, + }, + high: { + url: 'https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg', + width: 480, + height: 360, + }, + }, + channelTitle: 'Rick Astley', + liveBroadcastContent: 'none', + }, +}; + +const mockChannelSearchResult = { + kind: 'youtube#searchResult', + etag: 'test-etag', + id: { + kind: 'youtube#channel', + channelId: 'UCuAXFkgsw1L7xaCfnd5JJOw', + }, + snippet: { + publishedAt: '2006-05-16T10:00:00Z', + channelId: 'UCuAXFkgsw1L7xaCfnd5JJOw', + title: 'Rick Astley', + description: 'Official YouTube channel for Rick Astley', + thumbnails: { + default: { url: 'https://yt3.ggpht.com/default.jpg', width: 88, height: 88 }, + medium: { url: 'https://yt3.ggpht.com/medium.jpg', width: 240, height: 240 }, + high: { url: 'https://yt3.ggpht.com/high.jpg', width: 800, height: 800 }, + }, + channelTitle: 'Rick Astley', + liveBroadcastContent: 'none', + }, +}; + +const mockVideo = { + kind: 'youtube#video', + etag: 'test-etag', + id: 'dQw4w9WgXcQ', + snippet: { + publishedAt: '2009-10-25T06:57:33Z', + channelId: 'UCuAXFkgsw1L7xaCfnd5JJOw', + title: 'Rick Astley - Never Gonna Give You Up (Official Video)', + description: 'The official video for Rick Astley - Never Gonna Give You Up', + thumbnails: { + default: { + url: 'https://i.ytimg.com/vi/dQw4w9WgXcQ/default.jpg', + width: 120, + height: 90, + }, + medium: { + url: 'https://i.ytimg.com/vi/dQw4w9WgXcQ/mqdefault.jpg', + width: 320, + height: 180, + }, + high: { + url: 'https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg', + width: 480, + height: 360, + }, + }, + channelTitle: 'Rick Astley', + tags: ['Rick Astley', '80s', 'music', 'pop'], + categoryId: '10', + liveBroadcastContent: 'none', + defaultLanguage: 'en', + }, + statistics: { + viewCount: '1500000000', + likeCount: '15000000', + dislikeCount: '500000', + favoriteCount: '0', + commentCount: '2500000', + }, + contentDetails: { + duration: 'PT3M33S', + dimension: '2d', + definition: 'hd', + caption: 'false', + licensedContent: true, + }, + status: { + uploadStatus: 'processed', + privacyStatus: 'public', + license: 'youtube', + embeddable: true, + publicStatsViewable: true, + madeForKids: false, + }, +}; + +const mockChannel = { + kind: 'youtube#channel', + etag: 'test-etag', + id: 'UCuAXFkgsw1L7xaCfnd5JJOw', + snippet: { + title: 'Rick Astley', + description: 'Official YouTube channel for Rick Astley', + customUrl: '@RickAstleyYT', + publishedAt: '2006-05-16T10:00:00Z', + thumbnails: { + default: { url: 'https://yt3.ggpht.com/default.jpg', width: 88, height: 88 }, + medium: { url: 'https://yt3.ggpht.com/medium.jpg', width: 240, height: 240 }, + high: { url: 'https://yt3.ggpht.com/high.jpg', width: 800, height: 800 }, + }, + country: 'GB', + }, + statistics: { + viewCount: '1000000000', + subscriberCount: '3500000', + hiddenSubscriberCount: false, + videoCount: '25', + }, + contentDetails: { + relatedPlaylists: { + uploads: 'UUuAXFkgsw1L7xaCfnd5JJOw', + likes: 'LLuAXFkgsw1L7xaCfnd5JJOw', + }, + }, +}; + +const mockPlaylist = { + kind: 'youtube#playlist', + etag: 'test-etag', + id: 'PLuAXFkgsw1L7xaCfnd5JJOw', + snippet: { + publishedAt: '2020-01-01T10:00:00Z', + channelId: 'UCuAXFkgsw1L7xaCfnd5JJOw', + title: 'Greatest Hits', + description: 'The best of Rick Astley', + thumbnails: { + default: { + url: 'https://i.ytimg.com/vi/playlist/default.jpg', + width: 120, + height: 90, + }, + medium: { + url: 'https://i.ytimg.com/vi/playlist/mqdefault.jpg', + width: 320, + height: 180, + }, + high: { + url: 'https://i.ytimg.com/vi/playlist/hqdefault.jpg', + width: 480, + height: 360, + }, + }, + channelTitle: 'Rick Astley', + defaultLanguage: 'en', + }, + status: { + privacyStatus: 'public', + }, + contentDetails: { + itemCount: 15, + }, +}; + +const mockPlaylistItem = { + kind: 'youtube#playlistItem', + etag: 'test-etag', + id: 'PLuAXFkgsw1L7xaCfnd5JJOw_dQw4w9WgXcQ', + snippet: { + publishedAt: '2020-01-01T10:00:00Z', + channelId: 'UCuAXFkgsw1L7xaCfnd5JJOw', + title: 'Never Gonna Give You Up', + description: 'Classic hit from Rick Astley', + thumbnails: { + default: { + url: 'https://i.ytimg.com/vi/dQw4w9WgXcQ/default.jpg', + width: 120, + height: 90, + }, + }, + channelTitle: 'Rick Astley', + playlistId: 'PLuAXFkgsw1L7xaCfnd5JJOw', + position: 0, + resourceId: { + kind: 'youtube#video', + videoId: 'dQw4w9WgXcQ', + }, + }, + contentDetails: { + videoId: 'dQw4w9WgXcQ', + }, +}; + +describe('#YouTubeConnectorConfig', () => { + it('should have the correct basic properties', () => { + expect(YouTubeConnectorConfig.name).toBe('YouTube'); + expect(YouTubeConnectorConfig.key).toBe('youtube'); + expect(YouTubeConnectorConfig.version).toBe('1.0.0'); + expect(YouTubeConnectorConfig.description).toContain('YouTube content'); + }); + + it('should have tools object with expected tools', () => { + expect(typeof YouTubeConnectorConfig.tools).toBe('object'); + expect(YouTubeConnectorConfig.tools).toBeDefined(); + + const expectedTools = [ + 'SEARCH_VIDEOS', + 'SEARCH_CHANNELS', + 'GET_VIDEO_DETAILS', + 'GET_CHANNEL_DETAILS', + 'GET_PLAYLIST_DETAILS', + 'GET_PLAYLIST_ITEMS', + 'GET_POPULAR_VIDEOS', + ]; + + for (const toolName of expectedTools) { + expect(YouTubeConnectorConfig.tools[toolName]).toBeDefined(); + } + }); + + it('should have correct credential schema', () => { + const credentialsSchema = YouTubeConnectorConfig.credentials; + const parsedCredentials = credentialsSchema.safeParse({ + apiKey: 'test-api-key', + }); + + expect(parsedCredentials.success).toBe(true); + }); + + it('should have empty setup schema', () => { + const setupSchema = YouTubeConnectorConfig.setup; + const parsedSetup = setupSchema.safeParse({}); + + expect(parsedSetup.success).toBe(true); + }); + + it('should have a meaningful example prompt', () => { + expect(YouTubeConnectorConfig.examplePrompt).toContain('machine learning'); + expect(YouTubeConnectorConfig.examplePrompt).toContain('channel'); + expect(YouTubeConnectorConfig.examplePrompt).toContain('popular'); + }); + + it('should have proper logo URL', () => { + expect(YouTubeConnectorConfig.logo).toContain('youtube.com'); + }); + + describe('.SEARCH_VIDEOS', () => { + describe('when API request is successful', () => { + it('returns formatted video search results', async () => { + const mockResponse = { + kind: 'youtube#searchListResponse', + etag: 'test-etag', + nextPageToken: 'CAUQAA', + regionCode: 'US', + pageInfo: { + totalResults: 1000000, + resultsPerPage: 10, + }, + items: [mockVideoSearchResult], + }; + + server.use( + http.get('https://www.googleapis.com/youtube/v3/search', ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.get('q')).toBe('machine learning'); + expect(url.searchParams.get('type')).toBe('video'); + expect(url.searchParams.get('part')).toBe('snippet'); + expect(url.searchParams.get('key')).toBe('test-api-key'); + return HttpResponse.json(mockResponse); + }) + ); + + const tool = YouTubeConnectorConfig.tools.SEARCH_VIDEOS as MCPToolDefinition; + const mockContext = createMockConnectorContext(); + (mockContext.getCredentials as ReturnType).mockResolvedValue({ + apiKey: 'test-api-key', + }); + + const actual = await tool.handler( + { + q: 'machine learning', + maxResults: 10, + order: 'relevance', + }, + mockContext + ); + + expect(actual).toBe(JSON.stringify(mockResponse, null, 2)); + }); + }); + + describe('when API request fails', () => { + it('returns error message', async () => { + server.use( + http.get('https://www.googleapis.com/youtube/v3/search', () => { + return HttpResponse.json( + { error: { message: 'API key not valid' } }, + { status: 400 } + ); + }) + ); + + const tool = YouTubeConnectorConfig.tools.SEARCH_VIDEOS as MCPToolDefinition; + const mockContext = createMockConnectorContext(); + (mockContext.getCredentials as ReturnType).mockResolvedValue({ + apiKey: 'invalid-key', + }); + + const actual = await tool.handler({ q: 'test' }, mockContext); + + expect(actual).toContain('Failed to search videos'); + expect(actual).toContain('YouTube API error: 400'); + }); + }); + + describe('when advanced filters are used', () => { + it('includes filter parameters in request', async () => { + server.use( + http.get('https://www.googleapis.com/youtube/v3/search', ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.get('videoDuration')).toBe('medium'); + expect(url.searchParams.get('videoDefinition')).toBe('high'); + expect(url.searchParams.get('order')).toBe('viewCount'); + return HttpResponse.json({ items: [] }); + }) + ); + + const tool = YouTubeConnectorConfig.tools.SEARCH_VIDEOS as MCPToolDefinition; + const mockContext = createMockConnectorContext(); + (mockContext.getCredentials as ReturnType).mockResolvedValue({ + apiKey: 'test-api-key', + }); + + await tool.handler( + { + q: 'test', + videoDuration: 'medium', + videoDefinition: 'high', + order: 'viewCount', + }, + mockContext + ); + }); + }); + }); + + describe('.SEARCH_CHANNELS', () => { + describe('when searching for channels', () => { + it('returns channel search results', async () => { + const mockResponse = { + kind: 'youtube#searchListResponse', + etag: 'test-etag', + pageInfo: { + totalResults: 100, + resultsPerPage: 10, + }, + items: [mockChannelSearchResult], + }; + + server.use( + http.get('https://www.googleapis.com/youtube/v3/search', ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.get('q')).toBe('Rick Astley'); + expect(url.searchParams.get('type')).toBe('channel'); + return HttpResponse.json(mockResponse); + }) + ); + + const tool = YouTubeConnectorConfig.tools.SEARCH_CHANNELS as MCPToolDefinition; + const mockContext = createMockConnectorContext(); + (mockContext.getCredentials as ReturnType).mockResolvedValue({ + apiKey: 'test-api-key', + }); + + const actual = await tool.handler({ q: 'Rick Astley' }, mockContext); + + expect(actual).toBe(JSON.stringify(mockResponse, null, 2)); + }); + }); + }); + + describe('.GET_VIDEO_DETAILS', () => { + describe('when video exists', () => { + it('returns detailed video information', async () => { + const mockResponse = { + kind: 'youtube#videoListResponse', + etag: 'test-etag', + pageInfo: { + totalResults: 1, + resultsPerPage: 1, + }, + items: [mockVideo], + }; + + server.use( + http.get('https://www.googleapis.com/youtube/v3/videos', ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.get('id')).toBe('dQw4w9WgXcQ'); + expect(url.searchParams.get('part')).toBe( + 'snippet,statistics,contentDetails' + ); + return HttpResponse.json(mockResponse); + }) + ); + + const tool = YouTubeConnectorConfig.tools.GET_VIDEO_DETAILS as MCPToolDefinition; + const mockContext = createMockConnectorContext(); + (mockContext.getCredentials as ReturnType).mockResolvedValue({ + apiKey: 'test-api-key', + }); + + const actual = await tool.handler({ videoIds: ['dQw4w9WgXcQ'] }, mockContext); + + expect(actual).toBe(JSON.stringify(mockResponse, null, 2)); + }); + }); + + describe('when multiple videos are requested', () => { + it('includes all video IDs in request', async () => { + server.use( + http.get('https://www.googleapis.com/youtube/v3/videos', ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.get('id')).toBe('dQw4w9WgXcQ,eBGIQ7ZuuiU'); + return HttpResponse.json({ items: [] }); + }) + ); + + const tool = YouTubeConnectorConfig.tools.GET_VIDEO_DETAILS as MCPToolDefinition; + const mockContext = createMockConnectorContext(); + (mockContext.getCredentials as ReturnType).mockResolvedValue({ + apiKey: 'test-api-key', + }); + + await tool.handler({ videoIds: ['dQw4w9WgXcQ', 'eBGIQ7ZuuiU'] }, mockContext); + }); + }); + }); + + describe('.GET_CHANNEL_DETAILS', () => { + describe('when channel exists by ID', () => { + it('returns detailed channel information', async () => { + const mockResponse = { + kind: 'youtube#channelListResponse', + etag: 'test-etag', + pageInfo: { + totalResults: 1, + resultsPerPage: 1, + }, + items: [mockChannel], + }; + + server.use( + http.get('https://www.googleapis.com/youtube/v3/channels', ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.get('id')).toBe('UCuAXFkgsw1L7xaCfnd5JJOw'); + expect(url.searchParams.get('part')).toBe( + 'snippet,statistics,contentDetails' + ); + return HttpResponse.json(mockResponse); + }) + ); + + const tool = YouTubeConnectorConfig.tools + .GET_CHANNEL_DETAILS as MCPToolDefinition; + const mockContext = createMockConnectorContext(); + (mockContext.getCredentials as ReturnType).mockResolvedValue({ + apiKey: 'test-api-key', + }); + + const actual = await tool.handler( + { channelIds: ['UCuAXFkgsw1L7xaCfnd5JJOw'] }, + mockContext + ); + + expect(actual).toBe(JSON.stringify(mockResponse, null, 2)); + }); + }); + + describe('when channel exists by username', () => { + it('uses forUsername parameter', async () => { + server.use( + http.get('https://www.googleapis.com/youtube/v3/channels', ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.get('forUsername')).toBe('rickastley'); + expect(url.searchParams.has('id')).toBe(false); + return HttpResponse.json({ items: [mockChannel] }); + }) + ); + + const tool = YouTubeConnectorConfig.tools + .GET_CHANNEL_DETAILS as MCPToolDefinition; + const mockContext = createMockConnectorContext(); + (mockContext.getCredentials as ReturnType).mockResolvedValue({ + apiKey: 'test-api-key', + }); + + await tool.handler({ username: 'rickastley' }, mockContext); + }); + }); + }); + + describe('.GET_PLAYLIST_DETAILS', () => { + describe('when playlist exists', () => { + it('returns playlist information', async () => { + const mockResponse = { + kind: 'youtube#playlistListResponse', + etag: 'test-etag', + pageInfo: { + totalResults: 1, + resultsPerPage: 1, + }, + items: [mockPlaylist], + }; + + server.use( + http.get('https://www.googleapis.com/youtube/v3/playlists', ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.get('id')).toBe('PLuAXFkgsw1L7xaCfnd5JJOw'); + expect(url.searchParams.get('part')).toBe('snippet,status,contentDetails'); + return HttpResponse.json(mockResponse); + }) + ); + + const tool = YouTubeConnectorConfig.tools + .GET_PLAYLIST_DETAILS as MCPToolDefinition; + const mockContext = createMockConnectorContext(); + (mockContext.getCredentials as ReturnType).mockResolvedValue({ + apiKey: 'test-api-key', + }); + + const actual = await tool.handler( + { playlistIds: ['PLuAXFkgsw1L7xaCfnd5JJOw'] }, + mockContext + ); + + expect(actual).toBe(JSON.stringify(mockResponse, null, 2)); + }); + }); + }); + + describe('.GET_PLAYLIST_ITEMS', () => { + describe('when playlist has items', () => { + it('returns playlist videos', async () => { + const mockResponse = { + kind: 'youtube#playlistItemListResponse', + etag: 'test-etag', + nextPageToken: 'CAUQAA', + pageInfo: { + totalResults: 15, + resultsPerPage: 25, + }, + items: [mockPlaylistItem], + }; + + server.use( + http.get( + 'https://www.googleapis.com/youtube/v3/playlistItems', + ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.get('playlistId')).toBe('PLuAXFkgsw1L7xaCfnd5JJOw'); + expect(url.searchParams.get('part')).toBe('snippet,contentDetails'); + expect(url.searchParams.get('maxResults')).toBe('25'); + return HttpResponse.json(mockResponse); + } + ) + ); + + const tool = YouTubeConnectorConfig.tools.GET_PLAYLIST_ITEMS as MCPToolDefinition; + const mockContext = createMockConnectorContext(); + (mockContext.getCredentials as ReturnType).mockResolvedValue({ + apiKey: 'test-api-key', + }); + + const actual = await tool.handler( + { playlistId: 'PLuAXFkgsw1L7xaCfnd5JJOw' }, + mockContext + ); + + expect(actual).toBe(JSON.stringify(mockResponse, null, 2)); + }); + }); + + describe('when pagination is used', () => { + it('includes pageToken in request', async () => { + server.use( + http.get( + 'https://www.googleapis.com/youtube/v3/playlistItems', + ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.get('pageToken')).toBe('CAUQAA'); + return HttpResponse.json({ items: [] }); + } + ) + ); + + const tool = YouTubeConnectorConfig.tools.GET_PLAYLIST_ITEMS as MCPToolDefinition; + const mockContext = createMockConnectorContext(); + (mockContext.getCredentials as ReturnType).mockResolvedValue({ + apiKey: 'test-api-key', + }); + + await tool.handler( + { + playlistId: 'PLuAXFkgsw1L7xaCfnd5JJOw', + pageToken: 'CAUQAA', + }, + mockContext + ); + }); + }); + }); + + describe('.GET_POPULAR_VIDEOS', () => { + describe('when requesting popular videos', () => { + it('returns trending videos for region', async () => { + const mockResponse = { + kind: 'youtube#videoListResponse', + etag: 'test-etag', + nextPageToken: 'CAUQAA', + pageInfo: { + totalResults: 200, + resultsPerPage: 25, + }, + items: [mockVideo], + }; + + server.use( + http.get('https://www.googleapis.com/youtube/v3/videos', ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.get('chart')).toBe('mostPopular'); + expect(url.searchParams.get('regionCode')).toBe('US'); + expect(url.searchParams.get('part')).toBe( + 'snippet,statistics,contentDetails' + ); + return HttpResponse.json(mockResponse); + }) + ); + + const tool = YouTubeConnectorConfig.tools.GET_POPULAR_VIDEOS as MCPToolDefinition; + const mockContext = createMockConnectorContext(); + (mockContext.getCredentials as ReturnType).mockResolvedValue({ + apiKey: 'test-api-key', + }); + + const actual = await tool.handler({ regionCode: 'US' }, mockContext); + + expect(actual).toBe(JSON.stringify(mockResponse, null, 2)); + }); + }); + + describe('when category filter is applied', () => { + it('includes category ID in request', async () => { + server.use( + http.get('https://www.googleapis.com/youtube/v3/videos', ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.get('videoCategoryId')).toBe('10'); + return HttpResponse.json({ items: [] }); + }) + ); + + const tool = YouTubeConnectorConfig.tools.GET_POPULAR_VIDEOS as MCPToolDefinition; + const mockContext = createMockConnectorContext(); + (mockContext.getCredentials as ReturnType).mockResolvedValue({ + apiKey: 'test-api-key', + }); + + await tool.handler({ categoryId: '10' }, mockContext); + }); + }); + }); +}); diff --git a/packages/mcp-connectors/src/connectors/youtube.ts b/packages/mcp-connectors/src/connectors/youtube.ts new file mode 100644 index 00000000..e9bae0dc --- /dev/null +++ b/packages/mcp-connectors/src/connectors/youtube.ts @@ -0,0 +1,606 @@ +import { mcpConnectorConfig } from '@stackone/mcp-config-types'; +import { z } from 'zod'; + +// YouTube API interfaces for type safety +interface YouTubeVideo { + kind: string; + etag: string; + id: string; + snippet?: { + publishedAt: string; + channelId: string; + title: string; + description: string; + thumbnails: { + default: { url: string; width: number; height: number }; + medium?: { url: string; width: number; height: number }; + high?: { url: string; width: number; height: number }; + standard?: { url: string; width: number; height: number }; + maxres?: { url: string; width: number; height: number }; + }; + channelTitle: string; + tags?: string[]; + categoryId: string; + liveBroadcastContent: string; + defaultLanguage?: string; + defaultAudioLanguage?: string; + }; + statistics?: { + viewCount: string; + likeCount: string; + dislikeCount: string; + favoriteCount: string; + commentCount: string; + }; + contentDetails?: { + duration: string; + dimension: string; + definition: string; + caption: string; + licensedContent: boolean; + regionRestriction?: { + allowed?: string[]; + blocked?: string[]; + }; + }; + status?: { + uploadStatus: string; + privacyStatus: string; + license: string; + embeddable: boolean; + publicStatsViewable: boolean; + madeForKids: boolean; + }; +} + +interface YouTubeChannel { + kind: string; + etag: string; + id: string; + snippet?: { + title: string; + description: string; + customUrl?: string; + publishedAt: string; + thumbnails: { + default: { url: string; width: number; height: number }; + medium?: { url: string; width: number; height: number }; + high?: { url: string; width: number; height: number }; + }; + country?: string; + }; + statistics?: { + viewCount: string; + subscriberCount: string; + hiddenSubscriberCount: boolean; + videoCount: string; + }; + contentDetails?: { + relatedPlaylists: { + likes?: string; + favorites?: string; + uploads?: string; + watchHistory?: string; + watchLater?: string; + }; + }; +} + +interface YouTubePlaylist { + kind: string; + etag: string; + id: string; + snippet?: { + publishedAt: string; + channelId: string; + title: string; + description: string; + thumbnails: { + default: { url: string; width: number; height: number }; + medium?: { url: string; width: number; height: number }; + high?: { url: string; width: number; height: number }; + }; + channelTitle: string; + defaultLanguage?: string; + }; + status?: { + privacyStatus: string; + }; + contentDetails?: { + itemCount: number; + }; +} + +interface YouTubeSearchResult { + kind: string; + etag: string; + id: { + kind: string; + videoId?: string; + channelId?: string; + playlistId?: string; + }; + snippet: { + publishedAt: string; + channelId: string; + title: string; + description: string; + thumbnails: { + default: { url: string; width: number; height: number }; + medium?: { url: string; width: number; height: number }; + high?: { url: string; width: number; height: number }; + }; + channelTitle: string; + liveBroadcastContent: string; + }; +} + +interface YouTubePlaylistItem { + kind: string; + etag: string; + id: string; + snippet: { + publishedAt: string; + channelId: string; + title: string; + description: string; + thumbnails: { + default: { url: string; width: number; height: number }; + medium?: { url: string; width: number; height: number }; + high?: { url: string; width: number; height: number }; + }; + channelTitle: string; + playlistId: string; + position: number; + resourceId: { + kind: string; + videoId: string; + }; + }; + contentDetails: { + videoId: string; + startAt?: string; + endAt?: string; + note?: string; + videoPublishedAt?: string; + }; +} + +interface YouTubeApiResponse { + kind: string; + etag: string; + nextPageToken?: string; + prevPageToken?: string; + regionCode?: string; + pageInfo: { + totalResults: number; + resultsPerPage: number; + }; + items: T[]; +} + +class YouTubeClient { + private apiKey: string; + private baseUrl = 'https://www.googleapis.com/youtube/v3'; + + constructor(apiKey: string) { + this.apiKey = apiKey; + } + + private async makeRequest( + endpoint: string, + params: Record + ): Promise> { + const url = new URL(`${this.baseUrl}${endpoint}`); + url.searchParams.append('key', this.apiKey); + + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== null) { + url.searchParams.append(key, value); + } + } + + const response = await fetch(url.toString()); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `YouTube API error: ${response.status} ${response.statusText} - ${errorText}` + ); + } + + return response.json() as Promise>; + } + + async searchVideos(params: { + q: string; + maxResults?: number; + order?: 'relevance' | 'date' | 'rating' | 'viewCount' | 'title'; + publishedAfter?: string; + publishedBefore?: string; + regionCode?: string; + relevanceLanguage?: string; + channelId?: string; + channelType?: 'any' | 'show'; + videoDefinition?: 'any' | 'high' | 'standard'; + videoDuration?: 'any' | 'long' | 'medium' | 'short'; + videoLicense?: 'any' | 'creativeCommon' | 'youtube'; + videoCaption?: 'any' | 'closedCaption' | 'none'; + pageToken?: string; + }): Promise> { + const queryParams: Record = { + part: 'snippet', + type: 'video', + q: params.q, + maxResults: (params.maxResults || 10).toString(), + }; + + if (params.order) queryParams.order = params.order; + if (params.publishedAfter) queryParams.publishedAfter = params.publishedAfter; + if (params.publishedBefore) queryParams.publishedBefore = params.publishedBefore; + if (params.regionCode) queryParams.regionCode = params.regionCode; + if (params.relevanceLanguage) + queryParams.relevanceLanguage = params.relevanceLanguage; + if (params.channelId) queryParams.channelId = params.channelId; + if (params.channelType) queryParams.channelType = params.channelType; + if (params.videoDefinition) queryParams.videoDefinition = params.videoDefinition; + if (params.videoDuration) queryParams.videoDuration = params.videoDuration; + if (params.videoLicense) queryParams.videoLicense = params.videoLicense; + if (params.videoCaption) queryParams.videoCaption = params.videoCaption; + if (params.pageToken) queryParams.pageToken = params.pageToken; + + return this.makeRequest('/search', queryParams); + } + + async searchChannels(params: { + q: string; + maxResults?: number; + order?: 'relevance' | 'date' | 'rating' | 'viewCount' | 'title'; + regionCode?: string; + relevanceLanguage?: string; + pageToken?: string; + }): Promise> { + const queryParams: Record = { + part: 'snippet', + type: 'channel', + q: params.q, + maxResults: (params.maxResults || 10).toString(), + }; + + if (params.order) queryParams.order = params.order; + if (params.regionCode) queryParams.regionCode = params.regionCode; + if (params.relevanceLanguage) + queryParams.relevanceLanguage = params.relevanceLanguage; + if (params.pageToken) queryParams.pageToken = params.pageToken; + + return this.makeRequest('/search', queryParams); + } + + async getVideoDetails(params: { + videoIds: string[]; + part?: string[]; + }): Promise> { + const parts = params.part || ['snippet', 'statistics', 'contentDetails']; + const queryParams: Record = { + part: parts.join(','), + id: params.videoIds.join(','), + }; + + return this.makeRequest('/videos', queryParams); + } + + async getChannelDetails(params: { + channelIds?: string[]; + forUsername?: string; + part?: string[]; + }): Promise> { + const parts = params.part || ['snippet', 'statistics', 'contentDetails']; + const queryParams: Record = { + part: parts.join(','), + }; + + if (params.channelIds) { + queryParams.id = params.channelIds.join(','); + } else if (params.forUsername) { + queryParams.forUsername = params.forUsername; + } + + return this.makeRequest('/channels', queryParams); + } + + async getPlaylistDetails(params: { + playlistIds: string[]; + part?: string[]; + }): Promise> { + const parts = params.part || ['snippet', 'status', 'contentDetails']; + const queryParams: Record = { + part: parts.join(','), + id: params.playlistIds.join(','), + }; + + return this.makeRequest('/playlists', queryParams); + } + + async getPlaylistItems(params: { + playlistId: string; + maxResults?: number; + pageToken?: string; + }): Promise> { + const queryParams: Record = { + part: 'snippet,contentDetails', + playlistId: params.playlistId, + maxResults: (params.maxResults || 25).toString(), + }; + + if (params.pageToken) { + queryParams.pageToken = params.pageToken; + } + + return this.makeRequest('/playlistItems', queryParams); + } + + async getMostPopularVideos(params: { + regionCode?: string; + categoryId?: string; + maxResults?: number; + pageToken?: string; + }): Promise> { + const queryParams: Record = { + part: 'snippet,statistics,contentDetails', + chart: 'mostPopular', + maxResults: (params.maxResults || 25).toString(), + }; + + if (params.regionCode) queryParams.regionCode = params.regionCode; + if (params.categoryId) queryParams.videoCategoryId = params.categoryId; + if (params.pageToken) queryParams.pageToken = params.pageToken; + + return this.makeRequest('/videos', queryParams); + } +} + +export const YouTubeConnectorConfig = mcpConnectorConfig({ + name: 'YouTube', + key: 'youtube', + version: '1.0.0', + logo: 'https://www.youtube.com/s/desktop/8d9c6f0b/img/favicon_144x144.png', + description: + 'Access YouTube content including videos, channels, playlists, and search functionality', + credentials: z.object({ + apiKey: z + .string() + .describe('YouTube Data API v3 key (get from Google Cloud Console)'), + }), + setup: z.object({}), + examplePrompt: + 'Search for "machine learning tutorials" videos, get details about a specific channel, and find the most popular tech videos in the US.', + tools: (tool) => ({ + SEARCH_VIDEOS: tool({ + name: 'youtube_search_videos', + description: 'Search for videos on YouTube with advanced filtering options', + schema: z.object({ + q: z.string().describe('Search query'), + maxResults: z + .number() + .min(1) + .max(50) + .default(10) + .describe('Number of results (1-50)'), + order: z + .enum(['relevance', 'date', 'rating', 'viewCount', 'title']) + .default('relevance') + .describe('Sort order'), + publishedAfter: z + .string() + .optional() + .describe('Videos published after this date (RFC 3339)'), + publishedBefore: z + .string() + .optional() + .describe('Videos published before this date (RFC 3339)'), + regionCode: z + .string() + .optional() + .describe('Two-letter country code (e.g., "US", "GB")'), + relevanceLanguage: z + .string() + .optional() + .describe('Language code (e.g., "en", "es")'), + channelId: z.string().optional().describe('Search within specific channel'), + videoDefinition: z + .enum(['any', 'high', 'standard']) + .optional() + .describe('Video quality'), + videoDuration: z + .enum(['any', 'long', 'medium', 'short']) + .optional() + .describe('Video length'), + videoLicense: z + .enum(['any', 'creativeCommon', 'youtube']) + .optional() + .describe('Video license'), + videoCaption: z + .enum(['any', 'closedCaption', 'none']) + .optional() + .describe('Caption availability'), + pageToken: z.string().optional().describe('Token for pagination'), + }), + handler: async (args, context) => { + try { + const { apiKey } = await context.getCredentials(); + const client = new YouTubeClient(apiKey); + const results = await client.searchVideos(args); + return JSON.stringify(results, null, 2); + } catch (error) { + return `Failed to search videos: ${error instanceof Error ? error.message : String(error)}`; + } + }, + }), + + SEARCH_CHANNELS: tool({ + name: 'youtube_search_channels', + description: 'Search for channels on YouTube', + schema: z.object({ + q: z.string().describe('Search query'), + maxResults: z + .number() + .min(1) + .max(50) + .default(10) + .describe('Number of results (1-50)'), + order: z + .enum(['relevance', 'date', 'rating', 'viewCount', 'title']) + .default('relevance') + .describe('Sort order'), + regionCode: z.string().optional().describe('Two-letter country code'), + relevanceLanguage: z.string().optional().describe('Language code'), + pageToken: z.string().optional().describe('Token for pagination'), + }), + handler: async (args, context) => { + try { + const { apiKey } = await context.getCredentials(); + const client = new YouTubeClient(apiKey); + const results = await client.searchChannels(args); + return JSON.stringify(results, null, 2); + } catch (error) { + return `Failed to search channels: ${error instanceof Error ? error.message : String(error)}`; + } + }, + }), + + GET_VIDEO_DETAILS: tool({ + name: 'youtube_get_video_details', + description: 'Get detailed information about specific videos', + schema: z.object({ + videoIds: z + .array(z.string()) + .min(1) + .max(50) + .describe('Array of video IDs (max 50)'), + includeParts: z + .array(z.enum(['snippet', 'statistics', 'contentDetails', 'status'])) + .default(['snippet', 'statistics', 'contentDetails']) + .describe('Parts of video data to include'), + }), + handler: async (args, context) => { + try { + const { apiKey } = await context.getCredentials(); + const client = new YouTubeClient(apiKey); + const results = await client.getVideoDetails({ + videoIds: args.videoIds, + part: args.includeParts, + }); + return JSON.stringify(results, null, 2); + } catch (error) { + return `Failed to get video details: ${error instanceof Error ? error.message : String(error)}`; + } + }, + }), + + GET_CHANNEL_DETAILS: tool({ + name: 'youtube_get_channel_details', + description: 'Get detailed information about specific channels', + schema: z.object({ + channelIds: z.array(z.string()).optional().describe('Array of channel IDs'), + username: z.string().optional().describe('Channel username (alternative to IDs)'), + includeParts: z + .array(z.enum(['snippet', 'statistics', 'contentDetails', 'brandingSettings'])) + .default(['snippet', 'statistics', 'contentDetails']) + .describe('Parts of channel data to include'), + }), + handler: async (args, context) => { + try { + const { apiKey } = await context.getCredentials(); + const client = new YouTubeClient(apiKey); + const results = await client.getChannelDetails({ + channelIds: args.channelIds, + forUsername: args.username, + part: args.includeParts, + }); + return JSON.stringify(results, null, 2); + } catch (error) { + return `Failed to get channel details: ${error instanceof Error ? error.message : String(error)}`; + } + }, + }), + + GET_PLAYLIST_DETAILS: tool({ + name: 'youtube_get_playlist_details', + description: 'Get information about specific playlists', + schema: z.object({ + playlistIds: z.array(z.string()).min(1).max(50).describe('Array of playlist IDs'), + includeParts: z + .array(z.enum(['snippet', 'status', 'contentDetails'])) + .default(['snippet', 'status', 'contentDetails']) + .describe('Parts of playlist data to include'), + }), + handler: async (args, context) => { + try { + const { apiKey } = await context.getCredentials(); + const client = new YouTubeClient(apiKey); + const results = await client.getPlaylistDetails({ + playlistIds: args.playlistIds, + part: args.includeParts, + }); + return JSON.stringify(results, null, 2); + } catch (error) { + return `Failed to get playlist details: ${error instanceof Error ? error.message : String(error)}`; + } + }, + }), + + GET_PLAYLIST_ITEMS: tool({ + name: 'youtube_get_playlist_items', + description: 'Get videos from a specific playlist', + schema: z.object({ + playlistId: z.string().describe('Playlist ID'), + maxResults: z + .number() + .min(1) + .max(50) + .default(25) + .describe('Number of results (1-50)'), + pageToken: z.string().optional().describe('Token for pagination'), + }), + handler: async (args, context) => { + try { + const { apiKey } = await context.getCredentials(); + const client = new YouTubeClient(apiKey); + const results = await client.getPlaylistItems(args); + return JSON.stringify(results, null, 2); + } catch (error) { + return `Failed to get playlist items: ${error instanceof Error ? error.message : String(error)}`; + } + }, + }), + + GET_POPULAR_VIDEOS: tool({ + name: 'youtube_get_popular_videos', + description: 'Get most popular videos by region and category', + schema: z.object({ + regionCode: z.string().default('US').describe('Two-letter country code'), + categoryId: z + .string() + .optional() + .describe('Video category ID (e.g., "1" for Film & Animation)'), + maxResults: z + .number() + .min(1) + .max(50) + .default(25) + .describe('Number of results (1-50)'), + pageToken: z.string().optional().describe('Token for pagination'), + }), + handler: async (args, context) => { + try { + const { apiKey } = await context.getCredentials(); + const client = new YouTubeClient(apiKey); + const results = await client.getMostPopularVideos(args); + return JSON.stringify(results, null, 2); + } catch (error) { + return `Failed to get popular videos: ${error instanceof Error ? error.message : String(error)}`; + } + }, + }), + }), +}); diff --git a/packages/mcp-connectors/src/index.ts b/packages/mcp-connectors/src/index.ts index 73bb4412..6aee0a68 100644 --- a/packages/mcp-connectors/src/index.ts +++ b/packages/mcp-connectors/src/index.ts @@ -50,6 +50,7 @@ import { TodoListConnectorConfig } from './connectors/todolist'; import { TurbopufferConnectorConfig } from './connectors/turbopuffer'; import { WandbConnectorConfig } from './connectors/wandb'; import { XeroConnectorConfig } from './connectors/xero'; +import { YouTubeConnectorConfig } from './connectors/youtube'; import { ZapierConnectorConfig } from './connectors/zapier'; export const Connectors: readonly MCPConnectorConfig[] = [ @@ -102,6 +103,7 @@ export const Connectors: readonly MCPConnectorConfig[] = [ TurbopufferConnectorConfig, WandbConnectorConfig, XeroConnectorConfig, + YouTubeConnectorConfig, ZapierConnectorConfig, ] as const; @@ -155,5 +157,6 @@ export { TurbopufferConnectorConfig, WandbConnectorConfig, XeroConnectorConfig, + YouTubeConnectorConfig, ZapierConnectorConfig, };