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

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.

LanguageCodeFormality support
EnglishenSource only (formality not applicable)
SpanishesYes — FORMAL / INFORMAL
FrenchfrYes
GermandeYes
JapanesejaYes
KoreankoYes
DutchnlYes
Portuguese (Brazilian)ptYes
Portuguese (European)pt-PTYes
ItalianitYes
HindihiYes
Chinese SimplifiedzhNo
Chinese Traditionalzh-TWNo
ArabicarNo — check UI rendering for RTL
RussianruNo
TurkishtrNo
Auto-detectautoSource 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.

SituationSymptomFix
UNI terminology, translated to different target languageAppliedTerminologies is empty; no substitutionSwitch to MULTI directionality; re-import
Term not matching due to casingAppliedTerminologies is empty for that termAdd rows for each casing variant in CSV
TermCount lower than expectedFewer terms applied than were in the CSVCheck for BOM, encoding, or extra commas in CSV
ResourceNotFoundException at call timeTerminology name mismatch or region mismatchVerify name spelling and that terminology was imported in the same region as the client
Term at start of sentence not matchedTerminology hit rate drops for sentence-initial termsNo 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

ContentTypeInputFormat preservedLimit
text/plainUTF-8 plain textWhitespace structure only100KB
text/htmlValid HTML markupTags, attributes, classes, hrefs, src values, structure100KB
application/vnd.openxmlformats-officedocument.wordprocessingml.documentWord .docx binaryBold, italic, tables, headings, images layout100KB

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.

ElementContent translated?Notes
<p>, <h1>–<h6>, <li>, <td>YesStandard content elements
<a> text contentYeshref attribute is preserved unchanged
<img> alt attributeYessrc, class, id preserved
<title>YesPage title is translated
<script>No — skippedJavaScript is not translated
<style>No — skippedCSS is not translated
<code>, <pre>No — skippedCode 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

StatusMeaningAction
SUBMITTEDJob received, not yet processingContinue polling
IN_PROGRESSTranslating filesContinue polling (30–60 s interval)
COMPLETEDAll files translated successfullyDownload from OutputDataConfig.S3Uri
COMPLETED_WITH_ERRORSome files translated, some failedDownload successful files; read TranslationDetails.json for file-level errors
FAILEDEntire job failed — no output producedCheck IAM permissions, input file format, region availability; submit new job
STOP_REQUESTEDStopTextTranslationJobCommand was calledWait for STOPPED status
STOPPEDJob was stopped before completionPartial 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

ErrorCauseFix
InvalidRequestException: role cannot be passedCalling principal lacks iam:PassRoleAdd iam:PassRole with iam:PassedToService: translate.amazonaws.com condition
Job fails immediately with S3 access errorMissing s3:ListBucket on data access roleAdd s3:ListBucket to the data access role scoped to the bucket ARN (not prefix)
Job fails with FAILED for all filesContentType mismatch — HTML files submitted as text/plainSet InputDataConfig.ContentType to match actual file format; mixed formats require separate jobs
TooManyRequestsExceptionToo many concurrent jobs per accountDefault limit is 5 concurrent jobs; request increase via Service Quotas
UnsupportedLanguagePairExceptionOne of the TargetLanguageCodes is not supported from the sourceTranslate 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

ErrorCauseFix
TextSizeLimitExceededExceptionInput exceeds 10,000 bytes (UTF-8 byte count, not character count)Measure with TextEncoder; chunk at sentence boundaries before calling
UnsupportedLanguagePairExceptionSource→target language pair not supported by TranslateCheck Amazon Translate supported language pairs documentation; some pairs require English as pivot
ResourceNotFoundException on TranslateTextCommandTerminologyName does not exist in this region, or language pair mismatch for UNI terminologyVerify terminology was imported in the same region; for UNI terminologies, verify source and target language match
InvalidRequestException on StartTextTranslationJobCommandCalling principal lacks iam:PassRole for the data access roleAdd iam:PassRole with iam:PassedToService: translate.amazonaws.com condition to calling role
Batch job FAILED with S3 errorData access role missing s3:ListBucketAdd s3:ListBucket to data access role; apply to bucket ARN, not key prefix ARN
DetectedLanguageLowConfidenceExceptionAuto-detect confidence too low (very short input)Pass SourceLanguageCode explicitly for inputs under 20 characters; use Comprehend DetectDominantLanguageCommand as a pre-step
TooManyRequestsExceptionThrottle — batch job limit or API rate exceededAdd jittered exponential backoff; request quota increase for concurrent batch jobs via Service Quotas
Formality not applied (no error)Target language does not support formality settingCheck response.AppliedSettings?.Formality — if undefined or not matching, the language pair does not support it
Custom terminology not substitutingCase mismatch, directionality mismatch, or term not in inputVerify exact casing; check directionality; confirm term appears in Text exactly; read AppliedTerminologies response field for confirmation