Session Persistence
Priority: LOW Estimated Time: 16-20 hours Status: Not Planned
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:
// 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:
- In-memory
transportsMap is cleared - Redis session metadata remains, but there's no transport to resume
- Clients must re-initialize and get new session IDs
- 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:
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:
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:
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:
// 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
// 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:
| Approach | Startup Time | Memory Usage | Persistence |
|---|---|---|---|
| Current | Instant | Low | None |
| Metadata Only | Fast | Low | Partial |
| Full State | Slow | Medium | Complete |
Configuration
Add to .env.example:
# Session Persistence
SESSION_PERSISTENCE_ENABLED=true
SESSION_RECOVERY_ON_STARTUP=true
SESSION_RECOVERY_TIMEOUT_MS=30000 # 30 seconds max for recoveryGraceful Shutdown
Ensure all sessions are persisted before shutdown:
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:
// 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 balancingMonitoring
Add metrics for session recovery:
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
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
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:
// 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 tokenPros:
- No server-side state
- Horizontal scaling easier
- No recovery needed
Cons:
- Larger request payloads
- Can't revoke sessions instantly
- Token size limitations
Open Questions
- MCP Protocol Support: Does the protocol support session resumption?
- Client Expectations: Do Claude clients handle session recovery?
- Tool State: How to handle tool-specific state (e.g., open file handles)?
- Cleanup Strategy: How aggressively to clean up stale sessions?
- Backward Compatibility: How to handle sessions created before this feature?
Related Enhancements
- Session IP Binding - Persist IP binding data
- Audit Logging - Log session recovery events
- Multi-Tenancy - Per-tenant session recovery