Guide · AWS Textract · Async Jobs
Textract Async Document Analysis in MCP Servers — StartDocumentAnalysis, SNS, pagination
AWS Textract's async API is required for multi-page PDFs: the sync AnalyzeDocument path handles only single-page documents, while StartDocumentAnalysis handles up to 3,000 pages per job with results retrieved via paginated GetDocumentAnalysis calls. For MCP server tools that process contracts, financial reports, or scanned document archives, the async path is not optional — it's the only supported path for any document over one page. The async API introduces a fundamentally different programming model: submit returns a JobId immediately while processing continues asynchronously, completion is signaled via SNS, and results are retrieved in pages of 1,000 blocks each. This guide covers how to set up StartDocumentAnalysis with an SNS notification channel, the IAM role required for Textract to publish to SNS, how to paginate GetDocumentAnalysis to assemble the full block list, and the three-tool MCP pattern (submit / poll / retrieve) that exposes async jobs to AI orchestrators without blocking tool handlers.
TL;DR
Call StartDocumentAnalysisCommand with DocumentLocation (S3 only), FeatureTypes, and optionally a NotificationChannel (SNSTopicArn + RoleArn). The RoleArn must have a trust policy allowing textract.amazonaws.com to assume it, and the role must have sns:Publish on the topic. Poll GetDocumentAnalysisCommand until JobStatus is SUCCEEDED or FAILED. Paginate with NextToken — each page returns up to 1,000 blocks. Assemble all block pages before traversing the block graph, since TABLE and KEY_VALUE_SET relationships may span block pages. For MCP servers: expose three separate tools — submit-job, check-status, and get-results — so the orchestrator can poll non-blocking.
Why async is required for multi-page documents
The sync Textract APIs (DetectDocumentText, AnalyzeDocument, AnalyzeExpense, AnalyzeID) share a strict single-page limit: they accept a PDF document but process only the first page. If you pass a 10-page PDF to AnalyzeDocument, you get results for page 1 only — no error is thrown, and the remaining pages are silently ignored. This is the most common "where did my data go?" mistake when first integrating Textract.
The async path lifts this limit to 3,000 pages per job for StartDocumentAnalysis and StartDocumentTextDetection. Job processing time scales roughly with page count and feature type complexity: a 100-page PDF with TABLES and FORMS takes significantly longer than plain text detection. Expect processing times in the range of seconds for single-digit pages to several minutes for hundred-page documents. Do not poll GetDocumentAnalysis more frequently than every 5 seconds for typical workloads; for large documents, every 30 seconds is more appropriate.
IAM setup for async jobs with SNS notifications
The NotificationChannel parameter requires an IAM role that Textract assumes to publish the completion message. The trust policy of this role must grant sts:AssumeRole to the Textract service principal. The role's permissions policy must grant sns:Publish to the SNS topic ARN. This role is separate from the caller's IAM principal — it is specifically the role Textract will assume when the job completes:
# Trust policy — allows Textract to assume this role
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "Service": "textract.amazonaws.com" },
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": {
"aws:SourceAccount": "123456789012"
}
}
}]
}
# Permissions policy — allows publishing to the specific topic
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": "sns:Publish",
"Resource": "arn:aws:sns:us-east-1:123456789012:textract-completion"
}]
}
The aws:SourceAccount condition in the trust policy is a security best practice that prevents confused deputy attacks — it ensures only your account's Textract jobs can use this role, not Textract jobs submitted by other AWS customers.
The calling principal (your Lambda function or ECS task IAM role) needs separate permissions: textract:StartDocumentAnalysis, textract:GetDocumentAnalysis, s3:GetObject on the source document, and iam:PassRole on the Textract notification role ARN (required to pass the role to Textract).
Submitting an async job
import {
TextractClient,
StartDocumentAnalysisCommand,
FeatureType,
type QueriesConfig,
} from '@aws-sdk/client-textract';
const client = new TextractClient({ region: 'us-east-1' });
interface StartJobParams {
bucket: string;
key: string;
featureTypes: FeatureType[];
snsTopicArn?: string;
notificationRoleArn?: string;
queriesConfig?: QueriesConfig;
clientRequestToken?: string; // idempotency token, max 64 chars
}
async function startTextractJob(params: StartJobParams): Promise<string> {
const resp = await client.send(new StartDocumentAnalysisCommand({
DocumentLocation: {
S3Object: { Bucket: params.bucket, Name: params.key },
},
FeatureTypes: params.featureTypes,
QueriesConfig: params.queriesConfig,
NotificationChannel: params.snsTopicArn
? {
SNSTopicArn: params.snsTopicArn,
RoleArn: params.notificationRoleArn!,
}
: undefined,
ClientRequestToken: params.clientRequestToken,
// Optional: JobTag for CloudWatch Insights filtering
JobTag: `mcp-${Date.now()}`,
}));
return resp.JobId!;
}
ClientRequestToken is an idempotency key: if you call StartDocumentAnalysis twice with the same token within 24 hours, the second call returns the same JobId as the first instead of starting a new job. Use this to safely retry a submit call that may have succeeded but timed out before returning a response.
Polling job status
import {
TextractClient,
GetDocumentAnalysisCommand,
type Block,
} from '@aws-sdk/client-textract';
type JobStatus = 'IN_PROGRESS' | 'SUCCEEDED' | 'FAILED' | 'PARTIAL_SUCCESS';
interface JobStatusResult {
status: JobStatus;
statusMessage?: string;
warnings?: string[];
}
async function checkJobStatus(jobId: string): Promise<JobStatusResult> {
// Fetch first page only (MaxResults: 1) to get status without pulling all blocks
const resp = await client.send(new GetDocumentAnalysisCommand({
JobId: jobId,
MaxResults: 1,
}));
return {
status: resp.JobStatus as JobStatus,
statusMessage: resp.StatusMessage,
warnings: resp.Warnings?.map(w => w.ErrorCode ?? '').filter(Boolean),
};
}
Job status meanings:
- IN_PROGRESS — job is still running; continue polling
- SUCCEEDED — all pages processed; call
GetDocumentAnalysisto retrieve blocks - PARTIAL_SUCCESS — some pages processed, some failed (e.g., corrupted pages); blocks available for successful pages; check
Warningsfor page-level errors - FAILED — entire job failed; check
StatusMessagefor reason
Common failure reasons in StatusMessage: document is password-protected (Textract cannot decrypt), document format is not supported (must be JPEG/PNG/TIFF/PDF), S3 object not found or access denied, document exceeds page limit (3,000 pages).
Paginating GetDocumentAnalysis to assemble all blocks
async function getAllBlocks(jobId: string): Promise<Block[]> {
const allBlocks: Block[] = [];
let nextToken: string | undefined = undefined;
do {
const resp = await client.send(new GetDocumentAnalysisCommand({
JobId: jobId,
MaxResults: 1000, // maximum allowed per page
NextToken: nextToken,
}));
if (resp.JobStatus === 'IN_PROGRESS') {
throw new Error('Job is still in progress — call checkJobStatus before getAllBlocks');
}
if (resp.JobStatus === 'FAILED') {
throw new Error(`Job failed: ${resp.StatusMessage ?? 'unknown error'}`);
}
allBlocks.push(...(resp.Blocks ?? []));
nextToken = resp.NextToken;
} while (nextToken !== undefined);
return allBlocks;
}
Critical: Do not try to navigate block relationships until you have assembled the complete block list. TABLE blocks reference CELL blocks by ID, and those CELL blocks may be in a different response page than the TABLE block. If you process blocks page by page and try to resolve relationships as you go, you will encounter missing IDs for cross-page references. Always collect all pages first, then build the ID-to-block index, then traverse.
For very large documents (hundreds of pages with dense table content), the full block list can reach hundreds of thousands of blocks. If memory is a concern, consider streaming the block pages to an intermediate store (DynamoDB or S3) rather than accumulating in-memory, then performing graph traversal as a separate pass.
SNS completion notification structure
When the job completes, Textract publishes a JSON message to the SNS topic. The message body (the SNS Message field, which is itself a JSON string) has this structure:
{
"JobId": "abc123...",
"Status": "SUCCEEDED",
"API": "StartDocumentAnalysis",
"JobTag": "mcp-1730000000000",
"Timestamp": 1730000060000,
"DocumentLocation": {
"S3ObjectName": "documents/invoice.pdf",
"S3Bucket": "my-doc-bucket"
}
}
Connect SNS to SQS (fan-out pattern) so your MCP server can poll for completion via SQS rather than requiring an inbound webhook endpoint. The SNS → SQS subscription delivers the message as an SQS event body where event.Body is the SNS wrapper JSON and the Textract result is nested in JSON.parse(snsMessage.Message):
// Lambda processing SQS messages from SNS subscription
export async function handler(event: SQSEvent) {
for (const record of event.Records) {
const snsMessage = JSON.parse(record.body) as {
Message: string;
TopicArn: string;
};
const textractResult = JSON.parse(snsMessage.Message) as {
JobId: string;
Status: string;
JobTag: string;
};
if (textractResult.Status === 'SUCCEEDED') {
// Trigger downstream processing via SQS or EventBridge
await notifyMcpServer(textractResult.JobId, textractResult.JobTag);
}
}
}
Three-tool MCP pattern for async jobs
Async jobs require a different MCP tool design than sync APIs. You cannot block a tool handler for minutes waiting for a job to complete — MCP tool call timeouts are typically in the 30-60 second range. The standard pattern is three separate tools:
Tool 1: start_document_analysis — Accepts S3 URI and feature type list. Calls StartDocumentAnalysis, stores the job metadata (jobId, S3 location, requestedFeatures, submittedAt) in DynamoDB keyed by jobId. Returns the jobId immediately. Completes in under 1 second.
Tool 2: check_analysis_status — Accepts a jobId. Calls GetDocumentAnalysis with MaxResults: 1 to retrieve the status without transferring the full block list. Returns status (IN_PROGRESS, SUCCEEDED, FAILED) and the page count when available. Completes in under 1 second.
Tool 3: get_analysis_results — Accepts a jobId and optionally a result type (tables, forms, text, queries). Verifies status is SUCCEEDED, paginates GetDocumentAnalysis, assembles blocks, applies the requested extraction logic (table serialization, form field extraction, query results), and returns structured output. May take several seconds for large documents.
// Tool 1: submit
server.tool('start_document_analysis', {
s3_uri: z.string(),
features: z.array(z.enum(['TABLES', 'FORMS', 'QUERIES', 'SIGNATURES'])),
}, async ({ s3_uri, features }) => {
const url = new URL(s3_uri);
const jobId = await startTextractJob({
bucket: url.hostname,
key: url.pathname.slice(1),
featureTypes: features as FeatureType[],
snsTopicArn: process.env.TEXTRACT_SNS_TOPIC_ARN,
notificationRoleArn: process.env.TEXTRACT_NOTIFICATION_ROLE_ARN,
});
// Persist job metadata for retrieval in subsequent tool calls
await saveJobMetadata(jobId, { s3_uri, features, submittedAt: new Date().toISOString() });
return { content: [{ type: 'text', text: JSON.stringify({ jobId }) }] };
});
// Tool 2: status check
server.tool('check_analysis_status', {
job_id: z.string(),
}, async ({ job_id }) => {
const status = await checkJobStatus(job_id);
return { content: [{ type: 'text', text: JSON.stringify(status) }] };
});
// Tool 3: retrieve results
server.tool('get_analysis_results', {
job_id: z.string(),
result_type: z.enum(['text', 'tables', 'forms', 'queries']).default('text'),
}, async ({ job_id, result_type }) => {
const { status } = await checkJobStatus(job_id);
if (status !== 'SUCCEEDED') {
return { content: [{ type: 'text', text: JSON.stringify({ error: `Job not ready: ${status}` }) }] };
}
const blocks = await getAllBlocks(job_id);
// Apply the relevant extraction logic based on result_type
const result = await extractByType(blocks, result_type);
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
});
The AI orchestrator calls start_document_analysis, receives a jobId, then polls check_analysis_status in a loop (with delays between calls), and finally calls get_analysis_results when status is SUCCEEDED. This pattern keeps all three tool handlers fast and non-blocking, and allows the orchestrator to work on other tasks during the processing wait.
Concurrency limits and quota management
Default Textract service quotas for async jobs: 5 concurrent document analysis jobs per account per region. If your MCP server processes many documents simultaneously (e.g., batch invoice processing), you will hit this limit quickly. The submit call returns a LimitExceededException when the concurrent job limit is reached — implement exponential backoff and retry in your submit tool. Request a quota increase via the Service Quotas console for production workloads.
For high-volume batch processing (hundreds of documents per day), consider a job queue approach: submit documents to SQS, have a Lambda consumer pull from the queue and submit Textract jobs respecting the concurrency limit, and store results back to S3 or DynamoDB. The MCP tools then query this results store rather than calling Textract directly, decoupling the document processing pipeline from the real-time MCP tool interface.