@mindstone/mcp-server-elevenlabs 0.3.0 → 0.4.0

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.
Files changed (41) hide show
  1. package/README.md +30 -7
  2. package/dist/bridge.d.ts +1 -0
  3. package/dist/client.d.ts +34 -3
  4. package/dist/client.js +68 -14
  5. package/dist/endpoints.d.ts +34 -0
  6. package/dist/endpoints.js +37 -0
  7. package/dist/error-detail.d.ts +5 -0
  8. package/dist/error-detail.js +20 -0
  9. package/dist/server.js +9 -1
  10. package/dist/tools/account.d.ts +3 -0
  11. package/dist/tools/account.js +105 -0
  12. package/dist/tools/alignment.d.ts +3 -0
  13. package/dist/tools/alignment.js +55 -0
  14. package/dist/tools/audio-isolation.d.ts +3 -0
  15. package/dist/tools/audio-isolation.js +51 -0
  16. package/dist/tools/configure.js +18 -6
  17. package/dist/tools/dialogue.d.ts +3 -0
  18. package/dist/tools/dialogue.js +70 -0
  19. package/dist/tools/dubbing.d.ts +3 -0
  20. package/dist/tools/dubbing.js +234 -0
  21. package/dist/tools/file-input.d.ts +28 -0
  22. package/dist/tools/file-input.js +77 -0
  23. package/dist/tools/index.d.ts +8 -0
  24. package/dist/tools/index.js +8 -0
  25. package/dist/tools/music.js +85 -25
  26. package/dist/tools/path-safety.d.ts +1 -0
  27. package/dist/tools/path-safety.js +4 -1
  28. package/dist/tools/speech.js +57 -24
  29. package/dist/tools/transcription.js +27 -61
  30. package/dist/tools/voice-changer.d.ts +3 -0
  31. package/dist/tools/voice-changer.js +62 -0
  32. package/dist/tools/voice-clone.d.ts +3 -0
  33. package/dist/tools/voice-clone.js +104 -0
  34. package/dist/tools/voice-design.d.ts +3 -0
  35. package/dist/tools/voice-design.js +153 -0
  36. package/dist/tools/voices.js +167 -13
  37. package/dist/types.d.ts +91 -0
  38. package/dist/types.js +20 -4
  39. package/dist/untrusted-content.d.ts +45 -0
  40. package/dist/untrusted-content.js +104 -0
  41. package/package.json +2 -2
@@ -1,7 +1,9 @@
1
1
  import { z } from 'zod';
2
2
  import { getApiKey } from '../auth.js';
3
3
  import { elevenLabsJson, elevenLabsAudio } from '../client.js';
4
+ import { ENDPOINTS } from '../endpoints.js';
4
5
  import { ElevenLabsError } from '../types.js';
6
+ import { unwrapUntrustedJsonStrings, wrapUntrusted } from '../untrusted-content.js';
5
7
  import { withErrorHandling } from '../utils.js';
6
8
  const OUTPUT_FORMAT_ENUM = z.enum([
7
9
  'mp3_44100_128', 'mp3_44100_192',
@@ -61,19 +63,55 @@ const COMPOSITION_PLAN_SCHEMA = z.object({
61
63
  .describe('Composition plan object — must match the shape returned by create_music_plan exactly. ' +
62
64
  'Pass the plan through verbatim or edit individual fields. The legacy {style, lyrics} ' +
63
65
  'shape used by ≤0.2.2 is no longer accepted (the API rejects it with HTTP 422).');
66
+ function wrapMusicPlanArray(values, source) {
67
+ return values?.map((value, index) => wrapUntrusted(value, `${source}[${index}]`) ?? value);
68
+ }
69
+ function wrapMusicPlanForDisplay(plan) {
70
+ return {
71
+ ...plan,
72
+ positive_global_styles: wrapMusicPlanArray(plan.positive_global_styles, 'elevenlabs:create_music_plan:composition_plan:positive_global_styles'),
73
+ negative_global_styles: wrapMusicPlanArray(plan.negative_global_styles, 'elevenlabs:create_music_plan:composition_plan:negative_global_styles'),
74
+ sections: plan.sections.map((section, index) => ({
75
+ ...section,
76
+ section_name: wrapUntrusted(section.section_name, `elevenlabs:create_music_plan:composition_plan:sections[${index}]:section_name`) ?? section.section_name,
77
+ positive_local_styles: wrapMusicPlanArray(section.positive_local_styles, `elevenlabs:create_music_plan:composition_plan:sections[${index}]:positive_local_styles`),
78
+ negative_local_styles: wrapMusicPlanArray(section.negative_local_styles, `elevenlabs:create_music_plan:composition_plan:sections[${index}]:negative_local_styles`),
79
+ lines: wrapMusicPlanArray(section.lines, `elevenlabs:create_music_plan:composition_plan:sections[${index}]:lines`),
80
+ })),
81
+ };
82
+ }
83
+ function formatIssuePath(path) {
84
+ return path.length > 0 ? path.join('.') : 'composition_plan';
85
+ }
86
+ function parseCompositionPlanInput(value) {
87
+ const unwrapped = unwrapUntrustedJsonStrings(value);
88
+ const parsed = COMPOSITION_PLAN_SCHEMA.safeParse(unwrapped);
89
+ if (!parsed.success) {
90
+ const issues = parsed.error.issues
91
+ .map((issue) => `${formatIssuePath(issue.path)}: ${issue.message}`)
92
+ .join('; ');
93
+ throw new ElevenLabsError(`Invalid composition_plan: ${issues}`, 'INVALID_INPUT', 'Pass a composition_plan returned by create_music_plan, or provide sections with section_name, duration_ms, positive_local_styles, negative_local_styles, and lines.');
94
+ }
95
+ return parsed.data;
96
+ }
64
97
  export function registerMusicTools(server) {
65
98
  // ── generate_music ────────────────────────────────────────────────────
66
99
  server.registerTool('generate_music', {
67
- description: 'Generate music from a text prompt using ElevenLabs Music API. ' +
68
- 'Returns a saved audio file path. DURATION: 3-600 seconds (default 30). ' +
69
- 'COST: Consumes credits based on duration.\n\n' +
70
- 'PROMPT TIPS to get a song WITH VOCALS:\n' +
71
- ' 1. Do NOT set force_instrumental: true (it silently drops all vocals).\n' +
72
- ' 2. Embed lyrics in the prompt using ElevenLabs section markers: ' +
73
- '`[Verse 1] ... [Chorus] ... [Bridge] ...`. Each marker is on its own ' +
74
- 'line followed by the lines for that section.\n' +
75
- 'For fine-grained control (per-section style + lyrics), use create_music_plan ' +
76
- 'then generate_music_from_plan instead.',
100
+ description: `Generate music from a text prompt using ElevenLabs Music API.
101
+
102
+ WHEN TO USE:
103
+ - Quick music bed from a genre/mood description
104
+ - Vocal songs when the prompt includes [Verse]/[Chorus] lyric markers (do not set force_instrumental: true)
105
+
106
+ EXAMPLE: {"prompt": "Upbeat jazz piano, 30 seconds", "duration_seconds": 30}
107
+
108
+ RELATED TOOLS:
109
+ - create_music_plan / generate_music_from_plan: per-section lyrics and styles
110
+ - check_subscription: confirm credits before long tracks
111
+
112
+ RETURNS: file_path, size_bytes, duration_seconds, format; warnings[] when force_instrumental conflicts with lyric markers.
113
+
114
+ COST: Credits based on duration (3–600 seconds).`,
77
115
  inputSchema: z.object({
78
116
  prompt: z.string().min(1).describe('Describe the music: genre, mood, instruments, style. Include `[Verse]`/`[Chorus]`/`[Bridge]` blocks with lyrics if you want a vocal song.'),
79
117
  duration_seconds: z.number().min(3).max(600).optional().describe('Duration in seconds (3-600). Default: 30.'),
@@ -109,7 +147,7 @@ export function registerMusicTools(server) {
109
147
  if (args.seed !== undefined)
110
148
  body.seed = args.seed;
111
149
  const ext = outputFormat.startsWith('mp3') ? 'mp3' : 'wav';
112
- const result = await elevenLabsAudio(apiKey, `/music?output_format=${outputFormat}`, { method: 'POST', body: JSON.stringify(body) }, ext);
150
+ const result = await elevenLabsAudio(apiKey, `${ENDPOINTS.MUSIC}?output_format=${outputFormat}`, { method: 'POST', body: JSON.stringify(body) }, ext);
113
151
  return JSON.stringify({
114
152
  ok: true,
115
153
  file_path: result.filePath,
@@ -122,9 +160,21 @@ export function registerMusicTools(server) {
122
160
  }));
123
161
  // ── create_music_plan ─────────────────────────────────────────────────
124
162
  server.registerTool('create_music_plan', {
125
- description: 'Create a composition plan for music generation — FREE, no credits consumed. ' +
126
- 'Returns a structured plan with sections, styles, and lyrics. ' +
127
- 'Review the plan and pass it to generate_music_from_plan when ready.',
163
+ description: `Create a composition plan for music generation.
164
+
165
+ WHEN TO USE:
166
+ - Structure a song with per-section lyrics and styles before paying for generation
167
+ - Review or edit sections before calling generate_music_from_plan
168
+
169
+ EXAMPLE: {"prompt": "Acoustic folk ballad about the sea", "duration_seconds": 45}
170
+
171
+ RELATED TOOLS:
172
+ - generate_music_from_plan: generate audio from the returned composition_plan
173
+ - generate_music: faster one-shot generation without a plan
174
+
175
+ RETURNS: composition_plan (sections with section_name, duration_ms, styles, lines), total_duration_seconds.
176
+
177
+ COST: FREE — no credits consumed.`,
128
178
  inputSchema: z.object({
129
179
  prompt: z.string().min(1).describe('Describe the music you want.'),
130
180
  duration_seconds: z.number().min(3).max(600).optional().describe('Target duration in seconds (3-600). Default: 30.'),
@@ -137,7 +187,7 @@ export function registerMusicTools(server) {
137
187
  }
138
188
  const durationSeconds = args.duration_seconds ?? 30;
139
189
  const durationMs = Math.max(3000, Math.min(600000, durationSeconds * 1000));
140
- const plan = await elevenLabsJson(apiKey, '/music/plan', {
190
+ const plan = await elevenLabsJson(apiKey, ENDPOINTS.MUSIC_PLAN, {
141
191
  method: 'POST',
142
192
  body: JSON.stringify({
143
193
  prompt: args.prompt,
@@ -146,9 +196,10 @@ export function registerMusicTools(server) {
146
196
  }),
147
197
  });
148
198
  const totalDurationMs = plan.sections.reduce((sum, s) => sum + (s.duration_ms || 0), 0);
199
+ const displayPlan = wrapMusicPlanForDisplay(plan);
149
200
  return JSON.stringify({
150
201
  ok: true,
151
- composition_plan: plan,
202
+ composition_plan: displayPlan,
152
203
  total_duration_seconds: totalDurationMs / 1000,
153
204
  num_sections: plan.sections.length,
154
205
  cost: 'FREE — no credits consumed',
@@ -158,14 +209,23 @@ export function registerMusicTools(server) {
158
209
  }));
159
210
  // ── generate_music_from_plan ──────────────────────────────────────────
160
211
  server.registerTool('generate_music_from_plan', {
161
- description: 'Generate music from a composition plan (created by create_music_plan or manually crafted). ' +
162
- 'The plan must have at least one section. Each section requires section_name, duration_ms, ' +
163
- 'positive_local_styles, negative_local_styles, and lines. Pass plans from create_music_plan ' +
164
- 'through verbatim the field names are enforced by the ElevenLabs API.\n\n' +
165
- 'COST: Consumes credits based on duration. ' +
166
- 'Total duration: 3s-10min. Each section: 3-120s.',
212
+ description: `Generate music from a composition plan.
213
+
214
+ WHEN TO USE:
215
+ - After create_music_plan when you want per-section control over lyrics and styles
216
+ - When generate_music's single prompt is not precise enough
217
+
218
+ EXAMPLE: pass the composition_plan object from create_music_plan verbatim
219
+
220
+ RELATED TOOLS:
221
+ - create_music_plan: produces the plan shape this tool expects
222
+ - check_subscription: confirm credits before generation
223
+
224
+ RETURNS: file_path, size_bytes, duration_seconds, format.
225
+
226
+ COST: Credits based on total plan duration (3s–10min).`,
167
227
  inputSchema: z.object({
168
- composition_plan: COMPOSITION_PLAN_SCHEMA,
228
+ composition_plan: z.unknown().describe('Composition plan object from create_music_plan. Wrapped display text and raw manually edited text are both accepted; the plan is unwrapped and strictly validated before generation.'),
169
229
  seed: z.number().int().optional().describe('Random seed for reproducibility.'),
170
230
  output_format: OUTPUT_FORMAT_ENUM.describe('Audio output format. Default: mp3_44100_128.'),
171
231
  }),
@@ -175,7 +235,7 @@ export function registerMusicTools(server) {
175
235
  if (!apiKey) {
176
236
  throw new ElevenLabsError('ElevenLabs API key not configured', 'AUTH_REQUIRED', 'Ask the user for their API key, then call configure_elevenlabs_api_key.');
177
237
  }
178
- const compositionPlan = args.composition_plan;
238
+ const compositionPlan = parseCompositionPlanInput(args.composition_plan);
179
239
  const outputFormat = args.output_format ?? 'mp3_44100_128';
180
240
  const body = {
181
241
  composition_plan: compositionPlan,
@@ -184,7 +244,7 @@ export function registerMusicTools(server) {
184
244
  if (args.seed !== undefined)
185
245
  body.seed = args.seed;
186
246
  const ext = outputFormat.startsWith('mp3') ? 'mp3' : 'wav';
187
- const result = await elevenLabsAudio(apiKey, `/music?output_format=${outputFormat}`, { method: 'POST', body: JSON.stringify(body) }, ext);
247
+ const result = await elevenLabsAudio(apiKey, `${ENDPOINTS.MUSIC}?output_format=${outputFormat}`, { method: 'POST', body: JSON.stringify(body) }, ext);
188
248
  const totalDurationMs = compositionPlan.sections.reduce((sum, s) => sum + (s.duration_ms || 0), 0);
189
249
  return JSON.stringify({
190
250
  ok: true,
@@ -28,6 +28,7 @@ export declare function isRemoteUrl(input: string): boolean;
28
28
  * than crashing.
29
29
  */
30
30
  export declare function getAudioWorkspaceRoot(): string;
31
+ export declare function isInsideAudioWorkspaceRoot(resolvedPath: string, root?: string): boolean;
31
32
  /**
32
33
  * Validate that an LLM-supplied `file_path` argument resolves to a real
33
34
  * file under the audio workspace sandbox root, even after symlink
@@ -36,6 +36,9 @@ export function getAudioWorkspaceRoot() {
36
36
  return lexical;
37
37
  }
38
38
  }
39
+ export function isInsideAudioWorkspaceRoot(resolvedPath, root = getAudioWorkspaceRoot()) {
40
+ return resolvedPath === root || resolvedPath.startsWith(root + path.sep);
41
+ }
39
42
  /**
40
43
  * Canonicalise the deepest existing ancestor of an absolute path and
41
44
  * re-append the missing tail (M3-fix-C). This lets the lexical-prefix
@@ -90,7 +93,7 @@ function canonicalisePrefix(absoluteLexical) {
90
93
  export function resolveAudioPath(filePath) {
91
94
  const root = getAudioWorkspaceRoot();
92
95
  const denyMessage = `file_path is outside the workspace sandbox root (${root}). Got: ${filePath}`;
93
- const isInsideRoot = (p) => p === root || p.startsWith(root + path.sep);
96
+ const isInsideRoot = (p) => isInsideAudioWorkspaceRoot(p, root);
94
97
  // Step 1: lexical normalisation — expand `~`, collapse `..`, absolutise.
95
98
  const expanded = filePath.startsWith('~')
96
99
  ? path.join(os.homedir(), filePath.slice(1))
@@ -1,15 +1,21 @@
1
1
  import { z } from 'zod';
2
2
  import { getApiKey } from '../auth.js';
3
3
  import { elevenLabsJson, elevenLabsAudio } from '../client.js';
4
- import { ElevenLabsError } from '../types.js';
4
+ import { ENDPOINTS, voicesV2Url } from '../endpoints.js';
5
+ import { ElevenLabsError, VOICE_NOT_FOUND_RESOLUTION } from '../types.js';
6
+ import { wrapUntrusted } from '../untrusted-content.js';
5
7
  import { withErrorHandling } from '../utils.js';
6
8
  /**
7
9
  * Look up a voice by name via the ElevenLabs v2 voices API.
8
10
  */
9
11
  async function lookupVoiceByName(apiKey, name) {
10
- const data = await elevenLabsJson(apiKey, `https://api.elevenlabs.io/v2/voices?search=${encodeURIComponent(name)}&page_size=5`);
12
+ const params = new URLSearchParams({
13
+ search: name,
14
+ page_size: '5',
15
+ });
16
+ const data = await elevenLabsJson(apiKey, voicesV2Url(params));
11
17
  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.`);
18
+ throw new ElevenLabsError(`No voice found matching "${name}"`, 'VOICE_NOT_FOUND', VOICE_NOT_FOUND_RESOLUTION);
13
19
  }
14
20
  return data.voices[0];
15
21
  }
@@ -25,9 +31,10 @@ async function lookupVoiceByName(apiKey, name) {
25
31
  * resolution that tells the agent to call list_voices first.
26
32
  */
27
33
  async function pickDefaultVoice(apiKey) {
28
- const data = await elevenLabsJson(apiKey, 'https://api.elevenlabs.io/v2/voices?page_size=20');
34
+ const params = new URLSearchParams({ page_size: '20' });
35
+ const data = await elevenLabsJson(apiKey, voicesV2Url(params));
29
36
  if (!data.voices || data.voices.length === 0) {
30
- throw new ElevenLabsError('No voice specified and the account has no voices.', 'VOICE_NOT_FOUND', 'Add a voice in https://elevenlabs.io/app/voice-library or pass a voice_id / voice_name explicitly.');
37
+ throw new ElevenLabsError('No voice specified and the account has no voices.', 'VOICE_NOT_FOUND', VOICE_NOT_FOUND_RESOLUTION);
31
38
  }
32
39
  // Prefer premade voices (curated, generally suitable as defaults).
33
40
  const premade = data.voices.find((v) => v.category === 'premade');
@@ -36,16 +43,22 @@ async function pickDefaultVoice(apiKey) {
36
43
  export function registerSpeechTools(server) {
37
44
  // ── generate_speech ───────────────────────────────────────────────────
38
45
  server.registerTool('generate_speech', {
39
- description: 'Generate spoken audio from text using ElevenLabs text-to-speech. ' +
40
- 'Use voice_id (direct) or voice_name (fuzzy search). If neither is provided, ' +
41
- 'a premade voice from the account is used.\n\n' +
42
- 'MODELS:\n' +
43
- ' - eleven_v3 (default) most expressive, 70+ languages\n' +
44
- ' - eleven_multilingual_v2 — proven multilingual, 29 languages\n' +
45
- ' - eleven_flash_v2_5 low latency\n' +
46
- ' - eleven_turbo_v2_5 — low latency variant\n' +
47
- ' - eleven_monolingual_v1 — English-only legacy model (kept for backwards compatibility)\n\n' +
48
- 'COST: ~1 credit per 100 characters.',
46
+ description: `Generate spoken audio from text using ElevenLabs text-to-speech.
47
+
48
+ WHEN TO USE:
49
+ - Turn user text into a playable speech file
50
+ - Narration, voiceovers, or reading content aloud
51
+
52
+ EXAMPLE: {"text": "Hello world.", "voice_id": "21m00Tcm4TlvDq8ikWAM", "model_id": "eleven_v3"}
53
+
54
+ RELATED TOOLS:
55
+ - list_voices / search_shared_voices / get_voice: find voice_id
56
+ - list_models: pick model_id (default eleven_v3)
57
+ - check_subscription: confirm credits before long text
58
+
59
+ RETURNS: file_path, size_bytes, voice_id, model, format. API-resolved voice names are enveloped.
60
+
61
+ COST: ~1 credit per 100 characters.`,
49
62
  inputSchema: z.object({
50
63
  text: z.string().min(1).describe('Text to speak. Maximum ~5000 characters per request.'),
51
64
  voice_id: z.string().optional().describe('Direct voice ID (from list_voices). Takes priority over voice_name.'),
@@ -75,8 +88,13 @@ export function registerSpeechTools(server) {
75
88
  const stability = args.stability ?? 0.5;
76
89
  const similarityBoost = args.similarity_boost ?? 0.75;
77
90
  const outputFormat = args.output_format ?? 'mp3_44100_128';
78
- // Voice lookup
91
+ // Voice lookup. When the name comes back from the API it is external,
92
+ // possibly attacker-authored text (e.g. a shared/cloned voice named to
93
+ // carry a prompt injection) and must be enveloped before being returned
94
+ // (AGENTS.md invariant #6). When voice_id is passed directly, the name
95
+ // is just the caller's own input echoed back — no envelope needed.
79
96
  let resolvedVoiceName = voiceName || 'default';
97
+ let voiceNameFromApi = false;
80
98
  if (!voiceId) {
81
99
  if (voiceName) {
82
100
  const voice = await lookupVoiceByName(apiKey, voiceName);
@@ -89,6 +107,7 @@ export function registerSpeechTools(server) {
89
107
  voiceId = voice.voice_id;
90
108
  resolvedVoiceName = voice.name;
91
109
  }
110
+ voiceNameFromApi = true;
92
111
  }
93
112
  const body = {
94
113
  text: args.text,
@@ -99,24 +118,38 @@ export function registerSpeechTools(server) {
99
118
  },
100
119
  };
101
120
  const ext = outputFormat.startsWith('mp3') ? 'mp3' : 'wav';
102
- const result = await elevenLabsAudio(apiKey, `/text-to-speech/${voiceId}?output_format=${outputFormat}`, { method: 'POST', body: JSON.stringify(body) }, ext);
121
+ const result = await elevenLabsAudio(apiKey, ENDPOINTS.textToSpeech(voiceId, outputFormat), { method: 'POST', body: JSON.stringify(body) }, ext);
122
+ // The voice name lives ONLY in the (enveloped) `voice` field — never in
123
+ // `message`, so no unwrapped API-authored substring can reach the model.
103
124
  return JSON.stringify({
104
125
  ok: true,
105
126
  file_path: result.filePath,
106
127
  size_bytes: result.sizeBytes,
107
- voice: resolvedVoiceName,
128
+ voice: voiceNameFromApi
129
+ ? wrapUntrusted(resolvedVoiceName, 'elevenlabs:generate_speech:voice_name')
130
+ : resolvedVoiceName,
108
131
  voice_id: voiceId,
109
132
  model: modelId,
110
133
  format: outputFormat,
111
- message: `Speech generated with voice "${resolvedVoiceName}" and saved to ${result.filePath} (${(result.sizeBytes / 1024).toFixed(1)} KB).`,
134
+ message: `Speech generated and saved to ${result.filePath} (${(result.sizeBytes / 1024).toFixed(1)} KB).`,
112
135
  });
113
136
  }));
114
137
  // ── generate_sound_effect ─────────────────────────────────────────────
115
138
  server.registerTool('generate_sound_effect', {
116
- description: 'Generate sound effects from a text description. ' +
117
- 'DURATION: 0.5-22 seconds (auto if omitted). ' +
118
- 'PROMPT INFLUENCE (0-1): How closely to follow the text prompt. Default: 0.3. ' +
119
- 'COST: Credits based on duration.',
139
+ description: `Generate sound effects from a text description.
140
+
141
+ WHEN TO USE:
142
+ - Short ambient or UI sounds from a natural-language prompt
143
+ - Effects for video, games, or presentations
144
+
145
+ EXAMPLE: {"prompt": "Soft rain on a tin roof", "duration_seconds": 3}
146
+
147
+ RELATED TOOLS:
148
+ - check_subscription: confirm credits before generation
149
+
150
+ RETURNS: file_path, size_bytes, duration_seconds.
151
+
152
+ COST: Credits based on duration (0.5–22 seconds).`,
120
153
  inputSchema: z.object({
121
154
  prompt: z.string().min(1).describe('Describe the sound effect. Be specific about characteristics.'),
122
155
  duration_seconds: z.number().min(0.5).max(22).optional().describe('Duration in seconds (0.5-22). Auto if omitted.'),
@@ -136,7 +169,7 @@ export function registerSpeechTools(server) {
136
169
  if (args.duration_seconds !== undefined) {
137
170
  body.duration_seconds = Math.max(0.5, Math.min(22, args.duration_seconds));
138
171
  }
139
- const result = await elevenLabsAudio(apiKey, '/sound-generation', { method: 'POST', body: JSON.stringify(body) }, 'mp3');
172
+ const result = await elevenLabsAudio(apiKey, ENDPOINTS.SOUND_GENERATION, { method: 'POST', body: JSON.stringify(body) }, 'mp3');
140
173
  return JSON.stringify({
141
174
  ok: true,
142
175
  file_path: result.filePath,
@@ -1,17 +1,27 @@
1
1
  import { z } from 'zod';
2
- import * as fs from 'fs';
3
- import * as path from 'path';
4
2
  import { getApiKey } from '../auth.js';
5
3
  import { elevenLabsJson } from '../client.js';
4
+ import { ENDPOINTS } from '../endpoints.js';
6
5
  import { ElevenLabsError } from '../types.js';
6
+ import { wrapUntrusted } from '../untrusted-content.js';
7
7
  import { withErrorHandling } from '../utils.js';
8
- import { isRemoteUrl, resolveAudioPath } from './path-safety.js';
8
+ import { readSandboxedFile, sandboxedFileToBlob } from './file-input.js';
9
9
  export function registerTranscriptionTools(server) {
10
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.',
11
+ description: `Transcribe speech from a local audio file to text.
12
+
13
+ WHEN TO USE:
14
+ - Convert meeting recordings or voice memos to text
15
+ - Extract quotes from audio the user provides as a file path
16
+
17
+ EXAMPLE: {"file_path": "/path/to/recording.mp3", "language_code": "en"}
18
+
19
+ RELATED TOOLS:
20
+ - generate_speech: the inverse operation (text to audio)
21
+
22
+ RETURNS: enveloped text, word_count, language. File path must be inside MCP_WORKSPACE_PATH (or os.tmpdir()).
23
+
24
+ COST: Credits based on audio duration.`,
15
25
  inputSchema: z.object({
16
26
  file_path: z.string().min(1).describe('Absolute path to local audio file to transcribe.'),
17
27
  language_code: z.string().optional().describe('Language code (e.g., "en", "es", "fr"). Auto-detected if omitted.'),
@@ -24,74 +34,30 @@ export function registerTranscriptionTools(server) {
24
34
  if (!apiKey) {
25
35
  throw new ElevenLabsError('ElevenLabs API key not configured', 'AUTH_REQUIRED', 'Ask the user for their API key, then call configure_elevenlabs_api_key.');
26
36
  }
27
- const rawFilePath = args.file_path;
28
- // ----------------------------------------------------------------
29
- // SECURITY (M3.9): sandbox local audio reads to under
30
- // `MCP_WORKSPACE_PATH` (or `os.tmpdir()` when unset). Without this,
31
- // an LLM-supplied `file_path` could exfiltrate arbitrary host bytes
32
- // (e.g. ~/.ssh/id_rsa, /etc/passwd) via the multipart upload to
33
- // ElevenLabs Speech-to-Text.
34
- //
35
- // - Remote URL inputs bypass the sandbox: `transcribe_audio`
36
- // currently only handles local-file inputs, so a URL string falls
37
- // through to the existing FILE_NOT_FOUND branch (a non-sandbox
38
- // error code, preserving pre-fix behaviour for URL inputs).
39
- // - Local paths run through `resolveAudioPath`, which:
40
- // 1. Lexically resolves `~` and `..` and rejects paths outside
41
- // the workspace root before any disk read.
42
- // 2. Canonicalises the file via `fs.realpathSync` so a symlink
43
- // inside the workspace pointing OUTSIDE the workspace is
44
- // refused.
45
- // ----------------------------------------------------------------
46
- let filePath;
47
- if (isRemoteUrl(rawFilePath)) {
48
- // Preserve pre-existing behaviour for URL inputs: the
49
- // existsSync branch below will return a clean FILE_NOT_FOUND
50
- // (a non-sandbox error code).
51
- filePath = rawFilePath;
52
- }
53
- else {
54
- const resolution = resolveAudioPath(rawFilePath);
55
- if (!resolution.ok) {
56
- // Surface the sandbox / not-found error via the standard
57
- // ElevenLabsError path so withErrorHandling renders it with a
58
- // stable shape and a code.
59
- const code = resolution.error.startsWith('File not found')
60
- ? 'FILE_NOT_FOUND'
61
- : 'PATH_SANDBOX_VIOLATION';
62
- 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.');
63
- }
64
- filePath = resolution.path;
65
- }
66
- // Read local file
67
- if (!fs.existsSync(filePath)) {
68
- throw new ElevenLabsError(`File not found: ${rawFilePath}`, 'FILE_NOT_FOUND', 'Provide an absolute path to an existing audio file.');
69
- }
70
- // Defence-in-depth: re-canonicalise via realpathSync at the very
71
- // last moment to close the (vanishingly small) TOCTOU window
72
- // between sandbox validation and the readFileSync call. URL inputs
73
- // skip this since they did not go through the sandbox.
74
- const verifiedPath = isRemoteUrl(rawFilePath) ? filePath : fs.realpathSync(filePath);
75
- const fileBuffer = fs.readFileSync(verifiedPath);
76
- const fileName = path.basename(verifiedPath);
37
+ // Sandbox local reads via shared file-input helper (MCP_WORKSPACE_PATH +
38
+ // realpathSync TOCTOU guard — see file-input.ts).
39
+ const fileInput = readSandboxedFile(args.file_path);
77
40
  // Multipart upload. ElevenLabs Speech-to-Text v1 requires:
78
- // - field name `file` (NOT `audio` — see planning doc 260520)
41
+ // - field name `file` (NOT `audio`)
79
42
  // - `model_id` is mandatory (only `scribe_v1` is currently supported)
80
43
  // - `tag_audio_events=false` to avoid `(mouse click)` style noise
81
44
  const formData = new FormData();
82
- formData.append('file', new Blob([fileBuffer]), fileName);
45
+ formData.append('file', sandboxedFileToBlob(fileInput), fileInput.fileName);
83
46
  formData.append('model_id', args.model_id ?? 'scribe_v1');
84
47
  formData.append('tag_audio_events', String(args.tag_audio_events ?? false));
85
48
  if (args.language_code) {
86
49
  formData.append('language_code', args.language_code);
87
50
  }
88
- const data = await elevenLabsJson(apiKey, '/speech-to-text', {
51
+ const data = await elevenLabsJson(apiKey, ENDPOINTS.SPEECH_TO_TEXT, {
89
52
  method: 'POST',
90
53
  body: formData,
91
54
  });
55
+ // AGENTS.md invariant #6: the transcript is whatever was SPOKEN in the
56
+ // audio — the most attacker-controllable text this connector returns.
57
+ // `message` stays numeric-only; never echo transcript substrings into it.
92
58
  return JSON.stringify({
93
59
  ok: true,
94
- text: data.text,
60
+ text: wrapUntrusted(data.text, 'elevenlabs:transcribe_audio:text'),
95
61
  word_count: data.words?.length || 0,
96
62
  language: args.language_code || 'auto-detected',
97
63
  message: `Transcription complete: ${data.text.length} characters, ${data.words?.length || 0} words.`,
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function registerVoiceChangerTools(server: McpServer): void;
3
+ //# sourceMappingURL=voice-changer.d.ts.map
@@ -0,0 +1,62 @@
1
+ import { z } from 'zod';
2
+ import { getApiKey } from '../auth.js';
3
+ import { elevenLabsAudio } from '../client.js';
4
+ import { ENDPOINTS } from '../endpoints.js';
5
+ import { ElevenLabsError } from '../types.js';
6
+ import { withErrorHandling } from '../utils.js';
7
+ import { readSandboxedFile, sandboxedFileToBlob } from './file-input.js';
8
+ export function registerVoiceChangerTools(server) {
9
+ server.registerTool('speech_to_speech', {
10
+ description: `Convert an audio clip to sound like a different voice (voice conversion).
11
+
12
+ WHEN TO USE:
13
+ - Change the speaker voice of an existing recording while preserving timing
14
+ - Apply a premade or cloned voice_id to source audio the user provides as a file
15
+
16
+ EXAMPLE: {"audio_path": "/path/to/source.mp3", "voice_id": "21m00Tcm4TlvDq8ikWAM"}
17
+
18
+ RELATED TOOLS:
19
+ - list_voices / get_voice: resolve voice_id
20
+ - generate_speech: synthesize new speech from text instead of converting audio
21
+ - check_subscription: confirm credits before conversion
22
+
23
+ RETURNS: file_path and size_bytes for the converted audio (saved under os.tmpdir()).
24
+
25
+ COST: Credits based on source audio duration.`,
26
+ inputSchema: z.object({
27
+ audio_path: z.string().min(1).describe('Absolute path to local source audio inside MCP_WORKSPACE_PATH (or os.tmpdir()).'),
28
+ voice_id: z.string().min(1).describe('Target voice ID from list_voices or search_shared_voices.'),
29
+ model_id: z.string().optional().describe('STS model ID. Omit to use the API default.'),
30
+ remove_background_noise: z.boolean().optional().describe('When true, reduce background noise during conversion. Default: false.'),
31
+ voice_settings: z.object({
32
+ stability: z.number().min(0).max(1).optional(),
33
+ similarity_boost: z.number().min(0).max(1).optional(),
34
+ }).optional().describe('Optional voice settings JSON object (stability, similarity_boost).'),
35
+ }),
36
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
37
+ }, withErrorHandling(async (args) => {
38
+ const apiKey = getApiKey();
39
+ if (!apiKey) {
40
+ throw new ElevenLabsError('ElevenLabs API key not configured', 'AUTH_REQUIRED', 'Ask the user for their API key, then call configure_elevenlabs_api_key.');
41
+ }
42
+ const fileInput = readSandboxedFile(args.audio_path);
43
+ const formData = new FormData();
44
+ formData.append('audio', sandboxedFileToBlob(fileInput), fileInput.fileName);
45
+ if (args.model_id) {
46
+ formData.append('model_id', args.model_id);
47
+ }
48
+ if (args.voice_settings) {
49
+ formData.append('voice_settings', JSON.stringify(args.voice_settings));
50
+ }
51
+ formData.append('remove_background_noise', String(args.remove_background_noise ?? false));
52
+ const result = await elevenLabsAudio(apiKey, ENDPOINTS.speechToSpeech(args.voice_id), { method: 'POST', body: formData }, 'mp3');
53
+ return JSON.stringify({
54
+ ok: true,
55
+ file_path: result.filePath,
56
+ size_bytes: result.sizeBytes,
57
+ voice_id: args.voice_id,
58
+ message: `Speech-to-speech conversion saved to ${result.filePath} (${(result.sizeBytes / 1024).toFixed(1)} KB).`,
59
+ });
60
+ }));
61
+ }
62
+ //# sourceMappingURL=voice-changer.js.map
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function registerVoiceCloneTools(server: McpServer): void;
3
+ //# sourceMappingURL=voice-clone.d.ts.map