Skip to content

Session Expiration Notification

Status: ✅ Implemented (2026-01-06) Priority: Medium Effort: 1-2 hours Category: UX Enhancement

Overview

The Session Expiration Notification feature adds an X-Session-Expires-At response header to all MCP requests, informing clients when their session will expire. This enables clients to proactively manage session lifecycles, warn users before disconnection, and provide a better user experience.

Problem Statement

Prior to this enhancement:

  • Sessions expired silently after 24 hours of inactivity
  • Next request after expiration returned 404 (session not found)
  • No advance warning to clients
  • Users experienced sudden disconnections without notice
  • Clients had no way to determine when to refresh sessions

Solution

Add an X-Session-Expires-At header to all MCP POST responses containing:

  • ISO 8601 formatted timestamp
  • Calculated as: current_time + session_ttl
  • Updated on every request due to sliding window TTL

Implementation

Response Header

Header Format:

http
X-Session-Expires-At: 2026-01-07T10:30:00.000Z

Example Response:

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

{
  "jsonrpc": "2.0",
  "result": {...},
  "id": 1
}

Code Changes

File: src/routes/mcp.ts

For existing sessions:

typescript
if (sessionId) {
  const transport = await getTransport(sessionId);
  if (transport) {
    // Update last accessed time and refresh TTL in Redis
    const sessionStore = getSessionStore();
    await sessionStore.touch(sessionId);

    // Calculate expiration time from current time + TTL
    // (after touch, the TTL is refreshed from now)
    const expiresAtMs = Date.now() + config.session.ttlSeconds * 1000;
    const expiresAt = new Date(expiresAtMs).toISOString();
    res.setHeader("X-Session-Expires-At", expiresAt);

    await transport.handleRequest(req, res, req.body);
    return;
  }
}

For new sessions (initialize):

typescript
if (!sessionId && isInitializeRequest(req.body)) {
  const user = (req as Request & { user?: { sub: string } }).user;
  const transport = await createTransport(user?.sub);

  // For new sessions, set expiration header based on current time + TTL
  const expiresAtMs = Date.now() + config.session.ttlSeconds * 1000;
  const expiresAt = new Date(expiresAtMs).toISOString();
  res.setHeader("X-Session-Expires-At", expiresAt);

  await transport.handleRequest(req, res, req.body);
  return;
}

Test Coverage

File: src/routes/mcp.test.ts

Two new tests added:

  1. Existing session expiration header: Verifies header is present and timestamp is approximately current time + 24 hours
  2. New session expiration header: Verifies header is included in initialize response
typescript
it("should include X-Session-Expires-At header for existing session", async () => {
  const beforeRequest = Date.now();
  const response = await request(app)
    .post("/mcp")
    .set("mcp-session-id", "existing-session-123")
    .send({ jsonrpc: "2.0", method: "tools/list", id: 1 });
  const afterRequest = Date.now();

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

  const expiresAtHeader = response.headers["x-session-expires-at"];
  expect(expiresAtHeader).toBeDefined();
  expect(typeof expiresAtHeader).toBe("string");

  // Type guard ensures expiresAtHeader is string at this point
  if (typeof expiresAtHeader === "string") {
    const expiresAt = new Date(expiresAtHeader);
    const expiresAtMs = expiresAt.getTime();
    const expectedMinMs = beforeRequest + 86400 * 1000;
    const expectedMaxMs = afterRequest + 86400 * 1000;

    expect(expiresAtMs).toBeGreaterThanOrEqual(expectedMinMs);
    expect(expiresAtMs).toBeLessThanOrEqual(expectedMaxMs);
  }
});

Benefits

For Clients

  1. Predictable expiration: Clients know exactly when their session will expire
  2. Proactive management: Can send requests before expiration to refresh session
  3. Better UX: Can warn users before disconnection
  4. No surprise disconnects: Users are notified in advance

Client Implementation Example

typescript
class MCPClient {
  private sessionExpiresAt?: Date;

  async sendRequest(request: any): Promise<any> {
    const response = await fetch('/mcp', {
      method: 'POST',
      headers: {
        'mcp-session-id': this.sessionId,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(request),
    });

    // Update expiration time from response header
    const expiresAtHeader = response.headers.get('x-session-expires-at');
    if (expiresAtHeader) {
      this.sessionExpiresAt = new Date(expiresAtHeader);
      this.scheduleExpirationWarning();
    }

    return response.json();
  }

  private scheduleExpirationWarning(): void {
    if (!this.sessionExpiresAt) return;

    const timeUntilExpiry = this.sessionExpiresAt.getTime() - Date.now();
    const warningTime = 5 * 60 * 1000; // 5 minutes

    if (timeUntilExpiry > warningTime) {
      setTimeout(() => {
        this.emit('session-expiring-soon', {
          expiresAt: this.sessionExpiresAt,
          minutesRemaining: 5,
        });
      }, timeUntilExpiry - warningTime);
    }
  }
}

For Operations

  1. Consistent behavior: All sessions have expiration information
  2. Client debugging: Clients can log expiration times for troubleshooting
  3. Monitoring: Can track session lifetimes from client perspective

Sliding Window Behavior

Because Seed uses a sliding window TTL (refreshed on each access), the expiration time updates with every request:

Example Timeline:

10:00 AM - Initialize session
          X-Session-Expires-At: 10:00 AM + 24h = 10:00 AM (next day)

10:30 AM - Send request
          TTL refreshed (session touched)
          X-Session-Expires-At: 10:30 AM + 24h = 10:30 AM (next day)

2:15 PM - Send request
          TTL refreshed again
          X-Session-Expires-At: 2:15 PM + 24h = 2:15 PM (next day)

Clients should update their local expiration tracking on every response to reflect the sliding window.

Configuration

No new configuration required. The expiration time is calculated using existing session TTL:

typescript
// src/config/session.ts
export const sessionConfig = {
  ttlSeconds: parseInt(process.env.MCP_SESSION_TTL_SECONDS ?? "86400", 10), // 24 hours
};

Limitations

  1. Header only approach: No MCP notification protocol extension (would require spec change)
  2. Approximation: Expiration time is calculated at response time, not session creation
  3. Server clock: Assumes client and server clocks are synchronized (ISO 8601 absolute time)

Future Enhancements

  1. MCP Notification: Add MCP protocol notification 5 minutes before expiration (requires spec change)
  2. Session metadata endpoint: GET /sessions/:id to query expiration time independently
  3. Warning threshold config: Configurable threshold for client warnings

Implementation Date

Completed: 2026-01-06 Actual Effort: 1.5 hours Test Coverage: 2 additional tests (100% coverage for new code)

References

  • src/routes/mcp.ts - MCP Routes Implementation
  • src/services/session-store.ts - Session Store Service
  • src/config/session.ts - Session Configuration

Released under the MIT License.