zoom-recordings-server
Version:
MCP server for downloading Zoom Cloud Recordings (MP4 files only)
941 lines (827 loc) ⢠34 kB
text/typescript
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);