Guide · AWS DynamoDB

MCP Server DynamoDB Session Store — versioning, optimistic locking, conditional writes

DynamoDB is an excellent MCP session store because each session is a single-partition read/write — but without optimistic locking, two concurrent tool calls in the same session can corrupt each other's writes. MCP servers are inherently concurrent: a user may have multiple tabs open, an AI agent may fire parallel tool calls, and retried invocations can overlap. Three patterns prevent corruption: version attribute — store a monotonically incrementing version number on the session item; every update includes ConditionExpression: "version = :expected" and increments the version; if two concurrent writes race, only one succeeds and the other retries with a fresh read; append-only tool call log — instead of updating a mutable "last tool call" field, write each tool call result as a new item (sort key tool#<timestamp>#<toolCallId>); the session metadata item only stores the current context accumulated by the agent, not the full history; and atomic context accumulation — use UpdateExpression: "SET context = list_append(context, :newMessages)" to append new messages to the context list without a read-modify-write cycle.

TL;DR

Design the session table with two item types on the same partition key: sk="metadata" for session state + version, and sk="tool#<ts>#<id>" for immutable tool call records. All metadata updates must include ConditionExpression: "#v = :expectedVersion" and SET #v = #v + :inc. On ConditionalCheckFailedException, re-read the item, merge your change, and retry. Use list_append for additive changes like context accumulation to avoid full-item overwrites.

Session table schema design

A single-table design stores session metadata and tool call history on the same partition key, separated by sort key prefix:

pkskItem typeKey attributes
session#abc123metadataSession stateversion, status, context (list), clientId, createdAt, updatedAt, ttl
session#abc123tool#2026-09-10T12:00:00Z#call-001Tool call record (immutable)toolName, toolCallId, input, output, durationMs, error, ttl
session#abc123tool#2026-09-10T12:00:05Z#call-002Tool call record (immutable)same as above

Benefits: a single Query pk=session#abc123 returns both the metadata and the full tool call history in sort-key order. The metadata item is the only mutable item — tool call records are written once and never updated. TTL on both item types causes automatic cleanup at session expiry.

Optimistic locking with version attribute

Optimistic locking prevents two concurrent writes from silently overwriting each other. The session metadata item carries a version number; every update asserts the expected version in a ConditionExpression and increments it:

import {
  DynamoDBDocumentClient,
  GetCommand,
  UpdateCommand,
} from "@aws-sdk/lib-dynamodb";
import {
  ConditionalCheckFailedException,
  DynamoDBClient,
} from "@aws-sdk/client-dynamodb";

const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));

interface SessionMetadata {
  pk: string;
  sk: string;
  version: number;
  status: string;
  context: unknown[];
  updatedAt: string;
  ttl: number;
}

// Update session status with optimistic locking
export async function updateSessionStatus(
  sessionId: string,
  newStatus: string,
  maxRetries = 3
): Promise {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    // Read current state
    const result = await ddb.send(
      new GetCommand({
        TableName: "mcp-sessions",
        Key: { pk: `session#${sessionId}`, sk: "metadata" },
        ConsistentRead: true, // always strongly consistent for read-modify-write
      })
    );

    if (!result.Item) throw new Error(`Session ${sessionId} not found`);

    const session = result.Item as SessionMetadata;
    const expectedVersion = session.version;

    try {
      await ddb.send(
        new UpdateCommand({
          TableName: "mcp-sessions",
          Key: { pk: `session#${sessionId}`, sk: "metadata" },
          UpdateExpression: "SET #status = :status, updatedAt = :now, #v = #v + :inc",
          ConditionExpression: "#v = :expected",
          ExpressionAttributeNames: {
            "#status": "status",
            "#v": "version",
          },
          ExpressionAttributeValues: {
            ":status": newStatus,
            ":now": new Date().toISOString(),
            ":inc": 1,
            ":expected": expectedVersion,
          },
        })
      );
      return; // success
    } catch (err) {
      if (err instanceof ConditionalCheckFailedException && attempt < maxRetries) {
        // Another writer incremented the version concurrently — retry with fresh read
        const delay = 50 * Math.pow(2, attempt) + Math.random() * 20;
        await new Promise((r) => setTimeout(r, delay));
        continue;
      }
      throw err;
    }
  }
  throw new Error(`Could not update session ${sessionId} after ${maxRetries} retries`);
}

Atomic context accumulation with list_append

MCP servers accumulate conversation context (messages) as sessions progress. Instead of reading the context list, appending a message locally, and writing the full list back (a read-modify-write that races with concurrent writes), use DynamoDB's list_append function to atomically append to the list without a prior read:

// Append new messages to the context list atomically
export async function appendContextMessages(
  sessionId: string,
  newMessages: unknown[],
  expectedVersion: number
): Promise {
  await ddb.send(
    new UpdateCommand({
      TableName: "mcp-sessions",
      Key: { pk: `session#${sessionId}`, sk: "metadata" },
      UpdateExpression:
        "SET context = list_append(if_not_exists(context, :empty), :msgs), " +
        "updatedAt = :now, " +
        "#v = #v + :inc",
      ConditionExpression: "#v = :expected",
      ExpressionAttributeNames: { "#v": "version" },
      ExpressionAttributeValues: {
        ":msgs": newMessages,
        ":empty": [],          // handles the case where context doesn't exist yet
        ":now": new Date().toISOString(),
        ":inc": 1,
        ":expected": expectedVersion,
      },
    })
  );
}

// Record an immutable tool call result (no version check needed — new item)
export async function recordToolCall(
  sessionId: string,
  toolCallId: string,
  toolName: string,
  input: unknown,
  output: unknown,
  durationMs: number
): Promise {
  const nowIso = new Date().toISOString();
  const ttlSec = Math.floor(Date.now() / 1000) + 86400;

  await ddb.send(
    new PutCommand({
      TableName: "mcp-sessions",
      Item: {
        pk: `session#${sessionId}`,
        sk: `tool#${nowIso}#${toolCallId}`,
        toolCallId,
        toolName,
        input,
        output,
        durationMs,
        recordedAt: nowIso,
        ttl: ttlSec,
      },
      // Idempotent: if this toolCallId was already recorded, skip silently
      ConditionExpression: "attribute_not_exists(pk)",
    })
  );
}

Session state machine transitions

MCP sessions have a lifecycle: initializing → active → closing → closed. Guard state transitions with condition expressions to prevent invalid transitions (e.g., re-opening a closed session):

// Transition session from active to closing (idempotent)
export async function beginSessionClose(sessionId: string): Promise {
  try {
    await ddb.send(
      new UpdateCommand({
        TableName: "mcp-sessions",
        Key: { pk: `session#${sessionId}`, sk: "metadata" },
        UpdateExpression:
          "SET #status = :closing, updatedAt = :now, #v = #v + :inc",
        // Only transition if currently active (not already closing or closed)
        ConditionExpression: "#status = :active AND attribute_exists(pk)",
        ExpressionAttributeNames: {
          "#status": "status",
          "#v": "version",
        },
        ExpressionAttributeValues: {
          ":closing": "closing",
          ":active": "active",
          ":now": new Date().toISOString(),
          ":inc": 1,
        },
      })
    );
  } catch (err) {
    if (err instanceof ConditionalCheckFailedException) {
      // Session is already closing or closed — this is idempotent, not an error
      return;
    }
    throw err;
  }
}

Common failure modes

SymptomCauseFix
Session context is corrupted or messages are lost under concurrent tool callsTwo concurrent tool calls both read the session, append to the context list in memory, and write back — the last write wins and overwrites the first write's changesUse list_append in an UpdateExpression instead of read-modify-write; combine with optimistic locking on the version attribute
Version attribute grows unboundedly during retries; some tool calls never succeedHot session being updated by many concurrent invocations; optimistic locking retry stormAdd jitter to retry delays; implement exponential backoff; consider serializing tool calls that mutate session state through a Lambda queue or SQS FIFO queue for the session
Tool call records are duplicated in the session historyThe tool call Lambda retried (timeout or error) and inserted the same tool call record twice with a new sort key timestampDerive the sort key from the toolCallId, not the current timestamp: sk: "tool#${toolCallId}"; add ConditionExpression: "attribute_not_exists(pk)" to the Put; duplicate delivery will hit the condition and be silently skipped
ConditionalCheckFailedException storms after Lambda timeoutLambda timed out after starting a session write, was retried, and the retry conflicts with DynamoDB's in-progress or completed writeUse ClientRequestToken on PutItem/UpdateItem (via TransactWriteItems) for idempotency within 10 minutes; or use a dedicated idempotency key attribute with a attribute_not_exists condition guard
Queries for tool call history return items out of orderDynamoDB returns items in sort key order; if the sort key is tool#<toolCallId> without a timestamp, the order is alphabetical by ID, not chronologicalAlways include a timestamp in the sort key before the ID: tool#<ISO8601>#<toolCallId>; ISO 8601 sorts lexicographically in chronological order