@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.
- package/README.md +30 -7
- package/dist/bridge.d.ts +1 -0
- package/dist/client.d.ts +34 -3
- package/dist/client.js +68 -14
- package/dist/endpoints.d.ts +34 -0
- package/dist/endpoints.js +37 -0
- package/dist/error-detail.d.ts +5 -0
- package/dist/error-detail.js +20 -0
- package/dist/server.js +9 -1
- package/dist/tools/account.d.ts +3 -0
- package/dist/tools/account.js +105 -0
- package/dist/tools/alignment.d.ts +3 -0
- package/dist/tools/alignment.js +55 -0
- package/dist/tools/audio-isolation.d.ts +3 -0
- package/dist/tools/audio-isolation.js +51 -0
- package/dist/tools/configure.js +18 -6
- package/dist/tools/dialogue.d.ts +3 -0
- package/dist/tools/dialogue.js +70 -0
- package/dist/tools/dubbing.d.ts +3 -0
- package/dist/tools/dubbing.js +234 -0
- package/dist/tools/file-input.d.ts +28 -0
- package/dist/tools/file-input.js +77 -0
- package/dist/tools/index.d.ts +8 -0
- package/dist/tools/index.js +8 -0
- package/dist/tools/music.js +85 -25
- package/dist/tools/path-safety.d.ts +1 -0
- package/dist/tools/path-safety.js +4 -1
- package/dist/tools/speech.js +57 -24
- package/dist/tools/transcription.js +27 -61
- package/dist/tools/voice-changer.d.ts +3 -0
- package/dist/tools/voice-changer.js +62 -0
- package/dist/tools/voice-clone.d.ts +3 -0
- package/dist/tools/voice-clone.js +104 -0
- package/dist/tools/voice-design.d.ts +3 -0
- package/dist/tools/voice-design.js +153 -0
- package/dist/tools/voices.js +167 -13
- package/dist/types.d.ts +91 -0
- package/dist/types.js +20 -4
- package/dist/untrusted-content.d.ts +45 -0
- package/dist/untrusted-content.js +104 -0
- package/package.json +2 -2
package/dist/tools/music.js
CHANGED
|
@@ -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:
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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,
|
|
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:
|
|
126
|
-
|
|
127
|
-
|
|
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,
|
|
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:
|
|
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:
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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:
|
|
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,
|
|
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
|
|
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))
|
package/dist/tools/speech.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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',
|
|
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
|
|
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',
|
|
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:
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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,
|
|
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:
|
|
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
|
|
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:
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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,
|
|
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 {
|
|
8
|
+
import { readSandboxedFile, sandboxedFileToBlob } from './file-input.js';
|
|
9
9
|
export function registerTranscriptionTools(server) {
|
|
10
10
|
server.registerTool('transcribe_audio', {
|
|
11
|
-
description:
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
28
|
-
//
|
|
29
|
-
|
|
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`
|
|
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',
|
|
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,
|
|
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,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
|