Topic: session isolation security
MCP server session isolation security — shared state leakage, per-session context, concurrent session data separation
A Node.js MCP server that handles multiple concurrent connections shares a single process and module scope across all sessions by default. Module-level variables, singleton class instances, global caches, and static class properties are all accessible from every session simultaneously. When tool handlers read from or write to module-level state, they can unintentionally expose one session's data to another. In a multi-user deployment, this becomes a cross-tenant data leakage vulnerability. This page covers five session isolation patterns: per-session context objects, session-keyed storage discipline, session cleanup on disconnect, isolation testing, and explicit cross-session access controls.
1. Per-session context objects
The fundamental fix for shared state leakage is to encapsulate all per-session state in a single context object, keyed by session ID, rather than spreading it across multiple module-level variables. A session context object is created when the session initializes, accessed only by handlers for that session, and destroyed when the session ends.
// SHARED STATE ANTI-PATTERN — all sessions access the same variables
let currentUserId: string | null = null // module-level: shared across all sessions
const toolCallHistory: string[] = [] // module-level: accumulates for all sessions
let lastFetchedData: object | null = null // module-level: last session's data leaks
server.setRequestHandler(CallToolRequestSchema, async (request) => {
toolCallHistory.push(request.params.name) // mixes histories across sessions
return executeToolCall(request.params)
})
// PER-SESSION CONTEXT PATTERN
interface SessionContext {
userId: string
authorizedRepos: Set<string>
toolCallHistory: string[]
lastFetchedData: Map<string, unknown>
createdAt: number
}
const sessionRegistry = new Map<string, SessionContext>()
function createSessionContext(sessionId: string, userId: string): SessionContext {
const ctx: SessionContext = {
userId,
authorizedRepos: new Set(),
toolCallHistory: [],
lastFetchedData: new Map(),
createdAt: Date.now()
}
sessionRegistry.set(sessionId, ctx)
return ctx
}
function getSessionContext(sessionId: string): SessionContext {
const ctx = sessionRegistry.get(sessionId)
if (!ctx) throw new McpError(ErrorCode.InvalidRequest, `No session context for ${sessionId}`)
return ctx
}
function destroySessionContext(sessionId: string): void {
sessionRegistry.delete(sessionId)
}
// Handlers receive session context — no module-level state access
server.setRequestHandler(CallToolRequestSchema, async (request, context) => {
const sessionCtx = getSessionContext(context.sessionId)
sessionCtx.toolCallHistory.push(request.params.name) // per-session, isolated
return executeToolCall(request.params, sessionCtx)
})
2. Session-keyed storage discipline
Beyond the primary session context object, several common patterns create unintentional shared state: module-level caches that use resource identifiers as keys (not session-scoped keys), singleton service clients that maintain per-request state, and event emitters with module-level listeners. Each of these must be audited and converted to session-keyed access.
// VULNERABLE: cache keyed only by resource — leaks across sessions
const resourceCache = new Map<string, { data: object; fetchedAt: number }>()
async function fetchResourceVulnerable(resourceId: string, sessionId: string) {
if (resourceCache.has(resourceId)) {
return resourceCache.get(resourceId)!.data // returns ANY session's cached data
}
const data = await fetchFromApi(resourceId)
resourceCache.set(resourceId, { data, fetchedAt: Date.now() })
return data
}
// SAFE: cache keyed by [sessionId, resourceId] — isolates per session
const sessionResourceCache = new Map<string, Map<string, { data: object; fetchedAt: number }>>()
function getSessionCache(sessionId: string): Map<string, { data: object; fetchedAt: number }> {
if (!sessionResourceCache.has(sessionId)) {
sessionResourceCache.set(sessionId, new Map())
}
return sessionResourceCache.get(sessionId)!
}
async function fetchResourceSafe(resourceId: string, sessionId: string) {
const cache = getSessionCache(sessionId)
if (cache.has(resourceId)) {
return cache.get(resourceId)!.data
}
const data = await fetchFromApi(resourceId)
cache.set(resourceId, { data, fetchedAt: Date.now() })
return data
}
// Audit pattern: find all Map/Set/array/object declarations at module scope
// that are written to inside request handlers
function auditSharedState(moduleSource: string): string[] {
const issues: string[] = []
// Look for: module-level collection mutated inside handler callbacks
const moduleVarPattern = /^(?:const|let|var)\s+(\w+)\s*=/gm
const handlerWritePattern = /server\.setRequestHandler[^}]+(\w+)\.(?:set|push|add|delete|clear)\(/gs
const moduleVars = [...moduleSource.matchAll(moduleVarPattern)].map(m => m[1])
const handlerWrites = [...moduleSource.matchAll(handlerWritePattern)].flatMap(m => [m[1]])
for (const varName of handlerWrites) {
if (moduleVars.includes(varName)) {
issues.push(`Module-level variable '${varName}' is mutated inside a request handler — potential shared state`)
}
}
return issues
}
3. Session cleanup on disconnect
Session contexts that are never cleaned up accumulate over the server's lifetime. In long-running deployments with many sessions, this creates memory pressure. More dangerously, stale session contexts from disconnected sessions may contain sensitive data — user IDs, cached credentials, fetched content — that lingers in memory long after the session should have ended. Always register a disconnect handler that destroys the session context and purges any session-keyed caches.
// Session lifecycle management with guaranteed cleanup
class SessionLifecycleManager {
private contexts = new Map<string, SessionContext>()
private caches = new Map<string, Map<string, unknown>>()
private timers = new Map<string, NodeJS.Timeout>()
// Maximum session lifetime — destroy even if client doesn't disconnect cleanly
private readonly MAX_SESSION_AGE_MS = 8 * 60 * 60 * 1000 // 8 hours
create(sessionId: string, userId: string): SessionContext {
const ctx: SessionContext = {
userId,
authorizedRepos: new Set(),
toolCallHistory: [],
lastFetchedData: new Map(),
createdAt: Date.now()
}
this.contexts.set(sessionId, ctx)
this.caches.set(sessionId, new Map())
// Auto-destroy on max age
const timer = setTimeout(
() => this.destroy(sessionId, 'max_age_exceeded'),
this.MAX_SESSION_AGE_MS
)
timer.unref() // Don't prevent process exit
this.timers.set(sessionId, timer)
logger.info('session_created', { sessionId, userId })
return ctx
}
get(sessionId: string): SessionContext | undefined {
return this.contexts.get(sessionId)
}
destroy(sessionId: string, reason: string): void {
const ctx = this.contexts.get(sessionId)
if (!ctx) return
// Clear all per-session state
this.contexts.delete(sessionId)
this.caches.delete(sessionId)
// Cancel the auto-destroy timer
const timer = this.timers.get(sessionId)
if (timer) { clearTimeout(timer); this.timers.delete(sessionId) }
logger.info('session_destroyed', { sessionId, reason, ageMs: Date.now() - ctx.createdAt })
}
get activeSessionCount(): number { return this.contexts.size }
}
const sessions = new SessionLifecycleManager()
// Register disconnect handler — essential for cleanup
transport.onclose = () => {
sessions.destroy(transport.sessionId, 'transport_closed')
}
// Also handle explicit shutdown
process.on('SIGTERM', () => {
logger.info('shutting_down', { activeSessions: sessions.activeSessionCount })
// Sessions will be destroyed by GC — log the count for audit purposes
})
4. Isolation testing
Code review reliably catches intentional shared state. It often misses accidental shared state — a variable promoted from local to module scope during a refactor, a new field added to a singleton class, a cached result stored in a closure that lives longer than the request. Automated isolation tests that run two concurrent sessions and verify non-interference are the most reliable way to detect these bugs.
// Isolation test pattern — run with your test framework
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
describe('session isolation', () => {
it('tool call history is not shared between sessions', async () => {
const [clientA, clientB] = await Promise.all([
createTestClient(),
createTestClient()
])
// Session A makes tool calls
await clientA.callTool({ name: 'read_file', arguments: { path: '/tmp/a.txt' } })
await clientA.callTool({ name: 'list_repos', arguments: {} })
// Session B should have no knowledge of session A's history
const historyB = await clientB.callTool({ name: 'get_session_history', arguments: {} })
expect(historyB.content[0].text).not.toContain('read_file')
expect(historyB.content[0].text).not.toContain('list_repos')
await Promise.all([clientA.close(), clientB.close()])
})
it('cached data from session A does not appear in session B', async () => {
const [clientA, clientB] = await Promise.all([
createTestClient(),
createTestClient()
])
// Session A fetches a resource
await clientA.callTool({ name: 'fetch_user', arguments: { userId: 'user-123' } })
// Session B should not see session A's cached user
const resultB = await clientB.callTool({ name: 'get_cached_user', arguments: { userId: 'user-123' } })
expect(resultB.content[0].text).toContain('not cached')
await Promise.all([clientA.close(), clientB.close()])
})
async function createTestClient(): Promise<Client> {
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
const client = new Client({ name: 'test', version: '1.0' })
server.connect(serverTransport)
await client.connect(clientTransport)
return client
}
})
5. Explicit cross-session access controls
Some MCP server features legitimately share state across sessions — a shared tool-call rate limiter, an organization-wide resource cache, a real-time event log that multiple sessions subscribe to. These are intentional cross-session interactions. The security requirement is that they are explicitly designed, explicitly accessed through an interface that enforces access control, and explicitly documented as cross-session rather than accidentally shared.
// EXPLICIT cross-session shared state — marked, gated, audited
class OrganizationRateLimiter {
private readonly LABEL = 'CROSS_SESSION_SHARED' // explicit marker
private readonly buckets = new Map<string, { count: number; resetAt: number }>()
// Cross-session: keyed by organizationId, not sessionId
// Any access must provide BOTH sessionId (for audit) and organizationId (for limit)
checkAndIncrement(sessionId: string, orgId: string, toolName: string): void {
const key = `${orgId}:${toolName}`
const now = Date.now()
const bucket = this.buckets.get(key) ?? { count: 0, resetAt: now + 60_000 }
if (now > bucket.resetAt) {
bucket.count = 0
bucket.resetAt = now + 60_000
}
if (bucket.count >= 100) {
// Log which session hit the org-level limit
logger.warn('org_rate_limit_hit', { sessionId, orgId, toolName, [this.LABEL]: true })
throw new McpError(ErrorCode.InvalidRequest, `Organization rate limit exceeded for ${toolName}`)
}
bucket.count++
this.buckets.set(key, bucket)
// Audit every cross-session access
logger.info('cross_session_rate_check', {
sessionId, orgId, toolName,
[this.LABEL]: true,
remainingCalls: 100 - bucket.count
})
}
}
// Use a type guard to distinguish intentional cross-session state from accidents
type CrossSessionState = {
readonly _crossSession: true // TypeScript marker
}
// All intentional cross-session objects must carry this marker
const orgLimiter: OrganizationRateLimiter & CrossSessionState = Object.assign(
new OrganizationRateLimiter(),
{ _crossSession: true as const }
)
SkillAudit checks for session isolation
- Module-level mutable state accessed in handlers:
const/letvariable at module scope that is read or written insideserver.setRequestHandlercallbacks — indicates per-session data stored in shared scope - Cache keyed only by resource ID:
Maporobjectused as cache with resource-ID-only keys (no session ID component) inside handler code - No session cleanup on transport close: Missing
transport.onclose/ disconnect handler that purges session-keyed state - Static class fields used for per-session data:
staticproperties in TypeScript classes that accumulate per-session data — effectively module-level globals - No isolation tests: Test suite lacks any test that opens two concurrent sessions and verifies non-interference between them
— SkillAudit scans for these patterns automatically. Scan your MCP server.