Skip to content

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:

  1. Request Received (no session ID):

    json
    {
      "jsonrpc": "2.0",
      "method": "initialize",
      "params": {
        "protocolVersion": "0.1.0",
        "capabilities": {}
      },
      "id": 1
    }
  2. UUID Generation:

    typescript
    const sessionId = randomUUID();
    // Example: "123e4567-e89b-12d3-a456-426614174000"
  3. Transport Creation:

    typescript
    const transport = new StreamableHTTPServerTransport({
      sessionIdGenerator: () => sessionId,
      enableJsonResponse: true,
      onsessioninitialized: (id) => {
        transports[id] = transport;
      },
    });
  4. Response with Session ID:

    http
    HTTP/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)

typescript
// 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:

http
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):

typescript
// 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:

typescript
// 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):

typescript
// 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/json

Header 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:

typescript
// 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):

typescript
// 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:

http
DELETE /mcp
mcp-session-id: 123e4567-e89b-12d3-a456-426614174000
Authorization: Bearer eyJhbGc...

Response:
HTTP/1.1 204 No Content

Configuration:

bash
MCP_SESSION_TTL_SECONDS=86400  # Default: 24 hours
MCP_SESSION_KEY_PREFIX=mcp:session:  # Redis key prefix

OAuth 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: 2592000

Client Lifecycle

Client Lookup

Clients are retrieved from Redis during OAuth flows:

typescript
// 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_TTL environment variable
  • Redis automatically deletes expired keys
  • No renewal mechanism (clients must re-register)

Expiration Handling:

  1. Client not found in Redis
  2. Return invalid_client error
  3. Client must re-register via POST /oauth/register

User Context Sessions

JWT-Derived Context

Every authenticated request includes user context extracted from the JWT:

typescript
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:

typescript
// 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:

typescript
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

typescript
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 via REDIS_URL)
  • Max Retries: 3 attempts per request
  • Retry Strategy: Exponential backoff up to 30 seconds
  • Lazy Connect: Connection established on first command

Health Checking

typescript
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_error if 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:

typescript
// 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:

promql
# 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/register requests

Structured Logging

Session Creation:

typescript
logger.info('MCP session created', {
  sessionId,
  userId: req.user?.sub,  // Optional user ID for linking
  category: 'session',
});

Session Access:

typescript
logger.debug('Session accessed', {
  sessionId,
  category: 'session',
});

Session Touch (TTL Refresh):

typescript
logger.debug('Session touched', {
  sessionId,
  category: 'session',
});

Session Termination:

typescript
logger.info('Session terminated', {
  sessionId,
  reason: 'explicit_delete',  // or 'ttl_expired', 'server_restart'
  category: 'session',
});

Session Expiration:

typescript
logger.warn('Session expired', {
  sessionId,
  category: 'session',
});

Health Checks

Redis Health:

bash
GET /health

Response:
{
  "status": "healthy",
  "redis": "connected"
}

MCP Session Count:

typescript
export function getSessionCount(): number {
  return Object.keys(transports).length;
}

Configuration

Environment Variables

bash
# 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 hour

Code Configuration

typescript
// 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

  1. Generate session ID once: Don't regenerate on every request
  2. Store transport reference: Keep in session map for reuse
  3. Clean up on close: Remove from map when connection terminates
  4. Handle missing sessions: Return proper error for invalid session IDs

OAuth Clients

  1. Set appropriate TTL: Balance security and user experience
  2. Validate on every use: Don't trust cached client data
  3. Monitor Redis health: Implement alerting for connection issues
  4. Rate limit registration: Prevent abuse of registration endpoint

User Context

  1. Validate JWT on every request: Don't cache user context
  2. Extract only needed claims: Minimize data exposure
  3. Pass context to tools: Make user info available where needed
  4. 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

Released under the MIT License.