@nestr/mcp 0.1.68 → 0.1.69

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/http.js CHANGED
@@ -853,6 +853,7 @@ app.post("/oauth/token", tokenLimiter, express.urlencoded({ extended: true }), a
853
853
  });
854
854
  export const sessions = {};
855
855
  let shuttingDown = false;
856
+ let inFlightRequests = 0;
856
857
  /**
857
858
  * Session coalescing for poorly-behaved clients that create a new MCP connection per tool call.
858
859
  * If an initialize request arrives with the same auth token + client name as a recent session
@@ -863,7 +864,12 @@ let shuttingDown = false;
863
864
  export const SESSION_COALESCE_WINDOW_MS = 10 * 60 * 1000; // 10 minutes
864
865
  export const SESSION_COALESCE_MAX_INITS = 5;
865
866
  const SESSION_STALE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes without activity
866
- // Periodically clean up dead sessions (closed SSE + stale)
867
+ // Debounce for touchMcpSession. We refresh the Redis TTL at most this often
868
+ // so a chatty client doesn't hammer the store.
869
+ const MCP_SESSION_TOUCH_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
870
+ // Periodically clean up dead sessions (closed SSE + stale).
871
+ // We only drop the in-memory entry — the persistent Redis record lives on until
872
+ // its own TTL so a late-returning client can still rehydrate.
867
873
  // .unref() so this timer doesn't prevent process exit (tests, graceful shutdown)
868
874
  setInterval(() => {
869
875
  const now = Date.now();
@@ -875,6 +881,23 @@ setInterval(() => {
875
881
  }
876
882
  }
877
883
  }, 60000).unref();
884
+ /**
885
+ * Refresh the Redis TTL of a persisted MCP session, debounced per-session so
886
+ * an active client doesn't hammer the store.
887
+ */
888
+ async function maybeTouchMcpSession(sessionId, session) {
889
+ const now = Date.now();
890
+ const last = session.lastPersistedAt ?? 0;
891
+ if (now - last < MCP_SESSION_TOUCH_INTERVAL_MS)
892
+ return;
893
+ session.lastPersistedAt = now;
894
+ try {
895
+ await getStore().touchMcpSession(sessionId);
896
+ }
897
+ catch (e) {
898
+ console.error("[McpSession] touch failed:", e instanceof Error ? e.message : e);
899
+ }
900
+ }
878
901
  export function findCoalescableSession(authToken, mcpClient) {
879
902
  const now = Date.now();
880
903
  let bestMatch;
@@ -917,6 +940,245 @@ function getCachedIdentity(token) {
917
940
  function cacheIdentity(token, userId, userName) {
918
941
  identityCache.set(token, { userId, userName, expiresAt: Date.now() + IDENTITY_CACHE_TTL_MS });
919
942
  }
943
+ /**
944
+ * Build an in-memory MCP session: NestrClient + MCP server + transport, all
945
+ * registered in the local sessions map. Used for both fresh sessions (init
946
+ * request from the client) and rehydrated sessions (sessionId we no longer
947
+ * hold in memory but exists in Redis from a previous pod).
948
+ *
949
+ * For rehydrated sessions, the transport is pre-marked as already initialized
950
+ * so the SDK skips the init handshake — the client never knows the server
951
+ * restarted.
952
+ */
953
+ function buildMcpSession(opts) {
954
+ const sessionStartTime = Date.now();
955
+ let sessionRef;
956
+ const client = new NestrClient({
957
+ apiKey: opts.authToken,
958
+ baseUrl: process.env.NESTR_API_BASE,
959
+ mcpClient: opts.mcpClient,
960
+ // tokenProvider enables server-side token refresh for stored sessions (browser flow).
961
+ // In the standard MCP OAuth flow there's no stored session — the client manages refresh.
962
+ // When tokenProvider is undefined, NestrClient lets 401 propagate to the client.
963
+ tokenProvider: opts.hasStoredOAuthSession ? async () => {
964
+ const session = await getOAuthSession(opts.authToken);
965
+ if (!session) {
966
+ // Stored session expired and refresh failed — surface a 401 to the
967
+ // client without ripping the MCP session out from under the protocol.
968
+ // The HTTP-level pre-check in the POST handler will normally catch
969
+ // this first; this is the in-flight fallback.
970
+ const sid = sessionRef?.transport?.sessionId;
971
+ console.log(`OAuth session expired mid-session (MCP session: ${sid ?? "unknown"}). Returning 401 to client.`);
972
+ throw new NestrApiError("OAuth session expired", 401, "/", {
973
+ code: "AUTH_FAILED",
974
+ hint: "Your OAuth session has expired or the server was restarted. Reconnect to the MCP server to re-authenticate.",
975
+ });
976
+ }
977
+ return session.accessToken;
978
+ } : undefined,
979
+ });
980
+ const server = createServer({
981
+ client,
982
+ userId: opts.userId,
983
+ userName: opts.userName,
984
+ onToolCall: (toolName, args, success, error) => {
985
+ try {
986
+ if (sessionRef?.analytics) {
987
+ if (sessionRef.toolCallCount !== undefined) {
988
+ sessionRef.toolCallCount++;
989
+ }
990
+ analytics.trackToolCall(sessionRef.analytics, {
991
+ toolName,
992
+ workspaceId: args.workspaceId,
993
+ success,
994
+ errorCode: error,
995
+ });
996
+ }
997
+ }
998
+ catch (e) {
999
+ console.error("[Analytics] Tool call tracking error:", e);
1000
+ }
1001
+ },
1002
+ });
1003
+ const transport = new StreamableHTTPServerTransport({
1004
+ sessionIdGenerator: () => opts.rehydrateFor ?? randomUUID(),
1005
+ enableJsonResponse: opts.wantsJsonOnly,
1006
+ onsessioninitialized: (newSessionId) => {
1007
+ // Only fires for fresh sessions — rehydrated transports skip the handshake.
1008
+ console.log(`Session initialized: ${newSessionId}${opts.mcpClient ? ` (client: ${opts.mcpClient})` : ""}`);
1009
+ const sessionData = {
1010
+ transport,
1011
+ server,
1012
+ authToken: opts.authToken,
1013
+ mcpClient: opts.mcpClient,
1014
+ isApiKey: opts.isApiKey,
1015
+ wantsJsonOnly: opts.wantsJsonOnly,
1016
+ hasStoredOAuthSession: opts.hasStoredOAuthSession,
1017
+ userId: opts.userId,
1018
+ userName: opts.userName,
1019
+ analytics: opts.analyticsCtx,
1020
+ toolCallCount: 0,
1021
+ sessionStartTime,
1022
+ lastActivityAt: Date.now(),
1023
+ initCallCount: 1,
1024
+ lastPersistedAt: Date.now(),
1025
+ };
1026
+ sessions[newSessionId] = sessionData;
1027
+ sessionRef = sessionData;
1028
+ // Persist for rehydration after restart
1029
+ getStore().storeMcpSession(newSessionId, {
1030
+ authToken: opts.authToken,
1031
+ mcpClient: opts.mcpClient,
1032
+ userId: opts.userId,
1033
+ userName: opts.userName,
1034
+ isApiKey: opts.isApiKey,
1035
+ wantsJsonOnly: opts.wantsJsonOnly,
1036
+ hasStoredOAuthSession: opts.hasStoredOAuthSession,
1037
+ createdAt: Date.now(),
1038
+ }).catch(e => console.error("[McpSession] Failed to persist session:", e instanceof Error ? e.message : e));
1039
+ if (opts.analyticsCtx) {
1040
+ try {
1041
+ analytics.trackSessionStart(opts.analyticsCtx, {
1042
+ hasToken: true,
1043
+ authMethod: opts.isApiKey ? "api_key" : "oauth",
1044
+ });
1045
+ }
1046
+ catch (e) {
1047
+ console.error("[Analytics] Session start error:", e);
1048
+ }
1049
+ }
1050
+ },
1051
+ });
1052
+ transport.onclose = () => {
1053
+ const sid = transport.sessionId;
1054
+ if (sid && sessions[sid]) {
1055
+ const session = sessions[sid];
1056
+ if (session.analytics && session.sessionStartTime) {
1057
+ try {
1058
+ const duration = Math.floor((Date.now() - session.sessionStartTime) / 1000);
1059
+ analytics.trackSessionEnd(session.analytics, {
1060
+ duration,
1061
+ toolCallCount: session.toolCallCount || 0,
1062
+ });
1063
+ }
1064
+ catch (e) {
1065
+ console.error("[Analytics] Session end tracking error:", e);
1066
+ }
1067
+ }
1068
+ console.log(`Session closed: ${sid}`);
1069
+ delete sessions[sid];
1070
+ }
1071
+ // Drop persisted record on explicit close (DELETE /mcp). Pod-shutdown does
1072
+ // NOT call transport.close() so persisted records survive deploys.
1073
+ if (sid) {
1074
+ getStore().removeMcpSession(sid).catch(e => console.error("[McpSession] Failed to remove persisted session:", e instanceof Error ? e.message : e));
1075
+ }
1076
+ };
1077
+ // For rehydration we mark the transport as already initialized and register
1078
+ // the SessionData immediately. The SDK exposes no public API for this, so
1079
+ // we touch private fields — the alternative is forcing every client to
1080
+ // re-init on every deploy, which is the bug we're fixing.
1081
+ if (opts.rehydrateFor) {
1082
+ const inner = transport._webStandardTransport;
1083
+ inner._initialized = true;
1084
+ inner.sessionId = opts.rehydrateFor;
1085
+ const sessionData = {
1086
+ transport,
1087
+ server,
1088
+ authToken: opts.authToken,
1089
+ mcpClient: opts.mcpClient,
1090
+ isApiKey: opts.isApiKey,
1091
+ wantsJsonOnly: opts.wantsJsonOnly,
1092
+ hasStoredOAuthSession: opts.hasStoredOAuthSession,
1093
+ userId: opts.userId,
1094
+ userName: opts.userName,
1095
+ analytics: opts.analyticsCtx,
1096
+ toolCallCount: 0,
1097
+ sessionStartTime,
1098
+ lastActivityAt: Date.now(),
1099
+ initCallCount: 0,
1100
+ lastPersistedAt: Date.now(),
1101
+ };
1102
+ sessions[opts.rehydrateFor] = sessionData;
1103
+ sessionRef = sessionData;
1104
+ console.log(`[Rehydrate] Rebuilt session ${opts.rehydrateFor} (client: ${opts.mcpClient ?? "unknown"})`);
1105
+ return sessionData;
1106
+ }
1107
+ // Fresh session: caller still needs to attach via server.connect(transport)
1108
+ // and call transport.handleRequest() to drive the init handshake.
1109
+ return {
1110
+ transport,
1111
+ server,
1112
+ authToken: opts.authToken,
1113
+ mcpClient: opts.mcpClient,
1114
+ isApiKey: opts.isApiKey,
1115
+ wantsJsonOnly: opts.wantsJsonOnly,
1116
+ hasStoredOAuthSession: opts.hasStoredOAuthSession,
1117
+ userId: opts.userId,
1118
+ userName: opts.userName,
1119
+ analytics: opts.analyticsCtx,
1120
+ lastActivityAt: Date.now(),
1121
+ initCallCount: 0,
1122
+ };
1123
+ }
1124
+ /**
1125
+ * Look up a sessionId in the persistent store and rebuild the in-memory
1126
+ * session if found. Cross-checks the auth token to refuse rehydration when
1127
+ * the request token doesn't match the stored one (defense in depth).
1128
+ *
1129
+ * Returns the rebuilt SessionData (already in `sessions` map) or undefined.
1130
+ */
1131
+ async function rehydrateSession(sessionId, authToken) {
1132
+ let stored;
1133
+ try {
1134
+ stored = await getStore().getMcpSession(sessionId);
1135
+ }
1136
+ catch (e) {
1137
+ console.error("[Rehydrate] Failed to read MCP session from store:", e instanceof Error ? e.message : e);
1138
+ return undefined;
1139
+ }
1140
+ if (!stored)
1141
+ return undefined;
1142
+ // Cross-check token: rotated tokens or hijacking attempts → refuse, force re-init.
1143
+ if (stored.authToken !== authToken) {
1144
+ console.warn(`[Rehydrate] Session ${sessionId} found but token mismatch — refusing rehydration`);
1145
+ return undefined;
1146
+ }
1147
+ let analyticsCtx;
1148
+ try {
1149
+ analyticsCtx = analytics.isEnabled() ? {
1150
+ clientId: analytics.generateClientId(),
1151
+ userId: stored.userId,
1152
+ mcpClient: stored.mcpClient,
1153
+ transport: "http",
1154
+ } : undefined;
1155
+ }
1156
+ catch (e) {
1157
+ console.error("[Analytics] Context creation error:", e);
1158
+ }
1159
+ const session = buildMcpSession({
1160
+ authToken: stored.authToken,
1161
+ isApiKey: stored.isApiKey,
1162
+ mcpClient: stored.mcpClient,
1163
+ userId: stored.userId,
1164
+ userName: stored.userName,
1165
+ wantsJsonOnly: stored.wantsJsonOnly,
1166
+ hasStoredOAuthSession: stored.hasStoredOAuthSession,
1167
+ analyticsCtx,
1168
+ rehydrateFor: sessionId,
1169
+ });
1170
+ // Wire the protocol layer to the transport. server.connect() doesn't touch
1171
+ // _initialized so our hack stays valid.
1172
+ await session.server.connect(session.transport);
1173
+ // Refresh TTL: this session is being actively used.
1174
+ try {
1175
+ await getStore().touchMcpSession(sessionId);
1176
+ }
1177
+ catch (e) {
1178
+ console.error("[McpSession] touch on rehydrate failed:", e instanceof Error ? e.message : e);
1179
+ }
1180
+ return session;
1181
+ }
920
1182
  /**
921
1183
  * Extract authentication token from request headers
922
1184
  *
@@ -948,6 +1210,21 @@ function buildWwwAuthenticateHeader(req) {
948
1210
  const metadataUrl = `${baseUrl}/.well-known/oauth-protected-resource`;
949
1211
  return `Bearer resource_metadata="${metadataUrl}"`;
950
1212
  }
1213
+ // In-flight request tracking for /mcp so the shutdown handler can wait for
1214
+ // outstanding tool calls to finish before the pod terminates.
1215
+ app.use("/mcp", (_req, res, next) => {
1216
+ inFlightRequests++;
1217
+ let decremented = false;
1218
+ const decrement = () => {
1219
+ if (decremented)
1220
+ return;
1221
+ decremented = true;
1222
+ inFlightRequests--;
1223
+ };
1224
+ res.on("finish", decrement);
1225
+ res.on("close", decrement);
1226
+ next();
1227
+ });
951
1228
  /**
952
1229
  * MCP POST endpoint - handles JSON-RPC requests
953
1230
  */
@@ -970,17 +1247,42 @@ app.post("/mcp", async (req, res) => {
970
1247
  if (wantsJsonOnly) {
971
1248
  req.headers.accept = `${acceptHeader}, text/event-stream`;
972
1249
  }
973
- // Check for existing session
974
- if (sessionId && sessions[sessionId]) {
975
- const session = sessions[sessionId];
976
- session.lastActivityAt = Date.now();
977
- await session.transport.handleRequest(req, res, req.body);
978
- return;
979
- }
980
- // Session ID was provided but not found - return 404 per MCP spec
981
- // This signals compliant clients to re-initialize automatically
1250
+ // Check for existing session, or rehydrate from persistent store if the
1251
+ // pod has restarted since this client last connected.
982
1252
  if (sessionId) {
983
- console.log(`Session not found: ${sessionId} (server may have restarted)`);
1253
+ let session = sessions[sessionId];
1254
+ if (!session && authToken) {
1255
+ session = await rehydrateSession(sessionId, authToken) ?? undefined;
1256
+ }
1257
+ if (session) {
1258
+ // For sessions held over from a previous pod, the OAuth token may have
1259
+ // expired. Pre-check before invoking the transport so we can return a
1260
+ // proper HTTP 401 + WWW-Authenticate that triggers MCP client re-auth,
1261
+ // instead of wrapping the failure as a tool error the client ignores.
1262
+ if (session.hasStoredOAuthSession) {
1263
+ const oauthSession = await getOAuthSession(session.authToken);
1264
+ if (!oauthSession) {
1265
+ res.status(401);
1266
+ res.setHeader("WWW-Authenticate", buildWwwAuthenticateHeader(req));
1267
+ res.json({
1268
+ jsonrpc: "2.0",
1269
+ error: {
1270
+ code: -32001,
1271
+ message: "OAuth session expired. Reconnect to re-authenticate.",
1272
+ },
1273
+ id: req.body?.id ?? null,
1274
+ });
1275
+ return;
1276
+ }
1277
+ }
1278
+ session.lastActivityAt = Date.now();
1279
+ await maybeTouchMcpSession(sessionId, session);
1280
+ await session.transport.handleRequest(req, res, req.body);
1281
+ return;
1282
+ }
1283
+ // Session ID was provided but not found anywhere - return 404 per MCP spec.
1284
+ // Compliant clients will re-initialize automatically.
1285
+ console.log(`Session not found: ${sessionId} (no persisted record either)`);
984
1286
  res.status(404).json({
985
1287
  jsonrpc: "2.0",
986
1288
  error: {
@@ -1135,120 +1437,21 @@ app.post("/mcp", async (req, res) => {
1135
1437
  catch (e) {
1136
1438
  console.error("[Analytics] Context creation error:", e);
1137
1439
  }
1138
- // Track tool calls for analytics
1139
- // We use a mutable ref so the callback can access the session's analytics context
1140
- // after the session is initialized, and to allow tokenProvider to invalidate the session
1141
- let sessionRef;
1142
1440
  // Check if we have a stored session for this token (browser flow).
1143
1441
  // Standard MCP OAuth clients manage tokens client-side and won't have a stored session.
1144
1442
  const hasStoredSession = !isApiKey && !!(await getStore().getSession(authToken));
1145
- // Create new session with the auth token and MCP client info
1146
- const client = new NestrClient({
1147
- apiKey: authToken,
1148
- baseUrl: process.env.NESTR_API_BASE,
1443
+ const session = buildMcpSession({
1444
+ authToken,
1445
+ isApiKey,
1149
1446
  mcpClient: mcpClientName,
1150
- // tokenProvider enables server-side token refresh for stored sessions (browser flow).
1151
- // In the standard MCP OAuth flow, there's no stored session — the client manages refresh.
1152
- // When tokenProvider is undefined, NestrClient lets 401 propagate to the client.
1153
- tokenProvider: hasStoredSession ? async () => {
1154
- const session = await getOAuthSession(authToken);
1155
- if (!session) {
1156
- // Stored session expired and refresh failed — return a 401 error to the client.
1157
- // Do NOT delete the MCP session here: forcibly removing it from the sessions map
1158
- // while the MCP protocol is still using it causes "MCP session terminated" errors
1159
- // and prevents the client from cleanly re-authenticating. Let the client handle
1160
- // the 401 and reconnect on its own terms.
1161
- const sid = sessionRef?.transport?.sessionId;
1162
- console.log(`OAuth session expired mid-session (MCP session: ${sid ?? "unknown"}). Returning 401 to client.`);
1163
- throw new NestrApiError("OAuth session expired", 401, "/", {
1164
- code: "AUTH_FAILED",
1165
- hint: "Your OAuth session has expired or the server was restarted. Reconnect to the MCP server to re-authenticate.",
1166
- });
1167
- }
1168
- return session.accessToken;
1169
- } : undefined,
1170
- });
1171
- const server = createServer({
1172
- client,
1173
1447
  userId,
1174
1448
  userName,
1175
- onToolCall: (toolName, args, success, error) => {
1176
- try {
1177
- if (sessionRef?.analytics) {
1178
- // Increment tool call count
1179
- if (sessionRef.toolCallCount !== undefined) {
1180
- sessionRef.toolCallCount++;
1181
- }
1182
- // Track the tool call
1183
- analytics.trackToolCall(sessionRef.analytics, {
1184
- toolName,
1185
- workspaceId: args.workspaceId,
1186
- success,
1187
- errorCode: error,
1188
- });
1189
- }
1190
- }
1191
- catch (e) {
1192
- console.error("[Analytics] Tool call tracking error:", e);
1193
- }
1194
- },
1195
- });
1196
- const sessionStartTime = Date.now();
1197
- const transport = new StreamableHTTPServerTransport({
1198
- sessionIdGenerator: () => randomUUID(),
1199
- enableJsonResponse: wantsJsonOnly,
1200
- onsessioninitialized: (newSessionId) => {
1201
- console.log(`Session initialized: ${newSessionId}${mcpClientName ? ` (client: ${mcpClientName})` : ""}`);
1202
- sessions[newSessionId] = {
1203
- transport,
1204
- server,
1205
- authToken,
1206
- mcpClient: mcpClientName,
1207
- analytics: analyticsCtx,
1208
- toolCallCount: 0,
1209
- sessionStartTime,
1210
- lastActivityAt: Date.now(),
1211
- initCallCount: 1, // This is the first (original) init
1212
- };
1213
- // Set ref for tool call tracking callback
1214
- sessionRef = sessions[newSessionId];
1215
- // Track session start (wrapped to never break MCP)
1216
- if (analyticsCtx) {
1217
- try {
1218
- analytics.trackSessionStart(analyticsCtx, {
1219
- hasToken: true,
1220
- authMethod: isApiKey ? "api_key" : "oauth",
1221
- });
1222
- }
1223
- catch (e) {
1224
- console.error("[Analytics] Session start error:", e);
1225
- }
1226
- }
1227
- },
1449
+ wantsJsonOnly,
1450
+ hasStoredOAuthSession: hasStoredSession,
1451
+ analyticsCtx,
1228
1452
  });
1229
- transport.onclose = () => {
1230
- const sid = transport.sessionId;
1231
- if (sid && sessions[sid]) {
1232
- const session = sessions[sid];
1233
- // Track session end (wrapped to never break MCP)
1234
- if (session.analytics && session.sessionStartTime) {
1235
- try {
1236
- const duration = Math.floor((Date.now() - session.sessionStartTime) / 1000);
1237
- analytics.trackSessionEnd(session.analytics, {
1238
- duration,
1239
- toolCallCount: session.toolCallCount || 0,
1240
- });
1241
- }
1242
- catch (e) {
1243
- console.error("[Analytics] Session end tracking error:", e);
1244
- }
1245
- }
1246
- console.log(`Session closed: ${sid}`);
1247
- delete sessions[sid];
1248
- }
1249
- };
1250
- await server.connect(transport);
1251
- await transport.handleRequest(req, res, req.body);
1453
+ await session.server.connect(session.transport);
1454
+ await session.transport.handleRequest(req, res, req.body);
1252
1455
  }
1253
1456
  catch (error) {
1254
1457
  console.error("Error handling MCP POST request:", error);
@@ -1269,22 +1472,32 @@ app.post("/mcp", async (req, res) => {
1269
1472
  */
1270
1473
  app.get("/mcp", async (req, res) => {
1271
1474
  const sessionId = req.headers["mcp-session-id"];
1475
+ // Capture auth token before stripping headers, so we can rehydrate.
1476
+ const authToken = getAuthToken(req);
1272
1477
  delete req.headers.authorization;
1273
1478
  delete req.headers["x-nestr-api-key"];
1274
1479
  delete req.headers.cookie;
1275
- if (!sessionId || !sessions[sessionId]) {
1480
+ if (!sessionId) {
1276
1481
  res.status(404).json({
1277
1482
  jsonrpc: "2.0",
1278
- error: {
1279
- code: -32001,
1280
- message: "Session not found",
1281
- },
1483
+ error: { code: -32001, message: "Session not found" },
1484
+ id: null,
1485
+ });
1486
+ return;
1487
+ }
1488
+ let session = sessions[sessionId];
1489
+ if (!session && authToken) {
1490
+ session = await rehydrateSession(sessionId, authToken) ?? undefined;
1491
+ }
1492
+ if (!session) {
1493
+ res.status(404).json({
1494
+ jsonrpc: "2.0",
1495
+ error: { code: -32001, message: "Session not found" },
1282
1496
  id: null,
1283
1497
  });
1284
1498
  return;
1285
1499
  }
1286
1500
  console.log(`SSE stream requested for session: ${sessionId}`);
1287
- const session = sessions[sessionId];
1288
1501
  // Track the SSE response for liveness detection (used by session coalescing)
1289
1502
  session.sseResponse = res;
1290
1503
  res.on("close", () => {
@@ -1314,22 +1527,35 @@ app.get("/mcp", async (req, res) => {
1314
1527
  */
1315
1528
  app.delete("/mcp", async (req, res) => {
1316
1529
  const sessionId = req.headers["mcp-session-id"];
1530
+ const authToken = getAuthToken(req);
1317
1531
  delete req.headers.authorization;
1318
1532
  delete req.headers["x-nestr-api-key"];
1319
1533
  delete req.headers.cookie;
1320
- if (!sessionId || !sessions[sessionId]) {
1534
+ if (!sessionId) {
1321
1535
  res.status(404).json({
1322
1536
  jsonrpc: "2.0",
1323
- error: {
1324
- code: -32001,
1325
- message: "Session not found",
1326
- },
1537
+ error: { code: -32001, message: "Session not found" },
1538
+ id: null,
1539
+ });
1540
+ return;
1541
+ }
1542
+ let session = sessions[sessionId];
1543
+ if (!session && authToken) {
1544
+ // Rehydrate so we can route the DELETE through the SDK and clean up Redis.
1545
+ session = await rehydrateSession(sessionId, authToken) ?? undefined;
1546
+ }
1547
+ if (!session) {
1548
+ // Even if we can't rehydrate, drop any persisted record so a stale entry
1549
+ // doesn't outlive the client's intent.
1550
+ await getStore().removeMcpSession(sessionId).catch(() => { });
1551
+ res.status(404).json({
1552
+ jsonrpc: "2.0",
1553
+ error: { code: -32001, message: "Session not found" },
1327
1554
  id: null,
1328
1555
  });
1329
1556
  return;
1330
1557
  }
1331
1558
  console.log(`Session termination requested: ${sessionId}`);
1332
- const session = sessions[sessionId];
1333
1559
  try {
1334
1560
  await session.transport.handleRequest(req, res);
1335
1561
  }
@@ -1389,27 +1615,31 @@ async function shutdown(signal) {
1389
1615
  if (shuttingDown)
1390
1616
  return; // Prevent double shutdown
1391
1617
  shuttingDown = true;
1392
- console.log(`\nReceived ${signal}, draining sessions...`);
1393
- // Grace period: let in-flight requests complete and give the load balancer
1394
- // time to stop routing new traffic (k8s endpoint removal).
1395
- // Use a longer preStop sleep (e.g., 5s) in k8s to complement this.
1396
- const DRAIN_TIMEOUT_MS = 5000;
1397
- await new Promise(resolve => setTimeout(resolve, DRAIN_TIMEOUT_MS));
1398
- const sessionIds = Object.keys(sessions);
1399
- console.log(`Closing ${sessionIds.length} active session(s)...`);
1400
- for (const sessionId of sessionIds) {
1401
- try {
1402
- const session = sessions[sessionId];
1403
- if (!session)
1404
- continue;
1405
- const { transport, server } = session;
1406
- await transport.close();
1407
- await server.close();
1408
- delete sessions[sessionId];
1409
- }
1410
- catch (error) {
1411
- console.error(`Error closing session ${sessionId}:`, error);
1618
+ console.log(`\nReceived ${signal}, draining...`);
1619
+ // Wait for in-flight /mcp requests to finish so the client doesn't see a
1620
+ // mid-tool-call abort. Capped so a stuck request can't block forever.
1621
+ const DRAIN_TIMEOUT_MS = 25000;
1622
+ const POLL_MS = 200;
1623
+ const start = Date.now();
1624
+ while (inFlightRequests > 0 && Date.now() - start < DRAIN_TIMEOUT_MS) {
1625
+ if ((Date.now() - start) % 2000 < POLL_MS) {
1626
+ console.log(` ${inFlightRequests} request(s) in flight, waiting...`);
1412
1627
  }
1628
+ await new Promise(resolve => setTimeout(resolve, POLL_MS));
1629
+ }
1630
+ if (inFlightRequests > 0) {
1631
+ console.warn(`Drain timeout: ${inFlightRequests} request(s) still in flight, exiting anyway`);
1632
+ }
1633
+ // IMPORTANT: do NOT call transport.close() here. That fires onclose, which
1634
+ // removes the session from Redis — and we want persisted sessions to
1635
+ // survive the deploy so clients can rehydrate against the new pod.
1636
+ // Just drop the local map; tcp connections terminate when the pod exits.
1637
+ const count = Object.keys(sessions).length;
1638
+ for (const sid of Object.keys(sessions)) {
1639
+ delete sessions[sid];
1640
+ }
1641
+ if (count > 0) {
1642
+ console.log(`Released ${count} in-memory session handle(s) (persisted records preserved for rehydration)`);
1413
1643
  }
1414
1644
  try {
1415
1645
  await getStore().close();