Token Update Notification
Status: ✅ IMPLEMENTEDPriority: 🔴 HIGH Implementation Time: 2-3 hours Risk Level: LOW Impact: Improved user experience with seamless token updates
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:
- Client sends request with access token (expires in 2 minutes)
- Server detects expiration and refreshes token
- Server uses new token internally for this request
- Server returns response to client
- Client still has old token ❌
- Client sends next request with old (now expired) token
- 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.
Option A: Response Headers (Recommended)
Add custom headers to responses when token is refreshed:
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:
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:
// 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:
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 handlersImportant: 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:
// 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):
// 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):
// 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: trueheader - 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()returnsnull- Context remains
wasRefreshed: false - No headers added to response
- Client continues with current token
- Next request triggers another refresh attempt
Testing
Unit Tests
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
# 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"} 5Add to src/services/metrics.ts:
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:
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:
// 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:
- Implement Option A (response headers) immediately
- Propose Option B to MCP working group
- Migrate to Option B when spec is approved
Related Enhancements
- Automatic Token Refresh - Base token refresh implementation
- Token Refresh Race Condition - Prevent duplicate refreshes
- Token Revocation - Manual token invalidation