Skip to main content
Glama
index.ts19.6 kB
import express, { Request, Response } from 'express'; // Types for API responses interface DownloadResponse { download_id: string; status: string; message: string; } interface StatusResponse { download_id: string; status: string; message: string; space_url: string; space_id?: string; r2_url?: string; error?: string; filename?: string; } interface TranscribeResponse { transcription_id: string; space_id: string; status: string; message: string; } interface TranscriptionStatusResponse { transcription_id: string; space_id: string; status: string; message: string; error?: string; } interface SpaceInfo { id: string; title: string; creator_name: string; creator_screen_name: string; start_date: string; has_audio: boolean; has_transcript: boolean; audio_size?: number; state?: string; } interface SpaceAvailabilityResponse { available: boolean; status?: string; error?: string; space_info?: any; } // Configuration interface interface Config { apiUrl: string; timeout?: number; } // JSON-RPC request/response interfaces interface JsonRpcRequest { jsonrpc: '2.0'; id: string | number | null; method: string; params?: any; } interface JsonRpcResponse { jsonrpc: '2.0'; id: string | number | null; result?: any; error?: { code: number; message: string; data?: any; }; } // MCP Server capabilities const SERVER_CAPABILITIES = { tools: {}, }; // Server info const SERVER_INFO = { name: 'twitter-spaces', version: '1.0.0', }; // Utility function to make API requests async function makeApiRequest( url: string, config: Config, options: RequestInit = {} ): Promise<any> { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), (config.timeout || 30) * 1000); try { const response = await fetch(url, { ...options, signal: controller.signal, headers: { 'Content-Type': 'application/json', ...options.headers, }, }); clearTimeout(timeoutId); if (!response.ok) { const errorText = await response.text(); throw new Error(`HTTP ${response.status}: ${errorText}`); } return await response.json(); } catch (error) { clearTimeout(timeoutId); if (error instanceof Error) { throw new Error(`API request failed: ${error.message}`); } throw error; } } // Utility function to poll for completion async function pollForCompletion( statusUrl: string, config: Config, completedStatuses: string[] = ['completed'], failedStatuses: string[] = ['failed'], maxAttempts: number = 60, intervalMs: number = 5000 ): Promise<any> { for (let attempt = 0; attempt < maxAttempts; attempt++) { const status = await makeApiRequest(statusUrl, config); if (completedStatuses.includes(status.status)) { return status; } if (failedStatuses.includes(status.status)) { throw new Error(`Operation failed: ${status.error || status.message}`); } // Wait before next poll await new Promise(resolve => setTimeout(resolve, intervalMs)); } throw new Error(`Operation timed out after ${maxAttempts} attempts`); } // Parse configuration from query parameters function parseConfig(query: any): Config { return { apiUrl: query.apiUrl || 'http://localhost:8000', timeout: query.timeout ? parseInt(query.timeout) : 30, }; } // Tool definitions (lazy loading - no config required) const TOOLS = [ { name: 'check_space_availability', description: 'Check if a Twitter Space is available for download', inputSchema: { type: 'object', properties: { space_url: { type: 'string', description: 'Full Twitter Space URL (e.g., https://x.com/i/spaces/1ZkKzYLnWOLxv)', }, }, required: ['space_url'], }, }, { name: 'download_twitter_space', description: 'Download a Twitter Space and wait for completion', inputSchema: { type: 'object', properties: { space_url: { type: 'string', description: 'Full Twitter Space URL (e.g., https://x.com/i/spaces/1ZkKzYLnWOLxv)', }, wait_for_completion: { type: 'boolean', description: 'Whether to wait for download completion before returning', default: true, }, }, required: ['space_url'], }, }, { name: 'transcribe_space', description: 'Transcribe a downloaded Twitter Space using AI', inputSchema: { type: 'object', properties: { space_id: { type: 'string', description: 'Space ID (e.g., 1ZkKzYLnWOLxv)', }, wait_for_completion: { type: 'boolean', description: 'Whether to wait for transcription completion before returning', default: true, }, }, required: ['space_id'], }, }, { name: 'get_transcript', description: 'Download transcript in various formats (json, txt, paragraphs, timecoded, summary)', inputSchema: { type: 'object', properties: { space_id: { type: 'string', description: 'Space ID (e.g., 1ZkKzYLnWOLxv)', }, format: { type: 'string', enum: ['json', 'txt', 'paragraphs', 'timecoded', 'summary'], description: 'Transcript format to download', default: 'paragraphs', }, }, required: ['space_id'], }, }, { name: 'list_spaces', description: 'List all downloaded Twitter Spaces', inputSchema: { type: 'object', properties: {}, }, }, { name: 'download_and_transcribe_space', description: 'Download a Twitter Space and automatically transcribe it', inputSchema: { type: 'object', properties: { space_url: { type: 'string', description: 'Full Twitter Space URL (e.g., https://x.com/i/spaces/1ZkKzYLnWOLxv)', }, }, required: ['space_url'], }, }, ]; // Handle ping method async function handlePing(): Promise<any> { return {}; // Simple pong response } // Handle initialize method async function handleInitialize(params: any): Promise<any> { return { protocolVersion: '2024-11-05', capabilities: SERVER_CAPABILITIES, serverInfo: SERVER_INFO, }; } // Handle tools/list method async function handleListTools(): Promise<any> { return { tools: TOOLS }; } // Handle tools/call method async function handleCallTool(params: any, config: Config): Promise<any> { const { name, arguments: args } = params; try { switch (name) { case 'check_space_availability': { const { space_url } = args as { space_url: string }; // Extract space ID from URL const spaceIdMatch = space_url.match(/\/spaces\/([a-zA-Z0-9]+)/); if (!spaceIdMatch) { throw new Error('Invalid space URL format'); } const spaceId = spaceIdMatch[1]; const url = `${config.apiUrl}/api/check-space/${spaceId}`; const result: SpaceAvailabilityResponse = await makeApiRequest(url, config); return { content: [{ type: 'text', text: `Space Availability Check for ${space_url}:\n\n` + `Available: ${result.available ? '✅ Yes' : '❌ No'}\n` + `Status: ${result.status || 'Unknown'}\n` + (result.error ? `Error: ${result.error}\n` : '') + (result.space_info ? `Title: ${result.space_info.title || 'Unknown'}\n` : '') + (result.space_info ? `Creator: ${result.space_info.creator_name || 'Unknown'}\n` : '') + (result.space_info ? `State: ${result.space_info.state || 'Unknown'}\n` : '') }] }; } case 'download_twitter_space': { const { space_url, wait_for_completion = true } = args as { space_url: string; wait_for_completion?: boolean }; // Start download const downloadUrl = `${config.apiUrl}/api/download`; const downloadResponse: DownloadResponse = await makeApiRequest(downloadUrl, config, { method: 'POST', body: JSON.stringify({ space_url }), }); let result = `Started download for ${space_url}\n`; result += `Download ID: ${downloadResponse.download_id}\n`; result += `Status: ${downloadResponse.status}\n`; result += `Message: ${downloadResponse.message}\n\n`; if (wait_for_completion) { result += 'Waiting for download to complete...\n\n'; const statusUrl = `${config.apiUrl}/api/status/${downloadResponse.download_id}`; const finalStatus: StatusResponse = await pollForCompletion(statusUrl, config); result += `✅ Download completed!\n`; result += `Space ID: ${finalStatus.space_id}\n`; result += `Filename: ${finalStatus.filename}\n`; if (finalStatus.r2_url) { result += `R2 URL: ${finalStatus.r2_url}\n`; } result += `Final Message: ${finalStatus.message}\n`; } return { content: [{ type: 'text', text: result }] }; } case 'transcribe_space': { const { space_id, wait_for_completion = true } = args as { space_id: string; wait_for_completion?: boolean }; // Start transcription const transcribeUrl = `${config.apiUrl}/api/transcribe`; const transcribeResponse: TranscribeResponse = await makeApiRequest(transcribeUrl, config, { method: 'POST', body: JSON.stringify({ space_id }), }); let result = `Started transcription for space ${space_id}\n`; result += `Transcription ID: ${transcribeResponse.transcription_id}\n`; result += `Status: ${transcribeResponse.status}\n`; result += `Message: ${transcribeResponse.message}\n\n`; if (wait_for_completion) { result += 'Waiting for transcription to complete...\n\n'; const statusUrl = `${config.apiUrl}/api/transcription/status/${transcribeResponse.transcription_id}`; const finalStatus: TranscriptionStatusResponse = await pollForCompletion( statusUrl, config, ['completed'], ['failed'], 120, // 120 attempts 10000 // 10 second intervals ); result += `✅ Transcription completed!\n`; result += `Final Message: ${finalStatus.message}\n`; result += `\nYou can now download the transcript in different formats using the 'get_transcript' tool.`; } return { content: [{ type: 'text', text: result }] }; } case 'get_transcript': { const { space_id, format = 'paragraphs' } = args as { space_id: string; format?: string }; const transcriptUrl = `${config.apiUrl}/api/transcript/${space_id}/download/${format}`; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), (config.timeout || 30) * 1000); const response = await fetch(transcriptUrl, { signal: controller.signal, }); clearTimeout(timeoutId); if (!response.ok) { const errorText = await response.text(); throw new Error(`HTTP ${response.status}: ${errorText}`); } const transcript = await response.text(); return { content: [{ type: 'text', text: `Transcript for space ${space_id} (${format} format):\n\n${transcript}` }] }; } case 'list_spaces': { const spacesUrl = `${config.apiUrl}/api/spaces`; const response = await makeApiRequest(spacesUrl, config); if (!response.r2_configured) { return { content: [{ type: 'text', text: 'R2 storage is not configured. No spaces available.' }] }; } const spaces: SpaceInfo[] = response.spaces || []; if (spaces.length === 0) { return { content: [{ type: 'text', text: 'No spaces found. Download some Twitter Spaces to get started!' }] }; } let result = `Found ${spaces.length} spaces:\n\n`; spaces.forEach((space, index) => { result += `${index + 1}. ${space.title || 'Untitled Space'}\n`; result += ` ID: ${space.id}\n`; result += ` Creator: ${space.creator_name || 'Unknown'} (@${space.creator_screen_name || 'unknown'})\n`; result += ` Date: ${space.start_date || 'Unknown'}\n`; result += ` Audio: ${space.has_audio ? '✅ Available' : '❌ Missing'}\n`; result += ` Transcript: ${space.has_transcript ? '✅ Available' : '❌ Not transcribed'}\n`; if (space.audio_size) { result += ` Size: ${(space.audio_size / 1024 / 1024).toFixed(2)} MB\n`; } result += ` State: ${space.state || 'Unknown'}\n\n`; }); return { content: [{ type: 'text', text: result }] }; } case 'download_and_transcribe_space': { const { space_url } = args as { space_url: string }; let result = `Starting complete process for ${space_url}\n\n`; // Step 1: Check availability const spaceIdMatch = space_url.match(/\/spaces\/([a-zA-Z0-9]+)/); if (!spaceIdMatch) { throw new Error('Invalid space URL format'); } const spaceId = spaceIdMatch[1]; result += '1. Checking space availability...\n'; const availabilityUrl = `${config.apiUrl}/api/check-space/${spaceId}`; const availability: SpaceAvailabilityResponse = await makeApiRequest(availabilityUrl, config); if (!availability.available) { throw new Error(`Space not available: ${availability.error}`); } result += ' ✅ Space is available\n\n'; // Step 2: Download result += '2. Starting download...\n'; const downloadUrl = `${config.apiUrl}/api/download`; const downloadResponse: DownloadResponse = await makeApiRequest(downloadUrl, config, { method: 'POST', body: JSON.stringify({ space_url }), }); result += ` Download ID: ${downloadResponse.download_id}\n`; const statusUrl = `${config.apiUrl}/api/status/${downloadResponse.download_id}`; const downloadStatus: StatusResponse = await pollForCompletion(statusUrl, config); result += ' ✅ Download completed\n'; result += ` Filename: ${downloadStatus.filename}\n\n`; // Step 3: Transcribe result += '3. Starting transcription...\n'; const transcribeUrl = `${config.apiUrl}/api/transcribe`; const transcribeResponse: TranscribeResponse = await makeApiRequest(transcribeUrl, config, { method: 'POST', body: JSON.stringify({ space_id: downloadStatus.space_id }), }); result += ` Transcription ID: ${transcribeResponse.transcription_id}\n`; const transcribeStatusUrl = `${config.apiUrl}/api/transcription/status/${transcribeResponse.transcription_id}`; const transcribeStatus: TranscriptionStatusResponse = await pollForCompletion( transcribeStatusUrl, config, ['completed'], ['failed'], 120, 10000 ); result += ' ✅ Transcription completed\n\n'; result += `🎉 Complete! Space ${downloadStatus.space_id} has been downloaded and transcribed.\n`; result += `Use the 'get_transcript' tool with space_id="${downloadStatus.space_id}" to view the transcript.`; return { content: [{ type: 'text', text: result }] }; } default: throw new Error(`Unknown tool: ${name}`); } } catch (error) { return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}` }] }; } } // Handle MCP JSON-RPC requests async function handleMcpRequest(request: JsonRpcRequest, config: Config): Promise<JsonRpcResponse> { try { let result: any; switch (request.method) { case 'ping': result = await handlePing(); break; case 'initialize': result = await handleInitialize(request.params); break; case 'tools/list': result = await handleListTools(); break; case 'tools/call': result = await handleCallTool(request.params, config); break; default: return { jsonrpc: '2.0', id: request.id, error: { code: -32601, message: `Method not found: ${request.method}` } }; } return { jsonrpc: '2.0', id: request.id, result }; } catch (error) { return { jsonrpc: '2.0', id: request.id, error: { code: -32603, message: error instanceof Error ? error.message : 'Internal error' } }; } } // Create Express app for Streamable HTTP const app = express(); app.use(express.json()); app.all('/mcp', async (req: Request, res: Response) => { try { // Parse configuration from query parameters const config = parseConfig(req.query); // Handle the MCP request if (req.method === 'GET') { // Return server info for discovery (lazy loading) res.json({ ...SERVER_INFO, description: 'Download and transcribe Twitter Spaces using AI', capabilities: SERVER_CAPABILITIES, tools: TOOLS // Return tools without requiring config }); } else if (req.method === 'POST') { // Handle MCP JSON-RPC protocol messages const response = await handleMcpRequest(req.body, config); res.json(response); } else if (req.method === 'DELETE') { // Handle DELETE for cleanup (optional) res.json({ message: 'Session ended' }); } else { res.status(405).json({ error: 'Method not allowed' }); } } catch (error) { console.error('Error handling MCP request:', error); res.status(500).json({ error: error instanceof Error ? error.message : 'Internal server error' }); } }); // Health check endpoint app.get('/health', (req: Request, res: Response) => { res.json({ status: 'healthy', timestamp: new Date().toISOString() }); }); // Root endpoint for basic info app.get('/', (req: Request, res: Response) => { res.json({ ...SERVER_INFO, description: 'Twitter Spaces MCP Server - Download and transcribe Twitter Spaces using AI', endpoints: { health: '/health', mcp: '/mcp' } }); }); // Start server const PORT = process.env.PORT || 8000; app.listen(PORT, () => { console.log(`Twitter Spaces MCP server running on port ${PORT}`); });

Latest Blog Posts

MCP directory API

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

curl -X GET 'https://glama.ai/api/mcp/v1/servers/afarhadi99/spaces-download-mcp'

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