Guide · AWS Polly · Text-to-Speech Synthesis
AWS Polly Text-to-Speech in MCP Servers — SynthesizeSpeechCommand, Voice Selection, Output Formats
AWS Polly converts text into lifelike speech using deep-learning models, returning an audio stream your MCP server can save to S3, return as base64, or stream directly to a client. The core call is SynthesizeSpeechCommand, which takes plain text or SSML, a VoiceId, an output format, and an engine type, then returns an AudioStream readable. This guide covers the basic synthesis workflow, voice selection, output formats and sample rates, the 3,000-character limit and how to chunk longer text, returning audio to an AI agent, and IAM setup.
TL;DR
Call SynthesizeSpeechCommand with Text, VoiceId (e.g. Joanna), OutputFormat: 'mp3', and Engine: 'neural'. Collect the AudioStream into a Buffer and either write it to S3 or return it as base64 to the agent. Plain text inputs are capped at 3,000 UTF-8 characters per call — split longer strings into sentence-aligned chunks and concatenate the resulting audio buffers. SSML inputs allow up to 6,000 characters. Use neural engine for natural-sounding voices; standard for the lowest latency and cost when naturalness is not critical.
Basic tool implementation
import {
PollyClient,
SynthesizeSpeechCommand,
OutputFormat,
Engine,
VoiceId,
} from '@aws-sdk/client-polly';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
const polly = new PollyClient({ region: 'us-east-1' });
const s3 = new S3Client({ region: 'us-east-1' });
const server = new McpServer({ name: 'polly-tools', version: '1.0.0' });
server.tool('synthesize_speech', {
text: z.string().max(3000).describe('Text to synthesize (max 3000 chars; use synthesize_speech_long for longer text)'),
voice_id: z.enum(['Joanna', 'Matthew', 'Ivy', 'Kendra', 'Kimberly', 'Salli', 'Joey', 'Justin', 'Kevin']).default('Joanna'),
output_bucket: z.string().describe('S3 bucket to store the audio file'),
output_key: z.string().optional().describe('S3 key; defaults to audio/{timestamp}.mp3'),
}, async ({ text, voice_id, output_bucket, output_key }) => {
const key = output_key ?? `audio/${Date.now()}.mp3`;
const { AudioStream, ContentType } = await polly.send(new SynthesizeSpeechCommand({
Text: text,
VoiceId: voice_id as VoiceId,
OutputFormat: OutputFormat.MP3,
Engine: Engine.NEURAL,
SampleRate: '22050', // 22050 Hz is the default and recommended for mp3
}));
if (!AudioStream) throw new Error('No audio stream returned from Polly');
// Collect stream into a buffer
const chunks: Uint8Array[] = [];
for await (const chunk of AudioStream as AsyncIterable<Uint8Array>) {
chunks.push(chunk);
}
const audioBuffer = Buffer.concat(chunks);
await s3.send(new PutObjectCommand({
Bucket: output_bucket,
Key: key,
Body: audioBuffer,
ContentType: ContentType ?? 'audio/mpeg',
}));
return {
content: [{
type: 'text',
text: JSON.stringify({
s3_uri: `s3://${output_bucket}/${key}`,
bytes: audioBuffer.byteLength,
voice: voice_id,
engine: 'neural',
format: 'mp3',
}, null, 2),
}],
};
});
Output formats and sample rates
| OutputFormat | ContentType | Valid SampleRate values | Use case |
|---|---|---|---|
mp3 |
audio/mpeg |
8000, 16000, 22050, 24000 | Web playback, podcast, general storage. 22050 Hz default. |
ogg_vorbis |
audio/ogg |
8000, 16000, 22050 | Open-source codec; smaller files at equal perceived quality vs mp3. |
pcm |
audio/pcm |
8000, 16000 | Raw 16-bit signed PCM for telephony (8 kHz) or streaming to audio pipelines that require uncompressed input. |
json |
application/x-json-stream |
N/A | Speech marks only (no audio). Used with SpeechMarkTypes to get word-level timing without synthesis. |
The SampleRate parameter applies only to mp3, ogg_vorbis, and pcm formats. Specifying an invalid combination (e.g. pcm at 22050 Hz) causes a InvalidSampleRateException. For telephony integrations via Amazon Connect or Chime, use pcm at 8000 Hz. For high-quality web audio, use mp3 at 22050 or 24000 Hz.
Neural voices ignore the SampleRate for internal synthesis — the specified rate is used only for the output encoding. Requesting a lower sample rate from a neural voice still incurs the neural synthesis cost; it doesn't save time, only file size.
Chunking text longer than 3,000 characters
Each SynthesizeSpeechCommand call accepts at most 3,000 UTF-8 characters for plain text (6,000 for SSML). Longer content requires splitting into chunks, synthesizing each chunk separately, and concatenating the audio buffers. For seamless concatenation, split on sentence boundaries rather than at the character limit:
function splitIntoChunks(text: string, maxChars = 2900): string[] {
const sentences = text.match(/[^.!?]+[.!?]+/g) ?? [text];
const chunks: string[] = [];
let current = '';
for (const sentence of sentences) {
if ((current + sentence).length > maxChars) {
if (current) chunks.push(current.trim());
current = sentence;
} else {
current += sentence;
}
}
if (current.trim()) chunks.push(current.trim());
return chunks;
}
async function synthesizeLong(text: string, voiceId: VoiceId): Promise<Buffer> {
const chunks = splitIntoChunks(text);
const buffers: Buffer[] = [];
for (const chunk of chunks) {
const { AudioStream } = await polly.send(new SynthesizeSpeechCommand({
Text: chunk,
VoiceId: voiceId,
OutputFormat: OutputFormat.MP3,
Engine: Engine.NEURAL,
}));
if (!AudioStream) throw new Error('No audio stream');
const parts: Uint8Array[] = [];
for await (const part of AudioStream as AsyncIterable<Uint8Array>) parts.push(part);
buffers.push(Buffer.concat(parts));
}
// MP3 frames can be concatenated directly — decoders handle multi-segment files
return Buffer.concat(buffers);
}
MP3 and OGG Vorbis audio buffers from consecutive SynthesizeSpeechCommand calls can be concatenated directly — most audio players and decoders handle multi-segment files correctly. For PCM, raw sample concatenation also works without headers since PCM is headerless. For long-form documents, consider using StartSpeechSynthesisTaskCommand instead, which accepts up to 100,000 characters and delivers output directly to S3 without manual chunking.
Voice selection
| VoiceId | Language | Gender | Engines | Notes |
|---|---|---|---|---|
Joanna |
en-US | Female | standard, neural | Default for US English. Neutral, professional tone. |
Matthew |
en-US | Male | standard, neural | Authoritative, news-reader cadence. |
Ivy |
en-US | Female (child) | standard, neural | Younger-sounding; use for educational content. |
Kendra |
en-US | Female | standard, neural | Warm, conversational register. |
Kimberly |
en-US | Female | standard, neural | Energetic and upbeat. |
Salli |
en-US | Female | standard, neural | Confident, clear articulation. |
Joey |
en-US | Male | standard, neural | Casual, relaxed delivery. |
Justin |
en-US | Male (child) | standard, neural | Child voice for educational applications. |
Kevin |
en-US | Male (child) | neural only | Neural-only child voice; warmer tone than Justin. |
Amy |
en-GB | Female | standard, neural, generative | British English; available on generative engine. |
Brian |
en-GB | Male | standard, neural | British English male voice. |
Emma |
en-GB | Female | standard, neural | Softer British female voice. |
Ruth |
en-US | Female | neural, long-form | Recommended for long-form narration. |
Stephen |
en-US | Male | neural, long-form | Recommended for long-form narration. |
Call GetVoicesCommand at startup to fetch the complete voice catalog for your region. Filter by Engine and LanguageCode to build a voice picker for your application. Not all voices are available in all regions — Polly is a global service but regional deployments can differ on neural and generative voice availability. See the neural engine guide for a full comparison of engine tiers.
Returning audio to an AI agent
MCP tools return structured text, not binary streams. For audio output, two patterns work in practice:
Pattern 1 — S3 pre-signed URL: Upload the audio to S3 and return a pre-signed GetObject URL. The agent can pass this URL to a client or embed it in a response. This is the preferred pattern — the audio never touches the MCP transport layer.
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { GetObjectCommand } from '@aws-sdk/client-s3';
const url = await getSignedUrl(s3, new GetObjectCommand({
Bucket: output_bucket,
Key: key,
}), { expiresIn: 3600 }); // 1-hour expiry
return {
content: [{
type: 'text',
text: JSON.stringify({ audio_url: url, expires_in_seconds: 3600 }),
}],
};
Pattern 2 — base64 inline: For short clips (under ~100 KB), encode the audio buffer as base64 and return it in the tool result. The agent or client decodes it. This avoids the extra S3 upload but inflates the response size by ~33% and can exceed MCP message limits for longer audio.
return {
content: [{
type: 'text',
text: JSON.stringify({
audio_base64: audioBuffer.toString('base64'),
content_type: 'audio/mpeg',
bytes: audioBuffer.byteLength,
}),
}],
};
IAM permissions
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"polly:SynthesizeSpeech",
"polly:GetVoices"
],
"Resource": "*"
},
{
"Effect": "Allow",
"Action": ["s3:PutObject"],
"Resource": "arn:aws:s3:::your-audio-bucket/audio/*"
}
]
}
polly:SynthesizeSpeech and polly:GetVoices do not support resource-level restrictions — the "Resource": "*" is required. Polly does not store your text; it processes it in-memory and returns the stream directly. Add polly:StartSpeechSynthesisTask, polly:GetSpeechSynthesisTask, and polly:ListSpeechSynthesisTasks for the async task API.