Skip to main content
Glama
index.ts12.5 kB
#!/usr/bin/env node import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js'; import { spawn } from 'child_process'; import { stat } from 'node:fs/promises'; import { isAbsolute } from 'node:path'; type SystemSound = { type: 'system'; name: string }; type TTSSound = { type: 'tts'; text: string; voice?: string }; type FileSound = { type: 'file'; path: string }; type PlaySoundOptions = SystemSound | TTSSound | FileSound; const ALLOWED_SYSTEM_SOUNDS = new Set([ 'Basso', 'Blow', 'Bottle', 'Frog', 'Funk', 'Glass', 'Hero', 'Morse', 'Ping', 'Pop', 'Purr', 'Sosumi', 'Submarine', 'Tink' ]); const ALLOWED_TTS_VOICES = new Set([ // Popular English voices 'Albert', 'Alice', 'Bad News', 'Bahh', 'Bells', 'Boing', 'Bruce', 'Bubbles', 'Cellos', 'Daniel', 'Deranged', 'Fred', 'Good News', 'Hysterical', 'Junior', 'Kathy', 'Pipe Organ', 'Princess', 'Ralph', 'Trinoids', 'Whisper', 'Zarvox', // International voices (commonly available) 'Anna', 'Amélie', 'Daria', 'Eddy', 'Fiona', 'Jorge', 'Juan', 'Luca', 'Marie', 'Moira', 'Nora', 'Rishi', 'Samantha', 'Serena', 'Tessa', 'Thomas', 'Veena', 'Victoria', 'Xander', 'Yelda', 'Zosia' ]); const MAX_TTS_TEXT_LENGTH = 1000; const PROCESS_TIMEOUT_MS = 10000; // 10 seconds // Request throttling const activeRequests = new Set<string>(); // File cache with eviction strategy interface CacheEntry { stats: any; timestamp: number; lastAccessed: number; } const fileStatCache = new Map<string, CacheEntry>(); const FILE_CACHE_TTL = 60000; // 1 minute const MAX_CACHE_SIZE = 100; // Maximum number of cached entries // Cache cleanup interval (runs every 5 minutes) const CACHE_CLEANUP_INTERVAL = 5 * 60 * 1000; function cleanupCache(): void { const now = Date.now(); const entries = Array.from(fileStatCache.entries()); // Remove expired entries let removed = 0; for (const [key, entry] of entries) { if (now - entry.timestamp > FILE_CACHE_TTL) { fileStatCache.delete(key); removed++; } } // If still over limit, remove LRU entries if (fileStatCache.size > MAX_CACHE_SIZE) { const sortedEntries = Array.from(fileStatCache.entries()) .sort((a, b) => a[1].lastAccessed - b[1].lastAccessed); const toRemove = fileStatCache.size - MAX_CACHE_SIZE; for (let i = 0; i < toRemove; i++) { fileStatCache.delete(sortedEntries[i][0]); removed++; } } } // Set up periodic cache cleanup setInterval(cleanupCache, CACHE_CLEANUP_INTERVAL); const server = new Server( { name: 'mcp-make-sound', version: '1.0.0', }, { capabilities: { tools: {}, }, } ); async function playSound(soundType: 'info' | 'warning' | 'error'): Promise<void> { const requestId = `${soundType}-${Date.now()}`; // Throttle requests to prevent conflicts if (activeRequests.has(soundType)) { throw new Error(`${soundType} sound already playing`); } activeRequests.add(soundType); try { return new Promise((resolve, reject) => { let soundName: string; switch (soundType) { case 'info': soundName = 'Glass'; break; case 'warning': soundName = 'Purr'; break; case 'error': soundName = 'Sosumi'; break; default: soundName = 'Glass'; } const afplay = spawn('afplay', [`/System/Library/Sounds/${soundName}.aiff`]); // Add timeout const timeout = setTimeout(() => { afplay.kill(); reject(new Error('Sound playback timed out')); }, PROCESS_TIMEOUT_MS); afplay.once('close', (code) => { clearTimeout(timeout); if (code === 0) { resolve(); } else { reject(new Error(`Sound playback failed with code ${code}`)); } }); afplay.once('error', (error) => { clearTimeout(timeout); reject(error); }); }); } finally { activeRequests.delete(soundType); } } async function getCachedFileStat(filePath: string): Promise<any> { const now = Date.now(); const cached = fileStatCache.get(filePath); if (cached && (now - cached.timestamp) < FILE_CACHE_TTL) { // Update last accessed time for LRU cached.lastAccessed = now; return cached.stats; } try { const stats = await stat(filePath); // Clean up cache if it's getting too large before adding new entry if (fileStatCache.size >= MAX_CACHE_SIZE) { cleanupCache(); } fileStatCache.set(filePath, { stats, timestamp: now, lastAccessed: now }); return stats; } catch (error) { fileStatCache.delete(filePath); throw error; } } async function playCustomSound(options: PlaySoundOptions): Promise<string> { const requestId = `${options.type}-${Date.now()}`; // Throttle same type requests if (activeRequests.has(options.type)) { throw new Error(`${options.type} sound already playing`); } activeRequests.add(options.type); try { let child: ReturnType<typeof spawn>; // Validate and spawn process switch (options.type) { case 'system': { const { name: soundName } = options; if (!ALLOWED_SYSTEM_SOUNDS.has(soundName)) { throw new Error(`Unsupported system sound: ${soundName}`); } child = spawn('afplay', [`/System/Library/Sounds/${soundName}.aiff`]); break; } case 'tts': { const { text, voice } = options; // Validate text length if (text.length > MAX_TTS_TEXT_LENGTH) { throw new Error(`Text too long (max ${MAX_TTS_TEXT_LENGTH} characters)`); } // Validate voice if provided, gracefully fall back to system default let finalVoice = voice; if (voice !== undefined && !ALLOWED_TTS_VOICES.has(voice)) { // Log warning but continue with system default console.warn(`Unsupported voice: ${voice}. Using system default voice.`); finalVoice = undefined; } const args = finalVoice ? ['-v', finalVoice, text] : [text]; child = spawn('say', args); break; } case 'file': { const { path: filePath } = options; if (!isAbsolute(filePath)) { throw new Error('File path must be absolute'); } try { const stats = await getCachedFileStat(filePath); if (!stats.isFile()) { throw new Error('Path must point to a file'); } } catch (error) { throw new Error(`File not found or inaccessible: ${filePath}`); } child = spawn('afplay', [filePath]); break; } default: { // TypeScript ensures this is unreachable, but keep for runtime safety const exhaustiveCheck: never = options; throw new Error(`Unknown sound type: ${(exhaustiveCheck as any).type}`); } } // Wrap child process lifecycle in Promise with timeout return new Promise((resolve, reject) => { const timeout = setTimeout(() => { child.kill(); reject(new Error('Sound playback timed out')); }, PROCESS_TIMEOUT_MS); child.once('close', (code: number) => { clearTimeout(timeout); if (code === 0) { resolve(`${options.type} sound played successfully`); } else { reject(new Error(`Sound playback failed with code ${code}`)); } }); child.once('error', (error) => { clearTimeout(timeout); reject(error); }); }); } finally { activeRequests.delete(options.type); } } server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: 'play_info_sound', description: 'Play an informational system sound', inputSchema: { type: 'object', properties: {}, required: [], }, }, { name: 'play_warning_sound', description: 'Play a warning system sound', inputSchema: { type: 'object', properties: {}, required: [], }, }, { name: 'play_error_sound', description: 'Play an error system sound', inputSchema: { type: 'object', properties: {}, required: [], }, }, { name: 'play_sound', description: 'Play various types of sounds with customizable parameters', inputSchema: { type: 'object', required: ['type'], properties: { type: { type: 'string', enum: ['system', 'tts', 'file'], description: 'Type of sound to play' }, name: { type: 'string', enum: ['Basso', 'Blow', 'Bottle', 'Frog', 'Funk', 'Glass', 'Hero', 'Morse', 'Ping', 'Pop', 'Purr', 'Sosumi', 'Submarine', 'Tink'], description: 'System sound name (required when type is "system")' }, text: { type: 'string', description: 'Text to speak (required when type is "tts")' }, voice: { type: 'string', description: 'Voice name (optional, used with type "tts", uses system default if not specified)' }, path: { type: 'string', description: 'Absolute path to audio file (required when type is "file")' } }, additionalProperties: false }, }, ], }; }); server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; try { switch (name) { case 'play_info_sound': await playSound('info'); return { content: [ { type: 'text', text: 'Info sound played successfully', }, ], }; case 'play_warning_sound': await playSound('warning'); return { content: [ { type: 'text', text: 'Warning sound played successfully', }, ], }; case 'play_error_sound': await playSound('error'); return { content: [ { type: 'text', text: 'Error sound played successfully', }, ], }; case 'play_sound': { // Validate args exists if (!args || typeof args !== 'object') { throw new Error('Invalid arguments provided'); } // Validate required parameters based on type const { type } = args as { type?: string }; if (!type || !['system', 'tts', 'file'].includes(type)) { throw new Error('Invalid or missing type. Must be "system", "tts", or "file"'); } if (type === 'system' && !(args as any).name) { throw new Error('Parameter "name" is required when type is "system"'); } if (type === 'tts' && !(args as any).text) { throw new Error('Parameter "text" is required when type is "tts"'); } if (type === 'file' && !(args as any).path) { throw new Error('Parameter "path" is required when type is "file"'); } const soundOptions = args as PlaySoundOptions; const result = await playCustomSound(soundOptions); return { content: [ { type: 'text', text: result, }, ], }; } default: throw new Error(`Unknown tool: ${name}`); } } catch (error) { return { content: [ { type: 'text', text: `Error playing sound: ${error instanceof Error ? error.message : String(error)}`, }, ], isError: true, }; } }); async function main() { const transport = new StdioServerTransport(); await server.connect(transport); console.error('MCP Sound Server running on stdio'); } main().catch((error) => { console.error('Server error:', error); process.exit(1); });

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/nocoo/mcp-make-sound'

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