Guide · AWS Kinesis
AWS Kinesis Data Streams for MCP Servers
Amazon Kinesis Data Streams lets MCP server tools publish and consume event streams at sub-second latency — useful for real-time pipeline tools, audit event buses, and fan-out patterns where a single MCP tool call needs to notify multiple downstream consumers. Three things teams consistently get wrong: PutRecords silently partial-fails (the call returns HTTP 200 even when some records fail — you must check FailedRecordCount in the response and retry failed records individually), shard hot-spotting kills throughput (using a constant or low-cardinality partition key forces all records to the same shard, capping you at 1 MB/s write regardless of how many shards you provision), and GetRecords iterators expire after 5 minutes (a long-running MCP tool that calls GetShardIterator and then does slow processing will find its iterator expired before it can read — enhanced fan-out avoids this problem entirely).
TL;DR
Use PutRecords in batches of up to 500 records with a UUID or high-cardinality partition key. Read with Lambda ESM for simple fan-in patterns; use RegisterStreamConsumer + SubscribeToShard (enhanced fan-out) when you need multiple independent consumers without iterator throughput sharing. Provision shards at roughly 1 shard per 700 KB/s sustained write throughput to leave headroom for retries. Monitor WriteProvisionedThroughputExceeded and ReadProvisionedThroughputExceeded CloudWatch metrics to catch throttling before it affects MCP tool latency.
PutRecord vs PutRecords — when each matters
Kinesis exposes two write APIs. PutRecord publishes a single record synchronously and returns the shard ID and sequence number — straightforward but wasteful for MCP tools that emit events in tight loops. PutRecords accepts up to 500 records or 5 MB in one API call, significantly reducing per-record API overhead and improving throughput. The critical difference: PutRecords is partially atomic. The HTTP response is always 200 — you must check response.FailedRecordCount and then re-examine response.Records[i].ErrorCode for each record to identify which ones failed with ProvisionedThroughputExceededException or InternalFailure.
import {
KinesisClient,
PutRecordsCommand,
type PutRecordsRequestEntry,
} from "@aws-sdk/client-kinesis";
import { randomUUID } from "node:crypto";
const kinesis = new KinesisClient({ region: process.env.AWS_REGION ?? "us-east-1" });
async function publishToolCallEvents(
streamName: string,
events: Array<{ toolName: string; input: unknown; sessionId: string }>
): Promise<void> {
const records: PutRecordsRequestEntry[] = events.map((event) => ({
Data: Buffer.from(JSON.stringify(event)),
// High-cardinality partition key: sessionId distributes load across shards
// Do NOT use toolName — 5 tool names across 10 shards = hot-spotting
PartitionKey: event.sessionId,
}));
let remaining = records;
let attempts = 0;
while (remaining.length > 0 && attempts < 3) {
attempts++;
const response = await kinesis.send(
new PutRecordsCommand({ StreamName: streamName, Records: remaining })
);
if ((response.FailedRecordCount ?? 0) === 0) break;
// Collect only the records that failed — preserve original order mapping
const failed: PutRecordsRequestEntry[] = [];
for (let i = 0; i < (response.Records?.length ?? 0); i++) {
if (response.Records![i].ErrorCode) {
failed.push(remaining[i]);
}
}
remaining = failed;
if (remaining.length > 0) {
await new Promise((r) => setTimeout(r, 100 * Math.pow(2, attempts)));
}
}
if (remaining.length > 0) {
console.error(`${remaining.length} records failed after ${attempts} attempts`);
}
}
Shard throughput limits and partition key selection
Each Kinesis shard supports 1 MB/s or 1,000 records/s for writes and 2 MB/s for reads. These limits apply per shard, not per stream — and the assignment of records to shards is determined by the MD5 hash of the partition key. A partition key that hashes to the same shard range overloads that shard while others remain idle, a condition called hot-spotting.
For MCP server tools, the best partition keys are high-cardinality identifiers already present on the event: session ID, request ID (UUID), or tenant ID in multi-tenant deployments. Avoid using MCP tool names (low cardinality — your tool registry typically has 5–30 tools), timestamp rounded to the second (bursts all land on the same key), or a constant string (all records to one shard).
| Partition key candidate | Cardinality | Distribution | Verdict |
|---|---|---|---|
randomUUID() |
∞ | Perfect uniform | Best — use when you don't need ordered reads |
sessionId |
High | Good | Best — all events from one session land on one shard (ordered per session) |
tenantId |
Medium | Good if tenants are balanced | OK — use with explicit shard mapping for premium tenants |
toolName |
Low (5–30) | Skewed | Bad — popular tools become hot shards |
"audit" (constant) |
1 | All to one shard | Never — caps entire stream at 1 MB/s |
To detect hot-spotting, enable per-shard CloudWatch metrics (GetShardLevelMetrics in enhanced monitoring) and watch IncomingRecords per shard. A healthy stream shows roughly even distribution. A hot shard typically shows one shard consuming >80% of total IncomingRecords.
# Enable enhanced per-shard monitoring for throughput and error visibility
aws kinesis enable-enhanced-monitoring \
--stream-name mcp-tool-events \
--shard-level-metrics \
IncomingBytes \
IncomingRecords \
WriteProvisionedThroughputExceeded \
ReadProvisionedThroughputExceeded \
IteratorAgeMilliseconds
# Check shard count and iterator age (rising iterator age = consumer falling behind)
aws cloudwatch get-metric-statistics \
--namespace AWS/Kinesis \
--metric-name GetRecords.IteratorAgeMilliseconds \
--dimensions Name=StreamName,Value=mcp-tool-events \
--start-time 2026-09-20T00:00:00Z \
--end-time 2026-09-20T01:00:00Z \
--period 300 \
--statistics Maximum \
--query 'Datapoints[*].{Time:Timestamp,Age:Maximum}' \
--output table
GetShardIterator types — which one to use
When building a standard (polling) consumer, you first call GetShardIterator to get a cursor into the shard, then call GetRecords in a loop. The iterator type controls where in the shard the cursor starts.
| Iterator type | Starts at | Requires extra parameter | Best for |
|---|---|---|---|
LATEST |
Records arriving after the call | None | Live tailing — MCP monitoring dashboards |
TRIM_HORIZON |
Oldest retained record (up to 7 days) | None | Replay / backfill — reprocessing all events after a schema change |
AT_SEQUENCE_NUMBER |
The exact record with that sequence number | StartingSequenceNumber |
Resuming from a saved checkpoint |
AFTER_SEQUENCE_NUMBER |
The record immediately after that sequence number | StartingSequenceNumber |
Resuming after the last successfully processed record |
AT_TIMESTAMP |
First record at or after the given timestamp | Timestamp |
Incident replay — "reprocess everything since 14:00 UTC" |
The 5-minute iterator expiry trap: GetShardIterator returns a cursor that expires in exactly 5 minutes if unused. If your MCP tool calls GetShardIterator and then does slow work before calling GetRecords — connecting to a database, making external API calls, waiting for a mutex — the iterator expires and the next GetRecords call returns ExpiredIteratorException. The fix: never cache iterators across tool invocations. Call GetShardIterator immediately before the GetRecords loop, and refresh it using the NextShardIterator returned by each GetRecords call rather than requesting a new one.
import {
KinesisClient,
GetShardIteratorCommand,
GetRecordsCommand,
ShardIteratorType,
} from "@aws-sdk/client-kinesis";
const kinesis = new KinesisClient({ region: "us-east-1" });
async function readFromShard(
streamName: string,
shardId: string,
maxRecords = 100
): Promise<unknown[]> {
// Call GetShardIterator immediately before the read loop — never cache this
const iteratorResponse = await kinesis.send(
new GetShardIteratorCommand({
StreamName: streamName,
ShardId: shardId,
ShardIteratorType: ShardIteratorType.LATEST,
})
);
let iterator = iteratorResponse.ShardIterator!;
const collected: unknown[] = [];
while (collected.length < maxRecords) {
const records = await kinesis.send(
new GetRecordsCommand({ ShardIterator: iterator, Limit: 100 })
);
for (const record of records.Records ?? []) {
collected.push(JSON.parse(Buffer.from(record.Data!).toString("utf-8")));
}
// Always use NextShardIterator from the response — never call GetShardIterator again
if (!records.NextShardIterator) break; // shard closed (resharding)
iterator = records.NextShardIterator;
// Standard polling delay: GetRecords has a 5 TPS limit per shard per consumer
if ((records.Records?.length ?? 0) === 0) {
await new Promise((r) => setTimeout(r, 1000));
}
}
return collected;
}
Standard polling vs enhanced fan-out consumers
Standard GetRecords polling is throttled at 5 transactions per second per shard and the 2 MB/s read throughput is shared across all standard consumers reading the same shard. If three Lambda functions all poll the same shard, they collectively get 2 MB/s — roughly 667 KB/s each — and compete for the 5 TPS limit.
Enhanced fan-out consumers register with RegisterStreamConsumer and receive records via SubscribeToShard, a long-polling HTTP/2 connection. Each enhanced fan-out consumer gets a dedicated 2 MB/s per shard pipe, completely independent of other consumers. You can have up to 20 enhanced fan-out consumers per stream (compared to effectively 3–5 practical concurrent standard consumers before throughput degrades). The trade-off: enhanced fan-out costs $0.015 per consumer-shard-hour on top of the shard-hour cost, and the SubscribeToShard call times out after 5 minutes — your consumer must re-subscribe in a loop.
# Register an enhanced fan-out consumer (one-time setup per consumer application)
aws kinesis register-stream-consumer \
--stream-arn arn:aws:kinesis:us-east-1:123456789012:stream/mcp-tool-events \
--consumer-name mcp-audit-archiver
# Verify registration (status transitions: CREATING → ACTIVE within ~10s)
aws kinesis describe-stream-consumer \
--stream-arn arn:aws:kinesis:us-east-1:123456789012:stream/mcp-tool-events \
--consumer-name mcp-audit-archiver \
--query 'ConsumerDescription.{Status:ConsumerStatus,ARN:ConsumerARN}'
| Standard GetRecords polling | Enhanced fan-out | |
|---|---|---|
| Read throughput | 2 MB/s shared across all consumers | 2 MB/s dedicated per consumer |
| Latency | ~200ms avg (polling interval) | ~70ms avg (push-based) |
| API TPS limit | 5 GetRecords calls/s per shard | No GetRecords calls — push delivery |
| Max consumers | ~3–5 practical concurrent | 20 registered consumers per stream |
| Additional cost | None beyond shard-hours | $0.015/consumer-shard-hour |
| Best for | Single consumer, Lambda ESM, low fan-out | Multiple independent consumers, low-latency processing |
Shard-count planning for MCP tool call volumes
Kinesis Data Streams shards are elastic within limits: you can scale up with UpdateShardCount (doubles shards, causes a brief pause), or enable on-demand mode to let AWS handle scaling automatically. For MCP server tools, the write-side throughput calculation is straightforward:
# Sizing formula:
# peak_records_per_sec × avg_record_bytes / 1_000_000 = MB/s needed
# shards_needed = ceil(MB/s_needed / 0.85) ← 0.85 = 85% utilization target (15% headroom for retry bursts)
# Example: 500 MCP tool calls/sec × 2 KB average event = 1 MB/s → ceil(1/0.85) = 2 shards
# Check current shard count and stream summary
aws kinesis describe-stream-summary \
--stream-name mcp-tool-events \
--query 'StreamDescriptionSummary.{Shards:OpenShardCount,RetentionHours:RetentionPeriodHours,StreamStatus:StreamStatus}'
# Switch to on-demand mode (auto-scaling up to 200 shards, billed per GB)
aws kinesis update-stream-mode \
--stream-arn arn:aws:kinesis:us-east-1:123456789012:stream/mcp-tool-events \
--stream-mode-details StreamMode=ON_DEMAND
# Scale up provisioned stream manually (API halves or doubles shard count only)
aws kinesis update-shard-count \
--stream-name mcp-tool-events \
--target-shard-count 4 \
--scaling-type UNIFORM_SCALING
On-demand mode is appropriate for MCP servers with spiky or unpredictable tool call volumes — it automatically scales between 4 and 200 shards and you pay per GB ingested rather than per shard-hour. Provisioned mode is cheaper for steady-state workloads above ~10 shards. The break-even is roughly at $0.015/shard-hour × 730 hours/month ≈ $11/shard/month vs on-demand's $0.08/GB — if your stream ingests more than 137 GB/month per logical shard, provisioned mode costs less.
Kinesis Data Streams retention and data availability
Default retention is 24 hours. Extended retention costs $0.023/shard-hour of data retained and can be extended to 7 days via IncreaseStreamRetentionPeriod, or up to 365 days with long-term retention (additional cost tier). For MCP server audit event streams, 7-day retention is the practical minimum — it gives you a replay window for incident investigation and allows consumers to catch up after extended downtime without losing data.
# Extend retention from 24h to 7 days
aws kinesis increase-stream-retention-period \
--stream-name mcp-tool-events \
--retention-period-in-hours 168
# Long-term retention (up to 8760h / 365 days — higher cost tier)
aws kinesis increase-stream-retention-period \
--stream-name mcp-tool-events \
--retention-period-in-hours 720 # 30 days
Common failure modes reference
| Error / symptom | Root cause | Fix |
|---|---|---|
ProvisionedThroughputExceededException on PutRecords |
Hot shard or under-provisioned stream | Increase partition key cardinality; add shards or switch to on-demand |
| PutRecords returns 200 but records missing downstream | FailedRecordCount > 0 not checked |
Always check FailedRecordCount and retry failed record indexes |
ExpiredIteratorException |
Iterator unused for >5 minutes | Call GetShardIterator immediately before read loop; use enhanced fan-out |
GetRecords returns empty Records repeatedly |
No new data or shard split — old shard is sealed | Check MillisBehindLatest; follow child shards after split via DescribeStream |
| Consumer falls behind — iterator age rises | Consumer too slow or too few shards | Scale consumer parallelism; add shards; enable enhanced fan-out |
| Data lost after resharding | Old (parent) shards drained before reading child shards | Always drain parent shards before starting child shard reads after split/merge |