@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.
- package/build/api/client.d.ts +39 -3
- package/build/api/client.d.ts.map +1 -1
- package/build/api/client.js +57 -5
- package/build/api/client.js.map +1 -1
- package/build/help/articles.d.ts +146 -0
- package/build/help/articles.d.ts.map +1 -0
- package/build/help/articles.js +574 -0
- package/build/help/articles.js.map +1 -0
- package/build/help/cross-links.d.ts +21 -0
- package/build/help/cross-links.d.ts.map +1 -0
- package/build/help/cross-links.js +61 -0
- package/build/help/cross-links.js.map +1 -0
- package/build/help/topics.d.ts.map +1 -1
- package/build/help/topics.js +315 -15
- package/build/help/topics.js.map +1 -1
- package/build/http.d.ts +4 -13
- package/build/http.d.ts.map +1 -1
- package/build/http.js +341 -89
- package/build/http.js.map +1 -1
- package/build/oauth/client-info.d.ts +58 -0
- package/build/oauth/client-info.d.ts.map +1 -0
- package/build/oauth/client-info.js +68 -0
- package/build/oauth/client-info.js.map +1 -0
- package/build/oauth/config.d.ts +19 -0
- package/build/oauth/config.d.ts.map +1 -1
- package/build/oauth/config.js +12 -0
- package/build/oauth/config.js.map +1 -1
- package/build/server.d.ts +7 -0
- package/build/server.d.ts.map +1 -1
- package/build/server.js +23 -4
- package/build/server.js.map +1 -1
- package/build/skills/tension-processing.d.ts.map +1 -1
- package/build/skills/tension-processing.js +11 -1
- package/build/skills/tension-processing.js.map +1 -1
- package/build/tools/index.d.ts +612 -68
- package/build/tools/index.d.ts.map +1 -1
- package/build/tools/index.js +592 -63
- package/build/tools/index.js.map +1 -1
- package/build/tools/validation.d.ts +42 -0
- package/build/tools/validation.d.ts.map +1 -0
- package/build/tools/validation.js +97 -0
- package/build/tools/validation.js.map +1 -0
- package/package.json +2 -1
- package/web/index.html +25 -0
- 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
|
-
//
|
|
215
|
-
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
268
|
+
redirect_uris: redirectUris,
|
|
269
|
+
grant_types: requestedGrantTypes,
|
|
254
270
|
response_types: response_types || ["code"],
|
|
255
|
-
token_endpoint_auth_method:
|
|
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
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
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
|
|
902
|
-
//
|
|
903
|
-
//
|
|
904
|
-
// .
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
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
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
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
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
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
|
-
//
|
|
1590
|
-
//
|
|
1591
|
-
//
|
|
1592
|
-
//
|
|
1593
|
-
//
|
|
1594
|
-
//
|
|
1595
|
-
|
|
1596
|
-
|
|
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
|
-
|
|
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
|