Skip to content

Session Persistence

Priority: LOW Estimated Time: 16-20 hours Status: Not Planned

← Back to Enhancements


Overview

Add persistent session storage in Redis to allow MCP sessions to survive server restarts. Currently, sessions are stored in-memory and lost when the server restarts.


Current Limitation

Seed's hybrid session management approach:

typescript
// In-memory transports (lost on restart)
private transports = new Map<string, StreamableHTTPServerTransport>();

// Redis metadata (persists, but unusable without transport)
redis.setex(`session:${sessionId}`, ttl, JSON.stringify({ userId }));

Problem: When the server restarts:

  1. In-memory transports Map is cleared
  2. Redis session metadata remains, but there's no transport to resume
  3. Clients must re-initialize and get new session IDs
  4. Any session-specific state is lost

Proposed Solution

Store full session state in Redis and reconstruct transports on server restart.


Implementation

Note: Redis is included in the local development environment when using ./scripts/local (part of the Docker stack).

1. Enhanced Session Storage

Update src/services/session-store.ts:

typescript
interface SessionData {
  sessionId: string;
  userId: string;
  userContext: UserContext;
  createdAt: Date;
  lastAccessedAt: Date;
  clientInfo?: {
    userAgent: string;
    ip: string;
  };
  mcpState?: {
    capabilities: ServerCapabilities;
    clientInfo: Implementation;
    initializeParams: any;
  };
}

export class SessionStore {
  async saveSession(sessionId: string, data: SessionData): Promise<void> {
    const key = `session:${sessionId}`;
    await this.redis.setex(
      key,
      this.sessionTtl,
      JSON.stringify(data)
    );
  }

  async loadSession(sessionId: string): Promise<SessionData | null> {
    const key = `session:${sessionId}`;
    const data = await this.redis.get(key);
    return data ? JSON.parse(data) : null;
  }
}

2. Session Recovery on Startup

Add recovery logic to src/mcp/mcp.ts:

typescript
export class McpManager {
  async recoverSessions(): Promise<void> {
    logger.info('Recovering sessions from Redis...');

    // Get all session keys
    const keys = await this.redis.keys('session:*');
    logger.info(`Found ${keys.length} sessions to recover`);

    for (const key of keys) {
      const sessionId = key.replace('session:', '');

      try {
        // Load session data
        const sessionData = await this.sessionStore.loadSession(sessionId);

        if (!sessionData) {
          logger.warn(`Session ${sessionId} has no data, skipping`);
          continue;
        }

        // Check if session is still valid
        const age = Date.now() - new Date(sessionData.lastAccessedAt).getTime();
        if (age > this.sessionTtl * 1000) {
          logger.info(`Session ${sessionId} is expired, deleting`);
          await this.redis.del(key);
          continue;
        }

        // Recreate transport
        const transport = new StreamableHTTPServerTransport();
        await transport.initialize();

        // Restore transport with session state
        if (sessionData.mcpState) {
          // Note: MCP SDK doesn't support state restoration yet
          // This would require SDK enhancement or custom implementation
          logger.warn(`Session ${sessionId} state cannot be fully restored (SDK limitation)`);
        }

        // Store transport
        this.transports.set(sessionId, transport);

        logger.info(`Recovered session ${sessionId} for user ${sessionData.userId}`);
      } catch (error) {
        logger.error(`Failed to recover session ${sessionId}:`, error);
      }
    }

    logger.info('Session recovery complete');
  }
}

3. Server Startup Integration

Update src/index.ts:

typescript
import { mcpManager } from './mcp/mcp.js';

async function startServer() {
  // Initialize services
  await initializeRedis();
  await initializeJwks();

  // Recover existing sessions
  await mcpManager.recoverSessions();

  // Start HTTP server
  const server = app.listen(port, () => {
    logger.info(`Seed MCP Server listening on port ${port}`);
    logger.info(`Recovered ${mcpManager.getActiveSessionCount()} sessions`);
  });
}

4. Session State Capture

Enhance MCP handler to capture state:

typescript
// src/routes/mcp.ts
server.setRequestHandler(InitializeRequestSchema, async (request, extra) => {
  // Handle initialize...

  // Save session state
  const sessionData: SessionData = {
    sessionId,
    userId: extra.userContext.sub,
    userContext: extra.userContext,
    createdAt: new Date(),
    lastAccessedAt: new Date(),
    clientInfo: {
      userAgent: req.get('user-agent') ?? 'unknown',
      ip: req.ip ?? 'unknown'
    },
    mcpState: {
      capabilities: server.capabilities,
      clientInfo: request.params.clientInfo,
      initializeParams: request.params
    }
  };

  await sessionStore.saveSession(sessionId, sessionData);
});

Challenges & Limitations

MCP SDK Limitations

The @modelcontextprotocol/sdk doesn't currently support:

  • Serializing transport state
  • Restoring protocol state from JSON
  • Resuming mid-request operations

Workaround: Require clients to re-initialize after server restart, but preserve session IDs and user context.

State Synchronization

typescript
// Tool executions in progress during restart are lost
// Would need additional tracking:

interface InFlightRequest {
  requestId: string;
  toolName: string;
  args: any;
  startedAt: Date;
}

// Store in Redis with short TTL
await redis.setex(
  `in-flight:${sessionId}:${requestId}`,
  300, // 5 minutes
  JSON.stringify(inFlightRequest)
);

Memory vs Persistence Trade-off

Full persistence impacts performance:

ApproachStartup TimeMemory UsagePersistence
CurrentInstantLowNone
Metadata OnlyFastLowPartial
Full StateSlowMediumComplete

Configuration

Add to .env.example:

bash
# Session Persistence
SESSION_PERSISTENCE_ENABLED=true
SESSION_RECOVERY_ON_STARTUP=true
SESSION_RECOVERY_TIMEOUT_MS=30000  # 30 seconds max for recovery

Graceful Shutdown

Ensure all sessions are persisted before shutdown:

typescript
process.on('SIGTERM', async () => {
  logger.info('SIGTERM received, saving sessions...');

  // Save all active sessions
  const sessions = mcpManager.getAllSessions();
  await Promise.all(
    sessions.map(session => sessionStore.saveSession(session.id, session.data))
  );

  logger.info(`Saved ${sessions.length} sessions`);

  // Close server
  server.close(() => {
    logger.info('Server closed gracefully');
    process.exit(0);
  });

  // Force exit after timeout
  setTimeout(() => {
    logger.error('Forced shutdown after timeout');
    process.exit(1);
  }, 10000);
});

Migration Between Servers

With persistent sessions, enable server migration:

typescript
// Server A shuts down, sessions migrate to Server B
// Load balancer redirects traffic
// Server B recovers sessions from shared Redis

// Session affinity no longer required
// Can use round-robin load balancing

Monitoring

Add metrics for session recovery:

typescript
export const sessionRecoveryAttempts = new promClient.Counter({
  name: 'session_recovery_attempts_total',
  help: 'Total session recovery attempts on startup',
  labelNames: ['result']  // success | failed | expired
});

export const sessionRecoveryDuration = new promClient.Histogram({
  name: 'session_recovery_duration_seconds',
  help: 'Time taken to recover sessions on startup',
  buckets: [0.1, 0.5, 1, 5, 10, 30]
});

export const persistedSessions = new promClient.Gauge({
  name: 'sessions_persisted_total',
  help: 'Number of sessions persisted in Redis'
});

Testing

Unit Tests

typescript
describe('Session Persistence', () => {
  it('should save session state to Redis', async () => {
    const sessionData: SessionData = {
      sessionId: 'session-123',
      userId: 'user-456',
      userContext: mockUserContext,
      createdAt: new Date(),
      lastAccessedAt: new Date()
    };

    await sessionStore.saveSession('session-123', sessionData);

    const loaded = await sessionStore.loadSession('session-123');
    expect(loaded?.userId).toBe('user-456');
  });

  it('should recover sessions on startup', async () => {
    // Pre-populate Redis with session
    await sessionStore.saveSession('session-123', mockSessionData);

    // Restart manager
    const manager = new McpManager();
    await manager.recoverSessions();

    // Verify transport was recreated
    const transport = manager.getTransport('session-123');
    expect(transport).toBeDefined();
  });
});

Integration Tests

typescript
describe('Session Persistence E2E', () => {
  it('should survive server restart', async () => {
    // Start server, create session
    const { sessionId } = await initializeSession();

    // Stop server
    await server.close();

    // Start new server instance
    await startServer();

    // Verify session still exists
    const session = await sessionStore.loadSession(sessionId);
    expect(session).toBeDefined();
  });
});

Alternative Approaches

Stateless Sessions

Instead of persisting state, make sessions fully stateless:

typescript
// Encode all state in JWT-like session token
const sessionToken = jwt.sign({
  sessionId,
  userId,
  capabilities,
  expiresAt
}, secretKey);

// Client includes token in every request
// Server validates and recreates state from token

Pros:

  • No server-side state
  • Horizontal scaling easier
  • No recovery needed

Cons:

  • Larger request payloads
  • Can't revoke sessions instantly
  • Token size limitations

Open Questions

  1. MCP Protocol Support: Does the protocol support session resumption?
  2. Client Expectations: Do Claude clients handle session recovery?
  3. Tool State: How to handle tool-specific state (e.g., open file handles)?
  4. Cleanup Strategy: How aggressively to clean up stale sessions?
  5. Backward Compatibility: How to handle sessions created before this feature?

  • Session IP Binding - Persist IP binding data
  • Audit Logging - Log session recovery events
  • Multi-Tenancy - Per-tenant session recovery

← Back to Enhancements

Released under the MIT License.