Mcp

State Management in MCP Servers

Techniques for managing state in MCP servers including session tracking, state persistence, and concurrent client handling.

State Management in MCP Servers

MCP servers are stateless by default, but real-world applications almost always need state. Whether you are tracking conversation context across tool calls, maintaining user preferences for an active session, or persisting data between server restarts, you need a deliberate state management strategy. This article covers the full spectrum of state management techniques for MCP servers built with Node.js, from in-memory Maps to PostgreSQL-backed persistence layers.

Prerequisites

  • Node.js v18+ installed
  • Familiarity with the Model Context Protocol (MCP) and its server SDK
  • Basic understanding of Express.js or similar HTTP frameworks
  • PostgreSQL or Redis available for persistence examples
  • The @modelcontextprotocol/sdk package (v1.x or later)

Install the core dependencies:

npm install @modelcontextprotocol/sdk uuid pg redis

Understanding the MCP Server State Lifecycle

An MCP server goes through a predictable lifecycle. Understanding this lifecycle is critical to deciding where and how to manage state.

  1. Initialization — The server process starts, transports are configured, and tool/resource handlers are registered.
  2. Connection — A client (Claude Desktop, an IDE plugin, a custom agent) connects over stdio, SSE, or HTTP.
  3. Session — The server and client exchange an initialize handshake. This is where a session begins.
  4. Tool Calls — The client invokes tools. Each call is independent at the protocol level, but your server can correlate them.
  5. Disconnection — The client disconnects or the transport closes. Session state becomes orphaned unless you persist it.
  6. Shutdown — The server process exits. All in-memory state is lost.

The key insight is that MCP itself does not prescribe a state model. The protocol is request-response at its core. State is your responsibility, and that is actually a good thing because it means you can pick the right tool for the job.

var { McpServer } = require("@modelcontextprotocol/sdk/server/mcp");
var { StdioServerTransport } = require("@modelcontextprotocol/sdk/server/stdio");

var server = new McpServer({
  name: "stateful-demo",
  version: "1.0.0"
});

// This runs once at initialization
var startTime = Date.now();
console.error("Server initialized at:", new Date(startTime).toISOString());

// Tool handlers run per-request — no built-in state between calls
server.tool("ping", "Check server uptime", {}, function() {
  var uptime = Math.floor((Date.now() - startTime) / 1000);
  return {
    content: [{ type: "text", text: "Uptime: " + uptime + " seconds" }]
  };
});

var transport = new StdioServerTransport();
server.connect(transport);

That startTime variable is module-level state. It survives across tool calls because it lives in the Node.js process. But it disappears the moment the process dies. This is the simplest form of state, and for many servers it is all you need.

Stateless vs. Stateful Server Design

Before you add state, ask yourself whether you actually need it. Stateless servers are simpler to reason about, easier to scale horizontally, and have fewer failure modes.

Choose stateless when:

  • Each tool call is self-contained (e.g., a calculator, a file reader, a web scraper)
  • The client provides all necessary context in each request
  • You are building utility tools that transform input to output

Choose stateful when:

  • You need to track conversation history across multiple tool calls
  • Tools build on the results of previous tools (e.g., a multi-step workflow)
  • You want to cache expensive computations within a session
  • You need rate limiting or usage tracking per client
  • You are implementing a multi-turn interaction pattern

I have seen teams default to stateful designs when stateless would have been fine. Every piece of state you manage is a piece of state that can go stale, leak memory, or cause subtle bugs during concurrent access. Start stateless. Add state only when you have a concrete reason.

Session-Based State with Maps and WeakMaps

For in-memory session state, JavaScript's Map is your best friend. The pattern is straightforward: use a session identifier as the key and store whatever you need as the value.

Basic Session Map

var { v4: uuidv4 } = require("uuid");

var sessions = new Map();

function createSession() {
  var sessionId = uuidv4();
  sessions.set(sessionId, {
    id: sessionId,
    createdAt: Date.now(),
    lastActivity: Date.now(),
    history: [],
    metadata: {}
  });
  return sessionId;
}

function getSession(sessionId) {
  var session = sessions.get(sessionId);
  if (session) {
    session.lastActivity = Date.now();
  }
  return session || null;
}

function destroySession(sessionId) {
  return sessions.delete(sessionId);
}

Integrating Sessions with MCP Tool Calls

The MCP SDK does not give you a built-in session ID per connection when using stdio transport (since stdio is typically a single-client connection). For SSE or HTTP transports that support multiple clients, you need to extract or assign session identifiers yourself.

A practical approach is to have a start_session tool that initializes state and returns a session ID, and then require that ID in subsequent tool calls:

var { z } = require("zod");

server.tool(
  "start_session",
  "Initialize a new stateful session",
  {},
  function() {
    var sessionId = createSession();
    return {
      content: [{
        type: "text",
        text: JSON.stringify({ sessionId: sessionId, message: "Session created" })
      }]
    };
  }
);

server.tool(
  "add_context",
  "Add context to the current session",
  {
    sessionId: z.string().uuid(),
    key: z.string(),
    value: z.string()
  },
  function(params) {
    var session = getSession(params.sessionId);
    if (!session) {
      return {
        content: [{ type: "text", text: "Error: Invalid or expired session" }],
        isError: true
      };
    }
    session.metadata[params.key] = params.value;
    session.history.push({
      action: "add_context",
      key: params.key,
      timestamp: Date.now()
    });
    return {
      content: [{
        type: "text",
        text: "Context added. Session has " + session.history.length + " events."
      }]
    };
  }
);

WeakMaps for Object-Keyed State

If you are associating state with transport or connection objects rather than string IDs, use a WeakMap. This prevents memory leaks because entries are garbage collected when the key object is no longer referenced:

var connectionState = new WeakMap();

function onClientConnect(transport) {
  connectionState.set(transport, {
    connectedAt: Date.now(),
    requestCount: 0,
    rateLimitRemaining: 100
  });
}

function trackRequest(transport) {
  var state = connectionState.get(transport);
  if (state) {
    state.requestCount++;
    state.rateLimitRemaining--;
  }
  return state;
}
// When `transport` is garbage collected, its entry in connectionState
// is automatically cleaned up. No manual cleanup required.

The tradeoff is that you cannot iterate over a WeakMap or check its size. If you need to enumerate active sessions (e.g., for an admin dashboard), stick with a regular Map and implement manual cleanup.

Persisting State Across Server Restarts

In-memory state dies with the process. For production MCP servers that need durability, you have two solid options: PostgreSQL for structured state and Redis for ephemeral session caches.

PostgreSQL for Durable State

PostgreSQL is the right choice when session state has value beyond the current server lifecycle — conversation histories, user preferences, accumulated context that should survive deployments.

CREATE TABLE mcp_sessions (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    client_id VARCHAR(255),
    created_at TIMESTAMPTZ DEFAULT NOW(),
    last_activity TIMESTAMPTZ DEFAULT NOW(),
    metadata JSONB DEFAULT '{}',
    is_active BOOLEAN DEFAULT true
);

CREATE TABLE mcp_session_events (
    id SERIAL PRIMARY KEY,
    session_id UUID REFERENCES mcp_sessions(id) ON DELETE CASCADE,
    event_type VARCHAR(100) NOT NULL,
    payload JSONB DEFAULT '{}',
    created_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_sessions_client ON mcp_sessions(client_id);
CREATE INDEX idx_sessions_active ON mcp_sessions(is_active) WHERE is_active = true;
CREATE INDEX idx_events_session ON mcp_session_events(session_id);
var { Pool } = require("pg");

var pool = new Pool({
  connectionString: process.env.POSTGRES_CONNECTION_STRING,
  max: 10,
  idleTimeoutMillis: 30000
});

var SessionStore = {
  create: function(clientId) {
    return pool.query(
      "INSERT INTO mcp_sessions (client_id) VALUES ($1) RETURNING id, created_at",
      [clientId]
    ).then(function(result) {
      return result.rows[0];
    });
  },

  get: function(sessionId) {
    return pool.query(
      "UPDATE mcp_sessions SET last_activity = NOW() WHERE id = $1 AND is_active = true RETURNING *",
      [sessionId]
    ).then(function(result) {
      return result.rows[0] || null;
    });
  },

  addEvent: function(sessionId, eventType, payload) {
    return pool.query(
      "INSERT INTO mcp_session_events (session_id, event_type, payload) VALUES ($1, $2, $3) RETURNING id",
      [sessionId, eventType, JSON.stringify(payload)]
    ).then(function(result) {
      return result.rows[0];
    });
  },

  getHistory: function(sessionId, limit) {
    limit = limit || 50;
    return pool.query(
      "SELECT event_type, payload, created_at FROM mcp_session_events WHERE session_id = $1 ORDER BY created_at DESC LIMIT $2",
      [sessionId, limit]
    ).then(function(result) {
      return result.rows;
    });
  },

  deactivate: function(sessionId) {
    return pool.query(
      "UPDATE mcp_sessions SET is_active = false, last_activity = NOW() WHERE id = $1",
      [sessionId]
    );
  },

  cleanupStale: function(maxAgeHours) {
    maxAgeHours = maxAgeHours || 24;
    return pool.query(
      "UPDATE mcp_sessions SET is_active = false WHERE is_active = true AND last_activity < NOW() - INTERVAL '1 hour' * $1 RETURNING id",
      [maxAgeHours]
    ).then(function(result) {
      console.error("Deactivated " + result.rowCount + " stale sessions");
      return result.rowCount;
    });
  }
};

Redis for Ephemeral Session Cache

Redis is the right choice when you want fast read/write performance and built-in TTL expiration, but you do not need the data to survive a Redis restart:

var redis = require("redis");

var client = redis.createClient({
  url: process.env.REDIS_URL || "redis://localhost:6379"
});
client.connect();

var SESSION_TTL = 3600; // 1 hour

var RedisSessionStore = {
  create: function(sessionId, data) {
    var key = "mcp:session:" + sessionId;
    return client.set(key, JSON.stringify(data), { EX: SESSION_TTL });
  },

  get: function(sessionId) {
    var key = "mcp:session:" + sessionId;
    return client.get(key).then(function(raw) {
      if (!raw) return null;
      // Refresh TTL on access
      client.expire(key, SESSION_TTL);
      return JSON.parse(raw);
    });
  },

  update: function(sessionId, data) {
    var key = "mcp:session:" + sessionId;
    return client.set(key, JSON.stringify(data), {
      EX: SESSION_TTL,
      XX: true  // Only update if exists
    });
  },

  addEvent: function(sessionId, event) {
    var key = "mcp:session:" + sessionId + ":events";
    return client.rPush(key, JSON.stringify(event)).then(function() {
      return client.expire(key, SESSION_TTL);
    });
  },

  getEvents: function(sessionId, start, end) {
    start = start || 0;
    end = end || -1;
    var key = "mcp:session:" + sessionId + ":events";
    return client.lRange(key, start, end).then(function(items) {
      return items.map(function(item) { return JSON.parse(item); });
    });
  },

  destroy: function(sessionId) {
    var baseKey = "mcp:session:" + sessionId;
    return client.del([baseKey, baseKey + ":events"]);
  }
};

Choosing Between PostgreSQL and Redis

Concern PostgreSQL Redis
Durability Full ACID Optional (AOF/RDB)
Query flexibility SQL, JSONB operators Key-based only
Auto-expiration Manual (cron/trigger) Built-in TTL
Throughput ~10K ops/sec ~100K ops/sec
Memory usage Disk-backed RAM-bound
Best for Audit trails, analytics Session cache, rate limits

For most production MCP servers, I use both: Redis as the hot session cache (sub-millisecond reads) and PostgreSQL as the durable backing store for anything that matters.

Managing Concurrent Client Connections

When your MCP server handles multiple simultaneous clients — common with SSE or HTTP transports — you need to think about concurrency.

Connection Registry

function ConnectionRegistry() {
  this.connections = new Map();
  this.maxConnections = parseInt(process.env.MAX_MCP_CONNECTIONS, 10) || 50;
}

ConnectionRegistry.prototype.register = function(connectionId, metadata) {
  if (this.connections.size >= this.maxConnections) {
    throw new Error("Maximum connections reached (" + this.maxConnections + ")");
  }
  this.connections.set(connectionId, {
    id: connectionId,
    connectedAt: Date.now(),
    lastPing: Date.now(),
    metadata: metadata || {},
    requestsInFlight: 0
  });
  console.error("Connection registered: " + connectionId +
    " (total: " + this.connections.size + ")");
  return this.connections.get(connectionId);
};

ConnectionRegistry.prototype.unregister = function(connectionId) {
  var conn = this.connections.get(connectionId);
  if (conn && conn.requestsInFlight > 0) {
    console.error("Warning: Unregistering connection " + connectionId +
      " with " + conn.requestsInFlight + " requests in flight");
  }
  this.connections.delete(connectionId);
  console.error("Connection removed: " + connectionId +
    " (total: " + this.connections.size + ")");
};

ConnectionRegistry.prototype.trackRequest = function(connectionId, delta) {
  var conn = this.connections.get(connectionId);
  if (conn) {
    conn.requestsInFlight += delta;
    conn.lastPing = Date.now();
  }
};

ConnectionRegistry.prototype.getStats = function() {
  var totalRequests = 0;
  this.connections.forEach(function(conn) {
    totalRequests += conn.requestsInFlight;
  });
  return {
    activeConnections: this.connections.size,
    totalRequestsInFlight: totalRequests,
    maxConnections: this.maxConnections
  };
};

var registry = new ConnectionRegistry();

Preventing Race Conditions

When multiple tool calls from different clients (or even the same client) access shared state concurrently, you can hit race conditions. In Node.js, the event loop is single-threaded, so basic read-modify-write operations on in-memory data structures are atomic. But the moment you introduce async operations (database queries, file I/O), you need to be careful.

// WRONG: Race condition with async operations
server.tool("increment_counter", "Increment a shared counter", {
  sessionId: z.string().uuid()
}, function(params) {
  return SessionStore.get(params.sessionId).then(function(session) {
    // Another request could read the same value here
    var newCount = (session.metadata.counter || 0) + 1;
    session.metadata.counter = newCount;
    return SessionStore.update(session).then(function() {
      return {
        content: [{ type: "text", text: "Counter: " + newCount }]
      };
    });
  });
});

// RIGHT: Use database-level atomicity
server.tool("increment_counter", "Increment a shared counter", {
  sessionId: z.string().uuid()
}, function(params) {
  return pool.query(
    "UPDATE mcp_sessions SET metadata = jsonb_set(metadata, '{counter}', " +
    "to_jsonb(COALESCE((metadata->>'counter')::int, 0) + 1)) " +
    "WHERE id = $1 RETURNING metadata->>'counter' as counter",
    [params.sessionId]
  ).then(function(result) {
    return {
      content: [{ type: "text", text: "Counter: " + result.rows[0].counter }]
    };
  });
});

For in-memory state, you can use a simple mutex pattern when you have async operations between read and write:

var locks = new Map();

function withLock(key, fn) {
  var existing = locks.get(key) || Promise.resolve();
  var release;
  var next = new Promise(function(resolve) { release = resolve; });
  locks.set(key, next);

  return existing.then(function() {
    return fn();
  }).then(function(result) {
    release();
    if (locks.get(key) === next) locks.delete(key);
    return result;
  }).catch(function(err) {
    release();
    if (locks.get(key) === next) locks.delete(key);
    throw err;
  });
}

// Usage
server.tool("safe_update", "Thread-safe state update", {
  sessionId: z.string().uuid(),
  data: z.string()
}, function(params) {
  return withLock("session:" + params.sessionId, function() {
    return getSession(params.sessionId).then(function(session) {
      session.metadata.lastUpdate = params.data;
      return saveSession(session);
    });
  }).then(function() {
    return { content: [{ type: "text", text: "Updated safely" }] };
  });
});

State Isolation Between Different MCP Clients

State isolation is not optional — it is a security requirement. If client A can read or modify client B's state, you have a vulnerability.

Namespace-Based Isolation

function IsolatedStateManager() {
  this.namespaces = new Map();
}

IsolatedStateManager.prototype.getNamespace = function(clientId) {
  if (!this.namespaces.has(clientId)) {
    this.namespaces.set(clientId, {
      sessions: new Map(),
      cache: new Map(),
      createdAt: Date.now()
    });
  }
  return this.namespaces.get(clientId);
};

IsolatedStateManager.prototype.set = function(clientId, key, value) {
  var ns = this.getNamespace(clientId);
  ns.cache.set(key, {
    value: value,
    updatedAt: Date.now()
  });
};

IsolatedStateManager.prototype.get = function(clientId, key) {
  var ns = this.namespaces.get(clientId);
  if (!ns) return undefined;
  var entry = ns.cache.get(key);
  return entry ? entry.value : undefined;
};

IsolatedStateManager.prototype.clear = function(clientId) {
  this.namespaces.delete(clientId);
};

IsolatedStateManager.prototype.getStats = function() {
  var stats = {};
  this.namespaces.forEach(function(ns, clientId) {
    stats[clientId] = {
      sessions: ns.sessions.size,
      cacheEntries: ns.cache.size,
      createdAt: new Date(ns.createdAt).toISOString()
    };
  });
  return stats;
};

var stateManager = new IsolatedStateManager();

At the database level, every query should include a client_id filter. Never trust the client to supply only its own identifiers:

// Always scope queries to the authenticated client
SessionStore.getForClient = function(sessionId, clientId) {
  return pool.query(
    "SELECT * FROM mcp_sessions WHERE id = $1 AND client_id = $2 AND is_active = true",
    [sessionId, clientId]
  ).then(function(result) {
    return result.rows[0] || null;
  });
};

Implementing Conversation Context Tracking

One of the most common reasons to manage state in an MCP server is to track conversation context — the sequence of tool calls, their results, and derived context that accumulates over a multi-turn interaction.

function ConversationTracker() {
  this.conversations = new Map();
}

ConversationTracker.prototype.start = function(conversationId) {
  var conversation = {
    id: conversationId,
    startedAt: Date.now(),
    turns: [],
    context: {},
    summary: ""
  };
  this.conversations.set(conversationId, conversation);
  return conversation;
};

ConversationTracker.prototype.addTurn = function(conversationId, toolName, input, output) {
  var conversation = this.conversations.get(conversationId);
  if (!conversation) return null;

  var turn = {
    index: conversation.turns.length,
    toolName: toolName,
    input: input,
    output: output,
    timestamp: Date.now()
  };
  conversation.turns.push(turn);

  // Keep only the last 100 turns to prevent unbounded growth
  if (conversation.turns.length > 100) {
    conversation.turns = conversation.turns.slice(-100);
  }

  return turn;
};

ConversationTracker.prototype.getRecentContext = function(conversationId, maxTurns) {
  maxTurns = maxTurns || 10;
  var conversation = this.conversations.get(conversationId);
  if (!conversation) return null;

  var recentTurns = conversation.turns.slice(-maxTurns);
  return {
    conversationId: conversationId,
    turnCount: conversation.turns.length,
    recentTurns: recentTurns.map(function(t) {
      return {
        tool: t.toolName,
        input: t.input,
        outputPreview: typeof t.output === "string"
          ? t.output.substring(0, 200)
          : JSON.stringify(t.output).substring(0, 200),
        timestamp: t.timestamp
      };
    }),
    customContext: conversation.context
  };
};

ConversationTracker.prototype.setContext = function(conversationId, key, value) {
  var conversation = this.conversations.get(conversationId);
  if (conversation) {
    conversation.context[key] = value;
  }
};

var tracker = new ConversationTracker();

Integrating this with your MCP tools creates a powerful pattern where each tool can see what happened before:

server.tool(
  "analyze_with_context",
  "Analyze data with awareness of previous tool calls",
  {
    sessionId: z.string().uuid(),
    query: z.string()
  },
  function(params) {
    var context = tracker.getRecentContext(params.sessionId, 5);
    var contextSummary = "No previous context.";

    if (context && context.turnCount > 0) {
      contextSummary = "Previous " + context.recentTurns.length + " actions: " +
        context.recentTurns.map(function(t) {
          return t.tool + "(" + JSON.stringify(t.input).substring(0, 80) + ")";
        }).join(" -> ");
    }

    // Your analysis logic here, informed by context
    var result = performAnalysis(params.query, context);

    tracker.addTurn(params.sessionId, "analyze_with_context", params, result);

    return {
      content: [{
        type: "text",
        text: JSON.stringify({
          analysis: result,
          contextUsed: contextSummary
        }, null, 2)
      }]
    };
  }
);

State Cleanup and Garbage Collection Strategies

Unmanaged state will eventually consume all available memory. You need cleanup strategies from day one, not as an afterthought.

Time-Based Expiration

function startCleanupInterval(sessions, maxIdleMs) {
  maxIdleMs = maxIdleMs || 30 * 60 * 1000; // 30 minutes default

  var intervalId = setInterval(function() {
    var now = Date.now();
    var expired = 0;
    sessions.forEach(function(session, id) {
      if (now - session.lastActivity > maxIdleMs) {
        sessions.delete(id);
        expired++;
      }
    });
    if (expired > 0) {
      console.error("[Cleanup] Removed " + expired + " expired sessions. " +
        "Active: " + sessions.size);
    }
  }, 60000); // Run every minute

  // Allow graceful shutdown
  process.on("SIGTERM", function() {
    clearInterval(intervalId);
  });

  return intervalId;
}

startCleanupInterval(sessions);

Memory-Pressure Based Eviction

function checkMemoryPressure(sessions, maxMemoryMB) {
  maxMemoryMB = maxMemoryMB || 256;
  var usage = process.memoryUsage();
  var heapUsedMB = Math.round(usage.heapUsed / 1024 / 1024);

  if (heapUsedMB > maxMemoryMB) {
    console.error("[Memory] Heap at " + heapUsedMB + "MB (limit: " +
      maxMemoryMB + "MB). Evicting oldest sessions.");

    // Sort sessions by last activity, evict oldest first
    var sorted = Array.from(sessions.entries()).sort(function(a, b) {
      return a[1].lastActivity - b[1].lastActivity;
    });

    // Evict 25% of sessions
    var evictCount = Math.ceil(sorted.length * 0.25);
    for (var i = 0; i < evictCount; i++) {
      sessions.delete(sorted[i][0]);
    }

    console.error("[Memory] Evicted " + evictCount + " sessions. " +
      "Remaining: " + sessions.size);
  }
}

setInterval(function() {
  checkMemoryPressure(sessions);
}, 30000);

Graceful Shutdown with State Persistence

When your server shuts down, you may want to flush in-memory state to the database so it can be recovered on restart:

function gracefulShutdown(sessions, pool) {
  console.error("[Shutdown] Persisting " + sessions.size + " active sessions...");

  var promises = [];
  sessions.forEach(function(session, id) {
    var promise = pool.query(
      "INSERT INTO mcp_sessions (id, metadata, last_activity) " +
      "VALUES ($1, $2, to_timestamp($3 / 1000.0)) " +
      "ON CONFLICT (id) DO UPDATE SET metadata = $2, last_activity = to_timestamp($3 / 1000.0)",
      [id, JSON.stringify(session.metadata), session.lastActivity]
    ).catch(function(err) {
      console.error("[Shutdown] Failed to persist session " + id + ": " + err.message);
    });
    promises.push(promise);
  });

  return Promise.all(promises).then(function() {
    console.error("[Shutdown] All sessions persisted. Closing database pool.");
    return pool.end();
  });
}

process.on("SIGTERM", function() {
  gracefulShutdown(sessions, pool).then(function() {
    process.exit(0);
  });
});

process.on("SIGINT", function() {
  gracefulShutdown(sessions, pool).then(function() {
    process.exit(0);
  });
});

Complete Working Example

Here is a full stateful MCP server that tracks conversation context across tool calls and persists session state to PostgreSQL. This is production-grade code that I have used as a starting point in real projects.

// server.js — Stateful MCP Server with PostgreSQL Persistence
var { McpServer } = require("@modelcontextprotocol/sdk/server/mcp");
var { StdioServerTransport } = require("@modelcontextprotocol/sdk/server/stdio");
var { z } = require("zod");
var { Pool } = require("pg");
var { v4: uuidv4 } = require("uuid");

// ── Database Setup ──────────────────────────────────────────────────
var pool = new Pool({
  connectionString: process.env.POSTGRES_CONNECTION_STRING,
  max: 10,
  idleTimeoutMillis: 30000
});

// ── In-Memory Session Cache ─────────────────────────────────────────
var sessionCache = new Map();
var CACHE_TTL = 30 * 60 * 1000; // 30 minutes

// ── Session Store ───────────────────────────────────────────────────
var Store = {
  createSession: function(clientId) {
    var id = uuidv4();
    return pool.query(
      "INSERT INTO mcp_sessions (id, client_id) VALUES ($1, $2) RETURNING *",
      [id, clientId || "anonymous"]
    ).then(function(result) {
      var session = result.rows[0];
      sessionCache.set(id, {
        data: session,
        cachedAt: Date.now(),
        turns: []
      });
      return session;
    });
  },

  getSession: function(sessionId) {
    // Check cache first
    var cached = sessionCache.get(sessionId);
    if (cached && (Date.now() - cached.cachedAt) < CACHE_TTL) {
      return Promise.resolve(cached);
    }

    return pool.query(
      "UPDATE mcp_sessions SET last_activity = NOW() " +
      "WHERE id = $1 AND is_active = true RETURNING *",
      [sessionId]
    ).then(function(result) {
      if (result.rows.length === 0) return null;

      return pool.query(
        "SELECT * FROM mcp_session_events WHERE session_id = $1 " +
        "ORDER BY created_at DESC LIMIT 50",
        [sessionId]
      ).then(function(eventsResult) {
        var entry = {
          data: result.rows[0],
          cachedAt: Date.now(),
          turns: eventsResult.rows.reverse()
        };
        sessionCache.set(sessionId, entry);
        return entry;
      });
    });
  },

  addEvent: function(sessionId, eventType, payload) {
    return pool.query(
      "INSERT INTO mcp_session_events (session_id, event_type, payload) " +
      "VALUES ($1, $2, $3) RETURNING *",
      [sessionId, eventType, JSON.stringify(payload)]
    ).then(function(result) {
      var cached = sessionCache.get(sessionId);
      if (cached) {
        cached.turns.push(result.rows[0]);
        if (cached.turns.length > 50) {
          cached.turns = cached.turns.slice(-50);
        }
      }
      return result.rows[0];
    });
  },

  updateMetadata: function(sessionId, metadata) {
    return pool.query(
      "UPDATE mcp_sessions SET metadata = metadata || $2, last_activity = NOW() " +
      "WHERE id = $1 RETURNING metadata",
      [sessionId, JSON.stringify(metadata)]
    ).then(function(result) {
      var cached = sessionCache.get(sessionId);
      if (cached) {
        cached.data.metadata = result.rows[0].metadata;
      }
      return result.rows[0].metadata;
    });
  },

  endSession: function(sessionId) {
    sessionCache.delete(sessionId);
    return pool.query(
      "UPDATE mcp_sessions SET is_active = false WHERE id = $1",
      [sessionId]
    );
  }
};

// ── MCP Server ──────────────────────────────────────────────────────
var server = new McpServer({
  name: "stateful-context-server",
  version: "1.0.0"
});

server.tool(
  "create_session",
  "Start a new tracked session for multi-turn interactions",
  { clientId: z.string().optional() },
  function(params) {
    return Store.createSession(params.clientId).then(function(session) {
      return {
        content: [{
          type: "text",
          text: JSON.stringify({
            sessionId: session.id,
            message: "Session created. Use this sessionId in subsequent tool calls."
          }, null, 2)
        }]
      };
    });
  }
);

server.tool(
  "record_action",
  "Record an action and its result in the session history",
  {
    sessionId: z.string().uuid(),
    action: z.string(),
    details: z.string().optional()
  },
  function(params) {
    return Store.getSession(params.sessionId).then(function(session) {
      if (!session) {
        return {
          content: [{ type: "text", text: "Session not found or expired" }],
          isError: true
        };
      }
      return Store.addEvent(params.sessionId, "action", {
        action: params.action,
        details: params.details || ""
      }).then(function() {
        return {
          content: [{
            type: "text",
            text: "Action recorded. Total events in session: " + (session.turns.length + 1)
          }]
        };
      });
    });
  }
);

server.tool(
  "get_context",
  "Retrieve conversation context for the current session",
  {
    sessionId: z.string().uuid(),
    maxTurns: z.number().min(1).max(50).optional()
  },
  function(params) {
    var maxTurns = params.maxTurns || 10;
    return Store.getSession(params.sessionId).then(function(session) {
      if (!session) {
        return {
          content: [{ type: "text", text: "Session not found or expired" }],
          isError: true
        };
      }
      var recentTurns = session.turns.slice(-maxTurns);
      return {
        content: [{
          type: "text",
          text: JSON.stringify({
            sessionId: params.sessionId,
            totalEvents: session.turns.length,
            metadata: session.data.metadata,
            recentEvents: recentTurns.map(function(t) {
              return {
                type: t.event_type,
                payload: t.payload,
                time: t.created_at
              };
            })
          }, null, 2)
        }]
      };
    });
  }
);

server.tool(
  "set_preference",
  "Store a user preference in the session metadata",
  {
    sessionId: z.string().uuid(),
    key: z.string(),
    value: z.string()
  },
  function(params) {
    var update = {};
    update[params.key] = params.value;
    return Store.updateMetadata(params.sessionId, update).then(function(metadata) {
      return {
        content: [{
          type: "text",
          text: "Preference set. Current metadata: " + JSON.stringify(metadata)
        }]
      };
    });
  }
);

server.tool(
  "end_session",
  "End an active session and clean up resources",
  { sessionId: z.string().uuid() },
  function(params) {
    return Store.endSession(params.sessionId).then(function() {
      return {
        content: [{ type: "text", text: "Session ended and cleaned up." }]
      };
    });
  }
);

// ── Cleanup ─────────────────────────────────────────────────────────
setInterval(function() {
  var now = Date.now();
  var evicted = 0;
  sessionCache.forEach(function(entry, key) {
    if (now - entry.cachedAt > CACHE_TTL) {
      sessionCache.delete(key);
      evicted++;
    }
  });
  if (evicted > 0) {
    console.error("[Cache] Evicted " + evicted + " entries. Active: " + sessionCache.size);
  }
}, 60000);

// Stale session cleanup in DB — every 6 hours
setInterval(function() {
  pool.query(
    "UPDATE mcp_sessions SET is_active = false " +
    "WHERE is_active = true AND last_activity < NOW() - INTERVAL '24 hours' " +
    "RETURNING id"
  ).then(function(result) {
    if (result.rowCount > 0) {
      console.error("[DB Cleanup] Deactivated " + result.rowCount + " stale sessions");
    }
  }).catch(function(err) {
    console.error("[DB Cleanup] Error: " + err.message);
  });
}, 6 * 60 * 60 * 1000);

// ── Graceful Shutdown ───────────────────────────────────────────────
function shutdown() {
  console.error("[Shutdown] Closing database pool...");
  pool.end().then(function() {
    console.error("[Shutdown] Complete.");
    process.exit(0);
  });
}
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);

// ── Start ───────────────────────────────────────────────────────────
var transport = new StdioServerTransport();
server.connect(transport).then(function() {
  console.error("Stateful MCP server running on stdio");
});

To run this server, you need the database schema from the earlier section and these environment variables:

export POSTGRES_CONNECTION_STRING="postgresql://user:pass@localhost:5432/mcp_state"
node server.js

Register it in your Claude Desktop configuration:

{
  "mcpServers": {
    "stateful-context": {
      "command": "node",
      "args": ["/path/to/server.js"],
      "env": {
        "POSTGRES_CONNECTION_STRING": "postgresql://user:pass@localhost:5432/mcp_state"
      }
    }
  }
}

Common Issues and Troubleshooting

1. Session Not Found After Server Restart

Error: Session not found or expired

This happens when you rely on in-memory session state without persistence. After a restart, all Map entries are gone. The fix is to always treat in-memory storage as a cache with a database fallback. The Store.getSession function in the complete example handles this correctly — it checks the cache first, then falls back to PostgreSQL.

2. Memory Leak from Unbounded Session Maps

FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed - JavaScript heap out of memory

You will see this if you create sessions but never clean them up. A server handling 100 sessions per hour with 1KB of state each leaks roughly 2.4MB per day. That does not sound like much, but sessions with conversation histories can easily be 100KB+, which means 240MB per day. Always implement TTL-based expiration and memory pressure monitoring. The cleanup interval shown earlier prevents this.

3. Race Condition on Concurrent Metadata Updates

Expected metadata.counter to be 5, got 3

Two concurrent tool calls both read counter = 2, both increment to 3, and both write 3. You lose two increments. Use database-level atomic operations (jsonb_set with subqueries) or the in-memory mutex pattern shown in the concurrency section. Never do read-modify-write across async boundaries without synchronization.

4. PostgreSQL Connection Pool Exhaustion

Error: timeout exceeded when trying to connect

This occurs when your session store makes queries faster than the pool can recycle connections, or when queries hang due to missing indexes. Check that you have indexes on mcp_sessions(id), mcp_sessions(client_id), and mcp_session_events(session_id). Set reasonable pool limits:

var pool = new Pool({
  connectionString: process.env.POSTGRES_CONNECTION_STRING,
  max: 10,             // Match to expected concurrency
  idleTimeoutMillis: 30000,
  connectionTimeoutMillis: 5000  // Fail fast instead of hanging
});

pool.on("error", function(err) {
  console.error("[Pool] Unexpected error on idle client:", err.message);
});

5. Stale Cache Serving Outdated State

Session metadata shows old value despite database being updated

If another process or a direct database update modifies session state, your in-memory cache will serve stale data until the TTL expires. Solutions: reduce cache TTL, implement cache invalidation on writes, or use Redis pub/sub for cross-process cache invalidation.

Best Practices

  • Start stateless, add state incrementally. Every piece of state adds complexity. Begin with stateless tools and introduce state only when you have a clear use case that requires it.

  • Use session IDs, not connection identity, for state keys. Transport connections can drop and reconnect. A session ID that you control is stable and portable across connection interruptions.

  • Set hard limits on session count and event history size. Unbounded data structures are the number one source of memory leaks in long-running MCP servers. Cap sessions at a reasonable number (e.g., 1000) and cap event history per session (e.g., 100 events).

  • Always scope database queries by client identity. Never trust a session ID alone as proof of authorization. Include the client identifier in every query to prevent cross-client state access.

  • Log state lifecycle events to stderr. Session creation, expiration, and eviction events are invaluable for debugging. MCP servers use stderr for logging because stdout is reserved for the protocol transport.

  • Implement graceful shutdown handlers. Catch SIGTERM and SIGINT, flush critical state to the database, and close connection pools cleanly. Kubernetes, Docker, and most process managers send SIGTERM before killing your process.

  • Use JSONB columns for flexible metadata storage. Avoid creating new columns every time you need to track a new piece of session data. PostgreSQL's JSONB type with GIN indexes gives you schema flexibility with query performance.

  • Test with concurrent clients early. Spin up multiple MCP clients against your server during development. Race conditions that are invisible with a single client become immediately obvious with two or three.

  • Monitor memory usage in production. Expose process.memoryUsage() through a diagnostic tool or health check endpoint. Set alerting thresholds at 70% of your container's memory limit.

References

Powered by Contentful