Skip to content

Max Sessions Per User Limit

Status: ❌ Not Implemented Priority: 🟢 LOW Estimated Time: 4-6 hours Risk Level: LOW Impact: Resource exhaustion prevention and abuse protection

← Back to Enhancements


Problem Statement

Users can currently create unlimited concurrent MCP sessions, with each session consuming memory and Redis storage. While individual sessions are bounded by TTL (24 hours default), there's no per-user limit to prevent resource exhaustion or abuse.

Current Behavior:

  • No limit on concurrent sessions per user
  • Each session consumes ~50KB memory + Redis storage
  • Malicious or buggy clients could create thousands of sessions
  • No protection against resource exhaustion attacks

Risk Scenarios:

  1. Malicious abuse:

    • Attacker creates 10,000 sessions → 500MB memory + Redis storage
    • Impacts service availability for legitimate users
  2. Buggy client:

    • Client fails to reuse sessions, creates new session every minute
    • Over 24 hours: 1,440 sessions per user
    • 1,000 users: 1.44 million sessions
  3. Resource exhaustion:

    • No per-user fairness guarantees
    • Single user can consume disproportionate resources

Proposed Solution

Implement configurable per-user session limit with automatic eviction of oldest sessions.

Configuration

bash
# Maximum concurrent sessions per user
SESSION_MAX_PER_USER=10

# Eviction policy: oldest | least_recently_used
SESSION_EVICTION_POLICY=least_recently_used

Implementation

Location: src/mcp/mcp.ts

typescript
// src/services/session-store.ts

export interface SessionMetadata {
  sessionId: string;
  userId: string;
  createdAt: number;
  lastAccessedAt: number;
  expiresAt: number;
}

/**
 * Get all sessions for a user
 */
export async function getUserSessions(userId: string): Promise<SessionMetadata[]> {
  const redis = getRedisClient();
  const keys = await redis.keys(`${SESSION_KEY_PREFIX}${userId}:*`);

  const sessions: SessionMetadata[] = [];
  for (const key of keys) {
    const data = await redis.get(key);
    if (data) {
      sessions.push(JSON.parse(data));
    }
  }

  return sessions;
}

/**
 * Remove session from both Redis and in-memory transports
 */
export async function removeSession(sessionId: string): Promise<void> {
  const sessionStore = getSessionStore();
  const metadata = await sessionStore.get(sessionId);

  if (metadata) {
    // Remove from Redis
    await sessionStore.delete(sessionId);

    // Remove from in-memory transports
    const { removeTransport } = await import("../mcp/mcp.js");
    await removeTransport(sessionId);
  }
}
typescript
// src/mcp/mcp.ts - Session creation with limit enforcement

mcpRouter.post("/", async (req: Request, res: Response): Promise<void> => {
  // ... existing authentication logic ...

  // Check session limit before creating new session
  const maxSessionsPerUser = config.session.maxPerUser || 10;
  const userSessions = await getUserSessions(user.sub);

  if (userSessions.length >= maxSessionsPerUser) {
    // Evict oldest or least recently used session
    const evictionPolicy = config.session.evictionPolicy || "least_recently_used";

    let sessionToEvict: SessionMetadata;
    if (evictionPolicy === "oldest") {
      // Evict session with earliest createdAt
      sessionToEvict = userSessions.sort((a, b) => a.createdAt - b.createdAt)[0];
    } else {
      // Evict session with earliest lastAccessedAt (LRU)
      sessionToEvict = userSessions.sort((a, b) =>
        a.lastAccessedAt - b.lastAccessedAt
      )[0];
    }

    logger.info("Evicting session due to per-user limit", {
      userId: user.sub,
      evictedSessionId: sessionToEvict.sessionId,
      policy: evictionPolicy,
      limit: maxSessionsPerUser,
    });

    await removeSession(sessionToEvict.sessionId);

    // Increment eviction metric
    sessionEvictions.inc({
      reason: "max_sessions_exceeded",
      policy: evictionPolicy,
    });
  }

  // ... proceed with session creation ...
});

Prometheus Metrics

typescript
// src/services/metrics.ts

export const sessionEvictions = new Counter({
  name: "session_evictions_total",
  help: "Total session evictions",
  labelNames: ["reason", "policy"], // max_sessions_exceeded, oldest/lru
  registers: [register],
});

export const sessionsPerUser = new Histogram({
  name: "sessions_per_user",
  help: "Distribution of sessions per user",
  buckets: [1, 2, 5, 10, 20, 50],
  registers: [register],
});

Alternative Approaches

Approach: Return error when limit reached, don't create new session.

typescript
if (userSessions.length >= maxSessionsPerUser) {
  return res.status(429).json({
    jsonrpc: "2.0",
    error: {
      code: -32001,
      message: "Too many sessions",
      data: {
        reason: "max_sessions_exceeded",
        details: `Maximum ${maxSessionsPerUser} concurrent sessions allowed`,
        currentSessions: userSessions.length,
      },
    },
    id: null,
  });
}

Pros:

  • Simple implementation
  • User explicitly notified of limit

Cons:

  • Poor user experience (connection refused)
  • Requires user to manually clean up old sessions
  • Breaks "just works" principle

Approach: Automatically evict least recently used session, include warning in response.

typescript
if (userSessions.length >= maxSessionsPerUser) {
  const evicted = await evictLeastRecentlyUsed(user.sub);

  logger.warn("Auto-evicted session", {
    userId: user.sub,
    evictedSessionId: evicted.sessionId,
    lastAccessed: new Date(evicted.lastAccessedAt).toISOString(),
  });

  // Add warning header
  res.setHeader("X-Session-Evicted", evicted.sessionId);
  res.setHeader("X-Session-Eviction-Reason", "max_sessions_exceeded");
}

Pros:

  • Transparent to user (connection succeeds)
  • Automatic cleanup of inactive sessions
  • Good user experience

Cons:

  • May disconnect active session unexpectedly
  • Requires session access tracking

User-Facing Behavior

Scenario 1: Normal Usage (≤ 10 sessions)

User creates sessions 1-10
→ All sessions work normally
→ No evictions

Scenario 2: Exceeds Limit (11th session)

User has 10 active sessions
User creates 11th session
→ Least recently used session evicted
→ New session created successfully
→ User unaware unless monitoring old session

Scenario 3: Monitoring Evictions

User queries session list (future Session Management API)
→ Sees 10 sessions
→ Notices old session disappeared
→ Can adjust client behavior

Configuration Options

typescript
// src/config/session.ts

export interface SessionConfig {
  // Maximum sessions per user (0 = unlimited)
  maxPerUser: number;

  // Eviction policy: oldest | least_recently_used
  evictionPolicy: "oldest" | "least_recently_used";

  // Session TTL in milliseconds
  ttl: number;
}

export const sessionConfig: SessionConfig = {
  maxPerUser: parseInt(process.env.SESSION_MAX_PER_USER || "10", 10),
  evictionPolicy:
    (process.env.SESSION_EVICTION_POLICY as "oldest" | "least_recently_used") ||
    "least_recently_used",
  ttl: parseInt(process.env.SESSION_TTL_MS || String(24 * 60 * 60 * 1000), 10),
};

Testing Strategy

typescript
// src/mcp/mcp.test.ts

describe("Session limit enforcement", () => {
  it("should allow up to max sessions per user", async () => {
    const user = { sub: "user123", email: "test@example.com" };

    // Create 10 sessions (at limit)
    for (let i = 0; i < 10; i++) {
      const res = await request(app)
        .post("/mcp")
        .set("Authorization", `Bearer ${generateToken(user)}`)
        .send({ jsonrpc: "2.0", method: "initialize", params: {}, id: 1 });

      expect(res.status).toBe(200);
    }

    const sessions = await getUserSessions(user.sub);
    expect(sessions).toHaveLength(10);
  });

  it("should evict oldest session when limit exceeded", async () => {
    const user = { sub: "user123", email: "test@example.com" };

    // Create 10 sessions
    const sessionIds: string[] = [];
    for (let i = 0; i < 10; i++) {
      const res = await request(app)
        .post("/mcp")
        .set("Authorization", `Bearer ${generateToken(user)}`)
        .send({ jsonrpc: "2.0", method: "initialize", params: {}, id: 1 });

      sessionIds.push(res.headers["mcp-session-id"]);
      await sleep(10); // Ensure different createdAt timestamps
    }

    // Create 11th session
    const res = await request(app)
      .post("/mcp")
      .set("Authorization", `Bearer ${generateToken(user)}`)
      .send({ jsonrpc: "2.0", method: "initialize", params: {}, id: 1 });

    expect(res.status).toBe(200);

    // Verify oldest session evicted
    const sessions = await getUserSessions(user.sub);
    expect(sessions).toHaveLength(10);
    expect(sessions.find(s => s.sessionId === sessionIds[0])).toBeUndefined();
  });

  it("should evict least recently used session", async () => {
    config.session.evictionPolicy = "least_recently_used";

    const user = { sub: "user123", email: "test@example.com" };

    // Create 10 sessions
    const sessionIds: string[] = [];
    for (let i = 0; i < 10; i++) {
      const res = await request(app)
        .post("/mcp")
        .set("Authorization", `Bearer ${generateToken(user)}`)
        .send({ jsonrpc: "2.0", method: "initialize", params: {}, id: 1 });

      sessionIds.push(res.headers["mcp-session-id"]);
    }

    // Access session 0 to update lastAccessedAt
    await request(app)
      .post("/mcp")
      .set("Authorization", `Bearer ${generateToken(user)}`)
      .set("mcp-session-id", sessionIds[0])
      .send({ jsonrpc: "2.0", method: "ping", params: {}, id: 1 });

    // Create 11th session - should evict session 1 (not 0)
    const res = await request(app)
      .post("/mcp")
      .set("Authorization", `Bearer ${generateToken(user)}`)
      .send({ jsonrpc: "2.0", method: "initialize", params: {}, id: 1 });

    const sessions = await getUserSessions(user.sub);
    expect(sessions.find(s => s.sessionId === sessionIds[0])).toBeDefined();
    expect(sessions.find(s => s.sessionId === sessionIds[1])).toBeUndefined();
  });
});

Acceptance Criteria

  • [ ] Add SESSION_MAX_PER_USER configuration option (default: 10)
  • [ ] Add SESSION_EVICTION_POLICY configuration option (default: least_recently_used)
  • [ ] Implement getUserSessions() to query user's active sessions
  • [ ] Implement session eviction logic in MCP route handler
  • [ ] Add sessionEvictions Prometheus counter
  • [ ] Add sessionsPerUser Prometheus histogram
  • [ ] Comprehensive test coverage (≥90%)
  • [ ] Update session architecture documentation
  • [ ] Add monitoring guide for session limits

Sample Queries

promql
# Session eviction rate
rate(session_evictions_total[5m])

# Users hitting session limit
count(sessions_per_user_bucket{le="10"}) by (user)

# Average sessions per user
avg(sessions_per_user)

# Alert: High eviction rate (users creating too many sessions)
rate(session_evictions_total[5m]) > 0.1


Future Considerations

  • Per-tenant limits: Different limits for different organizations
  • Session pooling: Reuse sessions across requests from same client
  • Graceful eviction notification: Notify client when session evicted
  • Session priority: Pin important sessions to prevent eviction

Released under the MIT License.