@mindstone/mcp-server-elevenlabs 0.2.2 → 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 +68 -4
- package/dist/bridge.d.ts +1 -0
- package/dist/client.d.ts +34 -3
- package/dist/client.js +141 -35
- 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 +152 -27
- package/dist/tools/path-safety.d.ts +1 -0
- package/dist/tools/path-safety.js +4 -1
- package/dist/tools/speech.js +90 -34
- package/dist/tools/transcription.js +34 -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 +110 -13
- 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
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { getApiKey } from '../auth.js';
|
|
3
|
+
import { elevenLabsJson } from '../client.js';
|
|
4
|
+
import { ENDPOINTS } from '../endpoints.js';
|
|
5
|
+
import { ElevenLabsError } from '../types.js';
|
|
6
|
+
import { wrapUntrusted } from '../untrusted-content.js';
|
|
7
|
+
import { withErrorHandling } from '../utils.js';
|
|
8
|
+
import { readSandboxedFile, sandboxedFileToBlob } from './file-input.js';
|
|
9
|
+
export function registerAlignmentTools(server) {
|
|
10
|
+
server.registerTool('forced_alignment', {
|
|
11
|
+
description: `Align a transcript to audio and return per-word timestamps.
|
|
12
|
+
|
|
13
|
+
WHEN TO USE:
|
|
14
|
+
- Build karaoke-style captions or precise edit markers from audio + transcript
|
|
15
|
+
- Verify that spoken words match a provided script
|
|
16
|
+
|
|
17
|
+
EXAMPLE: {"file_path": "/path/to/clip.mp3", "text": "Hello world."}
|
|
18
|
+
|
|
19
|
+
RELATED TOOLS:
|
|
20
|
+
- transcribe_audio: generate transcript text from audio alone
|
|
21
|
+
- generate_speech: create audio from text (inverse workflow)
|
|
22
|
+
|
|
23
|
+
RETURNS: words[] with enveloped aligned text and start/end times, plus loss score. Input transcript text is caller-supplied and not echoed raw.
|
|
24
|
+
|
|
25
|
+
COST: Credits based on audio duration.`,
|
|
26
|
+
inputSchema: z.object({
|
|
27
|
+
file_path: z.string().min(1).describe('Absolute path to local audio inside MCP_WORKSPACE_PATH (or os.tmpdir()).'),
|
|
28
|
+
text: z.string().min(1).describe('Transcript text to align against the audio.'),
|
|
29
|
+
}),
|
|
30
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
31
|
+
}, withErrorHandling(async (args) => {
|
|
32
|
+
const apiKey = getApiKey();
|
|
33
|
+
if (!apiKey) {
|
|
34
|
+
throw new ElevenLabsError('ElevenLabs API key not configured', 'AUTH_REQUIRED', 'Ask the user for their API key, then call configure_elevenlabs_api_key.');
|
|
35
|
+
}
|
|
36
|
+
const fileInput = readSandboxedFile(args.file_path);
|
|
37
|
+
const formData = new FormData();
|
|
38
|
+
formData.append('file', sandboxedFileToBlob(fileInput), fileInput.fileName);
|
|
39
|
+
formData.append('text', args.text);
|
|
40
|
+
const data = await elevenLabsJson(apiKey, ENDPOINTS.FORCED_ALIGNMENT, { method: 'POST', body: formData });
|
|
41
|
+
const words = (data.words ?? []).map((w) => ({
|
|
42
|
+
text: wrapUntrusted(w.text, 'elevenlabs:forced_alignment:word_text'),
|
|
43
|
+
start: w.start,
|
|
44
|
+
end: w.end,
|
|
45
|
+
}));
|
|
46
|
+
return JSON.stringify({
|
|
47
|
+
ok: true,
|
|
48
|
+
words,
|
|
49
|
+
word_count: words.length,
|
|
50
|
+
loss: data.loss,
|
|
51
|
+
message: `Aligned ${words.length} word${words.length === 1 ? '' : 's'} to audio.`,
|
|
52
|
+
});
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
55
|
+
//# sourceMappingURL=alignment.js.map
|
|
@@ -0,0 +1,51 @@
|
|
|
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 registerAudioIsolationTools(server) {
|
|
9
|
+
server.registerTool('isolate_audio', {
|
|
10
|
+
description: `Remove background noise from an audio file (audio isolation).
|
|
11
|
+
|
|
12
|
+
WHEN TO USE:
|
|
13
|
+
- Clean up meeting recordings or voice memos with background noise
|
|
14
|
+
- Prepare a cleaner clip before transcription or voice cloning
|
|
15
|
+
- Source audio is at least ~4.6 seconds long (shorter clips fail upstream)
|
|
16
|
+
|
|
17
|
+
COMMON MISTAKES:
|
|
18
|
+
- Clips under ~4.6 seconds — the API rejects them; trim/merge or pick a longer sample first
|
|
19
|
+
|
|
20
|
+
EXAMPLE: {"audio_path": "/path/to/noisy.mp3"}
|
|
21
|
+
|
|
22
|
+
RELATED TOOLS:
|
|
23
|
+
- transcribe_audio: transcribe the isolated clip
|
|
24
|
+
- speech_to_speech: apply a different voice after isolation
|
|
25
|
+
- clone_voice: clone from a cleaner sample
|
|
26
|
+
|
|
27
|
+
RETURNS: file_path and size_bytes for the isolated audio (saved under os.tmpdir()).
|
|
28
|
+
|
|
29
|
+
COST: Credits based on audio duration.`,
|
|
30
|
+
inputSchema: z.object({
|
|
31
|
+
audio_path: z.string().min(1).describe('Absolute path to local audio inside MCP_WORKSPACE_PATH (or os.tmpdir()).'),
|
|
32
|
+
}),
|
|
33
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
34
|
+
}, withErrorHandling(async (args) => {
|
|
35
|
+
const apiKey = getApiKey();
|
|
36
|
+
if (!apiKey) {
|
|
37
|
+
throw new ElevenLabsError('ElevenLabs API key not configured', 'AUTH_REQUIRED', 'Ask the user for their API key, then call configure_elevenlabs_api_key.');
|
|
38
|
+
}
|
|
39
|
+
const fileInput = readSandboxedFile(args.audio_path);
|
|
40
|
+
const formData = new FormData();
|
|
41
|
+
formData.append('audio', sandboxedFileToBlob(fileInput), fileInput.fileName);
|
|
42
|
+
const result = await elevenLabsAudio(apiKey, ENDPOINTS.AUDIO_ISOLATION, { method: 'POST', body: formData }, 'mp3');
|
|
43
|
+
return JSON.stringify({
|
|
44
|
+
ok: true,
|
|
45
|
+
file_path: result.filePath,
|
|
46
|
+
size_bytes: result.sizeBytes,
|
|
47
|
+
message: `Isolated audio saved to ${result.filePath} (${(result.sizeBytes / 1024).toFixed(1)} KB).`,
|
|
48
|
+
});
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
51
|
+
//# sourceMappingURL=audio-isolation.js.map
|
package/dist/tools/configure.js
CHANGED
|
@@ -3,11 +3,23 @@ import { setApiKey } from '../auth.js';
|
|
|
3
3
|
import { bridgeRequest, BRIDGE_STATE_PATH } from '../bridge.js';
|
|
4
4
|
import { ElevenLabsError } from '../types.js';
|
|
5
5
|
import { withErrorHandling } from '../utils.js';
|
|
6
|
+
const CONFIGURED_MESSAGE = 'ElevenLabs API key configured successfully. Feature-specific permissions are checked on first use; call check_subscription to verify the account and credits now.';
|
|
6
7
|
export function registerConfigureTools(server) {
|
|
7
8
|
server.registerTool('configure_elevenlabs_api_key', {
|
|
8
|
-
description:
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
description: `Save the user's ElevenLabs API key for this session.
|
|
10
|
+
|
|
11
|
+
WHEN TO USE:
|
|
12
|
+
- When the user provides their API key in chat
|
|
13
|
+
- After AUTH_REQUIRED errors from any other tool
|
|
14
|
+
|
|
15
|
+
EXAMPLE: {"api_key": "sk_..."}
|
|
16
|
+
|
|
17
|
+
RELATED TOOLS:
|
|
18
|
+
- check_subscription: verify the key and see credits after configuring
|
|
19
|
+
|
|
20
|
+
RETURNS: ok, message.
|
|
21
|
+
|
|
22
|
+
COST: FREE.`,
|
|
11
23
|
inputSchema: z.object({
|
|
12
24
|
api_key: z.string().min(1).describe('ElevenLabs API key (starts with "sk_").'),
|
|
13
25
|
}),
|
|
@@ -21,8 +33,8 @@ export function registerConfigureTools(server) {
|
|
|
21
33
|
if (result.success) {
|
|
22
34
|
setApiKey(key);
|
|
23
35
|
const message = result.warning
|
|
24
|
-
?
|
|
25
|
-
:
|
|
36
|
+
? `${CONFIGURED_MESSAGE} Note: ${result.warning}`
|
|
37
|
+
: (result.message ?? CONFIGURED_MESSAGE);
|
|
26
38
|
return JSON.stringify({ ok: true, message });
|
|
27
39
|
}
|
|
28
40
|
// Bridge returned failure — surface as error, do NOT fall through
|
|
@@ -39,7 +51,7 @@ export function registerConfigureTools(server) {
|
|
|
39
51
|
setApiKey(key);
|
|
40
52
|
return JSON.stringify({
|
|
41
53
|
ok: true,
|
|
42
|
-
message:
|
|
54
|
+
message: CONFIGURED_MESSAGE,
|
|
43
55
|
});
|
|
44
56
|
}));
|
|
45
57
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
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, LONG_REQUEST_TIMEOUT_MS } from '../types.js';
|
|
6
|
+
import { withErrorHandling } from '../utils.js';
|
|
7
|
+
const dialogueInputSchema = z.object({
|
|
8
|
+
text: z.string().min(1).describe('Line of dialogue to speak.'),
|
|
9
|
+
voice_id: z.string().min(1).describe('Voice ID for this line (from list_voices).'),
|
|
10
|
+
});
|
|
11
|
+
export function registerDialogueTools(server) {
|
|
12
|
+
server.registerTool('text_to_dialogue', {
|
|
13
|
+
description: `Generate multi-voice dialogue audio from a script with one voice per line.
|
|
14
|
+
|
|
15
|
+
WHEN TO USE:
|
|
16
|
+
- Produce a conversation or script with different speakers
|
|
17
|
+
- Podcast-style back-and-forth with distinct voices per line
|
|
18
|
+
|
|
19
|
+
EXAMPLE: {"inputs": [{"text": "Hello there.", "voice_id": "21m00Tcm4TlvDq8ikWAM"}, {"text": "Hi!", "voice_id": "pNInz6obpgDQGcFmaJgB"}], "model_id": "eleven_v3"}
|
|
20
|
+
|
|
21
|
+
RELATED TOOLS:
|
|
22
|
+
- list_voices: pick voice_id values for each speaker
|
|
23
|
+
- generate_speech: single-voice TTS when you only need one narrator
|
|
24
|
+
- check_subscription: confirm credits before long scripts
|
|
25
|
+
|
|
26
|
+
RETURNS: file_path and size_bytes for the combined dialogue audio (tmp file).
|
|
27
|
+
|
|
28
|
+
COST: ~1 credit per 100 characters across all lines.`,
|
|
29
|
+
inputSchema: z.object({
|
|
30
|
+
inputs: z.array(dialogueInputSchema).min(1).describe('Ordered dialogue lines, each with text and voice_id.'),
|
|
31
|
+
model_id: z.enum([
|
|
32
|
+
'eleven_v3',
|
|
33
|
+
'eleven_multilingual_v2',
|
|
34
|
+
'eleven_flash_v2_5',
|
|
35
|
+
'eleven_turbo_v2_5',
|
|
36
|
+
]).optional().describe('TTS model. Default: eleven_v3.'),
|
|
37
|
+
language_code: z.string().optional().describe('ISO 639-1 language code (e.g. en, es). Optional hint for pronunciation.'),
|
|
38
|
+
seed: z.number().int().optional().describe('Optional seed for reproducible generation.'),
|
|
39
|
+
}),
|
|
40
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
41
|
+
}, withErrorHandling(async (args) => {
|
|
42
|
+
const apiKey = getApiKey();
|
|
43
|
+
if (!apiKey) {
|
|
44
|
+
throw new ElevenLabsError('ElevenLabs API key not configured', 'AUTH_REQUIRED', 'Ask the user for their API key, then call configure_elevenlabs_api_key.');
|
|
45
|
+
}
|
|
46
|
+
const modelId = args.model_id ?? 'eleven_v3';
|
|
47
|
+
const body = {
|
|
48
|
+
inputs: args.inputs,
|
|
49
|
+
model_id: modelId,
|
|
50
|
+
};
|
|
51
|
+
if (args.language_code)
|
|
52
|
+
body.language_code = args.language_code;
|
|
53
|
+
if (args.seed != null)
|
|
54
|
+
body.seed = args.seed;
|
|
55
|
+
const audio = await elevenLabsAudio(apiKey, ENDPOINTS.TEXT_TO_DIALOGUE, {
|
|
56
|
+
method: 'POST',
|
|
57
|
+
body: JSON.stringify(body),
|
|
58
|
+
timeoutMs: LONG_REQUEST_TIMEOUT_MS,
|
|
59
|
+
});
|
|
60
|
+
return JSON.stringify({
|
|
61
|
+
ok: true,
|
|
62
|
+
file_path: audio.filePath,
|
|
63
|
+
size_bytes: audio.sizeBytes,
|
|
64
|
+
line_count: args.inputs.length,
|
|
65
|
+
model_id: modelId,
|
|
66
|
+
message: `Dialogue audio saved to ${audio.filePath} (${audio.sizeBytes} bytes, ${args.inputs.length} lines).`,
|
|
67
|
+
});
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
70
|
+
//# sourceMappingURL=dialogue.js.map
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { getApiKey } from '../auth.js';
|
|
3
|
+
import { elevenLabsBinaryDownload, elevenLabsFetch, elevenLabsJson } from '../client.js';
|
|
4
|
+
import { ENDPOINTS } from '../endpoints.js';
|
|
5
|
+
import { ElevenLabsError, LONG_REQUEST_TIMEOUT_MS, } from '../types.js';
|
|
6
|
+
import { wrapUntrusted } from '../untrusted-content.js';
|
|
7
|
+
import { withErrorHandling } from '../utils.js';
|
|
8
|
+
import { readSandboxedFile, sandboxedFileToBlob } from './file-input.js';
|
|
9
|
+
const TERMINAL_DUBBING_STATUSES = new Set(['dubbed', 'failed', 'cancelled']);
|
|
10
|
+
const TERMINAL_STATUS_PHRASE = 'dubbed, failed, or cancelled';
|
|
11
|
+
function dubbingNextStep(status) {
|
|
12
|
+
if (status === 'dubbed') {
|
|
13
|
+
return 'Call download_dubbed_audio with dubbing_id and language_code.';
|
|
14
|
+
}
|
|
15
|
+
if (status === 'failed') {
|
|
16
|
+
return 'Inspect error_detail; fix source media or retry create_dubbing.';
|
|
17
|
+
}
|
|
18
|
+
if (status === 'cancelled') {
|
|
19
|
+
return 'Job was cancelled — do not poll further; submit a new create_dubbing if needed.';
|
|
20
|
+
}
|
|
21
|
+
return 'Poll get_dubbing again in ~10 seconds.';
|
|
22
|
+
}
|
|
23
|
+
function envelopDubbingStatusField(value, field) {
|
|
24
|
+
if (!value)
|
|
25
|
+
return undefined;
|
|
26
|
+
return wrapUntrusted(value, `elevenlabs:get_dubbing:${field}`);
|
|
27
|
+
}
|
|
28
|
+
export function registerDubbingTools(server) {
|
|
29
|
+
server.registerTool('create_dubbing', {
|
|
30
|
+
description: `Submit an async dubbing job (v1 API). You MUST poll get_dubbing until status is ${TERMINAL_STATUS_PHRASE}.
|
|
31
|
+
|
|
32
|
+
WHEN TO USE:
|
|
33
|
+
- Translate/dub existing audio or video into another language
|
|
34
|
+
- Localize a short clip the user already has on disk
|
|
35
|
+
|
|
36
|
+
EXAMPLE: {"file_path": "/path/in/workspace/clip.mp3", "target_lang": "es", "name": "rebel-live-test-dub"}
|
|
37
|
+
|
|
38
|
+
RELATED TOOLS:
|
|
39
|
+
- get_dubbing: poll job status (every ~10s; respect expected_duration_sec from this response)
|
|
40
|
+
- download_dubbed_audio: fetch audio once status is dubbed
|
|
41
|
+
- delete_dubbing: cleanup test jobs
|
|
42
|
+
|
|
43
|
+
RETURNS: dubbing_id and expected_duration_sec. The job runs server-side — poll get_dubbing; do not assume instant completion.
|
|
44
|
+
|
|
45
|
+
COST: Dubbing credits per minute of source media.`,
|
|
46
|
+
inputSchema: z.object({
|
|
47
|
+
target_lang: z.string().min(2).describe('Target language code (e.g. es, fr, de). Required.'),
|
|
48
|
+
file_path: z.string().optional().describe('Local audio/video file inside MCP_WORKSPACE_PATH (multipart field file).'),
|
|
49
|
+
source_url: z.string().url().optional().describe('Alternatively, a URL ElevenLabs fetches server-side (no local sandbox).'),
|
|
50
|
+
name: z.string().optional().describe('Optional job label (echoed by get_dubbing).'),
|
|
51
|
+
source_lang: z.string().optional().describe('Source language code when known (auto-detect when omitted).'),
|
|
52
|
+
num_speakers: z.number().int().min(1).optional().describe('Number of speakers when known.'),
|
|
53
|
+
watermark: z.boolean().optional().describe('Apply watermark when supported. Default: false.'),
|
|
54
|
+
}),
|
|
55
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
56
|
+
}, withErrorHandling(async (args) => {
|
|
57
|
+
const apiKey = getApiKey();
|
|
58
|
+
if (!apiKey) {
|
|
59
|
+
throw new ElevenLabsError('ElevenLabs API key not configured', 'AUTH_REQUIRED', 'Ask the user for their API key, then call configure_elevenlabs_api_key.');
|
|
60
|
+
}
|
|
61
|
+
if (!args.file_path && !args.source_url) {
|
|
62
|
+
throw new ElevenLabsError('Provide either file_path (local sandboxed file) or source_url (ElevenLabs-side fetch).', 'INVALID_INPUT', 'Pass file_path for a clip in MCP_WORKSPACE_PATH, or source_url for a remote asset.');
|
|
63
|
+
}
|
|
64
|
+
if (args.file_path && args.source_url) {
|
|
65
|
+
throw new ElevenLabsError('Provide only one of file_path or source_url, not both.', 'INVALID_INPUT', 'Choose a local file_path OR a remote source_url.');
|
|
66
|
+
}
|
|
67
|
+
const formData = new FormData();
|
|
68
|
+
formData.append('target_lang', args.target_lang);
|
|
69
|
+
if (args.name)
|
|
70
|
+
formData.append('name', args.name);
|
|
71
|
+
if (args.source_lang)
|
|
72
|
+
formData.append('source_lang', args.source_lang);
|
|
73
|
+
if (args.num_speakers != null)
|
|
74
|
+
formData.append('num_speakers', String(args.num_speakers));
|
|
75
|
+
formData.append('watermark', String(args.watermark ?? false));
|
|
76
|
+
if (args.file_path) {
|
|
77
|
+
const fileInput = readSandboxedFile(args.file_path);
|
|
78
|
+
formData.append('file', sandboxedFileToBlob(fileInput), fileInput.fileName);
|
|
79
|
+
}
|
|
80
|
+
else if (args.source_url) {
|
|
81
|
+
formData.append('source_url', args.source_url);
|
|
82
|
+
}
|
|
83
|
+
let data;
|
|
84
|
+
try {
|
|
85
|
+
data = await elevenLabsJson(apiKey, ENDPOINTS.DUBBING, { method: 'POST', body: formData, timeoutMs: LONG_REQUEST_TIMEOUT_MS });
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
if (error instanceof ElevenLabsError && error.code === 'TIMEOUT') {
|
|
89
|
+
throw new ElevenLabsError(error.message, 'TIMEOUT', 'The submit may have timed out after uploading, but the job might still be processing. Call get_dubbing with the dubbing_id if you have one, or check recent jobs in the ElevenLabs dashboard before resubmitting to avoid duplicate jobs.');
|
|
90
|
+
}
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
const expectedSec = data.expected_duration_sec;
|
|
94
|
+
return JSON.stringify({
|
|
95
|
+
ok: true,
|
|
96
|
+
dubbing_id: data.dubbing_id,
|
|
97
|
+
expected_duration_sec: expectedSec,
|
|
98
|
+
target_lang: args.target_lang,
|
|
99
|
+
message: `Dubbing job ${data.dubbing_id} submitted. You MUST poll get_dubbing every ~10 seconds until status is ${TERMINAL_STATUS_PHRASE}${expectedSec != null ? ` (expected ~${expectedSec}s)` : ''}.`,
|
|
100
|
+
poll_hint: `Call get_dubbing with this dubbing_id until status is ${TERMINAL_STATUS_PHRASE}; when dubbed, call download_dubbed_audio.`,
|
|
101
|
+
});
|
|
102
|
+
}));
|
|
103
|
+
server.registerTool('get_dubbing', {
|
|
104
|
+
description: `Poll dubbing job status. Call repeatedly after create_dubbing until status is ${TERMINAL_STATUS_PHRASE}.
|
|
105
|
+
|
|
106
|
+
WHEN TO USE:
|
|
107
|
+
- After create_dubbing — poll every ~10s (respect expected_duration_sec)
|
|
108
|
+
- Check whether a dub failed before retrying
|
|
109
|
+
|
|
110
|
+
EXAMPLE: {"dubbing_id": "dub_abc123"}
|
|
111
|
+
|
|
112
|
+
RELATED TOOLS:
|
|
113
|
+
- create_dubbing: submit the job
|
|
114
|
+
- download_dubbed_audio: fetch audio when status is dubbed
|
|
115
|
+
- delete_dubbing: remove test jobs
|
|
116
|
+
|
|
117
|
+
RETURNS: status (verbatim, incl. failed), enveloped name and error detail when present.
|
|
118
|
+
|
|
119
|
+
COST: FREE — status read only.`,
|
|
120
|
+
inputSchema: z.object({
|
|
121
|
+
dubbing_id: z.string().min(1).describe('Dubbing job ID from create_dubbing.'),
|
|
122
|
+
}),
|
|
123
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
124
|
+
}, withErrorHandling(async (args) => {
|
|
125
|
+
const apiKey = getApiKey();
|
|
126
|
+
if (!apiKey) {
|
|
127
|
+
throw new ElevenLabsError('ElevenLabs API key not configured', 'AUTH_REQUIRED', 'Ask the user for their API key, then call configure_elevenlabs_api_key.');
|
|
128
|
+
}
|
|
129
|
+
let data;
|
|
130
|
+
try {
|
|
131
|
+
data = await elevenLabsJson(apiKey, ENDPOINTS.dubbing(args.dubbing_id));
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
if (error instanceof ElevenLabsError && error.code === 'HTTP_404') {
|
|
135
|
+
throw new ElevenLabsError(`Dubbing job not found: ${args.dubbing_id}`, 'DUBBING_NOT_FOUND', 'Verify the dubbing_id from create_dubbing or list recent jobs in the ElevenLabs dashboard.');
|
|
136
|
+
}
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
const status = data.status;
|
|
140
|
+
const errorDetail = data.error_message ?? data.error;
|
|
141
|
+
const isTerminal = TERMINAL_DUBBING_STATUSES.has(status);
|
|
142
|
+
return JSON.stringify({
|
|
143
|
+
ok: true,
|
|
144
|
+
dubbing_id: data.dubbing_id ?? args.dubbing_id,
|
|
145
|
+
status,
|
|
146
|
+
name: envelopDubbingStatusField(data.name, 'name'),
|
|
147
|
+
target_languages: data.target_languages,
|
|
148
|
+
error_detail: envelopDubbingStatusField(errorDetail, 'error_detail'),
|
|
149
|
+
is_terminal: isTerminal,
|
|
150
|
+
message: isTerminal
|
|
151
|
+
? `Dubbing ${args.dubbing_id} reached terminal status: ${status}.`
|
|
152
|
+
: `Dubbing ${args.dubbing_id} status: ${status}. Keep polling get_dubbing until ${TERMINAL_STATUS_PHRASE}.`,
|
|
153
|
+
next_step: dubbingNextStep(status),
|
|
154
|
+
});
|
|
155
|
+
}));
|
|
156
|
+
server.registerTool('download_dubbed_audio', {
|
|
157
|
+
description: `Download dubbed audio for a completed dubbing job.
|
|
158
|
+
|
|
159
|
+
WHEN TO USE:
|
|
160
|
+
- After get_dubbing reports status dubbed
|
|
161
|
+
- Fetch the localized track for a target language
|
|
162
|
+
|
|
163
|
+
EXAMPLE: {"dubbing_id": "dub_abc123", "language_code": "es"}
|
|
164
|
+
|
|
165
|
+
RELATED TOOLS:
|
|
166
|
+
- get_dubbing: confirm status is dubbed before downloading
|
|
167
|
+
- delete_dubbing: cleanup after testing
|
|
168
|
+
|
|
169
|
+
RETURNS: file_path and size_bytes (extension sniffed from Content-Type).
|
|
170
|
+
|
|
171
|
+
COST: FREE — download only (generation credits charged at submit).`,
|
|
172
|
+
inputSchema: z.object({
|
|
173
|
+
dubbing_id: z.string().min(1).describe('Dubbing job ID.'),
|
|
174
|
+
language_code: z.string().min(2).describe('Target language code used when creating the dub (e.g. es).'),
|
|
175
|
+
}),
|
|
176
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
177
|
+
}, withErrorHandling(async (args) => {
|
|
178
|
+
const apiKey = getApiKey();
|
|
179
|
+
if (!apiKey) {
|
|
180
|
+
throw new ElevenLabsError('ElevenLabs API key not configured', 'AUTH_REQUIRED', 'Ask the user for their API key, then call configure_elevenlabs_api_key.');
|
|
181
|
+
}
|
|
182
|
+
const audio = await elevenLabsBinaryDownload(apiKey, ENDPOINTS.dubbingAudio(args.dubbing_id, args.language_code));
|
|
183
|
+
return JSON.stringify({
|
|
184
|
+
ok: true,
|
|
185
|
+
file_path: audio.filePath,
|
|
186
|
+
size_bytes: audio.sizeBytes,
|
|
187
|
+
dubbing_id: args.dubbing_id,
|
|
188
|
+
language_code: args.language_code,
|
|
189
|
+
message: `Dubbed audio saved to ${audio.filePath} (${audio.sizeBytes} bytes).`,
|
|
190
|
+
});
|
|
191
|
+
}));
|
|
192
|
+
server.registerTool('delete_dubbing', {
|
|
193
|
+
description: `Permanently delete a dubbing job and its outputs.
|
|
194
|
+
|
|
195
|
+
WHEN TO USE:
|
|
196
|
+
- Cleanup rebel-live-test-* dubbing jobs after live tests
|
|
197
|
+
- Remove a failed or unwanted dub from the account
|
|
198
|
+
|
|
199
|
+
EXAMPLE: {"dubbing_id": "dub_abc123"}
|
|
200
|
+
|
|
201
|
+
RELATED TOOLS:
|
|
202
|
+
- create_dubbing / get_dubbing / download_dubbed_audio: the dubbing lifecycle
|
|
203
|
+
|
|
204
|
+
RETURNS: ok confirmation. Irreversible.
|
|
205
|
+
|
|
206
|
+
COST: FREE — no generation; permanently removes the dubbing job.`,
|
|
207
|
+
inputSchema: z.object({
|
|
208
|
+
dubbing_id: z.string().min(1).describe('Dubbing job ID to delete.'),
|
|
209
|
+
}),
|
|
210
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
211
|
+
}, withErrorHandling(async (args) => {
|
|
212
|
+
const apiKey = getApiKey();
|
|
213
|
+
if (!apiKey) {
|
|
214
|
+
throw new ElevenLabsError('ElevenLabs API key not configured', 'AUTH_REQUIRED', 'Ask the user for their API key, then call configure_elevenlabs_api_key.');
|
|
215
|
+
}
|
|
216
|
+
try {
|
|
217
|
+
await elevenLabsFetch(apiKey, ENDPOINTS.dubbing(args.dubbing_id), {
|
|
218
|
+
method: 'DELETE',
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
catch (error) {
|
|
222
|
+
if (error instanceof ElevenLabsError && error.code === 'HTTP_404') {
|
|
223
|
+
throw new ElevenLabsError(`Dubbing job not found: ${args.dubbing_id}`, 'DUBBING_NOT_FOUND', 'The job may already be deleted.');
|
|
224
|
+
}
|
|
225
|
+
throw error;
|
|
226
|
+
}
|
|
227
|
+
return JSON.stringify({
|
|
228
|
+
ok: true,
|
|
229
|
+
dubbing_id: args.dubbing_id,
|
|
230
|
+
message: `Dubbing job ${args.dubbing_id} deleted permanently.`,
|
|
231
|
+
});
|
|
232
|
+
}));
|
|
233
|
+
}
|
|
234
|
+
//# sourceMappingURL=dubbing.js.map
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export interface SandboxedFileInput {
|
|
2
|
+
buffer: Buffer;
|
|
3
|
+
fileName: string;
|
|
4
|
+
verifiedPath: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Read bytes from a local file path constrained to the MCP_WORKSPACE_PATH
|
|
8
|
+
* sandbox (or os.tmpdir() when unset).
|
|
9
|
+
*
|
|
10
|
+
* Security invariants (R1 — see planning doc Refactor Assessment):
|
|
11
|
+
* 1. Remote URL inputs bypass the sandbox → FILE_NOT_FOUND at existsSync.
|
|
12
|
+
* 2. Out-of-root lexical path → PATH_SANDBOX_VIOLATION before any disk read.
|
|
13
|
+
* 3. In-root-but-missing file → FILE_NOT_FOUND.
|
|
14
|
+
* 4. Symlink inside root pointing outside root → refused.
|
|
15
|
+
* 5. Last-moment realpathSync re-canonicalisation before read (TOCTOU).
|
|
16
|
+
*
|
|
17
|
+
* Callers own multipart field naming (`file` vs `audio`); this helper
|
|
18
|
+
* returns `{ buffer, fileName, verifiedPath }` only.
|
|
19
|
+
*/
|
|
20
|
+
export declare function readSandboxedFile(rawFilePath: string): SandboxedFileInput;
|
|
21
|
+
/**
|
|
22
|
+
* Resolve a MIME type from a file name extension for multipart uploads.
|
|
23
|
+
* Defaults to audio/mpeg when the extension is unknown (most uploads are audio).
|
|
24
|
+
*/
|
|
25
|
+
export declare function mimeTypeForFileName(fileName: string): string;
|
|
26
|
+
/** Build a typed Blob from sandboxed file bytes for FormData multipart parts. */
|
|
27
|
+
export declare function sandboxedFileToBlob({ buffer, fileName }: SandboxedFileInput): Blob;
|
|
28
|
+
//# sourceMappingURL=file-input.d.ts.map
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { ElevenLabsError } from '../types.js';
|
|
4
|
+
import { getAudioWorkspaceRoot, isInsideAudioWorkspaceRoot, isRemoteUrl, resolveAudioPath } from './path-safety.js';
|
|
5
|
+
const FILE_INPUT_RESOLUTION = 'Provide a path to an existing audio file inside MCP_WORKSPACE_PATH (or os.tmpdir() when unset). Remote URLs are not supported.';
|
|
6
|
+
/**
|
|
7
|
+
* Read bytes from a local file path constrained to the MCP_WORKSPACE_PATH
|
|
8
|
+
* sandbox (or os.tmpdir() when unset).
|
|
9
|
+
*
|
|
10
|
+
* Security invariants (R1 — see planning doc Refactor Assessment):
|
|
11
|
+
* 1. Remote URL inputs bypass the sandbox → FILE_NOT_FOUND at existsSync.
|
|
12
|
+
* 2. Out-of-root lexical path → PATH_SANDBOX_VIOLATION before any disk read.
|
|
13
|
+
* 3. In-root-but-missing file → FILE_NOT_FOUND.
|
|
14
|
+
* 4. Symlink inside root pointing outside root → refused.
|
|
15
|
+
* 5. Last-moment realpathSync re-canonicalisation before read (TOCTOU).
|
|
16
|
+
*
|
|
17
|
+
* Callers own multipart field naming (`file` vs `audio`); this helper
|
|
18
|
+
* returns `{ buffer, fileName, verifiedPath }` only.
|
|
19
|
+
*/
|
|
20
|
+
export function readSandboxedFile(rawFilePath) {
|
|
21
|
+
let filePath;
|
|
22
|
+
if (isRemoteUrl(rawFilePath)) {
|
|
23
|
+
// Preserve pre-existing behaviour for URL inputs: existsSync below
|
|
24
|
+
// returns FILE_NOT_FOUND (non-sandbox error code).
|
|
25
|
+
filePath = rawFilePath;
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
const resolution = resolveAudioPath(rawFilePath);
|
|
29
|
+
if (!resolution.ok) {
|
|
30
|
+
const code = resolution.error.startsWith('File not found')
|
|
31
|
+
? 'FILE_NOT_FOUND'
|
|
32
|
+
: 'PATH_SANDBOX_VIOLATION';
|
|
33
|
+
throw new ElevenLabsError(resolution.error, code, FILE_INPUT_RESOLUTION);
|
|
34
|
+
}
|
|
35
|
+
filePath = resolution.path;
|
|
36
|
+
}
|
|
37
|
+
if (!fs.existsSync(filePath)) {
|
|
38
|
+
throw new ElevenLabsError(`File not found: ${rawFilePath}`, 'FILE_NOT_FOUND', 'Provide an absolute path to an existing audio file.');
|
|
39
|
+
}
|
|
40
|
+
// Defence-in-depth: re-canonicalise via realpathSync at the very last moment
|
|
41
|
+
// to close the TOCTOU window between sandbox validation and readFileSync.
|
|
42
|
+
const verifiedPath = isRemoteUrl(rawFilePath) ? filePath : fs.realpathSync(filePath);
|
|
43
|
+
if (!isRemoteUrl(rawFilePath)) {
|
|
44
|
+
const root = getAudioWorkspaceRoot();
|
|
45
|
+
if (!isInsideAudioWorkspaceRoot(verifiedPath, root)) {
|
|
46
|
+
throw new ElevenLabsError(`file_path resolves outside the workspace sandbox root (${root}); ` +
|
|
47
|
+
`symlinks may not escape the workspace. Got: ${rawFilePath}`, 'PATH_SANDBOX_VIOLATION', FILE_INPUT_RESOLUTION);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const buffer = fs.readFileSync(verifiedPath);
|
|
51
|
+
const fileName = path.basename(verifiedPath);
|
|
52
|
+
return { buffer, fileName, verifiedPath };
|
|
53
|
+
}
|
|
54
|
+
/** Extension → MIME for multipart uploads (ElevenLabs rejects application/octet-stream). */
|
|
55
|
+
const EXTENSION_TO_MIME = {
|
|
56
|
+
mp3: 'audio/mpeg',
|
|
57
|
+
wav: 'audio/wav',
|
|
58
|
+
m4a: 'audio/mp4',
|
|
59
|
+
ogg: 'audio/ogg',
|
|
60
|
+
flac: 'audio/flac',
|
|
61
|
+
webm: 'video/webm',
|
|
62
|
+
mp4: 'video/mp4',
|
|
63
|
+
mov: 'video/quicktime',
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* Resolve a MIME type from a file name extension for multipart uploads.
|
|
67
|
+
* Defaults to audio/mpeg when the extension is unknown (most uploads are audio).
|
|
68
|
+
*/
|
|
69
|
+
export function mimeTypeForFileName(fileName) {
|
|
70
|
+
const ext = path.extname(fileName).slice(1).toLowerCase();
|
|
71
|
+
return EXTENSION_TO_MIME[ext] ?? 'audio/mpeg';
|
|
72
|
+
}
|
|
73
|
+
/** Build a typed Blob from sandboxed file bytes for FormData multipart parts. */
|
|
74
|
+
export function sandboxedFileToBlob({ buffer, fileName }) {
|
|
75
|
+
return new Blob([new Uint8Array(buffer)], { type: mimeTypeForFileName(fileName) });
|
|
76
|
+
}
|
|
77
|
+
//# sourceMappingURL=file-input.js.map
|
package/dist/tools/index.d.ts
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
export { registerConfigureTools } from './configure.js';
|
|
2
|
+
export { registerAccountTools } from './account.js';
|
|
2
3
|
export { registerMusicTools } from './music.js';
|
|
3
4
|
export { registerSpeechTools } from './speech.js';
|
|
4
5
|
export { registerVoiceTools } from './voices.js';
|
|
5
6
|
export { registerTranscriptionTools } from './transcription.js';
|
|
7
|
+
export { registerVoiceChangerTools } from './voice-changer.js';
|
|
8
|
+
export { registerAudioIsolationTools } from './audio-isolation.js';
|
|
9
|
+
export { registerAlignmentTools } from './alignment.js';
|
|
10
|
+
export { registerVoiceCloneTools } from './voice-clone.js';
|
|
11
|
+
export { registerDialogueTools } from './dialogue.js';
|
|
12
|
+
export { registerVoiceDesignTools } from './voice-design.js';
|
|
13
|
+
export { registerDubbingTools } from './dubbing.js';
|
|
6
14
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/tools/index.js
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
export { registerConfigureTools } from './configure.js';
|
|
2
|
+
export { registerAccountTools } from './account.js';
|
|
2
3
|
export { registerMusicTools } from './music.js';
|
|
3
4
|
export { registerSpeechTools } from './speech.js';
|
|
4
5
|
export { registerVoiceTools } from './voices.js';
|
|
5
6
|
export { registerTranscriptionTools } from './transcription.js';
|
|
7
|
+
export { registerVoiceChangerTools } from './voice-changer.js';
|
|
8
|
+
export { registerAudioIsolationTools } from './audio-isolation.js';
|
|
9
|
+
export { registerAlignmentTools } from './alignment.js';
|
|
10
|
+
export { registerVoiceCloneTools } from './voice-clone.js';
|
|
11
|
+
export { registerDialogueTools } from './dialogue.js';
|
|
12
|
+
export { registerVoiceDesignTools } from './voice-design.js';
|
|
13
|
+
export { registerDubbingTools } from './dubbing.js';
|
|
6
14
|
//# sourceMappingURL=index.js.map
|