@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/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 +6 -0
- package/build/http.d.ts.map +1 -1
- package/build/http.js +380 -150
- 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/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,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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
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
|
-
|
|
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
|
-
},
|
|
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
|
-
|
|
1230
|
-
|
|
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
|
|
1480
|
+
if (!sessionId) {
|
|
1276
1481
|
res.status(404).json({
|
|
1277
1482
|
jsonrpc: "2.0",
|
|
1278
|
-
error: {
|
|
1279
|
-
|
|
1280
|
-
|
|
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
|
|
1534
|
+
if (!sessionId) {
|
|
1321
1535
|
res.status(404).json({
|
|
1322
1536
|
jsonrpc: "2.0",
|
|
1323
|
-
error: {
|
|
1324
|
-
|
|
1325
|
-
|
|
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
|
|
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);
|
|
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();
|