UNPKG

zoom-recordings-server

Version:

MCP server for downloading Zoom Cloud Recordings (MP4 files only)

941 lines (827 loc) • 34 kB
#!/usr/bin/env node import 'dotenv/config'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js'; import axios, { AxiosInstance } from 'axios'; import fs from 'fs-extra'; import path from 'path'; // Environment variables from MCP config const ZOOM_CLIENT_ID = process.env.ZOOM_CLIENT_ID; const ZOOM_CLIENT_SECRET = process.env.ZOOM_CLIENT_SECRET; const ZOOM_DATA = process.env.ZOOM_DATA; const AUTHORIZATION_CODE = process.env.AUTHORIZATION_CODE; // Token storage file const TOKEN_FILE = path.join(ZOOM_DATA!, '.zoom-tokens.json'); // Debug environment variables console.error(`Running with Node.js version: ${process.version}`); console.error('Environment variables:'); console.error(`ZOOM_CLIENT_ID: ${ZOOM_CLIENT_ID ? 'Set' : 'Not set'}`); console.error(`ZOOM_CLIENT_SECRET: ${ZOOM_CLIENT_SECRET ? 'Set' : 'Not set'}`); console.error(`ZOOM_DATA: ${ZOOM_DATA}`); console.error(`AUTHORIZATION_CODE: ${AUTHORIZATION_CODE ? 'Set' : 'Not set'}`); console.error(`TOKEN_FILE: ${TOKEN_FILE}`); // Validate required environment variables if (!ZOOM_CLIENT_ID || !ZOOM_CLIENT_SECRET || !ZOOM_DATA) { throw new Error('Missing required environment variables: ZOOM_CLIENT_ID, ZOOM_CLIENT_SECRET, ZOOM_DATA'); } // Ensure recordings directory exists fs.ensureDirSync(ZOOM_DATA!); // Types and interfaces interface ZoomTokens { access_token: string; refresh_token: string; token_type: string; expires_in: number; expires_at: number; scope: string; } interface ZoomMeeting { id: string; uuid: string; topic: string; start_time: string; duration: number; total_size: number; recording_count: number; recording_files?: ZoomRecordingFile[]; } interface ZoomRecordingFile { id: string; meeting_id: string; recording_start: string; recording_end: string; file_type: string; file_size: number; play_url: string; download_url: string; status: string; recording_type: string; file_extension?: string; // Added for transcript file type } interface ZoomMeetingRecordingDetails { uuid: string; // Meeting UUID id: number; // Meeting ID (numeric) topic: string; start_time: string; recording_count: number; recording_files: ZoomRecordingFile[]; // Add other fields from API response as needed } interface DateRange { from?: string; to?: string; } // Zoom API Client with User OAuth class ZoomUserClient { private tokens: ZoomTokens | null = null; private axiosInstance: AxiosInstance; constructor( private clientId: string, private clientSecret: string, private tokenFile: string ) { this.axiosInstance = axios.create({ baseURL: 'https://api.zoom.us/v2', }); // Add request interceptor to handle token refresh this.axiosInstance.interceptors.request.use( async (config) => { // If this is a request to the token endpoint, don't add auth header or try to refresh. // Note: refreshAccessToken and exchangeAuthorizationCode use direct axios.post, // so this interceptor won't apply to them anyway. This check is for robustness. if (config.url && (config.url.includes('zoom.us/oauth/token') || (config.baseURL && config.baseURL.includes('zoom.us/oauth/token')) )) { return config; } // Ensure we have a valid token before making a request (unless it's a token request itself) if (!this.tokens || this.isTokenExpired()) { console.error('Token is null or pre-request expired, attempting refresh.'); try { await this.refreshAccessToken(); } catch (refreshError) { console.error('Pre-request refreshAccessToken failed:', refreshError); // If refresh fails (e.g., needs new AUTHORIZATION_CODE), // let the original request proceed. It will likely fail and be caught // by the response interceptor, or the error from refreshAccessToken // might have already been thrown if it's critical. } } // Add authorization header if token exists if (this.tokens) { config.headers.Authorization = `Bearer ${this.tokens.access_token}`; } return config; }, (error) => { // Handle request configuration errors return Promise.reject(error); } ); // Add response interceptor to handle 401 errors and retry this.axiosInstance.interceptors.response.use( (response) => response, // Pass through successful responses async (error) => { const originalRequest = error.config; // Check if it's an Axios error, a 401 status, not already a retry, // and not a request to the token endpoint itself. if ( axios.isAxiosError(error) && error.response?.status === 401 && !originalRequest._retry && !(originalRequest.url && (originalRequest.url.includes('zoom.us/oauth/token') || (originalRequest.baseURL && originalRequest.baseURL.includes('zoom.us/oauth/token')))) ) { originalRequest._retry = true; // Mark that we are retrying this request console.error( 'API call failed with 401. Attempting token refresh and retry.' ); try { await this.refreshAccessToken(); // Attempt to refresh the token // If refresh is successful, this.tokens is updated. // Update the Authorization header for the original request. if (this.tokens) { originalRequest.headers.Authorization = `Bearer ${this.tokens.access_token}`; } return this.axiosInstance(originalRequest); // Retry the original request } catch (refreshError) { console.error( 'Token refresh failed during 401 recovery:', refreshError ); // If refresh fails, reject with the refreshError or the original error. // Rejecting with refreshError provides more specific info about why auth ultimately failed. return Promise.reject(refreshError); } } // For other errors, or if it's a retry that failed, or it's the token endpoint, // reject with the original error. return Promise.reject(error); } ); } private isTokenExpired(): boolean { if (!this.tokens) return true; // Consider token expired if less than 5 minutes remaining const now = Date.now(); return now >= this.tokens.expires_at - 5 * 60 * 1000; } async initialize(): Promise<void> { try { // Try to load existing tokens if (fs.existsSync(this.tokenFile)) { console.error('Loading existing tokens from file...'); const fileContent = fs.readFileSync( this.tokenFile, 'utf-8' ); this.tokens = JSON.parse(fileContent); console.error('Tokens loaded successfully'); // Check if tokens are expired if (this.isTokenExpired()) { console.error('Loaded tokens are expired, refreshing...'); await this.refreshAccessToken(); } } else { // No existing tokens, need to exchange authorization code if (!AUTHORIZATION_CODE) { throw new Error('No existing tokens found and no AUTHORIZATION_CODE provided. Please provide AUTHORIZATION_CODE for initial setup.'); } console.error('No existing tokens found, exchanging authorization code...'); await this.exchangeAuthorizationCode(AUTHORIZATION_CODE); } } catch (error) { console.error('Failed to initialize Zoom client:', error); throw new Error(`Failed to initialize Zoom authentication: ${error instanceof Error ? error.message : String(error)}`); } } private async exchangeAuthorizationCode(authCode: string): Promise<void> { try { console.error('Exchanging authorization code for tokens...'); const response = await axios.post( 'https://zoom.us/oauth/token', null, { params: { grant_type: 'authorization_code', code: authCode, redirect_uri: 'http://localhost:3000/callback', // This should match your OAuth app redirect URI }, auth: { username: this.clientId, password: this.clientSecret, }, headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, } ); this.tokens = { ...response.data, expires_at: Date.now() + response.data.expires_in * 1000, }; // Save tokens to file await this.saveTokens(); console.error('Authorization code exchanged successfully'); } catch (error) { console.error('Failed to exchange authorization code:', error); if (axios.isAxiosError(error)) { console.error('Response data:', error.response?.data); console.error('Response status:', error.response?.status); if (error.response?.status === 401) { throw new Error('Authentication failed. Access token may be invalid or expired.'); } else if (error.response?.status === 403) { throw new Error('Access forbidden. Check that your OAuth app has the required scopes: cloud_recording:read:list_user_recordings'); } else if (error.response?.status === 404) { throw new Error('User not found or no recordings available.'); } } throw new Error(`Failed to exchange authorization code: ${error instanceof Error ? error.message : String(error)}`); } } private async refreshAccessToken(): Promise<void> { if (!this.tokens?.refresh_token) { // If there's no refresh token to begin with, try to clear any potentially stale token file. if (fs.existsSync(this.tokenFile)) { try { console.error('No refresh token available, removing stale token file.'); fs.removeSync(this.tokenFile); } catch (removeError) { console.error('Failed to remove stale token file:', removeError); } } this.tokens = null; // Clear in-memory tokens throw new Error('No refresh token available. Please provide a new AUTHORIZATION_CODE.'); } try { console.error('Refreshing access token...'); const response = await axios.post( 'https://zoom.us/oauth/token', null, { params: { grant_type: 'refresh_token', refresh_token: this.tokens.refresh_token, }, auth: { username: this.clientId, password: this.clientSecret, }, headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, } ); this.tokens = { ...response.data, expires_at: Date.now() + response.data.expires_in * 1000, }; // Save updated tokens to file await this.saveTokens(); console.error('Access token refreshed successfully'); } catch (error) { console.error('Failed to refresh access token:', error); if (axios.isAxiosError(error)) { console.error('Response data:', error.response?.data); console.error('Response status:', error.response?.status); // If refresh token is invalid (e.g., 400 or 401 error) if (error.response?.status === 400 || error.response?.status === 401) { console.error('Refresh token is invalid. Clearing stored tokens and attempting to re-authenticate with AUTHORIZATION_CODE.'); // Clear existing tokens from disk and memory try { if (fs.existsSync(this.tokenFile)) { fs.removeSync(this.tokenFile); console.error('Token file removed.'); } } catch (removeError) { console.error('Failed to remove token file:', removeError); } this.tokens = null; // Clear in-memory tokens // Attempt to use AUTHORIZATION_CODE from .env if (AUTHORIZATION_CODE) { try { console.error('Attempting to exchange AUTHORIZATION_CODE for new tokens...'); await this.exchangeAuthorizationCode(AUTHORIZATION_CODE); console.error('Successfully re-authenticated using AUTHORIZATION_CODE. New tokens obtained.'); return; // New tokens obtained, refresh process is effectively complete with new tokens } catch (exchangeError) { console.error('Failed to re-authenticate using AUTHORIZATION_CODE:', exchangeError); // Throw an error indicating both attempts failed throw new Error(`Refresh token failed, and subsequent attempt to use AUTHORIZATION_CODE also failed: ${exchangeError instanceof Error ? exchangeError.message : String(exchangeError)}`); } } else { // No AUTHORIZATION_CODE available in .env throw new Error('Refresh token is invalid or expired, and no AUTHORIZATION_CODE is available in .env to re-authenticate. Please provide a new AUTHORIZATION_CODE manually.'); } } } // For other errors not handled above (e.g., network issues, non-400/401 errors during refresh) throw new Error(`Failed to refresh access token: ${error instanceof Error ? error.message : String(error)}`); } } private async saveTokens(): Promise<void> { if (this.tokens) { try { fs.writeFileSync( this.tokenFile, JSON.stringify(this.tokens, null, 2) ); } catch (error) { console.error('Failed to save tokens:', error); } console.error('Tokens saved to file'); } } async listRecordings(params: { from?: string; to?: string; page_size?: number; next_page_token?: string } = {}): Promise<any> { try { // Set default parameters if not provided const defaultParams = { page_size: params.page_size || 30, from: params.from || '2024-01-01', ...params }; console.error('Attempting to list recordings with params:', defaultParams); const response = await this.axiosInstance.get('/users/me/recordings', { params: defaultParams }); console.error('Recordings response received:', response.status); console.error('Total records:', response.data.total_records); console.error('Meetings count:', response.data.meetings?.length || 0); return response.data; } catch (error) { console.error('Failed to list recordings. Detailed error:', error); if (axios.isAxiosError(error)) { console.error('Response data:', error.response?.data); console.error('Response status:', error.response?.status); if (error.response?.status === 401) { throw new Error('Authentication failed. Access token may be invalid or expired.'); } else if (error.response?.status === 403) { throw new Error('Access forbidden. Check that your OAuth app has the required scopes: cloud_recording:read:list_user_recordings'); } else if (error.response?.status === 404) { throw new Error('User not found or no recordings available.'); } } throw new Error(`Failed to list Zoom recordings: ${error instanceof Error ? error.message : String(error)}`); } } async downloadRecording(downloadUrl: string, outputPath: string): Promise<void> { try { console.error(`Downloading recording to: ${outputPath}`); const response = await this.axiosInstance({ method: 'GET', url: downloadUrl, responseType: 'stream', maxRedirects: 5, }); // Ensure output directory exists await fs.ensureDir(path.dirname(outputPath)); // Create write stream const writer = fs.createWriteStream(outputPath); // Pipe the response to the file response.data.pipe(writer); // Wait for download to complete await new Promise((resolve, reject) => { writer.on('finish', () => resolve(undefined)); writer.on('error', reject); response.data.on('error', reject); }); // Check if file was created and has content const stats = await fs.stat(outputPath); if (stats.size === 0) { throw new Error('Downloaded file is empty. The download URL may be invalid or expired.'); } console.error(`Download completed: ${outputPath} (${stats.size} bytes)`); } catch (error) { console.error('Failed to download recording:', error); if (axios.isAxiosError(error)) { console.error('Response status:', error.response?.status); console.error('Response headers:', error.response?.headers); if (error.response?.status === 401) { throw new Error('Authentication failed during download. Access token may be invalid.'); } else if (error.response?.status === 403) { throw new Error('Access forbidden. You may not have permission to download this recording.'); } else if (error.response?.status === 404) { throw new Error('Recording not found or download URL is invalid/expired.'); } } throw new Error(`Failed to download Zoom recording: ${error instanceof Error ? error.message : String(error)}`); } } async getTranscript(meetingId: string, recordingFileId?: string): Promise<string> { console.error(`Attempting to get transcript content for meeting ${meetingId}` + (recordingFileId ? ` (recording file ID: ${recordingFileId})` : '')); // Get recordings list to find the meeting const recordingsList = await this.listRecordings({ page_size: 100, from: '2024-01-01', // Look back far enough to find the meeting }); // Find the meeting by ID or UUID. Ensure comparison handles both string and number types for ID. const meetingDetails = recordingsList.meetings?.find( (m: ZoomMeeting) => String(m.id) === String(meetingId) || m.uuid === meetingId ); if (!meetingDetails) { throw new Error(`Meeting with ID "${meetingId}" not found among your accessible recordings. Ensure the ID is correct and recordings exist.`); } let transcriptFile: ZoomRecordingFile | undefined; if (meetingDetails.recording_files && meetingDetails.recording_files.length > 0) { transcriptFile = meetingDetails.recording_files.find( (file: ZoomRecordingFile) => file.file_type === "TRANSCRIPT" && file.status === "completed" && (!recordingFileId || file.id === recordingFileId) ); } if (!transcriptFile) { const idFilter = recordingFileId ? ` with ID ${recordingFileId}` : ''; throw new Error(`Completed transcript file not found for meeting ${meetingId}${idFilter}.`); } if (!transcriptFile.download_url) { throw new Error(`Transcript file for meeting ${meetingId} (ID: ${transcriptFile.id}) does not have a download URL.`); } console.error(`Found transcript file: ID ${transcriptFile.id}, type ${transcriptFile.file_type}, extension ${transcriptFile.file_extension}. Fetching content...`); try { const response = await axios({ method: 'GET', url: transcriptFile.download_url, responseType: 'text', // Get raw text content headers: { Authorization: `Bearer ${this.tokens?.access_token}`, }, maxRedirects: 5, }); console.error(`Transcript content for meeting ${meetingId} (file ID: ${transcriptFile.id}) fetched successfully.`); return response.data as string; // This will be the VTT content or similar } catch (error) { console.error(`Failed to fetch transcript content from ${transcriptFile.download_url}:`, error); if (axios.isAxiosError(error)) { console.error('Response status:', error.response?.status); if (error.response?.status === 401) { throw new Error('Authentication failed fetching transcript. Access token may be invalid.'); } else if (error.response?.status === 403) { throw new Error('Access forbidden for transcript URL.'); } else if (error.response?.status === 404) { throw new Error('Transcript download URL not found or expired.'); } } throw new Error(`Failed to fetch transcript content for meeting ${meetingId} (file ID: ${transcriptFile.id}): ${error instanceof Error ? error.message : String(error)}`); } } } // File System Manager class FileSystemManager { constructor(private baseDir: string) { fs.ensureDirSync(this.baseDir); } getMonthlyDir(date: Date): string { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const monthDir = path.join(this.baseDir, `${year}-${month}`); fs.ensureDirSync(monthDir); return monthDir; } formatFileName(meeting: ZoomMeeting, fileType: string = 'mp4'): string { const startTime = new Date(meeting.start_time); const date = startTime.toISOString().split('T')[0]; const time = startTime.toTimeString().split(' ')[0].replace(/:/g, '-'); // Sanitize topic for filename const sanitizedTopic = meeting.topic .replace(/[^\w\s-]/g, '') .replace(/\s+/g, '-') .substring(0, 50); return `${date}_${time}_${sanitizedTopic}_${meeting.id}.${fileType}`; } getRecordingPath(meeting: ZoomMeeting, fileType: string = 'mp4'): string { const monthDir = this.getMonthlyDir(new Date(meeting.start_time)); const fileName = this.formatFileName(meeting, fileType); return path.join(monthDir, fileName); } } // MCP Server Implementation class ZoomRecordingsServer { private server: Server; private zoomClient: ZoomUserClient; private fileManager: FileSystemManager; constructor() { this.server = new Server( { name: 'zoom-recordings-server', version: '1.0.0', }, { capabilities: { tools: {}, }, } ); this.zoomClient = new ZoomUserClient( ZOOM_CLIENT_ID!, ZOOM_CLIENT_SECRET!, TOKEN_FILE ); this.fileManager = new FileSystemManager(ZOOM_DATA!); this.setupToolHandlers(); // Error handling this.server.onerror = (error) => console.error('[MCP Error]', error); process.on('SIGINT', async () => { await this.server.close(); process.exit(0); }); } private setupToolHandlers() { this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { name: 'list_recordings', description: 'List available Zoom cloud recordings with MP4 video files. This tool fetches your personal Zoom recordings from the cloud.', inputSchema: { type: 'object', properties: { dateRange: { type: 'object', properties: { from: { type: 'string', description: 'Start date (ISO format, e.g., 2024-01-01)', }, to: { type: 'string', description: 'End date (ISO format, e.g., 2024-12-31)', }, }, description: 'Optional date range filter', }, pageSize: { type: 'number', description: 'Number of recordings to fetch (default: 30, max: 300)', minimum: 1, maximum: 300, }, }, }, }, { name: 'download_recording', description: 'Download a specific Zoom recording MP4 file to local storage. Use the meeting ID or UUID from list_recordings.', inputSchema: { type: 'object', properties: { meetingId: { type: 'string', description: 'Meeting ID (numeric) or UUID (string with dashes) of the recording to download', }, }, required: ['meetingId'], }, }, { name: 'get_transcript', description: 'Retrieve the transcript for a specific Zoom recording.', inputSchema: { type: 'object', properties: { meetingId: { type: 'string', description: 'Meeting ID (numeric) or UUID (string with dashes) of the recording to get transcript for', }, }, required: ['meetingId'], }, }, ], })); this.server.setRequestHandler(CallToolRequestSchema, async (request) => { try { switch (request.params.name) { case 'list_recordings': return await this.handleListRecordings(request.params.arguments); case 'get_transcript': return await this.handleGetTranscript(request.params.arguments); case 'download_recording': return await this.handleDownloadRecording(request.params.arguments); default: throw new McpError( ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}` ); } } catch (error) { console.error(`Error handling tool ${request.params.name}:`, error); return { content: [ { type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}`, }, ], isError: true, }; } }); } private async handleListRecordings(args: any): Promise<any> { const dateRange = args?.dateRange; const pageSize = args?.pageSize || 30; try { // Prepare parameters const params: any = { page_size: Math.min(pageSize, 300), // Enforce maximum }; if (dateRange?.from) { params.from = dateRange.from; } if (dateRange?.to) { params.to = dateRange.to; } // Get recordings list const recordings = await this.zoomClient.listRecordings(params); // Check if there are recordings if (!recordings.meetings || recordings.meetings.length === 0) { return { content: [ { type: 'text', text: 'No Zoom recordings found for the specified criteria. You may not have any cloud recordings available in the requested date range.', }, ], }; } // Filter meetings that have MP4 recording files const meetingsWithMP4 = recordings.meetings.filter((meeting: ZoomMeeting) => { return meeting.recording_files?.some((file: ZoomRecordingFile) => file.file_type === 'MP4' && file.status === 'completed' ); }); if (meetingsWithMP4.length === 0) { return { content: [ { type: 'text', text: `Found ${recordings.meetings.length} meetings with recordings, but none have completed MP4 video files available for download.`, }, ], }; } // Format results const formattedResults = meetingsWithMP4.map((meeting: ZoomMeeting) => { const startTime = new Date(meeting.start_time).toLocaleString(); const duration = `${meeting.duration} minutes`; // Get MP4 file info const mp4Files = meeting.recording_files?.filter((file: ZoomRecordingFile) => file.file_type === 'MP4' && file.status === 'completed' ) || []; const totalSize = mp4Files.reduce((sum, file) => sum + (file.file_size || 0), 0); const sizeInMB = (totalSize / (1024 * 1024)).toFixed(2); return `šŸ“¹ **${meeting.topic}** • Meeting ID: ${meeting.id} • UUID: ${meeting.uuid} • Date: ${startTime} • Duration: ${duration} • MP4 Files: ${mp4Files.length} (${sizeInMB} MB total) • Recording Type: ${mp4Files.map(f => f.recording_type).join(', ')}`; }).join('\n\n'); const summary = `Found ${meetingsWithMP4.length} meetings with MP4 recordings (out of ${recordings.meetings.length} total meetings with recordings)`; return { content: [ { type: 'text', text: `${summary}\n\n${formattedResults}\n\nšŸ’” **To download a recording, use:** download_recording with meetingId: "meeting_id_or_uuid_here"`, }, ], }; } catch (error) { console.error('Error listing recordings:', error); return { content: [ { type: 'text', text: `Failed to list recordings: ${error instanceof Error ? error.message : String(error)}`, }, ], isError: true, }; } } private async handleGetTranscript(args: any): Promise<any> { const meetingId = args?.meetingId; if (typeof meetingId !== 'string' || !meetingId) { console.error(`Handling get_transcript for meeting ID: ${meetingId}`); throw new McpError( ErrorCode.InvalidParams, 'Invalid or missing "meetingId" parameter for get_transcript. It must be a non-empty string.' ); } // The zoomClient.getTranscript method has its own error handling for API calls. // Errors thrown there or by McpError above will be caught by the centralized // error handler in setRequestHandler for CallToolRequestSchema. const transcriptContent = await this.zoomClient.getTranscript(meetingId); return { content: [ { type: 'text', text: transcriptContent, }, ], }; } private async handleDownloadRecording(args: any): Promise<any> { if (!args?.meetingId) { throw new McpError(ErrorCode.InvalidParams, 'meetingId parameter is required'); } const meetingId = args.meetingId; try { // Get recordings list to find the meeting const recordings = await this.zoomClient.listRecordings({ page_size: 100, from: '2024-01-01', // Look back far enough to find the meeting }); // Try to find the meeting by ID or UUID const meeting = recordings.meetings?.find((m: ZoomMeeting) => String(m.id) === String(meetingId) || m.uuid === meetingId ); if (!meeting) { return { content: [ { type: 'text', text: `Meeting with ID "${meetingId}" not found in your recordings. Please check the meeting ID/UUID and ensure it has cloud recordings available.`, }, ], isError: true, }; } // Find MP4 recording files const mp4Files = meeting.recording_files?.filter((file: ZoomRecordingFile) => file.file_type === 'MP4' && file.status === 'completed' ) || []; if (mp4Files.length === 0) { return { content: [ { type: 'text', text: `Meeting "${meeting.topic}" found, but no completed MP4 files are available for download.`, }, ], isError: true, }; } // Download each MP4 file const downloadResults: string[] = []; for (const file of mp4Files) { try { const outputPath = this.fileManager.getRecordingPath(meeting, 'mp4'); // Check if file already exists if (await fs.pathExists(outputPath)) { const stats = await fs.stat(outputPath); downloadResults.push(`āœ… Already exists: ${outputPath} (${(stats.size / (1024 * 1024)).toFixed(2)} MB)`); continue; } // Download the file await this.zoomClient.downloadRecording(file.download_url, outputPath); // Get file size const stats = await fs.stat(outputPath); const sizeInMB = (stats.size / (1024 * 1024)).toFixed(2); downloadResults.push(`āœ… Downloaded: ${outputPath} (${sizeInMB} MB)`); } catch (downloadError) { downloadResults.push(`āŒ Failed to download ${file.recording_type}: ${downloadError instanceof Error ? downloadError.message : String(downloadError)}`); } } const summary = `šŸ“¹ **${meeting.topic}** (${new Date(meeting.start_time).toLocaleString()})\n` + `Meeting ID: ${meeting.id}\n\n` + `**Download Results:**\n${downloadResults.join('\n')}`; return { content: [ { type: 'text', text: summary, }, ], }; } catch (error) { console.error('Error downloading recording:', error); return { content: [ { type: 'text', text: `Failed to download recording: ${error instanceof Error ? error.message : String(error)}`, }, ], isError: true, }; } } async run() { try { // Initialize Zoom client (handle OAuth setup) await this.zoomClient.initialize(); const transport = new StdioServerTransport(); await this.server.connect(transport); console.error('Zoom Recordings MCP server running on stdio'); } catch (error) { console.error('Failed to start server:', error); process.exit(1); } } } // Run the server const server = new ZoomRecordingsServer(); server.run().catch(console.error);