@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.
- package/build/api/client.d.ts +84 -5
- package/build/api/client.d.ts.map +1 -1
- package/build/api/client.js +120 -21
- 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 +324 -12
- package/build/help/topics.js.map +1 -1
- package/build/http.d.ts +26 -13
- package/build/http.d.ts.map +1 -1
- package/build/http.js +572 -128
- 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/oauth/flow.d.ts +6 -0
- package/build/oauth/flow.d.ts.map +1 -1
- package/build/oauth/flow.js +30 -4
- package/build/oauth/flow.js.map +1 -1
- package/build/oauth/store.d.ts +14 -0
- package/build/oauth/store.d.ts.map +1 -1
- package/build/oauth/store.js.map +1 -1
- package/build/server.d.ts +11 -0
- package/build/server.d.ts.map +1 -1
- package/build/server.js +38 -5
- 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 +580 -90
- package/build/tools/index.d.ts.map +1 -1
- package/build/tools/index.js +598 -82
- 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/build/util/diagnose.d.ts +40 -0
- package/build/util/diagnose.d.ts.map +1 -0
- package/build/util/diagnose.js +26 -0
- package/build/util/diagnose.js.map +1 -0
- package/build/util/request-context.d.ts +11 -0
- package/build/util/request-context.d.ts.map +1 -0
- package/build/util/request-context.js +30 -0
- package/build/util/request-context.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";
|
|
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
|
-
//
|
|
213
|
-
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
268
|
+
redirect_uris: redirectUris,
|
|
269
|
+
grant_types: requestedGrantTypes,
|
|
252
270
|
response_types: response_types || ["code"],
|
|
253
|
-
token_endpoint_auth_method:
|
|
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
|
-
|
|
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
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
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
|
|
878
|
-
//
|
|
879
|
-
//
|
|
880
|
-
// .
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
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
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
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
|
-
|
|
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
|
|
1008
|
-
//
|
|
1009
|
-
//
|
|
1010
|
-
//
|
|
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(
|
|
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: "
|
|
1015
|
-
|
|
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(
|
|
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
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
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(
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
//
|
|
1315
|
-
//
|
|
1316
|
-
//
|
|
1317
|
-
//
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
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
|
-
//
|
|
1399
|
-
//
|
|
1400
|
-
//
|
|
1401
|
-
//
|
|
1402
|
-
//
|
|
1403
|
-
//
|
|
1404
|
-
|
|
1405
|
-
|
|
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(
|
|
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
|
-
|
|
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
|