Amazon Translate · 2026-11-05 · Translate arc
Amazon Translate for MCP Servers: Text Translation, Custom Terminology, Document Translation, and Async Batch Jobs
AliveMCP probes every registered MCP endpoint every 60 seconds — and the Amazon Translate integration failures we see cluster around four mistakes that nearly every MCP server team makes in their first week: a translation tool that sends 15,000-byte JSON payloads to TranslateTextCommand and receives a TextSizeLimitExceededException, because the 10,000-byte limit applies to the UTF-8 byte count not the JavaScript string length — a 5,000-character string containing Japanese or Chinese characters can easily exceed the limit because each CJK character encodes as 3 bytes in UTF-8; a terminology-enabled translation that never substitutes domain terms, because the team imported the CSV with Directionality: 'UNI' but then calls TranslateTextCommand with a different target language than the UNI terminology was built for — UNI terminologies apply to exactly one source→target pair, and presenting a UNI terminology to a mismatched translation silently skips it rather than raising an error; a document translation handler that passes an HTML string directly to Document.Content and then tries to use the returned Uint8Array as a string, because TranslateDocumentCommand requires TextEncoder to encode input to bytes before sending and TextDecoder to decode the output Uint8Array back to a string; and a batch job that fails immediately with an S3 permissions error, because the data access role has s3:GetObject and s3:PutObject but not s3:ListBucket — which Amazon Translate needs to enumerate input files before reading them, applied to the bucket ARN not the key prefix. Four Translate APIs form the complete multilingual content stack for MCP servers. Text translation with TranslateTextCommand is the entry point: you send plain text with a source and target language code, Translate returns the translated string in under a second, and optional Settings control formal vs informal register — right for any MCP server tool that localizes short strings, UI copy, or user messages in real time. Custom terminology locks down domain-specific vocabulary so that product names, technical terms, and brand words are never paraphrased by the neural translation model — you import a CSV with ImportTerminologyCommand, then reference it in every translation call that needs the controlled vocabulary enforced. Document translation with TranslateDocumentCommand handles structured content — HTML markup and Word .docx files — where only the text nodes should be translated while tags, links, formatting, and document structure remain intact, returned synchronously in the response with no S3 requirement. Async batch jobs with StartTextTranslationJobCommand handle multi-MB corpora where a synchronous call is impractical: you point Translate at an S3 input prefix, specify one or many target languages, and poll job status until it completes — Translate writes translated output to a separate S3 prefix with one subdirectory per target language. This guide synthesizes all four Translate patterns into a complete multilingual integration stack for MCP server builders.
TL;DR
- Text translation — construct
TranslateClientonce; callTranslateTextCommandwithText,SourceLanguageCode('auto'to delegate language detection to Comprehend), andTargetLanguageCode; readresponse.TranslatedTextfor the result andresponse.SourceLanguageCodefor the detected source language; IAM needstranslate:TranslateTexton*(no resource-level scope); the 10,000-byte limit is a byte count not a character count — measure withnew TextEncoder().encode(text).lengthand chunk at sentence boundaries: WhenSourceLanguageCodeis'auto', Translate calls Amazon Comprehend internally to detect the source language — this requirescomprehend:DetectDominantLanguagein the IAM policy in addition totranslate:TranslateText. Auto-detect accuracy drops for inputs under 20 characters; for very short fragments, pass the source language explicitly or use a ComprehendDetectDominantLanguageCommandpre-step with confidence scoring. The optionalSettingsobject controlsFormality('FORMAL'or'INFORMAL') andProfanity('MASK'); always checkresponse.AppliedSettings?.Formalityto verify the setting was honored — not all target languages support formality control, and an unsupported pair silently returns a successful translation without applying formality. - Custom terminology — import a CSV with
ImportTerminologyCommand({ Name, MergeStrategy: 'OVERWRITE', TerminologyData: { File: csvBuffer, Format: 'CSV', Directionality: 'MULTI' } }); apply by passingTerminologyNames: ['terminology-name']toTranslateTextCommand; checkresponse.AppliedTerminologies[].Terms[].SourceTextto confirm terms were matched; MULTI directionality supports one source → many target languages in a single file, UNI supports exactly one source → one target language: MULTI CSV format: a header row of language codes (en,es,fr,de,ja), followed by one row per term where the first column is the source text and each remaining column is the approved target translation in that language. UNI CSV format: exactly two columns (source, target). Case-sensitive by default —MCP servermatches the source textMCP serverbut notmcp serverorMCP Server. Up to 1 terminology perTranslateTextCommandcall. The terminology must match theSourceLanguageCodeof the translation; a mismatch raisesResourceNotFoundExceptionat call time, not at import time. Up to 256 entries in a MULTI terminology file; up to 10 target language columns. - Document translation — call
TranslateDocumentCommand({ Document: { Content: new TextEncoder().encode(htmlString), ContentType: 'text/html' }, SourceLanguageCode: 'auto', TargetLanguageCode: 'es' }); decode the response withnew TextDecoder().decode(response.TranslatedDocument!.Content); HTML tags, attributes, classes, hrefs, and src values are preserved unchanged — only text nodes inside elements are translated;<script>,<style>,<code>, and<pre>blocks are skipped; the synchronous 100KB content limit makes this right for single-document use cases up to that size: Word .docx documents are supported asContentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'— pass raw binary bytes, receive translated binary bytes, write them directly to a.docxfile. Bold, italic, tables, headings, and image layout are preserved; comments and tracked changes are not translated. For inputs over 100KB, use async batch jobs. For plain text under 10KB,TranslateTextCommandis simpler (no Uint8Array encoding needed). Custom terminologies and formality settings work identically inTranslateDocumentCommandandTranslateTextCommand. - Async batch jobs — upload source files to S3, then call
StartTextTranslationJobCommand({ JobName, InputDataConfig: { S3Uri, ContentType }, OutputDataConfig: { S3Uri }, DataAccessRoleArn, SourceLanguageCode, TargetLanguageCodes: ['es','fr','de'] }); store the returnedJobId; pollDescribeTextTranslationJobCommand({ JobId })every 30–60 seconds untilJobDetails.JobStatusisCOMPLETED,COMPLETED_WITH_ERROR, orFAILED; the IAM data access role must trusttranslate.amazonaws.comand haves3:GetObject+s3:ListBucketon the input bucket ands3:PutObjecton the output prefix: The calling principal needsiam:PassRolewith aiam:PassedToService: translate.amazonaws.comcondition to pass the data access role inDataAccessRoleArn— without this, the job submission raisesInvalidRequestException: role cannot be passed. The output is written to a subdirectory named{JobId}-{ISO8601timestamp}inside the output S3 prefix — you do not know the exact path until the job completes; readJobDetails.OutputDataConfig.S3Urifrom the completed job to get the exact path.COMPLETED_WITH_ERRORmeans some files were translated and some failed — readJobDetails/TranslationDetails.jsonin the output prefix for a file-level error report.FAILEDmeans the entire job failed; submit a new job rather than retrying the failed one.
Pattern 1: TranslateTextCommand, language auto-detection, formality, and 10,000-byte chunking
When an MCP server tool needs to translate short strings — user messages, UI labels, product descriptions, support ticket content — Amazon Translate text translation is the right starting point. The model is simple: construct one client, send one command per string, read the translated result from the response. The fundamental constraints are the byte limit (10,000 UTF-8 bytes, not characters) and IAM (the action does not support resource-level restrictions — the resource must be *).
Client setup
The Translate SDK is in the @aws-sdk/client-translate package, separate from other AWS AI services. Construct the TranslateClient once at module initialization — it is stateless and safe to share across concurrent tool calls.
import {
TranslateClient,
TranslateTextCommand,
type TranslateTextCommandInput,
type TranslateTextCommandOutput,
} from '@aws-sdk/client-translate';
const translate = new TranslateClient({
region: process.env.AWS_REGION ?? 'us-east-1',
// Credentials resolve from the standard chain:
// 1. AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY env vars
// 2. ~/.aws/credentials file
// 3. ECS task role / Lambda execution role / EC2 instance profile (preferred for production)
});
Translate is not available in every AWS region. Full language-pair coverage is widest in us-east-1, us-west-2, eu-west-1, and ap-northeast-1. If your MCP server runs in another region, verify that your required language pairs are available before deploying.
Basic translation and auto-detect
Setting SourceLanguageCode to 'auto' delegates language detection to Amazon Comprehend internally. The detected language code is returned in response.SourceLanguageCode so you can record or display it. The TargetLanguageCode must be an explicit BCP 47 code — 'auto' is not valid as a target.
export async function translateText(
text: string,
targetLang: string,
sourceLang = 'auto',
): Promise<{ translated: string; detectedSource: string }> {
const response: TranslateTextCommandOutput = await translate.send(
new TranslateTextCommand({
Text: text,
SourceLanguageCode: sourceLang, // 'auto' triggers Comprehend language detection internally
TargetLanguageCode: targetLang, // BCP 47 — 'es', 'fr', 'zh', 'ja', 'de', 'ko', etc.
}),
);
return {
translated: response.TranslatedText ?? '',
detectedSource: response.SourceLanguageCode ?? sourceLang,
};
}
// Usage in an MCP tool:
// const { translated, detectedSource } = await translateText(tool.input.text, 'es');
// → { translated: 'Hola, mundo', detectedSource: 'en' }
When SourceLanguageCode is 'auto', the IAM policy needs both translate:TranslateText and comprehend:DetectDominantLanguage — Comprehend is called internally by Translate, using your execution role's permissions. If comprehend:DetectDominantLanguage is missing, the call succeeds silently for language pairs Translate can infer, but fails with InternalServerException for ambiguous inputs.
Language code reference
Amazon Translate uses IETF BCP 47 language tags. The table below covers the most common codes for MCP server builders.
| Language | Code | Formality support |
|---|---|---|
| English | en | Source only (formality not applicable) |
| Spanish | es | Yes — FORMAL / INFORMAL |
| French | fr | Yes |
| German | de | Yes |
| Japanese | ja | Yes |
| Korean | ko | Yes |
| Dutch | nl | Yes |
| Portuguese (Brazilian) | pt | Yes |
| Portuguese (European) | pt-PT | Yes |
| Italian | it | Yes |
| Hindi | hi | Yes |
| Chinese Simplified | zh | No |
| Chinese Traditional | zh-TW | No |
| Arabic | ar | No — check UI rendering for RTL |
| Russian | ru | No |
| Turkish | tr | No |
| Auto-detect | auto | Source only — not valid as TargetLanguageCode |
Not all language pairs are directly supported. Some pairs route through English as a pivot language internally. The UnsupportedLanguagePairException is returned when a pair cannot be bridged even via pivot.
Formality and profanity settings
The optional Settings object controls two quality dimensions. Always verify the setting was applied using response.AppliedSettings — a language pair that does not support formality will return a successful translation without it, with no error or warning.
const formalResponse = await translate.send(new TranslateTextCommand({
Text: "Hey, can you send me the quarterly report?",
SourceLanguageCode: 'en',
TargetLanguageCode: 'de',
Settings: {
Formality: 'FORMAL', // 'FORMAL' | 'INFORMAL' — not all target languages support this
Profanity: 'MASK', // 'MASK' — replaces profane words with **** in output
},
}));
// Verify formality was actually honored — always check AppliedSettings
if (formalResponse.AppliedSettings?.Formality !== 'FORMAL') {
console.warn(
'Formality not applied for target language:',
formalResponse.TargetLanguageCode,
);
}
const result = formalResponse.TranslatedText;
// German formal: "Könnten Sie mir bitte den Quartalsbericht zusenden?"
Measuring input size and chunking at sentence boundaries
The 10,000-byte limit is a byte count, not a character count. JavaScript's string.length property counts UTF-16 code units, not UTF-8 bytes. A string with 5,000 Japanese characters contains approximately 15,000 UTF-8 bytes and will exceed the limit even though text.length === 5000. Always measure with TextEncoder and chunk at sentence boundaries to preserve translation quality.
const TRANSLATE_BYTE_LIMIT = 9500; // leave headroom below the 10,000-byte hard limit
function chunkBySentence(text: string, byteLimit: number): string[] {
const encoder = new TextEncoder();
const chunks: string[] = [];
// Split on sentence-ending punctuation followed by whitespace or end-of-string
const sentences = text.split(/(?<=[.!?。!?。])\s*/u);
let current = '';
for (const sentence of sentences) {
const candidate = current ? current + ' ' + sentence : sentence;
if (encoder.encode(candidate).length > byteLimit) {
if (current) chunks.push(current.trim());
current = sentence;
} else {
current = candidate;
}
}
if (current) chunks.push(current.trim());
return chunks;
}
export async function translateLongText(
text: string,
targetLang: string,
sourceLang = 'auto',
): Promise<string> {
const encoder = new TextEncoder();
if (encoder.encode(text).length <= TRANSLATE_BYTE_LIMIT) {
// Short enough for a single call
const response = await translate.send(new TranslateTextCommand({
Text: text,
SourceLanguageCode: sourceLang,
TargetLanguageCode: targetLang,
}));
return response.TranslatedText ?? '';
}
// Chunk and translate sequentially — preserve sentence order
const chunks = chunkBySentence(text, TRANSLATE_BYTE_LIMIT);
const translated: string[] = [];
for (const chunk of chunks) {
const response = await translate.send(new TranslateTextCommand({
Text: chunk,
SourceLanguageCode: sourceLang,
TargetLanguageCode: targetLang,
}));
translated.push(response.TranslatedText ?? '');
sourceLang = response.SourceLanguageCode ?? sourceLang; // pin detected language for subsequent chunks
}
return translated.join(' ');
}
Chunking at sentence boundaries rather than byte offsets is critical: splitting mid-sentence produces fragments that the neural model may mistranslate because they lack grammatical context. The regex above handles both ASCII sentence punctuation (.!?) and East Asian sentence terminators (。!?). For structured text like HTML or Markdown, use TranslateDocumentCommand instead — it handles chunking internally and preserves formatting.
IAM policy
Translate actions do not support resource-level restrictions. The resource must be *. When using SourceLanguageCode: 'auto', add the Comprehend action.
{
"Effect": "Allow",
"Action": [
"translate:TranslateText",
"comprehend:DetectDominantLanguage" // only needed when SourceLanguageCode is 'auto'
],
"Resource": "*"
}
Pattern 2: Custom terminology — ImportTerminologyCommand, CSV format, and UNI vs MULTI directionality
Neural translation models produce high-quality general-purpose translations but will paraphrase domain-specific terms — product names, proprietary technology, brand vocabulary, and technical jargon — in unpredictable ways. Amazon Translate custom terminologies enforce exact translations for these terms: when a source term from the terminology file appears in the input, Translate substitutes your pre-approved translation verbatim rather than generating one. The two most important design decisions are directionality (UNI for one target language, MULTI for many) and case sensitivity (matches are exact by default).
CSV file format: MULTI vs UNI directionality
A MULTI-directionality terminology file has a header row of language codes, followed by one row per term. Each row's first column is the source text; subsequent columns are the approved translations in those target languages. Leave cells empty for languages where you have no approved translation yet.
# terminology-multi.csv — MULTI directionality (source → many targets in one file)
en,es,fr,de,ja,ko
MCP server,servidor MCP,serveur MCP,MCP-Server,MCPサーバー,MCP 서버
uptime monitoring,monitoreo de tiempo de actividad,surveillance de disponibilité,Verfügbarkeitsüberwachung,稼働時間監視,가동 시간 모니터링
health check,verificación de estado,vérification de l'état,Integritätsprüfung,ヘルスチェック,상태 확인
AliveMCP,AliveMCP,AliveMCP,AliveMCP,AliveMCP,AliveMCP
A UNI-directionality terminology file has exactly two columns: source term and target term. It maps one source language to exactly one target language — the target language must match the TargetLanguageCode at translation time exactly, or the terminology is silently ignored.
# terminology-uni.csv — UNI directionality (source → single target only)
en,es
MCP server,servidor MCP
uptime monitoring,monitoreo de tiempo de actividad
health check,verificación de estado
When to use each: use MULTI when your MCP server translates to multiple target languages and you want one terminology file to cover all of them. Use UNI when you only ever translate into one target language, or when different target languages need different approved vocabularies. The directionality mode must be set at import time and cannot be changed later — to change it, re-import with the same name and MergeStrategy: 'OVERWRITE'.
Importing with ImportTerminologyCommand
Terminologies are account-and-region-scoped — a terminology imported in us-east-1 is not visible in eu-west-1. Import once per region at deploy time or on first run, not on every translation call. The MergeStrategy: 'OVERWRITE' option replaces the existing terminology if a file with the same name already exists.
import {
TranslateClient,
ImportTerminologyCommand,
type ImportTerminologyCommandInput,
} from '@aws-sdk/client-translate';
import { readFileSync } from 'fs';
const translate = new TranslateClient({ region: process.env.AWS_REGION ?? 'us-east-1' });
export async function importTerminology(
name: string,
csvPath: string,
directionality: 'UNI' | 'MULTI' = 'MULTI',
): Promise<void> {
const csvBuffer = readFileSync(csvPath); // Buffer / Uint8Array — raw CSV bytes
const input: ImportTerminologyCommandInput = {
Name: name, // unique name within the account+region
MergeStrategy: 'OVERWRITE', // 'OVERWRITE' (replace) | 'FAIL_ON_CONFLICT' (error if exists)
TerminologyData: {
File: csvBuffer, // raw bytes of the CSV file — UTF-8 without BOM
Format: 'CSV', // 'CSV' | 'TMX'
Directionality: directionality, // 'UNI' | 'MULTI'
},
Description: 'Domain terminology for MCP server vocabulary enforcement',
};
const response = await translate.send(new ImportTerminologyCommand(input));
// TermCount tells you how many entries Translate parsed successfully
// If lower than expected: check for BOM, encoding issues, or CSV formatting errors
console.log('Terminology imported:', {
name: response.TerminologyProperties?.Name,
sourceLanguage: response.TerminologyProperties?.SourceLanguageCode,
targetLanguages: response.TerminologyProperties?.TargetLanguageCodes,
termCount: response.TerminologyProperties?.TermCount,
});
}
// Deploy-time setup:
// await importTerminology('mcp-domain-terms', './terminology/domain-terms-multi.csv', 'MULTI');
UTF-8 without BOM is required for CSV files. A BOM at the start of the file causes the first language code in the header row to be parsed incorrectly, reducing TermCount silently. If your text editor adds a BOM, strip it before importing. Up to 100 terminologies per account; each file up to 10MB; up to 256 entries in a MULTI terminology.
Applying terminology in TranslateTextCommand
Pass the terminology name in TerminologyNames when calling TranslateTextCommand. The applied terminology must match the SourceLanguageCode of the translation — for MULTI terminologies this means the first column's language code matches; for UNI terminologies it means both source and target columns match. A mismatch raises ResourceNotFoundException at call time.
export async function translateWithTerminology(
text: string,
targetLang: string,
terminologyName: string,
): Promise<{ translated: string; appliedTerms: string[] }> {
const response = await translate.send(new TranslateTextCommand({
Text: text,
SourceLanguageCode: 'en', // must match the source column in the terminology file
TargetLanguageCode: targetLang, // for UNI: must match the target column exactly
TerminologyNames: [terminologyName], // array; currently max 1 custom terminology per call
}));
// AppliedTerminologies tells you which terms were found in the input and substituted
const appliedTerms = (response.AppliedTerminologies ?? []).flatMap(t =>
(t.Terms ?? []).map(term => `${term.SourceText} → ${term.TargetText}`),
);
return {
translated: response.TranslatedText ?? '',
appliedTerms,
};
}
// Example output when "MCP server health check" is translated to Spanish:
// { translated: 'verificación de estado del servidor MCP',
// appliedTerms: ['MCP server → servidor MCP', 'health check → verificación de estado'] }
Case sensitivity and debugging non-matches
Terminology matching is case-sensitive. The term MCP server matches the source text MCP server but not mcp server or MCP Server. When a term you expected to be substituted does not appear in AppliedTerminologies, check the casing of the term in the input against the casing in the CSV file. The usual fix is to add additional rows in the CSV for each casing variant you want to protect.
| Situation | Symptom | Fix |
|---|---|---|
| UNI terminology, translated to different target language | AppliedTerminologies is empty; no substitution | Switch to MULTI directionality; re-import |
| Term not matching due to casing | AppliedTerminologies is empty for that term | Add rows for each casing variant in CSV |
| TermCount lower than expected | Fewer terms applied than were in the CSV | Check for BOM, encoding, or extra commas in CSV |
| ResourceNotFoundException at call time | Terminology name mismatch or region mismatch | Verify name spelling and that terminology was imported in the same region as the client |
| Term at start of sentence not matched | Terminology hit rate drops for sentence-initial terms | No fix needed — Translate handles capitalization normalization for sentence-initial terms; re-test with the actual text that failed |
Up to 1 custom terminology can be applied per TranslateTextCommand call. If your MCP server needs to enforce two separate vocabulary sets simultaneously, combine them into a single terminology file and import the merged version.
Listing and managing terminologies
import {
ListTerminologiesCommand,
DeleteTerminologyCommand,
GetTerminologyCommand,
} from '@aws-sdk/client-translate';
// List all terminologies in the region
const listResponse = await translate.send(new ListTerminologiesCommand({}));
for (const t of listResponse.TerminologyPropertiesList ?? []) {
console.log(t.Name, t.TermCount, t.SourceLanguageCode, t.TargetLanguageCodes);
}
// Delete a terminology when it is no longer needed
await translate.send(new DeleteTerminologyCommand({ Name: 'old-terminology' }));
// Download the original file you imported
const getResponse = await translate.send(new GetTerminologyCommand({
Name: 'mcp-domain-terms',
TerminologyDataFormat: 'CSV',
}));
// getResponse.TerminologyDataLocation.PresignedUrl — download the original CSV
Pattern 3: Document translation — TranslateDocumentCommand, HTML format preservation, and Word .docx support
When an MCP server tool needs to translate structured content — an HTML page, a Markdown-rendered document, or a Word file — using TranslateTextCommand strips all markup and returns plain translated text. TranslateDocumentCommand preserves structure: it recognizes HTML tags, attributes, and document formatting, translates only the text nodes inside elements, and returns the translated document with the original markup intact. The key difference from async batch jobs is immediacy — TranslateDocumentCommand is synchronous and returns the translated bytes in the response body, requiring no S3 setup and no polling.
Supported content types and when to use each
| ContentType | Input | Format preserved | Limit |
|---|---|---|---|
text/plain | UTF-8 plain text | Whitespace structure only | 100KB |
text/html | Valid HTML markup | Tags, attributes, classes, hrefs, src values, structure | 100KB |
application/vnd.openxmlformats-officedocument.wordprocessingml.document | Word .docx binary | Bold, italic, tables, headings, images layout | 100KB |
Choosing between TranslateTextCommand and TranslateDocumentCommand for plain text: use TranslateTextCommand for inputs under 10KB (simpler, no encoding needed); use TranslateDocumentCommand for plain text inputs between 10KB and 100KB. For inputs over 100KB, use async batch jobs.
Translating an HTML document
TranslateDocumentCommand expects the document content as a Uint8Array (or Buffer, which is a subclass). Use TextEncoder to encode the HTML string to bytes before the call, and TextDecoder to decode the response bytes back to a string. This is the most common mistake — passing a plain string directly to Document.Content causes a TypeScript type error or a runtime marshalling error depending on the SDK version.
import {
TranslateClient,
TranslateDocumentCommand,
type TranslateDocumentCommandInput,
} from '@aws-sdk/client-translate';
const translate = new TranslateClient({ region: process.env.AWS_REGION ?? 'us-east-1' });
export async function translateHtml(
htmlContent: string,
targetLang: string,
sourceLang = 'auto',
): Promise<string> {
const encoder = new TextEncoder();
const decoder = new TextDecoder('utf-8');
const input: TranslateDocumentCommandInput = {
Document: {
Content: encoder.encode(htmlContent), // string → Uint8Array (required)
ContentType: 'text/html',
},
SourceLanguageCode: sourceLang,
TargetLanguageCode: targetLang,
// TerminologyNames: ['mcp-domain-terms'], // optional custom terminology
// Settings: { Formality: 'FORMAL' }, // optional formality (checks AppliedSettings)
};
const response = await translate.send(new TranslateDocumentCommand(input));
if (!response.TranslatedDocument?.Content) {
throw new Error('TranslateDocumentCommand returned empty content');
}
// Uint8Array → string decode (always specify 'utf-8' explicitly)
return decoder.decode(response.TranslatedDocument.Content);
}
// Example: translate a product description page
// const translatedHtml = await translateHtml(
// '<h1>MCP Server Monitoring</h1><p>Real-time uptime checks every 60 seconds.</p>',
// 'fr',
// );
// → '<h1>Surveillance du serveur MCP</h1><p>Vérifications de disponibilité en temps réel toutes les 60 secondes.</p>'
What HTML elements are translated and what are skipped
Translate skips the content of some HTML elements by default to protect code and presentation assets from inadvertent translation. Understanding which elements are skipped prevents surprising results.
| Element | Content translated? | Notes |
|---|---|---|
<p>, <h1>–<h6>, <li>, <td> | Yes | Standard content elements |
<a> text content | Yes | href attribute is preserved unchanged |
<img> alt attribute | Yes | src, class, id preserved |
<title> | Yes | Page title is translated |
<script> | No — skipped | JavaScript is not translated |
<style> | No — skipped | CSS is not translated |
<code>, <pre> | No — skipped | Code blocks passed through unchanged — desired behavior for technical documentation |
If you have localization strings inside <script> tags (for example, a JSON blob of UI strings), move them to <data> attributes or separate JSON files before calling the API. Script content is never translated.
Translating a Word .docx document
Word documents are passed as raw binary bytes — the OOXML format. Text runs, paragraph formatting, table cells, heading levels, and image layout are preserved; comments, tracked changes, and document metadata are not translated.
import { readFileSync, writeFileSync } from 'fs';
import { TranslateClient, TranslateDocumentCommand } from '@aws-sdk/client-translate';
const translate = new TranslateClient({ region: process.env.AWS_REGION ?? 'us-east-1' });
export async function translateDocx(
inputPath: string,
outputPath: string,
targetLang: string,
sourceLang = 'auto',
): Promise<void> {
const docxBytes = readFileSync(inputPath); // Buffer is a Uint8Array subclass — works directly
const response = await translate.send(new TranslateDocumentCommand({
Document: {
Content: docxBytes,
ContentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
},
SourceLanguageCode: sourceLang,
TargetLanguageCode: targetLang,
}));
if (!response.TranslatedDocument?.Content) {
throw new Error('TranslateDocumentCommand returned empty content for .docx');
}
// Write translated binary directly — it is a valid .docx file
writeFileSync(outputPath, Buffer.from(response.TranslatedDocument.Content));
}
// Example:
// await translateDocx('./reports/q3-en.docx', './reports/q3-es.docx', 'es');
The translated .docx file can be opened directly in Word or uploaded to Google Drive for conversion. Images and embedded objects are carried over as binary blobs without modification. Tables with merged cells are preserved correctly. Font names may be substituted if the target language requires a different character set that the source font does not cover — this is expected behavior for CJK translations.
Splitting large documents before calling TranslateDocumentCommand
The 100KB limit applies to the Document.Content bytes, not to rendered text. For HTML documents, most of those bytes are taken up by tags, attributes, and formatting — a 100KB HTML file contains significantly fewer than 100KB of translatable text. For typical MCP server use cases (product pages, error messages, support content), 100KB is enough. For larger documents, split on structural boundaries before calling the API.
import { JSDOM } from 'jsdom'; // npm install jsdom
function splitHtmlBySections(html: string, byteLimit: number): string[] {
const encoder = new TextEncoder();
const dom = new JSDOM(html);
const body = dom.window.document.body;
const chunks: string[] = [];
let current = '';
for (const child of Array.from(body.children)) {
const childHtml = child.outerHTML;
const candidate = current + '\n' + childHtml;
if (encoder.encode(candidate).length > byteLimit && current) {
chunks.push(`<html><body>${current}</body></html>`);
current = childHtml;
} else {
current = candidate;
}
}
if (current) chunks.push(`<html><body>${current}</body></html>`);
return chunks;
}
Pattern 4: Async batch jobs — StartTextTranslationJobCommand, IAM data access role, and job lifecycle
When an MCP server tool needs to translate a corpus of files — an entire product catalog, a documentation set, a directory of support tickets — making hundreds of individual TranslateTextCommand calls is inefficient and hard to track. Amazon Translate async batch jobs handle this workload natively: you point Translate at an S3 input prefix, specify one or more target languages, and submit a single job. Translate reads all files, translates them in parallel, and writes the output to a separate S3 prefix with one subdirectory per target language. The two most common failure modes are a missing s3:ListBucket permission on the data access role (causes immediate job failure with an opaque S3 error) and a missing iam:PassRole permission on the calling principal (causes InvalidRequestException at job submission).
Job architecture and S3 structure
# Input structure — all files must share the same ContentType
s3://my-bucket/translate-input/
report-q3.txt
product-spec.html
user-guide.docx
# Output structure after job COMPLETED
s3://my-bucket/translate-output/
{JobId}-{ISO8601timestamp}/
es/ # Spanish translations
report-q3.txt
product-spec.html
user-guide.docx
fr/ # French translations (same files)
report-q3.txt
product-spec.html
user-guide.docx
details/ # Job execution report
TranslationDetails.json
The output subdirectory name is {JobId}-{ISO8601timestamp} and is not known until the job completes. Use JobDetails.OutputDataConfig.S3Uri from the completed job description to get the exact output prefix, then list or download from there.
IAM data access role setup
Translate reads S3 input files and writes S3 output files using a dedicated IAM role that you create and pass as DataAccessRoleArn. This role must trust translate.amazonaws.com as a service principal. The s3:ListBucket permission is required (not just s3:GetObject) — Translate lists the input prefix to enumerate files before reading them. Apply s3:ListBucket to the bucket ARN (not the key prefix) — that is correct for ListBucket operations.
# Trust policy — allows Amazon Translate to assume this role
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "Service": "translate.amazonaws.com" },
"Action": "sts:AssumeRole"
}]
}
# Permission policy — S3 access for input (read) and output (write)
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "TranslateInputRead",
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:ListBucket"],
"Resource": [
"arn:aws:s3:::my-bucket", // s3:ListBucket applies to bucket ARN
"arn:aws:s3:::my-bucket/translate-input/*" // s3:GetObject applies to key prefix
]
},
{
"Sid": "TranslateOutputWrite",
"Effect": "Allow",
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::my-bucket/translate-output/*"
}
]
}
The calling principal (your Lambda execution role or ECS task role) also needs iam:PassRole with a condition scoped to Translate:
{
"Effect": "Allow",
"Action": "iam:PassRole",
"Resource": "arn:aws:iam::ACCOUNT:role/translate-data-access-role",
"Condition": {
"StringEquals": {
"iam:PassedToService": "translate.amazonaws.com"
}
}
}
Without iam:PassRole, StartTextTranslationJobCommand raises InvalidRequestException: The provided role ARN does not have permission to be assumed by the service. This is different from the data access role setup — the IAM error is about the calling principal, not the data access role itself.
Submitting a batch translation job
import {
TranslateClient,
StartTextTranslationJobCommand,
type StartTextTranslationJobCommandInput,
} from '@aws-sdk/client-translate';
const translate = new TranslateClient({ region: process.env.AWS_REGION ?? 'us-east-1' });
export async function startTranslationJob(opts: {
jobName: string;
inputS3Uri: string; // s3://my-bucket/translate-input/ — prefix, not individual file
outputS3Uri: string; // s3://my-bucket/translate-output/
dataAccessRoleArn: string; // ARN of the IAM role that trusts translate.amazonaws.com
sourceLanguage: string; // 'auto' or specific BCP 47 code
targetLanguages: string[]; // ['es', 'fr', 'de'] — Translate to all simultaneously in one job
contentType: string; // all input files must share the same content type
terminologyNames?: string[]; // optional custom terminologies applied to all files
}): Promise<string> { // returns JobId
const input: StartTextTranslationJobCommandInput = {
JobName: opts.jobName,
InputDataConfig: {
S3Uri: opts.inputS3Uri,
ContentType: opts.contentType, // 'text/plain' | 'text/html' | 'application/vnd...'
},
OutputDataConfig: {
S3Uri: opts.outputS3Uri,
},
DataAccessRoleArn: opts.dataAccessRoleArn,
SourceLanguageCode: opts.sourceLanguage,
TargetLanguageCodes: opts.targetLanguages,
TerminologyNames: opts.terminologyNames,
};
const response = await translate.send(new StartTextTranslationJobCommand(input));
if (!response.JobId) throw new Error('No JobId returned from StartTextTranslationJobCommand');
console.log('Translation job started:', response.JobId, response.JobStatus);
return response.JobId;
}
// Two-tool MCP pattern — submit and check separately:
// const jobId = await startTranslationJob({ ... }); // tool: translate_corpus_start
// const status = await checkTranslationJob(jobId); // tool: translate_corpus_status
Polling job status
StartTextTranslationJobCommand returns a JobId immediately — the actual translation runs asynchronously for minutes to hours depending on corpus size. Poll DescribeTextTranslationJobCommand every 30–60 seconds; never block an MCP tool handler waiting for a long-running job. Use the two-tool pattern: one tool submits the job and returns the JobId, a second tool checks status given a JobId.
import {
DescribeTextTranslationJobCommand,
type JobDetails,
} from '@aws-sdk/client-translate';
export async function checkTranslationJob(jobId: string): Promise<{
status: string;
outputUri?: string;
submittedAt?: Date;
endTime?: Date;
inputDocumentCount?: number;
translatedDocumentCount?: number;
documentsWithErrorCount?: number;
}> {
const response = await translate.send(
new DescribeTextTranslationJobCommand({ JobId: jobId }),
);
const job = response.TextTranslationJobProperties;
if (!job) throw new Error(`Job ${jobId} not found`);
return {
status: job.JobStatus ?? 'UNKNOWN',
outputUri: job.OutputDataConfig?.S3Uri, // exact output prefix when COMPLETED
submittedAt: job.SubmittedTime,
endTime: job.EndTime,
inputDocumentCount: job.JobDetails?.InputDocumentsCount,
translatedDocumentCount: job.JobDetails?.TranslatedDocumentsCount,
documentsWithErrorCount: job.JobDetails?.DocumentsWithErrorsCount,
};
}
Job status state machine
| Status | Meaning | Action |
|---|---|---|
SUBMITTED | Job received, not yet processing | Continue polling |
IN_PROGRESS | Translating files | Continue polling (30–60 s interval) |
COMPLETED | All files translated successfully | Download from OutputDataConfig.S3Uri |
COMPLETED_WITH_ERROR | Some files translated, some failed | Download successful files; read TranslationDetails.json for file-level errors |
FAILED | Entire job failed — no output produced | Check IAM permissions, input file format, region availability; submit new job |
STOP_REQUESTED | StopTextTranslationJobCommand was called | Wait for STOPPED status |
STOPPED | Job was stopped before completion | Partial output may exist; submit new job for unprocessed files |
Reading COMPLETED_WITH_ERROR details
When a job finishes with COMPLETED_WITH_ERROR, the output prefix contains a details/TranslationDetails.json file with per-file error information. Download and parse this file to identify which input files failed and why.
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
const s3 = new S3Client({ region: process.env.AWS_REGION ?? 'us-east-1' });
export async function getTranslationErrors(
outputS3Uri: string, // from job.OutputDataConfig.S3Uri after COMPLETED_WITH_ERROR
): Promise<Array<{ input: string; error: string }>> {
// outputS3Uri = s3://my-bucket/translate-output/{JobId}-{timestamp}/
// TranslationDetails.json is in the 'details/' subdirectory
const bucket = outputS3Uri.replace('s3://', '').split('/')[0];
const prefix = outputS3Uri.replace(`s3://${bucket}/`, '');
const detailsKey = `${prefix}details/TranslationDetails.json`;
const response = await s3.send(new GetObjectCommand({
Bucket: bucket,
Key: detailsKey,
}));
const body = await response.Body?.transformToString('utf-8') ?? '{}';
const details = JSON.parse(body);
// TranslationDetails.json structure: { details: [{ sourceFile, targetFile, auxiliaryData: { errors[] } }] }
return (details.details ?? [])
.filter((d: { auxiliaryData?: { errors?: unknown[] } }) =>
(d.auxiliaryData?.errors?.length ?? 0) > 0,
)
.map((d: { sourceFile?: string; auxiliaryData?: { errors?: Array<{ errorCode?: string; message?: string }> } }) => ({
input: d.sourceFile ?? 'unknown',
error: (d.auxiliaryData?.errors ?? [])
.map(e => `${e.errorCode}: ${e.message}`)
.join('; '),
}));
}
Unlike FAILED (which requires a new job for the entire corpus), COMPLETED_WITH_ERROR means only some files need to be retranslated. Collect the failing file paths from TranslationDetails.json, upload them to a separate input prefix, and submit a new job for just those files.
Error handling for job submission
| Error | Cause | Fix |
|---|---|---|
InvalidRequestException: role cannot be passed | Calling principal lacks iam:PassRole | Add iam:PassRole with iam:PassedToService: translate.amazonaws.com condition |
| Job fails immediately with S3 access error | Missing s3:ListBucket on data access role | Add s3:ListBucket to the data access role scoped to the bucket ARN (not prefix) |
Job fails with FAILED for all files | ContentType mismatch — HTML files submitted as text/plain | Set InputDataConfig.ContentType to match actual file format; mixed formats require separate jobs |
TooManyRequestsException | Too many concurrent jobs per account | Default limit is 5 concurrent jobs; request increase via Service Quotas |
UnsupportedLanguagePairException | One of the TargetLanguageCodes is not supported from the source | Translate supported pairs from source language; submit separate jobs per supported language |
Putting it together: a complete multilingual MCP tool
The four patterns above compose into a working multilingual content MCP tool. The tool accepts text input and a target language, applies custom domain terminology, handles documents, and falls back gracefully when inputs exceed the synchronous limit.
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import {
TranslateClient,
TranslateTextCommand,
TranslateDocumentCommand,
} from '@aws-sdk/client-translate';
const translate = new TranslateClient({ region: process.env.AWS_REGION ?? 'us-east-1' });
const TERMINOLOGY = process.env.TRANSLATE_TERMINOLOGY_NAME; // optional env var
const BYTE_LIMIT = 9500;
const server = new McpServer({ name: 'multilingual-mcp', version: '1.0.0' });
server.tool('translate_content', {
text: z.string().describe('Text or HTML to translate'),
target_lang: z.string().describe('BCP 47 target language code, e.g. es, fr, de, ja'),
source_lang: z.string().optional().default('auto').describe('BCP 47 source code, or auto'),
format: z.enum(['plain', 'html']).optional().default('plain').describe('Input format'),
formality: z.enum(['FORMAL', 'INFORMAL', 'NONE']).optional().describe('Formality register'),
}, async ({ text, target_lang, source_lang, format, formality }) => {
const encoder = new TextEncoder();
const byteLength = encoder.encode(text).length;
// For HTML or inputs within the 100KB document limit, use TranslateDocumentCommand
if (format === 'html' && byteLength <= 100_000) {
const decoder = new TextDecoder('utf-8');
const response = await translate.send(new TranslateDocumentCommand({
Document: { Content: encoder.encode(text), ContentType: 'text/html' },
SourceLanguageCode: source_lang ?? 'auto',
TargetLanguageCode: target_lang,
TerminologyNames: TERMINOLOGY ? [TERMINOLOGY] : undefined,
Settings: formality && formality !== 'NONE' ? { Formality: formality } : undefined,
}));
const translated = decoder.decode(response.TranslatedDocument?.Content);
return {
content: [{
type: 'text' as const,
text: JSON.stringify({
translated,
format: 'html',
source_lang: response.SourceLanguageCode,
target_lang: response.TargetLanguageCode,
}),
}],
};
}
// For plain text within the 10KB limit, use TranslateTextCommand
if (byteLength <= BYTE_LIMIT) {
const response = await translate.send(new TranslateTextCommand({
Text: text,
SourceLanguageCode: source_lang ?? 'auto',
TargetLanguageCode: target_lang,
TerminologyNames: TERMINOLOGY ? [TERMINOLOGY] : undefined,
Settings: formality && formality !== 'NONE' ? { Formality: formality } : undefined,
}));
return {
content: [{
type: 'text' as const,
text: JSON.stringify({
translated: response.TranslatedText,
source_lang: response.SourceLanguageCode,
target_lang: response.TargetLanguageCode,
formality_applied: response.AppliedSettings?.Formality,
applied_terms: (response.AppliedTerminologies ?? []).flatMap(t =>
(t.Terms ?? []).map(term => `${term.SourceText}→${term.TargetText}`)),
}),
}],
};
}
// Plain text over 10KB — chunk and translate in sequence
const chunks = chunkBySentence(text, BYTE_LIMIT);
const translated: string[] = [];
let detectedSource = source_lang ?? 'auto';
for (const chunk of chunks) {
const response = await translate.send(new TranslateTextCommand({
Text: chunk,
SourceLanguageCode: detectedSource,
TargetLanguageCode: target_lang,
TerminologyNames: TERMINOLOGY ? [TERMINOLOGY] : undefined,
}));
translated.push(response.TranslatedText ?? '');
if (detectedSource === 'auto') detectedSource = response.SourceLanguageCode ?? 'auto';
}
return {
content: [{
type: 'text' as const,
text: JSON.stringify({
translated: translated.join(' '),
source_lang: detectedSource,
target_lang,
chunks_used: chunks.length,
}),
}],
};
});
Monitoring translation health with AliveMCP
Translation tools in MCP servers follow the same session-based calling pattern as other tools — each invocation is a new API call, and there is no persistent connection to maintain. AliveMCP probes your MCP server endpoint every 60 seconds to detect availability gaps, response timeouts, and schema changes. Translation failures at the Translate API layer surface as tool call errors visible in the uptime dashboard — including TextSizeLimitExceededException from oversized inputs, UnsupportedLanguagePairException from misconfigured language pairs, and throttle errors from burst traffic patterns. Register your MCP server at AliveMCP to get Slack or email alerts the moment your multilingual tools start failing, so you catch configuration drift before users notice.
Error reference
| Error | Cause | Fix |
|---|---|---|
TextSizeLimitExceededException | Input exceeds 10,000 bytes (UTF-8 byte count, not character count) | Measure with TextEncoder; chunk at sentence boundaries before calling |
UnsupportedLanguagePairException | Source→target language pair not supported by Translate | Check Amazon Translate supported language pairs documentation; some pairs require English as pivot |
ResourceNotFoundException on TranslateTextCommand | TerminologyName does not exist in this region, or language pair mismatch for UNI terminology | Verify terminology was imported in the same region; for UNI terminologies, verify source and target language match |
InvalidRequestException on StartTextTranslationJobCommand | Calling principal lacks iam:PassRole for the data access role | Add iam:PassRole with iam:PassedToService: translate.amazonaws.com condition to calling role |
| Batch job FAILED with S3 error | Data access role missing s3:ListBucket | Add s3:ListBucket to data access role; apply to bucket ARN, not key prefix ARN |
DetectedLanguageLowConfidenceException | Auto-detect confidence too low (very short input) | Pass SourceLanguageCode explicitly for inputs under 20 characters; use Comprehend DetectDominantLanguageCommand as a pre-step |
TooManyRequestsException | Throttle — batch job limit or API rate exceeded | Add jittered exponential backoff; request quota increase for concurrent batch jobs via Service Quotas |
| Formality not applied (no error) | Target language does not support formality setting | Check response.AppliedSettings?.Formality — if undefined or not matching, the language pair does not support it |
| Custom terminology not substituting | Case mismatch, directionality mismatch, or term not in input | Verify exact casing; check directionality; confirm term appears in Text exactly; read AppliedTerminologies response field for confirmation |