@mindstone/mcp-server-elevenlabs 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,134 @@
1
+ import { z } from 'zod';
2
+ import { getApiKey } from '../auth.js';
3
+ import { elevenLabsJson, elevenLabsAudio } from '../client.js';
4
+ import { ElevenLabsError } from '../types.js';
5
+ import { withErrorHandling } from '../utils.js';
6
+ const OUTPUT_FORMAT_ENUM = z.enum([
7
+ 'mp3_44100_128', 'mp3_44100_192',
8
+ 'pcm_16000', 'pcm_22050', 'pcm_24000', 'pcm_44100',
9
+ 'ulaw_8000',
10
+ ]).optional();
11
+ export function registerMusicTools(server) {
12
+ // ── generate_music ────────────────────────────────────────────────────
13
+ server.registerTool('generate_music', {
14
+ description: 'Generate music from a text prompt using ElevenLabs Music API. ' +
15
+ 'Returns a saved audio file path. DURATION: 3-600 seconds (default 30). ' +
16
+ 'COST: Consumes credits based on duration. ' +
17
+ 'PROMPT TIPS: Describe genre, mood, instruments, style, lyrics.',
18
+ inputSchema: z.object({
19
+ prompt: z.string().min(1).describe('Describe the music: genre, mood, instruments, style, lyrics.'),
20
+ duration_seconds: z.number().min(3).max(600).optional().describe('Duration in seconds (3-600). Default: 30.'),
21
+ force_instrumental: z.boolean().optional().describe('Force instrumental-only output (no vocals). Default: false.'),
22
+ output_format: OUTPUT_FORMAT_ENUM.describe('Audio output format. Default: mp3_44100_128.'),
23
+ seed: z.number().int().optional().describe('Random seed for reproducibility.'),
24
+ }),
25
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
26
+ }, withErrorHandling(async (args) => {
27
+ const apiKey = getApiKey();
28
+ if (!apiKey) {
29
+ throw new ElevenLabsError('ElevenLabs API key not configured', 'AUTH_REQUIRED', 'Ask the user for their API key, then call configure_elevenlabs_api_key.');
30
+ }
31
+ const durationSeconds = args.duration_seconds ?? 30;
32
+ const durationMs = Math.max(3000, Math.min(600000, durationSeconds * 1000));
33
+ const outputFormat = args.output_format ?? 'mp3_44100_128';
34
+ const body = {
35
+ prompt: args.prompt,
36
+ music_length_ms: durationMs,
37
+ model_id: 'music_v1',
38
+ };
39
+ if (args.force_instrumental !== undefined)
40
+ body.force_instrumental = args.force_instrumental;
41
+ if (args.seed !== undefined)
42
+ body.seed = args.seed;
43
+ const ext = outputFormat.startsWith('mp3') ? 'mp3' : 'wav';
44
+ const result = await elevenLabsAudio(apiKey, `/music?output_format=${outputFormat}`, { method: 'POST', body: JSON.stringify(body) }, ext);
45
+ return JSON.stringify({
46
+ ok: true,
47
+ file_path: result.filePath,
48
+ size_bytes: result.sizeBytes,
49
+ duration_seconds: durationSeconds,
50
+ format: outputFormat,
51
+ message: `Music generated and saved to ${result.filePath} (${(result.sizeBytes / 1024).toFixed(1)} KB, ${durationSeconds}s).`,
52
+ });
53
+ }));
54
+ // ── create_music_plan ─────────────────────────────────────────────────
55
+ server.registerTool('create_music_plan', {
56
+ description: 'Create a composition plan for music generation — FREE, no credits consumed. ' +
57
+ 'Returns a structured plan with sections, styles, and lyrics. ' +
58
+ 'Review the plan and pass it to generate_music_from_plan when ready.',
59
+ inputSchema: z.object({
60
+ prompt: z.string().min(1).describe('Describe the music you want.'),
61
+ duration_seconds: z.number().min(3).max(600).optional().describe('Target duration in seconds (3-600). Default: 30.'),
62
+ }),
63
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
64
+ }, withErrorHandling(async (args) => {
65
+ const apiKey = getApiKey();
66
+ if (!apiKey) {
67
+ throw new ElevenLabsError('ElevenLabs API key not configured', 'AUTH_REQUIRED', 'Ask the user for their API key, then call configure_elevenlabs_api_key.');
68
+ }
69
+ const durationSeconds = args.duration_seconds ?? 30;
70
+ const durationMs = Math.max(3000, Math.min(600000, durationSeconds * 1000));
71
+ const plan = await elevenLabsJson(apiKey, '/music/plan', {
72
+ method: 'POST',
73
+ body: JSON.stringify({
74
+ prompt: args.prompt,
75
+ music_length_ms: durationMs,
76
+ model_id: 'music_v1',
77
+ }),
78
+ });
79
+ const totalDurationMs = plan.sections.reduce((sum, s) => sum + (s.duration_ms || 0), 0);
80
+ return JSON.stringify({
81
+ ok: true,
82
+ composition_plan: plan,
83
+ total_duration_seconds: totalDurationMs / 1000,
84
+ num_sections: plan.sections.length,
85
+ cost: 'FREE — no credits consumed',
86
+ message: `Composition plan created with ${plan.sections.length} sections (${(totalDurationMs / 1000).toFixed(1)}s total). Review the plan and pass it to generate_music_from_plan when ready.`,
87
+ hint: 'You can modify positive_global_styles, negative_global_styles, section styles, lyrics, and durations before generating.',
88
+ });
89
+ }));
90
+ // ── generate_music_from_plan ──────────────────────────────────────────
91
+ server.registerTool('generate_music_from_plan', {
92
+ description: 'Generate music from a composition plan (created by create_music_plan or manually crafted). ' +
93
+ 'The plan must have at least one section. COST: Consumes credits based on duration.',
94
+ inputSchema: z.object({
95
+ composition_plan: z.object({
96
+ positive_global_styles: z.array(z.string()).optional().describe('Styles to apply globally.'),
97
+ negative_global_styles: z.array(z.string()).optional().describe('Styles to avoid globally.'),
98
+ sections: z.array(z.object({
99
+ style: z.string().optional(),
100
+ lyrics: z.string().optional(),
101
+ duration_ms: z.number().optional(),
102
+ })).min(1).describe('Array of sections, each with style, lyrics, and duration_ms.'),
103
+ }).describe('The composition plan object.'),
104
+ seed: z.number().int().optional().describe('Random seed for reproducibility.'),
105
+ output_format: OUTPUT_FORMAT_ENUM.describe('Audio output format. Default: mp3_44100_128.'),
106
+ }),
107
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
108
+ }, withErrorHandling(async (args) => {
109
+ const apiKey = getApiKey();
110
+ if (!apiKey) {
111
+ throw new ElevenLabsError('ElevenLabs API key not configured', 'AUTH_REQUIRED', 'Ask the user for their API key, then call configure_elevenlabs_api_key.');
112
+ }
113
+ const compositionPlan = args.composition_plan;
114
+ const outputFormat = args.output_format ?? 'mp3_44100_128';
115
+ const body = {
116
+ composition_plan: compositionPlan,
117
+ model_id: 'music_v1',
118
+ };
119
+ if (args.seed !== undefined)
120
+ body.seed = args.seed;
121
+ const ext = outputFormat.startsWith('mp3') ? 'mp3' : 'wav';
122
+ const result = await elevenLabsAudio(apiKey, `/music?output_format=${outputFormat}`, { method: 'POST', body: JSON.stringify(body) }, ext);
123
+ const totalDurationMs = (compositionPlan.sections ?? []).reduce((sum, s) => sum + (s.duration_ms || 0), 0);
124
+ return JSON.stringify({
125
+ ok: true,
126
+ file_path: result.filePath,
127
+ size_bytes: result.sizeBytes,
128
+ duration_seconds: totalDurationMs / 1000,
129
+ format: outputFormat,
130
+ message: `Music generated from plan and saved to ${result.filePath} (${(result.sizeBytes / 1024).toFixed(1)} KB).`,
131
+ });
132
+ }));
133
+ }
134
+ //# sourceMappingURL=music.js.map
@@ -0,0 +1,51 @@
1
+ export type ResolveResult = {
2
+ ok: true;
3
+ path: string;
4
+ } | {
5
+ ok: false;
6
+ error: string;
7
+ };
8
+ /**
9
+ * Detect URL-shaped inputs so the caller can bypass the local-path sandbox.
10
+ * `transcribe_audio` does not currently accept URL inputs (it reads bytes
11
+ * from disk), but a URL string supplied by the LLM must NOT surface as a
12
+ * sandbox-violation — it should fall through to the existing not-found
13
+ * code path so the error remains stable.
14
+ */
15
+ export declare function isRemoteUrl(input: string): boolean;
16
+ /**
17
+ * Derive the canonical workspace root for READING audio files supplied to
18
+ * `transcribe_audio`.
19
+ *
20
+ * - `MCP_WORKSPACE_PATH` if set (and non-empty), else `os.tmpdir()`.
21
+ * - The root is canonicalised through `fs.realpathSync` so the prefix
22
+ * check in `resolveAudioPath` is stable on platforms where the system
23
+ * tmpdir itself is reached through a symlink (e.g. macOS:
24
+ * /var/folders/... -> /private/var/folders/..., or /tmp -> /private/tmp).
25
+ * - If `realpathSync` fails (e.g. user pointed `MCP_WORKSPACE_PATH` at a
26
+ * non-existent dir), we fall back to the lexically-resolved path so the
27
+ * subsequent containment check still produces a clean refusal rather
28
+ * than crashing.
29
+ */
30
+ export declare function getAudioWorkspaceRoot(): string;
31
+ /**
32
+ * Validate that an LLM-supplied `file_path` argument resolves to a real
33
+ * file under the audio workspace sandbox root, even after symlink
34
+ * resolution. Returns the canonicalised path on success.
35
+ *
36
+ * Security rules:
37
+ * - Tilde (`~`) is expanded lexically; the expanded path must still be
38
+ * inside the workspace root.
39
+ * - Lexical `path.resolve` collapses `..` segments; a path that resolves
40
+ * outside the root is rejected before any disk read.
41
+ * - Existing files have their canonical path computed via
42
+ * `fs.realpathSync` so a symlink inside the root pointing OUTSIDE the
43
+ * root is refused.
44
+ * - Remote URL inputs (https://, http://) MUST be detected and bypassed
45
+ * by the caller via `isRemoteUrl` BEFORE invoking this helper.
46
+ *
47
+ * On rejection, the error string includes both the substring "workspace"
48
+ * and "sandbox" so validators / log scanners can match either keyword.
49
+ */
50
+ export declare function resolveAudioPath(filePath: string): ResolveResult;
51
+ //# sourceMappingURL=path-safety.d.ts.map
@@ -0,0 +1,135 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import * as os from 'os';
4
+ /**
5
+ * Detect URL-shaped inputs so the caller can bypass the local-path sandbox.
6
+ * `transcribe_audio` does not currently accept URL inputs (it reads bytes
7
+ * from disk), but a URL string supplied by the LLM must NOT surface as a
8
+ * sandbox-violation — it should fall through to the existing not-found
9
+ * code path so the error remains stable.
10
+ */
11
+ export function isRemoteUrl(input) {
12
+ return /^https?:\/\//i.test(input);
13
+ }
14
+ /**
15
+ * Derive the canonical workspace root for READING audio files supplied to
16
+ * `transcribe_audio`.
17
+ *
18
+ * - `MCP_WORKSPACE_PATH` if set (and non-empty), else `os.tmpdir()`.
19
+ * - The root is canonicalised through `fs.realpathSync` so the prefix
20
+ * check in `resolveAudioPath` is stable on platforms where the system
21
+ * tmpdir itself is reached through a symlink (e.g. macOS:
22
+ * /var/folders/... -> /private/var/folders/..., or /tmp -> /private/tmp).
23
+ * - If `realpathSync` fails (e.g. user pointed `MCP_WORKSPACE_PATH` at a
24
+ * non-existent dir), we fall back to the lexically-resolved path so the
25
+ * subsequent containment check still produces a clean refusal rather
26
+ * than crashing.
27
+ */
28
+ export function getAudioWorkspaceRoot() {
29
+ const envRoot = process.env.MCP_WORKSPACE_PATH;
30
+ const raw = envRoot && envRoot.trim() ? envRoot.trim() : os.tmpdir();
31
+ const lexical = path.resolve(raw);
32
+ try {
33
+ return fs.realpathSync(lexical);
34
+ }
35
+ catch {
36
+ return lexical;
37
+ }
38
+ }
39
+ /**
40
+ * Canonicalise the deepest existing ancestor of an absolute path and
41
+ * re-append the missing tail (M3-fix-C). This lets the lexical-prefix
42
+ * containment check accept in-workspace files supplied via a symlinked
43
+ * alias of the workspace root (e.g. `/tmp` → `/private/tmp` on macOS)
44
+ * while still rejecting `..` traversal and out-of-root absolutes
45
+ * deterministically WITHOUT requiring the leaf file to exist. Critical
46
+ * for the ENOENT branch: the in-workspace-but-missing case must classify
47
+ * as FILE_NOT_FOUND, not as a sandbox-deny.
48
+ */
49
+ function canonicalisePrefix(absoluteLexical) {
50
+ const tail = [];
51
+ let cur = absoluteLexical;
52
+ while (true) {
53
+ try {
54
+ const real = fs.realpathSync(cur);
55
+ return tail.length === 0 ? real : path.join(real, ...tail.reverse());
56
+ }
57
+ catch (err) {
58
+ const code = err.code;
59
+ if (code !== 'ENOENT' && code !== 'ENOTDIR') {
60
+ throw err;
61
+ }
62
+ }
63
+ const parent = path.dirname(cur);
64
+ if (parent === cur) {
65
+ return absoluteLexical;
66
+ }
67
+ tail.push(path.basename(cur));
68
+ cur = parent;
69
+ }
70
+ }
71
+ /**
72
+ * Validate that an LLM-supplied `file_path` argument resolves to a real
73
+ * file under the audio workspace sandbox root, even after symlink
74
+ * resolution. Returns the canonicalised path on success.
75
+ *
76
+ * Security rules:
77
+ * - Tilde (`~`) is expanded lexically; the expanded path must still be
78
+ * inside the workspace root.
79
+ * - Lexical `path.resolve` collapses `..` segments; a path that resolves
80
+ * outside the root is rejected before any disk read.
81
+ * - Existing files have their canonical path computed via
82
+ * `fs.realpathSync` so a symlink inside the root pointing OUTSIDE the
83
+ * root is refused.
84
+ * - Remote URL inputs (https://, http://) MUST be detected and bypassed
85
+ * by the caller via `isRemoteUrl` BEFORE invoking this helper.
86
+ *
87
+ * On rejection, the error string includes both the substring "workspace"
88
+ * and "sandbox" so validators / log scanners can match either keyword.
89
+ */
90
+ export function resolveAudioPath(filePath) {
91
+ const root = getAudioWorkspaceRoot();
92
+ const denyMessage = `file_path is outside the workspace sandbox root (${root}). Got: ${filePath}`;
93
+ const isInsideRoot = (p) => p === root || p.startsWith(root + path.sep);
94
+ // Step 1: lexical normalisation — expand `~`, collapse `..`, absolutise.
95
+ const expanded = filePath.startsWith('~')
96
+ ? path.join(os.homedir(), filePath.slice(1))
97
+ : filePath;
98
+ const lexical = path.resolve(expanded);
99
+ // Step 2: canonicalise the deepest existing ancestor (M3-fix-C) and
100
+ // run the prefix check against the canonical candidate. This is what
101
+ // makes `/tmp/foo.mp3` work when the workspace root is the symlinked
102
+ // alias `/tmp` and the canonical root is `/private/tmp`. Doing this
103
+ // BEFORE the realpath of the leaf is crucial for the ENOENT branch:
104
+ // an in-workspace-but-missing file must classify as FILE_NOT_FOUND,
105
+ // not as a sandbox-deny. Conversely, `..` traversal and out-of-root
106
+ // absolutes still fail this check without requiring the leaf to
107
+ // exist.
108
+ const canonicalCandidate = canonicalisePrefix(lexical);
109
+ if (!isInsideRoot(canonicalCandidate)) {
110
+ return { ok: false, error: denyMessage };
111
+ }
112
+ // Step 3: full realpath on the input. This catches the (rare) case
113
+ // where the leaf itself is a symlink inside the root pointing OUTSIDE
114
+ // the root. If the file does not exist, surface FILE_NOT_FOUND now
115
+ // that we know the candidate is in-workspace.
116
+ let canonical;
117
+ try {
118
+ canonical = fs.realpathSync(lexical);
119
+ }
120
+ catch (err) {
121
+ if (err.code === 'ENOENT') {
122
+ return { ok: false, error: `File not found: ${filePath}` };
123
+ }
124
+ throw err;
125
+ }
126
+ if (!isInsideRoot(canonical)) {
127
+ return {
128
+ ok: false,
129
+ error: `file_path resolves outside the workspace sandbox root (${root}); ` +
130
+ `symlinks may not escape the workspace. Got: ${filePath}`,
131
+ };
132
+ }
133
+ return { ok: true, path: canonical };
134
+ }
135
+ //# sourceMappingURL=path-safety.js.map
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function registerSpeechTools(server: McpServer): void;
3
+ //# sourceMappingURL=speech.d.ts.map
@@ -0,0 +1,126 @@
1
+ import { z } from 'zod';
2
+ import { getApiKey } from '../auth.js';
3
+ import { elevenLabsJson, elevenLabsAudio } from '../client.js';
4
+ import { ElevenLabsError } from '../types.js';
5
+ import { withErrorHandling } from '../utils.js';
6
+ /**
7
+ * Look up a voice by name via the ElevenLabs v2 voices API.
8
+ */
9
+ async function lookupVoiceByName(apiKey, name) {
10
+ const data = await elevenLabsJson(apiKey, `https://api.elevenlabs.io/v2/voices?search=${encodeURIComponent(name)}&page_size=5`);
11
+ if (!data.voices || data.voices.length === 0) {
12
+ throw new ElevenLabsError(`No voice found matching "${name}"`, 'VOICE_NOT_FOUND', `No voice matched "${name}". Use list_voices to browse available voices and get the exact voice_id.`);
13
+ }
14
+ return data.voices[0];
15
+ }
16
+ export function registerSpeechTools(server) {
17
+ // ── generate_speech ───────────────────────────────────────────────────
18
+ server.registerTool('generate_speech', {
19
+ description: 'Generate spoken audio from text using ElevenLabs text-to-speech. ' +
20
+ 'Use voice_id (direct) or voice_name (fuzzy search). ' +
21
+ 'Models: eleven_multilingual_v2 (default, 29 languages), eleven_monolingual_v1 (English), eleven_turbo_v2_5 (low latency). ' +
22
+ 'COST: ~1 credit per 100 characters.',
23
+ inputSchema: z.object({
24
+ text: z.string().min(1).describe('Text to speak. Maximum ~5000 characters per request.'),
25
+ voice_id: z.string().optional().describe('Direct voice ID. Takes priority over voice_name.'),
26
+ voice_name: z.string().optional().describe('Voice name for fuzzy search (e.g., "Rachel", "Adam").'),
27
+ model_id: z.enum(['eleven_multilingual_v2', 'eleven_monolingual_v1', 'eleven_turbo_v2_5']).optional()
28
+ .describe('TTS model. Default: eleven_multilingual_v2.'),
29
+ stability: z.number().min(0).max(1).optional().describe('Voice stability 0-1. Default: 0.5.'),
30
+ similarity_boost: z.number().min(0).max(1).optional().describe('Voice similarity 0-1. Default: 0.75.'),
31
+ output_format: z.enum(['mp3_44100_128', 'mp3_44100_192', 'pcm_16000', 'pcm_22050', 'pcm_24000', 'pcm_44100']).optional()
32
+ .describe('Audio output format. Default: mp3_44100_128.'),
33
+ }),
34
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
35
+ }, withErrorHandling(async (args) => {
36
+ const apiKey = getApiKey();
37
+ if (!apiKey) {
38
+ throw new ElevenLabsError('ElevenLabs API key not configured', 'AUTH_REQUIRED', 'Ask the user for their API key, then call configure_elevenlabs_api_key.');
39
+ }
40
+ let voiceId = args.voice_id;
41
+ const voiceName = args.voice_name;
42
+ const modelId = args.model_id ?? 'eleven_multilingual_v2';
43
+ const stability = args.stability ?? 0.5;
44
+ const similarityBoost = args.similarity_boost ?? 0.75;
45
+ const outputFormat = args.output_format ?? 'mp3_44100_128';
46
+ // Voice lookup
47
+ let resolvedVoiceName = voiceName || 'default';
48
+ if (!voiceId) {
49
+ if (voiceName) {
50
+ const voice = await lookupVoiceByName(apiKey, voiceName);
51
+ voiceId = voice.voice_id;
52
+ resolvedVoiceName = voice.name;
53
+ }
54
+ else {
55
+ // Default to Rachel if no voice specified
56
+ try {
57
+ const voice = await lookupVoiceByName(apiKey, 'Rachel');
58
+ voiceId = voice.voice_id;
59
+ resolvedVoiceName = voice.name;
60
+ }
61
+ catch (err) {
62
+ // Propagate the error so withErrorHandling emits isError: true
63
+ if (err instanceof ElevenLabsError) {
64
+ throw err;
65
+ }
66
+ throw new ElevenLabsError('No voice specified and default voice lookup failed.', 'VOICE_NOT_FOUND', 'Provide a voice_id or voice_name. Use list_voices to find available voices.');
67
+ }
68
+ }
69
+ }
70
+ const body = {
71
+ text: args.text,
72
+ model_id: modelId,
73
+ voice_settings: {
74
+ stability,
75
+ similarity_boost: similarityBoost,
76
+ },
77
+ };
78
+ const ext = outputFormat.startsWith('mp3') ? 'mp3' : 'wav';
79
+ const result = await elevenLabsAudio(apiKey, `/text-to-speech/${voiceId}?output_format=${outputFormat}`, { method: 'POST', body: JSON.stringify(body) }, ext);
80
+ return JSON.stringify({
81
+ ok: true,
82
+ file_path: result.filePath,
83
+ size_bytes: result.sizeBytes,
84
+ voice: resolvedVoiceName,
85
+ voice_id: voiceId,
86
+ model: modelId,
87
+ format: outputFormat,
88
+ message: `Speech generated with voice "${resolvedVoiceName}" and saved to ${result.filePath} (${(result.sizeBytes / 1024).toFixed(1)} KB).`,
89
+ });
90
+ }));
91
+ // ── generate_sound_effect ─────────────────────────────────────────────
92
+ server.registerTool('generate_sound_effect', {
93
+ description: 'Generate sound effects from a text description. ' +
94
+ 'DURATION: 0.5-22 seconds (auto if omitted). ' +
95
+ 'PROMPT INFLUENCE (0-1): How closely to follow the text prompt. Default: 0.3. ' +
96
+ 'COST: Credits based on duration.',
97
+ inputSchema: z.object({
98
+ prompt: z.string().min(1).describe('Describe the sound effect. Be specific about characteristics.'),
99
+ duration_seconds: z.number().min(0.5).max(22).optional().describe('Duration in seconds (0.5-22). Auto if omitted.'),
100
+ prompt_influence: z.number().min(0).max(1).optional().describe('How closely to follow the prompt (0-1). Default: 0.3.'),
101
+ }),
102
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
103
+ }, withErrorHandling(async (args) => {
104
+ const apiKey = getApiKey();
105
+ if (!apiKey) {
106
+ throw new ElevenLabsError('ElevenLabs API key not configured', 'AUTH_REQUIRED', 'Ask the user for their API key, then call configure_elevenlabs_api_key.');
107
+ }
108
+ const promptInfluence = args.prompt_influence ?? 0.3;
109
+ const body = {
110
+ text: args.prompt,
111
+ prompt_influence: promptInfluence,
112
+ };
113
+ if (args.duration_seconds !== undefined) {
114
+ body.duration_seconds = Math.max(0.5, Math.min(22, args.duration_seconds));
115
+ }
116
+ const result = await elevenLabsAudio(apiKey, '/sound-generation', { method: 'POST', body: JSON.stringify(body) }, 'mp3');
117
+ return JSON.stringify({
118
+ ok: true,
119
+ file_path: result.filePath,
120
+ size_bytes: result.sizeBytes,
121
+ duration_seconds: args.duration_seconds ?? 'auto',
122
+ message: `Sound effect generated and saved to ${result.filePath} (${(result.sizeBytes / 1024).toFixed(1)} KB).`,
123
+ });
124
+ }));
125
+ }
126
+ //# sourceMappingURL=speech.js.map
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function registerTranscriptionTools(server: McpServer): void;
3
+ //# sourceMappingURL=transcription.d.ts.map
@@ -0,0 +1,94 @@
1
+ import { z } from 'zod';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import { getApiKey } from '../auth.js';
5
+ import { elevenLabsJson } from '../client.js';
6
+ import { ElevenLabsError } from '../types.js';
7
+ import { withErrorHandling } from '../utils.js';
8
+ import { isRemoteUrl, resolveAudioPath } from './path-safety.js';
9
+ export function registerTranscriptionTools(server) {
10
+ server.registerTool('transcribe_audio', {
11
+ description: 'Transcribe speech from an audio file to text using ElevenLabs Speech-to-Text. ' +
12
+ 'INPUT: Local audio file path (.mp3, .wav, .m4a, .ogg, .flac, .webm, .mp4). ' +
13
+ 'Auto-detects language by default. Specify language_code for better accuracy. ' +
14
+ 'COST: Credits based on audio duration.',
15
+ inputSchema: z.object({
16
+ file_path: z.string().min(1).describe('Absolute path to local audio file to transcribe.'),
17
+ language_code: z.string().optional().describe('Language code (e.g., "en", "es", "fr"). Auto-detected if omitted.'),
18
+ }),
19
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
20
+ }, withErrorHandling(async (args) => {
21
+ const apiKey = getApiKey();
22
+ if (!apiKey) {
23
+ throw new ElevenLabsError('ElevenLabs API key not configured', 'AUTH_REQUIRED', 'Ask the user for their API key, then call configure_elevenlabs_api_key.');
24
+ }
25
+ const rawFilePath = args.file_path;
26
+ // ----------------------------------------------------------------
27
+ // SECURITY (M3.9): sandbox local audio reads to under
28
+ // `MCP_WORKSPACE_PATH` (or `os.tmpdir()` when unset). Without this,
29
+ // an LLM-supplied `file_path` could exfiltrate arbitrary host bytes
30
+ // (e.g. ~/.ssh/id_rsa, /etc/passwd) via the multipart upload to
31
+ // ElevenLabs Speech-to-Text.
32
+ //
33
+ // - Remote URL inputs bypass the sandbox: `transcribe_audio`
34
+ // currently only handles local-file inputs, so a URL string falls
35
+ // through to the existing FILE_NOT_FOUND branch (a non-sandbox
36
+ // error code, preserving pre-fix behaviour for URL inputs).
37
+ // - Local paths run through `resolveAudioPath`, which:
38
+ // 1. Lexically resolves `~` and `..` and rejects paths outside
39
+ // the workspace root before any disk read.
40
+ // 2. Canonicalises the file via `fs.realpathSync` so a symlink
41
+ // inside the workspace pointing OUTSIDE the workspace is
42
+ // refused.
43
+ // ----------------------------------------------------------------
44
+ let filePath;
45
+ if (isRemoteUrl(rawFilePath)) {
46
+ // Preserve pre-existing behaviour for URL inputs: the
47
+ // existsSync branch below will return a clean FILE_NOT_FOUND
48
+ // (a non-sandbox error code).
49
+ filePath = rawFilePath;
50
+ }
51
+ else {
52
+ const resolution = resolveAudioPath(rawFilePath);
53
+ if (!resolution.ok) {
54
+ // Surface the sandbox / not-found error via the standard
55
+ // ElevenLabsError path so withErrorHandling renders it with a
56
+ // stable shape and a code.
57
+ const code = resolution.error.startsWith('File not found')
58
+ ? 'FILE_NOT_FOUND'
59
+ : 'PATH_SANDBOX_VIOLATION';
60
+ throw new ElevenLabsError(resolution.error, code, 'Provide a path to an existing audio file inside MCP_WORKSPACE_PATH (or os.tmpdir() when unset). Remote URLs are not supported.');
61
+ }
62
+ filePath = resolution.path;
63
+ }
64
+ // Read local file
65
+ if (!fs.existsSync(filePath)) {
66
+ throw new ElevenLabsError(`File not found: ${rawFilePath}`, 'FILE_NOT_FOUND', 'Provide an absolute path to an existing audio file.');
67
+ }
68
+ // Defence-in-depth: re-canonicalise via realpathSync at the very
69
+ // last moment to close the (vanishingly small) TOCTOU window
70
+ // between sandbox validation and the readFileSync call. URL inputs
71
+ // skip this since they did not go through the sandbox.
72
+ const verifiedPath = isRemoteUrl(rawFilePath) ? filePath : fs.realpathSync(filePath);
73
+ const fileBuffer = fs.readFileSync(verifiedPath);
74
+ const fileName = path.basename(verifiedPath);
75
+ // Use FormData to send as multipart
76
+ const formData = new FormData();
77
+ formData.append('audio', new Blob([fileBuffer]), fileName);
78
+ if (args.language_code) {
79
+ formData.append('language_code', args.language_code);
80
+ }
81
+ const data = await elevenLabsJson(apiKey, '/speech-to-text', {
82
+ method: 'POST',
83
+ body: formData,
84
+ });
85
+ return JSON.stringify({
86
+ ok: true,
87
+ text: data.text,
88
+ word_count: data.words?.length || 0,
89
+ language: args.language_code || 'auto-detected',
90
+ message: `Transcription complete: ${data.text.length} characters, ${data.words?.length || 0} words.`,
91
+ });
92
+ }));
93
+ }
94
+ //# sourceMappingURL=transcription.js.map
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function registerVoiceTools(server: McpServer): void;
3
+ //# sourceMappingURL=voices.d.ts.map
@@ -0,0 +1,49 @@
1
+ import { z } from 'zod';
2
+ import { getApiKey } from '../auth.js';
3
+ import { elevenLabsJson } from '../client.js';
4
+ import { ElevenLabsError } from '../types.js';
5
+ import { withErrorHandling } from '../utils.js';
6
+ export function registerVoiceTools(server) {
7
+ server.registerTool('list_voices', {
8
+ description: 'Search and browse available ElevenLabs voices. FREE — no credits consumed. ' +
9
+ 'Returns voice ID, name, description, category, preview URL, and labels. ' +
10
+ 'Use voice_id from results with generate_speech.',
11
+ inputSchema: z.object({
12
+ search: z.string().optional().describe('Search query to filter voices by name.'),
13
+ category: z.enum(['premade', 'cloned', 'generated', 'professional']).optional()
14
+ .describe('Filter by voice category.'),
15
+ page_size: z.number().int().min(1).max(100).optional().describe('Number of results (1-100). Default: 20.'),
16
+ }),
17
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
18
+ }, withErrorHandling(async (args) => {
19
+ const apiKey = getApiKey();
20
+ if (!apiKey) {
21
+ throw new ElevenLabsError('ElevenLabs API key not configured', 'AUTH_REQUIRED', 'Ask the user for their API key, then call configure_elevenlabs_api_key.');
22
+ }
23
+ const params = new URLSearchParams();
24
+ if (args.search)
25
+ params.set('search', args.search);
26
+ if (args.category)
27
+ params.set('category', args.category);
28
+ params.set('page_size', String(Math.min(100, args.page_size ?? 20)));
29
+ const data = await elevenLabsJson(apiKey, `https://api.elevenlabs.io/v2/voices?${params.toString()}`);
30
+ const voices = data.voices.map((v) => ({
31
+ voice_id: v.voice_id,
32
+ name: v.name,
33
+ category: v.category,
34
+ description: v.description,
35
+ labels: v.labels,
36
+ preview_url: v.preview_url,
37
+ }));
38
+ return JSON.stringify({
39
+ ok: true,
40
+ voices,
41
+ count: voices.length,
42
+ has_more: data.has_more || false,
43
+ cost: 'FREE — no credits consumed',
44
+ message: `Found ${voices.length} voice${voices.length === 1 ? '' : 's'}${args.search ? ` matching "${args.search}"` : ''}.`,
45
+ hint: 'Use voice_id with generate_speech to create audio with a specific voice.',
46
+ });
47
+ }));
48
+ }
49
+ //# sourceMappingURL=voices.js.map