Amazon Kendra · Enterprise Search · Custom Data Sources · Relevance Tuning · 2026-11-09 · Kendra arc
Amazon Kendra for MCP Servers: Enterprise Search, Custom Data Sources, and Relevance Tuning — Complete Playbook
AliveMCP monitors thousands of MCP endpoints every 60 seconds. Among the most requested architectures from enterprise teams is Kendra-backed search: an AI assistant with a search_knowledge_base tool that surfaces direct answers from a company's SharePoint library or S3 document corpus, complete with per-user ACL enforcement and relevance tuned to surface API reference above general FAQ. Amazon Kendra occupies a specific niche in the enterprise search space: it is not a general-purpose search engine like OpenSearch, and it is not an embedding-based RAG service like Bedrock Knowledge Bases. It is a managed ML-powered search service with pre-built data source connectors for 40+ enterprise content systems — SharePoint, Confluence, Salesforce, ServiceNow — and a document ranking model trained on hundreds of millions of enterprise queries. For MCP server developers, the value proposition is clear: Kendra handles authentication, pagination, and incremental crawling for enterprise sources automatically, while exposing a simple QueryCommand API that returns three distinct result types (direct FAQ answers, ML-extracted passages, and standard ranked documents) in a single call. The challenge is that the operational surface has real complexity — index editions with hard capacity and pricing differences, metadata sidecar conventions with silent failure modes, a custom data source model that inverts the crawl direction entirely, and a relevance tuning layer with three different levers (AttributeFilter, DocumentRelevanceOverride, UserContext) that interact in non-obvious ways and have a critical prerequisite that is easy to miss. This guide synthesizes four deep-dives — Amazon Kendra index setup, QueryCommand handler, and IAM permissions, Kendra S3 connector with metadata JSON and ACL mapping, Kendra custom data source with BatchPutDocument and sync job lifecycle, and Kendra relevance tuning with AttributeFilter, boosting, and UserContext — into a complete Kendra playbook for MCP server developers building enterprise search tools on AWS.
TL;DR
- Kendra index setup and QueryCommand — create a
CfnIndexwithDEVELOPER_EDITION(25,000 docs, $810/month, single-AZ) orENTERPRISE_EDITION(100,000+ docs, $1,008/month, multi-AZ HA), defineDocumentMetadataConfigurationsfor custom typed attributes, query withQueryCommandand routeResultItemsbyType(ANSWER → FAQ answer text fromAdditionalAttributes[Key="AnswerText"], QUESTION_ANSWER → ML-extracted passage from same path, DOCUMENT →DocumentExcerpt.Text), set IAM askendra:Query+kendra:DescribeIndexon the ARNarn:aws:kendra:REGION:ACCOUNT:index/INDEX_ID(theindex/prefix is mandatory — omitting it causesAccessDeniedExceptionwith no informative error message), surfaceANSWERandQUESTION_ANSWERresults first as direct answers beforeDOCUMENTresults: Two index architecture decisions are made once and are hard to change. The edition choice determines your document capacity ceiling and HA posture — you cannot change an existing index from DEVELOPER to ENTERPRISE or vice versa. You must create a new index and re-ingest. The custom attribute definitions (types: STRING_VALUE, STRING_LIST_VALUE, LONG_VALUE, DATE_VALUE) also affect what filtering and faceting is possible at query time. Plan your attribute schema before you start ingesting documents, because adding new attributes after ingestion does not backfill — previously indexed documents do not get the new attribute values without a full resync. - Kendra S3 connector — configure
CfnDataSourcewithType: "S3", IAM role trustingkendra.amazonaws.comwiths3:GetObject+s3:ListBucket+kendra:BatchPutDocument+kendra:BatchDeleteDocument, setDocumentsMetadataConfiguration.S3Prefixto a parallel prefix ending with/(sidecar fordocs/report.pdfmust live atmetadata/docs/report.pdf.metadata.json— stripping the document prefix is the most common sidecar-not-found failure), set built-in fields (_source_uri,_title,_last_updated_at) and custom attributes in the sidecar JSON, ACL filtering with_acl_principals(GROUP/USER/ALLOW/DENY entries) only enforces when the index edition is ENTERPRISE_EDITION — DEVELOPER_EDITION silently ignores UserContext and returns all documents regardless of ACL metadata: The S3 connector's most dangerous failure mode is silent: if you use DEVELOPER_EDITION with ACL-annotated documents and UserContext in QueryCommand, the ACL enforcement is silently skipped. Every user sees every document. The API accepts the configuration without error and returns results without indicating that ACL was not applied. Plan for ENTERPRISE_EDITION if document-level access control is a requirement — there is no migration path that preserves the index; you create a new ENTERPRISE_EDITION index and resync. For metadata-only changes on existing documents (sidecar updated, document body unchanged), Kendra's incremental sync skips unchanged files by content hash — trigger a full resync by updating the data source description to reset the hash cache. - Kendra custom data source — create
CfnDataSourcewithType: 'CUSTOM'(no S3 config, no schedule, no crawl role on Kendra's side), push documents from your code via the sync job protocol:StartDataSourceSyncJobCommand→ paginatedBatchPutDocumentCommandcalls (100 docs max per call, 2 MB per document blob) →StopDataSourceSyncJobCommandin afinallyblock (failure to stop leaves the data source locked — subsequent Start calls returnConflictExceptionuntil stopped), use SHA-256 hashed base64url document IDs (raw URLs contain/and#which causeINVALID_REQUEST), passBigInt(not number) for LONG attributes andnew Date()(not ISO strings) for DATE attributes, maintain a DynamoDB watermark for incremental updates: The CUSTOM data source inverts Kendra's crawl model: Kendra never reaches out, your code pushes. This makes it the right choice for event-driven content (GitHub PR webhooks, database deltas, REST API responses) where a scheduled crawl would miss changes between runs. The architectural implication is that your code is responsible for the full ETL pipeline — pagination, rate limiting, watermark management, deletion detection. Thefinallyblock aroundStopDataSourceSyncJobis non-negotiable: if your Lambda times out mid-sync without calling Stop, the data source stays locked until a human or automated monitor calls StopDataSourceSyncJob. Treat a stale RUNNING sync job as an operational incident, not a cosmetic issue. - Kendra relevance tuning —
AttributeFilteris a predicate tree evaluated before ML scoring (hard exclusion: tenant isolation, date ranges, status filters using EqualsTo/ContainsAny/ContainsAll/GreaterThanOrEquals/LessThanOrEquals/NotFilter),DocumentRelevanceOverridesadjusts scores post-ML (soft boost: Importance 1–10, ValueImportanceMap for string-value-specific weights, Freshness: true for_last_updated_atdecay, RankOrder ASCENDING/DESCENDING for numeric attributes),UserContextenforces ACL post-scoring but only whenUserContextPolicyon the index is set toUSER_TOKEN— the defaultATTRIBUTE_FILTERpolicy completely ignores UserContext, facets return per-attribute-value counts viaDocumentAttributeValueCountPairsfor drill-down UI patterns, pagination is 1-indexed with effective max of 100 results across all pages (PageNumber 10 × PageSize 10): The UserContext prerequisite is the single most common Kendra misconfiguration in MCP server deployments: teams annotate documents with_acl_principals, passUserContextin every QueryCommand, deploy to production, and then discover users can see documents they should not access. The root cause is always the same — the index UserContextPolicy is still at the default ATTRIBUTE_FILTER. The fix requires a single UpdateIndexCommand call to set USER_TOKEN, which takes effect within minutes without an index rebuild. The other critical interaction to understand: AttributeFilter and UserContext are independent mechanisms. AttributeFilter is for business logic scoping (tenant ID, content category, date range). UserContext is for access control (which users can see which documents). They compose: a document must pass both the AttributeFilter predicate tree AND the UserContext ACL check to appear in results.
Part 1: Kendra index architecture and the QueryCommand handler
An Amazon Kendra index is the central store that all ingestion routes feed into and all queries read from. The two decisions you make at index creation time — edition and custom attribute schema — are both difficult to change without downtime and full data re-ingestion, so it is worth spending time on them upfront.
Edition choice: DEVELOPER_EDITION vs ENTERPRISE_EDITION
DEVELOPER_EDITION costs approximately $810/month, stores up to 25,000 documents and 3 FAQs, runs in a single Availability Zone (no HA), and defaults to 10 QPS for Query operations. ENTERPRISE_EDITION costs approximately $1,008/month base, stores up to 100,000 documents (extendable via capacity units at ~$250/month per unit, each adding 100,000 documents and 0.5 QPS), runs across three AZs with automatic failover, and defaults to 40 QPS. Use DEVELOPER_EDITION for development and internal prototypes with small corpora. Use ENTERPRISE_EDITION for any production deployment where availability matters or where you need ACL enforcement — ACL via _acl_principals and UserContext is silently ignored on DEVELOPER_EDITION.
The fact that you cannot change the edition of an existing index is the most important implication of this decision. There is no in-place migration. When teams graduate from a DEVELOPER_EDITION prototype to production, the operational steps are: create a new ENTERPRISE_EDITION index with the same attribute schema, reconfigure all data sources to point at the new index, re-run all sync jobs, update the Lambda environment variable KENDRA_INDEX_ID, and decommission the old index. Plan this transition before you go into production rather than after.
Custom attribute schema design
Custom attributes are defined at index creation via DocumentMetadataConfigurations and are typed: STRING_VALUE (equality, faceting, ValueImportanceMap boosting), STRING_LIST_VALUE (ContainsAny/ContainsAll filters, multi-value tags), LONG_VALUE (range comparisons, priority scores, requires BigInt in SDK v3), and DATE_VALUE (freshness boost, date range filters, requires a Date object not an ISO string). Each attribute also has four boolean flags that determine its capabilities: searchable (required for use in AttributeFilter), facetable (required for Facets in QueryCommand), displayable (returned in result metadata), and sortable (STRING_LIST_VALUE cannot be sorted).
Changing an attribute's flags after index creation triggers a reindexing operation that can take 30+ minutes. Adding a new attribute definition does not backfill existing documents — you must re-ingest all documents (via a full S3 connector resync or a full BatchPutDocument pass) to populate the new attribute values. Design your attribute schema to cover the filtering, faceting, and boosting requirements you can anticipate before ingesting documents.
The QueryCommand handler: routing by result type
The QueryCommand response can contain three distinct result types in a single call. How you present them in the MCP tool response matters: users benefit most from seeing direct answers first, followed by supporting source documents. The key implementation detail is where the answer text lives for each type.
For ANSWER results (explicit FAQ pair match), the answer text is in AdditionalAttributes[Key="AnswerText"].Value.TextWithHighlightsValue.Text. For QUESTION_ANSWER results (ML-extracted passage from a document body), the passage text is in the same path. For DOCUMENT results (standard ranked document), the excerpt is in DocumentExcerpt.Text — which may contain <strong> highlight tags that you should strip for plain-text MCP responses. The DocumentExcerpt.Text field is also populated on ANSWER and QUESTION_ANSWER results as a fallback, but the high-fidelity text with highlights is in AdditionalAttributes.
import {
KendraClient,
QueryCommand,
QueryResultType,
type QueryResultItem,
type AttributeFilter,
} from '@aws-sdk/client-kendra';
const kendra = new KendraClient({ region: process.env.AWS_REGION ?? 'us-east-1' });
const INDEX_ID = process.env.KENDRA_INDEX_ID!;
function mapResultItem(item: QueryResultItem) {
const title = item.DocumentTitle?.Text ?? item.DocumentId ?? 'Untitled';
const sourceUri = item.DocumentURI ?? '';
const scoreConfidence = item.ScoreAttributes?.ScoreConfidence;
switch (item.Type) {
case QueryResultType.ANSWER:
return {
type: 'faq_answer' as const,
title,
excerpt: item.AdditionalAttributes?.find(a => a.Key === 'AnswerText')
?.Value?.TextWithHighlightsValue?.Text ?? item.DocumentExcerpt?.Text ?? '',
sourceUri,
scoreConfidence,
};
case QueryResultType.QUESTION_ANSWER:
return {
type: 'direct_answer' as const,
title,
excerpt: item.AdditionalAttributes?.find(a => a.Key === 'AnswerText')
?.Value?.TextWithHighlightsValue?.Text ?? item.DocumentExcerpt?.Text ?? '',
sourceUri,
scoreConfidence,
};
default: // DOCUMENT
return {
type: 'document' as const,
title,
excerpt: (item.DocumentExcerpt?.Text ?? '').replace(/<strong>|<\/strong>/g, ''),
sourceUri,
scoreConfidence,
};
}
}
export async function handleKendraSearch(args: {
query: string;
productLine?: string;
maxResults?: number;
}) {
const attributeFilter: AttributeFilter | undefined = args.productLine
? { EqualsTo: { Key: 'product_line', Value: { StringValue: args.productLine } } }
: undefined;
const response = await kendra.send(new QueryCommand({
IndexId: INDEX_ID,
QueryText: args.query,
PageSize: args.maxResults ?? 10,
...(attributeFilter ? { AttributeFilter: attributeFilter } : {}),
}));
const results = (response.ResultItems ?? []).map(mapResultItem);
const directAnswers = results.filter(r => r.type !== 'document');
const documents = results.filter(r => r.type === 'document');
const parts: string[] = [];
if (directAnswers.length > 0) {
parts.push('## Direct Answers\n');
for (const ans of directAnswers) {
parts.push(`### ${ans.title}${ans.scoreConfidence ? ` (${ans.scoreConfidence})` : ''}`);
parts.push(ans.excerpt);
if (ans.sourceUri) parts.push(`_Source: ${ans.sourceUri}_`);
parts.push('');
}
}
if (documents.length > 0) {
parts.push('## Relevant Documents\n');
for (const doc of documents) {
parts.push(`### ${doc.title}${doc.scoreConfidence ? ` (${doc.scoreConfidence})` : ''}`);
parts.push(doc.excerpt);
if (doc.sourceUri) parts.push(`_Source: ${doc.sourceUri}_`);
parts.push('');
}
}
return { content: [{ type: 'text', text: parts.join('\n') || 'No results found.' }] };
}
IAM: the index ARN format pitfall
The minimum IAM policy for a Kendra-backed MCP Lambda requires three actions: kendra:Query, kendra:DescribeIndex (for health checks before serving requests), and optionally kendra:SubmitFeedback (to send click signals that improve Kendra's ranking model over time). The resource ARN format is arn:aws:kendra:REGION:ACCOUNT_ID:index/INDEX_ID. The index/ path segment is mandatory — using arn:aws:kendra:REGION:ACCOUNT_ID:INDEX_ID (without index/) silently fails the IAM condition check and produces an AccessDeniedException at runtime that does not indicate the ARN is malformed. Use aws iam simulate-principal-policy to validate the policy before deploying if any doubt exists about the ARN format.
For cross-account deployments where the Lambda is in one account and the Kendra index is in another, Kendra does not support resource-based policies on the index. The standard pattern is IAM role assumption: the Lambda's execution role assumes a role in the Kendra account that has the query permissions, and the Kendra account's role has a trust policy permitting the Lambda's ARN. Cache the assumed credentials in memory using the SDK's fromTemporaryCredentials provider to avoid per-request STS latency.
ScoreConfidence and NOT_AVAILABLE warm-up windows
The ScoreAttributes.ScoreConfidence field on DOCUMENT results is an enum: VERY_HIGH, HIGH, MEDIUM, LOW, NOT_AVAILABLE. NOT_AVAILABLE appears on two occasions: in the first 30–60 minutes after index creation (Kendra's ranking model calibrates asynchronously after the first sync), and for documents with thin content (short titles, no body text). After warm-up, a steady-state NOT_AVAILABLE rate on a fraction of results is a data quality signal — those documents likely need richer content. Track the NOT_AVAILABLE ratio as a search quality metric. For FAQ-type ANSWER results, ScoreConfidence is typically absent entirely — FAQ matching is binary, not graded.
Part 2: S3 connector — document sync, metadata sidecars, and ACL mapping
The S3 connector is the most common ingestion path for Kendra: point it at an S3 prefix, optionally configure inclusion/exclusion patterns and a sync schedule, and Kendra crawls the bucket, extracts text from supported file formats (PDF up to 5 MB, Word/HTML/PPT/Excel up to 50 MB, plain text up to 150 MB, Markdown up to 50 MB), and indexes the content with ML relevance ranking. No custom ETL, no embedding calls, no document parsing code required.
CDK connector setup and the IAM role boundary
The CfnDataSource construct for an S3 connector needs an IAM role that Kendra can assume. The trust policy must name kendra.amazonaws.com as the principal — not lambda.amazonaws.com or any other service. The role needs s3:GetObject on the objects ARN (arn:aws:s3:::bucket-name/*) and s3:ListBucket on the bucket ARN (arn:aws:s3:::bucket-name) — a common mistake is applying both actions to the bucket ARN, which correctly grants ListBucket but silently fails for GetObject since object-level access requires the objects ARN. The role also needs kendra:BatchPutDocument and kendra:BatchDeleteDocument on the index ARN — the data source sync writes back to the index on behalf of this role.
import * as cdk from 'aws-cdk-lib';
import * as kendra from 'aws-cdk-lib/aws-kendra';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as s3 from 'aws-cdk-lib/aws-s3';
import { Construct } from 'constructs';
export class KendraS3Connector extends Construct {
constructor(scope: Construct, id: string, props: {
indexId: string;
documentsBucket: s3.IBucket;
documentsPrefix: string; // e.g. 'docs/'
metadataPrefix: string; // e.g. 'docs-metadata/' — MUST end with /
}) {
super(scope, id);
const stack = cdk.Stack.of(this);
const dataSourceRole = new iam.Role(this, 'KendraS3Role', {
assumedBy: new iam.ServicePrincipal('kendra.amazonaws.com'),
});
// GetObject on the objects ARN, ListBucket on the bucket ARN — different resource formats
props.documentsBucket.grantRead(dataSourceRole);
dataSourceRole.addToPolicy(new iam.PolicyStatement({
actions: ['s3:ListBucket'],
resources: [props.documentsBucket.bucketArn],
}));
// Write-back permissions so Kendra can update the index during sync
dataSourceRole.addToPolicy(new iam.PolicyStatement({
actions: ['kendra:BatchPutDocument', 'kendra:BatchDeleteDocument'],
resources: [`arn:aws:kendra:${stack.region}:${stack.account}:index/${props.indexId}`],
}));
new kendra.CfnDataSource(this, 'S3DataSource', {
indexId: props.indexId, // token reference registers CDK dependency on index
name: 'mcp-s3-documents',
type: 'S3',
roleArn: dataSourceRole.roleArn,
dataSourceConfiguration: {
s3Configuration: {
bucketName: props.documentsBucket.bucketName,
inclusionPrefixes: [props.documentsPrefix],
documentsMetadataConfiguration: {
s3Prefix: props.metadataPrefix, // must end with /
},
},
},
schedule: 'cron(0 2 * * ? *)', // daily at 02:00 UTC
});
}
}
Metadata sidecar format and the prefix convention
Metadata sidecar files extend indexed documents with structured attributes usable in AttributeFilter, Facets, and DocumentRelevanceOverride. The sidecar for a document at key docs/finance/report-q3.pdf must be placed at METADATA_PREFIX + FULL_DOCUMENT_KEY + ".metadata.json" — that is, docs-metadata/docs/finance/report-q3.pdf.metadata.json. The full document key (including the document prefix) is preserved in the sidecar path; it is not stripped. This is the most common metadata misconfiguration: placing sidecars at docs-metadata/finance/report-q3.pdf.metadata.json (stripping docs/) causes Kendra to silently skip the sidecar and index the document without custom attributes, with no error in the sync job response.
Built-in fields in the sidecar override Kendra's auto-extracted values: _source_uri controls the URL shown in search results (use the human-readable intranet URL rather than the raw S3 URI), _title overrides the document title, _last_updated_at and _created_at are date strings used for freshness ranking signals, and _category is a top-level facet string. Custom attributes live in the Attributes map with their type explicitly declared.
Metadata changes on previously indexed documents do not propagate during incremental syncs if the document body is unchanged (Kendra tracks content hashes). To force re-indexing of a metadata-only update, either trigger a full resync (by updating the data source description to reset the hash cache) or push the document directly via BatchPutDocumentCommand without going through the S3 connector sync cycle.
ACL enforcement: the ENTERPRISE_EDITION requirement
Kendra's access control system restricts query results to documents accessible by the requesting user based on _acl_principals metadata. Each ACL principal entry specifies a Name (user ID or group name), Type (USER or GROUP), and Access (ALLOW or DENY — DENY takes precedence over ALLOW). When QueryCommand is called with a UserContext specifying the user's ID and groups, Kendra filters results to: documents where _acl_principals includes the user ID or any of their groups as ALLOW, documents with no ACL set (public within the index), and excludes documents where the user is explicitly DENY-listed.
The critical limitation: ACL enforcement only works on ENTERPRISE_EDITION. DEVELOPER_EDITION silently ignores both _acl_principals metadata and UserContext in QueryCommand — all documents are returned to all users. This behavior is not indicated in the API response (no error, no warning). If document-level access control is a security requirement, it must be ENTERPRISE_EDITION from the start, since you cannot change an existing index's edition.
ACL filtering is enforced server-side by Kendra — it is not a post-query filter in your MCP handler code. This means users cannot access restricted documents even by calling the Kendra API directly (if your API Gateway or auth layer passes through to Kendra). It also means that your MCP handler never sees the filtered-out documents — the tool response naturally excludes documents the user cannot access without any application-layer code required.
Supported file types and the silent-skip trap
Files in unsupported formats (CSV, JSON, XML, ZIP, images) are silently skipped during sync — the job completes with SUCCEEDED and the skipped files do not appear in the failed document count. The only way to detect format-based exclusions is to compare the number of S3 objects matching the configured prefix against DocumentsAdded + DocumentsFailed in the sync job metrics. Set up a CloudWatch alarm on the difference. For scanned PDFs (image-only, no embedded text), the size limit is met but text extraction produces nothing — use Amazon Textract as a pre-processing step before ingesting into Kendra. For large PDFs over the 5 MB limit, split with a library like pdf-lib into smaller fragments and index each fragment separately with shared metadata.
Part 3: Custom data source — BatchPutDocument and the sync job protocol
When no built-in connector exists for your content source — GitHub webhook events, database query results, internal REST APIs, proprietary content systems — the CUSTOM data source type inverts Kendra's crawl model: instead of Kendra pulling documents from a source, your code pushes documents to Kendra via BatchPutDocument. The sync job is the logical wrapper for each ingestion run.
When to use custom vs built-in connectors
Use CUSTOM when: no connector exists for your source; you need on-demand triggering (connectors run on a schedule; custom jobs can be triggered any time programmatically); you need control over what text is indexed (connectors extract content according to their own logic; BatchPutDocument lets you index computed or synthesized text, not just raw document content); or your content is generated rather than stored (API responses, database query results, real-time aggregations). Use built-in connectors for SharePoint, Confluence, and Salesforce — the connectors handle authentication rotation, pagination edge cases, and ACL mapping to Kendra principals, all of which you would have to re-implement correctly with a custom connector.
A common hybrid pattern: a built-in connector for the main corpus (nightly Confluence sync) plus a CUSTOM data source for time-sensitive supplemental content (new Jira incidents indexed within seconds of creation via a webhook-triggered Lambda). Both data sources feed the same Kendra index and are searched together in a single QueryCommand call.
The sync job protocol and the finally-block rule
The sync job protocol has three mandatory steps: StartDataSourceSyncJobCommand (returns an ExecutionId), paginated BatchPutDocumentCommand calls within the job boundary, and StopDataSourceSyncJobCommand. The Stop call must be in a finally block — if your Lambda times out or throws an unhandled exception before calling Stop, the data source stays in a RUNNING state. All subsequent Start calls return ConflictException until the stale job is manually stopped. Kendra enforces exactly one concurrent sync job per data source.
import {
KendraClient,
StartDataSourceSyncJobCommand,
StopDataSourceSyncJobCommand,
BatchPutDocumentCommand,
ListDataSourceSyncJobsCommand,
} from '@aws-sdk/client-kendra';
const kendra = new KendraClient({ region: process.env.AWS_REGION ?? 'us-east-1' });
const INDEX_ID = process.env.KENDRA_INDEX_ID!;
const DATA_SOURCE_ID = process.env.KENDRA_DATA_SOURCE_ID!;
async function runFullSync(sourceDocs: SourceDocument[]): Promise<void> {
// Start the sync job
const { ExecutionId: executionId } = await kendra.send(
new StartDataSourceSyncJobCommand({ Id: DATA_SOURCE_ID, IndexId: INDEX_ID })
);
try {
// Push in batches of 100 (API limit)
for (let i = 0; i < sourceDocs.length; i += 100) {
const batch = sourceDocs.slice(i, i + 100);
const response = await kendra.send(new BatchPutDocumentCommand({
IndexId: INDEX_ID,
Documents: batch.map(doc => ({
Id: stableDocId(doc.url), // SHA-256 hash as base64url — never use raw URLs
Title: doc.title,
Blob: Buffer.from(doc.content.slice(0, 1_900_000), 'utf-8'), // 2 MB limit
ContentType: 'HTML',
Attributes: [
{ Key: 'category', Value: { StringValue: doc.category } },
{ Key: 'tags', Value: { StringListValue: doc.tags } },
{ Key: 'priority_score', Value: { LongValue: BigInt(doc.priority) } }, // BigInt required
{ Key: '_last_updated_at', Value: { DateValue: new Date(doc.updatedAt) } }, // Date object required
],
})),
}));
// FailedDocuments is a partial-failure list — inspect it
for (const failed of response.FailedDocuments ?? []) {
console.error('Partial failure', { id: failed.Id, code: failed.ErrorCode, msg: failed.ErrorMessage });
}
}
} finally {
// Always stop — even if an error occurred mid-sync
await kendra.send(new StopDataSourceSyncJobCommand({ Id: DATA_SOURCE_ID, IndexId: INDEX_ID }));
}
}
// Stable document ID — raw URLs contain / and # which cause INVALID_REQUEST
import crypto from 'crypto';
function stableDocId(sourceKey: string): string {
return crypto.createHash('sha256').update(sourceKey).digest('base64url');
}
Attribute type strict rules
Kendra enforces strict type rules at ingestion time that produce non-obvious errors. LONG_VALUE requires BigInt in the AWS SDK v3 — passing a regular JavaScript number causes a serialization error that is not surfaced as a BatchPutDocument FailedDocument error but rather a client-side SDK exception before the request is sent. DATE_VALUE requires a JavaScript Date object — passing an ISO 8601 string causes a ValidationException: Invalid attribute value type for DATE attribute error that does not name the offending attribute in the message, making it hard to debug in a document with many date attributes. For document IDs, raw URLs contain / and # characters that cause INVALID_REQUEST in FailedDocuments — always hash source keys to base64url encoded strings.
Incremental updates with DynamoDB watermark
Running a full sync every time is expensive for large datasets. Kendra supports efficient incremental updates: BatchPutDocument for a document ID that already exists in the index updates it in place (Kendra deduplicates by ID). For deletions, BatchDeleteDocumentCommand removes documents by ID. The pattern below maintains a last_synced_at watermark in DynamoDB: each sync run fetches only documents modified since the last run, pushes those to Kendra, checks for deletions, then updates the watermark only after a successful sync. If a Lambda times out without updating the watermark, the next invocation re-processes the same window — this produces duplicate BatchPutDocument calls for documents already indexed, which is safe because Kendra deduplicates by ID.
The deletion detection pattern uses Kendra QueryCommand with an AttributeFilter on _data_source_id to enumerate documents from your custom data source, then compares that set against the current source document IDs. Any Kendra ID not present in the source has been deleted and should be removed via BatchDeleteDocument. This enumeration has a Kendra limit: pagination caps at 100 total results per query (10 pages × 10 items). For indexes with more than 100 documents from your custom data source, maintain a separate DynamoDB table as the authoritative document ID registry rather than enumerating from Kendra.
CONFLICT exception handling for concurrent invocations
If your ingestion Lambda is triggered by an event source that can fire multiple concurrent invocations — SQS with concurrency > 1, EventBridge with multiple targets, manual retries — two invocations may race to start a sync job. The second StartDataSourceSyncJob call returns ConflictException. Wrap the Start call in a try/catch for ConflictException and implement exponential backoff (starting at 1s, capping at 30s, max 5 retries) before concluding the previous job is stale. If the job is genuinely stale after retries, call ListDataSourceSyncJobsCommand, verify the running job is old (started more than your Lambda timeout ago), call StopDataSourceSyncJobCommand, and retry Start.
Part 4: Relevance tuning — AttributeFilter, DocumentRelevanceOverride, and UserContext
Kendra's relevance tuning surface has three distinct levers that operate at different points in the query pipeline. Understanding where each applies is the key to using them correctly without being surprised by interactions.
AttributeFilter: hard pre-filtering before ML scoring
AttributeFilter is a predicate tree evaluated before Kendra's ML ranking model. Documents that do not match the filter are excluded from results entirely — they never reach the relevance model. This is the right tool for hard boundaries: tenant isolation (all documents from a specific workspace ID), content access control (only published documents, not drafts), and date range exclusions (documents created in the last 90 days). The predicate tree uses three logical operators (AndAllFilters, OrAllFilters, NotFilter) and seven leaf predicates (EqualsTo, ContainsAll, ContainsAny, GreaterThan, GreaterThanOrEquals, LessThan, LessThanOrEquals). All predicates compose freely to arbitrary depth.
// Multi-condition AttributeFilter: tenant-scoped, recency-gated, excluding archived docs
const attributeFilter = {
AndAllFilters: [
// Hard tenant boundary
{
EqualsTo: {
Key: 'workspace',
Value: { StringValue: workspaceId },
},
},
// Only documents from the last 30 days
{
GreaterThanOrEquals: {
Key: '_last_updated_at',
Value: { DateValue: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) },
},
},
// Exclude archived documents
{
NotFilter: {
EqualsTo: { Key: 'status', Value: { StringValue: 'archived' } },
},
},
// Documents tagged with any of the user's allowed categories
{
ContainsAny: {
Key: 'tags',
Value: { StringListValue: userAllowedTags },
},
},
],
};
Attributes used in AttributeFilter must have Searchable: true in their DocumentMetadataConfiguration definition. If you try to filter on a non-searchable attribute, Kendra throws ValidationException: attribute X is not searchable. Built-in underscore-prefixed attributes (_created_at, _last_updated_at, _data_source_id, _category) do not need to be declared in the schema — they are always available for filtering.
DocumentRelevanceOverride: soft boosting after ML scoring
DocumentRelevanceOverrides adjusts relevance scores after Kendra's ML model runs, without excluding any documents. Documents that match a boost pattern rank higher; those that do not simply retain their ML-assigned score. This is the right tool for surfacing preferred content types without hard-excluding the lower-ranked content: boost API reference docs to the top while still including general FAQ results further down.
Three types of override are available. First, Importance (integer 1–10) amplifies the attribute's overall contribution to the final score — passing 0, a float, or a value outside 1–10 raises a ValidationException. Second, ValueImportanceMap assigns different weights to specific string values of a STRING attribute: { 'api-reference': 8, 'tutorial': 5, 'faq': 3, 'general': 1 } boosts API reference results strongly and demotes general results slightly. Third, Freshness: true on the _last_updated_at attribute enables automatic time-decay boosting — documents updated today rank higher than documents from last year, with a smooth decay curve. RankOrder: 'DESCENDING' on a LONG_VALUE attribute ranks higher numeric values (e.g., priority_score 9) above lower ones.
Combining AttributeFilter and DocumentRelevanceOverrides on the same query is the standard pattern for a workspace-aware knowledge base tool: AttributeFilter restricts to the tenant's document set (hard boundary), DocumentRelevanceOverrides pushes the most relevant document types to the top of that filtered set (soft ranking). They operate at different pipeline stages and do not interfere with each other.
UserContext and the USER_TOKEN prerequisite
UserContext enforces ACL-based access on top of relevance scoring. When a document is indexed with an _acl_principals attribute, passing UserContext in QueryCommand causes Kendra to filter results to documents accessible by that user. This sounds straightforward — the critical subtlety is the UserContextPolicy on the index.
The default UserContextPolicy is ATTRIBUTE_FILTER. With this default, Kendra completely ignores the UserContext field in QueryCommand — the query runs as if no UserContext was provided, returning all documents regardless of their _acl_principals metadata. There is no error, no warning, no indication in the response that UserContext was ignored. Teams that annotate all their documents with ACL principals, pass UserContext in every query, and deploy to production under ATTRIBUTE_FILTER policy have zero actual access control. This is the single most common security misconfiguration in Kendra-backed MCP server deployments.
The fix is a single API call to change the policy to USER_TOKEN:
import { KendraClient, UpdateIndexCommand, UserContextPolicy } from '@aws-sdk/client-kendra';
// One-time setup after index creation — requires kendra:UpdateIndex permission
await kendra.send(new UpdateIndexCommand({
Id: INDEX_ID,
UserContextPolicy: UserContextPolicy.USER_TOKEN,
// If passing JWT tokens directly, also configure the signing key validation:
// UserTokenConfigurations: [{
// JwtTokenTypeConfiguration: {
// KeyLocation: 'SECRET_MANAGER',
// SecretManagerArn: process.env.KENDRA_JWT_SECRET_ARN,
// UserNameAttributeField: 'sub',
// GroupAttributeField: 'cognito:groups',
// },
// }],
}));
// This takes effect within minutes without an index rebuild
After setting USER_TOKEN, QueryCommand calls with UserContext enforce ACL. Documents with no _acl_principals attribute remain visible to all users (they are treated as public within the index). The UserContext principal names must exactly match the principal names in _acl_principals — Kendra does string equality, not fuzzy matching. User IDs from your JWT claim must match the USER principal names in document metadata exactly. If you are using an identity data source connector (Okta, IAM Identity Center), Kendra resolves group memberships from the identity provider at query time and you only need to supply UserId in UserContext, not the full group list.
Faceted search and the 100-result hard ceiling
Kendra returns up to 100 total results across all pages regardless of how many documents match the query. The TotalNumberOfResults field in the response is Kendra's estimate of matching documents — it can be in the thousands — but only 100 items are actually returnable. Always display Math.min(TotalNumberOfResults, 100) as the displayed total and communicate the limitation to the caller.
Facets in QueryCommand return per-attribute-value counts via DocumentAttributeValueCountPairs, enabling drill-down navigation. The two-phase faceted search pattern: first query returns facet counts plus the first page of results; second query adds an AttributeFilter on the selected facet value to narrow the result set. Do not request facets on the narrowed query — the AttributeFilter already constrains the scope, and facet computation on the narrowed result set produces misleading counts.
Query suggestions via GetQuerySuggestionsCommand power autocomplete. Kendra needs at least 100 unique queries submitted to the index before it generates suggestions — a fresh index returns empty suggestions. After sufficient query volume, suggestions are derived from actual query history filtered through any configured block lists. Query suggestions only affect the autocomplete dropdown; they do not affect the main QueryCommand ranking.
Kendra vs OpenSearch vs Bedrock Knowledge Bases: choosing the right search backend
All three services serve as search backends for MCP tools, but they occupy different positions in the search-vs-RAG spectrum. The wrong choice means engineering against the service's natural grain.
Kendra is the right choice when: your documents live in enterprise content systems (SharePoint, Confluence, Salesforce) with complex ACL structures you do not want to replicate; you need FAQ-style direct answer extraction as a first-class result type; or you need managed connectors that handle authentication and change detection without custom ETL. The tradeoff is opacity (the ranking model is a black box, throughput defaults to 10–40 QPS) and cost ($810–$1,008/month base regardless of usage).
OpenSearch is the right choice when: you need complete control over index mapping and scoring scripts (BM25, kNN hybrid, custom function_score); your use case requires aggregations, percolator queries, or query analytics; or you have an existing OpenSearch/Elasticsearch deployment. The tradeoff is operational burden — shard sizing, replica management, index lifecycle policies, and you build your own ingestion pipeline.
Bedrock Knowledge Bases is the right choice when: your documents are in S3 and you want the fastest path to grounded Q&A with citations; you need embedding-based semantic retrieval via the RetrieveAndGenerate API; or you want zero infrastructure management. The tradeoff is limited data sources (S3 natively; others require custom ingestion), no structured connectors, and no keyword-search fallback by default.
Production checklist
- Edition decision documented: DEVELOPER_EDITION (dev/prototype, <25K docs, no ACL) or ENTERPRISE_EDITION (production, ACL required, HA) — decision documented and not changeable without full data re-ingestion.
- Custom attribute schema finalized before first ingestion: All STRING/STRING_LIST/LONG/DATE attributes defined in
DocumentMetadataConfigurationswith correctSearchable/Facetable/Displayable/Sortableflags — adding attributes after first ingestion does not backfill. - IAM ARN format verified: Index ARN is
arn:aws:kendra:REGION:ACCOUNT:index/INDEX_ID(withindex/prefix) — validated withaws iam simulate-principal-policybefore deploy. - S3 connector metadata prefix convention verified: Sidecar for
docs/file.pdflives atMETADATA_PREFIX/docs/file.pdf.metadata.json(full document key preserved, not stripped) — confirmed withaws s3 ls s3://bucket/METADATA_PREFIX. - ACL edition check: If
_acl_principalsmetadata is set, index is ENTERPRISE_EDITION — DEVELOPER_EDITION silently ignores ACL with no error. - UserContext policy configured: If UserContext is passed in QueryCommand, index
UserContextPolicyisUSER_TOKEN(not the defaultATTRIBUTE_FILTER) — verified withDescribeIndexCommand. - Custom data source sync job has StopDataSourceSyncJob in finally block: Prevents data source lockout on Lambda timeout or unhandled exception.
- Document IDs are hashed: SHA-256 base64url encoded from source keys — raw URLs with
/and#causeINVALID_REQUESTin FailedDocuments. - LONG attributes use BigInt, DATE attributes use Date objects: Wrong types cause SDK-level serialization errors (LONG) or
ValidationException(DATE) that are hard to attribute to a specific document attribute. - DocumentRelevanceOverrides Importance values are integers 1–10: Float values and 0 raise
ValidationException. - Sync job INCOMPLETE status monitored: CloudWatch metric filter on
DocumentSyncErrorlog events in/aws/kendra/INDEX_IDlog group — INCOMPLETE syncs indicate partially failed ingestion requiring investigation. - Post-deploy warm-up wait: First sync on a new index takes 15–30 minutes before queries return results — integration tests include a wait step polling DescribeIndex document count before asserting query results.
- TotalNumberOfResults capped at 100 in MCP tool response: Kendra returns a maximum of 100 actual result items regardless of the estimated total — MCP tool communicates this ceiling to callers.
Related guides
- Amazon Kendra index setup, QueryCommand handler, and IAM permissions
- Kendra S3 connector — document sync, metadata JSON sidecars, and ACL mapping
- Kendra custom data source — BatchPutDocument, sync job lifecycle, and incremental updates
- Kendra AttributeFilter, DocumentRelevanceOverride, UserContext, and faceted search
- OpenSearch as a Kendra alternative — full-text search and custom scoring scripts
- Bedrock Knowledge Bases — embedding-based RAG with citations for MCP tools
- DynamoDB as the incremental sync watermark store for custom data sources
- CloudWatch alarms for Kendra sync job monitoring and query latency
- MCP server data infrastructure guide — DynamoDB, S3, and Aurora
- Amazon Neptune for MCP servers — graph databases, Gremlin traversals, and knowledge graphs