zoom-recordings-server
Version:
MCP server for downloading Zoom Cloud Recordings (MP4 files only)
750 lines ⢠36.5 kB
JavaScript
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 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);
// Zoom API Client with User OAuth
class ZoomUserClient {
constructor(clientId, clientSecret, tokenFile) {
this.clientId = clientId;
this.clientSecret = clientSecret;
this.tokenFile = tokenFile;
this.tokens = null;
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);
});
}
isTokenExpired() {
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() {
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)}`);
}
}
async exchangeAuthorizationCode(authCode) {
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)}`);
}
}
async refreshAccessToken() {
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)}`);
}
}
async saveTokens() {
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 = {}) {
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, outputPath) {
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, recordingFileId) {
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) => 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;
if (meetingDetails.recording_files && meetingDetails.recording_files.length > 0) {
transcriptFile = meetingDetails.recording_files.find((file) => 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; // 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(baseDir) {
this.baseDir = baseDir;
fs.ensureDirSync(this.baseDir);
}
getMonthlyDir(date) {
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, fileType = 'mp4') {
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, fileType = 'mp4') {
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 {
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);
});
}
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,
};
}
});
}
async handleListRecordings(args) {
const dateRange = args?.dateRange;
const pageSize = args?.pageSize || 30;
try {
// Prepare parameters
const params = {
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) => {
return meeting.recording_files?.some((file) => 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) => {
const startTime = new Date(meeting.start_time).toLocaleString();
const duration = `${meeting.duration} minutes`;
// Get MP4 file info
const mp4Files = meeting.recording_files?.filter((file) => 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,
};
}
}
async handleGetTranscript(args) {
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,
},
],
};
}
async handleDownloadRecording(args) {
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) => 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) => 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 = [];
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);
//# sourceMappingURL=index.js.map