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