Guide · AWS Transcribe · Batch Transcription
AWS Transcribe Batch Jobs in MCP Servers — StartTranscriptionJob, Speaker Diarization, Language Identification
AWS Transcribe converts audio files stored in S3 into structured JSON transcripts asynchronously — the job runs in the background and your MCP tool polls for completion rather than blocking a synchronous response. An MCP server wraps StartTranscriptionJobCommand and GetTranscriptionJobCommand so an AI agent can submit an audio file, poll until the job is COMPLETED, fetch the transcript JSON from S3, and return structured text with optional speaker labels. This guide covers the full job lifecycle: input media constraints, language identification vs a fixed language code, speaker diarization settings, transcript JSON parsing, PII redaction, and IAM setup.
TL;DR
Call StartTranscriptionJobCommand with a unique job name, the S3 URI of your audio, and either a LanguageCode or IdentifyLanguage: true. Set OutputBucketName so the transcript lands in your own S3 bucket (omitting it uses an AWS-managed bucket with a pre-signed URL that expires). Poll GetTranscriptionJobCommand every 15–30 seconds until TranscriptionJobStatus is COMPLETED or FAILED. The completed job returns a Transcript.TranscriptFileUri — fetch that JSON from S3 to get results.transcripts[0].transcript (full text) and results.items[] (word-level timestamps). Enable Settings.ShowSpeakerLabels: true with Settings.MaxSpeakerLabels: N (2–10) to get per-word speaker attribution in results.speaker_labels.
Basic tool implementation
import {
TranscribeClient,
StartTranscriptionJobCommand,
GetTranscriptionJobCommand,
TranscriptionJobStatus,
} from '@aws-sdk/client-transcribe';
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
const transcribe = new TranscribeClient({ region: 'us-east-1' });
const s3 = new S3Client({ region: 'us-east-1' });
const server = new McpServer({ name: 'transcribe-tools', version: '1.0.0' });
server.tool('transcribe_audio', {
s3_uri: z.string().regex(/^s3:\/\//).describe('Audio file URI, e.g. s3://bucket/key.mp3'),
language_code: z.string().optional().describe('BCP-47 code, e.g. en-US. Omit to auto-detect.'),
output_bucket: z.string().describe('S3 bucket for transcript output'),
show_speakers: z.boolean().default(false),
max_speakers: z.number().int().min(2).max(10).default(2),
}, async ({ s3_uri, language_code, output_bucket, show_speakers, max_speakers }) => {
const jobName = `mcp-transcribe-${Date.now()}`;
await transcribe.send(new StartTranscriptionJobCommand({
TranscriptionJobName: jobName,
Media: { MediaFileUri: s3_uri },
// Omit MediaFormat — Transcribe auto-detects from the file extension
...(language_code
? { LanguageCode: language_code }
: { IdentifyLanguage: true }),
OutputBucketName: output_bucket,
OutputKey: `transcripts/${jobName}.json`,
Settings: show_speakers
? { ShowSpeakerLabels: true, MaxSpeakerLabels: max_speakers }
: undefined,
}));
// Poll until COMPLETED or FAILED (batch jobs take 15 seconds to 20 minutes)
let job;
for (let attempt = 0; attempt < 60; attempt++) {
await new Promise(r => setTimeout(r, 20_000)); // 20-second poll interval
const { TranscriptionJob } = await transcribe.send(
new GetTranscriptionJobCommand({ TranscriptionJobName: jobName })
);
job = TranscriptionJob;
if (job?.TranscriptionJobStatus === TranscriptionJobStatus.COMPLETED) break;
if (job?.TranscriptionJobStatus === TranscriptionJobStatus.FAILED) {
return {
content: [{
type: 'text',
text: JSON.stringify({ error: 'FAILED', reason: job.FailureReason }),
}],
isError: true,
};
}
}
if (!job?.Transcript?.TranscriptFileUri) {
return {
content: [{ type: 'text', text: JSON.stringify({ error: 'timeout or missing URI' }) }],
isError: true,
};
}
// Fetch and parse the transcript JSON from S3
const [, bucket, ...keyParts] = job.Transcript.TranscriptFileUri.replace('s3://', '').split('/');
// Alternatively the URI may be an https:// URL — parse accordingly
const transcript = await fetchTranscriptJson(output_bucket, `transcripts/${jobName}.json`);
return {
content: [{
type: 'text',
text: JSON.stringify({
job_name: jobName,
language: job.LanguageCode ?? job.IdentifiedLanguageScore,
text: transcript.results.transcripts[0].transcript,
word_count: transcript.results.items.filter((i: any) => i.type === 'pronunciation').length,
speakers: show_speakers ? extractSpeakerTurns(transcript) : undefined,
}, null, 2),
}],
};
});
async function fetchTranscriptJson(bucket: string, key: string): Promise<any> {
const { Body } = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
const chunks: Uint8Array[] = [];
for await (const chunk of Body as AsyncIterable<Uint8Array>) chunks.push(chunk);
return JSON.parse(Buffer.concat(chunks).toString('utf-8'));
}
function extractSpeakerTurns(transcript: any) {
const labels: { speaker: string; words: string[] }[] = [];
let currentSpeaker = '';
let currentWords: string[] = [];
for (const item of transcript.results.items) {
if (item.type !== 'pronunciation') continue;
const speaker = item.speaker_label ?? 'spk_0';
if (speaker !== currentSpeaker) {
if (currentWords.length) labels.push({ speaker: currentSpeaker, words: currentWords });
currentSpeaker = speaker;
currentWords = [];
}
currentWords.push(item.alternatives[0].content);
}
if (currentWords.length) labels.push({ speaker: currentSpeaker, words: currentWords });
return labels.map(t => ({ speaker: t.speaker, text: t.words.join(' ') }));
}
Job parameters reference
| Parameter | Type | Notes |
|---|---|---|
TranscriptionJobName |
string | Unique per account per region. Use a timestamp or UUID suffix to avoid collisions. |
Media.MediaFileUri |
s3:// URI | Supported formats: MP3, MP4, WAV, FLAC, AMR, OGG, WEBM. Max 4 hours / 2 GB. |
LanguageCode |
BCP-47 | Mutually exclusive with IdentifyLanguage. Required if not auto-detecting. |
IdentifyLanguage |
boolean | Automatically detects language. Use LanguageOptions to narrow the candidates and reduce cost. |
LanguageOptions |
string[] | Hint list of BCP-47 codes to restrict auto-detection (e.g. ['en-US', 'es-US', 'fr-FR']). Improves accuracy and billing. |
OutputBucketName |
string | Strongly recommended. Omitting it uses an AWS-managed bucket with an expiring pre-signed URL — unreliable for async workflows. |
OutputKey |
string | Optional path within the output bucket. Defaults to jobName.json at bucket root. |
Settings.ShowSpeakerLabels |
boolean | Enables speaker diarization. Must also set MaxSpeakerLabels. |
Settings.MaxSpeakerLabels |
2–10 | Upper bound on distinct speakers Transcribe attempts to identify. Set to expected speaker count — a too-high value splits a single speaker across multiple labels. |
Settings.VocabularyName |
string | Applies a custom vocabulary to improve recognition of domain terms. See the custom vocabulary guide. |
Settings.VocabularyFilterName |
string | Applies a word filter. Use with VocabularyFilterMethod: mask | remove | tag. |
ContentRedaction.RedactionType |
PII |
Automatically detects and redacts PII (names, credit cards, SSNs, phone numbers) in the output transcript. |
Transcript JSON structure
The transcript file fetched from S3 is a JSON object. The two most useful fields are results.transcripts (full concatenated text) and results.items (word-level detail):
{
"jobName": "mcp-transcribe-1730678400000",
"accountId": "123456789012",
"results": {
"transcripts": [
{ "transcript": "Hello world this is a test." }
],
"items": [
{
"type": "pronunciation",
"start_time": "0.0",
"end_time": "0.49",
"alternatives": [{ "confidence": "0.9994", "content": "Hello" }],
"speaker_label": "spk_0" // only when ShowSpeakerLabels: true
},
{
"type": "punctuation", // no start_time / end_time for punctuation
"alternatives": [{ "confidence": "0.0", "content": "." }]
}
],
"speaker_labels": { // only when ShowSpeakerLabels: true
"channel_label": "ch_0",
"speakers": 2,
"segments": [
{
"start_time": "0.0",
"end_time": "3.4",
"speaker_label": "spk_0",
"items": [
{ "speaker_label": "spk_0", "start_time": "0.0", "end_time": "0.49" }
]
}
]
}
},
"status": "COMPLETED"
}
Items are ordered by start_time. Punctuation items (commas, periods) have no timestamps and are injected by the punctuation model. When reconstructing speaker-labelled text, join consecutive pronunciation items that share the same speaker_label into turns, then append any punctuation item that follows immediately.
Word-level confidence is in item.alternatives[0].confidence as a string (parse to float). Filter out low-confidence words (below 0.6) when building a cleaned transcript — they're often background noise or unintelligible syllables.
Language identification and LanguageOptions
Setting IdentifyLanguage: true without LanguageOptions causes Transcribe to test all supported languages, which increases job duration and cost. Always pass LanguageOptions with the plausible candidates for the deployment context:
// Multi-language support team (English + Spanish + French)
await transcribe.send(new StartTranscriptionJobCommand({
TranscriptionJobName: jobName,
Media: { MediaFileUri: s3_uri },
IdentifyLanguage: true,
LanguageOptions: ['en-US', 'en-GB', 'es-US', 'es-ES', 'fr-FR', 'fr-CA'],
OutputBucketName: output_bucket,
OutputKey: `transcripts/${jobName}.json`,
}));
The completed job returns LanguageCode (the detected language) and IdentifiedLanguageScore (confidence, 0–1). A score below 0.7 suggests ambiguous audio — consider flagging it for manual review. The IdentifiedLanguageCost field shows the incremental charge for language identification (a small addition to the per-second transcription charge).
For multi-lingual audio (code-switching within a single file), use IdentifyMultipleLanguages: true instead of IdentifyLanguage. This segments the audio by language and applies the best model per segment, returning LanguageCodes as an array of detected languages with per-segment attribution.
Speaker diarization design
| MaxSpeakerLabels | Recommended when | Risk if too high |
|---|---|---|
| 2 | Interview (one interviewer, one guest), customer support call | Splits background noise or coughs into a third speaker |
| 3–4 | Panel discussion, sales call with prospect + AE + manager | Pauses or mic bleed attributed to a phantom speaker |
| 5–10 | Focus group, conference roundtable | High speaker fragmentation — same person assigned multiple labels; set exactly to known speaker count when possible |
Diarization accuracy degrades when speakers overlap (crosstalk) or when the audio has significant background noise. For higher-quality diarization on telephony recordings, use the separate streaming transcription API which supports ShowSpeakerLabel in real time.
Speaker labels are not tied to identity — spk_0 is simply the dominant speaker by audio volume and turn length. If you need named attribution, build a speaker enrollment system: collect a short audio sample per known speaker, extract an audio embedding (via Amazon Connect Voice ID or a third-party model), and match the transcript's spk_N labels to enrolled speakers post-transcription.
PII redaction
// Redact PII types from the output transcript
await transcribe.send(new StartTranscriptionJobCommand({
TranscriptionJobName: jobName,
Media: { MediaFileUri: s3_uri },
LanguageCode: 'en-US',
OutputBucketName: output_bucket,
OutputKey: `transcripts/${jobName}-redacted.json`,
ContentRedaction: {
RedactionType: 'PII',
RedactionOutput: 'redacted', // 'redacted' | 'redacted_and_unredacted'
PiiEntityTypes: [
'NAME', 'PHONE', 'SSN', 'CREDIT_DEBIT_NUMBER',
'ADDRESS', 'EMAIL', 'BANK_ACCOUNT_NUMBER',
],
},
}));
When RedactionOutput: 'redacted_and_unredacted', Transcribe produces two transcript files: one with PII replaced by [PII] placeholders and one with the original text. The unredacted version is stored in a separate S3 key with -unredacted appended before the .json extension. Only store the unredacted version in a bucket with restricted access; share the redacted version with downstream systems. PII types not in PiiEntityTypes are still returned in full — be explicit about which types to redact rather than omitting the array (which defaults to all supported types and may change as AWS adds new PII categories).
IAM permissions
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"transcribe:StartTranscriptionJob",
"transcribe:GetTranscriptionJob",
"transcribe:DeleteTranscriptionJob"
],
"Resource": "*"
},
{
"Effect": "Allow",
"Action": ["s3:GetObject"],
"Resource": "arn:aws:s3:::input-bucket/*"
},
{
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:GetObject"],
"Resource": "arn:aws:s3:::output-bucket/transcripts/*"
}
]
}
Transcribe needs s3:GetObject on the input file and s3:PutObject on the output bucket. The service uses your IAM role's credentials — it does not assume a separate role unless you configure JobExecutionSettings.DataAccessRoleArn for cross-account jobs. If the input bucket uses SSE-KMS, also grant kms:GenerateDataKey and kms:Decrypt for the KMS key; similarly for the output bucket's encryption key.