@mindstone/mcp-server-elevenlabs 0.2.2 → 0.3.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 +41 -0
- package/dist/client.js +76 -24
- package/dist/tools/music.js +81 -16
- package/dist/tools/speech.js +43 -20
- package/dist/tools/transcription.js +9 -2
- package/dist/types.d.ts +19 -13
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,11 +5,52 @@
|
|
|
5
5
|
|
|
6
6
|
ElevenLabs MCP server for Model Context Protocol hosts. Generate speech, music, and sound effects, browse voices, and transcribe audio using the ElevenLabs API through a standardised MCP interface.
|
|
7
7
|
|
|
8
|
+
## Status
|
|
9
|
+
|
|
10
|
+
- **Version:** [0.3.0](./CHANGELOG.md) · [npm](https://www.npmjs.com/package/@mindstone/mcp-server-elevenlabs)
|
|
11
|
+
- **Auth:** API key ([`ELEVENLABS_API_KEY`](./server.json))
|
|
12
|
+
- **Tools:** [8](./src/tools/) (voices, speech, music, transcription)
|
|
13
|
+
- **Surface:** cloud-api
|
|
14
|
+
- **Hosts tested:** Claude Desktop, Cursor, Mindstone Rebel
|
|
15
|
+
- **Machine-readable:** [`STATUS.json`](./STATUS.json)
|
|
16
|
+
|
|
8
17
|
## Requirements
|
|
9
18
|
|
|
10
19
|
- Node.js 20+
|
|
11
20
|
- npm
|
|
12
21
|
|
|
22
|
+
<!-- BEGIN INSTALL_LINKS: do not edit by hand; regenerated by scripts/gen-install-links.mjs -->
|
|
23
|
+
## One-click install
|
|
24
|
+
|
|
25
|
+
[](cursor://anysphere.cursor-deeplink/mcp/install?name=ElevenLabs&config=eyJ0eXBlIjoic3RkaW8iLCJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBtaW5kc3RvbmUvbWNwLXNlcnZlci1lbGV2ZW5sYWJzIl0sImVudiI6eyJFTEVWRU5MQUJTX0FQSV9LRVkiOiIifX0)
|
|
26
|
+
[](vscode:mcp/install?%7B%22name%22%3A%22ElevenLabs%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40mindstone%2Fmcp-server-elevenlabs%22%5D%2C%22env%22%3A%7B%22ELEVENLABS_API_KEY%22%3A%22%22%7D%7D)
|
|
27
|
+
[](vscode-insiders:mcp/install?%7B%22name%22%3A%22ElevenLabs%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40mindstone%2Fmcp-server-elevenlabs%22%5D%2C%22env%22%3A%7B%22ELEVENLABS_API_KEY%22%3A%22%22%7D%7D)
|
|
28
|
+
|
|
29
|
+
After clicking the button, your host will prompt you to fill: `ELEVENLABS_API_KEY`.
|
|
30
|
+
|
|
31
|
+
<details>
|
|
32
|
+
<summary>Manual config for Claude Desktop / Claude Code / Goose / Continue.dev (ElevenLabs)</summary>
|
|
33
|
+
|
|
34
|
+
```json
|
|
35
|
+
{
|
|
36
|
+
"mcpServers": {
|
|
37
|
+
"ElevenLabs": {
|
|
38
|
+
"command": "npx",
|
|
39
|
+
"args": [
|
|
40
|
+
"-y",
|
|
41
|
+
"@mindstone/mcp-server-elevenlabs"
|
|
42
|
+
],
|
|
43
|
+
"env": {
|
|
44
|
+
"ELEVENLABS_API_KEY": ""
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
</details>
|
|
52
|
+
<!-- END INSTALL_LINKS -->
|
|
53
|
+
|
|
13
54
|
## Quick Start
|
|
14
55
|
|
|
15
56
|
### Install & build
|
package/dist/client.js
CHANGED
|
@@ -52,41 +52,93 @@ export async function elevenLabsFetch(apiKey, urlPath, options = {}) {
|
|
|
52
52
|
if (response.status === 429) {
|
|
53
53
|
throw new ElevenLabsError('Rate limited. Please wait a moment before retrying.', 'RATE_LIMITED', getErrorResolution(429));
|
|
54
54
|
}
|
|
55
|
-
// Handle auth errors
|
|
55
|
+
// Handle auth errors. ElevenLabs returns 401 both for genuinely invalid
|
|
56
|
+
// keys AND for valid keys missing a specific scope (e.g. sound_generation).
|
|
57
|
+
// The latter ships a `detail.status = "missing_permissions"` payload that
|
|
58
|
+
// the user needs to see in order to fix the key permissions; previously we
|
|
59
|
+
// threw it away and emitted a bare "Authentication failed".
|
|
56
60
|
if (response.status === 401) {
|
|
57
|
-
|
|
61
|
+
const detail = await extractErrorDetail(response);
|
|
62
|
+
const isMissingPermission = detail.includes('missing_permissions');
|
|
63
|
+
throw new ElevenLabsError(isMissingPermission
|
|
64
|
+
? `API key missing required permission: ${detail}`
|
|
65
|
+
: detail
|
|
66
|
+
? `Authentication failed: ${detail}`
|
|
67
|
+
: 'Authentication failed', isMissingPermission ? 'MISSING_PERMISSION' : 'AUTH_FAILED', isMissingPermission
|
|
68
|
+
? 'Regenerate your API key at https://elevenlabs.io/app/settings/api-keys with the missing permission enabled, then call configure_elevenlabs_api_key again.'
|
|
69
|
+
: getErrorResolution(401));
|
|
58
70
|
}
|
|
59
71
|
if (response.status === 403) {
|
|
60
|
-
|
|
61
|
-
try {
|
|
62
|
-
const errBody = await response.clone().json();
|
|
63
|
-
if (typeof errBody.detail === 'string') {
|
|
64
|
-
detail = errBody.detail;
|
|
65
|
-
}
|
|
66
|
-
else if (errBody.detail?.message) {
|
|
67
|
-
detail = errBody.detail.message;
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
catch { /* not JSON */ }
|
|
72
|
+
const detail = await extractErrorDetail(response);
|
|
71
73
|
throw new ElevenLabsError(`Access forbidden: ${detail || 'insufficient permissions or quota'}`, 'AUTH_FAILED', getErrorResolution(403, detail));
|
|
72
74
|
}
|
|
73
75
|
// Handle other errors
|
|
74
76
|
if (!response.ok) {
|
|
75
|
-
|
|
76
|
-
try {
|
|
77
|
-
const errBody = await response.clone().json();
|
|
78
|
-
if (typeof errBody.detail === 'string') {
|
|
79
|
-
detail = errBody.detail;
|
|
80
|
-
}
|
|
81
|
-
else if (errBody.detail?.message) {
|
|
82
|
-
detail = errBody.detail.message;
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
catch { /* not JSON */ }
|
|
77
|
+
const detail = await extractErrorDetail(response);
|
|
86
78
|
throw new ElevenLabsError(`ElevenLabs API error (HTTP ${response.status}): ${detail || response.statusText}`, `HTTP_${response.status}`, getErrorResolution(response.status, detail));
|
|
87
79
|
}
|
|
88
80
|
return response;
|
|
89
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* Extract a human-readable detail message from an ElevenLabs error response.
|
|
84
|
+
*
|
|
85
|
+
* The API uses several detail shapes:
|
|
86
|
+
* - `detail: "string message"` (legacy)
|
|
87
|
+
* - `detail: { message: "...", status?: "..." }` (current auth/permission errors)
|
|
88
|
+
* - `detail: [{ type, loc: ["body", "field", ...], msg, input }, ...]` (FastAPI 422)
|
|
89
|
+
*
|
|
90
|
+
* The 422 array form is the one that bit us in
|
|
91
|
+
* `generate_music_from_plan`: previously we threw away the field-level info
|
|
92
|
+
* and left the user with `HTTP 422: unknown`. We now flatten the validation
|
|
93
|
+
* errors into something like
|
|
94
|
+
* `body.composition_plan.sections.0.section_name: Field required;
|
|
95
|
+
* body.composition_plan.sections.0.lines: Field required`
|
|
96
|
+
* which is what an LLM agent actually needs to self-correct.
|
|
97
|
+
*/
|
|
98
|
+
async function extractErrorDetail(response) {
|
|
99
|
+
try {
|
|
100
|
+
const errBody = (await response.clone().json());
|
|
101
|
+
const d = errBody?.detail;
|
|
102
|
+
if (d == null)
|
|
103
|
+
return '';
|
|
104
|
+
if (typeof d === 'string')
|
|
105
|
+
return d;
|
|
106
|
+
if (Array.isArray(d)) {
|
|
107
|
+
// FastAPI validation errors → "loc.path: msg" joined by "; ".
|
|
108
|
+
// Skip null/non-object entries defensively rather than throwing.
|
|
109
|
+
const parts = d
|
|
110
|
+
.map((e) => {
|
|
111
|
+
if (!e || typeof e !== 'object')
|
|
112
|
+
return '';
|
|
113
|
+
const entry = e;
|
|
114
|
+
const loc = Array.isArray(entry.loc) ? entry.loc.join('.') : '';
|
|
115
|
+
const msg = typeof entry.msg === 'string'
|
|
116
|
+
? entry.msg
|
|
117
|
+
: typeof entry.type === 'string'
|
|
118
|
+
? entry.type
|
|
119
|
+
: 'invalid';
|
|
120
|
+
return loc ? `${loc}: ${msg}` : msg;
|
|
121
|
+
})
|
|
122
|
+
.filter((s) => s.length > 0);
|
|
123
|
+
return parts.join('; ');
|
|
124
|
+
}
|
|
125
|
+
if (typeof d === 'object') {
|
|
126
|
+
const o = d;
|
|
127
|
+
const parts = [];
|
|
128
|
+
if (typeof o.status === 'string')
|
|
129
|
+
parts.push(o.status);
|
|
130
|
+
if (typeof o.message === 'string')
|
|
131
|
+
parts.push(o.message);
|
|
132
|
+
if (typeof o.cause === 'string')
|
|
133
|
+
parts.push(`cause: ${o.cause}`);
|
|
134
|
+
return parts.join(' — ');
|
|
135
|
+
}
|
|
136
|
+
return '';
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return '';
|
|
140
|
+
}
|
|
141
|
+
}
|
|
90
142
|
/**
|
|
91
143
|
* Make a JSON API call and parse the response.
|
|
92
144
|
*/
|
package/dist/tools/music.js
CHANGED
|
@@ -8,17 +8,76 @@ const OUTPUT_FORMAT_ENUM = z.enum([
|
|
|
8
8
|
'pcm_16000', 'pcm_22050', 'pcm_24000', 'pcm_44100',
|
|
9
9
|
'ulaw_8000',
|
|
10
10
|
]).optional();
|
|
11
|
+
// Matches lyric section markers on their own line, with optional `]`, to
|
|
12
|
+
// avoid false-positives on prose like `[intro to jazz]`.
|
|
13
|
+
const LYRIC_MARKER_PATTERN = /(^|\n)\s*\[(verse|chorus|bridge|intro|outro|pre[- ]?chorus|hook|refrain)(\s\d+)?\s*\]/i;
|
|
14
|
+
const MIN_TOTAL_MUSIC_MS = 3_000;
|
|
15
|
+
const MAX_TOTAL_MUSIC_MS = 10 * 60 * 1_000; // 10 minutes per the API contract.
|
|
16
|
+
/**
|
|
17
|
+
* Composition section schema — matches the live ElevenLabs API shape.
|
|
18
|
+
* Field names are NOT cosmetic: the API enforces these exact names with
|
|
19
|
+
* `additionalProperties: false`, so renaming any of them breaks generation.
|
|
20
|
+
*
|
|
21
|
+
* `positive_local_styles`, `negative_local_styles`, and `lines` are all
|
|
22
|
+
* required by the API even when empty. We accept them as optional in the
|
|
23
|
+
* input schema but `.default([])` them so a hand-written plan that omits an
|
|
24
|
+
* empty array still passes the API.
|
|
25
|
+
*
|
|
26
|
+
* `.strict()` rejects unknown keys — important because the prior connector
|
|
27
|
+
* version accepted a `{style, lyrics, ...}` shape, and we want to error fast
|
|
28
|
+
* if an LLM agent or older caller sends those (rather than silently strip
|
|
29
|
+
* them and ship a 422 from upstream). See planning doc
|
|
30
|
+
* 260520_elevenlabs_oss_connector_fix.md.
|
|
31
|
+
*/
|
|
32
|
+
const COMPOSITION_SECTION_SCHEMA = z.object({
|
|
33
|
+
section_name: z.string().min(1).max(100).describe('Section label like "Verse 1", "Chorus", "Bridge".'),
|
|
34
|
+
duration_ms: z.number().min(3000).max(120_000).describe('Section duration in milliseconds (3000-120000).'),
|
|
35
|
+
positive_local_styles: z.array(z.string()).max(50).default([])
|
|
36
|
+
.describe('Section-specific styles to include (max 50). Use for stage directions like "whispered vocals" or "male voice"; do NOT put these in lyrics. Pass [] for sections that have no special direction.'),
|
|
37
|
+
negative_local_styles: z.array(z.string()).max(50).default([])
|
|
38
|
+
.describe('Section-specific styles to avoid (max 50). Pass [] when nothing to avoid.'),
|
|
39
|
+
lines: z.array(z.string().max(200)).max(30).default([])
|
|
40
|
+
.describe('Lyric lines for this section (max 30 lines, 200 chars each). Singable/speakable text only — performance directions belong in positive_local_styles. Pass [] for instrumental sections.'),
|
|
41
|
+
}).strict();
|
|
42
|
+
const COMPOSITION_PLAN_SCHEMA = z.object({
|
|
43
|
+
positive_global_styles: z.array(z.string()).optional()
|
|
44
|
+
.describe('Styles to apply globally (genre, mood, tempo, key, BPM).'),
|
|
45
|
+
negative_global_styles: z.array(z.string()).optional()
|
|
46
|
+
.describe('Styles to avoid globally.'),
|
|
47
|
+
sections: z.array(COMPOSITION_SECTION_SCHEMA).min(1).max(30)
|
|
48
|
+
.describe('Array of sections (max 30). Total duration must be between 3 seconds and 10 minutes.'),
|
|
49
|
+
})
|
|
50
|
+
.strict()
|
|
51
|
+
.superRefine((plan, ctx) => {
|
|
52
|
+
const total = plan.sections.reduce((acc, s) => acc + (s.duration_ms || 0), 0);
|
|
53
|
+
if (total < MIN_TOTAL_MUSIC_MS || total > MAX_TOTAL_MUSIC_MS) {
|
|
54
|
+
ctx.addIssue({
|
|
55
|
+
code: z.ZodIssueCode.custom,
|
|
56
|
+
path: ['sections'],
|
|
57
|
+
message: `Total duration across sections must be between ${MIN_TOTAL_MUSIC_MS}ms (3s) and ${MAX_TOTAL_MUSIC_MS}ms (10min); got ${total}ms.`,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
.describe('Composition plan object — must match the shape returned by create_music_plan exactly. ' +
|
|
62
|
+
'Pass the plan through verbatim or edit individual fields. The legacy {style, lyrics} ' +
|
|
63
|
+
'shape used by ≤0.2.2 is no longer accepted (the API rejects it with HTTP 422).');
|
|
11
64
|
export function registerMusicTools(server) {
|
|
12
65
|
// ── generate_music ────────────────────────────────────────────────────
|
|
13
66
|
server.registerTool('generate_music', {
|
|
14
67
|
description: 'Generate music from a text prompt using ElevenLabs Music API. ' +
|
|
15
68
|
'Returns a saved audio file path. DURATION: 3-600 seconds (default 30). ' +
|
|
16
|
-
'COST: Consumes credits based on duration
|
|
17
|
-
'PROMPT TIPS
|
|
69
|
+
'COST: Consumes credits based on duration.\n\n' +
|
|
70
|
+
'PROMPT TIPS — to get a song WITH VOCALS:\n' +
|
|
71
|
+
' 1. Do NOT set force_instrumental: true (it silently drops all vocals).\n' +
|
|
72
|
+
' 2. Embed lyrics in the prompt using ElevenLabs section markers: ' +
|
|
73
|
+
'`[Verse 1] ... [Chorus] ... [Bridge] ...`. Each marker is on its own ' +
|
|
74
|
+
'line followed by the lines for that section.\n' +
|
|
75
|
+
'For fine-grained control (per-section style + lyrics), use create_music_plan ' +
|
|
76
|
+
'then generate_music_from_plan instead.',
|
|
18
77
|
inputSchema: z.object({
|
|
19
|
-
prompt: z.string().min(1).describe('Describe the music: genre, mood, instruments, style
|
|
78
|
+
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.'),
|
|
20
79
|
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.'),
|
|
80
|
+
force_instrumental: z.boolean().optional().describe('Force instrumental-only output (no vocals). Default: false. WARNING: setting this to true overrides any lyrics in the prompt — do not enable for vocal songs.'),
|
|
22
81
|
output_format: OUTPUT_FORMAT_ENUM.describe('Audio output format. Default: mp3_44100_128.'),
|
|
23
82
|
seed: z.number().int().optional().describe('Random seed for reproducibility.'),
|
|
24
83
|
}),
|
|
@@ -28,6 +87,15 @@ export function registerMusicTools(server) {
|
|
|
28
87
|
if (!apiKey) {
|
|
29
88
|
throw new ElevenLabsError('ElevenLabs API key not configured', 'AUTH_REQUIRED', 'Ask the user for their API key, then call configure_elevenlabs_api_key.');
|
|
30
89
|
}
|
|
90
|
+
// Pre-call warning: force_instrumental + lyric markers means vocals get
|
|
91
|
+
// silently dropped. This bit a real conversation (260520) — emit a
|
|
92
|
+
// warning so the agent can self-correct rather than ship instrumental.
|
|
93
|
+
const warnings = [];
|
|
94
|
+
if (args.force_instrumental === true && LYRIC_MARKER_PATTERN.test(args.prompt)) {
|
|
95
|
+
warnings.push('force_instrumental: true was set, but the prompt contains lyric section ' +
|
|
96
|
+
'markers like [Verse]/[Chorus]. Vocals will be dropped. ' +
|
|
97
|
+
'Set force_instrumental: false (or omit it) to keep the vocal performance.');
|
|
98
|
+
}
|
|
31
99
|
const durationSeconds = args.duration_seconds ?? 30;
|
|
32
100
|
const durationMs = Math.max(3000, Math.min(600000, durationSeconds * 1000));
|
|
33
101
|
const outputFormat = args.output_format ?? 'mp3_44100_128';
|
|
@@ -49,6 +117,7 @@ export function registerMusicTools(server) {
|
|
|
49
117
|
duration_seconds: durationSeconds,
|
|
50
118
|
format: outputFormat,
|
|
51
119
|
message: `Music generated and saved to ${result.filePath} (${(result.sizeBytes / 1024).toFixed(1)} KB, ${durationSeconds}s).`,
|
|
120
|
+
warnings: warnings.length > 0 ? warnings : undefined,
|
|
52
121
|
});
|
|
53
122
|
}));
|
|
54
123
|
// ── create_music_plan ─────────────────────────────────────────────────
|
|
@@ -84,23 +153,19 @@ export function registerMusicTools(server) {
|
|
|
84
153
|
num_sections: plan.sections.length,
|
|
85
154
|
cost: 'FREE — no credits consumed',
|
|
86
155
|
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
|
|
156
|
+
hint: 'You can modify positive_global_styles, negative_global_styles, and per-section positive_local_styles, negative_local_styles, lines (lyric lines), or duration_ms before generating.',
|
|
88
157
|
});
|
|
89
158
|
}));
|
|
90
159
|
// ── generate_music_from_plan ──────────────────────────────────────────
|
|
91
160
|
server.registerTool('generate_music_from_plan', {
|
|
92
161
|
description: 'Generate music from a composition plan (created by create_music_plan or manually crafted). ' +
|
|
93
|
-
'The plan must have at least one section.
|
|
162
|
+
'The plan must have at least one section. Each section requires section_name, duration_ms, ' +
|
|
163
|
+
'positive_local_styles, negative_local_styles, and lines. Pass plans from create_music_plan ' +
|
|
164
|
+
'through verbatim — the field names are enforced by the ElevenLabs API.\n\n' +
|
|
165
|
+
'COST: Consumes credits based on duration. ' +
|
|
166
|
+
'Total duration: 3s-10min. Each section: 3-120s.',
|
|
94
167
|
inputSchema: z.object({
|
|
95
|
-
composition_plan:
|
|
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.'),
|
|
168
|
+
composition_plan: COMPOSITION_PLAN_SCHEMA,
|
|
104
169
|
seed: z.number().int().optional().describe('Random seed for reproducibility.'),
|
|
105
170
|
output_format: OUTPUT_FORMAT_ENUM.describe('Audio output format. Default: mp3_44100_128.'),
|
|
106
171
|
}),
|
|
@@ -120,7 +185,7 @@ export function registerMusicTools(server) {
|
|
|
120
185
|
body.seed = args.seed;
|
|
121
186
|
const ext = outputFormat.startsWith('mp3') ? 'mp3' : 'wav';
|
|
122
187
|
const result = await elevenLabsAudio(apiKey, `/music?output_format=${outputFormat}`, { method: 'POST', body: JSON.stringify(body) }, ext);
|
|
123
|
-
const totalDurationMs =
|
|
188
|
+
const totalDurationMs = compositionPlan.sections.reduce((sum, s) => sum + (s.duration_ms || 0), 0);
|
|
124
189
|
return JSON.stringify({
|
|
125
190
|
ok: true,
|
|
126
191
|
file_path: result.filePath,
|
package/dist/tools/speech.js
CHANGED
|
@@ -13,19 +13,51 @@ async function lookupVoiceByName(apiKey, name) {
|
|
|
13
13
|
}
|
|
14
14
|
return data.voices[0];
|
|
15
15
|
}
|
|
16
|
+
/**
|
|
17
|
+
* Pick a sensible default voice from the account.
|
|
18
|
+
*
|
|
19
|
+
* The previous version of this connector hardcoded a "Rachel" lookup, which
|
|
20
|
+
* silently fails on accounts that don't have Rachel in their library (most
|
|
21
|
+
* personal/business accounts). We now ask the API for the first page of
|
|
22
|
+
* voices, prefer premade voices, and fall back to whatever the account has.
|
|
23
|
+
*
|
|
24
|
+
* If the account has zero voices we surface a clear VOICE_NOT_FOUND with a
|
|
25
|
+
* resolution that tells the agent to call list_voices first.
|
|
26
|
+
*/
|
|
27
|
+
async function pickDefaultVoice(apiKey) {
|
|
28
|
+
const data = await elevenLabsJson(apiKey, 'https://api.elevenlabs.io/v2/voices?page_size=20');
|
|
29
|
+
if (!data.voices || data.voices.length === 0) {
|
|
30
|
+
throw new ElevenLabsError('No voice specified and the account has no voices.', 'VOICE_NOT_FOUND', 'Add a voice in https://elevenlabs.io/app/voice-library or pass a voice_id / voice_name explicitly.');
|
|
31
|
+
}
|
|
32
|
+
// Prefer premade voices (curated, generally suitable as defaults).
|
|
33
|
+
const premade = data.voices.find((v) => v.category === 'premade');
|
|
34
|
+
return premade ?? data.voices[0];
|
|
35
|
+
}
|
|
16
36
|
export function registerSpeechTools(server) {
|
|
17
37
|
// ── generate_speech ───────────────────────────────────────────────────
|
|
18
38
|
server.registerTool('generate_speech', {
|
|
19
39
|
description: 'Generate spoken audio from text using ElevenLabs text-to-speech. ' +
|
|
20
|
-
'Use voice_id (direct) or voice_name (fuzzy search). ' +
|
|
21
|
-
'
|
|
40
|
+
'Use voice_id (direct) or voice_name (fuzzy search). If neither is provided, ' +
|
|
41
|
+
'a premade voice from the account is used.\n\n' +
|
|
42
|
+
'MODELS:\n' +
|
|
43
|
+
' - eleven_v3 (default) — most expressive, 70+ languages\n' +
|
|
44
|
+
' - eleven_multilingual_v2 — proven multilingual, 29 languages\n' +
|
|
45
|
+
' - eleven_flash_v2_5 — low latency\n' +
|
|
46
|
+
' - eleven_turbo_v2_5 — low latency variant\n' +
|
|
47
|
+
' - eleven_monolingual_v1 — English-only legacy model (kept for backwards compatibility)\n\n' +
|
|
22
48
|
'COST: ~1 credit per 100 characters.',
|
|
23
49
|
inputSchema: z.object({
|
|
24
50
|
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., "
|
|
27
|
-
model_id: z.enum([
|
|
28
|
-
|
|
51
|
+
voice_id: z.string().optional().describe('Direct voice ID (from list_voices). Takes priority over voice_name.'),
|
|
52
|
+
voice_name: z.string().optional().describe('Voice name for fuzzy search (e.g., "Bella", "Sarah"). Use list_voices first to find a name that exists on the account.'),
|
|
53
|
+
model_id: z.enum([
|
|
54
|
+
'eleven_v3',
|
|
55
|
+
'eleven_multilingual_v2',
|
|
56
|
+
'eleven_flash_v2_5',
|
|
57
|
+
'eleven_turbo_v2_5',
|
|
58
|
+
'eleven_monolingual_v1',
|
|
59
|
+
]).optional()
|
|
60
|
+
.describe('TTS model. Default: eleven_v3.'),
|
|
29
61
|
stability: z.number().min(0).max(1).optional().describe('Voice stability 0-1. Default: 0.5.'),
|
|
30
62
|
similarity_boost: z.number().min(0).max(1).optional().describe('Voice similarity 0-1. Default: 0.75.'),
|
|
31
63
|
output_format: z.enum(['mp3_44100_128', 'mp3_44100_192', 'pcm_16000', 'pcm_22050', 'pcm_24000', 'pcm_44100']).optional()
|
|
@@ -39,7 +71,7 @@ export function registerSpeechTools(server) {
|
|
|
39
71
|
}
|
|
40
72
|
let voiceId = args.voice_id;
|
|
41
73
|
const voiceName = args.voice_name;
|
|
42
|
-
const modelId = args.model_id ?? '
|
|
74
|
+
const modelId = args.model_id ?? 'eleven_v3';
|
|
43
75
|
const stability = args.stability ?? 0.5;
|
|
44
76
|
const similarityBoost = args.similarity_boost ?? 0.75;
|
|
45
77
|
const outputFormat = args.output_format ?? 'mp3_44100_128';
|
|
@@ -52,19 +84,10 @@ export function registerSpeechTools(server) {
|
|
|
52
84
|
resolvedVoiceName = voice.name;
|
|
53
85
|
}
|
|
54
86
|
else {
|
|
55
|
-
//
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
}
|
|
87
|
+
// No voice specified — pick a sensible default from the account.
|
|
88
|
+
const voice = await pickDefaultVoice(apiKey);
|
|
89
|
+
voiceId = voice.voice_id;
|
|
90
|
+
resolvedVoiceName = voice.name;
|
|
68
91
|
}
|
|
69
92
|
}
|
|
70
93
|
const body = {
|
|
@@ -15,6 +15,8 @@ export function registerTranscriptionTools(server) {
|
|
|
15
15
|
inputSchema: z.object({
|
|
16
16
|
file_path: z.string().min(1).describe('Absolute path to local audio file to transcribe.'),
|
|
17
17
|
language_code: z.string().optional().describe('Language code (e.g., "en", "es", "fr"). Auto-detected if omitted.'),
|
|
18
|
+
model_id: z.enum(['scribe_v1']).optional().describe('STT model. Default: scribe_v1.'),
|
|
19
|
+
tag_audio_events: z.boolean().optional().describe('When true, include non-speech events like "(laughter)" in the transcript. Default: false.'),
|
|
18
20
|
}),
|
|
19
21
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
20
22
|
}, withErrorHandling(async (args) => {
|
|
@@ -72,9 +74,14 @@ export function registerTranscriptionTools(server) {
|
|
|
72
74
|
const verifiedPath = isRemoteUrl(rawFilePath) ? filePath : fs.realpathSync(filePath);
|
|
73
75
|
const fileBuffer = fs.readFileSync(verifiedPath);
|
|
74
76
|
const fileName = path.basename(verifiedPath);
|
|
75
|
-
//
|
|
77
|
+
// Multipart upload. ElevenLabs Speech-to-Text v1 requires:
|
|
78
|
+
// - field name `file` (NOT `audio` — see planning doc 260520)
|
|
79
|
+
// - `model_id` is mandatory (only `scribe_v1` is currently supported)
|
|
80
|
+
// - `tag_audio_events=false` to avoid `(mouse click)` style noise
|
|
76
81
|
const formData = new FormData();
|
|
77
|
-
formData.append('
|
|
82
|
+
formData.append('file', new Blob([fileBuffer]), fileName);
|
|
83
|
+
formData.append('model_id', args.model_id ?? 'scribe_v1');
|
|
84
|
+
formData.append('tag_audio_events', String(args.tag_audio_events ?? false));
|
|
78
85
|
if (args.language_code) {
|
|
79
86
|
formData.append('language_code', args.language_code);
|
|
80
87
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -20,24 +20,30 @@ export interface VoicesResponse {
|
|
|
20
20
|
voices: VoiceResult[];
|
|
21
21
|
has_more?: boolean;
|
|
22
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* ElevenLabs music composition section.
|
|
25
|
+
*
|
|
26
|
+
* Field names match the live ElevenLabs API exactly. The previous version of
|
|
27
|
+
* this connector (≤0.2.2) shipped a `{ style, lyrics, duration_ms }` shape
|
|
28
|
+
* that the API rejects with HTTP 422; see planning doc
|
|
29
|
+
* `docs/plans/260520_elevenlabs_oss_connector_fix.md` in MindstoneRebel for
|
|
30
|
+
* the live-capture trace.
|
|
31
|
+
*/
|
|
23
32
|
export interface CompositionSection {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
33
|
+
section_name: string;
|
|
34
|
+
duration_ms: number;
|
|
35
|
+
positive_local_styles?: string[];
|
|
36
|
+
negative_local_styles?: string[];
|
|
37
|
+
/** Lyric lines for the section. Empty array (or omit) for instrumental sections. */
|
|
38
|
+
lines?: string[];
|
|
27
39
|
}
|
|
28
40
|
export interface CompositionPlan {
|
|
29
41
|
positive_global_styles?: string[];
|
|
30
42
|
negative_global_styles?: string[];
|
|
31
|
-
sections
|
|
32
|
-
}
|
|
33
|
-
export interface MusicPlanResponse {
|
|
34
|
-
|
|
35
|
-
negative_global_styles: string[];
|
|
36
|
-
sections: Array<{
|
|
37
|
-
style: string;
|
|
38
|
-
lyrics: string;
|
|
39
|
-
duration_ms: number;
|
|
40
|
-
}>;
|
|
43
|
+
sections: CompositionSection[];
|
|
44
|
+
}
|
|
45
|
+
export interface MusicPlanResponse extends CompositionPlan {
|
|
46
|
+
sections: CompositionSection[];
|
|
41
47
|
}
|
|
42
48
|
export interface TranscriptionWord {
|
|
43
49
|
text: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mindstone/mcp-server-elevenlabs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"mcpName": "io.github.mindstone/mcp-server-elevenlabs",
|
|
5
5
|
"description": "ElevenLabs MCP server for Model Context Protocol hosts \u2014 music, TTS, sound effects, voices, transcription",
|
|
6
6
|
"license": "FSL-1.1-MIT",
|