AWS Polly · 2026-11-05 · Polly arc
AWS Polly Text-to-Speech for MCP Servers: Synthesis, Engine Selection, SSML, and Async Tasks
AliveMCP probes every registered MCP endpoint every 60 seconds — and the voice synthesis failure patterns we see cluster around four Polly integration mistakes that nearly every MCP server team makes in their first week: a speech tool that picks Engine: 'generative' and passes an <speak>-wrapped SSML string expecting Polly to honor prosody tags, only to receive robotic flat output because the generative engine silently strips all SSML and treats the entire input as plain text; a tool that sets OutputFormat: 'pcm' at SampleRate: '22050' and receives an InvalidSampleRateException because PCM only accepts 8000 or 16000 Hz; a tool that calls SynthesizeSpeechCommand on a 10,000-character article and gets a TextLengthExceededException because the 3,000-character plain-text limit is per-call, not per-request; and a long-form tool that calls StartSpeechSynthesisTaskCommand and tries to construct the output S3 key to fetch the audio immediately, only to find the file isn't there because Polly appends the TaskId to the prefix automatically and the file is still being synthesized. Four Polly APIs form the complete voice synthesis toolkit for MCP servers. Basic synthesis converts text into audio using SynthesizeSpeechCommand, returning an AudioStream readable that you collect into a Buffer and either upload to S3 or encode as base64 — the right choice for short notifications, conversational responses, and any text under 3,000 characters; the key design decisions are choosing an OutputFormat and SampleRate combination that matches your playback target, selecting a VoiceId appropriate to your content register, and chunking longer text at sentence boundaries rather than at the character limit to avoid cut-off words in the concatenated audio. Engine selection is the highest-leverage Polly decision an MCP team makes — standard is fast and cheap but robotic-sounding, neural is the right default for most use cases, long-form is better than neural for multi-paragraph documents and articles despite costing the same per character, and generative achieves the most human-like output but costs 20–30× standard and does not support SSML. SSML markup doubles the per-call character limit to 6,000 and gives you precise control over pauses, speech rate, pitch, volume, pronunciation, and number formatting — but every XML-special character in user-supplied text content must be escaped before insertion into an SSML template, and the most common production incident is a silent synthesis failure caused by an unescaped ampersand in a product name or URL. Async tasks remove the 3,000-character barrier entirely by running synthesis in the background and delivering output directly to S3 — accepting up to 100,000 characters per task — and are the only practical choice for long documents, audiobooks, or any text that exceeds a few hundred words; the same task can produce a parallel speech marks file containing word-level timestamps that enable subtitle generation, audio players with word highlighting, and avatar lip-sync animation. This guide synthesizes all four Polly patterns into a complete voice synthesis stack for MCP server builders.
TL;DR
- Basic synthesis — call
SynthesizeSpeechCommandwithText,VoiceId(e.g.Joanna),OutputFormat: 'mp3',Engine: 'neural', andSampleRate: '22050'; collect theAudioStreaminto a Buffer with afor awaitloop; upload to S3 and return a pre-signed URL or return the Buffer as base64 for short clips: Plain-text inputs cap at 3,000 UTF-8 characters per call — split longer strings at sentence boundaries (not at the character limit) and concatenate the resulting Buffers; MP3 frames concatenate cleanly across segments. SSML inputs allow up to 6,000 characters. TheContentTypefield in the response mirrors yourOutputFormat— use it as the S3ContentTypeheader onPutObjectCommandfor correct browser playback. For short clips under ~100 KB, base64 encoding in the tool result avoids the extra S3 round-trip; for anything longer, return a pre-signedGetObjectCommandURL — audio never touches the MCP transport layer. IAM:polly:SynthesizeSpeechandpolly:GetVoicesboth require"Resource": "*"(Polly does not support resource-level restrictions); adds3:PutObjecton your audio bucket prefix. - Engine selection — use
Engine: 'neural'for most voice tools; switch to'long-form'(voices: Ruth, Stephen) for documents and articles; use'standard'only when cost is the primary constraint and quality is secondary; reserve'generative'for products where human-likeness is a differentiator, with the understanding that it costs ~20–30× standard and does not support SSML: CallGetVoicesCommand({ Engine, LanguageCode })at startup to fetch the available voice catalog for your region — cache the result, never call it per-request. TheSupportedEnginesfield on each voice object is authoritative — not every voice listed for one engine is available for another; callingSynthesizeSpeechCommandwith an engine the voice doesn't support raisesInvalidParameterValueException: Engine not supported by voice. Long-form costs the same per character as neural — the choice is purely about quality: for multi-paragraph narration, long-form with Ruth or Stephen produces markedly better pacing. Generative does not support SSML — if your workflow already uses SSML for pronunciation or prosody, generative cannot be used. Voice consistency: pick oneVoiceIdper persona and store it in configuration; switching voices mid-session creates a jarring experience. - SSML — wrap input in
<speak>...</speak>and setTextType: 'ssml'; use<break time="1s"/>for section pauses,<prosody rate="slow">for deliberate speech,<say-as interpret-as="digits">404</say-as>to read numbers digit-by-digit,<phoneme alphabet="ipa" ph="..."/>for exact pronunciation, and<sub alias="sequel">SQL</sub>for acronym expansion; escape XML-special characters in all user-supplied text nodes before inserting into the SSML template: Unescaped&,<, and>in text content causeInvalidSsmlException— the most common silent production failure in Polly integrations. The generative engine ignores all SSML tags and treats input as plain text — do not setTextType: 'ssml'withEngine: 'generative'. SSML comments (<!-- -->) count toward the 6,000-character limit — strip them before submission. Nested<prosody>tags work: inner tags override outer for the same attribute. IPA transcriptions are not validated by the API — a wrong IPA string silently produces garbled audio; test in the Polly console before shipping.<say-as interpret-as="telephone">handles+1-800-555-0100format; for dates useformat="mdy"for US convention or"dmy"for European. - Async tasks — call
StartSpeechSynthesisTaskCommandwithOutputS3BucketName,OutputS3KeyPrefix,VoiceId,Engine, andOutputFormat; it returns aTaskIdimmediately; pollGetSpeechSynthesisTaskCommand({ TaskId })every 10–30 seconds untilTaskStatusiscompletedorfailed; readOutputUrifor the S3 path of the audio file; includeSpeechMarkTypes: ['word', 'sentence']to get a parallel JSON Lines timing file atOutputUri + '.marks': TheOutputUrikey is auto-generated by Polly asprefix + TaskId + '.mp3'— you cannot specify an exact key, only a prefix. Polly stores task metadata for 7 days after completion; storeOutputUriin your database at completion time rather than relying on being able to re-query the task later. For high-volume workloads, supplySnsTopicArnto receive a push notification on completion rather than polling. Speech marks output format: JSON Lines (one JSON object per line), each line hastime(ms offset from audio start),type(word/sentence/viseme/ssml),startandend(character offsets in source text), andvalue(word/sentence string, viseme code, or SSML mark name). For analysis without audio, setOutputFormat: 'json'to produce only the marks file at a fraction of synthesis cost.
Pattern 1: Basic synthesis — SynthesizeSpeechCommand, voice selection, output formats, and chunking
When an AI agent needs to convert a short notification, a conversational response, or a UI label into audio, basic synthesis with SynthesizeSpeechCommand is the right starting point. The call is synchronous — Polly processes the text in-memory and returns the audio stream directly in the response. The fundamental design constraint is the 3,000 UTF-8 character limit for plain-text input (6,000 for SSML). For most notification and conversational use cases this limit is irrelevant; for document synthesis it requires chunking.
Basic tool implementation
import {
PollyClient,
SynthesizeSpeechCommand,
OutputFormat,
Engine,
VoiceId,
} from '@aws-sdk/client-polly';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { GetObjectCommand } 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)'),
voice_id: z.enum(['Joanna', 'Matthew', 'Ivy', 'Kendra', 'Salli', 'Joey', 'Ruth', 'Stephen'])
.default('Joanna'),
output_bucket: z.string().describe('S3 bucket to store the audio file'),
}, async ({ text, voice_id, output_bucket }) => {
const 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',
}));
if (!AudioStream) throw new Error('No audio stream returned from Polly');
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',
}));
const url = await getSignedUrl(s3, new GetObjectCommand({
Bucket: output_bucket,
Key: key,
}), { expiresIn: 3600 });
return {
content: [{
type: 'text',
text: JSON.stringify({
audio_url: url,
expires_in_seconds: 3600,
bytes: audioBuffer.byteLength,
voice: voice_id,
engine: 'neural',
}, null, 2),
}],
};
});
Output format and sample rate compatibility
The combination of OutputFormat and SampleRate determines the size, quality, and downstream compatibility of the audio file. Specifying an invalid combination causes an InvalidSampleRateException — Polly validates this server-side before synthesis begins:
| OutputFormat | ContentType | Valid SampleRate values | Use case |
|---|---|---|---|
mp3 |
audio/mpeg |
8000, 16000, 22050, 24000 | Web playback, podcast, general storage. 22050 Hz is the default and recommended value. |
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 audio pipelines requiring uncompressed input. PCM does not support 22050 Hz — this is the most common InvalidSampleRateException. |
json |
application/x-json-stream |
N/A | Speech marks only (no audio). Used with SpeechMarkTypes for word-level timing without synthesis cost. |
Neural voices ignore SampleRate for internal synthesis — the specified rate affects only the output encoding. Requesting a lower sample rate from a neural voice still incurs the full neural synthesis cost and does not reduce processing time; it only reduces file size.
Chunking text longer than 3,000 characters
The critical mistake teams make with chunking is splitting at the character limit — which cuts sentences mid-word, creating an audible click or abrupt pause at the join point. The correct approach is to split on sentence boundaries and keep each chunk under the 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 from consecutive calls concatenate cleanly — decoders handle multi-segment files
return Buffer.concat(buffers);
}
MP3 and OGG Vorbis buffers from consecutive calls can be concatenated directly — most audio players handle multi-segment files correctly. For PCM, raw sample concatenation also works without headers since PCM is headerless. For long-form documents (articles, book chapters, full-document narration), use StartSpeechSynthesisTaskCommand instead — it accepts up to 100,000 characters and delivers output directly to S3 without manual chunking.
Returning audio to an AI agent
MCP tools return structured text, not binary streams. Two patterns work in practice for returning audio to the calling agent:
Pattern 1 — S3 pre-signed URL (preferred for all clips): Upload to S3 and return a pre-signed GetObject URL with a 1-hour expiry. The audio never touches the MCP transport layer. The agent passes the URL to a client or embeds it in a response.
Pattern 2 — base64 inline (acceptable for short clips under ~100 KB): Encode the buffer as base64 and return it in the tool result. This avoids the extra S3 upload but inflates response size by ~33% and can exceed MCP message limits for longer audio. Use only for sub-5-second clips such as notification sounds or short prompts.
Pattern 2: Engine selection — standard vs neural vs long-form vs generative
The choice of Engine is the most consequential Polly decision an MCP team makes. It determines audio quality, latency, cost, and which SSML features are available. Getting it wrong for the content type produces either robotic output that users find jarring, or unnecessarily high costs for a quality level the use case doesn't require. The four engine tiers map cleanly to four content categories.
| Engine | Quality | Latency | Cost (relative) | SSML support | Char limit per call |
|---|---|---|---|---|---|
standard |
Robotic, concatenative | Lowest | 1× | Full | 3,000 text / 6,000 SSML |
neural |
Natural, expressive | Low–medium | 4× | Full | 3,000 text / 6,000 SSML |
long-form |
Natural, narration-tuned | Medium | 4× (same as neural) | Subset | 3,000 text / 6,000 SSML; use async task for up to 100,000 |
generative |
Highly human-like | Highest | ~20–30× | None | 3,000 text only |
The long-form engine costs the same per character as neural — the choice between them is purely about quality for the content type. For a single-sentence notification, neural is indistinguishable from long-form. For a five-paragraph article narration, long-form with Ruth or Stephen produces markedly better pacing and prosody because it was trained on narration-style content rather than conversational audio. The generative engine should be reserved for products where human-likeness is a primary differentiator — a voice assistant product where users will notice robotic output, or a content narration service where audio quality directly affects retention. At 20–30× the standard cost, it cannot be used in high-volume paths without careful cost analysis.
Discovering the voice catalog with GetVoicesCommand
import {
PollyClient,
GetVoicesCommand,
Engine,
LanguageCode,
} from '@aws-sdk/client-polly';
const polly = new PollyClient({ region: 'us-east-1' });
// Cache at startup — the voice catalog changes infrequently
let voiceCache: Map<string, string[]> | null = null;
async function getVoicesForEngine(
engine: Engine,
languageCode?: LanguageCode,
) {
const { Voices } = await polly.send(new GetVoicesCommand({
Engine: engine,
LanguageCode: languageCode,
IncludeAdditionalLanguageCodes: true,
}));
return (Voices ?? []).map(v => ({
id: v.Id,
name: v.Name,
gender: v.Gender,
language: v.LanguageCode,
supportedEngines: v.SupportedEngines,
additionalLanguages: v.AdditionalLanguageCodes,
}));
}
// List long-form voices (English-only as of late 2026)
// Returns: Ruth (en-US, female), Stephen (en-US, male)
const longFormVoices = await getVoicesForEngine(Engine.LONG_FORM);
// List neural voices for US English
// Returns: Joanna, Matthew, Ivy, Kendra, Kimberly, Salli, Joey, Justin, Kevin
const neuralUsVoices = await getVoicesForEngine(Engine.NEURAL, LanguageCode.EN_US);
The SupportedEngines field on each voice object is the authoritative source of truth for which engines that voice supports — not all voices listed for one engine tier are available for another. Calling SynthesizeSpeechCommand with an engine the voice doesn't support raises InvalidParameterValueException: Engine not supported by voice. The Kevin voice (en-US, male child) is neural-only — there is no standard variant. Amy (en-GB, female) is one of the few voices available on the generative engine.
Engine selection by content type
type ContentType = 'notification' | 'conversation' | 'article' | 'presentation';
function pickEngine(contentType: ContentType): { engine: string; voiceId: string } {
switch (contentType) {
case 'notification':
// Short, single-sentence alerts — neural is natural enough; standard cuts cost
return { engine: 'neural', voiceId: 'Joanna' };
case 'conversation':
// Multi-turn dialogue, chatbot responses — consistent voice per persona
return { engine: 'neural', voiceId: 'Matthew' };
case 'article':
// Blog posts, documents, 3+ paragraphs — long-form narrates better
return { engine: 'long-form', voiceId: 'Ruth' };
case 'presentation':
// Slide narration or explainer video — long-form male voice
return { engine: 'long-form', voiceId: 'Stephen' };
}
}
Multi-language voice strategy
Some Polly voices support a secondary language via the LanguageCode override parameter on SynthesizeSpeechCommand — a voice trained primarily on US English can synthesize French text with a French accent. This is not the same as a native French voice: pronunciation is accented. For best results with non-English content, use a voice whose primary LanguageCode matches the input text. The AdditionalLanguageCodes field lists secondary languages the voice can synthesize with an accent — useful for mixed-language documents where a fully native voice isn't available. Neural voice availability varies by language — many non-English languages only have standard voices or limited neural coverage. Run GetVoicesCommand at startup and store the result to validate your language-to-voice map against what's actually available in your deployed region.
Pattern 3: SSML markup — prosody, phoneme, say-as, pauses, and XML escaping
SSML (Speech Synthesis Markup Language) is an XML vocabulary that gives MCP server tools precise control over how Polly speaks a given text — adding pauses between sections, slowing down for important numbers, correcting mispronounced product names, and formatting dates or phone numbers as a human would speak them. Using SSML instead of plain text unlocks two advantages: the character limit doubles to 6,000 characters, and you get fine-grained control over timing and pronunciation that plain text cannot express. The critical production discipline is XML character escaping — an unescaped ampersand in a product name or URL inserted into an SSML template causes InvalidSsmlException and silent synthesis failure.
SSML tool with escaping helper
import {
PollyClient,
SynthesizeSpeechCommand,
OutputFormat,
Engine,
TextType,
VoiceId,
} from '@aws-sdk/client-polly';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
const polly = new PollyClient({ region: 'us-east-1' });
const server = new McpServer({ name: 'polly-ssml-tools', version: '1.0.0' });
// Always escape user-supplied text before inserting into SSML templates
function escapeXml(str: string): string {
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
server.tool('speak_notification', {
title: z.string().describe('Alert title — will be spoken at slow rate'),
body: z.string().describe('Alert body text'),
code: z.string().optional().describe('Numeric code to read digit-by-digit (e.g. error code, verification code)'),
voice_id: z.string().default('Joanna'),
}, async ({ title, body, code, voice_id }) => {
const ssml = `<speak>
<prosody rate="slow" volume="loud">${escapeXml(title)}</prosody>
<break time="800ms"/>
${escapeXml(body)}
${code ? `<break time="500ms"/>Code: <prosody rate="x-slow"><say-as interpret-as="digits">${escapeXml(code)}</say-as></prosody>` : ''}
</speak>`;
const { AudioStream } = await polly.send(new SynthesizeSpeechCommand({
Text: ssml,
TextType: TextType.SSML,
VoiceId: voice_id as VoiceId,
OutputFormat: OutputFormat.MP3,
Engine: Engine.NEURAL,
}));
if (!AudioStream) throw new Error('No audio stream');
const chunks: Uint8Array[] = [];
for await (const chunk of AudioStream as AsyncIterable<Uint8Array>) chunks.push(chunk);
const buf = Buffer.concat(chunks);
return {
content: [{ type: 'text', text: JSON.stringify({
audio_base64: buf.toString('base64'),
content_type: 'audio/mpeg',
bytes: buf.byteLength,
}) }],
};
});
Pauses with <break>
The <break> element inserts silence. It accepts either a time attribute (in seconds or milliseconds, up to 10 seconds) or a strength attribute for preset relative durations:
| strength value | Approximate duration | Typical use |
|---|---|---|
none | 0 ms (removes natural pause) | Eliminate automatic word pause at punctuation |
x-weak | ~50 ms | Minimal hesitation between clauses |
weak | ~100 ms | Between items in a list |
medium | ~250 ms | Default for a comma |
strong | ~700 ms | Default for a period |
x-strong | ~1,200 ms | Section break or end of a major point |
Prosody: rate, pitch, and volume
Prosody tags can be nested — inner tags take precedence over outer tags for the same attribute. Keep pitch adjustments under ±10% for natural-sounding output — extreme values produce an unnatural chipmunk or low-robot effect. The standard engine supports prosody more reliably than neural; the neural engine may ignore subtle rate and pitch adjustments and apply its own prosodic model instead.
<speak>
Your verification code is
<prosody rate="x-slow" volume="loud">
<say-as interpret-as="digits">482910</say-as>
</prosody>.
<break time="800ms"/>
<prosody rate="fast" volume="soft">
Terms and conditions apply. See website for details.
</prosody>
</speak>
Pronunciation with <phoneme>
Domain-specific terms, product names, and proper nouns are frequently mispronounced. Use <phoneme> with International Phonetic Alphabet (IPA) to override the default pronunciation. IPA transcriptions are not validated by the API — a wrong IPA string silently produces garbled audio; test in the Polly console before shipping to production:
<speak>
Welcome to <phoneme alphabet="ipa" ph="ˈeɪlɪvˌɛm.siˈpiː">AliveMCP</phoneme>,
the uptime monitoring dashboard for
<phoneme alphabet="ipa" ph="ˌɛm.siˈpiː">MCP</phoneme> servers.
</speak>
Formatting with <say-as>
The <say-as interpret-as> element tells Polly how to interpret and speak a text value. Without it, Polly uses heuristics that are frequently wrong for structured data — it may read 404 as "four hundred and four" instead of "four zero four", or read API as a word instead of spelling out the letters:
| interpret-as | Input | Spoken as |
|---|---|---|
digits | 404 | "four zero four" |
spell-out | API | "A P I" |
cardinal | 1024 | "one thousand twenty-four" |
ordinal | 3 | "third" |
fraction | 3/4 | "three quarters" |
unit | 5kg | "five kilograms" |
date | 11/04/2026 (format="mdy") | "November fourth twenty twenty-six" |
time | 10:30am (format="hms12") | "ten thirty A M" |
telephone | +1-800-555-0100 | "plus one eight hundred five five five..." |
expletive | damn | Bleeps the word (content moderation) |
Acronym expansion with <sub> and inline language switching with <lang>
Use <sub alias="sequel">SQL</sub> to replace a term with an alternative pronunciation without changing the displayed text — unlike <phoneme> which requires IPA, <sub alias> takes a plain-text string that Polly synthesizes normally. This is easier to use for most substitutions: write what you want spoken, not a phonetic encoding. <sub> is not supported by the generative engine.
Use <lang xml:lang="fr-FR">bonjour</lang> to switch pronunciation rules for enclosed text. This requires that the selected VoiceId supports the target language as an additional language — query GetVoicesCommand and inspect AdditionalLanguageCodes for the voice you're using.
Common SSML pitfalls
| Pitfall | Symptom | Fix |
|---|---|---|
Unescaped & in text | InvalidSsmlException: The SSML is not valid | Replace & with & in all text nodes |
Missing <speak> root | InvalidSsmlException | Always wrap in <speak>...</speak> |
| SSML with generative engine | SSML tags stripped; plain text spoken | Use neural or long-form engine for SSML |
| IPA string with wrong symbols | Garbled audio (no error thrown) | Validate IPA in the Polly console before shipping |
| SSML > 6,000 characters | TextLengthExceededException | Split at SSML element boundaries and concatenate audio |
| Comments in SSML | Comments count toward character limit | Strip SSML comments before submitting |
Pattern 4: Async tasks and speech marks — StartSpeechSynthesisTaskCommand, polling, and subtitle generation
When synthesizing long-form documents, articles, or any content where the chunking-and-concatenate approach adds too much complexity, StartSpeechSynthesisTaskCommand is the right tool. It accepts up to 100,000 characters per task, runs synthesis asynchronously in the background, and writes the output audio directly to an S3 key — no AudioStream to collect, no manual chunking, no buffer concatenation. The same call can produce a parallel speech marks file containing word-level timestamps that enable synchronized subtitles, audio players with word highlighting, and avatar lip-sync animation.
Async task implementation with polling
import {
PollyClient,
StartSpeechSynthesisTaskCommand,
GetSpeechSynthesisTaskCommand,
OutputFormat,
Engine,
VoiceId,
SpeechMarkType,
TaskStatus,
} from '@aws-sdk/client-polly';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
const polly = new PollyClient({ region: 'us-east-1' });
const server = new McpServer({ name: 'polly-async-tools', version: '1.0.0' });
// Tool 1: submit the task and return immediately
server.tool('synthesize_long_text_start', {
text: z.string().max(100000).describe('Text to synthesize (up to 100,000 characters)'),
output_bucket: z.string(),
output_key_prefix: z.string().default('polly-output/'),
voice_id: z.enum(['Ruth', 'Stephen', 'Joanna', 'Matthew']).default('Ruth'),
include_speech_marks: z.boolean().default(false),
}, async ({ text, output_bucket, output_key_prefix, voice_id, include_speech_marks }) => {
const { SynthesisTask } = await polly.send(new StartSpeechSynthesisTaskCommand({
Text: text,
VoiceId: voice_id as VoiceId,
OutputFormat: OutputFormat.MP3,
Engine: voice_id === 'Ruth' || voice_id === 'Stephen' ? Engine.LONG_FORM : Engine.NEURAL,
OutputS3BucketName: output_bucket,
OutputS3KeyPrefix: output_key_prefix,
SpeechMarkTypes: include_speech_marks
? [SpeechMarkType.WORD, SpeechMarkType.SENTENCE]
: undefined,
}));
if (!SynthesisTask?.TaskId) throw new Error('No TaskId returned');
return {
content: [{
type: 'text',
text: JSON.stringify({
task_id: SynthesisTask.TaskId,
status: SynthesisTask.TaskStatus,
// Store task_id — task metadata expires after 7 days
}, null, 2),
}],
};
});
// Tool 2: poll for completion and return result when ready
server.tool('synthesize_long_text_result', {
task_id: z.string().describe('TaskId returned by synthesize_long_text_start'),
}, async ({ task_id }) => {
const { SynthesisTask } = await polly.send(
new GetSpeechSynthesisTaskCommand({ TaskId: task_id })
);
if (!SynthesisTask) throw new Error('Task not found');
if (SynthesisTask.TaskStatus === TaskStatus.FAILED) {
return {
content: [{
type: 'text',
text: JSON.stringify({ error: 'FAILED', reason: SynthesisTask.TaskStatusReason }),
}],
isError: true,
};
}
if (SynthesisTask.TaskStatus !== TaskStatus.COMPLETED) {
return {
content: [{
type: 'text',
text: JSON.stringify({ status: SynthesisTask.TaskStatus, task_id }),
}],
};
}
return {
content: [{
type: 'text',
text: JSON.stringify({
task_id,
audio_uri: SynthesisTask.OutputUri,
speech_marks_uri: SynthesisTask.OutputUri?.replace('.mp3', '.mp3.marks'),
creation_time: SynthesisTask.CreationTime,
completed_time: SynthesisTask.CompletionTime,
voice: SynthesisTask.VoiceId,
engine: SynthesisTask.Engine,
}, null, 2),
}],
};
});
Task lifecycle and S3 output location
| TaskStatus | Meaning | Recommended action |
|---|---|---|
scheduled | Task queued, not yet started | Poll again in 10–30 seconds |
inProgress | Synthesis actively running | Poll again in 10–30 seconds |
completed | Audio file written to S3 | Read OutputUri for the S3 path; store it in your DB now — task metadata expires in 7 days |
failed | Synthesis failed | Read TaskStatusReason for the error message |
The OutputUri is an S3 URI in the format s3://bucket/prefix<taskId>.mp3. Polly generates the key automatically — you cannot specify an exact output key, only a prefix. If you requested SpeechMarkTypes, the speech marks file is at the same key with .marks appended: s3://bucket/prefix<taskId>.mp3.marks. Store OutputUri in your database at task completion rather than relying on being able to re-query the task metadata later — Polly cleans up task records after 7 days.
Speech marks — word-level timing for subtitles and lip-sync
Speech marks are a JSON Lines file (one JSON object per line) where each line is a timing event. The word type gives you one object per word with its start time in milliseconds from audio start; the sentence type gives you sentence boundaries for subtitle cue groups:
{"time":0,"type":"sentence","start":0,"end":89,"value":"Hello, welcome to AliveMCP."}
{"time":0,"type":"word","start":0,"end":5,"value":"Hello"}
{"time":420,"type":"word","start":7,"end":14,"value":"welcome"}
{"time":820,"type":"word","start":15,"end":17,"value":"to"}
{"time":1010,"type":"word","start":18,"end":26,"value":"AliveMCP"}
{"time":1620,"type":"sentence","start":91,"end":140,"value":"Your server is healthy."}
{"time":1620,"type":"word","start":91,"end":95,"value":"Your"}
{"time":1850,"type":"word","start":96,"end":102,"value":"server"}
{"time":2200,"type":"word","start":103,"end":105,"value":"is"}
{"time":2340,"type":"word","start":106,"end":113,"value":"healthy"}
| SpeechMarkType | Emitted for | value field | Use case |
|---|---|---|---|
word | Each word in the input | The word string | Subtitle highlighting, word-level karaoke, keyword search with timestamps |
sentence | Each sentence boundary | Full sentence text | Subtitle cue boundaries, paragraph-level reading progress |
viseme | Each phonetic sound | Viseme code (e.g. p, t, sil) | Avatar lip-sync animation, mouth-shape timing |
ssml | <mark name="..."> tags in SSML | The mark name | Triggering UI events at precise audio positions |
Parsing speech marks for WebVTT subtitle generation
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
const s3 = new S3Client({ region: 'us-east-1' });
interface SpeechMark {
time: number;
type: 'word' | 'sentence' | 'viseme' | 'ssml';
start: number;
end: number;
value: string;
}
async function parseSpeechMarks(bucket: string, key: string): Promise<SpeechMark[]> {
const { Body } = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
const chunks: Uint8Array[] = [];
for await (const chunk of Body as AsyncIterable<Uint8Array>) chunks.push(chunk);
const text = Buffer.concat(chunks).toString('utf-8');
// JSON Lines — one JSON object per line
return text
.split('\n')
.filter(line => line.trim())
.map(line => JSON.parse(line) as SpeechMark);
}
function formatVttTime(ms: number): string {
const totalSec = Math.floor(ms / 1000);
const h = Math.floor(totalSec / 3600);
const m = Math.floor((totalSec % 3600) / 60);
const s = totalSec % 60;
const msRemainder = ms % 1000;
return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}.${String(msRemainder).padStart(3,'0')}`;
}
function buildWebVTT(marks: SpeechMark[]): string {
const sentences = marks.filter(m => m.type === 'sentence');
const cues = sentences.map((sentence, i) => {
const nextSentence = sentences[i + 1];
const endTime = nextSentence ? nextSentence.time : sentence.time + 5000;
return `${formatVttTime(sentence.time)} --> ${formatVttTime(endTime)}\n${sentence.value}`;
});
return `WEBVTT\n\n${cues.join('\n\n')}`;
}
SNS notification instead of polling
For high-volume workloads where you're running many concurrent synthesis tasks, polling GetSpeechSynthesisTaskCommand per task adds overhead. Supply SnsTopicArn to StartSpeechSynthesisTaskCommand and Polly publishes the full SynthesisTask object to your SNS topic on completion or failure. The SNS topic must grant Polly permission to publish — add a resource-based policy with Service: polly.amazonaws.com as the principal and a Condition: StringEquals: "aws:SourceAccount": "<your-account-id>" to prevent confused-deputy attacks. Polling remains the simpler implementation for low-volume MCP tools.
IAM permissions for async tasks
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"polly:SynthesizeSpeech",
"polly:GetVoices",
"polly:StartSpeechSynthesisTask",
"polly:GetSpeechSynthesisTask",
"polly:ListSpeechSynthesisTasks"
],
"Resource": "*"
},
{
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:GetObject"],
"Resource": "arn:aws:s3:::your-output-bucket/polly-output/*"
}
]
}
All Polly actions require "Resource": "*" — Polly does not support resource-level restrictions. Polly uses your execution role's credentials to write to S3 — it does not assume a separate role. If the output bucket uses SSE-KMS, grant kms:GenerateDataKey and kms:Decrypt for the KMS key to the same role. The speech marks .marks file is written to the same bucket and key prefix as the audio, so the same S3 permissions cover both files.