Skip to content

Token Update Notification

Status:IMPLEMENTEDPriority: 🔴 HIGH Implementation Time: 2-3 hours Risk Level: LOW Impact: Improved user experience with seamless token updates

← Back to Enhancements


Problem Statement

When the server automatically refreshes a user's access token, the client is not notified of the new token. This creates a critical UX gap:

Current Flow:

  1. Client sends request with access token (expires in 2 minutes)
  2. Server detects expiration and refreshes token
  3. Server uses new token internally for this request
  4. Server returns response to client
  5. Client still has old token
  6. Client sends next request with old (now expired) token
  7. Request fails with 401 Unauthorized

Result: The client must retry the request and rely on reactive token refresh, creating unnecessary latency and error handling complexity.


Current Behavior

Issues:

  • Client unaware of token refresh
  • Redundant refresh operations
  • Increased request latency (failed request + retry)
  • Poor user experience with transient failures

Proposed Solution

Implement response header notification to inform clients of refreshed tokens. This is the simplest and most compatible approach.

Add custom headers to responses when token is refreshed:

http
HTTP/1.1 200 OK
X-Token-Refreshed: true
X-New-Access-Token: eyJhbGc...
X-Token-Expires-At: 2026-01-06T15:30:00Z
X-Token-Type: Bearer
Content-Type: application/json

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

Advantages:

  • ✅ Simple implementation
  • ✅ Works with existing MCP protocol
  • ✅ No spec changes required
  • ✅ Backward compatible (clients can ignore headers)
  • ✅ Low overhead

Disadvantages:

  • ⚠️ Requires client-side implementation to read headers
  • ⚠️ Not part of MCP specification

Implementation

1. Token Refresh Context

Implementation: Request property approach (simpler than AsyncLocalStorage)

Added to src/middleware/token-notification.ts:

typescript
import type { Request, Response, NextFunction } from "express";
import { config } from "../config/index.js";
import { logger } from "../services/logger.js";

/**
 * Extended request type with token refresh flag
 */
export interface TokenRefreshRequest extends Request {
  tokenRefreshed?: {
    sessionId: string;
    userSub: string;
    newAccessToken: string;
    expiresAt: number;
  };
}

/**
 * Middleware to add token update notification headers to responses
 *
 * When a token is refreshed during request processing, this middleware:
 * 1. Checks if the tokenRefreshed flag was set in the request
 * 2. Adds response headers to notify the client:
 *    - X-Token-Refreshed: true
 *    - X-New-Access-Token: <new token>
 *    - X-Token-Expires-At: <expiration timestamp>
 */
export function tokenNotificationMiddleware(
  req: Request,
  res: Response,
  next: NextFunction,
): void {
  // Check if token was refreshed during this request
  const tokenRefreshReq = req as TokenRefreshRequest;

  if (tokenRefreshReq.tokenRefreshed && config.tokens.autoRefreshEnabled) {
    const { sessionId, userSub, newAccessToken, expiresAt } = tokenRefreshReq.tokenRefreshed;

    // Add notification headers
    res.setHeader("X-Token-Refreshed", "true");
    res.setHeader("X-New-Access-Token", newAccessToken);
    res.setHeader("X-Token-Expires-At", expiresAt.toString());

    logger.debug("Added token refresh notification headers", {
      sessionId,
      userSub,
      expiresAt,
    });
  }

  next();
}

2. Auth Middleware Updates

Updated src/middleware/auth.ts to set refresh flag:

typescript
// In attemptTokenRefresh function - changed return type
export interface TokenRefreshResult {
  accessToken: string;
  expiresAt: number;
}

export async function attemptTokenRefresh(
  sessionId: string,
  userSub: string,
): Promise<TokenRefreshResult | null> {
  // ... existing refresh logic ...

  return {
    accessToken: data.access_token,
    expiresAt,
  };
}

// In authMiddleware - proactive refresh
const refreshResult = await attemptTokenRefresh(sessionId, payload.sub);

if (refreshResult) {
  // Set flag for token notification middleware
  (req as TokenRefreshRequest).tokenRefreshed = {
    sessionId,
    userSub: payload.sub,
    newAccessToken: refreshResult.accessToken,
    expiresAt: refreshResult.expiresAt,
  };
  logger.info("Token refreshed successfully", { sessionId, sub: payload.sub });
}

// In authMiddleware - reactive refresh (after expiration)
const refreshResult = await attemptTokenRefresh(sessionId, expiredPayload.sub);

if (refreshResult) {
  // ... verify new token ...

  // Set flag for token notification middleware
  (req as TokenRefreshRequest).tokenRefreshed = {
    sessionId,
    userSub: newPayload.sub,
    newAccessToken: refreshResult.accessToken,
    expiresAt: refreshResult.expiresAt,
  };

  // Continue processing request
  next();
  return;
}

3. Register Middleware

Updated src/app.ts to include notification middleware:

typescript
import { tokenNotificationMiddleware } from "./middleware/token-notification.js";

// ... existing middleware ...

app.use(validateOrigin); // Origin validation
app.use(authMiddleware); // JWT validation
app.use(tokenNotificationMiddleware); // Add token refresh notification headers
app.use(routes); // Route handlers

Important: Notification middleware must be placed AFTER auth middleware (which sets the refresh flag) and BEFORE route handlers (which send the response).


Client Integration

Claude Desktop / Claude Code

Clients should check response headers after each request:

typescript
// Example client-side implementation
async function mcpRequest(
  sessionId: string,
  accessToken: string,
  request: any
): Promise<Response> {
  const response = await fetch("https://seed.example.com/mcp", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${accessToken}`,
      "mcp-session-id": sessionId,
    },
    body: JSON.stringify(request),
  });

  // Check if token was refreshed
  const tokenRefreshed = response.headers.get("X-Token-Refreshed");
  if (tokenRefreshed === "true") {
    const newAccessToken = response.headers.get("X-New-Access-Token");
    const expiresAt = response.headers.get("X-Token-Expires-At");

    if (newAccessToken) {
      // Update stored token
      await updateStoredToken({
        accessToken: newAccessToken,
        expiresAt: expiresAt ? new Date(expiresAt) : undefined,
      });

      console.log("Access token refreshed by server", {
        expiresAt,
      });
    }
  }

  return response;
}

Benefits for Clients

Before (Without Notification):

typescript
// Request 1: Server refreshes token
const response1 = await mcpRequest(sessionId, oldToken, request1);
// Client still has oldToken

// Request 2: Fails because oldToken expired
try {
  const response2 = await mcpRequest(sessionId, oldToken, request2);
} catch (error) {
  // Must retry with reactive refresh
  const response2 = await mcpRequest(sessionId, oldToken, request2);
}

After (With Notification):

typescript
// Request 1: Server refreshes token and notifies client
const response1 = await mcpRequest(sessionId, oldToken, request1);
// Client receives newToken in headers and updates storage

// Request 2: Uses newToken, succeeds immediately
const response2 = await mcpRequest(sessionId, newToken, request2);

Configuration

No additional configuration required. Token refresh headers are automatically added when tokens are refreshed.


Edge Cases

1. Multiple Concurrent Requests

Scenario: 5 requests sent simultaneously, token gets refreshed

Behavior:

  • First request acquires lock, refreshes token, sets context
  • Subsequent 4 requests wait for lock, get refreshed token from store
  • Only first request has X-Token-Refreshed: true header
  • Other requests return without refresh notification

Client Handling:

  • Client receives first response with new token
  • Updates stored token
  • Subsequent responses use updated token (already stored)

2. Client Ignores Headers (Backward Compatibility)

Scenario: Old client doesn't check X-Token-Refreshed header

Behavior:

  • Server still refreshes token internally
  • Client continues using old token
  • Next request triggers reactive refresh
  • Same behavior as before enhancement

Result: Backward compatible, no breaking changes

3. Token Refresh Fails

Scenario: Server attempts refresh but IdP returns error

Behavior:

  • attemptTokenRefresh() returns null
  • Context remains wasRefreshed: false
  • No headers added to response
  • Client continues with current token
  • Next request triggers another refresh attempt

Testing

Unit Tests

typescript
describe("Token Refresh Headers", () => {
  it("should add headers when token is refreshed", async () => {
    const mockContext: TokenRefreshContext = {
      wasRefreshed: true,
      newAccessToken: "new-token-value",
      expiresAt: new Date("2026-01-06T15:30:00Z"),
      tokenType: "Bearer",
    };

    // Mock async local storage
    tokenRefreshStorage.run(mockContext, async () => {
      const req = createMockRequest();
      const res = createMockResponse();

      await tokenRefreshHeaders(req, res, () => {});

      res.json({ result: "success" });

      expect(res.getHeader("X-Token-Refreshed")).toBe("true");
      expect(res.getHeader("X-New-Access-Token")).toBe("new-token-value");
      expect(res.getHeader("X-Token-Expires-At")).toBe(
        "2026-01-06T15:30:00.000Z"
      );
      expect(res.getHeader("X-Token-Type")).toBe("Bearer");
    });
  });

  it("should not add headers when token not refreshed", async () => {
    const mockContext: TokenRefreshContext = {
      wasRefreshed: false,
    };

    tokenRefreshStorage.run(mockContext, async () => {
      const req = createMockRequest();
      const res = createMockResponse();

      await tokenRefreshHeaders(req, res, () => {});

      res.json({ result: "success" });

      expect(res.getHeader("X-Token-Refreshed")).toBeUndefined();
      expect(res.getHeader("X-New-Access-Token")).toBeUndefined();
    });
  });
});

describe("Token Refresh Integration", () => {
  it("should notify client of token refresh", async () => {
    // Setup: Token expiring soon
    const session = await createTestSession({
      tokenExpiresIn: 120, // 2 minutes
    });

    // Request that triggers proactive refresh
    const response = await request(app)
      .post("/mcp")
      .set("Authorization", `Bearer ${session.accessToken}`)
      .set("mcp-session-id", session.sessionId)
      .send({
        jsonrpc: "2.0",
        method: "tools/list",
        id: 1,
      });

    expect(response.status).toBe(200);
    expect(response.headers["x-token-refreshed"]).toBe("true");
    expect(response.headers["x-new-access-token"]).toBeDefined();
    expect(response.headers["x-token-expires-at"]).toBeDefined();
  });
});

Acceptance Criteria

  • [x] Response header middleware implemented
  • [x] Token refresh context tracked (using request properties)
  • [x] Headers added to response when token is refreshed
  • [ ] CORS configuration exposes custom headers (optional - clients can still access)
  • [x] Backward compatible (clients can ignore headers)
  • [x] No performance impact (headers only when refreshed)
  • [x] Comprehensive logging for debugging
  • [x] Unit tests with >90% coverage
  • [x] Integration tests with actual token refresh (via auth middleware tests)
  • [x] Documentation updated with client examples

Metrics

prometheus
# Track how often clients receive refresh notifications
http_responses_with_token_refresh_total 45

# Responses by endpoint
http_responses_with_token_refresh_total{endpoint="/mcp"} 40
http_responses_with_token_refresh_total{endpoint="/oauth/token"} 5

Add to src/services/metrics.ts:

typescript
export const responsesWithTokenRefresh = new Counter({
  name: "http_responses_with_token_refresh_total",
  help: "Total HTTP responses that included token refresh headers",
  labelNames: ["endpoint"],
  registers: [register],
});

Update middleware to track:

typescript
if (context?.wasRefreshed && context.newAccessToken) {
  // Add headers...
  responsesWithTokenRefresh.inc({ endpoint: req.path });
}

Future Enhancements

Option B: MCP Protocol Extension (Long-term)

Propose MCP specification extension for server-initiated token updates:

typescript
// Server sends notification to client
{
  "jsonrpc": "2.0",
  "method": "notifications/auth/token_refreshed",
  "params": {
    "access_token": "eyJhbGc...",
    "token_type": "Bearer",
    "expires_at": 1704553800,
    "refresh_token": "optional-new-refresh-token"
  }
}

Advantages:

  • ✅ Part of MCP protocol (standard)
  • ✅ Bidirectional notification
  • ✅ Can include refresh token

Disadvantages:

  • ❌ Requires MCP spec change
  • ❌ Requires client SDK updates
  • ❌ Longer timeline (months)

Next Steps:

  1. Implement Option A (response headers) immediately
  2. Propose Option B to MCP working group
  3. Migrate to Option B when spec is approved


References

Released under the MIT License.