Guide · AWS ElastiCache
MCP Server ElastiCache Session State — distributed locks, atomic operations, connect-redis
ElastiCache Redis session state patterns for MCP servers differ from general web session storage in two important ways. First, MCP tool calls can fire concurrently — when an agent issues multiple tool calls in parallel, two invocations of the same tool may attempt to write the same session key simultaneously; a distributed lock using SET lock:call:id NX EX 30 ensures only one execution proceeds. Second, read-modify-write operations need atomic protection — reading a session value, modifying it, and writing it back is not atomic; between the read and the write, another Lambda invocation can overwrite the key; use WATCH + MULTI + EXEC for optimistic locking, or a Lua script for a check-and-set that must succeed atomically. A third common mistake: using KEYS session:* in production to enumerate sessions — KEYS is O(N) and blocks Redis for tens of milliseconds on a large keyspace; always use SCAN with a cursor.
TL;DR
Use SET key value NX EX seconds for distributed locks — NX makes it conditional on the key not existing (atomic), EX sets the TTL as a safeguard against lock leaks. Use WATCH + MULTI + EXEC for optimistic concurrency on read-modify-write operations. Never use KEYS in production; use SCAN with cursor iteration. Always store session data as JSON strings with JSON.stringify / JSON.parse.
Session storage with connect-redis
For MCP servers built on Express with SSE (Server-Sent Events), express-session backed by connect-redis and ioredis provides a production-ready session store. The session data is stored as a JSON-serialized object under a key like sess:{sessionId}.
import express from "express";
import session from "express-session";
import { createClient } from "redis"; // redis v4 client
import RedisStore from "connect-redis";
import Redis from "ioredis";
// connect-redis v7+ supports ioredis via the ioredis adapter
const ioredisClient = new Redis({
host: process.env.ELASTICACHE_PRIMARY_ENDPOINT,
port: 6379,
password: process.env.REDIS_AUTH_TOKEN,
tls: {},
lazyConnect: false,
});
const app = express();
app.use(
session({
store: new RedisStore({
client: ioredisClient,
prefix: "mcp:sess:", // Distinguishes session keys from other MCP keys
ttl: 3600, // Session TTL in seconds (1 hour)
disableTouch: false, // Touch (refresh TTL) on each request — keep sessions alive
}),
secret: process.env.SESSION_SECRET!,
resave: false,
saveUninitialized: false,
cookie: {
secure: true, // Require HTTPS
httpOnly: true,
sameSite: "strict",
maxAge: 3600 * 1000, // 1 hour in milliseconds
},
})
);
// MCP SSE endpoint — session is available via req.session
app.get("/mcp", (req, res) => {
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
// Session data persisted in ElastiCache
const mcpClientId = req.session.id;
console.log(`MCP client connected: ${mcpClientId}`);
req.on("close", () => {
console.log(`MCP client disconnected: ${mcpClientId}`);
});
});
Distributed locking for idempotent tool calls
When the same MCP tool call fires twice (agent retry on timeout, or concurrent agent branches), you need a lock to prevent duplicate execution. The SET key value NX EX seconds command is atomic — it sets the key only if it does not already exist, and sets a TTL in the same command. This is the correct primitive for distributed locking.
import Redis from "ioredis";
import crypto from "node:crypto";
const redis = new Redis({ /* ... */ });
/**
* Acquire a distributed lock. Returns the lock token if acquired, null if not.
* Use the callId from the MCP tool call as the lock key.
*/
async function acquireLock(
lockKey: string,
ttlSeconds: number
): Promise {
// Generate a unique token so only the lock holder can release it
const token = crypto.randomBytes(16).toString("hex");
// SET key token NX EX ttl — atomic: set only if not exists + set TTL
const result = await redis.set(lockKey, token, "NX", "EX", ttlSeconds);
return result === "OK" ? token : null;
}
/**
* Release the lock atomically using a Lua script.
* Only releases if the current token matches — prevents releasing another holder's lock.
*/
async function releaseLock(lockKey: string, token: string): Promise {
const script = `
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
`;
const result = await redis.eval(script, 1, lockKey, token);
return result === 1;
}
// In your MCP tool handler:
async function handleToolCall(callId: string, params: unknown) {
const lockKey = `lock:tool:${callId}`;
const lockToken = await acquireLock(lockKey, 30); // 30-second TTL
if (!lockToken) {
// Another invocation already acquired the lock — this is a duplicate
// Return a response indicating the call is already being processed
return { status: "duplicate", message: "Tool call already in progress" };
}
try {
// Execute the tool logic
const result = await executeToolLogic(params);
return result;
} finally {
// Always release the lock, even on error
await releaseLock(lockKey, lockToken);
}
}
The TTL on the lock key is a safety net: if the Lambda invocation crashes before releasing the lock, the TTL ensures the lock is released after ttlSeconds rather than remaining locked forever. Choose a TTL slightly longer than the maximum expected tool execution time.
Optimistic concurrency with WATCH/MULTI/EXEC
For read-modify-write operations on session data — such as appending to a tool call history or incrementing a counter — use WATCH to detect concurrent modification and retry if the data changed between your read and write.
/**
* Append a tool result to the session's tool call history.
* Uses WATCH/MULTI/EXEC for optimistic concurrency — retries if a concurrent
* write modified the history between our read and write.
*/
async function appendToolResult(
sessionId: string,
toolResult: unknown,
maxRetries = 3
): Promise {
const key = `{session:${sessionId}}:tool-history`;
for (let attempt = 0; attempt < maxRetries; attempt++) {
// WATCH: if key changes before EXEC, the transaction aborts
await redis.watch(key);
const raw = await redis.get(key);
const history: unknown[] = raw ? JSON.parse(raw) : [];
history.push(toolResult);
// MULTI/EXEC: execute the write as a transaction
// If the WATCHed key changed, EXEC returns null (transaction aborted)
const result = await redis
.multi()
.set(key, JSON.stringify(history), "EX", 3600)
.exec();
if (result !== null) {
// Transaction succeeded
return;
}
// Transaction aborted — concurrent write detected; retry
await new Promise((resolve) => setTimeout(resolve, 10 * (attempt + 1)));
}
throw new Error(`Failed to append tool result after ${maxRetries} attempts`);
}
WATCH/MULTI/EXEC implements optimistic locking: it succeeds when there is no contention and retries gracefully when there is. For high-contention keys (e.g., a shared counter updated by many concurrent tool calls), prefer a Lua script for atomic increment, or use Redis's built-in INCR / INCRBY commands which are atomic without a transaction.
Safe key iteration with SCAN
KEYS session:* is O(N) and blocks Redis's single-threaded event loop for the entire scan duration. On a keyspace of 100,000 session keys, this can block for 20–50 milliseconds, causing timeouts for all concurrent MCP tool calls during that window. Always use SCAN for key enumeration in production.
/**
* Iterate all active session keys using SCAN.
* Never use KEYS in production — it blocks Redis.
*/
async function* scanSessions(): AsyncGenerator {
let cursor = "0";
do {
const [nextCursor, keys] = await redis.scan(
cursor,
"MATCH", "mcp:sess:*",
"COUNT", 100 // Hint to Redis — actual count per call may vary
);
cursor = nextCursor;
for (const key of keys) {
yield key;
}
} while (cursor !== "0");
}
// Usage: count active sessions without blocking Redis
async function countActiveSessions(): Promise {
let count = 0;
for await (const _key of scanSessions()) {
count++;
}
return count;
}
// Usage: delete sessions for a specific user across all session keys
async function deleteUserSessions(userId: string): Promise {
const pipeline = redis.pipeline();
let pipelineSize = 0;
for await (const key of scanSessions()) {
const raw = await redis.get(key);
if (!raw) continue;
const sessionData = JSON.parse(raw);
if (sessionData?.userId === userId) {
pipeline.del(key);
pipelineSize++;
// Flush pipeline in batches to avoid large transactions
if (pipelineSize >= 100) {
await pipeline.exec();
pipelineSize = 0;
}
}
}
if (pipelineSize > 0) {
await pipeline.exec();
}
}
Session data serialization
ElastiCache Redis stores all values as strings (or binary data). Complex MCP session objects must be serialized to JSON for storage and deserialized on retrieval. Use a consistent serialization boundary at the session access layer:
type McpSessionData = {
userId: string;
toolCallHistory: Array<{ toolName: string; result: unknown; timestamp: number }>;
conversationCursor: string | null;
connectionInfo: { ip: string; userAgent: string };
};
const SESSION_TTL = 3600; // 1 hour
export async function saveSession(
sessionId: string,
data: McpSessionData
): Promise {
await redis.set(
`mcp:session:${sessionId}`,
JSON.stringify(data),
"EX",
SESSION_TTL
);
}
export async function loadSession(
sessionId: string
): Promise {
const raw = await redis.get(`mcp:session:${sessionId}`);
if (!raw) return null;
try {
return JSON.parse(raw) as McpSessionData;
} catch {
// Corrupted session data — delete and return null
await redis.del(`mcp:session:${sessionId}`);
return null;
}
}
// Refresh the TTL on access to keep active sessions alive (sliding expiry)
export async function touchSession(sessionId: string): Promise {
await redis.expire(`mcp:session:${sessionId}`, SESSION_TTL);
}
Common failure modes
| Symptom | Cause | Fix |
|---|---|---|
| Duplicate tool executions despite lock attempt | SET key value NX without EX — if lock holder crashes, the key persists forever and nobody can acquire the lock again; or using two separate commands (SETNX then EXPIRE) which are not atomic | Always use the atomic form: SET key token NX EX ttlSeconds in a single command |
| WATCH/MULTI/EXEC always aborts (EXEC returns null) | High contention — concurrent writes to the WATCHed key during every retry attempt | Switch to a Lua script for atomic check-and-set instead of WATCH; or use a distributed lock to serialize access to the contested key |
| Redis freezes briefly during session enumeration | KEYS session:* blocking the event loop on a large keyspace | Replace with SCAN-based cursor iteration; never use KEYS in production |
Session data appears as [object Object] string | Using .toString() or string concatenation instead of JSON.stringify() when storing session objects | Always use JSON.stringify(data) on write and JSON.parse(raw) on read; wrap in try/catch to handle corrupted values |
| Session expires immediately after creation | TTL set in milliseconds instead of seconds; ioredis redis.set(key, value, 'EX', ttl) takes seconds, not milliseconds — PX takes milliseconds | Verify TTL units: use 'EX', 3600 for seconds or 'PX', 3600000 for milliseconds; check the stored TTL with redis.ttl(key) |
| Lock not released on handler error; MCP tool stuck | Lock release code is not in a finally block and is skipped when an exception is thrown | Always release the lock in a finally block; the EX TTL provides a backstop for cases where the Lambda invocation is killed before finally runs |