Session Management
Seed implements two types of sessions: MCP Sessions for protocol communication and OAuth Client Sessions for registered clients. This page focuses on the session lifecycle and storage mechanisms.
Overview
Session management in Seed consists of:
- MCP Sessions: UUID-based transport tracking for MCP protocol
- OAuth Client Sessions: Redis-backed storage for dynamically registered clients
- User Context: JWT-derived user information attached to requests
MCP Session Management
Session Lifecycle
MCP sessions track individual client connections and their associated transports.
Session Creation
When a client sends an initialize request:
Request Received (no session ID):
json{ "jsonrpc": "2.0", "method": "initialize", "params": { "protocolVersion": "0.1.0", "capabilities": {} }, "id": 1 }UUID Generation:
typescriptconst sessionId = randomUUID(); // Example: "123e4567-e89b-12d3-a456-426614174000"Transport Creation:
typescriptconst transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => sessionId, enableJsonResponse: true, onsessioninitialized: (id) => { transports[id] = transport; }, });Response with Session ID:
httpHTTP/1.1 200 OK mcp-session-id: 123e4567-e89b-12d3-a456-426614174000 X-Session-Expires-At: 2026-01-07T10:30:00.000Z Content-Type: application/json { "jsonrpc": "2.0", "result": { "protocolVersion": "0.1.0", "capabilities": { "tools": {}, "prompts": {}, "resources": {} }, "serverInfo": { "name": "seed", "version": "0.1.3" } }, "id": 1 }
Session Storage
Storage Mechanism: Hybrid approach (in-memory transports + Redis metadata)
// In-memory transport storage (fast access)
interface TransportStore {
[sessionId: string]: StreamableHTTPServerTransport;
}
const transports: TransportStore = {};
// Redis session metadata (distributed validation + TTL)
interface SessionMetadata {
sessionId: string;
createdAt: number;
lastAccessedAt: number;
userId?: string; // Optional user ID from JWT for auditing and linking sessions to authenticated users
}Characteristics:
- Hybrid Storage: Transports in-memory for speed, metadata in Redis for TTL
- Fast Lookup: O(1) lookup by session ID
- Automatic Expiration: Redis TTL handles cleanup (default: 24 hours)
- Distributed: Sessions validated across multiple server instances
- Sliding Window: TTL refreshes on each access
Session Usage
After initialization, clients include the session ID in every request:
POST /mcp
Content-Type: application/json
mcp-session-id: 123e4567-e89b-12d3-a456-426614174000
Authorization: Bearer eyJhbGc...
{
"jsonrpc": "2.0",
"method": "tools/call",
"params": { "name": "seed_ping", "arguments": {} },
"id": 2
}Lookup Process (Async with Redis Validation):
// 1. Extract session ID from header
const sessionId = req.headers["mcp-session-id"] as string;
if (!sessionId) {
return res.status(400).json({
jsonrpc: "2.0",
error: { code: -32000, message: "Missing session ID" },
id: null
});
}
// 2. Get transport with async Redis validation
const transport = await getTransport(sessionId);
if (!transport) {
return res.status(404).json({
jsonrpc: "2.0",
error: { code: -32000, message: "Invalid or expired session" },
id: null
});
}
// 3. Refresh TTL via session touch (sliding window)
const sessionStore = getSessionStore();
await sessionStore.touch(sessionId);
// 4. Add session expiration header to inform client
const expiresAtMs = Date.now() + config.session.ttlSeconds * 1000;
const expiresAt = new Date(expiresAtMs).toISOString();
res.setHeader("X-Session-Expires-At", expiresAt);
// 5. Handle request with validated transport
await transport.handleRequest(req, res, req.body);Transport Lookup with Redis Validation:
// src/mcp/mcp.ts
export async function getTransport(
sessionId: string,
): Promise<StreamableHTTPServerTransport | undefined> {
// Get session store
const sessionStore = getSessionStore();
// Validate session exists in Redis
const sessionMetadata = await sessionStore.get(sessionId);
if (!sessionMetadata) {
// Session expired in Redis → Clean up in-memory
delete transports[sessionId];
return undefined;
}
// Session valid → Return transport
return transports[sessionId];
}Session Touch Mechanism (Sliding Window TTL):
// src/services/session-store.ts
export class SessionStore {
async touch(sessionId: string): Promise<void> {
const key = `${this.keyPrefix}${sessionId}`;
const session = await this.get(sessionId);
if (session) {
// Update lastAccessedAt timestamp
session.lastAccessedAt = Date.now();
// Store back to Redis with full TTL (sliding window)
await this.redis.set(
key,
JSON.stringify(session),
'EX',
this.ttlSeconds
);
logger.debug('Session touched', {
sessionId,
category: 'session',
});
}
}
}
### Session Expiration
Sessions expire when:
1. **TTL Expiration**: After 24 hours of inactivity (configurable via `MCP_SESSION_TTL_SECONDS`)
2. **Connection closes**: HTTP connection terminated
3. **Server restarts**: In-memory storage cleared (Redis metadata persists)
4. **Explicit deletion**: Via `DELETE /mcp` endpoint
**Automatic Cleanup**: Redis TTL automatically expires session metadata. On next access attempt:
- Session not found in Redis → Return 404
- Session exists → Refresh TTL (sliding window via `touch()`)
### Session Expiration Notification
To help clients manage session lifecycles, the server includes the `X-Session-Expires-At` header in all MCP responses:
**Response Header:**
```http
POST /mcp
mcp-session-id: 123e4567-e89b-12d3-a456-426614174000
HTTP/1.1 200 OK
X-Session-Expires-At: 2026-01-07T10:30:00.000Z
Content-Type: application/jsonHeader Format:
- Header Name:
X-Session-Expires-At - Value: ISO 8601 timestamp indicating when the session will expire
- Calculation: Current time + session TTL (refreshed on each request due to sliding window)
Client Usage:
// Parse expiration time from response header
const expiresAtHeader = response.headers.get('x-session-expires-at');
if (expiresAtHeader) {
const expiresAt = new Date(expiresAtHeader);
const timeUntilExpiry = expiresAt.getTime() - Date.now();
// Show warning 5 minutes before expiration
if (timeUntilExpiry < 5 * 60 * 1000) {
console.warn(`Session expiring in ${Math.floor(timeUntilExpiry / 60000)} minutes`);
}
}Benefits:
- Predictable expiration: Clients know exactly when their session will expire
- Proactive renewal: Clients can send requests before expiration to refresh the session
- Better UX: Clients can warn users before disconnection
- No surprise disconnects: Users are notified in advance
Session Removal (Explicit Cleanup):
// src/mcp/mcp.ts
export async function removeTransport(sessionId: string): Promise<void> {
const transport = transports[sessionId];
if (transport) {
// 1. Clean up in-memory transport
delete transports[sessionId];
// 2. Remove from Redis metadata store
const sessionStore = getSessionStore();
await sessionStore.delete(sessionId);
// 3. Update metrics
mcpSessionsActive.set(Object.keys(transports).length);
mcpSessionsTotal.inc({ status: 'terminated' });
logger.info('Session terminated', {
sessionId,
category: 'session',
});
}
}DELETE Endpoint Usage:
DELETE /mcp
mcp-session-id: 123e4567-e89b-12d3-a456-426614174000
Authorization: Bearer eyJhbGc...
Response:
HTTP/1.1 204 No ContentConfiguration:
MCP_SESSION_TTL_SECONDS=86400 # Default: 24 hours
MCP_SESSION_KEY_PREFIX=mcp:session: # Redis key prefixOAuth Client Sessions
Client Registration Storage
Dynamically registered OAuth clients are stored in Redis with TTL.
Storage Format:
Key: dcr:client:{client_id}
Value: JSON.stringify(RegisteredClient)
TTL: 2592000 seconds (30 days)Example:
Key: dcr:client:seed-a1b2c3d4e5f6
Value: {
"client_id": "seed-a1b2c3d4e5f6",
"client_name": "My Application",
"redirect_uris": ["https://app.example.com/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none",
"client_id_issued_at": 1702857600,
"client_secret_expires_at": 0
}
TTL: 2592000Client Lifecycle
Client Lookup
Clients are retrieved from Redis during OAuth flows:
// Authorization endpoint
const client = await clientStore.get(clientId);
if (!client) {
return res.redirect(`${redirectUri}?error=invalid_client`);
}
// Token endpoint
const client = await clientStore.get(params.client_id);
if (!client) {
return res.status(401).json({
error: "invalid_client",
error_description: "Client not found or expired"
});
}Client Expiration
TTL-Based Expiration:
- Default: 30 days (2,592,000 seconds)
- Configurable via
DCR_CLIENT_TTLenvironment variable - Redis automatically deletes expired keys
- No renewal mechanism (clients must re-register)
Expiration Handling:
- Client not found in Redis
- Return
invalid_clienterror - Client must re-register via
POST /oauth/register
User Context Sessions
JWT-Derived Context
Every authenticated request includes user context extracted from the JWT:
interface UserContext {
sub: string; // Subject (user ID) - REQUIRED
email?: string; // User email - OPTIONAL
name?: string; // Display name - OPTIONAL
groups?: string[]; // Group memberships - OPTIONAL
token: string; // Original JWT token
}Attachment to Request:
// src/middleware/auth.ts
const user: UserContext = {
sub: payload.sub as string,
token,
};
if (payload.email) user.email = payload.email as string;
if (payload.name) user.name = payload.name as string;
if (payload.groups) user.groups = payload.groups as string[];
req.user = user;Context Availability
User context is available in:
- Route handlers: via
req.user - MCP tools: via request object (if passed to tool)
- Middleware: for authorization checks
Example Usage in Tool:
export function registerAuthenticatedTool(server: McpServer): void {
server.tool("get_user_info", "Get current user information", {}, async (params, req) => {
const user = req.user; // Access user context
return {
content: [
{
type: "text",
text: `User: ${user.name} (${user.email})\nID: ${user.sub}`,
},
],
};
});
}Redis Connection Management
Connection Configuration
File: src/services/redis.ts
const redisClient = new Redis(config.dcr.redisUrl, {
maxRetriesPerRequest: 3,
retryStrategy(times: number) {
// Exponential backoff: 100ms, 200ms, 300ms, ..., max 30s
return Math.min(times * 100, 30000);
},
lazyConnect: true,
});Configuration Details:
- URL:
redis://redis:6379(default, configurable viaREDIS_URL) - Max Retries: 3 attempts per request
- Retry Strategy: Exponential backoff up to 30 seconds
- Lazy Connect: Connection established on first command
Health Checking
export async function isRedisHealthy(): Promise<boolean> {
try {
const result = await redisClient.ping();
return result.toUpperCase() === "PONG";
} catch {
return false;
}
}Used by: Health check endpoint (/health)
Error Handling
- Connection errors logged but don't crash service
- Exponential backoff prevents connection storms
- Client operations fail gracefully if Redis unavailable
- OAuth flows return
server_errorif storage unavailable
Session Security
MCP Sessions
- Not Authenticated Separately: Rely on JWT middleware
- UUID Unpredictability: 128-bit random IDs
- Transport Isolation: Each session has its own transport
- No Persistence: Can't be hijacked after server restart
OAuth Client Sessions
- TTL-Based Expiration: Automatic cleanup after 30 days
- Validation on Use: Clients validated on every OAuth flow
- Redirect URI Matching: Strict validation of callback URLs
- Public Clients: No client secrets (PKCE-protected)
User Context Security
- JWT Validation: Every request validates JWT signature
- Claim Verification: Issuer, audience, expiration checked
- Stateless: No server-side user sessions
- Token Refresh: Handled via OAuth refresh token flow
Monitoring and Observability
Session Metrics (Prometheus)
MCP Session Metrics:
// Gauge: Current number of active MCP sessions
const mcpSessionsActive = new promClient.Gauge({
name: 'mcp_sessions_active',
help: 'Number of currently active MCP sessions',
});
// Counter: Total number of sessions created/terminated
const mcpSessionsTotal = new promClient.Counter({
name: 'mcp_sessions_total',
help: 'Total number of MCP sessions',
labelNames: ['status'], // Labels: created, terminated, expired
});Metric Updates:
- Session Created:
mcpSessionsActive.inc(),mcpSessionsTotal.inc({ status: 'created' }) - Session Accessed: Update lastAccessedAt, no metric change
- Session Terminated:
mcpSessionsActive.dec(),mcpSessionsTotal.inc({ status: 'terminated' }) - Session Expired: Detected on next access,
mcpSessionsTotal.inc({ status: 'expired' })
PromQL Queries:
# Current active sessions
mcp_sessions_active
# Session creation rate (5min)
rate(mcp_sessions_total{status="created"}[5m])
# Session expiration rate
rate(mcp_sessions_total{status="expired"}[5m])
# Session churn rate (created vs terminated)
rate(mcp_sessions_total[5m])OAuth Client Metrics:
- Registered clients: Redis key count matching
dcr:client:* - Active registrations: Clients used in last 30 days
- Registration rate: Monitor
/oauth/registerrequests
Structured Logging
Session Creation:
logger.info('MCP session created', {
sessionId,
userId: req.user?.sub, // Optional user ID for linking
category: 'session',
});Session Access:
logger.debug('Session accessed', {
sessionId,
category: 'session',
});Session Touch (TTL Refresh):
logger.debug('Session touched', {
sessionId,
category: 'session',
});Session Termination:
logger.info('Session terminated', {
sessionId,
reason: 'explicit_delete', // or 'ttl_expired', 'server_restart'
category: 'session',
});Session Expiration:
logger.warn('Session expired', {
sessionId,
category: 'session',
});Health Checks
Redis Health:
GET /health
Response:
{
"status": "healthy",
"redis": "connected"
}MCP Session Count:
export function getSessionCount(): number {
return Object.keys(transports).length;
}Configuration
Environment Variables
# Redis Configuration
REDIS_URL=redis://redis:6379
# MCP Session Management
MCP_SESSION_TTL_SECONDS=86400 # 24 hours
MCP_SESSION_KEY_PREFIX=mcp:session: # Redis key prefix
# OAuth Client TTL
DCR_CLIENT_TTL=2592000 # 30 days in seconds
# Rate Limiting
RATE_LIMIT_ENABLED=true # Enable/disable rate limiting
MCP_RATE_LIMIT_WINDOW_MS=60000 # 1 minute
MCP_RATE_LIMIT_MAX=100 # 100 requests per minute
DCR_RATE_LIMIT_WINDOW_MS=3600000 # 1 hour
DCR_RATE_LIMIT_MAX=10 # 10 registrations per hourCode Configuration
// src/config/dcr.ts
export const dcrConfig = {
redisUrl: process.env.REDIS_URL ?? "redis://redis:6379",
clientTtlSeconds: parseInt(process.env.DCR_CLIENT_TTL ?? "2592000", 10),
keyPrefix: "dcr:client:",
clientIdPrefix: "seed-",
maxRedirectUris: 10,
rateLimit: {
windowMs: parseInt(process.env.DCR_RATE_LIMIT_WINDOW_MS ?? "3600000", 10),
maxRequests: parseInt(process.env.DCR_RATE_LIMIT_MAX ?? "10", 10),
},
};Best Practices
MCP Sessions
- Generate session ID once: Don't regenerate on every request
- Store transport reference: Keep in session map for reuse
- Clean up on close: Remove from map when connection terminates
- Handle missing sessions: Return proper error for invalid session IDs
OAuth Clients
- Set appropriate TTL: Balance security and user experience
- Validate on every use: Don't trust cached client data
- Monitor Redis health: Implement alerting for connection issues
- Rate limit registration: Prevent abuse of registration endpoint
User Context
- Validate JWT on every request: Don't cache user context
- Extract only needed claims: Minimize data exposure
- Pass context to tools: Make user info available where needed
- Log access patterns: Monitor for suspicious activity
Implementation Files
- MCP Sessions:
src/mcp/mcp.ts,src/routes/mcp.ts,src/services/session-store.ts - Session Config:
src/config/session.ts - Rate Limiting:
src/middleware/distributed-rate-limit.ts,src/config/rate-limit.ts - OAuth Clients:
src/services/client-store.ts - Redis Client:
src/services/redis.ts - User Context:
src/middleware/auth.ts - Metrics:
src/services/metrics.ts - Config:
src/config/dcr.ts,src/config/index.ts
Related Documentation
- MCP Server Design - Transport and session tracking details
- OAuth 2.1 Implementation - Client registration and flows
- Authentication Flow - JWT validation and user context
- Configuration System - Environment-based configuration