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/sdkpackage (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.
- Initialization — The server process starts, transports are configured, and tool/resource handlers are registered.
- Connection — A client (Claude Desktop, an IDE plugin, a custom agent) connects over stdio, SSE, or HTTP.
- Session — The server and client exchange an
initializehandshake. This is where a session begins. - Tool Calls — The client invokes tools. Each call is independent at the protocol level, but your server can correlate them.
- Disconnection — The client disconnects or the transport closes. Session state becomes orphaned unless you persist it.
- 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
SIGTERMandSIGINT, flush critical state to the database, and close connection pools cleanly. Kubernetes, Docker, and most process managers sendSIGTERMbefore 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
- Model Context Protocol Specification — The official MCP specification covering transports, lifecycle, and message formats
- MCP TypeScript SDK — The official SDK used in this article's examples
- Node.js pg (node-postgres) Documentation — Connection pooling, query parameterization, and error handling
- Redis Node.js Client — The official Redis client for Node.js used in the Redis examples
- PostgreSQL JSONB Documentation — JSONB operators, indexing, and query patterns