@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 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
+ [![Add to Cursor](https://img.shields.io/badge/Add_to_Cursor-black?style=for-the-badge&logo=cursor&logoColor=white)](cursor://anysphere.cursor-deeplink/mcp/install?name=ElevenLabs&config=eyJ0eXBlIjoic3RkaW8iLCJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBtaW5kc3RvbmUvbWNwLXNlcnZlci1lbGV2ZW5sYWJzIl0sImVudiI6eyJFTEVWRU5MQUJTX0FQSV9LRVkiOiIifX0)
26
+ [![Add to VS Code](https://img.shields.io/badge/Add_to_VS_Code-007ACC?style=for-the-badge&logo=visual-studio-code&logoColor=white)](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
+ [![Add to VS Code Insiders](https://img.shields.io/badge/Add_to_VS_Code_Insiders-24bfa5?style=for-the-badge&logo=visual-studio-code&logoColor=white)](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
- throw new ElevenLabsError('Authentication failed', 'AUTH_FAILED', getErrorResolution(401));
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
- let detail = '';
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
- let detail = '';
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
  */
@@ -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: Describe genre, mood, instruments, style, lyrics.',
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, lyrics.'),
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 styles, lyrics, and durations before generating.',
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. COST: Consumes credits based on duration.',
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: 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.'),
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 = (compositionPlan.sections ?? []).reduce((sum, s) => sum + (s.duration_ms || 0), 0);
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,
@@ -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
- 'Models: eleven_multilingual_v2 (default, 29 languages), eleven_monolingual_v1 (English), eleven_turbo_v2_5 (low latency). ' +
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., "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.'),
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 ?? 'eleven_multilingual_v2';
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
- // 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
- }
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
- // Use FormData to send as multipart
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('audio', new Blob([fileBuffer]), fileName);
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
- style?: string;
25
- lyrics?: string;
26
- duration_ms?: number;
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?: CompositionSection[];
32
- }
33
- export interface MusicPlanResponse {
34
- positive_global_styles: string[];
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.2.2",
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",