AWS Bedrock · 2026-11-02 · Bedrock Advanced arc
AWS Bedrock Advanced Patterns for MCP Servers: Prompt Caching, Embeddings, Batch Inference, and Multimodal
AliveMCP pings every registered MCP endpoint every 60 seconds — but the endpoints that are hardest to keep alive are the ones with AI inference in their hot path, because Bedrock API costs and latency amplify every reliability problem: a system prompt re-sent on every tool call at full input-token price, a semantic search implementation that re-embeds the same corpus on every cold start, a weekly health report that blocks on sequential on-demand inference instead of running a single batch job, or a multimodal tool that silently fails because it sends a PNG to Titan instead of Claude. Four advanced Bedrock capabilities close the gap between a basic ConverseCommand implementation and a production-grade AI-native MCP server. Prompt caching with cachePoint blocks eliminates the per-invocation cost of re-processing stable system prompts: for an MCP server calling Claude 3.5 Sonnet with a 3,000-token system prompt, every tool call without caching costs $0.009 in system-prompt tokens alone — at 100 calls per 5-minute window that's $0.90 per window; with a cachePoint after the system text, the first call pays $0.01125 (125% cache-write), each subsequent call pays $0.0009 (10% cache-read), cutting total cost from $0.90 to $0.10 per window, an 89% reduction; the mechanism is a { cachePoint: { type: 'default' } } block placed as the last element in the system array or in a user message content array, subject to a minimum token count before the marker (1,024 tokens for Claude 3 Haiku and Claude 3.5 Haiku, 2,048 tokens for all Sonnet variants), a 5-minute TTL that refreshes on every cache read hit, and the hard requirement that every byte before the cachePoint is identical across requests — any dynamic value interpolated into the system prompt (timestamp, user ID, environment variable) silently produces a cache miss and a fresh write, with the symptom being cacheReadInputTokenCount: 0 on every call after the first; the troubleshooting table covers five specific failure modes from zero cacheWriteInputTokenCount (below threshold) to cache hits in development but misses in production (environment-specific values in the system prompt), and multi-turn agentic loops can add a second cachePoint after accumulated message history once it exceeds the threshold to cache the growing conversation prefix. Bedrock embeddings via Titan Text Embeddings V2 and Cohere Embed 3 power the semantic search, RAG, and tool-routing capabilities that differentiate advanced MCP servers from simple API wrappers: both models are called via InvokeModelCommand with model-specific JSON request bodies (not the Converse API), and each has a distinct set of trade-offs that determine which is correct for a given use case; Titan V2 (amazon.titan-embed-text-v2:0) offers 8,192-token input capacity (enough for a 24-page document), three output dimension sizes (256/512/1024), and a normalize=true parameter that returns unit-normalized vectors so that cosine similarity reduces to a plain dot product — ideal for symmetric search tasks like finding MCP servers with similar capability descriptions; Cohere Embed 3 (cohere.embed-english-v3) requires specifying input_type: 'search_query' for query vectors and 'search_document' for corpus document vectors because the model was trained with separate encoder heads for queries and documents, and using the same input_type for both degrades retrieval quality by 5–15%; Cohere also accepts 96 texts per API call (vs Titan's 1), supports int8 (4× storage compression) and binary (32× storage compression) quantization at 1% and 5–10% quality loss respectively, and has a lower per-text token limit of 512 tokens (recommended) — making it the right choice for large corpora that need cost-efficient batch indexing; production semantic search stores vectors in a pgvector table with an HNSW index (vector_cosine_ops), queried with the <=> cosine distance operator; the embedding model IAM permission is bedrock:InvokeModel on the embedding model ARN, separate from the text-generation model permissions. Batch inference via CreateModelInvocationJob is the right architecture for MCP tools that perform bulk analysis — nightly report generation, weekly health summaries across thousands of monitored endpoints, offline corpus classification — because it runs asynchronously at 50% of on-demand pricing, decouples submission from result retrieval, and scales to 50,000 records per job without any rate-limit management: the input is a JSONL file uploaded to S3 where each line contains a recordId string and a modelInput object using the model's native request format (Anthropic Messages API for Claude, not the Converse API format); the job requires a dedicated IAM role with a bedrock.amazonaws.com service trust and explicit s3:GetObject / s3:PutObject permissions on the input and output buckets; the MCP tool returns the jobArn immediately and exposes a separate poll tool backed by GetModelInvocationJobCommand, or an EventBridge rule on detail-type: "Batch Inference Job State Change" with detail.status: ["Completed", "Failed"] triggers a Lambda that reads the output JSONL automatically; the output JSONL mirrors the input record IDs, with successful records containing a modelOutput field and failed records containing an error field with errorCode and errorMessage — critically, failed records do not make the job status Failed, a job can complete with partial failures and the job-level status is still Completed, so result processing must always check the failure count before declaring success; one concurrent running job per account per region is the hard limit (additional jobs queue, they don't fail), and jobs expire after 7 days. Bedrock multimodal via ImageBlock and DocumentBlock in the Converse API enables MCP tools that accept screenshots of monitoring dashboards, architecture diagrams, PDF reports, or Excel spreadsheets — but model support is far narrower than for text-only inference: Claude 3.x/4.x and Amazon Nova Lite/Pro support image inputs, Claude 3.5 Haiku and newer support DocumentBlock for DOCX, XLSX, and CSV files (Claude 3.0 only handles PDF), and Titan, Mistral, and Llama do not support any multimodal inputs at all via the Converse API; image tokens are priced separately and approximated as (width × height) / 750 for Claude models (a 1024×1024 image costs approximately 1,400 tokens at standard input pricing), with Claude automatically downsampling images longer than 1,568 pixels on the longest edge before processing; S3 source images are fetched by Bedrock using the caller's IAM credentials — not a separate service role — which means the Lambda execution role or ECS task role must have s3:GetObject on the image bucket; the most expensive anti-pattern is leaving images in multi-turn conversation history because image tokens are re-billed on every subsequent turn, making a 10-turn conversation about a single 1024×1024 screenshot approximately 14,000 extra image tokens across the full conversation compared to the first turn alone. This guide synthesises all four advanced Bedrock patterns. Pattern 1 covers prompt caching — cachePoint placement rules, minimum token thresholds per model family, TTL behavior, byte-identical content requirement, cost model, and troubleshooting — from Bedrock Prompt Caching in MCP Servers. Pattern 2 covers embeddings — Titan V2 vs Cohere Embed 3 comparison, asymmetric search, chunking, pgvector integration, and model selection guide — from Bedrock Embeddings in MCP Servers. Pattern 3 covers batch inference — JSONL format, IAM role setup, job lifecycle, EventBridge completion, output processing, and error handling — from Bedrock Batch Inference in MCP Servers. Pattern 4 covers multimodal — ImageBlock and DocumentBlock formats, model support matrix, S3 vs inline bytes, image token pricing, multi-turn cost, and error handling — from Bedrock Multimodal in MCP Servers.
TL;DR
- Prompt caching — place
cachePointat end of stable content; checkcacheReadInputTokenCountto verify hits; minimum 1024 tokens (Haiku) or 2048 tokens (Sonnet) before the marker; any byte difference between requests silently prevents hits: Add{ cachePoint: { type: 'default' } } as unknown as SystemContentBlockas the last element in thesystemarray. Build the system prompt string once at startup and reuse the same reference — never re-interpolate per request. Verify cache hits by checkingresponse.usage.cacheReadInputTokenCount > 0. Cache write = 125% of base input price; cache read = 10%; break-even at 2 requests per 5-minute TTL window. IfcacheWriteInputTokenCountis 0, the token count before the marker is below the model's minimum threshold. IfcacheReadInputTokenCountstays 0, a dynamic value is embedded in the cached section. - Embeddings — use Titan V2 for symmetric long-document search; use Cohere Embed 3 with
input_type: 'search_query'/'search_document'for asymmetric RAG; store in pgvector with HNSW index; require separatebedrock:InvokeModelpermission on embedding model ARN: CallInvokeModelCommandwithmodelId: 'amazon.titan-embed-text-v2:0'and body{ inputText, dimensions: 1024, normalize: true }. Cosine similarity of normalized vectors = dot product. For Cohere: always useinput_type: 'search_document'when indexing corpus,'search_query'for user queries — mixing types degrades retrieval 5–15%. Cohere batches 96 texts per call; Titan processes 1 text per call. pgvector:vector(1024)column + HNSW index withvector_cosine_ops; query with<=>cosine distance operator in ORDER BY. Add the embedding model ARN to IAM policy separately — text-generation ARNs do not grant embedding model access. - Batch inference — upload JSONL to S3, create job with bedrock.amazonaws.com trust role, return jobArn immediately, use EventBridge for completion notification; failed records don't fail the job: Input JSONL: one
{"recordId": "...", "modelInput": {...}}per line using the model's native format (Anthropic Messages API for Claude, not Converse format). IAM role needsbedrock:InvokeModel+ S3 read/write withbedrock.amazonaws.comtrust +SourceAccountcondition. Create job viaCreateModelInvocationJobCommand; store returnedjobArn. EventBridge rule: sourceaws.bedrock, detail-typeBatch Inference Job State Change, detail.status["Completed", "Failed"]. Output JSONL: each line has eithermodelOutput(success) orerror.errorCode+error.errorMessage(failure). A completed job can still have failed records — always count failures before declaring success. Limit: 1 running job per region at a time; 50,000 records per job; 50% cost vs on-demand. - Multimodal — Claude 3.x/4.x and Nova Lite/Pro support images; Claude 3.5+ supports DOCX/XLSX/CSV; NOT Titan/Mistral/Llama; image tokens = (w×h)/750 for Claude; images in conversation history are re-billed every turn: Inline bytes:
{ image: { format: 'jpeg'|'png'|'gif'|'webp', source: { bytes: imageUint8Array } } }in user message content array, max 5MB per image. S3 source:source: { s3Location: { uri, bucketOwner } }— caller role needss3:GetObject. Documents:{ document: { format: 'pdf'|'docx'|'xlsx'|'csv', name: 'file.pdf', source: { bytes } } }, PDF max 50 pages. Token estimate: 1024×1024 → 1,398 tokens ($0.0042/image at Sonnet pricing). Multi-turn cost trap: an image in turn 1 costs image tokens on turns 2, 3, … as well — extract text description in first turn and drop image from subsequent messages if token budget is a constraint. Nova Lite uses megapixel pricing and is significantly cheaper for high-volume classification tasks.
Pattern 1: Prompt Caching — eliminating repeated system-prompt costs in MCP tool handlers
The most expensive inefficiency in AI-native MCP servers is not the output tokens — it is the input tokens consumed by re-processing identical system prompts on every tool invocation. When an MCP server calls Claude 3.5 Sonnet with a 3,000-token system prompt containing tool documentation, behavioral instructions, and reference tables, every call pays the full input price for those tokens even though the model could, in principle, reuse the KV-cache computed for the same content on the previous request. Bedrock prompt caching makes that reuse explicit via the cachePoint block: a control directive inserted into the message array that tells Bedrock "everything before this marker is stable; store the intermediate computation and reuse it on the next request that presents the same prefix."
The cachePoint block and where to place it
A cachePoint is a typed content block with a single property: { cachePoint: { type: 'default' } }. It does not produce any output text — it is a routing instruction consumed before inference begins. Two placement positions are valid:
- System array placement: Add the
cachePointas the last element in thesystemarray after all system text blocks. Everything in the system array before the marker is checkpointed. This is the standard placement for MCP servers that have a large, stable system prompt. - User message content array placement: Add the
cachePointas the last element of thecontentarray within auserrole message. Useful for caching few-shot examples passed as user turns, or for caching accumulated multi-turn conversation history.
The SDK type for a cachePoint in the system array is ContentBlockMemberCachePoint, which requires a type assertion in TypeScript because the union type definition may not include it in older SDK versions:
import {
BedrockRuntimeClient,
ConverseCommand,
type SystemContentBlock,
} from '@aws-sdk/client-bedrock-runtime';
// Build stable system prompt once at server startup
const TOOL_DOCS = buildToolDocumentation(); // ~2500 tokens of stable reference text
const CACHED_SYSTEM: SystemContentBlock[] = [
{
text: `You are an MCP server diagnostics expert.
When analyzing issues, check the specific MCP error code (-32603, -32601, etc.)
and verify endpoint health via AliveMCP status page first.
Reference documentation:
${TOOL_DOCS}
`,
},
// cachePoint checkpoints everything above — must be last element
{ cachePoint: { type: 'default' } } as unknown as SystemContentBlock,
];
// Reuse CACHED_SYSTEM across all requests — never re-build per request
async function callBedrock(userQuery: string) {
const response = await new BedrockRuntimeClient({ region: 'us-east-1' }).send(
new ConverseCommand({
modelId: 'us.anthropic.claude-3-5-sonnet-20241022-v2:0',
system: CACHED_SYSTEM,
messages: [{ role: 'user', content: [{ text: userQuery }] }],
inferenceConfig: { maxTokens: 2048 },
}),
);
const { cacheWriteInputTokenCount, cacheReadInputTokenCount } = response.usage ?? {};
console.log({ cacheWriteInputTokenCount, cacheReadInputTokenCount });
return response;
}
Token thresholds and why they silently suppress caching
Bedrock enforces a minimum token count before the cachePoint. If the token count before the marker is below the minimum, caching does not activate — no error is raised, cacheWriteInputTokenCount will be 0, and the request processes normally at standard pricing. There is no warning in the response.
| Model family | Minimum tokens before cachePoint | Max cachePoints per request |
|---|---|---|
| Claude 3 Haiku, Claude 3.5 Haiku | 1,024 | 4 |
| Claude 3 Sonnet, Claude 3.5 Sonnet | 2,048 | 4 |
| Claude Sonnet 4.x, Claude Opus 4.x | 2,048 | 4 |
| Titan, Llama, Mistral, Command R+ | Not supported | — |
If your system prompt is below 2,048 tokens on Sonnet and you want caching to activate, add substantive content: tool schema documentation, behavioral examples, reference tables, or explanatory prose about the tools. Do not pad with filler text — the quality of the system prompt degrades. The right approach is to make the system prompt genuinely useful and naturally long enough to cross the threshold.
The TTL, AZ distribution, and deployment-boundary behavior
Cache entries expire 5 minutes after their last read. Each cache read resets the 5-minute clock. For MCP servers receiving more than one tool call per 5 minutes — any production service with meaningful traffic — the cache entry stays warm indefinitely. Three operational nuances matter for production deployments:
- AZ-level granularity: Cache entries are per-availability-zone. A multi-AZ Lambda deployment will see each AZ pay a cache-write cost on its first request, then cache-read prices within the AZ. This is expected behavior — high-traffic services will establish AZ-level caches within seconds of the first request to each AZ.
- Low-traffic tools: An MCP tool called once every 10 minutes will pay cache-write cost (125%) on every call, with zero benefit. Cache only pays off when call frequency exceeds one call per 5-minute window per cachePoint. For low-traffic tools, remove the cachePoint to avoid the 25% write-cost overhead with no benefit.
- Rolling deployments: Any change to the system prompt text invalidates the cache. During a rolling restart where new instances build their cache entry, there is a brief period of increased write costs. The system prompt should be versioned alongside the code — changing it incidentally (e.g., by embedding a deployment timestamp) is a common mistake that prevents caching from working.
Troubleshooting: why cache hits are not occurring
| Symptom | Cause | Fix |
|---|---|---|
cacheWriteInputTokenCount is 0, no error |
Token count before cachePoint is below the model's minimum threshold | Add more substantive content to the system prompt to exceed 2,048 tokens (Sonnet) or 1,024 tokens (Haiku) |
cacheReadInputTokenCount stays 0 after first request |
System prompt content differs between requests — a dynamic value (timestamp, user ID, request ID) is being interpolated into the stable section | Move all dynamic content to the user message; rebuild the system prompt object once at startup and reuse the same reference |
ValidationException: cachePoint is not supported |
Model does not support prompt caching (Titan, Llama, Mistral, Command R+) | Prompt caching is Claude-only via Bedrock Converse API; switch to a Claude model or remove the cachePoint |
| Cache hits in development, misses in production | Production system prompt includes environment-specific values (endpoint URLs, region names, stage names) absent from development | Externalize all environment-specific values to the user message or to a separate non-cached section after the cachePoint |
| cachePoint in middle of content array, ValidationException | cachePoint must be the last element of a content array; additional blocks after it are invalid | Move cachePoint to the end of the system array or the end of the user message content array |
Pattern 2: Embeddings — semantic search and RAG with Titan V2 and Cohere Embed 3
MCP servers that need to find semantically similar content — matching a user query to the right tool, retrieving relevant documentation chunks for a RAG pipeline, clustering MCP server capability descriptions, or identifying which monitored endpoint most closely matches a user's search term — need to convert text into dense vector representations. Bedrock provides two production-grade embedding model families with different trade-offs: Titan Text Embeddings V2 for symmetric search on long documents, and Cohere Embed 3 for asymmetric search with storage compression.
Titan Text Embeddings V2: symmetric search with long inputs
Titan V2 (amazon.titan-embed-text-v2:0) is called via InvokeModelCommand with a JSON body containing inputText, dimensions (256, 512, or 1024), and normalize. The 1,024-dimension model provides the highest retrieval quality and is the right default for new projects. The normalize=true parameter returns unit-length vectors, which means cosine similarity between two vectors reduces to a plain dot product — no magnitude division needed.
import {
BedrockRuntimeClient,
InvokeModelCommand,
} from '@aws-sdk/client-bedrock-runtime';
interface TitanEmbedResponse {
embedding: number[];
inputTextTokenCount: number;
}
async function embedWithTitan(
text: string,
dimensions: 256 | 512 | 1024 = 1024,
): Promise {
const client = new BedrockRuntimeClient({ region: 'us-east-1' });
const body = JSON.stringify({ inputText: text, dimensions, normalize: true });
const response = await client.send(new InvokeModelCommand({
modelId: 'amazon.titan-embed-text-v2:0',
contentType: 'application/json',
accept: 'application/json',
body: new TextEncoder().encode(body),
}));
const decoded = new TextDecoder().decode(response.body);
const result: TitanEmbedResponse = JSON.parse(decoded);
return result.embedding;
}
// With normalize=true, cosine similarity = dot product of the two vectors
function cosineSimilarity(a: number[], b: number[]): number {
return a.reduce((sum, val, i) => sum + val * b[i], 0);
}
Titan V2's 8,192-token limit (roughly 32,000 characters) is rarely a constraint for MCP tool inputs, but matters when embedding full document contents for a RAG pipeline. For documents longer than 8,192 tokens, chunking is required.
Cohere Embed 3: asymmetric search and storage compression
The defining characteristic of Cohere Embed 3 is asymmetric embeddings: the model was trained with separate encoder heads for short queries and longer documents. A query embedding for "what is the AliveMCP ping interval?" is numerically different from a document embedding for the same text, and when searching against documents indexed with input_type: 'search_document', the query encoded with input_type: 'search_query' retrieves more relevant results than symmetric encoding of either type. Using the same input_type for both queries and documents — the most common mistake — degrades retrieval quality by 5–15% on most RAG benchmarks.
// Corpus indexing — always search_document
async function buildSearchIndex(documents: string[]): Promise {
const response = await client.send(new InvokeModelCommand({
modelId: 'cohere.embed-english-v3',
contentType: 'application/json',
accept: 'application/json',
body: new TextEncoder().encode(JSON.stringify({
texts: documents, // up to 96 texts per call
input_type: 'search_document', // ALWAYS for corpus indexing
embedding_types: ['float'],
truncate: 'END', // safer than NONE (no error on overlong input)
})),
}));
const result = JSON.parse(new TextDecoder().decode(response.body));
return result.embeddings.float;
}
// Query embedding — always search_query
async function embedQuery(query: string): Promise {
const response = await client.send(new InvokeModelCommand({
modelId: 'cohere.embed-english-v3',
contentType: 'application/json',
accept: 'application/json',
body: new TextEncoder().encode(JSON.stringify({
texts: [query],
input_type: 'search_query', // ALWAYS for user queries
embedding_types: ['float'],
})),
}));
const result = JSON.parse(new TextDecoder().decode(response.body));
return result.embeddings.float[0];
}
Cohere also supports quantized output: embedding_types: ['int8'] reduces storage by 4× with approximately 1% quality loss; embedding_types: ['binary'] reduces storage by 32× with 5–10% quality loss. For AliveMCP's use case of indexing ~100k MCP server capability descriptions at 1024 dimensions, int8 quantization reduces the embedding store from 400MB to 100MB with negligible quality impact.
Model selection guide
| Criterion | Titan Text V2 | Cohere Embed 3 |
|---|---|---|
| Token limit per input | 8,192 tokens | 512 tokens (recommended), 2,048 max |
| Texts per API call | 1 | Up to 96 |
| Asymmetric search | No (symmetric only) | Yes — search_query vs search_document |
| Storage compression | Dimension reduction only | int8 (4×), binary (32×) |
| Multilingual | Yes (less accurate than English) | Dedicated multilingual model |
| Best for | Long-document search, simple semantic similarity, small corpora | RAG retrieval, large corpora, storage-constrained environments |
Storing and querying vectors with pgvector
Production MCP servers store embedding vectors in a searchable vector database. For servers already running PostgreSQL (common on AWS RDS or Aurora), pgvector is the lowest-friction choice — it adds a vector column type and HNSW approximate nearest-neighbor index to standard PostgreSQL without requiring a separate service.
-- pgvector schema for 1024-dim Titan embeddings
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE mcp_server_embeddings (
id TEXT PRIMARY KEY,
slug TEXT NOT NULL,
description TEXT NOT NULL,
embedding vector(1024) NOT NULL,
indexed_at TIMESTAMP DEFAULT NOW()
);
-- HNSW index: ef_construction=64, m=16 are good defaults for 1024-dim
CREATE INDEX ON mcp_server_embeddings
USING hnsw (embedding vector_cosine_ops)
WITH (ef_construction = 64, m = 16);
-- Query: 5 most similar MCP servers to a query vector
SELECT slug, description,
1 - (embedding <=> $1::vector) AS similarity
FROM mcp_server_embeddings
ORDER BY embedding <=> $1::vector -- <=> = cosine distance; lower = more similar
LIMIT 5;
Note the <=> operator is cosine distance (0 = identical, 2 = maximally different), so ORDER BY ... ASC returns the most similar items first. To convert to similarity (0–1 scale), use 1 - (embedding <=> $1::vector) as shown.
Pattern 3: Batch Inference — async bulk analysis at 50% cost via CreateModelInvocationJob
The weekly health summary that processes 10,000 monitored endpoint logs to identify deterioration patterns, the nightly classification pass that categorizes new MCP servers by capability cluster, the monthly SLA report that generates a narrative summary for each enterprise client's private endpoints — these workloads have two characteristics in common: they process many records in one go, and the user is not waiting for a result in real time. Bedrock batch inference is architecturally correct for exactly this pattern: a JSONL input file uploaded to S3, a single CreateModelInvocationJobCommand call, and results delivered to an S3 output prefix when the job completes — at 50% of on-demand pricing.
Input JSONL format and the model-native body requirement
The input file is a JSONL file where each line is a JSON object with two fields: recordId (a unique identifier for this record) and modelInput (the raw model request body). The critical nuance: modelInput uses the model's native request format — for Claude models accessed via Bedrock, this is the Anthropic Messages API format with anthropic_version: 'bedrock-2023-05-31', not the Converse API format. Sending a Converse API body as modelInput produces a ValidationException for every record.
// Correct: Anthropic Messages API format for Claude via Bedrock batch
const record = {
recordId: `endpoint-${serverSlug}`,
modelInput: {
anthropic_version: 'bedrock-2023-05-31', // required for Claude via Bedrock batch
max_tokens: 1024,
messages: [{ role: 'user', content: errorLogText }],
system: 'You are an MCP server diagnostics expert. Summarize the root cause in one sentence.',
},
};
// Incorrect: Converse API format will be rejected
// modelInput: { messages: [...], inferenceConfig: {...} } // ValidationException
IAM role with bedrock.amazonaws.com trust
Batch inference requires a dedicated IAM role — not the Lambda execution role — that Bedrock can assume. The trust policy must name bedrock.amazonaws.com as the service principal and should include a SourceAccount condition to prevent confused deputy attacks.
// Trust policy (applied to the batch inference IAM role)
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "Service": "bedrock.amazonaws.com" },
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": { "aws:SourceAccount": "123456789012" },
"ArnLike": { "aws:SourceArn": "arn:aws:bedrock:us-east-1:123456789012:*" }
}
}]
}
// Permission policy (also attached to the batch inference IAM role)
{
"Statement": [
{ "Action": ["bedrock:InvokeModel"], "Resource": ["arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-3-5-sonnet-20241022-v2:0"] },
{ "Action": ["s3:GetObject", "s3:ListBucket"], "Resource": ["arn:aws:s3:::my-batch-bucket", "arn:aws:s3:::my-batch-bucket/*"] },
{ "Action": ["s3:PutObject"], "Resource": ["arn:aws:s3:::my-batch-output-bucket/*"] }
]
}
Job creation, polling, and EventBridge notification
An MCP tool that submits a batch job should return the job ARN immediately — never block waiting for completion. The caller gets a handle they can use to poll status or receive a notification. For background pipelines, EventBridge is preferable to polling because it requires no background task management:
import { BedrockClient, CreateModelInvocationJobCommand } from '@aws-sdk/client-bedrock';
const bedrock = new BedrockClient({ region: 'us-east-1' });
async function submitBatchJob(inputS3Uri: string, outputS3Uri: string): Promise {
const response = await bedrock.send(new CreateModelInvocationJobCommand({
modelId: 'anthropic.claude-3-5-sonnet-20241022-v2:0',
jobName: `mcp-analysis-${Date.now()}`,
roleArn: process.env.BEDROCK_BATCH_ROLE_ARN!,
inputDataConfig: {
s3InputDataConfig: { s3Uri: inputS3Uri, s3InputFormat: 'JSONLines' },
},
outputDataConfig: {
s3OutputDataConfig: { s3Uri: outputS3Uri },
},
}));
return response.jobArn!; // store this — it is the durable job identifier
}
// EventBridge rule (set up via CDK or Console — not runtime code):
// {
// "source": ["aws.bedrock"],
// "detail-type": ["Batch Inference Job State Change"],
// "detail": { "status": ["Completed", "Failed"] }
// }
// Target: Lambda that reads output S3 and processes results
Output processing and inline error records
The output JSONL mirrors the input: each line contains a recordId plus either a modelOutput (success) or an error object (failure). The job-level status reports Completed even when some records failed — a completed job with 500 error records out of 10,000 is still Completed, not Failed. Always process the failure count before declaring the job a success.
interface BatchOutputRecord {
recordId: string;
modelOutput?: { content: Array<{ type: 'text'; text: string }>; stop_reason: string };
error?: { errorCode: string; errorMessage: string };
}
async function processOutput(outputS3Uri: string) {
// List .jsonl files under the output prefix and read each one
const records: BatchOutputRecord[] = await readOutputJsonl(outputS3Uri);
const successes = records.filter(r => r.modelOutput);
const failures = records.filter(r => r.error);
console.log(`Results: ${successes.length} succeeded, ${failures.length} failed`);
// Handle failures: ThrottledException → retry via on-demand
// ModelTimeoutException → reduce max_tokens or simplify prompt
// ValidationException → fix malformed modelInput records
for (const f of failures) {
console.error(`Record ${f.recordId}: ${f.error!.errorCode} — ${f.error!.errorMessage}`);
}
}
Supported models and hard limits
| Model | Batch inference support | Cost vs on-demand |
|---|---|---|
| Claude 3 Haiku, Claude 3 Sonnet | Supported | 50% discount |
| Claude 3.5 Haiku, Claude 3.5 Sonnet | Supported | 50% discount |
| Amazon Titan Text Premier | Supported | 50% discount |
| Meta Llama 3 70B | Supported | 50% discount |
| Mistral Large, Cohere Command R+ | Not supported | — |
Hard limits: 50,000 records per job; 1 concurrent running job per account per region (additional jobs queue — they do not fail); jobs expire after 7 days if not completed. Set a CloudWatch Alarm on the EventBridge Failed job state to get notified when a batch job fails entirely (distinct from records failing within a completed job).
Pattern 4: Multimodal — ImageBlock and DocumentBlock in the Converse API
MCP servers that accept screenshots of monitoring dashboards, infrastructure diagrams, error screenshots, PDF architecture documents, or Excel health reports need the Bedrock Converse API's multimodal content blocks. The implementation looks simple — add an ImageBlock or DocumentBlock to the user message content array — but the model support matrix is restrictive enough that sending the wrong content type to the wrong model produces a silent runtime error rather than a type error at compile time, and the image token pricing model has a multi-turn cost accumulation pattern that can multiply expected costs by 10× on long conversations.
Model support matrix
| Model | Images (JPEG, PNG, GIF, WebP) | DOCX / XLSX / CSV | Video | |
|---|---|---|---|---|
| Claude 3 Haiku, Claude 3 Sonnet | Yes | Yes | No | No |
| Claude 3.5 Haiku, Claude 3.5 Sonnet | Yes | Yes | Yes | No |
| Claude Sonnet 4.x | Yes | Yes | Yes (+ TXT) | No |
| Amazon Nova Lite | Yes | Yes | No | No |
| Amazon Nova Pro | Yes | Yes | No | Yes (MP4/MOV ≤30s) |
| Amazon Titan | No | No | No | No |
| Meta Llama 3.2 | Limited (InvokeModel only) | No | No | No |
| Mistral Large | No | No | No | No |
The safe default for production multimodal MCP tools is Claude 3.5 Sonnet or Claude Sonnet 4.x — full image support, all document formats, and the strongest multimodal reasoning. Use Claude 3.5 Haiku for high-volume image tasks where latency and cost matter more than reasoning depth. Use Amazon Nova Lite for very high-volume, cost-sensitive image classification tasks (Nova's megapixel pricing is significantly cheaper than Claude's token-based pricing for small images).
Sending images via inline bytes and S3 source
Inline bytes is the simplest approach: read the image into memory, wrap it in an ImageBlock, and include it in the user message content array. The SDK automatically base64-encodes the Uint8Array.
import { readFileSync } from 'fs';
import { BedrockRuntimeClient, ConverseCommand, type ContentBlock } from '@aws-sdk/client-bedrock-runtime';
type ImageFormat = 'jpeg' | 'png' | 'gif' | 'webp';
async function analyzeImage(imagePath: string, format: ImageFormat, prompt: string): Promise {
const imageBytes = readFileSync(imagePath);
if (imageBytes.length > 5 * 1024 * 1024) throw new Error('Image exceeds 5MB limit');
const client = new BedrockRuntimeClient({ region: 'us-east-1' });
const response = await client.send(new ConverseCommand({
modelId: 'us.anthropic.claude-3-5-sonnet-20241022-v2:0',
messages: [{
role: 'user',
content: [
{ image: { format, source: { bytes: imageBytes } } } as ContentBlock,
{ text: prompt },
],
}],
inferenceConfig: { maxTokens: 2048 },
}));
return response.output?.message?.content
?.filter(b => 'text' in b)
.map(b => (b as { text: string }).text)
.join('') ?? '';
}
// S3 source: Bedrock fetches the image — caller role needs s3:GetObject
async function analyzeImageFromS3(s3Uri: string, format: ImageFormat, prompt: string): Promise {
const client = new BedrockRuntimeClient({ region: 'us-east-1' });
const response = await client.send(new ConverseCommand({
modelId: 'us.anthropic.claude-3-5-sonnet-20241022-v2:0',
messages: [{
role: 'user',
content: [
{ image: { format, source: { s3Location: { uri: s3Uri, bucketOwner: process.env.AWS_ACCOUNT_ID } } } } as ContentBlock,
{ text: prompt },
],
}],
inferenceConfig: { maxTokens: 1024 },
}));
return response.output?.message?.content
?.filter(b => 'text' in b)
.map(b => (b as { text: string }).text)
.join('') ?? '';
}
Document processing: PDF, DOCX, XLSX, CSV
DocumentBlock handles non-image documents. Bedrock extracts text and structure from these formats and presents them to the model as semantic content — not as pixels — so the model can reason about table structure in Excel files, paragraph flow in Word documents, and section hierarchy in PDFs without needing vision capabilities.
type DocumentFormat = 'pdf' | 'docx' | 'xlsx' | 'csv' | 'txt' | 'md' | 'html';
async function analyzeDocument(docPath: string, format: DocumentFormat, question: string): Promise {
const docBytes = readFileSync(docPath);
if (docBytes.length > 4.5 * 1024 * 1024) throw new Error('Document exceeds 4.5MB limit');
const client = new BedrockRuntimeClient({ region: 'us-east-1' });
const response = await client.send(new ConverseCommand({
modelId: 'us.anthropic.claude-3-5-sonnet-20241022-v2:0', // Claude 3.5+ for DOCX/XLSX/CSV
messages: [{
role: 'user',
content: [
{
document: {
format,
name: docPath.split('/').pop()!, // filename for model context
source: { bytes: docBytes },
},
} as ContentBlock,
{ text: question },
],
}],
inferenceConfig: { maxTokens: 4096 },
}));
return response.output?.message?.content
?.filter(b => 'text' in b)
.map(b => (b as { text: string }).text)
.join('') ?? '';
}
Image token pricing and the multi-turn cost trap
Image tokens are priced as input tokens. The approximation for Claude is (width × height) / 750 after automatic downsampling (Claude resizes images whose longest edge exceeds 1,568 pixels). At Claude 3.5 Sonnet pricing ($3/M input tokens):
| Image dimensions | After Claude resize | Approximate tokens | Approximate cost/image |
|---|---|---|---|
| 640×480 | No resize needed | ~410 tokens | $0.0012 |
| 1024×1024 | No resize needed | ~1,398 tokens | $0.0042 |
| 1920×1080 | 1,568×882 (long edge capped) | ~1,847 tokens | $0.0055 |
| 100×100 (small icon) | No resize needed | ~14 tokens | $0.000042 |
The multi-turn cost trap: when an image appears in turn 1 of a multi-turn conversation, it remains in the context window for all subsequent turns. The model re-processes the image tokens on every call. A 10-turn conversation about a single 1024×1024 screenshot pays approximately 1,398 image tokens × 10 turns = 13,980 image tokens, compared to 1,398 tokens for a single-turn query. The correct mitigation: in turn 1, generate a detailed text description of the image and discard the image from subsequent turns.
// Anti-pattern: image re-billed on every turn
const messages: Message[] = [
{ role: 'user', content: [imageBlock, { text: 'Describe this dashboard.' }] },
{ role: 'assistant', content: [{ text: assistantResponse1 }] },
// image tokens charged again here ↓
{ role: 'user', content: [{ text: 'What caused the spike at 3pm?' }] },
];
// Correct: describe image in turn 1, drop it from subsequent turns
const description = await analyzeImage(imagePath, 'png', 'Describe every element of this monitoring dashboard in detail.');
const followUpMessages: Message[] = [
{ role: 'user', content: [{ text: `Dashboard description: ${description}\n\nWhat caused the spike at 3pm?` }] },
// no image in the message history — no image token re-billing
];
Common errors and solutions
| Error | Cause | Fix |
|---|---|---|
ValidationException: 'image' block is not supported for this model |
Model is Titan, Mistral, or Llama — does not support vision via Converse API | Use Claude 3.x/4.x or Amazon Nova Lite/Pro for multimodal requests |
ValidationException: image size exceeds 5 MB limit |
Image file is over 5MB | Resize with sharp in Node.js: sharp(buf).resize(1568, undefined, { fit: 'inside' }).jpeg({ quality: 80 }).toBuffer() |
AccessDeniedException with S3 source images |
Caller IAM role lacks s3:GetObject on the image bucket — Bedrock uses caller credentials, not a service role |
Add s3:GetObject to the Lambda execution role or ECS task role; verify bucket policy does not explicitly deny the caller |
ValidationException: document format 'xlsx' is not supported for model |
DOCX/XLSX/CSV DocumentBlocks require Claude 3.5 Haiku or newer — Claude 3.0 only handles PDF | Use Claude 3.5 Haiku, Claude 3.5 Sonnet, or Claude Sonnet 4.x for Office document processing |
| Model hallucinates content not visible in the image | Image resolution too low or heavy JPEG compression — model cannot reliably read small text | Send PNG instead of JPEG for screenshots with text; ensure minimum ~800px width for text-heavy dashboards |
Production checklist: Bedrock advanced patterns for MCP servers
| Check | What to verify |
|---|---|
| Prompt caching activated | cacheReadInputTokenCount > 0 on calls 2+ within the 5-minute TTL window; system prompt built once at startup and reused by reference; no dynamic values in the cached section; token count before cachePoint ≥ 2,048 (Sonnet) or 1,024 (Haiku) |
| Embedding model IAM permissions | bedrock:InvokeModel granted on the embedding model ARN (arn:aws:bedrock:REGION::foundation-model/amazon.titan-embed-text-v2:0) separately from text-generation model ARNs; AccessDeniedException is the symptom when this is missing |
| Cohere asymmetric search configured correctly | Corpus documents indexed with input_type: 'search_document'; user queries embedded with input_type: 'search_query'; never the same input_type for both; retrieval quality validated against a test query set |
| Batch inference IAM role has bedrock.amazonaws.com trust | Role trust policy names bedrock.amazonaws.com as Principal with SourceAccount condition; role has bedrock:InvokeModel + S3 read on input bucket + S3 write on output bucket; role ARN is stored as an environment variable on the submitter Lambda/ECS task |
| Batch job error records handled separately from job status | Output JSONL processing counts records with error field; job status Completed does not mean zero failures; CloudWatch Alarm on EventBridge Failed job state for full-job failures |
| Multimodal model selection validated | Claude 3.x/4.x or Nova Lite/Pro for images; Claude 3.5+ for DOCX/XLSX/CSV; no Titan/Mistral/Llama for multimodal; runtime check on model ID before sending multimodal content |
| Multi-turn image context cost managed | Images in early turns are extracted to text descriptions before later turns if the conversation exceeds 3 turns; image token cost per turn estimated and logged; S3 source used instead of inline bytes for images >1MB to reduce Lambda memory pressure |
| AliveMCP monitoring on AI-native endpoints | AliveMCP probe detects Bedrock ThrottlingException returned as 5xx from the MCP tool handler; prompt cache miss rate spike (via cacheReadInputTokenCount dropping to 0) indicates a system prompt change that should be validated before deployment; batch job completion latency tracked via EventBridge notification-to-result-available gap |