@nestr/mcp 0.1.68 → 0.1.70

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,17 +853,23 @@ 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
- * Session coalescing for poorly-behaved clients that create a new MCP connection per tool call.
858
- * If an initialize request arrives with the same auth token + client name as a recent session
859
- * (within 10 minutes, fewer than 5 original init requests), reuse the existing session.
860
- * The initCallCount limit prevents unbounded coalescing after 5 inits it's either a
861
- * legitimately new session or a client that needs fixing.
858
+ * Find a prior session that a re-initializing client is likely trying to replace.
859
+ *
860
+ * Matches on (authToken, mcpClient) within a 10-minute window. The POST /mcp
861
+ * init path closes and drops the match so the client gets a fresh, clean
862
+ * session this prevents "Server already initialized" 400s when a client
863
+ * reconnects after an SSE drop (the transport can only be initialized once).
862
864
  */
863
865
  export const SESSION_COALESCE_WINDOW_MS = 10 * 60 * 1000; // 10 minutes
864
- 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,13 +881,29 @@ 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;
881
904
  for (const [sid, session] of Object.entries(sessions)) {
882
905
  if (session.authToken === authToken &&
883
906
  session.mcpClient === mcpClient &&
884
- session.initCallCount < SESSION_COALESCE_MAX_INITS &&
885
907
  (now - session.lastActivityAt) < SESSION_COALESCE_WINDOW_MS) {
886
908
  const sseAlive = !!(session.sseResponse && !session.sseResponse.writableEnded);
887
909
  // Pick the best session: prefer live SSE, then most recently active
@@ -917,6 +939,242 @@ function getCachedIdentity(token) {
917
939
  function cacheIdentity(token, userId, userName) {
918
940
  identityCache.set(token, { userId, userName, expiresAt: Date.now() + IDENTITY_CACHE_TTL_MS });
919
941
  }
942
+ /**
943
+ * Build an in-memory MCP session: NestrClient + MCP server + transport, all
944
+ * registered in the local sessions map. Used for both fresh sessions (init
945
+ * request from the client) and rehydrated sessions (sessionId we no longer
946
+ * hold in memory but exists in Redis from a previous pod).
947
+ *
948
+ * For rehydrated sessions, the transport is pre-marked as already initialized
949
+ * so the SDK skips the init handshake — the client never knows the server
950
+ * restarted.
951
+ */
952
+ function buildMcpSession(opts) {
953
+ const sessionStartTime = Date.now();
954
+ let sessionRef;
955
+ const client = new NestrClient({
956
+ apiKey: opts.authToken,
957
+ baseUrl: process.env.NESTR_API_BASE,
958
+ mcpClient: opts.mcpClient,
959
+ // tokenProvider enables server-side token refresh for stored sessions (browser flow).
960
+ // In the standard MCP OAuth flow there's no stored session — the client manages refresh.
961
+ // When tokenProvider is undefined, NestrClient lets 401 propagate to the client.
962
+ tokenProvider: opts.hasStoredOAuthSession ? async () => {
963
+ const session = await getOAuthSession(opts.authToken);
964
+ if (!session) {
965
+ // Stored session expired and refresh failed — surface a 401 to the
966
+ // client without ripping the MCP session out from under the protocol.
967
+ // The HTTP-level pre-check in the POST handler will normally catch
968
+ // this first; this is the in-flight fallback.
969
+ const sid = sessionRef?.transport?.sessionId;
970
+ console.log(`OAuth session expired mid-session (MCP session: ${sid ?? "unknown"}). Returning 401 to client.`);
971
+ throw new NestrApiError("OAuth session expired", 401, "/", {
972
+ code: "AUTH_FAILED",
973
+ hint: "Your OAuth session has expired or the server was restarted. Reconnect to the MCP server to re-authenticate.",
974
+ });
975
+ }
976
+ return session.accessToken;
977
+ } : undefined,
978
+ });
979
+ const server = createServer({
980
+ client,
981
+ userId: opts.userId,
982
+ userName: opts.userName,
983
+ onToolCall: (toolName, args, success, error) => {
984
+ try {
985
+ if (sessionRef?.analytics) {
986
+ if (sessionRef.toolCallCount !== undefined) {
987
+ sessionRef.toolCallCount++;
988
+ }
989
+ analytics.trackToolCall(sessionRef.analytics, {
990
+ toolName,
991
+ workspaceId: args.workspaceId,
992
+ success,
993
+ errorCode: error,
994
+ });
995
+ }
996
+ }
997
+ catch (e) {
998
+ console.error("[Analytics] Tool call tracking error:", e);
999
+ }
1000
+ },
1001
+ });
1002
+ const transport = new StreamableHTTPServerTransport({
1003
+ sessionIdGenerator: () => opts.rehydrateFor ?? randomUUID(),
1004
+ enableJsonResponse: opts.wantsJsonOnly,
1005
+ onsessioninitialized: (newSessionId) => {
1006
+ // Only fires for fresh sessions — rehydrated transports skip the handshake.
1007
+ console.log(`Session initialized: ${newSessionId}${opts.mcpClient ? ` (client: ${opts.mcpClient})` : ""}`);
1008
+ const sessionData = {
1009
+ transport,
1010
+ server,
1011
+ authToken: opts.authToken,
1012
+ mcpClient: opts.mcpClient,
1013
+ isApiKey: opts.isApiKey,
1014
+ wantsJsonOnly: opts.wantsJsonOnly,
1015
+ hasStoredOAuthSession: opts.hasStoredOAuthSession,
1016
+ userId: opts.userId,
1017
+ userName: opts.userName,
1018
+ analytics: opts.analyticsCtx,
1019
+ toolCallCount: 0,
1020
+ sessionStartTime,
1021
+ lastActivityAt: Date.now(),
1022
+ lastPersistedAt: Date.now(),
1023
+ };
1024
+ sessions[newSessionId] = sessionData;
1025
+ sessionRef = sessionData;
1026
+ // Persist for rehydration after restart
1027
+ getStore().storeMcpSession(newSessionId, {
1028
+ authToken: opts.authToken,
1029
+ mcpClient: opts.mcpClient,
1030
+ userId: opts.userId,
1031
+ userName: opts.userName,
1032
+ isApiKey: opts.isApiKey,
1033
+ wantsJsonOnly: opts.wantsJsonOnly,
1034
+ hasStoredOAuthSession: opts.hasStoredOAuthSession,
1035
+ createdAt: Date.now(),
1036
+ }).catch(e => console.error("[McpSession] Failed to persist session:", e instanceof Error ? e.message : e));
1037
+ if (opts.analyticsCtx) {
1038
+ try {
1039
+ analytics.trackSessionStart(opts.analyticsCtx, {
1040
+ hasToken: true,
1041
+ authMethod: opts.isApiKey ? "api_key" : "oauth",
1042
+ });
1043
+ }
1044
+ catch (e) {
1045
+ console.error("[Analytics] Session start error:", e);
1046
+ }
1047
+ }
1048
+ },
1049
+ });
1050
+ transport.onclose = () => {
1051
+ const sid = transport.sessionId;
1052
+ if (sid && sessions[sid]) {
1053
+ const session = sessions[sid];
1054
+ if (session.analytics && session.sessionStartTime) {
1055
+ try {
1056
+ const duration = Math.floor((Date.now() - session.sessionStartTime) / 1000);
1057
+ analytics.trackSessionEnd(session.analytics, {
1058
+ duration,
1059
+ toolCallCount: session.toolCallCount || 0,
1060
+ });
1061
+ }
1062
+ catch (e) {
1063
+ console.error("[Analytics] Session end tracking error:", e);
1064
+ }
1065
+ }
1066
+ console.log(`Session closed: ${sid}`);
1067
+ delete sessions[sid];
1068
+ }
1069
+ // Drop persisted record on explicit close (DELETE /mcp). Pod-shutdown does
1070
+ // NOT call transport.close() so persisted records survive deploys.
1071
+ if (sid) {
1072
+ getStore().removeMcpSession(sid).catch(e => console.error("[McpSession] Failed to remove persisted session:", e instanceof Error ? e.message : e));
1073
+ }
1074
+ };
1075
+ // For rehydration we mark the transport as already initialized and register
1076
+ // the SessionData immediately. The SDK exposes no public API for this, so
1077
+ // we touch private fields — the alternative is forcing every client to
1078
+ // re-init on every deploy, which is the bug we're fixing.
1079
+ if (opts.rehydrateFor) {
1080
+ const inner = transport._webStandardTransport;
1081
+ inner._initialized = true;
1082
+ inner.sessionId = opts.rehydrateFor;
1083
+ const sessionData = {
1084
+ transport,
1085
+ server,
1086
+ authToken: opts.authToken,
1087
+ mcpClient: opts.mcpClient,
1088
+ isApiKey: opts.isApiKey,
1089
+ wantsJsonOnly: opts.wantsJsonOnly,
1090
+ hasStoredOAuthSession: opts.hasStoredOAuthSession,
1091
+ userId: opts.userId,
1092
+ userName: opts.userName,
1093
+ analytics: opts.analyticsCtx,
1094
+ toolCallCount: 0,
1095
+ sessionStartTime,
1096
+ lastActivityAt: Date.now(),
1097
+ lastPersistedAt: Date.now(),
1098
+ };
1099
+ sessions[opts.rehydrateFor] = sessionData;
1100
+ sessionRef = sessionData;
1101
+ console.log(`[Rehydrate] Rebuilt session ${opts.rehydrateFor} (client: ${opts.mcpClient ?? "unknown"})`);
1102
+ return sessionData;
1103
+ }
1104
+ // Fresh session: caller still needs to attach via server.connect(transport)
1105
+ // and call transport.handleRequest() to drive the init handshake.
1106
+ return {
1107
+ transport,
1108
+ server,
1109
+ authToken: opts.authToken,
1110
+ mcpClient: opts.mcpClient,
1111
+ isApiKey: opts.isApiKey,
1112
+ wantsJsonOnly: opts.wantsJsonOnly,
1113
+ hasStoredOAuthSession: opts.hasStoredOAuthSession,
1114
+ userId: opts.userId,
1115
+ userName: opts.userName,
1116
+ analytics: opts.analyticsCtx,
1117
+ lastActivityAt: Date.now(),
1118
+ };
1119
+ }
1120
+ /**
1121
+ * Look up a sessionId in the persistent store and rebuild the in-memory
1122
+ * session if found. Cross-checks the auth token to refuse rehydration when
1123
+ * the request token doesn't match the stored one (defense in depth).
1124
+ *
1125
+ * Returns the rebuilt SessionData (already in `sessions` map) or undefined.
1126
+ */
1127
+ async function rehydrateSession(sessionId, authToken) {
1128
+ let stored;
1129
+ try {
1130
+ stored = await getStore().getMcpSession(sessionId);
1131
+ }
1132
+ catch (e) {
1133
+ console.error("[Rehydrate] Failed to read MCP session from store:", e instanceof Error ? e.message : e);
1134
+ return undefined;
1135
+ }
1136
+ if (!stored)
1137
+ return undefined;
1138
+ // Cross-check token: rotated tokens or hijacking attempts → refuse, force re-init.
1139
+ if (stored.authToken !== authToken) {
1140
+ console.warn(`[Rehydrate] Session ${sessionId} found but token mismatch — refusing rehydration`);
1141
+ return undefined;
1142
+ }
1143
+ let analyticsCtx;
1144
+ try {
1145
+ analyticsCtx = analytics.isEnabled() ? {
1146
+ clientId: analytics.generateClientId(),
1147
+ userId: stored.userId,
1148
+ mcpClient: stored.mcpClient,
1149
+ transport: "http",
1150
+ } : undefined;
1151
+ }
1152
+ catch (e) {
1153
+ console.error("[Analytics] Context creation error:", e);
1154
+ }
1155
+ const session = buildMcpSession({
1156
+ authToken: stored.authToken,
1157
+ isApiKey: stored.isApiKey,
1158
+ mcpClient: stored.mcpClient,
1159
+ userId: stored.userId,
1160
+ userName: stored.userName,
1161
+ wantsJsonOnly: stored.wantsJsonOnly,
1162
+ hasStoredOAuthSession: stored.hasStoredOAuthSession,
1163
+ analyticsCtx,
1164
+ rehydrateFor: sessionId,
1165
+ });
1166
+ // Wire the protocol layer to the transport. server.connect() doesn't touch
1167
+ // _initialized so our hack stays valid.
1168
+ await session.server.connect(session.transport);
1169
+ // Refresh TTL: this session is being actively used.
1170
+ try {
1171
+ await getStore().touchMcpSession(sessionId);
1172
+ }
1173
+ catch (e) {
1174
+ console.error("[McpSession] touch on rehydrate failed:", e instanceof Error ? e.message : e);
1175
+ }
1176
+ return session;
1177
+ }
920
1178
  /**
921
1179
  * Extract authentication token from request headers
922
1180
  *
@@ -948,6 +1206,21 @@ function buildWwwAuthenticateHeader(req) {
948
1206
  const metadataUrl = `${baseUrl}/.well-known/oauth-protected-resource`;
949
1207
  return `Bearer resource_metadata="${metadataUrl}"`;
950
1208
  }
1209
+ // In-flight request tracking for /mcp so the shutdown handler can wait for
1210
+ // outstanding tool calls to finish before the pod terminates.
1211
+ app.use("/mcp", (_req, res, next) => {
1212
+ inFlightRequests++;
1213
+ let decremented = false;
1214
+ const decrement = () => {
1215
+ if (decremented)
1216
+ return;
1217
+ decremented = true;
1218
+ inFlightRequests--;
1219
+ };
1220
+ res.on("finish", decrement);
1221
+ res.on("close", decrement);
1222
+ next();
1223
+ });
951
1224
  /**
952
1225
  * MCP POST endpoint - handles JSON-RPC requests
953
1226
  */
@@ -970,17 +1243,42 @@ app.post("/mcp", async (req, res) => {
970
1243
  if (wantsJsonOnly) {
971
1244
  req.headers.accept = `${acceptHeader}, text/event-stream`;
972
1245
  }
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
1246
+ // Check for existing session, or rehydrate from persistent store if the
1247
+ // pod has restarted since this client last connected.
982
1248
  if (sessionId) {
983
- console.log(`Session not found: ${sessionId} (server may have restarted)`);
1249
+ let session = sessions[sessionId];
1250
+ if (!session && authToken) {
1251
+ session = await rehydrateSession(sessionId, authToken) ?? undefined;
1252
+ }
1253
+ if (session) {
1254
+ // For sessions held over from a previous pod, the OAuth token may have
1255
+ // expired. Pre-check before invoking the transport so we can return a
1256
+ // proper HTTP 401 + WWW-Authenticate that triggers MCP client re-auth,
1257
+ // instead of wrapping the failure as a tool error the client ignores.
1258
+ if (session.hasStoredOAuthSession) {
1259
+ const oauthSession = await getOAuthSession(session.authToken);
1260
+ if (!oauthSession) {
1261
+ res.status(401);
1262
+ res.setHeader("WWW-Authenticate", buildWwwAuthenticateHeader(req));
1263
+ res.json({
1264
+ jsonrpc: "2.0",
1265
+ error: {
1266
+ code: -32001,
1267
+ message: "OAuth session expired. Reconnect to re-authenticate.",
1268
+ },
1269
+ id: req.body?.id ?? null,
1270
+ });
1271
+ return;
1272
+ }
1273
+ }
1274
+ session.lastActivityAt = Date.now();
1275
+ await maybeTouchMcpSession(sessionId, session);
1276
+ await session.transport.handleRequest(req, res, req.body);
1277
+ return;
1278
+ }
1279
+ // Session ID was provided but not found anywhere - return 404 per MCP spec.
1280
+ // Compliant clients will re-initialize automatically.
1281
+ console.log(`Session not found: ${sessionId} (no persisted record either)`);
984
1282
  res.status(404).json({
985
1283
  jsonrpc: "2.0",
986
1284
  error: {
@@ -1037,17 +1335,24 @@ app.post("/mcp", async (req, res) => {
1037
1335
  });
1038
1336
  return;
1039
1337
  }
1040
- // Session coalescing: if a client sends repeated initialize requests with the same
1041
- // auth token + client name (common with agent frameworks that create a new connection
1042
- // per tool call), reuse the existing session instead of creating a new one.
1043
- const coalescable = findCoalescableSession(authToken, mcpClientName);
1044
- if (coalescable) {
1045
- const { sessionId: existingSid, session: existingSession } = coalescable;
1046
- existingSession.initCallCount++;
1047
- existingSession.lastActivityAt = Date.now();
1048
- console.log(`Session coalesced: reusing ${existingSid} for ${mcpClientName || "unknown client"} (init #${existingSession.initCallCount})`);
1049
- await existingSession.transport.handleRequest(req, res, req.body);
1050
- return;
1338
+ // Drop any lingering session for the same (auth token, client) before creating a
1339
+ // new one. This happens when a client reconnects after an SSE drop: the old
1340
+ // session is still in memory but its transport is already initialized, so we
1341
+ // can't route a fresh `initialize` through it — the SDK would 400 with
1342
+ // "Server already initialized". Creating a second session alongside the stale
1343
+ // one also leaks memory over time. Close-and-replace is the only safe option.
1344
+ const stale = findCoalescableSession(authToken, mcpClientName);
1345
+ if (stale) {
1346
+ const { sessionId: staleSid, session: staleSession } = stale;
1347
+ console.log(`Replacing stale session ${staleSid} for ${mcpClientName || "unknown client"} on re-init`);
1348
+ try {
1349
+ await staleSession.transport.close();
1350
+ }
1351
+ catch (e) {
1352
+ console.error("[Session] Failed to close stale transport:", e instanceof Error ? e.message : e);
1353
+ }
1354
+ delete sessions[staleSid];
1355
+ await getStore().removeMcpSession(staleSid).catch(e => console.error("[McpSession] Failed to remove persisted stale session:", e instanceof Error ? e.message : e));
1051
1356
  }
1052
1357
  if (mcpClientName) {
1053
1358
  console.log(`MCP client: ${mcpClientName}`);
@@ -1135,120 +1440,21 @@ app.post("/mcp", async (req, res) => {
1135
1440
  catch (e) {
1136
1441
  console.error("[Analytics] Context creation error:", e);
1137
1442
  }
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
1443
  // Check if we have a stored session for this token (browser flow).
1143
1444
  // Standard MCP OAuth clients manage tokens client-side and won't have a stored session.
1144
1445
  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,
1446
+ const session = buildMcpSession({
1447
+ authToken,
1448
+ isApiKey,
1149
1449
  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
1450
  userId,
1174
1451
  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
- },
1452
+ wantsJsonOnly,
1453
+ hasStoredOAuthSession: hasStoredSession,
1454
+ analyticsCtx,
1195
1455
  });
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
- },
1228
- });
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);
1456
+ await session.server.connect(session.transport);
1457
+ await session.transport.handleRequest(req, res, req.body);
1252
1458
  }
1253
1459
  catch (error) {
1254
1460
  console.error("Error handling MCP POST request:", error);
@@ -1269,28 +1475,48 @@ app.post("/mcp", async (req, res) => {
1269
1475
  */
1270
1476
  app.get("/mcp", async (req, res) => {
1271
1477
  const sessionId = req.headers["mcp-session-id"];
1478
+ // Capture auth token before stripping headers, so we can rehydrate.
1479
+ const authToken = getAuthToken(req);
1272
1480
  delete req.headers.authorization;
1273
1481
  delete req.headers["x-nestr-api-key"];
1274
1482
  delete req.headers.cookie;
1275
- if (!sessionId || !sessions[sessionId]) {
1483
+ if (!sessionId) {
1276
1484
  res.status(404).json({
1277
1485
  jsonrpc: "2.0",
1278
- error: {
1279
- code: -32001,
1280
- message: "Session not found",
1281
- },
1486
+ error: { code: -32001, message: "Session not found" },
1487
+ id: null,
1488
+ });
1489
+ return;
1490
+ }
1491
+ let session = sessions[sessionId];
1492
+ if (!session && authToken) {
1493
+ session = await rehydrateSession(sessionId, authToken) ?? undefined;
1494
+ }
1495
+ if (!session) {
1496
+ res.status(404).json({
1497
+ jsonrpc: "2.0",
1498
+ error: { code: -32001, message: "Session not found" },
1282
1499
  id: null,
1283
1500
  });
1284
1501
  return;
1285
1502
  }
1286
1503
  console.log(`SSE stream requested for session: ${sessionId}`);
1287
- const session = sessions[sessionId];
1288
1504
  // Track the SSE response for liveness detection (used by session coalescing)
1289
1505
  session.sseResponse = res;
1290
1506
  res.on("close", () => {
1291
1507
  if (session.sseResponse === res) {
1292
1508
  session.sseResponse = undefined;
1293
1509
  }
1510
+ // Tell the SDK the stream is gone. Without this, its internal
1511
+ // _streamMapping still holds the old entry and the next GET /mcp
1512
+ // for this session returns 409 "Only one SSE stream is allowed".
1513
+ // Safe to call even if the SDK already cleaned up via its own cancel path.
1514
+ try {
1515
+ session.transport.closeStandaloneSSEStream();
1516
+ }
1517
+ catch (e) {
1518
+ console.error("[Session] closeStandaloneSSEStream on socket close failed:", e instanceof Error ? e.message : e);
1519
+ }
1294
1520
  });
1295
1521
  try {
1296
1522
  await session.transport.handleRequest(req, res);
@@ -1314,22 +1540,35 @@ app.get("/mcp", async (req, res) => {
1314
1540
  */
1315
1541
  app.delete("/mcp", async (req, res) => {
1316
1542
  const sessionId = req.headers["mcp-session-id"];
1543
+ const authToken = getAuthToken(req);
1317
1544
  delete req.headers.authorization;
1318
1545
  delete req.headers["x-nestr-api-key"];
1319
1546
  delete req.headers.cookie;
1320
- if (!sessionId || !sessions[sessionId]) {
1547
+ if (!sessionId) {
1321
1548
  res.status(404).json({
1322
1549
  jsonrpc: "2.0",
1323
- error: {
1324
- code: -32001,
1325
- message: "Session not found",
1326
- },
1550
+ error: { code: -32001, message: "Session not found" },
1551
+ id: null,
1552
+ });
1553
+ return;
1554
+ }
1555
+ let session = sessions[sessionId];
1556
+ if (!session && authToken) {
1557
+ // Rehydrate so we can route the DELETE through the SDK and clean up Redis.
1558
+ session = await rehydrateSession(sessionId, authToken) ?? undefined;
1559
+ }
1560
+ if (!session) {
1561
+ // Even if we can't rehydrate, drop any persisted record so a stale entry
1562
+ // doesn't outlive the client's intent.
1563
+ await getStore().removeMcpSession(sessionId).catch(() => { });
1564
+ res.status(404).json({
1565
+ jsonrpc: "2.0",
1566
+ error: { code: -32001, message: "Session not found" },
1327
1567
  id: null,
1328
1568
  });
1329
1569
  return;
1330
1570
  }
1331
1571
  console.log(`Session termination requested: ${sessionId}`);
1332
- const session = sessions[sessionId];
1333
1572
  try {
1334
1573
  await session.transport.handleRequest(req, res);
1335
1574
  }
@@ -1389,27 +1628,31 @@ async function shutdown(signal) {
1389
1628
  if (shuttingDown)
1390
1629
  return; // Prevent double shutdown
1391
1630
  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);
1631
+ console.log(`\nReceived ${signal}, draining...`);
1632
+ // Wait for in-flight /mcp requests to finish so the client doesn't see a
1633
+ // mid-tool-call abort. Capped so a stuck request can't block forever.
1634
+ const DRAIN_TIMEOUT_MS = 25000;
1635
+ const POLL_MS = 200;
1636
+ const start = Date.now();
1637
+ while (inFlightRequests > 0 && Date.now() - start < DRAIN_TIMEOUT_MS) {
1638
+ if ((Date.now() - start) % 2000 < POLL_MS) {
1639
+ console.log(` ${inFlightRequests} request(s) in flight, waiting...`);
1412
1640
  }
1641
+ await new Promise(resolve => setTimeout(resolve, POLL_MS));
1642
+ }
1643
+ if (inFlightRequests > 0) {
1644
+ console.warn(`Drain timeout: ${inFlightRequests} request(s) still in flight, exiting anyway`);
1645
+ }
1646
+ // IMPORTANT: do NOT call transport.close() here. That fires onclose, which
1647
+ // removes the session from Redis — and we want persisted sessions to
1648
+ // survive the deploy so clients can rehydrate against the new pod.
1649
+ // Just drop the local map; tcp connections terminate when the pod exits.
1650
+ const count = Object.keys(sessions).length;
1651
+ for (const sid of Object.keys(sessions)) {
1652
+ delete sessions[sid];
1653
+ }
1654
+ if (count > 0) {
1655
+ console.log(`Released ${count} in-memory session handle(s) (persisted records preserved for rehydration)`);
1413
1656
  }
1414
1657
  try {
1415
1658
  await getStore().close();