Guide · AWS Textract · Queries
Textract Queries API in MCP Servers — targeted field extraction, QUERY blocks
The AWS Textract Queries feature lets MCP tool handlers extract specific values from documents by asking natural-language questions rather than parsing all key-value pairs and searching for the relevant one. Instead of getting a flat list of every form field and scanning for "What is the invoice total?", you submit that question directly to Textract and receive a QUERY_RESULT block with the answer and a confidence score. You can batch up to 15 queries per AnalyzeDocument call, target specific page ranges, and combine Queries with other feature types (FORMS, TABLES) in the same request. This guide covers the QUERY block structure, how to link QUERY to QUERY_RESULT via Relationships, confidence filtering, page targeting for multi-page documents (async path), and the MCP tool schema design that lets users pass arbitrary question lists.
TL;DR
Pass a QueriesConfig with up to 15 Query objects (each with a Text string and optional Alias and Pages) to AnalyzeDocument alongside FeatureTypes: [FeatureType.QUERIES]. The response contains QUERY blocks (one per question) each linked to a QUERY_RESULT block via a ANSWER relationship. Navigate the block index to extract result.Text and result.Confidence. Queries with no answer found return a QUERY block with no ANSWER relationship — check for this case explicitly. Confidence below 50 usually indicates the field was not found; above 80 is reliable for most structured documents.
Why Queries beats FORMS for targeted extraction
The FORMS feature type extracts every key-value pair on the page, including irrelevant fields, and returns them as KEY_VALUE_SET blocks. For a document with 30 form fields, you get 60 blocks (30 keys, 30 values), then must search for the specific key text you care about — which requires fuzzy string matching because Textract may return "Invoice Total" or "Total Due" or "TOTAL" depending on the document's label. Key detection also relies on visual proximity between label and input box, which breaks on documents with non-standard layouts.
The Queries API sidesteps all of this. You ask "What is the total amount due?" in natural language, and Textract's model searches the entire document — including paragraph text, tables, and inline values — not just structured form fields. A query can find an answer in running prose ("The total amount due is $1,542.00") that FORMS extraction would miss entirely because there is no visual key-value layout. Queries are better when: the document format varies across suppliers, the target value may appear in non-standard positions, or you want to parameterize field extraction at runtime without hardcoding field names.
QUERY and QUERY_RESULT block structure
Each query you submit generates exactly one QUERY block in the response. If Textract finds an answer, that QUERY block has a Relationships entry with Type: 'ANSWER' pointing to one QUERY_RESULT block. If no answer is found, the QUERY block has no ANSWER relationship — there is no QUERY_RESULT block for that query.
QUERY block fields:
- Id — unique block ID (UUID)
- BlockType —
'QUERY' - Query.Text — the question string you submitted
- Query.Alias — the alias you provided (optional), useful as a stable key in MCP tool output
- Query.Pages — the page filter you submitted (optional)
- Relationships — array; may contain one entry with
Type: 'ANSWER'and a single ID
QUERY_RESULT block fields:
- Id — unique block ID
- BlockType —
'QUERY_RESULT' - Text — the extracted answer string
- Confidence — 0–100 float; represents model confidence in the answer's accuracy and location
- Geometry — bounding box of where the answer text appears on the page
- Page — 1-based page number where the answer was found
Submitting queries and navigating results
import {
TextractClient,
AnalyzeDocumentCommand,
FeatureType,
type Block,
type QueriesConfig,
} from '@aws-sdk/client-textract';
const client = new TextractClient({ region: 'us-east-1' });
interface QueryResult {
alias: string;
question: string;
answer: string | null;
confidence: number | null;
page: number | null;
}
async function extractWithQueries(
bucket: string,
key: string,
queries: Array<{ text: string; alias: string; pages?: string[] }>,
): Promise<QueryResult[]> {
const queriesConfig: QueriesConfig = {
Queries: queries.map(q => ({
Text: q.text,
Alias: q.alias,
Pages: q.pages, // e.g. ['1', '2'] or ['1-3'] for multi-page async docs
})),
};
const resp = await client.send(new AnalyzeDocumentCommand({
Document: { S3Object: { Bucket: bucket, Name: key } },
FeatureTypes: [FeatureType.QUERIES],
QueriesConfig: queriesConfig,
}));
const blocks = resp.Blocks ?? [];
// Build id -> block index
const index = new Map<string, Block>();
for (const b of blocks) {
if (b.Id) index.set(b.Id, b);
}
const results: QueryResult[] = [];
for (const block of blocks) {
if (block.BlockType !== 'QUERY') continue;
const queryAlias = block.Query?.Alias ?? block.Query?.Text ?? '';
const questionText = block.Query?.Text ?? '';
// Find ANSWER relationship
const answerRel = (block.Relationships ?? [])
.find(r => r.Type === 'ANSWER');
if (!answerRel || !answerRel.Ids?.length) {
results.push({
alias: queryAlias,
question: questionText,
answer: null,
confidence: null,
page: null,
});
continue;
}
const resultBlock = index.get(answerRel.Ids[0]);
results.push({
alias: queryAlias,
question: questionText,
answer: resultBlock?.Text ?? null,
confidence: resultBlock?.Confidence ?? null,
page: resultBlock?.Page ?? null,
});
}
return results;
}
Call the function with an alias per query so that your MCP tool output uses stable keys regardless of how the question text is phrased:
const results = await extractWithQueries('doc-bucket', 'invoice.pdf', [
{ text: 'What is the total amount due?', alias: 'total' },
{ text: 'What is the vendor name?', alias: 'vendor' },
{ text: 'What is the invoice date?', alias: 'invoice_date' },
{ text: 'What is the invoice number?', alias: 'invoice_number' },
{ text: 'What are the payment terms?', alias: 'payment_terms' },
]);
// Convert to object keyed by alias
const extracted = Object.fromEntries(
results
.filter(r => r.answer !== null && (r.confidence ?? 0) >= 60)
.map(r => [r.alias, r.answer]),
);
// { total: '$1,542.00', vendor: 'Acme Corp', invoice_date: '2026-10-15', ... }
Confidence thresholds and missing answers
Confidence scores from QUERY_RESULT blocks reflect how certain Textract is about both the relevance and accuracy of the answer. Empirically:
| Confidence range | Interpretation | Recommended action |
|---|---|---|
| 90–100 | High confidence — exact match found | Use directly |
| 70–89 | Good confidence — likely correct | Use; log for spot-check |
| 50–69 | Moderate — possible but ambiguous | Flag for human review |
| Below 50 | Low — field may not exist or be non-standard | Treat as not found |
| No QUERY_RESULT block | Textract found no candidate answer | Field absent; do not infer |
A missing QUERY_RESULT (no ANSWER relationship on the QUERY block) is different from a low-confidence result. The missing case means Textract did not find any plausible answer location on the document. The low-confidence case means it found something but is uncertain. Treat both as "not extracted" in production pipelines to avoid writing incorrect values to downstream systems.
Page targeting for multi-page documents
In the sync AnalyzeDocument path, only single-page documents are supported. Page targeting in Query.Pages is therefore most useful in the async StartDocumentAnalysis path where you process multi-page PDFs. The Pages parameter accepts an array of page specifiers: individual page numbers as strings (['1', '3', '5']) or ranges (['1-3', '5']). Targeting a specific page reduces processing scope and can improve accuracy when you know which page of a multi-page document contains the target field.
// In async job context: QueriesConfig is passed to StartDocumentAnalysis
import {
TextractClient,
StartDocumentAnalysisCommand,
FeatureType,
} from '@aws-sdk/client-textract';
const client = new TextractClient({ region: 'us-east-1' });
async function startMultiPageQueryJob(
bucket: string,
key: string,
snsTopicArn: string,
roleArn: string,
): Promise<string> {
const resp = await client.send(new StartDocumentAnalysisCommand({
DocumentLocation: { S3Object: { Bucket: bucket, Name: key } },
FeatureTypes: [FeatureType.QUERIES],
QueriesConfig: {
Queries: [
// Look for contract value on page 1 only
{ Text: 'What is the total contract value?', Alias: 'contract_value', Pages: ['1'] },
// Look for signatory on last 2 pages of a 10-page contract
{ Text: 'Who signed the contract?', Alias: 'signatory', Pages: ['9', '10'] },
// Look for effective date on any page
{ Text: 'What is the effective date?', Alias: 'effective_date' },
],
},
NotificationChannel: {
SNSTopicArn: snsTopicArn,
RoleArn: roleArn,
},
}));
return resp.JobId!;
}
Combining Queries with FORMS and TABLES
You can include FeatureType.QUERIES alongside FeatureType.FORMS and FeatureType.TABLES in the same AnalyzeDocument call. This adds all block types to the response in one API call rather than requiring separate calls. The tradeoff is higher cost (you pay for each feature type used) and a larger response to parse. A practical pattern is to use QUERIES for the fields you specifically want and TABLES only when you know the document has tables you need to extract, skipping FORMS entirely:
// Combine QUERIES (targeted fields) + TABLES (structured data) in one call
const resp = await client.send(new AnalyzeDocumentCommand({
Document: { S3Object: { Bucket: bucket, Name: key } },
FeatureTypes: [FeatureType.QUERIES, FeatureType.TABLES],
QueriesConfig: {
Queries: [
{ Text: 'What is the vendor name?', Alias: 'vendor' },
{ Text: 'What is the invoice number?', Alias: 'invoice_number' },
],
},
}));
// Separate blocks by type for independent processing
const queryBlocks = (resp.Blocks ?? []).filter(b => b.BlockType === 'QUERY');
const tableBlocks = (resp.Blocks ?? []).filter(b => b.BlockType === 'TABLE');
MCP tool schema for parameterized query extraction
The Queries API is a natural fit for a generic "extract-fields" MCP tool where the AI orchestrator supplies the questions rather than having them hardcoded. The tool schema accepts an S3 URI and a list of field definitions, calls Textract, and returns a structured extraction result:
// MCP tool definition (using @modelcontextprotocol/sdk)
server.tool(
'extract_document_fields',
{
s3_uri: z.string().describe('S3 URI of the document (s3://bucket/key)'),
fields: z.array(z.object({
alias: z.string().describe('Stable key for this field in the result'),
question: z.string().describe('Natural-language question to ask Textract'),
pages: z.array(z.string()).optional().describe('Page specifiers like ["1"] or ["1-3"]'),
})).max(15).describe('Up to 15 field definitions to extract'),
confidence_threshold: z.number().min(0).max(100).default(60)
.describe('Minimum confidence score to include a result'),
},
async ({ s3_uri, fields, confidence_threshold }) => {
const url = new URL(s3_uri);
const bucket = url.hostname;
const key = url.pathname.slice(1); // remove leading /
const rawResults = await extractWithQueries(bucket, key,
fields.map(f => ({ text: f.question, alias: f.alias, pages: f.pages }))
);
const extracted: Record<string, unknown> = {};
const missing: string[] = [];
for (const r of rawResults) {
if (r.answer !== null && (r.confidence ?? 0) >= confidence_threshold) {
extracted[r.alias] = {
value: r.answer,
confidence: r.confidence,
page: r.page,
};
} else {
missing.push(r.alias);
}
}
return {
content: [{
type: 'text',
text: JSON.stringify({ extracted, missing }, null, 2),
}],
};
},
);
This design lets an AI orchestrator dynamically decide what to extract from a document based on downstream task requirements, without needing to modify the MCP server for each new document type. The missing array signals which fields the orchestrator should handle as absent — either by falling back to human review, skipping downstream steps that require those fields, or re-querying with rephrased questions.
Common mistakes and how to avoid them
Not providing FeatureTypes when using QueriesConfig: If you include QueriesConfig but omit FeatureType.QUERIES from FeatureTypes, the SDK throws a validation error. Always pair QueriesConfig with FeatureTypes: [FeatureType.QUERIES].
Assuming QUERY block order matches query submission order: The Blocks array is not sorted by query submission order. Always match query results back to their source queries using the Query.Alias or Query.Text field on each QUERY block, not by array position.
Missing the no-answer case: When a QUERY block has no ANSWER relationship, iterating block.Relationships ?? [] returns an empty array. Code that calls .find(r => r.Type === 'ANSWER')!.Ids[0] without null checking will throw. Always check that the find result is defined before accessing its Ids.
Asking overly broad questions on dense documents: "What are the terms?" on a legal contract may return the first occurrence of any term-related text rather than the specific section you need. Be specific: "What is the payment term in days?" returns a more actionable answer than "What are the payment terms?"