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

SkillAudit scans for these patterns automatically. Scan your MCP server.