@nestr/mcp 0.1.73 → 0.1.90

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 (45) hide show
  1. package/build/api/client.d.ts +39 -3
  2. package/build/api/client.d.ts.map +1 -1
  3. package/build/api/client.js +57 -5
  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 +315 -15
  15. package/build/help/topics.js.map +1 -1
  16. package/build/http.d.ts +4 -13
  17. package/build/http.d.ts.map +1 -1
  18. package/build/http.js +341 -89
  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/server.d.ts +7 -0
  29. package/build/server.d.ts.map +1 -1
  30. package/build/server.js +23 -4
  31. package/build/server.js.map +1 -1
  32. package/build/skills/tension-processing.d.ts.map +1 -1
  33. package/build/skills/tension-processing.js +11 -1
  34. package/build/skills/tension-processing.js.map +1 -1
  35. package/build/tools/index.d.ts +612 -68
  36. package/build/tools/index.d.ts.map +1 -1
  37. package/build/tools/index.js +592 -63
  38. package/build/tools/index.js.map +1 -1
  39. package/build/tools/validation.d.ts +42 -0
  40. package/build/tools/validation.d.ts.map +1 -0
  41. package/build/tools/validation.js +97 -0
  42. package/build/tools/validation.js.map +1 -0
  43. package/package.json +2 -1
  44. package/web/index.html +25 -0
  45. 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";
35
+ import { toolDefinitions, PUBLIC_TOOL_NAMES } from "./tools/index.js";
36
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";
@@ -211,16 +212,29 @@ app.post("/oauth/register", registerLimiter, express.json(), async (req, res) =>
211
212
  });
212
213
  return;
213
214
  }
214
- // Validate required fields
215
- 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)) {
216
222
  res.status(400).json({
217
223
  error: "invalid_client_metadata",
218
- 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",
219
233
  });
220
234
  return;
221
235
  }
222
236
  // Validate redirect URIs (must be localhost or HTTPS)
223
- for (const uri of redirect_uris) {
237
+ for (const uri of redirectUris) {
224
238
  try {
225
239
  const parsed = new URL(uri);
226
240
  const isLocalhost = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1";
@@ -241,18 +255,20 @@ app.post("/oauth/register", registerLimiter, express.json(), async (req, res) =>
241
255
  return;
242
256
  }
243
257
  }
244
- // 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.
245
260
  const clientId = `mcp-${randomUUID()}`;
246
- 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");
247
263
  // Create registered client
248
264
  const client = {
249
265
  client_id: clientId,
250
- client_secret: clientSecret,
266
+ ...(clientSecret ? { client_secret: clientSecret } : {}),
251
267
  client_name: client_name || "MCP Client",
252
- redirect_uris,
253
- grant_types: grant_types || ["authorization_code", "refresh_token"],
268
+ redirect_uris: redirectUris,
269
+ grant_types: requestedGrantTypes,
254
270
  response_types: response_types || ["code"],
255
- token_endpoint_auth_method: token_endpoint_auth_method || "client_secret_post",
271
+ token_endpoint_auth_method: tokenEndpointAuthMethod,
256
272
  scope: scope || "user nest",
257
273
  registered_at: Date.now(),
258
274
  };
@@ -261,7 +277,7 @@ app.post("/oauth/register", registerLimiter, express.json(), async (req, res) =>
261
277
  // Return registration response (RFC 7591)
262
278
  res.status(201).json({
263
279
  client_id: clientId,
264
- client_secret: clientSecret,
280
+ ...(clientSecret ? { client_secret: clientSecret } : {}),
265
281
  client_name: client.client_name,
266
282
  redirect_uris: client.redirect_uris,
267
283
  grant_types: client.grant_types,
@@ -876,21 +892,19 @@ app.post("/oauth/token", tokenLimiter, express.urlencoded({ extended: true }), a
876
892
  // revoked token slip past for too long.
877
893
  const AUTH_VERIFY_TTL_MS = 60 * 1000;
878
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 = {};
879
900
  let shuttingDown = false;
880
901
  let inFlightRequests = 0;
881
- /**
882
- * Find a prior session that a re-initializing client is likely trying to replace.
883
- *
884
- * Matches on (authToken, mcpClient) within a 10-minute window. The POST /mcp
885
- * init path closes and drops the match so the client gets a fresh, clean
886
- * session this prevents "Server already initialized" 400s when a client
887
- * reconnects after an SSE drop (the transport can only be initialized once).
888
- */
889
- export const SESSION_COALESCE_WINDOW_MS = 10 * 60 * 1000; // 10 minutes
890
- // Bumped from 30 → 60min: AI assistants commonly gap 30+ min between Nestr
891
- // tool bursts (long codebase work between status updates). Keeping the in-memory
892
- // session avoids a rehydration round-trip on the next call.
893
- 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
894
908
  // Debounce for touchMcpSession. We refresh the Redis TTL at most this often
895
909
  // so a chatty client doesn't hammer the store.
896
910
  const MCP_SESSION_TOUCH_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
@@ -898,23 +912,34 @@ const MCP_SESSION_TOUCH_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
898
912
  // AWS ALB, nginx) typically idle-timeout TCP at 60s. Sending a comment line
899
913
  // every 25s keeps the connection alive — the SDK doesn't ping on its own.
900
914
  const SSE_KEEPALIVE_INTERVAL_MS = 25 * 1000; // 25 seconds
901
- // Periodically clean up dead sessions (closed SSE + stale).
902
- // We only drop the in-memory entry — the persistent Redis record lives on until
903
- // its own TTL so a late-returning client can still rehydrate.
904
- // .unref() so this timer doesn't prevent process exit (tests, graceful shutdown)
905
- setInterval(() => {
906
- const now = Date.now();
907
- 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)) {
908
923
  const sseAlive = session.sseResponse && !session.sseResponse.writableEnded;
909
- const stale = (now - session.lastActivityAt) > SESSION_STALE_TIMEOUT_MS;
910
- if (!sseAlive && stale) {
911
- if (session.sseKeepaliveTimer) {
912
- clearInterval(session.sseKeepaliveTimer);
913
- session.sseKeepaliveTimer = undefined;
914
- }
915
- 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;
916
931
  }
932
+ delete map[sid];
933
+ evicted++;
917
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);
918
943
  }, 60000).unref();
919
944
  /**
920
945
  * Refresh the Redis TTL of a persisted MCP session, debounced per-session so
@@ -964,24 +989,6 @@ async function dropSessionOnTokenSwap(sessionId, session, authToken) {
964
989
  await getStore().removeMcpSession(sessionId).catch(() => { });
965
990
  return true;
966
991
  }
967
- export function findCoalescableSession(authToken, mcpClient) {
968
- const now = Date.now();
969
- let bestMatch;
970
- for (const [sid, session] of Object.entries(sessions)) {
971
- if (session.authToken === authToken &&
972
- session.mcpClient === mcpClient &&
973
- (now - session.lastActivityAt) < SESSION_COALESCE_WINDOW_MS) {
974
- const sseAlive = !!(session.sseResponse && !session.sseResponse.writableEnded);
975
- // Pick the best session: prefer live SSE, then most recently active
976
- if (!bestMatch ||
977
- (sseAlive && !bestMatch.sseAlive) || // prefer live SSE over dead
978
- (sseAlive === bestMatch.sseAlive && session.lastActivityAt > bestMatch.lastActivity)) {
979
- bestMatch = { sessionId: sid, session, lastActivity: session.lastActivityAt, sseAlive };
980
- }
981
- }
982
- }
983
- return bestMatch ? { sessionId: bestMatch.sessionId, session: bestMatch.session } : undefined;
984
- }
985
992
  /**
986
993
  * Cache resolved identities by auth token to avoid repeated /users/me calls.
987
994
  * Cursor-vscode reconnects every ~60s, and workspace API keys sent as Bearer tokens
@@ -1058,6 +1065,7 @@ function buildMcpSession(opts) {
1058
1065
  });
1059
1066
  const server = createServer({
1060
1067
  client,
1068
+ isPublic: opts.isPublic,
1061
1069
  userId: opts.userId,
1062
1070
  userName: opts.userName,
1063
1071
  onToolCall: (toolName, args, success, error) => {
@@ -1106,6 +1114,7 @@ function buildMcpSession(opts) {
1106
1114
  mcpClient: opts.mcpClient,
1107
1115
  mcpClientVersion: opts.mcpClientVersion,
1108
1116
  isApiKey: opts.isApiKey,
1117
+ isPublic: opts.isPublic,
1109
1118
  wantsJsonOnly: opts.wantsJsonOnly,
1110
1119
  hasStoredOAuthSession: opts.hasStoredOAuthSession,
1111
1120
  userId: opts.userId,
@@ -1119,18 +1128,21 @@ function buildMcpSession(opts) {
1119
1128
  };
1120
1129
  sessions[newSessionId] = sessionData;
1121
1130
  sessionRef = sessionData;
1122
- // Persist for rehydration after restart
1123
- getStore().storeMcpSession(newSessionId, {
1124
- authToken: opts.authToken,
1125
- mcpClient: opts.mcpClient,
1126
- mcpClientVersion: opts.mcpClientVersion,
1127
- userId: opts.userId,
1128
- userName: opts.userName,
1129
- isApiKey: opts.isApiKey,
1130
- wantsJsonOnly: opts.wantsJsonOnly,
1131
- hasStoredOAuthSession: opts.hasStoredOAuthSession,
1132
- createdAt: Date.now(),
1133
- }).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));
1134
1146
  if (opts.analyticsCtx) {
1135
1147
  try {
1136
1148
  analytics.trackSessionStart(opts.analyticsCtx, {
@@ -1188,6 +1200,7 @@ function buildMcpSession(opts) {
1188
1200
  mcpClient: opts.mcpClient,
1189
1201
  mcpClientVersion: opts.mcpClientVersion,
1190
1202
  isApiKey: opts.isApiKey,
1203
+ isPublic: opts.isPublic,
1191
1204
  wantsJsonOnly: opts.wantsJsonOnly,
1192
1205
  hasStoredOAuthSession: opts.hasStoredOAuthSession,
1193
1206
  userId: opts.userId,
@@ -1213,6 +1226,7 @@ function buildMcpSession(opts) {
1213
1226
  mcpClient: opts.mcpClient,
1214
1227
  mcpClientVersion: opts.mcpClientVersion,
1215
1228
  isApiKey: opts.isApiKey,
1229
+ isPublic: opts.isPublic,
1216
1230
  wantsJsonOnly: opts.wantsJsonOnly,
1217
1231
  hasStoredOAuthSession: opts.hasStoredOAuthSession,
1218
1232
  userId: opts.userId,
@@ -1573,8 +1587,16 @@ async function handleMcpPost(req, res) {
1573
1587
  // we capture both, surface them on the session, and forward to OAuth so the
1574
1588
  // upstream OAuth server can record the version on the issued token row
1575
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.
1576
1596
  const mcpClientName = req.body?.params?.clientInfo?.name;
1577
1597
  const mcpClientVersion = req.body?.params?.clientInfo?.version;
1598
+ const mcpClientSoftwareId = (req.body?.params?.clientInfo?.software_id
1599
+ ?? req.body?.params?.clientInfo?.softwareId);
1578
1600
  if (!isInitializeRequest(req.body)) {
1579
1601
  res.status(400).json({
1580
1602
  jsonrpc: "2.0",
@@ -1586,28 +1608,44 @@ async function handleMcpPost(req, res) {
1586
1608
  });
1587
1609
  return;
1588
1610
  }
1589
- // Drop any lingering session for the same (auth token, client) before creating a
1590
- // new one. This happens when a client reconnects after an SSE drop: the old
1591
- // session is still in memory but its transport is already initialized, so we
1592
- // can't route a fresh `initialize` through it — the SDK would 400 with
1593
- // "Server already initialized". Creating a second session alongside the stale
1594
- // one also leaks memory over time. Close-and-replace is the only safe option.
1595
- const stale = findCoalescableSession(authToken, mcpClientName);
1596
- if (stale) {
1597
- const { sessionId: staleSid, session: staleSession } = stale;
1598
- console.log(`Replacing stale session ${staleSid} for ${mcpClientName || "unknown client"} on re-init`);
1599
- try {
1600
- await staleSession.transport.close();
1601
- }
1602
- catch (e) {
1603
- console.error("[Session] Failed to close stale transport:", e instanceof Error ? e.message : e);
1604
- }
1605
- delete sessions[staleSid];
1606
- await getStore().removeMcpSession(staleSid).catch(e => console.error("[McpSession] Failed to remove persisted stale session:", e instanceof Error ? e.message : e));
1607
- }
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.
1608
1619
  if (mcpClientName) {
1609
1620
  console.log(`MCP client: ${mcpClientName}`);
1610
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
+ }
1611
1649
  let userId;
1612
1650
  let userName;
1613
1651
  // Check cross-session identity cache first (survives cursor-vscode reconnections)
@@ -1722,6 +1760,219 @@ async function handleMcpPost(req, res) {
1722
1760
  }
1723
1761
  }
1724
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
+ }
1975
+ });
1725
1976
  /**
1726
1977
  * MCP GET endpoint - handles SSE streams for server-initiated messages
1727
1978
  */
@@ -1763,7 +2014,8 @@ app.get("/mcp", async (req, res) => {
1763
2014
  return;
1764
2015
  }
1765
2016
  console.log(`SSE stream requested for session: ${sessionId}`);
1766
- // 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)
1767
2019
  session.sseResponse = res;
1768
2020
  // Heartbeat: SSE comment lines (`:keepalive`) keep the connection alive
1769
2021
  // through proxies/LBs that would otherwise drop idle TCP after ~60s. The