@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/api/client.d.ts +2 -0
- package/build/api/client.d.ts.map +1 -1
- package/build/api/client.js.map +1 -1
- package/build/http.d.ts +12 -7
- package/build/http.d.ts.map +1 -1
- package/build/http.js +411 -168
- package/build/http.js.map +1 -1
- package/build/oauth/file-store.d.ts.map +1 -1
- package/build/oauth/file-store.js +138 -0
- package/build/oauth/file-store.js.map +1 -1
- package/build/oauth/flow.d.ts.map +1 -1
- package/build/oauth/flow.js +52 -31
- package/build/oauth/flow.js.map +1 -1
- package/build/oauth/redis-store.d.ts.map +1 -1
- package/build/oauth/redis-store.js +19 -0
- package/build/oauth/redis-store.js.map +1 -1
- package/build/oauth/store.d.ts +24 -0
- package/build/oauth/store.d.ts.map +1 -1
- package/build/oauth/store.js.map +1 -1
- package/build/tools/index.d.ts +57 -51
- package/build/tools/index.d.ts.map +1 -1
- package/build/tools/index.js +7 -0
- package/build/tools/index.js.map +1 -1
- package/package.json +1 -1
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
|
-
*
|
|
858
|
-
*
|
|
859
|
-
*
|
|
860
|
-
*
|
|
861
|
-
*
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1041
|
-
//
|
|
1042
|
-
//
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
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
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
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
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
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
|
-
|
|
1197
|
-
|
|
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
|
|
1483
|
+
if (!sessionId) {
|
|
1276
1484
|
res.status(404).json({
|
|
1277
1485
|
jsonrpc: "2.0",
|
|
1278
|
-
error: {
|
|
1279
|
-
|
|
1280
|
-
|
|
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
|
|
1547
|
+
if (!sessionId) {
|
|
1321
1548
|
res.status(404).json({
|
|
1322
1549
|
jsonrpc: "2.0",
|
|
1323
|
-
error: {
|
|
1324
|
-
|
|
1325
|
-
|
|
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
|
|
1393
|
-
//
|
|
1394
|
-
//
|
|
1395
|
-
|
|
1396
|
-
const
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
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();
|