@nestr/mcp 0.1.72 → 0.1.89

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.
Files changed (60) hide show
  1. package/build/api/client.d.ts +84 -5
  2. package/build/api/client.d.ts.map +1 -1
  3. package/build/api/client.js +120 -21
  4. package/build/api/client.js.map +1 -1
  5. package/build/help/articles.d.ts +146 -0
  6. package/build/help/articles.d.ts.map +1 -0
  7. package/build/help/articles.js +574 -0
  8. package/build/help/articles.js.map +1 -0
  9. package/build/help/cross-links.d.ts +21 -0
  10. package/build/help/cross-links.d.ts.map +1 -0
  11. package/build/help/cross-links.js +61 -0
  12. package/build/help/cross-links.js.map +1 -0
  13. package/build/help/topics.d.ts.map +1 -1
  14. package/build/help/topics.js +324 -12
  15. package/build/help/topics.js.map +1 -1
  16. package/build/http.d.ts +26 -13
  17. package/build/http.d.ts.map +1 -1
  18. package/build/http.js +572 -128
  19. package/build/http.js.map +1 -1
  20. package/build/oauth/client-info.d.ts +58 -0
  21. package/build/oauth/client-info.d.ts.map +1 -0
  22. package/build/oauth/client-info.js +68 -0
  23. package/build/oauth/client-info.js.map +1 -0
  24. package/build/oauth/config.d.ts +19 -0
  25. package/build/oauth/config.d.ts.map +1 -1
  26. package/build/oauth/config.js +12 -0
  27. package/build/oauth/config.js.map +1 -1
  28. package/build/oauth/flow.d.ts +6 -0
  29. package/build/oauth/flow.d.ts.map +1 -1
  30. package/build/oauth/flow.js +30 -4
  31. package/build/oauth/flow.js.map +1 -1
  32. package/build/oauth/store.d.ts +14 -0
  33. package/build/oauth/store.d.ts.map +1 -1
  34. package/build/oauth/store.js.map +1 -1
  35. package/build/server.d.ts +11 -0
  36. package/build/server.d.ts.map +1 -1
  37. package/build/server.js +38 -5
  38. package/build/server.js.map +1 -1
  39. package/build/skills/tension-processing.d.ts.map +1 -1
  40. package/build/skills/tension-processing.js +11 -1
  41. package/build/skills/tension-processing.js.map +1 -1
  42. package/build/tools/index.d.ts +580 -90
  43. package/build/tools/index.d.ts.map +1 -1
  44. package/build/tools/index.js +598 -82
  45. package/build/tools/index.js.map +1 -1
  46. package/build/tools/validation.d.ts +42 -0
  47. package/build/tools/validation.d.ts.map +1 -0
  48. package/build/tools/validation.js +97 -0
  49. package/build/tools/validation.js.map +1 -0
  50. package/build/util/diagnose.d.ts +40 -0
  51. package/build/util/diagnose.d.ts.map +1 -0
  52. package/build/util/diagnose.js +26 -0
  53. package/build/util/diagnose.js.map +1 -0
  54. package/build/util/request-context.d.ts +11 -0
  55. package/build/util/request-context.d.ts.map +1 -0
  56. package/build/util/request-context.js +30 -0
  57. package/build/util/request-context.js.map +1 -0
  58. package/package.json +2 -1
  59. package/web/index.html +25 -0
  60. package/web/styles.css +62 -0
package/build/http.js CHANGED
@@ -32,11 +32,12 @@ import { randomUUID, randomBytes } from "node:crypto";
32
32
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
33
33
  import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
34
34
  import { createServer } from "./server.js";
35
- import { toolDefinitions } from "./tools/index.js";
36
- import { NestrClient, NestrApiError } from "./api/client.js";
35
+ import { toolDefinitions, PUBLIC_TOOL_NAMES } from "./tools/index.js";
36
+ import { NestrClient, NestrApiError, tokenFingerprint } from "./api/client.js";
37
37
  import { getProtectedResourceMetadata, getAuthorizationServerMetadata, getOAuthConfig, } from "./oauth/config.js";
38
38
  import { createAuthorizationRequest, getPendingAuth, exchangeCodeForTokens, storeOAuthSession, verifyPKCE, getOAuthSession, } from "./oauth/flow.js";
39
39
  import { initStore, getStore, } from "./oauth/store.js";
40
+ import { tagOAuthClientInfo, deriveOAuthBaseUrl, } from "./oauth/client-info.js";
40
41
  import { constantTimeCompare, validateRedirectUri, } from "./oauth/storage.js";
41
42
  import { analytics } from "./analytics/index.js";
42
43
  import "./analytics/ga4.js";
@@ -46,6 +47,8 @@ import path from "path";
46
47
  import fs from "fs";
47
48
  import { fileURLToPath } from "url";
48
49
  import { VERSION } from "./version.js";
50
+ import { runWithContext, cidTag } from "./util/request-context.js";
51
+ import { tryDecodeJwtAge } from "./util/diagnose.js";
49
52
  const __filename = fileURLToPath(import.meta.url);
50
53
  const __dirname = path.dirname(__filename);
51
54
  const PORT = process.env.PORT || 3000;
@@ -209,16 +212,29 @@ app.post("/oauth/register", registerLimiter, express.json(), async (req, res) =>
209
212
  });
210
213
  return;
211
214
  }
212
- // Validate required fields
213
- if (!redirect_uris || !Array.isArray(redirect_uris) || redirect_uris.length === 0) {
215
+ // RFC 7591: redirect_uris is only required for redirect-based grants.
216
+ // Device-code-only clients (headless agents) never redirect and may omit it.
217
+ const requestedGrantTypes = Array.isArray(grant_types) && grant_types.length > 0
218
+ ? grant_types
219
+ : ["authorization_code", "refresh_token"];
220
+ const usesRedirectGrant = requestedGrantTypes.includes("authorization_code") || requestedGrantTypes.includes("implicit");
221
+ if (redirect_uris !== undefined && !Array.isArray(redirect_uris)) {
214
222
  res.status(400).json({
215
223
  error: "invalid_client_metadata",
216
- error_description: "redirect_uris is required and must be a non-empty array",
224
+ error_description: "redirect_uris must be an array",
225
+ });
226
+ return;
227
+ }
228
+ const redirectUris = redirect_uris ?? [];
229
+ if (usesRedirectGrant && redirectUris.length === 0) {
230
+ res.status(400).json({
231
+ error: "invalid_client_metadata",
232
+ error_description: "redirect_uris is required and must be a non-empty array for redirect-based grant types",
217
233
  });
218
234
  return;
219
235
  }
220
236
  // Validate redirect URIs (must be localhost or HTTPS)
221
- for (const uri of redirect_uris) {
237
+ for (const uri of redirectUris) {
222
238
  try {
223
239
  const parsed = new URL(uri);
224
240
  const isLocalhost = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1";
@@ -239,18 +255,20 @@ app.post("/oauth/register", registerLimiter, express.json(), async (req, res) =>
239
255
  return;
240
256
  }
241
257
  }
242
- // Generate client credentials
258
+ // Generate client credentials. RFC 7591: do not issue credentials the
259
+ // client cannot use, so public clients (auth method "none") get no secret.
243
260
  const clientId = `mcp-${randomUUID()}`;
244
- const clientSecret = randomBytes(32).toString("base64url");
261
+ const tokenEndpointAuthMethod = token_endpoint_auth_method || "client_secret_post";
262
+ const clientSecret = tokenEndpointAuthMethod === "none" ? undefined : randomBytes(32).toString("base64url");
245
263
  // Create registered client
246
264
  const client = {
247
265
  client_id: clientId,
248
- client_secret: clientSecret,
266
+ ...(clientSecret ? { client_secret: clientSecret } : {}),
249
267
  client_name: client_name || "MCP Client",
250
- redirect_uris,
251
- grant_types: grant_types || ["authorization_code", "refresh_token"],
268
+ redirect_uris: redirectUris,
269
+ grant_types: requestedGrantTypes,
252
270
  response_types: response_types || ["code"],
253
- token_endpoint_auth_method: token_endpoint_auth_method || "client_secret_post",
271
+ token_endpoint_auth_method: tokenEndpointAuthMethod,
254
272
  scope: scope || "user nest",
255
273
  registered_at: Date.now(),
256
274
  };
@@ -259,7 +277,7 @@ app.post("/oauth/register", registerLimiter, express.json(), async (req, res) =>
259
277
  // Return registration response (RFC 7591)
260
278
  res.status(201).json({
261
279
  client_id: clientId,
262
- client_secret: clientSecret,
280
+ ...(clientSecret ? { client_secret: clientSecret } : {}),
263
281
  client_name: client.client_name,
264
282
  redirect_uris: client.redirect_uris,
265
283
  grant_types: client.grant_types,
@@ -322,6 +340,10 @@ app.get("/oauth/authorize", oauthLimiter, async (req, res) => {
322
340
  const codeChallenge = req.query.code_challenge;
323
341
  const codeChallengeMethod = req.query.code_challenge_method;
324
342
  const clientConsumer = req.query.client_consumer;
343
+ // MCP `clientInfo.version` forwarded from the initialize handshake. Slashme-online
344
+ // persists it on the issued token row (see PR #1392) for triage / outdated-install
345
+ // detection. We just plumb the URL param through.
346
+ const clientVersion = req.query.client_version;
325
347
  // GA4 analytics: use provided client_id or generate new one for tracking
326
348
  const gaClientId = req.query._ga_client_id ||
327
349
  (analytics.isEnabled() ? analytics.generateClientId() : undefined);
@@ -391,6 +413,7 @@ app.get("/oauth/authorize", oauthLimiter, async (req, res) => {
391
413
  codeChallenge,
392
414
  codeChallengeMethod: codeChallengeMethod || "S256",
393
415
  clientConsumer: effectiveClientConsumer,
416
+ clientVersion,
394
417
  gaClientId,
395
418
  });
396
419
  // Override the redirect_uri in the auth URL to use OUR callback
@@ -610,7 +633,7 @@ app.get("/oauth/callback", async (req, res) => {
610
633
  app.post("/oauth/device", oauthLimiter, express.urlencoded({ extended: true }), async (req, res) => {
611
634
  const config = getOAuthConfig();
612
635
  try {
613
- const { client_id, scope, client_consumer } = req.body;
636
+ const { client_id, scope, client_consumer, client_version } = req.body;
614
637
  // Require client_id
615
638
  if (!client_id) {
616
639
  res.status(400).json({
@@ -653,7 +676,11 @@ app.post("/oauth/device", oauthLimiter, express.urlencoded({ extended: true }),
653
676
  if (effectiveClientConsumer) {
654
677
  body.client_consumer = effectiveClientConsumer;
655
678
  }
656
- console.log(`OAuth Device: Requesting device code from Nestr${effectiveClientConsumer ? ` (consumer: ${effectiveClientConsumer})` : ""}`);
679
+ // Pass client_version (MCP `clientInfo.version`) for triage / outdated-install detection.
680
+ if (client_version) {
681
+ body.client_version = client_version;
682
+ }
683
+ console.log(`OAuth Device: Requesting device code from Nestr${effectiveClientConsumer ? ` (consumer: ${effectiveClientConsumer})` : ""}${client_version ? ` v${client_version}` : ""}`);
657
684
  const response = await fetch(config.deviceAuthorizationEndpoint, {
658
685
  method: "POST",
659
686
  headers: {
@@ -691,7 +718,7 @@ app.post("/oauth/token", tokenLimiter, express.urlencoded({ extended: true }), a
691
718
  const config = getOAuthConfig();
692
719
  try {
693
720
  // Get form body params
694
- const { grant_type, code, redirect_uri, refresh_token, client_id, client_secret, code_verifier, client_consumer, } = req.body;
721
+ const { grant_type, code, redirect_uri, refresh_token, client_id, client_secret, code_verifier, client_consumer, client_version, } = req.body;
695
722
  if (grant_type === "authorization_code") {
696
723
  if (!code) {
697
724
  res.status(400).json({
@@ -763,6 +790,12 @@ app.post("/oauth/token", tokenLimiter, express.urlencoded({ extended: true }), a
763
790
  if (client_consumer) {
764
791
  body.client_consumer = client_consumer;
765
792
  }
793
+ // Pass client_version (MCP `clientInfo.version`) — slashme-online persists
794
+ // it on the token row alongside the consumer for triage / outdated-install
795
+ // detection. Ignored upstream until that PR lands.
796
+ if (client_version) {
797
+ body.client_version = client_version;
798
+ }
766
799
  const response = await fetch(config.tokenEndpoint, {
767
800
  method: "POST",
768
801
  headers: {
@@ -826,6 +859,9 @@ app.post("/oauth/token", tokenLimiter, express.urlencoded({ extended: true }), a
826
859
  if (client_consumer) {
827
860
  body.client_consumer = client_consumer;
828
861
  }
862
+ if (client_version) {
863
+ body.client_version = client_version;
864
+ }
829
865
  console.log(`OAuth Token: Polling device code at Nestr${client_consumer ? ` (consumer: ${client_consumer})` : ""}`);
830
866
  const response = await fetch(config.tokenEndpoint, {
831
867
  method: "POST",
@@ -851,22 +887,24 @@ app.post("/oauth/token", tokenLimiter, express.urlencoded({ extended: true }), a
851
887
  });
852
888
  }
853
889
  });
890
+ // How long a successful upstream auth check is good for. 60s is enough to
891
+ // amortize the probe across a normal tool burst without letting a freshly
892
+ // revoked token slip past for too long.
893
+ const AUTH_VERIFY_TTL_MS = 60 * 1000;
854
894
  export const sessions = {};
895
+ // Public (unauthenticated) guest sessions live in their own map, fully isolated
896
+ // from authenticated `sessions`. They never carry a token, never persist to
897
+ // Redis, and never participate in coalescing/rehydration — so a guest session id
898
+ // can never be confused with (or escalate into) an authenticated one.
899
+ export const publicSessions = {};
855
900
  let shuttingDown = false;
856
901
  let inFlightRequests = 0;
857
- /**
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).
864
- */
865
- export const SESSION_COALESCE_WINDOW_MS = 10 * 60 * 1000; // 10 minutes
866
- // Bumped from 30 → 60min: AI assistants commonly gap 30+ min between Nestr
867
- // tool bursts (long codebase work between status updates). Keeping the in-memory
868
- // session avoids a rehydration round-trip on the next call.
869
- const SESSION_STALE_TIMEOUT_MS = 60 * 60 * 1000; // 60 minutes without activity
902
+ // Idle timeout for sessions WITHOUT a live SSE stream. Eviction is in-memory
903
+ // only (no transport.close(), no Redis delete) so a returning client e.g.
904
+ // an AI assistant gapping 30+ min between tool bursts — transparently
905
+ // rehydrates from the persisted record (7-day TTL). Sessions holding a live
906
+ // SSE stream are never swept: an open stream means a connected client.
907
+ export const SSE_DEAD_IDLE_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
870
908
  // Debounce for touchMcpSession. We refresh the Redis TTL at most this often
871
909
  // so a chatty client doesn't hammer the store.
872
910
  const MCP_SESSION_TOUCH_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
@@ -874,23 +912,34 @@ const MCP_SESSION_TOUCH_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
874
912
  // AWS ALB, nginx) typically idle-timeout TCP at 60s. Sending a comment line
875
913
  // every 25s keeps the connection alive — the SDK doesn't ping on its own.
876
914
  const SSE_KEEPALIVE_INTERVAL_MS = 25 * 1000; // 25 seconds
877
- // Periodically clean up dead sessions (closed SSE + stale).
878
- // We only drop the in-memory entry — the persistent Redis record lives on until
879
- // its own TTL so a late-returning client can still rehydrate.
880
- // .unref() so this timer doesn't prevent process exit (tests, graceful shutdown)
881
- setInterval(() => {
882
- const now = Date.now();
883
- for (const [sid, session] of Object.entries(sessions)) {
915
+ // Periodically evict idle sessions that hold no live SSE stream. In-memory
916
+ // eviction only — the persistent Redis record lives on until its own TTL so
917
+ // a late-returning client rehydrates transparently (same mechanism as
918
+ // deploys). This sweep is the ONLY reaper of idle sessions; nothing else may
919
+ // close or delete a session another client might still be using.
920
+ export function sweepStaleSessions(map, now) {
921
+ let evicted = 0;
922
+ for (const [sid, session] of Object.entries(map)) {
884
923
  const sseAlive = session.sseResponse && !session.sseResponse.writableEnded;
885
- const stale = (now - session.lastActivityAt) > SESSION_STALE_TIMEOUT_MS;
886
- if (!sseAlive && stale) {
887
- if (session.sseKeepaliveTimer) {
888
- clearInterval(session.sseKeepaliveTimer);
889
- session.sseKeepaliveTimer = undefined;
890
- }
891
- delete sessions[sid];
924
+ if (sseAlive)
925
+ continue;
926
+ if (now - session.lastActivityAt <= SSE_DEAD_IDLE_TIMEOUT_MS)
927
+ continue;
928
+ if (session.sseKeepaliveTimer) {
929
+ clearInterval(session.sseKeepaliveTimer);
930
+ session.sseKeepaliveTimer = undefined;
892
931
  }
932
+ delete map[sid];
933
+ evicted++;
893
934
  }
935
+ if (evicted > 0) {
936
+ console.log(`[Sweep] Evicted ${evicted} idle session(s) from memory (persisted records preserved for rehydration)`);
937
+ }
938
+ }
939
+ setInterval(() => {
940
+ const now = Date.now();
941
+ sweepStaleSessions(sessions, now);
942
+ sweepStaleSessions(publicSessions, now);
894
943
  }, 60000).unref();
895
944
  /**
896
945
  * Refresh the Redis TTL of a persisted MCP session, debounced per-session so
@@ -940,24 +989,6 @@ async function dropSessionOnTokenSwap(sessionId, session, authToken) {
940
989
  await getStore().removeMcpSession(sessionId).catch(() => { });
941
990
  return true;
942
991
  }
943
- export function findCoalescableSession(authToken, mcpClient) {
944
- const now = Date.now();
945
- let bestMatch;
946
- for (const [sid, session] of Object.entries(sessions)) {
947
- if (session.authToken === authToken &&
948
- session.mcpClient === mcpClient &&
949
- (now - session.lastActivityAt) < SESSION_COALESCE_WINDOW_MS) {
950
- const sseAlive = !!(session.sseResponse && !session.sseResponse.writableEnded);
951
- // Pick the best session: prefer live SSE, then most recently active
952
- if (!bestMatch ||
953
- (sseAlive && !bestMatch.sseAlive) || // prefer live SSE over dead
954
- (sseAlive === bestMatch.sseAlive && session.lastActivityAt > bestMatch.lastActivity)) {
955
- bestMatch = { sessionId: sid, session, lastActivity: session.lastActivityAt, sseAlive };
956
- }
957
- }
958
- }
959
- return bestMatch ? { sessionId: bestMatch.sessionId, session: bestMatch.session } : undefined;
960
- }
961
992
  /**
962
993
  * Cache resolved identities by auth token to avoid repeated /users/me calls.
963
994
  * Cursor-vscode reconnects every ~60s, and workspace API keys sent as Bearer tokens
@@ -994,32 +1025,47 @@ function cacheIdentity(token, userId, userName) {
994
1025
  function buildMcpSession(opts) {
995
1026
  const sessionStartTime = Date.now();
996
1027
  let sessionRef;
1028
+ const flow = opts.hasStoredOAuthSession ? "A" : opts.isApiKey ? "unknown" : "B";
997
1029
  const client = new NestrClient({
998
1030
  apiKey: opts.authToken,
999
1031
  baseUrl: process.env.NESTR_API_BASE,
1000
1032
  mcpClient: opts.mcpClient,
1001
- // tokenProvider enables server-side token refresh for stored sessions (browser flow).
1033
+ flow,
1034
+ // tokenProvider enables server-side token refresh for stored sessions (Flow A).
1002
1035
  // In the standard MCP OAuth flow there's no stored session — the client manages refresh.
1003
1036
  // When tokenProvider is undefined, NestrClient lets 401 propagate to the client.
1004
1037
  tokenProvider: opts.hasStoredOAuthSession ? async () => {
1005
1038
  const session = await getOAuthSession(opts.authToken);
1006
1039
  if (!session) {
1007
- // Stored session expired and refresh failed — surface a 401 to the
1008
- // client without ripping the MCP session out from under the protocol.
1009
- // The HTTP-level pre-check in the POST handler will normally catch
1010
- // this first; this is the in-flight fallback.
1040
+ // Stored session expired and refresh failed — surface a typed
1041
+ // AUTH_REFRESH_FAILED so the client can branch on it. The HTTP-level
1042
+ // pre-check in the POST handler normally catches this first; this is
1043
+ // the in-flight fallback.
1011
1044
  const sid = sessionRef?.transport?.sessionId;
1012
- console.log(`OAuth session expired mid-session (MCP session: ${sid ?? "unknown"}). Returning 401 to client.`);
1045
+ console.log(`${cidTag()}OAuth session expired mid-session (MCP session: ${sid ?? "unknown"}). Returning AUTH_REFRESH_FAILED to client.`);
1013
1046
  throw new NestrApiError("OAuth session expired", 401, "/", {
1014
- code: "AUTH_FAILED",
1015
- hint: "Your OAuth session has expired or the server was restarted. Reconnect to the MCP server to re-authenticate.",
1047
+ code: "AUTH_REFRESH_FAILED",
1048
+ flow: "A",
1049
+ hint: "Server-side refresh failed (session expired or server was restarted). User must reconnect Nestr to re-authenticate.",
1016
1050
  });
1017
1051
  }
1018
1052
  return session.accessToken;
1019
1053
  } : undefined,
1054
+ onUpstreamAuthFailure: () => {
1055
+ if (sessionRef) {
1056
+ sessionRef.authInvalidated = true;
1057
+ sessionRef.lastUpstream401At = Date.now();
1058
+ }
1059
+ },
1060
+ onRefreshAttempt: (result) => {
1061
+ if (sessionRef) {
1062
+ sessionRef.lastRefreshAttempt = result;
1063
+ }
1064
+ },
1020
1065
  });
1021
1066
  const server = createServer({
1022
1067
  client,
1068
+ isPublic: opts.isPublic,
1023
1069
  userId: opts.userId,
1024
1070
  userName: opts.userName,
1025
1071
  onToolCall: (toolName, args, success, error) => {
@@ -1040,19 +1086,35 @@ function buildMcpSession(opts) {
1040
1086
  console.error("[Analytics] Tool call tracking error:", e);
1041
1087
  }
1042
1088
  },
1089
+ getDiagnose: () => ({
1090
+ flow: opts.hasStoredOAuthSession ? "A" : opts.isApiKey ? "unknown" : "B",
1091
+ tokenPresented: !!opts.authToken,
1092
+ tokenFingerprint: tokenFingerprint(opts.authToken),
1093
+ tokenAge: tryDecodeJwtAge(opts.authToken),
1094
+ lastUpstream401At: sessionRef?.lastUpstream401At,
1095
+ lastRefreshAttempt: sessionRef?.lastRefreshAttempt,
1096
+ sessionCorrelationId: sessionRef?.sessionCorrelationId,
1097
+ hasStoredOAuthSession: opts.hasStoredOAuthSession,
1098
+ isApiKey: opts.isApiKey,
1099
+ mcpClient: opts.mcpClient,
1100
+ mcpClientVersion: opts.mcpClientVersion,
1101
+ userId: opts.userId,
1102
+ }),
1043
1103
  });
1044
1104
  const transport = new StreamableHTTPServerTransport({
1045
1105
  sessionIdGenerator: () => opts.rehydrateFor ?? randomUUID(),
1046
1106
  enableJsonResponse: opts.wantsJsonOnly,
1047
1107
  onsessioninitialized: (newSessionId) => {
1048
1108
  // Only fires for fresh sessions — rehydrated transports skip the handshake.
1049
- console.log(`Session initialized: ${newSessionId}${opts.mcpClient ? ` (client: ${opts.mcpClient})` : ""}`);
1109
+ console.log(`${cidTag()}Session initialized: ${newSessionId}${opts.mcpClient ? ` (client: ${opts.mcpClient}${opts.mcpClientVersion ? ` v${opts.mcpClientVersion}` : ""})` : ""}`);
1050
1110
  const sessionData = {
1051
1111
  transport,
1052
1112
  server,
1053
1113
  authToken: opts.authToken,
1054
1114
  mcpClient: opts.mcpClient,
1115
+ mcpClientVersion: opts.mcpClientVersion,
1055
1116
  isApiKey: opts.isApiKey,
1117
+ isPublic: opts.isPublic,
1056
1118
  wantsJsonOnly: opts.wantsJsonOnly,
1057
1119
  hasStoredOAuthSession: opts.hasStoredOAuthSession,
1058
1120
  userId: opts.userId,
@@ -1062,20 +1124,25 @@ function buildMcpSession(opts) {
1062
1124
  sessionStartTime,
1063
1125
  lastActivityAt: Date.now(),
1064
1126
  lastPersistedAt: Date.now(),
1127
+ sessionCorrelationId: randomUUID(),
1065
1128
  };
1066
1129
  sessions[newSessionId] = sessionData;
1067
1130
  sessionRef = sessionData;
1068
- // Persist for rehydration after restart
1069
- getStore().storeMcpSession(newSessionId, {
1070
- authToken: opts.authToken,
1071
- mcpClient: opts.mcpClient,
1072
- userId: opts.userId,
1073
- userName: opts.userName,
1074
- isApiKey: opts.isApiKey,
1075
- wantsJsonOnly: opts.wantsJsonOnly,
1076
- hasStoredOAuthSession: opts.hasStoredOAuthSession,
1077
- createdAt: Date.now(),
1078
- }).catch(e => console.error("[McpSession] Failed to persist session:", e instanceof Error ? e.message : e));
1131
+ // Persist for rehydration after restart. Public guest sessions are never
1132
+ // persisted: they hold no token, so rehydration's token cross-check could
1133
+ // never match, and there's no user state worth surviving a restart.
1134
+ if (!opts.isPublic)
1135
+ getStore().storeMcpSession(newSessionId, {
1136
+ authToken: opts.authToken,
1137
+ mcpClient: opts.mcpClient,
1138
+ mcpClientVersion: opts.mcpClientVersion,
1139
+ userId: opts.userId,
1140
+ userName: opts.userName,
1141
+ isApiKey: opts.isApiKey,
1142
+ wantsJsonOnly: opts.wantsJsonOnly,
1143
+ hasStoredOAuthSession: opts.hasStoredOAuthSession,
1144
+ createdAt: Date.now(),
1145
+ }).catch(e => console.error("[McpSession] Failed to persist session:", e instanceof Error ? e.message : e));
1079
1146
  if (opts.analyticsCtx) {
1080
1147
  try {
1081
1148
  analytics.trackSessionStart(opts.analyticsCtx, {
@@ -1131,7 +1198,9 @@ function buildMcpSession(opts) {
1131
1198
  server,
1132
1199
  authToken: opts.authToken,
1133
1200
  mcpClient: opts.mcpClient,
1201
+ mcpClientVersion: opts.mcpClientVersion,
1134
1202
  isApiKey: opts.isApiKey,
1203
+ isPublic: opts.isPublic,
1135
1204
  wantsJsonOnly: opts.wantsJsonOnly,
1136
1205
  hasStoredOAuthSession: opts.hasStoredOAuthSession,
1137
1206
  userId: opts.userId,
@@ -1141,10 +1210,11 @@ function buildMcpSession(opts) {
1141
1210
  sessionStartTime,
1142
1211
  lastActivityAt: Date.now(),
1143
1212
  lastPersistedAt: Date.now(),
1213
+ sessionCorrelationId: randomUUID(),
1144
1214
  };
1145
1215
  sessions[opts.rehydrateFor] = sessionData;
1146
1216
  sessionRef = sessionData;
1147
- console.log(`[Rehydrate] Rebuilt session ${opts.rehydrateFor} (client: ${opts.mcpClient ?? "unknown"})`);
1217
+ console.log(`${cidTag()}[Rehydrate] Rebuilt session ${opts.rehydrateFor} (client: ${opts.mcpClient ?? "unknown"}${opts.mcpClientVersion ? ` v${opts.mcpClientVersion}` : ""})`);
1148
1218
  return sessionData;
1149
1219
  }
1150
1220
  // Fresh session: caller still needs to attach via server.connect(transport)
@@ -1154,7 +1224,9 @@ function buildMcpSession(opts) {
1154
1224
  server,
1155
1225
  authToken: opts.authToken,
1156
1226
  mcpClient: opts.mcpClient,
1227
+ mcpClientVersion: opts.mcpClientVersion,
1157
1228
  isApiKey: opts.isApiKey,
1229
+ isPublic: opts.isPublic,
1158
1230
  wantsJsonOnly: opts.wantsJsonOnly,
1159
1231
  hasStoredOAuthSession: opts.hasStoredOAuthSession,
1160
1232
  userId: opts.userId,
@@ -1202,6 +1274,7 @@ async function rehydrateSession(sessionId, authToken) {
1202
1274
  authToken: stored.authToken,
1203
1275
  isApiKey: stored.isApiKey,
1204
1276
  mcpClient: stored.mcpClient,
1277
+ mcpClientVersion: stored.mcpClientVersion,
1205
1278
  userId: stored.userId,
1206
1279
  userName: stored.userName,
1207
1280
  wantsJsonOnly: stored.wantsJsonOnly,
@@ -1244,13 +1317,144 @@ export function getAuthToken(req) {
1244
1317
  return null;
1245
1318
  }
1246
1319
  /**
1247
- * Build WWW-Authenticate header for 401 responses
1248
- * Directs MCP clients to the OAuth protected resource metadata
1320
+ * Build WWW-Authenticate header for 401 responses (RFC 6750 + RFC 9728).
1321
+ *
1322
+ * `resource_metadata` points the MCP client at our metadata endpoint so it can
1323
+ * discover the authorization server. `error` / `error_description` follow
1324
+ * RFC 6750 §3 so a SDK that surfaces the header can display a useful reason.
1249
1325
  */
1250
- function buildWwwAuthenticateHeader(req) {
1326
+ function buildWwwAuthenticateHeader(req, options = {}) {
1251
1327
  const baseUrl = getServerBaseUrl(req);
1252
1328
  const metadataUrl = `${baseUrl}/.well-known/oauth-protected-resource`;
1253
- return `Bearer resource_metadata="${metadataUrl}"`;
1329
+ const parts = [`resource_metadata="${metadataUrl}"`];
1330
+ if (options.error) {
1331
+ parts.push(`error="${options.error}"`);
1332
+ }
1333
+ if (options.errorDescription) {
1334
+ // Keep description ASCII-safe; strip quotes to avoid breaking the header.
1335
+ parts.push(`error_description="${options.errorDescription.replace(/"/g, "'")}"`);
1336
+ }
1337
+ return `Bearer ${parts.join(", ")}`;
1338
+ }
1339
+ function isToolCallRequest(body) {
1340
+ return !!body && typeof body === "object" && body.method === "tools/call";
1341
+ }
1342
+ /**
1343
+ * Pre-flight upstream auth check.
1344
+ *
1345
+ * Goal: when the bearer is dead, we want to respond with HTTP 401 +
1346
+ * WWW-Authenticate (which triggers the MCP SDK's auto-refresh) instead of a
1347
+ * `tools/call` JSON-RPC result with `isError: true` (which is invisible to
1348
+ * the SDK's auth machinery — that was the original Cowork bug).
1349
+ *
1350
+ * Strategy:
1351
+ * 1. If `session.authInvalidated` is set, a previous in-flight call already
1352
+ * saw a 401. Return HTTP 401 immediately for any method (tools/call,
1353
+ * tools/list, ping…) so the client knows to re-auth. Clear the flag so a
1354
+ * subsequent request (after the client refreshes) can re-verify.
1355
+ * 2. Non-tool methods (tools/list, ping, resources/*) don't talk to Nestr.
1356
+ * Skip the upstream probe entirely — the next tools/call will catch any
1357
+ * expired session.
1358
+ * 3. For tools/call, probe Nestr with a cheap, idempotent call:
1359
+ * - Flow A (stored OAuth session): refresh-or-return via getOAuthSession.
1360
+ * - Flow B (Bearer, no stored session): GET /users/me, cached 60s.
1361
+ * - API key: GET /workspaces?limit=1, cached 60s.
1362
+ * A 200 → cache verified-at-now (Flow B/API key). A 401 → return HTTP 401.
1363
+ *
1364
+ * Skipped for `nestr_diagnose` since that tool's whole purpose is to be
1365
+ * callable without auth.
1366
+ */
1367
+ async function preflightAuthCheck(req, res, session, toolName, isToolCall) {
1368
+ if (toolName === "nestr_diagnose")
1369
+ return { blocked: false };
1370
+ const replyWith401 = (errorDescription) => {
1371
+ res.status(401);
1372
+ res.setHeader("WWW-Authenticate", buildWwwAuthenticateHeader(req, { error: "invalid_token", errorDescription }));
1373
+ res.json({
1374
+ jsonrpc: "2.0",
1375
+ error: {
1376
+ code: -32001,
1377
+ message: "Authentication required. Token rejected by Nestr.",
1378
+ data: {
1379
+ flow: session.hasStoredOAuthSession ? "A" : session.isApiKey ? "unknown" : "B",
1380
+ hint: session.hasStoredOAuthSession
1381
+ ? "Server-side refresh failed. Reconnect Nestr to re-authenticate."
1382
+ : session.isApiKey
1383
+ ? "API key was rejected. Regenerate the workspace API key."
1384
+ : "Bearer was rejected by Nestr. Refresh via /oauth/token, or run a fresh OAuth flow if refresh also fails.",
1385
+ correlationId: session.sessionCorrelationId,
1386
+ },
1387
+ },
1388
+ id: req.body?.id ?? null,
1389
+ });
1390
+ };
1391
+ // Always-on: a previous tool call on this session saw upstream 401.
1392
+ // Whatever method is being called now, fail fast so the client re-auths.
1393
+ // This stays unconditional (cheap — just a flag check) so an in-flight 401
1394
+ // on a tool call surfaces as transport 401 on whatever the *next* request
1395
+ // happens to be, even if it's a `tools/list` or `ping`.
1396
+ if (session.authInvalidated) {
1397
+ console.log(`${cidTag()}[Preflight] session previously saw upstream 401 → returning HTTP 401`);
1398
+ session.authInvalidated = false; // one-shot: clear so a refreshed retry can re-verify
1399
+ session.lastAuthVerifiedAt = undefined;
1400
+ replyWith401("Bearer rejected by Nestr on a prior request");
1401
+ return { blocked: true };
1402
+ }
1403
+ // Non-tool calls (`tools/list`, `ping`, `resources/*`, etc.) don't talk to
1404
+ // Nestr, so they don't need an upstream auth check. Skip out before any
1405
+ // Redis read. The next `tools/call` will catch any expired session.
1406
+ if (!isToolCall)
1407
+ return { blocked: false };
1408
+ // Flow A: refresh-or-return without an extra Nestr round-trip.
1409
+ // getOAuthSession refreshes if the access token is near expiry; if the
1410
+ // refresh fails the session is wiped and we surface the 401.
1411
+ if (session.hasStoredOAuthSession) {
1412
+ try {
1413
+ const oauthSession = await getOAuthSession(session.authToken);
1414
+ if (!oauthSession) {
1415
+ replyWith401("Stored OAuth session has expired and could not be refreshed");
1416
+ return { blocked: true };
1417
+ }
1418
+ }
1419
+ catch (e) {
1420
+ console.error(`${cidTag()}[Preflight] Flow A check failed:`, e instanceof Error ? e.message : e);
1421
+ replyWith401("Stored OAuth session could not be revalidated");
1422
+ return { blocked: true };
1423
+ }
1424
+ // Flow A is satisfied without hitting Nestr; no Flow B probe needed.
1425
+ return { blocked: false };
1426
+ }
1427
+ const now = Date.now();
1428
+ if (session.lastAuthVerifiedAt && now - session.lastAuthVerifiedAt < AUTH_VERIFY_TTL_MS) {
1429
+ return { blocked: false };
1430
+ }
1431
+ // Flow B / API key: probe upstream with a cheap call.
1432
+ try {
1433
+ const probe = new NestrClient({
1434
+ apiKey: session.authToken,
1435
+ baseUrl: process.env.NESTR_API_BASE,
1436
+ flow: session.isApiKey ? "unknown" : "B",
1437
+ });
1438
+ if (session.isApiKey) {
1439
+ await probe.listWorkspaces({ limit: 1 });
1440
+ }
1441
+ else {
1442
+ await probe.getCurrentUser();
1443
+ }
1444
+ session.lastAuthVerifiedAt = now;
1445
+ return { blocked: false };
1446
+ }
1447
+ catch (e) {
1448
+ if (e instanceof NestrApiError && e.status === 401) {
1449
+ session.lastUpstream401At = now;
1450
+ replyWith401(e.message);
1451
+ return { blocked: true };
1452
+ }
1453
+ // Probe couldn't reach Nestr or hit a non-auth error. Don't fail closed —
1454
+ // a 5xx from Nestr or a network blip shouldn't take the user offline.
1455
+ console.warn(`${cidTag()}[Preflight] probe failed (non-401), allowing through:`, e instanceof Error ? e.message : e);
1456
+ return { blocked: false };
1457
+ }
1254
1458
  }
1255
1459
  // In-flight request tracking for /mcp so the shutdown handler can wait for
1256
1460
  // outstanding tool calls to finish before the pod terminates.
@@ -1271,6 +1475,10 @@ app.use("/mcp", (_req, res, next) => {
1271
1475
  * MCP POST endpoint - handles JSON-RPC requests
1272
1476
  */
1273
1477
  app.post("/mcp", async (req, res) => {
1478
+ const correlationId = randomUUID();
1479
+ await runWithContext({ correlationId }, () => handleMcpPost(req, res));
1480
+ });
1481
+ async function handleMcpPost(req, res) {
1274
1482
  const sessionId = req.headers["mcp-session-id"];
1275
1483
  const authToken = getAuthToken(req);
1276
1484
  const isApiKey = !!req.headers["x-nestr-api-key"];
@@ -1311,26 +1519,18 @@ app.post("/mcp", async (req, res) => {
1311
1519
  });
1312
1520
  return;
1313
1521
  }
1314
- // For sessions held over from a previous pod, the OAuth token may have
1315
- // expired. Pre-check before invoking the transport so we can return a
1316
- // proper HTTP 401 + WWW-Authenticate that triggers MCP client re-auth,
1317
- // instead of wrapping the failure as a tool error the client ignores.
1318
- if (session.hasStoredOAuthSession) {
1319
- const oauthSession = await getOAuthSession(session.authToken);
1320
- if (!oauthSession) {
1321
- res.status(401);
1322
- res.setHeader("WWW-Authenticate", buildWwwAuthenticateHeader(req));
1323
- res.json({
1324
- jsonrpc: "2.0",
1325
- error: {
1326
- code: -32001,
1327
- message: "OAuth session expired. Reconnect to re-authenticate.",
1328
- },
1329
- id: req.body?.id ?? null,
1330
- });
1331
- return;
1332
- }
1333
- }
1522
+ // Pre-flight upstream auth check. For Flow A this leans on the existing
1523
+ // getOAuthSession refresh-or-return logic; for Flow B (Cowork etc.) it
1524
+ // probes Nestr with /users/me on tool calls. Either way, if the bearer
1525
+ // is dead we respond with HTTP 401 + WWW-Authenticate so the MCP SDK
1526
+ // auto-refreshes — instead of wrapping the failure as a tool error the
1527
+ // client ignores.
1528
+ const toolName = isToolCallRequest(req.body)
1529
+ ? req.body?.params?.name
1530
+ : undefined;
1531
+ const preflight = await preflightAuthCheck(req, res, session, toolName, isToolCallRequest(req.body));
1532
+ if (preflight.blocked)
1533
+ return;
1334
1534
  session.lastActivityAt = Date.now();
1335
1535
  await maybeTouchMcpSession(sessionId, session);
1336
1536
  await session.transport.handleRequest(req, res, req.body);
@@ -1382,8 +1582,21 @@ app.post("/mcp", async (req, res) => {
1382
1582
  });
1383
1583
  return;
1384
1584
  }
1385
- // Extract MCP client info early (needed for coalescing check)
1585
+ // Extract MCP client info early (needed for coalescing check). The MCP
1586
+ // initialize handshake provides a structured `clientInfo: { name, version }`;
1587
+ // we capture both, surface them on the session, and forward to OAuth so the
1588
+ // upstream OAuth server can record the version on the issued token row
1589
+ // (slashme-online PR #1392).
1590
+ //
1591
+ // Some clients additionally emit a stable `software_id` extension field
1592
+ // (snake_case per the slashme `client_software_id` convention; we also
1593
+ // accept camelCase `softwareId` for forward compat). Capturing it lets the
1594
+ // runtime tagging call fire even when a client emits software_id without
1595
+ // a version.
1386
1596
  const mcpClientName = req.body?.params?.clientInfo?.name;
1597
+ const mcpClientVersion = req.body?.params?.clientInfo?.version;
1598
+ const mcpClientSoftwareId = (req.body?.params?.clientInfo?.software_id
1599
+ ?? req.body?.params?.clientInfo?.softwareId);
1387
1600
  if (!isInitializeRequest(req.body)) {
1388
1601
  res.status(400).json({
1389
1602
  jsonrpc: "2.0",
@@ -1395,28 +1608,44 @@ app.post("/mcp", async (req, res) => {
1395
1608
  });
1396
1609
  return;
1397
1610
  }
1398
- // Drop any lingering session for the same (auth token, client) before creating a
1399
- // new one. This happens when a client reconnects after an SSE drop: the old
1400
- // session is still in memory but its transport is already initialized, so we
1401
- // can't route a fresh `initialize` through it — the SDK would 400 with
1402
- // "Server already initialized". Creating a second session alongside the stale
1403
- // one also leaks memory over time. Close-and-replace is the only safe option.
1404
- const stale = findCoalescableSession(authToken, mcpClientName);
1405
- if (stale) {
1406
- const { sessionId: staleSid, session: staleSession } = stale;
1407
- console.log(`Replacing stale session ${staleSid} for ${mcpClientName || "unknown client"} on re-init`);
1408
- try {
1409
- await staleSession.transport.close();
1410
- }
1411
- catch (e) {
1412
- console.error("[Session] Failed to close stale transport:", e instanceof Error ? e.message : e);
1413
- }
1414
- delete sessions[staleSid];
1415
- await getStore().removeMcpSession(staleSid).catch(e => console.error("[McpSession] Failed to remove persisted stale session:", e instanceof Error ? e.message : e));
1416
- }
1611
+ // Concurrent sessions per (auth token, client name) are intentional.
1612
+ // Parallel clients routinely share one identity (agent jobs, Claude Code
1613
+ // subagents, Codex workers), so an initialize must NEVER touch another
1614
+ // connection's session doing so caused the 2026-07-10 incident where
1615
+ // sessions were killed ~1s after creation. A fresh initialize carries no
1616
+ // Mcp-Session-Id, so it can't collide with an existing transport
1617
+ // ("Server already initialized" is impossible on this path). Stale
1618
+ // sessions are reaped non-destructively by sweepStaleSessions instead.
1417
1619
  if (mcpClientName) {
1418
1620
  console.log(`MCP client: ${mcpClientName}`);
1419
1621
  }
1622
+ // Tag the upstream OAuth token row with the MCP `clientInfo` we just
1623
+ // captured. The OAuth grant happened BEFORE this initialize handshake,
1624
+ // so the token was issued without `client_version` / `client_software_id`
1625
+ // even though slashme-online accepts those fields. Calling
1626
+ // /oauth/tokens/client-info here closes the loop so the API Keys UI
1627
+ // shows e.g. "Claude Code 2.1.15" right after first connect, without
1628
+ // waiting for the next refresh-token cycle.
1629
+ //
1630
+ // Skip for API keys (no OAuth row exists). Fire when EITHER version or
1631
+ // software_id is present — the slashme endpoint accepts either alone.
1632
+ // For software_id, prefer the explicit `clientInfo.software_id` extension
1633
+ // when present and fall back to `name` so existing clients (which only
1634
+ // emit name+version) continue to populate clientSoftwareId on the row.
1635
+ // Fire-and-forget — failures are logged but never block the MCP session.
1636
+ if (!isApiKey && (mcpClientVersion || mcpClientSoftwareId)) {
1637
+ const oauthBase = deriveOAuthBaseUrl(process.env.NESTR_API_BASE);
1638
+ void tagOAuthClientInfo({
1639
+ bearerToken: authToken,
1640
+ baseUrl: oauthBase,
1641
+ clientVersion: mcpClientVersion,
1642
+ clientSoftwareId: mcpClientSoftwareId || mcpClientName,
1643
+ }).then((result) => {
1644
+ if (!result.ok) {
1645
+ console.warn(`[ClientInfo] tag failed: status=${result.status ?? "n/a"} error=${result.error ?? "unknown"}`);
1646
+ }
1647
+ });
1648
+ }
1420
1649
  let userId;
1421
1650
  let userName;
1422
1651
  // Check cross-session identity cache first (survives cursor-vscode reconnections)
@@ -1507,6 +1736,7 @@ app.post("/mcp", async (req, res) => {
1507
1736
  authToken,
1508
1737
  isApiKey,
1509
1738
  mcpClient: mcpClientName,
1739
+ mcpClientVersion,
1510
1740
  userId,
1511
1741
  userName,
1512
1742
  wantsJsonOnly,
@@ -1517,7 +1747,7 @@ app.post("/mcp", async (req, res) => {
1517
1747
  await session.transport.handleRequest(req, res, req.body);
1518
1748
  }
1519
1749
  catch (error) {
1520
- console.error("Error handling MCP POST request:", error);
1750
+ console.error(`${cidTag()}Error handling MCP POST request:`, error);
1521
1751
  if (!res.headersSent) {
1522
1752
  res.status(500).json({
1523
1753
  jsonrpc: "2.0",
@@ -1529,6 +1759,219 @@ app.post("/mcp", async (req, res) => {
1529
1759
  });
1530
1760
  }
1531
1761
  }
1762
+ }
1763
+ // ─── PUBLIC (unauthenticated) MCP surface ──────────────────────────────────
1764
+ //
1765
+ // POST /mcp/public is a credential-free "guest" surface for support-only AI
1766
+ // agents that have no workspace AI credit. It exposes ONLY the three help tools
1767
+ // (nestr_help, nestr_diagnose, nestr_get_me) and can NEVER reach authenticated
1768
+ // Nestr data:
1769
+ // - Any Authorization / X-Nestr-API-Key / Cookie header is stripped on entry
1770
+ // and ignored — there is no code path here that reads a credential.
1771
+ // - Sessions are built with isPublic:true, which (a) filters tools/list to the
1772
+ // three help tools and (b) makes handleToolCall refuse every other tool and
1773
+ // serve nestr_get_me from a fixed guest payload with no Nestr API call.
1774
+ // - The NestrClient is constructed with a sentinel token it never uses, since
1775
+ // none of the three public tools issue an authenticated request.
1776
+ // - Guest sessions live in their own `publicSessions` map, isolated from the
1777
+ // authenticated `sessions` map (no coalescing, no Redis rehydration).
1778
+ const PUBLIC_SENTINEL_TOKEN = "public-guest-no-auth";
1779
+ app.post("/mcp/public", async (req, res) => {
1780
+ const correlationId = randomUUID();
1781
+ await runWithContext({ correlationId }, () => handlePublicMcpPost(req, res));
1782
+ });
1783
+ async function handlePublicMcpPost(req, res) {
1784
+ const sessionId = req.headers["mcp-session-id"];
1785
+ // Hard requirement: never read or honour credentials on the public surface.
1786
+ // Strip them before anything else so they can't leak into the MCP SDK's
1787
+ // requestInfo / MCPCat or be picked up by any downstream code.
1788
+ delete req.headers.authorization;
1789
+ delete req.headers["x-nestr-api-key"];
1790
+ delete req.headers.cookie;
1791
+ try {
1792
+ const acceptHeader = req.headers.accept || "";
1793
+ const wantsJsonOnly = acceptHeader.includes("application/json") && !acceptHeader.includes("text/event-stream");
1794
+ if (wantsJsonOnly) {
1795
+ req.headers.accept = `${acceptHeader}, text/event-stream`;
1796
+ }
1797
+ // Existing public session — route straight through. No token checks: public
1798
+ // sessions have no credential to compare.
1799
+ if (sessionId) {
1800
+ const session = publicSessions[sessionId];
1801
+ if (session) {
1802
+ session.lastActivityAt = Date.now();
1803
+ await session.transport.handleRequest(req, res, req.body);
1804
+ return;
1805
+ }
1806
+ res.status(404).json({
1807
+ jsonrpc: "2.0",
1808
+ error: { code: -32001, message: "Session not found" },
1809
+ id: req.body?.id ?? null,
1810
+ });
1811
+ return;
1812
+ }
1813
+ if (shuttingDown) {
1814
+ res.status(503).json({
1815
+ jsonrpc: "2.0",
1816
+ error: { code: -32000, message: "Server is shutting down, please retry" },
1817
+ id: req.body?.id ?? null,
1818
+ });
1819
+ return;
1820
+ }
1821
+ // Unauthenticated tools/list (no session yet) — return the public tool list
1822
+ // directly, mirroring the authed surface's scanner-friendly shortcut.
1823
+ if (req.body?.method === "tools/list") {
1824
+ res.json({
1825
+ jsonrpc: "2.0",
1826
+ result: { tools: toolDefinitions.filter((t) => PUBLIC_TOOL_NAMES.has(t.name)) },
1827
+ id: req.body?.id ?? null,
1828
+ });
1829
+ return;
1830
+ }
1831
+ // New session must be an initialize request (same contract as authed /mcp).
1832
+ if (!isInitializeRequest(req.body)) {
1833
+ res.status(400).json({
1834
+ jsonrpc: "2.0",
1835
+ error: {
1836
+ code: -32000,
1837
+ message: "Bad Request: No valid session ID provided, and request is not an initialization request",
1838
+ },
1839
+ id: req.body?.id ?? null,
1840
+ });
1841
+ return;
1842
+ }
1843
+ const mcpClientName = req.body?.params?.clientInfo?.name;
1844
+ const mcpClientVersion = req.body?.params?.clientInfo?.version;
1845
+ const session = buildMcpSession({
1846
+ authToken: PUBLIC_SENTINEL_TOKEN,
1847
+ isApiKey: false,
1848
+ isPublic: true,
1849
+ mcpClient: mcpClientName,
1850
+ mcpClientVersion,
1851
+ wantsJsonOnly,
1852
+ hasStoredOAuthSession: false,
1853
+ });
1854
+ // buildMcpSession's onsessioninitialized registers fresh sessions in the
1855
+ // authenticated `sessions` map. Re-home this guest session into the isolated
1856
+ // publicSessions map once its id is known, so it never lives alongside authed
1857
+ // sessions. The onclose handler also targets `sessions`, so we override it to
1858
+ // clean up publicSessions instead.
1859
+ const transport = session.transport;
1860
+ await session.server.connect(transport);
1861
+ await transport.handleRequest(req, res, req.body);
1862
+ const sid = transport.sessionId;
1863
+ if (sid) {
1864
+ const created = sessions[sid] ?? session;
1865
+ delete sessions[sid];
1866
+ publicSessions[sid] = created;
1867
+ transport.onclose = () => {
1868
+ if (created.sseKeepaliveTimer) {
1869
+ clearInterval(created.sseKeepaliveTimer);
1870
+ created.sseKeepaliveTimer = undefined;
1871
+ }
1872
+ delete publicSessions[sid];
1873
+ };
1874
+ }
1875
+ }
1876
+ catch (error) {
1877
+ console.error(`${cidTag()}Error handling public MCP POST request:`, error);
1878
+ if (!res.headersSent) {
1879
+ res.status(500).json({
1880
+ jsonrpc: "2.0",
1881
+ error: {
1882
+ code: -32603,
1883
+ message: error instanceof Error ? error.message : "Internal server error",
1884
+ },
1885
+ id: req.body?.id ?? null,
1886
+ });
1887
+ }
1888
+ }
1889
+ }
1890
+ // Public SSE stream (server-initiated messages for a guest session).
1891
+ app.get("/mcp/public", async (req, res) => {
1892
+ delete req.headers.authorization;
1893
+ delete req.headers["x-nestr-api-key"];
1894
+ delete req.headers.cookie;
1895
+ const sessionId = req.headers["mcp-session-id"];
1896
+ const session = sessionId ? publicSessions[sessionId] : undefined;
1897
+ if (!session) {
1898
+ res.status(404).json({
1899
+ jsonrpc: "2.0",
1900
+ error: { code: -32001, message: "Session not found" },
1901
+ id: null,
1902
+ });
1903
+ return;
1904
+ }
1905
+ session.sseResponse = res;
1906
+ if (session.sseKeepaliveTimer)
1907
+ clearInterval(session.sseKeepaliveTimer);
1908
+ session.sseKeepaliveTimer = setInterval(() => {
1909
+ if (res.writableEnded || res.destroyed)
1910
+ return;
1911
+ try {
1912
+ res.write(": keepalive\n\n");
1913
+ }
1914
+ catch (e) {
1915
+ console.error("[PublicSession] SSE keepalive write failed:", e instanceof Error ? e.message : e);
1916
+ }
1917
+ }, SSE_KEEPALIVE_INTERVAL_MS);
1918
+ session.sseKeepaliveTimer.unref?.();
1919
+ res.on("close", () => {
1920
+ if (session.sseKeepaliveTimer) {
1921
+ clearInterval(session.sseKeepaliveTimer);
1922
+ session.sseKeepaliveTimer = undefined;
1923
+ }
1924
+ if (session.sseResponse === res)
1925
+ session.sseResponse = undefined;
1926
+ try {
1927
+ session.transport.closeStandaloneSSEStream();
1928
+ }
1929
+ catch (e) {
1930
+ console.error("[PublicSession] closeStandaloneSSEStream on socket close failed:", e instanceof Error ? e.message : e);
1931
+ }
1932
+ });
1933
+ try {
1934
+ await session.transport.handleRequest(req, res);
1935
+ }
1936
+ catch (error) {
1937
+ console.error("Error handling public MCP GET request:", error);
1938
+ if (!res.headersSent) {
1939
+ res.status(500).json({
1940
+ jsonrpc: "2.0",
1941
+ error: { code: -32603, message: "Internal server error" },
1942
+ id: null,
1943
+ });
1944
+ }
1945
+ }
1946
+ });
1947
+ // Public session termination.
1948
+ app.delete("/mcp/public", async (req, res) => {
1949
+ delete req.headers.authorization;
1950
+ delete req.headers["x-nestr-api-key"];
1951
+ delete req.headers.cookie;
1952
+ const sessionId = req.headers["mcp-session-id"];
1953
+ const session = sessionId ? publicSessions[sessionId] : undefined;
1954
+ if (!session) {
1955
+ res.status(404).json({
1956
+ jsonrpc: "2.0",
1957
+ error: { code: -32001, message: "Session not found" },
1958
+ id: null,
1959
+ });
1960
+ return;
1961
+ }
1962
+ try {
1963
+ await session.transport.handleRequest(req, res);
1964
+ }
1965
+ catch (error) {
1966
+ console.error("Error handling public MCP DELETE request:", error);
1967
+ if (!res.headersSent) {
1968
+ res.status(500).json({
1969
+ jsonrpc: "2.0",
1970
+ error: { code: -32603, message: "Internal server error" },
1971
+ id: null,
1972
+ });
1973
+ }
1974
+ }
1532
1975
  });
1533
1976
  /**
1534
1977
  * MCP GET endpoint - handles SSE streams for server-initiated messages
@@ -1571,7 +2014,8 @@ app.get("/mcp", async (req, res) => {
1571
2014
  return;
1572
2015
  }
1573
2016
  console.log(`SSE stream requested for session: ${sessionId}`);
1574
- // Track the SSE response for liveness detection (used by session coalescing)
2017
+ session.lastActivityAt = Date.now();
2018
+ // Track the SSE response for liveness detection (used by the stale-session sweep)
1575
2019
  session.sseResponse = res;
1576
2020
  // Heartbeat: SSE comment lines (`:keepalive`) keep the connection alive
1577
2021
  // through proxies/LBs that would otherwise drop idle TCP after ~60s. The