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

SymptomCauseFix
Duplicate tool executions despite lock attemptSET 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 atomicAlways 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 attemptSwitch 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 enumerationKEYS session:* blocking the event loop on a large keyspaceReplace with SCAN-based cursor iteration; never use KEYS in production
Session data appears as [object Object] stringUsing .toString() or string concatenation instead of JSON.stringify() when storing session objectsAlways use JSON.stringify(data) on write and JSON.parse(raw) on read; wrap in try/catch to handle corrupted values
Session expires immediately after creationTTL set in milliseconds instead of seconds; ioredis redis.set(key, value, 'EX', ttl) takes seconds, not milliseconds — PX takes millisecondsVerify 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 stuckLock release code is not in a finally block and is skipped when an exception is thrownAlways release the lock in a finally block; the EX TTL provides a backstop for cases where the Lambda invocation is killed before finally runs