Guide · AWS Bedrock
Bedrock Prompt Caching in MCP Servers — cachePoint, token thresholds, cost reduction
AWS Bedrock prompt caching lets MCP server tool handlers reuse the KV-cache from a previous inference request, reducing input token costs by up to 90% on the cached portion and cutting first-token latency by 30–85% on cache hits. The mechanism is a cachePoint content block inserted into the message array — it tells Bedrock "everything before this marker is stable; checkpoint it so the next request can skip re-processing." For MCP servers that call Bedrock on every tool invocation with a large system prompt, tool schema definitions, or long few-shot examples, prompt caching pays for itself within a few hundred requests. This guide covers where to place the cachePoint block, the minimum token thresholds per model family, how to read cache hit metrics from the usage object, and the most common placement mistakes that silently prevent caching from activating.
TL;DR
Add a { cachePoint: { type: 'default' } } block at the end of stable content in your system array or as the last element in a user message's content array. Minimum eligible tokens before the checkpoint: 1024 for Claude 3 Haiku, 2048 for Claude 3 Sonnet/Sonnet 3.5, 1024 for Claude 3.5 Haiku. Cache TTL is 5 minutes, refreshed on each read hit. Check response.usage.cacheReadInputTokenCount to verify hits. Cache writes cost 125% of the base input token price; cache reads cost 10% — break-even at roughly an 8% cache hit rate per token.
How prompt caching works in Bedrock
When Bedrock processes an inference request it must run the transformer's attention mechanism over every input token. For a 4000-token system prompt followed by a 50-token user query, that's 4050 tokens of full-context computation — even though the system prompt is identical to the previous request. Prompt caching stores the intermediate KV-cache state computed for the tokens before the cachePoint marker on a dedicated cache tier. A subsequent request that presents the same token prefix up to the same marker retrieves the cached state instead of recomputing it.
The cachePoint block is available in the Bedrock Converse API for Claude model families. It is not supported for Titan, Llama, Mistral, or Command R+ via Bedrock. The block is a typed union member in the content array — the SDK type for it is ContentBlockMemberCachePoint. A cachePoint does not produce any output text; it is a control directive consumed by the Bedrock routing layer.
There are two positions where a cachePoint is valid: inside the system array (after the last system text block you want to cache), or as the last element in the content array of a user message. It is not valid in assistant messages or in the middle of a content array followed by additional text blocks.
import {
BedrockRuntimeClient,
ConverseCommand,
type ConverseCommandInput,
type SystemContentBlock,
type Message,
} from '@aws-sdk/client-bedrock-runtime';
const client = new BedrockRuntimeClient({ region: 'us-east-1' });
// System array with cachePoint after the stable system prompt
const system: SystemContentBlock[] = [
{
text: `You are an expert MCP server diagnostics assistant.
You help developers debug protocol errors, health check failures,
and authentication issues across all MCP transport types (SSE, stdio, HTTP).
When diagnosing issues, always:
1. Identify the specific MCP error code if mentioned (e.g. -32603, -32601)
2. Check for common SSE transport problems: connection drops, buffering, timeout
3. Suggest AliveMCP health check endpoint verification as first step
4. Provide actionable remediation steps in order of likelihood
Reference documentation:
${TOOL_DOCUMENTATION} // ~3000 tokens of stable reference text
`,
},
// cachePoint after system text — everything above is checkpointed
{ cachePoint: { type: 'default' } } as unknown as SystemContentBlock,
];
// Per-turn user message — only the query is variable
const messages: Message[] = [
{
role: 'user',
content: [{ text: userQuery }], // just the user's question — NOT cached
},
];
const input: ConverseCommandInput = {
modelId: 'us.anthropic.claude-3-5-sonnet-20241022-v2:0',
system,
messages,
inferenceConfig: { maxTokens: 2048 },
};
const response = await client.send(new ConverseCommand(input));
// Inspect cache efficiency
const usage = response.usage;
console.log({
inputTokens: usage?.inputTokens,
cacheWriteTokenCount: usage?.cacheWriteInputTokenCount, // tokens written to cache (first request)
cacheReadTokenCount: usage?.cacheReadInputTokenCount, // tokens read from cache (subsequent requests)
outputTokens: usage?.outputTokens,
});
On the first request, cacheWriteInputTokenCount will equal the token count of everything before the cachePoint. On subsequent requests within the 5-minute TTL window, cacheReadInputTokenCount equals that same count — and the cost for those tokens drops to 10% of the base input price. If cacheReadInputTokenCount stays zero after the first request, the cache is not being hit — see the troubleshooting section below.
Token thresholds per model
Bedrock enforces minimum token counts before a cachePoint can activate. Requests where the token count before the cachePoint is below the minimum are processed normally without caching — no error is raised, caching simply does not activate, and cacheWriteInputTokenCount will be 0.
| Model | Model ID prefix | Min tokens for cachePoint | Max cachePoints per request |
|---|---|---|---|
| Claude 3 Haiku | anthropic.claude-3-haiku-* | 1024 | 4 |
| Claude 3 Sonnet | anthropic.claude-3-sonnet-* | 2048 | 4 |
| Claude 3.5 Haiku | anthropic.claude-3-5-haiku-* | 1024 | 4 |
| Claude 3.5 Sonnet | anthropic.claude-3-5-sonnet-* | 2048 | 4 |
| Claude Sonnet 4.x | anthropic.claude-sonnet-4-* | 2048 | 4 |
| Claude Opus 4.x | anthropic.claude-opus-4-* | 2048 | 4 |
For an MCP server using a 500-token system prompt and a 300-token tool schema block — 800 tokens total — caching will not activate on Claude 3.5 Sonnet (threshold: 2048). To reach the threshold you can either add more substantive content to the system prompt (documentation, examples, reference tables) or consolidate tool schemas into a larger single description block. Do not add filler text just to reach the threshold — it reduces model quality.
When content naturally falls below the minimum, omit the cachePoint entirely rather than leaving it in with no effect. A cachePoint that never activates adds a small serialization overhead with no benefit.
Caching tool schemas alongside the system prompt
MCP servers that act as Bedrock sub-agents pass tool schemas via toolConfig. Tool schemas are stable — they don't change between user requests — but they are passed as part of the toolConfig field, not as content blocks, so they cannot be directly targeted by a cachePoint in the standard Converse API flow.
The practical workaround is to embed a stringified summary of your tool schemas inside the system prompt text block, then place the cachePoint after that block. This way both the system instructions and the schema documentation are cached together.
// Build a stable system prompt that includes tool reference docs
function buildSystemPrompt(toolDefs: ToolSpec[]): string {
const toolDocs = toolDefs.map(t =>
`Tool: ${t.name}\nDescription: ${t.description}\n` +
`Input schema: ${JSON.stringify(t.inputSchema.json, null, 2)}`
).join('\n\n---\n\n');
return `${STATIC_SYSTEM_INSTRUCTIONS}\n\n## Available Tools\n\n${toolDocs}`;
}
// Build system array with cachePoint — generated once per server startup
const CACHED_SYSTEM: SystemContentBlock[] = [
{ text: buildSystemPrompt(MY_TOOL_SPECS) },
{ cachePoint: { type: 'default' } } as unknown as SystemContentBlock,
];
// Reuse CACHED_SYSTEM across all tool handler invocations
// The token content is identical across requests → cache hits
async function handleToolCall(userQuery: string) {
return client.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 },
}));
}
The critical requirement for a cache hit is that the bytes before the cachePoint are byte-for-byte identical across requests. Even a single character difference — including whitespace, ordering, or version strings injected into the system prompt — produces a cache miss and a new cache write. For MCP servers: build the system prompt string once at startup and reuse the same object reference; do not re-interpolate per request.
Cache TTL and refresh behavior
Bedrock prompt cache entries expire 5 minutes after their last read. Each cache hit resets the 5-minute clock. For an MCP server receiving steady traffic (more than one call per 5 minutes to a given tool handler), the cache entry stays warm indefinitely at cache-read prices. For infrequently called tools, the first call in each 5-minute window pays the cache-write price; all subsequent calls within the window pay cache-read prices.
There is no API to extend TTL, manually evict entries, or query cache state. The 5-minute TTL is fixed. This has implications for:
- Low-traffic MCP servers: If your tool is called once every 10 minutes, every call will pay cache-write cost (125%). Caching provides no benefit unless call frequency exceeds one call per 5 minutes per cachePoint.
- Deployments across multiple availability zones: Cache entries are per-availability-zone. Requests routed to different AZs will each pay cache-write cost on their first request. High-traffic deployments should see AZ-level cache hits within seconds of the first request to each AZ.
- System prompt updates during deployment: Any change to the system prompt text invalidates the cache for all subsequent requests. Zero-downtime deployments with rolling restarts will see a brief period of increased cache-write costs as new instances build their cache entries.
Cost model and break-even analysis
Prompt caching pricing uses three tiers relative to the model's standard input token price:
| Operation | Price multiplier | When it applies |
|---|---|---|
| Cache write | 125% of base input price | First request that activates a new cache entry |
| Cache read | 10% of base input price | Each request that hits an existing cache entry |
| Standard input (no cache) | 100% of base input price | Tokens below cachePoint threshold, or no cachePoint |
| Output tokens | Standard output price | Unaffected by caching |
Break-even calculation: if you send N requests per 5-minute TTL window, all sharing the same cache entry, the cost with caching is 1.25 × P_cached + (N-1) × 0.10 × P_cached + N × P_uncached where P_cached is the cost of the cached tokens and P_uncached is the cost of the non-cached tokens (user query, etc.). Without caching it is N × (P_cached + P_uncached). Break-even occurs at approximately N=1.4, meaning caching starts saving money after just 2 requests per 5-minute window that share the same cached prefix.
For an MCP server with a 3000-token system prompt at Claude 3.5 Sonnet pricing ($3/M input tokens): 3000 tokens × $0.000003/token = $0.009 per request without caching. With caching, first request = $0.01125 (125%), each subsequent = $0.0009 (10%). At 100 requests per 5-minute window: without caching = $0.90/window; with caching = $0.01125 + 99×$0.0009 = $0.10/window — an 89% reduction in system-prompt token costs.
Common placement mistakes and troubleshooting
| 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, or check that the correct model ID is used (thresholds differ per model family) |
cacheReadInputTokenCount stays 0 after first request |
System prompt content differs between requests (e.g., a timestamp, user ID, or dynamic value embedded in the system prompt) | Isolate all dynamic content to the user message; keep everything before the cachePoint byte-for-byte identical |
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. Use a Claude model or remove the cachePoint block |
| cachePoint in the middle of a content array after more text blocks | cachePoint must be the last element of a content array; additional blocks after it cause a ValidationException | Move the cachePoint to the end of the system array or user message content array |
| Cache hits in development, misses in production | Production system prompt includes environment-specific values (endpoint URLs, region, etc.) that differ from development | Externalize all environment-specific values to a separate non-cached section or pass them via the user message |
Multi-turn conversations and cachePoint placement
For multi-turn agentic loops (where the MCP server calls Bedrock iteratively with accumulating message history), the cachePoint strategy changes. The system-level cachePoint stays constant across all turns. For the message history itself, you can add an additional cachePoint after the last assistant message to cache the accumulated conversation up to that point.
// Multi-turn loop with progressive caching
async function runAgentLoop(userQuery: string): Promise {
const messages: Message[] = [];
// Turn 1 — user query
messages.push({ role: 'user', content: [{ text: userQuery }] });
for (let turn = 0; turn < 10; turn++) {
const response = await client.send(new ConverseCommand({
modelId: 'us.anthropic.claude-3-5-sonnet-20241022-v2:0',
system: CACHED_SYSTEM, // stable system prompt with cachePoint
messages,
inferenceConfig: { maxTokens: 4096 },
}));
const outputMessage = response.output?.message!;
messages.push(outputMessage);
if (response.stopReason === 'end_turn') {
return outputMessage.content
?.filter(b => 'text' in b)
.map(b => (b as { text: string }).text)
.join('') ?? '';
}
// Add cachePoint after the last assistant message to cache accumulated history
// This is optional — only worth it if conversation history is > 2048 tokens
if (turn > 0) {
const lastAssistant = messages[messages.length - 1];
if (lastAssistant.role === 'assistant') {
// Add cachePoint as last block in next user message
// to cache everything up to this point
messages.push({
role: 'user',
content: [
{ text: await runToolAndGetResult(response) },
{ cachePoint: { type: 'default' } } as never,
],
});
continue;
}
}
// Standard tool result injection without cachePoint
messages.push({
role: 'user',
content: [{ text: await runToolAndGetResult(response) }],
});
}
throw new Error('Agent loop exceeded max turns');
}
Use the message-level cachePoint conservatively: it only pays off when the accumulated history exceeds the minimum token threshold and when you expect multiple additional turns. For short conversations (2–3 turns), stick to the system-level cachePoint only.